diff --git a/docs/billing-acceptance.md b/docs/billing-acceptance.md new file mode 100644 index 0000000..126dfc2 --- /dev/null +++ b/docs/billing-acceptance.md @@ -0,0 +1,28 @@ +# 计费赠金 / A-B 计量 / 包月补扣 — 业务验收清单 + +## 1. 新用户赠金 + +- [ ] 新注册用户钱包仅入账 **5 元**,流水备注为「新用户赠送」 +- [ ] 余额消耗至 1 元以下时,**不再**出现「新用户低余额自动赠送」流水或站内通知 +- [ ] 生产环境可设置 `H5_LOW_BALANCE_GIFT_AMOUNT_CENTS=0` 锁定关闭 + +## 2. 计量公式 A / B + +- [ ] 管理后台「计费中心 → 计量公式」可分别编辑并保存公式 A、公式 B +- [ ] 修改公式 A 不影响已分配到公式 B 的用户扣费 +- [ ] 批量分配可将用户划入 A 或 B,用户列表显示当前公式 +- [ ] Portal 对话扣费在数秒内按用户所属公式生效(无需重启) + +## 3. 包月自动续费补扣 + +- [ ] 到期且开启自动续费、余额不足:套餐过期降为免费,**自动续费标记保持开启** +- [ ] 订阅列表显示「待补扣」(已过期 + 自动续费开启) +- [ ] 用户充值后余额足够:自动扣款并恢复套餐,无需等待小时任务 +- [ ] 小时任务对仍开启自动续费且已过期的订阅持续重试 + +## 发布顺序 + +1. Memind(103 Portal 8081)— schema + 扣费/续费运行时 +2. memind_adm(5174 / 8085)— 管理后台 UI 与 API + +发布前分别执行各仓库 `bash scripts/check-release-ready.sh`。 diff --git a/server/app.mjs b/server/app.mjs index 0eeecd2..907136a 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -660,11 +660,12 @@ export function createAdminApp(services) { res.json(await skillRuntimeConfigService.getPublicRuntimeConfig()); }); - adminApi.get('/billing/config', requireAdmin, async (_req, res) => { + adminApi.get('/billing/config', requireAdmin, async (req, res) => { if (!billingConfigService?.getAdminConfig) { return res.status(503).json({ message: '计费公式配置服务未启用' }); } - return res.json(await billingConfigService.getAdminConfig()); + const formula = req.query.formula ? String(req.query.formula) : undefined; + return res.json(await billingConfigService.getAdminConfig({ formula })); }); const updateBillingConfig = async (req, res) => { @@ -672,9 +673,10 @@ export function createAdminApp(services) { return res.status(503).json({ message: '计费公式配置服务未启用' }); } try { + const formula = req.body?.formula ? String(req.body.formula) : undefined; return res.json(await billingConfigService.updateAdminConfig( req.body?.config ?? req.body ?? {}, - { updatedBy: req.currentUser.id }, + { updatedBy: req.currentUser.id, formula }, )); } catch (error) { if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') { @@ -687,11 +689,25 @@ export function createAdminApp(services) { adminApi.put('/billing/config', requireAdmin, updateBillingConfig); adminApi.patch('/billing/config', requireAdmin, updateBillingConfig); - adminApi.get('/billing/runtime', requireAdmin, async (_req, res) => { + adminApi.post('/billing/formula-assignments', requireAdmin, async (req, res) => { + if (!billingConfigService?.assignBillingFormula) { + return res.status(503).json({ message: '计费公式配置服务未启用' }); + } + const { userIds, formula } = req.body ?? {}; + const result = await billingConfigService.assignBillingFormula(userIds, formula); + if (!result.ok) { + return res.status(400).json(result); + } + return res.json(result); + }); + + adminApi.get('/billing/runtime', requireAdmin, async (req, res) => { if (!billingConfigService?.getRuntimeState) { return res.status(503).json({ message: '计费公式配置服务未启用' }); } - return res.json(await billingConfigService.getRuntimeState()); + const formula = req.query.formula ? String(req.query.formula) : undefined; + const userId = req.query.userId ? String(req.query.userId) : undefined; + return res.json(await billingConfigService.getRuntimeState({ formula, userId })); }); adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => { diff --git a/server/billing-config-routes.test.mjs b/server/billing-config-routes.test.mjs index 74a6151..dbfc087 100644 --- a/server/billing-config-routes.test.mjs +++ b/server/billing-config-routes.test.mjs @@ -28,8 +28,15 @@ function createServices({ role = 'admin' } = {}) { getMe: async () => ({ id: 'admin-id', username: 'admin', role }), }, billingConfigService: { - getAdminConfig: async () => ({ + getAdminConfig: async ({ formula = 'A' } = {}) => ({ config: stored, + formulas: { A: stored, B: stored }, + formulaMeta: { + A: { source: 'env', updatedAt: null, updatedBy: null }, + B: { source: 'env', updatedAt: null, updatedBy: null }, + }, + activeFormula: formula, + defaultFormula: 'A', source: 'env', updatedAt: null, updatedBy: null, @@ -39,12 +46,24 @@ function createServices({ role = 'admin' } = {}) { stored = { ...stored, ...(patch.config ?? patch) }; return { config: stored, + formulas: { A: stored, B: stored }, + formulaMeta: { + A: { source: 'admin-db', updatedAt: Date.now(), updatedBy: context?.updatedBy ?? null }, + B: { source: 'admin-db', updatedAt: null, updatedBy: null }, + }, + activeFormula: context?.formula ?? 'A', + defaultFormula: 'A', source: 'admin-db', updatedAt: Date.now(), updatedBy: context?.updatedBy ?? null, formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数', }; }, + assignBillingFormula: async (userIds, formula) => ({ + ok: true, + updated: userIds.length, + formula, + }), getRuntimeState: async () => ({ source: 'admin-db', config: stored, diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index 74bf002..5c8defd 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -165,6 +165,7 @@ export async function bootstrapAdminServices() { getPlanAsync: (planType) => planCatalogService.getPlan(planType), }); subscriptionService._planCatalogService = planCatalogService; + userAuth.setSubscriptionService(subscriptionService); const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs'); const templateCatalogService = createPageTemplateCatalogService(pool, { userAuth, diff --git a/src/admin/pages/BillingPage.tsx b/src/admin/pages/BillingPage.tsx index 2e1054f..f454682 100644 --- a/src/admin/pages/BillingPage.tsx +++ b/src/admin/pages/BillingPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { + assignBillingFormula, cancelUserSubscription, createSubscriptionPlan, deleteSubscriptionPlan, @@ -22,6 +23,7 @@ import type { AdminSubscription, AdminUserRow, BillingAdminConfig, + BillingFormulaKey, LedgerEntry, PlanDefinition, UsageRecord, @@ -175,11 +177,298 @@ const DEFAULT_BILLING_FORMULA: BillingAdminConfig = { costEstimateOutputUsdPer1M: 1.1, }; +const FORMULA_TABS: BillingFormulaKey[] = ['A', 'B']; + +function FormulaConfigForm({ + formulaKey, + draft, + source, + updatedAt, + envLocked, + saving, + onDraftChange, + onSave, +}: { + formulaKey: BillingFormulaKey; + draft: BillingAdminConfig; + source: string; + updatedAt: number | null; + envLocked: boolean; + saving: boolean; + onDraftChange: (next: BillingAdminConfig) => void; + onSave: (event: React.FormEvent) => void; +}) { + const patchNumber = (key: keyof BillingAdminConfig, value: string) => { + const num = Number(value); + onDraftChange({ + ...draft, + [key]: Number.isFinite(num) ? num : draft[key], + }); + }; + + return ( +
+

+ 计量公式 {formulaKey} · 来源:{SOURCE_LABELS[source] ?? source} + {updatedAt + ? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}` + : ''} +

+ + + + + + + +

Token 回退单价(成本缺失时)

+ + + +

上游成本估算(Finish 无 cost 时)

+ + + + +

+ 预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier} +

+ + +
+ ); +} + +function FormulaAssignmentSection() { + const { users, reload, error, setError } = useAdminUsers(); + const [selected, setSelected] = useState([]); + const [formulaFilter, setFormulaFilter] = useState<'all' | BillingFormulaKey>('all'); + const [search, setSearch] = useState(''); + const [assigning, setAssigning] = useState(false); + const [message, setMessage] = useState(null); + + const filteredUsers = users.filter((user) => { + if (user.role !== 'user') return false; + const formula = user.billingFormula ?? 'A'; + if (formulaFilter !== 'all' && formula !== formulaFilter) return false; + if (!search.trim()) return true; + const q = search.toLowerCase(); + return user.username.toLowerCase().includes(q) || user.displayName.toLowerCase().includes(q); + }); + + const toggleUser = (userId: string) => { + setSelected((prev) => ( + prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId] + )); + }; + + const toggleAll = () => { + const ids = filteredUsers.map((user) => user.id); + const allSelected = ids.length > 0 && ids.every((id) => selected.includes(id)); + setSelected((prev) => ( + allSelected ? prev.filter((id) => !ids.includes(id)) : [...new Set([...prev, ...ids])] + )); + }; + + const handleAssign = async (formula: BillingFormulaKey) => { + if (!selected.length) return; + setAssigning(true); + setMessage(null); + setError(null); + try { + const result = await assignBillingFormula(selected, formula); + setMessage(`已将 ${result.updated} 位用户分配到计量公式 ${formula}`); + setSelected([]); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : '批量分配失败'); + } finally { + setAssigning(false); + } + }; + + return ( +
+

批量分配计量公式

+

勾选用户后分配到公式 A 或 B。未分配用户默认使用公式 A。

+ {error &&

{error}

} + {message &&

{message}

} +
+ setSearch(event.target.value)} + /> + + + + +
+
+ + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + + ))} + +
+ 0 && filteredUsers.every((user) => selected.includes(user.id))} + onChange={toggleAll} + /> + 用户当前公式余额
+ toggleUser(user.id)} + /> + + {user.displayName} + @{user.username} + {user.billingFormula ?? 'A'}¥{formatYuan(user.balanceCents)}
+
+ {filteredUsers.length === 0 ?

没有匹配的用户

: null} +
+ ); +} + function FormulaTab() { - const [draft, setDraft] = useState(DEFAULT_BILLING_FORMULA); - const [source, setSource] = useState('default'); - const [updatedAt, setUpdatedAt] = useState(null); - const [formula, setFormula] = useState(''); + const [activeFormula, setActiveFormula] = useState('A'); + const [drafts, setDrafts] = useState>({ + A: DEFAULT_BILLING_FORMULA, + B: DEFAULT_BILLING_FORMULA, + }); + const [meta, setMeta] = useState>({ + A: { source: 'default', updatedAt: null }, + B: { source: 'default', updatedAt: null }, + }); + const [formulaText, setFormulaText] = useState(''); const [envLocked, setEnvLocked] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -190,11 +479,22 @@ function FormulaTab() { setLoading(true); setError(null); try { - const result = await getBillingConfig(); - setDraft(result.config); - setSource(result.source ?? 'default'); - setUpdatedAt(result.updatedAt ?? null); - setFormula(result.formula ?? ''); + const result = await getBillingConfig('A'); + setDrafts({ + A: result.formulas?.A ?? result.config, + B: result.formulas?.B ?? result.config, + }); + setMeta({ + A: { + source: result.formulaMeta?.A?.source ?? result.source ?? 'default', + updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null, + }, + B: { + source: result.formulaMeta?.B?.source ?? result.source ?? 'default', + updatedAt: result.formulaMeta?.B?.updatedAt ?? null, + }, + }); + setFormulaText(result.formula ?? ''); setEnvLocked(Boolean(result.envOverrideActive)); } catch (err) { setError(err instanceof Error ? err.message : '加载计量公式失败'); @@ -207,16 +507,9 @@ function FormulaTab() { void load(); }, [load]); - const patchNumber = (key: keyof BillingAdminConfig, value: string) => { - const num = Number(value); - setDraft((prev) => ({ - ...prev, - [key]: Number.isFinite(num) ? num : prev[key], - })); - }; - const handleSave = async (e: React.FormEvent) => { e.preventDefault(); + const draft = drafts[activeFormula]; if (envLocked) { setError('当前环境已锁定为仅读 env(H5_BILLING_CONFIG_SOURCE=env),无法保存。'); return; @@ -228,7 +521,7 @@ function FormulaTab() { if ( draft.useBackendCost && !window.confirm( - `确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`, + `确认保存公式 ${activeFormula} 成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`, ) ) { return; @@ -237,13 +530,24 @@ function FormulaTab() { setError(null); setMessage(null); try { - const result = await updateBillingConfig(draft); - setDraft(result.config); - setSource(result.source ?? 'admin-db'); - setUpdatedAt(result.updatedAt ?? null); - setFormula(result.formula ?? ''); + const result = await updateBillingConfig(draft, activeFormula); + setDrafts({ + A: result.formulas?.A ?? drafts.A, + B: result.formulas?.B ?? drafts.B, + }); + setMeta({ + A: { + source: result.formulaMeta?.A?.source ?? result.source ?? 'admin-db', + updatedAt: result.formulaMeta?.A?.updatedAt ?? result.updatedAt ?? null, + }, + B: { + source: result.formulaMeta?.B?.source ?? result.source ?? 'admin-db', + updatedAt: result.formulaMeta?.B?.updatedAt ?? result.updatedAt ?? null, + }, + }); + setFormulaText(result.formula ?? ''); setEnvLocked(Boolean(result.envOverrideActive)); - setMessage('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。'); + setMessage(`计量公式 ${activeFormula} 已保存。Portal 扣费会在数秒内读取新配置,无需重启。`); } catch (err) { setError(err instanceof Error ? err.message : '保存失败'); } finally { @@ -252,144 +556,45 @@ function FormulaTab() { }; return ( -
-

计量公式

-

- 成本模式:最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数。无上游成本时回退 Token 单价。 -

- {loading ?

加载中…

: null} - {error &&

{error}

} - {message &&

{message}

} - {!loading ? ( -
-

- 当前来源:{SOURCE_LABELS[source] ?? source} - {updatedAt - ? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}` - : ''} -

- {formula ?

{formula}

: null} - - - - - - - -

Token 回退单价(成本缺失时)

- - - -

上游成本估算(Finish 无 cost 时)

- - - - -

- 预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier} -

- - -
- ) : null} -
+ <> +
+

计量公式

+

+ 支持公式 A / B 两套独立配置。用户默认走公式 A,可在下方批量分配到 B。 +

+ {formulaText ?

{formulaText}

: null} +
+ {FORMULA_TABS.map((key) => ( + + ))} +
+ {loading ?

加载中…

: null} + {error &&

{error}

} + {message &&

{message}

} + {!loading ? ( + setDrafts((prev) => ({ ...prev, [activeFormula]: next }))} + onSave={handleSave} + /> + ) : null} +
+ + ); } @@ -1545,6 +1750,12 @@ function SubscriptionsTab() { }; const STATUS_LABEL: Record = { active: '有效', expired: '已到期', cancelled: '已取消' }; + const autoRenewLabel = (row: AdminSubscription) => { + if (!row.autoRenew) return '未开启'; + if (row.status === 'active') return '已开启'; + if (row.status === 'expired') return '待补扣'; + return '—'; + }; return ( <> @@ -1637,6 +1848,7 @@ function SubscriptionsTab() { 用户 套餐 状态 + 自动续费 Token 用量 图片用量 到期时间 @@ -1657,6 +1869,11 @@ function SubscriptionsTab() { {STATUS_LABEL[row.status] ?? row.status} + + + {autoRenewLabel(row)} + + {row.periodTokensUsed.toLocaleString()} {row.periodTokensLimit > 0 && ( diff --git a/src/admin/pages/UserDetailPage.tsx b/src/admin/pages/UserDetailPage.tsx index 0bc6912..609b828 100644 --- a/src/admin/pages/UserDetailPage.tsx +++ b/src/admin/pages/UserDetailPage.tsx @@ -273,6 +273,29 @@ export function UserDetailPage() { )} +
+
计量公式
+
+ +
+
工作目录
{user.workspaceRoot}
diff --git a/src/api/client.ts b/src/api/client.ts index 96ac62c..098fb3a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -887,6 +887,7 @@ export async function updateAdminUser( balanceCents: number; spaceQuotaBytes: number; role: 'user' | 'admin'; + billingFormula: 'A' | 'B'; }>, ): Promise { const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, { @@ -1409,16 +1410,28 @@ export async function syncSubscriptionPlansToProduction(): Promise { - return portalFetch('/admin-api/billing/config'); +export async function getBillingConfig(formula: 'A' | 'B' = 'A'): Promise { + const q = formula === 'B' ? '?formula=B' : ''; + return portalFetch(`/admin-api/billing/config${q}`); } export async function updateBillingConfig( config: BillingAdminConfig, + formula: 'A' | 'B' = 'A', ): Promise { return portalFetch('/admin-api/billing/config', { method: 'PUT', - body: JSON.stringify({ config }), + body: JSON.stringify({ config, formula }), + }); +} + +export async function assignBillingFormula( + userIds: string[], + formula: 'A' | 'B', +): Promise<{ ok: boolean; updated: number; formula: 'A' | 'B'; message?: string }> { + return portalFetch('/admin-api/billing/formula-assignments', { + method: 'POST', + body: JSON.stringify({ userIds, formula }), }); } diff --git a/src/types.ts b/src/types.ts index a6e81e7..6a6087b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,7 @@ export type PortalUser = { balanceCents: number; totalCreditCents?: number; tokensUsed: number; + billingFormula?: 'A' | 'B'; spaceQuotaBytes?: number; spaceUsedBytes?: number; spaceReservedBytes?: number; @@ -44,6 +45,7 @@ export type AuthStatus = { }; export type AdminUserRow = PortalUser & { + billingFormula?: 'A' | 'B'; createdAt: number; updatedAt: number; }; @@ -676,8 +678,20 @@ export type BillingAdminConfig = { costEstimateOutputUsdPer1M: number; }; +export type BillingFormulaKey = 'A' | 'B'; + +export type BillingFormulaMeta = { + updatedAt: number | null; + updatedBy: string | null; + source?: string; +}; + export type BillingAdminConfigResponse = { config: BillingAdminConfig; + formulas?: Record; + formulaMeta?: Record; + activeFormula?: BillingFormulaKey; + defaultFormula?: BillingFormulaKey; updatedAt: number | null; updatedBy: string | null; source?: string; @@ -722,6 +736,7 @@ export type AdminSubscription = { periodImagesLimit: number; periodImagesUsed: number; periodImagesBonus?: number; + autoRenew?: boolean; note: string | null; createdAt: number; };