fix: post-merge fixes for upstream v0.1.112 integration

- Fix AccountUsageCell loadUsage() parameter type mismatch (string→object)
- Add missing i18n keys for openaiMessages family mapping (en/zh, 17 keys)
- Remove duplicate payment provider declarations in wire.go
- Add sort_order field to PlanEditDialog.vue
- Remove 10 duplicate upstream-renumbered migration files
- Fix NewUserService call signatures in test files (missing 4th param)
- Update api_contract_test.go snapshots for new balance_notify fields
This commit is contained in:
erio
2026-04-14 02:57:24 +08:00
parent 54e55e8da0
commit 33a0ad767c
18 changed files with 57 additions and 275 deletions
-7
View File
@@ -43,13 +43,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Server layer ProviderSet
server.ProviderSet,
// Payment providers
payment.ProvideRegistry,
payment.ProvideEncryptionKey,
payment.ProvideDefaultLoadBalancer,
service.ProvidePaymentConfigService,
service.ProvidePaymentOrderExpiryService,
// Privacy client factory for OpenAI training opt-out
providePrivacyClientFactory,
+12 -2
View File
@@ -53,8 +53,13 @@ func TestAPIContracts(t *testing.T) {
"username": "alice",
"role": "user",
"balance": 12.5,
"balance_notify_enabled": false,
"balance_notify_extra_emails": null,
"balance_notify_threshold": null,
"balance_notify_threshold_type": "",
"concurrency": 5,
"status": "active",
"total_recharged": 0,
"allowed_groups": null,
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z",
@@ -606,7 +611,12 @@ func TestAPIContracts(t *testing.T) {
"payment_cancel_rate_limit_max": 0,
"payment_cancel_rate_limit_window": 0,
"payment_cancel_rate_limit_unit": "",
"payment_cancel_rate_limit_window_mode": ""
"payment_cancel_rate_limit_window_mode": "",
"balance_low_notify_enabled": false,
"balance_low_notify_threshold": 0,
"balance_low_notify_recharge_url": "",
"account_quota_notify_enabled": false,
"account_quota_notify_emails": []
}
}`,
},
@@ -699,7 +709,7 @@ func newContractDeps(t *testing.T) *contractDeps {
RunMode: config.RunModeStandard,
}
userService := service.NewUserService(userRepo, nil, nil)
userService := service.NewUserService(userRepo, nil, nil, nil)
apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, nil, apiKeyCache, cfg)
usageRepo := newStubUsageLogRepo()
@@ -39,7 +39,7 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
return &clone, nil
},
}
userService := service.NewUserService(userRepo, nil, nil)
userService := service.NewUserService(userRepo, nil, nil, nil)
router := gin.New()
router.Use(gin.HandlerFunc(NewAdminAuthMiddleware(authService, userService, nil)))
@@ -41,7 +41,7 @@ func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthSer
userRepo := &stubJWTUserRepo{users: users}
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil)
userSvc := service.NewUserService(userRepo, nil, nil)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
mw := NewJWTAuthMiddleware(authSvc, userSvc)
r := gin.New()
-47
View File
@@ -1,47 +0,0 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
user_email VARCHAR(255) NOT NULL DEFAULT '',
user_name VARCHAR(100) NOT NULL DEFAULT '',
user_notes TEXT,
amount DECIMAL(20,2) NOT NULL,
pay_amount DECIMAL(20,2) NOT NULL,
fee_rate DECIMAL(10,4) NOT NULL DEFAULT 0,
recharge_code VARCHAR(64) NOT NULL DEFAULT '',
payment_type VARCHAR(30) NOT NULL DEFAULT '',
payment_trade_no VARCHAR(128) NOT NULL DEFAULT '',
pay_url TEXT,
qr_code TEXT,
qr_code_img TEXT,
order_type VARCHAR(20) NOT NULL DEFAULT 'balance',
plan_id BIGINT,
subscription_group_id BIGINT,
subscription_days INT,
provider_instance_id VARCHAR(64),
status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
refund_amount DECIMAL(20,2) NOT NULL DEFAULT 0,
refund_reason TEXT,
refund_at TIMESTAMPTZ,
force_refund BOOLEAN NOT NULL DEFAULT FALSE,
refund_requested_at TIMESTAMPTZ,
refund_request_reason TEXT,
refund_requested_by VARCHAR(20),
expires_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failed_reason TEXT,
client_ip VARCHAR(50) NOT NULL DEFAULT '',
src_host VARCHAR(255) NOT NULL DEFAULT '',
src_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_id ON payment_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_expires_at ON payment_orders(expires_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_created_at ON payment_orders(created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_paid_at ON payment_orders(paid_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_type_paid ON payment_orders(payment_type, paid_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_order_type ON payment_orders(order_type);
@@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS payment_audit_logs (
id BIGSERIAL PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
action VARCHAR(50) NOT NULL,
detail TEXT NOT NULL DEFAULT '',
operator VARCHAR(100) NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_audit_logs_order_id ON payment_audit_logs(order_id);
@@ -1,4 +0,0 @@
-- Migration 092: payment_channels table was removed before release.
-- This file is a no-op placeholder to maintain migration numbering continuity.
-- The payment system now uses the existing channels table (migration 081).
SELECT 1;
@@ -1,18 +0,0 @@
CREATE TABLE IF NOT EXISTS subscription_plans (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL DEFAULT '',
price DECIMAL(20,2) NOT NULL,
original_price DECIMAL(20,2),
validity_days INT NOT NULL DEFAULT 30,
validity_unit VARCHAR(10) NOT NULL DEFAULT 'day',
features TEXT NOT NULL DEFAULT '',
product_name VARCHAR(100) NOT NULL DEFAULT '',
for_sale BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_subscription_plans_group_id ON subscription_plans(group_id);
CREATE INDEX IF NOT EXISTS idx_subscription_plans_for_sale ON subscription_plans(for_sale);
@@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS payment_provider_instances (
id BIGSERIAL PRIMARY KEY,
provider_key VARCHAR(30) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
config TEXT NOT NULL,
supported_types VARCHAR(200) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
limits TEXT NOT NULL DEFAULT '',
refund_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_provider_key ON payment_provider_instances(provider_key);
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_enabled ON payment_provider_instances(enabled);
@@ -1,70 +0,0 @@
-- 096_migrate_purchase_subscription_to_custom_menu.sql
--
-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
-- After migration, purchase_subscription_enabled is set to "false" and
-- purchase_subscription_url is cleared.
--
-- Idempotent: skips if custom_menu_items already contains
-- "migrated_purchase_subscription".
DO $$
DECLARE
v_enabled text;
v_url text;
v_raw text;
v_items jsonb;
v_new_item jsonb;
BEGIN
-- Read legacy settings
SELECT value INTO v_enabled
FROM settings WHERE key = 'purchase_subscription_enabled';
SELECT value INTO v_url
FROM settings WHERE key = 'purchase_subscription_url';
-- Skip if not enabled or URL is empty
IF COALESCE(v_enabled, '') <> 'true' OR COALESCE(TRIM(v_url), '') = '' THEN
RETURN;
END IF;
-- Read current custom_menu_items
SELECT value INTO v_raw
FROM settings WHERE key = 'custom_menu_items';
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
v_items := '[]'::jsonb;
ELSE
v_items := v_raw::jsonb;
END IF;
-- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(v_items) elem
WHERE elem ->> 'id' = 'migrated_purchase_subscription'
) THEN
RETURN;
END IF;
-- Build the new menu item
v_new_item := jsonb_build_object(
'id', 'migrated_purchase_subscription',
'label', 'Purchase',
'icon_svg', '',
'url', TRIM(v_url),
'visibility', 'user',
'sort_order', 100
);
-- Append to array
v_items := v_items || jsonb_build_array(v_new_item);
-- Upsert custom_menu_items
INSERT INTO settings (key, value)
VALUES ('custom_menu_items', v_items::text)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- Clear legacy settings
UPDATE settings SET value = 'false' WHERE key = 'purchase_subscription_enabled';
UPDATE settings SET value = '' WHERE key = 'purchase_subscription_url';
RAISE NOTICE '[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items', v_url;
END $$;
@@ -1,51 +0,0 @@
-- 097_fix_migrated_purchase_menu_label_icon.sql
--
-- Fixes the custom menu item created by migration 096: updates the label
-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg
-- to a credit-card SVG matching the sidebar CreditCardIcon.
--
-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'.
DO $$
DECLARE
v_raw text;
v_items jsonb;
v_idx int;
v_icon text;
v_elem jsonb;
v_i int := 0;
BEGIN
SELECT value INTO v_raw
FROM settings WHERE key = 'custom_menu_items';
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
RETURN;
END IF;
v_items := v_raw::jsonb;
-- Find the index of the migrated item by iterating the array
v_idx := NULL;
FOR v_elem IN SELECT jsonb_array_elements(v_items) LOOP
IF v_elem ->> 'id' = 'migrated_purchase_subscription' THEN
v_idx := v_i;
EXIT;
END IF;
v_i := v_i + 1;
END LOOP;
IF v_idx IS NULL THEN
RETURN; -- item not found, nothing to fix
END IF;
-- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar)
v_icon := '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"/></svg>';
-- Update label and icon_svg
v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'label'], '"充值/订阅"'::jsonb);
v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'icon_svg'], to_jsonb(v_icon));
UPDATE settings SET value = v_items::text WHERE key = 'custom_menu_items';
RAISE NOTICE '[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG';
END $$;
@@ -1,17 +0,0 @@
-- 098_remove_easypay_from_enabled_payment_types.sql
--
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
-- "easypay" is a provider key, not a payment type. Valid payment types
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
--
-- Idempotent: safe to run multiple times.
UPDATE settings
SET value = array_to_string(
array_remove(
string_to_array(value, ','),
'easypay'
), ','
)
WHERE key = 'ENABLED_PAYMENT_TYPES'
AND value LIKE '%easypay%';
@@ -1,16 +0,0 @@
-- Add payment_mode field to payment_provider_instances
-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20) NOT NULL DEFAULT '';
-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
UPDATE payment_provider_instances
SET payment_mode = 'redirect',
supported_types = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(
supported_types, 'easypay,', ''), ',easypay', ''), 'easypay', ''))
WHERE provider_key = 'easypay' AND supported_types LIKE '%easypay%';
-- EasyPay instances without 'easypay' in supported_types → api mode
UPDATE payment_provider_instances
SET payment_mode = 'api'
WHERE provider_key = 'easypay' AND payment_mode = '';
@@ -1,6 +0,0 @@
-- 100_add_out_trade_no_to_payment_orders.sql
-- Adds out_trade_no column for external order ID used with payment providers.
-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS out_trade_no VARCHAR(64) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS paymentorder_out_trade_no ON payment_orders (out_trade_no);
@@ -1041,7 +1041,7 @@ const flushPendingAutoLoad = () => {
const source = pendingAutoLoadSource.value
pendingAutoLoad.value = false
pendingAutoLoadSource.value = undefined
loadUsage(source).catch((e) => {
loadUsage({ source }).catch((e) => {
console.error('Failed to load deferred usage:', e)
})
}
@@ -1053,7 +1053,7 @@ const requestAutoLoad = (source?: 'passive' | 'active') => {
pendingAutoLoadSource.value = source
return
}
loadUsage(source).catch((e) => {
loadUsage({ source }).catch((e) => {
console.error('Failed to auto load usage:', e)
})
}
+17 -3
View File
@@ -1703,9 +1703,23 @@ export default {
title: 'OpenAI Messages Dispatch',
allowDispatch: 'Allow /v1/messages dispatch',
allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint',
defaultModel: 'Default mapped model',
defaultModelPlaceholder: 'e.g., gpt-4.1',
defaultModelHint: 'When account has no model mapping configured, all request models will be mapped to this model'
familyMappingTitle: 'Family Default Mapping',
familyMappingHint: 'Requests that match the Opus, Sonnet, or Haiku families will prefer the target model configured here.',
opusModel: 'Opus Target Model',
opusModelPlaceholder: 'e.g., gpt-5.4',
sonnetModel: 'Sonnet Target Model',
sonnetModelPlaceholder: 'e.g., gpt-5.3-codex',
haikuModel: 'Haiku Target Model',
haikuModelPlaceholder: 'e.g., gpt-5.4-mini',
exactMappingTitle: 'Exact Model Overrides',
exactMappingHint: 'Exact Claude model overrides take priority over the family defaults and can route a specific Claude model to a different target model.',
noExactMappings: 'No exact model overrides yet',
addExactMapping: 'Add Exact Mapping',
claudeModel: 'Claude Model',
claudeModelPlaceholder: 'e.g., claude-sonnet-4-5-20250929',
targetModel: 'Target Model',
targetModelPlaceholder: 'e.g., gpt-5.4',
removeExactMapping: 'Remove Exact Mapping'
},
invalidRequestFallback: {
title: 'Invalid Request Fallback Group',
+17 -3
View File
@@ -1789,9 +1789,23 @@ export default {
title: 'OpenAI Messages 调度配置',
allowDispatch: '允许 /v1/messages 调度',
allowDispatchHint: '启用后,此 OpenAI 分组的 API Key 可以通过 /v1/messages 端点调度请求',
defaultModel: '默认映射模型',
defaultModelPlaceholder: '例如: gpt-4.1',
defaultModelHint: '当账号未配置模型映射时,所有请求模型将映射到此模型'
familyMappingTitle: '系列默认映射',
familyMappingHint: '当请求命中 Opus、Sonnet、Haiku 系列时,会优先使用这里配置的目标模型。',
opusModel: 'Opus 映射模型',
opusModelPlaceholder: '例如: gpt-5.4',
sonnetModel: 'Sonnet 映射模型',
sonnetModelPlaceholder: '例如: gpt-5.3-codex',
haikuModel: 'Haiku 映射模型',
haikuModelPlaceholder: '例如: gpt-5.4-mini',
exactMappingTitle: '精确模型覆盖',
exactMappingHint: '精确 Claude 模型覆盖优先级高于系列默认映射,可将某个具体 Claude 模型单独映射到不同的目标模型。',
noExactMappings: '暂无精确模型覆盖规则',
addExactMapping: '添加精确映射',
claudeModel: 'Claude 模型',
claudeModelPlaceholder: '例如: claude-sonnet-4-5-20250929',
targetModel: '目标模型',
targetModelPlaceholder: '例如: gpt-5.4',
removeExactMapping: '删除精确映射'
},
invalidRequestFallback: {
title: '无效请求兜底分组',
@@ -42,6 +42,9 @@
<div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.features') }}</label>
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
@@ -102,7 +105,7 @@ const { t } = useI18n()
const appStore = useAppStore()
const saving = ref(false)
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true })
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
const planFeaturesText = ref('')
const validityUnitOptions = computed(() => [
@@ -130,10 +133,10 @@ const selectedGroupInfo = computed(() => {
watch(() => props.show, (visible) => {
if (!visible) return
if (props.plan) {
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', for_sale: props.plan.for_sale })
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale })
planFeaturesText.value = (props.plan.features || []).join('\n')
} else {
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true })
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
planFeaturesText.value = ''
}
})
@@ -149,6 +152,7 @@ function buildPlanPayload() {
original_price: planForm.original_price || 0,
validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit,
sort_order: planForm.sort_order,
for_sale: planForm.for_sale,
features,
}