/** * 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 —— * 每个功能是对应模块的管理用例 + 本文件的路由组装)。 */ import type { Hono } from 'hono'; import type { ApiError, GovernanceFunction } from '@contracts'; import { createStaffRoute, disableStaffCheckRoute, disableStaffRoute, listGrantsRoute, listLlmInstancesRoute, listStaffRoute, listWorkGroupsRoute, positionIdSchema, listLlmProvidersRoute, LLM_PROVIDERS, deleteLlmInstanceRoute, probeLlmDraftRoute, probeLlmInstanceRoute, saveWorkGroupRoute, setGrantsRoute, staffIdSchema, upsertLlmInstanceRoute, } from '@contracts'; import { DisableBlockedError } from '@modules/org'; import { PROVIDER_PROFILES } from '@modules/llm'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; /** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */ function requireGovern(container: Container, fn: GovernanceFunction) { return async (account: string): Promise => { const staff = await container.org.findByAccount(account); if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' }; const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern'); return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` }; }; } export function registerGovernanceRoutes(app: Hono, container: Container): void { const guardStaff = requireGovern(container, 'staff'); const guardSystem = requireGovern(container, 'system'); /* ── 员工管理 ─────────────────────────────────────────────────────── */ app.get(listStaffRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); return c.json(await container.org.listStaff()); }); app.post(createStaffRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = createStaffRoute.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.org.createStaff(parsed.data)); }); app.get(disableStaffCheckRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const staffId = staffIdSchema.parse(c.req.param('staffId')); return c.json(await container.org.checkDisable(staffId)); }); app.post(disableStaffRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const staffId = staffIdSchema.parse(c.req.param('staffId')); try { await container.org.disableStaff(staffId); return c.json({ ok: true, check: await container.org.checkDisable(staffId) }); } catch (error) { if (error instanceof DisableBlockedError) { // 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check }; return c.json(body, 412); } throw error; } }); app.get(listGrantsRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const positionId = positionIdSchema.parse(c.req.param('positionId')); return c.json({ grants: await container.org.listGrants(positionId) }); }); app.post(setGrantsRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const positionId = positionIdSchema.parse(c.req.param('positionId')); const parsed = setGrantsRoute.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({ grants: await container.org.setGrants(positionId, parsed.data.grants) }); }); /* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */ /** * 全部工作组 —— 一人至多一组,按员工遍历取。 * `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。 */ app.get(listWorkGroupsRoute.path, async (c) => { const denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); 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 denied = await guardStaff(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); 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, async (c) => { const denied = await guardSystem(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); return 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) => { const denied = await guardSystem(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); return c.json({ instances: await container.llm.list() }); }); app.post(upsertLlmInstanceRoute.path, async (c) => { const denied = await guardSystem(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); 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 denied = await guardSystem(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); 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 denied = await guardSystem(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); 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) => { const denied = await guardSystem(c.get('account')); if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); return c.json(await container.llm.probe(c.req.param('id'))); }); }