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 {
@@ -0,0 +1,250 @@
import { useEffect, useState } from "react";
import { adminPacks, savePack, genRedeemCodes, listRedeemCodes, type CreditPack, type RedeemCodeRow } from "../api";
// 充值渠道(P5.1,设计见 PAYMENT_DESIGN.md):
// - 积分包:钱→积分的第一层汇率(第二层 积分→token 在上方「计费规则」里,两层解耦)。
// 微信支付(P5.2)上线前包只做展示位,这里先把配置面备好。
// - 兑换码:零资质渠道 + 线下打款核销通道。生成后明文码只显示一次(列表页常驻展示
// 等于把「钱」贴在墙上,别这么干)。
const MICRO = 1_000_000;
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
export function TopupChannels() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>
<span className="text-[11px] text-gray-400">P5.2</span>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<RedeemBlock />
<PacksBlock />
</div>
</div>
);
}
// ---- 兑换码:生成 + 台账 ----
function RedeemBlock() {
const [rows, setRows] = useState<RedeemCodeRow[]>([]);
const [creditsIn, setCreditsIn] = useState("100");
const [count, setCount] = useState("5");
const [memo, setMemo] = useState("");
const [fresh, setFresh] = useState<string[]>([]); // 刚生成的明文码(只此一屏)
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const load = () => listRedeemCodes().then(setRows).catch((e) => setErr((e as Error).message));
useEffect(() => {
void load();
}, []);
const gen = async () => {
const c = Number(creditsIn);
const n = Number(count);
if (!c || c <= 0 || !n || n <= 0 || busy) return;
setBusy(true);
setErr("");
try {
setFresh(await genRedeemCodes(c, n, memo.trim()));
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
const copyAll = () => void navigator.clipboard?.writeText(fresh.join("\n"));
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400"> Web </span>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-gray-500">
<input value={creditsIn} onChange={(e) => setCreditsIn(e.target.value)} inputMode="numeric"
className="mt-1 block w-24 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={count} onChange={(e) => setCount(e.target.value)} inputMode="numeric"
className="mt-1 block w-16 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="flex-1 text-xs text-gray-500">
/
<input value={memo} onChange={(e) => setMemo(e.target.value)} placeholder="如:X 公司 PoC"
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<button onClick={() => void gen()} disabled={busy}
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "生成中…" : "生成"}
</button>
</div>
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
{fresh.length > 0 && (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-emerald-700"> {fresh.length} </span>
<button onClick={copyAll} className="rounded border border-emerald-300 px-2 py-0.5 text-[11px] text-emerald-700 hover:bg-emerald-100">
</button>
</div>
<div className="grid grid-cols-1 gap-0.5 font-mono text-xs text-emerald-900 md:grid-cols-2">
{fresh.map((c) => (
<span key={c}>{c}</span>
))}
</div>
</div>
)}
<div className="mt-4 max-h-56 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-gray-50 last:border-0">
{/* 服务端已脱敏(只露首尾):完整明文只在生成响应里给一次 */}
<td className="py-1.5 pr-3 font-mono text-xs text-gray-600">{r.code}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(r.credits_micro)}</td>
<td className="py-1.5 pr-3">
{r.status === "used" ? (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500"></span>
) : (
<span className="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">使</span>
)}
</td>
<td className="py-1.5 text-xs text-gray-400">{r.memo || "—"}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-xs text-gray-400"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ----
function PacksBlock() {
const [rows, setRows] = useState<CreditPack[]>([]);
const [name, setName] = useState("");
const [creditsIn, setCreditsIn] = useState("");
const [yuan, setYuan] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const load = () => adminPacks().then(setRows).catch((e) => setErr((e as Error).message));
useEffect(() => {
void load();
}, []);
const add = async () => {
const c = Number(creditsIn);
const y = Number(yuan);
if (!name.trim() || !c || c <= 0 || y < 0 || busy) return;
setBusy(true);
setErr("");
try {
await savePack({ name: name.trim(), credits: c, price_fen: Math.round(y * 100), active: true, sort: rows.length });
setName("");
setCreditsIn("");
setYuan("");
await load();
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
const toggle = async (p: CreditPack) => {
try {
await savePack({ id: p.id, name: p.name, credits: p.credits_micro / MICRO, price_fen: p.price_fen, active: !p.active, sort: p.sort });
await load();
} catch (e) {
setErr((e as Error).message);
}
};
return (
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-700"></h4>
<span className="text-[11px] text-gray-400">token </span>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="flex-1 text-xs text-gray-500">
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:入门包"
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
<input value={creditsIn} onChange={(e) => setCreditsIn(e.target.value)} inputMode="numeric"
className="mt-1 block w-24 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<label className="text-xs text-gray-500">
¥
<input value={yuan} onChange={(e) => setYuan(e.target.value)} inputMode="decimal"
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
</label>
<button onClick={() => void add()} disabled={busy}
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
{busy ? "…" : "新增"}
</button>
</div>
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
<div className="mt-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
<th className="py-2 pr-3 font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 pr-3 text-right font-medium"></th>
<th className="py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(p.credits_micro)}</td>
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{(p.price_fen / 100).toFixed(2)}</td>
<td className="py-2 text-right">
<button onClick={() => void toggle(p)}
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="py-6 text-center text-xs text-gray-400">线</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+4
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { adminUsage, grantCredits, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
import { BillingRules } from "../components/BillingRules";
import { TopupChannels } from "../components/TopupChannels";
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
@@ -97,6 +98,9 @@ export function UsagePage() {
{/* 配置端:计费规则(改规则即对后续任务生效) */}
<BillingRules onSaved={() => void load()} />
{/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价,P5.1) */}
<TopupChannels />
{/* 观测端:用量结果 */}
<div className="flex items-center gap-2 pt-1">
<h3 className="text-sm font-semibold text-gray-700"></h3>