Commit 7be0b6da by luoqi

Merge verified FridayAI integration and unified login into main

parents 9f7ec1a4 4c3930dc
Pipeline #3730 failed in 0 seconds
.git
**/node_modules
**/dist
**/generated
**/.next
**/.next-*
**/coverage
**/.turbo
**/.env
**/.env.*
!**/.env.example
**/*.log
**/*.tsbuildinfo
**/*.dump
docs/qa
.claude
.DS_Store
deploy/secrets
**/*.pem
**/*.key
**/.test-dist
# 仅本地 Docker 基础设施;复制为 .env.local-infra,权限 600。
# 已有数据库改环境变量不会改库内密码,须填写当前有效凭据。
# 测试/生产使用各环境的专用配置,不使用这些本地默认值。
AI_POSTGRES_USER=postgres
AI_POSTGRES_PASSWORD=postgres
...@@ -4,6 +4,7 @@ node_modules ...@@ -4,6 +4,7 @@ node_modules
dist dist
build build
.next .next
.next-*
out out
coverage coverage
.turbo .turbo
...@@ -11,7 +12,9 @@ coverage ...@@ -11,7 +12,9 @@ coverage
.env .env
.env.local .env.local
.env.*.local .env.*.local
.env.*
!.env.example !.env.example
!.env.*.example
*.log *.log
npm-debug.log* npm-debug.log*
...@@ -46,3 +49,6 @@ ds-bundle/ ...@@ -46,3 +49,6 @@ ds-bundle/
.design-sync/learnings/ .design-sync/learnings/
.design-sync/node_modules .design-sync/node_modules
apps/pac-web/.ds-css/ apps/pac-web/.ds-css/
# 部署私钥仅留在服务器,不进入版本控制。
deploy/secrets/
# PAC CI # PAC CI
# #
# 目前只有一个 job:OpenAPI 文档「漂移闸」。 # PAC OpenAPI 文档漂移闸,以及 FridayAI/host-service 独立门禁。
# 文档站的 /docs/api 由 apps/pac-docs/openapi/pac.json 渲染,而该 spec 从 # 文档站的 /docs/api 由 apps/pac-docs/openapi/pac.json 渲染,而该 spec 从
# pac-service 的代码(nestjs-zod DTO)生成。本闸确保两者同步: # pac-service 的代码(nestjs-zod DTO)生成。本闸确保两者同步:
# 改了 API 但忘了重新导出 spec → 文档与真实接口对不上 → CI fail。 # 改了 API 但忘了重新导出 spec → 文档与真实接口对不上 → CI fail。
...@@ -9,10 +9,12 @@ ...@@ -9,10 +9,12 @@
stages: stages:
- docs - docs
- guards
- ai
openapi-drift: openapi-drift:
stage: docs stage: docs
image: node:22 image: node:22.23.2-bookworm
rules: rules:
# MR 里只在改了 service / types / spec 时跑(省 CI) # MR 里只在改了 service / types / spec 时跑(省 CI)
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
...@@ -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 过滤** ——
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' # 否则 apps/ai-service 的提交推到 main 也会触发整套 install + prisma generate +
# openapi dump,纯属白烧 CI。
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH || $CI_COMMIT_BRANCH == "test"'
changes:
- apps/pac-service/**/*
- packages/types/**/*
- apps/pac-docs/openapi/pac.json
cache: cache:
key: key:
files: files:
...@@ -33,6 +41,7 @@ openapi-drift: ...@@ -33,6 +41,7 @@ openapi-drift:
- corepack prepare pnpm@10.13.1 --activate - corepack prepare pnpm@10.13.1 --activate
- pnpm config set store-dir .pnpm-store - pnpm config set store-dir .pnpm-store
- pnpm install --frozen-lockfile - pnpm install --frozen-lockfile
- pnpm --filter './packages/*' build
# preview 模式不连 DB,但构建 AppModule 需要 prisma client + 一份 env # preview 模式不连 DB,但构建 AppModule 需要 prisma client + 一份 env
- pnpm --filter @pac/service prisma:generate - pnpm --filter @pac/service prisma:generate
- cp apps/pac-service/.env.example apps/pac-service/.env - cp apps/pac-service/.env.example apps/pac-service/.env
...@@ -48,3 +57,140 @@ openapi-drift: ...@@ -48,3 +57,140 @@ 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 的构建
# AI 的 job 不构建 PAC;共享包变更可能同时触发两组检查。
# 任一必需 job 失败仍会使整个 pipeline 失败。
# ═══════════════════════════════════════════════════════════════════════════
.ai-changes: &ai-changes
- package.json
- pnpm-workspace.yaml
- pnpm-lock.yaml
- docker-compose.yml
- .dockerignore
- apps/ai-service/**/*
- apps/ai-web/**/*
- packages/host-connector/**/*
- packages/utils/**/*
- packages/types/**/*
- packages/pac-client/**/*
- packages/ecom-client/**/*
- packages/design-tokens/**/*
- deploy/deploy-ai.sh
- deploy/ai-preflight.mjs
- deploy/tests/**/*
- docker-compose.ai.yml
- scripts/check-ai-boundaries.mjs
host-build:
stage: ai
image: node:22.23.2-bookworm
rules:
- changes:
- package.json
- pnpm-workspace.yaml
- pnpm-lock.yaml
- apps/host-service/**/*
- packages/host-connector/**/*
- packages/pac-client/**/*
- apps/ai-service/src/modules/scope/**/*
- apps/pac-service/src/modules/host-write/**/*
before_script:
- corepack enable
- corepack prepare pnpm@10.13.1 --activate
- pnpm install --frozen-lockfile
script:
- pnpm --filter './packages/*' build
- HOST_DATABASE_URL='postgresql://build:unused@localhost/unused?schema=host' pnpm --filter @pac/host-service prisma:generate
- pnpm --filter @pac/host-service type-check
- pnpm --filter @pac/host-service build
- pnpm --filter @pac/host-service test
.ai-setup: &ai-setup
image: node:22.23.2-bookworm
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 || $CI_COMMIT_BRANCH == "test"'
changes: *ai-changes
script:
- node scripts/check-ai-boundaries.mjs
- bash -n deploy/deploy-ai.sh
- node --test deploy/tests/*.test.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 || $CI_COMMIT_BRANCH == "test"'
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/ai-service^...' --filter '@pac/ai-web^...' build
# ⚠️⚠️ @pac/pac-client 同理,而且它**漏了很久** —— 实测删掉它的 dist 之后
# ai-service 的 type-check 报 4 处 TS2307「Cannot find module '@pac/pac-client'」。
# 它的 dist 是 gitignore 的 ⇒ CI 上不 build 就根本不存在。
# ⭐ 而它现在是**承重**的:开放面契约(窗口端点的必填 purpose)就靠它的 .d.ts
# 让漏传变成编译错误。不 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
channel-ingress:
stage: ai
image: node:22.23.2-bookworm
rules:
- changes:
- apps/channel-ingress/**/*
- docker-compose.ingress.yml
- deploy/deploy-ingress.sh
- pnpm-lock.yaml
- tsconfig.base.json
before_script:
- corepack enable
- corepack prepare pnpm@10.13.1 --activate
- pnpm install --frozen-lockfile
script:
- pnpm --filter @pac/channel-ingress type-check
- pnpm --filter @pac/channel-ingress lint
- pnpm --filter @pac/channel-ingress test
- pnpm --filter @pac/channel-ingress build
- bash -n deploy/deploy-ingress.sh
...@@ -15,7 +15,7 @@ packages/ ...@@ -15,7 +15,7 @@ packages/
types/ API/model/enum types shared by service and web types/ API/model/enum types shared by service and web
utils/ Crypto, date, template helpers utils/ Crypto, date, template helpers
docs/architecture.md docs/architecture.md
docker-compose.yml Local dev (postgres / redis / service / web) docker-compose.yml Local infrastructure (postgres / redis; ai, dw, apps profiles)
``` ```
## Architecture in one screen ## Architecture in one screen
...@@ -57,7 +57,9 @@ pnpm --filter @pac/service prisma:seed ...@@ -57,7 +57,9 @@ pnpm --filter @pac/service prisma:seed
# → seeds appId=demo-app / appSecret=demo-secret # → seeds appId=demo-app / appSecret=demo-secret
# 4. dev (parallel start of service + web) # 4. dev (parallel start of service + web)
pnpm dev pnpm --filter @pac/service dev
# another terminal
pnpm --filter @pac/web dev
``` ```
- Backend: http://localhost:3101 - Backend: http://localhost:3101
......
# FridayAI API:复制为 .env,权限 600。不要 source 此文件。
# UTC、端口、监听地址和连接池由代码/启动单元管理。
# 独立数据库;示例地址仅用于本地开发。
AI_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:55432/friday_ai
# 本地 Ed25519 私钥 PEM 或绝对路径,重启沿用;容器部署由 Compose 注入文件路径。
AI_JWT_PRIVATE_KEY=
# PAC 后端读取,不是 FridayAI 的网页地址。
AI_PAC_BASE_URL=http://127.0.0.1:3101
AI_PAC_READ_SECRET=
# 企微托管平台凭据。
AI_ECOM_BASE_URL=
AI_ECOM_ACCESS_ID=
AI_ECOM_ACCESS_SECRET=
# 正式部署使用持久中转,值与 channel-ingress 的 INGRESS_TOKEN 一致。
AI_INGRESS_TOKEN=
# 未启用持久中转时的直推回调 IP 白名单。
AI_ECOM_CALLBACK_IPS=
# 真实反向代理层数:本地 0,单层 Nginx 通常 1。
AI_TRUST_PROXY_HOPS=0
# 默认模型端点/型号由代码提供;更换供应商时另配 AI_LLM_BASE_URL / AI_LLM_MODEL。
AI_LLM_API_KEY=
# 首次部署保持 off,验收测试账号和待处理队列后再开启。
AI_OUTBOX_WORKER=off
AI_SCHEDULER=off
# 媒体固定使用 OSS(本地开发与服务器一致),缺少配置时拒绝启动。
AI_OSS_REGION=
AI_OSS_BUCKET=
AI_OSS_ACCESS_KEY_ID=
AI_OSS_ACCESS_KEY_SECRET=
# 共用 bucket 时按环境覆盖前缀,默认 friday-ai;前缀不代替 bucket/RAM 权限。
# AI_OSS_PREFIX=friday-ai-test
# 仅本地开发:公网持久中转(启用前移除旧 AI_RELAY_URL / AI_RELAY_TOKEN)。
# 本地开启后不配置 AI_INGRESS_TOKEN;服务器部署不配置以下两项。
AI_INGRESS_URL=
AI_INGRESS_PULL_TOKEN=
# 不消费消息时设 off;CLI 自动关闭,不影响正常业务服务。
# AI_INGRESS_SUBSCRIBER=off
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/"]
}
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=22.23.2-alpine3.24
FROM node:${NODE_VERSION} AS base
RUN apk add --no-cache libc6-compat openssl
RUN corepack enable && corepack prepare pnpm@10.13.1 --activate
WORKDIR /app
ENV TZ=UTC
FROM base AS deps
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc* ./
COPY turbo.json tsconfig.base.json ./
COPY apps/ai-service/package.json apps/ai-service/
COPY packages/types/package.json packages/types/
COPY packages/utils/package.json packages/utils/
COPY packages/host-connector/package.json packages/host-connector/
COPY packages/ecom-client/package.json packages/ecom-client/
COPY packages/pac-client/package.json packages/pac-client/
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY . .
RUN pnpm --workspace-concurrency=1 --filter '@pac/ai-service^...' build
WORKDIR /app/apps/ai-service
RUN AI_DATABASE_URL=postgresql://build:unused@localhost/unused pnpm exec prisma generate
RUN pnpm build && test -f dist/main.js
FROM base AS prod
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages ./packages
COPY --from=build /app/apps/ai-service/node_modules ./apps/ai-service/node_modules
COPY --from=build /app/apps/ai-service/dist ./apps/ai-service/dist
COPY --from=build /app/apps/ai-service/generated ./apps/ai-service/generated
COPY --from=build /app/apps/ai-service/prisma ./apps/ai-service/prisma
COPY --from=build /app/apps/ai-service/package.json ./apps/ai-service/package.json
COPY deploy/ai-preflight.mjs ./deploy/ai-preflight.mjs
WORKDIR /app/apps/ai-service
EXPOSE 3104
CMD ["node", "--enable-source-maps", "dist/main.js"]
[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",
"onboard:tenant": "ts-node --transpile-only src/cli/onboard-tenant.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",
"context:cache": "ts-node --transpile-only src/cli/context-cache.cli.ts",
"guard:disagreements": "ts-node --transpile-only src/cli/guard-disagreements.cli.ts",
"media:to-oss": "ts-node --transpile-only src/cli/media-to-oss.cli.ts",
"backfill:text": "ts-node --transpile-only src/cli/backfill-text.cli.ts",
"ai:integration": "ts-node --transpile-only src/cli/integration.cli.ts",
"ai:hosting": "ts-node --transpile-only src/cli/hosting-seat.cli.ts",
"ai:platform": "ts-node --transpile-only src/cli/platform.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/host-connector": "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",
"fflate": "^0.8.3",
"helmet": "^8.1.0",
"nestjs-zod": "^5.3.0",
"pngjs": "^7.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"silk-wasm": "^3.7.1",
"unpdf": "^1.8.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",
"@types/pngjs": "^6.0.5",
"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(明文);明文只在生成那一刻出现一次,⛔ 不落库不进日志';
-- ⭐ 把 6 个手写外键的 ON UPDATE 补成 CASCADE —— 对齐全库约定。
--
-- ═══ 为什么补这个:它本身无害,但它**藏漏洞** ═════════════════════════════
--
-- 这 6 个外键写在手写迁移里,当时只给了 ON DELETE,
-- ⚠️ Postgres 于是把 ON UPDATE 默认成 NO ACTION —— 而 Prisma 生成的关系
-- (全库另外 39 个外键)一律是 ON UPDATE CASCADE。
-- [12 §5] 已经写过这条约定:「ON UPDATE CASCADE 是**对齐 Prisma 生成物**用的」,
-- 只是后来这几张表建的时候漏了。
--
-- ⛔ 真正的代价不是行为差异(tenant_id / id 都是 uuid,主键值永远不会被 UPDATE,
-- 所以 ON UPDATE 这一支根本不会触发),而是:
--
-- `prisma migrate diff` 每次都固定吐出这 6 条「删掉再原样加回」的噪音。
-- ⇒ 真漂移就混在噪音里看不见了。实测代价:`llm_calls.cached_input_tokens`
-- 在 schema.prisma 里漏了一整天没人发现 —— Prisma 本来一直在报
-- 「Removed column cached_input_tokens」,只是没人从这 7 条里把它挑出来。
--
-- ⇒ 补完之后 migrate diff 的干净输出就是 **零行**。
-- ⭐ 那才是这条迁移的目的:让「有输出」本身成为信号。
--
-- ═══ ⚠️ fk_conv_handled_by 看着像改了语义,其实没有 ═══════════════════════
--
-- 它当初连 ON DELETE 都没写 ⇒ 现在是 NO ACTION,这里改成 RESTRICT。
-- 在 Postgres 里,**非 DEFERRABLE** 的外键上 NO ACTION 与 RESTRICT 行为完全一致
-- (区别只在能不能延迟到事务提交时再检查,而这条约束不是 DEFERRABLE)。
-- 之所以写 RESTRICT 而不是 NO ACTION:schema.prisma 里 handledBy 的复合外键
-- 含必填的 tenant_id ⇒ Prisma 按 Restrict 生成。跟着它写才能真正归零。
--
-- ═══ 锁 ═══════════════════════════════════════════════════════════════════
--
-- ADD CONSTRAINT 要全表校验,期间持 SHARE ROW EXCLUSIVE(挡写不挡读)。
-- 实测这几张表最大的 conversations 只有 113 行,users 5 行 ⇒ 毫秒级。
-- ⚠️ 将来表大了再补这类约束,改用 `ADD CONSTRAINT ... NOT VALID` +
-- `VALIDATE CONSTRAINT` 两步,避免长时间挡写。
-- ═══════════════════════════════════════════════════════════════════════════
-- ── app.conversation_reads ────────────────────────────────────────────────
ALTER TABLE app.conversation_reads DROP CONSTRAINT fk_cr_conv;
ALTER TABLE app.conversation_reads ADD CONSTRAINT fk_cr_conv
FOREIGN KEY (tenant_id, conversation_id) REFERENCES app.conversations (tenant_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE app.conversation_reads DROP CONSTRAINT fk_cr_user;
ALTER TABLE app.conversation_reads ADD CONSTRAINT fk_cr_user
FOREIGN KEY (tenant_id, user_id) REFERENCES app.users (tenant_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- ── app.friend_add_attempts ───────────────────────────────────────────────
ALTER TABLE app.friend_add_attempts DROP CONSTRAINT fk_faa_account;
ALTER TABLE app.friend_add_attempts ADD CONSTRAINT fk_faa_account
FOREIGN KEY (tenant_id, account_id) REFERENCES app.wecom_accounts (tenant_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE app.friend_add_attempts DROP CONSTRAINT fk_faa_actor;
ALTER TABLE app.friend_add_attempts ADD CONSTRAINT fk_faa_actor
FOREIGN KEY (tenant_id, actor_user_id) REFERENCES app.users (tenant_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- ── app.user_activations ──────────────────────────────────────────────────
ALTER TABLE app.user_activations DROP CONSTRAINT fk_ua_user;
ALTER TABLE app.user_activations ADD CONSTRAINT fk_ua_user
FOREIGN KEY (tenant_id, user_id) REFERENCES app.users (tenant_id, id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- ── app.conversations ─────────────────────────────────────────────────────
-- ⚠️ 见抬头:NO ACTION → RESTRICT 在这里不是语义变化
ALTER TABLE app.conversations DROP CONSTRAINT fk_conv_handled_by;
ALTER TABLE app.conversations ADD CONSTRAINT fk_conv_handled_by
FOREIGN KEY (tenant_id, handled_by_user_id) REFERENCES app.users (tenant_id, id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- ⭐⭐ 第三种终态:员工离职。
--
-- ═══ 为什么必须单独一档 ══════════════════════════════════════════════════════
--
-- 员工被企业管理员标记离职后,企微号**立即强制下线且永远登不回来**
-- (`docs/_vendor/ecom/doc-6876797.md`)。它下发的还是 `[11027]` 退出登录事件。
--
-- ⚠️⚠️ 而它原来会被判成 `rescan`(普通掉线):
-- · 封号判据是 `code = -11004 AND msg ~ 违法|违规|禁止登录|封号`,
-- 而离职文案是「你在 XX 的账号已被管理员删除」—— **一个词都不匹配**;
-- · 于是退回按 reasonType 归类,而离职的 reasonType = 1 =「其他端触发退出」
-- —— 和「你已在手机上退出企业微信」**完全一样**。
-- ⇒ 结果:号已经作废了,界面还在**永远催人重新扫码**,而那个号再也登不回来。
-- 对人员流动大的连锁诊所,这不是边角情况。
--
-- ═══ ⛔ 为什么不并进 banned ═══════════════════════════════════════════════════
--
-- 两者的**处置动作**相同(都是终态、都别自动重扫),但**对人的意思完全不同**:
-- banned 这个号出事了 —— 可能要找供应商、可能牵连整条通道的合规风险
-- dismissed 这个人走了 —— 换一个号托管就行,通道本身没毛病
-- 合成一档,界面就会对着离职员工说「账号被封禁」。那是假话,而且会引发不必要的排查。
-- ═══════════════════════════════════════════════════════════════════════════
-- 账号状态:加第三个终态
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','dismissed'));
-- 掉线后该干什么:同步加一档
ALTER TABLE app.wecom_accounts DROP CONSTRAINT ck_wecom_accounts_action;
ALTER TABLE app.wecom_accounts ADD CONSTRAINT ck_wecom_accounts_action
CHECK (offline_action IS NULL OR
offline_action IN ('rescan', 'verify', 'banned', 'dismissed', 'intended'));
-- ⛔ 不回填存量。历史上被误判成 rescan 的离职号,原始文案还在 offline_reason 里,
-- 但我们无法凭它区分"当时真的是离职"和"当时确实只是掉线"——
-- 猜一次就是在数据里写一条没有依据的结论。让它们保持原样,下次事件到达时自然纠正。
-- ⭐⭐ 「这个会话有没有已经决定要说、还没发出去的话」—— agent 每次装配上下文都要问一次。
--
-- ⚠️ 出站消息发送成功之后才落 app.messages,而"决定要说"到"发出去"之间隔着
-- 随机间隔 3–15 秒 + 按号串行排队(风控要求)。这段时间里 agent 看不到自己刚说的话,
-- 连发时就会一句话回三遍。补法是装配时顺带查一次 outbox —— 就是这个索引。
--
-- ⚠️ 三个谓词缺一不可,缺了就用不上这个部分索引:
-- status —— 只有在途的才算(sent 的已经在 messages 里,dead 的永远说不出口)
-- task_type —— 只有出站消息有 conversationId
-- ⛔ 别写成全表索引:outbox 是只增不删的,sent 行会一直堆着,
-- 而在途行任何时刻都只有个位数。
CREATE INDEX idx_outbox_inflight
ON app.outbox ((payload->>'conversationId'))
WHERE status IN ('pending', 'processing')
AND task_type IN ('outbound.ecom.text', 'outbound.ecom.media');
-- ⭐⭐ 新增终态 `canceled` —— 「这句话我们主动决定不发了」。
--
-- ⛔ **不复用 `dead`。** dead 的语义是「试到放弃」,是要人去分诊的;
-- canceled 是「判断之后主动不发」,一条都不用人管。混成一个值之后,
-- 死信队列就再也分不出「出事了」和「按规矩没发」——那正是它唯一的用处。
--
-- ⭐ 现有五处读 outbox 状态的地方用的都是 `IN ('pending','processing')` 白名单,
-- 新增一个终态对它们全部天然隐形,零改动。
ALTER TABLE app.outbox DROP CONSTRAINT ck_outbox_status;
ALTER TABLE app.outbox ADD CONSTRAINT ck_outbox_status
CHECK (status IN ('pending','processing','sent','dead','canceled'));
-- ⚠️⚠️ 作废时必须把 dedup_key 置 NULL —— `uq_outbox_dedup` **没有 status 谓词**,
-- 作废掉的那行会永远占着幂等键,之后想重新写一条同样的话会被
-- `ON CONFLICT DO NOTHING` **静默吞掉**。
-- ⛔ 不在这里改唯一索引:热表上 DROP/CREATE 唯一索引代价大,
-- 而 `dedup_key = NULL` 对任何路径造出来的 canceled 行都生效。
-- (ck_outbox_hash 只约束「有 dedup_key 就必须有 hash」,反向不约束,所以置 NULL 合法。)
COMMENT ON CONSTRAINT ck_outbox_status ON app.outbox IS
'pending|processing|sent|dead|canceled —— canceled 是主动不发,⛔ 和 dead 不是一回事';
-- ⭐⭐ 组织节点 → 宿主实体的映射,从「节点上的一列」改成「一张表」。
--
-- ═══ 为什么一列不够 ═══════════════════════════════════════════════════════
--
-- 一列同时假设了三件事,对接第二个宿主时三条全不成立:
-- ① 只有一个宿主 并购后两个品牌各一套系统
-- ② 一个节点最多一个坐标 迁移期新旧系统双档案
-- ③ 坐标是节点的固有属性 组织架构跟着租户变,坐标跟着宿主变,两者不同步
--
-- ⚠️ 而 source_system 记的是**通道名**('pac')不是宿主名。同一条通道接第二个宿主,
-- 两家店记的是同一个值,分不出谁是谁。宿主名今天在 tenants.partner_code 上。
--
-- ⭐ 患者那一侧早就是 (host_code, host_patient_id) 二元组 —— 这次是把组织树补齐。
CREATE TABLE app.org_unit_refs (
tenant_id uuid NOT NULL,
org_unit_id uuid NOT NULL,
-- 宿主的代号。⚠️ 是**宿主**不是通道 —— 同一条通道可以接多个宿主。
host_code text NOT NULL,
-- 这个节点在那个宿主里的 id
external_id text NOT NULL,
created_at timestamptz(3) NOT NULL DEFAULT now(),
-- ⭐⭐ 主键是 (租户, 宿主, 外部 id) —— **一个宿主实体只能挂在一个节点下**。
-- 这条是防重复扫描的根本保证:同一家店被两个节点认领,它的患者就会被扫两遍。
-- ⛔ 别把 org_unit_id 加进主键 —— 那样同一家店挂到两个节点上就不报错了。
CONSTRAINT pk_org_unit_refs PRIMARY KEY (tenant_id, host_code, external_id),
-- ⚠️ 复合外键:单列挡不住跨租户挂坐标
CONSTRAINT fk_our_org FOREIGN KEY (tenant_id, org_unit_id)
REFERENCES app.org_units (tenant_id, id) ON DELETE RESTRICT ON UPDATE CASCADE
);
-- 按节点查它的坐标(一个节点可以有多条)
CREATE INDEX idx_our_unit ON app.org_unit_refs (tenant_id, org_unit_id);
COMMENT ON TABLE app.org_unit_refs IS
'组织节点 → 宿主实体。一个节点可以有零个(纯管理层级)、一个、或多个坐标';
-- 迁移现有数据:宿主名取租户的 partner_code
INSERT INTO app.org_unit_refs (tenant_id, org_unit_id, host_code, external_id)
SELECT o.tenant_id, o.id, t.partner_code, o.source_ref
FROM app.org_units o
JOIN app.tenants t ON t.id = o.tenant_id
WHERE o.source_ref IS NOT NULL;
-- 旧的两列去掉。⛔ 不留着并存 —— 两个真相源比一个错的更糟。
ALTER TABLE app.org_units DROP CONSTRAINT ck_org_units_source;
ALTER TABLE app.org_units DROP COLUMN source_ref;
ALTER TABLE app.org_units DROP COLUMN source_system;
-- ⭐⭐ 一位患者只能对**一个**微信。
--
-- ═══ 原来防错了对象 ═══════════════════════════════════════════════════════
--
-- 原有唯一键 uq_cl (account_id, peer_vid, host_code, host_patient_id) 防的是
-- 「同一个微信对同一位患者重复绑」—— 那件事本来也不会发生。
-- ⛔ 它防不住真正会出事的那个:**同一位患者被两个不同的微信各绑一次**。
-- 实测能直接插进去,没有任何报错。
--
-- 后果是**静默的**:两条会话线各自以为在跟这位患者说话,各读一遍治疗史、各回一次;
-- 工作台上是两条独立会话,谁也不知道对面是同一个人。
--
-- ═══ ⚠️ 为什么不能用普通唯一索引 ═══════════════════════════════════════════
--
-- UNIQUE (tenant_id, host_code, host_patient_id) 会**误伤**:
-- 同一位患者加了前台的号、也加了医生的号 —— 那本来就该有两条认人记录。
-- 实测本地 144 条通讯录只有 77 个不同的微信,一个微信被两个托管号加是**常态**。
--
-- ⇒ 要表达的是「这一组行里 peer_vid 只能有一个值」,那不是唯一索引能说的话。
-- 排他约束正好说得出来:同租户同患者,peer_vid 不同 = 冲突。
--
-- ⚠️ 只约束 is_active 的行 —— 改绑时旧的置为失效、不删除(要留痕)。
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE app.contact_links
ADD CONSTRAINT ex_cl_patient_one_wechat
EXCLUDE USING gist (
tenant_id WITH =,
host_code WITH =,
host_patient_id WITH =,
peer_vid WITH <>
) WHERE (is_active);
COMMENT ON CONSTRAINT ex_cl_patient_one_wechat ON app.contact_links IS
'一位患者只能对一个微信。⛔ 跨托管号的多条认人记录是正常的,但 peer_vid 必须一致';
-- ⭐⭐ **待办的身份 = (租户, 要办什么, 患者, 实体)** —— ⛔ 执行者不在里面。
--
-- ═══ 两件事,缺一不可 ═══════════════════════════════════════════════════════
--
-- ① **挂住那个事实的实体**(白名单加 `appointment`)
--
-- 判据是「办完了,患者事实会不会不一样」⇒ 一条待办总有它要改的那个事实。
-- ⚠️⚠️ 不挂已经在漏:`uq_tasks_active` 的去重键含
-- `coalesce(host_object_type,'')` / `coalesce(host_object_id,'')`,两列全 NULL
-- 时它退化成**患者维度**:
-- ```
-- 患者上午一个约、下午一个约
-- 第一个 openTask → 建
-- 第二个 openTask → 撞去重 → 复用第一条
-- ⇒ 第二个预约**永远不会被确认**,而日志说"复用成功"
-- ```
-- 实测:32 条 tasks,挂了业务对象的 **0** 条。
--
-- ② **把 `agent_code` 从身份键里摘掉**
--
-- ⚠️ 它在键里意味着:同一个预约,换个 agent 来办就变成两条待办。
-- 而 [第四章论点一]「Agent 是一个**执行者**,⛔ 不是一个新物种」——
-- 把执行者写进身份,是同一个错误在待办层重演。
-- ⭐ 执行者已经记在**步骤**上(`jobs.executor_type` / `executor_ref`),那才是它的层。
--
-- ⛔ **列保留** —— `agent_code` 仍是举证(哪个 agent 建的),也仍是
-- `ck_tasks_agent` 和这个索引 WHERE 谓词的依据。摘掉的只是**身份**。
--
-- ⚠️ 今天只有一个 agent 产 appointment_confirm ⇒ **零行为变更**。
--
-- ═══ ⚠️ 为什么「患者」还留在键里 ═══════════════════════════════════════════
--
-- 实体确定了患者 ⇒ 逻辑上冗余。⛔ 但交接/控制态那些行的实体是 NULL,
-- 摘掉患者它们会**全撞在一起**。⇒ 等它们不在这张表里了再收。
--
-- ⚠️ 过渡期:已有的行 host_object 是 NULL,新行带 id ⇒ 两者**不冲突**,
-- 同一个预约可能被确认两次。⛔ 补不回来 —— tasks 上没存过预约 id。
ALTER TABLE app.tasks DROP CONSTRAINT ck_tasks_object_v;
ALTER TABLE app.tasks ADD CONSTRAINT ck_tasks_object_v
CHECK (host_object_type IS NULL OR host_object_type IN ('opportunity','diagnosis','appointment'));
DROP INDEX app.uq_tasks_active;
CREATE UNIQUE INDEX uq_tasks_active ON app.tasks
(tenant_id, 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');
-- ⭐⭐⭐ **AI 开关** —— 四层,一张表。
--
-- ═══ 判据(用户 2026-09-06 定) ══════════════════════════════════════════════
--
-- > 开关管的是 **Agent**,⛔ 不管人工。分**全局 · 租户 · 单企微号 · 单会话**四层。
-- > **拦就是停,⛔ 不是延。**
--
-- ⚠️⚠️ 三条推论,都和出表前不一样:
--
-- ① **人工一律放行。** 出表前单号闸连人工一起拦,理由是「风控不区分是谁发的」——
-- 而那条推理的前提是**我们能预判风控**。预判不了(封了可以申诉)。
-- ⇒ 那道闸对人工只剩副作用:护士该发的发不出去,而号该封还是封。
--
-- ② **拦下的消息直接作废**(`outbox.status = 'canceled'`),⛔ 不再 hold。
-- 出表前 hold 30 秒一轮、**永远**,于是三个洞:
-- · 终态号(banned/dismissed)上排的消息永远躺着
-- · 租户闸默认关着 ⇒ 有人打开那天**积压一次性全发**
-- · 「明天记得空腹来」延三天发出去 —— 那不是迟到,是**误导**
-- ⇒ 停掉就没有这些。⛔ 也不需要给 outbox 加"不晚于"那一列。
--
-- ③ **没有行 = 不限制。** 四层默认都不打开 —— 它是**应急开关**(出事了关掉),
-- ⛔ 不是灰度开关(验证好了再开)。⚠️ 于是不再读 `tenants.ai_auto_send`
-- 和 `wecom_accounts.sending_enabled`,那两列成为历史列。
--
-- ═══ ⭐ 为什么四层挤一张表 ═══════════════════════════════════════════════════
--
-- 它们是**同一件事**(AI 别开口),只差作用范围。分四张表 = 一个判据写四遍,
-- 而这个仓库刚为"同一判据散在三处"付过一次代价(见 `tests/ai-paused.spec.ts`)。
-- ⇒ 一张表 + 一个 `scope` 列,出站闸一条 SQL 判完。
CREATE TABLE app.ai_pauses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ⭐ 作用范围。⚠️ 取值受 CHECK 约束 —— ⛔ 别加第五种而不改闸门查询
scope TEXT NOT NULL,
/**
* 关的是谁。⚠️ **只有 `global` 时为 NULL**:
* tenant → tenants.id · account → wecom_accounts.id · conversation → conversations.id
* ⛔ 刻意不做外键:四种指向三张表,做不成一个外键;而多态外键
* (三列各建一个)会让每加一层就要改表结构。
*/
scope_ref UUID,
paused_by UUID NOT NULL,
paused_at TIMESTAMPTZ NOT NULL DEFAULT now(),
released_by UUID,
released_at TIMESTAMPTZ,
CONSTRAINT ck_ai_pause_scope CHECK (scope IN ('global','tenant','account','conversation')),
-- ⚠️ 只有全局那层没有 ref —— 其余三层缺了 ref 就是关掉了所有人
CONSTRAINT ck_ai_pause_ref CHECK ((scope = 'global') = (scope_ref IS NULL))
);
-- ⭐ 同一个对象同时只能有一条开着 —— 连点两下不会变两行。
-- ⚠️ 全局那条 `scope_ref` 是 NULL,⛔ 唯一索引对 NULL 不去重 ⇒ 用哨兵值补上。
CREATE UNIQUE INDEX uq_ai_pause_active ON app.ai_pauses
(scope, coalesce(scope_ref, '00000000-0000-0000-0000-000000000000'::uuid))
WHERE released_at IS NULL;
-- 出站闸在热路径上查它:一条 SQL 判四层
CREATE INDEX idx_ai_pause_open ON app.ai_pauses (scope, scope_ref) WHERE released_at IS NULL;
COMMENT ON TABLE app.ai_pauses IS
'AI 应急开关(全局/租户/号/会话四层)。⛔ 只管 Agent,人工一律放行;拦就是停,不是延。见 docs/16 §4.3。';
-- ⭐⭐ **交接搬出 tasks 表** —— 它不是待办。这是三分口径的最后一刀。
--
-- ═══ 判据(用户 2026-09-06 定) ══════════════════════════════════════════════
--
-- > **没有明确是待办的,一律不走待办。**
--
-- 待办是一张**已开清单**,⛔ 不是从意图推出来的分类。今天清单上一行:确认预约。
-- ⚠️ 所以这不是「医疗/投诉不算待办」的口径之争 ——
-- 投诉以后完全可能开成一种待办(它也能改变患者事实),只是**今天没开**。
-- ⇒ 判据不看"像不像",看"开没开"。清单在 `shared/reply-routing.ts` 的 `todo` 位。
--
-- ⚠️ 「医疗」抓的是什么:`shared/medical-signal.ts` 三档(急症/症状/用药)——
-- **患者身上正在发生的、可能是术后并发症的东西**。⛔ 不是"医疗话题",
-- 也不是事故,是事故的前一秒。
-- ⇒ 而我们**改不了那个事实**(不能把出血改成不出血):要改的是临床处置,
-- 那不在这个系统里。⇒ 按判据它就不是待办。
--
-- ═══ ⚠️⚠️ 出表之前,这条腿在界面上只有一个落点 ═══════════════════════════
--
-- 实测查完五个消费者:
-- ```
-- 患者时间线 escalate_to_human:medical_question → on:false ⛔ 不显示
-- 待办中心 四处都带 task_kind='appointment_confirm' ⛔ 不显示
-- pendingFor ⭐ 前端一处都没调(死接口) ⛔ 不显示
-- 会话列表 chip chipsFor 查 tasks.status IN (needs_human,…) ✅ 唯一落点
-- ```
-- ⇒ 那条 task 存在的全部作用,就是让 `chipsFor` 数出一行。而 `jobs.task_id`
-- NOT NULL 又逼着它必须存在。**结构在养一个只为被数一次的空壳。**
--
-- ⇒ 给交接自己的表,`chipsFor` 改成两半拼(见 takeover.service.ts)。
--
-- ═══ ⭐ 顺手修一个真洞:交接不该要患者 ═══════════════════════════════════
--
-- 出表前 reception 有一句 `if (ctx.patients.length === 0) return no_patient_link`,
-- 理由是 `tasks.host_patient_id` NOT NULL、没患者落不了库。
-- ⇒ **没绑患者的人说「牙龈出血止不住」,系统什么都不做,界面上一点痕迹都没有。**
-- ⚠️ 而交接的落点本来就是**会话**(「这一次,人回应完就完」),⛔ 不是患者。
-- ⇒ 患者两列在这里**可空**:有就记(为了以后按患者查),没有照样开。
CREATE TABLE app.conversation_handoffs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- ⭐ 身份就是这一条会话 —— 见下面的唯一索引
conversation_id UUID NOT NULL,
-- ⚠️ 可空,见上面「顺手修的那个洞」。⛔ 别改回 NOT NULL
host_code TEXT,
host_patient_id TEXT,
-- ═══ 留痕 —— 出表前这些落在 `parse_response` 那条 job 的 result 里 ═══════
-- ⚠️ 规则腿**没有 llm_calls**(它不问模型)⇒ 这几列是它唯一的留痕,一列都不能省。
/** `PatientIntent`:medical / reschedule / complaint / question … */
intent TEXT NOT NULL,
/** `escalationReasonOf(intent)` —— ⛔ 别另起一套名字,`NEED` 那张表按它分档 */
reason TEXT NOT NULL,
/** 谁判的:`medical_keyword` / `model` */
rule TEXT NOT NULL,
/** 命中的那个词(规则腿才有)—— 事后能回答「它凭什么顶这条」 */
matched TEXT,
/** 急症 / 症状 / 用药 —— ⚠️ 给人分诊看的(先看哪条),⛔ 不改变动作(三档都转人工)*/
category TEXT,
/**
* ⭐ `category` 的序(急症 3 > 症状 2 > 用药 1;模型腿 0)。
* ⚠️ 真理源是 `shared/medical-signal.ts` 的词表顺序(`severityOf`),
* ⛔ 别在 SQL 里抄一张 CASE 表 —— 那是第二份,必然漂。
* ⚠️ 存它**只为一件事**:同一条交接被反复撞进来时,`category` 取**最严重**的那档。
* ⛔ 不覆盖成最新那档:先说「牙龈出血」(急症) 再说「有点酸」(症状),
* 覆盖会让这条在分诊列表里**往下掉**。
*/
severity SMALLINT NOT NULL DEFAULT 0,
/** 患者原话,截 500。⚠️ [15 §3] 第 4 条:原样存,⛔ 不预先总结 */
said TEXT NOT NULL,
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
/**
* ⭐⭐ 开着的时候**又撞进来几次** —— 出表前这一位是「又落了一条 `parse_response`」。
*
* ⚠️⚠️ 这一列不能省。出表前每一句患者消息都落一条留痕 job,而
* `ON CONFLICT DO NOTHING` 会让**第二句更严重的话一点痕迹都不留** ——
* 而整个接待层最要紧的那个实测就是这一句:
* 「牙龈有点出血」→「现在止不住了」,后者绝不能被吞掉。
* ⇒ 撞上时 UPDATE:`said` 换成**最新那句**(护士要看的是"现在怎么样了"),
* `severity`/`category` 取**最严重**那档,`bumps + 1`。
* ⚠️ 完整原话链在 `messages`(分区表,会话面板逐条都有)——
* ⛔ 这里不再存第二份。这一行回答的是「这次交接是怎么触发的」。
*/
bumps INT NOT NULL DEFAULT 0,
bumped_at TIMESTAMPTZ,
-- ⭐ 「回应」= 人在这条会话里发了一条消息。⛔ 没有出口、没有按钮:
-- 人要做的事只有一件 —— 回他。摆按钮是在问一个不存在的问题。
responded_by UUID,
responded_at TIMESTAMPTZ
);
-- ⭐⭐ 幂等 = **结构给的**,⛔ 不是查一遍 pending job 再决定挂不挂。
-- 患者连说五句疼 ⇒ 一行 ⇒ 一句交接话术。人回了之后再说疼 ⇒ 新的一行,那是新一轮。
CREATE UNIQUE INDEX uq_handoff_open ON app.conversation_handoffs (tenant_id, conversation_id)
WHERE responded_at IS NULL;
-- 会话列表那颗标在热路径上批量查它(`conversation_id = ANY(...)`)
CREATE INDEX idx_handoff_open ON app.conversation_handoffs (tenant_id, conversation_id)
WHERE responded_at IS NULL;
COMMENT ON TABLE app.conversation_handoffs IS
'会话交接:患者说了一句要人回的话。⛔ 不是待办(没有目标、不挂步骤、没有出口)——见 docs/16 §3.1。';
-- ⚠️ 不搬数据:`conversation_handoff` 这个 kind 库里**一行都没有**
-- (交接的历史行全是 2026-09-05 合并前停产的 manual_followup / symptom_triage)。
-- ⇒ 和 ai_pauses 那次一样,代码改完就不再往 tasks 里写。
-- ⭐⭐ 一条消息可以带**多个**媒体(多段消息:图+配文,实测最多 3 张图)
-- ⚠️ 旧键 (message_id, tier) 隐含"每档最多一个" —— 第 2、3 张会被
-- `ON CONFLICT DO NOTHING` **静默丢掉**。⛔ 那不是幂等,是丢数据。
-- ⚠️ 幂等性不受影响:重放时 file_id 相同,照样撞唯一键。
ALTER TABLE app.message_media DROP CONSTRAINT IF EXISTS uq_mm;
DROP INDEX IF EXISTS app.uq_mm;
CREATE UNIQUE INDEX uq_mm ON app.message_media (message_id, tier, file_id);
-- ⭐⭐ 文档正文抽出来存下来 —— **挂在 attachment 上,⛔ 不是 message 上**。
--
-- `attachments` 是**按内容寻址**的(checksum_sha256 去重)⇒ 同一份文件
-- 不管被几个人在几条会话里发过,**只抽一次**。挂在 message 上就是抽 N 次。
--
-- ⚠️ `doc_text` 用 \f(换页符)分页 —— pdftotext 的传统做法,split 回来就是页数组。
-- ⛔ 别存 JSON 数组:这一列要能直接 ILIKE 全文搜。
-- ⚠️ NULL = **还没抽 / 抽不了**(不是 PDF、是扫描件、抽失败)——
-- ⛔ 和"抽出来是空的"要能分开,所以⛔ 不给 DEFAULT ''。
ALTER TABLE app.attachments
ADD COLUMN doc_text text,
ADD COLUMN doc_pages smallint;
-- ⭐ 只给「已抽出正文」的那些建索引 —— 附件里绝大多数是图片,⛔ 别让它们占索引
CREATE INDEX idx_att_doc ON app.attachments (id) WHERE doc_text IS NOT NULL;
-- ⭐⭐ 文档里那些**图**的索引 —— 让模型知道「p2 有张 1600×1200 的图,附近写着曲面断层片」。
--
-- ⚠️⚠️ 只抽文字的话有个**最糟的失败**:检查报告的关键信息在 X 光片里,
-- 而抽出来的文字是「姓名/日期」这类边角 ⇒ 索引卡看着像有内容,
-- 模型据此以为自己读懂了 —— **比完全读不到更糟**。
--
-- ⚠️ 每一项形如 {"page":2,"w":1600,"h":1200,"near":"曲面断层片","att":"<uuid>"}
-- `att` 指向**另一条 attachments 记录**(图的字节本身,内容寻址去重)
-- ⇒ 给模型看时走**签名 URL**,和患者直接发的图**同一条路**。
-- ⚠️ 空数组 = 抽过了但没有够格的图;NULL = 还没抽 —— ⛔ 两者不能混。
ALTER TABLE app.attachments ADD COLUMN doc_images jsonb;
-- 解除托管(「解绑」)—— 轴3:这个号还算不算我们托管的。
--
-- ⭐⭐ 为什么是**三列**而不是第 5 个 status 值
--
-- status 表达的是**轴1**:号在不在线(online / offline / banned / dismissed)。
-- 「解除托管」是**轴3**:我们还管不管它。两者正交 ——
-- 一个已解除的号当然也是 offline,但 offline ⛔ 不等于已解除:
-- offline 「掉了,去扫码」 → 界面给「重新扫码」按钮、掉线告警要喊
-- released 「不用了,别扫」 → ⛔ 不给按钮、⛔ 不告警
-- 塞进 status 的代价是**每一个**读 status 的地方都要多分辨一次,而漏掉是静默的。
--
-- ⚠️ 三列各自回答一个问题,⛔ 不能合并:
-- release_requested_at 发起了没有 —— `[15001]` 是**异步**的(文档:「响应成功不代表登出,
-- 登出业务处理以登出事件为准」)⇒ 发起到落地之间有个真实存在的中间态
-- released_at 真的下线了没有(由 `[11027] intended` 事件落地)
-- released_by 谁按的 —— 不可逆动作必须留人
ALTER TABLE app.wecom_accounts
ADD COLUMN release_requested_at timestamptz(3),
ADD COLUMN released_at timestamptz(3),
ADD COLUMN released_by uuid;
-- ⚠️ 先有发起才可能有落地。⛔ 反过来的行是脏数据,让库自己拦住
ALTER TABLE app.wecom_accounts
ADD CONSTRAINT ck_wecom_accounts_release
CHECK (released_at IS NULL OR release_requested_at IS NOT NULL);
-- ⭐ 超时兜底要扫「发起了但还没落地」的行。⚠️ 部分索引 —— 这类行永远是个位数
CREATE INDEX idx_wecom_releasing ON app.wecom_accounts (release_requested_at)
WHERE released_at IS NULL AND release_requested_at IS NOT NULL;
-- ⭐⭐ 门店信息 —— 接待 AI 回答「你们在哪 / 几点开门 / 能停车吗 / 洗牙多少钱」的**唯一出处**。
--
-- ═══ 为什么要有它(2026-09-11 用户拍板) ════════════════════════════════════
--
-- 实测撞到过:患者问「你们周末营业吗」,AI 起草「我们周末正常营业…」——**编的**。
-- 提示词写着「营业时段不要猜」,但守卫只对账时刻/日期/医生名,这句一个时刻都没有 ⇒ 漏拦。
-- ⇒ 这张表不是"后台缺一个表单",是给 AI **一个能对账的出处**。
--
-- ⚠️ 判据(用户定):friday-ai **自己维护**这份数据;从宿主导入是**同步**的事,今天不做。
-- ⛔ 所以这里没有 source / 宿主坐标列 —— 真做同步时再加,⛔ 别预先放一个永远为空的列。
--
-- ═══ ⭐ 为什么是一个 JSONB 而不是十几张表 ═══════════════════════════════════
--
-- 内容是**一家店一份的档案**:地址、电话、营业时间、医生、价格、常见问答……
-- · 每一类都很小(医生十来个、问答几十条)⛔ 没有独立查询的需求
-- · 读取永远是"整份拿走"(AI 工具 / 录入页),⛔ 从不跨店聚合
-- · 结构会随「能想到的都先录全」持续加字段 —— 每加一项就一次迁移是纯负担
-- ⇒ 一份 JSONB,**形状由应用层 zod 定死**(`clinic-profile.schema.ts`),库里只挡"必须是对象"。
--
-- ⚠️⚠️ **乐观锁(version)不能省**:两个店长同时打开这一页,后保存的会**整份覆盖**先保存的。
-- ⇒ 保存时带上读到的 version,对不上就拒(`UPDATE … WHERE version = $expected`)。
CREATE TABLE app.clinic_profiles (
-- ⭐ 一个组织节点一份。⚠️ 通常是 kind='clinic',但**不在库里限制**:
-- 单店客户可能把品牌节点当门店用(kind 是领域取值,基础层不知道枚举)。
org_unit_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
version INTEGER NOT NULL DEFAULT 1,
-- ⚠️ 不建外键 —— 改的人离职删号后这条留痕还要在(同 user_org_scopes.granted_by)
updated_by UUID,
created_at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
-- ⚠️⚠️ 复合外键:单列 REFERENCES org_units(id) 挡不住「A 租户给 B 租户的店写档案」
CONSTRAINT fk_cp_org FOREIGN KEY (tenant_id, org_unit_id)
REFERENCES app.org_units (tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT ck_cp_version CHECK (version >= 1),
CONSTRAINT ck_cp_data_object CHECK (jsonb_typeof(data) = 'object')
);
-- ⭐ **每一次保存都留一版**。两个用途,⛔ 缺一个都不行:
-- ① 录错了能看出**是谁、什么时候**改的(这份内容会被 AI 原样说给患者)
-- ② NFR-006 版本可追溯:AI 那一句话引用的是**哪一版**门店信息
-- ⚠️ append-only,⛔ 没有 updated_at。
CREATE TABLE app.clinic_profile_revisions (
id UUID PRIMARY KEY DEFAULT app.uuidv7(),
tenant_id UUID NOT NULL,
org_unit_id UUID NOT NULL,
version INTEGER NOT NULL,
data JSONB NOT NULL,
edited_by UUID,
edited_at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
CONSTRAINT fk_cpr_profile FOREIGN KEY (org_unit_id)
REFERENCES app.clinic_profiles (org_unit_id) ON DELETE RESTRICT,
-- ⭐ 同一家店同一个版本号只能有一行 —— 乐观锁失败时这里是第二道
CONSTRAINT uq_cpr_version UNIQUE (org_unit_id, version),
CONSTRAINT ck_cpr_data_object CHECK (jsonb_typeof(data) = 'object')
);
CREATE INDEX idx_cpr_tenant ON app.clinic_profile_revisions (tenant_id, org_unit_id, version DESC);
-- ⚠️ 和主键 (org_unit_id) 冗余 —— 存在的唯一理由是 Prisma 的建模规则:
-- 一对一关系要求定义侧的外键字段组合唯一(`(tenant_id, org_unit_id)` → org_units)。
-- ⛔ 没有它 `prisma generate` 直接失败(P1012),客户端里就没有 clinicProfile。
-- ⚠️ 单独一个迁移:上一个已经跑过,改它会让迁移校验和对不上。
CREATE UNIQUE INDEX uq_cp_tenant_org ON app.clinic_profiles (tenant_id, org_unit_id);
-- ⭐ 门店信息的两个外键补上 ON UPDATE CASCADE —— 对齐全库约定(同 20260902100000_fk_on_update_cascade)。
--
-- schema.prisma 里这两条关系只写了 onDelete ⇒ Prisma 按 ON UPDATE CASCADE 算;
-- 手写迁移只给 ON DELETE 时,Postgres 把 ON UPDATE 默认成 NO ACTION ⇒ 两边不一致。
--
-- ⛔ 代价不在行为:org_unit_id / tenant_id 都是 uuid 主键,永远不会被 UPDATE,这一支不会触发。
-- ⚠️ 代价在 `prisma migrate diff`:不补就固定吐这两条「删掉再原样加回」,
-- 「有输出」不再是漂移信号,真漏的列会混在噪音里(理由详见 20260902100000 抬头)。
--
-- ⚠️ 单独一个迁移:20260911010000 已经跑过,改它校验和对不上。
-- 锁:ADD CONSTRAINT 要全表校验,持 SHARE ROW EXCLUSIVE(挡写不挡读);一店一行 / 一版一行,毫秒级。
-- ── app.clinic_profiles ───────────────────────────────────────────────────
ALTER TABLE app.clinic_profiles DROP CONSTRAINT fk_cp_org;
ALTER TABLE app.clinic_profiles ADD CONSTRAINT fk_cp_org
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES app.org_units (tenant_id, id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- ── app.clinic_profile_revisions ──────────────────────────────────────────
ALTER TABLE app.clinic_profile_revisions DROP CONSTRAINT fk_cpr_profile;
ALTER TABLE app.clinic_profile_revisions ADD CONSTRAINT fk_cpr_profile
FOREIGN KEY (org_unit_id) REFERENCES app.clinic_profiles (org_unit_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- P2 组织与人员后台:组织树约束 + 组织写锁版本号。
-- 前置检查(只读,本地活库)全部为 0。
-- 锁:ADD CONSTRAINT / CREATE UNIQUE INDEX 要全表校验;四张表都是个位数到几百行,毫秒级。
-- 注意:本文件不许出现反引号。
-- 1. 名称:已去首尾空格,1..32 字
ALTER TABLE app.org_units ADD CONSTRAINT ck_org_units_name
CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 32);
-- 2. 同级不重名。根节点由 3 限定每租户一个,所以不写 NULLS NOT DISTINCT
-- (schema.prisma 的 @@unique 表达不了它,写了就和 schema 永久漂移)
CREATE UNIQUE INDEX uq_org_units_sibling_name ON app.org_units (tenant_id, parent_id, name);
-- 3. 每租户只有一个根
CREATE UNIQUE INDEX uq_org_units_one_root ON app.org_units (tenant_id) WHERE parent_id IS NULL;
-- 4. 备注
ALTER TABLE app.org_units ADD COLUMN note text;
ALTER TABLE app.org_units ADD CONSTRAINT ck_org_units_note
CHECK (note IS NULL OR char_length(note) <= 200);
-- 5. 编码两列换成 app.ext_id 域(挡空串 / 全空白)。text 为底的域二进制兼容,只做一次全表 CHECK
ALTER TABLE app.org_unit_refs
ALTER COLUMN host_code TYPE app.ext_id,
ALTER COLUMN external_id TYPE app.ext_id;
-- 6. 同一节点、同一宿主只能一条(读取方原来 take:1 取哪条不确定)
CREATE UNIQUE INDEX uq_our_unit_host ON app.org_unit_refs (tenant_id, org_unit_id, host_code);
-- 7. 组织写锁版本号。integer 而非 bigint:避免 BigInt 进 JSON 响应 / 审计时序列化崩溃
ALTER TABLE app.tenants ADD COLUMN org_rev integer NOT NULL DEFAULT 0;
ALTER TABLE app.tenants ADD CONSTRAINT ck_tenants_org_rev CHECK (org_rev >= 0);
-- 8. 防环兜底(只防同一事务内造环;并发靠 tenants 行锁)
CREATE FUNCTION app.org_units_no_cycle() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
cur uuid := NEW.parent_id;
hops int := 0;
BEGIN
WHILE cur IS NOT NULL LOOP
IF cur = NEW.id THEN
RAISE EXCEPTION 'org_units: node % cannot be placed under its own subtree', NEW.id
USING ERRCODE = 'check_violation', CONSTRAINT = 'ck_org_units_no_cycle';
END IF;
hops := hops + 1;
IF hops > 64 THEN
RAISE EXCEPTION 'org_units: depth over 64 at node %', NEW.id
USING ERRCODE = 'check_violation', CONSTRAINT = 'ck_org_units_no_cycle';
END IF;
SELECT parent_id INTO cur FROM app.org_units WHERE id = cur AND tenant_id = NEW.tenant_id;
END LOOP;
RETURN NEW;
END $$;
CREATE TRIGGER trg_org_units_no_cycle
BEFORE INSERT OR UPDATE OF parent_id ON app.org_units
FOR EACH ROW EXECUTE FUNCTION app.org_units_no_cycle();
-- 企业微信号不再挂组织节点。
--
-- 组织树是我们自己的业务结构(⛔ 不是从企业微信的组织同步来的)⇒ 企业微信号和它之间
-- 只有一条路:企业微信号 --扫码绑定--> 账号 --授权--> 组织节点。
-- AI 无人值守时读患者的范围,改为按**号主被授权的节点**算(ScopeService.pacScopeForAccount)。
--
-- ⚠️ 这一列上那个部分索引 idx_wecom_accounts_online (tenant_id, org_unit_id) WHERE status='online'
-- 随列一起消失 —— 改完之后没有任何查询按「某个节点下在线的号」取数了。
-- ⚠️ 复合外键 fk_wecom_accounts_org 同理。
ALTER TABLE app.wecom_accounts DROP COLUMN org_unit_id;
-- ⭐⭐ **语料** —— 「AI 该**怎么说**」的知识。第一条内容是从门店信息里搬过来的「给 AI 的须知」。
--
-- ═══ ⚠️⚠️ 红线:语料 ⛔ **不是事实** ══════════════════════════════════════════
--
-- 门店信息是**可对账的事实**:守卫按它放行金额、医生名、时刻、电话。
-- 语料是**"怎么说"的知识** —— 它⛔ **不进事实账本**,一个字都不进。
--
-- 混在一层的后果很具体:教材上写「种植牙一般 8000–15000」,如果它也进账本,
-- AI 就能拿一份**未必是这家店**的材料给报价作证 —— 患者收到的是错的价。
-- ⇒ 语料里出现具体数字/承诺时只有两条出路:**是这家店的事实 ⇒ 退回门店信息录**
-- (那里有对账);只是举例 ⇒ 录入时就得改写掉。
--
-- ═══ ⭐ 为什么是一张行表,而不是门店信息里再加一个字段 ═══════════════════════
--
-- 「给 AI 的须知」原来是 `clinic_profiles.data->>'aiNotes'` 一个长文本框。三个问题:
-- ① 它**不是门店事实**,却和电话、价格躺在同一份 JSON 里(见上面那条红线)
-- ② 它只能挂在**门店**那一层 ⇒ 品牌的口径要在五家店里各抄一遍,改一次要改五处
-- ③ 一整块文本 ⇒ ⛔ 不能单条启停、⛔ 不能过期、⛔ 看不出哪一条是谁加的
--
-- ⇒ 一条一行、挂在**任意组织节点**上、**沿树向下继承**(累加)。
--
-- ⚠️⚠️ 继承的方向和门店信息**刚好相反**,这是刻意的:
-- ```
-- 门店信息 是「定位」的 —— 一条会话只认**一个**节点(所以才有「上层不许录」那条规矩)
-- 语料 是「累加」的 —— 从门店往上,把**所有祖先**的条目收齐
-- ```
-- 累加没有"录了 AI 不读"的陷阱 —— 那正是门店信息的继承卡住的地方。
--
-- ═══ ⛔ 这一版**刻意没有**的列(别提前放空列,同 clinic_profiles 那条纪律) ═══
--
-- 主题标签 topics[] —— 是**检索**维度。检索(`search_knowledge`)还没做,放了永远是空的
-- 适用 agents[] —— 今天只有接待一个场景在读语料,分不出"只给某个 Agent"
-- ⇒ 真做到那一步再加。
CREATE TABLE app.corpus_entries (
id UUID PRIMARY KEY DEFAULT app.uuidv7(),
tenant_id UUID NOT NULL,
-- ⭐ 挂在哪一层。⚠️ **任意节点**(集团 / 品牌 / 区域 / 门店)——
-- ⛔ 库里不限 kind:哪一层该录是业务判断,而且会随客户形态变(单店客户把品牌当门店用)
org_unit_id UUID NOT NULL,
-- 类型。⚠️ 取值是领域概念(今天只有 'note' = 给 AI 的内部须知),
-- 白名单在应用层(`corpus.schema.ts`)—— 同 `org_units.kind` 的做法,基础层不知道枚举
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
-- ⭐ **有效期** —— 门店信息没有这一项,语料必须有。
-- ⚠️ 活动/时令类语料**天然会过期**,而过期的活动话术 = AI 承诺一个已经结束的优惠。
-- 两头都可空:空 = 一直有效。
starts_on DATE,
ends_on DATE,
-- 停用 = 立刻不给 AI 看,但留着(⛔ 不是删)
enabled BOOLEAN NOT NULL DEFAULT true,
-- ⚠️ 乐观锁:两个人同时改同一条,后保存的会整条吃掉先保存的(同 clinic_profiles)
version INTEGER NOT NULL DEFAULT 1,
-- ⚠️ 不建外键 —— 改的人离职删号后这条留痕还要在(同 clinic_profiles.updated_by)
created_by UUID,
updated_by UUID,
created_at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
-- ⚠️⚠️ 复合外键:单列 REFERENCES org_units(id) 挡不住「A 租户往 B 租户的节点上录语料」
CONSTRAINT fk_ce_org FOREIGN KEY (tenant_id, org_unit_id)
REFERENCES app.org_units (tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT ck_ce_kind CHECK (char_length(kind) BETWEEN 1 AND 32),
CONSTRAINT ck_ce_title CHECK (title = btrim(title) AND char_length(title) BETWEEN 1 AND 40),
CONSTRAINT ck_ce_body CHECK (body = btrim(body) AND char_length(body) BETWEEN 1 AND 2000),
-- 只填一头是允许的("从 10-01 起"/"到 10-07 为止");两头都填就得前后有序
CONSTRAINT ck_ce_dates CHECK (starts_on IS NULL OR ends_on IS NULL OR starts_on <= ends_on),
CONSTRAINT ck_ce_version CHECK (version >= 1)
);
-- 读永远是「这个节点 + 它的祖先链上有哪些条目」⇒ 按 (租户, 节点) 取
CREATE INDEX idx_ce_node ON app.corpus_entries (tenant_id, org_unit_id, kind);
-- ═══ 搬家:门店信息里已经录过的「给 AI 的须知」→ 第一批语料 ═══════════════════
--
-- ⚠️ 空白的⛔ 不搬(`btrim` 之后为空 = 没录过,搬过去就成了一条空条目)。
-- ⚠️ 超过 2000 字的截断 —— 原字段上限 1000,这里放宽到 2000,实际不会触发;
-- 写成 `left(...)` 只是为了这条 INSERT **不可能**被 ck_ce_body 挡下来。
-- ⚠️ `created_by` 记成原来那份档案的 `updated_by`(最后改门店信息的人)——
-- ⛔ 不记成 NULL:回头查「这句须知哪来的」时,那是唯一的线索。
INSERT INTO app.corpus_entries (tenant_id, org_unit_id, kind, title, body, created_by, updated_by)
SELECT
cp.tenant_id,
cp.org_unit_id,
'note',
'给 AI 的须知',
left(btrim(cp.data->>'aiNotes'), 2000),
cp.updated_by,
cp.updated_by
FROM app.clinic_profiles cp
WHERE btrim(coalesce(cp.data->>'aiNotes', '')) <> '';
-- ⭐ 再把这个键从**当前**那份档案里摘掉 —— 留着就是两份真理源。
-- ⚠️ ⛔ 不动 `clinic_profile_revisions`:它是 append-only 的历史,搬错了要靠它查回去。
-- ⚠️ ⛔ 不 bump version:zod 解析时本来就会丢掉认不得的键 ⇒ 解析结果逐字节不变,
-- 对正开着录入页的人来说什么都没发生(bump 了反而会让他的保存被判过期)。
UPDATE app.clinic_profiles
SET data = data - 'aiNotes'
WHERE data ? 'aiNotes';
-- ⭐⭐ **账号的个人化配置** —— 今天只装一样东西:「AI 替我说话的样子」。
--
-- ═══ ⚠️⚠️ 判据:会不会影响**患者收到的内容** ═══════════════════════════════
--
-- 个人配置分三档,只有中间那一档要谨慎:
-- ```
-- 只影响自己 通知偏好、列表密度、默认诊所 随便改,⛔ 不用留痕
-- 影响患者 AI 替我说话的自称 / 语气 / 签名 ⭐ 必须在机构口径的边界内,且留痕
-- 其实是策略 AI 自动回 / 先给我看 / 不回 ⛔ **不是偏好**,那是「人工接管」那条控制权
-- ```
-- ⇒ 这一列先只收第二档那三项(2026-09-13 用户定:自称 + 签名 + **语气也开**)。
--
-- ═══ ⭐ 为什么是一列 JSONB,而不是三个列、也不是一张表 ═══════════════════
--
-- · 它是**账号的属性**,一个账号一份 ⇒ ⛔ 没有多行需求,建表是纯负担
-- · 形状由应用层 zod 定死(`corpus/speak.schema.ts`),库里只挡"必须是对象"
-- · 以后加"通知偏好"这类只影响自己的,往同一个对象里加一个键就行,⛔ 不用再迁移
--
-- ═══ ⚠️⚠️ 这几个值会拼进 **system 提示词** ═══════════════════════════════
--
-- 而 system 里住着**医疗红线**。⇒ 任何人(包括只读岗位)自助改的字段直接进 system,
-- 就是一条**从员工资料到系统提示词的注入通道**。
-- ⇒ 应用层把口收死:语气是**枚举**;自称/签名只许中文、字母、数字和少数几个符号,
-- ⛔ 不许换行、⛔ 不许标点。10 / 20 字上限。见 `speak.schema.ts`。
ALTER TABLE app.users ADD COLUMN preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
-- ⚠️ 库里只保证"是个对象" —— 形状归应用层(同 clinic_profiles.data 那条)
ALTER TABLE app.users
ADD CONSTRAINT ck_users_preferences_object CHECK (jsonb_typeof(preferences) = 'object');
-- ⭐ `fk_ce_org` 补上 ON UPDATE CASCADE —— 对齐全库约定
-- (同 20260902100000_fk_on_update_cascade / 20260911030000_clinic_profile_fk_on_update)。
--
-- ⚠️⚠️ 这是**同一个坑的第二次**:20260911030000 就是为门店信息补的这一条,
-- 而 20260913140000 抄的是它**被修之前**的那一版写法 ⇒ 又漏了。
-- 实测确认(`pg_constraint.confupdtype`):全库指向 org_units 的外键里,
-- `fk_ce_org` 是**唯一**一个 NO ACTION。
--
-- schema.prisma 里这条关系只写了 onDelete ⇒ Prisma 按 ON UPDATE CASCADE 算;
-- 手写迁移只给 ON DELETE 时,Postgres 把 ON UPDATE 默认成 NO ACTION ⇒ 两边不一致。
--
-- ⛔ 代价不在行为:tenant_id / org_unit_id 都是 uuid 主键,永远不会被 UPDATE,这一支不会触发。
-- ⚠️ 代价在 `prisma migrate diff`:不补就固定吐一条「删掉再原样加回」,
-- 「有输出」不再是漂移信号,真漏的列会混在噪音里。
--
-- ⚠️ 单独一个迁移:20260913140000 已经跑过,改它校验和对不上。
-- 锁:ADD CONSTRAINT 要全表校验,持 SHARE ROW EXCLUSIVE(挡写不挡读);语料一条一行,毫秒级。
ALTER TABLE app.corpus_entries DROP CONSTRAINT fk_ce_org;
ALTER TABLE app.corpus_entries ADD CONSTRAINT fk_ce_org
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES app.org_units (tenant_id, id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- ⭐⭐⭐ 语料改成「位置由系统定,内容由机构填」(2026-09-15 用户定)。
--
-- 原来每条是「类型 + 自由标题 + 正文」。实测三个问题:
-- ① 类型选错:新增默认「须知」,填了「语气:温柔」却落成须知,大部分轮次 AI 看不到
-- ② 上下层打架:品牌「语气:严谨」+ 门店「语气:温柔」两条都给模型,系统判断不了是不是同一件事
-- ③ 「说话的样子」和个人配置里的「语气」是同一件事的两个入口
-- ⇒ `title`(自由文本)改成 `slot`(系统枚举的位置 key,清单在 `corpus.schema.ts` 的 `SLOTS`)。
--
-- ⚠️ 区的 key 不变,但**集合变了**:redline / script / knowledge / note,⛔ 没有 voice 了。
-- 基础层照旧不知道枚举(同 org_units.kind),这里只约束 slot 的**形状**。
--
-- ═══ 搬家:一条都⛔ 不丢,放不进新位置的一律进「其他交代」 ═════════════════════
--
-- 老标题是自由文本,⛔ 映射不到枚举位置(「语气」该算哪个?猜错比不猜更糟)。
-- ⇒ 只有一个位置可以无损接住任意内容:note / other(其他交代,可以多条)。
-- 标题拼进正文开头,⛔ 不丢信息;录的人回头再把它们挪到正式位置。
--
-- voice / knowledge / script → note / other,正文 = 「标题:正文」
-- note → note / other,标题是「给 AI 的须知」(门店信息搬过来的那批)就只留正文
-- redline → **每层留最早的一条**进 redline / other(其他边界);
-- 同层多出来的进 note / other,并在开头标「(原边界)」
-- ⚠️ 为什么⛔ 不全放进「其他边界」:新规则「每层每位置 1 条」,同层几条红线会撞唯一索引。
-- ⚠️ 为什么⛔ 不把几条拼成一条:拼接会超 2000 字上限、有效期/启停也合不到一起。
-- ⚠️ 降进「其他交代」意味着那几条**不再每一轮都在** —— 迁移后录入页要人去确认。
-- (本地库里 redline 是 0 条;这一支是为别的环境写的。)
--
-- ⚠️ `left(..., 2000)`:拼上标题可能超过正文上限,⛔ 让这条 UPDATE 被 ck_ce_body 挡下来。
-- ⚠️ ⛔ 不 bump version:内容没被人改过,bump 了会让正开着录入页的人保存时被判过期。
-- ① 其余几类全部进「其他交代」
-- ⚠️⚠️ **必须排在边界那一句前面。** 边界那句会把同层多出来的降成 kind = 'note';
-- 如果这句在后面跑,`kind IN (..., 'note')` 会把刚降下来的**再处理一遍**,
-- 正文变成「other:(原边界)……」。本地库里边界是 0 条,迁移时这一支是空转,
-- 是 `corpus.probe.spec.ts` 用旧结构的临时表把它跑出来的。
UPDATE app.corpus_entries
SET body = left(btrim(CASE WHEN kind = 'note' AND title = '给 AI 的须知' THEN body
ELSE title || ':' || body END), 2000),
kind = 'note',
title = 'other'
WHERE kind IN ('voice', 'knowledge', 'script', 'note');
-- ② redline:每层最早那条留作「其他边界」,其余降级(⚠️ 放在最后,见 ① 的注释)
WITH ranked AS (
SELECT id, row_number() OVER (PARTITION BY tenant_id, org_unit_id ORDER BY created_at, id) AS rn
FROM app.corpus_entries
WHERE kind = 'redline'
)
UPDATE app.corpus_entries ce
SET kind = CASE WHEN r.rn = 1 THEN 'redline' ELSE 'note' END,
body = left(btrim(
CASE WHEN r.rn = 1 THEN ce.title || ':' || ce.body
ELSE '(原边界)' || ce.title || ':' || ce.body END), 2000),
title = 'other'
FROM ranked r
WHERE ce.id = r.id;
-- ③ 列改名 + 约束换成「位置 key 的形状」
ALTER TABLE app.corpus_entries RENAME COLUMN title TO slot;
ALTER TABLE app.corpus_entries DROP CONSTRAINT ck_ce_title;
ALTER TABLE app.corpus_entries ADD CONSTRAINT ck_ce_slot CHECK (slot ~ '^[a-z][a-z0-9_]{0,31}$');
-- ④ ⭐⭐ **每层每位置 1 条**由库来保证(「其他交代」除外,它可以多条)。
-- ⚠️ 应用层先查一遍给出人话;并发时两个人同时写同一个位置,靠这条兜底(P2002)。
-- ⚠️ 本仓的坑:改索引要 grep 全库 ON CONFLICT(42P10)—— 已查,corpus_entries 没有任何 ON CONFLICT。
CREATE UNIQUE INDEX uq_ce_slot ON app.corpus_entries (tenant_id, org_unit_id, kind, slot)
WHERE kind <> 'note';
-- ⭐⭐⭐ 人设从**号主那个人**搬到**企业微信号**上(2026-09-16 用户定)。
--
-- 原来叫「AI 替我说话的样子」,存在 `users.preferences.speak`,读的时候走
-- 这条会话 → 这个企微号 → 号主是谁 → 取他的偏好
-- 那条链上有三个**静默**的洞,全都朝着患者:
-- 归属转交 ownerOf 换人 ⇒ 自称当场变,而患者那头头像昵称一个字没变
-- 号没绑人 user_id 可空(schema 写明「一个企微号可以没绑人」)⇒ 整段人设为空
-- 号主离职停用 is_active=false ⇒ 同上,且没有任何地方会提示
--
-- ⭐ 判据:患者看到的是这个号的头像和昵称(`nickname`),⛔ 不是号主的登录名
-- ⇒ 人设是**号**的属性。⚠️ 存在号上 ⛔ 不等于号主不能改:编辑入口仍然给号主自己。
--
-- 同一趟顺便把人设**变大**了:原来只有自称和语气,现在加了性别、年龄、性格、爱好
-- (字段清单和值域见 `modules/corpus/persona.schema.ts`,⛔ 这里不建约束 ——
-- 值域收在 zod 那一份,DB 只管它是个 jsonb)。
ALTER TABLE app.wecom_accounts ADD COLUMN persona jsonb;
-- ═══ 搬家:号主填过的自称和语气,抄到他名下**每一个**号上 ═══════════════════
--
-- ⚠️ 一人多号时**每个号都抄一份** —— 那正是原来的表现(他的号都用他的偏好),
-- ⛔ 别只抄给"主责号":搬家这一趟不该顺手改变任何人看到的结果。
-- ⚠️ 空值⛔ 不抄:`''` 和"没设过"在 zod 那头是同一个结果,但库里留一堆
-- `{"selfName": ""}` 会让人误以为设过。
UPDATE app.wecom_accounts a
SET persona =
(CASE WHEN coalesce(u.preferences -> 'speak' ->> 'selfName', '') <> ''
THEN jsonb_build_object('selfName', u.preferences -> 'speak' ->> 'selfName')
ELSE '{}'::jsonb END)
||
(CASE WHEN coalesce(u.preferences -> 'speak' ->> 'tone', '') <> ''
THEN jsonb_build_object('tone', u.preferences -> 'speak' ->> 'tone')
ELSE '{}'::jsonb END)
FROM app.users u
WHERE u.id = a.user_id
AND u.tenant_id = a.tenant_id
AND (coalesce(u.preferences -> 'speak' ->> 'selfName', '') <> ''
OR coalesce(u.preferences -> 'speak' ->> 'tone', '') <> '');
-- ═══ ⚠️⚠️ 老键**删掉**,⛔ 不留 ═══════════════════════════════════════════
--
-- 留着就是**两个真理源**,而漂掉的那一侧是静默的:号上改了人设、
-- `users.preferences.speak` 还停在旧值,下一个读代码的人分不出哪个作数。
-- ⚠️ `preferences` 里别的键**一个都不动**(`- 'speak'` 只摘这一个键)。
UPDATE app.users
SET preferences = preferences - 'speak'
WHERE preferences ? 'speak';
-- ⭐⭐⭐ 提醒(docs/17 §5)—— 两件事:**提醒号**,和**交接爬到第几级**。
--
-- 背景:转人工开了一条交接之后,同事那侧今天**只有会话列表上一颗标** ——
-- 人不打开工作台就什么都不知道,而患者那侧我们一个字都不发(那条是刻意的)。
-- ⇒ 最轻的一级也必须是**直接触达**:发企微。
-- ═══ ① 提醒号 ═══════════════════════════════════════════════════════════════
--
-- ⚠️⚠️ **⛔ 不能用当事人自己的号发。** 转人工的责任人**本人就是那个号的主人**
-- ⇒ 用他的号发 = 自己发给自己,企微里根本没有这个会话。
-- 换别人的号发 ⇒ 同事看到「李护士找我」而李护士不知情 —— 那是对内版的
-- 「别人的号替他说话」。⇒ 必须有第三个号。
--
-- ⭐ **一个租户一个**,靠部分唯一索引钉死,⛔ 不是靠代码里"记得只建一个"。
-- 判据是「提醒号必须和收件人在**同一个企业**」(内部联系人按企业算),
-- 而企微企业是集团(顶层)开的 ⇒ 全租户同一本通讯录 ⇒ 一个够。
-- ⚠️ 真出现跨企业主体(加盟各自独立开企业)时才需要放宽这条唯一约束。
ALTER TABLE app.wecom_accounts
ADD COLUMN purpose text NOT NULL DEFAULT 'patient';
ALTER TABLE app.wecom_accounts
ADD CONSTRAINT ck_wa_purpose CHECK (purpose IN ('patient', 'notifier'));
-- ⚠️ 部分唯一:只约束 notifier 那一种。⛔ 不能写成 UNIQUE(tenant_id),
-- 那会让一个租户只能有一个企微号。
CREATE UNIQUE INDEX uq_wa_notifier
ON app.wecom_accounts (tenant_id)
WHERE purpose = 'notifier';
-- ═══ ② 交接爬到第几级 ═══════════════════════════════════════════════════════
--
-- ```
-- 0 提醒责任人本人 当场
-- 1 再提醒他一次 +5 分
-- 2 本店内部群 +5 分 ⚠️ 没配群就跳过这一级
-- 3 本店诊所经理 +10 分 ⚠️ 没设经理就跳过 —— 那就是顶
-- ```
-- ⚠️⚠️ **一件事一条提醒,升级是同一条往上爬** ⇒ 状态挂在交接这一行上,
-- ⛔ 不另开一张"提醒表"。否则爬到高处时,人手上有四条关于同一件事的东西 ——
-- 告警群就是这么被静音的。
--
-- ⚠️ `-1` 而不是 `0`:`0` 是**已经发过第 0 级**,两者必须分得开,
-- 否则调度器认不出"这条还一次都没提醒过"。
ALTER TABLE app.conversation_handoffs
ADD COLUMN notified_level smallint NOT NULL DEFAULT -1,
ADD COLUMN notified_at timestamptz(6),
-- ⭐⭐ **这件事归谁** —— 提醒发给他(docs/17 §3)。
--
-- ⚠️⚠️ **必须在开交接那一刻存下来,⛔ 不能事后算**:
-- · 群里 @ 了谁是**按消息**判的 —— 那条消息过去了就算不回来
-- · 而且从追责看,该记的是「**这件事发生时**谁负责」,
-- ⛔ 不是"现在谁是群主"(群主可以转让)
-- ⚠️ 可空:号没绑人、或者群主不是我们托管的号 ⇒ 记不到谁
-- ⇒ 第 0/1 级发不出去,梯子直接从第 2 级(本店群)起步。
-- ⛔ 别因为记不到就不开交接 —— 那样患者就彻底没人管了。
ADD COLUMN owner_user_id uuid;
ALTER TABLE app.conversation_handoffs
ADD CONSTRAINT ck_handoff_notified CHECK (notified_level BETWEEN -1 AND 3);
-- ⚠️⚠️ **双向约束**:发过就一定有时刻,没发过就一定没有。
-- ⛔ 少了它,`notified_level=2 而 notified_at IS NULL` 这种行会让
-- 调度器算不出"下一级什么时候到期",而且**不会报错** —— 它会永远不再升级。
ALTER TABLE app.conversation_handoffs
ADD CONSTRAINT ck_handoff_notified_pair
CHECK ((notified_level = -1) = (notified_at IS NULL));
-- ═══ ③ 回填:迁移那一刻**还开着**的交接,也得有人收提醒 ═══════════════════
--
-- ⚠️⚠️ ⛔ 不回填的后果是**静默失联**:那些行 `owner_user_id` 为空
-- ⇒ 第 0/1 级发不出去;而第 2 级(本店内部群)现在还没地方配
-- ⇒ **一条提醒都发不出去**,而日志里一个字都不会说。
--
-- ⭐ 退到「**这条会话那个号的号主**」—— 单聊本来就是这条判据;
-- 群聊按判据该是群主,但那要 join `wecom_rooms` 且多数群主⛔ 不是我们的号,
-- ⇒ 存量按号主算**够用**:它至少是个真的能收到消息的人。
-- ⚠️ 只补**还开着**的(`responded_at IS NULL`)—— 已经回掉的⛔ 不用管。
UPDATE app.conversation_handoffs h
SET owner_user_id = a.user_id
FROM app.conversations c
JOIN app.wecom_accounts a ON a.id = c.account_id
WHERE h.conversation_id = c.id
AND h.tenant_id = c.tenant_id
AND h.responded_at IS NULL
AND h.owner_user_id IS NULL
AND a.user_id IS NOT NULL;
-- ⭐ 调度器每分钟扫「还开着、该升级了」。
-- ⚠️ `notified_at NULLS FIRST` —— 一次都没提醒过的**最该先发**(第 0 级是当场发的)。
-- ⚠️ 部分索引:已经回了的那些⛔ 不用扫,而绝大多数交接最终都会被回掉。
CREATE INDEX idx_handoff_due
ON app.conversation_handoffs (tenant_id, notified_at NULLS FIRST)
WHERE responded_at IS NULL;
-- ⭐⭐ **每家店的提醒设置** —— 今天只有一项:第 2 级发到哪个内部群(docs/17 §5.3)。
--
-- ═══ ⚠️⚠️ 为什么⛔ 不放门店信息、也⛔ 不放组织节点 ═══════════════════════════
--
-- · `clinic_profiles` 是**给患者看的事实** —— AI 会把它整段读去答问题。
-- 把一个内部群 id 混进去就是**泄漏**,而且是静默的。
-- · `org_units` 是**基础层通用表**(租户/组织树),塞一个通道概念进去,
-- 以后换通道就要动基础层。
-- ⇒ 单开一张。⭐ 先例就在旁边:`wecom_departments`(组织节点 ↔ 企微部门)
-- 同样是**通道概念挂在 org_units 上**,也是单独一张表,⛔ 不是往 org_units 加列。
--
-- ═══ ⚠️ 为什么是**带约束的列**,⛔ 不是一个 jsonb 袋子 ═══════════════════════
--
-- 这个仓库的 settings 一律是**带约束的列**:`tenants.ai_auto_send`、
-- `tenants.patient_dedup_kind`、`wecom_accounts.purpose`、`ai_pauses` 的 typed 行。
-- ⚠️ jsonb 只给两类:**一整份人填的表单**(`clinic_profiles.data` / `persona`)、
-- 和**只影响自己的个人偏好**(`users.preferences` —— 它自己的注释划了界:
-- 「判据是『会不会影响患者收到的内容』」)。
-- ⇒ 提醒群恰好在那条界**外面**:它直接决定患者有没有人回。
--
-- ⭐ 表名叫 settings 而不是 rooms:**窄表可以加列** ——
-- 「提醒发给哪些岗位」以后直接在这张表上加一列,⛔ 不用再开一张。
CREATE TABLE app.clinic_notify_settings (
tenant_id uuid NOT NULL,
-- ⭐ 哪家店。⚠️ 一家店一个群 ⇒ 复合主键,⛔ 不另起 id
org_unit_id uuid NOT NULL,
-- ⭐ 提醒号看到的那个群。⚠️ ⛔ 不存 account_id —— 它**永远是提醒号**:
-- 候选本来就只有「提醒号自己在的内部群」(它不在里面就发不进去)。
room_id text NOT NULL,
updated_by uuid,
updated_at timestamptz(6) NOT NULL DEFAULT now(),
CONSTRAINT pk_cns PRIMARY KEY (tenant_id, org_unit_id),
-- ⚠️ 复合外键 —— 单列 REFERENCES org_units(id) 挡不住跨租户挂
CONSTRAINT fk_cns_org FOREIGN KEY (tenant_id, org_unit_id)
REFERENCES app.org_units (tenant_id, id) ON DELETE CASCADE,
CONSTRAINT ck_cns_room CHECK (char_length(room_id) BETWEEN 1 AND 128)
);
-- ⚠️⚠️ ⛔ **不做 room_id 的外键** —— `wecom_rooms` 的键是 `(account_id, room_id)`,
-- 而这里存的是「提醒号看到的那个群」。提醒号一换,那一行就指向另一个号的视角
-- ⇒ 外键会在换号那一刻炸,而正确的处置是**换号时清掉这张表**(服务里做)。
-- ⭐ 联系人的性别 —— 称呼算法要它(docs/17 §7.2)。
--
-- ⚠️⚠️ 它是**免费**的:企微 [11037]/[11036] 本来就回 sex,ecom-client 也早解析了,
-- 我们只是一直没存。⇒ ⛔ 别为这一列去 PAC 多打一次。
-- ⚠️ 取值对齐企微 sex:0 未知 / 1 男 / 2 女。⛔ 别自定义编码。
-- NULL = 还没同步到(老行)⇒ 和 0 一样按「未知」算,退到「您」。
ALTER TABLE app.wecom_contacts ADD COLUMN sex smallint;
ALTER TABLE app.wecom_contacts
ADD CONSTRAINT ck_wc_sex CHECK (sex IS NULL OR sex IN (0, 1, 2));
-- ⭐ 患者年龄段的**本地缓存** —— 称呼算法要它(docs/17 §7.2)。
--
-- ═══ ⚠️⚠️ 为什么落在 contact_links,而不是新建患者表 ═══════════════════════════
--
-- 年龄段来自 PAC(patient.birthDate),而边界原则是「读全走 PAC、⛔ 不存患者副本」。
-- ⇒ 落地要**尽量小**:
-- · 存的是**派生出来的段**(child/adult),⛔ 不是生日本身 —— 段一年最多翻一次,
-- 生日是更敏感的患者数据。
-- · 挂在 **contact_links**(我们自己的绑定表,本来就存 relation=self/parent/child
-- 这类患者相关的派生标注)⇒ ⛔ 不新造一张「患者事实表」,那才是真的越界。
--
-- ⚠️ 判据:它是**称呼提示**,⛔ 不是事实账本。查全、作证一律仍走 PAC。
-- ⚠️ NULL = 还没查过(没 brief 过这个患者)⇒ 算法按「年龄未知」退「您」,⛔ 不猜。
-- ⭐ 写入点:get_patient_brief 跑的时候顺带回写(那次本来就取了 birthDate,免费)。
ALTER TABLE app.contact_links ADD COLUMN patient_age_band text;
ALTER TABLE app.contact_links
ADD CONSTRAINT ck_cl_age_band CHECK (patient_age_band IS NULL OR patient_age_band IN ('child', 'adult'));
-- ⚠️ ⛔ 不存 'unknown' —— 查过但生日缺 ⇒ 留 NULL(和"没查过"同义:都退「您」)。
-- 多一个取值只会逼读的那头多分一档,而两档的动作一模一样。
-- ⭐ 企微**个人标签** —— 号主对这个客户的主观印象(docs/17 §7.3)。
--
-- ═══ ⚠️⚠️ 它是「印象」,⛔ 不是「事实」 ═══════════════════════════════════════
--
-- 三道闸(在渲染和接线处落实,这里只说清这张表的身份):
-- ⛔ 不给守卫作证 —— 标签写「已交定金」,AI ⛔ 不能说「您已经交过定金了」
-- ⛔ 不驱动主动行为 —— 只影响**怎么说**,⛔ 不影响**要不要说**;
-- ⛔ 不进待办、⛔ 不进召回、⛔ 不触发任何主动消息(docs/17 §7.4 是承重的)
-- ⛔ 不进 system —— 它按「号 × 患者」变,放 system 会把前缀共享打穿
--
-- ⚠️⚠️ **label_id 必须是 text** —— 雪花号 1.4e16 **超过** Number.MAX_SAFE_INTEGER。
-- 传输层已经 json-bigint storeAsString 存成字符串了,这里⛔ 绝不能建成 bigint/numeric,
-- 更⛔ 不能在代码里 Number(labelId)(会静默改值,见 transport.ts 头注)。
CREATE TABLE app.wecom_labels (
tenant_id uuid NOT NULL,
account_id uuid NOT NULL,
label_id app.ext_id NOT NULL,
name text NOT NULL,
-- ⚠️ 1 企业 / 2 个人 / 3 管理组。我们只同步 **2 个人标签**(判据:那才是"号主的印象"),
-- 列留着是为了「拉回来的到底是哪一类」有据可查,⛔ 不是为了将来混着存。
label_type smallint NOT NULL,
group_id app.ext_id,
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT pk_wl PRIMARY KEY (account_id, label_id),
CONSTRAINT ck_wl_type CHECK (label_type IN (1, 2, 3)),
CONSTRAINT ck_wl_name CHECK (char_length(name) BETWEEN 1 AND 64),
CONSTRAINT fk_wl_account FOREIGN KEY (tenant_id, account_id)
REFERENCES app.wecom_accounts (tenant_id, id) ON UPDATE CASCADE ON DELETE CASCADE
);
-- ⭐ 这个联系人被打了哪些标签 —— [11037] 每条联系人本来就带 labelIdList,一直丢着没存。
-- ⚠️ 同 remark_phones 的形状:数组、整份覆盖([11037] 每次给的是**全量**,⛔ 不是增量)。
-- ⚠️ 存 **ID** ⛔ 不存名字:名字会被号主改,存名字就得靠联系人重新同步才能刷新,
-- 而 [11037] 是游标增量的 ⇒ 没变的联系人永远刷不到 ⇒ 显示的是旧名字。
-- ⚠️⚠️ **必须是朴素 `text[]`,⛔ 不能是 `app.ext_id[]`。**
-- Prisma 的驱动**反序列化不了域类型的数组**(`_ext_id`),真读的时候直接炸:
-- Inconsistent column data: Column type '_ext_id' could not be deserialized
-- ⇒ tsc / jest 一个都拦不住,只有真库读得出来(踩过一次)。
-- ⭐ 同表的 `remark_phones` 一直就是 `text[]` —— 照它。
-- ⚠️ 标量域(`label_id app.ext_id`)是好的,**只有数组不行**。
ALTER TABLE app.wecom_contacts ADD COLUMN label_ids text[] NOT NULL DEFAULT '{}';
-- ⭐⭐ **第五层:按联系人**(docs/17 §10)—— 四层(全局/租户/号/会话)之外。
--
-- ⚠️⚠️ **它和会话那层性质不同,⛔ 不是同一件事的粗细之分**:
-- 会话级 = 「这一轮我来」(人正在打字,临时的)
-- 联系人级 = 「这个人 AI 永远别管」(常设策略:贵宾要人服务 / 同事 / 供应商)
--
-- ⚠️ scope_ref 指向 **app.wecom_contacts.id**(那一列正好是 uuid)。
-- ⛔ 不能指 vid —— vid 是 text,而 scope_ref 是 uuid。
-- ⚠️ 联系人行是 upsert 出来的、删好友也只把 relation_type 置 0 **不删行**
-- ⇒ id 稳定,⛔ 不会出现悬空的暂停。
--
-- ⚠️ 这一层天然**只在单聊生效**:群会话的 peer_vid 为空,判据里那个子查询解不出联系人。
-- 那是对的 —— 群里有很多人,「让 AI 别管其中一个」当不了整条会话的闸。
ALTER TABLE app.ai_pauses DROP CONSTRAINT ck_ai_pause_scope;
ALTER TABLE app.ai_pauses
ADD CONSTRAINT ck_ai_pause_scope
CHECK (scope IN ('global', 'tenant', 'account', 'conversation', 'contact'));
-- ⭐ 新好友开场白(docs/17 §10)—— 「新好友要先说一句,开场也是服务」。
--
-- ═══ ⚠️⚠️⚠️ 这一列存在的**首要理由是安全,不是功能** ═══════════════════════
--
-- 开场白是 **AI 主动对真人开口** —— 这是整条产品线风险最高的动作。
-- 而库里**已经有一批存量联系人**(本地 148 个,线上只会更多)。
-- 功能一上线,如果判据只是「没问候过就问候」,那么**全部存量好友会被群发一遍**。
-- 那不是 bug,那是事故:几百个真实患者同时收到一句「您好,我是…」,
-- 而他们可能几个月前就加过、早就聊过了。
--
-- ⇒ **回填**:此刻库里的每一行都记成「已问候」。
-- 判据因此变成「**这一列之后新加的**才问候」,存量一个都碰不到。
-- ⚠️ ⛔ 别把这个回填删掉或改成只填一部分 —— 它是这条功能唯一的兜底。
ALTER TABLE app.wecom_contacts ADD COLUMN greeted_at timestamptz(3);
-- ⭐ 存量全部视为已问候。⚠️ 用 now() 而不是 external_created_at:
-- 记的是「我们决定不问候他」的时刻,⛔ 不是"假装当时问候过"。
UPDATE app.wecom_contacts SET greeted_at = now() WHERE greeted_at IS NULL;
-- ⚠️ 扫描要按 (greeted_at IS NULL) 找,行数极少 ⇒ 部分索引。
-- ⛔ 别建全列索引:这一列绝大多数行都是非空,扫的永远是那一小撮。
CREATE INDEX idx_wc_ungreeted ON app.wecom_contacts (account_id, external_created_at)
WHERE greeted_at IS NULL;
-- ⭐⭐ **分段接管**(docs/17 §10 / 教条 75)—— 「20:00–08:00 让 AI 全权」。
--
-- ═══ ⚠️⚠️ 为什么存的是「人工时段」而不是「AI 时段」 ═══════════════════════
--
-- 系统的**默认已经是「AI 一直在」** ⇒ 「这个时段 AI 全权」本身**不改变任何行为**,
-- 存它等于存一个恒真的东西。真正加东西的是**反面**:
-- 「这个时段**人来**,AI 别说话」。
-- ⇒ 配 08:00–20:00 为人工时段,得到的正好就是教条里那句「20:00–08:00 让 AI 全权」。
-- ⭐ 一个含义、一种极性,⛔ 没有"这个窗口是开还是关"的歧义。
--
-- ⚠️ 单位是**当天的分钟数**(0–1439),时区按 Asia/Shanghai 判 ——
-- ⛔ 别存 time/timestamptz:那会把"每天 8 点"变成一个具体日期的 8 点。
-- ⚠️ 允许跨午夜(from > to,例如 22:00–06:00)—— 判据里分两支,见 aiSilencedQuery。
-- ⚠️ **两列同生共死**:只设一头是个说不清的状态("从 8 点开始,到什么时候?")。
-- ⚠️ 两列都空 = **没有排班** = 现状(AI 一直在)⇒ 这条功能默认**完全不生效**。
ALTER TABLE app.wecom_accounts
ADD COLUMN human_hours_from smallint,
ADD COLUMN human_hours_to smallint;
ALTER TABLE app.wecom_accounts
ADD CONSTRAINT ck_wa_human_hours CHECK (
(human_hours_from IS NULL) = (human_hours_to IS NULL)
AND (human_hours_from IS NULL OR (
human_hours_from BETWEEN 0 AND 1439
AND human_hours_to BETWEEN 0 AND 1439
-- ⚠️ 相等 = 空窗口还是全天?说不清 ⇒ 直接禁掉,⛔ 别留给判据去猜
AND human_hours_from <> human_hours_to)));
-- ⭐ 「现在是上海时间的第几分钟」—— 分段接管的判据要用(docs/17 §10)。
-- ⚠️ 做成函数而不是在每处内联:这段表达式一旦在两处出现就会漂,
-- 而漂掉的表现是"排班在闸门生效、在上下文不生效"(正是 ai-pause.ts 头注警告的分叉)。
-- ⚠️ STABLE 而不是 IMMUTABLE —— 它依赖 now()。⛔ 标成 IMMUTABLE 会被规划器缓存。
CREATE OR REPLACE FUNCTION app.minute_of_day_sh() RETURNS integer
LANGUAGE sql STABLE AS $$
SELECT (EXTRACT(HOUR FROM (now() AT TIME ZONE 'Asia/Shanghai'))::int * 60)
+ EXTRACT(MINUTE FROM (now() AT TIME ZONE 'Asia/Shanghai'))::int
$$;
-- ⭐⭐ **提醒已经走完** —— 让走完的交接退出每分钟的扫描集合(2026-09-18 codereview)。
--
-- ═══ ⚠️⚠️ 原来的两个洞 ═══════════════════════════════════════════════════
--
-- ① **活交接被饿死**:交接只有「人回了一句」(responded_at) 才离开扫描集合,
-- ⛔ 没有任何「提醒已经走完」的出口。爬到顶的那些每轮都算出 none,
-- 但它们 notified_at 最老,在 `ORDER BY notified_at ASC` 里**排最前**,
-- 把一轮的 100 个名额占满 ⇒ 刚发过第 0 级的活交接排在后面、再也扫不到
-- ⇒ 第 1/2/3 级一级都发不出去。
-- ② **一个租户饿死所有租户**:没配提醒的租户,交接 notified_at 永远为 NULL,
-- 而 `NULLS FIRST` 让它们**永远排在最前** —— 而 BATCH 是全库的,不分租户。
--
-- ⇒ planEscalation 现在会说出「以后也不会发」(final),调度器据此记下这一刻,
-- `due()` 只扫还没走完的。
-- ⚠️ 它⛔ 不是「结束」—— 结束仍然是 responded_at(人回了一句)。
-- 走完了提醒 ≠ 患者被回了:工作台上那颗「我需要答」照样挂着,直到有人回。
ALTER TABLE app.conversation_handoffs ADD COLUMN escalation_done_at timestamptz(6);
-- ═══ ⚠️ 索引按**真实的查询**重建 ═══════════════════════════════════════════
--
-- 原来是 (tenant_id, notified_at NULLS FIRST),而 `due()` 是**全租户**扫、
-- 不带 tenant_id 等值条件 ⇒ B-tree 的前导列没有等值谓词,给不出 notified_at 的有序流
-- ⇒ 规划器拿不到「有序索引扫 + LIMIT」,只能把整个开集捞回来再排序。
-- ⚠️ 原注释写着「走 idx_handoff_due」—— 描述了一个实际拿不到的执行计划。
-- ⇒ 前导列就是排序列;谓词和 `due()` 的 WHERE 逐字相同。
DROP INDEX app.idx_handoff_due;
CREATE INDEX idx_handoff_due ON app.conversation_handoffs (notified_at ASC NULLS FIRST)
WHERE responded_at IS NULL AND escalation_done_at IS NULL;
-- ⭐⭐ 人工时段改成**按星期几 + 多时段**(2026-09-20)—— 形状对齐门店的「周营业时间」。
--
-- ═══ 为什么改 ═══════════════════════════════════════════════════════════════
--
-- 原来一个号只有一段(`human_hours_from/to`),而且**每天都一样**。
-- 真实门诊排不成这样:周末只有半天、周三下午休、夜班只排两天。
-- ⚠️⚠️ 表达不了的时候人会去**凑**(把窗口设宽一点),而凑出来的结果是
-- **AI 在不该闭嘴的时候闭嘴** —— 患者那侧看到的就是"没人回"。
-- ⇒ 和门店营业时间同一个形状:**一天可以多段**,某天**没有段 = 那天没有人工时段**。
--
-- ═══ ⚠️ 为什么存分钟数,而门店营业时间存的是 HH:MM ═══════════════════════════
--
-- 这个判据**每一轮都在 SQL 里跑**(`aiSilencedQuery`,出站闸门和上下文都问它)。
-- 存 HH:MM 就要在 SQL 里解析字符串;存分钟数只是两个整数比较。
-- ⛔ 门店营业时间那份是给人读、给模型读的,⛔ 不进热路径 ⇒ 两边**形状一致、单位不同**。
--
-- ═══ ⚠️⚠️ 三条边界,⛔ 别再改回去 ═══════════════════════════════════════════
--
-- ① `to` 允许到 **1440**(当天 24:00,**右开**区间)——
-- 否则"排到半夜"只能写 23:59,会静默漏掉最后一分钟。判据是 `>= from AND < to`。
-- ② ⛔ **单段不许跨午夜**(from > to)。按星期几之后,跨午夜必然跨到**另一天**,
-- 一段里塞两天既查不了也改不了 ⇒ 跨夜**拆两段**:周一 20:00–24:00 + 周二 00:00–08:00。
-- ③ 默认 `'[]'` = 没排班 = AI 一直在(现状)⇒ 这条功能默认**完全不生效**。
--
-- ⚠️ CHECK 只管得到「它是个数组」—— 元素里的 day/from/to 和**同一天不许重叠**
-- 由服务端 zod 守(`human-hours.schema.ts`)。CHECK 里写不了子查询,
-- 而 `jsonb_array_elements` 只能在子查询里展开。⛔ 别把这条当成"忘了加约束"。
ALTER TABLE app.wecom_accounts
ADD COLUMN human_hours jsonb NOT NULL DEFAULT '[]'::jsonb;
-- ⭐ 回填:老的那一段(每天同一个窗口)⇒ 7 天各一段。
-- ⚠️⚠️ 跨午夜的老值(from > to)必须**拆成两段**,⛔ 不能原样搬 ——
-- 原样搬进新判据(from < to 的区间比较)会变成**恒假**:设了排班却从不生效,且不报错。
UPDATE app.wecom_accounts a
SET human_hours = COALESCE((
SELECT jsonb_agg(s ORDER BY (s->>'day')::int, (s->>'from')::int)
FROM (
-- 不跨午夜:原样铺到 7 天
SELECT jsonb_build_object('day', d, 'from', a.human_hours_from, 'to', a.human_hours_to) AS s
FROM generate_series(1, 7) d
WHERE a.human_hours_from < a.human_hours_to
UNION ALL
-- 跨午夜的前半段:当天 from → 24:00
SELECT jsonb_build_object('day', d, 'from', a.human_hours_from, 'to', 1440)
FROM generate_series(1, 7) d
WHERE a.human_hours_from > a.human_hours_to
UNION ALL
-- 跨午夜的后半段:每天 00:00 → to(⚠️ 每天都排的窗口,第二天那一段就是它自己)
SELECT jsonb_build_object('day', d, 'from', 0, 'to', a.human_hours_to)
FROM generate_series(1, 7) d
WHERE a.human_hours_from > a.human_hours_to
) t), '[]'::jsonb)
WHERE a.human_hours_from IS NOT NULL;
ALTER TABLE app.wecom_accounts
DROP CONSTRAINT ck_wa_human_hours,
DROP COLUMN human_hours_from,
DROP COLUMN human_hours_to;
ALTER TABLE app.wecom_accounts
ADD CONSTRAINT ck_wa_human_hours CHECK (jsonb_typeof(human_hours) = 'array');
-- ⭐ 「今天是上海时间的星期几」(ISO:1 = 周一 … 7 = 周日)。
-- ⚠️ 同 `app.minute_of_day_sh()` 的理由:做成函数而不是在判据里内联 ——
-- 这段表达式一旦在两处出现就会漂,而漂掉的表现是"排班在闸门生效、在上下文不生效"。
-- ⚠️ ISO 口径(周日 = 7),⛔ 不是 `EXTRACT(DOW)`(那是周日 = 0)——
-- 和门店营业时间、界面上的星期几必须是**同一套编号**。
-- ⚠️ STABLE 而不是 IMMUTABLE —— 它依赖 now()。
CREATE OR REPLACE FUNCTION app.iso_dow_sh() RETURNS integer
LANGUAGE sql STABLE AS $$
SELECT EXTRACT(ISODOW FROM (now() AT TIME ZONE 'Asia/Shanghai'))::int
$$;
-- ⭐ 删掉 `tenants.patient_dedup_kind`(2026-09-21 用户定)。
--
-- ═══ 为什么删 ═══════════════════════════════════════════════════════════════
--
-- 它是 docs/11 §2.4 为「friday-ai 自己建患者表」设计的(配合一列 `dedup_scope_ref` 做患者唯一键)。
-- 后来 D9 定了 friday-ai **不存患者副本**,那张表没建 ⇒ 这一列从建库起**一行代码都没读过**。
--
-- ⚠️ 「患者在哪一层唯一」确实因集团而异(全集团 / 品牌 / 门店),⛔ 但这个事实 **PAC 按患者存着**:
-- `patients.source_unit` = 外部 id 唯一的命名空间(全集团唯一为空 / 品牌级 = 品牌 / 门店级 = 门店),
-- PAC 的唯一键是 (host, tenant, source_unit, external_id)。
-- friday-ai 关联患者用的是 PAC 的**内部 uuid**(全局唯一)⇒ 认人、查询都用不到层级。
-- ⇒ 在租户上再存一份,等于手工维护 PAC 已有的事实;而这一份已经错了:默认 `''`(全集团唯一),
-- 瑞尔实际是品牌级(本地 33 个手机号在两个品牌各有一份档案)。
-- ⭐ 将来真要用(病历号撞号消歧 / 按手机号自动认人 / 一个微信对每个命名空间一位患者),
-- 让 PAC 的患者卡片带出 `sourceUnit`,⛔ 别把这一列加回来。
ALTER TABLE app.tenants
DROP CONSTRAINT IF EXISTS ck_tenants_dedup_kind,
DROP COLUMN patient_dedup_kind;
-- ⭐⭐ **建库即有月分区** —— 本月 + 往后 3 个月(和定时任务 `PartitionService.ensureFuturePartitions` 同一个窗口)。
--
-- ═══ 为什么要这一条(2026-09-21 清库重建时撞出来的)═════════════════════════════
--
-- 之前的迁移只建父表 + `*_default` 兜底分区,月分区**只有**每天 03:15 的定时任务(或 `pnpm maintain`)建。
-- ⇒ 首次部署(`createdb` → `migrate deploy` → 起服务)到第一次 03:15 之间,写进来的行全落在 default:
-- 入驻 CLI 的审计 → audit_logs_default
-- 回调 / 中转补齐 → webhook_deliveries_default(本地清库后几秒内就进了 2000 行)
-- 当天的消息 → messages_default
-- 🔴 而 default 里一旦有本月的行,`CREATE TABLE … PARTITION OF … FOR VALUES FROM (本月)` 直接报
-- `23514 updated partition constraint for default partition … would be violated`(实测)
-- ⇒ 本月分区**永远建不出来**,定时任务每晚都失败 —— 首装当天就注定。
--
-- ⭐ 放在迁移里而不是部署脚本里:「建库」在文档里就是 `createdb` + `migrate deploy` 两步,
-- ⛔ 多一步「记得跑 maintain」就会有人(和我)忘。之后的月份仍归定时任务。
--
-- ⚠️ default 里**已经有**这个月的行(本地清过库的环境)⇒ 走「先建独立表 → 把行搬过去 → ATTACH」:
-- 先锁 default,免得搬完到 ATTACH 之间又进来一行、ATTACH 校验失败。
-- 没有行 ⇒ 和定时任务完全一样的 `PARTITION OF`。
-- ⚠️ 分区键取 `partattrs[0]` —— **0 索引**,写 `[1]` 拿到 NULL(docs/12 R3 记过的坑)。
-- ⚠️ 边界是 date 字面量、按会话时区转成 timestamptz —— 和定时任务同一个写法,两边切出来的边界才一致。
-- ⚠️ 动态发现分区表,⛔ 写死表名(docs/12 R3)。
DO $$
DECLARE
r record;
m date;
nxt date;
part text;
dflt text;
has_rows boolean;
BEGIN
FOR r IN
SELECT c.relname AS tbl, a.attname AS key
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
JOIN pg_attribute a ON a.attrelid = pt.partrelid AND a.attnum = pt.partattrs[0]
WHERE n.nspname = 'app' AND c.relkind = 'p'
ORDER BY c.relname
LOOP
dflt := r.tbl || '_default';
FOR i IN 0..3 LOOP
m := (date_trunc('month', current_date) + make_interval(months => i))::date;
nxt := (m + interval '1 month')::date;
part := format('%s_%s', r.tbl, to_char(m, 'YYYY_MM'));
CONTINUE WHEN to_regclass('app.' || part) IS NOT NULL;
has_rows := false;
IF to_regclass('app.' || dflt) IS NOT NULL THEN
EXECUTE format('SELECT EXISTS (SELECT 1 FROM app.%I WHERE %I >= %L AND %I < %L)',
dflt, r.key, m, r.key, nxt)
INTO has_rows;
END IF;
IF NOT has_rows THEN
EXECUTE format('CREATE TABLE app.%I PARTITION OF app.%I FOR VALUES FROM (%L) TO (%L)',
part, r.tbl, m, nxt);
ELSE
EXECUTE format('LOCK TABLE app.%I IN ACCESS EXCLUSIVE MODE', dflt);
EXECUTE format('CREATE TABLE app.%I (LIKE app.%I INCLUDING DEFAULTS INCLUDING CONSTRAINTS)',
part, r.tbl);
EXECUTE format('INSERT INTO app.%I SELECT * FROM app.%I WHERE %I >= %L AND %I < %L',
part, dflt, r.key, m, r.key, nxt);
EXECUTE format('DELETE FROM app.%I WHERE %I >= %L AND %I < %L',
dflt, r.key, m, r.key, nxt);
EXECUTE format('ALTER TABLE app.%I ATTACH PARTITION app.%I FOR VALUES FROM (%L) TO (%L)',
r.tbl, part, m, nxt);
RAISE NOTICE '% 的 default 里有 % 的行,已搬进新分区 %', r.tbl, to_char(m, 'YYYY-MM'), part;
END IF;
END LOOP;
END LOOP;
END $$;
-- ⭐ 群资料(2026-09-23):会话右栏「群资料」要显示群公告和成员头像。
--
-- ⚠️ 数据本来就在 [13100] 的返回里(noticeInfo / roomMemberList[].avatar),同步时一直丢着没存。
-- ⛔ 别为了显示公告在打开会话时现拉 ECom —— 那是一次同步的外部往返挂在打开会话的路上。
-- ⚠️ 公告三列一起空 = 没有公告(或还没同步到);界面写「没有群公告」。
ALTER TABLE app.wecom_rooms
ADD COLUMN notice_content text,
ADD COLUMN notice_publisher_vid text,
ADD COLUMN notice_published_at timestamptz(3);
-- ⚠️ 成员头像只给**不在通讯录里**的群成员兜底用(在通讯录里的走 wecom_contacts.avatar)。
-- 浏览器⛔ 直接拿这条 URL,一律经 /api/avatars 中转(同通讯录头像)。
ALTER TABLE app.wecom_room_members
ADD COLUMN avatar text;
-- ⭐ 让现有的群在下一轮会话对账(每 10 分钟)里重拉一次 [13100],把公告和成员头像补上。
--
-- ⚠️ 为什么要这一条:`RoomSyncService.syncMissing` 只重拉**超过 24 小时没更新**的群。
-- 不加的话,上线当天打开任何一个老群,群资料都写「没有群公告」—— 那不是没有,是还没拉。
-- ⚠️ 只改 updated_at,⛔ 不碰别的列:重拉本来就是整群快照覆盖。
-- ⚠️ 重拉按号分批(每批 ≤10 个群,[13100] 的接口约束),和平时对账同一条路。
UPDATE app.wecom_rooms SET updated_at = now() - interval '25 hours';
-- ⭐ 修上一条(20260923120100_room_notice_backfill):它**没有生效**。
--
-- ⚠️⚠️ wecom_rooms 上有 BEFORE UPDATE 触发器 trg_wecom_rooms_updated_at(app.set_updated_at),
-- 任何 UPDATE 都会把 updated_at 改成 now() ⇒ 「往回拨 25 小时」当场被改回「刚更新过」,
-- 对账反而认为所有群都是新的、一个都不拉(2026-09-23 本地实测:66 个群的 updated_at 全是同一秒)。
-- ⇒ 在这一条里临时关掉这个触发器,改完立刻打开。⚠️ 只关这一个触发器,⛔ 不是 DISABLE TRIGGER ALL
-- (那会连外键约束的内部触发器一起关)。
-- ⚠️ 上一条已经在一些库里记为已应用,⛔ 不改它(改了校验和对不上)⇒ 另起这一条。
ALTER TABLE app.wecom_rooms DISABLE TRIGGER trg_wecom_rooms_updated_at;
UPDATE app.wecom_rooms SET updated_at = now() - interval '25 hours';
ALTER TABLE app.wecom_rooms ENABLE TRIGGER trg_wecom_rooms_updated_at;
-- ⭐⭐ 提醒发给**这条会话的那个企微号**(2026-09-23 拍板),⛔ 不从人推算他的号。
--
-- ⚠️ 原来交接只记 owner_user_id(人),调度器再按「这个人名下第一个接患者的号」找地址。
-- 本地撞出来的后果:五光那条挂在罗启号上,罗启号又是提醒号 ⇒ 被排除 ⇒
-- 落到同一个人名下的**孙武东号** ⇒ 两级提醒都发给了孙武东,罗启手机上什么都没有。
-- ⇒ 那个推算的前提「名下的号 = 本人在通讯录里的身份」一托管别人的号就不成立。
-- ⭐ 现在开交接时就把**号**记下来(单聊 = 这条会话的号;群里 = 被 @ 的托管号 / 群主那个托管号),
-- 调度器直接发给它。⚠️ 这个号就是提醒号 ⇒ 这一级没有收件人(⛔ 不自己提醒自己),梯子往上走。
-- ⚠️ 同 owner_user_id ⛔ 不挂外键(号只会解除托管、⛔ 不删行;真没了 ⇒ join 不到 ⇒ 这一级没有收件人)。
ALTER TABLE app.conversation_handoffs
ADD COLUMN owner_account_id uuid;
-- ⚠️ 回填只补**还开着**的:已经回了的不再参与提醒,补了也没人读。
-- 单聊 = 会话那个号;群 = 群主那个托管号(@ 是按消息判的,事后还原不了 ⇒ 退到群主,同 resolveOwner)。
UPDATE app.conversation_handoffs h
SET owner_account_id = c.account_id
FROM app.conversations c
WHERE c.tenant_id = h.tenant_id AND c.id = h.conversation_id
AND c.kind <> 'group'
AND h.responded_at IS NULL AND h.owner_account_id IS NULL;
UPDATE app.conversation_handoffs h
SET owner_account_id = a.id
FROM app.conversations c
JOIN app.wecom_rooms r ON r.account_id = c.account_id AND r.room_id = c.room_id
JOIN app.wecom_accounts a ON a.tenant_id = c.tenant_id AND a.vid = r.owner_vid
WHERE c.tenant_id = h.tenant_id AND c.id = h.conversation_id
AND c.kind = 'group'
AND h.responded_at IS NULL AND h.owner_account_id IS NULL;
-- ⭐ 会话里那一行「已提醒 N 次 · 发到哪」要按交接取它的提醒 —— 提醒的投递记录就是 outbox 行
-- (表上写明⛔ 不另开提醒表)。outbox 只增不删 ⇒ ⛔ 别全表扫,给这一类建部分索引。
CREATE INDEX idx_outbox_notify_handoff
ON app.outbox ((payload->>'handoffId'))
WHERE task_type = 'outbound.ecom.notify';
-- ⭐ 交接摘要(2026-09-23):提醒里那句「怎么回事」—— 被提醒的人不打开工作台也知道前因后果。
--
-- ⚠️ 原来提醒只有「谁 · 等了多久 · 他最后一句」,接到的人只看到「谁来接待下」,完全不知道在说哪件事。
-- ⭐ 由接待模型在**判定要转人工的那一轮**顺手写(submit 的 handoffBrief,⛔ 不多一次调用);
-- 并进同一件事(患者又催)时换成最新那份 —— 它是看过整段对话之后写的,比旧的更全。
-- ⚠️ 可空:规则腿(词表命中)、模型挂了、守卫拦下那一轮都没有可信的摘要,提醒里就只有原话。
ALTER TABLE app.conversation_handoffs
ADD COLUMN brief text;
-- ⭐ 转人工以后 5 分钟没人回 ⇒ 先回患者一句(2026-09-23 评审会:「客户最烦没人回,必回,回得委婉一点」)。
--
-- ⚠️ 原来的口径是「转人工的一律不对患者开口」(2026-09-07)⇒ 人没回之前患者一个字都收不到,
-- 说「牙龈出血止不住」的人连一句「紧急就去急诊」都没有。
-- ⭐ 一条交接只垫一句:记下发过的时刻,⛔ 不按催的次数再发(催了走提醒阶梯,叫的是人)。
-- ⚠️ 可空 = 还没垫过。人回了、过了窗口、闸门按停着 ⇒ 永远是空的,那是对的。
ALTER TABLE app.conversation_handoffs
ADD COLUMN hold_sent_at timestamptz;
-- ⭐ 调度器每分钟按「开了多久」扫还没垫过的;只扫还开着的,集合天然很小
CREATE INDEX idx_handoff_hold ON app.conversation_handoffs (opened_at)
WHERE responded_at IS NULL AND hold_sent_at IS NULL;
-- ⭐⭐ **同事的个人微信**(2026-09-23 评审会:曹萌萌说的「绑个微」其实是这个意思 —— 在群里认得出这是自己人)。
--
-- ═══ 它补的是哪个洞 ═══════════════════════════════════════════════════════════
--
-- 「我们的人」原来只认两种:我们托管的号、同一个企业的同事(通讯录内部成员 / corpId 相同)。
-- ⚠️ 同事用**个人微信**进患者群(很常见)⇒ 在我们这里就是一个外部成员,落在「还没绑定」里:
-- · 他在群里回了患者 ⇒ ⛔ 不算「人回了」—— 提醒梯子照爬,把本店群和经理都叫起来
-- · 转人工 5 分钟后,AI 还会再垫一句「收到,我帮您问一下」—— 而同事早就回过了
-- · 群资料里他挂在「还没绑定」,看着像一个没绑的患者
-- ⇒ 由人在群资料里标一下「是同事」。⭐ 标的是**这个微信**(vid 在企微里是全局的,实测同一个人在两个号眼里 vid 相同),
-- ⇒ 按租户存一份,这个集团所有群里都认得他。
--
-- ⚠️ ⛔ 不能标已经绑了患者的人(服务端拦)—— 标成同事之后他说的话会结掉交接,
-- 标错一个患者 = 他在群里说什么都没人被叫到。
-- ⚠️ 只在**群里**生效。单聊里他就是对面那个人,⛔ 不能拿他的话当「我们回了」。
CREATE TABLE app.colleague_wechats (
tenant_id uuid NOT NULL,
vid text NOT NULL,
-- 标的时候他在群里叫什么 —— 只给审计 / 排错看,界面上的名字照旧现取
name text,
marked_by uuid,
marked_at timestamptz(6) NOT NULL DEFAULT now(),
CONSTRAINT pk_colleague_wechats PRIMARY KEY (tenant_id, vid),
CONSTRAINT fk_cw_tenant FOREIGN KEY (tenant_id) REFERENCES app.tenants (id) ON DELETE CASCADE,
CONSTRAINT ck_cw_vid CHECK (char_length(vid) BETWEEN 1 AND 64)
);
-- ⭐⭐ 从群里加好友([11071] 申请添加群成员为好友,2026-09-28 用户:「接上群成员加好友」)。
--
-- ═══ 它补的是哪个洞 ═══════════════════════════════════════════════════════════
--
-- 群资料「还没绑定」里不是好友的人(比如家属拉进群的亲戚)原来只写一句「不是好友,绑不了」——
-- 企业微信只能按手机号搜人,而我们手上没有他的手机号。[11071] 用群成员身份直接发申请,不要手机号。
--
-- ⚠️ 和手机号那条走**同一张表、同一组闸**(在线 / 24 小时配额 / 同一个人 24 小时一次 / 最小间隔):
-- 配额和间隔是按号算的,两条路分两张表就会各算各的,一个号就能发出两倍的申请。
-- ⇒ 这张表多一列 `source` 说是哪条路;群里那条没有手机号 ⇒ 两列手机号放开可空,去重改认对方 vid。
ALTER TABLE app.friend_add_attempts
ALTER COLUMN phone_hash DROP NOT NULL,
ALTER COLUMN phone_masked DROP NOT NULL,
-- phone(按手机号搜到的) | room(群成员)
ADD COLUMN source text NOT NULL DEFAULT 'phone',
-- 从哪个群加的。⚠️ 只有 source = room 才有
ADD COLUMN room_id text;
ALTER TABLE app.friend_add_attempts
ADD CONSTRAINT ck_faa_source CHECK (source IN ('phone', 'room')),
-- ⚠️ 每条路自己的去重键必须在:手机号那条认 phone_hash,群那条认 target_vid
ADD CONSTRAINT ck_faa_source_key CHECK (
(source = 'phone' AND phone_hash IS NOT NULL AND phone_masked IS NOT NULL)
OR (source = 'room' AND room_id IS NOT NULL AND target_vid IS NOT NULL));
-- 去重查询:「这个号 24 小时内给这个人发过申请吗」(群那条路按对方 vid 认)
CREATE INDEX idx_faa_target ON app.friend_add_attempts (account_id, target_vid, created_at DESC)
WHERE target_vid IS NOT NULL;
-- ⭐⭐ 模型活动调试记录(2026-09-29 用户:「调提示词是没有优化边界的,改了这个丢了那个,而且还有随机性,
-- 我需要你开发各面板用于 debug,记录模型的所有有序活动,包括思考、调用工具、输出」)。
--
-- ⚠️⚠️ 它存了**输入全文**(system + 会话)、**思考**、**工具查到的内容** —— 和 llm_calls「刻意不存 prompt 全文」
-- ([14 §5.2]:存下来就是又一份患者数据副本)正好相反。⇒ 三条闸,缺一条都⛔ 不该开:
-- · 单独一张表,只作调试;⛔ 不拿它当举证(举证归 llm_calls,那张表的口径⛔ 不动)
-- · 默认只留 7 天(AI_DEBUG_TRACE_DAYS),整个能关(AI_DEBUG_TRACE=off)—— 调度器每天清
-- · 只有 ai.debug.view(owner)看得到,还要过会话的数据范围
--
-- ⭐ 一行 = 一轮里的一件事,按 round_id 串起来、seq 定顺序:
-- reception 接待模型那一次调用(输入 → 每一步的思考 / 工具调用 / 工具返回 → 交答案)
-- guard_review 审核模型
-- guard_arbiter 裁决模型(审核和代码守卫不一致时才有)
-- decision 代码守卫的结论 + 最终判定
-- outcome 这一轮最后去向(发出去了没、草稿、交接)
-- ⚠️ ⛔ 不挂外键:它是短命的调试数据,⛔ 不该挡住会话 / 租户的任何删除;到期整行清掉。
-- ⚠️ ⛔ 不分区:只留 7 天,量小;按 created_at 删。
CREATE TABLE app.llm_traces (
id uuid NOT NULL DEFAULT app.uuidv7(),
tenant_id uuid NOT NULL,
conversation_id uuid,
round_id uuid NOT NULL,
seq smallint NOT NULL,
kind text NOT NULL,
model text,
prompt_version text,
status text NOT NULL DEFAULT 'ok',
error text,
started_at timestamptz(3) NOT NULL,
latency_ms integer,
summary jsonb,
events jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT llm_traces_pkey PRIMARY KEY (id),
CONSTRAINT ck_llm_trace_kind CHECK (kind IN ('reception', 'guard_review', 'guard_arbiter', 'decision', 'outcome')),
CONSTRAINT ck_llm_trace_status CHECK (status IN ('ok', 'error'))
);
-- 一轮的全部,按顺序取
CREATE INDEX idx_llm_trace_round ON app.llm_traces (round_id, seq);
-- 调试页的列表:最近的轮次(全租户 / 某条会话)
CREATE INDEX idx_llm_trace_recent ON app.llm_traces (tenant_id, started_at DESC);
CREATE INDEX idx_llm_trace_conv ON app.llm_traces (tenant_id, conversation_id, started_at DESC);
-- 到期清理
CREATE INDEX idx_llm_trace_created ON app.llm_traces (created_at);
-- ⭐⭐ 交接隔天就算新的一件事(2026-10-02 用户:「提醒规则有没有超时的,现在隔了很久还是在上次提醒中」)。
--
-- ⚠️ 原来一条交接只有「人回了一句」才结束 ⇒ 9/30 那句「把店长手机号给我」没人回,
-- 10/2 他问「我下次预约是什么时候」也并了进去,提醒成了「他又催了,已经等了 3205 分钟」,
-- 原话是新的、前因后果是两天前那件事。
-- ⭐ 判据:开着的这条,患者上一次说要人回的话(并进来的那一句,没有就是开的那一句)在**前一天或更早**
-- (按上海的日历日)⇒ 这一条接替它:旧的记 superseded_at、⛔ 不再提醒(它的提醒早就走完了),
-- 新的从第 0 级重新走。同一天里照旧算「又催了」。
-- ⚠️ superseded_at ⛔ 不是「人回了」—— responded_at 仍然只记人回的那一刻(响应时长靠它)。
ALTER TABLE app.conversation_handoffs ADD COLUMN superseded_at timestamptz(6);
-- ⭐ 摘要是哪一刻写的 —— 并进来那一轮没写摘要时,提醒里要说清楚摘要讲的是「之前那件事」,
-- ⛔ 别让新的原话配着旧的前因后果读成同一件事。
ALTER TABLE app.conversation_handoffs ADD COLUMN brief_at timestamptz(6);
-- ⚠️ 老数据不知道摘要是哪一轮写的 ⇒ 当作最新那一轮写的(⛔ 不平白给老交接打上「之前那件事」)
UPDATE app.conversation_handoffs SET brief_at = coalesce(bumped_at, opened_at) WHERE brief IS NOT NULL;
-- ⭐ 「还开着」= 人没回、也没被接替。⚠️ 谓词改了,所有 ON CONFLICT 要逐字跟上(42P10)
DROP INDEX app.uq_handoff_open;
CREATE UNIQUE INDEX uq_handoff_open ON app.conversation_handoffs (tenant_id, conversation_id)
WHERE responded_at IS NULL AND superseded_at IS NULL;
DROP INDEX app.idx_handoff_open;
CREATE INDEX idx_handoff_open ON app.conversation_handoffs (tenant_id, conversation_id)
WHERE responded_at IS NULL AND superseded_at IS NULL;
DROP INDEX app.idx_handoff_due;
CREATE INDEX idx_handoff_due ON app.conversation_handoffs (notified_at ASC NULLS FIRST)
WHERE responded_at IS NULL AND superseded_at IS NULL AND escalation_done_at IS NULL;
DROP INDEX app.idx_handoff_hold;
CREATE INDEX idx_handoff_hold ON app.conversation_handoffs (opened_at)
WHERE responded_at IS NULL AND superseded_at IS NULL AND hold_sent_at IS NULL;
-- ⭐ 会话里开的改约 / 取消待办补上店和预约时刻(2026-10-04 测 L01:「这里我没看到有何一舟的」)。
--
-- ⚠️ 原来会话那条腿开待办时 org_unit_id、due_at 都空着 ⇒ 预约确认列表按当前诊所收窄、按预约那天切,
-- 这条哪一档都不在,人在待办中心找不到它;它又挡着同一条预约的前一天提醒(活跃待办去重)。
-- 代码 2026-10-04 起建的时候就填(reception.service),这里补老数据。
-- ⭐ 从这条待办自己存的预约快照取(最新那一份 read_context):店 = 那条预约的诊所在组织树上的关联,时刻 = 预约时刻。
-- ⚠️ 没关联的店 ⇒ 留空,⛔ 不猜;预约时刻早于建待办那一刻 ⇒ 留空(ck_tasks_time_order)。
WITH snap AS (
SELECT DISTINCT ON (j.task_id)
j.task_id,
j.result ->> 'clinicId' AS clinic,
(j.result ->> 'plannedFor')::timestamptz AS planned
FROM app.jobs j
WHERE j.job_type = 'read_context' AND j.status = 'done'
ORDER BY j.task_id, j.seq DESC
)
UPDATE app.tasks t
SET org_unit_id = coalesce(t.org_unit_id, (
SELECT ref.org_unit_id FROM app.org_unit_refs ref
WHERE ref.tenant_id = t.tenant_id AND ref.host_code = t.host_code AND ref.external_id = s.clinic
LIMIT 1)),
due_at = coalesce(t.due_at, CASE WHEN s.planned >= t.scheduled_at THEN s.planned END)
FROM snap s
WHERE s.task_id = t.id
AND t.task_kind = 'appointment_confirm'
AND t.trigger_source = 'conversation'
AND (t.org_unit_id IS NULL OR t.due_at IS NULL);
-- ⭐ 称呼要的姓和性别也缓存到绑定上(2026-10-04 测 L01:提醒只说「您好」,业务系统里明明登记着「男」)。
--
-- ⚠️ 原来 AI 聊天装配「现在的情况」时只读本地:姓用企微实名、性别用企微 sex(个微好友常常没填),
-- 前一天提醒用业务系统的名字、却也只看企微 sex ⇒ 两边各取各的,都可能缺性别。
-- ⭐ 现在两边同一个函数(patient-address.ts 的 algoAddressFor):本人的微信,业务系统登记的姓、性别优先。
-- 聊天那边不每轮去问业务系统 ⇒ 读到患者资料时(查患者要点、前一天提醒)顺手回写,同年龄段那一列。
-- ⚠️ 只存派生出来的:姓(⛔ 不存全名)、性别 —— 压小患者数据足迹,同 patient_age_band 的理由。NULL = 还没读到过。
ALTER TABLE app.contact_links ADD COLUMN patient_surname text;
ALTER TABLE app.contact_links ADD COLUMN patient_gender text;
ALTER TABLE app.contact_links
ADD CONSTRAINT ck_cl_patient_surname CHECK (patient_surname IS NULL OR char_length(patient_surname) BETWEEN 1 AND 2);
ALTER TABLE app.contact_links
ADD CONSTRAINT ck_cl_patient_gender CHECK (patient_gender IS NULL OR patient_gender IN ('male', 'female'));
ALTER TABLE app.tenants ADD COLUMN partner_org_root_ref text NOT NULL DEFAULT '';
-- 已有独立集团数据空间保持原范围;合成市场根必须显式配置客户范围。
UPDATE app.tenants SET partner_org_root_ref = partner_tenant_id
WHERE NOT (partner_code = 'friday' AND partner_tenant_id = 'friday-market');
DROP INDEX app.uq_tenants_partner;
CREATE UNIQUE INDEX uq_tenants_partner_root ON app.tenants(partner_code, partner_tenant_id, partner_org_root_ref);
CREATE TABLE app.auth_sessions (
id uuid PRIMARY KEY DEFAULT app.uuidv7(), tenant_id uuid NOT NULL, user_id uuid NOT NULL,
source text NOT NULL DEFAULT 'password' CHECK (source IN ('password','activation','development')),
token_version integer NOT NULL, refresh_hash text NOT NULL,
previous_refresh_hash text, refresh_generation integer NOT NULL DEFAULT 0,
last_request_id text, rotated_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL, idle_expires_at timestamptz NOT NULL,
revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (tenant_id,user_id) REFERENCES app.users(tenant_id,id),
CONSTRAINT ck_auth_sessions_expiry CHECK (expires_at > created_at AND idle_expires_at <= expires_at)
);
CREATE INDEX idx_auth_sessions_user ON app.auth_sessions(tenant_id,user_id);
CREATE INDEX idx_auth_sessions_expiry ON app.auth_sessions(expires_at);
COMMENT ON TABLE app.auth_sessions IS '统一网页会话;无刷新凭据原文,支持轮换与单会话撤销';
ALTER TABLE app.tenants ADD COLUMN integration_client_id text, ADD COLUMN source_tenant_id text,
ADD COLUMN integration_enabled boolean NOT NULL DEFAULT false, ADD COLUMN integration_policy jsonb NOT NULL DEFAULT '{}';
CREATE UNIQUE INDEX uq_tenants_integration_source ON app.tenants(integration_client_id,source_tenant_id);
ALTER TABLE app.users ADD COLUMN integration_managed_role boolean NOT NULL DEFAULT false,
ADD COLUMN integration_managed_name boolean NOT NULL DEFAULT false,
ADD COLUMN integration_managed_scope boolean NOT NULL DEFAULT false;
ALTER TABLE app.user_org_scopes ADD COLUMN managed_by_client_id text;
ALTER TABLE app.auth_sessions ADD COLUMN integration_context jsonb;
ALTER TABLE app.auth_sessions DROP CONSTRAINT auth_sessions_source_check;
ALTER TABLE app.auth_sessions ADD CONSTRAINT auth_sessions_source_check CHECK (source IN ('password','activation','development','host'));
CREATE TABLE app.integration_authorizations (
id uuid PRIMARY KEY DEFAULT app.uuidv7(), tenant_id uuid NOT NULL REFERENCES app.tenants(id),
client_id text NOT NULL, source_tenant_id text NOT NULL, kind text NOT NULL CHECK (kind IN ('launch','revoke')),
idempotency_key uuid NOT NULL, request_hash text NOT NULL, request jsonb NOT NULL,
code_hash text UNIQUE, code_nonce text, code_expires_at timestamptz, consumed_at timestamptz,
exchange_request_id uuid, session_id uuid, source_user_id text, source_session_id text, verified_at timestamptz,
target_type text, target_id text, effective_at timestamptz, revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_integration_authorizations_idempotency UNIQUE(client_id,kind,idempotency_key)
);
CREATE INDEX idx_integration_authorizations_fence ON app.integration_authorizations(tenant_id,client_id,kind,target_type,target_id,effective_at);
CREATE INDEX idx_integration_authorizations_source_session ON app.integration_authorizations(tenant_id,client_id,source_session_id);
COMMENT ON TABLE app.integration_authorizations IS '宿主授权与撤权账本;进入码只存摘要,撤销截止线不得随幂等结果过期而删除';
ALTER TABLE app.outbox ADD COLUMN authorization_context jsonb DEFAULT NULLIF(current_setting('friday_ai.host_authorization', true), '')::jsonb;
CREATE FUNCTION app.stamp_host_authorization() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.authorization_context IS NULL THEN
NEW.authorization_context := NULLIF(current_setting('friday_ai.host_authorization', true), '')::jsonb;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER stamp_host_authorization BEFORE INSERT ON app.outbox FOR EACH ROW EXECUTE FUNCTION app.stamp_host_authorization();
BEGIN;
CREATE TABLE app.integration_apps (
app_id text PRIMARY KEY, host_code text NOT NULL, enabled boolean NOT NULL DEFAULT false,
secret_hashes text[] NOT NULL DEFAULT '{}', web_base_url text NOT NULL DEFAULT '',
modules text[] NOT NULL DEFAULT '{workbench,appointment-confirm}', allowed_roles text[] NOT NULL DEFAULT '{staff,viewer}',
capabilities text[] NOT NULL DEFAULT '{}', max_session_seconds integer NOT NULL DEFAULT 28800 CHECK (max_session_seconds BETWEEN 60 AND 28800),
requests_per_minute integer NOT NULL DEFAULT 120 CHECK (requests_per_minute BETWEEN 1 AND 10000),
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
-- 旧应用先保留为停用占位行,凭据由显式 CLI 导入,不从文件或日志读取。
INSERT INTO app.integration_apps(app_id,host_code)
SELECT integration_client_id,min(partner_code) FROM app.tenants WHERE integration_client_id IS NOT NULL GROUP BY integration_client_id;
-- 已绑定的人员身份迁到固定内部集团命名空间,保持原 user_id,不按姓名/手机号合并。
INSERT INTO app.user_identities(provider,external_id,user_id)
SELECT 'host:' || encode(sha256(convert_to(t.integration_client_id,'UTF8') || decode('00','hex') || convert_to(t.id::text,'UTF8')),'hex'), i.external_id,i.user_id
FROM app.tenants t JOIN app.users u ON u.tenant_id=t.id
JOIN app.user_identities i ON i.user_id=u.id AND i.provider='host:' || encode(sha256(convert_to(t.integration_client_id,'UTF8') || decode('00','hex') || convert_to(t.source_tenant_id,'UTF8')),'hex')
WHERE t.integration_client_id IS NOT NULL AND t.source_tenant_id IS NOT NULL;
UPDATE app.auth_sessions SET revoked_at=now() WHERE source='host' AND revoked_at IS NULL;
UPDATE app.integration_authorizations SET revoked_at=now() WHERE kind='launch' AND revoked_at IS NULL;
UPDATE app.integration_authorizations a SET source_tenant_id=t.partner_tenant_id FROM app.tenants t WHERE a.tenant_id=t.id;
-- 旧源角色映射只用于迁移已有能力上限;新的请求不接受任何源角色编码。
UPDATE app.tenants SET integration_policy=(integration_policy - 'roleMappings') || jsonb_build_object(
'version',2,'allowedRoles',jsonb_build_array('staff','viewer'),
'capabilities',COALESCE((SELECT jsonb_agg(DISTINCT c.value) FROM jsonb_each(integration_policy->'roleMappings') m,
jsonb_array_elements(m.value->'capabilities') c),'[]'::jsonb))
WHERE integration_client_id IS NOT NULL AND integration_policy->>'version'='1';
DROP INDEX app.uq_tenants_integration_source;
ALTER TABLE app.tenants DROP COLUMN source_tenant_id;
ALTER TABLE app.tenants ADD CONSTRAINT fk_tenants_integration_app FOREIGN KEY(integration_client_id) REFERENCES app.integration_apps(app_id) ON DELETE RESTRICT;
CREATE INDEX idx_tenants_integration_space ON app.tenants(integration_client_id,partner_tenant_id);
DROP INDEX app.idx_integration_authorizations_fence;
DROP INDEX app.idx_integration_authorizations_source_session;
ALTER TABLE app.integration_authorizations DROP COLUMN source_session_id, DROP COLUMN verified_at, DROP COLUMN target_type, DROP COLUMN target_id, DROP COLUMN effective_at;
CREATE INDEX idx_integration_authorizations_user ON app.integration_authorizations(tenant_id,client_id,source_tenant_id,source_user_id,kind);
COMMENT ON TABLE app.integration_apps IS '宿主应用准入;只保存凭据摘要,复用 AI 集团和组织结构,不要求外部 customerId';
COMMIT;
-- 平台级运输回执,不属于租户业务表;与 webhook/outbox 同事务写入。
CREATE TABLE app.ingress_receipts (
id uuid PRIMARY KEY,
payload_hash varchar(64) NOT NULL,
received_at timestamptz(3) NOT NULL DEFAULT clock_timestamp()
);
-- 回执不自动过期,保证旧投递重放仍幂等。只存 ID/hash,不存消息副本。
CREATE TABLE app.hosting_seats (
id uuid PRIMARY KEY DEFAULT app.uuidv7(),
tenant_id uuid NOT NULL REFERENCES app.tenants(id) ON DELETE RESTRICT,
account_id uuid UNIQUE,
expires_at timestamptz(3), revoked_at timestamptz(3),
authorized_user_id uuid,
bind_expires_at timestamptz(3), reservation_id text UNIQUE, device_id text,
reserved_at timestamptz(3), note text,
created_at timestamptz(3) NOT NULL DEFAULT now(),
updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT fk_hosting_account FOREIGN KEY (tenant_id, account_id) REFERENCES app.wecom_accounts(tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT fk_hosting_user FOREIGN KEY (tenant_id, authorized_user_id) REFERENCES app.users(tenant_id, id) ON DELETE RESTRICT,
CONSTRAINT ck_hosting_grant CHECK ((authorized_user_id IS NULL) = (bind_expires_at IS NULL)),
CONSTRAINT ck_hosting_reservation CHECK ((reservation_id IS NULL) = (reserved_at IS NULL)),
CONSTRAINT ck_hosting_device CHECK (device_id IS NULL OR reservation_id IS NOT NULL)
);
CREATE INDEX hosting_seats_tenant_id_authorized_user_id_idx ON app.hosting_seats(tenant_id, authorized_user_id);
CREATE UNIQUE INDEX uq_hosting_pending_user ON app.hosting_seats(tenant_id, authorized_user_id)
WHERE account_id IS NULL AND authorized_user_id IS NOT NULL;
-- 兼容存量:不因升级中断已开通账号。后续可通过 CLI 设置明确的到期时间。
INSERT INTO app.hosting_seats(tenant_id, account_id, note)
SELECT tenant_id, id, '存量账号迁移:永久席位,待运营核对期限' FROM app.wecom_accounts;
BEGIN;
-- 统一账号,分范围授权;客户业务表仍保留 NOT NULL tenant_id 和复合外键。
ALTER TABLE app.users ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE app.users ADD CONSTRAINT ck_platform_user_profile CHECK
(tenant_id IS NOT NULL OR (org_unit_id IS NULL AND job_title IS NULL AND data_scope='scoped'));
CREATE TABLE app.user_role_grants (
id uuid PRIMARY KEY DEFAULT app.uuidv7(),
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE ON UPDATE CASCADE,
scope text NOT NULL, tenant_id uuid REFERENCES app.tenants(id) ON DELETE RESTRICT,
role text NOT NULL, source text NOT NULL DEFAULT 'manual', granted_by uuid,
created_at timestamptz(3) NOT NULL DEFAULT now(), updated_at timestamptz(3) NOT NULL DEFAULT now(),
CONSTRAINT uq_role_grant_user_scope UNIQUE(user_id,scope),
CONSTRAINT ck_role_grant_scope CHECK (
(scope='tenant' AND tenant_id IS NOT NULL AND role IN ('owner','staff','viewer')) OR
(scope='platform' AND tenant_id IS NULL AND role IN ('platform_admin','platform_viewer'))),
CONSTRAINT ck_role_grant_source CHECK (source IN ('manual','host','migration')),
CONSTRAINT ck_role_grant_host CHECK (source<>'host' OR scope='tenant'),
CONSTRAINT fk_role_grant_tenant_user FOREIGN KEY(tenant_id,user_id) REFERENCES app.users(tenant_id,id) ON DELETE CASCADE
);
CREATE INDEX user_role_grants_tenant_id_role_idx ON app.user_role_grants(tenant_id,role);
INSERT INTO app.user_role_grants(user_id,scope,tenant_id,role,source)
SELECT id,'tenant',tenant_id,role,CASE WHEN integration_managed_role THEN 'host' ELSE 'migration' END FROM app.users WHERE tenant_id IS NOT NULL;
-- role 是兼容投影,授权表是唯一可修改的角色来源。开户时允许一次初始角色。
CREATE FUNCTION app.project_user_role() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF COALESCE(NEW.scope,OLD.scope)='tenant' THEN
UPDATE app.users SET role=CASE WHEN TG_OP='DELETE' THEN 'viewer' ELSE NEW.role END,
integration_managed_role=CASE WHEN TG_OP='DELETE' THEN false ELSE NEW.source='host' END
WHERE id=COALESCE(NEW.user_id,OLD.user_id);
END IF;
RETURN COALESCE(NEW,OLD);
END $$;
CREATE TRIGGER role_grant_projection AFTER INSERT OR UPDATE OR DELETE ON app.user_role_grants FOR EACH ROW EXECUTE FUNCTION app.project_user_role();
CREATE FUNCTION app.protect_user_role() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN RAISE EXCEPTION '用户租户归属不可直接变更'; END IF;
IF pg_trigger_depth()=1 AND (NEW.role IS DISTINCT FROM OLD.role OR NEW.integration_managed_role IS DISTINCT FROM OLD.integration_managed_role) THEN
RAISE EXCEPTION '请通过 user_role_grants 修改角色';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER user_role_projection_guard BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.protect_user_role();
CREATE FUNCTION app.initial_user_role() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.tenant_id IS NOT NULL THEN
INSERT INTO app.user_role_grants(user_id,scope,tenant_id,role,source)
VALUES(NEW.id,'tenant',NEW.tenant_id,NEW.role,CASE WHEN NEW.integration_managed_role THEN 'host' ELSE 'manual' END);
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER user_initial_role AFTER INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.initial_user_role();
ALTER TABLE app.auth_sessions ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE app.auth_sessions ADD COLUMN scope text NOT NULL DEFAULT 'tenant';
ALTER TABLE app.auth_sessions ADD CONSTRAINT ck_session_scope CHECK
((scope='tenant' AND tenant_id IS NOT NULL) OR (scope='platform' AND tenant_id IS NULL AND source='password'));
-- 保留原租户复合外键,另补平台账号的单列引用。
ALTER TABLE app.auth_sessions ADD CONSTRAINT fk_session_global_user FOREIGN KEY(user_id) REFERENCES app.users(id) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE app.audit_logs ALTER COLUMN tenant_id DROP NOT NULL;
ALTER TABLE app.audit_logs ADD COLUMN scope text NOT NULL DEFAULT 'tenant';
ALTER TABLE app.audit_logs ADD CONSTRAINT ck_audit_scope CHECK
((scope='tenant' AND tenant_id IS NOT NULL) OR scope='platform');
CREATE INDEX idx_audit_scope_time ON app.audit_logs(scope,occurred_at DESC,id DESC);
COMMIT;
This source diff could not be displayed because it is too large. You can view the blob instead.
import { PlatformModule } from './modules/platform/platform.module';
import { IntegrationAccessGuard } from './modules/integration/integration-access.service';
import { IntegrationModule } from './modules/integration/integration.module';
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 { AlertingModule } from './common/alerting/alerting.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 { ClinicModule } from './modules/clinic/clinic.module';
import { CorpusModule } from './modules/corpus/corpus.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():告警是横切的(outbox / scheduling / channel 都要),
// 逐个模块 import 时漏掉的那个就是"这里出事没人知道"
AlertingModule,
// ⚠️ @Global():摄入侧发布、工作台订阅,必须是同一个实例
RealtimeModule,
AuthModule,
PlatformModule,
IntegrationModule,
ChannelModule,
MediaModule,
PacModule,
// ⭐ 门店信息 —— 录入页 + 接待 AI 的 get_clinic_info 共用一份
ClinicModule,
CorpusModule,
OutboundModule,
OutboxModule,
SchedulingModule,
AgentModule,
WorkbenchModule,
],
controllers: [HealthController],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },
// ⚠️ 全局登录守卫。@Public() 的端点(两个探针 + 登录三件套)跳过。
// ⛔ 加新的公开端点时别忘了 @Public() —— 探针拿 401 会让 systemd 无限重启。
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: IntegrationAccessGuard },
// 响应腿上的顺序(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 {}
import { createCliApp } from './cli-app';
/**
* 拿一条真会话 + 一句话去问模型,把**完整往返**打出来。
*
* ⚠️ 它跑的是**生产同一条路**(`ReceptionService.handle`):规则先判,没命中才问模型,
* 然后落 `llm_calls`、按结果建 Task / 存草稿。⛔ 不是一个"只调模型"的玩具。
*
* ⚠️ **会真的花钱**(一次 qwen3.8-max 调用),也会真的建 Task / 写草稿。
* ⛔ **不会发消息** —— ⚠️ 理由是下面**不传 `appInfo`**(发送的硬前提),
* ⛔ 不是"这个场景没有发消息的动作"(它有)。加上 `appInfo` 就会真发。
*
* 用法:pnpm --filter @pac/ai-service agent:advise "患者说的话"
*/
import 'dotenv/config';
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 createCliApp({ 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); });
import { createCliApp } from './cli-app';
/**
* 打印一条会话**这一轮送给模型的完整输入**。
*
* ⚠️⚠️ 2026-09-08 之前它只打印**五段里的一段**(会话记录),
* 而排查"模型为什么这么答"时看到的就是那 1/5 —— 偏偏媒体说明和事实缓存
* 是最容易出事的两段。现在走 `assembleRound`,和真跑**同一个装配**。
*
* ⚠️ 它存在的理由是**让上下文装配可验证**:接模型之前,人要能一眼看出
* "喂进去的会是什么"。⛔ 别等到有模型了再回头查这一层 ——
* 那时候错的表现是"模型胡说",而根因在这里。
*
* 用法:pnpm --filter @pac/ai-service agent:context <conversationId>
*/
import 'dotenv/config';
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 createCliApp({ 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); });
import { createCliApp } from './cli-app';
/**
* 手动跑一次预约确认扫描。
*
* ⚠️ 和定时任务跑的是**同一段逻辑**([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 remind 2026-10-06T05:30:00Z # ⭐ 只发前一天的提醒
* ↑ 拿假的「现在」演练时用这个:⛔ 不做中途关怀、⛔ 不跟进已开的待办。
* ⚠️ 不带 remind 直接给假时间,同一轮的跟进会把刚发的提醒当成早就没回,当场再追问一句。
* 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 { AppointmentConfirmService } from '../modules/agent/scenes/appointment-confirm/appointment-confirm.service';
async function main() {
const sweep = process.argv[2] === 'sweep';
const remindOnly = process.argv[2] === 'remind';
const arg = sweep || remindOnly ? 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 createCliApp({
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 if (remindOnly) {
console.log(`以 ${now.toISOString()} 为"现在",窗口 = 未来 24 小时,只发前一天的提醒(不做中途关怀、不跟进已开的待办)\n`);
console.log(JSON.stringify(await svc.scan(now, { remindOnly: true }), 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);
});
import { createCliApp } from './cli-app';
/**
* 预约确认**全流程演练** —— 五条出路各跑一遍。
*
* ⚠️ 这是**联调工具不是单元测试**:它真的会建 Task、真的会往 outbox 排消息。
*
* ═══ ⛔⛔ 2026-09-01:两个前提塌了,已就地补硬闸 ═══════════════════════════
*
* **① 「出站一条都发不出去(租户闸默认关)」—— 那是个假设,不是保证。**
* 闸是可变的库值,联调时被打开过。⇒ 现在**开跑前先查**,
* 没有开着的 global/tenant 级暂停就**拒绝运行**。⛔ 别把安全性寄托在"默认值"上。
*
* **② 收尾的两条 UPDATE 原来没有 WHERE 范围** —— 全库把未完成 Task 关掉、
* 把所有 `outbound.ecom.text` 标 dead,而且**归零那条在循环里跑 5 次**。
* ⇒ 跑一次演练 = 抹掉工作台上所有真待办 + 干掉所有待发消息。
* 现在只收**这次演练自己建的**(按开跑时刻卡 `created_at`)。
*
* ⚠️⚠️ **③ 还有一处没修:`scan.created !== 1` 这个断言现在不成立。**
* 扫描单位从「一个托管号」改成「一家诊所」之后,一次 scan 会为**所有诊所**
* 建 Task(本地 5 家)。⇒ 这个演练需要重做成"只扫一家诊所",
* ⛔ 在那之前它会红,而那是**对的** —— 别把断言改松来让它变绿。
*
* 用法:pnpm --filter @pac/ai-service agent:appt-drill
*/
import 'dotenv/config';
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 createCliApp({ logger: ['error'] });
const prisma = app.get(PrismaService);
const scene = app.get(AppointmentConfirmService);
/**
* ⛔⛔ **硬闸:开跑前必须先把 AI 关掉(全局或本租户)。**
*
* ⚠️ 这个演练会把真提醒排进 outbox。闸没关的话 worker 会**真的发给患者**。
*
* ⚠️⚠️ **2026-09-06 换了判据。** 上一版查的是 `tenants.ai_auto_send = false`,
* 而出站闸从那天起**不再读那一列**(四层开关搬去 `app.ai_pauses`,
* 没有行 = 放行)。⇒ 那条硬闸当天就失效了 —— 它会放行,而消息会真发出去。
* ⭐ 现在查的是「有没有一条开着的 global / tenant 级暂停」。
* ⛔ 仍然是**拒绝运行**而不是"自己开一条":演练崩在半路的话,
* 自己开的那条会把全局 AI 一直关着,而没人知道是谁关的。
*/
const paused = await prisma.$queryRaw<{ scope: string }[]>`
SELECT scope FROM app.ai_pauses
WHERE released_at IS NULL AND scope IN ('global', 'tenant') LIMIT 1`;
if (!paused.length) {
console.error(
'⛔ 拒绝运行:AI 开关是开着的 —— 演练排进 outbox 的提醒会真的发给患者。\n' +
' 先关掉再跑(在工作台上关,或者往 app.ai_pauses 插一条 scope=\'global\' 的)。',
);
await app.close();
process.exit(1);
}
/** ⭐ 水位线 —— 收尾只收**这一次跑**建出来的东西,⛔ 别再全库 UPDATE */
const startedAt = (await prisma.$queryRaw<{ now: Date }[]>`SELECT now() AS now`)[0]!.now;
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'
AND task_id IN (SELECT id FROM app.tasks WHERE created_at >= ${startedAt})`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='演练归零', finished_at=now()
WHERE created_at >= ${startedAt} AND 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;
}
/**
* ⚠️ 2026-09-22 起读回复是接待那一次模型调用顺带判的(见 `AgentInboundHandler`);
* 这个演练只走**规则兜底**那条路(模型不在场时)——「停车方便吗」规则读不懂 ⇒ 转人工,同原来的期望。
*/
const sc = await scene.awaitingScene(t0[0].tenant_id, t0[0].conversation_id);
const r = sc
? { handled: true as const, ...(await scene.settleByRules(sc, d.say, { modelUnavailable: true })) }
: { handled: false as const, why: 'no_awaiting_task' };
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'
AND task_id IN (SELECT id FROM app.tasks WHERE created_at >= ${startedAt})`;
await prisma.$executeRaw`
UPDATE app.tasks SET status='closed', closed_reason='演练残留', finished_at=now()
WHERE created_at >= ${startedAt} AND status NOT IN ('completed','closed')`;
await prisma.$executeRaw`
UPDATE app.outbox SET status='dead', last_error='【演练残留】⛔ 不许真发'
WHERE task_type = 'outbound.ecom.text' AND status <> 'dead'
AND created_at >= ${startedAt}`;
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 { createCliApp } from './cli-app';
/**
* 把**解析漏掉的文字**从 `raw` 里补回 `text_content`。
*
* ⚠️⚠️ 这是**一次性的补救,⛔ 不是常规维护**:解析已经在 `textOf()` 修好了,
* 新消息不会再漏。它存在只是因为已经落库的那些行**没有第二次机会** ——
* `raw` 是唯一还留着原文的地方(消息不可重拉)。
*
* ⚠️ 只补 `text_content` 是 NULL 的行。⛔ 不覆盖已有的 —— 那可能是人工改过的。
* ⚠️ ⛔ **补不了嵌套的媒体**:`contentType 123` 那 37 条里的图片凭据从来没落库,
* 而源有 TTL(个微 72h)⇒ 已经取不回来了。这条只救文字。
*
* 用法: pnpm --filter @pac/ai-service backfill:text [--check]
*/
import 'dotenv/config';
import { PrismaService } from '../prisma/prisma.service';
import { textOf } from '@pac/ecom-client';
async function main() {
const check = process.argv.includes('--check');
const app = await createCliApp({ logger: ['error'] });
const prisma = app.get(PrismaService);
const rows = await prisma.$queryRaw<{ id: string; send_time: Date; kind: string; raw: unknown }[]>`
SELECT id::text, send_time, kind, raw FROM app.messages
WHERE nullif(btrim(text_content), '') IS NULL AND raw IS NOT NULL
ORDER BY send_time`;
const byKind = new Map<string, number>();
let chars = 0;
let n = 0;
for (const r of rows) {
const c = (r.raw as { data?: { content?: unknown } })?.data?.content;
if (typeof c !== 'object' || c === null) continue;
const t = textOf(c as Record<string, unknown>).trim();
if (!t) continue;
n++;
chars += t.length;
byKind.set(r.kind, (byKind.get(r.kind) ?? 0) + 1);
if (check) continue;
// ⚠️ 必须带 send_time —— 分区表,不带谓词分区裁剪失效(实测 18 个扫描节点 vs 1 个)
await prisma.$executeRaw`
UPDATE app.messages SET text_content = ${t}
WHERE id = ${r.id}::uuid AND send_time = ${r.send_time}`;
}
console.log(`扫了 ${rows.length} 条没有 text_content 的消息`);
console.log(`${check ? '待补' : '已补'} ${n} 条 · 共 ${chars} 字`);
for (const [k, v] of [...byKind].sort((a, b) => b[1] - a[1])) console.log(` ${k.padEnd(10)} ${v}`);
await app.close();
}
main().catch((e) => { console.error('失败:', e?.stack ?? e); process.exit(1); });
import type { INestApplicationContext, LogLevel } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
/**
* ⭐⭐ CLI 起应用的**唯一**入口 —— 先关掉三样后台的东西,再起。
*
* ═══ 为什么 ═══════════════════════════════════════════════════════════════
*
* `createApplicationContext(AppModule)` 会把整个应用拉起来,连同:
* ```
* outbox worker 认领真任务(给患者发消息、调模型)
* 定时任务 11 个 cron(提醒阶梯、新好友开场白、预约确认扫描…)
* 中转订阅 本地配了 AI_RELAY_URL 时,再开一路去收入站消息
* ```
* 于是每跑一次 CLI,线上服务旁边就多一个短命的第二实例。最实际的风险在 outbox:
* CLI 进程认领到一条出站消息 → ECom 已经发出去 → 人按了 Ctrl+C、没来得及标完成 →
* 租约 5 分钟后过期 → 服务再发一遍 ⇒ **患者收到两条**。定时任务有 `scheduled_runs` 锁不会并发,
* 但 CLI 抢到锁跑一半就退,锁要等超时才放,那一轮就晚了。
* ⇒ CLI 进程里这三样**恒关**。⛔ 没有哪条 CLI 靠进程内的 worker 替它把活干完
* (`send:text` 只入队,投递归线上服务)。
*
* ⚠️ 用法:`const app = await createCliApp({ logger: [...] })`。
* ⛔ 别在 CLI 里直接调 `NestFactory.createApplicationContext` —— 有测试锁着。
*/
export async function createCliApp(opts: { logger?: LogLevel[] } = {}): Promise<INestApplicationContext> {
process.env.AI_OUTBOX_WORKER = 'off';
process.env.AI_SCHEDULER = 'off';
process.env.AI_RELAY_SUBSCRIBER = 'off';
process.env.AI_INGRESS_SUBSCRIBER = 'off';
return NestFactory.createApplicationContext(AppModule, { logger: opts.logger ?? ['log', 'warn', 'error'] });
}
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);
}
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
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