Commit 887530ca by luoqi

feat(pac): 只读开放面 —— 患者优先三端点 + 预约窗口 + 出库展示串重写

给外部消费方(首个是 friday-ai)一套 HMAC 验签、免登录的只读接口。

## 端点

· search / batch-get / profile —— **患者优先**:先有 patientId 才问得出东西
· appointments/window —— 唯一一个**事实优先**的查询。「明天这家店有谁要来」
  患者优先答不了它,绕过去要按名册逐批查(单品牌 1200+ 次往返/轮,不可行)

## 越权防线只有一道,是刻意的

profile 的第一步就是 `PatientService.getByIds(scope, ids)`,品牌维和诊所维都在
那里过完;**后面所有查询只用它返回的 id**。 别为了少一次查询改成拿请求里的
id 直接查 fact —— 那一刻防线就没了,而且是静默的:知道一个 id 就能读别家的病历。

## 出库时的展示串重写(open-display.ts)

`patient_facts.title` 里是 code / uuid / 半截存储路径 —— 那是**入库保真**,不是 bug。
翻译是**开放面对消费方的责任**,PAC 内部一个字不动:

    治疗 periodontic · 龈上洁治          → 治疗 牙周 · 龈上洁治
    收款 ¥0.00(other)                    → 收款 ¥0.00 · 未记渠道
    影像 intraoral_photo <120字符路径>    → 影像 口内照片
    电子病历 <32位uuid>                   → 电子病历 · 李闻

 只改 title,`content` 一个字不动 —— 那里面是事实本身,算法直接消费它。
️ 认不出的 code **回退原值**,宿主加新码时至少 grep 得到,不静默变空。

## 码表补全(@pac/types)

治疗类别 / 影像模态本来就带 nameZh;新增收款渠道(15 种)与预约状态(8 态)。
️ `rescheduled` 占预约的 45%(249,007/556,883)—— 消费方漏掉它就等于
近一半的预约显示成英文码。

## 其它

· 读密钥与推送密钥分离(hmac-verifier)—— 读方拿到的密钥不该能往回写
· patient_transactions (patient_id, clinic_id) 索引 —— 诊所维反查用
· 回访任务记录并入 profile(`include: ['returnVisits']`,刻意不进默认)
  ️ 它**不是 fact**:装配器里写着「不进 transaction/fact、不进召回」,
   别为了时间线好拼就把它改成 fact。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 07e47a65
-- ═══════════════════════════════════════════════════════════════════════════
-- hosts.read_secrets —— 只读系统间调用的共享密钥
--
-- 背景:friday-ai 要从 PAC 读患者(把企微/个微联系人对应到患者)。
-- 鉴权复用已有的 HMAC 三件套(X-PAC-Host-Id / -Timestamp / -Signature),
-- 但**密钥必须另起一把**:
--
-- push_secret_hashes = 能往 PAC **写**患者数据
-- read_secrets = 只能读
--
-- 共用一把的话,friday-ai 侧一旦泄露,攻击者就能往 PAC 灌假患者数据 ——
-- 而 friday-ai 压根不需要写权限。轮换也会被绑死:换读密钥要连带换推送密钥。
--
-- ⚠️ 存的是**明文**(HMAC 要求双方持原文),和 push_secret_hashes 同样处理。
-- 生产应在应用层加密(KMS/Vault),与 push 的 TODO 是同一条。
-- ═══════════════════════════════════════════════════════════════════════════
-- ⚠️ DEFAULT '{}' 让存量 host 行不需要回填,且**默认没有读密钥** ——
-- 没配就调不通(verifyRead 会拒),而不是默认放行。fail-closed。
ALTER TABLE hosts ADD COLUMN read_secrets text[] NOT NULL DEFAULT '{}';
COMMENT ON COLUMN hosts.read_secrets IS
'只读系统间调用的 HMAC 共享密钥(明文,数组支持轮换)。与 push_secret_hashes 分开:那把能写,这把只能读';
-- ═══════════════════════════════════════════════════════════════════════════
-- patient_transactions (patient_id, clinic_id) —— 支持「按诊所筛患者」
--
-- 背景:PAC 的**患者没有诊所归属** —— `patients` 只有 source_unit(品牌),
-- 「该患者去过哪些诊所」靠 patient_transactions.clinic_id 反查(见 schema 头注)。
-- 但只读开放面(friday-ai 把微信联系人对应到患者)需要按**当前诊所**筛,
-- 否则一个只管一家店的客服要在整个品牌两万多人里翻。
--
-- ⇒ 判据落成 `EXISTS (SELECT 1 FROM patient_transactions
-- WHERE patient_id = p.id AND clinic_id = ANY(:clinics))`
-- 语义是「**在这家诊所有过就诊记录**」——PAC 里没有比这更强的"属于"。
--
-- ═══ 为什么必须有这个索引 ═══════════════════════════════════════════════
--
-- 没有它,EXISTS 只能走 (patient_id, occurred_at) 索引再逐行过滤 clinic_id:
-- 实测(本地 114 万行)**6110 ms**。加上之后 **16 ms** —— 375 倍。
-- 生产 379 万行只会更悬殊,而这是人在界面上等的一次查询。
--
-- 代价:本地实测索引 10 MB / 表 1503 MB(0.67%),按生产 379 万行外推约 34 MB。
--
-- ⚠️ CONCURRENTLY:这张表是摄入热路径,普通 CREATE INDEX 会持写锁把同步卡住。
-- ⛔ 代价是它**不能在事务里跑** —— Prisma 迁移默认包事务,所以本文件
-- 必须靠下面这行注释让 Prisma 不包(见 prisma 文档 "index creation")。
-- ═══════════════════════════════════════════════════════════════════════════
-- CreateIndex
CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_transactions_patient_id_clinic_id_idx"
ON "patient_transactions" ("patient_id", "clinic_id");
...@@ -111,6 +111,14 @@ model Host { ...@@ -111,6 +111,14 @@ model Host {
/// 约定:索引 0 current,后置为 grace period 内仍接受的旧 key,运维定期清理过期项 /// 约定:索引 0 current,后置为 grace period 内仍接受的旧 key,运维定期清理过期项
pushSecretHashes String[] @map("push_secret_hashes") pushSecretHashes String[] @map("push_secret_hashes")
/// 只读系统间调用的共享密钥(HMAC)** push_secret_hashes 刻意分开**:
/// 那一把是「能往 PAC 写患者数据」的凭据, friday-ai 这类消费方只需要读 ——
/// 给它推送密钥,等于它被攻破就能往 PAC 灌假患者数据。
/// 数组形式支持轮换(索引 0 = current,后置 = grace period), push 同构。
/// ⚠️ push_secret_hashes 一样**存明文**(HMAC 必须双方持原文);
/// 这里不带 "Hashes" 后缀就是为了不再继承那个误导性命名。
readSecrets String[] @default([]) @map("read_secrets")
/// 动作跳转 URL 模板(VIEW_PATIENTCREATE_APPOINTMENT ) /// 动作跳转 URL 模板(VIEW_PATIENTCREATE_APPOINTMENT )
/// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮 /// 形状: { "VIEW_PATIENT": "https://.../patient/{patientId}", ... };null/缺失 = 前端不渲染该按钮
actionUrls Json @default("{}") @map("action_urls") actionUrls Json @default("{}") @map("action_urls")
...@@ -578,6 +586,10 @@ model PatientTransaction { ...@@ -578,6 +586,10 @@ model PatientTransaction {
@@index([hostId, tenantId, clinicId]) @@index([hostId, tenantId, clinicId])
/// 按患者拉时间轴(parser / 审计反查路径) /// 按患者拉时间轴(parser / 审计反查路径)
@@index([patientId, occurredAt]) @@index([patientId, occurredAt])
/// 「按诊所筛患者」用 —— patients 没有诊所列,靠这张表反查
/// (`EXISTS(... patient_id=p.id AND clinic_id=ANY(...))`)
/// ⚠️ 没有它实测 6110ms, 16ms(本地 114 万行)。见 migration 20260827160000
@@index([patientId, clinicId], map: "patient_transactions_patient_id_clinic_id_idx")
/// host 运行状态统计(admin/host/self 24h/30d 计数)—— (patientId, occurredAt) 的第二列在 /// host 运行状态统计(admin/host/self 24h/30d 计数)—— (patientId, occurredAt) 的第二列在
/// "只按时间过滤不带 patientId"时用不上(需近乎全扫索引),线上实测单次 count 冷时 8s+ /// "只按时间过滤不带 patientId"时用不上(需近乎全扫索引),线上实测单次 count 冷时 8s+
@@index([hostId, occurredAt]) @@index([hostId, occurredAt])
......
...@@ -13,6 +13,7 @@ import { FactsModule } from './modules/facts/facts.module'; ...@@ -13,6 +13,7 @@ import { FactsModule } from './modules/facts/facts.module';
import { ClinicalSignalsModule } from './modules/clinical-signals/clinical-signals.module'; import { ClinicalSignalsModule } from './modules/clinical-signals/clinical-signals.module';
import { SyncModule } from './modules/sync/sync.module'; import { SyncModule } from './modules/sync/sync.module';
import { PatientModule } from './modules/patient/patient.module'; import { PatientModule } from './modules/patient/patient.module';
import { OpenModule } from './modules/open/open.module';
import { PersonaModule } from './modules/persona/persona.module'; import { PersonaModule } from './modules/persona/persona.module';
import { PlanModule } from './modules/plan/plan.module'; import { PlanModule } from './modules/plan/plan.module';
import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.module'; import { PlanAggregateModule } from './modules/plan-aggregate/plan-aggregate.module';
...@@ -58,6 +59,7 @@ import { HealthController } from './health.controller'; ...@@ -58,6 +59,7 @@ import { HealthController } from './health.controller';
ClinicalSignalsModule, ClinicalSignalsModule,
SyncModule, SyncModule,
PatientModule, PatientModule,
OpenModule, // 只读开放面(HMAC 系统间调用,当前消费方:friday-ai)
PersonaModule, PersonaModule,
PlanModule, PlanModule,
AiModule, AiModule,
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
* pnpm pac:host get <id-or-name> * pnpm pac:host get <id-or-name>
* pnpm pac:host rotate-secret <id-or-name> * pnpm pac:host rotate-secret <id-or-name>
* pnpm pac:host rotate-push <id-or-name> * pnpm pac:host rotate-push <id-or-name>
* pnpm pac:host rotate-read <id-or-name>
* pnpm pac:host deactivate <id-or-name> * pnpm pac:host deactivate <id-or-name>
*/ */
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
...@@ -60,6 +61,7 @@ Commands: ...@@ -60,6 +61,7 @@ Commands:
get <id-or-name> 查看一个 host 详情 get <id-or-name> 查看一个 host 详情
rotate-secret <id-or-name> 轮换 appSecret(旧立即失效) rotate-secret <id-or-name> 轮换 appSecret(旧立即失效)
rotate-push <id-or-name> 轮换 pushSecret(数组前插,grace 期共存) rotate-push <id-or-name> 轮换 pushSecret(数组前插,grace 期共存)
rotate-read <id-or-name> 轮换 readSecret(只读消费方用,如 friday-ai;与 push 是两把)
deactivate <id-or-name> 软停 host(后续 auth 拒绝) deactivate <id-or-name> 软停 host(后续 auth 拒绝)
Examples: Examples:
...@@ -205,6 +207,28 @@ async function main() { ...@@ -205,6 +207,28 @@ async function main() {
break; break;
} }
case 'rotate-read': {
const idOrName = args.positional[0];
if (!idOrName) {
// eslint-disable-next-line no-console
console.error(' usage: pnpm pac:host rotate-read <id-or-name>');
process.exit(2);
}
const id = await resolveHostId(svc, idOrName);
const r = await svc.rotateReadSecret(id);
// eslint-disable-next-line no-console
console.error(`
✓ Rotated readSecret for host id=${id}
new readSecret: ${r.readSecret}
total active keys (含 grace 旧 key): ${r.totalKeys}
⚠️ 这把是**只读**密钥(hosts.read_secrets),给 friday-ai 这类消费方调
POST /pac/v1/open/... 用。⛔ 它**不能**往 PAC 写(push 是另一把)。
⚠️ 数组前插 — 旧 readSecret 仍接受,grace 期共存;消费方切完再清旧 key。
`);
break;
}
case 'deactivate': { case 'deactivate': {
const idOrName = args.positional[0]; const idOrName = args.positional[0];
if (!idOrName) { if (!idOrName) {
......
...@@ -153,6 +153,30 @@ export class HostsService { ...@@ -153,6 +153,30 @@ export class HostsService {
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// Rotate readSecret(数组前插;只读系统间调用 → 存**原文**)
// ─────────────────────────────────────────────────────────
/**
* 轮换只读密钥(`hosts.read_secrets`)—— 给 friday-ai 这类**只读**消费方。
*
* ⚠️⚠️ **和 pushSecret 是两把,不能混用。**
* push 那把是「能往 PAC **写**患者数据」的凭据;只读方不该持有它 ——
* 共用一把的话,消费方一旦泄露,攻击者就能拿同一把往 PAC 灌假患者数据。
* 验签侧由 `HmacVerifier.verify(..., 'read')` 保证互不通用(有测试锁着)。
*
* ⚠️ 返回类型刻意**内联**而不是加进 `@pac/types` —— 这是 CLI 单点消费的结构,
* 放进共享类型包只会让两个仓库为一个本地结构耦合。
*/
async rotateReadSecret(id: string): Promise<{ readSecret: string; totalKeys: number }> {
const p = await this.getHostOrThrow(id);
// 存原文(理由同 push):HMAC 验签需双方持同一 key。
const readSecret = randomCode(32);
const next = [readSecret, ...p.readSecrets];
await this.prisma.host.update({ where: { id }, data: { readSecrets: next } });
return { readSecret, totalKeys: next.length };
}
// ─────────────────────────────────────────────────────────
// Rotate callbackSecret(数组前插;PAC 签名用 → 存**原文**,非哈希) // Rotate callbackSecret(数组前插;PAC 签名用 → 存**原文**,非哈希)
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
......
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { PatientService } from '../patient/patient.service';
import { redactJson, redactText } from './open-redact';
/** 窗口里的一条预约。字段名与 `patient_facts` 一致,⛔ 别在这层改名 */
export interface OpenAppointment {
patientId: string;
subjectId: string;
/** 宿主系统里那条预约的号(subject_id 冒号后面那段) */
externalId: string | null;
/** planned(未来安排) | actual(已发生) */
kind: string;
status: string;
clinicId: string | null;
plannedFor: string;
title: string | null;
summary: string | null;
/** ⚠️ 已过 redactJson —— 见 open-redact.ts */
content: unknown;
}
/**
* 「这段时间里有哪些预约」 —— 只读开放面**唯一一个事实优先的查询**。
*
* ═══ ⚠️⚠️ 它绕开了那道防线,所以在这里补回来 ═══════════════════════════════
*
* `OpenProfileService` 的头注说得很清楚:越权防线在
* `PatientService.getByIds(scope, ids)` —— 那一步把品牌维(`source_unit`)和
* 诊所维(反查 `patient_transactions.clinic_id`)都过了,后面所有查询**只用它返回的 id**。
*
* 事实优先的查询天然不在那条路上:它是先有 fact 才有 patient。
* ⇒ 这里**先查 fact,再把 patientId 原样送回 `getByIds` 筛一遍**,
* 活下来的患者对应的预约才出库。
*
* ⭐ 这样做的性质很重要:**这个端点在构造上不可能比现有三个看得更宽** ——
* 它用的是同一个收窄函数,而不是一份"照着写的"平行实现。
* ⛔ 别为了省一次查询改成"直接拿 fact.clinic_id 判范围":
* 那一刻两套判据就并存了,而漂掉的那一侧是**静默**的洞。
*
* ⚠️ 副作用:**先分页后过滤** ⇒ 一页可能返回少于 limit 条,甚至 0 条,
* 而 `nextCursor` 仍然非空。调用方必须**按游标翻到 null 为止**,
* ⛔ 不能拿 `items.length < limit` 当"到头了"。契约里写死了这一条。
*/
@Injectable()
export class OpenAppointmentService {
private readonly logger = new Logger(OpenAppointmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly patients: PatientService,
) {}
async window(
scope: TenantScopeContext,
input: { from: string; to: string; clinicIds?: string[]; cursor?: string; limit: number },
): Promise<{ items: OpenAppointment[]; nextCursor: string | null }> {
/**
* 诊所维的**交集**。
* ⚠️ 请求里的 `clinicIds` 是**再收窄**,⛔ 不是放宽 —— 传范围外的 id 取完交集就没了。
* ⚠️ `scope.clinicIds` 为空 = orgScope 展开到品牌层(不按诊所收窄),
* 这时请求里给什么就用什么;都没有就不加这一维。**和现有端点同一套写法**。
*/
const askedClinics = input.clinicIds?.length ? input.clinicIds : null;
const scopeClinics = scope.clinicIds.length ? scope.clinicIds : null;
const clinics =
askedClinics && scopeClinics
? askedClinics.filter((c) => scopeClinics.includes(c))
: (askedClinics ?? scopeClinics);
// ⚠️ 交集空 ⇒ **直接回空**,⛔ 别退化成"不加这一维"(那就成了越权)
if (askedClinics && scopeClinics && clinics && clinics.length === 0) {
return { items: [], nextCursor: null };
}
const cur = parseCursor(input.cursor);
const rows = await this.prisma.$queryRaw<
{
patient_id: string; subject_id: string; kind: string; status: string;
clinic_id: string | null; planned_for: Date;
title: string | null; summary: string | null; content: unknown;
}[]
>`
SELECT patient_id, subject_id, kind, status, clinic_id, planned_for, title, summary, content
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND type = 'appointment_record'
AND planned_for IS NOT NULL
AND planned_for >= ${new Date(input.from)}
AND planned_for < ${new Date(input.to)}
AND status IN ('active', 'fulfilled')
AND (${clinics}::text[] IS NULL OR clinic_id = ANY(${clinics}::text[]))
AND (${cur === null}::boolean
OR (planned_for, subject_id) > (${cur?.at ?? null}::timestamptz, ${cur?.id ?? null}::text))
ORDER BY planned_for ASC, subject_id ASC
LIMIT ${input.limit}`;
if (rows.length === 0) return { items: [], nextCursor: null };
/**
* ⭐⭐ 越权防线 —— 用**现有的**收窄函数,⛔ 不另写一套。
* 活下来的 id 才算数;其余的连 patientId 都不出库(⛔ 不回"有 N 条被过滤了",
* 那本身就是在泄露"别家诊所那天有几个人")。
*/
const allowed = new Set(
(await this.patients.getByIds(scope, [...new Set(rows.map((r) => r.patient_id))])).map((p) => p.id),
);
const kept = rows.filter((r) => allowed.has(r.patient_id));
if (kept.length !== rows.length) {
// ⚠️ 只记数量,⛔ 不记 id:日志也是出口
this.logger.warn(`预约窗口:${rows.length - kept.length} 条因患者不在范围内被滤掉`);
}
const last = rows[rows.length - 1]!; // ⚠️ 游标跟**过滤前**的最后一条,否则会跳过整页
return {
items: kept.map((r) => ({
patientId: r.patient_id,
subjectId: r.subject_id,
externalId: r.subject_id.includes(':') ? r.subject_id.slice(r.subject_id.indexOf(':') + 1) : null,
kind: r.kind,
status: r.status,
clinicId: r.clinic_id,
plannedFor: r.planned_for.toISOString(),
title: redactText(r.title),
summary: redactText(r.summary),
// ⚠️⚠️ 同 profile:自由文本里真的有手机号(实测 9774 条),这一行是最后一道
content: redactJson(r.content),
})),
// ⚠️ 取满 limit 才可能还有下一页。⛔ 别用 kept.length 判——过滤后的条数说明不了游标状态
nextCursor: rows.length === input.limit ? `${last.planned_for.toISOString()}|${last.subject_id}` : null,
};
}
}
/**
* 游标 = `<planned_for ISO>|<subject_id>`。
* ⚠️ 认不出就当没有游标(回第一页),⛔ 不抛错 —— 一个坏游标不该让调用方拿到 400
* 然后以为是自己的请求有问题。
*/
function parseCursor(c: string | undefined): { at: string; id: string } | null {
if (!c) return null;
const i = c.indexOf('|');
if (i <= 0) return null;
const at = c.slice(0, i);
const id = c.slice(i + 1);
if (!id || Number.isNaN(Date.parse(at))) return null;
return { at, id };
}
import {
imageModalityNameZh,
paymentChannelNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
/**
* ⭐⭐ **出库时的展示串重写** —— 把 `patient_facts.title` 翻成给人看的一句话。
*
* ═══ ⚠️⚠️ 为什么在**接口层**做,而不是改 parser ═══════════════════════════
*
* `patient_facts.title` 是 PAC **自己**存下来、自己也在用的派生列。它里面是
* code(`periodontic`)、uuid(病历 id)、半截存储路径(影像)—— 那是**入库保真**,
* ⛔ 不是 bug。改 parser 等于**替 PAC 改它自己的数据**,而且要回填上百万行。
*
* ⇒ 翻译是**开放面对消费方的责任**,而且是 PAC 要**通用提供**的:
* 不止 friday-ai 一个消费方需要"能直接显示的一句话"。
* ⚠️ 码表全部来自 `@pac/types`(治疗类别 / 影像模态本来就带 `nameZh`),
* ⛔ 这里不另建第二张表。认不出的**回退原值**(helper 里保证)——
* 宿主加了新码时至少能 grep 到,⛔ 不静默变空。
*
* ⚠️ **只重写 `title`**。`content` 一个字不动 —— 那里面是事实本身,
* 算法和下游都直接消费它,⛔ 翻译进去就是污染数据。
*/
/** `治疗 periodontic · 龈上洁治` → `治疗 牙周 · 龈上洁治` */
const TREATMENT = /^治疗\s+([a-z_]+)/;
/** `影像 intraoral_photo <120 字符的存储路径>` → `影像 口内照片` */
const IMAGE = /^影像\s+([a-z_]+)(?:\s+.*)?$/s;
/** `收款 ¥350.00(apple_pay)` → `收款 ¥350.00 · Apple Pay` */
const PAYMENT = /^收款\s+(¥[\d.]+)\(([a-z_]+)\)$/;
/** `电子病历 <32 位 uuid>(质控打回)?` → `电子病历`(+ 医生名,由调用方补) */
const EMR = /^电子病历\s+[0-9a-f]{16,}(.*)$/s;
/**
* 把一条事实的 title 翻成人话。认不出的**原样返回**。
*
* @param content 事实的 content —— 只用来补 title 里没有、但人要看的东西
* (目前只有病历的医生名)。⛔ 不改它。
*/
export function displayTitle(type: string, title: string | null, content: unknown): string | null {
if (!title) return title;
const c = (typeof content === 'object' && content !== null ? content : {}) as Record<string, unknown>;
switch (type) {
case 'treatment_record':
return title.replace(TREATMENT, (_m, cat: string) => `治疗 ${treatmentCategoryNameZh(cat)}`);
case 'image_record': {
/**
* ⚠️⚠️ 后面那截**必须丢掉**。它长这样:
* `e1734259ed8a4f07987ec3b70bf5bcd4|img|tenant/77057.../c5bf9c4e.JPG`
* —— 120 多个字符,在时间线上折成两行,而且是**半截的存储路径**:
* 既取不到图(要签名),对人也没有任何意义。
* ⭐ 它仍然在 `content.image_external_id` 里,要取图的调用方照常拿得到。
*/
const m = IMAGE.exec(title);
return m ? `影像 ${imageModalityNameZh(m[1]!)}` : title;
}
case 'payment_record': {
const m = PAYMENT.exec(title);
// ⚠️ 分隔符用 ` · `(同治疗那条)—— 括号里塞一个中文词读起来像补充说明,
// 而渠道其实是这条收款的一个**维度**。
return m ? `收款 ${m[1]} · ${paymentChannelNameZh(m[2]!)}` : title;
}
case 'emr_record': {
/**
* ⚠️⚠️ 32 位 uuid **不给人看** —— 它在时间线上占满一行而零信息。
* ⭐ 换成**接诊医生**(实测 110,956 条 100% 有 `doctor_name`)——
* 那才是人看这一行时想知道的。
* ⭐ 要定位到具体那份病历,调用方用 `subjectId`(`emr_record:<externalId>`),
* 开放面本来就回它,⛔ 不需要把 uuid 摆在标题里。
*/
const m = EMR.exec(title);
if (!m) return title;
const doctor = typeof c.doctor_name === 'string' ? c.doctor_name.trim() : '';
// m[1] 是尾巴(比如「(质控打回)」)—— 原样保留,那是状态不是 id
return `电子病历${doctor ? ` · ${doctor}` : ''}${m[1] ?? ''}`;
}
default:
return title;
}
}
import { z } from 'zod';
/**
* 只读开放面的请求契约。
*
* ⚠️ **一律 POST + body,不用 GET + query。**
* HMAC 签的是 `timestamp + "." + rawBody`(见 HmacVerifier)——
* GET 无 body ⇒ 同一秒内所有 GET 的签名相同,抓到一次就能换任意查询串重放,
* 对患者检索等于"把整个患者库交出去"。POST 让签名绑住查询内容。
*/
/**
* 组织范围。**必填,且不许空数组。**
*
* ⚠️⚠️ 这不是"严一点比较好",是 friday host 的接入 manifest **明确要求**的:
*
* > FRIDAY 是多品牌 SaaS(30+ 独立品牌 tenant_id,各含 1-8 诊所)。
* > PAC tenant = 合成 friday-market(整个市场一个租户)…
* > **隔离靠 orgScope/source_unit,非 tenant 边界 → 生产必须「禁止空 orgScope」。**
*
* `expandOrgScope([])` 返回 `{sourceUnits:[], clinicIds:[]}` = **不限** ——
* 在 friday host 下就是跨 30 多家**互不相干的公司**看患者。
* 登录态那条路由人的角色兜底(集团级角色本来就该看全部);
* 这条系统间的路**没有人**,一把 host 级密钥就能拿全部,所以必须由契约挡住。
*
* ⚠️ 想看全租户?显式传集团根节点 id —— 那是"唯一合法的不限来路"(见 expandOrgScope),
* 而且在请求体里留了痕。
*
* ⚠️ 可以传**任意层级**的 ref(集团 / 区域 / 品牌 / 诊所 id 都行),展开后两维都生效:
* · 品牌维 `sourceUnits` → `patients.source_unit`
* · 诊所维 `clinicIds` → 反查 `patient_transactions.clinic_id`(「在这家店有过就诊记录」)
* ⇒ 传品牌 = 该品牌全部患者;传诊所 = 只这家店的。
* ⚠️ 传诊所 id 时 `sourceUnits` 里还会带上它所属的品牌(继承而来),这是对的 ——
* 两维取交集,结果仍然是这家诊所。
*/
const OrgScope = z
.array(z.string().min(1))
.min(1, 'orgScope 必填且不能为空数组(空 = 不限 = 跨品牌泄露)')
.max(200)
.describe('组织节点 ref(集团 / 区域 / 品牌 / 诊所 id 均可)。必填,不许空');
export const OpenPatientSearchSchema = z.object({
tenantId: z.string().min(1).describe('集团 id'),
/**
* ⚠️ **可选** —— 不给就是"按最近更新倒序翻名册"。
* 人工把微信联系人对应到患者时,操作者未必知道要打什么字(备注名可能是"五光"),
* 得能先翻一翻。⇒ 这个接口既是检索也是列表。
* ⚠️ 但**必须配分页**:单品牌实测 24175 人,不分页就是把患者库整个拖走。
*/
q: z.string().trim().max(64).optional().describe('姓名 / 手机号 / 患者号(模糊匹配);省略 = 不过滤'),
/** keyset 游标,来自上一页的 `nextCursor`。⚠️ 不透明串,别自己构造。 */
cursor: z.string().max(128).optional(),
limit: z.number().int().min(1).max(100).optional().default(20),
orgScope: OrgScope,
});
export const OpenPatientBatchGetSchema = z.object({
tenantId: z.string().min(1).describe('集团 id'),
/** ⚠️ 上限 100:会话列表一页 30,留够余量又不至于变成"分页拖库"的工具。 */
patientIds: z.array(z.string().min(1)).min(1).max(100).describe('PAC 患者 id'),
orgScope: OrgScope,
});
export type OpenPatientSearchRequest = z.infer<typeof OpenPatientSearchSchema>;
export type OpenPatientBatchGetRequest = z.infer<typeof OpenPatientBatchGetSchema>;
/**
* 患者全貌 —— **一次拿走"这位患者是什么样"**。
*
* ═══ 为什么是 `include` 而不是三个接口 ═══════════════════════════════════════
*
* 会话工作台点开一位患者,顶栏要**风险/末诊**、概述要**事实时间轴**、EMR 页要**病历**。
* 拆三个接口 = 打开一条会话三次往返;做成一个胖接口 = 只要列表页的调用方也被迫
* 拉一整份病历。⇒ 调用方自己挑,PAC 只做没被要的就不查(见 OpenProfileService)。
*
* ⚠️ **不是给列表页用的。** 上限 20 而不是 batch-get 的 100:一位患者的事实
* 均值 33 条 / p95 133 条,20 × 60 已经是一次几千条的响应。
* 列表页要卡片走 `batch-get`。
*/
export const OpenPatientProfileSchema = z.object({
tenantId: z.string().min(1).describe('集团 id'),
patientIds: z.array(z.string().min(1)).min(1).max(20).describe('PAC 患者 id(≤20)'),
orgScope: OrgScope,
/**
* 要哪几块。⚠️ 默认仍是**原来那三块** —— 省略它的调用方多半是"我要看这个患者的全部",
* 而不是"我什么都不要"。
*
* ⚠️⚠️ `returnVisits` **刻意不进默认**:它是 2026-09-01 新加的一块,
* 进默认等于让**所有现存调用方**在没要的情况下多背一段数据(而且是自由文本,
* 人均 3.8 条 / p95 13 条 / 最多 54 条)。⛔ 加字段不该改变老调用方的响应体积。
* 要它的调用方显式写进 include。
*/
include: z
.array(z.enum(['profile', 'persona', 'facts', 'returnVisits']))
.min(1)
.optional()
.default(['profile', 'persona', 'facts'])
.describe(
'profile=末诊/免打扰等派生属性;persona=画像特征;facts=事实时间轴;returnVisits=回访任务记录',
),
/** 每位患者最多几条回访(按 task_date 倒序)。截断时 `returnVisitsTruncated=true`。 */
returnVisitLimit: z.number().int().min(1).max(100).optional(),
/**
* 只要某几类事实。省略 = 全要。
* ⚠️ 值是 canonical fact layer 的 16 个 type(diagnosis_record / treatment_record / …)。
* ⛔ PAC **不校验**取值:写错只会查不到,而枚举写死在这里会让 PAC 加新类型时
* 老调用方拿到 400(它明明只是想要老的那几类)。
*/
factTypes: z.array(z.string().min(1)).max(32).optional(),
/** 每位患者最多几条事实(倒序取最近的)。截断时响应里 `factsTruncated=true`。 */
factLimit: z.number().int().min(1).max(200).optional(),
});
export type OpenPatientProfileRequest = z.infer<typeof OpenPatientProfileSchema>;
/**
* ⭐⭐ **按时间窗取预约** —— 只读开放面里**唯一一个「事实优先」的查询**。
*
* ═══ 为什么非要它不可 ═══════════════════════════════════════════════════════
*
* 上面三个端点全是**患者优先**的:调用方得先知道 patientId 才问得出东西。
* 而「预约确认」这件事的问题形状恰好相反 ——
* **「明天这家诊所有谁要来?」**
* 患者优先答不了它。绕过去的唯一办法是翻整个名册再逐批查事实:
* 单品牌实测 24175 人 ÷ 20(profile 上限)= **1200+ 次往返/轮**,不可行。
*
* 后果实测(2026-08-31):friday-ai 的预约确认 Agent 只能从"已绑微信的患者"起扫,
* 于是当天 17:15 北京朝阳公园那条真实预约**一条任务都没生成** ——
* 而 SRS FR-CFM-001 要求的是按**预约**生成任务,绑定只决定触达渠道。
*
* ═══ ⚠️⚠️ 它绕开了现有那道防线,所以自己得补上 ═══════════════════════════
*
* `OpenProfileService` 的头注写着:越权防线在 `PatientService.getByIds(scope, ids)`,
* 后面所有查询只用它返回的 id。**事实优先的查询天然不在那条路上。**
* ⇒ 实现里必须把查出来的 patientId **原样送回 `getByIds`** 再筛一遍
* (见 OpenAppointmentService)。⛔ 别在这里另写一套范围判定:
* 两套必然漂,而漂的那一侧就是洞。
*
* ⚠️ 窗口是**半开区间 [from, to)**,且**必须有上界** —— 不封顶的话
* 「from=1970」就是把全部预约拖走,和翻名册没区别。最长 31 天。
*/
export const OpenAppointmentWindowSchema = z.object({
tenantId: z.string().min(1).describe('集团 id'),
orgScope: OrgScope,
from: z.string().datetime({ offset: true }).describe('窗口起(含),ISO8601 带时区'),
to: z.string().datetime({ offset: true }).describe('窗口止(不含),ISO8601 带时区'),
/**
* 只要这几家诊所的。省略 = orgScope 展开出来的全部。
* ⚠️ 这是**再收窄**,⛔ 不是放宽:传了范围外的 id 只会查不到
* (交集在 service 里取,见那边注释)。
*/
clinicIds: z.array(z.string().min(1)).max(50).optional(),
/** keyset 游标(planned_for, subject_id)。⚠️ 不透明串,别自己构造 */
cursor: z.string().max(160).optional(),
/**
* ⚠️ 上限 200 而不是 profile 的 20:这里一条预约只有十来个字段,
* 不像 profile 一位患者拖 60 条事实。一家诊所一天的预约量正好落在这个数量级。
*/
limit: z.number().int().min(1).max(200).optional().default(100),
});
export type OpenAppointmentWindowRequest = z.infer<typeof OpenAppointmentWindowSchema>;
/**
* 开放面的出站脱敏。
*
* ═══ ⚠️⚠️ 为什么非有不可 ═══════════════════════════════════════════════════
*
* 只读开放面有一条贯穿始终的契约(见 open-patients.controller 的头注):
*
* **PAC 从不返回调用方手上没有的手机号。**
*
* 患者卡片靠"只出掩码号"做到了这一点。但 `patient_facts.content` 是**自由文本**,
* 实测(2026-08-30,30000 患者 / 111 万 fact)有 **9774 条**命中手机号形状,
* 而且大多在预约的 `staff_notes` 里 —— 前台顺手记的:
*
* 「暂挂等微信确认 15901210126 无法接通」
* 「15801120088 这个电话是客人本人的,病例电话是妈妈的」
*
* 第二条尤其要命:那是**第三人(母亲)的号**,患者卡片里根本没有。
* 原样透出去,上面那条契约就当场作废,而且**没有任何 schema 能挡住** ——
* 自由文本里出现什么由前台当时怎么打字决定。
*
* ═══ 两道,顺序不能换 ═══════════════════════════════════════════════════════
*
* ① 按 key 整块丢弃(DENY_KEYS)—— 运营内部草稿,不是临床事实
* ② 剩下的每一个字符串做号码打码 —— 兜住自由文本
*
* ⛔ 只做 ② 不做 ①:staff_notes 里还有「客服中心 1106 约」「万科业主」这类
* 跟诊疗无关的内部信息,不该顺着一条"给客服看患者"的接口流出去。
* ⛔ 只做 ① 不做 ②:主诉、医嘱、病历自由文本一样会被人打进号码。
*
* ═══ ⚠️ 这里**不是**逐类型白名单,是刻意的 ═══════════════════════════════════
*
* 白名单要在两个仓里同时维护:PAC 加一个字段、friday 那边收不到,
* 表现是"这个字段永远是空的"而**不报错** —— 而 canonical fact layer 的
* content 本来就是设计给下游消费的结构。
* ⇒ 默认透出,只把**已知危险的**挡掉,并且在这里把这个取舍写清楚。
* ⚠️ 加新 fact 类型时,如果它带运营自由文本,**必须往 DENY_KEYS 里加**。
*/
/**
* 整块丢弃的 key(**递归、不分层级**)。
*
* ⚠️ 大小写不敏感 + 兼容 camel/snake 两种写法 —— content 是各 host 摄入时拼的,
* 两种命名都真实存在过。只写一种等于漏一半。
*/
const DENY_KEYS = new Set([
// 前台/客服的内部草稿:9774 条手机号里的绝大多数在这里,而且掺着排班、卡券、内部代号
'staff_notes',
'staffnotes',
// 通用的备注/联系方式字段名 —— 现在的 16 类 fact 里没有,但摄入侧随时可能长出来
'notes',
'note',
'remark',
'remarks',
'phone',
'mobile',
'tel',
'telephone',
'contact',
'contact_phone',
'contactphone',
'id_card',
'idcard',
'id_number',
'idnumber',
]);
/**
* 手机号打码:`15901210126` → `159****0126`。
*
* ⚠️ 口径和 `PatientService` 的卡片掩码**保持一致**(前 3 + 后 4),
* 不然同一个号在两处长得不一样,人会以为是两个号。
* ⚠️ 用 `\d` 前后的边界判断避免把 11 位以上的长数字串(外部 id)腰斩 ——
* `49e5ed...` 那类里没有纯数字长串,但 `image_external_id` 里有,不设边界会把它改坏。
*/
const PHONE_RE = /(?<!\d)1[3-9]\d{9}(?!\d)/g;
export function maskPhonesInText(s: string): string {
return s.replace(PHONE_RE, (m) => `${m.slice(0, 3)}****${m.slice(-4)}`);
}
/**
* 递归脱敏任意 JSON 值。
*
* ⚠️ 有**深度上限**。content 是外部摄入的 jsonb,理论上可以是任意深的嵌套;
* 没有上限的话一份构造过的数据就能把这个进程递归爆栈 —— 而它挂在
* 一个免登录(HMAC)的端点后面。到顶了返回 `null` 而不是原值:
* ⛔ 返回原值等于"太深就不脱敏了",正好是攻击者要的。
*/
const MAX_DEPTH = 12;
export function redactJson(value: unknown, depth = 0): unknown {
if (depth > MAX_DEPTH) return null;
if (typeof value === 'string') return maskPhonesInText(value);
if (Array.isArray(value)) return value.map((v) => redactJson(v, depth + 1));
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (DENY_KEYS.has(k.toLowerCase())) continue;
out[k] = redactJson(v, depth + 1);
}
return out;
}
// number / boolean / null / undefined —— 原样
return value === undefined ? null : value;
}
/** 可空字符串的脱敏(title / summary 用)。 */
export function redactText(s: string | null | undefined): string | null {
return s === null || s === undefined ? null : maskPhonesInText(s);
}
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PatientModule } from '../patient/patient.module';
import { HmacVerifier } from '../sync/push/hmac-verifier.service';
import { OpenPatientsController } from './open-patients.controller';
import { OpenProfileService } from './open-profile.service';
import { OpenAppointmentService } from './open-appointment.service';
/**
* 只读开放面 —— HMAC 鉴权的系统间调用(当前消费方:friday-ai)。
*
* ⚠️ `HmacVerifier` 在这里**自己 provide 一份**,而不是从 SyncModule 导出 ——
* 它是无状态的(只依赖 PrismaService),多一个实例没有代价,
* 而改 SyncModule 的 exports 就动了现有模块。
*/
@Module({
imports: [AuthModule, PatientModule],
controllers: [OpenPatientsController],
providers: [HmacVerifier, OpenProfileService, OpenAppointmentService],
})
export class OpenModule {}
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { PatientTimelineResponse } from '@pac/types'; import type { PatientTimelineResponse } from '@pac/types';
import { FactStatus } from '@pac/types'; import { FactStatus } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
...@@ -16,9 +17,26 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -16,9 +17,26 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
/** find_patient 返回的极简候选卡片(消歧用;手机号掩码,真号走 revealPhone)。 */ /** find_patient 返回的极简候选卡片(消歧用;手机号掩码,真号走 revealPhone)。 */
export interface PatientSearchItem { export interface PatientSearchItem {
id: string; id: string;
/**
* ⚠️ 宿主侧的**患者 id**(`patients.external_id`),⛔ **不是病历号**。
* 两者在库里是两列,实测 3 万行**没有一行相同**。
* 界面上要给人看的是下面那个 `medicalRecordNumber`。
*/
externalId: string; externalId: string;
/** 病历号(`patients.medical_record_number`)—— 界面上和患者对号时用的就是它 */
medicalRecordNumber: string | null;
name: string | null; name: string | null;
phoneMasked: string | null; phoneMasked: string | null;
/**
* 这个手机号是不是**真号**。
*
* ⚠️⚠️ 测试库里的 phone **全是造数假号**(`import-real-phones` CLI 的原话),
* 只有对照表导入过的那批 `phone_verified=true`。
* ⇒ 任何**按手机号自动认人**的逻辑(如把微信联系人对应到患者)
* **必须只认 `phoneVerified=true`** —— 跟假号撞上会把 A 的会话
* 连到 B 的病历,那比"没连上"坏得多。
*/
phoneVerified: boolean;
gender: string | null; gender: string | null;
birthDate: string | null; birthDate: string | null;
status: 'active' | 'archived'; status: 'active' | 'archived';
...@@ -63,11 +81,13 @@ export class PatientService { ...@@ -63,11 +81,13 @@ export class PatientService {
select: { select: {
id: true, id: true,
externalId: true, externalId: true,
medicalRecordNumber: true,
name: true, name: true,
phone: true, phone: true,
gender: true, gender: true,
birthDate: true, birthDate: true,
active: true, active: true,
phoneVerified: true,
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take, take,
...@@ -75,8 +95,169 @@ export class PatientService { ...@@ -75,8 +95,169 @@ export class PatientService {
return rows.map((p) => ({ return rows.map((p) => ({
id: p.id, id: p.id,
externalId: p.externalId, externalId: p.externalId,
medicalRecordNumber: p.medicalRecordNumber,
name: p.name,
phoneMasked: maskPhone(p.phone),
phoneVerified: p.phoneVerified,
gender: p.gender,
birthDate: p.birthDate ? p.birthDate.toISOString().slice(0, 10) : null,
status: p.active ? 'active' : 'archived',
}));
}
/**
* 分页浏览 / 检索患者(keyset 分页)。
*
* 与 `search()` 的分工:
* `search()` —— 给 agent(MCP `find_patient`)。**必须给 query**,只取前 N 条,不分页:
* agent 要的是"消歧候选",不是名册。
* `browse()` —— 给人用的界面(如把微信联系人手工对应到患者)。
* `q` **可选**:不给就是按最近更新倒序翻名册;给了就在范围内过滤。
*
* ⚠️ 刻意**不改 search()** 去兼容两种用途 —— MCP 那条路已经在跑,
* 它的 "空 query 返空" 对 agent 是对的语义(别让模型一句空话拖走整个患者库)。
*
* ⚠️ **keyset 分页,不用 OFFSET**:患者表随每次摄入在变(updated_at 会动),
* OFFSET 在数据变动时会漏行 / 重复行。游标是 `<updatedAt ISO>|<id>`。
* 排序键取 `(updated_at DESC, id DESC)` —— updated_at 不唯一,必须再带 id 才是全序。
*/
async browse(
scope: TenantScopeContext,
opts: { q?: string; cursor?: string | null; limit?: number },
): Promise<{ items: PatientSearchItem[]; nextCursor: string | null }> {
const take = Math.min(Math.max(opts.limit ?? 20, 1), 100);
const q = (opts.q ?? '').trim();
const where: Prisma.PatientWhereInput = {
hostId: scope.hostId,
tenantId: scope.tenantId,
...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
// ⭐ **诊所维过滤** —— `patients` 没有诊所列(schema 头注:「"该患者去过哪些诊所"
// 靠 patient_transactions.clinic_id 反查」),所以只能反查。
// 语义是「**在这些诊所有过就诊记录**」——PAC 里没有比这更强的"属于"。
// ⚠️ 靠 `patient_transactions (patient_id, clinic_id)` 索引才跑得动:
// 实测本地 114 万行,无索引 6110ms / 有索引 16ms。见 migration 20260827160000。
// ⚠️ 不会误伤:实测 0 个患者没有交易记录、0 个患者的 clinic_id 全空
// (cohort 定义就是"有就诊记录的患者")。
...(scope.clinicIds.length
? { transactions: { some: { clinicId: { in: scope.clinicIds } } } }
: {}),
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q } },
{ externalId: { contains: q } },
/**
* ⭐ **病历号**。2026-08-30 补 —— 在这之前这里只有 `external_id`,
* 而两者是**两列不同的东西**:实测 30000 行,没有一行相等。
* ⇒ 界面上给人看的是病历号(`BJ0U017088`),而按它检索**一条都搜不到**,
* 表现是"这个患者不存在"。开放面的接口说明里也一直写着「病历号」,
* 那句话在这之前是**假的**。
* ⚠️ 这一列没有索引。和上面的 phone/external_id 同一档代价:
* 都是在已按 host+tenant+品牌+诊所收窄过的集合里做 contains,
* 不是新引入的一类开销。真成瓶颈时三列一起建 trigram。
*/
{ medicalRecordNumber: { contains: q, mode: 'insensitive' } },
],
}
: {}),
};
// ⚠️ 游标解析失败(格式不对 / 日期非法)时**当成没有游标**,不报错 ——
// 游标是我们自己发出去的不透明串,客户端不该构造它;真拿到坏的,
// 回到第一页比报错更有用(界面不会卡死在一个翻不动的位置)。
if (opts.cursor) {
const i = opts.cursor.indexOf('|');
const ts = i > 0 ? new Date(opts.cursor.slice(0, i)) : null;
const id = i > 0 ? opts.cursor.slice(i + 1) : '';
if (ts && !Number.isNaN(ts.getTime()) && id) {
where.AND = [
{ OR: [{ updatedAt: { lt: ts } }, { updatedAt: ts, id: { lt: id } }] },
];
}
}
const rows = await this.prisma.patient.findMany({
where,
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
take: take + 1,
select: {
id: true,
externalId: true,
medicalRecordNumber: true,
name: true,
phone: true,
gender: true,
birthDate: true,
active: true,
phoneVerified: true,
updatedAt: true,
},
});
const page = rows.slice(0, take);
const last = page[page.length - 1];
return {
items: page.map((p) => ({
id: p.id,
externalId: p.externalId,
medicalRecordNumber: p.medicalRecordNumber,
name: p.name,
phoneMasked: maskPhone(p.phone),
phoneVerified: p.phoneVerified,
gender: p.gender,
birthDate: p.birthDate ? p.birthDate.toISOString().slice(0, 10) : null,
status: p.active ? 'active' : 'archived',
})),
nextCursor: rows.length > take && last ? `${last.updatedAt.toISOString()}|${last.id}` : null,
};
}
/**
* 按 id 批量取患者卡片(与 search 同一形状)。
*
* 为什么需要它:消费方(如 friday-ai 工作台)拿到 `patientId` 之后要显示"这是张三"。
* 会话列表一页 30 条,逐个查就是 30 次往返 ⇒ 必须能批量。
*
* ⚠️⚠️ **必须过 scope**,不能只按 id 查 —— 只按 id 查等于"知道 id 就能读",
* 那是越权(BOLA)。这里的 where 与 search / revealPhone 完全一致。
* ⚠️ 查不到的 id **静默跳过**,不报错:患者可能被归档、或已不在调用方范围内,
* 那是正常状态,调用方按"缺了就显示未知"处理即可。报错会让整页渲染不出来。
*/
async getByIds(scope: TenantScopeContext, ids: string[]): Promise<PatientSearchItem[]> {
const unique = [...new Set(ids.filter(Boolean))];
if (unique.length === 0) return [];
const rows = await this.prisma.patient.findMany({
where: {
id: { in: unique },
hostId: scope.hostId,
tenantId: scope.tenantId,
...(scope.sourceUnits.length ? { sourceUnit: { in: scope.sourceUnits } } : {}),
// ⚠️ 诊所维也要过 —— 否则"按 id 取"就成了绕过诊所范围的后门:
// 知道 id 就能读到别家诊所的患者。理由同 browse()。
...(scope.clinicIds.length
? { transactions: { some: { clinicId: { in: scope.clinicIds } } } }
: {}),
},
select: {
id: true,
externalId: true,
medicalRecordNumber: true,
name: true,
phone: true,
gender: true,
birthDate: true,
active: true,
phoneVerified: true,
},
});
return rows.map((p) => ({
id: p.id,
externalId: p.externalId,
medicalRecordNumber: p.medicalRecordNumber,
name: p.name, name: p.name,
phoneMasked: maskPhone(p.phone), phoneMasked: maskPhone(p.phone),
phoneVerified: p.phoneVerified,
gender: p.gender, gender: p.gender,
birthDate: p.birthDate ? p.birthDate.toISOString().slice(0, 10) : null, birthDate: p.birthDate ? p.birthDate.toISOString().slice(0, 10) : null,
status: p.active ? 'active' : 'archived', status: p.active ? 'active' : 'archived',
......
...@@ -15,6 +15,20 @@ import { PrismaService } from '../../../prisma/prisma.service'; ...@@ -15,6 +15,20 @@ import { PrismaService } from '../../../prisma/prisma.service';
* - 索引 0 = current,后置 = grace period 旧 key * - 索引 0 = current,后置 = grace period 旧 key
* - 字段名带 "Hashes" 是历史命名,**实际存储 raw secret 字符串**(HMAC 必须双方都持有原文) * - 字段名带 "Hashes" 是历史命名,**实际存储 raw secret 字符串**(HMAC 必须双方都持有原文)
* - 生产环境应该在 application layer 加密存储(KMS / Vault),dev 明文 OK * - 生产环境应该在 application layer 加密存储(KMS / Vault),dev 明文 OK
*
* ── 两种用途,两把密钥(2026-08-27 加)────────────────────────────────────────
* purpose='push'(默认) → Host.pushSecretHashes —— 宿主**写**数据进 PAC
* purpose='read' → Host.readSecrets —— 消费方(friday-ai)**只读**
*
* ⚠️ 刻意分开:共用一把的话,只读方一旦泄露,攻击者就能往 PAC 灌假患者数据。
* 除了取哪个字段,**验签算法与流程完全相同** —— 不为第二种用途另写一份
* 安全关键代码(重复的验签实现比一个带默认值的参数危险得多)。
*
* ⚠️ 已知边界:签名只覆盖 `timestamp + "." + rawBody`,**不绑定 method / path / query**。
* 对 push 无所谓(body 就是请求全部内容);对只读面意味着同一秒的签名可以在
* 不同端点间重放 —— 目前靠"每个端点 body schema 不同"挡住(重放过去会 400)。
* ⇒ 只读端点一律用 **POST + body**,不用 GET query;真要绑 path 时再加一个
* `X-PAC-Sig-Version` 头升级规范串,两侧同步换。
*/ */
@Injectable() @Injectable()
export class HmacVerifier { export class HmacVerifier {
...@@ -27,12 +41,16 @@ export class HmacVerifier { ...@@ -27,12 +41,16 @@ export class HmacVerifier {
* 验签 + 重放检查。失败抛 UnauthorizedException。 * 验签 + 重放检查。失败抛 UnauthorizedException。
* 成功返回 host(已 lookup 出来,调用方复用)。 * 成功返回 host(已 lookup 出来,调用方复用)。
*/ */
async verify(input: { async verify(
input: {
hostIdHeader: string | undefined; hostIdHeader: string | undefined;
timestampHeader: string | undefined; timestampHeader: string | undefined;
signatureHeader: string | undefined; signatureHeader: string | undefined;
rawBody: string; rawBody: string;
}): Promise<{ hostId: string; hostName: string }> { },
/** 用哪把密钥。⚠️ 默认 'push' —— 现有调用方一个字都不用改。 */
purpose: 'push' | 'read' = 'push',
): Promise<{ hostId: string; hostName: string }> {
const { hostIdHeader, timestampHeader, signatureHeader, rawBody } = input; const { hostIdHeader, timestampHeader, signatureHeader, rawBody } = input;
if (!hostIdHeader) throw new UnauthorizedException('缺 X-PAC-Host-Id header'); if (!hostIdHeader) throw new UnauthorizedException('缺 X-PAC-Host-Id header');
...@@ -56,12 +74,16 @@ export class HmacVerifier { ...@@ -56,12 +74,16 @@ export class HmacVerifier {
name: true, name: true,
active: true, active: true,
pushSecretHashes: true, pushSecretHashes: true,
readSecrets: true,
}, },
}); });
if (!host) throw new UnauthorizedException(`unknown host_id=${hostIdHeader}`); if (!host) throw new UnauthorizedException(`unknown host_id=${hostIdHeader}`);
if (!host.active) throw new UnauthorizedException(`host=${host.name} 已停用`); if (!host.active) throw new UnauthorizedException(`host=${host.name} 已停用`);
if (host.pushSecretHashes.length === 0) { const secrets = purpose === 'read' ? host.readSecrets : host.pushSecretHashes;
throw new UnauthorizedException(`host=${host.name} 未配置 push secret`); // ⚠️ 没配 = 拒,不是放行。read_secrets 默认空数组,所以「没给某个 host 开只读」
// 的表现是调不通,而不是默认可读全部患者。
if (secrets.length === 0) {
throw new UnauthorizedException(`host=${host.name} 未配置 ${purpose} secret`);
} }
// 遍历所有 secret(支持 key rotation),任一命中通过 // 遍历所有 secret(支持 key rotation),任一命中通过
...@@ -70,7 +92,7 @@ export class HmacVerifier { ...@@ -70,7 +92,7 @@ export class HmacVerifier {
if (!expectedBuf) throw new UnauthorizedException('signature 不是合法 hex'); if (!expectedBuf) throw new UnauthorizedException('signature 不是合法 hex');
let hit = false; let hit = false;
for (const secret of host.pushSecretHashes) { for (const secret of secrets) {
const computed = createHmac('sha256', secret).update(signed).digest(); const computed = createHmac('sha256', secret).update(signed).digest();
if (computed.length === expectedBuf.length && timingSafeEqual(computed, expectedBuf)) { if (computed.length === expectedBuf.length && timingSafeEqual(computed, expectedBuf)) {
hit = true; hit = true;
...@@ -78,7 +100,7 @@ export class HmacVerifier { ...@@ -78,7 +100,7 @@ export class HmacVerifier {
} }
} }
if (!hit) { if (!hit) {
this.logger.warn(`HMAC mismatch host=${host.name}(${host.id})`); this.logger.warn(`HMAC mismatch host=${host.name}(${host.id}) purpose=${purpose}`);
throw new UnauthorizedException('签名不匹配'); throw new UnauthorizedException('签名不匹配');
} }
......
/**
* HMAC 验签 — 读密钥与推送密钥**必须互不通用**。
*
* 背景:friday-ai 这类只读消费方走 `POST /pac/v1/open/...`,复用 push 的 HMAC 三件套,
* 但用 `hosts.read_secrets` 而不是 `push_secret_hashes`。
*
* ⚠️ 这个分离是**安全边界**,不是整洁:共用一把的话,只读方一旦泄露,
* 攻击者就能拿同一把密钥往 PAC 灌假患者数据(`POST /push/rows`)。
* 而它失效时**不会报错** —— 两边照样验签通过,只是权限边界没了。
* ⇒ 必须由测试锁住,不能靠"记得别改"。
*/
import { UnauthorizedException } from '@nestjs/common';
import { HmacVerifier } from '../src/modules/sync/push/hmac-verifier.service';
import type { PrismaService } from '../src/prisma/prisma.service';
const PUSH_SECRET = 'push-secret-aaaa';
const READ_SECRET = 'read-secret-bbbb';
const HOST_ID = '11111111-1111-1111-1111-111111111111';
function verifierWith(host: Partial<Record<string, unknown>> | null): HmacVerifier {
const prisma = {
host: { findUnique: jest.fn().mockResolvedValue(host) },
} as unknown as PrismaService;
return new HmacVerifier(prisma);
}
const HOST = {
id: HOST_ID,
name: 'jvs-dw',
active: true,
pushSecretHashes: [PUSH_SECRET],
readSecrets: [READ_SECRET],
};
const call = (v: HmacVerifier, secret: string, purpose: 'push' | 'read', body = '{"a":1}') => {
const ts = Math.floor(Date.now() / 1000);
return v.verify(
{
hostIdHeader: HOST_ID,
timestampHeader: String(ts),
signatureHeader: HmacVerifier.sign(secret, ts, body),
rawBody: body,
},
purpose,
);
};
describe('HmacVerifier | 读密钥 vs 推送密钥', () => {
test('push 密钥验 push —— 通过(既有行为,回归)', async () => {
await expect(call(verifierWith(HOST), PUSH_SECRET, 'push')).resolves.toMatchObject({
hostId: HOST_ID,
});
});
test('不传 purpose 时默认走 push —— 既有调用方一个字不用改', async () => {
const v = verifierWith(HOST);
const ts = Math.floor(Date.now() / 1000);
const body = '{"a":1}';
await expect(
v.verify({
hostIdHeader: HOST_ID,
timestampHeader: String(ts),
signatureHeader: HmacVerifier.sign(PUSH_SECRET, ts, body),
rawBody: body,
}),
).resolves.toMatchObject({ hostId: HOST_ID });
});
test('read 密钥验 read —— 通过', async () => {
await expect(call(verifierWith(HOST), READ_SECRET, 'read')).resolves.toMatchObject({
hostId: HOST_ID,
});
});
test('⛔ push 密钥**验不了** read', async () => {
await expect(call(verifierWith(HOST), PUSH_SECRET, 'read')).rejects.toThrow(
UnauthorizedException,
);
});
test('⛔ read 密钥**写不进** PAC(验不了 push)', async () => {
await expect(call(verifierWith(HOST), READ_SECRET, 'push')).rejects.toThrow(
UnauthorizedException,
);
});
test('没配 read_secrets → 拒(fail-closed,不是默认放行)', async () => {
const v = verifierWith({ ...HOST, readSecrets: [] });
await expect(call(v, READ_SECRET, 'read')).rejects.toThrow(/未配置 read secret/);
});
test('配了 read 但没配 push → push 仍然拒(两把互不兜底)', async () => {
const v = verifierWith({ ...HOST, pushSecretHashes: [] });
await expect(call(v, READ_SECRET, 'push')).rejects.toThrow(/未配置 push secret/);
});
test('密钥轮换:read_secrets 里任一把命中即可', async () => {
const v = verifierWith({ ...HOST, readSecrets: ['old-key', READ_SECRET] });
await expect(call(v, 'old-key', 'read')).resolves.toMatchObject({ hostId: HOST_ID });
await expect(call(v, READ_SECRET, 'read')).resolves.toMatchObject({ hostId: HOST_ID });
});
test('时间戳超窗 → 拒(read 面同样受重放保护)', async () => {
const v = verifierWith(HOST);
const ts = Math.floor(Date.now() / 1000) - 600;
const body = '{"a":1}';
await expect(
v.verify(
{
hostIdHeader: HOST_ID,
timestampHeader: String(ts),
signatureHeader: HmacVerifier.sign(READ_SECRET, ts, body),
rawBody: body,
},
'read',
),
).rejects.toThrow(/时间戳偏差/);
});
test('host 停用 → 两种用途都拒', async () => {
const v = verifierWith({ ...HOST, active: false });
await expect(call(v, READ_SECRET, 'read')).rejects.toThrow(/已停用/);
await expect(call(v, PUSH_SECRET, 'push')).rejects.toThrow(/已停用/);
});
});
import { displayTitle } from '../src/modules/open/open-display';
/**
* 开放面出库时的**展示串重写**。
*
* ═══ ⚠️⚠️ 这一层存在的理由,以及它**不**做什么 ═══════════════════════════
*
* `patient_facts.title` 是 PAC 自己存、自己也在用的派生列,里面是 code / uuid /
* 半截存储路径 —— 那是**入库保真**,⛔ 不是 bug。
* 翻译是**开放面对消费方的责任**(2026-09-01 用户拍板:「不要动 pac 自己的逻辑,
* 你要改的是接口层就行,这是 pac 需要通用提供的」)。
*
* ⚠️ 一度改成"改 parser + reparse 回填"—— **那是错的**:等于替 PAC 改它自己的数据,
* 而且要回填上百万行。已还原。⛔ 别再往那个方向走。
*/
describe('displayTitle', () => {
it('⭐ 治疗:类别 code → 中文,其余原样', () => {
expect(displayTitle('treatment_record', '治疗 periodontic · 龈上洁治', {}))
.toBe('治疗 牙周 · 龈上洁治');
expect(displayTitle('treatment_record', '治疗 implant · 种植修复 · 牙位 24;', {}))
.toBe('治疗 种植 · 种植修复 · 牙位 24;');
});
it('⚠️⚠️ 影像:**丢掉那截存储路径** —— 120 字符、取不到图、对人零信息', () => {
const t =
'影像 intraoral_photo e1734259ed8a4f07987ec3b70bf5bcd4|img|tenant/77057aed/inspectionImage/98c31018.JPG';
expect(displayTitle('image_record', t, {})).toBe('影像 口内照片');
});
it('⭐ 收款:渠道 code → 中文,分隔符用 ·', () => {
expect(displayTitle('payment_record', '收款 ¥350.00(apple_pay)', {}))
.toBe('收款 ¥350.00 · Apple Pay');
// ⚠️ `other` 占全库 45% —— 它的意思是"宿主没记渠道",⛔ 不是某种支付方式
expect(displayTitle('payment_record', '收款 ¥0.00(other)', {}))
.toBe('收款 ¥0.00 · 未记渠道');
});
it('⭐ 病历:uuid 换成接诊医生', () => {
expect(displayTitle('emr_record', '电子病历 1d8856b82f6e41f0ba854ea9c45926d5', { doctor_name: '张钧婷' }))
.toBe('电子病历 · 张钧婷');
});
it('⚠️ 病历:没有医生名时只留「电子病历」,⛔ 不回退成 uuid', () => {
expect(displayTitle('emr_record', '电子病历 1d8856b82f6e41f0ba854ea9c45926d5', {}))
.toBe('电子病历');
});
it('⚠️ 病历:「(质控打回)」是**状态**不是 id —— 要留着', () => {
expect(displayTitle('emr_record', '电子病历 1d8856b82f6e41f0ba854ea9c45926d5(质控打回)', { doctor_name: '李医生' }))
.toBe('电子病历 · 李医生(质控打回)');
});
it('⛔ 认不出的 code **原样返回** —— 宿主加新码时至少 grep 得到,不静默变空', () => {
expect(displayTitle('treatment_record', '治疗 brandnew_category · 某项目', {}))
.toBe('治疗 brandnew_category · 某项目');
expect(displayTitle('payment_record', '收款 ¥1.00(some_new_pay)', {}))
.toBe('收款 ¥1.00 · some_new_pay');
});
it('⛔ 不认识的 type / 空 title:一个字都不动', () => {
expect(displayTitle('diagnosis_record', '诊断 K04 · 牙位 2D', {})).toBe('诊断 K04 · 牙位 2D');
expect(displayTitle('appointment_record', '预约 2026-08-31', {})).toBe('预约 2026-08-31');
expect(displayTitle('emr_record', null, {})).toBeNull();
});
it('⚠️ 形状对不上时原样返回 —— ⛔ 别硬套正则把好数据改坏', () => {
// 没有 code 的治疗标题(历史数据 / 宿主口径变了)
expect(displayTitle('treatment_record', '治疗 龈上洁治', {})).toBe('治疗 龈上洁治');
// 收款标题没有括号
expect(displayTitle('payment_record', '收款 ¥12.00', {})).toBe('收款 ¥12.00');
});
});
import { maskPhonesInText, redactJson, redactText } from '../src/modules/open/open-redact';
/**
* 这些用例守的是开放面那条契约:**PAC 从不返回调用方手上没有的手机号**。
* 每条都对应一份实测数据(2026-08-30 扫 111 万条 patient_facts)。
*/
describe('maskPhonesInText —— 自由文本里的号码', () => {
it('前台备注里的号被打码', () => {
expect(maskPhonesInText('暂挂等微信确认 15901210126 无法接通')).toBe(
'暂挂等微信确认 159****0126 无法接通',
);
});
it('🔴 第三人的号也要打码 —— 患者卡片里根本没有这个号', () => {
expect(maskPhonesInText('15801120088这个电话是客人本人的,病例电话是妈妈的')).toBe(
'158****0088这个电话是客人本人的,病例电话是妈妈的',
);
});
it('一句话里多个号全打', () => {
expect(maskPhonesInText('13601351153 或 15116969479')).toBe('136****1153 或 151****9479');
});
it('⚠️ 掩码口径与患者卡片一致(前3后4)', () => {
expect(maskPhonesInText('13800009804')).toBe('138****9804');
});
it('⚠️ 更长的数字串不腰斩 —— 外部 id 里有纯数字长串,改坏了就对不上源记录', () => {
expect(maskPhonesInText('138000098041234')).toBe('138000098041234');
expect(maskPhonesInText('0013800009804')).toBe('0013800009804');
});
it('座机 / 400 号不动(不是手机号形状)', () => {
expect(maskPhonesInText('请联系 400-800-1234')).toBe('请联系 400-800-1234');
});
});
describe('redactJson —— 递归脱敏', () => {
it('🔴 staff_notes 整块丢掉,不只是打码', () => {
const out = redactJson({
complaint_text: '洁牙',
staff_notes: '未接 13601351153 客服中心1106约',
scheduled_at: '2025-06-27T01:00:00.000Z',
}) as Record<string, unknown>;
expect(out.staff_notes).toBeUndefined();
expect('staff_notes' in out).toBe(false);
expect(out.complaint_text).toBe('洁牙');
});
it('key 大小写 / camel 写法都挡得住', () => {
const out = redactJson({ staffNotes: 'x', StaffNotes: 'y', PHONE: '13800009804' }) as Record<string, unknown>;
expect(Object.keys(out)).toEqual([]);
});
it('嵌套结构里的号一样打码', () => {
const out = redactJson({ items: [{ desc: '回电 15901210126' }] }) as { items: { desc: string }[] };
expect(out.items[0]!.desc).toBe('回电 159****0126');
});
it('保留非字符串值', () => {
expect(redactJson({ amount_cents: 76000, ok: true, nothing: null })).toEqual({
amount_cents: 76000,
ok: true,
nothing: null,
});
});
it('⚠️ 超深嵌套返回 null,⛔ 不是"太深就原样透出"', () => {
let deep: unknown = '13800009804';
for (let i = 0; i < 30; i++) deep = { next: deep };
const s = JSON.stringify(redactJson(deep));
expect(s).not.toContain('13800009804');
});
});
describe('redactText', () => {
it('null 保持 null', () => expect(redactText(null)).toBeNull());
it('undefined 也回 null', () => expect(redactText(undefined)).toBeNull());
it('正常文本打码', () => expect(redactText('叫 13601351153')).toBe('叫 136****1153'));
});
...@@ -429,6 +429,27 @@ export const PACTreatmentStatusSchema = z.enum( ...@@ -429,6 +429,27 @@ export const PACTreatmentStatusSchema = z.enum(
); );
// ============================================================= // =============================================================
// 预约状态(PACAppointmentStatus)— appointment_record.content.status
//
// ⚠️ 8 态,和 `AppointmentCanonicalSchema.status` / jvs-dw enum_mapping 对齐。
// ⚠️⚠️ `rescheduled` 是**最常见**的一个(本地实测 249,007/556,883 = 45%)——
// 消费方漏掉它就等于近一半的预约显示成英文码。
// =============================================================
export const PACAppointmentStatuses = {
scheduled: { nameZh: '已预约' },
rescheduled: { nameZh: '已改约' },
cancelled: { nameZh: '已取消' },
arrived: { nameZh: '已到诊' },
in_treatment: { nameZh: '治疗中' },
completed: { nameZh: '已完成' },
no_show: { nameZh: '爽约' },
/** 没预约直接来的 */
walk_in: { nameZh: '空降' },
} as const;
export type PACAppointmentStatus = keyof typeof PACAppointmentStatuses;
// =============================================================
// 治疗链生命周期模型(TreatmentLifecycle + TreatmentMilestones) // 治疗链生命周期模型(TreatmentLifecycle + TreatmentMilestones)
// ============================================================= // =============================================================
// //
...@@ -677,6 +698,36 @@ export const PACImageModalitySchema = z.enum( ...@@ -677,6 +698,36 @@ export const PACImageModalitySchema = z.enum(
); );
// ============================================================= // =============================================================
// 收款渠道(PACPaymentChannel)— payment_record.content.channel
//
// ⚠️ 取值来自宿主结算单的 `payType`,**本地实测 15 种**(2026-09-01 全量统计)。
// `other` 占 30,062/67,387(45%)—— 宿主那边大量单据本来就没记渠道,
// ⛔ 别把它读成"某种特殊支付方式"。
// ⚠️ 认不出的**回退原值**(见 `paymentChannelNameZh`)—— 宿主加了新渠道时
// 至少显示 code,⛔ 不静默变空。
// =============================================================
export const PACPaymentChannels = {
cash: { nameZh: '现金' },
card: { nameZh: '刷卡' },
wechat: { nameZh: '微信' },
alipay: { nameZh: '支付宝' },
apple_pay: { nameZh: 'Apple Pay' },
mini_program: { nameZh: '小程序' },
third_party: { nameZh: '第三方' },
insurance: { nameZh: '保险' },
medical_insurance: { nameZh: '医保' },
membership_card: { nameZh: '会员卡' },
store: { nameZh: '储值' },
advance: { nameZh: '预收款' },
check: { nameZh: '支票' },
debt: { nameZh: '欠费' },
/** ⚠️ 宿主没记渠道时落这里 —— 占了将近一半,⛔ 不是"其它支付方式" */
other: { nameZh: '未记渠道' },
} as const;
export type PACPaymentChannel = keyof typeof PACPaymentChannels;
// =============================================================
// Recommendation 提取来源(content.extractedBy) // Recommendation 提取来源(content.extractedBy)
// ============================================================= // =============================================================
...@@ -710,6 +761,39 @@ export function treatmentCategoryNameZh(category: string): string { ...@@ -710,6 +761,39 @@ export function treatmentCategoryNameZh(category: string): string {
} }
/** /**
* ⭐⭐ 事实的 `content.status` → 中文名 —— **按 fact type 分派**。
*
* ⚠️ 必须分派而不是合成一张大表:`completed` 在预约和治疗里都出现,
* 而 `arrived` / `no_show` 只属于预约、`failed` 只属于治疗。
* 合成一张表的话,哪天两边同一个码要不同措辞就没地方改了。
* ⚠️ 认不出回退原值 —— 宿主加了新状态时至少显示 code,⛔ 不静默变空。
*/
export function factStatusNameZh(factType: string, status: string): string {
const table: Record<string, Record<string, { nameZh: string }>> = {
appointment_record: PACAppointmentStatuses,
treatment_record: PACTreatmentStatuses,
};
return table[factType]?.[status]?.nameZh ?? status;
}
/**
* 影像模态 → 中文名(intraoral_photo → 口内照片)。
* ⚠️ 认不出回退原值 —— 宿主加了新模态时至少显示 code,⛔ 不静默变空。
*/
export function imageModalityNameZh(modality: string): string {
return (
(PACImageModalities as Record<string, { nameZh: string }>)[modality]?.nameZh ?? modality
);
}
/** 收款渠道 → 中文名(apple_pay → Apple Pay)。⚠️ 认不出回退原值。 */
export function paymentChannelNameZh(channel: string): string {
return (
(PACPaymentChannels as Record<string, { nameZh: string }>)[channel]?.nameZh ?? channel
);
}
/**
* 乳牙判定(单牙位 token)— FDI 51-85(`^[5-8][1-5]$`)+ 宿主象限记法 1A-4E(`^[1-4][A-E]$`)。 * 乳牙判定(单牙位 token)— FDI 51-85(`^[5-8][1-5]$`)+ 宿主象限记法 1A-4E(`^[1-4][A-E]$`)。
* 恒牙 FDI 11-48(象限 1-4 + 数字 1-8)不匹配。前后端 / 召回 SQL 同口径(单一真理源)。 * 恒牙 FDI 11-48(象限 1-4 + 数字 1-8)不匹配。前后端 / 召回 SQL 同口径(单一真理源)。
*/ */
......
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