feat(admin): 计费页长出「充值渠道」块 —— 兑换码生成/台账 + 积分包定价

P5.1 收尾:此前生成码只有 API。计费页现在从上到下 = 计费规则(积分→token
汇率) → 充值渠道(钱→积分:兑换码 + 微信定价用的积分包) → 用量观测,
两层汇率在同一页可见、各管各的。

- 兑换码:面额/张数/备注生成;**明文码只在生成响应显示一次**(等同现金,
  台账 GET /admin/redeem-codes 服务端脱敏只露首尾,丢码重生成、不提供找回
  ——顺手把接口这个第二明文出口堵了);台账含核销状态。
- 积分包:新增/上下架(微信 P5.2 上线前把定价面备好);admin api.ts 补
  packs/redeem-codes 四个函数。
- launch.json 加 admin-console-alt(:5176)——5174 被用户自己的 sundynix-site
  占着,不动别人端口。
live:5176 登录→生成 5 张(绿色一次性面板+复制全部)→配「入门包 1000分/¥9.9」
在售可下架→台账脱敏 curl 复核;tsc+41 vitest 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-17 10:28:03 +08:00
parent 8e05f4b3fe
commit 929bbf334b
5 changed files with 320 additions and 1 deletions
+51
View File
@@ -175,6 +175,57 @@ export async function grantCredits(tenantId: string, credits: number, memo: stri
return d.balance_micro ?? 0;
}
// ---- 充值渠道(P5.1):积分包配置 + 兑换码 ----
export interface CreditPack {
id: string;
name: string;
credits_micro: number;
price_fen: number;
active: boolean;
sort: number;
}
export async function adminPacks(): Promise<CreditPack[]> {
const res = guard(await fetch(`${ADMIN}/packs`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { packs?: CreditPack[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `packs failed: ${res.status}`);
return d.packs ?? [];
}
// savePackid 空 = 新建。credits 单位为「积分」(面向人,服务端转 micro)。
export async function savePack(p: { id?: string; name: string; credits: number; price_fen: number; active: boolean; sort: number }): Promise<void> {
const res = guard(await fetch(`${ADMIN}/packs`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
const d = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
}
export interface RedeemCodeRow {
id: string;
code: string;
credits_micro: number;
status: string; // unused / used
used_tenant: string;
used_at: string | null;
memo: string;
created_at: string;
}
export async function genRedeemCodes(credits: number, count: number, memo: string): Promise<string[]> {
const res = guard(
await fetch(`${ADMIN}/redeem-codes`, { method: "POST", headers: authHeaders(true), body: JSON.stringify({ credits, count, memo }) }),
);
const d = (await res.json().catch(() => ({}))) as { codes?: string[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `生成失败: ${res.status}`);
return d.codes ?? [];
}
export async function listRedeemCodes(): Promise<RedeemCodeRow[]> {
const res = guard(await fetch(`${ADMIN}/redeem-codes`, { headers: authHeaders() }));
const d = (await res.json().catch(() => ({}))) as { codes?: RedeemCodeRow[]; error?: string };
if (!res.ok) throw new Error(d.error ?? `codes failed: ${res.status}`);
return d.codes ?? [];
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {