Archived
主支这边(f01286a)是前端接 Keycloak(keycloak-js / PKCE / Bearer / 60 秒票据)、
活动模块运营态模版、数据域与工作台改版、/me 个人中心;分支这边 66 条:后端资源服务器
与角色→端、邮件收发(IMAP/SMTP · 企业微信邮件 API)、CI/CD 四件与两份文档、
pnpm-lock 补 mail 模块 importer。
冲突两处(packages/contracts/src/org.ts · api/workbench.ts):前端对着后端文档先行补的
principalSchema / meRoute / authTicketRoute(注释里自己写着「apps/api 仍是旧后端,契约暂留」)
缺 RouteContract 必填的 access,SURFACES/surfaceSchema 也已在 api/http.ts —— 取分支(后端)
这一份;前端代码对它原样编译通过(staff.email / roles 本就在 staffSchema 里)。
pnpm-lock 自动合并,--frozen-lockfile 核对一致。
五道门禁绿:1945 测试通过、23 跳过(无模型密钥 / 无真信箱的惯常项);
合并树与先前验证过的那棵树(9e9ba38)逐字节一致。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01712YeSo5fTfkCSPZHBiniq
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
# API 文档 —— 邮件收发(ADR-013)
|
||||
|
||||
> 契约的唯一真相在 `packages/contracts/src/api/mail.ts` 与 `packages/contracts/src/mail.ts`(zod schema,前后端共用);
|
||||
> 本文是给前端 / 联调 / 运维读的人话版。两者不一致时以契约为准并回来改本文。
|
||||
> 设计出处:`docs/superpowers/specs/2026-08-25-mail-design.md`;前端怎么接:`docs/前端对接说明-邮件收发.md`;
|
||||
> 鉴权 / 票据 / 错误体通则:`docs/API文档-鉴权与人员.md` §0。
|
||||
|
||||
## 0. 通则
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 前缀 | `/api/gov/mail/*`;**15 条全部 `access: 'ops'`**(读写都归运营中心)—— 邮箱那一屏落在运营中心,设计端没有消费方(测试台读的是通道配置,不是邮箱账号);入站正文装的是客户来往内容,ADR-012 已把「设计端能读客户主数据」记为一处有意承担的代价,不再无故加宽;`route-access.test.ts` 那份「两端共用读路由」名单也因此没有再长(见 ADR-013 决策 10) |
|
||||
| 广播 | 写路由成功后自动广播 `{kind:'system', id:'gov'}`;轮询器的新来信 / 拉取出错 / 发信结果广播 `{kind:'system', id:'mail', reason}` —— 前端按 `kind:'system'` 失效 `['gov','mail',*]` |
|
||||
| 凭证 | 视图只回 `secretBound: boolean`;`secret` 只写不读,PUT 时缺省 = 不改 |
|
||||
| 厂商失败 | 探测 / 试发 / 立即拉取的**厂商失败不是接口错误**:200 的 body 里 `ok:false` / `state:'failed'` / `error` 带原话(已脱敏) |
|
||||
| 两种账号 | `kind: 'imap-smtp'`(任意腾讯企业邮箱地址;凭证 = 客户端专用密码)/ `kind: 'wecom'`(企业微信自建应用的应用邮箱;凭证 = 应用 secret) |
|
||||
| 游标推进 | 两条路不是同一种:IMAP 随每一封成功入库**逐封前进**;企业微信是**整窗全有或全无**——一轮吃不完这一窗的新信,游标就原样不动、下一轮整窗重来(已入库的会被滤掉,不会重复处理),被截断的信不会跳过 |
|
||||
|
||||
状态码怎么读(通则之外本文特有的):
|
||||
|
||||
| 状态 | code | 何时 |
|
||||
|---|---|---|
|
||||
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);试发 / 发信的语义错误(无正文、附件超限)`detail` 是问题列表 |
|
||||
| 404 | `not-found` | 账号 / 来信 / 附件(超限没存的也是 404)/ 原件(超 16MB 没存)/ 订阅方名不认识 |
|
||||
| 409 | `conflict` | 账号名重复;立即拉取时**另一台后端正占着租约** |
|
||||
| 412 | `precondition-failed` | 账号停用;没有默认账号;账号还没配凭证(探测 / 拉取 / 发信都会) |
|
||||
|
||||
## 1. 账号
|
||||
|
||||
### `GET /api/gov/mail/accounts` —— access: `ops`
|
||||
|
||||
```json
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"id": "6e1c…", "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
|
||||
"enabled": true, "isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail", "backfillFrom": null,
|
||||
"settings": { "imapHost": "imap.exmail.qq.com", "imapPort": 993, "imapTls": true, "smtpHost": "smtp.exmail.qq.com", "smtpPort": 465, "smtpTls": true, "folder": "INBOX" },
|
||||
"secretBound": true,
|
||||
"status": { "lastPolledAt": "2026-08-25T10:00:05.000Z", "lastPollError": null, "lastInboundAt": "2026-08-25T09:58:11.000Z", "cursor": "UID 1207 · validity 1721", "polling": false },
|
||||
"createdAt": "2026-08-25T08:00:00.000Z", "updatedAt": "2026-08-25T08:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "9a2f…", "kind": "wecom", "name": "应用邮箱", "address": "", "displayName": "", "enabled": true, "isDefault": false,
|
||||
"pollIntervalSec": 60, "channelId": null, "backfillFrom": null,
|
||||
"settings": { "corpId": "ww1a2b3c", "apiBase": "https://qyapi.weixin.qq.com" },
|
||||
"secretBound": false,
|
||||
"status": { "lastPolledAt": null, "lastPollError": null, "lastInboundAt": null, "cursor": null, "polling": false },
|
||||
"createdAt": "2026-08-25T08:10:00.000Z", "updatedAt": "2026-08-25T08:10:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `address`:imap-smtp 是人填的登录邮箱;wecom 由探测回填,探到之前是空串(前端摆「待探测」)。
|
||||
- `status.cursor` 是人读摘要(还没拉过 = null);`status.polling` 为真表示此刻某台后端正拉着。
|
||||
- `status.lastPollError` 是厂商原话(脱敏);为 null 表示上一轮没出错。
|
||||
|
||||
### `POST /api/gov/mail/accounts` —— access: `ops`
|
||||
|
||||
body 按 `kind` 分两形(缺省值见 §5 schema):
|
||||
|
||||
```json
|
||||
{ "kind": "imap-smtp", "name": "销售信箱", "address": "sales@corp.com", "displayName": "销售部",
|
||||
"isDefault": true, "pollIntervalSec": 30, "channelId": "ch-rfq-mail",
|
||||
"settings": { "folder": "INBOX" }, "secret": { "password": "客户端专用密码" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "kind": "wecom", "name": "应用邮箱", "settings": { "corpId": "ww1a2b3c" }, "secret": { "corpSecret": "应用 secret" } }
|
||||
```
|
||||
|
||||
→ 200 一条账号视图(同 §1 列表里的一项)。409 名字重复。`isDefault:true` 会把别的账号的默认位放下。
|
||||
|
||||
### `POST /api/gov/mail/accounts/probe?accountId=` —— access: `ops`
|
||||
|
||||
**未存**的配置探测:body 同建账号;一个字节都不落库。改已存账号的表单上按「测试」而没重填密码,带 `?accountId=` 借库里那份密文。
|
||||
没 secret 又没 `accountId` → 412。
|
||||
|
||||
```json
|
||||
{ "kind": "imap-smtp", "address": "sales@corp.com",
|
||||
"receive": { "ok": true, "latencyMs": 412 },
|
||||
"send": { "checked": true, "ok": false, "error": "Invalid login —— 535 Error: authentication failed" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "kind": "wecom", "address": "yingyongyouxiang@corp.wecom.work",
|
||||
"receive": { "ok": true, "latencyMs": 230 },
|
||||
"send": { "checked": false, "note": "凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)" } }
|
||||
```
|
||||
|
||||
### `GET /api/gov/mail/accounts/:accountId` —— access: `ops`
|
||||
|
||||
→ 账号视图;404。
|
||||
|
||||
### `PUT /api/gov/mail/accounts/:accountId` —— access: `ops`
|
||||
|
||||
body 同建账号;`secret` 缺省 = 不改密文;换 `kind` 又没给 secret → 旧密文作废(`secretBound:false`);
|
||||
`kind` 或 `settings` 变了 → 游标清空,下一轮重新锚定(不回填)。409 改名撞别人。
|
||||
|
||||
### `DELETE /api/gov/mail/accounts/:accountId` —— access: `ops`
|
||||
|
||||
→ `{ "ok": true }`。留痕(入站 / 出站)不删。
|
||||
|
||||
### `POST /api/gov/mail/accounts/:accountId/probe` —— access: `ops`
|
||||
|
||||
已存账号探测(用库里凭证)→ 同 probe 的报告;wecom 探到应用邮箱地址会回填 `address`。412 凭证空。
|
||||
|
||||
### `POST /api/gov/mail/accounts/:accountId/poll` —— access: `ops`
|
||||
|
||||
立即拉一轮(不等轮询到点):
|
||||
|
||||
```json
|
||||
{ "accountId": "6e1c…", "pulled": 3, "stored": 2, "dispatched": 2, "more": false }
|
||||
```
|
||||
|
||||
- `pulled` 服务器交出几封;`stored` 真新入库几封(去重后);`dispatched` 分发过几封;`more` 为真表示预算(每轮 50 封 / 20 秒)用尽还有余下,下一轮接着。
|
||||
- 出错:`error` 带原话(同时写进账号的 `status.lastPollError`),仍是 200。
|
||||
- 409:另一台后端正占着租约;412:账号停用。
|
||||
|
||||
### `POST /api/gov/mail/accounts/:accountId/send-test` —— access: `ops`
|
||||
|
||||
body `{ "to": "me@corp.com", "subject": "试发", "text": "正文" }` →
|
||||
|
||||
```json
|
||||
{ "outboundId": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "state": "sent", "providerRef": "250 OK: queued as …" }
|
||||
```
|
||||
|
||||
失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。
|
||||
这条路**不带 `idempotencyKey`**,不受 §4「发」那三支幂等逻辑保护 —— 每次调用都是一封新信,重试会真的重发一遍。
|
||||
|
||||
## 2. 入站
|
||||
|
||||
### `GET /api/gov/mail/inbound?accountId=&limit=` —— access: `ops`
|
||||
|
||||
按收信时间倒序;`limit` 默认 50、上限 200(超了 400)。
|
||||
|
||||
```json
|
||||
{
|
||||
"mails": [
|
||||
{
|
||||
"id": "in-01J…", "accountId": "6e1c…", "messageId": "<CAF…@mail.acme.com>",
|
||||
"from": { "address": "buyer@acme.com", "name": "张三" },
|
||||
"to": [{ "address": "sales@corp.com", "name": "" }], "cc": [],
|
||||
"subject": "询价 RFQ-2026-081", "sentAt": "2026-08-25T09:57:40.000Z", "receivedAt": "2026-08-25T09:58:11.000Z",
|
||||
"textPreview": "你好,请报价以下料号……", "hasHtml": true,
|
||||
"attachments": [
|
||||
{ "index": 0, "name": "BOM.xlsx", "contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "size": 18234, "ref": "3f9e…", "note": "" },
|
||||
{ "index": 1, "name": "datasheets.zip", "contentType": "application/zip", "size": 9437184, "ref": null, "note": "附件 9.0MB,超过 8MB 上限,未存原件" }
|
||||
],
|
||||
"deliveries": [
|
||||
{ "subscriber": "channel", "state": "done", "error": null, "startedAt": "2026-08-25T09:58:11.200Z", "finishedAt": "2026-08-25T09:58:11.480Z" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`deliveries[].state`:`dispatching`(正在投;超过 10 分钟还是它 = 进程中断,交人重投)/ `done` / `skipped`(`error` 里是理由,如「账号未绑定通道」)/ `failed`(`error` 是原话,如「通道实例已停用:ch-rfq-mail」)。
|
||||
|
||||
### `GET /api/gov/mail/inbound/:mailId` —— access: `ops`
|
||||
|
||||
列表那一项 + `inReplyTo` `references[]` `text` `html` `size` `rawAvailable`。404。
|
||||
|
||||
### `GET /api/gov/mail/inbound/:mailId/attachments/:index` —— access: `ops`
|
||||
|
||||
→ `{ "name": "BOM.xlsx", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形)。可带 `?ticket=` 用 `<a href download>`(ADR-012 §6)。
|
||||
超限没存的(`ref: null`)→ 404。附件同时也是一个上传件(`ref` = `sys.upload` 的 id),活动那一侧读得到抽出来的文本。
|
||||
|
||||
### `GET /api/gov/mail/inbound/:mailId/raw` —— access: `ops`
|
||||
|
||||
→ `{ "name": "CAF…_mail.acme.com.eml", "contentType": "message/rfc822", "dataBase64": "…" }`;超 16MB 没存 → 404。可带 `?ticket=`。
|
||||
|
||||
### `POST /api/gov/mail/inbound/:mailId/redeliver` —— access: `ops`
|
||||
|
||||
body `{ "subscriber": "channel" }`(可省 = 全部订阅方;body 也可整个不带)→ `{ "deliveries": [ … ] }`。
|
||||
正在 `dispatching` 的那条跳过(原样回);不认识的订阅方 404。**从这一刻起对该订阅方是至少一次**:channel 那条链会再起一单。
|
||||
|
||||
## 3. 出站
|
||||
|
||||
### `GET /api/gov/mail/outbound?accountId=&limit=` —— access: `ops`
|
||||
|
||||
```json
|
||||
{
|
||||
"mails": [
|
||||
{ "id": "c0ff…", "accountId": "6e1c…", "messageId": "<c0ff…@corp.com>", "to": ["buyer@acme.com"], "cc": [], "bcc": [],
|
||||
"subject": "回复:询价 RFQ-2026-081", "state": "sent", "error": null, "providerRef": "250 OK: queued as …",
|
||||
"requestedBy": { "kind": "tool", "tool": "mail.send" }, "createdAt": "2026-08-25T11:02:00.000Z", "sentAt": "2026-08-25T11:02:01.300Z" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`requestedBy.kind`:`tool`(活动放行时经 `mail.send` 落地)/ `channel`(channel.sendMail)/ `ops`(运营面试发,带 `staffId`)。`state`:`sending`(在发;久停不动 = 进程中断)/ `sent` / `failed`。
|
||||
|
||||
## 4. 活动怎么用(不是 HTTP)
|
||||
|
||||
- **收**:账号绑 `channelId` → 来信经 `channel.ingestMail` 归一进事件流 → 触发器起单 / 运行中任务的 `listen` 监听命中 → 任务拿到 `data` 事件(载荷带 subject / body / from / channelId 与提取键)。附件以 `ref` 进信封,活动经上传件读文本。
|
||||
线程身份取来信 `References` 的**首项**(即这一串的根),没有 `References` 时退回 `In-Reply-To`,全新来信两者皆无则不带线程 ——
|
||||
归属解析①级靠它与我方出站登记的线程号(同样是首封的 messageId)对表;若认最近一跳,回信来回一趟就会对不上,退化成③级地址 + 时间窗。
|
||||
- **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。
|
||||
放行落地会带 `idempotencyKey`(同一次放行重放不发两封),重试分三种局面:上一封已 `sent` → 直接短路回那次的结果,不再发;
|
||||
上一封 `failed`,或停在 `sending` **超过 5 分钟**(算作上次进程崩在半路)→ 复用那一行重发;停在 `sending` **不足 5 分钟**
|
||||
(算作正在飞)→ **不发**,如实回 `state:'failed'` 加一句「同一幂等键的上一封仍在发送中,本次未重复发送」,过一会再放行会重试。
|
||||
复用那一行之前还要**抢**(一条条件更新):两个后端连同一个库时两台会同时判出「可以复用」,抢不到的那一路回
|
||||
「同一幂等键的上一封正被另一路重发,本次未重复发送」,一封真信只出一次。
|
||||
- **发出去的信不登记 `outbound_ref`**(实情,2026-08-25 终审核实):`event.recordOutbound` 的唯一调用方是
|
||||
`channel.sendMail`,而 `channel.sendMail` 在 src 里**零调用方** —— 放行落地走的是 `deps.tools.execute` → `mail.send` 工具这条路,
|
||||
它不经 channel,也就不写 `sys.outbound_ref`。所以:**活动经 `mail.send` 发出的信,回信归属只靠②字段提取与触发器**
|
||||
(以及③地址 + 时间窗);①级(`outbound_ref` 对表)目前只对 channel 路成立,而 channel 路暂无调用方。
|
||||
下面 §5 里 `sys.mail_outbound.message_id`「线程级归属的对表来源」那句话,对生产唯一的发信路径**尚不成立**。
|
||||
根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(跨模块契约变更,单独一件事),已记 `docs/debt.md`。
|
||||
|
||||
## 5. Schema 附录(与 `packages/contracts/src/mail.ts` 同名)
|
||||
|
||||
```ts
|
||||
type MailAccountKind = 'imap-smtp' | 'wecom';
|
||||
|
||||
type ImapSmtpSettings = { imapHost: string /* imap.exmail.qq.com */; imapPort: number /* 993 */; imapTls: boolean /* true */;
|
||||
smtpHost: string /* smtp.exmail.qq.com */; smtpPort: number /* 465 */; smtpTls: boolean /* true = 隐式 TLS;false = STARTTLS */;
|
||||
login?: string /* 缺省 = address */; folder: string /* INBOX */ };
|
||||
type WecomSettings = { corpId: string; apiBase: string /* https://qyapi.weixin.qq.com */ };
|
||||
|
||||
// POST / PUT 的 body(缺省值:displayName '' · enabled true · isDefault false · pollIntervalSec 30(10–3600)· channelId null · backfillFrom null)
|
||||
type MailAccountInput =
|
||||
| { kind: 'imap-smtp'; address: string; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number;
|
||||
channelId?: string | null; backfillFrom?: string | null; settings?: Partial<ImapSmtpSettings>; secret?: { password: string } }
|
||||
| { kind: 'wecom'; name: string; displayName?: string; enabled?: boolean; isDefault?: boolean; pollIntervalSec?: number;
|
||||
channelId?: string | null; backfillFrom?: string | null; settings: { corpId: string; apiBase?: string }; secret?: { corpSecret: string } };
|
||||
|
||||
type MailAccountStatus = { lastPolledAt: string | null; lastPollError: string | null; lastInboundAt: string | null; cursor: string | null; polling: boolean };
|
||||
type MailAccountView = (
|
||||
| { kind: 'imap-smtp'; settings: ImapSmtpSettings }
|
||||
| { kind: 'wecom'; settings: WecomSettings }
|
||||
) & { id: string; name: string; address: string; displayName: string; enabled: boolean; isDefault: boolean; pollIntervalSec: number;
|
||||
channelId: string | null; backfillFrom: string | null; secretBound: boolean; status: MailAccountStatus; createdAt: string; updatedAt: string };
|
||||
|
||||
type MailProbeReport = { kind: MailAccountKind; address?: string;
|
||||
receive: { ok: boolean; error?: string; latencyMs?: number };
|
||||
send: { checked: boolean; ok?: boolean; error?: string; note?: string } };
|
||||
|
||||
type MailPollOutcome = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string };
|
||||
|
||||
type MailAddress = { address: string; name: string };
|
||||
type MailDeliveryView = { subscriber: string; state: 'dispatching' | 'done' | 'failed' | 'skipped'; error: string | null; startedAt: string; finishedAt: string | null };
|
||||
type MailInboundAttachment = { index: number; name: string; contentType: string; size: number; ref: string | null; note: string };
|
||||
type MailInboundSummary = { id: string; accountId: string; messageId: string; from: MailAddress; to: MailAddress[]; cc: MailAddress[]; subject: string;
|
||||
sentAt: string | null; receivedAt: string; textPreview: string; hasHtml: boolean; attachments: MailInboundAttachment[]; deliveries: MailDeliveryView[] };
|
||||
type MailInboundDetail = MailInboundSummary & { inReplyTo: string | null; references: string[]; text: string | null; html: string | null; size: number; rawAvailable: boolean };
|
||||
|
||||
type MailSendResult = { outboundId: string; accountId: string; messageId: string; state: 'sent' | 'failed'; error?: string; providerRef?: string };
|
||||
type MailOutboundView = { id: string; accountId: string; messageId: string; to: string[]; cc: string[]; bcc: string[]; subject: string;
|
||||
state: 'sending' | 'sent' | 'failed'; error: string | null; providerRef: string | null; requestedBy: Record<string, unknown>; createdAt: string; sentAt: string | null };
|
||||
```
|
||||
|
||||
## 6. curl 实录
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
|
||||
-d grant_type=client_credentials -d client_id=bpos-e2e -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
|
||||
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')
|
||||
|
||||
# 建一个 IMAP/SMTP 账号(密码是腾讯企业邮箱的客户端专用密码)
|
||||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts \
|
||||
-d '{"kind":"imap-smtp","name":"销售信箱","address":"sales@corp.com","isDefault":true,"channelId":"ch-rfq-mail","secret":{"password":"xxxx"}}' | jq .
|
||||
|
||||
# 探测(已存)/ 立即拉取 / 试发
|
||||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/probe | jq .
|
||||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/poll | jq .
|
||||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/accounts/$ID/send-test -d '{"to":"me@corp.com","subject":"试发","text":"正文"}' | jq .
|
||||
|
||||
# 来信、附件、重新投递
|
||||
curl -s "${H[@]}" "localhost:8787/api/gov/mail/inbound?accountId=$ID&limit=20" | jq '.mails[] | {subject, deliveries}'
|
||||
curl -s "${H[@]}" localhost:8787/api/gov/mail/inbound/$MAIL/attachments/0 | jq -r .dataBase64 | base64 -d > BOM.xlsx
|
||||
curl -s "${H[@]}" -X POST localhost:8787/api/gov/mail/inbound/$MAIL/redeliver -d '{"subscriber":"channel"}' | jq .
|
||||
|
||||
# 出站
|
||||
curl -s "${H[@]}" "localhost:8787/api/gov/mail/outbound?limit=20" | jq '.mails[] | {subject, state, error}'
|
||||
```
|
||||
|
||||
示例响应按契约构造;真实响应待账号配好后用上面的序列换。
|
||||
@@ -0,0 +1,431 @@
|
||||
# API 文档 —— 鉴权、人员与端(ADR-012)
|
||||
|
||||
> 契约的唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);本文是给前端 / 联调 / 运维读的人话版。
|
||||
> 两者不一致时以契约为准并回来改本文(实施计划 Task 12 会用契约把 §6 的总表重新生成一遍核对)。
|
||||
> 设计出处:`docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`;前端怎么接:`docs/前端对接说明-统一鉴权中心.md`。
|
||||
|
||||
## 0. 通则
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| Base | 同源 `/api/*`(开发期 Vite 把 `/api` 反代到 `:8787`) |
|
||||
| 认证 | `Authorization: Bearer <Keycloak access_token>`;RS256;`iss` = `https://keycloak.akhr-ai.com/realms/corp`;`aud` 须含本服务 clientId —— **本部署是 `task-dispatcher`**(后端环境变量 `KEYCLOAK_CLIENT_ID`) |
|
||||
| 票据 | **GET** 且没带头时认 `?ticket=<60 秒票据>`(取自 `GET /api/auth/ticket`);其它方法不认 |
|
||||
| HEAD | 按 GET 判(框架把 HEAD 改写成 GET 再分发)—— 端归属、票据两处都与同路径的 GET 一模一样 |
|
||||
| 请求体 | JSON,`content-type: application/json` |
|
||||
| 错误体 | `{ "code": string, "message": string, "detail"?: unknown }`;`code` ∈ `unauthenticated` `forbidden` `not-found` `validation-failed` `conflict` `precondition-failed` `internal` `gone` |
|
||||
| 端归属 | 每条路由有 `access`:`public`(不要凭据)/ `any`(登录即可)/ `design` / `ops` / `workbench` / `design\|ops`(能进其中任一端即可) |
|
||||
|
||||
状态码怎么读:
|
||||
|
||||
| 状态 | code | 何时 |
|
||||
|---|---|---|
|
||||
| 401 | `unauthenticated` | 缺 token / 过期 / 签名不对 / aud 或 iss 不对 / 票据坏了(message 逐一说清) |
|
||||
| 403 | `forbidden` | 账号已在鉴权中心停用;或这条路由要求的端不在你的 `ends` 里(`detail.need` / `detail.ends`) |
|
||||
| 404 | `not-found` | 无此路由(**先 401 后 404**:没登录的人探不到路由表)/ 对象不存在 |
|
||||
| 409 | `conflict` | 角色 → 端改完没人能进运营中心;**这次名册同步会让没人能进运营中心**(整体拒绝,一条都不写);删 LLM 实例还有活动引用 |
|
||||
| 410 | `gone` | 路由随统一鉴权中心退役(§5) |
|
||||
| 412 | `precondition-failed` | 入参合法、权限也够,但**这件事此刻办不了**:流程包写回生效版本 / 启用时闭合校验没过、活动包安装两审(静态审计 + 回放)未过、决议已登记过不重复起单、汇报已提交过、入站邮件通道已停用。`detail` 常带闭合报告或安装审计结论 |
|
||||
| 400 | `validation-failed` | 入参不合 schema,`detail` 是 zod issues |
|
||||
|
||||
## 1. 身份
|
||||
|
||||
### `GET /api/me` —— access: `any`
|
||||
|
||||
我是谁、能进哪几个端。**`ends` 为空也 200**(前端据此摆「未授权」页)。
|
||||
|
||||
出参 `Principal`(§7):
|
||||
|
||||
```json
|
||||
{
|
||||
"staff": {
|
||||
"id": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
|
||||
"account": "chaihao",
|
||||
"name": "柴浩",
|
||||
"email": "chaihao@akhr-ai.com",
|
||||
"positionId": "pm",
|
||||
"state": "active",
|
||||
"roles": ["admin"]
|
||||
},
|
||||
"position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" },
|
||||
"roles": ["admin"],
|
||||
"ends": ["design", "ops", "workbench"]
|
||||
}
|
||||
```
|
||||
|
||||
副作用:第一次见到这个 `sub` 会在名册里建档(岗位 `NONE` 未分配);账号 / 姓名 / 邮箱 / 角色变了会写回。
|
||||
|
||||
### `GET /api/auth/ticket` —— access: `any`
|
||||
|
||||
换一枚 60 秒票据,给 `EventSource` 与 `<a href download>` 这两条带不了 Authorization 头的路。
|
||||
|
||||
```json
|
||||
{ "ticket": "v1.OGYzYzFhMmItNmMxZS00YjNhLTlmMGUtMmQ1YzdhMWI5ZTQ0.1756112460.k5vJ…", "expiresAt": "2026-08-25T09:01:00.000Z" }
|
||||
```
|
||||
|
||||
- 只在 GET 的 `?ticket=` 上认;票据里没有权限,后端按它对应的 sub 回库现算(停用 / 改角色即时生效)。
|
||||
- 同时带头与票据时以头为准。
|
||||
- 过期或坏了 → 401 `票据无效或已过期 —— 重新取一张 GET /api/auth/ticket`。
|
||||
|
||||
## 2. 实时推送
|
||||
|
||||
### `GET /api/stream?ticket=…` —— access: `any`(SSE)
|
||||
|
||||
事件:`ready`(连上)、`touch`(哪个目标动了;载荷形状不变,见 `apps/web/src/app/subscribe.ts`)、`ping`(20 秒心跳)。
|
||||
连接只在建立那一刻验一次票据;断线重连要重新取票据(浏览器自动重连用旧 URL 会吃 401 且不再重试)。
|
||||
|
||||
## 3. 员工与岗位(运营中心 · 员工)
|
||||
|
||||
### `GET /api/gov/staff` —— access: `design|ops`
|
||||
|
||||
```json
|
||||
{
|
||||
"staff": [
|
||||
{
|
||||
"id": "8f3c1a2b-…", "account": "chaihao", "name": "柴浩", "email": "chaihao@akhr-ai.com",
|
||||
"positionId": "pm", "state": "active", "roles": ["admin"],
|
||||
"position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" }
|
||||
}
|
||||
],
|
||||
"positions": [
|
||||
{ "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" },
|
||||
{ "id": "bd", "code": "BD", "name": "商务", "note": "RFQ 受理、成本定价、报价议价、情报归档" },
|
||||
{ "id": "fae", "code": "FAE", "name": "现场应用", "note": "替代选型" },
|
||||
{ "id": "prm", "code": "PRM", "name": "采购/寻源", "note": "渠道寻源" },
|
||||
{ "id": "op", "code": "OP", "name": "运营", "note": "运营支持" },
|
||||
{ "id": "test", "code": "TEST", "name": "测试", "note": "测试账号专用岗;整条流经工作组绑定落到本人" },
|
||||
{ "id": "none", "code": "NONE", "name": "未分配", "note": "未指派岗位;流程包不得声明它" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`state` 只由名册同步置 `disabled`(Keycloak 停用或删除),有效 token 不改它;`roles` 是签发时的快照。
|
||||
测试台选负责人也用这条,所以设计端也能读。
|
||||
|
||||
### `POST /api/gov/staff/sync` —— access: `ops`
|
||||
|
||||
从 Keycloak Admin API 同步名册(provisioning 服务账号,只读)。无请求体。
|
||||
|
||||
```json
|
||||
{ "configured": true, "inserted": 2, "updated": 1, "disabled": 0, "total": 14, "failed": [] }
|
||||
```
|
||||
|
||||
三个计数说的是**真落进库里的条数**。单条写失败不让整次同步停在半路:它不进计数,而是点名进 `failed`
|
||||
(`reason` 照抄原话,不改写成「操作失败」):
|
||||
|
||||
```json
|
||||
{ "configured": true, "inserted": 0, "updated": 3, "disabled": 1, "total": 14,
|
||||
"failed": [{ "account": "bob", "reason": "duplicate key value violates unique constraint \"staff_account_key\"" }] }
|
||||
```
|
||||
|
||||
没配 provisioning 时如实回(**不是**一排 0):
|
||||
|
||||
```json
|
||||
{ "configured": false, "message": "未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档" }
|
||||
```
|
||||
|
||||
规则:远端没有的本地人 → `disabled`;远端 `enabled=false` → `disabled`;远端又启用 → `active`;字段没变的不写。
|
||||
|
||||
「缺席即停用」有三道下限保护(全员停用之后,这个按钮自己要 `ops`,那条恢复路会自锁):
|
||||
|
||||
1. 远端**一个人都没有**时不据此停任何人 —— 那更像取数出了问题(realm 配错、代理 200 空体、
|
||||
`view-users` 被改窄),不像全员离职;
|
||||
2. `JCAI_TEST_ACCOUNTS` 里的服务账号缺席不算离职 —— Admin 的 `GET /users` 本来就不返回服务账号用户;
|
||||
3. 落库之前先算一遍「这一次同步之后还有谁能进运营中心」,算下来没有人 → **409 整体拒绝,一条都不写**:
|
||||
|
||||
```json
|
||||
{ "code": "conflict", "message": "这次同步会让没有人能进运营中心,已拒绝(一条都没写)—— 先核对 provisioning 指的是不是同一个 realm,以及那个服务账号还有没有 view-users。" }
|
||||
```
|
||||
|
||||
### `PUT /api/gov/staff/:staffId/position` —— access: `ops`
|
||||
|
||||
body `{ "positionId": "bd" }` → 出参 = 那一行 `staff` 连同 `position`(形状同 §3 列表里的一项)。
|
||||
指回 `NONE` 的 id 等于取消指派。404:员工或岗位不存在。
|
||||
|
||||
### `GET /api/gov/staff/:staffId/in-flight` —— access: `ops`
|
||||
|
||||
此人身上还挂着什么(只读;原「停用前置检查」)。停用本身在 Keycloak 做。
|
||||
|
||||
```json
|
||||
{
|
||||
"staffId": "8f3c1a2b-…",
|
||||
"blocked": true,
|
||||
"openTasks": [{ "taskId": "t-01J…", "title": "客户RFQ受理 · Itelma" }],
|
||||
"openCards": [{ "cardId": "c-01J…", "title": "受理需求审定" }],
|
||||
"openApprovals": []
|
||||
}
|
||||
```
|
||||
|
||||
### 工作组(不变):`GET /api/gov/work-groups` · `PUT /api/gov/work-groups` —— access: `ops`
|
||||
|
||||
- GET → `{ "groups": [{ "id", "name", "ownerId", "members": { "BD": "<staffId>", … }, "ownerName" }] }`
|
||||
- PUT body `{ "ownerId", "name", "members": { "<岗位 code>": "<staffId>" } }` → `{ "group": {…} }`;一人一组,按 `ownerId` 落。
|
||||
|
||||
## 4. 角色 → 端(运营中心)
|
||||
|
||||
一行一个 Keycloak realm 角色;一个人的 `ends` = 他的角色命中行的并集。缺省两行:`admin → design, ops, workbench`;`user → workbench`。
|
||||
新角色在 Keycloak 建;这边 `knownRoles` 把名册上见过的角色列出来供配置 —— 没配置前它等于「无端可进」。
|
||||
|
||||
### `GET /api/gov/access/roles` —— access: `ops`
|
||||
|
||||
```json
|
||||
{
|
||||
"roles": [
|
||||
{ "role": "admin", "ends": ["design", "ops", "workbench"] },
|
||||
{ "role": "user", "ends": ["workbench"] }
|
||||
],
|
||||
"knownRoles": ["admin", "designer", "user"]
|
||||
}
|
||||
```
|
||||
|
||||
### `PUT /api/gov/access/roles/:role` —— access: `ops`
|
||||
|
||||
body `{ "ends": ["design"] }`(端名数组,顺序 / 重复不敏感)→ `{ "role": "designer", "ends": ["design"] }`。
|
||||
**409** `conflict`:改完之后没有任何在册活跃员工能进运营中心 —— 那张表就再也没人改得了,拒绝。名册为空(全新安装)不算锁死。
|
||||
|
||||
### `DELETE /api/gov/access/roles/:role` —— access: `ops`
|
||||
|
||||
→ `{ "ok": true }`;同样的 409 保护。
|
||||
|
||||
## 5. 退役路由(一律 410 `gone`,契约留一轮下个迭代删)
|
||||
|
||||
| 退役 | message 里指的路 |
|
||||
|---|---|
|
||||
| `POST /api/login` | 登录由 Keycloak 完成,请求带 Authorization: Bearer |
|
||||
| `POST /api/logout` | 登出在前端丢弃 token,或调 Keycloak 的 end-session |
|
||||
| `GET /api/session` | 改用 `GET /api/me` |
|
||||
| `POST /api/gov/staff` | 首次登录自动建档,或 `POST /api/gov/staff/sync` |
|
||||
| `GET /api/gov/staff/:staffId/disable-check` | `GET /api/gov/staff/:staffId/in-flight` |
|
||||
| `POST /api/gov/staff/:staffId/disable` | 在 Keycloak 停用后 `POST /api/gov/staff/sync` |
|
||||
| `GET /api/gov/positions/:positionId/grants` | `GET /api/gov/access/roles` |
|
||||
| `POST /api/gov/positions/:positionId/grants` | `PUT /api/gov/access/roles/:role` |
|
||||
|
||||
## 6. 全部路由的端归属(总表)
|
||||
|
||||
规则:**写归所属端;被两端消费的读标两端**。
|
||||
|
||||
| access | 方法 | 路径 |
|
||||
|---|---|---|
|
||||
| `public` | GET | `/api/health` |
|
||||
| `public` | POST | `/api/ingest/channel/:channelId/mail`(外界边界:模拟入站邮件) |
|
||||
| `public`(410) | — | §5 的八条 |
|
||||
| `any` | GET | `/api/me` |
|
||||
| `any` | GET | `/api/auth/ticket` |
|
||||
| `any` | GET | `/api/stream`(SSE) |
|
||||
| `workbench` | GET | `/api/desk/tasks` |
|
||||
| `workbench` | GET | `/api/desk/tasks/:taskId` |
|
||||
| `workbench` | POST | `/api/desk/work-orders/reply` |
|
||||
| `workbench` | POST | `/api/desk/chat-cards/reply` |
|
||||
| `workbench` | POST | `/api/desk/comms/:commId/read` |
|
||||
| `workbench` | POST | `/api/desk/tasks/:taskId/table` |
|
||||
| `workbench` | GET | `/api/desk/tasks/:taskId/file`(`?address=&key=&raw=1`;可用票据) |
|
||||
| `workbench` | POST | `/api/desk/tasks/:taskId/say` |
|
||||
| `workbench` | GET | `/api/desk/messages` |
|
||||
| `workbench` | POST | `/api/desk/messages/:messageId/read` |
|
||||
| `workbench` | POST | `/api/desk/messages/:messageId/answer` |
|
||||
| `workbench` | POST | `/api/desk/trigger-pending/:id/confirm` |
|
||||
| `workbench` | GET | `/api/minutes` |
|
||||
| `workbench` | POST | `/api/minutes` |
|
||||
| `workbench` | POST | `/api/minutes/:minuteId/resolutions` |
|
||||
| `workbench` | POST | `/api/minutes/resolutions/:resolutionId/register` |
|
||||
| `workbench` | GET | `/api/reports/:period` |
|
||||
| `workbench` | GET | `/api/reports` |
|
||||
| `workbench` | POST | `/api/reports/:reportId/submit` |
|
||||
| `workbench` | GET | `/api/approvals` |
|
||||
| `workbench` | POST | `/api/approvals/:workflowId/:nodeId/decide` |
|
||||
| `workbench` | GET | `/api/triggers/registration` |
|
||||
| `workbench` | POST | `/api/triggers/:triggerId/register` |
|
||||
| `design` | PUT | `/api/gov/process` |
|
||||
| `design` | POST | `/api/gov/process/:id/:version/state` |
|
||||
| `design` | DELETE | `/api/gov/process/:id/:version` |
|
||||
| `design` | DELETE | `/api/gov/process/:id` |
|
||||
| `design` | POST | `/api/gov/process/:id/:version/edit` |
|
||||
| `design` | POST | `/api/gov/process/validate` |
|
||||
| `design` | GET | `/api/gov/approval-templates` |
|
||||
| `design` | PUT | `/api/gov/approval-templates` |
|
||||
| `design` | DELETE | `/api/gov/approval-templates/:id` |
|
||||
| `design` | PUT | `/api/gov/triggers`(定义归设计) |
|
||||
| `design` | GET | `/api/gov/activities` |
|
||||
| `design` | POST | `/api/gov/activities` |
|
||||
| `design` | GET | `/api/gov/tools` |
|
||||
| `design` | POST | `/api/gov/tools` |
|
||||
| `design` | GET | `/api/gov/memory/:activityId` |
|
||||
| `design` | GET | `/api/gov/llm/providers` |
|
||||
| `design` | GET | `/api/gov/llm` |
|
||||
| `design` | POST | `/api/gov/llm` |
|
||||
| `design` | DELETE | `/api/gov/llm/:id` |
|
||||
| `design` | POST | `/api/gov/llm/probe` |
|
||||
| `design` | POST | `/api/gov/llm/:id/probe` |
|
||||
| `design` | POST | `/api/gov/testbed/run` |
|
||||
| `design` | POST | `/api/gov/testbed/run-activity` |
|
||||
| `design` | POST | `/api/gov/testbed/stop` |
|
||||
| `design` | GET | `/api/gov/testbed/current` |
|
||||
| `design` | GET | `/api/gov/testbed/activity/:taskId` |
|
||||
| `design` | GET | `/api/gov/testbed/logs` |
|
||||
| `design` | GET | `/api/gov/testbed/logs/:id` |
|
||||
| `design` | DELETE | `/api/gov/testbed/logs/:id` |
|
||||
| `design` | DELETE | `/api/gov/testbed/logs` |
|
||||
| `design` | POST | `/api/gov/upload` |
|
||||
| `design` | GET | `/api/gov/upload/:id` |
|
||||
| `design` | GET | `/api/gov/workflows/:id/trace` |
|
||||
| `ops` | GET | `/api/gov/tasks` |
|
||||
| `ops` | POST | `/api/gov/tasks/:taskId/control` |
|
||||
| `ops` | POST | `/api/gov/tasks/:taskId/resume` |
|
||||
| `ops` | POST | `/api/gov/tasks/:taskId/reassign` |
|
||||
| `ops` | GET | `/api/gov/tasks/:taskId/log` |
|
||||
| `ops` | GET | `/api/gov/approvals` |
|
||||
| `ops` | GET | `/api/gov/metrics` |
|
||||
| `ops` | POST | `/api/gov/triggers/:id/toggle`(启停归运营) |
|
||||
| `ops` | POST | `/api/gov/triggers/:id/confirm` |
|
||||
| `ops` | GET | `/api/gov/packages/:id/owners` |
|
||||
| `ops` | PUT | `/api/gov/packages/:id/owners/:nodeId` |
|
||||
| `ops` | GET | `/api/gov/work-groups` |
|
||||
| `ops` | PUT | `/api/gov/work-groups` |
|
||||
| `ops` | POST | `/api/gov/staff/sync` |
|
||||
| `ops` | PUT | `/api/gov/staff/:staffId/position` |
|
||||
| `ops` | GET | `/api/gov/staff/:staffId/in-flight` |
|
||||
| `ops` | GET | `/api/gov/access/roles` |
|
||||
| `ops` | PUT | `/api/gov/access/roles/:role` |
|
||||
| `ops` | DELETE | `/api/gov/access/roles/:role` |
|
||||
| `ops` | POST | `/api/gov/mail/accounts` |
|
||||
| `ops` | POST | `/api/gov/mail/accounts/probe`(未存草稿探测) |
|
||||
| `ops` | PUT | `/api/gov/mail/accounts/:accountId` |
|
||||
| `ops` | DELETE | `/api/gov/mail/accounts/:accountId` |
|
||||
| `ops` | POST | `/api/gov/mail/accounts/:accountId/probe` |
|
||||
| `ops` | POST | `/api/gov/mail/accounts/:accountId/poll` |
|
||||
| `ops` | POST | `/api/gov/mail/accounts/:accountId/send-test` |
|
||||
| `ops` | POST | `/api/gov/mail/inbound/:mailId/redeliver` |
|
||||
| `ops` | GET | `/api/gov/mail/accounts` |
|
||||
| `ops` | GET | `/api/gov/mail/accounts/:accountId` |
|
||||
| `ops` | GET | `/api/gov/mail/inbound` |
|
||||
| `ops` | GET | `/api/gov/mail/inbound/:mailId` |
|
||||
| `ops` | GET | `/api/gov/mail/inbound/:mailId/attachments/:index`(可用票据) |
|
||||
| `ops` | GET | `/api/gov/mail/inbound/:mailId/raw`(可用票据) |
|
||||
| `ops` | GET | `/api/gov/mail/outbound` |
|
||||
| `design\|ops` | GET | `/api/gov/staff`(测试台选负责人) |
|
||||
| `design\|ops` | GET | `/api/gov/process` |
|
||||
| `design\|ops` | GET | `/api/gov/triggers` |
|
||||
| `design\|ops` | GET | `/api/gov/channels` |
|
||||
| `design\|ops` | GET | `/api/gov/data/objects` |
|
||||
| `design\|ops` | GET | `/api/gov/data/by-activity` |
|
||||
| `design\|ops` | GET | `/api/gov/data/:domain/:object` |
|
||||
| `design\|ops` | GET | `/api/gov/workflows/:id` |
|
||||
|
||||
计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 34 + 两端 8 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。
|
||||
|
||||
## 7. Schema 附录(与 `packages/contracts/src/org.ts` 同名)
|
||||
|
||||
```ts
|
||||
// 三个端
|
||||
type Surface = 'design' | 'ops' | 'workbench';
|
||||
|
||||
// 员工 —— id 就是 Keycloak sub
|
||||
type Staff = {
|
||||
id: string; // Keycloak sub(UUID)
|
||||
account: string; // preferred_username,只作显示
|
||||
name: string; // name claim,缺则 = account
|
||||
email?: string;
|
||||
positionId: string; // 未指派 = 哨兵岗 NONE 的 id
|
||||
state: 'active' | 'disabled';
|
||||
roles: string[]; // realm 角色快照(噪声角色已滤)
|
||||
};
|
||||
|
||||
type Position = { id: string; code: string; name: string; note?: string };
|
||||
|
||||
// GET /api/me
|
||||
type Principal = { staff: Staff; position: Position; roles: string[]; ends: Surface[] };
|
||||
|
||||
// 角色 → 端
|
||||
type RoleAccess = { role: string; ends: Surface[] };
|
||||
|
||||
// POST /api/gov/staff/sync
|
||||
type RosterSyncOutcome =
|
||||
| { configured: false; message: string }
|
||||
| {
|
||||
configured: true;
|
||||
inserted: number; // 三个计数 = 真落进库里的条数,不是打算写几条
|
||||
updated: number;
|
||||
disabled: number;
|
||||
total: number; // 远端这一次回了几个人
|
||||
failed: Array<{ account: string; reason: string }>; // 逐条落库时失败的人,原话照抄
|
||||
};
|
||||
|
||||
// GET /api/gov/staff/:id/in-flight
|
||||
type InFlightWork = {
|
||||
staffId: string;
|
||||
blocked: boolean;
|
||||
openTasks: Array<{ taskId: string; title: string }>;
|
||||
openCards: Array<{ cardId: string; title: string }>;
|
||||
openApprovals: Array<{ approvalId: string; title: string }>;
|
||||
};
|
||||
|
||||
// 工作组(ADR-011,不变)
|
||||
type WorkGroup = { id: string; name: string; ownerId: string; members: Record<string /* 岗位 code */, string /* staffId */> };
|
||||
|
||||
type ApiError = { code: 'unauthenticated' | 'forbidden' | 'not-found' | 'validation-failed' | 'conflict' | 'precondition-failed' | 'internal' | 'gone'; message: string; detail?: unknown };
|
||||
```
|
||||
|
||||
## 8. curl 实录
|
||||
|
||||
用服务账号拿 token(自动化的正道,手册 §10;该 client 需 `serviceAccountsEnabled`,且 audience mapper **指向本服务 clientId** ——
|
||||
服务账号 token 默认不含自己的 clientId,漏了这条就会 401 并点名让你加 mapper):
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token \
|
||||
-d grant_type=client_credentials -d client_id=task-dispatcher -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token)
|
||||
|
||||
# 我是谁 —— 下面是 2026-08-26 的实测响应(服务账号首次调用即被 JIT 建档,落「未分配」岗)
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq .
|
||||
# {
|
||||
# "staff": { "id": "49a85fc7-020c-4098-ba38-af7b138129ea",
|
||||
# "account": "service-account-task-dispatcher",
|
||||
# "name": "service-account-task-dispatcher",
|
||||
# "positionId": "3e36f2dd-982b-4412-bb1a-4c12a93c053d",
|
||||
# "state": "active", "roles": ["service", "user"] },
|
||||
# "position": { "id": "3e36f2dd-…", "code": "NONE", "name": "未分配",
|
||||
# "note": "未指派岗位;流程包不得声明它" },
|
||||
# "roles": ["service", "user"],
|
||||
# "ends": ["workbench"] ← service 没配进「角色→端」表,只有 user 生效
|
||||
# }
|
||||
|
||||
# 没带 token → 401
|
||||
curl -s http://localhost:8787/api/me | jq .
|
||||
# { "code": "unauthenticated", "message": "缺少 Bearer token" }
|
||||
|
||||
# 端不够 → 403(实测响应)
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/tasks | jq .
|
||||
# { "code": "forbidden", "message": "进入「运营中心」需要授权;你的角色(service, user)可进:工作台", "detail": { "need": ["ops"], "ends": ["workbench"] } }
|
||||
|
||||
# aud 不对 → 401,话直接点到要改哪儿(实测响应:mapper 补上之前就是这个)
|
||||
# { "code": "unauthenticated", "message": "token 的 aud 不含 \"task-dispatcher\" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper" }
|
||||
|
||||
# 票据 + SSE
|
||||
# 实测:{ "ticket": "v1.NDlhODVmYzctMDIwYy00MDk4LWJhMzgtYWY3YjEzODEyOWVh.1787712844.x6HoZb7E…",
|
||||
# "expiresAt": "2026-08-26T02:54:04.000Z" } ← 中段是 base64url 的 sub,末段是 HMAC
|
||||
TICKET=$(curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq -r .ticket)
|
||||
curl -s -N "http://localhost:8787/api/stream?ticket=$TICKET"
|
||||
# event: ready
|
||||
# data: 1
|
||||
|
||||
# 角色 → 端
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq .
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
-d '{"ends":["design"]}' http://localhost:8787/api/gov/access/roles/designer | jq .
|
||||
|
||||
# 名册同步 / 指派岗位
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/staff/sync | jq .
|
||||
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
-d '{"positionId":"bd"}' http://localhost:8787/api/gov/staff/8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44/position | jq .
|
||||
|
||||
# 退役路由 → 410
|
||||
curl -s -X POST http://localhost:8787/api/login | jq .
|
||||
# { "code": "gone", "message": "已随统一鉴权中心退役:登录由 Keycloak 完成,请求带 Authorization: Bearer <token>" }
|
||||
```
|
||||
|
||||
Task 12 已用契约(`packages/contracts/src/api/*.ts` + `org.ts`)核过本文:§6 总表逐行比对生成表、§7 字段名逐个核对 schema —— 全部一致,零改动。
|
||||
**2026-08-26 补:已在真环境跑通,§8 的响应示例换成了实测响应。** 当时的部署事实:
|
||||
`KEYCLOAK_CLIENT_ID=task-dispatcher`;`task-dispatcher` / `web-portal` / `web-console` 三个 client 都挂了指向
|
||||
`task-dispatcher` 的 audience mapper;realm 里 8 个用户,5 个持 `admin`(→ 三端)、3 个持 `user`(→ 工作台)。
|
||||
实测验过:验签 · claims 归一(噪声角色已滤)· JIT 建档 · 角色→端 · 端判定(工作台 200 / 运营中心 403)·
|
||||
票据签发与 SSE 连通 · 票据在 POST 上被拒 · `HEAD` 与 `GET` 判定一致 · 退役路由 410。
|
||||
仍未实测的只有一件:**用真人 token 走一遍**(要前端接入,或临时开 Direct Access Grants,手册 §6.1)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-010 鉴权会话方案
|
||||
|
||||
状态:已接受(2026-07-27)
|
||||
状态:已接受(2026-07-27);已被 ADR-012 取代(2026-08-25)
|
||||
背景出处:编码规范 §4「鉴权定调:单组织 cookie session —— 登录、会话与岗位权限校验中间件在
|
||||
apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制」;
|
||||
§12 留白清单「鉴权会话与凭证加密方案 —— 实现期再定,各建 ADR 后方可动工」。
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# ADR-012 统一鉴权中心(Keycloak):身份、名册与「角色 → 端」
|
||||
|
||||
状态:已接受(2026-08-25)
|
||||
背景出处:`docs/keycloak-backend-guide.md`(Keycloak 26.7.1 / jose,核实日期 2026-08-12);
|
||||
设计稿 `docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`。
|
||||
取代:ADR-010(口令 + 服务端会话)与编码规范 §4 原「不引 OAuth/OIDC」的定调。
|
||||
|
||||
## 问题
|
||||
|
||||
公司有了统一鉴权中心,本系统还在自建账号口令、服务端会话表与岗位授权(客体三类 × 动作三种)。
|
||||
人要在两处维护,权限模型也比产品实际需要的细太多 —— 用户只要「不同角色可以动态配置能进哪几个端」。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **资源服务器,不做 BFF**:前端(`web-portal` / `web-console`,PKCE)自取 token;后端只本地验签
|
||||
Bearer JWT(`jose`:RS256 写死、校 iss 与 aud、30s 容差、远程 JWKS 缓存 10 分钟 / 冷却 30 秒),
|
||||
不回源、不 introspect、**不配 client secret**。
|
||||
2. **身份在 token 里**:`Staff.id` = `sub`;`account` = `preferred_username` 只作显示;
|
||||
首次见到即建档(JIT),账号 / 姓名 / 邮箱 / 角色变了才写,`last_seen_at` 60 秒内不重写。
|
||||
3. **权限只到端**:三个端 design / ops / workbench;`sys.role_access` 一行一个 realm 角色,
|
||||
运营中心可改;一个人的端 = 其角色命中行的并集。缺省 `admin → 三端`,`user → workbench`。
|
||||
**锁死不变量**:改完至少一名在册活跃员工仍能进运营中心,否则 409。
|
||||
4. **路由归属进契约**:`RouteContract.access` 必填(public / any / 端 / 端数组);app 层一处中间件
|
||||
按注册表判 401 / 403;治理路由里 59 处逐条守卫整体删除(governance-ops 45 + governance 14);覆盖测试 fail-closed。
|
||||
5. **名册**:岗位仍是本系统的表(哨兵岗 `NONE / 未分配`,运营中心指派);名册经 Admin API 同步
|
||||
(master realm 的 provisioning 服务账号,只要 `view-users`)+ 首登即建。`state` 只由同步置
|
||||
`disabled`(Keycloak 停用或删除),有效 token 不改它 —— 撤权在同步之后即时 403。
|
||||
6. **票据**:`EventSource` 与 `<a href download>` 带不了 Authorization 头 —— `GET /api/auth/ticket`
|
||||
换一枚 60 秒 HMAC 票据(`HKDF(JCAI_SECRET_KEY, 'jcai-ticket')`),只在 GET 的 `?ticket=` 上认;
|
||||
票据里没有权限,中间件拿 sub 回库现算。无状态、不单次:60 秒重放窗换零状态表。
|
||||
**横向范围要说明白:票据里既然没有用途,它在这 60 秒里就等于持票人的全部读权限** ——
|
||||
凭一枚为下载附件取的票据,同一分钟内照样读得了 `GET /api/gov/data/:domain/:object`(整张客户主数据)。
|
||||
这**不是提权**(该用户本就有这些读权限),风险在**留痕面**:票据走在 URL 上,
|
||||
浏览器历史、反向代理日志、`Referer` 都可能把它留下来。收敛做法见 `docs/debt.md` 鉴权段
|
||||
(签发时把用途绑进票据,或中间件对认票据的路径设白名单)。
|
||||
7. **退役**:`login` / `logout` / `session` / 建人 / 停用 / 岗位授权八条路由回 410(新错误码 `gone`),
|
||||
契约标 `@deprecated` 留一轮(§12.4 三步);`sys.session` `sys.position_grant` `staff.password_hash` drop。
|
||||
8. **测试账号**:`JCAI_TEST_ACCOUNTS`(Keycloak 用户名)命中者落 TEST 岗 + 独立工作组(ADR-011 原语义);
|
||||
e2e 与试跑用服务账号(client_credentials),不用人的账号。
|
||||
|
||||
## 后果
|
||||
|
||||
- 撤角色的时效 ≤ 5 分钟(token 寿命);停用经同步后即时。
|
||||
- **权限只到端,粒度就到此为止** —— 这是有意的收敛,但半年后有人问起时,答案得在这儿写着:
|
||||
`design` 现在同时意味着装活动、跑测试台、传文件、探大模型;`design|ops` 意味着
|
||||
`GET /api/gov/data/:domain/:object` 能翻整张客户主数据(**从前要 `data:read` 这类客体授权才行**)。
|
||||
产品要的就是「不同角色能进哪几个端」,再细的粒度没有需求撑着;真要收窄,
|
||||
该走的是「给那几条路由单列一个端」,不是把客体 × 动作那套授权请回来。
|
||||
- web 需按 `docs/前端对接说明-统一鉴权中心.md` 改造(登录 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读 `/api/me` 的 `ends`),
|
||||
改造前登不进 —— 这是「只做后端」的既定后果。
|
||||
- **部署顺序是先跑迁移、再 `reset-runs`**:迁移 0032 清空 `sys.staff` 并连带清掉两张持有身份的
|
||||
**配置**表(`sys.node_owner`、`sys.trigger` 的确认位);而 `sys.task.owner_id` 这类**无外键**的
|
||||
运行数据列不受迁移约束,若顺序颠倒,这些运行数据会指着一个已经不存在的身份变成孤儿 ——
|
||||
`reset-runs` 必须排在迁移之后跑,才能把这批指着旧身份的运行数据一并清掉。
|
||||
- Keycloak 侧前置(管理员):本服务 client(建议复用 `api-gateway`)、`web-console` 补 audience mapper、
|
||||
provisioning 服务账号、e2e 服务账号 client(`bpos-e2e`,realm 角色 admin,firstName「AI」)。
|
||||
- 欠账见 `docs/debt.md`「鉴权」段。
|
||||
@@ -0,0 +1,96 @@
|
||||
# ADR-013 邮件收发:外界适配的两条路、入站先持久后分发、出站留痕
|
||||
|
||||
状态:已接受(2026-08-25)
|
||||
背景出处:设计稿 `docs/superpowers/specs/2026-08-25-mail-design.md`;腾讯企业邮箱 / 企业微信官方文档
|
||||
(核实日期 2026-08-25,链接列在设计稿开头)。
|
||||
关联:ADR-008(投递记录即队列)、ADR-009(凭证密封)、ADR-012(票据 / 权限只到端)。
|
||||
|
||||
## 问题
|
||||
|
||||
`channel` 模块(L2)早就有整条「入站归一 → 事件 → 触发器起单 / 任务监听 / 消息兜底」与出站 `MailTransport` 端口,
|
||||
但两端都是替身:出站落库不真发,入站只能经 `/api/ingest` 注入;`mail.send` 工具回 `{queued:true}` 从不触达世界。
|
||||
系统要接真信箱(腾讯企业邮箱),要给活动一个能发信的口,要让来信真的进入事件链;账号参数要能在前端配。
|
||||
腾讯的两条接入路(IMAP/SMTP、企业微信邮件 API)都**没有带内容的推送**:收信只能轮询拉。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **新模块 `mail`(L1),channel 作首个订阅方**:`mail` 管账号(配置 + 密封凭证)、两条接入路的适配器、
|
||||
轮询拉取、入站 / 出站留痕、订阅注册表;app 装配把 `channel.ingestMail` 注册为订阅方、把 channel 的
|
||||
`MailTransport` 注入为「转 `mail.send`」。channel / toolbox 的契约一字不动,也不 import `mail`。
|
||||
2. **两条接入路一个端口**:`MailProvider { probe, anchor, pull, send }`,`imap-smtp`(imapflow + nodemailer)与
|
||||
`wecom`(企业微信邮件 API,应用邮箱)各一个适配器;MIME 解析共用 postal-mime(`read_mail` 回的也是 EML)。
|
||||
批准三个新依赖(§12.5 #3):`imapflow` `nodemailer@8` `postal-mime`。
|
||||
3. **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`,
|
||||
重复拉到静默跳过;首次不回填(锚在当下,`backfillFrom` 才回填);IMAP 只读打开,不改已读标记。
|
||||
**两条接入路的游标推进规则不是同一种,不能一句「逐封/逐批」带过**:IMAP 随每一封成功入库**逐封前进**
|
||||
(每封给出自己的 `cursorAfter` = 该封 UID),`more` 与游标推进彼此独立,失败停在最后一封成功处;
|
||||
企业微信是**整窗全有或全无** —— 只有这一窗的新信全吃完(`more === false`)才把游标推到窗口上沿,
|
||||
被本轮预算截断时**游标原样退回**(`more = !exhausted || fresh.length > wanted.length`)——
|
||||
因为它的窗口是时间区间,推早了、没读到的那些就被永久跳过去了(重叠窗只兜 10 分钟);
|
||||
退回不会卡住,已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。
|
||||
**入站的线程身份取「根」,不取最近一跳**:我方出站登记的 threadId 恒是这一串的**首封**
|
||||
(`channelTransport.send` 回 `input.threadId ?? messageId`,channel 据此 `recordOutbound`),
|
||||
而归属解析①级是精确等值比对。若入站取 `In-Reply-To`(最近一跳),跑一个来回就漂:
|
||||
我方 M1 → 客户回 M1(①命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 →
|
||||
库里从没记过 M2 ⇒ ①级落空,退到③地址+时间窗。故取 `references[0] ?? inReplyTo`;
|
||||
客户端只给 `In-Reply-To` 时退回它,全新来信两者皆无则不带线程。
|
||||
**这条口径全仓只有一处**:mail 模块 domain 的 `threadIdOf`,`mail-wiring.ts` 的 `toInboundMail` 调它
|
||||
——2026-08-25 终审逮到过两套相反的实现(导出的那个取最近一跳、app 里内联的取根),已并成一份。
|
||||
4. **分发自动只投一次**:每个订阅方一条留痕 `dispatching → done | skipped | failed(原话脱敏)`,不自动重试 ——
|
||||
channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次。
|
||||
订阅是代码级装配(不落库、不回放老信)。
|
||||
5. **跨进程租约**:`sys.mail_account.polling_until` 一条 SQL 判 + 占(与 `sys.task.advancing_until` 同款),
|
||||
过期 max(5 分钟, 3 × 间隔);两个后端连同一个库时一个信箱同一时刻只有一个在拉。
|
||||
6. **出站先落行**:`sys.mail_outbound` `sending → sent | failed`。带 `idempotencyKey` 的重发分**三支**,
|
||||
落在 `mail-service.ts` 的 `send()` 里 ——
|
||||
已 `sent` 的**短路**,原样回上次的结果、不再发;`failed`、以及**停在 `sending` 超过 5 分钟**
|
||||
(`SEND_STALE_MS`)的(视为上次崩在半路)复用那一行**重发**;**停在 `sending` 不足 5 分钟**
|
||||
(视为正在飞)则**不发**,如实回 `{state:'failed', error:'同一幂等键的上一封仍在发送中(Ns),
|
||||
本次未重复发送'}`。「在飞」与「崩在半路」在库里长得一模一样,只能靠这个 5 分钟时限分开:
|
||||
宁可让调用方再试一次,也不能发出第二封真邮件。**同键不发两封是契约写死的**;
|
||||
厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成 `send-failed`(可重试),
|
||||
channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应,放行时经 `ToolInvoker`
|
||||
执行,幂等键随调用一起带来);`connectionKeys: ['account']` 说「从哪个信箱发」。
|
||||
**幂等保护只在这一条路上**:`channel.sendMail`(`channelTransport.send`)与运营面
|
||||
`POST …/send-test` 都不传 `idempotencyKey`,不受这三支逻辑保护 —— 各自的调用方式本就不重放。
|
||||
**复用旧行之前还要抢**(2026-08-25 终审补):判「可以复用」与「真去发」之间隔着 await,
|
||||
两个后端连同一个库时两台会同时判出同一个结论 —— 都发就是两封真邮件。故 `OutboundStore.claimForResend`
|
||||
是一条条件更新(`failed`、或 `created_at <= now − 5min` 的 `sending` 才抢得到;抢到即置 `sending`
|
||||
并把 `created_at` 推到 now,那一行当场离开可抢的集合),**抢到才发**,抢不到回
|
||||
`{state:'failed', error:'同一幂等键的上一封正被另一路重发,本次未重复发送'}`。
|
||||
**`outbound_ref` 的实情**:`event.recordOutbound` 的唯一调用方是 `channel.sendMail`,而
|
||||
`channel.sendMail` 在 src 里**零调用方** —— 放行落地走 `deps.tools.execute` → `mail.send` 工具,不经 channel。
|
||||
于是**活动经 `mail.send` 发出的信不登记 `outbound_ref`**:回信归属只靠②字段提取与触发器(以及③地址 + 时间窗),
|
||||
①级(`outbound_ref` 对表)目前只对 channel 路成立,而 channel 路暂无调用方。
|
||||
根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(跨模块契约变更,单独一件事),已记 debt。
|
||||
7. **凭证**:`secret_cipher` 密封(AES-256-GCM,`JCAI_SECRET_KEY`);视图只回 `secretBound`;更新时 secret
|
||||
缺省 = 不改;明文只在用例打开、交给 provider 那一段;所有落库 / 上接口的厂商原话先脱敏(明文 / base64 / URL 编码三形)。
|
||||
8. **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发,
|
||||
`compose_send` 没有只验不发的口);wecom 的地址探到之前是空串;拉取错误摆厂商原话;探测 / 试发 / 拉取的
|
||||
厂商失败在 200 的 body 里说,不编状态码。
|
||||
9. **§7.3「两缝」的解释**:`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录
|
||||
(回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(可录可放),系统侧出站留痕
|
||||
`sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」在本模块的落法。
|
||||
10. **15 条路由读写都归运营中心 `ops`,不设计端两端共读**:`route-access.test.ts`(编码规范 / ADR-012)钉着
|
||||
一份有意枚举、随权限模型一起审过的「两端共用读路由」名单(原 8 条)。设计稿原把 7 条 GET 标成
|
||||
`design|ops`,会让这份名单长到 14 条。落地时改成路由、不改名单 —— ①邮箱那一屏按本模块自己的前端
|
||||
对接说明落在运营中心,设计端没有消费方(测试台读的是通道配置,不是邮箱账号);②入站正文装的是
|
||||
客户来往内容,ADR-012 已把「设计端能读客户主数据」记为一处有意承担的代价,不再无故加宽;
|
||||
③要往那份名单里加,得有真实消费方撑着,现在没有。故 15 条(账号 CRUD / 探测 / 拉取 / 试发 /
|
||||
入站 / 出站 / 附件 / 原件 / 重投)一律 `access: 'ops'`。
|
||||
|
||||
## 后果
|
||||
|
||||
- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。
|
||||
- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。
|
||||
还有一半要说清:wecom 的 `compose_send` 载荷里**没有 `inReplyTo` / `references` 的位置**
|
||||
(适配器发出去的就是 to/cc/bcc/subject/content/attachment_list)—— 所以 **wecom 账号发出的信在对方的邮件客户端里
|
||||
不与来信串成一个线程**;imap-smtp 那条路走 nodemailer,两个头都带得上,线程正常。
|
||||
- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。
|
||||
- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串),
|
||||
唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。
|
||||
- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 ——
|
||||
`channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。
|
||||
- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、**留账号但清它的游标与拉取状态**
|
||||
(留着游标而信没了,wecom 那十分钟重叠窗会把最近的信当新信再投一次)。
|
||||
- 欠账见 `docs/debt.md`「邮件收发」段。
|
||||
+165
-7
@@ -56,14 +56,28 @@ ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照
|
||||
`.parallel()` 任一失败即整体失败(失败走向不进 Mastra)、三态聚合投影
|
||||
(读实例表不读 run 状态)、`node_instance` 投影表照写(发号权在主工程)。
|
||||
|
||||
## 鉴权(ADR-010 落地后的已知欠账)
|
||||
## 鉴权(ADR-012 落地后的已知欠账)
|
||||
|
||||
- **登录失败无次数限制**:可无限次试口令。单组织 ≤50 人、内网部署下暂缓;
|
||||
接入外网前必须补(按账号 + 来源 IP 双维度退避)。
|
||||
- **无口令强度校验、无首登强制改密**:全员初始口令相同(`JCAI_SEED_PASSWORD`),
|
||||
谁都没改过就等于没有口令。治理台「员工」面需补:改密 / 重置 / 强制改密标记。
|
||||
- **`setPassword` 已在 org 出口就位但无路由**:改密只能经播种或直接调用例,界面上没有入口。
|
||||
- **会话无「登出全部设备」**:`revokeAllOf` 已实现(停用员工时调),但本人无从触发。
|
||||
- **撤角色时效 ≤ 5 分钟**:改了 Keycloak 里的角色,现有 access token 到期前仍按旧角色算(手册 13.4);
|
||||
要立即生效在 Keycloak 上 `POST /users/{id}/logout`。停用经名册同步后即时 403。
|
||||
- **票据 60 秒可重放窗**(无状态换来的):绑定 sub、只认 GET;要单次得加一张已用票据表。
|
||||
- **票据没有用途,横向范围 = 持票人的全部读权限**:它走在 **URL** 上(浏览器历史 / 反代日志 /
|
||||
`Referer` 都可能留下),而中间件只认「这枚票据是谁的」,不认「取它是为了干什么」——
|
||||
一枚为下载附件取的票据,同一分钟内照样读得了 `GET /api/gov/data/:domain/:object`(整张客户主数据)。
|
||||
**不是提权**(那些读权限他本来就有),风险在留痕面。收敛做法二选一:
|
||||
①签发时把用途绑进票据(`v1.<sub>.<scope>.<exp>.<mac>`,scope ∈ {stream, file},中间件按路由核 scope);
|
||||
②中间件对「认票据」的路径设白名单(只有 `/api/stream` 与那两条下载路由认 `?ticket=`)。
|
||||
- **名册同步是手动按钮**(运营中心 · 员工 · 同步),没有定时;没配 provisioning 就只有首登即建。
|
||||
- **web 待按《Keycloak 前端接入手册》接入**:登录页 / Bearer / 401 跳转 / SSE 与下载改走 `GET /api/auth/ticket` /
|
||||
入口亮灯改读 `GET /api/me` 的 `ends`;接入前登不进。那一刻一并清掉 `SURFACE_FNS` 与退役契约。
|
||||
- **退役契约下个迭代删**(§12.4 三步之第三步):`loginRoute` `logoutRoute` `sessionRoute` `createStaffRoute`
|
||||
`disableStaff*Route` `listGrantsRoute` `setGrantsRoute` 与 `sessionSchema` / `grant*` / `governanceFunction*` / `disableCheckSchema`。
|
||||
- **`groups` 未用**:日后要按部门过滤数据,需 Keycloak 配 group membership mapper。
|
||||
- **aud mapper 未配即全线 401**:401 的 message 会点名「给发 token 的那个 client 加 audience mapper」,但没有启动期探测。
|
||||
- **无库环境里,路由覆盖的 fail-closed 保险会静默消失**:`apps/api/src/app/__tests__/route-coverage.it.test.ts`
|
||||
用 `describe.skipIf(database === undefined)` 跳过(同目录另外三份 it-test 同款,是仓库级惯例)——
|
||||
没有库的环境里这条测试根本不跑,进程仍 exit 0;正在搭 CI 的人必须先给它一个库,
|
||||
否则「写了路由处理器却没在契约里声明 access」这类漏洞会在 CI 里悄悄放过。
|
||||
|
||||
## 工作组(ADR-011 落地后的已知欠账)
|
||||
|
||||
@@ -1023,3 +1037,147 @@ S1 被收了两遍(两条 rationale 只差一个标点,是两次独立采样)。
|
||||
「抄名单逐条核对,一家都不能多」看着(实测这半边模型守得住;漏家那半边守不住,
|
||||
所以才立的机检)。真要机检集合相等,得让基座出「A 表某列的值集 ⊆ B 表某列的值集」
|
||||
这类读数 —— 等第二次真踩到再立,别为想象中的路修桥。
|
||||
|
||||
## 2026-08-25 · CI 接上了主干,PR 之前仍是空门
|
||||
|
||||
**已接**(`.gitea/workflows/ci.yml`,host 模式 runner、label `ubuntu-latest`):push 到
|
||||
`mvp-foundation` 时跑 五道门禁 → 生成物漂移检查 → 建一次性库并从空库跑完整条迁移链 →
|
||||
单元与集成测试 → 三份生产构建(api / web / 活动包)。取代码是纯 git,**一个第三方 action
|
||||
都不依赖**;包与镜像源走 npmmirror。实测本地全程约 30 秒(静态门禁 14s、迁移 1s、
|
||||
1705 条测试 7s、三份构建 6s)。
|
||||
|
||||
**还欠三处**:
|
||||
|
||||
① **PR 合并之前不跑**(用户当场定的口径:「只允许主支推送或合并进主支才触发」)——
|
||||
坏代码得先进了主干才发现,`docs/编码规范.md` §12.7 写的「合入前五门禁全绿」这道要求
|
||||
在机器那一侧仍然没人看着。要改成合并前把关,`on:` 下补一段
|
||||
`pull_request: branches: [mvp-foundation]` 即可,别的一行不用动。
|
||||
|
||||
② **活动 e2e 不进 CI**(`tools/e2e-a2.mjs` 那八套):要真模型密钥、单案例 20 分钟封顶、
|
||||
一跑几十万 token。也就是说「E04 / I01 跑不跑得通」这件事**没有任何自动化在看着**,
|
||||
仍然靠人手工跑。要接的话它该是另一条手动/定时的 workflow,不是 PR 门禁。
|
||||
|
||||
③ **构建产物不归档**:上传要 `actions/upload-artifact`,又是一个得出网拉的第三方 action;
|
||||
本轮口径是能不用就不用。要归档时再加。
|
||||
|
||||
**另记一处失败开口**:`tools/api-report.mjs` 的 git 比对一旦抛错(换 uid 后的
|
||||
dubious ownership 之类),`catch` 里印一句「非 git 仓库,跳过比对」然后 **exit 0** ——
|
||||
契约门禁当场变成空门。CI 侧已由「生成物无漂移」那一步(跑完自己再 `git status` 一遍)
|
||||
堵住,**本地 `pnpm gates` 仍然是开口的**:谁在一个 git 出问题的环境里跑,它会安静地放行。
|
||||
|
||||
**顺带一条实测账(接 CI 时踩的)**:`createdb` 出来的空库**直接 `pnpm db:migrate` 迁不动** ——
|
||||
`db/migrations/0026_object_meta.sql` 往 `ledger.__object_meta` 建表,而迁移链只会替自己
|
||||
建 `sys`(drizzle 的 `migrationsSchema`)。四个 schema 一直是 docker-compose 把
|
||||
`db/init/` 挂进 `/docker-entrypoint-initdb.d`、在**首次起卷**时灌的 —— 换任何一条不经
|
||||
compose 的路子建库,都得自己先灌一遍。`tools/ci.mjs db-create` 已经这么做(灌的是
|
||||
`db/init/` 那份文件本身,不另抄一份)。
|
||||
|
||||
## 2026-08-25 · CD 接上了,五处「信号全绿而事情没办成」各配了一道机检
|
||||
|
||||
**已接**(`.gitea/workflows/ci.yml` 后半段 14 步 + `tools/cd.mjs` 八个子命令 + `docs/部署.md`):
|
||||
门禁全绿 → 前置断言 → 备份 → 确保库在 → 发布到 `releases/<sha>` → 迁移 → 迁移到位断言 →
|
||||
判首次/升级 → 播种或装活动包 → 回查 → 发前端 → pm2 重启 → 探活 → 收尾。
|
||||
部署在 runner 本机(host 模式),路径类配置从 Gitea Variables 读,机密从 Secrets 读。
|
||||
|
||||
接的过程中逐条对上了五个**会静默成功**的机制,每个都配了一道机检:
|
||||
|
||||
| 机制 | 它会怎么骗人 | 机检 |
|
||||
|---|---|---|
|
||||
| `main.ts` 的入口守卫是 `import.meta.url === \`file://${process.argv[1]}\`` 字符串比较 | 经软链启动两边不等 → `boot()` 永不执行 → **进程零输出 exit 0**(实测过) | pm2 一律用 `releases/<sha>` 真实路径起;探活兜底 |
|
||||
| `install-activities` 所有失败路径都是「打印一行然后 continue」 | 一个包都没装成也 exit 0 | 装完回查 `sys.activity_package`(`activities-verify`) |
|
||||
| `savePackage` 是 `onConflictDoNothing`,主键 `(activity_id, version)` | 版本没升就什么都不更新,日志照说「已装」 | 版本没变但产物文本变了 → **当场红**(`activities-plan`) |
|
||||
| drizzle 只取 `max(created_at)` 当水位线,`hash` 列只写不读 | 新迁移的 `when` ≤ 水位线会被永久静默跳过,而 migrate 照样打印「完成」 | 已应用条数 == journal 条数(`migrate-verify`) |
|
||||
| `/api/health` 用 `c.json()` 没传 status | 库挂了照样返 200,只是 body 里 `db:"down"` | 探活验 body(`health`) |
|
||||
|
||||
**还欠**:
|
||||
|
||||
① **无版本可见性**。`/api/health` 的 `version` 是写死的 `CONTRACTS_VERSION = '0.1.0-m1'`,
|
||||
从 M1 至今没动过;全仓没有任何地方注入 git sha(`tsup.config.ts` 无 `define`,`vite.config.ts` 也没有)。
|
||||
部署完只能靠 `releases/<sha>/RELEASE` 文件与 `sys.activity_package.installed_at` 间接确认「新版真上去了」。
|
||||
要补的话最小侵入是 tsup 加一个 `define: { __GIT_SHA__ }` 再塞进 health 的出参 ——
|
||||
但那是契约变更(`/api/health` 还是**免鉴权**的,暴露 commit sha 要先想清楚)。
|
||||
|
||||
② **`main.ts` 没有 graceful shutdown**。`serve()` 的返回值没被接住,HTTP server 从不 `close()`;
|
||||
SIGTERM 直接 `process.exit(0)` —— 在途请求与**所有 SSE 长连接**硬切。
|
||||
换版本时必然掉一次连接(前端 EventSource 会自己重连,所以影响小,但不是零)。
|
||||
|
||||
③ **换版本有几秒空窗**。pm2 是 `delete` + `start`(脚本路径每版都变,`restart` 会留在旧路径),
|
||||
中间那几秒 502。要做到零停机得上两个端口 + nginx upstream 切换,MVP 阶段先不做。
|
||||
|
||||
④ **升级时 `activities:install` 的副作用还在**:它会按主键 upsert 覆盖 8 行样例主数据
|
||||
(含 `CU-0142` 的 `coverage_score`),并发出 8 个 data 事件 —— 生产库里若有启用的 data 触发器,
|
||||
会凭空起 8 张单。当前是靠「版本没变就不装」把它挡在门外,而不是根治。
|
||||
根治要一个「只装包不写样例」的入口,那是改代码。
|
||||
|
||||
⑤ **`reset-runs.js` 会被 tsup 打进 dist 一起发到生产机**(`tsup.config.ts` 里它是显式 entry)。
|
||||
没人调它,但它就在 `apps/api/dist/reset-runs.js` 躺着,一条命令就能 TRUNCATE 33 张表。
|
||||
|
||||
⑥ **每版一份 node_modules**(`pnpm install --prod` 在发布目录里跑,实测 214M)。
|
||||
pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人。留最近 5 版。
|
||||
|
||||
## 2026-08-25 · 邮件收发接上了,留下十五笔账(ADR-013)
|
||||
|
||||
① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次;
|
||||
根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。
|
||||
② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。
|
||||
③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。
|
||||
④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。
|
||||
⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。
|
||||
⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。
|
||||
⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。
|
||||
要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。
|
||||
另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。
|
||||
⑧ **`setState(id,'sent',…)` 的「不留错误话」是展开顺序撑出来的,不是真不变量**(`mail-store.ts`):
|
||||
`{ ...(state==='sent'?{error:null}:{}), ...(patch.error===undefined?{}:{error:patch.error}) }` 里 `patch.error` 排在后面,
|
||||
`setState(id,'sent',{error:'x'})` 在类型上合法且会让它赢,静默破掉「sent 的行没有错误话」这条 store 侧不变量。
|
||||
当下没有调用点传得到这个组合,只是没人踩雷;把 `patch.error` 那条挪到 `state==='sent'` 判断前面即可让它成为真不变量。
|
||||
⑨ ~~**`parseCursor` 只查类型不查取值范围**(`cursor.ts`)~~(2026-08-25 终审修复轮销账):
|
||||
`{kind:'wecom', lastTime: 1e13}` 这类脏游标能让 `cursorSummary` 的 `new Date(lastTime*1000).toISOString()`
|
||||
抛 `RangeError`,而它渲的正是账号视图 `status.cursor` 那一格 —— 一行脏游标能让 `listAccounts` 整个 500。
|
||||
已修:两形的数值都要**非负安全整数且在界内**(imap 的 `lastUid` ≤ 0xFFFFFFFF,RFC 3501 的 32 位;
|
||||
wecom 的 `lastTime` ≤ 8.64e12 秒 = JS Date 的上限)—— 只写「安全整数」还不够,`1e13` 正是安全整数,
|
||||
乘 1000 才出界。不合格的当没有游标(照原注释「形状不对当没有游标,不抛」)。`cursor.test.ts` 钉住。
|
||||
⑩ **Message-ID 撞车(providerRef 是新的)留孤儿附件**:`storeOne` 先按 `provider_ref` 查重、再解析存附件、最后才 `insert`;
|
||||
若真正撞车的是 `(account_id, message_id)` 那把键(provider_ref 没撞),附件已经落进 `sys.upload` 而 `insert` 报 `inserted:false`,
|
||||
没人再引用这批附件。根治要给 `InboundStore` 加一个按 `messageId` 查重的口,插入前先挡。
|
||||
⑪ **批内逐封持久化游标那条代码路径没有测试钉死**:要在中途注入一次 provider 失败才验得到「游标停在最后一封成功处」,
|
||||
现有测试夹具还没有故障注入的口子。只影响批失败时的**返工量**,不影响正确性(两把唯一键仍挡得住重复入库)。
|
||||
⑫ **两份 IT 测试的 `provisionDatabase()` 近乎逐字重复,且换出来的库从不清理**:`mail-store.it.test.ts` 与
|
||||
`route-coverage.it.test.ts` 各自实现了一遍「从 base URL 换一个新库名」,`jcai_it_mail` 与先例 `jcai_it_routes` 一样
|
||||
永不 DROP DATABASE —— 长跑的开发机会慢慢攒下若干 IT 库,该抽成一个共用 helper 并在跑完后清理。
|
||||
⑬ **`mailSendTool` 的 `dryRun` 自己另写了一份校验**,与模块的 `validateSend` 是两份规则,会漂;
|
||||
且 dryRun 完全不查附件的 `uploadRef` 是否真存在 —— 「dryRun 绿」不能保证「真发能过」(附件缺失要等真发才现形)。
|
||||
⑭ **`attachmentsOf` 对形状不对的附件项仍是静默丢**(`mail-wiring.ts`):数组里混进非对象项会被 `flatMap` 悄悄滤掉,
|
||||
与已经修过的「缺 `uploadRef` 当场拒」是同一族问题的更窄一种,还没堵上。
|
||||
⑮ **`JCAI_MAIL_POLL_TICK_MS` 给垃圾值会得 `NaN`**:`Number(env[...] ?? 5000)` 不校验,`setInterval(fn, NaN)` 按最小间隔跑;
|
||||
与既有的 `dispatchIntervalMs` 是同一款约定(未做输入校验),但这一条每拍要真打一次库,代价更大。
|
||||
|
||||
### 2026-08-25 终审修复轮之后新增 / 变更的几笔
|
||||
|
||||
⑯ **工具路发出的信不登记 `outbound_ref`**:`event.recordOutbound` 的唯一调用方是 `channel.sendMail`,
|
||||
而 `channel.sendMail` 在 src 里**零调用方** —— 放行落地走 `deps.tools.execute` → `mail.send` 工具,不经 channel。
|
||||
于是生产唯一的发信路径**不写** `sys.outbound_ref`:回信归属只剩②字段提取与③地址窗,①级(线程号对表)落空。
|
||||
根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(处理器现在只拿得到 input / connection / idempotencyKey),
|
||||
再由 `mailSendTool` 在发成功后调 `event.recordOutbound` —— 那是跨模块契约变更,单独一件事。
|
||||
本轮只把文档改老实(ADR-013 决策 6、`docs/API文档-邮件收发.md` §4)。
|
||||
⑰ **`imapNeedsReanchor` 导出而无人调用**:UIDVALIDITY 变没变的判断在 `imap-smtp-provider.pull` 里内联了一份
|
||||
(`cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity`)。两处同义、只有一处在跑 ——
|
||||
与本轮修掉的 `threadIdOf` 是同一族(导出的那个没人用,内联的那个才是实情),下一轮并成一处。
|
||||
⑱ **降级存档行不自动分发,而「重新投递」会把降级正文投给通道**:解析不动的那一封落的是骨架行
|
||||
(正文是失败原因,不是客户说的话),自动路径跳过它是对的;但人按「重新投递」时基座不拦 ——
|
||||
下游会按一段「【降级存档】…」的正文起单。要么在重投前提醒,要么给这类行一个标(库里现在只能靠正文认)。
|
||||
⑲ **`SEND_STALE_MS` 与 `claimForResend` 的 5 分钟是两处各写一份**:用例算 `staleBefore = now − SEND_STALE_MS` 传进去,
|
||||
store 只按传进来的时刻判 —— 目前只有一个调用方所以对得上,但这个数没有一处共同的定义点。
|
||||
⑳ **IMAP 每一轮都新建 TLS 连接 + 登录一次**:`withImap` 用完即 `logout`,而轮询间隔默认 30s、下限 10s ——
|
||||
一个账号一天 2880-8640 次登录。腾讯企业邮箱对登录频率有限制,**上线前先拿真账号把 `pollIntervalSec`
|
||||
压到 10s 跑一小时**看会不会被拒;这比 IDLE 缺席更早会成为事故。根治是连接复用(或 IDLE),
|
||||
两者都要先解决「跨进程租约下谁持有那条连接」。
|
||||
㉑ **wecom 的 `-1`(系统繁忙)重试没有退避**:`call` 连打 `BUSY_RETRIES` 次,中间不等 ——
|
||||
对方正忙的时候我们打得更密。加指数退避即可。
|
||||
㉒ **`pollDue` 对「未配置凭证」的账号每 tick 写一行 stderr**:5 秒一次、一个账号一行,
|
||||
生产日志会被这条刷屏。应按**状态变化**打印(从能拉变成拉不动那一次说一句),而不是每轮说一遍。
|
||||
㉓ **一轮里的 `notes` 不设上限**:50 封毒信就把 50 句话拼进同一个 `last_poll_error`(几 KB)。
|
||||
不是错,但运营面那一格会变得没法读 —— 截断并注明「另有 N 条」即可。
|
||||
㉔ **`claimForResend` 会把抢到的那一行的 `created_at` 推到当下**:这是 CAS 判据的一部分
|
||||
(抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。
|
||||
要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
# 统一鉴权中心(Keycloak)接入 —— 后端设计
|
||||
|
||||
- 日期:2026-08-25
|
||||
- 状态:设计已通过,待实施(实施计划另出)
|
||||
- 范围:**只做后端**(apps/api + packages/contracts + 文档);web 一个文件不动
|
||||
- 依据:`docs/keycloak-backend-guide.md`(Keycloak 26.7.1 / jose / 核实日期 2026-08-12)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现状(ADR-010):自建账号口令(scrypt)+ 服务端会话表 `sys.session` + 岗位授权
|
||||
`sys.position_grant`(客体三类 × 动作三种,治理台九功能)。全部推翻,换成:
|
||||
|
||||
- **身份来自 Keycloak**(realm `corp`):后端只本地验签 Bearer JWT(RS256 / iss / aud),不回源、不 introspect。
|
||||
- **权限只到「端」**:三个端 —— 设计中心 `design` / 运营中心 `ops` / 工作台 `workbench`;
|
||||
**角色 → 能进哪几个端** 是一张放在我们库里、运营中心可动态改的表。角色 = Keycloak realm 角色。
|
||||
- **名册**:人从 Keycloak 来(首次登录即建 + Admin API 同步);岗位仍是我们的表,在运营中心给人指派。
|
||||
|
||||
非目标:前端接入(按《Keycloak 前端接入手册》另做)、对象级/功能级细粒度权限、多租户、API 网关、
|
||||
服务间调用、Keycloak 组(`groups`)。
|
||||
|
||||
## 2. 已定决策(用户 2026-08-25 裁决)
|
||||
|
||||
| 问题 | 决策 |
|
||||
|---|---|
|
||||
| 后端形态 | **资源服务器**:前端自取 token,后端只验 Bearer(手册模型;BFF 弃) |
|
||||
| 角色来源 | **Keycloak realm 角色**(`realm_access.roles`);角色→端映射表在我们库里 |
|
||||
| 岗位与名册 | **岗位本地 + 名册同步**(Admin API,provisioning 服务账号)+ 首登即建 |
|
||||
| 新依赖 | **批准 `jose`**(§12.5 #3;与手册同一标准件,零传递依赖) |
|
||||
| 路由归属 | 进契约:`RouteContract.access` 必填,一处中间件按注册表判 |
|
||||
| 岗位可空? | 不可空:种哨兵岗 `NONE / 未分配`,~40 处 `staff.position.*` 一行不动 |
|
||||
| 退役契约 | 留一轮:标 `@deprecated`,后端回 **410**(新增错误码 `gone`),web 不动 |
|
||||
|
||||
## 3. 架构与请求链
|
||||
|
||||
```
|
||||
浏览器(web-portal / web-console,PKCE,前端手册)
|
||||
│ Authorization: Bearer <RS256 JWT,aud ∋ KEYCLOAK_CLIENT_ID>
|
||||
▼
|
||||
apps/api server.ts 鉴权中间件(app 层,唯一一处)
|
||||
① 取凭据:Authorization 头;GET 且无头时认 ?ticket=(60s 票据,专给 EventSource / <a href> 下载)
|
||||
② org.authenticate(token) → Principal
|
||||
验签(jose,本地)→ claims → Identity → 名册即时更新(JIT)→ 角色 × role_access → ends
|
||||
票据路:org.principalOf(sub)(从库现算,不信票据里的任何权限信息)
|
||||
③ 路由注册表:lookup(method, path) → access(public / any / 端 / 端数组)
|
||||
④ 判定:public 放行;无凭据或无效 → 401;账号已停用 → 403;端不在 principal.ends → 403
|
||||
⑤ c.set('principal') —— 路由用 principal.staff;findByAccount(c.get('account')) 那一族全部退役
|
||||
```
|
||||
|
||||
分工沿用 ADR-010:**中间件在 app 层,身份判定读 `org` 出口**。治理路由里约 100 处
|
||||
`guard(fn, action)` / `requireGovern(fn)` 整体删除 —— 端级控制由中间件按注册表统一做,
|
||||
「一处拦住,新路由自动生效」(与 `/api/gov/*` 广播中间件同一套路);注册表覆盖有测试兜底(§9)。
|
||||
|
||||
## 4. 契约变更(`packages/contracts`)
|
||||
|
||||
### 4.1 新增
|
||||
|
||||
```ts
|
||||
// api/http.ts
|
||||
export const SURFACES = ['design', 'ops', 'workbench'] as const;
|
||||
export const surfaceSchema = z.enum(SURFACES);
|
||||
export type Surface = z.infer<typeof surfaceSchema>;
|
||||
|
||||
/** public:无需凭据;any:登录即可,不看端;端名 / 端名数组:能进其中任一端即可 */
|
||||
export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[];
|
||||
|
||||
export type RouteContract<…> = {
|
||||
readonly method: HttpMethod;
|
||||
readonly path: string;
|
||||
readonly access: RouteAccess; // ← 必填;漏了 `satisfies RouteContract` 编译不过
|
||||
…
|
||||
};
|
||||
|
||||
export const API_ERROR_CODES = [ …既有七个…, 'gone' ] as const; // 410:已退役
|
||||
```
|
||||
|
||||
```ts
|
||||
// org.ts
|
||||
export const staffSchema = z.object({
|
||||
id: staffIdSchema, // 值 = Keycloak sub(手册硬约束 8);类型不变
|
||||
account: z.string().min(1), // = preferred_username,只作显示
|
||||
name: z.string().min(1), // = name claim,缺则回退 account
|
||||
email: z.string().optional(),
|
||||
positionId: positionIdSchema, // 未指派 = 哨兵岗 NONE 的 id
|
||||
state: staffStateSchema.default('active'),
|
||||
roles: z.array(z.string()).default([]), // realm 角色快照(签发时;已滤噪声角色)
|
||||
});
|
||||
|
||||
export const principalSchema = z.object({
|
||||
staff: staffSchema,
|
||||
position: positionSchema,
|
||||
roles: z.array(z.string()),
|
||||
ends: z.array(surfaceSchema),
|
||||
});
|
||||
export type Principal = z.infer<typeof principalSchema>;
|
||||
|
||||
export const roleAccessSchema = z.object({ role: z.string().min(1), ends: z.array(surfaceSchema) });
|
||||
|
||||
/** 某人的在途工作(只读)—— 形状 = 原 disableCheckSchema;本体改名,旧名留作 @deprecated 别名 */
|
||||
export const inFlightWorkSchema = z.object({ staffId, blocked, openTasks, openCards, openApprovals });
|
||||
/** @deprecated 下个迭代删 */
|
||||
export const disableCheckSchema = inFlightWorkSchema;
|
||||
```
|
||||
|
||||
新路由(`api/workbench.ts` / `api/governance.ts`):
|
||||
|
||||
| 路由 | access | 说明 |
|
||||
|---|---|---|
|
||||
| `meRoute` `GET /api/me` → `principalSchema` | any | 我是谁、我能进哪几个端;`ends` 为空也 200(前端据此说「无端可进」) |
|
||||
| `authTicketRoute` `GET /api/auth/ticket` → `{ ticket, expiresAt }` | any | 60s 票据,见 §9.3 |
|
||||
| `listRoleAccessRoute` `GET /api/gov/access/roles` → `{ roles: roleAccess[], knownRoles: string[] }` | ops | `knownRoles` = 名册上见过的角色 ∪ 已配置的角色 |
|
||||
| `setRoleAccessRoute` `PUT /api/gov/access/roles/:role` body `{ ends }` → `roleAccess` | ops | 锁死不变量不过 → 409 `conflict` |
|
||||
| `deleteRoleAccessRoute` `DELETE /api/gov/access/roles/:role` → `{ ok }` | ops | 同上不变量 |
|
||||
| `syncStaffRoute` `POST /api/gov/staff/sync` → `{ configured:false, message } \| { configured:true, inserted, updated, disabled, total }` | ops | 未配 provisioning 就如实说 |
|
||||
| `setStaffPositionRoute` `PUT /api/gov/staff/:staffId/position` body `{ positionId }` → `staff & { position }` | ops | 指派岗位;哨兵岗 NONE 也可指(= 取消指派) |
|
||||
| `staffInFlightRoute` `GET /api/gov/staff/:staffId/in-flight` → `inFlightWorkSchema` | ops | 原停用前置检查降为只读信息 |
|
||||
|
||||
### 4.2 修改
|
||||
|
||||
- `listStaffRoute` 出参形状不变(`staffSchema.extend({ position })` + `positions`);`positions` 含 NONE 与 TEST。
|
||||
- 全部既有路由加 `access`(归属表见 §4.4)。
|
||||
|
||||
### 4.3 退役(§12.4 三步之第二步:本轮标 `@deprecated`,后端回 410 `gone`;下个迭代删)
|
||||
|
||||
`loginRoute` `logoutRoute` `sessionRoute` `createStaffRoute` `disableStaffCheckRoute` `disableStaffRoute`
|
||||
`listGrantsRoute` `setGrantsRoute`;类型 `sessionSchema` `Session` `grantSchema` `Grant` `grantObjectSchema`
|
||||
`GrantObject` `grantActionSchema` `GrantAction` `GRANT_ACTIONS` `GRANT_ACTION_LABEL` `governanceFunctionSchema`
|
||||
`GovernanceFunction` `GOVERNANCE_FUNCTIONS` `GOVERNANCE_FUNCTION_LABEL` `disableCheckSchema` `DisableCheck`。
|
||||
退役路由的 `access: 'public'`(410 不需要凭据,也不泄漏任何东西);后端由一份 `RETIRED_ROUTES` 清单统一注册
|
||||
410 处理器:`{ code: 'gone', message: '已随统一鉴权中心退役:<替代路由或说明>' }`。
|
||||
|
||||
web 引用这些名字的地方(`features/auth/LoginPage.tsx`、`shared/gov/surfaces.ts`、三处 `sessionRoute`)
|
||||
**本轮不动**:类型仍在,编译照过;运行时登录页无法登录 —— 这是「只做后端」的既定后果,
|
||||
前端按《前端接入手册》改造时一并清掉。
|
||||
|
||||
### 4.4 路由归属全表
|
||||
|
||||
规则:**写归所属端;读被两端消费的标两端**(按 web 各区实际消费定;壳层只在 ops 台面拉任务与对象)。
|
||||
|
||||
| access | 路由 |
|
||||
|---|---|
|
||||
| `public` | `healthRoute` · `ingestMailRoute`(外界边界,ADR-010 原判)· 退役八条 |
|
||||
| `any` | `meRoute` · `authTicketRoute` · `GET /api/stream`(SSE,字面路径注册,注册表以 `EXTRA_ROUTES` 显式补入) |
|
||||
| `workbench` | `listTasks` `taskDetail` `replyWorkOrder` `replyChatCard` `markCommRead` `saveTable` `openAssetFile` `say` `listMessages` `markMessageRead` `answerMessage` `confirmTriggerPending` `listMinutes` `createMinute` `appendResolution` `registerResolution` `draftReport` `listReports` `submitReport` `listApprovalsForMe` `decideApproval` `listRegistrationTriggers` `fireRegistration` |
|
||||
| `design` | 流程包:`saveProcess` `setProcessState` `deleteProcessVersion` `deleteProcessFlow` `editProcessVersion` `validateProcess` · 审批模板:`listApprovalTemplates` `upsertApprovalTemplate` `deleteApprovalTemplate` · 触发器定义:`upsertTrigger` · 活动库:`listActivities` `installActivity` · 工具箱:`listTools` `registerTool` · 知识库:`listActivityMemory` · 大模型:`listLlmProviders` `listLlmInstances` `upsertLlmInstance` `deleteLlmInstance` `probeLlmDraft` `probeLlmInstance` · 测试台:`testRun` `testRunActivity` `testStop` `testCurrent` `testActivityState` `listTestLogs` `testLogDetail` `deleteTestLog` `clearTestLogs` `uploadFile` `downloadFile` `traceOfWorkflow` |
|
||||
| `ops` | 任务:`listGovTasks` `controlTask` `resumeTask` `reassignTask` `govTaskLog` `listApprovals` · 驾驶舱:`govMetrics` · 工作流运行:`toggleTrigger` `setTriggerConfirm` `listNodeOwners` `setNodeOwner` · 员工:`listWorkGroups` `saveWorkGroup` `setStaffPosition` `syncStaff` `staffInFlight` · 角色→端:`listRoleAccess` `setRoleAccess` `deleteRoleAccess` |
|
||||
| `['design','ops']` | `listStaff`(测试台选负责人)· `listProcessPackages` `listTriggers` `listChannels`(运营的工作流区也看)· `listLedgerObjects` `ledgerByActivity` `browseLedger`(结构归设计、行数据归运营)· `govWorkflow`(任务区 + 测试台) |
|
||||
|
||||
## 5. `org` 模块(L1,原地重写)
|
||||
|
||||
### 5.1 `contract.ts`
|
||||
|
||||
```ts
|
||||
export type Identity = { // 从 token 或 Admin API 归一出来的一个人
|
||||
readonly sub: string;
|
||||
readonly account: string; // preferred_username
|
||||
readonly name: string; // name ?? account
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[]; // 已滤噪声角色
|
||||
};
|
||||
|
||||
export type OrgService = {
|
||||
/* 身份 */
|
||||
authenticate(token: string): Promise<Principal>; // 抛 TokenRejectedError / AccountDisabledError
|
||||
principalOf(staffId: StaffId): Promise<Principal | undefined>; // 票据路;停用抛 AccountDisabledError
|
||||
registerIdentity(identity: Identity): Promise<Staff>; // JIT 入口;测试也用它造人
|
||||
/* 名册与岗位 */
|
||||
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>; // 只回 active;NONE 恒回 []
|
||||
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
||||
syncRoster(): Promise<RosterSyncOutcome>;
|
||||
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
||||
/* 角色 → 端 */
|
||||
listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>;
|
||||
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>; // 抛 AccessLockoutError
|
||||
deleteRoleAccess(role: string): Promise<void>; // 抛 AccessLockoutError
|
||||
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>; // 通知收件人:能进该端的活跃员工
|
||||
/* 工作组(ADR-011,原样) */
|
||||
saveWorkGroup(...) / workGroupOf(...) / bindingOf(...)
|
||||
ensureSeeded(): Promise<void>;
|
||||
};
|
||||
|
||||
export class TokenRejectedError extends Error { } // → 401,message 是手册 4.4 的人话
|
||||
export class AccountDisabledError extends Error { } // → 403
|
||||
export class AccessLockoutError extends Error { } // → 409
|
||||
export class StaffNotFoundError / PositionNotFoundError // 沿用
|
||||
```
|
||||
|
||||
删除:`login` `logout` `resolveSession` `setPassword` `loadSession` `authorize` `listGrants` `setGrants`
|
||||
`createStaff` `checkDisable` `disableStaff` `findByAccount`;`IssuedSession` `CreateStaffInput`
|
||||
`AccountTakenError` `DisableBlockedError`;常量 `DEFAULT_LOGIN_ACCOUNT` `STAFF_SEED`;
|
||||
纯函数 `isAuthorized` `actionCovers` `objectCovers`;adapter 出口 `hashPassword` `verifyPassword`
|
||||
`newSessionToken` `createSessionRepository` `createGrantRepository` `objectKeyOf`;表 `sessionTable` `grantTable`。
|
||||
|
||||
### 5.2 `port.ts`
|
||||
|
||||
```ts
|
||||
/** 验签 —— 实现在 adapter(jose);测试注入本地密钥对 */
|
||||
export type TokenVerifier = { verify(token: string): Promise<VerifiedClaims> }; // 失败抛 TokenRejectedError
|
||||
export type VerifiedClaims = {
|
||||
readonly sub: string;
|
||||
readonly preferred_username: string;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
readonly realm_access?: { readonly roles?: readonly string[] };
|
||||
};
|
||||
|
||||
/** 名册来源 —— Keycloak Admin API;app 未配 provisioning 时注入 undefined */
|
||||
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
|
||||
export type RosterUser = Identity & { readonly enabled: boolean };
|
||||
|
||||
export type StaffRepository = {
|
||||
list(): Promise<Staff[]>;
|
||||
findById(id: StaffId): Promise<Staff | undefined>;
|
||||
findByAccount(account: string): Promise<Staff | undefined>; // 仅播种/测试账号策略用
|
||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
||||
insert(staff: Staff): Promise<Staff>;
|
||||
updateIdentity(id: StaffId, patch: { account; name; email?; roles; lastSeenAt? }): Promise<void>;
|
||||
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||||
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||||
};
|
||||
export type RoleAccessRepository = {
|
||||
list(): Promise<RoleAccess[]>;
|
||||
upsert(row: RoleAccess): Promise<RoleAccess>;
|
||||
remove(role: string): Promise<void>;
|
||||
};
|
||||
// PositionRepository / WorkGroupRepository / InFlightWorkProbe / Clock / IdGenerator 沿用
|
||||
```
|
||||
|
||||
删除:`SessionRepository` `PasswordHasher` `GrantRepository`;`StaffRepository.passwordHashOf/setPasswordHash`。
|
||||
|
||||
### 5.3 `internal/domain/`(纯函数,零 IO)
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `claims.ts` | `identityOf(claims): Identity` —— `name ?? preferred_username`;`roles` 滤掉 `/^default-roles-/`、`offline_access`、`uma_authorization` |
|
||||
| `ends.ts` | `endsOf(roles, table): Surface[]`(并集,按 `SURFACES` 顺序);`assertNotLockedOut(table, activeStaffRoles)`:名册为空 → 通过;否则至少一名活跃员工的角色命中一个含 `ops` 的行,否则抛 `AccessLockoutError`(**没人能进运营中心,这张表就再也改不回来**) |
|
||||
| `roster-diff.ts` | `diffRoster(remote: RosterUser[], local: Staff[]) → { insert: Identity[]; update: {id, patch}[]; setState: {id, state}[] }`;远端 `enabled=false` 或**不在远端** → `disabled`;远端 enabled 而本地 disabled → `active`;字段没变的不出现在 update |
|
||||
| `test-account.ts` | `isTestAccount(account, list)`(不区分大小写);`soloGroupOf(staff)`:`SOLO_GROUP_POSITIONS`(PM/BD/FAE/PRM/OP)全指向本人,id `wg-solo-<account>` |
|
||||
| `in-flight.ts` | 原 `disable-check.ts`,`buildInFlightWork` |
|
||||
| `seed-data.ts` | `POSITION_SEED`(五业务岗 + TEST + **NONE / 未分配**)、`DEFAULT_ROLE_ACCESS`(`admin → 三端`,`user → workbench`)、`SOLO_GROUP_POSITIONS`;**不再有 STAFF_SEED** |
|
||||
|
||||
### 5.4 `internal/usecase/org-service.ts` 要点
|
||||
|
||||
`OrgServiceDeps = { verifier: TokenVerifier; roster?: RosterSource; staff; positions; roleAccess; workGroups;
|
||||
inFlight: InFlightWorkProbe; clock; ids; testAccounts: readonly string[] }` —— 口令 / 会话 / 授权三组端口与
|
||||
`seedPassword` 全部退役。
|
||||
|
||||
- `authenticate(token)`:`verifier.verify` → `identityOf` → `registerIdentity` → 读 `role_access` → `Principal`。
|
||||
**`state` 由同步说了算,JIT 不碰它**(插入时 `active`):本地 `disabled` 即使 token 有效也抛
|
||||
`AccountDisabledError`(撤权在同步之后即时生效;Keycloak 恢复了就再同步一次)。
|
||||
- `registerIdentity`:按 sub 找;没有 → 插入(岗 NONE,`roles` 快照)并施加测试账号策略;有 → 账号 / 姓名 /
|
||||
邮箱 / 角色任一变了才写;`last_seen_at` 60s 内不重写(避免每请求一次写)。
|
||||
- `principalOf(staffId)`:查库现算;停用抛错;不存在回 `undefined`。
|
||||
- `syncRoster`:`roster` 未注入 → `{ configured: false }`;否则 `diffRoster` 后逐条落库,回报告。
|
||||
- `setRoleAccess` / `deleteRoleAccess`:先算新表,`assertNotLockedOut(新表, 活跃员工角色快照)`,再写。
|
||||
- `staffWithEnd(surface)`:活跃员工中 `endsOf(roles) ∋ surface`。
|
||||
- `ensureSeeded`:岗位(含 NONE / TEST)→ `role_access` **表为空时**播缺省 → 对 `testAccounts` 里已在册的人
|
||||
施加测试账号策略。幂等,不覆盖人工改动。
|
||||
|
||||
### 5.5 `internal/adapter/`
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `keycloak-token-verifier.ts` | `createKeycloakTokenVerifier({ issuer, audience, getKey })`:`jwtVerify(token, getKey, { algorithms: ['RS256'], issuer, audience, clockTolerance: 30 })`;`getKey` 由 app 给 `createRemoteJWKSet(certs, { cooldownDuration: 30_000, cacheMaxAge: 600_000, timeoutDuration: 5_000 })`,测试给 `createLocalJWKSet`;jose 错误按手册 4.4 译成人话后包成 `TokenRejectedError`;claims 经 zod 校验(`sub` / `preferred_username` 必有) |
|
||||
| `keycloak-admin-roster.ts` | `createKeycloakRoster({ url, realm, provisioning: { clientId, secret } })`:master realm `client_credentials` 取管理 token(提前 60s 过期缓存在闭包里);`GET /admin/realms/{realm}/users?first&max=100&briefRepresentation=false` 分页到底;每人 `GET /users/{id}/role-mappings/realm/composite`;归一成 `RosterUser` |
|
||||
| `org-repository.ts` | Drizzle 仓储:staff / position / role_access / work_group |
|
||||
| `org-schema.ts` | 见 §6 |
|
||||
|
||||
删除:`password.ts`;`__tests__/password.test.ts` `authorization.test.ts`(测的对象没了,随代码一起删,不是绕门禁)。
|
||||
|
||||
## 6. 数据模型与迁移 `db/migrations/0032_keycloak_identity.sql`
|
||||
|
||||
```sql
|
||||
-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak sub
|
||||
drop table if exists sys.session;
|
||||
drop table if exists sys.position_grant;
|
||||
|
||||
alter table sys.staff
|
||||
drop column if exists password_hash,
|
||||
add column if not exists email text,
|
||||
add column if not exists roles jsonb not null default '[]'::jsonb,
|
||||
add column if not exists last_seen_at timestamptz,
|
||||
add column if not exists synced_at timestamptz;
|
||||
|
||||
create table if not exists sys.role_access (
|
||||
role text primary key,
|
||||
design boolean not null default false,
|
||||
ops boolean not null default false,
|
||||
workbench boolean not null default false,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- 种子人不是 Keycloak 的人,id 不是 sub,账号还会与 Keycloak 用户名撞唯一索引:清掉。
|
||||
-- 他们名下的运行数据由 `reset-runs` 清(迁移不碰运行表)。
|
||||
delete from sys.work_group;
|
||||
delete from sys.staff;
|
||||
```
|
||||
|
||||
- `sys.staff.account` 唯一索引保留(Keycloak 用户名本就唯一)。
|
||||
- `sys.position` 不动;哨兵岗 `NONE` 由 `ensureSeeded` 播(不写进迁移,与其他岗位同一条路)。
|
||||
- `reset-runs` **不清** `staff` / `role_access` / `position` / `work_group`(它们是配置,不是运行数据)。
|
||||
|
||||
## 7. 身份、名册与测试账号
|
||||
|
||||
- **服务账号也是人**:`preferred_username = service-account-<clientId>` 的 token 与普通人同一条路 JIT 建档 ——
|
||||
e2e 与试跑用的 AI 账号就是它(手册 §10:自动化不用人的账号)。
|
||||
- **测试账号策略**:`JCAI_TEST_ACCOUNTS`(逗号分隔的 Keycloak 用户名,缺省空)。命中者在**首次建档**与**播种**
|
||||
两个时机都施加:岗位若为 NONE 则置 TEST + 建独立工作组(所有业务岗指向本人)。ADR-011 语义原样。
|
||||
- **名册同步是手动按钮**(运营中心 · 员工 · 同步),不做定时(记 debt)。
|
||||
- `process.OrgDirectory.positions()`(engines 装配)滤掉 `NONE`:流程包不该声明它。
|
||||
|
||||
## 8. 角色 → 端
|
||||
|
||||
- 表 `sys.role_access`,行 = 一个 realm 角色;缺省(表为空时播)`admin → design+ops+workbench`,`user → workbench`
|
||||
—— 与手册两个前端「用户端要 user、管理端要 admin」一致。
|
||||
- `principal.ends` = 其 `roles` 命中的行的并集;没有任何行命中 → `[]`(`/api/me` 仍 200,别的路由 403)。
|
||||
- 新角色在 Keycloak 建;这边 `knownRoles` 把名册上见过的角色列出来供配置,配置前它等于「无端可进」。
|
||||
- 锁死不变量见 §5.3。
|
||||
|
||||
## 9. 中间件、注册表、票据、错误语义(app 层)
|
||||
|
||||
### 9.1 注册表 `app/route-registry.ts`
|
||||
|
||||
- `buildRouteRegistry(routes: readonly RouteContract[], extra: readonly { method; path; access }[])`:
|
||||
`path` 的 `:name` 段匹配 `[^/]+`,其余字面;`lookup(method, path)` 字面精确命中优先于参数段命中。
|
||||
- 来源:`import * as contracts from '@contracts'`,取形状为 `{ method, path, access, output }` 的导出
|
||||
+ `EXTRA_ROUTES = [{ GET /api/stream, any }]`。
|
||||
- **覆盖测试**:`app.routes`(Hono 自带)里每条已注册路由(除 Hono 内部 `ALL`)都能在注册表查到;
|
||||
查不到即测试红 —— fail-closed。
|
||||
|
||||
### 9.2 中间件 `app/server.ts`
|
||||
|
||||
```
|
||||
access = registry.lookup(method, path) ?? 'any' // 未知路由:先鉴权再 404,与今天一致
|
||||
if access === 'public' → next
|
||||
bearer = Authorization: Bearer …
|
||||
ticket = method === 'GET' ? query.ticket : undefined
|
||||
无凭据 → 401 { unauthenticated, '缺少 Bearer token' }
|
||||
bearer → principal = org.authenticate(bearer) // TokenRejectedError → 401(人话)
|
||||
ticket → sub = verifyTicket(ticket) (坏/过期 → 401 '票据无效或已过期')
|
||||
principal = org.principalOf(sub) ?? 401
|
||||
AccountDisabledError → 403 { forbidden, '账号已在鉴权中心停用;若已恢复,请运营中心同步一次名册' }
|
||||
access !== 'any' && required ∩ principal.ends = ∅
|
||||
→ 403 { forbidden, '进入「设计中心」需要授权;你的角色(user)可进:工作台', detail: { need, ends } }
|
||||
c.set('principal', principal) → next
|
||||
```
|
||||
|
||||
`AppEnv.Variables` 改为 `{ principal: Principal | undefined }`;`account` 变量删除。
|
||||
路由里 `me(c.get('account'))` / `findByAccount(...)` 一律改 `c.get('principal')!.staff`
|
||||
(中间件已保证非公开路由上必有;公开路由不读它)。
|
||||
|
||||
### 9.3 票据 `app/ticket.ts`
|
||||
|
||||
- 只为两条带不了 Authorization 头的路:`EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。
|
||||
- `GET /api/auth/ticket`(持 Bearer)→ `v1.<sub>.<expUnix>.<hmac-b64url>`,HMAC-SHA256,密钥 = `HKDF(JCAI_SECRET_KEY, 'jcai-ticket')`,
|
||||
有效 60s;**只在 GET 的 `?ticket=` 上认**,POST/PUT/DELETE 带票据一律当无凭据。
|
||||
- 权限不在票据里:中间件拿 sub 走 `principalOf` 从库现算,停用 / 改角色即时生效。
|
||||
- 无状态、不单次:60s 可重放窗换零状态表;写进 ADR-012 与 debt。
|
||||
|
||||
### 9.4 退役路由
|
||||
|
||||
`RETIRED_ROUTES` 清单统一注册 410 处理器(`code: 'gone'`),消息点名替代路:登录 → 统一鉴权中心;
|
||||
`/api/session` → `/api/me`;建人 → 名册同步;停用 → 在 Keycloak 停用后同步;岗位授权 → 角色→端。
|
||||
|
||||
## 10. 配置、启动自检、Keycloak 侧前置
|
||||
|
||||
### 10.1 `app/config.ts`
|
||||
|
||||
| 变量 | 必填 | 用途 |
|
||||
|---|---|---|
|
||||
| `KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID` | 启动必填(`main.ts` 缺一即拒绝启动;`loadConfig` 本身不抛,集成测试可不配) | issuer / JWKS / aud |
|
||||
| `KEYCLOAK_PROVISIONING_CLIENT_ID` `KEYCLOAK_PROVISIONING_CLIENT_SECRET` | 可选 | master realm 服务账号,只要 `view-users`;缺省 → 同步按钮如实回未配置 |
|
||||
| `JCAI_TEST_ACCOUNTS` | 可选 | 测试账号策略(§7) |
|
||||
| `JCAI_SECRET_KEY` | 沿用 | 票据 HMAC 派生 |
|
||||
| ~~`JCAI_SEED_PASSWORD`~~ | 删除 | 口令没了 |
|
||||
|
||||
本服务**不配 `KEYCLOAK_CLIENT_SECRET`**(只验不调,手册 13.2)。`AppConfig` 增
|
||||
`keycloak?: { url; realm; clientId; issuer; jwksUrl; provisioning?: { clientId; secret } }`、`testAccounts: string[]`。
|
||||
|
||||
### 10.2 启动(`main.ts`)
|
||||
|
||||
`boot()`:配置缺 → 抛明文错误退出;`checkKeycloakConnection`(拉 `.well-known/openid-configuration`,
|
||||
`issuer` 与本地拼的不一致 → 打印期望 / 实际 / 排查指引,`exit(1)`,手册第 5 步);通过后打印
|
||||
`✓ Keycloak 连通 issuer=… client=…`。`createContainer(config, overrides?: { tokenVerifier?; roster? })`:
|
||||
缺省按配置装 jose 远程 JWKS 与 Admin roster;无 keycloak 配置时装一个**恒拒**的 verifier
|
||||
(`TokenRejectedError('未配置 KEYCLOAK_*')`),集成测试走用例不走 HTTP,不受影响。
|
||||
|
||||
### 10.3 Keycloak 侧前置(管理员执行;涉及写操作先向人确认)
|
||||
|
||||
1. 本服务 client:**建议复用 `api-gateway`**(四个里唯一的统一入口,`web-portal` 已挂其 audience mapper);
|
||||
否则新建 bearer-only client(不需要 secret)。`KEYCLOAK_CLIENT_ID` 填它。
|
||||
2. `web-console` 补指向本服务 clientId 的 audience mapper(手册 13.3「两个前端都要配」)。
|
||||
3. provisioning 服务账号(master realm,手册 §11.1;本服务只读,授 `view-users` 即可)。
|
||||
4. e2e / 试跑服务账号 client `bpos-e2e`:`serviceAccountsEnabled`,realm 角色 `admin`(测试台在设计端、
|
||||
答卡在工作台),服务账号用户 firstName 设「AI」(显示名),挂本服务 audience mapper。
|
||||
5. 团队成员账号与角色;要更多角色(如 `designer` / `operator`)在 realm 建,再到运营中心配端。
|
||||
|
||||
## 11. 连带改动
|
||||
|
||||
| 处 | 改动 |
|
||||
|---|---|
|
||||
| `app/container.ts` | org 装配换新 deps(`createDeferredInFlightProbe` 保留,在途探针仍要);会话 / 口令 / 授权仓储的装配删除;`message.org.governanceStaff` → `org.staffWithEnd('ops')` |
|
||||
| `app/wiring/engines.ts` | 死信通知收件人 → `staffWithEnd('ops')`;`OrgDirectory.positions()` 滤 NONE |
|
||||
| `packages/contracts/src/api/index.ts` | 导出新路由与 `SURFACES` / `surfaceSchema` / `RouteAccess` |
|
||||
| `app/http-env.ts` | `Variables: { principal: Principal \| undefined }`;`account` 删除 |
|
||||
| `app/routes/workbench.ts` | 删 login/logout/session;加 `/api/me` `/api/auth/ticket` |
|
||||
| `app/routes/governance.ts` | 删 `requireGovern` 与员工建/停/授权四条;加 sync / position / in-flight / access 六条 |
|
||||
| `app/routes/governance-ops.ts` | 删 `guard` 工厂与全部 `denied` 行 |
|
||||
| `app/routes/desk*.ts` `registration.ts` `log-lines.ts` | `findByAccount` → `principal.staff` |
|
||||
| `app/session-cookie.ts` `app/set-password.ts` | 删除;`apps/api/package.json` 去 `set-password` 脚本,加 `jose` |
|
||||
| `tools/e2e-a2.mjs` | `login()` 改 `KEYCLOAK_E2E_CLIENT_ID/SECRET` client_credentials,请求头带 Bearer;断言 `/api/me.ends` 含 design 与 workbench |
|
||||
| 三份 `*.it.test.ts` | `ensureSeeded()` 后用 `registerIdentity({ sub:'it-chaihao', account:'chaihao', name:'柴浩', roles:['admin'] })` + `setPosition(TEST)` 造人 |
|
||||
| `docs/adr/ADR-012-keycloak-identity.md` | 新立:决策 / 分工 / 票据取舍 / 撤权时效 / 名册与岗位 |
|
||||
| `docs/编码规范.md` | §1 表加 `jose`;§4 「鉴权定调」改为「统一鉴权中心(Keycloak)资源服务器;中间件在 app 层,身份判定读 org 出口;权限只到端」;§14 列 ADR-012 |
|
||||
| `modules/org/CONTRACT.md` | 重写 |
|
||||
| `docs/debt.md` | 鉴权段重写(见 §13) |
|
||||
| `CLAUDE.md` | 铁规矩 5 环境变量列表加 `KEYCLOAK_*`;铁规矩 6 「AI 账号(密码…)」改「AI 服务账号(client_credentials)」;现状快照加一条 |
|
||||
| `api-report/` | `pnpm api:check --update` |
|
||||
| `docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md` | 设计阶段按本 spec 写好(用户 2026-08-25 追加);实现落地后用契约重新生成路由归属总表核对(计划 Task 12) |
|
||||
|
||||
## 12. 测试策略
|
||||
|
||||
| 层 | 测什么 | 怎么测 |
|
||||
|---|---|---|
|
||||
| domain | `identityOf` 五种 claims 形状(缺 name / 缺 roles / 噪声角色 / 服务账号);`endsOf` 并集与顺序;`assertNotLockedOut` 空名册通过 / 摘掉最后一个 ops 拒 / 换到另一角色仍有 ops 过;`diffRoster` 增 / 改 / 停 / 恢复 / 无变化;测试账号策略;路由匹配(字面优先、`:param`、方法不同不混) | 零 mock |
|
||||
| adapter | verifier:本地 `generateKeyPair('RS256')` + `createLocalJWKSet`(Keycloak 是外界,替身合规);RS256 过、HS256 拒、`alg:none` 拒、错 iss 拒、错 aud 拒、过期 30s 内过 / 超出拒、错 kid 拒、缺 sub 拒;每种拒回的 message 是人话;票据:签 / 验 / 过期 / 篡改 / 错版本 | jose 本地密钥 |
|
||||
| usecase | 内存仓储:JIT 建人 / 改名 / 角色变更写快照 / 60s 不重写 last_seen;停用抛 403 型错误;`syncRoster` 未配置回 `configured:false`;锁死;`staffWithEnd`;`ensureSeeded` 幂等 + 表非空不覆盖 | 内存实现 |
|
||||
| app | 小 Hono + 假 verifier:public 放行 / 无凭据 401 / 坏 token 401 带人话 / 端不够 403 带 detail / any 只要登录 / 票据 GET 过、POST 拒 / 退役路由 410 / 未知路由先 401 后 404;**注册表覆盖**:`app.routes` ⊆ 注册表 | 假 verifier 注入 |
|
||||
| 门禁 | `pnpm gates` 全绿;api-report 刷新 | — |
|
||||
| e2e | `tools/e2e-a2.mjs` 任一套件跑通(需 Keycloak 侧前置 1 与 4 就位) | 真 Keycloak |
|
||||
|
||||
## 13. 风险、取舍与欠账(进 `docs/debt.md`)
|
||||
|
||||
- **撤权时效 ≤ 5 分钟**(token 寿命,手册 13.4):改角色要等下一枚 token;停用经同步后即时 403。
|
||||
- **票据 60s 可重放窗**(无状态换来的);绑定 sub、只认 GET。
|
||||
- **名册同步是手动按钮**;没配 provisioning 就只有首登即建。
|
||||
- **web 待按《前端接入手册》接入**:登录页 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读 `me.ends`;
|
||||
退役契约与 `SURFACE_FNS` 那一刻一并清。
|
||||
- **退役契约下个迭代删**(三步之第三步)。
|
||||
- `groups` 未用;若日后按部门过滤数据,需 group membership mapper。
|
||||
- aud mapper 未配 → 全线 401:401 的 message 会点名「给发 token 的那个 client 加 audience mapper」。
|
||||
|
||||
## 14. 实施边界
|
||||
|
||||
- 不动:`apps/web/**`、`activities/**`、`process` / `message` 等模块的 port 形状(只改 app 层注入的实现)。
|
||||
- 分支:`feat/keycloak-auth`;提交按承包工单粒度,五道门禁以退出码验。
|
||||
@@ -0,0 +1,599 @@
|
||||
# 邮件收发模块(`mail`)—— 后端设计
|
||||
|
||||
- 日期:2026-08-25
|
||||
- 状态:设计已通过(用户 2026-08-25 裁决),**已实施** —— 落地以 `docs/adr/ADR-013-mail-gateway.md` 与代码为准
|
||||
- 范围:**只做后端**(apps/api + packages/contracts + db + 文档);web 一个文件不动,
|
||||
前端按本轮产出的《前端对接说明-邮件收发》另做
|
||||
- 依据(官方文档,核实日期 2026-08-25):
|
||||
- 腾讯企业邮箱客户端协议设置(企业微信帮助中心):<https://open.work.weixin.qq.com/help2/pc/19886>
|
||||
- 企业微信 · 邮件概述:<https://developer.work.weixin.qq.com/document/path/95486>
|
||||
- 发送普通邮件 `compose_send`:<https://developer.work.weixin.qq.com/document/path/97445>
|
||||
- 获取收件箱邮件列表 `get_mail_list`:<https://developer.work.weixin.qq.com/document/path/97369>
|
||||
- 获取邮件内容 `read_mail`:<https://developer.work.weixin.qq.com/document/path/97979>
|
||||
- 查询应用邮箱账号 `get_email_alias`:<https://developer.work.weixin.qq.com/document/path/97991>
|
||||
- 邮箱回调通知(公共邮箱 `receive_email`):<https://developer.work.weixin.qq.com/document/path/100180>
|
||||
- 创建公共邮箱(`create_auth_code` 拿客户端专用密码):<https://developer.work.weixin.qq.com/document/path/95511>
|
||||
- 获取 access_token:<https://developer.work.weixin.qq.com/document/path/91039>
|
||||
- 全局错误码 / 访问频率限制:<https://developer.work.weixin.qq.com/document/path/90313> / <https://developer.work.weixin.qq.com/document/path/90312>
|
||||
- 老版企业邮箱开放接口(`api.exmail.qq.com`):<https://exmail.qq.com/qy_mng_logic/doc>
|
||||
|
||||
> **这份稿子里有三处已被 ADR-013 取代,别照它接线**(2026-08-25 终审补记):
|
||||
> ① **§7.2 的读路由 `access`** —— 稿子把 7 条 GET 标成 `design|ops`,落地时 15 条**一律 `ops`**(ADR-013 决策 10:
|
||||
> 邮箱那一屏没有设计端消费方,入站正文是客户来往内容);
|
||||
> ② **§5.3 的 `threadId`** —— 稿子取最近一跳,落地取**这一串的根** `references[0] ?? inReplyTo`(ADR-013 决策 3);
|
||||
> ③ **§7.3 的「`body` 改名 `text`」与 `connectionKeys: []`** —— 都没有那么做:契约演进只增不破坏,
|
||||
> 2.0.0 **保留** v1 的 `body`(纯文本正文,与 `html` 二选一或并存),连接键是 **`['account']`**
|
||||
> (「从哪个信箱发」:`'default'` 或某个账号 id),不是空数组(ADR-013 决策 6)。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现状:`channel` 模块(L2)已有整条「入站归一 → 事件(归属解析 / 触发器起单 / 任务 `listen` 监听 /
|
||||
消息兜底)」与出站 `MailTransport` 端口,但两端都是**外界的替身**:出站 `createSimulatedMailTransport`
|
||||
落库不真发,入站只能经 `POST /api/ingest/channel/:channelId/mail` 注入;`mail.send` 工具处理器回
|
||||
`{queued:true}` 从不触达世界;全仓没有 IMAP/SMTP 代码,也没有周期作业调度器
|
||||
(pg-boss 声明了零引用);channel 配置只读、无凭证列。
|
||||
|
||||
目标:新增后端模块 `mail`,把这条链的两端做实 ——
|
||||
|
||||
- **收**:轮询拉取指定邮箱(腾讯企业邮箱)的来信,解析、留痕、交给**注册了回调的订阅方**;
|
||||
`channel` 是第一个订阅方,活动经现有事件链收到信(触发器起单 / `listen` 监听)。
|
||||
- **发**:提供给系统内部调用的发信接口;活动经 `mail.send` 工具发信(写效应,放行时落地)。
|
||||
- **配**:账号参数(含凭证)由前端配置(本轮只出接口与文档),多账号 + 默认发件账号。
|
||||
|
||||
非目标:前端页面、HTTP webhook 注册、IMAP IDLE 推送、INBOX 以外的文件夹、经 API 创建公共邮箱 /
|
||||
邮件群组、日程 / 会议邮件、企业发信配额控制(只在文档里写腾讯的限额)。
|
||||
|
||||
## 2. 已定决策(用户 2026-08-25 裁决)
|
||||
|
||||
| 问题 | 决策 |
|
||||
|---|---|
|
||||
| 接入路 | **两条都做**:标准协议 IMAP/SMTP 与企业微信邮件 API(应用邮箱);一个 `MailProvider` 端口两个适配器,账号配置里选 `kind` |
|
||||
| 新依赖 | **批准 `imapflow` + `nodemailer` + `postal-mime`**(§12.5 #3;三者 MIT / MIT-0,同一作者维护;postal-mime 零依赖,用 Node 自带 `TextDecoder` 解 GBK/GB18030,本机 Node 24 验过) |
|
||||
| 「注册回调」的外延 | **进程内端口 `subscribe`**;不做 HTTP webhook |
|
||||
| 账号数 | **多账号 + 默认发件账号** |
|
||||
| 放置 | **新模块 `mail`(L1)+ `channel` 作首个订阅方**;channel 契约一字不动 |
|
||||
|
||||
## 3. 腾讯企业邮箱三条接入路(官方文档核实)与取法
|
||||
|
||||
| 路 | 收 | 发 | 前提 | 结论 |
|
||||
|---|---|---|---|---|
|
||||
| ① 标准协议 | IMAP `imap.exmail.qq.com:993`(SSL);**无推送**,只能轮询;IDLE 官方未提及 | SMTP `smtp.exmail.qq.com:465`(SSL;官方帮助只列 465,客服 FAQ 另列 587/25) | 账号=完整邮箱地址;密码=邮箱密码,开了安全登录 / 微信绑定则必须用**客户端专用密码**;管理员先在「协作 → 邮件 → 安全管理 → 客户端访问权限」放开,成员在「设置 → 收发信设置」开 IMAP/SMTP;公共邮箱也能生成专用密码(API 建箱时 `create_auth_code:1` 一次性返回) | **做**。任意指定邮箱都能接,不依赖企业微信应用授权 |
|
||||
| ② 企业微信邮件 API | `get_mail_list(begin_time,end_time,cursor,limit≤1000)` → `mail_id`;`read_mail(mail_id)` → `mail_data`(原始 EML,仍要解 MIME);**只读应用邮箱自己的收件箱** | `compose_send`(to/cc/bcc 的 emails 或 userids,subject,content,content_type html/text,attachment_list base64;正文+附件 ≤50MB、附件 ≤200 个);发件人恒为应用邮箱;**不回 Message-ID** | 企业微信 ≥ 4.0.20;自建应用在管理端「协作 → 邮件」授邮件权限,系统自动分配应用邮箱(地址可经 `update_email_alias` 改);`gettoken(corpid, corpsecret)` → access_token 有效 7200s;频率限制每企业每接口 1 万次/分 | **做**。回调 `public_email_change/receive_email` 只对应用创建的公共邮箱、只推「新邮件数」不推内容,**不用** |
|
||||
| ③ 老版开放接口 `api.exmail.qq.com` | 只有「新邮件提醒」(未读数;回调模式推新信通知),无读信 | 无 | — | **不可用** |
|
||||
|
||||
两条路共同点:**都是轮询拉 + MIME 解析**,腾讯没有带内容的推送。因此收信是工作者定时拉,
|
||||
不是 webhook。
|
||||
|
||||
## 4. 架构与数据流
|
||||
|
||||
```
|
||||
┌──────────────── 外界 ────────────────┐
|
||||
│ 腾讯企业邮箱 IMAP/SMTP │ 企业微信邮件 API │
|
||||
└──────────┬────────────┴───────┬────────┘
|
||||
│ MailProvider(端口,两个适配器)
|
||||
app/workers/mail-poller ─┤ 每 5s 一 tick,挑到期账号,一条 SQL 判+占租约
|
||||
▼
|
||||
modules/mail(L1) ── pull → MimeParser(postal-mime)→ 附件入 sys.upload
|
||||
→ sys.mail_inbound(唯一键去重)→ 逐封推进游标
|
||||
→ 分发给订阅方(sys.mail_inbound_delivery 逐订阅方留痕)
|
||||
│ subscribe('channel', …) ← app 装配
|
||||
▼
|
||||
modules/channel(L2).ingestMail(channelId, InboundMail) ── 不动 ──▶ event → 触发器起单 / 任务 listen / 消息兜底
|
||||
|
||||
发:活动 mail.send 工具(write 效应 → 放行时 ToolGateway.execute)─┐
|
||||
channel.sendMail → MailTransport(app 注入:转 mail.send)──────┼──▶ modules/mail.send
|
||||
运营面试发路由 ───────────────────────────────────────────────┘ → sys.mail_outbound(先落 sending)
|
||||
→ MailProvider.send → sent / failed(厂商原话)
|
||||
```
|
||||
|
||||
层级:`mail` 是 **L1**(只依赖 `@contracts`、`@shared/db` 与端口);`channel`、`toolbox` 不 import 它,
|
||||
全部经 app 装配(与 channel 依赖 event 的方式相同)。
|
||||
|
||||
## 5. `mail` 模块(`apps/api/src/modules/mail`)
|
||||
|
||||
### 5.1 `contract.ts`
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MailAccountInput, MailAccountView, MailAccountKind, ProbeReport,
|
||||
InboundSummary, InboundDetail, DeliveryView, OutboundView, SendInput, SendResult,
|
||||
} from '@contracts'; // schema 定义在 packages/contracts/src/mail.ts(§7.1)
|
||||
|
||||
export class MailAccountNotFoundError extends Error { constructor(readonly accountId: string) }
|
||||
export class MailAccountNameTakenError extends Error { constructor(readonly name: string) } // 409
|
||||
export class MailAccountDisabledError extends Error { constructor(readonly accountId: string) } // 412
|
||||
export class MailNoDefaultAccountError extends Error {} // 412
|
||||
export class MailSecretMissingError extends Error { constructor(readonly accountId: string) } // 412
|
||||
export class MailPollBusyError extends Error { constructor(readonly accountId: string) } // 409(另一进程占着租约)
|
||||
export class MailInboundNotFoundError extends Error { constructor(readonly mailId: string) } // 404
|
||||
export class MailAttachmentNotFoundError extends Error { constructor(readonly mailId: string, readonly index: number) }
|
||||
export class MailSubscriberUnknownError extends Error { constructor(readonly subscriber: string) } // 404
|
||||
export class MailSendFailedError extends Error { // 发信失败:已落 failed 行
|
||||
constructor(readonly outboundId: string, readonly vendorMessage: string, readonly retryable: boolean)
|
||||
}
|
||||
|
||||
/** 订阅方回调:返回 `{skipped}` 表示这封信与我无关(留痕为 skipped,不算失败) */
|
||||
export type InboundHandler = (
|
||||
mail: InboundDetail,
|
||||
ctx: { readonly accountId: string; readonly channelId: string | undefined },
|
||||
) => Promise<void | { readonly skipped: string }>;
|
||||
|
||||
export type PollOutcome = {
|
||||
readonly accountId: string;
|
||||
readonly pulled: number; // provider 交出的封数
|
||||
readonly stored: number; // 真新入库的封数(去重后)
|
||||
readonly dispatched: number; // 分发过的封数
|
||||
readonly more: boolean; // 预算用尽还有余下,下一 tick 接着
|
||||
readonly error?: string; // 厂商原话(已脱敏)
|
||||
};
|
||||
|
||||
export type MailService = {
|
||||
// 账号
|
||||
listAccounts(): Promise<MailAccountView[]>;
|
||||
getAccount(accountId: string): Promise<MailAccountView>;
|
||||
createAccount(input: MailAccountInput): Promise<MailAccountView>;
|
||||
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>; // secret 缺省 = 不改
|
||||
deleteAccount(accountId: string): Promise<void>;
|
||||
probe(target: { accountId: string } | { input: MailAccountInput }): Promise<ProbeReport>; // 已存 / 未存两种
|
||||
// 收
|
||||
pollDue(now: Date): Promise<PollOutcome[]>; // 工作者用:到期且未被占的账号各拉一轮
|
||||
pollNow(accountId: string): Promise<PollOutcome>; // 路由用:租约被占 → MailPollBusyError
|
||||
subscribe(name: string, handler: InboundHandler): () => void; // 同名重复注册 → 抛
|
||||
listInbound(query: { accountId?: string; limit?: number }): Promise<InboundSummary[]>;
|
||||
getInbound(mailId: string): Promise<InboundDetail>;
|
||||
attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>;
|
||||
raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>; // 原件 .eml;超限没存 → MailAttachmentNotFoundError
|
||||
redeliver(mailId: string, subscriber?: string): Promise<DeliveryView[]>; // 人按的钮,至少一次
|
||||
// 发
|
||||
send(input: SendInput): Promise<SendResult>;
|
||||
listOutbound(query: { accountId?: string; limit?: number }): Promise<OutboundView[]>;
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 `port.ts`
|
||||
|
||||
```ts
|
||||
export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; // 与 llm/toolbox 同款,app 注入同一实例
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
/** 库形与传输形(domain 定义,这里只列名):`MailCursor`(§5.3 两形)、`Address = {address, name}`、`DeliveryState = 'dispatching'|'done'|'failed'|'skipped'`、
|
||||
* `InboundRecord` / `OutboundRecord` = §6 两张表的行(驼峰)、`OutboundMessage` = 交给 provider 的出信(from/to/cc/bcc/subject/text/html/messageId/inReplyTo/references/attachments 字节) */
|
||||
/** 账号记录(库形):`MailAccountView` 的全部字段(不含 status / secretBound)+ 下面三列;settings 为按 kind 的非密配置;secretCipher 为密封的 JSON({password} | {corpSecret}) */
|
||||
export type MailAccountRecord = MailAccountViewFields & { secretCipher: string | null; cursor: MailCursor | null; pollingUntil: Date | null; lastPolledAt: Date | null; lastPollError: string | null; lastInboundAt: Date | null };
|
||||
|
||||
export type MailAccountStore = {
|
||||
list(): Promise<MailAccountRecord[]>;
|
||||
get(id: string): Promise<MailAccountRecord | undefined>;
|
||||
save(record: MailAccountRecord): Promise<void>; // upsert;name 唯一、默认至多一个由库约束
|
||||
remove(id: string): Promise<void>;
|
||||
listDue(now: Date): Promise<MailAccountRecord[]>; // enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空/过期
|
||||
claimPolling(id: string, now: Date, leaseMs: number): Promise<boolean>; // 一条 SQL 判+占(§9.1)
|
||||
releasePolling(id: string): Promise<void>;
|
||||
savePoll(id: string, patch: { cursor?: MailCursor; polledAt?: Date; error?: string | null; lastInboundAt?: Date; address?: string }): Promise<void>;
|
||||
};
|
||||
|
||||
export type InboundStore = {
|
||||
insert(record: InboundRecord): Promise<{ inserted: boolean }>; // (account_id, message_id) / (account_id, provider_ref) 冲突 → false
|
||||
knownProviderRefs(accountId: string, refs: readonly string[]): Promise<Set<string>>; // wecom 拉列表后先滤,不重复 read_mail
|
||||
list(query: { accountId?: string; limit: number }): Promise<InboundSummary[]>;
|
||||
get(mailId: string): Promise<InboundDetail | undefined>;
|
||||
setDelivery(d: { mailId: string; subscriber: string; state: DeliveryState; error?: string; startedAt: Date; finishedAt?: Date }): Promise<void>;
|
||||
deliveries(mailId: string): Promise<DeliveryView[]>;
|
||||
};
|
||||
|
||||
export type OutboundStore = {
|
||||
insert(record: OutboundRecord): Promise<void>;
|
||||
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
|
||||
setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise<void>;
|
||||
list(query: { accountId?: string; limit: number }): Promise<OutboundView[]>;
|
||||
};
|
||||
|
||||
/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */
|
||||
export type AttachmentStore = {
|
||||
put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string; text?: string }>;
|
||||
get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>;
|
||||
};
|
||||
|
||||
/** 外界:一种账号类型一个适配器。凭证明文只在这一层与 usecase 之间传,不落任何留痕 */
|
||||
export type ProviderAccount = { readonly id: string; readonly kind: MailAccountKind; readonly address: string; readonly displayName: string; readonly settings: Record<string, unknown>; readonly secret: Record<string, string> };
|
||||
export type RawMessage = { readonly providerRef: string; readonly eml: Uint8Array; readonly receivedAt: Date; readonly cursorAfter?: MailCursor };
|
||||
export type MailProvider = {
|
||||
readonly kind: MailAccountKind;
|
||||
probe(account: ProviderAccount): Promise<ProbeReport>;
|
||||
/** 无游标时由 provider 锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
|
||||
anchor(account: ProviderAccount, backfillFrom: Date | undefined, now: Date): Promise<MailCursor>;
|
||||
pull(account: ProviderAccount, cursor: MailCursor, opts: { limit: number; now: Date }): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>;
|
||||
send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string }>;
|
||||
};
|
||||
|
||||
export type ParsedMail = { messageId?: string; inReplyTo?: string; references: string[]; from?: { address: string; name: string }; to: Address[]; cc: Address[]; subject: string; text?: string; html?: string; date?: Date; attachments: Array<{ name: string; contentType: string; bytes: Uint8Array }> };
|
||||
export type MimeParser = { parse(eml: Uint8Array): Promise<ParsedMail> };
|
||||
|
||||
/** 变更通知 —— app 接到 broadcaster(`{kind:'system', id:'mail'}`);模块不知道 SSE 的存在 */
|
||||
export type ChangeNotifier = { changed(reason: string): void };
|
||||
|
||||
export type MailServiceDeps = {
|
||||
readonly accounts: MailAccountStore; readonly inbound: InboundStore; readonly outbound: OutboundStore;
|
||||
readonly attachments: AttachmentStore;
|
||||
readonly providers: Readonly<Record<MailAccountKind, MailProvider>>;
|
||||
readonly mime: MimeParser; readonly secrets: SecretBox; readonly clock: Clock; readonly ids: IdGenerator;
|
||||
readonly notify: ChangeNotifier;
|
||||
readonly limits?: { perTick?: number; tickBudgetMs?: number; attachmentBytes?: number; rawBytes?: number }; // 默认 50 封 / 20s / 8MB / 16MB
|
||||
};
|
||||
```
|
||||
|
||||
### 5.3 `internal/domain/`(纯函数,零 IO)
|
||||
|
||||
- `cursor.ts`:`MailCursor` 两形 `{kind:'imap', uidValidity, lastUid}` / `{kind:'wecom', lastTime}`;
|
||||
`cursorSummary(cursor)` 人读摘要(「UID 1234 · validity 5」/「至 2026-08-25 10:00」);
|
||||
`wecomWindow(cursor, backfillFrom, now)` → `{beginTime: lastTime − 600s, endTime: now}`(重叠 10 分钟治时钟偏,重复由唯一键挡)。
|
||||
- `due.ts`:`isDue(record, now)`、`leaseMsFor(intervalSec) = max(5min, 3×interval)`。
|
||||
- `inbound-record.ts`:`inboundRecordOf(parsed, raw, account, receivedAt, ids)`:`messageId` 缺失时用
|
||||
`<providerRef@account.address>` 顶(不抛);`threadId = inReplyTo ?? references 末项`;
|
||||
`bodyText = text ?? stripHtml(html) ?? ''`;附件按上限分「存」与「超限留注」。
|
||||
- `redact.ts`:`redactSecrets(text, secrets)` —— 明文、base64、URL-encoded 三形都换成 `***`。
|
||||
- `send-input.ts`:`validateSend(input)`(收件人非空、text/html 至少一个、附件单个 ≤8MB、总 ≤20MB;
|
||||
wecom 额外 ≤50MB / ≤200 个)、`pickAccount(accounts, requested?)`(指定的 → 必须 enabled;不指定 → 唯一 `isDefault`;没有 → `MailNoDefaultAccountError`)、
|
||||
`messageIdFor(id, address) = <id@域>`。
|
||||
- `delivery.ts`:状态机 `dispatching → done | failed | skipped`,`redeliver` 只从 `done/failed/skipped` 出发。
|
||||
- `account-view.ts`:`viewOf(record)`(去密文、`secretBound = cipher != null`、状态子对象、`polling = pollingUntil > now`)。
|
||||
|
||||
### 5.4 `internal/usecase/mail-service.ts` 要点
|
||||
|
||||
- **拉一轮**(`pollAccount`):`claimPolling` 抢不到 → busy;凭证空 → 记 `last_poll_error='未配置凭证'`、还租约;
|
||||
无游标 → `provider.anchor`;循环 `pull`(封顶 50 封 / 20s):逐封 `mime.parse` → 附件 `attachments.put`
|
||||
(超限 `ref:null` + 注)→ `inbound.insert`(冲突 = 已有,跳过)→ **带 `cursorAfter` 的逐封存游标** →
|
||||
新入库的才 `dispatch`;整批完存 `cursor` + `polledAt` + `error:null`;任何异常:存 `error`(脱敏)、
|
||||
游标停在最后一封成功处;`finally` 还租约;有变化就 `notify.changed('inbound')`。
|
||||
- **分发**(`dispatch`):对每个已注册订阅方顺序执行,先写 `dispatching` 再调,`done / skipped / failed(原话)`;
|
||||
一个订阅方失败不影响其它。**不自动重试**(§9.3)。
|
||||
- **发**(`send`):`validateSend` → `pickAccount` → 若带 `idempotencyKey` 且已有 `sent` 行 → 直接回它
|
||||
(放行重放不发两封)→ 落 `sending` 行 → `provider.send` → `sent`(providerRef)或 `failed` + 抛
|
||||
`MailSendFailedError`;附件 `uploadRef` 经 `attachments.get` 取字节。
|
||||
- **到期扫描**(`pollDue`):账号间 `Promise.allSettled` 并行,单账号内串行;一个账号的异常只记在它自己的 `last_poll_error`。
|
||||
- **账号变更**:`kind` 或 `settings` 变了 → 游标清空(下一轮重锚)、`last_poll_error` 清空;删除账号时另一进程若正拉着,它的 `savePoll` 落在不存在的行上 = no-op,不抛。
|
||||
- **探测**(`probe`):已存账号用库里凭证,未存用入参里的;wecom 探到应用邮箱地址后回填 `address`(已存账号)。
|
||||
- **凭证**:`createAccount/updateAccount` 里 `secret` 有值才 `seal`,空 = 保留原密文(与 llm 同款);
|
||||
视图只回 `secretBound`。
|
||||
- **默认账号**:`isDefault:true` 保存时先把别的置 false(同一事务;库上再钉一个部分唯一索引兜底)。
|
||||
|
||||
### 5.5 `internal/adapter/`
|
||||
|
||||
- `mail-schema.ts`:四张 Drizzle 表(§6)。`mail-store.ts`:三个 store;`claimPolling` 用原生 SQL(§9.1)。
|
||||
- `imap-smtp-provider.ts`(imapflow + nodemailer):
|
||||
- `anchor`:连上、`mailboxOpen(folder, {readOnly:true})`,无 backfill → `{uidValidity, lastUid: uidNext−1}`;
|
||||
有 backfill → `search({since})` 取最小 UID − 1。
|
||||
- `pull`:`readOnly` 打开(**不改已读标记**,轮询不动人的信箱);`uidValidity` 变了 → 重锚不回填、记一条 error 说明;
|
||||
`fetch(`${lastUid+1}:*`, {uid:true, source:true, internalDate:true})`,按 UID 升序,每封 `cursorAfter = {…, lastUid: uid}`;
|
||||
`more = 取满 limit`。**陷阱**:IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义),
|
||||
客户端必须再按 `uid > lastUid` 过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。
|
||||
- `send`:`nodemailer.createTransport({host, port, secure: smtpTls, auth:{user: login ?? address, pass}})`,
|
||||
`sendMail({messageId, from: '"displayName" <address>', to, cc, bcc, subject, text, html, inReplyTo, references, attachments})`;
|
||||
`probe`:IMAP 登录 + `transporter.verify()` 各报 ok / 厂商原话。
|
||||
- 超时:连接 30s、单封拉取 60s。
|
||||
- `wecom-provider.ts`(原生 `fetch`,可注入以便测试):
|
||||
- token 缓存**在实例闭包里**按账号存(不是模块级,§12.5 #6),提前 5 分钟刷新;40014 / 42001 刷一次重试;
|
||||
45009 → 本轮停、记 error;`-1` 重试 ≤3 次。
|
||||
- `anchor`:`{lastTime: backfillFrom ?? now}`;`pull`:`get_mail_list(window, cursor, limit)` 翻页,
|
||||
`knownProviderRefs` 先滤,新的才 `read_mail` → `mail_data`;**文档未说 `mail_data` 编码**,
|
||||
实现时探针定:先按原文解,解不出 `From:`/`Subject:` 再按 base64 兜底,探明后写死一档并记注释。
|
||||
- `send`:`compose_send` 按文档校验 50MB / 200 附件;发件人恒为应用邮箱;
|
||||
**不回 Message-ID** → `providerRef` 空,线程级归属(①级)对 wecom 发出的信不成立,③级地址窗仍可用(写进文档)。
|
||||
- `probe`:`gettoken` + `get_email_alias` → `receive.ok` 与地址;`send.checked=false` + 注「发信权限只能在真发时验证」——
|
||||
界面不撒谎,不把「拿到 token」说成「能发」。
|
||||
- `postal-mime-parser.ts`:`PostalMime.parse(eml)` → `ParsedMail`;地址、Message-ID、References 归一为裸串;
|
||||
日期解析失败留空。
|
||||
- `fake-provider.ts`:内存假 provider(可喂 EML 夹具、可让 send 失败)—— **邮件通道是外界,替身允许**;
|
||||
它是实现端口的适配器,不是测试里的 mock。
|
||||
|
||||
## 6. 数据模型与迁移 `db/migrations/0033_mail.sql`
|
||||
|
||||
```sql
|
||||
create table sys.mail_account (
|
||||
id text primary key,
|
||||
name text not null,
|
||||
kind text not null check (kind in ('imap-smtp','wecom')),
|
||||
address text not null default '', -- imap-smtp:登录邮箱;wecom:应用邮箱(探测后回填)
|
||||
display_name text not null default '',
|
||||
enabled boolean not null default true,
|
||||
is_default boolean not null default false,
|
||||
poll_interval_sec integer not null default 30 check (poll_interval_sec between 10 and 3600),
|
||||
channel_id text, -- 入站交给哪个通道;空 = channel 订阅方 skipped
|
||||
backfill_from timestamptz,
|
||||
settings jsonb not null default '{}', -- 按 kind 的非密配置(§7.1 schema)
|
||||
secret_cipher text, -- AES-256-GCM 密封的 JSON;永不出接口
|
||||
cursor jsonb,
|
||||
last_polled_at timestamptz,
|
||||
last_poll_error text,
|
||||
last_inbound_at timestamptz,
|
||||
polling_until timestamptz, -- 跨进程租约(与 sys.task.advancing_until 同款)
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create unique index mail_account_name_key on sys.mail_account (name);
|
||||
create unique index mail_account_one_default on sys.mail_account ((true)) where is_default; -- 默认账号至多一个
|
||||
create index mail_account_due_idx on sys.mail_account (enabled, last_polled_at);
|
||||
|
||||
create table sys.mail_inbound (
|
||||
id text primary key,
|
||||
account_id text not null, -- 不设外键:账号删了留痕还在
|
||||
message_id text not null,
|
||||
provider_ref text not null, -- imap:UID(带 validity 前缀);wecom:mail_id
|
||||
in_reply_to text,
|
||||
reference_ids jsonb not null default '[]',
|
||||
from_addr text not null default '',
|
||||
from_name text not null default '',
|
||||
to_list jsonb not null default '[]', -- [{address,name}]
|
||||
cc_list jsonb not null default '[]',
|
||||
subject text not null default '',
|
||||
text_body text,
|
||||
html_body text,
|
||||
sent_at timestamptz,
|
||||
received_at timestamptz not null,
|
||||
attachments jsonb not null default '[]', -- [{index,name,contentType,size,ref|null,note}],ref = sys.upload.id
|
||||
raw bytea, -- 原件;> 16MB 留空
|
||||
raw_note text not null default '',
|
||||
size integer not null default 0,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create unique index mail_inbound_message_key on sys.mail_inbound (account_id, message_id);
|
||||
create unique index mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref);
|
||||
create index mail_inbound_time_idx on sys.mail_inbound (received_at desc);
|
||||
|
||||
create table sys.mail_inbound_delivery (
|
||||
mail_id text not null references sys.mail_inbound(id) on delete cascade,
|
||||
subscriber text not null,
|
||||
state text not null check (state in ('dispatching','done','failed','skipped')),
|
||||
error text,
|
||||
started_at timestamptz not null default now(),
|
||||
finished_at timestamptz,
|
||||
primary key (mail_id, subscriber)
|
||||
);
|
||||
|
||||
create table sys.mail_outbound (
|
||||
id text primary key,
|
||||
account_id text not null,
|
||||
message_id text not null, -- 自造 <uuid@域>;线程级归属的对表来源
|
||||
idempotency_key text, -- 放行落地带的键;同键不发两封
|
||||
to_list jsonb not null,
|
||||
cc_list jsonb not null default '[]',
|
||||
bcc_list jsonb not null default '[]',
|
||||
subject text not null,
|
||||
text_body text,
|
||||
html_body text,
|
||||
in_reply_to text,
|
||||
reference_ids jsonb not null default '[]',
|
||||
attachments jsonb not null default '[]', -- [{name,contentType,size,ref}]
|
||||
state text not null check (state in ('sending','sent','failed')),
|
||||
error text,
|
||||
provider_ref text,
|
||||
requested_by jsonb not null default '{}', -- {kind:'tool'|'channel'|'ops', taskId?, staffId?, tool?}
|
||||
created_at timestamptz not null default now(),
|
||||
sent_at timestamptz
|
||||
);
|
||||
create unique index mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null;
|
||||
create index mail_outbound_time_idx on sys.mail_outbound (created_at desc);
|
||||
```
|
||||
|
||||
`meta/_journal.json` 追加 `idx 33 / tag 0033_mail`。`reset-runs` 加 `sys.mail_inbound`、
|
||||
`sys.mail_inbound_delivery`、`sys.mail_outbound`(清跑出来的,留 `sys.mail_account` 这份配置)。
|
||||
|
||||
## 7. 契约变更(`packages/contracts`)
|
||||
|
||||
### 7.1 新增 `packages/contracts/src/mail.ts`(schema,前后端共用)
|
||||
|
||||
```ts
|
||||
export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const;
|
||||
export const imapSmtpSettingsSchema = z.object({
|
||||
imapHost: z.string().min(1).default('imap.exmail.qq.com'), imapPort: z.number().int().min(1).max(65535).default(993), imapTls: z.boolean().default(true),
|
||||
smtpHost: z.string().min(1).default('smtp.exmail.qq.com'), smtpPort: z.number().int().min(1).max(65535).default(465), smtpTls: z.boolean().default(true), // true=隐式 TLS(465);false=STARTTLS(587/25)
|
||||
login: z.string().min(1).optional(), // 缺省 = address
|
||||
folder: z.string().min(1).default('INBOX'),
|
||||
});
|
||||
export const wecomSettingsSchema = z.object({ corpId: z.string().min(1), apiBase: z.string().url().default('https://qyapi.weixin.qq.com') });
|
||||
const accountCommon = { name: z.string().min(1).max(64), displayName: z.string().max(64).default(''), enabled: z.boolean().default(true), isDefault: z.boolean().default(false),
|
||||
pollIntervalSec: z.number().int().min(10).max(3600).default(30), channelId: channelIdSchema.nullable().default(null), backfillFrom: z.iso.datetime().nullable().default(null) };
|
||||
export const mailAccountInputSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('imap-smtp'), address: z.string().email(), ...accountCommon, settings: imapSmtpSettingsSchema, secret: z.object({ password: z.string().min(1) }).optional() }),
|
||||
z.object({ kind: z.literal('wecom'), ...accountCommon, settings: wecomSettingsSchema, secret: z.object({ corpSecret: z.string().min(1) }).optional() }), // address 由探测回填
|
||||
]);
|
||||
export const mailAccountViewSchema = z.object({ id, kind, name, address, displayName, enabled, isDefault, pollIntervalSec, channelId, backfillFrom, settings /* 非密 */, secretBound: z.boolean(),
|
||||
status: z.object({ lastPolledAt: iso|null, lastPollError: string|null, lastInboundAt: iso|null, cursor: string|null /* 人读摘要 */, polling: z.boolean() }), createdAt, updatedAt });
|
||||
export const probeReportSchema = z.object({ kind, address: z.string().optional(),
|
||||
receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().optional() }),
|
||||
send: z.object({ checked: z.boolean(), ok: z.boolean().optional(), error: z.string().optional(), note: z.string().optional() }) });
|
||||
export const deliveryViewSchema = z.object({ subscriber, state: z.enum(['dispatching','done','failed','skipped']), error: string|null, startedAt, finishedAt: iso|null });
|
||||
export const inboundAttachmentSchema = z.object({ index: int, name, contentType, size: int, ref: string|null, note: string });
|
||||
export const inboundSummarySchema = z.object({ id, accountId, messageId, from: address, to: address[], cc: address[], subject, sentAt: iso|null, receivedAt, textPreview: z.string() /* ≤200 字 */, hasHtml, attachments: inboundAttachmentSchema[], deliveries: deliveryViewSchema[] });
|
||||
export const inboundDetailSchema = inboundSummarySchema.extend({ inReplyTo: string|null, references: string[], text: string|null, html: string|null, size: int, rawAvailable: z.boolean() });
|
||||
export const sendInputSchema = z.object({ accountId: z.string().optional(), to: z.array(z.string().email()).min(1), cc: z.array(z.string().email()).default([]), bcc: z.array(z.string().email()).default([]),
|
||||
subject: z.string().min(1), text: z.string().optional(), html: z.string().optional(), inReplyTo: z.string().optional(), references: z.array(z.string()).default([]),
|
||||
attachments: z.array(z.object({ name: z.string().min(1), contentType: z.string().default('application/octet-stream'), dataBase64: z.string().optional(), uploadRef: z.string().optional() })).default([]),
|
||||
idempotencyKey: z.string().optional(), requestedBy: z.object({ kind: z.enum(['tool','channel','ops']), taskId: z.string().optional(), staffId: z.string().optional(), tool: z.string().optional() }) })
|
||||
.refine(v => v.text !== undefined || v.html !== undefined, '正文 text / html 至少一个');
|
||||
export const sendResultSchema = z.object({ outboundId, accountId, messageId, state: z.enum(['sent','failed']), error: z.string().optional(), providerRef: z.string().optional() });
|
||||
export const outboundViewSchema = z.object({ id, accountId, messageId, to: string[], cc, bcc, subject, state: z.enum(['sending','sent','failed']), error: string|null, providerRef: string|null, requestedBy, createdAt, sentAt: iso|null });
|
||||
```
|
||||
|
||||
### 7.2 新增路由 `packages/contracts/src/api/mail.ts`(经 `api/index.ts` 转出;写路由由 `/api/gov/*` 中间件自动广播)
|
||||
|
||||
| 方法 | 路径 | access | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/gov/mail/accounts` | `['design','ops']` | `{accounts: MailAccountView[]}` |
|
||||
| POST | `/api/gov/mail/accounts` | `ops` | body `MailAccountInput`;409 名字重复 |
|
||||
| POST | `/api/gov/mail/accounts/probe` | `ops` | **未存**的配置探测:body `MailAccountInput`(带 secret)→ `ProbeReport` |
|
||||
| GET | `/api/gov/mail/accounts/:accountId` | `['design','ops']` | 404 |
|
||||
| PUT | `/api/gov/mail/accounts/:accountId` | `ops` | secret 缺省 = 不改 |
|
||||
| DELETE | `/api/gov/mail/accounts/:accountId` | `ops` | `{ok:true}`;留痕不删 |
|
||||
| POST | `/api/gov/mail/accounts/:accountId/probe` | `ops` | 已存账号探测(用库里凭证)→ `ProbeReport`;凭证空 412 |
|
||||
| POST | `/api/gov/mail/accounts/:accountId/poll` | `ops` | 立即拉一轮 → `PollOutcome`;另一进程占着 409;停用 412 |
|
||||
| POST | `/api/gov/mail/accounts/:accountId/send-test` | `ops` | body `{to, subject, text}` → `SendResult`(失败在 200 的 body 里如实说) |
|
||||
| GET | `/api/gov/mail/inbound?accountId&limit` | `['design','ops']` | `{mails: InboundSummary[]}`,limit 默认 50、上限 200 |
|
||||
| GET | `/api/gov/mail/inbound/:mailId` | `['design','ops']` | `InboundDetail` |
|
||||
| GET | `/api/gov/mail/inbound/:mailId/attachments/:index` | `['design','ops']` | `{name, contentType, dataBase64}`;可带 `?ticket=` 下载(ADR-012 §6);未存(超限)404 |
|
||||
| GET | `/api/gov/mail/inbound/:mailId/raw` | `['design','ops']` | 原件 `{name:'<messageId>.eml', contentType:'message/rfc822', dataBase64}`;可带 `?ticket=`;超限没存 404 |
|
||||
| POST | `/api/gov/mail/inbound/:mailId/redeliver` | `ops` | body `{subscriber?}` → `{deliveries}`;订阅方名不认识 404 |
|
||||
| GET | `/api/gov/mail/outbound?accountId&limit` | `['design','ops']` | `{mails: OutboundView[]}` |
|
||||
|
||||
错误语义按仓内口径:400 `validation-failed`(带 issues)· 404 `not-found` · 409 `conflict` · 412 `precondition-failed`;
|
||||
探测 / 试发 / 拉取的**厂商失败不是接口错误**,在 200 的 body 里带原话(脱敏)。
|
||||
`route-coverage.it.test.ts` 自动兜:少一条契约就红。
|
||||
|
||||
### 7.3 `mail.send` 工具契约升 2.0.0(`seed-mvp.ts` 的 `MAIL_TOOL`;安装恒新增,同版重装 no-op)
|
||||
|
||||
```ts
|
||||
name: 'mail.send', version: '2.0.0', effect: 'write', idempotent: true, connectionKeys: [], // 凭证归 sys.mail_account,不再走工具连接配置
|
||||
input: { type:'object', required:['to','subject'], properties: {
|
||||
to: { type:'array', items:{type:'string'}, minItems:1 }, cc: {…}, bcc: {…}, subject: {type:'string'}, text: {type:'string'}, html: {type:'string'},
|
||||
attachments: { type:'array', items:{ type:'object', required:['uploadRef'], properties:{ uploadRef:{type:'string'}, name:{type:'string'} } } },
|
||||
inReplyTo: {type:'string'}, accountId: {type:'string'} } },
|
||||
output: { type:'object', properties: { outboundId:{type:'string'}, messageId:{type:'string'}, state:{type:'string'} } },
|
||||
errors: [{code:'send-failed', retryable:true, message:'发信失败'}, {code:'no-account', retryable:false, message:'没有可用的邮箱账号'}],
|
||||
```
|
||||
|
||||
**破坏性**:`to` 由 string 变 array、`body` 改名 `text`。唯一消费方是已卸载的 quote-* 活动(2026-08-24),
|
||||
无在途调用;按 §12.4 记入 CONTRACT.md 与本 spec,不走三步弃用。
|
||||
|
||||
### 7.4 路由归属总表
|
||||
|
||||
`docs/API文档-鉴权与人员.md` §6 总表补上 7.2 的 15 行(读标两端、写归 `ops`)。
|
||||
|
||||
## 8. app 层
|
||||
|
||||
- **装配**(`container.ts`):`mail` 在 `toolRuntime` 之前创建(它是 L1,只要 db / secrets / clock / ids);
|
||||
`attachments` 注入件复用 `sys.upload` 的存取(把 `governance-ops.ts` 上传路由里那段 insert/select 抽成
|
||||
`app/upload-store.ts` 两个函数,路由与注入件同用 —— 唯一一处顺手抽取,范围限定在那两段);
|
||||
`notify` → `broadcaster.publish({kind:'system', id:'mail', reason})`。
|
||||
`toolRuntime` 改为 `createToolRuntime(new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), httpToolHandler)`
|
||||
—— 处理器在 container 里闭包持 `mail`(`firstPartyToolHandlers` 这个模块级导出随之改成装配时拼出的局部量;仓内没有别处引用它),`first-party.ts` 里的旧 `mailSend` 删除;处理器把 `idempotencyKey`
|
||||
与 `requestedBy:{kind:'tool', taskId?}` 带进 `mail.send`,失败映射为 `{ok:false, error:{code:'send-failed', retryable}}`。
|
||||
channel 的 `mail:` 注入件改为 `{ send: (m) => mail.send({...m 映射, requestedBy:{kind:'channel', taskId}}) 再回 {messageId, threadId: m.threadId ?? messageId} }`
|
||||
(按 `from` 找账号,找不到用默认);`createSimulatedMailTransport` 不再装配(代码与表暂留,记 debt)。
|
||||
channel 创建后:`mail.subscribe('channel', …)`(§8 末)。
|
||||
- **工作者**(`app/workers/mail-poller.ts`):`startMailPoller({container}, tickMs)`,与 `startDispatcher` 同形
|
||||
(`setInterval` + `unref` + `stop()`),每 tick `container.mail.pollDue(now)`;`main.ts` 起、`close()` 停。
|
||||
`config.ts` 加 `mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000)`。
|
||||
- **路由**(`app/routes/mail.ts`,`registerMailRoutes(app, container)`):按 7.2 逐条,错误映射照 5.1 的注释;
|
||||
附件下载路由认 `?ticket=`(中间件已支持 GET 票据)。
|
||||
- **channel 订阅方**(app 装配处的一个函数 `toInboundMail(detail)`,有测):
|
||||
账号 `channelId` 空 → `{skipped:'账号未绑定通道'}`;否则
|
||||
`channel.ingestMail(channelId, { messageId, threadId: inReplyTo ?? references.at(-1), from: from.address, to: to.map(a=>a.address), cc: …, subject, body: text ?? '', receivedAt: iso, attachments: 只带已存的 [{name, contentType, size, ref}] })`;
|
||||
`ChannelNotFoundError / ChannelDisabledError` 原样抛 → 留痕 `failed` 带原话(通道停用不是信的错,人在运营面看得见)。
|
||||
- **seed**:不种账号(凭证只能人填);`activities:install` 不涉及。
|
||||
|
||||
## 9. 可靠性与安全纪律
|
||||
|
||||
### 9.1 跨进程租约(两个后端连同一个库)
|
||||
|
||||
```sql
|
||||
update sys.mail_account
|
||||
set polling_until = now() + ($2 || ' milliseconds')::interval
|
||||
where id = $1 and enabled
|
||||
and (polling_until is null or polling_until < now())
|
||||
returning id
|
||||
```
|
||||
判与占一条语句(分两句写就是经典竞态);过期 = `max(5min, 3×poll_interval)`,进程崩了不锁死;拉完置空。
|
||||
`pollNow` 抢不到 → 409 `MailPollBusyError`(「另一台正在拉取」)。
|
||||
|
||||
### 9.2 去重与游标
|
||||
|
||||
`(account_id, message_id)` 与 `(account_id, provider_ref)` 两把唯一键:重复拉到(时钟偏、UIDVALIDITY 重锚、
|
||||
wecom 十分钟重叠窗)一律 `inserted:false` 静默跳过、不分发。游标**逐封推进**(IMAP)/ 逐批推进(wecom),
|
||||
中途失败停在最后一封成功处,下一 tick 从那儿接。首次无游标锚在当下,**不回填整箱**;
|
||||
`backfillFrom` 才回填。
|
||||
|
||||
### 9.3 分发语义(诚实版「至少一次」)
|
||||
|
||||
自动路径**只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话)`。不自动重试 ——
|
||||
channel → event 这条链按 `cause.ref` **不幂等**(`event.ingestInbound` 无去重),重投会起两单。
|
||||
失败在运营面看得见,「重新投递」是人按的钮,那一刻起是至少一次(文档写明)。
|
||||
进程在 `dispatching` 期间崩了:行停在 `dispatching`,列表按「投递状态未知(进程中断)」摆出来,同样交人。
|
||||
根治(channel/event 按 `cause.ref` 幂等,自动重投才安全)记 `docs/debt.md`。
|
||||
|
||||
### 9.4 凭证与脱敏
|
||||
|
||||
- 密封:复用 `SecretBox`(AES-256-GCM,`JCAI_SECRET_KEY`,ADR-009);视图只回 `secretBound`;更新时空 = 不改。
|
||||
- 明文只在 usecase 打开、交给 provider 那一段;不进 domain、不进留痕、不进日志。
|
||||
- 所有写进 `last_poll_error / delivery.error / outbound.error` 的厂商原话先过 `redactSecrets`
|
||||
(明文、base64、URL 编码三形);nodemailer 的 `response` 行可能带 AUTH 明文/密文,同样过。
|
||||
- token(wecom)只在 provider 实例内存里,不落库。
|
||||
|
||||
### 9.5 §7.3「两缝」
|
||||
|
||||
`MailProvider` 是新 port,它不经 `ModelGateway` / `ToolInvoker`:**入站是外界事实,持久化即记录**
|
||||
(回放读 `sys.mail_inbound`,永不重拉);**出站**活动侧经 `ToolInvoker`(可录可放),系统侧(channel / ops)
|
||||
留痕 `sys.mail_outbound`;`createFakeMailProvider` 是测试替身。这是「新增 port 的验收条件」的解释,
|
||||
随 ADR-013 一并定案。
|
||||
|
||||
### 9.6 界面不撒谎
|
||||
|
||||
探测分收 / 发两栏各说各的,wecom 的发信一栏 `checked:false` 明说「只能真发时验证」;
|
||||
拉取错误摆厂商原话;wecom 的 `address` 探到之前是空串,前端摆「待探测」而不是编一个;
|
||||
`polling` 只在租约未过期时为真。
|
||||
|
||||
## 10. 腾讯侧前置(管理员执行;写进《前端对接说明》)
|
||||
|
||||
**IMAP/SMTP 路**:① 管理员「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP;② 该邮箱在网页端
|
||||
「设置 → 收发信设置」开启 IMAP/SMTP 服务;③ 开了安全登录 / 微信绑定的,「设置 → 邮箱绑定」生成
|
||||
**客户端专用密码**(最多 20 个,短信下发),填进账号配置的密码格;公共邮箱可在企业微信管理端或经 API
|
||||
`create_auth_code:1` 生成(只返回一次)。
|
||||
**企业微信 API 路**:① 企业微信 ≥ 4.0.20;② 建自建应用,记下 corpid 与应用 secret;③ 管理端「协作 → 邮件」
|
||||
给该应用授邮件权限,系统自动分配应用邮箱(前缀=应用名拼音,可改);④ 如企业开了 IP 白名单,把后端出口 IP 加进去
|
||||
(否则 60020)。⑤ 发信配额归企业级共享,腾讯按账号数与信誉浮动,超了 SMTP/API 都会拒 —— 只记文档,不做控制。
|
||||
|
||||
## 11. 连带改动
|
||||
|
||||
| 处 | 改动 |
|
||||
|---|---|
|
||||
| `tools/module-tiers.json` + `docs/编码规范.md` §4 | tier 1 加 `mail`;表加一行「邮箱账号与收发:两条接入路适配、轮询拉取与留痕、订阅分发、出站留痕」 |
|
||||
| `apps/api/package.json` | `imapflow` `nodemailer` `postal-mime`(+ `@types/nodemailer` dev) |
|
||||
| `apps/api/src/app/tools/first-party.ts` | 删 `mailSend`;`FIRST_PARTY_TOOLS` 去掉 `mail.send` 那行(改由 container 装配) |
|
||||
| `apps/api/src/app/seed-mvp.ts` | `MAIL_TOOL` 升 2.0.0(§7.3) |
|
||||
| `apps/api/src/app/routes/governance-ops.ts` | 上传两段 SQL 抽到 `app/upload-store.ts`,行为不变 |
|
||||
| `apps/api/src/app/reset-runs.ts` | 加三张留痕表 |
|
||||
| `apps/api/src/modules/channel/CONTRACT.md` | 「邮件通道是外界」一条改为:真通道由 `mail` 模块提供(app 注入),模拟件只剩测试用途 |
|
||||
| `docs/adr/ADR-013-mail-gateway.md` | 邮件收发:外界适配两条路、入站先持久后分发、出站留痕、凭证密封、跨进程租约、「两缝」解释 |
|
||||
| `docs/API文档-邮件收发.md` · `docs/前端对接说明-邮件收发.md` | 本轮产出(§15) |
|
||||
| `docs/API文档-鉴权与人员.md` §6 | 总表加 15 行 |
|
||||
| `docs/debt.md` | 见 §13 |
|
||||
| `CLAUDE.md` 现状快照 | 加一条 |
|
||||
| `api-report/` | `pnpm api:check --update && git add api-report`(新模块契约面 + contracts) |
|
||||
|
||||
## 12. 测试策略
|
||||
|
||||
- **domain 零 mock**:游标两形与摘要、wecom 窗口、到期判定与租约时长、`inboundRecordOf`(缺 Message-ID / 只有 html /
|
||||
附件超限)、`redactSecrets` 三形、`validateSend` 与 `pickAccount`(指定停用 / 无默认 / 多默认)、分发状态机、`viewOf` 不带密文。
|
||||
- **usecase**(假 provider + 内存 store):拉 → 存 → 分发全链;重复拉不重分发;中途失败游标停对位;订阅方 skipped / failed 互不影响;
|
||||
`redeliver`;`send` 的幂等键、失败落行并抛、附件取自 upload;`probe` 两种入参;`updateAccount` 空 secret 不改密文;默认账号唯一。
|
||||
- **adapter**:`postal-mime-parser` 跑夹具(GBK 主题正文、多段、纯 html、附件、无 Message-ID);
|
||||
`wecom-provider` 对假 `fetch`(token 缓存与过期刷新、45009 停、翻页、已知 mail_id 不再 read_mail、compose_send 载荷);
|
||||
`imap-smtp-provider` 的映射逻辑(UID 区间、validity 变化、`cursorAfter`)单测;真信箱 IT 用 env 门控
|
||||
`JCAI_MAIL_IT_IMAP_HOST/USER/PASS`(缺即 `describe.skipIf`)。
|
||||
- **IT(真 PG)**:迁移;三个 store;`claimPolling` 两连接对抢 20 轮每轮恰一个赢、过期后接得上;唯一键冲突回 `inserted:false`。
|
||||
- **app**:`toInboundMail` 映射;`mailSendTool` 错误映射;`route-coverage` 自动覆盖新路由归属。
|
||||
- 门禁:`pnpm gates` 全绿 + api-report 更新。
|
||||
|
||||
## 13. 风险、取舍与欠账(进 `docs/debt.md`)
|
||||
|
||||
1. **channel/event 按 `cause.ref` 不幂等** → 分发只能自动一次;根治后可改自动重投。
|
||||
2. **wecom 发信拿不到 Message-ID** → 线程级归属对 wecom 出信不成立(③级地址窗仍可用)。
|
||||
3. **`mail_data` 编码文档未说** → 实现时探针定档;两档都留兜底直到探明。
|
||||
4. **IMAP IDLE 未做** → 最快 10s 延迟(`pollIntervalSec` 下限)。
|
||||
5. **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留** → 下一轮退役。
|
||||
6. **附件走 `sys.upload` 的 8MB 上限** → 更大的附件只留注;要真存得改上传件的存放策略。
|
||||
7. **订阅方不落库** → 新加订阅方收不到历史信(运营面可手动投)。
|
||||
|
||||
## 14. 实施边界
|
||||
|
||||
改:`apps/api/src/modules/mail/**`(新)· `packages/contracts/src/{mail.ts, api/mail.ts, api/index.ts, index.ts}` ·
|
||||
`db/migrations/0033_mail.sql` + journal · `apps/api/src/app/{container.ts, main.ts, config.ts, reset-runs.ts, seed-mvp.ts, upload-store.ts(新), routes/mail.ts(新), workers/mail-poller.ts(新), tools/first-party.ts}` ·
|
||||
`tools/module-tiers.json` · 文档(§11)。
|
||||
不改:`apps/web/**`、`channel` 的 index/contract/port、任何门禁配置、`ingest` 路由(仍是注入门,e2e 继续用)。
|
||||
|
||||
## 15. 文档产出
|
||||
|
||||
- `docs/API文档-邮件收发.md`:通则 → 每条路由一节(`METHOD /path —— access`)→ 状态码与 body → schema 附录 → curl 实录。
|
||||
- `docs/前端对接说明-邮件收发.md`:一分钟看懂 → 建议落点(运营中心 · 系统区「邮箱」)→ 账号表单按 `kind` 切换的字段清单与默认值 →
|
||||
探测 / 立即拉取 / 试发 / 重新投递四个动作的交互与「界面不撒谎」口径 → 入站 / 出站列表与附件预览 → SSE 失效映射(`kind:'system'` → `['gov','mail',*]`)→
|
||||
腾讯侧前置(§10)→ 接入完成的判据。
|
||||
@@ -0,0 +1,213 @@
|
||||
# 统一鉴权中心(Keycloak)—— 前端对接说明
|
||||
|
||||
> 后端已按 ADR-012 改成**资源服务器**:不再有登录口、会话 cookie 与岗位授权;
|
||||
> 每个请求带 `Authorization: Bearer <access_token>`,后端本地验签、按「角色 → 端」判权。
|
||||
> 这份说明只讲**前端要改什么、后端给了什么**。浏览器怎么登录、怎么取 / 刷 token,
|
||||
> 照公司统一的《Keycloak 前端接入手册》,本文不重复。
|
||||
> 接口的字段与状态码以 `docs/API文档-鉴权与人员.md` 与 `packages/contracts/src/api/*.ts` 为准。
|
||||
|
||||
## 0. 一分钟看懂
|
||||
|
||||
- 后端**不做登录**。前端(公司已在 Keycloak `corp` realm 建好两个前端 client:`web-portal` 用户端
|
||||
`jcai-bpos.akhr-ai.com` / `web-console` 管理端 `jcai-bpos-opt.akhr-ai.com`,PKCE)自己拿 token。
|
||||
- token 的 `aud` 必须含本后端的 clientId —— **本部署是 `task-dispatcher`**(后端环境变量 `KEYCLOAK_CLIENT_ID`);
|
||||
`web-portal` 与 `web-console` 都已挂好指向它的 audience mapper(2026-08-26 实测确认),前端不用为此做任何事;
|
||||
后端**不看 `azp`**,用哪个前端 client 取的 token 都行 —— 没有 aud 会全线 401,后端的 401 message 会点名让管理员加 audience mapper。
|
||||
- 每个请求头带 `Authorization: Bearer …`。`GET /api/me` 回答两件事:我是谁、我能进哪几个端(`ends`)。
|
||||
- **权限只到端**:`design`(设计中心 `/design`)/ `ops`(运营中心 `/ops`)/ `workbench`(工作台 `/`)。
|
||||
当前 realm 里的角色不止 `admin` / `user` 两种(服务账号还持有 `service`),而**没配进「角色 → 端」表的角色一律不加端** ——
|
||||
所以 `ends: []` 不是假想的边界,是真会遇到的(§3 那一段说了怎么呈现)。
|
||||
入口亮不亮、页面能不能进,只看 `ends`;进去之后哪个接口能调,后端按每条路由的归属自己判(§6 总表)。
|
||||
- 两条带不了 Authorization 头的路(`EventSource` 与 `<a href download>`)用 **60 秒票据**:
|
||||
`GET /api/auth/ticket` → 把 `ticket` 放到那条 GET 的 `?ticket=` 上。
|
||||
- 401 = 该重新登录(或刷 token);403 = 没权限,重登没用;410 = 这条路由退役了,看 §7 的替代。
|
||||
|
||||
## 1. 请求链
|
||||
|
||||
```
|
||||
浏览器(SPA,PKCE)──登录/刷新 token──▶ https://keycloak.akhr-ai.com/realms/corp
|
||||
│
|
||||
│ Authorization: Bearer <access_token>(aud ∋ task-dispatcher)
|
||||
▼
|
||||
apps/api 鉴权中间件(唯一一处)
|
||||
① 路由归属:契约里每条路由自带 access(public / any / 端)
|
||||
② 验签:RS256 · iss · aud · 30s 容差(本地,不回源)
|
||||
③ 名册即时更新:第一次见到这个 sub 就建档(岗位「未分配」)
|
||||
④ 角色 × 角色→端表 → ends;端不够 → 403
|
||||
⑤ 路由拿到 principal(staff / position / roles / ends)
|
||||
```
|
||||
|
||||
## 2. 改动清单(按 `apps/web` 的文件)
|
||||
|
||||
| 文件 | 现在 | 改成 |
|
||||
|---|---|---|
|
||||
| `shared/typed-fetch.ts` | `credentials: 'same-origin'`,靠 cookie | 每个请求加 `Authorization: Bearer <token>`;401 → 刷一次 token 重试,仍 401 → 落登录;403 → 抛给页面提示(带 `detail.need` / `detail.ends`),**不要**跳登录 |
|
||||
| `app/main.tsx`(`Gate`) | `call(sessionRoute)`,401 → `LoginPage` | 没 token → 走手册的登录跳转;有 token → `call(meRoute)`;`authed` = `me` 200;401 仍落登录 |
|
||||
| `features/auth/internal/LoginPage.tsx` | 账号 + 口令表单 `POST /api/login` | 删除表单;换成「跳转到统一登录」(手册的 `login()`);`/api/login` 现回 410 |
|
||||
| `shared/gov/surfaces.ts` | `canSeeSurface(session, surface)` 读 `session.grants` × `SURFACE_FNS` | `canSeeSurface(me, surface) = me.ends.includes(surface)`;`SURFACE_FNS` 删除 |
|
||||
| `app/gov-shell.tsx` / `app/app-shell.tsx` | `session.data?.staff.name`;登出 `call(logoutRoute)` | `me.data?.staff.name`;登出 = 丢掉本地 token + Keycloak end-session(手册);`/api/logout` 现回 410 |
|
||||
| `app/subscribe.ts` | `new EventSource('/api/stream')` | 先 `GET /api/auth/ticket`,再 `new EventSource('/api/stream?ticket=…')`;断线重连要**重新取票据**(§5) |
|
||||
| `features/desk-task/internal/ViewRegion.tsx` + `FileViewer.tsx` | `<a href="/api/desk/tasks/:id/file?…&raw=1" download>` | 点击前现取票据拼到 `&ticket=`,或 `fetch`(带头)→ `blob:` URL(§5) |
|
||||
| `features/gov-staff/*` + `shared/gov/gov-api.ts` | 建人 / 停用 / 停用前置检查 / 岗位授权 | 四条已退役(410);换成:同步名册、指派岗位、在途工作、角色 → 端(§7);`useGovGrants` 删 |
|
||||
| 类型清理 | `Session` `sessionSchema` `grant*` `GovernanceFunction*` `disableCheckSchema` | 这轮契约里标了 `@deprecated`,web 编译照过;前端接入完成时一并从 `@contracts` 删(§12.4 三步之第三步) |
|
||||
|
||||
不用动的:`ui-kit` 的 `ViewContainer`、各 feature 的业务调用(路由契约的路径 / 参数 / 出参一个没改,只是每条多了 `access`)、
|
||||
Vite 的 `/api` 反代、SSE 事件的形状(`ready` / `touch` / `ping`)。
|
||||
|
||||
## 3. `GET /api/me` 与「端」
|
||||
|
||||
```json
|
||||
{
|
||||
"staff": {
|
||||
"id": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
|
||||
"account": "chaihao",
|
||||
"name": "柴浩",
|
||||
"email": "chaihao@akhr-ai.com",
|
||||
"positionId": "pm",
|
||||
"state": "active",
|
||||
"roles": ["admin"]
|
||||
},
|
||||
"position": { "id": "pm", "code": "PM", "name": "项目经理", "note": "定价审批、策略拍板" },
|
||||
"roles": ["admin"],
|
||||
"ends": ["design", "ops", "workbench"]
|
||||
}
|
||||
```
|
||||
|
||||
- `staff.id` 就是 Keycloak 的 `sub`。所有原来拿 `staff.id` 的地方(负责人、卡的收件人、工作组成员)语义不变,值变成 UUID。
|
||||
- `staff.roles` 是 realm 角色(签发时的快照;`default-roles-corp` / `offline_access` / `uma_authorization` 已滤掉)。
|
||||
- `ends` 是**并集**:角色命中「角色 → 端」表的每一行取并集;顺序固定 `design, ops, workbench`。
|
||||
- `ends` 可以是 `[]`(角色在表里一行都没命中)—— `/api/me` 仍 200,别的路由 403。
|
||||
这时候页面该说「你的角色(xxx)还没有被授权进入任何一端,请联系运营中心」,不要落登录页。
|
||||
- 岗位 `position.code` 有两个特殊值:`NONE`(未分配,新人第一次登录就是它)、`TEST`(测试账号)。
|
||||
|
||||
403 的 `detail` 告诉你差的是哪个端、他能进哪几个:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "forbidden",
|
||||
"message": "进入「运营中心」需要授权;你的角色(user)可进:工作台",
|
||||
"detail": { "need": ["ops"], "ends": ["workbench"] }
|
||||
}
|
||||
```
|
||||
|
||||
## 4. token 怎么带、失败怎么处理
|
||||
|
||||
| 状态 | code | 意思 | 前端动作 |
|
||||
|---|---|---|---|
|
||||
| 401 | `unauthenticated` | 没带 token / 过期 / 签名不对 / aud 不含本服务(message 会说清哪一种) | 先刷一次 token 重试;仍 401 → 清本地 token,走登录 |
|
||||
| 403 | `forbidden` | token 对,但账号已在鉴权中心停用,或这条路由的端不在 `ends` 里 | 提示,不重登;停用的 message 会说「已停用」 |
|
||||
| 410 | `gone` | 路由随统一鉴权中心退役 | 改调 message 里点名的替代路由(§7) |
|
||||
| 409 | `conflict` | 角色 → 端改完会没人能进运营中心(锁死保护) | 把 message 原样给用户看 |
|
||||
|
||||
细节:
|
||||
|
||||
- access token 默认 5 分钟;刷新由前端管(手册),后端不参与。后端验签有 30 秒时钟容差。
|
||||
- 后端每个请求都验签,是纯本地运算(公钥缓存 10 分钟),不用担心「带 token 会慢」。
|
||||
- 撤了角色,旧 token 到期前仍按旧角色算(最坏 5 分钟);账号停用经运营中心「同步名册」后即时 403。
|
||||
- 不要把 token 放进 URL(票据除外,见 §5)、不要放进 localStorage 以外的地方由后端读 —— 后端只认 `Authorization` 头。
|
||||
|
||||
## 5. 票据:SSE 与下载
|
||||
|
||||
`EventSource` 与 `<a href download>` 带不了请求头,所以后端给了一条**只对 GET 有效**的路:
|
||||
|
||||
```
|
||||
GET /api/auth/ticket Authorization: Bearer …
|
||||
→ 200 { "ticket": "v1.OGYzYzFhMmI….1756112460.k5v…", "expiresAt": "2026-08-25T09:01:00.000Z" }
|
||||
```
|
||||
|
||||
- 票据 60 秒有效、只在 **GET** 的 `?ticket=` 上认;POST / PUT / DELETE 带了也当没带(401)。
|
||||
- 票据里没有权限:后端拿它对应的 sub 回库现算,停用 / 改角色即时生效。
|
||||
- 同时带了 `Authorization` 头和票据时,以头为准。
|
||||
|
||||
### SSE
|
||||
|
||||
```ts
|
||||
// app/subscribe.ts 的改法(示意)
|
||||
async function openStream(onTouch: (t: Touch) => void, onOpen: () => void): Promise<() => void> {
|
||||
const { ticket } = await call<{ ticket: string; expiresAt: string }>(authTicketRoute);
|
||||
const source = new EventSource(`/api/stream?ticket=${encodeURIComponent(ticket)}`);
|
||||
source.addEventListener('touch', (e) => onTouch(JSON.parse((e as MessageEvent<string>).data)));
|
||||
source.addEventListener('open', onOpen);
|
||||
// 票据只活 60 秒:断线后浏览器自动重连用的还是旧 URL,后端会 401,
|
||||
// 而 EventSource 收到非 200 就不再自动重连 —— 所以 error 时要自己关掉、重新取票据、重建
|
||||
source.addEventListener('error', () => {
|
||||
source.close();
|
||||
setTimeout(() => void openStream(onTouch, onOpen), 1000);
|
||||
});
|
||||
return () => source.close();
|
||||
}
|
||||
```
|
||||
|
||||
### 下载原件
|
||||
|
||||
两种都行,任选其一:
|
||||
|
||||
1. 点击前现取票据:`href = \`/api/desk/tasks/${id}/file?address=…&key=…&raw=1&ticket=${encodeURIComponent(ticket)}\``
|
||||
—— 票据 60 秒,**别在渲染时预取**,要在 `onClick` 里取完再 `window.location.assign(href)`(或动态创建 `<a>` 点一下)。
|
||||
2. `fetch(href, { headers: { authorization } })` → `blob()` → `URL.createObjectURL` → `<a download>` 点一下 → `revokeObjectURL`。
|
||||
不用票据,但大文件会先进内存。
|
||||
|
||||
`FileViewer` 里「预览」那条路本来就是 `fetch`(经 `typed-fetch`),带头即可;只有「下载原件」那枚 `<a download>` 需要上面两选一。
|
||||
|
||||
## 6. 谁能进哪个端:入口与页面
|
||||
|
||||
- 入口亮灯:`canSeeSurface(me, 'design')` / `'ops'`;工作台入口同理看 `'workbench'`(从前工作台是默认进得去的,现在也要看 `ends`)。
|
||||
- 深链保护:进入 `/design` 或 `/ops` 时若 `ends` 不含该端,落到能进的第一个端;一个都不能进 → §3 的「未授权」页。
|
||||
- 页面内的按钮**不用**再按功能藏:后端不再有「task:read / task:govern」这种粒度,同一端内的接口对能进该端的人全开。
|
||||
某些**读**接口两端都能调(`GET /api/gov/staff` 等八条,见 API 文档 §6),
|
||||
写接口只归一端(触发器的启停 / 节点负责人归运营;定义类归设计)。
|
||||
|
||||
## 7. 退役路由与替代
|
||||
|
||||
| 退役(410) | 替代 |
|
||||
|---|---|
|
||||
| `POST /api/login` | 手册的登录跳转(Keycloak) |
|
||||
| `POST /api/logout` | 丢本地 token + Keycloak end-session |
|
||||
| `GET /api/session` | `GET /api/me` |
|
||||
| `POST /api/gov/staff`(建人) | 人来自鉴权中心:首次登录自动建档;或 `POST /api/gov/staff/sync` 同步名册 |
|
||||
| `GET /api/gov/staff/:id/disable-check` | `GET /api/gov/staff/:id/in-flight`(只读:此人身上还挂着什么) |
|
||||
| `POST /api/gov/staff/:id/disable` | 在 Keycloak 停用账号 → `POST /api/gov/staff/sync` |
|
||||
| `GET|POST /api/gov/positions/:id/grants` | `GET|PUT|DELETE /api/gov/access/roles[/:role]`(角色 → 端) |
|
||||
|
||||
运营中心 · 员工 那一页建议长这样:
|
||||
|
||||
- **名册**:`GET /api/gov/staff` 照旧(每人多了 `email` / `roles`;`positions` 里多了 `NONE` 未分配、`TEST` 测试);
|
||||
「同步名册」按钮 → `POST /api/gov/staff/sync`,`configured:false` 就把 `message` 原样摆出来(别摆一排 0);
|
||||
每人一个岗位下拉 → `PUT /api/gov/staff/:id/position`;「在途工作」→ `GET /api/gov/staff/:id/in-flight`。
|
||||
- **角色 → 端**:`GET /api/gov/access/roles` 出 `roles`(已配置的行)与 `knownRoles`(名册上见过的 ∪ 已配置的);
|
||||
每行三个勾(设计中心 / 运营中心 / 工作台)→ `PUT /api/gov/access/roles/:role` body `{ ends }`;
|
||||
删行 → `DELETE`;409 时把 message 摆出来(「改完没人能进运营中心」)。
|
||||
没在表里的 `knownRoles` 项要能一键「加进来」(等于 PUT 一行)。
|
||||
- **工作组**:`GET|PUT /api/gov/work-groups` 不变。
|
||||
|
||||
## 8. 本地开发
|
||||
|
||||
- 后端启动必须配 `KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID`(缺一拒绝启动,会打印缺哪个);
|
||||
可选 `KEYCLOAK_PROVISIONING_CLIENT_ID/_SECRET`(同步名册)、`JCAI_TEST_ACCOUNTS`(测试账号用户名)。
|
||||
- Vite 的 `/api` 反代不变;浏览器直连 `keycloak.akhr-ai.com` 取 token,与后端无关(跨域由 Keycloak 的 Web Origins 管,手册有写)。
|
||||
- 想在没前端的情况下打接口:用服务账号拿 token(见 API 文档 §8 的 curl),或让管理员临时给 `web-portal` 开 Direct Access Grants 用密码取一枚(手册 §6.1,用完关掉)。
|
||||
- 试跑 / e2e 用的是 **`task-dispatcher` 的服务账号**(`service-account-task-dispatcher`)。
|
||||
注意它当前只有 `service` + `user` 两个 realm 角色 → 只进得了工作台;要跑测试台(设计端)得在运营中心
|
||||
给 `service` 配上 design,或给该账号加 `admin`。
|
||||
|
||||
## 9. 接入完成的判据
|
||||
|
||||
- [ ] 未登录打开任一页 → 跳统一登录;登录回来 `GET /api/me` 200,首页按 `ends` 落到能进的端
|
||||
- [ ] `ends = []` 的账号看到「未授权」页,不是登录页,也不是白屏
|
||||
- [ ] 三个入口的亮灯与 `ends` 一致;深链到没权限的端会被挡回
|
||||
- [ ] token 过期(等 5 分钟)后操作不报错:刷新后重试成功
|
||||
- [ ] SSE 连上、断线 61 秒后能自动重连(新票据)
|
||||
- [ ] 「下载原件」拿到的是真字节(`.docx` 能被 Word 打开)
|
||||
- [ ] 运营中心 · 员工:同步名册 / 指派岗位 / 角色 → 端三样能用;409 与 `configured:false` 的话原样可见
|
||||
- [ ] 登出后再访问 → 401 → 登录页;旧 token 不再被任何地方发出
|
||||
- [ ] `apps/web` 里不再 import `loginRoute` / `logoutRoute` / `sessionRoute` / `Session` / `grantSchema` / `SURFACE_FNS`
|
||||
|
||||
## 10. 本文与真实契约的核对状态
|
||||
|
||||
Task 12 已用契约把本文核过一遍:§2 的十个文件指针全部存在(`apps/web` 本轮未动,指针原样有效);
|
||||
§7 的退役路由与替代逐条对过 `docs/API文档-鉴权与人员.md` §6 的总表,一致。
|
||||
**2026-08-26 补:整条链已在真环境跑通并实测。** Keycloak 侧前置已就位(`task-dispatcher` 及两个前端 client
|
||||
的 audience mapper 齐备),后端以 `KEYCLOAK_CLIENT_ID=task-dispatcher` 启动、自检通过。实测验过的行为:
|
||||
验签 → claims 归一 → 首次登录 JIT 建档(落 `NONE / 未分配` 岗)→ 角色→端 → 端判定;工作台 200、运营中心 403(带 `detail`);
|
||||
票据签发后 `?ticket=` 真连上 SSE、同一张票据用在 POST 上被拒;`HEAD` 与 `GET` 判定一致。
|
||||
本文与 API 文档 §8 的响应示例现在都是**实测响应**,不再是按契约构造的样例。
|
||||
@@ -0,0 +1,120 @@
|
||||
# 邮件收发 —— 前端对接说明
|
||||
|
||||
> 后端已按 ADR-013 接上腾讯企业邮箱(两条路:IMAP/SMTP、企业微信邮件 API)。这份说明只讲
|
||||
> **前端要做什么、后端给了什么**;接口字段与状态码以 `docs/API文档-邮件收发.md` 与
|
||||
> `packages/contracts/src/{mail.ts, api/mail.ts}` 为准。鉴权、票据、SSE 的通则照《前端对接说明-统一鉴权中心》。
|
||||
|
||||
## 0. 一分钟看懂
|
||||
|
||||
- 腾讯**没有带内容的推送**:后端每个账号按 `pollIntervalSec` 轮询拉信,拉到就分发给订阅方(现在只有 `channel`)。
|
||||
界面上没有「实时收信」这回事,有的是「上次拉取时间 / 游标 / 上次错误」三个事实。
|
||||
- 账号有两种:**IMAP/SMTP**(任何腾讯企业邮箱地址,凭证是客户端专用密码)与**企业微信应用邮箱**(自建应用的 corpid + secret)。
|
||||
表单按 `kind` 切换字段;两种都有「探测」。
|
||||
- 凭证**只写不读**:视图只有 `secretBound`;编辑时密码框留空 = 不改。
|
||||
- 探测 / 立即拉取 / 试发 的失败在 **200 的 body** 里(`ok:false` / `state:'failed'` / `error` 原话),不是接口错误;把原话原样摆出来。
|
||||
- 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。**15 条接口全部 `access: 'ops'`**——
|
||||
账号 / 来信 / 发信记录都只有运营中心能读能写,设计中心没有这一页、也调不通(邮箱那一屏没有设计端消费方,
|
||||
入站正文又是客户来往内容,见 ADR-013 决策 10)。
|
||||
|
||||
## 1. 建议的信息架构
|
||||
|
||||
```
|
||||
运营中心 / 系统 / 邮箱
|
||||
├─ 账号列表(GET /api/gov/mail/accounts) 每行:名称 · kind 徽 · 地址 · 默认 ★ · 启用 · 通道 · 上次拉取 / 上次错误 · 「正在拉取」灯
|
||||
│ ├─ 新建 / 编辑(POST / PUT) 表单按 kind 切换;底部三个钮:测试连接 · 保存 · 删除
|
||||
│ └─ 行内动作:立即拉取(POST …/poll)· 试发(POST …/send-test)· 探测(POST …/:id/probe)
|
||||
├─ 来信(GET /api/gov/mail/inbound?accountId&limit) 列表 → 详情(正文 / 附件 / 原件 / 投递状态 / 重新投递)
|
||||
└─ 发信记录(GET /api/gov/mail/outbound?accountId&limit) 列表:主题 · 收件人 · 状态 · 错误 · 来路(tool / channel / ops)· 时间
|
||||
```
|
||||
|
||||
## 2. 账号表单(字段清单与默认值)
|
||||
|
||||
公共字段:`name`(必填,≤64)· `displayName`(发件人显示名)· `enabled`(默认开)· `isDefault`(默认发件账号,全局至多一个;勾上会把别人的放下)·
|
||||
`pollIntervalSec`(默认 30,10–3600)· `channelId`(下拉,来自 `GET /api/gov/channels`;空 = 来信留在运营面不进事件流,投递状态显示 `skipped · 账号未绑定通道`)·
|
||||
`backfillFrom`(日期,空 = 首次拉取从当下开始,不回填整箱)。
|
||||
|
||||
`kind = 'imap-smtp'`:`address`(登录邮箱,必填)· `settings.imapHost / imapPort / imapTls`(默认 `imap.exmail.qq.com` / 993 / 开)·
|
||||
`settings.smtpHost / smtpPort / smtpTls`(默认 `smtp.exmail.qq.com` / 465 / 开;关 = STARTTLS,配 587)· `settings.login`(登录名,缺省 = 地址)·
|
||||
`settings.folder`(默认 INBOX)· `secret.password`(客户端专用密码;编辑时留空 = 不改)。
|
||||
|
||||
`kind = 'wecom'`:`settings.corpId`(必填)· `settings.apiBase`(默认 `https://qyapi.weixin.qq.com`,一般不改)· `secret.corpSecret`(应用 secret;编辑时留空 = 不改)。
|
||||
`address` **不让人填**:探测成功后由后端回填(应用邮箱地址),探到之前摆「待探测」。
|
||||
|
||||
保存后建议立刻调一次 `POST …/:id/probe`,把报告摆在表单下方(收 / 发两栏)。
|
||||
|
||||
## 3. 四个动作的交互与「界面不撒谎」
|
||||
|
||||
| 动作 | 接口 | 怎么摆 |
|
||||
|---|---|---|
|
||||
| 测试连接(表单里) | `POST /api/gov/mail/accounts/probe?accountId=<编辑中的 id>` body = 表单 | 收 / 发两栏各一个绿勾 / 红叉 + 原话;wecom 的发信栏是灰色「未验证」+ note(拿到 token 不等于能发);没重填密码时**必须带 `?accountId=`**,否则 412 |
|
||||
| 探测(已存) | `POST …/:id/probe` | 同上;wecom 探到地址后列表里的 `address` 会变,重取列表 |
|
||||
| 立即拉取 | `POST …/:id/poll` | 摆 `pulled / stored / dispatched`;`more:true` 提示「还有余下,下一轮接着」;`error` 原话;409 摆「另一台后端正在拉取,稍后再试」;412 摆「账号已停用」 |
|
||||
| 试发 | `POST …/:id/send-test` body `{to, subject, text}` | `state:'sent'` 绿、`'failed'` 红 + `error` 原话;这一笔会出现在发信记录里 |
|
||||
|
||||
列表上的「正在拉取」灯只在 `status.polling` 为真时亮;`status.lastPollError` 非空就在行上摆一条红带(原话)。
|
||||
|
||||
## 4. 来信与投递状态
|
||||
|
||||
- 列表按收信时间倒序;每行:发件人 · 主题 · 收信时间 · 附件数 · 投递状态徽。
|
||||
- 投递状态徽按 `deliveries[]` 每个订阅方一枚:`done` 绿 / `skipped` 灰(悬停 `error` 里的理由)/ `failed` 红(原话)/ `dispatching` 蓝。
|
||||
**`dispatching` 超过 10 分钟** = 进程中断,摆成「投递状态未知」并给「重新投递」钮。
|
||||
- 详情:`text` 直接摆;`html` 走 `@ui` 的 `ViewContainer` 沙箱(feature 不得自行 `dangerouslySetInnerHTML`);`references` / `inReplyTo` 折叠在「线程」里。
|
||||
- 附件:`ref !== null` 的可预览 / 下载 —— `GET …/attachments/:index` 回 JSON + base64,与上传件同形,
|
||||
预览走 `fetch`(带 Authorization)解 base64;「下载原件」用票据:`GET /api/auth/ticket` → `href = …/attachments/0?ticket=…`,在 `onClick` 里取完再跳(票据 60 秒)。
|
||||
`ref === null` 的摆 `note`(「超过 8MB 上限,未存原件」),不给下载钮。
|
||||
- 「原件 .eml」同法:`GET …/raw`(`rawAvailable:false` 不给钮)。
|
||||
- 「重新投递」:`POST …/redeliver` body `{subscriber}` 或不带;先弹一句「从这一刻起是至少一次:channel 会再起一单」再发。
|
||||
|
||||
## 5. 发信记录
|
||||
|
||||
列表:主题 · 收件人 · `state`(sending 蓝 / sent 绿 / failed 红 + `error`)· `requestedBy.kind`(`tool` 活动放行 / `channel` 通道回信 / `ops` 运营试发)· `createdAt` / `sentAt`。
|
||||
`sending` 久停不动 = 进程中断(没有自动重试;活动放行那一侧会按落地失败处理)。
|
||||
|
||||
## 6. SSE 失效映射
|
||||
|
||||
`app/subscribe.ts` 的 `invalidateFor` 加一条:`touch.kind === 'system' && touch.id === 'mail'` → 失效 `['gov','mail']` 前缀
|
||||
(账号状态 / 来信 / 发信三组键都挂在它下面);写路由那一侧照旧走 `kind:'system', id:'gov'`。
|
||||
`reason` 取值:`inbound`(新来信或拉取出错)· `outbound`(发信落地)。不带载荷,回服务端重取。
|
||||
|
||||
## 7. 腾讯侧前置(管理员执行;缺一步就是探测红叉)
|
||||
|
||||
**IMAP/SMTP 路**
|
||||
1. 企业微信管理端「协作 → 邮件 → 安全管理 → 客户端访问权限」放开 IMAP/SMTP(范围含这个邮箱)。
|
||||
2. 该邮箱登录网页端「设置 → 收发信设置」开启 IMAP/SMTP 服务。
|
||||
3. 开了安全登录 / 微信绑定的邮箱,在「设置 → 邮箱绑定」生成**客户端专用密码**(最多 20 个,短信下发),填进表单的密码框;
|
||||
公共邮箱可在管理端生成(或经 API 建箱时 `create_auth_code:1`,只返回一次)。
|
||||
4. 服务器:`imap.exmail.qq.com:993` / `smtp.exmail.qq.com:465`(表单默认值);信创环境是 `xcimap` / `xcsmtp`。
|
||||
|
||||
**企业微信邮件 API 路**
|
||||
1. 企业微信 ≥ 4.0.20;建自建应用,记下 corpid 与应用 secret。
|
||||
2. 管理端「协作 → 邮件」给该应用授邮件权限 —— 系统自动分配应用邮箱(前缀 = 应用名拼音,可改)。
|
||||
3. 企业开了 API IP 白名单的,把后端出口 IP 加进去(否则探测报 60020)。
|
||||
4. 只能收发**应用邮箱自己**的信,不能读成员邮箱;发出的信拿不到 Message-ID(回信的线程归属靠地址 + 时间窗)。
|
||||
|
||||
发信配额归企业级共享(腾讯按账号数与信誉浮动),超了 SMTP / API 都会拒 —— 摆原话即可,后端不做配额控制。
|
||||
|
||||
## 8. 错误处理
|
||||
|
||||
| 状态 | 何时 | 前端动作 |
|
||||
|---|---|---|
|
||||
| 400 | 表单不合 schema / 试发缺正文 | 按 `detail`(zod issues)标到字段 |
|
||||
| 404 | 账号 / 来信 / 附件(超限没存)/ 原件(没存)/ 订阅方 | 刷新列表;附件那两种 404 不是 bug,是「没存」 |
|
||||
| 409 | 账号名重复 / 另一台后端占着租约 | 原话摆给人;租约那条几秒后可重试 |
|
||||
| 412 | 账号停用 / 没有默认账号 / 凭证空 | 原话摆给人;凭证空 → 引导去填密码 |
|
||||
|
||||
## 9. 接入完成的判据
|
||||
|
||||
- [ ] 两种 kind 都能建、改、删;编辑时不填密码保存后 `secretBound` 仍为 true
|
||||
- [ ] 表单里「测试连接」对已存账号带 `?accountId=`,不重填密码也能测;收 / 发两栏各自显示原话
|
||||
- [ ] wecom 账号探测后 `address` 从「待探测」变成应用邮箱地址
|
||||
- [ ] 立即拉取后来信列表多出条目;投递状态徽三色正确;`skipped` 悬停能看到理由
|
||||
- [ ] 附件预览 / 下载原件(票据)拿到真字节(`.xlsx` 能被 Excel 打开);超限附件只显示 note
|
||||
- [ ] 重新投递前有提示,投递后状态刷新
|
||||
- [ ] SSE:另一台后端拉到新信,本页 6 秒内自己刷新(不用点)
|
||||
- [ ] 试发失败时列表里看得到 `failed` 与原话
|
||||
- [ ] 账号停用 / 凭证空 / 名字重复的提示原话可见,没有「操作失败」这种空话
|
||||
|
||||
## 10. 本文与真实契约的核对状态
|
||||
|
||||
路由 15 条与 `packages/contracts/src/api/mail.ts` 逐条对过(全部 `access: 'ops'`);字段名与 `packages/contracts/src/mail.ts` 同名。
|
||||
示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。
|
||||
@@ -0,0 +1,458 @@
|
||||
# 数据域字段说明 —— E04 关系覆盖 · I01 品牌内容
|
||||
|
||||
> 对应**设计中心 → 数据域**(按活动分组的那一屏)。当前系统里装着的活动只有两个:
|
||||
> `crm-relationship-coverage`(E04 关系覆盖与关键人经营,v2.1.9)与
|
||||
> `brand-content-toolkit`(I01 品牌形象与销售工具包管理,v1.2.2)——
|
||||
> 报价链 A1–A7 已于 2026-08-24 从系统卸载,它们的表不在本文范围内。
|
||||
>
|
||||
> 字段以**活动清单的台账声明**为准(`activities/<包>/src/manifest.ts` 的
|
||||
> `declarations.ledger`)。那份声明同时是**授权面**:没声明的列写不进去,
|
||||
> 也不会出现在件上、判据里、数据域的活动分组里。
|
||||
|
||||
---
|
||||
|
||||
## 怎么读这份文档
|
||||
|
||||
- **★ = 重点字段**。每张表挑 5–8 个,在「重点字段」小节里**按重要程度从高到低**排;
|
||||
数据结构表里在列名后打 ★。
|
||||
- **【人填】= 人在活动过程中亲手填、提交后入库的字段** —— 执行工单的件上编辑,或数据录入卡。
|
||||
这类字段**一律进重点**:它们是这一单里唯一不能靠 AI 补出来的事实,AI 替它们编值就是编数据。
|
||||
- 「写入者」一栏的口径:
|
||||
- **人(件上)** —— 工作区那份 HTML 上点「编辑」改的格子,保存即落草稿层
|
||||
- **人(录入卡)** —— 对话区的数据录入卡,人确认那一下基座直接落库
|
||||
- **AI** —— 模型按说明书与剧本写
|
||||
- **工具** —— 活动声明的工具算出来、AI 照抄落表
|
||||
- **基座** —— 平台自己生成(自增序号等),不问人也不经模型
|
||||
- **承继** —— 从本活动上一张表照抄过来,供只读展示
|
||||
- 「约束」一栏的词:
|
||||
|
||||
| 词 | 含义 |
|
||||
|---|---|
|
||||
| 主键 | 认行的钥匙。件上主键格也挂着绑定,那是为了认行,不是让人改 |
|
||||
| 自增 | 基座按「同组主键值-两位序号」续号(如 `CU-0142-02`),不进表单 |
|
||||
| 必填 | 件上红带 / 执行卡提交闸 / 放行门判据**三处同源**,缺了交不了卷 |
|
||||
| 建议填 | 橘色占位提示,只提示不拦单 |
|
||||
| 取值域 | 只许写列出的那几个值(tally),基座据此出分档计数 |
|
||||
| 引用 | 值必须在另一张表里真实存在,查无此值写入当场被拒 |
|
||||
| 去重计数 | 基座数「有几个不同的值」,判据拿它做闭合(如「计划盖住了几家客户」) |
|
||||
| 发布时必填 | 行的状态流到某个值才必填(如内容状态 = 已发布) |
|
||||
| 脱敏 | 展示与留痕按脱敏口径处理 |
|
||||
|
||||
### 一览
|
||||
|
||||
| 活动 | 台账地址 | 人读名 | 性质 | 字段数 | 重点 | 人填列 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| E04 | `customer.profile` | 客户档案(L-02 客户主台账) | 主数据 · 共享 | 8 | 6 | 5 |
|
||||
| E04 | `customer.contact` | 客户联系人(L-08) | 主数据 · 共享 | 12 | 8 | 7 |
|
||||
| E04 | `crm_coverage.candidate` | 候选覆盖客户清单 | 本活动工作台账 | 9 | 7 | 6 |
|
||||
| E04 | `crm_coverage.plan` | 关键人经营计划 | 本活动工作台账 | 12 | 8 | 0(只审批,不直接改) |
|
||||
| E04 | `crm_coverage.receipt` | 经营计划执行回执 | 本活动工作台账 | 11 | 6 | 4 |
|
||||
| E04 | `crm_coverage.ledger_update` | 台账更新明细 | 本活动工作台账 | 11 | 7 | 0 |
|
||||
| E04 | `crm_coverage.verdict` | 本轮覆盖结论 | 本活动工作台账 | 10 | 6 | 0 |
|
||||
| I01 | `market.signal` | 宏观市场台账(L-01) | 主数据 · **只读** | 14 | 6 | 0 |
|
||||
| I01 | `content.asset` | 品牌内容库(L-05) | 主数据 · I01 唯一读写者 | 14 | 8 | 0 |
|
||||
| I01 | `content_brand.requirement` | 内容需求清单 | 本活动工作台账 | 8 | 6 | 4 |
|
||||
| I01 | `content_brand.verdict` | 本轮内容生产结论 | 本活动工作台账 | 10 | 6 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 一、E04 关系覆盖与关键人经营
|
||||
|
||||
七步:识别候选客户 → **覆盖范围确认(人)** → 缺口分析与制定计划 → **经营计划审批(人)** →
|
||||
**执行经营动作与回执(人)** → 更新关系台账 → **放行收口(人)**。
|
||||
四道人环里有三道会往台账里写下人亲手填的事实。
|
||||
|
||||
### 1.1 `crm_coverage.candidate` 候选覆盖客户清单
|
||||
|
||||
本轮要经营哪几家客户。**AI 先识别出候选基线,人在「覆盖范围确认」那一屏增删改** ——
|
||||
加一行 = 额外纳入(AI 没识别到但人有价值判断的客户),删一行 = 排除,改格子 = 修正。
|
||||
人加的行同等作数:后面的计划、回执、收口都得盖住它(`计划里不同客户数 = 候选行数` 是硬闭合)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `customer_code` ★【人填】主键之一、必填 —— 覆盖闭合判据的分母,人加行时自己填
|
||||
2. `customer_name` ★【人填】必填 —— 卡面、计划、回执上的「对象」都从它来
|
||||
3. `bt_level` ★【人填】决策链层级,重算覆盖度的入参
|
||||
4. `depth` ★【人填】本轮要做到多深(维护 / 突破 / 首访)
|
||||
5. `basis` ★【人填】纳入依据 —— 人加的那一行为什么值得经营,只有他说得出
|
||||
6. `cust_type` ★【人填】客户类型,有客户档案就抄档案
|
||||
7. `status` ★ 状态 —— 收口时置「已覆盖」,界面上的状态徽靠它
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | 案号,按开案时刻铸(含年秒,跨单唯一) |
|
||||
| `customer_code` ★ | 客户编码 | text | 主键 · 必填 | AI / **人(件上)** | 新客户由录入卡现场给码,AI 不许自编 |
|
||||
| `customer_name` ★ | 客户名称 | text | 必填 | AI / **人(件上)** | |
|
||||
| `cust_type` ★ | 客户类型 | text | 建议填 | AI / **人(件上)** | 有 `customer.profile.cust_type` 就抄它 |
|
||||
| `bt_level` ★ | BT 层级 | text | 建议填 | **人(件上下拉)** | 存 ASCII(`bt-01/02/03`),件上摆人读名「BT-01 · 采购/供应链」 |
|
||||
| `status` ★ | 状态 | text | 取值域 `pending` / `covered` / `excluded` | AI | S6 收口时置 `covered` |
|
||||
| `basis` ★ | 纳入依据 | text | 建议填 | AI / **人(件上)** | |
|
||||
| `depth` ★ | 覆盖深度 | text | 建议填 | **人(件上下拉)** | |
|
||||
| `origin` | 来源 | text | — | AI | `ai` / `human-added`,标出哪几行是人加的 |
|
||||
|
||||
### 1.2 `crm_coverage.plan` 关键人经营计划
|
||||
|
||||
一位关键人一行:该做什么动作、什么时候做、为什么。**件是只读的** ——
|
||||
业务口径写死「人只做审批,不能直接改结论」;人点「重做」并写意见,AI 按意见改了再重新提交。
|
||||
意见里点名哪一家/哪一位,基座会先按字面比对把归属摆给模型,不让它凭印象挑行。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `contact_code` ★ 主键之一 · **引用** `customer.contact.contact_code` —— 计划只许指向真实存在的人
|
||||
2. `customer_code` ★ 必填 · **去重计数** —— 「计划盖住了几家客户」就是数它
|
||||
3. `action` ★ 必填 —— 建议动作,审批被驳回时最常改的一格
|
||||
4. `target_date` ★ 必填 —— 没有时间点的建议不算数,验收判据点名核它
|
||||
5. `rationale` ★ 必填 —— 理由;驳回重做时若没写具体意见,先改实这一格
|
||||
6. `rank_score` ★ 必填 —— 排序分,模版不排序,必须已按此列降序写入
|
||||
7. `priority` ★ 必填 · 取值域 —— 优先级
|
||||
8. `relationship_gap` ★ 必填 —— 亲密度缺口(目标亲密度 − 当前亲密度)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | |
|
||||
| `contact_code` ★ | 联系人编码 | text | 主键 · 引用 `customer.contact.contact_code` | AI | 查无此码当场拒,并指路「先发录入卡把人收进来」 |
|
||||
| `customer_code` ★ | 客户编码 | text | 必填 · 去重计数 | AI | 覆盖闭合判据的落点 |
|
||||
| `customer_name` | 客户名称 | text | 建议填 | AI | |
|
||||
| `contact_name` | 联系人姓名 | text | 建议填 | AI | |
|
||||
| `action` ★ | 建议动作 | text | 必填 | AI(可按人的驳回意见改) | |
|
||||
| `priority` ★ | 优先级 | text | 必填 · 取值域 `high` / `medium` / `low` | AI | |
|
||||
| `rationale` ★ | 理由 | text | 必填 | AI | |
|
||||
| `influence` | 影响力(写计划那一刻的快照) | text | 必填 | AI | 抄自联系人档案 |
|
||||
| `relationship_gap` ★ | 亲密度缺口 | integer | 必填 | 工具 → AI | |
|
||||
| `rank_score` ★ | 排序分 | number | 必填 | 工具 `rank-key-persons` → AI | |
|
||||
| `target_date` ★ | 建议动作时间点 | date | 必填 | AI | |
|
||||
|
||||
### 1.3 `crm_coverage.receipt` 经营计划执行回执
|
||||
|
||||
**这张表是「人填后入库」最典型的一张**:S3 只写骨架(与计划一一对应,行数必须相等),
|
||||
完成状态那几列全部留空;人真的去拜访/沟通完,在「执行经营动作与回执」那一屏把结果填回来。
|
||||
空手交卷会被提交闸拦住,并点名缺在哪一行哪一格。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `completion_status` ★【人填】必填 · 取值域 —— 这件事到底办没办成
|
||||
2. `result_summary` ★【人填】必填 —— 见到谁、谈了什么、达成什么(多行文本)
|
||||
3. `key_person_change` ★【人填】必填 · 取值域 —— 关键人有没有变动,变了就触发主数据改档
|
||||
4. `customer_feedback` ★【人填】建议填 —— 客户态度 / 诉求 / 下一步信号(多行文本)
|
||||
5. `contact_code` ★ 主键之一 · 引用 —— 与计划一一对应,回执行数 = 计划行数是硬门
|
||||
6. `customer_name` ★ 承继 —— 件上「对象」列长「客户名 · 关键人」,两个名字都得在
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | |
|
||||
| `contact_code` ★ | 联系人编码(与计划一一对应) | text | 主键 · 引用 `customer.contact.contact_code` | AI | |
|
||||
| `customer_code` | 客户编码 | text | 建议填 | 承继自计划 | |
|
||||
| `customer_name` ★ | 客户名称 | text | — | 承继自计划 | 只读展示 |
|
||||
| `contact_name` | 联系人姓名 | text | 建议填 | 承继自计划 | |
|
||||
| `action` | 计划动作 | text | — | 承继自计划 | 只读展示,人照着它去执行 |
|
||||
| `target_date` | 建议时间点 | date | — | 承继自计划 | 只读展示 |
|
||||
| `completion_status` ★ | 完成状态 | text | 必填 · 取值域 `done` / `partial` / `skipped` | **人(件上下拉)** | |
|
||||
| `result_summary` ★ | 结果摘要 | text | 必填 | **人(件上多行文本)** | |
|
||||
| `customer_feedback` ★ | 客户反馈 | text | 建议填 | **人(件上多行文本)** | |
|
||||
| `key_person_change` ★ | 关键人变动 | text | 必填 · 取值域 `changed` / `unchanged` | **人(件上下拉)** | |
|
||||
|
||||
### 1.4 `customer.profile` 客户档案(L-02 客户主台账 · 共享主数据)
|
||||
|
||||
E04 平时只读它;两个例外会写:①**新客户建立合作**那一刻代行建档职责(数据录入卡);
|
||||
②收口时写回关系经营的结果(覆盖度、决策链地图、互动时间线)。
|
||||
放行前那一屏有「将写入的客户档案」落库预览 —— 摆的是**签下去之后这一行长什么样**,可就地改。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `customer_code` ★【人填】主键 —— 新客户的编码由人在录入卡上现场给,AI 不许自编
|
||||
2. `coverage_score` ★ 必填 —— 本活动的核心产出:关系覆盖度,由工具重算后写回
|
||||
3. `decision_chain` ★ 必填 —— 决策链地图(JSON),覆盖缺口从它算出来
|
||||
4. `name` ★【人填】必填 —— 无名档案进不了正式表(2026-08-24 立的墙)
|
||||
5. `cust_type` ★【人填】建议填 —— 客户类型
|
||||
6. `interaction_timeline` ★ 建议填 —— 互动时间线(JSON),每次整个数组重写
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `customer_code` ★ | 客户编码 | text | 主键 | **人(录入卡)** / 既有客户沿用 | 主数据主键,编错一次后面每一单都跟着错 |
|
||||
| `name` ★ | 客户名称 | text | 必填 | **人(录入卡 / 件上)** | |
|
||||
| `industry` | 所属行业 | text | 建议填 | **人(录入卡 / 件上)** / AI 推 | 推不出就留空给人补,不许编 |
|
||||
| `region` | 区域 | text | 建议填 | **人(录入卡 / 件上)** / AI 推 | 同上 |
|
||||
| `cust_type` ★ | 客户类型 | text | 建议填 | **人(录入卡 / 件上)** | 存中文枚举字面值,不带取值域 |
|
||||
| `coverage_score` ★ | 关系覆盖度 | integer | 必填 | 工具 `recompute-score` → AI | |
|
||||
| `decision_chain` ★ | 决策链地图 | json | 必填 | AI | `{nodes:[{node,btLevel,required,contactCode,covered}]}` |
|
||||
| `interaction_timeline` ★ | 互动时间线 | json | 建议填 | AI | `{entries:[{date,type,contactCode,note}]}`,追加式重写 |
|
||||
|
||||
### 1.5 `customer.contact` 客户联系人(L-08 · 共享主数据)
|
||||
|
||||
计划与回执都**引用**这张表:计划只能指向这里真实存在的人。
|
||||
候选客户查无联系人时,S3 先发**数据录入卡**把第一位关键人收进来(人填姓名/职务/决策链节点),
|
||||
编码由基座续号;放行前那一屏还可以再加一行(编码留空即可)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `contact_code` ★ 主键之一 · **自增** —— 计划/回执的引用目标;人不用填,基座按「客户编码-两位序号」续号
|
||||
2. `name` ★【人填】必填 —— 无名联系人进不了正式表
|
||||
3. `relationship` ★【人填】必填 —— 关系亲密度 1–5,缺口与排序分的入参
|
||||
4. `influence` ★【人填】必填 · 取值域 —— 影响力评级
|
||||
5. `key_person` ★【人填】必填 —— 关键人标记,覆盖度的分子
|
||||
6. `decision_node` ★【人填】建议填 —— 决策链节点(采购 / 技术 / 高层)
|
||||
7. `title` ★【人填】职务
|
||||
8. `customer_code` ★ 主键之一 —— 这位联系人属于哪家客户
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `customer_code` ★ | 客户编码 | text | 主键 | 承继 | |
|
||||
| `contact_code` ★ | 联系人编码 | text | 主键 · 自增 | **基座** | 声明里标了 `auto`,件上留空即续号 |
|
||||
| `name` ★ | 姓名 | text | 必填 | **人(录入卡 / 件上)** | |
|
||||
| `title` ★ | 职务 | text | — | **人(录入卡 / 件上)** | |
|
||||
| `email` | 邮箱 | text | 脱敏 | **人(录入卡)** | |
|
||||
| `role` | 角色 | text | 建议填 | AI | |
|
||||
| `decision_node` ★ | 决策链节点 | text | 建议填 | **人(录入卡 / 件上下拉)** | `bt-01` 采购 / `bt-02` 技术 / `bt-03` 高层 |
|
||||
| `influence` ★ | 影响力评级 | text | 必填 · 取值域 `high` / `medium` / `low` | **人(件上下拉)** / AI 初评 | |
|
||||
| `relationship` ★ | 关系亲密度(1-5) | integer | 必填 | **人(件上)** / AI 初评 | |
|
||||
| `comm_pref` | 沟通偏好 | text | 建议填 | AI | |
|
||||
| `key_person` ★ | 关键人标记 | boolean | 必填 | **人(件上下拉)** / AI | |
|
||||
| `change_log` | 变动记录 | text | — | AI | 入职 / 离职 / 调岗留痕 |
|
||||
|
||||
### 1.6 `crm_coverage.ledger_update` 台账更新明细
|
||||
|
||||
「这一签下去,哪几张台账的哪几格会变成什么」——一处字段一行,给签字的人核对用。
|
||||
放行时人可以点重做,AI 按意见改;改过的那一处**同位置覆盖写**,老行要撤掉,不许两条并存。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `update_key` ★ 必填 · **去重计数** —— 更新位置键(台账地址 · 客户 · 联系人 · 字段),同一处只许留一条
|
||||
2. `field_label` ★ 必填 —— 改的是哪一格(人读列名,不写机器列名)
|
||||
3. `after_value` ★ 必填 —— 更新后的值
|
||||
4. `before_value` ★ 必填 —— 更新前的值(无值写「(无)」)
|
||||
5. `evidence_ref` ★ 必填 —— 依据,用人话引用回执事实(不写编码前缀)
|
||||
6. `ledger_label` ★ 必填 —— 改的是哪张台账(人读名)
|
||||
7. `customer_code` ★ 必填 —— 落在哪家客户头上
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | |
|
||||
| `update_seq` | 本轮更新序号 | integer | 主键 | AI | |
|
||||
| `ledger_label` ★ | 台账(人读名) | text | 必填 | AI | 如「客户联系人」 |
|
||||
| `ledger_address` | 台账地址(机器地址) | text | 必填 | AI | 如 `customer.contact` |
|
||||
| `customer_code` ★ | 客户编码 | text | 必填 | AI | |
|
||||
| `contact_code` | 联系人编码 | text | 建议填 | AI | 空 = 客户级字段 |
|
||||
| `field_label` ★ | 字段(人读列名) | text | 必填 | AI | |
|
||||
| `before_value` ★ | 更新前 | text | 必填 | AI | 无值写「(无)」 |
|
||||
| `after_value` ★ | 更新后 | text | 必填 | AI | |
|
||||
| `evidence_ref` ★ | 依据 | text | 必填 | AI | 用人话引回执:「某某某·某某的回执:……」 |
|
||||
| `update_key` ★ | 更新位置键 | text | 必填 · 去重计数 | AI | `台账地址\|客户\|联系人\|字段` |
|
||||
|
||||
### 1.7 `crm_coverage.verdict` 本轮覆盖结论
|
||||
|
||||
一单一行的收口结论,同时挂着几面「人环走到哪儿了」的记账旗 —— 它们是下一步能不能开工的哨兵。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `scope_confirmed_total` ★ —— 覆盖范围已确认(人确认过才写),制定计划那一步的入场哨兵
|
||||
2. `plan_approved_total` ★ —— 经营计划已审批通过,派执行回执单的入场哨兵
|
||||
3. `ledger_update_confirmed_total` ★ —— 台账更新已收口
|
||||
4. `summary` ★ —— 本轮覆盖结论(摆在归档件上的那段话)
|
||||
5. `covered_customer_total` ★ —— 本轮覆盖客户数
|
||||
6. `key_person_covered_total` ★ —— 本轮覆盖关键人数
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 覆盖轮次编号 | text | 主键 | AI | |
|
||||
| `trigger_label` | 触发方式(人读标签) | text | — | AI | 写下之前那一格真空着,不摆入参英文码 |
|
||||
| `event_type` | 事件类型 | text | — | AI | 月度例行留空 |
|
||||
| `note` | 本轮说明 | text | — | AI | |
|
||||
| `scope_confirmed_total` ★ | 覆盖范围已确认 | integer | — | AI | 人环凭据 |
|
||||
| `plan_approved_total` ★ | 经营计划已审批通过 | integer | — | AI | 人环凭据 |
|
||||
| `ledger_update_confirmed_total` ★ | 台账更新已收口 | integer | — | AI | |
|
||||
| `covered_customer_total` ★ | 本轮覆盖客户数 | integer | — | AI | |
|
||||
| `key_person_covered_total` ★ | 本轮覆盖关键人数 | integer | — | AI | |
|
||||
| `summary` ★ | 本轮覆盖结论 | text | — | AI | |
|
||||
|
||||
> 注:执行回执那一步**没有**记账旗 —— 回执四列全是人填的,门直接读基座数
|
||||
> (回执有几行、缺不缺必填),被叫的那一方不给自己记账。
|
||||
|
||||
---
|
||||
|
||||
## 二、I01 品牌形象与销售工具包管理
|
||||
|
||||
五步:需求识别 → **需求受理与信息补充(人)** → 采集与创作 → **初稿审核(人)** →
|
||||
拟发布 → **发布入库放行(人)**。人填的事实集中在第一道人环;后两道是审核与签字。
|
||||
|
||||
### 2.1 `content_brand.requirement` 内容需求清单
|
||||
|
||||
本轮要生产哪几份内容。AI 出候选基线,**人在「需求受理与信息补充」那一屏增删改** ——
|
||||
加一行 = 补充新需求,删一行 = 本轮不做,改格子 = 修正 AI 识别的内容。
|
||||
序号留空由基座续号(人加行时不用自己编号)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `content_type` ★【人填】必填 —— 要产出什么(公司介绍 / 能力声明 / 销售工具包 / 案例集 / 白皮书…)
|
||||
2. `scene` ★【人填】建议填 —— 适用场景,S3 会照抄进品牌内容库那一行
|
||||
3. `target_customer` ★【人填】建议填 —— 目标客户(定向版本从它来)
|
||||
4. `source` ★【人填】建议填 —— 需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)
|
||||
5. `req_seq` ★【人填/基座】主键之一 —— 人加行时留空,基座按现有最大序号 +1 续号
|
||||
6. `priority` ★ 取值域 —— 优先级,件上是只读徽标
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 案号 | text | 主键 | AI | |
|
||||
| `req_seq` ★ | 本轮需求序号 | integer | 主键 | AI / **基座续号** | 件上加行留空即续号 |
|
||||
| `content_type` ★ | 内容类型 | text | 必填 | AI / **人(件上)** | |
|
||||
| `source` ★ | 需求来源 | text | 建议填 | AI / **人(件上)** | |
|
||||
| `target_customer` ★ | 目标客户 | text | 建议填 | AI / **人(件上)** | |
|
||||
| `scene` ★ | 适用场景 | text | 建议填 | AI / **人(件上)** | 存人读标签(首触 / 方案 / 攻坚 / 展会 / 报价),不存 ASCII 码 |
|
||||
| `priority` ★ | 优先级 | text | 取值域 `high` / `medium` / `low` | AI | 件上只读徽标 |
|
||||
| `origin` | 来源 | text | — | AI | `ai` / `human-added` |
|
||||
|
||||
### 2.2 `content.asset` 品牌内容库(L-05 · 主数据,I01 是唯一读写者)
|
||||
|
||||
**一行 = 一份资料文件**。正文只写一份 Markdown,基座按格式列现造成真字节
|
||||
(.docx 用 Word 打得开、.xlsx 用 Excel 打得开),件上点资料名就能打开渲染、下载原件;
|
||||
放行那一刻还会把每一份**冻进存档**,归档件里点开的永远是签字那一刻的那一份。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `content_id` ★ 主键 —— 资料的身份;件上的资料名链接、存档、下载都按它认
|
||||
2. `title` ★ 必填 —— 内容标题,同时就是资料文件名
|
||||
3. `body_md` ★ 必填 —— 内容正文(Markdown 原文),**文件的字节从它现造**
|
||||
4. `file_format` ★ 必填 · 取值域 —— 决定造出哪种文件:表格用 `xlsx`、正式文档用 `docx`、网页用 `html`、短要点用 `md`
|
||||
5. `content_status` ★ 必填 · 取值域 —— 草稿 → 已审核 → 已发布,不许跳步
|
||||
6. `scene` ★ 建议填 · **发布时必填** —— 适用场景,状态一到已发布就三处同源地拦
|
||||
7. `bt_tag` ★ 建议填 · **发布时必填** —— BT 层级标签
|
||||
8. `content_type` ★ 必填 —— 内容类型
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `content_id` ★ | 内容ID | text | 主键 | AI | |
|
||||
| `content_type` ★ | 内容类型 | text | 必填 | AI | 抄需求清单那一行 |
|
||||
| `title` ★ | 内容标题 | text | 必填 | AI | 资料文件的标题与文件名 |
|
||||
| `language` | 语言版本 | text | 必填 | AI | 多语言用「 · 」连接 |
|
||||
| `version` | 版本号 | text | 必填 | AI | 审核通过后升版 |
|
||||
| `scene` ★ | 适用场景 | text | 建议填 · 发布时必填 | AI | 状态 = 已发布 时才拦 |
|
||||
| `bt_tag` ★ | BT 层级标签 | text | 建议填 · 发布时必填 | AI | 存人读值(`BT-01`/`BT-02`/`BT-03`) |
|
||||
| `content_status` ★ | 内容状态 | text | 必填 · 取值域 `draft` / `reviewed` / `published` / `deprecated` | AI | 人在决策卡上审核,AI 据此改状态 |
|
||||
| `brand_tone` | 品牌调性标签 | text | 建议填 | AI | |
|
||||
| `target_customer` | 定向客户版本 | text | — | AI | |
|
||||
| `body_md` ★ | 内容正文(Markdown 原文) | text | 必填 | AI | 资料文件的唯一正文来源 |
|
||||
| `file_format` ★ | 资料文件格式 | text | 必填 · 取值域 `md` / `html` / `docx` / `xlsx` | AI | 按内容形态挑 |
|
||||
| `file_ref` | 文件位置 | text | — | AI | |
|
||||
| `reviewer` | 审核人 | text | — | AI | 审核通过后补一句 |
|
||||
|
||||
> 这张表声明了 `file` 段(标题取 `title`、正文取 `body_md`、格式取 `file_format`)——
|
||||
> 那一段同时是**授权面**:不声明它,件上的资料名点不开。
|
||||
|
||||
### 2.3 `market.signal` 宏观市场台账(L-01 · 主数据,I01 **只读**)
|
||||
|
||||
创作那一步的市场依据从这儿取。I01 一个字都不写它,读不到相关信号时如实说读不到,不许编造。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `signal_id` ★ 主键 —— 引用信号时点它
|
||||
2. `title` ★ —— 信号标题,创作时的依据摘要从它来
|
||||
3. `content` ★ —— 详情正文
|
||||
4. `signal_type` ★ 取值域 —— 宏观经济 / 行业趋势 / 竞争 / 政策 / 客户市场
|
||||
5. `importance` ★ —— 重要性评分 1–5,挑哪几条先看
|
||||
6. `impact_dir` ★ 取值域 —— 机会 / 威胁 / 中性
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `signal_id` ★ | 信号ID | text | 主键 | 外部 / 其它活动 | I01 只读 |
|
||||
| `signal_type` ★ | 信号类型 | text | 取值域 `macro_economy` / `industry_trend` / `competition` / `policy` / `customer_market` | — | |
|
||||
| `title` ★ | 标题 | text | — | — | |
|
||||
| `content` ★ | 详情 | text | — | — | |
|
||||
| `source` | 信号来源 | text | — | — | |
|
||||
| `signal_date` | 信号日期 | date | — | — | |
|
||||
| `market_region` | 所属市场 | text | — | — | |
|
||||
| `industry` | 细分行业 | text | — | — | |
|
||||
| `chip_category` | 关联芯片品类 | text | — | — | |
|
||||
| `importance` ★ | 重要性评分(1-5) | integer | — | — | |
|
||||
| `impact_dir` ★ | 影响方向 | text | 取值域 `opportunity` / `threat` / `neutral` | — | |
|
||||
| `signal_status` | 信号状态 | text | 取值域 `pending` / `evaluated` / `archived` / `expired` | — | |
|
||||
| `version` | 版本号 | text | — | — | |
|
||||
| `review_note` | 复盘备注 | text | — | — | |
|
||||
|
||||
### 2.4 `content_brand.verdict` 本轮内容生产结论
|
||||
|
||||
一单一行的收口结论,同样挂着人环记账旗。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `requirement_confirmed_total` ★ —— 需求已确认,采集创作那一步的入场哨兵
|
||||
2. `draft_approved_total` ★ —— 初稿已审核通过,拟发布那一步的入场哨兵
|
||||
3. `publish_prepared_total` ★ —— 发布准备已收口
|
||||
4. `content_produced_total` ★ —— 本轮产出内容份数
|
||||
5. `summary` ★ —— 本轮生产结论
|
||||
6. `trigger` ★ —— 触发方式(人读标签)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `round_ref` | 案号 | text | 主键 | AI | |
|
||||
| `trigger` ★ | 触发方式(人读标签) | text | — | AI | |
|
||||
| `trigger_detail` | 触发细节(人读标签) | text | — | AI | |
|
||||
| `target_customer` | 定向客户 | text | — | AI | |
|
||||
| `note` | 本轮说明 | text | — | AI | |
|
||||
| `requirement_confirmed_total` ★ | 需求已确认 | integer | — | AI | 人环凭据 |
|
||||
| `draft_approved_total` ★ | 初稿已审核通过 | integer | — | AI | 人环凭据 |
|
||||
| `publish_prepared_total` ★ | 发布准备已收口 | integer | — | AI | |
|
||||
| `content_produced_total` ★ | 本轮产出内容份数 | integer | — | AI | |
|
||||
| `summary` ★ | 本轮生产结论 | text | — | AI | |
|
||||
|
||||
---
|
||||
|
||||
## 三、人填字段总表(按人办事的那一屏)
|
||||
|
||||
| 活动 · 屏 | 卡的形式 | 落进哪张表 | 人亲手填的列 |
|
||||
|---|---|---|---|
|
||||
| E04 · 覆盖范围确认 | 执行工单(件上编辑 + 增删行) | `crm_coverage.candidate` | `customer_code` · `customer_name` · `cust_type` · `bt_level` · `depth` · `basis` |
|
||||
| E04 · 新客户建档(仅新客户) | 数据录入卡(一卡两段) | `customer.profile` + `customer.contact` | 客户段:`customer_code` · `name` · `cust_type` · `industry` · `region`;联系人段:`name` · `title` · `email` |
|
||||
| E04 · 新纳入客户补关键人 | 数据录入卡 | `customer.contact` | `name` · `title` · `decision_node`(编码基座续号) |
|
||||
| E04 · 经营计划审批 | 决策卡(通过 / 重做 + 意见) | 间接改 `crm_coverage.plan` | 意见文本(AI 据此改 `action` / `rationale` / `target_date`) |
|
||||
| E04 · 执行经营动作与回执 | 执行工单(件上编辑) | `crm_coverage.receipt` | `completion_status` · `result_summary` · `customer_feedback` · `key_person_change` |
|
||||
| E04 · 台账更新确认(放行前) | 验收工单(落库预览可改 · 联系人可加行) | `customer.profile` · `customer.contact` | 档案:`name` · `cust_type` · `industry` · `region`;联系人:`name` · `title` · `decision_node` · `influence` · `relationship` · `key_person` |
|
||||
| I01 · 需求受理与信息补充 | 执行工单(件上编辑 + 增删行) | `content_brand.requirement` | `content_type` · `source` · `target_customer` · `scene`(`req_seq` 留空续号) |
|
||||
| I01 · 初稿审核 | 决策卡(通过 / 驳回 + 意见) | 间接改 `content.asset` | 意见文本(AI 据此改稿并升状态) |
|
||||
| I01 · 发布入库放行 | 验收工单(签字) | `content.asset` 合并进正式表 | 裁决与意见 |
|
||||
|
||||
---
|
||||
|
||||
## 四、在数据域里看这些表时的三条提醒
|
||||
|
||||
1. **主数据是共享的,物理表里还有别的活动留下的列。**
|
||||
`customer.profile` 物理上还有 `grade` / `owner` / `payment_terms` / `credit_limit` /
|
||||
`profile_tags` / `status`,`customer.contact` 还有 `phone` —— 那是报价链 A1 早先声明的,
|
||||
E04 不写它们,本文也不列。记录浏览里看到它们空着是正常的。
|
||||
|
||||
2. **台账只增列不删列:退役的列还在。**
|
||||
`crm_coverage.plan.status`、`crm_coverage.verdict` 的 `trigger` / `event_note` /
|
||||
`receipt_confirmed_total` 都已退役,当前版本一个字都不写(为什么退役见清单里的墓志铭注释)。
|
||||
判断一列还算不算数,以**活动清单的声明**为准,不以物理表结构为准。
|
||||
|
||||
3. **试跑单默认也落真库。**
|
||||
测试台起单默认 `land_master = true` —— 跑一遍活动,主数据真的会变
|
||||
(新客户、新联系人、覆盖度都会落进正式表)。要它不落,起单时显式关掉
|
||||
(端到端脚本就是这么做的:只能测一次的样例归脚本自己管)。
|
||||
|
||||
---
|
||||
|
||||
*字段口径以 `activities/crm-relationship-coverage/src/manifest.ts` 与
|
||||
`activities/brand-content-toolkit/src/manifest.ts` 的 `declarations.ledger` 为准;
|
||||
业务设计源见 `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`
|
||||
与 `docs/SS01-客户与市场/E04I01-Demo-台账设计/*.xlsx`(L-01 / L-02 / L-05 / L-08)。*
|
||||
+4
-1
@@ -44,6 +44,7 @@
|
||||
| 台账自生长 | node-postgres(`pg`)直写(ledger adapter 内) | 运行时 DDL/DML 由声明生成,ORM 静态 schema 不适用 |
|
||||
| 队列/定时 | pg-boss | PG 原生,先持久后投递、至少一次天然契合 |
|
||||
| 代码契约校验 | zod | 仓库内 API/DTO |
|
||||
| 鉴权 | Keycloak(统一鉴权中心)+ `jose` 本地验签 | 资源服务器:RS256 / iss / aud 本地验,不回源;权限只到端(ADR-012) |
|
||||
| 声明数据校验 | ajv(JSON Schema 2020-12 子集) | 活动声明是运行时数据,不是编译期代码(ADR-003) |
|
||||
| 测试 | Vitest;集成测试用 Testcontainers(PG) | domain 层测试零 mock |
|
||||
| 门禁 | tsc / ESLint(flat) / dependency-cruiser / 契约快照 | §11 |
|
||||
@@ -132,6 +133,7 @@ repo/
|
||||
| L1 | `org` | 员工与岗位:账号、岗位授权(客体三类×动作三种)、停用前置检查 | OS 02 |
|
||||
| L1 | `llm` | LLM 实例:自定义模型名 → 四元绑定(Provider 枚举/URL/API-Key/Model),ModelGateway 含录制/回放缝 | OS 04/11 |
|
||||
| L1 | `ledger` | 数据与台账:三级寻址、通用事务接口、schema 自生长(最大化合并/最宽类型/只增不破坏)、任务级草稿层与任务视图、写三分 | OS 04/09 |
|
||||
| L1 | `mail` | 邮箱账号与收发:两条接入路(IMAP/SMTP · 企业微信邮件 API)的适配、轮询拉取与入站留痕、订阅分发、出站留痕;凭证密封、跨进程租约 | ADR-013 |
|
||||
| L2 | `memory` | 任务记忆(会话历史+窗口配置)、活动记忆四层存取、任务日志两级投影、蒸馏钩子执行 | OS 08 |
|
||||
| L2 | `toolbox` | 功能工具注册与两审、调用契约、效应等级、连接配置声明与凭证绑定、调用留痕、纯工具作用域 | OS 07 |
|
||||
| L2 | `channel` | 通道实例:入站归一(解析→系统写→数据事件)、出站发送 | OS 07 |
|
||||
@@ -154,7 +156,7 @@ repo/
|
||||
- 技能编排属于活动(AM:活动内容物含技能清单)——`skill-engine` 与 loop/flow 同列范式运行器;置于 L4 因两范式依赖它(临场装配/步引用),import 恒向下、免 port。
|
||||
- 找不到归属的代码,先怀疑模块拆分错了,再考虑 `shared/`;`shared/` 只收无业务语义的纯工具
|
||||
- 度量八指标(OS 8.3)是 `memory`(任务日志)与 `task`(终态/纠正信号)之上的**只读投影**,落 `report` 的用例,不设独立模块
|
||||
- 鉴权定调:单组织 cookie session——登录、会话与岗位权限校验中间件在 apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制
|
||||
- 鉴权定调(ADR-012):统一鉴权中心(Keycloak)资源服务器——后端只验 Bearer JWT;鉴权中间件在 apps/api 的 app 层实现,身份判定读 `org` 出口;**权限只到端**(设计中心 / 运营中心 / 工作台),角色→端由运营中心的表定,路由归属写在契约的 `access` 上;不做多租户
|
||||
|
||||
---
|
||||
|
||||
@@ -640,6 +642,7 @@ pnpm test
|
||||
- **ADR-004** 活动 .JS 装载 ABI:单文件 ESM,默认导出 `defineActivity()`;能力全部注入(基座六件句柄),零环境能力;静态审扫描产物 import 与全局引用
|
||||
- **ADR-005** 工具 `validate` 能力位:调用契约 `capabilities.validate?: true`,预检存在即调(OS 7.3 落地预检第四项)
|
||||
- **ADR-006** S-FEEL 自研于 `@sfeel`,子集文法冻结于 OS 03;不引第三方 FEEL 引擎
|
||||
- **ADR-012** 统一鉴权中心(Keycloak):资源服务器本地验签(`jose`),`Staff.id` = `sub`;权限只到端,`sys.role_access` 可改;名册 Admin API 同步 + 首登即建;60 秒票据给 SSE 与下载;口令 / 会话 / 岗位授权退役(取代 ADR-010)
|
||||
|
||||
留白(实现期再定,各建 ADR 后方可动工):鉴权会话与凭证加密方案;JSON Schema→zod 转换库选型;活动装载的 worker 隔离硬化;活动记忆向量召回启停;e2e 框架。
|
||||
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# 部署
|
||||
|
||||
> CD 由 `.gitea/workflows/ci.yml` 的后半段做:推主干 → 五道门禁全绿 → 三份构建 → 发到 runner 这台机上。
|
||||
> 这份文档写的是**机器那一侧**:一次性要准备什么、nginx 怎么配、出事怎么回滚、什么命令绝对不能在生产上跑。
|
||||
|
||||
## 这套东西部署起来长什么样
|
||||
|
||||
```
|
||||
浏览器 ──HTTPS──► nginx ┬── / → $WEB_ROOT(apps/web/dist 的静态文件,SPA)
|
||||
└── /api/* → 127.0.0.1:8787(pm2 托的 node 进程)
|
||||
/api/stream 是一条 SSE 长连接,单独配
|
||||
|
||||
$DEPLOY_DIR/
|
||||
releases/<commit sha>/ 一次发布的完整目录(产物 + db/ + 自己的 node_modules)
|
||||
current -> releases/<sha> **只是给人看的指针**,pm2 不经它启动(见下面「为什么不用软链启动」)
|
||||
data/attachments/ 来信原件。**唯一一份不在数据库里的持久数据,定了不能改**
|
||||
backups/<时刻>-<sha>.sql.gz 每次部署在迁移之前 pg_dump 一份,留最近 10 份
|
||||
.secret-fingerprint JCAI_SECRET_KEY 的指纹,换了钥匙下次部署当场红
|
||||
```
|
||||
|
||||
治理台上配的一切(模型实例连密钥、工具连接凭证、白名单、活动包连代码产物、流程包、触发器、
|
||||
员工、台账)**全在数据库里**,换代码重启不丢。所以「发布」这件事只换三样:node 产物、静态文件、库结构。
|
||||
|
||||
## 一次性准备
|
||||
|
||||
```bash
|
||||
# ① 两个目录,runner 用户要写得动
|
||||
sudo mkdir -p /opt/jcai /var/www/jcai
|
||||
sudo chown -R "$(whoami)" /opt/jcai /var/www/jcai # 换成 runner 跑的那个用户
|
||||
|
||||
# ② 确认 runner 用户的非交互 shell 里有这两个命令
|
||||
# (pm2 常装在 nvm/fnm 的 shim 里,而非交互 shell 读不到 ~/.zshrc —— 那会让部署红在前置断言)
|
||||
ssh <runner用户>@<主机> 'command -v pm2 rsync'
|
||||
|
||||
# ③ 生成密钥。**生成一次,永久不变** —— 换了它,库里所有凭证密文全部解不开,
|
||||
# 而且不在启动时炸,是第一次真调模型才炸。
|
||||
openssl rand -base64 32
|
||||
|
||||
# ④ 备份手段二选一:主机上装 postgresql-client(有 pg_dump),
|
||||
# 或者库在容器里时把容器名填进 Variables 的 PG_CONTAINER
|
||||
```
|
||||
|
||||
**不需要开机自启**(按你的口径)。要的话:`pm2 startup` 生成 systemd 单元(那一步要 sudo)+ `pm2 save`。
|
||||
|
||||
## Gitea 上要填的
|
||||
|
||||
**仓库设置 → Actions → Variables**(不敏感,可随时改)
|
||||
|
||||
| 名 | 例 | 说明 |
|
||||
|---|---|---|
|
||||
| `DEPLOY_DIR` | `/opt/jcai` | 发布根目录 |
|
||||
| `PM2_APP` | `jcai-api` | pm2 里的应用名 |
|
||||
| `WEB_ROOT` | `/var/www/jcai` | nginx 的静态根 |
|
||||
| `DEPLOY_PORT` | `8787` | 不填就是 8787 |
|
||||
| `APP_NODE_ENV` | `production` | 不填就是 production。**没有 HTTPS 时必须置空**,见下 |
|
||||
| `PG_CONTAINER` | `jcai-pg` | 只在主机没有 `pg_dump`、库跑在容器里时才要 |
|
||||
|
||||
**Secrets**
|
||||
|
||||
| 名 | 说明 |
|
||||
|---|---|
|
||||
| `PROD_DATABASE_URL` | 指向 `jcai_prod`。首次部署要建库,账号需 CREATEDB;也可以你先手建好 |
|
||||
| `JCAI_SECRET_KEY` | `openssl rand -base64 32`。**永久不变** |
|
||||
| `JCAI_SEED_PASSWORD` | 首次播种时给五个员工与 AI 账号设的登录口令 |
|
||||
| `JCAI_DEEPSEEK_API_KEY` | 模型密钥。没有它,模型实例会建成「未绑定」,而案例活动每一拍都要 `agent.decide` —— 一步都跑不动 |
|
||||
|
||||
## nginx
|
||||
|
||||
放 `/etc/nginx/conf.d/jcai.conf`(或 `sites-available` + 软链),改掉 `server_name` 与证书路径:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name jcai.example.internal;
|
||||
# 内网也要 HTTPS:后端在 production 下给登录 cookie 加 Secure,
|
||||
# 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效(见排障表第一行)
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on; # nginx < 1.25 写成:listen 443 ssl http2;
|
||||
server_name jcai.example.internal;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/jcai.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/jcai.key;
|
||||
|
||||
root /var/www/jcai; # = WEB_ROOT
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 16m; # 上传走 base64,接口自己封顶 8MB
|
||||
|
||||
# 带指纹的资源可长缓存
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SSE —— 整套界面的实时刷新全靠这一条长连接。
|
||||
# nginx 默认会缓冲响应,缓冲之后这条流就整个不动了(卡片要手动刷新才变)。
|
||||
location = /api/stream {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
chunked_transfer_encoding off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# 三个台面是同一个 SPA:/(工作台)· /design(设计中心)· /ops(运营中心),
|
||||
# 外加 /gov(旧链,前端自己跳 /ops)。刷新任意子路径都要吐 index.html。
|
||||
# 前端的 vite base 没配,所以资源引用是绝对路径 —— **必须挂在域名根,不能挂子路径**。
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`sudo nginx -t && sudo nginx -s reload`。
|
||||
|
||||
## 部署时都发生了什么
|
||||
|
||||
前置断言 → 备份 → 确保库在 → 发布产物 → 迁移 → 迁移到位断言 → 判首次/升级 →
|
||||
(首次播种 | 装活动包) → 活动包回查 → 发前端 → 重启 pm2 → 探活 → 收尾。
|
||||
|
||||
几处口径值得知道:
|
||||
|
||||
- **备份在换代码之前**,而且备不成就红。迁移只有 up 没有 down,dump 是唯一的回滚手段。
|
||||
- **`db:seed` 只在首次(空库)跑一次**。升级时跑它会把治理台配好的东西打回播种值:
|
||||
工具的连接端点变回 `replay://crm`、工具白名单被全量替换、模型实例的 baseUrl/model 被覆盖。
|
||||
- **活动包只在版本变了才装**。版本没升但产物内容变了会**当场红** —— 因为安装恒新增,
|
||||
同版本重装是 no-op,库里还是旧产物,而日志会照样说「已装」、退出码 0(CLAUDE.md 铁规矩 7)。
|
||||
- **装完回查库**:`install-activities` 的所有失败路径都只打一行日志然后 continue,**永远 exit 0**。
|
||||
- **探活验 body 不验状态码**:`/api/health` 在库挂掉时照样返 200,只是 body 里 `db:"down"`。
|
||||
|
||||
### 为什么 pm2 不用 `current` 软链启动
|
||||
|
||||
`apps/api/src/app/main.ts` 结尾的入口守卫是字符串比较:
|
||||
|
||||
```ts
|
||||
if (import.meta.url === `file://${process.argv[1]}`) { await boot(); }
|
||||
```
|
||||
|
||||
Node 解析主模块时会 realpath,而 `process.argv[1]` 保留你传进去的原样。
|
||||
所以经软链启动时两边不等,`boot()` 永不执行 —— **进程零输出、退出码 0,看着像"跑完了"**(实测过)。
|
||||
CD 因此一律用 `releases/<sha>` 的真实路径起;`current` 只是给人看的指针。
|
||||
|
||||
## 回滚
|
||||
|
||||
```bash
|
||||
ls -1t /opt/jcai/releases # 看有哪些版本
|
||||
PREV=/opt/jcai/releases/<上一版的 sha>
|
||||
|
||||
pm2 delete jcai-api
|
||||
NODE_ENV=production PORT=8787 \
|
||||
DATABASE_URL='<PROD_DATABASE_URL>' \
|
||||
JCAI_SECRET_KEY='<同一把钥匙>' \
|
||||
JCAI_SEED_PASSWORD='<...>' \
|
||||
JCAI_ATTACHMENT_ROOT=/opt/jcai/data/attachments \
|
||||
pm2 start "$PREV/apps/api/dist/main.js" --name jcai-api --cwd "$PREV" --update-env
|
||||
|
||||
ln -sfn "$PREV" /opt/jcai/current
|
||||
rsync -a --delete "$PREV/apps/web/dist/" /var/www/jcai/ # 前端也要跟着退
|
||||
```
|
||||
|
||||
**数据库不跟着回滚** —— 迁移没有 down。要连库一起退,只能拿 `backups/` 里那份 dump 恢复:
|
||||
|
||||
```bash
|
||||
gzip -dc /opt/jcai/backups/<时刻>-<sha>.sql.gz | psql '<PROD_DATABASE_URL>'
|
||||
```
|
||||
|
||||
## 生产上绝对不能跑的
|
||||
|
||||
| 命令 | 后果 |
|
||||
|---|---|
|
||||
| `pnpm --filter @app/api reset-runs` | TRUNCATE 33 张 `sys` 表 + `ledger` 下几乎所有表(还带 cascade):所有任务、工作流、卡、事件、trace、上传件、沟通记录、产出的资料、台账单据全没,**不可恢复** |
|
||||
| `pnpm db:seed` | 重写整套治理台配置(见上) |
|
||||
| 改 `JCAI_SECRET_KEY` | 库里所有凭证密文全部解不开,且要等第一次真调模型才炸 |
|
||||
| 改 `JCAI_ATTACHMENT_ROOT` | 历史来信原件的指针全部指空(存进库的是 `file://绝对路径`) |
|
||||
|
||||
## 排障速查
|
||||
|
||||
| 症状 | 多半是 |
|
||||
|---|---|
|
||||
| 页面开得了,一登录就弹回登录页,**前后端日志全绿** | HTTP 下的 `Secure` cookie:`POST /api/login` 返 200、服务端会话也真写了,但浏览器直接丢掉 cookie。上 HTTPS,或把 Variables 的 `APP_NODE_ENV` 置空(代价:凭证退到开发派生密钥加密) |
|
||||
| pm2 说 online,但端口没人听、日志一行都没有 | 入口守卫:启动路径经过了软链。用 `releases/<sha>` 的真实路径起 |
|
||||
| 部署全绿,但系统里一个活动都没有 | `install-activities` 永远 exit 0。看「部署 · 活动包回查」那一步 —— 它才说得出实情 |
|
||||
| 界面不实时刷新,要手动刷新才变 | nginx 把 `/api/stream` 缓冲了。照上面 SSE 那段配 |
|
||||
| 迁移「完成」了但新表没有 | drizzle 只比最后一条的时间戳当水位线,不逐条比 hash。新迁移在 `db/migrations/meta/_journal.json` 里的 `when` 若 ≤ 水位线会被永久静默跳过。「部署 · 迁移到位断言」就是拦这个的 |
|
||||
| 第一次真调模型才炸「解不开」 | `JCAI_SECRET_KEY` 换过了 |
|
||||
| 换版之后浏览器还是旧界面 | `index.html` 被缓存了。照上面 `location /` 那段配 `no-store` |
|
||||
Reference in New Issue
Block a user