feat(org): 成员目录 —— Keycloak 读写面穿透、治理台成员/组织/权限三页、姓在前 personName、Staff.kind

后端:`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
This commit is contained in:
xcs
2026-08-27 11:44:36 +08:00
co-authored by Claude Fable 5
parent bd86dd6088
commit 8556432dba
50 changed files with 4242 additions and 402 deletions
+7
View File
@@ -64,6 +64,13 @@ export const API_ERROR_CODES = [
'precondition-failed',
'internal',
'gone',
/*
* 成员目录专用的两档 502(ADR-012)—— **重试有没有意义**是它们的分界,
* 前端据此说两句不同的话:一句「找管理员补权限」,一句「过会儿再点」。
* 混成一个 code 的话,界面只能说一句含糊的「出错了」。
*/
'keycloak-misconfigured',
'keycloak-unavailable',
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
+1
View File
@@ -1,5 +1,6 @@
export * from './http.ts';
export * from './workbench.ts';
export * from './governance.ts';
export * from './org-directory.ts';
export * from './ingest.ts';
export * from './mail.ts';
+260
View File
@@ -0,0 +1,260 @@
/**
* 成员与组织(ADR-012 · 运营中心「员工」那一屏的三个页签)。
*
* **真源是 Keycloak**:读接口是它的投影,写接口**直接穿透**到 Admin API ——
* 这里建的人立刻能登录、这里停的人立刻登不上、这里删的人从 Keycloak 消失。
* 没有「先存本地草稿再同步」这回事,**也没有撤销**;界面上凡写必二次确认。
*
* 与本地名册的关系:`sys.staff` 是这份目录的投影 + 一列本地岗位(见 org/CONTRACT.md)。
* 涉及身份的写(停用 / 删除 / 授收角色)之后,本地那一行当场跟手 —— 两屏不许说两句话。
*
* 三处口径与 Keycloak 的形状有关,写在这儿免得每条接口重复:
* ① **服务账号一律不可见、不可写**:目录读剔除 `service-account-*`(它是身份不是人,
* 见 `staffKindSchema`);写面对着服务账号的 sub 一律 403。
* ② **组树是平表**:一行一个组,靠 `parentId` / `depth` 表达层级(按 `path` 升序已排好)。
* 契约里不放递归结构 —— 递归 schema 到了声明文件那一层只会变成一坨 any。
* ③ **列表无分页**:realm 不大,一次拉全;Keycloak 那一侧的 100 条上限由适配器分页兜住。
*/
import { z } from 'zod';
import type { RouteContract } from './http.ts';
import { surfaceSchema } from './http.ts';
/* ── 成员 ─────────────────────────────────────────────────────────────── */
export const orgUserSchema = z.object({
/** Keycloak 用户 id(= `sub`,业务外键用它,不用用户名) */
sub: z.string().min(1),
/** 登录名,建后不可改 */
username: z.string().min(1),
/** `firstName lastName` 直拼;皆空回落 username —— 不会是空串 */
displayName: z.string().min(1),
email: z.string().optional(),
enabled: z.boolean(),
/** realm 角色(家具角色已滤) */
roles: z.array(z.string()),
/** 这些角色合起来能进哪几个端 —— 由 `sys.role_access` 算,不是 Keycloak 的字段 */
ends: z.array(surfaceSchema),
/** 本地名册记的「几时来过」;没有 = 还没拿这枚身份访问过本系统 */
lastSeenAt: z.string().optional(),
/** 本地岗位(哨兵岗 NONE = 未分配)—— 目录里只读,改岗位走 `setStaffPositionRoute` */
positionCode: z.string().optional(),
});
export type OrgUser = z.infer<typeof orgUserSchema>;
export const orgUserDetailSchema = orgUserSchema.extend({
firstName: z.string().optional(),
lastName: z.string().optional(),
emailVerified: z.boolean(),
/** 直属组;`path` 形如 `/技术部/平台组` */
groups: z.array(z.object({ id: z.string(), path: z.string() })),
});
export type OrgUserDetail = z.infer<typeof orgUserDetailSchema>;
export const listOrgUsersRoute = {
method: 'GET',
path: '/api/gov/org/users',
access: 'ops',
output: z.object({ users: z.array(orgUserSchema) }),
} as const satisfies RouteContract;
export const getOrgUserRoute = {
method: 'GET',
path: '/api/gov/org/users/:sub',
access: 'ops',
params: z.object({ sub: z.string().min(1) }),
output: orgUserDetailSchema,
} as const satisfies RouteContract;
/**
* 新建成员 —— 建完立刻能登录。口令是**临时**的:本人首登必须改
* (`temporary:true`,手册 §14.2);我们不存它,也不回显。
*/
export const createOrgUserRoute = {
method: 'POST',
path: '/api/gov/org/users',
access: 'ops',
body: z.object({
username: z.string().min(1),
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().optional(),
/** 临时口令;不给就建一个登不进来的壳(人自己去改密码那条路) */
password: z.string().min(1).optional(),
/** 一并授的 realm 角色;不给就只有 Keycloak 的默认角色 */
roles: z.array(z.string()).optional(),
}),
output: orgUserSchema,
} as const satisfies RouteContract;
/** 改资料(姓名 / 邮箱)—— 用户名建后不可改,不在这里 */
export const patchOrgUserRoute = {
method: 'PATCH',
path: '/api/gov/org/users/:sub',
access: 'ops',
params: z.object({ sub: z.string().min(1) }),
body: z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().optional(),
}),
output: orgUserSchema,
} as const satisfies RouteContract;
/**
* 启用 / 停用。停用**立刻踢掉该用户全部会话** —— 不然他攥着的 token
* 还能用到过期为止,而「已停用」这三个字在界面上已经写出去了(界面不撒谎)。
*/
export const setOrgUserEnabledRoute = {
method: 'POST',
path: '/api/gov/org/users/:sub/enabled',
access: 'ops',
params: z.object({ sub: z.string().min(1) }),
body: z.object({ enabled: z.boolean() }),
output: orgUserSchema,
} as const satisfies RouteContract;
/** 重置口令 —— 一律临时口令,本人首登必须改;服务端不回显、不留痕 */
export const resetOrgUserPasswordRoute = {
method: 'POST',
path: '/api/gov/org/users/:sub/reset-password',
access: 'ops',
params: z.object({ sub: z.string().min(1) }),
body: z.object({ password: z.string().min(1) }),
output: z.object({ ok: z.literal(true) }),
} as const satisfies RouteContract;
/** 删人 —— 从 Keycloak 消失,不可撤销;本地名册那一行同批停用(留痕与在途还挂着他) */
export const deleteOrgUserRoute = {
method: 'DELETE',
path: '/api/gov/org/users/:sub',
access: 'ops',
params: z.object({ sub: z.string().min(1) }),
output: z.object({ ok: z.literal(true) }),
} as const satisfies RouteContract;
/* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ─────────────────────────── */
export const orgRoleSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
/** Keycloak 的组合角色(展开后会带出别的角色) */
composite: z.boolean(),
/** 这个角色能进哪几个端(`sys.role_access`;没配过 = 空 = 哪儿都进不去) */
ends: z.array(surfaceSchema),
/** 持有它的成员数(已剔服务账号) */
holders: z.number().int().nonnegative(),
});
export type OrgRole = z.infer<typeof orgRoleSchema>;
export const listOrgRolesRoute = {
method: 'GET',
path: '/api/gov/org/roles',
access: 'ops',
output: z.object({ roles: z.array(orgRoleSchema) }),
} as const satisfies RouteContract;
/**
* 授 / 收一个 realm 角色。收权**立刻踢掉该用户全部会话**(手册 §17.3)——
* 否则他手上那枚 token 还带着旧角色,收权要等到 token 过期才生效。
* 不能收自己的最后一条进运营中心的路(409)—— 收完没人进得来,这扇门就锁死了。
*/
export const setOrgUserRoleRoute = {
method: 'POST',
path: '/api/gov/org/users/:sub/roles/:role',
access: 'ops',
params: z.object({ sub: z.string().min(1), role: z.string().min(1) }),
body: z.object({ granted: z.boolean() }),
output: orgUserSchema,
} as const satisfies RouteContract;
/* ── 组织:Keycloak 组 ─────────────────────────────────────────────────── */
export const orgGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
/** 从根拼下来,如 `/技术部/平台组` */
path: z.string().min(1),
/** 顶层组为空 */
parentId: z.string().optional(),
/** 0 = 顶层 */
depth: z.number().int().nonnegative(),
/** 本组**直属**成员数(已剔服务账号,不含子组) */
memberCount: z.number().int().nonnegative(),
});
export type OrgGroup = z.infer<typeof orgGroupSchema>;
/** 组树平表,按 `path` 升序 —— 层级看 `parentId` / `depth` */
export const listOrgGroupsRoute = {
method: 'GET',
path: '/api/gov/org/groups',
access: 'ops',
output: z.object({ groups: z.array(orgGroupSchema) }),
} as const satisfies RouteContract;
/** 组的直属成员(含被停用的人 —— 这一屏管的是归属,不是能不能登录) */
export const listOrgGroupMembersRoute = {
method: 'GET',
path: '/api/gov/org/groups/:id/members',
access: 'ops',
params: z.object({ id: z.string().min(1) }),
output: z.object({ members: z.array(orgUserSchema) }),
} as const satisfies RouteContract;
/** 建组:`parentId` 给了就是子组,不给就是顶层组。同级不得重名(Keycloak 那一侧拦,409) */
export const createOrgGroupRoute = {
method: 'POST',
path: '/api/gov/org/groups',
access: 'ops',
body: z.object({ name: z.string().min(1), parentId: z.string().optional() }),
output: orgGroupSchema,
} as const satisfies RouteContract;
export const renameOrgGroupRoute = {
method: 'PATCH',
path: '/api/gov/org/groups/:id',
access: 'ops',
params: z.object({ id: z.string().min(1) }),
body: z.object({ name: z.string().min(1) }),
output: orgGroupSchema,
} as const satisfies RouteContract;
/** 删组 —— 连同子组一起没(Keycloak 语义);人不会被删,只是不再属于这个组 */
export const deleteOrgGroupRoute = {
method: 'DELETE',
path: '/api/gov/org/groups/:id',
access: 'ops',
params: z.object({ id: z.string().min(1) }),
output: z.object({ ok: z.literal(true) }),
} as const satisfies RouteContract;
/** 成员进 / 出组 */
export const setOrgUserGroupRoute = {
method: 'POST',
path: '/api/gov/org/users/:sub/groups/:groupId',
access: 'ops',
params: z.object({ sub: z.string().min(1), groupId: z.string().min(1) }),
body: z.object({ member: z.boolean() }),
output: z.object({ ok: z.literal(true) }),
} as const satisfies RouteContract;
/**
* 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言。
* 逐个 import 去断言的写法,新增一条只要忘了加进名单就悄悄漏过去了。
*/
export const ORG_DIRECTORY_ROUTES = [
listOrgUsersRoute,
getOrgUserRoute,
createOrgUserRoute,
patchOrgUserRoute,
setOrgUserEnabledRoute,
resetOrgUserPasswordRoute,
deleteOrgUserRoute,
listOrgRolesRoute,
setOrgUserRoleRoute,
listOrgGroupsRoute,
listOrgGroupMembersRoute,
createOrgGroupRoute,
renameOrgGroupRoute,
deleteOrgGroupRoute,
setOrgUserGroupRoute,
] as const satisfies readonly RouteContract[];
+16
View File
@@ -21,6 +21,20 @@ export const STAFF_STATES = ['active', 'disabled'] as const;
export const staffStateSchema = z.enum(STAFF_STATES);
export type StaffState = z.infer<typeof staffStateSchema>;
/**
* 人,还是服务账号。
*
* Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为
* `service-account-<clientId>` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在
* 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。
*
* 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**:
* 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。
* 这条判断归后端 —— 界面不该去猜账号名长什么样。
*/
export const staffKindSchema = z.enum(['person', 'service']);
export type StaffKind = z.infer<typeof staffKindSchema>;
export const staffSchema = z.object({
/** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */
id: staffIdSchema,
@@ -35,6 +49,8 @@ export const staffSchema = z.object({
state: staffStateSchema.default('active'),
/** realm 角色快照(token 签发时;噪声角色已滤) */
roles: z.array(z.string()).default([]),
/** 人 / 服务账号 —— 由账号名判(见 `staffKindSchema`),不落库 */
kind: staffKindSchema.default('person'),
});
export type Staff = z.infer<typeof staffSchema>;