Files
sundynix-agentix/sundynix-web/src/pages/Usage.tsx
T
Blizzard efd185b779 feat(billing): 支付 P5.2 —— 微信支付 Native 渠道(扫码充值)
wechatpay-go v0.2.21。凭据全 env 注入(WECHAT_MCHID/MCH_CERT_SERIAL/
MCH_PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL),缺一渠道即隐藏——半配置/假凭据
只打日志不拖垮 gateway(用假私钥实测过降级)。

- internal/payment:Native 下单出 code_url、APIv3 回调验签解密、主动查单,
  三者统一收敛为 QueryResult。
- 下单 POST /billing/orders {pack_id}(≥member+审计):金额/积分按在售包服务端
  锁定进订单行,不信任客户端;渠道下单失败即作废,不留付不了的 pending。
- 到账两条路汇入同一个 MarkOrderPaid 幂等闸(CAS+唯一索引双闸,同 P5.1):
  ①公开回调路由(验签是唯一的门;金额与订单不符不入账);②前端轮询的
  GET /billing/orders/:id 在 pending 时顺路主动查单——本地/内网收不到公网
  回调也能确认到账,回调只是生产更快的通道。pending 超 30 分钟置 expired。
- Web 面:在售包卡片(渠道亮才出现)→扫码弹窗(qrcode 画 code_url,二维码底色
  固定纯白——暗色主题下低对比码扫不出来)→2.5s 轮询→到账 toast+刷余额。

验证:go/tsc/vitest 全绿;无凭据+假凭据两种降级 live 四连
(channels 只剩 redeem/下单 400 引导兑换码/回调 503/兑换码闭环不受影响)。
⚠️ 真通道(prepay→扫码→回调/查单→入账)需真实商户号,未 live——用户配好
env 后用小额包实测,建议先配 ¥0.01 测试包走一单。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:39:24 +08:00

276 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react";
import { useTenant } from "../shell/AppShell";
import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, type MyUsage, type TopupOrder, type Pack } from "../api";
import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui";
// 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。
// 微信扫码支付在 P5.2 挂上(渠道适配器已留位),此前不做假支付入口。
export function Usage() {
const toast = useToast();
const { ctx, refresh } = useTenant();
const [days, setDays] = useState(7);
const [u, setU] = useState<MyUsage | null>(null);
const [orders, setOrders] = useState<TopupOrder[]>([]);
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [packs, setPacks] = useState<Pack[]>([]);
const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮
const [paying, setPaying] = useState<Pack | null>(null); // 正在扫码支付的包
const load = useCallback(() => {
myUsage(days).then(setU).catch(() => {});
billingOrders().then(setOrders).catch(() => {});
billingPacks()
.then((r) => {
setPacks(r.packs);
setWechatOn(r.channels.includes("wechat"));
})
.catch(() => {});
}, [days]);
useEffect(load, [load, ctx?.tenant?.id]);
const canTopup = ctx?.role !== "viewer"; // 真闸在后端(≥member),这里只是不摆没用的输入框
const redeem = async () => {
if (!code.trim() || busy) return;
setBusy(true);
try {
const r = await redeemCode(code.trim());
toast.push("success", `已入账,当前余额 ${fmtCredits(r.balance_micro)} 积分`);
setCode("");
load();
refresh(); // 顶栏/概览的余额也跟着变
} catch (e) {
toast.push("error", (e as Error).message);
} finally {
setBusy(false);
}
};
const maxTok = Math.max(...(u?.trend.map((d) => d.total_tok) ?? []), 1);
const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${n}`);
return (
<div className="mx-auto max-w-3xl space-y-4 p-8">
<div>
<h1 className="text-xl font-semibold tracking-tight text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500">{ctx?.tenant?.name ?? "…"} · </p>
</div>
{/* 余额 hero */}
<div className="rounded-xl border border-line bg-ink-900 p-5 shadow-card">
<div className="flex items-end justify-between">
<div>
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-3xl font-semibold tabular-nums text-slate-100">
{fmtCredits(u?.balance_micro ?? ctx?.credit_balance_micro ?? 0)}
<span className="ml-1.5 text-sm font-normal text-slate-500"></span>
</div>
</div>
{u?.credit_enforce && (u?.balance_micro ?? 0) <= 0 ? (
<Badge tone="danger"></Badge>
) : null}
</div>
{canTopup ? (
<div className="mt-4 border-t border-line pt-4">
{/* 微信扫码:服务端配了商户号且有在售包才出现 */}
{wechatOn && packs.length > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<QrCode className="h-3.5 w-3.5" />
</div>
<div className="flex flex-wrap gap-2">
{packs.map((p) => (
<button key={p.id} onClick={() => setPaying(p)}
className="group rounded-lg border border-line bg-ink-850 px-4 py-2.5 text-left transition-colors hover:border-brand/50">
<div className="text-sm font-medium text-slate-200">{p.name}</div>
<div className="mt-0.5 text-xs text-slate-500">
<span className="tabular-nums text-brand-400">{fmtCredits(p.credits_micro)}</span> ·
<span className="ml-1 tabular-nums">¥{(p.price_fen / 100).toFixed(2)}</span>
</div>
</button>
))}
</div>
</div>
)}
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<Ticket className="h-3.5 w-3.5" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="w-56 font-mono uppercase placeholder:font-sans placeholder:normal-case"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && redeem()}
placeholder="SDX-XXXX-XXXX-XXXX"
/>
<Button variant="primary" onClick={redeem} disabled={busy || !code.trim()}>
{busy ? "入账中…" : "兑换"}
</Button>
<span className="text-[11px] text-slate-600">
{wechatOn ? "兑换码请向平台获取。" : "微信扫码支付即将上线;兑换码请向平台获取。"}
</span>
</div>
</div>
) : (
<p className="mt-3 text-[11px] text-slate-600"></p>
)}
</div>
{/* 趋势 */}
<Panel
title="消耗趋势"
icon={Coins}
actions={
<div className="flex items-center gap-1 rounded-md border border-line p-0.5">
{[7, 30].map((d) => (
<button key={d} onClick={() => setDays(d)}
className={cn("rounded px-2 py-0.5 text-[11px] transition", days === d ? "bg-ink-800 text-slate-100" : "text-slate-500 hover:text-slate-300")}>
{d}
</button>
))}
</div>
}>
<div className="flex h-28 items-end gap-1.5">
{(u?.trend ?? []).map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div className="w-full rounded-t bg-brand/60 transition-all hover:bg-brand"
style={{ height: `${Math.max((d.total_tok / maxTok) * 100, 2)}%` }}
title={`${d.day}${fmtTok(d.total_tok)} tok · ${fmtCredits(d.credits_micro)} 积分 · ${d.task_count}`} />
<span className="text-[9px] tabular-nums text-slate-600">{d.day.slice(-2)}</span>
</div>
))}
{(!u || u.trend.length === 0) && <p className="flex-1 self-center text-center text-xs text-slate-600"></p>}
</div>
<div className="mt-3 flex gap-4 border-t border-line pt-3 text-[11px] text-slate-500">
<span> <span className="tabular-nums text-slate-300">{fmtTok(u?.totals.total_tok ?? 0)}</span> tok</span>
<span><span className="tabular-nums text-slate-300">{fmtCredits(u?.totals.credits_micro ?? 0)}</span> </span>
<span><span className="tabular-nums text-slate-300">{u?.totals.task_count ?? 0}</span> </span>
</div>
</Panel>
{/* 最近消耗 */}
<Panel title="最近消耗" icon={ReceiptText} bodyClassName="p-0">
<Table cols={["任务", "模型", "tokens", "积分"]}>
{(u?.recent ?? []).map((r) => (
<Tr key={r.task_id}>
<Td><span className="font-mono text-[11px] text-slate-400">{r.task_id}</span></Td>
<Td><span className="text-xs text-slate-300">{r.model || "—"}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-300">{fmtTok(r.total_tok)}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-300">{fmtCredits(r.credits_micro)}</span></Td>
</Tr>
))}
</Table>
{(!u || u.recent.length === 0) && <p className="p-4 text-xs text-slate-600"></p>}
</Panel>
{/* 最近充值(订单流:兑换码与将来的微信支付一个查法) */}
{orders.length > 0 && (
<Panel title="最近充值" icon={Ticket} bodyClassName="p-0">
<Table cols={["时间", "渠道", "积分", "状态"]}>
{orders.map((o) => (
<Tr key={o.id}>
<Td><span className="text-xs text-slate-400">{new Date(o.created_at).toLocaleString()}</span></Td>
<Td><span className="text-xs text-slate-300">{o.channel === "redeem" ? "兑换码" : o.channel === "wechat" ? "微信支付" : o.channel}</span></Td>
<Td><span className="text-xs tabular-nums text-slate-200">+{fmtCredits(o.credits_micro)}</span></Td>
<Td>
<Badge tone={o.status === "paid" ? "success" : o.status === "pending" ? "warn" : "danger"}>
{o.status === "paid" ? "已入账" : o.status === "pending" ? "待支付" : o.status}
</Badge>
</Td>
</Tr>
))}
</Table>
</Panel>
)}
{paying && (
<PayDialog
pack={paying}
onClose={(paid) => {
setPaying(null);
if (paid) {
toast.push("success", "支付成功,积分已入账");
load();
refresh();
}
}}
/>
)}
</div>
);
}
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态(2.5s;
// 服务端 pending 时会顺路主动查单,收不到公网回调的环境也能确认到账)。
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
const orderRef = useRef("");
useEffect(() => {
let alive = true;
let timer: number | null = null;
(async () => {
try {
const o = await createWechatOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
setState("waiting");
timer = window.setInterval(async () => {
try {
const s = await orderStatus(orderRef.current);
if (!alive) return;
if (s.status === "paid") {
setState("paid");
if (timer) window.clearInterval(timer);
window.setTimeout(() => onClose(true), 800);
} else if (s.status === "expired" || s.status === "failed") {
setState("expired");
if (timer) window.clearInterval(timer);
}
} catch {
/* 单次轮询失败忽略,下个 tick 再试 */
}
}, 2500);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
return (
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
<div className="flex flex-col items-center gap-3 py-2">
{err ? (
<p className="text-xs text-danger">{err}</p>
) : state === "creating" ? (
<p className="py-16 text-xs text-slate-500"></p>
) : (
<>
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
<div className="rounded-lg bg-white p-2">
<img src={qr} alt="微信支付二维码" width={240} height={240} />
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
<div className="mt-1 text-xs text-slate-500">
{state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
</div>
</div>
</>
)}
</div>
</Dialog>
);
}