diff --git a/.claude/launch.json b/.claude/launch.json index 9b1f5d7..0a0539a 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -15,6 +15,13 @@ "cwd": "sundynix-admin", "port": 5174 }, + { + "name": "admin-console-alt", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5176"], + "cwd": "sundynix-admin", + "port": 5176 + }, { "name": "web-face", "runtimeExecutable": "npm", diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index cdad392..a9afd13 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -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 { + 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 ?? []; +} + +// savePack:id 空 = 新建。credits 单位为「积分」(面向人,服务端转 micro)。 +export async function savePack(p: { id?: string; name: string; credits: number; price_fen: number; active: boolean; sort: number }): Promise { + 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 { + 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 { + 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 { try { diff --git a/sundynix-admin/src/components/TopupChannels.tsx b/sundynix-admin/src/components/TopupChannels.tsx new file mode 100644 index 0000000..681f4e5 --- /dev/null +++ b/sundynix-admin/src/components/TopupChannels.tsx @@ -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 ( +
+
+

充值渠道

+ 兑换码即刻可用;微信扫码支付(P5.2)待商户号 +
+
+ + +
+
+ ); +} + +// ---- 兑换码:生成 + 台账 ---- +function RedeemBlock() { + const [rows, setRows] = useState([]); + const [creditsIn, setCreditsIn] = useState("100"); + const [count, setCount] = useState("5"); + const [memo, setMemo] = useState(""); + const [fresh, setFresh] = useState([]); // 刚生成的明文码(只此一屏) + 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 ( +
+
+

兑换码

+ 用户在 Web 面「用量与账单」输码入账 +
+ +
+ + + + +
+ {err &&

{err}

} + + {fresh.length > 0 && ( +
+
+ 已生成 {fresh.length} 张——明文码只显示这一次,请立即复制发放 + +
+
+ {fresh.map((c) => ( + {c} + ))} +
+
+ )} + +
+ + + + + + + + + + + {rows.map((r) => ( + + {/* 服务端已脱敏(只露首尾):完整明文只在生成响应里给一次 */} + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
面额状态备注
{r.code}{credits(r.credits_micro)} + {r.status === "used" ? ( + 已核销 + ) : ( + 未使用 + )} + {r.memo || "—"}
还没有兑换码
+
+
+ ); +} + +// ---- 积分包:钱→积分定价(微信支付上线后用户按包扫码) ---- +function PacksBlock() { + const [rows, setRows] = useState([]); + 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 ( +
+
+

积分包(微信支付定价)

+ 钱→积分;积分→token 的汇率在上方计费规则 +
+ +
+ + + + +
+ {err &&

{err}

} + +
+ + + + + + + + + + + {rows.map((p) => ( + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
名称积分售价状态
{p.name}{credits(p.credits_micro)}¥{(p.price_fen / 100).toFixed(2)} + +
还没有积分包——微信支付上线前配好即可
+
+
+ ); +} diff --git a/sundynix-admin/src/pages/UsagePage.tsx b/sundynix-admin/src/pages/UsagePage.tsx index 9ddac07..6b3767e 100644 --- a/sundynix-admin/src/pages/UsagePage.tsx +++ b/sundynix-admin/src/pages/UsagePage.tsx @@ -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() { {/* 配置端:计费规则(改规则即对后续任务生效) */} void load()} /> + {/* 配置端:充值渠道(兑换码生成/台账 + 积分包定价,P5.1) */} + + {/* 观测端:用量结果 */}

用量观测

diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 6fd3c1b..98be862 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -84,13 +84,20 @@ func (h *Handler) AdminGenRedeemCodes(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"codes": codes}) } -// AdminRedeemCodes: GET /api/v1/admin/redeem-codes —— 兑换码列表(含核销状态)。 +// AdminRedeemCodes: GET /api/v1/admin/redeem-codes —— 兑换码台账(含核销状态)。 +// 码在台账里脱敏只露首尾:完整明文只在生成响应里给一次。兑换码等同现金, +// 常驻可查的列表接口不该是第二个明文出口(丢了码就重新生成一张,不提供找回)。 func (h *Handler) AdminRedeemCodes(c *gin.Context) { rows, err := h.db.ListRedeemCodes(c.Request.Context(), 200) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } + for i := range rows { + if n := len(rows[i].Code); n > 12 { + rows[i].Code = rows[i].Code[:8] + "…" + rows[i].Code[n-4:] + } + } c.JSON(http.StatusOK, gin.H{"codes": rows}) }