Archived
后端:`KeycloakDirectory` 端口(users / groups / roles 读写;写直接穿透 Keycloak —— 这里建的人立刻能登录、 删的人立刻消失,停用与收权之后 logout 踢掉会话)、`keycloak-directory.ts` + `keycloak-admin-http.ts` 适配器、 org-directory 契约与 /api/gov 路由、`sync-roster` CLI(ops 路由够不着时整册照过来), 容器按 provisioning 服务账号有无决定注不注入(没配就如实说不可用)。 `personName` 姓在前:汉字直拼「柴浩」、否则留一个空格「Admin Amy」,token 与名册同步两条路同一把尺; `staffKindOf` 按 Keycloak 固定前缀 service-account- 认服务账号,员工那一屏不摆它(`Staff.kind` 不落库,按账号名现判)。 前端:员工那一屏重做成成员 / 组织 / 权限三页签(member-page 下钻),页签栏右端一句话说清这一格管什么。 契约变更:contracts org.ts / api/http.ts / api/org-directory.ts;api-report 已刷新;org CONTRACT.md 与 ADR-012 同步。 2026-08-26 做的,一直留在工作树没入库。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
/**
|
|
* 路由契约的形状(编码规范 §8.1 / §9.2)。
|
|
*
|
|
* 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。
|
|
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
|
|
*/
|
|
import { z } from 'zod';
|
|
|
|
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
|
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
|
|
/**
|
|
* 三个端(ADR-012):设计中心 / 运营中心 / 工作台。
|
|
* 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。
|
|
*/
|
|
export const SURFACES = ['design', 'ops', 'workbench'] as const;
|
|
export const surfaceSchema = z.enum(SURFACES);
|
|
export type Surface = z.infer<typeof surfaceSchema>;
|
|
|
|
export const SURFACE_LABEL: Readonly<Record<Surface, string>> = {
|
|
design: '设计中心',
|
|
ops: '运营中心',
|
|
workbench: '工作台',
|
|
};
|
|
|
|
/**
|
|
* 一条路由归哪个端(ADR-012):
|
|
* `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。
|
|
* 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。
|
|
*/
|
|
export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[];
|
|
|
|
/**
|
|
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
|
|
* body 只在 POST/PUT/PATCH 上有意义;GET 用 query。
|
|
*/
|
|
export type RouteContract<
|
|
TParams extends z.ZodTypeAny = z.ZodTypeAny,
|
|
TQuery extends z.ZodTypeAny = z.ZodTypeAny,
|
|
TBody extends z.ZodTypeAny = z.ZodTypeAny,
|
|
TOutput extends z.ZodTypeAny = z.ZodTypeAny,
|
|
> = {
|
|
readonly method: HttpMethod;
|
|
readonly path: string;
|
|
readonly access: RouteAccess;
|
|
readonly params?: TParams;
|
|
readonly query?: TQuery;
|
|
readonly body?: TBody;
|
|
readonly output: TOutput;
|
|
};
|
|
|
|
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
|
|
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny
|
|
? z.infer<R['body']>
|
|
: never;
|
|
|
|
/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */
|
|
export const API_ERROR_CODES = [
|
|
'unauthenticated',
|
|
'forbidden',
|
|
'not-found',
|
|
'validation-failed',
|
|
'conflict',
|
|
'precondition-failed',
|
|
'internal',
|
|
'gone',
|
|
/*
|
|
* 成员目录专用的两档 502(ADR-012)—— **重试有没有意义**是它们的分界,
|
|
* 前端据此说两句不同的话:一句「找管理员补权限」,一句「过会儿再点」。
|
|
* 混成一个 code 的话,界面只能说一句含糊的「出错了」。
|
|
*/
|
|
'keycloak-misconfigured',
|
|
'keycloak-unavailable',
|
|
] as const;
|
|
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
|
|
|
export type ApiError = {
|
|
readonly code: ApiErrorCode;
|
|
readonly message: string;
|
|
readonly detail?: unknown;
|
|
};
|