Commit 9d962648 by luoqi

feat(friday-ai): AI 客服工作台 —— 托管企微、接待 Agent、预约确认

一套**寄生在宿主业务系统上**的 AI 客服能力:读全走 PAC,写留自己,
企微通过 ECom 非官方通道托管。独立库、独立部署,与 PAC 同仓但不焊死。

## 边界(scripts/check-ai-boundaries.mjs 五道闸)

依赖白名单(分应用)· 独立 prisma schema 与 AI_DATABASE_URL · 跨 app 相对路径 ·
ECom 调用必带 deviceId(丢了会被当新设备,严重时封号)· 用户侧查询过数据范围。
️ 做成**可本地运行的脚本**而不是只写在 CI 里 —— 只存在于 CI 的检查没人推之前跑,
实际作用会退化成"合并请求红了才发现"。

## 两条权限轴,各归各的

    托管轴 accountScope()  = wecom_accounts.user_id = 我   会话/发送/附件/通讯录
    授权轴 orgUnitScope()  = user_org_scopes                组织/诊所/无会话待办/PAC 读

️ 混在一起踩过:新同事绑好自己的号,看到的是**同事号里的**患者对话,
而自己号里的一条都没有。

## 三层租户隔离

DB 复合外键(写)· Prisma 扩展自动注入 where.tenantId(读)· service 显式 where。
️ 第二层的受管模型**从 schema 推导**, 不手写清单 —— 手写那份漏过 4 张表,
而且正如它自己预言的:不报错。

## Agent

接待(医疗信号规则先行,模型兜底)· 预约确认(扫描 + 会话双腿)。
出站四道闸:账号开关 · 在线 · 租户 AI 自动发送 · 人工接管态。
输出守卫拦医生姓名与金额 —— 不是"暂时",是没有任何工具能返回它们。

## 前端

会话接待 · 通讯录绑患者 · 预约确认工作台 · 患者面板(PAC 事实 + 回访归并的时间线)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 887530ca
...@@ -9,6 +9,8 @@ ...@@ -9,6 +9,8 @@
stages: stages:
- docs - docs
- guards
- ai
openapi-drift: openapi-drift:
stage: docs stage: docs
...@@ -20,8 +22,14 @@ openapi-drift: ...@@ -20,8 +22,14 @@ openapi-drift:
- apps/pac-service/**/* - apps/pac-service/**/*
- packages/types/**/* - packages/types/**/*
- apps/pac-docs/openapi/pac.json - apps/pac-docs/openapi/pac.json
# 默认分支推送总是跑(兜底) # 默认分支推送也跑(兜底),但**同样按 changes 过滤** ——
# 否则 apps/ai-service 的提交推到 main 也会触发整套 install + prisma generate +
# openapi dump,纯属白烧 CI。
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
changes:
- apps/pac-service/**/*
- packages/types/**/*
- apps/pac-docs/openapi/pac.json
cache: cache:
key: key:
files: files:
...@@ -48,3 +56,76 @@ openapi-drift: ...@@ -48,3 +56,76 @@ openapi-drift:
exit 1 exit 1
fi fi
- echo "✓ OpenAPI spec 与代码一致" - echo "✓ OpenAPI spec 与代码一致"
# ═══════════════════════════════════════════════════════════════════════════
# friday-ai(apps/ai-service · apps/ai-web · packages/host-connector)
#
# ⚠️ 闸 4「turbo --filter 分流」体现在这里:friday-ai 的任务
# ① 只在 friday-ai 自己的路径变更时触发(changes 过滤)
# ② 全部走 `--filter`,不碰 PAC 的构建
# ⇒ friday-ai 的失败**不会染红 PAC 的 pipeline**。CI 红灯疲劳是真会死人的:
# 一旦"红了也不一定有事"成为常识,真正的红灯也就没人看了。
# ═══════════════════════════════════════════════════════════════════════════
.ai-changes: &ai-changes
- apps/ai-service/**/*
- apps/ai-web/**/*
- packages/host-connector/**/*
- scripts/check-ai-boundaries.mjs
.ai-setup: &ai-setup
image: node:22
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
before_script:
- corepack enable
- corepack prepare pnpm@10.13.1 --activate
- pnpm config set store-dir .pnpm-store
- pnpm install --frozen-lockfile
# ── 闸 1 / 2 / 3:边界 ────────────────────────────────────────────────────────
# 逻辑全在 scripts/check-ai-boundaries.mjs 里,CI 只是调用它 ——
# 这样任何人推之前都能在本地跑同一条命令,不必等 MR 红了才知道。
ai-boundaries:
stage: guards
<<: *ai-setup
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: *ai-changes
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
changes: *ai-changes
script:
- node scripts/check-ai-boundaries.mjs
- pnpm exec eslint apps/ai-service apps/ai-web packages/host-connector
# ── 类型 + 构建 ──────────────────────────────────────────────────────────────
ai-build:
stage: ai
<<: *ai-setup
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: *ai-changes
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
changes: *ai-changes
variables:
# 构建期不连库,给一个形状合法的占位值即可(configuration.ts 的 required() 只查存在性)
AI_DATABASE_URL: 'postgresql://ci:ci@localhost:5432/ci'
script:
# ⚠️ 顺序不能反:@pac/types 是源码包,不先 build 出 dist,ai-service 的 tsc 找不到它
- pnpm --filter @pac/types build
- pnpm --filter @pac/host-connector build
- pnpm --filter @pac/ai-service exec prisma generate
- pnpm --filter @pac/ai-service type-check
- pnpm --filter @pac/ai-service build
- pnpm --filter @pac/ai-service test
# ⭐ 构建完立刻断言 dist 非空 —— tsconfig 的 incremental 一旦被改回 true,
# `nest build` 会**静默产出空 dist 并退出 0**(详见 apps/ai-service/tsconfig.json 的注释)。
# 没有这一句,CI 全绿、部署上去起不来。
- test -f apps/ai-service/dist/main.js || (echo "✖ dist/main.js 不存在 —— 见 tsconfig.json 里 incremental 的注释"; exit 1)
- pnpm --filter @pac/ai-web type-check
- pnpm --filter @pac/ai-web build
# ── ai-service ───────────────────────────────────────────────────────────────
# 复制成 .env 后改。⚠️ chmod 600 —— 里面有库密码。
#
# ⚠️ 键名前缀刻意跟 pac-service 岔开(AI_*):同仓开发时两个 app 的 .env 会在同一个
# shell 里被 source,共用键名会让 friday-ai 连上 PAC 的库、错误报进 PAC 的 Sentry。
NODE_ENV=development
PORT=3011
LOG_LEVEL=info
# ── 数据库 ────────────────────────────────────────────────────────────────────
# ⚠️ 必须与 pac-service 的 DATABASE_URL 指向**不同的库**([05 §2 闸 2](docs/05-工程约定.md))。
# CI 会断言这两个值不相等。
AI_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/friday_ai
# worker / CLI 这类独立进程按自己的 --concurrency 设它,连接池会跟着放大(封顶 40)。
# 常驻 API 进程留空即可 —— 见 prisma.service.ts 的说明。
# AI_DB_CONCURRENCY=1
# 租户守卫模式:enforce(默认,自动注入 where.tenantId)| warn(只告警,排查期临时用)
# AI_TENANT_GUARD=enforce
# ── CORS ─────────────────────────────────────────────────────────────────────
# 逗号分隔。留空 = 允许任意来源(仅限本地);生产必须显式列出 ai-web 的域名。
CORS_ORIGINS=
# ── Sentry(可选,缺省则整体 no-op)─────────────────────────────────────────────
# AI_SENTRY_DSN=
# SENTRY_ENVIRONMENT=test
# SENTRY_TRACES_SAMPLE_RATE=0.1
# ── ECom(企微托管)────────────────────────────────────────────────────────────
# ⚠️ 平台级凭据,一套管所有托管账号。accessSecret 只在服务端,绝不下发浏览器。
AI_ECOM_BASE_URL=
AI_ECOM_ACCESS_ID=
AI_ECOM_ACCESS_SECRET=
# AI_ECOM_TIMEOUT_MS=20000
# ── 登录(JWT)─────────────────────────────────────────────────────────────────
# ⚠️ 必须是 **Ed25519** 私钥。生成:pnpm --filter @pac/ai-service gen:jwt-key
# 可以直接给 PEM(\n 转义),也可以给文件路径。⚠️ chmod 600。
# ⛔ 别用对称密钥:将来给宿主验签得共享私钥,那就白选 JWT 了([11 §6.4])。
AI_JWT_PRIVATE_KEY=
# 密钥标识,进令牌头。换密钥时改它 —— 轮换不用改协议。
AI_JWT_KID=k1
# 8 小时 = 一个工作日。撤销不靠过期(靠 token_version + 每请求查 is_active)。
# AI_JWT_TTL_SECONDS=28800
# ── 以下到对应里程碑再启用,现在配了也没人读 ──────────────────────────────────────
#
#
# M3 PAC 读通道
# AI_PAC_BASE_URL=
# AI_PAC_APP_ID=
# AI_PAC_APP_SECRET=
# ── ECom 回调入口的准入控制(生产必配至少一项)────────────────────
# ⚠️ 生产的入站正路是 ECom 直推本服务的 POST /api/channel/ecom/callback。
# 回调**没有验签**(供应商文档完全没提),所以这个端点是公网无鉴权可写的 ——
# 知道地址的人就能往医疗系统灌假患者消息,而那些消息会进 Agent 的推理上下文。
# ① IP 白名单(最有效)。实测 ECom 出口 43.139.77.71,逗号分隔。
AI_ECOM_CALLBACK_IPS=
# ② 路径 secret(供应商给不出固定出口 IP 时的兜底):
# 回调地址写成 …/api/channel/ecom/callback/<这个值>
# 生成:node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"
AI_ECOM_CALLBACK_SECRET=
# ③ 信任几层反向代理。⚠️ 默认 0 = 不信 X-Forwarded-For。
# ⛔ 别为了省事调大 —— 盲信 XFF 等于「填个 header 就能绕过 ① 」。
# 经 Caddy/Nginx 反代时填真实层数(通常 1)。
AI_TRUST_PROXY_HOPS=0
# ── 登录限流(有默认值,一般不用配)──────────────────────────────
# ⚠️ 为什么需要:/auth/login 是公开的,而每次请求都要付一次 scrypt
# (~360ms / 128MB,手机号不存在时也照付 —— 那是防时序侧信道的代价)。
# 账号锁定只挡「盯着一个号猜密码」,⛔ 挡不住「换手机号打」。
# 总容量只有 2 并发 × 60 秒 = 120 秒 scrypt/分钟。
# ⚠️ 阈值是按「一个 IP = 一家诊所(NAT 出口)」定的,不是按一个人。
# ⛔ 没有关闭开关 —— 要放宽就调大数字。
# ① 单 IP 每分钟。20 次 = 20 人的诊所早上集体登录够用,攻击者只拿到 6% 容量。
# AI_LOGIN_RATE_PER_IP=20
# ② 全局每分钟(挡分布式:换 IP 打时 ① 对每个 IP 都是一份新预算)。
# ⚠️ 这是**速率**。突发上限是另一个数,写死在代码里 = 队列深度 - 2 = 30,
# 保证「未经证实的流量一瞬间填不满 scrypt 的排队位」。
# ⭐ 它触发时,**近 7 天成功登录过的 IP 不受影响** —— 被打的时候
# 我们自己的门诊照常进得来,代价是没见过的新地方暂时登不了。
# AI_LOGIN_RATE_GLOBAL=120
# ③ 单 IP 同时在飞几个(共 32 个排队位)。令牌桶按分钟算,拦不住一瞬间的并发。
# ⚠️ 调小 → 同一家诊所几个人同一秒点登录会有人被挡(重试即可)。
# AI_LOGIN_INFLIGHT_PER_IP=6
# ── 回调中转(⚠️ 仅本地开发)──────────────────────────────────────
# 开发机没有公网地址,ECom 的回调给不出去,所以订阅公网中转的 SSE。
# ⛔ 生产不要配 —— 那边是 ECom 直推,同时开两条摄入路径说不清走的哪条。
# ⛔ 别把任何能力建在中转的接口上,它会下线。
AI_RELAY_URL=
AI_RELAY_TOKEN=
# ── 媒体转存(⏱️ 有时间压力)────────────────────────────────────────
# ECom 不替我们存资源,只给带 TTL 的下载凭据:个微 72 小时 / CDN 14 天。
# 过期之后患者发的那张牙齿照片就真的没了,任何接口都取不回来。
# ⚠️ 本地盘落点。**生产必须换对象存储** —— 起服务时会 WARN 提醒。
# 这里存的是患者的照片和病历文件:本地盘不做多副本、不异地、不在备份策略里。
AI_MEDIA_DIR=data/media
# 转哪几档。默认不转中图 —— 供应商建议「先下缩略图即可」,
# 我们真正需要的是 thumb(界面/Agent 上下文)+ full(临床证据),mid 是纯冗余。
AI_MEDIA_TIERS=thumb,full
# ⚠️⚠️ 下面两个是**限速**不是性能调优。
# 文档原话:「个微资源若频繁刷新获取,可能导致风控介入,请合理控制请求频次」。
# 实际速率 = 定时任务每分钟一次 × 每轮 BATCH 条 × 每条间隔 GAP_MS。调之前想清楚。
AI_MEDIA_BATCH=10
AI_MEDIA_GAP_MS=1500
# ── 出站附件(发图片 / 文件给患者)────────────────────────────────────────
# ⚠️⚠️ ECom 的上传/发送接口**只收 URL**(它自己去取),没有 base64 / multipart。
# 所以要发图必须给一个 ECom 够得着的地址。生产填**回调用的那个域名**即可
# ([03 D13]:生产由 ECom 直推 ai-service,那个地址本来就是公网的)。
# ⚠️ 本地开发没有公网地址 ⇒ 本地发图片会失败在「ECom 取不到那个 URL」,这是环境限制。
AI_PUBLIC_BASE_URL=
# 取件票有效期(秒)。10 分钟够 ECom 取一次,又短到丢了也没多大用
AI_ASSET_TTL_SECONDS=600
# 单个上传上限(字节)。默认 20MB
AI_MAX_UPLOAD_BYTES=20971520
dist/
.turbo/
tsconfig.tsbuildinfo
.env
# prisma client 生成物(generator.output 指到这里,见 prisma/schema.prisma)
generated/
# ⚠️ 媒体转存的本地落点 —— 里面是**患者的照片和病历文件**。
# 绝不能进版本库。(生产会换成对象存储,见 AI_MEDIA_DIR)
/data/
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"parser": { "syntax": "typescript", "decorators": true, "dynamicImport": true },
"transform": { "legacyDecorator": true, "decoratorMetadata": true },
"baseUrl": "./",
"paths": { "@/*": ["src/*"] },
"target": "es2022",
"keepClassNames": true
},
"module": { "type": "commonjs" },
"exclude": ["^node_modules/", "^dist/"]
}
[Unit]
Description=friday-ai 媒体备份
After=network-online.target
[Service]
Type=oneshot
# ⚠️ 按实际情况改这三行
WorkingDirectory=/opt/friday-ai/apps/ai-service
Environment=RESTIC_REPOSITORY=sftp:backup-host:/backup/friday-media
Environment=RESTIC_PASSWORD_FILE=/etc/friday-ai/restic.pass
ExecStart=/opt/friday-ai/apps/ai-service/deploy/backup-media.sh
# 备份读的是患者数据,收紧权限
UMask=0077
NoNewPrivileges=true
#!/bin/sh
# ═══════════════════════════════════════════════════════════════════════════
# 媒体备份 —— 本地盘唯一真正的短板
#
# 转存下来的是**患者的照片和病历文件**,而且**源已经过期了**
# (个微 72h / CDN 14d),盘坏了就是永久丢失,任何接口都取不回来。
# ⇒ 这不是"顺手加个备份",是这套存储方案唯一缺的那一块。
#
# 用 restic:加密(患者数据不能明文躺在备份盘上)、增量、去重、能校验。
#
# ⚠️ 跑之前必须先有两样东西,见下面 RESTIC_REPOSITORY / RESTIC_PASSWORD_FILE。
# ═══════════════════════════════════════════════════════════════════════════
set -eu
# ⚠️ 备份到哪。三种都行,选一个:
# sftp:backup-host:/backup/friday-media 另一台机器(推荐,异地)
# /mnt/backup/friday-media 另一块盘(挡盘坏,挡不住整机丢)
# s3:https://…/bucket 将来上了对象存储用这个
: "${RESTIC_REPOSITORY:?请先设 RESTIC_REPOSITORY —— 备份目的地}"
# ⚠️ 密码文件,权限 600。**丢了就解不开备份**,要单独存一份在别处。
: "${RESTIC_PASSWORD_FILE:?请先设 RESTIC_PASSWORD_FILE —— 加密口令文件}"
MEDIA_DIR="${AI_MEDIA_DIR:-/opt/friday-ai/apps/ai-service/data/media}"
[ -d "$MEDIA_DIR" ] || { echo "媒体目录不存在:$MEDIA_DIR"; exit 1; }
# 首次跑要先 init(已存在时 restic 会报错,这里吞掉)
restic snapshots >/dev/null 2>&1 || restic init
restic backup "$MEDIA_DIR" --tag friday-media --host "$(hostname)"
# ⚠️ 保留策略:媒体是**只增不改**的(内容寻址),所以快照数不用留很多。
# 但别用 --prune 之外的激进策略 —— 误删的代价是不可逆的。
restic forget --tag friday-media \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
# ⚠️⚠️ **必须校验**。没验过的备份不算备份 —— restic 自己的文档也这么说。
# --read-data-subset 每次抽 5% 真读一遍,一个月能把全量轮一遍。
restic check --read-data-subset=5%
# ⚠️ 用 --json 取,别去解析给人看的表格 —— 那个格式会随 restic 版本变
restic snapshots --tag friday-media --latest 1 --json 2>/dev/null \
| sed -n 's/.*"short_id":"\([^"]*\)".*/备份完成,快照 \1/p' | tail -1
[Unit]
Description=friday-ai 媒体备份(每天)
[Timer]
# ⚠️ 挑一个业务低谷。3:40 而不是整点 —— 整点上一堆定时任务会撞在一起。
OnCalendar=*-*-* 03:40:00
# ⚠️ 关机错过了要补跑。不补的话,一台每晚关机的机器等于从来没备份过。
Persistent=true
[Install]
WantedBy=timers.target
/**
* Jest 配置。测试目录 `tests/`(跟 src 平行,不混进 nest build 产物)。
*
* 配置照抄 pac-service,两条关键项连同它们的实测理由一起搬过来:
*
* 🔴 `isolatedModules: true` —— jest **不再做类型检查**,只转译。
* 默认 ts-jest 会在每个 worker 里各跑一个完整 TypeScript program,而 `@pac/types`
* 在下面被映射到**源码**,于是每个 worker 都要把整个 types 包连同 src 重新检查一遍。
* pac-service 实测(16 核):默认 27.8s / CPU 251s → 加这个 ~10s → 再加 maxWorkers 6.9s / CPU 43s。
* ⚠️⚠️ 代价:**类型错误不会再让 jest 变红**。类型安全只能靠单独那一趟
* `pnpm exec tsc --noEmit -p tsconfig.typecheck.json`(已在 package.json 的 type-check 里)。
* ⛔ 谁要去掉那一趟,必须先把这里改回来,否则两道闸同时没了。
* ⚠️ ts-jest 会提示「isolatedModules 已废弃,请写进 tsconfig」—— **别照做**:
* tsconfig.json 里它是 false(那份同时给 nest build / swc 用),ts-jest 读到 false
* 就退回全量类型检查,实测反而慢 3 倍。忍受那条 WARN。
*
* ⚠️ maxWorkers 50% —— 默认 `核数-1`,本地还并行跑着 nest --watch / next dev / docker,
* 整机没有余量。CI 上单独跑可以 `--maxWorkers=100%` 覆盖回来。
*/
module.exports = {
rootDir: '.',
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
moduleNameMapper: {
'^@pac/types$': '<rootDir>/../../packages/types/src/index.ts',
'^@pac/types/(.*)$': '<rootDir>/../../packages/types/src/$1',
},
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json', isolatedModules: true }],
},
maxWorkers: '50%',
};
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false
}
}
{
"name": "@pac/ai-service",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch --builder swc",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"lint": "eslint src",
"type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json",
"test": "jest --passWithNoTests",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"sync:sessions": "ts-node --transpile-only src/cli/sync-sessions.cli.ts",
"sync:sessions:prod": "node dist/cli/sync-sessions.cli.js",
"register:account": "ts-node --transpile-only src/cli/register-account.cli.ts",
"maintain": "ts-node --transpile-only src/cli/maintain.cli.ts",
"sync:contacts": "ts-node --transpile-only src/cli/sync-contacts.cli.ts",
"send:text": "ts-node --transpile-only src/cli/send-text.cli.ts",
"gen:jwt-key": "ts-node --transpile-only src/cli/gen-jwt-key.cli.ts",
"dev:token": "ts-node --transpile-only src/cli/dev-token.cli.ts",
"sync:rooms": "ts-node --transpile-only src/cli/sync-rooms.cli.ts",
"reconcile:contacts": "ts-node --transpile-only src/cli/reconcile-contacts.cli.ts",
"agent:appt": "ts-node --transpile-only src/cli/appointment-confirm.cli.ts",
"agent:appt-drill": "ts-node --transpile-only src/cli/appointment-drill.cli.ts",
"agent:context": "ts-node --transpile-only src/cli/agent-context.cli.ts",
"agent:advise": "ts-node --transpile-only src/cli/agent-advise.cli.ts",
"agent:reception-drill": "ts-node --transpile-only src/cli/reception-drill.cli.ts",
"seed:org": "ts-node --transpile-only src/cli/seed-org.cli.ts",
"pac:find": "ts-node --transpile-only src/cli/pac-find.cli.ts",
"ai:user": "ts-node --transpile-only src/cli/user-admin.cli.ts"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.48",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@pac/ecom-client": "workspace:*",
"@pac/pac-client": "workspace:*",
"@pac/types": "workspace:*",
"@pac/utils": "workspace:*",
"@prisma/client": "^6.19.2",
"@sentry/nestjs": "^10.66.0",
"ai": "^6.0.184",
"helmet": "^8.1.0",
"nestjs-zod": "^5.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"silk-wasm": "^3.7.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@swc/cli": "^0.7.8",
"@swc/core": "^1.13.7",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.2",
"jest": "^30.3.0",
"prisma": "^6.19.2",
"source-map-support": "^0.5.21",
"ts-jest": "^29.4.9",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
-- ═══════════════════════════════════════════════════════════════════════════
-- 20260825110000_auth —— 登录所需的最小改动
-- 依赖:0001_baseline
--
-- [12 §2.4](docs/12-基础层数据库设计.md) 把 token_version 列在「刻意不要」里,但写的是
-- 「**不是不要,是还没到时候** …… **做登录那天必须加** …… 它一上线就有消费方,不是投机,别忘了」。
-- 今天就是那天。
-- ═══════════════════════════════════════════════════════════════════════════
-- ⭐ JWT 签发后服务端**不知道有哪些令牌还活着**,无法枚举也无法撤销单个。
-- 「手机丢了 → 强制下线该用户全部令牌」只能靠这一列:+1 之后,
-- 令牌里的 ver 对不上就拒([11 §6.4](docs/11-架构定位与设计判据.md))。
-- ⚠️ ADD COLUMN … DEFAULT 0 在 PG 11+ 不重写表,零回填。
ALTER TABLE app.users ADD COLUMN token_version integer NOT NULL DEFAULT 0;
ALTER TABLE app.users ADD CONSTRAINT ck_users_token_version CHECK (token_version >= 0);
-- ⚠️ user_identities.provider 现在有了第一个真实取值 'wecom'。
-- 刻意**不加 CHECK** —— 身份提供方是会长的(将来有 host_sso / phone),
-- 而 [12 建表原则] 里「别人定的取值不能封闭」正是这一类。
COMMENT ON COLUMN app.user_identities.provider IS
'身份提供方。第一版:wecom(ECom 扫码)。将来:host_sso / phone。'
'⚠️ 刻意不加 CHECK —— 会增长的取值集合封闭了就得发版才能加。';
-- ═══════════════════════════════════════════════════════════════════════════
-- 20260825120000_relay_seq —— 中转事件序号
-- 依赖:0001_baseline
--
-- ⚠️ 为什么不塞进 sync_cursors:中转游标是**平台级**的(一条流里混着所有托管账号
-- 甚至所有租户的事件),而 sync_cursors 的主键是 (tenant_id, ...) 且 tenant_id
-- 带外键 —— 硬塞就得造一个哨兵租户,那是把"这个游标不属于任何租户"这件事藏起来。
--
-- ⭐ 改成**从数据本身推导**:游标 = 已落库事件里最大的 relay_seq。
-- 好处是它**不可能和现实漂移** —— 不存在"游标推进了但数据没落库"的中间态,
-- 而那正是 [12 §9.3] 列的头一种错法(先写游标再写数据 → 崩在中间数据永久丢失)。
-- ═══════════════════════════════════════════════════════════════════════════
-- 中转赋的单调序号(跨中转重启单调)。⚠️ 直推回调时为 NULL —— 那条路没有这个概念。
ALTER TABLE app.webhook_deliveries ADD COLUMN relay_seq bigint;
ALTER TABLE app.webhook_deliveries ADD CONSTRAINT ck_webhook_relay_seq CHECK (relay_seq IS NULL OR relay_seq > 0);
-- ⭐ 取 max(relay_seq) 的专用索引。
-- ⚠️ 必须是部分索引:直推来的行 relay_seq 全是 NULL,收进索引纯属浪费。
-- ⚠️ 分区表上建的是分区索引 —— 取 max 仍要扫每个分区,所以查询端**必须带 received_at 谓词**
-- 收窄到最近几天(见 RelaySubscriberService.readCursor)。
CREATE INDEX idx_webhook_relay_seq ON app.webhook_deliveries (relay_seq DESC)
WHERE relay_seq IS NOT NULL;
COMMENT ON COLUMN app.webhook_deliveries.relay_seq IS
'回调中转赋予的单调序号。订阅端断线重连时用 max(relay_seq) 当 ?since= 补齐漏掉的事件。'
'⚠️ 直推回调(ECom 直接打 ai-service)时为 NULL —— 那条路没有可续游标,'
'也正是不该走那条路的理由(ECom 回调重试 3 次就放弃、没有补拉接口)。';
-- ═══════════════════════════════════════════════════════════════════════════
-- 托管账号的生命周期状态
--
-- 背景:「企业wx必须**全程登录在线**,否则接口调用失败」(ECom「开发前必读」)。
-- 号一掉,出站发不了、通讯录同步不了、连回调都不再推 —— 整条通道静默死掉。
-- 在这之前 [11027] 退出登录 / [100008] 风控通知都被当"未知事件"扔进死信,
-- 也就是**号掉了没人知道**。这次把状态落到表上。
-- ═══════════════════════════════════════════════════════════════════════════
-- ⭐ 登录状态事件的递增版本。文档原话([11026]/[11027] 的 eventVersion):
-- 「用于解决登录、登出及跨设备顶号事件**乱序**」。
-- ⚠️ 没有它,一次顶号会出现「先收到新登录、后收到旧登出」→ 把刚上线的账号写成
-- offline,而且**再也不会有事件来纠正它**(ECom 不会重发)。
ALTER TABLE app.wecom_accounts ADD COLUMN event_version bigint;
COMMENT ON COLUMN app.wecom_accounts.event_version IS
'最后一次生效的登录状态事件版本;小于等于它的事件一律忽略(防乱序)';
-- 掉线原因。⚠️ 分两列是因为**单看哪一个都判不出严重性**:
-- 文档示例里 reasonType=1 同时是「手机上退出」和「被封号」,
-- code=-11004 同时是「手机上退出」和「新设备验证」。
ALTER TABLE app.wecom_accounts ADD COLUMN offline_code integer;
ALTER TABLE app.wecom_accounts ADD COLUMN offline_reason text;
COMMENT ON COLUMN app.wecom_accounts.offline_reason IS
'人话原因,来自 [11027].reasonTips/msg 或 [100008].spamInfo';
-- ⏱️ 本轮「重新登录 / 新设备验证」是什么时候发起的。
-- 新设备验证有硬时限:下线后 60 秒内必须发起 [11028]、120 秒内扫完码,
-- 否则「验证失败只能重新获取二维码登录,等下一次掉线」。
-- ⚠️ 这一列同时是**查二维码事件的时间下界** —— 不带它的话,
-- 按 device_id 查会翻出上一次登录成功的 [11026],前端立刻显示"已登录"。
ALTER TABLE app.wecom_accounts ADD COLUMN relogin_started_at timestamptz(3);
-- ⚠️ 'banned' 是**终态**,和 offline 有本质区别:扫多少次码都回不来,
-- 自动重扫只会一直失败。必须让状态本身能表达这件事,否则运维看到的
-- 永远是"又掉线了",不会去查为什么扫不上。
ALTER TABLE app.wecom_accounts DROP CONSTRAINT ck_wecom_accounts_status;
ALTER TABLE app.wecom_accounts ADD CONSTRAINT ck_wecom_accounts_status
CHECK (status IN ('online','offline','banned'));
-- 找"现在有账号掉着"用。⚠️ 部分索引:绝大多数时候账号是在线的,
-- 全表索引在这个场景上没有意义。
CREATE INDEX idx_wecom_accounts_down ON app.wecom_accounts (tenant_id, status)
WHERE status <> 'online';
-- ═══════════════════════════════════════════════════════════════════════════
-- 媒体下载凭据补两列
--
-- `[201001]下载CDN资源` 的请求里这两个都要用,而它们**只在收到消息那一刻拿得到**:
-- 消息报文过期不了(在 messages.raw 里),但把它们摊平成列才查得动、才好排队。
-- ═══════════════════════════════════════════════════════════════════════════
-- ⚠️⚠️ 个微资源下载**必须带 authKey**。文档原话:
-- 「收到个微好友发送的图片、视频、文件、语音消息类型时,进行文件下载时
-- 需要把消息中的 `authKey` 传过来,否则有可能会导致下载失败」。
-- ⇒ 不存这一列,所有**患者从微信发来的**图片都可能下不下来 ——
-- 而那恰恰是我们最需要的那一类(企微员工之间发的反而是 CDN 资源,不需要它)。
-- 实测企微 CDN 资源这一列是空串,所以可空。
ALTER TABLE app.message_media ADD COLUMN auth_key text;
COMMENT ON COLUMN app.message_media.auth_key IS
'个微资源下载必需(imunion 链接);企微 CDN 资源为空';
-- `[201001]` 把 fileName 列为**必填**。文档兜底规则:
-- 「如果下发没有文件名则自己生成…如图片下发没有文件名,则使用 {md5或aeskey}.jpg」
-- ⚠️ 实测这个名字会被供应商拼进它返回的 COS 路径里,所以**三档必须不同名**,
-- 否则原图/中图/缩略图在它桶里互相覆盖。自造的名字带了档位后缀。
ALTER TABLE app.message_media ADD COLUMN file_name text;
-- ⚠️ 转存队列的取数索引**不在这里建** —— baseline 里已经有了:
-- idx_mm_pending (expires_at) WHERE attachment_id IS NULL AND expires_at IS NOT NULL
-- (在这里重建会在全新库上报 "already exists",实测撞过)。
-- 它的谓词不含 download_error,所以标死的行还会留在索引里;
-- 量级极小(标死 = 源真的没了,是罕见事件),不值得为它重建索引。
-- ═══════════════════════════════════════════════════════════════════════════
-- 数据权限:把「归属」和「范围」拆开
--
-- 在这之前 `users.data_scope` 是 self|subtree|tenant,而**一处都没有被查询用到**
-- —— 会话列表 / 详情 / 发送 / 附件全都只按 tenant_id 过滤。也就是说
-- 「罗启 data_scope=self」在库里写着,实际能看全租户 61 条会话。
--
-- ═══ 为什么单列表达不了 ═══════════════════════════════════════════════════
--
-- [12 §4.2](docs/12-基础层数据库设计.md) 的 `subtree` 从 `users.org_unit_id`
-- 展开,而那**只有一列** ⇒ 只能表达"一棵子树"。
-- 真实需求是「海淀 + 广外,但不含同父的其他分院」—— 连锁牙科里区域经理
-- 管的几家店未必在树上连着,一个客服也可能同时接两家店。
--
-- [12] 第 299 行早就写下了触发条件:
-- > `scope_ref`(范围锚点)…**只有当归属 ≠ 范围时才需要**
-- > ("属于总部但只准看 A 店")| 出现归属与范围不一致的授权
-- 现在就是这个条件成立的时候。
--
-- ⇒ 归属留在 `users.org_unit_id`(在哪儿上班,1 个)
-- 范围搬到 `user_org_scopes`(能看哪儿,0..N 行)
-- ═══════════════════════════════════════════════════════════════════════════
-- ── ① 范围表 ──────────────────────────────────────────────────────────────
CREATE TABLE app.user_org_scopes (
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
org_unit_id uuid NOT NULL,
-- true = 这个节点**及其全部后代**;false = 只这一个节点。
-- ⭐ 绝大多数授权是 true("这个店长管这个店和它下面的"),所以默认 true。
include_subtree boolean NOT NULL DEFAULT true,
-- ⚠️ 授权留痕。这是**权限表** —— 出事时「谁在什么时候把这个范围给了他」
-- 必须能回答。刻意不建外键到 users:授权人离职删号了,这条记录还要留着。
granted_by uuid,
granted_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT pk_user_org_scopes PRIMARY KEY (user_id, org_unit_id),
-- ⚠️⚠️ 两个都必须是**复合**外键。单列 REFERENCES 挡不住「把 A 租户的用户
-- 授权到 B 租户的诊所」—— 而这是所有洞里最坏的一个:跨租户读患者数据。
-- org_units 自己的 parent_id 也是这个理由([12 §5])。
-- ⚠️ ON UPDATE CASCADE 是**对齐 Prisma 生成物**用的(全库其它外键都是它)——
-- id 是 uuid,永远不会被 UPDATE,所以语义上无所谓;但不写的话
-- `migrate diff --from-migrations --to-schema-datamodel` 会永久报差异,
-- 而那条命令是我们唯一的「迁移 ↔ schema 一致」自检,不能让它长期红着。
CONSTRAINT fk_uos_user FOREIGN KEY (tenant_id, user_id)
REFERENCES app.users (tenant_id, id) ON DELETE CASCADE ON UPDATE CASCADE,
-- ⚠️ org_unit 是 RESTRICT 而不是 CASCADE:还有人被授权的诊所**不能删**。
-- CASCADE 的话删一个诊所会静默地把某人的范围改小(或改没),
-- 表现是"他忽然看不到会话了",而且查不出原因。
CONSTRAINT fk_uos_org FOREIGN KEY (tenant_id, org_unit_id)
REFERENCES app.org_units (tenant_id, id) ON DELETE RESTRICT ON UPDATE CASCADE
);
COMMENT ON TABLE app.user_org_scopes IS
'数据权限的范围锚点(能看哪儿)。与 users.org_unit_id(归属,在哪儿上班)是两回事';
COMMENT ON COLUMN app.user_org_scopes.include_subtree IS
'true=该节点及全部后代;false=仅该节点';
-- ⚠️ 不建 (user_id) 索引 —— 主键的前导列就是它,再建一个是纯浪费。
-- 反查"这个诊所授权给了谁"要用 org_unit_id,那个不是前导列,得单独建。
CREATE INDEX idx_uos_org ON app.user_org_scopes (tenant_id, org_unit_id);
-- ── ② data_scope 收成两档 ─────────────────────────────────────────────────
--
-- ⚠️ 去掉 `self` 和 `subtree`,理由各不相同:
--
-- `subtree` → 降级成 `user_org_scopes` 里 include_subtree=true 的一行。
-- 保留它意味着范围有**两个来源**,「他为什么能看到这条」要查两个地方。
-- 医疗数据场景下这是必须能查的。而且它表达不了"减":总部的人
-- org_unit_id 是根节点,subtree 就是全部,没法收窄到"只准看 A 店"。
--
-- `self` → **它根本不是同一个轴**。subtree/tenant 是"组织范围",
-- self 是"按人归属",混在一个枚举里看起来像三档深度,其实不是。
-- 而且现在**落不了地**:conversations 表没有 owner/assignee 列
-- (只有 pending_mention_user_id,那是"@我"的瞬时标记,不是归属),
-- 唯一能落的判据是 wecom_accounts.user_id = me。
-- ⇒ 真需求(咨询师个人号,涉及提成,同事不该看)将来用
-- `user_account_scopes(user_id, account_id)` 表达 —— 因为号 A 和号 B
-- 在同一个诊所,org_unit 粒度根本区分不了。那时枚举不用动。
--
-- ⭐ 现在改是最便宜的时候:全库 2 个用户,`subtree` 零使用。
-- 先迁数据再换约束。self / subtree 都变成 scoped ——
-- ⚠️ 方向是**收紧**:它们原本就该比 tenant 窄,只是没实现。
-- 迁完这些人的 user_org_scopes 是空的 ⇒ 一条会话都看不到。
-- 这是**对的**(fail-closed),而且会立刻暴露"范围还没配",比静默放行好。
-- ⚠️⚠️ **顺序:先 DROP 旧约束,再 UPDATE,最后 ADD 新约束。**
-- 反过来写(先 UPDATE)会当场失败 —— 'scoped' 违反的是**旧** CHECK
-- (self|subtree|tenant),而那时它还挂着。踩过一次,别再换回去。
ALTER TABLE app.users DROP CONSTRAINT ck_users_scope;
UPDATE app.users SET data_scope = 'scoped' WHERE data_scope IN ('self', 'subtree');
-- 现在数据已经全是 tenant|scoped,新约束才加得上
ALTER TABLE app.users ADD CONSTRAINT ck_users_scope
CHECK (data_scope IN ('tenant', 'scoped'));
-- ⚠️ 默认值必须一起改,否则新建用户会写进一个违反 CHECK 的值 —— 插入直接失败。
ALTER TABLE app.users ALTER COLUMN data_scope SET DEFAULT 'scoped';
COMMENT ON COLUMN app.users.data_scope IS
'tenant=全租户(特权逃生口);scoped=一律查 user_org_scopes。默认最小权限 scoped';
-- ⚠️ 为什么还要保留一个显式的 `tenant`,而不是"授权到根节点+子树":
-- ① 单店客户 org_units 可能**一行都没有**([12]:NULL = 直属集团),没有根可授权
-- ② wecom_accounts.org_unit_id IS NULL 的号永远不落在任何 org 授权里
-- 这两种号只有 tenant 看得见 —— fail-closed:「忘了配 org_unit」的表现是
-- 客服看不到(吵但安全),不是看到了不该看的(安静但泄露)。
-- 掉线之后该干什么 —— rescan | verify | banned | intended
--
-- ⚠️ 之前只有 offline_reason(给人读的一句话),界面只能靠 `status='offline'` 一刀切,
-- 于是「我们自己主动退的」和「被封号了」和「⏱️60 秒内要扫完的新设备验证」
-- 在工作台上长得一模一样,都写「去重新扫码」。banned 那一条是**骗人**的。
--
-- ⚠️ 可空,且 `status='online'` 时必须由应用清空。约束里不强制这一条 ——
-- 在线且带着旧 action 只是显示误导,不是数据损坏,加个 CHECK 反而会让
-- 「先置 online 再清 action」这种两步写入直接失败。
ALTER TABLE app.wecom_accounts ADD COLUMN offline_action text;
-- ⚠️ 值域收在库里。应用层的 LogoutAction 是 TS 联合类型,编译期管得住我们自己,
-- 管不住 CLI / 手工 UPDATE / 将来别的写入方。
ALTER TABLE app.wecom_accounts
ADD CONSTRAINT ck_wecom_accounts_action
CHECK (offline_action IS NULL OR offline_action IN ('rescan', 'verify', 'banned', 'intended'));
-- 已有的 banned 行补上 action —— 它们是终态,界面必须立刻停止给「重新扫码」。
-- ⛔ 不给 offline 行补 'rescan':那是**猜**。没有事件记录的掉线原因就是不知道,
-- NULL 恰好表达这个,而前端对 NULL 的兜底本来就是"当普通掉线处理"。
UPDATE app.wecom_accounts SET offline_action = 'banned' WHERE status = 'banned';
-- 当前 device_id 是不是 ECom 新分配的([11028] 事件的 isNewDevice)
--
-- ⚠️ 为什么值得单独一列:它是「复用失败」的**唯一可观测信号**,而复用失败
-- 直接对应风控(「频繁使用新设备登录可能触发风控」doc-6619702)。
-- 在这之前我们收到了这个字段但从没读过,于是一次静默降级把号挪到了新设备上,
-- 紧接着就被要求做验证码登录 —— 事后完全无从追溯。
--
-- ⛔ 不给存量行填值:那是猜。NULL 恰好表示「那一轮没读这个字段」。
ALTER TABLE app.wecom_accounts ADD COLUMN device_is_new boolean;
-- 自建登录:手机号 + 密码
--
-- ⚠️ [11 §6.1] 原本把它定位成「只服务少数人」的兜底。现在它是主路,
-- 随之而来的登录审计 / 账号锁定这些等保要求也一并要落。
--
-- ⚠️ phone **全局唯一**,不是 (tenant_id, phone) —— 登录时人只输手机号、不输诊所,
-- 同一个号出现在两个租户就无法判定是谁。⭐ PG 的唯一索引把 NULL 视作互不相等,
-- 所以可空列直接加 UNIQUE 就是"填了的必须唯一",不需要 partial index。
ALTER TABLE app.users
ADD COLUMN phone text,
ADD COLUMN password_hash text,
ADD COLUMN password_set_at timestamptz(3),
ADD COLUMN must_change_password boolean NOT NULL DEFAULT false,
ADD COLUMN failed_attempts integer NOT NULL DEFAULT 0,
ADD COLUMN locked_until timestamptz(3);
CREATE UNIQUE INDEX users_phone_key ON app.users (phone);
-- ⚠️ E.164 收在库里。应用层会规范化,但**规范化函数改坏了不会报错**,
-- 而库里混进 `13800138000` / `+86-138…` 三种写法之后,登录就会时灵时不灵。
ALTER TABLE app.users
ADD CONSTRAINT ck_users_phone
CHECK (phone IS NULL OR phone ~ '^\+[1-9][0-9]{7,14}$');
ALTER TABLE app.users
ADD CONSTRAINT ck_users_failed_attempts CHECK (failed_attempts >= 0);
-- ⚠️ 双向约束:有哈希就必须有设置时刻,反之亦然。
-- 缺了任一边,"这个密码多久没换了"就答不出来 —— 而那是等保要问的。
ALTER TABLE app.users
ADD CONSTRAINT ck_users_password
CHECK ((password_hash IS NULL) = (password_set_at IS NULL));
-- 这个微信的主人,和被关联的患者是什么关系。
--
-- ⚠️ 它**不决定「消息记给谁」** —— 一个微信在一个唯一性范围内只对一位患者
-- (范围由 tenants.patient_dedup_kind 划,见 [11 §2.4])。它记的是
-- **说话的人是谁**:家长代述和患者自述,在病历口径上不是一回事。
--
-- ⛔ 可空,且**不给 DEFAULT 'self'**。默认值会让忘了传 relation 的调用点
-- 静默记成「本人」—— 那是在病历上撒谎,而且不报错。
-- NULL 的含义是明确的:**这条是加这一列之前建的,没人记过**。
ALTER TABLE app.contact_links ADD COLUMN relation text;
-- self 本人 / parent 家长 / child 子女 / spouse 配偶 / other 其他家属
ALTER TABLE app.contact_links ADD CONSTRAINT ck_cl_relation
CHECK (relation IS NULL OR relation IN ('self', 'parent', 'child', 'spouse', 'other'));
-- 每个人读到哪儿了。
--
-- ⚠️⚠️ 为什么是**每人一行**,不是在 conversations 上加一列 read_at:
-- 一条会话可以被不止一个人看到(数据范围里有它的都能看)。放在 conversations 上
-- 等于「谁先点开,红点对所有人一起消失」—— 另一个人永远不知道有新消息进来过。
--
-- ⚠️ 存**时刻**不存 seq:ECom 有两个互不相通的数字空间(serverId ≈3.1e6 / seq ≈8.1e6,
-- [13 §8.3] 实测),而 messages 本来就按 send_time 分区并排序。
-- 用时刻既省一次换算,也让"未读数"就是一句带分区裁剪的 count。
--
-- ⚠️ NULL / 无行 = **从没读过** ⇒ 该会话的入站消息全部算未读。这是诚实的:
-- 刚上线时确实一条都没读过。⛔ 别用 now() 做默认把历史一笔勾销——
-- 那会让上线前积压的患者消息**静默消失**在红点之外。
CREATE TABLE app.conversation_reads (
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
conversation_id uuid NOT NULL,
-- 读到这一刻为止(含)。⚠️ 用消息的 send_time 口径,不是"我点开的时刻"——
-- 点开的瞬间可能正好有消息在飞,用本地时钟会把它算成已读。
last_read_at timestamptz(3) NOT NULL,
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT pk_conv_reads PRIMARY KEY (user_id, conversation_id),
-- ⚠️ 复合外键带 tenant_id:少了它,A 租户的 user 能给 B 租户的会话写已读位点
CONSTRAINT fk_cr_conv FOREIGN KEY (tenant_id, conversation_id)
REFERENCES app.conversations (tenant_id, id) ON DELETE CASCADE,
CONSTRAINT fk_cr_user FOREIGN KEY (tenant_id, user_id)
REFERENCES app.users (tenant_id, id) ON DELETE CASCADE
);
-- 列表要「这一页的 30 条会话,我各自读到哪儿」⇒ 前导列是 user_id
CREATE INDEX idx_conv_reads_user ON app.conversation_reads (user_id, conversation_id);
-- 主动加好友的尝试记录 —— **闸门的状态 + 留痕**,一张表两个用途。
--
-- ⚠️⚠️ 为什么必须有这张表:主动加好友是**风控最敏感的动作**(批量加人 = 封号),
-- 而号一掉就是整条通道死掉。没有记录就没有配额、没有去重、也说不清"谁加的"。
--
-- ⚠️ **不存明文手机号**。去重只需要"是不是同一个号",展示只需要掩码。
-- ⇒ `phone_hash` 做判据、`phone_masked` 给人看。
-- ⚠️ sha256(11 位手机号) 是**可枚举的**(10^11),所以这不是加密,只是不主动摊开;
-- 真正的边界是这张表本来就在租户隔离里(和 wecom_contacts.remark_phones 同一档)。
CREATE TABLE app.friend_add_attempts (
id uuid PRIMARY KEY DEFAULT app.uuidv7(),
tenant_id uuid NOT NULL,
account_id uuid NOT NULL,
-- 搜到的对方 vid。⚠️ 可空:搜不到人时也要留一条(那也是一次调用,占配额)
target_vid text,
-- 去重判据。⚠️ 只认它,⛔ 别用 masked(掩码会撞:138****1234 有一万个)
phone_hash text NOT NULL,
phone_masked text NOT NULL,
-- sent(已发出邀请) | failed(ECom 拒了) | blocked(被我们自己的闸挡下)
status text NOT NULL,
-- 失败/被挡的原因。人要能看懂为什么没发出去
error text,
-- ⚠️ 一定要记是谁点的 —— 这是对外发起的动作
actor_user_id uuid NOT NULL,
created_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT ck_faa_status CHECK (status IN ('sent', 'failed', 'blocked')),
CONSTRAINT fk_faa_account FOREIGN KEY (tenant_id, account_id)
REFERENCES app.wecom_accounts (tenant_id, id) ON DELETE CASCADE,
CONSTRAINT fk_faa_actor FOREIGN KEY (tenant_id, actor_user_id)
REFERENCES app.users (tenant_id, id) ON DELETE CASCADE
);
-- 配额查询:「这个号今天发了几条」。⚠️ 前导列 account_id + 时间倒序
CREATE INDEX idx_faa_quota ON app.friend_add_attempts (account_id, created_at DESC);
-- 去重查询:「这个号 24 小时内加过这个手机号吗」
CREATE INDEX idx_faa_dedup ON app.friend_add_attempts (account_id, phone_hash, created_at DESC);
-- 「这一轮我处理完了」—— 给等待队列一个**不发消息**的出口。
--
-- ⚠️⚠️ 为什么非要有:`waiting_since` 只在**我方发出一条消息**时清
-- (inbound 置位 / outbound 清空)。而真实场景里大量会话是「看了,不用回」——
-- 推销、群里闲聊、患者回了句"好的"。那些会话会**永远挂在等待队列里**。
--
-- ⚠️ 而且我们刚加了未读之后,两个信号开始打架:实测有会话显示「等 4 天」
-- 而未读是 0(读过了,红计时器还在涨)。人要么学会无视那个红色
-- —— 那真正在等的那些也一起被无视了。
--
-- ⚠️ 语义是「**这一轮**处理完了」,不是「这个人不用管了」:
-- 患者再发一条,inbound 那条逻辑会把 `waiting_since` 重新置位。
-- ⇒ 这两列只是**留痕**(谁、什么时候),⛔ 不是一个"已关闭"的状态位。
ALTER TABLE app.conversations
ADD COLUMN handled_at timestamptz(3),
-- ⚠️ 一定要记是谁标的:否则说不清是"处理了"还是"嫌烦划掉了"
ADD COLUMN handled_by_user_id uuid;
ALTER TABLE app.conversations
ADD CONSTRAINT fk_conv_handled_by FOREIGN KEY (tenant_id, handled_by_user_id)
REFERENCES app.users (tenant_id, id);
-- ⚠️ 两列要么都有要么都没有 —— 只有时间没有人 = 留痕等于没留
ALTER TABLE app.conversations
ADD CONSTRAINT ck_conv_handled
CHECK ((handled_at IS NULL) = (handled_by_user_id IS NULL));
-- ═══════════════════════════════════════════════════════════════════════════
-- llm_calls 补两个用量维度
--
-- 为什么现在补:
-- · reasoning_tokens —— 实测 qwen3.8-max **默认开思考**,一次调用 217 个输出
-- token 里 214 个是思考(98.6%)。而 output_tokens 是把思考算进去的计费口径,
-- ⇒ 光看 output_tokens **说不出"思考占了多少"**,而那正是
-- 「要不要关思考」这个决定唯一的依据。
-- · cached_input_tokens —— DashScope 有隐式前缀缓存(实测阈值 4096、块 4096)。
-- 我们现在的稳定前缀 954 token,一点都命中不了;但工具变多会自然跨过去。
-- ⇒ 有这一列才量得出"什么时候真的开始命中了"。
--
-- ⚠️ llm_calls 是**按月分区**的。ALTER 父表会传播到所有分区,
-- 两列都可空,不需要重写数据。
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE app.llm_calls ADD COLUMN reasoning_tokens integer;
ALTER TABLE app.llm_calls ADD COLUMN cached_input_tokens integer;
COMMENT ON COLUMN app.llm_calls.reasoning_tokens IS
'思考消耗的 token。⚠️ 已包含在 output_tokens 里(计费口径),⛔ 别再加一遍';
COMMENT ON COLUMN app.llm_calls.cached_input_tokens IS
'命中前缀缓存的输入 token。⚠️ 已包含在 input_tokens 里,⛔ 别再加一遍';
-- 非负约束跟着扩 —— ⚠️ 分区表上 DROP/ADD CHECK 会传播到各分区
ALTER TABLE app.llm_calls DROP CONSTRAINT ck_llm_nonneg;
ALTER TABLE app.llm_calls ADD CONSTRAINT ck_llm_nonneg CHECK (
COALESCE(input_tokens, 0) >= 0 AND
COALESCE(output_tokens, 0) >= 0 AND
COALESCE(reasoning_tokens, 0) >= 0 AND
COALESCE(cached_input_tokens, 0) >= 0 AND
COALESCE(cost_micros, 0) >= 0 AND
COALESCE(latency_ms, 0) >= 0);
-- ⭐ 两条"包含关系"约束 —— 实测 217 = 3 文字 + 214 思考,缓存同理。
-- ⚠️ 它们挡的是**把两个数加起来当总量**那类错:一旦哪个 provider 的口径
-- 变成"另计",这个 CHECK 会当场炸,而不是让成本报表悄悄翻倍。
ALTER TABLE app.llm_calls ADD CONSTRAINT ck_llm_reasoning_within_output CHECK (
reasoning_tokens IS NULL OR output_tokens IS NULL OR reasoning_tokens <= output_tokens);
ALTER TABLE app.llm_calls ADD CONSTRAINT ck_llm_cached_within_input CHECK (
cached_input_tokens IS NULL OR input_tokens IS NULL OR cached_input_tokens <= input_tokens);
-- ⭐ 回填 jobs.title —— agent 执行的那些 job 一直是 NULL。
--
-- 原因:JobRunner 只在「人工挂起」那条路径传了 title(那里有 titleOf 带患者原话),
-- agent 那条忘了传,于是库里一列 read_context / send_appointment_reminder。
-- 而 [15 §6] 要审计能回答「它做了哪一步」—— 原始 job_type 不是给人看的,
-- 预约确认那一页要把执行过程摆给人看,更不行。
--
-- ⚠️ 代码侧已经修好(openJob 默认落 action.label),⇒ **新行不会再是 NULL**。
-- 这个迁移只补历史。
--
-- ⚠️⚠️ 按 (agent_code, job_type) 匹配,⛔ 不能只按 job_type:
-- parse_response 在两个场景里的 label 不一样
-- (预约确认叫「读患者回复」,通用接待叫「读患者消息」)。
-- 只按 job_type 回填的话,有一半的行会被写上另一个场景的说法。
--
-- ⛔ 这张对照表**不要**再复制到运行时代码里:回填之后 jobs.title 就是唯一真源,
-- 而 openJob 是它唯一的写入者。两处并存必然漂。
UPDATE app.jobs j
SET title = m.label
FROM app.tasks t,
(VALUES
('appointment-confirm', 'read_context', '读取预约信息'),
('appointment-confirm', 'send_appointment_reminder', '发送预约确认'),
('appointment-confirm', 'parse_response', '读患者回复'),
('appointment-confirm', 'send_appointment_ack', '回复患者'),
('appointment-confirm', 'propose_reschedule', '患者要求改期'),
('appointment-confirm', 'escalate_to_human', '转人工'),
('reception', 'parse_response', '读患者消息'),
('reception', 'send_reply', '回复患者'),
('reception', 'send_symptom_ack', '症状交接告知'),
('reception', 'escalate_to_human', '转人工')
) AS m(agent_code, job_type, label)
WHERE t.tenant_id = j.tenant_id
AND t.id = j.task_id
AND j.title IS NULL
AND t.agent_code = m.agent_code
AND j.job_type = m.job_type;
-- ⭐⭐ 显式人工接管 —— 「现在是谁在跟这位患者说话」
--
-- ═══ 为什么不新开一列/一张表 ═══════════════════════════════════════════════
--
-- 「AI 别插嘴」这件事**已经有真理源**了:`idx_tasks_holding`
-- (tenant_id, host_code, host_patient_id) WHERE status IN ('needs_human','human_working')
-- 出站闸门(GateService 第四道)和 Agent 上下文(CommonContextService.heldByHuman)
-- 都读它。再加一个 `conversations.taken_over_by` 就是**第二个真理源** ——
-- 两者必然漂,而漂掉的那一侧就是"界面说人接管了、AI 照样在说话"。
--
-- ⇒ 人主动接管 = 建一条 Task:
-- source_type='human' (⚠️ 不是 agent —— ck_tasks_agent 要求 agent 来源必须带
-- code+version,而这条压根不是哪个 Agent 判出来的)
-- trigger_source='manual'
-- task_kind='manual_takeover'
-- status='human_working' (§10.3 原文「人工处理中」—— ⛔ 不是 needs_human:
-- 人不是"被需要",他**已经在处理**了)
-- 交还 AI = 把它置 completed。闸门自己就开了,⛔ 不用改任何一处闸的代码。
--
-- ═══ ⚠️ 为什么必须有这个唯一索引 ═══════════════════════════════════════════
--
-- 现成的 `uq_tasks_active` 带着 `WHERE agent_code IS NOT NULL` —— 而人建的这条
-- **没有 agent_code**,所以它一条都拦不住:连点两下「人工接管」就是两行,
-- 交还时结掉一行、另一行还挂着 ⇒ AI 从此对这位患者永久失语,而界面显示"已交还"。
--
-- ⚠️ 维度是**患者**不是会话 —— 和 idx_tasks_holding 同一个维度。
-- 同一位患者的单聊和家属群是一件事:护士拿着他,AI 在哪儿都该闭嘴。
CREATE UNIQUE INDEX uq_tasks_takeover ON app.tasks (tenant_id, host_code, host_patient_id)
WHERE task_kind = 'manual_takeover' AND status NOT IN ('completed','closed');
-- ⭐⭐ 开户激活票 —— 管理员开号之后,本人凭它设手机号和密码。
--
-- ═══ 为什么需要一张表 ═══════════════════════════════════════════════════════
--
-- 上一版开户是**管理员生成临时密码、口头转达**。那条路有两个问题:
-- ① 管理员知道对方的密码(哪怕只是一瞬)
-- ② 转达出去的口令**不会过期** —— 那条微信记录一直躺在聊天里,
-- 而本人改没改密码没人知道
-- ⇒ 换成一次性票:管理员只转发一条链接,**票过期即废纸**,
-- 密码从头到尾只有本人知道。
--
-- ═══ ⚠️ 存哈希不存明文 ═══════════════════════════════════════════════════════
--
-- 和密码同一条纪律:这张票**就是**一次开户凭据,明文落库等于把它写在库里。
-- ⇒ 只存 sha256,校验时算一遍比对。⛔ 也不进日志、不进 audit 的 detail。
CREATE TABLE app.user_activations (
token_hash text PRIMARY KEY,
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
expires_at timestamptz(3) NOT NULL,
-- ⚠️ 用过就作废。⛔ 不删行 —— 「这张票什么时候被谁用掉的」是要查的
used_at timestamptz(3),
created_by uuid,
created_at timestamptz(3) NOT NULL DEFAULT now(),
-- ⚠️⚠️ 复合外键:单列挡不住「A 租户的票指向 B 租户的用户」
CONSTRAINT fk_ua_user FOREIGN KEY (tenant_id, user_id)
REFERENCES app.users (tenant_id, id) ON DELETE CASCADE
);
-- ⭐ 一个用户同时只应有一张**未用且未过期**的票。
-- ⚠️ 没有它的话,重发链接会留下一串都能用的票 —— 而"重发"恰恰是最常见的操作
-- (管理员发错人、对方没收到)。旧票必须先作废。
CREATE UNIQUE INDEX uq_ua_live ON app.user_activations (user_id)
WHERE used_at IS NULL;
-- 过期清理扫描用
CREATE INDEX idx_ua_expires ON app.user_activations (expires_at) WHERE used_at IS NULL;
COMMENT ON TABLE app.user_activations IS
'开户激活票(一次性)。token_hash=sha256(明文);明文只在生成那一刻出现一次,⛔ 不落库不进日志';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { SentryModule } from '@sentry/nestjs/setup';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { loadConfig } from './config/configuration';
import { PrismaModule } from './prisma/prisma.module';
import { RealtimeModule } from './common/realtime/realtime.module';
import { AuthModule } from './modules/auth/auth.module';
import { MediaModule } from './modules/media/media.module';
import { PacModule } from './modules/pac/pac.module';
import { ChannelModule } from './modules/channel/channel.module';
import { OutboundModule } from './modules/outbound/outbound.module';
import { OutboxModule } from './modules/outbox/outbox.module';
import { WorkbenchModule } from './modules/workbench/workbench.module';
import { SchedulingModule } from './modules/scheduling/scheduling.module';
import { AgentModule } from './modules/agent/agent.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { TenantScopeInterceptor } from './common/interceptors/tenant-scope.interceptor';
import { WrapResponseInterceptor } from './common/interceptors/wrap-response.interceptor';
import { HealthController } from './health.controller';
@Module({
imports: [
// Sentry 注册在最前(官方要求),配合 main.ts 首行的 ./instrument 提供请求级 tracing
SentryModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
load: [loadConfig],
// ⚠️ 刻意**不配 validationSchema**。pac-service 同时有 joi schema 和 loadConfig 里的
// required(),两套校验并存 → 加一个键要改两处,漏一处就出现"schema 说可选、代码却抛"。
// 这里只留 required() 一处:它离取值点最近,报错文案能直接指到 .env.example。
}),
ScheduleModule.forRoot(),
PrismaModule,
// ⚠️ @Global():摄入侧发布、工作台订阅,必须是同一个实例
RealtimeModule,
AuthModule,
ChannelModule,
MediaModule,
PacModule,
OutboundModule,
OutboxModule,
SchedulingModule,
AgentModule,
WorkbenchModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },
// ⚠️ 全局登录守卫。@Public() 的端点(两个探针 + 登录三件套)跳过。
// ⛔ 加新的公开端点时别忘了 @Public() —— 探针拿 401 会让 systemd 无限重启。
{ provide: APP_GUARD, useClass: JwtAuthGuard },
// 响应腿上的顺序(Nest 对拦截器是**倒序**执行的):
// handler → ZodSerializer(校验内层形状)→ Wrap(套信封)→ 出站
// ⇒ 这里必须把 Wrap 注册在 Zod **之前**。写反了就是"先套信封再校验",
// 于是 Zod 拿到的是 { code, msg, data },永远校验不过。
// ⚠️ 租户上下文必须用**拦截器**铺,不能在守卫里铺 ——
// AsyncLocalStorage 的上下文在守卫返回时就结束了(见 TenantScopeInterceptor 的说明)。
{ provide: APP_INTERCEPTOR, useClass: TenantScopeInterceptor },
{ provide: APP_INTERCEPTOR, useClass: WrapResponseInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
],
})
export class AppModule {}
/**
* 拿一条真会话 + 一句话去问模型,把**完整往返**打出来。
*
* ⚠️ 它跑的是**生产同一条路**(`ReceptionService.handle`):规则先判,没命中才问模型,
* 然后落 `llm_calls`、按结果建 Task / 存草稿。⛔ 不是一个"只调模型"的玩具。
*
* ⚠️ **会真的花钱**(一次 qwen3.8-max 调用),也会真的建 Task / 写草稿。
* ⛔ 但**不会发消息** —— 这个场景根本没有发消息的动作。
*
* 用法:pnpm --filter @pac/ai-service agent:advise "患者说的话"
*/
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ReceptionService } from '../modules/agent/scenes/reception/reception.service';
async function main() {
const text = process.argv.slice(2).join(' ');
if (!text) { console.error('用法:agent:advise "患者说的话"'); process.exit(1); }
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
const prisma = app.get(PrismaService);
const conv = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>`
SELECT c.id, c.tenant_id FROM app.conversations c
JOIN app.contact_links l
ON l.tenant_id = c.tenant_id AND l.account_id = c.account_id AND l.peer_vid = c.peer_vid
WHERE l.is_active AND c.kind = 'single' LIMIT 1`;
if (!conv[0]) { console.error('没有「认准了患者的单聊」'); process.exit(1); }
console.log(`会话 ${conv[0].id}\n患者说:「${text}」\n`);
const r = await app.get(ReceptionService).handle({
tenantId: conv[0].tenant_id, conversationId: conv[0].id, text,
});
console.log('结果:', JSON.stringify(r, null, 2));
const calls = await prisma.$queryRaw<Record<string, unknown>[]>`
SELECT model, prompt_version, input_tokens, output_tokens, finish_reason,
guard_verdict, guard_reason, latency_ms, output_text, context_refs
FROM app.llm_calls WHERE conversation_id = ${conv[0].id}::uuid
ORDER BY created_at DESC LIMIT 1`;
console.log('\nllm_calls 最新一条:', JSON.stringify(calls[0] ?? null, null, 2));
const draft = await prisma.$queryRaw<{ ai_draft: string | null; ai_draft_at: Date | null }[]>`
SELECT ai_draft, ai_draft_at FROM app.conversations WHERE id = ${conv[0].id}::uuid`;
console.log('\n会话上的草稿:', JSON.stringify(draft[0], null, 2));
await app.close();
}
main().catch((e) => { console.error('失败:', e?.message ?? e); process.exit(1); });
/**
* 打印一条会话的**公共层上下文** —— 也就是将来会喂给模型的那一段。
*
* ⚠️ 它存在的理由是**让上下文装配可验证**:接模型之前,人要能一眼看出
* "喂进去的会是什么"。⛔ 别等到有模型了再回头查这一层 ——
* 那时候错的表现是"模型胡说",而根因在这里。
*
* 用法:pnpm --filter @pac/ai-service agent:context <conversationId>
*/
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ReceptionService } from '../modules/agent/scenes/reception/reception.service';
async function main() {
const convId = process.argv[2];
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const prisma = app.get(PrismaService);
const conv = convId
? await prisma.conversation.findFirst({ where: { id: convId }, select: { id: true, tenantId: true } })
: await prisma.conversation.findFirst({
orderBy: { lastMsgAt: 'desc' },
select: { id: true, tenantId: true },
});
if (!conv) {
console.error(convId ? `会话 ${convId} 不存在` : '一条会话都没有');
process.exit(1);
}
const text = await app.get(ReceptionService).preview(conv.tenantId, conv.id);
console.log(`── 会话 ${conv.id} 的公共层上下文 ──\n`);
console.log(text ?? '(装配不出来)');
await app.close();
}
main().catch((e) => { console.error('失败:', e?.message ?? e); process.exit(1); });
/**
* 手动跑一次预约确认扫描。
*
* ⚠️ 和定时任务跑的是**同一段逻辑**([05 工程约定]:CLI 手动跑同一段代码,
* 不要为手动路径另写一份 —— 两份会漂移,而漂移的那次一定是出事那次)。
*
* ⚠️ 定时器本身受 `AI_SCHEDULER=off` 控制,而这个 CLI **绕过定时器直接调 service** ——
* 所以关了定时器也能手动跑。但闸门一道都没绕:出站仍然在投递时过闸。
*
* 用法:
* pnpm --filter @pac/ai-service agent:appt # 扫描:建 Task + 发确认
* pnpm --filter @pac/ai-service agent:appt 2026-09-01T02:00:00Z # 指定"现在"
* ↑ 把"现在"往前挪,就能让一条更远的预约落进 24h 窗口,⛔ 不用去改 PAC 的数据。
* pnpm --filter @pac/ai-service agent:appt sweep # 收尾:回读 PAC 定结局
* pnpm --filter @pac/ai-service agent:appt sweep 2026-09-03T00:00:00Z
*/
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { AppointmentConfirmService } from '../modules/agent/scenes/appointment-confirm/appointment-confirm.service';
async function main() {
const sweep = process.argv[2] === 'sweep';
const arg = sweep ? process.argv[3] : process.argv[2];
const now = arg ? new Date(arg) : new Date();
if (Number.isNaN(now.getTime())) {
console.error(`时间解析不了:${arg}`);
process.exit(1);
}
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
const svc = app.get(AppointmentConfirmService);
if (sweep) {
console.log(`以 ${now.toISOString()} 为"现在",收 due_at 早于 24 小时前的 Task\n`);
console.log(JSON.stringify(await svc.sweepDue(now), null, 2));
} else {
console.log(`以 ${now.toISOString()} 为"现在",窗口 = 未来 24 小时\n`);
console.log(JSON.stringify(await svc.scan(now), null, 2));
}
await app.close();
}
main().catch((e) => {
console.error('失败:', e?.message ?? e);
process.exit(1);
});
/**
* 预约确认**全流程演练** —— 五条出路各跑一遍,每轮都从干净状态开始。
*
* ⚠️ 这是**联调工具不是单元测试**:它真的会建 Task、真的会往 outbox 排消息。
* 跑完自己收尾(Task 关掉、outbox 标 dead),但它**依赖 PAC 里有一条未来预约** ——
* 本地那条是手工插的测试事实 `appointment_record:TEST-FRIDAY-AGENT-1`。
*
* ⚠️ 出站一条都发不出去(租户闸 `ai_auto_send` 默认关),这是刻意的:
* 演练不该给真人发微信。⛔ 别为了"看看效果"去开那个闸。
*
* 用法:pnpm --filter @pac/ai-service agent:appt-drill
*/
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { AppointmentConfirmService } from '../modules/agent/scenes/appointment-confirm/appointment-confirm.service';
/** 每一轮:患者说这句话 → 期望 Task 落到这个状态 */
const DRILLS: { say: string; expectIntent: string; expectTaskStatus: string; note: string }[] = [
{ say: '好的', expectIntent: 'confirmed', expectTaskStatus: 'agent_done', note: '确认 ⇒ ⭐ agent_done 而不是 completed' },
{ say: '明天有事,能改到后天吗', expectIntent: 'reschedule', expectTaskStatus: 'needs_human', note: '改期 ⇒ 人来查号源' },
{ say: '不来了,取消吧', expectIntent: 'cancel', expectTaskStatus: 'needs_human', note: '取消是业务决定' },
{ say: '好的,不过我这两天牙有点疼', expectIntent: 'medical', expectTaskStatus: 'needs_human', note: '⚠️ 医疗压过确认' },
{ say: '你们停车方便吗', expectIntent: 'unknown', expectTaskStatus: 'needs_human', note: '读不懂 ⇒ 转人工' },
];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const prisma = app.get(PrismaService);
const scene = app.get(AppointmentConfirmService);
let pass = 0;
const fails: string[] = [];
for (const d of DRILLS) {
// ── 归零:把上一轮的 Task 收掉,让 uq_tasks_active 放行 ──
await prisma.$executeRaw`
UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='演练归零', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
const scan = await scene.scan();
if (scan.created !== 1) {
fails.push(`「${d.say}」建 Task 失败:${JSON.stringify(scan)}`);
continue;
}
const t0 = await prisma.$queryRaw<{ id: string; status: string; conversation_id: string; tenant_id: string }[]>`
SELECT id, status, conversation_id, tenant_id FROM app.tasks
WHERE status='awaiting_patient' ORDER BY created_at DESC LIMIT 1`;
if (!t0[0]) {
fails.push(`「${d.say}」建完 Task 不是 awaiting_patient`);
continue;
}
const r = await scene.handleReply({
tenantId: t0[0].tenant_id,
conversationId: t0[0].conversation_id,
text: d.say,
});
const t1 = await prisma.$queryRaw<{ status: string }[]>`
SELECT status FROM app.tasks WHERE id = ${t0[0].id}::uuid`;
const jobs = await prisma.$queryRaw<{ seq: number; job_type: string; status: string; escalation_reason: string | null }[]>`
SELECT seq, job_type, status, escalation_reason FROM app.jobs
WHERE task_id = ${t0[0].id}::uuid ORDER BY seq`;
const gotIntent = r.handled ? r.intent : `未处理(${r.why})`;
const gotStatus = t1[0]?.status ?? '?';
const ok = gotIntent === d.expectIntent && gotStatus === d.expectTaskStatus;
if (ok) pass += 1;
else fails.push(`「${d.say}」期望 ${d.expectIntent}/${d.expectTaskStatus},实得 ${gotIntent}/${gotStatus}`);
console.log(`${ok ? '✅' : '❌'}${d.say}」`);
console.log(` ${d.note}`);
console.log(` 判为 ${gotIntent} → Task ${gotStatus}`);
console.log(
` Job: ${jobs.map((j) => `${j.seq}.${j.job_type}(${j.status}${j.escalation_reason ? `/${j.escalation_reason}` : ''})`).join(' ')}\n`,
);
}
// ── 收尾:别把演练残留留在工作台上,也别留一条能发出去的消息 ──
await prisma.$executeRaw`
UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='演练残留', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
await prisma.$executeRaw`
UPDATE app.outbox SET status='dead', last_error='【演练残留】⛔ 不许真发'
WHERE task_type IN ('outbound.ecom.text') AND status <> 'dead'`;
console.log(`\n${pass}/${DRILLS.length} 通过`);
if (fails.length) {
console.log('失败:');
for (const f of fails) console.log(' · ' + f);
}
await app.close();
process.exit(fails.length ? 1 : 0);
}
main().catch((e) => {
console.error('失败:', e?.message ?? e);
process.exit(1);
});
import { BizError } from '../common/errors/biz-error';
/**
* 把异常翻成一行人能看懂的话,给 CLI 的顶层 catch 用。
*
* ⚠️⚠️ **为什么需要它**:`BizError` 继承 `HttpException`,而它传给 `super` 的
* 是一个**对象** `{code,msg,details}`。Nest 在 response 不是字符串时把
* `Error.message` 填成字面量 `'Biz Error'` ——
* 于是所有 CLI 里那句 `console.error(e instanceof Error ? e.message : e)`
* 打出来永远是「Biz Error」三个字,**真正的原因一个字都看不到**。
*
* 踩过:查越权拦截时只看到 `Biz Error`,完全不知道是"节点不存在"
* 还是"没配 PAC ref"。
*
* ⚠️ 其余 CLI(sync:contacts / send:text / register:account …)目前还是老写法,
* 抛 BizError 时同样会打「Biz Error」。要么逐个换成这里,要么别在 CLI 里抛 BizError。
*/
export function describeCliError(e: unknown): string {
if (e instanceof BizError) {
const d = e.details ? ` details=${JSON.stringify(e.details)}` : '';
return `[${e.code}] ${e.msg}${d}`;
}
if (e instanceof Error) return e.message;
return String(e);
}
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '../modules/auth/jwt.service';
/**
* 给一个已存在的用户签一个令牌 —— **仅用于本地开发与联调**。
*
* 用法:pnpm --filter @pac/ai-service dev:token -- --user=<id>
* pnpm --filter @pac/ai-service dev:token -- --create='王护长' # 顺手建一个
*
* ⚠️⚠️ 刻意做成 **CLI 而不是 HTTP 端点**。端点的话就是一扇「不用扫码就能登录」的门 ——
* 哪怕加了 env 开关,那个开关也会在某台机器上被打开然后忘掉。
* CLI 需要 shell 权限,而有 shell 权限的人本来就能直接读库。
* ⚠️ 生产环境不该有人跑它。真要给人临时访问,正确做法是走扫码。
*/
async function main() {
const arg = (k: string) => process.argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3);
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
const prisma = app.get(PrismaService);
const jwt = app.get(JwtService);
try {
let userId = arg('user');
const create = arg('create');
if (!userId && create) {
const tenant = await prisma.tenant.findFirstOrThrow({ select: { id: true, name: true } });
const u = await prisma.user.create({
data: { tenantId: tenant.id, displayName: create, role: 'owner', dataScope: 'tenant' },
select: { id: true },
});
userId = u.id;
console.error(`已建用户 ${create} (${userId}) 于租户 ${tenant.name}`);
}
if (!userId) throw new Error('需要 --user=<id> 或 --create=<显示名>');
const u = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { id: true, displayName: true, tokenVersion: true, isActive: true },
});
if (!u.isActive) throw new Error('该用户已停用');
const { token, expiresAt } = jwt.issue(u.id, u.tokenVersion);
console.error(`用户 ${u.displayName} · ver=${u.tokenVersion} · 到期 ${expiresAt.toISOString()}`);
console.log(token);
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});
import { generateKeyPairSync } from 'node:crypto';
/**
* 生成一对 Ed25519 密钥。
*
* 用法:pnpm --filter @pac/ai-service gen:jwt-key
*
* ⚠️ 私钥进 `.env` 的 `AI_JWT_PRIVATE_KEY`(或存成文件给路径),**chmod 600**。
* ⚠️ 公钥可以公开 —— 将来给宿主验签用,这正是选非对称的理由。
* ⚠️ 换密钥时把 `AI_JWT_KID` 也改掉:kid 从第一天就带在令牌头里,
* 轮换时不用改协议,零成本预留。
*/
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const priv = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
const pub = publicKey.export({ type: 'spki', format: 'pem' }).toString();
console.log('# ── 私钥:写进 apps/ai-service/.env,chmod 600 ──');
console.log(`AI_JWT_PRIVATE_KEY="${priv.trimEnd().replace(/\n/g, '\\n')}"`);
console.log(`AI_JWT_KID=k${Math.floor(Date.now() / 1000)}`);
console.log('\n# ── 公钥:可公开,给验签方 ──');
console.log(pub.trimEnd());
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PartitionService } from '../modules/scheduling/partition.service';
import { ScheduledRunService } from '../modules/scheduling/scheduled-run.service';
/**
* 手动跑一次分区维护。
*
* 用法:pnpm --filter @pac/ai-service maintain
*
* ⚠️ 它和后台定时任务**抢同一把锁**(`partitions.maintain`)—— 后台正在跑时这里会
* 直接退出并说明原因,而不是两边同时 CREATE TABLE 撞 42P07。
* ⚠️ 想让 CLI 独占,起进程时加 `AI_SCHEDULER=off AI_OUTBOX_WORKER=off`。
*/
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
const runs = app.get(ScheduledRunService);
const partitions = app.get(PartitionService);
const log = new Logger('maintain');
try {
const r = await runs.withLock('partitions.maintain', async () => {
const created = await partitions.ensureFuturePartitions();
const dirty = await partitions.inspectDefaultPartitions();
const dropped = await partitions.dropExpiredPartitions();
return { created: created.created, defaultDirty: dirty, dropped: dropped.dropped, dropEnabled: dropped.enabled };
});
if (!r.ran) {
log.warn('没抢到锁 —— 后台定时任务正在跑同一个,本次跳过(这不是错误)');
return;
}
const d = r.result;
log.log(`预建分区 ${d.created.length}${d.created.length ? ':' + d.created.join(', ') : ''}`);
log.log(d.defaultDirty.length ? `🔴 DEFAULT 分区非空:${JSON.stringify(d.defaultDirty)}` : 'DEFAULT 分区都是空的 ✓');
log.log(d.dropEnabled ? `删除过期分区 ${d.dropped.length} 个` : '过期分区删除:未启用(AI_PARTITION_DROP≠on)');
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
import 'reflect-metadata';
import { describeCliError } from './cli-error';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ScopeService } from '../modules/scope/scope.service';
import { PacService } from '../modules/pac/pac.service';
import type { AuthUser } from '../common/guards/jwt-auth.guard';
/**
* 以**某个用户的身份**查 PAC 患者 —— 联调 / 排查用。
*
* ```
* pnpm --filter @pac/ai-service pac:find -- --user=罗启 --q=张伟
* pnpm --filter @pac/ai-service pac:find -- --user=罗启 # 不给 q = 翻名册
* pnpm --filter @pac/ai-service pac:find -- --user=罗启 --ids=<uuid>,<uuid>
* pnpm --filter @pac/ai-service pac:find -- --user=王护长 --clinic=<org_unit_id> --q=张
* ```
*
* ⚠️ 刻意做成**带身份**的 CLI,而不是"直接调 PAC 看看通不通" ——
* 要验的不只是"连得上",而是**范围算得对**:同一个查询换个用户/换家诊所,
* 结果必须跟着变。不带身份的连通性测试会让越权问题完全测不出来。
*/
async function main() {
const arg = (k: string) => process.argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3);
const who = arg('user');
if (!who) throw new Error('需要 --user=<用户id 或 显示名>');
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
const prisma = app.get(PrismaService);
const scopeSvc = app.get(ScopeService);
const pac = app.get(PacService);
try {
const users = await prisma.user.findMany({
where: { OR: [{ id: isUuid(who) ? who : undefined }, { displayName: who }] },
select: { id: true, tenantId: true, displayName: true, role: true, dataScope: true, orgUnitId: true },
});
if (users.length !== 1) throw new Error(`"${who}" 匹配到 ${users.length} 个用户`);
const u = users[0]!;
const user: AuthUser = {
id: u.id, tenantId: u.tenantId, displayName: u.displayName,
role: u.role, dataScope: u.dataScope, orgUnitId: u.orgUnitId,
// ⚠️ CLI 不走登录,"要不要改初始密码"和它无关 —— 恒 false。
// ⛔ 别为了省事把 AuthUser 里这个字段改成可选:它在守卫里是**拦截判据**,
// 可选就意味着漏写等于放行。
mustChangePassword: false,
};
// ⚠️ 两条路**不能混**:
// `--clinic=<org_unit_id>` → `pacScope()`:任意组织节点,带越权校验。
// 不给 → `pacScopeForClinic()`:走切换器的默认那家。
// ⛔ 早先我把 org_unit_id 塞进 pacScopeForClinic,结果全被静默回退到默认诊所 ——
// 因为切换器**只列有托管号的诊所**,别的 id 一律 fallback。
// 那让整个越权测试变成恒绿。
const explicit = arg('clinic');
const clinicId = explicit ?? (await scopeSvc.clinicOptions(user))[0]?.id ?? null;
const scope = explicit
? await scopeSvc.pacScope(user, explicit)
: await scopeSvc.pacScopeForClinic(user, clinicId);
console.log(`身份 ${u.displayName} [${u.role}/${u.dataScope}]`);
console.log(`当前诊所 ${scope.fromOrgUnit.name} [${scope.fromOrgUnit.kind}]`);
if (scope.widened) {
console.log(`⚠️ 你选的节点没有 PAC ref,范围上借到了「${scope.fromOrgUnit.name}」—— 实际看到的比那个节点大`);
}
console.log(`PAC 范围 tenant=${scope.tenantId} orgScope=${JSON.stringify(scope.orgScope)}`);
console.log('');
const ids = arg('ids');
if (ids) {
const list = ids.split(',').map((s) => s.trim()).filter(Boolean);
const got = await pac.getPatients(user, clinicId, list, explicit ?? undefined);
console.log(`按 id 取 ${list.length} 个 → 命中 ${got.size} 个`);
for (const id of list) {
const p = got.get(id);
// ⚠️ 缺席是正常的(归档 / 不在范围内),显式打出来而不是跳过
console.log(p ? ` ✓ ${fmt(p)}` : ` ✗ ${id.slice(0, 8)}… 不在范围内或不存在`);
}
return;
}
const q = arg('q');
const r = await pac.searchPatients(user, clinicId, { q, limit: Number(arg('limit') ?? 10) }, explicit ?? undefined);
console.log(`${q ? `检索「${q}」` : '翻名册'}${r.items.length}${r.nextCursor ? '(还有下一页)' : ''}`);
for (const p of r.items) console.log(` ${fmt(p)}`);
if (r.items.some((p) => !p.phoneVerified)) {
console.log('');
console.log('⚠️ 带「假号」标记的,手机号是造数的 —— ⛔ 不能用来自动认人,只能人工确认。');
}
} finally {
await app.close();
}
}
function fmt(p: {
id: string; externalId: string; name: string | null;
phoneMasked: string | null; phoneVerified: boolean; gender: string | null;
birthDate: string | null; status: string;
}): string {
return [
(p.name ?? '(无名)').padEnd(8),
`病历号 ${p.externalId}`.padEnd(16),
`${p.phoneMasked ?? '无号'}${p.phoneVerified ? '' : '(假号)'}`.padEnd(18),
p.gender ?? '-',
p.birthDate ?? '-',
p.status === 'active' ? '' : `[${p.status}]`,
p.id,
].join(' ');
}
const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
main().catch((e) => {
// ⚠️ 用 describeCliError 而不是 e.message —— BizError 的 message 恒为 'Biz Error'
console.error(describeCliError(e));
process.exit(1);
});
/**
* 通用会话接待**演练** —— 医疗信号兜底的四种情况各跑一遍。
*
* ⚠️ 联调工具不是单元测试:它真的会建 Task / Job。跑完自己收尾。
* ⚠️ 这个场景**一条消息都不发**(见 `RECEPTION_ACTIONS`),所以没有出站残留。
*
* 用法:pnpm --filter @pac/ai-service agent:reception-drill
*/
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ReceptionService } from '../modules/agent/scenes/reception/reception.service';
interface Step {
say: string;
/** 期望:建了新 Task / 复用 / 完全没动作 */
expect: 'created' | 'reused' | 'skipped';
/** 期望这一步之后,这个 Task 下总共几条 Job */
jobs: number;
note: string;
}
const STEPS: Step[] = [
{ say: '牙龈有点出血,正常吗', expect: 'created', jobs: 2, note: '急症信号 → 建 Task + 留痕 + 转人工' },
{ say: '现在有点止不住了', expect: 'reused', jobs: 3, note: '⭐ 更严重的话必须留痕(+1 条 parse_response);待办不重复挂' },
{ say: '你们几点下班', expect: 'skipped', jobs: 3, note: '无信号 → 什么都不做([15 §1.2])' },
{ say: '消炎药还要吃几天', expect: 'reused', jobs: 4, note: '用药信号 → 同一个 Task 再留一条痕' },
];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const prisma = app.get(PrismaService);
const scene = app.get(ReceptionService);
// ── 归零 ──
await prisma.$executeRaw`UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='接待演练归零', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
const conv = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>`
SELECT c.id, c.tenant_id FROM app.conversations c
JOIN app.contact_links l
ON l.tenant_id = c.tenant_id AND l.account_id = c.account_id AND l.peer_vid = c.peer_vid
WHERE l.is_active AND c.kind = 'single'
LIMIT 1`;
if (!conv[0]) {
console.error('没有「认准了患者的单聊」—— 接待这条线没有可演练的对象');
await app.close();
process.exit(1);
}
let taskId: string | null = null;
const fails: string[] = [];
for (const s of STEPS) {
const r = await scene.handle({
tenantId: conv[0].tenant_id,
conversationId: conv[0].id,
text: s.say,
});
// ⚠️ 这份演练只覆盖**规则**那一层(医疗信号)。模型那一层由 agent:advise 单独试 ——
// 混在一起的话,模型一变(温度/版本)这份用例就红,而它测的本来是规则。
const got: Step['expect'] =
!r.handled || r.via !== 'rule' ? 'skipped' : r.created ? 'created' : 'reused';
if (r.handled && r.via === 'rule') taskId = r.taskId;
const jobs = taskId
? await prisma.$queryRaw<{ seq: number; job_type: string; status: string }[]>`
SELECT seq, job_type, status FROM app.jobs WHERE task_id = ${taskId}::uuid ORDER BY seq`
: [];
const ok = got === s.expect && jobs.length === s.jobs;
if (!ok) {
fails.push(`「${s.say}」期望 ${s.expect}/${s.jobs} 条 Job,实得 ${got}/${jobs.length} 条`);
}
console.log(`${ok ? '✅' : '❌'}${s.say}」`);
console.log(` ${s.note}`);
console.log(` ${got}${r.handled ? '' : `(${r.why})`} · Job ${jobs.length} 条:` +
jobs.map((j) => `${j.seq}.${j.job_type}(${j.status})`).join(' '));
console.log();
}
// ── 第五步:护士处理完之后,患者再说 → 应该**重新**挂待办 ──
if (taskId) {
/**
* ⚠️ 必须给 `executor_user_id` —— `ck_jobs_who` 要求「human 的 job 一旦
* running/done/failed 就必须知道是谁做的」。第一版没给,直接被 CHECK 拦了。
* ⇒ 这也说明生产里**只能走 `TaskService.choose`**,它会填这一列。
*/
const someone = await prisma.$queryRaw<{ id: string }[]>`SELECT id FROM app.users LIMIT 1`;
await prisma.$executeRaw`
UPDATE app.jobs SET status='done', chosen_option='handled',
executor_user_id = ${someone[0]!.id}::uuid,
started_at=coalesce(started_at, now()), finished_at=now()
WHERE task_id = ${taskId}::uuid AND executor_type='human' AND status='pending'`;
const r = await scene.handle({
tenantId: conv[0].tenant_id,
conversationId: conv[0].id,
text: '又开始疼了',
});
const pending = await prisma.$queryRaw<{ n: bigint }[]>`
SELECT count(*) AS n FROM app.jobs
WHERE task_id = ${taskId}::uuid AND executor_type='human' AND status='pending'`;
const ok = r.handled && Number(pending[0]!.n) === 1;
if (!ok) fails.push('护士处理完后患者再说,没有重新挂待办');
console.log(`${ok ? '✅' : '❌'} 「又开始疼了」(护士刚处理完)`);
console.log(' ⭐ 新一轮:待办应该**重新**挂出来,而不是被"已存在"吞掉');
console.log(` 待办数 ${Number(pending[0]!.n)}(应为 1)\n`);
}
// ── 收尾 ──
await prisma.$executeRaw`UPDATE app.jobs SET status='skipped', finished_at=now() WHERE status='pending'`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='接待演练残留', finished_at=now()
WHERE status NOT IN ('completed','closed')`;
console.log(fails.length ? `❌ ${fails.length} 项失败:\n · ${fails.join('\n · ')}` : '✅ 全部通过');
await app.close();
process.exit(fails.length ? 1 : 0);
}
main().catch((e) => {
console.error('失败:', e?.message ?? e);
process.exit(1);
});
/**
* 手动跑一次通讯录全量对账。
* ⚠️ 和定时任务跑的是**同一段逻辑**([05 工程约定]:CLI 手动跑同一段代码,
* 不要为手动路径另写一份 —— 两份会漂移,而漂移的那次一定是出事那次)。
*/
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ContactReconcileService } from '../modules/channel/contact-reconcile.service';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn', 'log'] });
const accounts = await app.get(PrismaService).wecomAccount.findMany({
where: { status: 'online' },
select: { id: true, tenantId: true, vid: true, deviceId: true },
});
if (!accounts.length) { console.log('没有在线账号'); await app.close(); return; }
const svc = app.get(ContactReconcileService);
for (const a of accounts) {
console.log(`\n=== ${a.vid} ===`);
console.log(JSON.stringify(await svc.reconcile(a), null, 2));
}
await app.close();
}
main().catch((e) => { console.error('失败:', e?.message ?? e); process.exit(1); });
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { AccountService } from '../modules/channel/account.service';
/**
* 登记一个已托管的企微账号,并立刻对齐在线状态。
*
* 用法:
* pnpm --filter @pac/ai-service register:account -- \
* --partner=friday --partner-tenant=T1 --tenant-name='测试集团' \
* --vid=1688852690257230 --device-id=XXXX --region=Zhejiang \
* --corp-id=1970325128032426 --nickname=罗启
*
* ⚠️ `deviceId` 必须是**当初扫码登录时用的那个**。企微把「同一账号 + 同一 deviceId」
* 视作老设备;换一个就被当成新设备,触发环境异常风控(-18020000/-18020001/-18020002),
* 严重时封号。⛔ 不知道就别瞎填,去 ECom 后台或原始登录记录里翻。
*
* ⚠️ `region` 同理 —— 重新登录要用同一个区划。
*/
async function main() {
const arg = (k: string) => process.argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3);
const need = (k: string) => {
const v = arg(k);
if (!v) throw new Error(`缺少 --${k}=`);
return v;
};
const partnerCode = need('partner');
const partnerTenantId = need('partner-tenant');
const tenantName = arg('tenant-name') ?? partnerTenantId;
const vid = need('vid');
const deviceId = need('device-id');
const region = need('region');
const corpId = arg('corp-id') ?? null;
const nickname = arg('nickname') ?? null;
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
const prisma = app.get(PrismaService);
const accounts = app.get(AccountService);
const log = new Logger('register-account');
try {
// 租户:按 (partner_code, partner_tenant_id) 幂等
const tenant = await prisma.tenant.upsert({
where: { partnerCode_partnerTenantId: { partnerCode, partnerTenantId } },
create: { partnerCode, partnerTenantId, name: tenantName },
update: {},
});
log.log(`租户 ${tenant.name} (${tenant.id})`);
// 账号:按 vid 幂等。⚠️ update 里**不写 device_id / region** ——
// 它们是登录时定死的,事后改只会把风控信息覆盖成错的。要改必须是显式的重新登录流程。
const account = await prisma.wecomAccount.upsert({
where: { vid },
create: { tenantId: tenant.id, vid, deviceId, region, corpId, nickname },
update: { nickname, corpId },
});
log.log(`账号 ${account.nickname ?? account.vid} (${account.id})`);
const r = await accounts.refreshOnlineStatus();
log.log(`在线状态:${r.online.includes(vid) ? '✓ online' : '✗ offline'}`);
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { OutboundService } from '../modules/outbound/outbound.service';
import { GateService } from '../modules/outbound/gate.service';
/**
* 把一条文本消息排进出站队列。
*
* 用法:
* pnpm --filter @pac/ai-service send:text -- --conv=<会话id> --text='内容' [--actor=agent] [--dedup=key]
* pnpm --filter @pac/ai-service send:text -- --conv=<会话id> --dry # 只过闸不入队
*
* ⚠️⚠️ **这个命令会真的把消息发给真人。** `--dry` 先看一遍闸门结论再决定。
* ⚠️ 入队 ≠ 发送:闸门在**投递时**判,排队期间关闸仍然拦得住。
*/
async function main() {
const arg = (k: string) => process.argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3);
const conversationId = arg('conv');
const text = arg('text');
const actor = (arg('actor') ?? 'human') as 'human' | 'agent';
const dedup = arg('dedup') ?? null;
const dry = process.argv.includes('--dry');
if (!conversationId) throw new Error('缺少 --conv=');
if (!dry && !text) throw new Error('缺少 --text=');
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['log', 'warn', 'error'] });
const prisma = app.get(PrismaService);
const outbound = app.get(OutboundService);
const gate = app.get(GateService);
const log = new Logger('send-text');
try {
const conv = await prisma.conversation.findUnique({
where: { id: conversationId },
select: { id: true, tenantId: true, accountId: true, convKey: true, kind: true, peerVid: true },
});
if (!conv) throw new Error(`会话 ${conversationId} 不存在`);
// 先把闸门结论打出来 —— 入队之前就让人看见"现在发得出去吗"
const v = await gate.check({ tenantId: conv.tenantId, accountId: conv.accountId, actor });
log.log(`会话 ${conv.convKey}(${conv.kind}) actor=${actor}`);
log.log(v.pass ? '闸门:✓ 全部通过,投递时会真的发出去' : `闸门:✗ ${v.gate} —— ${v.reason}`);
if (dry) return void log.log('--dry:不入队');
await prisma.$transaction(async (tx) => {
const r = await outbound.enqueueText(tx, {
tenantId: conv.tenantId,
conversationId: conv.id,
contentList: [text!],
actor,
dedupKey: dedup,
});
if ('skipped' in r) log.warn(`幂等键 ${dedup} 已存在,本次不入队`);
else log.log(`已入队 outbox=${r.outboxId}`);
});
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ContactSyncService } from '../modules/channel/contact-sync.service';
import { ScheduledRunService } from '../modules/scheduling/scheduled-run.service';
/**
* 拉通讯录增量。
*
* 用法:
* pnpm --filter @pac/ai-service sync:contacts # 所有在线账号,增量
* pnpm --filter @pac/ai-service sync:contacts -- --full # 从头全量重拉
*
* ⚠️ `--full` 的代价要先评估([12 §9.5]):`[12001]` 标签那类**事件流**从 0 重拉会
* 回放历史上删掉的记录。通讯录这两个是**快照**流,重拉是安全的,但会拉全量数据。
* ⚠️ 和后台定时任务抢同一把锁 —— 并发拉取会导致 seq 区间重叠、重复数据 + 游标回退。
*/
async function main() {
const full = process.argv.includes('--full');
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['log', 'warn', 'error'] });
const prisma = app.get(PrismaService);
const contacts = app.get(ContactSyncService);
const runs = app.get(ScheduledRunService);
const log = new Logger('sync-contacts');
try {
const accounts = await prisma.wecomAccount.findMany({
where: { status: 'online' },
select: { id: true, tenantId: true, vid: true, deviceId: true, nickname: true },
});
if (!accounts.length) return void log.warn('没有 status=online 的托管账号');
for (const a of accounts) {
const r = await runs.withLock(
// ⚠️ 锁按**账号**分,不是按任务分 —— 不同账号的通讯录互不相干,
// 共用一把锁会让账号多的时候排长队。tenantId 那一维给的是租户级隔离,
// 账号级要靠 schedule_name 里带 id。
`contacts.sync:${a.id}`,
async (heartbeat) => {
const ext = await contacts.syncExternal(a, { full });
await heartbeat();
const int = await contacts.syncInternal(a, { full });
return { external: ext, internal: int };
},
{ tenantId: a.tenantId, timeoutMinutes: 30 },
);
if (!r.ran) log.warn(`${a.nickname ?? a.vid}:没抢到锁,跳过`);
}
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { RoomSyncService } from '../modules/channel/room-sync.service';
/**
* 补齐有会话的群的群名与成员。
* 用法:pnpm --filter @pac/ai-service sync:rooms [-- --stale-hours=0]
* ⚠️ `--stale-hours=0` = 全部重拉(默认只补缺失或超过 24 小时没更新的)。
*/
async function main() {
const sh = process.argv.find((a) => a.startsWith('--stale-hours='))?.split('=')[1];
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['log', 'warn', 'error'] });
const prisma = app.get(PrismaService);
const rooms = app.get(RoomSyncService);
const log = new Logger('sync-rooms');
try {
const accounts = await prisma.wecomAccount.findMany({
where: { status: 'online' },
select: { id: true, tenantId: true, vid: true, deviceId: true },
});
for (const a of accounts) {
const r = await rooms.syncMissing(a, { staleHours: sh === undefined ? undefined : Number(sh) });
log.log(`${a.vid}: ${r.rooms} 个群 / ${r.members} 名成员`);
}
} finally {
await app.close();
}
}
main().catch((e) => { console.error(e); process.exit(1); });
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { SessionSyncService } from '../modules/channel/session-sync.service';
/**
* 拉一个(或全部在线)托管账号的会话列表。
*
* 用法:
* pnpm --filter @pac/ai-service sync:sessions # 所有 status='online' 的账号
* pnpm --filter @pac/ai-service sync:sessions -- --vid=168… # 指定账号
*
* ⚠️ 没有 `--full` —— `[11062]` 每次都是全量枚举,它的 seq 是翻页令牌不是水位线
* (见 session-sync.service.ts 的类注释)。
*
* ⚠️ 这是**手动触发**的一次性入口,不是定时任务。定时化要等 outbox worker(M1 后半),
* 因为并发拉取会导致游标重叠 —— 串行化必须靠 `scheduled_runs` 的 singleton 索引,
* 而不是"记得别同时点两次"。
*/
async function main() {
const args = process.argv.slice(2);
const vid = args.find((a) => a.startsWith('--vid='))?.slice(6);
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
const prisma = app.get(PrismaService);
const sync = app.get(SessionSyncService);
const log = new Logger('sync-sessions');
try {
const accounts = await prisma.wecomAccount.findMany({
where: vid ? { vid } : { status: 'online' },
select: { id: true, tenantId: true, vid: true, deviceId: true, nickname: true },
});
if (!accounts.length) {
log.warn(vid ? `找不到 vid=${vid} 的账号` : '没有 status=online 的托管账号');
return;
}
for (const a of accounts) {
log.log(`── ${a.nickname ?? a.vid} (${a.vid}) ──`);
const r = await sync.sync(a);
if (r.alerts.length) {
log.error(`🔴 ${r.alerts.length} 条告警:`);
for (const al of r.alerts) log.error(` type=${al.type} reason=${al.reason}`);
}
}
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
import type { AuthUser } from '../guards/jwt-auth.guard';
/** 取当前登录用户。⚠️ 只有过了 JwtAuthGuard 的端点才有值。 */
export const CurrentUser = createParamDecorator((_d: unknown, ctx: ExecutionContext): AuthUser => {
const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser }>();
if (!req.user) throw new Error('CurrentUser 用在了 @Public() 端点上 —— 那里没有用户');
return req.user;
});
import { SetMetadata } from '@nestjs/common';
/** 标记端点无需登录。⚠️ 探针和登录端点必须带它,否则 systemd 拿 401 会无限重启。 */
export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);
import { SetMetadata } from '@nestjs/common';
/**
* 标记端点**不套统一响应信封**,原样返回 controller 的返回值。
*
* ⚠️ 只给「对端不是我们自己的前端」的端点用 —— 目前唯一的用例是 ECom 的回调入口:
* 供应商「开发前必读」§3 规定 body 必须**恰好**是 `success`
* (或 `{"message":"success"}`),否则「视为失败并重试三次」。
* 套上 `{code,msg,data}` 就是未满足成功条件。
*
* ⭐ 用元数据而不是在拦截器里维护一张路径表:路径表会和
* `@Controller()` / `setGlobalPrefix()` 悄悄漂移,而漂移的后果是**安静的**
* (ECom 收到信封 → 判定失败 → 每条回调重推 3 次,我们这边只看到"重复回调变多了")。
* 标记跟着方法走,改路径不会失配。
*/
export const RAW_RESPONSE = 'rawResponse';
export const RawResponse = () => SetMetadata(RAW_RESPONSE, true);
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_ROLE = 'requireRole';
/**
* 这个接口**只有指定角色**能调。
*
* ═══ ⚠️ 这是这个系统里第一个真正按 role 判的闸 ═══════════════════════════
*
* `users.role`(owner / staff / viewer)一直存在,但在这之前**一处都没被用来判权限** ——
* 它只被读出来传给前端、打印在 CLI 里。所有接口对三种角色一视同仁。
* ⇒ 加第一个的时候就把它做成**通用的一次**,⛔ 不是在 controller 里写
* `if (user.role !== 'owner') throw` —— 那样第二个第三个就会各写各的,
* 而漏写一处的表现是**安静的**(接口照常返回 200)。
*
* ⚠️ 挡在 **Guard** 里而不是靠前端不画按钮 —— 前端绕得过去,Guard 绕不过去。
* (和 `@AllowTempPassword()` 同一条理由。)
*
* ⚠️ 拒绝时回**和"不存在"同一个错**(404 语义),⛔ 不回 403:
* 403 等于告诉对方"这个接口是真的,只是你不够格",那本身也是信息。
*/
export const RequireRole = (...roles: string[]) => SetMetadata(REQUIRE_ROLE, roles);
import { SetMetadata } from '@nestjs/common';
export const ALLOW_TEMP_PASSWORD = 'allowTempPassword';
/**
* 允许**还在用临时密码**的人访问这个接口。
*
* ═══ 为什么需要它 ═════════════════════════════════════════════════════════
*
* 管理员重置密码时发的是一串临时密码,它可能经过微信、口头、便利贴 ——
* 也就是说**它已经不是秘密了**。所以拿临时密码登录的人只应该能做两件事:
* ① 知道自己是谁(`/auth/me`,前端要据此渲染"请先改密码")
* ② 改密码(`/auth/password`)
* 其余一律挡住 —— ⛔ 否则"临时密码"和正式密码没有任何区别,那个字段就是摆设。
*
* ⚠️ 挡在 **Guard** 里而不是靠前端跳转:前端跳转绕得过去,Guard 绕不过去。
*/
export const AllowTempPassword = () => SetMetadata(ALLOW_TEMP_PASSWORD, true);
import { SetMetadata } from '@nestjs/common';
export const ALLOW_VIEWER_WRITE = 'allowViewerWrite';
/**
* ⭐⭐ 这个**写**接口,`viewer` 也能调。
*
* ═══ ⚠️⚠️ 它是一张白名单,因为 viewer 的闸是**默认拒** ═══════════════════
*
* `viewer` 的语义是"只看不动"。而"只看不动"如果做成
* **逐个写接口挂 `@RequireRole('owner','staff')`**,失效方式是致命的:
* 明天加一个新的 POST,谁都不会想起来去挂那个装饰器,
* 而漏掉的表现是**安静的** —— 接口照常 200,只读账号动了本不该动的数据。
* (今天已经踩过一次同类的坑:`load()` 那道"有没有绑号"的闸,
* 靠人记得写,结果漏成了反向泄露。)
*
* ⇒ 判据反过来:**非 GET/HEAD 一律拒**,要放行的**显式挂这个**。
* 新加接口默认是安全的那一侧,⛔ 而不是默认敞开。
*
* ⚠️ 目前只有一处要放行:**改自己的密码**。那既不是"动数据",
* 也不能不给 —— 不给的话激活后再也改不了密码。
* ⛔ 别顺手把"接管会话""结待办"加进来:那是**替诊所对患者做决定**,
* 正是 viewer 不该有的东西。
*/
export const AllowViewerWrite = () => SetMetadata(ALLOW_VIEWER_WRITE, true);
import { ApiCode as PacApiCode, API_CODE_MESSAGES as PAC_MESSAGES } from '@pac/types';
/**
* friday-ai 的业务码 —— **沿用 PAC 的码表,不另起一套**([05 §4](docs/05-工程约定.md))。
*
* PAC 的 5 位分段是 `A-BB-CC`([@pac/types schemas/wrap.ts](packages/types/src/schemas/wrap.ts)):
* A 错误来源:0 成功 · 1 客户端 · 2 业务状态 · 3 第三方/上游 · 9 内部
* BB 模块号
* CC 模块内序号
*
* ⭐ **模块号 BB 的分配**:PAC 已用 `00–10`(common / auth / …/ tenant)。
* friday-ai 取 **20–29**,中间的 11–19 留给 PAC 继续长。
*
* 20 通道(ECom / 企微) 21 会话 22 患者关联
* 23 Task/Job 24 Agent 25–29 预留
*
* ⚠️ **为什么不直接往 `@pac/types` 里加**:那个包 60 天被 PAC 改了 124 次,
* 在长命分支上往里塞 friday-ai 的词汇 = 每天 merge 都在同一个文件上打架,
* 而且会把 friday-ai 的领域概念渗进 PAC 的公共包。段位隔离已经保证零碰撞,
* 共享的只是**编号规则**和 auth/client 那些真正通用的码 —— 那才是同仓该共享的东西。
*/
export const AiApiCode = {
...PacApiCode,
// === 1 客户端错误 ==========================================
// 1-20-xx 通道
/// 托管账号不存在 / 不属于当前租户
CHANNEL_ACCOUNT_NOT_FOUND: 12001,
// 1-21-xx 会话
CONVERSATION_NOT_FOUND: 12101,
MESSAGE_NOT_FOUND: 12102,
// 1-22-xx 组织
/// 组织节点不存在 —— **也用于"存在但不在你的数据范围内"**:
/// 区分开就等于告诉对方这个 id 是真的(同会话的 404 语义)
ORG_UNIT_NOT_FOUND: 12201,
// 1-23-xx Task / Job
TASK_NOT_FOUND: 12301,
JOB_NOT_FOUND: 12302,
// === 2 业务状态错误 ========================================
// 2-20-xx 通道
/// 托管账号掉线 —— 掉线是常态([13 §1](docs/13-会话层数据库设计.md)),文案要跟"账号不存在"区分开
CHANNEL_ACCOUNT_OFFLINE: 22001,
/// 账号级出站闸门关着(`wecom_accounts.sending_enabled = false`)
CHANNEL_SENDING_DISABLED: 22002,
/// 租户级 AI 自动发送闸门关着(`tenants.ai_auto_send = false`)—— 默认就是关的
CHANNEL_AI_AUTOSEND_DISABLED: 22003,
// 2-22-xx 组织
/// 这个组织节点(及其所有上级)没配 PAC 的 `source_ref`,读不了患者。
/// ⚠️ 是**配置缺失**不是权限问题 —— 文案要指向"去补配置",不是"你没权限"
ORG_UNIT_NO_PAC_REF: 22201,
// 2-21-xx 会话
/// 会话已被人工接管,Agent 的自动动作在此静默([14 §2](docs/14-Agent层数据库设计.md))
CONVERSATION_HELD_BY_HUMAN: 22101,
/// 群会话不能关联患者 —— 群没有单一对应的人,这个问题本身不成立
CONVERSATION_NOT_SINGLE: 22102,
// 2-23-xx Task / Job
/// 同一 Agent 对同一业务对象已有未完成 Task(`uq_tasks_active`)
TASK_ALREADY_ACTIVE: 22301,
/// Task 已是终态,不能再改
TASK_ALREADY_FINISHED: 22302,
// === 3 第三方 / 上游 =======================================
// 3-20-xx ECom
ECOM_UNREACHABLE: 32001,
ECOM_RETURNED_ERROR: 32002,
/// ECom 侧限流("发送频繁")—— 具体触发条件供应商没给,只能被动识别([08 §7](docs/08-ECom能力全景与验证结论.md))
ECOM_RATE_LIMITED: 32003,
// 3-22-xx PAC
PAC_UNREACHABLE: 32201,
PAC_RETURNED_ERROR: 32202,
} as const;
export type AiApiCode = (typeof AiApiCode)[keyof typeof AiApiCode];
/** 默认文案。BizError 不传 msg 时用它兜底。 */
export const AI_API_CODE_MESSAGES: Record<number, string> = {
...PAC_MESSAGES,
[AiApiCode.CHANNEL_ACCOUNT_NOT_FOUND]: '托管账号不存在',
[AiApiCode.CONVERSATION_NOT_FOUND]: '会话不存在',
[AiApiCode.MESSAGE_NOT_FOUND]: '消息不存在',
[AiApiCode.ORG_UNIT_NOT_FOUND]: '组织节点不存在',
[AiApiCode.ORG_UNIT_NO_PAC_REF]: '该组织节点未关联 PAC 组织,读不了患者数据',
[AiApiCode.TASK_NOT_FOUND]: '任务不存在',
[AiApiCode.JOB_NOT_FOUND]: '执行步骤不存在',
[AiApiCode.CHANNEL_ACCOUNT_OFFLINE]: '托管账号已掉线,请重新扫码登录',
[AiApiCode.CHANNEL_SENDING_DISABLED]: '该账号的出站已停用',
[AiApiCode.CHANNEL_AI_AUTOSEND_DISABLED]: 'AI 自动发送未开启,请人工确认后发送',
[AiApiCode.CONVERSATION_HELD_BY_HUMAN]: '会话已由人工接管',
[AiApiCode.CONVERSATION_NOT_SINGLE]: '群会话不能关联患者',
[AiApiCode.TASK_ALREADY_ACTIVE]: '该业务对象已有进行中的同类任务',
[AiApiCode.TASK_ALREADY_FINISHED]: '任务已结束,不能再修改',
[AiApiCode.ECOM_UNREACHABLE]: '企微通道不可达',
[AiApiCode.ECOM_RETURNED_ERROR]: '企微通道返回错误',
[AiApiCode.ECOM_RATE_LIMITED]: '企微通道限流,请稍后重试',
[AiApiCode.PAC_UNREACHABLE]: 'PAC 接口不可达',
[AiApiCode.PAC_RETURNED_ERROR]: 'PAC 返回错误',
};
export function describeAiApiCode(code: number): string {
return AI_API_CODE_MESSAGES[code] ?? '未知错误';
}
import { HttpException, HttpStatus } from '@nestjs/common';
import { describeAiApiCode } from './api-codes';
/**
* 携带明确 5 位业务码的错误。
*
* 领域错误一律抛它,异常过滤器会解包成信封 `{ code, msg, data: null, details? }`。
*
* ⚠️ 构造时传给 `super` 的 `HttpStatus.OK` 是个**占位**:真正的 HTTP 状态由过滤器决定
* (已知业务结果恒 200,只有真崩了才 5xx)。写 OK 是为了让 Nest 内部不把它当 5xx 处理。
*
* 用法:
* throw new BizError(AiApiCode.CONVERSATION_NOT_FOUND, `会话 ${id} 不存在`);
* throw new BizError(AiApiCode.CHANNEL_ACCOUNT_OFFLINE); // 用默认文案
* throw new BizError(AiApiCode.ECOM_RETURNED_ERROR, 'msg', { raw }); // 带 details
*/
export class BizError extends HttpException {
readonly code: number;
readonly msg: string;
readonly details?: unknown;
constructor(code: number, msg?: string, details?: unknown) {
const finalMsg = msg ?? describeAiApiCode(code);
super({ code, msg: finalMsg, details }, HttpStatus.OK);
this.code = code;
this.msg = finalMsg;
this.details = details;
}
}
import { HttpException } from '@nestjs/common';
/**
* 原样返回一个 HTTP 响应,**不套统一信封**。
*
* ⚠️ 只给「对端不是我们自己的前端」的端点用 —— 也就是 `@RawResponse()` 的成功腿
* 对应的失败腿。目前唯一用例是 ECom 回调入口的准入闸(EcomCallbackGuard):
*
* 信封规则是「HTTP 恒 200 + code 在 body」,对前端是对的。但对一个
* **公网无鉴权可写**的端点,它有两个坏处:
* ① 探测者拿到 `200 {"code":10004,"msg":"Not Found"}` —— 等于告诉他
* "这里有东西、是 JSON 的、错误码长这样"。真 404 什么都不说。
* ② ECom 的成功判据是 body 恰好等于 `success`。虽然信封也不等于 success
* (所以会重试,行为上没错),但让"拒绝"和"处理失败"长得一模一样,
* 排查时分不出是被闸挡了还是落库炸了。
*
* ⇒ 抛它,AllExceptionsFilter 会原样吐出 status + 纯文本 body。
*/
export class RawHttpException extends HttpException {
/** ⚠️ `status` 在基类上是方法(getStatus 的字段版),这里用独立名字避开覆盖。 */
readonly rawStatus: number;
readonly text: string;
constructor(status: number, text: string) {
super(text, status);
this.rawStatus = status;
this.text = text;
}
}
import {
ArgumentsHost,
BadRequestException,
Catch,
ExceptionFilter,
ForbiddenException,
HttpException,
HttpStatus,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import * as Sentry from '@sentry/nestjs';
import type { Request, Response } from 'express';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ZodError } from 'zod';
import { AiApiCode, describeAiApiCode } from '../errors/api-codes';
import { BizError } from '../errors/biz-error';
import { RawHttpException } from '../errors/raw-http.exception';
/**
* 统一异常过滤器 —— 输出信封 `{ code, msg, data: null, details? }`。
*
* HTTP 状态策略:
* · 所有已知业务 / HTTP 异常 → **200**(客户端恒解 body.code)
* · 真·未捕获错误 → **500**(探针 / LB 才看得见实例挂了)
*
* 分支顺序**不能改**,每一条的位置都有理由:
* ⓪ RawHttpException → 原样吐 status + 纯文本,**不套信封**
* ⚠️ 必须第一条 —— 它的整个意义就是"别套信封"。
* ① ZodSerializationException → 90000 + **必须打日志**
* ⚠️ 它继承的是 InternalServerErrorException(⛔ 不是 ZodValidationException,名字像血缘不同)。
* 不单独拦就会掉进 ⑤ 的通用 HttpException 分支 —— 那条既不打日志、HTTP 又保持 200,
* 于是"响应字段漂了"这种自家 bug 会**一个字都不留**地静默返回。
* pac-service 2026-08-08 因此查了很久,这里直接继承那个教训。
* ② BizError → 用它自带的 5 位码
* ③ ZodValidationException → 10002
* ④ PayloadTooLargeError → 10002 且 HTTP 200
* ⚠️ 它是 body-parser 抛的**裸 Error**,不是 HttpException。不提前拦就会掉进 ⑥ 变成 500/90000,
* 对面按文档退避重试,永远重试不好。
* ⑤ NestJS HttpException → 按类映射
* ⑥ 裸 Error → 90000,HTTP 500,上报 Sentry
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
// ⓪ RawHttpException —— **必须在最前面**。它的整个意义就是"别套信封",
// 掉进下面任何一条分支都会被包起来。见 raw-http.exception.ts。
if (exception instanceof RawHttpException) {
res.status(exception.rawStatus).type('text/plain').send(exception.text);
return;
}
let httpStatus: number = HttpStatus.OK;
let code: number = AiApiCode.INTERNAL_ERROR;
let msg = describeAiApiCode(AiApiCode.INTERNAL_ERROR);
let details: unknown;
if (exception instanceof ZodSerializationException) {
code = AiApiCode.INTERNAL_ERROR;
msg = describeAiApiCode(code);
const err = exception.getZodError();
const issues =
err instanceof ZodError
? err.issues.map((i) => ({ path: i.path.join('.'), code: i.code, message: i.message }))
: undefined;
this.logger.error(
`${req.method} ${req.url} → 响应不符合 schema(ai-service 自身 bug):${JSON.stringify(issues)}`,
);
// ⚠️ details 只在非生产给出:字段路径属于内部结构,⛔ 别漏给调用方
if (process.env.NODE_ENV !== 'production') details = issues;
Sentry.captureException(exception, {
tags: { path: `${req.method} ${req.url}`, kind: 'zod-serialization' },
});
} else if (exception instanceof BizError) {
code = exception.code;
msg = exception.msg;
details = exception.details;
} else if (exception instanceof ZodValidationException) {
code = AiApiCode.CLIENT_VALIDATION_FAILED;
msg = describeAiApiCode(code);
const err = exception.getZodError();
if (err instanceof ZodError) {
details = err.issues.map((i) => ({
path: i.path.join('.'),
code: i.code,
message: i.message,
}));
}
} else if (isPayloadTooLargeError(exception)) {
code = AiApiCode.CLIENT_VALIDATION_FAILED;
msg = `请求体超出上限(${BODY_LIMIT_HINT})`;
details = { limit: BODY_LIMIT_HINT };
this.logger.warn(`${req.method} ${req.url} → 请求体超限,已按 10002 回执`);
} else if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
msg = exception.message;
if (typeof body === 'object' && body !== null) {
const b = body as Record<string, unknown>;
msg = (b.message as string) ?? msg;
details = b.details ?? (Array.isArray(b.message) ? b.message : undefined);
} else if (typeof body === 'string') {
msg = body;
}
code = mapNestExceptionToCode(exception, status);
} else if (exception instanceof Error) {
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
code = AiApiCode.INTERNAL_ERROR;
msg = exception.message || describeAiApiCode(code);
this.logger.error(exception.stack ?? msg);
// 只上报真·未预期错误;业务错误不上报,免噪音。无 DSN 时 captureException 是 no-op。
Sentry.captureException(exception, { tags: { path: `${req.method} ${req.url}` } });
}
if (httpStatus >= 500) {
this.logger.error(`${req.method} ${req.url} → 5xx code=${code} msg=${msg}`);
}
res.status(httpStatus).json({
code,
msg,
data: null,
...(details !== undefined ? { details } : {}),
});
}
}
function mapNestExceptionToCode(exc: HttpException, status: number): number {
if (exc instanceof BadRequestException) return AiApiCode.CLIENT_BAD_REQUEST;
if (exc instanceof UnauthorizedException) return AiApiCode.AUTH_TOKEN_INVALID;
if (exc instanceof ForbiddenException) return AiApiCode.AUTH_PERMISSION_DENIED;
if (exc instanceof NotFoundException) return AiApiCode.CLIENT_NOT_FOUND_GENERIC;
if (status === 400) return AiApiCode.CLIENT_BAD_REQUEST;
if (status === 401) return AiApiCode.AUTH_TOKEN_INVALID;
if (status === 403) return AiApiCode.AUTH_PERMISSION_DENIED;
if (status === 404) return AiApiCode.CLIENT_NOT_FOUND_GENERIC;
if (status === 422) return AiApiCode.CLIENT_VALIDATION_FAILED;
if (status === 429) return AiApiCode.CLIENT_RATE_LIMITED;
return AiApiCode.INTERNAL_ERROR;
}
/// 请求体上限的展示串 —— 真值在 main.ts 的 BODY_LIMIT,两处都改才对得上。
/// 这里不 import main.ts:那会把 bootstrap 的副作用拖进过滤器。
const BODY_LIMIT_HINT = '2MB';
/**
* body-parser(raw-body)在请求体超限时抛的错 —— **裸 Error**,也不导出类型供 instanceof。
* 稳定特征是 `type === 'entity.too.large'`,辅以 413 兜底(不同版本字段名有出入)。
*/
function isPayloadTooLargeError(e: unknown): boolean {
if (!(e instanceof Error)) return false;
const x = e as Error & { type?: string; status?: number; statusCode?: number };
return x.type === 'entity.too.large' || x.status === 413 || x.statusCode === 413;
}
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { JwtService } from '../../modules/auth/jwt.service';
import { IS_PUBLIC } from '../decorators/public.decorator';
import { ALLOW_TEMP_PASSWORD } from '../decorators/temp-password.decorator';
import { REQUIRE_ROLE } from '../decorators/require-role.decorator';
import { ALLOW_VIEWER_WRITE } from '../decorators/viewer-write.decorator';
export interface AuthUser {
id: string;
tenantId: string;
displayName: string;
role: string;
dataScope: string;
orgUnitId: string | null;
/** 还在用管理员发的临时密码。⚠️ 除了 /auth/me 和 /auth/password,其余接口守卫都会挡 */
mustChangePassword: boolean;
}
/** ⚠️ 只读方法 —— `viewer` 只能走这些(见守卫里那道只读闸) */
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/** cookie 名。⚠️ httpOnly —— 令牌不该被 JS 读到([11 §6.4] 的载体设计)。 */
export const AUTH_COOKIE = 'friday_token';
/**
* 登录守卫。
*
* ═══ ⚠️ 验签只是一半 ═══════════════════════════════════════════════════════
*
* JWT 的代价是**服务端不知道有哪些令牌还活着**。撤销靠另外两样,都在这里做:
* ① `is_active` —— 离职立即失效
* ② `token_version` —— 手机丢了 `+1`,该用户全部旧令牌当场作废
* 所以**每请求都要查一次库**。这不是性能疏忽:选 JWT 的理由从来不是"省这一次查询"
* (那样的话 session 表也一样),而是**将来对外提供接口时验签方只需要公钥**([11 §6.4])。
*
* ═══ ⚠️ 权限从库里读,不从令牌读 ═══════════════════════════════════════════
*
* `role` / `data_scope` / `tenant_id` 都不在 claims 里 —— 权限收紧必须**立即生效**。
* 带进令牌意味着「把范围从 subtree 收到 self」要等过期,**越权继续存在**。
* 反正这里已经查了 users,顺手一起读。
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwt: JwtService,
private readonly prisma: PrismaService,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
ctx.getHandler(),
ctx.getClass(),
]);
if (isPublic) return true;
const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser }>();
const token = extractToken(req);
if (!token) throw new UnauthorizedException('未携带登录令牌');
// ⚠️ 显式写 'login':默认值也是它,但**这里是唯一把令牌换成身份的地方**,
// 让"我只接受登录令牌"在代码里看得见,而不是靠一个默认参数。
const claims = this.jwt.verify(token, 'login');
if (!claims) throw new UnauthorizedException('登录令牌无效或已过期');
const user = await this.prisma.user.findUnique({
where: { id: claims.sub },
select: {
id: true, tenantId: true, displayName: true, role: true,
dataScope: true, orgUnitId: true, isActive: true, tokenVersion: true,
mustChangePassword: true,
},
});
// ⚠️ 三个条件缺一不可,而且**每一个都对应一种真实的撤销需求**
if (!user) throw new UnauthorizedException('用户不存在');
if (!user.isActive) throw new UnauthorizedException('账号已停用');
if (user.tokenVersion !== claims.ver) throw new UnauthorizedException('登录已失效,请重新登录');
/**
* ⚠️⚠️ 还在用**临时密码**的人,只能访问带 `@AllowTempPassword()` 的接口。
*
* 临时密码是管理员经微信/口头/便利贴转达的 —— **它已经不是秘密了**。
* 不挡的话,"临时密码"和正式密码没有任何区别,`must_change_password` 就是摆设。
* ⛔ 挡在这里而不是靠前端跳转:前端跳转绕得过去,守卫绕不过去。
*/
if (user.mustChangePassword) {
const allowed = this.reflector.getAllAndOverride<boolean>(ALLOW_TEMP_PASSWORD, [
ctx.getHandler(),
ctx.getClass(),
]);
if (!allowed) throw new UnauthorizedException('请先修改初始密码');
}
/**
* ⭐⭐ 角色闸 —— 这是这个系统里**第一个**真正按 `role` 判的地方。
*
* ⚠️ 放在守卫里而不是各个 controller 里 `if (user.role !== 'owner')`:
* 散着写的话第二个第三个会各写各的,而漏写一处的表现是**安静的**
* (接口照常 200,只是谁都能调)。
* ⚠️ 拒绝时回 `NotFound` 而不是 `Forbidden`:403 等于告诉对方
* "这个接口是真的,只是你不够格" —— 那本身也是信息(同会话/待办那两处的理由)。
*/
const need = this.reflector.getAllAndOverride<string[]>(REQUIRE_ROLE, [
ctx.getHandler(),
ctx.getClass(),
]);
if (need?.length && !need.includes(user.role)) {
throw new NotFoundException('接口不存在');
}
/**
* ⭐⭐ **`viewer` 只读闸 —— 默认拒所有写。**
*
* ⚠️⚠️ `viewer` 这个取值一直存在(开户界面就让人选),而在这之前
* 它和 `staff` **没有任何区别**:照样能发消息、绑患者、结待办、绑企微号。
* 也就是说界面**承诺了一件后端没做的事**。
*
* ⚠️ 判据是**方法**不是接口清单:非 GET/HEAD 一律拒,要放行的显式挂
* `@AllowViewerWrite()`(见那个装饰器的头注 —— 反过来做的话,
* 明天新加的 POST 会默认敞开,而且没人会发现)。
* ⚠️ `GET` 里也有写(`detail()` 会记已读位点)—— 那是**读的副作用**,
* ⛔ 不在这道闸的范围里,也不该在:它不改变任何业务事实。
* ⚠️ 拒绝同样回 404 语义(同上面那道闸的理由)。
*/
if (user.role === 'viewer' && !READ_METHODS.has(req.method)) {
const allowed = this.reflector.getAllAndOverride<boolean>(ALLOW_VIEWER_WRITE, [
ctx.getHandler(),
ctx.getClass(),
]);
if (!allowed) throw new NotFoundException('接口不存在');
}
const authUser: AuthUser = {
id: user.id, tenantId: user.tenantId, displayName: user.displayName,
role: user.role, dataScope: user.dataScope, orgUnitId: user.orgUnitId,
mustChangePassword: user.mustChangePassword,
};
req.user = authUser;
// ⚠️ 租户上下文**不在这里铺** —— AsyncLocalStorage 的上下文在 `run()` 返回时就结束了,
// 而真正的请求处理发生在守卫返回**之后**。在守卫里 `runWithTenant(..., () => true)`
// 看起来对,实际上业务代码跑的时候上下文已经空了,而**纵深防御静默失效**
// (不报错、不告警,只是不再注入 tenantId)。
// ⇒ 铺上下文必须包住"后续处理"这个 continuation → 只能在拦截器里做,
// 见 TenantScopeInterceptor。
return true;
}
}
/**
* 令牌载体:cookie 优先,Bearer 兜底。
* **同一个令牌格式,两种载体** —— 自家前端用 httpOnly cookie(防 XSS 偷令牌),
* 将来的对外 API 用 Bearer header(无 CSRF)。
*/
function extractToken(req: Request): string | null {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) return auth.slice(7);
const cookie = req.headers.cookie;
if (!cookie) return null;
for (const part of cookie.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === AUTH_COOKIE) return decodeURIComponent(v.join('='));
}
return null;
}
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import type { Request } from 'express';
import type { Observable } from 'rxjs';
import { runWithTenant } from '../tenant-context';
import type { AuthUser } from '../guards/jwt-auth.guard';
/**
* 把租户上下文铺到整个请求处理链上。
*
* ⚠️ **必须是拦截器,不能是守卫** —— AsyncLocalStorage 的上下文只在传入的回调内有效,
* 而守卫返回后才轮到业务代码跑。拦截器的 `next.handle()` 就是那个 continuation,
* 包住它才能让后面所有 Prisma 读都看得到租户。
* 写成守卫的后果是**纵深防御静默失效**:不报错、不告警,只是不再注入 tenantId。
*
* ⚠️ 注册顺序:必须排在 JwtAuthGuard **之后**(守卫先于拦截器执行,这一点 Nest 保证),
* 否则 req.user 还没有。
*/
@Injectable()
export class TenantScopeInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser }>();
const user = req.user;
// @Public() 端点没有 user —— 不铺上下文,守卫扩展会照常放行(那是系统任务的语义)
if (!user) return next.handle();
return runWithTenant({ tenantId: user.tenantId, userId: user.id }, () => next.handle());
}
}
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request, Response } from 'express';
import { Observable, map } from 'rxjs';
import { RAW_RESPONSE } from '../decorators/raw-response.decorator';
import { AiApiCode } from '../errors/api-codes';
/**
* 统一响应信封。挂在拦截器链的**最后一环**(响应腿上第一个执行前的位置),
* 此时内层数据已被 ZodSerializerInterceptor 校验过。
*
* 每个成功响应都被强制成 HTTP 200 并包成:
* { code: 0, msg: 'ok', data: <controller 返回值> }
*
* 不包的四类:
* · text/event-stream(SSE)
* · 带 **`@RawResponse()`** 的端点 —— 对端不是我们自己的前端。
* 目前唯一用例是 ECom 回调入口:它的成功判据是 body **恰好**等于 `success`,
* 套上信封就是"未满足成功条件",ECom 会重推 3 次(「开发前必读」§3)。
* · **两个探针端点** —— LB / systemd 不解信封,而且 /health/ready 要靠 HTTP 状态码
* 表达"不就绪"(见 health.controller.ts),套上 `code: 0` 反而会说谎
* · /api/openapi.json 与 /api/docs —— Scalar 工具链要原始 OpenAPI 文档
*/
@Injectable()
export class WrapResponseInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const httpCtx = ctx.switchToHttp();
const req = httpCtx.getRequest<Request>();
const res = httpCtx.getResponse<Response>();
// ⭐ @RawResponse() 优先:标记跟着方法走,不会和路由前缀漂移
const raw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE, [
ctx.getHandler(),
ctx.getClass(),
]);
if (raw) return next.handle();
if (shouldSkip(req, res)) return next.handle();
return next.handle().pipe(
map((data) => {
// 防御:handler 自己已经返回了信封就别再包一层(双层信封前端解不出来)
if (
data &&
typeof data === 'object' &&
'code' in (data as Record<string, unknown>) &&
'msg' in (data as Record<string, unknown>) &&
'data' in (data as Record<string, unknown>)
) {
return data;
}
// 强制 200 —— 本契约下原状态码(201/202)不再有意义,客户端只认 body.code
res.status(200);
return { code: AiApiCode.OK, msg: 'ok', data: data ?? null };
}),
);
}
}
function shouldSkip(req: Request, res: Response): boolean {
const url = req.url ?? '';
// ⚠️ 精确匹配,不用 startsWith('/health') —— 那会把将来的 /healthz-admin 之类一并漏掉信封
if (PROBE_PATHS.has(url)) return true;
if (url.startsWith('/api/openapi.json') || url.startsWith('/api/docs')) return true;
const ct = res.getHeader('content-type');
if (typeof ct === 'string' && ct.startsWith('text/event-stream')) return true;
return false;
}
/// 探针路径。⚠️ health.controller.ts 加端点时**必须**同步登记到这里,
/// 否则新端点会被套信封 —— 而探针方读不懂信封。
const PROBE_PATHS = new Set(['/health', '/health/ready']);
/**
* ⭐⭐ 手机号遮罩 —— **全系统唯一一处**。
*
* ═══ ⚠️⚠️ 为什么必须唯一:原来有 5 份、3 种行为 ═══════════════════════════════
*
* 2026-09-01 扫代码时对出来的(同一个号 `15888834114`):
* ```
* password.service.ts `*`×(len-4) + 后4 → **********4114 (入参是 E.164)
* contact.service.ts 同上 → *******4114
* friend-add.service.ts 剥非数字 → 前3+****+后4 → 158****4114
* conversation.service.ts 前3+****+后4(不剥) → 158****4114
* agent-task.service.ts 同上 → 158****4114
* ```
* ⇒ **同一位患者,通讯录页显示 `*******4114`、会话页显示 `158****4114`**。
* 两处都对不上不只是丑:把两屏拼起来,看到的比任何一屏单独给的都多。
* 而遮罩这件事的意义恰恰在于"到此为止"。
*
* ⚠️ `conversation` / `agent-task` 那两份还**不剥非数字**。库里现在都是裸 11 位所以没露怯,
* 但 `remark_phones` 是**人手填的备注**(`+86 158 8883 4114` / `158-8883-4114` 都可能)——
* 那时 `slice(0,3)` 切出来的是 `+86`,遮罩变成 `+86****4114`:既没帮上人认号,
* 又白露了一段。⇒ 这里先归一化。
*
* ═══ 口径:`前3 + **** + 后4` ═══════════════════════════════════════════════
*
* ⚠️ 选它不是因为它更宽松,是因为**前 3 位在中国是运营商号段**(`138`/`158`…只有几十个值),
* 它几乎不携带身份信息,却是人区分两位联系人的主要线索。
* 而后 4 位是"确认是不是这个人"要用的。中间 4 位才是真正需要挡住的部分。
* ⛔ 完整号码**任何情况下都不下发前端** —— 它是患者识别的唯一路径。
*/
export function maskPhone(raw: string | null | undefined): string | null {
if (!raw) return null;
/**
* ⚠️ 先归一化:剥掉非数字,再剥掉中国区号。
* ⛔ 只对 `86 + 1[3-9]xxxxxxxxx` 这一种剥 —— 别写成"开头是 86 就剥":
* `8613...` 之外还有以 86 开头的固话和外国号,剥错了遮罩就落在别的位上。
*/
const digits = raw.replace(/\D/g, '');
const local = /^861[3-9]\d{9}$/.test(digits) ? digits.slice(2) : digits;
// ⚠️ 短到连"前3 + 后4"都排不下时**不要露任何一位** —— 那多半是脏数据,不是号码
return local.length >= 7 ? `${local.slice(0, 3)}****${local.slice(-4)}` : '***';
}
import { Global, Module } from '@nestjs/common';
import { RealtimeService } from './realtime.service';
/**
* ⚠️ `@Global()`:发布方在摄入侧(OutboxModule / OutboundModule),订阅方在工作台
* (WorkbenchModule)—— 三个模块各自 import 一遍只是噪音,而**总线必须是同一个实例**
* (Nest 的 provider 默认是模块级单例,不做全局就会出现"发布到了另一个总线上"
* 这种查半天的问题)。
*/
@Global()
@Module({ providers: [RealtimeService], exports: [RealtimeService] })
export class RealtimeModule {}
import { Injectable, Logger, type BeforeApplicationShutdown } from '@nestjs/common';
/**
* 「哪条会话动了」这一个信号。
*
* ⚠️⚠️ **刻意只推信号,不推内容。**
*
* 推内容意味着推送这条路上要再实现一遍列表/详情的**数据范围判定**
* (哪些号可见、哪些诊所可见、内部同事算不算患者…),而那正是最容易漏的地方 ——
* 漏一次就是把 A 诊所的患者对话推给了 B 诊所的坐席,而且**没有任何请求日志**。
* ⇒ 这里只说"某条会话有动静",前端拿着它**照常走 `GET /conversations`**,
* 于是鉴权、收窄、脱敏全部沿用同一条已经审过的路径。多一次往返,换掉一整类越权。
*/
export interface ConvEvent {
tenantId: string;
accountId: string;
conversationId: string;
/** 事件时刻(ISO)。⚠️ 是**这次推送**的时刻,不是消息的 send_time —— 别拿它排消息。 */
at: string;
}
/** 一条打开着的推送流。`send` 往里写事件,`close` 把它关掉(进程要退时用)。 */
interface Sub {
send: (e: ConvEvent) => void;
close: () => void;
}
/**
* 进程内的会话事件总线。
*
* ═══ 为什么不引 @nestjs/event-emitter,也不用 node:events ═══════════════════
*
* · `@nestjs/event-emitter` 买到的是"跨模块解耦的字符串事件名" —— 我们只有一种事件,
* 收发两端都在本仓,解不解耦没有区别。仓里的规矩是「能不引就不引」。
* · `node:events` 免费,但它在**第 11 个监听者**上会打 MaxListenersExceededWarning ——
* 而 11 个浏览器标签页是完全正常的。那就得 `setMaxListeners(0)`,
* 等于把它唯一自带的保护关掉,剩下的就只是一个 Map<Set>。⇒ 直接写 Map<Set>。
*
* ═══ ⚠️ 单进程假设 ═══════════════════════════════════════════════════════════
*
* 这条总线**只在本进程内广播**。ai-service 现在是 systemd 里的**单实例**
* (deploy/systemd/ai-service.service,`Type=simple`,没有多副本),摄入 worker 和
* HTTP 在同一个进程里,所以成立。
* ⛔ 将来真要横向扩容,别在这里加"猜测式"的兜底轮询 —— 那时应该换成
* PG `LISTEN/NOTIFY`(库已经在,不用新组件),而**只需要改这个类**。
* 前端拿到的仍然是同一个信号,不用动。
*/
@Injectable()
export class RealtimeService implements BeforeApplicationShutdown {
private readonly logger = new Logger(RealtimeService.name);
private readonly byTenant = new Map<string, Set<Sub>>();
/**
* 全局订阅上限。
*
* ⚠️ 一条 SSE 就是一个**一直开着**的连接。浏览器崩了/网线拔了的连接不会立刻被感知,
* 没有上限的话它们只会越积越多(每条挂着一个 25 秒的心跳定时器)。
* 200 远高于真实用量(一个诊所十几个人、每人两三个标签页),够用又封住了失控。
*/
private static readonly MAX_TOTAL = 200;
private total = 0;
/** 订阅本租户的会话事件。返回退订函数 —— ⚠️ 调用方**必须**在连接关闭时调它。 */
subscribe(tenantId: string, sub: Sub): (() => void) | null {
if (this.total >= RealtimeService.MAX_TOTAL) {
this.logger.warn(
`实时订阅已达上限 ${RealtimeService.MAX_TOTAL} —— 本次拒绝。` +
'若不是真有这么多人在线,多半是有连接没有被正确关闭。',
);
return null;
}
const set = this.byTenant.get(tenantId) ?? new Set<Sub>();
set.add(sub);
this.byTenant.set(tenantId, set);
this.total += 1;
return () => {
if (!set.delete(sub)) return; // 幂等:重复退订不该把计数减两次
this.total -= 1;
if (set.size === 0) this.byTenant.delete(tenantId);
};
}
/**
* 广播。
*
* ⚠️⚠️ **绝不能抛**。调用点在入站/出站的消息落库之后 —— 一个写不出去的 SSE 连接
* 把异常冒到那里,就变成"推送失败 ⇒ 这条消息处理失败 ⇒ 重试 ⇒ 死信"。
* 消息是不可重拉的,为了一个刷新信号赔掉一条患者消息,荒谬。
*/
publish(e: ConvEvent): void {
const set = this.byTenant.get(e.tenantId);
if (!set?.size) return;
for (const sub of [...set]) {
try {
sub.send(e);
} catch (err) {
this.logger.warn(`推送给某个订阅者失败(已忽略):${(err as Error).message}`);
}
}
}
/**
* ⚠️⚠️ **进程要退时必须主动把这些流关掉。实测踩过:不关就退不掉。**
*
* `main.ts` 开了 `enableShutdownHooks()` —— 它会等"在飞的请求"跑完再退。
* 而一条 SSE 就是一个**永不结束的请求**:开着一个工作台标签页,
* `systemctl restart ai-service` 就会**一直挂着**,直到有人去 kill -9。
*
* 而这条链上的下一环更糟:ECom 回调**只重试 3 次、没有补拉接口**
* ([03 D13](docs/03-决策记录.md))。停机窗口拖长 = **真的丢患者消息**。
* ⇒ 优雅关闭是这个系统的硬要求,不能被一个"刷新信号"卡住。
*
* ⚠️⚠️ **必须是 `beforeApplicationShutdown`,不能是 `onApplicationShutdown`。**
* Nest 的关闭顺序是:
* onModuleDestroy → **beforeApplicationShutdown** → 关 HTTP 服务器 → onApplicationShutdown
* 而"关 HTTP 服务器"这一步正是**等在飞请求跑完**的那一步。
* 挂在 `onApplicationShutdown` 上等于排在它后面 —— 实测:那个钩子**根本不会被调到**,
* 进程就那么一直挂着(第一版就是这么写的,SIGTERM 后 15 秒没退)。
*
* ⚠️ 遍历前先拷一份:`close()` 会触发对端的 `req.on('close')` → 回调 `off()` →
* 在遍历中删元素。
*/
beforeApplicationShutdown(): void {
if (!this.total) return;
this.logger.log(`进程退出:主动关闭 ${this.total} 条实时连接`);
for (const set of [...this.byTenant.values()]) {
for (const sub of [...set]) {
try {
sub.close();
} catch {
// 关不掉就算了 —— 这一步不该阻止退出
}
}
}
this.byTenant.clear();
this.total = 0;
}
/** 诊断用:当前有多少条实时连接。 */
get connections(): number {
return this.total;
}
}
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* 请求级租户上下文。
*
* 由认证守卫在每个请求入口 `run()` 一次,之后同一异步链上的任何代码都能读到,
* 不必把 tenantId 一层层当参数传 —— 传参那种写法漏一处就是跨租户读。
*
* ⚠️ **系统任务(outbox worker / 定时作业 / 数据回填)故意没有上下文**。
* 它们跨租户跑,注入 tenantId 反而会让它们只处理一个租户的数据。
* 守卫读到 undefined 时不注入(见 tenant-guard.extension.ts),这是设计不是漏洞。
*/
export interface TenantContext {
tenantId: string;
userId: string;
/**
* ⭐⭐ **请求级备忘** —— 一次请求里算过的东西不再算第二遍。
*
* ⚠️⚠️ 它**不是缓存**,区别是硬的:生命周期就是这一次请求。
* `ScopeService.load()` 头上那条纪律(「不缓存,每次算 —— 范围收紧必须立即生效,
* 任何 TTL 都是一个越权继续存在的窗口」)**一个字都不用改**:
* 请求内答案不可能变,⇒ 这里没有任何窗口,下一个请求照旧重算。
*
* ⚠️ 存的是 **Promise 不是值** —— 同一次请求里并发问同一个问题
* (`Promise.all` 那几处)要共用同一次在飞的查询,⛔ 不是各发一次。
* ⚠️ 系统任务没有上下文 ⇒ 没有备忘 ⇒ 照旧每次都算。那是对的:
* 一个 cron 跑几小时,备忘就成了真缓存。
*/
memo: Map<string, Promise<unknown>>;
}
const als = new AsyncLocalStorage<TenantContext>();
export function runWithTenant<T>(ctx: Omit<TenantContext, 'memo'>, fn: () => T): T {
return als.run({ ...ctx, memo: new Map() }, fn);
}
/**
* 请求级备忘 —— 有上下文就记住,没有就照常算(见 {@link TenantContext.memo})。
*
* ⚠️ 失败的 Promise 也会被记住:同一次请求里重试同一个查询没有意义,
* 而且**结论必须一致** —— 一次请求里"第一次说没权限、第二次说有"是最难查的那种 bug。
*/
export function memoPerRequest<T>(key: string, compute: () => Promise<T>): Promise<T> {
const ctx = als.getStore();
if (!ctx) return compute();
const hit = ctx.memo.get(key);
if (hit) return hit as Promise<T>;
const p = compute();
ctx.memo.set(key, p);
return p;
}
/** 当前租户;系统任务里返回 undefined。 */
export function currentTenant(): TenantContext | undefined {
return als.getStore();
}
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import { Public } from './common/decorators/public.decorator';
import type { Response } from 'express';
import { PrismaService } from './prisma/prisma.service';
/**
* 探针。分成**存活**和**就绪**两个端点,不合并成一个 —— 合并会犯两个方向的错:
* · 只做静态返回 → 库挂了探针仍绿,LB 继续往死实例打流量
* · 只做查库 → 库抖一下 systemd 就把进程重启,而重启治不了库的问题,只会放大故障
*
* ⚠️ **两个端点都必须对未认证开放** —— 所以带 `@Public()`。
* 去掉的话探针拿 401 → systemd 判定不健康 → **无限重启**。
*/
@Controller('health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
/** 存活:进程还在就是绿。给 systemd / 容器 liveness 用。 */
@Public()
@Get()
live() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
/**
* 就绪:能查库才算绿。给 LB / 灰度切流用。
*
* ⚠️ 这是**全服务唯一一处刻意违反「HTTP 恒 200」信封契约**的地方,理由是探针方不解信封:
* LB 只看状态码。库挂了还回 200,LB 就永远不会把这个实例摘掉 —— 探针等于白做。
* ⇒ 不就绪时返回 **503**,body 里再给人看的原因。
* 信封由 wrap-response.interceptor.ts 的 PROBE_PATHS 跳过。
*
* ⚠️ 也刻意**不抛异常**:抛了会被 AllExceptionsFilter 包成 500/90000,
* 而 500 的语义是"服务自己崩了",跟"依赖不可用"是两回事,值班时的处置动作完全不同。
*/
@Public()
@Get('ready')
async ready(@Res({ passthrough: true }) res: Response) {
try {
await this.prisma.$queryRaw`SELECT 1`;
return { status: 'ok', db: 'up', timestamp: new Date().toISOString() };
} catch (e) {
res.status(HttpStatus.SERVICE_UNAVAILABLE);
return {
status: 'degraded',
db: 'down',
reason: e instanceof Error ? e.message : String(e),
timestamp: new Date().toISOString(),
};
}
}
}
/**
* Sentry 初始化 —— 必须在**任何其它模块之前**被 import(main.ts 首行),
* 这样 @sentry/node 的自动埋点(http / express 等)才能在库加载前挂上钩子。
*
* 与 pac-service 同源同配置(自部署 Sentry,只做 error 上报 + 基础 tracing)。
* DSN 缺省 → 整体 no-op,本地无 Sentry 也能跑。
*
* ⚠️ 用**独立**的 `AI_SENTRY_DSN` 而不是 `SENTRY_DSN`:同仓开发时两个 app 的 env 会串,
* 共用键名会让 friday-ai 的错误混进 PAC 的项目里 —— 出事时看板上分不清是谁挂了。
*
* ⚠️⚠️ **本文件读的是真·环境变量,读不到 `.env` 文件**。
* 它在 main.ts 首行被 import,那时 `ConfigModule.forRoot()` 还没执行(AppModule 尚未加载),
* 所以 dotenv 还没把 `.env` 灌进 process.env。
* ⇒ 生产没问题(systemd 的 `EnvironmentFile=` 是真环境变量);
* **本地把 DSN 写进 `.env` 不会生效** —— 要本地验 Sentry 就 `AI_SENTRY_DSN=... pnpm dev`。
* ⛔ 不要为了修这个在这里 import dotenv:那会让加载顺序出现两个真理源,
* 而 Sentry 必须在**任何**模块之前初始化的约束比这点便利重要。
*/
import * as Sentry from '@sentry/nestjs';
const dsn = process.env.AI_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? 'development',
release: process.env.SENTRY_RELEASE || undefined,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
profilesSampleRate: 0,
});
}
export const sentryEnabled = !!dsn;
import './instrument'; // ⚠️ 必须首行:Sentry 自动埋点需在其它模块加载前初始化
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
/**
* 请求体上限。
*
* ⚠️ **必须显式设** —— 不设就吃 body-parser 出厂默认的 100KB。
* pac-service 2026-07 在这上面栽过:对面按文档发合规的批量包,被 100KB 截住,
* 报的却是 500/90000,对面完全看不出是自己包太大。
*
* 取 2MB 的依据:入站回调最终要落 `webhook_deliveries.payload`,而那张表自己有
* `ck_webhook_payload_size CHECK (octet_length <= 262144)` = 256KB 的硬上限
* ([12 §10](docs/12-基础层数据库设计.md))。2MB 给了 8 倍余量 —— 够任何正常回调,
* 又远小于 pac-service 的 10MB(那是给 500 行病历批量用的,friday-ai 没有那种流量形态)。
*/
const BODY_LIMIT = '2mb';
async function bootstrap() {
// rawBody: true → req.rawBody 保留原始 bytes。ECom 回调若将来上验签,
// 必须按原始字节算 —— parsed JSON 的字段顺序不稳定,重新序列化算出来的签名对不上。
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: true,
rawBody: true,
});
app.useBodyParser('json', { limit: BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: BODY_LIMIT, extended: true });
app.use(helmet({ contentSecurityPolicy: false }));
const corsOrigins = app.get(ConfigService).getOrThrow<{ origins: string[] }>('cors').origins;
app.enableCors({
// 空 = 允许任意来源(本地开发)。生产的 .env 必须显式列出 ai-web 的域名。
origin: corsOrigins.length > 0 ? corsOrigins : true,
credentials: false,
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'Accept'],
});
// /health 不带前缀 —— systemd / LB 的探针不该知道业务路由前缀
app.setGlobalPrefix('api', { exclude: ['health', 'health/ready'] });
app.useGlobalFilters(new AllExceptionsFilter());
const config = app.get(ConfigService);
/**
* ⚠️ **trust proxy 决定 `req.ip` 是不是可信的**,而 ECom 回调的 IP 白名单
* (EcomCallbackGuard)整个建立在 `req.ip` 上。
*
* · 默认 **0 = 完全不信 `X-Forwarded-For`**。⛔ 别图省事写 `true`:
* 盲信 XFF = 任何人加一个 header 就能绕过白名单,那道闸等于没有。
* · 经 Caddy/Nginx 反代时填真实的代理层数(通常 1)。
*/
const hops = config.getOrThrow<{ trustProxyHops: number }>('ecomCallback').trustProxyHops;
app.set('trust proxy', hops > 0 ? hops : false);
/**
* ⚠️ 优雅关闭 —— 直推模式下这不是"锦上添花"。
*
* ECom 的回调**重试 3 次就放弃、没有补拉接口**,而消息不可重拉。
* 进程被 SIGTERM 掉的那一刻若有回调正在处理,不等它落库就退 = 那条永久丢。
* `enableShutdownHooks` + Nest 的 close 会先停止接收新连接、
* 把在飞的请求跑完,再关 Prisma 连接池。
*
* ⚠️ 它只覆盖**已经进来**的请求。**部署窗口本身仍然是有损的** ——
* 要零停机得靠双实例 / 端口接管,见 [03 D13](docs/03-决策记录.md) 的「影响」。
*/
app.enableShutdownHooks();
const port = Number(process.env.PORT ?? 3011);
await app.listen(port, '0.0.0.0');
Logger.log(`ai-service listening on http://localhost:${port}`, 'Bootstrap');
warnOnIngressMisconfig(config);
}
/**
* 起服务时把两类**会静默丢消息或静默放行**的配置喊出来。
*
* 这两条都不做成"起不来":本地开发天天这么跑。但生产上任一条命中都是真问题,
* 而它们的表现都是安静的 —— 不喊就没人会发现。
*/
function warnOnIngressMisconfig(config: ConfigService): void {
const isProd = process.env.NODE_ENV === 'production';
const cb = config.getOrThrow<{ allowedIps: string[]; secret: string }>('ecomCallback');
const relay = config.getOrThrow<{ enabled: boolean }>('relay');
if (cb.allowedIps.length === 0 && !cb.secret) {
const msg =
'回调入口 /api/channel/ecom/callback 当前**无任何准入控制** —— ' +
'ECom 回调没有验签,这等于公网可写。请配 AI_ECOM_CALLBACK_IPS(推荐)或 AI_ECOM_CALLBACK_SECRET。';
if (isProd) Logger.error(`🔴 ${msg}`, 'Bootstrap');
else Logger.warn(`⚠️ ${msg}(本地开发可忽略)`, 'Bootstrap');
} else if (cb.allowedIps.length === 0) {
Logger.warn('⚠️ 回调只有路径 secret 兜底 —— 拿到供应商固定出口 IP 后请配 AI_ECOM_CALLBACK_IPS', 'Bootstrap');
}
/**
* ⚠️ 反代后面没配 hops 的表现是**安静的拒绝服务**:`req.ip` 恒等于代理地址,
* 于是登录限流把全世界当成同一个 IP。平时看不出来,直到某天早上集体上班。
* ⛔ 这里只能"提醒",判不了真假 —— 真正的判定在 LoginThrottleGuard 里
* (有 XFF 头 + 对端是内网地址),那个只在确实被反代时才会喊。
*/
if (isProd && config.getOrThrow<{ trustProxyHops: number }>('ecomCallback').trustProxyHops === 0) {
Logger.warn(
'⚠️ AI_TRUST_PROXY_HOPS=0。若本服务在 Caddy/Nginx 后面,请按真实层数配置 —— ' +
'否则回调 IP 白名单永远匹配不上,登录限流也会把所有用户当成同一个 IP。',
'Bootstrap',
);
}
if (relay.enabled && isProd) {
Logger.error(
'🔴 生产环境配了 AI_RELAY_URL —— 中转只是本地开发桥([03 D13])。' +
'同时开直推和订阅两条摄入路径,"现在到底走哪条"会变成没人说得清的事。请取消配置。',
'Bootstrap',
);
}
}
bootstrap().catch((err) => {
console.error('Failed to bootstrap', err);
process.exit(1);
});
import { Module } from '@nestjs/common';
import { OutboundModule } from '../outbound/outbound.module';
import { PacModule } from '../pac/pac.module';
import { JobRunnerService } from './runtime/job-runner.service';
import { TaskFactory } from './runtime/task.factory';
import { CommonContextService } from './context/common-context.service';
import { LlmProviderService } from './llm/provider.service';
import { LlmRecorderService } from './llm/llm-recorder.service';
import { FactCacheService } from './llm/fact-cache.service';
import { ReceptionAdvisorService } from './llm/reception-advisor.service';
import { GetAppointmentTool } from './llm/tools/get-appointment.tool';
import { GetPatientBriefTool } from './llm/tools/get-patient-brief.tool';
import { AgentInboundHandler } from './inbound/agent-inbound.handler';
import { ReplyModelService } from './scenes/appointment-confirm/reply-model.service';
import { AppointmentConfirmService } from './scenes/appointment-confirm/appointment-confirm.service';
import { ReceptionService } from './scenes/reception/reception.service';
/**
* Agent 层 —— [14](docs/14-Agent层数据库设计.md) / [15](docs/15-Agent实现规准.md) 的运行时。
*
* ═══ 三层,加场景只动第三层 ═══════════════════════════════════════════════════
*
* ```
* runtime/ 与业务无关:动作注册表 · Job 落账 · Task get-or-create
* context/ 公共层上下文装配 —— 所有场景共用([15 §5.1])
* shared/ 跨场景的领域词表(医疗信号)—— ⛔ 别在场景里各抄一份
* llm/ 模型能力:provider(千问/DashScope)· agent loop · 工具 · 输出守卫 · 事实账本 · llm_calls
* inbound/ 「患者说话了」的分发器 —— 一条消息**一次**调用([15 §1.2])
* scenes/ ⭐ 一个场景一个文件夹。加场景 = 加一个文件夹 + 在这里登记一行
* ```
*
* ⚠️ **两个场景用的机制完全不同,这是刻意的**:
* · 预约确认 —— **一次模型调用都没有**(触发是 SQL、话术是模板、回复归类是规则)
* · 通用接待 —— **agent loop + 工具**(它要读懂自由文本,而且可能要现查预约)
* ⛔ 别因为模块叫 agent 就默认每个场景都要起 loop:那更贵、更慢、更不可复现。
* 判据见 [15 §1]:**能用规则算出来的就不要问模型**。
*
* ═══ ⚠️ 只导出给**定时器**,别当通用服务用 ═══════════════════════════════════
*
* 这一层是**出口**不是入口:定时器叫醒它,它去调下面的。
* 导出 `AppointmentConfirmService` 唯一的消费者是 `SchedulerService` ——
* [15 §1] 的「模型不会主动醒来,总得有东西叫它」。
*
* ⛔ 别为了"在别处也触发一下"去扩大导出面:绕过这一层直接发消息,
* 就绕过了 Task/Job 的落账,而那是审计链成立的唯一依据([15 §6])。
* 要"从界面上手工跑一次",加一个控制器**在这个模块里**。
*/
@Module({
imports: [PacModule, OutboundModule],
providers: [
TaskFactory,
JobRunnerService,
CommonContextService,
LlmProviderService,
LlmRecorderService,
FactCacheService,
GetAppointmentTool,
GetPatientBriefTool,
ReceptionAdvisorService,
ReplyModelService,
AppointmentConfirmService,
ReceptionService,
AgentInboundHandler,
],
// ⚠️ `AgentInboundHandler` 导出给 OutboxModule 登记进处理器清单;
// ⛔ 它不是给业务代码调的,业务侧一律走 outbox 入队。
// ⚠️ `ReceptionService` 导出只为了 CLI 预览上下文(见 agent:context)。
// ⚠️ `FactCacheService` 导出是**让外面把"数据变了"这个信号送进来**(invalidate),
// ⛔ 不是把 Agent 的能力放出去 —— 上面那条"别扩大导出面"说的是后者。
// ⇒ 外面只该调 `invalidate`,⛔ 别去 `put`/`fresh`。
exports: [AppointmentConfirmService, ReceptionService, AgentInboundHandler, FactCacheService],
})
export class AgentModule {}
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { RealtimeService } from '../../../common/realtime/realtime.service';
import { TASK_TYPE } from '../../outbox/outbox.types';
import type { ClaimedTask, HandlerResult, OutboxHandler } from '../../outbox/outbox.types';
import { AppointmentConfirmService } from '../scenes/appointment-confirm/appointment-confirm.service';
import { ReceptionService } from '../scenes/reception/reception.service';
interface AgentInboundPayload {
conversationId: string;
/** 通道侧的消息 id —— 排错时靠它回到那条原文 */
appInfo: string;
text: string | null;
}
/**
* 「患者说话了」的分发器。
*
* ═══ ⭐ 为什么是**一个**处理器而不是每个场景一个 ═══════════════════════════
*
* [15 §1.2](docs/15-Agent实现规准.md):
*
* > **一条消息一次调用一条 `llm_calls`,不是两条。**
* > 也不需要「便宜模型粗筛 + 贵模型确认」—— 本来就只有一次调用。
*
* 每个场景各挂一个 outbox 处理器的话,一条患者消息会产生 N 条 outbox 行、
* N 次上下文装配。⇒ **一条消息进来,一个分发器决定谁接**。
*
* ═══ 顺序:先专用,后通用 ═══════════════════════════════════════════════════
*
* ```
* ① 预约确认 ——「这句话是不是在回我刚才那个问题」。有活跃 Task 才轮得到它
* ② 通用接待 ——「这句话里有没有必须有人看的东西」
* ```
*
* ⚠️ **专用场景接住了就停** —— 患者回「好的,不过我牙有点疼」时,
* 预约确认已经把它判成 medical 并转了人工;接待再顶一条就是**同一件事两个待办**。
* ⚠️ 反过来不行:先跑接待的话,一句「好的」会被接待判成"没信号"什么都不做,
* 而预约确认那条 Task 永远停在 awaiting_patient。
*
* ⛔ 别把顺序做成配置 —— 它不是偏好,是语义:**具体的先于笼统的**。
*/
@Injectable()
export class AgentInboundHandler implements OutboxHandler {
readonly taskType = TASK_TYPE.AGENT_REPLY;
private readonly logger = new Logger(AgentInboundHandler.name);
constructor(
private readonly appointment: AppointmentConfirmService,
private readonly reception: ReceptionService,
private readonly prisma: PrismaService,
private readonly realtime: RealtimeService,
) {}
async handle(task: ClaimedTask): Promise<HandlerResult> {
const p = task.payload as AgentInboundPayload | null;
if (!p?.conversationId) {
return { ok: false, kind: 'dead', error: 'payload 缺 conversationId' };
}
/**
* ⭐ **开跑就推一次** —— 让会话里那块反馈区从「排队中」变成「AI 正在读…」。
* ⚠️ 模型这条链路实测 20–31 秒。不推的话那半分钟界面一个字都不说,
* 人会以为 AI 坏了(实测撞到:用户连问了两次同一个问题)。
* ⚠️ 拿不到 accountId 就不推,⛔ 不为此让整条处理失败 —— 它只是个提示。
*/
const accountId = await this.accountOf(p.conversationId);
if (accountId) this.publish(task.tenantId, accountId, p.conversationId);
try {
// ① 专用场景
const appt = await this.appointment.handleReply({
tenantId: task.tenantId,
conversationId: p.conversationId,
text: p.text,
});
if (appt.handled) {
this.logger.log(
`预约确认接住:判为「${appt.intent}」 task=${appt.taskId} msg=${p.appInfo}`,
);
return { ok: true };
}
// ② 通用接待 —— 这一版只做医疗信号兜底,见 ReceptionService 顶部
const rec = await this.reception.handle({
tenantId: task.tenantId,
conversationId: p.conversationId,
text: p.text,
// ⚠️ 发送的幂等键就靠它 —— outbox 重投时不会再发一遍给患者
appInfo: p.appInfo,
});
if (rec.handled && rec.via === 'rule') {
this.logger.log(
`接待·规则:「${rec.matched}」 task=${rec.taskId}(${rec.created ? '新建' : '复用'}) msg=${p.appInfo}`,
);
} else if (rec.handled) {
this.logger.log(
`接待·模型:${rec.intent} needsHuman=${rec.needsHuman} ` +
`${rec.sent ? '已发送' : '未发'} 草稿=${rec.draftSaved ? '已存' : '无'} ` +
`guard=${rec.guard} msg=${p.appInfo}`,
);
} else {
// ⚠️ debug 级 —— 稳态下绝大多数消息都会落到这里(没信号就该什么都不做)
this.logger.debug(`无动作:${appt.why}/${rec.why} conv=${p.conversationId}`);
}
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
/**
* ⚠️ 一律 retry:这条路上的失败几乎都是"库/PAC 暂时不行"。
* 真的是脏数据时,attempt 撞上限会自己进死信,那时有 last_error 可查。
* ⛔ 别在这里猜哪些是永久性的 —— 猜错的代价是**患者的消息被丢掉**,
* 而里面可能有一句「我牙龈出血了」。
*/
return { ok: false, kind: 'retry', error: msg };
} finally {
/**
* ⚠️ `finally` —— **成功失败都推**。失败时任务状态也可能已经动过一半
* (比如 Task 建好了、发消息那步炸了),界面照样该跟上。
* ⚠️ 推的只是"哪条会话动了",正文照旧走原来的接口取 ——
* 数据范围/遮罩沿用已经审过的路径。
* ⚠️ 拿不到 accountId 就不推(推了对面也过滤不到):⛔ 别为此多查一次库,
* 入站 payload 里本来就有。
*/
if (accountId) this.publish(task.tenantId, accountId, p.conversationId);
}
}
/**
* ⚠️ 现查 accountId —— 一次主键查询。⛔ 不加进 payload:
* 已经排在队里的那些拿不到它,会静默地不推送(而那正是要修的毛病)。
*/
private async accountOf(conversationId: string): Promise<string | null> {
const c = await this.prisma.conversation
.findUnique({ where: { id: conversationId }, select: { accountId: true } })
.catch(() => null);
return c?.accountId ?? null;
}
private publish(tenantId: string, accountId: string, conversationId: string): void {
this.realtime.publish({ tenantId, accountId, conversationId, at: new Date().toISOString() });
}
}
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import type { GuardVerdict } from './output-guard';
/**
* `llm_calls` 账本 —— **理由是举证,不是成本**([14 §5.1](docs/14-Agent层数据库设计.md))。
*
* > 医疗场景一旦有纠纷("AI 给我错误建议"),要能拿出当时模型输出了什么、
* > 哪些被 harness 拦下了、用的哪个模型哪版提示词。
* > **这些日志系统留不住** —— 会滚掉,而且不按合规标准存储和访问控制。
*
* ═══ ⚠️⚠️ 刻意**不存 prompt 全文** ═══════════════════════════════════════════
*
* [14 §5.2]:完整 prompt 里含患者姓名、诊断、对话内容 —— **存下来就是又一份患者
* 数据副本**,违反「不存患者副本」那条边界。
*
* ⇒ 存**重建它所需的输入**,不存结果:
* · `prompt_version`(模板版本) ⛔ 不是 prompt 全文
* · `context_refs`(装配了哪些上下文的**引用**)⛔ 不是上下文的内容
*
* ⚠️ 但 `output_text` **存原文**([14 §5.3]):被 harness 拦下的输出**不在
* `messages` 里** —— 发出去的话术在那边,没发出去的只有这里有。
* 「大部分情况重复,少数不重复 —— 而恰恰那少数最值钱。」
*
* ═══ 两条证据链,必须挂上一条 ═══════════════════════════════════════════════
*
* ```
* Task 驱动 llm_calls → jobs → tasks job_id 非空
* 会话接待 llm_calls → conversations conversation_id 非空
* ```
* `ck_llm_subject` 强制。接待走的是第二条 —— 它不建 Task 时也要能追溯。
*/
@Injectable()
export class LlmRecorderService {
private readonly logger = new Logger(LlmRecorderService.name);
constructor(private readonly prisma: PrismaService) {}
async record(input: {
tenantId: string;
/** 两条链至少一条 —— `ck_llm_subject` */
conversationId?: string | null;
jobId?: string | null;
/** Job 内第几次调用。接待一次一条,恒为 1 */
seq: number;
provider: string;
model: string;
promptVersion: string;
/** ⚠️ 只放**引用**,⛔ 不放内容。见类注释 */
contextRefs: Record<string, unknown>;
inputTokens: number;
outputTokens: number;
/** ⭐ 思考消耗。⚠️ 已含在 `outputTokens` 里,⛔ 别相加 */
reasoningTokens?: number;
/** ⭐ 命中前缀缓存的输入。⚠️ 已含在 `inputTokens` 里,⛔ 别相加 */
cachedInputTokens?: number;
/** ⭐ 原文 —— 被拦下的那些只有这里有 */
outputText: string | null;
finishReason: string | null;
/** ⭐ 模型**请求**了哪些工具([14 §5.5])。⚠️ `jobs` 里只有实际执行的,两者不同 */
toolCalls?: string[];
guard: GuardVerdict;
latencyMs: number;
error?: string | null;
}): Promise<void> {
try {
await this.prisma.$executeRaw`
INSERT INTO app.llm_calls (
tenant_id, conversation_id, job_id, seq, provider, model, prompt_version,
context_refs, input_tokens, output_tokens, reasoning_tokens, cached_input_tokens,
output_text, tool_calls, finish_reason,
guard_verdict, guard_reason, latency_ms, error)
VALUES (
${input.tenantId}::uuid,
${input.conversationId ?? null}::uuid,
${input.jobId ?? null}::uuid,
${input.seq},
${input.provider}, ${input.model}, ${input.promptVersion},
${JSON.stringify(input.contextRefs)}::jsonb,
${input.inputTokens}, ${input.outputTokens},
${input.reasoningTokens ?? null}, ${input.cachedInputTokens ?? null},
${input.outputText},
${input.toolCalls?.length ? JSON.stringify(input.toolCalls) : null}::jsonb,
${input.finishReason},
${input.guard.verdict},
-- ⚠️ ck_llm_guard_reason **双向**:blocked iff reason
${input.guard.verdict === 'blocked' ? input.guard.reason : null},
${input.latencyMs}, ${input.error ?? null})`;
// ⚠️ cost_micros / currency 一起留空 —— ck_llm_cost 要求它们同生共死。
// qwen3.8-max **没有公开单价**(PAC 那边 2026-08-13 查过),
// ⛔ 与其编一个数字,不如空着:空着是"不知道",填错是"知道但错了"。
} catch (e) {
/**
* ⚠️ 记账失败**不能**让业务失败 —— 这是账本不是主流程。
* 但要 error 级日志:举证记录漏了,是合规问题,不是小事。
*/
this.logger.error(`llm_calls 落库失败(业务继续):${(e as Error)?.message}`);
}
}
}
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModel } from 'ai';
import type { AppConfig } from '../../../config/configuration';
/**
* 模型工厂 —— 通义千问,走 DashScope 的 OpenAI-兼容端点。
*
* ═══ ⚠️⚠️ DashScope 的四个坑,全是 PAC 那边踩出来的 ═══════════════════════════
*
* 抄自 `pac-service/src/modules/ai/core/ai-provider.service.ts`,连理由一起搬:
*
* | # | 坑 | 对策 |
* |---|---|---|
* | 1 | `response_format=json_schema` **不被强制执行** —— 即便 `strict:true` 也返 200,但 key 全自己编(拿 `.describe()` 的中文当 key)→ Zod 一校验必 fail | `supportsStructuredOutputs: false` |
* | 2 | 但 `response_format=json_object` **是认的**(保证纯 JSON、不裹 markdown) | fetch 中间件注入 |
* | 3 | 用 `json_object` 时 DashScope **硬性要求 messages 含 "json" 字样**,否则 400 | 骨架提示文案里带「JSON」(见 `qwenSkeletonHint`) |
* | 4 | qwen 是**推理模型**,流式下 thinking 会污染 content | json mode 那条路 `enable_thinking:false` |
*
* ⚠️ 第 4 条**只在 json mode 关**。带 tools 的那条路(将来的 agent loop)不要关 ——
* PAC 那边 2026-08-14 记着:无条件关会让"要不要边写边调工具"的对比变成
* 「便宜档+会思考」对「最高档+不许思考」,比较本身不成立。
*
* ═══ 没配 key 怎么办 ═══════════════════════════════════════════════════════
*
* ⚠️ **返回 null,⛔ 不抛**。调用方拿到 null 就走规则兜底 ——
* [15 §4.4](docs/15-Agent实现规准.md) 的兜底终点是转人工,不是崩。
* 而启动期打一条 warn:漏配 key 的表现是"AI 从来不出草稿",
* 没有那条 warn 没人会想到是漏了一个 env。
*/
@Injectable()
export class LlmProviderService {
private readonly logger = new Logger(LlmProviderService.name);
private readonly model: LanguageModel | null;
readonly modelId: string;
readonly timeoutMs: number;
constructor(config: ConfigService<AppConfig, true>) {
const llm = config.get('llm', { infer: true });
this.modelId = llm.model;
this.timeoutMs = llm.timeoutMs;
if (!llm.apiKey) {
this.model = null;
this.logger.warn(
'⚠️ AI_LLM_API_KEY 未配置 —— 模型能力不启用。\n' +
' 通用接待会退回**纯规则**(只做医疗信号兜底),不会出回复草稿。\n' +
' key 到阿里云百炼 dashscope 申请,或与 PAC 共用同一个账号的 QWEN_API_KEY。',
);
return;
}
const qwen = createOpenAICompatible({
name: 'qwen',
apiKey: llm.apiKey,
baseURL: llm.baseUrl,
// 坑 1
supportsStructuredOutputs: false,
fetch: (async (url: string | URL | Request, options?: RequestInit) => {
if (options?.body && typeof options.body === 'string') {
try {
const b = JSON.parse(options.body) as Record<string, unknown>;
const hasTools = Array.isArray(b.tools) && b.tools.length > 0;
// 坑 4 —— ⚠️ 只在非工具调用时关思考,见类注释
if (!hasTools) b.enable_thinking = false;
// 坑 2 —— ⚠️ 带 tools 时**不能**注入:json mode 与工具调用互斥,且会 400
if (!hasTools && b.response_format === undefined) {
b.response_format = { type: 'json_object' };
}
options = { ...options, body: JSON.stringify(b) };
} catch {
/* 非 JSON body 不动 */
}
}
return fetch(url, options);
}) as typeof fetch,
});
this.model = qwen(llm.model);
this.silenceKnownWarning();
this.logger.log(`模型已启用:${llm.model} @ ${llm.baseUrl}`);
}
/**
* 关掉**那一条**已知警告,别的照常打。
*
* AI SDK 每次调用都会喊:
* > The feature "responseFormat" is not supported. JSON response format schema
* > is only supported with structuredOutputs
*
* ⚠️ 它说的是对的,而且**正是我们要的**:`supportsStructuredOutputs:false` 是
* 坑 1 的对策(qwen 不认 json_schema),json 模式由 fetch 中间件注入 `json_object`。
* ⇒ 这条警告是**预期行为的回声**,每次调用刷一遍是纯噪音。
*
* ⛔ 但**不要** `AI_SDK_LOG_WARNINGS = false` —— 那会把将来所有警告一起关掉,
* 包括"这个模型不支持你传的某个参数"这种真该看见的。
* ⇒ 只按 feature 名过滤这一条。
*/
private silenceKnownWarning(): void {
const g = globalThis as { AI_SDK_LOG_WARNINGS?: unknown };
if (g.AI_SDK_LOG_WARNINGS) return; // 已经有人设过就别抢
g.AI_SDK_LOG_WARNINGS = (o: { warnings?: { type?: string; setting?: string }[] }) => {
const rest = (o.warnings ?? []).filter(
(w) => !(w.type === 'unsupported-setting' && w.setting === 'responseFormat'),
);
for (const w of rest) this.logger.warn(`AI SDK:${JSON.stringify(w)}`);
};
}
/** `null` = 没配 key。⚠️ 调用方必须处理这一支,⛔ 别 `!` 断言过去 */
get(): LanguageModel | null {
return this.model;
}
get enabled(): boolean {
return this.model !== null;
}
}
/**
* 给 qwen 的**输出骨架提示**。
*
* ⚠️⚠️ 这不是"锦上添花",是坑 1 和坑 3 的对策:
* · 坑 1:qwen 不认 json_schema,会拿 `.describe()` 的中文当 key ⇒ 必须
* 把**英文 key 骨架**直接写进提示词里,它才知道该用哪些 key。
* · 坑 3:DashScope 用 json_object 时要求 messages 里出现 "json" 字样,
* 这段文案里的「JSON」正好满足 —— ⛔ 改文案时别把那两个字删了。
*
* ⚠️ 给骨架比给完整 JSON Schema 稳:后者里的 `description` 会被它当成 key。
*/
export function qwenSkeletonHint(skeleton: unknown): string {
return (
'\n\n【输出格式·硬约束】必须输出一个 JSON 对象,**严格使用下面骨架里的英文 key 与嵌套结构**' +
'(不要用中文 key、不要多余字段、不要 markdown 代码块);尖括号 <...> 处替换成实际内容:\n' +
JSON.stringify(skeleton)
);
}
/**
* JSON Schema → 紧凑「示例骨架」(英文 key 保留、嵌套保留、说明降级为 `<...>` 占位)。
* ⚠️ 与 PAC 的 `jsonSchemaToSkeleton` 同一套逻辑 —— 两边都在跟同一个模型打交道。
*/
export function schemaSkeleton(node: unknown): unknown {
if (!node || typeof node !== 'object') return '<...>';
const n = node as Record<string, unknown>;
if (Array.isArray(n.enum)) return `<${(n.enum as unknown[]).join('|')}>`;
if (n.type === 'array') return [schemaSkeleton(n.items)];
if (n.type === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries((n.properties as Record<string, unknown>) ?? {})) {
out[k] = schemaSkeleton(v);
}
return out;
}
if (n.type === 'boolean') return '<true|false>';
if (n.type === 'integer' || n.type === 'number') {
return `<数字${n.description ? '·' + String(n.description) : ''}>`;
}
// ⚠️ 允许 null 的字段要说清楚,否则 qwen 会填字符串 "null"
const nullable = Array.isArray(n.type) && (n.type as string[]).includes('null');
return `<${n.description ? String(n.description) : '文本'}${nullable ? ',没有就填 null' : ''}>`;
}
import { Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
import { PacService } from '../../../pac/pac.service';
import { pickUpcoming } from '../../scenes/appointment-confirm/appointment.reader';
import type { CommonContext } from '../../context/common-context';
import { recordInstant, type FactLedger } from '../fact-ledger';
/**
* `get_appointment` —— **第一个专用层工具**([15 §5.1](docs/15-Agent实现规准.md))。
*
* ═══ 为什么它是工具而不是预装配 ═══════════════════════════════════════════════
*
* [15 §5.1] 的分层:
* > 公共层预装配(**一定用得上**),专用层按需调用(**省 token 且灵活**)。
*
* 患者绝大多数消息跟预约无关(「好的」「几点下班」「谢谢」)。把预约塞进每一条
* 消息的上下文 = 每条消息一次 PAC 往返 + 一段没人看的 token。
* ⇒ **让模型自己判断要不要查**。
*
* ═══ ⭐ 它同时是幻觉检测的**数据来源** ═══════════════════════════════════════
*
* [15 §4.2]:「话术里出现的预约时间/医生名,**如果不在任何 tool 返回值里**
* → 判定幻觉」。⇒ 这个工具返回的时刻/日期会记进 `FactLedger`,
* 守卫拿草稿去对账。**没调工具就说时间 = 拦下**,这条现在是真的在跑。
*
* ═══ ⚠️ 三件它**做不到**的事,都写进了描述里 ═══════════════════════════════
*
* | 做不到 | 为什么 |
* |---|---|
* | 查号源 | PAC 开放面**没有任何号源/预约接口**(实测 grep 全模块) |
* | 改约 | 同上,而且 [15 §2.2]「只提案的用 `propose_` 前缀」 |
*
* ⭐ **「说出医生是谁」2026-08-31 从这张表里去掉了** —— 业务系统的同步补上了
* `content.doctor_name`(未来有效预约实测 280/280 都有)。返回的名字会记进账本,
* 守卫对医生姓名从"一律拦"改成"对账放行"。
* ⚠️ 但**可能为 null** —— 那时 `doctor` 就是 null,模型照旧不能提医生。
*
* ⚠️ 这些**必须写进工具描述**,不能只写在提示词里 —— 模型在**决定调不调**的时候
* 读的是工具描述([15 §2.2] 的命名规准同理)。写在别处它看不到。
*/
/** 工具返回给模型的形状 —— ⚠️ 字段名和值都要**它能直接说出口** */
export interface AppointmentToolResult {
found: boolean;
/** ⭐ 本地时间字符串,`YYYY-MM-DD HH:mm`。⚠️ 给 UTC 的 ISO 它会说错时区 */
plannedForLocal?: string;
/** 「正畸复查」这类 */
item?: string;
durationMinutes?: number;
/**
* 医生姓名。⚠️ **要显式给**(哪怕是 null)—— 缺字段和"没有医生"在模型眼里
* 不是一回事,后者它会照实说,前者它会去猜。
* ⚠️ 有值时会记进账本 ⇒ 模型可以说;⛔ 没值时说出任何医生名照旧被守卫拦下。
*/
doctor: string | null;
/** 给模型的一句话说明,⚠️ 比 schema 里的 description 更容易被它读到 */
note: string;
}
/**
* 工具的两个出口:给模型的 + 给缓存的。
*
* ⚠️⚠️ **`seed` 不能并进 `result`。** 里面是原始 ISO(UTC),
* 给模型看它就会引用「05:00」这种 UTC 时刻 —— 而患者要的是北京时间。
* ⇒ 模型只看 `result.plannedForLocal`;`seed` 只喂缓存和账本。
*
* ⚠️ 缓存里存**原始 ISO 而不是算好的账本条目**:重放时要用**当时的 now**
* 重算「今天/明天」—— 23:55 缓存的「明天」到 00:02 就该变成「今天」。
*/
export interface AppointmentToolOutput {
result: AppointmentToolResult;
seed: { instants: string[]; names: string[] };
}
export const GET_APPOINTMENT = {
name: 'get_appointment',
description:
'查这位患者**最近一次未来的预约**(时间、项目、时长、医生)。' +
'\n要在回复里提到预约的时间,**必须先调它**——你没有别的途径知道那个时间,编的会被拦下。' +
'\n`doctor` 有值时可以说「X 医生」;为 null 就别提医生(那条预约没记医生)。' +
'\n⛔ 它**查不了号源、也改不了约**。患者要改期就说会安排同事跟进,别自己许时间。',
/** ⚠️ 无参:患者是谁由**程序**从会话推出来,⛔ 不让模型传 patientId —— 那是越权的口子 */
parameters: z.object({}),
} as const;
const TZ = 'Asia/Shanghai';
/** 往后看多远。⚠️ 比预约确认那条线的 24h 宽 —— 这里是"他问起来",不是"我们主动提醒" */
const HORIZON_MS = 30 * 24 * 3600 * 1000;
@Injectable()
export class GetAppointmentTool {
private readonly logger = new Logger(GetAppointmentTool.name);
constructor(private readonly pac: PacService) {}
/**
* 执行。
*
* ⚠️ **患者身份来自 `ctx`,不来自模型的入参** —— [15 §2.3]:
* 「授权和审计必须在我们这边…我们要的是『这个 agent 这一次只能读这一个患者』」。
* 模型传 patientId 的话,一次提示词注入就能让它去读别人的病历。
*
* ⚠️ 一对多时取**第一位**并在 note 里说明 —— 同接待建 Task 那条。
*
* @param ledger ⭐ 查到的时刻会**记进账本**,守卫据此判断草稿有没有编时间。
*/
async run(
ctx: CommonContext,
tenantId: string,
ledger: FactLedger,
now = new Date(),
): Promise<AppointmentToolOutput> {
if (ctx.patients.length === 0) {
return {
result: { found: false, doctor: null, note: '这个微信号还没对上患者,查不了预约。' },
seed: { instants: [], names: [] },
};
}
const p = ctx.patients[0]!;
try {
const items = await this.pac.getProfilesForAccount(tenantId, ctx.accountId, [p.hostPatientId], {
factTypes: ['appointment_record'],
include: ['facts'],
factLimit: 40,
});
const appt = pickUpcoming(items[0]?.facts ?? [], {
from: now,
to: new Date(now.getTime() + HORIZON_MS),
})[0];
if (!appt) {
return {
result: { found: false, doctor: null, note: '他名下没有未来的预约。⛔ 别说"您的预约"。' },
seed: { instants: [], names: [] },
};
}
// ⭐ 记进账本 —— 这一行是幻觉检测能成立的全部依据
recordInstant(ledger, appt.plannedFor, now);
const local = new Intl.DateTimeFormat('sv-SE', {
timeZone: TZ,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(appt.plannedFor));
const many =
ctx.patients.length > 1
? `⚠️ 这个微信号还对应另外 ${ctx.patients.length - 1} 位患者,这是其中一位的预约。`
: '';
return {
// ⭐ 医生姓名进 seed ⇒ 记进账本 ⇒ 模型说出「刘柳医生」时守卫对得上。
// ⚠️ 没有医生时**不塞空串**:账本里有个空串会让任意"X 医生"都放行。
seed: { instants: [appt.plannedFor], names: appt.doctorName ? [appt.doctorName] : [] },
result: {
found: true,
plannedForLocal: local,
...(appt.complaintText || appt.complaintCategory
? { item: appt.complaintText ?? appt.complaintCategory ?? undefined }
: {}),
...(appt.durationMinutes ? { durationMinutes: appt.durationMinutes } : {}),
doctor: appt.doctorName,
// ⚠️ 两种情况说**两句不同的话** —— 「没记医生」和「不许提医生」对模型是两回事
note:
`时间是北京时间。` +
(appt.doctorName ? '' : '⛔ 这条预约没记医生,别提医生。') +
many,
},
};
} catch (e) {
/**
* ⚠️ PAC 挂了要**明确告诉模型**,⛔ 不要静默返回 `found:false` ——
* 那会让它说出「您名下没有预约」,而真相是我们没查到。
* 两句话对患者的意义完全不同。
*/
this.logger.warn(`get_appointment 查 PAC 失败:${(e as Error)?.message}`);
return {
result: {
found: false,
doctor: null,
note: '⚠️ 查不到(系统暂时读不到预约数据)。⛔ 别说"您没有预约",说会让同事确认。',
},
seed: { instants: [], names: [] },
};
}
}
}
/**
* 提示词分层 —— 抄 PAC 助手的分块法(`assistant-prompts.ts`),连理由一起搬。
*
* ```
* ① 装置 你是谁、和使用者什么关系、你看不见什么 恒定
* ② 诚实 数从哪来、什么不许编 恒定
* ③ 语气 怎么说话(整块可换) 恒定(将来按品牌换)
* ④ 授权 ⭐ 你手上有哪些工具、各能做什么 **从实际工具清单生成**
* ⑤ 现场 这一刻是什么情况 **每次装配**
* ```
*
* ⚠️ 顺序是「先通用后特殊」—— ⑤ 摆最后。PAC 那边的原话:
* 「④ 角色:只写权责边界,⛔ 不写他在做哪件事;⑤ 现场:他此刻在做的那条业务线」。
*
* ═══ ⭐⭐ 为什么 ④ 必须生成,不能写死 ═══════════════════════════════════════
*
* 上一版提示词里硬写着:
* > 要在草稿里提到**预约的具体时间**,必须先调 `get_appointment`。
*
* 而 `get_appointment` 在**没绑患者时根本不该出现在工具清单里**(它只能返回
* 「这个微信号还没对上患者」)。于是那种会话里,提示词在教模型去调一个
* **它手上没有**的工具 —— 模型要么白试一次,要么学会"提示词说的不算数"。
*
* ⇒ ④ 由 `buildAuthorityLayer(names)` 从**这一次真实注册的工具**生成。
* 工具没注册,那句话就不出现。[15 §2.1]:**没有那个工具,模型就做不到那件事**。
*/
/** ① 装置 —— 你是谁,你看不见什么 */
export const IDENTITY = `你是一家口腔诊所的微信客服助手。你在帮同事**看**患者发来的消息,
判断这条要不要人来处理,并在有把握时给一份**回复草稿**。
## 你是什么
- 你**不直接对患者说话**。你写的草稿要经同事过目才可能发出去。
- 你看到的对话**只有我们接管这个微信号之后的**,更早的你看不到。`;
/**
* ② 诚实 —— ⚠️ 这一层里**只写"不许编什么"**,⛔ 不写"该调哪个工具"。
* 后者随工具清单变,属于 ④。混在一起就会出现"教它调一个没有的工具"。
*/
export const HONESTY = `## 不许编
- 你手上**只有工具给过的东西**。工具没给的具体信息(时间、日期、姓名、金额、地址、营业时段),
一律**不要写进草稿**——编出来会被系统拦下,草稿直接作废。
- 医生姓名**只有 \`get_appointment\` 返回 \`doctor\` 时才能说**(说成"X 医生")。
它是 null 或者你没调过这个工具,就⛔别提医生——编一个名字会被拦下。
- 不确定就说"我安排同事确认后回复您",⛔ 不要猜。`;
/** ③ 语气 —— ⚠️ **整块可换**。将来按品牌分租户时换的就是这一块 */
export const VOICE_DEFAULT = `## 草稿是发到**微信**里的
- 就是一条普通微信消息。⛔ 不要用 markdown(\`**加粗**\` \`# 标题\` \`- 列表\` 在微信里会**原样显示成符号**)。
- ⛔ 不要分点、不要小标题、不要空行排版。要说两件事就用逗号或者句号连起来。
- ⛔ 不要写"尊敬的客户""此致敬礼"这种邮件腔。就按平时微信上跟人说话那样。
- 称呼用姓氏("罗先生""王女士"),⛔ 不要直呼全名。
- 一句到两句话,别超过 200 字。`;
/**
* ⚠️ 医疗红线**单独一段**,而且不在任何可换的块里。
* [15 §3.1]:「这条要**单独成段**,不能混在一堆规则里 —— 它是这个产品
* 最不能出错的地方」。⛔ 换语气、换品牌、换场景都不许动它。
*/
export const REDLINE = `## 医疗红线(最重要,单独看)
患者提到症状、用药、疼痛、出血、肿胀、发热时:
**needsHuman 一律填 true。**
draft 只能写一句**交接话术**,而且必须同时说到两件事:
① 我们看到了,会马上安排同事联系他
② **如果情况紧急,让他直接去医院急诊**
除这两件事之外,⛔ 一个字都不许多:
- ⛔ 不许安抚("别担心""问题不大""很正常""不要紧"**都是安抚**)
- ⛔ 不许解释、不许说"一般来说"、不许猜可能是什么问题
- ⛔ 不许说"先观察几天"——那会让该去医院的人在家等
- ⛔ 不许提任何药名、不许给用药建议
- 拿不准怎么写就 draft 填 null。**填 null 也是对的**,同事照样会看到。`;
/** 每个工具在 ④ 层里的那几句 —— ⚠️ 与工具描述**互补不重复** */
const TOOL_BRIEF: Record<string, string> = {
get_appointment:
'- `get_appointment`:查他**最近一次未来的预约**。要在草稿里提预约时间,**必须先调它**。\n' +
' ⛔ 它查不了号源、也改不了约。患者要改期就说会安排同事跟进,**别自己许时间**。',
get_patient_brief:
'- `get_patient_brief`:查他的要点(**是否免打扰/已故**、禁忌、上次到诊多久、诊所希望谈的治疗)。\n' +
' 拿不准这话该不该你接的时候调它。它说了免打扰或已故:**只能转人工,draft 一律 null**。\n' +
' ⛔ 它不给病历不给诊断。「诊所希望谈的治疗」是运营机会,**不是医生开的单**。',
};
/**
* ④ 授权 —— **从这一次真实注册的工具生成**。
*
* ⚠️ 传进来的是 `tools` 对象的键,⛔ 不是一个手写清单 ——
* 手写的那份和真实注册的会漂,而漂的方向恰好是"教它调一个没有的工具"。
*/
export function buildAuthorityLayer(toolNames: readonly string[]): string {
const usable = toolNames.filter((n) => n !== 'submit' && TOOL_BRIEF[n]);
const lines = ['## 你手上有什么'];
if (usable.length === 0) {
/**
* ⚠️ 一个只读工具都没有时**要明说**。
* 不说的话模型会按常识假设"我应该能查到他的预约" —— 而它查不到,
* 于是要么编,要么答非所问。
*/
lines.push(
'- **这次你没有任何可以查东西的工具**(多半是这个微信号还没对上患者)。',
' ⇒ 凡是需要查才知道的(预约、病历、他是谁),一律 needsHuman=true、draft=null。',
);
} else {
for (const n of usable) lines.push(TOOL_BRIEF[n]!);
}
lines.push(
'',
'## 怎么收尾',
'想清楚之后,**调用 `submit` 交答案**。不要用普通文本回复。',
'- needsHuman = true:医疗问题、投诉、要退款、要改期/取消、明确要求找人、你读不懂。',
'- needsHuman = false:闲聊、你手上的工具答得了的。',
'- reason 用一句中文说清依据,给同事看,别说套话。',
'- 拿不准就 draft 填 null。**填 null 不是失败**,是正确的克制。',
);
return lines.join('\n');
}
/**
* 装配成一份完整的 system prompt。
*
* ⚠️ **⑤ 现场不在这里** —— 它是 `renderCommonContext` 的产物,拼在 `prompt` 而不是
* `system` 里。理由:system 是**稳定前缀**,而 DashScope 的隐式缓存按前缀命中
* (实测 4096 token 起、块大小 4096)。把每次都变的会话记录塞进 system,
* 就等于**永远没有可缓存的前缀**。
*/
export function buildSystemPrompt(input: {
toolNames: readonly string[];
/** ③ 语气可换 —— 省略用默认 */
voice?: string;
}): string {
return [
IDENTITY,
REDLINE,
HONESTY,
input.voice ?? VOICE_DEFAULT,
buildAuthorityLayer(input.toolNames),
].join('\n\n');
}
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
/** 触发源。⚠️ [14 §2.6] **刻意无默认值** —— 忘了填就写不进去,好过污染漏检统计。 */
export type TriggerSource = 'schedule' | 'fact_event' | 'conversation' | 'manual';
export interface CreateTaskInput {
tenantId: string;
/** `code@version` 拆开落两列;⚠️ [14 §2.2] 有 CHECK:agent 来源必须两者都有 */
agentCode: string;
agentVersion: string;
taskKind: string;
triggerSource: TriggerSource;
/** 给人看的自由文本 */
title: string;
/** SRS §9.6 必备 */
triggerReason: string;
hostCode: string;
hostPatientId: string;
/** 业务实例。⚠️ 必须同生共死(ck_tasks_object) */
hostObjectType?: 'opportunity' | 'diagnosis' | null;
hostObjectId?: string | null;
conversationId?: string | null;
orgUnitId?: string | null;
priority?: number;
scheduledAt?: Date;
/** 过期时刻 —— 过了这个点这件事就没意义了(预约都过完了) */
dueAt?: Date | null;
}
export type CreateTaskResult =
| { created: true; taskId: string }
/** ⭐ 已经有一个活跃的同类 Task —— **这不是错误**,见 `create` 的注释 */
| { created: false; taskId: string };
/**
* TaskFactory —— Task 的 **get-or-create**。
*
* ═══ ⭐ 为什么"已存在"不能当错误 ═══════════════════════════════════════════
*
* [15 §1.2](docs/15-Agent实现规准.md) 原话:
*
* > **`create_task` 必须是 get-or-create 语义**
* >
* > | 语义 | 后果 |
* > |---|---|
* > | 撞唯一键报错 | 模型拿到错误,可能重试,白烧一轮 |
* > | **返回已有 Task + 标明「已存在」** | ✅ 模型直接在它上面加 Job 或只回一句 |
*
* 由此得到那条容易被问反的结论:
*
* > **意图识别的产物是 Task;但已有活跃 Task 时,实际产物是那个 Task 下的一个 Job。**
* > **是 Task 还是 Job,取决于有没有活跃 Task —— 不取决于意图的类型。**
*
* ⇒ 调用方拿到 `created:false` 的正确反应是**继续往那个 Task 上挂 Job**,
* ⛔ 不是放弃、也不是重试。
*
* ═══ 它挡住的是什么 ═══════════════════════════════════════════════════════
*
* [15 §1.1] 的两条腿(事件驱动 + 定期兜底)**同时跑就必然重复生成 Task**。
* `uq_tasks_active` 七列部分唯一索引是唯一的防线,而这里是它唯一的入口。
*
* ⚠️ 两个维度是**踩出来的**,少一列就静默吞任务([14 §2.5]):
* · `host_object_type` —— Opportunity 与 Diagnosis 基于同一个 `diagnosis_record`,
* **id 极可能相同**;漏了它,`opportunity:X` 会把 `diagnosis:X` 的任务错误去重掉
* · `task_kind` —— 同一个 Opportunity 下「价格异议」和「方案未反馈」是两件事;
* 漏了它,后来的那件被**静默吞掉**
*/
@Injectable()
export class TaskFactory {
private readonly logger = new Logger(TaskFactory.name);
constructor(private readonly prisma: PrismaService) {}
async create(input: CreateTaskInput): Promise<CreateTaskResult> {
const objType = input.hostObjectType ?? null;
const objId = input.hostObjectId ?? null;
// ⚠️ ck_tasks_object:同生共死。在这里拦住,好过撞 DB CHECK 拿一条看不懂的报错
if ((objType === null) !== (objId === null)) {
throw new Error(`host_object_type / host_object_id 必须同生共死,收到 ${objType} / ${objId}`);
}
const scheduledAt = input.scheduledAt ?? new Date();
// ⚠️ ck_tasks_time_order:due_at >= scheduled_at
if (input.dueAt && input.dueAt < scheduledAt) {
throw new Error(`due_at(${input.dueAt.toISOString()}) 不能早于 scheduled_at`);
}
const inserted = await this.prisma.$queryRaw<{ id: string }[]>`
INSERT INTO app.tasks (
tenant_id, org_unit_id, host_code, host_patient_id,
host_object_type, host_object_id, conversation_id,
source_type, trigger_source, agent_code, agent_version,
task_kind, title, trigger_reason, priority, scheduled_at, due_at, status)
VALUES (
${input.tenantId}::uuid, ${input.orgUnitId ?? null}::uuid,
${input.hostCode}, ${input.hostPatientId},
${objType}, ${objId}, ${input.conversationId ?? null}::uuid,
'agent', ${input.triggerSource}, ${input.agentCode}, ${input.agentVersion},
${input.taskKind}, ${input.title}, ${input.triggerReason},
${input.priority ?? 3}, ${scheduledAt}, ${input.dueAt ?? null}, 'pending')
-- ⚠️⚠️ 这里的列表达式和 WHERE **必须和 uq_tasks_active 逐字一致**,
-- 否则 Postgres 推断不出用哪个索引,直接报 42P10(而不是走 DO NOTHING)
-- 索引定义见 migration 20260825100200_agent
ON CONFLICT (tenant_id, agent_code, task_kind, host_code, host_patient_id,
coalesce(host_object_type,''), coalesce(host_object_id,''))
WHERE agent_code IS NOT NULL AND status NOT IN ('completed','closed')
DO NOTHING
RETURNING id`;
const id = inserted[0]?.id;
if (id) return { created: true, taskId: id };
// ── 撞上了:把那个活跃的捞出来 ──
const existing = await this.prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM app.tasks
WHERE tenant_id = ${input.tenantId}::uuid
AND agent_code = ${input.agentCode}
AND task_kind = ${input.taskKind}
AND host_code = ${input.hostCode}
AND host_patient_id = ${input.hostPatientId}
AND coalesce(host_object_type,'') = coalesce(${objType}, '')
AND coalesce(host_object_id,'') = coalesce(${objId}, '')
AND status NOT IN ('completed','closed')
LIMIT 1`;
const found = existing[0]?.id;
if (!found) {
// ⚠️ 走到这里说明**冲突的那一行刚刚被终结了**(另一个进程同时把它 completed 了)。
// 罕见但真实。⛔ 不要静默返回一个假 id —— 抛出去让调用方重试一次是对的。
throw new Error(
`Task 冲突但捞不到活跃行(可能刚被终结):${input.agentCode}/${input.taskKind}/${input.hostPatientId}`,
);
}
this.logger.debug(
`Task 已存在,复用 ${found} —— ${input.taskKind} @ ${input.hostPatientId}(${input.triggerSource} 触发)`,
);
return { created: false, taskId: found };
}
/**
* 关闭一个 Task。
*
* ⚠️ `completed` 与 `closed` 的分工([14 §2.2] 约束 ③):
* · `completed` = **业务结果达成了**,必须给 `outcome`
* · `closed` = 没达成但不用再跟了(过期 / 取消 / 重复),必须给 `closedReason`
* ⛔ 别拿 `closed` 当"做完了"用 —— 那会让完成率统计全错。
*/
async finish(
tenantId: string,
taskId: string,
end: { status: 'completed'; outcome: string } | { status: 'closed'; closedReason: string },
): Promise<void> {
await this.prisma.$executeRaw`
UPDATE app.tasks
SET status = ${end.status},
outcome = ${end.status === 'completed' ? end.outcome : null},
closed_reason = ${end.status === 'closed' ? end.closedReason : null},
-- ck_tasks_finished **双向**:终态 iff 有终态时刻
finished_at = now(), updated_at = now()
WHERE tenant_id = ${tenantId}::uuid AND id = ${taskId}::uuid
AND status NOT IN ('completed','closed')`;
}
}
/**
* 读患者对「明天的预约能来吗」的回复。
*
* ═══ ⚠️⚠️ 这一版**全是规则,没有模型** ═══════════════════════════════════════
*
* 两个理由,一个是原则一个是现实:
*
* ① [15 §1](docs/15-Agent实现规准.md) 的分工是「能用规则算出来的就不要问模型」。
* 这个问题的**答案空间只有五个**,而且患者的回话八成是「好的」两个字。
* 为这个起一次模型调用,是拿最贵的工具做最简单的活。
*
* ② 本环境**没有任何 LLM key**(pac-service / ai-service 的 .env 都没有)。
* ⇒ 就算写了模型分支也跑不起来,而**跑不起来的代码等于没写**。
*
* ⚠️ 但规则**不负责兜底** —— 认不出来就是 `unknown`,而 `unknown` **一律转人工**。
* [15 §4.4]:「所有兜底路径的终点都是 `escalate_to_human`。
* 医疗场景不该有『AI 自己想办法』的分支。」
* ⇒ 规则的目标是**高精度地认出那几种确定的**,⛔ 不是"尽量都归个类"。
*
* ═══ 判定顺序是安全顺序,⛔ 别调 ═══════════════════════════════════════════
*
* ```
* ① 医疗 任意位置命中 → 立刻转人工 ← 召回优先,宁可多转
* ② 取消
* ③ 改期
* ④ 确认 **整句精确匹配** ← 精度优先,见下
* ⑤ 其他 → unknown → 转人工
* ```
*
* ⚠️ ① 必须在 ④ 前面:「好的,不过我牙有点疼」如果先判确认就会**把一个医疗问题
* 当成确认关掉**。这是这个文件里最不能错的一条。
*/
import { detectMedicalSignal } from '../../shared/medical-signal';
export type ReplyIntent =
/** 会准时来 */
| 'confirmed'
/** 要改时间 */
| 'reschedule'
/** 不来了 */
| 'cancel'
/** 提到症状 / 用药 / 疼痛 —— ⚠️ 立刻转人工,⛔ 不安抚不解释 */
| 'medical'
/** 认不出来 —— ⚠️ 这不是失败,是**明确的"我不知道"** */
| 'unknown';
export interface Classification {
intent: ReplyIntent;
/** 命中了哪条规则 —— 落 `jobs.result`,事后能回答"它当时凭什么这么判" */
rule: string;
/** 命中的那个词 —— 同上 */
matched?: string;
}
/**
* ⚠️⚠️ 医疗词表**不在这个文件里** —— 在 `agent/shared/medical-signal.ts`。
*
* 原来这里有一份自己的 `MEDICAL` 数组,而通用接待那条路也要问同一个问题。
* 两份**必然漂移**:某天有人给其中一份加了「牙龈」,另一份没有,
* 于是同一句话在两条路上一个转人工一个不转 —— 而这正是
* [15 §3.1] 说的「这个产品最不能出错的地方」。
*
* ⇒ 词表抽走了,这里只调 `detectMedicalSignal`。⛔ 别把它抄回来。
*/
/** 取消词 —— 任意位置 */
const CANCEL = ['取消', '不来了', '不去了', '不做了', '算了', '退了'] as const;
/**
* 改期词 —— 任意位置。
*
* ⚠️ 「来不了 / 去不了 / 没空 / 有事」归**改期**而不是取消:
* 患者说"明天有事来不了"通常是要改时间,不是不治了。判成改期会走到人工那里,
* 人再决定;判成取消则会把这条治疗线**直接关掉**,而那是更贵的错。
*
* ⚠️⚠️ **这一组刻意比确认组"贪"** —— 两个方向的错代价差一个量级:
* · 把确认错判成改期 → 多转一次人工。**烦,但安全**。
* · 把改期错判成确认 → Task 关掉、没人再跟,**患者第二天没来才发现**。
* ⇒ 宁可让「不用改」这种句子也落到人工手里(它含「改」),
* 也不要为了少转几次而收窄这一组。
*/
const RESCHEDULE = [
'改期', '改约', '改时间', '改到', '改成', '改天', '换个时间', '换一天',
'换到', '换成', '调到', '挪到', '推迟', '延后', '往后', '提前', '早点', '晚点',
'来不了', '去不了', '到不了', '赶不上', '没空', '有事', '出差', '忙',
'能不能改', '可以改',
] as const;
/**
* 确认词 —— ⚠️⚠️ **整句精确匹配,不是包含匹配。**
*
* 这是本文件的第二条要紧规则。用包含匹配的话:
* · 「**好**像来不了」→ 命中「好」→ 判成确认 ⇒ 该改期的没改,患者白等
* · 「不**行**啊」 → 命中「行」→ 判成确认
* 而整句匹配把这一类彻底堵死:患者只要多说一个字,就落到 `unknown` 转人工 ——
* **多转一次人工是可接受的代价,判错方向不是。**
*/
const CONFIRM_EXACT = [
'好', '好的', '好嘞', '好呀', '好滴', '行', '行的', '可以', '没问题', '嗯',
'嗯嗯', '恩', '收到', '知道了', '明白', '会的', '会去', '会来', '准时到',
'准时', '到', '来', '去', 'ok', 'okay', 'k', '👌', '👍', '🆗', '是',
'对', '没错', '当然', '肯定',
] as const;
/**
* 归一化 —— 只做**不改变语义**的处理。
*
* ⚠️ 标点、空白、结尾语气词全去掉,这样「好的。」「好的~」「好的!」都能落到精确匹配。
* ⛔ 但**不做**同义词替换、不做繁简转换以外的重写:那些会让"精确匹配"名不副实。
*/
function normalize(text: string): string {
return text
.trim()
.toLowerCase()
/**
* 去掉常见标点与空白(含全角)。
* ⚠️ 全角空格写 `\u3000` 而不是直接敲那个字符 —— eslint 的
* `no-irregular-whitespace` 会报错,而且肉眼看不出区别(本仓库已栽过四次)。
*/
.replace(/[\s\u3000。,,.!!??~~、;;::\-—_"'"'()()【】[\]]/g, '')
// 去掉结尾语气词。⚠️ 只去**结尾**的:「啊呀」在句中可能是内容
.replace(/[啊呀吧呢哦噢喔嘛啦咯]+$/u, '')
/**
* ⚠️ 结尾的「的」也去 —— 它在这里是确定语气的助词:
* 会去**的** / 是**的** / 对**的** / 行**的** / 可以**的**。
*
* 安全性:这一步跑在医疗/取消/改期三组**之后**,能走到这里的句子已经排除了
* 那三类。剩下能被它变成确认词的实测只有「来的/对的/是的/行的/可以的/好的」——
* 在"明天能来吗"这个上下文里,它们**都确实是确认**。
*/
.replace(/的$/u, '');
}
/**
* 分类。
*
* @param text 患者原话。⚠️ [15 §3] 第 4 条:**原样传入,⛔ 不要预先总结** ——
* 「脸有点肿」和「术后不适」信息量差一个量级。
*/
export function classifyReply(text: string | null | undefined): Classification {
const raw = (text ?? '').trim();
if (!raw) return { intent: 'unknown', rule: 'empty' };
// ⚠️ 医疗判定用**原文**不用归一化后的:归一化会去掉标点,
// 而「牙-疼」这种写法去掉连字符反而更容易命中,不去也命中。用原文更直白。
const med = detectMedicalSignal(raw);
if (med) return { intent: 'medical', rule: 'medical_keyword', matched: med.matched };
const cancel = CANCEL.find((w) => raw.includes(w));
if (cancel) return { intent: 'cancel', rule: 'cancel_keyword', matched: cancel };
const resched = RESCHEDULE.find((w) => raw.includes(w));
if (resched) return { intent: 'reschedule', rule: 'reschedule_keyword', matched: resched };
const norm = normalize(raw);
if ((CONFIRM_EXACT as readonly string[]).includes(norm)) {
return { intent: 'confirmed', rule: 'confirm_exact', matched: norm };
}
// ⭐ 到这里就是"我不知道" —— ⛔ 别再猜。调用方会转人工。
return { intent: 'unknown', rule: 'no_match' };
}
/**
* `unknown` / `cancel` / `medical` 转人工时填的 `jobs.escalation_reason`。
*
* ⚠️ [14 §3.2] 这一列的用途是**统计哪条规则触发转人工最多**,
* 所以必须是**有限取值**,⛔ 不能是自由文本。
*/
export function escalationReasonOf(intent: ReplyIntent): string {
switch (intent) {
case 'medical':
return 'medical_question';
case 'cancel':
return 'appointment_cancel';
case 'reschedule':
return 'reschedule_request';
default:
return 'low_confidence';
}
}
import { Injectable, Logger } from '@nestjs/common';
import { generateText, hasToolCall, stepCountIs, tool } from 'ai';
import { z } from 'zod';
import { LlmProviderService } from '../../llm/provider.service';
import { LlmRecorderService } from '../../llm/llm-recorder.service';
import type { ReplyIntent } from './reply-classifier';
/**
* ⭐⭐ 规则读不懂时**问一次模型** —— 三层里的中间那层。
*
* ═══ 为什么加这一层 ═══════════════════════════════════════════════════════
*
* 这个场景本来**一次模型调用都没有**,理由写在 `appointment-confirm.service.ts`:
*
* > 读患者回复 | **规则**(整句精确匹配 + 关键词)| 答案空间只有五个,
* > 八成回话是「好的」两个字
*
* ⚠️⚠️ **那个"八成"被第一条真回复证伪了**(2026-08-31):
* 患者说「**我会准时到达**」—— 一句再清楚不过的确认,规则判成 `unknown`,
* 于是建了一条「需要人工参与」。
* 原因是确认组用的是**整句精确匹配**(那条规则本身是对的:包含匹配会让
* 「好像来不了」命中「好」)。但整句匹配天生处理不了**句子**,只能处理**词**。
*
* ⇒ 三层:**规则 → 模型 → 人工**。这正是 [15 §1]「触发是规则,处理是模型」。
* 规则命中的照旧(快、免费、可复现);规则读不懂的先问模型;模型也拿不准才转人工。
*
* ═══ ⚠️ 三条安全约束,一条都不能松 ═══════════════════════════════════════
*
* ① **医疗永远轮不到模型**:`classifyReply` 里医疗是第一条,命中就直接返回。
* 能走到这里的句子,规则已经确认它不含医疗词。
* ⚠️ 而模型**仍然可以判出 medical** —— 那是净收益:词表漏了的它可能认得。
* ⛔ 反过来不行:模型说别的,不代表可以推翻规则的医疗判定(那条根本到不了这里)。
*
* ② **拿不准就 `unknown`** —— 和接待那边同一条纪律。模型的价值是把
* "明显但规则没覆盖"的句子救回来,⛔ 不是去猜模棱两可的。
*
* ③ **失败一律回落 `unknown`**(超时 / 没配模型 / 输出不合法)——
* 那就是今天的行为(转人工)。⭐ 也就是说这一层**只可能把人工变少**,
* 不可能让结果比现在更差。
*/
/** ⚠️ 改提示词就要改它 —— `llm_calls.prompt_version` 靠它分组比较效果 */
const PROMPT_VERSION = 'appt-reply@2026-08-31-a';
/** ⚠️ 和 `ReplyIntent` 同一套取值 —— ⛔ 别让模型发明新档 */
const INTENTS = ['confirmed', 'reschedule', 'cancel', 'medical', 'unknown'] as const;
const SubmitSchema = z.object({
intent: z.enum(INTENTS).describe('患者这句话属于哪一档'),
reason: z.string().max(120).describe('一句中文说清依据,给同事看'),
});
/**
* ⚠️ 提示词**很短**是刻意的:这一层只做一件事,而且它跑在每一条读不懂的回复上。
* ⛔ 别把接待那套五层提示词搬过来 —— 那是给"要不要回、回什么"用的。
*/
const SYSTEM = `你在读一位口腔诊所患者对「预约确认」的回复。
我们刚问过他:「提醒您<某时间>有预约,请问能按时过来吗?如果时间需要调整,直接回复我就行。」
把他的回话归到下面五档之一:
- confirmed —— 他表示会来 / 会准时到 / 没问题
- reschedule —— 他想换个时间(包括"来不了""有事""晚点到"这类)
- cancel —— 他不来了、要取消
- medical —— 他提到症状、疼痛、出血、肿胀、发热、用药
- unknown —— 上面都不是,或者你**拿不准**
⚠️ 拿不准就选 unknown。**选 unknown 不是失败**——会有同事去看,那是安全的。
⛔ 不要为了给个答案去猜。判错方向的代价比多转一次人工大得多:
把"想改期"判成"会来",患者第二天没来才发现。
想清楚之后调用 submit 交答案。`;
@Injectable()
export class ReplyModelService {
private readonly logger = new Logger(ReplyModelService.name);
constructor(
private readonly provider: LlmProviderService,
private readonly llmLog: LlmRecorderService,
) {}
get enabled(): boolean {
return this.provider.enabled;
}
/**
* @returns null = 没结论(没配模型 / 失败 / 它自己说 unknown)⇒ 调用方按今天的路走(转人工)
*/
async classify(
text: string,
/**
* ⚠️⚠️ **举证用**,⛔ 不是可选的花边:[14 §5]「每次模型调用落一条 `llm_calls`」。
* `ck_llm_subject` 要求两条证据链至少有一条 —— 这里给会话 id。
*/
ref: { tenantId: string; conversationId: string },
): Promise<{ intent: ReplyIntent; reason: string; latencyMs: number } | null> {
const model = this.provider.get();
if (!model) return null;
const t0 = Date.now();
try {
const r = await generateText({
model,
system: SYSTEM,
prompt: `患者的回话(原文,⛔ 不要脑补上下文):\n「${text.slice(0, 500)}」`,
tools: {
submit: tool({
description: '交出判定结果',
inputSchema: SubmitSchema,
execute: async (a) => a,
}),
},
// ⚠️ 2 步就够:一次工具调用 + 一步余量。⛔ 这一层不该有多轮
stopWhen: [hasToolCall('submit'), stepCountIs(2)],
abortSignal: AbortSignal.timeout(this.provider.timeoutMs),
});
const raw = r.staticToolCalls?.find((c) => c.toolName === 'submit')?.input;
const parsed = SubmitSchema.safeParse(raw);
const latencyMs = Date.now() - t0;
/**
* ⭐ **记在判定之前** —— 同接待那条的理由([14 §5.1]):先记的话,
* 即使后面的分支出错,"模型当时说了什么"也留下来了。
* ⚠️ `contextRefs` 只放**引用**,⛔ 不放患者原话(那是又一份患者数据副本);
* 原话本来就在 `messages` 和 `parse_response` 的 result 里。
*/
await this.llmLog.record({
tenantId: ref.tenantId,
conversationId: ref.conversationId,
seq: 1,
provider: 'qwen',
model: this.provider.modelId,
promptVersion: PROMPT_VERSION,
contextRefs: { conversationId: ref.conversationId, textLength: text.length },
inputTokens: r.usage?.inputTokens ?? 0,
outputTokens: r.usage?.outputTokens ?? 0,
...(r.usage?.reasoningTokens !== undefined ? { reasoningTokens: r.usage.reasoningTokens } : {}),
...(r.usage?.cachedInputTokens !== undefined
? { cachedInputTokens: r.usage.cachedInputTokens }
: {}),
outputText: JSON.stringify(raw ?? r.text).slice(0, 2000),
finishReason: r.finishReason ?? null,
toolCalls: r.staticToolCalls?.map((c) => c.toolName) ?? [],
// ⚠️ 这一层**没有守卫** —— 它不产出给患者看的文本,只挑一个枚举值。
// ⛔ 别为了"格式统一"编一个假的 guard 结论。
guard: { verdict: 'passed' },
latencyMs,
});
if (!parsed.success) {
// ⚠️ 它不调 submit 直接说话 —— ⛔ 别去解析那段自由文本猜意图,那是又一处猜
this.logger.warn(`读回复:模型没按格式交答案,回落转人工(${latencyMs}ms)`);
return null;
}
// ⚠️ 它自己说 unknown ⇒ 和没结论一样处理,⛔ 别把 unknown 当一个"模型的判断"记功
if (parsed.data.intent === 'unknown') return null;
return { intent: parsed.data.intent, reason: parsed.data.reason, latencyMs };
} catch (e) {
// ⚠️ 超时/网络/限流 —— 一律回落。⛔ 不重试:患者在等,而转人工本来就是安全出口
const msg = e instanceof Error ? e.message : String(e);
this.logger.warn(`读回复:模型不可用,回落转人工:${msg}`);
// ⚠️ **失败也要记** —— 「它当时没答上来」和「它答错了」是两件事,
// 只记成功的话,事后算出来的可用率永远是 100%
await this.llmLog.record({
tenantId: ref.tenantId,
conversationId: ref.conversationId,
seq: 1,
provider: 'qwen',
model: this.provider.modelId,
promptVersion: PROMPT_VERSION,
contextRefs: { conversationId: ref.conversationId, textLength: text.length },
inputTokens: 0,
outputTokens: 0,
outputText: null,
finishReason: null,
guard: { verdict: 'passed' },
latencyMs: Date.now() - t0,
error: msg.slice(0, 500),
});
return null;
}
}
}
/**
* 接待层的医疗哨兵。
*
* ⚠️ 词表**不在这里** —— 在 `agent/shared/medical-signal.ts`,全 Agent 层唯一一份。
* 这个文件只是给接待场景一个语义清楚的入口名字。
* ⛔ 别在这里补词:补到 shared 那份去,否则预约确认那条路就漏了。
*/
export { detectMedicalSignal, type MedicalSignal } from '../../shared/medical-signal';
/**
* 医疗信号词表 —— **全 Agent 层唯一一份**。
*
* ═══ 为什么必须只有一份 ═══════════════════════════════════════════════════════
*
* 两个地方要问同一个问题「这句话里有没有医疗信号」:
* · 预约确认读患者回复时(`reply-classifier`)
* · 通用接待看每一条消息时(`medical-sentinel`)
*
* 各写一份的结果是**必然漂移**,而漂移的方向没人控制得住 ——
* 某天有人给其中一份加了「牙龈」,另一份没有,于是同一句话在两条路上一个转人工
* 一个不转。而这恰好是 [15 §3.1](docs/15-Agent实现规准.md) 说的
* 「**这个产品最不能出错的地方**」。
*
* ⇒ 词表在这里,两边都 import。⛔ 别在场景里再列一遍。
*
* ═══ 判据:召回优先 ═══════════════════════════════════════════════════════════
*
* 漏掉一个医疗信号的代价,比多转一次人工大得多。所以:
* ✅ 任意位置**包含**匹配(不是整句匹配)
* ✅ 宁滥勿缺
* ⛔ **不做否定判断**(「不疼了」也命中)—— 患者说"不疼了"本身就值得人看一眼,
* 而写否定判断会开一个必然写错的口子(「不怎么疼」「没那么疼」……)
*/
/**
* 信号分类 —— 给人做分诊用的提示,⛔ 不是给机器做决策用的。
*
* ⚠️ 三档都转人工,**分类不改变动作**。它只影响护士先看哪一条。
* ⛔ 别据此写出「一般症状可以先自动回一句」那种分支 ——
* [15 §3.1] 那段是**不许安抚**,没有例外档。
*/
export type SignalCategory = '急症' | '症状' | '用药';
export interface MedicalSignal {
/** 命中的那个词 —— 落 `jobs.result`,事后能回答"它凭什么顶这条" */
matched: string;
category: SignalCategory;
}
/**
* ⚠️ **顺序即优先级**:前面的先匹配。急症排最前,这样
* 「出血还有点疼」会被标成急症而不是一般症状。
*/
const TABLE: ReadonlyArray<{ words: readonly string[]; category: SignalCategory }> = [
{
// 术后并发症那一类 —— 拖一天可能就是事故
category: '急症',
words: [
// ⚠️ 裸的「血」也要 —— 「牙龈有血」「一直有血丝」都不含「出血/流血」。
// 抽词表时漏过一次,是测试补回来的。误报(血压/贫血/抽血)本来也都是医疗话题。
'血', '止不住', '发烧', '发热', '高烧',
'化脓', '脓', '肿', '胀', '张不开', '咽不下', '呼吸',
'晕', '休克', '过敏', '起疹', '掉了', '崩了', '裂了', '断了',
],
},
{
category: '症状',
words: [
'疼', '痛', '酸', '麻', '麻木', '松动', '发炎', '炎症', '溃疡',
'异味', '口臭', '塞牙', '咬合', '硌', '磨', '划',
'不舒服', '难受', '不适', '异常',
],
},
{
category: '用药',
words: ['吃药', '用药', '服药', '消炎药', '止痛', '止疼', '抗生素', '甲硝唑', '布洛芬'],
},
];
/**
* 找第一个命中的医疗信号。
*
* @param text 患者原话。⚠️ [15 §3] 第 4 条:**原样传入,⛔ 不要预先总结**。
* @returns `null` = 没命中。⛔ **不代表"没有医疗问题"** ——
* 只代表"规则认不出来"。真正的兜底是:认不出来的那一大片交给人/模型,
* ⛔ 不是当成没事。
*/
export function detectMedicalSignal(text: string | null | undefined): MedicalSignal | null {
const raw = (text ?? '').trim();
if (!raw) return null;
for (const group of TABLE) {
const hit = group.words.find((w) => raw.includes(w));
if (hit) return { matched: hit, category: group.category };
}
return null;
}
/** 全部词 —— 给测试和文档用。⛔ 别用它做匹配,用 `detectMedicalSignal` */
export const MEDICAL_WORDS: readonly string[] = TABLE.flatMap((g) => g.words);
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { BizError } from '../../common/errors/biz-error';
import { AiApiCode } from '../../common/errors/api-codes';
import { PasswordService, toE164 } from './password.service';
/**
* ⭐⭐ 开户激活 —— 管理员开了号,本人凭一次性票**自己**设手机号和密码。
*
* ═══ 为什么不是"管理员发临时密码" ═══════════════════════════════════════════
*
* 临时密码那条路上,管理员**知道过**对方的密码,而且转达出去的那串字
* (微信、口头、便利贴)**不会过期** —— 那条聊天记录一直躺在那儿。
* ⇒ 换成票:管理员只转发一条链接,**过期即废纸**,密码从头到尾只有本人知道。
*
* ⚠️⚠️ 票**仍然要人转发一次**(管理员用自己的微信发给他)。
* ⛔ 刻意**不**用我们托管的企微号自动发 —— 出站要经过 ECom(非官方中转),
* 而这张票就是一次开户凭据,交给供应商中转等于给了它一个先于本人使用的机会。
*
* ═══ ⚠️ 手机号为什么由本人填 ═══════════════════════════════════════════════
*
* 企微的内部通讯录接口(`[11036]` / `[11037]`)**不返回手机号**
* —— vendor 文档的 schema 里没有这个字段,库里 67 个内部成员也是 0 个有号。
* ⇒ 管理员开户时根本拿不到,只能本人填。⭐ 顺带也就没有"管理员输错一位"的问题,
* 而手机号是**登录账号本身**(全局唯一),输错的后果是建了个登不进去的号。
*/
@Injectable()
export class ActivationService {
private readonly logger = new Logger(ActivationService.name);
constructor(
private readonly prisma: PrismaService,
private readonly pw: PasswordService,
) {}
/** 打开激活页时先问一下:这张票还有效吗、是给谁的。 */
async peek(token: string): Promise<{ displayName: string; tenantName: string }> {
const row = await this.live(token);
return { displayName: row.user.displayName, tenantName: row.user.tenant.name };
}
/**
* 用票设手机号 + 密码。
*
* @returns 用户 id 和 tokenVersion —— 调用方据此**直接签令牌放人进去**。
* ⭐ 不让他再去登录页输一遍:他刚证明了持有这张票、又刚亲手设了密码,
* 再输一次是白走一步。
*/
async activate(
token: string,
input: { phone: string; password: string },
): Promise<{ userId: string; displayName: string; tenantName: string; tokenVersion: number }> {
const row = await this.live(token);
const phone = toE164(input.phone);
if (!phone) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '手机号格式不对');
/**
* ⚠️⚠️ 手机号**全局唯一**(跨租户)—— 登录时人只输手机号、不选诊所。
* ⛔ 撞号时**不能说"这个号已经注册过"**:那等于确认了某个手机号在这个系统里有账号,
* 而问的人可能只是在试号。⇒ 回一句中性的、并指向管理员。
*/
const taken = await this.prisma.user.findUnique({ where: { phone }, select: { id: true } });
if (taken) {
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '这个手机号不能用于激活,请联系管理员');
}
// ⚠️ 强度判据**复用同一处**(⛔ 别在这儿另写一份 —— 两份必然漂)
const weak = this.pw.checkStrength(input.password);
if (weak) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, weak);
const hash = await this.pw.hash(input.password);
await this.prisma.$transaction(async (tx) => {
/**
* ⚠️⚠️ **票要在同一个事务里作废,而且带条件**。
* `used_at IS NULL` 写进 WHERE ⇒ 两个人同时点同一条链接时,
* 只有一个 UPDATE 命中 —— ⛔ 不能先读后写,那之间是一个真实的窗口。
*/
const n = await tx.userActivation.updateMany({
where: { tokenHash: sha256(token), usedAt: null },
data: { usedAt: new Date() },
});
if (n.count !== 1) {
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '这个链接已经用过了');
}
await tx.user.update({
where: { id: row.userId },
data: {
phone,
passwordHash: hash,
passwordSetAt: new Date(),
// ⭐ 本人自己设的,⛔ 不是管理员发的临时密码 ⇒ 不置 mustChangePassword
mustChangePassword: false,
},
});
await tx.auditLog.create({
data: {
tenantId: row.tenantId,
action: 'user.activate',
objectType: 'user',
objectId: row.userId,
result: 'ok',
actorType: 'human',
actorUserId: row.userId,
// ⛔ 不记手机号明文、不记票 —— 前者是个人信息,后者是凭据
changes: { phoneSet: true },
},
});
});
this.logger.log(`账号激活 ${row.user.displayName}`);
const u = await this.prisma.user.findUniqueOrThrow({
where: { id: row.userId },
select: { tokenVersion: true },
});
return {
userId: row.userId,
displayName: row.user.displayName,
tenantName: row.user.tenant.name,
tokenVersion: u.tokenVersion,
};
}
/**
* 取一张**还活着**的票。
* ⚠️ 过期 / 用过 / 不存在 **回同一个错** —— 区分开等于告诉对方"这串是真的票"。
*/
private async live(token: string) {
const t = token.trim();
// ⚠️ 空票直接拒,⛔ 别拿空串去查(sha256('') 是一个确定值,等于给了个可撞的键)
if (!t) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '链接无效或已过期');
const row = await this.prisma.userActivation.findUnique({
where: { tokenHash: sha256(t) },
select: {
userId: true,
tenantId: true,
expiresAt: true,
usedAt: true,
user: { select: { displayName: true, isActive: true, tenant: { select: { name: true } } } },
},
});
if (!row || row.usedAt || row.expiresAt.getTime() < Date.now() || !row.user.isActive) {
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '链接无效或已过期');
}
return row;
}
}
/** ⚠️ 票只存哈希 —— 明文落库等于把开户凭据写在库里(同密码那条纪律) */
function sha256(s: string): string {
return createHash('sha256').update(s).digest('hex');
}
import { Global, Module } from '@nestjs/common';
import { ChannelModule } from '../channel/channel.module';
import { ScopeModule } from '../scope/scope.module';
import { AuthController } from './auth.controller';
import { JwtService } from './jwt.service';
import { ActivationService } from './activation.service';
import { QrLoginService } from './qr-login.service';
import { PasswordService } from './password.service';
import { PasswordLoginService } from './password-login.service';
import { LoginThrottleService } from './login-throttle.service';
import { LoginThrottleGuard } from './login-throttle.guard';
/** @Global:JwtAuthGuard 注册成全局守卫,需要在任何模块里都能注入 JwtService。 */
@Global()
@Module({
imports: [ChannelModule, ScopeModule],
controllers: [AuthController],
providers: [
ActivationService,
JwtService,
QrLoginService,
PasswordService,
PasswordLoginService,
LoginThrottleService,
// ⚠️ 路由级守卫也要能被注入 ⇒ 必须在这里登记,
// ⛔ 不注册成 APP_GUARD:它只该管登录那两条路,全局挂上会连轮询接口一起限。
LoginThrottleGuard,
],
exports: [JwtService, QrLoginService, PasswordService],
})
export class AuthModule {}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment