Archived
Compare commits
67
Commits
f01286a89d
...
a681ffe9f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a681ffe9f7 | ||
|
|
de63c275cf | ||
|
|
f52edd5383 | ||
|
|
0f1e53d114 | ||
|
|
f70de44338 | ||
|
|
58b1111e7e | ||
|
|
816389a185 | ||
|
|
18ca6dacfe | ||
|
|
e862a5279b | ||
|
|
e0fc16db33 | ||
|
|
b6ec37a231 | ||
|
|
ab30ce56c5 | ||
|
|
2d1558b99e | ||
|
|
7b79d542a8 | ||
|
|
d64f0b5ffd | ||
|
|
0c6e8e2c1b | ||
|
|
58358210e2 | ||
|
|
5bd92f2e93 | ||
|
|
7afc40987b | ||
|
|
f04c64926b | ||
|
|
40e592ae94 | ||
|
|
fba82c71dc | ||
|
|
c5cb779390 | ||
|
|
43bacffd90 | ||
|
|
78e36ab73c | ||
|
|
79fd25e1c1 | ||
|
|
1328aeb142 | ||
|
|
b9a4af9a2a | ||
|
|
d65579c0d8 | ||
|
|
5f1e64b4d4 | ||
|
|
8d7e56aa92 | ||
|
|
6748ef01b7 | ||
|
|
58d6d778ea | ||
|
|
83f65b2785 | ||
|
|
7c6e399b57 | ||
|
|
411fecc8c6 | ||
|
|
404a32fa0a | ||
|
|
26f07f3487 | ||
|
|
649cec1197 | ||
|
|
ebc6f778df | ||
|
|
bb22545bdf | ||
|
|
5facf6b745 | ||
|
|
5964a80b28 | ||
|
|
253c6c3afa | ||
|
|
a4716c8632 | ||
|
|
7978c6faca | ||
|
|
aa18701130 | ||
|
|
9848e2f735 | ||
|
|
4dabac2f7d | ||
|
|
fc5afa9600 | ||
|
|
61622f94d5 | ||
|
|
8dc71db9d8 | ||
|
|
dd31b91829 | ||
|
|
81a185cd61 | ||
|
|
d66f7fad3a | ||
|
|
3a34362eec | ||
|
|
2eef8b4544 | ||
|
|
4d55ac465d | ||
|
|
43917b559a | ||
|
|
cb143d6413 | ||
|
|
5108d7a580 | ||
|
|
a5c8d4786d | ||
|
|
61f96d5ab2 | ||
|
|
d788140548 | ||
|
|
094386f02c | ||
|
|
8ba4ab339e | ||
|
|
98ce2966a2 |
@@ -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/^/最近一份备份:/'
|
||||||
@@ -18,3 +18,6 @@ coverage/
|
|||||||
# 浏览器走查的截图与日志(本地取证用,不入库)
|
# 浏览器走查的截图与日志(本地取证用,不入库)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
/*.png
|
/*.png
|
||||||
|
|
||||||
|
# SDD 工作区(ledger / brief / 评审包)—— 记录归 git 历史,过程件不入库
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -1294,6 +1294,37 @@
|
|||||||
的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路**
|
的 card-sent 也算隔开),一句话只领一张卡。3 测(26 → 全绿)。**同一症状两条独立的路**
|
||||||
(时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 08-23 灰底那课的第二次。
|
(时钟偏 / 并卡误认),治完一条要把另一条也走一遍 —— 这是 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` | 三面一体的产品形态与决策记录 |
|
||||||
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 |
|
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-08-02 第三版,按四轴重组:解耦机制/Harness 契约/Loop/活动怎么写):基座读数与机检纪律、门与闸、五类卡、剧本 steps、建模十纪律、三常备案例与验收口径(released+被拒0)、**排障速查表**;§8 = 待实现 |
|
||||||
| | `docs/HTML报告生成规范.md` | **报告模版照这份**(2026-08-02 从开发规范拆出):一份 HTML 三个身份(件/表单/归档件)、**报告与卡的关系与同源纪律**、绑定语言全表、编辑态(闸/孪生件/增删行/红带)、PRD 平移法、陷阱表 |
|
| | `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/debt.md` | 欠账台账(只增,销账划线) |
|
||||||
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
||||||
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
||||||
@@ -1317,10 +1352,11 @@
|
|||||||
3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;
|
3. **§12.5 禁止清单**:no `any` / `as unknown as` / `@ts-ignore`;domain 零 IO;
|
||||||
不删测试;不动门禁配置;不绕写三分/两缝。
|
不删测试;不动门禁配置;不绕写三分/两缝。
|
||||||
4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。
|
4. **mock 只许出现在外界**(邮件通道、外部系统的录制回放替身);系统内部机制不得 mock。
|
||||||
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SEED_PASSWORD`(种子口令)、
|
5. **密钥只从环境读**:`JCAI_DEEPSEEK_API_KEY`(模型)、`JCAI_SECRET_KEY`(凭证密封 + 票据派生)、
|
||||||
`JCAI_SECRET_KEY`(凭证密封);不入源码、不入包、不入留痕;E2E 永不回退开发库。
|
`KEYCLOAK_URL` / `KEYCLOAK_REALM` / `KEYCLOAK_CLIENT_ID`(启动必填)、`KEYCLOAK_PROVISIONING_CLIENT_*`(名册同步,可选);
|
||||||
6. **试跑一律用 AI 账号**(密码 `jcai-dev-2026`),绝不派到用户工作台;测完
|
不入源码、不入包、不入留痕;E2E 永不回退开发库。
|
||||||
`pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
|
6. **试跑一律用 AI 服务账号**(Keycloak 里 `bpos-e2e` 的服务账号,client_credentials 拿 token;`JCAI_TEST_ACCOUNTS` 列上它的用户名),
|
||||||
|
绝不派到用户工作台;测完 `pnpm --filter @app/api reset-runs` 清运行数据(清跑出来的,留配置好的)。
|
||||||
7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。
|
7. **安装恒新增**:活动/工具改了内容必须升版本号,同版重装是 no-op(静默丢失,踩过)。
|
||||||
8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。
|
8. **界面不撒谎**:状态如实(未绑就写未绑)、错误带厂商原话、不可用不摆假数字。
|
||||||
|
|
||||||
|
|||||||
@@ -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<void | {
|
||||||
|
readonly skipped: string;
|
||||||
|
}>;
|
||||||
|
export type MailService = {
|
||||||
|
listAccounts(): Promise<MailAccountView[]>;
|
||||||
|
getAccount(accountId: string): Promise<MailAccountView>;
|
||||||
|
createAccount(input: MailAccountInput): Promise<MailAccountView>;
|
||||||
|
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
|
||||||
|
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
|
||||||
|
/** 留痕不删(入站 / 出站表对账号不设外键) */
|
||||||
|
deleteAccount(accountId: string): Promise<void>;
|
||||||
|
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
|
||||||
|
probe(target: {
|
||||||
|
accountId: string;
|
||||||
|
} | {
|
||||||
|
input: MailAccountInput;
|
||||||
|
accountId?: string | undefined;
|
||||||
|
}): Promise<MailProbeReport>;
|
||||||
|
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
|
||||||
|
pollDue(now: Date): Promise<MailPollOutcome[]>;
|
||||||
|
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
|
||||||
|
pollNow(accountId: string): Promise<MailPollOutcome>;
|
||||||
|
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
|
||||||
|
subscribe(name: string, handler: InboundHandler): () => void;
|
||||||
|
listInbound(query: {
|
||||||
|
accountId?: string | undefined;
|
||||||
|
limit?: number | undefined;
|
||||||
|
}): Promise<MailInboundSummary[]>;
|
||||||
|
getInbound(mailId: string): Promise<MailInboundDetail>;
|
||||||
|
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<MailDeliveryView[]>;
|
||||||
|
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
|
||||||
|
send(input: MailSendInput): Promise<MailSendResult>;
|
||||||
|
listOutbound(query: {
|
||||||
|
accountId?: string | undefined;
|
||||||
|
limit?: number | undefined;
|
||||||
|
}): Promise<MailOutboundView[]>;
|
||||||
|
};
|
||||||
+21
@@ -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';
|
||||||
+170
@@ -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<MailAccountRecord[]>;
|
||||||
|
get(id: string): Promise<MailAccountRecord | undefined>;
|
||||||
|
findByName(name: string): Promise<MailAccountRecord | undefined>;
|
||||||
|
/**
|
||||||
|
* upsert **只写配置列**;`isDefault:true` 时先把别的置 false(同一事务)。
|
||||||
|
* 运行列(cursor / lastPolledAt / lastPollError / lastInboundAt / pollingUntil)一概不动 —— 见 `savePoll`。
|
||||||
|
*/
|
||||||
|
save(record: MailAccountRecord): Promise<void>;
|
||||||
|
remove(id: string): Promise<void>;
|
||||||
|
/** enabled 且(从没拉过 或 last_polled_at + interval <= now)且租约空 / 过期 */
|
||||||
|
listDue(now: Date): Promise<MailAccountRecord[]>;
|
||||||
|
/** 一条 SQL 判 + 占(设计稿 §9.1);抢到 true */
|
||||||
|
claimPolling(id: string, leaseMs: number): Promise<boolean>;
|
||||||
|
releasePolling(id: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 拉取过程的进度:游标 / 时刻 / 错误 / 最近来信 / 探到的地址;行不存在 = 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<void>;
|
||||||
|
};
|
||||||
|
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<Set<string>>;
|
||||||
|
/**
|
||||||
|
* 列表 —— **不取 `raw` 原件字节**(一封上限 16MB,50 行就是几百 MB,只为算 200 字预览);
|
||||||
|
* `rawAvailable` 由 `raw is not null` 投影出来,不靠把字节搬回来再判。
|
||||||
|
*/
|
||||||
|
list(query: {
|
||||||
|
accountId?: string | undefined;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<Array<{
|
||||||
|
record: InboundRecord;
|
||||||
|
deliveries: DeliveryRecord[];
|
||||||
|
}>>;
|
||||||
|
/** 只要元信息的读(分发 / 详情 / 附件 / 重投都走它)—— 同样不取 `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<void>;
|
||||||
|
};
|
||||||
|
export type OutboundStore = {
|
||||||
|
insert(record: OutboundRecord): Promise<void>;
|
||||||
|
byIdempotencyKey(key: string): Promise<OutboundRecord | undefined>;
|
||||||
|
/**
|
||||||
|
* 复用旧行重发之前**先抢**(一条条件更新,抢到才回 true):
|
||||||
|
* `failed` 的行、或 `created_at <= staleBefore` 的 `sending` 行才抢得到;抢到即置 `sending` 并把
|
||||||
|
* `created_at` 推到 `now` —— 这一手让那一行**离开可抢的集合**,第二个人当场抢不到。
|
||||||
|
*
|
||||||
|
* 本仓是两个后端连同一个库:没有这一抢,两台同时重放同一次放行都会判「超时可复用」,
|
||||||
|
* 发出**两封真邮件**,直接破掉契约白纸黑字的「同键不发两封」。
|
||||||
|
*/
|
||||||
|
claimForResend(id: string, opts: {
|
||||||
|
readonly staleBefore: Date;
|
||||||
|
readonly now: Date;
|
||||||
|
}): Promise<boolean>;
|
||||||
|
setState(id: string, state: 'sent' | 'failed', patch: {
|
||||||
|
error?: string;
|
||||||
|
providerRef?: string;
|
||||||
|
sentAt?: Date;
|
||||||
|
}): Promise<void>;
|
||||||
|
list(query: {
|
||||||
|
accountId?: string | undefined;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<OutboundRecord[]>;
|
||||||
|
};
|
||||||
|
/** 附件存放 —— 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<string, unknown>;
|
||||||
|
readonly secret: Record<string, string>;
|
||||||
|
};
|
||||||
|
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<MailProbeReport>;
|
||||||
|
/** 无游标时锚定初始游标(不回填;设了 backfillFrom 才从那天起) */
|
||||||
|
anchor(account: ProviderAccount, backfillFrom: Date | null, now: Date): Promise<MailCursor>;
|
||||||
|
/**
|
||||||
|
* 拉一批。回参的 `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<Set<string>>;
|
||||||
|
}): 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<ParsedMail>;
|
||||||
|
};
|
||||||
|
/** 变更通知 —— app 接到 broadcaster;模块不知道 SSE 的存在 */
|
||||||
|
export type ChangeNotifier = {
|
||||||
|
changed(reason: string): void;
|
||||||
|
};
|
||||||
+50
-35
@@ -1,16 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
* 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 & {
|
export type StaffWithPosition = Staff & {
|
||||||
readonly position: Position;
|
readonly position: Position;
|
||||||
};
|
};
|
||||||
export type CreateStaffInput = {
|
/** 从 token 或 Admin API 归一出来的一个人 —— `sub` 即 Staff.id */
|
||||||
|
export type Identity = {
|
||||||
|
readonly sub: string;
|
||||||
|
/** preferred_username */
|
||||||
readonly account: string;
|
readonly account: string;
|
||||||
|
/** name claim,缺则回退 account */
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly positionId: string;
|
readonly email?: string;
|
||||||
|
/** realm 角色,噪声角色已滤、已排序 */
|
||||||
|
readonly roles: readonly string[];
|
||||||
};
|
};
|
||||||
export declare class StaffNotFoundError extends Error {
|
export declare class StaffNotFoundError extends Error {
|
||||||
readonly staffId: StaffId;
|
readonly staffId: StaffId;
|
||||||
@@ -20,45 +30,51 @@ export declare class PositionNotFoundError extends Error {
|
|||||||
readonly positionCode: string;
|
readonly positionCode: string;
|
||||||
constructor(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;
|
readonly account: string;
|
||||||
constructor(account: string);
|
constructor(account: string);
|
||||||
}
|
}
|
||||||
/** 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02) */
|
/**
|
||||||
export declare class DisableBlockedError extends Error {
|
* 这一步做下去就没有任何在册活跃员工能进运营中心了 —— 拒绝(路由映射 409)。
|
||||||
readonly check: DisableCheck;
|
* 两个来路:改「角色 → 端」(那张表就再也没人改得了)、同步名册(同步按钮自己也要 ops)。
|
||||||
constructor(check: DisableCheck);
|
* `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 = {
|
export type OrgService = {
|
||||||
|
/** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */
|
||||||
|
authenticate(token: string): Promise<Principal>;
|
||||||
|
/** 票据那条路:按 sub 从库现算;停用抛 AccountDisabledError;不存在回 undefined */
|
||||||
|
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||||||
|
/** 名册即时更新的入口 —— 没有就建(NONE 岗、active),有就把变了的列写回;测试也用它造人 */
|
||||||
|
registerIdentity(identity: Identity): Promise<Staff>;
|
||||||
listStaff(): Promise<{
|
listStaff(): Promise<{
|
||||||
staff: StaffWithPosition[];
|
staff: StaffWithPosition[];
|
||||||
positions: Position[];
|
positions: Position[];
|
||||||
}>;
|
}>;
|
||||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||||
findByAccount(account: string): Promise<StaffWithPosition | undefined>;
|
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用);只回 active;NONE 恒回 [] */
|
||||||
createStaff(input: CreateStaffInput): Promise<Staff>;
|
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||||
/** 停用前置检查 —— 只读,治理台先看再点 */
|
setPosition(staffId: StaffId, positionId: PositionId): Promise<StaffWithPosition>;
|
||||||
checkDisable(staffId: StaffId): Promise<DisableCheck>;
|
/** Admin API 同步;未配 provisioning 回 configured:false */
|
||||||
/** 停用 —— 前置检查不过即抛 DisableBlockedError,不做部分执行 */
|
syncRoster(): Promise<RosterSyncOutcome>;
|
||||||
disableStaff(staffId: StaffId): Promise<void>;
|
/** 此人身上还挂着什么(只读;OS 02 改派先于停用) */
|
||||||
listGrants(positionId: string): Promise<Grant[]>;
|
inFlightOf(staffId: StaffId): Promise<InFlightWork>;
|
||||||
setGrants(positionId: string, grants: readonly Grant[]): Promise<Grant[]>;
|
listRoleAccess(): Promise<{
|
||||||
/** 岗位权限校验 —— app 层鉴权中间件读它 */
|
roles: RoleAccess[];
|
||||||
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
|
knownRoles: string[];
|
||||||
loadSession(account: string): Promise<Session | undefined>;
|
}>;
|
||||||
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
|
/** 抛 AccessLockoutError(409):改完没有任何在册活跃员工能进运营中心 */
|
||||||
login(account: string, password: string): Promise<IssuedSession | undefined>;
|
setRoleAccess(role: string, ends: readonly Surface[]): Promise<RoleAccess>;
|
||||||
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
|
deleteRoleAccess(role: string): Promise<void>;
|
||||||
logout(token: string): Promise<void>;
|
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
|
||||||
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
|
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
|
||||||
resolveSession(token: string): Promise<Session | undefined>;
|
|
||||||
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
|
|
||||||
setPassword(staffId: StaffId, password: string): Promise<void>;
|
|
||||||
/** 建组 / 改组;id 留空即新建 */
|
/** 建组 / 改组;id 留空即新建 */
|
||||||
saveWorkGroup(input: {
|
saveWorkGroup(input: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -70,7 +86,6 @@ export type OrgService = {
|
|||||||
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
||||||
/** 装进工作流的快照:改组不改已起的单 */
|
/** 装进工作流的快照:改组不改已起的单 */
|
||||||
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
||||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
|
/** 岗位(含 NONE / TEST)+ 缺省角色→端(表空时)+ 已在册测试账号的策略;幂等 */
|
||||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
|
||||||
ensureSeeded(): Promise<void>;
|
ensureSeeded(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-8
@@ -1,13 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
* 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 type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts';
|
||||||
export { AccountTakenError, DisableBlockedError, PositionNotFoundError, StaffNotFoundError, } from './contract.ts';
|
export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts';
|
||||||
export type { Clock, GrantRepository, IdGenerator, InFlightWorkProbe, PasswordHasher, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository, } from './port.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 type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
||||||
export { createOrgService } from './internal/usecase/org-service.ts';
|
export { createOrgService } from './internal/usecase/org-service.ts';
|
||||||
export { isAuthorized, actionCovers, objectCovers } from './internal/domain/authorization.ts';
|
export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts';
|
||||||
export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/domain/seed-data.ts';
|
export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts';
|
||||||
export { createGrantRepository, createPositionRepository, createSessionRepository, createStaffRepository, createWorkGroupRepository, objectKeyOf, } from './internal/adapter/org-repository.ts';
|
export { buildInFlightWork } from './internal/domain/in-flight.ts';
|
||||||
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
|
export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts';
|
||||||
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.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';
|
||||||
|
|||||||
+54
-38
@@ -2,10 +2,40 @@
|
|||||||
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
* 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 = {
|
export type Clock = {
|
||||||
now(): Date;
|
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<VerifiedClaims>;
|
||||||
|
};
|
||||||
|
/** 名册来源(Keycloak Admin API)—— app 未配 provisioning 服务账号时不注入 */
|
||||||
|
export type RosterUser = Identity & {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
};
|
||||||
|
export type RosterSource = {
|
||||||
|
listUsers(): Promise<readonly RosterUser[]>;
|
||||||
|
};
|
||||||
|
/** 名册即时更新 / 同步要改的那几列 */
|
||||||
|
export type IdentityPatch = {
|
||||||
|
readonly account: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly email?: string;
|
||||||
|
readonly roles: readonly string[];
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
};
|
||||||
export type PositionRepository = {
|
export type PositionRepository = {
|
||||||
list(): Promise<Position[]>;
|
list(): Promise<Position[]>;
|
||||||
findByCode(code: string): Promise<Position | undefined>;
|
findByCode(code: string): Promise<Position | undefined>;
|
||||||
@@ -14,46 +44,32 @@ export type PositionRepository = {
|
|||||||
id?: PositionId;
|
id?: PositionId;
|
||||||
}): Promise<Position>;
|
}): Promise<Position>;
|
||||||
};
|
};
|
||||||
|
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
|
||||||
|
export type StaffRecord = Staff & {
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
readonly syncedAt?: Date;
|
||||||
|
};
|
||||||
export type StaffRepository = {
|
export type StaffRepository = {
|
||||||
list(): Promise<Staff[]>;
|
list(): Promise<StaffRecord[]>;
|
||||||
findById(id: StaffId): Promise<Staff | undefined>;
|
findById(id: StaffId): Promise<StaffRecord | undefined>;
|
||||||
findByAccount(account: string): Promise<Staff | undefined>;
|
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
|
||||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
findByAccount(account: string): Promise<StaffRecord | undefined>;
|
||||||
insert(staff: Staff): Promise<Staff>;
|
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
|
||||||
setState(id: StaffId, state: Staff['state']): Promise<void>;
|
insert(staff: Staff, marks: {
|
||||||
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
|
readonly lastSeenAt?: Date;
|
||||||
passwordHashOf(account: string): Promise<string | undefined>;
|
readonly syncedAt?: Date;
|
||||||
setPasswordHash(id: StaffId, hash: string): Promise<void>;
|
}): Promise<StaffRecord>;
|
||||||
|
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
|
||||||
|
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
|
||||||
|
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
|
||||||
|
};
|
||||||
|
export type RoleAccessRepository = {
|
||||||
|
list(): Promise<RoleAccess[]>;
|
||||||
|
upsert(row: RoleAccess): Promise<RoleAccess>;
|
||||||
|
remove(role: string): Promise<void>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
|
* 在途工作探针 —— 要问的是 L6 `task` 与 L3 `human-tool`,
|
||||||
* token 即主键,不透明随机串,不编码任何身份信息。
|
|
||||||
*/
|
|
||||||
export type SessionRepository = {
|
|
||||||
issue(input: {
|
|
||||||
token: string;
|
|
||||||
staffId: StaffId;
|
|
||||||
expiresAt: Date;
|
|
||||||
}): Promise<void>;
|
|
||||||
/** 未过期才返回;命中同时滑动续期 */
|
|
||||||
touch(token: string, now: Date, expiresAt: Date): Promise<{
|
|
||||||
staffId: StaffId;
|
|
||||||
} | undefined>;
|
|
||||||
revoke(token: string): Promise<void>;
|
|
||||||
revokeAllOf(staffId: StaffId): Promise<void>;
|
|
||||||
};
|
|
||||||
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
|
|
||||||
export type PasswordHasher = {
|
|
||||||
hash(password: string): Promise<string>;
|
|
||||||
verify(password: string, stored: string | undefined): Promise<boolean>;
|
|
||||||
newToken(): string;
|
|
||||||
};
|
|
||||||
export type GrantRepository = {
|
|
||||||
listByPosition(positionId: PositionId): Promise<Grant[]>;
|
|
||||||
replaceForPosition(positionId: PositionId, grants: readonly Grant[]): Promise<Grant[]>;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 在途工作探针 —— 停用前置检查要问的是 L6 `task` 与 L3 `human-tool`,
|
|
||||||
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
* 二者层级高于 org,故一律经本端口由 app 注入(编码规范 §5.2 同层/跨层协作规矩)。
|
||||||
*/
|
*/
|
||||||
export type InFlightWorkProbe = {
|
export type InFlightWorkProbe = {
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ export type TaskService = {
|
|||||||
/** 控制三语义:暂停 / 叫停 / 终止 */
|
/** 控制三语义:暂停 / 叫停 / 终止 */
|
||||||
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
control(taskId: TaskId, semantic: ControlSemantic, by: StaffId, reason?: string): Promise<ControlOutcome>;
|
||||||
resume(taskId: TaskId, by: StaffId): Promise<Task>;
|
resume(taskId: TaskId, by: StaffId): Promise<Task>;
|
||||||
|
/**
|
||||||
|
* **人答复落库那一刻就把「在办事」写进投影**(2026-08-25):不等派发器投递、推进器
|
||||||
|
* 抢到租约再翻 —— 那几秒里对话区(卡状态已落)与工作区(读 runtime)说两句相反的话。
|
||||||
|
* 只在挂起等人 / 等外部时翻;已终结、暂停(suspended)、已在跑的原样不动。
|
||||||
|
* 返回翻没翻 —— 调用方据此决定要不要广播。
|
||||||
|
*/
|
||||||
|
wake(taskId: TaskId): Promise<boolean>;
|
||||||
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
|
/** 改派:在途任务的负责人变更,**留痕并通知双方** */
|
||||||
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
|
reassign(taskId: TaskId, to: StaffId, by: StaffId): Promise<Task>;
|
||||||
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
|
/** 终态裁定 —— 完成由框架裁定,终态由系统命名 */
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -6,16 +6,19 @@ import { z } from 'zod';
|
|||||||
export declare const listStaffRoute: {
|
export declare const listStaffRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/staff";
|
readonly path: "/api/gov/staff";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
staff: z.ZodArray<z.ZodObject<{
|
staff: z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
state: z.ZodDefault<z.ZodEnum<{
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
active: "active";
|
active: "active";
|
||||||
disabled: "disabled";
|
disabled: "disabled";
|
||||||
}>>;
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
position: z.ZodObject<{
|
position: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
code: z.ZodString;
|
code: z.ZodString;
|
||||||
@@ -31,9 +34,11 @@ export declare const listStaffRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/** @deprecated 随 ADR-012 退役,后端回 410;人来自 Keycloak,改用 syncStaffRoute */
|
||||||
export declare const createStaffRoute: {
|
export declare const createStaffRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/staff";
|
readonly path: "/api/gov/staff";
|
||||||
|
readonly access: "public";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -43,16 +48,20 @@ export declare const createStaffRoute: {
|
|||||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
state: z.ZodDefault<z.ZodEnum<{
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
active: "active";
|
active: "active";
|
||||||
disabled: "disabled";
|
disabled: "disabled";
|
||||||
}>>;
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */
|
||||||
export declare const disableStaffCheckRoute: {
|
export declare const disableStaffCheckRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/staff/:staffId/disable-check";
|
readonly path: "/api/gov/staff/:staffId/disable-check";
|
||||||
|
readonly access: "public";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -73,9 +82,11 @@ export declare const disableStaffCheckRoute: {
|
|||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/** @deprecated 随 ADR-012 退役,后端回 410;停用改在 Keycloak 做,名册同步据此落 disabled */
|
||||||
export declare const disableStaffRoute: {
|
export declare const disableStaffRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/staff/:staffId/disable";
|
readonly path: "/api/gov/staff/:staffId/disable";
|
||||||
|
readonly access: "public";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -99,9 +110,11 @@ export declare const disableStaffRoute: {
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 listRoleAccessRoute */
|
||||||
export declare const listGrantsRoute: {
|
export declare const listGrantsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/positions/:positionId/grants";
|
readonly path: "/api/gov/positions/:positionId/grants";
|
||||||
|
readonly access: "public";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -138,9 +151,11 @@ export declare const listGrantsRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
/** @deprecated 随 ADR-012 退役,后端回 410;权限只到端,改用 setRoleAccessRoute */
|
||||||
export declare const setGrantsRoute: {
|
export declare const setGrantsRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/positions/:positionId/grants";
|
readonly path: "/api/gov/positions/:positionId/grants";
|
||||||
|
readonly access: "public";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -209,6 +224,132 @@ export declare const setGrantsRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, 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<false>;
|
||||||
|
message: z.ZodString;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
configured: z.ZodLiteral<true>;
|
||||||
|
inserted: z.ZodNumber;
|
||||||
|
updated: z.ZodNumber;
|
||||||
|
disabled: z.ZodNumber;
|
||||||
|
total: z.ZodNumber;
|
||||||
|
failed: z.ZodArray<z.ZodObject<{
|
||||||
|
account: z.ZodString;
|
||||||
|
reason: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, 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.ZodString, "StaffId", "out">;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
account: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
position: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
code: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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.ZodString, "StaffId", "out">;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
blocked: z.ZodBoolean;
|
||||||
|
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
cardId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
approvalId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, 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.ZodObject<{
|
||||||
|
role: z.ZodString;
|
||||||
|
ends: z.ZodArray<z.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
knownRoles: z.ZodArray<z.ZodString>;
|
||||||
|
}, 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.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
role: z.ZodString;
|
||||||
|
ends: z.ZodArray<z.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>>;
|
||||||
|
}, 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 LLM_PROVIDERS: readonly ["openai-compatible", "openai", "deepseek", "anthropic", "qwen"];
|
||||||
export declare const llmProviderSchema: z.ZodEnum<{
|
export declare const llmProviderSchema: z.ZodEnum<{
|
||||||
"openai-compatible": "openai-compatible";
|
"openai-compatible": "openai-compatible";
|
||||||
@@ -243,6 +384,7 @@ export type LlmInstanceView = z.infer<typeof llmInstanceViewSchema>;
|
|||||||
export declare const listLlmProvidersRoute: {
|
export declare const listLlmProvidersRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/llm/providers";
|
readonly path: "/api/gov/llm/providers";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
providers: z.ZodArray<z.ZodObject<{
|
providers: z.ZodArray<z.ZodObject<{
|
||||||
provider: z.ZodEnum<{
|
provider: z.ZodEnum<{
|
||||||
@@ -264,6 +406,7 @@ export declare const listLlmProvidersRoute: {
|
|||||||
export declare const listLlmInstancesRoute: {
|
export declare const listLlmInstancesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/llm";
|
readonly path: "/api/gov/llm";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
instances: z.ZodArray<z.ZodObject<{
|
instances: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -286,6 +429,7 @@ export declare const listLlmInstancesRoute: {
|
|||||||
export declare const upsertLlmInstanceRoute: {
|
export declare const upsertLlmInstanceRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/llm";
|
readonly path: "/api/gov/llm";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
id: z.ZodOptional<z.ZodString>;
|
id: z.ZodOptional<z.ZodString>;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -331,6 +475,7 @@ export declare const upsertLlmInstanceRoute: {
|
|||||||
export declare const deleteLlmInstanceRoute: {
|
export declare const deleteLlmInstanceRoute: {
|
||||||
readonly method: "DELETE";
|
readonly method: "DELETE";
|
||||||
readonly path: "/api/gov/llm/:id";
|
readonly path: "/api/gov/llm/:id";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -341,6 +486,7 @@ export declare const deleteLlmInstanceRoute: {
|
|||||||
export declare const probeLlmDraftRoute: {
|
export declare const probeLlmDraftRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/llm/probe";
|
readonly path: "/api/gov/llm/probe";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
id: z.ZodOptional<z.ZodString>;
|
id: z.ZodOptional<z.ZodString>;
|
||||||
name: z.ZodOptional<z.ZodString>;
|
name: z.ZodOptional<z.ZodString>;
|
||||||
@@ -364,6 +510,7 @@ export declare const probeLlmDraftRoute: {
|
|||||||
export declare const probeLlmInstanceRoute: {
|
export declare const probeLlmInstanceRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/llm/:id/probe";
|
readonly path: "/api/gov/llm/:id/probe";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -402,6 +549,7 @@ export type GovTaskRow = z.infer<typeof govTaskRowSchema>;
|
|||||||
export declare const listGovTasksRoute: {
|
export declare const listGovTasksRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/tasks";
|
readonly path: "/api/gov/tasks";
|
||||||
|
readonly access: "ops";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
tasks: z.ZodArray<z.ZodObject<{
|
tasks: z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
@@ -447,6 +595,7 @@ export declare const listGovTasksRoute: {
|
|||||||
export declare const controlTaskRoute: {
|
export declare const controlTaskRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/tasks/:taskId/control";
|
readonly path: "/api/gov/tasks/:taskId/control";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -466,6 +615,7 @@ export declare const controlTaskRoute: {
|
|||||||
export declare const resumeTaskRoute: {
|
export declare const resumeTaskRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/tasks/:taskId/resume";
|
readonly path: "/api/gov/tasks/:taskId/resume";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -485,6 +635,7 @@ export declare const resumeTaskRoute: {
|
|||||||
export declare const reassignTaskRoute: {
|
export declare const reassignTaskRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/tasks/:taskId/reassign";
|
readonly path: "/api/gov/tasks/:taskId/reassign";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -500,6 +651,7 @@ export declare const reassignTaskRoute: {
|
|||||||
export declare const govTaskLogRoute: {
|
export declare const govTaskLogRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/tasks/:taskId/log";
|
readonly path: "/api/gov/tasks/:taskId/log";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -516,6 +668,7 @@ export declare const govTaskLogRoute: {
|
|||||||
export declare const listProcessPackagesRoute: {
|
export declare const listProcessPackagesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/process";
|
readonly path: "/api/gov/process";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
packages: z.ZodArray<z.ZodObject<{
|
packages: z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||||
@@ -635,6 +788,7 @@ export declare const listProcessPackagesRoute: {
|
|||||||
export declare const saveProcessRoute: {
|
export declare const saveProcessRoute: {
|
||||||
readonly method: "PUT";
|
readonly method: "PUT";
|
||||||
readonly path: "/api/gov/process";
|
readonly path: "/api/gov/process";
|
||||||
|
readonly access: "design";
|
||||||
/**
|
/**
|
||||||
* 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。
|
* 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。
|
||||||
* `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。
|
* `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。
|
||||||
@@ -879,6 +1033,7 @@ export declare const saveProcessRoute: {
|
|||||||
export declare const setProcessStateRoute: {
|
export declare const setProcessStateRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/process/:id/:version/state";
|
readonly path: "/api/gov/process/:id/:version/state";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
version: z.ZodString;
|
version: z.ZodString;
|
||||||
@@ -1005,6 +1160,7 @@ export declare const setProcessStateRoute: {
|
|||||||
export declare const deleteProcessVersionRoute: {
|
export declare const deleteProcessVersionRoute: {
|
||||||
readonly method: "DELETE";
|
readonly method: "DELETE";
|
||||||
readonly path: "/api/gov/process/:id/:version";
|
readonly path: "/api/gov/process/:id/:version";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
version: z.ZodString;
|
version: z.ZodString;
|
||||||
@@ -1121,6 +1277,7 @@ export declare const deleteProcessVersionRoute: {
|
|||||||
export declare const listApprovalTemplatesRoute: {
|
export declare const listApprovalTemplatesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/approval-templates";
|
readonly path: "/api/gov/approval-templates";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
templates: z.ZodArray<z.ZodObject<{
|
templates: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -1151,6 +1308,7 @@ export declare const listApprovalTemplatesRoute: {
|
|||||||
export declare const upsertApprovalTemplateRoute: {
|
export declare const upsertApprovalTemplateRoute: {
|
||||||
readonly method: "PUT";
|
readonly method: "PUT";
|
||||||
readonly path: "/api/gov/approval-templates";
|
readonly path: "/api/gov/approval-templates";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
template: z.ZodObject<{
|
template: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -1198,6 +1356,7 @@ export declare const upsertApprovalTemplateRoute: {
|
|||||||
export declare const deleteApprovalTemplateRoute: {
|
export declare const deleteApprovalTemplateRoute: {
|
||||||
readonly method: "DELETE";
|
readonly method: "DELETE";
|
||||||
readonly path: "/api/gov/approval-templates/:id";
|
readonly path: "/api/gov/approval-templates/:id";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -1213,6 +1372,7 @@ export declare const deleteApprovalTemplateRoute: {
|
|||||||
export declare const deleteProcessFlowRoute: {
|
export declare const deleteProcessFlowRoute: {
|
||||||
readonly method: "DELETE";
|
readonly method: "DELETE";
|
||||||
readonly path: "/api/gov/process/:id";
|
readonly path: "/api/gov/process/:id";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -1335,6 +1495,7 @@ export declare const deleteProcessFlowRoute: {
|
|||||||
export declare const editProcessVersionRoute: {
|
export declare const editProcessVersionRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/process/:id/:version/edit";
|
readonly path: "/api/gov/process/:id/:version/edit";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
version: z.ZodString;
|
version: z.ZodString;
|
||||||
@@ -1455,6 +1616,7 @@ export declare const editProcessVersionRoute: {
|
|||||||
export declare const validateProcessRoute: {
|
export declare const validateProcessRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/process/validate";
|
readonly path: "/api/gov/process/validate";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
pkg: z.ZodObject<{
|
pkg: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "ProcessPackageId", "out">;
|
||||||
@@ -1580,6 +1742,7 @@ export declare const validateProcessRoute: {
|
|||||||
export declare const listTriggersRoute: {
|
export declare const listTriggersRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/triggers";
|
readonly path: "/api/gov/triggers";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
triggers: z.ZodArray<z.ZodObject<{
|
triggers: z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||||
@@ -1639,6 +1802,7 @@ export declare const listTriggersRoute: {
|
|||||||
export declare const toggleTriggerRoute: {
|
export declare const toggleTriggerRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/triggers/:id/toggle";
|
readonly path: "/api/gov/triggers/:id/toggle";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -1659,6 +1823,7 @@ export declare const toggleTriggerRoute: {
|
|||||||
export declare const upsertTriggerRoute: {
|
export declare const upsertTriggerRoute: {
|
||||||
readonly method: "PUT";
|
readonly method: "PUT";
|
||||||
readonly path: "/api/gov/triggers";
|
readonly path: "/api/gov/triggers";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
trigger: z.ZodObject<{
|
trigger: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "TriggerId", "out">;
|
||||||
@@ -1779,6 +1944,7 @@ export declare const upsertTriggerRoute: {
|
|||||||
export declare const listWorkGroupsRoute: {
|
export declare const listWorkGroupsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/work-groups";
|
readonly path: "/api/gov/work-groups";
|
||||||
|
readonly access: "ops";
|
||||||
/** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */
|
/** `ownerName` 是组长姓名的呈现补充,读表时一并带出,免得前端再对一次 id */
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
groups: z.ZodArray<z.ZodObject<{
|
groups: z.ZodArray<z.ZodObject<{
|
||||||
@@ -1794,6 +1960,7 @@ export declare const listWorkGroupsRoute: {
|
|||||||
export declare const saveWorkGroupRoute: {
|
export declare const saveWorkGroupRoute: {
|
||||||
readonly method: "PUT";
|
readonly method: "PUT";
|
||||||
readonly path: "/api/gov/work-groups";
|
readonly path: "/api/gov/work-groups";
|
||||||
|
readonly access: "ops";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
ownerId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -1818,6 +1985,7 @@ export declare const saveWorkGroupRoute: {
|
|||||||
export declare const installActivityRoute: {
|
export declare const installActivityRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/activities";
|
readonly path: "/api/gov/activities";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
manifest: z.ZodObject<{
|
manifest: z.ZodObject<{
|
||||||
identity: z.ZodObject<{
|
identity: z.ZodObject<{
|
||||||
@@ -2126,6 +2294,7 @@ export declare const installActivityRoute: {
|
|||||||
export declare const registerToolRoute: {
|
export declare const registerToolRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/tools";
|
readonly path: "/api/gov/tools";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
contract: z.ZodObject<{
|
contract: z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -2170,6 +2339,7 @@ export declare const registerToolRoute: {
|
|||||||
export declare const listActivitiesRoute: {
|
export declare const listActivitiesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/activities";
|
readonly path: "/api/gov/activities";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
activities: z.ZodArray<z.ZodObject<{
|
activities: z.ZodArray<z.ZodObject<{
|
||||||
activityId: z.ZodString;
|
activityId: z.ZodString;
|
||||||
@@ -2228,6 +2398,7 @@ export declare const listActivitiesRoute: {
|
|||||||
export declare const listToolsRoute: {
|
export declare const listToolsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/tools";
|
readonly path: "/api/gov/tools";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
tools: z.ZodArray<z.ZodObject<{
|
tools: z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -2246,6 +2417,7 @@ export declare const listToolsRoute: {
|
|||||||
export declare const listLedgerObjectsRoute: {
|
export declare const listLedgerObjectsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/data/objects";
|
readonly path: "/api/gov/data/objects";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
|
/** `label` 是人读名(落在表注释上);缺省即没登记,呈现退回对象键 */
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
objects: z.ZodArray<z.ZodObject<{
|
objects: z.ZodArray<z.ZodObject<{
|
||||||
@@ -2278,6 +2450,7 @@ export declare const listLedgerObjectsRoute: {
|
|||||||
export declare const ledgerByActivityRoute: {
|
export declare const ledgerByActivityRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/data/by-activity";
|
readonly path: "/api/gov/data/by-activity";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
activities: z.ZodArray<z.ZodObject<{
|
activities: z.ZodArray<z.ZodObject<{
|
||||||
activityId: z.ZodString;
|
activityId: z.ZodString;
|
||||||
@@ -2313,6 +2486,7 @@ export declare const ledgerByActivityRoute: {
|
|||||||
export declare const browseLedgerRoute: {
|
export declare const browseLedgerRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/data/:domain/:object";
|
readonly path: "/api/gov/data/:domain/:object";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
object: z.ZodString;
|
object: z.ZodString;
|
||||||
@@ -2332,6 +2506,7 @@ export declare const browseLedgerRoute: {
|
|||||||
export declare const listActivityMemoryRoute: {
|
export declare const listActivityMemoryRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/memory/:activityId";
|
readonly path: "/api/gov/memory/:activityId";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
activityId: z.ZodString;
|
activityId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2350,6 +2525,7 @@ export declare const listActivityMemoryRoute: {
|
|||||||
export declare const listChannelsRoute: {
|
export declare const listChannelsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/channels";
|
readonly path: "/api/gov/channels";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
channels: z.ZodArray<z.ZodObject<{
|
channels: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -2382,6 +2558,7 @@ export declare const listChannelsRoute: {
|
|||||||
export declare const listApprovalsRoute: {
|
export declare const listApprovalsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/approvals";
|
readonly path: "/api/gov/approvals";
|
||||||
|
readonly access: "ops";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
approvals: z.ZodArray<z.ZodObject<{
|
approvals: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -2409,6 +2586,7 @@ export declare const listApprovalsRoute: {
|
|||||||
export declare const setTriggerConfirmRoute: {
|
export declare const setTriggerConfirmRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/triggers/:id/confirm";
|
readonly path: "/api/gov/triggers/:id/confirm";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2424,6 +2602,7 @@ export declare const setTriggerConfirmRoute: {
|
|||||||
export declare const listNodeOwnersRoute: {
|
export declare const listNodeOwnersRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/packages/:id/owners";
|
readonly path: "/api/gov/packages/:id/owners";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2437,6 +2616,7 @@ export declare const listNodeOwnersRoute: {
|
|||||||
export declare const setNodeOwnerRoute: {
|
export declare const setNodeOwnerRoute: {
|
||||||
readonly method: "PUT";
|
readonly method: "PUT";
|
||||||
readonly path: "/api/gov/packages/:id/owners/:nodeId";
|
readonly path: "/api/gov/packages/:id/owners/:nodeId";
|
||||||
|
readonly access: "ops";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
nodeId: z.ZodString;
|
nodeId: z.ZodString;
|
||||||
@@ -2452,6 +2632,7 @@ export declare const setNodeOwnerRoute: {
|
|||||||
export declare const testRunRoute: {
|
export declare const testRunRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/testbed/run";
|
readonly path: "/api/gov/testbed/run";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
packageId: z.ZodString;
|
packageId: z.ZodString;
|
||||||
mail: z.ZodObject<{
|
mail: z.ZodObject<{
|
||||||
@@ -2491,6 +2672,7 @@ export declare const testRunRoute: {
|
|||||||
export declare const testRunActivityRoute: {
|
export declare const testRunActivityRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/testbed/run-activity";
|
readonly path: "/api/gov/testbed/run-activity";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
activityId: z.ZodString;
|
activityId: z.ZodString;
|
||||||
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
@@ -2510,6 +2692,7 @@ export declare const testRunActivityRoute: {
|
|||||||
export declare const testStopRoute: {
|
export declare const testStopRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/testbed/stop";
|
readonly path: "/api/gov/testbed/stop";
|
||||||
|
readonly access: "design";
|
||||||
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
|
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
workflowId: z.ZodDefault<z.ZodString>;
|
workflowId: z.ZodDefault<z.ZodString>;
|
||||||
@@ -2532,6 +2715,7 @@ export declare const testStopRoute: {
|
|||||||
export declare const testCurrentRoute: {
|
export declare const testCurrentRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/testbed/current";
|
readonly path: "/api/gov/testbed/current";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
workflowId: z.ZodOptional<z.ZodString>;
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
subject: z.ZodOptional<z.ZodString>;
|
subject: z.ZodOptional<z.ZodString>;
|
||||||
@@ -2551,6 +2735,7 @@ export declare const testCurrentRoute: {
|
|||||||
export declare const testActivityStateRoute: {
|
export declare const testActivityStateRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/testbed/activity/:taskId";
|
readonly path: "/api/gov/testbed/activity/:taskId";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.ZodString;
|
taskId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2617,6 +2802,7 @@ export declare const testActivityStateRoute: {
|
|||||||
export declare const uploadFileRoute: {
|
export declare const uploadFileRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/upload";
|
readonly path: "/api/gov/upload";
|
||||||
|
readonly access: "design";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
contentType: z.ZodDefault<z.ZodString>;
|
contentType: z.ZodDefault<z.ZodString>;
|
||||||
@@ -2636,6 +2822,7 @@ export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
|
|||||||
export declare const downloadFileRoute: {
|
export declare const downloadFileRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/upload/:id";
|
readonly path: "/api/gov/upload/:id";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2685,6 +2872,7 @@ export type TestLogRow = z.infer<typeof testLogRowSchema>;
|
|||||||
export declare const listTestLogsRoute: {
|
export declare const listTestLogsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/testbed/logs";
|
readonly path: "/api/gov/testbed/logs";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
runs: z.ZodArray<z.ZodObject<{
|
runs: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -2720,6 +2908,7 @@ export declare const listTestLogsRoute: {
|
|||||||
export declare const testLogDetailRoute: {
|
export declare const testLogDetailRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/testbed/logs/:id";
|
readonly path: "/api/gov/testbed/logs/:id";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2774,6 +2963,7 @@ export declare const testLogDetailRoute: {
|
|||||||
export declare const deleteTestLogRoute: {
|
export declare const deleteTestLogRoute: {
|
||||||
readonly method: "DELETE";
|
readonly method: "DELETE";
|
||||||
readonly path: "/api/gov/testbed/logs/:id";
|
readonly path: "/api/gov/testbed/logs/:id";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2784,6 +2974,7 @@ export declare const deleteTestLogRoute: {
|
|||||||
export declare const clearTestLogsRoute: {
|
export declare const clearTestLogsRoute: {
|
||||||
readonly method: "DELETE";
|
readonly method: "DELETE";
|
||||||
readonly path: "/api/gov/testbed/logs";
|
readonly path: "/api/gov/testbed/logs";
|
||||||
|
readonly access: "design";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
deleted: z.ZodNumber;
|
deleted: z.ZodNumber;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2792,6 +2983,7 @@ export declare const clearTestLogsRoute: {
|
|||||||
export declare const traceOfWorkflowRoute: {
|
export declare const traceOfWorkflowRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/workflows/:id/trace";
|
readonly path: "/api/gov/workflows/:id/trace";
|
||||||
|
readonly access: "design";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2825,6 +3017,7 @@ export declare const traceOfWorkflowRoute: {
|
|||||||
export declare const govWorkflowRoute: {
|
export declare const govWorkflowRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/workflows/:id";
|
readonly path: "/api/gov/workflows/:id";
|
||||||
|
readonly access: readonly ["design", "ops"];
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -2986,6 +3179,7 @@ export declare const govWorkflowRoute: {
|
|||||||
export declare const govMetricsRoute: {
|
export declare const govMetricsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/metrics";
|
readonly path: "/api/gov/metrics";
|
||||||
|
readonly access: "ops";
|
||||||
/** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */
|
/** 统计窗口 —— 不传按月;驾驶舱可切日/周/月/年 */
|
||||||
readonly query: z.ZodObject<{
|
readonly query: z.ZodObject<{
|
||||||
period: z.ZodOptional<z.ZodEnum<{
|
period: z.ZodOptional<z.ZodEnum<{
|
||||||
|
|||||||
+23
-4
@@ -4,16 +4,35 @@
|
|||||||
* 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。
|
* 前端只经 `shared/typed-fetch` + 本目录的路由契约与服务端交互;组件内禁止裸 fetch。
|
||||||
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
|
* **禁止 Hono RPC 客户端** —— 它要求前端 import 后端类型,违反 §5.1。
|
||||||
*/
|
*/
|
||||||
import type { z } from 'zod';
|
import { z } from 'zod';
|
||||||
export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
export declare const HTTP_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||||
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||||
|
/**
|
||||||
|
* 三个端(ADR-012):设计中心 / 运营中心 / 工作台。
|
||||||
|
* 权限只到端 —— 一个角色能进哪几个端由运营中心的「角色 → 端」表定,不写死在代码里。
|
||||||
|
*/
|
||||||
|
export declare const SURFACES: readonly ["design", "ops", "workbench"];
|
||||||
|
export declare const surfaceSchema: z.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>;
|
||||||
|
export type Surface = z.infer<typeof surfaceSchema>;
|
||||||
|
export declare const SURFACE_LABEL: Readonly<Record<Surface, string>>;
|
||||||
|
/**
|
||||||
|
* 一条路由归哪个端(ADR-012):
|
||||||
|
* `public` 不需要凭据;`any` 登录即可、不看端;端名 / 端名数组 —— 能进其中任一端即可。
|
||||||
|
* 鉴权中间件按它统一判,路由里不再各写守卫;漏写编译不过(`satisfies RouteContract`)。
|
||||||
|
*/
|
||||||
|
export type RouteAccess = 'public' | 'any' | Surface | readonly Surface[];
|
||||||
/**
|
/**
|
||||||
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
|
* 一条路由契约。`path` 用 `:name` 表示路径参数,与 `params` 的键一一对应。
|
||||||
* body 只在 POST/PATCH 上有意义;GET 用 query。
|
* 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> = {
|
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 method: HttpMethod;
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
|
readonly access: RouteAccess;
|
||||||
readonly params?: TParams;
|
readonly params?: TParams;
|
||||||
readonly query?: TQuery;
|
readonly query?: TQuery;
|
||||||
readonly body?: TBody;
|
readonly body?: TBody;
|
||||||
@@ -21,8 +40,8 @@ export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery ex
|
|||||||
};
|
};
|
||||||
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
|
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
|
||||||
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny ? z.infer<R['body']> : never;
|
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny ? z.infer<R['body']> : never;
|
||||||
/** 结构化错误:前端按 code 分辨,不解析文案 */
|
/** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */
|
||||||
export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal"];
|
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 ApiErrorCode = (typeof API_ERROR_CODES)[number];
|
||||||
export type ApiError = {
|
export type ApiError = {
|
||||||
readonly code: ApiErrorCode;
|
readonly code: ApiErrorCode;
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './http.ts';
|
|||||||
export * from './workbench.ts';
|
export * from './workbench.ts';
|
||||||
export * from './governance.ts';
|
export * from './governance.ts';
|
||||||
export * from './ingest.ts';
|
export * from './ingest.ts';
|
||||||
|
export * from './mail.ts';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type InboundMail = z.infer<typeof inboundMailSchema>;
|
|||||||
export declare const ingestMailRoute: {
|
export declare const ingestMailRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/ingest/channel/:channelId/mail";
|
readonly path: "/api/ingest/channel/:channelId/mail";
|
||||||
|
readonly access: "public";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
channelId: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
|
channelId: z.core.$ZodBranded<z.ZodString, "ChannelId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
|||||||
+690
@@ -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<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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.ZodURL>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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<z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
password: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"imap-smtp">;
|
||||||
|
address: z.ZodEmail;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
corpId: z.ZodString;
|
||||||
|
apiBase: z.ZodDefault<z.ZodURL>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
corpSecret: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"wecom">;
|
||||||
|
}, z.core.$strip>], "kind">;
|
||||||
|
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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.ZodURL>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly body: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
settings: z.ZodPrefault<z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
password: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"imap-smtp">;
|
||||||
|
address: z.ZodEmail;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
corpId: z.ZodString;
|
||||||
|
apiBase: z.ZodDefault<z.ZodURL>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
corpSecret: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"wecom">;
|
||||||
|
}, z.core.$strip>], "kind">;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
"imap-smtp": "imap-smtp";
|
||||||
|
wecom: "wecom";
|
||||||
|
}>;
|
||||||
|
address: z.ZodOptional<z.ZodString>;
|
||||||
|
receive: z.ZodObject<{
|
||||||
|
ok: z.ZodBoolean;
|
||||||
|
error: z.ZodOptional<z.ZodString>;
|
||||||
|
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
send: z.ZodObject<{
|
||||||
|
checked: z.ZodBoolean;
|
||||||
|
ok: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
error: z.ZodOptional<z.ZodString>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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.ZodURL>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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<z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
password: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"imap-smtp">;
|
||||||
|
address: z.ZodEmail;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
corpId: z.ZodString;
|
||||||
|
apiBase: z.ZodDefault<z.ZodURL>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
corpSecret: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"wecom">;
|
||||||
|
}, z.core.$strip>], "kind">;
|
||||||
|
readonly output: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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.ZodURL>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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<true>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
receive: z.ZodObject<{
|
||||||
|
ok: z.ZodBoolean;
|
||||||
|
error: z.ZodOptional<z.ZodString>;
|
||||||
|
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
send: z.ZodObject<{
|
||||||
|
checked: z.ZodBoolean;
|
||||||
|
ok: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
error: z.ZodOptional<z.ZodString>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
providerRef: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
mails: z.ZodArray<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<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
cc: z.ZodArray<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
receivedAt: z.ZodISODateTime;
|
||||||
|
textPreview: z.ZodString;
|
||||||
|
hasHtml: z.ZodBoolean;
|
||||||
|
attachments: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
ref: z.ZodNullable<z.ZodString>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
deliveries: z.ZodArray<z.ZodObject<{
|
||||||
|
subscriber: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
done: "done";
|
||||||
|
dispatching: "dispatching";
|
||||||
|
skipped: "skipped";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodISODateTime;
|
||||||
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, 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<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
cc: z.ZodArray<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
receivedAt: z.ZodISODateTime;
|
||||||
|
textPreview: z.ZodString;
|
||||||
|
hasHtml: z.ZodBoolean;
|
||||||
|
attachments: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
ref: z.ZodNullable<z.ZodString>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
deliveries: z.ZodArray<z.ZodObject<{
|
||||||
|
subscriber: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
done: "done";
|
||||||
|
dispatching: "dispatching";
|
||||||
|
skipped: "skipped";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodISODateTime;
|
||||||
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
inReplyTo: z.ZodNullable<z.ZodString>;
|
||||||
|
references: z.ZodArray<z.ZodString>;
|
||||||
|
text: z.ZodNullable<z.ZodString>;
|
||||||
|
html: z.ZodNullable<z.ZodString>;
|
||||||
|
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<unknown>;
|
||||||
|
}, 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.ZodObject<{
|
||||||
|
subscriber: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
deliveries: z.ZodArray<z.ZodObject<{
|
||||||
|
subscriber: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
done: "done";
|
||||||
|
dispatching: "dispatching";
|
||||||
|
skipped: "skipped";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodISODateTime;
|
||||||
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
limit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
mails: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
accountId: z.ZodString;
|
||||||
|
messageId: z.ZodString;
|
||||||
|
to: z.ZodArray<z.ZodString>;
|
||||||
|
cc: z.ZodArray<z.ZodString>;
|
||||||
|
bcc: z.ZodArray<z.ZodString>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
sending: "sending";
|
||||||
|
sent: "sent";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
providerRef: z.ZodNullable<z.ZodString>;
|
||||||
|
requestedBy: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
createdAt: z.ZodISODateTime;
|
||||||
|
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/** 全部 15 条 —— 文档总表与覆盖测试按它数 */
|
||||||
|
export declare const MAIL_ROUTES: readonly RouteContract[];
|
||||||
+79
-6
@@ -3,20 +3,23 @@
|
|||||||
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
|
* 工作台五要素:任务 / 审批 / 消息 / 纪要 / 汇报 —— 同一份过程事实的五种投影。
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
/** 我是谁 —— 无会话即 401,前端据此落登录页(ADR-010) */
|
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
|
||||||
export declare const sessionRoute: {
|
export declare const sessionRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/session";
|
readonly path: "/api/session";
|
||||||
|
readonly access: "public";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
staff: z.ZodObject<{
|
staff: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
state: z.ZodDefault<z.ZodEnum<{
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
active: "active";
|
active: "active";
|
||||||
disabled: "disabled";
|
disabled: "disabled";
|
||||||
}>>;
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
position: z.ZodObject<{
|
position: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
@@ -56,13 +59,11 @@ export declare const sessionRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
/**
|
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
|
||||||
* 登录 —— 单组织 cookie session(编码规范 §4 / ADR-010)。
|
|
||||||
* 成功即种 `jcai_session`(HttpOnly);失败只回一句话,不区分账号不存在还是口令错。
|
|
||||||
*/
|
|
||||||
export declare const loginRoute: {
|
export declare const loginRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/login";
|
readonly path: "/api/login";
|
||||||
|
readonly access: "public";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
password: z.ZodString;
|
password: z.ZodString;
|
||||||
@@ -72,11 +73,13 @@ export declare const loginRoute: {
|
|||||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
state: z.ZodDefault<z.ZodEnum<{
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
active: "active";
|
active: "active";
|
||||||
disabled: "disabled";
|
disabled: "disabled";
|
||||||
}>>;
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
position: z.ZodObject<{
|
position: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
@@ -116,10 +119,11 @@ export declare const loginRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
/** 登出 —— 服务端会话立刻作废,不等 cookie 到期 */
|
/** @deprecated 随 ADR-012 退役,后端回 410;改用 meRoute */
|
||||||
export declare const logoutRoute: {
|
export declare const logoutRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/logout";
|
readonly path: "/api/logout";
|
||||||
|
readonly access: "public";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
ok: z.ZodBoolean;
|
ok: z.ZodBoolean;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -127,6 +131,7 @@ export declare const logoutRoute: {
|
|||||||
export declare const healthRoute: {
|
export declare const healthRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/health";
|
readonly path: "/api/health";
|
||||||
|
readonly access: "public";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
ok: z.ZodBoolean;
|
ok: z.ZodBoolean;
|
||||||
version: z.ZodString;
|
version: z.ZodString;
|
||||||
@@ -136,6 +141,51 @@ export declare const healthRoute: {
|
|||||||
}>;
|
}>;
|
||||||
}, z.core.$strip>;
|
}, 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<z.ZodString, "StaffId", "out">;
|
||||||
|
account: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
position: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
code: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
roles: z.ZodArray<z.ZodString>;
|
||||||
|
ends: z.ZodArray<z.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路:
|
||||||
|
* `EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。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<{
|
export declare const taskCardSchema: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
@@ -177,6 +227,7 @@ export type TaskCard = z.infer<typeof taskCardSchema>;
|
|||||||
export declare const listTasksRoute: {
|
export declare const listTasksRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/desk/tasks";
|
readonly path: "/api/desk/tasks";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
tasks: z.ZodArray<z.ZodObject<{
|
tasks: z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
@@ -2138,6 +2189,7 @@ export type TaskDetail = z.infer<typeof taskDetailSchema>;
|
|||||||
export declare const taskDetailRoute: {
|
export declare const taskDetailRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/desk/tasks/:taskId";
|
readonly path: "/api/desk/tasks/:taskId";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4043,6 +4095,7 @@ export declare const taskDetailRoute: {
|
|||||||
export declare const replyWorkOrderRoute: {
|
export declare const replyWorkOrderRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/work-orders/reply";
|
readonly path: "/api/desk/work-orders/reply";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4058,6 +4111,7 @@ export declare const replyWorkOrderRoute: {
|
|||||||
export declare const replyChatCardRoute: {
|
export declare const replyChatCardRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/chat-cards/reply";
|
readonly path: "/api/desk/chat-cards/reply";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
reply: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4072,6 +4126,7 @@ export declare const replyChatCardRoute: {
|
|||||||
export declare const markCommReadRoute: {
|
export declare const markCommReadRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/comms/:commId/read";
|
readonly path: "/api/desk/comms/:commId/read";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
commId: z.ZodString;
|
commId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4120,6 +4175,7 @@ export declare const markCommReadRoute: {
|
|||||||
export declare const saveTableRoute: {
|
export declare const saveTableRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/tasks/:taskId/table";
|
readonly path: "/api/desk/tasks/:taskId/table";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4149,6 +4205,7 @@ export declare const saveTableRoute: {
|
|||||||
export declare const openAssetFileRoute: {
|
export declare const openAssetFileRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/desk/tasks/:taskId/file";
|
readonly path: "/api/desk/tasks/:taskId/file";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4199,6 +4256,7 @@ export declare const openAssetFileRoute: {
|
|||||||
export declare const sayRoute: {
|
export declare const sayRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/tasks/:taskId/say";
|
readonly path: "/api/desk/tasks/:taskId/say";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
taskId: z.core.$ZodBranded<z.ZodString, "TaskId", "out">;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4393,6 +4451,7 @@ export type SystemMessageView = z.infer<typeof systemMessageSchema>;
|
|||||||
export declare const listMessagesRoute: {
|
export declare const listMessagesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/desk/messages";
|
readonly path: "/api/desk/messages";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
messages: z.ZodArray<z.ZodObject<{
|
messages: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -4448,6 +4507,7 @@ export declare const listMessagesRoute: {
|
|||||||
export declare const markMessageReadRoute: {
|
export declare const markMessageReadRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/messages/:messageId/read";
|
readonly path: "/api/desk/messages/:messageId/read";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
messageId: z.ZodString;
|
messageId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4459,6 +4519,7 @@ export declare const markMessageReadRoute: {
|
|||||||
export declare const answerMessageRoute: {
|
export declare const answerMessageRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/messages/:messageId/answer";
|
readonly path: "/api/desk/messages/:messageId/answer";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
messageId: z.ZodString;
|
messageId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4473,6 +4534,7 @@ export declare const answerMessageRoute: {
|
|||||||
export declare const confirmTriggerPendingRoute: {
|
export declare const confirmTriggerPendingRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/desk/trigger-pending/:id/confirm";
|
readonly path: "/api/desk/trigger-pending/:id/confirm";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4521,6 +4583,7 @@ export type MinuteView = z.infer<typeof minuteSchema>;
|
|||||||
export declare const listMinutesRoute: {
|
export declare const listMinutesRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/minutes";
|
readonly path: "/api/minutes";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
minutes: z.ZodArray<z.ZodObject<{
|
minutes: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -4550,6 +4613,7 @@ export declare const listMinutesRoute: {
|
|||||||
export declare const createMinuteRoute: {
|
export declare const createMinuteRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/minutes";
|
readonly path: "/api/minutes";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
body: z.ZodString;
|
body: z.ZodString;
|
||||||
@@ -4587,6 +4651,7 @@ export declare const createMinuteRoute: {
|
|||||||
export declare const appendResolutionRoute: {
|
export declare const appendResolutionRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/minutes/:minuteId/resolutions";
|
readonly path: "/api/minutes/:minuteId/resolutions";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
minuteId: z.ZodString;
|
minuteId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4622,6 +4687,7 @@ export declare const appendResolutionRoute: {
|
|||||||
export declare const registerResolutionRoute: {
|
export declare const registerResolutionRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/minutes/resolutions/:resolutionId/register";
|
readonly path: "/api/minutes/resolutions/:resolutionId/register";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
resolutionId: z.ZodString;
|
resolutionId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4704,6 +4770,7 @@ export type ReportView = z.infer<typeof reportSchema>;
|
|||||||
export declare const draftReportRoute: {
|
export declare const draftReportRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/reports/:period";
|
readonly path: "/api/reports/:period";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
period: z.ZodEnum<{
|
period: z.ZodEnum<{
|
||||||
day: "day";
|
day: "day";
|
||||||
@@ -4759,6 +4826,7 @@ export declare const draftReportRoute: {
|
|||||||
export declare const listReportsRoute: {
|
export declare const listReportsRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/reports";
|
readonly path: "/api/reports";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
reports: z.ZodArray<z.ZodObject<{
|
reports: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -4809,6 +4877,7 @@ export declare const listReportsRoute: {
|
|||||||
export declare const submitReportRoute: {
|
export declare const submitReportRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/reports/:reportId/submit";
|
readonly path: "/api/reports/:reportId/submit";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
reportId: z.ZodString;
|
reportId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -4884,6 +4953,7 @@ export type ApprovalItem = z.infer<typeof approvalItemSchema>;
|
|||||||
export declare const listApprovalsForMeRoute: {
|
export declare const listApprovalsForMeRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/approvals";
|
readonly path: "/api/approvals";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
approvals: z.ZodArray<z.ZodObject<{
|
approvals: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -4910,6 +4980,7 @@ export declare const listApprovalsForMeRoute: {
|
|||||||
export declare const decideApprovalRoute: {
|
export declare const decideApprovalRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/approvals/:workflowId/:nodeId/decide";
|
readonly path: "/api/approvals/:workflowId/:nodeId/decide";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
workflowId: z.ZodString;
|
workflowId: z.ZodString;
|
||||||
nodeId: z.ZodString;
|
nodeId: z.ZodString;
|
||||||
@@ -4940,6 +5011,7 @@ export type ListApprovalsForMeOutput = z.infer<typeof listApprovalsForMeRoute.ou
|
|||||||
export declare const listRegistrationTriggersRoute: {
|
export declare const listRegistrationTriggersRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/triggers/registration";
|
readonly path: "/api/triggers/registration";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
triggers: z.ZodArray<z.ZodObject<{
|
triggers: z.ZodArray<z.ZodObject<{
|
||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
@@ -4953,6 +5025,7 @@ export declare const listRegistrationTriggersRoute: {
|
|||||||
export declare const fireRegistrationRoute: {
|
export declare const fireRegistrationRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/triggers/:triggerId/register";
|
readonly path: "/api/triggers/:triggerId/register";
|
||||||
|
readonly access: "workbench";
|
||||||
readonly params: z.ZodObject<{
|
readonly params: z.ZodObject<{
|
||||||
triggerId: z.ZodString;
|
triggerId: z.ZodString;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export * from './ledger.ts';
|
|||||||
export * from './process-package.ts';
|
export * from './process-package.ts';
|
||||||
export * from './manifest.ts';
|
export * from './manifest.ts';
|
||||||
export * from './org.ts';
|
export * from './org.ts';
|
||||||
|
export * from './mail.ts';
|
||||||
export * from './api/index.ts';
|
export * from './api/index.ts';
|
||||||
|
|||||||
+364
@@ -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<typeof mailAccountKindSchema>;
|
||||||
|
export declare const MAIL_ACCOUNT_KIND_LABEL: Readonly<Record<MailAccountKind, string>>;
|
||||||
|
/** 标准协议:默认值就是腾讯企业邮箱官方帮助给的服务器与 SSL 端口 */
|
||||||
|
export declare const imapSmtpSettingsSchema: z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ImapSmtpSettings = z.infer<typeof imapSmtpSettingsSchema>;
|
||||||
|
/** 企业微信邮件 API:corpid + 自建应用 secret(密封,不在 settings 里) */
|
||||||
|
export declare const wecomSettingsSchema: z.ZodObject<{
|
||||||
|
corpId: z.ZodString;
|
||||||
|
apiBase: z.ZodDefault<z.ZodURL>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type WecomSettings = z.infer<typeof wecomSettingsSchema>;
|
||||||
|
export declare const mailAccountInputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
settings: z.ZodPrefault<z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
password: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"imap-smtp">;
|
||||||
|
address: z.ZodEmail;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
corpId: z.ZodString;
|
||||||
|
apiBase: z.ZodDefault<z.ZodURL>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
secret: z.ZodOptional<z.ZodObject<{
|
||||||
|
corpSecret: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
name: z.ZodString;
|
||||||
|
displayName: z.ZodDefault<z.ZodString>;
|
||||||
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
isDefault: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
pollIntervalSec: z.ZodDefault<z.ZodNumber>;
|
||||||
|
channelId: z.ZodDefault<z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ChannelId", "out">>>;
|
||||||
|
backfillFrom: z.ZodDefault<z.ZodNullable<z.ZodISODateTime>>;
|
||||||
|
kind: z.ZodLiteral<"wecom">;
|
||||||
|
}, z.core.$strip>], "kind">;
|
||||||
|
export type MailAccountInput = z.infer<typeof mailAccountInputSchema>;
|
||||||
|
export declare const mailAccountStatusSchema: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
polling: z.ZodBoolean;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export declare const mailAccountViewSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
settings: z.ZodObject<{
|
||||||
|
imapHost: z.ZodDefault<z.ZodString>;
|
||||||
|
imapPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
imapTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
smtpHost: z.ZodDefault<z.ZodString>;
|
||||||
|
smtpPort: z.ZodDefault<z.ZodNumber>;
|
||||||
|
smtpTls: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
login: z.ZodOptional<z.ZodString>;
|
||||||
|
folder: z.ZodDefault<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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.ZodURL>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
backfillFrom: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
secretBound: z.ZodBoolean;
|
||||||
|
status: z.ZodObject<{
|
||||||
|
lastPolledAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
lastPollError: z.ZodNullable<z.ZodString>;
|
||||||
|
lastInboundAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
cursor: z.ZodNullable<z.ZodString>;
|
||||||
|
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<typeof mailAccountViewSchema>;
|
||||||
|
/** 探测报告:收 / 发各说各的;wecom 的发信一栏 `checked:false` —— 拿到 token 不等于能发 */
|
||||||
|
export declare const mailProbeReportSchema: z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
"imap-smtp": "imap-smtp";
|
||||||
|
wecom: "wecom";
|
||||||
|
}>;
|
||||||
|
address: z.ZodOptional<z.ZodString>;
|
||||||
|
receive: z.ZodObject<{
|
||||||
|
ok: z.ZodBoolean;
|
||||||
|
error: z.ZodOptional<z.ZodString>;
|
||||||
|
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
send: z.ZodObject<{
|
||||||
|
checked: z.ZodBoolean;
|
||||||
|
ok: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
error: z.ZodOptional<z.ZodString>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailProbeReport = z.infer<typeof mailProbeReportSchema>;
|
||||||
|
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<typeof mailDeliveryStateSchema>;
|
||||||
|
export declare const mailDeliveryViewSchema: z.ZodObject<{
|
||||||
|
subscriber: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
done: "done";
|
||||||
|
dispatching: "dispatching";
|
||||||
|
skipped: "skipped";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodISODateTime;
|
||||||
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailDeliveryView = z.infer<typeof mailDeliveryViewSchema>;
|
||||||
|
export declare const mailAddressSchema: z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailAddress = z.infer<typeof mailAddressSchema>;
|
||||||
|
export declare const mailInboundAttachmentSchema: z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
ref: z.ZodNullable<z.ZodString>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailInboundAttachment = z.infer<typeof mailInboundAttachmentSchema>;
|
||||||
|
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<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
cc: z.ZodArray<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
receivedAt: z.ZodISODateTime;
|
||||||
|
textPreview: z.ZodString;
|
||||||
|
hasHtml: z.ZodBoolean;
|
||||||
|
attachments: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
ref: z.ZodNullable<z.ZodString>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
deliveries: z.ZodArray<z.ZodObject<{
|
||||||
|
subscriber: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
done: "done";
|
||||||
|
dispatching: "dispatching";
|
||||||
|
skipped: "skipped";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodISODateTime;
|
||||||
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailInboundSummary = z.infer<typeof mailInboundSummarySchema>;
|
||||||
|
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<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
cc: z.ZodArray<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
receivedAt: z.ZodISODateTime;
|
||||||
|
textPreview: z.ZodString;
|
||||||
|
hasHtml: z.ZodBoolean;
|
||||||
|
attachments: z.ZodArray<z.ZodObject<{
|
||||||
|
index: z.ZodNumber;
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodString;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
ref: z.ZodNullable<z.ZodString>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
deliveries: z.ZodArray<z.ZodObject<{
|
||||||
|
subscriber: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
done: "done";
|
||||||
|
dispatching: "dispatching";
|
||||||
|
skipped: "skipped";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
startedAt: z.ZodISODateTime;
|
||||||
|
finishedAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
inReplyTo: z.ZodNullable<z.ZodString>;
|
||||||
|
references: z.ZodArray<z.ZodString>;
|
||||||
|
text: z.ZodNullable<z.ZodString>;
|
||||||
|
html: z.ZodNullable<z.ZodString>;
|
||||||
|
size: z.ZodNumber;
|
||||||
|
rawAvailable: z.ZodBoolean;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailInboundDetail = z.infer<typeof mailInboundDetailSchema>;
|
||||||
|
export declare const mailSendInputSchema: z.ZodObject<{
|
||||||
|
accountId: z.ZodOptional<z.ZodString>;
|
||||||
|
from: z.ZodOptional<z.ZodString>;
|
||||||
|
to: z.ZodArray<z.ZodEmail>;
|
||||||
|
cc: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
|
||||||
|
bcc: z.ZodDefault<z.ZodArray<z.ZodEmail>>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
text: z.ZodOptional<z.ZodString>;
|
||||||
|
html: z.ZodOptional<z.ZodString>;
|
||||||
|
inReplyTo: z.ZodOptional<z.ZodString>;
|
||||||
|
references: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
attachments: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
contentType: z.ZodDefault<z.ZodString>;
|
||||||
|
dataBase64: z.ZodOptional<z.ZodString>;
|
||||||
|
uploadRef: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
||||||
|
requestedBy: z.ZodObject<{
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
channel: "channel";
|
||||||
|
tool: "tool";
|
||||||
|
ops: "ops";
|
||||||
|
}>;
|
||||||
|
taskId: z.ZodOptional<z.ZodString>;
|
||||||
|
staffId: z.ZodOptional<z.ZodString>;
|
||||||
|
tool: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailSendInput = z.infer<typeof mailSendInputSchema>;
|
||||||
|
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<typeof mailOutboundStateSchema>;
|
||||||
|
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<z.ZodString>;
|
||||||
|
providerRef: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailSendResult = z.infer<typeof mailSendResultSchema>;
|
||||||
|
export declare const mailOutboundViewSchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
accountId: z.ZodString;
|
||||||
|
messageId: z.ZodString;
|
||||||
|
to: z.ZodArray<z.ZodString>;
|
||||||
|
cc: z.ZodArray<z.ZodString>;
|
||||||
|
bcc: z.ZodArray<z.ZodString>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
failed: "failed";
|
||||||
|
sending: "sending";
|
||||||
|
sent: "sent";
|
||||||
|
}>;
|
||||||
|
error: z.ZodNullable<z.ZodString>;
|
||||||
|
providerRef: z.ZodNullable<z.ZodString>;
|
||||||
|
requestedBy: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
createdAt: z.ZodISODateTime;
|
||||||
|
sentAt: z.ZodNullable<z.ZodISODateTime>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailOutboundView = z.infer<typeof mailOutboundViewSchema>;
|
||||||
|
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.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MailPollOutcome = z.infer<typeof mailPollOutcomeSchema>;
|
||||||
+115
-23
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 员工、岗位与权限(编码规范 §4 · `org`;语义出处 OS 02 员工管理)。
|
* 员工、岗位与端(编码规范 §4 · `org`;语义出处 OS 02 员工管理;鉴权归统一鉴权中心,ADR-012)。
|
||||||
*
|
*
|
||||||
* 权限按岗位授予:客体三类(数据 域.对象[.字段] / 治理功能 / 任务)× 动作三种(读 / 写 / 治理)。
|
* 身份来自 Keycloak:`Staff.id` 就是 token 的 `sub`。权限只到「端」(design / ops / workbench),
|
||||||
* 任务负责人与被征答人对所涉内容**天然可见**,不另行授权。
|
* 角色 → 端由运营中心的表定;岗位仍是本系统的表(流程引擎按它解析负责人)。
|
||||||
* 单组织、≤50 人:不做多租户,不引 OAuth/OIDC(编码规范 §4 鉴权定调)。
|
* 旧的岗位授权(客体三类 × 动作三种)与口令会话已退役 —— 类型留一轮(@deprecated,下个迭代删)。
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
export declare const positionSchema: z.ZodObject<{
|
export declare const positionSchema: z.ZodObject<{
|
||||||
@@ -23,23 +23,123 @@ export declare const staffSchema: z.ZodObject<{
|
|||||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
state: z.ZodDefault<z.ZodEnum<{
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
active: "active";
|
active: "active";
|
||||||
disabled: "disabled";
|
disabled: "disabled";
|
||||||
}>>;
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type Staff = z.infer<typeof staffSchema>;
|
export type Staff = z.infer<typeof staffSchema>;
|
||||||
|
export declare const principalSchema: z.ZodObject<{
|
||||||
|
staff: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
account: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
|
active: "active";
|
||||||
|
disabled: "disabled";
|
||||||
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
position: z.ZodObject<{
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
|
code: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
roles: z.ZodArray<z.ZodString>;
|
||||||
|
ends: z.ZodArray<z.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type Principal = z.infer<typeof principalSchema>;
|
||||||
|
/** 角色 → 端:一行一个 realm 角色 */
|
||||||
|
export declare const roleAccessSchema: z.ZodObject<{
|
||||||
|
role: z.ZodString;
|
||||||
|
ends: z.ZodArray<z.ZodEnum<{
|
||||||
|
design: "design";
|
||||||
|
ops: "ops";
|
||||||
|
workbench: "workbench";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type RoleAccess = z.infer<typeof roleAccessSchema>;
|
||||||
|
/**
|
||||||
|
* 名册同步的结果 —— 没配 provisioning 就如实说没配,不摆零。
|
||||||
|
* 三个计数说的是**真落进库里的条数**,不是打算写几条:单条写失败时它不进计数,
|
||||||
|
* 而是点名进 `failed` —— 一次半途失败的同步不许报成一排绿数字。
|
||||||
|
*/
|
||||||
|
export declare const rosterSyncOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
configured: z.ZodLiteral<false>;
|
||||||
|
message: z.ZodString;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
configured: z.ZodLiteral<true>;
|
||||||
|
inserted: z.ZodNumber;
|
||||||
|
updated: z.ZodNumber;
|
||||||
|
disabled: z.ZodNumber;
|
||||||
|
total: z.ZodNumber;
|
||||||
|
failed: z.ZodArray<z.ZodObject<{
|
||||||
|
account: z.ZodString;
|
||||||
|
reason: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>], "configured">;
|
||||||
|
export type RosterSyncOutcome = z.infer<typeof rosterSyncOutcomeSchema>;
|
||||||
|
export declare const inFlightWorkSchema: z.ZodObject<{
|
||||||
|
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
blocked: z.ZodBoolean;
|
||||||
|
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
cardId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
approvalId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type InFlightWork = z.infer<typeof inFlightWorkSchema>;
|
||||||
|
/** @deprecated 随 ADR-012 改名 `inFlightWorkSchema`;下个迭代删 */
|
||||||
|
export declare const disableCheckSchema: z.ZodObject<{
|
||||||
|
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
blocked: z.ZodBoolean;
|
||||||
|
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
cardId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
approvalId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
/** @deprecated 改用 `InFlightWork` */
|
||||||
|
export type DisableCheck = InFlightWork;
|
||||||
|
/** @deprecated 权限只到端,动作三分已退役 */
|
||||||
export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"];
|
export declare const GRANT_ACTIONS: readonly ["read", "write", "govern"];
|
||||||
|
/** @deprecated */
|
||||||
export declare const grantActionSchema: z.ZodEnum<{
|
export declare const grantActionSchema: z.ZodEnum<{
|
||||||
read: "read";
|
read: "read";
|
||||||
write: "write";
|
write: "write";
|
||||||
govern: "govern";
|
govern: "govern";
|
||||||
}>;
|
}>;
|
||||||
|
/** @deprecated */
|
||||||
export type GrantAction = z.infer<typeof grantActionSchema>;
|
export type GrantAction = z.infer<typeof grantActionSchema>;
|
||||||
|
/** @deprecated */
|
||||||
export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
|
export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
|
||||||
/** 治理台九功能 —— 授权客体之一 */
|
/** @deprecated 治理台九功能不再是授权客体 */
|
||||||
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
||||||
|
/** @deprecated */
|
||||||
export declare const governanceFunctionSchema: z.ZodEnum<{
|
export declare const governanceFunctionSchema: z.ZodEnum<{
|
||||||
trigger: "trigger";
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
@@ -51,8 +151,11 @@ export declare const governanceFunctionSchema: z.ZodEnum<{
|
|||||||
process: "process";
|
process: "process";
|
||||||
memory: "memory";
|
memory: "memory";
|
||||||
}>;
|
}>;
|
||||||
|
/** @deprecated */
|
||||||
export type GovernanceFunction = z.infer<typeof governanceFunctionSchema>;
|
export type GovernanceFunction = z.infer<typeof governanceFunctionSchema>;
|
||||||
|
/** @deprecated */
|
||||||
export declare const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>>;
|
export declare const GOVERNANCE_FUNCTION_LABEL: Readonly<Record<GovernanceFunction, string>>;
|
||||||
|
/** @deprecated */
|
||||||
export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"data">;
|
kind: z.ZodLiteral<"data">;
|
||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
@@ -75,7 +178,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
kind: z.ZodLiteral<"task">;
|
kind: z.ZodLiteral<"task">;
|
||||||
activityId: z.ZodOptional<z.ZodString>;
|
activityId: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>], "kind">;
|
}, z.core.$strip>], "kind">;
|
||||||
|
/** @deprecated */
|
||||||
export type GrantObject = z.infer<typeof grantObjectSchema>;
|
export type GrantObject = z.infer<typeof grantObjectSchema>;
|
||||||
|
/** @deprecated */
|
||||||
export declare const grantSchema: z.ZodObject<{
|
export declare const grantSchema: z.ZodObject<{
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
object: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
@@ -106,35 +211,21 @@ export declare const grantSchema: z.ZodObject<{
|
|||||||
govern: "govern";
|
govern: "govern";
|
||||||
}>;
|
}>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
/** @deprecated */
|
||||||
export type Grant = z.infer<typeof grantSchema>;
|
export type Grant = z.infer<typeof grantSchema>;
|
||||||
/** 员工停用前,其在途任务与待答卡须先改派完毕 */
|
/** @deprecated 改用 `principalSchema` / `GET /api/me` */
|
||||||
export declare const disableCheckSchema: z.ZodObject<{
|
|
||||||
staffId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
|
||||||
blocked: z.ZodBoolean;
|
|
||||||
openTasks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
||||||
taskId: z.ZodString;
|
|
||||||
title: z.ZodString;
|
|
||||||
}, z.core.$strip>>>;
|
|
||||||
openCards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
||||||
cardId: z.ZodString;
|
|
||||||
title: z.ZodString;
|
|
||||||
}, z.core.$strip>>>;
|
|
||||||
openApprovals: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
||||||
approvalId: z.ZodString;
|
|
||||||
title: z.ZodString;
|
|
||||||
}, z.core.$strip>>>;
|
|
||||||
}, z.core.$strip>;
|
|
||||||
export type DisableCheck = z.infer<typeof disableCheckSchema>;
|
|
||||||
export declare const sessionSchema: z.ZodObject<{
|
export declare const sessionSchema: z.ZodObject<{
|
||||||
staff: z.ZodObject<{
|
staff: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
account: z.ZodString;
|
account: z.ZodString;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
|
email: z.ZodOptional<z.ZodString>;
|
||||||
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
positionId: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
state: z.ZodDefault<z.ZodEnum<{
|
state: z.ZodDefault<z.ZodEnum<{
|
||||||
active: "active";
|
active: "active";
|
||||||
disabled: "disabled";
|
disabled: "disabled";
|
||||||
}>>;
|
}>>;
|
||||||
|
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
position: z.ZodObject<{
|
position: z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
|
||||||
@@ -173,6 +264,7 @@ export declare const sessionSchema: z.ZodObject<{
|
|||||||
}>;
|
}>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
/** @deprecated 改用 `Principal` */
|
||||||
export type Session = z.infer<typeof sessionSchema>;
|
export type Session = z.infer<typeof sessionSchema>;
|
||||||
/**
|
/**
|
||||||
* 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。
|
* 工作组 —— 一组「岗位 → 员工」的绑定,起单时装载、随工作流走。
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"seed": "node dist/seed.js",
|
"seed": "node dist/seed.js",
|
||||||
"migrate": "node dist/migrate.js",
|
"migrate": "node dist/migrate.js",
|
||||||
"set-password": "node dist/set-password.js",
|
|
||||||
"reset-runs": "node dist/reset-runs.js",
|
"reset-runs": "node dist/reset-runs.js",
|
||||||
"install-activities": "node dist/install-activities.js"
|
"install-activities": "node dist/install-activities.js"
|
||||||
},
|
},
|
||||||
@@ -26,11 +25,16 @@
|
|||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"hono": "^4.12.32",
|
"hono": "^4.12.32",
|
||||||
|
"imapflow": "^1.7.6",
|
||||||
|
"jose": "^5.10.0",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"pg-boss": "^12.26.3",
|
"pg-boss": "^12.26.3",
|
||||||
|
"postal-mime": "^3.0.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "^8.20.0"
|
"@types/pg": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { TaskId } from '@contracts';
|
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;
|
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 —— 存哪些、不存哪些', () => {
|
describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
||||||
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
|
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
|
||||||
const db = fakeDb();
|
const db = fakeDb();
|
||||||
@@ -62,6 +67,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
|||||||
taskId: TASK,
|
taskId: TASK,
|
||||||
ids,
|
ids,
|
||||||
declarations: [ASSET],
|
declarations: [ASSET],
|
||||||
|
touched: touch('CT-1', 'CT-2'),
|
||||||
rowsOfAddress: async () => [
|
rowsOfAddress: async () => [
|
||||||
{ content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' },
|
{ 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' },
|
{ 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('白皮书');
|
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 () => {
|
it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => {
|
||||||
const db = fakeDb();
|
const db = fakeDb();
|
||||||
const saved = await archiveAssetFiles({
|
const saved = await archiveAssetFiles({
|
||||||
@@ -79,6 +135,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
|||||||
taskId: TASK,
|
taskId: TASK,
|
||||||
ids,
|
ids,
|
||||||
declarations: [ASSET],
|
declarations: [ASSET],
|
||||||
|
touched: touch('CT-1'),
|
||||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
|
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
|
||||||
});
|
});
|
||||||
expect(saved).toBe(0);
|
expect(saved).toBe(0);
|
||||||
@@ -92,6 +149,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
|||||||
taskId: TASK,
|
taskId: TASK,
|
||||||
ids,
|
ids,
|
||||||
declarations: [ASSET],
|
declarations: [ASSET],
|
||||||
|
touched: touch(''),
|
||||||
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
|
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||||
});
|
});
|
||||||
expect(saved).toBe(0);
|
expect(saved).toBe(0);
|
||||||
@@ -104,6 +162,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
|||||||
taskId: TASK,
|
taskId: TASK,
|
||||||
ids,
|
ids,
|
||||||
declarations: [{ ...ASSET, file: undefined }],
|
declarations: [{ ...ASSET, file: undefined }],
|
||||||
|
touched: touch('CT-1'),
|
||||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
|
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
|
||||||
});
|
});
|
||||||
expect(saved).toBe(0);
|
expect(saved).toBe(0);
|
||||||
@@ -116,6 +175,7 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
|||||||
taskId: TASK,
|
taskId: TASK,
|
||||||
ids,
|
ids,
|
||||||
declarations: [ASSET],
|
declarations: [ASSET],
|
||||||
|
touched: touch('CT-1'),
|
||||||
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
|
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
|
||||||
});
|
});
|
||||||
expect(saved).toBe(1);
|
expect(saved).toBe(1);
|
||||||
@@ -125,7 +185,32 @@ describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
|
|||||||
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
|
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
|
||||||
const db = fakeDb();
|
const db = fakeDb();
|
||||||
expect(
|
expect(
|
||||||
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }),
|
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], touched: [], rowsOfAddress: async () => [] }),
|
||||||
).toBe(0);
|
).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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<AppEnv> {
|
||||||
|
const app = new Hono<AppEnv>();
|
||||||
|
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<Response>(Hono 4.12);包一层 Promise.resolve
|
||||||
|
// 只是把类型收窄成调用方好用的 Promise<Response>,不改变行为(await 对两者结果一致)。
|
||||||
|
const get = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||||
|
Promise.resolve(app.request(path, { headers }));
|
||||||
|
const post = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||||
|
Promise.resolve(app.request(path, { method: 'POST', headers }));
|
||||||
|
const head = (path: string, headers: Record<string, string> = {}): Promise<Response> =>
|
||||||
|
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('无端可进');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AppEnv>();
|
||||||
|
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: '<m1@a>' })));
|
||||||
|
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: '<x-1@we.com>' });
|
||||||
|
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: '<m1@a>', 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<typeof harness>, id: string): Promise<Response> {
|
||||||
|
return Promise.resolve(h.app.request(`/api/gov/mail/inbound/${id}/redeliver`, { method: 'POST' }));
|
||||||
|
}
|
||||||
@@ -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: '<m1@acme.com>',
|
||||||
|
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: ['<root@we.com>', '<q1@we.com>'],
|
||||||
|
text: null,
|
||||||
|
html: '<p>你好</p>',
|
||||||
|
size: 100,
|
||||||
|
rawAvailable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeSend(result: Partial<MailSendResult> = {}): { send: (input: MailSendInput) => Promise<MailSendResult>; calls: MailSendInput[] } {
|
||||||
|
const calls: MailSendInput[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
send: async (input) => {
|
||||||
|
calls.push(input);
|
||||||
|
return { outboundId: 'out-1', accountId: 'acc-1', messageId: '<out-1@we.com>', state: 'sent', ...result };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toInboundMail', () => {
|
||||||
|
it('线程取 References 首项(即这一串的根);只带存下来的附件;纯 html 剥成正文', () => {
|
||||||
|
const mail: InboundMail = toInboundMail(detail);
|
||||||
|
// 不是末项 <q1@we.com>:我方出站登记的恒是首封,①级归属是精确等值比对 ——
|
||||||
|
// 取最近一跳的话,跑一个来回(我方再回一封)线程就漂到库里没记过的 Message-ID 上,①级落空退③级
|
||||||
|
expect(mail.threadId).toBe('<root@we.com>');
|
||||||
|
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: '<q1@we.com>' };
|
||||||
|
expect(toInboundMail(onlyHop).threadId).toBe(threadIdOf(onlyHop));
|
||||||
|
expect(toInboundMail(onlyHop).threadId).toBe('<q1@we.com>');
|
||||||
|
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('<m1@acme.com>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<m1@acme.com>', attachments: [{ name: 'q.pdf', ref: 'up-9' }] });
|
||||||
|
expect(sent).toEqual({ messageId: '<out-1@we.com>', threadId: '<m1@acme.com>' });
|
||||||
|
expect(mail.calls[0]).toMatchObject({ from: 'sales@we.com', inReplyTo: '<m1@acme.com>', references: ['<m1@acme.com>'], 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('<out-1@we.com>');
|
||||||
|
});
|
||||||
|
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: '<m1@a>', attachments: [{ uploadRef: 'up-1', name: 'q.pdf' }] } });
|
||||||
|
expect(result).toEqual({ ok: true, output: { queued: true, outboundId: 'out-1', messageId: '<out-1@we.com>', state: 'sent' } });
|
||||||
|
expect(mail.calls[0]).toMatchObject({ accountId: 'acc-2', to: ['a@x.com', 'b@x.com'], text: '正文', inReplyTo: '<m1@a>', references: ['<m1@a>'], 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,12 +80,14 @@ beforeAll(async () => {
|
|||||||
await pool.query(`create schema "${schema}"`);
|
await pool.query(`create schema "${schema}"`);
|
||||||
}
|
}
|
||||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
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 container.org.ensureSeeded();
|
||||||
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
|
// 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行
|
||||||
await seedMvp(container);
|
await seedMvp(container);
|
||||||
const { staff } = await container.org.listStaff();
|
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -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<void> } | 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof buildRouteRegistry>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,12 +77,14 @@ beforeAll(async () => {
|
|||||||
await pool.query(`create schema "${schema}"`);
|
await pool.query(`create schema "${schema}"`);
|
||||||
}
|
}
|
||||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
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 container.org.ensureSeeded();
|
||||||
await seedMvp(container);
|
await seedMvp(container);
|
||||||
const { staff } = await container.org.listStaff();
|
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||||
ai = staff.find((s) => s.account === 'AI')!.id;
|
ai = (await container.org.registerIdentity({ sub: 'it-ai', account: 'ai', name: 'AI', roles: ['admin'] })).id;
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -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.<sub>.<exp>.<mac>;到期时刻 = 签发 + 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,12 +88,14 @@ beforeAll(async () => {
|
|||||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||||
|
|
||||||
// 配置指向测试库 —— 经 loadConfig 的 env 形参,不污染 process.env
|
// 配置指向测试库 —— 经 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 container.org.ensureSeeded();
|
||||||
await seedMvp(container);
|
await seedMvp(container);
|
||||||
|
|
||||||
const { staff } = await container.org.listStaff();
|
// 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己)
|
||||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).id;
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
|
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
|
||||||
*
|
*
|
||||||
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
|
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
|
||||||
|
*
|
||||||
|
* **「这一单产出的」认的是本单碰过的行**,不是这张表里现有的所有行 ——
|
||||||
|
* 见 `assetRowsOf` 的病根记档。
|
||||||
*/
|
*/
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import type { TaskId } from '@contracts';
|
import type { TaskId } from '@contracts';
|
||||||
@@ -35,6 +38,71 @@ function rowsOf<T>(result: unknown): T[] {
|
|||||||
return Array.isArray(rows) ? (rows as 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<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 这一单产出的一份资料 —— 还没造成字节的那一步 */
|
||||||
|
export type AssetRow = {
|
||||||
|
readonly address: string;
|
||||||
|
readonly key: Record<string, string>;
|
||||||
|
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<Record<string, unknown>>,
|
||||||
|
touched: ReadonlyArray<TouchedKey>,
|
||||||
|
): 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<string, string> = {};
|
||||||
|
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: {
|
export async function archiveAssetFiles(input: {
|
||||||
@@ -119,40 +187,25 @@ export async function archiveAssetFiles(input: {
|
|||||||
readonly taskId: TaskId;
|
readonly taskId: TaskId;
|
||||||
readonly ids: { next(): string };
|
readonly ids: { next(): string };
|
||||||
/** 活动清单声明的台账对象(带 `file` 段的才算) */
|
/** 活动清单声明的台账对象(带 `file` 段的才算) */
|
||||||
readonly declarations: ReadonlyArray<{
|
readonly declarations: ReadonlyArray<AssetDeclaration>;
|
||||||
domain: string;
|
/**
|
||||||
object: string;
|
* **本单碰过的行** —— `ledger.taskDrafts()` 那一份。
|
||||||
fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
|
* 「哪几行是这一单产出的」只有这一个答案:表里现有的行不作数(见 `assetRowsOf`)。
|
||||||
file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
|
*/
|
||||||
}>;
|
readonly touched: ReadonlyArray<TouchedKey>;
|
||||||
/** 本单在该地址写下的行(正式版 + 本单草稿) */
|
/** 该地址的合并行(正式现值 + 本单草稿)—— 值按「落库后的样子」造文件 */
|
||||||
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
|
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
let saved = 0;
|
let saved = 0;
|
||||||
for (const declared of input.declarations) {
|
for (const declared of input.declarations) {
|
||||||
const spec = declared.file;
|
if (declared.file === undefined) continue; // 整张表不产资料,连取行都免了
|
||||||
if (spec === undefined) continue;
|
|
||||||
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
|
|
||||||
if (keys.length === 0) continue;
|
|
||||||
|
|
||||||
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
|
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
|
||||||
for (const row of rows) {
|
for (const asset of assetRowsOf(declared, rows, input.touched)) {
|
||||||
const markdown = String(row[spec.bodyColumn] ?? '').trim();
|
const composed = composeFile({ title: asset.title, markdown: asset.markdown, format: asset.format });
|
||||||
if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳
|
|
||||||
const key: Record<string, string> = {};
|
|
||||||
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}`;
|
|
||||||
|
|
||||||
await input.db.execute(sql`
|
await input.db.execute(sql`
|
||||||
insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size)
|
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,
|
values (${input.ids.next()}, ${input.taskId}, ${asset.address}, ${JSON.stringify(asset.key)}::jsonb,
|
||||||
${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
|
${asset.title}, ${asset.format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
|
||||||
on conflict (task_id, address, row_key) do update set
|
on conflict (task_id, address, row_key) do update set
|
||||||
title = excluded.title, format = excluded.format, name = excluded.name,
|
title = excluded.title, format = excluded.format, name = excluded.name,
|
||||||
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
|
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 鉴权中间件(ADR-012)—— app 层唯一一处;身份判定读 org 出口(ADR-010 的分工不变)。
|
||||||
|
*
|
||||||
|
* ① 查路由注册表拿 access;public 直接放行。
|
||||||
|
* ② 取凭据:Authorization: Bearer;GET 且没带头时认 ?ticket=(EventSource / <a href> 的路)。
|
||||||
|
* 方法先归一(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<Principal>;
|
||||||
|
principalOf(staffId: StaffId): Promise<Principal | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<AppEnv> {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
import { join } from 'node:path';
|
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 = {
|
export type AppConfig = {
|
||||||
readonly nodeEnv: 'development' | 'test' | 'production';
|
readonly nodeEnv: 'development' | 'test' | 'production';
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
@@ -11,25 +23,55 @@ export type AppConfig = {
|
|||||||
readonly attachmentRoot: string;
|
readonly attachmentRoot: string;
|
||||||
/** 派发器轮询间隔(毫秒) */
|
/** 派发器轮询间隔(毫秒) */
|
||||||
readonly dispatchIntervalMs: number;
|
readonly dispatchIntervalMs: number;
|
||||||
/**
|
/** 邮件轮询工作者的 tick(毫秒);每个账号自己的间隔另算 */
|
||||||
* 播种口令(ADR-010)—— 新建员工的初始口令。
|
readonly mailPollTickMs: number;
|
||||||
* 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。
|
/** 三样齐了才有;缺一样即 undefined —— main.ts 据此拒绝启动,集成测试可不配 */
|
||||||
*/
|
readonly keycloak?: KeycloakConfig;
|
||||||
readonly seedPassword: string;
|
/** 测试账号(Keycloak 用户名):首次建档 / 播种时落 TEST 岗 + 独立工作组 */
|
||||||
|
readonly testAccounts: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
|
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 {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const nodeEnv = env['NODE_ENV'];
|
const nodeEnv = env['NODE_ENV'];
|
||||||
|
const keycloak = keycloakOf(env);
|
||||||
return {
|
return {
|
||||||
nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development',
|
nodeEnv: nodeEnv === 'production' || nodeEnv === 'test' ? nodeEnv : 'development',
|
||||||
port: Number(env['PORT'] ?? 8787),
|
port: Number(env['PORT'] ?? 8787),
|
||||||
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
|
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
|
||||||
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
|
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
|
||||||
dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000),
|
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 !== ''),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-30
@@ -16,18 +16,20 @@ import { createEventService, createEventStore } from '@modules/event';
|
|||||||
import type { LedgerService } from '@modules/ledger';
|
import type { LedgerService } from '@modules/ledger';
|
||||||
import type { LoopEngineService } from '@modules/loop-engine';
|
import type { LoopEngineService } from '@modules/loop-engine';
|
||||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
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 {
|
import {
|
||||||
createGrantRepository,
|
createKeycloakRoster,
|
||||||
|
createKeycloakTokenVerifier,
|
||||||
createOrgService,
|
createOrgService,
|
||||||
createPositionRepository,
|
createPositionRepository,
|
||||||
createSessionRepository,
|
createRoleAccessRepository,
|
||||||
createStaffRepository,
|
createStaffRepository,
|
||||||
createWorkGroupRepository,
|
createWorkGroupRepository,
|
||||||
hashPassword,
|
TokenRejectedError,
|
||||||
newSessionToken,
|
|
||||||
verifyPassword,
|
|
||||||
} from '@modules/org';
|
} 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 type { LlmService, ModelGateway } from '@modules/llm';
|
||||||
import {
|
import {
|
||||||
createLlmInstanceRepository,
|
createLlmInstanceRepository,
|
||||||
@@ -72,12 +74,19 @@ import type { TraceService } from '@modules/trace';
|
|||||||
import { wireEngines } from './wiring/engines.ts';
|
import { wireEngines } from './wiring/engines.ts';
|
||||||
import { wireDeskElements } from './wiring/desk-elements.ts';
|
import { wireDeskElements } from './wiring/desk-elements.ts';
|
||||||
import type { ChannelService } from '@modules/channel';
|
import type { ChannelService } from '@modules/channel';
|
||||||
|
import { createChannelRepository, createChannelService, createFileAttachmentArchive } from '@modules/channel';
|
||||||
|
import type { MailService } from '@modules/mail';
|
||||||
import {
|
import {
|
||||||
createChannelRepository,
|
createImapSmtpProvider,
|
||||||
createChannelService,
|
createInboundStore,
|
||||||
createFileAttachmentArchive,
|
createMailAccountStore,
|
||||||
createSimulatedMailTransport,
|
createMailService,
|
||||||
} from '@modules/channel';
|
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 type { AppConfig } from './config.ts';
|
||||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||||
@@ -94,6 +103,8 @@ export type Container = {
|
|||||||
readonly pool: Pool;
|
readonly pool: Pool;
|
||||||
readonly db: Db;
|
readonly db: Db;
|
||||||
readonly org: OrgService;
|
readonly org: OrgService;
|
||||||
|
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
|
||||||
|
readonly tickets: TicketIssuer;
|
||||||
readonly ledger: LedgerService;
|
readonly ledger: LedgerService;
|
||||||
readonly event: EventService;
|
readonly event: EventService;
|
||||||
readonly llm: LlmService;
|
readonly llm: LlmService;
|
||||||
@@ -103,6 +114,8 @@ export type Container = {
|
|||||||
readonly toolInvoker: ToolInvoker;
|
readonly toolInvoker: ToolInvoker;
|
||||||
readonly message: MessageService;
|
readonly message: MessageService;
|
||||||
readonly channel: ChannelService;
|
readonly channel: ChannelService;
|
||||||
|
/** 邮件收发(ADR-013):账号 / 轮询 / 订阅 / 发信 */
|
||||||
|
readonly mail: MailService;
|
||||||
readonly humanTool: HumanToolService;
|
readonly humanTool: HumanToolService;
|
||||||
readonly interaction: InteractionService;
|
readonly interaction: InteractionService;
|
||||||
readonly registry: ActivityRegistryService;
|
readonly registry: ActivityRegistryService;
|
||||||
@@ -168,10 +181,13 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 第一方工具实现 —— 由 app 注册进 ToolRuntime;活动只见调用契约 */
|
/** 集成测试的注入缝:验签与名册来源都是外界,测试给替身,不联网 */
|
||||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
export type ContainerOverrides = {
|
||||||
|
readonly tokenVerifier?: TokenVerifier;
|
||||||
|
readonly roster?: RosterSource;
|
||||||
|
};
|
||||||
|
|
||||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
|
||||||
/*
|
/*
|
||||||
* 广播器**先建** —— 装配期就要往它上面接线。
|
* 广播器**先建** —— 装配期就要往它上面接线。
|
||||||
*
|
*
|
||||||
@@ -197,25 +213,60 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
*/
|
*/
|
||||||
const bridge = bridgeBroadcaster(pool, broadcaster);
|
const bridge = bridgeBroadcaster(pool, broadcaster);
|
||||||
const ids = createIdGenerator();
|
const ids = createIdGenerator();
|
||||||
const secrets = createSecretBox(loadSecretKey());
|
const masterKey = loadSecretKey();
|
||||||
|
const secrets = createSecretBox(masterKey);
|
||||||
|
const tickets = createTicketIssuer(masterKey);
|
||||||
|
|
||||||
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
||||||
await pool.query(DRAFT_DDL);
|
await pool.query(DRAFT_DDL);
|
||||||
|
|
||||||
const inFlightProbe = createDeferredInFlightProbe();
|
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({
|
const org = createOrgService({
|
||||||
|
verifier,
|
||||||
|
...(roster === undefined ? {} : { roster }),
|
||||||
positions: createPositionRepository(db),
|
positions: createPositionRepository(db),
|
||||||
staff: createStaffRepository(db),
|
staff: createStaffRepository(db),
|
||||||
grants: createGrantRepository(db),
|
roleAccess: createRoleAccessRepository(db),
|
||||||
sessions: createSessionRepository(db),
|
|
||||||
workGroups: createWorkGroupRepository(db),
|
workGroups: createWorkGroupRepository(db),
|
||||||
// 散列算法在 adapter,usecase 只认这三个动作(ADR-010)
|
|
||||||
passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken },
|
|
||||||
inFlight: inFlightProbe,
|
inFlight: inFlightProbe,
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
seedPassword: config.seedPassword,
|
|
||||||
ids,
|
ids,
|
||||||
|
testAccounts: config.testAccounts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
|
// message 是 event 的 `MessageFallback` 实现面 —— event(L1)不反向依赖它,由此处注入
|
||||||
@@ -226,15 +277,20 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
const { staff } = await org.listStaff();
|
const { staff } = await org.listStaff();
|
||||||
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
|
return staff.filter((s) => s.positionId === positionId).map((s) => ({ id: s.id }));
|
||||||
},
|
},
|
||||||
|
/*
|
||||||
|
* 治理台收件人 = 能进运营中心的活跃员工(任务在那儿管;ADR-012 权限只到端)。
|
||||||
|
* **空集要出声**:全新安装、还没人以 admin 登录过时这里就是空的,而调用方是死信通知与
|
||||||
|
* 无主入站兜底 —— 收件人为空就等于把「出事了」这件事无声丢掉(铁规矩 8:界面不撒谎)。
|
||||||
|
*/
|
||||||
governanceStaff: async () => {
|
governanceStaff: async () => {
|
||||||
const { staff } = await org.listStaff();
|
const recipients = await org.staffWithEnd('ops');
|
||||||
const governing = await Promise.all(
|
if (recipients.length === 0) {
|
||||||
staff.map(async (s) => ({
|
process.stderr.write(
|
||||||
s,
|
'[org] 没有能进运营中心的活跃员工 —— 死信与无主入站的通知这一次没有收件人。' +
|
||||||
ok: await org.authorize(s.id, { kind: 'governance', function: 'task' }, 'govern'),
|
'去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。\n',
|
||||||
})),
|
);
|
||||||
);
|
}
|
||||||
return governing.filter((g) => g.ok).map((g) => ({ id: g.s.id }));
|
return recipients.map((s) => ({ id: s.id }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
@@ -319,9 +375,36 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
ids,
|
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);
|
const toolRegistry = createToolRegistry(db);
|
||||||
|
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
|
||||||
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
||||||
const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);
|
const toolRuntime = createToolRuntime(
|
||||||
|
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
|
||||||
|
httpToolHandler,
|
||||||
|
);
|
||||||
const schemas = createSchemaValidator();
|
const schemas = createSchemaValidator();
|
||||||
const toolbox = createToolboxService({
|
const toolbox = createToolboxService({
|
||||||
registry: toolRegistry,
|
registry: toolRegistry,
|
||||||
@@ -349,11 +432,13 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
recordOutbound: (ref) => event.recordOutbound(ref),
|
recordOutbound: (ref) => event.recordOutbound(ref),
|
||||||
},
|
},
|
||||||
ledger: { systemWrite: (input) => ledger.systemWrite(input) },
|
ledger: { systemWrite: (input) => ledger.systemWrite(input) },
|
||||||
mail: createSimulatedMailTransport(db, ids),
|
mail: channelTransport(mail),
|
||||||
archive: createFileAttachmentArchive(config.attachmentRoot),
|
archive: createFileAttachmentArchive(config.attachmentRoot),
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
ids,
|
ids,
|
||||||
});
|
});
|
||||||
|
// channel 是第一个订阅方:来信归一进事件流,活动经触发器 / 监听收到;账号没绑通道的信留在运营面
|
||||||
|
mail.subscribe('channel', channelSubscriber(channel));
|
||||||
|
|
||||||
/* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */
|
/* ── L3/L4:交互、人类工具、活动库、放行门 ─────────────────────── */
|
||||||
|
|
||||||
@@ -715,6 +800,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
pool,
|
pool,
|
||||||
db,
|
db,
|
||||||
org,
|
org,
|
||||||
|
tickets,
|
||||||
ledger,
|
ledger,
|
||||||
event,
|
event,
|
||||||
llm,
|
llm,
|
||||||
@@ -724,6 +810,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
toolInvoker,
|
toolInvoker,
|
||||||
message,
|
message,
|
||||||
channel,
|
channel,
|
||||||
|
mail,
|
||||||
humanTool,
|
humanTool,
|
||||||
interaction,
|
interaction,
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
|
* 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)。
|
* 请求上下文(ADR-012)。`principal` 由鉴权中间件从 Bearer / 票据解出 ——
|
||||||
* `session` 由鉴权中间件从**服务端会话表**解出 —— 不再有「cookie 里写谁就是谁」的明文身份;
|
* 身份只能来自验过签的 token,不再有「cookie 里写谁就是谁」。
|
||||||
* `account` 保留为便捷读法,值恒取自已核验的会话,免鉴权路由上为空串。
|
|
||||||
*/
|
*/
|
||||||
export type AppEnv = { Variables: { account: string; session: Session | undefined } };
|
export type AppEnv = { Variables: { principal: Principal | undefined } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前登录者(连同岗位)。非公开路由上中间件已保证存在;公开路由上是 undefined ——
|
||||||
|
* 调用方照旧判一下 undefined,免得哪天有人把一条路由改成 public 还在读它。
|
||||||
|
*/
|
||||||
|
export function staffOf(c: Context<AppEnv>): (Staff & { readonly position: Position }) | undefined {
|
||||||
|
const principal = c.get('principal');
|
||||||
|
return principal === undefined ? undefined : { ...principal.staff, position: principal.position };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<unknown> }): 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<MailService, 'send'>): 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<string, unknown>;
|
||||||
|
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<MailService, 'send'>): 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,14 +5,30 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { loadConfig } from './config.ts';
|
import { loadConfig } from './config.ts';
|
||||||
import { createContainer } from './container.ts';
|
import { createContainer } from './container.ts';
|
||||||
|
import { checkKeycloakConnection } from './keycloak-check.ts';
|
||||||
import { createServer } from './server.ts';
|
import { createServer } from './server.ts';
|
||||||
import { createConsumers } from './workers/consumers.ts';
|
import { createConsumers } from './workers/consumers.ts';
|
||||||
import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts';
|
import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts';
|
||||||
import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts';
|
import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts';
|
||||||
|
import { startMailPoller } from './workers/mail-poller.ts';
|
||||||
import { registerPulse } from './workers/pulse.ts';
|
import { registerPulse } from './workers/pulse.ts';
|
||||||
|
|
||||||
export async function boot(): Promise<void> {
|
export async function boot(): Promise<void> {
|
||||||
const config = loadConfig();
|
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);
|
const container = await createContainer(config);
|
||||||
await container.org.ensureSeeded();
|
await container.org.ensureSeeded();
|
||||||
|
|
||||||
@@ -23,6 +39,9 @@ export async function boot(): Promise<void> {
|
|||||||
// 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线
|
// 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线
|
||||||
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
|
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
|
||||||
|
|
||||||
|
// 邮件轮询:腾讯没有带内容的推送,信箱只能拉;跨进程租约让两台后端不重复拉同一个信箱
|
||||||
|
const mailPoller = startMailPoller({ container }, config.mailPollTickMs);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。
|
* 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。
|
||||||
* 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮,
|
* 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮,
|
||||||
@@ -39,6 +58,7 @@ export async function boot(): Promise<void> {
|
|||||||
const shutdown = async (): Promise<void> => {
|
const shutdown = async (): Promise<void> => {
|
||||||
dispatcher.stop();
|
dispatcher.stop();
|
||||||
advancer.stop();
|
advancer.stop();
|
||||||
|
mailPoller.stop();
|
||||||
await container.close();
|
await container.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆…
|
* **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆…
|
||||||
* **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、
|
* **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、
|
||||||
* 触发器、通道、模型实例、纪要。
|
* 触发器、通道、模型实例、纪要、**邮箱账号**(它身上的游标与拉取状态是运行痕,跟着清)。
|
||||||
*
|
*
|
||||||
* 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 ——
|
* 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 ——
|
||||||
* 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。
|
* 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。
|
||||||
@@ -44,6 +44,10 @@ const RUN_TABLES = [
|
|||||||
'sys.event_delivery',
|
'sys.event_delivery',
|
||||||
'sys.event_consumption',
|
'sys.event_consumption',
|
||||||
'sys.outbound_mail',
|
'sys.outbound_mail',
|
||||||
|
// 邮件收发的留痕是跑出来的;账号配置(sys.mail_account)留着
|
||||||
|
'sys.mail_inbound',
|
||||||
|
'sys.mail_inbound_delivery',
|
||||||
|
'sys.mail_outbound',
|
||||||
'sys.outbound_ref',
|
'sys.outbound_ref',
|
||||||
'sys.trigger_claim',
|
'sys.trigger_claim',
|
||||||
// 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动
|
// 待确认单是运行痕:命中未确认的那一票随运行数据一起清 —— 配置(trigger/node_owner)不动
|
||||||
@@ -85,6 +89,17 @@ export async function resetRuns(): Promise<void> {
|
|||||||
);
|
);
|
||||||
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
||||||
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
|
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 }>(
|
const kept = await pool.query<{ n: string }>(
|
||||||
`select count(*)::text as n from ledger.__object_meta where kind = 'master'`,
|
`select count(*)::text as n from ledger.__object_meta where kind = 'master'`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string, RouteAccess>();
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { Hono } from 'hono';
|
|||||||
import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
|
import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts';
|
||||||
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */
|
/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */
|
||||||
@@ -23,7 +24,7 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
|
|||||||
|
|
||||||
/** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */
|
/** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */
|
||||||
app.get(listApprovalsForMeRoute.path, async (c) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
|
|
||||||
const workflows = await container.process.listWorkflows(50);
|
const workflows = await container.process.listWorkflows(50);
|
||||||
@@ -72,7 +73,7 @@ export function registerDeskApprovalRoutes(app: Hono<AppEnv>, container: Contain
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(decideApprovalRoute.path, async (c) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
const parsed = decideApprovalRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -23,13 +23,10 @@ import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/
|
|||||||
import type { Report } from '@modules/report';
|
import type { Report } from '@modules/report';
|
||||||
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
async function me(account: string) {
|
|
||||||
return container.org.findByAccount(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
|
|
||||||
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
||||||
@@ -85,7 +82,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(createMinuteRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
|
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -104,7 +101,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(appendResolutionRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -129,7 +126,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
|
|
||||||
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
||||||
app.post(registerResolutionRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -167,14 +164,14 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
app.get(listReportsRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const reports = await container.report.list(staff.id);
|
const reports = await container.report.list(staff.id);
|
||||||
return c.json({ reports: reports.map(toReportView) });
|
return c.json({ reports: reports.map(toReportView) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(draftReportRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
||||||
if (!period.success) {
|
if (!period.success) {
|
||||||
@@ -186,7 +183,7 @@ export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Containe
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(submitReportRoute.path, async (c) => {
|
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);
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from '@contracts';
|
} from '@contracts';
|
||||||
import { ReplyRejectedError } from '@modules/human-tool';
|
import { ReplyRejectedError } from '@modules/human-tool';
|
||||||
import { advanceOrder } from '@modules/process';
|
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 { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
||||||
import { toFileDoc } from '../file-doc.ts';
|
import { toFileDoc } from '../file-doc.ts';
|
||||||
import { displayLog } from './log-lines.ts';
|
import { displayLog } from './log-lines.ts';
|
||||||
@@ -41,6 +41,7 @@ function rowsOf<T>(result: unknown): T[] {
|
|||||||
const rows = (result as { rows?: unknown }).rows;
|
const rows = (result as { rows?: unknown }).rows;
|
||||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||||
}
|
}
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
||||||
import { orderChat } from '../chat-order.ts';
|
import { orderChat } from '../chat-order.ts';
|
||||||
@@ -510,6 +511,9 @@ async function landingOf(
|
|||||||
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
|
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
|
||||||
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
|
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
|
||||||
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
|
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
|
||||||
|
*
|
||||||
|
* 在办这一路挑哪几行与存档同一段(`assetRowsOf`):认**本单碰过的行**,
|
||||||
|
* 不是这张表里现有的所有行 —— 两处各写一份的下场见那段的病根记档。
|
||||||
*/
|
*/
|
||||||
async function assetFilesOf(
|
async function assetFilesOf(
|
||||||
container: Container,
|
container: Container,
|
||||||
@@ -529,12 +533,11 @@ async function assetFilesOf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const declarations = await declarationsOf(container, taskId);
|
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 }> = [];
|
const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = [];
|
||||||
for (const declared of declarations ?? []) {
|
for (const declared of declarations ?? []) {
|
||||||
const spec = declared.file;
|
if (declared.file === undefined) continue;
|
||||||
if (spec === undefined) continue;
|
|
||||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
|
||||||
if (keys.length === 0) continue;
|
|
||||||
const page = await container.ledger
|
const page = await container.ledger
|
||||||
.taskView(taskId, {
|
.taskView(taskId, {
|
||||||
address: { domain: declared.domain, object: declared.object },
|
address: { domain: declared.domain, object: declared.object },
|
||||||
@@ -544,21 +547,13 @@ async function assetFilesOf(
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
})
|
})
|
||||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||||
for (const row of page.rows) {
|
for (const asset of assetRowsOf(declared, page.rows as Array<Record<string, unknown>>, touched)) {
|
||||||
// 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」
|
|
||||||
if (String(row[spec.bodyColumn] ?? '').trim() === '') continue;
|
|
||||||
const key: Record<string, string> = {};
|
|
||||||
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('-');
|
|
||||||
out.push({
|
out.push({
|
||||||
address: `${declared.domain}.${declared.object}`,
|
address: asset.address,
|
||||||
key: keyText(key),
|
key: keyText(asset.key),
|
||||||
title,
|
title: asset.title,
|
||||||
format,
|
format: asset.format,
|
||||||
name: safeFileName(title, format),
|
name: safeFileName(asset.title, asset.format),
|
||||||
archived: false,
|
archived: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -852,12 +847,6 @@ export function gateOf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
/** 当前登录者;缺会话即 401 */
|
|
||||||
async function me(account: string) {
|
|
||||||
const staff = await container.org.findByAccount(account);
|
|
||||||
return staff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */
|
/** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */
|
||||||
async function toCard(task: Awaited<ReturnType<Container['task']['get']>>): Promise<TaskCard> {
|
async function toCard(task: Awaited<ReturnType<Container['task']['get']>>): Promise<TaskCard> {
|
||||||
let position = 1;
|
let position = 1;
|
||||||
@@ -955,7 +944,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get(listTasksRoute.path, async (c) => {
|
app.get(listTasksRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -966,7 +955,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(taskDetailRoute.path, async (c) => {
|
app.get(taskDetailRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1198,7 +1187,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
||||||
*/
|
*/
|
||||||
app.get(openAssetFileRoute.path, async (c) => {
|
app.get(openAssetFileRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1304,7 +1293,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(saveTableRoute.path, async (c) => {
|
app.post(saveTableRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1343,7 +1332,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
|
|
||||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||||
app.post(replyWorkOrderRoute.path, async (c) => {
|
app.post(replyWorkOrderRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1437,7 +1426,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
||||||
*/
|
*/
|
||||||
app.post(replyChatCardRoute.path, async (c) => {
|
app.post(replyChatCardRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1562,7 +1551,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
||||||
*/
|
*/
|
||||||
app.post(markCommReadRoute.path, async (c) => {
|
app.post(markCommReadRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1621,7 +1610,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(sayRoute.path, async (c) => {
|
app.post(sayRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1636,7 +1625,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(listMessagesRoute.path, async (c) => {
|
app.get(listMessagesRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1692,7 +1681,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
|
|
||||||
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
|
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
|
||||||
app.post(confirmTriggerPendingRoute.path, async (c) => {
|
app.post(confirmTriggerPendingRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1716,7 +1705,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(markMessageReadRoute.path, async (c) => {
|
app.post(markMessageReadRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
@@ -1731,7 +1720,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
||||||
*/
|
*/
|
||||||
app.post(answerMessageRoute.path, async (c) => {
|
app.post(answerMessageRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||||
return c.json(body, 401);
|
return c.json(body, 401);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
||||||
*/
|
*/
|
||||||
import { sql } from 'drizzle-orm';
|
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 { Hono } from 'hono';
|
||||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
import type { ApiError } from '@contracts';
|
||||||
import {
|
import {
|
||||||
browseLedgerRoute,
|
browseLedgerRoute,
|
||||||
controlTaskRoute,
|
controlTaskRoute,
|
||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
VersionTakenError,
|
VersionTakenError,
|
||||||
} from '@modules/process';
|
} from '@modules/process';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
import { displayLog } from './log-lines.ts';
|
import { displayLog } from './log-lines.ts';
|
||||||
|
|
||||||
@@ -366,46 +367,9 @@ async function assembleFromMail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, 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<ApiError | undefined> => {
|
|
||||||
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) => {
|
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 { staff } = await container.org.listStaff();
|
||||||
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
||||||
const rows = await Promise.all(
|
const rows = await Promise.all(
|
||||||
@@ -438,9 +402,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
||||||
app.post(controlTaskRoute.path, async (c) => {
|
app.post(controlTaskRoute.path, async (c) => {
|
||||||
const denied = await governTask(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
const staff = await container.org.findByAccount(c.get('account'));
|
|
||||||
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success || staff === undefined) {
|
if (!parsed.success || staff === undefined) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||||
@@ -456,18 +418,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(resumeTaskRoute.path, async (c) => {
|
app.post(resumeTaskRoute.path, async (c) => {
|
||||||
const denied = await governTask(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
const staff = await container.org.findByAccount(c.get('account'));
|
|
||||||
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
|
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);
|
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
|
||||||
return c.json({ ok: true, runtime: task.runtime });
|
return c.json({ ok: true, runtime: task.runtime });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(reassignTaskRoute.path, async (c) => {
|
app.post(reassignTaskRoute.path, async (c) => {
|
||||||
const denied = await governTask(c.get('account'));
|
const staff = staffOf(c);
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
const staff = await container.org.findByAccount(c.get('account'));
|
|
||||||
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success || staff === undefined) {
|
if (!parsed.success || staff === undefined) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||||
@@ -482,8 +440,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(govTaskLogRoute.path, async (c) => {
|
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')));
|
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||||
return c.json({
|
return c.json({
|
||||||
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
|
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
|
||||||
@@ -502,9 +458,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
||||||
*/
|
*/
|
||||||
app.get(listApprovalsRoute.path, async (c) => {
|
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 workflows = await container.process.listWorkflows(50);
|
||||||
const perWorkflow = await Promise.all(
|
const perWorkflow = await Promise.all(
|
||||||
workflows.map(async (w) => {
|
workflows.map(async (w) => {
|
||||||
@@ -551,14 +504,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
|
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
|
||||||
*/
|
*/
|
||||||
app.post(testRunRoute.path, async (c) => {
|
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());
|
const parsed = testRunRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
const me = await container.org.findByAccount(c.get('account'));
|
const me = staffOf(c);
|
||||||
const { packageId, mail, assignments } = parsed.data;
|
const { packageId, mail, assignments } = parsed.data;
|
||||||
try {
|
try {
|
||||||
const data = await assembleFromMail(container, packageId, mail);
|
const data = await assembleFromMail(container, packageId, mail);
|
||||||
@@ -592,8 +543,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。
|
* 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。
|
||||||
*/
|
*/
|
||||||
app.post(testStopRoute.path, async (c) => {
|
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());
|
const parsed = testStopRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
||||||
@@ -620,8 +569,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
|
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
|
||||||
app.get(testCurrentRoute.path, async (c) => {
|
app.get(testCurrentRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
/*
|
/*
|
||||||
* **在跑与跑完要分得开**(`live`)。
|
* **在跑与跑完要分得开**(`live`)。
|
||||||
*
|
*
|
||||||
@@ -668,14 +615,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* `origin='testbed'`,好让人随时连根拔掉。
|
* `origin='testbed'`,好让人随时连根拔掉。
|
||||||
*/
|
*/
|
||||||
app.post(testRunActivityRoute.path, async (c) => {
|
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());
|
const parsed = testRunActivityRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
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;
|
const { activityId, data, message, ownerId } = parsed.data;
|
||||||
/*
|
/*
|
||||||
* 派给谁:显式指定优先,否则**派给操作者自己**。
|
* 派给谁:显式指定优先,否则**派给操作者自己**。
|
||||||
@@ -729,8 +674,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
||||||
*/
|
*/
|
||||||
app.get(testActivityStateRoute.path, async (c) => {
|
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'));
|
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||||
/*
|
/*
|
||||||
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
|
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
|
||||||
@@ -776,16 +719,9 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
|
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
|
||||||
|
|
||||||
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
|
|
||||||
const UPLOAD_CAP = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
app.post(uploadFileRoute.path, async (c) => {
|
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());
|
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
||||||
@@ -805,47 +741,22 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
||||||
const got = extractText(name, contentType, bytes);
|
return c.json(await storeUpload(container.db, { name, contentType, bytes, uploadedBy: staffOf(c)?.id ?? null }));
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(downloadFileRoute.path, async (c) => {
|
app.get(downloadFileRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
const hit = await readUpload(container.db, c.req.param('id'));
|
||||||
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];
|
|
||||||
if (hit === undefined) {
|
if (hit === undefined) {
|
||||||
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
|
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
|
||||||
return c.json(body, 404);
|
return c.json(body, 404);
|
||||||
}
|
}
|
||||||
return c.json({
|
return c.json({ name: hit.name, contentType: hit.contentType, dataBase64: Buffer.from(hit.bytes).toString('base64') });
|
||||||
name: hit.name,
|
|
||||||
contentType: hit.content_type,
|
|
||||||
dataBase64: Buffer.from(hit.bytes).toString('base64'),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
|
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
|
||||||
|
|
||||||
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
|
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
|
||||||
app.get(listTestLogsRoute.path, async (c) => {
|
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<TestLogDbRow>(sql`
|
const rows = await container.db.execute<TestLogDbRow>(sql`
|
||||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||||
jsonb_array_length(trail) as trail_count,
|
jsonb_array_length(trail) as trail_count,
|
||||||
@@ -857,8 +768,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(testLogDetailRoute.path, async (c) => {
|
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<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
|
const rows = await container.db.execute<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
|
||||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||||
jsonb_array_length(trail) as trail_count,
|
jsonb_array_length(trail) as trail_count,
|
||||||
@@ -875,8 +784,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete(deleteTestLogRoute.path, async (c) => {
|
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 }>(
|
const rows = await container.db.execute<{ id: string }>(
|
||||||
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
|
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
|
||||||
);
|
);
|
||||||
@@ -884,25 +791,18 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete(clearTestLogsRoute.path, async (c) => {
|
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`);
|
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 });
|
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
||||||
app.get(traceOfWorkflowRoute.path, async (c) => {
|
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'));
|
const spans = await container.trace.ofWorkflow(c.req.param('id'));
|
||||||
return c.json({ spans });
|
return c.json({ spans });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
||||||
app.get(govWorkflowRoute.path, async (c) => {
|
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 workflow = await container.process.getWorkflow(c.req.param('id'));
|
||||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||||
if (pkg === undefined) {
|
if (pkg === undefined) {
|
||||||
@@ -945,8 +845,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
app.get(listProcessPackagesRoute.path, async (c) => {
|
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() });
|
return c.json({ packages: await container.process.listPackages() });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -955,8 +853,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
|
* 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。
|
||||||
*/
|
*/
|
||||||
app.put(saveProcessRoute.path, async (c) => {
|
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());
|
const parsed = saveProcessRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||||
@@ -986,8 +882,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
/** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */
|
||||||
/** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */
|
/** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */
|
||||||
app.delete(deleteProcessVersionRoute.path, async (c) => {
|
app.delete(deleteProcessVersionRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
try {
|
try {
|
||||||
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
|
const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version')));
|
||||||
return c.json({ packages });
|
return c.json({ packages });
|
||||||
@@ -1004,14 +898,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
|
/* ── 审批模板(审批活动)—— 活动库里配、编排里引 ─────────────── */
|
||||||
|
|
||||||
app.get(listApprovalTemplatesRoute.path, async (c) => {
|
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() });
|
return c.json({ templates: await container.process.approvalTemplates() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put(upsertApprovalTemplateRoute.path, async (c) => {
|
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());
|
const parsed = upsertApprovalTemplateRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' };
|
const body: ApiError = { code: 'validation-failed', message: '审批模板不合协议(标识与名称必填)' };
|
||||||
@@ -1021,8 +911,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete(deleteApprovalTemplateRoute.path, async (c) => {
|
app.delete(deleteApprovalTemplateRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
|
||||||
try {
|
try {
|
||||||
await container.process.deleteApprovalTemplate(c.req.param('id'));
|
await container.process.deleteApprovalTemplate(c.req.param('id'));
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -1042,8 +930,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
|
* 反查放在路由层而不是用例层:process 不该知道 trigger 的存在(L7 不依赖它)。
|
||||||
*/
|
*/
|
||||||
app.delete(deleteProcessFlowRoute.path, async (c) => {
|
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 id = c.req.param('id');
|
||||||
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
|
const rules = (await container.trigger.list()).filter((t) => t.processPackageId === id);
|
||||||
if (rules.length > 0) {
|
if (rules.length > 0) {
|
||||||
@@ -1067,8 +953,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
|
/** 改版本记录:号自己定、名随之 —— 撞号与「跑过单改号」409,别的版一字不动 */
|
||||||
app.post(editProcessVersionRoute.path, async (c) => {
|
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());
|
const parsed = editProcessVersionRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
|
const body: ApiError = { code: 'validation-failed', message: '版本号要 1–9999 的整数,名字 1–40 字' };
|
||||||
@@ -1096,8 +980,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(setProcessStateRoute.path, async (c) => {
|
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());
|
const parsed = setProcessStateRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' };
|
||||||
@@ -1122,8 +1004,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 画图即校验 —— 与前端画布同源 */
|
/** 画图即校验 —— 与前端画布同源 */
|
||||||
app.post(validateProcessRoute.path, async (c) => {
|
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());
|
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||||
@@ -1135,14 +1015,10 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
app.get(listTriggersRoute.path, async (c) => {
|
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() });
|
return c.json({ triggers: await container.trigger.list() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(toggleTriggerRoute.path, async (c) => {
|
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());
|
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||||
@@ -1158,8 +1034,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 设计动规则不碰这两项,运营动这两项不碰规则。
|
* 设计动规则不碰这两项,运营动这两项不碰规则。
|
||||||
*/
|
*/
|
||||||
app.post(setTriggerConfirmRoute.path, async (c) => {
|
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());
|
const parsed = setTriggerConfirmRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||||
@@ -1178,15 +1052,11 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
|
|
||||||
/** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */
|
/** 节点负责人(运营配置):显式指派 > 这里 > 轮值 */
|
||||||
app.get(listNodeOwnersRoute.path, async (c) => {
|
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'));
|
const owners = await container.process.nodeOwners(c.req.param('id'));
|
||||||
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
|
return c.json({ owners: Object.entries(owners).map(([nodeId, staffId]) => ({ nodeId, staffId })) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put(setNodeOwnerRoute.path, async (c) => {
|
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());
|
const parsed = setNodeOwnerRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||||
@@ -1201,8 +1071,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
|
* 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。
|
||||||
*/
|
*/
|
||||||
app.put(upsertTriggerRoute.path, async (c) => {
|
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());
|
const parsed = upsertTriggerRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues };
|
||||||
@@ -1219,8 +1087,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
|
* 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。
|
||||||
*/
|
*/
|
||||||
app.post(installActivityRoute.path, async (c) => {
|
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());
|
const parsed = installActivityRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues };
|
||||||
@@ -1259,8 +1125,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(listActivitiesRoute.path, async (c) => {
|
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();
|
const cards = await container.registry.list();
|
||||||
/*
|
/*
|
||||||
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
* 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里:
|
||||||
@@ -1374,8 +1238,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
||||||
*/
|
*/
|
||||||
app.post(registerToolRoute.path, async (c) => {
|
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());
|
const parsed = registerToolRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
||||||
@@ -1397,8 +1259,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(listToolsRoute.path, async (c) => {
|
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();
|
const tools = await container.toolbox.list();
|
||||||
return c.json({
|
return c.json({
|
||||||
tools: tools.map((t) => ({
|
tools: tools.map((t) => ({
|
||||||
@@ -1417,8 +1277,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
||||||
|
|
||||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
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)——
|
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
|
||||||
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
|
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
|
||||||
@@ -1438,9 +1296,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
* · 草稿行数与时刻 —— `ledger.__draft`(在办中的单写下的,放行才进正式表)。
|
||||||
*/
|
*/
|
||||||
app.get(ledgerByActivityRoute.path, async (c) => {
|
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([
|
const [cards, materialized] = await Promise.all([
|
||||||
container.registry.list(),
|
container.registry.list(),
|
||||||
container.ledger.listObjects(),
|
container.ledger.listObjects(),
|
||||||
@@ -1557,8 +1412,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(browseLedgerRoute.path, async (c) => {
|
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 address = { domain: c.req.param('domain'), object: c.req.param('object') };
|
||||||
const described = await container.ledger.describe(address);
|
const described = await container.ledger.describe(address);
|
||||||
if (described === undefined) {
|
if (described === undefined) {
|
||||||
@@ -1595,8 +1448,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
|
* 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。
|
||||||
*/
|
*/
|
||||||
app.get(govMetricsRoute.path, async (c) => {
|
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());
|
const parsed = govMetricsRoute.query.safeParse(c.req.query());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues };
|
||||||
@@ -1612,8 +1463,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
||||||
|
|
||||||
app.get(listChannelsRoute.path, async (c) => {
|
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();
|
const channels = await container.channel.list();
|
||||||
return c.json({
|
return c.json({
|
||||||
channels: channels.map((ch) => ({
|
channels: channels.map((ch) => ({
|
||||||
@@ -1632,8 +1481,6 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(listActivityMemoryRoute.path, async (c) => {
|
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 });
|
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
|
||||||
return c.json({
|
return c.json({
|
||||||
entries: entries.map((e) => ({
|
entries: entries.map((e) => ({
|
||||||
|
|||||||
@@ -1,107 +1,122 @@
|
|||||||
/**
|
/**
|
||||||
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
||||||
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
||||||
|
* 端级授权由 server.ts 的鉴权中间件按路由归属统一判(ADR-012),这里不再各写守卫。
|
||||||
*/
|
*/
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ApiError, GovernanceFunction } from '@contracts';
|
import type { ApiError } from '@contracts';
|
||||||
import {
|
import {
|
||||||
createStaffRoute,
|
deleteLlmInstanceRoute,
|
||||||
disableStaffCheckRoute,
|
deleteRoleAccessRoute,
|
||||||
disableStaffRoute,
|
|
||||||
listGrantsRoute,
|
|
||||||
listLlmInstancesRoute,
|
listLlmInstancesRoute,
|
||||||
|
listLlmProvidersRoute,
|
||||||
|
listRoleAccessRoute,
|
||||||
listStaffRoute,
|
listStaffRoute,
|
||||||
listWorkGroupsRoute,
|
listWorkGroupsRoute,
|
||||||
positionIdSchema,
|
|
||||||
listLlmProvidersRoute,
|
|
||||||
LLM_PROVIDERS,
|
LLM_PROVIDERS,
|
||||||
deleteLlmInstanceRoute,
|
|
||||||
probeLlmDraftRoute,
|
probeLlmDraftRoute,
|
||||||
probeLlmInstanceRoute,
|
probeLlmInstanceRoute,
|
||||||
saveWorkGroupRoute,
|
saveWorkGroupRoute,
|
||||||
setGrantsRoute,
|
setRoleAccessRoute,
|
||||||
|
setStaffPositionRoute,
|
||||||
staffIdSchema,
|
staffIdSchema,
|
||||||
|
staffInFlightRoute,
|
||||||
|
syncStaffRoute,
|
||||||
upsertLlmInstanceRoute,
|
upsertLlmInstanceRoute,
|
||||||
} from '@contracts';
|
} from '@contracts';
|
||||||
import { DisableBlockedError } from '@modules/org';
|
import { AccessLockoutError, PositionNotFoundError, StaffNotFoundError } from '@modules/org';
|
||||||
import { PROVIDER_PROFILES } from '@modules/llm';
|
import { PROVIDER_PROFILES } from '@modules/llm';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */
|
|
||||||
function requireGovern(container: Container, fn: GovernanceFunction) {
|
|
||||||
return async (account: string): Promise<ApiError | undefined> => {
|
|
||||||
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<AppEnv>, container: Container): void {
|
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
const guardStaff = requireGovern(container, 'staff');
|
/* ── 员工管理:名册来自鉴权中心,这里只管岗位与同步(ADR-012) ─────────── */
|
||||||
const guardSystem = requireGovern(container, 'system');
|
|
||||||
|
|
||||||
/* ── 员工管理 ─────────────────────────────────────────────────────── */
|
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'));
|
* 从 Keycloak Admin API 同步名册;没配 provisioning 就回 configured:false(界面不撒谎)。
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
* 409:这一次同步会把最后一个能进运营中心的人也停了 —— 整体拒绝,一条都没写
|
||||||
return c.json(await container.org.listStaff());
|
* (与「角色 → 端」那两条同款,理由不同、话不同)。
|
||||||
});
|
*/
|
||||||
|
app.post(syncStaffRoute.path, async (c) => {
|
||||||
app.post(createStaffRoute.path, async (c) => {
|
|
||||||
const denied = await guardStaff(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
const parsed = createStaffRoute.body.safeParse(await c.req.json());
|
|
||||||
if (!parsed.success) {
|
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
|
||||||
return c.json(body, 400);
|
|
||||||
}
|
|
||||||
return c.json(await container.org.createStaff(parsed.data));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get(disableStaffCheckRoute.path, async (c) => {
|
|
||||||
const denied = await guardStaff(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
|
||||||
return c.json(await container.org.checkDisable(staffId));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(disableStaffRoute.path, async (c) => {
|
|
||||||
const denied = await guardStaff(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
|
||||||
try {
|
try {
|
||||||
await container.org.disableStaff(staffId);
|
return c.json(await container.org.syncRoster());
|
||||||
return c.json({ ok: true, check: await container.org.checkDisable(staffId) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DisableBlockedError) {
|
if (error instanceof AccessLockoutError) {
|
||||||
// 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02)
|
const body: ApiError = { code: 'conflict', message: error.message };
|
||||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check };
|
return c.json(body, 409);
|
||||||
return c.json(body, 412);
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(listGrantsRoute.path, async (c) => {
|
app.put(setStaffPositionRoute.path, async (c) => {
|
||||||
const denied = await guardStaff(c.get('account'));
|
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
const parsed = setStaffPositionRoute.body.safeParse(await c.req.json());
|
||||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
|
||||||
return c.json({ grants: await container.org.listGrants(positionId) });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(setGrantsRoute.path, async (c) => {
|
|
||||||
const denied = await guardStaff(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
|
||||||
const parsed = setGrantsRoute.body.safeParse(await c.req.json());
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||||
return c.json(body, 400);
|
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) ──────────────────────────────────── */
|
/* ── 员工管理 · 工作组(ADR-011) ──────────────────────────────────── */
|
||||||
@@ -111,8 +126,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
|
* `ownerName` 一并带出:前端要显示的是「柴浩的组」,不是一串 id。
|
||||||
*/
|
*/
|
||||||
app.get(listWorkGroupsRoute.path, async (c) => {
|
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 { staff } = await container.org.listStaff();
|
||||||
const found = await Promise.all(
|
const found = await Promise.all(
|
||||||
staff.map(async (s) => {
|
staff.map(async (s) => {
|
||||||
@@ -129,8 +142,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
* 改组**不影响在途**:装进工作流的是起单时的快照。
|
* 改组**不影响在途**:装进工作流的是起单时的快照。
|
||||||
*/
|
*/
|
||||||
app.put(saveWorkGroupRoute.path, async (c) => {
|
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());
|
const parsed = saveWorkGroupRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '工作组不合 schema', detail: parsed.error.issues };
|
||||||
@@ -149,10 +160,8 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
||||||
|
|
||||||
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
|
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
|
||||||
app.get(listLlmProvidersRoute.path, async (c) => {
|
app.get(listLlmProvidersRoute.path, (c) =>
|
||||||
const denied = await guardSystem(c.get('account'));
|
c.json({
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
return c.json({
|
|
||||||
providers: LLM_PROVIDERS.map((provider) => {
|
providers: LLM_PROVIDERS.map((provider) => {
|
||||||
const profile = PROVIDER_PROFILES[provider];
|
const profile = PROVIDER_PROFILES[provider];
|
||||||
return {
|
return {
|
||||||
@@ -162,18 +171,12 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
structuredOutput: profile.structuredOutput,
|
structuredOutput: profile.structuredOutput,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
app.get(listLlmInstancesRoute.path, async (c) => {
|
app.get(listLlmInstancesRoute.path, async (c) => c.json({ instances: await container.llm.list() }));
|
||||||
const denied = await guardSystem(c.get('account'));
|
|
||||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
|
||||||
return c.json({ instances: await container.llm.list() });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post(upsertLlmInstanceRoute.path, async (c) => {
|
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());
|
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||||
@@ -184,8 +187,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
|
|
||||||
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
|
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
|
||||||
app.delete(deleteLlmInstanceRoute.path, async (c) => {
|
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 id = c.req.param('id');
|
||||||
const target = (await container.llm.list()).find((i) => i.id === id);
|
const target = (await container.llm.list()).find((i) => i.id === id);
|
||||||
if (target === undefined) {
|
if (target === undefined) {
|
||||||
@@ -208,8 +209,6 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
|
|
||||||
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
||||||
app.post(probeLlmDraftRoute.path, async (c) => {
|
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());
|
const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||||
@@ -218,9 +217,5 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
|||||||
return c.json(await container.llm.probeDraft(parsed.data));
|
return c.json(await container.llm.probeDraft(parsed.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(probeLlmInstanceRoute.path, async (c) => {
|
app.post(probeLlmInstanceRoute.path, async (c) => c.json(await container.llm.probe(c.req.param('id'))));
|
||||||
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')));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AppEnv>, message: string, detail: unknown): Response {
|
||||||
|
const body: ApiError = { code: 'validation-failed', message, detail };
|
||||||
|
return c.json(body, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
|
||||||
|
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<AppEnv>, deps: MailRouteDeps): void {
|
||||||
|
const { mail } = deps;
|
||||||
|
|
||||||
|
const guarded = async (c: Context<AppEnv>, run: () => Promise<Response>): Promise<Response> => {
|
||||||
|
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 }) });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type { Hono } from 'hono';
|
|||||||
import type { ApiError } from '@contracts';
|
import type { ApiError } from '@contracts';
|
||||||
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
|
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { staffOf } from '../http-env.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
@@ -17,7 +18,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
|
|||||||
|
|
||||||
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
|
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
|
||||||
app.get(listRegistrationTriggersRoute.path, async (c) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
|
|
||||||
const triggers = await container.trigger.list();
|
const triggers = await container.trigger.list();
|
||||||
@@ -38,7 +39,7 @@ export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Contain
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post(fireRegistrationRoute.path, async (c) => {
|
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);
|
if (me === undefined) return c.json(unauthenticated, 401);
|
||||||
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
|
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
||||||
* 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。
|
* 登录已归统一鉴权中心(ADR-012):这里只剩「我是谁」与票据。
|
||||||
*/
|
*/
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ApiError } from '@contracts';
|
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 type { Container } from '../container.ts';
|
||||||
import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts';
|
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
import { systemClock } from '../runtime.ts';
|
||||||
|
|
||||||
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
app.get(healthRoute.path, async (c) => {
|
app.get(healthRoute.path, async (c) => {
|
||||||
@@ -21,44 +21,20 @@ export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container)
|
|||||||
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
|
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */
|
const unauthenticated: ApiError = { code: 'unauthenticated', message: '缺少 Bearer token' };
|
||||||
app.get(sessionRoute.path, (c) => {
|
|
||||||
const session = c.get('session');
|
/** 我是谁、我能进哪几个端 —— 中间件已把 principal 放进上下文,这里只回投影;ends 为空也 200 */
|
||||||
if (session === undefined) {
|
app.get(meRoute.path, (c) => {
|
||||||
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
|
const principal = c.get('principal');
|
||||||
return c.json(body, 401);
|
if (principal === undefined) return c.json(unauthenticated, 401);
|
||||||
}
|
return c.json(principal);
|
||||||
return c.json(session);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(loginRoute.path, async (c) => {
|
/** 60 秒票据(ADR-012 §9.3)—— 给 EventSource 与 <a href> 下载这两条带不了 Authorization 头的路 */
|
||||||
const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
app.get(authTicketRoute.path, (c) => {
|
||||||
if (!parsed.success) {
|
const principal = c.get('principal');
|
||||||
const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' };
|
if (principal === undefined) return c.json(unauthenticated, 401);
|
||||||
return c.json(body, 400);
|
const issued = container.tickets.issue(principal.staff.id, systemClock.now());
|
||||||
}
|
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt.toISOString() });
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,21 +48,60 @@ const HISTORY_TOOL = {
|
|||||||
connectionKeys: ['endpoint'],
|
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,
|
...HISTORY_TOOL,
|
||||||
name: 'mail.send',
|
name: 'mail.send',
|
||||||
displayName: '发送邮件',
|
displayName: '发送邮件',
|
||||||
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
|
version: '2.0.0',
|
||||||
|
description:
|
||||||
|
'从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' +
|
||||||
|
'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。',
|
||||||
input: {
|
input: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['to', 'subject'],
|
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,
|
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,
|
idempotent: true,
|
||||||
connectionKeys: ['endpoint'],
|
connectionKeys: ['account'],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -297,8 +336,10 @@ export async function seedMvp(container: Container): Promise<void> {
|
|||||||
// 样例集一份喂四件工具:并集覆盖各家必填
|
// 样例集一份喂四件工具:并集覆盖各家必填
|
||||||
input: {
|
input: {
|
||||||
pn: 'LM358DR',
|
pn: 'LM358DR',
|
||||||
to: 'x@y.com',
|
to: ['x@y.com'],
|
||||||
subject: '询价',
|
subject: '询价',
|
||||||
|
body: '样例正文',
|
||||||
|
dryRun: true,
|
||||||
history_days: 30,
|
history_days: 30,
|
||||||
market_stable: true,
|
market_stable: true,
|
||||||
lifecycle: '在产',
|
lifecycle: '在产',
|
||||||
@@ -306,8 +347,10 @@ export async function seedMvp(container: Container): Promise<void> {
|
|||||||
expectShape: { type: 'object' },
|
expectShape: { type: 'object' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点
|
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」
|
||||||
...(contract.connectionKeys.length === 0 ? {} : { connectionValues: { endpoint: 'replay://crm' } }),
|
...(contract.connectionKeys.length === 0
|
||||||
|
? {}
|
||||||
|
: { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }),
|
||||||
});
|
});
|
||||||
if (!verdict.accepted) {
|
if (!verdict.accepted) {
|
||||||
log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; '));
|
log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; '));
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export async function seed(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await container.org.ensureSeeded();
|
await container.org.ensureSeeded();
|
||||||
const { staff, positions } = await container.org.listStaff();
|
const { staff, positions } = await container.org.listStaff();
|
||||||
process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`);
|
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`);
|
for (const s of staff) process.stdout.write(` ${s.account.padEnd(24)} ${s.name}(${s.position.code})\n`);
|
||||||
|
|
||||||
// MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条)
|
// MVP 底座:四域数据 · 六活动槽位 · 1:1 流程包(README MVP 完成判据第 1 条)
|
||||||
process.stdout.write('\n── 治理台配置 ──\n');
|
process.stdout.write('\n── 治理台配置 ──\n');
|
||||||
|
|||||||
+53
-31
@@ -1,13 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
||||||
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
* 鉴权、端归属、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
||||||
*/
|
*/
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ApiError } from '@contracts';
|
import * as contracts from '@contracts';
|
||||||
import { healthRoute, ingestMailRoute, loginRoute } 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 { Container } from './container.ts';
|
||||||
import type { AppEnv } from './http-env.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 { registerDeskRoutes } from './routes/desk.ts';
|
||||||
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||||
import { registerDeskApprovalRoutes } from './routes/desk-approval.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 { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||||
|
import { registerMailRoutes } from './routes/mail.ts';
|
||||||
import { registerStreamRoute } from './routes/stream.ts';
|
import { registerStreamRoute } from './routes/stream.ts';
|
||||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||||
|
|
||||||
export type { AppEnv };
|
export type { AppEnv };
|
||||||
|
|
||||||
/**
|
/** 字面路径注册、不在契约里的路由 —— 注册表得知道它们归哪儿(SSE 只要登录) */
|
||||||
* 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。
|
export const EXTRA_ROUTES: readonly RegisteredRoute[] = [{ method: 'GET', path: '/api/stream', access: 'any' }];
|
||||||
* 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。
|
|
||||||
*/
|
|
||||||
const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':'));
|
|
||||||
|
|
||||||
function isPublic(method: string, path: string): boolean {
|
/** 全部路由的归属(ADR-012)—— 契约命名空间扫一遍 + 字面路径补入;新路由写在契约里就自动生效 */
|
||||||
if (method === 'GET' && path === healthRoute.path) return true;
|
export function appRouteRegistry(): RouteRegistry {
|
||||||
if (method === 'POST' && path === loginRoute.path) return true;
|
return buildRouteRegistry([...routesOf(Object.values(contracts)), ...EXTRA_ROUTES]);
|
||||||
if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随统一鉴权中心退役的路由(§12.4 三步之第二步):契约留一轮给 web 编译,后端一律 410 并指路。
|
||||||
|
* 它们的 access 是 public —— 410 不需要凭据,也不泄漏任何东西。
|
||||||
|
*/
|
||||||
|
const RETIRED_ROUTES: ReadonlyArray<{ readonly route: RouteContract; readonly hint: string }> = [
|
||||||
|
{ route: loginRoute, hint: '登录由 Keycloak 完成,请求带 Authorization: Bearer <token>' },
|
||||||
|
{ 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<AppEnv> {
|
export function createServer(container: Container): Hono<AppEnv> {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。
|
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-012)。
|
||||||
*
|
* 凭据只认验过签的 Bearer token(或由它换来的 60 秒票据);归属按路由注册表统一判,
|
||||||
* 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来,
|
* 路由里不再各写守卫 —— 一处拦住,新路由自动生效。
|
||||||
* 冒充不了任何人 —— 这正是明文 `jcai_account` cookie 被废止的理由。
|
|
||||||
*/
|
*/
|
||||||
app.use('*', async (c, next) => {
|
app.use(
|
||||||
const token = readSessionToken(c.req.header('cookie'));
|
'*',
|
||||||
const session = token === undefined ? undefined : await container.org.resolveSession(token);
|
createAuthMiddleware({ registry: appRouteRegistry(), org: container.org, tickets: container.tickets, clock: systemClock }),
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
|
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
|
||||||
@@ -92,6 +106,13 @@ export function createServer(container: Container): Hono<AppEnv> {
|
|||||||
return c.json(body, 404);
|
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);
|
registerWorkbenchRoutes(app, container);
|
||||||
registerGovernanceRoutes(app, container);
|
registerGovernanceRoutes(app, container);
|
||||||
registerDeskRoutes(app, container);
|
registerDeskRoutes(app, container);
|
||||||
@@ -99,6 +120,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
|||||||
registerDeskApprovalRoutes(app, container);
|
registerDeskApprovalRoutes(app, container);
|
||||||
registerRegistrationRoutes(app, container);
|
registerRegistrationRoutes(app, container);
|
||||||
registerGovernanceOpsRoutes(app, container);
|
registerGovernanceOpsRoutes(app, container);
|
||||||
|
registerMailRoutes(app, container);
|
||||||
registerIngestRoutes(app, container);
|
registerIngestRoutes(app, container);
|
||||||
registerStreamRoute(app, container.broadcaster);
|
registerStreamRoute(app, container.broadcaster);
|
||||||
|
|
||||||
|
|||||||
@@ -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<AppEnv>, 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<AppEnv>): void {
|
|
||||||
c.header(
|
|
||||||
'Set-Cookie',
|
|
||||||
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`,
|
|
||||||
{ append: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 60 秒票据(ADR-012 §9.3)—— 只为两条带不了 Authorization 头的路:
|
||||||
|
* `EventSource('/api/stream')` 与 `<a href="…&raw=1" download>`。
|
||||||
|
*
|
||||||
|
* 形状 `v1.<sub-b64url>.<expUnix>.<hmac-b64url>`;密钥由 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -286,15 +286,6 @@ const rankKeyPersons: ToolHandler = async ({ input }) => {
|
|||||||
return { ok: true, output: { ranked, counts } };
|
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<readonly [string, ToolHandler]> =
|
|||||||
['quote-pricing.floor-gate', floorGate],
|
['quote-pricing.floor-gate', floorGate],
|
||||||
['quote-archive.profile', profile],
|
['quote-archive.profile', profile],
|
||||||
['quote-archive.landing-rate', landingRate],
|
['quote-archive.landing-rate', landingRate],
|
||||||
['mail.send', mailSend],
|
|
||||||
['slot.placeholder', slotPlaceholder],
|
['slot.placeholder', slotPlaceholder],
|
||||||
['crm-relationship-coverage.recompute-score', recomputeScore],
|
['crm-relationship-coverage.recompute-score', recomputeScore],
|
||||||
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
||||||
|
|||||||
@@ -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<T>(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<StoredUpload> {
|
||||||
|
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) };
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { renderView } from '@modules/interaction';
|
|||||||
import type { InteractionService } from '@modules/interaction';
|
import type { InteractionService } from '@modules/interaction';
|
||||||
import type { MessageService } from '@modules/message';
|
import type { MessageService } from '@modules/message';
|
||||||
import type { OrgService } from '@modules/org';
|
import type { OrgService } from '@modules/org';
|
||||||
|
import { NONE_POSITION_CODE } from '@modules/org';
|
||||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||||
import type { ActivityRegistryService } from '@modules/activity-registry';
|
import type { ActivityRegistryService } from '@modules/activity-registry';
|
||||||
import type { SkillEngineService } from '@modules/skill-engine';
|
import type { SkillEngineService } from '@modules/skill-engine';
|
||||||
@@ -2277,6 +2278,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
ids,
|
ids,
|
||||||
declarations: manifest.declarations.ledger,
|
declarations: manifest.declarations.ledger,
|
||||||
|
// 「这一单产出的」= 本单碰过的行:整表里躺着历次跑出来的资产,不是这一单的成果
|
||||||
|
touched: await deps.ledger.taskDrafts(input.taskId),
|
||||||
rowsOfAddress: async (address) =>
|
rowsOfAddress: async (address) =>
|
||||||
(
|
(
|
||||||
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
|
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
|
||||||
@@ -2451,7 +2454,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
org: {
|
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) => {
|
candidates: async (position) => {
|
||||||
const staff = await deps.org.staffOfPosition(position);
|
const staff = await deps.org.staffOfPosition(position);
|
||||||
const withLoad = await Promise.all(
|
const withLoad = await Promise.all(
|
||||||
@@ -2612,10 +2619,17 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
|
// 起单失败入死信 —— 落日志 + 送治理台的消息要素,不静默丢弃
|
||||||
report: async (input) => {
|
report: async (input) => {
|
||||||
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
writeLog(`[trigger] 起单失败入死信:${input.triggerId} —— ${input.reason}`);
|
||||||
const { staff } = await deps.org.listStaff();
|
// 收件人 = 能进运营中心的人(触发器的启停归运营;ADR-012 权限只到端)
|
||||||
for (const person of staff) {
|
const recipients = await deps.org.staffWithEnd('ops');
|
||||||
const governs = await deps.org.authorize(person.id, { kind: 'governance', function: 'trigger' }, 'govern');
|
// 一个收件人都没有:这条通知这一次谁也收不到 —— 把「出事了」无声丢掉最糟,
|
||||||
if (!governs) continue;
|
// 所以出声(与 container.ts 的 governanceStaff 同一口径)
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
writeLog(
|
||||||
|
'[trigger] 死信通知这一次没有收件人 —— 没有能进运营中心的活跃员工。' +
|
||||||
|
'去运营中心的「角色 → 端」把某个 realm 角色配上 ops,或让持该角色的人先登录一次。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const person of recipients) {
|
||||||
await deps.message.notify({
|
await deps.message.notify({
|
||||||
staffId: person.id,
|
staffId: person.id,
|
||||||
title: `触发器起单失败:${input.triggerId}`,
|
title: `触发器起单失败:${input.triggerId}`,
|
||||||
|
|||||||
@@ -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<Container, 'mail'>; readonly now?: () => Date };
|
||||||
|
|
||||||
|
export async function pollMailOnce(deps: MailPollerDeps): Promise<MailPollOutcome[]> {
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
层级: L2(编码规范 §4)
|
层级: L2(编码规范 §4)
|
||||||
依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、
|
依赖: @contracts;@shared/db;port:`EventGateway`(app 注入 event)、
|
||||||
`LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(模拟通道)
|
`LedgerSystemWriter`(app 注入 ledger)、`MailTransport`(app 注入,转 mail 模块)
|
||||||
被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由)
|
被依赖: release(出站发送)、app(治理台 · 系统管理 / ingest 路由)
|
||||||
|
|
||||||
不变量:
|
不变量:
|
||||||
@@ -29,7 +29,9 @@
|
|||||||
- **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底
|
- **无主不写台账**:仅有主入站才归一为系统写;无主入站只入事件流并走消息兜底
|
||||||
- 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置
|
- 无主键的映射结果丢弃(无从定位),由调用方按提取失败处置
|
||||||
- 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源
|
- 出站即登记关联记录:线程 ID 与往来地址是归属解析①③级的对表来源
|
||||||
- 邮件通道是**外界**:MVP 用模拟通道(出站落库不真发,入站经 ingest 注入);
|
- 邮件通道是**外界**:真通道由 `mail` 模块提供(ADR-013)—— app 把 `channel.ingestMail` 注册为它的订阅方,
|
||||||
|
把 `MailTransport` 注入为「转 `mail.send`」(按发件地址找账号,对不上不回落默认);
|
||||||
|
`createSimulatedMailTransport` 只剩测试 / 演示用途,`/api/ingest` 仍是注入门(e2e 用);
|
||||||
系统内部机制一律真跑,不得 mock
|
系统内部机制一律真跑,不得 mock
|
||||||
|
|
||||||
影响半径: index/contract/port 未变 → 对外零影响
|
影响半径: index/contract/port 未变 → 对外零影响
|
||||||
|
|||||||
@@ -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 侧装配未变
|
||||||
@@ -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<string, unknown>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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<string, MailAccountRecord> } {
|
||||||
|
const rows = new Map<string, MailAccountRecord>();
|
||||||
|
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<string, InboundRecord>; deliveries: Map<string, DeliveryRecord> } {
|
||||||
|
const rows = new Map<string, InboundRecord>();
|
||||||
|
const deliveries = new Map<string, DeliveryRecord>();
|
||||||
|
// 与 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<string, OutboundRecord> } {
|
||||||
|
const rows = new Map<string, OutboundRecord>();
|
||||||
|
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<string, { name: string; contentType: string; bytes: Uint8Array; note: string }> } {
|
||||||
|
const files = new Map<string, { name: string; contentType: string; bytes: Uint8Array; note: string }>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -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: '<o1@we.com>',
|
||||||
|
from: { address: 'sales@we.com', name: '销售部' },
|
||||||
|
to: ['b@acme.com'],
|
||||||
|
cc: [],
|
||||||
|
bcc: ['c@we.com'],
|
||||||
|
subject: '报价',
|
||||||
|
text: '正文',
|
||||||
|
inReplyTo: '<m1@acme.com>',
|
||||||
|
references: ['<m1@acme.com>'],
|
||||||
|
attachments: [{ name: 'q.pdf', contentType: 'application/pdf', bytes: new Uint8Array([1, 2]) }],
|
||||||
|
});
|
||||||
|
expect(options.messageId).toBe('<o1@we.com>');
|
||||||
|
expect(options.from).toEqual({ name: '销售部', address: 'sales@we.com' });
|
||||||
|
expect(options.bcc).toEqual(['c@we.com']);
|
||||||
|
expect(options.inReplyTo).toBe('<m1@acme.com>');
|
||||||
|
expect(options.references).toEqual(['<m1@acme.com>']);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Set<string>> => new Set<string>();
|
||||||
|
|
||||||
|
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: `<it-${Date.now()}@${account.address.split('@')[1] ?? 'local'}>`,
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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: '<m1@acme.com>',
|
||||||
|
inReplyTo: '<q1@we.com>',
|
||||||
|
references: ['<root@we.com>', '<q1@we.com>'],
|
||||||
|
from: { address: 'buyer@acme.com', name: '张三' },
|
||||||
|
to: [{ address: 'sales@we.com', name: '' }],
|
||||||
|
cc: [],
|
||||||
|
subject: '询价 RFQ-1',
|
||||||
|
text: '你好,请报价',
|
||||||
|
html: '<p>你好,请报价</p>',
|
||||||
|
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 现在给的是根 <root@we.com>。
|
||||||
|
*/
|
||||||
|
expect(threadIdOf(parsed)).toBe('<root@we.com>');
|
||||||
|
expect(threadIdOf({ references: ['<a>', '<b>'] })).toBe('<a>');
|
||||||
|
expect(threadIdOf({ references: [], inReplyTo: '<only-hop>' })).toBe('<only-hop>');
|
||||||
|
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('<uid:7:12@we.com>');
|
||||||
|
expect(fallbackMessageId('x', 'no-at')).toBe('<x@local>');
|
||||||
|
});
|
||||||
|
it('纯 html 的信剥标签当正文;script / style 整段丢;实体解回来;空白收成一个', () => {
|
||||||
|
expect(stripHtml('<style>p{}</style><p>你好&<b>世界</b></p>\n<script>x()</script> 再见')).toBe('你好&世界 再见');
|
||||||
|
expect(bodyTextOf({ html: '<div>只有 html</div>' })).toBe('只有 html');
|
||||||
|
expect(bodyTextOf({ text: '有 text', html: '<i>忽略</i>' })).toBe('有 text');
|
||||||
|
expect(bodyTextOf({})).toBe('');
|
||||||
|
});
|
||||||
|
it('预览 200 字', () => {
|
||||||
|
expect(textPreviewOf('a'.repeat(300), null)).toHaveLength(200);
|
||||||
|
expect(textPreviewOf(null, '<p>来自 html</p>')).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: `<m${NUL}1@acme.com>`,
|
||||||
|
inReplyTo: `<q${NUL}1@we.com>`,
|
||||||
|
references: [`<root${NUL}@we.com>`],
|
||||||
|
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: `<p>${NUL}二</p>`,
|
||||||
|
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('<p>二</p>');
|
||||||
|
expect(dirty.messageId).toBe('<m1@acme.com>');
|
||||||
|
expect(dirty.providerRef).toBe('uid:7:14');
|
||||||
|
expect(dirty.inReplyTo).toBe('<q1@we.com>');
|
||||||
|
expect(dirty.references).toEqual(['<root@we.com>']);
|
||||||
|
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('<uid:7:15@we.com>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<m1@acme.com>');
|
||||||
|
expect(record.inReplyTo).toBe('<q1@we.com>');
|
||||||
|
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('<uid:7:13@we.com>');
|
||||||
|
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(['<root@we.com>', '<q1@we.com>']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): OutboundRecord {
|
||||||
|
return {
|
||||||
|
id: 'out-9',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
messageId: '<out-9@we.com>',
|
||||||
|
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: '<m-1@acme.com>',
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '<m1@acme.com>', subject: '一', attachment: { name: 'bom.xlsx', contentType: 'application/vnd.ms-excel', content: 'PK' } })),
|
||||||
|
rawMessage('uid:v1:2', eml({ messageId: '<m2@acme.com>', 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: '<m1@acme.com>', 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: '<m1@acme.com>', attachment: { name: 'dup.txt', contentType: 'text/plain', content: 'y' } })),
|
||||||
|
// 注:撞 **messageId**(而 providerRef 是新的)那条路,附件会在 insert 报重复之前就存下 ——
|
||||||
|
// 留下一份没人引用的孤儿。已记欠账(要根治得给 InboundStore 加一个按 messageId 查重的口),本轮不在此坐实。
|
||||||
|
rawMessage('uid:v1:9', eml({ messageId: '<m1@acme.com>' })),
|
||||||
|
);
|
||||||
|
const again = await h.service.pollNow('acc-1');
|
||||||
|
expect(again.pulled).toBe(2);
|
||||||
|
expect(again.stored).toBe(0);
|
||||||
|
expect(hits).toEqual(['<m1@acme.com>']);
|
||||||
|
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: '<m1@a>' })), rawMessage('uid:v1:2', eml({ messageId: '<m2@a>' })));
|
||||||
|
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: '<x@a>' })));
|
||||||
|
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: '<bad@a>', subject: '毒信一封' })),
|
||||||
|
rawMessage('uid:v1:2', eml({ messageId: '<ok@a>', 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: '<x@a>' })));
|
||||||
|
|
||||||
|
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: '<x@a>' })));
|
||||||
|
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: '<m1@a>' })));
|
||||||
|
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: '<m1@a>' })));
|
||||||
|
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: '<m1@a>', 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: '<m2@a>' })));
|
||||||
|
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: '<m1@a>' })));
|
||||||
|
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: '<m1@a>', 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:');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 是 <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: '<out-1@we.com>', state: 'sent', providerRef: 'fake:1' });
|
||||||
|
expect(h.imap.sent[0]?.from).toEqual({ address: 'sales@we.com', name: '销售部' });
|
||||||
|
expect(h.imap.sent[0]?.messageId).toBe('<out-1@we.com>');
|
||||||
|
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: '<m1@acme.com>', references: ['<m1@acme.com>'], requestedBy: { kind: 'channel', taskId: 't-1' } });
|
||||||
|
expect(result.accountId).toBe('acc-1');
|
||||||
|
expect(h.imap.sent[0]?.inReplyTo).toBe('<m1@acme.com>');
|
||||||
|
expect(h.imap.sent[0]?.references).toEqual(['<m1@acme.com>']);
|
||||||
|
expect(h.outbound.rows.get('out-1')?.requestedBy).toEqual({ kind: 'channel', taskId: 't-1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> } | 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>): 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>): InboundRecord {
|
||||||
|
return {
|
||||||
|
id: 'in-1', accountId: 'acc-a', messageId: '<m1@x>', 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>): OutboundRecord {
|
||||||
|
return {
|
||||||
|
id: 'out-1', accountId: 'acc-a', messageId: '<o1@we.com>', 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: '<other@x>' }))).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: '<m2@x>', 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: '<m-noraw@x>', 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: '<o2@we.com>' }));
|
||||||
|
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: '<race@we.com>', 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: '"张三" <buyer@acme.com>',
|
||||||
|
to: 'sales@we.com, "Ops" <ops@we.com>',
|
||||||
|
subject: '询价 RFQ-1',
|
||||||
|
messageId: '<m1@acme.com>',
|
||||||
|
inReplyTo: '<q1@we.com>',
|
||||||
|
references: '<root@we.com> <q1@we.com>',
|
||||||
|
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('<m1@acme.com>');
|
||||||
|
expect(parsed.inReplyTo).toBe('<q1@we.com>');
|
||||||
|
expect(parsed.references).toEqual(['<root@we.com>', '<q1@we.com>']);
|
||||||
|
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" <c@x.com>;', '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', '', '<b>only html</b>', ''].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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>): 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('<id@域>;地址没有域用 local', () => {
|
||||||
|
expect(messageIdFor('u-1', 'a@we.com')).toBe('<u-1@we.com>');
|
||||||
|
expect(messageIdFor('u-1', '')).toBe('<u-1@local>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> };
|
||||||
|
|
||||||
|
function scripted(handlers: Record<string, (body: Record<string, unknown>, nth: number) => unknown>): { fetch: FetchLike; calls: Call[] } {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
const fetch: FetchLike = async (url, init) => {
|
||||||
|
const body = init?.body === undefined ? {} : (JSON.parse(init.body) as Record<string, unknown>);
|
||||||
|
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<Set<string>> => new Set<string>();
|
||||||
|
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: '<x@y>',
|
||||||
|
from: { address: 'app@corp.wecom.work', name: '应用' },
|
||||||
|
to: ['a@x.com'],
|
||||||
|
cc: ['b@x.com'],
|
||||||
|
bcc: [],
|
||||||
|
subject: '报价',
|
||||||
|
text: '纯文本',
|
||||||
|
html: '<p>html</p>',
|
||||||
|
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: '<p>html</p>',
|
||||||
|
content_type: 'html',
|
||||||
|
attachment_list: [{ file_name: 'q.pdf', content: Buffer.from([1, 2]).toString('base64') }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void | { readonly skipped: string }>;
|
||||||
|
|
||||||
|
export type MailService = {
|
||||||
|
/* ── 账号 ── */
|
||||||
|
listAccounts(): Promise<MailAccountView[]>;
|
||||||
|
getAccount(accountId: string): Promise<MailAccountView>;
|
||||||
|
createAccount(input: MailAccountInput): Promise<MailAccountView>;
|
||||||
|
/** secret 缺省 = 不改密文;kind / settings 变了游标清空重锚 */
|
||||||
|
updateAccount(accountId: string, input: MailAccountInput): Promise<MailAccountView>;
|
||||||
|
/** 留痕不删(入站 / 出站表对账号不设外键) */
|
||||||
|
deleteAccount(accountId: string): Promise<void>;
|
||||||
|
/** 已存账号用库里凭证;未存用入参里的;一个字节都不落库(wecom 探到地址的已存账号除外:回填 address) */
|
||||||
|
probe(target: { accountId: string } | { input: MailAccountInput; accountId?: string | undefined }): Promise<MailProbeReport>;
|
||||||
|
/* ── 收 ── */
|
||||||
|
/** 工作者用:到期且未被占的账号各拉一轮(账号间并行) */
|
||||||
|
pollDue(now: Date): Promise<MailPollOutcome[]>;
|
||||||
|
/** 路由用:租约被占 → MailPollBusyError;停用 → MailAccountDisabledError */
|
||||||
|
pollNow(accountId: string): Promise<MailPollOutcome>;
|
||||||
|
/** 同名重复注册 → MailSubscriberTakenError;返回注销函数 */
|
||||||
|
subscribe(name: string, handler: InboundHandler): () => void;
|
||||||
|
listInbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailInboundSummary[]>;
|
||||||
|
getInbound(mailId: string): Promise<MailInboundDetail>;
|
||||||
|
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<MailDeliveryView[]>;
|
||||||
|
/* ── 发 ── */
|
||||||
|
/** 先落 sending 行再经 provider 发;厂商失败落 failed 并以 state='failed' 返回,不抛 */
|
||||||
|
send(input: MailSendInput): Promise<MailSendResult>;
|
||||||
|
listOutbound(query: { accountId?: string | undefined; limit?: number | undefined }): Promise<MailOutboundView[]>;
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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<T>(account: ProviderAccount, fn: (client: ImapFlow, folder: string) => Promise<T>): Promise<T> {
|
||||||
|
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<void>): 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<MailProbeReport> {
|
||||||
|
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<MailCursor> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>>().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<string[]>().notNull().default([]),
|
||||||
|
fromAddr: text('from_addr').notNull().default(''),
|
||||||
|
fromName: text('from_name').notNull().default(''),
|
||||||
|
toList: jsonb('to_list').$type<MailAddress[]>().notNull().default([]),
|
||||||
|
ccList: jsonb('cc_list').$type<MailAddress[]>().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<Array<{ index: number; name: string; contentType: string; size: number; ref: string | null; note: string }>>()
|
||||||
|
.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<string[]>().notNull(),
|
||||||
|
ccList: jsonb('cc_list').$type<string[]>().notNull().default([]),
|
||||||
|
bccList: jsonb('bcc_list').$type<string[]>().notNull().default([]),
|
||||||
|
subject: text('subject').notNull(),
|
||||||
|
textBody: text('text_body'),
|
||||||
|
htmlBody: text('html_body'),
|
||||||
|
inReplyTo: text('in_reply_to'),
|
||||||
|
referenceIds: jsonb('reference_ids').$type<string[]>().notNull().default([]),
|
||||||
|
attachments: jsonb('attachments').$type<Array<{ name: string; contentType: string; size: number; ref: string | null }>>().notNull().default([]),
|
||||||
|
state: text('state').notNull(),
|
||||||
|
error: text('error'),
|
||||||
|
providerRef: text('provider_ref'),
|
||||||
|
requestedBy: jsonb('requested_by').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||||
|
});
|
||||||
@@ -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<typeof mailAccountTable.$inferInsert> = {};
|
||||||
|
// `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<boolean>`${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<Map<string, DeliveryRecord[]>> {
|
||||||
|
const out = new Map<string, DeliveryRecord[]>();
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 是空白分隔的一串 <id>;拆开、去空 */
|
||||||
|
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<ParsedMail> {
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, string>; body?: string; signal?: AbortSignal },
|
||||||
|
) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
|
||||||
|
|
||||||
|
type WecomResponse = Record<string, unknown> & { 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<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
async function call(api: string, url: string, init?: Parameters<FetchLike>[1]): Promise<WecomResponse> {
|
||||||
|
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<string> {
|
||||||
|
// 先取凭证再查缓存 —— 键就是凭证的指纹,取不到凭证也就无所谓缓存(见 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<WecomResponse> {
|
||||||
|
const settings = wecomSettingsSchema.parse(account.settings);
|
||||||
|
const run = (token: string): Promise<WecomResponse> =>
|
||||||
|
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<MailProbeReport> {
|
||||||
|
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<MailCursor> {
|
||||||
|
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 {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Record<MailAccountKind, readonly string[]>> = {
|
||||||
|
'imap-smtp': ['imapHost', 'imapPort', 'imapTls', 'folder'],
|
||||||
|
wecom: ['corpId', 'apiBase'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sameReceiveSettings(kind: MailAccountKind, a: Record<string, unknown>, b: Record<string, unknown>): 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<string, unknown>, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record<string, string>> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
||||||
|
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
||||||
|
.replace(/<br\s*\/?>|<\/(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<ParsedMail, 'text' | 'html'>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
/** 密封的 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<string, unknown>;
|
||||||
|
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[];
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<MailSendInput, 'to' | 'cc' | 'bcc' | 'subject' | 'text' | 'html' | 'attachments'>;
|
||||||
|
|
||||||
|
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}>`;
|
||||||
|
}
|
||||||
@@ -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<Record<MailAccountKind, MailProvider>>;
|
||||||
|
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<string, InboundHandler>();
|
||||||
|
|
||||||
|
async function requireAccount(accountId: string): Promise<MailAccountRecord> {
|
||||||
|
const record = await deps.accounts.get(accountId);
|
||||||
|
if (record === undefined) throw new MailAccountNotFoundError(accountId);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 明文只在这里打开、交给 provider 那一段;不进任何留痕 */
|
||||||
|
function openSecret(record: Pick<MailAccountRecord, 'id' | 'secretCipher'>): Record<string, string> {
|
||||||
|
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<string, unknown>).map(([k, v]) => [k, String(v)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerAccount(record: MailAccountRecord, secret: Record<string, string>): ProviderAccount {
|
||||||
|
return { id: record.id, kind: record.kind, address: record.address, displayName: record.displayName, settings: record.settings, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactWith(secret: Record<string, string>): (text: string) => string {
|
||||||
|
const values = Object.values(secret);
|
||||||
|
return (text) => redactSecrets(text, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactReport(report: MailProbeReport, secret: Record<string, string>): 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<DeliveryRecord[]> {
|
||||||
|
// 只要那几条投递痕,不必把 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<InboundRecord | undefined> {
|
||||||
|
// 先按 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<MailPollOutcome> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, string>)
|
||||||
|
: 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(),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user