70b0419387
后端: - 主动查单加最小间隔(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>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
// 薄 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;
|