ci / 质量门禁 (push) Canceled after 0s
背景: - 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、 权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。 改动: - packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role) 与 user:view 权限点;写接口 strict 校验。 - apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/ user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh 旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。 - apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403; usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。 - e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。 - openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。 影响面: - 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。 - 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。 - 平台导航「用户与组织」由 /platform/users 调整为 /users。 回滚方式: - 代码整体回滚本提交;迁移为 expand,回滚代码不需删表; - 本地如需彻底回退按实施报告 §6 处理。 验证证据: - pnpm format:check / lint / typecheck 通过; - pnpm test:shared 95 / api 60 / web 55; - pnpm test:int:7 个(真实 PG/Redis); - A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6; - 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
203 lines
5.5 KiB
TypeScript
203 lines
5.5 KiB
TypeScript
import { apiErrorBodySchema } from '@lingxi/shared';
|
||
|
||
import { useAuthStore } from '@/stores/auth-store';
|
||
|
||
const DEFAULT_BASE_URL = 'http://127.0.0.1:3000/api/v1';
|
||
|
||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||
|
||
export interface RequestOptions {
|
||
method?: HttpMethod;
|
||
body?: unknown;
|
||
headers?: Record<string, string>;
|
||
signal?: AbortSignal;
|
||
/** 登录、刷新等接口不需要携带 token */
|
||
auth?: boolean;
|
||
}
|
||
|
||
export interface ApiErrorInit {
|
||
code: number;
|
||
status: number;
|
||
message: string;
|
||
details?: unknown;
|
||
traceId?: string;
|
||
}
|
||
|
||
/** 前端统一 API 错误:与服务端错误体 {code,message,details,trace_id} 对齐 */
|
||
export class ApiError extends Error {
|
||
readonly code: number;
|
||
readonly status: number;
|
||
readonly details?: unknown;
|
||
readonly traceId?: string;
|
||
|
||
constructor(init: ApiErrorInit) {
|
||
super(init.message);
|
||
this.name = 'ApiError';
|
||
this.code = init.code;
|
||
this.status = init.status;
|
||
this.details = init.details;
|
||
this.traceId = init.traceId;
|
||
}
|
||
}
|
||
|
||
let unauthorizedHandler: (() => void) | null = null;
|
||
|
||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||
unauthorizedHandler = handler;
|
||
}
|
||
|
||
function baseUrl(): string {
|
||
const configured = import.meta.env.VITE_PUBLIC_API_BASE_URL;
|
||
return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||
}
|
||
|
||
function buildUrl(path: string): string {
|
||
if (/^https?:\/\//.test(path)) {
|
||
return path;
|
||
}
|
||
return `${baseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
|
||
}
|
||
|
||
function serializeBody(body: unknown): BodyInit | undefined {
|
||
if (body === undefined) {
|
||
return undefined;
|
||
}
|
||
if (
|
||
typeof body === 'string' ||
|
||
body instanceof FormData ||
|
||
body instanceof Blob ||
|
||
body instanceof URLSearchParams ||
|
||
body instanceof ArrayBuffer
|
||
) {
|
||
return body;
|
||
}
|
||
return JSON.stringify(body);
|
||
}
|
||
|
||
function fallbackCode(status: number): number {
|
||
if (status === 401) return 40101;
|
||
if (status === 403) return 40301;
|
||
if (status === 404) return 40401;
|
||
if (status === 409) return 40903;
|
||
if (status === 429) return 42901;
|
||
if (status >= 500) return 50001;
|
||
return 40001;
|
||
}
|
||
|
||
async function toApiError(response: Response): Promise<ApiError> {
|
||
let payload: unknown = null;
|
||
try {
|
||
payload = await response.json();
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
const parsed = apiErrorBodySchema.safeParse(payload);
|
||
if (parsed.success) {
|
||
return new ApiError({
|
||
code: parsed.data.code,
|
||
status: response.status,
|
||
message: parsed.data.message,
|
||
details: parsed.data.details,
|
||
traceId: parsed.data.trace_id,
|
||
});
|
||
}
|
||
return new ApiError({
|
||
code: fallbackCode(response.status),
|
||
status: response.status,
|
||
message: `请求失败(HTTP ${response.status})`,
|
||
});
|
||
}
|
||
|
||
/** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */
|
||
function failRefresh(): false {
|
||
useAuthStore.getState().clearSession();
|
||
unauthorizedHandler?.();
|
||
return false;
|
||
}
|
||
|
||
let refreshPromise: Promise<boolean> | null = null;
|
||
|
||
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
|
||
export function refreshSession(): Promise<boolean> {
|
||
refreshPromise ??= doRefresh().finally(() => {
|
||
refreshPromise = null;
|
||
});
|
||
return refreshPromise;
|
||
}
|
||
|
||
async function doRefresh(): Promise<boolean> {
|
||
const { refreshToken } = useAuthStore.getState();
|
||
if (refreshToken === null) {
|
||
return failRefresh();
|
||
}
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(buildUrl('/auth/refresh'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refreshToken }),
|
||
});
|
||
} catch {
|
||
return failRefresh();
|
||
}
|
||
if (!response.ok) {
|
||
return failRefresh();
|
||
}
|
||
const payload: unknown = await response.json().catch(() => null);
|
||
const accessToken =
|
||
typeof payload === 'object' && payload !== null && 'accessToken' in payload
|
||
? payload.accessToken
|
||
: null;
|
||
const nextRefreshToken =
|
||
typeof payload === 'object' && payload !== null && 'refreshToken' in payload
|
||
? payload.refreshToken
|
||
: null;
|
||
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
||
return failRefresh();
|
||
}
|
||
useAuthStore.getState().setTokens({
|
||
accessToken,
|
||
refreshToken: typeof nextRefreshToken === 'string' ? nextRefreshToken : refreshToken,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async function send<T>(path: string, options: RequestOptions, allowRefresh: boolean): Promise<T> {
|
||
const { accessToken } = useAuthStore.getState();
|
||
const headers = new Headers(options.headers);
|
||
const body = serializeBody(options.body);
|
||
if (body !== undefined && !(body instanceof FormData) && !headers.has('Content-Type')) {
|
||
headers.set('Content-Type', 'application/json');
|
||
}
|
||
if (options.auth !== false && accessToken !== null) {
|
||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||
}
|
||
|
||
const response = await fetch(buildUrl(path), {
|
||
method: options.method ?? 'GET',
|
||
headers,
|
||
body,
|
||
signal: options.signal,
|
||
});
|
||
|
||
if (response.status === 401 && allowRefresh && options.auth !== false) {
|
||
const refreshed = await refreshSession();
|
||
if (refreshed) {
|
||
return send<T>(path, options, false);
|
||
}
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw await toApiError(response);
|
||
}
|
||
if (response.status === 204) {
|
||
return undefined as T;
|
||
}
|
||
return (await response.json()) as T;
|
||
}
|
||
|
||
/** 业务请求入口:自动注入 token、401 单飞刷新、统一错误体解析 */
|
||
export function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||
return send<T>(path, options, true);
|
||
}
|