feat(billing): add dual formulas, disable 10yuan gift, retry auto-renew
Memind CI / Test, build, and release guards (push) Successful in 3m43s
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:
+197
-63
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user