diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a9c36d3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,320 @@ +# JCAI-WOS CI —— 五道门禁 + 从空库起的集成测试 + 生产构建 +# +# 只在主干跑:push 到 mvp-foundation(PR 合并产生的那一推同样算)。 +# runner 是 host 模式,label `ubuntu-latest`;宿主机自带 node22 / pnpm10 / docker。 +# 门禁一律以退出码验(CLAUDE.md 铁规矩 1),不以 echo 验。 +name: CI + +on: + push: + branches: [mvp-foundation] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + # 国内镜像源:pnpm 认 npm 的 env 形式 + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + COREPACK_NPM_REGISTRY: https://registry.npmmirror.com + # NODE_ENV 一律不设 production —— secret-box.ts 在 production 下拿不到 + # JCAI_SECRET_KEY 会直接抛;CI 要的是 dev 兜底那条路。 + +jobs: + ci: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + # ── 取代码:纯 git,一个第三方 action 都不依赖 ───────────────────── + - name: 取代码 + env: + T1: ${{ secrets.GITEA_TOKEN }} + T2: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cd "$GITHUB_WORKSPACE" + git init -q . + git config --global --add safe.directory "$GITHUB_WORKSPACE" + URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + git remote set-url origin "$URL" 2>/dev/null || git remote add origin "$URL" + + TOKEN="${T1:-${T2:-}}" + HEADER="http.${GITHUB_SERVER_URL}/.extraheader" + if [ -n "$TOKEN" ]; then + AUTH="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')" + git config --local "$HEADER" "Authorization: basic $AUTH" + fi + + fetch() { git fetch --depth=1 --no-tags origin "+${1}:refs/ci/head"; } + + # 两档退让:先按 commit(服务端未必允许按 SHA 取),再按分支 ref + if ! fetch "$GITHUB_SHA" && ! fetch "$GITHUB_REF"; then + # 换一种带凭据的写法再试一遍(extraheader 与 token 进 URL,Gitea 各版本认的不一样) + if [ -z "$TOKEN" ]; then + echo '取代码失败:没有可用 token,匿名也拉不下来'; exit 1 + fi + git config --local --unset-all "$HEADER" || true + SCHEME="${GITHUB_SERVER_URL%%://*}" + HOST="${GITHUB_SERVER_URL#*://}" + git remote set-url origin "${SCHEME}://${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git" + fetch "$GITHUB_SHA" || fetch "$GITHUB_REF" + fi + + git checkout -qf refs/ci/head + # 留着 node_modules(host 模式工作区跨次复用,留着就等于一份缓存), + # 其余未跟踪物一律清掉 —— 上一跑的 dist/.build/.api-tmp 不许影响这一跑 + git clean -ffdx -e node_modules + echo "HEAD=$(git rev-parse HEAD) 期望=${GITHUB_SHA}" + + - name: 工具链断言 + run: | + set -euo pipefail + node -v + pnpm -v + node -e 'const [a, b] = process.versions.node.split(".").map(Number); + if (a < 22 || (a === 22 && b < 6)) { + console.error("需要 Node >= 22.6 —— gen:tiers 用 --experimental-strip-types"); + process.exit(1); + }' + case "$(pnpm -v)" in + 10.*) ;; + *) echo "需要 pnpm 10.x(仓库钉的是 10.33.0)"; exit 1 ;; + esac + + - name: 装依赖 + run: pnpm install --frozen-lockfile + + # ── 五道门禁,逐条一步 ──────────────────────────────────────────── + - name: 门禁前置 · 生成层级表 + run: pnpm gen:tiers + + - name: 门禁 1/5 · 类型检查 + run: pnpm typecheck + + - name: 门禁 2/5 · Lint + run: pnpm lint + + - name: 门禁 3/5 · 依赖门禁 + run: pnpm depcruise + + - name: 门禁 4/5 · 契约快照 + run: pnpm api:check + + # api:check 自己那道 git 比对是**会失败开口**的(git 一抛错就印「非 git 仓库, + # 跳过比对」然后 exit 0)。这一步自己再 status 一遍,吞不掉; + # 顺带管住 gen:tiers 的入库产物 tools/depcruise-tiers.generated.cjs。 + # 位置在测试之前 —— 测试期的 seed 可能往 .data/ 落文件,那不算漂移。 + - name: 生成物无漂移 + run: | + set -euo pipefail + DIRTY="$(git status --porcelain)" + if [ -n "$DIRTY" ]; then + echo "$DIRTY" + echo '---' + git --no-pager diff --stat + echo '门禁跑完工作树有改动:入库的生成物与现生成的不一致。' + echo '本地跑 pnpm gates,把 api-report/ 与 tools/depcruise-tiers.generated.cjs 一并提交。' + exit 1 + fi + echo '工作树干净 ✔' + + # ── 库:一次性,跑完就拆 ────────────────────────────────────────── + - name: 建本次跑的一次性库 + id: db + env: + CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }} + run: node tools/ci.mjs db-create "jcai_ci_${GITHUB_RUN_ID}" + + - name: 从空库跑完整条迁移链 + run: pnpm db:migrate + + - name: 库真的连得上 + run: node tools/ci.mjs db-probe + + - name: 门禁 5/5 · 单元与集成测试 + run: | + set -euo pipefail + pnpm test --reporter=default --reporter=json \ + --outputFile.json="${RUNNER_TEMP:-/tmp}/vitest.json" + node tools/ci.mjs assert-it "${RUNNER_TEMP:-/tmp}/vitest.json" + + # 用 steps.db.outcome 而不是裸 always():装依赖就失败的那种跑法里 + # 根本没有库可拆,裸 always() 只会多吐一条看着吓人的假失败。 + - name: 拆掉一次性库 + if: ${{ always() && steps.db.outcome == 'success' }} + env: + CI_PG_ADMIN_URL: ${{ secrets.CI_PG_ADMIN_URL }} + run: node tools/ci.mjs db-drop "jcai_ci_${GITHUB_RUN_ID}" + + # ── 生产构建 ──────────────────────────────────────────────────── + - name: 生产构建 · api + run: pnpm --filter @app/api build + + - name: 生产构建 · web + run: pnpm --filter @app/web build + + - name: 生产构建 · 活动包 + run: pnpm activities:build + + # ══ CD ══════════════════════════════════════════════════════════ + # 门禁全绿之后才走到这儿(同一个 job,前面任一步红后面自然不跑)。 + # 机器相关的东西一律从 vars.* 读,不写死;机密从 secrets.* 读。 + - name: 部署 · 前置断言 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + PM2_APP: ${{ vars.PM2_APP }} + WEB_ROOT: ${{ vars.WEB_ROOT }} + PG_CONTAINER: ${{ vars.PG_CONTAINER }} + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} + JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }} + run: node tools/cd.mjs preflight + + # 迁移没有 down,备份是唯一的回滚手段 —— 所以它在换代码之前,且备不成就红 + - name: 部署 · 备份生产库 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + PG_CONTAINER: ${{ vars.PG_CONTAINER }} + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs backup + + - name: 部署 · 确保生产库在 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs db-ensure + + - name: 部署 · 发布产物 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + run: | + set -euo pipefail + REL="${DEPLOY_DIR}/releases/${GITHUB_SHA}" + rm -rf "$REL" + mkdir -p "$REL" + + # 产物 + db/(migrate.js 靠向上找 db/migrations,相对位置不能变)+ 根三件 + rsync -a --relative \ + ./apps/api/dist ./apps/api/package.json \ + ./apps/web/dist \ + ./db \ + ./package.json ./pnpm-lock.yaml ./pnpm-workspace.yaml \ + "$REL"/ + # 活动包产物(装包时要读;装完就不再需要) + rsync -a --relative ./activities/*/dist "$REL"/ + # 各 workspace 包的 package.json —— pnpm 认工作区要它们全在,少一个 lockfile 就对不上 + git ls-files '*/package.json' | grep -v '^spike/' > "$RUNNER_TEMP/pkgs.txt" + rsync -a --files-from="$RUNNER_TEMP/pkgs.txt" ./ "$REL"/ + + # 产物是 ESM 且 skipNodeModulesBundle —— 目标目录必须有自己的 node_modules + cd "$REL" + pnpm install --prod --frozen-lockfile --ignore-scripts + + printf 'sha=%s\nref=%s\nrun=%s\nat=%s\n' \ + "$GITHUB_SHA" "$GITHUB_REF_NAME" "$GITHUB_RUN_ID" "$(date -Iseconds)" > "$REL/RELEASE" + echo "RELEASE_DIR=$REL" >> "$GITHUB_ENV" + echo "发布目录:$REL" + + # 直接跑 dist 里的入口,不走 pnpm db:migrate —— 那条会先 tsup(clean:true), + # 在正跑着的服务脚下把 dist 清掉 + - name: 部署 · 迁移 + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: cd "$RELEASE_DIR" && node apps/api/dist/migrate.js + + - name: 部署 · 迁移到位断言 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs migrate-verify + + # 出两个旗:FIRST_DEPLOY / ACTIVITIES_INSTALL。 + # 版本没升但产物内容变了会在这里当场红(否则新代码静默不上线)。 + - name: 部署 · 判首次播种与活动包升级 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs activities-plan + + # 空库唯一该跑 seed 的时刻。升级时绝不跑 —— 它会把治理台配好的 + # 工具连接端点打回 replay://crm、重置白名单、重置模型实例的 baseUrl/model。 + - name: 部署 · 首次播种 + if: ${{ env.FIRST_DEPLOY == '1' }} + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} + JCAI_DEEPSEEK_API_KEY: ${{ secrets.JCAI_DEEPSEEK_API_KEY }} + run: | + set -euo pipefail + # 上面 if: 判错的危险方向是「升级时误跑 seed」—— 那会重写整套治理台配置。 + # 所以这里自己再确认一次:表达式判错也伤不到生产。 + [ "${FIRST_DEPLOY:-0}" = "1" ] || { echo "FIRST_DEPLOY 不是 1,不播种(这一步本不该执行到)"; exit 0; } + cd "$RELEASE_DIR" + JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/seed.js + + - name: 部署 · 装活动包 + if: ${{ env.FIRST_DEPLOY == '0' && env.ACTIVITIES_INSTALL == '1' }} + env: + DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + run: | + set -euo pipefail + # 同上:装包会按主键覆盖 8 行样例主数据并发出 data 事件,不该装的时候别装 + [ "${ACTIVITIES_INSTALL:-0}" = "1" ] || { echo "ACTIVITIES_INSTALL 不是 1,不装(这一步本不该执行到)"; exit 0; } + cd "$RELEASE_DIR" + JCAI_ACTIVITIES_DIR="$RELEASE_DIR/activities" node apps/api/dist/install-activities.js + + # install-activities 的所有失败路径都只打日志、永远 exit 0 —— 回查库才说得出实情 + - name: 部署 · 活动包回查 + env: + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + run: node tools/cd.mjs activities-verify + + - name: 部署 · 发前端 + env: + WEB_ROOT: ${{ vars.WEB_ROOT }} + run: | + set -euo pipefail + rsync -a --delete ./apps/web/dist/ "$WEB_ROOT"/ + echo "前端已发到 $WEB_ROOT($(find "$WEB_ROOT" -type f | wc -l | tr -d ' ') 个文件)" + + # 用 releases 的**真实路径**起,不经 current 软链: + # main.ts 的入口守卫拿 import.meta.url 与 process.argv[1] 做字符串比较, + # 经软链启动两边不等 → boot() 永不执行 → 进程零输出 exit 0(实测过)。 + # 换版本时脚本路径会变,所以是 delete+start 而不是 restart。 + - name: 部署 · 重启 pm2 + env: + PM2_APP: ${{ vars.PM2_APP }} + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + APP_NODE_ENV: ${{ vars.APP_NODE_ENV }} + DEPLOY_PORT: ${{ vars.DEPLOY_PORT }} + PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} + JCAI_SECRET_KEY: ${{ secrets.JCAI_SECRET_KEY }} + JCAI_SEED_PASSWORD: ${{ secrets.JCAI_SEED_PASSWORD }} + run: | + set -euo pipefail + # 有 HTTPS 才该设 production:production 会给登录 cookie 加 Secure, + # 而 Secure 的 cookie 浏览器在 HTTP 下根本不回传 —— 登录会静默失效。 + export NODE_ENV="${APP_NODE_ENV:-production}" + export PORT="${DEPLOY_PORT:-8787}" + export DATABASE_URL="$PROD_DATABASE_URL" + export JCAI_ATTACHMENT_ROOT="$DEPLOY_DIR/data/attachments" + pm2 delete "$PM2_APP" >/dev/null 2>&1 || true + pm2 start "$RELEASE_DIR/apps/api/dist/main.js" --name "$PM2_APP" --cwd "$RELEASE_DIR" --update-env + pm2 list + # 探活地址交给下一步,别让它去猜端口 —— 猜错会打到别人的服务上,还判成绿 + echo "HEALTH_URL=http://127.0.0.1:$PORT/api/health" >> "$GITHUB_ENV" + + # 库挂了 /api/health 照样返 200,只是 body 里 db:'down' —— 所以验 body + - name: 部署 · 探活 + run: node tools/cd.mjs health + + - name: 部署 · 收尾 + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR }} + run: | + set -euo pipefail + ln -sfn "$RELEASE_DIR" "$DEPLOY_DIR/current" + node tools/cd.mjs prune + echo '── 这一版发出去了 ──' + cat "$RELEASE_DIR/RELEASE" + ls -1t "$DEPLOY_DIR/backups" | head -1 | sed 's/^/最近一份备份:/' diff --git a/.gitignore b/.gitignore index 75f4da4..dee73b2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage/ # 浏览器走查的截图与日志(本地取证用,不入库) .playwright-mcp/ /*.png + +# SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库 +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index 73fde80..3730214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1294,6 +1294,37 @@ 的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路** (时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 08-23 灰底那课的第二次。 +- **工作成果里摆着别单的资料:「这一单产出的」被算成了「这张表里所有的行」**(2026-08-25 + 柴浩:「这个工作成果怎么有 4 个资料,这单的需求只会产出一个」):那一单只写了 + `CT-2026-0004` 一行(三笔留痕全指着它、放行合并 3 行),而工作成果里摆着四份 —— + 另外三份是**品牌内容库里历次跑出来的资产**。病根一处、抄了两遍:放行存档 + (`archiveAssetFiles`)与在办列单(`assetFilesOf`)取行都用 `taskView(where:{})` —— + 那是「正式版全表 + 本单草稿」的合并视图,而资料常落在**主数据**上(`content.asset`), + 整表里躺着别单放行过的行。台账契约本就把这两件事分得清清楚楚(`taskDrafts` 的注释: + 「引擎求值要区分『这一单的事实』与『别人放行后的事实』」),视图模版也一律绑 `mine:` + ——只有资料这两处忘了。修:抽一段 `assetRowsOf` 存档与列单同用,认**本单碰过的行** + (草稿键),值仍取叠加行(改过一格的行按落库后的样子造文件);`touched` 是必填入参 —— + 「哪几行是这一单的」每个调用方都得回答得出来。e2e 补**份数断言**(存档份数 = + 本单写下的资产行数,多一份也判失败 —— 从前只断「取得回、渲得出」,多摆几份照样全绿)。 + 用户那一单误存的三份已删,工作成果现摆 1 份、点开即那份 .docx。5 测。 + 顺手清两笔:`wake.test.ts` 的 `as unknown as`(§12.5 禁止清单,lint 红)、 + `wake` 契约的 api-report 快照(上一轮没入库,api:check 一直红)。 +- **统一鉴权中心(Keycloak)接入,只做后端**(2026-08-25,ADR-012):资源服务器 —— 后端只验 Bearer JWT + (`jose`,RS256 / iss / aud 本地验,不回源);`Staff.id` = `sub`,首登即建、Admin API 同步名册; + **权限只到端**:`sys.role_access` 一行一个 realm 角色,运营中心可改(锁死不变量:改完得有人还能进运营中心); + 路由归属写在契约 `access` 上,app 层一处中间件统一判 401/403,治理路由里 59 处守卫整体删除, + 覆盖测试 fail-closed;SSE 与下载走 60 秒票据;口令 / 会话表 / 岗位授权退役(八条路由回 410,契约留一轮)。 + web 待按前端手册接入,接入前登不进。 +- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与 + 企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 → + `sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。 + 分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。 + 发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、 + 运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。 + 15 条 `/api/gov/mail/*` 路由**全部归运营中心 `ops`**(读写都不给设计端 —— 邮箱那一屏没有设计端消费方,入站正文是客户来往内容, + 见 ADR-013 决策 10);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。 + 欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由(共十五笔见 debt.md)。 + ## 文档权威地图 | 层 | 文件 | 说明 | @@ -1302,6 +1333,10 @@ | | `docs/产品设计.md` | 三面一体的产品形态与决策记录 | | | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 | | | `docs/HTML报告生成规范.md` | **报告模版照这份**(2026-08-02 从开发规范拆出):一份 HTML 三个身份(件/表单/归档件)、**报告与卡的关系与同源纪律**、绑定语言全表、编辑态(闸/孪生件/增删行/红带)、PRD 平移法、陷阱表 | +| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 | +| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 | +| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) | +| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 | | | `docs/debt.md` | 欠账台账(只增,销账划线) | | | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 | | 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 | @@ -1317,10 +1352,11 @@ 3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO; 不删测试;不动门禁配置;不绕写三分/两缝。 4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。 -5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SEED_PASSWORD`(种子口令)、 - `JCAI_SECRET_KEY`(凭证密封);不入源码、不入包、不入留痕;E2E 永不回退开发库。 -6. **试跑一律用 AI 账号**(密码 `jcai-dev-2026`),绝不派到用户工作台;测完 - `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。 +5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、 + `KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选); + 不入源码、不入包、不入留痕;E2E 永不回退开发库。 +6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名), + 绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。 7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。 8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。 diff --git a/api-report/apps/api/src/modules/mail/contract.d.ts b/api-report/apps/api/src/modules/mail/contract.d.ts new file mode 100644 index 0000000..f62c5ba --- /dev/null +++ b/api-report/apps/api/src/modules/mail/contract.d.ts @@ -0,0 +1,62 @@ +/** + * module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。 + */ +import type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult } from '@contracts'; +export type { MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, }; +export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './internal/domain/errors.ts'; +/** + * 订阅方回调 —— 「注册回调读到邮件内容」的落点。 + * 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。 + * **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。 + */ +export type InboundHandler = (mail: MailInboundDetail, ctx: { + readonly accountId: string; + readonly channelId: string | undefined; +}) => Promise; +export type MailService = { + listAccounts(): Promise; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ + updateAccount(accountId: string, input: MailAccountInput): Promise; + /** 留痕不删(入站 / 出站表对账号不设外键) */ + deleteAccount(accountId: string): Promise; + /** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */ + probe(target: { + accountId: string; + } | { + input: MailAccountInput; + accountId?: string | undefined; + }): Promise; + /** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */ + pollDue(now: Date): Promise; + /** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */ + pollNow(accountId: string): Promise; + /** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */ + subscribe(name: string, handler: InboundHandler): () => void; + listInbound(query: { + accountId?: string | undefined; + limit?: number | undefined; + }): Promise; + getInbound(mailId: string): Promise; + attachment(mailId: string, index: number): Promise<{ + name: string; + contentType: string; + bytes: Uint8Array; + }>; + /** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */ + raw(mailId: string): Promise<{ + name: string; + bytes: Uint8Array; + }>; + /** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */ + redeliver(mailId: string, subscriber?: string): Promise; + /** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */ + send(input: MailSendInput): Promise; + listOutbound(query: { + accountId?: string | undefined; + limit?: number | undefined; + }): Promise; +}; diff --git a/api-report/apps/api/src/modules/mail/index.d.ts b/api-report/apps/api/src/modules/mail/index.d.ts new file mode 100644 index 0000000..dc8dbd6 --- /dev/null +++ b/api-report/apps/api/src/modules/mail/index.d.ts @@ -0,0 +1,21 @@ +/** + * module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { InboundHandler, MailAccountInput, MailAccountView, MailDeliveryView, MailInboundDetail, MailInboundSummary, MailOutboundView, MailPollOutcome, MailProbeReport, MailSendInput, MailSendResult, MailService, } from './contract.ts'; +export { MailAccountDisabledError, MailAccountNameTakenError, MailAccountNotFoundError, MailAttachmentNotFoundError, MailInboundNotFoundError, MailNoDefaultAccountError, MailPollBusyError, MailSecretMissingError, MailSendInvalidError, MailSubscriberTakenError, MailSubscriberUnknownError, } from './contract.ts'; +export type { AttachmentStore, ChangeNotifier, Clock, DeliveryRecord, IdGenerator, InboundRecord, InboundStore, MailAccountRecord, MailAccountStore, MailCursor, MailProvider, MimeParser, OutboundMessage, OutboundRecord, OutboundStore, ParsedMail, ProviderAccount, RawMessage, SecretBox, } from './port.ts'; +export { advanceImap, cursorSummary, imapCursor, imapNeedsReanchor, newUids, parseCursor, wecomCursor, wecomWindow, } from './internal/domain/cursor.ts'; +export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts'; +export { ATTACHMENT_CAP, bodyTextOf, buildInboundRecord, detailOf, planAttachments, RAW_CAP, stripHtml, summaryOf, threadIdOf, } from './internal/domain/inbound-record.ts'; +export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; +export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts'; +export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts'; +export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts'; +export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; +export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; +export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; +export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; +export { createMailService } from './internal/usecase/mail-service.ts'; +export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts'; +export type { FetchLike } from './internal/adapter/wecom-provider.ts'; +export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts'; diff --git a/api-report/apps/api/src/modules/mail/port.d.ts b/api-report/apps/api/src/modules/mail/port.d.ts new file mode 100644 index 0000000..82f99e2 --- /dev/null +++ b/api-report/apps/api/src/modules/mail/port.d.ts @@ -0,0 +1,170 @@ +/** + * module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import type { MailCursor } from './internal/domain/cursor.ts'; +import type { DeliveryRecord, InboundRecord, MailAccountRecord, OutboundMessage, OutboundRecord, ParsedMail } from './internal/domain/records.ts'; +export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail }; +/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */ +export type SecretBox = { + seal(plaintext: string): string; + open(cipher: string): string; +}; +export type Clock = { + now(): Date; +}; +export type IdGenerator = { + next(): string; +}; +export type MailAccountStore = { + list(): Promise; + get(id: string): Promise; + findByName(name: string): Promise; + /** + * upsert **只写配置列**;`isDefault:true` 时先把别的置 false(同一事务)。 + * 运行列(cursor / lastPolledAt / lastPollError / lastInboundAt / pollingUntil)一概不动 —— 见 `savePoll`。 + */ + save(record: MailAccountRecord): Promise; + remove(id: string): Promise; + /** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */ + listDue(now: Date): Promise; + /** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */ + claimPolling(id: string, leaseMs: number): Promise; + releasePolling(id: string): Promise; + /** + * 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op。 + * + * **运行列只走这条路**(与 `claimPolling` / `releasePolling` 一起)——`save()` 一个字都不碰它们: + * 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并覆盖回读那一刻的快照 + * (`sys.loop_state` 整份覆盖那一课的同款指纹)。 + * `cursor: null` 是**显式清游标**(改了收侧配置要重锚时用)。 + */ + savePoll(id: string, patch: { + cursor?: MailCursor | null; + polledAt?: Date; + error?: string | null; + lastInboundAt?: Date; + address?: string; + }): Promise; +}; +export type InboundStore = { + /** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */ + insert(record: InboundRecord): Promise<{ + inserted: boolean; + }>; + knownProviderRefs(accountId: string, refs: readonly string[]): Promise>; + /** + * 列表 —— **不取 `raw` 原件字节**(一封上限 16MB,50 行就是几百 MB,只为算 200 字预览); + * `rawAvailable` 由 `raw is not null` 投影出来,不靠把字节搬回来再判。 + */ + list(query: { + accountId?: string | undefined; + limit: number; + }): Promise>; + /** 只要元信息的读(分发 / 详情 / 附件 / 重投都走它)—— 同样不取 `raw` 字节 */ + getMeta(mailId: string): Promise<{ + record: InboundRecord; + deliveries: DeliveryRecord[]; + } | undefined>; + /** 全列读 —— 只有「下载原件」那一条路要它 */ + get(mailId: string): Promise<{ + record: InboundRecord; + deliveries: DeliveryRecord[]; + } | undefined>; + /** upsert on (mail_id, subscriber) */ + setDelivery(delivery: DeliveryRecord): Promise; +}; +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + /** + * 复用旧行重发之前**先抢**(一条条件更新,抢到才回 true): + * `failed` 的行、或 `created_at <= staleBefore` 的 `sending` 行才抢得到;抢到即置 `sending` 并把 + * `created_at` 推到 `now` —— 这一手让那一行**离开可抢的集合**,第二个人当场抢不到。 + * + * 本仓是两个后端连同一个库:没有这一抢,两台同时重放同一次放行都会判「超时可复用」, + * 发出**两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。 + */ + claimForResend(id: string, opts: { + readonly staleBefore: Date; + readonly now: Date; + }): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { + error?: string; + providerRef?: string; + sentAt?: Date; + }): Promise; + list(query: { + accountId?: string | undefined; + limit: number; + }): Promise; +}; +/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */ +export type AttachmentStore = { + put(file: { + name: string; + contentType: string; + bytes: Uint8Array; + note: string; + }): Promise<{ + ref: string; + }>; + get(ref: string): Promise<{ + name: string; + contentType: string; + bytes: Uint8Array; + } | undefined>; +}; +/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */ +export type ProviderAccount = { + readonly id: string; + readonly kind: MailAccountKind; + readonly address: string; + readonly displayName: string; + readonly settings: Record; + readonly secret: Record; +}; +export type RawMessage = { + readonly providerRef: string; + readonly eml: Uint8Array; + readonly receivedAt: Date; + /** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */ + readonly cursorAfter?: MailCursor | undefined; +}; +/** 外界:一种账号类型一个适配器(设计稿 §5.5) */ +export type MailProvider = { + readonly kind: MailAccountKind; + probe(account: ProviderAccount): Promise; + /** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise; + /** + * 拉一批。回参的 `note` 是这一轮**不算失败、但人必须知道**的事 + * (当前唯一一种:IMAP 的 UIDVALIDITY 变了、已重锚在当下、期间来信不回填)—— + * 用例把它落进 `last_poll_error`。丢了一段信而界面一片绿,那是界面撒谎。 + */ + pull(account: ProviderAccount, cursor: MailCursor, opts: { + readonly limit: number; + readonly now: Date; + readonly backfillFrom: Date | null; + /** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */ + readonly known: (refs: readonly string[]) => Promise>; + }): Promise<{ + messages: RawMessage[]; + cursor: MailCursor; + more: boolean; + note?: string | undefined; + }>; + send(account: ProviderAccount, message: OutboundMessage): Promise<{ + providerRef?: string | undefined; + }>; +}; +export type MimeParser = { + parse(eml: Uint8Array): Promise; +}; +/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */ +export type ChangeNotifier = { + changed(reason: string): void; +}; diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index a63caa7..aa1575b 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -1,16 +1,26 @@ /** * module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。 + * + * 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。 + * 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。 + * 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。 */ -import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts'; -export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding }; +import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts'; +export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position; }; -export type CreateStaffInput = { +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ readonly account: string; + /** name claim,缺则回退 account */ readonly name: string; - readonly positionId: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; }; export declare class StaffNotFoundError extends Error { readonly staffId: StaffId; @@ -20,45 +30,51 @@ export declare class PositionNotFoundError extends Error { readonly positionCode: string; constructor(positionCode: string); } -export declare class AccountTakenError extends Error { +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export declare class TokenRejectedError extends Error { + constructor(message: string); +} +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export declare class AccountDisabledError extends Error { readonly account: string; constructor(account: string); } -/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */ -export declare class DisableBlockedError extends Error { - readonly check: DisableCheck; - constructor(check: DisableCheck); +/** + * 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。 + * 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。 + * `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。 + */ +export declare class AccessLockoutError extends Error { + constructor(message?: string); } -/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */ -export type IssuedSession = { - readonly token: string; - readonly expiresAt: string; -}; export type OrgService = { + /** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */ + authenticate(token: string): Promise; + /** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */ + principalOf(staffId: StaffId): Promise; + /** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */ + registerIdentity(identity: Identity): Promise; listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[]; }>; getStaff(staffId: StaffId): Promise; - findByAccount(account: string): Promise; - createStaff(input: CreateStaffInput): Promise; - /** 停用前置检查 —— 只读,治理台先看再点 */ - checkDisable(staffId: StaffId): Promise; - /** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */ - disableStaff(staffId: StaffId): Promise; - listGrants(positionId: string): Promise; - setGrants(positionId: string, grants: readonly Grant[]): Promise; - /** 岗位权限校验 —— app 层鉴权中间件读它 */ - authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise; - loadSession(account: string): Promise; - /** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */ - login(account: string, password: string): Promise; - /** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */ - logout(token: string): Promise; - /** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */ - resolveSession(token: string): Promise; - /** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */ - setPassword(staffId: StaffId, password: string): Promise; + /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */ + staffOfPosition(positionCode: string): Promise; + setPosition(staffId: StaffId, positionId: PositionId): Promise; + /** Admin API 同步;未配 provisioning 回 configured:false */ + syncRoster(): Promise; + /** 此人身上还挂着什么(只读;OS 02 改派先于停用) */ + inFlightOf(staffId: StaffId): Promise; + listRoleAccess(): Promise<{ + roles: RoleAccess[]; + knownRoles: string[]; + }>; + /** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */ + setRoleAccess(role: string, ends: readonly Surface[]): Promise; + deleteRoleAccess(role: string): Promise; + /** 能进该端的活跃员工 —— 治理台通知的收件人 */ + staffWithEnd(surface: Surface): Promise; /** 建组 / 改组;id 留空即新建 */ saveWorkGroup(input: { id?: string; @@ -70,7 +86,6 @@ export type OrgService = { workGroupOf(staffId: StaffId): Promise; /** 装进工作流的快照:改组不改已起的单 */ bindingOf(staffId: StaffId): Promise; - /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */ - staffOfPosition(positionCode: string): Promise; + /** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */ ensureSeeded(): Promise; }; diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index aaa64e7..2a126d4 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -1,13 +1,18 @@ /** * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ -export type { CreateStaffInput, DisableCheck, Grant, GrantAction, GrantObject, IssuedSession, OrgService, Position, Session, Staff, StaffWithPosition, WorkGroup, WorkGroupBinding, } from './contract.ts'; -export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts'; -export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository, } from './port.ts'; +export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; +export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts'; export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; export { createOrgService } from './internal/usecase/org-service.ts'; -export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts'; -export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts'; -export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts'; -export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts'; -export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts'; +export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index 791f337..f0b9ff1 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -2,10 +2,40 @@ * module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 * adapter 实现之,usecase 依赖之,app 装配之。 */ -import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Identity } from './contract.ts'; export type Clock = { now(): Date; }; +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { + readonly roles?: readonly string[]; + }; +}; +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { + verify(token: string): Promise; +}; +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { + readonly enabled: boolean; +}; +export type RosterSource = { + listUsers(): Promise; +}; +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; export type PositionRepository = { list(): Promise; findByCode(code: string): Promise; @@ -14,46 +44,32 @@ export type PositionRepository = { id?: PositionId; }): Promise; }; +/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ +export type StaffRecord = Staff & { + readonly lastSeenAt?: Date; + readonly syncedAt?: Date; +}; export type StaffRepository = { - list(): Promise; - findById(id: StaffId): Promise; - findByAccount(account: string): Promise; - listByPosition(positionId: PositionId): Promise; - insert(staff: Staff): Promise; - setState(id: StaffId, state: Staff['state']): Promise; - /** 口令散列的读写 —— 明文不经此端口(ADR-010) */ - passwordHashOf(account: string): Promise; - setPasswordHash(id: StaffId, hash: string): Promise; + list(): Promise; + findById(id: StaffId): Promise; + /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ + findByAccount(account: string): Promise; + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff, marks: { + readonly lastSeenAt?: Date; + readonly syncedAt?: Date; + }): Promise; + updateIdentity(id: StaffId, patch: IdentityPatch): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; }; /** - * 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。 - * token 即主键,不透明随机串,不编码任何身份信息。 - */ -export type SessionRepository = { - issue(input: { - token: string; - staffId: StaffId; - expiresAt: Date; - }): Promise; - /** 未过期才返回;命中同时滑动续期 */ - touch(token: string, now: Date, expiresAt: Date): Promise<{ - staffId: StaffId; - } | undefined>; - revoke(token: string): Promise; - revokeAllOf(staffId: StaffId): Promise; -}; -/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */ -export type PasswordHasher = { - hash(password: string): Promise; - verify(password: string, stored: string | undefined): Promise; - newToken(): string; -}; -export type GrantRepository = { - listByPosition(positionId: PositionId): Promise; - replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise; -}; -/** - * 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`, + * 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`, * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 */ export type InFlightWorkProbe = { diff --git a/api-report/apps/api/src/modules/task/contract.d.ts b/api-report/apps/api/src/modules/task/contract.d.ts index 29c3e64..c300c0c 100644 --- a/api-report/apps/api/src/modules/task/contract.d.ts +++ b/api-report/apps/api/src/modules/task/contract.d.ts @@ -85,6 +85,13 @@ export type TaskService = { /** 控制三语义:暂停 / 叫停 / 终止 */ control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise; resume(taskId: TaskId, by: StaffId): Promise; + /** + * **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器 + * 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。 + * 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。 + * 返回翻没翻 —— 调用方据此决定要不要广播。 + */ + wake(taskId: TaskId): Promise; /** 改派:在途任务的负责人变更,**留痕并通知双方** */ reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise; /** 终态裁定 —— 完成由框架裁定,终态由系统命名 */ diff --git a/api-report/packages/contracts/src/__tests__/mail.test.d.ts b/api-report/packages/contracts/src/__tests__/mail.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/api-report/packages/contracts/src/__tests__/mail.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/api-report/packages/contracts/src/__tests__/route-access.test.d.ts b/api-report/packages/contracts/src/__tests__/route-access.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/api-report/packages/contracts/src/__tests__/route-access.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index b34c11b..8a8de12 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -6,16 +6,19 @@ import { z } from 'zod'; export declare const listStaffRoute: { readonly method: "GET"; readonly path: "/api/gov/staff"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ staff: z.ZodArray; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; position: z.ZodObject<{ id: z.core.$ZodBranded; code: z.ZodString; @@ -31,9 +34,11 @@ export declare const listStaffRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */ export declare const createStaffRoute: { readonly method: "POST"; readonly path: "/api/gov/staff"; + readonly access: "public"; readonly body: z.ZodObject<{ account: z.ZodString; name: z.ZodString; @@ -43,16 +48,20 @@ export declare const createStaffRoute: { id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */ export declare const disableStaffCheckRoute: { readonly method: "GET"; readonly path: "/api/gov/staff/:staffId/disable-check"; + readonly access: "public"; readonly params: z.ZodObject<{ staffId: z.core.$ZodBranded; }, z.core.$strip>; @@ -73,9 +82,11 @@ export declare const disableStaffCheckRoute: { }, z.core.$strip>>>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */ export declare const disableStaffRoute: { readonly method: "POST"; readonly path: "/api/gov/staff/:staffId/disable"; + readonly access: "public"; readonly params: z.ZodObject<{ staffId: z.core.$ZodBranded; }, z.core.$strip>; @@ -99,9 +110,11 @@ export declare const disableStaffRoute: { }, z.core.$strip>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */ export declare const listGrantsRoute: { readonly method: "GET"; readonly path: "/api/gov/positions/:positionId/grants"; + readonly access: "public"; readonly params: z.ZodObject<{ positionId: z.core.$ZodBranded; }, z.core.$strip>; @@ -138,9 +151,11 @@ export declare const listGrantsRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */ export declare const setGrantsRoute: { readonly method: "POST"; readonly path: "/api/gov/positions/:positionId/grants"; + readonly access: "public"; readonly params: z.ZodObject<{ positionId: z.core.$ZodBranded; }, z.core.$strip>; @@ -209,6 +224,132 @@ export declare const setGrantsRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */ +export declare const syncStaffRoute: { + readonly method: "POST"; + readonly path: "/api/gov/staff/sync"; + readonly access: "ops"; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + configured: z.ZodLiteral; + message: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + configured: z.ZodLiteral; + inserted: z.ZodNumber; + updated: z.ZodNumber; + disabled: z.ZodNumber; + total: z.ZodNumber; + failed: z.ZodArray>; + }, z.core.$strip>], "configured">; +}; +/** 指派岗位;指回哨兵岗 NONE = 取消指派 */ +export declare const setStaffPositionRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/staff/:staffId/position"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + staffId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + positionId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + email: z.ZodOptional; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + roles: z.ZodDefault>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}; +/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */ +export declare const staffInFlightRoute: { + readonly method: "GET"; + readonly path: "/api/gov/staff/:staffId/in-flight"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + staffId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + staffId: z.core.$ZodBranded; + blocked: z.ZodBoolean; + openTasks: z.ZodDefault>>; + openCards: z.ZodDefault>>; + openApprovals: z.ZodDefault>>; + }, z.core.$strip>; +}; +export declare const listRoleAccessRoute: { + readonly method: "GET"; + readonly path: "/api/gov/access/roles"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + roles: z.ZodArray>; + }, z.core.$strip>>; + knownRoles: z.ZodArray; + }, z.core.$strip>; +}; +/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */ +export declare const setRoleAccessRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/access/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + role: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + ends: z.ZodArray>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + role: z.ZodString; + ends: z.ZodArray>; + }, z.core.$strip>; +}; +export declare const deleteRoleAccessRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/access/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + role: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; export declare const LLM_PROVIDERS: readonly ["openai-compatible", "openai", "deepseek", "anthropic", "qwen"]; export declare const llmProviderSchema: z.ZodEnum<{ "openai-compatible": "openai-compatible"; @@ -243,6 +384,7 @@ export type LlmInstanceView = z.infer; export declare const listLlmProvidersRoute: { readonly method: "GET"; readonly path: "/api/gov/llm/providers"; + readonly access: "design"; readonly output: z.ZodObject<{ providers: z.ZodArray; name: z.ZodString; @@ -331,6 +475,7 @@ export declare const upsertLlmInstanceRoute: { export declare const deleteLlmInstanceRoute: { readonly method: "DELETE"; readonly path: "/api/gov/llm/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -341,6 +486,7 @@ export declare const deleteLlmInstanceRoute: { export declare const probeLlmDraftRoute: { readonly method: "POST"; readonly path: "/api/gov/llm/probe"; + readonly access: "design"; readonly body: z.ZodObject<{ id: z.ZodOptional; name: z.ZodOptional; @@ -364,6 +510,7 @@ export declare const probeLlmDraftRoute: { export declare const probeLlmInstanceRoute: { readonly method: "POST"; readonly path: "/api/gov/llm/:id/probe"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -402,6 +549,7 @@ export type GovTaskRow = z.infer; export declare const listGovTasksRoute: { readonly method: "GET"; readonly path: "/api/gov/tasks"; + readonly access: "ops"; readonly output: z.ZodObject<{ tasks: z.ZodArray; @@ -447,6 +595,7 @@ export declare const listGovTasksRoute: { export declare const controlTaskRoute: { readonly method: "POST"; readonly path: "/api/gov/tasks/:taskId/control"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -466,6 +615,7 @@ export declare const controlTaskRoute: { export declare const resumeTaskRoute: { readonly method: "POST"; readonly path: "/api/gov/tasks/:taskId/resume"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -485,6 +635,7 @@ export declare const resumeTaskRoute: { export declare const reassignTaskRoute: { readonly method: "POST"; readonly path: "/api/gov/tasks/:taskId/reassign"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -500,6 +651,7 @@ export declare const reassignTaskRoute: { export declare const govTaskLogRoute: { readonly method: "GET"; readonly path: "/api/gov/tasks/:taskId/log"; + readonly access: "ops"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -516,6 +668,7 @@ export declare const govTaskLogRoute: { export declare const listProcessPackagesRoute: { readonly method: "GET"; readonly path: "/api/gov/process"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ packages: z.ZodArray; @@ -635,6 +788,7 @@ export declare const listProcessPackagesRoute: { export declare const saveProcessRoute: { readonly method: "PUT"; readonly path: "/api/gov/process"; + readonly access: "design"; /** * 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。 * `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。 @@ -879,6 +1033,7 @@ export declare const saveProcessRoute: { export declare const setProcessStateRoute: { readonly method: "POST"; readonly path: "/api/gov/process/:id/:version/state"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; version: z.ZodString; @@ -1005,6 +1160,7 @@ export declare const setProcessStateRoute: { export declare const deleteProcessVersionRoute: { readonly method: "DELETE"; readonly path: "/api/gov/process/:id/:version"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; version: z.ZodString; @@ -1121,6 +1277,7 @@ export declare const deleteProcessVersionRoute: { export declare const listApprovalTemplatesRoute: { readonly method: "GET"; readonly path: "/api/gov/approval-templates"; + readonly access: "design"; readonly output: z.ZodObject<{ templates: z.ZodArray; @@ -1213,6 +1372,7 @@ export declare const deleteApprovalTemplateRoute: { export declare const deleteProcessFlowRoute: { readonly method: "DELETE"; readonly path: "/api/gov/process/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -1335,6 +1495,7 @@ export declare const deleteProcessFlowRoute: { export declare const editProcessVersionRoute: { readonly method: "POST"; readonly path: "/api/gov/process/:id/:version/edit"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; version: z.ZodString; @@ -1455,6 +1616,7 @@ export declare const editProcessVersionRoute: { export declare const validateProcessRoute: { readonly method: "POST"; readonly path: "/api/gov/process/validate"; + readonly access: "design"; readonly body: z.ZodObject<{ pkg: z.ZodObject<{ id: z.core.$ZodBranded; @@ -1580,6 +1742,7 @@ export declare const validateProcessRoute: { export declare const listTriggersRoute: { readonly method: "GET"; readonly path: "/api/gov/triggers"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ triggers: z.ZodArray; @@ -1639,6 +1802,7 @@ export declare const listTriggersRoute: { export declare const toggleTriggerRoute: { readonly method: "POST"; readonly path: "/api/gov/triggers/:id/toggle"; + readonly access: "ops"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -1659,6 +1823,7 @@ export declare const toggleTriggerRoute: { export declare const upsertTriggerRoute: { readonly method: "PUT"; readonly path: "/api/gov/triggers"; + readonly access: "design"; readonly body: z.ZodObject<{ trigger: z.ZodObject<{ id: z.core.$ZodBranded; @@ -1779,6 +1944,7 @@ export declare const upsertTriggerRoute: { export declare const listWorkGroupsRoute: { readonly method: "GET"; readonly path: "/api/gov/work-groups"; + readonly access: "ops"; /** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */ readonly output: z.ZodObject<{ groups: z.ZodArray; name: z.ZodString; @@ -1818,6 +1985,7 @@ export declare const saveWorkGroupRoute: { export declare const installActivityRoute: { readonly method: "POST"; readonly path: "/api/gov/activities"; + readonly access: "design"; readonly body: z.ZodObject<{ manifest: z.ZodObject<{ identity: z.ZodObject<{ @@ -2126,6 +2294,7 @@ export declare const installActivityRoute: { export declare const registerToolRoute: { readonly method: "POST"; readonly path: "/api/gov/tools"; + readonly access: "design"; readonly body: z.ZodObject<{ contract: z.ZodObject<{ name: z.ZodString; @@ -2170,6 +2339,7 @@ export declare const registerToolRoute: { export declare const listActivitiesRoute: { readonly method: "GET"; readonly path: "/api/gov/activities"; + readonly access: "design"; readonly output: z.ZodObject<{ activities: z.ZodArray; @@ -2350,6 +2525,7 @@ export declare const listActivityMemoryRoute: { export declare const listChannelsRoute: { readonly method: "GET"; readonly path: "/api/gov/channels"; + readonly access: readonly ["design", "ops"]; readonly output: z.ZodObject<{ channels: z.ZodArray; @@ -2424,6 +2602,7 @@ export declare const setTriggerConfirmRoute: { export declare const listNodeOwnersRoute: { readonly method: "GET"; readonly path: "/api/gov/packages/:id/owners"; + readonly access: "ops"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2437,6 +2616,7 @@ export declare const listNodeOwnersRoute: { export declare const setNodeOwnerRoute: { readonly method: "PUT"; readonly path: "/api/gov/packages/:id/owners/:nodeId"; + readonly access: "ops"; readonly params: z.ZodObject<{ id: z.ZodString; nodeId: z.ZodString; @@ -2452,6 +2632,7 @@ export declare const setNodeOwnerRoute: { export declare const testRunRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/run"; + readonly access: "design"; readonly body: z.ZodObject<{ packageId: z.ZodString; mail: z.ZodObject<{ @@ -2491,6 +2672,7 @@ export declare const testRunRoute: { export declare const testRunActivityRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/run-activity"; + readonly access: "design"; readonly body: z.ZodObject<{ activityId: z.ZodString; data: z.ZodDefault>; @@ -2510,6 +2692,7 @@ export declare const testRunActivityRoute: { export declare const testStopRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/stop"; + readonly access: "design"; /** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */ readonly body: z.ZodObject<{ workflowId: z.ZodDefault; @@ -2532,6 +2715,7 @@ export declare const testStopRoute: { export declare const testCurrentRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/current"; + readonly access: "design"; readonly output: z.ZodObject<{ workflowId: z.ZodOptional; subject: z.ZodOptional; @@ -2551,6 +2735,7 @@ export declare const testCurrentRoute: { export declare const testActivityStateRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/activity/:taskId"; + readonly access: "design"; readonly params: z.ZodObject<{ taskId: z.ZodString; }, z.core.$strip>; @@ -2617,6 +2802,7 @@ export declare const testActivityStateRoute: { export declare const uploadFileRoute: { readonly method: "POST"; readonly path: "/api/gov/upload"; + readonly access: "design"; readonly body: z.ZodObject<{ name: z.ZodString; contentType: z.ZodDefault; @@ -2636,6 +2822,7 @@ export type UploadedFile = z.infer; export declare const downloadFileRoute: { readonly method: "GET"; readonly path: "/api/gov/upload/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2685,6 +2872,7 @@ export type TestLogRow = z.infer; export declare const listTestLogsRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/logs"; + readonly access: "design"; readonly output: z.ZodObject<{ runs: z.ZodArray; @@ -2774,6 +2963,7 @@ export declare const testLogDetailRoute: { export declare const deleteTestLogRoute: { readonly method: "DELETE"; readonly path: "/api/gov/testbed/logs/:id"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2784,6 +2974,7 @@ export declare const deleteTestLogRoute: { export declare const clearTestLogsRoute: { readonly method: "DELETE"; readonly path: "/api/gov/testbed/logs"; + readonly access: "design"; readonly output: z.ZodObject<{ deleted: z.ZodNumber; }, z.core.$strip>; @@ -2792,6 +2983,7 @@ export declare const clearTestLogsRoute: { export declare const traceOfWorkflowRoute: { readonly method: "GET"; readonly path: "/api/gov/workflows/:id/trace"; + readonly access: "design"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2825,6 +3017,7 @@ export declare const traceOfWorkflowRoute: { export declare const govWorkflowRoute: { readonly method: "GET"; readonly path: "/api/gov/workflows/:id"; + readonly access: readonly ["design", "ops"]; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -2986,6 +3179,7 @@ export declare const govWorkflowRoute: { export declare const govMetricsRoute: { readonly method: "GET"; readonly path: "/api/gov/metrics"; + readonly access: "ops"; /** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */ readonly query: z.ZodObject<{ period: z.ZodOptional; +export type Surface = z.infer; +export declare const SURFACE_LABEL: Readonly>; +/** + * 一条路由归哪个端(ADR-012): + * `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。 + * 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。 + */ +export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[]; /** * 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。 - * body 只在 POST/PATCH 上有意义;GET 用 query。 + * body 只在 POST/PUT/PATCH 上有意义;GET 用 query。 */ export type RouteContract = { readonly method: HttpMethod; readonly path: string; + readonly access: RouteAccess; readonly params?: TParams; readonly query?: TQuery; readonly body?: TBody; @@ -21,8 +40,8 @@ export type RouteContract = z.infer; export type RouteBody = R['body'] extends z.ZodTypeAny ? z.infer : never; -/** 结构化错误:前端按 code 分辨,不解析文案 */ -export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal"]; +/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ +export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone"]; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; export type ApiError = { readonly code: ApiErrorCode; diff --git a/api-report/packages/contracts/src/api/index.d.ts b/api-report/packages/contracts/src/api/index.d.ts index 69dfaef..297cfdd 100644 --- a/api-report/packages/contracts/src/api/index.d.ts +++ b/api-report/packages/contracts/src/api/index.d.ts @@ -2,3 +2,4 @@ export * from './http.ts'; export * from './workbench.ts'; export * from './governance.ts'; export * from './ingest.ts'; +export * from './mail.ts'; diff --git a/api-report/packages/contracts/src/api/ingest.d.ts b/api-report/packages/contracts/src/api/ingest.d.ts index d15629a..ab07272 100644 --- a/api-report/packages/contracts/src/api/ingest.d.ts +++ b/api-report/packages/contracts/src/api/ingest.d.ts @@ -26,6 +26,7 @@ export type InboundMail = z.infer; export declare const ingestMailRoute: { readonly method: "POST"; readonly path: "/api/ingest/channel/:channelId/mail"; + readonly access: "public"; readonly params: z.ZodObject<{ channelId: z.core.$ZodBranded; }, z.core.$strip>; diff --git a/api-report/packages/contracts/src/api/mail.d.ts b/api-report/packages/contracts/src/api/mail.d.ts new file mode 100644 index 0000000..be53e80 --- /dev/null +++ b/api-report/packages/contracts/src/api/mail.d.ts @@ -0,0 +1,690 @@ +/** + * 邮件收发的路由契约(设计稿 §7.2;ADR-013)。 + * + * 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方; + * 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。 + * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 + * 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。 + */ +import { z } from 'zod'; +import type { RouteContract } from './http.ts'; +export declare const listMailAccountsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/accounts"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + accounts: z.ZodArray; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>; +}; +export declare const createMailAccountRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts"; + readonly access: "ops"; + readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; +}; +/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */ +export declare const probeMailDraftRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/probe"; + readonly access: "ops"; + /** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */ + readonly query: z.ZodObject<{ + accountId: z.ZodOptional; + }, z.core.$strip>; + readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; + readonly output: z.ZodObject<{ + kind: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; + }>; + address: z.ZodOptional; + receive: z.ZodObject<{ + ok: z.ZodBoolean; + error: z.ZodOptional; + latencyMs: z.ZodOptional; + }, z.core.$strip>; + send: z.ZodObject<{ + checked: z.ZodBoolean; + ok: z.ZodOptional; + error: z.ZodOptional; + note: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}; +export declare const getMailAccountRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/accounts/:accountId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; +}; +/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ +export declare const updateMailAccountRoute: { + readonly method: "PUT"; + readonly path: "/api/gov/mail/accounts/:accountId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; + readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; + }, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; + }, z.core.$strip>], "kind">; +}; +export declare const deleteMailAccountRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/mail/accounts/:accountId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** 已存账号探测(用库里凭证);凭证空 412 */ +export declare const probeMailAccountRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/:accountId/probe"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + kind: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; + }>; + address: z.ZodOptional; + receive: z.ZodObject<{ + ok: z.ZodBoolean; + error: z.ZodOptional; + latencyMs: z.ZodOptional; + }, z.core.$strip>; + send: z.ZodObject<{ + checked: z.ZodBoolean; + ok: z.ZodOptional; + error: z.ZodOptional; + note: z.ZodOptional; + }, z.core.$strip>; + }, z.core.$strip>; +}; +/** 立即拉一轮;另一进程占着租约 409;停用 412 */ +export declare const pollMailAccountRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/:accountId/poll"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + accountId: z.ZodString; + pulled: z.ZodNumber; + stored: z.ZodNumber; + dispatched: z.ZodNumber; + more: z.ZodBoolean; + error: z.ZodOptional; + }, z.core.$strip>; +}; +export declare const sendTestMailRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/accounts/:accountId/send-test"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + accountId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + to: z.ZodEmail; + subject: z.ZodString; + text: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + outboundId: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sent: "sent"; + }>; + error: z.ZodOptional; + providerRef: z.ZodOptional; + }, z.core.$strip>; +}; +export declare const listMailInboundRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound"; + readonly access: "ops"; + readonly query: z.ZodObject<{ + accountId: z.ZodOptional; + limit: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + mails: z.ZodArray; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const getMailInboundRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound/:mailId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + from: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; + }, z.core.$strip>; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + inReplyTo: z.ZodNullable; + references: z.ZodArray; + text: z.ZodNullable; + html: z.ZodNullable; + size: z.ZodNumber; + rawAvailable: z.ZodBoolean; + }, z.core.$strip>; +}; +/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */ +export declare const mailAttachmentRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound/:mailId/attachments/:index"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + index: z.ZodCoercedNumber; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodString; + dataBase64: z.ZodString; + }, z.core.$strip>; +}; +/** 原件 .eml;超限没存 404 */ +export declare const mailRawRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/inbound/:mailId/raw"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodLiteral<"message/rfc822">; + dataBase64: z.ZodString; + }, z.core.$strip>; +}; +/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */ +export declare const redeliverMailInboundRoute: { + readonly method: "POST"; + readonly path: "/api/gov/mail/inbound/:mailId/redeliver"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + mailId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodDefault; + }, z.core.$strip>>; + readonly output: z.ZodObject<{ + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const listMailOutboundRoute: { + readonly method: "GET"; + readonly path: "/api/gov/mail/outbound"; + readonly access: "ops"; + readonly query: z.ZodObject<{ + accountId: z.ZodOptional; + limit: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + mails: z.ZodArray; + cc: z.ZodArray; + bcc: z.ZodArray; + subject: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sending: "sending"; + sent: "sent"; + }>; + error: z.ZodNullable; + providerRef: z.ZodNullable; + requestedBy: z.ZodRecord; + createdAt: z.ZodISODateTime; + sentAt: z.ZodNullable; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 全部 15 条 —— 文档总表与覆盖测试按它数 */ +export declare const MAIL_ROUTES: readonly RouteContract[]; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index 43ae242..b120b40 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -3,20 +3,23 @@ * 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。 */ import { z } from 'zod'; -/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export declare const sessionRoute: { readonly method: "GET"; readonly path: "/api/session"; + readonly access: "public"; readonly output: z.ZodObject<{ staff: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -56,13 +59,11 @@ export declare const sessionRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; -/** - * 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。 - * 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。 - */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export declare const loginRoute: { readonly method: "POST"; readonly path: "/api/login"; + readonly access: "public"; readonly body: z.ZodObject<{ account: z.ZodString; password: z.ZodString; @@ -72,11 +73,13 @@ export declare const loginRoute: { id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -116,10 +119,11 @@ export declare const loginRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; -/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export declare const logoutRoute: { readonly method: "POST"; readonly path: "/api/logout"; + readonly access: "public"; readonly output: z.ZodObject<{ ok: z.ZodBoolean; }, z.core.$strip>; @@ -127,6 +131,7 @@ export declare const logoutRoute: { export declare const healthRoute: { readonly method: "GET"; readonly path: "/api/health"; + readonly access: "public"; readonly output: z.ZodObject<{ ok: z.ZodBoolean; version: z.ZodString; @@ -136,6 +141,51 @@ export declare const healthRoute: { }>; }, z.core.$strip>; }; +/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */ +export declare const meRoute: { + readonly method: "GET"; + readonly path: "/api/me"; + readonly access: "any"; + readonly output: z.ZodObject<{ + staff: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + email: z.ZodOptional; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + roles: z.ZodDefault>; + }, z.core.$strip>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + roles: z.ZodArray; + ends: z.ZodArray>; + }, z.core.$strip>; +}; +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。GET 上 `?ticket=` 认它。 + */ +export declare const authTicketRoute: { + readonly method: "GET"; + readonly path: "/api/auth/ticket"; + readonly access: "any"; + readonly output: z.ZodObject<{ + ticket: z.ZodString; + expiresAt: z.ZodString; + }, z.core.$strip>; +}; /** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */ export declare const taskCardSchema: z.ZodObject<{ id: z.core.$ZodBranded; @@ -177,6 +227,7 @@ export type TaskCard = z.infer; export declare const listTasksRoute: { readonly method: "GET"; readonly path: "/api/desk/tasks"; + readonly access: "workbench"; readonly output: z.ZodObject<{ tasks: z.ZodArray; @@ -2138,6 +2189,7 @@ export type TaskDetail = z.infer; export declare const taskDetailRoute: { readonly method: "GET"; readonly path: "/api/desk/tasks/:taskId"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4043,6 +4095,7 @@ export declare const taskDetailRoute: { export declare const replyWorkOrderRoute: { readonly method: "POST"; readonly path: "/api/desk/work-orders/reply"; + readonly access: "workbench"; readonly body: z.ZodObject<{ reply: z.ZodRecord; }, z.core.$strip>; @@ -4058,6 +4111,7 @@ export declare const replyWorkOrderRoute: { export declare const replyChatCardRoute: { readonly method: "POST"; readonly path: "/api/desk/chat-cards/reply"; + readonly access: "workbench"; readonly body: z.ZodObject<{ reply: z.ZodRecord; }, z.core.$strip>; @@ -4072,6 +4126,7 @@ export declare const replyChatCardRoute: { export declare const markCommReadRoute: { readonly method: "POST"; readonly path: "/api/desk/comms/:commId/read"; + readonly access: "workbench"; readonly params: z.ZodObject<{ commId: z.ZodString; }, z.core.$strip>; @@ -4120,6 +4175,7 @@ export declare const markCommReadRoute: { export declare const saveTableRoute: { readonly method: "POST"; readonly path: "/api/desk/tasks/:taskId/table"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4149,6 +4205,7 @@ export declare const saveTableRoute: { export declare const openAssetFileRoute: { readonly method: "GET"; readonly path: "/api/desk/tasks/:taskId/file"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4199,6 +4256,7 @@ export declare const openAssetFileRoute: { export declare const sayRoute: { readonly method: "POST"; readonly path: "/api/desk/tasks/:taskId/say"; + readonly access: "workbench"; readonly params: z.ZodObject<{ taskId: z.core.$ZodBranded; }, z.core.$strip>; @@ -4393,6 +4451,7 @@ export type SystemMessageView = z.infer; export declare const listMessagesRoute: { readonly method: "GET"; readonly path: "/api/desk/messages"; + readonly access: "workbench"; readonly output: z.ZodObject<{ messages: z.ZodArray; @@ -4459,6 +4519,7 @@ export declare const markMessageReadRoute: { export declare const answerMessageRoute: { readonly method: "POST"; readonly path: "/api/desk/messages/:messageId/answer"; + readonly access: "workbench"; readonly params: z.ZodObject<{ messageId: z.ZodString; }, z.core.$strip>; @@ -4473,6 +4534,7 @@ export declare const answerMessageRoute: { export declare const confirmTriggerPendingRoute: { readonly method: "POST"; readonly path: "/api/desk/trigger-pending/:id/confirm"; + readonly access: "workbench"; readonly params: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>; @@ -4521,6 +4583,7 @@ export type MinuteView = z.infer; export declare const listMinutesRoute: { readonly method: "GET"; readonly path: "/api/minutes"; + readonly access: "workbench"; readonly output: z.ZodObject<{ minutes: z.ZodArray; @@ -4622,6 +4687,7 @@ export declare const appendResolutionRoute: { export declare const registerResolutionRoute: { readonly method: "POST"; readonly path: "/api/minutes/resolutions/:resolutionId/register"; + readonly access: "workbench"; readonly params: z.ZodObject<{ resolutionId: z.ZodString; }, z.core.$strip>; @@ -4704,6 +4770,7 @@ export type ReportView = z.infer; export declare const draftReportRoute: { readonly method: "GET"; readonly path: "/api/reports/:period"; + readonly access: "workbench"; readonly params: z.ZodObject<{ period: z.ZodEnum<{ day: "day"; @@ -4759,6 +4826,7 @@ export declare const draftReportRoute: { export declare const listReportsRoute: { readonly method: "GET"; readonly path: "/api/reports"; + readonly access: "workbench"; readonly output: z.ZodObject<{ reports: z.ZodArray; @@ -4884,6 +4953,7 @@ export type ApprovalItem = z.infer; export declare const listApprovalsForMeRoute: { readonly method: "GET"; readonly path: "/api/approvals"; + readonly access: "workbench"; readonly output: z.ZodObject<{ approvals: z.ZodArray; diff --git a/api-report/packages/contracts/src/index.d.ts b/api-report/packages/contracts/src/index.d.ts index 6cafdfc..9ef3862 100644 --- a/api-report/packages/contracts/src/index.d.ts +++ b/api-report/packages/contracts/src/index.d.ts @@ -13,4 +13,5 @@ export * from './ledger.ts'; export * from './process-package.ts'; export * from './manifest.ts'; export * from './org.ts'; +export * from './mail.ts'; export * from './api/index.ts'; diff --git a/api-report/packages/contracts/src/mail.d.ts b/api-report/packages/contracts/src/mail.d.ts new file mode 100644 index 0000000..8833d17 --- /dev/null +++ b/api-report/packages/contracts/src/mail.d.ts @@ -0,0 +1,364 @@ +/** + * 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。 + * + * 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。 + * 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱), + * 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。 + */ +import { z } from 'zod'; +export declare const MAIL_ACCOUNT_KINDS: readonly ["imap-smtp", "wecom"]; +export declare const mailAccountKindSchema: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; +}>; +export type MailAccountKind = z.infer; +export declare const MAIL_ACCOUNT_KIND_LABEL: Readonly>; +/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */ +export declare const imapSmtpSettingsSchema: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; +}, z.core.$strip>; +export type ImapSmtpSettings = z.infer; +/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */ +export declare const wecomSettingsSchema: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; +}, z.core.$strip>; +export type WecomSettings = z.infer; +export declare const mailAccountInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodPrefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"imap-smtp">; + address: z.ZodEmail; +}, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + secret: z.ZodOptional>; + name: z.ZodString; + displayName: z.ZodDefault; + enabled: z.ZodDefault; + isDefault: z.ZodDefault; + pollIntervalSec: z.ZodDefault; + channelId: z.ZodDefault>>; + backfillFrom: z.ZodDefault>; + kind: z.ZodLiteral<"wecom">; +}, z.core.$strip>], "kind">; +export type MailAccountInput = z.infer; +export declare const mailAccountStatusSchema: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; +}, z.core.$strip>; +export declare const mailAccountViewSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + settings: z.ZodObject<{ + imapHost: z.ZodDefault; + imapPort: z.ZodDefault; + imapTls: z.ZodDefault; + smtpHost: z.ZodDefault; + smtpPort: z.ZodDefault; + smtpTls: z.ZodDefault; + login: z.ZodOptional; + folder: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"imap-smtp">; +}, z.core.$strip>, z.ZodObject<{ + settings: z.ZodObject<{ + corpId: z.ZodString; + apiBase: z.ZodDefault; + }, z.core.$strip>; + id: z.ZodString; + name: z.ZodString; + address: z.ZodString; + displayName: z.ZodString; + enabled: z.ZodBoolean; + isDefault: z.ZodBoolean; + pollIntervalSec: z.ZodNumber; + channelId: z.ZodNullable; + backfillFrom: z.ZodNullable; + secretBound: z.ZodBoolean; + status: z.ZodObject<{ + lastPolledAt: z.ZodNullable; + lastPollError: z.ZodNullable; + lastInboundAt: z.ZodNullable; + cursor: z.ZodNullable; + polling: z.ZodBoolean; + }, z.core.$strip>; + createdAt: z.ZodISODateTime; + updatedAt: z.ZodISODateTime; + kind: z.ZodLiteral<"wecom">; +}, z.core.$strip>], "kind">; +export type MailAccountView = z.infer; +/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */ +export declare const mailProbeReportSchema: z.ZodObject<{ + kind: z.ZodEnum<{ + "imap-smtp": "imap-smtp"; + wecom: "wecom"; + }>; + address: z.ZodOptional; + receive: z.ZodObject<{ + ok: z.ZodBoolean; + error: z.ZodOptional; + latencyMs: z.ZodOptional; + }, z.core.$strip>; + send: z.ZodObject<{ + checked: z.ZodBoolean; + ok: z.ZodOptional; + error: z.ZodOptional; + note: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; +export type MailProbeReport = z.infer; +export declare const MAIL_DELIVERY_STATES: readonly ["dispatching", "done", "failed", "skipped"]; +export declare const mailDeliveryStateSchema: z.ZodEnum<{ + failed: "failed"; + done: "done"; + dispatching: "dispatching"; + skipped: "skipped"; +}>; +export type MailDeliveryState = z.infer; +export declare const mailDeliveryViewSchema: z.ZodObject<{ + subscriber: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + done: "done"; + dispatching: "dispatching"; + skipped: "skipped"; + }>; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; +}, z.core.$strip>; +export type MailDeliveryView = z.infer; +export declare const mailAddressSchema: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; +}, z.core.$strip>; +export type MailAddress = z.infer; +export declare const mailInboundAttachmentSchema: z.ZodObject<{ + index: z.ZodNumber; + name: z.ZodString; + contentType: z.ZodString; + size: z.ZodNumber; + ref: z.ZodNullable; + note: z.ZodString; +}, z.core.$strip>; +export type MailInboundAttachment = z.infer; +export declare const mailInboundSummarySchema: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + from: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; + }, z.core.$strip>; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; +}, z.core.$strip>; +export type MailInboundSummary = z.infer; +export declare const mailInboundDetailSchema: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + from: z.ZodObject<{ + address: z.ZodString; + name: z.ZodString; + }, z.core.$strip>; + to: z.ZodArray>; + cc: z.ZodArray>; + subject: z.ZodString; + sentAt: z.ZodNullable; + receivedAt: z.ZodISODateTime; + textPreview: z.ZodString; + hasHtml: z.ZodBoolean; + attachments: z.ZodArray; + note: z.ZodString; + }, z.core.$strip>>; + deliveries: z.ZodArray; + error: z.ZodNullable; + startedAt: z.ZodISODateTime; + finishedAt: z.ZodNullable; + }, z.core.$strip>>; + inReplyTo: z.ZodNullable; + references: z.ZodArray; + text: z.ZodNullable; + html: z.ZodNullable; + size: z.ZodNumber; + rawAvailable: z.ZodBoolean; +}, z.core.$strip>; +export type MailInboundDetail = z.infer; +export declare const mailSendInputSchema: z.ZodObject<{ + accountId: z.ZodOptional; + from: z.ZodOptional; + to: z.ZodArray; + cc: z.ZodDefault>; + bcc: z.ZodDefault>; + subject: z.ZodString; + text: z.ZodOptional; + html: z.ZodOptional; + inReplyTo: z.ZodOptional; + references: z.ZodDefault>; + attachments: z.ZodDefault; + dataBase64: z.ZodOptional; + uploadRef: z.ZodOptional; + }, z.core.$strip>>>; + idempotencyKey: z.ZodOptional; + requestedBy: z.ZodObject<{ + kind: z.ZodEnum<{ + channel: "channel"; + tool: "tool"; + ops: "ops"; + }>; + taskId: z.ZodOptional; + staffId: z.ZodOptional; + tool: z.ZodOptional; + }, z.core.$strip>; +}, z.core.$strip>; +export type MailSendInput = z.infer; +export declare const MAIL_OUTBOUND_STATES: readonly ["sending", "sent", "failed"]; +export declare const mailOutboundStateSchema: z.ZodEnum<{ + failed: "failed"; + sending: "sending"; + sent: "sent"; +}>; +export type MailOutboundState = z.infer; +export declare const mailSendResultSchema: z.ZodObject<{ + outboundId: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sent: "sent"; + }>; + error: z.ZodOptional; + providerRef: z.ZodOptional; +}, z.core.$strip>; +export type MailSendResult = z.infer; +export declare const mailOutboundViewSchema: z.ZodObject<{ + id: z.ZodString; + accountId: z.ZodString; + messageId: z.ZodString; + to: z.ZodArray; + cc: z.ZodArray; + bcc: z.ZodArray; + subject: z.ZodString; + state: z.ZodEnum<{ + failed: "failed"; + sending: "sending"; + sent: "sent"; + }>; + error: z.ZodNullable; + providerRef: z.ZodNullable; + requestedBy: z.ZodRecord; + createdAt: z.ZodISODateTime; + sentAt: z.ZodNullable; +}, z.core.$strip>; +export type MailOutboundView = z.infer; +export declare const mailPollOutcomeSchema: z.ZodObject<{ + accountId: z.ZodString; + pulled: z.ZodNumber; + stored: z.ZodNumber; + dispatched: z.ZodNumber; + more: z.ZodBoolean; + error: z.ZodOptional; +}, z.core.$strip>; +export type MailPollOutcome = z.infer; diff --git a/api-report/packages/contracts/src/org.d.ts b/api-report/packages/contracts/src/org.d.ts index 78f519c..cfde330 100644 --- a/api-report/packages/contracts/src/org.d.ts +++ b/api-report/packages/contracts/src/org.d.ts @@ -1,9 +1,9 @@ /** - * 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。 + * 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。 * - * 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。 - * 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。 - * 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。 + * 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench), + * 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。 + * 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。 */ import { z } from 'zod'; export declare const positionSchema: z.ZodObject<{ @@ -23,23 +23,123 @@ export declare const staffSchema: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; export type Staff = z.infer; +export declare const principalSchema: z.ZodObject<{ + staff: z.ZodObject<{ + id: z.core.$ZodBranded; + account: z.ZodString; + name: z.ZodString; + email: z.ZodOptional; + positionId: z.core.$ZodBranded; + state: z.ZodDefault>; + roles: z.ZodDefault>; + }, z.core.$strip>; + position: z.ZodObject<{ + id: z.core.$ZodBranded; + code: z.ZodString; + name: z.ZodString; + note: z.ZodOptional; + }, z.core.$strip>; + roles: z.ZodArray; + ends: z.ZodArray>; +}, z.core.$strip>; +export type Principal = z.infer; +/** 角色 → 端:一行一个 realm 角色 */ +export declare const roleAccessSchema: z.ZodObject<{ + role: z.ZodString; + ends: z.ZodArray>; +}, z.core.$strip>; +export type RoleAccess = z.infer; +/** + * 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。 + * 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数, + * 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。 + */ +export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ + configured: z.ZodLiteral; + message: z.ZodString; +}, z.core.$strip>, z.ZodObject<{ + configured: z.ZodLiteral; + inserted: z.ZodNumber; + updated: z.ZodNumber; + disabled: z.ZodNumber; + total: z.ZodNumber; + failed: z.ZodArray>; +}, z.core.$strip>], "configured">; +export type RosterSyncOutcome = z.infer; +export declare const inFlightWorkSchema: z.ZodObject<{ + staffId: z.core.$ZodBranded; + blocked: z.ZodBoolean; + openTasks: z.ZodDefault>>; + openCards: z.ZodDefault>>; + openApprovals: z.ZodDefault>>; +}, z.core.$strip>; +export type InFlightWork = z.infer; +/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */ +export declare const disableCheckSchema: z.ZodObject<{ + staffId: z.core.$ZodBranded; + blocked: z.ZodBoolean; + openTasks: z.ZodDefault>>; + openCards: z.ZodDefault>>; + openApprovals: z.ZodDefault>>; +}, z.core.$strip>; +/** @deprecated 改用 `InFlightWork` */ +export type DisableCheck = InFlightWork; +/** @deprecated 权限只到端,动作三分已退役 */ export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"]; +/** @deprecated */ export declare const grantActionSchema: z.ZodEnum<{ read: "read"; write: "write"; govern: "govern"; }>; +/** @deprecated */ export type GrantAction = z.infer; +/** @deprecated */ export declare const GRANT_ACTION_LABEL: Readonly>; -/** 治理台九功能 —— 授权客体之一 */ +/** @deprecated 治理台九功能不再是授权客体 */ export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"]; +/** @deprecated */ export declare const governanceFunctionSchema: z.ZodEnum<{ trigger: "trigger"; data: "data"; @@ -51,8 +151,11 @@ export declare const governanceFunctionSchema: z.ZodEnum<{ process: "process"; memory: "memory"; }>; +/** @deprecated */ export type GovernanceFunction = z.infer; +/** @deprecated */ export declare const GOVERNANCE_FUNCTION_LABEL: Readonly>; +/** @deprecated */ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"data">; domain: z.ZodString; @@ -75,7 +178,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"task">; activityId: z.ZodOptional; }, z.core.$strip>], "kind">; +/** @deprecated */ export type GrantObject = z.infer; +/** @deprecated */ export declare const grantSchema: z.ZodObject<{ positionId: z.core.$ZodBranded; object: z.ZodDiscriminatedUnion<[z.ZodObject<{ @@ -106,35 +211,21 @@ export declare const grantSchema: z.ZodObject<{ govern: "govern"; }>; }, z.core.$strip>; +/** @deprecated */ export type Grant = z.infer; -/** 员工停用前,其在途任务与待答卡须先改派完毕 */ -export declare const disableCheckSchema: z.ZodObject<{ - staffId: z.core.$ZodBranded; - blocked: z.ZodBoolean; - openTasks: z.ZodDefault>>; - openCards: z.ZodDefault>>; - openApprovals: z.ZodDefault>>; -}, z.core.$strip>; -export type DisableCheck = z.infer; +/** @deprecated 改用 `principalSchema` / `GET /api/me` */ export declare const sessionSchema: z.ZodObject<{ staff: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; name: z.ZodString; + email: z.ZodOptional; positionId: z.core.$ZodBranded; state: z.ZodDefault>; + roles: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -173,6 +264,7 @@ export declare const sessionSchema: z.ZodObject<{ }>; }, z.core.$strip>>; }, z.core.$strip>; +/** @deprecated 改用 `Principal` */ export type Session = z.infer; /** * 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。 diff --git a/apps/api/package.json b/apps/api/package.json index 48c9895..3036521 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,6 @@ "start": "node dist/main.js", "seed": "node dist/seed.js", "migrate": "node dist/migrate.js", - "set-password": "node dist/set-password.js", "reset-runs": "node dist/reset-runs.js", "install-activities": "node dist/install-activities.js" }, @@ -26,11 +25,16 @@ "ajv-formats": "^3.0.1", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", + "imapflow": "^1.7.6", + "jose": "^5.10.0", + "nodemailer": "^8.0.11", "pg": "^8.22.0", "pg-boss": "^12.26.3", + "postal-mime": "^3.0.0", "zod": "^4.4.3" }, "devDependencies": { + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.20.0" } } diff --git a/apps/api/src/app/__tests__/asset-archive.test.ts b/apps/api/src/app/__tests__/asset-archive.test.ts index 9bcfd53..d80cb27 100644 --- a/apps/api/src/app/__tests__/asset-archive.test.ts +++ b/apps/api/src/app/__tests__/asset-archive.test.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest'; import type { TaskId } from '@contracts'; -import { archiveAssetFiles, keyText } from '../asset-archive.ts'; +import { archiveAssetFiles, assetRowsOf, keyText } from '../asset-archive.ts'; const TASK = 't-1' as TaskId; @@ -54,6 +54,11 @@ describe('keyText —— 主键拼法必须稳定', () => { }); }); +/** 本单碰过的行 —— `ledger.taskDrafts()` 那一份的形状 */ +function touch(...ids: string[]) { + return ids.map((content_id) => ({ domain: 'content', object: 'asset', key: { content_id } })); +} + describe('archiveAssetFiles —— 存哪些、不存哪些', () => { it('一行一份,存下来的是真字节(存几份就写几笔)', async () => { const db = fakeDb(); @@ -62,6 +67,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { taskId: TASK, ids, declarations: [ASSET], + touched: touch('CT-1', 'CT-2'), rowsOfAddress: async () => [ { content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' }, { content_id: 'CT-2', title: '对照表', body_md: '| a | b |\n| - | - |\n| 1 | 2 |', file_format: 'xlsx' }, @@ -72,6 +78,56 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { expect(db.writes.join()).toContain('白皮书'); }); + /* + * 2026-08-25 柴浩实测:「工作成果怎么有 4 个资料,这单只会产出一个」—— + * 资料落在**主数据**上,整表里躺着历次跑出来的资产,前三份都是别单的成果。 + */ + it('**别单放行过的行不算这一单产出的** —— 整表四行,本单只碰过一行,就只存一份', async () => { + const db = fakeDb(); + const saved = await archiveAssetFiles({ + db, + taskId: TASK, + ids, + declarations: [ASSET], + touched: touch('CT-2026-0004'), + rowsOfAddress: async () => [ + { content_id: 'CT-2026-0001', title: '白皮书', body_md: '别单的正文', file_format: 'docx' }, + { content_id: 'CT-2026-0002', title: '白皮书 2026', body_md: '别单的正文', file_format: 'docx' }, + { content_id: 'CT-2026-0003', title: '产品介绍书', body_md: '别单的正文', file_format: 'docx' }, + { content_id: 'CT-2026-0004', title: '白皮书 E04 定制版', body_md: '这一单写的正文', file_format: 'docx' }, + ], + }); + expect(saved).toBe(1); + expect(db.writes.join()).toContain('CT-2026-0004'); + expect(db.writes.join()).not.toContain('CT-2026-0001'); + }); + + it('这一单一行也没碰过:表里再多行也存 0 份', async () => { + const db = fakeDb(); + const saved = await archiveAssetFiles({ + db, + taskId: TASK, + ids, + declarations: [ASSET], + touched: [], + rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }], + }); + expect(saved).toBe(0); + }); + + it('别的表碰过不算这张表碰过 —— 地址得对上', async () => { + const db = fakeDb(); + const saved = await archiveAssetFiles({ + db, + taskId: TASK, + ids, + declarations: [ASSET], + touched: [{ domain: 'content_brand', object: 'requirement', key: { content_id: 'CT-1' } }], + rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }], + }); + expect(saved).toBe(0); + }); + it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => { const db = fakeDb(); const saved = await archiveAssetFiles({ @@ -79,6 +135,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { taskId: TASK, ids, declarations: [ASSET], + touched: touch('CT-1'), rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }], }); expect(saved).toBe(0); @@ -92,6 +149,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { taskId: TASK, ids, declarations: [ASSET], + touched: touch(''), rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }], }); expect(saved).toBe(0); @@ -104,6 +162,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { taskId: TASK, ids, declarations: [{ ...ASSET, file: undefined }], + touched: touch('CT-1'), rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }], }); expect(saved).toBe(0); @@ -116,6 +175,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { taskId: TASK, ids, declarations: [ASSET], + touched: touch('CT-1'), rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }], }); expect(saved).toBe(1); @@ -125,7 +185,32 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => { it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => { const db = fakeDb(); expect( - await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }), + await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], touched: [], rowsOfAddress: async () => [] }), ).toBe(0); }); }); + +describe('assetRowsOf —— 存档与工作成果同一段', () => { + it('值取合并行那一份:这轮只改了标题,正文仍按落库后的样子造', () => { + const picked = assetRowsOf( + ASSET, + [{ content_id: 'CT-1', title: '改过的标题', body_md: '早先写下的正文', file_format: 'docx' }], + touch('CT-1'), + ); + expect(picked).toEqual([ + { + address: 'content.asset', + key: { content_id: 'CT-1' }, + title: '改过的标题', + format: 'docx', + markdown: '早先写下的正文', + }, + ]); + }); + + it('标题空着:退主键,不摆一份没名字的资料', () => { + const picked = assetRowsOf(ASSET, [{ content_id: 'CT-1', title: ' ', body_md: '正文' }], touch('CT-1')); + expect(picked[0]?.title).toBe('CT-1'); + expect(picked[0]?.format).toBe('md'); + }); +}); diff --git a/apps/api/src/app/__tests__/auth-middleware.test.ts b/apps/api/src/app/__tests__/auth-middleware.test.ts new file mode 100644 index 0000000..5881c31 --- /dev/null +++ b/apps/api/src/app/__tests__/auth-middleware.test.ts @@ -0,0 +1,164 @@ +/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import type { Position, Principal, Surface } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts'; +import type { AppEnv } from '../http-env.ts'; +import { buildRouteRegistry } from '../route-registry.ts'; +import { createTicketIssuer } from '../ticket.ts'; + +const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; +const principal = (ends: Surface[], roles: string[]): Principal => ({ + staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles }, + position, + roles, + ends, +}); +const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']); +const USER = principal(['workbench'], ['user']); +const NOBODY = principal([], ['auditor']); + +const tickets = createTicketIssuer(Buffer.alloc(32, 3)); +const now = new Date('2026-08-25T09:00:00Z'); + +function makeApp(): Hono { + const app = new Hono(); + app.use( + '*', + createAuthMiddleware({ + registry: buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/desk/tasks', access: 'workbench' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, + ]), + org: { + authenticate: async (token) => { + if (token === 'admin') return ADMIN; + if (token === 'user') return USER; + if (token === 'nobody') return NOBODY; + if (token === 'disabled') throw new AccountDisabledError('alice'); + throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'); + }, + principalOf: async (id) => (id === 'sub-1' ? USER : undefined), + }, + tickets, + clock: { now: () => now }, + }), + ); + app.get('/api/health', (c) => c.json({ ok: true })); + app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null })); + app.get('/api/desk/tasks', (c) => c.json({ ok: true })); + app.get('/api/gov/tasks', (c) => c.json({ ok: true })); + app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true })); + app.get('/api/gov/staff', (c) => c.json({ ok: true })); + return app; +} + +const app = makeApp(); +// app.request() 的类型签名是 Response | Promise(Hono 4.12);包一层 Promise.resolve +// 只是把类型收窄成调用方好用的 Promise,不改变行为(await 对两者结果一致)。 +const get = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { headers })); +const post = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { method: 'POST', headers })); +const head = (path: string, headers: Record = {}): Promise => + Promise.resolve(app.request(path, { method: 'HEAD', headers })); +type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; + +describe('鉴权中间件', () => { + it('public 不要凭据', async () => { + expect((await get('/api/health')).status).toBe(200); + }); + it('没凭据 → 401', async () => { + const res = await get('/api/me'); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' }); + }); + it('坏 token → 401 带人话', async () => { + const res = await get('/api/me', { authorization: 'Bearer garbage' }); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('签名校验失败'); + }); + it('any:登录即可,连无端可进的人也行', async () => { + const res = await get('/api/me', { authorization: 'Bearer nobody' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ account: 'alice' }); + }); + it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => { + const res = await get('/api/gov/tasks', { authorization: 'Bearer user' }); + expect(res.status).toBe(403); + const body = (await res.json()) as Body; + expect(body.code).toBe('forbidden'); + expect(body.message).toContain('运营中心'); + expect(body.message).toContain('工作台'); + expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] }); + }); + it('端够了 → 放行;数组归属任一命中即可', async () => { + expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('账号停用 → 403(重新登录没用)', async () => { + const res = await get('/api/me', { authorization: 'Bearer disabled' }); + expect(res.status).toBe(403); + expect(((await res.json()) as Body).message).toContain('停用'); + }); + it('票据:GET 上认,POST 上不认', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401); + }); + it('过期票据 → 401 指路重取', async () => { + const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000)); + const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('/api/auth/ticket'); + }); + it('Bearer 与票据同时在:Bearer 说了算', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); + }); + it('HEAD 按 GET 判:端不够 → 403,与 GET 同款', async () => { + // Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 c.req.method 仍是 'HEAD' —— + // 不归一的话查表必然 miss、落进「any」兜底,于是「只要登录就能跑任何 GET 处理器」。 + expect((await get('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); + expect((await head('/api/gov/tasks', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('HEAD 该进的还得进:端够了 → 200', async () => { + expect((await head('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + }); + it('HEAD 上的票据与 GET 同款(归一之后两条路一致)', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await head(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await head('/api/gov/tasks', {})).status).toBe(401); + }); + it('未知路由:没凭据先 401,有凭据再 404', async () => { + expect((await get('/api/nope')).status).toBe(401); + expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); + }); +}); + +describe('小件', () => { + it('bearerOf 认大小写与空白', () => { + expect(bearerOf('bearer abc')).toBe('abc'); + expect(bearerOf('Bearer abc ')).toBe('abc'); + expect(bearerOf('Basic abc')).toBeUndefined(); + expect(bearerOf(undefined)).toBeUndefined(); + expect(bearerOf('Bearer ')).toBeUndefined(); + }); + it('requiredEnds', () => { + expect(requiredEnds('public')).toBeUndefined(); + expect(requiredEnds('any')).toBeUndefined(); + expect(requiredEnds('ops')).toEqual(['ops']); + expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']); + }); + it('forbiddenBody 对无端可进的人如实说', () => { + expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进'); + }); +}); diff --git a/apps/api/src/app/__tests__/config-keycloak.test.ts b/apps/api/src/app/__tests__/config-keycloak.test.ts new file mode 100644 index 0000000..1dda212 --- /dev/null +++ b/apps/api/src/app/__tests__/config-keycloak.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { loadConfig } from '../config.ts'; + +describe('Keycloak 配置(ADR-012)', () => { + it('三样齐了才有 keycloak 段;尾斜杠去掉;issuer / jwks 拼好', () => { + const c = loadConfig({ KEYCLOAK_URL: 'https://kc.test/', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'api-gateway' }); + expect(c.keycloak).toEqual({ + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', + }); + }); + it('缺一样就没有 keycloak 段(main.ts 据此拒绝启动)', () => { + expect(loadConfig({ KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp' }).keycloak).toBeUndefined(); + expect(loadConfig({ KEYCLOAK_URL: ' ', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }).keycloak).toBeUndefined(); + }); + it('provisioning 两样都给才带', () => { + const base = { KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }; + expect( + loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning', KEYCLOAK_PROVISIONING_CLIENT_SECRET: 's3' }).keycloak + ?.provisioning, + ).toEqual({ clientId: 'provisioning', secret: 's3' }); + expect(loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning' }).keycloak?.provisioning).toBeUndefined(); + }); + it('测试账号逗号分隔、去空白、丢空项', () => { + expect(loadConfig({ JCAI_TEST_ACCOUNTS: ' ai, chaihao ,, ' }).testAccounts).toEqual(['ai', 'chaihao']); + expect(loadConfig({}).testAccounts).toEqual([]); + }); +}); diff --git a/apps/api/src/app/__tests__/keycloak-check.test.ts b/apps/api/src/app/__tests__/keycloak-check.test.ts new file mode 100644 index 0000000..c728f2d --- /dev/null +++ b/apps/api/src/app/__tests__/keycloak-check.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import type { KeycloakConfig } from '../config.ts'; +import { checkKeycloakConnection } from '../keycloak-check.ts'; + +const kc: KeycloakConfig = { + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', +}; +const reply = (status: number, body: unknown): typeof fetch => async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('启动自检(手册第 5 步)', () => { + it('issuer 一致 → 回 issuer 与 clientId', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: kc.issuer }))).resolves.toEqual({ + issuer: kc.issuer, + clientId: 'api-gateway', + }); + }); + it('issuer 不一致 → 抛,话里带期望 / 实际 / KC_HOSTNAME', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: 'http://localhost:8080/realms/corp' }))).rejects.toThrow( + /KC_HOSTNAME/, + ); + }); + it('发现文档拉不到 → 抛带状态码', async () => { + await expect(checkKeycloakConnection(kc, reply(503, {}))).rejects.toThrow(/503/); + }); +}); diff --git a/apps/api/src/app/__tests__/mail-poller.test.ts b/apps/api/src/app/__tests__/mail-poller.test.ts new file mode 100644 index 0000000..1e9be32 --- /dev/null +++ b/apps/api/src/app/__tests__/mail-poller.test.ts @@ -0,0 +1,47 @@ +/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MailPollOutcome } from '@contracts'; +import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts'; + +function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) { + const calls: Date[] = []; + return { + calls, + mail: { + pollDue: async (now: Date) => { + calls.push(now); + if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs)); + return outcomes; + }, + }, + }; +} + +describe('pollMailOnce', () => { + it('回用例的结果;出错的账号写一行 stderr', async () => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]); + const out = await pollMailOnce({ container: { mail: mail as never } }); + expect(out).toHaveLength(2); + expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上')); + write.mockRestore(); + }); +}); + +describe('startMailPoller', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('按 tick 调;在跑时跳过;stop 后停', async () => { + const { mail, calls } = fakeMail([], 1000); + const poller = startMailPoller({ container: { mail: mail as never } }, 100); + await vi.advanceTimersByTimeAsync(350); + expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过 + await vi.advanceTimersByTimeAsync(1000); + expect(calls.length).toBeGreaterThanOrEqual(2); + poller.stop(); + const before = calls.length; + await vi.advanceTimersByTimeAsync(2000); + expect(calls).toHaveLength(before); + }); +}); diff --git a/apps/api/src/app/__tests__/mail-routes-registry.test.ts b/apps/api/src/app/__tests__/mail-routes-registry.test.ts new file mode 100644 index 0000000..10a7776 --- /dev/null +++ b/apps/api/src/app/__tests__/mail-routes-registry.test.ts @@ -0,0 +1,32 @@ +/** + * 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞), + * 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决:设计端没有消费方,不再无故加宽)。 + */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { MAIL_ROUTES } from '@contracts'; +import { buildRouteRegistry, routesOf } from '../route-registry.ts'; + +describe('邮件路由契约', () => { + it('15 条全在契约命名空间里,注册表能建起来', () => { + const all = routesOf(Object.values(contracts)); + const registry = buildRouteRegistry(all); + expect(MAIL_ROUTES).toHaveLength(15); + for (const route of MAIL_ROUTES) { + expect(registry.has(route.method, route.path)).toBe(true); + } + }); + + it('读写都归 ops', () => { + for (const route of MAIL_ROUTES) { + expect(route.access).toBe('ops'); + } + }); + + it('未存草稿探测与已存账号探测是两条不同的路', () => { + const registry = buildRouteRegistry(routesOf(Object.values(contracts))); + expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops'); + expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops'); + expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toBe('ops'); + }); +}); diff --git a/apps/api/src/app/__tests__/mail-routes.test.ts b/apps/api/src/app/__tests__/mail-routes.test.ts new file mode 100644 index 0000000..ee2578b --- /dev/null +++ b/apps/api/src/app/__tests__/mail-routes.test.ts @@ -0,0 +1,119 @@ +/** + * 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。 + * 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。 + */ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vitest'; +import { createMailService, createPostalMimeParser } from '@modules/mail'; +import type { AppEnv } from '../http-env.ts'; +import { registerMailRoutes } from '../routes/mail.ts'; +import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const imap = fakeProvider('imap-smtp'); + const mail = createMailService({ + accounts, + inbound: memoryInbound(), + outbound: memoryOutbound(), + attachments: memoryAttachments(), + providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('x'), + notify: { changed: () => undefined }, + }); + const app = new Hono(); + registerMailRoutes(app, { mail }); + const json = (method: string, path: string, body?: unknown) => + app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + return { app, mail, accounts, imap, json }; +} +const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }; + +describe('账号路由', () => { + it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => { + const h = harness(); + expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] }); + expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400); + const created = await h.json('POST', '/api/gov/mail/accounts', input); + expect(created.status).toBe(200); + const view = (await created.json()) as { id: string; secretBound: boolean }; + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('pw-1234'); + expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409); + expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200); + expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404); + expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200); + expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true }); + expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404); + }); + + it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => { + const h = harness(); + expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412); + const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string }; + expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200); + h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } }); + const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } }); + }); + + it('立即拉取:200 outcome;停用 412;租约被占 409', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 }); + await h.accounts.save(account({ enabled: false })); + expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412); + // 租约是**运行列**:`save()` 只写配置列(整行 upsert 会盖掉另一台正持着的租约), + // 所以按真路径占它 —— claimPolling 就是另一台机器那一下 + await h.accounts.save(account()); + expect(await h.accounts.claimPolling('acc-1', 60_000)).toBe(true); + expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409); + }); + + it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => { + const h = harness(); + await h.accounts.save(account()); + const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' }); + expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '' }); + h.imap.failSendWith(new Error('535 nope')); + expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' }); + expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400); + }); +}); + +describe('入站 / 出站路由', () => { + it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => { + const h = harness(); + await h.accounts.save(account()); + h.mail.subscribe('spy', async () => undefined); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } }))); + await h.json('POST', '/api/gov/mail/accounts/acc-1/poll'); + const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> }; + expect(list.mails).toHaveLength(1); + expect(list.mails[0]?.deliveries).toHaveLength(1); + const id = list.mails[0]!.id; + expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200); + expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404); + const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string }; + expect(att.name).toBe('a.txt'); + expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi'); + expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404); + const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string }; + expect(raw.contentType).toBe('message/rfc822'); + expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] }); + expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404); + expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400); + expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] }); + }); +}); + +/** 空 body 的 POST:redeliver 的 body 是可省的 */ +function app_request_redeliver(h: ReturnType, id: string): Promise { + return Promise.resolve(h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' })); +} diff --git a/apps/api/src/app/__tests__/mail-wiring.test.ts b/apps/api/src/app/__tests__/mail-wiring.test.ts new file mode 100644 index 0000000..4b5b64c --- /dev/null +++ b/apps/api/src/app/__tests__/mail-wiring.test.ts @@ -0,0 +1,172 @@ +/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */ +import { describe, expect, it } from 'vitest'; +import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts'; +import type { InboundMail } from '@contracts'; +import { auditPassed, checkEvolution, staticAudit } from '@modules/toolbox'; +import type { ToolContract } from '@modules/toolbox'; +import { threadIdOf } from '@modules/mail'; +import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts'; +import { MAIL_TOOL } from '../seed-mvp.ts'; + +const detail: MailInboundDetail = { + id: 'in-1', + accountId: 'acc-1', + messageId: '', + from: { address: 'buyer@acme.com', name: '张三' }, + to: [{ address: 'sales@we.com', name: '' }], + cc: [{ address: 'ops@we.com', name: 'Ops' }], + subject: '询价 RFQ-1', + sentAt: '2026-08-25T09:00:00.000Z', + receivedAt: '2026-08-25T09:30:00.000Z', + textPreview: '你好', + hasHtml: true, + attachments: [ + { index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }, + { index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' }, + ], + deliveries: [], + inReplyTo: null, + references: ['', ''], + text: null, + html: '

你好

', + size: 100, + rawAvailable: true, +}; + +function fakeSend(result: Partial = {}): { send: (input: MailSendInput) => Promise; calls: MailSendInput[] } { + const calls: MailSendInput[] = []; + return { + calls, + send: async (input) => { + calls.push(input); + return { outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', ...result }; + }, + }; +} + +describe('toInboundMail', () => { + it('线程取 References 首项(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => { + const mail: InboundMail = toInboundMail(detail); + // 不是末项 :我方出站登记的恒是首封,①级归属是精确等值比对 —— + // 取最近一跳的话,跑一个来回(我方再回一封)线程就漂到库里没记过的 Message-ID 上,①级落空退③级 + expect(mail.threadId).toBe(''); + expect(mail.from).toBe('buyer@acme.com'); + expect(mail.to).toEqual(['sales@we.com']); + expect(mail.cc).toEqual(['ops@we.com']); + expect(mail.body).toBe('你好'); + expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]); + expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z'); + }); + + it('线程口径只有一处:这里就是 mail 模块的 threadIdOf,不是另抄的一份', () => { + // 从前这里内联了一份同义实现,而模块导出的 threadIdOf 取的是**最近一跳** —— + // 同一个模块两套相反的语义,下一个人照导出的那个接线就会接反 + expect(toInboundMail(detail).threadId).toBe(threadIdOf(detail)); + const onlyHop: MailInboundDetail = { ...detail, references: [], inReplyTo: '' }; + expect(toInboundMail(onlyHop).threadId).toBe(threadIdOf(onlyHop)); + expect(toInboundMail(onlyHop).threadId).toBe(''); + const none: MailInboundDetail = { ...detail, references: [], inReplyTo: null }; + expect(toInboundMail(none).threadId).toBeUndefined(); + }); +}); + +describe('channelSubscriber', () => { + it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => { + const calls: Array<[string, InboundMail]> = []; + const handler = channelSubscriber({ + ingestMail: async (id, mail) => { + calls.push([id, mail]); + return undefined; + }, + }); + expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' }); + expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined(); + expect(calls[0]?.[0]).toBe('ch-1'); + expect(calls[0]?.[1].messageId).toBe(''); + }); +}); + +describe('channelTransport', () => { + it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => { + const mail = fakeSend(); + const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '', attachments: [{ name: 'q.pdf', ref: 'up-9' }] }); + expect(sent).toEqual({ messageId: '', threadId: '' }); + expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '', references: [''], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } }); + const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] }); + expect(fresh.threadId).toBe(''); + }); + it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => { + await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed'); + }); +}); + +describe('mailSendTool', () => { + const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' }; + it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => { + const mail = fakeSend(); + const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }] } }); + expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '', state: 'sent' } }); + expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '', references: [''], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } }); + await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); + expect(mail.calls[1]?.accountId).toBeUndefined(); + }); + it('没给 uploadRef 的附件不静默丢掉:整次调用判 invalid,一封都不发', async () => { + // 模块那一层本就是硬的(既没字节也没 ref 就抛 MailSendInvalidError);app 层不许比它松 —— + // 悄悄丢掉附件再回 queued:true,等于让界面撒谎 + const mail = fakeSend(); + const result = await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } }); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('invalid'); + expect(result.error?.retryable).toBe(false); + expect(result.error?.message).toContain('no-ref'); + expect(mail.calls).toHaveLength(0); + }); + it('dryRun 只校验不发', async () => { + const mail = fakeSend(); + expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } }); + expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false); + expect(mail.calls).toHaveLength(0); + }); + it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => { + const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); + expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } }); + const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail'); + const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); + expect(noAccount.error?.code).toBe('no-account'); + const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } }); + expect(invalid.error?.code).toBe('invalid'); + }); +}); + +describe('mail.send 2.0.0 的演进', () => { + /* + * **对真契约断言,不对手抄的夹具**。上一版这里的 v1/v2 都是照着 `seed-mvp.ts` 手抄的两份 JSON: + * 改真的那一份它不会红(自证),而且抄的 v1 版本号还写成了 `1.0.0` —— + * 真前一版是 `1.1.0`(MAIL_TOOL 展开 HISTORY_TOOL,版本跟着它)。 + * v1 是从 git 里那一版逐字取回来的**历史快照**(只有它必须手写:那份代码已经不在仓里了)。 + */ + const v1: ToolContract = { + ...MAIL_TOOL, + version: '1.1.0', + description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界', + input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } }, + output: { type: 'object', properties: { queued: { type: 'boolean' } } }, + connectionKeys: ['endpoint'], + }; + + it('真契约就是 2.0.0,且过得了静态审', () => { + expect(MAIL_TOOL.name).toBe('mail.send'); + expect(MAIL_TOOL.version).toBe('2.0.0'); + expect(auditPassed(staticAudit(MAIL_TOOL))).toBe(true); + }); + + it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => { + expect(checkEvolution(v1, MAIL_TOOL)).toEqual([]); + // 反证这条断言真的在看东西:真把 v1 的 body 删掉就该报违规 + const dropped: ToolContract = { + ...MAIL_TOOL, + input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'array' }, subject: { type: 'string' } } }, + }; + expect(checkEvolution(v1, dropped).map((f) => f.field)).toContain('input.body'); + }); +}); diff --git a/apps/api/src/app/__tests__/new-package-flow.it.test.ts b/apps/api/src/app/__tests__/new-package-flow.it.test.ts index 9b58b87..2877922 100644 --- a/apps/api/src/app/__tests__/new-package-flow.it.test.ts +++ b/apps/api/src/app/__tests__/new-package-flow.it.test.ts @@ -80,12 +80,14 @@ beforeAll(async () => { await pool.query(`create schema "${schema}"`); } await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); - container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); await container.org.ensureSeeded(); // 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行 await seedMvp(container); - const { staff } = await container.org.listStaff(); - chaihao = staff.find((s) => s.account === 'Chaihao')!.id; + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { diff --git a/apps/api/src/app/__tests__/route-coverage.it.test.ts b/apps/api/src/app/__tests__/route-coverage.it.test.ts new file mode 100644 index 0000000..4117363 --- /dev/null +++ b/apps/api/src/app/__tests__/route-coverage.it.test.ts @@ -0,0 +1,96 @@ +/** + * 路由覆盖(fail-closed,ADR-012):Hono 里每一条注册了的路由都得在路由注册表里有归属。 + * 漏一条 = 一个没人管的口子,这里把它变成测试红。要真容器只是为了把路由都挂上,不发请求。 + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from '../config.ts'; +import { createContainer } from '../container.ts'; +import type { Container } from '../container.ts'; +import { appRouteRegistry, createServer } from '../server.ts'; + +const IT_DB = 'jcai_it_routes'; + +async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { + const explicit = process.env['JCAI_TEST_DATABASE_URL']; + if (explicit !== undefined) return { url: explicit, stop: async () => undefined }; + const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; + try { + const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); + const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); + if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); + await admin.end(); + return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; + } catch { + /* 开发实例连不上,试 Testcontainers */ + } + try { + const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); + const started = await new PostgreSqlContainer('postgres:16-alpine').start(); + return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; + } catch (error) { + process.stderr.write( + `⚠️ 路由覆盖 it 跳过 —— **这道 fail-closed 的安全保险这一次没有跑**:` + + `「写了路由处理器却没有契约声明 access」这一类漏网,本次构建没有检查。\n` + + ` 连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, + ); + return undefined; + } +} + +function migrationsFolder(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i += 1) { + const candidate = join(dir, 'db', 'migrations'); + if (existsSync(candidate)) return candidate; + const parent = resolve(dir, '..'); + if (parent === dir) break; + dir = parent; + } + throw new Error('找不到 db/migrations'); +} + +const database = await provisionDatabase(); +let pool: Pool | undefined; +let container: Container | undefined; + +beforeAll(async () => { + if (database === undefined) return; + pool = new Pool({ connectionString: database.url, max: 4 }); + for (const schema of ['sys', 'ledger', 'mastra', 'queue']) { + await pool.query(`drop schema if exists "${schema}" cascade`); + await pool.query(`create schema "${schema}"`); + } + await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); + container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); +}, 180_000); + +afterAll(async () => { + await container?.close(); + await pool?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('路由覆盖', () => { + it('Hono 里每条注册的路由都在归属注册表里', () => { + const app = createServer(container!); + const registry = appRouteRegistry(); + const registered = app.routes.filter((r) => r.method !== 'ALL'); + + /* + * 先钉下界:`missing` 为空有两种可能 —— 真的一条不漏,或者**根本没数到路由** + * (createServer 改用子应用组合、Hono 换了 routes 的形状、建 server 中途早退)。 + * 空集是任何集合的子集,不设下界的话后一种情况会伪装成前一种, + * 这道保险就从「会红」变成「永远绿」。 + */ + expect(registered.length, '一条路由都没数到 —— 先查 createServer 与 app.routes 的形状').toBeGreaterThan(50); + + const missing = registered.filter((r) => !registry.has(r.method, r.path)).map((r) => `${r.method} ${r.path}`); + expect(missing, '这些路由没有声明归哪个端(契约加 access,或 EXTRA_ROUTES 补入)').toEqual([]); + }); +}); diff --git a/apps/api/src/app/__tests__/route-registry.test.ts b/apps/api/src/app/__tests__/route-registry.test.ts new file mode 100644 index 0000000..734cf4c --- /dev/null +++ b/apps/api/src/app/__tests__/route-registry.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { buildRouteRegistry, isRouteContract, routesOf } from '../route-registry.ts'; + +const registry = buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/testbed/logs', access: 'design' }, + { method: 'GET', path: '/api/gov/testbed/logs/:id', access: 'design' }, + { method: 'POST', path: '/api/gov/process/validate', access: 'design' }, + { method: 'POST', path: '/api/gov/process/:id/:version/state', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, +]); + +describe('路由注册表', () => { + it('字面路径精确命中', () => { + expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops'); + expect(registry.lookup('GET', '/api/health')).toBe('public'); + }); + it('参数段:一段认一段,多一段少一段都不算', () => { + expect(registry.lookup('POST', '/api/gov/tasks/t-1/control')).toBe('ops'); + expect(registry.lookup('POST', '/api/gov/tasks/t-1/x/control')).toBeUndefined(); + expect(registry.lookup('POST', '/api/gov/process/p1/3/state')).toBe('design'); + }); + it('方法不同不混', () => { + expect(registry.lookup('POST', '/api/gov/tasks')).toBeUndefined(); + expect(registry.lookup('GET', '/api/gov/tasks/t-1/control')).toBeUndefined(); + }); + it('字面优先于参数段(logs 与 logs/:id 各归各)', () => { + expect(registry.lookup('GET', '/api/gov/testbed/logs')).toBe('design'); + expect(registry.lookup('GET', '/api/gov/testbed/logs/abc')).toBe('design'); + expect(registry.lookup('GET', '/api/gov/testbed/logs/abc/def')).toBeUndefined(); + }); + it('未知路由 → undefined', () => { + expect(registry.lookup('GET', '/api/nope')).toBeUndefined(); + }); + it('只认五个 HTTP 方法:HEAD 查不到 —— 归一是中间件的职责,不是表的', () => { + // 框架把 HEAD 改写成 GET 再分发,而请求行上的方法仍是 HEAD; + // 表只按 HTTP_METHODS 装,所以这里如实回 undefined。 + // 这条钉住的是分工:哪天有人把中间件里那行归一挪走,鉴权就漏了,而这里仍然该是 undefined。 + expect(registry.lookup('HEAD', '/api/gov/tasks')).toBeUndefined(); + expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops'); + }); + it('数组归属原样回', () => { + expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); + }); + it('has 认的是注册时的模版,lookup 认的是实际路径', () => { + expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true); + expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false); + expect(registry.has('GET', '/api/health')).toBe(true); + expect(registry.size).toBe(9); + }); + it('参数名换一个:lookup 还是能匹配(参数段认任意值),has 就说没有', () => { + // 注册的模版是 `:taskId`,lookup('/api/gov/tasks/:otherName/control') 的 + // `:otherName` 满足参数段的 [^/]+ 正则,lookup 会命中; + // 但 has 只认注册时的模版名,两者语义不同 + expect(registry.lookup('POST', '/api/gov/tasks/:otherName/control')).toBe('ops'); + expect(registry.has('POST', '/api/gov/tasks/:otherName/control')).toBe(false); + }); +}); + +describe('字面优先于参数段 —— 同一个路径两边都能匹配时', () => { + const collide = buildRouteRegistry([ + { method: 'GET', path: '/api/gov/:zone', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, + ]); + it('字面赢', () => { + expect(collide.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); + }); + it('没有字面可命中时才落到参数段', () => { + expect(collide.lookup('GET', '/api/gov/tools')).toBe('design'); + }); +}); + +describe('routesOf —— 从契约命名空间挑路由', () => { + it('只认长得像路由契约的导出', () => { + expect(isRouteContract(contracts.meRoute)).toBe(true); + expect(isRouteContract(contracts.staffSchema)).toBe(false); + expect(isRouteContract('x')).toBe(false); + expect(isRouteContract(null)).toBe(false); + }); + it('扫真契约至少 90 条,且每条都能 has 到自己', () => { + const all = routesOf(Object.values(contracts)); + expect(all.length).toBeGreaterThanOrEqual(90); + const reg = buildRouteRegistry(all); + for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true); + }); +}); + +describe('建表自检 —— 两条模版能撞就不许建', () => { + const reg = (routes: Parameters[0]) => () => buildRouteRegistry(routes); + it('同方法、同段数、逐位兼容 → 抛错', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'GET', path: '/api/:y/b', access: 'design' }, + ]), + ).toThrow(/同一个具体路径/); + }); + it('方法不同不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'POST', path: '/api/:y/b', access: 'design' }, + ]), + ).not.toThrow(); + }); + it('段数不同不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'GET', path: '/api/:y/b/c', access: 'design' }, + ]), + ).not.toThrow(); + }); + it('字面段对不上不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/a/:x', access: 'ops' }, + { method: 'GET', path: '/api/b/:y', access: 'design' }, + ]), + ).not.toThrow(); + }); + it('字面与参数并存不算撞 —— 那是有意的优先级', () => { + expect( + reg([ + { method: 'GET', path: '/api/gov/:zone', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: 'ops' }, + ]), + ).not.toThrow(); + }); + it('同一条字面路由注册两遍 → 抛(后写的会静默盖掉先写的)', () => { + expect( + reg([ + { method: 'GET', path: '/api/gov/staff', access: 'ops' }, + { method: 'GET', path: '/api/gov/staff', access: 'design' }, + ]), + ).toThrow(/注册了两遍/); + }); + it('同路径不同方法照旧不算撞', () => { + expect( + reg([ + { method: 'GET', path: '/api/gov/staff', access: 'ops' }, + { method: 'POST', path: '/api/gov/staff', access: 'ops' }, + ]), + ).not.toThrow(); + }); + it('真实 95 条契约路由建得起来(没有暗撞)', () => { + expect(() => buildRouteRegistry(routesOf(Object.values(contracts)))).not.toThrow(); + }); +}); diff --git a/apps/api/src/app/__tests__/testbed-trace.it.test.ts b/apps/api/src/app/__tests__/testbed-trace.it.test.ts index e3d6185..5f86e3b 100644 --- a/apps/api/src/app/__tests__/testbed-trace.it.test.ts +++ b/apps/api/src/app/__tests__/testbed-trace.it.test.ts @@ -77,12 +77,14 @@ beforeAll(async () => { await pool.query(`create schema "${schema}"`); } await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); - container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); await container.org.ensureSeeded(); await seedMvp(container); - const { staff } = await container.org.listStaff(); - chaihao = staff.find((s) => s.account === 'Chaihao')!.id; - ai = staff.find((s) => s.account === 'AI')!.id; + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; + ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { diff --git a/apps/api/src/app/__tests__/ticket.test.ts b/apps/api/src/app/__tests__/ticket.test.ts new file mode 100644 index 0000000..e8eed5c --- /dev/null +++ b/apps/api/src/app/__tests__/ticket.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { TICKET_TTL_MS, createTicketIssuer } from '../ticket.ts'; + +const issuer = createTicketIssuer(Buffer.alloc(32, 7)); +const t0 = new Date('2026-08-25T09:00:00Z'); + +describe('60 秒票据', () => { + it('签出来能验回 sub;形状 v1...;到期时刻 = 签发 + 60s', () => { + const { ticket, expiresAt } = issuer.issue('sub-1', t0); + expect(ticket.split('.')).toHaveLength(4); + expect(ticket.startsWith('v1.')).toBe(true); + expect(expiresAt.getTime() - t0.getTime()).toBe(TICKET_TTL_MS); + expect(issuer.verify(ticket, new Date(t0.getTime() + 30_000))).toEqual({ staffId: 'sub-1' }); + }); + + it('到期即拒(61 秒后)', () => { + const { ticket } = issuer.issue('sub-1', t0); + expect(issuer.verify(ticket, new Date(t0.getTime() + TICKET_TTL_MS + 1000))).toBeUndefined(); + }); + + it('改一个字就拒:mac / sub / exp', () => { + const { ticket } = issuer.issue('sub-1', t0); + const [v, id, exp, mac] = ticket.split('.') as [string, string, string, string]; + expect(issuer.verify(`${v}.${id}.${exp}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`, t0)).toBeUndefined(); + expect(issuer.verify(`${v}.${Buffer.from('sub-2').toString('base64url')}.${exp}.${mac}`, t0)).toBeUndefined(); + expect(issuer.verify(`${v}.${id}.${Number(exp) + 600}.${mac}`, t0)).toBeUndefined(); + }); + + it('别的密钥签的不认', () => { + const other = createTicketIssuer(Buffer.alloc(32, 8)); + expect(issuer.verify(other.issue('sub-1', t0).ticket, t0)).toBeUndefined(); + }); + + it('垃圾输入不抛只拒', () => { + expect(issuer.verify('', t0)).toBeUndefined(); + expect(issuer.verify('v2.a.b.c', t0)).toBeUndefined(); + expect(issuer.verify('a.b', t0)).toBeUndefined(); + expect(issuer.verify('v1...', t0)).toBeUndefined(); + }); +}); diff --git a/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts b/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts index 0cad965..d4edbf4 100644 --- a/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts +++ b/apps/api/src/app/__tests__/unified-quote-flow.it.test.ts @@ -88,12 +88,14 @@ beforeAll(async () => { await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); // 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env - container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); await container.org.ensureSeeded(); await seedMvp(container); - const { staff } = await container.org.listStaff(); - chaihao = staff.find((s) => s.account === 'Chaihao')!.id; + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; }, 180_000); afterAll(async () => { diff --git a/apps/api/src/app/asset-archive.ts b/apps/api/src/app/asset-archive.ts index ca09a7c..4d1addc 100644 --- a/apps/api/src/app/asset-archive.ts +++ b/apps/api/src/app/asset-archive.ts @@ -10,6 +10,9 @@ * 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。 * * 一单一行一份,重复放行覆盖(不留两份说法不一的档)。 + * + * **「这一单产出的」认的是本单碰过的行**,不是这张表里现有的所有行 —— + * 见 `assetRowsOf` 的病根记档。 */ import { sql } from 'drizzle-orm'; import type { TaskId } from '@contracts'; @@ -35,6 +38,71 @@ function rowsOf(result: unknown): T[] { return Array.isArray(rows) ? (rows as T[]) : []; } +/** 一张台账表的声明 —— 带 `file` 段的才产资料;这份声明同时是授权面 */ +export type AssetDeclaration = { + readonly domain: string; + readonly object: string; + readonly fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>; + readonly file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined; +}; + +/** 本单碰过的一行(`ledger.taskDrafts()` 那一份的形状) */ +export type TouchedKey = { + readonly domain: string; + readonly object: string; + readonly key: Readonly>; +}; + +/** 这一单产出的一份资料 —— 还没造成字节的那一步 */ +export type AssetRow = { + readonly address: string; + readonly key: Record; + readonly title: string; + readonly format: FileFormat; + readonly markdown: string; +}; + +/** + * 这一单**产出的**资料行 —— 存档与工作成果两处同一段,不各写一份。 + * + * 「产出的」认的是**本单碰过的行**(草稿键),不是这张表里现有的所有行。 + * 病根记档(2026-08-25 柴浩实测:「工作成果怎么有 4 个资料,这单只会产出一个」): + * 两处从前都拿 `taskView` 的整页,而那是「正式版全表 + 本单草稿」的合并视图 —— + * 资料常落在**主数据**上(品牌内容库 `content.asset`,历次跑出来的资产都躺在里面), + * 于是一单产 1 份、工作成果里摆 4 份:前三份是别单的成果,冒充这一单交出去的东西。 + * 台账契约本就把这两件事分得很清楚(`taskDrafts` 的注释:「引擎求值要区分 + * 『这一单的事实』与『别人放行后的事实』」),视图模版也一律绑 `mine:` —— + * 只有资料这两处忘了。 + * + * 值仍取合并视图那一份(正式现值 + 本单草稿叠加):改过一格的行要按 + * 「落库后的样子」造文件,只拿草稿会缺掉这轮没碰过的那几列(与 `touched:` 同一道理)。 + */ +export function assetRowsOf( + declared: AssetDeclaration, + rows: ReadonlyArray>, + touched: ReadonlyArray, +): AssetRow[] { + const spec = declared.file; + if (spec === undefined) return []; // 没声明 `file` 段的表不产资料 —— 存档与列表都认这道授权面 + const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name); + if (keys.length === 0) return []; + const mine = touched.filter((t) => t.domain === declared.domain && t.object === declared.object); + const address = `${declared.domain}.${declared.object}`; + const out: AssetRow[] = []; + for (const row of rows) { + const key: Record = {}; + for (const k of keys) key[k] = String(row[k] ?? ''); + if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回 + if (!mine.some((t) => keys.every((k) => String(t.key[k] ?? '') === key[k]))) continue; // 别单的成果不算这一单的 + const markdown = String(row[spec.bodyColumn] ?? '').trim(); + if (markdown === '') continue; // 没正文的行不是资料,别摆一份空壳 + const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-'); + const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase(); + out.push({ address, key, title, format: isFileFormat(rawFormat) ? rawFormat : 'md', markdown }); + } + return out; +} + /** * 主键 → 一个**稳定的**字符串(按列名排序后拼)。 * 稳定是要害:存的时候与取的时候拼法不一样,那一行就永远对不上。 @@ -111,7 +179,7 @@ export async function readArchived( * 把这一单产出的资料**全部存一份**。放行落地之前调 —— 那时草稿还在、人的修订也已落回, * 存下来的正是他签字那一刻的样子。 * - * 返回存了几份;一份都没有(活动没声明 `file` 段、或一行都没写)时返回 0,不报错 —— + * 返回存了几份;一份都没有(活动没声明 `file` 段、或这一单一行也没写)时返回 0,不报错 —— * 大多数活动本来就不产资料文件。 */ export async function archiveAssetFiles(input: { @@ -119,40 +187,25 @@ export async function archiveAssetFiles(input: { readonly taskId: TaskId; readonly ids: { next(): string }; /** 活动清单声明的台账对象(带 `file` 段的才算) */ - readonly declarations: ReadonlyArray<{ - domain: string; - object: string; - fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>; - file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined; - }>; - /** 本单在该地址写下的行(正式版 + 本单草稿) */ + readonly declarations: ReadonlyArray; + /** + * **本单碰过的行** —— `ledger.taskDrafts()` 那一份。 + * 「哪几行是这一单产出的」只有这一个答案:表里现有的行不作数(见 `assetRowsOf`)。 + */ + readonly touched: ReadonlyArray; + /** 该地址的合并行(正式现值 + 本单草稿)—— 值按「落库后的样子」造文件 */ readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise>>; }): Promise { let saved = 0; for (const declared of input.declarations) { - const spec = declared.file; - if (spec === undefined) continue; - const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name); - if (keys.length === 0) continue; - + if (declared.file === undefined) continue; // 整张表不产资料,连取行都免了 const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object }); - for (const row of rows) { - const markdown = String(row[spec.bodyColumn] ?? '').trim(); - if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳 - const key: Record = {}; - for (const k of keys) key[k] = String(row[k] ?? ''); - if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回 - - const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-'); - const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase(); - const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md'; - const composed = composeFile({ title, markdown, format }); - const address = `${declared.domain}.${declared.object}`; - + for (const asset of assetRowsOf(declared, rows, input.touched)) { + const composed = composeFile({ title: asset.title, markdown: asset.markdown, format: asset.format }); await input.db.execute(sql` insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size) - values (${input.ids.next()}, ${input.taskId}, ${address}, ${JSON.stringify(key)}::jsonb, - ${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length}) + values (${input.ids.next()}, ${input.taskId}, ${asset.address}, ${JSON.stringify(asset.key)}::jsonb, + ${asset.title}, ${asset.format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length}) on conflict (task_id, address, row_key) do update set title = excluded.title, format = excluded.format, name = excluded.name, bytes = excluded.bytes, size = excluded.size, created_at = now()`); diff --git a/apps/api/src/app/auth-middleware.ts b/apps/api/src/app/auth-middleware.ts new file mode 100644 index 0000000..0501282 --- /dev/null +++ b/apps/api/src/app/auth-middleware.ts @@ -0,0 +1,111 @@ +/** + * 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。 + * + * ① 查路由注册表拿 access;public 直接放行。 + * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource /
的路)。 + * 方法先归一(HEAD 按 GET),再查表、再判票据 —— 两处都按分发时的方法算。 + * ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。 + * ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。 + * 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。 + */ +import type { MiddlewareHandler } from 'hono'; +import type { ApiError, Principal, RouteAccess, StaffId, Surface } from '@contracts'; +import { SURFACE_LABEL, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import type { AppEnv } from './http-env.ts'; +import type { RouteRegistry } from './route-registry.ts'; +import type { TicketIssuer } from './ticket.ts'; + +/** org 出口里中间件用到的那两口 —— 窄接口,测试好伪造 */ +export type Authenticator = { + authenticate(token: string): Promise; + principalOf(staffId: StaffId): Promise; +}; + +export type AuthMiddlewareDeps = { + readonly registry: RouteRegistry; + readonly org: Authenticator; + readonly tickets: TicketIssuer; + readonly clock: { now(): Date }; +}; + +export function bearerOf(header: string | undefined): string | undefined { + if (header === undefined) return undefined; + const match = /^bearer\s+(\S+)\s*$/i.exec(header.trim()); + return match?.[1]; +} + +/** access 要求的端;public / any 不要求 */ +export function requiredEnds(access: RouteAccess): readonly Surface[] | undefined { + if (access === 'public' || access === 'any') return undefined; + return typeof access === 'string' ? [access] : access; +} + +export function forbiddenBody(need: readonly Surface[], principal: Principal): ApiError { + const needText = need.map((s) => `「${SURFACE_LABEL[s]}」`).join(' / '); + const roles = principal.roles.length === 0 ? '无' : principal.roles.join(', '); + const can = principal.ends.length === 0 ? '无端可进' : principal.ends.map((s) => SURFACE_LABEL[s]).join('、'); + return { + code: 'forbidden', + message: `进入${needText}需要授权;你的角色(${roles})可进:${can}`, + detail: { need: [...need], ends: [...principal.ends] }, + }; +} + +const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated', message }); + +export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler { + return async (c, next) => { + c.set('principal', undefined); + /* + * HEAD 按 GET 判。Hono 在 #dispatch 里把 HEAD 改写成 GET 再分发,而 `c.req.method` + * 读的是请求行上的原始方法 —— 不归一的话 lookup 必然 miss,落进 `?? 'any'` 兜底, + * 于是「只要登录就能跑任何 GET 处理器」。**凭据与归属一律按分发时的方法判, + * 不按请求行上的字面方法。** + */ + const method = c.req.method === 'HEAD' ? 'GET' : c.req.method; + // 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表) + const access = deps.registry.lookup(method, c.req.path) ?? 'any'; + if (access === 'public') { + await next(); + return undefined; + } + + const bearer = bearerOf(c.req.header('authorization')); + const ticket = method === 'GET' ? c.req.query('ticket') : undefined; + if (bearer === undefined && (ticket === undefined || ticket === '')) { + return c.json(unauthenticated('缺少 Bearer token'), 401); + } + + let principal: Principal; + try { + if (bearer !== undefined) { + principal = await deps.org.authenticate(bearer); + } else { + const opened = deps.tickets.verify(ticket ?? '', deps.clock.now()); + if (opened === undefined) { + return c.json(unauthenticated('票据无效或已过期 —— 重新取一张 GET /api/auth/ticket'), 401); + } + const found = await deps.org.principalOf(staffIdSchema.parse(opened.staffId)); + if (found === undefined) return c.json(unauthenticated('票据对应的员工不存在'), 401); + principal = found; + } + } catch (error) { + if (error instanceof TokenRejectedError) return c.json(unauthenticated(error.message), 401); + if (error instanceof AccountDisabledError) { + const body: ApiError = { code: 'forbidden', message: error.message }; + return c.json(body, 403); + } + throw error; + } + + const need = requiredEnds(access); + if (need !== undefined && !need.some((s) => principal.ends.includes(s))) { + return c.json(forbiddenBody(need, principal), 403); + } + + c.set('principal', principal); + await next(); + return undefined; + }; +} diff --git a/apps/api/src/app/config.ts b/apps/api/src/app/config.ts index a1a7ba8..84a7297 100644 --- a/apps/api/src/app/config.ts +++ b/apps/api/src/app/config.ts @@ -3,6 +3,18 @@ */ import { join } from 'node:path'; +/** 统一鉴权中心(ADR-012)。本服务只验不调,**不配 client secret**(手册 13.2) */ +export type KeycloakConfig = { + readonly url: string; + readonly realm: string; + /** 校 aud 用 —— 填错全线 401 */ + readonly clientId: string; + readonly issuer: string; + readonly jwksUrl: string; + /** master realm 的 provisioning 服务账号(只读名册,view-users);可选 */ + readonly provisioning?: { readonly clientId: string; readonly secret: string }; +}; + export type AppConfig = { readonly nodeEnv: 'development' | 'test' | 'production'; readonly port: number; @@ -11,25 +23,55 @@ export type AppConfig = { readonly attachmentRoot: string; /** 派发器轮询间隔(毫秒) */ readonly dispatchIntervalMs: number; - /** - * 播种口令(ADR-010)—— 新建员工的初始口令。 - * 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。 - */ - readonly seedPassword: string; + /** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */ + readonly mailPollTickMs: number; + /** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */ + readonly keycloak?: KeycloakConfig; + /** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; }; const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos'; -/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */ -const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026'; + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +function keycloakOf(env: NodeJS.ProcessEnv): KeycloakConfig | undefined { + const url = nonEmpty(env['KEYCLOAK_URL'])?.replace(/\/+$/, ''); + const realm = nonEmpty(env['KEYCLOAK_REALM']); + const clientId = nonEmpty(env['KEYCLOAK_CLIENT_ID']); + if (url === undefined || realm === undefined || clientId === undefined) return undefined; + const issuer = `${url}/realms/${realm}`; + const provisioningId = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_ID']); + const provisioningSecret = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_SECRET']); + return { + url, + realm, + clientId, + issuer, + jwksUrl: `${issuer}/protocol/openid-connect/certs`, + ...(provisioningId === undefined || provisioningSecret === undefined + ? {} + : { provisioning: { clientId: provisioningId, secret: provisioningSecret } }), + }; +} export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const nodeEnv = env['NODE_ENV']; + const keycloak = keycloakOf(env); return { nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development', port: Number(env['PORT'] ?? 8787), databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), - seedPassword: env['JCAI_SEED_PASSWORD'] ?? DEFAULT_SEED_PASSWORD, + mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000), + ...(keycloak === undefined ? {} : { keycloak }), + testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), }; } diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 6a14e2b..83ff4eb 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -16,18 +16,20 @@ import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { LoopEngineService } from '@modules/loop-engine'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; -import type { OrgService, InFlightWorkProbe } from '@modules/org'; +import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org'; import { - createGrantRepository, + createKeycloakRoster, + createKeycloakTokenVerifier, createOrgService, createPositionRepository, - createSessionRepository, + createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, - hashPassword, - newSessionToken, - verifyPassword, + TokenRejectedError, } from '@modules/org'; +import { createRemoteJWKSet } from 'jose'; +import { createTicketIssuer } from './ticket.ts'; +import type { TicketIssuer } from './ticket.ts'; import type { LlmService, ModelGateway } from '@modules/llm'; import { createLlmInstanceRepository, @@ -72,12 +74,19 @@ import type { TraceService } from '@modules/trace'; import { wireEngines } from './wiring/engines.ts'; import { wireDeskElements } from './wiring/desk-elements.ts'; import type { ChannelService } from '@modules/channel'; +import { createChannelRepository, createChannelService, createFileAttachmentArchive } from '@modules/channel'; +import type { MailService } from '@modules/mail'; import { - createChannelRepository, - createChannelService, - createFileAttachmentArchive, - createSimulatedMailTransport, -} from '@modules/channel'; + createImapSmtpProvider, + createInboundStore, + createMailAccountStore, + createMailService, + createOutboundStore, + createPostalMimeParser, + createWecomProvider, +} from '@modules/mail'; +import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts'; +import { readUpload, storeUpload } from './upload-store.ts'; import type { AppConfig } from './config.ts'; import { createIdGenerator, systemClock } from './runtime.ts'; @@ -94,6 +103,8 @@ export type Container = { readonly pool: Pool; readonly db: Db; readonly org: OrgService; + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + readonly tickets: TicketIssuer; readonly ledger: LedgerService; readonly event: EventService; readonly llm: LlmService; @@ -103,6 +114,8 @@ export type Container = { readonly toolInvoker: ToolInvoker; readonly message: MessageService; readonly channel: ChannelService; + /** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ + readonly mail: MailService; readonly humanTool: HumanToolService; readonly interaction: InteractionService; readonly registry: ActivityRegistryService; @@ -168,10 +181,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly(FIRST_PARTY_TOOLS); +/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */ +export type ContainerOverrides = { + readonly tokenVerifier?: TokenVerifier; + readonly roster?: RosterSource; +}; -export async function createContainer(config: AppConfig): Promise { +export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise { /* * 广播器**先建** —— 装配期就要往它上面接线。 * @@ -197,25 +213,60 @@ export async function createContainer(config: AppConfig): Promise { */ const bridge = bridgeBroadcaster(pool, broadcaster); const ids = createIdGenerator(); - const secrets = createSecretBox(loadSecretKey()); + const masterKey = loadSecretKey(); + const secrets = createSecretBox(masterKey); + const tickets = createTicketIssuer(masterKey); // 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出 await pool.query(DRAFT_DDL); const inFlightProbe = createDeferredInFlightProbe(); + /* + * 身份归统一鉴权中心(ADR-012):验签走 jose 远程 JWKS(首次用到才拉、缓存 10 分钟、 + * 30 秒冷却、5 秒超时 —— 手册 4.1);没配 KEYCLOAK_* 就装一个恒拒的 verifier: + * 集成测试走用例不走 HTTP,不受影响;真跑起来缺配置的话 main.ts 早就拒绝启动了。 + */ + const keycloak = config.keycloak; + const verifier: TokenVerifier = + overrides.tokenVerifier ?? + (keycloak === undefined + ? { + verify: async () => { + throw new TokenRejectedError('未配置 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID'); + }, + } + : createKeycloakTokenVerifier({ + issuer: keycloak.issuer, + audience: keycloak.clientId, + getKey: createRemoteJWKSet(new URL(keycloak.jwksUrl), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, + }), + })); + const roster: RosterSource | undefined = + overrides.roster ?? + (keycloak?.provisioning === undefined + ? undefined + : createKeycloakRoster({ + url: keycloak.url, + realm: keycloak.realm, + clientId: keycloak.provisioning.clientId, + secret: keycloak.provisioning.secret, + })); + const org = createOrgService({ + verifier, + ...(roster === undefined ? {} : { roster }), positions: createPositionRepository(db), staff: createStaffRepository(db), - grants: createGrantRepository(db), - sessions: createSessionRepository(db), + roleAccess: createRoleAccessRepository(db), workGroups: createWorkGroupRepository(db), - // 散列算法在 adapter,usecase 只认这三个动作(ADR-010) - passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken }, inFlight: inFlightProbe, clock: systemClock, - seedPassword: config.seedPassword, ids, + testAccounts: config.testAccounts, }); // message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入 @@ -226,15 +277,20 @@ export async function createContainer(config: AppConfig): Promise { const { staff } = await org.listStaff(); return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id })); }, + /* + * 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端)。 + * **空集要出声**:全新安装、还没人以 admin 登录过时这里就是空的,而调用方是死信通知与 + * 无主入站兜底 —— 收件人为空就等于把「出事了」这件事无声丢掉(铁规矩 8:界面不撒谎)。 + */ governanceStaff: async () => { - const { staff } = await org.listStaff(); - const governing = await Promise.all( - staff.map(async (s) => ({ - s, - ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'), - })), - ); - return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id })); + const recipients = await org.staffWithEnd('ops'); + if (recipients.length === 0) { + process.stderr.write( + '[org] 没有能进运营中心的活跃员工 —— 死信与无主入站的通知这一次没有收件人。' + + '去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。\n', + ); + } + return recipients.map((s) => ({ id: s.id })); }, }, clock: systemClock, @@ -319,9 +375,36 @@ export async function createContainer(config: AppConfig): Promise { ids, }); + /* + * 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。 + * 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。 + * 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。 + */ + const mail = createMailService({ + accounts: createMailAccountStore(db), + inbound: createInboundStore(db), + outbound: createOutboundStore(db), + attachments: { + put: async (file) => ({ + ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref, + }), + get: (ref) => readUpload(db, ref), + }, + providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() }, + mime: createPostalMimeParser(), + secrets, + clock: systemClock, + ids, + notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) }, + }); + const toolRegistry = createToolRegistry(db); + /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ // 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去 - const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler); + const toolRuntime = createToolRuntime( + new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), + httpToolHandler, + ); const schemas = createSchemaValidator(); const toolbox = createToolboxService({ registry: toolRegistry, @@ -349,11 +432,13 @@ export async function createContainer(config: AppConfig): Promise { recordOutbound: (ref) => event.recordOutbound(ref), }, ledger: { systemWrite: (input) => ledger.systemWrite(input) }, - mail: createSimulatedMailTransport(db, ids), + mail: channelTransport(mail), archive: createFileAttachmentArchive(config.attachmentRoot), clock: systemClock, ids, }); + // channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面 + mail.subscribe('channel', channelSubscriber(channel)); /* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */ @@ -715,6 +800,7 @@ export async function createContainer(config: AppConfig): Promise { pool, db, org, + tickets, ledger, event, llm, @@ -724,6 +810,7 @@ export async function createContainer(config: AppConfig): Promise { toolInvoker, message, channel, + mail, humanTool, interaction, registry, diff --git a/apps/api/src/app/http-env.ts b/apps/api/src/app/http-env.ts index 0da4db4..b118114 100644 --- a/apps/api/src/app/http-env.ts +++ b/apps/api/src/app/http-env.ts @@ -1,11 +1,20 @@ /** * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 */ -import type { Session } from '@contracts'; +import type { Context } from 'hono'; +import type { Position, Principal, Staff } from '@contracts'; /** - * 请求上下文(ADR-010)。 - * `session` 由鉴权中间件从**服务端会话表**解出 —— 不再有「cookie 里写谁就是谁」的明文身份; - * `account` 保留为便捷读法,值恒取自已核验的会话,免鉴权路由上为空串。 + * 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 —— + * 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。 */ -export type AppEnv = { Variables: { account: string; session: Session | undefined } }; +export type AppEnv = { Variables: { principal: Principal | undefined } }; + +/** + * 当前登录者(连同岗位)。非公开路由上中间件已保证存在;公开路由上是 undefined —— + * 调用方照旧判一下 undefined,免得哪天有人把一条路由改成 public 还在读它。 + */ +export function staffOf(c: Context): (Staff & { readonly position: Position }) | undefined { + const principal = c.get('principal'); + return principal === undefined ? undefined : { ...principal.staff, position: principal.position }; +} diff --git a/apps/api/src/app/keycloak-check.ts b/apps/api/src/app/keycloak-check.ts new file mode 100644 index 0000000..9b7cab1 --- /dev/null +++ b/apps/api/src/app/keycloak-check.ts @@ -0,0 +1,22 @@ +/** + * 启动自检(手册第 5 步;ADR-012):拉 `.well-known/openid-configuration`, + * issuer 与本地拼出来的不一致就别起 —— 配错了每个请求都 401 还查不出原因,不如启动时就说清楚。 + */ +import type { KeycloakConfig } from './config.ts'; + +export async function checkKeycloakConnection( + kc: KeycloakConfig, + fetchImpl: typeof fetch = fetch, +): Promise<{ issuer: string; clientId: string }> { + const url = `${kc.issuer}/.well-known/openid-configuration`; + const resp = await fetchImpl(url, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); + const conf = (await resp.json()) as { issuer?: unknown }; + if (conf.issuer !== kc.issuer) { + throw new Error( + `issuer 不一致!\n 期望: ${kc.issuer}\n 实际: ${String(conf.issuer)}\n` + + ' → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了', + ); + } + return { issuer: kc.issuer, clientId: kc.clientId }; +} diff --git a/apps/api/src/app/mail-wiring.ts b/apps/api/src/app/mail-wiring.ts new file mode 100644 index 0000000..0cde742 --- /dev/null +++ b/apps/api/src/app/mail-wiring.ts @@ -0,0 +1,158 @@ +/** + * mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013): + * ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信 + * ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认) + * ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器) + * 三段都是纯映射 + 一次调用,单测直接对函数。 + */ +import type { InboundMail, MailSendInput } from '@contracts'; +import type { MailTransport } from '@modules/channel'; +import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail'; +import { + bodyTextOf, + MailAccountDisabledError, + MailAccountNotFoundError, + MailNoDefaultAccountError, + MailSecretMissingError, + MailSendInvalidError, + threadIdOf, +} from '@modules/mail'; +import type { ToolHandler } from '@modules/toolbox'; + +/** + * mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。 + * + * **线程口径只有一处**:`threadIdOf`(mail 模块的 domain,取这一串的根;缘由与反例见那里)。 + * 这里曾经内联过一份同义实现,而模块里那一份当时取的是最近一跳 —— 同一个模块两套相反的语义, + * 下一个人照导出的那个接线就会接反。现在调它。 + */ +export function toInboundMail(mail: MailInboundDetail): InboundMail { + const threadId = threadIdOf(mail); + return { + messageId: mail.messageId, + ...(threadId === undefined ? {} : { threadId }), + from: mail.from.address, + to: mail.to.map((a) => a.address), + cc: mail.cc.map((a) => a.address), + subject: mail.subject, + body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }), + receivedAt: mail.receivedAt, + attachments: mail.attachments.flatMap((a) => + a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }], + ), + }; +} + +/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */ +export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise }): InboundHandler { + return async (mail, ctx) => { + if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' }; + await channel.ingestMail(ctx.channelId, toInboundMail(mail)); + // 显式回 undefined —— 投递成功就是「没话说」;`noImplicitReturns` 不许一条路返值一条路掉出去 + return undefined; + }; +} + +/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */ +export function channelTransport(mail: Pick): MailTransport { + return { + async send(input) { + const result = await mail.send({ + from: input.from, + to: [...input.to], + cc: [...input.cc], + bcc: [], + subject: input.subject, + text: input.body, + ...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }), + references: input.threadId === undefined ? [] : [input.threadId], + attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })), + requestedBy: { kind: 'channel' }, + }); + if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`); + return { messageId: result.messageId, threadId: input.threadId ?? result.messageId }; + }, + }; +} + +function recipientsOf(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== ''); + if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== ''); + return []; +} + +function attachmentsOf(value: unknown): MailSendInput['attachments'] { + if (!Array.isArray(value)) return []; + return value.flatMap((v) => { + if (typeof v !== 'object' || v === null) return []; + const a = v as Record; + const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined; + // 没给 uploadRef 的附件**不静默丢掉**:活动说「带上 q.pdf」,信发了附件没了还回 queued:true,是界面撒谎 + if (uploadRef === undefined) throw new MailSendInvalidError([`附件 ${String(a['name'] ?? '(无名)')} 没给 uploadRef`]); + const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef; + return [{ name, contentType: 'application/octet-stream', uploadRef }]; + }); +} + +export function mailSendTool(mail: Pick): ToolHandler { + return async ({ input, connection, idempotencyKey }) => { + const to = recipientsOf(input['to']); + const cc = recipientsOf(input['cc']); + const bcc = recipientsOf(input['bcc']); + const subject = typeof input['subject'] === 'string' ? input['subject'] : ''; + const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined; + const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined; + const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined; + // 账号:入参指定 > 连接配置('default' = 默认账号) + const configured = connection['account']; + const accountId = + typeof input['accountId'] === 'string' && input['accountId'] !== '' + ? input['accountId'] + : configured !== undefined && configured !== '' && configured !== 'default' + ? configured + : undefined; + + if (input['dryRun'] === true) { + const problems = [ + ...(to.length === 0 ? ['收件人至少一个'] : []), + ...(subject === '' ? ['主题不能为空'] : []), + ...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []), + ]; + return problems.length === 0 + ? { ok: true, output: { queued: false, dryRun: true } } + : { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } }; + } + + try { + const result = await mail.send({ + ...(accountId === undefined ? {} : { accountId }), + to, + cc, + bcc, + subject, + ...(body === undefined ? {} : { text: body }), + ...(html === undefined ? {} : { html }), + ...(inReplyTo === undefined ? {} : { inReplyTo }), + references: inReplyTo === undefined ? [] : [inReplyTo], + attachments: attachmentsOf(input['attachments']), + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + requestedBy: { kind: 'tool', tool: 'mail.send' }, + }); + if (result.state === 'failed') { + return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } }; + } + return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } }; + } catch (error) { + if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } }; + if ( + error instanceof MailNoDefaultAccountError || + error instanceof MailAccountNotFoundError || + error instanceof MailAccountDisabledError || + error instanceof MailSecretMissingError + ) { + return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } }; + } + throw error; + } + }; +} diff --git a/apps/api/src/app/main.ts b/apps/api/src/app/main.ts index 75b5997..41ebbf6 100644 --- a/apps/api/src/app/main.ts +++ b/apps/api/src/app/main.ts @@ -5,14 +5,30 @@ import { serve } from '@hono/node-server'; import { loadConfig } from './config.ts'; import { createContainer } from './container.ts'; +import { checkKeycloakConnection } from './keycloak-check.ts'; import { createServer } from './server.ts'; import { createConsumers } from './workers/consumers.ts'; import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts'; import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts'; +import { startMailPoller } from './workers/mail-poller.ts'; import { registerPulse } from './workers/pulse.ts'; export async function boot(): Promise { const config = loadConfig(); + if (config.keycloak === undefined) { + process.stderr.write( + '✗ 缺 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID —— 统一鉴权中心是唯一的登录途径,缺了谁也进不来(ADR-012)\n', + ); + process.exit(1); + } + try { + const info = await checkKeycloakConnection(config.keycloak); + process.stdout.write(`✓ Keycloak 连通 · issuer=${info.issuer} · client=${info.clientId}\n`); + } catch (error) { + // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因(手册第 5 步) + process.stderr.write(`✗ Keycloak 连不上或配置有误:${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } const container = await createContainer(config); await container.org.ensureSeeded(); @@ -23,6 +39,9 @@ export async function boot(): Promise { // 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线 const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs); + // 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱 + const mailPoller = startMailPoller({ container }, config.mailPollTickMs); + /* * 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。 * 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮, @@ -39,6 +58,7 @@ export async function boot(): Promise { const shutdown = async (): Promise => { dispatcher.stop(); advancer.stop(); + mailPoller.stop(); await container.close(); process.exit(0); }; diff --git a/apps/api/src/app/reset-runs.ts b/apps/api/src/app/reset-runs.ts index fffb96d..a3b8ed9 100644 --- a/apps/api/src/app/reset-runs.ts +++ b/apps/api/src/app/reset-runs.ts @@ -5,7 +5,7 @@ * * **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆… * **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、 - * 触发器、通道、模型实例、纪要。 + * 触发器、通道、模型实例、纪要、**邮箱账号**(它身上的游标与拉取状态是运行痕,跟着清)。 * * 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 —— * 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。 @@ -44,6 +44,10 @@ const RUN_TABLES = [ 'sys.event_delivery', 'sys.event_consumption', 'sys.outbound_mail', + // 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着 + 'sys.mail_inbound', + 'sys.mail_inbound_delivery', + 'sys.mail_outbound', 'sys.outbound_ref', 'sys.trigger_claim', // 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动 @@ -85,6 +89,17 @@ export async function resetRuns(): Promise { ); const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)]; await pool.query(`truncate table ${all.join(', ')} restart identity cascade`); + /* + * 邮件账号是**配置**(留着),但它身上挂着的**游标是运行痕** —— 清了 `sys.mail_inbound` + * 却留着游标,下一轮就从游标处往后拉:wecom 那十分钟重叠窗会把最近的信当新信**再投一次** + * (库里已经没有那一行了,唯一键挡不住),可能凭空再起一单。 + * 游标清空 = 下一轮重锚在当下、不回填,与「第一次配上账号」同一个起点。 + * 上次拉取 / 错误 / 最近来信也一并清:它们说的都是刚被清掉的那些信。 + */ + await pool.query( + `update sys.mail_account + set cursor = null, last_polled_at = null, last_poll_error = null, last_inbound_at = null, polling_until = null`, + ); const kept = await pool.query<{ n: string }>( `select count(*)::text as n from ledger.__object_meta where kind = 'master'`, ); diff --git a/apps/api/src/app/route-registry.ts b/apps/api/src/app/route-registry.ts new file mode 100644 index 0000000..2fdf58c --- /dev/null +++ b/apps/api/src/app/route-registry.ts @@ -0,0 +1,128 @@ +/** + * 路由注册表(ADR-012)—— 「哪条路由归哪个端」的唯一事实源是契约本身: + * 每条 `RouteContract` 自带 `access`,这里把契约命名空间扫一遍,给中间件一个 `lookup`。 + * 新路由写在契约里就自动生效,不必谁记得再加一行守卫(漏一条就是一个没人管的口子)。 + * + * 匹配:`:name` 段认任意非空段;字面精确命中优先于参数段命中;方法不同不混。 + */ +import type { HttpMethod, RouteAccess, RouteContract } from '@contracts'; +import { HTTP_METHODS } from '@contracts'; + +export type RegisteredRoute = { + readonly method: HttpMethod; + readonly path: string; + readonly access: RouteAccess; +}; + +export type RouteRegistry = { + readonly size: number; + /** + * 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404)。 + * **只认 `HTTP_METHODS` 那五个方法,归一是调用方的职责** —— 比如 HEAD:框架把它 + * 改写成 GET 再分发,这里照字面查必然 miss,中间件必须先归一再来查。 + */ + lookup(method: string, path: string): RouteAccess | undefined; + /** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */ + has(method: string, pathPattern: string): boolean; +}; + +const METHODS: readonly string[] = HTTP_METHODS; + +export function isRouteContract(value: unknown): value is RouteContract { + if (typeof value !== 'object' || value === null) return false; + const v = value as { method?: unknown; path?: unknown; access?: unknown; output?: unknown }; + return ( + typeof v.method === 'string' && + METHODS.includes(v.method) && + typeof v.path === 'string' && + v.access !== undefined && + v.output !== undefined + ); +} + +/** 从一堆导出里挑出路由契约(`Object.values(contracts)` 喂进来) */ +export function routesOf(values: readonly unknown[]): RegisteredRoute[] { + return values.filter(isRouteContract).map((r) => ({ method: r.method, path: r.path, access: r.access })); +} + +function escapeSegment(segment: string): string { + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matcherOf(pattern: string): RegExp { + const body = pattern + .split('/') + .map((seg) => (seg.startsWith(':') ? '[^/]+' : escapeSegment(seg))) + .join('/'); + return new RegExp(`^${body}$`); +} + +/** + * 两条模版会不会匹配到同一个具体路径:同段数,且每一位都兼容 + * (参数段与任何段兼容;字面段只与相同的字面兼容)。 + */ +function canCollide(a: string, b: string): boolean { + const sa = a.split('/'); + const sb = b.split('/'); + if (sa.length !== sb.length) return false; + return sa.every((seg, i) => { + const other = sb[i] ?? ''; + if (seg.startsWith(':') || other.startsWith(':')) return true; + return seg === other; + }); +} + +export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry { + const literal = new Map(); + const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = []; + for (const r of routes) { + if (r.path.includes(':')) { + patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); + continue; + } + /* + * 同方法同路径注册两遍:后写的静默盖掉先写的,命中谁全看 `Object.values(contracts)` + * 的顺序 —— 与两条模版相撞是同一个病、同一个后果(静默判错权限)。 + * 注册表是唯一的授权事实源,它的自检不该有半边缺口:字面这一半照样掀桌子。 + */ + const key = `${r.method} ${r.path}`; + if (literal.has(key)) { + throw new Error( + `路由归属表冲突:${key} 注册了两遍 —— 后写的会静默盖掉先写的,等于静默判错权限。删掉重复的那一条。`, + ); + } + literal.set(key, r.access); + } + + /* + * 两条模版能匹配到同一个具体路径 = 查它时命中谁全看声明顺序 —— 那是**静默判错权限**, + * 比启动失败严重得多。契约是我们自己写的,撞了就是写错了:开发期就掀桌子。 + * 只检模版之间;字面与参数并存是**有意的**优先级(字面赢),不算冲突。 + */ + for (let i = 0; i < patterns.length; i += 1) { + for (let j = i + 1; j < patterns.length; j += 1) { + const a = patterns[i]; + const b = patterns[j]; + if (a === undefined || b === undefined) continue; + if (a.method === b.method && canCollide(a.pattern, b.pattern)) { + throw new Error( + `路由归属表冲突:${a.method} ${a.pattern} 与 ${a.method} ${b.pattern} 会匹配到同一个具体路径 —— ` + + '命中谁只看声明顺序,等于静默判错权限。改掉其中一条的路径。', + ); + } + } + } + + return { + // 实际可查询的条数(重复被拒之后它与 routes.length 相等 —— 但语义上它说的是表里有几条) + size: literal.size + patterns.length, + lookup(method, path) { + const exact = literal.get(`${method} ${path}`); + if (exact !== undefined) return exact; + return patterns.find((p) => p.method === method && p.re.test(path))?.access; + }, + has(method, pathPattern) { + return literal.has(`${method} ${pathPattern}`) || patterns.some((p) => p.method === method && p.pattern === pathPattern); + }, + }; +} diff --git a/apps/api/src/app/routes/desk-approval.ts b/apps/api/src/app/routes/desk-approval.ts index c9cddba..fe6e465 100644 --- a/apps/api/src/app/routes/desk-approval.ts +++ b/apps/api/src/app/routes/desk-approval.ts @@ -9,6 +9,7 @@ import type { Hono } from 'hono'; import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts'; import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; /** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */ @@ -23,7 +24,7 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain /** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */ app.get(listApprovalsForMeRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const workflows = await container.process.listWorkflows(50); @@ -72,7 +73,7 @@ export function registerDeskApprovalRoutes(app: Hono, container: Contain }); app.post(decideApprovalRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const parsed = decideApprovalRoute.body.safeParse(await c.req.json()); if (!parsed.success) { diff --git a/apps/api/src/app/routes/desk-elements.ts b/apps/api/src/app/routes/desk-elements.ts index de2ae34..ba23011 100644 --- a/apps/api/src/app/routes/desk-elements.ts +++ b/apps/api/src/app/routes/desk-elements.ts @@ -23,13 +23,10 @@ import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/ import type { Report } from '@modules/report'; import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; export function registerDeskElementRoutes(app: Hono, container: Container): void { - async function me(account: string) { - return container.org.findByAccount(account); - } - const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' }; /** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */ @@ -85,7 +82,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe }); app.post(createMinuteRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = createMinuteRoute.body.safeParse(await c.req.json()); if (!parsed.success) { @@ -104,7 +101,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe }); app.post(appendResolutionRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = appendResolutionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { @@ -129,7 +126,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe /** 决议 → 登记起单:登记人即来源(OS 3.1) */ app.post(registerResolutionRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = registerResolutionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { @@ -167,14 +164,14 @@ export function registerDeskElementRoutes(app: Hono, container: Containe /* ── 汇报 ─────────────────────────────────────────────────────────── */ app.get(listReportsRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const reports = await container.report.list(staff.id); return c.json({ reports: reports.map(toReportView) }); }); app.get(draftReportRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const period = reportPeriodSchema.safeParse(c.req.param('period')); if (!period.success) { @@ -186,7 +183,7 @@ export function registerDeskElementRoutes(app: Hono, container: Containe }); app.post(submitReportRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json(unauthenticated, 401); const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({}))); try { diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 642af5c..047b264 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -28,7 +28,7 @@ import { } from '@contracts'; import { ReplyRejectedError } from '@modules/human-tool'; import { advanceOrder } from '@modules/process'; -import { keyText, listArchived, readArchived } from '../asset-archive.ts'; +import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archive.ts'; import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts'; import { toFileDoc } from '../file-doc.ts'; import { displayLog } from './log-lines.ts'; @@ -41,6 +41,7 @@ function rowsOf(result: unknown): T[] { const rows = (result as { rows?: unknown }).rows; return Array.isArray(rows) ? (rows as T[]) : []; } +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts'; import { orderChat } from '../chat-order.ts'; @@ -510,6 +511,9 @@ async function landingOf( * 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻 * 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。 * 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。 + * + * 在办这一路挑哪几行与存档同一段(`assetRowsOf`):认**本单碰过的行**, + * 不是这张表里现有的所有行 —— 两处各写一份的下场见那段的病根记档。 */ async function assetFilesOf( container: Container, @@ -529,12 +533,11 @@ async function assetFilesOf( } const declarations = await declarationsOf(container, taskId); + // 本单碰过的行 —— 「这一单产出的」只有这一个答案(整表里还躺着别单放行过的资产) + const touched = await container.ledger.taskDrafts(taskId).catch(() => []); const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = []; for (const declared of declarations ?? []) { - const spec = declared.file; - if (spec === undefined) continue; - const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name); - if (keys.length === 0) continue; + if (declared.file === undefined) continue; const page = await container.ledger .taskView(taskId, { address: { domain: declared.domain, object: declared.object }, @@ -544,21 +547,13 @@ async function assetFilesOf( offset: 0, }) .catch(() => ({ rows: [] as Array> })); - for (const row of page.rows) { - // 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」 - if (String(row[spec.bodyColumn] ?? '').trim() === '') continue; - const key: Record = {}; - for (const k of keys) key[k] = String(row[k] ?? ''); - if (Object.values(key).some((v) => v === '')) continue; - const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase(); - const format = isFileFormat(rawFormat) ? rawFormat : 'md'; - const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-'); + for (const asset of assetRowsOf(declared, page.rows as Array>, touched)) { out.push({ - address: `${declared.domain}.${declared.object}`, - key: keyText(key), - title, - format, - name: safeFileName(title, format), + address: asset.address, + key: keyText(asset.key), + title: asset.title, + format: asset.format, + name: safeFileName(asset.title, asset.format), archived: false, }); } @@ -852,12 +847,6 @@ export function gateOf( } export function registerDeskRoutes(app: Hono, container: Container): void { - /** 当前登录者;缺会话即 401 */ - async function me(account: string) { - const staff = await container.org.findByAccount(account); - return staff; - } - /** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */ async function toCard(task: Awaited>): Promise { let position = 1; @@ -955,7 +944,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi } app.get(listTasksRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -966,7 +955,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.get(taskDetailRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1198,7 +1187,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。 */ app.get(openAssetFileRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1304,7 +1293,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.post(saveTableRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1343,7 +1332,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi /** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */ app.post(replyWorkOrderRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1437,7 +1426,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。 */ app.post(replyChatCardRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1562,7 +1551,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。 */ app.post(markCommReadRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1621,7 +1610,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.post(sayRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1636,7 +1625,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.get(listMessagesRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1692,7 +1681,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi /** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */ app.post(confirmTriggerPendingRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1716,7 +1705,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); app.post(markMessageReadRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); @@ -1731,7 +1720,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi * 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。 */ app.post(answerMessageRoute.path, async (c) => { - const staff = await me(c.get('account')); + const staff = staffOf(c); if (staff === undefined) { const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; return c.json(body, 401); diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 62adb4f..7b4f0d3 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -5,9 +5,9 @@ * 「治理动作本身留痕入事件流 —— 治理也被治理。」 */ import { sql } from 'drizzle-orm'; -import { extractText } from '../upload-extract.ts'; +import { readUpload, storeUpload, UPLOAD_CAP } from '../upload-store.ts'; import type { Hono } from 'hono'; -import type { ApiError, GovernanceFunction, GrantAction } from '@contracts'; +import type { ApiError } from '@contracts'; import { browseLedgerRoute, controlTaskRoute, @@ -71,6 +71,7 @@ import { VersionTakenError, } from '@modules/process'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; import { displayLog } from './log-lines.ts'; @@ -366,46 +367,9 @@ async function assembleFromMail( } export function registerGovernanceOpsRoutes(app: Hono, container: Container): void { - /** - * 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。 - * - * 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件; - * PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。 - * 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。 - */ - function guard(fn: GovernanceFunction, action: GrantAction) { - return async (account: string): Promise => { - const staff = await container.org.findByAccount(account); - if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' }; - const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action); - if (ok) return undefined; - const need = action === 'govern' ? '治理权' : '查看权'; - return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` }; - }; - } - - const readTask = guard('task', 'read'); - const governTask = guard('task', 'govern'); - const readProcess = guard('process', 'read'); - const governProcess = guard('process', 'govern'); - const readTrigger = guard('trigger', 'read'); - const governTrigger = guard('trigger', 'govern'); - const readActivity = guard('activity', 'read'); - const governActivity = guard('activity', 'govern'); - const readTool = guard('tool', 'read'); - const governTool = guard('tool', 'govern'); - const readData = guard('data', 'read'); - const readMemory = guard('memory', 'read'); - - /** 拒绝的 HTTP 语义:无权 403,无会话 401 */ - const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401); - /* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */ app.get(listGovTasksRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const { staff } = await container.org.listStaff(); const nameOf = new Map(staff.map((s) => [s.id, s.name])); const rows = await Promise.all( @@ -438,9 +402,7 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 控制三语义 —— 叫停留痕为最重档纠正信号 */ app.post(controlTaskRoute.path, async (c) => { - const denied = await governTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const staff = await container.org.findByAccount(c.get('account')); + const staff = staffOf(c); const parsed = controlTaskRoute.body.safeParse(await c.req.json()); if (!parsed.success || staff === undefined) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -456,18 +418,14 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.post(resumeTaskRoute.path, async (c) => { - const denied = await governTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const staff = await container.org.findByAccount(c.get('account')); + const staff = staffOf(c); if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401); const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id); return c.json({ ok: true, runtime: task.runtime }); }); app.post(reassignTaskRoute.path, async (c) => { - const denied = await governTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const staff = await container.org.findByAccount(c.get('account')); + const staff = staffOf(c); const parsed = reassignTaskRoute.body.safeParse(await c.req.json()); if (!parsed.success || staff === undefined) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -482,8 +440,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(govTaskLogRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId'))); return c.json({ // 与工作台同一份日志、同一套口径:章是四字动作,内容带主语 @@ -502,9 +458,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。 */ app.get(listApprovalsRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const workflows = await container.process.listWorkflows(50); const perWorkflow = await Promise.all( workflows.map(async (w) => { @@ -551,14 +504,12 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。 */ app.post(testRunRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = testRunRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues }; return c.json(body, 400); } - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); const { packageId, mail, assignments } = parsed.data; try { const data = await assembleFromMail(container, packageId, mail); @@ -592,8 +543,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。 */ app.post(testStopRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = testStopRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' }; @@ -620,8 +569,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */ app.get(testCurrentRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); /* * **在跑与跑完要分得开**(`live`)。 * @@ -668,14 +615,12 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * `origin='testbed'`,好让人随时连根拔掉。 */ app.post(testRunActivityRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = testRunActivityRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues }; return c.json(body, 400); } - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); const { activityId, data, message, ownerId } = parsed.data; /* * 派给谁:显式指定优先,否则**派给操作者自己**。 @@ -729,8 +674,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。 */ app.get(testActivityStateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const taskId = taskIdSchema.parse(c.req.param('taskId')); /* * **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在 @@ -776,16 +719,9 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); }); - - /* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */ - /** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */ - const UPLOAD_CAP = 8 * 1024 * 1024; - app.post(uploadFileRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = uploadFileRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues }; @@ -805,47 +741,22 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json(body, 400); } // 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note - const got = extractText(name, contentType, bytes); - const me = await container.org.findByAccount(c.get('account')); - const ref = crypto.randomUUID(); - await container.db.execute(sql` - insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by) - values (${ref}, ${name}, ${contentType}, ${bytes.length}, ${Buffer.from(bytes)}, - ${got.text ?? null}, ${got.note}, ${me?.id ?? null})`); - return c.json({ - ref, - name, - contentType, - size: bytes.length, - ...(got.text === undefined ? {} : { text: got.text }), - note: got.note, - }); + return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null })); }); app.get(downloadFileRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>( - sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`, - ); - const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0]; + const hit = await readUpload(container.db, c.req.param('id')); if (hit === undefined) { const body: ApiError = { code: 'not-found', message: '这个文件不在了' }; return c.json(body, 404); } - return c.json({ - name: hit.name, - contentType: hit.content_type, - dataBase64: Buffer.from(hit.bytes).toString('base64'), - }); + return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') }); }); /* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */ /** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */ app.get(listTestLogsRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute(sql` select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, jsonb_array_length(trail) as trail_count, @@ -857,8 +768,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(testLogDetailRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute(sql` select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, jsonb_array_length(trail) as trail_count, @@ -875,8 +784,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.delete(deleteTestLogRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute<{ id: string }>( sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`, ); @@ -884,25 +791,18 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.delete(clearTestLogsRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`); return c.json({ deleted: rowsOf<{ id: string }>(rows).length }); }); /** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */ app.get(traceOfWorkflowRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const spans = await container.trace.ofWorkflow(c.req.param('id')); return c.json({ spans }); }); /** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */ app.get(govWorkflowRoute.path, async (c) => { - const denied = await readTask(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const workflow = await container.process.getWorkflow(c.req.param('id')); const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); if (pkg === undefined) { @@ -945,8 +845,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 工作流管理 ──────────────────────────────────────────────────── */ app.get(listProcessPackagesRoute.path, async (c) => { - const denied = await readProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); return c.json({ packages: await container.process.listPackages() }); }); @@ -955,8 +853,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。 */ app.put(saveProcessRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = saveProcessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; @@ -986,8 +882,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */ /** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */ app.delete(deleteProcessVersionRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); try { const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version'))); return c.json({ packages }); @@ -1004,14 +898,10 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */ app.get(listApprovalTemplatesRoute.path, async (c) => { - const denied = await readProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); return c.json({ templates: await container.process.approvalTemplates() }); }); app.put(upsertApprovalTemplateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' }; @@ -1021,8 +911,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.delete(deleteApprovalTemplateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); try { await container.process.deleteApprovalTemplate(c.req.param('id')); return c.json({ ok: true }); @@ -1042,8 +930,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。 */ app.delete(deleteProcessFlowRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const id = c.req.param('id'); const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id); if (rules.length > 0) { @@ -1067,8 +953,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */ app.post(editProcessVersionRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = editProcessVersionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' }; @@ -1096,8 +980,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.post(setProcessStateRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = setProcessStateRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' }; @@ -1122,8 +1004,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 画图即校验 —— 与前端画布同源 */ app.post(validateProcessRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = validateProcessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; @@ -1135,14 +1015,10 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 触发器配置 ──────────────────────────────────────────────────── */ app.get(listTriggersRoute.path, async (c) => { - const denied = await readTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); return c.json({ triggers: await container.trigger.list() }); }); app.post(toggleTriggerRoute.path, async (c) => { - const denied = await governTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = toggleTriggerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -1158,8 +1034,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 设计动规则不碰这两项,运营动这两项不碰规则。 */ app.post(setTriggerConfirmRoute.path, async (c) => { - const denied = await governTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -1178,15 +1052,11 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */ app.get(listNodeOwnersRoute.path, async (c) => { - const denied = await readProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const owners = await container.process.nodeOwners(c.req.param('id')); return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) }); }); app.put(setNodeOwnerRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; @@ -1201,8 +1071,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。 */ app.put(upsertTriggerRoute.path, async (c) => { - const denied = await governTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = upsertTriggerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues }; @@ -1219,8 +1087,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。 */ app.post(installActivityRoute.path, async (c) => { - const denied = await governActivity(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = installActivityRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues }; @@ -1259,8 +1125,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(listActivitiesRoute.path, async (c) => { - const denied = await readActivity(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const cards = await container.registry.list(); /* * 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里: @@ -1374,8 +1238,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。 */ app.post(registerToolRoute.path, async (c) => { - const denied = await governTool(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = registerToolRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues }; @@ -1397,8 +1259,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(listToolsRoute.path, async (c) => { - const denied = await readTool(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const tools = await container.toolbox.list(); return c.json({ tools: tools.map((t) => ({ @@ -1417,8 +1277,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */ app.get(listLedgerObjectsRoute.path, async (c) => { - const denied = await readData(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); /* * 性质随 `listObjects` **一次查询带回**(0026_object_meta)—— * 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *, @@ -1438,9 +1296,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。 */ app.get(ledgerByActivityRoute.path, async (c) => { - const denied = await readData(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - const [cards, materialized] = await Promise.all([ container.registry.list(), container.ledger.listObjects(), @@ -1557,8 +1412,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(browseLedgerRoute.path, async (c) => { - const denied = await readData(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const address = { domain: c.req.param('domain'), object: c.req.param('object') }; const described = await container.ledger.describe(address); if (described === undefined) { @@ -1595,8 +1448,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai * 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。 */ app.get(govMetricsRoute.path, async (c) => { - const denied = await readMemory(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const parsed = govMetricsRoute.query.safeParse(c.req.query()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues }; @@ -1612,8 +1463,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 系统管理 · 通道实例 ────────────────────────────────────────── */ app.get(listChannelsRoute.path, async (c) => { - const denied = await readTrigger(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const channels = await container.channel.list(); return c.json({ channels: channels.map((ch) => ({ @@ -1632,8 +1481,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); app.get(listActivityMemoryRoute.path, async (c) => { - const denied = await readMemory(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 }); return c.json({ entries: entries.map((e) => ({ diff --git a/apps/api/src/app/routes/governance.ts b/apps/api/src/app/routes/governance.ts index 2960216..c26b7ca 100644 --- a/apps/api/src/app/routes/governance.ts +++ b/apps/api/src/app/routes/governance.ts @@ -1,107 +1,122 @@ /** * 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 —— * 每个功能是对应模块的管理用例 + 本文件的路由组装)。 + * 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。 */ import type { Hono } from 'hono'; -import type { ApiError, GovernanceFunction } from '@contracts'; +import type { ApiError } from '@contracts'; import { - createStaffRoute, - disableStaffCheckRoute, - disableStaffRoute, - listGrantsRoute, + deleteLlmInstanceRoute, + deleteRoleAccessRoute, listLlmInstancesRoute, + listLlmProvidersRoute, + listRoleAccessRoute, listStaffRoute, listWorkGroupsRoute, - positionIdSchema, - listLlmProvidersRoute, LLM_PROVIDERS, - deleteLlmInstanceRoute, probeLlmDraftRoute, probeLlmInstanceRoute, saveWorkGroupRoute, - setGrantsRoute, + setRoleAccessRoute, + setStaffPositionRoute, staffIdSchema, + staffInFlightRoute, + syncStaffRoute, upsertLlmInstanceRoute, } from '@contracts'; -import { DisableBlockedError } from '@modules/org'; +import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org'; import { PROVIDER_PROFILES } from '@modules/llm'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; -/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */ -function requireGovern(container: Container, fn: GovernanceFunction) { - return async (account: string): Promise => { - const staff = await container.org.findByAccount(account); - if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' }; - const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern'); - return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` }; - }; -} - export function registerGovernanceRoutes(app: Hono, container: Container): void { - const guardStaff = requireGovern(container, 'staff'); - const guardSystem = requireGovern(container, 'system'); + /* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */ - /* ── 员工管理 ─────────────────────────────────────────────────────── */ + app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff())); - app.get(listStaffRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json(await container.org.listStaff()); - }); - - app.post(createStaffRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const parsed = createStaffRoute.body.safeParse(await c.req.json()); - if (!parsed.success) { - const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; - return c.json(body, 400); - } - return c.json(await container.org.createStaff(parsed.data)); - }); - - app.get(disableStaffCheckRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const staffId = staffIdSchema.parse(c.req.param('staffId')); - return c.json(await container.org.checkDisable(staffId)); - }); - - app.post(disableStaffRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const staffId = staffIdSchema.parse(c.req.param('staffId')); + /** + * 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎)。 + * 409:这一次同步会把最后一个能进运营中心的人也停了 —— 整体拒绝,一条都没写 + * (与「角色 → 端」那两条同款,理由不同、话不同)。 + */ + app.post(syncStaffRoute.path, async (c) => { try { - await container.org.disableStaff(staffId); - return c.json({ ok: true, check: await container.org.checkDisable(staffId) }); + return c.json(await container.org.syncRoster()); } catch (error) { - if (error instanceof DisableBlockedError) { - // 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) - const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check }; - return c.json(body, 412); + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); } throw error; } }); - app.get(listGrantsRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const positionId = positionIdSchema.parse(c.req.param('positionId')); - return c.json({ grants: await container.org.listGrants(positionId) }); - }); - - app.post(setGrantsRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - const positionId = positionIdSchema.parse(c.req.param('positionId')); - const parsed = setGrantsRoute.body.safeParse(await c.req.json()); + app.put(setStaffPositionRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + const parsed = setStaffPositionRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; return c.json(body, 400); } - return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) }); + try { + return c.json(await container.org.setPosition(staffId, parsed.data.positionId)); + } catch (error) { + if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } + }); + + /** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */ + app.get(staffInFlightRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + try { + return c.json(await container.org.inFlightOf(staffId)); + } catch (error) { + if (error instanceof StaffNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } + }); + + /* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */ + + app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess())); + + /** 锁死不变量不过(改完没人能进运营中心)→ 409 */ + app.put(setRoleAccessRoute.path, async (c) => { + const role = c.req.param('role'); + const parsed = setRoleAccessRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + return c.json(await container.org.setRoleAccess(role, parsed.data.ends)); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } + }); + + app.delete(deleteRoleAccessRoute.path, async (c) => { + try { + await container.org.deleteRoleAccess(c.req.param('role')); + return c.json({ ok: true }); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } }); /* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */ @@ -111,8 +126,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container * `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。 */ app.get(listWorkGroupsRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const { staff } = await container.org.listStaff(); const found = await Promise.all( staff.map(async (s) => { @@ -129,8 +142,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container * 改组**不影响在途**:装进工作流的是起单时的快照。 */ app.put(saveWorkGroupRoute.path, async (c) => { - const denied = await guardStaff(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues }; @@ -149,10 +160,8 @@ export function registerGovernanceRoutes(app: Hono, container: Container /* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */ /** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */ - app.get(listLlmProvidersRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json({ + app.get(listLlmProvidersRoute.path, (c) => + c.json({ providers: LLM_PROVIDERS.map((provider) => { const profile = PROVIDER_PROFILES[provider]; return { @@ -162,18 +171,12 @@ export function registerGovernanceRoutes(app: Hono, container: Container structuredOutput: profile.structuredOutput, }; }), - }); - }); + }), + ); - app.get(listLlmInstancesRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json({ instances: await container.llm.list() }); - }); + app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() })); app.post(upsertLlmInstanceRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; @@ -184,8 +187,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container /** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */ app.delete(deleteLlmInstanceRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const id = c.req.param('id'); const target = (await container.llm.list()).find((i) => i.id === id); if (target === undefined) { @@ -208,8 +209,6 @@ export function registerGovernanceRoutes(app: Hono, container: Container /** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */ app.post(probeLlmDraftRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; @@ -218,9 +217,5 @@ export function registerGovernanceRoutes(app: Hono, container: Container return c.json(await container.llm.probeDraft(parsed.data)); }); - app.post(probeLlmInstanceRoute.path, async (c) => { - const denied = await guardSystem(c.get('account')); - if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401); - return c.json(await container.llm.probe(c.req.param('id'))); - }); + app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id')))); } diff --git a/apps/api/src/app/routes/mail.ts b/apps/api/src/app/routes/mail.ts new file mode 100644 index 0000000..0f7ce97 --- /dev/null +++ b/apps/api/src/app/routes/mail.ts @@ -0,0 +1,191 @@ +/** + * 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判; + * 这里只做 zod 校验、调用用例、把领域错误翻成状态码。 + * 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。 + */ +import type { Context, Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { + createMailAccountRoute, + deleteMailAccountRoute, + getMailAccountRoute, + getMailInboundRoute, + listMailAccountsRoute, + listMailInboundRoute, + listMailOutboundRoute, + mailAttachmentRoute, + mailRawRoute, + pollMailAccountRoute, + probeMailAccountRoute, + probeMailDraftRoute, + redeliverMailInboundRoute, + sendTestMailRoute, + updateMailAccountRoute, +} from '@contracts'; +import type { MailService } from '@modules/mail'; +import { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberUnknownError, +} from '@modules/mail'; +import type { AppEnv } from '../http-env.ts'; +import { staffOf } from '../http-env.ts'; + +export type MailRouteDeps = { readonly mail: MailService }; + +/** 领域错误 → 状态码;认不出的交回 onError(500) */ +export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined { + if ( + error instanceof MailAccountNotFoundError || + error instanceof MailInboundNotFoundError || + error instanceof MailAttachmentNotFoundError || + error instanceof MailSubscriberUnknownError + ) { + return { status: 404, body: { code: 'not-found', message: error.message } }; + } + if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) { + return { status: 409, body: { code: 'conflict', message: error.message } }; + } + if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) { + return { status: 412, body: { code: 'precondition-failed', message: error.message } }; + } + if (error instanceof MailSendInvalidError) { + return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } }; + } + return undefined; +} + +function invalid(c: Context, message: string, detail: unknown): Response { + const body: ApiError = { code: 'validation-failed', message, detail }; + return c.json(body, 400); +} + +async function jsonBody(c: Context): Promise { + const text = await c.req.text(); + if (text.trim() === '') return {}; + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +export function registerMailRoutes(app: Hono, deps: MailRouteDeps): void { + const { mail } = deps; + + const guarded = async (c: Context, run: () => Promise): Promise => { + try { + return await run(); + } catch (error) { + const mapped = mailErrorResponse(error); + if (mapped === undefined) throw error; + return c.json(mapped.body, mapped.status); + } + }; + + /* ── 账号 ── */ + + app.get(listMailAccountsRoute.path, async (c) => c.json({ accounts: await mail.listAccounts() })); + + app.post(createMailAccountRoute.path, async (c) => { + const parsed = createMailAccountRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); + return guarded(c, async () => c.json(await mail.createAccount(parsed.data))); + }); + + app.post(probeMailDraftRoute.path, async (c) => { + const parsed = probeMailDraftRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); + const accountId = c.req.query('accountId'); + return guarded(c, async () => + c.json(await mail.probe({ input: parsed.data, ...(accountId === undefined || accountId === '' ? {} : { accountId }) })), + ); + }); + + app.get(getMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.getAccount(c.req.param('accountId'))))); + + app.put(updateMailAccountRoute.path, async (c) => { + const parsed = updateMailAccountRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); + return guarded(c, async () => c.json(await mail.updateAccount(c.req.param('accountId'), parsed.data))); + }); + + app.delete(deleteMailAccountRoute.path, async (c) => + guarded(c, async () => { + await mail.deleteAccount(c.req.param('accountId')); + return c.json({ ok: true as const }); + }), + ); + + app.post(probeMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.probe({ accountId: c.req.param('accountId') })))); + + app.post(pollMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.pollNow(c.req.param('accountId'))))); + + app.post(sendTestMailRoute.path, async (c) => { + const parsed = sendTestMailRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '试发入参不合法', parsed.error.issues); + const me = staffOf(c); + return guarded(c, async () => + c.json( + await mail.send({ + accountId: c.req.param('accountId'), + to: [parsed.data.to], + cc: [], + bcc: [], + subject: parsed.data.subject, + text: parsed.data.text, + references: [], + attachments: [], + requestedBy: { kind: 'ops', ...(me === undefined ? {} : { staffId: me.id }) }, + }), + ), + ); + }); + + /* ── 入站 ── */ + + app.get(listMailInboundRoute.path, async (c) => { + const query = listMailInboundRoute.query.safeParse(c.req.query()); + if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); + return c.json({ mails: await mail.listInbound({ accountId: query.data.accountId, limit: query.data.limit }) }); + }); + + app.get(getMailInboundRoute.path, async (c) => guarded(c, async () => c.json(await mail.getInbound(c.req.param('mailId'))))); + + app.get(mailAttachmentRoute.path, async (c) => { + const params = mailAttachmentRoute.params.safeParse({ mailId: c.req.param('mailId'), index: c.req.param('index') }); + if (!params.success) return invalid(c, '附件序号不合法', params.error.issues); + return guarded(c, async () => { + const file = await mail.attachment(params.data.mailId, params.data.index); + return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') }); + }); + }); + + app.get(mailRawRoute.path, async (c) => + guarded(c, async () => { + const file = await mail.raw(c.req.param('mailId')); + return c.json({ name: file.name, contentType: 'message/rfc822' as const, dataBase64: Buffer.from(file.bytes).toString('base64') }); + }), + ); + + app.post(redeliverMailInboundRoute.path, async (c) => { + const parsed = redeliverMailInboundRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '入参不合法', parsed.error.issues); + return guarded(c, async () => c.json({ deliveries: await mail.redeliver(c.req.param('mailId'), parsed.data.subscriber) })); + }); + + /* ── 出站 ── */ + + app.get(listMailOutboundRoute.path, async (c) => { + const query = listMailOutboundRoute.query.safeParse(c.req.query()); + if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); + return c.json({ mails: await mail.listOutbound({ accountId: query.data.accountId, limit: query.data.limit }) }); + }); +} diff --git a/apps/api/src/app/routes/registration.ts b/apps/api/src/app/routes/registration.ts index a3e71ee..684f633 100644 --- a/apps/api/src/app/routes/registration.ts +++ b/apps/api/src/app/routes/registration.ts @@ -10,6 +10,7 @@ import type { Hono } from 'hono'; import type { ApiError } from '@contracts'; import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts'; import type { Container } from '../container.ts'; +import { staffOf } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts'; export function registerRegistrationRoutes(app: Hono, container: Container): void { @@ -17,7 +18,7 @@ export function registerRegistrationRoutes(app: Hono, container: Contain /** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */ app.get(listRegistrationTriggersRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const triggers = await container.trigger.list(); @@ -38,7 +39,7 @@ export function registerRegistrationRoutes(app: Hono, container: Contain }); app.post(fireRegistrationRoute.path, async (c) => { - const me = await container.org.findByAccount(c.get('account')); + const me = staffOf(c); if (me === undefined) return c.json(unauthenticated, 401); const parsed = fireRegistrationRoute.body.safeParse(await c.req.json()); if (!parsed.success) { diff --git a/apps/api/src/app/routes/workbench.ts b/apps/api/src/app/routes/workbench.ts index 66e9b0e..8471d1e 100644 --- a/apps/api/src/app/routes/workbench.ts +++ b/apps/api/src/app/routes/workbench.ts @@ -1,13 +1,13 @@ /** * 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。 - * 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。 + * 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。 */ import type { Hono } from 'hono'; import type { ApiError } from '@contracts'; -import { CONTRACTS_VERSION, healthRoute, loginRoute, logoutRoute, sessionRoute } from '@contracts'; +import { CONTRACTS_VERSION, authTicketRoute, healthRoute, meRoute } from '@contracts'; import type { Container } from '../container.ts'; -import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts'; import type { AppEnv } from '../http-env.ts'; +import { systemClock } from '../runtime.ts'; export function registerWorkbenchRoutes(app: Hono, container: Container): void { app.get(healthRoute.path, async (c) => { @@ -21,44 +21,20 @@ export function registerWorkbenchRoutes(app: Hono, container: Container) return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db }); }); - /** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */ - app.get(sessionRoute.path, (c) => { - const session = c.get('session'); - if (session === undefined) { - const body: ApiError = { code: 'unauthenticated', message: '尚未登录' }; - return c.json(body, 401); - } - return c.json(session); + const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' }; + + /** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */ + app.get(meRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + return c.json(principal); }); - app.post(loginRoute.path, async (c) => { - const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) { - const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' }; - return c.json(body, 400); - } - - const issued = await container.org.login(parsed.data.account, parsed.data.password); - // 失败只有一句话:不区分账号不存在 / 已停用 / 口令错,不外泄账号是否存在 - if (issued === undefined) { - const body: ApiError = { code: 'unauthenticated', message: '账号或口令不对' }; - return c.json(body, 401); - } - - const session = await container.org.resolveSession(issued.token); - if (session === undefined) { - const body: ApiError = { code: 'internal', message: '会话签发后立即失效' }; - return c.json(body, 500); - } - setSessionCookie(c, issued.token, new Date(issued.expiresAt)); - return c.json(session); - }); - - app.post(logoutRoute.path, async (c) => { - const token = c.req.header('cookie')?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))?.[1]; - // 服务端表先作废,再清 cookie —— 顺序反了会留下「cookie 没了但会话还活着」的窗口 - if (token !== undefined) await container.org.logout(token); - clearSessionCookie(c); - return c.json({ ok: true }); + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + app.get(authTicketRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + const issued = container.tickets.issue(principal.staff.id, systemClock.now()); + return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() }); }); } diff --git a/apps/api/src/app/seed-mvp.ts b/apps/api/src/app/seed-mvp.ts index c790103..cad8758 100644 --- a/apps/api/src/app/seed-mvp.ts +++ b/apps/api/src/app/seed-mvp.ts @@ -48,21 +48,60 @@ const HISTORY_TOOL = { connectionKeys: ['endpoint'], }; -/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */ -const MAIL_TOOL = { +/** + * 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。 + * + * 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。 + * 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。 + * `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。 + * `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。 + */ +export const MAIL_TOOL = { ...HISTORY_TOOL, name: 'mail.send', displayName: '发送邮件', - description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界', + version: '2.0.0', + description: + '从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' + + 'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。', input: { type: 'object', required: ['to', 'subject'], - properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } }, + properties: { + to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' }, + cc: { type: 'array', items: { type: 'string' }, description: '抄送' }, + bcc: { type: 'array', items: { type: 'string' }, description: '密送' }, + subject: { type: 'string' }, + body: { type: 'string', description: '纯文本正文(与 html 至少一个)' }, + html: { type: 'string', description: 'HTML 正文' }, + attachments: { + type: 'array', + items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } }, + description: '附件:上传件的 ref', + }, + inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' }, + accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' }, + dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' }, + }, + }, + output: { + type: 'object', + properties: { + queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' }, + outboundId: { type: 'string' }, + messageId: { type: 'string' }, + state: { type: 'string' }, + dryRun: { type: 'boolean' }, + }, }, - output: { type: 'object', properties: { queued: { type: 'boolean' } } }, effect: 'write' as const, + errors: [ + { code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' }, + { code: 'no-account', retryable: false, message: '没有可用的邮箱账号' }, + { code: 'invalid', retryable: false, message: '入参不合法' }, + ], idempotent: true, - connectionKeys: ['endpoint'], + connectionKeys: ['account'], }; /** @@ -297,8 +336,10 @@ export async function seedMvp(container: Container): Promise { // 样例集一份喂四件工具:并集覆盖各家必填 input: { pn: 'LM358DR', - to: 'x@y.com', + to: ['x@y.com'], subject: '询价', + body: '样例正文', + dryRun: true, history_days: 30, market_stable: true, lifecycle: '在产', @@ -306,8 +347,10 @@ export async function seedMvp(container: Container): Promise { expectShape: { type: 'object' }, }, ], - // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点 - ...(contract.connectionKeys.length === 0 ? {} : { connectionValues: { endpoint: 'replay://crm' } }), + // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」 + ...(contract.connectionKeys.length === 0 + ? {} + : { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }), }); if (!verdict.accepted) { log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; ')); diff --git a/apps/api/src/app/seed.ts b/apps/api/src/app/seed.ts index 4927bc0..ff7b350 100644 --- a/apps/api/src/app/seed.ts +++ b/apps/api/src/app/seed.ts @@ -11,8 +11,8 @@ export async function seed(): Promise { try { await container.org.ensureSeeded(); const { staff, positions } = await container.org.listStaff(); - process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`); - for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`); + process.stdout.write(`种子完成:${positions.length} 个岗位;名册 ${staff.length} 人(人来自鉴权中心:首登自动建档 / 运营中心同步)\n`); + for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`); // MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条) process.stdout.write('\n── 治理台配置 ──\n'); diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index f86d6e5..6e6e8a0 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -1,13 +1,26 @@ /** * Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。 - * 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。 + * 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。 */ import { Hono } from 'hono'; -import type { ApiError } from '@contracts'; -import { healthRoute, ingestMailRoute, loginRoute } from '@contracts'; +import * as contracts from '@contracts'; +import type { ApiError, RouteContract } from '@contracts'; +import { + createStaffRoute, + disableStaffCheckRoute, + disableStaffRoute, + listGrantsRoute, + loginRoute, + logoutRoute, + sessionRoute, + setGrantsRoute, +} from '@contracts'; import type { Container } from './container.ts'; import type { AppEnv } from './http-env.ts'; -import { readSessionToken } from './session-cookie.ts'; +import { createAuthMiddleware } from './auth-middleware.ts'; +import { buildRouteRegistry, routesOf } from './route-registry.ts'; +import type { RegisteredRoute, RouteRegistry } from './route-registry.ts'; +import { systemClock } from './runtime.ts'; import { registerDeskRoutes } from './routes/desk.ts'; import { registerDeskElementRoutes } from './routes/desk-elements.ts'; import { registerDeskApprovalRoutes } from './routes/desk-approval.ts'; @@ -15,46 +28,47 @@ import { registerRegistrationRoutes } from './routes/registration.ts'; import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; import { registerGovernanceRoutes } from './routes/governance.ts'; import { registerIngestRoutes } from './routes/ingest.ts'; +import { registerMailRoutes } from './routes/mail.ts'; import { registerStreamRoute } from './routes/stream.ts'; import { registerWorkbenchRoutes } from './routes/workbench.ts'; export type { AppEnv }; -/** - * 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。 - * 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。 - */ -const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':')); +/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */ +export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }]; -function isPublic(method: string, path: string): boolean { - if (method === 'GET' && path === healthRoute.path) return true; - if (method === 'POST' && path === loginRoute.path) return true; - if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true; - return false; +/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */ +export function appRouteRegistry(): RouteRegistry { + return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]); } +/** + * 随统一鉴权中心退役的路由(§12.4 三步之第二步):契约留一轮给 web 编译,后端一律 410 并指路。 + * 它们的 access 是 public —— 410 不需要凭据,也不泄漏任何东西。 + */ +const RETIRED_ROUTES: ReadonlyArray<{ readonly route: RouteContract; readonly hint: string }> = [ + { route: loginRoute, hint: '登录由 Keycloak 完成,请求带 Authorization: Bearer ' }, + { route: logoutRoute, hint: '登出在前端丢弃 token,或调 Keycloak 的 end-session' }, + { route: sessionRoute, hint: '改用 GET /api/me' }, + { route: createStaffRoute, hint: '人来自鉴权中心 —— 首次登录自动建档,或 POST /api/gov/staff/sync 同步名册' }, + { route: disableStaffCheckRoute, hint: '改用 GET /api/gov/staff/:staffId/in-flight' }, + { route: disableStaffRoute, hint: '在 Keycloak 停用账号后 POST /api/gov/staff/sync' }, + { route: listGrantsRoute, hint: '岗位授权已退役,改用 GET /api/gov/access/roles(角色→端)' }, + { route: setGrantsRoute, hint: '岗位授权已退役,改用 PUT /api/gov/access/roles/:role' }, +]; + export function createServer(container: Container): Hono { const app = new Hono(); /** - * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。 - * - * 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来, - * 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。 + * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。 + * 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判, + * 路由里不再各写守卫 —— 一处拦住,新路由自动生效。 */ - app.use('*', async (c, next) => { - const token = readSessionToken(c.req.header('cookie')); - const session = token === undefined ? undefined : await container.org.resolveSession(token); - c.set('session', session); - c.set('account', session?.staff.account ?? ''); - - if (session === undefined && !isPublic(c.req.method, c.req.path)) { - const body: ApiError = { code: 'unauthenticated', message: '尚未登录' }; - return c.json(body, 401); - } - await next(); - return undefined; - }); + app.use( + '*', + createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }), + ); /** * 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。 @@ -92,6 +106,13 @@ export function createServer(container: Container): Hono { return c.json(body, 404); }); + for (const { route, hint } of RETIRED_ROUTES) { + app.on(route.method, route.path, (c) => { + const body: ApiError = { code: 'gone', message: `已随统一鉴权中心退役:${hint}` }; + return c.json(body, 410); + }); + } + registerWorkbenchRoutes(app, container); registerGovernanceRoutes(app, container); registerDeskRoutes(app, container); @@ -99,6 +120,7 @@ export function createServer(container: Container): Hono { registerDeskApprovalRoutes(app, container); registerRegistrationRoutes(app, container); registerGovernanceOpsRoutes(app, container); + registerMailRoutes(app, container); registerIngestRoutes(app, container); registerStreamRoute(app, container.broadcaster); diff --git a/apps/api/src/app/session-cookie.ts b/apps/api/src/app/session-cookie.ts deleted file mode 100644 index 2bb5d27..0000000 --- a/apps/api/src/app/session-cookie.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 会话 cookie 的读写(ADR-010)—— 只此一处拼 `Set-Cookie`,属性不许各写各的。 - * - * `HttpOnly`:脚本读不到 token,XSS 拿不走会话。 - * `SameSite=Lax`:跨站表单 POST 不带 cookie,挡掉 CSRF 的常见形态; - * 同站导航仍带,登录后跳转不受影响。 - * 开发期走 http://localhost,故 `Secure` 随部署环境开关(生产必开)。 - */ -import type { Context } from 'hono'; -import type { AppEnv } from './http-env.ts'; - -export const SESSION_COOKIE = 'jcai_session'; - -/** 生产环境必带 Secure;开发期 localhost 是 http,带了浏览器就不存 */ -function secureAttr(): string { - return process.env['NODE_ENV'] === 'production' ? '; Secure' : ''; -} - -export function readSessionToken(cookieHeader: string | undefined): string | undefined { - if (cookieHeader === undefined) return undefined; - const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookieHeader); - return match?.[1]; -} - -export function setSessionCookie(c: Context, token: string, expiresAt: Date): void { - c.header( - 'Set-Cookie', - `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Expires=${expiresAt.toUTCString()}`, - { append: true }, - ); -} - -export function clearSessionCookie(c: Context): void { - c.header( - 'Set-Cookie', - `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`, - { append: true }, - ); -} diff --git a/apps/api/src/app/set-password.ts b/apps/api/src/app/set-password.ts deleted file mode 100644 index e321231..0000000 --- a/apps/api/src/app/set-password.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 改密 CLI(ADR-010)—— 治理台的改密界面还没做,先给运维一条路。 - * - * pnpm --filter @app/api exec node --experimental-strip-types src/app/set-password.ts <账号> <新口令> - * - * 口令从**命令行参数**取,不写进任何文件;落库前即散列,明文只在进程内存里停留一瞬。 - */ -import { loadConfig } from './config.ts'; -import { createContainer } from './container.ts'; - -export async function setPassword(account: string, password: string): Promise { - const container = await createContainer(loadConfig()); - try { - const staff = await container.org.findByAccount(account); - if (staff === undefined) throw new Error(`账号不存在:${account}`); - await container.org.setPassword(staff.id, password); - process.stdout.write(`已改密:${staff.name}(${account})\n`); - } finally { - await container.close(); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const [account, password] = process.argv.slice(2); - if (account === undefined || password === undefined) { - process.stderr.write('用法:set-password.ts <账号> <新口令>\n'); - process.exit(1); - } - await setPassword(account, password); - process.exit(0); -} diff --git a/apps/api/src/app/ticket.ts b/apps/api/src/app/ticket.ts new file mode 100644 index 0000000..c04d141 --- /dev/null +++ b/apps/api/src/app/ticket.ts @@ -0,0 +1,43 @@ +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。 + * + * 形状 `v1...`;密钥由 JCAI_SECRET_KEY 经 HKDF 派生(不直接拿主密钥签)。 + * 票据里**没有权限**:中间件拿 sub 回库现算 principal,停用 / 改角色即时生效。 + * 无状态、不单次 —— 60 秒可重放窗换零状态表(记 debt);只在 GET 的 ?ticket= 上认,POST 带了也当没带。 + */ +import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto'; + +export const TICKET_TTL_MS = 60_000; + +export type TicketIssuer = { + issue(staffId: string, now: Date): { ticket: string; expiresAt: Date }; + verify(ticket: string, now: Date): { staffId: string } | undefined; +}; + +export function createTicketIssuer(masterKey: Buffer): TicketIssuer { + const key = Buffer.from(hkdfSync('sha256', masterKey, '', 'jcai-ticket', 32)); + const sign = (body: string): string => createHmac('sha256', key).update(body).digest('base64url'); + + return { + issue(staffId, now) { + const expUnix = Math.floor((now.getTime() + TICKET_TTL_MS) / 1000); + const body = `v1.${Buffer.from(staffId, 'utf8').toString('base64url')}.${expUnix}`; + return { ticket: `${body}.${sign(body)}`, expiresAt: new Date(expUnix * 1000) }; + }, + verify(ticket, now) { + const parts = ticket.split('.'); + if (parts.length !== 4) return undefined; + const [version, idRaw, expRaw, mac] = parts; + if (version !== 'v1' || idRaw === undefined || idRaw === '' || expRaw === undefined || mac === undefined) return undefined; + const expected = sign(`${version}.${idRaw}.${expRaw}`); + const given = Buffer.from(mac, 'utf8'); + const wanted = Buffer.from(expected, 'utf8'); + if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) return undefined; + const expUnix = Number(expRaw); + if (!Number.isInteger(expUnix) || expUnix * 1000 <= now.getTime()) return undefined; + const staffId = Buffer.from(idRaw, 'base64url').toString('utf8'); + return staffId === '' ? undefined : { staffId }; + }, + }; +} diff --git a/apps/api/src/app/tools/first-party.ts b/apps/api/src/app/tools/first-party.ts index e9f7c92..367da49 100644 --- a/apps/api/src/app/tools/first-party.ts +++ b/apps/api/src/app/tools/first-party.ts @@ -286,15 +286,6 @@ const rankKeyPersons: ToolHandler = async ({ input }) => { return { ok: true, output: { ranked, counts } }; }; -/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */ -const mailSend: ToolHandler = async ({ input, connection }) => { - // 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担 - return { - ok: true, - output: { queued: true, to: String(input['to'] ?? ''), via: connection['endpoint'] ?? 'simulated' }, - }; -}; - /* ── 活动槽位的占位产出(编排层跑通期专用) ───────────────────────────── * * 六个活动都只有契约面、没有实现。而出口载荷只从**落地事实**推导, @@ -349,7 +340,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray = ['quote-pricing.floor-gate', floorGate], ['quote-archive.profile', profile], ['quote-archive.landing-rate', landingRate], - ['mail.send', mailSend], ['slot.placeholder', slotPlaceholder], ['crm-relationship-coverage.recompute-score', recomputeScore], ['crm-relationship-coverage.rank-key-persons', rankKeyPersons], diff --git a/apps/api/src/app/upload-store.ts b/apps/api/src/app/upload-store.ts new file mode 100644 index 0000000..97ab9aa --- /dev/null +++ b/apps/api/src/app/upload-store.ts @@ -0,0 +1,42 @@ +/** + * sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。 + * 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。 + */ +import { sql } from 'drizzle-orm'; +import type { Db } from '@shared/db.ts'; +import { extractText } from './upload-extract.ts'; + +/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */ +export const UPLOAD_CAP = 8 * 1024 * 1024; + +function rowsOf(result: unknown): T[] { + const bag = result as { rows?: T[] } | T[]; + return Array.isArray(bag) ? bag : (bag.rows ?? []); +} + +export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string }; + +/** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */ +export async function storeUpload( + db: Db, + file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined }, +): Promise { + const got = extractText(file.name, file.contentType, file.bytes); + const ref = crypto.randomUUID(); + const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`; + await db.execute(sql` + insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by) + values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)}, + ${got.text ?? null}, ${note}, ${file.uploadedBy})`); + return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note }; +} + +export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> { + const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>( + sql`select name, content_type, bytes from sys.upload where id = ${ref}`, + ); + const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0]; + if (hit === undefined) return undefined; + // pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科) + return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) }; +} diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 9e28e5e..28ece17 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -17,6 +17,7 @@ import { renderView } from '@modules/interaction'; import type { InteractionService } from '@modules/interaction'; import type { MessageService } from '@modules/message'; import type { OrgService } from '@modules/org'; +import { NONE_POSITION_CODE } from '@modules/org'; import type { LlmService, ModelGateway } from '@modules/llm'; import type { ActivityRegistryService } from '@modules/activity-registry'; import type { SkillEngineService } from '@modules/skill-engine'; @@ -2277,6 +2278,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { taskId: input.taskId, ids, declarations: manifest.declarations.ledger, + // 「这一单产出的」= 本单碰过的行:整表里躺着历次跑出来的资产,不是这一单的成果 + touched: await deps.ledger.taskDrafts(input.taskId), rowsOfAddress: async (address) => ( await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 }) @@ -2451,7 +2454,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }, org: { - positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)), + // 哨兵岗 NONE(未分配)不是流程包能声明的岗位 —— 不进候选集(ADR-012) + positions: async () => + new Set( + (await deps.org.listStaff()).positions.filter((p) => p.code !== NONE_POSITION_CODE).map((p) => p.code), + ), candidates: async (position) => { const staff = await deps.org.staffOfPosition(position); const withLoad = await Promise.all( @@ -2612,10 +2619,17 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { // 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃 report: async (input) => { writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`); - const { staff } = await deps.org.listStaff(); - for (const person of staff) { - const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern'); - if (!governs) continue; + // 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端) + const recipients = await deps.org.staffWithEnd('ops'); + // 一个收件人都没有:这条通知这一次谁也收不到 —— 把「出事了」无声丢掉最糟, + // 所以出声(与 container.ts 的 governanceStaff 同一口径) + if (recipients.length === 0) { + writeLog( + '[trigger] 死信通知这一次没有收件人 —— 没有能进运营中心的活跃员工。' + + '去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。', + ); + } + for (const person of recipients) { await deps.message.notify({ staffId: person.id, title: `触发器起单失败:${input.triggerId}`, diff --git a/apps/api/src/app/workers/mail-poller.ts b/apps/api/src/app/workers/mail-poller.ts new file mode 100644 index 0000000..6454369 --- /dev/null +++ b/apps/api/src/app/workers/mail-poller.ts @@ -0,0 +1,41 @@ +/** + * 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里, + * 两个后端连同一个库时一个信箱同一时刻只有一个在拉。 + * 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。 + */ +import type { MailPollOutcome } from '@modules/mail'; +import type { Container } from '../container.ts'; + +export type MailPollerDeps = { readonly container: Pick; readonly now?: () => Date }; + +export async function pollMailOnce(deps: MailPollerDeps): Promise { + const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))()); + for (const outcome of outcomes) { + if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`); + } + return outcomes; +} + +export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } { + let stopped = false; + let running = false; + const tick = async (): Promise => { + if (stopped || running) return; + running = true; + try { + await pollMailOnce(deps); + } catch (error) { + process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`); + } finally { + running = false; + } + }; + const timer = setInterval(() => void tick(), tickMs); + timer.unref(); + return { + stop() { + stopped = true; + clearInterval(timer); + }, + }; +} diff --git a/apps/api/src/modules/channel/CONTRACT.md b/apps/api/src/modules/channel/CONTRACT.md index 3e30f48..642d258 100644 --- a/apps/api/src/modules/channel/CONTRACT.md +++ b/apps/api/src/modules/channel/CONTRACT.md @@ -18,7 +18,7 @@ 层级: L2(编码规范 §4) 依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、 - `LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(模拟通道) + `LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(app 注入,转 mail 模块) 被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由) 不变量: @@ -29,7 +29,9 @@ - **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底 - 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置 - 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源 - - 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入); + - 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方, + 把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认); + `createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用); 系统内部机制一律真跑,不得 mock 影响半径: index/contract/port 未变 → 对外零影响 diff --git a/apps/api/src/modules/mail/CONTRACT.md b/apps/api/src/modules/mail/CONTRACT.md new file mode 100644 index 0000000..5b9ecdc --- /dev/null +++ b/apps/api/src/modules/mail/CONTRACT.md @@ -0,0 +1,76 @@ +# module: mail + +职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、 + 发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API) +不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、 + SSE(app 经 `ChangeNotifier` 端口接到 broadcaster) + +公开出口: index.ts + - 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` / + `MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` / + `MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出 + - 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412) + `MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409) + `MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404) + `MailSubscriberTakenError` `MailSendInvalidError`(400) + - 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser` + `SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord` + `OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor` + - 用例工厂:`createMailService(deps: MailServiceDeps): MailService` + - 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow` + `cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、 + 入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、 + 脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、 + 账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`) + - adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、 + `createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`; + Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable` + +层级: L1(编码规范 §4) +依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime +被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块: + channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`; + `mail.send` 工具处理器在 app 里闭包持本服务 + +不变量(ADR-013): + - **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`, + 重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处 + - **一封解析不动的信不许卡死整条路**(2026-08-25 终审):落 text/jsonb 的字段一律剥 NUL(`stripNul`); + 单封 `storeOne` 抛错时先落**降级骨架**(原件原样存、主题从原始字节扒、正文写明失败原因)—— + 落成了就推进游标并把「第 N 封(providerRef)解析失败,已降级存档」写进 `last_poll_error`; + 骨架也落不下才停在这一封,那时 `last_poll_error` **必须点名是哪一封**。降级行**不自动分发** + (正文是失败原因不是客户的话),人可按「重新投递」 + - **线程取根**:`threadIdOf` = `references[0] ?? inReplyTo`(ADR-013 决策 3),全仓唯一口径 —— + app 的 `mail-wiring.toInboundMail` 调它,不另抄一份 + - **provider 的 `note` 要上界面**:`pull` 回的 `note`(当前唯一一种:UIDVALIDITY 变化已重锚、期间来信未拉取) + 落进 `last_poll_error`;丢了一段信而账号页一片绿是界面撒谎 + - **读取分两档**:`list` / `getMeta` **不取 `raw` 原件字节**(一封上限 16MB),`rawAvailable` 由 + `raw is not null` 投影;只有「下载原件」那一条路走全列的 `get` + - **账号的配置列与运行列分家**:`save()` 只写配置列;运行列(cursor / last_polled_at / last_poll_error / + last_inbound_at / polling_until)只经 `savePoll` / `claimPolling` / `releasePolling` 改 —— + 整行 upsert 会把另一台正持着的租约与刚推进的游标盖回读那一刻的快照。清游标是显式的 `savePoll({cursor:null})` + - **重锚只看收侧**:`cursorReset` 只比 kind 与收侧 settings 键(imap-smtp:`imapHost`/`imapPort`/`imapTls`/ + `login`/`folder`;wecom:`corpId`/`apiBase`)—— 改 SMTP 端口不该把来信丢掉 + - **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 —— + channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次; + 一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信 + - **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记 + - **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError` + - **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封; + 厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译) + - **复用旧行重发要先抢**:`OutboundStore.claimForResend` 一条条件更新(`failed`、或停够 5 分钟的 `sending` + 才抢得到;抢到即置 `sending` 并把 `created_at` 推到 now)—— 抢到才发,抢不到回「本次未重复发送」。 + 两个后端连同一个库时,这一句是「同键不发两封」唯一的墙 + - **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改; + 明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets` + - **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发); + wecom 的 `address` 探到之前是空串 + - **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录 + (回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕 + `sys.mail_outbound`;测试替身是 `__tests__/fakes.ts` 里的内存件(**不从 index 导出** —— 外界替身只进测试) + +影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变。 + 2026-08-25 终审修复轮改了 `port.ts`:`InboundStore` 新增 `getMeta`(list / getMeta 不再取 raw)、 + `OutboundStore` 新增 `claimForResend`、`MailAccountStore.savePoll` 的 `cursor` 允许显式 `null`、 + `MailProvider.pull` 回参可带 `note` —— 端口的实现方只有本模块的 adapter 与 `__tests__/fakes.ts` + 的内存件(两边语义必须一致),app 侧装配未变 diff --git a/apps/api/src/modules/mail/__tests__/account-view.test.ts b/apps/api/src/modules/mail/__tests__/account-view.test.ts new file mode 100644 index 0000000..29ec51e --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/account-view.test.ts @@ -0,0 +1,123 @@ +/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */ +import { describe, expect, it } from 'vitest'; +import { mailAccountInputSchema } from '@contracts'; +import type { MailAccountInput } from '@contracts'; +import type { MailAccountRecord } from '../internal/domain/records.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } }); + +describe('mergeAccount / secretOf', () => { + it('新建:id / 时间 / 密文来自 base,游标空', () => { + const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }); + expect(record.id).toBe('acc-1'); + expect(record.address).toBe('sales@we.com'); + expect(record.secretCipher).toBe('sealed'); + expect(record.cursor).toBeNull(); + expect(record.createdAt).toBe(now); + expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' })); + expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined(); + }); + it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => { + const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); + expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe(''); + const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }); + const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' }; + expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work'); + }); + it('**只有收侧的键动了才重锚**:改 smtpPort 游标留着,改 imapHost / folder 才清', () => { + /* + * 从前比对全部 settings 键:人把 465 改成 587(纯发信的事)也会清游标 → IMAP 重锚在当下 → + * 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。改发信的设置不该丢来信。 + */ + const existing: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), + cursor: imapCursor('7', 9), + }; + const changed = (over: Record): MailAccountInput => + mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, ...over } }); + // 发侧三键:一个都不重锚 + expect(cursorReset(existing, changed({ smtpPort: 587 }))).toBe(false); + expect(cursorReset(existing, changed({ smtpHost: 'smtp.other.com' }))).toBe(false); + expect(cursorReset(existing, changed({ smtpTls: false }))).toBe(false); + // 收侧:换信箱 / 换文件夹 / 换登录名 / 换端口都是另一个游标 + expect(cursorReset(existing, changed({ imapHost: 'imap.other.com' }))).toBe(true); + expect(cursorReset(existing, changed({ folder: 'Archive' }))).toBe(true); + expect(cursorReset(existing, changed({ imapPort: 143 }))).toBe(true); + expect(cursorReset(existing, changed({ imapTls: false }))).toBe(true); + expect(cursorReset(existing, changed({ login: 'other@we.com' }))).toBe(true); + // 只改 smtpPort 时游标真的留在记录里(不只是判据为假) + expect(mergeAccount(changed({ smtpPort: 587 }), { id: 'acc-1', existing, secretCipher: 's', now }).cursor).toEqual(imapCursor('7', 9)); + }); + + it('**登录名空着时,换 address 就是换信箱** —— 收侧判据认的是 `login ?? address`', () => { + /* + * `imapSettingsOf` 里写的是 `login: settings.login ?? account.address`:login 常常空着, + * 这时候真正决定「登到哪个信箱」的是 address。只比 settings 里的 login, + * 人把 address 换成另一个邮箱时判据为假 —— 旧游标的 UID 说的是原来那个箱子, + * 而 UID 是**按信箱**编号的。反过来,显式填了 login 时 address 只是发信落款,换它不动收信。 + */ + const base: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), + cursor: imapCursor('7', 9), + }; + const withAddress = (address: string): MailAccountInput => mailAccountInputSchema.parse({ ...input, address }); + // login 空着:address 就是登录名,换了它要重锚 + expect(base.settings['login']).toBeUndefined(); + expect(cursorReset(base, withAddress('ops@we.com'))).toBe(true); + expect(cursorReset(base, withAddress('sales@we.com'))).toBe(false); + // 显式填了 login:address 只是落款,换它不重锚(而换 login 照旧重锚) + const pinned = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, login: 'box@we.com' } }); + const withPinnedLogin: MailAccountRecord = { ...mergeAccount(pinned, { id: 'acc-1', existing: base, secretCipher: 's', now }), cursor: imapCursor('7', 9) }; + expect(cursorReset(withPinnedLogin, mailAccountInputSchema.parse({ ...pinned, address: 'ops@we.com' }))).toBe(false); + expect(cursorReset(withPinnedLogin, mailAccountInputSchema.parse({ ...pinned, settings: { ...pinned.settings, login: 'other@we.com' } }))).toBe(true); + // 游标真的留下 / 真的被清(不只是判据) + expect(mergeAccount(withAddress('ops@we.com'), { id: 'acc-1', existing: base, secretCipher: 's', now }).cursor).toBeNull(); + }); + + it('wecom 形按 corpId / apiBase 判重锚', () => { + const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); + const existing: MailAccountRecord = { + ...mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }), + cursor: null, + }; + expect(cursorReset(existing, wecom)).toBe(false); + expect(cursorReset(existing, mailAccountInputSchema.parse({ ...wecom, settings: { corpId: 'ww2' } }))).toBe(true); + expect(cursorReset(existing, mailAccountInputSchema.parse({ ...wecom, settings: { corpId: 'ww1', apiBase: 'https://qyapi.example.com' } }))).toBe(true); + }); + + it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => { + const existing: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), + cursor: imapCursor('7', 9), + lastPollError: '旧错误', + }; + expect(cursorReset(existing, input)).toBe(false); + const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now }); + expect(same.cursor).toEqual(imapCursor('7', 9)); + const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } }); + expect(cursorReset(existing, changed)).toBe(true); + const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now }); + expect(reset.cursor).toBeNull(); + expect(reset.lastPollError).toBeNull(); + }); +}); + +describe('viewOf', () => { + it('不带密文只带 secretBound;status 带游标摘要与 polling', () => { + const record: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }), + cursor: imapCursor('7', 9), + pollingUntil: new Date(now.getTime() + 1000), + }; + const view = viewOf(record, now); + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('sealed'); + expect(view.status.cursor).toBe('UID 9 · validity 7'); + expect(view.status.polling).toBe(true); + expect(view.kind).toBe('imap-smtp'); + if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com'); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/cursor.test.ts b/apps/api/src/modules/mail/__tests__/cursor.test.ts new file mode 100644 index 0000000..1e224b8 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/cursor.test.ts @@ -0,0 +1,83 @@ +/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */ +import { describe, expect, it } from 'vitest'; +import { + advanceImap, + cursorSummary, + imapCursor, + imapNeedsReanchor, + newUids, + parseCursor, + wecomCursor, + wecomWindow, + WECOM_OVERLAP_SEC, +} from '../internal/domain/cursor.ts'; + +describe('imap 游标', () => { + it('逐封推进只往前不往后', () => { + const c = imapCursor('7', 100); + expect(advanceImap(c, 105).lastUid).toBe(105); + expect(advanceImap(c, 90).lastUid).toBe(100); + }); + + it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => { + expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false); + expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true); + expect(imapNeedsReanchor(null, '7')).toBe(true); + expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true); + }); + + it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => { + expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]); + expect(newUids([100], 100)).toEqual([]); + }); +}); + +describe('wecom 游标', () => { + const now = new Date('2026-08-25T10:00:00Z'); + it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => { + const w = wecomWindow(wecomCursor(1_000_000), null, now); + expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) }); + }); + it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => { + const end = Math.floor(now.getTime() / 1000); + expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end }); + const since = new Date('2026-08-20T00:00:00Z'); + expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000)); + }); + it('lastTime 往前退不会退成负数', () => { + expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0); + }); +}); + +describe('摘要与解析', () => { + it('人读摘要;没拉过是 null', () => { + expect(cursorSummary(null)).toBeNull(); + expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7'); + expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z'); + }); + it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => { + expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3)); + expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5)); + expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull(); + expect(parseCursor(null)).toBeNull(); + expect(parseCursor('garbage')).toBeNull(); + }); + it('**取值范围也算形状**:一行脏游标不许把账号页炸成 500', () => { + /* + * `cursorSummary` 跑在 `viewOf` 里:`new Date(1e13 * 1000).toISOString()` 抛 RangeError, + * 一行脏游标 → `listAccounts` 整页 500。不合格的当没有游标(下一轮重锚),不抛。 + */ + for (const bad of [1e13, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.5, Number.MAX_SAFE_INTEGER + 2]) { + expect(parseCursor({ kind: 'wecom', lastTime: bad })).toBeNull(); + expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: bad })).toBeNull(); + } + // 1e13 是安全整数,乘 1000 却出了 Date 的界 —— 「安全整数」这一句拦不住它,上界必须自己写 + expect(Number.isSafeInteger(1e13)).toBe(true); + // UID 是 32 位无符号:恰好在界内的收,越界的当没有 + expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 0xffff_ffff })).toEqual(imapCursor('7', 0xffff_ffff)); + expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 0x1_0000_0000 })).toBeNull(); + expect(parseCursor({ kind: 'wecom', lastTime: 0 })).toEqual(wecomCursor(0)); + expect(() => cursorSummary(parseCursor({ kind: 'wecom', lastTime: 1e13 }))).not.toThrow(); + expect(cursorSummary(parseCursor({ kind: 'wecom', lastTime: 1e13 }))).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/delivery.test.ts b/apps/api/src/modules/mail/__tests__/delivery.test.ts new file mode 100644 index 0000000..7aec6b9 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/delivery.test.ts @@ -0,0 +1,17 @@ +/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */ +import { describe, expect, it } from 'vitest'; +import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts'; + +describe('delivery', () => { + it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => { + expect(canRedeliver(undefined)).toBe(true); + expect(canRedeliver('done')).toBe(true); + expect(canRedeliver('failed')).toBe(true); + expect(canRedeliver('skipped')).toBe(true); + expect(canRedeliver('dispatching')).toBe(false); + }); + it('回调的返回值定状态', () => { + expect(stateOfOutcome(undefined)).toBe('done'); + expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped'); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/due.test.ts b/apps/api/src/modules/mail/__tests__/due.test.ts new file mode 100644 index 0000000..f942d1a --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/due.test.ts @@ -0,0 +1,36 @@ +/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */ +import { describe, expect, it } from 'vitest'; +import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null }; + +describe('isDue', () => { + it('从没拉过 → 到期;停用 → 永不到期', () => { + expect(isDue(base, now)).toBe(true); + expect(isDue({ ...base, enabled: false }, now)).toBe(false); + }); + it('上次 + 间隔 <= 现在才到期', () => { + expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false); + expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true); + }); + it('租约还没过期 → 别人在拉,不到期;过期了照常', () => { + expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false); + expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true); + }); +}); + +describe('leaseMsFor / isPolling', () => { + it('租约 = max(5 分钟, 3 × 间隔)', () => { + // 下限那一档写死 300_000,不写 MIN_LEASE_MS:拿被测的常量当期望值是自证 —— + // 哪天有人把 MIN_LEASE_MS 改成 5 秒,这条断言照样绿 + expect(leaseMsFor(30)).toBe(300_000); + expect(MIN_LEASE_MS).toBe(300_000); + expect(leaseMsFor(600)).toBe(1_800_000); + }); + it('polling 只在租约未过期时为真', () => { + expect(isPolling(null, now)).toBe(false); + expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true); + expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/fakes.ts b/apps/api/src/modules/mail/__tests__/fakes.ts new file mode 100644 index 0000000..0a3c4fc --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/fakes.ts @@ -0,0 +1,403 @@ +/** + * 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。 + * 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import { isDue } from '../internal/domain/due.ts'; +import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts'; +import type { + AttachmentStore, + Clock, + DeliveryRecord, + IdGenerator, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + MailCursor, + MailProvider, + OutboundMessage, + OutboundRecord, + OutboundStore, + RawMessage, + SecretBox, +} from '../port.ts'; + +const CRLF = '\r\n'; + +export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } { + let t = new Date(iso).getTime(); + return { + now: () => new Date(t), + tick(ms) { + t += ms; + }, + }; +} + +export function seqIds(prefix = 'id'): IdGenerator { + let n = 0; + return { + next: () => { + n += 1; + return `${prefix}-${n}`; + }, + }; +} + +export const plainSecrets: SecretBox = { + seal: (s) => `sealed:${s}`, + open: (c) => { + if (!c.startsWith('sealed:')) throw new Error('密文格式非法'); + return c.slice('sealed:'.length); + }, +}; + +export function account(over: Partial = {}): MailAccountRecord { + const now = new Date('2026-08-25T09:00:00Z'); + return { + id: 'acc-1', + kind: 'imap-smtp', + name: '销售信箱', + address: 'sales@we.com', + displayName: '销售部', + enabled: true, + isDefault: true, + pollIntervalSec: 30, + channelId: 'ch-1', + backfillFrom: null, + settings: { folder: 'INBOX' }, + secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`, + cursor: null, + lastPolledAt: null, + lastPollError: null, + lastInboundAt: null, + pollingUntil: null, + createdAt: now, + updatedAt: now, + ...over, + }; +} + +export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map } { + const rows = new Map(); + return { + rows, + async list() { + return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, + async get(id) { + return rows.get(id); + }, + async findByName(name) { + return [...rows.values()].find((r) => r.name === name); + }, + async save(record) { + if (record.isDefault) { + for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false }); + } + const existing = rows.get(record.id); + /* + * 与真 store 同一条不变量:**upsert 只写配置列**。运行列(游标 / 上次拉取 / 上次错误 / + * 最近来信 / 租约)一概保留库里当下那一份 —— 整行覆盖会把另一台正持着的租约与刚推进的游标 + * 盖回读那一刻的快照。替身与真身在这一条上分叉,测试就会连错的点(本项目已踩过两次)。 + * 要在测试里摆运行列:用 `savePoll` / `claimPolling`,或直接动 `rows`。 + */ + rows.set( + record.id, + existing === undefined + ? record + : { + ...record, + cursor: existing.cursor, + lastPolledAt: existing.lastPolledAt, + lastPollError: existing.lastPollError, + lastInboundAt: existing.lastInboundAt, + pollingUntil: existing.pollingUntil, + createdAt: existing.createdAt, + }, + ); + }, + async remove(id) { + rows.delete(id); + }, + async listDue(now) { + return [...rows.values()].filter((r) => isDue(r, now)); + }, + async claimPolling(id, leaseMs) { + const r = rows.get(id); + const now = clock.now(); + if (r === undefined || !r.enabled) return false; + // 与真 store 的边界方向不同:真 SQL 是 `polling_until < now()`(恰好等于 now 仍挡住), + // 这里是 `> now`(恰好等于 now 可抢)。微秒级边界、两边 now 的来源本就不同,不改行为 —— 只记差异。 + if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false; + rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) }); + return true; + }, + async releasePolling(id) { + const r = rows.get(id); + if (r !== undefined) rows.set(id, { ...r, pollingUntil: null }); + }, + async savePoll(id, patch) { + const r = rows.get(id); + if (r === undefined) return; + rows.set(id, { + ...r, + ...(patch.cursor === undefined ? {} : { cursor: patch.cursor }), + ...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }), + ...(patch.error === undefined ? {} : { lastPollError: patch.error }), + ...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }), + ...(patch.address === undefined ? {} : { address: patch.address }), + }); + }, + }; +} + +/** + * 与真 store 同款:列表与只要元信息的读**不带 `raw` 原件字节**(只有 `get()` 带)。 + * 「有没有原件」一律问 `rawAvailable` —— 替身若照旧把字节递出来, + * 「列表不搬原件」这条就没有任何测试拦得住了。 + */ +function withoutRaw(record: InboundRecord): InboundRecord { + return { ...record, raw: null }; +} + +export function memoryInbound(): InboundStore & { rows: Map; deliveries: Map } { + const rows = new Map(); + const deliveries = new Map(); + // 与 DB store 同序:按订阅方名排(测试里对顺序有断言)。 + // `localeCompare` vs 真 store 的 PG `orderBy(subscriber)`(依 collation)—— + // 订阅方名都是 ASCII 标识符时两者一致,前提仅此而已。 + const of = (mailId: string): DeliveryRecord[] => + [...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber)); + return { + rows, + deliveries, + async insert(record) { + for (const r of rows.values()) { + if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) { + return { inserted: false }; + } + } + rows.set(record.id, record); + return { inserted: true }; + }, + async knownProviderRefs(accountId, refs) { + return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef)); + }, + async list(query) { + return [...rows.values()] + .map(withoutRaw) + .filter((r) => query.accountId === undefined || r.accountId === query.accountId) + // 与真 store 同序:两级键 desc(receivedAt), desc(createdAt) —— 并列时后创建的排前。 + // 少了第二级,并列会按 Map 插入序解(= createdAt 升序),与真身**方向相反**, + // 而 rawMessage() 的 receivedAt 默认是同一个常量,并列是常态不是例外。 + .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime() || b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, query.limit) + .map((record) => ({ record, deliveries: of(record.id) })); + }, + async getMeta(mailId) { + const record = rows.get(mailId); + return record === undefined ? undefined : { record: withoutRaw(record), deliveries: of(mailId) }; + }, + async get(mailId) { + const record = rows.get(mailId); + return record === undefined ? undefined : { record, deliveries: of(mailId) }; + }, + async setDelivery(delivery) { + deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery); + }, + }; +} + +export function memoryOutbound(): OutboundStore & { rows: Map } { + const rows = new Map(); + return { + rows, + async insert(record) { + if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) { + throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"'); + } + rows.set(record.id, record); + }, + async byIdempotencyKey(key) { + return [...rows.values()].find((r) => r.idempotencyKey === key); + }, + async claimForResend(id, opts) { + // 与真 store 同一句条件:failed 可抢;sending 只有停够了才算「上次崩在半路」。 + // 抢到即置 sending 并把 createdAt 推到 now —— 那一行当场离开可抢的集合,第二个人抢不到。 + const r = rows.get(id); + if (r === undefined) return false; + const claimable = r.state === 'failed' || (r.state === 'sending' && r.createdAt.getTime() <= opts.staleBefore.getTime()); + if (!claimable) return false; + rows.set(id, { ...r, state: 'sending', createdAt: opts.now, error: null }); + return true; + }, + async setState(id, state, patch) { + const r = rows.get(id); + if (r === undefined) return; + rows.set(id, { + ...r, + state, + // 与真 store 同一条不变量:发成功的那一行不留上一次的错误话(替身与真身分叉,测试就会连错的点) + ...(state === 'sent' ? { error: null } : {}), + ...(patch.error === undefined ? {} : { error: patch.error }), + ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), + ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), + }); + }, + async list(query) { + return [...rows.values()] + .filter((r) => query.accountId === undefined || r.accountId === query.accountId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, query.limit); + }, + }; +} + +export function memoryAttachments(): AttachmentStore & { files: Map } { + const files = new Map(); + let n = 0; + return { + files, + async put(file) { + n += 1; + const ref = `up-${n}`; + files.set(ref, { ...file }); + return { ref }; + }, + async get(ref) { + const f = files.get(ref); + return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes }; + }, + }; +} + +export type FakeProvider = MailProvider & { + readonly sent: OutboundMessage[]; + readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>; + queue(...messages: RawMessage[]): void; + /** + * 让 `pull` 从第几次调用开始报错(1-based 调用计数;`afterCalls` 缺省 0 = 第一次调用就炸, + * 与既有用例的期望一致)。**中途失败**才验得出「游标停在最后一封成功处」—— + * 若失败恒发生在第一次调用,游标"没动"只是因为它压根没机会动,测不出逐封推进的行为。 + */ + failPullWith(error: Error | undefined, opts?: { readonly afterCalls?: number }): void; + failSendWith(error: Error | undefined): void; + probeWith(report: MailProbeReport | undefined): void; + /** 这一轮带回一句「不算失败但人得知道」的话(真身:UIDVALIDITY 变化已重锚) */ + noteWith(note: string | undefined): void; +}; + +/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */ +export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider { + const pending: RawMessage[] = []; + const sent: OutboundMessage[] = []; + const pullCalls: Array<{ cursor: MailCursor; limit: number }> = []; + let pullError: Error | undefined; + let pullFailAfter = 0; + let sendError: Error | undefined; + let probeReport: MailProbeReport | undefined; + let note: string | undefined; + let uidSeq = 0; + return { + kind, + sent, + pullCalls, + queue(...messages) { + pending.push(...messages); + }, + failPullWith(error, opts = {}) { + pullError = error; + pullFailAfter = opts.afterCalls ?? 0; + }, + failSendWith(error) { + sendError = error; + }, + probeWith(report) { + probeReport = report; + }, + noteWith(value) { + note = value; + }, + async probe(accountArg) { + return ( + probeReport ?? { + kind, + address: accountArg.address, + receive: { ok: true, latencyMs: 1 }, + send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true }, + } + ); + }, + async anchor(_accountArg, _backfillFrom, now) { + return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000)); + }, + async pull(_accountArg, cursor, opts) { + pullCalls.push({ cursor, limit: opts.limit }); + if (pullError !== undefined && pullCalls.length > pullFailAfter) throw pullError; + const batch = pending.splice(0, opts.limit).map((m) => { + if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m; + uidSeq += 1; + return { ...m, cursorAfter: imapCursor('v1', uidSeq) }; + }); + const last = batch.at(-1)?.cursorAfter; + const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000)); + return { messages: batch, cursor: next, more: pending.length > 0, ...(note === undefined ? {} : { note }) }; + }, + async send(_accountArg, message) { + if (sendError !== undefined) throw sendError; + sent.push(message); + return { providerRef: `fake:${sent.length}` }; + }, + }; +} + +export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage { + return { providerRef, eml, receivedAt }; +} + +/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */ +export function eml(parts: { + from?: string; + to?: string; + cc?: string; + subject?: string; + text?: string; + html?: string; + messageId?: string; + inReplyTo?: string; + references?: string; + date?: string; + attachment?: { name: string; contentType: string; content: string }; +}): Uint8Array { + const headers = [ + `From: ${parts.from ?? 'buyer@acme.com'}`, + `To: ${parts.to ?? 'sales@we.com'}`, + ...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]), + `Subject: ${parts.subject ?? '询价'}`, + ...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]), + ...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]), + ...(parts.references === undefined ? [] : [`References: ${parts.references}`]), + `Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`, + 'MIME-Version: 1.0', + ]; + const text = parts.text ?? '你好,请报价'; + if (parts.html === undefined && parts.attachment === undefined) { + return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF)); + } + const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text]; + if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html); + if (parts.attachment !== undefined) { + lines.push( + '--B1', + `Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`, + `Content-Disposition: attachment; filename="${parts.attachment.name}"`, + 'Content-Transfer-Encoding: base64', + '', + Buffer.from(parts.attachment.content, 'utf8').toString('base64'), + ); + } + lines.push('--B1--', ''); + return new TextEncoder().encode(lines.join(CRLF)); +} diff --git a/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts b/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts new file mode 100644 index 0000000..af48327 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/imap-helpers.test.ts @@ -0,0 +1,79 @@ +/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */ +import { describe, expect, it } from 'vitest'; +import { imapSettingsOf, internalDateOf, lowestUid, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; + +const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } }; + +describe('imapSettingsOf', () => { + it('空 settings 取腾讯默认;登录名缺省 = 地址', () => { + const s = imapSettingsOf(account); + expect(s.settings.imapHost).toBe('imap.exmail.qq.com'); + expect(s.settings.smtpPort).toBe(465); + expect(s.login).toBe('sales@we.com'); + expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com'); + }); + it('缺密码当场说清', () => { + expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码'); + }); +}); + +describe('internalDateOf', () => { + const fallback = new Date('2026-08-25T10:00:00Z'); + it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => { + const d = new Date('2026-08-25T09:00:00Z'); + expect(internalDateOf(d, fallback)).toBe(d); + expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z'); + expect(internalDateOf('garbage', fallback)).toBe(fallback); + expect(internalDateOf(undefined, fallback)).toBe(fallback); + }); +}); + +describe('orderFetched', () => { + it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => { + const rows = orderFetched('7', [ + { uid: 12, source: Buffer.from('b'), internalDate: undefined }, + { uid: 10, source: Buffer.from('a'), internalDate: undefined }, + { uid: 11, source: undefined, internalDate: undefined }, + ], new Date('2026-08-25T10:00:00Z')); + expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']); + expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12)); + expect(rows[0]?.eml).toBeInstanceOf(Uint8Array); + }); +}); + +describe('lowestUid', () => { + it('取最小;空结果回兜底;**十万级不栈溢出**(Math.min(...uids) 会)', () => { + expect(lowestUid([12, 7, 30], 99)).toBe(7); + expect(lowestUid([], 99)).toBe(99); + expect(lowestUid([100], 3)).toBe(3); // 兜底比数组里的都小时,兜底赢(它是 uidNext,只在空集时才该赢) + const many = Array.from({ length: 200_000 }, (_, i) => i + 5); + expect(() => Math.min(...many)).toThrow(RangeError); // 反证:铺参数那一手在这个量级真的炸 + expect(lowestUid(many, Number.MAX_SAFE_INTEGER)).toBe(5); + }); +}); + +describe('mailOptionsOf', () => { + it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => { + const options = mailOptionsOf({ + messageId: '', + from: { address: 'sales@we.com', name: '销售部' }, + to: ['b@acme.com'], + cc: [], + bcc: ['c@we.com'], + subject: '报价', + text: '正文', + inReplyTo: '', + references: [''], + attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], + }); + expect(options.messageId).toBe(''); + expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' }); + expect(options.bcc).toEqual(['c@we.com']); + expect(options.inReplyTo).toBe(''); + expect(options.references).toEqual(['']); + expect('html' in options).toBe(false); + expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' }); + expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts b/apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts new file mode 100644 index 0000000..8a560d8 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts @@ -0,0 +1,63 @@ +/** + * 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。 + * JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com) + * JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事) + */ +import { describe, expect, it } from 'vitest'; +import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts'; + +const user = process.env['JCAI_MAIL_IT_IMAP_USER']; +const pass = process.env['JCAI_MAIL_IT_IMAP_PASS']; +const enabled = user !== undefined && pass !== undefined; +if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n'); + +const account = { + id: 'it', + kind: 'imap-smtp' as const, + address: user ?? '', + displayName: 'JCAI IT', + settings: { + imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com', + smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com', + }, + secret: { password: pass ?? '' }, +}; +const known = async (): Promise> => new Set(); + +describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => { + const provider = createImapSmtpProvider(); + + it('探测:收发都 ok', async () => { + const report = await provider.probe(account); + expect(report.receive).toMatchObject({ ok: true }); + expect(report.send).toMatchObject({ checked: true, ok: true }); + }, 60_000); + + it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => { + const now = new Date(); + const cursor = await provider.anchor(account, null, now); + expect(cursor.kind).toBe('imap'); + const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known }); + expect(empty.messages).toEqual([]); + const since = new Date(now.getTime() - 7 * 86_400_000); + const back = await provider.anchor(account, since, now); + const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known }); + expect(some.messages.length).toBeLessThanOrEqual(3); + for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0); + }, 120_000); + + it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => { + const result = await provider.send(account, { + messageId: ``, + from: { address: account.address, name: 'JCAI IT' }, + to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''], + cc: [], + bcc: [], + subject: 'JCAI mail IT', + text: '这是一封集成测试信', + references: [], + attachments: [], + }); + expect(result.providerRef ?? '').toContain('250'); + }, 60_000); +}); diff --git a/apps/api/src/modules/mail/__tests__/inbound-record.test.ts b/apps/api/src/modules/mail/__tests__/inbound-record.test.ts new file mode 100644 index 0000000..7e30281 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/inbound-record.test.ts @@ -0,0 +1,203 @@ +/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */ +import { describe, expect, it } from 'vitest'; +import type { ParsedMail } from '../internal/domain/records.ts'; +import { + ATTACHMENT_CAP, + bodyTextOf, + buildInboundRecord, + detailOf, + fallbackMessageId, + planAttachments, + rawOf, + stripHtml, + stripNul, + summaryOf, + textPreviewOf, + threadIdOf, +} from '../internal/domain/inbound-record.ts'; + +const parsed: ParsedMail = { + messageId: '', + inReplyTo: '', + references: ['', ''], + from: { address: 'buyer@acme.com', name: '张三' }, + to: [{ address: 'sales@we.com', name: '' }], + cc: [], + subject: '询价 RFQ-1', + text: '你好,请报价', + html: '

你好,请报价

', + date: new Date('2026-08-25T09:00:00Z'), + attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }], +}; +const now = new Date('2026-08-25T10:00:00Z'); + +describe('线程与正文', () => { + it('**线程 = 这一串的根**(References[0]);只给 In-Reply-To 时才退回它;都没有就是 undefined', () => { + /* + * 2026-08-25 终审改口径:原先取的是最近一跳(inReplyTo ?? references.at(-1)), + * 与 ADR-013 决策 3 和 app 侧接线正相反 —— 跑一个来回归属①级就漂掉。 + * 断言随语义更新(测试没删,期望值改对):同一封信 parsed 现在给的是根 。 + */ + expect(threadIdOf(parsed)).toBe(''); + expect(threadIdOf({ references: ['
', ''] })).toBe(''); + expect(threadIdOf({ references: [], inReplyTo: '' })).toBe(''); + expect(threadIdOf({ references: [] })).toBeUndefined(); + // 入站详情那一形用 null 表示「没有」—— 同一个函数要接得住(app 的 mail-wiring 传的就是它) + expect(threadIdOf({ references: [], inReplyTo: null })).toBeUndefined(); + }); + it('缺 Message-ID 用 providerRef@域顶上,不抛', () => { + expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe(''); + expect(fallbackMessageId('x', 'no-at')).toBe(''); + }); + it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => { + expect(stripHtml('

你好&世界

\n 再见')).toBe('你好&世界 再见'); + expect(bodyTextOf({ html: '
只有 html
' })).toBe('只有 html'); + expect(bodyTextOf({ text: '有 text', html: '忽略' })).toBe('有 text'); + expect(bodyTextOf({})).toBe(''); + }); + it('预览 200 字', () => { + expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200); + expect(textPreviewOf(null, '

来自 html

')).toBe('来自 html'); + }); +}); + +describe('附件与原件', () => { + it('超上限的附件不存,留注;下标按原顺序', () => { + const plan = planAttachments( + [ + { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) }, + { name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) }, + ], + ATTACHMENT_CAP, + ); + expect(plan.map((p) => [p.index, p.store])).toEqual([ + [0, true], + [1, false], + ]); + expect(plan[1]!.note).toContain('8MB'); + }); + it('原件超上限留空 + 注', () => { + expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' }); + const big = rawOf(new Uint8Array(9), 8); + expect(big.raw).toBeNull(); + expect(big.note).not.toBe(''); + }); +}); + +describe('NUL(U+0000)一律剥掉', () => { + /* + * PG 的 text / jsonb 列拒收 NUL,而 MIME 解出二进制垃圾时它真会出现。 + * 不剥的话这一封每 30 秒炸一次、游标停在它前面,后面所有新信永远进不来。 + */ + const NUL = '\u0000'; + it('纯函数:有就剥,没有就原样(同一个引用)', () => { + expect(stripNul(`a${NUL}b`)).toBe('ab'); + const clean = '干净的一句'; + expect(stripNul(clean)).toBe(clean); + expect(stripNul(NUL)).toBe(''); + }); + + it('落 text / jsonb 的字段全剥:主题 / 正文 / html / 地址 / 头部串 / 附件名', () => { + const dirty = buildInboundRecord({ + id: 'in-nul', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: `uid:7:${NUL}14`, + parsed: { + messageId: ``, + inReplyTo: ``, + references: [``], + from: { address: `buyer${NUL}@acme.com`, name: `张${NUL}三` }, + to: [{ address: `sales${NUL}@we.com`, name: '' }], + cc: [{ address: `cc${NUL}@we.com`, name: '' }], + subject: `询价${NUL}RFQ-1`, + text: `正文${NUL}一`, + html: `

${NUL}二

`, + attachments: [], + }, + eml: new Uint8Array([0, 1, 0]), + receivedAt: now, + attachments: [{ index: 0, name: `bom${NUL}.xlsx`, contentType: `application/${NUL}zip`, size: 3, ref: null, note: `注${NUL}` }], + now, + }); + expect(JSON.stringify(dirty)).not.toContain('\\u0000'); + expect(dirty.subject).toBe('询价RFQ-1'); + expect(dirty.text).toBe('正文一'); + expect(dirty.html).toBe('

二

'); + expect(dirty.messageId).toBe(''); + expect(dirty.providerRef).toBe('uid:7:14'); + expect(dirty.inReplyTo).toBe(''); + expect(dirty.references).toEqual(['']); + expect(dirty.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(dirty.to[0]?.address).toBe('sales@we.com'); + expect(dirty.cc[0]?.address).toBe('cc@we.com'); + expect(dirty.attachments[0]).toMatchObject({ name: 'bom.xlsx', contentType: 'application/zip', note: '注' }); + // 原件是 bytea:一个字节都不动 + expect(dirty.raw).toEqual(new Uint8Array([0, 1, 0])); + }); + + it('缺 Message-ID 时顶上的那一串也剥过(它进唯一键)', () => { + const record = buildInboundRecord({ + id: 'in-nul-2', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: `uid:7:${NUL}15`, + parsed: { references: [], to: [], cc: [], subject: '', attachments: [] }, + eml: new Uint8Array(), + receivedAt: now, + attachments: [], + now, + }); + expect(record.messageId).toBe(''); + }); +}); + +describe('buildInboundRecord / 视图', () => { + const record = buildInboundRecord({ + id: 'in-1', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid:7:12', + parsed, + eml: new Uint8Array([1, 2, 3]), + receivedAt: now, + attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }], + now, + }); + + it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => { + expect(record.messageId).toBe(''); + expect(record.inReplyTo).toBe(''); + expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(record.size).toBe(3); + expect(record.raw).toEqual(new Uint8Array([1, 2, 3])); + expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z'); + }); + it('缺 from / 缺 Message-ID 也能落记录', () => { + const bare = buildInboundRecord({ + id: 'in-2', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid:7:13', + parsed: { references: [], to: [], cc: [], subject: '', attachments: [] }, + eml: new Uint8Array(), + receivedAt: now, + attachments: [], + now, + }); + expect(bare.messageId).toBe(''); + expect(bare.from).toEqual({ address: '', name: '' }); + }); + it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => { + const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }]; + const summary = summaryOf(record, deliveries); + expect(summary.textPreview).toBe('你好,请报价'); + expect(summary.hasHtml).toBe(true); + expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() }); + expect('text' in summary).toBe(false); + const detail = detailOf(record, deliveries); + expect(detail.text).toBe('你好,请报价'); + expect(detail.rawAvailable).toBe(true); + expect(detail.references).toEqual(['', '']); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts new file mode 100644 index 0000000..a62f27a --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts @@ -0,0 +1,100 @@ +/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */ +import { describe, expect, it } from 'vitest'; +import { mailAccountInputSchema } from '@contracts'; +import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const changes: string[] = []; + const service = createMailService({ + accounts, + inbound: memoryInbound(), + outbound: memoryOutbound(), + attachments: memoryAttachments(), + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('acc'), + notify: { changed: (r) => void changes.push(r) }, + }); + return { service, accounts, imap, wecom, clock, changes }; +} +// 入参是判别联合:`{...imapInput, address}` 若停在联合上,TS 会拿 wecom 那一形来判超额属性(它没有 address)。 +// 先窄到 imap-smtp 这一形,下面各处展开才成立 —— 窄的是夹具的静态型,值一个字节没变。 +const parsedImapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }); +if (parsedImapInput.kind !== 'imap-smtp') throw new Error('夹具:imapInput 应是 imap-smtp 那一形'); +const imapInput = parsedImapInput; + +describe('账号', () => { + it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => { + const { service, accounts } = harness(); + const view = await service.createAccount(imapInput); + expect(view.id).toBe('acc-1'); + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('pw-1234'); + expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`); + expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']); + }); + + it('名字唯一:重名 409;改名撞别人也 409', async () => { + const { service } = harness(); + await service.createAccount(imapInput); + await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError); + const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' }); + await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError); + }); + + it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => { + const { service, accounts } = harness(); + const created = await service.createAccount(imapInput); + await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' }); + expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234'); + await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } }); + expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9'); + const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } }); + const switched = await service.updateAccount(created.id, wecomInput); + expect(switched.secretBound).toBe(false); + expect(switched.address).toBe(''); + }); + + it('默认账号只有一个;删了就没了(404)', async () => { + const { service } = harness(); + const a = await service.createAccount(imapInput); + const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true }); + expect((await service.getAccount(a.id)).isDefault).toBe(false); + expect((await service.getAccount(b.id)).isDefault).toBe(true); + await service.deleteAccount(a.id); + await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); + await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); + }); + + it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => { + const { service, wecom, imap } = harness(); + imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } }); + const a = await service.createAccount(imapInput); + const report = await service.probe({ accountId: a.id }); + expect(report.receive.error).toBe('LOGIN sales@we.com *** failed'); + const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } })); + wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } }); + await service.probe({ accountId: w.id }); + expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work'); + }); + + it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => { + const { service, accounts } = harness(); + const report = await service.probe({ input: imapInput }); + expect(report.receive.ok).toBe(true); + expect(accounts.rows.size).toBe(0); + await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError); + const a = await service.createAccount(imapInput); + const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id }); + expect(borrowed.receive.ok).toBe(true); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/mail-service-guards.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-guards.test.ts new file mode 100644 index 0000000..60a8e32 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-service-guards.test.ts @@ -0,0 +1,208 @@ +/** + * 这一份钉的是 Task 9 评审逮出的三条 Important 与一条设计律 —— + * 它们都在拉取 / 发信 / 分发那几条**当时还没有测试**的路上。 + */ +import { describe, expect, it } from 'vitest'; +import { mailSendInputSchema } from '@contracts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { buildInboundRecord } from '../internal/domain/inbound-record.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import type { OutboundRecord, ParsedMail } from '../port.ts'; +import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const inbound = memoryInbound(); + const outbound = memoryOutbound(); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const changes: string[] = []; + const service = createMailService({ + accounts, + inbound, + outbound, + attachments: memoryAttachments(), + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('out'), + notify: { changed: (r) => void changes.push(r) }, + }); + return { service, accounts, inbound, outbound, imap, wecom, clock, changes }; +} + +const sendInput = mailSendInputSchema.parse({ to: ['buyer@acme.com'], subject: '报价', text: '见附', requestedBy: { kind: 'ops' } }); + +function outboundRow(over: Partial = {}): OutboundRecord { + return { + id: 'out-9', + accountId: 'acc-1', + messageId: '', + idempotencyKey: 'k-1', + to: ['buyer@acme.com'], + cc: [], + bcc: [], + subject: '报价', + text: '见附', + html: null, + inReplyTo: null, + references: [], + attachments: [], + state: 'sending', + error: null, + providerRef: null, + requestedBy: { kind: 'ops' }, + createdAt: new Date('2026-08-25T10:00:00Z'), + sentAt: null, + ...over, + }; +} + +describe('凭证解不开', () => { + it('密文解得开但不是 JSON:错误话里没有明文,只说「未配置凭证」', async () => { + const { service, accounts, imap } = harness(); + // 解得开(plainSecrets 会回出 `pw-1234`),但那不是 JSON —— JSON.parse 的 SyntaxError 把原文嵌在 message 里 + await accounts.save(account({ secretCipher: 'sealed:pw-1234' })); + const outcome = await service.pollNow('acc-1'); + expect(outcome.error).not.toContain('pw-1234'); + expect(outcome.error).toContain('未配置凭证'); + expect(accounts.rows.get('acc-1')?.lastPollError).not.toContain('pw-1234'); + expect(accounts.rows.get('acc-1')?.lastPollError).toContain('未配置凭证'); + // 凭证都没打开,一次都不该去够厂商 + expect(imap.pullCalls).toEqual([]); + }); +}); + +describe('幂等键', () => { + it('同键的上一封还在飞:不发第二封,如实回没发出去', async () => { + const { service, accounts, outbound, imap, clock } = harness(); + await accounts.save(account()); + await outbound.insert(outboundRow({ createdAt: clock.now() })); + const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' }); + expect(result.state).toBe('failed'); + expect(result.error).toContain('仍在发送中'); + expect(imap.sent).toHaveLength(0); + expect(outbound.rows.size).toBe(1); + expect(outbound.rows.get('out-9')?.state).toBe('sending'); + }); + + it('同键的 sending 停太久:当上次崩在半路,复用那一行重发', async () => { + const { service, accounts, outbound, imap, clock } = harness(); + await accounts.save(account()); + await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) })); + const result = await service.send({ ...sendInput, idempotencyKey: 'k-1' }); + expect(result.state).toBe('sent'); + expect(imap.sent).toHaveLength(1); + expect(outbound.rows.get('out-9')?.state).toBe('sent'); + // 复用,不是新开一行 + expect(outbound.rows.size).toBe(1); + expect(result.outboundId).toBe('out-9'); + }); + + it('**并发复用要抢**:同键两路同时重发,真信只出一封,另一路如实回没发出去', async () => { + /* + * 本仓是两个后端连同一个库:两台同时重放同一次放行,都会读到同一行 `failed`、都判「可以复用」。 + * 没有那一句条件更新的话,两台都发 —— **两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。 + */ + const { service, accounts, outbound, imap } = harness(); + await accounts.save(account()); + await outbound.insert(outboundRow({ state: 'failed', error: '上一次没发出去' })); + + const [a, b] = await Promise.all([ + service.send({ ...sendInput, idempotencyKey: 'k-1' }), + service.send({ ...sendInput, idempotencyKey: 'k-1' }), + ]); + expect(imap.sent).toHaveLength(1); + const states = [a.state, b.state].sort(); + expect(states).toEqual(['failed', 'sent']); + const loser = a.state === 'failed' ? a : b; + expect(loser.error).toContain('本次未重复发送'); + expect(loser.outboundId).toBe('out-9'); + // 抢输的那一路一个字都没往外发,也没新开一行 + expect(outbound.rows.size).toBe(1); + expect(outbound.rows.get('out-9')?.state).toBe('sent'); + }); + + it('**超时 sending 的复用同样要抢**:抢不到就走「未重复发送」那一支', async () => { + const { service, accounts, outbound, imap, clock } = harness(); + await accounts.save(account()); + await outbound.insert(outboundRow({ createdAt: new Date(clock.now().getTime() - 6 * 60_000) })); + const [a, b] = await Promise.all([ + service.send({ ...sendInput, idempotencyKey: 'k-1' }), + service.send({ ...sendInput, idempotencyKey: 'k-1' }), + ]); + expect(imap.sent).toHaveLength(1); + expect([a.state, b.state].sort()).toEqual(['failed', 'sent']); + }); + + it('失败重发成功之后,那一行不留上一次的错误话', async () => { + const { service, accounts, outbound, imap } = harness(); + await accounts.save(account()); + imap.failSendWith(new Error('暂时不行')); + const first = await service.send({ ...sendInput, idempotencyKey: 'k-1' }); + expect(first.state).toBe('failed'); + expect(outbound.rows.get(first.outboundId)?.error).toContain('暂时不行'); + + imap.failSendWith(undefined); + const second = await service.send({ ...sendInput, idempotencyKey: 'k-1' }); + expect(second.state).toBe('sent'); + expect(outbound.rows.get(first.outboundId)?.error).toBeNull(); + expect((await service.listOutbound({}))[0]?.error).toBeNull(); + + // 第三次同键走 resultOf(prior):既说成功又带错误的结果会让调用方按 error 分支判反 + const third = await service.send({ ...sendInput, idempotencyKey: 'k-1' }); + expect(third.state).toBe('sent'); + expect(third.error).toBeUndefined(); + expect(imap.sent).toHaveLength(1); + }); +}); + +describe('卡死的 dispatching', () => { + const parsed: ParsedMail = { + messageId: '', + references: [], + from: { address: 'buyer@acme.com', name: '买家' }, + to: [{ address: 'sales@we.com', name: '销售' }], + cc: [], + subject: '询价', + text: '请报价', + attachments: [], + }; + + it('自动路径不进(墙),人按的重新投递能接管(路)', async () => { + const { service, accounts, inbound, clock } = harness(); + await accounts.save(account()); + await inbound.insert( + buildInboundRecord({ + id: 'in-1', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid-1', + parsed, + eml: new Uint8Array(), + receivedAt: clock.now(), + attachments: [], + now: clock.now(), + }), + ); + let calls = 0; + service.subscribe('channel', async () => { + calls += 1; + }); + + // 进程崩在写 dispatching 与写结果之间:这条留痕永远停在 dispatching + const stuck = { mailId: 'in-1', subscriber: 'channel', state: 'dispatching' as const, error: null, finishedAt: null }; + await inbound.setDelivery({ ...stuck, startedAt: new Date(clock.now().getTime() - 11 * 60_000) }); + const reclaimed = await service.redeliver('in-1'); + expect(reclaimed[0]?.state).toBe('done'); + expect(calls).toBe(1); + + // 新鲜的那条仍是墙:人按也不抢,免得两条线同时改一件事 + await inbound.setDelivery({ ...stuck, startedAt: clock.now() }); + const skipped = await service.redeliver('in-1'); + expect(skipped[0]?.state).toBe('dispatching'); + expect(calls).toBe(1); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts new file mode 100644 index 0000000..00fde95 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts @@ -0,0 +1,349 @@ +/** + * 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、 + * 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。 + */ +import { describe, expect, it } from 'vitest'; +import type { MailInboundDetail } from '@contracts'; +import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts'; +import { REANCHOR_NOTE } from '../internal/adapter/imap-smtp-provider.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import type { InboundStore, MimeParser } from '../port.ts'; +import { + account, + eml, + fakeProvider, + fixedClock, + memoryAccounts, + memoryAttachments, + memoryInbound, + memoryOutbound, + plainSecrets, + rawMessage, + seqIds, +} from './fakes.ts'; + +function harness(over: { perTick?: number; mime?: MimeParser; inbound?: InboundStore } = {}) { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const inbound = over.inbound ?? memoryInbound(); + const attachments = memoryAttachments(); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const changes: string[] = []; + const service = createMailService({ + accounts, + inbound, + outbound: memoryOutbound(), + attachments, + providers: { 'imap-smtp': imap, wecom }, + mime: over.mime ?? createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('in'), + notify: { changed: (r) => void changes.push(r) }, + limits: { perTick: over.perTick ?? 50 }, + }); + return { service, accounts, inbound, attachments, imap, wecom, clock, changes }; +} + +/** 解析到点名那封就炸 —— 「毒信」的最小可复现形(真实触发器是 MIME 解出二进制垃圾) */ +function poisonMime(poison: string): MimeParser { + const real = createPostalMimeParser(); + return { + async parse(emlBytes) { + if (new TextDecoder().decode(emlBytes).includes(poison)) throw new Error('MIME 解析炸了:invalid byte sequence'); + return real.parse(emlBytes); + }, + }; +} + +describe('拉取', () => { + it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.queue( + rawMessage('uid:v1:1', eml({ messageId: '', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })), + rawMessage('uid:v1:2', eml({ messageId: '', subject: '二' })), + ); + const outcome = await h.service.pollNow('acc-1'); + expect(outcome).toEqual({ accountId: 'acc-1', pulled: 2, stored: 2, dispatched: 2, more: false }); + const saved = h.accounts.rows.get('acc-1'); + expect(saved?.cursor).toEqual(imapCursor('v1', 2)); + expect(saved?.lastPolledAt).toEqual(h.clock.now()); + expect(saved?.lastInboundAt).not.toBeNull(); + expect(saved?.lastPollError).toBeNull(); + expect(saved?.pollingUntil).toBeNull(); + const list = await h.service.listInbound({}); + // 两封 receivedAt 相同(夹具默认值),按插入顺序稳定排 —— 只断集合,不赌顺序 + expect(list.map((m) => m.subject).sort()).toEqual(['一', '二']); + const one = list.find((m) => m.subject === '一'); + expect(one?.attachments[0]?.ref).toBe('up-1'); + expect(h.attachments.files.get('up-1')?.note).toBe('邮件附件:一'); + expect(h.changes).toContain('inbound'); + }); + + it('重复拉到(同 provider_ref 或同 Message-ID)不入库不分发、附件不重存', async () => { + const h = harness(); + await h.accounts.save(account()); + const hits: string[] = []; + h.service.subscribe('spy', async (m) => void hits.push(m.messageId)); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } }))); + await h.service.pollNow('acc-1'); + h.imap.queue( + // 撞 providerRef 的这封**带附件**:守卫在存附件之前,所以「不重存」这句话才验得出来 + // (原来两封都没附件,files.size 恒为 1,改坏实现照样绿) + rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'dup.txt', contentType: 'text/plain', content: 'y' } })), + // 注:撞 **messageId**(而 providerRef 是新的)那条路,附件会在 insert 报重复之前就存下 —— + // 留下一份没人引用的孤儿。已记欠账(要根治得给 InboundStore 加一个按 messageId 查重的口),本轮不在此坐实。 + rawMessage('uid:v1:9', eml({ messageId: '' })), + ); + const again = await h.service.pollNow('acc-1'); + expect(again.pulled).toBe(2); + expect(again.stored).toBe(0); + expect(hits).toEqual(['']); + expect(h.attachments.files.size).toBe(1); + }); + + it('每轮封顶 perTick:余下的报 more=true,下一轮接着;wecom 形整批推进游标', async () => { + const h = harness({ perTick: 2 }); + await h.accounts.save(account({ id: 'w', kind: 'wecom', name: '应用', address: 'app@corp.wecom.work', secretCipher: `sealed:${JSON.stringify({ corpSecret: 'cs' })}`, settings: { corpId: 'ww1' } })); + h.wecom.queue(rawMessage('mid-1', eml({ messageId: '<1@a>' })), rawMessage('mid-2', eml({ messageId: '<2@a>' })), rawMessage('mid-3', eml({ messageId: '<3@a>' }))); + const first = await h.service.pollNow('w'); + expect(first).toMatchObject({ pulled: 2, stored: 2, more: true }); + expect(h.accounts.rows.get('w')?.cursor?.kind).toBe('wecom'); + const second = await h.service.pollNow('w'); + expect(second).toMatchObject({ pulled: 1, stored: 1, more: false }); + }); + + it('拉取抛错:第二次调用才炸(不是从没机会挪动的第一次)—— 游标停在上一轮的成功处、记 last_poll_error(脱敏)、租约还了、下一轮照常', async () => { + /* + * 这只假 provider 的 pull() 是原子的(一次要么整批给、要么整批不给),且总是把能给的一次给满 —— + * 因此单次 pollNow() 内永远只有一次真实的 pull() 调用(实测验证过)。 + * 要测「中途失败、游标停在最后一封成功处」,失败就不能发生在第一次调用上(那样游标压根没机会挪动, + * "没动"是因为它从来没有过机会,不是因为它被稳稳地拦住了)。这里让第一次调用(第一轮 pollNow) + * 先成功推进游标,第二次调用(第二轮 pollNow)才炸 —— 借新加的 afterCalls 钉死"哪一次调用开始炸"。 + */ + const h = harness({ perTick: 1 }); // 每轮只吃一封,好让第一轮先真的往前挪一格 + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' })), rawMessage('uid:v1:2', eml({ messageId: '' }))); + h.imap.failPullWith(new Error('LOGIN pw-1234 rejected'), { afterCalls: 1 }); + + const first = await h.service.pollNow('acc-1'); + expect(first).toMatchObject({ stored: 1, more: true }); + expect(first.error).toBeUndefined(); + expect(h.accounts.rows.get('acc-1')?.cursor).toEqual(imapCursor('v1', 1)); // 第一轮真的挪到了 uid 1 + + const outcome = await h.service.pollNow('acc-1'); + expect(outcome.error).toBe('LOGIN *** rejected'); + const saved = h.accounts.rows.get('acc-1'); + // **停在最后一封成功处**(上一轮推进到的 uid 1),不是"从未挪动过" + expect(saved?.cursor).toEqual(imapCursor('v1', 1)); + expect(saved?.lastPollError).toBe('LOGIN *** rejected'); + expect(saved?.pollingUntil).toBeNull(); + + h.imap.failPullWith(undefined); + const third = await h.service.pollNow('acc-1'); + expect(third.error).toBeUndefined(); + expect(third.stored).toBe(1); // 第二封这才真正拉到 + expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull(); + }); + + it('凭证空:不连 provider,记「未配置凭证」;停用 → 412;租约被占 → 409', async () => { + const h = harness(); + await h.accounts.save(account({ secretCipher: null })); + const outcome = await h.service.pollNow('acc-1'); + expect(outcome.error).toContain('未配置凭证'); + expect(h.imap.pullCalls).toHaveLength(0); + await h.accounts.save(account({ enabled: false })); + await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailAccountDisabledError); + // 租约是**运行列**:`save()` 只写配置列(两台后端连同一个库时整行 upsert 会盖掉别人的租约), + // 所以这里按真路径占它 —— claimPolling 就是另一台机器那一下 + await h.accounts.save(account()); + expect(await h.accounts.claimPolling('acc-1', 60_000)).toBe(true); + await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailPollBusyError); + }); + + it('pollDue:到期的账号各拉一轮,一个出错不影响别的', async () => { + const h = harness(); + await h.accounts.save(account({ id: 'a', name: 'a', address: 'a@we.com' })); + await h.accounts.save(account({ id: 'b', name: 'b', address: 'b@we.com', secretCipher: null })); + await h.accounts.save(account({ id: 'c', name: 'c', address: 'c@we.com', enabled: false })); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + const outcomes = await h.service.pollDue(h.clock.now()); + expect(outcomes.map((o) => o.accountId).sort()).toEqual(['a', 'b']); + expect(outcomes.find((o) => o.accountId === 'a')?.stored).toBe(1); + expect(outcomes.find((o) => o.accountId === 'b')?.error).toContain('未配置凭证'); + }); +}); + +describe('毒信不许卡死整条路(降级存档)', () => { + it('第一封解析必炸:降级行落库、游标越过它、第二封照常入库、error 点了名', async () => { + const h = harness({ mime: poisonMime('毒') }); + await h.accounts.save(account()); + h.imap.queue( + rawMessage('uid:v1:1', eml({ messageId: '', subject: '毒信一封' })), + rawMessage('uid:v1:2', eml({ messageId: '', subject: '好信' })), + ); + const hits: string[] = []; + h.service.subscribe('channel', async (m) => void hits.push(m.subject)); + + const outcome = await h.service.pollNow('acc-1'); + // 两封都落了库(降级的那封也算 stored),但只有解得开的那封投给了订阅方 + expect(outcome).toMatchObject({ pulled: 2, stored: 2, dispatched: 1 }); + expect(hits).toEqual(['好信']); + + const list = await h.service.listInbound({}); + const degraded = list.find((m) => m.messageId.includes('uid:v1:1')); + // 主题从原始字节里扒出来 —— 人在列表上认得出是哪一封卡住了 + expect(degraded?.subject).toBe('毒信一封'); + expect(degraded?.textPreview).toContain('降级存档'); + expect(list.map((m) => m.subject).sort()).toEqual(['好信', '毒信一封']); + + // **游标越过它**:下一轮不会再从这一封重来(否则后面所有新信永远进不来) + const saved = h.accounts.rows.get('acc-1'); + expect(saved?.cursor).toEqual(imapCursor('v1', 2)); + // 顺利收尾也要说实话:第几封、是谁 + expect(saved?.lastPollError).toContain('第 1 封'); + expect(saved?.lastPollError).toContain('uid:v1:1'); + expect(saved?.lastPollError).toContain('已降级存档'); + expect(outcome.error).toBe(saved?.lastPollError); + + // 降级行原件仍在:人点得开、看得到原文 + expect((await h.service.raw('in-1')).bytes.byteLength).toBeGreaterThan(0); + // 它没有任何投递痕 —— 正文是失败原因不是客户的话,不自动投;人可按「重新投递」 + expect((await h.service.getInbound('in-1')).deliveries).toEqual([]); + }); + + it('骨架也落不下:停在这一封,error 点名是谁卡着(不静默跳过,那是丢信)', async () => { + const inbound = memoryInbound(); + const real = inbound.insert.bind(inbound); + let refuse = true; + // 入库这一步整个不行(磁盘满 / 约束冲突 / 列型不合)—— 连最小骨架都写不下去 + inbound.insert = async (record) => (refuse ? Promise.reject(new Error('入库失败:disk full')) : real(record)); + const h = harness({ inbound }); + await h.accounts.save(account({ cursor: imapCursor('v1', 0) })); + h.imap.queue(rawMessage('uid:v1:7', eml({ messageId: '' }))); + + const outcome = await h.service.pollNow('acc-1'); + expect(outcome.error).toContain('uid:v1:7'); + expect(outcome.error).toContain('降级骨架也落不下'); + const saved = h.accounts.rows.get('acc-1'); + expect(saved?.lastPollError).toBe(outcome.error); + // 停在这一封**之前**:数据一封都没丢,人知道是谁卡着,修好了下一轮接着拉 + expect(saved?.cursor).toEqual(imapCursor('v1', 0)); + + refuse = false; + h.imap.queue(rawMessage('uid:v1:7', eml({ messageId: '' }))); + const recovered = await h.service.pollNow('acc-1'); + expect(recovered.stored).toBe(1); + expect(recovered.error).toBeUndefined(); + }); +}); + +describe('provider 说的话要落到界面上', () => { + it('UIDVALIDITY 变化重锚:这一轮不算失败,但 last_poll_error 里有那句说明', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.noteWith(REANCHOR_NOTE); + h.imap.queue(rawMessage('uid:v2:1', eml({ messageId: '' }))); + const outcome = await h.service.pollNow('acc-1'); + expect(outcome.stored).toBe(1); // 拉到的照常入库 + expect(outcome.error).toBe(REANCHOR_NOTE); + expect(h.accounts.rows.get('acc-1')?.lastPollError).toContain('UIDVALIDITY'); + expect(h.accounts.rows.get('acc-1')?.lastPollError).toContain('期间来信未拉取'); + // 说明只属于报了它的那一轮:下一轮没话说就清干净,不留一条过期的红字 + h.imap.noteWith(undefined); + expect((await h.service.pollNow('acc-1')).error).toBeUndefined(); + expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull(); + }); +}); + +describe('列表与分发不搬原件', () => { + it('list / getMeta 不带 raw 字节,而 rawAvailable 仍然正确;下载原件那条路照旧拿得到', async () => { + /* + * 一封原件上限 16MB、列表上限 200 行:每次 SSE 失效重取都可能搬几百 MB 只为算 200 字预览。 + * 这条断言在 store 那一层 —— 服务出参本来就不带 raw,只有真去数 store 交出来的东西才验得出。 + */ + const h = harness(); + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + await h.service.pollNow('acc-1'); + + const listed = await h.inbound.list({ limit: 10 }); + expect(listed[0]?.record.raw).toBeNull(); + expect(listed[0]?.record.rawAvailable).toBe(true); + const meta = await h.inbound.getMeta('in-1'); + expect(meta?.record.raw).toBeNull(); + expect(meta?.record.rawAvailable).toBe(true); + // 详情面的 rawAvailable 读的是投影,不是「字节在不在手上」 + expect((await h.service.getInbound('in-1')).rawAvailable).toBe(true); + // 下载原件仍拿得到字节 + expect((await h.service.raw('in-1')).bytes.byteLength).toBeGreaterThan(0); + }); +}); + +describe('分发', () => { + it('订阅方收到详情与 ctx(账号 / 通道);skipped 与 failed 各自留痕,互不影响;同名注册被拒;注销后不再收', async () => { + const h = harness(); + await h.accounts.save(account({ channelId: 'ch-9' })); + const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = []; + // 注册顺序 ok → boom → skip:出错的 boom **不在最后一个**,后面还有 skip —— + // 若 dispatch 遇错就 break,skip 会被漏投,连它那条留痕都不会存在(而不只是状态不对) + h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId })); + const off = h.service.subscribe('boom', async () => { + throw new Error('通道实例已停用:ch-9'); + }); + h.service.subscribe('skip', async () => ({ skipped: '与我无关' })); + expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', subject: '主题' }))); + await h.service.pollNow('acc-1'); + expect(seen[0]?.mail.subject).toBe('主题'); + expect(seen[0]?.channelId).toBe('ch-9'); + const detail = await h.service.getInbound('in-1'); + expect(detail.deliveries.map((d) => [d.subscriber, d.state, d.error])).toEqual([ + ['boom', 'failed', '通道实例已停用:ch-9'], + ['ok', 'done', null], + ['skip', 'skipped', '与我无关'], + ]); + off(); + h.imap.queue(rawMessage('uid:v1:2', eml({ messageId: '' }))); + await h.service.pollNow('acc-1'); + expect((await h.service.getInbound('in-2')).deliveries.map((d) => d.subscriber)).toEqual(['ok', 'skip']); + }); + + it('重新投递:指定订阅方或全部;正在 dispatching 的跳过;不认识的订阅方 404', async () => { + const h = harness(); + await h.accounts.save(account()); + let fail = true; + h.service.subscribe('flaky', async () => { + if (fail) throw new Error('第一次不行'); + }); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + await h.service.pollNow('acc-1'); + expect((await h.service.getInbound('in-1')).deliveries[0]?.state).toBe('failed'); + fail = false; + const redone = await h.service.redeliver('in-1', 'flaky'); + expect(redone).toEqual([expect.objectContaining({ subscriber: 'flaky', state: 'done' })]); + await expect(h.service.redeliver('in-1', 'nobody')).rejects.toBeInstanceOf(MailSubscriberUnknownError); + await h.inbound.setDelivery({ mailId: 'in-1', subscriber: 'flaky', state: 'dispatching', error: null, startedAt: h.clock.now(), finishedAt: null }); + expect((await h.service.redeliver('in-1'))[0]?.state).toBe('dispatching'); + }); +}); + +describe('附件与原件', () => { + it('attachment 取回字节;超限没存的 404;raw 取回原件', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hello' } }))); + await h.service.pollNow('acc-1'); + const file = await h.service.attachment('in-1', 0); + expect(new TextDecoder().decode(file.bytes)).toBe('hello'); + expect(file.name).toBe('a.txt'); + await expect(h.service.attachment('in-1', 5)).rejects.toThrow('附件没有存下来'); + const raw = await h.service.raw('in-1'); + expect(raw.name).toBe('m1_a.eml'); + expect(new TextDecoder().decode(raw.bytes)).toContain('Subject:'); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts b/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts new file mode 100644 index 0000000..8c909ce --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-service-send.test.ts @@ -0,0 +1,109 @@ +/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */ +import { describe, expect, it } from 'vitest'; +import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const outbound = memoryOutbound(); + const attachments = memoryAttachments(); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const service = createMailService({ + accounts, + inbound: memoryInbound(), + outbound, + attachments, + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('out'), + notify: { changed: () => undefined }, + }); + return { service, accounts, outbound, attachments, imap, clock }; +} +const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } }; + +describe('send', () => { + it('默认账号发出:留痕 sent、Message-ID 是 、发件人带显示名、返回 providerRef', async () => { + const h = harness(); + await h.accounts.save(account()); + const result = await h.service.send(base); + expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', providerRef: 'fake:1' }); + expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' }); + expect(h.imap.sent[0]?.messageId).toBe(''); + const row = h.outbound.rows.get('out-1'); + expect(row?.state).toBe('sent'); + expect(row?.sentAt).toEqual(h.clock.now()); + expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] }); + }); + + it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' })); + const result = await h.service.send(base); + expect(result.state).toBe('failed'); + expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected'); + expect(h.outbound.rows.get('out-1')?.state).toBe('failed'); + }); + + it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.failSendWith(new Error('暂时不行')); + const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(failed.state).toBe('failed'); + h.imap.failSendWith(undefined); + const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); + const again = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); + expect(h.imap.sent).toHaveLength(1); + expect(h.outbound.rows.size).toBe(1); + }); + + it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => { + const h = harness(); + await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError); + await h.accounts.save(account()); + await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError); + await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError); + await h.accounts.save(account({ secretCipher: null })); + await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError); + expect(h.outbound.rows.size).toBe(0); + }); + + it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => { + const h = harness(); + await h.accounts.save(account()); + const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' }); + await h.service.send({ + ...base, + attachments: [ + { name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref }, + { name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') }, + ], + }); + expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([ + ['q.pdf', 'application/pdf', 2], + ['n.txt', 'text/plain', 2], + ]); + await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError); + await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError); + }); + + it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => { + const h = harness(); + await h.accounts.save(account({ isDefault: false })); + const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '', references: [''], requestedBy: { kind: 'channel', taskId: 't-1' } }); + expect(result.accountId).toBe('acc-1'); + expect(h.imap.sent[0]?.inReplyTo).toBe(''); + expect(h.imap.sent[0]?.references).toEqual(['']); + expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' }); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts b/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts new file mode 100644 index 0000000..4cc9a07 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/mail-store.it.test.ts @@ -0,0 +1,302 @@ +/** + * mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、 + * 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。 + * + * 这组用例**共享状态、按顺序跑**:游标那条依赖第一条落下的 acc-a,listDue / SQL-domain 同源 + * 依赖前面存的那一批账号,投递与「不搬原件」两条依赖入站那条写下的 in-1 —— + * 单独用 `-t` 挑一条跑会失败,这是设计如此,不是缺陷。 + * 同理:新增用例要**摆在对列表顺序有精确断言的那一条之后**(否则新行会挤进它的期望里)。 + */ +import { readFileSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import type { Db } from '@shared/db.ts'; +import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import { isDue } from '../internal/domain/due.ts'; +import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts'; + +/* + * **独占一个库**:这份测试要 drop/建 `sys.mail_*` 四张表,其中 `sys.mail_account` 是**配置表** + * (刻意不进 reset-runs)。若落在共享库上,开发者导出过 DATABASE_URL 的那一刻, + * 一次 `pnpm gates` 就会把他真实配好的邮箱账号连同密封凭证删掉。 + * 所以照 route-coverage.it.test.ts 的先例:从 base URL 换出一个 `jcai_it_mail` 来,谁的库都不碰。 + */ +const IT_DB = 'jcai_it_mail'; + +async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { + const base = process.env['JCAI_TEST_DATABASE_URL'] ?? process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; + try { + const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); + const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); + if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); + await admin.end(); + return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; + } catch { + /* 开发实例连不上,试 Testcontainers */ + } + try { + const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); + const started = await new PostgreSqlContainer('postgres:16-alpine').start(); + return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; + } catch (error) { + process.stderr.write( + `⚠️ mail store it 跳过:既连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, + ); + return undefined; + } +} + +const database = await provisionDatabase(); +let poolA: Pool | undefined; +let poolB: Pool | undefined; +let db: Db; +let dbB: Db; + +const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url); +const now = new Date('2026-08-25T10:00:00Z'); + +function account(over: Partial): MailAccountRecord { + return { + id: 'acc-a', kind: 'imap-smtp', name: '甲', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, + pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: 'v1.x.y.z', + cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, + ...over, + }; +} +function inbound(over: Partial): InboundRecord { + return { + id: 'in-1', accountId: 'acc-a', messageId: '', providerRef: 'uid:7:1', inReplyTo: null, references: [], + from: { address: 'b@acme.com', name: '' }, to: [{ address: 'a@we.com', name: '' }], cc: [], subject: 's', text: 't', html: null, + sentAt: null, receivedAt: now, attachments: [], raw: new Uint8Array([1, 2, 3]), rawAvailable: true, rawNote: '', size: 3, createdAt: now, + ...over, + }; +} +function outbound(over: Partial): OutboundRecord { + return { + id: 'out-1', accountId: 'acc-a', messageId: '', idempotencyKey: null, to: ['b@acme.com'], cc: [], bcc: [], + subject: 's', text: 't', html: null, inReplyTo: null, references: [], attachments: [], state: 'sending', error: null, + providerRef: null, requestedBy: { kind: 'ops' }, createdAt: now, sentAt: null, + ...over, + }; +} + +beforeAll(async () => { + if (database === undefined) return; + poolA = new Pool({ connectionString: database.url, max: 2 }); + poolB = new Pool({ connectionString: database.url, max: 2 }); + // 新库(jcai_it_mail)里没有 sys schema —— 迁移文件假定它已存在(开发库由 db:migrate 建过) + await poolA.query('create schema if not exists sys'); + await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade'); + await poolA.query(readFileSync(MIGRATION, 'utf8')); + db = drizzle(poolA); + dbB = drizzle(poolB); +}); + +afterAll(async () => { + await poolA?.end(); + await poolB?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('mail store(真 PG)', () => { + it('账号:save / get / findByName / list;isDefault 只留一个', async () => { + const store = createMailAccountStore(db); + await store.save(account({ id: 'acc-a', name: '甲', isDefault: true })); + await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', isDefault: true })); + expect((await store.get('acc-a'))?.isDefault).toBe(false); + expect((await store.get('acc-b'))?.isDefault).toBe(true); + expect((await store.findByName('甲'))?.id).toBe('acc-a'); + /* + * 排序键是 name(与 channel / llm 两个 store 同惯例:这份清单是运营面给人看的账号列表)。 + * 中文名的先后依 PG 的 collation 而定 —— 本机 en_US.utf8 与 alpine 的 C 都按码点把「乙」排在「甲」前, + * 而 ICU 的中文 collation 按拼音会反过来。所以这里断言**集合**,不断言序: + * 这条用例要验的是「两个账号都回来了」,不是「谁在前」。 + */ + expect((await store.list()).map((a) => a.id).sort()).toEqual(['acc-a', 'acc-b']); + }); + + it('游标 jsonb 存取回来还是同一形;savePoll 只改给了的列', async () => { + const store = createMailAccountStore(db); + await store.savePoll('acc-a', { cursor: imapCursor('7', 12), polledAt: now, error: null }); + const got = await store.get('acc-a'); + expect(got?.cursor).toEqual(imapCursor('7', 12)); + expect(got?.lastPolledAt?.toISOString()).toBe(now.toISOString()); + await store.savePoll('acc-a', { error: '连不上' }); + expect((await store.get('acc-a'))?.cursor).toEqual(imapCursor('7', 12)); + expect((await store.get('acc-a'))?.lastPollError).toBe('连不上'); + await expect(store.savePoll('no-such', { error: 'x' })).resolves.toBeUndefined(); + }); + + it('listDue:从没拉过 / 到点的 / 租约过期的算;停用 / 没到点 / 租约在手的不算', async () => { + const store = createMailAccountStore(db); + /* + * **运行列走 savePoll,不走 save** —— `save()` 只写配置列(见 accountConfigValues): + * 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并盖回去。 + * acc-a / acc-b 在前面的用例里已经存在,所以它们的 lastPolledAt 必须显式 savePoll; + * acc-c / acc-d / acc-e 是新行,运行列跟着 insert 落下即可。 + */ + await store.save(account({ id: 'acc-a', name: '甲' })); + await store.savePoll('acc-a', { polledAt: new Date(now.getTime() - 60_000) }); + await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com' })); + await store.savePoll('acc-b', { polledAt: new Date(now.getTime() - 5_000) }); + await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false })); + await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) })); + // 恰好到点的那一行:钉住区间比较符是 `<=` 而不是 `<`(SQL 与 domain 都该判它到期) + await store.save(account({ id: 'acc-e', name: '戊', address: 'e@we.com', lastPolledAt: new Date(now.getTime() - 30_000) })); + expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a', 'acc-e']); + }); + + it('**save 只写配置列**:占着租约的账号被 save 一次,租约与游标一个字不动;要清游标显式单写那一列', async () => { + /* + * 08-24 `loop_state` 整份覆盖那一课的同款指纹:`mergeAccount` 里的运行列是**读那一刻的快照**, + * 运营面 PUT 的同时另一台正拉着 → PUT 把 polling_until 写回旧值 → 第三台抢到租约 → 两台同时拉同一信箱。 + */ + const store = createMailAccountStore(db); + await store.save(account({ id: 'acc-save', name: '存', address: 's@we.com' })); + await store.savePoll('acc-save', { cursor: imapCursor('7', 42), polledAt: now, error: '上一轮连不上', lastInboundAt: now }); + expect(await store.claimPolling('acc-save', 60_000)).toBe(true); + const held = await store.get('acc-save'); + + // 运营面 PUT:只改了显示名,而这一刻另一台正持着租约、游标刚推到 42 + await store.save(account({ id: 'acc-save', name: '存', address: 's@we.com', displayName: '改过', cursor: null, pollingUntil: null })); + const after = await store.get('acc-save'); + expect(after?.displayName).toBe('改过'); + expect(after?.cursor).toEqual(imapCursor('7', 42)); + expect(after?.pollingUntil?.toISOString()).toBe(held?.pollingUntil?.toISOString()); + expect(after?.lastPolledAt?.toISOString()).toBe(now.toISOString()); + expect(after?.lastPollError).toBe('上一轮连不上'); + expect(after?.lastInboundAt?.toISOString()).toBe(now.toISOString()); + // 收侧配置真变了那条路:用例显式单写 cursor 这一列(cursorReset 为真时) + await store.savePoll('acc-save', { cursor: null, error: null }); + const reset = await store.get('acc-save'); + expect(reset?.cursor).toBeNull(); + expect(reset?.lastPollError).toBeNull(); + // 清游标不碰租约 + expect(reset?.pollingUntil?.toISOString()).toBe(held?.pollingUntil?.toISOString()); + await store.releasePolling('acc-save'); + }); + + it('**SQL 与 domain 的到期判据同源**:listDue 的返回集合 == 用 isDue 过滤 list() 的结果', async () => { + // 同一条「到期」判据两处各存一份实现(SQL 里的 listDue / domain 的 isDue)—— + // 本测钉住二者不悄悄分叉:哪天 SQL 把 `<=` 写成 `<`、或漏掉租约那一段,这里立刻炸。 + const store = createMailAccountStore(db); + const fromSql = (await store.listDue(now)).map((a) => a.id).sort(); + const fromDomain = (await store.list()) + .filter((a) => isDue(a, now)) + .map((a) => a.id) + .sort(); + expect(fromSql).toEqual(fromDomain); + }); + + it('**跨进程租约**:两条真连接对抢 20 轮,每轮恰好一个赢;还了再抢得到;过期了别人接得上', async () => { + const a = createMailAccountStore(db); + const b = createMailAccountStore(dbB); + await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com' })); + for (let round = 0; round < 20; round += 1) { + const wins = await Promise.all([a.claimPolling('acc-lease', 60_000), b.claimPolling('acc-lease', 60_000)]); + expect(wins.filter(Boolean)).toHaveLength(1); + await a.releasePolling('acc-lease'); + } + expect(await a.claimPolling('acc-lease', 60_000)).toBe(true); + expect(await b.claimPolling('acc-lease', 60_000)).toBe(false); + await poolA!.query(`update sys.mail_account set polling_until = now() - interval '1 second' where id = 'acc-lease'`); + expect(await b.claimPolling('acc-lease', 60_000)).toBe(true); + await b.releasePolling('acc-lease'); + // 停用的账号抢不到 + await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com', enabled: false })); + expect(await a.claimPolling('acc-lease', 60_000)).toBe(false); + }); + + it('入站:两把唯一键都回 inserted:false;knownProviderRefs 只回库里有的;raw 拷回来是独立字节', async () => { + const store = createInboundStore(db); + expect(await store.insert(inbound({}))).toEqual({ inserted: true }); + expect(await store.insert(inbound({ id: 'in-dup-msg', providerRef: 'uid:7:2' }))).toEqual({ inserted: false }); + expect(await store.insert(inbound({ id: 'in-dup-ref', messageId: '' }))).toEqual({ inserted: false }); + expect(await store.knownProviderRefs('acc-a', ['uid:7:1', 'uid:7:99'])).toEqual(new Set(['uid:7:1'])); + expect(await store.knownProviderRefs('acc-a', [])).toEqual(new Set()); + const got = await store.get('in-1'); + expect(got?.record.raw).toEqual(new Uint8Array([1, 2, 3])); + expect(got?.record.raw).toBeInstanceOf(Uint8Array); + expect(Buffer.isBuffer(got?.record.raw)).toBe(false); + expect(got?.record.rawAvailable).toBe(true); + }); + + it('投递:同一 (mail, subscriber) upsert;list 按收信时间倒序并带投递', async () => { + const store = createInboundStore(db); + await store.insert(inbound({ id: 'in-2', messageId: '', providerRef: 'uid:7:2', receivedAt: new Date(now.getTime() + 1000) })); + await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'dispatching', error: null, startedAt: now, finishedAt: null }); + await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }); + const list = await store.list({ accountId: 'acc-a', limit: 10 }); + expect(list.map((x) => x.record.id)).toEqual(['in-2', 'in-1']); + expect(list[0]?.deliveries).toEqual([{ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }]); + expect(await store.list({ accountId: 'acc-zzz', limit: 10 })).toEqual([]); + expect((await store.list({ limit: 1 })).map((x) => x.record.id)).toEqual(['in-2']); + }); + + it('**列表与元信息读不搬原件**:raw 不取回来,而 rawAvailable 由 `raw is not null` 投影出来', async () => { + /* + * 一封原件上限 16MB、列表默认 50 行 / 上限 200:每次 SSE 失效重取都可能把几百 MB 搬进内存, + * 只为算一段 200 字预览。判「有没有原件」不必先把它扛回来。 + */ + const store = createInboundStore(db); + await store.insert(inbound({ id: 'in-noraw', messageId: '', providerRef: 'uid:7:9', raw: null, rawAvailable: false, rawNote: '原件超限未存' })); + const listed = await store.list({ accountId: 'acc-a', limit: 10 }); + for (const row of listed) expect(row.record.raw).toBeNull(); + expect(listed.find((r) => r.record.id === 'in-1')?.record.rawAvailable).toBe(true); + expect(listed.find((r) => r.record.id === 'in-noraw')?.record.rawAvailable).toBe(false); + + const meta = await store.getMeta('in-1'); + expect(meta?.record.raw).toBeNull(); + expect(meta?.record.rawAvailable).toBe(true); + expect(meta?.record.subject).toBe('s'); // 元信息一样不少 + expect(await store.getMeta('in-none')).toBeUndefined(); + // 下载原件那条路照旧拿得到字节 + expect((await store.get('in-1'))?.record.raw).toEqual(new Uint8Array([1, 2, 3])); + expect((await store.get('in-noraw'))?.record.rawAvailable).toBe(false); + }); + + it('出站:insert / byIdempotencyKey / setState / list', async () => { + const store = createOutboundStore(db); + await store.insert(outbound({ id: 'out-1', idempotencyKey: 'k-1' })); + await store.insert(outbound({ id: 'out-2', messageId: '' })); + expect((await store.byIdempotencyKey('k-1'))?.id).toBe('out-1'); + expect(await store.byIdempotencyKey('k-none')).toBeUndefined(); + await store.setState('out-1', 'sent', { providerRef: 'smtp:250 ok', sentAt: now }); + await store.setState('out-2', 'failed', { error: '535 auth failed' }); + const list = await store.list({ accountId: 'acc-a', limit: 10 }); + expect(list.find((o) => o.id === 'out-1')).toMatchObject({ state: 'sent', providerRef: 'smtp:250 ok' }); + expect(list.find((o) => o.id === 'out-2')).toMatchObject({ state: 'failed', error: '535 auth failed' }); + // 同一幂等键第二次 insert 撞唯一索引 —— 用例层先查后写,这里只证明索引在 + await expect(store.insert(outbound({ id: 'out-3', idempotencyKey: 'k-1' }))).rejects.toThrow(); + }); + + it('**复用旧行要抢**:两条真连接同时抢同一行,只有一个抢得到;sent 的行谁也抢不到', async () => { + /* + * 两个后端连同一个库时,两台会同时读到同一行 `failed` / 超时 `sending` 并都判「可以复用」—— + * 没有这一句条件更新,两台都发,就是两封真邮件(契约白纸黑字的「同键不发两封」当场破掉)。 + */ + const a = createOutboundStore(db); + const b = createOutboundStore(dbB); + const staleBefore = new Date(now.getTime() - 5 * 60_000); + await a.insert(outbound({ id: 'out-race', messageId: '', state: 'failed', error: '上一次没发出去' })); + const wins = await Promise.all([ + a.claimForResend('out-race', { staleBefore, now }), + b.claimForResend('out-race', { staleBefore, now }), + ]); + expect(wins.filter(Boolean)).toHaveLength(1); + const row = (await a.list({ accountId: 'acc-a', limit: 50 })).find((o) => o.id === 'out-race'); + // 抢到即置 sending 并把 created_at 推到 now —— 那一行当场离开可抢的集合 + expect(row?.state).toBe('sending'); + expect(row?.error).toBeNull(); + expect(await b.claimForResend('out-race', { staleBefore, now })).toBe(false); + + // 停够了才算「上次崩在半路」:把 created_at 推到 staleBefore 之前,又抢得到 + expect(await b.claimForResend('out-race', { staleBefore: new Date(now.getTime() + 60_000), now })).toBe(true); + // 已经发出去的那一行谁也抢不到 + await a.setState('out-race', 'sent', { sentAt: now }); + expect(await a.claimForResend('out-race', { staleBefore: new Date(now.getTime() + 60_000), now })).toBe(false); + expect(await a.claimForResend('out-none', { staleBefore, now })).toBe(false); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts b/apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts new file mode 100644 index 0000000..0047ea2 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts @@ -0,0 +1,81 @@ +/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */ +import { describe, expect, it } from 'vitest'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { eml } from './fakes.ts'; + +const parser = createPostalMimeParser(); +const CRLF = '\r\n'; + +describe('createPostalMimeParser', () => { + it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => { + const parsed = await parser.parse( + eml({ + from: '"张三" ', + to: 'sales@we.com, "Ops" ', + subject: '询价 RFQ-1', + messageId: '', + inReplyTo: '', + references: ' ', + text: '你好,请报价', + }), + ); + expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(parsed.to).toEqual([ + { address: 'sales@we.com', name: '' }, + { address: 'ops@we.com', name: 'Ops' }, + ]); + expect(parsed.subject).toBe('询价 RFQ-1'); + expect(parsed.messageId).toBe(''); + expect(parsed.inReplyTo).toBe(''); + expect(parsed.references).toEqual(['', '']); + expect(parsed.text?.trim()).toBe('你好,请报价'); + expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z'); + }); + + it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => { + const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价 + const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好 + const raw = [ + 'From: buyer@acme.com', + 'To: sales@we.com', + `Subject: =?GBK?B?${subject}?=`, + 'Content-Type: text/plain; charset=gbk', + 'Content-Transfer-Encoding: base64', + '', + body, + '', + ].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.subject).toBe('询价'); + expect(parsed.text?.trim()).toBe('你好'); + }); + + it('群组地址摊平;没有 Cc 就是空数组', async () => { + const raw = ['From: a@x.com', 'To: team: b@x.com, "C" ;', 'Subject: s', '', 'body', ''].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.to).toEqual([ + { address: 'b@x.com', name: '' }, + { address: 'c@x.com', name: 'C' }, + ]); + expect(parsed.cc).toEqual([]); + }); + + it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => { + const raw = ['Subject: x', 'Content-Type: text/html', '', 'only html', ''].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.text).toBeUndefined(); + expect(parsed.html).toContain('only html'); + expect(parsed.messageId).toBeUndefined(); + expect(parsed.from).toBeUndefined(); + expect(parsed.date).toBeUndefined(); + }); + + it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => { + const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } })); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0]?.name).toBe('bom.xlsx'); + expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel'); + expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz'); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/redact.test.ts b/apps/api/src/modules/mail/__tests__/redact.test.ts new file mode 100644 index 0000000..78fd2df --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/redact.test.ts @@ -0,0 +1,40 @@ +/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */ +import { describe, expect, it } from 'vitest'; +import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts'; + +describe('redactSecrets', () => { + it('三形都换成 ***', () => { + const secret = 'p@ss w0rd'; + const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`; + expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***'); + }); + it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => { + expect(secretVariants('')).toEqual([]); + expect(secretVariants('abc')).toEqual([]); + expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc'); + }); + it('没命中原样返回;正则元字符当字面', () => { + expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here'); + expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***'); + }); +}); + +describe('errorText', () => { + it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => { + expect(errorText(new Error('boom'))).toBe('boom'); + const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' }); + expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed'); + expect(errorText('raw')).toBe('raw'); + expect(errorText(undefined)).toBe('未知错误'); + }); + it('**出口剥 NUL** —— 这句话的去向全是 PG 的 text 列,而它的来源常常是那封信自己的字节', () => { + /* + * 降级存档那条路把解析失败的原因写进正文与 `last_poll_error`,原因串里可能带着信里的 NUL。 + * 不剥的话「记下这次失败」这一笔自己也写不进去 —— 治毒信的那道门,自己被同一个字符毒死。 + */ + expect(errorText(new Error('bad\u0000byte'))).toBe('badbyte'); + expect(errorText(Object.assign(new Error('parse failed'), { response: 'at \u0000 offset 12' }))).toBe('parse failed —— at offset 12'); + expect(errorText('\u0000')).toBe(''); + expect(errorText(new Error('clean')).includes('\u0000')).toBe(false); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/send-input.test.ts b/apps/api/src/modules/mail/__tests__/send-input.test.ts new file mode 100644 index 0000000..0269661 --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/send-input.test.ts @@ -0,0 +1,57 @@ +/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */ +import { describe, expect, it } from 'vitest'; +import type { MailAccountRecord } from '../internal/domain/records.ts'; +import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts'; +import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +function account(over: Partial): MailAccountRecord { + return { + id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, + pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null, + lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, + ...over, + }; +} +const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] }; + +describe('validateSend', () => { + it('合法入参零问题', () => { + expect(validateSend(base, 'imap-smtp', [])).toEqual([]); + }); + it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => { + expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']); + expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB'); + expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB'); + }); + it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => { + expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200'); + expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]); + }); +}); + +describe('pickAccount', () => { + const a = account({ id: 'a', isDefault: true }); + const b = account({ id: 'b', address: 'b@we.com', enabled: false }); + it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => { + expect(pickAccount([a, b], {}).id).toBe('a'); + expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError); + }); + it('指定 id:不存在 404、停用 412', () => { + expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a'); + expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError); + expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError); + }); + it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => { + expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a'); + expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError); + expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError); + }); +}); + +describe('messageIdFor', () => { + it(';地址没有域用 local', () => { + expect(messageIdFor('u-1', 'a@we.com')).toBe(''); + expect(messageIdFor('u-1', '')).toBe(''); + }); +}); diff --git a/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts b/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts new file mode 100644 index 0000000..0c77d6f --- /dev/null +++ b/apps/api/src/modules/mail/__tests__/wecom-provider.test.ts @@ -0,0 +1,256 @@ +/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */ +import { describe, expect, it } from 'vitest'; +import { createWecomProvider, tokenKeyOf, WecomApiError } from '../internal/adapter/wecom-provider.ts'; +import type { FetchLike } from '../internal/adapter/wecom-provider.ts'; +import { wecomCursor } from '../internal/domain/cursor.ts'; + +type Call = { url: string; body: Record }; + +function scripted(handlers: Record, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } { + const calls: Call[] = []; + const counts = new Map(); + const fetch: FetchLike = async (url, init) => { + const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record); + calls.push({ url, body }); + const key = Object.keys(handlers).find((k) => url.includes(k)); + if (key === undefined) return { ok: false, status: 404, json: async () => ({}) }; + const nth = (counts.get(key) ?? 0) + 1; + counts.set(key, nth); + return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) }; + }; + return { fetch, calls }; +} + +const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } }; +const known = async (): Promise> => new Set(); +const now = new Date('2026-08-25T10:00:00Z'); +const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n'; + +describe('token', () => { + it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => { + let clock = 0; + let issued = 0; + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }), + 'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }), + }); + const provider = createWecomProvider({ fetch, now: () => clock }); + await provider.probe(account); + await provider.probe(account); + expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次 + clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟 + await provider.probe(account); + expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3); + expect(calls[0]?.url).toContain('corpid=ww1'); + expect(calls[0]?.url).toContain('corpsecret=cs-1'); + }); + + it('**缓存按凭证指纹,不按 account.id**:两家公司的草稿探测都用 id:draft,各取各的 token', async () => { + /* + * 未存账号的探测在用例层一律是 `id:'draft'`(mail-service.ts 的 `stored?.id ?? 'draft'`)。 + * 按 id 缓存时,探完 A 公司再探 B 公司会命中 A 的 token —— 回的是 **A 的应用邮箱地址**, + * 而屏上写着 B 已探到;凭证填错的 B 也会显示成功。 + */ + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': (_b, nth) => ({ errcode: 0, access_token: `tok-${nth}`, expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work' }), + }); + const provider = createWecomProvider({ fetch, now: () => 0 }); + const corpA = { ...account, id: 'draft', settings: { corpId: 'wwAAA' }, secret: { corpSecret: 'cs-a' } }; + const corpB = { ...account, id: 'draft', settings: { corpId: 'wwBBB' }, secret: { corpSecret: 'cs-b' } }; + await provider.probe(corpA); + await provider.probe(corpB); + const tokenCalls = calls.filter((c) => c.url.includes('gettoken')); + expect(tokenCalls).toHaveLength(2); + expect(tokenCalls[0]?.url).toContain('corpid=wwAAA'); + expect(tokenCalls[1]?.url).toContain('corpid=wwBBB'); + // 第二家拿的是自己那份 token,不是第一家的 + expect(calls.filter((c) => c.url.includes('get_email_alias')).map((c) => c.url)).toEqual([ + expect.stringContaining('access_token=tok-1'), + expect.stringContaining('access_token=tok-2'), + ]); + // 再探第一家:凭证没变,还是它那一槽,不重取 + await provider.probe(corpA); + expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); + }); + + it('**同一账号换了 secret 就换槽**:不再拿旧 token 拉旧 corp 的信箱', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': (_b, nth) => ({ errcode: 0, access_token: `tok-${nth}`, expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work' }), + }); + const provider = createWecomProvider({ fetch, now: () => 0 }); + await provider.probe(account); + await provider.probe(account); + expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(1); + // PUT 换了应用 secret,id 一个字没变 —— 旧 token 立刻作废 + await provider.probe({ ...account, secret: { corpSecret: 'cs-2' } }); + const tokenCalls = calls.filter((c) => c.url.includes('gettoken')); + expect(tokenCalls).toHaveLength(2); + expect(tokenCalls[1]?.url).toContain('corpsecret=cs-2'); + }); + + it('指纹里不含明文 secret', () => { + const key = tokenKeyOf('ww1', 'cs-1'); + expect(key).not.toContain('cs-1'); + expect(key).toMatch(/^[0-9a-f]{64}$/); + expect(tokenKeyOf('ww1', 'cs-1')).toBe(tokenKeyOf('ww1', 'cs-1')); + expect(tokenKeyOf('ww1', 'cs-1')).not.toBe(tokenKeyOf('ww2', 'cs-1')); + // 裸拼会让这两组撞在同一个指纹上 + expect(tokenKeyOf('ww1', '2cs')).not.toBe(tokenKeyOf('ww12', 'cs')); + }); + + it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => { + let busy = 2; + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report.receive.ok).toBe(false); + expect(report.receive.error).toContain('get_email_alias'); + expect(report.receive.error).toContain('48002'); + expect(report.send).toMatchObject({ checked: false }); + await expect( + createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }), + ).rejects.toBeInstanceOf(WecomApiError); + }); + + it('-1 连击到边界不再重试(首次 + 3 次重试恰好 4 次)', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: -1, errmsg: 'system busy' }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report.receive.ok).toBe(false); + expect(report.receive.error).toContain('-1'); + expect(calls.filter((c) => c.url.includes('get_email_alias'))).toHaveLength(4); + }); +}); + +describe('probe / anchor', () => { + it('探到应用邮箱地址;发信一栏 checked=false', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } }); + }); + it('锚定 = 当下(或回填起点)的秒', async () => { + const provider = createWecomProvider({ fetch: scripted({}).fetch }); + expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000))); + expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000))); + }); +}); + +describe('pull', () => { + it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_mail_list': (body) => + body['cursor'] === undefined + ? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] } + : { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] }, + 'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }), + }); + const provider = createWecomProvider({ fetch }); + const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000); + const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async (refs) => new Set(refs.filter((r) => r === 'm2')) }); + const listCall = calls.find((c) => c.url.includes('get_mail_list'))?.body; + expect(listCall).toMatchObject({ begin_time: cursor.lastTime - 600, end_time: Math.floor(now.getTime() / 1000) }); + expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm3']); + expect(new TextDecoder().decode(result.messages[1]?.eml)).toBe(EML); + expect(calls.filter((c) => c.url.includes('read_mail')).map((c) => c.body['mail_id'])).toEqual(['m1', 'm3']); + expect(result.cursor).toEqual(wecomCursor(Math.floor(now.getTime() / 1000))); + expect(result.more).toBe(false); + }); + + it('单页翻完但新信多过 limit —— 游标不许推进(has_more:0 也可能被 limit 截断)', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_mail_list': () => ({ errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }, { mail_id: 'm3' }] }), + 'read_mail': () => ({ errcode: 0, mail_data: EML }), + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known }); + expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm2']); + expect(result.more).toBe(true); + // 页翻完了(has_more:0)不等于「这一窗的新信全吃完了」—— LIST_PAGE(100)与 opts.limit 无关, + // 最后一页仍可能被 limit 截断;截断时游标必须原样退回,否则 m3 就永久丢了(重叠窗只兜 10 分钟)。 + expect(result.cursor).toEqual(cursor); + }); + + it('**翻页有上限**:服务端一直回 has_more:1 也不死循环 —— 到顶当本轮吃不完,游标不推进', async () => { + /* + * 单请求 30s 超时护不住这一条:每一页都是**成功返回**,超时永远不触发。 + * 到顶就交出手上这些、报 more=true、游标原样退回,下一轮接着翻,一封都不丢。 + */ + let pages = 0; + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + // 每页都说「还有」,而一封新信都不给(极端形:cursor 一直动、内容全被 known 滤掉) + 'get_mail_list': () => { + pages += 1; + return { errcode: 0, next_cursor: `c${pages}`, has_more: 1, mail_list: [{ mail_id: `known-${pages}` }] }; + }, + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { + limit: 50, + now, + backfillFrom: null, + known: async (refs) => new Set(refs), // 全都已入库 + }); + expect(pages).toBeLessThanOrEqual(50); + expect(pages).toBeGreaterThan(1); + expect(result.messages).toEqual([]); + expect(result.more).toBe(true); + expect(result.cursor).toEqual(cursor); + }); + + it('取满 limit 就停,more=true', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_mail_list': () => ({ errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }), + 'read_mail': () => ({ errcode: 0, mail_data: EML }), + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known }); + expect(result.messages).toHaveLength(2); + expect(result.more).toBe(true); + // **截断时游标原样退回**:没读到的那几封不能被跳过去(重叠窗兜不住一次积压) + expect(result.cursor).toEqual(cursor); + }); +}); + +describe('send', () => { + it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'compose_send': () => ({ errcode: 0, errmsg: 'ok' }), + }); + const result = await createWecomProvider({ fetch }).send(account, { + messageId: '', + from: { address: 'app@corp.wecom.work', name: '应用' }, + to: ['a@x.com'], + cc: ['b@x.com'], + bcc: [], + subject: '报价', + text: '纯文本', + html: '

html

', + references: [], + attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], + }); + expect(result).toEqual({}); + const payload = calls.find((c) => c.url.includes('compose_send'))?.body; + expect(payload).toEqual({ + to: { emails: ['a@x.com'] }, + cc: { emails: ['b@x.com'] }, + subject: '报价', + content: '

html

', + content_type: 'html', + attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }], + }); + }); +}); diff --git a/apps/api/src/modules/mail/contract.ts b/apps/api/src/modules/mail/contract.ts new file mode 100644 index 0000000..21c130f --- /dev/null +++ b/apps/api/src/modules/mail/contract.ts @@ -0,0 +1,83 @@ +/** + * module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。 + */ +import type { + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, +} from '@contracts'; + +export type { + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, +}; + +export { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from './internal/domain/errors.ts'; + +/** + * 订阅方回调 —— 「注册回调读到邮件内容」的落点。 + * 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。 + * **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。 + */ +export type InboundHandler = ( + mail: MailInboundDetail, + ctx: { readonly accountId: string; readonly channelId: string | undefined }, +) => Promise; + +export type MailService = { + /* ── 账号 ── */ + listAccounts(): Promise; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ + updateAccount(accountId: string, input: MailAccountInput): Promise; + /** 留痕不删(入站 / 出站表对账号不设外键) */ + deleteAccount(accountId: string): Promise; + /** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */ + probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise; + /* ── 收 ── */ + /** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */ + pollDue(now: Date): Promise; + /** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */ + pollNow(accountId: string): Promise; + /** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */ + subscribe(name: string, handler: InboundHandler): () => void; + listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise; + getInbound(mailId: string): Promise; + attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>; + /** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */ + raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>; + /** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */ + redeliver(mailId: string, subscriber?: string): Promise; + /* ── 发 ── */ + /** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */ + send(input: MailSendInput): Promise; + listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise; +}; diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts new file mode 100644 index 0000000..8a19542 --- /dev/null +++ b/apps/api/src/modules/mail/index.ts @@ -0,0 +1,91 @@ +/** + * module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + InboundHandler, + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, + MailService, +} from './contract.ts'; +export { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from './contract.ts'; + +export type { + AttachmentStore, + ChangeNotifier, + Clock, + DeliveryRecord, + IdGenerator, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + MailCursor, + MailProvider, + MimeParser, + OutboundMessage, + OutboundRecord, + OutboundStore, + ParsedMail, + ProviderAccount, + RawMessage, + SecretBox, +} from './port.ts'; + +export { + advanceImap, + cursorSummary, + imapCursor, + imapNeedsReanchor, + newUids, + parseCursor, + wecomCursor, + wecomWindow, +} from './internal/domain/cursor.ts'; +export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts'; +export { + ATTACHMENT_CAP, + bodyTextOf, + buildInboundRecord, + detailOf, + planAttachments, + RAW_CAP, + stripHtml, + summaryOf, + threadIdOf, +} from './internal/domain/inbound-record.ts'; +export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; +export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts'; +export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts'; +export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts'; + +export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; +export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; +export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; + +export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; +export { createMailService } from './internal/usecase/mail-service.ts'; + +export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts'; + +export type { FetchLike } from './internal/adapter/wecom-provider.ts'; +export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts'; diff --git a/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts b/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts new file mode 100644 index 0000000..a0720e1 --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts @@ -0,0 +1,216 @@ +/** + * 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。 + * + * 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址; + * 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。 + * 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。 + * IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。 + */ +import { ImapFlow } from 'imapflow'; +import nodemailer from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import { imapSmtpSettingsSchema } from '@contracts'; +import type { ImapSmtpSettings, MailProbeReport } from '@contracts'; +import { imapCursor, newUids } from '../domain/cursor.ts'; +import type { MailCursor } from '../domain/cursor.ts'; +import { errorText } from '../domain/redact.ts'; +import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts'; + +const CONNECT_TIMEOUT_MS = 30_000; +const SOCKET_TIMEOUT_MS = 60_000; + +/** UIDVALIDITY 变化时这一轮带回的说明 —— 落进 `last_poll_error`(测试对着这个常量断言) */ +export const REANCHOR_NOTE = '信箱 UIDVALIDITY 变化(重建 / 迁移),已重锚在当下;期间来信未拉取'; + +export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } { + const settings = imapSmtpSettingsSchema.parse(account.settings); + const password = account.secret['password']; + if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)'); + return { settings, login: settings.login ?? account.address, password }; +} + +export function internalDateOf(value: Date | string | undefined, fallback: Date): Date { + if (value instanceof Date) return value; + if (typeof value === 'string') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return fallback; +} + +/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */ +export function orderFetched( + uidValidity: string, + rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>, + now: Date, +): RawMessage[] { + return rows + .filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined) + .sort((a, b) => a.uid - b.uid) + .map((r) => ({ + providerRef: `${uidValidity}:${r.uid}`, + eml: Uint8Array.from(r.source), + receivedAt: internalDateOf(r.internalDate, now), + cursorAfter: imapCursor(uidValidity, r.uid), + })); +} + +export function mailOptionsOf(message: OutboundMessage): Mail.Options { + return { + messageId: message.messageId, + from: { name: message.from.name, address: message.from.address }, + to: [...message.to], + cc: [...message.cc], + bcc: [...message.bcc], + subject: message.subject, + ...(message.text === undefined ? {} : { text: message.text }), + ...(message.html === undefined ? {} : { html: message.html }), + ...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }), + references: [...message.references], + attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })), + }; +} + +/** + * 回填锚点要的是**最小的那个 UID**。 + * + * `Math.min(...uids)` 在十万级 UID 的信箱上会**栈溢出**(整个数组铺成实参), + * 而回填正是「一次搜出一大把」的那条路。reduce 不铺参数,搜多少都稳。 + * 空结果回 `fallback`(uidNext:什么都没有就锚在下一封之前)。 + */ +export function lowestUid(uids: readonly number[], fallback: number): number { + return uids.reduce((low, uid) => (uid < low ? uid : low), fallback); +} + +function transporterOf(account: ProviderAccount) { + const { settings, login, password } = imapSettingsOf(account); + return nodemailer.createTransport({ + host: settings.smtpHost, + port: settings.smtpPort, + secure: settings.smtpTls, + requireTLS: !settings.smtpTls, + auth: { user: login, pass: password }, + connectionTimeout: CONNECT_TIMEOUT_MS, + greetingTimeout: CONNECT_TIMEOUT_MS, + socketTimeout: SOCKET_TIMEOUT_MS, + }); +} + +async function withImap(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise): Promise { + const { settings, login, password } = imapSettingsOf(account); + const client = new ImapFlow({ + host: settings.imapHost, + port: settings.imapPort, + secure: settings.imapTls, + auth: { user: login, pass: password }, + logger: false, + connectionTimeout: CONNECT_TIMEOUT_MS, + socketTimeout: SOCKET_TIMEOUT_MS, + }); + await client.connect(); + try { + return await fn(client, settings.folder); + } finally { + await client.logout().catch(() => client.close()); + } +} + +function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } { + const box = client.mailbox; + if (box === false) throw new Error('文件夹未打开'); + return { uidValidity: String(box.uidValidity), uidNext: box.uidNext }; +} + +async function timed(run: () => Promise): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> { + const started = Date.now(); + try { + await run(); + return { ok: true, latencyMs: Date.now() - started }; + } catch (error) { + return { ok: false, error: errorText(error) }; + } +} + +export function createImapSmtpProvider(): MailProvider { + return { + kind: 'imap-smtp', + + async probe(account): Promise { + const receive = await timed(() => + withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + lock.release(); + }), + ); + const send = await timed(async () => { + const transporter = transporterOf(account); + try { + await transporter.verify(); + } finally { + transporter.close(); + } + }); + return { + kind: 'imap-smtp', + address: account.address, + receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error }, + send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error }, + }; + }, + + async anchor(account, backfillFrom): Promise { + return withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + try { + const box = mailboxOf(client); + if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1); + const uids = await client.search({ since: backfillFrom }, { uid: true }); + const first = lowestUid(uids === false ? [] : uids, box.uidNext); + return imapCursor(box.uidValidity, first - 1); + } finally { + lock.release(); + } + }); + }, + + async pull(account, cursor, opts) { + return withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + try { + const box = mailboxOf(client); + if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) { + /* + * UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡。 + * **重锚要出声**:上一次游标到此刻之间的来信从此拉不到了,而账号页若一片绿, + * 人只会以为「这几天没来信」。这句话经用例落进 `last_poll_error`(设计稿 §5.5)。 + */ + return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false, note: REANCHOR_NOTE }; + } + const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true }); + const fresh = newUids(found === false ? [] : found, cursor.lastUid); + const wanted = fresh.slice(0, opts.limit); + const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = []; + if (wanted.length > 0) { + for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) { + rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate }); + } + } + const messages = orderFetched(box.uidValidity, rows, opts.now); + return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length }; + } finally { + lock.release(); + } + }); + }, + + async send(account, message) { + const transporter = transporterOf(account); + try { + const info = await transporter.sendMail(mailOptionsOf(message)); + return { providerRef: info.response }; + } finally { + transporter.close(); + } + }, + }; +} diff --git a/apps/api/src/modules/mail/internal/adapter/mail-schema.ts b/apps/api/src/modules/mail/internal/adapter/mail-schema.ts new file mode 100644 index 0000000..7e66070 --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/mail-schema.ts @@ -0,0 +1,95 @@ +/** + * mail 的 sys schema 表(编码规范 §7 / ADR-002)。 + * 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。 + */ +import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core'; +import type { MailAddress } from '@contracts'; +import { sys } from '@shared/db.ts'; + +/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */ +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return 'bytea'; + }, +}); + +export const mailAccountTable = sys.table('mail_account', { + id: text('id').primaryKey(), + name: text('name').notNull(), + kind: text('kind').notNull(), + address: text('address').notNull().default(''), + displayName: text('display_name').notNull().default(''), + enabled: boolean('enabled').notNull().default(true), + isDefault: boolean('is_default').notNull().default(false), + pollIntervalSec: integer('poll_interval_sec').notNull().default(30), + channelId: text('channel_id'), + backfillFrom: timestamp('backfill_from', { withTimezone: true }), + settings: jsonb('settings').$type>().notNull().default({}), + /** AES-256-GCM 密文;永不出接口 */ + secretCipher: text('secret_cipher'), + cursor: jsonb('cursor'), + lastPolledAt: timestamp('last_polled_at', { withTimezone: true }), + lastPollError: text('last_poll_error'), + lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }), + /** 跨进程租约 —— 与 sys.task.advancing_until 同款 */ + pollingUntil: timestamp('polling_until', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const mailInboundTable = sys.table('mail_inbound', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + messageId: text('message_id').notNull(), + providerRef: text('provider_ref').notNull(), + inReplyTo: text('in_reply_to'), + referenceIds: jsonb('reference_ids').$type().notNull().default([]), + fromAddr: text('from_addr').notNull().default(''), + fromName: text('from_name').notNull().default(''), + toList: jsonb('to_list').$type().notNull().default([]), + ccList: jsonb('cc_list').$type().notNull().default([]), + subject: text('subject').notNull().default(''), + textBody: text('text_body'), + htmlBody: text('html_body'), + sentAt: timestamp('sent_at', { withTimezone: true }), + receivedAt: timestamp('received_at', { withTimezone: true }).notNull(), + attachments: jsonb('attachments') + .$type>() + .notNull() + .default([]), + raw: bytea('raw'), + rawNote: text('raw_note').notNull().default(''), + size: integer('size').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', { + mailId: text('mail_id').notNull(), + subscriber: text('subscriber').notNull(), + state: text('state').notNull(), + error: text('error'), + startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), + finishedAt: timestamp('finished_at', { withTimezone: true }), +}); + +export const mailOutboundTable = sys.table('mail_outbound', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + messageId: text('message_id').notNull(), + idempotencyKey: text('idempotency_key'), + toList: jsonb('to_list').$type().notNull(), + ccList: jsonb('cc_list').$type().notNull().default([]), + bccList: jsonb('bcc_list').$type().notNull().default([]), + subject: text('subject').notNull(), + textBody: text('text_body'), + htmlBody: text('html_body'), + inReplyTo: text('in_reply_to'), + referenceIds: jsonb('reference_ids').$type().notNull().default([]), + attachments: jsonb('attachments').$type>().notNull().default([]), + state: text('state').notNull(), + error: text('error'), + providerRef: text('provider_ref'), + requestedBy: jsonb('requested_by').$type>().notNull().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + sentAt: timestamp('sent_at', { withTimezone: true }), +}); diff --git a/apps/api/src/modules/mail/internal/adapter/mail-store.ts b/apps/api/src/modules/mail/internal/adapter/mail-store.ts new file mode 100644 index 0000000..d4a434d --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/mail-store.ts @@ -0,0 +1,466 @@ +/** + * mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 + * 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。 + */ +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { + DeliveryRecord, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + OutboundRecord, + OutboundStore, +} from '../../port.ts'; +import { parseCursor } from '../domain/cursor.ts'; +import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts'; + +function rowsOf(result: unknown): unknown[] { + if (Array.isArray(result)) return result; + const rows = (result as { rows?: unknown }).rows; + return Array.isArray(rows) ? rows : []; +} + +/* ── 账号 ─────────────────────────────────────────────────────────────── */ + +type AccountRow = typeof mailAccountTable.$inferSelect; + +function toAccount(row: AccountRow): MailAccountRecord { + return { + id: row.id, + kind: mailAccountKindSchema.parse(row.kind), + name: row.name, + address: row.address, + displayName: row.displayName, + enabled: row.enabled, + isDefault: row.isDefault, + pollIntervalSec: row.pollIntervalSec, + channelId: row.channelId, + backfillFrom: row.backfillFrom, + settings: row.settings, + secretCipher: row.secretCipher, + cursor: parseCursor(row.cursor), + lastPolledAt: row.lastPolledAt, + lastPollError: row.lastPollError, + lastInboundAt: row.lastInboundAt, + pollingUntil: row.pollingUntil, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** + * **配置列** —— 人在运营面填的那些,`save()` 写的就是这一组。 + * 运行列(cursor / last_polled_at / last_poll_error / last_inbound_at / polling_until)不在里面: + * `mergeAccount` 里的运行列是**读那一刻的快照**,整行 upsert 会把另一台正持着的租约与刚推进的游标 + * 一并盖回去 —— 第三台随即抢到租约,两台同时拉同一个信箱(08-24 `loop_state` 整份覆盖那一课的同款指纹)。 + */ +function accountConfigValues(record: MailAccountRecord) { + return { + name: record.name, + kind: record.kind, + address: record.address, + displayName: record.displayName, + enabled: record.enabled, + isDefault: record.isDefault, + pollIntervalSec: record.pollIntervalSec, + channelId: record.channelId, + backfillFrom: record.backfillFrom, + settings: record.settings, + secretCipher: record.secretCipher, + updatedAt: record.updatedAt, + }; +} + +/** 新行才带得上运行列(那时它们本就是空的);已存在的行只更新配置列 */ +function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert { + return { + id: record.id, + ...accountConfigValues(record), + cursor: record.cursor, + lastPolledAt: record.lastPolledAt, + lastPollError: record.lastPollError, + lastInboundAt: record.lastInboundAt, + pollingUntil: record.pollingUntil, + createdAt: record.createdAt, + }; +} + +export function createMailAccountStore(db: Db): MailAccountStore { + return { + async list() { + const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name); + return rows.map(toAccount); + }, + async get(id) { + const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toAccount(rows[0]); + }, + async findByName(name) { + const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1); + return rows[0] === undefined ? undefined : toAccount(rows[0]); + }, + async save(record) { + await db.transaction(async (tx) => { + // 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底 + if (record.isDefault) { + await tx + .update(mailAccountTable) + .set({ isDefault: false }) + .where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`)); + } + await tx + .insert(mailAccountTable) + .values(accountValues(record)) + // **只写配置列**:运行列归 savePoll / claimPolling / releasePolling(见 accountConfigValues) + .onConflictDoUpdate({ target: mailAccountTable.id, set: accountConfigValues(record) }); + }); + }, + async remove(id) { + await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id)); + }, + async listDue(now) { + const rows = await db + .select() + .from(mailAccountTable) + .where( + sql`${mailAccountTable.enabled} + and (${mailAccountTable.lastPolledAt} is null + or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now}) + and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`, + ) + .orderBy(mailAccountTable.name); + return rows.map(toAccount); + }, + async claimPolling(id, leaseMs) { + const result = await db.execute(sql` + update sys.mail_account + set polling_until = now() + (${leaseMs} || ' milliseconds')::interval + where id = ${id} and enabled + and (polling_until is null or polling_until < now()) + returning id`); + return rowsOf(result).length > 0; + }, + async releasePolling(id) { + // 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉 + await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined); + }, + async savePoll(id, patch) { + const set: Partial = {}; + // `cursor: null` 是显式清游标(改了收侧配置要重锚);`undefined` 才是「这一次不动它」 + if (patch.cursor !== undefined) set.cursor = patch.cursor; + if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt; + if (patch.error !== undefined) set.lastPollError = patch.error; + if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt; + if (patch.address !== undefined) set.address = patch.address; + if (Object.keys(set).length === 0) return; + await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id)); + }, + }; +} + +/* ── 入站 ─────────────────────────────────────────────────────────────── */ + +type InboundRow = typeof mailInboundTable.$inferSelect; +type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect; + +/** + * **只要元信息的那几列** —— 唯独不选 `raw`。 + * + * 一封原件上限 16MB,列表默认 50 行、上限 200:每次 SSE 失效重取都可能把几百 MB 搬进内存, + * 只为算一段 200 字的预览。`rawAvailable` 让库自己投影(`raw is not null`), + * 不靠把字节取回来再判 —— 判「有没有」不必先把它扛回来。 + */ +const inboundMetaColumns = { + id: mailInboundTable.id, + accountId: mailInboundTable.accountId, + messageId: mailInboundTable.messageId, + providerRef: mailInboundTable.providerRef, + inReplyTo: mailInboundTable.inReplyTo, + referenceIds: mailInboundTable.referenceIds, + fromAddr: mailInboundTable.fromAddr, + fromName: mailInboundTable.fromName, + toList: mailInboundTable.toList, + ccList: mailInboundTable.ccList, + subject: mailInboundTable.subject, + textBody: mailInboundTable.textBody, + htmlBody: mailInboundTable.htmlBody, + sentAt: mailInboundTable.sentAt, + receivedAt: mailInboundTable.receivedAt, + attachments: mailInboundTable.attachments, + rawAvailable: sql`${mailInboundTable.raw} is not null`, + rawNote: mailInboundTable.rawNote, + size: mailInboundTable.size, + createdAt: mailInboundTable.createdAt, +}; + +type InboundMetaRow = { [K in keyof typeof inboundMetaColumns]: K extends 'rawAvailable' ? boolean : InboundRow[K & keyof InboundRow] }; + +function toInboundMeta(row: InboundMetaRow): InboundRecord { + return { + id: row.id, + accountId: row.accountId, + messageId: row.messageId, + providerRef: row.providerRef, + inReplyTo: row.inReplyTo, + references: row.referenceIds, + from: { address: row.fromAddr, name: row.fromName }, + to: row.toList, + cc: row.ccList, + subject: row.subject, + text: row.textBody, + html: row.htmlBody, + sentAt: row.sentAt, + receivedAt: row.receivedAt, + attachments: row.attachments, + // 没取字节:`raw` 恒 null,「有没有原件」问 rawAvailable + raw: null, + rawAvailable: row.rawAvailable, + rawNote: row.rawNote, + size: row.size, + createdAt: row.createdAt, + }; +} + +function toInbound(row: InboundRow): InboundRecord { + return { + id: row.id, + accountId: row.accountId, + messageId: row.messageId, + providerRef: row.providerRef, + inReplyTo: row.inReplyTo, + references: row.referenceIds, + from: { address: row.fromAddr, name: row.fromName }, + to: row.toList, + cc: row.ccList, + subject: row.subject, + text: row.textBody, + html: row.htmlBody, + sentAt: row.sentAt, + receivedAt: row.receivedAt, + attachments: row.attachments, + /* + * pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 —— + * 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。 + */ + raw: row.raw === null ? null : Uint8Array.from(row.raw), + rawAvailable: row.raw !== null, + rawNote: row.rawNote, + size: row.size, + createdAt: row.createdAt, + }; +} + +function toDelivery(row: DeliveryRow): DeliveryRecord { + return { + mailId: row.mailId, + subscriber: row.subscriber, + state: mailDeliveryStateSchema.parse(row.state), + error: row.error, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + }; +} + +export function createInboundStore(db: Db): InboundStore { + async function deliveriesOf(mailIds: readonly string[]): Promise> { + const out = new Map(); + if (mailIds.length === 0) return out; + const rows = await db + .select() + .from(mailInboundDeliveryTable) + .where(inArray(mailInboundDeliveryTable.mailId, [...mailIds])) + .orderBy(mailInboundDeliveryTable.subscriber); + for (const row of rows) { + const list = out.get(row.mailId) ?? []; + list.push(toDelivery(row)); + out.set(row.mailId, list); + } + return out; + } + + return { + async insert(record) { + const rows = await db + .insert(mailInboundTable) + .values({ + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + providerRef: record.providerRef, + inReplyTo: record.inReplyTo, + referenceIds: [...record.references], + fromAddr: record.from.address, + fromName: record.from.name, + toList: [...record.to], + ccList: [...record.cc], + subject: record.subject, + textBody: record.text, + htmlBody: record.html, + sentAt: record.sentAt, + receivedAt: record.receivedAt, + attachments: [...record.attachments], + raw: record.raw === null ? null : Buffer.from(record.raw), + rawNote: record.rawNote, + size: record.size, + createdAt: record.createdAt, + }) + .onConflictDoNothing() + .returning({ id: mailInboundTable.id }); + return { inserted: rows.length > 0 }; + }, + async knownProviderRefs(accountId, refs) { + if (refs.length === 0) return new Set(); + const rows = await db + .select({ providerRef: mailInboundTable.providerRef }) + .from(mailInboundTable) + .where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs]))); + return new Set(rows.map((r) => r.providerRef)); + }, + async list(query) { + const rows = await db + .select(inboundMetaColumns) + .from(mailInboundTable) + .where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId)) + .orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt)) + .limit(query.limit); + const deliveries = await deliveriesOf(rows.map((r) => r.id)); + return rows.map((row) => ({ record: toInboundMeta(row), deliveries: deliveries.get(row.id) ?? [] })); + }, + async getMeta(mailId) { + const rows = await db.select(inboundMetaColumns).from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1); + const row = rows[0]; + if (row === undefined) return undefined; + const deliveries = await deliveriesOf([mailId]); + return { record: toInboundMeta(row), deliveries: deliveries.get(mailId) ?? [] }; + }, + async get(mailId) { + const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1); + const row = rows[0]; + if (row === undefined) return undefined; + const deliveries = await deliveriesOf([mailId]); + return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] }; + }, + async setDelivery(delivery) { + const values = { + mailId: delivery.mailId, + subscriber: delivery.subscriber, + state: delivery.state, + error: delivery.error, + startedAt: delivery.startedAt, + finishedAt: delivery.finishedAt, + }; + await db + .insert(mailInboundDeliveryTable) + .values(values) + .onConflictDoUpdate({ + target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber], + set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt }, + }); + }, + }; +} + +/* ── 出站 ─────────────────────────────────────────────────────────────── */ + +type OutboundRow = typeof mailOutboundTable.$inferSelect; + +function toOutbound(row: OutboundRow): OutboundRecord { + return { + id: row.id, + accountId: row.accountId, + messageId: row.messageId, + idempotencyKey: row.idempotencyKey, + to: row.toList, + cc: row.ccList, + bcc: row.bccList, + subject: row.subject, + text: row.textBody, + html: row.htmlBody, + inReplyTo: row.inReplyTo, + references: row.referenceIds, + attachments: row.attachments, + state: mailOutboundStateSchema.parse(row.state), + error: row.error, + providerRef: row.providerRef, + requestedBy: row.requestedBy, + createdAt: row.createdAt, + sentAt: row.sentAt, + }; +} + +export function createOutboundStore(db: Db): OutboundStore { + return { + async insert(record) { + await db.insert(mailOutboundTable).values({ + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + idempotencyKey: record.idempotencyKey, + toList: [...record.to], + ccList: [...record.cc], + bccList: [...record.bcc], + subject: record.subject, + textBody: record.text, + htmlBody: record.html, + inReplyTo: record.inReplyTo, + referenceIds: [...record.references], + attachments: [...record.attachments], + state: record.state, + error: record.error, + providerRef: record.providerRef, + requestedBy: record.requestedBy, + createdAt: record.createdAt, + sentAt: record.sentAt, + }); + }, + async byIdempotencyKey(key) { + const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1); + return rows[0] === undefined ? undefined : toOutbound(rows[0]); + }, + async claimForResend(id, opts) { + /* + * **抢到才发**(设计稿 §5.4 / 契约「同键不发两封」)。条件更新一句话说完三件事: + * ①`failed` 的行可以抢;②`sending` 的行只有停够了 `staleBefore` 才算「上次崩在半路」; + * ③抢到就置 `sending` 并把 `created_at` 推到 now —— 那一行**当场离开可抢的集合**, + * 第二个人的同一句更新影响 0 行,回 false 走「本次未重复发送」那一支。 + * 两个后端连同一个库时,这一句是「两台同时重放同一次放行」与「两封真邮件」之间唯一的墙。 + */ + const result = await db.execute(sql` + update sys.mail_outbound + set state = 'sending', created_at = ${opts.now}, error = null + where id = ${id} + and (state = 'failed' or (state = 'sending' and created_at <= ${opts.staleBefore})) + returning id`); + return rowsOf(result).length > 0; + }, + async setState(id, state, patch) { + await db + .update(mailOutboundTable) + .set({ + state, + /* + * **发成功的那一行不留上一次的错误话**:同一行可能先 failed、再被幂等键复用重发成功。 + * 不清的话,`listOutbound` 会摆出「state: sent 而 error: 上次那句」, + * `resultOf` 也会回出一个既说成功又带错误的结果 —— 调用方按 error 分支就判反了。 + */ + ...(state === 'sent' ? { error: null } : {}), + ...(patch.error === undefined ? {} : { error: patch.error }), + ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), + ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), + }) + .where(eq(mailOutboundTable.id, id)); + }, + async list(query) { + const rows = await db + .select() + .from(mailOutboundTable) + .where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId)) + .orderBy(desc(mailOutboundTable.createdAt)) + .limit(query.limit); + return rows.map(toOutbound); + }, + }; +} diff --git a/apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts b/apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts new file mode 100644 index 0000000..46d13f0 --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts @@ -0,0 +1,63 @@ +/** + * MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。 + * 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。 + */ +import PostalMime from 'postal-mime'; +import type { Address } from 'postal-mime'; +import type { MailAddress } from '@contracts'; +import type { MimeParser, ParsedMail } from '../../port.ts'; + +function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] { + const out: MailAddress[] = []; + for (const entry of list ?? []) { + if (entry.group !== undefined) { + for (const member of entry.group) out.push({ address: member.address, name: member.name }); + } else if (entry.address !== undefined) { + out.push({ address: entry.address, name: entry.name }); + } + } + return out; +} + +/** References / In-Reply-To 是空白分隔的一串 ;拆开、去空 */ +function idsOf(value: string | undefined): string[] { + if (value === undefined) return []; + return value + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s !== ''); +} + +function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array { + if (typeof content === 'string') return new TextEncoder().encode(content); + return content instanceof Uint8Array ? content : new Uint8Array(content); +} + +export function createPostalMimeParser(): MimeParser { + return { + async parse(eml): Promise { + const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' }); + const from = + email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined; + const date = email.date === undefined ? undefined : new Date(email.date); + const inReplyTo = idsOf(email.inReplyTo)[0]; + return { + ...(email.messageId === undefined ? {} : { messageId: email.messageId }), + ...(inReplyTo === undefined ? {} : { inReplyTo }), + references: idsOf(email.references), + ...(from === undefined ? {} : { from }), + to: mailboxesOf(email.to), + cc: mailboxesOf(email.cc), + subject: email.subject ?? '', + ...(email.text === undefined ? {} : { text: email.text }), + ...(email.html === undefined ? {} : { html: email.html }), + ...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }), + attachments: email.attachments.map((a) => ({ + name: a.filename ?? '', + contentType: a.mimeType, + bytes: bytesOf(a.content), + })), + }; + }, + }; +} diff --git a/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts b/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts new file mode 100644 index 0000000..304764e --- /dev/null +++ b/apps/api/src/modules/mail/internal/adapter/wecom-provider.ts @@ -0,0 +1,212 @@ +/** + * 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25): + * gettoken(corpid, corpsecret) → access_token(7200s) + * exmail/app/get_email_alias → 应用邮箱地址 + * exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱) + * exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64) + * exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立) + * token 缓存在实例闭包里按**凭证指纹**存(不是模块级状态,也不是账号 id —— 见 `tokenKeyOf`); + * 提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。 + */ +import { createHash } from 'node:crypto'; +import { wecomSettingsSchema } from '@contracts'; +import type { MailProbeReport } from '@contracts'; +import { wecomCursor, wecomWindow } from '../domain/cursor.ts'; +import type { MailCursor } from '../domain/cursor.ts'; +import { errorText } from '../domain/redact.ts'; +import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts'; + +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +type WecomResponse = Record & { errcode?: number; errmsg?: string }; + +export class WecomApiError extends Error { + constructor( + readonly errcode: number, + readonly errmsg: string, + readonly api: string, + ) { + super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`); + this.name = 'WecomApiError'; + } +} + +const TOKEN_EARLY_MS = 5 * 60_000; +const BUSY_RETRIES = 3; +const REQUEST_TIMEOUT_MS = 30_000; +const LIST_PAGE = 100; +/** + * 一轮最多翻几页 —— 服务端若一直回 `has_more:1` 且 next_cursor 不动,这个循环就永远转下去 + * (只有单请求 30s 超时护着,而每一页都是一次成功返回,超时永远不触发)。 + * 到顶就当本轮吃不完:`more = true`、游标照旧不推进,下一轮接着翻,一封都不丢。 + */ +const MAX_LIST_PAGES = 50; + +/** + * token 缓存的键 = **凭证本身的指纹**,不是 `account.id`。 + * + * 按 id 存有两条真实的串号路径: + * ① 草稿探测共用 `id:'draft'`(用例层未存账号时就是这个字面量)—— 探完 A 公司再探 B 公司, + * B 会命中 A 的 token,`get_email_alias` 回的是 **A 的应用邮箱地址**,凭证填错的 B 也显示成功; + * ② 已存账号换了 corpId / corpSecret 而 id 不变 —— 下一轮轮询继续拿旧 token 拉**旧 corp 的收件箱**, + * 存进新配置的账号、分发给它绑的通道,窗口 ≤ 7200s。 + * 凭证一变自然换槽,两条路都封死。**secret 不进 Map 的键**:哈希掉,内存里也不留明文。 + */ +export function tokenKeyOf(corpId: string, corpSecret: string): string { + // JSON 分隔而不是裸拼:拼法有歧义(corpId 末尾多一个字符 = secret 首字少一个字符,指纹却相同) + return createHash('sha256').update(JSON.stringify([corpId, corpSecret])).digest('hex'); +} + +/** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */ +export function emlBytesOf(mailData: string): Uint8Array { + const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048)); + return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64')); +} + +export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider { + const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init)); + const nowMs = deps.now ?? (() => Date.now()); + const tokens = new Map(); + + async function call(api: string, url: string, init?: Parameters[1]): Promise { + for (let attempt = 0; ; attempt += 1) { + const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`); + const body = (await res.json()) as WecomResponse; + const code = typeof body.errcode === 'number' ? body.errcode : 0; + if (code === 0) return body; + if (code === -1 && attempt < BUSY_RETRIES) continue; + throw new WecomApiError(code, String(body.errmsg ?? ''), api); + } + } + + async function tokenOf(account: ProviderAccount, force = false): Promise { + // 先取凭证再查缓存 —— 键就是凭证的指纹,取不到凭证也就无所谓缓存(见 tokenKeyOf) + const settings = wecomSettingsSchema.parse(account.settings); + const secret = account.secret['corpSecret']; + if (secret === undefined || secret === '') throw new Error('缺应用 secret'); + const key = tokenKeyOf(settings.corpId, secret); + const cached = tokens.get(key); + if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token; + const body = await call( + 'gettoken', + `${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`, + { method: 'GET' }, + ); + const token = typeof body['access_token'] === 'string' ? body['access_token'] : ''; + if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token'); + const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200; + tokens.set(key, { token, expiresAt: nowMs() + expiresIn * 1000 }); + return token; + } + + async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise { + const settings = wecomSettingsSchema.parse(account.settings); + const run = (token: string): Promise => + call( + api, + `${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`, + payload === undefined + ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } + : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }, + ); + try { + return await run(await tokenOf(account)); + } catch (error) { + if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) { + return run(await tokenOf(account, true)); + } + throw error; + } + } + + return { + kind: 'wecom', + + async probe(account): Promise { + const started = nowMs(); + try { + const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias'); + const address = typeof alias['email'] === 'string' ? alias['email'] : ''; + return { + kind: 'wecom', + address, + receive: { ok: true, latencyMs: nowMs() - started }, + send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' }, + }; + } catch (error) { + return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } }; + } + }, + + async anchor(_account, backfillFrom, now): Promise { + return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000)); + }, + + async pull(account, cursor, opts) { + const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now); + /* + * **先滤库里已有的,再数够不够** —— 顺序反了会卡死:一个窗口里若前 limit 条恰好都已入库, + * 这一轮就交出 0 封,而引擎见空批即收工、游标又没动,下一轮还是这 0 封,永远吃不到后面的新信。 + * 滤在前,每一轮都实实在在往前吃 limit 封**新**的。 + */ + const fresh: string[] = []; + let pageCursor: string | undefined; + let exhausted = false; + let pages = 0; + while (fresh.length < opts.limit && pages < MAX_LIST_PAGES) { + pages += 1; + const page = await withToken(account, 'get_mail_list', '/cgi-bin/exmail/app/get_mail_list', { + begin_time: beginTime, + end_time: endTime, + ...(pageCursor === undefined ? {} : { cursor: pageCursor }), + limit: LIST_PAGE, + }); + const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : []; + const ids = list.flatMap((item) => (typeof item.mail_id === 'string' ? [item.mail_id] : [])); + const known = await opts.known(ids); + for (const id of ids) if (!known.has(id)) fresh.push(id); + const hasMore = page['has_more'] === 1 || page['has_more'] === true; + pageCursor = typeof page['next_cursor'] === 'string' && page['next_cursor'] !== '' ? page['next_cursor'] : undefined; + if (!hasMore || list.length === 0 || pageCursor === undefined) { + exhausted = true; + break; + } + } + const wanted = fresh.slice(0, opts.limit); + const messages: RawMessage[] = []; + for (const mailId of wanted) { + const mail = await withToken(account, 'read_mail', '/cgi-bin/exmail/app/read_mail', { mail_id: mailId }); + const data = mail['mail_data']; + if (typeof data !== 'string' || data === '') continue; + messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now }); + } + /* + * 游标推进与 `more` **必须由同一个判据决定**:只有「这一窗的新信全吃完了」才推到窗口上沿。 + * `exhausted` 只说明**页翻完了** —— 最后一页可能同时 `has_more:0` 且新信多过本轮预算 + * (`LIST_PAGE` 是 100,与 `opts.limit` 无关),那时 `wanted` 截掉的那些若跟着推进游标就**永久丢**了 + * (重叠窗只兜 10 分钟)。退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。 + */ + const more = !exhausted || fresh.length > wanted.length; + return { messages, cursor: more ? cursor : wecomCursor(endTime), more }; + }, + + async send(account, message) { + await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', { + to: { emails: [...message.to] }, + ...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }), + ...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }), + subject: message.subject, + content: message.html ?? message.text ?? '', + content_type: message.html !== undefined ? 'html' : 'text', + ...(message.attachments.length === 0 + ? {} + : { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }), + }); + return {}; + }, + }; +} diff --git a/apps/api/src/modules/mail/internal/domain/account-view.ts b/apps/api/src/modules/mail/internal/domain/account-view.ts new file mode 100644 index 0000000..9741b37 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/account-view.ts @@ -0,0 +1,106 @@ +/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */ +import type { MailAccountInput, MailAccountKind, MailAccountView } from '@contracts'; +import { mailAccountViewSchema } from '@contracts'; +import { cursorSummary } from './cursor.ts'; +import { isPolling } from './due.ts'; +import type { MailAccountRecord } from './records.ts'; + +export function viewOf(record: MailAccountRecord, now: Date): MailAccountView { + return mailAccountViewSchema.parse({ + id: record.id, + kind: record.kind, + name: record.name, + address: record.address, + displayName: record.displayName, + enabled: record.enabled, + isDefault: record.isDefault, + pollIntervalSec: record.pollIntervalSec, + channelId: record.channelId, + backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(), + settings: record.settings, + secretBound: record.secretCipher !== null && record.secretCipher !== '', + status: { + lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(), + lastPollError: record.lastPollError, + lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(), + cursor: cursorSummary(record.cursor), + polling: isPolling(record.pollingUntil, now), + }, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }); +} + +/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */ +export function secretOf(input: MailAccountInput): string | undefined { + return input.secret === undefined ? undefined : JSON.stringify(input.secret); +} + +/** + * **只有收侧的键动了才谈得上重锚** —— 游标说的是「这个信箱 / 这个文件夹我读到哪儿了」。 + * + * 从前比的是全部 settings 键,连 `smtpHost` / `smtpPort` / `smtpTls` 都算: + * 人把 465 改成 587(纯发信的事)→ 游标清空 → IMAP 重锚 `lastUid = uidNext − 1` → + * 上次游标到重锚之间到达的信**永远不拉**(设计即「不回填」)。**改发信的设置不该丢来信**。 + * 登录名不在这张表里 —— 它由 `loginOf` 单独判(见下)。 + */ +const RECEIVE_KEYS: Readonly> = { + 'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'folder'], + wecom: ['corpId', 'apiBase'], +}; + +function sameReceiveSettings(kind: MailAccountKind, a: Record, b: Record): boolean { + return RECEIVE_KEYS[kind].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null)); +} + +/** + * imap-smtp **实际拿去登录的那个名字** = `login ?? address`(与 `imapSettingsOf` 同一句)。 + * + * 只比 `login` 不够:它**常常空着**,这时候真正决定「读哪个信箱」的是 `address` —— + * 人把 address 换成另一个邮箱,IMAP 就登到别的箱子上去了,而旧游标的 UID 说的是原来那个箱子。 + * 也不能改成比 `address`:显式填了 `login` 时,address 只是发信的落款,换它不动收信。 + * wecom 不参与 —— 它的 address 由探测回填(不是人填的),比它会在回填那一刻误清游标。 + */ +function loginOf(settings: Record, address: string): string { + const login = settings['login']; + return typeof login === 'string' && login !== '' ? login : address; +} + +/** kind 或**收侧** settings / 登录名变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */ +export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean { + if (existing === undefined) return false; + if (existing.kind !== input.kind) return true; + if (!sameReceiveSettings(input.kind, existing.settings, input.settings)) return true; + if (input.kind !== 'imap-smtp') return false; + return loginOf(existing.settings, existing.address) !== loginOf(input.settings, input.address); +} + +export function mergeAccount( + input: MailAccountInput, + base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date }, +): MailAccountRecord { + const reset = cursorReset(base.existing, input); + const existing = base.existing; + return { + id: base.id, + kind: input.kind, + name: input.name, + // wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准 + address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '', + displayName: input.displayName, + enabled: input.enabled, + isDefault: input.isDefault, + pollIntervalSec: input.pollIntervalSec, + channelId: input.channelId, + backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom), + settings: { ...input.settings }, + secretCipher: base.secretCipher, + cursor: reset ? null : (existing?.cursor ?? null), + lastPolledAt: existing?.lastPolledAt ?? null, + lastPollError: reset ? null : (existing?.lastPollError ?? null), + lastInboundAt: existing?.lastInboundAt ?? null, + pollingUntil: existing?.pollingUntil ?? null, + createdAt: existing?.createdAt ?? base.now, + updatedAt: base.now, + }; +} diff --git a/apps/api/src/modules/mail/internal/domain/cursor.ts b/apps/api/src/modules/mail/internal/domain/cursor.ts new file mode 100644 index 0000000..0e2348a --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/cursor.ts @@ -0,0 +1,82 @@ +/** + * 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。 + * IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。 + */ +export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number }; +export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number }; +export type MailCursor = ImapCursor | WecomCursor; + +/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */ +export const WECOM_OVERLAP_SEC = 600; + +export function imapCursor(uidValidity: string, lastUid: number): ImapCursor { + return { kind: 'imap', uidValidity, lastUid }; +} + +export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor { + return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor; +} + +export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean { + return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity; +} + +/** + * IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)—— + * 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。 + */ +export function newUids(uids: readonly number[], lastUid: number): number[] { + return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b); +} + +export function wecomCursor(lastTime: number): WecomCursor { + return { kind: 'wecom', lastTime }; +} + +export function wecomWindow( + cursor: MailCursor | null, + backfillFrom: Date | null, + now: Date, +): { beginTime: number; endTime: number } { + const endTime = Math.floor(now.getTime() / 1000); + if (cursor !== null && cursor.kind === 'wecom') { + return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime }; + } + if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime }; + // 无游标不回填:窗口空,这一轮只是把游标锚在当下 + return { beginTime: endTime, endTime }; +} + +export function cursorSummary(cursor: MailCursor | null): string | null { + if (cursor === null) return null; + if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`; + return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`; +} + +/** IMAP 的 UID 是 32 位无符号(RFC 3501)—— 比它大的一定不是我们写下的那个数 */ +const MAX_UID = 0xffff_ffff; +/** + * wecom 游标是 unix 秒,`cursorSummary` 要拿 `new Date(lastTime * 1000)` 渲。 + * JS 的 Date 上限是 ±8.64e15 毫秒 —— 换算成秒就是这个数,超了 `toISOString()` 直接抛 RangeError。 + * **安全整数还不够**:1e13 是安全整数,乘 1000 就出了 Date 的界。 + */ +const MAX_UNIX_SECONDS = 8_640_000_000_000; + +/** + * 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛。 + * + * **取值范围也算形状**:`{kind:'wecom', lastTime: 1e13}` 类型上是个 number,而 + * `new Date(lastTime * 1000).toISOString()` 会抛 `RangeError` —— 而 `cursorSummary` 跑在 `viewOf` 里, + * 一行脏游标能让 `listAccounts` **整页 500**。外部够不着这一列(只有我们自己写游标), + * 但炸起来是整页,而拦住它只要一句:非负安全整数才算数。 + */ +export function parseCursor(raw: unknown): MailCursor | null { + if (typeof raw !== 'object' || raw === null) return null; + const c = raw as Record; + const within = (v: unknown, max: number): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= max; + if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && within(c['lastUid'], MAX_UID)) { + return imapCursor(c['uidValidity'], c['lastUid']); + } + if (c['kind'] === 'wecom' && within(c['lastTime'], MAX_UNIX_SECONDS)) return wecomCursor(c['lastTime']); + return null; +} diff --git a/apps/api/src/modules/mail/internal/domain/delivery.ts b/apps/api/src/modules/mail/internal/domain/delivery.ts new file mode 100644 index 0000000..ab7c3a2 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/delivery.ts @@ -0,0 +1,10 @@ +/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */ +import type { MailDeliveryState } from '@contracts'; + +export function canRedeliver(current: MailDeliveryState | undefined): boolean { + return current !== 'dispatching'; +} + +export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' { + return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done'; +} diff --git a/apps/api/src/modules/mail/internal/domain/due.ts b/apps/api/src/modules/mail/internal/domain/due.ts new file mode 100644 index 0000000..8b936c0 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/due.ts @@ -0,0 +1,21 @@ +/** 到期判定与租约时长(设计稿 §9.1) */ +export const MIN_LEASE_MS = 5 * 60_000; + +/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */ +export function leaseMsFor(pollIntervalSec: number): number { + return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000); +} + +export function isPolling(pollingUntil: Date | null, now: Date): boolean { + return pollingUntil !== null && pollingUntil.getTime() > now.getTime(); +} + +export function isDue( + account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null }, + now: Date, +): boolean { + if (!account.enabled) return false; + if (isPolling(account.pollingUntil, now)) return false; + if (account.lastPolledAt === null) return true; + return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime(); +} diff --git a/apps/api/src/modules/mail/internal/domain/errors.ts b/apps/api/src/modules/mail/internal/domain/errors.ts new file mode 100644 index 0000000..ab0cd1f --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/errors.ts @@ -0,0 +1,67 @@ +/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */ +export class MailAccountNotFoundError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号不存在:${accountId}`); + this.name = 'MailAccountNotFoundError'; + } +} +export class MailAccountNameTakenError extends Error { + constructor(readonly accountName: string) { + super(`邮箱账号名已被占用:${accountName}`); + this.name = 'MailAccountNameTakenError'; + } +} +export class MailAccountDisabledError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号已停用:${accountId}`); + this.name = 'MailAccountDisabledError'; + } +} +export class MailNoDefaultAccountError extends Error { + constructor() { + super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认'); + this.name = 'MailNoDefaultAccountError'; + } +} +export class MailSecretMissingError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`); + this.name = 'MailSecretMissingError'; + } +} +export class MailPollBusyError extends Error { + constructor(readonly accountId: string) { + super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`); + this.name = 'MailPollBusyError'; + } +} +export class MailInboundNotFoundError extends Error { + constructor(readonly mailId: string) { + super(`这封信不在了:${mailId}`); + this.name = 'MailInboundNotFoundError'; + } +} +export class MailAttachmentNotFoundError extends Error { + constructor(readonly mailId: string, readonly index: number) { + super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`); + this.name = 'MailAttachmentNotFoundError'; + } +} +export class MailSubscriberUnknownError extends Error { + constructor(readonly subscriber: string) { + super(`没有这个订阅方:${subscriber}`); + this.name = 'MailSubscriberUnknownError'; + } +} +export class MailSubscriberTakenError extends Error { + constructor(readonly subscriber: string) { + super(`订阅方已注册,不许重名:${subscriber}`); + this.name = 'MailSubscriberTakenError'; + } +} +export class MailSendInvalidError extends Error { + constructor(readonly problems: readonly string[]) { + super(`发信入参不合法:${problems.join('; ')}`); + this.name = 'MailSendInvalidError'; + } +} diff --git a/apps/api/src/modules/mail/internal/domain/inbound-record.ts b/apps/api/src/modules/mail/internal/domain/inbound-record.ts new file mode 100644 index 0000000..1694d21 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/inbound-record.ts @@ -0,0 +1,210 @@ +/** + * 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做, + * 存完把 ref 交回来再 `buildInboundRecord`。 + */ +import type { MailAddress, MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts'; +import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts'; + +/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */ +export const ATTACHMENT_CAP = 8 * 1024 * 1024; +export const RAW_CAP = 16 * 1024 * 1024; +export const PREVIEW_CHARS = 200; + +function domainOf(address: string): string { + const at = address.lastIndexOf('@'); + return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); +} + +/** + * **剥掉 NUL(U+0000)** —— PG 的 `text` / `jsonb` 列一律拒收它(`invalid byte sequence` / `unsupported Unicode escape`), + * 而 MIME 解出二进制垃圾时它真会出现(声明成 text/plain 的附件、编码标错的正文)。 + * + * 落在这一层是因为:整封信卡住的病根在**一个字符**,而 domain 是纯函数、好测; + * 不剥的话这一封每 30 秒炸一次,游标停在它前面,**后面所有新信永远进不来**。 + * 只剥这一个码点,别的字节原样留着 —— 原件 `raw` 是 bytea,一个字节都不动。 + */ +export function stripNul(text: string): string { + return text.includes('\u0000') ? text.replaceAll('\u0000', '') : text; +} + +function stripNulAddress(address: MailAddress): MailAddress { + return { address: stripNul(address.address), name: stripNul(address.name) }; +} + +/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */ +export function fallbackMessageId(providerRef: string, address: string): string { + return `<${providerRef}@${domainOf(address)}>`; +} + +/** + * 线程 = **这一串的根**(`References[0]`),不是最近一跳(ADR-013 决策 3)。 + * + * 我方出站登记的 threadId 恒是首封(`channelTransport` 回 `input.threadId ?? messageId`, + * channel 据此 `recordOutbound`),而归属解析①级是精确等值比对。取最近一跳的话跑一个来回就漂: + * 我方 M1 → 客户回 M1(命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 → + * 取到 M2 而库里从没记过 M2 ⇒ ①级落空,退到③地址 + 时间窗。 + * `references[0]` 才稳定:客户端续链时首项恒是首封;客户端只给 In-Reply-To 时退回它。 + * + * 入参放宽到 `string | null`:入站详情(`MailInboundDetail`)与解析结果(`ParsedMail`) + * 对「没有」的写法一个是 null 一个是 undefined,同一套口径要能同时接住两边 —— + * 这个函数就是那**唯一一处口径**(app 的 mail-wiring 调它,不再内联一份)。 + */ +export function threadIdOf(parsed: { readonly inReplyTo?: string | null | undefined; readonly references: readonly string[] }): string | undefined { + const root = parsed.references[0]; + if (root !== undefined && root !== '') return root; + return parsed.inReplyTo === null || parsed.inReplyTo === '' ? undefined : parsed.inReplyTo; +} + +const ENTITIES: Readonly> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; + +export function stripHtml(html: string): string { + return html + .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') + .replace(/|<\/(p|div|tr|li|h[1-6])>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => { + if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16)); + if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1))); + return ENTITIES[code.toLowerCase()] ?? whole; + }) + .replace(/[ \t\r\f\v]+/g, ' ') + .replace(/\s*\n\s*/g, ' ') + .trim(); +} + +export function bodyTextOf(parsed: Pick): string { + if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text; + if (parsed.html !== undefined) return stripHtml(parsed.html); + return ''; +} + +export function textPreviewOf(text: string | null, html: string | null): string { + const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) }); + return body.slice(0, PREVIEW_CHARS); +} + +export type AttachmentPlan = { + readonly index: number; + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly store: boolean; + readonly note: string; +}; + +export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] { + return attachments.map((a, index) => { + const size = a.bytes.byteLength; + const store = size <= cap; + return { + index, + name: a.name === '' ? `attachment-${index + 1}` : a.name, + contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType, + size, + store, + note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`, + }; + }); +} + +/** + * 解析炸了之后从**原始字节**里扒一行头 —— 「主题能取多少取多少」那一手(降级存档用)。 + * + * 不做 MIME 解码(encoded-word 原样留着):这一封本来就是解不动的那一封, + * 这里要的只是让人在列表上认出「是哪一封卡住了」,不是还原它。 + * 头名只认 ASCII 字母与连字符 —— 别的一律当没有,不把外来串塞进正则。 + */ +export function peekHeader(eml: Uint8Array, name: string): string { + if (!/^[A-Za-z][A-Za-z-]*$/.test(name)) return ''; + const head = new TextDecoder('utf-8', { fatal: false }).decode(eml.slice(0, 16 * 1024)); + const blank = head.search(/\r?\n\r?\n/); + const headers = blank < 0 ? head : head.slice(0, blank); + const hit = new RegExp(`^${name}:[ \\t]*(.*)$`, 'im').exec(headers); + return stripNul((hit?.[1] ?? '').trim()); +} + +export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } { + if (eml.byteLength <= cap) return { raw: eml, note: '' }; + return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` }; +} + +export function buildInboundRecord(input: { + readonly id: string; + readonly accountId: string; + readonly address: string; + readonly providerRef: string; + readonly parsed: ParsedMail; + readonly eml: Uint8Array; + readonly receivedAt: Date; + readonly attachments: readonly InboundAttachmentRecord[]; + readonly now: Date; + readonly rawCap?: number | undefined; +}): InboundRecord { + const { parsed } = input; + const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address); + const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP); + // 凡是要落 text / jsonb 的字段一律先剥 NUL(见 stripNul);raw 是 bytea,原样存 + return { + id: input.id, + accountId: input.accountId, + messageId: stripNul(messageId), + providerRef: stripNul(input.providerRef), + inReplyTo: parsed.inReplyTo === undefined ? null : stripNul(parsed.inReplyTo), + references: parsed.references.map(stripNul), + from: stripNulAddress(parsed.from ?? { address: '', name: '' }), + to: parsed.to.map(stripNulAddress), + cc: parsed.cc.map(stripNulAddress), + subject: stripNul(parsed.subject), + text: parsed.text === undefined ? null : stripNul(parsed.text), + html: parsed.html === undefined ? null : stripNul(parsed.html), + sentAt: parsed.date ?? null, + receivedAt: input.receivedAt, + attachments: input.attachments.map((a) => ({ ...a, name: stripNul(a.name), contentType: stripNul(a.contentType), note: stripNul(a.note) })), + raw, + rawAvailable: raw !== null, + rawNote: note, + size: input.eml.byteLength, + createdAt: input.now, + }; +} + +export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView { + return { + subscriber: d.subscriber, + state: d.state, + error: d.error, + startedAt: d.startedAt.toISOString(), + finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(), + }; +} + +export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary { + return { + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + from: record.from, + to: [...record.to], + cc: [...record.cc], + subject: record.subject, + sentAt: record.sentAt === null ? null : record.sentAt.toISOString(), + receivedAt: record.receivedAt.toISOString(), + textPreview: textPreviewOf(record.text, record.html), + hasHtml: record.html !== null, + attachments: record.attachments.map((a) => ({ ...a })), + deliveries: deliveries.map(deliveryViewOf), + }; +} + +export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail { + return { + ...summaryOf(record, deliveries), + inReplyTo: record.inReplyTo, + references: [...record.references], + text: record.text, + html: record.html, + size: record.size, + // 问投影出来的那一格,不问字节在不在手上 —— 列表 / 元信息读压根不取字节 + rawAvailable: record.rawAvailable, + }; +} diff --git a/apps/api/src/modules/mail/internal/domain/records.ts b/apps/api/src/modules/mail/internal/domain/records.ts new file mode 100644 index 0000000..c523504 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/records.ts @@ -0,0 +1,135 @@ +/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */ +import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts'; +import type { MailCursor } from './cursor.ts'; + +export type MailAccountRecord = { + readonly id: string; + readonly kind: MailAccountKind; + readonly name: string; + readonly address: string; + readonly displayName: string; + readonly enabled: boolean; + readonly isDefault: boolean; + readonly pollIntervalSec: number; + readonly channelId: string | null; + readonly backfillFrom: Date | null; + /** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */ + readonly settings: Record; + /** 密封的 JSON:{password} | {corpSecret};永不出接口 */ + readonly secretCipher: string | null; + readonly cursor: MailCursor | null; + readonly lastPolledAt: Date | null; + readonly lastPollError: string | null; + readonly lastInboundAt: Date | null; + readonly pollingUntil: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; +}; + +export type InboundAttachmentRecord = { + readonly index: number; + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly ref: string | null; + readonly note: string; +}; + +export type InboundRecord = { + readonly id: string; + readonly accountId: string; + readonly messageId: string; + readonly providerRef: string; + readonly inReplyTo: string | null; + readonly references: readonly string[]; + readonly from: MailAddress; + readonly to: readonly MailAddress[]; + readonly cc: readonly MailAddress[]; + readonly subject: string; + readonly text: string | null; + readonly html: string | null; + readonly sentAt: Date | null; + readonly receivedAt: Date; + readonly attachments: readonly InboundAttachmentRecord[]; + /** + * 原件字节 —— **只有「下载原件」那一条读取回它**(`InboundStore.get`); + * 列表与只要元信息的读一律不取(一封上限 16MB),那时这里恒是 null。 + * 「有没有原件」问 `rawAvailable`,别拿 `raw !== null` 去问 —— 那两件事只在全列读时才等价。 + */ + readonly raw: Uint8Array | null; + /** 库里那一行有没有原件(`raw is not null` 投影出来的,不靠把字节搬回来再判) */ + readonly rawAvailable: boolean; + readonly rawNote: string; + readonly size: number; + readonly createdAt: Date; +}; + +export type DeliveryRecord = { + readonly mailId: string; + readonly subscriber: string; + readonly state: MailDeliveryState; + readonly error: string | null; + readonly startedAt: Date; + readonly finishedAt: Date | null; +}; + +export type OutboundAttachmentRecord = { + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly ref: string | null; +}; + +export type OutboundRecord = { + readonly id: string; + readonly accountId: string; + readonly messageId: string; + readonly idempotencyKey: string | null; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly bcc: readonly string[]; + readonly subject: string; + readonly text: string | null; + readonly html: string | null; + readonly inReplyTo: string | null; + readonly references: readonly string[]; + readonly attachments: readonly OutboundAttachmentRecord[]; + readonly state: MailOutboundState; + readonly error: string | null; + readonly providerRef: string | null; + readonly requestedBy: Record; + readonly createdAt: Date; + readonly sentAt: Date | null; +}; + +export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }; + +/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */ +export type ParsedMail = { + readonly messageId?: string | undefined; + readonly inReplyTo?: string | undefined; + readonly references: readonly string[]; + readonly from?: MailAddress | undefined; + readonly to: readonly MailAddress[]; + readonly cc: readonly MailAddress[]; + readonly subject: string; + readonly text?: string | undefined; + readonly html?: string | undefined; + readonly date?: Date | undefined; + readonly attachments: readonly ParsedAttachment[]; +}; + +/** 交给 provider 的出信 —— 附件已是字节 */ +export type OutboundMessage = { + readonly messageId: string; + readonly from: MailAddress; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly bcc: readonly string[]; + readonly subject: string; + readonly text?: string | undefined; + readonly html?: string | undefined; + readonly inReplyTo?: string | undefined; + readonly references: readonly string[]; + readonly attachments: readonly ParsedAttachment[]; +}; diff --git a/apps/api/src/modules/mail/internal/domain/redact.ts b/apps/api/src/modules/mail/internal/domain/redact.ts new file mode 100644 index 0000000..3f8bf9d --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/redact.ts @@ -0,0 +1,49 @@ +/** + * 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。 + * 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。 + */ +import { stripNul } from './inbound-record.ts'; + +const MIN_SECRET_CHARS = 4; + +export function secretVariants(secret: string): string[] { + if (secret.length < MIN_SECRET_CHARS) return []; + const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)]; + return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))]; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function redactSecrets(text: string, secrets: readonly string[]): string { + let out = text; + for (const secret of secrets) { + for (const variant of secretVariants(secret)) { + out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***'); + } + } + return out; +} + +/** + * 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上。 + * + * **出口一律剥 NUL**:这句话的去向全是 PG 的 `text` 列(`last_poll_error` / 投递的 `error` / + * 降级存档行的正文),而它的来源常常是**解析失败的那封信自己的字节** —— 一个 U+0000 就能让 + * 「记下这次失败」这一笔自己也写不进去。剥在这里,是因为所有落库的错误话都从这个出口出去; + * 分散到每个调用点去剥,总会漏掉下一个新调用点。 + */ +export function errorText(error: unknown): string { + return stripNul(rawErrorText(error)); +} + +function rawErrorText(error: unknown): string { + if (error instanceof Error) { + const bag = error as Error & { response?: unknown; responseText?: unknown }; + const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : ''; + return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`; + } + if (error === undefined || error === null) return '未知错误'; + return String(error); +} diff --git a/apps/api/src/modules/mail/internal/domain/send-input.ts b/apps/api/src/modules/mail/internal/domain/send-input.ts new file mode 100644 index 0000000..b375f36 --- /dev/null +++ b/apps/api/src/modules/mail/internal/domain/send-input.ts @@ -0,0 +1,60 @@ +/** 发信校验与选账号(设计稿 §5.3 / §5.4) */ +import type { MailAccountKind, MailSendInput } from '@contracts'; +import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts'; +import type { MailAccountRecord } from './records.ts'; + +export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024; +export const SEND_TOTAL_CAP = 20 * 1024 * 1024; +/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */ +export const WECOM_TOTAL_CAP = 50 * 1024 * 1024; +export const WECOM_ATTACHMENT_COUNT_CAP = 200; + +type SendShape = Pick; + +export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] { + const problems: string[] = []; + if (input.to.length === 0) problems.push('收件人至少一个'); + if (input.subject.trim() === '') problems.push('主题不能为空'); + if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) { + problems.push('正文 text / html 至少一个'); + } + const total = attachmentSizes.reduce((sum, n) => sum + n, 0); + if (kind === 'wecom') { + if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`); + if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB'); + } else { + const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP); + if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`); + if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB'); + } + return problems; +} + +export function pickAccount( + accounts: readonly MailAccountRecord[], + requested: { readonly accountId?: string | undefined; readonly from?: string | undefined }, +): MailAccountRecord { + if (requested.accountId !== undefined) { + const hit = accounts.find((a) => a.id === requested.accountId); + if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId); + if (!hit.enabled) throw new MailAccountDisabledError(hit.id); + return hit; + } + if (requested.from !== undefined) { + const wanted = requested.from.trim().toLowerCase(); + const hit = accounts.find((a) => a.address.toLowerCase() === wanted); + // 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗 + if (hit === undefined) throw new MailAccountNotFoundError(requested.from); + if (!hit.enabled) throw new MailAccountDisabledError(hit.id); + return hit; + } + const fallback = accounts.find((a) => a.isDefault && a.enabled); + if (fallback === undefined) throw new MailNoDefaultAccountError(); + return fallback; +} + +export function messageIdFor(id: string, address: string): string { + const at = address.lastIndexOf('@'); + const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); + return `<${id}@${domain}>`; +} diff --git a/apps/api/src/modules/mail/internal/usecase/mail-service.ts b/apps/api/src/modules/mail/internal/usecase/mail-service.ts new file mode 100644 index 0000000..27c0400 --- /dev/null +++ b/apps/api/src/modules/mail/internal/usecase/mail-service.ts @@ -0,0 +1,652 @@ +/** + * mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。 + * + * 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重) + * → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。 + * 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。 + */ +import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts'; +import type { InboundHandler, MailService } from '../../contract.ts'; +import { cursorReset, mergeAccount, secretOf, viewOf } from '../domain/account-view.ts'; +import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts'; +import { leaseMsFor } from '../domain/due.ts'; +import { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from '../domain/errors.ts'; +import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, peekHeader, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts'; +import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts'; +import { errorText, redactSecrets } from '../domain/redact.ts'; +import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts'; +import type { + AttachmentStore, + ChangeNotifier, + Clock, + IdGenerator, + InboundStore, + MailAccountStore, + MailProvider, + MimeParser, + OutboundStore, + ProviderAccount, + RawMessage, + SecretBox, +} from '../../port.ts'; + +export type MailServiceDeps = { + readonly accounts: MailAccountStore; + readonly inbound: InboundStore; + readonly outbound: OutboundStore; + readonly attachments: AttachmentStore; + readonly providers: Readonly>; + readonly mime: MimeParser; + readonly secrets: SecretBox; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly notify: ChangeNotifier; + readonly limits?: + | { + readonly perTick?: number | undefined; + readonly tickBudgetMs?: number | undefined; + readonly attachmentBytes?: number | undefined; + readonly rawBytes?: number | undefined; + } + | undefined; +}; + +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 200; + +/** + * 同键的上一封停在 `sending` 多久之后,算作「上次崩在半路」而不是「正在飞」。 + * 这两种局面在库里长得一模一样,只能靠时限分开:短于此值一律不发(宁可让调用方再试一次, + * 也不能发出第二封真邮件);长于此值就当那一行没人管了,复用它重发。 + */ +const SEND_STALE_MS = 5 * 60_000; + +/** + * 一条投递停在 `dispatching` 多久之后,允许**人手**的「重新投递」把它接管过去。 + * 自动路径永远不重投(那是防双投的墙);但进程若崩在写 dispatching 与写结果之间, + * 那条留痕就永远卡着 —— 墙要配路:人按的那一下得有门进。 + */ +const DISPATCH_STALE_MS = 10 * 60_000; + +type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string }; + +function tallyToOutcome(t: PollTally): MailPollOutcome { + return { + accountId: t.accountId, + pulled: t.pulled, + stored: t.stored, + dispatched: t.dispatched, + more: t.more, + ...(t.error === undefined ? {} : { error: t.error }), + }; +} + +function safeFileName(messageId: string): string { + const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned === '' ? 'mail' : cleaned; +} + +function resultOf(record: OutboundRecord): MailSendResult { + return { + outboundId: record.id, + accountId: record.accountId, + messageId: record.messageId, + state: record.state === 'sent' ? 'sent' : 'failed', + ...(record.error === null ? {} : { error: record.error }), + ...(record.providerRef === null ? {} : { providerRef: record.providerRef }), + }; +} + +export function createMailService(deps: MailServiceDeps): MailService { + const limits = { + perTick: deps.limits?.perTick ?? 50, + tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000, + attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP, + rawBytes: deps.limits?.rawBytes ?? RAW_CAP, + }; + const subscribers = new Map(); + + async function requireAccount(accountId: string): Promise { + const record = await deps.accounts.get(accountId); + if (record === undefined) throw new MailAccountNotFoundError(accountId); + return record; + } + + /** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */ + function openSecret(record: Pick): Record { + if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id); + /* + * 解密与解析都包进来:`JSON.parse` 抛的 SyntaxError **把被解析的原文嵌在 message 里** + * (V8:`Unexpected token 'p', "pw-1234" is not valid JSON`),而这一抛发生在调用方把 `secret` + * 赋值**之前** —— 脱敏那一层此刻手里是空的、一个字都洗不掉,明文会经 `savePoll({error})` + * 落进 `last_poll_error`,再由账号视图上到运营台。 + * 解不开就是解不开:一律换成「未配置凭证」,原文一个字不外传。 + */ + let parsed: unknown; + try { + parsed = JSON.parse(deps.secrets.open(record.secretCipher)); + } catch { + throw new MailSecretMissingError(record.id); + } + if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id); + return Object.fromEntries(Object.entries(parsed as Record).map(([k, v]) => [k, String(v)])); + } + + function providerAccount(record: MailAccountRecord, secret: Record): ProviderAccount { + return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret }; + } + + function redactWith(secret: Record): (text: string) => string { + const values = Object.values(secret); + return (text) => redactSecrets(text, values); + } + + function redactReport(report: MailProbeReport, secret: Record): MailProbeReport { + const redact = redactWith(secret); + return { + ...report, + receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) }, + send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) }, + }; + } + + function limitOf(query: { limit?: number | undefined }): number { + return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT)); + } + + /* ── 分发 ────────────────────────────────────────────────────────── */ + + async function dispatch( + record: InboundRecord, + ctx: { accountId: string; channelId: string | null }, + only?: string, + opts: { readonly reclaimStale?: boolean } = {}, + ): Promise { + // 只要那几条投递痕,不必把 16MB 的原件搬过来 + const existing = (await deps.inbound.getMeta(record.id))?.deliveries ?? []; + const out: DeliveryRecord[] = []; + for (const [name, handler] of subscribers) { + if (only !== undefined && name !== only) continue; + const current = existing.find((d) => d.subscriber === name); + // 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事 + const stale = + opts.reclaimStale === true && + current?.state === 'dispatching' && + deps.clock.now().getTime() - current.startedAt.getTime() >= DISPATCH_STALE_MS; + if (!canRedeliver(current?.state) && !stale) { + if (current !== undefined) out.push(current); + continue; + } + const startedAt = deps.clock.now(); + const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null }; + await deps.inbound.setDelivery(dispatching); + let settled: DeliveryRecord; + try { + const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined }); + const state = stateOfOutcome(outcome); + settled = { + ...dispatching, + state, + // skipped 的理由也放 error 列:人在列表上看得见「为什么没投」 + error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null, + finishedAt: deps.clock.now(), + }; + } catch (error) { + settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() }; + } + await deps.inbound.setDelivery(settled); + out.push(settled); + } + return out; + } + + /* ── 拉取 ────────────────────────────────────────────────────────── */ + + async function storeOne(record: MailAccountRecord, message: RawMessage): Promise { + // 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍 + const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]); + if (known.has(message.providerRef)) return undefined; + const parsed = await deps.mime.parse(message.eml); + const attachments: InboundAttachmentRecord[] = []; + for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) { + let ref: string | null = null; + if (plan.store) { + const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array(); + ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref; + } + attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note }); + } + const built = buildInboundRecord({ + id: deps.ids.next(), + accountId: record.id, + address: record.address, + providerRef: message.providerRef, + parsed, + eml: message.eml, + receivedAt: message.receivedAt, + attachments, + now: deps.clock.now(), + rawCap: limits.rawBytes, + }); + const { inserted } = await deps.inbound.insert(built); + return inserted ? built : undefined; + } + + /** + * 单封解析失败的**退路**:先试着落一行最小骨架(照迁移 0033 的 NOT NULL 填),落成了就推进游标。 + * + * 没有这条路时,一封「毒信」把整个账号永久卡死:`storeOne` 抛错 → 外层 catch 接住 → + * 游标停在这一封**之前** → 下一 tick 从同一个 UID 重拉、同样抛,**后面所有新信永远进不来**, + * 而运营面只有一行 `last_poll_error`、没有任何钮能跳过它。 + * 骨架行不是「跳过」(那是丢信):原件 `raw` 原样存着,主题能扒多少扒多少, + * 正文写明这是降级行与失败原因,人在列表上看得见、点得开、可按「重新投递」。 + */ + async function storeDegraded( + record: MailAccountRecord, + message: RawMessage, + cause: unknown, + ): Promise<{ record: InboundRecord; inserted: boolean } | undefined> { + try { + const subject = peekHeader(message.eml, 'Subject'); + const built = buildInboundRecord({ + id: deps.ids.next(), + accountId: record.id, + address: record.address, + providerRef: message.providerRef, + parsed: { + references: [], + to: [], + cc: [], + subject: subject === '' ? '(主题解析失败)' : subject, + text: `【降级存档】这一封的 MIME 解析或入库失败,原文见「下载原件」。失败原因:${errorText(cause)}`, + attachments: [], + }, + eml: message.eml, + receivedAt: message.receivedAt, + attachments: [], + now: deps.clock.now(), + rawCap: limits.rawBytes, + }); + // 唯一键撞上(这一封先前已经降级存过)也算「落下了」:那一行就在库里,照常越过去,只是不重复计数 + const { inserted } = await deps.inbound.insert(built); + return { record: built, inserted }; + } catch { + // 骨架都落不下 —— 交给调用方停在这一封并点名是谁 + return undefined; + } + } + + async function pollAccount(record: MailAccountRecord): Promise { + const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec)); + if (!claimed) throw new MailPollBusyError(record.id); + const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false }; + /** 这一轮的「有话说」:降级存档了哪几封、provider 报的重锚说明 —— 顺利收尾也要落进 last_poll_error */ + const notes: string[] = []; + let secret: Record = {}; + try { + secret = openSecret(record); + const provider = deps.providers[record.kind]; + const account = providerAccount(record, secret); + const started = deps.clock.now(); + let cursor = record.cursor; + if (cursor === null) { + cursor = await provider.anchor(account, record.backfillFrom, started); + await deps.accounts.savePoll(record.id, { cursor }); + } + let budget = limits.perTick; + let more = true; + let seq = 0; + while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) { + const batch = await provider.pull(account, cursor, { + limit: budget, + now: deps.clock.now(), + backfillFrom: record.backfillFrom, + known: (refs) => deps.inbound.knownProviderRefs(record.id, refs), + }); + // provider 有话说(如 UIDVALIDITY 变化已重锚)—— 这一轮不算失败,但界面上必须看得见 + if (batch.note !== undefined && batch.note !== '') notes.push(batch.note); + tally.pulled += batch.messages.length; + budget -= batch.messages.length; + for (const message of batch.messages) { + seq += 1; + let stored: InboundRecord | undefined; + let degraded = false; + try { + stored = await storeOne(record, message); + } catch (error) { + /* + * **一封解析不动的信不许卡死整条路**:先落降级骨架,落成了就照常推进游标。 + * 骨架也落不下才停在这一封 —— 那时 `last_poll_error` 必须点名是哪一封(UID / providerRef), + * 否则人只看得见一句「解析失败」,不知道是谁卡着、更不知道能不能跳过。 + */ + const fallback = await storeDegraded(record, message, error); + if (fallback === undefined) { + throw new Error(`第 ${seq} 封(${message.providerRef})存不下,降级骨架也落不下,这一轮停在它这里:${errorText(error)}`); + } + degraded = true; + stored = fallback.inserted ? fallback.record : undefined; + notes.push(`第 ${seq} 封(${message.providerRef})解析失败,已降级存档:${errorText(error)}`); + } + // 逐封推进:中途栽了,下一轮从这一封之后接着 + if (message.cursorAfter !== undefined) { + cursor = message.cursorAfter; + await deps.accounts.savePoll(record.id, { cursor }); + } + if (stored !== undefined) { + tally.stored += 1; + await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt }); + /* + * 降级行**不自动分发**:它的正文是失败原因,不是客户说的话 —— 投给通道等于让下游 + * 按一段垃圾正文起单。它照旧躺在入站列表里(带原件),人看过之后可以按「重新投递」。 + */ + if (!degraded) { + await dispatch(stored, { accountId: record.id, channelId: record.channelId }); + tally.dispatched += 1; + } + } + } + cursor = batch.cursor; + await deps.accounts.savePoll(record.id, { cursor }); + more = batch.more; + if (batch.messages.length === 0) break; + } + tally.more = more; + // 这一轮没崩,但有话要说的照说(降级存档 / 重锚)—— 一片绿不是实情 + const note = notes.length === 0 ? null : notes.join(';'); + if (note !== null) tally.error = note; + await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: note }); + } catch (error) { + const text = redactWith(secret)(errorText(error)); + tally.error = text; + await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text }); + } finally { + await deps.accounts.releasePolling(record.id); + } + if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound'); + return tallyToOutcome(tally); + } + + /* ── 服务 ────────────────────────────────────────────────────────── */ + + return { + async listAccounts() { + const now = deps.clock.now(); + return (await deps.accounts.list()).map((r) => viewOf(r, now)); + }, + + async getAccount(accountId) { + return viewOf(await requireAccount(accountId), deps.clock.now()); + }, + + async createAccount(input) { + if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name); + const secretJson = secretOf(input); + const now = deps.clock.now(); + const record = mergeAccount(input, { + id: deps.ids.next(), + existing: undefined, + secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson), + now, + }); + await deps.accounts.save(record); + return viewOf(record, now); + }, + + async updateAccount(accountId, input) { + const existing = await requireAccount(accountId); + const clash = await deps.accounts.findByName(input.name); + if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name); + const secretJson = secretOf(input); + // 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废 + const secretCipher = + secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null; + const now = deps.clock.now(); + // kind / **收侧** settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset) + const record = mergeAccount(input, { id: accountId, existing, secretCipher, now }); + await deps.accounts.save(record); + /* + * `save()` 只写配置列(不然会盖掉另一台正持着的租约与刚推进的游标),所以清游标要**显式单写**这一列。 + * 上一轮的错误话跟着清:它说的是旧配置下的事,留着会让人对着新配置读旧毛病。 + */ + if (cursorReset(existing, input)) await deps.accounts.savePoll(accountId, { cursor: null, error: null }); + return viewOf(record, now); + }, + + async deleteAccount(accountId) { + await requireAccount(accountId); + await deps.accounts.remove(accountId); + }, + + async probe(target) { + if ('input' in target) { + const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId); + const secretJson = secretOf(target.input); + const secret = + secretJson !== undefined + ? (JSON.parse(secretJson) as Record) + : stored !== undefined && stored.kind === target.input.kind + ? openSecret(stored) + : (() => { + throw new MailSecretMissingError(target.accountId ?? 'draft'); + })(); + const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() }); + const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret)); + return redactReport(report, secret); + } + const record = await requireAccount(target.accountId); + const secret = openSecret(record); + const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret); + // wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改 + if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) { + await deps.accounts.savePoll(record.id, { address: report.address }); + } + return report; + }, + + async pollDue(now) { + const due = await deps.accounts.listDue(now); + // 账号间并行,单账号内串行;一个账号的异常只记在它自己头上 + const settled = await Promise.allSettled(due.map((record) => pollAccount(record))); + return settled.map((r, i) => + r.status === 'fulfilled' + ? r.value + : { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) }, + ); + }, + + async pollNow(accountId) { + const record = await requireAccount(accountId); + if (!record.enabled) throw new MailAccountDisabledError(accountId); + return pollAccount(record); + }, + + subscribe(name, handler) { + if (subscribers.has(name)) throw new MailSubscriberTakenError(name); + subscribers.set(name, handler); + return () => { + subscribers.delete(name); + }; + }, + + async listInbound(query) { + const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) }); + return rows.map((r) => summaryOf(r.record, r.deliveries)); + }, + + async getInbound(mailId) { + const hit = await deps.inbound.getMeta(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + return detailOf(hit.record, hit.deliveries); + }, + + async attachment(mailId, index) { + // 附件在 sys.upload 里,这里只要那一行的 meta —— 原件字节不必跟着出来 + const hit = await deps.inbound.getMeta(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + const meta = hit.record.attachments.find((a) => a.index === index); + if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index); + const file = await deps.attachments.get(meta.ref); + if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index); + return { name: meta.name, contentType: meta.contentType, bytes: file.bytes }; + }, + + async raw(mailId) { + // 全仓唯一要 raw 字节的一条路 + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1); + return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw }; + }, + + async redeliver(mailId, subscriber) { + const hit = await deps.inbound.getMeta(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber); + const owner = await deps.accounts.get(hit.record.accountId); + const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber, { + reclaimStale: true, + }); + deps.notify.changed('inbound'); + return deliveries.map(deliveryViewOf); + }, + + async send(input) { + const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from }); + // 幂等键:放行重放不发两封 + const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey); + if (prior !== undefined && prior.state === 'sent') return resultOf(prior); + /* + * **同键不发两封**。`sending` 有两种局面而库里长得一样:正在飞 / 上次崩在半路 —— 用时限分开。 + * 没超时的:不发,如实回「没发出去」并说清缘由(调用方再试一次即可;第一封落地后同键就短路成 sent 了)。 + * 超时的、以及 `failed` 的:复用那一行重发。 + */ + if (prior !== undefined && prior.state === 'sending') { + const ageMs = deps.clock.now().getTime() - prior.createdAt.getTime(); + if (ageMs < SEND_STALE_MS) { + return { + outboundId: prior.id, + accountId: prior.accountId, + messageId: prior.messageId, + state: 'failed', + error: `同一幂等键的上一封仍在发送中(${Math.round(ageMs / 1000)}s),本次未重复发送`, + }; + } + } + + const files: ParsedAttachment[] = []; + for (const a of input.attachments) { + if (a.dataBase64 !== undefined) { + files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) }); + } else if (a.uploadRef !== undefined) { + const got = await deps.attachments.get(a.uploadRef); + if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]); + files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes }); + } else { + throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]); + } + } + const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength)); + if (problems.length > 0) throw new MailSendInvalidError(problems); + const secret = openSecret(account); + + const now = deps.clock.now(); + const id = prior?.id ?? deps.ids.next(); + const messageId = prior?.messageId ?? messageIdFor(id, account.address); + if (prior !== undefined) { + /* + * **复用旧行之前先抢**:上面那一段只判断了「这一行看着可以复用」,而两个后端连同一个库时 + * 两台会同时判出同一个结论 —— 都发,就是两封真邮件。抢是一条条件更新(见 `claimForResend`), + * 抢不到说明另一路已经接管,这一次如实回「没发出去」。 + */ + const claimed = await deps.outbound.claimForResend(prior.id, { staleBefore: new Date(now.getTime() - SEND_STALE_MS), now }); + if (!claimed) { + return { + outboundId: prior.id, + accountId: prior.accountId, + messageId: prior.messageId, + state: 'failed', + error: '同一幂等键的上一封正被另一路重发,本次未重复发送', + }; + } + } + if (prior === undefined) { + const record: OutboundRecord = { + id, + accountId: account.id, + messageId, + idempotencyKey: input.idempotencyKey ?? null, + to: [...input.to], + cc: [...input.cc], + bcc: [...input.bcc], + subject: input.subject, + text: input.text ?? null, + html: input.html ?? null, + inReplyTo: input.inReplyTo ?? null, + references: [...input.references], + attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })), + state: 'sending', + error: null, + providerRef: null, + requestedBy: { ...input.requestedBy }, + createdAt: now, + sentAt: null, + }; + await deps.outbound.insert(record); + } + + try { + const sent = await deps.providers[account.kind].send(providerAccount(account, secret), { + messageId, + from: { address: account.address, name: account.displayName }, + to: input.to, + cc: input.cc, + bcc: input.bcc, + subject: input.subject, + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.html === undefined ? {} : { html: input.html }), + ...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }), + references: input.references, + attachments: files, + }); + await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }); + deps.notify.changed('outbound'); + return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }; + } catch (error) { + const text = redactWith(secret)(errorText(error)); + await deps.outbound.setState(id, 'failed', { error: text }); + deps.notify.changed('outbound'); + return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text }; + } + }, + + async listOutbound(query) { + const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) }); + return rows.map((r) => ({ + id: r.id, + accountId: r.accountId, + messageId: r.messageId, + to: [...r.to], + cc: [...r.cc], + bcc: [...r.bcc], + subject: r.subject, + state: r.state, + error: r.error, + providerRef: r.providerRef, + requestedBy: r.requestedBy, + createdAt: r.createdAt.toISOString(), + sentAt: r.sentAt === null ? null : r.sentAt.toISOString(), + })); + }, + }; +} diff --git a/apps/api/src/modules/mail/package.json b/apps/api/src/modules/mail/package.json new file mode 100644 index 0000000..c0f19b4 --- /dev/null +++ b/apps/api/src/modules/mail/package.json @@ -0,0 +1,7 @@ +{ + "name": "@modules/mail", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} diff --git a/apps/api/src/modules/mail/port.ts b/apps/api/src/modules/mail/port.ts new file mode 100644 index 0000000..5104dfe --- /dev/null +++ b/apps/api/src/modules/mail/port.ts @@ -0,0 +1,142 @@ +/** + * module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import type { MailCursor } from './internal/domain/cursor.ts'; +import type { + DeliveryRecord, + InboundRecord, + MailAccountRecord, + OutboundMessage, + OutboundRecord, + ParsedMail, +} from './internal/domain/records.ts'; + +export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail }; + +/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */ +export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +export type MailAccountStore = { + list(): Promise; + get(id: string): Promise; + findByName(name: string): Promise; + /** + * upsert **只写配置列**;`isDefault:true` 时先把别的置 false(同一事务)。 + * 运行列(cursor / lastPolledAt / lastPollError / lastInboundAt / pollingUntil)一概不动 —— 见 `savePoll`。 + */ + save(record: MailAccountRecord): Promise; + remove(id: string): Promise; + /** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */ + listDue(now: Date): Promise; + /** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */ + claimPolling(id: string, leaseMs: number): Promise; + releasePolling(id: string): Promise; + /** + * 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op。 + * + * **运行列只走这条路**(与 `claimPolling` / `releasePolling` 一起)——`save()` 一个字都不碰它们: + * 运营面 PUT 的同时另一台正拉着,整行 upsert 会把租约与游标一并覆盖回读那一刻的快照 + * (`sys.loop_state` 整份覆盖那一课的同款指纹)。 + * `cursor: null` 是**显式清游标**(改了收侧配置要重锚时用)。 + */ + savePoll( + id: string, + patch: { + cursor?: MailCursor | null; + polledAt?: Date; + error?: string | null; + lastInboundAt?: Date; + address?: string; + }, + ): Promise; +}; + +export type InboundStore = { + /** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */ + insert(record: InboundRecord): Promise<{ inserted: boolean }>; + knownProviderRefs(accountId: string, refs: readonly string[]): Promise>; + /** + * 列表 —— **不取 `raw` 原件字节**(一封上限 16MB,50 行就是几百 MB,只为算 200 字预览); + * `rawAvailable` 由 `raw is not null` 投影出来,不靠把字节搬回来再判。 + */ + list(query: { accountId?: string | undefined; limit: number }): Promise>; + /** 只要元信息的读(分发 / 详情 / 附件 / 重投都走它)—— 同样不取 `raw` 字节 */ + getMeta(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>; + /** 全列读 —— 只有「下载原件」那一条路要它 */ + get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>; + /** upsert on (mail_id, subscriber) */ + setDelivery(delivery: DeliveryRecord): Promise; +}; + +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + /** + * 复用旧行重发之前**先抢**(一条条件更新,抢到才回 true): + * `failed` 的行、或 `created_at <= staleBefore` 的 `sending` 行才抢得到;抢到即置 `sending` 并把 + * `created_at` 推到 `now` —— 这一手让那一行**离开可抢的集合**,第二个人当场抢不到。 + * + * 本仓是两个后端连同一个库:没有这一抢,两台同时重放同一次放行都会判「超时可复用」, + * 发出**两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。 + */ + claimForResend(id: string, opts: { readonly staleBefore: Date; readonly now: Date }): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise; + list(query: { accountId?: string | undefined; limit: number }): Promise; +}; + +/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */ +export type AttachmentStore = { + put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string }>; + get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>; +}; + +/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */ +export type ProviderAccount = { + readonly id: string; + readonly kind: MailAccountKind; + readonly address: string; + readonly displayName: string; + readonly settings: Record; + readonly secret: Record; +}; + +export type RawMessage = { + readonly providerRef: string; + readonly eml: Uint8Array; + readonly receivedAt: Date; + /** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */ + readonly cursorAfter?: MailCursor | undefined; +}; + +/** 外界:一种账号类型一个适配器(设计稿 §5.5) */ +export type MailProvider = { + readonly kind: MailAccountKind; + probe(account: ProviderAccount): Promise; + /** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise; + /** + * 拉一批。回参的 `note` 是这一轮**不算失败、但人必须知道**的事 + * (当前唯一一种:IMAP 的 UIDVALIDITY 变了、已重锚在当下、期间来信不回填)—— + * 用例把它落进 `last_poll_error`。丢了一段信而界面一片绿,那是界面撒谎。 + */ + pull( + account: ProviderAccount, + cursor: MailCursor, + opts: { + readonly limit: number; + readonly now: Date; + readonly backfillFrom: Date | null; + /** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */ + readonly known: (refs: readonly string[]) => Promise>; + }, + ): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean; note?: string | undefined }>; + send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>; +}; + +export type MimeParser = { parse(eml: Uint8Array): Promise }; + +/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */ +export type ChangeNotifier = { changed(reason: string): void }; diff --git a/apps/api/src/modules/org/CONTRACT.md b/apps/api/src/modules/org/CONTRACT.md index 05e7412..3b1d849 100644 --- a/apps/api/src/modules/org/CONTRACT.md +++ b/apps/api/src/modules/org/CONTRACT.md @@ -1,51 +1,61 @@ # module: org -职责: 员工与岗位:账号、口令与登录会话(ADR-010)、岗位授权(客体三类 × 动作三种)、 - 停用前置检查、岗位在岗人员查询(负责人四策略解析的候选集来源)、 - 工作组(ADR-011:起单时的岗位绑定)、种子数据播种 -不负责: 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 端口由 app 注入)、 - 鉴权中间件与 cookie 的读写(app 层:§4「中间件在 app 层,身份判定读 org 出口」) +职责: 员工与岗位(ADR-012):验签后的人是谁(名册即时更新)、他能进哪几个端(角色→端)、 + 岗位指派与在岗人员查询(负责人四策略解析的候选集来源)、名册同步(Admin API)、 + 在途工作(只读)、工作组(ADR-011)、种子(岗位 + 缺省角色→端) +不负责: 验签之外的 HTTP 语义(401/403 由 app 层中间件按路由归属判)、 + 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 由 app 注入) 公开出口: index.ts - - 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` `IssuedSession` 与 @contracts 的 - `Staff` / `Position` / `Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出 - - 错误:`StaffNotFoundError` `PositionNotFoundError` `AccountTakenError` `DisableBlockedError` - - 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `SessionRepository` - `WorkGroupRepository` `PasswordHasher` `InFlightWorkProbe` `Clock` `IdGenerator` + - 类型:`OrgService` `StaffWithPosition` `Identity` 与 @contracts 的 `Staff` / `Position` / `Principal` / + `RoleAccess` / `RosterSyncOutcome` / `InFlightWork` / `Surface` / `WorkGroup` / `WorkGroupBinding` 转出 + - 错误:`TokenRejectedError`(→401)`AccountDisabledError`(→403)`AccessLockoutError`(→409) + `StaffNotFoundError` `PositionNotFoundError` + - 端口类型:`TokenVerifier` `VerifiedClaims` `RosterSource` `RosterUser` `IdentityPatch` + `PositionRepository` `StaffRepository` `StaffRecord` `RoleAccessRepository` + `WorkGroupRepository` `InFlightWorkProbe` `Clock` `IdGenerator` - 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService` - - 纯函数:`isAuthorized` `actionCovers` `objectCovers` - - 常量:`POSITION_SEED` `STAFF_SEED` `DEFAULT_LOGIN_ACCOUNT` - - adapter 工厂(供 app 装配):`createPositionRepository` `createStaffRepository` - `createGrantRepository` `createSessionRepository` `createWorkGroupRepository` `objectKeyOf` - - 口令散列(供 app 装 `PasswordHasher`):`hashPassword` `verifyPassword` `newSessionToken` - - Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable` `sessionTable` - `workGroupTable` + - 纯函数:`identityOf` `isNoiseRole` `normalizeRoles` `endsOf` `assertNotLockedOut` `normalizeEnds` `buildInFlightWork` + - 常量:`POSITION_SEED` `NONE_POSITION_CODE` `TEST_POSITION_CODE` `DEFAULT_ROLE_ACCESS` + - adapter 工厂(供 app 装配):`createKeycloakTokenVerifier`(jose,配置型 `KeycloakVerifierOptions`) + `createKeycloakRoster`(Admin API,配置型 `KeycloakRosterOptions`) + `createPositionRepository` `createStaffRepository` `createRoleAccessRepository` `createWorkGroupRepository` + - 错误译文(供 app 拼 401 message):`explainJoseError`(jose 错误 → 人话,手册 4.4) + - Drizzle 表:`positionTable` `staffTable` `roleAccessTable` `workGroupTable` 层级: L1(编码规范 §4) -依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema) - port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator` -被依赖: process(负责人解析的候选集)、message、interaction、human-tool、report、 - app(鉴权中间件与 /api/gov/staff 路由组装) +依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema);jose(仅 adapter) + port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`、 + `TokenVerifier`(app 给 jose 远程 JWKS;测试给本地密钥对)、`RosterSource`(可缺省) +被依赖: process(负责人解析的候选集)、message(治理台收件人 = staffWithEnd('ops'))、 + app(鉴权中间件与 /api/gov/staff、/api/gov/access 路由组装) 不变量: - - 动作蕴含:治理 ⊃ 写 ⊃ 读;数据客体三级覆盖(域 ⊃ 对象 ⊃ 字段) - - 停用是全有全无:前置检查未过即抛 `DisableBlockedError`,不做部分执行 - - `authorize` 对停用员工恒 false - - `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动; - 唯一例外是**补种口令** —— 已存在但 `password_hash` 为空的员工会被补上初始口令, - 否则 ADR-010 之前建的账号永远登不进(这不算覆盖人工改动:空不是改动) - - **登录失败不区分原因**:账号不存在 / 已停用 / 未设口令 / 口令错,一律 `undefined`; - 且无论账号是否存在都走一遍散列比对,免得响应时间泄漏账号是否存在 - - **明文永不落库**:口令只在 `login` / `setPassword` 的入参上停留一瞬,落库前即散列 - - **会话可即刻作废**:服务端表而非签名 cookie —— `logout` 立刻生效; - `disableStaff` 连带 `revokeAllOf`,被停用的人拿旧 cookie 也办不了事 - - **工作组是绑定不是策略**(ADR-011):它给出岗位的人选,四策略于是无从发问; - `bindingOf` 只出成员表快照,在岗与否由调用方(process)核 —— 岗位候选集核不了跨岗位的绑定 + - `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键 + - `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError, + 有效 token 不能把它翻回 active(撤权在同步之后即时生效) + - **停用判定走在 JIT 写之前**:已停用的人攥着有效 token 也驱动不了 `updateIdentity`; + 「第一次见到的人要建档」不受影响(本地查无此人时照建) + - 名册即时更新只写变了的列;`last_seen_at` 60 秒内不重写 + - **同名让位**:`account` 有唯一索引;建档前按账号查一次,占着的若是另一个 sub(stale 行), + 先把它的账号让位成 `账号#retired-<旧 sub 前 8 位>` 再插 —— 不删行、留 stderr + - 角色→端:`setRoleAccess` / `deleteRoleAccess` 先算新表再验锁死不变量(名册为空不算锁死) + - **名册同步的三道下限保护**(全员停用之后同步按钮自己要 ops,那条恢复路会自锁): + ①远端整个空着不据此停任何人;②服务账号(`testAccounts`)缺席不算离职(Admin 用户列表本来就不含它们); + ③落库之前按差异算一遍「同步后还有谁能进运营中心」,不过就整体拒绝(AccessLockoutError → 409),一条都不写 + - 同步逐条落库:**单条失败不停在半路**,失败的人点名进 `RosterSyncOutcome.failed`; + 三个计数说的是真落下的条数,不是打算写几条 + - `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 [] + - `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖 + - 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核 -影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`; - port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法; - 下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认 +影响半径(2026-08-25,ADR-012):contract / port / index 全面重写 —— 退役 `login` `logout` `resolveSession` + `setPassword` `authorize` `loadSession` `listGrants` `setGrants` `createStaff` `checkDisable` + `disableStaff` `findByAccount` 与 `SessionRepository` `PasswordHasher` `GrantRepository`; + 下游 app(中间件 / 装配 / 路由)与 message 装配同批改,depcruise 全量校验通过。 -近变(2026-07-27):模块面**未变**。app 层新增工作组读写路由 - (`GET|PUT /api/gov/work-groups`),组装既有的 `workGroupOf` / `saveWorkGroup` - —— ADR-011 落地时欠的那个治理界面就此清账,一人一组的口径由 app 层按 ownerId 保证。 +影响半径(2026-08-25,终审修复波次):`StaffRepository.findByAccount` **回来了**(同名让位要按账号查一次; + 它是仓储端口上的一口,不是 OrgService 的出口 —— 退役的那个是后者); + `RosterSyncOutcome` 增 `failed`(契约变更,api-report 已刷新,`docs/API文档-鉴权与人员.md` §3/§7 同步); + `AccessLockoutError` / `assertNotLockedOut` 收可选 `message`(两个来路各说各的话)。 + 下游 depcruise 反查:`rosterSyncOutcomeSchema` 只被 `syncStaffRoute.output` 与本模块用,web 尚无消费方。 diff --git a/apps/api/src/modules/org/__tests__/authorization.test.ts b/apps/api/src/modules/org/__tests__/authorization.test.ts deleted file mode 100644 index 6e66817..0000000 --- a/apps/api/src/modules/org/__tests__/authorization.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** domain 测试零 mock(编码规范 §16) */ -import { describe, expect, it } from 'vitest'; -import type { Grant, PositionId } from '@contracts'; -import { actionCovers, isAuthorized, objectCovers } from '../internal/domain/authorization.ts'; - -const pos = 'p-bd' as PositionId; - -describe('actionCovers —— 动作蕴含', () => { - it('治理蕴含写与读', () => { - expect(actionCovers('govern', 'write')).toBe(true); - expect(actionCovers('govern', 'read')).toBe(true); - }); - it('写蕴含读,但读不蕴含写', () => { - expect(actionCovers('write', 'read')).toBe(true); - expect(actionCovers('read', 'write')).toBe(false); - }); -}); - -describe('objectCovers —— 客体三级覆盖', () => { - it('对象级授权覆盖其下字段', () => { - expect( - objectCovers( - { kind: 'data', domain: 'shared', object: 'customer' }, - { kind: 'data', domain: 'shared', object: 'customer', field: 'grade' }, - ), - ).toBe(true); - }); - - it('字段级授权不覆盖别的字段', () => { - expect( - objectCovers( - { kind: 'data', domain: 'shared', object: 'customer', field: 'grade' }, - { kind: 'data', domain: 'shared', object: 'customer', field: 'payment_terms' }, - ), - ).toBe(false); - }); - - it('域级通配覆盖域内全部对象', () => { - expect( - objectCovers( - { kind: 'data', domain: 'shared', object: '*' }, - { kind: 'data', domain: 'shared', object: 'supplier', field: 'contact' }, - ), - ).toBe(true); - }); - - it('跨域不覆盖', () => { - expect( - objectCovers( - { kind: 'data', domain: 'shared', object: '*' }, - { kind: 'data', domain: 'quote_intake', object: 'record' }, - ), - ).toBe(false); - }); - - it('治理功能逐项授权', () => { - expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'task' })).toBe(true); - expect(objectCovers({ kind: 'governance', function: 'task' }, { kind: 'governance', function: 'staff' })).toBe(false); - }); - - it('任务客体未限定活动即覆盖全部任务', () => { - expect(objectCovers({ kind: 'task' }, { kind: 'task', activityId: 'a1' })).toBe(true); - expect(objectCovers({ kind: 'task', activityId: 'a1' }, { kind: 'task', activityId: 'a2' })).toBe(false); - }); - - it('客体类型不同一律不覆盖', () => { - expect(objectCovers({ kind: 'task' }, { kind: 'governance', function: 'task' })).toBe(false); - }); -}); - -describe('isAuthorized —— 任一授权覆盖即通过', () => { - const grants: Grant[] = [ - { positionId: pos, object: { kind: 'governance', function: 'task' }, action: 'read' }, - { positionId: pos, object: { kind: 'data', domain: 'shared', object: '*' }, action: 'write' }, - ]; - - it('命中数据域的写', () => { - expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'write')).toBe(true); - }); - - it('写权蕴含读权', () => { - expect(isAuthorized(grants, { kind: 'data', domain: 'shared', object: 'customer' }, 'read')).toBe(true); - }); - - it('只读的治理功能不得写', () => { - expect(isAuthorized(grants, { kind: 'governance', function: 'task' }, 'govern')).toBe(false); - }); - - it('空授权一律不通过', () => { - expect(isAuthorized([], { kind: 'governance', function: 'system' }, 'read')).toBe(false); - }); -}); diff --git a/apps/api/src/modules/org/__tests__/claims.test.ts b/apps/api/src/modules/org/__tests__/claims.test.ts new file mode 100644 index 0000000..559c3c9 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/claims.test.ts @@ -0,0 +1,45 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts'; + +describe('identityOf —— claims 归一成一个人', () => { + it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => { + const id = identityOf({ + sub: 'sub-1', + preferred_username: 'alice', + name: '爱丽丝', + email: 'a@x.test', + realm_access: { roles: ['user', 'default-roles-corp', 'offline_access', 'uma_authorization', 'admin', 'user'] }, + }); + expect(id).toEqual({ sub: 'sub-1', account: 'alice', name: '爱丽丝', email: 'a@x.test', roles: ['admin', 'user'] }); + }); + + it('没有 name(服务账号常见)→ 用用户名;没有角色 → 空数组;没有邮箱 → 不带键', () => { + const id = identityOf({ sub: 'sub-svc', preferred_username: 'service-account-bpos-e2e' }); + expect(id.name).toBe('service-account-bpos-e2e'); + expect(id.roles).toEqual([]); + expect('email' in id).toBe(false); + }); + + it('name 只有空白 → 同样回退;email 空串 → 不带键', () => { + const id = identityOf({ sub: 's', preferred_username: 'bob', name: ' ', email: '' }); + expect(id.name).toBe('bob'); + expect('email' in id).toBe(false); + }); + + it('realm_access 有但 roles 缺 → 空角色', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]); + }); +}); + +describe('噪声角色', () => { + it('三种家具角色都算噪声,业务角色不算', () => { + expect(isNoiseRole('default-roles-corp')).toBe(true); + expect(isNoiseRole('offline_access')).toBe(true); + expect(isNoiseRole('uma_authorization')).toBe(true); + expect(isNoiseRole('admin')).toBe(false); + }); + it('normalizeRoles 去重、排序、丢空串', () => { + expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/ends.test.ts b/apps/api/src/modules/org/__tests__/ends.test.ts new file mode 100644 index 0000000..bcb758e --- /dev/null +++ b/apps/api/src/modules/org/__tests__/ends.test.ts @@ -0,0 +1,55 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { RoleAccess } from '@contracts'; +import { AccessLockoutError } from '../contract.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../internal/domain/ends.ts'; + +const table: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + { role: 'designer', ends: ['design'] }, +]; + +describe('endsOf —— 角色命中行的并集', () => { + it('单角色', () => { + expect(endsOf(['user'], table)).toEqual(['workbench']); + }); + it('多角色并集,按 SURFACES 顺序', () => { + expect(endsOf(['user', 'designer'], table)).toEqual(['design', 'workbench']); + }); + it('没有任何行命中 → 空(无端可进)', () => { + expect(endsOf(['auditor'], table)).toEqual([]); + }); + it('表空 → 空', () => { + expect(endsOf(['admin'], [])).toEqual([]); + }); +}); + +describe('assertNotLockedOut —— 改完得有人还能进运营中心', () => { + it('名册空着不算锁死', () => { + expect(() => assertNotLockedOut([], [])).not.toThrow(); + }); + it('唯一的 admin 被摘掉 ops → 拒', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['user']])).toThrow(AccessLockoutError); + }); + it('ops 换到另一个在册角色上 → 过', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design'] }, + { role: 'ops-lead', ends: ['ops'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['ops-lead']])).not.toThrow(); + }); + it('有 ops 行但没人持有那个角色 → 拒', () => { + expect(() => assertNotLockedOut([{ role: 'ghost', ends: ['ops'] }], [['user']])).toThrow(AccessLockoutError); + }); +}); + +describe('normalizeEnds', () => { + it('去重、按固定顺序', () => { + expect(normalizeEnds(['workbench', 'design', 'workbench'])).toEqual(['design', 'workbench']); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts new file mode 100644 index 0000000..8689740 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts @@ -0,0 +1,76 @@ +/** Admin API 是外界 —— fetch 是注入的,这里给一个会分页、会发 token 的假 Keycloak */ +import { describe, expect, it } from 'vitest'; +import { createKeycloakRoster } from '../internal/adapter/keycloak-admin-roster.ts'; + +function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { + const calls: string[] = []; + const users = Array.from({ length: 105 }, (_, i) => ({ + id: `sub-${i}`, + username: `u${i}`, + enabled: i !== 3, + firstName: i === 0 ? '' : '名', + lastName: i === 0 ? '' : `${i}`, + email: `u${i}@x.test`, + })); + const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (url.endsWith('/realms/master/protocol/openid-connect/token')) { + return json({ access_token: 'admin-token', expires_in: 300 }); + } + const u = new URL(url); + if (u.pathname === '/admin/realms/corp/users') { + const first = Number(u.searchParams.get('first')); + const max = Number(u.searchParams.get('max')); + return json(users.slice(first, first + max)); + } + const m = /^\/admin\/realms\/corp\/users\/([^/]+)\/role-mappings\/realm\/composite$/.exec(u.pathname); + if (m !== null) { + return json([ + { name: 'user' }, + { name: 'default-roles-corp' }, + { name: 'offline_access' }, + ...(m[1] === 'sub-1' ? [{ name: 'admin' }] : []), + ]); + } + return json({ error: 'not found' }, 404); + }; + return { fetch: fetchImpl, calls }; +} + +describe('Keycloak Admin API 名册', () => { + it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + const kc = fakeKeycloak(); + const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); + const users = await roster.listUsers(); + expect(users).toHaveLength(105); + expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + expect(users[3]?.enabled).toBe(false); + expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); + }); + + it('管理 token 只取一次(闭包缓存,提前 60 秒过期)', async () => { + const kc = fakeKeycloak(); + let t = 0; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch, now: () => t }); + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(1); + t = 241 * 1000; // expires_in 300 - 60 = 240 秒后才过期 + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(2); + }); + + it('Admin API 非 2xx → 抛带状态码的错(不吞)', async () => { + const fetchImpl: typeof fetch = async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith('/token')) return new Response(JSON.stringify({ access_token: 't', expires_in: 300 }), { status: 200 }); + return new Response('forbidden', { status: 403 }); + }; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'p', secret: 's', fetch: fetchImpl }); + await expect(roster.listUsers()).rejects.toThrow(/403/); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts new file mode 100644 index 0000000..85187f3 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts @@ -0,0 +1,121 @@ +/** + * Keycloak 是外界,替身只能在外界:这里用本地密钥对充当它的 JWKS(`createLocalJWKSet`), + * 验签逻辑本身一行不假 —— 与生产走的是同一个 `jwtVerify`。 + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { SignJWT, UnsecuredJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'; +import type { JWK, JWTVerifyGetKey } from 'jose'; +import { TokenRejectedError } from '../contract.ts'; +import { createKeycloakTokenVerifier } from '../internal/adapter/keycloak-token-verifier.ts'; +import type { TokenVerifier } from '../port.ts'; + +const ISSUER = 'https://keycloak.test/realms/corp'; +const AUDIENCE = 'api-gateway'; + +let privateKey: Awaited>['privateKey']; +let getKey: JWTVerifyGetKey; +let verifier: TokenVerifier; + +beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + const jwk: JWK = { ...(await exportJWK(pair.publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }; + getKey = createLocalJWKSet({ keys: [jwk] }); + verifier = createKeycloakTokenVerifier({ issuer: ISSUER, audience: AUDIENCE, getKey }); +}); + +type Mint = { + sub?: string; + issuer?: string; + audience?: string | string[]; + /** 秒级 unix 时刻;缺省 5 分钟后 */ + exp?: number; + kid?: string; + claims?: Record; +}; + +const nowSec = (): number => Math.floor(Date.now() / 1000); + +async function mint(o: Mint = {}): Promise { + const jwt = new SignJWT({ + preferred_username: 'alice', + name: '爱丽丝', + email: 'alice@akhr-ai.com', + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...o.claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: o.kid ?? 'k1' }) + .setSubject(o.sub ?? 'sub-alice') + .setIssuer(o.issuer ?? ISSUER) + .setAudience(o.audience ?? [AUDIENCE, 'account']) + .setIssuedAt() + .setExpirationTime(o.exp ?? nowSec() + 300); + return jwt.sign(privateKey); +} + +describe('Keycloak 验签', () => { + it('RS256 · iss · aud 都对 → 返回 claims', async () => { + const claims = await verifier.verify(await mint()); + expect(claims.sub).toBe('sub-alice'); + expect(claims.preferred_username).toBe('alice'); + expect(claims.name).toBe('爱丽丝'); + expect(claims.realm_access?.roles).toContain('user'); + }); + + it('过期 30 秒内(时钟容差)仍通过', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined(); + }); + + it('过期超过容差 → 拒,话是「已过期」', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 120 }))).rejects.toThrow(/已过期/); + }); + + it('aud 不含本服务 → 拒,话点名 audience mapper', async () => { + await expect(verifier.verify(await mint({ audience: ['data-service'] }))).rejects.toThrow(/audience mapper/); + }); + + it('iss 不符 → 拒,话点名 KEYCLOAK_URL', async () => { + await expect(verifier.verify(await mint({ issuer: 'https://other.test/realms/corp' }))).rejects.toThrow(/KEYCLOAK_URL/); + }); + + it('kid 对不上 JWKS → 拒,话点名 JWKS', async () => { + await expect(verifier.verify(await mint({ kid: 'k-unknown' }))).rejects.toThrow(/JWKS/); + }); + + it('HS256 → 拒(算法写死 RS256)', async () => { + const secret = new TextEncoder().encode('0123456789abcdef0123456789abcdef'); + const token = await new SignJWT({ preferred_username: 'alice' }) + .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(secret); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('alg: none → 拒', async () => { + const token = new UnsecuredJWT({ preferred_username: 'alice' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .encode(); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('缺 preferred_username → 拒,话点名 client scope', async () => { + const token = await new SignJWT({ realm_access: { roles: ['user'] } }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setSubject('sub-x') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(privateKey); + await expect(verifier.verify(token)).rejects.toThrow(/preferred_username/); + }); + + it('拒回的一律是 TokenRejectedError(中间件据此 401)', async () => { + await expect(verifier.verify('not-a-jwt')).rejects.toBeInstanceOf(TokenRejectedError); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/org-service.test.ts b/apps/api/src/modules/org/__tests__/org-service.test.ts new file mode 100644 index 0000000..3124742 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/org-service.test.ts @@ -0,0 +1,416 @@ +/** + * usecase 测试:仓储全是内存实现(模块内部机制不 mock,只是换个存法), + * 验签是注入的假 verifier(Keycloak 是外界)。 + */ +import { describe, expect, it } from 'vitest'; +import type { Position, RoleAccess, WorkGroup } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccessLockoutError, AccountDisabledError, TokenRejectedError } from '../contract.ts'; +import { createOrgService } from '../internal/usecase/org-service.ts'; +import type { OrgServiceDeps } from '../internal/usecase/org-service.ts'; +import type { RosterSource, StaffRecord, VerifiedClaims } from '../port.ts'; + +type Harness = { + org: ReturnType; + tokens: Map; + staff: Map; + roleAccess: Map; + groups: Map; + positions: Map; + calls: { updateIdentity: number }; + /** 往里放 staffId:那一行的写入就当场失败(模拟单条落库出错) */ + failWrites: Set; + advance(ms: number): void; +}; + +function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness { + const positions = new Map(); + const staff = new Map(); + const roleAccess = new Map(); + const groups = new Map(); + const tokens = new Map(); + const calls = { updateIdentity: 0 }; + const failWrites = new Set(); + let nowMs = Date.parse('2026-08-25T09:00:00Z'); + let seq = 0; + + const deps: OrgServiceDeps = { + verifier: { + verify: async (token) => { + const claims = tokens.get(token); + if (claims === undefined) throw new TokenRejectedError('签名校验失败'); + return claims; + }, + }, + ...(opts.roster === undefined ? {} : { roster: opts.roster }), + positions: { + list: async () => [...positions.values()], + findByCode: async (code) => [...positions.values()].find((p) => p.code === code), + findById: async (id) => positions.get(id), + upsert: async (p) => { + const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase()); + const row: Position = { id, code: p.code, name: p.name, ...(p.note === undefined ? {} : { note: p.note }) }; + positions.set(id, row); + return row; + }, + }, + staff: { + list: async () => [...staff.values()], + findById: async (id) => staff.get(id), + findByAccount: async (account) => [...staff.values()].find((s) => s.account === account), + listByPosition: async (pid) => [...staff.values()].filter((s) => s.positionId === pid), + insert: async (s, marks) => { + if (failWrites.has(s.id)) throw new Error(`写不进去:${s.id}`); + // 库里 `account` 有唯一索引 —— 内存版把这条约束也照搬,不然「同名让位」在测里等于没测 + const clash = [...staff.values()].find((row) => row.account === s.account && row.id !== s.id); + if (clash !== undefined) { + throw new Error('duplicate key value violates unique constraint "staff_account_key"'); + } + const row: StaffRecord = { ...s, ...marks }; + staff.set(s.id, row); + return row; + }, + updateIdentity: async (id, patch) => { + if (failWrites.has(id)) throw new Error(`写不进去:${id}`); + calls.updateIdentity += 1; + const { email: _dropped, ...rest } = staff.get(id)!; + staff.set(id, { + ...rest, + account: patch.account, + name: patch.name, + roles: [...patch.roles], + ...(patch.email === undefined ? {} : { email: patch.email }), + ...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }), + }); + }, + setPosition: async (id, pid) => { + staff.set(id, { ...staff.get(id)!, positionId: pid }); + }, + setState: async (id, state, syncedAt) => { + if (failWrites.has(id)) throw new Error(`写不进去:${id}`); + staff.set(id, { ...staff.get(id)!, state, syncedAt }); + }, + }, + roleAccess: { + list: async () => [...roleAccess.values()], + upsert: async (row) => { + roleAccess.set(row.role, row); + return row; + }, + remove: async (role) => { + roleAccess.delete(role); + }, + }, + workGroups: { + listByOwner: async (owner) => [...groups.values()].filter((g) => g.ownerId === owner), + upsert: async (g) => { + groups.set(g.id, g); + return g; + }, + }, + inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] }, + clock: { now: () => new Date(nowMs) }, + ids: { next: () => `id-${(seq += 1)}` }, + testAccounts: opts.testAccounts ?? [], + }; + + return { + org: createOrgService(deps), + tokens, + staff, + roleAccess, + groups, + positions, + calls, + failWrites, + advance: (ms) => { + nowMs += ms; + }, + }; +} + +const claims = (over: Partial & { sub: string; preferred_username: string }): VerifiedClaims => ({ + name: over.preferred_username, + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...over, +}); + +describe('authenticate —— 验签 → 名册即时更新 → 角色→端', () => { + it('首次见到:建档在 NONE 岗、active、角色滤了噪声、端按缺省表', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t-alice', claims({ sub: 'sub-alice', preferred_username: 'alice', name: '爱丽丝', email: 'a@x.test' })); + const p = await h.org.authenticate('t-alice'); + expect(p.staff.id).toBe('sub-alice'); + expect(p.staff.account).toBe('alice'); + expect(p.staff.name).toBe('爱丽丝'); + expect(p.staff.email).toBe('a@x.test'); + expect(p.staff.state).toBe('active'); + expect(p.position.code).toBe('NONE'); + expect(p.roles).toEqual(['user']); + expect(p.ends).toEqual(['workbench']); + }); + + it('admin → 三端', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'root', realm_access: { roles: ['admin'] } })); + expect((await h.org.authenticate('t')).ends).toEqual(['design', 'ops', 'workbench']); + }); + + it('坏 token → TokenRejectedError 原样抛出', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await expect(h.org.authenticate('nope')).rejects.toBeInstanceOf(TokenRejectedError); + }); + + it('60 秒内再来且没变 → 不重写;改了名 → 写回并返回新名', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃' })); + await h.org.authenticate('t'); + h.advance(10_000); + await h.org.authenticate('t'); + expect(h.calls.updateIdentity).toBe(0); + h.advance(61_000); + await h.org.authenticate('t'); + expect(h.calls.updateIdentity).toBe(1); // 过了 60s 只为刷 last_seen_at 写一次 + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃·新' })); + const p = await h.org.authenticate('t'); + expect(p.staff.name).toBe('鲍勃·新'); + expect(h.calls.updateIdentity).toBe(2); + }); + + it('名册标了停用 → 即使 token 有效也抛 AccountDisabledError;principalOf 同款', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' })); + await h.org.authenticate('t'); + h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' }); + await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError); + await expect(h.org.principalOf(staffIdSchema.parse('s'))).rejects.toBeInstanceOf(AccountDisabledError); + expect(await h.org.principalOf(staffIdSchema.parse('ghost'))).toBeUndefined(); + }); + + it('停用的账号攥着有效 token 也驱动不了 JIT 写 —— 停用判定走在建档之前', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' })); + await h.org.authenticate('t'); + h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' }); + const before = h.calls.updateIdentity; + h.advance(600_000); // 过了 60s 的重写窗:从前这一下就会写一次 last_seen_at + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '改了名也不许写' })); + await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError); + expect(h.calls.updateIdentity).toBe(before); + expect(h.staff.get('s')?.name).toBe('bob'); + }); + + it('同名让位:Keycloak 里同名重建(新 sub)—— 旧行让开账号,新人进得来', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'old-sub-1234567', account: 'alice', name: '旧爱丽丝', roles: ['user'] }); + h.tokens.set('t', claims({ sub: 'new-sub', preferred_username: 'alice', name: '新爱丽丝' })); + const p = await h.org.authenticate('t'); + expect(p.staff.id).toBe('new-sub'); + expect(p.staff.account).toBe('alice'); + expect(h.staff.get('old-sub-1234567')?.account).toBe('alice#retired-old-sub-'); + expect(h.staff.size).toBe(2); + }); + + it('测试账号:首次建档即 TEST 岗 + 独立工作组(五岗指向本人)', async () => { + const h = harness({ testAccounts: ['service-account-bpos-e2e'] }); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 'sub-ai', preferred_username: 'service-account-bpos-e2e', realm_access: { roles: ['admin'] } })); + const p = await h.org.authenticate('t'); + expect(p.position.code).toBe('TEST'); + const group = await h.org.workGroupOf(staffIdSchema.parse('sub-ai')); + expect(group?.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); + +describe('名册与岗位', () => { + it('staffOfPosition 只回 active;NONE 恒回 []', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] }); + const b = await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: [] }); + const bd = (await h.org.listStaff()).positions.find((p) => p.code === 'BD')!; + await h.org.setPosition(a.id, bd.id); + await h.org.setPosition(b.id, bd.id); + h.staff.set('b', { ...h.staff.get('b')!, state: 'disabled' }); + expect((await h.org.staffOfPosition('BD')).map((s) => s.id)).toEqual(['a']); + expect(await h.org.staffOfPosition('NONE')).toEqual([]); + }); + + it('syncRoster 未配置 → configured:false', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const outcome = await h.org.syncRoster(); + expect(outcome.configured).toBe(false); + }); + + it('syncRoster:新增 / 改名 / 远端停用 / 本地有远端无 → 停;远端启用 → 复活', async () => { + const remote: RosterSource = { + listUsers: async () => [ + { sub: 'a', account: 'a', name: '阿', roles: ['user'], enabled: true }, + { sub: 'b', account: 'b', name: 'B', roles: ['user'], enabled: false }, + { sub: 'c', account: 'c', name: 'C', roles: ['admin'], enabled: true }, + ], + }; + const h = harness({ roster: remote }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'z', account: 'z', name: 'Z', roles: ['user'] }); + h.staff.set('c', { ...h.staff.get('a')!, id: staffIdSchema.parse('c'), account: 'c', name: 'C', roles: ['admin'], state: 'disabled' }); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3, failed: [] }); + expect(h.staff.get('a')?.name).toBe('阿'); + expect(h.staff.get('b')?.state).toBe('disabled'); + expect(h.staff.get('z')?.state).toBe('disabled'); + expect(h.staff.get('c')?.state).toBe('active'); + }); + + it('一次同步会把最后一个能进运营中心的人也停了 → 整体拒绝,一条 setState 都没落', async () => { + // 远端只回了一个人(realm 配错 / view-users 被改窄的典型形状):admin 那位「缺席」被判停用 + const remote: RosterSource = { + listUsers: async () => [{ sub: 'u', account: 'u', name: 'U', roles: ['user'], enabled: true }], + }; + const h = harness({ roster: remote }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] }); + await expect(h.org.syncRoster()).rejects.toBeInstanceOf(AccessLockoutError); + expect(h.staff.get('root')?.state).toBe('active'); + expect(h.staff.get('root')?.syncedAt).toBeUndefined(); // 连碰都没碰过 + }); + + it('远端整个空着 → 一个人都不停(取数出问题不是全员离职)', async () => { + const h = harness({ roster: { listUsers: async () => [] } }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] }); + expect(await h.org.syncRoster()).toEqual({ configured: true, inserted: 0, updated: 0, disabled: 0, total: 0, failed: [] }); + expect(h.staff.get('root')?.state).toBe('active'); + expect(h.staff.get('u')?.state).toBe('active'); + }); + + it('服务账号不在 Admin 的用户列表里 —— 缺席不停它', async () => { + const remote: RosterSource = { + listUsers: async () => [{ sub: 'root', account: 'root', name: 'R', roles: ['admin'], enabled: true }], + }; + const h = harness({ roster: remote, testAccounts: ['service-account-bpos-e2e'] }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'ai', account: 'service-account-bpos-e2e', name: 'AI', roles: ['admin'] }); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 0, updated: 0, disabled: 0, total: 1, failed: [] }); + expect(h.staff.get('ai')?.state).toBe('active'); + }); + + it('单条落库失败:不停在半路,计数只数真落下的,失败的人点名', async () => { + const remote: RosterSource = { + listUsers: async () => [ + { sub: 'root', account: 'root', name: 'R', roles: ['admin'], enabled: true }, + { sub: 'u1', account: 'u1', name: '改了名', roles: ['user'], enabled: true }, + ], + }; + const h = harness({ roster: remote }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'u1', account: 'u1', name: 'U1', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'u2', account: 'u2', name: 'U2', roles: ['user'] }); + h.failWrites.add('u1'); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ + configured: true, + inserted: 0, + updated: 0, + disabled: 1, + total: 2, + failed: [{ account: 'u1', reason: '写不进去:u1' }], + }); + expect(h.staff.get('u1')?.name).toBe('U1'); // 没改成 + expect(h.staff.get('u2')?.state).toBe('disabled'); // 后面那条照办 —— 不停在半路 + }); + + it('syncRoster 插入的新人也走测试账号策略,且远端停用的直接落 disabled', async () => { + const remote: RosterSource = { + listUsers: async () => [{ sub: 'ai', account: 'ai', name: 'AI', roles: ['admin'], enabled: true }, { sub: 'x', account: 'x', name: 'X', roles: [], enabled: false }], + }; + const h = harness({ roster: remote, testAccounts: ['AI'] }); + await h.org.ensureSeeded(); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2, failed: [] }); + expect((await h.org.getStaff(staffIdSchema.parse('ai'))).position.code).toBe('TEST'); + expect(h.staff.get('x')?.state).toBe('disabled'); + }); +}); + +describe('角色 → 端', () => { + it('listRoleAccess:knownRoles = 名册上见过的 ∪ 已配置的', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['auditor'] }); + const { roles, knownRoles } = await h.org.listRoleAccess(); + expect(roles.map((r) => r.role).sort()).toEqual(['admin', 'user']); + expect(knownRoles).toEqual(['admin', 'auditor', 'user']); + }); + + it('setRoleAccess 归一端的顺序;唯一 admin 摘 ops → 锁死拒;换到别的在册角色 → 过', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'lead', account: 'lead', name: 'L', roles: ['ops-lead'] }); + expect((await h.org.setRoleAccess('designer', ['workbench', 'design'])).ends).toEqual(['design', 'workbench']); + await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).rejects.toBeInstanceOf(AccessLockoutError); + await h.org.setRoleAccess('ops-lead', ['ops']); + await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).resolves.toBeDefined(); + await expect(h.org.deleteRoleAccess('ops-lead')).rejects.toBeInstanceOf(AccessLockoutError); + }); + + it('staffWithEnd:能进该端的活跃员工', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] }); + expect((await h.org.staffWithEnd('ops')).map((s) => s.id)).toEqual(['root']); + expect((await h.org.staffWithEnd('workbench')).map((s) => s.id).sort()).toEqual(['root', 'u']); + }); +}); + +describe('ensureSeeded —— 幂等,不覆盖人工改动', () => { + it('岗位含 NONE 与 TEST;缺省两行只在表空时播;改过的行不被重播', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const codes = (await h.org.listStaff()).positions.map((p) => p.code).sort(); + expect(codes).toEqual(['BD', 'FAE', 'NONE', 'OP', 'PM', 'PRM', 'TEST']); + expect([...h.roleAccess.keys()].sort()).toEqual(['admin', 'user']); + h.roleAccess.set('user', { role: 'user', ends: [] }); + await h.org.ensureSeeded(); + expect(h.roleAccess.get('user')?.ends).toEqual([]); + expect(h.positions.size).toBe(7); + }); + + it('播种时给已在册的测试账号补策略', async () => { + const h = harness({ testAccounts: ['chaihao'] }); + await h.org.ensureSeeded(); + const none = (await h.org.listStaff()).positions.find((p) => p.code === 'NONE')!; + h.staff.set('ch', { id: staffIdSchema.parse('ch'), account: 'Chaihao', name: '柴浩', positionId: none.id, state: 'active', roles: [] }); + await h.org.ensureSeeded(); + expect((await h.org.getStaff(staffIdSchema.parse('ch'))).position.code).toBe('TEST'); + expect((await h.org.workGroupOf(staffIdSchema.parse('ch')))?.id).toBe('wg-solo-chaihao'); + }); +}); + +describe('工作组(ADR-011 原样)', () => { + it('建组 / 取组 / 绑定快照', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] }); + const g = await h.org.saveWorkGroup({ name: 'A 组', ownerId: a.id, members: { BD: a.id } }); + expect((await h.org.workGroupOf(a.id))?.id).toBe(g.id); + expect(await h.org.bindingOf(a.id)).toEqual({ BD: 'a' }); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/password.test.ts b/apps/api/src/modules/org/__tests__/password.test.ts deleted file mode 100644 index b8a66b6..0000000 --- a/apps/api/src/modules/org/__tests__/password.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 口令散列(ADR-010)—— 守的是「明文不可逆、比对不早退、坏串不抛错」。 - */ -import { describe, expect, it } from 'vitest'; -import { hashPassword, newSessionToken, verifyPassword } from '../internal/adapter/password.ts'; - -describe('hashPassword / verifyPassword', () => { - it('散列串自描述参数,且不含明文', async () => { - const hash = await hashPassword('jcai-dev-2026'); - expect(hash.split('$')).toHaveLength(6); - expect(hash.startsWith('scrypt$16384$8$1$')).toBe(true); - expect(hash).not.toContain('jcai-dev-2026'); - }); - - it('同一口令两次散列不同 —— 盐是随机的', async () => { - expect(await hashPassword('same')).not.toBe(await hashPassword('same')); - }); - - it('对的口令过,错的不过', async () => { - const hash = await hashPassword('correct horse'); - expect(await verifyPassword('correct horse', hash)).toBe(true); - expect(await verifyPassword('correct hors', hash)).toBe(false); - expect(await verifyPassword('', hash)).toBe(false); - }); - - it('未设口令(null / undefined)一律不过,不抛错', async () => { - // 「账号存在但没口令」不能变成一条能登进去的缝 - expect(await verifyPassword('anything', null)).toBe(false); - expect(await verifyPassword('anything', undefined)).toBe(false); - }); - - it('坏串一律不过,不抛错 —— 登录失败的原因不外泄', async () => { - for (const bad of ['', 'plaintext', 'scrypt$x$8$1$aa$bb', 'bcrypt$16384$8$1$aa$bb', 'scrypt$16384$8$1$aa']) { - expect(await verifyPassword('anything', bad)).toBe(false); - } - }); - - it('按记录自己的参数重算 —— 日后调参不必迁移旧记录', async () => { - // 用一组非默认参数造串,再以默认参数的实现去验:能过才说明读的是串里的参数 - const legacy = 'scrypt$1024$8$1$c2FsdHNhbHQ$'; - expect(legacy.split('$')[1]).toBe('1024'); - // 空 hash 段应判假(下面单独覆盖),这里只确认解析不炸 - expect(await verifyPassword('x', legacy)).toBe(false); - }); -}); - -describe('newSessionToken', () => { - it('不透明、够长、每次不同 —— 不编码任何身份信息', () => { - const a = newSessionToken(); - const b = newSessionToken(); - expect(a).not.toBe(b); - // 32 字节 base64url ≈ 43 字符 - expect(a.length).toBeGreaterThanOrEqual(43); - expect(a).toMatch(/^[A-Za-z0-9_-]+$/); - }); -}); diff --git a/apps/api/src/modules/org/__tests__/roster-diff.test.ts b/apps/api/src/modules/org/__tests__/roster-diff.test.ts new file mode 100644 index 0000000..03efa13 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/roster-diff.test.ts @@ -0,0 +1,116 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { activeRolesAfter, diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts'; +import type { RosterUser } from '../port.ts'; + +const none = positionIdSchema.parse('none'); +const local = (sub: string, over: Partial = {}): Staff => ({ + id: staffIdSchema.parse(sub), + account: sub, + name: sub, + positionId: none, + state: 'active', + roles: ['user'], + ...over, +}); +const remote = (sub: string, over: Partial = {}): RosterUser => ({ + sub, + account: sub, + name: sub, + roles: ['user'], + enabled: true, + ...over, +}); + +describe('diffRoster —— 远端说了算', () => { + it('远端有本地无 → 插', () => { + const d = diffRoster([remote('a')], [], []); + expect(d.insert.map((u) => u.sub)).toEqual(['a']); + expect(d.update).toEqual([]); + expect(d.setState).toEqual([]); + }); + it('改名 / 改角色 → 改;没变的不出现', () => { + const d = diffRoster([remote('a', { name: '阿尔法', roles: ['admin', 'user'] }), remote('b')], [local('a'), local('b')], []); + expect(d.update).toEqual([{ id: 'a', patch: { account: 'a', name: '阿尔法', roles: ['admin', 'user'] } }]); + expect(d.setState).toEqual([]); + }); + it('远端 enabled=false → 停;本地已停不重复', () => { + const d = diffRoster( + [remote('a', { enabled: false }), remote('b', { enabled: false })], + [local('a'), local('b', { state: 'disabled' })], + [], + ); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('本地有远端无(Keycloak 删了人)→ 停', () => { + const d = diffRoster([remote('c')], [local('a'), local('b', { state: 'disabled' }), local('c')], []); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('远端又启用了 → 复活', () => { + const d = diffRoster([remote('a')], [local('a', { state: 'disabled' })], []); + expect(d.setState).toEqual([{ id: 'a', state: 'active' }]); + }); +}); + +describe('缺席即停用的两道下限保护', () => { + it('远端一个人都没有 → 一个都不停(那更像取数出了问题,不像全员离职)', () => { + const d = diffRoster([], [local('a'), local('b')], []); + expect(d.setState).toEqual([]); + expect(d.insert).toEqual([]); + expect(d.update).toEqual([]); + }); + it('远端说停的照停 —— 下限保护只挡「缺席」那一支', () => { + // b 在远端也在(只是没被停),所以这里只该停 a —— 两支各管各的 + const d = diffRoster([remote('a', { enabled: false }), remote('b')], [local('a'), local('b')], []); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('测试账号缺席不算离职(Admin 的用户列表本来就不含服务账号)', () => { + const d = diffRoster( + [remote('x')], + [local('ai', { account: 'service-account-bpos-e2e' }), local('b'), local('x')], + ['service-account-bpos-e2e'], + ); + expect(d.setState).toEqual([{ id: 'b', state: 'disabled' }]); + }); + it('测试账号认大小写与空白 —— 与 isTestAccount 同一把尺', () => { + const d = diffRoster([remote('x')], [local('ai', { account: 'AI-Runner' }), local('x')], [' ai-runner ']); + expect(d.setState).toEqual([]); + }); +}); + +describe('activeRolesAfter —— 同步落库之后还活跃的人', () => { + it('停用的不算、复活的算、改了角色的按新角色算、新插的按 enabled 算', () => { + const localRows = [ + local('root', { roles: ['admin'] }), + local('u1'), + local('u2', { state: 'disabled' }), + ]; + const diff = diffRoster( + [ + remote('u1', { enabled: false }), + remote('u2'), + remote('root', { roles: ['user'] }), + remote('new', { roles: ['ops-lead'] }), + remote('newoff', { roles: ['admin'], enabled: false }), + ], + localRows, + [], + ); + expect(activeRolesAfter(localRows, diff).map((r) => r.join('+')).sort()).toEqual(['ops-lead', 'user', 'user']); + }); +}); + +describe('identityChanged / sameRoles', () => { + it('邮箱从无到有算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'], email: 'a@x' })).toBe(true); + }); + it('一模一样不算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'] })).toBe(false); + }); + it('角色顺序不同不算变', () => { + expect(sameRoles(['a', 'b'], ['b', 'a'])).toBe(true); + expect(sameRoles(['a'], ['a', 'b'])).toBe(false); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/test-account.test.ts b/apps/api/src/modules/org/__tests__/test-account.test.ts new file mode 100644 index 0000000..5494c5e --- /dev/null +++ b/apps/api/src/modules/org/__tests__/test-account.test.ts @@ -0,0 +1,20 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { staffIdSchema } from '@contracts'; +import { isTestAccount, soloGroupOf } from '../internal/domain/test-account.ts'; + +describe('测试账号策略(ADR-011 语义原样)', () => { + it('用户名比对不分大小写、忽略首尾空白;空串永远不算', () => { + expect(isTestAccount('AI', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('Chaihao', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('fengbing', ['ai'])).toBe(false); + expect(isTestAccount('', [''])).toBe(false); + }); + it('独立工作组:五个业务岗全指向本人,id 由用户名铸', () => { + const g = soloGroupOf({ id: staffIdSchema.parse('sub-ai'), account: 'service-account-bpos-e2e', name: 'AI' }); + expect(g.id).toBe('wg-solo-service-account-bpos-e2e'); + expect(g.ownerId).toBe('sub-ai'); + expect(g.name).toBe('AI 独立测试组'); + expect(g.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); diff --git a/apps/api/src/modules/org/contract.ts b/apps/api/src/modules/org/contract.ts index 6f66e0e..e17e299 100644 --- a/apps/api/src/modules/org/contract.ts +++ b/apps/api/src/modules/org/contract.ts @@ -1,17 +1,39 @@ /** * module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。 + * + * 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。 + * 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。 + * 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。 */ -import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts'; +import type { + InFlightWork, + Position, + PositionId, + Principal, + RoleAccess, + RosterSyncOutcome, + Staff, + StaffId, + Surface, + WorkGroup, + WorkGroupBinding, +} from '@contracts'; -export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding }; +export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position }; -export type CreateStaffInput = { +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ readonly account: string; + /** name claim,缺则回退 account */ readonly name: string; - readonly positionId: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; }; export class StaffNotFoundError extends Error { @@ -28,52 +50,64 @@ export class PositionNotFoundError extends Error { } } -export class AccountTakenError extends Error { +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export class TokenRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenRejectedError'; + } +} + +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export class AccountDisabledError extends Error { constructor(readonly account: string) { - super(`账号已存在:${account}`); - this.name = 'AccountTakenError'; + super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`); + this.name = 'AccountDisabledError'; } } -/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */ -export class DisableBlockedError extends Error { - constructor(readonly check: DisableCheck) { - super(`员工停用前置检查未过:在途任务 ${check.openTasks.length} 件、待答卡 ${check.openCards.length} 张`); - this.name = 'DisableBlockedError'; +/** + * 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。 + * 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。 + * `message` 可覆盖:拒绝的理由不同,给人的下一步也不同。 + */ +export class AccessLockoutError extends Error { + constructor(message = '改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝') { + super(message); + this.name = 'AccessLockoutError'; } } -/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */ -export type IssuedSession = { - readonly token: string; - readonly expiresAt: string; -}; - export type OrgService = { + /* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */ + + /** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */ + authenticate(token: string): Promise; + /** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */ + principalOf(staffId: StaffId): Promise; + /** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */ + registerIdentity(identity: Identity): Promise; + + /* ── 名册与岗位 ───────────────────────────────────────────────────── */ + listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>; getStaff(staffId: StaffId): Promise; - findByAccount(account: string): Promise; - createStaff(input: CreateStaffInput): Promise; - /** 停用前置检查 —— 只读,治理台先看再点 */ - checkDisable(staffId: StaffId): Promise; - /** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */ - disableStaff(staffId: StaffId): Promise; - listGrants(positionId: string): Promise; - setGrants(positionId: string, grants: readonly Grant[]): Promise; - /** 岗位权限校验 —— app 层鉴权中间件读它 */ - authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise; - loadSession(account: string): Promise; + /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */ + staffOfPosition(positionCode: string): Promise; + setPosition(staffId: StaffId, positionId: PositionId): Promise; + /** Admin API 同步;未配 provisioning 回 configured:false */ + syncRoster(): Promise; + /** 此人身上还挂着什么(只读;OS 02 改派先于停用) */ + inFlightOf(staffId: StaffId): Promise; - /* ── 登录会话(ADR-010)—— 中间件在 app 层,身份判定读这里 ───────────── */ + /* ── 角色 → 端 ───────────────────────────────────────────────────── */ - /** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */ - login(account: string, password: string): Promise; - /** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */ - logout(token: string): Promise; - /** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */ - resolveSession(token: string): Promise; - /** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */ - setPassword(staffId: StaffId, password: string): Promise; + listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>; + /** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */ + setRoleAccess(role: string, ends: readonly Surface[]): Promise; + deleteRoleAccess(role: string): Promise; + /** 能进该端的活跃员工 —— 治理台通知的收件人 */ + staffWithEnd(surface: Surface): Promise; /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ @@ -89,7 +123,6 @@ export type OrgService = { /** 装进工作流的快照:改组不改已起的单 */ bindingOf(staffId: StaffId): Promise; - /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */ - staffOfPosition(positionCode: string): Promise; + /** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */ ensureSeeded(): Promise; }; diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index 84afd7e..f920fb0 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -2,52 +2,59 @@ * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ export type { - CreateStaffInput, - DisableCheck, - Grant, - GrantAction, - GrantObject, - IssuedSession, + Identity, + InFlightWork, OrgService, Position, - Session, + Principal, + RoleAccess, + RosterSyncOutcome, Staff, StaffWithPosition, + Surface, WorkGroup, WorkGroupBinding, } from './contract.ts'; export { - AccountTakenError, - DisableBlockedError, + AccessLockoutError, + AccountDisabledError, PositionNotFoundError, StaffNotFoundError, + TokenRejectedError, } from './contract.ts'; export type { Clock, - GrantRepository, IdGenerator, + IdentityPatch, InFlightWorkProbe, - PasswordHasher, PositionRepository, - SessionRepository, + RoleAccessRepository, + RosterSource, + RosterUser, + StaffRecord, StaffRepository, + TokenVerifier, + VerifiedClaims, WorkGroupRepository, } from './port.ts'; export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; export { createOrgService } from './internal/usecase/org-service.ts'; -export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts'; -export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts'; +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; export { - createGrantRepository, createPositionRepository, - createSessionRepository, + createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, - objectKeyOf, } from './internal/adapter/org-repository.ts'; -export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts'; -export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; +export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts new file mode 100644 index 0000000..bffb86b --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts @@ -0,0 +1,97 @@ +/** + * 名册来源:Keycloak Admin API(手册 §11 / §14;ADR-012)。 + * + * 用 master realm 的 provisioning 服务账号(client_credentials)取管理 token,只读: + * GET /admin/realms/{realm}/users?first&max=100(列表默认 max=100,必须分页) + * GET /users/{id}/role-mappings/realm/composite(实际生效的 realm 角色) + * 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。 + */ +import type { RosterSource, RosterUser } from '../../port.ts'; +import { normalizeRoles } from '../domain/claims.ts'; + +export type KeycloakRosterOptions = { + readonly url: string; + readonly realm: string; + readonly clientId: string; + readonly secret: string; + /** 测试注入;缺省全局 fetch */ + readonly fetch?: typeof fetch; + /** 毫秒时刻;测试注入 */ + readonly now?: () => number; +}; + +type KcUser = { + readonly id: string; + readonly username: string; + readonly enabled: boolean; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; +type KcRole = { readonly name: string }; + +const PAGE = 100; + +export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSource { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? ((): number => Date.now()); + const base = options.url.replace(/\/+$/, ''); + let cached: { token: string; expiresAt: number } | undefined; + + async function adminToken(): Promise { + if (cached !== undefined && now() < cached.expiresAt) return cached.token; + const resp = await fetchImpl(`${base}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: options.clientId, + client_secret: options.secret, + }), + }); + if (!resp.ok) throw new Error(`取 Keycloak 管理 token 失败:${resp.status} ${await resp.text()}`); + const data = (await resp.json()) as { access_token: string; expires_in: number }; + // 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调 + cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 }; + return cached.token; + } + + async function adminGet(path: string): Promise { + const token = await adminToken(); + const resp = await fetchImpl(`${base}/admin/realms/${options.realm}${path}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`Admin API GET ${path} 失败:${resp.status} ${await resp.text()}`); + return (await resp.json()) as T; + } + + return { + async listUsers(): Promise { + const users: KcUser[] = []; + for (let first = 0; ; first += PAGE) { + const page = await adminGet(`/users?first=${first}&max=${PAGE}&briefRepresentation=false`); + users.push(...page); + if (page.length < PAGE) break; + } + const out: RosterUser[] = []; + for (const u of users) { + const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); + const name = [u.firstName, u.lastName] + .filter((s): s is string => s !== undefined && s.trim() !== '') + .join(' ') + .trim(); + out.push({ + sub: u.id, + account: u.username, + name: name === '' ? u.username : name, + // 与 token 那条路同一把尺 —— 归一自己再写一遍,两处理解一分叉, + // `identityChanged` 的逐字比对就会天天判「变了」(或天天判「没变」) + roles: normalizeRoles(roles.map((r) => r.name)), + enabled: u.enabled, + ...(u.email === undefined || u.email === '' ? {} : { email: u.email }), + }); + } + return out; + }, + }; +} diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts new file mode 100644 index 0000000..06c6795 --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts @@ -0,0 +1,96 @@ +/** + * Keycloak 验签(手册 §4;ADR-012)—— 本地计算,不联网(公钥由 getKey 缓存)。 + * + * 四项校验,顺序即安全要点:`algorithms: ['RS256']` 写死(绝不读 header 的 alg)、 + * `issuer`、`audience`(本服务 clientId)、`clockTolerance: 30`。 + * jose 的错误翻成人话(手册 4.4)后包成 TokenRejectedError —— 排错时能省很多时间。 + */ +import { jwtVerify, errors as joseErrors } from 'jose'; +import type { JWTVerifyGetKey } from 'jose'; +import { z } from 'zod'; +import { TokenRejectedError } from '../../contract.ts'; +import type { TokenVerifier, VerifiedClaims } from '../../port.ts'; + +export type KeycloakVerifierOptions = { + readonly issuer: string; + readonly audience: string; + /** app 给 `createRemoteJWKSet(certsUrl, {...})`;测试给 `createLocalJWKSet(jwks)` */ + readonly getKey: JWTVerifyGetKey; + /** 秒;缺省 30 */ + readonly clockTolerance?: number; +}; + +const claimsSchema = z.object({ + sub: z.string().min(1), + preferred_username: z.string().min(1), + name: z.string().optional(), + email: z.string().optional(), + realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), +}); + +/** + * zod 的 `.optional()` 推出的类型是「键必在、值可为 undefined」, + * 而本仓 `exactOptionalPropertyTypes: true` 下 `VerifiedClaims` 的可选字段要求 + * 「键可不在,但若在则不是 undefined」—— 两边对不上,故显式收窄 + * (与同目录 `claims.ts` 的 `identityOf` 同一手法:undefined 就不带这个键)。 + */ +function toVerifiedClaims(claims: z.infer): VerifiedClaims { + return { + sub: claims.sub, + preferred_username: claims.preferred_username, + ...(claims.name === undefined ? {} : { name: claims.name }), + ...(claims.email === undefined ? {} : { email: claims.email }), + ...(claims.realm_access === undefined + ? {} + : { realm_access: claims.realm_access.roles === undefined ? {} : { roles: claims.realm_access.roles } }), + }; +} + +/** + * jose 的错误 → 人话(手册 4.4)。 + * 两者都直接继承自 `JOSEError`:类型声明上 `JWTExpired implements JWTClaimValidationFailed`, + * 但运行时**没有**继承关系(`implements` 只是结构化类型)。这里判在前不是因为父子, + * 而是要让「已过期」这条更具体的话先命中 —— 顺序是给人看的,不是给 instanceof 兜底的。 + */ +export function explainJoseError(error: unknown, ctx: { readonly issuer: string; readonly audience: string }): string { + if (error instanceof joseErrors.JWTExpired) return 'token 已过期,用 refresh_token 换个新的'; + if (error instanceof joseErrors.JWTClaimValidationFailed) { + if (error.claim === 'aud') { + return `token 的 aud 不含 "${ctx.audience}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; + } + if (error.claim === 'iss') return `token 的 iss 与 "${ctx.issuer}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + return `token 的 ${error.claim} 校验未过`; + } + if (error instanceof joseErrors.JWKSNoMatchingKey) { + return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; + } + if (error instanceof joseErrors.JWSSignatureVerificationFailed) { + return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; + } + if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'token 的签名算法不被接受 —— 只认 RS256'; + if (error instanceof joseErrors.JWSInvalid || error instanceof joseErrors.JWTInvalid) return 'token 格式不合法'; + return error instanceof Error ? error.message : String(error); +} + +export function createKeycloakTokenVerifier(options: KeycloakVerifierOptions): TokenVerifier { + return { + async verify(token: string): Promise { + let payload: unknown; + try { + ({ payload } = await jwtVerify(token, options.getKey, { + algorithms: ['RS256'], + issuer: options.issuer, + audience: options.audience, + clockTolerance: options.clockTolerance ?? 30, + })); + } catch (error) { + throw new TokenRejectedError(explainJoseError(error, options)); + } + const parsed = claimsSchema.safeParse(payload); + if (!parsed.success) { + throw new TokenRejectedError('token 缺少 sub 或 preferred_username —— 检查 Keycloak 的 client scope 是否带 profile'); + } + return toVerifiedClaims(parsed.data); + }, + }; +} diff --git a/apps/api/src/modules/org/internal/adapter/org-repository.ts b/apps/api/src/modules/org/internal/adapter/org-repository.ts index a84dc88..72df653 100644 --- a/apps/api/src/modules/org/internal/adapter/org-repository.ts +++ b/apps/api/src/modules/org/internal/adapter/org-repository.ts @@ -1,24 +1,18 @@ /** * org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。 */ -import { and, eq, gt } from 'drizzle-orm'; -import type { Grant, GrantObject, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts'; -import { grantSchema, positionIdSchema, positionSchema, staffIdSchema, staffSchema, workGroupSchema } from '@contracts'; +import { eq } from 'drizzle-orm'; +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import { SURFACES, positionIdSchema, positionSchema, staffSchema, workGroupSchema } from '@contracts'; import type { Db } from '@shared/db.ts'; -import type { GrantRepository, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository } from '../../port.ts'; -import { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './org-schema.ts'; - -/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */ -export function objectKeyOf(object: GrantObject): string { - switch (object.kind) { - case 'data': - return `data:${object.domain}.${object.object}${object.field === undefined ? '' : `.${object.field}`}`; - case 'governance': - return `governance:${object.function}`; - case 'task': - return `task${object.activityId === undefined ? '' : `:${object.activityId}`}`; - } -} +import type { + PositionRepository, + RoleAccessRepository, + StaffRecord, + StaffRepository, + WorkGroupRepository, +} from '../../port.ts'; +import { positionTable, roleAccessTable, staffTable, workGroupTable } from './org-schema.ts'; export function createPositionRepository(db: Db): PositionRepository { const toPosition = (row: typeof positionTable.$inferSelect): Position => @@ -59,109 +53,104 @@ export function createPositionRepository(db: Db): PositionRepository { } export function createStaffRepository(db: Db): StaffRepository { - const toStaff = (row: typeof staffTable.$inferSelect): Staff => - staffSchema.parse({ + const toRecord = (row: typeof staffTable.$inferSelect): StaffRecord => ({ + ...staffSchema.parse({ id: row.id, account: row.account, name: row.name, + ...(row.email === null ? {} : { email: row.email }), positionId: row.positionId, state: row.state, - }); + roles: row.roles, + }), + ...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }), + ...(row.syncedAt === null ? {} : { syncedAt: row.syncedAt }), + }); return { async list() { const rows = await db.select().from(staffTable).orderBy(staffTable.account); - return rows.map(toStaff); + return rows.map(toRecord); }, async findById(id: StaffId) { const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1); - return rows[0] === undefined ? undefined : toStaff(rows[0]); + return rows[0] === undefined ? undefined : toRecord(rows[0]); }, async findByAccount(account: string) { const rows = await db.select().from(staffTable).where(eq(staffTable.account, account)).limit(1); - return rows[0] === undefined ? undefined : toStaff(rows[0]); + return rows[0] === undefined ? undefined : toRecord(rows[0]); }, async listByPosition(positionId: PositionId) { const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId)); - return rows.map(toStaff); + return rows.map(toRecord); }, - async insert(staff: Staff) { - const [row] = await db.insert(staffTable).values(staff).returning(); + async insert(staff: Staff, marks) { + const [row] = await db + .insert(staffTable) + .values({ + id: staff.id, + account: staff.account, + name: staff.name, + email: staff.email ?? null, + positionId: staff.positionId, + state: staff.state, + roles: [...staff.roles], + lastSeenAt: marks.lastSeenAt ?? null, + syncedAt: marks.syncedAt ?? null, + }) + .returning(); if (row === undefined) throw new Error('员工写入未返回行'); - return toStaff(row); + return toRecord(row); }, - async setState(id: StaffId, state) { - await db.update(staffTable).set({ state }).where(eq(staffTable.id, id)); + async updateIdentity(id: StaffId, patch) { + await db + .update(staffTable) + .set({ + account: patch.account, + name: patch.name, + email: patch.email ?? null, + roles: [...patch.roles], + ...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }), + }) + .where(eq(staffTable.id, id)); }, - async passwordHashOf(account: string) { - const rows = await db - .select({ hash: staffTable.passwordHash }) - .from(staffTable) - .where(eq(staffTable.account, account)) - .limit(1); - return rows[0]?.hash ?? undefined; + async setPosition(id: StaffId, positionId: PositionId) { + await db.update(staffTable).set({ positionId }).where(eq(staffTable.id, id)); }, - async setPasswordHash(id: StaffId, hash: string) { - await db.update(staffTable).set({ passwordHash: hash }).where(eq(staffTable.id, id)); + async setState(id: StaffId, state, syncedAt: Date) { + await db.update(staffTable).set({ state, syncedAt }).where(eq(staffTable.id, id)); }, }; } -/** 会话仓储 —— 过期由 `expires_at` 判定,读时顺带滑动续期(ADR-010) */ -export function createSessionRepository(db: Db): SessionRepository { - return { - async issue({ token, staffId, expiresAt }) { - await db.insert(sessionTable).values({ id: token, staffId, expiresAt }); - }, - async touch(token: string, now: Date, expiresAt: Date) { - const rows = await db - .update(sessionTable) - .set({ lastSeenAt: now, expiresAt }) - .where(and(eq(sessionTable.id, token), gt(sessionTable.expiresAt, now))) - .returning({ staffId: sessionTable.staffId }); - const row = rows[0]; - return row === undefined ? undefined : { staffId: staffIdSchema.parse(row.staffId) }; - }, - async revoke(token: string) { - await db.delete(sessionTable).where(eq(sessionTable.id, token)); - }, - async revokeAllOf(staffId: StaffId) { - await db.delete(sessionTable).where(eq(sessionTable.staffId, staffId)); - }, - }; -} - -export function createGrantRepository(db: Db): GrantRepository { - const toGrant = (row: typeof grantTable.$inferSelect): Grant => - grantSchema.parse({ positionId: row.positionId, object: row.object, action: row.action }); +/** 角色 → 端:契约里是端名数组,表里是三列布尔 —— 这里来回翻译 */ +export function createRoleAccessRepository(db: Db): RoleAccessRepository { + const toRow = (r: typeof roleAccessTable.$inferSelect): RoleAccess => ({ + role: r.role, + ends: SURFACES.filter((s) => r[s]), + }); return { - async listByPosition(positionId: PositionId) { - const rows = await db - .select() - .from(grantTable) - .where(and(eq(grantTable.positionId, positionId), eq(grantTable.enabled, true))); - return rows.map(toGrant); + async list() { + const rows = await db.select().from(roleAccessTable).orderBy(roleAccessTable.role); + return rows.map(toRow); }, - async replaceForPosition(positionId: PositionId, grants) { - return db.transaction(async (tx) => { - await tx.delete(grantTable).where(eq(grantTable.positionId, positionId)); - if (grants.length === 0) return []; - const rows = await tx - .insert(grantTable) - .values( - grants.map((g) => ({ - positionId, - objectKey: objectKeyOf(g.object), - action: g.action, - object: g.object, - enabled: true, - })), - ) - .onConflictDoNothing() - .returning(); - return rows.map(toGrant); - }); + async upsert(row: RoleAccess) { + const flags = { + design: row.ends.includes('design'), + ops: row.ends.includes('ops'), + workbench: row.ends.includes('workbench'), + }; + const [saved] = await db + .insert(roleAccessTable) + .values({ role: row.role, ...flags }) + .onConflictDoUpdate({ target: roleAccessTable.role, set: { ...flags, updatedAt: new Date() } }) + .returning(); + if (saved === undefined) throw new Error('角色→端写入未返回行'); + return toRow(saved); + }, + async remove(role: string) { + await db.delete(roleAccessTable).where(eq(roleAccessTable.role, role)); }, }; } diff --git a/apps/api/src/modules/org/internal/adapter/org-schema.ts b/apps/api/src/modules/org/internal/adapter/org-schema.ts index 5937fcc..2678055 100644 --- a/apps/api/src/modules/org/internal/adapter/org-schema.ts +++ b/apps/api/src/modules/org/internal/adapter/org-schema.ts @@ -1,8 +1,9 @@ /** * org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。 * drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。 + * 会话表与岗位授权表已随 ADR-012 退役(迁移 0032 drop)。 */ -import { boolean, index, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; import { sys } from '@shared/db.ts'; export const positionTable = sys.table( @@ -17,55 +18,36 @@ export const positionTable = sys.table( (t) => [uniqueIndex('position_code_key').on(t.code)], ); +/** 员工 —— `id` 就是 Keycloak 的 sub(ADR-012);`account` 只作显示 */ export const staffTable = sys.table( 'staff', { id: text('id').primaryKey(), account: text('account').notNull(), name: text('name').notNull(), + email: text('email'), positionId: text('position_id') .notNull() .references(() => positionTable.id), + /** 只由名册同步置 disabled;有效 token 不改它 */ state: text('state').notNull().default('active'), - /** 口令散列(ADR-010):`scrypt$N$r$p$salt$hash`,明文永不落库 */ - passwordHash: text('password_hash'), + /** realm 角色快照(签发时;噪声角色已滤) */ + roles: jsonb('roles').$type().notNull().default([]), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + syncedAt: timestamp('synced_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)], ); -/** - * 会话(ADR-010)—— 服务端表而非签名 cookie:**登出必须立刻失效**, - * 且停用员工时要能一并踢掉在途会话。id 即 token,不透明随机串。 - */ -export const sessionTable = sys.table( - 'session', - { - id: text('id').primaryKey(), - staffId: text('staff_id') - .notNull() - .references(() => staffTable.id, { onDelete: 'cascade' }), - issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [index('session_staff_idx').on(t.staffId), index('session_expires_idx').on(t.expiresAt)], -); - -export const grantTable = sys.table( - 'position_grant', - { - positionId: text('position_id') - .notNull() - .references(() => positionTable.id, { onDelete: 'cascade' }), - /** 客体的规范化串:data:<域>.<对象>[.<字段>] / governance:<功能> / task[:<活动>] */ - objectKey: text('object_key').notNull(), - action: text('action').notNull(), - object: jsonb('object').notNull(), - enabled: boolean('enabled').notNull().default(true), - }, - (t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })], -); +/** 角色 → 端(ADR-012):一行一个 realm 角色,三列布尔 —— 运营中心可改 */ +export const roleAccessTable = sys.table('role_access', { + role: text('role').primaryKey(), + design: boolean('design').notNull().default(false), + ops: boolean('ops').notNull().default(false), + workbench: boolean('workbench').notNull().default(false), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); /** * 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。 diff --git a/apps/api/src/modules/org/internal/adapter/password.ts b/apps/api/src/modules/org/internal/adapter/password.ts deleted file mode 100644 index 10a190f..0000000 --- a/apps/api/src/modules/org/internal/adapter/password.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。 - * - * 串格式自描述:`scrypt$N$r$p$$`。 - * 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。 - * 明文永不落库,比对走 `timingSafeEqual`(等长常数时间,不因前缀相同而早退)。 - */ -import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; - -/** 默认代价参数:N=16384 约 16MB 内存、单次 ~50ms —— 内网 ≤50 人足够 */ -const N = 16_384; -const R = 8; -const P = 1; -const KEY_LEN = 32; -const SALT_LEN = 16; - -const b64 = (buf: Buffer) => buf.toString('base64url'); - -function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise { - return new Promise((resolve, reject) => { - // maxmem 需随 N 抬高:默认 32MB 挡不住 N=16384(约需 128*N*r ≈ 16MB,留两倍余量) - scrypt(password, salt, KEY_LEN, { N: n, r, p, maxmem: 256 * n * r }, (error, key) => { - if (error !== null) reject(error); - else resolve(key); - }); - }); -} - -export async function hashPassword(password: string): Promise { - const salt = randomBytes(SALT_LEN); - const hash = await derive(password, salt, N, R, P); - return `scrypt$${N}$${R}$${P}$${b64(salt)}$${b64(hash)}`; -} - -/** - * 比对。存的串不合格式 / 员工尚未设口令 → 一律 false,不抛错: - * 登录失败的原因不外泄(账号不存在、未设口令、口令错,对外都是同一句话)。 - */ -export async function verifyPassword(password: string, stored: string | null | undefined): Promise { - if (stored === null || stored === undefined) return false; - const parts = stored.split('$'); - if (parts.length !== 6 || parts[0] !== 'scrypt') return false; - - const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts; - const n = Number(nRaw); - const r = Number(rRaw); - const p = Number(pRaw); - if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false; - - const expected = Buffer.from(hashRaw ?? '', 'base64url'); - if (expected.length === 0) return false; - - const actual = await derive(password, Buffer.from(saltRaw ?? '', 'base64url'), n, r, p).catch(() => undefined); - if (actual === undefined || actual.length !== expected.length) return false; - return timingSafeEqual(actual, expected); -} - -/** 会话 token —— 32 字节密码学随机,不透明(不编码任何身份信息) */ -export function newSessionToken(): string { - return b64(randomBytes(32)); -} diff --git a/apps/api/src/modules/org/internal/domain/authorization.ts b/apps/api/src/modules/org/internal/domain/authorization.ts deleted file mode 100644 index 98b744c..0000000 --- a/apps/api/src/modules/org/internal/domain/authorization.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 岗位授权判定 —— 纯逻辑,零 IO(编码规范 §5.3)。 - * - * 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。 - * 语义出处:OS 02 员工管理。 - */ -import type { Grant, GrantAction, GrantObject } from '@contracts'; - -/** 动作蕴含:治理 ⊃ 写 ⊃ 读 —— 有写权即可读,有治理权即可写 */ -const IMPLIES: Readonly> = { - govern: ['govern', 'write', 'read'], - write: ['write', 'read'], - read: ['read'], -}; - -export function actionCovers(granted: GrantAction, required: GrantAction): boolean { - return IMPLIES[granted].includes(required); -} - -/** - * 客体覆盖:授权客体是否覆盖被请求客体。 - * 数据客体分三级:域.对象.字段 —— 授到域即覆盖其下全部对象,授到对象即覆盖其下全部字段。 - */ -export function objectCovers(granted: GrantObject, required: GrantObject): boolean { - if (granted.kind !== required.kind) return false; - - switch (granted.kind) { - case 'data': { - const req = required as Extract; - if (granted.domain !== req.domain) return false; - // 授权到域级(未指定对象名的通配 '*') - if (granted.object !== '*' && granted.object !== req.object) return false; - if (granted.field === undefined) return true; - return granted.field === req.field; - } - case 'governance': { - const req = required as Extract; - return granted.function === req.function; - } - case 'task': { - const req = required as Extract; - // 未限定活动 = 覆盖全部任务 - if (granted.activityId === undefined) return true; - return granted.activityId === req.activityId; - } - } -} - -/** 任一授权覆盖即通过 */ -export function isAuthorized( - grants: readonly Grant[], - object: GrantObject, - action: GrantAction, -): boolean { - return grants.some((g) => actionCovers(g.action, action) && objectCovers(g.object, object)); -} diff --git a/apps/api/src/modules/org/internal/domain/claims.ts b/apps/api/src/modules/org/internal/domain/claims.ts new file mode 100644 index 0000000..0015c65 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/claims.ts @@ -0,0 +1,33 @@ +/** + * token claims → 一个人(纯函数,零 IO;ADR-012)。 + * + * 只认手册 §17 列出的那几个 claim;`name` 缺了回退用户名(服务账号常常没有 name)。 + * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: + * `default-roles-` / `offline_access` / `uma_authorization`。 + */ +import type { Identity } from '../../contract.ts'; +import type { VerifiedClaims } from '../../port.ts'; + +const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); + +export function isNoiseRole(role: string): boolean { + return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); +} + +/** 去重、滤噪声、排序 —— 角色快照要能逐字比对(「变了没有」靠它) */ +export function normalizeRoles(roles: readonly string[]): string[] { + return [...new Set(roles.filter((r) => r.trim() !== '' && !isNoiseRole(r)))].sort(); +} + +export function identityOf(claims: VerifiedClaims): Identity { + const account = claims.preferred_username; + const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); + return { + sub: claims.sub, + account, + name, + roles: normalizeRoles(claims.realm_access?.roles ?? []), + ...(email === undefined ? {} : { email }), + }; +} diff --git a/apps/api/src/modules/org/internal/domain/ends.ts b/apps/api/src/modules/org/internal/domain/ends.ts new file mode 100644 index 0000000..58726ca --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/ends.ts @@ -0,0 +1,38 @@ +/** + * 角色 → 端(纯函数,零 IO;ADR-012)。 + * + * 一个人的端 = 他的角色命中的每一行的并集。表可以空、角色可以一行都不命中 —— 那就是「无端可进」。 + * **锁死不变量**:改完之后至少一名在册活跃员工仍能进运营中心,否则这张表再也没人改得了。 + */ +import type { RoleAccess, Surface } from '@contracts'; +import { SURFACES } from '@contracts'; +import { AccessLockoutError } from '../../contract.ts'; + +export function endsOf(roles: readonly string[], table: readonly RoleAccess[]): Surface[] { + const hit = new Set(); + for (const row of table) { + if (!roles.includes(row.role)) continue; + for (const end of row.ends) hit.add(end); + } + return SURFACES.filter((s) => hit.has(s)); +} + +/** + * 名册空着(全新安装)不算锁死 —— 那时缺省表还在,第一个人进来就能改。 + * 两个调用点各说各的话:改「角色 → 端」说的是这张表,同步名册说的是这一次同步 —— + * 拒绝的理由不同,`message` 就得不同(话说不对,人不知道下一步该查什么)。 + */ +export function assertNotLockedOut( + table: readonly RoleAccess[], + activeStaffRoles: ReadonlyArray, + message?: string, +): void { + if (activeStaffRoles.length === 0) return; + const someoneKeepsOps = activeStaffRoles.some((roles) => endsOf(roles, table).includes('ops')); + if (!someoneKeepsOps) throw new AccessLockoutError(message); +} + +/** 一行存进表里的样子只有一种:去重、按 SURFACES 的顺序 */ +export function normalizeEnds(ends: readonly Surface[]): Surface[] { + return SURFACES.filter((s) => ends.includes(s)); +} diff --git a/apps/api/src/modules/org/internal/domain/disable-check.ts b/apps/api/src/modules/org/internal/domain/in-flight.ts similarity index 58% rename from apps/api/src/modules/org/internal/domain/disable-check.ts rename to apps/api/src/modules/org/internal/domain/in-flight.ts index cc39c19..8635aa3 100644 --- a/apps/api/src/modules/org/internal/domain/disable-check.ts +++ b/apps/api/src/modules/org/internal/domain/in-flight.ts @@ -1,17 +1,16 @@ /** - * 停用前置检查 —— 纯逻辑,零 IO(编码规范 §5.3)。 - * - * OS 02:员工停用前,其在途任务与待答卡须先改派完毕。 + * 在途工作(纯逻辑,零 IO;OS 02:员工停用前,其在途任务与待答卡须先改派完毕)。 + * 停用本身已归鉴权中心(ADR-012),这里只剩「摆给人看」:此人身上还挂着什么。 */ -import type { DisableCheck, StaffId } from '@contracts'; +import type { InFlightWork, StaffId } from '@contracts'; -export type InFlightWork = { +export type InFlightInput = { readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>; readonly openCards: ReadonlyArray<{ cardId: string; title: string }>; readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>; }; -export function buildDisableCheck(staffId: StaffId, work: InFlightWork): DisableCheck { +export function buildInFlightWork(staffId: StaffId, work: InFlightInput): InFlightWork { const openTasks = [...work.openTasks]; const openCards = [...work.openCards]; const openApprovals = [...work.openApprovals]; diff --git a/apps/api/src/modules/org/internal/domain/roster-diff.ts b/apps/api/src/modules/org/internal/domain/roster-diff.ts new file mode 100644 index 0000000..d7d55bc --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/roster-diff.ts @@ -0,0 +1,105 @@ +/** + * 名册同步的差异(纯函数,零 IO;ADR-012)。 + * + * 远端(Keycloak Admin API)说了算: + * 不在本地 → 插;字段变了 → 改;远端 enabled=false 或**根本不在远端** → 停;远端又启用 → 复活。 + * 没变的不出现在结果里 —— 同步一次不该把全表重写一遍。 + * + * 「缺席即停用」那一支有两道下限保护:远端整个空着不据此停任何人;服务账号缺席不算离职 + * (Admin 的用户列表本来就不含它们)。理由写在函数里。 + */ +import type { Staff, StaffId } from '@contracts'; +import type { Identity } from '../../contract.ts'; +import type { IdentityPatch, RosterUser } from '../../port.ts'; +import { isTestAccount } from './test-account.ts'; + +export type RosterDiff = { + readonly insert: RosterUser[]; + readonly update: Array<{ readonly id: StaffId; readonly patch: IdentityPatch }>; + readonly setState: Array<{ readonly id: StaffId; readonly state: Staff['state'] }>; +}; + +export function sameRoles(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + return sa.every((x, i) => x === sb[i]); +} + +/** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */ +export function identityChanged(staff: Staff, identity: Identity): boolean { + return ( + staff.account !== identity.account || + staff.name !== identity.name || + staff.email !== identity.email || + !sameRoles(staff.roles, identity.roles) + ); +} + +export function patchOf(identity: Identity): IdentityPatch { + return { + account: identity.account, + name: identity.name, + roles: [...identity.roles], + ...(identity.email === undefined ? {} : { email: identity.email }), + }; +} + +/** + * @param testAccounts 服务账号的用户名(`JCAI_TEST_ACCOUNTS`)—— **必填,调用方得回答得出来**: + * 「缺席即停用」这一支要绕开它们,而只有装配层知道哪几个是服务账号。 + */ +export function diffRoster( + remote: readonly RosterUser[], + local: readonly Staff[], + testAccounts: readonly string[], +): RosterDiff { + const bySub = new Map(local.map((s) => [s.id, s])); + const diff: RosterDiff = { insert: [], update: [], setState: [] }; + const seen = new Set(); + for (const user of remote) { + seen.add(user.sub); + const existing = bySub.get(user.sub); + if (existing === undefined) { + diff.insert.push(user); + continue; + } + if (identityChanged(existing, user)) diff.update.push({ id: existing.id, patch: patchOf(user) }); + const wanted: Staff['state'] = user.enabled ? 'active' : 'disabled'; + if (existing.state !== wanted) diff.setState.push({ id: existing.id, state: wanted }); + } + /* + * 「本地有、远端无 → 停」是有意的(Keycloak 里删了人,这边就该停),但它是一刀切人的动作, + * 下面两道下限保护缺一不可 —— 全员 disabled 之后,恢复动作(同步按钮要 ops)自己就点不动了。 + */ + // ① 远端一个人都没有:更像是取数出了问题(realm 配错、代理 200 空体、view-users 被改窄), + // 不像是全员离职。一个人都不据此停。 + if (remote.length === 0) return diff; + for (const s of local) { + if (seen.has(s.id) || s.state === 'disabled') continue; + // ② 服务账号(e2e / 试跑)是首登 JIT 建的,而 Admin 的 `GET /users` **不返回服务账号用户** + // (手册 §14.5 为此单列一条接口)—— 它的「缺席」是名单的形状,不是这个人不在了。 + if (isTestAccount(s.account, testAccounts)) continue; + diff.setState.push({ id: s.id, state: 'disabled' }); + } + return diff; +} + +/** + * 这次同步落库之后,还活跃的人各自的角色 —— 喂给锁死校验(`assertNotLockedOut`)。 + * 一次同步能停很多人;要是把最后一个能进运营中心的人也停了,那张表与同步按钮就都没人点得动了, + * 只剩去库里手改 `state`。所以要**在落 setState 之前**先按差异算一遍结果。 + */ +export function activeRolesAfter(local: readonly Staff[], diff: RosterDiff): string[][] { + const state = new Map(local.map((s) => [s.id, s.state])); + const roles = new Map(local.map((s) => [s.id, s.roles])); + for (const u of diff.update) roles.set(u.id, u.patch.roles); + for (const s of diff.setState) state.set(s.id, s.state); + for (const user of diff.insert) { + state.set(user.sub, user.enabled ? 'active' : 'disabled'); + roles.set(user.sub, user.roles); + } + const out: string[][] = []; + for (const [id, st] of state) if (st === 'active') out.push([...(roles.get(id) ?? [])]); + return out; +} diff --git a/apps/api/src/modules/org/internal/domain/seed-data.ts b/apps/api/src/modules/org/internal/domain/seed-data.ts index b610e52..5b8ff1b 100644 --- a/apps/api/src/modules/org/internal/domain/seed-data.ts +++ b/apps/api/src/modules/org/internal/domain/seed-data.ts @@ -1,7 +1,12 @@ /** - * 种子数据(README《种子数据》)—— 纯常量,零 IO。 + * 种子数据 —— 纯常量,零 IO。 + * 人不再播种(名册来自鉴权中心,ADR-012);这里只有岗位与缺省的角色→端。 * 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。 */ +import type { RoleAccess } from '@contracts'; + +export const NONE_POSITION_CODE = 'NONE'; +export const TEST_POSITION_CODE = 'TEST'; export const POSITION_SEED = [ { code: 'PM', name: '项目经理', note: '定价审批、策略拍板' }, @@ -14,78 +19,21 @@ export const POSITION_SEED = [ * 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 —— * 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。 */ - { code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' }, + { code: TEST_POSITION_CODE, name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' }, + /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */ + { code: NONE_POSITION_CODE, name: '未分配', note: '未指派岗位;流程包不得声明它' }, ] as const; -export const STAFF_SEED = [ - { account: 'dingziqiao', name: '丁子乔', position: 'PM' }, - { account: 'fengbing', name: '冯兵', position: 'BD' }, - { account: 'wangziming', name: '王梓名', position: 'FAE' }, - { account: 'linpeixuan', name: '林佩萱', position: 'PRM' }, - { account: 'liyuanchu', name: '李元初', position: 'OP' }, - // 两个测试账号:各自一个「全岗位指向自己」的工作组,起单即整条流归自己 - { account: 'AI', name: 'AI', position: 'TEST' }, - { account: 'Chaihao', name: '柴浩', position: 'TEST' }, -] as const; - -/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */ -export const DEFAULT_LOGIN_ACCOUNT = 'fengbing'; - export type PositionSeed = (typeof POSITION_SEED)[number]; -export type StaffSeed = (typeof STAFF_SEED)[number]; /** - * 岗位默认授权 —— 治理台九功能对 PM 全开,其余岗位只开与其职责相关的读。 - * 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。 + * 缺省的角色 → 端(表为空时播,之后由运营中心改): + * 与手册两个前端的口径一致 —— 用户端要 `user`、管理端要 `admin`。 */ -export const POSITION_GRANT_SEED: Readonly>> = { - /** 测试岗与 PM 同权:一个人要走完整条流,还得在治理台看过程 */ - TEST: [ - { fn: 'task', action: 'govern' }, - { fn: 'staff', action: 'govern' }, - { fn: 'process', action: 'govern' }, - { fn: 'trigger', action: 'govern' }, - { fn: 'activity', action: 'govern' }, - { fn: 'tool', action: 'govern' }, - { fn: 'data', action: 'govern' }, - { fn: 'memory', action: 'govern' }, - { fn: 'system', action: 'govern' }, - ], - PM: [ - { fn: 'task', action: 'govern' }, - { fn: 'staff', action: 'govern' }, - { fn: 'process', action: 'govern' }, - { fn: 'trigger', action: 'govern' }, - { fn: 'activity', action: 'govern' }, - { fn: 'tool', action: 'govern' }, - { fn: 'data', action: 'govern' }, - { fn: 'memory', action: 'govern' }, - { fn: 'system', action: 'govern' }, - ], - BD: [ - { fn: 'task', action: 'read' }, - { fn: 'data', action: 'read' }, - ], - FAE: [ - { fn: 'task', action: 'read' }, - { fn: 'data', action: 'read' }, - ], - PRM: [ - { fn: 'task', action: 'read' }, - { fn: 'data', action: 'read' }, - ], - OP: [ - { fn: 'task', action: 'read' }, - { fn: 'data', action: 'read' }, - { fn: 'memory', action: 'read' }, - ], -}; +export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; -/** - * 测试账号的**独立工作组**(ADR-011)—— 所有岗位都指向本人。 - * 起单即整条流(A1…A7 + 审批点)全部落到自己工位,一个人从头走到尾。 - */ -export const SOLO_GROUP_ACCOUNTS = ['AI', 'Chaihao'] as const; - -/** 组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */ +/** 独立测试组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */ export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const; diff --git a/apps/api/src/modules/org/internal/domain/test-account.ts b/apps/api/src/modules/org/internal/domain/test-account.ts new file mode 100644 index 0000000..d26cd55 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/test-account.ts @@ -0,0 +1,22 @@ +/** + * 测试账号策略(纯函数,零 IO;ADR-011 语义原样搬到 ADR-012 的名册上)。 + * + * 试跑与 e2e 用的账号要能一个人走完整条流:落 TEST 岗(不混进业务岗,免得轮值把真单派给它), + * 再建一个「所有业务岗都指向自己」的独立工作组。 + */ +import type { Staff, WorkGroup } from '@contracts'; +import { SOLO_GROUP_POSITIONS } from './seed-data.ts'; + +export function isTestAccount(account: string, testAccounts: readonly string[]): boolean { + const needle = account.trim().toLowerCase(); + return needle !== '' && testAccounts.some((a) => a.trim().toLowerCase() === needle); +} + +export function soloGroupOf(staff: Pick): WorkGroup { + return { + id: `wg-solo-${staff.account.toLowerCase()}`, + name: `${staff.name} 独立测试组`, + ownerId: staff.id, + members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])), + }; +} diff --git a/apps/api/src/modules/org/internal/usecase/org-service.ts b/apps/api/src/modules/org/internal/usecase/org-service.ts index 75f89f2..248b531 100644 --- a/apps/api/src/modules/org/internal/usecase/org-service.ts +++ b/apps/api/src/modules/org/internal/usecase/org-service.ts @@ -1,51 +1,57 @@ /** * org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。 + * + * 身份归统一鉴权中心(ADR-012): + * authenticate = 验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × 角色→端表 → Principal。 + * `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 —— + * 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。 */ -import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, Staff, StaffId } from '@contracts'; -import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts'; -import type { - CreateStaffInput, - IssuedSession, - OrgService, - StaffWithPosition, -} from '../../contract.ts'; -import { - AccountTakenError, - DisableBlockedError, - PositionNotFoundError, - StaffNotFoundError, -} from '../../contract.ts'; +import type { Position, Principal, RoleAccess, Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts'; +import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts'; import type { Clock, - GrantRepository, IdGenerator, InFlightWorkProbe, - PasswordHasher, PositionRepository, - SessionRepository, + RoleAccessRepository, + RosterSource, + StaffRecord, StaffRepository, + TokenVerifier, WorkGroupRepository, } from '../../port.ts'; -import { isAuthorized } from '../domain/authorization.ts'; -import { buildDisableCheck } from '../domain/disable-check.ts'; -import { POSITION_GRANT_SEED, POSITION_SEED, SOLO_GROUP_ACCOUNTS, SOLO_GROUP_POSITIONS, STAFF_SEED } from '../domain/seed-data.ts'; +import { identityOf } from '../domain/claims.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts'; +import { buildInFlightWork } from '../domain/in-flight.ts'; +import { activeRolesAfter, diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts'; +import { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from '../domain/seed-data.ts'; +import { isTestAccount, soloGroupOf } from '../domain/test-account.ts'; export type OrgServiceDeps = { + readonly verifier: TokenVerifier; + /** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */ + readonly roster?: RosterSource; readonly positions: PositionRepository; readonly staff: StaffRepository; - readonly grants: GrantRepository; - readonly sessions: SessionRepository; + readonly roleAccess: RoleAccessRepository; readonly workGroups: WorkGroupRepository; - readonly passwords: PasswordHasher; readonly inFlight: InFlightWorkProbe; readonly clock: Clock; readonly ids: IdGenerator; - /** 种子口令 —— 由 app 从环境读入,源码不写任何真实口令(ADR-010) */ - readonly seedPassword: string; + /** 测试账号(Keycloak 用户名):首次建档与播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; }; -/** 会话有效期 12 小时,每次命中滑动续期 */ -const SESSION_TTL_MS = 12 * 60 * 60 * 1000; +/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */ +const LAST_SEEN_REWRITE_MS = 60_000; + +/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */ +function toStaff(record: StaffRecord): Staff { + const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record; + return staff; +} export function createOrgService(deps: OrgServiceDeps): OrgService { async function positionMap(): Promise> { @@ -60,141 +66,253 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { return { ...staff, position }; } - async function grantsOfStaff(staffId: StaffId): Promise { - const staff = await deps.staff.findById(staffId); - if (!staff) throw new StaffNotFoundError(staffId); - return deps.grants.listByPosition(staff.positionId); + async function positionByCode(code: string): Promise { + const position = await deps.positions.findByCode(code); + if (!position) throw new PositionNotFoundError(code); + return position; } - return { + async function principalFor(record: StaffRecord): Promise { + if (record.state !== 'active') throw new AccountDisabledError(record.account); + const staff = toStaff(record); + const position = await deps.positions.findById(staff.positionId); + if (!position) throw new PositionNotFoundError(staff.positionId); + const table = await deps.roleAccess.list(); + return { staff, position, roles: [...staff.roles], ends: endsOf(staff.roles, table) }; + } + + /** 测试账号策略(ADR-011):TEST 岗 + 独立工作组;不是测试账号就什么都不做 */ + async function applyTestAccountPolicy(staff: Staff): Promise { + if (!isTestAccount(staff.account, deps.testAccounts)) return; + const none = await positionByCode(NONE_POSITION_CODE); + if (staff.positionId === none.id) { + const test = await positionByCode(TEST_POSITION_CODE); + await deps.staff.setPosition(staff.id, test.id); + } + if ((await deps.workGroups.listByOwner(staff.id)).length === 0) { + await deps.workGroups.upsert(soloGroupOf(staff)); + } + } + + async function activeStaffRoles(): Promise { + return (await deps.staff.list()).filter((s) => s.state === 'active').map((s) => [...s.roles]); + } + + /* + * 同名让位 —— `sys.staff.account` 有唯一索引。Keycloak 里删了人再建一个同名的 + * (或用户名被让渡给别人),新 sub 直插就撞索引;撞了的后果是**这个人一次也登不进来**, + * 拿到的还是一句 PG 原文的 500,同步则停在半路。 + * 走到 insert 就说明按 sub 找不到他 —— 本地那条同名的必然是**另一个 sub 的 stale 行**: + * 把它的账号让开(`账号#retired-<旧 sub 前 8 位>`)再插,并留一条 stderr 说明发生了什么。 + * 不删那一行:它身上可能还挂着在途工作,谁是谁得留得下痕(下一次同步会把它停用)。 + */ + async function retireAccountClash(identity: Identity): Promise { + const clash = await deps.staff.findByAccount(identity.account); + if (clash === undefined || clash.id === identity.sub) return; + const retired = `${clash.account}#retired-${clash.id.slice(0, 8)}`; + await deps.staff.updateIdentity(clash.id, { + account: retired, + name: clash.name, + roles: [...clash.roles], + ...(clash.email === undefined ? {} : { email: clash.email }), + }); + process.stderr.write( + `[org] 账号 ${identity.account} 被新的 sub(${identity.sub})占用 —— ` + + `本地那条 stale 行(sub ${clash.id})的账号让位为 ${retired};` + + '若这是同一个人换了 sub,去运营中心把他身上的在途工作改派过来。\n', + ); + } + + async function insertPerson( + identity: Identity, + state: Staff['state'], + marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }, + ): Promise { + const none = await positionByCode(NONE_POSITION_CODE); + await retireAccountClash(identity); + const created = await deps.staff.insert( + { + id: staffIdSchema.parse(identity.sub), + account: identity.account, + name: identity.name, + ...(identity.email === undefined ? {} : { email: identity.email }), + positionId: none.id, + state, + roles: [...identity.roles], + }, + marks, + ); + await applyTestAccountPolicy(toStaff(created)); + const fresh = await deps.staff.findById(created.id); + return toStaff(fresh ?? created); + } + + const service: OrgService = { + async authenticate(token) { + const claims = await deps.verifier.verify(token); + const identity = identityOf(claims); + const id = staffIdSchema.parse(identity.sub); + /* + * 停用判定走在 JIT 写之前:一个已停用的账号只要还攥着有效 token, + * 每 60 秒就能驱动一次 `updateIdentity` —— 停用的人不该还能写我们的库。 + * 「第一次见到的人要建档」这条不丢:本地查无此人时照建(建出来即 active)。 + */ + const existing = await deps.staff.findById(id); + if (existing !== undefined && existing.state !== 'active') throw new AccountDisabledError(existing.account); + await service.registerIdentity(identity); + const record = await deps.staff.findById(id); + if (record === undefined) throw new StaffNotFoundError(id); + return principalFor(record); + }, + + async principalOf(staffId) { + const record = await deps.staff.findById(staffId); + return record === undefined ? undefined : principalFor(record); + }, + + async registerIdentity(identity) { + const now = deps.clock.now(); + const id = staffIdSchema.parse(identity.sub); + const existing = await deps.staff.findById(id); + if (existing === undefined) return insertPerson(identity, 'active', { lastSeenAt: now }); + + const changed = identityChanged(existing, identity); + const stale = + existing.lastSeenAt === undefined || now.getTime() - existing.lastSeenAt.getTime() >= LAST_SEEN_REWRITE_MS; + if (!changed && !stale) return toStaff(existing); + await deps.staff.updateIdentity(id, { ...patchOf(identity), lastSeenAt: now }); + const fresh = await deps.staff.findById(id); + return toStaff(fresh ?? existing); + }, + async listStaff() { const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]); const map = new Map(positions.map((p) => [p.id, p])); - const staff = await Promise.all(rows.map((s) => withPosition(s, map))); + const staff = await Promise.all(rows.map((s) => withPosition(toStaff(s), map))); return { staff, positions }; }, async getStaff(staffId) { - const staff = await deps.staff.findById(staffId); - if (!staff) throw new StaffNotFoundError(staffId); - return withPosition(staff); + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + return withPosition(toStaff(record)); }, - async findByAccount(account) { - const staff = await deps.staff.findByAccount(account); - if (!staff) return undefined; - return withPosition(staff); + async staffOfPosition(positionCode) { + if (positionCode === NONE_POSITION_CODE) return []; + const position = await positionByCode(positionCode); + const rows = await deps.staff.listByPosition(position.id); + return rows.filter((s) => s.state === 'active').map((s) => ({ ...toStaff(s), position })); }, - async createStaff(input: CreateStaffInput) { - const taken = await deps.staff.findByAccount(input.account); - if (taken) throw new AccountTakenError(input.account); - const positionId = positionIdSchema.parse(input.positionId); + async setPosition(staffId, positionId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); const position = await deps.positions.findById(positionId); - if (!position) throw new PositionNotFoundError(input.positionId); - const created = await deps.staff.insert({ - id: staffIdSchema.parse(deps.ids.next()), - account: input.account, - name: input.name, - positionId, - state: 'active', - }); - // 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品 - await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword)); - return created; + if (!position) throw new PositionNotFoundError(positionId); + await deps.staff.setPosition(staffId, positionId); + return { ...toStaff(record), positionId, position }; }, - async checkDisable(staffId) { - const staff = await deps.staff.findById(staffId); - if (!staff) throw new StaffNotFoundError(staffId); + async syncRoster(): Promise { + if (deps.roster === undefined) { + return { + configured: false, + message: '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档', + }; + } + const remote = await deps.roster.listUsers(); + const local = await deps.staff.list(); + const diff = diffRoster(remote, local, deps.testAccounts); + /* + * 落库之前先算一遍「这一次同步之后还有谁能进运营中心」—— 不过就**整体拒绝**, + * 一条 setState 都不落。恢复动作(同步按钮)本身要 ops:全员停用之后这条路自己把自己锁死, + * 只剩去库里手改 state。宁可这一次不同步,也不能把最后一个能进来的人关在门外。 + */ + assertNotLockedOut( + await deps.roleAccess.list(), + activeRolesAfter(local, diff), + '这次同步会让没有人能进运营中心,已拒绝(一条都没写)—— 先核对 provisioning 指的是不是同一个 realm,' + + '以及那个服务账号还有没有 view-users。', + ); + const now = deps.clock.now(); + const accountOf = new Map(local.map((s) => [s.id, s.account])); + const failed: Array<{ account: string; reason: string }> = []; + // 单条写失败不该让整次同步静默停在半路:记下是谁、原话是什么,接着往下走 + const guard = async (account: string, write: () => Promise): Promise => { + try { + await write(); + return true; + } catch (error) { + failed.push({ account, reason: error instanceof Error ? error.message : String(error) }); + return false; + } + }; + + let inserted = 0; + let updated = 0; + let disabled = 0; + for (const user of diff.insert) { + const ok = await guard(user.account, () => insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now })); + if (ok) inserted += 1; + } + for (const u of diff.update) { + const ok = await guard(u.patch.account, () => deps.staff.updateIdentity(u.id, u.patch)); + if (ok) updated += 1; + } + for (const s of diff.setState) { + const ok = await guard(accountOf.get(s.id) ?? s.id, () => deps.staff.setState(s.id, s.state, now)); + if (ok && s.state === 'disabled') disabled += 1; + } + // 三个计数说的是真落进库里的条数 —— 半途失败的同步不许报成一排绿数字 + return { configured: true, inserted, updated, disabled, total: remote.length, failed }; + }, + + async inFlightOf(staffId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); const [openTasks, openCards, openApprovals] = await Promise.all([ deps.inFlight.openTasksOf(staffId), deps.inFlight.openCardsOf(staffId), deps.inFlight.openApprovalsOf(staffId), ]); - return buildDisableCheck(staffId, { openTasks, openCards, openApprovals }); + return buildInFlightWork(staffId, { openTasks, openCards, openApprovals }); }, - async disableStaff(staffId) { - const check = await this.checkDisable(staffId); - if (check.blocked) throw new DisableBlockedError(check); - await deps.staff.setState(staffId, 'disabled'); - // 停用即踢会话:否则被停用的人凭旧 cookie 还能继续办事(ADR-010) - await deps.sessions.revokeAllOf(staffId); + async listRoleAccess() { + const rows = await deps.roleAccess.list(); + const seen = new Set(rows.map((r) => r.role)); + for (const s of await deps.staff.list()) for (const r of s.roles) seen.add(r); + return { roles: rows, knownRoles: [...seen].sort() }; }, - async listGrants(positionId) { - return deps.grants.listByPosition(positionIdSchema.parse(positionId)); + async setRoleAccess(role, ends) { + const row: RoleAccess = { role, ends: normalizeEnds(ends) }; + const current = await deps.roleAccess.list(); + assertNotLockedOut([...current.filter((r) => r.role !== role), row], await activeStaffRoles()); + return deps.roleAccess.upsert(row); }, - async setGrants(positionId, grants) { - return deps.grants.replaceForPosition(positionIdSchema.parse(positionId), grants); + async deleteRoleAccess(role) { + const current = await deps.roleAccess.list(); + assertNotLockedOut( + current.filter((r) => r.role !== role), + await activeStaffRoles(), + ); + await deps.roleAccess.remove(role); }, - async authorize(staffId: StaffId, object: GrantObject, action: GrantAction) { - const staff = await deps.staff.findById(staffId); - if (!staff || staff.state !== 'active') return false; - const grants = await grantsOfStaff(staffId); - return isAuthorized(grants, object, action); - }, - - async loadSession(account): Promise { - const staff = await deps.staff.findByAccount(account); - if (!staff || staff.state !== 'active') return undefined; - const position = await deps.positions.findById(staff.positionId); - if (!position) throw new PositionNotFoundError(staff.positionId); - const grants = await deps.grants.listByPosition(staff.positionId); - return { staff, position, grants }; - }, - - /** - * 登录 —— 失败一律 undefined,**不区分原因**: - * 账号不存在 / 已停用 / 未设口令 / 口令错,对外都是同一句话,不外泄账号是否存在。 - */ - async login(account, password): Promise { - const staff = await deps.staff.findByAccount(account); - const stored = await deps.staff.passwordHashOf(account); - // 无论账号是否存在都走一遍散列比对:否则响应时间会泄漏账号是否存在 - const ok = await deps.passwords.verify(password, stored); - if (!ok || staff === undefined || staff.state !== 'active') return undefined; - - const token = deps.passwords.newToken(); - const expiresAt = new Date(deps.clock.now().getTime() + SESSION_TTL_MS); - await deps.sessions.issue({ token, staffId: staff.id, expiresAt }); - return { token, expiresAt: expiresAt.toISOString() }; - }, - - async logout(token) { - await deps.sessions.revoke(token); - }, - - async resolveSession(token): Promise { - const now = deps.clock.now(); - const hit = await deps.sessions.touch(token, now, new Date(now.getTime() + SESSION_TTL_MS)); - if (hit === undefined) return undefined; - const staff = await deps.staff.findById(hit.staffId); - // 会话还在但人已停用 —— 立刻作废,不给旧 cookie 留缝 - if (staff === undefined || staff.state !== 'active') { - await deps.sessions.revoke(token); - return undefined; - } - const position = await deps.positions.findById(staff.positionId); - if (!position) throw new PositionNotFoundError(staff.positionId); - const grants = await deps.grants.listByPosition(staff.positionId); - return { staff, position, grants }; - }, - - async setPassword(staffId, password) { - const staff = await deps.staff.findById(staffId); - if (!staff) throw new StaffNotFoundError(staffId); - await deps.staff.setPasswordHash(staffId, await deps.passwords.hash(password)); + async staffWithEnd(surface) { + const table = await deps.roleAccess.list(); + const { staff } = await service.listStaff(); + return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface)); }, /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */ async saveWorkGroup(input) { - const staff = await deps.staff.findById(input.ownerId); - if (!staff) throw new StaffNotFoundError(input.ownerId); + const record = await deps.staff.findById(input.ownerId); + if (!record) throw new StaffNotFoundError(input.ownerId); return deps.workGroups.upsert({ id: input.id ?? deps.ids.next(), name: input.name, @@ -210,83 +328,31 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { }, async bindingOf(staffId) { - const group = await this.workGroupOf(staffId); + const group = await service.workGroupOf(staffId); return group === undefined ? undefined : { ...group.members }; }, - async staffOfPosition(positionCode) { - const position = await deps.positions.findByCode(positionCode); - if (!position) throw new PositionNotFoundError(positionCode); - const rows = await deps.staff.listByPosition(position.id); - return rows.filter((s) => s.state === 'active').map((s) => ({ ...s, position })); - }, - async ensureSeeded() { - const byCode = new Map(); for (const seed of POSITION_SEED) { const existing = await deps.positions.findByCode(seed.code); - const position = existing - ? existing - : await deps.positions.upsert({ - id: positionIdSchema.parse(deps.ids.next()), - code: seed.code, - name: seed.name, - note: seed.note, - }); - byCode.set(seed.code, position.id); - } - - for (const seed of STAFF_SEED) { - const existing = await deps.staff.findByAccount(seed.account); - if (existing) { - // 补种口令:ADR-010 之前建的员工没有 password_hash,不补就永远登不进 - const hash = await deps.staff.passwordHashOf(seed.account); - if (hash === undefined) { - await deps.staff.setPasswordHash(existing.id, await deps.passwords.hash(deps.seedPassword)); - } - continue; - } - const positionId = byCode.get(seed.position); - if (!positionId) throw new PositionNotFoundError(seed.position); - const created = await deps.staff.insert({ - id: staffIdSchema.parse(deps.ids.next()), - account: seed.account, + if (existing !== undefined) continue; + await deps.positions.upsert({ + id: positionIdSchema.parse(deps.ids.next()), + code: seed.code, name: seed.name, - positionId, - state: 'active', - }); - // 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口 - await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword)); - } - - // 测试账号的独立工作组(ADR-011):所有业务岗都指向本人,起单即整条流归自己 - for (const account of SOLO_GROUP_ACCOUNTS) { - const staff = await deps.staff.findByAccount(account); - if (staff === undefined) continue; - const existing = await deps.workGroups.listByOwner(staff.id); - if (existing.length > 0) continue; - await deps.workGroups.upsert({ - id: `wg-solo-${account.toLowerCase()}`, - name: `${staff.name} 独立测试组`, - ownerId: staff.id, - members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])), + note: seed.note, }); } - - for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) { - const positionId = byCode.get(code); - if (!positionId) continue; - const existing = await deps.grants.listByPosition(positionId); - if (existing.length > 0) continue; - await deps.grants.replaceForPosition( - positionId, - entries.map((e) => ({ - positionId, - object: { kind: 'governance' as const, function: governanceFunctionSchema.parse(e.fn) }, - action: e.action, - })), - ); + // 缺省的角色→端只在表空时播 —— 之后是运营中心的事,播种不覆盖人工改动 + if ((await deps.roleAccess.list()).length === 0) { + for (const row of DEFAULT_ROLE_ACCESS) await deps.roleAccess.upsert(row); + } + // 已在册的测试账号补策略(名册先同步后配 JCAI_TEST_ACCOUNTS 的那种顺序) + for (const record of await deps.staff.list()) { + if (isTestAccount(record.account, deps.testAccounts)) await applyTestAccountPolicy(toStaff(record)); } }, }; + + return service; } diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index bcda358..b438108 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -2,10 +2,40 @@ * module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 * adapter 实现之,usecase 依赖之,app 装配之。 */ -import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Identity } from './contract.ts'; export type Clock = { now(): Date }; +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { readonly roles?: readonly string[] }; +}; + +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { verify(token: string): Promise }; + +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { readonly enabled: boolean }; +export type RosterSource = { listUsers(): Promise }; + +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; + +/* ── 仓储 ─────────────────────────────────────────────────────────────── */ + export type PositionRepository = { list(): Promise; findByCode(code: string): Promise; @@ -13,44 +43,29 @@ export type PositionRepository = { upsert(position: Omit & { id?: PositionId }): Promise; }; +/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ +export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; + export type StaffRepository = { - list(): Promise; - findById(id: StaffId): Promise; - findByAccount(account: string): Promise; - listByPosition(positionId: PositionId): Promise; - insert(staff: Staff): Promise; - setState(id: StaffId, state: Staff['state']): Promise; - /** 口令散列的读写 —— 明文不经此端口(ADR-010) */ - passwordHashOf(account: string): Promise; - setPasswordHash(id: StaffId, hash: string): Promise; + list(): Promise; + findById(id: StaffId): Promise; + /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ + findByAccount(account: string): Promise; + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; + updateIdentity(id: StaffId, patch: IdentityPatch): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; + +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; }; /** - * 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。 - * token 即主键,不透明随机串,不编码任何身份信息。 - */ -export type SessionRepository = { - issue(input: { token: string; staffId: StaffId; expiresAt: Date }): Promise; - /** 未过期才返回;命中同时滑动续期 */ - touch(token: string, now: Date, expiresAt: Date): Promise<{ staffId: StaffId } | undefined>; - revoke(token: string): Promise; - revokeAllOf(staffId: StaffId): Promise; -}; - -/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */ -export type PasswordHasher = { - hash(password: string): Promise; - verify(password: string, stored: string | undefined): Promise; - newToken(): string; -}; - -export type GrantRepository = { - listByPosition(positionId: PositionId): Promise; - replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise; -}; - -/** - * 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`, + * 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`, * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 */ export type InFlightWorkProbe = { diff --git a/apps/api/src/modules/task/__tests__/wake.test.ts b/apps/api/src/modules/task/__tests__/wake.test.ts index 296bde5..8a4a9f8 100644 --- a/apps/api/src/modules/task/__tests__/wake.test.ts +++ b/apps/api/src/modules/task/__tests__/wake.test.ts @@ -4,24 +4,26 @@ * 租约、开跑前那一刻才翻;那几秒里对话区与工作区读到的是两份相反的事实。 */ import { describe, expect, it } from 'vitest'; +import type { StaffId, TaskId } from '@contracts'; import type { LandedFacts, Task } from '../contract.ts'; import { createTaskService } from '../internal/usecase/task-service.ts'; import type { TaskServiceDeps } from '../internal/usecase/task-service.ts'; function harness(runtime: Task['runtime'], terminal?: Task['terminal']) { const phases: string[] = []; - const task = { - id: 't-1', + const task: Task = { + id: 't-1' as TaskId, activityId: 'e04', - ownerId: 's-1', + ownerId: 's-1' as StaffId, title: 'x', runtime, - terminal, + ...(terminal === undefined ? {} : { terminal }), + envelope: {}, data: {}, caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 }, used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 }, startedAt: '2026-08-25T00:00:00.000Z', - } as unknown as Task; + }; const tasks = new Map([[task.id, task]]); const deps: TaskServiceDeps = { store: { diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index bfbcc84..d5c2a54 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -5,8 +5,6 @@ export default defineConfig({ main: 'src/app/main.ts', seed: 'src/app/seed.ts', migrate: 'src/app/migrate.ts', - // 改密 CLI(ADR-010):治理台改密界面未做前的运维口子 - 'set-password': 'src/app/set-password.ts', // 清运行数据:开发期反复用 'reset-runs': 'src/app/reset-runs.ts', // 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次 diff --git a/db/migrations/0032_keycloak_identity.sql b/db/migrations/0032_keycloak_identity.sql new file mode 100644 index 0000000..0425682 --- /dev/null +++ b/db/migrations/0032_keycloak_identity.sql @@ -0,0 +1,35 @@ +-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak 的 sub。 +-- +-- 从前(ADR-010)是自建账号口令 + 服务端会话表 + 岗位授权(客体三类 × 动作三种)。 +-- 现在:后端只验 Bearer JWT;权限只到「端」(design / ops / workbench), +-- 角色 → 端是运营中心可改的一张表;名册从 Keycloak 同步、岗位本地指派。 +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.node_owner.staff_id` 是运营配的节点负责人,它排在轮值**之前** —— 留着就是把单派给一个不存在的人; +-- `sys.trigger.confirm_staff_id` 是起单确认人,留下 stale id + confirm_required=true, +-- 正是「命中的单没人点头、全进死信」那个状态(那也是 setTriggerConfirm 不允许人手工造出来的状态)。 +-- 这两张是**配置**表,`reset-runs` 按设计不碰(见 reset-runs.ts 的注释),所以只能在这里收拾。 +delete from sys.node_owner; +update sys.trigger set confirm_required = false, confirm_staff_id = null where confirm_staff_id is not null; diff --git a/db/migrations/0033_mail.sql b/db/migrations/0033_mail.sql new file mode 100644 index 0000000..c38cb40 --- /dev/null +++ b/db/migrations/0033_mail.sql @@ -0,0 +1,92 @@ +-- 邮件收发(ADR-013):账号(配置 + 密封凭证 + 游标 + 跨进程租约)、入站留痕、逐订阅方投递留痕、出站留痕。 +-- +-- 腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)都没有带内容的推送,只能轮询拉: +-- 两个后端连同一个库时,一个信箱同一时刻只许一个进程在拉 —— `polling_until` 与 +-- `sys.task.advancing_until` 同款(一条 SQL 判 + 占,带过期时间,进程崩了不锁死)。 +-- 入站两把唯一键:重复拉到(时钟偏 / UIDVALIDITY 重锚 / wecom 十分钟重叠窗)一律静默跳过。 +-- 入站 / 出站表对账号**不设外键**:账号删了留痕还在。 +create table if not exists 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 '', + 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, + backfill_from timestamptz, + settings jsonb not null default '{}'::jsonb, + secret_cipher text, + cursor jsonb, + last_polled_at timestamptz, + last_poll_error text, + last_inbound_at timestamptz, + polling_until timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create unique index if not exists mail_account_name_key on sys.mail_account (name); +-- 默认发件账号至多一个 —— 用例先把别的置 false,这道索引兜底 +create unique index if not exists mail_account_one_default on sys.mail_account ((true)) where is_default; +create index if not exists mail_account_due_idx on sys.mail_account (enabled, last_polled_at); + +create table if not exists sys.mail_inbound ( + id text primary key, + account_id text not null, + message_id text not null, + provider_ref text not null, + in_reply_to text, + reference_ids jsonb not null default '[]'::jsonb, + from_addr text not null default '', + from_name text not null default '', + to_list jsonb not null default '[]'::jsonb, + cc_list jsonb not null default '[]'::jsonb, + subject text not null default '', + text_body text, + html_body text, + sent_at timestamptz, + received_at timestamptz not null, + attachments jsonb not null default '[]'::jsonb, + raw bytea, + raw_note text not null default '', + size integer not null default 0, + created_at timestamptz not null default now() +); +create unique index if not exists mail_inbound_message_key on sys.mail_inbound (account_id, message_id); +create unique index if not exists mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref); +create index if not exists mail_inbound_time_idx on sys.mail_inbound (received_at desc); + +create table if not exists 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 if not exists sys.mail_outbound ( + id text primary key, + account_id text not null, + message_id text not null, + idempotency_key text, + to_list jsonb not null, + cc_list jsonb not null default '[]'::jsonb, + bcc_list jsonb not null default '[]'::jsonb, + subject text not null, + text_body text, + html_body text, + in_reply_to text, + reference_ids jsonb not null default '[]'::jsonb, + attachments jsonb not null default '[]'::jsonb, + state text not null check (state in ('sending', 'sent', 'failed')), + error text, + provider_ref text, + requested_by jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + sent_at timestamptz +); +create unique index if not exists mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null; +create index if not exists mail_outbound_time_idx on sys.mail_outbound (created_at desc); diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 14fc57d..766b860 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -225,6 +225,20 @@ "when": 1785130120179, "tag": "0031_task_land_master", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1785130120180, + "tag": "0032_keycloak_identity", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1785130120181, + "tag": "0033_mail", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/docs/API文档-邮件收发.md b/docs/API文档-邮件收发.md new file mode 100644 index 0000000..4bac558 --- /dev/null +++ b/docs/API文档-邮件收发.md @@ -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": "", "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": "", + "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=` 用 `
`(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": "", "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; 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; 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}' +``` + +示例响应按契约构造;真实响应待账号配好后用上面的序列换。 diff --git a/docs/API文档-鉴权与人员.md b/docs/API文档-鉴权与人员.md new file mode 100644 index 0000000..95e9b58 --- /dev/null +++ b/docs/API文档-鉴权与人员.md @@ -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 `;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` 与 `` 这两条带不了 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": "", … }, "ownerName" }] }` +- PUT body `{ "ownerId", "name", "members": { "<岗位 code>": "" } }` → `{ "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 }; + +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 " } +``` + +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)。 diff --git a/docs/adr/ADR-010-auth-session.md b/docs/adr/ADR-010-auth-session.md index f2480d8..7eed4bd 100644 --- a/docs/adr/ADR-010-auth-session.md +++ b/docs/adr/ADR-010-auth-session.md @@ -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 后方可动工」。 diff --git a/docs/adr/ADR-012-keycloak-identity.md b/docs/adr/ADR-012-keycloak-identity.md new file mode 100644 index 0000000..a4516da --- /dev/null +++ b/docs/adr/ADR-012-keycloak-identity.md @@ -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` 与 `` 带不了 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`「鉴权」段。 diff --git a/docs/adr/ADR-013-mail-gateway.md b/docs/adr/ADR-013-mail-gateway.md new file mode 100644 index 0000000..7be2928 --- /dev/null +++ b/docs/adr/ADR-013-mail-gateway.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`「邮件收发」段。 diff --git a/docs/debt.md b/docs/debt.md index edf3f60..09d53bf 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -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....`,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/` → 迁移 → 迁移到位断言 → +判首次/升级 → 播种或装活动包 → 回查 → 发前端 → pm2 重启 → 探活 → 收尾。 +部署在 runner 本机(host 模式),路径类配置从 Gitea Variables 读,机密从 Secrets 读。 + +接的过程中逐条对上了五个**会静默成功**的机制,每个都配了一道机检: + +| 机制 | 它会怎么骗人 | 机检 | +|---|---|---| +| `main.ts` 的入口守卫是 `import.meta.url === \`file://${process.argv[1]}\`` 字符串比较 | 经软链启动两边不等 → `boot()` 永不执行 → **进程零输出 exit 0**(实测过) | pm2 一律用 `releases/` 真实路径起;探活兜底 | +| `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//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 判据的一部分 + (抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。 + 要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。 diff --git a/docs/keycloak-backend-guide.md b/docs/keycloak-backend-guide.md new file mode 100644 index 0000000..38a9f61 --- /dev/null +++ b/docs/keycloak-backend-guide.md @@ -0,0 +1,1065 @@ +# 统一鉴权中心(Keycloak)—— 后端接入手册(Node.js) + +> **本文档的用途**:从零把一个 Node.js 后端服务接进统一鉴权中心, +> 一步一步做到第一个受保护接口跑通。按第 1 步到第 12 步顺序执行, +> 每步都写了"做完应该看到什么",对不上就别往下走。 +> +> **配套文档**:浏览器登录、前端 token 处理见《Keycloak 前端接入手册》。 +> +> **文档基准**:Keycloak 26.7.1,`jose` 5.10.0,Node.js 20+,核实日期 2026-08-12。 +> 文中代码均用真实签名的 RS256 token 端到端测试通过。 +> +> **给 AI agent 的执行原则** +> 1. 第 4 步的代码可直接落地,四个服务共用同一份,不要各写一套验签逻辑。 +> 2. 任何写操作(建用户、改角色、删客户端)执行前先向人类确认,只读查询可直接执行。 +> 3. 遇到本文档没写的 Admin API 端点,去 `https://www.keycloak.org/docs-api/latest/rest-api/` 查,不要猜。 +> 4. 每步的"做完应该看到什么"是硬检查点,不要跳过。 + +--- + +## 0. 环境信息 + +| 项 | 值 | +|---|---| +| Keycloak 地址 | `https://keycloak.akhr-ai.com` | +| Realm | `corp` | +| Issuer | `https://keycloak.akhr-ai.com/realms/corp` | +| JWKS | `https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs` | + +四个后端服务,各自一个 client: + +| 服务 | clientId | service account | client secret | +|---|---|---|---| +| API 网关 | `api-gateway` | ✓ | 有 | +| 智能体服务 | `agent-service` | ✓ | 有 | +| 任务分发服务 | `task-dispatcher` | ✓ | 有 | +| 业务数据服务 | `data-service` | ✗ | **没有,也不需要**(见 13.2) | + +两个前端(token 的来源): + +| | clientId | 要求的角色 | +|---|---|---| +| 用户端 `jcai-bpos.akhr-ai.com` | `web-portal` | `user` | +| 管理端 `jcai-bpos-opt.akhr-ai.com` | `web-console` | `admin` | + +角色模型(realm 级,所有服务共用同一套): + +``` +admin ──── user 管理员:组合包含 user + +user 普通用户;realm 默认角色,新建用户自动获得 +``` + +--- + +## 1. 确认 Keycloak 侧已就绪 + +代码写得再对,Keycloak 那边没配好也验不过。先花 30 秒确认: + +```bash +curl -s https://keycloak.akhr-ai.com/realms/corp/.well-known/openid-configuration | jq -r .issuer +``` + +**做完应该看到**: + +``` +https://keycloak.akhr-ai.com/realms/corp +``` + +出来是别的或报错,**先停下**,让管理员修 Keycloak 的 `KC_HOSTNAME`。 +这种情况下后端怎么写都验不过,而且报错完全指不到这里。 + +再确认公钥能拉到: + +```bash +curl -s https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/certs | jq '.keys[0].kid' +``` + +有输出即可。这是个**公开端点,不需要任何凭据**——后端验签只需要它。 + +--- + +## 2. 装依赖,建目录 + +```bash +npm i jose express +``` + +> **不要用 `keycloak-connect`**(Node 官方适配器),已标记 DEPRECATED、不再维护。 +> `jose` 是标准 JOSE 实现,`createRemoteJWKSet` 自带公钥缓存和密钥轮换处理。 + +目录结构(四个服务都一样): + +``` +your-service/ +├── .env ← 第 3 步 +└── src/ + ├── keycloak-auth.js ← 第 4 步,核心,四个服务共用 + └── server.js ← 第 5 步 +``` + +`package.json` 里加 `"type": "module"`(下面代码用 ESM)。 + +--- + +## 3. 写环境变量 + +**每个服务填自己的 `KEYCLOAK_CLIENT_ID`**,这决定了它校验 `aud` 时比对哪个值。填错会导致所有请求 401。 + +```bash +# api-gateway 的 .env +KEYCLOAK_URL=https://keycloak.akhr-ai.com +KEYCLOAK_REALM=corp +KEYCLOAK_CLIENT_ID=api-gateway +KEYCLOAK_CLIENT_SECRET=<管理台 Clients → api-gateway → Credentials 抄> +PORT=8080 +``` + +```bash +# data-service 的 .env —— 没有 SECRET 是对的,不是配漏了 +KEYCLOAK_URL=https://keycloak.akhr-ai.com +KEYCLOAK_REALM=corp +KEYCLOAK_CLIENT_ID=data-service +PORT=8083 +``` + +`agent-service` / `task-dispatcher` 同 `api-gateway` 的形式,改 `CLIENT_ID` 和 `SECRET`。 + +**为什么 `data-service` 没有 secret**:secret 的用途是"向 Keycloak 证明自己是谁", +不是"验别人的 token"。验签只需要公钥,公钥是公开的。`data-service` 只验不调, +所以用不上。详见 13.2。 + +--- + +## 4. 写 `src/keycloak-auth.js` + +核心文件,四个服务共用一份。下面分五段讲,最后给完整版。 + +### 4.1 远程公钥集 + +```js +import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose'; + +const KEYCLOAK_URL = process.env.KEYCLOAK_URL; +const REALM = process.env.KEYCLOAK_REALM; +const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID; + +export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`; + +const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), { + cooldownDuration: 30_000, // 两次重新拉取之间至少间隔 30 秒 + cacheMaxAge: 10 * 60_000, // 公钥缓存 10 分钟 + timeoutDuration: 5_000, +}); +``` + +`createRemoteJWKSet` 内部行为: + +- **首次用到时才拉取**,不是启动时 +- 结果缓存 `cacheMaxAge` +- 遇到缓存里没有的 `kid` 会自动重新拉,但受 `cooldownDuration` 限流 + +**密钥轮换你不用管。** Keycloak 换签名密钥后新 token 的 `kid` 是缓存里没有的, +库会自动重拉;`cooldownDuration` 防止恶意 token 把 Keycloak 打爆。 + +### 4.2 验签 + +```js +export async function verifyAccessToken(token) { + const { payload } = await jwtVerify(token, JWKS, { + algorithms: ['RS256'], // 写死,绝不读 token header 里的 alg + issuer: ISSUER, + audience: CLIENT_ID, + clockTolerance: 30, + }); + return payload; +} +``` + +四项校验,顺序即安全要点: + +| 校验 | 为什么 | +|---|---| +| `algorithms: ['RS256']` | **必须写死**。读 header 里的 `alg` 会被 `alg: none` 攻击绕过 | +| `issuer` | 确认 token 来自本 realm,不是另一个 Keycloak 实例 | +| `audience` | 确认这个 token 是**发给本服务**的 | +| `clockTolerance: 30` | 容忍 30 秒时钟偏移,避免边界抖动 | + +**这一步是纯本地计算,不联网。** 公钥缓存后连续验 500 次实测约 57ms +(平均 0.1ms/次),Keycloak 不在请求热路径上。 + +### 4.3 Express 中间件 + +```js +function extractBearer(req) { + const h = req.headers?.authorization || ''; + if (!/^bearer /i.test(h)) return null; + return h.slice(7).trim() || null; +} + +export function requireAuth(options = {}) { + const { optional = false } = options; + + return async function authMiddleware(req, res, next) { + const token = extractBearer(req); + if (!token) { + if (optional) return next(); + return res.status(401).json({ error: 'unauthorized', message: '缺少 Bearer token' }); + } + + try { + const p = await verifyAccessToken(token); + req.auth = p; // 原始 payload + req.user = { + id: p.sub, // 稳定 UUID,业务表外键用它 + username: p.preferred_username, + email: p.email, + name: p.name, + roles: p.realm_access?.roles ?? [], + groups: p.groups ?? [], // 服务账号 token 没有这个,默认空数组 + isService: (p.preferred_username || '').startsWith('service-account-'), + }; + return next(); + } catch (err) { + if (optional) return next(); + return res.status(401).json({ error: 'invalid_token', message: explain(err) }); + } + }; +} +``` + +`req.user.groups` 默认成空数组很重要——**服务账号 token 没有 `groups`**, +按组过滤数据的代码不处理会崩。 + +### 4.4 把 jose 的错误翻译成人话 + +排错时能省很多时间: + +```js +function explain(err) { + if (err instanceof joseErrors.JWTExpired) { + return 'token 已过期,用 refresh_token 换个新的'; + } + if (err instanceof joseErrors.JWTClaimValidationFailed) { + if (err.claim === 'aud') { + return `token 的 aud 不含 "${CLIENT_ID}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; + } + if (err.claim === 'iss') { + return `token 的 iss 与 "${ISSUER}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + } + } + if (err instanceof joseErrors.JWKSNoMatchingKey) { + return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; + } + if (err instanceof joseErrors.JWSSignatureVerificationFailed) { + return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; + } + return err.message; +} +``` + +### 4.5 角色守卫 + +```js +/** 要求持有指定 realm 角色之一。admin 组合含 user,所以管理员能过 user 的检查 */ +export function requireRealmRole(...roles) { + return (req, res, next) => { + const have = req.user?.roles ?? []; + if (roles.some((r) => have.includes(r))) return next(); + return res.status(403).json({ + error: 'forbidden', + message: `需要角色之一: ${roles.join(', ')};当前持有: ${have.join(', ') || '无'}`, + }); + }; +} + +/** 按组过滤:groups 是完整路径数组,判断"属于某部门或其子部门"用前缀匹配 */ +export function isInGroup(user, groupPath) { + const base = groupPath.replace(/\/$/, ''); + return (user?.groups ?? []).some((g) => g === base || g.startsWith(base + '/')); +} +``` + +**401 和 403 必须分开**: + +| 状态码 | 含义 | 前端该怎么反应 | +|---|---|---| +| 401 | token 缺失/无效/过期 | 重新登录 | +| 403 | token 有效但角色不够 | 提示"没有权限",重新登录没用 | + +### 4.6 完整文件 + +```js +// src/keycloak-auth.js +import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from 'jose'; + +const KEYCLOAK_URL = process.env.KEYCLOAK_URL; +const REALM = process.env.KEYCLOAK_REALM; +const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID; +const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; + +export const ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`; +const TOKEN_URL = `${ISSUER}/protocol/openid-connect/token`; + +const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, +}); + +export async function verifyAccessToken(token) { + const { payload } = await jwtVerify(token, JWKS, { + algorithms: ['RS256'], + issuer: ISSUER, + audience: CLIENT_ID, + clockTolerance: 30, + }); + return payload; +} + +function extractBearer(req) { + const h = req.headers?.authorization || ''; + if (!/^bearer /i.test(h)) return null; + return h.slice(7).trim() || null; +} + +function explain(err) { + if (err instanceof joseErrors.JWTExpired) return 'token 已过期'; + if (err instanceof joseErrors.JWTClaimValidationFailed) { + if (err.claim === 'aud') return `token 的 aud 不含 "${CLIENT_ID}" —— 给签发方 client 加 audience mapper`; + if (err.claim === 'iss') return `iss 与 "${ISSUER}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + } + if (err instanceof joseErrors.JWKSNoMatchingKey) return 'JWKS 里找不到匹配的公钥'; + if (err instanceof joseErrors.JWSSignatureVerificationFailed) return '签名校验失败'; + return err.message; +} + +export function requireAuth(options = {}) { + const { optional = false } = options; + return async function authMiddleware(req, res, next) { + const token = extractBearer(req); + if (!token) { + if (optional) return next(); + return res.status(401).json({ error: 'unauthorized', message: '缺少 Bearer token' }); + } + try { + const p = await verifyAccessToken(token); + req.auth = p; + req.user = { + id: p.sub, + username: p.preferred_username, + email: p.email, + name: p.name, + roles: p.realm_access?.roles ?? [], + groups: p.groups ?? [], + isService: (p.preferred_username || '').startsWith('service-account-'), + }; + return next(); + } catch (err) { + if (optional) return next(); + return res.status(401).json({ error: 'invalid_token', message: explain(err) }); + } + }; +} + +export function requireRealmRole(...roles) { + return (req, res, next) => { + const have = req.user?.roles ?? []; + if (roles.some((r) => have.includes(r))) return next(); + return res.status(403).json({ + error: 'forbidden', + message: `需要角色之一: ${roles.join(', ')};当前持有: ${have.join(', ') || '无'}`, + }); + }; +} + +export function isInGroup(user, groupPath) { + const base = groupPath.replace(/\/$/, ''); + return (user?.groups ?? []).some((g) => g === base || g.startsWith(base + '/')); +} + +// ───────── 服务账号:用于调用其它服务或 Admin API ───────── +let _svcToken = null; +let _svcExpireAt = 0; + +/** 用 client_credentials 拿代表"本服务自己"的 token,内部带缓存 */ +export async function getServiceToken() { + if (_svcToken && Date.now() < _svcExpireAt) return _svcToken; + if (!CLIENT_SECRET) throw new Error('未配置 KEYCLOAK_CLIENT_SECRET,无法使用服务账号'); + + const resp = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + }), + }); + if (!resp.ok) throw new Error(`获取服务账号 token 失败: ${resp.status} ${await resp.text()}`); + + const data = await resp.json(); + _svcToken = data.access_token; + _svcExpireAt = Date.now() + (data.expires_in - 60) * 1000; // 提前 60 秒过期 + return _svcToken; +} + +// ───────── 启动自检 ───────── +/** 启动时调一次,把配置错误暴露在启动阶段而不是第一个请求 */ +export async function checkKeycloakConnection() { + const url = `${ISSUER}/.well-known/openid-configuration`; + const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); + const conf = await resp.json(); + if (conf.issuer !== ISSUER) { + throw new Error( + `issuer 不一致!\n 期望: ${ISSUER}\n 实际: ${conf.issuer}\n` + + ` → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了` + ); + } + return { issuer: conf.issuer, clientId: CLIENT_ID }; +} + +export const config = { KEYCLOAK_URL, REALM, CLIENT_ID, ISSUER, TOKEN_URL }; +``` + +--- + +## 5. 挂到 Express + +```js +// src/server.js +import express from 'express'; +import { requireAuth, requireRealmRole, checkKeycloakConnection, config } from './keycloak-auth.js'; + +const app = express(); +app.use(express.json()); + +// 健康检查必须放在鉴权之前 —— 让 k8s/负载均衡探活不需要 token +app.get('/healthz', (req, res) => res.json({ ok: true })); + +// 公开接口 +app.get('/api/public', (req, res) => res.json({ message: '不需要登录' })); + +// 只要求登录 +app.get('/api/me', requireAuth(), (req, res) => { + res.json({ + user: req.user, + tokenExpiresAt: new Date(req.auth.exp * 1000).toISOString(), + }); +}); + +// 普通用户能访问(admin 组合含 user,管理员也能过) +app.get('/api/records', requireAuth(), requireRealmRole('user'), (req, res) => { + res.json({ by: req.user.username, records: [] }); +}); + +// 管理端专属 +app.get('/api/admin/stats', requireAuth(), requireRealmRole('admin'), (req, res) => { + res.json({ message: '管理员数据' }); +}); + +const PORT = process.env.PORT || 8083; + +try { + const info = await checkKeycloakConnection(); + console.log('✓ Keycloak 连通'); + console.log(' issuer:', info.issuer); + console.log(' client:', info.clientId); +} catch (e) { + console.error('✗ Keycloak 连不上或配置有误:', e.message); + process.exit(1); // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因 +} + +app.listen(PORT, () => console.log(`✓ 服务已启动 http://localhost:${PORT}`)); +``` + +**做完应该看到**: + +``` +✓ Keycloak 连通 + issuer: https://keycloak.akhr-ai.com/realms/corp + client: data-service +✓ 服务已启动 http://localhost:8083 +``` + +启动就报 `issuer 不一致` 说明 `KEYCLOAK_URL` 或 realm 名写错了,改完再跑。 + +> 顶层 `await` 需要 ESM(`"type": "module"`)。 +> 用 Express 4 的话异步中间件抛错不会自动进错误处理器——本文的中间件内部已经 +> `try/catch` 兜住了,不受影响。 + +--- + +## 6. 第一次运行:用真 token 打通 + +### 6.1 取一个真实 token + +```bash +cd /opt/keycloak + +# 临时开密码模式(仅为调试取 token) +WEB=$(sudo ./kc kcadm get clients -r corp -q clientId=web-portal --fields id --format csv --noquotes | tail -1) +sudo ./kc kcadm update "clients/$WEB" -r corp -s directAccessGrantsEnabled=true + +TOKEN=$(curl -s -X POST \ + "https://keycloak.akhr-ai.com/realms/corp/protocol/openid-connect/token" \ + -d grant_type=password -d client_id=web-portal \ + -d username=<你的测试账号> -d password='<密码>' -d scope=openid \ + | jq -r .access_token) + +echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, azp, preferred_username, realm_access}' +``` + +**必须确认 `aud` 里有本服务的 clientId**,没有的话验签必 401——那是 Keycloak 侧 +audience mapper 没配,改后端代码没用。 + +用完立刻关掉: + +```bash +sudo ./kc kcadm update "clients/$WEB" -r corp -s directAccessGrantsEnabled=false +``` + +> 报 `Account is not fully set up` 的话,是账号上挂着必输动作。 +> Users → 该用户 → Details → **Required user actions** 清空,Credentials 里 +> 重设一个非临时密码。 + +### 6.2 打接口 + +```bash +curl -s http://localhost:8083/api/public | jq . +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8083/api/me | jq . +curl -s http://localhost:8083/api/me | jq . # 无 token,应 401 +``` + +**做完应该看到**: + +| 请求 | 期望 | +|---|---| +| `/api/public` | 200 | +| `/api/me` 带 token | 200,返回 `user` 对象含 `username` / `roles` | +| `/api/me` 不带 token | 401 `{"error":"unauthorized"}` | +| `/api/records` 带普通用户 token | 200 | +| `/api/admin/stats` 带普通用户 token | **403**(不是 401) | + +最后一条最关键——**403 说明验签通过了、只是角色不够**,整条链路是通的。 + +### 6.3 这一步的三个常见卡点 + +| 现象 | 原因 | 处理 | +|---|---|---| +| 401 且提示 `aud 不含 xxx` | Keycloak 侧没给前端 client 配 audience mapper | 找管理员补,见 14.5 | +| 401 且提示 `iss 与 ... 不符` | `KEYCLOAK_URL` 或 realm 名写错 | 对照第 0 节 | +| 全部 401 且提示签名校验失败 | token 来自另一个 Keycloak 实例 | 确认取 token 和验 token 用的是同一个地址 | + +--- + +## 7. 角色鉴权怎么用 + +```js +// 管理端专属接口 +app.get('/api/admin/users', requireAuth(), requireRealmRole('admin'), handler); +app.delete('/api/records/:id', requireAuth(), requireRealmRole('admin'), handler); + +// 普通接口(admin 组合含 user,管理员也能过) +app.get('/api/records', requireAuth(), requireRealmRole('user'), handler); +``` + +**前端的角色判断不是安全措施。** 前端是 public client,代码在用户浏览器里, +能被绕过。管理端专属接口**必须**在后端加 `requireRealmRole('admin')`, +这是唯一真正的安全边界。 + +按部门过滤数据: + +```js +import { isInGroup } from './keycloak-auth.js'; + +app.get('/api/dept-docs', requireAuth(), requireRealmRole('user'), async (req, res) => { + const all = await db.docs.findAll(); + // 资源表上存 visible_to_group(组的完整路径),前缀匹配 + res.json(all.filter((d) => isInGroup(req.user, d.visible_to_group))); +}); +``` + +`groups` 是完整路径数组(如 `["/技术部", "/技术部/平台组"]`), +需要 Keycloak 侧配了 group membership mapper 才有。**服务账号 token 没有它**, +所以上面代码对服务调用会返回空——这是正确行为。 + +--- + +## 8. 业务库怎么存用户 + +不要每次都问 Keycloak。首次见到这个 `sub` 就在自己库里落一条: + +```js +app.use(requireAuth(), async (req, res, next) => { + let local = await db.users.findOne({ keycloak_sub: req.user.id }); + if (!local) { + local = await db.users.insert({ + keycloak_sub: req.user.id, // 稳定 UUID,所有业务外键指向它 + username: req.user.username, // 冗余一份仅供显示,不要当主键 + created_at: new Date(), + }); + } + req.localUser = local; + next(); +}); +``` + +**外键必须用 `sub`,不能用 username。** username 允许修改,`sub` 永不变。 +用 username 做外键的话,用户改个名你的数据就对不上了。 + +--- + +## 9. API 网关的写法(和下游不一样) + +网关只做两件事:**验签拦截 + 原样转发**。不判业务角色,那是下游各自的事。 + +```js +// gateway.js —— KEYCLOAK_CLIENT_ID=api-gateway +import express from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { requireAuth } from './keycloak-auth.js'; + +const app = express(); + +// 第一件事:剥掉客户端可能伪造的内部 header +app.use((req, _res, next) => { + for (const h of ['x-user-id', 'x-user-roles', 'x-internal-token']) delete req.headers[h]; + next(); +}); + +app.get('/healthz', (req, res) => res.json({ ok: true })); + +app.use('/api', requireAuth()); // 这里校验的 aud 是 api-gateway + +const forward = (target) => createProxyMiddleware({ + target, + changeOrigin: true, + // 关键:原样带上 Authorization,下游会自己再验一遍 + on: { + proxyReq: (proxyReq, req) => { + proxyReq.setHeader('Authorization', req.headers.authorization); + }, + }, +}); + +app.use('/api/agent', forward(process.env.AGENT_SERVICE_URL)); +app.use('/api/task', forward(process.env.TASK_SERVICE_URL)); +app.use('/api/data', forward(process.env.DATA_SERVICE_URL)); + +app.listen(process.env.PORT || 8080); +``` + +### 两条铁律 + +**1. 下游必须自己验签,不能信任网关。** +这是纵深防御:网关被绕过、内网被横向移动时,下游仍然安全。 +验签是纯本地计算,成本可以忽略。 + +**2. 网关不要把解出的用户信息塞进自定义 header 让下游信任。** + +```js +// ❌ 错误做法 +proxyReq.setHeader('X-User-Id', req.user.id); +// 下游读 X-User-Id 就信 —— 等于把安全边界搬到了内网可伪造的地方 +``` + +下游从自己验出来的 token 里读用户身份。 + +> 网关和下游能验通同一个 token,是因为前端 token 的 `aud` 同时包含四个后端 +> (`web-portal` 上挂了四个 audience mapper)。网关校验 `aud` 含 `api-gateway`, +> `data-service` 校验 `aud` 含 `data-service`,各查各的。 + +--- + +## 10. 服务间调用 + +定时任务、后台作业这类脱离用户请求的场景,用服务账号拿代表"服务自己"的 token。 + +```js +import { getServiceToken } from './keycloak-auth.js'; + +async function callDataService(path) { + const token = await getServiceToken(); // 内部有缓存,不会每次都换 + const resp = await fetch(`${process.env.DATA_SERVICE_URL}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`调用失败 ${resp.status}`); + return resp.json(); +} +``` + +### 服务账号 token 与用户 token 的三处不同 + +| claim | 用户 token | 服务账号 token | +|---|---|---| +| `sub` | 真实用户 UUID | 服务账号用户的 UUID | +| `preferred_username` | 用户名 | `service-account-` | +| `groups` | 用户所在组 | **不存在** | +| `email` | 有 | 通常没有 | + +下游收到时要注意: + +1. **不要假设 `req.user.groups` 非空**,按组过滤的代码要处理空数组 +2. **不要用 `sub` 去业务用户表查人**,会查不到 +3. 判断是不是服务调用,**推荐给服务账号授予专门的 realm 角色**再判角色 —— + `preferred_username` 的前缀只是 Keycloak 的命名约定,判角色更稳 + +**做完应该看到**:服务账号 token 解开后 `preferred_username` 是 +`service-account-task-dispatcher`,`realm_access.roles` 是你给它挂的角色。 + +--- + +## 11. 调 Admin API(管用户和角色) + +需要一个有管理权限的服务账号。**不要用人的账号密码做自动化。** + +### 11.1 一次性准备(人类执行) + +```bash +cd /opt/keycloak && sudo ./kc login + +sudo ./kc kcadm create clients -r master \ + -s clientId=provisioning -s enabled=true -s publicClient=false \ + -s standardFlowEnabled=false -s serviceAccountsEnabled=true + +PROV=$(sudo ./kc kcadm get clients -r master -q clientId=provisioning \ + --fields id --format csv --noquotes | tail -1) +sudo ./kc kcadm get "clients/$PROV/client-secret" -r master # 记下 secret + +SA=$(sudo ./kc kcadm get "clients/$PROV/service-account-user" -r master \ + --fields id --format csv --noquotes | tail -1) +sudo ./kc kcadm add-roles -r master --uid "$SA" \ + --cclientid corp-realm --rolename manage-users --rolename view-users \ + --rolename query-groups --rolename view-clients +``` + +> `corp-realm` 是 master realm 里自动生成的、代表 `corp` realm 的管理 client, +> 命名规则是 `-realm`。 + +### 11.2 Node 里调用 + +```js +// admin.js +const KEYCLOAK_URL = process.env.KEYCLOAK_URL; +const REALM = process.env.KEYCLOAK_REALM; +const PROV_ID = process.env.PROVISIONING_CLIENT_ID; // provisioning +const PROV_SECRET = process.env.PROVISIONING_CLIENT_SECRET; + +let _tok = null, _exp = 0; + +async function adminToken() { + if (_tok && Date.now() < _exp) return _tok; + const resp = await fetch(`${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: PROV_ID, + client_secret: PROV_SECRET, + }), + }); + if (!resp.ok) throw new Error(`取管理 token 失败: ${resp.status}`); + const d = await resp.json(); + _tok = d.access_token; + _exp = Date.now() + (d.expires_in - 60) * 1000; + return _tok; +} + +/** 调 Admin REST API。path 相对于 /admin/realms/{realm} */ +export async function adminFetch(path, { method = 'GET', body, ...rest } = {}) { + const token = await adminToken(); + const resp = await fetch(`${KEYCLOAK_URL}/admin/realms/${REALM}${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + ...rest, + }); + if (!resp.ok) throw new Error(`Admin API ${method} ${path} 失败: ${resp.status} ${await resp.text()}`); + + // 201 Created 没有响应体,新资源 ID 在 Location 头最后一段 + const text = await resp.text(); + if (!text) { + const loc = resp.headers.get('location'); + return loc ? { id: loc.split('/').pop() } : null; + } + return JSON.parse(text); +} +``` + +### 11.3 常用操作 + +```js +// 精确查用户(能用 exact 就别用 search,search 在大 realm 上很慢) +const [u] = await adminFetch('/users?username=alice&exact=true'); + +// 建用户,201 的 Location 头带新 ID +const { id } = await adminFetch('/users', { method: 'POST', body: { + username: 'bob', email: 'bob@akhr-ai.com', enabled: true, emailVerified: true, + credentials: [{ type: 'password', value: 'Init@2026', temporary: true }], +}}); + +// 查实际生效的角色(composite 会展开组合角色) +const roles = await adminFetch(`/users/${id}/role-mappings/realm/composite`); + +// 授予 admin 角色(body 是角色对象数组,不是名字数组) +const adminRole = await adminFetch('/roles/admin'); +await adminFetch(`/users/${id}/role-mappings/realm`, { method: 'POST', body: [adminRole] }); + +// 强制下线 —— 撤权后让其立即失效的唯一手段 +await adminFetch(`/users/${id}/logout`, { method: 'POST' }); +``` + +**做完应该看到**:`adminFetch('/users?username=xxx&exact=true')` 返回一个数组。 +403 的话是服务账号缺 realm-management 角色,对照 11.1 补。 + +--- + +## 12. 上线前检查清单 + +- [ ] 每个服务的 `KEYCLOAK_CLIENT_ID` 是**自己的** clientId(填错会全线 401) +- [ ] `data-service` 没配 `KEYCLOAK_CLIENT_SECRET`(正确,见 13.2) +- [ ] secret 只在环境变量里,没进 Git、没写死在代码里 +- [ ] 启动自检 `checkKeycloakConnection()` 已接上,配置错了直接退出 +- [ ] `/healthz` 在鉴权中间件**之前**注册(否则探活会 401) +- [ ] 管理端专属接口都加了 `requireRealmRole('admin')` +- [ ] 401 和 403 分开返回,没有混用 +- [ ] 网关剥掉了 `x-user-id` 之类的伪造 header +- [ ] 下游服务各自验签,没有"信任网关"的代码 +- [ ] 没有在请求热路径上调 introspect / userinfo / Admin API +- [ ] 业务表外键用的是 `sub`,不是 username + +--- + +## 13. 鉴权原理速查 + +### 13.1 谁验什么 + +| 角色 | 要做的验证 | 不做什么 | +|---|---|---| +| 前端 | 读 `realm_access.roles` 决定显示什么 | **不做安全判断** | +| API 网关 | 完整验签,拒绝无效 token,原样转发 | 不做业务权限判断 | +| 下游三个服务 | **各自再完整验一遍签**,然后判角色 | 不假设"网关已经验过了" | + +### 13.2 验 token 不需要 client secret + +**secret 的用途是"向 Keycloak 证明自己是谁",不是"验别人的 token"。** + +| 服务在做什么 | 需要 secret | +|---|---| +| 验别人递来的 Bearer token | ❌ | +| 用 `client_credentials` 换自己的 token 去调别的服务 | ✅ | +| 调 token introspection | ✅(但不推荐) | +| 做 token exchange | ✅ | + +所以 `data-service` 的 `.env` 里没有 `KEYCLOAK_CLIENT_SECRET` 是正确的, +不是配漏了。看到它没 secret 时不要自作主张配一个——不用的凭据多存一处 +就多一个泄漏面。 + +### 13.3 身份数据从哪来 —— 不要向 Keycloak 回源查 + +> **后端不向 Keycloak「要」用户身份。身份就在 token 里。** +> 看到「验签通过后调 `/userinfo` 拿用户信息」这类写法,那是错的。 + +``` +首次请求 + 客户端 ──Bearer eyJ...──► 服务 + ├ 解 header 拿 kid,本地无此公钥 + ├───► GET /certs ← 唯一一次网络往返 + ├ 公钥存内存,缓存 10 分钟 + └ 验签 → 校验 iss/aud/exp → 读 sub/roles/groups + ≈ 50–200 ms + +第二次及以后 + 客户端 ──Bearer eyJ...──► 服务 + ├ kid 命中缓存 + └ 纯本地运算 ≈ 0.1 ms,Keycloak 全程未被打扰 +``` + +**需要 token 里没有的字段时,改配 mapper,不要调接口。** +比如要用户工号:Clients → `web-portal` → Client scopes → `web-portal-dedicated` +→ Add mapper → By configuration → **User Attribute**, +配 `employee_id` → `employee_id`,Add to access token = On。 +之后 `req.auth.employee_id` 直接可读,零网络开销。**两个前端都要配。** + +### 13.4 撤权的时效性 + +本地验签的代价:**改了角色或禁用用户,现有 access token 在过期前仍然有效**。 +access token 是 5 分钟,所以最坏滞后 5 分钟。 + +要立即生效调 `POST /admin/realms/corp/users/{id}/logout`, +它会作废该用户所有会话和 refresh token。 +**不要为了消除这 5 分钟去每请求调 introspect**,那个代价大得多。 + +--- + +## 14. Admin REST API 清单 + +基地址 `https://keycloak.akhr-ai.com/admin/realms/corp`, +带 `Authorization: Bearer <管理 token>`。 + +### 14.1 三条必须记住的路径规则 + +1. **路径里的 `{client-uuid}` 是 client 的内部 UUID,不是 clientId 字符串。** + 官方文档原文:`id of client (not client-id!)`。 + 先 `GET /clients?clientId=data-service` 拿 `[0].id`。 +2. **创建成功返回 201,响应体为空,新资源 ID 在 `Location` 头最后一段。** +3. **列表接口默认 `max=100`**,必须分页。 + +### 14.2 用户 + +| 操作 | 方法与路径 | +|---|---| +| 精确查 | `GET /users?username=alice&exact=true` | +| 模糊搜索 | `GET /users?search=&first=0&max=50`(大 realm 上慢,能用 exact 就别用) | +| 详情 | `GET /users/{user-id}` | +| 创建 | `POST /users` → 201 + Location | +| 更新 | `PUT /users/{user-id}`(全量覆盖,先 GET 再改再 PUT) | +| 删除 | `DELETE /users/{user-id}` | +| 设密码 | `PUT /users/{user-id}/reset-password` body `{"type":"password","value":"...","temporary":true}` | +| 查会话 | `GET /users/{user-id}/sessions` | +| **强制下线** | `POST /users/{user-id}/logout` | +| 解除暴力锁定 | `DELETE /attack-detection/brute-force/users/{user-id}` | + +### 14.3 组 + +| 操作 | 方法与路径 | +|---|---| +| 组树 | `GET /groups?briefRepresentation=false` | +| 建顶层组 | `POST /groups` body `{"name":"技术部"}` | +| 建子组 | `POST /groups/{parent-id}/children` | +| 组成员 | `GET /groups/{group-id}/members?first=0&max=100` | +| **加进组** | `PUT /users/{user-id}/groups/{group-id}`(无 body,204) | +| 移出组 | `DELETE /users/{user-id}/groups/{group-id}` | +| 查用户所属组 | `GET /users/{user-id}/groups` | + +### 14.4 角色 + +| 操作 | 方法与路径 | +|---|---| +| 列 realm 角色 | `GET /roles` | +| 建 realm 角色 | `POST /roles` body `{"name":"auditor"}` | +| **查实际生效的 realm 角色** | `GET /users/{user-id}/role-mappings/realm/composite` | +| 授 realm 角色 | `POST /users/{user-id}/role-mappings/realm` body 是**角色对象数组** | +| 撤 realm 角色 | `DELETE /users/{user-id}/role-mappings/realm` | +| 给**组**授角色 | 同上,`users/{id}` 换成 `groups/{id}` | + +`/composite` 会递归展开组合角色,返回"实际生效"的集合。 +**排查权限问题永远看 `/composite`。** 注意它不包含通过组继承的角色, +要查完整生效集合需同时看用户所属组的角色。 + +### 14.5 客户端与 mapper + +| 操作 | 方法与路径 | +|---|---| +| 按 clientId 查 | `GET /clients?clientId=data-service` → 取 `[0].id` | +| 取 client secret | `GET /clients/{client-uuid}/client-secret` | +| 重新生成 secret | `POST /clients/{client-uuid}/client-secret` | +| 服务账号对应用户 | `GET /clients/{client-uuid}/service-account-user` | +| 列 mapper | `GET /clients/{client-uuid}/protocol-mappers/models` | +| 加 mapper | `POST /clients/{client-uuid}/protocol-mappers/models` | +| **预览 token** | `GET /clients/{client-uuid}/evaluate-scopes/generate-example-access-token?userId=` | + +最后一个在调 mapper 时极其有用:不用真登录就能看到 token 会变成什么样。 + +audience mapper 的 body: + +```json +{ "name": "aud-data-service", "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { "included.client.audience": "data-service", "access.token.claim": "true" } } +``` + +### 14.6 服务账号所需权限(403 时查这里) + +| 你要做的事 | 需要的 realm-management 角色 | +|---|---| +| 查用户 | `view-users` | +| 建/改/删用户、改密码、加入组 | `manage-users` | +| 搜索组、查组成员 | `query-groups` + `view-users` | +| 查 client 与其角色 | `view-clients` | +| 建/改 client、加 mapper | `manage-clients` | +| 改 realm 设置 | `manage-realm` | + +**最小权限原则。** `realm-admin` 是包含一切的组合角色,除非确有必要否则不要授予。 + +--- + +## 15. 错误对照表 + +| 现象 | 根因 | 处理 | +|---|---|---| +| 401 提示 `aud 不含 xxx` | token 的 `aud` 不含本服务 clientId | 给**发 token 的那个 client**加 audience mapper(14.5) | +| 401 提示 `iss 不符` | realm 名或 `KEYCLOAK_URL` 写错 | 对照 `.well-known` 里的 issuer | +| 401 提示 JWKS 找不到公钥 | token 来自别的 realm,或密钥刚轮换 | 确认 realm;库应自动重拉 | +| 401 提示签名校验失败 | token 被篡改或来自另一个实例 | 确认取/验 token 用同一地址 | +| 启动就报 `issuer 不一致` | `KEYCLOAK_URL` 或 realm 名错 | 见第 1 步 | +| 探活接口也 401 | `/healthz` 注册在鉴权中间件之后 | 挪到前面 | +| Admin API 403 | 服务账号缺 realm-management 角色 | 对照 14.6 | +| Admin API 404,路径里有两个斜杠 | 变量取值为空 | 先 `console.log` 确认变量非空 | +| 路径填了 clientId 却 404 | 路径参数要的是内部 UUID | 先 `GET /clients?clientId=xxx` 取 `[0].id` | +| 撤了角色但用户还能访问 | JWT 自包含,要等 5 分钟过期 | 调 `POST /users/{id}/logout` | +| `req.user.groups` 是 undefined 导致崩溃 | 服务账号 token 没有 groups | 代码里默认成 `[]`(4.3 已处理) | +| 通过 nginx 访问全部 403 | Keycloak 侧没设 `proxy-headers` | 运维改 `KC_PROXY_HEADERS=xforwarded` | +| 502 / `upstream sent too big header` | token 撑爆 nginx 缓冲区 | 调大 `proxy_buffer_size`、`large_client_header_buffers` | + +--- + +## 16. 硬性约束(不要违反,也不要建议人类违反) + +1. **不要用 `keycloak-connect`**(Node 官方适配器,已废弃)。 +2. **`algorithms` 必须写死 `['RS256']`**,绝不读 token header 里的 `alg`。 +3. **不要为每个请求调 introspect**,用本地 JWKS 验签。 + 同理不要在热路径调 `/userinfo` 或 Admin API 补查字段——需要什么字段就配 mapper。 +4. **下游服务必须自己验签**,不能因为"网关已经验过了"就跳过。 +5. **网关不要把解出的用户信息塞进自定义 header 让下游信任。** +6. **不要给"只验 token 不主动调别人"的服务配 secret**(见 13.2)。 +7. **不要用 `realm-admin` 做日常自动化**,按 14.6 授最小权限。 +8. **业务表外键用 `sub`**,不要用 username。 +9. **401 和 403 不要混用**,前端靠它区分"该重新登录"和"没权限"。 +10. **业务用户不放 `master` realm**,`master` 只放管理员。 +11. **不要把细粒度对象权限写进 Keycloak**(如"用户 A 能看文档 12345")。 + Keycloak 管身份 + 组织结构 + 粗粒度角色,对象级权限留在业务库。 +12. 涉及删除用户、删除 client、改 realm 设置的操作,**执行前必须向人类确认**。 + +--- + +## 17. Token 结构速查 + +用户 token: + +```json +{ + "iss": "https://keycloak.akhr-ai.com/realms/corp", + "aud": ["api-gateway", "agent-service", "task-dispatcher", "data-service", "account"], + "sub": "8f3c1a2b-...", + "exp": 1755000000, + "azp": "web-portal", + "preferred_username": "chaihao", + "email": "chaihao@akhr-ai.com", + "name": "张三", + "groups": ["/技术部", "/技术部/平台组"], + "realm_access": { "roles": ["user", "default-roles-corp"] } +} +``` + +| claim | 含义 | 注意 | +|---|---|---| +| `sub` | 用户唯一 ID,**永不变** | 业务表存这个 | +| `azp` | 哪个前端签发的 | 与 `aud` 不同,别混用 | +| `aud` | 目标受众 | 默认不含后端,靠 audience mapper | +| `groups` | 组完整路径数组 | 默认不存在,靠 group membership mapper;服务账号没有 | +| `realm_access.roles` | realm 角色 | 只有 `user` / `admin` 两种 | + +--- + +## 18. 官方文档索引 + +| 主题 | 链接 | +|---|---| +| Admin REST API 完整参考 | https://www.keycloak.org/docs-api/latest/rest-api/ | +| 应用接入 OIDC | https://www.keycloak.org/securing-apps/oidc-layers | +| Token Exchange | https://www.keycloak.org/securing-apps/token-exchange | +| 服务器管理指南 | https://www.keycloak.org/docs/latest/server_admin/ | +| jose 库文档 | https://github.com/panva/jose | diff --git a/docs/superpowers/plans/2026-08-25-keycloak-auth.md b/docs/superpowers/plans/2026-08-25-keycloak-auth.md new file mode 100644 index 0000000..ee0d484 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-keycloak-auth.md @@ -0,0 +1,4975 @@ +# 统一鉴权中心(Keycloak)后端接入 —— 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把身份交给 Keycloak(后端只验 Bearer JWT),权限收成「角色 → 能进哪几个端」一张可动态改的表,名册从 Keycloak 同步、岗位本地指派;口令 / 会话表 / 岗位授权整套退役。只做后端,web 一个文件不动。 + +**Architecture:** `org`(L1)原地重写:`authenticate(token)` = jose 本地验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × `sys.role_access` → `Principal{staff, position, roles, ends}`。app 层一处鉴权中间件按**路由注册表**(每条契约自带 `access`)统一判 401/403,治理路由里约 100 处逐条守卫整体删除;SSE 与 `` 下载走 60s HMAC 票据。 + +**Tech Stack:** TypeScript ESM(import 带 `.ts` 后缀)· Hono 4 · Drizzle(sys schema)· zod 4 · vitest · **jose ^5.10.0(新依赖,用户已批)** · Node 22 `node:crypto`(HKDF / HMAC) + +**Spec:** `docs/superpowers/specs/2026-08-25-keycloak-auth-design.md`(计划从它推出来;执行者两份都读) + +## Global Constraints + +- 五道门禁以退出码验:`pnpm typecheck && pnpm lint && pnpm depcruise && pnpm api:check && pnpm test`;每个任务提交前至少 typecheck / lint / test 绿,改了契约面(`@contracts` / 模块 `index|contract|port.ts`)的任务还要 `pnpm api:check --update && git add api-report`。 +- §12.5 禁止清单:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO、零 `Date.now` / `Math.random` / `new Date()`;模块级零 `let`;不删无关测试;不动门禁配置。 +- 模块内 `export` 的函数必须写显式返回类型(eslint `explicit-module-boundary-types`)。可选属性一律用 `...(x === undefined ? {} : { x })` 的写法(仓库开着 `exactOptionalPropertyTypes` 风格)。 +- 验签:`algorithms: ['RS256']` 写死、校 `issuer` 与 `audience`、`clockTolerance: 30`;远程 JWKS `cooldownDuration: 30_000` / `cacheMaxAge: 10 * 60_000` / `timeoutDuration: 5_000`;本服务**不配 client secret**。 +- 票据:`v1...`,HMAC-SHA256,密钥 `HKDF(JCAI_SECRET_KEY, info='jcai-ticket')`,TTL 60s,**只在 GET 的 `?ticket=` 上认**。 +- 名册:`last_seen_at` 60s 内不重写;Admin API 分页 `max=100`;噪声角色 `default-roles-*` / `offline_access` / `uma_authorization` 一律滤掉。 +- 岗位哨兵码 `NONE`(未分配)、测试岗 `TEST`;缺省角色→端:`admin → design+ops+workbench`,`user → workbench`。 +- 环境变量:`KEYCLOAK_URL` `KEYCLOAK_REALM` `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_ID` `KEYCLOAK_PROVISIONING_CLIENT_SECRET`(可选)、`JCAI_TEST_ACCOUNTS`(可选,逗号分隔 Keycloak 用户名)、`JCAI_SECRET_KEY`(沿用);`JCAI_SEED_PASSWORD` 删除。 +- HTTP 语义:无凭据 / 坏 token → 401 `unauthenticated`(message 是手册 4.4 的人话);账号停用或端不够 → 403 `forbidden`;角色→端锁死 → 409 `conflict`;退役路由 → 410 `gone`(新增错误码)。 +- 路由归属(`access`):`public` 只有 `healthRoute` / `ingestMailRoute` / 退役八条;`any` 只有 `meRoute` / `authTicketRoute` / `GET /api/stream`;其余按 §4.4 表(本计划 Task 1 逐条列出)。 +- **执行前置**:工作树里有用户未提交的改动(`CLAUDE.md` `api-report/.../task/contract.d.ts` `asset-archive*.ts` `desk.ts` `engines.ts` `wake.test.ts` `e2e-a2.mjs` + 两份 untracked 文档)。**开工前请用户先把它们提交或 stash**;本计划每次提交只 `git add` 任务点名的文件。分支 `feat/keycloak-auth`(spec 已在上面)。 + +--- + +## 文件结构(改动全貌) + +| 目的 | 文件 | 职责 | +|---|---|---| +| 契约 | `packages/contracts/src/api/http.ts` | `SURFACES` / `RouteAccess` / `RouteContract.access` / 错误码 `gone` | +| | `packages/contracts/src/org.ts` | `staffSchema`(+email/roles)、`principalSchema`、`roleAccessSchema`、`rosterSyncOutcomeSchema`、`inFlightWorkSchema`;旧授权 / 会话类型标 `@deprecated` | +| | `packages/contracts/src/api/workbench.ts` `governance.ts` `ingest.ts` | 每条路由加 `access`;新路由 8 条 | +| | `packages/contracts/src/__tests__/route-access.test.ts` | 每条路由都声明了合法的 `access`;公开路由只有那几条 | +| org domain | `apps/api/src/modules/org/internal/domain/claims.ts` | claims → Identity(名回退、噪声角色) | +| | `…/domain/ends.ts` | 角色 × 表 → 端;锁死不变量 | +| | `…/domain/roster-diff.ts` | 同步差异(增 / 改 / 停 / 复活) | +| | `…/domain/test-account.ts` | 测试账号策略(TEST 岗 + 独立工作组) | +| | `…/domain/in-flight.ts` | 在途工作(原 disable-check 改名) | +| | `…/domain/seed-data.ts` | 岗位种子(含 NONE / TEST)、缺省角色→端 | +| org adapter | `…/adapter/keycloak-token-verifier.ts` | jose 验签 + 错误译人话 | +| | `…/adapter/keycloak-admin-roster.ts` | Admin API 拉名册(provisioning 服务账号) | +| | `…/adapter/org-schema.ts` `org-repository.ts` | 表与仓储(staff 新列、role_access 表;session / grant / password 退役) | +| org 面 | `…/org/contract.ts` `port.ts` `index.ts` `internal/usecase/org-service.ts` `CONTRACT.md` | 新出口 | +| app | `apps/api/src/app/route-registry.ts` | 契约命名空间 → 归属注册表(`lookup` / `has`) | +| | `apps/api/src/app/ticket.ts` | 60s HMAC 票据 | +| | `apps/api/src/app/auth-middleware.ts` | 唯一一处鉴权:凭据 → Principal → 端判定 | +| | `apps/api/src/app/keycloak-check.ts` | 启动自检(issuer 一致才起) | +| | `apps/api/src/app/{config,container,http-env,server,main,seed}.ts` | 配置 / 装配 / 上下文 / 中间件挂载与退役路由 / 启动 | +| | `apps/api/src/app/routes/{workbench,governance,governance-ops,desk,desk-elements,desk-approval,registration}.ts` | 去守卫、取当前人改读 principal、新路由 | +| | `apps/api/src/app/wiring/engines.ts` | 死信收件人 → `staffWithEnd('ops')`;`positions()` 滤 NONE | +| 删除 | `apps/api/src/app/session-cookie.ts` `set-password.ts`;`org/internal/adapter/password.ts`;`org/internal/domain/{authorization,disable-check}.ts`;`org/__tests__/{authorization,password}.test.ts` | 随口令 / 会话 / 岗位授权退役 | +| 数据 | `db/migrations/0032_keycloak_identity.sql` + `meta/_journal.json` | 见 spec §6 | +| 测试 | `org/__tests__/{claims,ends,roster-diff,test-account,keycloak-token-verifier,keycloak-admin-roster,org-service}.test.ts`;`app/__tests__/{route-registry,ticket,auth-middleware,config-keycloak,keycloak-check}.test.ts`;`app/__tests__/route-coverage.it.test.ts`;三份既有 `*.it.test.ts` 改造人 | | +| 工具 / 文档 | `tools/e2e-a2.mjs`;`docs/adr/ADR-012-keycloak-identity.md`;`docs/编码规范.md`;`docs/debt.md`;`CLAUDE.md`;`api-report/` | | +| 对外文档(已随设计写好,Task 12 核对) | `docs/前端对接说明-统一鉴权中心.md`;`docs/API文档-鉴权与人员.md` | 前端要改什么、后端给了什么;接口人话版 + 全部路由归属总表 | + +--- + +### Task 1: 契约 —— 端、路由归属、新 schema 与新路由 + +**Files:** +- Modify: `packages/contracts/src/api/http.ts`(整文件替换) +- Modify: `packages/contracts/src/org.ts`(整文件替换) +- Modify: `packages/contracts/src/api/workbench.ts`、`packages/contracts/src/api/governance.ts`、`packages/contracts/src/api/ingest.ts`(每条路由加 `access`;新增 8 条) +- Create: `packages/contracts/src/__tests__/route-access.test.ts` + +**Interfaces:** +- Produces:`SURFACES` `surfaceSchema` `Surface` `SURFACE_LABEL` `RouteAccess`;`RouteContract.access: RouteAccess`(必填);`API_ERROR_CODES` 增 `'gone'`;`staffSchema`(+ `email?: string` / `roles: string[]`)、`principalSchema` / `Principal`、`roleAccessSchema` / `RoleAccess`、`rosterSyncOutcomeSchema` / `RosterSyncOutcome`、`inFlightWorkSchema` / `InFlightWork`;路由 `meRoute` `authTicketRoute` `listRoleAccessRoute` `setRoleAccessRoute` `deleteRoleAccessRoute` `syncStaffRoute` `setStaffPositionRoute` `staffInFlightRoute`。 +- 不删任何既有导出(旧的标 `@deprecated`),web 编译不受影响。 + +- [ ] **Step 1: 写失败测试** + +`packages/contracts/src/__tests__/route-access.test.ts`: + +```ts +/** 路由契约:每条都得说清归哪个端(ADR-012)—— 中间件靠它,漏一条就是一个没人管的口子 */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '../index.ts'; +import type { RouteContract } from '../index.ts'; +import { SURFACES } from '../index.ts'; + +function isRoute(value: unknown): value is RouteContract { + if (typeof value !== 'object' || value === null) return false; + const v = value as { method?: unknown; path?: unknown; output?: unknown }; + return typeof v.method === 'string' && typeof v.path === 'string' && v.output !== undefined; +} + +const routes = Object.entries(contracts).filter((entry): entry is [string, RouteContract] => isRoute(entry[1])); +const surfaces: readonly string[] = SURFACES; + +describe('路由契约的归属(access)', () => { + it('收得到全部路由(至少 90 条)', () => { + expect(routes.length).toBeGreaterThanOrEqual(90); + }); + + it('access 只取 public / any / 端名 / 非空端名数组', () => { + for (const [name, route] of routes) { + const a = route.access; + const ok = + a === 'public' || + a === 'any' || + (typeof a === 'string' ? surfaces.includes(a) : a.length > 0 && a.every((s) => surfaces.includes(s))); + expect(ok, `${name}.access = ${JSON.stringify(a)}`).toBe(true); + } + }); + + it('公开路由只有健康检查、入站邮件与退役八条', () => { + const publicNames = routes.filter(([, r]) => r.access === 'public').map(([n]) => n).sort(); + expect(publicNames).toEqual( + [ + 'healthRoute', + 'ingestMailRoute', + 'loginRoute', + 'logoutRoute', + 'sessionRoute', + 'createStaffRoute', + 'disableStaffCheckRoute', + 'disableStaffRoute', + 'listGrantsRoute', + 'setGrantsRoute', + ].sort(), + ); + }); + + it('只要登录不看端的只有 me / 票据', () => { + const anyNames = routes.filter(([, r]) => r.access === 'any').map(([n]) => n).sort(); + expect(anyNames).toEqual(['authTicketRoute', 'meRoute']); + }); + + it('两端共用的读路由就是 spec §4.4 点名的那八条', () => { + const both = routes + .filter(([, r]) => Array.isArray(r.access)) + .map(([n]) => n) + .sort(); + expect(both).toEqual( + [ + 'listStaffRoute', + 'listProcessPackagesRoute', + 'listTriggersRoute', + 'listChannelsRoute', + 'listLedgerObjectsRoute', + 'ledgerByActivityRoute', + 'browseLedgerRoute', + 'govWorkflowRoute', + ].sort(), + ); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run packages/contracts/src/__tests__/route-access.test.ts` +Expected: FAIL —— `Property 'access' does not exist` / `SURFACES` 不存在(typecheck 阶段就红)。 + +- [ ] **Step 3: 重写 `packages/contracts/src/api/http.ts`** + +```ts +/** + * 路由契约的形状(编码规范 §8.1 / §9.2)。 + * + * 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。 + * **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。 + */ +import { z } from 'zod'; + +export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** + * 三个端(ADR-012):设计中心 / 运营中心 / 工作台。 + * 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。 + */ +export const SURFACES = ['design', 'ops', 'workbench'] as const; +export const surfaceSchema = z.enum(SURFACES); +export type Surface = z.infer; + +export const SURFACE_LABEL: Readonly> = { + design: '设计中心', + ops: '运营中心', + workbench: '工作台', +}; + +/** + * 一条路由归哪个端(ADR-012): + * `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。 + * 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。 + */ +export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[]; + +/** + * 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。 + * body 只在 POST/PUT/PATCH 上有意义;GET 用 query。 + */ +export type RouteContract< + TParams extends z.ZodTypeAny = z.ZodTypeAny, + TQuery extends z.ZodTypeAny = z.ZodTypeAny, + TBody extends z.ZodTypeAny = z.ZodTypeAny, + TOutput extends z.ZodTypeAny = z.ZodTypeAny, +> = { + readonly method: HttpMethod; + readonly path: string; + readonly access: RouteAccess; + readonly params?: TParams; + readonly query?: TQuery; + readonly body?: TBody; + readonly output: TOutput; +}; + +export type RouteOutput = z.infer; +export type RouteBody = R['body'] extends z.ZodTypeAny + ? z.infer + : never; + +/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ +export const API_ERROR_CODES = [ + 'unauthenticated', + 'forbidden', + 'not-found', + 'validation-failed', + 'conflict', + 'precondition-failed', + 'internal', + 'gone', +] as const; +export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; + +export type ApiError = { + readonly code: ApiErrorCode; + readonly message: string; + readonly detail?: unknown; +}; +``` + +- [ ] **Step 4: 重写 `packages/contracts/src/org.ts`** + +```ts +/** + * 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。 + * + * 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench), + * 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。 + * 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。 + */ +import { z } from 'zod'; +import { positionIdSchema, staffIdSchema } from './id.ts'; +import { surfaceSchema } from './api/http.ts'; + +export const positionSchema = z.object({ + id: positionIdSchema, + code: z.string().min(1), + name: z.string().min(1), + note: z.string().optional(), +}); +export type Position = z.infer; + +export const STAFF_STATES = ['active', 'disabled'] as const; +export const staffStateSchema = z.enum(STAFF_STATES); +export type StaffState = z.infer; + +export const staffSchema = z.object({ + /** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */ + id: staffIdSchema, + /** = `preferred_username`,只作显示 —— 用户名允许改,别拿它当键 */ + account: z.string().min(1), + /** = `name` claim,缺则回退 account */ + name: z.string().min(1), + email: z.string().optional(), + /** 未指派 = 哨兵岗 `NONE / 未分配` 的 id */ + positionId: positionIdSchema, + /** 只由名册同步置 `disabled`(Keycloak 停用或删除);有效 token 不改它 */ + state: staffStateSchema.default('active'), + /** realm 角色快照(token 签发时;噪声角色已滤) */ + roles: z.array(z.string()).default([]), +}); +export type Staff = z.infer; + +/* ── 我是谁、能进哪几个端(ADR-012) ───────────────────────────────── */ + +export const principalSchema = z.object({ + staff: staffSchema, + position: positionSchema, + roles: z.array(z.string()), + ends: z.array(surfaceSchema), +}); +export type Principal = z.infer; + +/** 角色 → 端:一行一个 realm 角色 */ +export const roleAccessSchema = z.object({ + role: z.string().min(1), + ends: z.array(surfaceSchema), +}); +export type RoleAccess = z.infer; + +/** 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零 */ +export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [ + z.object({ configured: z.literal(false), message: z.string() }), + z.object({ + configured: z.literal(true), + inserted: z.number().int().min(0), + updated: z.number().int().min(0), + disabled: z.number().int().min(0), + total: z.number().int().min(0), + }), +]); +export type RosterSyncOutcome = z.infer; + +/* ── 在途工作(只读;OS 02:改派先于停用,停用本身已归鉴权中心) ───── */ + +export const inFlightWorkSchema = z.object({ + staffId: staffIdSchema, + blocked: z.boolean(), + openTasks: z.array(z.object({ taskId: z.string(), title: z.string() })).default([]), + openCards: z.array(z.object({ cardId: z.string(), title: z.string() })).default([]), + openApprovals: z.array(z.object({ approvalId: z.string(), title: z.string() })).default([]), +}); +export type InFlightWork = z.infer; + +/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */ +export const disableCheckSchema = inFlightWorkSchema; +/** @deprecated 改用 `InFlightWork` */ +export type DisableCheck = InFlightWork; + +/* ── 岗位授权(已退役,ADR-012;类型留一轮给 web 编译,下个迭代删) ──── */ + +/** @deprecated 权限只到端,动作三分已退役 */ +export const GRANT_ACTIONS = ['read', 'write', 'govern'] as const; +/** @deprecated */ +export const grantActionSchema = z.enum(GRANT_ACTIONS); +/** @deprecated */ +export type GrantAction = z.infer; +/** @deprecated */ +export const GRANT_ACTION_LABEL: Readonly> = { + read: '读', + write: '写', + govern: '治理', +}; + +/** @deprecated 治理台九功能不再是授权客体 */ +export const GOVERNANCE_FUNCTIONS = [ + 'task', + 'staff', + 'process', + 'trigger', + 'activity', + 'tool', + 'data', + 'memory', + 'system', +] as const; +/** @deprecated */ +export const governanceFunctionSchema = z.enum(GOVERNANCE_FUNCTIONS); +/** @deprecated */ +export type GovernanceFunction = z.infer; +/** @deprecated */ +export const GOVERNANCE_FUNCTION_LABEL: Readonly> = { + task: '任务管理', + staff: '员工管理', + process: '工作流管理', + trigger: '触发器配置', + activity: '活动库管理', + tool: '工具管理', + data: '数据管理', + memory: '记忆管理', + system: '系统管理', +}; + +/** @deprecated */ +export const grantObjectSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('data'), + domain: z.string().min(1), + object: z.string().min(1), + field: z.string().optional(), + }), + z.object({ kind: z.literal('governance'), function: governanceFunctionSchema }), + z.object({ kind: z.literal('task'), activityId: z.string().optional() }), +]); +/** @deprecated */ +export type GrantObject = z.infer; + +/** @deprecated */ +export const grantSchema = z.object({ + positionId: positionIdSchema, + object: grantObjectSchema, + action: grantActionSchema, +}); +/** @deprecated */ +export type Grant = z.infer; + +/* ── 会话(已退役:身份在 token 里,ADR-012) ──────────────────────── */ + +/** @deprecated 改用 `principalSchema` / `GET /api/me` */ +export const sessionSchema = z.object({ + staff: staffSchema, + position: positionSchema, + grants: z.array(grantSchema), +}); +/** @deprecated 改用 `Principal` */ +export type Session = z.infer; + +/* ── 工作组(ADR-011) ────────────────────────────────────────────────── */ + +/** + * 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。 + * + * **不是第五种负责人策略**(OS 03「策略封闭四选」不动): + * 四策略回答的是「这个岗位该派给谁」,工作组直接给出了答案,策略于是无从发问。 + * 键是**岗位 code**(BD / PM / FAE …),与流程包 `owner.position` 同一口径。 + */ +export const workGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + /** 组归谁 —— 起单时按来源人取其组 */ + ownerId: staffIdSchema, + members: z.record(z.string().min(1), staffIdSchema), +}); +export type WorkGroup = z.infer; + +/** 装进工作流的**快照**:改组不改已起的单(留痕不被改写) */ +export const workGroupBindingSchema = z.record(z.string().min(1), staffIdSchema); +export type WorkGroupBinding = z.infer; +``` + +- [ ] **Step 5: `packages/contracts/src/api/workbench.ts` —— 每条路由加 `access`,新增两条** + +在每个 `xxxRoute = { method: …, path: …,` 的 `path:` 行之后插入一行 `access: …,`。取值: + +| 路由 | access | +|---|---| +| `sessionRoute` `loginRoute` `logoutRoute` | `'public'`(退役;各加 JSDoc `/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */`) | +| `healthRoute` | `'public'` | +| `listTasksRoute` `taskDetailRoute` `replyWorkOrderRoute` `replyChatCardRoute` `markCommReadRoute` `saveTableRoute` `openAssetFileRoute` `sayRoute` `listMessagesRoute` `markMessageReadRoute` `answerMessageRoute` `confirmTriggerPendingRoute` `listMinutesRoute` `createMinuteRoute` `appendResolutionRoute` `registerResolutionRoute` `draftReportRoute` `listReportsRoute` `submitReportRoute` `listApprovalsForMeRoute` `decideApprovalRoute` `listRegistrationTriggersRoute` `fireRegistrationRoute` | `'workbench'` | + +把 `import { sessionSchema } from '../org.ts';` 改成 `import { principalSchema, sessionSchema } from '../org.ts';`,并在 `healthRoute` 之后加: + +```ts +/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */ +export const meRoute = { + method: 'GET', + path: '/api/me', + access: 'any', + output: principalSchema, +} as const satisfies RouteContract; + +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。GET 上 `?ticket=` 认它。 + */ +export const authTicketRoute = { + method: 'GET', + path: '/api/auth/ticket', + access: 'any', + output: z.object({ ticket: z.string().min(1), expiresAt: z.string() }), +} as const satisfies RouteContract; +``` + +- [ ] **Step 6: `packages/contracts/src/api/governance.ts` —— 每条路由加 `access`,新增六条** + +取值(写归所属端;两端共用的读标数组): + +| access | 路由 | +|---|---| +| `'public'`(退役,加 `@deprecated` JSDoc) | `createStaffRoute` `disableStaffCheckRoute` `disableStaffRoute` `listGrantsRoute` `setGrantsRoute` | +| `'design'` | `saveProcessRoute` `setProcessStateRoute` `deleteProcessVersionRoute` `deleteProcessFlowRoute` `editProcessVersionRoute` `validateProcessRoute` `listApprovalTemplatesRoute` `upsertApprovalTemplateRoute` `deleteApprovalTemplateRoute` `upsertTriggerRoute` `listActivitiesRoute` `installActivityRoute` `listToolsRoute` `registerToolRoute` `listActivityMemoryRoute` `listLlmProvidersRoute` `listLlmInstancesRoute` `upsertLlmInstanceRoute` `deleteLlmInstanceRoute` `probeLlmDraftRoute` `probeLlmInstanceRoute` `testRunRoute` `testRunActivityRoute` `testStopRoute` `testCurrentRoute` `testActivityStateRoute` `listTestLogsRoute` `testLogDetailRoute` `deleteTestLogRoute` `clearTestLogsRoute` `uploadFileRoute` `downloadFileRoute` `traceOfWorkflowRoute` | +| `'ops'` | `listGovTasksRoute` `controlTaskRoute` `resumeTaskRoute` `reassignTaskRoute` `govTaskLogRoute` `listApprovalsRoute` `govMetricsRoute` `toggleTriggerRoute` `setTriggerConfirmRoute` `listNodeOwnersRoute` `setNodeOwnerRoute` `listWorkGroupsRoute` `saveWorkGroupRoute` | +| `['design', 'ops']` | `listStaffRoute` `listProcessPackagesRoute` `listTriggersRoute` `listChannelsRoute` `listLedgerObjectsRoute` `ledgerByActivityRoute` `browseLedgerRoute` `govWorkflowRoute` | + +把第 7 行的 import 改成: + +```ts +import { + disableCheckSchema, + grantSchema, + inFlightWorkSchema, + positionSchema, + roleAccessSchema, + rosterSyncOutcomeSchema, + staffSchema, + workGroupSchema, +} from '../org.ts'; +import { surfaceSchema } from './http.ts'; +``` + +在「员工管理」段末尾(`setGrantsRoute` 之后)加六条: + +```ts +/* ── 员工管理 · 名册与岗位(ADR-012:人来自鉴权中心,岗位本地指派) ────── */ + +/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */ +export const syncStaffRoute = { + method: 'POST', + path: '/api/gov/staff/sync', + access: 'ops', + output: rosterSyncOutcomeSchema, +} as const satisfies RouteContract; + +/** 指派岗位;指回哨兵岗 NONE = 取消指派 */ +export const setStaffPositionRoute = { + method: 'PUT', + path: '/api/gov/staff/:staffId/position', + access: 'ops', + params: z.object({ staffId: staffIdSchema }), + body: z.object({ positionId: positionIdSchema }), + output: staffSchema.extend({ position: positionSchema }), +} as const satisfies RouteContract; + +/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */ +export const staffInFlightRoute = { + method: 'GET', + path: '/api/gov/staff/:staffId/in-flight', + access: 'ops', + params: z.object({ staffId: staffIdSchema }), + output: inFlightWorkSchema, +} as const satisfies RouteContract; + +/* ── 角色 → 端(ADR-012:权限只到端,这张表运营中心可改) ─────────────── */ + +export const listRoleAccessRoute = { + method: 'GET', + path: '/api/gov/access/roles', + access: 'ops', + output: z.object({ + roles: z.array(roleAccessSchema), + /** 名册上见过的角色 ∪ 已配置的角色 —— 配置前它等于「无端可进」 */ + knownRoles: z.array(z.string()), + }), +} as const satisfies RouteContract; + +/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */ +export const setRoleAccessRoute = { + method: 'PUT', + path: '/api/gov/access/roles/:role', + access: 'ops', + params: z.object({ role: z.string().min(1) }), + body: z.object({ ends: z.array(surfaceSchema) }), + output: roleAccessSchema, +} as const satisfies RouteContract; + +export const deleteRoleAccessRoute = { + method: 'DELETE', + path: '/api/gov/access/roles/:role', + access: 'ops', + params: z.object({ role: z.string().min(1) }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; +``` + +`packages/contracts/src/api/ingest.ts`:`ingestMailRoute` 加 `access: 'public',`(外界边界,ADR-010 原判)。 + +- [ ] **Step 7: 跑测试与 typecheck** + +Run: `pnpm vitest run packages/contracts/src/__tests__/route-access.test.ts && pnpm typecheck` +Expected: 5 条 PASS;typecheck 绿(web 引用的 `Session` / `loginRoute` 等都还在,只是多了 `access` 字段)。若 typecheck 报某条路由缺 `access`,补上。 + +- [ ] **Step 8: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add packages/contracts api-report +git commit -m "契约:三个端与路由归属(access 必填)、Principal / 角色→端 / 名册同步 schema、退役路由标 @deprecated(ADR-012)" +``` + +--- + +### Task 2: org domain —— claims 归一、角色→端、名册差异、测试账号策略(纯函数) + +**Files:** +- Modify: `apps/api/src/modules/org/contract.ts`(追加 `Identity` 与三个错误类) +- Modify: `apps/api/src/modules/org/port.ts`(追加 `VerifiedClaims` `TokenVerifier` `RosterUser` `RosterSource` `IdentityPatch`) +- Modify: `apps/api/src/modules/org/internal/domain/seed-data.ts`(`POSITION_SEED` 加 NONE;追加三个常量) +- Create: `apps/api/src/modules/org/internal/domain/claims.ts` `ends.ts` `roster-diff.ts` `test-account.ts` `in-flight.ts` +- Modify: `apps/api/src/modules/org/index.ts`(追加导出) +- Test: `apps/api/src/modules/org/__tests__/claims.test.ts` `ends.test.ts` `roster-diff.test.ts` `test-account.test.ts` + +**Interfaces:** +- Produces:`Identity{sub,account,name,email?,roles}`;`TokenRejectedError(message)` `AccountDisabledError(account)` `AccessLockoutError()`;`VerifiedClaims` `TokenVerifier{verify(token)}` `RosterUser = Identity & {enabled}` `RosterSource{listUsers()}` `IdentityPatch{account,name,email?,roles,lastSeenAt?}`;`identityOf(claims)` `isNoiseRole` `normalizeRoles`;`endsOf(roles, table)` `assertNotLockedOut(table, activeStaffRoles)` `normalizeEnds`;`diffRoster(remote, local)` `identityChanged(staff, identity)` `patchOf(identity)` `sameRoles`;`isTestAccount(account, list)` `soloGroupOf(staff)`;`buildInFlightWork(staffId, work)`;`NONE_POSITION_CODE='NONE'` `TEST_POSITION_CODE='TEST'` `DEFAULT_ROLE_ACCESS`。 +- 旧导出(`STAFF_SEED` 等)本任务**不删**,Task 8 一并退役。 + +- [ ] **Step 1: 写失败测试(四个文件)** + +`apps/api/src/modules/org/__tests__/claims.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts'; + +describe('identityOf —— claims 归一成一个人', () => { + it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => { + const id = identityOf({ + sub: 'sub-1', + preferred_username: 'alice', + name: '爱丽丝', + email: 'a@x.test', + realm_access: { roles: ['user', 'default-roles-corp', 'offline_access', 'uma_authorization', 'admin', 'user'] }, + }); + expect(id).toEqual({ sub: 'sub-1', account: 'alice', name: '爱丽丝', email: 'a@x.test', roles: ['admin', 'user'] }); + }); + + it('没有 name(服务账号常见)→ 用用户名;没有角色 → 空数组;没有邮箱 → 不带键', () => { + const id = identityOf({ sub: 'sub-svc', preferred_username: 'service-account-bpos-e2e' }); + expect(id.name).toBe('service-account-bpos-e2e'); + expect(id.roles).toEqual([]); + expect('email' in id).toBe(false); + }); + + it('name 只有空白 → 同样回退;email 空串 → 不带键', () => { + const id = identityOf({ sub: 's', preferred_username: 'bob', name: ' ', email: '' }); + expect(id.name).toBe('bob'); + expect('email' in id).toBe(false); + }); + + it('realm_access 有但 roles 缺 → 空角色', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob', realm_access: {} }).roles).toEqual([]); + }); +}); + +describe('噪声角色', () => { + it('三种家具角色都算噪声,业务角色不算', () => { + expect(isNoiseRole('default-roles-corp')).toBe(true); + expect(isNoiseRole('offline_access')).toBe(true); + expect(isNoiseRole('uma_authorization')).toBe(true); + expect(isNoiseRole('admin')).toBe(false); + }); + it('normalizeRoles 去重、排序、丢空串', () => { + expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']); + }); +}); +``` + +`apps/api/src/modules/org/__tests__/ends.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { RoleAccess } from '@contracts'; +import { AccessLockoutError } from '../contract.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../internal/domain/ends.ts'; + +const table: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + { role: 'designer', ends: ['design'] }, +]; + +describe('endsOf —— 角色命中行的并集', () => { + it('单角色', () => { + expect(endsOf(['user'], table)).toEqual(['workbench']); + }); + it('多角色并集,按 SURFACES 顺序', () => { + expect(endsOf(['user', 'designer'], table)).toEqual(['design', 'workbench']); + }); + it('没有任何行命中 → 空(无端可进)', () => { + expect(endsOf(['auditor'], table)).toEqual([]); + }); + it('表空 → 空', () => { + expect(endsOf(['admin'], [])).toEqual([]); + }); +}); + +describe('assertNotLockedOut —— 改完得有人还能进运营中心', () => { + it('名册空着不算锁死', () => { + expect(() => assertNotLockedOut([], [])).not.toThrow(); + }); + it('唯一的 admin 被摘掉 ops → 拒', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['user']])).toThrow(AccessLockoutError); + }); + it('ops 换到另一个在册角色上 → 过', () => { + const next: RoleAccess[] = [ + { role: 'admin', ends: ['design'] }, + { role: 'ops-lead', ends: ['ops'] }, + ]; + expect(() => assertNotLockedOut(next, [['admin'], ['ops-lead']])).not.toThrow(); + }); + it('有 ops 行但没人持有那个角色 → 拒', () => { + expect(() => assertNotLockedOut([{ role: 'ghost', ends: ['ops'] }], [['user']])).toThrow(AccessLockoutError); + }); +}); + +describe('normalizeEnds', () => { + it('去重、按固定顺序', () => { + expect(normalizeEnds(['workbench', 'design', 'workbench'])).toEqual(['design', 'workbench']); + }); +}); +``` + +`apps/api/src/modules/org/__tests__/roster-diff.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts'; +import type { RosterUser } from '../port.ts'; + +const none = positionIdSchema.parse('none'); +const local = (sub: string, over: Partial = {}): Staff => ({ + id: staffIdSchema.parse(sub), + account: sub, + name: sub, + positionId: none, + state: 'active', + roles: ['user'], + ...over, +}); +const remote = (sub: string, over: Partial = {}): RosterUser => ({ + sub, + account: sub, + name: sub, + roles: ['user'], + enabled: true, + ...over, +}); + +describe('diffRoster —— 远端说了算', () => { + it('远端有本地无 → 插', () => { + const d = diffRoster([remote('a')], []); + expect(d.insert.map((u) => u.sub)).toEqual(['a']); + expect(d.update).toEqual([]); + expect(d.setState).toEqual([]); + }); + it('改名 / 改角色 → 改;没变的不出现', () => { + const d = diffRoster([remote('a', { name: '阿尔法', roles: ['admin', 'user'] }), remote('b')], [local('a'), local('b')]); + expect(d.update).toEqual([{ id: 'a', patch: { account: 'a', name: '阿尔法', roles: ['admin', 'user'] } }]); + expect(d.setState).toEqual([]); + }); + it('远端 enabled=false → 停;本地已停不重复', () => { + const d = diffRoster( + [remote('a', { enabled: false }), remote('b', { enabled: false })], + [local('a'), local('b', { state: 'disabled' })], + ); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('本地有远端无(Keycloak 删了人)→ 停', () => { + const d = diffRoster([], [local('a'), local('b', { state: 'disabled' })]); + expect(d.setState).toEqual([{ id: 'a', state: 'disabled' }]); + }); + it('远端又启用了 → 复活', () => { + const d = diffRoster([remote('a')], [local('a', { state: 'disabled' })]); + expect(d.setState).toEqual([{ id: 'a', state: 'active' }]); + }); +}); + +describe('identityChanged / sameRoles', () => { + it('邮箱从无到有算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'], email: 'a@x' })).toBe(true); + }); + it('一模一样不算变', () => { + expect(identityChanged(local('a'), { sub: 'a', account: 'a', name: 'a', roles: ['user'] })).toBe(false); + }); + it('角色顺序不同不算变', () => { + expect(sameRoles(['a', 'b'], ['b', 'a'])).toBe(true); + expect(sameRoles(['a'], ['a', 'b'])).toBe(false); + }); +}); +``` + +`apps/api/src/modules/org/__tests__/test-account.test.ts`: + +```ts +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import { staffIdSchema } from '@contracts'; +import { isTestAccount, soloGroupOf } from '../internal/domain/test-account.ts'; + +describe('测试账号策略(ADR-011 语义原样)', () => { + it('用户名比对不分大小写、忽略首尾空白;空串永远不算', () => { + expect(isTestAccount('AI', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('Chaihao', ['ai', ' chaihao '])).toBe(true); + expect(isTestAccount('fengbing', ['ai'])).toBe(false); + expect(isTestAccount('', [''])).toBe(false); + }); + it('独立工作组:五个业务岗全指向本人,id 由用户名铸', () => { + const g = soloGroupOf({ id: staffIdSchema.parse('sub-ai'), account: 'service-account-bpos-e2e', name: 'AI' }); + expect(g.id).toBe('wg-solo-service-account-bpos-e2e'); + expect(g.ownerId).toBe('sub-ai'); + expect(g.name).toBe('AI 独立测试组'); + expect(g.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/claims.test.ts apps/api/src/modules/org/__tests__/ends.test.ts apps/api/src/modules/org/__tests__/roster-diff.test.ts apps/api/src/modules/org/__tests__/test-account.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3: `contract.ts` 末尾追加** + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ + readonly account: string; + /** name claim,缺则回退 account */ + readonly name: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; +}; + +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export class TokenRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenRejectedError'; + } +} + +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export class AccountDisabledError extends Error { + constructor(readonly account: string) { + super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`); + this.name = 'AccountDisabledError'; + } +} + +/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */ +export class AccessLockoutError extends Error { + constructor() { + super('改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝'); + this.name = 'AccessLockoutError'; + } +} +``` + +- [ ] **Step 4: `port.ts` 末尾追加(并在文件头 import 加一行 `import type { Identity } from './contract.ts';`)** + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { readonly roles?: readonly string[] }; +}; + +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { verify(token: string): Promise }; + +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { readonly enabled: boolean }; +export type RosterSource = { listUsers(): Promise }; + +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; +``` + +- [ ] **Step 5: `seed-data.ts` 追加常量,`POSITION_SEED` 加哨兵岗** + +在 `POSITION_SEED` 数组的 TEST 项之后加一项: + +```ts + /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */ + { code: 'NONE', name: '未分配', note: '未指派岗位;流程包不得声明它' }, +``` + +文件末尾追加: + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +export const NONE_POSITION_CODE = 'NONE'; +export const TEST_POSITION_CODE = 'TEST'; + +/** + * 缺省的角色 → 端(表为空时播,之后由运营中心改): + * 与手册两个前端的口径一致 —— 用户端要 `user`、管理端要 `admin`。 + */ +export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; +``` + +文件头加 `import type { RoleAccess } from '@contracts';`。 + +- [ ] **Step 6: 五个 domain 文件** + +`apps/api/src/modules/org/internal/domain/claims.ts`: + +```ts +/** + * token claims → 一个人(纯函数,零 IO;ADR-012)。 + * + * 只认手册 §17 列出的那几个 claim;`name` 缺了回退用户名(服务账号常常没有 name)。 + * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: + * `default-roles-` / `offline_access` / `uma_authorization`。 + */ +import type { Identity } from '../../contract.ts'; +import type { VerifiedClaims } from '../../port.ts'; + +const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); + +export function isNoiseRole(role: string): boolean { + return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); +} + +/** 去重、滤噪声、排序 —— 角色快照要能逐字比对(「变了没有」靠它) */ +export function normalizeRoles(roles: readonly string[]): string[] { + return [...new Set(roles.filter((r) => r.trim() !== '' && !isNoiseRole(r)))].sort(); +} + +export function identityOf(claims: VerifiedClaims): Identity { + const account = claims.preferred_username; + const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); + return { + sub: claims.sub, + account, + name, + roles: normalizeRoles(claims.realm_access?.roles ?? []), + ...(email === undefined ? {} : { email }), + }; +} +``` + +`apps/api/src/modules/org/internal/domain/ends.ts`: + +```ts +/** + * 角色 → 端(纯函数,零 IO;ADR-012)。 + * + * 一个人的端 = 他的角色命中的每一行的并集。表可以空、角色可以一行都不命中 —— 那就是「无端可进」。 + * **锁死不变量**:改完之后至少一名在册活跃员工仍能进运营中心,否则这张表再也没人改得了。 + */ +import type { RoleAccess, Surface } from '@contracts'; +import { SURFACES } from '@contracts'; +import { AccessLockoutError } from '../../contract.ts'; + +export function endsOf(roles: readonly string[], table: readonly RoleAccess[]): Surface[] { + const hit = new Set(); + for (const row of table) { + if (!roles.includes(row.role)) continue; + for (const end of row.ends) hit.add(end); + } + return SURFACES.filter((s) => hit.has(s)); +} + +/** 名册空着(全新安装)不算锁死 —— 那时缺省表还在,第一个人进来就能改 */ +export function assertNotLockedOut( + table: readonly RoleAccess[], + activeStaffRoles: ReadonlyArray, +): void { + if (activeStaffRoles.length === 0) return; + const someoneKeepsOps = activeStaffRoles.some((roles) => endsOf(roles, table).includes('ops')); + if (!someoneKeepsOps) throw new AccessLockoutError(); +} + +/** 一行存进表里的样子只有一种:去重、按 SURFACES 的顺序 */ +export function normalizeEnds(ends: readonly Surface[]): Surface[] { + return SURFACES.filter((s) => ends.includes(s)); +} +``` + +`apps/api/src/modules/org/internal/domain/roster-diff.ts`: + +```ts +/** + * 名册同步的差异(纯函数,零 IO;ADR-012)。 + * + * 远端(Keycloak Admin API)说了算: + * 不在本地 → 插;字段变了 → 改;远端 enabled=false 或**根本不在远端** → 停;远端又启用 → 复活。 + * 没变的不出现在结果里 —— 同步一次不该把全表重写一遍。 + */ +import type { Staff, StaffId } from '@contracts'; +import type { Identity } from '../../contract.ts'; +import type { IdentityPatch, RosterUser } from '../../port.ts'; + +export type RosterDiff = { + readonly insert: RosterUser[]; + readonly update: Array<{ readonly id: StaffId; readonly patch: IdentityPatch }>; + readonly setState: Array<{ readonly id: StaffId; readonly state: Staff['state'] }>; +}; + +export function sameRoles(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + return sa.every((x, i) => x === sb[i]); +} + +/** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */ +export function identityChanged(staff: Staff, identity: Identity): boolean { + return ( + staff.account !== identity.account || + staff.name !== identity.name || + staff.email !== identity.email || + !sameRoles(staff.roles, identity.roles) + ); +} + +export function patchOf(identity: Identity): IdentityPatch { + return { + account: identity.account, + name: identity.name, + roles: [...identity.roles], + ...(identity.email === undefined ? {} : { email: identity.email }), + }; +} + +export function diffRoster(remote: readonly RosterUser[], local: readonly Staff[]): RosterDiff { + const bySub = new Map(local.map((s) => [s.id, s])); + const diff: RosterDiff = { insert: [], update: [], setState: [] }; + const seen = new Set(); + for (const user of remote) { + seen.add(user.sub); + const existing = bySub.get(user.sub); + if (existing === undefined) { + diff.insert.push(user); + continue; + } + if (identityChanged(existing, user)) diff.update.push({ id: existing.id, patch: patchOf(user) }); + const wanted: Staff['state'] = user.enabled ? 'active' : 'disabled'; + if (existing.state !== wanted) diff.setState.push({ id: existing.id, state: wanted }); + } + for (const s of local) { + if (!seen.has(s.id) && s.state !== 'disabled') diff.setState.push({ id: s.id, state: 'disabled' }); + } + return diff; +} +``` + +`apps/api/src/modules/org/internal/domain/test-account.ts`: + +```ts +/** + * 测试账号策略(纯函数,零 IO;ADR-011 语义原样搬到 ADR-012 的名册上)。 + * + * 试跑与 e2e 用的账号要能一个人走完整条流:落 TEST 岗(不混进业务岗,免得轮值把真单派给它), + * 再建一个「所有业务岗都指向自己」的独立工作组。 + */ +import type { Staff, WorkGroup } from '@contracts'; +import { SOLO_GROUP_POSITIONS } from './seed-data.ts'; + +export function isTestAccount(account: string, testAccounts: readonly string[]): boolean { + const needle = account.trim().toLowerCase(); + return needle !== '' && testAccounts.some((a) => a.trim().toLowerCase() === needle); +} + +export function soloGroupOf(staff: Pick): WorkGroup { + return { + id: `wg-solo-${staff.account.toLowerCase()}`, + name: `${staff.name} 独立测试组`, + ownerId: staff.id, + members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])), + }; +} +``` + +`apps/api/src/modules/org/internal/domain/in-flight.ts`(原 `disable-check.ts` 的内容改名,旧文件 Task 8 删): + +```ts +/** + * 在途工作(纯逻辑,零 IO;OS 02:员工停用前,其在途任务与待答卡须先改派完毕)。 + * 停用本身已归鉴权中心(ADR-012),这里只剩「摆给人看」:此人身上还挂着什么。 + */ +import type { InFlightWork, StaffId } from '@contracts'; + +export type InFlightInput = { + readonly openTasks: ReadonlyArray<{ taskId: string; title: string }>; + readonly openCards: ReadonlyArray<{ cardId: string; title: string }>; + readonly openApprovals: ReadonlyArray<{ approvalId: string; title: string }>; +}; + +export function buildInFlightWork(staffId: StaffId, work: InFlightInput): InFlightWork { + const openTasks = [...work.openTasks]; + const openCards = [...work.openCards]; + const openApprovals = [...work.openApprovals]; + return { + staffId, + blocked: openTasks.length > 0 || openCards.length > 0 || openApprovals.length > 0, + openTasks, + openCards, + openApprovals, + }; +} +``` + +- [ ] **Step 7: `index.ts` 追加导出(放在既有导出之后)** + +```ts +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ +export type { Identity } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, TokenRejectedError } from './contract.ts'; +export type { IdentityPatch, RosterSource, RosterUser, TokenVerifier, VerifiedClaims } from './port.ts'; +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; +``` + +- [ ] **Step 8: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/modules/org && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 新四个文件全 PASS,旧两个(authorization / password)照旧 PASS;typecheck / lint / depcruise 绿(`roster-diff.ts` 与 `test-account.ts` 被各自的测试引用,不算孤儿)。 + +- [ ] **Step 9: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add apps/api/src/modules/org api-report +git commit -m "org domain:claims 归一、角色→端与锁死不变量、名册差异、测试账号策略(ADR-012,纯函数)" +``` + +--- + +### Task 3: org adapter —— jose 验签(新依赖 `jose`) + +**Files:** +- Modify: `apps/api/package.json`(加 `jose`) +- Create: `apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts` +- Modify: `apps/api/src/modules/org/index.ts`(导出 `createKeycloakTokenVerifier` `explainJoseError`) +- Test: `apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts` + +**Interfaces:** +- Consumes:`TokenVerifier` `VerifiedClaims`(Task 2)、`TokenRejectedError`。 +- Produces:`createKeycloakTokenVerifier({ issuer, audience, getKey, clockTolerance? }): TokenVerifier`;`getKey` 是 jose 的 `JWTVerifyGetKey`(app 给 `createRemoteJWKSet(...)`,测试给 `createLocalJWKSet(...)`)。 + +- [ ] **Step 1: 装依赖** + +```bash +pnpm --filter @app/api add jose@^5.10.0 +``` +Expected:`apps/api/package.json` dependencies 里出现 `"jose": "^5.10.0"`(手册核实过的版本线;`createRemoteJWKSet` / `jwtVerify` / `errors` 在 5 与 6 同 API)。 + +- [ ] **Step 2: 写失败测试** + +`apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts`: + +```ts +/** + * Keycloak 是外界,替身只能在外界:这里用本地密钥对充当它的 JWKS(`createLocalJWKSet`), + * 验签逻辑本身一行不假 —— 与生产走的是同一个 `jwtVerify`。 + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import { SignJWT, UnsecuredJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'; +import type { JWK, JWTVerifyGetKey } from 'jose'; +import { TokenRejectedError } from '../contract.ts'; +import { createKeycloakTokenVerifier } from '../internal/adapter/keycloak-token-verifier.ts'; +import type { TokenVerifier } from '../port.ts'; + +const ISSUER = 'https://keycloak.test/realms/corp'; +const AUDIENCE = 'api-gateway'; + +let privateKey: Awaited>['privateKey']; +let getKey: JWTVerifyGetKey; +let verifier: TokenVerifier; + +beforeAll(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + const jwk: JWK = { ...(await exportJWK(pair.publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }; + getKey = createLocalJWKSet({ keys: [jwk] }); + verifier = createKeycloakTokenVerifier({ issuer: ISSUER, audience: AUDIENCE, getKey }); +}); + +type Mint = { + sub?: string; + issuer?: string; + audience?: string | string[]; + /** 秒级 unix 时刻;缺省 5 分钟后 */ + exp?: number; + kid?: string; + claims?: Record; +}; + +const nowSec = (): number => Math.floor(Date.now() / 1000); + +async function mint(o: Mint = {}): Promise { + const jwt = new SignJWT({ + preferred_username: 'alice', + name: '爱丽丝', + email: 'alice@akhr-ai.com', + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...o.claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: o.kid ?? 'k1' }) + .setSubject(o.sub ?? 'sub-alice') + .setIssuer(o.issuer ?? ISSUER) + .setAudience(o.audience ?? [AUDIENCE, 'account']) + .setIssuedAt() + .setExpirationTime(o.exp ?? nowSec() + 300); + return jwt.sign(privateKey); +} + +describe('Keycloak 验签', () => { + it('RS256 · iss · aud 都对 → 返回 claims', async () => { + const claims = await verifier.verify(await mint()); + expect(claims.sub).toBe('sub-alice'); + expect(claims.preferred_username).toBe('alice'); + expect(claims.name).toBe('爱丽丝'); + expect(claims.realm_access?.roles).toContain('user'); + }); + + it('过期 30 秒内(时钟容差)仍通过', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined(); + }); + + it('过期超过容差 → 拒,话是「已过期」', async () => { + await expect(verifier.verify(await mint({ exp: nowSec() - 120 }))).rejects.toThrow(/已过期/); + }); + + it('aud 不含本服务 → 拒,话点名 audience mapper', async () => { + await expect(verifier.verify(await mint({ audience: ['data-service'] }))).rejects.toThrow(/audience mapper/); + }); + + it('iss 不符 → 拒,话点名 KEYCLOAK_URL', async () => { + await expect(verifier.verify(await mint({ issuer: 'https://other.test/realms/corp' }))).rejects.toThrow(/KEYCLOAK_URL/); + }); + + it('kid 对不上 JWKS → 拒,话点名 JWKS', async () => { + await expect(verifier.verify(await mint({ kid: 'k-unknown' }))).rejects.toThrow(/JWKS/); + }); + + it('HS256 → 拒(算法写死 RS256)', async () => { + const secret = new TextEncoder().encode('0123456789abcdef0123456789abcdef'); + const token = await new SignJWT({ preferred_username: 'alice' }) + .setProtectedHeader({ alg: 'HS256', kid: 'k1' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(secret); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('alg: none → 拒', async () => { + const token = new UnsecuredJWT({ preferred_username: 'alice' }) + .setSubject('sub-alice') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .encode(); + await expect(verifier.verify(token)).rejects.toThrow(TokenRejectedError); + }); + + it('缺 preferred_username → 拒,话点名 client scope', async () => { + const token = await new SignJWT({ realm_access: { roles: ['user'] } }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1' }) + .setSubject('sub-x') + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime(nowSec() + 300) + .sign(privateKey); + await expect(verifier.verify(token)).rejects.toThrow(/preferred_username/); + }); + + it('拒回的一律是 TokenRejectedError(中间件据此 401)', async () => { + await expect(verifier.verify('not-a-jwt')).rejects.toBeInstanceOf(TokenRejectedError); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts` +Expected: FAIL —— adapter 文件不存在。 + +- [ ] **Step 4: 写 adapter** + +`apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts`: + +```ts +/** + * Keycloak 验签(手册 §4;ADR-012)—— 本地计算,不联网(公钥由 getKey 缓存)。 + * + * 四项校验,顺序即安全要点:`algorithms: ['RS256']` 写死(绝不读 header 的 alg)、 + * `issuer`、`audience`(本服务 clientId)、`clockTolerance: 30`。 + * jose 的错误翻成人话(手册 4.4)后包成 TokenRejectedError —— 排错时能省很多时间。 + */ +import { jwtVerify, errors as joseErrors } from 'jose'; +import type { JWTVerifyGetKey } from 'jose'; +import { z } from 'zod'; +import { TokenRejectedError } from '../../contract.ts'; +import type { TokenVerifier, VerifiedClaims } from '../../port.ts'; + +export type KeycloakVerifierOptions = { + readonly issuer: string; + readonly audience: string; + /** app 给 `createRemoteJWKSet(certsUrl, {...})`;测试给 `createLocalJWKSet(jwks)` */ + readonly getKey: JWTVerifyGetKey; + /** 秒;缺省 30 */ + readonly clockTolerance?: number; +}; + +const claimsSchema = z.object({ + sub: z.string().min(1), + preferred_username: z.string().min(1), + name: z.string().optional(), + email: z.string().optional(), + realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), +}); + +/** jose 的错误 → 人话(手册 4.4)。JWTExpired 是 JWTClaimValidationFailed 的子类,先判它 */ +export function explainJoseError(error: unknown, ctx: { readonly issuer: string; readonly audience: string }): string { + if (error instanceof joseErrors.JWTExpired) return 'token 已过期,用 refresh_token 换个新的'; + if (error instanceof joseErrors.JWTClaimValidationFailed) { + if (error.claim === 'aud') { + return `token 的 aud 不含 "${ctx.audience}" —— 去 Keycloak 给发 token 的那个 client 加 audience mapper`; + } + if (error.claim === 'iss') return `token 的 iss 与 "${ctx.issuer}" 不符 —— 检查 realm 名和 KEYCLOAK_URL`; + return `token 的 ${error.claim} 校验未过`; + } + if (error instanceof joseErrors.JWKSNoMatchingKey) { + return 'JWKS 里找不到匹配的公钥 —— token 可能来自别的 realm,或 Keycloak 刚轮换过密钥'; + } + if (error instanceof joseErrors.JWSSignatureVerificationFailed) { + return '签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'; + } + if (error instanceof joseErrors.JOSEAlgNotAllowed) return 'token 的签名算法不被接受 —— 只认 RS256'; + if (error instanceof joseErrors.JWSInvalid || error instanceof joseErrors.JWTInvalid) return 'token 格式不合法'; + return error instanceof Error ? error.message : String(error); +} + +export function createKeycloakTokenVerifier(options: KeycloakVerifierOptions): TokenVerifier { + return { + async verify(token: string): Promise { + let payload: unknown; + try { + ({ payload } = await jwtVerify(token, options.getKey, { + algorithms: ['RS256'], + issuer: options.issuer, + audience: options.audience, + clockTolerance: options.clockTolerance ?? 30, + })); + } catch (error) { + throw new TokenRejectedError(explainJoseError(error, options)); + } + const parsed = claimsSchema.safeParse(payload); + if (!parsed.success) { + throw new TokenRejectedError('token 缺少 sub 或 preferred_username —— 检查 Keycloak 的 client scope 是否带 profile'); + } + return parsed.data; + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +``` + +- [ ] **Step 5: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 10 条 PASS;门禁绿(`jose` 在 adapter,不在 domain / usecase)。 + +- [ ] **Step 6: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add apps/api/package.json pnpm-lock.yaml apps/api/src/modules/org api-report +git commit -m "org adapter:jose 验签 —— RS256 写死、校 iss/aud、30s 容差、错误译人话(ADR-012;新依赖 jose,用户已批)" +``` + +--- + +### Task 4: org adapter —— Keycloak Admin API 拉名册 + +**Files:** +- Create: `apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts` +- Modify: `apps/api/src/modules/org/index.ts`(导出 `createKeycloakRoster`) +- Test: `apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts` + +**Interfaces:** +- Consumes:`RosterSource` `RosterUser`(Task 2)、`isNoiseRole`(Task 2)。 +- Produces:`createKeycloakRoster({ url, realm, clientId, secret, fetch?, now? }): RosterSource`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts`: + +```ts +/** Admin API 是外界 —— fetch 是注入的,这里给一个会分页、会发 token 的假 Keycloak */ +import { describe, expect, it } from 'vitest'; +import { createKeycloakRoster } from '../internal/adapter/keycloak-admin-roster.ts'; + +function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { + const calls: string[] = []; + const users = Array.from({ length: 105 }, (_, i) => ({ + id: `sub-${i}`, + username: `u${i}`, + enabled: i !== 3, + firstName: i === 0 ? '' : '名', + lastName: i === 0 ? '' : `${i}`, + email: `u${i}@x.test`, + })); + const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (url.endsWith('/realms/master/protocol/openid-connect/token')) { + return json({ access_token: 'admin-token', expires_in: 300 }); + } + const u = new URL(url); + if (u.pathname === '/admin/realms/corp/users') { + const first = Number(u.searchParams.get('first')); + const max = Number(u.searchParams.get('max')); + return json(users.slice(first, first + max)); + } + const m = /^\/admin\/realms\/corp\/users\/([^/]+)\/role-mappings\/realm\/composite$/.exec(u.pathname); + if (m !== null) { + return json([ + { name: 'user' }, + { name: 'default-roles-corp' }, + { name: 'offline_access' }, + ...(m[1] === 'sub-1' ? [{ name: 'admin' }] : []), + ]); + } + return json({ error: 'not found' }, 404); + }; + return { fetch: fetchImpl, calls }; +} + +describe('Keycloak Admin API 名册', () => { + it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + const kc = fakeKeycloak(); + const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); + const users = await roster.listUsers(); + expect(users).toHaveLength(105); + expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + expect(users[3]?.enabled).toBe(false); + expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); + }); + + it('管理 token 只取一次(闭包缓存,提前 60 秒过期)', async () => { + const kc = fakeKeycloak(); + let t = 0; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch, now: () => t }); + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(1); + t = 241 * 1000; // expires_in 300 - 60 = 240 秒后才过期 + await roster.listUsers(); + expect(kc.calls.filter((c) => c.includes('/openid-connect/token')).length).toBe(2); + }); + + it('Admin API 非 2xx → 抛带状态码的错(不吞)', async () => { + const fetchImpl: typeof fetch = async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith('/token')) return new Response(JSON.stringify({ access_token: 't', expires_in: 300 }), { status: 200 }); + return new Response('forbidden', { status: 403 }); + }; + const roster = createKeycloakRoster({ url: 'https://kc.test', realm: 'corp', clientId: 'p', secret: 's', fetch: fetchImpl }); + await expect(roster.listUsers()).rejects.toThrow(/403/); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts` +Expected: FAIL —— adapter 文件不存在。 + +- [ ] **Step 3: 写 adapter** + +`apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts`: + +```ts +/** + * 名册来源:Keycloak Admin API(手册 §11 / §14;ADR-012)。 + * + * 用 master realm 的 provisioning 服务账号(client_credentials)取管理 token,只读: + * GET /admin/realms/{realm}/users?first&max=100(列表默认 max=100,必须分页) + * GET /users/{id}/role-mappings/realm/composite(实际生效的 realm 角色) + * 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。 + */ +import type { RosterSource, RosterUser } from '../../port.ts'; +import { isNoiseRole } from '../domain/claims.ts'; + +export type KeycloakRosterOptions = { + readonly url: string; + readonly realm: string; + readonly clientId: string; + readonly secret: string; + /** 测试注入;缺省全局 fetch */ + readonly fetch?: typeof fetch; + /** 毫秒时刻;测试注入 */ + readonly now?: () => number; +}; + +type KcUser = { + readonly id: string; + readonly username: string; + readonly enabled: boolean; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; +type KcRole = { readonly name: string }; + +const PAGE = 100; + +export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSource { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? ((): number => Date.now()); + const base = options.url.replace(/\/+$/, ''); + let cached: { token: string; expiresAt: number } | undefined; + + async function adminToken(): Promise { + if (cached !== undefined && now() < cached.expiresAt) return cached.token; + const resp = await fetchImpl(`${base}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: options.clientId, + client_secret: options.secret, + }), + }); + if (!resp.ok) throw new Error(`取 Keycloak 管理 token 失败:${resp.status} ${await resp.text()}`); + const data = (await resp.json()) as { access_token: string; expires_in: number }; + // 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调 + cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 }; + return cached.token; + } + + async function adminGet(path: string): Promise { + const token = await adminToken(); + const resp = await fetchImpl(`${base}/admin/realms/${options.realm}${path}`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (!resp.ok) throw new Error(`Admin API GET ${path} 失败:${resp.status} ${await resp.text()}`); + return (await resp.json()) as T; + } + + return { + async listUsers(): Promise { + const users: KcUser[] = []; + for (let first = 0; ; first += PAGE) { + const page = await adminGet(`/users?first=${first}&max=${PAGE}&briefRepresentation=false`); + users.push(...page); + if (page.length < PAGE) break; + } + const out: RosterUser[] = []; + for (const u of users) { + const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); + const name = [u.firstName, u.lastName] + .filter((s): s is string => s !== undefined && s.trim() !== '') + .join(' ') + .trim(); + out.push({ + sub: u.id, + account: u.username, + name: name === '' ? u.username : name, + roles: [...new Set(roles.map((r) => r.name).filter((r) => !isNoiseRole(r)))].sort(), + enabled: u.enabled, + ...(u.email === undefined || u.email === '' ? {} : { email: u.email }), + }); + } + return out; + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +``` + +- [ ] **Step 4: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 3 条 PASS;门禁绿。 + +- [ ] **Step 5: 刷新契约快照并提交** + +```bash +pnpm api:check --update +git add apps/api/src/modules/org api-report +git commit -m "org adapter:Keycloak Admin API 拉名册(provisioning 服务账号,分页 100,只读 view-users)" +``` + +--- + +### Task 5: app —— 路由注册表(契约命名空间 → 归属) + +**Files:** +- Create: `apps/api/src/app/route-registry.ts` +- Test: `apps/api/src/app/__tests__/route-registry.test.ts` + +**Interfaces:** +- Consumes:`RouteContract.access` `HTTP_METHODS`(Task 1)。 +- Produces:`RegisteredRoute{method,path,access}`;`RouteRegistry{size, lookup(method, path), has(method, pathPattern)}`;`isRouteContract(value)`;`routesOf(values: readonly unknown[]): RegisteredRoute[]`;`buildRouteRegistry(routes): RouteRegistry`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/app/__tests__/route-registry.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { buildRouteRegistry, isRouteContract, routesOf } from '../route-registry.ts'; + +const registry = buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/testbed/logs', access: 'design' }, + { method: 'GET', path: '/api/gov/testbed/logs/:id', access: 'design' }, + { method: 'POST', path: '/api/gov/process/validate', access: 'design' }, + { method: 'POST', path: '/api/gov/process/:id/:version/state', access: 'design' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, +]); + +describe('路由注册表', () => { + it('字面路径精确命中', () => { + expect(registry.lookup('GET', '/api/gov/tasks')).toBe('ops'); + expect(registry.lookup('GET', '/api/health')).toBe('public'); + }); + it('参数段:一段认一段,多一段少一段都不算', () => { + expect(registry.lookup('POST', '/api/gov/tasks/t-1/control')).toBe('ops'); + expect(registry.lookup('POST', '/api/gov/tasks/t-1/x/control')).toBeUndefined(); + expect(registry.lookup('POST', '/api/gov/process/p1/3/state')).toBe('design'); + }); + it('方法不同不混', () => { + expect(registry.lookup('POST', '/api/gov/tasks')).toBeUndefined(); + expect(registry.lookup('GET', '/api/gov/tasks/t-1/control')).toBeUndefined(); + }); + it('字面优先于参数段(logs 与 logs/:id 各归各)', () => { + expect(registry.lookup('GET', '/api/gov/testbed/logs')).toBe('design'); + expect(registry.lookup('GET', '/api/gov/testbed/logs/abc')).toBe('design'); + expect(registry.lookup('GET', '/api/gov/testbed/logs/abc/def')).toBeUndefined(); + }); + it('未知路由 → undefined', () => { + expect(registry.lookup('GET', '/api/nope')).toBeUndefined(); + }); + it('数组归属原样回', () => { + expect(registry.lookup('GET', '/api/gov/staff')).toEqual(['design', 'ops']); + }); + it('has 按注册时的模版查', () => { + expect(registry.has('POST', '/api/gov/tasks/:taskId/control')).toBe(true); + expect(registry.has('GET', '/api/gov/tasks/:taskId/control')).toBe(false); + expect(registry.has('GET', '/api/health')).toBe(true); + expect(registry.size).toBe(9); + }); +}); + +describe('routesOf —— 从契约命名空间挑路由', () => { + it('只认长得像路由契约的导出', () => { + expect(isRouteContract(contracts.meRoute)).toBe(true); + expect(isRouteContract(contracts.staffSchema)).toBe(false); + expect(isRouteContract('x')).toBe(false); + expect(isRouteContract(null)).toBe(false); + }); + it('扫真契约至少 90 条,且每条都能 has 到自己', () => { + const all = routesOf(Object.values(contracts)); + expect(all.length).toBeGreaterThanOrEqual(90); + const reg = buildRouteRegistry(all); + for (const r of all) expect(reg.has(r.method, r.path), `${r.method} ${r.path}`).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/route-registry.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3: 写 `apps/api/src/app/route-registry.ts`** + +```ts +/** + * 路由注册表(ADR-012)—— 「哪条路由归哪个端」的唯一事实源是契约本身: + * 每条 `RouteContract` 自带 `access`,这里把契约命名空间扫一遍,给中间件一个 `lookup`。 + * 新路由写在契约里就自动生效,不必谁记得再加一行守卫(漏一条就是一个没人管的口子)。 + * + * 匹配:`:name` 段认任意非空段;字面精确命中优先于参数段命中;方法不同不混。 + */ +import type { HttpMethod, RouteAccess, RouteContract } from '@contracts'; +import { HTTP_METHODS } from '@contracts'; + +export type RegisteredRoute = { + readonly method: HttpMethod; + readonly path: string; + readonly access: RouteAccess; +}; + +export type RouteRegistry = { + readonly size: number; + /** 按请求的方法与实际路径查归属;查不到回 undefined(中间件按 any 处置,随后 404) */ + lookup(method: string, path: string): RouteAccess | undefined; + /** 按注册时的路径模版查(覆盖测试用):`has('GET', '/api/gov/tasks/:taskId/log')` */ + has(method: string, pathPattern: string): boolean; +}; + +const METHODS: readonly string[] = HTTP_METHODS; + +export function isRouteContract(value: unknown): value is RouteContract { + if (typeof value !== 'object' || value === null) return false; + const v = value as { method?: unknown; path?: unknown; access?: unknown; output?: unknown }; + return ( + typeof v.method === 'string' && + METHODS.includes(v.method) && + typeof v.path === 'string' && + v.access !== undefined && + v.output !== undefined + ); +} + +/** 从一堆导出里挑出路由契约(`Object.values(contracts)` 喂进来) */ +export function routesOf(values: readonly unknown[]): RegisteredRoute[] { + return values.filter(isRouteContract).map((r) => ({ method: r.method, path: r.path, access: r.access })); +} + +function escapeSegment(segment: string): string { + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matcherOf(pattern: string): RegExp { + const body = pattern + .split('/') + .map((seg) => (seg.startsWith(':') ? '[^/]+' : escapeSegment(seg))) + .join('/'); + return new RegExp(`^${body}$`); +} + +export function buildRouteRegistry(routes: readonly RegisteredRoute[]): RouteRegistry { + const literal = new Map(); + const patterns: Array<{ method: string; pattern: string; re: RegExp; access: RouteAccess }> = []; + for (const r of routes) { + if (r.path.includes(':')) patterns.push({ method: r.method, pattern: r.path, re: matcherOf(r.path), access: r.access }); + else literal.set(`${r.method} ${r.path}`, r.access); + } + return { + size: routes.length, + lookup(method, path) { + const exact = literal.get(`${method} ${path}`); + if (exact !== undefined) return exact; + return patterns.find((p) => p.method === method && p.re.test(path))?.access; + }, + has(method, pathPattern) { + return literal.has(`${method} ${pathPattern}`) || patterns.some((p) => p.method === method && p.pattern === pathPattern); + }, + }; +} +``` + +- [ ] **Step 4: 跑测试** + +Run: `pnpm vitest run apps/api/src/app/__tests__/route-registry.test.ts && pnpm typecheck && pnpm lint` +Expected: 9 条 PASS;绿。 + +- [ ] **Step 5: 提交** + +```bash +git add apps/api/src/app/route-registry.ts apps/api/src/app/__tests__/route-registry.test.ts +git commit -m "app:路由注册表 —— 契约自带 access,一处 lookup 给中间件(ADR-012)" +``` + +--- + +### Task 6: app —— 60 秒票据(HKDF + HMAC) + +**Files:** +- Create: `apps/api/src/app/ticket.ts` +- Test: `apps/api/src/app/__tests__/ticket.test.ts` + +**Interfaces:** +- Produces:`TICKET_TTL_MS = 60_000`;`TicketIssuer{ issue(staffId, now): {ticket, expiresAt}; verify(ticket, now): {staffId} | undefined }`;`createTicketIssuer(masterKey: Buffer): TicketIssuer`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/app/__tests__/ticket.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { TICKET_TTL_MS, createTicketIssuer } from '../ticket.ts'; + +const issuer = createTicketIssuer(Buffer.alloc(32, 7)); +const t0 = new Date('2026-08-25T09:00:00Z'); + +describe('60 秒票据', () => { + it('签出来能验回 sub;形状 v1...;到期时刻 = 签发 + 60s', () => { + const { ticket, expiresAt } = issuer.issue('sub-1', t0); + expect(ticket.split('.')).toHaveLength(4); + expect(ticket.startsWith('v1.')).toBe(true); + expect(expiresAt.getTime() - t0.getTime()).toBe(TICKET_TTL_MS); + expect(issuer.verify(ticket, new Date(t0.getTime() + 30_000))).toEqual({ staffId: 'sub-1' }); + }); + + it('到期即拒(61 秒后)', () => { + const { ticket } = issuer.issue('sub-1', t0); + expect(issuer.verify(ticket, new Date(t0.getTime() + TICKET_TTL_MS + 1000))).toBeUndefined(); + }); + + it('改一个字就拒:mac / sub / exp', () => { + const { ticket } = issuer.issue('sub-1', t0); + const [v, id, exp, mac] = ticket.split('.') as [string, string, string, string]; + expect(issuer.verify(`${v}.${id}.${exp}.${mac.slice(0, -1)}${mac.endsWith('A') ? 'B' : 'A'}`, t0)).toBeUndefined(); + expect(issuer.verify(`${v}.${Buffer.from('sub-2').toString('base64url')}.${exp}.${mac}`, t0)).toBeUndefined(); + expect(issuer.verify(`${v}.${id}.${Number(exp) + 600}.${mac}`, t0)).toBeUndefined(); + }); + + it('别的密钥签的不认', () => { + const other = createTicketIssuer(Buffer.alloc(32, 8)); + expect(issuer.verify(other.issue('sub-1', t0).ticket, t0)).toBeUndefined(); + }); + + it('垃圾输入不抛只拒', () => { + expect(issuer.verify('', t0)).toBeUndefined(); + expect(issuer.verify('v2.a.b.c', t0)).toBeUndefined(); + expect(issuer.verify('a.b', t0)).toBeUndefined(); + expect(issuer.verify('v1...', t0)).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/ticket.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3: 写 `apps/api/src/app/ticket.ts`** + +```ts +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。 + * + * 形状 `v1...`;密钥由 JCAI_SECRET_KEY 经 HKDF 派生(不直接拿主密钥签)。 + * 票据里**没有权限**:中间件拿 sub 回库现算 principal,停用 / 改角色即时生效。 + * 无状态、不单次 —— 60 秒可重放窗换零状态表(记 debt);只在 GET 的 ?ticket= 上认,POST 带了也当没带。 + */ +import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto'; + +export const TICKET_TTL_MS = 60_000; + +export type TicketIssuer = { + issue(staffId: string, now: Date): { ticket: string; expiresAt: Date }; + verify(ticket: string, now: Date): { staffId: string } | undefined; +}; + +export function createTicketIssuer(masterKey: Buffer): TicketIssuer { + const key = Buffer.from(hkdfSync('sha256', masterKey, '', 'jcai-ticket', 32)); + const sign = (body: string): string => createHmac('sha256', key).update(body).digest('base64url'); + + return { + issue(staffId, now) { + const expUnix = Math.floor((now.getTime() + TICKET_TTL_MS) / 1000); + const body = `v1.${Buffer.from(staffId, 'utf8').toString('base64url')}.${expUnix}`; + return { ticket: `${body}.${sign(body)}`, expiresAt: new Date(expUnix * 1000) }; + }, + verify(ticket, now) { + const parts = ticket.split('.'); + if (parts.length !== 4) return undefined; + const [version, idRaw, expRaw, mac] = parts; + if (version !== 'v1' || idRaw === undefined || idRaw === '' || expRaw === undefined || mac === undefined) return undefined; + const expected = sign(`${version}.${idRaw}.${expRaw}`); + const given = Buffer.from(mac, 'utf8'); + const wanted = Buffer.from(expected, 'utf8'); + if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) return undefined; + const expUnix = Number(expRaw); + if (!Number.isInteger(expUnix) || expUnix * 1000 <= now.getTime()) return undefined; + const staffId = Buffer.from(idRaw, 'base64url').toString('utf8'); + return staffId === '' ? undefined : { staffId }; + }, + }; +} +``` + +- [ ] **Step 4: 跑测试** + +Run: `pnpm vitest run apps/api/src/app/__tests__/ticket.test.ts && pnpm typecheck && pnpm lint` +Expected: 5 条 PASS;绿。 + +- [ ] **Step 5: 提交** + +```bash +git add apps/api/src/app/ticket.ts apps/api/src/app/__tests__/ticket.test.ts +git commit -m "app:60 秒 HMAC 票据 —— 给 EventSource 与 下载这两条带不了头的路(ADR-012)" +``` + +--- + +### Task 7: app —— 鉴权中间件(凭据 → Principal → 端判定) + +**Files:** +- Modify: `apps/api/src/app/http-env.ts`(`Variables` 加 `principal`;旧的 `account` / `session` 本任务先留着,Task 8 删) +- Create: `apps/api/src/app/auth-middleware.ts` +- Test: `apps/api/src/app/__tests__/auth-middleware.test.ts` + +**Interfaces:** +- Consumes:`RouteRegistry`(Task 5)、`TicketIssuer`(Task 6)、`TokenRejectedError` / `AccountDisabledError`(Task 2)、`Principal` `SURFACE_LABEL`(Task 1)。 +- Produces:`Authenticator{ authenticate(token): Promise; principalOf(staffId: StaffId): Promise }`;`AuthMiddlewareDeps{registry, org: Authenticator, tickets, clock}`;`createAuthMiddleware(deps): MiddlewareHandler`;`bearerOf(header)` `requiredEnds(access)` `forbiddenBody(need, principal)`。Task 8 的 `OrgService` 结构上满足 `Authenticator`。 + +- [ ] **Step 1: `http-env.ts` 加 `principal`** + +```ts +/** + * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 + */ +import type { Principal, Session } from '@contracts'; + +/** + * 请求上下文。`principal` 由鉴权中间件从 Bearer / 票据解出(ADR-012); + * `account` / `session` 是 ADR-010 的旧物,随 Task 8 一并删除。 + */ +export type AppEnv = { + Variables: { account: string; session: Session | undefined; principal: Principal | undefined }; +}; +``` + +- [ ] **Step 2: 写失败测试** + +`apps/api/src/app/__tests__/auth-middleware.test.ts`: + +```ts +/** 小 Hono + 假的 org 出口:中间件的四个出口(放行 / 401 / 403 / 票据)逐一走一遍 */ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import type { Position, Principal, Surface } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import { bearerOf, createAuthMiddleware, forbiddenBody, requiredEnds } from '../auth-middleware.ts'; +import type { AppEnv } from '../http-env.ts'; +import { buildRouteRegistry } from '../route-registry.ts'; +import { createTicketIssuer } from '../ticket.ts'; + +const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; +const principal = (ends: Surface[], roles: string[]): Principal => ({ + staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles }, + position, + roles, + ends, +}); +const ADMIN = principal(['design', 'ops', 'workbench'], ['admin']); +const USER = principal(['workbench'], ['user']); +const NOBODY = principal([], ['auditor']); + +const tickets = createTicketIssuer(Buffer.alloc(32, 3)); +const now = new Date('2026-08-25T09:00:00Z'); + +function makeApp(): Hono { + const app = new Hono(); + app.use( + '*', + createAuthMiddleware({ + registry: buildRouteRegistry([ + { method: 'GET', path: '/api/health', access: 'public' }, + { method: 'GET', path: '/api/me', access: 'any' }, + { method: 'GET', path: '/api/desk/tasks', access: 'workbench' }, + { method: 'GET', path: '/api/gov/tasks', access: 'ops' }, + { method: 'POST', path: '/api/gov/tasks/:taskId/control', access: 'ops' }, + { method: 'GET', path: '/api/gov/staff', access: ['design', 'ops'] }, + ]), + org: { + authenticate: async (token) => { + if (token === 'admin') return ADMIN; + if (token === 'user') return USER; + if (token === 'nobody') return NOBODY; + if (token === 'disabled') throw new AccountDisabledError('alice'); + throw new TokenRejectedError('签名校验失败 —— token 被篡改,或来自另一个 Keycloak 实例'); + }, + principalOf: async (id) => (id === 'sub-1' ? USER : undefined), + }, + tickets, + clock: { now: () => now }, + }), + ); + app.get('/api/health', (c) => c.json({ ok: true })); + app.get('/api/me', (c) => c.json({ account: c.get('principal')?.staff.account ?? null })); + app.get('/api/desk/tasks', (c) => c.json({ ok: true })); + app.get('/api/gov/tasks', (c) => c.json({ ok: true })); + app.post('/api/gov/tasks/:taskId/control', (c) => c.json({ ok: true })); + app.get('/api/gov/staff', (c) => c.json({ ok: true })); + return app; +} + +const app = makeApp(); +const get = (path: string, headers: Record = {}): Promise => app.request(path, { headers }); +const post = (path: string, headers: Record = {}): Promise => + app.request(path, { method: 'POST', headers }); +type Body = { code: string; message: string; detail?: { need: string[]; ends: string[] } }; + +describe('鉴权中间件', () => { + it('public 不要凭据', async () => { + expect((await get('/api/health')).status).toBe(200); + }); + it('没凭据 → 401', async () => { + const res = await get('/api/me'); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ code: 'unauthenticated', message: '缺少 Bearer token' }); + }); + it('坏 token → 401 带人话', async () => { + const res = await get('/api/me', { authorization: 'Bearer garbage' }); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('签名校验失败'); + }); + it('any:登录即可,连无端可进的人也行', async () => { + const res = await get('/api/me', { authorization: 'Bearer nobody' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ account: 'alice' }); + }); + it('端不够 → 403,说清需要哪个端、你能进哪几个', async () => { + const res = await get('/api/gov/tasks', { authorization: 'Bearer user' }); + expect(res.status).toBe(403); + const body = (await res.json()) as Body; + expect(body.code).toBe('forbidden'); + expect(body.message).toContain('运营中心'); + expect(body.message).toContain('工作台'); + expect(body.detail).toEqual({ need: ['ops'], ends: ['workbench'] }); + }); + it('端够了 → 放行;数组归属任一命中即可', async () => { + expect((await get('/api/gov/tasks', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/desk/tasks', { authorization: 'Bearer user' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer admin' })).status).toBe(200); + expect((await get('/api/gov/staff', { authorization: 'Bearer user' })).status).toBe(403); + }); + it('账号停用 → 403(重新登录没用)', async () => { + const res = await get('/api/me', { authorization: 'Bearer disabled' }); + expect(res.status).toBe(403); + expect(((await res.json()) as Body).message).toContain('停用'); + }); + it('票据:GET 上认,POST 上不认', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`)).status).toBe(200); + expect((await post(`/api/gov/tasks/t-1/control?ticket=${encodeURIComponent(ticket)}`)).status).toBe(401); + }); + it('过期票据 → 401 指路重取', async () => { + const { ticket } = tickets.issue('sub-1', new Date(now.getTime() - 120_000)); + const res = await get(`/api/desk/tasks?ticket=${encodeURIComponent(ticket)}`); + expect(res.status).toBe(401); + expect(((await res.json()) as Body).message).toContain('/api/auth/ticket'); + }); + it('Bearer 与票据同时在:Bearer 说了算', async () => { + const { ticket } = tickets.issue('sub-1', now); + expect((await get(`/api/gov/tasks?ticket=${encodeURIComponent(ticket)}`, { authorization: 'Bearer admin' })).status).toBe(200); + }); + it('未知路由:没凭据先 401,有凭据再 404', async () => { + expect((await get('/api/nope')).status).toBe(401); + expect((await get('/api/nope', { authorization: 'Bearer user' })).status).toBe(404); + }); +}); + +describe('小件', () => { + it('bearerOf 认大小写与空白', () => { + expect(bearerOf('bearer abc')).toBe('abc'); + expect(bearerOf('Bearer abc ')).toBe('abc'); + expect(bearerOf('Basic abc')).toBeUndefined(); + expect(bearerOf(undefined)).toBeUndefined(); + expect(bearerOf('Bearer ')).toBeUndefined(); + }); + it('requiredEnds', () => { + expect(requiredEnds('public')).toBeUndefined(); + expect(requiredEnds('any')).toBeUndefined(); + expect(requiredEnds('ops')).toEqual(['ops']); + expect(requiredEnds(['design', 'ops'])).toEqual(['design', 'ops']); + }); + it('forbiddenBody 对无端可进的人如实说', () => { + expect(forbiddenBody(['design'], NOBODY).message).toContain('无端可进'); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/auth-middleware.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 4: 写 `apps/api/src/app/auth-middleware.ts`** + +```ts +/** + * 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。 + * + * ① 查路由注册表拿 access;public 直接放行。 + * ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / 的路)。 + * ③ Bearer → org.authenticate(验签 + 名册即时更新 + 角色→端);票据 → org.principalOf(从库现算)。 + * ④ 401 = 没凭据 / 凭据坏了(该重新登录);403 = 人对了但进不了这个端 / 账号停用(重新登录没用)。 + * 401 与 403 不混用 —— 前端靠它分辨「该重新登录」和「没权限」(手册 4.5)。 + */ +import type { MiddlewareHandler } from 'hono'; +import type { ApiError, Principal, RouteAccess, StaffId, Surface } from '@contracts'; +import { SURFACE_LABEL, staffIdSchema } from '@contracts'; +import { AccountDisabledError, TokenRejectedError } from '@modules/org'; +import type { AppEnv } from './http-env.ts'; +import type { RouteRegistry } from './route-registry.ts'; +import type { TicketIssuer } from './ticket.ts'; + +/** org 出口里中间件用到的那两口 —— 窄接口,测试好伪造 */ +export type Authenticator = { + authenticate(token: string): Promise; + principalOf(staffId: StaffId): Promise; +}; + +export type AuthMiddlewareDeps = { + readonly registry: RouteRegistry; + readonly org: Authenticator; + readonly tickets: TicketIssuer; + readonly clock: { now(): Date }; +}; + +export function bearerOf(header: string | undefined): string | undefined { + if (header === undefined) return undefined; + const match = /^bearer\s+(\S+)\s*$/i.exec(header.trim()); + return match?.[1]; +} + +/** access 要求的端;public / any 不要求 */ +export function requiredEnds(access: RouteAccess): readonly Surface[] | undefined { + if (access === 'public' || access === 'any') return undefined; + return typeof access === 'string' ? [access] : access; +} + +export function forbiddenBody(need: readonly Surface[], principal: Principal): ApiError { + const needText = need.map((s) => `「${SURFACE_LABEL[s]}」`).join(' / '); + const roles = principal.roles.length === 0 ? '无' : principal.roles.join(', '); + const can = principal.ends.length === 0 ? '无端可进' : principal.ends.map((s) => SURFACE_LABEL[s]).join('、'); + return { + code: 'forbidden', + message: `进入${needText}需要授权;你的角色(${roles})可进:${can}`, + detail: { need: [...need], ends: [...principal.ends] }, + }; +} + +const unauthenticated = (message: string): ApiError => ({ code: 'unauthenticated', message }); + +export function createAuthMiddleware(deps: AuthMiddlewareDeps): MiddlewareHandler { + return async (c, next) => { + c.set('principal', undefined); + // 未知路由按 any 处置:先鉴权再 404,与从前一致(不给没登录的人探路由表) + const access = deps.registry.lookup(c.req.method, c.req.path) ?? 'any'; + if (access === 'public') { + await next(); + return undefined; + } + + const bearer = bearerOf(c.req.header('authorization')); + const ticket = c.req.method === 'GET' ? c.req.query('ticket') : undefined; + if (bearer === undefined && (ticket === undefined || ticket === '')) { + return c.json(unauthenticated('缺少 Bearer token'), 401); + } + + let principal: Principal; + try { + if (bearer !== undefined) { + principal = await deps.org.authenticate(bearer); + } else { + const opened = deps.tickets.verify(ticket ?? '', deps.clock.now()); + if (opened === undefined) { + return c.json(unauthenticated('票据无效或已过期 —— 重新取一张 GET /api/auth/ticket'), 401); + } + const found = await deps.org.principalOf(staffIdSchema.parse(opened.staffId)); + if (found === undefined) return c.json(unauthenticated('票据对应的员工不存在'), 401); + principal = found; + } + } catch (error) { + if (error instanceof TokenRejectedError) return c.json(unauthenticated(error.message), 401); + if (error instanceof AccountDisabledError) { + const body: ApiError = { code: 'forbidden', message: error.message }; + return c.json(body, 403); + } + throw error; + } + + const need = requiredEnds(access); + if (need !== undefined && !need.some((s) => principal.ends.includes(s))) { + return c.json(forbiddenBody(need, principal), 403); + } + + c.set('principal', principal); + await next(); + return undefined; + }; +} +``` + +- [ ] **Step 5: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/app/__tests__/auth-middleware.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 14 条 PASS;绿(`@modules/org` 的两个错误类 Task 2 已导出)。 + +- [ ] **Step 6: 提交** + +```bash +git add apps/api/src/app/http-env.ts apps/api/src/app/auth-middleware.ts apps/api/src/app/__tests__/auth-middleware.test.ts +git commit -m "app:鉴权中间件 —— Bearer / 票据 → Principal → 按路由归属判 401/403(ADR-012)" +``` + +--- + +### Task 8: 大切换 —— org 重写 + app 重接线 + 迁移(一次提交) + +> 这一任务是原子的:org 的新出口与 app 的装配互相依赖,拆开中间态 typecheck 必红。步骤 A(org 模块)与 B(app 层)顺着做,**最后一起跑门禁、一起提交**。先写 org-service 的测试(A1),再实现。 + +**Files(A · org 模块):** +- Modify(整文件替换):`apps/api/src/modules/org/contract.ts` `port.ts` `index.ts` `internal/domain/seed-data.ts` `internal/adapter/org-schema.ts` `internal/adapter/org-repository.ts` `internal/usecase/org-service.ts` +- Delete:`internal/adapter/password.ts` `internal/domain/authorization.ts` `internal/domain/disable-check.ts` `__tests__/authorization.test.ts` `__tests__/password.test.ts` +- Create:`db/migrations/0032_keycloak_identity.sql`;Modify `db/migrations/meta/_journal.json` +- Test:`apps/api/src/modules/org/__tests__/org-service.test.ts` + +**Files(B · app 层):** +- Modify(整文件替换):`apps/api/src/app/config.ts` `http-env.ts` `server.ts` `routes/workbench.ts` `routes/governance.ts` +- Modify(定点):`apps/api/src/app/container.ts` `routes/governance-ops.ts` `routes/desk.ts` `routes/desk-elements.ts` `routes/desk-approval.ts` `routes/registration.ts` `wiring/engines.ts` `seed.ts` `apps/api/tsup.config.ts` `apps/api/package.json`;三份 `__tests__/*.it.test.ts` +- Delete:`apps/api/src/app/session-cookie.ts` `set-password.ts` + +**Interfaces:** +- Consumes:Task 1–7 的一切。 +- Produces:`OrgService`(下文);`createContainer(config, overrides?: { tokenVerifier?, roster? })`;`Container.tickets: TicketIssuer`;`AppEnv = { Variables: { principal } }` + `staffOf(c)`;`createServer` 挂中间件与 410 退役路由;`appRouteRegistry()` `EXTRA_ROUTES`(Task 9 的覆盖测试用)。 + +#### A1 · 写失败测试:`apps/api/src/modules/org/__tests__/org-service.test.ts` + +- [ ] **Step A1** + +```ts +/** + * usecase 测试:仓储全是内存实现(模块内部机制不 mock,只是换个存法), + * 验签是注入的假 verifier(Keycloak 是外界)。 + */ +import { describe, expect, it } from 'vitest'; +import type { Position, RoleAccess, WorkGroup } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import { AccessLockoutError, AccountDisabledError, TokenRejectedError } from '../contract.ts'; +import { createOrgService } from '../internal/usecase/org-service.ts'; +import type { OrgServiceDeps } from '../internal/usecase/org-service.ts'; +import type { RosterSource, StaffRecord, VerifiedClaims } from '../port.ts'; + +type Harness = { + org: ReturnType; + tokens: Map; + staff: Map; + roleAccess: Map; + groups: Map; + positions: Map; + calls: { updateIdentity: number }; + advance(ms: number): void; +}; + +function harness(opts: { testAccounts?: string[]; roster?: RosterSource } = {}): Harness { + const positions = new Map(); + const staff = new Map(); + const roleAccess = new Map(); + const groups = new Map(); + const tokens = new Map(); + const calls = { updateIdentity: 0 }; + let nowMs = Date.parse('2026-08-25T09:00:00Z'); + let seq = 0; + + const deps: OrgServiceDeps = { + verifier: { + verify: async (token) => { + const claims = tokens.get(token); + if (claims === undefined) throw new TokenRejectedError('签名校验失败'); + return claims; + }, + }, + ...(opts.roster === undefined ? {} : { roster: opts.roster }), + positions: { + list: async () => [...positions.values()], + findByCode: async (code) => [...positions.values()].find((p) => p.code === code), + findById: async (id) => positions.get(id), + upsert: async (p) => { + const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase()); + const row: Position = { id, code: p.code, name: p.name, ...(p.note === undefined ? {} : { note: p.note }) }; + positions.set(id, row); + return row; + }, + }, + staff: { + list: async () => [...staff.values()], + findById: async (id) => staff.get(id), + listByPosition: async (pid) => [...staff.values()].filter((s) => s.positionId === pid), + insert: async (s, marks) => { + const row: StaffRecord = { ...s, ...marks }; + staff.set(s.id, row); + return row; + }, + updateIdentity: async (id, patch) => { + calls.updateIdentity += 1; + const { email: _dropped, ...rest } = staff.get(id)!; + staff.set(id, { + ...rest, + account: patch.account, + name: patch.name, + roles: [...patch.roles], + ...(patch.email === undefined ? {} : { email: patch.email }), + ...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }), + }); + }, + setPosition: async (id, pid) => { + staff.set(id, { ...staff.get(id)!, positionId: pid }); + }, + setState: async (id, state, syncedAt) => { + staff.set(id, { ...staff.get(id)!, state, syncedAt }); + }, + }, + roleAccess: { + list: async () => [...roleAccess.values()], + upsert: async (row) => { + roleAccess.set(row.role, row); + return row; + }, + remove: async (role) => { + roleAccess.delete(role); + }, + }, + workGroups: { + listByOwner: async (owner) => [...groups.values()].filter((g) => g.ownerId === owner), + upsert: async (g) => { + groups.set(g.id, g); + return g; + }, + }, + inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] }, + clock: { now: () => new Date(nowMs) }, + ids: { next: () => `id-${(seq += 1)}` }, + testAccounts: opts.testAccounts ?? [], + }; + + return { + org: createOrgService(deps), + tokens, + staff, + roleAccess, + groups, + positions, + calls, + advance: (ms) => { + nowMs += ms; + }, + }; +} + +const claims = (over: Partial & { sub: string; preferred_username: string }): VerifiedClaims => ({ + name: over.preferred_username, + realm_access: { roles: ['user', 'default-roles-corp'] }, + ...over, +}); + +describe('authenticate —— 验签 → 名册即时更新 → 角色→端', () => { + it('首次见到:建档在 NONE 岗、active、角色滤了噪声、端按缺省表', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t-alice', claims({ sub: 'sub-alice', preferred_username: 'alice', name: '爱丽丝', email: 'a@x.test' })); + const p = await h.org.authenticate('t-alice'); + expect(p.staff.id).toBe('sub-alice'); + expect(p.staff.account).toBe('alice'); + expect(p.staff.name).toBe('爱丽丝'); + expect(p.staff.email).toBe('a@x.test'); + expect(p.staff.state).toBe('active'); + expect(p.position.code).toBe('NONE'); + expect(p.roles).toEqual(['user']); + expect(p.ends).toEqual(['workbench']); + }); + + it('admin → 三端', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'root', realm_access: { roles: ['admin'] } })); + expect((await h.org.authenticate('t')).ends).toEqual(['design', 'ops', 'workbench']); + }); + + it('坏 token → TokenRejectedError 原样抛出', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await expect(h.org.authenticate('nope')).rejects.toBeInstanceOf(TokenRejectedError); + }); + + it('60 秒内再来且没变 → 不重写;改了名 → 写回并返回新名', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃' })); + await h.org.authenticate('t'); + h.advance(10_000); + await h.org.authenticate('t'); + expect(h.calls.updateIdentity).toBe(0); + h.advance(61_000); + await h.org.authenticate('t'); + expect(h.calls.updateIdentity).toBe(1); // 过了 60s 只为刷 last_seen_at 写一次 + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob', name: '鲍勃·新' })); + const p = await h.org.authenticate('t'); + expect(p.staff.name).toBe('鲍勃·新'); + expect(h.calls.updateIdentity).toBe(2); + }); + + it('名册标了停用 → 即使 token 有效也抛 AccountDisabledError;principalOf 同款', async () => { + const h = harness(); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 's', preferred_username: 'bob' })); + await h.org.authenticate('t'); + h.staff.set('s', { ...h.staff.get('s')!, state: 'disabled' }); + await expect(h.org.authenticate('t')).rejects.toBeInstanceOf(AccountDisabledError); + await expect(h.org.principalOf(staffIdSchema.parse('s'))).rejects.toBeInstanceOf(AccountDisabledError); + expect(await h.org.principalOf(staffIdSchema.parse('ghost'))).toBeUndefined(); + }); + + it('测试账号:首次建档即 TEST 岗 + 独立工作组(五岗指向本人)', async () => { + const h = harness({ testAccounts: ['service-account-bpos-e2e'] }); + await h.org.ensureSeeded(); + h.tokens.set('t', claims({ sub: 'sub-ai', preferred_username: 'service-account-bpos-e2e', realm_access: { roles: ['admin'] } })); + const p = await h.org.authenticate('t'); + expect(p.position.code).toBe('TEST'); + const group = await h.org.workGroupOf(staffIdSchema.parse('sub-ai')); + expect(group?.members).toEqual({ PM: 'sub-ai', BD: 'sub-ai', FAE: 'sub-ai', PRM: 'sub-ai', OP: 'sub-ai' }); + }); +}); + +describe('名册与岗位', () => { + it('staffOfPosition 只回 active;NONE 恒回 []', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] }); + const b = await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: [] }); + const bd = (await h.org.listStaff()).positions.find((p) => p.code === 'BD')!; + await h.org.setPosition(a.id, bd.id); + await h.org.setPosition(b.id, bd.id); + h.staff.set('b', { ...h.staff.get('b')!, state: 'disabled' }); + expect((await h.org.staffOfPosition('BD')).map((s) => s.id)).toEqual(['a']); + expect(await h.org.staffOfPosition('NONE')).toEqual([]); + }); + + it('syncRoster 未配置 → configured:false', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const outcome = await h.org.syncRoster(); + expect(outcome.configured).toBe(false); + }); + + it('syncRoster:新增 / 改名 / 远端停用 / 本地有远端无 → 停;远端启用 → 复活', async () => { + const remote: RosterSource = { + listUsers: async () => [ + { sub: 'a', account: 'a', name: '阿', roles: ['user'], enabled: true }, + { sub: 'b', account: 'b', name: 'B', roles: ['user'], enabled: false }, + { sub: 'c', account: 'c', name: 'C', roles: ['admin'], enabled: true }, + ], + }; + const h = harness({ roster: remote }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'b', account: 'b', name: 'B', roles: ['user'] }); + await h.org.registerIdentity({ sub: 'z', account: 'z', name: 'Z', roles: ['user'] }); + h.staff.set('c', { ...h.staff.get('a')!, id: staffIdSchema.parse('c'), account: 'c', name: 'C', roles: ['admin'], state: 'disabled' }); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 0, updated: 1, disabled: 2, total: 3 }); + expect(h.staff.get('a')?.name).toBe('阿'); + expect(h.staff.get('b')?.state).toBe('disabled'); + expect(h.staff.get('z')?.state).toBe('disabled'); + expect(h.staff.get('c')?.state).toBe('active'); + }); + + it('syncRoster 插入的新人也走测试账号策略,且远端停用的直接落 disabled', async () => { + const remote: RosterSource = { + listUsers: async () => [{ sub: 'ai', account: 'ai', name: 'AI', roles: ['admin'], enabled: true }, { sub: 'x', account: 'x', name: 'X', roles: [], enabled: false }], + }; + const h = harness({ roster: remote, testAccounts: ['AI'] }); + await h.org.ensureSeeded(); + const outcome = await h.org.syncRoster(); + expect(outcome).toEqual({ configured: true, inserted: 2, updated: 0, disabled: 0, total: 2 }); + expect((await h.org.getStaff(staffIdSchema.parse('ai'))).position.code).toBe('TEST'); + expect(h.staff.get('x')?.state).toBe('disabled'); + }); +}); + +describe('角色 → 端', () => { + it('listRoleAccess:knownRoles = 名册上见过的 ∪ 已配置的', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: ['auditor'] }); + const { roles, knownRoles } = await h.org.listRoleAccess(); + expect(roles.map((r) => r.role).sort()).toEqual(['admin', 'user']); + expect(knownRoles).toEqual(['admin', 'auditor', 'user']); + }); + + it('setRoleAccess 归一端的顺序;唯一 admin 摘 ops → 锁死拒;换到别的在册角色 → 过', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'lead', account: 'lead', name: 'L', roles: ['ops-lead'] }); + expect((await h.org.setRoleAccess('designer', ['workbench', 'design'])).ends).toEqual(['design', 'workbench']); + await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).rejects.toBeInstanceOf(AccessLockoutError); + await h.org.setRoleAccess('ops-lead', ['ops']); + await expect(h.org.setRoleAccess('admin', ['design', 'workbench'])).resolves.toBeDefined(); + await expect(h.org.deleteRoleAccess('ops-lead')).rejects.toBeInstanceOf(AccessLockoutError); + }); + + it('staffWithEnd:能进该端的活跃员工', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'root', account: 'root', name: 'R', roles: ['admin'] }); + await h.org.registerIdentity({ sub: 'u', account: 'u', name: 'U', roles: ['user'] }); + expect((await h.org.staffWithEnd('ops')).map((s) => s.id)).toEqual(['root']); + expect((await h.org.staffWithEnd('workbench')).map((s) => s.id).sort()).toEqual(['root', 'u']); + }); +}); + +describe('ensureSeeded —— 幂等,不覆盖人工改动', () => { + it('岗位含 NONE 与 TEST;缺省两行只在表空时播;改过的行不被重播', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const codes = (await h.org.listStaff()).positions.map((p) => p.code).sort(); + expect(codes).toEqual(['BD', 'FAE', 'NONE', 'OP', 'PM', 'PRM', 'TEST']); + expect([...h.roleAccess.keys()].sort()).toEqual(['admin', 'user']); + h.roleAccess.set('user', { role: 'user', ends: [] }); + await h.org.ensureSeeded(); + expect(h.roleAccess.get('user')?.ends).toEqual([]); + expect(h.positions.size).toBe(7); + }); + + it('播种时给已在册的测试账号补策略', async () => { + const h = harness({ testAccounts: ['chaihao'] }); + await h.org.ensureSeeded(); + const none = (await h.org.listStaff()).positions.find((p) => p.code === 'NONE')!; + h.staff.set('ch', { id: staffIdSchema.parse('ch'), account: 'Chaihao', name: '柴浩', positionId: none.id, state: 'active', roles: [] }); + await h.org.ensureSeeded(); + expect((await h.org.getStaff(staffIdSchema.parse('ch'))).position.code).toBe('TEST'); + expect((await h.org.workGroupOf(staffIdSchema.parse('ch')))?.id).toBe('wg-solo-chaihao'); + }); +}); + +describe('工作组(ADR-011 原样)', () => { + it('建组 / 取组 / 绑定快照', async () => { + const h = harness(); + await h.org.ensureSeeded(); + const a = await h.org.registerIdentity({ sub: 'a', account: 'a', name: 'A', roles: [] }); + const g = await h.org.saveWorkGroup({ name: 'A 组', ownerId: a.id, members: { BD: a.id } }); + expect((await h.org.workGroupOf(a.id))?.id).toBe(g.id); + expect(await h.org.bindingOf(a.id)).toEqual({ BD: 'a' }); + }); +}); +``` + +Run: `pnpm vitest run apps/api/src/modules/org/__tests__/org-service.test.ts` +Expected: FAIL(`createOrgService` 的 deps 形状还是旧的)。 + +#### A2 · `apps/api/src/modules/org/contract.ts`(整文件) + +- [ ] **Step A2** + +```ts +/** + * module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。 + * + * 身份归统一鉴权中心(ADR-012):`Staff.id` 就是 Keycloak 的 `sub`。 + * 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。 + * 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。 + */ +import type { + InFlightWork, + Position, + PositionId, + Principal, + RoleAccess, + RosterSyncOutcome, + Staff, + StaffId, + Surface, + WorkGroup, + WorkGroupBinding, +} from '@contracts'; + +export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; + +/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ +export type StaffWithPosition = Staff & { readonly position: Position }; + +/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */ +export type Identity = { + readonly sub: string; + /** preferred_username */ + readonly account: string; + /** name claim,缺则回退 account */ + readonly name: string; + readonly email?: string; + /** realm 角色,噪声角色已滤、已排序 */ + readonly roles: readonly string[]; +}; + +export class StaffNotFoundError extends Error { + constructor(readonly staffId: StaffId) { + super(`员工不存在:${staffId}`); + this.name = 'StaffNotFoundError'; + } +} + +export class PositionNotFoundError extends Error { + constructor(readonly positionCode: string) { + super(`岗位不存在:${positionCode}`); + this.name = 'PositionNotFoundError'; + } +} + +/** 验签没过 —— message 是手册 4.4 的人话,中间件原样回 401 */ +export class TokenRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenRejectedError'; + } +} + +/** 名册同步标了停用 —— 即使 token 还有效也拒(403);Keycloak 恢复后再同步一次 */ +export class AccountDisabledError extends Error { + constructor(readonly account: string) { + super(`账号已在鉴权中心停用:${account} —— 若已恢复,请在运营中心同步一次名册`); + this.name = 'AccountDisabledError'; + } +} + +/** 角色→端改完之后没有任何在册员工能进运营中心 —— 那张表就再也没人改得了 */ +export class AccessLockoutError extends Error { + constructor() { + super('改完之后没有任何在册员工能进运营中心 —— 这张表就再也改不回来了,拒绝'); + this.name = 'AccessLockoutError'; + } +} + +export type OrgService = { + /* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */ + + /** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */ + authenticate(token: string): Promise; + /** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */ + principalOf(staffId: StaffId): Promise; + /** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */ + registerIdentity(identity: Identity): Promise; + + /* ── 名册与岗位 ───────────────────────────────────────────────────── */ + + listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>; + getStaff(staffId: StaffId): Promise; + /** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */ + staffOfPosition(positionCode: string): Promise; + setPosition(staffId: StaffId, positionId: PositionId): Promise; + /** Admin API 同步;未配 provisioning 回 configured:false */ + syncRoster(): Promise; + /** 此人身上还挂着什么(只读;OS 02 改派先于停用) */ + inFlightOf(staffId: StaffId): Promise; + + /* ── 角色 → 端 ───────────────────────────────────────────────────── */ + + listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>; + /** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */ + setRoleAccess(role: string, ends: readonly Surface[]): Promise; + deleteRoleAccess(role: string): Promise; + /** 能进该端的活跃员工 —— 治理台通知的收件人 */ + staffWithEnd(surface: Surface): Promise; + + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ + + /** 建组 / 改组;id 留空即新建 */ + saveWorkGroup(input: { + id?: string; + name: string; + ownerId: StaffId; + members: Readonly>; + }): Promise; + /** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */ + workGroupOf(staffId: StaffId): Promise; + /** 装进工作流的快照:改组不改已起的单 */ + bindingOf(staffId: StaffId): Promise; + + /** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */ + ensureSeeded(): Promise; +}; +``` + +#### A3 · `apps/api/src/modules/org/port.ts`(整文件) + +- [ ] **Step A3** + +```ts +/** + * module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 + * adapter 实现之,usecase 依赖之,app 装配之。 + */ +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import type { Identity } from './contract.ts'; + +export type Clock = { now(): Date }; + +/* ── 统一鉴权中心(ADR-012) ──────────────────────────────────────────── */ + +/** 验签通过后的 claims —— 只认手册 §17 列的那几个 */ +export type VerifiedClaims = { + readonly sub: string; + readonly preferred_username: string; + readonly name?: string; + readonly email?: string; + readonly realm_access?: { readonly roles?: readonly string[] }; +}; + +/** 验签 —— 实现在 adapter(jose);失败抛 TokenRejectedError;测试注入本地密钥对 */ +export type TokenVerifier = { verify(token: string): Promise }; + +/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */ +export type RosterUser = Identity & { readonly enabled: boolean }; +export type RosterSource = { listUsers(): Promise }; + +/** 名册即时更新 / 同步要改的那几列 */ +export type IdentityPatch = { + readonly account: string; + readonly name: string; + readonly email?: string; + readonly roles: readonly string[]; + readonly lastSeenAt?: Date; +}; + +/* ── 仓储 ─────────────────────────────────────────────────────────────── */ + +export type PositionRepository = { + list(): Promise; + findByCode(code: string): Promise; + findById(id: PositionId): Promise; + upsert(position: Omit & { id?: PositionId }): Promise; +}; + +/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ +export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; + +export type StaffRepository = { + list(): Promise; + findById(id: StaffId): Promise; + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; + updateIdentity(id: StaffId, patch: IdentityPatch): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; + +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; +}; + +/** + * 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`, + * 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。 + */ +export type InFlightWorkProbe = { + openTasksOf(staffId: StaffId): Promise>; + openCardsOf(staffId: StaffId): Promise>; + openApprovalsOf(staffId: StaffId): Promise>; +}; + +/** 工作组仓储(ADR-011) */ +export type WorkGroupRepository = { + listByOwner(ownerId: StaffId): Promise; + upsert(group: WorkGroup): Promise; +}; + +/** ID 生成 —— domain 零随机(§5.3) */ +export type IdGenerator = { next(): string }; +``` + +#### A4 · `apps/api/src/modules/org/internal/domain/seed-data.ts`(整文件) + +- [ ] **Step A4** + +```ts +/** + * 种子数据 —— 纯常量,零 IO。 + * 人不再播种(名册来自鉴权中心,ADR-012);这里只有岗位与缺省的角色→端。 + * 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。 + */ +import type { RoleAccess } from '@contracts'; + +export const NONE_POSITION_CODE = 'NONE'; +export const TEST_POSITION_CODE = 'TEST'; + +export const POSITION_SEED = [ + { code: 'PM', name: '项目经理', note: '定价审批、策略拍板' }, + { code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' }, + { code: 'FAE', name: '现场应用', note: '替代选型' }, + { code: 'PRM', name: '采购/寻源', note: '渠道寻源' }, + { code: 'OP', name: '运营', note: '运营支持' }, + /* + * 测试岗(ADR-011):测试账号单独占一个岗位,**不混进五个业务岗**。 + * 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 —— + * 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。 + */ + { code: TEST_POSITION_CODE, name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' }, + /** 哨兵岗(ADR-012):从鉴权中心来的人先落这里,由运营中心指派岗位;流程包不得声明它 */ + { code: NONE_POSITION_CODE, name: '未分配', note: '未指派岗位;流程包不得声明它' }, +] as const; + +export type PositionSeed = (typeof POSITION_SEED)[number]; + +/** + * 缺省的角色 → 端(表为空时播,之后由运营中心改): + * 与手册两个前端的口径一致 —— 用户端要 `user`、管理端要 `admin`。 + */ +export const DEFAULT_ROLE_ACCESS: readonly RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; + +/** 独立测试组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */ +export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const; +``` + +#### A5 · `apps/api/src/modules/org/internal/adapter/org-schema.ts`(整文件) + +- [ ] **Step A5** + +```ts +/** + * org 的 sys schema 表(编码规范 §7 / ADR-002:sys 由各模块 adapter 经 Drizzle 单写)。 + * drizzle-kit 以 glob 收集本仓全部 `*-schema.ts`(见 drizzle.config.ts)。 + * 会话表与岗位授权表已随 ADR-012 退役(迁移 0032 drop)。 + */ +import { boolean, index, jsonb, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sys } from '@shared/db.ts'; + +export const positionTable = sys.table( + 'position', + { + id: text('id').primaryKey(), + code: text('code').notNull(), + name: text('name').notNull(), + note: text('note'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [uniqueIndex('position_code_key').on(t.code)], +); + +/** 员工 —— `id` 就是 Keycloak 的 sub(ADR-012);`account` 只作显示 */ +export const staffTable = sys.table( + 'staff', + { + id: text('id').primaryKey(), + account: text('account').notNull(), + name: text('name').notNull(), + email: text('email'), + positionId: text('position_id') + .notNull() + .references(() => positionTable.id), + /** 只由名册同步置 disabled;有效 token 不改它 */ + state: text('state').notNull().default('active'), + /** realm 角色快照(签发时;噪声角色已滤) */ + roles: jsonb('roles').$type().notNull().default([]), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + syncedAt: timestamp('synced_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)], +); + +/** 角色 → 端(ADR-012):一行一个 realm 角色,三列布尔 —— 运营中心可改 */ +export const roleAccessTable = sys.table('role_access', { + role: text('role').primaryKey(), + design: boolean('design').notNull().default(false), + ops: boolean('ops').notNull().default(false), + workbench: boolean('workbench').notNull().default(false), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。 + * 起单时按来源人取其组、快照进工作流;这里存的是**当前**成员表,可改。 + */ +export const workGroupTable = sys.table( + 'work_group', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + ownerId: text('owner_id') + .notNull() + .references(() => staffTable.id, { onDelete: 'cascade' }), + members: jsonb('members').notNull().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('work_group_owner_idx').on(t.ownerId)], +); +``` + +#### A6 · `apps/api/src/modules/org/internal/adapter/org-repository.ts`(整文件) + +- [ ] **Step A6** + +```ts +/** + * org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。 + */ +import { eq } from 'drizzle-orm'; +import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; +import { SURFACES, positionIdSchema, positionSchema, staffSchema, workGroupSchema } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { + PositionRepository, + RoleAccessRepository, + StaffRecord, + StaffRepository, + WorkGroupRepository, +} from '../../port.ts'; +import { positionTable, roleAccessTable, staffTable, workGroupTable } from './org-schema.ts'; + +export function createPositionRepository(db: Db): PositionRepository { + const toPosition = (row: typeof positionTable.$inferSelect): Position => + positionSchema.parse({ + id: row.id, + code: row.code, + name: row.name, + ...(row.note === null ? {} : { note: row.note }), + }); + + return { + async list() { + const rows = await db.select().from(positionTable).orderBy(positionTable.code); + return rows.map(toPosition); + }, + async findByCode(code) { + const rows = await db.select().from(positionTable).where(eq(positionTable.code, code)).limit(1); + return rows[0] === undefined ? undefined : toPosition(rows[0]); + }, + async findById(id) { + const rows = await db.select().from(positionTable).where(eq(positionTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toPosition(rows[0]); + }, + async upsert(position) { + const id = position.id ?? positionIdSchema.parse(position.code.toLowerCase()); + const [row] = await db + .insert(positionTable) + .values({ id, code: position.code, name: position.name, note: position.note ?? null }) + .onConflictDoUpdate({ + target: positionTable.code, + set: { name: position.name, note: position.note ?? null }, + }) + .returning(); + if (row === undefined) throw new Error('岗位写入未返回行'); + return toPosition(row); + }, + }; +} + +export function createStaffRepository(db: Db): StaffRepository { + const toRecord = (row: typeof staffTable.$inferSelect): StaffRecord => ({ + ...staffSchema.parse({ + id: row.id, + account: row.account, + name: row.name, + ...(row.email === null ? {} : { email: row.email }), + positionId: row.positionId, + state: row.state, + roles: row.roles, + }), + ...(row.lastSeenAt === null ? {} : { lastSeenAt: row.lastSeenAt }), + ...(row.syncedAt === null ? {} : { syncedAt: row.syncedAt }), + }); + + return { + async list() { + const rows = await db.select().from(staffTable).orderBy(staffTable.account); + return rows.map(toRecord); + }, + async findById(id: StaffId) { + const rows = await db.select().from(staffTable).where(eq(staffTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toRecord(rows[0]); + }, + async listByPosition(positionId: PositionId) { + const rows = await db.select().from(staffTable).where(eq(staffTable.positionId, positionId)); + return rows.map(toRecord); + }, + async insert(staff: Staff, marks) { + const [row] = await db + .insert(staffTable) + .values({ + id: staff.id, + account: staff.account, + name: staff.name, + email: staff.email ?? null, + positionId: staff.positionId, + state: staff.state, + roles: [...staff.roles], + lastSeenAt: marks.lastSeenAt ?? null, + syncedAt: marks.syncedAt ?? null, + }) + .returning(); + if (row === undefined) throw new Error('员工写入未返回行'); + return toRecord(row); + }, + async updateIdentity(id: StaffId, patch) { + await db + .update(staffTable) + .set({ + account: patch.account, + name: patch.name, + email: patch.email ?? null, + roles: [...patch.roles], + ...(patch.lastSeenAt === undefined ? {} : { lastSeenAt: patch.lastSeenAt }), + }) + .where(eq(staffTable.id, id)); + }, + async setPosition(id: StaffId, positionId: PositionId) { + await db.update(staffTable).set({ positionId }).where(eq(staffTable.id, id)); + }, + async setState(id: StaffId, state, syncedAt: Date) { + await db.update(staffTable).set({ state, syncedAt }).where(eq(staffTable.id, id)); + }, + }; +} + +/** 角色 → 端:契约里是端名数组,表里是三列布尔 —— 这里来回翻译 */ +export function createRoleAccessRepository(db: Db): RoleAccessRepository { + const toRow = (r: typeof roleAccessTable.$inferSelect): RoleAccess => ({ + role: r.role, + ends: SURFACES.filter((s) => r[s]), + }); + + return { + async list() { + const rows = await db.select().from(roleAccessTable).orderBy(roleAccessTable.role); + return rows.map(toRow); + }, + async upsert(row: RoleAccess) { + const flags = { + design: row.ends.includes('design'), + ops: row.ends.includes('ops'), + workbench: row.ends.includes('workbench'), + }; + const [saved] = await db + .insert(roleAccessTable) + .values({ role: row.role, ...flags }) + .onConflictDoUpdate({ target: roleAccessTable.role, set: { ...flags, updatedAt: new Date() } }) + .returning(); + if (saved === undefined) throw new Error('角色→端写入未返回行'); + return toRow(saved); + }, + async remove(role: string) { + await db.delete(roleAccessTable).where(eq(roleAccessTable.role, role)); + }, + }; +} + +/** 工作组仓储(ADR-011)—— 成员表整块存 jsonb,键是岗位 code */ +export function createWorkGroupRepository(db: Db): WorkGroupRepository { + const toGroup = (row: typeof workGroupTable.$inferSelect): WorkGroup => + workGroupSchema.parse({ id: row.id, name: row.name, ownerId: row.ownerId, members: row.members }); + + return { + async listByOwner(ownerId: StaffId) { + const rows = await db.select().from(workGroupTable).where(eq(workGroupTable.ownerId, ownerId)); + return rows.map(toGroup); + }, + async upsert(group: WorkGroup) { + const [row] = await db + .insert(workGroupTable) + .values({ id: group.id, name: group.name, ownerId: group.ownerId, members: group.members }) + .onConflictDoUpdate({ + target: workGroupTable.id, + set: { name: group.name, ownerId: group.ownerId, members: group.members }, + }) + .returning(); + if (row === undefined) throw new Error('工作组写入未返回行'); + return toGroup(row); + }, + }; +} +``` + +#### A7 · `apps/api/src/modules/org/internal/usecase/org-service.ts`(整文件) + +- [ ] **Step A7** + +```ts +/** + * org 用例 —— 编排 domain,依赖 port.ts 声明的能力,不 import 任何 adapter(编码规范 §5.3)。 + * + * 身份归统一鉴权中心(ADR-012): + * authenticate = 验签 → claims 归一 → 名册即时更新(JIT)→ 角色 × 角色→端表 → Principal。 + * `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 —— + * 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。 + */ +import type { Position, Principal, RoleAccess, Staff } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts'; +import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts'; +import type { + Clock, + IdGenerator, + InFlightWorkProbe, + PositionRepository, + RoleAccessRepository, + RosterSource, + StaffRecord, + StaffRepository, + TokenVerifier, + WorkGroupRepository, +} from '../../port.ts'; +import { identityOf } from '../domain/claims.ts'; +import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts'; +import { buildInFlightWork } from '../domain/in-flight.ts'; +import { diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts'; +import { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from '../domain/seed-data.ts'; +import { isTestAccount, soloGroupOf } from '../domain/test-account.ts'; + +export type OrgServiceDeps = { + readonly verifier: TokenVerifier; + /** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */ + readonly roster?: RosterSource; + readonly positions: PositionRepository; + readonly staff: StaffRepository; + readonly roleAccess: RoleAccessRepository; + readonly workGroups: WorkGroupRepository; + readonly inFlight: InFlightWorkProbe; + readonly clock: Clock; + readonly ids: IdGenerator; + /** 测试账号(Keycloak 用户名):首次建档与播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; +}; + +/** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */ +const LAST_SEEN_REWRITE_MS = 60_000; + +/** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */ +function toStaff(record: StaffRecord): Staff { + const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record; + return staff; +} + +export function createOrgService(deps: OrgServiceDeps): OrgService { + async function positionMap(): Promise> { + const all = await deps.positions.list(); + return new Map(all.map((p) => [p.id, p])); + } + + async function withPosition(staff: Staff, positions?: Map): Promise { + const map = positions ?? (await positionMap()); + const position = map.get(staff.positionId); + if (!position) throw new PositionNotFoundError(staff.positionId); + return { ...staff, position }; + } + + async function positionByCode(code: string): Promise { + const position = await deps.positions.findByCode(code); + if (!position) throw new PositionNotFoundError(code); + return position; + } + + async function principalFor(record: StaffRecord): Promise { + if (record.state !== 'active') throw new AccountDisabledError(record.account); + const staff = toStaff(record); + const position = await deps.positions.findById(staff.positionId); + if (!position) throw new PositionNotFoundError(staff.positionId); + const table = await deps.roleAccess.list(); + return { staff, position, roles: [...staff.roles], ends: endsOf(staff.roles, table) }; + } + + /** 测试账号策略(ADR-011):TEST 岗 + 独立工作组;不是测试账号就什么都不做 */ + async function applyTestAccountPolicy(staff: Staff): Promise { + if (!isTestAccount(staff.account, deps.testAccounts)) return; + const none = await positionByCode(NONE_POSITION_CODE); + if (staff.positionId === none.id) { + const test = await positionByCode(TEST_POSITION_CODE); + await deps.staff.setPosition(staff.id, test.id); + } + if ((await deps.workGroups.listByOwner(staff.id)).length === 0) { + await deps.workGroups.upsert(soloGroupOf(staff)); + } + } + + async function activeStaffRoles(): Promise { + return (await deps.staff.list()).filter((s) => s.state === 'active').map((s) => [...s.roles]); + } + + async function insertPerson( + identity: Identity, + state: Staff['state'], + marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }, + ): Promise { + const none = await positionByCode(NONE_POSITION_CODE); + const created = await deps.staff.insert( + { + id: staffIdSchema.parse(identity.sub), + account: identity.account, + name: identity.name, + ...(identity.email === undefined ? {} : { email: identity.email }), + positionId: none.id, + state, + roles: [...identity.roles], + }, + marks, + ); + await applyTestAccountPolicy(toStaff(created)); + const fresh = await deps.staff.findById(created.id); + return toStaff(fresh ?? created); + } + + const service: OrgService = { + async authenticate(token) { + const claims = await deps.verifier.verify(token); + await service.registerIdentity(identityOf(claims)); + const id = staffIdSchema.parse(claims.sub); + const record = await deps.staff.findById(id); + if (record === undefined) throw new StaffNotFoundError(id); + return principalFor(record); + }, + + async principalOf(staffId) { + const record = await deps.staff.findById(staffId); + return record === undefined ? undefined : principalFor(record); + }, + + async registerIdentity(identity) { + const now = deps.clock.now(); + const id = staffIdSchema.parse(identity.sub); + const existing = await deps.staff.findById(id); + if (existing === undefined) return insertPerson(identity, 'active', { lastSeenAt: now }); + + const changed = identityChanged(existing, identity); + const stale = + existing.lastSeenAt === undefined || now.getTime() - existing.lastSeenAt.getTime() >= LAST_SEEN_REWRITE_MS; + if (!changed && !stale) return toStaff(existing); + await deps.staff.updateIdentity(id, { ...patchOf(identity), lastSeenAt: now }); + const fresh = await deps.staff.findById(id); + return toStaff(fresh ?? existing); + }, + + async listStaff() { + const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]); + const map = new Map(positions.map((p) => [p.id, p])); + const staff = await Promise.all(rows.map((s) => withPosition(toStaff(s), map))); + return { staff, positions }; + }, + + async getStaff(staffId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + return withPosition(toStaff(record)); + }, + + async staffOfPosition(positionCode) { + if (positionCode === NONE_POSITION_CODE) return []; + const position = await positionByCode(positionCode); + const rows = await deps.staff.listByPosition(position.id); + return rows.filter((s) => s.state === 'active').map((s) => ({ ...toStaff(s), position })); + }, + + async setPosition(staffId, positionId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + const position = await deps.positions.findById(positionId); + if (!position) throw new PositionNotFoundError(positionId); + await deps.staff.setPosition(staffId, positionId); + return { ...toStaff(record), positionId, position }; + }, + + async syncRoster(): Promise { + if (deps.roster === undefined) { + return { + configured: false, + message: '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 名册只能靠首次登录时自动建档', + }; + } + const remote = await deps.roster.listUsers(); + const local = await deps.staff.list(); + const diff = diffRoster(remote, local); + const now = deps.clock.now(); + for (const user of diff.insert) await insertPerson(user, user.enabled ? 'active' : 'disabled', { syncedAt: now }); + for (const u of diff.update) await deps.staff.updateIdentity(u.id, u.patch); + for (const s of diff.setState) await deps.staff.setState(s.id, s.state, now); + return { + configured: true, + inserted: diff.insert.length, + updated: diff.update.length, + disabled: diff.setState.filter((s) => s.state === 'disabled').length, + total: remote.length, + }; + }, + + async inFlightOf(staffId) { + const record = await deps.staff.findById(staffId); + if (!record) throw new StaffNotFoundError(staffId); + const [openTasks, openCards, openApprovals] = await Promise.all([ + deps.inFlight.openTasksOf(staffId), + deps.inFlight.openCardsOf(staffId), + deps.inFlight.openApprovalsOf(staffId), + ]); + return buildInFlightWork(staffId, { openTasks, openCards, openApprovals }); + }, + + async listRoleAccess() { + const rows = await deps.roleAccess.list(); + const seen = new Set(rows.map((r) => r.role)); + for (const s of await deps.staff.list()) for (const r of s.roles) seen.add(r); + return { roles: rows, knownRoles: [...seen].sort() }; + }, + + async setRoleAccess(role, ends) { + const row: RoleAccess = { role, ends: normalizeEnds(ends) }; + const current = await deps.roleAccess.list(); + assertNotLockedOut([...current.filter((r) => r.role !== role), row], await activeStaffRoles()); + return deps.roleAccess.upsert(row); + }, + + async deleteRoleAccess(role) { + const current = await deps.roleAccess.list(); + assertNotLockedOut( + current.filter((r) => r.role !== role), + await activeStaffRoles(), + ); + await deps.roleAccess.remove(role); + }, + + async staffWithEnd(surface) { + const table = await deps.roleAccess.list(); + const { staff } = await service.listStaff(); + return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface)); + }, + + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */ + + async saveWorkGroup(input) { + const record = await deps.staff.findById(input.ownerId); + if (!record) throw new StaffNotFoundError(input.ownerId); + return deps.workGroups.upsert({ + id: input.id ?? deps.ids.next(), + name: input.name, + ownerId: input.ownerId, + members: { ...input.members }, + }); + }, + + async workGroupOf(staffId) { + // 一人一组:多组时取稳定的第一条(选组这一步 MVP 不做,见 debt) + const groups = await deps.workGroups.listByOwner(staffId); + return [...groups].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0]; + }, + + async bindingOf(staffId) { + const group = await service.workGroupOf(staffId); + return group === undefined ? undefined : { ...group.members }; + }, + + async ensureSeeded() { + for (const seed of POSITION_SEED) { + const existing = await deps.positions.findByCode(seed.code); + if (existing !== undefined) continue; + await deps.positions.upsert({ + id: positionIdSchema.parse(deps.ids.next()), + code: seed.code, + name: seed.name, + note: seed.note, + }); + } + // 缺省的角色→端只在表空时播 —— 之后是运营中心的事,播种不覆盖人工改动 + if ((await deps.roleAccess.list()).length === 0) { + for (const row of DEFAULT_ROLE_ACCESS) await deps.roleAccess.upsert(row); + } + // 已在册的测试账号补策略(名册先同步后配 JCAI_TEST_ACCOUNTS 的那种顺序) + for (const record of await deps.staff.list()) { + if (isTestAccount(record.account, deps.testAccounts)) await applyTestAccountPolicy(toStaff(record)); + } + }, + }; + + return service; +} +``` + +四个类型 import 都在文件里用到(`Position` 岗位表、`Principal` 返回值、`RoleAccess` 行、`Staff` 剥时刻);再多一个 lint 就会报未用。 + +#### A8 · `apps/api/src/modules/org/index.ts`(整文件) + +- [ ] **Step A8** + +```ts +/** + * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + Identity, + InFlightWork, + OrgService, + Position, + Principal, + RoleAccess, + RosterSyncOutcome, + Staff, + StaffWithPosition, + Surface, + WorkGroup, + WorkGroupBinding, +} from './contract.ts'; +export { + AccessLockoutError, + AccountDisabledError, + PositionNotFoundError, + StaffNotFoundError, + TokenRejectedError, +} from './contract.ts'; + +export type { + Clock, + IdGenerator, + IdentityPatch, + InFlightWorkProbe, + PositionRepository, + RoleAccessRepository, + RosterSource, + RosterUser, + StaffRecord, + StaffRepository, + TokenVerifier, + VerifiedClaims, + WorkGroupRepository, +} from './port.ts'; + +export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; +export { createOrgService } from './internal/usecase/org-service.ts'; + +export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; +export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { buildInFlightWork } from './internal/domain/in-flight.ts'; +export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; + +export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; +export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; +export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; +export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { + createPositionRepository, + createRoleAccessRepository, + createStaffRepository, + createWorkGroupRepository, +} from './internal/adapter/org-repository.ts'; +export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; +``` + +#### A9 · 删旧文件、写迁移 + +- [ ] **Step A9** + +```bash +git rm apps/api/src/modules/org/internal/adapter/password.ts \ + apps/api/src/modules/org/internal/domain/authorization.ts \ + apps/api/src/modules/org/internal/domain/disable-check.ts \ + apps/api/src/modules/org/__tests__/authorization.test.ts \ + apps/api/src/modules/org/__tests__/password.test.ts +``` + +`db/migrations/0032_keycloak_identity.sql`: + +```sql +-- 身份归统一鉴权中心(ADR-012):会话表与岗位授权表退役;员工 id 从此就是 Keycloak 的 sub。 +-- +-- 从前(ADR-010)是自建账号口令 + 服务端会话表 + 岗位授权(客体三类 × 动作三种)。 +-- 现在:后端只验 Bearer JWT;权限只到「端」(design / ops / workbench), +-- 角色 → 端是运营中心可改的一张表;名册从 Keycloak 同步、岗位本地指派。 +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; +``` + +登记进 `db/migrations/meta/_journal.json`: + +```bash +python3 - <<'EOF' +import json +p = 'db/migrations/meta/_journal.json' +j = json.load(open(p, encoding='utf-8')) +assert j['entries'][-1]['idx'] == 31, j['entries'][-1] +j['entries'].append({"idx": 32, "version": "7", "when": 1785130120180, "tag": "0032_keycloak_identity", "breakpoints": True}) +with open(p, 'w', encoding='utf-8') as f: + json.dump(j, f, indent=2, ensure_ascii=False) + f.write('\n') +EOF +``` + +- [ ] **Step A10: 跑 org 的单元测试(此时 app 层还没接线,typecheck 会红 —— 先只跑测试)** + +Run: `pnpm vitest run apps/api/src/modules/org` +Expected: `org-service.test.ts` 16 条 + 之前六个文件全 PASS。若 `org-service.test.ts` 里 `syncRoster` 那条的 `updated`/`disabled` 计数对不上,先核 `diffRoster` 的输入(本地那条 `c` 是手工塞进 map 的、state 为 disabled;远端 `c` enabled → 复活计入 setState 但不计入 disabled)。 + +#### B1 · `apps/api/src/app/config.ts`(整文件) + +- [ ] **Step B1** + +```ts +/** + * 环境配置 —— 只在 app/ 层读环境变量;模块内一律经 port 注入(§5.3)。 + */ +import { join } from 'node:path'; + +/** 统一鉴权中心(ADR-012)。本服务只验不调,**不配 client secret**(手册 13.2) */ +export type KeycloakConfig = { + readonly url: string; + readonly realm: string; + /** 校 aud 用 —— 填错全线 401 */ + readonly clientId: string; + readonly issuer: string; + readonly jwksUrl: string; + /** master realm 的 provisioning 服务账号(只读名册,view-users);可选 */ + readonly provisioning?: { readonly clientId: string; readonly secret: string }; +}; + +export type AppConfig = { + readonly nodeEnv: 'development' | 'test' | 'production'; + readonly port: number; + readonly databaseUrl: string; + /** 原件存档根目录 —— 信封的原件指针指向这里 */ + readonly attachmentRoot: string; + /** 派发器轮询间隔(毫秒) */ + readonly dispatchIntervalMs: number; + /** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */ + readonly keycloak?: KeycloakConfig; + /** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */ + readonly testAccounts: readonly string[]; +}; + +const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos'; + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +function keycloakOf(env: NodeJS.ProcessEnv): KeycloakConfig | undefined { + const url = nonEmpty(env['KEYCLOAK_URL'])?.replace(/\/+$/, ''); + const realm = nonEmpty(env['KEYCLOAK_REALM']); + const clientId = nonEmpty(env['KEYCLOAK_CLIENT_ID']); + if (url === undefined || realm === undefined || clientId === undefined) return undefined; + const issuer = `${url}/realms/${realm}`; + const provisioningId = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_ID']); + const provisioningSecret = nonEmpty(env['KEYCLOAK_PROVISIONING_CLIENT_SECRET']); + return { + url, + realm, + clientId, + issuer, + jwksUrl: `${issuer}/protocol/openid-connect/certs`, + ...(provisioningId === undefined || provisioningSecret === undefined + ? {} + : { provisioning: { clientId: provisioningId, secret: provisioningSecret } }), + }; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + const nodeEnv = env['NODE_ENV']; + const keycloak = keycloakOf(env); + return { + nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development', + port: Number(env['PORT'] ?? 8787), + databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL, + attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'), + dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000), + ...(keycloak === undefined ? {} : { keycloak }), + testAccounts: (env['JCAI_TEST_ACCOUNTS'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), + }; +} +``` + +#### B2 · `apps/api/src/app/http-env.ts`(整文件) + +- [ ] **Step B2** + +```ts +/** + * Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。 + */ +import type { Context } from 'hono'; +import type { Position, Principal, Staff } from '@contracts'; + +/** + * 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 —— + * 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。 + */ +export type AppEnv = { Variables: { principal: Principal | undefined } }; + +/** + * 当前登录者(连同岗位)。非公开路由上中间件已保证存在;公开路由上是 undefined —— + * 调用方照旧判一下 undefined,免得哪天有人把一条路由改成 public 还在读它。 + */ +export function staffOf(c: Context): (Staff & { readonly position: Position }) | undefined { + const principal = c.get('principal'); + return principal === undefined ? undefined : { ...principal.staff, position: principal.position }; +} +``` + +#### B3 · `apps/api/src/app/container.ts`(定点改) + +- [ ] **Step B3** + +(1)把 org 的 import 段 + +```ts +import type { OrgService, InFlightWorkProbe } from '@modules/org'; +import { + createGrantRepository, + createOrgService, + createPositionRepository, + createSessionRepository, + createStaffRepository, + createWorkGroupRepository, + hashPassword, + newSessionToken, + verifyPassword, +} from '@modules/org'; +``` + +换成 + +```ts +import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org'; +import { + createKeycloakRoster, + createKeycloakTokenVerifier, + createOrgService, + createPositionRepository, + createRoleAccessRepository, + createStaffRepository, + createWorkGroupRepository, + TokenRejectedError, +} from '@modules/org'; +import { createRemoteJWKSet } from 'jose'; +import { createTicketIssuer } from './ticket.ts'; +import type { TicketIssuer } from './ticket.ts'; +``` + +(2)`Container` 类型里 `readonly org: OrgService;` 之后加一行: + +```ts + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + readonly tickets: TicketIssuer; +``` + +(3)`export async function createContainer(config: AppConfig): Promise {` 之前加类型、并改签名: + +```ts +/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */ +export type ContainerOverrides = { + readonly tokenVerifier?: TokenVerifier; + readonly roster?: RosterSource; +}; + +export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise { +``` + +(4)`const secrets = createSecretBox(loadSecretKey());` 换成 + +```ts + const masterKey = loadSecretKey(); + const secrets = createSecretBox(masterKey); + const tickets = createTicketIssuer(masterKey); +``` + +(5)把 `const org = createOrgService({ … seedPassword: config.seedPassword, ids, });` 整块换成 + +```ts + /* + * 身份归统一鉴权中心(ADR-012):验签走 jose 远程 JWKS(首次用到才拉、缓存 10 分钟、 + * 30 秒冷却、5 秒超时 —— 手册 4.1);没配 KEYCLOAK_* 就装一个恒拒的 verifier: + * 集成测试走用例不走 HTTP,不受影响;真跑起来缺配置的话 main.ts 早就拒绝启动了。 + */ + const keycloak = config.keycloak; + const verifier: TokenVerifier = + overrides.tokenVerifier ?? + (keycloak === undefined + ? { + verify: async () => { + throw new TokenRejectedError('未配置 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID'); + }, + } + : createKeycloakTokenVerifier({ + issuer: keycloak.issuer, + audience: keycloak.clientId, + getKey: createRemoteJWKSet(new URL(keycloak.jwksUrl), { + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000, + timeoutDuration: 5_000, + }), + })); + const roster: RosterSource | undefined = + overrides.roster ?? + (keycloak?.provisioning === undefined + ? undefined + : createKeycloakRoster({ + url: keycloak.url, + realm: keycloak.realm, + clientId: keycloak.provisioning.clientId, + secret: keycloak.provisioning.secret, + })); + + const org = createOrgService({ + verifier, + ...(roster === undefined ? {} : { roster }), + positions: createPositionRepository(db), + staff: createStaffRepository(db), + roleAccess: createRoleAccessRepository(db), + workGroups: createWorkGroupRepository(db), + inFlight: inFlightProbe, + clock: systemClock, + ids, + testAccounts: config.testAccounts, + }); +``` + +(6)message 装配里的 `governanceStaff` 整块 + +```ts + governanceStaff: async () => { + const { staff } = await org.listStaff(); + const governing = await Promise.all( + staff.map(async (s) => ({ + s, + ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'), + })), + ); + return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id })); + }, +``` + +换成 + +```ts + // 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端) + governanceStaff: async () => (await org.staffWithEnd('ops')).map((s) => ({ id: s.id })), +``` + +(7)`return { config, broadcaster, pool, db, org,` 里 `org,` 之后加 `tickets,`。 + +#### B4 · `apps/api/src/app/server.ts`(整文件) + +- [ ] **Step B4** + +```ts +/** + * Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。 + * 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。 + */ +import { Hono } from 'hono'; +import * as contracts from '@contracts'; +import type { ApiError, RouteContract } from '@contracts'; +import { + createStaffRoute, + disableStaffCheckRoute, + disableStaffRoute, + listGrantsRoute, + loginRoute, + logoutRoute, + sessionRoute, + setGrantsRoute, +} from '@contracts'; +import type { Container } from './container.ts'; +import type { AppEnv } from './http-env.ts'; +import { createAuthMiddleware } from './auth-middleware.ts'; +import { buildRouteRegistry, routesOf } from './route-registry.ts'; +import type { RegisteredRoute, RouteRegistry } from './route-registry.ts'; +import { systemClock } from './runtime.ts'; +import { registerDeskRoutes } from './routes/desk.ts'; +import { registerDeskElementRoutes } from './routes/desk-elements.ts'; +import { registerDeskApprovalRoutes } from './routes/desk-approval.ts'; +import { registerRegistrationRoutes } from './routes/registration.ts'; +import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; +import { registerGovernanceRoutes } from './routes/governance.ts'; +import { registerIngestRoutes } from './routes/ingest.ts'; +import { registerStreamRoute } from './routes/stream.ts'; +import { registerWorkbenchRoutes } from './routes/workbench.ts'; + +export type { AppEnv }; + +/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */ +export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }]; + +/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */ +export function appRouteRegistry(): RouteRegistry { + return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]); +} + +/** + * 随统一鉴权中心退役的路由(§12.4 三步之第二步):契约留一轮给 web 编译,后端一律 410 并指路。 + * 它们的 access 是 public —— 410 不需要凭据,也不泄漏任何东西。 + */ +const RETIRED_ROUTES: ReadonlyArray<{ readonly route: RouteContract; readonly hint: string }> = [ + { route: loginRoute, hint: '登录由 Keycloak 完成,请求带 Authorization: Bearer ' }, + { route: logoutRoute, hint: '登出在前端丢弃 token,或调 Keycloak 的 end-session' }, + { route: sessionRoute, hint: '改用 GET /api/me' }, + { route: createStaffRoute, hint: '人来自鉴权中心 —— 首次登录自动建档,或 POST /api/gov/staff/sync 同步名册' }, + { route: disableStaffCheckRoute, hint: '改用 GET /api/gov/staff/:staffId/in-flight' }, + { route: disableStaffRoute, hint: '在 Keycloak 停用账号后 POST /api/gov/staff/sync' }, + { route: listGrantsRoute, hint: '岗位授权已退役,改用 GET /api/gov/access/roles(角色→端)' }, + { route: setGrantsRoute, hint: '岗位授权已退役,改用 PUT /api/gov/access/roles/:role' }, +]; + +export function createServer(container: Container): Hono { + const app = new Hono(); + + /** + * 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。 + * 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判, + * 路由里不再各写守卫 —— 一处拦住,新路由自动生效。 + */ + app.use( + '*', + createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }), + ); + + /** + * 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。 + * + * 从前 `kind:'system'` 只在类型里声明过,**全仓一处也没发过** —— 于是治理台的 + * 全部配置面(活动库 / 工具箱 / 大模型 / 流程包 / 数据域 / 驾驶舱指标 / 触发器) + * 只有自己那一次 mutation 之后的本地失效才刷新:换一个浏览器标签、或另一个人改了配置, + * 这边纹丝不动,直到手动刷新。 + * + * 放在中间件而不是逐个路由里,是因为治理写路由有二十几条、还会长 —— + * 挨个加一行的写法,漏一条就是一处**看不见的**不同步(这类漏最难发现: + * 它不报错,只是画面比事实旧)。这里一处拦住,新增路由自动生效。 + * + * 只在**成功**(2xx)之后发;推送不带载荷,前端照旧回服务端重取。 + */ + app.use('/api/gov/*', async (c, next) => { + await next(); + const mutating = c.req.method !== 'GET' && c.req.method !== 'HEAD'; + if (mutating && c.res.status >= 200 && c.res.status < 300) { + container.broadcaster.publish({ kind: 'system', id: 'gov', reason: `${c.req.method} ${c.req.path}` }); + } + }); + + app.onError((error, c) => { + const body: ApiError = { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + }; + process.stderr.write(`[api] ${body.message}\n`); + return c.json(body, 500); + }); + + app.notFound((c) => { + const body: ApiError = { code: 'not-found', message: `无此路由:${c.req.path}` }; + return c.json(body, 404); + }); + + for (const { route, hint } of RETIRED_ROUTES) { + app.on(route.method, route.path, (c) => { + const body: ApiError = { code: 'gone', message: `已随统一鉴权中心退役:${hint}` }; + return c.json(body, 410); + }); + } + + registerWorkbenchRoutes(app, container); + registerGovernanceRoutes(app, container); + registerDeskRoutes(app, container); + registerDeskElementRoutes(app, container); + registerDeskApprovalRoutes(app, container); + registerRegistrationRoutes(app, container); + registerGovernanceOpsRoutes(app, container); + registerIngestRoutes(app, container); + registerStreamRoute(app, container.broadcaster); + + return app; +} +``` + +#### B5 · `apps/api/src/app/routes/workbench.ts`(整文件) + +- [ ] **Step B5** + +```ts +/** + * 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。 + * 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { CONTRACTS_VERSION, authTicketRoute, healthRoute, meRoute } from '@contracts'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; +import { systemClock } from '../runtime.ts'; + +export function registerWorkbenchRoutes(app: Hono, container: Container): void { + app.get(healthRoute.path, async (c) => { + let db: 'up' | 'down' = 'down'; + try { + await container.pool.query('select 1'); + db = 'up'; + } catch { + db = 'down'; + } + return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db }); + }); + + const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' }; + + /** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */ + app.get(meRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + return c.json(principal); + }); + + /** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 下载这两条带不了 Authorization 头的路 */ + app.get(authTicketRoute.path, (c) => { + const principal = c.get('principal'); + if (principal === undefined) return c.json(unauthenticated, 401); + const issued = container.tickets.issue(principal.staff.id, systemClock.now()); + return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() }); + }); +} +``` + +#### B6 · `apps/api/src/app/routes/governance.ts`(整文件) + +- [ ] **Step B6** + +```ts +/** + * 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 —— + * 每个功能是对应模块的管理用例 + 本文件的路由组装)。 + * 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { + deleteLlmInstanceRoute, + deleteRoleAccessRoute, + listLlmInstancesRoute, + listLlmProvidersRoute, + listRoleAccessRoute, + listStaffRoute, + listWorkGroupsRoute, + LLM_PROVIDERS, + probeLlmDraftRoute, + probeLlmInstanceRoute, + saveWorkGroupRoute, + setRoleAccessRoute, + setStaffPositionRoute, + staffIdSchema, + staffInFlightRoute, + syncStaffRoute, + upsertLlmInstanceRoute, +} from '@contracts'; +import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org'; +import { PROVIDER_PROFILES } from '@modules/llm'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +export function registerGovernanceRoutes(app: Hono, container: Container): void { + /* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */ + + app.get(listStaffRoute.path, async (c) => c.json(await container.org.listStaff())); + + /** 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎) */ + app.post(syncStaffRoute.path, async (c) => c.json(await container.org.syncRoster())); + + app.put(setStaffPositionRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + const parsed = setStaffPositionRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + return c.json(await container.org.setPosition(staffId, parsed.data.positionId)); + } catch (error) { + if (error instanceof StaffNotFoundError || error instanceof PositionNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } + }); + + /** 此人身上还挂着什么 —— 原「停用前置检查」降为只读信息;停用在 Keycloak 做 */ + app.get(staffInFlightRoute.path, async (c) => { + const staffId = staffIdSchema.parse(c.req.param('staffId')); + try { + return c.json(await container.org.inFlightOf(staffId)); + } catch (error) { + if (error instanceof StaffNotFoundError) { + const body: ApiError = { code: 'not-found', message: error.message }; + return c.json(body, 404); + } + throw error; + } + }); + + /* ── 角色 → 端(ADR-012) ─────────────────────────────────────────────── */ + + app.get(listRoleAccessRoute.path, async (c) => c.json(await container.org.listRoleAccess())); + + /** 锁死不变量不过(改完没人能进运营中心)→ 409 */ + app.put(setRoleAccessRoute.path, async (c) => { + const role = c.req.param('role'); + const parsed = setRoleAccessRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + try { + return c.json(await container.org.setRoleAccess(role, parsed.data.ends)); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } + }); + + app.delete(deleteRoleAccessRoute.path, async (c) => { + try { + await container.org.deleteRoleAccess(c.req.param('role')); + return c.json({ ok: true }); + } catch (error) { + if (error instanceof AccessLockoutError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + throw error; + } + }); + + /* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */ + + /** + * 全部工作组 —— 一人至多一组,按员工遍历取。 + * `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。 + */ + app.get(listWorkGroupsRoute.path, async (c) => { + const { staff } = await container.org.listStaff(); + const found = await Promise.all( + staff.map(async (s) => { + const group = await container.org.workGroupOf(s.id); + return group === undefined ? undefined : { ...group, ownerName: s.name }; + }), + ); + return c.json({ groups: found.filter((g) => g !== undefined) }); + }); + + /** + * 建组 / 改组 —— 按 `ownerId` 落:已有即更新(复用 id),没有即新建。 + * 与 `workGroupOf` 的取组口径一致(一人一组),不给同一个人建出两个组来。 + * 改组**不影响在途**:装进工作流的是起单时的快照。 + */ + app.put(saveWorkGroupRoute.path, async (c) => { + const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues }; + return c.json(body, 400); + } + const existing = await container.org.workGroupOf(parsed.data.ownerId); + const group = await container.org.saveWorkGroup({ + ...(existing === undefined ? {} : { id: existing.id }), + name: parsed.data.name, + ownerId: parsed.data.ownerId, + members: parsed.data.members, + }); + return c.json({ group }); + }); + + /* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */ + + /** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */ + app.get(listLlmProvidersRoute.path, (c) => + c.json({ + providers: LLM_PROVIDERS.map((provider) => { + const profile = PROVIDER_PROFILES[provider]; + return { + provider, + label: profile.label, + defaultBaseUrl: profile.defaultBaseUrl, + structuredOutput: profile.structuredOutput, + }; + }), + }), + ); + + app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() })); + + app.post(upsertLlmInstanceRoute.path, async (c) => { + const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + return c.json(await container.llm.upsert(parsed.data)); + }); + + /** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */ + app.delete(deleteLlmInstanceRoute.path, async (c) => { + const id = c.req.param('id'); + const target = (await container.llm.list()).find((i) => i.id === id); + if (target === undefined) { + const body: ApiError = { code: 'not-found', message: '实例不存在' }; + return c.json(body, 404); + } + const cards = await container.registry.list(); + const manifests = await Promise.all(cards.map((a) => container.registry.manifestOf(a.activityId))); + const users = manifests.filter((m) => m.declarations.models.includes(target.name)); + if (users.length > 0) { + const body: ApiError = { + code: 'conflict', + message: `还有 ${users.length} 个活动声明着用途名「${target.name}」—— 先改那些活动的声明再删`, + }; + return c.json(body, 409); + } + await container.llm.remove(id); + return c.json({ ok: true }); + }); + + /** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */ + app.post(probeLlmDraftRoute.path, async (c) => { + const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues }; + return c.json(body, 400); + } + return c.json(await container.llm.probeDraft(parsed.data)); + }); + + app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id')))); +} +``` + +#### B7 · `apps/api/src/app/routes/governance-ops.ts`(脚本化删守卫) + +- [ ] **Step B7** + +```bash +python3 - <<'EOF' +import re +p = 'apps/api/src/app/routes/governance-ops.ts' +s = open(p, encoding='utf-8').read() + +# ① 守卫工厂 + 14 个 const + denyStatus(含其上的 doc 注释)整块删掉 +start = s.index(' /**\n * 岗位授权:客体 = 治理功能') +end_marker = " const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);\n" +end = s.index(end_marker) + len(end_marker) +s = s[:start] + s[end:] + +# ② 45 处两行守卫 +s, n = re.subn( + r"\n[ \t]*const denied = await \w+\(c\.get\('account'\)\);\n[ \t]*if \(denied\) return c\.json\(denied, denyStatus\(denied\)\);\n", + "\n", + s, +) +assert n == 45, f'守卫两行应有 45 处,实际 {n}' + +# ③ 取当前人 → 读 principal +s = s.replace("await container.org.findByAccount(c.get('account'))", 'staffOf(c)') + +# ④ import:去掉退役类型,引入 staffOf +s = s.replace( + "import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';", + "import type { ApiError } from '@contracts';", +) +s = s.replace( + "import type { AppEnv } from '../http-env.ts';", + "import { staffOf } from '../http-env.ts';\nimport type { AppEnv } from '../http-env.ts';", +) +open(p, 'w', encoding='utf-8').write(s) +print('governance-ops.ts: 守卫已删,findByAccount 已换') +EOF +grep -n "denyStatus\|denied\|GovernanceFunction\|GrantAction\|findByAccount" apps/api/src/app/routes/governance-ops.ts || echo "(clean)" +``` +Expected:脚本打印「守卫已删」;grep 无输出(clean)。文件头的 doc 注释若还提「岗位授权」,改成「端级授权由 server.ts 的中间件按路由归属统一判(ADR-012)」。 + +#### B8 · desk / desk-elements / desk-approval / registration(取当前人改读 principal) + +- [ ] **Step B8** + +```bash +perl -0pi -e "s/await me\(c\.get\('account'\)\)/staffOf(c)/g; s/await container\.org\.findByAccount\(c\.get\('account'\)\)/staffOf(c)/g; s/import type \{ AppEnv \} from '\.\.\/http-env\.ts';/import { staffOf } from '..\/http-env.ts';\nimport type { AppEnv } from '..\/http-env.ts';/" \ + apps/api/src/app/routes/desk.ts apps/api/src/app/routes/desk-elements.ts apps/api/src/app/routes/desk-approval.ts apps/api/src/app/routes/registration.ts +grep -c "staffOf(c)" apps/api/src/app/routes/desk.ts apps/api/src/app/routes/desk-elements.ts apps/api/src/app/routes/desk-approval.ts apps/api/src/app/routes/registration.ts +``` +Expected 计数:desk.ts 12 · desk-elements.ts 6 · desk-approval.ts 2 · registration.ts 2。 + +再删掉两个已无人调用的 helper: + +`apps/api/src/app/routes/desk.ts` 删 + +```ts + /** 当前登录者;缺会话即 401 */ + async function me(account: string) { + const staff = await container.org.findByAccount(account); + return staff; + } + +``` + +`apps/api/src/app/routes/desk-elements.ts` 删 + +```ts + async function me(account: string) { + return container.org.findByAccount(account); + } + +``` + +各路由里紧跟着的 `if (staff === undefined) …401` 判断**原样保留**(见 `staffOf` 的注释)。 + +#### B9 · `apps/api/src/app/wiring/engines.ts`(两处) + +- [ ] **Step B9** + +(1)`positions: async () => new Set((await deps.org.listStaff()).positions.map((p) => p.code)),` 换成 + +```ts + // 哨兵岗 NONE(未分配)不是流程包能声明的岗位 —— 不进候选集(ADR-012) + positions: async () => + new Set( + (await deps.org.listStaff()).positions.filter((p) => p.code !== NONE_POSITION_CODE).map((p) => p.code), + ), +``` + +并把文件头 `import type { OrgService } from '@modules/org';` 之后加 `import { NONE_POSITION_CODE } from '@modules/org';`。 + +(2)死信通知那段 + +```ts + const { staff } = await deps.org.listStaff(); + for (const person of staff) { + const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern'); + if (!governs) continue; + await deps.message.notify({ +``` + +换成 + +```ts + // 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端) + for (const person of await deps.org.staffWithEnd('ops')) { + await deps.message.notify({ +``` + +(`notify({...})` 的正文与 `}` 闭合照旧。) + +#### B10 · `seed.ts` `tsup.config.ts` `package.json`、删旧文件 + +- [ ] **Step B10** + +`apps/api/src/app/seed.ts` 里 `process.stdout.write(`种子完成:…`)` 那两行换成: + +```ts + process.stdout.write(`种子完成:${positions.length} 个岗位;名册 ${staff.length} 人(人来自鉴权中心:首登自动建档 / 运营中心同步)\n`); + for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`); +``` + +`apps/api/tsup.config.ts` 删这两行: + +```ts + // 改密 CLI(ADR-010):治理台改密界面未做前的运维口子 + 'set-password': 'src/app/set-password.ts', +``` + +`apps/api/package.json` 的 scripts 删 `"set-password": "node dist/set-password.js",`。 + +```bash +git rm apps/api/src/app/session-cookie.ts apps/api/src/app/set-password.ts +``` + +#### B11 · 三份集成测试改「造人」 + +- [ ] **Step B11** + +三个文件(`new-package-flow.it.test.ts` `unified-quote-flow.it.test.ts` `testbed-trace.it.test.ts`)各做两处: + +(a)`createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }))` → 环境里加测试账号: + +```ts + container = await createContainer( + loadConfig({ ...process.env, DATABASE_URL: database.url, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), + ); +``` + +(b)把 + +```ts + const { staff } = await container.org.listStaff(); + chaihao = staff.find((s) => s.account === 'Chaihao')!.id; +``` + +换成 + +```ts + // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) + chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id; +``` + +`testbed-trace.it.test.ts` 还有一行 `ai = staff.find((s) => s.account === 'AI')!.id;`,换成 + +```ts + ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id; +``` + +- [ ] **Step B12: 全量门禁** + +```bash +pnpm typecheck && pnpm lint && pnpm depcruise && pnpm test +``` +Expected:全绿。常见红点与处置: +- `explicit-module-boundary-types`:模块里 `export function` 缺返回类型 → 补; +- 未用 import(`PositionId` / `ApiError` 之类)→ 删; +- `org-service.test.ts` 里 `Harness.org` 的类型:`ReturnType` 即 `OrgService`; +- 三份 it-test 若因没有库而跳过,属正常(它们自报「跳过」);有库时应全绿。 + +- [ ] **Step B13: 刷新契约快照、提交** + +```bash +pnpm api:check --update +git add -A apps/api packages db api-report +git status --short # 只应有本任务点名的文件;不该有 web / CLAUDE.md / docs +git commit -m "统一鉴权中心接入(ADR-012):org 重写为 Keycloak 身份 + 角色→端;中间件按路由归属统一判;口令/会话/岗位授权退役;迁移 0032" +``` + +--- + +### Task 9: 启动自检、配置测试、路由覆盖(fail-closed) + +**Files:** +- Create: `apps/api/src/app/keycloak-check.ts` +- Modify: `apps/api/src/app/main.ts` +- Test: `apps/api/src/app/__tests__/keycloak-check.test.ts` `config-keycloak.test.ts` `route-coverage.it.test.ts` + +**Interfaces:** +- Consumes:`KeycloakConfig`(Task 8 B1)、`appRouteRegistry` `EXTRA_ROUTES`(Task 8 B4)、`createServer` `createContainer`。 +- Produces:`checkKeycloakConnection(kc, fetchImpl?): Promise<{ issuer, clientId }>`。 + +- [ ] **Step 1: 写失败测试** + +`apps/api/src/app/__tests__/config-keycloak.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { loadConfig } from '../config.ts'; + +describe('Keycloak 配置(ADR-012)', () => { + it('三样齐了才有 keycloak 段;尾斜杠去掉;issuer / jwks 拼好', () => { + const c = loadConfig({ KEYCLOAK_URL: 'https://kc.test/', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'api-gateway' }); + expect(c.keycloak).toEqual({ + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', + }); + }); + it('缺一样就没有 keycloak 段(main.ts 据此拒绝启动)', () => { + expect(loadConfig({ KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp' }).keycloak).toBeUndefined(); + expect(loadConfig({ KEYCLOAK_URL: ' ', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }).keycloak).toBeUndefined(); + }); + it('provisioning 两样都给才带', () => { + const base = { KEYCLOAK_URL: 'https://kc.test', KEYCLOAK_REALM: 'corp', KEYCLOAK_CLIENT_ID: 'x' }; + expect( + loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning', KEYCLOAK_PROVISIONING_CLIENT_SECRET: 's3' }).keycloak + ?.provisioning, + ).toEqual({ clientId: 'provisioning', secret: 's3' }); + expect(loadConfig({ ...base, KEYCLOAK_PROVISIONING_CLIENT_ID: 'provisioning' }).keycloak?.provisioning).toBeUndefined(); + }); + it('测试账号逗号分隔、去空白、丢空项', () => { + expect(loadConfig({ JCAI_TEST_ACCOUNTS: ' ai, chaihao ,, ' }).testAccounts).toEqual(['ai', 'chaihao']); + expect(loadConfig({}).testAccounts).toEqual([]); + }); +}); +``` + +`apps/api/src/app/__tests__/keycloak-check.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import type { KeycloakConfig } from '../config.ts'; +import { checkKeycloakConnection } from '../keycloak-check.ts'; + +const kc: KeycloakConfig = { + url: 'https://kc.test', + realm: 'corp', + clientId: 'api-gateway', + issuer: 'https://kc.test/realms/corp', + jwksUrl: 'https://kc.test/realms/corp/protocol/openid-connect/certs', +}; +const reply = (status: number, body: unknown): typeof fetch => async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('启动自检(手册第 5 步)', () => { + it('issuer 一致 → 回 issuer 与 clientId', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: kc.issuer }))).resolves.toEqual({ + issuer: kc.issuer, + clientId: 'api-gateway', + }); + }); + it('issuer 不一致 → 抛,话里带期望 / 实际 / KC_HOSTNAME', async () => { + await expect(checkKeycloakConnection(kc, reply(200, { issuer: 'http://localhost:8080/realms/corp' }))).rejects.toThrow( + /KC_HOSTNAME/, + ); + }); + it('发现文档拉不到 → 抛带状态码', async () => { + await expect(checkKeycloakConnection(kc, reply(503, {}))).rejects.toThrow(/503/); + }); +}); +``` + +`apps/api/src/app/__tests__/route-coverage.it.test.ts`(真库;库的取法与其他 it-test 同规矩): + +```ts +/** + * 路由覆盖(fail-closed,ADR-012):Hono 里每一条注册了的路由都得在路由注册表里有归属。 + * 漏一条 = 一个没人管的口子,这里把它变成测试红。要真容器只是为了把路由都挂上,不发请求。 + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig } from '../config.ts'; +import { createContainer } from '../container.ts'; +import type { Container } from '../container.ts'; +import { appRouteRegistry, createServer } from '../server.ts'; + +const IT_DB = 'jcai_it_routes'; + +async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | undefined> { + const explicit = process.env['JCAI_TEST_DATABASE_URL']; + if (explicit !== undefined) return { url: explicit, stop: async () => undefined }; + const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai'; + try { + const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 }); + const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]); + if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`); + await admin.end(); + return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined }; + } catch { + /* 开发实例连不上,试 Testcontainers */ + } + try { + const { PostgreSqlContainer } = await import('@testcontainers/postgresql'); + const started = await new PostgreSqlContainer('postgres:16-alpine').start(); + return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) }; + } catch (error) { + process.stderr.write( + `⚠️ 路由覆盖 it 跳过:连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`, + ); + return undefined; + } +} + +function migrationsFolder(): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i += 1) { + const candidate = join(dir, 'db', 'migrations'); + if (existsSync(candidate)) return candidate; + const parent = resolve(dir, '..'); + if (parent === dir) break; + dir = parent; + } + throw new Error('找不到 db/migrations'); +} + +const database = await provisionDatabase(); +let pool: Pool | undefined; +let container: Container | undefined; + +beforeAll(async () => { + if (database === undefined) return; + pool = new Pool({ connectionString: database.url, max: 4 }); + for (const schema of ['sys', 'ledger', 'mastra', 'queue']) { + await pool.query(`drop schema if exists "${schema}" cascade`); + await pool.query(`create schema "${schema}"`); + } + await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' }); + container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url })); +}, 180_000); + +afterAll(async () => { + await container?.close(); + await pool?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('路由覆盖', () => { + it('Hono 里每条注册的路由都在归属注册表里', () => { + const app = createServer(container!); + const registry = appRouteRegistry(); + const missing = app.routes + .filter((r) => r.method !== 'ALL') + .filter((r) => !registry.has(r.method, r.path)) + .map((r) => `${r.method} ${r.path}`); + expect(missing, '这些路由没有声明归哪个端(契约加 access,或 EXTRA_ROUTES 补入)').toEqual([]); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/config-keycloak.test.ts apps/api/src/app/__tests__/keycloak-check.test.ts` +Expected:config 那份 PASS(B1 已做);keycloak-check 那份 FAIL(模块不存在)。 + +- [ ] **Step 3: 写 `apps/api/src/app/keycloak-check.ts`** + +```ts +/** + * 启动自检(手册第 5 步;ADR-012):拉 `.well-known/openid-configuration`, + * issuer 与本地拼出来的不一致就别起 —— 配错了每个请求都 401 还查不出原因,不如启动时就说清楚。 + */ +import type { KeycloakConfig } from './config.ts'; + +export async function checkKeycloakConnection( + kc: KeycloakConfig, + fetchImpl: typeof fetch = fetch, +): Promise<{ issuer: string; clientId: string }> { + const url = `${kc.issuer}/.well-known/openid-configuration`; + const resp = await fetchImpl(url, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) throw new Error(`拉取发现文档失败 (${resp.status}): ${url}`); + const conf = (await resp.json()) as { issuer?: unknown }; + if (conf.issuer !== kc.issuer) { + throw new Error( + `issuer 不一致!\n 期望: ${kc.issuer}\n 实际: ${String(conf.issuer)}\n` + + ' → Keycloak 的 KC_HOSTNAME 配错了,或本服务的 KEYCLOAK_URL 写错了', + ); + } + return { issuer: kc.issuer, clientId: kc.clientId }; +} +``` + +- [ ] **Step 4: `main.ts` 的 `boot()` 开头改成** + +```ts +export async function boot(): Promise { + const config = loadConfig(); + if (config.keycloak === undefined) { + process.stderr.write( + '✗ 缺 KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID —— 统一鉴权中心是唯一的登录途径,缺了谁也进不来(ADR-012)\n', + ); + process.exit(1); + } + try { + const info = await checkKeycloakConnection(config.keycloak); + process.stdout.write(`✓ Keycloak 连通 · issuer=${info.issuer} · client=${info.clientId}\n`); + } catch (error) { + // 配置错了就别启动,免得跑起来每个请求都 401 还查不出原因(手册第 5 步) + process.stderr.write(`✗ Keycloak 连不上或配置有误:${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + const container = await createContainer(config); + await container.org.ensureSeeded(); +``` + +并加 `import { checkKeycloakConnection } from './keycloak-check.ts';`。 + +- [ ] **Step 5: 跑测试与门禁** + +Run: `pnpm vitest run apps/api/src/app/__tests__/keycloak-check.test.ts apps/api/src/app/__tests__/config-keycloak.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected:全 PASS(覆盖测试有库时跑、无库时自报跳过);绿。若覆盖测试报了 `missing`,按提示给那条契约加 `access` 或补进 `EXTRA_ROUTES`。 + +- [ ] **Step 6: 提交** + +```bash +git add apps/api/src/app/keycloak-check.ts apps/api/src/app/main.ts apps/api/src/app/__tests__/keycloak-check.test.ts apps/api/src/app/__tests__/config-keycloak.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts +git commit -m "启动自检 issuer 不一致拒绝启动;路由覆盖 fail-closed;Keycloak 配置解析测试(ADR-012)" +``` + +--- + +### Task 10: e2e 脚本改走服务账号 token + +**Files:** +- Modify: `tools/e2e-a2.mjs` + +**Interfaces:** +- Consumes:`GET /api/me`(`ends`)。 +- 环境变量:`KEYCLOAK_URL` `KEYCLOAK_REALM`(缺省 `corp`)`KEYCLOAK_E2E_CLIENT_ID` `KEYCLOAK_E2E_CLIENT_SECRET`(client_credentials;该服务账号需 realm 角色 `admin`,且发出的 token `aud` 含本服务 clientId —— Keycloak 侧前置 4)。 + +- [ ] **Step 1: 常量** + +把 + +```js +const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026'; +``` + +换成 + +```js +/** 身份归统一鉴权中心(ADR-012):e2e 用服务账号的 client_credentials 拿 token,不用人的账号 */ +const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, ''); +const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp'; +const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? ''; +const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? ''; +/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */ +const TOKEN_REFRESH_MS = 4 * 60 * 1000; +``` + +- [ ] **Step 2: `api()` 与 `login()`** + +把 + +```js +let cookie = ''; + +async function api(path, init = {}) { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) }, + }); +``` + +换成 + +```js +let bearer = ''; +let bearerAt = 0; + +async function api(path, init = {}) { + if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login(); + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) }, + }); +``` + +把整个 `async function login() { … }` 换成 + +```js +async function login() { + if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') { + throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)'); + } + const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'client_credentials', client_id: E2E_CLIENT_ID, client_secret: E2E_CLIENT_SECRET }), + }); + if (!res.ok) throw new Error(`取 token 失败:${res.status} ${await res.text()}`); + const { access_token: token } = await res.json(); + bearer = `Bearer ${token}`; + bearerAt = Date.now(); + // 试跑要进设计端(测试台)、答卡要进工作台 —— 服务账号的角色不够,这里就先说清楚 + const me = await api('/api/me'); + for (const end of ['design', 'workbench']) { + if (!me.body.ends.includes(end)) throw new Error(`e2e 服务账号进不了 ${end}:ends=${JSON.stringify(me.body.ends)} —— 给它 realm 角色 admin`); + } +} +``` + +- [ ] **Step 3: 两处裸 fetch 的头** + +两处 `{ headers: { cookie } }`(下载原件的 `…&raw=1`)都改成 `{ headers: { authorization: bearer } }`。 + +- [ ] **Step 4: 语法与 lint** + +Run: `node --check tools/e2e-a2.mjs && pnpm lint` +Expected:无输出、lint 绿。真跑 `node tools/e2e-a2.mjs …` 需要 Keycloak 侧前置 1 与 4 就位 —— 就位后跑任一套件应 `released`;未就位时脚本在 `login()` 就把缺什么说清楚。 + +- [ ] **Step 5: 提交** + +```bash +git add tools/e2e-a2.mjs +git commit -m "e2e:改用 Keycloak 服务账号 client_credentials 拿 token,4 分钟一换;断言进得了设计端与工作台" +``` + +--- + +### Task 11: 文档、契约快照、五道门禁 + +**Files:** +- Create: `docs/adr/ADR-012-keycloak-identity.md` +- Modify: `apps/api/src/modules/org/CONTRACT.md`(整文件)、`docs/编码规范.md`(三处)、`docs/debt.md`(鉴权段)、`CLAUDE.md`(铁规矩 5 / 6 + 现状快照一条) +- `api-report/`(`pnpm api:check --update`) + +- [ ] **Step 1: ADR-012** + +`docs/adr/ADR-012-keycloak-identity.md`: + +```markdown +# 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;治理路由里约 100 处逐条守卫整体删除;覆盖测试 fail-closed。 +5. **名册**:岗位仍是本系统的表(哨兵岗 `NONE / 未分配`,运营中心指派);名册经 Admin API 同步 + (master realm 的 provisioning 服务账号,只要 `view-users`)+ 首登即建。`state` 只由同步置 + `disabled`(Keycloak 停用或删除),有效 token 不改它 —— 撤权在同步之后即时 403。 +6. **票据**:`EventSource` 与 `` 带不了 Authorization 头 —— `GET /api/auth/ticket` + 换一枚 60 秒 HMAC 票据(`HKDF(JCAI_SECRET_KEY, 'jcai-ticket')`),只在 GET 的 `?ticket=` 上认; + 票据里没有权限,中间件拿 sub 回库现算。无状态、不单次:60 秒重放窗换零状态表。 +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 寿命);停用经同步后即时。 +- web 需按《前端接入手册》改造(登录 / Bearer / 401 跳转 / SSE 与下载改走票据 / 入口亮灯改读 `/api/me` 的 `ends`), + 改造前登不进 —— 这是「只做后端」的既定后果。 +- 迁移 0032 清掉种子人员与工作组;运行数据由 `reset-runs` 清。 +- Keycloak 侧前置(管理员):本服务 client(建议复用 `api-gateway`)、`web-console` 补 audience mapper、 + provisioning 服务账号、e2e 服务账号 client(`bpos-e2e`,realm 角色 admin,firstName「AI」)。 +- 欠账见 `docs/debt.md`「鉴权」段。 +``` + +- [ ] **Step 2: `apps/api/src/modules/org/CONTRACT.md`(整文件)** + +```markdown +# module: org + +职责: 员工与岗位(ADR-012):验签后的人是谁(名册即时更新)、他能进哪几个端(角色→端)、 + 岗位指派与在岗人员查询(负责人四策略解析的候选集来源)、名册同步(Admin API)、 + 在途工作(只读)、工作组(ADR-011)、种子(岗位 + 缺省角色→端) +不负责: 验签之外的 HTTP 语义(401/403 由 app 层中间件按路由归属判)、 + 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 由 app 注入) + +公开出口: index.ts + - 类型:`OrgService` `StaffWithPosition` `Identity` 与 @contracts 的 `Staff` / `Position` / `Principal` / + `RoleAccess` / `RosterSyncOutcome` / `InFlightWork` / `Surface` / `WorkGroup` / `WorkGroupBinding` 转出 + - 错误:`TokenRejectedError`(→401)`AccountDisabledError`(→403)`AccessLockoutError`(→409) + `StaffNotFoundError` `PositionNotFoundError` + - 端口类型:`TokenVerifier` `VerifiedClaims` `RosterSource` `RosterUser` `IdentityPatch` + `PositionRepository` `StaffRepository` `StaffRecord` `RoleAccessRepository` + `WorkGroupRepository` `InFlightWorkProbe` `Clock` `IdGenerator` + - 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService` + - 纯函数:`identityOf` `isNoiseRole` `normalizeRoles` `endsOf` `assertNotLockedOut` `normalizeEnds` `buildInFlightWork` + - 常量:`POSITION_SEED` `NONE_POSITION_CODE` `TEST_POSITION_CODE` `DEFAULT_ROLE_ACCESS` + - adapter 工厂(供 app 装配):`createKeycloakTokenVerifier`(jose)`createKeycloakRoster`(Admin API) + `createPositionRepository` `createStaffRepository` `createRoleAccessRepository` `createWorkGroupRepository` + - Drizzle 表:`positionTable` `staffTable` `roleAccessTable` `workGroupTable` + +层级: L1(编码规范 §4) +依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema);jose(仅 adapter) + port:`InFlightWorkProbe`(app 注入 task / human-tool 的只读用例)、`IdGenerator`、 + `TokenVerifier`(app 给 jose 远程 JWKS;测试给本地密钥对)、`RosterSource`(可缺省) +被依赖: process(负责人解析的候选集)、message(治理台收件人 = staffWithEnd('ops'))、 + app(鉴权中间件与 /api/gov/staff、/api/gov/access 路由组装) + +不变量: + - `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键 + - `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError, + 有效 token 不能把它翻回 active(撤权在同步之后即时生效) + - 名册即时更新只写变了的列;`last_seen_at` 60 秒内不重写 + - 角色→端:`setRoleAccess` / `deleteRoleAccess` 先算新表再验锁死不变量(名册为空不算锁死) + - `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 [] + - `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖 + - 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核 + +影响半径(2026-08-25,ADR-012):contract / port / index 全面重写 —— 退役 `login` `logout` `resolveSession` + `setPassword` `authorize` `loadSession` `listGrants` `setGrants` `createStaff` `checkDisable` + `disableStaff` `findByAccount` 与 `SessionRepository` `PasswordHasher` `GrantRepository`; + 下游 app(中间件 / 装配 / 路由)与 message 装配同批改,depcruise 已反查确认。 +``` + +- [ ] **Step 3: `docs/编码规范.md` 三处** + +(1)§1 表在 `| 代码契约校验 | zod | 仓库内 API/DTO |` 之后加一行: + +```markdown +| 鉴权 | Keycloak(统一鉴权中心)+ `jose` 本地验签 | 资源服务器:RS256 / iss / aud 本地验,不回源;权限只到端(ADR-012) | +``` + +(2)§4 约束里那一行 + +```markdown +- 鉴权定调:单组织 cookie session——登录、会话与岗位权限校验中间件在 apps/api 的 app 层实现,权限判定读 `org` 出口;不引 OAuth/OIDC 与多租户机制 +``` + +换成 + +```markdown +- 鉴权定调(ADR-012):统一鉴权中心(Keycloak)资源服务器——后端只验 Bearer JWT;鉴权中间件在 apps/api 的 app 层实现,身份判定读 `org` 出口;**权限只到端**(设计中心 / 运营中心 / 工作台),角色→端由运营中心的表定,路由归属写在契约的 `access` 上;不做多租户 +``` + +(3)§14 在 `- **ADR-006** …` 之后加: + +```markdown +- **ADR-012** 统一鉴权中心(Keycloak):资源服务器本地验签(`jose`),`Staff.id` = `sub`;权限只到端,`sys.role_access` 可改;名册 Admin API 同步 + 首登即建;60 秒票据给 SSE 与下载;口令 / 会话 / 岗位授权退役(取代 ADR-010) +``` + +- [ ] **Step 4: `docs/debt.md` —— 把「## 鉴权(ADR-010 落地后的已知欠账)」整段(到下一个 `## ` 之前)换成** + +```markdown +## 鉴权(ADR-012 落地后的已知欠账) + +- **撤角色时效 ≤ 5 分钟**:改了 Keycloak 里的角色,现有 access token 到期前仍按旧角色算(手册 13.4); + 要立即生效在 Keycloak 上 `POST /users/{id}/logout`。停用经名册同步后即时 403。 +- **票据 60 秒可重放窗**(无状态换来的):绑定 sub、只认 GET;要单次得加一张已用票据表。 +- **名册同步是手动按钮**(运营中心 · 员工 · 同步),没有定时;没配 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」,但没有启动期探测。 +``` + +- [ ] **Step 5: `CLAUDE.md`** + +铁规矩 5 与 6 换成: + +```markdown +5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、 + `KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选); + 不入源码、不入包、不入留痕;E2E 永不回退开发库。 +6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名), + 绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。 +``` + +「文档权威地图」表在 `docs/debt.md` 那一行之前加两行: + +```markdown +| | `docs/前端对接说明-统一鉴权中心.md` | **前端接统一鉴权中心照这份**(ADR-012):要改哪些文件、`/api/me` 与端、401/403/410、SSE 与下载的票据 | +| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 | +``` + +现状快照列表末尾(`## 文档权威地图` 之前)加一条: + +```markdown +- **统一鉴权中心(Keycloak)接入,只做后端**(2026-08-25,ADR-012):资源服务器 —— 后端只验 Bearer JWT + (`jose`,RS256 / iss / aud 本地验,不回源);`Staff.id` = `sub`,首登即建、Admin API 同步名册; + **权限只到端**:`sys.role_access` 一行一个 realm 角色,运营中心可改(锁死不变量:改完得有人还能进运营中心); + 路由归属写在契约 `access` 上,app 层一处中间件统一判 401/403,治理路由里约 100 处守卫整体删除, + 覆盖测试 fail-closed;SSE 与下载走 60 秒票据;口令 / 会话表 / 岗位授权退役(八条路由回 410,契约留一轮)。 + web 待按前端手册接入,接入前登不进。 +``` + +- [ ] **Step 6: 五道门禁** + +```bash +pnpm api:check --update && git add api-report +pnpm gates +``` +Expected:五道全绿(退出码 0)。 + +- [ ] **Step 7: 提交** + +```bash +git add docs/adr/ADR-012-keycloak-identity.md apps/api/src/modules/org/CONTRACT.md docs/编码规范.md docs/debt.md CLAUDE.md api-report +git commit -m "文档:ADR-012 统一鉴权中心;编码规范鉴权定调改为资源服务器 + 权限只到端;org CONTRACT.md 重写;debt 鉴权段;CLAUDE.md 铁规矩 5/6" +``` + +--- + +### Task 12: 前端对接说明与 API 文档 —— 与真实契约核对 + +两份文档是设计阶段按 spec 写好的(`docs/前端对接说明-统一鉴权中心.md` `docs/API文档-鉴权与人员.md`), +实现落地后要用**契约本身**把它们核一遍:路由归属总表由契约重新生成、schema 字段名逐个对、示例尽量换成真实响应。 + +**Files:** +- Modify: `docs/API文档-鉴权与人员.md`(§6 总表整段重生成;§7 字段核对;§8 示例) +- Modify: `docs/前端对接说明-统一鉴权中心.md`(§2 文件指针、§7 接口名核对) + +**Interfaces:** +- Consumes:`packages/contracts`(Task 1 之后每条路由都有 `access`)、`GET /api/me` 等已实现的路由(Task 8)。 + +- [ ] **Step 1: 用契约重新生成路由归属总表** + +```bash +node --experimental-strip-types --input-type=module -e " +const m = await import('./packages/contracts/src/index.ts'); +const isRoute = (v) => v !== null && typeof v === 'object' && typeof v.method === 'string' && typeof v.path === 'string' && 'access' in v && 'output' in v; +const order = ['public', 'any', 'workbench', 'design', 'ops', 'design|ops']; +const rows = Object.entries(m).filter(([, v]) => isRoute(v)).map(([name, r]) => ({ access: Array.isArray(r.access) ? r.access.join('|') : r.access, method: r.method, path: r.path, name })); +rows.sort((a, b) => order.indexOf(a.access) - order.indexOf(b.access) || a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); +console.log('| access | 方法 | 路径 |'); +console.log('|---|---|---|'); +for (const r of rows) console.log('| \`' + r.access.replace('|', '\\\\|') + '\` | ' + r.method + ' | \`' + r.path + '\` |'); +console.log('\n计数:' + rows.length + ' 条契约路由'); +" > /tmp/route-access-table.md +wc -l /tmp/route-access-table.md +``` +Expected:打印 95 行路由 + 计数 `95 条契约路由`。若 `node --experimental-strip-types` 在本机版本上不认 `satisfies`(Node < 22.7),改用 `pnpm vitest run` 跑一个临时测试文件把同一段代码 `console.log` 出来,跑完删掉临时文件。 + +- [ ] **Step 2: 对 §6 总表** + +把生成表与 `docs/API文档-鉴权与人员.md` §6 逐行比:方法 / 路径 / access 三者任一不同 → 以生成表为准改文档; +文档里手写的备注(「定义归设计」「可用票据」等)保留。SSE `GET /api/stream` 不在契约里(`EXTRA_ROUTES`),文档单列一行。 +最后一行「计数」按生成结果改。 + +- [ ] **Step 3: 对 §7 字段名** + +```bash +grep -n "^export const \(staffSchema\|principalSchema\|roleAccessSchema\|rosterSyncOutcomeSchema\|inFlightWorkSchema\|workGroupSchema\|positionSchema\)" packages/contracts/src/org.ts +grep -n "API_ERROR_CODES" -A 10 packages/contracts/src/api/http.ts | head -12 +``` +逐个 schema 打开看字段名与可选性,与文档 §7 的 TS 形状对齐(字段多了补、少了删、可选性以契约为准)。 + +- [ ] **Step 4: 示例换成真实响应(Keycloak 侧前置 1 与 4 就位时做;未就位就跳过并在文档末尾注明「示例按契约构造」)** + +```bash +export KEYCLOAK_URL=https://keycloak.akhr-ai.com KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=api-gateway +pnpm dev:api & # 另开终端亦可;启动日志须见「✓ Keycloak 连通」 +TOKEN=$(curl -s -X POST "$KEYCLOAK_URL/realms/corp/protocol/openid-connect/token" -d grant_type=client_credentials -d client_id="$KEYCLOAK_E2E_CLIENT_ID" -d client_secret="$KEYCLOAK_E2E_CLIENT_SECRET" | jq -r .access_token) +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/me | jq . +curl -s http://localhost:8787/api/me | jq . +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/auth/ticket | jq . +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/gov/access/roles | jq . +curl -s -X POST http://localhost:8787/api/login | jq . +``` +把上面五个响应原样贴进文档 §1 / §4 / §5 / §8(sub、邮箱按真实值;secret 一个字都别贴)。 + +- [ ] **Step 5: 核前端对接说明里的文件指针** + +```bash +for f in shared/typed-fetch.ts app/main.tsx features/auth/internal/LoginPage.tsx shared/gov/surfaces.ts app/gov-shell.tsx app/app-shell.tsx app/subscribe.ts features/desk-task/internal/ViewRegion.tsx features/desk-task/internal/FileViewer.tsx shared/gov/gov-api.ts; do test -f "apps/web/src/$f" && echo "ok $f" || echo "MISSING $f"; done +``` +Expected:全部 `ok`;有 `MISSING` 就改文档 §2 的指针(web 本轮没动,理论上不会)。§7 里提到的接口名与 §6 总表逐个对一遍。 + +- [ ] **Step 6: 提交** + +```bash +git add 'docs/API文档-鉴权与人员.md' 'docs/前端对接说明-统一鉴权中心.md' +git commit -m "文档:前端对接说明与 API 文档按真实契约核对(路由归属总表由契约生成,示例换真实响应)" +``` + +--- + +## 自审记录(写完计划后对着 spec 过的一遍) + +- **spec 覆盖**:§4 契约 → Task 1;§5.3 domain → Task 2;§5.5 两个 adapter → Task 3 / 4;§9.1 注册表 → Task 5;§9.3 票据 → Task 6;§9.2 中间件 → Task 7;§5.1 / 5.2 / 5.4 / 5.5 仓储与用例、§6 迁移、§11 连带改动(container / engines / message / 路由 / it-tests / tsup / package.json)→ Task 8;§10.2 启动自检 + §9.1 覆盖测试 → Task 9;§11 e2e → Task 10;§11 文档 + §13 债 → Task 11;用户追加的前端对接说明与 API 文档(设计阶段已写)→ Task 12 用契约核对。§10.3 Keycloak 侧前置是管理员的事,写在 ADR-012「后果」与 Task 10 的说明里。 +- **类型一致性**:`OrgService` 的方法名在 Task 8 A2(契约)、A7(实现)、B3–B9(调用方)一致:`authenticate` `principalOf` `registerIdentity` `listStaff` `getStaff` `staffOfPosition` `setPosition` `syncRoster` `inFlightOf` `listRoleAccess` `setRoleAccess` `deleteRoleAccess` `staffWithEnd` `saveWorkGroup` `workGroupOf` `bindingOf` `ensureSeeded`;`Authenticator`(Task 7)是它的子集;`StaffRepository` 无 `findByAccount`(A3 / A6 / A7 / A1 的内存实现一致);`createContainer(config, overrides)`(B3)与 Task 9 的 it-test 调用一致;`EXTRA_ROUTES` / `appRouteRegistry`(B4)与 Task 9 覆盖测试一致。 +- **占位扫描**:无 TBD / TODO;每个改动步骤都给了代码或精确的替换文本;脚本化改写(B7 / B8)带断言与验收 grep。 +- **已知取舍**:Task 8 是一次提交(中间态 typecheck 必红,不拆);it-tests 与路由覆盖测试需要 PG,没库时自报跳过。 diff --git a/docs/superpowers/plans/2026-08-25-mail.md b/docs/superpowers/plans/2026-08-25-mail.md new file mode 100644 index 0000000..3e6ada0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-mail.md @@ -0,0 +1,7052 @@ +# 邮件收发模块(`mail`)实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 新增后端模块 `mail`(L1):腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)的收信轮询、订阅分发、发信留痕与账号配置接口;`channel` 成为首个订阅方,活动经 `mail.send` 工具发信。 + +**Architecture:** `mail` 只依赖 `@contracts` / `@shared/db` 与端口(`MailProvider` × 2 适配器、`MimeParser`、三个 store、`AttachmentStore`、`SecretBox`);app 层装配:轮询工作者(跨进程租约)、`channel` 订阅方、`mail.send` 工具处理器、15 条 `/api/gov/mail/*` 路由。入站先持久(两把唯一键去重)再分发(逐订阅方留痕,自动只投一次);出站先落 `sending` 行再经 provider 发。 + +**Tech Stack:** TypeScript / Hono / Drizzle + pg / zod v4 / vitest;新依赖 `imapflow@^1.7.6`、`nodemailer@^8.0.11` + `@types/nodemailer@^8.0.1`、`postal-mime@^3.0.0`。 + +**Spec:** `docs/superpowers/specs/2026-08-25-mail-design.md`(本计划与 spec 的三处差异见「与 spec 的差异」)。 + +## Global Constraints + +- 五道门禁以退出码验:`pnpm gates`(= `gen:tiers → typecheck → lint → depcruise → api:check → test`);api-report 用 `pnpm api:check --update && git add api-report` 刷新。 +- §12.5 禁止清单:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO 零 `Date.now`/`new Date()`/`Math.random`;不删测试;不动门禁配置;模块内 `.ts` 后缀显式;`apps/api/src/modules/**` 公开签名显式类型(`explicit-module-boundary-types`)。 +- 模块只 import 更低层 `index.ts`;`mail` 是 L1(`tools/module-tiers.json` tier 1);`channel` / `toolbox` **不 import** `mail`,全经 app 注入。 +- Drizzle / pg / imapflow / nodemailer / postal-mime 只准出现在 `internal/adapter/` 与 `apps/api/src/app/`。 +- 凭证:只回 `secretBound: boolean`,密文永不出接口;错误文本先 `redactSecrets`。 +- 界面不撒谎:探测 / 试发 / 拉取的厂商失败在 200 的 body 里带原话,不编状态码。 +- 邮件通道是外界:`createFakeMailProvider` 是允许的替身;系统内部机制(store / usecase / 路由)真跑。 +- 提交信息中文、末尾带 `Co-Authored-By: Claude Fable 5 ` 与 `Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr` 两行(仓内惯例)。 +- **不改** `apps/web/**`、`channel` 的 index/contract/port、`ingest` 路由、任何门禁配置。 + +## 与 spec 的差异(实施前核仓逼出的三处,以本计划为准) + +1. **`mail.send` 工具契约保留 v1 字段**:toolbox 的 `checkEvolution` 不许删入参 / 出参字段 —— v2 保留 `body`(纯文本正文,不改名 `text`)与出参 `queued`;新增 `html` / `cc` / `bcc` / `attachments` / `inReplyTo` / `accountId` / `dryRun`。`to` 改为数组(演进检查不管类型;处理器仍容忍逗号分隔的字符串)。`connectionKeys: ['account']`(值 `default` = 用默认账号)—— 静态审对无连接键只 warn,但「从哪个信箱发」本来就是一个连接目标。 +2. **`toolbox.register` 的试运行会真调处理器**:样例入参带 `dryRun: true`,处理器只校验不发。 +3. **`send()` 不抛 `MailSendFailedError`**:厂商失败落 `failed` 行并以 `MailSendResult.state='failed'` + `error` 返回;只有入参 / 账号问题才抛(`MailSendInvalidError` 400、`MailNoDefaultAccountError` / `MailAccountDisabledError` / `MailSecretMissingError` 412、`MailAccountNotFoundError` 404)。channel 注入件与工具处理器各自把 `failed` 翻成自己的失败形态。 +4. **nodemailer 用 8.x**:9.0.x 没有 DefinitelyTyped 类型包(`@types/nodemailer` 最新 8.0.1),8.0.11 + `@types/nodemailer@8.0.1` 类型对齐。 + +## 文件结构 + +``` +packages/contracts/src/mail.ts # 账号 / 入站 / 出站 / 探测的 zod schema(§Task 2) +packages/contracts/src/api/mail.ts # 15 条路由契约(§Task 3) +packages/contracts/src/__tests__/mail.test.ts +db/migrations/0033_mail.sql + meta/_journal.json # 四张表(§Task 8) +apps/api/src/modules/mail/ + package.json · index.ts · contract.ts · port.ts · CONTRACT.md + internal/domain/records.ts # 库形 / 传输形类型(纯类型) + internal/domain/cursor.ts # 游标两形、UID 过滤、wecom 窗口 + internal/domain/due.ts # 到期判定、租约时长 + internal/domain/inbound-record.ts # 解析结果 → 入站记录 / 视图、附件规划、正文提取 + internal/domain/redact.ts # 脱敏、错误文本 + internal/domain/send-input.ts # 发信校验、选账号、Message-ID + internal/domain/delivery.ts # 分发状态机 + internal/domain/account-view.ts # 账号记录 ↔ 输入 / 视图 + internal/usecase/mail-service.ts # MailService + internal/adapter/mail-schema.ts # Drizzle 表 + internal/adapter/mail-store.ts # 三个 store + 租约 SQL + internal/adapter/postal-mime-parser.ts + internal/adapter/imap-smtp-provider.ts + internal/adapter/wecom-provider.ts + internal/adapter/fake-provider.ts # 外界替身 + __tests__/fakes.ts # 内存 store / 时钟 / EML 夹具 + __tests__/{cursor,due,inbound-record,redact,send-input,account-view,delivery}.test.ts + __tests__/{mail-service-accounts,mail-service-poll,mail-service-send}.test.ts + __tests__/postal-mime-parser.test.ts · imap-helpers.test.ts · imap-smtp-provider.it.test.ts · wecom-provider.test.ts + __tests__/mail-store.it.test.ts +apps/api/src/app/upload-store.ts # sys.upload 存取抽出(路由与 mail 注入件同用) +apps/api/src/app/mail-wiring.ts # toInboundMail / channel 订阅方 / channel 传输件 / 工具处理器 +apps/api/src/app/workers/mail-poller.ts +apps/api/src/app/routes/mail.ts +apps/api/src/app/__tests__/{mail-wiring,mail-routes,mail-poller,mail-routes-registry}.test.ts +apps/api/src/app/{container,config,main,reset-runs,seed-mvp}.ts · tools/first-party.ts · routes/governance-ops.ts(上传两段抽出) +tools/module-tiers.json · docs/编码规范.md §4 · apps/api/package.json +docs/adr/ADR-013-mail-gateway.md · docs/API文档-邮件收发.md · docs/前端对接说明-邮件收发.md · docs/API文档-鉴权与人员.md §6 · docs/debt.md · CLAUDE.md · apps/api/src/modules/channel/CONTRACT.md +``` + +--- + +### Task 1: 依赖与模块登记 + +**Files:** +- Modify: `apps/api/package.json`(dependencies / devDependencies) +- Modify: `tools/module-tiers.json`(tier 1 加 `mail`) +- Modify: `docs/编码规范.md`(§4 层级表 L1 加一行) +- Generated: `tools/depcruise-tiers.generated.cjs`(`pnpm gen:tiers` 产物,入库) + +**Interfaces:** +- Produces: 包可 import `imapflow` / `nodemailer` / `postal-mime`;`apps/api/src/modules/mail/` 目录被门禁认作 L1。 + +- [ ] **Step 1: 加依赖** + +```bash +cd /Users/xcs/project2/tmp/BPOS-MVP +pnpm --filter @app/api add imapflow@^1.7.6 nodemailer@^8.0.11 postal-mime@^3.0.0 +pnpm --filter @app/api add -D @types/nodemailer@^8.0.1 +node -e "const p=require('./apps/api/package.json');console.log(p.dependencies.imapflow,p.dependencies.nodemailer,p.dependencies['postal-mime'],p.devDependencies['@types/nodemailer'])" +``` +Expected: 四个版本号各打印一行(`^1.7.6 ^8.0.11 ^3.0.0 ^8.0.1`)。 + +- [ ] **Step 2: 登记层级** + +`tools/module-tiers.json` 的 `"1"` 数组改为: + +```json + "1": [ + "event", + "org", + "llm", + "ledger", + "mail" + ], +``` + +`docs/编码规范.md` §4 层级表,在 `| L1 | \`ledger\` |` 那一行之后加: + +```markdown +| L1 | `mail` | 邮箱账号与收发:两条接入路(IMAP/SMTP · 企业微信邮件 API)的适配、轮询拉取与入站留痕、订阅分发、出站留痕;凭证密封、跨进程租约 | ADR-013 | +``` + +- [ ] **Step 3: 重新生成 tier 规则并验门禁** + +Run: `pnpm gen:tiers && pnpm depcruise` +Expected: 退出码 0;`git diff --stat tools/depcruise-tiers.generated.cjs` 显示该文件有变更(多了 `tier-order-mail` 规则)。 + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/package.json pnpm-lock.yaml tools/module-tiers.json tools/depcruise-tiers.generated.cjs docs/编码规范.md +git commit -m "$(printf 'mail 模块登记为 L1;批准 imapflow / nodemailer / postal-mime 三个依赖(设计稿 §2)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 2: 契约 schema `packages/contracts/src/mail.ts` + +**Files:** +- Create: `packages/contracts/src/mail.ts` +- Modify: `packages/contracts/src/index.ts`(加 `export * from './mail.ts';`) +- Test: `packages/contracts/src/__tests__/mail.test.ts` + +**Interfaces:** +- Produces(后续任务全靠这些名字):`MAIL_ACCOUNT_KINDS` `mailAccountKindSchema` `MailAccountKind` `MAIL_ACCOUNT_KIND_LABEL` `imapSmtpSettingsSchema` `ImapSmtpSettings` `wecomSettingsSchema` `WecomSettings` `mailAccountInputSchema` `MailAccountInput` `mailAccountStatusSchema` `mailAccountViewSchema` `MailAccountView` `mailProbeReportSchema` `MailProbeReport` `MAIL_DELIVERY_STATES` `mailDeliveryStateSchema` `MailDeliveryState` `mailDeliveryViewSchema` `MailDeliveryView` `mailAddressSchema` `MailAddress` `mailInboundAttachmentSchema` `MailInboundAttachment` `mailInboundSummarySchema` `MailInboundSummary` `mailInboundDetailSchema` `MailInboundDetail` `mailSendInputSchema` `MailSendInput` `mailSendResultSchema` `MailSendResult` `MAIL_OUTBOUND_STATES` `mailOutboundStateSchema` `MailOutboundState` `mailOutboundViewSchema` `MailOutboundView` `mailPollOutcomeSchema` `MailPollOutcome`。 + +- [ ] **Step 1: 写失败测试** + +```ts +// packages/contracts/src/__tests__/mail.test.ts +/** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */ +import { describe, expect, it } from 'vitest'; +import { + mailAccountInputSchema, + mailAccountViewSchema, + mailSendInputSchema, + MAIL_ACCOUNT_KINDS, +} from '../mail.ts'; + +describe('mailAccountInputSchema', () => { + it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => { + const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' }); + expect(parsed.kind).toBe('imap-smtp'); + if (parsed.kind !== 'imap-smtp') throw new Error('unreachable'); + expect(parsed.settings).toEqual({ + imapHost: 'imap.exmail.qq.com', + imapPort: 993, + imapTls: true, + smtpHost: 'smtp.exmail.qq.com', + smtpPort: 465, + smtpTls: true, + folder: 'INBOX', + }); + expect(parsed.enabled).toBe(true); + expect(parsed.isDefault).toBe(false); + expect(parsed.pollIntervalSec).toBe(30); + expect(parsed.channelId).toBeNull(); + expect(parsed.backfillFrom).toBeNull(); + expect(parsed.secret).toBeUndefined(); + }); + + it('wecom:corpId 必填,地址不由人填(探测回填)', () => { + const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } }); + if (parsed.kind !== 'wecom') throw new Error('unreachable'); + expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com'); + expect('address' in parsed).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false); + }); + + it('轮询间隔下限 10 秒;地址必须是邮箱;kind 只认两种', () => { + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.c', pollIntervalSec: 5 }).success).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false); + expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']); + }); +}); + +describe('mailAccountViewSchema', () => { + it('视图只有 secretBound,没有任何密文 / 密码字段', () => { + const keys = Object.keys(mailAccountViewSchema.options[0]!.shape); + expect(keys).toContain('secretBound'); + expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false); + }); +}); + +describe('mailSendInputSchema', () => { + it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => { + const ok = mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }); + expect(ok.success).toBe(true); + if (ok.success) expect(ok.data.attachments).toEqual([]); + expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false); + expect(mailSendInputSchema.safeParse({ to: ['a@b.c'], subject: 'hi', text: 'x' }).success).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts` +Expected: FAIL(`Cannot find module '../mail.ts'`)。 + +- [ ] **Step 3: 写 schema** + +```ts +// packages/contracts/src/mail.ts +/** + * 邮件收发的契约(设计稿 docs/superpowers/specs/2026-08-25-mail-design.md §7.1;ADR-013)。 + * + * 只有 zod schema 与推导类型。账号视图**永不含密文**(ADR-009 §4):只回 `secretBound`。 + * 入参按 `kind` 分两形 —— 标准协议(IMAP/SMTP)与企业微信邮件 API(应用邮箱), + * 两条路的共同点是「轮询拉 + MIME 解析」,腾讯没有带内容的推送。 + */ +import { z } from 'zod'; +import { channelIdSchema } from './id.ts'; + +export const MAIL_ACCOUNT_KINDS = ['imap-smtp', 'wecom'] as const; +export const mailAccountKindSchema = z.enum(MAIL_ACCOUNT_KINDS); +export type MailAccountKind = z.infer; +export const MAIL_ACCOUNT_KIND_LABEL: Readonly> = { + 'imap-smtp': '标准协议(IMAP/SMTP)', + wecom: '企业微信邮件 API(应用邮箱)', +}; + +/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */ +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), + /** true = 隐式 TLS(993);false = 明文(不建议) */ + 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), + /** true = 隐式 TLS(465);false = STARTTLS(587 / 25) */ + smtpTls: z.boolean().default(true), + /** 登录名;缺省 = address */ + login: z.string().min(1).optional(), + folder: z.string().min(1).default('INBOX'), +}); +export type ImapSmtpSettings = z.infer; + +/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */ +export const wecomSettingsSchema = z.object({ + corpId: z.string().min(1), + apiBase: z.url().default('https://qyapi.weixin.qq.com'), +}); +export type WecomSettings = z.infer; + +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), + /** 轮询间隔(秒);下限 10 —— 腾讯没有推送,只能拉 */ + pollIntervalSec: z.number().int().min(10).max(3600).default(30), + /** 入站交给哪个通道;空 = channel 订阅方 skipped */ + 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.email(), + ...accountCommon, + settings: imapSmtpSettingsSchema.prefault({}), + /** 只写不读:缺省 = 不改(与 LLM 密钥同款) */ + 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(), + }), +]); +export type MailAccountInput = z.infer; + +export const mailAccountStatusSchema = z.object({ + lastPolledAt: z.iso.datetime().nullable(), + /** 厂商原话(已脱敏);空 = 上一轮没出错 */ + lastPollError: z.string().nullable(), + lastInboundAt: z.iso.datetime().nullable(), + /** 游标的人读摘要;还没拉过 = null */ + cursor: z.string().nullable(), + /** 此刻租约在手(某个进程正在拉) */ + polling: z.boolean(), +}); + +const viewCommon = { + id: z.string(), + name: z.string(), + /** imap-smtp:登录邮箱;wecom:应用邮箱(探测回填前是空串,前端摆「待探测」) */ + address: z.string(), + displayName: z.string(), + enabled: z.boolean(), + isDefault: z.boolean(), + pollIntervalSec: z.number().int(), + channelId: z.string().nullable(), + backfillFrom: z.iso.datetime().nullable(), + secretBound: z.boolean(), + status: mailAccountStatusSchema, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}; +export const mailAccountViewSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('imap-smtp'), ...viewCommon, settings: imapSmtpSettingsSchema }), + z.object({ kind: z.literal('wecom'), ...viewCommon, settings: wecomSettingsSchema }), +]); +export type MailAccountView = z.infer; + +/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */ +export const mailProbeReportSchema = z.object({ + kind: mailAccountKindSchema, + address: z.string().optional(), + receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().int().optional() }), + send: z.object({ + checked: z.boolean(), + ok: z.boolean().optional(), + error: z.string().optional(), + note: z.string().optional(), + }), +}); +export type MailProbeReport = z.infer; + +export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const; +export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES); +export type MailDeliveryState = z.infer; + +export const mailDeliveryViewSchema = z.object({ + subscriber: z.string(), + state: mailDeliveryStateSchema, + error: z.string().nullable(), + startedAt: z.iso.datetime(), + finishedAt: z.iso.datetime().nullable(), +}); +export type MailDeliveryView = z.infer; + +export const mailAddressSchema = z.object({ address: z.string(), name: z.string() }); +export type MailAddress = z.infer; + +export const mailInboundAttachmentSchema = z.object({ + index: z.number().int(), + name: z.string(), + contentType: z.string(), + size: z.number().int(), + /** `sys.upload` 的 id;超限没存 = null,理由在 note */ + ref: z.string().nullable(), + note: z.string(), +}); +export type MailInboundAttachment = z.infer; + +export const mailInboundSummarySchema = z.object({ + id: z.string(), + accountId: z.string(), + messageId: z.string(), + from: mailAddressSchema, + to: z.array(mailAddressSchema), + cc: z.array(mailAddressSchema), + subject: z.string(), + sentAt: z.iso.datetime().nullable(), + receivedAt: z.iso.datetime(), + /** 正文前 200 字 */ + textPreview: z.string(), + hasHtml: z.boolean(), + attachments: z.array(mailInboundAttachmentSchema), + deliveries: z.array(mailDeliveryViewSchema), +}); +export type MailInboundSummary = z.infer; + +export const mailInboundDetailSchema = mailInboundSummarySchema.extend({ + inReplyTo: z.string().nullable(), + references: z.array(z.string()), + text: z.string().nullable(), + html: z.string().nullable(), + size: z.number().int(), + /** 原件 .eml 存下来了(≤16MB) */ + rawAvailable: z.boolean(), +}); +export type MailInboundDetail = z.infer; + +export const mailSendInputSchema = z.object({ + /** 指定账号;缺省用默认账号 */ + accountId: z.string().optional(), + /** 指定发件地址(channel 用):必须对上一个已启用账号,不静默回落 */ + from: z.string().optional(), + to: z.array(z.email()).min(1), + cc: z.array(z.email()).default([]), + bcc: z.array(z.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'), + /** 二选一:现给字节,或引用 sys.upload 的 id */ + 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(), + }), +}); +export type MailSendInput = z.infer; + +export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const; +export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES); +export type MailOutboundState = z.infer; + +export const mailSendResultSchema = z.object({ + outboundId: z.string(), + accountId: z.string(), + messageId: z.string(), + state: z.enum(['sent', 'failed']), + error: z.string().optional(), + providerRef: z.string().optional(), +}); +export type MailSendResult = z.infer; + +export const mailOutboundViewSchema = z.object({ + id: z.string(), + accountId: z.string(), + messageId: z.string(), + to: z.array(z.string()), + cc: z.array(z.string()), + bcc: z.array(z.string()), + subject: z.string(), + state: mailOutboundStateSchema, + error: z.string().nullable(), + providerRef: z.string().nullable(), + requestedBy: z.record(z.string(), z.unknown()), + createdAt: z.iso.datetime(), + sentAt: z.iso.datetime().nullable(), +}); +export type MailOutboundView = z.infer; + +export const mailPollOutcomeSchema = z.object({ + accountId: z.string(), + /** provider 交出的封数 */ + pulled: z.number().int(), + /** 真新入库的封数(去重后) */ + stored: z.number().int(), + dispatched: z.number().int(), + /** 预算用尽还有余下,下一 tick 接着 */ + more: z.boolean(), + error: z.string().optional(), +}); +export type MailPollOutcome = z.infer; +``` + +在 `packages/contracts/src/index.ts` 的 `export * from './org.ts';` 之后加一行: + +```ts +export * from './mail.ts'; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run packages/contracts/src/__tests__/mail.test.ts && pnpm typecheck` +Expected: 4 tests PASS;typecheck 退出码 0。 + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/mail.ts packages/contracts/src/index.ts packages/contracts/src/__tests__/mail.test.ts +git commit -m "$(printf '邮件契约:账号入参按 kind 分两形、视图只回 secretBound、入站 / 出站 / 探测 schema\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 3: 路由契约 `packages/contracts/src/api/mail.ts` + +**Files:** +- Create: `packages/contracts/src/api/mail.ts` +- Modify: `packages/contracts/src/api/index.ts`(加 `export * from './mail.ts';`) +- Test: `apps/api/src/app/__tests__/mail-routes-registry.test.ts` + +**Interfaces:** +- Produces 15 条契约常量:`listMailAccountsRoute` `createMailAccountRoute` `probeMailDraftRoute` `getMailAccountRoute` `updateMailAccountRoute` `deleteMailAccountRoute` `probeMailAccountRoute` `pollMailAccountRoute` `sendTestMailRoute` `listMailInboundRoute` `getMailInboundRoute` `mailAttachmentRoute` `mailRawRoute` `redeliverMailInboundRoute` `listMailOutboundRoute`;`MAIL_ROUTES`(全部 15 条的数组,给文档 / 测试数)。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/app/__tests__/mail-routes-registry.test.ts +/** + * 邮件路由的归属:15 条全部进契约,注册表建得起来(字面 / 参数模版互不撞), + * 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方; + * 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。 + */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { MAIL_ROUTES } from '@contracts'; +import { buildRouteRegistry, routesOf } from '../route-registry.ts'; + +describe('邮件路由契约', () => { + it('15 条全在契约命名空间里,注册表能建起来', () => { + const all = routesOf(Object.values(contracts)); + const registry = buildRouteRegistry(all); + expect(MAIL_ROUTES).toHaveLength(15); + for (const route of MAIL_ROUTES) { + expect(registry.has(route.method, route.path)).toBe(true); + } + }); + + it('读写都归 ops', () => { + for (const route of MAIL_ROUTES) { + expect(route.access).toBe('ops'); + } + }); + + it('未存草稿探测与已存账号探测是两条不同的路', () => { + const registry = buildRouteRegistry(routesOf(Object.values(contracts))); + expect(registry.lookup('POST', '/api/gov/mail/accounts/probe')).toBe('ops'); + expect(registry.lookup('POST', '/api/gov/mail/accounts/acc-1/probe')).toBe('ops'); + expect(registry.lookup('GET', '/api/gov/mail/inbound/m-1/attachments/0')).toBe('ops'); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts` +Expected: FAIL(`MAIL_ROUTES` 不存在)。 + +- [ ] **Step 3: 写路由契约** + +```ts +// packages/contracts/src/api/mail.ts +/** + * 邮件收发的路由契约(设计稿 §7.2;ADR-013)。 + * + * 读写都归运营中心 —— 邮箱那一屏落在运营中心,设计端没有消费方;入站正文装的是客户来往内容,不无故加宽可读面。 + * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 + * 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。 + */ +import { z } from 'zod'; +import type { RouteContract } from './http.ts'; +import { + mailAccountInputSchema, + mailAccountViewSchema, + mailDeliveryViewSchema, + mailInboundDetailSchema, + mailInboundSummarySchema, + mailOutboundViewSchema, + mailPollOutcomeSchema, + mailProbeReportSchema, + mailSendResultSchema, +} from '../mail.ts'; + +const accountParams = z.object({ accountId: z.string().min(1) }); +const mailParams = z.object({ mailId: z.string().min(1) }); +const listQuery = z.object({ + accountId: z.string().optional(), + /** 默认 50,上限 200 */ + limit: z.coerce.number().int().min(1).max(200).optional(), +}); + +export const listMailAccountsRoute = { + method: 'GET', + path: '/api/gov/mail/accounts', + access: 'ops', + output: z.object({ accounts: z.array(mailAccountViewSchema) }), +} as const satisfies RouteContract; + +export const createMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts', + access: 'ops', + body: mailAccountInputSchema, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */ +export const probeMailDraftRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/probe', + access: 'ops', + /** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */ + query: z.object({ accountId: z.string().optional() }), + body: mailAccountInputSchema, + output: mailProbeReportSchema, +} as const satisfies RouteContract; + +export const getMailAccountRoute = { + method: 'GET', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ +export const updateMailAccountRoute = { + method: 'PUT', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + body: mailAccountInputSchema, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +export const deleteMailAccountRoute = { + method: 'DELETE', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 已存账号探测(用库里凭证);凭证空 412 */ +export const probeMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/probe', + access: 'ops', + params: accountParams, + output: mailProbeReportSchema, +} as const satisfies RouteContract; + +/** 立即拉一轮;另一进程占着租约 409;停用 412 */ +export const pollMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/poll', + access: 'ops', + params: accountParams, + output: mailPollOutcomeSchema, +} as const satisfies RouteContract; + +export const sendTestMailRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/send-test', + access: 'ops', + params: accountParams, + body: z.object({ to: z.email(), subject: z.string().min(1), text: z.string().min(1) }), + output: mailSendResultSchema, +} as const satisfies RouteContract; + +export const listMailInboundRoute = { + method: 'GET', + path: '/api/gov/mail/inbound', + access: 'ops', + query: listQuery, + output: z.object({ mails: z.array(mailInboundSummarySchema) }), +} as const satisfies RouteContract; + +export const getMailInboundRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId', + access: 'ops', + params: mailParams, + output: mailInboundDetailSchema, +} as const satisfies RouteContract; + +/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */ +export const mailAttachmentRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId/attachments/:index', + access: 'ops', + params: z.object({ mailId: z.string().min(1), index: z.coerce.number().int().min(0) }), + output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }), +} as const satisfies RouteContract; + +/** 原件 .eml;超限没存 404 */ +export const mailRawRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId/raw', + access: 'ops', + params: mailParams, + output: z.object({ name: z.string(), contentType: z.literal('message/rfc822'), dataBase64: z.string() }), +} as const satisfies RouteContract; + +/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */ +export const redeliverMailInboundRoute = { + method: 'POST', + path: '/api/gov/mail/inbound/:mailId/redeliver', + access: 'ops', + params: mailParams, + body: z.object({ subscriber: z.string().min(1).optional() }).default({}), + output: z.object({ deliveries: z.array(mailDeliveryViewSchema) }), +} as const satisfies RouteContract; + +export const listMailOutboundRoute = { + method: 'GET', + path: '/api/gov/mail/outbound', + access: 'ops', + query: listQuery, + output: z.object({ mails: z.array(mailOutboundViewSchema) }), +} as const satisfies RouteContract; + +/** 全部 15 条 —— 文档总表与覆盖测试按它数 */ +export const MAIL_ROUTES: readonly RouteContract[] = [ + listMailAccountsRoute, + createMailAccountRoute, + probeMailDraftRoute, + getMailAccountRoute, + updateMailAccountRoute, + deleteMailAccountRoute, + probeMailAccountRoute, + pollMailAccountRoute, + sendTestMailRoute, + listMailInboundRoute, + getMailInboundRoute, + mailAttachmentRoute, + mailRawRoute, + redeliverMailInboundRoute, + listMailOutboundRoute, +]; +``` + +`packages/contracts/src/api/index.ts` 末尾加: + +```ts +export * from './mail.ts'; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes-registry.test.ts && pnpm typecheck` +Expected: 3 tests PASS;typecheck 退出码 0。若 `buildRouteRegistry` 抛「路由撞车」,说明某条字面路径与参数模版同方法撞了 —— 按抛出的两条路径改 path,不改注册表。 + +- [ ] **Step 5: Commit** + +```bash +git add packages/contracts/src/api/mail.ts packages/contracts/src/api/index.ts apps/api/src/app/__tests__/mail-routes-registry.test.ts +git commit -m "$(printf '邮件路由契约 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站列表 / 附件与原件 / 重新投递\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 4: 模块骨架 + domain:类型、错误、游标、到期 + +**Files:** +- Create: `apps/api/src/modules/mail/package.json` · `index.ts` · `contract.ts` · `port.ts` · `CONTRACT.md` +- Create: `apps/api/src/modules/mail/internal/domain/{records,errors,cursor,due}.ts` +- Test: `apps/api/src/modules/mail/__tests__/{cursor,due}.test.ts` + +**Interfaces:** +- Produces:`MailService` / `InboundHandler`(contract)· 全部端口类型(port)· 记录类型(records)· 11 个错误类 · `MailCursor` 与游标函数 · `leaseMsFor` `isDue` `isPolling`。后续任务只往 `index.ts` 追加导出行。 + +- [ ] **Step 1: 写失败测试(游标与到期)** + +```ts +// apps/api/src/modules/mail/__tests__/cursor.test.ts +/** 游标两形 —— domain 零 mock。IMAP 按 UID 逐封推进,wecom 按时间窗逐批推进 */ +import { describe, expect, it } from 'vitest'; +import { + advanceImap, + cursorSummary, + imapCursor, + imapNeedsReanchor, + newUids, + parseCursor, + wecomCursor, + wecomWindow, + WECOM_OVERLAP_SEC, +} from '../internal/domain/cursor.ts'; + +describe('imap 游标', () => { + it('逐封推进只往前不往后', () => { + const c = imapCursor('7', 100); + expect(advanceImap(c, 105).lastUid).toBe(105); + expect(advanceImap(c, 90).lastUid).toBe(100); + }); + + it('UIDVALIDITY 变了、或根本不是 imap 游标,都要重锚', () => { + expect(imapNeedsReanchor(imapCursor('7', 100), '7')).toBe(false); + expect(imapNeedsReanchor(imapCursor('7', 100), '8')).toBe(true); + expect(imapNeedsReanchor(null, '7')).toBe(true); + expect(imapNeedsReanchor(wecomCursor(1), '7')).toBe(true); + }); + + it('**n:* 陷阱**:服务器会把最大那一封也回回来,客户端再按 uid > lastUid 过滤一遍,并升序去重', () => { + expect(newUids([100, 103, 101, 103, 99], 100)).toEqual([101, 103]); + expect(newUids([100], 100)).toEqual([]); + }); +}); + +describe('wecom 游标', () => { + const now = new Date('2026-08-25T10:00:00Z'); + it('有游标:窗口从 lastTime 往前退十分钟(治时钟偏,重复由唯一键挡)到当下', () => { + const w = wecomWindow(wecomCursor(1_000_000), null, now); + expect(w).toEqual({ beginTime: 1_000_000 - WECOM_OVERLAP_SEC, endTime: Math.floor(now.getTime() / 1000) }); + }); + it('无游标且不回填:窗口空(begin = end),等锚定;设了回填就从那天起', () => { + const end = Math.floor(now.getTime() / 1000); + expect(wecomWindow(null, null, now)).toEqual({ beginTime: end, endTime: end }); + const since = new Date('2026-08-20T00:00:00Z'); + expect(wecomWindow(null, since, now).beginTime).toBe(Math.floor(since.getTime() / 1000)); + }); + it('lastTime 往前退不会退成负数', () => { + expect(wecomWindow(wecomCursor(10), null, now).beginTime).toBe(0); + }); +}); + +describe('摘要与解析', () => { + it('人读摘要;没拉过是 null', () => { + expect(cursorSummary(null)).toBeNull(); + expect(cursorSummary(imapCursor('7', 100))).toBe('UID 100 · validity 7'); + expect(cursorSummary(wecomCursor(1787652000))).toBe('至 2026-08-25T10:00:00.000Z'); + }); + it('从 jsonb 解回来:形状不对当没有游标(重锚),不抛', () => { + expect(parseCursor({ kind: 'imap', uidValidity: '7', lastUid: 3 })).toEqual(imapCursor('7', 3)); + expect(parseCursor({ kind: 'wecom', lastTime: 5 })).toEqual(wecomCursor(5)); + expect(parseCursor({ kind: 'imap', lastUid: 'x' })).toBeNull(); + expect(parseCursor(null)).toBeNull(); + expect(parseCursor('garbage')).toBeNull(); + }); +}); +``` + +```ts +// apps/api/src/modules/mail/__tests__/due.test.ts +/** 到期与租约 —— 从没拉过就到期;租约在手不算到期;租约时长下限 5 分钟 */ +import { describe, expect, it } from 'vitest'; +import { isDue, isPolling, leaseMsFor, MIN_LEASE_MS } from '../internal/domain/due.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +const base = { enabled: true, lastPolledAt: null, pollIntervalSec: 30, pollingUntil: null }; + +describe('isDue', () => { + it('从没拉过 → 到期;停用 → 永不到期', () => { + expect(isDue(base, now)).toBe(true); + expect(isDue({ ...base, enabled: false }, now)).toBe(false); + }); + it('上次 + 间隔 <= 现在才到期', () => { + expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 29_000) }, now)).toBe(false); + expect(isDue({ ...base, lastPolledAt: new Date(now.getTime() - 30_000) }, now)).toBe(true); + }); + it('租约还没过期 → 别人在拉,不到期;过期了照常', () => { + expect(isDue({ ...base, pollingUntil: new Date(now.getTime() + 1) }, now)).toBe(false); + expect(isDue({ ...base, pollingUntil: new Date(now.getTime() - 1) }, now)).toBe(true); + }); +}); + +describe('leaseMsFor / isPolling', () => { + it('租约 = max(5 分钟, 3 × 间隔)', () => { + expect(leaseMsFor(30)).toBe(MIN_LEASE_MS); + expect(leaseMsFor(600)).toBe(1_800_000); + }); + it('polling 只在租约未过期时为真', () => { + expect(isPolling(null, now)).toBe(false); + expect(isPolling(new Date(now.getTime() + 10), now)).toBe(true); + expect(isPolling(new Date(now.getTime() - 10), now)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/cursor.test.ts apps/api/src/modules/mail/__tests__/due.test.ts` +Expected: FAIL(模块不存在)。 + +- [ ] **Step 3: 写骨架与 domain** + +```json +// apps/api/src/modules/mail/package.json +{ + "name": "@modules/mail", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./index.ts" } +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/errors.ts +/** 领域错误(编码规范 §5.3:领域错误住 domain,经 contract.ts 转出) */ +export class MailAccountNotFoundError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号不存在:${accountId}`); + this.name = 'MailAccountNotFoundError'; + } +} +export class MailAccountNameTakenError extends Error { + constructor(readonly accountName: string) { + super(`邮箱账号名已被占用:${accountName}`); + this.name = 'MailAccountNameTakenError'; + } +} +export class MailAccountDisabledError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号已停用:${accountId}`); + this.name = 'MailAccountDisabledError'; + } +} +export class MailNoDefaultAccountError extends Error { + constructor() { + super('没有可用的默认邮箱账号 —— 去运营中心 · 邮箱把某个账号设为默认'); + this.name = 'MailNoDefaultAccountError'; + } +} +export class MailSecretMissingError extends Error { + constructor(readonly accountId: string) { + super(`邮箱账号未配置凭证(客户端专用密码 / 应用 secret):${accountId}`); + this.name = 'MailSecretMissingError'; + } +} +export class MailPollBusyError extends Error { + constructor(readonly accountId: string) { + super(`另一台后端正在拉取这个信箱,稍后再试:${accountId}`); + this.name = 'MailPollBusyError'; + } +} +export class MailInboundNotFoundError extends Error { + constructor(readonly mailId: string) { + super(`这封信不在了:${mailId}`); + this.name = 'MailInboundNotFoundError'; + } +} +export class MailAttachmentNotFoundError extends Error { + constructor(readonly mailId: string, readonly index: number) { + super(`附件没有存下来(超过上限或不存在):${mailId} #${index}`); + this.name = 'MailAttachmentNotFoundError'; + } +} +export class MailSubscriberUnknownError extends Error { + constructor(readonly subscriber: string) { + super(`没有这个订阅方:${subscriber}`); + this.name = 'MailSubscriberUnknownError'; + } +} +export class MailSubscriberTakenError extends Error { + constructor(readonly subscriber: string) { + super(`订阅方已注册,不许重名:${subscriber}`); + this.name = 'MailSubscriberTakenError'; + } +} +export class MailSendInvalidError extends Error { + constructor(readonly problems: readonly string[]) { + super(`发信入参不合法:${problems.join('; ')}`); + this.name = 'MailSendInvalidError'; + } +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/cursor.ts +/** + * 拉取游标 —— 两种接入路各一形(设计稿 §5.3 / §9.2)。 + * IMAP:UIDVALIDITY + 最后一封 UID,逐封推进;wecom:时间窗上沿,逐批推进。 + */ +export type ImapCursor = { readonly kind: 'imap'; readonly uidValidity: string; readonly lastUid: number }; +export type WecomCursor = { readonly kind: 'wecom'; readonly lastTime: number }; +export type MailCursor = ImapCursor | WecomCursor; + +/** wecom 窗口往前退的重叠(秒):治两台机器的时钟偏;重复由唯一键挡 */ +export const WECOM_OVERLAP_SEC = 600; + +export function imapCursor(uidValidity: string, lastUid: number): ImapCursor { + return { kind: 'imap', uidValidity, lastUid }; +} + +export function advanceImap(cursor: ImapCursor, uid: number): ImapCursor { + return uid > cursor.lastUid ? { ...cursor, lastUid: uid } : cursor; +} + +export function imapNeedsReanchor(cursor: MailCursor | null, uidValidity: string): boolean { + return cursor === null || cursor.kind !== 'imap' || cursor.uidValidity !== uidValidity; +} + +/** + * IMAP 的 `n:*` 在 n 大于当前最大 UID 时仍会回最大那一封(`*` 语义)—— + * 客户端必须再过滤一遍,否则每轮都重拉最后一封(唯一键挡得住,但白拉)。 + */ +export function newUids(uids: readonly number[], lastUid: number): number[] { + return [...new Set(uids.filter((u) => u > lastUid))].sort((a, b) => a - b); +} + +export function wecomCursor(lastTime: number): WecomCursor { + return { kind: 'wecom', lastTime }; +} + +export function wecomWindow( + cursor: MailCursor | null, + backfillFrom: Date | null, + now: Date, +): { beginTime: number; endTime: number } { + const endTime = Math.floor(now.getTime() / 1000); + if (cursor !== null && cursor.kind === 'wecom') { + return { beginTime: Math.max(0, cursor.lastTime - WECOM_OVERLAP_SEC), endTime }; + } + if (backfillFrom !== null) return { beginTime: Math.floor(backfillFrom.getTime() / 1000), endTime }; + // 无游标不回填:窗口空,这一轮只是把游标锚在当下 + return { beginTime: endTime, endTime }; +} + +export function cursorSummary(cursor: MailCursor | null): string | null { + if (cursor === null) return null; + if (cursor.kind === 'imap') return `UID ${cursor.lastUid} · validity ${cursor.uidValidity}`; + return `至 ${new Date(cursor.lastTime * 1000).toISOString()}`; +} + +/** 从 jsonb 解回来 —— 形状不对当没有游标(下一轮重锚),不抛 */ +export function parseCursor(raw: unknown): MailCursor | null { + if (typeof raw !== 'object' || raw === null) return null; + const c = raw as Record; + if (c['kind'] === 'imap' && typeof c['uidValidity'] === 'string' && typeof c['lastUid'] === 'number') { + return imapCursor(c['uidValidity'], c['lastUid']); + } + if (c['kind'] === 'wecom' && typeof c['lastTime'] === 'number') return wecomCursor(c['lastTime']); + return null; +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/due.ts +/** 到期判定与租约时长(设计稿 §9.1) */ +export const MIN_LEASE_MS = 5 * 60_000; + +/** 租约 = max(5 分钟, 3 × 间隔):比最慢的一轮宽得多,又不至于让崩掉的进程锁上半天 */ +export function leaseMsFor(pollIntervalSec: number): number { + return Math.max(MIN_LEASE_MS, 3 * pollIntervalSec * 1000); +} + +export function isPolling(pollingUntil: Date | null, now: Date): boolean { + return pollingUntil !== null && pollingUntil.getTime() > now.getTime(); +} + +export function isDue( + account: { readonly enabled: boolean; readonly lastPolledAt: Date | null; readonly pollIntervalSec: number; readonly pollingUntil: Date | null }, + now: Date, +): boolean { + if (!account.enabled) return false; + if (isPolling(account.pollingUntil, now)) return false; + if (account.lastPolledAt === null) return true; + return account.lastPolledAt.getTime() + account.pollIntervalSec * 1000 <= now.getTime(); +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/records.ts +/** 库形与传输形 —— 纯类型,零逻辑(设计稿 §5.2) */ +import type { MailAccountKind, MailAddress, MailDeliveryState, MailOutboundState } from '@contracts'; +import type { MailCursor } from './cursor.ts'; + +export type MailAccountRecord = { + readonly id: string; + readonly kind: MailAccountKind; + readonly name: string; + readonly address: string; + readonly displayName: string; + readonly enabled: boolean; + readonly isDefault: boolean; + readonly pollIntervalSec: number; + readonly channelId: string | null; + readonly backfillFrom: Date | null; + /** 按 kind 的非密配置(imapSmtpSettingsSchema / wecomSettingsSchema 的形) */ + readonly settings: Record; + /** 密封的 JSON:{password} | {corpSecret};永不出接口 */ + readonly secretCipher: string | null; + readonly cursor: MailCursor | null; + readonly lastPolledAt: Date | null; + readonly lastPollError: string | null; + readonly lastInboundAt: Date | null; + readonly pollingUntil: Date | null; + readonly createdAt: Date; + readonly updatedAt: Date; +}; + +export type InboundAttachmentRecord = { + readonly index: number; + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly ref: string | null; + readonly note: string; +}; + +export type InboundRecord = { + readonly id: string; + readonly accountId: string; + readonly messageId: string; + readonly providerRef: string; + readonly inReplyTo: string | null; + readonly references: readonly string[]; + readonly from: MailAddress; + readonly to: readonly MailAddress[]; + readonly cc: readonly MailAddress[]; + readonly subject: string; + readonly text: string | null; + readonly html: string | null; + readonly sentAt: Date | null; + readonly receivedAt: Date; + readonly attachments: readonly InboundAttachmentRecord[]; + readonly raw: Uint8Array | null; + readonly rawNote: string; + readonly size: number; + readonly createdAt: Date; +}; + +export type DeliveryRecord = { + readonly mailId: string; + readonly subscriber: string; + readonly state: MailDeliveryState; + readonly error: string | null; + readonly startedAt: Date; + readonly finishedAt: Date | null; +}; + +export type OutboundAttachmentRecord = { + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly ref: string | null; +}; + +export type OutboundRecord = { + readonly id: string; + readonly accountId: string; + readonly messageId: string; + readonly idempotencyKey: string | null; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly bcc: readonly string[]; + readonly subject: string; + readonly text: string | null; + readonly html: string | null; + readonly inReplyTo: string | null; + readonly references: readonly string[]; + readonly attachments: readonly OutboundAttachmentRecord[]; + readonly state: MailOutboundState; + readonly error: string | null; + readonly providerRef: string | null; + readonly requestedBy: Record; + readonly createdAt: Date; + readonly sentAt: Date | null; +}; + +export type ParsedAttachment = { readonly name: string; readonly contentType: string; readonly bytes: Uint8Array }; + +/** MIME 解析结果 —— 地址、Message-ID、References 已归一为裸串 */ +export type ParsedMail = { + readonly messageId?: string | undefined; + readonly inReplyTo?: string | undefined; + readonly references: readonly string[]; + readonly from?: MailAddress | undefined; + readonly to: readonly MailAddress[]; + readonly cc: readonly MailAddress[]; + readonly subject: string; + readonly text?: string | undefined; + readonly html?: string | undefined; + readonly date?: Date | undefined; + readonly attachments: readonly ParsedAttachment[]; +}; + +/** 交给 provider 的出信 —— 附件已是字节 */ +export type OutboundMessage = { + readonly messageId: string; + readonly from: MailAddress; + readonly to: readonly string[]; + readonly cc: readonly string[]; + readonly bcc: readonly string[]; + readonly subject: string; + readonly text?: string | undefined; + readonly html?: string | undefined; + readonly inReplyTo?: string | undefined; + readonly references: readonly string[]; + readonly attachments: readonly ParsedAttachment[]; +}; +``` + +```ts +// apps/api/src/modules/mail/contract.ts +/** + * module: mail —— 对外类型(编码规范 §10.1;设计稿 §5.1)。 + */ +import type { + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, +} from '@contracts'; + +export type { + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, +}; + +export { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from './internal/domain/errors.ts'; + +/** + * 订阅方回调 —— 「注册回调读到邮件内容」的落点。 + * 返回 `{skipped}` 表示这封信与我无关(留痕 skipped,不算失败);抛错留痕 failed(原话脱敏)。 + * **自动只投一次,不自动重试**(设计稿 §9.3);人按「重新投递」才再来。 + */ +export type InboundHandler = ( + mail: MailInboundDetail, + ctx: { readonly accountId: string; readonly channelId: string | undefined }, +) => Promise; + +export type MailService = { + /* ── 账号 ── */ + listAccounts(): Promise; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + /** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ + updateAccount(accountId: string, input: MailAccountInput): Promise; + /** 留痕不删(入站 / 出站表对账号不设外键) */ + deleteAccount(accountId: string): Promise; + /** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */ + probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise; + /* ── 收 ── */ + /** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */ + pollDue(now: Date): Promise; + /** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */ + pollNow(accountId: string): Promise; + /** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */ + subscribe(name: string, handler: InboundHandler): () => void; + listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise; + getInbound(mailId: string): Promise; + attachment(mailId: string, index: number): Promise<{ name: string; contentType: string; bytes: Uint8Array }>; + /** 原件 .eml;超限没存 → MailAttachmentNotFoundError(index = -1) */ + raw(mailId: string): Promise<{ name: string; bytes: Uint8Array }>; + /** 人按的钮:指定订阅方或全部;正在 dispatching 的那条跳过 */ + redeliver(mailId: string, subscriber?: string): Promise; + /* ── 发 ── */ + /** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */ + send(input: MailSendInput): Promise; + listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise; +}; +``` + +```ts +// apps/api/src/modules/mail/port.ts +/** + * module: mail —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3;设计稿 §5.2)。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import type { MailCursor } from './internal/domain/cursor.ts'; +import type { + DeliveryRecord, + InboundRecord, + MailAccountRecord, + OutboundMessage, + OutboundRecord, + ParsedMail, +} from './internal/domain/records.ts'; + +export type { DeliveryRecord, InboundRecord, MailAccountRecord, MailCursor, OutboundMessage, OutboundRecord, ParsedMail }; + +/** 凭证密封 —— 与 llm / toolbox 同款,app 注入同一实例(ADR-009) */ +export type SecretBox = { seal(plaintext: string): string; open(cipher: string): string }; +export type Clock = { now(): Date }; +export type IdGenerator = { next(): string }; + +export type MailAccountStore = { + list(): Promise; + get(id: string): Promise; + findByName(name: string): Promise; + /** upsert;`isDefault:true` 时先把别的置 false(同一事务) */ + save(record: MailAccountRecord): Promise; + remove(id: string): Promise; + /** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */ + listDue(now: Date): Promise; + /** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */ + claimPolling(id: string, leaseMs: number): Promise; + releasePolling(id: string): Promise; + /** 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = no-op */ + savePoll( + id: string, + patch: { + cursor?: MailCursor; + polledAt?: Date; + error?: string | null; + lastInboundAt?: Date; + address?: string; + }, + ): Promise; +}; + +export type InboundStore = { + /** (account_id, message_id) / (account_id, provider_ref) 冲突 → inserted:false,不抛 */ + insert(record: InboundRecord): Promise<{ inserted: boolean }>; + knownProviderRefs(accountId: string, refs: readonly string[]): Promise>; + list(query: { accountId?: string | undefined; limit: number }): Promise>; + get(mailId: string): Promise<{ record: InboundRecord; deliveries: DeliveryRecord[] } | undefined>; + /** upsert on (mail_id, subscriber) */ + setDelivery(delivery: DeliveryRecord): Promise; +}; + +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise; + list(query: { accountId?: string | undefined; limit: number }): Promise; +}; + +/** 附件存放 —— app 注入 = `sys.upload`(复用 8MB 上限、文本抽取、下载路由) */ +export type AttachmentStore = { + put(file: { name: string; contentType: string; bytes: Uint8Array; note: string }): Promise<{ ref: string }>; + get(ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined>; +}; + +/** 交给 provider 的账号:凭证明文只在这一层与 usecase 之间传,不落任何留痕 */ +export type ProviderAccount = { + readonly id: string; + readonly kind: MailAccountKind; + readonly address: string; + readonly displayName: string; + readonly settings: Record; + readonly secret: Record; +}; + +export type RawMessage = { + readonly providerRef: string; + readonly eml: Uint8Array; + readonly receivedAt: Date; + /** 这一封之后游标该在哪(IMAP 逐封给;wecom 不给,整批完才推进) */ + readonly cursorAfter?: MailCursor | undefined; +}; + +/** 外界:一种账号类型一个适配器(设计稿 §5.5) */ +export type MailProvider = { + readonly kind: MailAccountKind; + probe(account: ProviderAccount): Promise; + /** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise; + pull( + account: ProviderAccount, + cursor: MailCursor, + opts: { + readonly limit: number; + readonly now: Date; + readonly backfillFrom: Date | null; + /** 库里已有的 provider_ref —— wecom 用它在 read_mail 之前把重叠窗里的旧信滤掉;IMAP 靠 UID 游标不用 */ + readonly known: (refs: readonly string[]) => Promise>; + }, + ): Promise<{ messages: RawMessage[]; cursor: MailCursor; more: boolean }>; + send(account: ProviderAccount, message: OutboundMessage): Promise<{ providerRef?: string | undefined }>; +}; + +export type MimeParser = { parse(eml: Uint8Array): Promise }; + +/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */ +export type ChangeNotifier = { changed(reason: string): void }; +``` + +```ts +// apps/api/src/modules/mail/index.ts +/** + * module: mail —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + InboundHandler, + MailAccountInput, + MailAccountView, + MailDeliveryView, + MailInboundDetail, + MailInboundSummary, + MailOutboundView, + MailPollOutcome, + MailProbeReport, + MailSendInput, + MailSendResult, + MailService, +} from './contract.ts'; +export { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from './contract.ts'; + +export type { + AttachmentStore, + ChangeNotifier, + Clock, + DeliveryRecord, + IdGenerator, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + MailCursor, + MailProvider, + MimeParser, + OutboundMessage, + OutboundRecord, + OutboundStore, + ParsedMail, + ProviderAccount, + RawMessage, + SecretBox, +} from './port.ts'; + +export { + advanceImap, + cursorSummary, + imapCursor, + imapNeedsReanchor, + newUids, + parseCursor, + wecomCursor, + wecomWindow, +} from './internal/domain/cursor.ts'; +export { isDue, isPolling, leaseMsFor } from './internal/domain/due.ts'; +``` + +```markdown + +# module: mail + +职责: 邮箱账号(配置 + 凭证密封)、收(轮询拉取 → MIME 解析 → 入站留痕 → 分发订阅方)、 + 发(出站留痕 → provider 发出)、订阅注册表、连通探测;两条接入路(IMAP/SMTP · 企业微信邮件 API) +不负责: 归一 / 归属 / 事件(见 channel、event)、放行意向(见 release)、工具白名单(见 toolbox)、 + SSE(app 经 `ChangeNotifier` 端口接到 broadcaster) + +公开出口: index.ts + - 类型:`MailService` `InboundHandler` 与 @contracts 的 `MailAccountInput` / `MailAccountView` / + `MailInboundSummary` / `MailInboundDetail` / `MailOutboundView` / `MailSendInput` / `MailSendResult` / + `MailProbeReport` / `MailPollOutcome` / `MailDeliveryView` 转出 + - 错误:`MailAccountNotFoundError`(404)`MailAccountNameTakenError`(409)`MailAccountDisabledError`(412) + `MailNoDefaultAccountError`(412)`MailSecretMissingError`(412)`MailPollBusyError`(409) + `MailInboundNotFoundError`(404)`MailAttachmentNotFoundError`(404)`MailSubscriberUnknownError`(404) + `MailSubscriberTakenError` `MailSendInvalidError`(400) + - 端口类型:`MailAccountStore` `InboundStore` `OutboundStore` `AttachmentStore` `MailProvider` `MimeParser` + `SecretBox` `Clock` `IdGenerator` `ChangeNotifier`;记录类型 `MailAccountRecord` `InboundRecord` + `OutboundRecord` `DeliveryRecord` `ParsedMail` `OutboundMessage` `ProviderAccount` `RawMessage` `MailCursor` + - 用例工厂:`createMailService(deps: MailServiceDeps): MailService` + - 纯函数:游标(`imapCursor` `advanceImap` `imapNeedsReanchor` `newUids` `wecomCursor` `wecomWindow` + `cursorSummary` `parseCursor`)、到期(`isDue` `isPolling` `leaseMsFor`)、 + 入站(`buildInboundRecord` `planAttachments` `bodyTextOf` `stripHtml` `threadIdOf` `summaryOf` `detailOf`)、 + 脱敏(`redactSecrets` `secretVariants` `errorText`)、发信(`validateSend` `pickAccount` `messageIdFor`)、 + 账号(`viewOf` `mergeAccount` `secretOf` `cursorReset`)、分发(`canRedeliver` `stateOfOutcome`) + - adapter:`createMailAccountStore` `createInboundStore` `createOutboundStore`(Drizzle)、 + `createPostalMimeParser`、`createImapSmtpProvider`、`createWecomProvider`、`createFakeMailProvider`; + Drizzle 表:`mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable` + +层级: L1(编码规范 §4) +依赖: @contracts;@shared/db;npm(只在 adapter):imapflow / nodemailer / postal-mime +被依赖: app(container 装配 / routes / workers / tools)。channel 与 toolbox **不 import** 本模块: + channel 经 app 注册为订阅方并由 app 注入 `MailTransport` 转 `mail.send`; + `mail.send` 工具处理器在 app 里闭包持本服务 + +不变量(ADR-013): + - **入站先持久后分发**:`sys.mail_inbound` 两把唯一键 `(account_id, message_id)` / `(account_id, provider_ref)`, + 重复拉到一律 `inserted:false` 静默跳过、不分发;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处 + - **分发自动只投一次**:先落 `dispatching` 再调,`done / skipped / failed(原话脱敏)`;不自动重试 —— + channel → event 按 `cause.ref` 不幂等,重投会起两单;「重新投递」是人按的钮,那一刻起是至少一次; + 一个订阅方失败不影响其它;订阅是代码级装配,不落库、不回放老信 + - **首次不回填**:无游标锚在当下;`backfillFrom` 才回填;IMAP 只读打开文件夹,不改已读标记 + - **跨进程租约**:`polling_until` 一条 SQL 判 + 占,过期 max(5min, 3×间隔);抢不到 → `MailPollBusyError` + - **出站先落行**:`sending` → provider → `sent` / `failed(原话)`;带 `idempotencyKey` 且已 `sent` 的不发第二封; + 厂商失败**不抛**,以 `state='failed'` 返回(调用方各自翻译) + - **凭证密封**:`secret_cipher` 永不出接口,视图只回 `secretBound`;更新时 secret 缺省 = 不改; + 明文只在 usecase 打开、交给 provider 那一段;所有落库的厂商原话先过 `redactSecrets` + - **界面不撒谎**:探测收 / 发分栏各报;wecom 的发信一栏 `checked:false`(拿到 token 不等于能发); + wecom 的 `address` 探到之前是空串 + - **两缝**(§7.3):`MailProvider` 不经 ModelGateway / ToolInvoker —— 入站是外界事实、持久化即记录 + (回放读 `sys.mail_inbound`,永不重拉);活动侧出站经 `ToolInvoker`(`mail.send` 工具),系统侧留痕 + `sys.mail_outbound`;`createFakeMailProvider` 是测试替身 + +影响半径: 新模块;channel / toolbox / event 的 index / contract / port 未变 +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: cursor 7 + due 5 = 12 tests PASS;三道门禁退出码 0(`no-orphans` 对 index/contract/port 与 domain 文件:records / errors 被 contract.ts 与 port.ts 引用,cursor / due 被 index.ts 引用,不算孤儿)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail 模块骨架:契约 / 端口 / 领域错误 / 游标两形 / 到期与租约(ADR-013)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 5: domain:入站记录与脱敏 + +**Files:** +- Create: `apps/api/src/modules/mail/internal/domain/inbound-record.ts` · `redact.ts` +- Modify: `apps/api/src/modules/mail/index.ts`(追加导出) +- Test: `apps/api/src/modules/mail/__tests__/{inbound-record,redact}.test.ts` + +**Interfaces:** +- Produces:`ATTACHMENT_CAP` `RAW_CAP` `PREVIEW_CHARS` `fallbackMessageId(providerRef, address)` `threadIdOf(parsed)` `stripHtml(html)` `bodyTextOf(parsed)` `textPreviewOf(text, html)` `planAttachments(attachments, cap?)` → `AttachmentPlan[]` `rawOf(eml, cap?)` `buildInboundRecord(input)` `deliveryViewOf(d)` `summaryOf(record, deliveries)` `detailOf(record, deliveries)`;`secretVariants(secret)` `redactSecrets(text, secrets)` `errorText(error)`。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/modules/mail/__tests__/inbound-record.test.ts +/** 解析结果 → 入站记录:缺 Message-ID 顶上、线程取 In-Reply-To、纯 html 剥标签、附件按上限分「存」与「留注」 */ +import { describe, expect, it } from 'vitest'; +import type { ParsedMail } from '../internal/domain/records.ts'; +import { + ATTACHMENT_CAP, + bodyTextOf, + buildInboundRecord, + detailOf, + fallbackMessageId, + planAttachments, + rawOf, + stripHtml, + summaryOf, + textPreviewOf, + threadIdOf, +} from '../internal/domain/inbound-record.ts'; + +const parsed: ParsedMail = { + messageId: '', + inReplyTo: '', + references: ['', ''], + from: { address: 'buyer@acme.com', name: '张三' }, + to: [{ address: 'sales@we.com', name: '' }], + cc: [], + subject: '询价 RFQ-1', + text: '你好,请报价', + html: '

你好,请报价

', + date: new Date('2026-08-25T09:00:00Z'), + attachments: [{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', bytes: new Uint8Array(10) }], +}; +const now = new Date('2026-08-25T10:00:00Z'); + +describe('线程与正文', () => { + it('线程 = In-Reply-To;没有就取 References 末项;都没有就是 undefined', () => { + expect(threadIdOf(parsed)).toBe(''); + expect(threadIdOf({ references: ['
', ''] })).toBe(''); + expect(threadIdOf({ references: [] })).toBeUndefined(); + }); + it('缺 Message-ID 用 providerRef@域顶上,不抛', () => { + expect(fallbackMessageId('uid:7:12', 'sales@we.com')).toBe(''); + expect(fallbackMessageId('x', 'no-at')).toBe(''); + }); + it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => { + expect(stripHtml('

你好&世界

\n 再见')).toBe('你好&世界 再见'); + expect(bodyTextOf({ html: '
只有 html
' })).toBe('只有 html'); + expect(bodyTextOf({ text: '有 text', html: '忽略' })).toBe('有 text'); + expect(bodyTextOf({})).toBe(''); + }); + it('预览 200 字', () => { + expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200); + expect(textPreviewOf(null, '

来自 html

')).toBe('来自 html'); + }); +}); + +describe('附件与原件', () => { + it('超上限的附件不存,留注;下标按原顺序', () => { + const plan = planAttachments( + [ + { name: 'a.pdf', contentType: 'application/pdf', bytes: new Uint8Array(3) }, + { name: 'big.zip', contentType: 'application/zip', bytes: new Uint8Array(ATTACHMENT_CAP + 1) }, + ], + ATTACHMENT_CAP, + ); + expect(plan.map((p) => [p.index, p.store])).toEqual([ + [0, true], + [1, false], + ]); + expect(plan[1]!.note).toContain('8MB'); + }); + it('原件超上限留空 + 注', () => { + expect(rawOf(new Uint8Array(4), 8)).toEqual({ raw: new Uint8Array(4), note: '' }); + const big = rawOf(new Uint8Array(9), 8); + expect(big.raw).toBeNull(); + expect(big.note).not.toBe(''); + }); +}); + +describe('buildInboundRecord / 视图', () => { + const record = buildInboundRecord({ + id: 'in-1', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid:7:12', + parsed, + eml: new Uint8Array([1, 2, 3]), + receivedAt: now, + attachments: [{ index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }], + now, + }); + + it('记录照抄解析结果,raw 存下来,size 是原件字节数', () => { + expect(record.messageId).toBe(''); + expect(record.inReplyTo).toBe(''); + expect(record.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(record.size).toBe(3); + expect(record.raw).toEqual(new Uint8Array([1, 2, 3])); + expect(record.sentAt?.toISOString()).toBe('2026-08-25T09:00:00.000Z'); + }); + it('缺 from / 缺 Message-ID 也能落记录', () => { + const bare = buildInboundRecord({ + id: 'in-2', + accountId: 'acc-1', + address: 'sales@we.com', + providerRef: 'uid:7:13', + parsed: { references: [], to: [], cc: [], subject: '', attachments: [] }, + eml: new Uint8Array(), + receivedAt: now, + attachments: [], + now, + }); + expect(bare.messageId).toBe(''); + expect(bare.from).toEqual({ address: '', name: '' }); + }); + it('摘要不带正文只带预览;详情带正文、rawAvailable、投递', () => { + const deliveries = [{ mailId: 'in-1', subscriber: 'channel', state: 'done' as const, error: null, startedAt: now, finishedAt: now }]; + const summary = summaryOf(record, deliveries); + expect(summary.textPreview).toBe('你好,请报价'); + expect(summary.hasHtml).toBe(true); + expect(summary.deliveries[0]).toEqual({ subscriber: 'channel', state: 'done', error: null, startedAt: now.toISOString(), finishedAt: now.toISOString() }); + expect('text' in summary).toBe(false); + const detail = detailOf(record, deliveries); + expect(detail.text).toBe('你好,请报价'); + expect(detail.rawAvailable).toBe(true); + expect(detail.references).toEqual(['', '']); + }); +}); +``` + +```ts +// apps/api/src/modules/mail/__tests__/redact.test.ts +/** 脱敏三形:明文、base64、URL 编码 —— nodemailer 的 response 行会把 AUTH 明文 / 密文都带出来 */ +import { describe, expect, it } from 'vitest'; +import { errorText, redactSecrets, secretVariants } from '../internal/domain/redact.ts'; + +describe('redactSecrets', () => { + it('三形都换成 ***', () => { + const secret = 'p@ss w0rd'; + const text = `login p@ss w0rd failed; AUTH ${Buffer.from(secret).toString('base64')}; url ${encodeURIComponent(secret)}`; + expect(redactSecrets(text, [secret])).toBe('login *** failed; AUTH ***; url ***'); + }); + it('短于 4 字或空的密钥不脱(否则一封信的每个字母都成了 ***)', () => { + expect(secretVariants('')).toEqual([]); + expect(secretVariants('abc')).toEqual([]); + expect(redactSecrets('abc abc', ['abc'])).toBe('abc abc'); + }); + it('没命中原样返回;正则元字符当字面', () => { + expect(redactSecrets('nothing here', ['s3cret!'])).toBe('nothing here'); + expect(redactSecrets('key=a.b*c', ['a.b*c'])).toBe('key=***'); + }); +}); + +describe('errorText', () => { + it('Error 取 message;nodemailer 式的 response 一并带上;别的 String()', () => { + expect(errorText(new Error('boom'))).toBe('boom'); + const withResponse = Object.assign(new Error('Invalid login'), { response: '535 5.7.8 Error: authentication failed' }); + expect(errorText(withResponse)).toBe('Invalid login —— 535 5.7.8 Error: authentication failed'); + expect(errorText('raw')).toBe('raw'); + expect(errorText(undefined)).toBe('未知错误'); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/inbound-record.test.ts apps/api/src/modules/mail/__tests__/redact.test.ts` +Expected: FAIL(模块不存在)。 + +- [ ] **Step 3: 写实现** + +```ts +// apps/api/src/modules/mail/internal/domain/inbound-record.ts +/** + * 解析结果 → 入站记录 / 视图(设计稿 §5.3)。零 IO:附件的存放由 usecase 按 `planAttachments` 去做, + * 存完把 ref 交回来再 `buildInboundRecord`。 + */ +import type { MailDeliveryView, MailInboundDetail, MailInboundSummary } from '@contracts'; +import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, ParsedAttachment, ParsedMail } from './records.ts'; + +/** 与 sys.upload 的上限同一个数(超了当场留注,不静默截断) */ +export const ATTACHMENT_CAP = 8 * 1024 * 1024; +export const RAW_CAP = 16 * 1024 * 1024; +export const PREVIEW_CHARS = 200; + +function domainOf(address: string): string { + const at = address.lastIndexOf('@'); + return at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); +} + +/** 缺 Message-ID 的信(有的自动回复就没有)用 providerRef 顶上 —— 唯一键要的是一个稳定的串 */ +export function fallbackMessageId(providerRef: string, address: string): string { + return `<${providerRef}@${domainOf(address)}>`; +} + +/** 线程 = In-Reply-To(父)⊃ References 末项;归属解析①级拿它对出站的 Message-ID */ +export function threadIdOf(parsed: Pick): string | undefined { + if (parsed.inReplyTo !== undefined && parsed.inReplyTo !== '') return parsed.inReplyTo; + return parsed.references.at(-1); +} + +const ENTITIES: Readonly> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; + +export function stripHtml(html: string): string { + return html + .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') + .replace(/|<\/(p|div|tr|li|h[1-6])>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => { + if (code.startsWith('#x') || code.startsWith('#X')) return String.fromCodePoint(Number.parseInt(code.slice(2), 16)); + if (code.startsWith('#')) return String.fromCodePoint(Number(code.slice(1))); + return ENTITIES[code.toLowerCase()] ?? whole; + }) + .replace(/[ \t\r\f\v]+/g, ' ') + .replace(/\s*\n\s*/g, ' ') + .trim(); +} + +export function bodyTextOf(parsed: Pick): string { + if (parsed.text !== undefined && parsed.text.trim() !== '') return parsed.text; + if (parsed.html !== undefined) return stripHtml(parsed.html); + return ''; +} + +export function textPreviewOf(text: string | null, html: string | null): string { + const body = bodyTextOf({ ...(text === null ? {} : { text }), ...(html === null ? {} : { html }) }); + return body.slice(0, PREVIEW_CHARS); +} + +export type AttachmentPlan = { + readonly index: number; + readonly name: string; + readonly contentType: string; + readonly size: number; + readonly store: boolean; + readonly note: string; +}; + +export function planAttachments(attachments: readonly ParsedAttachment[], cap: number = ATTACHMENT_CAP): AttachmentPlan[] { + return attachments.map((a, index) => { + const size = a.bytes.byteLength; + const store = size <= cap; + return { + index, + name: a.name === '' ? `attachment-${index + 1}` : a.name, + contentType: a.contentType === '' ? 'application/octet-stream' : a.contentType, + size, + store, + note: store ? '' : `附件 ${(size / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存原件`, + }; + }); +} + +export function rawOf(eml: Uint8Array, cap: number = RAW_CAP): { raw: Uint8Array | null; note: string } { + if (eml.byteLength <= cap) return { raw: eml, note: '' }; + return { raw: null, note: `原件 ${(eml.byteLength / 1024 / 1024).toFixed(1)}MB,超过 ${Math.round(cap / 1024 / 1024)}MB 上限,未存` }; +} + +export function buildInboundRecord(input: { + readonly id: string; + readonly accountId: string; + readonly address: string; + readonly providerRef: string; + readonly parsed: ParsedMail; + readonly eml: Uint8Array; + readonly receivedAt: Date; + readonly attachments: readonly InboundAttachmentRecord[]; + readonly now: Date; + readonly rawCap?: number | undefined; +}): InboundRecord { + const { parsed } = input; + const messageId = parsed.messageId !== undefined && parsed.messageId !== '' ? parsed.messageId : fallbackMessageId(input.providerRef, input.address); + const { raw, note } = rawOf(input.eml, input.rawCap ?? RAW_CAP); + return { + id: input.id, + accountId: input.accountId, + messageId, + providerRef: input.providerRef, + inReplyTo: parsed.inReplyTo ?? null, + references: [...parsed.references], + from: parsed.from ?? { address: '', name: '' }, + to: [...parsed.to], + cc: [...parsed.cc], + subject: parsed.subject, + text: parsed.text ?? null, + html: parsed.html ?? null, + sentAt: parsed.date ?? null, + receivedAt: input.receivedAt, + attachments: [...input.attachments], + raw, + rawNote: note, + size: input.eml.byteLength, + createdAt: input.now, + }; +} + +export function deliveryViewOf(d: DeliveryRecord): MailDeliveryView { + return { + subscriber: d.subscriber, + state: d.state, + error: d.error, + startedAt: d.startedAt.toISOString(), + finishedAt: d.finishedAt === null ? null : d.finishedAt.toISOString(), + }; +} + +export function summaryOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundSummary { + return { + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + from: record.from, + to: [...record.to], + cc: [...record.cc], + subject: record.subject, + sentAt: record.sentAt === null ? null : record.sentAt.toISOString(), + receivedAt: record.receivedAt.toISOString(), + textPreview: textPreviewOf(record.text, record.html), + hasHtml: record.html !== null, + attachments: record.attachments.map((a) => ({ ...a })), + deliveries: deliveries.map(deliveryViewOf), + }; +} + +export function detailOf(record: InboundRecord, deliveries: readonly DeliveryRecord[]): MailInboundDetail { + return { + ...summaryOf(record, deliveries), + inReplyTo: record.inReplyTo, + references: [...record.references], + text: record.text, + html: record.html, + size: record.size, + rawAvailable: record.raw !== null, + }; +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/redact.ts +/** + * 脱敏 —— 凡要落库 / 上接口的厂商原话都先过这里(设计稿 §9.4)。 + * 明文、base64(SMTP AUTH 的形)、URL 编码三形都换掉。 + */ +const MIN_SECRET_CHARS = 4; + +export function secretVariants(secret: string): string[] { + if (secret.length < MIN_SECRET_CHARS) return []; + const variants = [secret, Buffer.from(secret, 'utf8').toString('base64'), encodeURIComponent(secret)]; + return [...new Set(variants.filter((v) => v.length >= MIN_SECRET_CHARS))]; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function redactSecrets(text: string, secrets: readonly string[]): string { + let out = text; + for (const secret of secrets) { + for (const variant of secretVariants(secret)) { + out = out.replace(new RegExp(escapeRegex(variant), 'g'), '***'); + } + } + return out; +} + +/** 错误 → 一句话;nodemailer / imapflow 把服务器回话挂在 `response` / `responseText` 上,一并带上 */ +export function errorText(error: unknown): string { + if (error instanceof Error) { + const bag = error as Error & { response?: unknown; responseText?: unknown }; + const response = typeof bag.response === 'string' ? bag.response : typeof bag.responseText === 'string' ? bag.responseText : ''; + return response === '' || response === error.message ? error.message : `${error.message} —— ${response}`; + } + if (error === undefined || error === null) return '未知错误'; + return String(error); +} +``` + +`index.ts` 追加: + +```ts +export { + ATTACHMENT_CAP, + bodyTextOf, + buildInboundRecord, + detailOf, + planAttachments, + RAW_CAP, + stripHtml, + summaryOf, + threadIdOf, +} from './internal/domain/inbound-record.ts'; +export { errorText, redactSecrets, secretVariants } from './internal/domain/redact.ts'; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail && pnpm lint` +Expected: 全部 PASS;lint 退出码 0(domain 里 `Buffer` 是全局对象不是 import,不触 `domain-is-pure-core`)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail domain:解析结果 → 入站记录 / 视图,附件与原件按上限分存留注,厂商原话脱敏三形\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 6: domain:发信校验、选账号、分发状态、账号视图 + +**Files:** +- Create: `apps/api/src/modules/mail/internal/domain/{send-input,delivery,account-view}.ts` +- Modify: `apps/api/src/modules/mail/index.ts`(追加导出) +- Test: `apps/api/src/modules/mail/__tests__/{send-input,delivery,account-view}.test.ts` + +**Interfaces:** +- Produces:`validateSend(input, kind, sizes)` → `string[]`;`pickAccount(accounts, {accountId?, from?})` → `MailAccountRecord`(抛 `MailAccountNotFoundError` / `MailAccountDisabledError` / `MailNoDefaultAccountError`);`messageIdFor(id, address)`;`canRedeliver(state)`;`stateOfOutcome(outcome)`;`viewOf(record, now)` → `MailAccountView`;`secretOf(input)` → `string | undefined`(JSON);`cursorReset(existing, input)`;`mergeAccount(input, base)` → `MailAccountRecord`。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/modules/mail/__tests__/send-input.test.ts +/** 发信入参校验与选账号 —— 指定的必须存在且启用;不指定用唯一默认;from 必须对上账号,不静默回落 */ +import { describe, expect, it } from 'vitest'; +import type { MailAccountRecord } from '../internal/domain/records.ts'; +import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from '../internal/domain/errors.ts'; +import { messageIdFor, pickAccount, SEND_ATTACHMENT_CAP, validateSend } from '../internal/domain/send-input.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +function account(over: Partial): MailAccountRecord { + return { + id: 'a', kind: 'imap-smtp', name: 'a', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, + pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: {}, secretCipher: 'c', cursor: null, + lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, + ...over, + }; +} +const base = { to: ['x@y.com'], cc: [], bcc: [], subject: '主题', text: '正文', html: undefined, attachments: [] }; + +describe('validateSend', () => { + it('合法入参零问题', () => { + expect(validateSend(base, 'imap-smtp', [])).toEqual([]); + }); + it('正文 text / html 至少一个;附件单个 ≤8MB、总 ≤20MB', () => { + expect(validateSend({ ...base, text: undefined }, 'imap-smtp', [])).toEqual(['正文 text / html 至少一个']); + expect(validateSend(base, 'imap-smtp', [SEND_ATTACHMENT_CAP + 1])[0]).toContain('8MB'); + expect(validateSend(base, 'imap-smtp', [7_000_000, 7_000_000, 7_000_000])[0]).toContain('20MB'); + }); + it('wecom 按文档:正文 + 附件 ≤50MB、附件 ≤200 个', () => { + expect(validateSend(base, 'wecom', Array.from({ length: 201 }, () => 1))[0]).toContain('200'); + expect(validateSend(base, 'wecom', Array.from({ length: 3 }, () => 7_000_000))).toEqual([]); + }); +}); + +describe('pickAccount', () => { + const a = account({ id: 'a', isDefault: true }); + const b = account({ id: 'b', address: 'b@we.com', enabled: false }); + it('不指定 → 唯一默认;没有默认 → MailNoDefaultAccountError', () => { + expect(pickAccount([a, b], {}).id).toBe('a'); + expect(() => pickAccount([b], {})).toThrow(MailNoDefaultAccountError); + }); + it('指定 id:不存在 404、停用 412', () => { + expect(pickAccount([a, b], { accountId: 'a' }).id).toBe('a'); + expect(() => pickAccount([a, b], { accountId: 'zz' })).toThrow(MailAccountNotFoundError); + expect(() => pickAccount([a, b], { accountId: 'b' })).toThrow(MailAccountDisabledError); + }); + it('指定 from:按地址(不分大小写)对账号,对不上不回落默认', () => { + expect(pickAccount([a, b], { from: 'A@WE.com' }).id).toBe('a'); + expect(() => pickAccount([a, b], { from: 'nobody@we.com' })).toThrow(MailAccountNotFoundError); + expect(() => pickAccount([a, b], { from: 'b@we.com' })).toThrow(MailAccountDisabledError); + }); +}); + +describe('messageIdFor', () => { + it(';地址没有域用 local', () => { + expect(messageIdFor('u-1', 'a@we.com')).toBe(''); + expect(messageIdFor('u-1', '')).toBe(''); + }); +}); +``` + +```ts +// apps/api/src/modules/mail/__tests__/delivery.test.ts +/** 分发状态机:dispatching 不能被重投;回调回 skipped 就是 skipped */ +import { describe, expect, it } from 'vitest'; +import { canRedeliver, stateOfOutcome } from '../internal/domain/delivery.ts'; + +describe('delivery', () => { + it('从没投过 / done / failed / skipped 都能重投;正在投的不能', () => { + expect(canRedeliver(undefined)).toBe(true); + expect(canRedeliver('done')).toBe(true); + expect(canRedeliver('failed')).toBe(true); + expect(canRedeliver('skipped')).toBe(true); + expect(canRedeliver('dispatching')).toBe(false); + }); + it('回调的返回值定状态', () => { + expect(stateOfOutcome(undefined)).toBe('done'); + expect(stateOfOutcome({ skipped: '账号未绑定通道' })).toBe('skipped'); + }); +}); +``` + +```ts +// apps/api/src/modules/mail/__tests__/account-view.test.ts +/** 账号记录 ↔ 入参 / 视图:视图不带密文;kind / settings 变了游标清空;secret 缺省保留 */ +import { describe, expect, it } from 'vitest'; +import { mailAccountInputSchema } from '@contracts'; +import type { MailAccountRecord } from '../internal/domain/records.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import { cursorReset, mergeAccount, secretOf, viewOf } from '../internal/domain/account-view.ts'; + +const now = new Date('2026-08-25T10:00:00Z'); +const input = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', secret: { password: 'pw-1234' } }); + +describe('mergeAccount / secretOf', () => { + it('新建:id / 时间 / 密文来自 base,游标空', () => { + const record = mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }); + expect(record.id).toBe('acc-1'); + expect(record.address).toBe('sales@we.com'); + expect(record.secretCipher).toBe('sealed'); + expect(record.cursor).toBeNull(); + expect(record.createdAt).toBe(now); + expect(secretOf(input)).toBe(JSON.stringify({ password: 'pw-1234' })); + expect(secretOf(mailAccountInputSchema.parse({ kind: 'imap-smtp', name: 'x', address: 'x@we.com' }))).toBeUndefined(); + }); + it('wecom 的地址不由人填:新建是空串,更新沿用探到的', () => { + const wecom = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' } }); + expect(mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }).address).toBe(''); + const existing = mergeAccount(wecom, { id: 'w', existing: undefined, secretCipher: null, now }); + const probed: MailAccountRecord = { ...existing, address: 'app@corp.wecom.work' }; + expect(mergeAccount(wecom, { id: 'w', existing: probed, secretCipher: null, now }).address).toBe('app@corp.wecom.work'); + }); + it('更新:settings 没变游标保留;变了(或换 kind)游标与上次错误清空', () => { + const existing: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 's', now }), + cursor: imapCursor('7', 9), + lastPollError: '旧错误', + }; + expect(cursorReset(existing, input)).toBe(false); + const same = mergeAccount(input, { id: 'acc-1', existing, secretCipher: 's', now }); + expect(same.cursor).toEqual(imapCursor('7', 9)); + const changed = mailAccountInputSchema.parse({ ...input, settings: { ...input.settings, folder: 'Archive' } }); + expect(cursorReset(existing, changed)).toBe(true); + const reset = mergeAccount(changed, { id: 'acc-1', existing, secretCipher: 's', now }); + expect(reset.cursor).toBeNull(); + expect(reset.lastPollError).toBeNull(); + }); +}); + +describe('viewOf', () => { + it('不带密文只带 secretBound;status 带游标摘要与 polling', () => { + const record: MailAccountRecord = { + ...mergeAccount(input, { id: 'acc-1', existing: undefined, secretCipher: 'sealed', now }), + cursor: imapCursor('7', 9), + pollingUntil: new Date(now.getTime() + 1000), + }; + const view = viewOf(record, now); + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('sealed'); + expect(view.status.cursor).toBe('UID 9 · validity 7'); + expect(view.status.polling).toBe(true); + expect(view.kind).toBe('imap-smtp'); + if (view.kind === 'imap-smtp') expect(view.settings.imapHost).toBe('imap.exmail.qq.com'); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/send-input.test.ts apps/api/src/modules/mail/__tests__/delivery.test.ts apps/api/src/modules/mail/__tests__/account-view.test.ts` +Expected: FAIL(模块不存在)。 + +- [ ] **Step 3: 写实现** + +```ts +// apps/api/src/modules/mail/internal/domain/send-input.ts +/** 发信校验与选账号(设计稿 §5.3 / §5.4) */ +import type { MailAccountKind, MailSendInput } from '@contracts'; +import { MailAccountDisabledError, MailAccountNotFoundError, MailNoDefaultAccountError } from './errors.ts'; +import type { MailAccountRecord } from './records.ts'; + +export const SEND_ATTACHMENT_CAP = 8 * 1024 * 1024; +export const SEND_TOTAL_CAP = 20 * 1024 * 1024; +/** 企业微信文档:所有附件加正文不超过 50M,附件个数不超过 200 */ +export const WECOM_TOTAL_CAP = 50 * 1024 * 1024; +export const WECOM_ATTACHMENT_COUNT_CAP = 200; + +type SendShape = Pick; + +export function validateSend(input: SendShape, kind: MailAccountKind, attachmentSizes: readonly number[]): string[] { + const problems: string[] = []; + if (input.to.length === 0) problems.push('收件人至少一个'); + if (input.subject.trim() === '') problems.push('主题不能为空'); + if ((input.text === undefined || input.text === '') && (input.html === undefined || input.html === '')) { + problems.push('正文 text / html 至少一个'); + } + const total = attachmentSizes.reduce((sum, n) => sum + n, 0); + if (kind === 'wecom') { + if (attachmentSizes.length > WECOM_ATTACHMENT_COUNT_CAP) problems.push(`企业微信附件不能超过 ${WECOM_ATTACHMENT_COUNT_CAP} 个`); + if (total + (input.text?.length ?? 0) + (input.html?.length ?? 0) > WECOM_TOTAL_CAP) problems.push('企业微信正文加附件不能超过 50MB'); + } else { + const oversize = attachmentSizes.findIndex((n) => n > SEND_ATTACHMENT_CAP); + if (oversize >= 0) problems.push(`第 ${oversize + 1} 个附件超过 8MB`); + if (total > SEND_TOTAL_CAP) problems.push('附件合计超过 20MB'); + } + return problems; +} + +export function pickAccount( + accounts: readonly MailAccountRecord[], + requested: { readonly accountId?: string | undefined; readonly from?: string | undefined }, +): MailAccountRecord { + if (requested.accountId !== undefined) { + const hit = accounts.find((a) => a.id === requested.accountId); + if (hit === undefined) throw new MailAccountNotFoundError(requested.accountId); + if (!hit.enabled) throw new MailAccountDisabledError(hit.id); + return hit; + } + if (requested.from !== undefined) { + const wanted = requested.from.trim().toLowerCase(); + const hit = accounts.find((a) => a.address.toLowerCase() === wanted); + // 对不上就是对不上:拿别的账号冒充这个发件地址,SMTP 会拒,人也会被骗 + if (hit === undefined) throw new MailAccountNotFoundError(requested.from); + if (!hit.enabled) throw new MailAccountDisabledError(hit.id); + return hit; + } + const fallback = accounts.find((a) => a.isDefault && a.enabled); + if (fallback === undefined) throw new MailNoDefaultAccountError(); + return fallback; +} + +export function messageIdFor(id: string, address: string): string { + const at = address.lastIndexOf('@'); + const domain = at < 0 || at === address.length - 1 ? 'local' : address.slice(at + 1); + return `<${id}@${domain}>`; +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/delivery.ts +/** 分发状态机(设计稿 §9.3):dispatching → done | failed | skipped;重投只从「不在投」出发 */ +import type { MailDeliveryState } from '@contracts'; + +export function canRedeliver(current: MailDeliveryState | undefined): boolean { + return current !== 'dispatching'; +} + +export function stateOfOutcome(outcome: void | { readonly skipped: string }): 'done' | 'skipped' { + return outcome !== undefined && typeof outcome === 'object' && 'skipped' in outcome ? 'skipped' : 'done'; +} +``` + +```ts +// apps/api/src/modules/mail/internal/domain/account-view.ts +/** 账号记录 ↔ 入参 / 视图(设计稿 §5.3);视图经 schema 过一遍,密文进不来 */ +import type { MailAccountInput, MailAccountView } from '@contracts'; +import { mailAccountViewSchema } from '@contracts'; +import { cursorSummary } from './cursor.ts'; +import { isPolling } from './due.ts'; +import type { MailAccountRecord } from './records.ts'; + +export function viewOf(record: MailAccountRecord, now: Date): MailAccountView { + return mailAccountViewSchema.parse({ + id: record.id, + kind: record.kind, + name: record.name, + address: record.address, + displayName: record.displayName, + enabled: record.enabled, + isDefault: record.isDefault, + pollIntervalSec: record.pollIntervalSec, + channelId: record.channelId, + backfillFrom: record.backfillFrom === null ? null : record.backfillFrom.toISOString(), + settings: record.settings, + secretBound: record.secretCipher !== null && record.secretCipher !== '', + status: { + lastPolledAt: record.lastPolledAt === null ? null : record.lastPolledAt.toISOString(), + lastPollError: record.lastPollError, + lastInboundAt: record.lastInboundAt === null ? null : record.lastInboundAt.toISOString(), + cursor: cursorSummary(record.cursor), + polling: isPolling(record.pollingUntil, now), + }, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }); +} + +/** 入参里的凭证 → 待密封的 JSON;没给就是 undefined(= 不改) */ +export function secretOf(input: MailAccountInput): string | undefined { + return input.secret === undefined ? undefined : JSON.stringify(input.secret); +} + +function sameSettings(a: Record, b: Record): boolean { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...keys].every((k) => JSON.stringify(a[k] ?? null) === JSON.stringify(b[k] ?? null)); +} + +/** kind 或 settings 变了,旧游标指的就不是同一个信箱 / 同一个文件夹 —— 清空重锚 */ +export function cursorReset(existing: MailAccountRecord | undefined, input: MailAccountInput): boolean { + if (existing === undefined) return false; + return existing.kind !== input.kind || !sameSettings(existing.settings, input.settings); +} + +export function mergeAccount( + input: MailAccountInput, + base: { readonly id: string; readonly existing: MailAccountRecord | undefined; readonly secretCipher: string | null; readonly now: Date }, +): MailAccountRecord { + const reset = cursorReset(base.existing, input); + const existing = base.existing; + return { + id: base.id, + kind: input.kind, + name: input.name, + // wecom 的地址由探测回填,人不填;换成 imap-smtp 时以填的为准 + address: input.kind === 'imap-smtp' ? input.address : existing?.kind === 'wecom' ? existing.address : '', + displayName: input.displayName, + enabled: input.enabled, + isDefault: input.isDefault, + pollIntervalSec: input.pollIntervalSec, + channelId: input.channelId, + backfillFrom: input.backfillFrom === null ? null : new Date(input.backfillFrom), + settings: { ...input.settings }, + secretCipher: base.secretCipher, + cursor: reset ? null : (existing?.cursor ?? null), + lastPolledAt: existing?.lastPolledAt ?? null, + lastPollError: reset ? null : (existing?.lastPollError ?? null), + lastInboundAt: existing?.lastInboundAt ?? null, + pollingUntil: existing?.pollingUntil ?? null, + createdAt: existing?.createdAt ?? base.now, + updatedAt: base.now, + }; +} +``` + +`index.ts` 追加: + +```ts +export { messageIdFor, pickAccount, validateSend } from './internal/domain/send-input.ts'; +export { canRedeliver, stateOfOutcome } from './internal/domain/delivery.ts'; +export { cursorReset, mergeAccount, secretOf, viewOf } from './internal/domain/account-view.ts'; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 全部 PASS;三道门禁退出码 0。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail domain:发信校验与选账号(from 不静默回落)、分发状态机、账号记录与视图(密文进不了视图)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 7: 迁移 0033 + Drizzle 表 + 三个 store(真 PG 集成测试) + +**Files:** +- Create: `db/migrations/0033_mail.sql`;Modify: `db/migrations/meta/_journal.json`(追加 idx 33) +- Create: `apps/api/src/modules/mail/internal/adapter/mail-schema.ts` · `mail-store.ts` +- Modify: `apps/api/src/modules/mail/index.ts`(追加 adapter 与表导出);`apps/api/src/app/reset-runs.ts`(三张留痕表) +- Test: `apps/api/src/modules/mail/__tests__/mail-store.it.test.ts` + +**Interfaces:** +- Produces:`createMailAccountStore(db)` `createInboundStore(db)` `createOutboundStore(db)`(各实现 port.ts 同名类型);表 `mailAccountTable` `mailInboundTable` `mailInboundDeliveryTable` `mailOutboundTable`。 + +- [ ] **Step 1: 写迁移与 journal** + +```sql +-- db/migrations/0033_mail.sql +-- 邮件收发(ADR-013):账号(配置 + 密封凭证 + 游标 + 跨进程租约)、入站留痕、逐订阅方投递留痕、出站留痕。 +-- +-- 腾讯企业邮箱两条接入路(IMAP/SMTP · 企业微信邮件 API)都没有带内容的推送,只能轮询拉: +-- 两个后端连同一个库时,一个信箱同一时刻只许一个进程在拉 —— `polling_until` 与 +-- `sys.task.advancing_until` 同款(一条 SQL 判 + 占,带过期时间,进程崩了不锁死)。 +-- 入站两把唯一键:重复拉到(时钟偏 / UIDVALIDITY 重锚 / wecom 十分钟重叠窗)一律静默跳过。 +-- 入站 / 出站表对账号**不设外键**:账号删了留痕还在。 +create table if not exists 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 '', + 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, + backfill_from timestamptz, + settings jsonb not null default '{}'::jsonb, + secret_cipher text, + cursor jsonb, + last_polled_at timestamptz, + last_poll_error text, + last_inbound_at timestamptz, + polling_until timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create unique index if not exists mail_account_name_key on sys.mail_account (name); +-- 默认发件账号至多一个 —— 用例先把别的置 false,这道索引兜底 +create unique index if not exists mail_account_one_default on sys.mail_account ((true)) where is_default; +create index if not exists mail_account_due_idx on sys.mail_account (enabled, last_polled_at); + +create table if not exists sys.mail_inbound ( + id text primary key, + account_id text not null, + message_id text not null, + provider_ref text not null, + in_reply_to text, + reference_ids jsonb not null default '[]'::jsonb, + from_addr text not null default '', + from_name text not null default '', + to_list jsonb not null default '[]'::jsonb, + cc_list jsonb not null default '[]'::jsonb, + subject text not null default '', + text_body text, + html_body text, + sent_at timestamptz, + received_at timestamptz not null, + attachments jsonb not null default '[]'::jsonb, + raw bytea, + raw_note text not null default '', + size integer not null default 0, + created_at timestamptz not null default now() +); +create unique index if not exists mail_inbound_message_key on sys.mail_inbound (account_id, message_id); +create unique index if not exists mail_inbound_provider_key on sys.mail_inbound (account_id, provider_ref); +create index if not exists mail_inbound_time_idx on sys.mail_inbound (received_at desc); + +create table if not exists 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 if not exists sys.mail_outbound ( + id text primary key, + account_id text not null, + message_id text not null, + idempotency_key text, + to_list jsonb not null, + cc_list jsonb not null default '[]'::jsonb, + bcc_list jsonb not null default '[]'::jsonb, + subject text not null, + text_body text, + html_body text, + in_reply_to text, + reference_ids jsonb not null default '[]'::jsonb, + attachments jsonb not null default '[]'::jsonb, + state text not null check (state in ('sending', 'sent', 'failed')), + error text, + provider_ref text, + requested_by jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + sent_at timestamptz +); +create unique index if not exists mail_outbound_idem_key on sys.mail_outbound (idempotency_key) where idempotency_key is not null; +create index if not exists mail_outbound_time_idx on sys.mail_outbound (created_at desc); +``` + +`db/migrations/meta/_journal.json` 的 `entries` 末尾追加(注意上一条后面补逗号): + +```json + { + "idx": 33, + "version": "7", + "when": 1785130120181, + "tag": "0033_mail", + "breakpoints": true + } +``` + +`apps/api/src/app/reset-runs.ts` 的 `RUN_TABLES` 在 `'sys.outbound_mail',` 之后加: + +```ts + // 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着 + 'sys.mail_inbound', + 'sys.mail_inbound_delivery', + 'sys.mail_outbound', +``` + +- [ ] **Step 2: 写失败测试(真 PG)** + +```ts +// apps/api/src/modules/mail/__tests__/mail-store.it.test.ts +/** + * mail 存储集成测试 —— 真 PG:两把唯一键、默认账号唯一、到期挑选、**跨进程租约两连接对抢**、 + * 投递 upsert、出站幂等键、bytea 原件按元素拷回来(pg 的 Buffer 挂在共享内存池上)。 + */ +import { readFileSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { startItDatabase } from '@testing'; +import type { ItDatabase } from '@testing'; +import type { Db } from '@shared/db.ts'; +import type { InboundRecord, MailAccountRecord, OutboundRecord } from '../port.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import { createInboundStore, createMailAccountStore, createOutboundStore } from '../internal/adapter/mail-store.ts'; + +const database: ItDatabase | undefined = await startItDatabase(); +let poolA: Pool | undefined; +let poolB: Pool | undefined; +let db: Db; +let dbB: Db; + +const MIGRATION = new URL('../../../../../../db/migrations/0033_mail.sql', import.meta.url); +const now = new Date('2026-08-25T10:00:00Z'); + +function account(over: Partial): MailAccountRecord { + return { + id: 'acc-a', kind: 'imap-smtp', name: '甲', address: 'a@we.com', displayName: '', enabled: true, isDefault: false, + pollIntervalSec: 30, channelId: null, backfillFrom: null, settings: { folder: 'INBOX' }, secretCipher: 'v1.x.y.z', + cursor: null, lastPolledAt: null, lastPollError: null, lastInboundAt: null, pollingUntil: null, createdAt: now, updatedAt: now, + ...over, + }; +} +function inbound(over: Partial): InboundRecord { + return { + id: 'in-1', accountId: 'acc-a', messageId: '', providerRef: 'uid:7:1', inReplyTo: null, references: [], + from: { address: 'b@acme.com', name: '' }, to: [{ address: 'a@we.com', name: '' }], cc: [], subject: 's', text: 't', html: null, + sentAt: null, receivedAt: now, attachments: [], raw: new Uint8Array([1, 2, 3]), rawNote: '', size: 3, createdAt: now, + ...over, + }; +} +function outbound(over: Partial): OutboundRecord { + return { + id: 'out-1', accountId: 'acc-a', messageId: '', idempotencyKey: null, to: ['b@acme.com'], cc: [], bcc: [], + subject: 's', text: 't', html: null, inReplyTo: null, references: [], attachments: [], state: 'sending', error: null, + providerRef: null, requestedBy: { kind: 'ops' }, createdAt: now, sentAt: null, + ...over, + }; +} + +beforeAll(async () => { + if (database === undefined) return; + poolA = new Pool({ connectionString: database.url, max: 2 }); + poolB = new Pool({ connectionString: database.url, max: 2 }); + await poolA.query('drop table if exists sys.mail_inbound_delivery, sys.mail_inbound, sys.mail_outbound, sys.mail_account cascade'); + await poolA.query(readFileSync(MIGRATION, 'utf8')); + db = drizzle(poolA); + dbB = drizzle(poolB); +}); + +afterAll(async () => { + await poolA?.end(); + await poolB?.end(); + await database?.stop(); +}); + +describe.skipIf(database === undefined)('mail store(真 PG)', () => { + it('账号:save / get / findByName / list;isDefault 只留一个', async () => { + const store = createMailAccountStore(db); + await store.save(account({ id: 'acc-a', name: '甲', isDefault: true })); + await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', isDefault: true })); + expect((await store.get('acc-a'))?.isDefault).toBe(false); + expect((await store.get('acc-b'))?.isDefault).toBe(true); + expect((await store.findByName('甲'))?.id).toBe('acc-a'); + expect((await store.list()).map((a) => a.id)).toEqual(['acc-a', 'acc-b']); + }); + + it('游标 jsonb 存取回来还是同一形;savePoll 只改给了的列', async () => { + const store = createMailAccountStore(db); + await store.savePoll('acc-a', { cursor: imapCursor('7', 12), polledAt: now, error: null }); + const got = await store.get('acc-a'); + expect(got?.cursor).toEqual(imapCursor('7', 12)); + expect(got?.lastPolledAt?.toISOString()).toBe(now.toISOString()); + await store.savePoll('acc-a', { error: '连不上' }); + expect((await store.get('acc-a'))?.cursor).toEqual(imapCursor('7', 12)); + expect((await store.get('acc-a'))?.lastPollError).toBe('连不上'); + await expect(store.savePoll('no-such', { error: 'x' })).resolves.toBeUndefined(); + }); + + it('listDue:从没拉过 / 到点的 / 租约过期的算;停用 / 没到点 / 租约在手的不算', async () => { + const store = createMailAccountStore(db); + await store.save(account({ id: 'acc-a', name: '甲', lastPolledAt: new Date(now.getTime() - 60_000) })); + await store.save(account({ id: 'acc-b', name: '乙', address: 'b@we.com', lastPolledAt: new Date(now.getTime() - 5_000) })); + await store.save(account({ id: 'acc-c', name: '丙', address: 'c@we.com', enabled: false })); + await store.save(account({ id: 'acc-d', name: '丁', address: 'd@we.com', pollingUntil: new Date(now.getTime() + 60_000) })); + expect((await store.listDue(now)).map((a) => a.id).sort()).toEqual(['acc-a']); + }); + + it('**跨进程租约**:两条真连接对抢 20 轮,每轮恰好一个赢;还了再抢得到;过期了别人接得上', async () => { + const a = createMailAccountStore(db); + const b = createMailAccountStore(dbB); + await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com' })); + for (let round = 0; round < 20; round += 1) { + const wins = await Promise.all([a.claimPolling('acc-lease', 60_000), b.claimPolling('acc-lease', 60_000)]); + expect(wins.filter(Boolean)).toHaveLength(1); + await a.releasePolling('acc-lease'); + } + expect(await a.claimPolling('acc-lease', 60_000)).toBe(true); + expect(await b.claimPolling('acc-lease', 60_000)).toBe(false); + await poolA!.query(`update sys.mail_account set polling_until = now() - interval '1 second' where id = 'acc-lease'`); + expect(await b.claimPolling('acc-lease', 60_000)).toBe(true); + await b.releasePolling('acc-lease'); + // 停用的账号抢不到 + await a.save(account({ id: 'acc-lease', name: '租', address: 'l@we.com', enabled: false })); + expect(await a.claimPolling('acc-lease', 60_000)).toBe(false); + }); + + it('入站:两把唯一键都回 inserted:false;knownProviderRefs 只回库里有的;raw 拷回来是独立字节', async () => { + const store = createInboundStore(db); + expect(await store.insert(inbound({}))).toEqual({ inserted: true }); + expect(await store.insert(inbound({ id: 'in-dup-msg', providerRef: 'uid:7:2' }))).toEqual({ inserted: false }); + expect(await store.insert(inbound({ id: 'in-dup-ref', messageId: '' }))).toEqual({ inserted: false }); + expect(await store.knownProviderRefs('acc-a', ['uid:7:1', 'uid:7:99'])).toEqual(new Set(['uid:7:1'])); + expect(await store.knownProviderRefs('acc-a', [])).toEqual(new Set()); + const got = await store.get('in-1'); + expect(got?.record.raw).toEqual(new Uint8Array([1, 2, 3])); + expect(got?.record.raw).toBeInstanceOf(Uint8Array); + expect(Buffer.isBuffer(got?.record.raw)).toBe(false); + }); + + it('投递:同一 (mail, subscriber) upsert;list 按收信时间倒序并带投递', async () => { + const store = createInboundStore(db); + await store.insert(inbound({ id: 'in-2', messageId: '', providerRef: 'uid:7:2', receivedAt: new Date(now.getTime() + 1000) })); + await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'dispatching', error: null, startedAt: now, finishedAt: null }); + await store.setDelivery({ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }); + const list = await store.list({ accountId: 'acc-a', limit: 10 }); + expect(list.map((x) => x.record.id)).toEqual(['in-2', 'in-1']); + expect(list[0]?.deliveries).toEqual([{ mailId: 'in-2', subscriber: 'channel', state: 'failed', error: '通道停用', startedAt: now, finishedAt: now }]); + expect(await store.list({ accountId: 'acc-zzz', limit: 10 })).toEqual([]); + expect((await store.list({ limit: 1 })).map((x) => x.record.id)).toEqual(['in-2']); + }); + + it('出站:insert / byIdempotencyKey / setState / list', async () => { + const store = createOutboundStore(db); + await store.insert(outbound({ id: 'out-1', idempotencyKey: 'k-1' })); + await store.insert(outbound({ id: 'out-2', messageId: '' })); + expect((await store.byIdempotencyKey('k-1'))?.id).toBe('out-1'); + expect(await store.byIdempotencyKey('k-none')).toBeUndefined(); + await store.setState('out-1', 'sent', { providerRef: 'smtp:250 ok', sentAt: now }); + await store.setState('out-2', 'failed', { error: '535 auth failed' }); + const list = await store.list({ accountId: 'acc-a', limit: 10 }); + expect(list.find((o) => o.id === 'out-1')).toMatchObject({ state: 'sent', providerRef: 'smtp:250 ok' }); + expect(list.find((o) => o.id === 'out-2')).toMatchObject({ state: 'failed', error: '535 auth failed' }); + // 同一幂等键第二次 insert 撞唯一索引 —— 用例层先查后写,这里只证明索引在 + await expect(store.insert(outbound({ id: 'out-3', idempotencyKey: 'k-1' }))).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts` +Expected: FAIL(`mail-store.ts` 不存在)。本机 `pnpm db:up` 起着 PG 时用 `localhost:54329`;起不了就走 Testcontainers;两者皆无 → 套件跳过并告警(那样本任务不算验过,先把库起来)。 + +- [ ] **Step 4: 写表与 store** + +```ts +// apps/api/src/modules/mail/internal/adapter/mail-schema.ts +/** + * mail 的 sys schema 表(编码规范 §7 / ADR-002)。 + * 表定义单独成文件:drizzle.config.ts 以 `*-schema.ts` glob 收集;迁移手写在 db/migrations/0033_mail.sql。 + */ +import { boolean, customType, integer, jsonb, text, timestamp } from 'drizzle-orm/pg-core'; +import type { MailAddress } from '@contracts'; +import { sys } from '@shared/db.ts'; + +/** drizzle 没有 bytea 内置类型;pg 驱动两头都是 Buffer */ +const bytea = customType<{ data: Buffer; driverData: Buffer }>({ + dataType() { + return 'bytea'; + }, +}); + +export const mailAccountTable = sys.table('mail_account', { + id: text('id').primaryKey(), + name: text('name').notNull(), + kind: text('kind').notNull(), + address: text('address').notNull().default(''), + displayName: text('display_name').notNull().default(''), + enabled: boolean('enabled').notNull().default(true), + isDefault: boolean('is_default').notNull().default(false), + pollIntervalSec: integer('poll_interval_sec').notNull().default(30), + channelId: text('channel_id'), + backfillFrom: timestamp('backfill_from', { withTimezone: true }), + settings: jsonb('settings').$type>().notNull().default({}), + /** AES-256-GCM 密文;永不出接口 */ + secretCipher: text('secret_cipher'), + cursor: jsonb('cursor'), + lastPolledAt: timestamp('last_polled_at', { withTimezone: true }), + lastPollError: text('last_poll_error'), + lastInboundAt: timestamp('last_inbound_at', { withTimezone: true }), + /** 跨进程租约 —— 与 sys.task.advancing_until 同款 */ + pollingUntil: timestamp('polling_until', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const mailInboundTable = sys.table('mail_inbound', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + messageId: text('message_id').notNull(), + providerRef: text('provider_ref').notNull(), + inReplyTo: text('in_reply_to'), + referenceIds: jsonb('reference_ids').$type().notNull().default([]), + fromAddr: text('from_addr').notNull().default(''), + fromName: text('from_name').notNull().default(''), + toList: jsonb('to_list').$type().notNull().default([]), + ccList: jsonb('cc_list').$type().notNull().default([]), + subject: text('subject').notNull().default(''), + textBody: text('text_body'), + htmlBody: text('html_body'), + sentAt: timestamp('sent_at', { withTimezone: true }), + receivedAt: timestamp('received_at', { withTimezone: true }).notNull(), + attachments: jsonb('attachments') + .$type>() + .notNull() + .default([]), + raw: bytea('raw'), + rawNote: text('raw_note').notNull().default(''), + size: integer('size').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const mailInboundDeliveryTable = sys.table('mail_inbound_delivery', { + mailId: text('mail_id').notNull(), + subscriber: text('subscriber').notNull(), + state: text('state').notNull(), + error: text('error'), + startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), + finishedAt: timestamp('finished_at', { withTimezone: true }), +}); + +export const mailOutboundTable = sys.table('mail_outbound', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + messageId: text('message_id').notNull(), + idempotencyKey: text('idempotency_key'), + toList: jsonb('to_list').$type().notNull(), + ccList: jsonb('cc_list').$type().notNull().default([]), + bccList: jsonb('bcc_list').$type().notNull().default([]), + subject: text('subject').notNull(), + textBody: text('text_body'), + htmlBody: text('html_body'), + inReplyTo: text('in_reply_to'), + referenceIds: jsonb('reference_ids').$type().notNull().default([]), + attachments: jsonb('attachments').$type>().notNull().default([]), + state: text('state').notNull(), + error: text('error'), + providerRef: text('provider_ref'), + requestedBy: jsonb('requested_by').$type>().notNull().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + sentAt: timestamp('sent_at', { withTimezone: true }), +}); +``` + +```ts +// apps/api/src/modules/mail/internal/adapter/mail-store.ts +/** + * mail 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 + * 租约那一句用原生 SQL:判与占必须在同一条语句里(设计稿 §9.1)。 + */ +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { mailAccountKindSchema, mailDeliveryStateSchema, mailOutboundStateSchema } from '@contracts'; +import type { Db } from '@shared/db.ts'; +import type { + DeliveryRecord, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + OutboundRecord, + OutboundStore, +} from '../../port.ts'; +import { parseCursor } from '../domain/cursor.ts'; +import { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './mail-schema.ts'; + +function rowsOf(result: unknown): unknown[] { + if (Array.isArray(result)) return result; + const rows = (result as { rows?: unknown }).rows; + return Array.isArray(rows) ? rows : []; +} + +/* ── 账号 ─────────────────────────────────────────────────────────────── */ + +type AccountRow = typeof mailAccountTable.$inferSelect; + +function toAccount(row: AccountRow): MailAccountRecord { + return { + id: row.id, + kind: mailAccountKindSchema.parse(row.kind), + name: row.name, + address: row.address, + displayName: row.displayName, + enabled: row.enabled, + isDefault: row.isDefault, + pollIntervalSec: row.pollIntervalSec, + channelId: row.channelId, + backfillFrom: row.backfillFrom, + settings: row.settings, + secretCipher: row.secretCipher, + cursor: parseCursor(row.cursor), + lastPolledAt: row.lastPolledAt, + lastPollError: row.lastPollError, + lastInboundAt: row.lastInboundAt, + pollingUntil: row.pollingUntil, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function accountValues(record: MailAccountRecord): typeof mailAccountTable.$inferInsert { + return { + id: record.id, + name: record.name, + kind: record.kind, + address: record.address, + displayName: record.displayName, + enabled: record.enabled, + isDefault: record.isDefault, + pollIntervalSec: record.pollIntervalSec, + channelId: record.channelId, + backfillFrom: record.backfillFrom, + settings: record.settings, + secretCipher: record.secretCipher, + cursor: record.cursor, + lastPolledAt: record.lastPolledAt, + lastPollError: record.lastPollError, + lastInboundAt: record.lastInboundAt, + pollingUntil: record.pollingUntil, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +export function createMailAccountStore(db: Db): MailAccountStore { + return { + async list() { + const rows = await db.select().from(mailAccountTable).orderBy(mailAccountTable.name); + return rows.map(toAccount); + }, + async get(id) { + const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.id, id)).limit(1); + return rows[0] === undefined ? undefined : toAccount(rows[0]); + }, + async findByName(name) { + const rows = await db.select().from(mailAccountTable).where(eq(mailAccountTable.name, name)).limit(1); + return rows[0] === undefined ? undefined : toAccount(rows[0]); + }, + async save(record) { + const values = accountValues(record); + await db.transaction(async (tx) => { + // 默认账号只留一个:先把别的放下,库上的部分唯一索引兜底 + if (record.isDefault) { + await tx + .update(mailAccountTable) + .set({ isDefault: false }) + .where(and(eq(mailAccountTable.isDefault, true), sql`${mailAccountTable.id} <> ${record.id}`)); + } + await tx.insert(mailAccountTable).values(values).onConflictDoUpdate({ target: mailAccountTable.id, set: values }); + }); + }, + async remove(id) { + await db.delete(mailAccountTable).where(eq(mailAccountTable.id, id)); + }, + async listDue(now) { + const rows = await db + .select() + .from(mailAccountTable) + .where( + sql`${mailAccountTable.enabled} + and (${mailAccountTable.lastPolledAt} is null + or ${mailAccountTable.lastPolledAt} + (${mailAccountTable.pollIntervalSec} || ' seconds')::interval <= ${now}) + and (${mailAccountTable.pollingUntil} is null or ${mailAccountTable.pollingUntil} < ${now})`, + ) + .orderBy(mailAccountTable.name); + return rows.map(toAccount); + }, + async claimPolling(id, leaseMs) { + const result = await db.execute(sql` + update sys.mail_account + set polling_until = now() + (${leaseMs} || ' milliseconds')::interval + where id = ${id} and enabled + and (polling_until is null or polling_until < now()) + returning id`); + return rowsOf(result).length > 0; + }, + async releasePolling(id) { + // 还锁失败不抛:租约会过期,这一轮的成果不该被还锁连累掉 + await db.execute(sql`update sys.mail_account set polling_until = null where id = ${id}`).catch(() => undefined); + }, + async savePoll(id, patch) { + const set: Partial = {}; + if (patch.cursor !== undefined) set.cursor = patch.cursor; + if (patch.polledAt !== undefined) set.lastPolledAt = patch.polledAt; + if (patch.error !== undefined) set.lastPollError = patch.error; + if (patch.lastInboundAt !== undefined) set.lastInboundAt = patch.lastInboundAt; + if (patch.address !== undefined) set.address = patch.address; + if (Object.keys(set).length === 0) return; + await db.update(mailAccountTable).set(set).where(eq(mailAccountTable.id, id)); + }, + }; +} + +/* ── 入站 ─────────────────────────────────────────────────────────────── */ + +type InboundRow = typeof mailInboundTable.$inferSelect; +type DeliveryRow = typeof mailInboundDeliveryTable.$inferSelect; + +function toInbound(row: InboundRow): InboundRecord { + return { + id: row.id, + accountId: row.accountId, + messageId: row.messageId, + providerRef: row.providerRef, + inReplyTo: row.inReplyTo, + references: row.referenceIds, + from: { address: row.fromAddr, name: row.fromName }, + to: row.toList, + cc: row.ccList, + subject: row.subject, + text: row.textBody, + html: row.htmlBody, + sentAt: row.sentAt, + receivedAt: row.receivedAt, + attachments: row.attachments, + /* + * pg 取回来的 Buffer 挂在共享的 64KB 内存池上,`.buffer` 是视图不是拷贝 —— + * 三份资料下载下来都是 65536 字节的前科在案(2026-08-23)。按元素拷。 + */ + raw: row.raw === null ? null : Uint8Array.from(row.raw), + rawNote: row.rawNote, + size: row.size, + createdAt: row.createdAt, + }; +} + +function toDelivery(row: DeliveryRow): DeliveryRecord { + return { + mailId: row.mailId, + subscriber: row.subscriber, + state: mailDeliveryStateSchema.parse(row.state), + error: row.error, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + }; +} + +export function createInboundStore(db: Db): InboundStore { + async function deliveriesOf(mailIds: readonly string[]): Promise> { + const out = new Map(); + if (mailIds.length === 0) return out; + const rows = await db + .select() + .from(mailInboundDeliveryTable) + .where(inArray(mailInboundDeliveryTable.mailId, [...mailIds])) + .orderBy(mailInboundDeliveryTable.subscriber); + for (const row of rows) { + const list = out.get(row.mailId) ?? []; + list.push(toDelivery(row)); + out.set(row.mailId, list); + } + return out; + } + + return { + async insert(record) { + const rows = await db + .insert(mailInboundTable) + .values({ + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + providerRef: record.providerRef, + inReplyTo: record.inReplyTo, + referenceIds: [...record.references], + fromAddr: record.from.address, + fromName: record.from.name, + toList: [...record.to], + ccList: [...record.cc], + subject: record.subject, + textBody: record.text, + htmlBody: record.html, + sentAt: record.sentAt, + receivedAt: record.receivedAt, + attachments: [...record.attachments], + raw: record.raw === null ? null : Buffer.from(record.raw), + rawNote: record.rawNote, + size: record.size, + createdAt: record.createdAt, + }) + .onConflictDoNothing() + .returning({ id: mailInboundTable.id }); + return { inserted: rows.length > 0 }; + }, + async knownProviderRefs(accountId, refs) { + if (refs.length === 0) return new Set(); + const rows = await db + .select({ providerRef: mailInboundTable.providerRef }) + .from(mailInboundTable) + .where(and(eq(mailInboundTable.accountId, accountId), inArray(mailInboundTable.providerRef, [...refs]))); + return new Set(rows.map((r) => r.providerRef)); + }, + async list(query) { + const rows = await db + .select() + .from(mailInboundTable) + .where(query.accountId === undefined ? undefined : eq(mailInboundTable.accountId, query.accountId)) + .orderBy(desc(mailInboundTable.receivedAt), desc(mailInboundTable.createdAt)) + .limit(query.limit); + const deliveries = await deliveriesOf(rows.map((r) => r.id)); + return rows.map((row) => ({ record: toInbound(row), deliveries: deliveries.get(row.id) ?? [] })); + }, + async get(mailId) { + const rows = await db.select().from(mailInboundTable).where(eq(mailInboundTable.id, mailId)).limit(1); + const row = rows[0]; + if (row === undefined) return undefined; + const deliveries = await deliveriesOf([mailId]); + return { record: toInbound(row), deliveries: deliveries.get(mailId) ?? [] }; + }, + async setDelivery(delivery) { + const values = { + mailId: delivery.mailId, + subscriber: delivery.subscriber, + state: delivery.state, + error: delivery.error, + startedAt: delivery.startedAt, + finishedAt: delivery.finishedAt, + }; + await db + .insert(mailInboundDeliveryTable) + .values(values) + .onConflictDoUpdate({ + target: [mailInboundDeliveryTable.mailId, mailInboundDeliveryTable.subscriber], + set: { state: values.state, error: values.error, startedAt: values.startedAt, finishedAt: values.finishedAt }, + }); + }, + }; +} + +/* ── 出站 ─────────────────────────────────────────────────────────────── */ + +type OutboundRow = typeof mailOutboundTable.$inferSelect; + +function toOutbound(row: OutboundRow): OutboundRecord { + return { + id: row.id, + accountId: row.accountId, + messageId: row.messageId, + idempotencyKey: row.idempotencyKey, + to: row.toList, + cc: row.ccList, + bcc: row.bccList, + subject: row.subject, + text: row.textBody, + html: row.htmlBody, + inReplyTo: row.inReplyTo, + references: row.referenceIds, + attachments: row.attachments, + state: mailOutboundStateSchema.parse(row.state), + error: row.error, + providerRef: row.providerRef, + requestedBy: row.requestedBy, + createdAt: row.createdAt, + sentAt: row.sentAt, + }; +} + +export function createOutboundStore(db: Db): OutboundStore { + return { + async insert(record) { + await db.insert(mailOutboundTable).values({ + id: record.id, + accountId: record.accountId, + messageId: record.messageId, + idempotencyKey: record.idempotencyKey, + toList: [...record.to], + ccList: [...record.cc], + bccList: [...record.bcc], + subject: record.subject, + textBody: record.text, + htmlBody: record.html, + inReplyTo: record.inReplyTo, + referenceIds: [...record.references], + attachments: [...record.attachments], + state: record.state, + error: record.error, + providerRef: record.providerRef, + requestedBy: record.requestedBy, + createdAt: record.createdAt, + sentAt: record.sentAt, + }); + }, + async byIdempotencyKey(key) { + const rows = await db.select().from(mailOutboundTable).where(eq(mailOutboundTable.idempotencyKey, key)).limit(1); + return rows[0] === undefined ? undefined : toOutbound(rows[0]); + }, + async setState(id, state, patch) { + await db + .update(mailOutboundTable) + .set({ + state, + ...(patch.error === undefined ? {} : { error: patch.error }), + ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), + ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), + }) + .where(eq(mailOutboundTable.id, id)); + }, + async list(query) { + const rows = await db + .select() + .from(mailOutboundTable) + .where(query.accountId === undefined ? undefined : eq(mailOutboundTable.accountId, query.accountId)) + .orderBy(desc(mailOutboundTable.createdAt)) + .limit(query.limit); + return rows.map(toOutbound); + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createInboundStore, createMailAccountStore, createOutboundStore } from './internal/adapter/mail-store.ts'; +export { mailAccountTable, mailInboundDeliveryTable, mailInboundTable, mailOutboundTable } from './internal/adapter/mail-schema.ts'; +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-store.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 7 tests PASS(套件没跳过 —— 输出里不能有「集成测试跳过」);三道门禁退出码 0。 +另跑一次 `pnpm vitest run apps/api/src/app/__tests__/route-coverage.it.test.ts`:它对 `jcai_it_routes` 库跑全部迁移,0033 能应用即绿。 + +- [ ] **Step 6: Commit** + +```bash +git add db/migrations/0033_mail.sql db/migrations/meta/_journal.json apps/api/src/modules/mail apps/api/src/app/reset-runs.ts +git commit -m "$(printf '迁移 0033:邮箱账号 / 入站 / 投递 / 出站四张表;mail store 与跨进程租约(两连接对抢 20 轮每轮一个赢)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 8: adapter:postal-mime 解析器 + 测试夹具 + +**Files:** +- Create: `apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts` +- Create: `apps/api/src/modules/mail/__tests__/fakes.ts`(EML 夹具 + 内存 store + 假 provider + 时钟;后续用例测试共用) +- Modify: `apps/api/src/modules/mail/index.ts`(追加导出) +- Test: `apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts` + +**Interfaces:** +- Produces:`createPostalMimeParser(): MimeParser`;夹具 `eml(parts)` `fixedClock(iso?)` `seqIds(prefix?)` `plainSecrets` `memoryAccounts(clock)` `memoryInbound()` `memoryOutbound()` `memoryAttachments()` `fakeProvider(kind?)` → `FakeProvider`。 +- postal-mime 3.0.0 的真实形状(本机核过):`PostalMime.parse(Uint8Array)` → `{ from?: {address,name}, to?: Address[], cc?: Address[], subject?, messageId?, inReplyTo?, references?: string(空格分隔), date?: ISO 串, text?, html?, attachments: [{filename, mimeType, disposition, content: ArrayBuffer}] }`;GBK 主题 / 正文解得出;群组地址形 `{name, group: Mailbox[]}`。 + +- [ ] **Step 1: 写夹具** + +```ts +// apps/api/src/modules/mail/__tests__/fakes.ts +/** + * 测试夹具 —— 内存 store(用例测试用)、假 provider(邮件通道是外界,替身允许)、EML 构造。 + * 系统内部机制在 IT 里真跑(mail-store.it.test.ts);这里的内存 store 只服务用例逻辑。 + */ +import type { MailAccountKind, MailProbeReport } from '@contracts'; +import { isDue } from '../internal/domain/due.ts'; +import { imapCursor, wecomCursor } from '../internal/domain/cursor.ts'; +import type { + AttachmentStore, + Clock, + DeliveryRecord, + IdGenerator, + InboundRecord, + InboundStore, + MailAccountRecord, + MailAccountStore, + MailCursor, + MailProvider, + OutboundMessage, + OutboundRecord, + OutboundStore, + RawMessage, + SecretBox, +} from '../port.ts'; + +const CRLF = '\r\n'; + +export function fixedClock(iso = '2026-08-25T10:00:00Z'): Clock & { tick(ms: number): void } { + let t = new Date(iso).getTime(); + return { + now: () => new Date(t), + tick(ms) { + t += ms; + }, + }; +} + +export function seqIds(prefix = 'id'): IdGenerator { + let n = 0; + return { + next: () => { + n += 1; + return `${prefix}-${n}`; + }, + }; +} + +export const plainSecrets: SecretBox = { + seal: (s) => `sealed:${s}`, + open: (c) => { + if (!c.startsWith('sealed:')) throw new Error('密文格式非法'); + return c.slice('sealed:'.length); + }, +}; + +export function account(over: Partial = {}): MailAccountRecord { + const now = new Date('2026-08-25T09:00:00Z'); + return { + id: 'acc-1', + kind: 'imap-smtp', + name: '销售信箱', + address: 'sales@we.com', + displayName: '销售部', + enabled: true, + isDefault: true, + pollIntervalSec: 30, + channelId: 'ch-1', + backfillFrom: null, + settings: { folder: 'INBOX' }, + secretCipher: `sealed:${JSON.stringify({ password: 'pw-1234' })}`, + cursor: null, + lastPolledAt: null, + lastPollError: null, + lastInboundAt: null, + pollingUntil: null, + createdAt: now, + updatedAt: now, + ...over, + }; +} + +export function memoryAccounts(clock: Clock): MailAccountStore & { rows: Map } { + const rows = new Map(); + return { + rows, + async list() { + return [...rows.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, + async get(id) { + return rows.get(id); + }, + async findByName(name) { + return [...rows.values()].find((r) => r.name === name); + }, + async save(record) { + if (record.isDefault) { + for (const [k, v] of rows) if (k !== record.id && v.isDefault) rows.set(k, { ...v, isDefault: false }); + } + rows.set(record.id, record); + }, + async remove(id) { + rows.delete(id); + }, + async listDue(now) { + return [...rows.values()].filter((r) => isDue(r, now)); + }, + async claimPolling(id, leaseMs) { + const r = rows.get(id); + const now = clock.now(); + if (r === undefined || !r.enabled) return false; + if (r.pollingUntil !== null && r.pollingUntil.getTime() > now.getTime()) return false; + rows.set(id, { ...r, pollingUntil: new Date(now.getTime() + leaseMs) }); + return true; + }, + async releasePolling(id) { + const r = rows.get(id); + if (r !== undefined) rows.set(id, { ...r, pollingUntil: null }); + }, + async savePoll(id, patch) { + const r = rows.get(id); + if (r === undefined) return; + rows.set(id, { + ...r, + ...(patch.cursor === undefined ? {} : { cursor: patch.cursor }), + ...(patch.polledAt === undefined ? {} : { lastPolledAt: patch.polledAt }), + ...(patch.error === undefined ? {} : { lastPollError: patch.error }), + ...(patch.lastInboundAt === undefined ? {} : { lastInboundAt: patch.lastInboundAt }), + ...(patch.address === undefined ? {} : { address: patch.address }), + }); + }, + }; +} + +export function memoryInbound(): InboundStore & { rows: Map; deliveries: Map } { + const rows = new Map(); + const deliveries = new Map(); + // 与 DB store 同序:按订阅方名排(测试里对顺序有断言) + const of = (mailId: string): DeliveryRecord[] => + [...deliveries.values()].filter((d) => d.mailId === mailId).sort((a, b) => a.subscriber.localeCompare(b.subscriber)); + return { + rows, + deliveries, + async insert(record) { + for (const r of rows.values()) { + if (r.accountId === record.accountId && (r.messageId === record.messageId || r.providerRef === record.providerRef)) { + return { inserted: false }; + } + } + rows.set(record.id, record); + return { inserted: true }; + }, + async knownProviderRefs(accountId, refs) { + return new Set([...rows.values()].filter((r) => r.accountId === accountId && refs.includes(r.providerRef)).map((r) => r.providerRef)); + }, + async list(query) { + return [...rows.values()] + .filter((r) => query.accountId === undefined || r.accountId === query.accountId) + .sort((a, b) => b.receivedAt.getTime() - a.receivedAt.getTime()) + .slice(0, query.limit) + .map((record) => ({ record, deliveries: of(record.id) })); + }, + async get(mailId) { + const record = rows.get(mailId); + return record === undefined ? undefined : { record, deliveries: of(mailId) }; + }, + async setDelivery(delivery) { + deliveries.set(`${delivery.mailId}|${delivery.subscriber}`, delivery); + }, + }; +} + +export function memoryOutbound(): OutboundStore & { rows: Map } { + const rows = new Map(); + return { + rows, + async insert(record) { + if (record.idempotencyKey !== null && [...rows.values()].some((r) => r.idempotencyKey === record.idempotencyKey)) { + throw new Error('duplicate key value violates unique constraint "mail_outbound_idem_key"'); + } + rows.set(record.id, record); + }, + async byIdempotencyKey(key) { + return [...rows.values()].find((r) => r.idempotencyKey === key); + }, + async setState(id, state, patch) { + const r = rows.get(id); + if (r === undefined) return; + rows.set(id, { + ...r, + state, + ...(patch.error === undefined ? {} : { error: patch.error }), + ...(patch.providerRef === undefined ? {} : { providerRef: patch.providerRef }), + ...(patch.sentAt === undefined ? {} : { sentAt: patch.sentAt }), + }); + }, + async list(query) { + return [...rows.values()] + .filter((r) => query.accountId === undefined || r.accountId === query.accountId) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, query.limit); + }, + }; +} + +export function memoryAttachments(): AttachmentStore & { files: Map } { + const files = new Map(); + let n = 0; + return { + files, + async put(file) { + n += 1; + const ref = `up-${n}`; + files.set(ref, { ...file }); + return { ref }; + }, + async get(ref) { + const f = files.get(ref); + return f === undefined ? undefined : { name: f.name, contentType: f.contentType, bytes: f.bytes }; + }, + }; +} + +export type FakeProvider = MailProvider & { + readonly sent: OutboundMessage[]; + readonly pullCalls: Array<{ cursor: MailCursor; limit: number }>; + queue(...messages: RawMessage[]): void; + failPullWith(error: Error | undefined): void; + failSendWith(error: Error | undefined): void; + probeWith(report: MailProbeReport | undefined): void; +}; + +/** 可脚本化的假 provider:排队的信按 limit 交出去,IMAP 形逐封带 cursorAfter */ +export function fakeProvider(kind: MailAccountKind = 'imap-smtp'): FakeProvider { + const pending: RawMessage[] = []; + const sent: OutboundMessage[] = []; + const pullCalls: Array<{ cursor: MailCursor; limit: number }> = []; + let pullError: Error | undefined; + let sendError: Error | undefined; + let probeReport: MailProbeReport | undefined; + let uidSeq = 0; + return { + kind, + sent, + pullCalls, + queue(...messages) { + pending.push(...messages); + }, + failPullWith(error) { + pullError = error; + }, + failSendWith(error) { + sendError = error; + }, + probeWith(report) { + probeReport = report; + }, + async probe(accountArg) { + return ( + probeReport ?? { + kind, + address: accountArg.address, + receive: { ok: true, latencyMs: 1 }, + send: kind === 'wecom' ? { checked: false, note: '发信权限只能在真发时验证' } : { checked: true, ok: true }, + } + ); + }, + async anchor(_accountArg, _backfillFrom, now) { + return kind === 'imap-smtp' ? imapCursor('v1', 0) : wecomCursor(Math.floor(now.getTime() / 1000)); + }, + async pull(_accountArg, cursor, opts) { + pullCalls.push({ cursor, limit: opts.limit }); + if (pullError !== undefined) throw pullError; + const batch = pending.splice(0, opts.limit).map((m) => { + if (kind !== 'imap-smtp' || m.cursorAfter !== undefined) return m; + uidSeq += 1; + return { ...m, cursorAfter: imapCursor('v1', uidSeq) }; + }); + const last = batch.at(-1)?.cursorAfter; + const next = kind === 'imap-smtp' ? (last ?? cursor) : wecomCursor(Math.floor(opts.now.getTime() / 1000)); + return { messages: batch, cursor: next, more: pending.length > 0 }; + }, + async send(_accountArg, message) { + if (sendError !== undefined) throw sendError; + sent.push(message); + return { providerRef: `fake:${sent.length}` }; + }, + }; +} + +export function rawMessage(providerRef: string, eml: Uint8Array, receivedAt = new Date('2026-08-25T09:30:00Z')): RawMessage { + return { providerRef, eml, receivedAt }; +} + +/** 极简 RFC822 构造:够 postal-mime 解出地址、主题、正文、附件 */ +export function eml(parts: { + from?: string; + to?: string; + cc?: string; + subject?: string; + text?: string; + html?: string; + messageId?: string; + inReplyTo?: string; + references?: string; + date?: string; + attachment?: { name: string; contentType: string; content: string }; +}): Uint8Array { + const headers = [ + `From: ${parts.from ?? 'buyer@acme.com'}`, + `To: ${parts.to ?? 'sales@we.com'}`, + ...(parts.cc === undefined ? [] : [`Cc: ${parts.cc}`]), + `Subject: ${parts.subject ?? '询价'}`, + ...(parts.messageId === undefined ? [] : [`Message-ID: ${parts.messageId}`]), + ...(parts.inReplyTo === undefined ? [] : [`In-Reply-To: ${parts.inReplyTo}`]), + ...(parts.references === undefined ? [] : [`References: ${parts.references}`]), + `Date: ${parts.date ?? 'Tue, 25 Aug 2026 09:00:00 +0800'}`, + 'MIME-Version: 1.0', + ]; + const text = parts.text ?? '你好,请报价'; + if (parts.html === undefined && parts.attachment === undefined) { + return new TextEncoder().encode([...headers, 'Content-Type: text/plain; charset=utf-8', '', text, ''].join(CRLF)); + } + const lines = [...headers, 'Content-Type: multipart/mixed; boundary="B1"', '', '--B1', 'Content-Type: text/plain; charset=utf-8', '', text]; + if (parts.html !== undefined) lines.push('--B1', 'Content-Type: text/html; charset=utf-8', '', parts.html); + if (parts.attachment !== undefined) { + lines.push( + '--B1', + `Content-Type: ${parts.attachment.contentType}; name="${parts.attachment.name}"`, + `Content-Disposition: attachment; filename="${parts.attachment.name}"`, + 'Content-Transfer-Encoding: base64', + '', + Buffer.from(parts.attachment.content, 'utf8').toString('base64'), + ); + } + lines.push('--B1--', ''); + return new TextEncoder().encode(lines.join(CRLF)); +} +``` + +- [ ] **Step 2: 写失败测试(真解析器跑夹具)** + +```ts +// apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts +/** MIME 解析 —— 真 postal-mime:GBK 编码字头、群组地址、References 拆串、纯 html、附件字节 */ +import { describe, expect, it } from 'vitest'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { eml } from './fakes.ts'; + +const parser = createPostalMimeParser(); +const CRLF = '\r\n'; + +describe('createPostalMimeParser', () => { + it('地址 / 主题 / 正文 / Message-ID / In-Reply-To / References / 日期', async () => { + const parsed = await parser.parse( + eml({ + from: '"张三" ', + to: 'sales@we.com, "Ops" ', + subject: '询价 RFQ-1', + messageId: '', + inReplyTo: '', + references: ' ', + text: '你好,请报价', + }), + ); + expect(parsed.from).toEqual({ address: 'buyer@acme.com', name: '张三' }); + expect(parsed.to).toEqual([ + { address: 'sales@we.com', name: '' }, + { address: 'ops@we.com', name: 'Ops' }, + ]); + expect(parsed.subject).toBe('询价 RFQ-1'); + expect(parsed.messageId).toBe(''); + expect(parsed.inReplyTo).toBe(''); + expect(parsed.references).toEqual(['', '']); + expect(parsed.text?.trim()).toBe('你好,请报价'); + expect(parsed.date?.toISOString()).toBe('2026-08-25T01:00:00.000Z'); + }); + + it('GBK 的 =?GBK?B?…?= 主题与 charset=gbk 正文解成中文(Node 自带 TextDecoder)', async () => { + const subject = Buffer.from([0xd1, 0xaf, 0xbc, 0xdb]).toString('base64'); // 询价 + const body = Buffer.from([0xc4, 0xe3, 0xba, 0xc3]).toString('base64'); // 你好 + const raw = [ + 'From: buyer@acme.com', + 'To: sales@we.com', + `Subject: =?GBK?B?${subject}?=`, + 'Content-Type: text/plain; charset=gbk', + 'Content-Transfer-Encoding: base64', + '', + body, + '', + ].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.subject).toBe('询价'); + expect(parsed.text?.trim()).toBe('你好'); + }); + + it('群组地址摊平;没有 Cc 就是空数组', async () => { + const raw = ['From: a@x.com', 'To: team: b@x.com, "C" ;', 'Subject: s', '', 'body', ''].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.to).toEqual([ + { address: 'b@x.com', name: '' }, + { address: 'c@x.com', name: 'C' }, + ]); + expect(parsed.cc).toEqual([]); + }); + + it('纯 html 的信 text 缺席、html 在;没有 Message-ID / From 也不抛', async () => { + const raw = ['Subject: x', 'Content-Type: text/html', '', 'only html', ''].join(CRLF); + const parsed = await parser.parse(new TextEncoder().encode(raw)); + expect(parsed.text).toBeUndefined(); + expect(parsed.html).toContain('only html'); + expect(parsed.messageId).toBeUndefined(); + expect(parsed.from).toBeUndefined(); + expect(parsed.date).toBeUndefined(); + }); + + it('附件:文件名、类型、字节(ArrayBuffer → Uint8Array)', async () => { + const parsed = await parser.parse(eml({ attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PKxyz' } })); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0]?.name).toBe('bom.xlsx'); + expect(parsed.attachments[0]?.contentType).toBe('application/vnd.ms-excel'); + expect(parsed.attachments[0]?.bytes).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(parsed.attachments[0]?.bytes)).toBe('PKxyz'); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts` +Expected: FAIL(解析器不存在)。 + +- [ ] **Step 4: 写解析器** + +```ts +// apps/api/src/modules/mail/internal/adapter/postal-mime-parser.ts +/** + * MIME 解析 —— postal-mime(零依赖;GBK / GB18030 走 Node 自带 TextDecoder)。 + * 两条接入路共用:IMAP 拉到的 source 与 wecom `read_mail` 的 EML 都从这里过。 + */ +import PostalMime from 'postal-mime'; +import type { Address } from 'postal-mime'; +import type { MailAddress } from '@contracts'; +import type { MimeParser, ParsedMail } from '../../port.ts'; + +function mailboxesOf(list: readonly Address[] | undefined): MailAddress[] { + const out: MailAddress[] = []; + for (const entry of list ?? []) { + if (entry.group !== undefined) { + for (const member of entry.group) out.push({ address: member.address, name: member.name }); + } else if (entry.address !== undefined) { + out.push({ address: entry.address, name: entry.name }); + } + } + return out; +} + +/** References / In-Reply-To 是空白分隔的一串 ;拆开、去空 */ +function idsOf(value: string | undefined): string[] { + if (value === undefined) return []; + return value + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s !== ''); +} + +function bytesOf(content: ArrayBuffer | Uint8Array | string): Uint8Array { + if (typeof content === 'string') return new TextEncoder().encode(content); + return content instanceof Uint8Array ? content : new Uint8Array(content); +} + +export function createPostalMimeParser(): MimeParser { + return { + async parse(eml): Promise { + const email = await PostalMime.parse(eml, { attachmentEncoding: 'arraybuffer' }); + const from = + email.from !== undefined && email.from.address !== undefined ? { address: email.from.address, name: email.from.name } : undefined; + const date = email.date === undefined ? undefined : new Date(email.date); + const inReplyTo = idsOf(email.inReplyTo)[0]; + return { + ...(email.messageId === undefined ? {} : { messageId: email.messageId }), + ...(inReplyTo === undefined ? {} : { inReplyTo }), + references: idsOf(email.references), + ...(from === undefined ? {} : { from }), + to: mailboxesOf(email.to), + cc: mailboxesOf(email.cc), + subject: email.subject ?? '', + ...(email.text === undefined ? {} : { text: email.text }), + ...(email.html === undefined ? {} : { html: email.html }), + ...(date === undefined || Number.isNaN(date.getTime()) ? {} : { date }), + attachments: email.attachments.map((a) => ({ + name: a.filename ?? '', + contentType: a.mimeType, + bytes: bytesOf(a.content), + })), + }; + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createPostalMimeParser } from './internal/adapter/postal-mime-parser.ts'; +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/postal-mime-parser.test.ts && pnpm typecheck && pnpm lint` +Expected: 5 tests PASS;门禁退出码 0。若 `Address` 类型的 `group` 判别在本版 d.ts 里报错,以 `node_modules/postal-mime/postal-mime.d.ts` 的 `Address` 定义为准改判别(本计划核对的是 3.0.0:`Mailbox = {name, address, group?: undefined}`,群组形 `{name, address?: undefined, group: Mailbox[]}`)。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail adapter:postal-mime 解析器(GBK 字头 / 群组地址 / References 拆串 / 附件字节)+ 测试夹具\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 9: usecase `createMailService` —— 账号 CRUD / 探测 / 视图 + +**Files:** +- Create: `apps/api/src/modules/mail/internal/usecase/mail-service.ts`(整份;收 / 发的用例也在这份里,Task 10 / 11 只加测试) +- Modify: `apps/api/src/modules/mail/index.ts`(追加 `createMailService` 与 `MailServiceDeps`) +- Test: `apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts` + +**Interfaces:** +- Produces:`createMailService(deps: MailServiceDeps): MailService`;`MailServiceDeps = { accounts, inbound, outbound, attachments, providers: Record, mime, secrets, clock, ids, notify, limits? }`。 +- `probe` 的第二形是 `{ input, accountId? }`:改已存账号的表单上「测试」不必重打密码 —— 没给 secret 且给了 accountId 就用库里那份密文。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts +/** 账号用例:建 / 改 / 删 / 探测;密文只进库不出接口;名字唯一;secret 缺省保留;kind 变了游标清空 */ +import { describe, expect, it } from 'vitest'; +import { mailAccountInputSchema } from '@contracts'; +import { MailAccountNameTakenError, MailAccountNotFoundError, MailSecretMissingError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const changes: string[] = []; + const service = createMailService({ + accounts, + inbound: memoryInbound(), + outbound: memoryOutbound(), + attachments: memoryAttachments(), + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('acc'), + notify: { changed: (r) => void changes.push(r) }, + }); + return { service, accounts, imap, wecom, clock, changes }; +} +const imapInput = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }); + +describe('账号', () => { + it('建:视图不带密文、secretBound 真;库里密文是密封过的', async () => { + const { service, accounts } = harness(); + const view = await service.createAccount(imapInput); + expect(view.id).toBe('acc-1'); + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('pw-1234'); + expect(accounts.rows.get('acc-1')?.secretCipher).toBe(`sealed:${JSON.stringify({ password: 'pw-1234' })}`); + expect((await service.listAccounts()).map((a) => a.name)).toEqual(['销售']); + }); + + it('名字唯一:重名 409;改名撞别人也 409', async () => { + const { service } = harness(); + await service.createAccount(imapInput); + await expect(service.createAccount(imapInput)).rejects.toBeInstanceOf(MailAccountNameTakenError); + const second = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com' }); + await expect(service.updateAccount(second.id, { ...imapInput, name: '销售' })).rejects.toBeInstanceOf(MailAccountNameTakenError); + }); + + it('改:secret 缺省保留原密文;给了就换;换 kind 没给 secret 时旧密文作废', async () => { + const { service, accounts } = harness(); + const created = await service.createAccount(imapInput); + await service.updateAccount(created.id, { ...imapInput, secret: undefined, displayName: '销售一部' }); + expect(accounts.rows.get(created.id)?.secretCipher).toContain('pw-1234'); + await service.updateAccount(created.id, { ...imapInput, secret: { password: 'new-pw-9' } }); + expect(accounts.rows.get(created.id)?.secretCipher).toContain('new-pw-9'); + const wecomInput = mailAccountInputSchema.parse({ kind: 'wecom', name: '销售', settings: { corpId: 'ww1' } }); + const switched = await service.updateAccount(created.id, wecomInput); + expect(switched.secretBound).toBe(false); + expect(switched.address).toBe(''); + }); + + it('默认账号只有一个;删了就没了(404)', async () => { + const { service } = harness(); + const a = await service.createAccount(imapInput); + const b = await service.createAccount({ ...imapInput, name: '采购', address: 'buy@we.com', isDefault: true }); + expect((await service.getAccount(a.id)).isDefault).toBe(false); + expect((await service.getAccount(b.id)).isDefault).toBe(true); + await service.deleteAccount(a.id); + await expect(service.getAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); + await expect(service.deleteAccount(a.id)).rejects.toBeInstanceOf(MailAccountNotFoundError); + }); + + it('探测(已存):用库里凭证,报告里的厂商原话脱敏;wecom 探到地址回填', async () => { + const { service, wecom, imap } = harness(); + imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: 'LOGIN sales@we.com pw-1234 failed' }, send: { checked: true, ok: true } }); + const a = await service.createAccount(imapInput); + const report = await service.probe({ accountId: a.id }); + expect(report.receive.error).toBe('LOGIN sales@we.com *** failed'); + const w = await service.createAccount(mailAccountInputSchema.parse({ kind: 'wecom', name: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-9999' } })); + wecom.probeWith({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false, note: '发信权限只能在真发时验证' } }); + await service.probe({ accountId: w.id }); + expect((await service.getAccount(w.id)).address).toBe('app@corp.wecom.work'); + }); + + it('探测(草稿):不落库;没 secret 又没 accountId → 412;给了 accountId 就借库里的密文', async () => { + const { service, accounts } = harness(); + const report = await service.probe({ input: imapInput }); + expect(report.receive.ok).toBe(true); + expect(accounts.rows.size).toBe(0); + await expect(service.probe({ input: { ...imapInput, secret: undefined } })).rejects.toBeInstanceOf(MailSecretMissingError); + const a = await service.createAccount(imapInput); + const borrowed = await service.probe({ input: { ...imapInput, secret: undefined, displayName: '改过' }, accountId: a.id }); + expect(borrowed.receive.ok).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts` +Expected: FAIL(`mail-service.ts` 不存在)。 + +- [ ] **Step 3: 写用例(整份)** + +先把 `contract.ts` 里 `probe` 的签名改成: + +```ts + probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise; +``` + +```ts +// apps/api/src/modules/mail/internal/usecase/mail-service.ts +/** + * mail 用例 —— 账号、收、发(编码规范 §5.3;设计稿 §5.4;ADR-013)。 + * + * 收:抢租约 → 无游标先锚 → 循环 pull(封顶 50 封 / 20s)→ 逐封解析 / 存附件 / 入库(唯一键去重) + * → 逐封推进游标 → 新入库的分发给订阅方 → 还租约。任何异常:记 last_poll_error(脱敏),游标停在最后一封成功处。 + * 发:选账号 → 幂等键 → 校验 → 落 sending → provider → sent / failed(原话脱敏);厂商失败不抛。 + */ +import type { MailAccountKind, MailPollOutcome, MailProbeReport, MailSendResult } from '@contracts'; +import type { InboundHandler, MailService } from '../../contract.ts'; +import { mergeAccount, secretOf, viewOf } from '../domain/account-view.ts'; +import { canRedeliver, stateOfOutcome } from '../domain/delivery.ts'; +import { leaseMsFor } from '../domain/due.ts'; +import { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberTakenError, + MailSubscriberUnknownError, +} from '../domain/errors.ts'; +import { ATTACHMENT_CAP, buildInboundRecord, deliveryViewOf, detailOf, planAttachments, RAW_CAP, summaryOf } from '../domain/inbound-record.ts'; +import type { DeliveryRecord, InboundAttachmentRecord, InboundRecord, MailAccountRecord, OutboundRecord, ParsedAttachment } from '../domain/records.ts'; +import { errorText, redactSecrets } from '../domain/redact.ts'; +import { messageIdFor, pickAccount, validateSend } from '../domain/send-input.ts'; +import type { + AttachmentStore, + ChangeNotifier, + Clock, + IdGenerator, + InboundStore, + MailAccountStore, + MailProvider, + MimeParser, + OutboundStore, + ProviderAccount, + RawMessage, + SecretBox, +} from '../../port.ts'; + +export type MailServiceDeps = { + readonly accounts: MailAccountStore; + readonly inbound: InboundStore; + readonly outbound: OutboundStore; + readonly attachments: AttachmentStore; + readonly providers: Readonly>; + readonly mime: MimeParser; + readonly secrets: SecretBox; + readonly clock: Clock; + readonly ids: IdGenerator; + readonly notify: ChangeNotifier; + readonly limits?: + | { + readonly perTick?: number | undefined; + readonly tickBudgetMs?: number | undefined; + readonly attachmentBytes?: number | undefined; + readonly rawBytes?: number | undefined; + } + | undefined; +}; + +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 200; + +type PollTally = { accountId: string; pulled: number; stored: number; dispatched: number; more: boolean; error?: string }; + +function tallyToOutcome(t: PollTally): MailPollOutcome { + return { + accountId: t.accountId, + pulled: t.pulled, + stored: t.stored, + dispatched: t.dispatched, + more: t.more, + ...(t.error === undefined ? {} : { error: t.error }), + }; +} + +function safeFileName(messageId: string): string { + const cleaned = messageId.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned === '' ? 'mail' : cleaned; +} + +function resultOf(record: OutboundRecord): MailSendResult { + return { + outboundId: record.id, + accountId: record.accountId, + messageId: record.messageId, + state: record.state === 'sent' ? 'sent' : 'failed', + ...(record.error === null ? {} : { error: record.error }), + ...(record.providerRef === null ? {} : { providerRef: record.providerRef }), + }; +} + +export function createMailService(deps: MailServiceDeps): MailService { + const limits = { + perTick: deps.limits?.perTick ?? 50, + tickBudgetMs: deps.limits?.tickBudgetMs ?? 20_000, + attachmentBytes: deps.limits?.attachmentBytes ?? ATTACHMENT_CAP, + rawBytes: deps.limits?.rawBytes ?? RAW_CAP, + }; + const subscribers = new Map(); + + async function requireAccount(accountId: string): Promise { + const record = await deps.accounts.get(accountId); + if (record === undefined) throw new MailAccountNotFoundError(accountId); + return record; + } + + /** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */ + function openSecret(record: Pick): Record { + if (record.secretCipher === null || record.secretCipher === '') throw new MailSecretMissingError(record.id); + const parsed: unknown = JSON.parse(deps.secrets.open(record.secretCipher)); + if (typeof parsed !== 'object' || parsed === null) throw new MailSecretMissingError(record.id); + return Object.fromEntries(Object.entries(parsed as Record).map(([k, v]) => [k, String(v)])); + } + + function providerAccount(record: MailAccountRecord, secret: Record): ProviderAccount { + return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret }; + } + + function redactWith(secret: Record): (text: string) => string { + const values = Object.values(secret); + return (text) => redactSecrets(text, values); + } + + function redactReport(report: MailProbeReport, secret: Record): MailProbeReport { + const redact = redactWith(secret); + return { + ...report, + receive: { ...report.receive, ...(report.receive.error === undefined ? {} : { error: redact(report.receive.error) }) }, + send: { ...report.send, ...(report.send.error === undefined ? {} : { error: redact(report.send.error) }) }, + }; + } + + function limitOf(query: { limit?: number | undefined }): number { + return Math.min(MAX_LIST_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIST_LIMIT)); + } + + /* ── 分发 ────────────────────────────────────────────────────────── */ + + async function dispatch(record: InboundRecord, ctx: { accountId: string; channelId: string | null }, only?: string): Promise { + const existing = (await deps.inbound.get(record.id))?.deliveries ?? []; + const out: DeliveryRecord[] = []; + for (const [name, handler] of subscribers) { + if (only !== undefined && name !== only) continue; + const current = existing.find((d) => d.subscriber === name); + // 正在投的那条不重投:进程还没回话,再投一遍就是两条线同时改一件事 + if (!canRedeliver(current?.state)) { + if (current !== undefined) out.push(current); + continue; + } + const startedAt = deps.clock.now(); + const dispatching: DeliveryRecord = { mailId: record.id, subscriber: name, state: 'dispatching', error: null, startedAt, finishedAt: null }; + await deps.inbound.setDelivery(dispatching); + let settled: DeliveryRecord; + try { + const outcome = await handler(detailOf(record, existing), { accountId: ctx.accountId, channelId: ctx.channelId ?? undefined }); + const state = stateOfOutcome(outcome); + settled = { + ...dispatching, + state, + // skipped 的理由也放 error 列:人在列表上看得见「为什么没投」 + error: state === 'skipped' && outcome !== undefined && typeof outcome === 'object' ? outcome.skipped : null, + finishedAt: deps.clock.now(), + }; + } catch (error) { + settled = { ...dispatching, state: 'failed', error: errorText(error), finishedAt: deps.clock.now() }; + } + await deps.inbound.setDelivery(settled); + out.push(settled); + } + return out; + } + + /* ── 拉取 ────────────────────────────────────────────────────────── */ + + async function storeOne(record: MailAccountRecord, message: RawMessage): Promise { + // 先按 provider 给的引用查重,免得重复拉到的信把附件再存一遍 + const known = await deps.inbound.knownProviderRefs(record.id, [message.providerRef]); + if (known.has(message.providerRef)) return undefined; + const parsed = await deps.mime.parse(message.eml); + const attachments: InboundAttachmentRecord[] = []; + for (const plan of planAttachments(parsed.attachments, limits.attachmentBytes)) { + let ref: string | null = null; + if (plan.store) { + const bytes = parsed.attachments[plan.index]?.bytes ?? new Uint8Array(); + ref = (await deps.attachments.put({ name: plan.name, contentType: plan.contentType, bytes, note: `邮件附件:${parsed.subject}` })).ref; + } + attachments.push({ index: plan.index, name: plan.name, contentType: plan.contentType, size: plan.size, ref, note: plan.note }); + } + const built = buildInboundRecord({ + id: deps.ids.next(), + accountId: record.id, + address: record.address, + providerRef: message.providerRef, + parsed, + eml: message.eml, + receivedAt: message.receivedAt, + attachments, + now: deps.clock.now(), + rawCap: limits.rawBytes, + }); + const { inserted } = await deps.inbound.insert(built); + return inserted ? built : undefined; + } + + async function pollAccount(record: MailAccountRecord): Promise { + const claimed = await deps.accounts.claimPolling(record.id, leaseMsFor(record.pollIntervalSec)); + if (!claimed) throw new MailPollBusyError(record.id); + const tally: PollTally = { accountId: record.id, pulled: 0, stored: 0, dispatched: 0, more: false }; + let secret: Record = {}; + try { + secret = openSecret(record); + const provider = deps.providers[record.kind]; + const account = providerAccount(record, secret); + const started = deps.clock.now(); + let cursor = record.cursor; + if (cursor === null) { + cursor = await provider.anchor(account, record.backfillFrom, started); + await deps.accounts.savePoll(record.id, { cursor }); + } + let budget = limits.perTick; + let more = true; + while (more && budget > 0 && deps.clock.now().getTime() - started.getTime() < limits.tickBudgetMs) { + const batch = await provider.pull(account, cursor, { + limit: budget, + now: deps.clock.now(), + backfillFrom: record.backfillFrom, + known: (refs) => deps.inbound.knownProviderRefs(record.id, refs), + }); + tally.pulled += batch.messages.length; + budget -= batch.messages.length; + for (const message of batch.messages) { + const stored = await storeOne(record, message); + // 逐封推进:中途栽了,下一轮从这一封之后接着 + if (message.cursorAfter !== undefined) { + cursor = message.cursorAfter; + await deps.accounts.savePoll(record.id, { cursor }); + } + if (stored !== undefined) { + tally.stored += 1; + await deps.accounts.savePoll(record.id, { lastInboundAt: stored.receivedAt }); + await dispatch(stored, { accountId: record.id, channelId: record.channelId }); + tally.dispatched += 1; + } + } + cursor = batch.cursor; + await deps.accounts.savePoll(record.id, { cursor }); + more = batch.more; + if (batch.messages.length === 0) break; + } + tally.more = more; + await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: null }); + } catch (error) { + const text = redactWith(secret)(errorText(error)); + tally.error = text; + await deps.accounts.savePoll(record.id, { polledAt: deps.clock.now(), error: text }); + } finally { + await deps.accounts.releasePolling(record.id); + } + if (tally.stored > 0 || tally.error !== undefined) deps.notify.changed('inbound'); + return tallyToOutcome(tally); + } + + /* ── 服务 ────────────────────────────────────────────────────────── */ + + return { + async listAccounts() { + const now = deps.clock.now(); + return (await deps.accounts.list()).map((r) => viewOf(r, now)); + }, + + async getAccount(accountId) { + return viewOf(await requireAccount(accountId), deps.clock.now()); + }, + + async createAccount(input) { + if ((await deps.accounts.findByName(input.name)) !== undefined) throw new MailAccountNameTakenError(input.name); + const secretJson = secretOf(input); + const now = deps.clock.now(); + const record = mergeAccount(input, { + id: deps.ids.next(), + existing: undefined, + secretCipher: secretJson === undefined ? null : deps.secrets.seal(secretJson), + now, + }); + await deps.accounts.save(record); + return viewOf(record, now); + }, + + async updateAccount(accountId, input) { + const existing = await requireAccount(accountId); + const clash = await deps.accounts.findByName(input.name); + if (clash !== undefined && clash.id !== accountId) throw new MailAccountNameTakenError(input.name); + const secretJson = secretOf(input); + // 只写不读:secret 留空即保持原密文;换了 kind 旧密文不是这一形的,作废 + const secretCipher = + secretJson !== undefined ? deps.secrets.seal(secretJson) : input.kind === existing.kind ? existing.secretCipher : null; + const now = deps.clock.now(); + // kind / settings 变了游标清空重锚 —— 判定在 mergeAccount 里(cursorReset) + const record = mergeAccount(input, { id: accountId, existing, secretCipher, now }); + await deps.accounts.save(record); + return viewOf(record, now); + }, + + async deleteAccount(accountId) { + await requireAccount(accountId); + await deps.accounts.remove(accountId); + }, + + async probe(target) { + if ('input' in target) { + const stored = target.accountId === undefined ? undefined : await requireAccount(target.accountId); + const secretJson = secretOf(target.input); + const secret = + secretJson !== undefined + ? (JSON.parse(secretJson) as Record) + : stored !== undefined && stored.kind === target.input.kind + ? openSecret(stored) + : (() => { + throw new MailSecretMissingError(target.accountId ?? 'draft'); + })(); + const draft = mergeAccount(target.input, { id: stored?.id ?? 'draft', existing: stored, secretCipher: null, now: deps.clock.now() }); + const report = await deps.providers[target.input.kind].probe(providerAccount(draft, secret)); + return redactReport(report, secret); + } + const record = await requireAccount(target.accountId); + const secret = openSecret(record); + const report = redactReport(await deps.providers[record.kind].probe(providerAccount(record, secret)), secret); + // wecom 的地址由探测回填;imap-smtp 的地址是人填的,不改 + if (record.kind === 'wecom' && report.address !== undefined && report.address !== record.address) { + await deps.accounts.savePoll(record.id, { address: report.address }); + } + return report; + }, + + async pollDue(now) { + const due = await deps.accounts.listDue(now); + // 账号间并行,单账号内串行;一个账号的异常只记在它自己头上 + const settled = await Promise.allSettled(due.map((record) => pollAccount(record))); + return settled.map((r, i) => + r.status === 'fulfilled' + ? r.value + : { accountId: due[i]?.id ?? '?', pulled: 0, stored: 0, dispatched: 0, more: false, error: errorText(r.reason) }, + ); + }, + + async pollNow(accountId) { + const record = await requireAccount(accountId); + if (!record.enabled) throw new MailAccountDisabledError(accountId); + return pollAccount(record); + }, + + subscribe(name, handler) { + if (subscribers.has(name)) throw new MailSubscriberTakenError(name); + subscribers.set(name, handler); + return () => { + subscribers.delete(name); + }; + }, + + async listInbound(query) { + const rows = await deps.inbound.list({ accountId: query.accountId, limit: limitOf(query) }); + return rows.map((r) => summaryOf(r.record, r.deliveries)); + }, + + async getInbound(mailId) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + return detailOf(hit.record, hit.deliveries); + }, + + async attachment(mailId, index) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + const meta = hit.record.attachments.find((a) => a.index === index); + if (meta === undefined || meta.ref === null) throw new MailAttachmentNotFoundError(mailId, index); + const file = await deps.attachments.get(meta.ref); + if (file === undefined) throw new MailAttachmentNotFoundError(mailId, index); + return { name: meta.name, contentType: meta.contentType, bytes: file.bytes }; + }, + + async raw(mailId) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + if (hit.record.raw === null) throw new MailAttachmentNotFoundError(mailId, -1); + return { name: `${safeFileName(hit.record.messageId)}.eml`, bytes: hit.record.raw }; + }, + + async redeliver(mailId, subscriber) { + const hit = await deps.inbound.get(mailId); + if (hit === undefined) throw new MailInboundNotFoundError(mailId); + if (subscriber !== undefined && !subscribers.has(subscriber)) throw new MailSubscriberUnknownError(subscriber); + const owner = await deps.accounts.get(hit.record.accountId); + const deliveries = await dispatch(hit.record, { accountId: hit.record.accountId, channelId: owner?.channelId ?? null }, subscriber); + deps.notify.changed('inbound'); + return deliveries.map(deliveryViewOf); + }, + + async send(input) { + const account = pickAccount(await deps.accounts.list(), { accountId: input.accountId, from: input.from }); + // 幂等键:放行重放不发两封;上一次失败 / 中断的那一行复用,重发 + const prior = input.idempotencyKey === undefined ? undefined : await deps.outbound.byIdempotencyKey(input.idempotencyKey); + if (prior !== undefined && prior.state === 'sent') return resultOf(prior); + + const files: ParsedAttachment[] = []; + for (const a of input.attachments) { + if (a.dataBase64 !== undefined) { + files.push({ name: a.name, contentType: a.contentType, bytes: new Uint8Array(Buffer.from(a.dataBase64, 'base64')) }); + } else if (a.uploadRef !== undefined) { + const got = await deps.attachments.get(a.uploadRef); + if (got === undefined) throw new MailSendInvalidError([`附件不存在:${a.uploadRef}`]); + files.push({ name: a.name, contentType: a.contentType === 'application/octet-stream' ? got.contentType : a.contentType, bytes: got.bytes }); + } else { + throw new MailSendInvalidError([`附件 ${a.name} 既没给字节也没给 uploadRef`]); + } + } + const problems = validateSend(input, account.kind, files.map((f) => f.bytes.byteLength)); + if (problems.length > 0) throw new MailSendInvalidError(problems); + const secret = openSecret(account); + + const now = deps.clock.now(); + const id = prior?.id ?? deps.ids.next(); + const messageId = prior?.messageId ?? messageIdFor(id, account.address); + if (prior === undefined) { + const record: OutboundRecord = { + id, + accountId: account.id, + messageId, + idempotencyKey: input.idempotencyKey ?? null, + to: [...input.to], + cc: [...input.cc], + bcc: [...input.bcc], + subject: input.subject, + text: input.text ?? null, + html: input.html ?? null, + inReplyTo: input.inReplyTo ?? null, + references: [...input.references], + attachments: files.map((f, i) => ({ name: f.name, contentType: f.contentType, size: f.bytes.byteLength, ref: input.attachments[i]?.uploadRef ?? null })), + state: 'sending', + error: null, + providerRef: null, + requestedBy: { ...input.requestedBy }, + createdAt: now, + sentAt: null, + }; + await deps.outbound.insert(record); + } + + try { + const sent = await deps.providers[account.kind].send(providerAccount(account, secret), { + messageId, + from: { address: account.address, name: account.displayName }, + to: input.to, + cc: input.cc, + bcc: input.bcc, + subject: input.subject, + ...(input.text === undefined ? {} : { text: input.text }), + ...(input.html === undefined ? {} : { html: input.html }), + ...(input.inReplyTo === undefined ? {} : { inReplyTo: input.inReplyTo }), + references: input.references, + attachments: files, + }); + await deps.outbound.setState(id, 'sent', { sentAt: deps.clock.now(), ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }); + deps.notify.changed('outbound'); + return { outboundId: id, accountId: account.id, messageId, state: 'sent', ...(sent.providerRef === undefined ? {} : { providerRef: sent.providerRef }) }; + } catch (error) { + const text = redactWith(secret)(errorText(error)); + await deps.outbound.setState(id, 'failed', { error: text }); + deps.notify.changed('outbound'); + return { outboundId: id, accountId: account.id, messageId, state: 'failed', error: text }; + } + }, + + async listOutbound(query) { + const rows = await deps.outbound.list({ accountId: query.accountId, limit: limitOf(query) }); + return rows.map((r) => ({ + id: r.id, + accountId: r.accountId, + messageId: r.messageId, + to: [...r.to], + cc: [...r.cc], + bcc: [...r.bcc], + subject: r.subject, + state: r.state, + error: r.error, + providerRef: r.providerRef, + requestedBy: r.requestedBy, + createdAt: r.createdAt.toISOString(), + sentAt: r.sentAt === null ? null : r.sentAt.toISOString(), + })); + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export type { MailServiceDeps } from './internal/usecase/mail-service.ts'; +export { createMailService } from './internal/usecase/mail-service.ts'; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-accounts.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 6 tests PASS;门禁退出码 0。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail 用例:账号建改删与探测(草稿探测可借库里密文;wecom 探到地址回填);收发用例同份落地待测\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 10: usecase 测试 —— 拉取 / 去重 / 分发 / 重新投递 + +**Files:** +- Test: `apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts` +- Modify(仅当测试逼出缺陷): `apps/api/src/modules/mail/internal/usecase/mail-service.ts` + +**Interfaces:** +- Consumes:Task 9 的 `createMailService` 与 Task 8 的夹具。 + +- [ ] **Step 1: 写测试** + +```ts +// apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts +/** + * 拉取与分发:先持久后分发、两把唯一键去重、逐封推进游标、中途失败停在最后一封成功处、 + * 订阅方 skipped / failed 互不影响、自动只投一次、重新投递、租约被占 409、凭证空记错误。 + */ +import { describe, expect, it } from 'vitest'; +import type { MailInboundDetail } from '@contracts'; +import { MailAccountDisabledError, MailPollBusyError, MailSubscriberTakenError, MailSubscriberUnknownError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; +import { + account, + eml, + fakeProvider, + fixedClock, + memoryAccounts, + memoryAttachments, + memoryInbound, + memoryOutbound, + plainSecrets, + rawMessage, + seqIds, +} from './fakes.ts'; + +function harness(over: { perTick?: number } = {}) { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const inbound = memoryInbound(); + const attachments = memoryAttachments(); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const changes: string[] = []; + const service = createMailService({ + accounts, + inbound, + outbound: memoryOutbound(), + attachments, + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('in'), + notify: { changed: (r) => void changes.push(r) }, + limits: { perTick: over.perTick ?? 50 }, + }); + return { service, accounts, inbound, attachments, imap, wecom, clock, changes }; +} + +describe('拉取', () => { + it('无游标先锚定;拉到的信入库、附件入 upload、游标逐封推进、lastPolledAt 与 lastInboundAt 记下', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.queue( + rawMessage('uid:v1:1', eml({ messageId: '', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })), + rawMessage('uid:v1:2', eml({ messageId: '', subject: '二' })), + ); + const outcome = await h.service.pollNow('acc-1'); + expect(outcome).toEqual({ accountId: 'acc-1', pulled: 2, stored: 2, dispatched: 2, more: false }); + const saved = h.accounts.rows.get('acc-1'); + expect(saved?.cursor).toEqual(imapCursor('v1', 2)); + expect(saved?.lastPolledAt).toEqual(h.clock.now()); + expect(saved?.lastInboundAt).not.toBeNull(); + expect(saved?.lastPollError).toBeNull(); + expect(saved?.pollingUntil).toBeNull(); + const list = await h.service.listInbound({}); + // 两封 receivedAt 相同(夹具默认值),按插入顺序稳定排 —— 只断集合,不赌顺序 + expect(list.map((m) => m.subject).sort()).toEqual(['一', '二']); + const one = list.find((m) => m.subject === '一'); + expect(one?.attachments[0]?.ref).toBe('up-1'); + expect(h.attachments.files.get('up-1')?.note).toBe('邮件附件:一'); + expect(h.changes).toContain('inbound'); + }); + + it('重复拉到(同 provider_ref 或同 Message-ID)不入库不分发、附件不重存', async () => { + const h = harness(); + await h.accounts.save(account()); + const hits: string[] = []; + h.service.subscribe('spy', async (m) => void hits.push(m.messageId)); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'x' } }))); + await h.service.pollNow('acc-1'); + h.imap.queue( + rawMessage('uid:v1:1', eml({ messageId: '' })), + rawMessage('uid:v1:9', eml({ messageId: '' })), + ); + const again = await h.service.pollNow('acc-1'); + expect(again.pulled).toBe(2); + expect(again.stored).toBe(0); + expect(hits).toEqual(['']); + expect(h.attachments.files.size).toBe(1); + }); + + it('每轮封顶 perTick:余下的报 more=true,下一轮接着;wecom 形整批推进游标', async () => { + const h = harness({ perTick: 2 }); + await h.accounts.save(account({ id: 'w', kind: 'wecom', name: '应用', address: 'app@corp.wecom.work', secretCipher: `sealed:${JSON.stringify({ corpSecret: 'cs' })}`, settings: { corpId: 'ww1' } })); + h.wecom.queue(rawMessage('mid-1', eml({ messageId: '<1@a>' })), rawMessage('mid-2', eml({ messageId: '<2@a>' })), rawMessage('mid-3', eml({ messageId: '<3@a>' }))); + const first = await h.service.pollNow('w'); + expect(first).toMatchObject({ pulled: 2, stored: 2, more: true }); + expect(h.accounts.rows.get('w')?.cursor?.kind).toBe('wecom'); + const second = await h.service.pollNow('w'); + expect(second).toMatchObject({ pulled: 1, stored: 1, more: false }); + }); + + it('拉取抛错:记 last_poll_error(脱敏)、游标停在最后一封成功处、租约还了、下一轮照常', async () => { + const h = harness(); + await h.accounts.save(account({ cursor: imapCursor('v1', 5) })); + h.imap.failPullWith(new Error('LOGIN pw-1234 rejected')); + const outcome = await h.service.pollNow('acc-1'); + expect(outcome.error).toBe('LOGIN *** rejected'); + const saved = h.accounts.rows.get('acc-1'); + expect(saved?.lastPollError).toBe('LOGIN *** rejected'); + expect(saved?.cursor).toEqual(imapCursor('v1', 5)); + expect(saved?.pollingUntil).toBeNull(); + h.imap.failPullWith(undefined); + expect((await h.service.pollNow('acc-1')).error).toBeUndefined(); + expect(h.accounts.rows.get('acc-1')?.lastPollError).toBeNull(); + }); + + it('凭证空:不连 provider,记「未配置凭证」;停用 → 412;租约被占 → 409', async () => { + const h = harness(); + await h.accounts.save(account({ secretCipher: null })); + const outcome = await h.service.pollNow('acc-1'); + expect(outcome.error).toContain('未配置凭证'); + expect(h.imap.pullCalls).toHaveLength(0); + await h.accounts.save(account({ enabled: false })); + await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailAccountDisabledError); + await h.accounts.save(account({ pollingUntil: new Date(h.clock.now().getTime() + 60_000) })); + await expect(h.service.pollNow('acc-1')).rejects.toBeInstanceOf(MailPollBusyError); + }); + + it('pollDue:到期的账号各拉一轮,一个出错不影响别的', async () => { + const h = harness(); + await h.accounts.save(account({ id: 'a', name: 'a', address: 'a@we.com' })); + await h.accounts.save(account({ id: 'b', name: 'b', address: 'b@we.com', secretCipher: null })); + await h.accounts.save(account({ id: 'c', name: 'c', address: 'c@we.com', enabled: false })); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + const outcomes = await h.service.pollDue(h.clock.now()); + expect(outcomes.map((o) => o.accountId).sort()).toEqual(['a', 'b']); + expect(outcomes.find((o) => o.accountId === 'a')?.stored).toBe(1); + expect(outcomes.find((o) => o.accountId === 'b')?.error).toContain('未配置凭证'); + }); +}); + +describe('分发', () => { + it('订阅方收到详情与 ctx(账号 / 通道);skipped 与 failed 各自留痕,互不影响;同名注册被拒;注销后不再收', async () => { + const h = harness(); + await h.accounts.save(account({ channelId: 'ch-9' })); + const seen: Array<{ mail: MailInboundDetail; channelId: string | undefined }> = []; + h.service.subscribe('ok', async (mail, ctx) => void seen.push({ mail, channelId: ctx.channelId })); + h.service.subscribe('skip', async () => ({ skipped: '与我无关' })); + const off = h.service.subscribe('boom', async () => { + throw new Error('通道实例已停用:ch-9'); + }); + expect(() => h.service.subscribe('ok', async () => undefined)).toThrow(MailSubscriberTakenError); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', subject: '主题' }))); + await h.service.pollNow('acc-1'); + expect(seen[0]?.mail.subject).toBe('主题'); + expect(seen[0]?.channelId).toBe('ch-9'); + const detail = await h.service.getInbound('in-1'); + expect(detail.deliveries.map((d) => [d.subscriber, d.state, d.error])).toEqual([ + ['boom', 'failed', '通道实例已停用:ch-9'], + ['ok', 'done', null], + ['skip', 'skipped', '与我无关'], + ]); + off(); + h.imap.queue(rawMessage('uid:v1:2', eml({ messageId: '' }))); + await h.service.pollNow('acc-1'); + expect((await h.service.getInbound('in-2')).deliveries.map((d) => d.subscriber)).toEqual(['ok', 'skip']); + }); + + it('重新投递:指定订阅方或全部;正在 dispatching 的跳过;不认识的订阅方 404', async () => { + const h = harness(); + await h.accounts.save(account()); + let fail = true; + h.service.subscribe('flaky', async () => { + if (fail) throw new Error('第一次不行'); + }); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + await h.service.pollNow('acc-1'); + expect((await h.service.getInbound('in-1')).deliveries[0]?.state).toBe('failed'); + fail = false; + const redone = await h.service.redeliver('in-1', 'flaky'); + expect(redone).toEqual([expect.objectContaining({ subscriber: 'flaky', state: 'done' })]); + await expect(h.service.redeliver('in-1', 'nobody')).rejects.toBeInstanceOf(MailSubscriberUnknownError); + await h.inbound.setDelivery({ mailId: 'in-1', subscriber: 'flaky', state: 'dispatching', error: null, startedAt: h.clock.now(), finishedAt: null }); + expect((await h.service.redeliver('in-1'))[0]?.state).toBe('dispatching'); + }); +}); + +describe('附件与原件', () => { + it('attachment 取回字节;超限没存的 404;raw 取回原件', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hello' } }))); + await h.service.pollNow('acc-1'); + const file = await h.service.attachment('in-1', 0); + expect(new TextDecoder().decode(file.bytes)).toBe('hello'); + expect(file.name).toBe('a.txt'); + await expect(h.service.attachment('in-1', 5)).rejects.toThrow('附件没有存下来'); + const raw = await h.service.raw('in-1'); + expect(raw.name).toBe('m1_a.eml'); + expect(new TextDecoder().decode(raw.bytes)).toContain('Subject:'); + }); +}); +``` + +- [ ] **Step 2: 跑测试** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/mail-service-poll.test.ts` +Expected: 9 tests PASS。红了就按测试名对回 Task 9 的用例修(常见:`listInbound` 排序断言 —— 两封 `receivedAt` 相同时按插入顺序,把断言里的顺序改成与夹具一致;`raw.name` 的 `safeFileName` 把 `` 收成 `m1_a`)。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail 用例测试:拉取 / 去重 / 逐封游标 / 失败停位 / 分发三态 / 重新投递 / 附件原件\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 11: usecase 测试 —— 发信 + +**Files:** +- Test: `apps/api/src/modules/mail/__tests__/mail-service-send.test.ts` +- Modify(仅当测试逼出缺陷): `apps/api/src/modules/mail/internal/usecase/mail-service.ts` + +- [ ] **Step 1: 写测试** + +```ts +// apps/api/src/modules/mail/__tests__/mail-service-send.test.ts +/** 发信:先落 sending 再发;厂商失败落 failed 不抛;幂等键不发两封、失败可重发;附件取自 upload;from 对账号 */ +import { describe, expect, it } from 'vitest'; +import { MailAccountNotFoundError, MailNoDefaultAccountError, MailSecretMissingError, MailSendInvalidError } from '../contract.ts'; +import { createMailService } from '../internal/usecase/mail-service.ts'; +import { createPostalMimeParser } from '../internal/adapter/postal-mime-parser.ts'; +import { account, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, seqIds } from './fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const outbound = memoryOutbound(); + const attachments = memoryAttachments(); + const imap = fakeProvider('imap-smtp'); + const wecom = fakeProvider('wecom'); + const service = createMailService({ + accounts, + inbound: memoryInbound(), + outbound, + attachments, + providers: { 'imap-smtp': imap, wecom }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('out'), + notify: { changed: () => undefined }, + }); + return { service, accounts, outbound, attachments, imap, clock }; +} +const base = { to: ['b@acme.com'], cc: [], bcc: [], subject: '报价', text: '见附件', references: [], attachments: [], requestedBy: { kind: 'ops' as const } }; + +describe('send', () => { + it('默认账号发出:留痕 sent、Message-ID 是 、发件人带显示名、返回 providerRef', async () => { + const h = harness(); + await h.accounts.save(account()); + const result = await h.service.send(base); + expect(result).toEqual({ outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', providerRef: 'fake:1' }); + expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' }); + expect(h.imap.sent[0]?.messageId).toBe(''); + const row = h.outbound.rows.get('out-1'); + expect(row?.state).toBe('sent'); + expect(row?.sentAt).toEqual(h.clock.now()); + expect((await h.service.listOutbound({}))[0]).toMatchObject({ id: 'out-1', state: 'sent', to: ['b@acme.com'] }); + }); + + it('厂商失败:落 failed(原话脱敏)、不抛、返回 state=failed', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.failSendWith(Object.assign(new Error('Invalid login'), { response: '535 5.7.8 pw-1234 rejected' })); + const result = await h.service.send(base); + expect(result.state).toBe('failed'); + expect(result.error).toBe('Invalid login —— 535 5.7.8 *** rejected'); + expect(h.outbound.rows.get('out-1')?.state).toBe('failed'); + }); + + it('幂等键:已 sent 的直接回原结果不再发;失败的那一行复用重发', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.failSendWith(new Error('暂时不行')); + const failed = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(failed.state).toBe('failed'); + h.imap.failSendWith(undefined); + const retried = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(retried).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); + const again = await h.service.send({ ...base, idempotencyKey: 'k-1' }); + expect(again).toMatchObject({ outboundId: failed.outboundId, state: 'sent' }); + expect(h.imap.sent).toHaveLength(1); + expect(h.outbound.rows.size).toBe(1); + }); + + it('入参问题抛而不落行:无正文 400、没默认账号 412、from 对不上 404、凭证空 412', async () => { + const h = harness(); + await expect(h.service.send(base)).rejects.toBeInstanceOf(MailNoDefaultAccountError); + await h.accounts.save(account()); + await expect(h.service.send({ ...base, text: undefined })).rejects.toBeInstanceOf(MailSendInvalidError); + await expect(h.service.send({ ...base, from: 'nobody@we.com' })).rejects.toBeInstanceOf(MailAccountNotFoundError); + await h.accounts.save(account({ secretCipher: null })); + await expect(h.service.send(base)).rejects.toBeInstanceOf(MailSecretMissingError); + expect(h.outbound.rows.size).toBe(0); + }); + + it('附件:uploadRef 取 upload 的字节与类型;dataBase64 直接用;两者都没有 400', async () => { + const h = harness(); + await h.accounts.save(account()); + const { ref } = await h.attachments.put({ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]), note: '' }); + await h.service.send({ + ...base, + attachments: [ + { name: 'q.pdf', contentType: 'application/octet-stream', uploadRef: ref }, + { name: 'n.txt', contentType: 'text/plain', dataBase64: Buffer.from('hi').toString('base64') }, + ], + }); + expect(h.imap.sent[0]?.attachments.map((a) => [a.name, a.contentType, a.bytes.byteLength])).toEqual([ + ['q.pdf', 'application/pdf', 2], + ['n.txt', 'text/plain', 2], + ]); + await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain' }] })).rejects.toBeInstanceOf(MailSendInvalidError); + await expect(h.service.send({ ...base, attachments: [{ name: 'x', contentType: 'text/plain', uploadRef: 'up-404' }] })).rejects.toBeInstanceOf(MailSendInvalidError); + }); + + it('from 指定发件地址(channel 用):按地址找账号,inReplyTo / references 原样交给 provider', async () => { + const h = harness(); + await h.accounts.save(account({ isDefault: false })); + const result = await h.service.send({ ...base, from: 'SALES@we.com', inReplyTo: '', references: [''], requestedBy: { kind: 'channel', taskId: 't-1' } }); + expect(result.accountId).toBe('acc-1'); + expect(h.imap.sent[0]?.inReplyTo).toBe(''); + expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' }); + }); +}); +``` + +- [ ] **Step 2: 跑测试** + +Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint` +Expected: 全部 PASS;门禁退出码 0。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail 用例测试:发信留痕 / 厂商失败不抛 / 幂等键 / 附件来源 / from 对账号\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 12: adapter:IMAP/SMTP provider(imapflow + nodemailer) + +**Files:** +- Create: `apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts` +- Modify: `apps/api/src/modules/mail/index.ts`(追加导出) +- Test: `apps/api/src/modules/mail/__tests__/imap-helpers.test.ts`(纯映射)· `apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts`(env 门控的真信箱) + +**Interfaces:** +- Produces:`createImapSmtpProvider(): MailProvider`;导出的纯助手 `imapSettingsOf(account)` `internalDateOf(value, fallback)` `mailOptionsOf(message)` `orderFetched(rows)`。 +- 真实签名(本机核过 imapflow 1.7.6 / @types/nodemailer 8.0.1):`new ImapFlow({host, port, secure, auth:{user, pass}, logger: false})`;`getMailboxLock(path, {readOnly: true})` → `{release()}`;`client.mailbox` → `{uidValidity: bigint, uidNext: number}`;`search({uid: 'n:*'} | {since: Date}, {uid: true})` → `number[] | false`;`fetch(number[], {uid, source, internalDate}, {uid: true})` → 异步迭代 `{uid, source?: Buffer, internalDate?: Date | string}`;`logout()` / `close()`。nodemailer:`createTransport({host, port, secure, requireTLS, auth:{user, pass}, connectionTimeout, greetingTimeout, socketTimeout})`,`verify(): Promise`,`sendMail({...}) → {messageId, response}`,`close()`。 + +- [ ] **Step 1: 写失败测试(纯映射)** + +```ts +// apps/api/src/modules/mail/__tests__/imap-helpers.test.ts +/** IMAP/SMTP 适配器里不碰网络的那几段:设置默认值、内部时间兜底、fetch 结果排序、nodemailer 入参映射 */ +import { describe, expect, it } from 'vitest'; +import { imapSettingsOf, internalDateOf, mailOptionsOf, orderFetched } from '../internal/adapter/imap-smtp-provider.ts'; +import { imapCursor } from '../internal/domain/cursor.ts'; + +const account = { id: 'a', kind: 'imap-smtp' as const, address: 'sales@we.com', displayName: '销售部', settings: {}, secret: { password: 'pw' } }; + +describe('imapSettingsOf', () => { + it('空 settings 取腾讯默认;登录名缺省 = 地址', () => { + const s = imapSettingsOf(account); + expect(s.settings.imapHost).toBe('imap.exmail.qq.com'); + expect(s.settings.smtpPort).toBe(465); + expect(s.login).toBe('sales@we.com'); + expect(imapSettingsOf({ ...account, settings: { login: 'other@we.com' } }).login).toBe('other@we.com'); + }); + it('缺密码当场说清', () => { + expect(() => imapSettingsOf({ ...account, secret: {} })).toThrow('缺客户端专用密码'); + }); +}); + +describe('internalDateOf', () => { + const fallback = new Date('2026-08-25T10:00:00Z'); + it('Date 原样;字符串解得出就用;解不出 / 缺席用兜底', () => { + const d = new Date('2026-08-25T09:00:00Z'); + expect(internalDateOf(d, fallback)).toBe(d); + expect(internalDateOf('2026-08-25T09:00:00Z', fallback).toISOString()).toBe('2026-08-25T09:00:00.000Z'); + expect(internalDateOf('garbage', fallback)).toBe(fallback); + expect(internalDateOf(undefined, fallback)).toBe(fallback); + }); +}); + +describe('orderFetched', () => { + it('按 UID 升序,cursorAfter 就是自己的 UID;没 source 的丢', () => { + const rows = orderFetched('7', [ + { uid: 12, source: Buffer.from('b'), internalDate: undefined }, + { uid: 10, source: Buffer.from('a'), internalDate: undefined }, + { uid: 11, source: undefined, internalDate: undefined }, + ], new Date('2026-08-25T10:00:00Z')); + expect(rows.map((r) => r.providerRef)).toEqual(['7:10', '7:12']); + expect(rows[1]?.cursorAfter).toEqual(imapCursor('7', 12)); + expect(rows[0]?.eml).toBeInstanceOf(Uint8Array); + }); +}); + +describe('mailOptionsOf', () => { + it('Message-ID / 显示名 / 附件字节全部带上;没 html 就不带 html 键', () => { + const options = mailOptionsOf({ + messageId: '', + from: { address: 'sales@we.com', name: '销售部' }, + to: ['b@acme.com'], + cc: [], + bcc: ['c@we.com'], + subject: '报价', + text: '正文', + inReplyTo: '', + references: [''], + attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], + }); + expect(options.messageId).toBe(''); + expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' }); + expect(options.bcc).toEqual(['c@we.com']); + expect(options.inReplyTo).toBe(''); + expect(options.references).toEqual(['']); + expect('html' in options).toBe(false); + expect(options.attachments?.[0]).toMatchObject({ filename: 'q.pdf', contentType: 'application/pdf' }); + expect(Buffer.isBuffer(options.attachments?.[0]?.content)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts` +Expected: FAIL(适配器不存在)。 + +- [ ] **Step 3: 写适配器** + +```ts +// apps/api/src/modules/mail/internal/adapter/imap-smtp-provider.ts +/** + * 标准协议适配器 —— imapflow 收、nodemailer 发(外界;设计稿 §5.5;官方帮助 open.work.weixin.qq.com/help2/pc/19886)。 + * + * 腾讯企业邮箱:imap.exmail.qq.com:993 / smtp.exmail.qq.com:465(SSL);账号 = 完整邮箱地址; + * 密码 = 邮箱密码,开了安全登录 / 微信绑定则是**客户端专用密码**。 + * 只读打开文件夹 —— 轮询不动人的信箱(不改已读标记)。 + * IMAP 的 `n:*` 在 n 大于最大 UID 时仍回最大那一封:UID 列表再按 `newUids` 过滤一遍。 + */ +import { ImapFlow } from 'imapflow'; +import nodemailer from 'nodemailer'; +import type Mail from 'nodemailer/lib/mailer'; +import { imapSmtpSettingsSchema } from '@contracts'; +import type { ImapSmtpSettings, MailProbeReport } from '@contracts'; +import { imapCursor, newUids } from '../domain/cursor.ts'; +import type { MailCursor } from '../domain/cursor.ts'; +import { errorText } from '../domain/redact.ts'; +import type { MailProvider, OutboundMessage, ProviderAccount, RawMessage } from '../../port.ts'; + +const CONNECT_TIMEOUT_MS = 30_000; +const SOCKET_TIMEOUT_MS = 60_000; + +export function imapSettingsOf(account: ProviderAccount): { settings: ImapSmtpSettings; login: string; password: string } { + const settings = imapSmtpSettingsSchema.parse(account.settings); + const password = account.secret['password']; + if (password === undefined || password === '') throw new Error('缺客户端专用密码(邮箱密码 / 授权码)'); + return { settings, login: settings.login ?? account.address, password }; +} + +export function internalDateOf(value: Date | string | undefined, fallback: Date): Date { + if (value instanceof Date) return value; + if (typeof value === 'string') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return fallback; +} + +/** fetch 的到达顺序不保证升序 —— 按 UID 排好,cursorAfter 就是各自的 UID(逐封推进的依据) */ +export function orderFetched( + uidValidity: string, + rows: ReadonlyArray<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }>, + now: Date, +): RawMessage[] { + return rows + .filter((r): r is { uid: number; source: Buffer; internalDate: Date | string | undefined } => r.source !== undefined) + .sort((a, b) => a.uid - b.uid) + .map((r) => ({ + providerRef: `${uidValidity}:${r.uid}`, + eml: Uint8Array.from(r.source), + receivedAt: internalDateOf(r.internalDate, now), + cursorAfter: imapCursor(uidValidity, r.uid), + })); +} + +export function mailOptionsOf(message: OutboundMessage): Mail.Options { + return { + messageId: message.messageId, + from: { name: message.from.name, address: message.from.address }, + to: [...message.to], + cc: [...message.cc], + bcc: [...message.bcc], + subject: message.subject, + ...(message.text === undefined ? {} : { text: message.text }), + ...(message.html === undefined ? {} : { html: message.html }), + ...(message.inReplyTo === undefined ? {} : { inReplyTo: message.inReplyTo }), + references: [...message.references], + attachments: message.attachments.map((a) => ({ filename: a.name, content: Buffer.from(a.bytes), contentType: a.contentType })), + }; +} + +function transporterOf(account: ProviderAccount) { + const { settings, login, password } = imapSettingsOf(account); + return nodemailer.createTransport({ + host: settings.smtpHost, + port: settings.smtpPort, + secure: settings.smtpTls, + requireTLS: !settings.smtpTls, + auth: { user: login, pass: password }, + connectionTimeout: CONNECT_TIMEOUT_MS, + greetingTimeout: CONNECT_TIMEOUT_MS, + socketTimeout: SOCKET_TIMEOUT_MS, + }); +} + +async function withImap(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise): Promise { + const { settings, login, password } = imapSettingsOf(account); + const client = new ImapFlow({ + host: settings.imapHost, + port: settings.imapPort, + secure: settings.imapTls, + auth: { user: login, pass: password }, + logger: false, + connectionTimeout: CONNECT_TIMEOUT_MS, + socketTimeout: SOCKET_TIMEOUT_MS, + }); + await client.connect(); + try { + return await fn(client, settings.folder); + } finally { + await client.logout().catch(() => client.close()); + } +} + +function mailboxOf(client: ImapFlow): { uidValidity: string; uidNext: number } { + const box = client.mailbox; + if (box === false) throw new Error('文件夹未打开'); + return { uidValidity: String(box.uidValidity), uidNext: box.uidNext }; +} + +async function timed(run: () => Promise): Promise<{ ok: true; latencyMs: number } | { ok: false; error: string }> { + const started = Date.now(); + try { + await run(); + return { ok: true, latencyMs: Date.now() - started }; + } catch (error) { + return { ok: false, error: errorText(error) }; + } +} + +export function createImapSmtpProvider(): MailProvider { + return { + kind: 'imap-smtp', + + async probe(account): Promise { + const receive = await timed(() => + withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + lock.release(); + }), + ); + const send = await timed(async () => { + const transporter = transporterOf(account); + try { + await transporter.verify(); + } finally { + transporter.close(); + } + }); + return { + kind: 'imap-smtp', + address: account.address, + receive: receive.ok ? { ok: true, latencyMs: receive.latencyMs } : { ok: false, error: receive.error }, + send: send.ok ? { checked: true, ok: true } : { checked: true, ok: false, error: send.error }, + }; + }, + + async anchor(account, backfillFrom): Promise { + return withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + try { + const box = mailboxOf(client); + if (backfillFrom === null) return imapCursor(box.uidValidity, box.uidNext - 1); + const uids = await client.search({ since: backfillFrom }, { uid: true }); + const first = uids === false || uids.length === 0 ? box.uidNext : Math.min(...uids); + return imapCursor(box.uidValidity, first - 1); + } finally { + lock.release(); + } + }); + }, + + async pull(account, cursor, opts) { + return withImap(account, async (client, folder) => { + const lock = await client.getMailboxLock(folder, { readOnly: true }); + try { + const box = mailboxOf(client); + if (cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity) { + // UIDVALIDITY 变了(信箱重建 / 迁移):旧 UID 全作废,重锚在当下、不回填;万一重复由唯一键挡 + return { messages: [], cursor: imapCursor(box.uidValidity, box.uidNext - 1), more: false }; + } + const found = await client.search({ uid: `${cursor.lastUid + 1}:*` }, { uid: true }); + const fresh = newUids(found === false ? [] : found, cursor.lastUid); + const wanted = fresh.slice(0, opts.limit); + const rows: Array<{ uid: number; source: Buffer | undefined; internalDate: Date | string | undefined }> = []; + if (wanted.length > 0) { + for await (const msg of client.fetch(wanted, { uid: true, source: true, internalDate: true }, { uid: true })) { + rows.push({ uid: msg.uid, source: msg.source, internalDate: msg.internalDate }); + } + } + const messages = orderFetched(box.uidValidity, rows, opts.now); + return { messages, cursor: messages.at(-1)?.cursorAfter ?? cursor, more: fresh.length > wanted.length }; + } finally { + lock.release(); + } + }); + }, + + async send(account, message) { + const transporter = transporterOf(account); + try { + const info = await transporter.sendMail(mailOptionsOf(message)); + return { providerRef: info.response }; + } finally { + transporter.close(); + } + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export { createImapSmtpProvider } from './internal/adapter/imap-smtp-provider.ts'; +``` + +- [ ] **Step 4: 写 env 门控的真信箱集成测试** + +```ts +// apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts +/** + * 真信箱集成测试 —— 只在给了 JCAI_MAIL_IT_IMAP_USER / _PASS 时跑(缺即跳过并告警)。 + * JCAI_MAIL_IT_IMAP_HOST(缺省 imap.exmail.qq.com)· JCAI_MAIL_IT_SMTP_HOST(缺省 smtp.exmail.qq.com) + * JCAI_MAIL_IT_SEND_TO:给了才真发一封(发给自己最省事) + */ +import { describe, expect, it } from 'vitest'; +import { createImapSmtpProvider } from '../internal/adapter/imap-smtp-provider.ts'; + +const user = process.env['JCAI_MAIL_IT_IMAP_USER']; +const pass = process.env['JCAI_MAIL_IT_IMAP_PASS']; +const enabled = user !== undefined && pass !== undefined; +if (!enabled) process.stderr.write('⚠️ IMAP/SMTP 真信箱集成测试跳过:未设 JCAI_MAIL_IT_IMAP_USER / JCAI_MAIL_IT_IMAP_PASS\n'); + +const account = { + id: 'it', + kind: 'imap-smtp' as const, + address: user ?? '', + displayName: 'JCAI IT', + settings: { + imapHost: process.env['JCAI_MAIL_IT_IMAP_HOST'] ?? 'imap.exmail.qq.com', + smtpHost: process.env['JCAI_MAIL_IT_SMTP_HOST'] ?? 'smtp.exmail.qq.com', + }, + secret: { password: pass ?? '' }, +}; +const known = async (): Promise> => new Set(); + +describe.skipIf(!enabled)('IMAP/SMTP provider(真信箱)', () => { + const provider = createImapSmtpProvider(); + + it('探测:收发都 ok', async () => { + const report = await provider.probe(account); + expect(report.receive).toMatchObject({ ok: true }); + expect(report.send).toMatchObject({ checked: true, ok: true }); + }, 60_000); + + it('锚定在当下;紧接着拉一轮是空的;回填 7 天能拉到东西(或至少不抛)', async () => { + const now = new Date(); + const cursor = await provider.anchor(account, null, now); + expect(cursor.kind).toBe('imap'); + const empty = await provider.pull(account, cursor, { limit: 5, now, backfillFrom: null, known }); + expect(empty.messages).toEqual([]); + const since = new Date(now.getTime() - 7 * 86_400_000); + const back = await provider.anchor(account, since, now); + const some = await provider.pull(account, back, { limit: 3, now, backfillFrom: since, known }); + expect(some.messages.length).toBeLessThanOrEqual(3); + for (const m of some.messages) expect(m.eml.byteLength).toBeGreaterThan(0); + }, 120_000); + + it.skipIf(process.env['JCAI_MAIL_IT_SEND_TO'] === undefined)('真发一封', async () => { + const result = await provider.send(account, { + messageId: ``, + from: { address: account.address, name: 'JCAI IT' }, + to: [process.env['JCAI_MAIL_IT_SEND_TO'] ?? ''], + cc: [], + bcc: [], + subject: 'JCAI mail IT', + text: '这是一封集成测试信', + references: [], + attachments: [], + }); + expect(result.providerRef ?? '').toContain('250'); + }, 60_000); +}); +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/imap-helpers.test.ts apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 助手 6 tests PASS;IT 套件跳过并打印告警(没给真信箱);typecheck 退出码 0 —— 若 `ImapFlowOptions` 对 `connectionTimeout` / `socketTimeout` 报错,以 `node_modules/imapflow/lib/imap-flow.d.ts` 为准删掉不认识的键;`import type Mail from 'nodemailer/lib/mailer'` 报错则改成 `import type { Options as MailOptions } from 'nodemailer/lib/mailer';`。depcruise:imapflow / nodemailer 只在 adapter,过。 +有真信箱时:`JCAI_MAIL_IT_IMAP_USER=xxx@corp.com JCAI_MAIL_IT_IMAP_PASS=客户端专用密码 pnpm vitest run apps/api/src/modules/mail/__tests__/imap-smtp-provider.it.test.ts` → 2 PASS(第 3 条给 `JCAI_MAIL_IT_SEND_TO` 才跑)。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail adapter:IMAP/SMTP provider(只读打开 / UID 游标 / n:* 过滤 / UIDVALIDITY 重锚 / nodemailer 发信)+ env 门控真信箱 IT\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 13: adapter:企业微信邮件 API provider + +**Files:** +- Create: `apps/api/src/modules/mail/internal/adapter/wecom-provider.ts` +- Modify: `apps/api/src/modules/mail/index.ts`(追加导出) +- Test: `apps/api/src/modules/mail/__tests__/wecom-provider.test.ts`(对假 fetch) + +**Interfaces:** +- Produces:`createWecomProvider(deps?: { fetch?: FetchLike; now?: () => number }): MailProvider`;`FetchLike`;`WecomApiError(errcode, errmsg, api)`。 +- 官方接口(核实 2026-08-25):`GET {apiBase}/cgi-bin/gettoken?corpid&corpsecret` → `{access_token, expires_in}`;`POST /cgi-bin/exmail/app/get_email_alias` → `{email, alias_list}`;`POST /cgi-bin/exmail/app/get_mail_list` body `{begin_time, end_time, cursor?, limit≤1000}` → `{next_cursor, has_more(0|1), mail_list:[{mail_id}]}`;`POST /cgi-bin/exmail/app/read_mail` body `{mail_id}` → `{mail_data}`(EML;编码文档未说);`POST /cgi-bin/exmail/app/compose_send` body `{to:{emails}, cc?, bcc?, subject, content, content_type:'html'|'text', attachment_list?:[{file_name, content(base64)}]}` → `{errcode, errmsg}`;错误码 `-1` 系统繁忙、`40014` token 非法、`42001` token 过期、`45009` 频率超限、`48002` 无权限、`60020` IP 不在白名单。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/modules/mail/__tests__/wecom-provider.test.ts +/** 企业微信邮件 API 适配器 —— 对假 fetch:token 缓存 / 过期刷新 / -1 重试 / 翻页 / 已知 mail_id 不再 read_mail / EML 编码兜底 / compose_send 载荷 */ +import { describe, expect, it } from 'vitest'; +import { createWecomProvider, WecomApiError } from '../internal/adapter/wecom-provider.ts'; +import type { FetchLike } from '../internal/adapter/wecom-provider.ts'; +import { wecomCursor } from '../internal/domain/cursor.ts'; + +type Call = { url: string; body: unknown }; + +function scripted(handlers: Record, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } { + const calls: Call[] = []; + const counts = new Map(); + const fetch: FetchLike = async (url, init) => { + const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record); + calls.push({ url, body }); + const key = Object.keys(handlers).find((k) => url.includes(k)); + if (key === undefined) return { ok: false, status: 404, json: async () => ({}) }; + const nth = (counts.get(key) ?? 0) + 1; + counts.set(key, nth); + return { ok: true, status: 200, json: async () => handlers[key]!(body, nth) }; + }; + return { fetch, calls }; +} + +const account = { id: 'w', kind: 'wecom' as const, address: '', displayName: '应用', settings: { corpId: 'ww1' }, secret: { corpSecret: 'cs-1' } }; +const known = async (): Promise> => new Set(); +const now = new Date('2026-08-25T10:00:00Z'); +const EML = 'From: a@x.com\r\nTo: b@y.com\r\nSubject: hi\r\n\r\nbody\r\n'; + +describe('token', () => { + it('第一次取 token 之后缓存;过期前 5 分钟重取;42001 刷一次重试', async () => { + let clock = 0; + let issued = 0; + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: `tok-${(issued += 1)}`, expires_in: 7200 }), + 'get_email_alias': (_b, nth) => (nth === 2 ? { errcode: 42001, errmsg: 'access_token expired' } : { errcode: 0, email: 'app@corp.wecom.work' }), + }); + const provider = createWecomProvider({ fetch, now: () => clock }); + await provider.probe(account); + await provider.probe(account); + expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(2); // 第二次 alias 回 42001 → 刷一次 + clock = 7200_000 - 4 * 60_000; // 离过期不到 5 分钟 + await provider.probe(account); + expect(calls.filter((c) => c.url.includes('gettoken'))).toHaveLength(3); + expect(calls[0]?.url).toContain('corpid=ww1'); + expect(calls[0]?.url).toContain('corpsecret=cs-1'); + }); + + it('**-1 连着来到边界就不再重试**:第 4 次仍是 -1 即抛', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: -1, errmsg: 'system busy' }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report.receive.ok).toBe(false); + expect(report.receive.error).toContain('-1'); + // BUSY_RETRIES = 3 —— 首次 + 3 次重试 = 4 次调用,然后放弃 + expect(calls.filter((c) => c.url.includes('get_email_alias'))).toHaveLength(4); + }); + + it('-1 系统繁忙重试 ≤3 次;别的错误码抛 WecomApiError 带 api 名', async () => { + let busy = 2; + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => (busy-- > 0 ? { errcode: -1, errmsg: 'system busy' } : { errcode: 48002, errmsg: 'api forbidden' }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report.receive.ok).toBe(false); + expect(report.receive.error).toContain('get_email_alias'); + expect(report.receive.error).toContain('48002'); + expect(report.send).toMatchObject({ checked: false }); + await expect( + createWecomProvider({ fetch: scripted({ '/cgi-bin/gettoken': () => ({ errcode: 40001, errmsg: 'invalid credential' }) }).fetch }).pull(account, wecomCursor(1), { limit: 5, now, backfillFrom: null, known }), + ).rejects.toBeInstanceOf(WecomApiError); + }); +}); + +describe('probe / anchor', () => { + it('探到应用邮箱地址;发信一栏 checked=false', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_email_alias': () => ({ errcode: 0, email: 'app@corp.wecom.work', alias_list: [] }), + }); + const report = await createWecomProvider({ fetch }).probe(account); + expect(report).toMatchObject({ kind: 'wecom', address: 'app@corp.wecom.work', receive: { ok: true }, send: { checked: false } }); + }); + it('锚定 = 当下(或回填起点)的秒', async () => { + const provider = createWecomProvider({ fetch: scripted({}).fetch }); + expect(await provider.anchor(account, null, now)).toEqual(wecomCursor(Math.floor(now.getTime() / 1000))); + expect(await provider.anchor(account, new Date('2026-08-20T00:00:00Z'), now)).toEqual(wecomCursor(Math.floor(new Date('2026-08-20T00:00:00Z').getTime() / 1000))); + }); +}); + +describe('pull', () => { + it('窗口 = lastTime−600 到当下;翻页;库里已有的 mail_id 不再 read_mail;EML 原文与 base64 都解得出;more 反映还没翻完', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_mail_list': (body) => + body['cursor'] === undefined + ? { errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] } + : { errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm3' }] }, + 'read_mail': (body) => ({ errcode: 0, mail_data: body['mail_id'] === 'm3' ? Buffer.from(EML).toString('base64') : EML }), + }); + const provider = createWecomProvider({ fetch }); + const cursor = wecomCursor(Math.floor(now.getTime() / 1000) - 1000); + const result = await provider.pull(account, cursor, { limit: 10, now, backfillFrom: null, known: async (refs) => new Set(refs.filter((r) => r === 'm2')) }); + const listCall = calls.find((c) => c.url.includes('get_mail_list'))?.body; + expect(listCall).toMatchObject({ begin_time: cursor.lastTime - 600, end_time: Math.floor(now.getTime() / 1000) }); + expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm3']); + expect(new TextDecoder().decode(result.messages[1]?.eml)).toBe(EML); + expect(calls.filter((c) => c.url.includes('read_mail')).map((c) => c.body['mail_id'])).toEqual(['m1', 'm3']); + expect(result.cursor).toEqual(wecomCursor(Math.floor(now.getTime() / 1000))); + expect(result.more).toBe(false); + }); + + it('**单页翻完但新信多过 limit**:游标不许推进(否则截掉的那些永久丢)', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + // has_more:0 —— 页是翻完了,可这一页的新信比本轮预算多 + 'get_mail_list': () => ({ errcode: 0, next_cursor: '', has_more: 0, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }, { mail_id: 'm3' }] }), + 'read_mail': () => ({ errcode: 0, mail_data: EML }), + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known }); + expect(result.messages.map((m) => m.providerRef)).toEqual(['m1', 'm2']); + expect(result.more).toBe(true); + // 「页翻完了」≠「新信吃完了」—— 游标跟着 exhausted 走就会把 m3 跳过去 + expect(result.cursor).toEqual(cursor); + }); + + it('取满 limit 就停,more=true', async () => { + const { fetch } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'get_mail_list': () => ({ errcode: 0, next_cursor: 'c2', has_more: 1, mail_list: [{ mail_id: 'm1' }, { mail_id: 'm2' }] }), + 'read_mail': () => ({ errcode: 0, mail_data: EML }), + }); + const cursor = wecomCursor(1); + const result = await createWecomProvider({ fetch }).pull(account, cursor, { limit: 2, now, backfillFrom: null, known }); + expect(result.messages).toHaveLength(2); + expect(result.more).toBe(true); + // **截断时游标原样退回**:没读到的那几封不能被跳过去(重叠窗兜不住一次积压) + expect(result.cursor).toEqual(cursor); + }); +}); + +describe('send', () => { + it('compose_send 载荷:emails 三组、html 优先、附件 base64;成功不回 providerRef', async () => { + const { fetch, calls } = scripted({ + '/cgi-bin/gettoken': () => ({ errcode: 0, access_token: 't', expires_in: 7200 }), + 'compose_send': () => ({ errcode: 0, errmsg: 'ok' }), + }); + const result = await createWecomProvider({ fetch }).send(account, { + messageId: '', + from: { address: 'app@corp.wecom.work', name: '应用' }, + to: ['a@x.com'], + cc: ['b@x.com'], + bcc: [], + subject: '报价', + text: '纯文本', + html: '

html

', + references: [], + attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }], + }); + expect(result).toEqual({}); + const payload = calls.find((c) => c.url.includes('compose_send'))?.body; + expect(payload).toEqual({ + to: { emails: ['a@x.com'] }, + cc: { emails: ['b@x.com'] }, + subject: '报价', + content: '

html

', + content_type: 'html', + attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }], + }); + }); +}); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/modules/mail/__tests__/wecom-provider.test.ts` +Expected: FAIL(适配器不存在)。 + +- [ ] **Step 3: 写适配器** + +```ts +// apps/api/src/modules/mail/internal/adapter/wecom-provider.ts +/** + * 企业微信邮件 API 适配器(外界;设计稿 §5.5;文档核实 2026-08-25): + * gettoken(corpid, corpsecret) → access_token(7200s) + * exmail/app/get_email_alias → 应用邮箱地址 + * exmail/app/get_mail_list(begin_time, end_time, cursor, limit ≤ 1000) → mail_id(只有应用邮箱自己的收件箱) + * exmail/app/read_mail(mail_id) → mail_data(原始 EML;**文档未说编码** —— 像 EML 按原文,否则当 base64) + * exmail/app/compose_send → 发件人恒为应用邮箱;**不回 Message-ID**(线程级归属对它发出的信不成立) + * token 缓存在实例闭包里按账号存(不是模块级状态);提前 5 分钟刷新;40014 / 42001 刷一次重试;-1 重试 ≤3 次。 + */ +import { wecomSettingsSchema } from '@contracts'; +import type { MailProbeReport } from '@contracts'; +import { wecomCursor, wecomWindow } from '../domain/cursor.ts'; +import type { MailCursor } from '../domain/cursor.ts'; +import { errorText } from '../domain/redact.ts'; +import type { MailProvider, ProviderAccount, RawMessage } from '../../port.ts'; + +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +type WecomResponse = Record & { errcode?: number; errmsg?: string }; + +export class WecomApiError extends Error { + constructor( + readonly errcode: number, + readonly errmsg: string, + readonly api: string, + ) { + super(`企业微信 ${api} 失败:errcode ${errcode} ${errmsg}`); + this.name = 'WecomApiError'; + } +} + +const TOKEN_EARLY_MS = 5 * 60_000; +const BUSY_RETRIES = 3; +const REQUEST_TIMEOUT_MS = 30_000; +const LIST_PAGE = 100; + +/** 文档只说「邮件 eml 内容」:头部行的形(`Name: value`)就按原文;否则当 base64 */ +export function emlBytesOf(mailData: string): Uint8Array { + const looksLikeEml = /^[A-Za-z][A-Za-z0-9-]*:\s/m.test(mailData.slice(0, 2048)); + return looksLikeEml ? new TextEncoder().encode(mailData) : new Uint8Array(Buffer.from(mailData, 'base64')); +} + +export function createWecomProvider(deps: { fetch?: FetchLike; now?: () => number } = {}): MailProvider { + const doFetch: FetchLike = deps.fetch ?? ((url, init) => fetch(url, init)); + const nowMs = deps.now ?? (() => Date.now()); + const tokens = new Map(); + + async function call(api: string, url: string, init?: Parameters[1]): Promise { + for (let attempt = 0; ; attempt += 1) { + const res = await doFetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`企业微信 ${api} HTTP ${res.status}`); + const body = (await res.json()) as WecomResponse; + const code = typeof body.errcode === 'number' ? body.errcode : 0; + if (code === 0) return body; + if (code === -1 && attempt < BUSY_RETRIES) continue; + throw new WecomApiError(code, String(body.errmsg ?? ''), api); + } + } + + async function tokenOf(account: ProviderAccount, force = false): Promise { + const cached = tokens.get(account.id); + if (!force && cached !== undefined && cached.expiresAt - TOKEN_EARLY_MS > nowMs()) return cached.token; + const settings = wecomSettingsSchema.parse(account.settings); + const secret = account.secret['corpSecret']; + if (secret === undefined || secret === '') throw new Error('缺应用 secret'); + const body = await call( + 'gettoken', + `${settings.apiBase}/cgi-bin/gettoken?corpid=${encodeURIComponent(settings.corpId)}&corpsecret=${encodeURIComponent(secret)}`, + { method: 'GET' }, + ); + const token = typeof body['access_token'] === 'string' ? body['access_token'] : ''; + if (token === '') throw new Error('企业微信 gettoken 没有返回 access_token'); + const expiresIn = typeof body['expires_in'] === 'number' ? body['expires_in'] : 7200; + tokens.set(account.id, { token, expiresAt: nowMs() + expiresIn * 1000 }); + return token; + } + + async function withToken(account: ProviderAccount, api: string, path: string, payload?: unknown): Promise { + const settings = wecomSettingsSchema.parse(account.settings); + const run = (token: string): Promise => + call( + api, + `${settings.apiBase}${path}?access_token=${encodeURIComponent(token)}`, + payload === undefined + ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } + : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }, + ); + try { + return await run(await tokenOf(account)); + } catch (error) { + if (error instanceof WecomApiError && (error.errcode === 40014 || error.errcode === 42001)) { + return run(await tokenOf(account, true)); + } + throw error; + } + } + + return { + kind: 'wecom', + + async probe(account): Promise { + const started = nowMs(); + try { + const alias = await withToken(account, 'get_email_alias', '/cgi-bin/exmail/app/get_email_alias'); + const address = typeof alias['email'] === 'string' ? alias['email'] : ''; + return { + kind: 'wecom', + address, + receive: { ok: true, latencyMs: nowMs() - started }, + send: { checked: false, note: '凭证有效;发信权限只能在真发时验证(compose_send 没有只验不发的口)' }, + }; + } catch (error) { + return { kind: 'wecom', receive: { ok: false, error: errorText(error) }, send: { checked: false, note: '收信凭证未通过,发信未验' } }; + } + }, + + async anchor(_account, backfillFrom, now): Promise { + return wecomCursor(Math.floor((backfillFrom ?? now).getTime() / 1000)); + }, + + async pull(account, cursor, opts) { + const { beginTime, endTime } = wecomWindow(cursor, opts.backfillFrom, opts.now); + /* + * **先滤库里已有的,再数够不够** —— 顺序反了会卡死:一个窗口里若前 limit 条恰好都已入库, + * 这一轮就交出 0 封,而引擎见空批即收工、游标又没动,下一轮还是这 0 封,永远吃不到后面的新信。 + * 滤在前,每一轮都实实在在往前吃 limit 封**新**的。 + */ + const fresh: string[] = []; + let pageCursor: string | undefined; + let exhausted = false; + while (fresh.length < opts.limit) { + const page = await withToken(account, 'get_mail_list', '/cgi-bin/exmail/app/get_mail_list', { + begin_time: beginTime, + end_time: endTime, + ...(pageCursor === undefined ? {} : { cursor: pageCursor }), + limit: LIST_PAGE, + }); + const list = Array.isArray(page['mail_list']) ? (page['mail_list'] as Array<{ mail_id?: unknown }>) : []; + const ids = list.flatMap((item) => (typeof item.mail_id === 'string' ? [item.mail_id] : [])); + const known = await opts.known(ids); + for (const id of ids) if (!known.has(id)) fresh.push(id); + const hasMore = page['has_more'] === 1 || page['has_more'] === true; + pageCursor = typeof page['next_cursor'] === 'string' && page['next_cursor'] !== '' ? page['next_cursor'] : undefined; + if (!hasMore || list.length === 0 || pageCursor === undefined) { + exhausted = true; + break; + } + } + const wanted = fresh.slice(0, opts.limit); + const messages: RawMessage[] = []; + for (const mailId of wanted) { + const mail = await withToken(account, 'read_mail', '/cgi-bin/exmail/app/read_mail', { mail_id: mailId }); + const data = mail['mail_data']; + if (typeof data !== 'string' || data === '') continue; + messages.push({ providerRef: mailId, eml: emlBytesOf(data), receivedAt: opts.now }); + } + /* + * 游标推进与 `more` **必须由同一个判据决定**:只有「这一窗的新信全吃完了」才推到窗口上沿。 + * `exhausted` 只说明**页翻完了** —— 最后一页可能同时 `has_more:0` 且新信多过本轮预算 + * (`LIST_PAGE` 是 100,与 `opts.limit` 无关),那时 `wanted` 截掉的那些若跟着推进游标就**永久丢**了 + * (重叠窗只兜 10 分钟)。退回不会卡住:已入库的下一轮被 `known` 滤掉,每轮照旧往前吃一批。 + */ + const more = !exhausted || fresh.length > wanted.length; + return { messages, cursor: more ? cursor : wecomCursor(endTime), more }; + }, + + async send(account, message) { + await withToken(account, 'compose_send', '/cgi-bin/exmail/app/compose_send', { + to: { emails: [...message.to] }, + ...(message.cc.length === 0 ? {} : { cc: { emails: [...message.cc] } }), + ...(message.bcc.length === 0 ? {} : { bcc: { emails: [...message.bcc] } }), + subject: message.subject, + content: message.html ?? message.text ?? '', + content_type: message.html !== undefined ? 'html' : 'text', + ...(message.attachments.length === 0 + ? {} + : { attachment_list: message.attachments.map((a) => ({ file_name: a.name, content: Buffer.from(a.bytes).toString('base64') })) }), + }); + return {}; + }, + }; +} +``` + +`index.ts` 追加: + +```ts +export type { FetchLike } from './internal/adapter/wecom-provider.ts'; +export { createWecomProvider, WecomApiError } from './internal/adapter/wecom-provider.ts'; +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/modules/mail && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 全部 PASS(wecom 7 tests);门禁退出码 0。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/modules/mail +git commit -m "$(printf 'mail adapter:企业微信邮件 API provider(token 缓存与刷新 / 翻页 / 已知 mail_id 不再读 / EML 编码兜底 / compose_send)\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 14: app 装配 —— 上传件存取抽出、mail 接线(channel 订阅方 / 传输件 / 工具处理器)、轮询工作者、config / main + +**Files:** +- Create: `apps/api/src/app/upload-store.ts` · `apps/api/src/app/mail-wiring.ts` · `apps/api/src/app/workers/mail-poller.ts` +- Modify: `apps/api/src/app/container.ts`(装配 mail;`mail.send` 处理器闭包;channel 传输件与订阅方;删模块级 `firstPartyToolHandlers`)· `apps/api/src/app/config.ts`(`mailPollTickMs`)· `apps/api/src/app/main.ts`(起停轮询器)· `apps/api/src/app/routes/governance-ops.ts`(上传两段改调 `upload-store.ts`)· `apps/api/src/app/tools/first-party.ts`(删 `mailSend`) +- Test: `apps/api/src/app/__tests__/mail-wiring.test.ts` · `apps/api/src/app/__tests__/mail-poller.test.ts` + +**Interfaces:** +- Produces:`storeUpload(db, {name, contentType, bytes, uploadedBy, note?})` → `StoredUpload`;`readUpload(db, ref)`;`toInboundMail(detail)` → `InboundMail`;`channelSubscriber(channel)` → `InboundHandler`;`channelTransport(mail)` → `MailTransport`;`mailSendTool(mail)` → `ToolHandler`;`pollMailOnce(deps)`;`startMailPoller(deps, tickMs)`;`Container.mail: MailService`;`AppConfig.mailPollTickMs`。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/app/__tests__/mail-wiring.test.ts +/** mail ↔ channel / toolbox 的接线:三段纯映射各自成立;dryRun 不发;失败翻成工具错误 */ +import { describe, expect, it } from 'vitest'; +import type { MailInboundDetail, MailSendInput, MailSendResult } from '@contracts'; +import type { InboundMail } from '@contracts'; +import { channelSubscriber, channelTransport, mailSendTool, toInboundMail } from '../mail-wiring.ts'; + +const detail: MailInboundDetail = { + id: 'in-1', + accountId: 'acc-1', + messageId: '', + from: { address: 'buyer@acme.com', name: '张三' }, + to: [{ address: 'sales@we.com', name: '' }], + cc: [{ address: 'ops@we.com', name: 'Ops' }], + subject: '询价 RFQ-1', + sentAt: '2026-08-25T09:00:00.000Z', + receivedAt: '2026-08-25T09:30:00.000Z', + textPreview: '你好', + hasHtml: true, + attachments: [ + { index: 0, name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1', note: '' }, + { index: 1, name: 'big.zip', contentType: 'application/zip', size: 9_000_000, ref: null, note: '超限' }, + ], + deliveries: [], + inReplyTo: null, + references: ['', ''], + text: null, + html: '

你好

', + size: 100, + rawAvailable: true, +}; + +function fakeSend(result: Partial = {}): { send: (input: MailSendInput) => Promise; calls: MailSendInput[] } { + const calls: MailSendInput[] = []; + return { + calls, + send: async (input) => { + calls.push(input); + return { outboundId: 'out-1', accountId: 'acc-1', messageId: '', state: 'sent', ...result }; + }, + }; +} + +describe('toInboundMail', () => { + it('线程取 References **首项**(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => { + const mail: InboundMail = toInboundMail(detail); + // 根 = 我方出站登记的那个 threadId —— 取最近一跳会让①级归属跑一个来回就漂 + expect(mail.threadId).toBe(''); + expect(mail.from).toBe('buyer@acme.com'); + expect(mail.to).toEqual(['sales@we.com']); + expect(mail.cc).toEqual(['ops@we.com']); + expect(mail.body).toBe('你好'); + expect(mail.attachments).toEqual([{ name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', size: 10, ref: 'up-1' }]); + expect(mail.receivedAt).toBe('2026-08-25T09:30:00.000Z'); + }); +}); + +describe('channelSubscriber', () => { + it('账号没绑通道 → skipped;绑了就 ingestMail(通道 id + 归一后的信)', async () => { + const calls: Array<[string, InboundMail]> = []; + const handler = channelSubscriber({ + ingestMail: async (id, mail) => { + calls.push([id, mail]); + return undefined; + }, + }); + expect(await handler(detail, { accountId: 'acc-1', channelId: undefined })).toEqual({ skipped: '账号未绑定通道' }); + expect(await handler(detail, { accountId: 'acc-1', channelId: 'ch-1' })).toBeUndefined(); + expect(calls[0]?.[0]).toBe('ch-1'); + expect(calls[0]?.[1].messageId).toBe(''); + }); +}); + +describe('channelTransport', () => { + it('按 from 找账号、threadId 进 inReplyTo / references、附件 ref 变 uploadRef;回 messageId 与线程', async () => { + const mail = fakeSend(); + const sent = await channelTransport(mail).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: '报价', body: '正文', threadId: '', attachments: [{ name: 'q.pdf', ref: 'up-9' }] }); + expect(sent).toEqual({ messageId: '', threadId: '' }); + expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '', references: [''], text: '正文', attachments: [{ name: 'q.pdf', uploadRef: 'up-9' }], requestedBy: { kind: 'channel' } }); + const fresh = await channelTransport(fakeSend()).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] }); + expect(fresh.threadId).toBe(''); + }); + it('厂商失败抛出,让 channel.sendMail 的调用方看到', async () => { + await expect(channelTransport(fakeSend({ state: 'failed', error: '535 auth failed' })).send({ from: 'sales@we.com', to: ['b@acme.com'], cc: [], subject: 's', body: 'b', attachments: [] })).rejects.toThrow('535 auth failed'); + }); +}); + +describe('mailSendTool', () => { + const base = { connection: { account: 'default' }, operation: undefined, idempotencyKey: 'k-1' }; + it('数组 / 逗号串都认作收件人;body → text;幂等键与连接配置的账号带过去;出参保留 queued', async () => { + const mail = fakeSend(); + const result = await mailSendTool(mail)({ ...base, connection: { account: 'acc-2' }, input: { to: 'a@x.com; b@x.com', subject: 's', body: '正文', inReplyTo: '', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }, { name: 'no-ref' }] } }); + expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '', state: 'sent' } }); + expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '', references: [''], idempotencyKey: 'k-1', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }], requestedBy: { kind: 'tool', tool: 'mail.send' } }); + await mailSendTool(mail)({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); + expect(mail.calls[1]?.accountId).toBeUndefined(); + }); + it('dryRun 只校验不发', async () => { + const mail = fakeSend(); + expect(await mailSendTool(mail)({ ...base, input: { to: ['x@y.com'], subject: '询价', body: 'b', dryRun: true } })).toEqual({ ok: true, output: { queued: false, dryRun: true } }); + expect((await mailSendTool(mail)({ ...base, input: { to: [], subject: '', dryRun: true } })).ok).toBe(false); + expect(mail.calls).toHaveLength(0); + }); + it('厂商失败 → send-failed 可重试;没账号 → no-account 不重试;入参问题 → invalid', async () => { + const failed = await mailSendTool(fakeSend({ state: 'failed', error: '535' }))({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); + expect(failed).toEqual({ ok: false, error: { code: 'send-failed', retryable: true, message: '535' } }); + const { MailNoDefaultAccountError, MailSendInvalidError } = await import('@modules/mail'); + const noAccount = await mailSendTool({ send: async () => { throw new MailNoDefaultAccountError(); } })({ ...base, input: { to: ['a@x.com'], subject: 's', body: 'b' } }); + expect(noAccount.error?.code).toBe('no-account'); + const invalid = await mailSendTool({ send: async () => { throw new MailSendInvalidError(['正文 text / html 至少一个']); } })({ ...base, input: { to: ['a@x.com'], subject: 's' } }); + expect(invalid.error?.code).toBe('invalid'); + }); +}); +``` + +```ts +// apps/api/src/app/__tests__/mail-poller.test.ts +/** 轮询工作者:每 tick 调 pollDue;上一 tick 没跑完就跳过这一拍;出错的账号写 stderr;stop 后不再跑 */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MailPollOutcome } from '@contracts'; +import { pollMailOnce, startMailPoller } from '../workers/mail-poller.ts'; + +function fakeMail(outcomes: MailPollOutcome[], holdMs = 0) { + const calls: Date[] = []; + return { + calls, + mail: { + pollDue: async (now: Date) => { + calls.push(now); + if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs)); + return outcomes; + }, + }, + }; +} + +describe('pollMailOnce', () => { + it('回用例的结果;出错的账号写一行 stderr', async () => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const { mail } = fakeMail([{ accountId: 'a', pulled: 1, stored: 1, dispatched: 1, more: false }, { accountId: 'b', pulled: 0, stored: 0, dispatched: 0, more: false, error: '连不上' }]); + const out = await pollMailOnce({ container: { mail: mail as never } }); + expect(out).toHaveLength(2); + expect(write).toHaveBeenCalledWith(expect.stringContaining('[mail-poller] b:连不上')); + write.mockRestore(); + }); +}); + +describe('startMailPoller', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('按 tick 调;在跑时跳过;stop 后停', async () => { + const { mail, calls } = fakeMail([], 1000); + const poller = startMailPoller({ container: { mail: mail as never } }, 100); + await vi.advanceTimersByTimeAsync(350); + expect(calls).toHaveLength(1); // 第一拍 1000ms 没跑完,后面两拍跳过 + await vi.advanceTimersByTimeAsync(1000); + expect(calls.length).toBeGreaterThanOrEqual(2); + poller.stop(); + const before = calls.length; + await vi.advanceTimersByTimeAsync(2000); + expect(calls).toHaveLength(before); + }); +}); +``` + +注:两处 `mail as never` 只是把测试桩缩到 `pollDue` 一个方法 —— `Container['mail']` 是整个 `MailService`,桩不必长成它;`as never` 不在 §12.5 的禁止清单里(禁的是 `as unknown as`)。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts` +Expected: FAIL(文件不存在)。 + +- [ ] **Step 3: 写 upload-store / mail-wiring / mail-poller** + +```ts +// apps/api/src/app/upload-store.ts +/** + * sys.upload 的存取 —— 上传路由与 mail 的附件注入件同用这一段(设计稿 §8)。 + * 表是 0024 手写的 SQL,不在 Drizzle 里;照旧走 sql``。 + */ +import { sql } from 'drizzle-orm'; +import type { Db } from '@shared/db.ts'; +import { extractText } from './upload-extract.ts'; + +/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */ +export const UPLOAD_CAP = 8 * 1024 * 1024; + +function rowsOf(result: unknown): T[] { + const bag = result as { rows?: T[] } | T[]; + return Array.isArray(bag) ? bag : (bag.rows ?? []); +} + +export type StoredUpload = { ref: string; name: string; contentType: string; size: number; text?: string; note: string }; + +/** 存字节 + 读出来的文本;`note` 给了就拼在抽取说明前面(邮件附件写「邮件附件:主题」) */ +export async function storeUpload( + db: Db, + file: { name: string; contentType: string; bytes: Uint8Array; uploadedBy: string | null; note?: string | undefined }, +): Promise { + const got = extractText(file.name, file.contentType, file.bytes); + const ref = crypto.randomUUID(); + const note = file.note === undefined || file.note === '' ? got.note : `${file.note};${got.note}`; + await db.execute(sql` + insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by) + values (${ref}, ${file.name}, ${file.contentType}, ${file.bytes.length}, ${Buffer.from(file.bytes)}, + ${got.text ?? null}, ${note}, ${file.uploadedBy})`); + return { ref, name: file.name, contentType: file.contentType, size: file.bytes.length, ...(got.text === undefined ? {} : { text: got.text }), note }; +} + +export async function readUpload(db: Db, ref: string): Promise<{ name: string; contentType: string; bytes: Uint8Array } | undefined> { + const rows = await db.execute<{ name: string; content_type: string; bytes: Buffer }>( + sql`select name, content_type, bytes from sys.upload where id = ${ref}`, + ); + const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0]; + if (hit === undefined) return undefined; + // pg 的 Buffer 挂在共享内存池上,按元素拷(2026-08-23 的前科) + return { name: hit.name, contentType: hit.content_type, bytes: Uint8Array.from(hit.bytes) }; +} +``` + +`apps/api/src/app/routes/governance-ops.ts` 里上传两段改为(其余不动;`UPLOAD_CAP` 常量与 `extractText` import 删掉,改 import `storeUpload, readUpload, UPLOAD_CAP` from `'../upload-store.ts'`): + +```ts + app.post(uploadFileRoute.path, async (c) => { + const parsed = uploadFileRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues }; + return c.json(body, 400); + } + const { name, contentType, dataBase64 } = parsed.data; + const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64')); + if (bytes.length === 0) { + const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` }; + return c.json(body, 400); + } + if (bytes.length > UPLOAD_CAP) { + const body: ApiError = { + code: 'validation-failed', + message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`, + }; + return c.json(body, 400); + } + // 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note + return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null })); + }); + + app.get(downloadFileRoute.path, async (c) => { + const hit = await readUpload(container.db, c.req.param('id')); + if (hit === undefined) { + const body: ApiError = { code: 'not-found', message: '这个文件不在了' }; + return c.json(body, 404); + } + return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') }); + }); +``` + +```ts +// apps/api/src/app/mail-wiring.ts +/** + * mail 与 channel / toolbox 的接线(app 层;设计稿 §8;ADR-013): + * ① channel 订阅方:入站 → channel.ingestMail(账号绑的通道)—— 活动经现有事件链收到信 + * ② channel 的 MailTransport:出站 → mail.send(按 from 找账号,对不上不回落默认) + * ③ `mail.send` 工具处理器:活动侧出站,放行时经 ToolInvoker 执行;dryRun 只校验不发(注册试运行会真调处理器) + * 三段都是纯映射 + 一次调用,单测直接对函数。 + */ +import type { InboundMail, MailSendInput } from '@contracts'; +import type { MailTransport } from '@modules/channel'; +import type { InboundHandler, MailInboundDetail, MailService } from '@modules/mail'; +import { + bodyTextOf, + MailAccountDisabledError, + MailAccountNotFoundError, + MailNoDefaultAccountError, + MailSecretMissingError, + MailSendInvalidError, +} from '@modules/mail'; +import type { ToolHandler } from '@modules/toolbox'; + +/** + * mail 的入站详情 → channel 的 InboundMail:只带存下来的附件。 + * + * **线程取的是根,不是最近一跳**:我方出站登记的 threadId 恒是这一串的**首封** + * (`channelTransport` 回的是 `input.threadId ?? messageId`,channel 据此 `recordOutbound`), + * 而归属解析①级是精确等值比对。若这里取 `inReplyTo`(最近一跳),跑一个来回就漂: + * 我方 M1 → 客户回 M1(命中)→ 我方再回(实际 M2、仍记在 M1 名下)→ 客户回 M2 → + * 取到 M2,而库里从没记过 M2 ⇒ ①级落空,退到③地址+时间窗。 + * `references[0]` 才稳定:客户端续链时首项恒是首封;客户端只给 In-Reply-To 时退回它。 + */ +export function toInboundMail(mail: MailInboundDetail): InboundMail { + const threadId = mail.references[0] ?? mail.inReplyTo ?? undefined; + return { + messageId: mail.messageId, + ...(threadId === undefined ? {} : { threadId }), + from: mail.from.address, + to: mail.to.map((a) => a.address), + cc: mail.cc.map((a) => a.address), + subject: mail.subject, + body: bodyTextOf({ ...(mail.text === null ? {} : { text: mail.text }), ...(mail.html === null ? {} : { html: mail.html }) }), + receivedAt: mail.receivedAt, + attachments: mail.attachments.flatMap((a) => + a.ref === null ? [] : [{ name: a.name, contentType: a.contentType, size: a.size, ref: a.ref }], + ), + }; +} + +/** 只要 `ingestMail` 这一个口,出参不看(InboundOutcome 由 channel 自己留痕) */ +export function channelSubscriber(channel: { ingestMail(channelId: string, mail: InboundMail): Promise }): InboundHandler { + return async (mail, ctx) => { + if (ctx.channelId === undefined) return { skipped: '账号未绑定通道' }; + await channel.ingestMail(ctx.channelId, toInboundMail(mail)); + }; +} + +/** channel 的出站面:厂商失败抛出 —— channel.sendMail 的调用方(放行落地)要看到失败 */ +export function channelTransport(mail: Pick): MailTransport { + return { + async send(input) { + const result = await mail.send({ + from: input.from, + to: [...input.to], + cc: [...input.cc], + bcc: [], + subject: input.subject, + text: input.body, + ...(input.threadId === undefined ? {} : { inReplyTo: input.threadId }), + references: input.threadId === undefined ? [] : [input.threadId], + attachments: input.attachments.map((a) => ({ name: a.name, contentType: 'application/octet-stream', uploadRef: a.ref })), + requestedBy: { kind: 'channel' }, + }); + if (result.state === 'failed') throw new Error(`发信失败:${result.error ?? '未知'}`); + return { messageId: result.messageId, threadId: input.threadId ?? result.messageId }; + }, + }; +} + +function recipientsOf(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string').map((v) => v.trim()).filter((v) => v !== ''); + if (typeof value === 'string') return value.split(/[,;]/).map((s) => s.trim()).filter((s) => s !== ''); + return []; +} + +function attachmentsOf(value: unknown): MailSendInput['attachments'] { + if (!Array.isArray(value)) return []; + return value.flatMap((v) => { + if (typeof v !== 'object' || v === null) return []; + const a = v as Record; + const uploadRef = typeof a['uploadRef'] === 'string' && a['uploadRef'] !== '' ? a['uploadRef'] : undefined; + // 没给 uploadRef 的附件**不静默丢掉**:活动说「带上 q.pdf」,信发了附件没了还回 queued:true,是界面撒谎 + if (uploadRef === undefined) throw new MailSendInvalidError([`附件 ${String(a['name'] ?? '(无名)')} 没给 uploadRef`]); + const name = typeof a['name'] === 'string' && a['name'] !== '' ? a['name'] : uploadRef; + return [{ name, contentType: 'application/octet-stream', uploadRef }]; + }); +} + +export function mailSendTool(mail: Pick): ToolHandler { + return async ({ input, connection, idempotencyKey }) => { + const to = recipientsOf(input['to']); + const cc = recipientsOf(input['cc']); + const bcc = recipientsOf(input['bcc']); + const subject = typeof input['subject'] === 'string' ? input['subject'] : ''; + const body = typeof input['body'] === 'string' && input['body'] !== '' ? input['body'] : undefined; + const html = typeof input['html'] === 'string' && input['html'] !== '' ? input['html'] : undefined; + const inReplyTo = typeof input['inReplyTo'] === 'string' && input['inReplyTo'] !== '' ? input['inReplyTo'] : undefined; + // 账号:入参指定 > 连接配置('default' = 默认账号) + const configured = connection['account']; + const accountId = + typeof input['accountId'] === 'string' && input['accountId'] !== '' + ? input['accountId'] + : configured !== undefined && configured !== '' && configured !== 'default' + ? configured + : undefined; + + if (input['dryRun'] === true) { + const problems = [ + ...(to.length === 0 ? ['收件人至少一个'] : []), + ...(subject === '' ? ['主题不能为空'] : []), + ...(body === undefined && html === undefined ? ['正文 body / html 至少一个'] : []), + ]; + return problems.length === 0 + ? { ok: true, output: { queued: false, dryRun: true } } + : { ok: false, error: { code: 'invalid', retryable: false, message: problems.join('; ') } }; + } + + try { + const result = await mail.send({ + ...(accountId === undefined ? {} : { accountId }), + to, + cc, + bcc, + subject, + ...(body === undefined ? {} : { text: body }), + ...(html === undefined ? {} : { html }), + ...(inReplyTo === undefined ? {} : { inReplyTo }), + references: inReplyTo === undefined ? [] : [inReplyTo], + attachments: attachmentsOf(input['attachments']), + ...(idempotencyKey === undefined ? {} : { idempotencyKey }), + requestedBy: { kind: 'tool', tool: 'mail.send' }, + }); + if (result.state === 'failed') { + return { ok: false, error: { code: 'send-failed', retryable: true, message: result.error ?? '发信失败' } }; + } + return { ok: true, output: { queued: true, outboundId: result.outboundId, messageId: result.messageId, state: result.state } }; + } catch (error) { + if (error instanceof MailSendInvalidError) return { ok: false, error: { code: 'invalid', retryable: false, message: error.message } }; + if ( + error instanceof MailNoDefaultAccountError || + error instanceof MailAccountNotFoundError || + error instanceof MailAccountDisabledError || + error instanceof MailSecretMissingError + ) { + return { ok: false, error: { code: 'no-account', retryable: false, message: error.message } }; + } + throw error; + } + }; +} +``` + +```ts +// apps/api/src/app/workers/mail-poller.ts +/** + * 邮件轮询工作者 —— 每 tick 挑到期账号各拉一轮(设计稿 §4);跨进程租约在 store 里, + * 两个后端连同一个库时一个信箱同一时刻只有一个在拉。 + * 与 startDispatcher / startTaskAdvancer 同形;上一 tick 没跑完(账号多、信多)就跳过这一拍,不叠着跑。 + */ +import type { MailPollOutcome } from '@modules/mail'; +import type { Container } from '../container.ts'; + +export type MailPollerDeps = { readonly container: Pick; readonly now?: () => Date }; + +export async function pollMailOnce(deps: MailPollerDeps): Promise { + const outcomes = await deps.container.mail.pollDue((deps.now ?? (() => new Date()))()); + for (const outcome of outcomes) { + if (outcome.error !== undefined) process.stderr.write(`[mail-poller] ${outcome.accountId}:${outcome.error}\n`); + } + return outcomes; +} + +export function startMailPoller(deps: MailPollerDeps, tickMs: number): { stop(): void } { + let stopped = false; + let running = false; + const tick = async (): Promise => { + if (stopped || running) return; + running = true; + try { + await pollMailOnce(deps); + } catch (error) { + process.stderr.write(`[mail-poller] ${error instanceof Error ? error.message : String(error)}\n`); + } finally { + running = false; + } + }; + const timer = setInterval(() => void tick(), tickMs); + timer.unref(); + return { + stop() { + stopped = true; + clearInterval(timer); + }, + }; +} +``` + +- [ ] **Step 4: 改 container / config / main / first-party** + +`apps/api/src/app/config.ts`:`AppConfig` 加 `/** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */ readonly mailPollTickMs: number;`,`loadConfig` 加 `mailPollTickMs: Number(env['JCAI_MAIL_POLL_TICK_MS'] ?? 5000),`。 + +`apps/api/src/app/tools/first-party.ts`:删掉 `const mailSend: ToolHandler = …` 整段(含注释)与 `FIRST_PARTY_TOOLS` 里的 `['mail.send', mailSend],` 一行。 + +`apps/api/src/app/container.ts`: + +1. import:删 `createSimulatedMailTransport`(保留 channel 其余三个);加 + ```ts + import type { MailService } from '@modules/mail'; + import { + createImapSmtpProvider, + createInboundStore, + createMailAccountStore, + createMailService, + createOutboundStore, + createPostalMimeParser, + createWecomProvider, + } from '@modules/mail'; + import { channelSubscriber, channelTransport, mailSendTool } from './mail-wiring.ts'; + import { readUpload, storeUpload } from './upload-store.ts'; + ``` +2. `Container` 类型加 `/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */ readonly mail: MailService;`(放在 `channel` 之后)。 +3. 删掉模块级 `export const firstPartyToolHandlers = …`(仓内无其它引用)。 +4. 在 `const toolRegistry = createToolRegistry(db);` 之前加: + ```ts + /* + * 邮件收发(ADR-013)—— L1,只要库 / 密封 / 时钟;先于工具运行时装配,`mail.send` 的处理器要闭包持它。 + * 变更经 ChangeNotifier 接到广播:轮询器不走 /api/gov/* 中间件,新来信 / 拉取出错得自己说一声。 + * 附件落 sys.upload —— 与上传路由同一段存取(8MB 上限、文本抽取、下载路由一并复用)。 + */ + const mail = createMailService({ + accounts: createMailAccountStore(db), + inbound: createInboundStore(db), + outbound: createOutboundStore(db), + attachments: { + put: async (file) => ({ + ref: (await storeUpload(db, { name: file.name, contentType: file.contentType, bytes: file.bytes, uploadedBy: null, note: file.note })).ref, + }), + get: (ref) => readUpload(db, ref), + }, + providers: { 'imap-smtp': createImapSmtpProvider(), wecom: createWecomProvider() }, + mime: createPostalMimeParser(), + secrets, + clock: systemClock, + ids, + notify: { changed: (reason) => broadcaster.publish({ kind: 'system', id: 'mail', reason }) }, + }); + ``` +5. `const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);` 改为 + ```ts + /** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */ + const toolRuntime = createToolRuntime( + new Map([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]), + httpToolHandler, + ); + ``` +6. channel 装配里 `mail: createSimulatedMailTransport(db, ids),` 改为 `mail: channelTransport(mail),`;紧跟 `const channel = createChannelService({...});` 之后加: + ```ts + // channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面 + mail.subscribe('channel', channelSubscriber(channel)); + ``` +7. `return {...}` 里 `channel,` 之后加 `mail,`。 + +`apps/api/src/app/main.ts`:`import { startMailPoller } from './workers/mail-poller.ts';`;在 `const advancer = …` 之后加 +```ts + // 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱 + const mailPoller = startMailPoller({ container }, config.mailPollTickMs); +``` +`shutdown` 里 `advancer.stop();` 之后加 `mailPoller.stop();`。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `pnpm vitest run apps/api/src/app/__tests__/mail-wiring.test.ts apps/api/src/app/__tests__/mail-poller.test.ts && pnpm typecheck && pnpm lint && pnpm depcruise` +Expected: 8 + 2 tests PASS;三道门禁退出码 0(`app-not-into-internal`:app 只 import `@modules/mail` 的出口;`no-orphans`:`upload-store.ts` / `mail-wiring.ts` 被 container 引用)。 + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/app +git commit -m "$(printf 'app 装配 mail:channel 作首个订阅方、出站经 mail.send、mail.send 工具真发信(dryRun 只校验)、轮询工作者;上传件存取抽出共用\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 15: 路由 `/api/gov/mail/*` + +**Files:** +- Create: `apps/api/src/app/routes/mail.ts` +- Modify: `apps/api/src/app/server.ts`(注册) +- Test: `apps/api/src/app/__tests__/mail-routes.test.ts`(Hono `app.request`,真用例 + 内存 store,不起 PG) + +**Interfaces:** +- Produces:`registerMailRoutes(app: Hono, deps: { mail: MailService }): void`;`mailErrorResponse(error)`。 + +- [ ] **Step 1: 写失败测试** + +```ts +// apps/api/src/app/__tests__/mail-routes.test.ts +/** + * 邮件路由:zod 校验 → 用例 → 领域错误翻状态码;厂商失败在 200 的 body 里如实说。 + * 不挂鉴权中间件(归属由路由覆盖测试与注册表测试兜);用例是真的,store 是内存的,provider 是假的。 + */ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vitest'; +import { createMailService, createPostalMimeParser } from '@modules/mail'; +import type { AppEnv } from '../http-env.ts'; +import { registerMailRoutes } from '../routes/mail.ts'; +import { account, eml, fakeProvider, fixedClock, memoryAccounts, memoryAttachments, memoryInbound, memoryOutbound, plainSecrets, rawMessage, seqIds } from '../../modules/mail/__tests__/fakes.ts'; + +function harness() { + const clock = fixedClock(); + const accounts = memoryAccounts(clock); + const imap = fakeProvider('imap-smtp'); + const mail = createMailService({ + accounts, + inbound: memoryInbound(), + outbound: memoryOutbound(), + attachments: memoryAttachments(), + providers: { 'imap-smtp': imap, wecom: fakeProvider('wecom') }, + mime: createPostalMimeParser(), + secrets: plainSecrets, + clock, + ids: seqIds('x'), + notify: { changed: () => undefined }, + }); + const app = new Hono(); + registerMailRoutes(app, { mail }); + const json = (method: string, path: string, body?: unknown) => + app.request(path, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + return { app, mail, accounts, imap, json }; +} +const input = { kind: 'imap-smtp', name: '销售', address: 'sales@we.com', isDefault: true, secret: { password: 'pw-1234' } }; + +describe('账号路由', () => { + it('列表 / 建 / 取 / 改 / 删;400 / 404 / 409', async () => { + const h = harness(); + expect(await (await h.json('GET', '/api/gov/mail/accounts')).json()).toEqual({ accounts: [] }); + expect((await h.json('POST', '/api/gov/mail/accounts', { kind: 'imap-smtp' })).status).toBe(400); + const created = await h.json('POST', '/api/gov/mail/accounts', input); + expect(created.status).toBe(200); + const view = (await created.json()) as { id: string; secretBound: boolean }; + expect(view.secretBound).toBe(true); + expect(JSON.stringify(view)).not.toContain('pw-1234'); + expect((await h.json('POST', '/api/gov/mail/accounts', input)).status).toBe(409); + expect((await h.json('GET', `/api/gov/mail/accounts/${view.id}`)).status).toBe(200); + expect((await h.json('GET', '/api/gov/mail/accounts/nope')).status).toBe(404); + expect((await h.json('PUT', `/api/gov/mail/accounts/${view.id}`, { ...input, displayName: '销售部' })).status).toBe(200); + expect(await (await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).json()).toEqual({ ok: true }); + expect((await h.json('DELETE', `/api/gov/mail/accounts/${view.id}`)).status).toBe(404); + }); + + it('探测:草稿没密码 412;已存账号 200 且厂商失败在 body 里', async () => { + const h = harness(); + expect((await h.json('POST', '/api/gov/mail/accounts/probe', { ...input, secret: undefined })).status).toBe(412); + const view = (await (await h.json('POST', '/api/gov/mail/accounts', input)).json()) as { id: string }; + expect((await h.json('POST', '/api/gov/mail/accounts/probe?accountId=' + view.id, { ...input, secret: undefined })).status).toBe(200); + h.imap.probeWith({ kind: 'imap-smtp', receive: { ok: false, error: '535 pw-1234 bad' }, send: { checked: true, ok: true } }); + const res = await h.json('POST', `/api/gov/mail/accounts/${view.id}/probe`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ receive: { ok: false, error: '535 *** bad' } }); + }); + + it('立即拉取:200 outcome;停用 412;租约被占 409', async () => { + const h = harness(); + await h.accounts.save(account()); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '' }))); + expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).json()).toMatchObject({ pulled: 1, stored: 1 }); + await h.accounts.save(account({ enabled: false })); + expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(412); + await h.accounts.save(account({ pollingUntil: new Date('2027-01-01T00:00:00Z') })); + expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/poll')).status).toBe(409); + }); + + it('试发:成功与厂商失败都是 200,state 说话;入参错 400', async () => { + const h = harness(); + await h.accounts.save(account()); + const ok = await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' }); + expect(await ok.json()).toMatchObject({ state: 'sent', messageId: '' }); + h.imap.failSendWith(new Error('535 nope')); + expect(await (await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'me@we.com', subject: '试发', text: '正文' })).json()).toMatchObject({ state: 'failed', error: '535 nope' }); + expect((await h.json('POST', '/api/gov/mail/accounts/acc-1/send-test', { to: 'not-mail', subject: '', text: '' })).status).toBe(400); + }); +}); + +describe('入站 / 出站路由', () => { + it('列表(limit)/ 详情 / 附件 / 原件 / 重新投递;404 各就各位', async () => { + const h = harness(); + await h.accounts.save(account()); + h.mail.subscribe('spy', async () => undefined); + h.imap.queue(rawMessage('uid:v1:1', eml({ messageId: '', attachment: { name: 'a.txt', contentType: 'text/plain', content: 'hi' } }))); + await h.json('POST', '/api/gov/mail/accounts/acc-1/poll'); + const list = (await (await h.json('GET', '/api/gov/mail/inbound?accountId=acc-1&limit=10')).json()) as { mails: Array<{ id: string; deliveries: unknown[] }> }; + expect(list.mails).toHaveLength(1); + expect(list.mails[0]?.deliveries).toHaveLength(1); + const id = list.mails[0]!.id; + expect((await h.json('GET', `/api/gov/mail/inbound/${id}`)).status).toBe(200); + expect((await h.json('GET', '/api/gov/mail/inbound/nope')).status).toBe(404); + const att = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/0`)).json()) as { name: string; dataBase64: string }; + expect(att.name).toBe('a.txt'); + expect(Buffer.from(att.dataBase64, 'base64').toString()).toBe('hi'); + expect((await h.json('GET', `/api/gov/mail/inbound/${id}/attachments/7`)).status).toBe(404); + const raw = (await (await h.json('GET', `/api/gov/mail/inbound/${id}/raw`)).json()) as { contentType: string }; + expect(raw.contentType).toBe('message/rfc822'); + expect(await (await app_request_redeliver(h, id)).json()).toMatchObject({ deliveries: [{ subscriber: 'spy', state: 'done' }] }); + expect((await h.json('POST', `/api/gov/mail/inbound/${id}/redeliver`, { subscriber: 'nobody' })).status).toBe(404); + expect((await h.json('GET', '/api/gov/mail/inbound?limit=0')).status).toBe(400); + expect(await (await h.json('GET', '/api/gov/mail/outbound')).json()).toEqual({ mails: [] }); + }); +}); + +/** 空 body 的 POST:redeliver 的 body 是可省的 */ +function app_request_redeliver(h: ReturnType, id: string): Promise { + return h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' }); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts` +Expected: FAIL(`routes/mail.ts` 不存在)。 + +- [ ] **Step 3: 写路由并注册** + +```ts +// apps/api/src/app/routes/mail.ts +/** + * 邮件收发路由(设计稿 §7.2;ADR-013)。归属在契约的 access 上,中间件统一判; + * 这里只做 zod 校验、调用用例、把领域错误翻成状态码。 + * 探测 / 试发 / 拉取的**厂商失败在 200 的 body 里如实说** —— 不是接口错误。 + */ +import type { Context, Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { + createMailAccountRoute, + deleteMailAccountRoute, + getMailAccountRoute, + getMailInboundRoute, + listMailAccountsRoute, + listMailInboundRoute, + listMailOutboundRoute, + mailAttachmentRoute, + mailRawRoute, + pollMailAccountRoute, + probeMailAccountRoute, + probeMailDraftRoute, + redeliverMailInboundRoute, + sendTestMailRoute, + updateMailAccountRoute, +} from '@contracts'; +import type { MailService } from '@modules/mail'; +import { + MailAccountDisabledError, + MailAccountNameTakenError, + MailAccountNotFoundError, + MailAttachmentNotFoundError, + MailInboundNotFoundError, + MailNoDefaultAccountError, + MailPollBusyError, + MailSecretMissingError, + MailSendInvalidError, + MailSubscriberUnknownError, +} from '@modules/mail'; +import type { AppEnv } from '../http-env.ts'; +import { staffOf } from '../http-env.ts'; + +export type MailRouteDeps = { readonly mail: MailService }; + +/** 领域错误 → 状态码;认不出的交回 onError(500) */ +export function mailErrorResponse(error: unknown): { status: 400 | 404 | 409 | 412; body: ApiError } | undefined { + if ( + error instanceof MailAccountNotFoundError || + error instanceof MailInboundNotFoundError || + error instanceof MailAttachmentNotFoundError || + error instanceof MailSubscriberUnknownError + ) { + return { status: 404, body: { code: 'not-found', message: error.message } }; + } + if (error instanceof MailAccountNameTakenError || error instanceof MailPollBusyError) { + return { status: 409, body: { code: 'conflict', message: error.message } }; + } + if (error instanceof MailAccountDisabledError || error instanceof MailNoDefaultAccountError || error instanceof MailSecretMissingError) { + return { status: 412, body: { code: 'precondition-failed', message: error.message } }; + } + if (error instanceof MailSendInvalidError) { + return { status: 400, body: { code: 'validation-failed', message: error.message, detail: error.problems } }; + } + return undefined; +} + +function invalid(c: Context, message: string, detail: unknown): Response { + const body: ApiError = { code: 'validation-failed', message, detail }; + return c.json(body, 400); +} + +async function jsonBody(c: Context): Promise { + const text = await c.req.text(); + if (text.trim() === '') return {}; + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +export function registerMailRoutes(app: Hono, deps: MailRouteDeps): void { + const { mail } = deps; + + const guarded = async (c: Context, run: () => Promise): Promise => { + try { + return await run(); + } catch (error) { + const mapped = mailErrorResponse(error); + if (mapped === undefined) throw error; + return c.json(mapped.body, mapped.status); + } + }; + + /* ── 账号 ── */ + + app.get(listMailAccountsRoute.path, async (c) => c.json({ accounts: await mail.listAccounts() })); + + app.post(createMailAccountRoute.path, async (c) => { + const parsed = createMailAccountRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); + return guarded(c, async () => c.json(await mail.createAccount(parsed.data))); + }); + + app.post(probeMailDraftRoute.path, async (c) => { + const parsed = probeMailDraftRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); + const accountId = c.req.query('accountId'); + return guarded(c, async () => + c.json(await mail.probe({ input: parsed.data, ...(accountId === undefined || accountId === '' ? {} : { accountId }) })), + ); + }); + + app.get(getMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.getAccount(c.req.param('accountId'))))); + + app.put(updateMailAccountRoute.path, async (c) => { + const parsed = updateMailAccountRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '账号入参不合法', parsed.error.issues); + return guarded(c, async () => c.json(await mail.updateAccount(c.req.param('accountId'), parsed.data))); + }); + + app.delete(deleteMailAccountRoute.path, async (c) => + guarded(c, async () => { + await mail.deleteAccount(c.req.param('accountId')); + return c.json({ ok: true as const }); + }), + ); + + app.post(probeMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.probe({ accountId: c.req.param('accountId') })))); + + app.post(pollMailAccountRoute.path, async (c) => guarded(c, async () => c.json(await mail.pollNow(c.req.param('accountId'))))); + + app.post(sendTestMailRoute.path, async (c) => { + const parsed = sendTestMailRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '试发入参不合法', parsed.error.issues); + const me = staffOf(c); + return guarded(c, async () => + c.json( + await mail.send({ + accountId: c.req.param('accountId'), + to: [parsed.data.to], + cc: [], + bcc: [], + subject: parsed.data.subject, + text: parsed.data.text, + references: [], + attachments: [], + requestedBy: { kind: 'ops', ...(me === undefined ? {} : { staffId: me.id }) }, + }), + ), + ); + }); + + /* ── 入站 ── */ + + app.get(listMailInboundRoute.path, async (c) => { + const query = listMailInboundRoute.query.safeParse(c.req.query()); + if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); + return c.json({ mails: await mail.listInbound({ accountId: query.data.accountId, limit: query.data.limit }) }); + }); + + app.get(getMailInboundRoute.path, async (c) => guarded(c, async () => c.json(await mail.getInbound(c.req.param('mailId'))))); + + app.get(mailAttachmentRoute.path, async (c) => { + const params = mailAttachmentRoute.params.safeParse({ mailId: c.req.param('mailId'), index: c.req.param('index') }); + if (!params.success) return invalid(c, '附件序号不合法', params.error.issues); + return guarded(c, async () => { + const file = await mail.attachment(params.data.mailId, params.data.index); + return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') }); + }); + }); + + app.get(mailRawRoute.path, async (c) => + guarded(c, async () => { + const file = await mail.raw(c.req.param('mailId')); + return c.json({ name: file.name, contentType: 'message/rfc822' as const, dataBase64: Buffer.from(file.bytes).toString('base64') }); + }), + ); + + app.post(redeliverMailInboundRoute.path, async (c) => { + const parsed = redeliverMailInboundRoute.body.safeParse(await jsonBody(c)); + if (!parsed.success) return invalid(c, '入参不合法', parsed.error.issues); + return guarded(c, async () => c.json({ deliveries: await mail.redeliver(c.req.param('mailId'), parsed.data.subscriber) })); + }); + + /* ── 出站 ── */ + + app.get(listMailOutboundRoute.path, async (c) => { + const query = listMailOutboundRoute.query.safeParse(c.req.query()); + if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); + return c.json({ mails: await mail.listOutbound({ accountId: query.data.accountId, limit: query.data.limit }) }); + }); +} +``` + +`apps/api/src/app/server.ts`:`import { registerMailRoutes } from './routes/mail.ts';`,在 `registerGovernanceOpsRoutes(app, container);` 之后加 `registerMailRoutes(app, container);`。 + +- [ ] **Step 4: 跑测试确认通过(含路由覆盖 IT)** + +Run: `pnpm vitest run apps/api/src/app/__tests__/mail-routes.test.ts apps/api/src/app/__tests__/route-coverage.it.test.ts && pnpm typecheck && pnpm lint` +Expected: 路由 5 tests PASS;路由覆盖 IT PASS(15 条新路由都在注册表里 —— 它是 fail-closed 的,红了就是有路由没契约);门禁退出码 0。若 `c.json(mapped.body, mapped.status)` 因状态码联合类型报错,把 `mailErrorResponse` 的 `status` 类型改成 hono 导出的 `ContentfulStatusCode`(`import type { ContentfulStatusCode } from 'hono/utils/http-status'`)。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/app +git commit -m "$(printf '邮件路由 15 条:账号 CRUD / 探测 / 立即拉取 / 试发 / 入站出站 / 附件原件 / 重新投递;领域错误翻状态码,厂商失败在 body 里说\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 16: `mail.send` 工具契约 2.0.0、api-report、五道门禁 + +**Files:** +- Modify: `apps/api/src/app/seed-mvp.ts`(`MAIL_TOOL` 与注册循环的样例 / 连接值) +- Modify: `api-report/**`(`pnpm api:check --update`) +- Test: 既有 `apps/api/src/modules/toolbox/__tests__/*`(演进检查)+ 全量 `pnpm gates` + +- [ ] **Step 1: 改 seed 的工具契约** + +把 `seed-mvp.ts` 里的 `MAIL_TOOL` 整段换成: + +```ts +/** + * 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。 + * + * 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。 + * 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。 + * `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。 + * `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。 + */ +const MAIL_TOOL = { + ...HISTORY_TOOL, + name: 'mail.send', + displayName: '发送邮件', + version: '2.0.0', + description: + '从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' + + 'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。', + input: { + type: 'object', + required: ['to', 'subject'], + properties: { + to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' }, + cc: { type: 'array', items: { type: 'string' }, description: '抄送' }, + bcc: { type: 'array', items: { type: 'string' }, description: '密送' }, + subject: { type: 'string' }, + body: { type: 'string', description: '纯文本正文(与 html 至少一个)' }, + html: { type: 'string', description: 'HTML 正文' }, + attachments: { + type: 'array', + items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } }, + description: '附件:上传件的 ref', + }, + inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' }, + accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' }, + dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' }, + }, + }, + output: { + type: 'object', + properties: { + queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' }, + outboundId: { type: 'string' }, + messageId: { type: 'string' }, + state: { type: 'string' }, + dryRun: { type: 'boolean' }, + }, + }, + effect: 'write' as const, + errors: [ + { code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' }, + { code: 'no-account', retryable: false, message: '没有可用的邮箱账号' }, + { code: 'invalid', retryable: false, message: '入参不合法' }, + ], + idempotent: true, + connectionKeys: ['account'], +}; +``` + +注册循环里的样例改为(`to` 变数组、加 `dryRun`;连接值按键给): + +```ts + input: { + pn: 'LM358DR', + to: ['x@y.com'], + subject: '询价', + body: '样例正文', + dryRun: true, + history_days: 30, + market_stable: true, + lifecycle: '在产', + }, +``` + +```ts + // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」 + ...(contract.connectionKeys.length === 0 + ? {} + : { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }), +``` + +- [ ] **Step 2: 演进检查与试运行走一遍(单测)** + +在 `apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts` 末尾追加一条(照该文件既有的 `checkEvolution` 用例写法): + +```ts +describe('mail.send 2.0.0 的演进', () => { + it('保留 v1 的 body / queued、不新增必填 —— 演进检查零违规', () => { + const v1 = { + name: 'mail.send', displayName: '发送邮件', version: '1.0.0', description: '发出邮件(报价单 / 询价函 / 催办)', + input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } } }, + output: { type: 'object', properties: { queued: { type: 'boolean' } } }, + effect: 'write' as const, errors: [], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'], + }; + const v2 = { + ...v1, version: '2.0.0', connectionKeys: ['account'], + input: { type: 'object', required: ['to', 'subject'], properties: { ...v1.input.properties, to: { type: 'array', items: { type: 'string' } }, html: { type: 'string' }, dryRun: { type: 'boolean' } } }, + output: { type: 'object', properties: { ...v1.output.properties, outboundId: { type: 'string' }, state: { type: 'string' } } }, + }; + expect(checkEvolution(v1, v2)).toEqual([]); + }); +}); +``` + +Run: `pnpm vitest run apps/api/src/modules/toolbox` +Expected: PASS(含新用例)。 + +- [ ] **Step 3: 刷新契约快照并跑五道门禁** + +```bash +pnpm api:check --update && git add api-report +pnpm gates +``` +Expected: `api-report/apps/api/src/modules/mail/{index,contract,port}.d.ts` 与 `api-report/packages/contracts/...` 有新增;`pnpm gates` 退出码 0(gen:tiers / typecheck / lint / depcruise / api:check / test 全绿;IT 套件在有 PG 时跑、没有时跳过并告警 —— 本轮验收要求 PG 在)。 + +- [ ] **Step 4: 本机起一次真服务核一遍(有 PG + Keycloak 配置时)** + +```bash +pnpm db:migrate && pnpm db:seed +pnpm dev:api +``` +Expected:启动日志无 `[mail-poller]` 报错(没有账号时每 tick 空转);`pnpm db:seed` 日志里 `② 功能工具已注册并入白名单`,且 `mail.send` 未出现「未过两审」(dryRun 样例通过试运行)。 +用服务账号 token(API 文档 §8 的取法)`curl -s -H "Authorization: Bearer $TOKEN" localhost:8787/api/gov/mail/accounts` → `{"accounts":[]}`。 + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/app/seed-mvp.ts apps/api/src/modules/toolbox/__tests__/tool-contract.test.ts api-report +git commit -m "$(printf 'mail.send 工具契约 2.0.0(to 数组 / html / 附件 / inReplyTo / dryRun;保留 v1 的 body 与 queued);契约快照刷新,五道门禁绿\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +### Task 17: 文档 —— ADR-013、CONTRACT.md、API 文档、前端对接说明、总表、debt、CLAUDE.md + +**Files:** +- Create: `docs/adr/ADR-013-mail-gateway.md` · `docs/API文档-邮件收发.md` · `docs/前端对接说明-邮件收发.md` +- Modify: `apps/api/src/modules/channel/CONTRACT.md`(「邮件通道是外界」一条)· `docs/API文档-鉴权与人员.md`(§6 总表 + 计数)· `docs/debt.md`(新段)· `CLAUDE.md`(现状快照一条 + 文档权威地图两行) + +- [ ] **Step 1: ADR-013** + +```markdown +# 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)`, + 重复拉到静默跳过;游标逐封(IMAP)/ 逐批(wecom)推进,失败停在最后一封成功处;首次不回填(锚在当下, + `backfillFrom` 才回填);IMAP 只读打开,不改已读标记。 +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` 且已 `sent` 的不发第二封, + 失败的那一行复用重发;厂商失败不抛(`state='failed'` + 原话),调用方各自翻译 —— 工具处理器翻成 + `send-failed`(可重试),channel 传输件抛出让放行落地看到。活动侧出站走 `mail.send` 工具(写效应, + 放行时经 `ToolInvoker` 执行);`connectionKeys: ['account']` 说「从哪个信箱发」。 +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 的验收条件」在本模块的落法。 + +## 后果 + +- 收信最快延迟 = `pollIntervalSec`(下限 10 秒);没有 IDLE 推送(记 debt)。 +- wecom 发出的信拿不到 Message-ID,线程级归属(①级)对它不成立;③级地址窗仍可用。 +- 附件走 `sys.upload` 的 8MB 上限,更大的只留注;原件 .eml 超 16MB 不存。 +- `mail.send` 工具契约升 2.0.0:`to` 由 string 变 array(演进检查不管类型;处理器仍容忍逗号串), + 唯一消费方 quote-* 活动已卸载,无在途调用;`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留待退役。 +- 前端接入前:账号只能经接口配;配好账号 + 通道 + 触发器,来信才会起单 —— + `channel` 配置仍是只读种子(无 upsert 路由),真要按业务收信得先把通道种上(记 debt)。 +- 部署:迁移 0033 先于启动;`reset-runs` 清三张留痕表、留账号。 +- 欠账见 `docs/debt.md`「邮件收发」段。 +``` + +- [ ] **Step 2: channel 的 CONTRACT.md** + +把 `apps/api/src/modules/channel/CONTRACT.md` 不变量里这一条: + +``` + - 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入); + 系统内部机制一律真跑,不得 mock +``` + +改成: + +``` + - 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方, + 把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认); + `createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用); + 系统内部机制一律真跑,不得 mock +``` + +`依赖:` 那一行的 `MailTransport`(模拟通道)改为 `MailTransport`(app 注入,转 mail 模块)。 + +- [ ] **Step 3: API 文档 `docs/API文档-邮件收发.md`** + +```markdown +# 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/*`;**读写都归运营中心 `ops`** —— 邮箱那一屏落在运营中心,设计端没有消费方;而入站正文装的是客户来往内容,不无故加宽可读面 | +| 广播 | 写路由成功后自动广播 `{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) | + +状态码怎么读(通则之外本文特有的): + +| 状态 | 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: `design|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: `design|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": "", "state": "sent", "providerRef": "250 OK: queued as …" } +``` + +失败照样 200:`{ …, "state": "failed", "error": "Invalid login —— 535 …" }`;这一笔也进出站列表。400 入参不合法;412 凭证空 / 停用。 + +## 2. 入站 + +### `GET /api/gov/mail/inbound?accountId=&limit=` —— access: `design|ops` + +按收信时间倒序;`limit` 默认 50、上限 200(超了 400)。 + +```json +{ + "mails": [ + { + "id": "in-01J…", "accountId": "6e1c…", "messageId": "", + "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: `design|ops` + +列表那一项 + `inReplyTo` `references[]` `text` `html` `size` `rawAvailable`。404。 + +### `GET /api/gov/mail/inbound/:mailId/attachments/:index` —— access: `design|ops` + +→ `{ "name": "BOM.xlsx", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形)。可带 `?ticket=` 用 `
`(ADR-012 §6)。 +超限没存的(`ref: null`)→ 404。附件同时也是一个上传件(`ref` = `sys.upload` 的 id),活动那一侧读得到抽出来的文本。 + +### `GET /api/gov/mail/inbound/:mailId/raw` —— access: `design|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: `design|ops` + +```json +{ + "mails": [ + { "id": "c0ff…", "accountId": "6e1c…", "messageId": "", "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` 进信封,活动经上传件读文本。 +- **发**:活动声明工具 `mail.send`(2.0.0)—— 入参 `to[]` `subject` `body` / `html` `cc[]` `bcc[]` `attachments[{uploadRef}]` `inReplyTo` `accountId`;写效应,放行时才真发;出参 `queued / outboundId / messageId / state`;错误 `send-failed`(可重试)/ `no-account` / `invalid`。 + +## 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; 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; 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}' +``` + +示例响应按契约构造;真实响应待账号配好后用上面的序列换。 +``` + +- [ ] **Step 4: 前端对接说明 `docs/前端对接说明-邮件收发.md`** + +```markdown +# 邮件收发 —— 前端对接说明 + +> 后端已按 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` 原话),不是接口错误;把原话原样摆出来。 +- 建议落点:**运营中心 · 系统区** 新增「邮箱」页(账号列表 + 详情 / 表单;来信;发信)。读接口两端都能调,设计中心可只读展示。 + +## 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` 逐条对过;字段名与 `packages/contracts/src/mail.ts` 同名。 +示例响应按契约构造 —— 真实响应待账号配好后从 API 文档 §6 的 curl 序列换。 +``` + +- [ ] **Step 5: 总表、debt、CLAUDE.md** + +`docs/API文档-鉴权与人员.md` §6 总表:在 `| \`ops\` | DELETE | \`/api/gov/access/roles/:role\` |` 之后加 8 行: + +```markdown +| `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\` | DELETE | \`/api/gov/access/roles/:role\` |` 那一组的末尾再加 7 行(与上面的写路由并成一组,邮件读写同归 ops): + +```markdown +| `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` | +``` + +计数那一行改为:`计数:public 2 + 退役 8 + any 2(+ SSE 1 条字面路由)+ workbench 23 + design 33 + ops 27 + 两端 15 = 契约 110 条(邮件收发 15 条见 docs/API文档-邮件收发.md)。` + +`docs/debt.md` 末尾追加: + +```markdown +## 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`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。 + 另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。 +``` + +`CLAUDE.md`:现状快照末尾加一条(照既有格式): + +```markdown +- **邮件收发模块接上腾讯企业邮箱,两条路**(2026-08-25,ADR-013,`apps/api/src/modules/mail` L1):IMAP/SMTP(任意信箱 + 客户端专用密码)与 + 企业微信邮件 API(应用邮箱);腾讯没有带内容的推送,收信是**轮询拉**(每账号自己的间隔,跨进程租约 `polling_until`)→ postal-mime 解析 → + `sys.mail_inbound` 两把唯一键去重 → 分发给订阅方(`channel` 是第一个:来信归一进事件流,活动经触发器 / `listen` 收到)。 + 分发**自动只投一次**(channel → event 按 cause.ref 不幂等,重投会起两单),失败在运营面可见,「重新投递」是人按的钮。 + 发信:`mail.send` 工具 2.0.0 真发(放行时经 ToolInvoker;`dryRun` 只校验 —— 注册的试运行会真调处理器)、channel 出站经 `mail.send`、 + 运营面试发;先落 `sending` 再发,厂商失败不抛(`state:'failed'` + 原话脱敏);带幂等键的不发两封。凭证密封,视图只回 `secretBound`。 + 15 条 `/api/gov/mail/*` 路由(读两端、写 ops);前端未做,照 `docs/前端对接说明-邮件收发.md`。新依赖 imapflow / nodemailer@8 / postal-mime。 + 欠账(debt):event 按 cause.ref 幂等、IDLE、wecom 无 Message-ID、channel 无 upsert 路由。 +``` + +文档权威地图加两行(放在「前端对接说明-统一鉴权中心」之后): + +```markdown +| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 | +| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) | +``` + +- [ ] **Step 6: 核对与提交** + +Run: `pnpm gates` +Expected: 退出码 0(文档改动不影响门禁;api-report 已在 Task 16 刷新)。 +核一遍:`grep -c "/api/gov/mail" docs/API文档-鉴权与人员.md` = 15;`grep -n "mail" tools/module-tiers.json` 有一行。 + +```bash +git add docs apps/api/src/modules/channel/CONTRACT.md CLAUDE.md +git commit -m "$(printf '文档:ADR-013 邮件收发、API 文档与前端对接说明(两形账号 / 四个动作 / 投递状态 / 腾讯侧前置)、总表 15 条、欠账七笔、CLAUDE 快照\n\nCo-Authored-By: Claude Fable 5 \nClaude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr')" +``` + +--- + +## 自审(spec 覆盖 / 占位 / 类型一致) + +| spec 节 | 落在 | +|---|---| +| §2 决策(两路 / 三依赖 / subscribe / 多账号 / 方案 A) | Task 1(依赖 + L1)· Task 4(subscribe 契约)· Task 12 / 13(两路) | +| §5.1 contract / §5.2 port | Task 4(+ Task 9 的 `probe` 第二形 `{input, accountId?}`) | +| §5.3 domain 八件 | Task 4(cursor / due / records / errors)· Task 5(inbound-record / redact)· Task 6(send-input / delivery / account-view) | +| §5.4 usecase 要点(拉 / 分发 / 发 / 探测 / 凭证 / 默认账号 / 到期扫描 / 账号变更) | Task 9 实现,Task 10 / 11 测试 | +| §5.5 adapter 六件 | Task 7(schema / store)· Task 8(postal-mime)· Task 12(imap-smtp)· Task 13(wecom)· Task 8 的 fakes(假 provider:住在 `__tests__/fakes.ts` 而不是 adapter —— 它只服务测试,不进生产装配) | +| §6 四张表 + journal + reset-runs | Task 7 | +| §7.1 / §7.2 / §7.3 / §7.4 契约、15 条路由、mail.send 2.0.0、总表 | Task 2 / 3 / 16 / 17 | +| §8 app 装配 / 工作者 / 路由 / channel 订阅方 / 上传件抽出 | Task 14 / 15 | +| §9 租约 / 去重 / 分发语义 / 脱敏 / 两缝 / 界面不撒谎 | Task 7(租约 IT)· Task 10(去重 / 分发)· Task 5(脱敏)· Task 17(ADR 写明两缝) | +| §10 腾讯侧前置 | Task 17 前端对接说明 §7 | +| §11 连带改动 | Task 1(tiers / 编码规范)· Task 14(first-party / 上传)· Task 16(seed / api-report)· Task 17(CONTRACT.md / ADR / 文档 / debt / CLAUDE) | +| §12 测试策略 | 各任务 Step 1;IT:Task 7(store + 租约)、Task 12(真信箱 env 门控)、Task 15(路由覆盖) | +| §13 欠账 | Task 17 debt 七笔(spec 的七条 + channel 无 upsert 路由这一条是实施核仓时补的) | + +与 spec 的差异已在开头「与 spec 的差异」列明(工具契约保留 v1 字段 / dryRun / send 不抛 / nodemailer 8)。 +类型名在各任务间核过:`MailAccountRecord` `InboundRecord` `DeliveryRecord` `OutboundRecord` `ParsedMail` `OutboundMessage` `RawMessage` +`ProviderAccount` `MailCursor` 定义于 Task 4,Task 7–15 一律按此名引用;`known` 回调在 Task 4 的 port、Task 9 的用例、Task 12 / 13 的 provider 三处同名。 diff --git a/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md b/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md new file mode 100644 index 0000000..f6adf8d --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-keycloak-auth-design.md @@ -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 + ▼ +apps/api server.ts 鉴权中间件(app 层,唯一一处) + ① 取凭据:Authorization 头;GET 且无头时认 ?ticket=(60s 票据,专给 EventSource / 下载) + ② 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; + +/** 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; + +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; // 抛 TokenRejectedError / AccountDisabledError + principalOf(staffId: StaffId): Promise; // 票据路;停用抛 AccountDisabledError + registerIdentity(identity: Identity): Promise; // JIT 入口;测试也用它造人 + /* 名册与岗位 */ + listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>; + getStaff(staffId: StaffId): Promise; + staffOfPosition(positionCode: string): Promise; // 只回 active;NONE 恒回 [] + setPosition(staffId: StaffId, positionId: PositionId): Promise; + syncRoster(): Promise; + inFlightOf(staffId: StaffId): Promise; + /* 角色 → 端 */ + listRoleAccess(): Promise<{ roles: RoleAccess[]; knownRoles: string[] }>; + setRoleAccess(role: string, ends: readonly Surface[]): Promise; // 抛 AccessLockoutError + deleteRoleAccess(role: string): Promise; // 抛 AccessLockoutError + staffWithEnd(surface: Surface): Promise; // 通知收件人:能进该端的活跃员工 + /* 工作组(ADR-011,原样) */ + saveWorkGroup(...) / workGroupOf(...) / bindingOf(...) + ensureSeeded(): Promise; +}; + +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 }; // 失败抛 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 }; +export type RosterUser = Identity & { readonly enabled: boolean }; + +export type StaffRepository = { + list(): Promise; + findById(id: StaffId): Promise; + findByAccount(account: string): Promise; // 仅播种/测试账号策略用 + listByPosition(positionId: PositionId): Promise; + insert(staff: Staff): Promise; + updateIdentity(id: StaffId, patch: { account; name; email?; roles; lastSeenAt? }): Promise; + setPosition(id: StaffId, positionId: PositionId): Promise; + setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; +}; +export type RoleAccessRepository = { + list(): Promise; + upsert(row: RoleAccess): Promise; + remove(role: string): Promise; +}; +// 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-` | +| `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-` 的 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')` 与 ``。 +- `GET /api/auth/ticket`(持 Bearer)→ `v1...`,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`;提交按承包工单粒度,五道门禁以退出码验。 diff --git a/docs/superpowers/specs/2026-08-25-mail-design.md b/docs/superpowers/specs/2026-08-25-mail-design.md new file mode 100644 index 0000000..ad348df --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-mail-design.md @@ -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): + - 腾讯企业邮箱客户端协议设置(企业微信帮助中心): + - 企业微信 · 邮件概述: + - 发送普通邮件 `compose_send`: + - 获取收件箱邮件列表 `get_mail_list`: + - 获取邮件内容 `read_mail`: + - 查询应用邮箱账号 `get_email_alias`: + - 邮箱回调通知(公共邮箱 `receive_email`): + - 创建公共邮箱(`create_auth_code` 拿客户端专用密码): + - 获取 access_token: + - 全局错误码 / 访问频率限制: / + - 老版企业邮箱开放接口(`api.exmail.qq.com`): + +> **这份稿子里有三处已被 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; + +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; + getAccount(accountId: string): Promise; + createAccount(input: MailAccountInput): Promise; + updateAccount(accountId: string, input: MailAccountInput): Promise; // secret 缺省 = 不改 + deleteAccount(accountId: string): Promise; + probe(target: { accountId: string } | { input: MailAccountInput }): Promise; // 已存 / 未存两种 + // 收 + pollDue(now: Date): Promise; // 工作者用:到期且未被占的账号各拉一轮 + pollNow(accountId: string): Promise; // 路由用:租约被占 → MailPollBusyError + subscribe(name: string, handler: InboundHandler): () => void; // 同名重复注册 → 抛 + listInbound(query: { accountId?: string; limit?: number }): Promise; + getInbound(mailId: string): Promise; + 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; // 人按的钮,至少一次 + // 发 + send(input: SendInput): Promise; + listOutbound(query: { accountId?: string; limit?: number }): Promise; +}; +``` + +### 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; + get(id: string): Promise; + save(record: MailAccountRecord): Promise; // upsert;name 唯一、默认至多一个由库约束 + remove(id: string): Promise; + listDue(now: Date): Promise; // enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空/过期 + claimPolling(id: string, now: Date, leaseMs: number): Promise; // 一条 SQL 判+占(§9.1) + releasePolling(id: string): Promise; + savePoll(id: string, patch: { cursor?: MailCursor; polledAt?: Date; error?: string | null; lastInboundAt?: Date; address?: string }): Promise; +}; + +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>; // wecom 拉列表后先滤,不重复 read_mail + list(query: { accountId?: string; limit: number }): Promise; + get(mailId: string): Promise; + setDelivery(d: { mailId: string; subscriber: string; state: DeliveryState; error?: string; startedAt: Date; finishedAt?: Date }): Promise; + deliveries(mailId: string): Promise; +}; + +export type OutboundStore = { + insert(record: OutboundRecord): Promise; + byIdempotencyKey(key: string): Promise; + setState(id: string, state: 'sent' | 'failed', patch: { error?: string; providerRef?: string; sentAt?: Date }): Promise; + list(query: { accountId?: string; limit: number }): Promise; +}; + +/** 附件存放 —— 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; readonly secret: Record }; +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; + /** 无游标时由 provider 锚定初始游标(不回填;设了 backfillFrom 才从那天起) */ + anchor(account: ProviderAccount, backfillFrom: Date | undefined, now: Date): Promise; + 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 }; + +/** 变更通知 —— 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>; + 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` 缺失时用 + `` 顶(不抛);`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) = `。 +- `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"
', 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, -- 自造 ;线程级归属的对表来源 + 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:'.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)→ 接入完成的判据。 diff --git a/docs/前端对接说明-统一鉴权中心.md b/docs/前端对接说明-统一鉴权中心.md new file mode 100644 index 0000000..c76ef82 --- /dev/null +++ b/docs/前端对接说明-统一鉴权中心.md @@ -0,0 +1,213 @@ +# 统一鉴权中心(Keycloak)—— 前端对接说明 + +> 后端已按 ADR-012 改成**资源服务器**:不再有登录口、会话 cookie 与岗位授权; +> 每个请求带 `Authorization: Bearer `,后端本地验签、按「角色 → 端」判权。 +> 这份说明只讲**前端要改什么、后端给了什么**。浏览器怎么登录、怎么取 / 刷 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` 与 ``)用 **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 (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 `;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` | `` | 点击前现取票据拼到 `&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` 与 `` 带不了请求头,所以后端给了一条**只对 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).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)`(或动态创建 `` 点一下)。 +2. `fetch(href, { headers: { authorization } })` → `blob()` → `URL.createObjectURL` → `` 点一下 → `revokeObjectURL`。 + 不用票据,但大文件会先进内存。 + +`FileViewer` 里「预览」那条路本来就是 `fetch`(经 `typed-fetch`),带头即可;只有「下载原件」那枚 `` 需要上面两选一。 + +## 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 的响应示例现在都是**实测响应**,不再是按契约构造的样例。 diff --git a/docs/前端对接说明-邮件收发.md b/docs/前端对接说明-邮件收发.md new file mode 100644 index 0000000..5c1055d --- /dev/null +++ b/docs/前端对接说明-邮件收发.md @@ -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 序列换。 diff --git a/docs/数据域字段说明-E04-I01.md b/docs/数据域字段说明-E04-I01.md new file mode 100644 index 0000000..c26b7ac --- /dev/null +++ b/docs/数据域字段说明-E04-I01.md @@ -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)。* diff --git a/docs/编码规范.md b/docs/编码规范.md index 8033e29..0abaea8 100644 --- a/docs/编码规范.md +++ b/docs/编码规范.md @@ -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 框架。 diff --git a/docs/部署.md b/docs/部署.md new file mode 100644 index 0000000..e641559 --- /dev/null +++ b/docs/部署.md @@ -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// 一次发布的完整目录(产物 + db/ + 自己的 node_modules) + current -> releases/ **只是给人看的指针**,pm2 不经它启动(见下面「为什么不用软链启动」) + data/attachments/ 来信原件。**唯一一份不在数据库里的持久数据,定了不能改** + backups/<时刻>-.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 @<主机> '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/` 的真实路径起;`current` 只是给人看的指针。 + +## 回滚 + +```bash +ls -1t /opt/jcai/releases # 看有哪些版本 +PREV=/opt/jcai/releases/<上一版的 sha> + +pm2 delete jcai-api +NODE_ENV=production PORT=8787 \ + 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/<时刻>-.sql.gz | psql '' +``` + +## 生产上绝对不能跑的 + +| 命令 | 后果 | +|---|---| +| `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/` 的真实路径起 | +| 部署全绿,但系统里一个活动都没有 | `install-activities` 永远 exit 0。看「部署 · 活动包回查」那一步 —— 它才说得出实情 | +| 界面不实时刷新,要手动刷新才变 | nginx 把 `/api/stream` 缓冲了。照上面 SSE 那段配 | +| 迁移「完成」了但新表没有 | drizzle 只比最后一条的时间戳当水位线,不逐条比 hash。新迁移在 `db/migrations/meta/_journal.json` 里的 `when` 若 ≤ 水位线会被永久静默跳过。「部署 · 迁移到位断言」就是拦这个的 | +| 第一次真调模型才炸「解不开」 | `JCAI_SECRET_KEY` 换过了 | +| 换版之后浏览器还是旧界面 | `index.html` 被缓存了。照上面 `location /` 那段配 `no-store` | diff --git a/packages/contracts/src/__tests__/mail.test.ts b/packages/contracts/src/__tests__/mail.test.ts new file mode 100644 index 0000000..ccef494 --- /dev/null +++ b/packages/contracts/src/__tests__/mail.test.ts @@ -0,0 +1,66 @@ +/** 邮件契约 —— 账号入参按 kind 分两形,默认值与视图不带密文 */ +import { describe, expect, it } from 'vitest'; +import { + mailAccountInputSchema, + mailAccountViewSchema, + mailSendInputSchema, + MAIL_ACCOUNT_KINDS, +} from '../mail.ts'; + +describe('mailAccountInputSchema', () => { + it('imap-smtp:只给地址与名字,腾讯的服务器 / 端口 / TLS / 文件夹全部有默认值', () => { + const parsed = mailAccountInputSchema.parse({ kind: 'imap-smtp', name: '销售信箱', address: 'sales@we.com' }); + expect(parsed.kind).toBe('imap-smtp'); + if (parsed.kind !== 'imap-smtp') throw new Error('unreachable'); + expect(parsed.settings).toEqual({ + imapHost: 'imap.exmail.qq.com', + imapPort: 993, + imapTls: true, + smtpHost: 'smtp.exmail.qq.com', + smtpPort: 465, + smtpTls: true, + folder: 'INBOX', + }); + expect(parsed.enabled).toBe(true); + expect(parsed.isDefault).toBe(false); + expect(parsed.pollIntervalSec).toBe(30); + expect(parsed.channelId).toBeNull(); + expect(parsed.backfillFrom).toBeNull(); + expect(parsed.secret).toBeUndefined(); + }); + + it('wecom:corpId 必填,地址不由人填(探测回填)', () => { + const parsed = mailAccountInputSchema.parse({ kind: 'wecom', name: '应用邮箱', settings: { corpId: 'ww123' } }); + if (parsed.kind !== 'wecom') throw new Error('unreachable'); + expect(parsed.settings.apiBase).toBe('https://qyapi.weixin.qq.com'); + expect('address' in parsed).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'wecom', name: 'x', settings: {} }).success).toBe(false); + }); + + it('轮询间隔下限 10 秒;地址必须是带 TLD 的邮箱;kind 只认两种', () => { + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'a@b.com', pollIntervalSec: 5 }).success).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'not-mail' }).success).toBe(false); + // 漏了 TLD 的地址(sales@corp)是打字错误,在 API 边界就拦下 —— 别拖到 SMTP 才炸 + expect(mailAccountInputSchema.safeParse({ kind: 'imap-smtp', name: 'a', address: 'sales@corp' }).success).toBe(false); + expect(mailAccountInputSchema.safeParse({ kind: 'pop3', name: 'a' }).success).toBe(false); + expect(MAIL_ACCOUNT_KINDS).toEqual(['imap-smtp', 'wecom']); + }); +}); + +describe('mailAccountViewSchema', () => { + it('视图只有 secretBound,没有任何密文 / 密码字段', () => { + const keys = Object.keys(mailAccountViewSchema.options[0]!.shape); + expect(keys).toContain('secretBound'); + expect(keys.some((k) => /cipher|password|secret$/i.test(k))).toBe(false); + }); +}); + +describe('mailSendInputSchema', () => { + it('收件人至少一个、主题必填;附件默认空数组;requestedBy 必带 kind', () => { + const ok = mailSendInputSchema.safeParse({ to: ['a@b.com'], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }); + expect(ok.success).toBe(true); + if (ok.success) expect(ok.data.attachments).toEqual([]); + expect(mailSendInputSchema.safeParse({ to: [], subject: 'hi', text: 'x', requestedBy: { kind: 'ops' } }).success).toBe(false); + expect(mailSendInputSchema.safeParse({ to: ['a@b.com'], subject: 'hi', text: 'x' }).success).toBe(false); + }); +}); diff --git a/packages/contracts/src/__tests__/route-access.test.ts b/packages/contracts/src/__tests__/route-access.test.ts new file mode 100644 index 0000000..ac16a4c --- /dev/null +++ b/packages/contracts/src/__tests__/route-access.test.ts @@ -0,0 +1,81 @@ +/** 路由契约:每条都得说清归哪个端(ADR-012)—— 中间件靠它,漏一条就是一个没人管的口子 */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '../index.ts'; +import type { RouteContract } from '../index.ts'; +import { SURFACES } from '../index.ts'; + +function isRoute(value: unknown): value is RouteContract { + if (typeof value !== 'object' || value === null) return false; + const v = value as { method?: unknown; path?: unknown; output?: unknown }; + return typeof v.method === 'string' && typeof v.path === 'string' && v.output !== undefined; +} + +/** + * `contracts` 是异质的命名空间(schema / 常量 / 路由混杂),`Object.entries` 推出的元素类型 + * 因而是一个几百项的大联合;`entry is [string, RouteContract]` 直接对着那个联合做型别谓词, + * TS 判不出谓词类型与参数类型互相兼容(TS2677)。先收窄成 `[string, unknown][]` 再筛 —— + * 语义不变(照旧靠 `isRoute` 的运行时判断筛路由),只是把关系判断让给 `unknown`。 + */ +const routes = (Object.entries(contracts) as [string, unknown][]).filter( + (entry): entry is [string, RouteContract] => isRoute(entry[1]), +); +const surfaces: readonly string[] = SURFACES; + +describe('路由契约的归属(access)', () => { + it('收得到全部路由(至少 90 条)', () => { + expect(routes.length).toBeGreaterThanOrEqual(90); + }); + + it('access 只取 public / any / 端名 / 非空端名数组', () => { + for (const [name, route] of routes) { + const a = route.access; + const ok = + a === 'public' || + a === 'any' || + (typeof a === 'string' ? surfaces.includes(a) : a.length > 0 && a.every((s) => surfaces.includes(s))); + expect(ok, `${name}.access = ${JSON.stringify(a)}`).toBe(true); + } + }); + + it('公开路由只有健康检查、入站邮件与退役八条', () => { + const publicNames = routes.filter(([, r]) => r.access === 'public').map(([n]) => n).sort(); + expect(publicNames).toEqual( + [ + 'healthRoute', + 'ingestMailRoute', + 'loginRoute', + 'logoutRoute', + 'sessionRoute', + 'createStaffRoute', + 'disableStaffCheckRoute', + 'disableStaffRoute', + 'listGrantsRoute', + 'setGrantsRoute', + ].sort(), + ); + }); + + it('只要登录不看端的只有 me / 票据', () => { + const anyNames = routes.filter(([, r]) => r.access === 'any').map(([n]) => n).sort(); + expect(anyNames).toEqual(['authTicketRoute', 'meRoute']); + }); + + it('两端共用的读路由就是 spec §4.4 点名的那八条', () => { + const both = routes + .filter(([, r]) => Array.isArray(r.access)) + .map(([n]) => n) + .sort(); + expect(both).toEqual( + [ + 'listStaffRoute', + 'listProcessPackagesRoute', + 'listTriggersRoute', + 'listChannelsRoute', + 'listLedgerObjectsRoute', + 'ledgerByActivityRoute', + 'browseLedgerRoute', + 'govWorkflowRoute', + ].sort(), + ); + }); +}); diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 1a401e6..91f3777 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -4,7 +4,17 @@ */ import { z } from 'zod'; import type { RouteContract } from './http.ts'; -import { disableCheckSchema, grantSchema, positionSchema, staffSchema, workGroupSchema } from '../org.ts'; +import { + disableCheckSchema, + grantSchema, + inFlightWorkSchema, + positionSchema, + roleAccessSchema, + rosterSyncOutcomeSchema, + staffSchema, + workGroupSchema, +} from '../org.ts'; +import { surfaceSchema } from './http.ts'; import { positionIdSchema, staffIdSchema, taskIdSchema } from '../id.ts'; import { ledgerObjectKindSchema } from '../ledger.ts'; import { approvalTemplateSchema, closureReportSchema, processPackageSchema, triggerConfigSchema } from '../process-package.ts'; @@ -17,15 +27,18 @@ import { reportMetricsSchema, reportPeriodSchema } from './workbench.ts'; export const listStaffRoute = { method: 'GET', path: '/api/gov/staff', + access: ['design', 'ops'], output: z.object({ staff: z.array(staffSchema.extend({ position: positionSchema })), positions: z.array(positionSchema), }), } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */ export const createStaffRoute = { method: 'POST', path: '/api/gov/staff', + access: 'public', body: z.object({ account: z.string().min(1), name: z.string().min(1), @@ -34,35 +47,103 @@ export const createStaffRoute = { output: staffSchema, } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */ export const disableStaffCheckRoute = { method: 'GET', path: '/api/gov/staff/:staffId/disable-check', + access: 'public', params: z.object({ staffId: staffIdSchema }), output: disableCheckSchema, } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */ export const disableStaffRoute = { method: 'POST', path: '/api/gov/staff/:staffId/disable', + access: 'public', params: z.object({ staffId: staffIdSchema }), output: z.object({ ok: z.boolean(), check: disableCheckSchema }), } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */ export const listGrantsRoute = { method: 'GET', path: '/api/gov/positions/:positionId/grants', + access: 'public', params: z.object({ positionId: positionIdSchema }), output: z.object({ grants: z.array(grantSchema) }), } as const satisfies RouteContract; +/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */ export const setGrantsRoute = { method: 'POST', path: '/api/gov/positions/:positionId/grants', + access: 'public', params: z.object({ positionId: positionIdSchema }), body: z.object({ grants: z.array(grantSchema) }), output: z.object({ grants: z.array(grantSchema) }), } as const satisfies RouteContract; +/* ── 员工管理 · 名册与岗位(ADR-012:人来自鉴权中心,岗位本地指派) ────── */ + +/** 从 Keycloak Admin API 同步名册;没配 provisioning 服务账号就如实回 configured:false */ +export const syncStaffRoute = { + method: 'POST', + path: '/api/gov/staff/sync', + access: 'ops', + output: rosterSyncOutcomeSchema, +} as const satisfies RouteContract; + +/** 指派岗位;指回哨兵岗 NONE = 取消指派 */ +export const setStaffPositionRoute = { + method: 'PUT', + path: '/api/gov/staff/:staffId/position', + access: 'ops', + params: z.object({ staffId: staffIdSchema }), + body: z.object({ positionId: positionIdSchema }), + output: staffSchema.extend({ position: positionSchema }), +} as const satisfies RouteContract; + +/** 此人身上还挂着什么(只读)—— 原「停用前置检查」;停用本身在 Keycloak 做 */ +export const staffInFlightRoute = { + method: 'GET', + path: '/api/gov/staff/:staffId/in-flight', + access: 'ops', + params: z.object({ staffId: staffIdSchema }), + output: inFlightWorkSchema, +} as const satisfies RouteContract; + +/* ── 角色 → 端(ADR-012:权限只到端,这张表运营中心可改) ─────────────── */ + +export const listRoleAccessRoute = { + method: 'GET', + path: '/api/gov/access/roles', + access: 'ops', + output: z.object({ + roles: z.array(roleAccessSchema), + /** 名册上见过的角色 ∪ 已配置的角色 —— 配置前它等于「无端可进」 */ + knownRoles: z.array(z.string()), + }), +} as const satisfies RouteContract; + +/** 锁死不变量不过(改完没人能进运营中心)→ 409 conflict */ +export const setRoleAccessRoute = { + method: 'PUT', + path: '/api/gov/access/roles/:role', + access: 'ops', + params: z.object({ role: z.string().min(1) }), + body: z.object({ ends: z.array(surfaceSchema) }), + output: roleAccessSchema, +} as const satisfies RouteContract; + +export const deleteRoleAccessRoute = { + method: 'DELETE', + path: '/api/gov/access/roles/:role', + access: 'ops', + params: z.object({ role: z.string().min(1) }), + output: z.object({ ok: z.boolean() }), +} as const satisfies RouteContract; + /* ── 系统管理 · LLM 实例(四元绑定) ────────────────────────────────── */ export const LLM_PROVIDERS = ['openai-compatible', 'openai', 'deepseek', 'anthropic', 'qwen'] as const; @@ -92,6 +173,7 @@ export type LlmInstanceView = z.infer; export const listLlmProvidersRoute = { method: 'GET', path: '/api/gov/llm/providers', + access: 'design', output: z.object({ providers: z.array( z.object({ @@ -109,12 +191,14 @@ export const listLlmProvidersRoute = { export const listLlmInstancesRoute = { method: 'GET', path: '/api/gov/llm', + access: 'design', output: z.object({ instances: z.array(llmInstanceViewSchema) }), } as const satisfies RouteContract; export const upsertLlmInstanceRoute = { method: 'POST', path: '/api/gov/llm', + access: 'design', body: z.object({ id: z.string().optional(), name: z.string().min(1), @@ -142,6 +226,7 @@ export const upsertLlmInstanceRoute = { export const deleteLlmInstanceRoute = { method: 'DELETE', path: '/api/gov/llm/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; @@ -149,6 +234,7 @@ export const deleteLlmInstanceRoute = { export const probeLlmDraftRoute = { method: 'POST', path: '/api/gov/llm/probe', + access: 'design', body: z.object({ id: z.string().optional(), name: z.string().optional(), @@ -163,6 +249,7 @@ export const probeLlmDraftRoute = { export const probeLlmInstanceRoute = { method: 'POST', path: '/api/gov/llm/:id/probe', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean(), latencyMs: z.number().int().optional(), error: z.string().optional() }), } as const satisfies RouteContract; @@ -186,6 +273,7 @@ export type GovTaskRow = z.infer; export const listGovTasksRoute = { method: 'GET', path: '/api/gov/tasks', + access: 'ops', output: z.object({ tasks: z.array(govTaskRowSchema), workflows: z.array( @@ -204,6 +292,7 @@ export const listGovTasksRoute = { export const controlTaskRoute = { method: 'POST', path: '/api/gov/tasks/:taskId/control', + access: 'ops', params: z.object({ taskId: taskIdSchema }), body: z.object({ semantic: controlSemanticSchema, reason: z.string().optional() }), output: z.object({ ok: z.boolean(), resumable: z.boolean() }), @@ -212,6 +301,7 @@ export const controlTaskRoute = { export const resumeTaskRoute = { method: 'POST', path: '/api/gov/tasks/:taskId/resume', + access: 'ops', params: z.object({ taskId: taskIdSchema }), output: z.object({ ok: z.boolean(), runtime: taskRuntimeStateSchema }), } as const satisfies RouteContract; @@ -220,6 +310,7 @@ export const resumeTaskRoute = { export const reassignTaskRoute = { method: 'POST', path: '/api/gov/tasks/:taskId/reassign', + access: 'ops', params: z.object({ taskId: taskIdSchema }), body: z.object({ to: staffIdSchema }), output: z.object({ ok: z.boolean(), ownerId: staffIdSchema }), @@ -229,6 +320,7 @@ export const reassignTaskRoute = { export const govTaskLogRoute = { method: 'GET', path: '/api/gov/tasks/:taskId/log', + access: 'ops', params: z.object({ taskId: taskIdSchema }), output: z.object({ // 与工作台同一份日志:章是四字动作,内容带主语(见 workbench 的 log 项) @@ -243,6 +335,7 @@ export const govTaskLogRoute = { export const listProcessPackagesRoute = { method: 'GET', path: '/api/gov/process', + access: ['design', 'ops'], output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; @@ -257,6 +350,7 @@ export const listProcessPackagesRoute = { export const saveProcessRoute = { method: 'PUT', path: '/api/gov/process', + access: 'design', /** * 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。 * `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。 @@ -275,6 +369,7 @@ export const saveProcessRoute = { export const setProcessStateRoute = { method: 'POST', path: '/api/gov/process/:id/:version/state', + access: 'design', params: z.object({ id: z.string(), version: z.string() }), body: z.object({ state: z.enum(['active', 'retired']) }), output: z.object({ packages: z.array(processPackageSchema) }), @@ -288,6 +383,7 @@ export const setProcessStateRoute = { export const deleteProcessVersionRoute = { method: 'DELETE', path: '/api/gov/process/:id/:version', + access: 'design', params: z.object({ id: z.string(), version: z.string() }), output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; @@ -298,6 +394,7 @@ export const deleteProcessVersionRoute = { export const listApprovalTemplatesRoute = { method: 'GET', path: '/api/gov/approval-templates', + access: 'design', output: z.object({ templates: z.array( approvalTemplateSchema.extend({ @@ -312,6 +409,7 @@ export const listApprovalTemplatesRoute = { export const upsertApprovalTemplateRoute = { method: 'PUT', path: '/api/gov/approval-templates', + access: 'design', body: z.object({ template: approvalTemplateSchema }), output: z.object({ template: approvalTemplateSchema }), } as const satisfies RouteContract; @@ -320,6 +418,7 @@ export const upsertApprovalTemplateRoute = { export const deleteApprovalTemplateRoute = { method: 'DELETE', path: '/api/gov/approval-templates/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; @@ -332,6 +431,7 @@ export const deleteApprovalTemplateRoute = { export const deleteProcessFlowRoute = { method: 'DELETE', path: '/api/gov/process/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; @@ -347,6 +447,7 @@ export const deleteProcessFlowRoute = { export const editProcessVersionRoute = { method: 'POST', path: '/api/gov/process/:id/:version/edit', + access: 'design', params: z.object({ id: z.string(), version: z.string() }), body: z.object({ version: z.number().int().min(1).max(9999).optional(), @@ -360,6 +461,7 @@ export const editProcessVersionRoute = { export const validateProcessRoute = { method: 'POST', path: '/api/gov/process/validate', + access: 'design', body: z.object({ pkg: processPackageSchema }), output: closureReportSchema, } as const satisfies RouteContract; @@ -369,12 +471,14 @@ export const validateProcessRoute = { export const listTriggersRoute = { method: 'GET', path: '/api/gov/triggers', + access: ['design', 'ops'], output: z.object({ triggers: z.array(triggerConfigSchema) }), } as const satisfies RouteContract; export const toggleTriggerRoute = { method: 'POST', path: '/api/gov/triggers/:id/toggle', + access: 'ops', params: z.object({ id: z.string() }), body: z.object({ enabled: z.boolean() }), output: z.object({ ok: z.boolean() }), @@ -390,6 +494,7 @@ export const toggleTriggerRoute = { export const upsertTriggerRoute = { method: 'PUT', path: '/api/gov/triggers', + access: 'design', body: z.object({ trigger: triggerConfigSchema }), output: z.object({ trigger: triggerConfigSchema }), } as const satisfies RouteContract; @@ -405,6 +510,7 @@ export const upsertTriggerRoute = { export const listWorkGroupsRoute = { method: 'GET', path: '/api/gov/work-groups', + access: 'ops', /** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */ output: z.object({ groups: z.array(workGroupSchema.extend({ ownerName: z.string() })) }), } as const satisfies RouteContract; @@ -413,6 +519,7 @@ export const listWorkGroupsRoute = { export const saveWorkGroupRoute = { method: 'PUT', path: '/api/gov/work-groups', + access: 'ops', body: z.object({ ownerId: staffIdSchema, name: z.string().min(1), @@ -434,6 +541,7 @@ export const saveWorkGroupRoute = { export const installActivityRoute = { method: 'POST', path: '/api/gov/activities', + access: 'design', body: z.object({ /** 清单六区:身份 / 七件契约 / 声明 / 资产初始版 / 样例集 / 代码入口 */ manifest: activityManifestSchema, @@ -465,6 +573,7 @@ export const installActivityRoute = { export const registerToolRoute = { method: 'POST', path: '/api/gov/tools', + access: 'design', body: z.object({ contract: z.object({ name: z.string().min(1), @@ -500,6 +609,7 @@ export const registerToolRoute = { export const listActivitiesRoute = { method: 'GET', path: '/api/gov/activities', + access: 'design', output: z.object({ activities: z.array( z.object({ @@ -562,6 +672,7 @@ export const listActivitiesRoute = { export const listToolsRoute = { method: 'GET', path: '/api/gov/tools', + access: 'design', output: z.object({ tools: z.array( z.object({ @@ -583,6 +694,7 @@ export const listToolsRoute = { export const listLedgerObjectsRoute = { method: 'GET', path: '/api/gov/data/objects', + access: ['design', 'ops'], /** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */ output: z.object({ objects: z.array( @@ -623,6 +735,7 @@ export const listLedgerObjectsRoute = { export const ledgerByActivityRoute = { method: 'GET', path: '/api/gov/data/by-activity', + access: ['design', 'ops'], output: z.object({ activities: z.array( z.object({ @@ -667,6 +780,7 @@ export const ledgerByActivityRoute = { export const browseLedgerRoute = { method: 'GET', path: '/api/gov/data/:domain/:object', + access: ['design', 'ops'], params: z.object({ domain: z.string(), object: z.string() }), output: z.object({ label: z.string().optional(), @@ -682,6 +796,7 @@ export const browseLedgerRoute = { export const listActivityMemoryRoute = { method: 'GET', path: '/api/gov/memory/:activityId', + access: 'design', params: z.object({ activityId: z.string() }), output: z.object({ entries: z.array( @@ -703,6 +818,7 @@ export const listActivityMemoryRoute = { export const listChannelsRoute = { method: 'GET', path: '/api/gov/channels', + access: ['design', 'ops'], output: z.object({ channels: z.array( z.object({ @@ -733,6 +849,7 @@ export const listChannelsRoute = { export const listApprovalsRoute = { method: 'GET', path: '/api/gov/approvals', + access: 'ops', output: z.object({ approvals: z.array( z.object({ @@ -763,6 +880,7 @@ export const listApprovalsRoute = { export const setTriggerConfirmRoute = { method: 'POST', path: '/api/gov/triggers/:id/confirm', + access: 'ops', params: z.object({ id: z.string() }), body: z.object({ required: z.boolean(), staffId: z.string().optional() }), output: z.object({ ok: z.boolean() }), @@ -772,6 +890,7 @@ export const setTriggerConfirmRoute = { export const listNodeOwnersRoute = { method: 'GET', path: '/api/gov/packages/:id/owners', + access: 'ops', params: z.object({ id: z.string() }), output: z.object({ owners: z.array(z.object({ nodeId: z.string(), staffId: z.string() })) }), } as const satisfies RouteContract; @@ -779,6 +898,7 @@ export const listNodeOwnersRoute = { export const setNodeOwnerRoute = { method: 'PUT', path: '/api/gov/packages/:id/owners/:nodeId', + access: 'ops', params: z.object({ id: z.string(), nodeId: z.string() }), /** null = 撤掉运营指定,回到轮值 */ body: z.object({ staffId: z.string().nullable() }), @@ -788,6 +908,7 @@ export const setNodeOwnerRoute = { export const testRunRoute = { method: 'POST', path: '/api/gov/testbed/run', + access: 'design', body: z.object({ packageId: z.string(), /** @@ -827,6 +948,7 @@ export const testRunRoute = { export const testRunActivityRoute = { method: 'POST', path: '/api/gov/testbed/run-activity', + access: 'design', body: z.object({ activityId: z.string().min(1), /** 起单入参 —— 按该活动契约的 `input.data` 填;可用包自带样例预填 */ @@ -860,6 +982,7 @@ export const testRunActivityRoute = { export const testStopRoute = { method: 'POST', path: '/api/gov/testbed/stop', + access: 'design', /** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */ body: z.object({ workflowId: z.string().default(''), taskId: z.string().default('') }), output: z.object({ destroyed: z.boolean(), tasks: z.number().int() }), @@ -877,6 +1000,7 @@ export const testStopRoute = { export const testCurrentRoute = { method: 'GET', path: '/api/gov/testbed/current', + access: 'design', output: z.object({ workflowId: z.string().optional(), subject: z.string().optional(), @@ -900,6 +1024,7 @@ export const testCurrentRoute = { export const testActivityStateRoute = { method: 'GET', path: '/api/gov/testbed/activity/:taskId', + access: 'design', params: z.object({ taskId: z.string() }), output: z.object({ taskId: z.string(), @@ -954,6 +1079,7 @@ export const testActivityStateRoute = { export const uploadFileRoute = { method: 'POST', path: '/api/gov/upload', + access: 'design', body: z.object({ name: z.string().min(1), contentType: z.string().default(''), @@ -976,6 +1102,7 @@ export type UploadedFile = z.infer; export const downloadFileRoute = { method: 'GET', path: '/api/gov/upload/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }), } as const satisfies RouteContract; @@ -1013,6 +1140,7 @@ export type TestLogRow = z.infer; export const listTestLogsRoute = { method: 'GET', path: '/api/gov/testbed/logs', + access: 'design', output: z.object({ runs: z.array(testLogRowSchema) }), } as const satisfies RouteContract; @@ -1020,6 +1148,7 @@ export const listTestLogsRoute = { export const testLogDetailRoute = { method: 'GET', path: '/api/gov/testbed/logs/:id', + access: 'design', params: z.object({ id: z.string() }), output: testLogRowSchema.extend({ trail: z.array( @@ -1053,6 +1182,7 @@ export const testLogDetailRoute = { export const deleteTestLogRoute = { method: 'DELETE', path: '/api/gov/testbed/logs/:id', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ deleted: z.number().int() }), } as const satisfies RouteContract; @@ -1060,6 +1190,7 @@ export const deleteTestLogRoute = { export const clearTestLogsRoute = { method: 'DELETE', path: '/api/gov/testbed/logs', + access: 'design', output: z.object({ deleted: z.number().int() }), } as const satisfies RouteContract; @@ -1067,6 +1198,7 @@ export const clearTestLogsRoute = { export const traceOfWorkflowRoute = { method: 'GET', path: '/api/gov/workflows/:id/trace', + access: 'design', params: z.object({ id: z.string() }), output: z.object({ spans: z.array( @@ -1092,6 +1224,7 @@ export const traceOfWorkflowRoute = { export const govWorkflowRoute = { method: 'GET', path: '/api/gov/workflows/:id', + access: ['design', 'ops'], params: z.object({ id: z.string() }), output: z.object({ workflow: z.object({ @@ -1131,6 +1264,7 @@ export const govWorkflowRoute = { export const govMetricsRoute = { method: 'GET', path: '/api/gov/metrics', + access: 'ops', /** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */ query: z.object({ period: reportPeriodSchema.optional() }), /** `period` 原样回声:客户端不传时,由它知道服务端用的是哪个窗口 */ diff --git a/packages/contracts/src/api/http.ts b/packages/contracts/src/api/http.ts index 2f9528c..1e86c24 100644 --- a/packages/contracts/src/api/http.ts +++ b/packages/contracts/src/api/http.ts @@ -4,14 +4,35 @@ * 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。 * **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。 */ -import type { z } from 'zod'; +import { z } from 'zod'; export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; export type HttpMethod = (typeof HTTP_METHODS)[number]; +/** + * 三个端(ADR-012):设计中心 / 运营中心 / 工作台。 + * 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。 + */ +export const SURFACES = ['design', 'ops', 'workbench'] as const; +export const surfaceSchema = z.enum(SURFACES); +export type Surface = z.infer; + +export const SURFACE_LABEL: Readonly> = { + design: '设计中心', + ops: '运营中心', + workbench: '工作台', +}; + +/** + * 一条路由归哪个端(ADR-012): + * `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。 + * 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。 + */ +export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[]; + /** * 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。 - * body 只在 POST/PATCH 上有意义;GET 用 query。 + * body 只在 POST/PUT/PATCH 上有意义;GET 用 query。 */ export type RouteContract< TParams extends z.ZodTypeAny = z.ZodTypeAny, @@ -21,6 +42,7 @@ export type RouteContract< > = { readonly method: HttpMethod; readonly path: string; + readonly access: RouteAccess; readonly params?: TParams; readonly query?: TQuery; readonly body?: TBody; @@ -32,7 +54,7 @@ export type RouteBody = R['body'] extends z.ZodTypeAny ? z.infer : never; -/** 结构化错误:前端按 code 分辨,不解析文案 */ +/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ export const API_ERROR_CODES = [ 'unauthenticated', 'forbidden', @@ -41,6 +63,7 @@ export const API_ERROR_CODES = [ 'conflict', 'precondition-failed', 'internal', + 'gone', ] as const; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; diff --git a/packages/contracts/src/api/index.ts b/packages/contracts/src/api/index.ts index 69dfaef..297cfdd 100644 --- a/packages/contracts/src/api/index.ts +++ b/packages/contracts/src/api/index.ts @@ -2,3 +2,4 @@ export * from './http.ts'; export * from './workbench.ts'; export * from './governance.ts'; export * from './ingest.ts'; +export * from './mail.ts'; diff --git a/packages/contracts/src/api/ingest.ts b/packages/contracts/src/api/ingest.ts index d320a44..3241557 100644 --- a/packages/contracts/src/api/ingest.ts +++ b/packages/contracts/src/api/ingest.ts @@ -28,6 +28,7 @@ export type InboundMail = z.infer; export const ingestMailRoute = { method: 'POST', path: '/api/ingest/channel/:channelId/mail', + access: 'public', params: z.object({ channelId: channelIdSchema }), body: inboundMailSchema, output: z.object({ diff --git a/packages/contracts/src/api/mail.ts b/packages/contracts/src/api/mail.ts new file mode 100644 index 0000000..f7009d8 --- /dev/null +++ b/packages/contracts/src/api/mail.ts @@ -0,0 +1,179 @@ +/** + * 邮件收发的路由契约(设计稿 §7.2;ADR-013)。 + * + * 读写都归运营中心(设计稿 §7.2 + 2026-08-26 裁决):邮箱那一屏落在运营中心,设计端没有消费方; + * 而入站正文装的是客户来往内容 —— ADR-012 已把「设计端能读客户主数据」记为一处后果,不再无故加宽。 + * `/api/gov/*` 的写路由由 app 中间件自动广播 `{kind:'system', id:'gov'}`。 + * 探测 / 试发 / 拉取的**厂商失败不是接口错误**:200 的 body 里带原话(脱敏)。 + */ +import { z } from 'zod'; +import type { RouteContract } from './http.ts'; +import { + mailAccountInputSchema, + mailAccountViewSchema, + mailDeliveryViewSchema, + mailInboundDetailSchema, + mailInboundSummarySchema, + mailOutboundViewSchema, + mailPollOutcomeSchema, + mailProbeReportSchema, + mailSendResultSchema, +} from '../mail.ts'; + +const accountParams = z.object({ accountId: z.string().min(1) }); +const mailParams = z.object({ mailId: z.string().min(1) }); +const listQuery = z.object({ + accountId: z.string().optional(), + /** 默认 50,上限 200 */ + limit: z.coerce.number().int().min(1).max(200).optional(), +}); + +export const listMailAccountsRoute = { + method: 'GET', + path: '/api/gov/mail/accounts', + access: 'ops', + output: z.object({ accounts: z.array(mailAccountViewSchema) }), +} as const satisfies RouteContract; + +export const createMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts', + access: 'ops', + body: mailAccountInputSchema, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +/** 未存的配置探测 —— 表单上正填的那份,一个字节都不落库 */ +export const probeMailDraftRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/probe', + access: 'ops', + /** 改已存账号时带上 ?accountId=:表单没重填密码就借库里那份密文 */ + query: z.object({ accountId: z.string().optional() }), + body: mailAccountInputSchema, + output: mailProbeReportSchema, +} as const satisfies RouteContract; + +export const getMailAccountRoute = { + method: 'GET', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */ +export const updateMailAccountRoute = { + method: 'PUT', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + body: mailAccountInputSchema, + output: mailAccountViewSchema, +} as const satisfies RouteContract; + +export const deleteMailAccountRoute = { + method: 'DELETE', + path: '/api/gov/mail/accounts/:accountId', + access: 'ops', + params: accountParams, + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 已存账号探测(用库里凭证);凭证空 412 */ +export const probeMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/probe', + access: 'ops', + params: accountParams, + output: mailProbeReportSchema, +} as const satisfies RouteContract; + +/** 立即拉一轮;另一进程占着租约 409;停用 412 */ +export const pollMailAccountRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/poll', + access: 'ops', + params: accountParams, + output: mailPollOutcomeSchema, +} as const satisfies RouteContract; + +export const sendTestMailRoute = { + method: 'POST', + path: '/api/gov/mail/accounts/:accountId/send-test', + access: 'ops', + params: accountParams, + body: z.object({ to: z.email(), subject: z.string().min(1), text: z.string().min(1) }), + output: mailSendResultSchema, +} as const satisfies RouteContract; + +export const listMailInboundRoute = { + method: 'GET', + path: '/api/gov/mail/inbound', + access: 'ops', + query: listQuery, + output: z.object({ mails: z.array(mailInboundSummarySchema) }), +} as const satisfies RouteContract; + +export const getMailInboundRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId', + access: 'ops', + params: mailParams, + output: mailInboundDetailSchema, +} as const satisfies RouteContract; + +/** 附件原件(JSON + base64,与 /api/gov/upload/:id 同形);可带 ?ticket= 下载(ADR-012 §6) */ +export const mailAttachmentRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId/attachments/:index', + access: 'ops', + params: z.object({ mailId: z.string().min(1), index: z.coerce.number().int().min(0) }), + output: z.object({ name: z.string(), contentType: z.string(), dataBase64: z.string() }), +} as const satisfies RouteContract; + +/** 原件 .eml;超限没存 404 */ +export const mailRawRoute = { + method: 'GET', + path: '/api/gov/mail/inbound/:mailId/raw', + access: 'ops', + params: mailParams, + output: z.object({ name: z.string(), contentType: z.literal('message/rfc822'), dataBase64: z.string() }), +} as const satisfies RouteContract; + +/** 人按的钮:重新投给订阅方(缺省全部);从此是至少一次 */ +export const redeliverMailInboundRoute = { + method: 'POST', + path: '/api/gov/mail/inbound/:mailId/redeliver', + access: 'ops', + params: mailParams, + body: z.object({ subscriber: z.string().min(1).optional() }).default({}), + output: z.object({ deliveries: z.array(mailDeliveryViewSchema) }), +} as const satisfies RouteContract; + +export const listMailOutboundRoute = { + method: 'GET', + path: '/api/gov/mail/outbound', + access: 'ops', + query: listQuery, + output: z.object({ mails: z.array(mailOutboundViewSchema) }), +} as const satisfies RouteContract; + +/** 全部 15 条 —— 文档总表与覆盖测试按它数 */ +export const MAIL_ROUTES: readonly RouteContract[] = [ + listMailAccountsRoute, + createMailAccountRoute, + probeMailDraftRoute, + getMailAccountRoute, + updateMailAccountRoute, + deleteMailAccountRoute, + probeMailAccountRoute, + pollMailAccountRoute, + sendTestMailRoute, + listMailInboundRoute, + getMailInboundRoute, + mailAttachmentRoute, + mailRawRoute, + redeliverMailInboundRoute, + listMailOutboundRoute, +]; diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts index 347672b..6bcc2f5 100644 --- a/packages/contracts/src/api/workbench.ts +++ b/packages/contracts/src/api/workbench.ts @@ -9,70 +9,35 @@ import { chatMessageSchema, deliverableSchema, fileDocSchema, formFieldSchema, s import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts'; import { staffIdSchema, taskIdSchema } from '../id.ts'; -/** - * 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) - * - * @deprecated 统一鉴权中心(ADR-012)后已退役(410):改用 `meRoute`。 - */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export const sessionRoute = { method: 'GET', path: '/api/session', + access: 'public', output: sessionSchema, } as const satisfies RouteContract; -/** - * 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。 - * 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。 - * - * @deprecated 统一鉴权中心(ADR-012)后已退役(410):登录由 Keycloak 完成, - * 请求带 `Authorization: Bearer `。 - */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export const loginRoute = { method: 'POST', path: '/api/login', + access: 'public', body: z.object({ account: z.string().min(1), password: z.string().min(1) }), output: sessionSchema, } as const satisfies RouteContract; -/** - * 登出 —— 服务端会话立刻作废,不等 cookie 到期 - * - * @deprecated 统一鉴权中心(ADR-012)后已退役(410): - * 登出 = 前端丢弃 token + Keycloak end-session。 - */ +/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */ export const logoutRoute = { method: 'POST', path: '/api/logout', + access: 'public', output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; -/** - * 我是谁、能进哪几个端(ADR-012)—— access: `any`(登录即可)。 - * - * `ends` 为空也 200:前端据此摆「未授权」页,不落登录页。 - * 副作用:第一次见到这个 `sub` 后端会在名册建档(岗位 NONE 未分配)。 - */ -export const meRoute = { - method: 'GET', - path: '/api/me', - output: principalSchema, -} as const satisfies RouteContract; - -/** - * 换一枚 60 秒票据(ADR-012)—— access: `any`。 - * - * 给 `EventSource` 与 `` 这两条带不了 Authorization 头的路: - * 只在 GET 的 `?ticket=` 上认;票据里没有权限,后端按它对应的 sub 回库现算。 - */ -export const authTicketRoute = { - method: 'GET', - path: '/api/auth/ticket', - output: z.object({ ticket: z.string(), expiresAt: z.string() }), -} as const satisfies RouteContract; - export const healthRoute = { method: 'GET', path: '/api/health', + access: 'public', output: z.object({ ok: z.boolean(), version: z.string(), @@ -80,6 +45,25 @@ export const healthRoute = { }), } as const satisfies RouteContract; +/** 我是谁、我能进哪几个端(ADR-012)—— 中间件解好 principal 放进上下文,这里只回投影 */ +export const meRoute = { + method: 'GET', + path: '/api/me', + access: 'any', + output: principalSchema, +} as const satisfies RouteContract; + +/** + * 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路: + * `EventSource('/api/stream')` 与 ``。GET 上 `?ticket=` 认它。 + */ +export const authTicketRoute = { + method: 'GET', + path: '/api/auth/ticket', + access: 'any', + output: z.object({ ticket: z.string().min(1), expiresAt: z.string() }), +} as const satisfies RouteContract; + /* ── 任务要素:四区(任务列表 / 标题区 / 工作区 / 对话区) ─────────────── */ /** 任务卡 —— 清单里一条 = 一个活动的一次运行,不是一整条工作流 */ @@ -113,6 +97,7 @@ export type TaskCard = z.infer; export const listTasksRoute = { method: 'GET', path: '/api/desk/tasks', + access: 'workbench', output: z.object({ tasks: z.array(taskCardSchema) }), } as const satisfies RouteContract; @@ -265,6 +250,7 @@ export type TaskDetail = z.infer; export const taskDetailRoute = { method: 'GET', path: '/api/desk/tasks/:taskId', + access: 'workbench', params: z.object({ taskId: taskIdSchema }), output: taskDetailSchema, } as const satisfies RouteContract; @@ -273,6 +259,7 @@ export const taskDetailRoute = { export const replyWorkOrderRoute = { method: 'POST', path: '/api/desk/work-orders/reply', + access: 'workbench', body: z.object({ reply: z.record(z.string(), z.unknown()) }), output: z.object({ ok: z.boolean(), producesCompletion: z.boolean() }), } as const satisfies RouteContract; @@ -284,6 +271,7 @@ export const replyWorkOrderRoute = { export const replyChatCardRoute = { method: 'POST', path: '/api/desk/chat-cards/reply', + access: 'workbench', body: z.object({ reply: z.record(z.string(), z.unknown()) }), output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; @@ -295,6 +283,7 @@ export const replyChatCardRoute = { export const markCommReadRoute = { method: 'POST', path: '/api/desk/comms/:commId/read', + access: 'workbench', params: z.object({ commId: z.string() }), body: z.object({ read: z.boolean() }), output: z.object({ ok: z.boolean(), unread: z.boolean() }), @@ -337,6 +326,7 @@ export const markCommReadRoute = { export const saveTableRoute = { method: 'POST', path: '/api/desk/tasks/:taskId/table', + access: 'workbench', params: z.object({ taskId: taskIdSchema }), body: z.object({ /** `{ '域.对象': [ { 台账列名: 值 } ] }` —— 主键列用来认行,不参与改行;台账里没有的行就是**新增** */ @@ -383,6 +373,7 @@ export const saveTableRoute = { export const openAssetFileRoute = { method: 'GET', path: '/api/desk/tasks/:taskId/file', + access: 'workbench', params: z.object({ taskId: taskIdSchema }), query: z.object({ /** `域.对象` —— 必须是活动清单里声明过 `file` 段的那张表(授权面) */ @@ -398,6 +389,7 @@ export const openAssetFileRoute = { export const sayRoute = { method: 'POST', path: '/api/desk/tasks/:taskId/say', + access: 'workbench', params: z.object({ taskId: taskIdSchema }), body: z.object({ text: z.string().min(1) }), output: chatMessageSchema, @@ -438,12 +430,14 @@ export type SystemMessageView = z.infer; export const listMessagesRoute = { method: 'GET', path: '/api/desk/messages', + access: 'workbench', output: z.object({ messages: z.array(systemMessageSchema) }), } as const satisfies RouteContract; export const markMessageReadRoute = { method: 'POST', path: '/api/desk/messages/:messageId/read', + access: 'workbench', params: z.object({ messageId: z.string() }), output: z.object({ ok: z.boolean() }), } as const satisfies RouteContract; @@ -452,6 +446,7 @@ export const markMessageReadRoute = { export const answerMessageRoute = { method: 'POST', path: '/api/desk/messages/:messageId/answer', + access: 'workbench', params: z.object({ messageId: z.string() }), body: z.object({ answer: z.record(z.string(), z.unknown()) }), output: z.object({ ok: z.boolean() }), @@ -461,6 +456,7 @@ export const answerMessageRoute = { export const confirmTriggerPendingRoute = { method: 'POST', path: '/api/desk/trigger-pending/:id/confirm', + access: 'workbench', params: z.object({ id: z.string() }), output: z.object({ workflowId: z.string(), alreadyStarted: z.boolean() }), } as const satisfies RouteContract; @@ -496,12 +492,14 @@ export type MinuteView = z.infer; export const listMinutesRoute = { method: 'GET', path: '/api/minutes', + access: 'workbench', output: z.object({ minutes: z.array(minuteSchema) }), } as const satisfies RouteContract; export const createMinuteRoute = { method: 'POST', path: '/api/minutes', + access: 'workbench', body: z.object({ title: z.string().min(1), body: z.string(), @@ -515,6 +513,7 @@ export const createMinuteRoute = { export const appendResolutionRoute = { method: 'POST', path: '/api/minutes/:minuteId/resolutions', + access: 'workbench', params: z.object({ minuteId: z.string() }), body: z.object({ text: z.string().min(1), ownerId: staffIdSchema.optional() }), output: minuteSchema, @@ -524,6 +523,7 @@ export const appendResolutionRoute = { export const registerResolutionRoute = { method: 'POST', path: '/api/minutes/resolutions/:resolutionId/register', + access: 'workbench', params: z.object({ resolutionId: z.string() }), body: z.object({ processPackageId: z.string().min(1), data: z.record(z.string(), z.unknown()) }), output: z.object({ workflowId: z.string(), resolutionId: z.string() }), @@ -578,6 +578,7 @@ export type ReportView = z.infer; export const draftReportRoute = { method: 'GET', path: '/api/reports/:period', + access: 'workbench', params: z.object({ period: reportPeriodSchema }), output: reportSchema, } as const satisfies RouteContract; @@ -585,6 +586,7 @@ export const draftReportRoute = { export const listReportsRoute = { method: 'GET', path: '/api/reports', + access: 'workbench', output: z.object({ reports: z.array(reportSchema) }), } as const satisfies RouteContract; @@ -592,6 +594,7 @@ export const listReportsRoute = { export const submitReportRoute = { method: 'POST', path: '/api/reports/:reportId/submit', + access: 'workbench', params: z.object({ reportId: z.string() }), body: z.object({ note: z.string().optional() }), output: reportSchema, @@ -621,6 +624,7 @@ export type ApprovalItem = z.infer; export const listApprovalsForMeRoute = { method: 'GET', path: '/api/approvals', + access: 'workbench', output: z.object({ approvals: z.array(approvalItemSchema) }), } as const satisfies RouteContract; @@ -628,6 +632,7 @@ export const listApprovalsForMeRoute = { export const decideApprovalRoute = { method: 'POST', path: '/api/approvals/:workflowId/:nodeId/decide', + access: 'workbench', params: z.object({ workflowId: z.string(), nodeId: z.string() }), body: z.object({ verdict: z.enum(['pass', 'reject']), comment: z.string().optional() }), output: z.object({ ok: z.boolean(), workflowState: workflowStateSchema }), @@ -644,6 +649,7 @@ export type ListApprovalsForMeOutput = z.infer; +export const MAIL_ACCOUNT_KIND_LABEL: Readonly> = { + 'imap-smtp': '标准协议(IMAP/SMTP)', + wecom: '企业微信邮件 API(应用邮箱)', +}; + +/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */ +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), + /** true = 隐式 TLS(993);false = 明文(不建议) */ + 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), + /** true = 隐式 TLS(465);false = STARTTLS(587 / 25) */ + smtpTls: z.boolean().default(true), + /** 登录名;缺省 = address */ + login: z.string().min(1).optional(), + folder: z.string().min(1).default('INBOX'), +}); +export type ImapSmtpSettings = z.infer; + +/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */ +export const wecomSettingsSchema = z.object({ + corpId: z.string().min(1), + apiBase: z.url().default('https://qyapi.weixin.qq.com'), +}); +export type WecomSettings = z.infer; + +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), + /** 轮询间隔(秒);下限 10 —— 腾讯没有推送,只能拉 */ + pollIntervalSec: z.number().int().min(10).max(3600).default(30), + /** 入站交给哪个通道;空 = channel 订阅方 skipped */ + 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.email(), + ...accountCommon, + settings: imapSmtpSettingsSchema.prefault({}), + /** 只写不读:缺省 = 不改(与 LLM 密钥同款) */ + 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(), + }), +]); +export type MailAccountInput = z.infer; + +export const mailAccountStatusSchema = z.object({ + lastPolledAt: z.iso.datetime().nullable(), + /** 厂商原话(已脱敏);空 = 上一轮没出错 */ + lastPollError: z.string().nullable(), + lastInboundAt: z.iso.datetime().nullable(), + /** 游标的人读摘要;还没拉过 = null */ + cursor: z.string().nullable(), + /** 此刻租约在手(某个进程正在拉) */ + polling: z.boolean(), +}); + +const viewCommon = { + id: z.string(), + name: z.string(), + /** imap-smtp:登录邮箱;wecom:应用邮箱(探测回填前是空串,前端摆「待探测」) */ + address: z.string(), + displayName: z.string(), + enabled: z.boolean(), + isDefault: z.boolean(), + pollIntervalSec: z.number().int(), + channelId: z.string().nullable(), + backfillFrom: z.iso.datetime().nullable(), + secretBound: z.boolean(), + status: mailAccountStatusSchema, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}; +export const mailAccountViewSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('imap-smtp'), ...viewCommon, settings: imapSmtpSettingsSchema }), + z.object({ kind: z.literal('wecom'), ...viewCommon, settings: wecomSettingsSchema }), +]); +export type MailAccountView = z.infer; + +/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */ +export const mailProbeReportSchema = z.object({ + kind: mailAccountKindSchema, + address: z.string().optional(), + receive: z.object({ ok: z.boolean(), error: z.string().optional(), latencyMs: z.number().int().optional() }), + send: z.object({ + checked: z.boolean(), + ok: z.boolean().optional(), + error: z.string().optional(), + note: z.string().optional(), + }), +}); +export type MailProbeReport = z.infer; + +export const MAIL_DELIVERY_STATES = ['dispatching', 'done', 'failed', 'skipped'] as const; +export const mailDeliveryStateSchema = z.enum(MAIL_DELIVERY_STATES); +export type MailDeliveryState = z.infer; + +export const mailDeliveryViewSchema = z.object({ + subscriber: z.string(), + state: mailDeliveryStateSchema, + error: z.string().nullable(), + startedAt: z.iso.datetime(), + finishedAt: z.iso.datetime().nullable(), +}); +export type MailDeliveryView = z.infer; + +export const mailAddressSchema = z.object({ address: z.string(), name: z.string() }); +export type MailAddress = z.infer; + +export const mailInboundAttachmentSchema = z.object({ + index: z.number().int(), + name: z.string(), + contentType: z.string(), + size: z.number().int(), + /** `sys.upload` 的 id;超限没存 = null,理由在 note */ + ref: z.string().nullable(), + note: z.string(), +}); +export type MailInboundAttachment = z.infer; + +export const mailInboundSummarySchema = z.object({ + id: z.string(), + accountId: z.string(), + messageId: z.string(), + from: mailAddressSchema, + to: z.array(mailAddressSchema), + cc: z.array(mailAddressSchema), + subject: z.string(), + sentAt: z.iso.datetime().nullable(), + receivedAt: z.iso.datetime(), + /** 正文前 200 字 */ + textPreview: z.string(), + hasHtml: z.boolean(), + attachments: z.array(mailInboundAttachmentSchema), + deliveries: z.array(mailDeliveryViewSchema), +}); +export type MailInboundSummary = z.infer; + +export const mailInboundDetailSchema = mailInboundSummarySchema.extend({ + inReplyTo: z.string().nullable(), + references: z.array(z.string()), + text: z.string().nullable(), + html: z.string().nullable(), + size: z.number().int(), + /** 原件 .eml 存下来了(≤16MB) */ + rawAvailable: z.boolean(), +}); +export type MailInboundDetail = z.infer; + +export const mailSendInputSchema = z.object({ + /** 指定账号;缺省用默认账号 */ + accountId: z.string().optional(), + /** 指定发件地址(channel 用):必须对上一个已启用账号,不静默回落 */ + from: z.string().optional(), + to: z.array(z.email()).min(1), + cc: z.array(z.email()).default([]), + bcc: z.array(z.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'), + /** 二选一:现给字节,或引用 sys.upload 的 id */ + 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(), + }), +}); +export type MailSendInput = z.infer; + +export const MAIL_OUTBOUND_STATES = ['sending', 'sent', 'failed'] as const; +export const mailOutboundStateSchema = z.enum(MAIL_OUTBOUND_STATES); +export type MailOutboundState = z.infer; + +export const mailSendResultSchema = z.object({ + outboundId: z.string(), + accountId: z.string(), + messageId: z.string(), + state: z.enum(['sent', 'failed']), + error: z.string().optional(), + providerRef: z.string().optional(), +}); +export type MailSendResult = z.infer; + +export const mailOutboundViewSchema = z.object({ + id: z.string(), + accountId: z.string(), + messageId: z.string(), + to: z.array(z.string()), + cc: z.array(z.string()), + bcc: z.array(z.string()), + subject: z.string(), + state: mailOutboundStateSchema, + error: z.string().nullable(), + providerRef: z.string().nullable(), + requestedBy: z.record(z.string(), z.unknown()), + createdAt: z.iso.datetime(), + sentAt: z.iso.datetime().nullable(), +}); +export type MailOutboundView = z.infer; + +export const mailPollOutcomeSchema = z.object({ + accountId: z.string(), + /** provider 交出的封数 */ + pulled: z.number().int(), + /** 真新入库的封数(去重后) */ + stored: z.number().int(), + dispatched: z.number().int(), + /** 预算用尽还有余下,下一 tick 接着 */ + more: z.boolean(), + error: z.string().optional(), +}); +export type MailPollOutcome = z.infer; diff --git a/packages/contracts/src/org.ts b/packages/contracts/src/org.ts index 3537609..e53c340 100644 --- a/packages/contracts/src/org.ts +++ b/packages/contracts/src/org.ts @@ -1,12 +1,13 @@ /** - * 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。 + * 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。 * - * 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。 - * 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。 - * 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。 + * 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench), + * 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。 + * 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。 */ import { z } from 'zod'; import { positionIdSchema, staffIdSchema } from './id.ts'; +import { surfaceSchema } from './api/http.ts'; export const positionSchema = z.object({ id: positionIdSchema, @@ -21,27 +22,90 @@ export const staffStateSchema = z.enum(STAFF_STATES); export type StaffState = z.infer; export const staffSchema = z.object({ + /** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */ id: staffIdSchema, + /** = `preferred_username`,只作显示 —— 用户名允许改,别拿它当键 */ account: z.string().min(1), + /** = `name` claim,缺则回退 account */ name: z.string().min(1), + email: z.string().optional(), + /** 未指派 = 哨兵岗 `NONE / 未分配` 的 id */ positionId: positionIdSchema, + /** 只由名册同步置 `disabled`(Keycloak 停用或删除);有效 token 不改它 */ state: staffStateSchema.default('active'), + /** realm 角色快照(token 签发时;噪声角色已滤) */ + roles: z.array(z.string()).default([]), }); export type Staff = z.infer; -/* ── 岗位授权:客体三类 × 动作三种 ─────────────────────────────────── */ +/* ── 我是谁、能进哪几个端(ADR-012) ───────────────────────────────── */ +export const principalSchema = z.object({ + staff: staffSchema, + position: positionSchema, + roles: z.array(z.string()), + ends: z.array(surfaceSchema), +}); +export type Principal = z.infer; + +/** 角色 → 端:一行一个 realm 角色 */ +export const roleAccessSchema = z.object({ + role: z.string().min(1), + ends: z.array(surfaceSchema), +}); +export type RoleAccess = z.infer; + +/** + * 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。 + * 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数, + * 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。 + */ +export const rosterSyncOutcomeSchema = z.discriminatedUnion('configured', [ + z.object({ configured: z.literal(false), message: z.string() }), + z.object({ + configured: z.literal(true), + inserted: z.number().int().min(0), + updated: z.number().int().min(0), + disabled: z.number().int().min(0), + total: z.number().int().min(0), + /** 逐条落库时失败的人:账号 + 原话(厂商原文照抄,不改写成「操作失败」) */ + failed: z.array(z.object({ account: z.string(), reason: z.string() })), + }), +]); +export type RosterSyncOutcome = z.infer; + +/* ── 在途工作(只读;OS 02:改派先于停用,停用本身已归鉴权中心) ───── */ + +export const inFlightWorkSchema = z.object({ + staffId: staffIdSchema, + blocked: z.boolean(), + openTasks: z.array(z.object({ taskId: z.string(), title: z.string() })).default([]), + openCards: z.array(z.object({ cardId: z.string(), title: z.string() })).default([]), + openApprovals: z.array(z.object({ approvalId: z.string(), title: z.string() })).default([]), +}); +export type InFlightWork = z.infer; + +/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */ +export const disableCheckSchema = inFlightWorkSchema; +/** @deprecated 改用 `InFlightWork` */ +export type DisableCheck = InFlightWork; + +/* ── 岗位授权(已退役,ADR-012;类型留一轮给 web 编译,下个迭代删) ──── */ + +/** @deprecated 权限只到端,动作三分已退役 */ export const GRANT_ACTIONS = ['read', 'write', 'govern'] as const; +/** @deprecated */ export const grantActionSchema = z.enum(GRANT_ACTIONS); +/** @deprecated */ export type GrantAction = z.infer; - +/** @deprecated */ export const GRANT_ACTION_LABEL: Readonly> = { read: '读', write: '写', govern: '治理', }; -/** 治理台九功能 —— 授权客体之一 */ +/** @deprecated 治理台九功能不再是授权客体 */ export const GOVERNANCE_FUNCTIONS = [ 'task', 'staff', @@ -53,9 +117,11 @@ export const GOVERNANCE_FUNCTIONS = [ 'memory', 'system', ] as const; +/** @deprecated */ export const governanceFunctionSchema = z.enum(GOVERNANCE_FUNCTIONS); +/** @deprecated */ export type GovernanceFunction = z.infer; - +/** @deprecated */ export const GOVERNANCE_FUNCTION_LABEL: Readonly> = { task: '任务管理', staff: '员工管理', @@ -68,6 +134,7 @@ export const GOVERNANCE_FUNCTION_LABEL: Readonly; +/** @deprecated */ export const grantSchema = z.object({ positionId: positionIdSchema, object: grantObjectSchema, action: grantActionSchema, }); +/** @deprecated */ export type Grant = z.infer; -/* ── 停用前置检查(OS 02) ─────────────────────────────────────────── */ +/* ── 会话(已退役:身份在 token 里,ADR-012) ──────────────────────── */ -/** 员工停用前,其在途任务与待答卡须先改派完毕 */ -export const disableCheckSchema = z.object({ - staffId: staffIdSchema, - blocked: z.boolean(), - openTasks: z.array(z.object({ taskId: z.string(), title: z.string() })).default([]), - openCards: z.array(z.object({ cardId: z.string(), title: z.string() })).default([]), - openApprovals: z.array(z.object({ approvalId: z.string(), title: z.string() })).default([]), -}); -export type DisableCheck = z.infer; - -/* ── 会话 ─────────────────────────────────────────────────────────────── */ - -/** - * @deprecated 统一鉴权中心(ADR-012)后退役:`GET /api/session` 已 410, - * 改用 `principalSchema`(`GET /api/me`)。本仓库 apps/api 仍是旧后端,契约暂留。 - */ +/** @deprecated 改用 `principalSchema` / `GET /api/me` */ export const sessionSchema = z.object({ staff: staffSchema, position: positionSchema, grants: z.array(grantSchema), }); +/** @deprecated 改用 `Principal` */ export type Session = z.infer; -/* ── 统一鉴权中心(ADR-012,2026-08-26)───────────────────────────────── */ - -/** 三个端(台面):权限只到端,端内接口对能进的人全开 */ -export const SURFACES = ['design', 'ops', 'workbench'] as const; -export const surfaceSchema = z.enum(SURFACES); -export type Surface = z.infer; - -/** - * 「我是谁、能进哪几个端」—— `GET /api/me` 的出参。 - * - * `staff.id` 就是 Keycloak 的 `sub`(UUID);`roles` 是 realm 角色快照 - * (噪声角色已被后端滤掉);`ends` 是「角色 → 端」表的并集,可以为空 — - * 空也 200,前端据此摆「未授权」页而不是登录页。 - */ -export const principalSchema = z.object({ - staff: staffSchema.extend({ - email: z.string().optional(), - roles: z.array(z.string()).default([]), - }), - position: positionSchema, - roles: z.array(z.string()), - ends: z.array(surfaceSchema), -}); -export type Principal = z.infer; - /* ── 工作组(ADR-011) ────────────────────────────────────────────────── */ /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b5a577..14451ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,16 +96,31 @@ importers: hono: specifier: ^4.12.32 version: 4.12.32 + imapflow: + specifier: ^1.7.6 + version: 1.7.6 + jose: + specifier: ^5.10.0 + version: 5.10.0 + nodemailer: + specifier: ^8.0.11 + version: 8.0.11 pg: specifier: ^8.22.0 version: 8.22.0 pg-boss: specifier: ^12.26.3 version: 12.26.3 + postal-mime: + specifier: ^3.0.0 + version: 3.0.0 zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -126,6 +141,8 @@ importers: apps/api/src/modules/loop-engine: {} + apps/api/src/modules/mail: {} + apps/api/src/modules/memory: {} apps/api/src/modules/message: {} @@ -1186,6 +1203,9 @@ packages: '@cfworker/json-schema': optional: true + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1462,6 +1482,9 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -1589,6 +1612,9 @@ packages: '@workflow/serde@4.1.0-beta.2': resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + '@zone-eu/mailsplit@5.4.15': + resolution: {integrity: sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1684,6 +1710,10 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -2155,6 +2185,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2499,6 +2533,9 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} + imapflow@1.7.6: + resolution: {integrity: sha512-xah3HpJKHzNbO780jy+v/Gjmly9WBgPH2dl5/nNs+Q5ofjpn4bUTz0PR2kcrHGlBRsjGFOC7NTYkStakGN8Fyg==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2596,6 +2633,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.4: resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} @@ -2683,6 +2723,15 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.4.2: + resolution: {integrity: sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2948,6 +2997,10 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + nodemailer@8.0.11: + resolution: {integrity: sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==} + engines: {node: '>=6.0.0'} + non-error@0.1.0: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} engines: {node: '>=20'} @@ -2971,6 +3024,10 @@ packages: object-refs@0.4.0: resolution: {integrity: sha512-6kJqKWryKZmtte6QYvouas0/EIJKPI1/MMIuRsiBlNuhIMfqYTggzX2F1AJ2+cDs288xyi9GL7FyasHINR98BQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3095,6 +3152,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3106,6 +3173,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + postal-mime@3.0.0: + resolution: {integrity: sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -3172,6 +3242,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -3206,6 +3279,9 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -3245,6 +3321,13 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -3315,6 +3398,10 @@ packages: safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -3403,6 +3490,17 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3545,6 +3643,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tiny-svg@4.1.4: resolution: {integrity: sha512-cBaEACCbouYrQc9RG+eTXnPYosX1Ijqty/I6DdXovwDd89Pwu4jcmpOR7BuFEF9YCcd7/AWwasE0207WMK7hdw==} engines: {node: '>= 20'} @@ -4711,6 +4813,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4936,6 +5040,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 22.20.1 + '@types/pg@8.20.0': dependencies: '@types/node': 22.20.1 @@ -5116,6 +5224,12 @@ snapshots: '@workflow/serde@4.1.0-beta.2': {} + '@zone-eu/mailsplit@5.4.15': + dependencies: + libbase64: 1.3.0 + libmime: 5.4.2 + libqp: 2.1.1 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5218,6 +5332,8 @@ snapshots: async@3.2.6: {} + atomic-sleep@1.0.0: {} + b4a@1.8.1: {} bail@2.0.2: {} @@ -5603,6 +5719,8 @@ snapshots: encodeurl@2.0.0: {} + encoding-japanese@2.2.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -6074,6 +6192,17 @@ snapshots: ignore@7.0.6: {} + imapflow@1.7.6: + dependencies: + '@zone-eu/mailsplit': 5.4.15 + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libmime: 5.4.2 + libqp: 2.1.1 + pino: 10.3.1 + socks: 2.8.9 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -6140,6 +6269,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@5.10.0: {} + jose@6.2.4: {} joycon@3.1.1: {} @@ -6222,6 +6353,17 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libbase64@1.3.0: {} + + libmime@5.4.2: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -6630,6 +6772,8 @@ snapshots: node-releases@2.0.51: {} + nodemailer@8.0.11: {} + non-error@0.1.0: {} normalize-path@3.0.0: {} @@ -6645,6 +6789,8 @@ snapshots: object-refs@0.4.0: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -6758,6 +6904,26 @@ snapshots: picomatch@4.0.5: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} pkce-challenge@5.0.1: {} @@ -6768,6 +6934,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + postal-mime@3.0.0: {} + postcss-load-config@6.0.1(postcss@8.5.23)(tsx@4.23.1)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 @@ -6806,6 +6974,8 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@5.1.0: {} + process@0.11.10: {} prompts@2.4.2: @@ -6857,6 +7027,8 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + quick-format-unescaped@4.0.4: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -6905,6 +7077,10 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + rechoir@0.8.0: dependencies: resolve: 1.22.12 @@ -7009,6 +7185,8 @@ snapshots: dependencies: regexp-tree: 0.1.27 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -7108,6 +7286,17 @@ snapshots: sisteransi@1.0.5: {} + smart-buffer@4.2.0: {} + + socks@2.8.9: + dependencies: + ip-address: 10.3.1 + smart-buffer: 4.2.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -7299,6 +7488,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tiny-svg@4.1.4: {} tinybench@2.9.0: {} diff --git a/tools/cd.mjs b/tools/cd.mjs new file mode 100644 index 0000000..4d2585a --- /dev/null +++ b/tools/cd.mjs @@ -0,0 +1,486 @@ +#!/usr/bin/env node +/** + * CD 的八件小事(只在 Gitea Actions 的部署段用,本地开发不需要): + * + * preflight 配置齐不齐、密钥换没换、外部命令在不在、目录写不写得动 + * backup pg_dump 生产库(迁移没有 down,这是唯一的回滚手段) + * db-ensure 生产库不在就建 + 灌 db/init + * migrate-verify 已应用迁移条数 == journal 条数 + * activities-plan 判「首次播种 / 装活动包 / 什么都不做 / 当场红」 + * activities-verify 回查 sys.activity_package + * health 轮询 /api/health,**验 body** + * prune releases 只留最近几版 + * + * 三条设计律,每条都对着一个「信号全绿而事情没办成」的真实机制: + * ① `install-activities` 的所有失败路径都是「打印一行然后 continue」,**永远 exit 0** + * —— 所以装完必须回查库,不能信它的退出码(activities-verify)。 + * ② 活动包版本没升时 `savePackage` 是 onConflictDoNothing,新代码静默丢失而日志照样说成功 + * —— 所以版本没变但产物内容变了要**当场红**(activities-plan)。 + * ③ `/api/health` 在库挂掉时**照样返 200**,只是 body 里 `db:'down'` + * —— 所以探活验 body 不验状态码(health)。 + * + * 不新增依赖:pg 是根 devDependency(§12.5#3);共用的连库与灌 init 从 ci.mjs 取,不另抄一份。 + */ +import { createHash } from 'node:crypto'; +import { spawn, spawnSync } from 'node:child_process'; +import { + accessSync, + appendFileSync, + constants, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import { applyInit, connect, die, withDatabase } from './ci.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +/** 路径类配置(Gitea Variables)与机密(Gitea Secrets)—— 少一条就别往下走 */ +const VARS = ['DEPLOY_DIR', 'PM2_APP', 'WEB_ROOT']; +const SECRETS = ['PROD_DATABASE_URL', 'JCAI_SECRET_KEY', 'JCAI_SEED_PASSWORD', 'JCAI_DEEPSEEK_API_KEY']; + +function need(name) { + const value = process.env[name]; + if (value === undefined || value.trim() === '') die(`缺 ${name} —— 在 Gitea 仓库设置里补上再部署`); + return value; +} + +function emit(line) { + process.stdout.write(`${line}\n`); +} + +/** 写进 $GITHUB_ENV 供后续步骤读;本地跑没有这个文件就把该导的那行摆出来 */ +function exportEnv(key, value) { + const file = process.env['GITHUB_ENV']; + if (file === undefined) { + emit(`export ${key}='${value}'`); + return; + } + appendFileSync(file, `${key}=${value}\n`); +} + +/** + * 命令在不在 PATH 上 —— 借 shell 的 `command -v`,别自己拼 PATH 去猜。 + * 走 `sh -c` 而不是 `{ shell: true }`:后者在 node 22 上每次都吐一条 + * 「传 args 给 shell 有安全风险」的弃用告警,而 CI 日志里那种噪音会让人不信别的话。 + * 参数来自本文件里写死的名单,不接外部输入。 + */ +function which(command) { + return spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }).status === 0; +} + +/* ── 生产库地址 ─────────────────────────────────────────────────────── */ + +function prodUrl() { + return need('PROD_DATABASE_URL'); +} + +function adminUrlOf(url) { + return withDatabase(url, 'postgres'); +} + +function databaseNameOf(url) { + return new URL(url).pathname.replace(/^\//, ''); +} + +async function databaseExists(url) { + const admin = await connect(adminUrlOf(url)); + try { + const found = await admin.query('select 1 from pg_database where datname = $1', [databaseNameOf(url)]); + return found.rowCount > 0; + } finally { + await admin.end(); + } +} + +/* ── preflight ─────────────────────────────────────────────────────── */ + +async function preflight() { + const missing = [...VARS, ...SECRETS].filter((name) => { + const value = process.env[name]; + return value === undefined || value.trim() === ''; + }); + if (missing.length > 0) { + die( + `以下配置还没设(一次说全,免得你来回补):\n` + + missing.map((name) => ` · ${name}${VARS.includes(name) ? '(Variables)' : '(Secrets)'}`).join('\n'), + ); + } + + const key = Buffer.from(need('JCAI_SECRET_KEY'), 'base64'); + if (key.length !== 32) { + die(`JCAI_SECRET_KEY 必须是 32 字节的 base64(现在解出来 ${key.length} 字节)—— openssl rand -base64 32`); + } + + const deployDir = need('DEPLOY_DIR'); + const webRoot = need('WEB_ROOT'); + mkdirSync(join(deployDir, 'releases'), { recursive: true }); + mkdirSync(join(deployDir, 'backups'), { recursive: true }); + mkdirSync(join(deployDir, 'data', 'attachments'), { recursive: true }); + for (const [label, path] of [['DEPLOY_DIR', deployDir], ['WEB_ROOT', webRoot]]) { + if (!existsSync(path)) die(`${label} 不存在:${path} —— 先在主机上建好并让 runner 用户写得动`); + try { + accessSync(path, constants.W_OK); + } catch { + die(`${label} 写不动:${path} —— runner 用户没有写权限(nginx 的目录常常是 root 的)`); + } + } + + const tools = ['pm2', 'rsync'].filter((command) => !which(command)); + if (tools.length > 0) { + die( + `这几个命令不在 runner 用户的 PATH 上:${tools.join(' / ')}\n` + + '(pm2 常装在某个 node 版本管理器的 shim 里,而非交互 shell 读不到 ~/.zshrc)', + ); + } + if (!which('pg_dump') && !(which('docker') && (process.env['PG_CONTAINER'] ?? '') !== '')) { + die( + '备份没有着落:PATH 上没有 pg_dump,也没给 PG_CONTAINER(库在容器里时用它 docker exec)。\n' + + '你选的是「自动跑迁移,先备份」—— 而迁移没有 down,备份是唯一的回滚手段,不能悄悄跳过。', + ); + } + + // 密钥指纹:换了这把钥匙 = 库里所有凭证密文全部解不开,而且不在启动时炸,是第一次真调模型才炸 + const fingerprint = createHash('sha256').update(key).digest('hex').slice(0, 16); + const marker = join(deployDir, '.secret-fingerprint'); + if (existsSync(marker)) { + const known = readFileSync(marker, 'utf8').trim(); + if (known !== fingerprint) { + die( + `JCAI_SECRET_KEY 与上一次部署的不是同一把(${known} → ${fingerprint})。\n` + + 'sys.llm_instance.api_key_cipher 与 sys.tool.connection_cipher 都是用它封的 ——\n' + + '换了就全部解不开,而且要等第一次真调模型才炸。\n' + + `确实要换,先把 ${marker} 删掉,并准备重配所有凭证。`, + ); + } + } else { + writeFileSync(marker, `${fingerprint}\n`, 'utf8'); + emit(`记下密钥指纹:${fingerprint}(以后每次部署都比对)`); + } + + emit('部署前置检查通过 ✔'); +} + +/* ── backup ────────────────────────────────────────────────────────── */ + +async function backup() { + const url = prodUrl(); + if (!(await databaseExists(url))) { + emit(`生产库 ${databaseNameOf(url)} 还不存在 —— 这是首次部署,没有东西可备份,跳过`); + return; + } + + const deployDir = need('DEPLOY_DIR'); + const dir = join(deployDir, 'backups'); + mkdirSync(dir, { recursive: true }); + const parsed = new URL(url); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const sha = (process.env['GITHUB_SHA'] ?? 'local').slice(0, 8); + const file = join(dir, `${stamp}-${sha}.sql.gz`); + + const database = databaseNameOf(url); + const user = decodeURIComponent(parsed.username); + const env = { ...process.env, PGPASSWORD: decodeURIComponent(parsed.password) }; + + // 口令一律走环境,不进 argv(argv 在 ps 里对同机其他用户可见) + const [command, args] = + which('pg_dump') + ? ['pg_dump', ['-h', parsed.hostname, '-p', parsed.port === '' ? '5432' : parsed.port, '-U', user, '-d', database, '--no-owner', '--no-privileges']] + : ['docker', ['exec', '-e', 'PGPASSWORD', '-i', need('PG_CONTAINER'), 'pg_dump', '-U', user, '-d', database, '--no-owner', '--no-privileges']]; + + const child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + const finished = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${command} 退出码 ${code}\n${stderr}`)))); + }); + + try { + await Promise.all([pipeline(child.stdout, createGzip(), createWriteStream(file)), finished]); + } catch (error) { + die(`备份失败:${error instanceof Error ? error.message : String(error)}`); + } + + emit(`已备份 → ${file}(${(statSync(file).size / 1024).toFixed(0)} KB)`); + keepNewest(dir, Number(process.env['KEEP_BACKUPS'] ?? 10), '备份'); +} + +/* ── db-ensure ─────────────────────────────────────────────────────── */ + +async function dbEnsure() { + const url = prodUrl(); + const name = databaseNameOf(url); + if (await databaseExists(url)) { + emit(`生产库 ${name} 已在 ✔`); + return; + } + const admin = await connect(adminUrlOf(url)); + try { + await admin.query(`create database "${name}"`); + } catch (error) { + die( + `建库失败:${error instanceof Error ? error.message : String(error)}\n` + + 'PROD_DATABASE_URL 里那个账号需要 CREATEDB 权限,或者你先手工把库建好。', + ); + } finally { + await admin.end(); + } + emit(`已建生产库 ${name}`); + // 空库不灌 db/init 迁不动:0026 往 ledger.__object_meta 建表,而迁移链只会替自己建 sys + await applyInit(url); +} + +/* ── migrate-verify ────────────────────────────────────────────────── */ + +async function migrateVerify() { + const journal = JSON.parse(readFileSync(join(root, 'db', 'migrations', 'meta', '_journal.json'), 'utf8')); + const expected = Array.isArray(journal.entries) ? journal.entries.length : 0; + const client = await connect(prodUrl()); + let applied; + try { + const result = await client.query('select count(*)::int as n from sys.__drizzle_migrations'); + applied = result.rows[0].n; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + // 记账表不存在 = 迁移那一步压根没跑成(而它可能仍然打印了「完成」并 exit 0) + if (detail.includes('__drizzle_migrations')) { + die('库里连 sys.__drizzle_migrations 都没有 —— 迁移那一步根本没跑成。先看上一步的输出。'); + } + die(`查迁移记账失败:${detail}`); + } finally { + await client.end(); + } + if (applied !== expected) { + die( + `迁移没到位:库里 ${applied} 条,db/migrations 里 ${expected} 条。\n` + + 'drizzle 只比「最后一条的时间戳」当水位线,不逐条比 hash ——\n' + + '新迁移在 _journal.json 里的 when 若 ≤ 水位线,会被永久静默跳过,而 migrate 照样打印「完成」。\n' + + '查:select max(created_at) from sys.__drizzle_migrations,与新增那条的 when 比一比。', + ); + } + emit(`迁移到位 ✔ ${applied}/${expected} 条`); +} + +/* ── 活动包 ────────────────────────────────────────────────────────── */ + +/** 装哪几个包是 seed-activities.ts 说了算 —— 读它,不在这儿另存一份名单 */ +function realPackages() { + const source = readFileSync(join(root, 'apps', 'api', 'src', 'app', 'seed-activities.ts'), 'utf8'); + const matched = /const REAL_PACKAGES = \[([^\]]*)\]/.exec(source); + if (matched === null) { + die('读不出 REAL_PACKAGES —— seed-activities.ts 的写法变了,来改 tools/cd.mjs(别让它拿一份过期名单继续跑)'); + } + const names = [...matched[1].matchAll(/'([^']+)'/g)].map((one) => one[1]); + if (names.length === 0) die('REAL_PACKAGES 是空的 —— 那就没有活动包要装,确认这是不是你要的'); + return names; +} + +function distOf(pkg) { + const dir = join(root, 'activities', pkg, 'dist'); + const manifestPath = join(dir, 'manifest.json'); + const entryPath = join(dir, 'index.js'); + if (!existsSync(manifestPath) || !existsSync(entryPath)) { + die(`活动包 ${pkg} 没有构建产物(${dir})—— 先跑 pnpm activities:build`); + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + return { + activityId: manifest.identity.activityId, + version: manifest.identity.packageVersion, + artifact: readFileSync(entryPath, 'utf8'), + }; +} + +async function activitiesPlan() { + const client = await connect(prodUrl()); + let staffCount; + const decisions = []; + try { + const staff = await client.query('select count(*)::int as n from sys.staff'); + staffCount = staff.rows[0].n; + + for (const pkg of realPackages()) { + const dist = distOf(pkg); + const row = await client.query( + 'select artifact from sys.activity_package where activity_id = $1 and version = $2', + [dist.activityId, dist.version], + ); + if (row.rowCount === 0) decisions.push({ pkg, dist, verdict: 'install' }); + else if (row.rows[0].artifact === dist.artifact) decisions.push({ pkg, dist, verdict: 'skip' }); + else decisions.push({ pkg, dist, verdict: 'conflict' }); + } + } finally { + await client.end(); + } + + const conflicts = decisions.filter((one) => one.verdict === 'conflict'); + if (conflicts.length > 0) { + die( + `这几个活动包**版本没升但内容变了**:\n` + + conflicts.map((one) => ` · ${one.pkg}@${one.dist.version}`).join('\n') + + '\n安装恒新增:同版本重装是 no-op(onConflictDoNothing),库里还是旧产物,\n' + + '而日志会照样打印「活动包已装」、退出码 0 —— 新代码静默不上线。\n' + + '改了内容就升 manifest 里的 packageVersion(CLAUDE.md 铁规矩 7)。', + ); + } + + const first = staffCount === 0; + const toInstall = decisions.filter((one) => one.verdict === 'install'); + + if (first) { + emit('库里一个员工都没有 → 判为**首次部署**,走 seed.js 播一次种(此后永不再跑 seed)'); + exportEnv('FIRST_DEPLOY', '1'); + exportEnv('ACTIVITIES_INSTALL', '0'); + return; + } + + exportEnv('FIRST_DEPLOY', '0'); + exportEnv('ACTIVITIES_INSTALL', toInstall.length > 0 ? '1' : '0'); + if (toInstall.length === 0) { + emit('活动包版本与库里一致、内容也一致 → 不装(顺带避开它会覆盖样例主数据、并发出 data 事件)'); + return; + } + emit(`要装的活动包:${toInstall.map((one) => `${one.pkg}@${one.dist.version}`).join(' / ')}`); +} + +async function activitiesVerify() { + const client = await connect(prodUrl()); + const bad = []; + try { + for (const pkg of realPackages()) { + const dist = distOf(pkg); + const row = await client.query( + 'select artifact from sys.activity_package where activity_id = $1 and version = $2', + [dist.activityId, dist.version], + ); + if (row.rowCount === 0) bad.push(`${pkg}@${dist.version}:库里根本没有这一版`); + else if (row.rows[0].artifact !== dist.artifact) bad.push(`${pkg}@${dist.version}:库里那一版的产物与刚构建的不一致`); + else emit(` ✔ ${pkg}@${dist.version}`); + } + } finally { + await client.end(); + } + if (bad.length > 0) { + die( + `活动包没装成:\n${bad.map((one) => ` · ${one}`).join('\n')}\n` + + 'install-activities 的所有失败路径都是「打印一行然后 continue」,**永远 exit 0** ——\n' + + '所以这道回查才是唯一说得出实情的地方。看它的日志里有没有「本次跳过」。', + ); + } + emit('活动包已在库里 ✔'); +} + +/* ── health ────────────────────────────────────────────────────────── */ + +async function health() { + const url = process.env['HEALTH_URL'] ?? `http://127.0.0.1:${process.env['PORT'] ?? '8787'}/api/health`; + const deadline = Date.now() + Number(process.env['HEALTH_TIMEOUT_MS'] ?? 60_000); + let last = '还没连上'; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + const body = await response.json(); + // 库挂了它照样返 200,只是 body 里 db:'down' —— 所以验 body,不验状态码 + if (body.ok === true && body.db === 'up') { + emit(`探活通过 ✔ ${url} → ok=true db=up version=${body.version}`); + return; + } + last = `HTTP ${response.status} ${JSON.stringify(body)}`; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + die( + `探活没过:${url}\n最后一次看到的是:${last}\n` + + '两种常见死法:①进程压根没起来(入口守卫遇软链会静默 exit 0 —— pm2 必须用 releases 的真实路径起);\n' + + '②起来了但连不上库(body 里 db:"down",而 HTTP 仍是 200)。先看 pm2 logs。', + ); +} + +/* ── prune ─────────────────────────────────────────────────────────── */ + +function keepNewest(dir, keep, label) { + const entries = readdirSync(dir) + .map((name) => ({ name, path: join(dir, name) })) + .map((one) => ({ ...one, at: statSync(one.path).mtimeMs })) + .sort((a, b) => b.at - a.at); + const drop = entries.slice(keep); + for (const one of drop) rmSync(one.path, { recursive: true, force: true }); + emit(`${label}:留 ${Math.min(keep, entries.length)} 份,清掉 ${drop.length} 份`); +} + +function prune() { + const deployDir = need('DEPLOY_DIR'); + const dir = join(deployDir, 'releases'); + if (!existsSync(dir)) return; + const keep = Number(process.env['KEEP_RELEASES'] ?? 5); + const currentLink = join(deployDir, 'current'); + const current = existsSync(currentLink) ? realpathSync(currentLink) : undefined; + + const entries = readdirSync(dir) + .map((name) => join(dir, name)) + .map((path) => ({ path, at: statSync(path).mtimeMs })) + .sort((a, b) => b.at - a.at); + + let kept = 0; + let dropped = 0; + for (const one of entries) { + // 在跑的那一版永远不删,哪怕它排到了第 6 位 + if (kept < keep || one.path === current) { + kept += 1; + continue; + } + rmSync(one.path, { recursive: true, force: true }); + dropped += 1; + } + emit(`发布目录:留 ${kept} 版,清掉 ${dropped} 版`); +} + +/* ── 分发 ──────────────────────────────────────────────────────────── */ + +const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; +const [command] = process.argv.slice(2); + +if (isMain) { + switch (command) { + case 'preflight': + await preflight(); + break; + case 'backup': + await backup(); + break; + case 'db-ensure': + await dbEnsure(); + break; + case 'migrate-verify': + await migrateVerify(); + break; + case 'activities-plan': + await activitiesPlan(); + break; + case 'activities-verify': + await activitiesVerify(); + break; + case 'health': + await health(); + break; + case 'prune': + prune(); + break; + default: + die('用法:node tools/cd.mjs '); + } +} diff --git a/tools/ci.mjs b/tools/ci.mjs new file mode 100644 index 0000000..8817d20 --- /dev/null +++ b/tools/ci.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +/** + * CI 的四件小事(只在 Gitea Actions 里用,本地开发不需要): + * + * db-create 在常驻 PG 上建一个一次性库、灌 db/init(四个 schema), + * 连接串写进 $GITHUB_ENV 的 DATABASE_URL + * db-drop 拆掉它 + * db-probe 拿 DATABASE_URL 探一次 —— 连不上就红 + * assert-it 断言 ledger 集成测试**真的跑了**,不是静默跳过 + * + * 后两条治的是同一个病:`tools/it-database.ts` 连不上库、又起不了 Testcontainers 时, + * 打一行警告就返回 undefined —— 整套集成测试跳过而退出码仍是 0, + * CI 会拿「什么都没测」冒充绿灯。**界面不撒谎**:探不到库就红在探库那一步, + * 跑完发现被跳过就红在断言这一步。 + * + * 不新增依赖:pg 是根 devDependency(§12.5#3)。 + */ +import { appendFileSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import pg from 'pg'; + +/** 一次性库的前缀白名单 —— 本脚本永远只碰以它打头的库,`jcai_wos` 之类绝不误伤 */ +const PREFIX = 'jcai_ci_'; +/** 唯一一个「库连得上就真跑」的集成测试文件(其余三个被 SKIP_UNTIL_MODEL 钉着) */ +const LEDGER_IT = 'ledger-store.it.test.ts'; + +export function die(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function adminUrl() { + const url = process.env['CI_PG_ADMIN_URL']; + if (url === undefined || url === '') { + die('缺 CI_PG_ADMIN_URL —— 仓库设置 → Actions → Secrets 里加一条,指向常驻 PG 的 postgres 库'); + } + return url; +} + +function checkName(name) { + if (name === undefined || !name.startsWith(PREFIX)) { + die(`库名必须以 ${PREFIX} 打头(拿到的是「${name ?? ''}」)—— 这道白名单挡的是误删开发库`); + } + if (!/^[a-z0-9_]+$/.test(name)) die(`库名只许小写字母、数字、下划线:${name}`); + return name; +} + +/** 换掉连接串里的库名 —— 走 URL 解析,`?sslmode=` 这类查询串照样保住 */ +export function withDatabase(url, name) { + const parsed = new URL(url); + parsed.pathname = `/${name}`; + return parsed.toString(); +} + +export async function connect(url) { + const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 5000 }); + await client.connect(); + return client; +} + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * 新库要先灌 `db/init` 才迁得动 —— `0026_object_meta.sql` 往 `ledger.` 里建表, + * 而迁移链本身只会替自己建 `sys`。docker-compose 是靠把这个目录挂进 + * `/docker-entrypoint-initdb.d` 在首次起卷时灌的;`createdb` 出来的库没人替它灌。 + * **灌的是仓库里那份文件本身**,不在这里抄一份 —— 同一份事实两处各存一份,改一处就分叉。 + */ +export async function applyInit(url) { + const dir = join(root, 'db', 'init'); + const files = readdirSync(dir) + .filter((name) => name.endsWith('.sql')) + .sort(); + const client = await connect(url); + try { + for (const file of files) { + await client.query(readFileSync(join(dir, file), 'utf8')); + process.stdout.write(`灌了 db/init/${file}\n`); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + die( + `灌 db/init 失败:${detail}\n` + + '若卡在 CREATE EXTENSION pgcrypto,是账号权限不够(该扩展默认非 trusted,要超级用户)——\n' + + '让 DBA 先在那台 PG 上装一次,或给 CI 账号超级用户权限。', + ); + } finally { + await client.end(); + } +} + +async function dbCreate(name) { + const admin = await connect(adminUrl()); + try { + // 上一跑被取消时会留下库(拆库那一步没轮到跑)—— 顺手清掉,只清本前缀 + const stale = await admin.query('select datname from pg_database where datname like $1', [ + `${PREFIX}%`, + ]); + for (const row of stale.rows) { + await admin.query(`drop database if exists "${row.datname}" with (force)`); + process.stdout.write(`清掉上一跑的残留:${row.datname}\n`); + } + await admin.query(`create database "${name}"`); + } finally { + await admin.end(); + } + + const url = withDatabase(adminUrl(), name); + await applyInit(url); + + // 只把库名摆出来:派生串与 secret 不逐字相同,Gitea 的日志掩码盖不住它 + process.stdout.write(`本次跑的库:${name}\n`); + + const envFile = process.env['GITHUB_ENV']; + if (envFile === undefined) { + // 本地跑:没有 $GITHUB_ENV,把该导的那一行直接给出来(自己的终端,不存在日志外泄) + process.stdout.write(`export DATABASE_URL='${url}'\n`); + return; + } + appendFileSync(envFile, `DATABASE_URL=${url}\n`); +} + +async function dbDrop(name) { + const admin = await connect(adminUrl()); + try { + await admin.query(`drop database if exists "${name}" with (force)`); + } finally { + await admin.end(); + } + process.stdout.write(`已拆掉:${name}\n`); +} + +async function dbProbe() { + const url = process.env['DATABASE_URL']; + if (url === undefined || url === '') die('DATABASE_URL 没设 —— 建库那一步该把它写进 $GITHUB_ENV'); + + let client; + try { + client = await connect(url); + } catch (error) { + die( + `连不上库:${error instanceof Error ? error.message : String(error)}\n` + + '集成测试会因此静默跳过,这一跑等于什么都没测 —— 先修库,不是放松断言。', + ); + } + try { + const result = await client.query('select version()'); + process.stdout.write(`库连得上 ✔ ${result.rows[0].version}\n`); + } finally { + await client.end(); + } +} + +function assertIt(file) { + if (file === undefined) die('用法:node tools/ci.mjs assert-it '); + + let report; + try { + report = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + die(`读不到 vitest 的 json 报告(${file}):${error instanceof Error ? error.message : String(error)}`); + } + + const files = Array.isArray(report.testResults) ? report.testResults : []; + const target = files.find((one) => typeof one.name === 'string' && one.name.endsWith(LEDGER_IT)); + if (target === undefined) { + die(`vitest 报告里没有 ${LEDGER_IT} —— 集成测试整个没被收集到,这一跑等于什么都没测`); + } + + const cases = Array.isArray(target.assertionResults) ? target.assertionResults : []; + const passed = cases.filter((one) => one.status === 'passed'); + const skipped = cases.filter((one) => one.status !== 'passed' && one.status !== 'failed'); + + if (passed.length === 0 || skipped.length > 0) { + process.stderr.write( + `${LEDGER_IT}:${passed.length} 条通过、${skipped.length} 条被跳过。\n` + + '集成测试被跳过 = 这一跑什么都没测(库没连上时那几条会退化成 it.skip,而退出码仍是 0)。\n' + + '去看「建本次跑的一次性库」与「库真的连得上」两步,别改这道断言。\n', + ); + process.exit(1); + } + process.stdout.write(`集成测试真跑了 ✔ ${LEDGER_IT}:${passed.length} 条通过\n`); +} + +/** 被 import 时不跑分发器。用 pathToFileURL 归一 —— 拼字符串那种写法遇软链会不成立(实测过) */ +const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url; + +const [command, argument] = process.argv.slice(2); +if (isMain) + switch (command) { + case 'db-create': + await dbCreate(checkName(argument)); + break; + case 'db-drop': + await dbDrop(checkName(argument)); + break; + case 'db-probe': + await dbProbe(); + break; + case 'assert-it': + assertIt(argument); + break; + default: + die('用法:node tools/ci.mjs [参数]'); +} diff --git a/tools/depcruise-tiers.generated.cjs b/tools/depcruise-tiers.generated.cjs index bc4fe7a..cd707f1 100644 --- a/tools/depcruise-tiers.generated.cjs +++ b/tools/depcruise-tiers.generated.cjs @@ -34,7 +34,7 @@ module.exports = [ "path": "^apps/api/src/modules/event/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|ledger|llm|loop-engine|mail|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -78,7 +78,7 @@ module.exports = [ "path": "^apps/api/src/modules/ledger/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|llm|loop-engine|mail|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -89,7 +89,7 @@ module.exports = [ "path": "^apps/api/src/modules/llm/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|loop-engine|mail|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -103,6 +103,17 @@ module.exports = [ "path": "^apps/api/src/modules/(flow-engine|minute|process|report|task|trigger)/" } }, + { + "name": "tier-order-mail", + "comment": "模块 mail(L1)只准依赖更低层级模块;同层互需一律 port + app 装配(编码规范 §4 / §5.2)", + "severity": "error", + "from": { + "path": "^apps/api/src/modules/mail/" + }, + "to": { + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" + } + }, { "name": "tier-order-memory", "comment": "模块 memory(L2)只准依赖更低层级模块;同层互需一律 port + app 装配(编码规范 §4 / §5.2)", @@ -144,7 +155,7 @@ module.exports = [ "path": "^apps/api/src/modules/org/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|process|release|report|skill-engine|task|toolbox|trace|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|mail|memory|message|minute|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -242,7 +253,7 @@ module.exports = [ "from": {}, "to": { "path": "^apps/api/src/modules/([^/]+)/", - "pathNot": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" + "pathNot": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|mail|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } } ]; diff --git a/tools/e2e-a2.mjs b/tools/e2e-a2.mjs index 82cac6a..1364080 100644 --- a/tools/e2e-a2.mjs +++ b/tools/e2e-a2.mjs @@ -25,7 +25,13 @@ */ const BASE = process.env.JCAI_E2E_BASE ?? 'http://localhost:8787'; -const PASSWORD = process.env.JCAI_SEED_PASSWORD ?? 'jcai-dev-2026'; +/** 身份归统一鉴权中心(ADR-012):e2e 用服务账号的 client_credentials 拿 token,不用人的账号 */ +const KC_URL = (process.env.KEYCLOAK_URL ?? '').replace(/\/+$/, ''); +const KC_REALM = process.env.KEYCLOAK_REALM ?? 'corp'; +const E2E_CLIENT_ID = process.env.KEYCLOAK_E2E_CLIENT_ID ?? ''; +const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? ''; +/** access token 只活 5 分钟:超过 4 分钟就换一枚,免得长案例中途 401 */ +const TOKEN_REFRESH_MS = 4 * 60 * 1000; /** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */ const SUITES = { @@ -503,12 +509,14 @@ const SUITES = { }, }; -let cookie = ''; +let bearer = ''; +let bearerAt = 0; async function api(path, init = {}) { + if (bearer !== '' && Date.now() - bearerAt > TOKEN_REFRESH_MS) await login(); const res = await fetch(`${BASE}${path}`, { ...init, - headers: { 'content-type': 'application/json', cookie, ...(init.headers ?? {}) }, + headers: { 'content-type': 'application/json', authorization: bearer, ...(init.headers ?? {}) }, }); const text = await res.text(); let body; @@ -526,15 +534,23 @@ async function api(path, init = {}) { } async function login() { - const res = await fetch(`${BASE}/api/login`, { + if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') { + throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012)'); + } + const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, { method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ account: 'AI', password: PASSWORD }), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'client_credentials', client_id: E2E_CLIENT_ID, client_secret: E2E_CLIENT_SECRET }), }); - if (!res.ok) throw new Error(`登录失败:${res.status} ${await res.text()}`); - const raw = res.headers.get('set-cookie') ?? ''; - cookie = raw.split(';')[0] ?? ''; - if (!cookie.includes('jcai_session')) throw new Error(`没拿到会话 cookie:${raw}`); + if (!res.ok) throw new Error(`取 token 失败:${res.status} ${await res.text()}`); + const { access_token: token } = await res.json(); + bearer = `Bearer ${token}`; + bearerAt = Date.now(); + // 试跑要进设计端(测试台)、答卡要进工作台 —— 服务账号的角色不够,这里就先说清楚 + const me = await api('/api/me'); + for (const end of ['design', 'workbench']) { + if (!me.body.ends.includes(end)) throw new Error(`e2e 服务账号进不了 ${end}:ends=${JSON.stringify(me.body.ends)} —— 给它 realm 角色 admin`); + } } async function loadSample(activityId, index) { @@ -731,7 +747,7 @@ async function assertAssetFiles(taskId, spec) { if (empty) return { ok: false, note: `${key}(${format})是一份空壳 —— 渲得出形状,里头没内容` }; // 原字节也得取得到:下载到的是真 .docx / .xlsx - const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { cookie } }); + const raw = await fetch(`${BASE}/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(spec.address)}&key=${encodeURIComponent(key)}&raw=1`, { headers: { authorization: bearer } }); const bytes = new Uint8Array(await raw.arrayBuffer()); if (!raw.ok || bytes.length < 40) return { ok: false, note: `${key} 的原字节取不到(${raw.status})` }; if ((format === 'docx' || format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) { @@ -739,20 +755,30 @@ async function assertAssetFiles(taskId, spec) { } seen.add(format); } - return { ok: true, formats: seen, note: `${rows.length} 份资料全部取得回、渲得出(格式:${[...seen].join(' / ')})` }; + return { ok: true, formats: seen, count: rows.length, note: `${rows.length} 份资料全部取得回、渲得出(格式:${[...seen].join(' / ')})` }; } /** * 收工之后再看一眼:那几份资料**存档了没有、点得开吗**。 * 查的是工作台任务详情里那一摞(`assetFiles`,工作成果那一屏摆的就是它), * 逐份按同一条路取回来 —— 与人在界面上点它是同一个接口。 + * + * **份数也得对上**(2026-08-25 柴浩:「工作成果怎么有 4 个资料,这单只会产出一个」)—— + * 资料落在主数据上,整表里躺着历次跑出来的资产;从前存档拿的是整表, + * 于是这一摞里混着别单的成果。多一份也是错:签字的人会以为那是他交出去的东西。 */ -async function assertArchived(taskId) { +async function assertArchived(taskId, expected) { const { body } = await api(`/api/desk/tasks/${taskId}`); const files = body.assetFiles ?? []; if (files.length === 0) return { ok: false, note: '工作成果里一份资料都没有 —— 放行时没存档' }; const live = files.filter((f) => !f.archived); if (live.length > 0) return { ok: false, note: `${live.length} 份还标着「在办中」—— 放行那一刻没冻住` }; + if (expected !== undefined && files.length !== expected) { + return { + ok: false, + note: `工作成果里 ${files.length} 份,而这一单只产出 ${expected} 份 —— 多出来的是别单的成果(${files.map((f) => f.title).join(' / ')})`, + }; + } for (const f of files) { const url = `/api/desk/tasks/${taskId}/file?address=${encodeURIComponent(f.address)}&key=${encodeURIComponent(f.key)}`; let got; @@ -762,7 +788,7 @@ async function assertArchived(taskId) { return { ok: false, note: `${f.name} 存档里取不回来:${String(error.message).slice(0, 100)}` }; } if (got.body.file?.kind === undefined) return { ok: false, note: `${f.name} 取回来是个空壳` }; - const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { cookie } }); + const raw = await fetch(`${BASE}${url}&raw=1`, { headers: { authorization: bearer } }); const bytes = new Uint8Array(await raw.arrayBuffer()); if (!raw.ok || bytes.length < 40) return { ok: false, note: `${f.name} 的原件取不到(${raw.status} / ${bytes.length}B)` }; if ((f.format === 'docx' || f.format === 'xlsx') && !(bytes[0] === 0x50 && bytes[1] === 0x4b)) { @@ -1057,7 +1083,7 @@ async function runCase(suite, kase) { * 资料落在主数据上,而试跑单放行时主数据不落正式表:不存档的话这一刻全是 404 * (这正是 2026-08-23 之前的实情)。所以这条断言查的是**存档**,不是台账。 */ - const archived = await assertArchived(taskId); + const archived = await assertArchived(taskId, fileCheck?.count); console.log(` 资料存档:${archived.note}`); if (!archived.ok) ok = false; } diff --git a/tools/module-tiers.json b/tools/module-tiers.json index 7207ec2..3844696 100644 --- a/tools/module-tiers.json +++ b/tools/module-tiers.json @@ -5,7 +5,8 @@ "event", "org", "llm", - "ledger" + "ledger", + "mail" ], "2": [ "memory",