Files
sundynix-agentix/sundynix-web/src/api.ts
T
Blizzard 70b0419387 fix(payment): 支付轮询链路收口——查单节流 + 前端轮询重写
后端:
- 主动查单加最小间隔(5s)。此前 BillingOrderStatus 见 pending 就直连微信查单,
  而前端 2.5s 轮一次 —— 单笔订单在 30min TTL 内可打出约 720 次渠道调用,
  微信侧有频控,并发用户一多先被限流的是我们自己。回调才是入账主路径,
  查单只是兜底,节流最坏只让在场用户多等 5s。
- 标记随订单落终态清除,并在补偿定时器每轮 prune 掉超 TTL 的残留
  (用户扫码前就关弹窗的订单不会再被轮询,其标记无人回收)。
- 下单响应补 expires_at:二维码有效期由服务端 orderTTL 说了算,
  前端硬编码一份迟早漂移。

前端(sundynix-web):
- orderStatus 不再丢掉 warn。服务端在「已付但金额与订单不符」时不入账、
  挂起人工核对,订单一直停在 pending —— 丢掉 warn 用户就会一直等一个
  永远不会来的结果。现在弹窗显式告警并给出订单号。
- setInterval → 递归 setTimeout:请求慢于间隔时 setInterval 会把请求摞起来,
  对「每次可能触发渠道查单」的端点尤其糟。
- 标签页切走时暂停轮询(用户扫完码要切去微信 App),切回前台立刻补查一次。
- 连续失败指数退避至 15s 封顶,断网时不再定频猛打。
- 二维码倒计时 + 失效遮罩,过期不再让用户扫一个必然失败的码。
- 明说「关掉也不会丢钱,到账会自动补上」——掉单补偿定时器本就兜底,
  但此前 UI 没讲,用户只能守着弹窗。
- 关闭弹窗一律刷新余额:用户可能在关闭前一刻付款、状态刚落地。

测试:新增 handler 包首个测试,钉住节流与标记回收行为(-race 通过)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:46:40 +08:00

335 lines
11 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.
// 薄 Web 面的 Gateway 客户端。auth/tenant/usage 段与桌面端 lib/api.ts 同构(纯 fetch
// token 走 localStorage `sdx_token`——localStorage 按 origin 隔离,与桌面端预览不冲突);
// 成员管理/建组织打租户自助接口(/tenants/current/members、/me/tenants)。
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
export interface AuthUser {
id: string;
email: string;
name?: string;
}
// ---- JWT 令牌存储 ----
const TOKEN_KEY = "sdx_token";
let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
export function setToken(t: string): void {
authToken = t;
try {
localStorage.setItem(TOKEN_KEY, t);
} catch {
/* 隐私模式忽略 */
}
}
export function clearToken(): void {
authToken = "";
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
function bearer(): Record<string, string> {
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
}
// guard401 收到 401 清令牌并广播登出(App 监听后回登录页)。
function guard401(res: Response): Response {
if (res.status === 401) {
clearToken();
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
}
return res;
}
// jsonOrThrow 统一「非 2xx 抛后端 error 文案」的小助手。
async function jsonOrThrow<T>(res: Response, fallback: string): Promise<T> {
const d = (await res.json().catch(() => ({}))) as T & { error?: string };
if (!res.ok) throw new Error(d.error ?? `${fallback}: ${res.status}`);
return d;
}
// ---- 鉴权 ----
export async function authRegister(email: string, password: string, name: string): Promise<AuthUser> {
const res = await fetch(`${GATEWAY}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name }),
});
const d = await jsonOrThrow<{ token?: string; user?: AuthUser }>(res, "注册失败");
if (!d.token || !d.user) throw new Error("注册失败");
setToken(d.token);
return d.user;
}
export async function authLogin(email: string, password: string): Promise<AuthUser> {
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const d = await jsonOrThrow<{ token?: string; user?: AuthUser }>(res, "登录失败");
if (!d.token || !d.user) throw new Error("登录失败");
setToken(d.token);
return d.user;
}
export async function authMe(): Promise<AuthUser | null> {
if (!authToken) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
if (!res.ok) {
clearToken();
return null;
}
const d = (await res.json()) as { user?: AuthUser };
return d.user ?? null;
}
export function logout(): void {
clearToken();
}
// ---- 租户上下文 ----
export interface TenantCtx {
tenant: { id: string; name: string; plan: string; status: string; shared_billing?: boolean } | null;
role: string;
credit_balance_micro: number;
credit_enforce: boolean;
}
export async function tenantCurrent(): Promise<TenantCtx | null> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current`, { headers: bearer() }));
if (!res.ok) return null;
const d = (await res.json()) as Partial<TenantCtx>;
return {
tenant: d.tenant ?? null,
role: d.role ?? "",
credit_balance_micro: d.credit_balance_micro ?? 0,
credit_enforce: !!d.credit_enforce,
};
}
export interface MyTenant {
id: string;
name: string;
slug: string;
plan: string;
credit_balance_micro: number;
shared_billing: boolean;
members: number;
}
export async function myTenants(): Promise<{ tenants: MyTenant[]; active: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/tenants`, { headers: bearer() }));
if (!res.ok) return { tenants: [], active: "" };
const d = (await res.json()) as { tenants?: MyTenant[]; active?: string };
return { tenants: d.tenants ?? [], active: d.active ?? "" };
}
export async function switchTenant(tenantId: string): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/me/tenant`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ tenant_id: tenantId }),
}),
);
await jsonOrThrow(res, "切换失败");
}
// createTenant 自助建组织:创建者即 owner,后端建完自动切入。
export async function createTenant(name: string): Promise<{ id: string; name: string }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/me/tenants`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ name }),
}),
);
const d = await jsonOrThrow<{ tenant?: { id: string; name: string } }>(res, "创建失败");
if (!d.tenant) throw new Error("创建失败");
return d.tenant;
}
// ---- 成员自助管理(作用于当前活跃租户;写操作后端要求 ≥admin)----
export interface Member {
user_id: string;
email: string;
name: string;
role: string;
status: string;
joined_at: string;
}
export async function listMembers(): Promise<Member[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current/members`, { headers: bearer() }));
if (!res.ok) return [];
const d = (await res.json()) as { members?: Member[] };
return d.members ?? [];
}
export async function addMember(email: string, role: string): Promise<Member> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/tenants/current/members`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ email, role }),
}),
);
const d = await jsonOrThrow<{ member?: Member }>(res, "邀请失败");
if (!d.member) throw new Error("邀请失败");
return d.member;
}
export async function setMemberRole(userId: string, role: string): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/tenants/current/members/${userId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ role }),
}),
);
await jsonOrThrow(res, "改角色失败");
}
export async function removeMember(userId: string): Promise<void> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/tenants/current/members/${userId}`, {
method: "DELETE",
headers: bearer(),
}),
);
await jsonOrThrow(res, "移除失败");
}
// ---- 我的用量(余额 + 按天趋势 + 最近消耗)----
export interface UsageDay {
day: string; // YYYYMMDD
total_tok: number;
credits_micro: number;
cost_micros: number;
task_count: number;
}
export interface UsageRecent {
task_id: string;
model: string;
total_tok: number;
credits_micro: number;
cost_micros: number;
currency: string;
ts: number;
}
export interface MyUsage {
from: string;
to: string;
balance_micro: number;
credit_enforce: boolean;
trend: UsageDay[];
totals: { total_tok: number; credits_micro: number; cost_micros: number; task_count: number };
recent: UsageRecent[];
}
export async function myUsage(days = 30): Promise<MyUsage> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/me/usage?days=${days}`, { headers: bearer() }));
if (!res.ok) throw new Error(`usage failed: ${res.status}`);
const d = (await res.json()) as Partial<MyUsage>;
return {
from: d.from ?? "",
to: d.to ?? "",
balance_micro: d.balance_micro ?? 0,
credit_enforce: !!d.credit_enforce,
trend: d.trend ?? [],
totals: d.totals ?? { total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 },
recent: d.recent ?? [],
};
}
// ---- 充值(P5.1 兑换码;微信支付 P5.2 上线)----
export interface TopupOrder {
id: string;
credits_micro: number;
amount_fen: number;
channel: string;
status: string;
created_at: string;
}
export interface Pack {
id: string;
name: string;
credits_micro: number;
price_fen: number;
}
// billingPacks 在售积分包 + 可用渠道(服务端配了微信 env 才会出现 "wechat")。
export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[] }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/packs`, { headers: bearer() }));
if (!res.ok) return { packs: [], channels: [] };
const d = (await res.json()) as { packs?: Pack[]; channels?: string[] };
return { packs: d.packs ?? [], channels: d.channels ?? [] };
}
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
export async function createWechatOrder(
packId: string,
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ pack_id: packId }),
}),
);
return jsonOrThrow(res, "下单失败");
}
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
export async function orderStatus(orderId: string): Promise<{ order: TopupOrder; warn?: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() }));
return jsonOrThrow<{ order: TopupOrder; warn?: string }>(res, "查询失败");
}
// redeemCode 核销兑换码,返回入账后的余额。
export async function redeemCode(code: string): Promise<{ balance_micro: number }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/redeem`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify({ code }),
}),
);
return jsonOrThrow<{ balance_micro: number }>(res, "兑换失败");
}
// billingOrders 计费租户最近充值记录。
export async function billingOrders(): Promise<TopupOrder[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders`, { headers: bearer() }));
if (!res.ok) return [];
const d = (await res.json()) as { orders?: TopupOrder[] };
return d.orders ?? [];
}
// 积分显示:micro(1e6) → 人类可读。与桌面端同一约定;小数去尾零(1.50 → 1.5)。
export function fmtCredits(micro: number): string {
const v = micro / 1e6;
if (Math.abs(v) >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (Math.abs(v) >= 1e3) return `${(v / 1e3).toFixed(1)}K`;
return `${parseFloat(v.toFixed(2))}`;
}
// 角色中文名(角色本身英文入库,仅展示层翻译)。
export const ROLE_LABEL: Record<string, string> = {
owner: "所有者",
admin: "管理员",
member: "成员",
viewer: "只读",
billing_admin: "财务",
};
// 邀请/改角色可选项(owner 不可授予——须转让,后端也拦)。
export const ASSIGNABLE_ROLES = ["admin", "member", "viewer", "billing_admin"] as const;