feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 接口客户端。
|
||||
*
|
||||
* 三条约定:
|
||||
* 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。
|
||||
* 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。
|
||||
* 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。
|
||||
*/
|
||||
|
||||
const BASE = "/api/v1";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface Tokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "dadao.access";
|
||||
const REFRESH_KEY = "dadao.refresh";
|
||||
|
||||
export const session = {
|
||||
get access(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
},
|
||||
get refresh(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
},
|
||||
save(tokens: Tokens) {
|
||||
localStorage.setItem(ACCESS_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
|
||||
},
|
||||
clear() {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const authExpired = new EventTarget();
|
||||
|
||||
type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
|
||||
interface RequestOptions {
|
||||
method?: Method;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
/** 幂等键;不传则对写请求自动生成 */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const method: Method = opts.method ?? "GET";
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
|
||||
|
||||
const token = session.access;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const isWrite = method !== "GET";
|
||||
if (isWrite) {
|
||||
headers["Idempotency-Key"] = opts.idempotencyKey ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
const resp = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
signal: opts.signal,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
});
|
||||
|
||||
let body: { code: number; message: string; data: T; request_id: string } | null = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`);
|
||||
}
|
||||
if (!resp.ok || body.code !== 0) {
|
||||
if (resp.status === 401) {
|
||||
session.clear();
|
||||
authExpired.dispatchEvent(new Event("expired"));
|
||||
}
|
||||
throw new ApiError(body.code, body.message, body.request_id);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, signal?: AbortSignal) => raw<T>(path, { signal }),
|
||||
post: <T>(path: string, body?: unknown) => raw<T>(path, { method: "POST", body }),
|
||||
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
nickname: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan: string;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type Role = "owner" | "admin" | "member" | "guest";
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: Role;
|
||||
status: string;
|
||||
nickname: string;
|
||||
phone: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
code: string;
|
||||
role: Role;
|
||||
expires_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user