883540bd7e
上一版做成了网页授权(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>
389 lines
14 KiB
TypeScript
389 lines
14 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;
|
||
}
|
||
|
||
// ---- 微信扫码登录(带参二维码 + 关注/扫码)----
|
||
// 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;
|