feat(billing): add dual formulas, disable 10yuan gift, retry auto-renew
Memind CI / Test, build, and release guards (push) Successful in 3m43s

Turn off the low-balance 10 CNY signup bonus while keeping the 5 CNY
registration credit, split metering into independent formula A/B scopes,
and retry expired auto-renew subscriptions after recharge or hourly worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-22 08:45:43 +08:00
parent f57e080b49
commit 4d12ea438b
9 changed files with 474 additions and 164 deletions
+197 -63
View File
@@ -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,
+43 -4
View File
@@ -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);
});
+123 -82
View File
@@ -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,
+61 -2
View File
@@ -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);
});
});
});
+6
View File
@@ -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`,
+1
View File
@@ -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),
+8 -4
View File
@@ -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,
+34 -8
View File
@@ -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,
+1 -1
View File
@@ -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,