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 (
+
+ );
+}
+
+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.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 ? (
-
- ) : 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;
};
|