diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 3127a9c55f..64709b5b1c 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -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, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 08291faa5c..3a2dba3818 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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() diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index aafe4a58d5..ed2578c843 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -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))) diff --git a/backend/internal/server/middleware/jwt_auth_test.go b/backend/internal/server/middleware/jwt_auth_test.go index ad9c1b5b62..c483a51eaf 100644 --- a/backend/internal/server/middleware/jwt_auth_test.go +++ b/backend/internal/server/middleware/jwt_auth_test.go @@ -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() diff --git a/backend/migrations/092_payment_orders.sql b/backend/migrations/092_payment_orders.sql deleted file mode 100644 index 036e4dedc0..0000000000 --- a/backend/migrations/092_payment_orders.sql +++ /dev/null @@ -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); diff --git a/backend/migrations/093_payment_audit_logs.sql b/backend/migrations/093_payment_audit_logs.sql deleted file mode 100644 index d05b15ef83..0000000000 --- a/backend/migrations/093_payment_audit_logs.sql +++ /dev/null @@ -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); diff --git a/backend/migrations/094_removed_payment_channels.sql b/backend/migrations/094_removed_payment_channels.sql deleted file mode 100644 index cb20234795..0000000000 --- a/backend/migrations/094_removed_payment_channels.sql +++ /dev/null @@ -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; diff --git a/backend/migrations/095_subscription_plans.sql b/backend/migrations/095_subscription_plans.sql deleted file mode 100644 index 541d8f0c89..0000000000 --- a/backend/migrations/095_subscription_plans.sql +++ /dev/null @@ -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); diff --git a/backend/migrations/096_payment_provider_instances.sql b/backend/migrations/096_payment_provider_instances.sql deleted file mode 100644 index bedd75df60..0000000000 --- a/backend/migrations/096_payment_provider_instances.sql +++ /dev/null @@ -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); diff --git a/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql b/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql deleted file mode 100644 index 1864459ecc..0000000000 --- a/backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql +++ /dev/null @@ -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 $$; diff --git a/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql b/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql deleted file mode 100644 index 5361ad8166..0000000000 --- a/backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql +++ /dev/null @@ -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 := ''; - - -- 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 $$; diff --git a/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql b/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql deleted file mode 100644 index 8128ed09ff..0000000000 --- a/backend/migrations/100_remove_easypay_from_enabled_payment_types.sql +++ /dev/null @@ -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%'; diff --git a/backend/migrations/101_add_payment_mode.sql b/backend/migrations/101_add_payment_mode.sql deleted file mode 100644 index eeb6ba7b2f..0000000000 --- a/backend/migrations/101_add_payment_mode.sql +++ /dev/null @@ -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 = ''; diff --git a/backend/migrations/102_add_out_trade_no_to_payment_orders.sql b/backend/migrations/102_add_out_trade_no_to_payment_orders.sql deleted file mode 100644 index 896c3c954d..0000000000 --- a/backend/migrations/102_add_out_trade_no_to_payment_orders.sql +++ /dev/null @@ -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); diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 588832252c..c76dc4964c 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -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) }) } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 14e1e6a896..5c1d725dd3 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c30a46b3cd..6a29adb30e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '无效请求兜底分组', diff --git a/frontend/src/views/admin/orders/PlanEditDialog.vue b/frontend/src/views/admin/orders/PlanEditDialog.vue index 17c8084f68..acc70bef16 100644 --- a/frontend/src/views/admin/orders/PlanEditDialog.vue +++ b/frontend/src/views/admin/orders/PlanEditDialog.vue @@ -42,6 +42,9 @@
+