This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/routes/governance.ts
T
xcsandClaude Opus 5 5facf6b745 名册同步补三道下限保护;同名让位;停用判定走在 JIT 写之前
**C2:一次同步能把所有人停用,而停用之后没有界面能救回来。**
「本地有、远端无 → 停」是有意的,但没有下限:①`listUsers()` 因任何原因回了空数组
(realm 配错但存在、代理 200 空体、view-users 被改窄)→ 全表 disabled;②更可能的是
**Admin 的 `GET /users` 不返回服务账号用户**(手册 §14.5 为此单列一条接口)——
e2e 与试跑的 AI 账号是首登 JIT 建的,本地在册、远端查无此人,**第一次点「同步名册」
就把它停了,再同步多少次都回不来**。而恢复动作 `POST /api/gov/staff/sync` 要 ops,
ops 要一个 active 的 principal:全员停用之后这条路自己把自己锁死,只剩去库里手改 state。

三道闸(两个触发面分别被后两条挡住,第一条是最后的网):
- `diffRoster`:`remote` 为空时不产出「缺席即停」—— 远端一个人都没有,更像取数出了问题;
- `diffRoster`:`testAccounts` 豁免「缺席即停」(新入参,必填 —— 只有装配层知道哪几个是
  服务账号),判据用既有的 `isTestAccount`(不分大小写、忽略空白);
- `syncRoster`:落 setState 之前用**同步后的活跃员工角色集**(新纯函数 `activeRolesAfter`)
  跑一遍 `assertNotLockedOut`,不过就整体拒绝、一条都不写;路由映射 409(与
  setRoleAccess 那两条同款,`syncStaffRoute` 从前没有这个 catch)。
  `AccessLockoutError` / `assertNotLockedOut` 收可选 message —— 两个来路拒绝的理由不同,
  给人的下一步也不同(这条指向「先核对 provisioning 指的是不是同一个 realm」)。

**I3:JIT 与同步撞 `account` 唯一索引 → 500,且此人从此登不进。**
Keycloak 里删了人再建一个同名的(或用户名被让渡),新 sub 走 insert 就撞
`staff_account_key`,`server.ts` 的 onError 把 PG 原文原样回给调用方。
处置选的是**先查后让位**(不是捕异常):走到 insert 就说明按 sub 找不到他,本地那条同名的
必然是另一个 sub 的 stale 行 —— 把它的账号让位成 `账号#retired-<旧 sub 前 8 位>` 再插,
留一条 stderr;不删那一行(它身上可能还挂着在途工作)。`StaffRepository.findByAccount`
为此回到端口上(退役的是 OrgService 上的那个同名出口,不是仓储这一口)。
同步逐条落库改为**单条失败不停在半路**:失败的人点名进 `RosterSyncOutcome.failed`
(契约变更,api-report 已刷新),三个计数改成**真落下的条数** —— 半途失败的同步
不许报成一排绿数字。

顺手两条:
- `authenticate` 的 JIT 写挪到停用判定之后 —— 一个已停用的账号只要还攥着有效 token,
  从前每 60 秒就能驱动一次 updateIdentity 写库;「第一次见到的人要建档」原样保留。
- `keycloak-admin-roster` 自己又写了一遍 dedup+sort+滤噪声 → 改用 `normalizeRoles`。
  两处对「同一次归一」的理解一分叉,`identityChanged` 的逐字比对就会天天判「变了」。

测:roster-diff +8(两道下限保护 + activeRolesAfter 四种局面)、org-service +5
(整体拒绝且一条 setState 都没落 · 远端空着不停人 · 服务账号缺席不停 · 单条失败点名且
不停在半路 · 停用账号驱动不了 JIT 写)、同名让位 1(内存仓储照搬了唯一索引这条约束)。
文档:org/CONTRACT.md 不变量与影响半径、API 文档 §0(HEAD / 409)· §3(三道保护 + failed)· §7。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
2026-08-25 19:42:19 +08:00

222 lines
8.8 KiB
TypeScript

/**
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
* 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。
*/
import type { Hono } from 'hono';
import type { ApiError } from '@contracts';
import {
deleteLlmInstanceRoute,
deleteRoleAccessRoute,
listLlmInstancesRoute,
listLlmProvidersRoute,
listRoleAccessRoute,
listStaffRoute,
listWorkGroupsRoute,
LLM_PROVIDERS,
probeLlmDraftRoute,
probeLlmInstanceRoute,
saveWorkGroupRoute,
setRoleAccessRoute,
setStaffPositionRoute,
staffIdSchema,
staffInFlightRoute,
syncStaffRoute,
upsertLlmInstanceRoute,
} from '@contracts';
import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org';
import { PROVIDER_PROFILES } from '@modules/llm';
import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts';
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
/* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */
app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff()));
/**
* 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎)。
* 409:这一次同步会把最后一个能进运营中心的人也停了 —— 整体拒绝,一条都没写
* (与「角色 → 端」那两条同款,理由不同、话不同)。
*/
app.post(syncStaffRoute.path, async (c) => {
try {
return c.json(await container.org.syncRoster());
} catch (error) {
if (error instanceof AccessLockoutError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
throw error;
}
});
app.put(setStaffPositionRoute.path, async (c) => {
const staffId = staffIdSchema.parse(c.req.param('staffId'));
const parsed = setStaffPositionRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
try {
return c.json(await container.org.setPosition(staffId, parsed.data.positionId));
} catch (error) {
if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) {
const body: ApiError = { code: 'not-found', message: error.message };
return c.json(body, 404);
}
throw error;
}
});
/** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */
app.get(staffInFlightRoute.path, async (c) => {
const staffId = staffIdSchema.parse(c.req.param('staffId'));
try {
return c.json(await container.org.inFlightOf(staffId));
} catch (error) {
if (error instanceof StaffNotFoundError) {
const body: ApiError = { code: 'not-found', message: error.message };
return c.json(body, 404);
}
throw error;
}
});
/* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */
app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess()));
/** 锁死不变量不过(改完没人能进运营中心)→ 409 */
app.put(setRoleAccessRoute.path, async (c) => {
const role = c.req.param('role');
const parsed = setRoleAccessRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
try {
return c.json(await container.org.setRoleAccess(role, parsed.data.ends));
} catch (error) {
if (error instanceof AccessLockoutError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
throw error;
}
});
app.delete(deleteRoleAccessRoute.path, async (c) => {
try {
await container.org.deleteRoleAccess(c.req.param('role'));
return c.json({ ok: true });
} catch (error) {
if (error instanceof AccessLockoutError) {
const body: ApiError = { code: 'conflict', message: error.message };
return c.json(body, 409);
}
throw error;
}
});
/* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */
/**
* 全部工作组 —— 一人至多一组,按员工遍历取。
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
*/
app.get(listWorkGroupsRoute.path, async (c) => {
const { staff } = await container.org.listStaff();
const found = await Promise.all(
staff.map(async (s) => {
const group = await container.org.workGroupOf(s.id);
return group === undefined ? undefined : { ...group, ownerName: s.name };
}),
);
return c.json({ groups: found.filter((g) => g !== undefined) });
});
/**
* 建组 / 改组 —— 按 `ownerId` 落:已有即更新(复用 id),没有即新建。
* 与 `workGroupOf` 的取组口径一致(一人一组),不给同一个人建出两个组来。
* 改组**不影响在途**:装进工作流的是起单时的快照。
*/
app.put(saveWorkGroupRoute.path, async (c) => {
const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
return c.json(body, 400);
}
const existing = await container.org.workGroupOf(parsed.data.ownerId);
const group = await container.org.saveWorkGroup({
...(existing === undefined ? {} : { id: existing.id }),
name: parsed.data.name,
ownerId: parsed.data.ownerId,
members: parsed.data.members,
});
return c.json({ group });
});
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
app.get(listLlmProvidersRoute.path, (c) =>
c.json({
providers: LLM_PROVIDERS.map((provider) => {
const profile = PROVIDER_PROFILES[provider];
return {
provider,
label: profile.label,
defaultBaseUrl: profile.defaultBaseUrl,
structuredOutput: profile.structuredOutput,
};
}),
}),
);
app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() }));
app.post(upsertLlmInstanceRoute.path, async (c) => {
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
return c.json(await container.llm.upsert(parsed.data));
});
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
app.delete(deleteLlmInstanceRoute.path, async (c) => {
const id = c.req.param('id');
const target = (await container.llm.list()).find((i) => i.id === id);
if (target === undefined) {
const body: ApiError = { code: 'not-found', message: '实例不存在' };
return c.json(body, 404);
}
const cards = await container.registry.list();
const manifests = await Promise.all(cards.map((a) => container.registry.manifestOf(a.activityId)));
const users = manifests.filter((m) => m.declarations.models.includes(target.name));
if (users.length > 0) {
const body: ApiError = {
code: 'conflict',
message: `还有 ${users.length} 个活动声明着用途名「${target.name}」—— 先改那些活动的声明再删`,
};
return c.json(body, 409);
}
await container.llm.remove(id);
return c.json({ ok: true });
});
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
app.post(probeLlmDraftRoute.path, async (c) => {
const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
return c.json(body, 400);
}
return c.json(await container.llm.probeDraft(parsed.data));
});
app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id'))));
}