diff --git a/billing-admin-config.mjs b/billing-admin-config.mjs index 9a2c4cc..b913f6d 100644 --- a/billing-admin-config.mjs +++ b/billing-admin-config.mjs @@ -1,7 +1,9 @@ import { loadBillingConfig } from './billing.mjs'; const CONFIG_TABLE = 'h5_billing_admin_config'; -const CONFIG_SCOPE = 'global'; +const CONFIG_SCOPE_LEGACY = 'global'; +const CONFIG_SCOPE_A = 'formula-a'; +const CONFIG_SCOPE_B = 'formula-b'; const SOURCE_ADMIN_DB = 'admin-db'; const SOURCE_ENV = 'env'; const SOURCE_ENV_OVERRIDE = 'env-override'; @@ -9,6 +11,13 @@ const SOURCE_DEFAULT = 'default'; const CACHE_TTL_MS = 5_000; const DEFAULT_ESTIMATE_INPUT_USD_PER_1M = 0.27; const DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M = 1.1; +const DEFAULT_FORMULA = 'A'; +const FORMULA_KEYS = ['A', 'B']; + +const SCOPE_BY_FORMULA = { + A: CONFIG_SCOPE_A, + B: CONFIG_SCOPE_B, +}; function normalizeBoolean(value, fallback = false) { if (value == null || value === '') return fallback; @@ -31,6 +40,11 @@ function normalizeNonNegativeNumber(value, fallback) { return num; } +function normalizeFormulaKey(value) { + const key = String(value ?? DEFAULT_FORMULA).trim().toUpperCase(); + return FORMULA_KEYS.includes(key) ? key : DEFAULT_FORMULA; +} + function defaultConfigShape(env = process.env) { const billing = loadBillingConfig(env); const costEstimateFromTokens = env.H5_COST_ESTIMATE_FROM_TOKENS !== '0'; @@ -160,24 +174,98 @@ async function ensureConfigTable(pool) { `); } -async function loadStoredState(pool) { +async function loadStoredStateByScope(pool, scope, env = process.env) { await ensureConfigTable(pool); const [rows] = await pool.query( `SELECT config_json, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, - [CONFIG_SCOPE], + [scope], ); const row = rows[0]; if (!row) return null; return { - config: mergePatch(defaultConfigShape(), parseJsonLike(row.config_json, {})), + config: mergePatch(defaultConfigShape(env), parseJsonLike(row.config_json, {}), env), updatedAt: Number(row.updated_at ?? 0) || null, updatedBy: row.updated_by ?? null, }; } +async function upsertStoredConfig(pool, scope, config, { updatedBy = null } = {}, env = process.env) { + await ensureConfigTable(pool); + const now = Date.now(); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} + (config_scope, config_json, updated_by, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [scope, JSON.stringify(cloneConfig(config, env)), updatedBy, now], + ); + return { + config: cloneConfig(config, env), + updatedAt: now, + updatedBy, + }; +} + +function resolveDefaultSource(env = process.env) { + if (envLocked(env)) return SOURCE_ENV_OVERRIDE; + if (envLooksCustomized(env)) return SOURCE_ENV; + return SOURCE_DEFAULT; +} + +function buildFormulaState(stored, env = process.env) { + if (stored) { + return { + config: cloneConfig(stored.config, env), + updatedAt: stored.updatedAt, + updatedBy: stored.updatedBy, + source: SOURCE_ADMIN_DB, + }; + } + return { + config: defaultConfigShape(env), + updatedAt: null, + updatedBy: null, + source: resolveDefaultSource(env), + }; +} + +async function migrateLegacyConfig(pool, env = process.env) { + await ensureConfigTable(pool); + const legacy = await loadStoredStateByScope(pool, CONFIG_SCOPE_LEGACY, env); + const formulaA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env); + const formulaB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env); + + const seedConfig = legacy?.config + ?? formulaA?.config + ?? formulaB?.config + ?? defaultConfigShape(env); + + if (!formulaA) { + await upsertStoredConfig( + pool, + CONFIG_SCOPE_A, + seedConfig, + { updatedBy: legacy?.updatedBy ?? formulaB?.updatedBy ?? null }, + env, + ); + } + if (!formulaB) { + await upsertStoredConfig( + pool, + CONFIG_SCOPE_B, + cloneConfig(formulaA?.config ?? seedConfig, env), + { updatedBy: legacy?.updatedBy ?? formulaA?.updatedBy ?? null }, + env, + ); + } +} + export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) { let cache = null; @@ -185,101 +273,142 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache cache = null; } - async function loadEffectiveConfig({ bypassCache = false } = {}) { + async function loadAllFormulaStates({ bypassCache = false } = {}) { const now = Date.now(); if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) { return cache.state; } if (envLocked(env)) { + const shared = defaultConfigShape(env); const state = { - config: defaultConfigShape(env), - updatedAt: null, - updatedBy: null, - source: SOURCE_ENV_OVERRIDE, + formulas: { + A: { + config: cloneConfig(shared, env), + updatedAt: null, + updatedBy: null, + source: SOURCE_ENV_OVERRIDE, + }, + B: { + config: cloneConfig(shared, env), + updatedAt: null, + updatedBy: null, + source: SOURCE_ENV_OVERRIDE, + }, + }, + defaultFormula: DEFAULT_FORMULA, }; cache = { loadedAt: now, state }; return state; } - const stored = await loadStoredState(pool); - let state; - if (stored) { - state = { - config: cloneConfig(stored.config, env), - updatedAt: stored.updatedAt, - updatedBy: stored.updatedBy, - source: SOURCE_ADMIN_DB, - }; - } else if (envLooksCustomized(env)) { - state = { - config: defaultConfigShape(env), - updatedAt: null, - updatedBy: null, - source: SOURCE_ENV, - }; - } else { - state = { - config: defaultConfigShape(env), - updatedAt: null, - updatedBy: null, - source: SOURCE_DEFAULT, - }; - } + await migrateLegacyConfig(pool, env); + const storedA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env); + const storedB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env); + const state = { + formulas: { + A: buildFormulaState(storedA, env), + B: buildFormulaState(storedB, env), + }, + defaultFormula: DEFAULT_FORMULA, + }; cache = { loadedAt: now, state }; return state; } + async function getUserFormulaKey(userId) { + if (!userId) return DEFAULT_FORMULA; + const [rows] = await pool.query( + `SELECT billing_formula FROM h5_users WHERE id = ? LIMIT 1`, + [userId], + ); + return normalizeFormulaKey(rows[0]?.billing_formula); + } + + async function loadFormulaState(formulaKey, options = {}) { + const all = await loadAllFormulaStates(options); + return all.formulas[normalizeFormulaKey(formulaKey)]; + } + return { async ensureSchema() { await ensureConfigTable(pool); + await migrateLegacyConfig(pool, env); }, clearCache, - async getAdminConfig() { - const state = await loadEffectiveConfig({ bypassCache: true }); + async getAdminConfig({ formula = DEFAULT_FORMULA } = {}) { + const all = await loadAllFormulaStates({ bypassCache: true }); + const activeFormula = normalizeFormulaKey(formula); + const active = all.formulas[activeFormula]; return { - config: state.config, - updatedAt: state.updatedAt, - updatedBy: state.updatedBy, - source: state.source, - envOverrideActive: state.source === SOURCE_ENV_OVERRIDE, + config: active.config, + formulas: { + A: all.formulas.A.config, + B: all.formulas.B.config, + }, + formulaMeta: { + A: { + updatedAt: all.formulas.A.updatedAt, + updatedBy: all.formulas.A.updatedBy, + source: all.formulas.A.source, + }, + B: { + updatedAt: all.formulas.B.updatedAt, + updatedBy: all.formulas.B.updatedBy, + source: all.formulas.B.source, + }, + }, + activeFormula, + defaultFormula: DEFAULT_FORMULA, + updatedAt: active.updatedAt, + updatedBy: active.updatedBy, + source: active.source, + envOverrideActive: active.source === SOURCE_ENV_OVERRIDE, formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价', }; }, - async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { + async updateAdminConfig(patch = {}, { updatedBy = null, formula = DEFAULT_FORMULA } = {}) { if (envLocked(env)) { throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), { code: 'BILLING_CONFIG_ENV_LOCKED', }); } - const stored = await loadStoredState(pool); + const formulaKey = normalizeFormulaKey(formula); + const scope = SCOPE_BY_FORMULA[formulaKey]; + const stored = await loadStoredStateByScope(pool, scope, env); const base = stored?.config ?? defaultConfigShape(env); const nextConfig = mergePatch(base, patch, env); - await ensureConfigTable(pool); - const now = Date.now(); - await pool.query( - `INSERT INTO ${CONFIG_TABLE} - (config_scope, config_json, updated_by, updated_at) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - config_json = VALUES(config_json), - updated_by = VALUES(updated_by), - updated_at = VALUES(updated_at)`, - [CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now], - ); + await upsertStoredConfig(pool, scope, nextConfig, { updatedBy }, env); clearCache(); - return this.getAdminConfig(); + return this.getAdminConfig({ formula: formulaKey }); }, - async getRuntimeState() { - const state = await loadEffectiveConfig(); + async assignBillingFormula(userIds = [], formula = DEFAULT_FORMULA) { + const formulaKey = normalizeFormulaKey(formula); + const ids = [...new Set((Array.isArray(userIds) ? userIds : []).map((id) => String(id).trim()).filter(Boolean))]; + if (!ids.length) { + return { ok: false, message: '请选择至少一个用户', updated: 0 }; + } + const now = Date.now(); + const placeholders = ids.map(() => '?').join(', '); + const [result] = await pool.query( + `UPDATE h5_users SET billing_formula = ?, updated_at = ? WHERE id IN (${placeholders})`, + [formulaKey, now, ...ids], + ); + return { ok: true, updated: Number(result.affectedRows ?? 0), formula: formulaKey }; + }, + + async getRuntimeState({ formula = DEFAULT_FORMULA, userId = null } = {}) { + const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula); + const state = await loadFormulaState(formulaKey); return { source: state.source, updatedAt: state.updatedAt, updatedBy: state.updatedBy, + formula: formulaKey, config: state.config, compute: toComputeBillingConfig(state.config), estimate: toCostEstimateConfig(state.config), @@ -287,23 +416,28 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache }; }, - async getEffectiveBillingConfig() { - const state = await loadEffectiveConfig(); + async getEffectiveBillingConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) { + const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula); + const state = await loadFormulaState(formulaKey); return toComputeBillingConfig(state.config); }, - async getEffectiveCostEstimateConfig() { - const state = await loadEffectiveConfig(); + async getEffectiveCostEstimateConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) { + const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula); + const state = await loadFormulaState(formulaKey); return toCostEstimateConfig(state.config); }, }; } export const billingAdminConfigInternals = { - CONFIG_SCOPE, - CONFIG_TABLE, + CONFIG_SCOPE_LEGACY, + CONFIG_SCOPE_A, + CONFIG_SCOPE_B, + DEFAULT_FORMULA, defaultConfigShape, mergePatch, + normalizeFormulaKey, toComputeBillingConfig, toCostEstimateConfig, envLocked, diff --git a/billing-admin-config.test.mjs b/billing-admin-config.test.mjs index a8ad9e4..ae112d0 100644 --- a/billing-admin-config.test.mjs +++ b/billing-admin-config.test.mjs @@ -7,13 +7,21 @@ import { toCostEstimateConfig, } from './billing-admin-config.mjs'; -function createMemoryPool(initialRows = []) { +function createMemoryPool(initialRows = [], userFormulas = new Map()) { const rows = new Map( initialRows.map((row) => [row.config_scope, { ...row }]), ); return { async query(sql, params = []) { if (/CREATE TABLE/i.test(sql)) return [{}, undefined]; + if (/SELECT billing_formula FROM h5_users/i.test(sql)) { + const userId = params[0]; + return [[{ billing_formula: userFormulas.get(userId) ?? 'A' }], undefined]; + } + if (/SELECT/i.test(sql) && /config_scope IN/i.test(sql)) { + const scopes = params.slice(0, 3); + return [scopes.map((scope) => rows.get(scope)).filter(Boolean), undefined]; + } if (/SELECT/i.test(sql)) { const scope = params[0]; const row = rows.get(scope); @@ -29,9 +37,15 @@ function createMemoryPool(initialRows = []) { }); return [{ affectedRows: 1 }, undefined]; } + if (/UPDATE h5_users SET billing_formula/i.test(sql)) { + const [formula, , ...ids] = params; + ids.forEach((id) => userFormulas.set(id, formula)); + return [{ affectedRows: ids.length }, undefined]; + } return [{}, undefined]; }, _rows: rows, + _userFormulas: userFormulas, }; } @@ -89,14 +103,38 @@ test('admin-db config wins over env for effective billing', async () => { const service = createBillingAdminConfigService(pool, { env, cacheTtlMs: 0 }); await service.updateAdminConfig( { marginMultiplier: 2, usdCnyRate: 7.5, useBackendCost: true }, - { updatedBy: 'admin-1' }, + { updatedBy: 'admin-1', formula: 'A' }, ); - const effective = await service.getEffectiveBillingConfig(); + const effective = await service.getEffectiveBillingConfig({ userId: 'user-a' }); assert.equal(effective.marginMultiplier, 2); assert.equal(effective.usdCnyRate, 7.5); const admin = await service.getAdminConfig(); assert.equal(admin.source, 'admin-db'); assert.equal(admin.updatedBy, 'admin-1'); + assert.equal(admin.formulas?.A.marginMultiplier, 2); +}); + +test('formula A and B can diverge', async () => { + const pool = createMemoryPool([], new Map([['user-b', 'B']])); + const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 }); + await service.updateAdminConfig({ marginMultiplier: 1.1 }, { formula: 'A' }); + await service.updateAdminConfig({ marginMultiplier: 2.2 }, { formula: 'B' }); + const admin = await service.getAdminConfig(); + assert.equal(admin.formulas.A.marginMultiplier, 1.1); + assert.equal(admin.formulas.B.marginMultiplier, 2.2); + const userA = await service.getEffectiveBillingConfig({ userId: 'user-a' }); + const userB = await service.getEffectiveBillingConfig({ userId: 'user-b' }); + assert.equal(userA.marginMultiplier, 1.1); + assert.equal(userB.marginMultiplier, 2.2); +}); + +test('assignBillingFormula updates users', async () => { + const pool = createMemoryPool(); + const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 }); + const result = await service.assignBillingFormula(['u1', 'u2'], 'B'); + assert.equal(result.ok, true); + assert.equal(result.updated, 2); + assert.equal(pool._userFormulas.get('u1'), 'B'); }); test('H5_BILLING_CONFIG_SOURCE=env locks admin writes', async () => { @@ -125,7 +163,8 @@ test('env fallback used when no admin row', async () => { cacheTtlMs: 0, }); const admin = await service.getAdminConfig(); - assert.equal(admin.source, 'env'); assert.equal(admin.config.marginMultiplier, 1.2); assert.equal(admin.config.useBackendCost, true); + assert.equal(admin.formulas.A.marginMultiplier, 1.2); + assert.equal(admin.formulas.B.marginMultiplier, 1.2); }); diff --git a/billing-subscription.mjs b/billing-subscription.mjs index 1ed7d18..bb86b9c 100644 --- a/billing-subscription.mjs +++ b/billing-subscription.mjs @@ -477,99 +477,139 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { return { ok: true, updated: result.affectedRows > 0 }; }; - // Process auto-renewals for subscriptions that just expired with auto_renew = 1. - // Should be called BEFORE expireStaleSubscriptions in the periodic worker. - const processAutoRenewals = async () => { - const now = Date.now(); - // Find active subscriptions that have expired with auto_renew on. - const [rows] = await pool.query( + // Process auto-renewals for subscriptions that expired with auto_renew = 1. + // Retries until balance is sufficient; does not disable auto_renew on failure. + const processAutoRenewalsForUser = async (userId, { now = Date.now() } = {}) => { + const [activeRows] = await pool.query( + `SELECT id FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + LIMIT 1`, + [userId, now], + ); + if (activeRows.length) { + return { ok: false, reason: 'already_active' }; + } + + const [candidateRows] = await pool.query( `SELECT s.*, w.balance_cents FROM h5_subscriptions s JOIN h5_user_wallets w ON w.user_id = s.user_id - WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`, - [now], + WHERE s.user_id = ? AND s.auto_renew = 1 AND s.expires_at <= ? + ORDER BY s.expires_at DESC + LIMIT 1`, + [userId, now], + ); + const row = candidateRows[0]; + if (!row) { + return { ok: false, reason: 'no_candidate' }; + } + + const sub = mapSubRow(row); + const plan = await resolvePlan(sub.planType); + if (!plan || plan.priceCents === 0) { + return { ok: false, reason: 'invalid_plan' }; + } + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const [walletRows] = await conn.query( + `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + [sub.userId], + ); + const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); + + if (balanceCents < plan.priceCents) { + await conn.rollback(); + console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`); + return { ok: false, reason: 'insufficient_balance', balanceCents, requiredCents: plan.priceCents }; + } + + await conn.query( + `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, + [plan.priceCents, now, sub.userId], + ); + await conn.query( + `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, + [sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now], + ); + + if (sub.status === 'active') { + await conn.query( + `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`, + [now, sub.id], + ); + } + + const newId = crypto.randomUUID(); + const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, period_images_bonus, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`, + [ + newId, sub.userId, sub.planType, plan.periodTokens, + plan.periodImages ?? 0, + sub.periodImagesBonus ?? 0, + now, newPeriodEnd, newPeriodEnd, + Number(plan.overageRate.toFixed(2)), + now, now, + ], + ); + + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [sub.planType, now, sub.userId], + ); + + await conn.commit(); + console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`); + const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]); + return { ok: true, subscription: mapSubRow(newRows[0]), balanceCents: balanceCents - plan.priceCents }; + } catch (err) { + await conn.rollback(); + console.warn(`Auto-renew error for user ${sub.userId}:`, err); + throw err; + } finally { + conn.release(); + } + }; + + const processAutoRenewals = async () => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT DISTINCT s.user_id + FROM h5_subscriptions s + WHERE s.auto_renew = 1 + AND s.expires_at <= ? + AND s.plan_type != 'free' + AND NOT EXISTS ( + SELECT 1 FROM h5_subscriptions s2 + WHERE s2.user_id = s.user_id + AND s2.status = 'active' + AND s2.expires_at > ? + )`, + [now, now], ); let renewed = 0; let failed = 0; for (const row of rows) { - const sub = mapSubRow(row); - const plan = await resolvePlan(sub.planType); - if (!plan || plan.priceCents === 0) continue; - - const balance = Number(row.balance_cents ?? 0); - const conn = await pool.getConnection(); try { - await conn.beginTransaction(); - - // Re-lock wallet and verify balance - const [walletRows] = await conn.query( - `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, - [sub.userId], - ); - const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); - - if (balanceCents < plan.priceCents) { - // Not enough — let expireStaleSubscriptions handle the reset - await conn.rollback(); - failed++; - console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`); - continue; + const result = await processAutoRenewalsForUser(row.user_id, { now }); + if (result.ok) { + renewed += 1; + } else if (result.reason === 'insufficient_balance') { + failed += 1; } - - // Deduct wallet - await conn.query( - `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, - [plan.priceCents, now, sub.userId], - ); - await conn.query( - `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) - VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, - [sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now], - ); - - // Expire old subscription - await conn.query( - `UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`, - [now, sub.id], - ); - - // Create new subscription - const newId = crypto.randomUUID(); - const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; - await conn.query( - `INSERT INTO h5_subscriptions - (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, - period_images_limit, period_images_used, period_images_bonus, - period_start, period_end, expires_at, overage_rate, auto_renew, - operator_id, note, created_at, updated_at) - VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`, - [ - newId, sub.userId, sub.planType, plan.periodTokens, - plan.periodImages ?? 0, - sub.periodImagesBonus ?? 0, - now, newPeriodEnd, newPeriodEnd, - Number(plan.overageRate.toFixed(2)), - now, now, - ], - ); - - // Keep user's plan_type current - await conn.query( - `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, - [sub.planType, now, sub.userId], - ); - - await conn.commit(); - renewed++; - console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`); - } catch (err) { - await conn.rollback(); - console.warn(`Auto-renew error for user ${sub.userId}:`, err); - failed++; - } finally { - conn.release(); + } catch { + failed += 1; } } @@ -901,6 +941,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { purchaseSubscription, setAutoRenew, processAutoRenewals, + processAutoRenewalsForUser, consumeQuota, consumeImageQuota, getImageQuota, diff --git a/billing-subscription.test.mjs b/billing-subscription.test.mjs index 809facf..9f0ac4f 100644 --- a/billing-subscription.test.mjs +++ b/billing-subscription.test.mjs @@ -353,8 +353,11 @@ describe('createSubscriptionService', () => { describe('processAutoRenewals', () => { it('uses DB-backed plan definitions for renewed subscriptions', async () => { + const userId = 'user-lite-1'; const expiredSub = makeSubRow({ + user_id: userId, plan_type: 'lite', + status: 'expired', auto_renew: 1, expires_at: Date.now() - 1000, balance_cents: 9999, @@ -364,6 +367,9 @@ describe('createSubscriptionService', () => { async query(sql, params) { this.queries.push({ sql, params }); if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: 9999 }]]; + if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) { + return [[{ ...expiredSub, id: 'new-sub-id', status: 'active' }]]; + } return [{ affectedRows: 1 }]; }, async beginTransaction() {}, @@ -372,8 +378,12 @@ describe('createSubscriptionService', () => { release() {}, }; const pool = { - async query(sql) { - if (sql.includes('FROM h5_subscriptions s')) return [[expiredSub]]; + async query(sql, params = []) { + if (sql.includes('SELECT DISTINCT s.user_id')) return [[{ user_id: userId }]]; + if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]]; + if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) { + return [[expiredSub]]; + } return [[]]; }, async getConnection() { return conn; }, @@ -407,5 +417,54 @@ describe('createSubscriptionService', () => { assert.equal(insert.params[5], 0); assert.equal(insert.params[9], 0.45); }); + + it('retries expired subscriptions when balance becomes sufficient', async () => { + const userId = 'user-retry-1'; + let balance = 500; + const expiredSub = makeSubRow({ + user_id: userId, + plan_type: 'lite', + status: 'expired', + auto_renew: 1, + expires_at: Date.now() - 1000, + }); + const conn = { + async query(sql) { + if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: balance }]]; + if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) { + return [[{ ...expiredSub, id: 'renewed-sub', status: 'active' }]]; + } + return [{ affectedRows: 1 }]; + }, + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + }; + const pool = { + async query(sql) { + if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]]; + if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) { + return [[expiredSub]]; + } + return [[]]; + }, + async getConnection() { return conn; }, + }; + const svc = createSubscriptionService(pool, { + getPlanAsync: async (planType) => planType === 'lite' + ? { priceCents: 990, periodTokens: 1000, periodImages: 0, overageRate: 1, periodDays: 30 } + : null, + }); + + const failed = await svc.processAutoRenewalsForUser(userId); + assert.equal(failed.ok, false); + assert.equal(failed.reason, 'insufficient_balance'); + + balance = 5000; + const renewed = await svc.processAutoRenewalsForUser(userId); + assert.equal(renewed.ok, true); + assert.equal(renewed.balanceCents, 4010); + }); }); }); diff --git a/db.mjs b/db.mjs index 054dbe5..0061073 100644 --- a/db.mjs +++ b/db.mjs @@ -583,6 +583,12 @@ export async function migrateSchema(pool) { ); } + if (!(await columnExists(pool, 'h5_users', 'billing_formula'))) { + await pool.query( + `ALTER TABLE h5_users ADD COLUMN billing_formula ENUM('A','B') NOT NULL DEFAULT 'A' AFTER low_balance_gift_granted_at`, + ); + } + if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) { await pool.query( `ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`, diff --git a/schema.sql b/schema.sql index 804ba8c..66dd00c 100644 --- a/schema.sql +++ b/schema.sql @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS h5_users ( workspace_root VARCHAR(512) NOT NULL, low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0, low_balance_gift_granted_at BIGINT NULL, + billing_formula ENUM('A','B') NOT NULL DEFAULT 'A', created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE KEY uq_h5_users_slug (slug), diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index fdc7b93..d01063b 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -2109,12 +2109,12 @@ export function createTkmindProxy({ return upstream.json(); } - async function resolveSessionBillingTokenState(sessionId, tokenStateRaw) { + async function resolveSessionBillingTokenState(sessionId, tokenStateRaw, userId = null) { return resolveBillingTokenState(tokenStateRaw, { sessionId, fetchSession: fetchSessionBillingCost, loadEstimateConfig: billingConfigService?.getEffectiveCostEstimateConfig - ? () => billingConfigService.getEffectiveCostEstimateConfig() + ? () => billingConfigService.getEffectiveCostEstimateConfig({ userId }) : null, }); } @@ -2218,7 +2218,7 @@ export function createTkmindProxy({ } if (!finishResult.ok) throw finishResult.error; const finish = finishResult.value; - const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState); + const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState, userId); if (tokenState && userAuth.billSessionUsage) { await userAuth .billSessionUsage(userId, sessionId, tokenState, requestId) @@ -2720,7 +2720,11 @@ export function createTkmindProxy({ .catch(() => {}); } const billingRequestId = event.request_id ?? event.chat_request_id ?? null; - const tokenState = await resolveSessionBillingTokenState(sessionId, event.token_state); + const tokenState = await resolveSessionBillingTokenState( + sessionId, + event.token_state, + req.currentUser.id, + ); const result = await userAuth.billSessionUsage( req.currentUser.id, sessionId, diff --git a/user-auth.mjs b/user-auth.mjs index 6eedfbb..1cf5473 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -129,15 +129,19 @@ export function createUserAuth(pool, options = {}) { const { storageRoot, publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env); const skillCatalog = listPlatformSkillCatalog(h5Root); const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500); - const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100); - const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1000); + const lowBalanceGiftThresholdCents = Number( + options.lowBalanceGiftThresholdCents ?? env.H5_LOW_BALANCE_GIFT_THRESHOLD_CENTS ?? 100, + ); + const lowBalanceGiftAmountCents = Number( + options.lowBalanceGiftAmountCents ?? env.H5_LOW_BALANCE_GIFT_AMOUNT_CENTS ?? 0, + ); const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000); const loginMaxFailures = Number(options.loginMaxFailures ?? 5); const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000); const persistSessions = options.persistSessions !== false && Boolean(pool); let rechargeNotifier = typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null; - const subscriptionService = options.subscriptionService ?? null; + let subscriptionService = options.subscriptionService ?? null; const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function' ? options.provisionUserDataSpace : null; @@ -190,7 +194,7 @@ export function createUserAuth(pool, options = {}) { const getUserById = async (userId) => { const [rows] = await pool.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, - u.plan_type, u.workspace_root, + u.plan_type, u.workspace_root, u.billing_formula, s.quota_bytes, s.used_bytes, s.reserved_bytes, w.balance_cents, w.tokens_used, (SELECT COALESCE(SUM(ABS(amount_cents)), 0) @@ -218,6 +222,7 @@ export function createUserAuth(pool, options = {}) { status: row.status, planType: row.plan_type ?? 'free', workspaceRoot: row.workspace_root, + billingFormula: row.billing_formula === 'B' ? 'B' : 'A', balanceCents, totalCreditCents: balanceCents + spentCents, tokensUsed: Number(row.tokens_used ?? 0), @@ -447,7 +452,7 @@ export function createUserAuth(pool, options = {}) { (id, username, slug, email, display_name, salt, password_hash, password_algorithm, role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 0, NULL, ?, ?)`, [ userId, normalized, @@ -900,7 +905,7 @@ export function createUserAuth(pool, options = {}) { ); const [rows] = await pool.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, - u.plan_type, u.workspace_root, + u.plan_type, u.workspace_root, u.billing_formula, s.quota_bytes, s.used_bytes, s.reserved_bytes, u.created_at, u.updated_at, w.balance_cents, w.tokens_used FROM h5_users u @@ -1038,6 +1043,14 @@ export function createUserAuth(pool, options = {}) { fields.push('role = ?'); values.push(patch.role === 'admin' ? 'admin' : 'user'); } + if (patch.billingFormula !== undefined) { + const formula = String(patch.billingFormula).trim().toUpperCase(); + if (!['A', 'B'].includes(formula)) { + return { ok: false, message: '计量公式只能是 A 或 B' }; + } + fields.push('billing_formula = ?'); + values.push(formula); + } if (fields.length > 0) { fields.push('updated_at = ?'); @@ -1272,6 +1285,16 @@ export function createUserAuth(pool, options = {}) { ]); } if (ownsConnection) await conn.commit(); + if (subscriptionService?.processAutoRenewalsForUser) { + try { + await subscriptionService.processAutoRenewalsForUser(userId); + } catch (err) { + console.warn( + 'Post-recharge auto-renew retry failed:', + err instanceof Error ? err.message : String(err), + ); + } + } const updated = await getUserById(userId); if (rechargeNotifier) { try { @@ -1336,7 +1359,7 @@ export function createUserAuth(pool, options = {}) { } const tokenState = normalizeTokenState(tokenStateRaw); const config = billingConfigService?.getEffectiveBillingConfig - ? await billingConfigService.getEffectiveBillingConfig() + ? await billingConfigService.getEffectiveBillingConfig({ userId }) : loadBillingConfig(env); const normalizedRequestId = requestId ? String(requestId).trim() || null : null; const now = Date.now(); @@ -2747,7 +2770,7 @@ export function createUserAuth(pool, options = {}) { (id, username, slug, email, display_name, salt, password_hash, password_algorithm, role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible, low_balance_gift_granted_at, created_at, updated_at) - VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`, + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 0, NULL, ?, ?)`, [ userId, normalized, @@ -3079,6 +3102,9 @@ export function createUserAuth(pool, options = {}) { setRechargeNotifier(callback) { rechargeNotifier = typeof callback === 'function' ? callback : null; }, + setSubscriptionService(service) { + subscriptionService = service ?? null; + }, findWechatUserByOpenid, getWechatAgentRoute, upsertWechatAgentRoute, diff --git a/user-auth.test.mjs b/user-auth.test.mjs index 4c554f7..e38301c 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -743,7 +743,7 @@ test('billSessionUsage auto gifts low-balance bonus once for eligible new users' }, }; - const auth = createUserAuth(pool, { persistSessions: false }); + const auth = createUserAuth(pool, { persistSessions: false, lowBalanceGiftAmountCents: 1000 }); const first = await auth.billSessionUsage( userRow.id,