Files
sundynix-agentix/sundynix-web/src/api.ts
T
Blizzard 883540bd7e feat(auth): 微信扫码登录改为「带参二维码 + 关注/扫码事件」(登录即涨粉)
上一版做成了网页授权(OAuth 允许页),方向错了。改成用户要的流程:
扫码 → 弹公众号关注页 → 关注即登录,服务号顺带涨粉。

流程:PC 建票 → 后端用 access_token 调「带参数二维码」接口(scene=ticket) →
展示微信二维码图 → 用户扫码关注 → 微信推 subscribe/SCAN 事件到 /wx/mp/callback →
按 openid 找/建用户 → ticket 置 authorized → PC 轮询拿 JWT。

明文模式(消息加解密):回调只验签名 sha1(sort(token,ts,nonce)),不做 AES。

关键实现点:
- access_token 缓存进 Redis(跨实例共享,避免重复拉取互相失效)+ 进程内锁双检;
- 事件同时处理 subscribe(未关注,EventKey 带 qrscene_ 前缀)与 SCAN(已关注,不带);
- 事件回调必须验签——否则任何人 POST 一个 openid 就能登录别人;
- 回调无论如何回 "success",否则微信重试并给用户弹"公众号故障";
- User.wechat_openid 用部分唯一索引(WHERE <> ''),避开存量空串互撞。

配置(appid/secret/token)后台可改、secret AES 加密入库。管理端「运维 → 登录设置」
列出还需在公众平台做的事(服务器 URL / Token 一致 / 明文模式 / IP 白名单)。

本地验证(真流程,非 mock):验签回 echostr 与微信算法一致;模拟 subscribe 事件
→ 建号 + 置票 → PC 轮询拿到 token+user → 库里确有该 openid 用户。真微信推真事件
留待部署后扫码。前端 web 登录页加「微信扫码/邮箱」双 tab,默认微信。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:58:31 +08:00

389 lines
14 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;
}
// ---- 微信扫码登录(带参二维码 + 关注/扫码)----
// PC 建票拿到微信二维码图 URL → 展示 → 轮询登录态。全公开接口。
export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> {
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" });
return jsonOrThrow<{ ticket: string; qr_image: string; expires_in: number }>(res, "创建登录二维码失败");
}
// 轮询:pending | authorized(带 token/user) | expired | consumed。
export async function wxPoll(ticket: string): Promise<{ status: string; user?: AuthUser }> {
const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`);
if (!res.ok) return { status: "expired" };
const d = (await res.json()) as { status?: string; token?: string; user?: AuthUser };
if (d.token && d.user) {
setToken(d.token); // authorized:后端走 issueToken 返回 token+user
return { status: "authorized", user: d.user };
}
return { status: d.status ?? "pending" };
}
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;