Files
sundynix-agentix/sundynix-web/src/api.ts
T
Blizzard caa2602131 feat(web): 用户面订阅 —— 购买/续订入口 + 到期提醒
账单页新增:当前订阅状态(套餐/发放节奏/已发放次数/到期倒计时)+ 可购套餐。
已有订阅时购买区标题自动变成「续订(在当前到期时间上顺延)」,与后端的顺延
语义一致,免得用户以为会新开一条。

到期倒计时 ≤7 天转琥珀、≤3 天转红:到期即失效且**没有任何扣款或续费通知**,
用户只能从这里看见,不显眼等于没有。

支付弹窗改为对「买什么」中立(PayItem:积分包 | 订阅),下单/轮询/超时/warn
处理全共用。给订阅复制一份弹窗的话,两边迟早漂移——之前修过的轮询问题就得修
两遍。

真环境验证:账单页显示真实订阅(已发放 3 次 = 首笔 + 补发 2 笔)、余额 3.0K
(NULL 回填后的 2981)、套餐卡片算出「周期内共 3 次,合计 3.0K 积分」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:33:59 +08:00

370 lines
13 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(
target: { packId: string } | { planId: string },
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
// 积分包与订阅走同一条支付链路(下单/回调/查单/掉单补偿全复用),只是订单内容不同。
const body = "packId" in target ? { pack_id: target.packId } : { plan_id: target.planId };
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearer() },
body: JSON.stringify(body),
}),
);
return jsonOrThrow(res, "下单失败");
}
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
export interface SubPlan {
id: string;
name: string;
price_fen: number;
duration_days: number;
refill_credits_micro: number;
refill_interval_days: number;
}
export interface MySub {
id: string;
plan_id: string;
status: string;
started_at: string;
expires_at: string;
refill_seq: number;
}
export async function subPlans(): Promise<SubPlan[]> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/sub-plans`, { headers: bearer() }));
if (!res.ok) return [];
return ((await res.json()) as { plans?: SubPlan[] }).plans ?? [];
}
// 我的订阅(无则 null)。到期即失效、不自动续费,所以到期时间要显眼地告诉用户。
export async function mySubscription(): Promise<{ subscription: MySub | null; plan: SubPlan | null }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/subscription`, { headers: bearer() }));
if (!res.ok) return { subscription: null, plan: null };
const d = (await res.json()) as { subscription?: MySub | null; plan?: SubPlan | null };
return { subscription: d.subscription ?? null, plan: d.plan ?? null };
}
// 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;