Commit 7bdc1a5c by luoqi

feat(open): add application auth and exact patient facts lookup

parent b7901c93
Pipeline #3830 failed in 0 seconds
......@@ -24,7 +24,6 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
return (
<DocsPage toc={page.data.toc} full>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<DocsBody>
<OpenAPIPage {...page.data.getOpenAPIPageProps()} />
</DocsBody>
......
......@@ -4,6 +4,9 @@ description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数
icon: KeyRound
---
同一 `/pac/v1/auth/token` 也支持应用只读查询:仅传 `appId/appSecret`、不传 `user` 即进入应用模式,需事先开通授权。原用户登录流程保持不变,详见[患者事实查询](/docs/integration/patient-facts-lookup)。
> PAC 工作台以 iframe 嵌入宿主系统。用户在宿主侧登录后,宿主**换票**给 PAC —— 传入「身份 + 角色 + 可见范围」,换取一次性 code 打开 PAC 页面。
> 接口与字段以 [API 参考 · auth](/docs/api/auth/AuthController_exchangeToken) 的 OpenAPI 定义为准。
......
......@@ -10,6 +10,8 @@
"channel-push",
"friday-push-payload",
"auth-login",
"patient-facts-lookup",
"patient-fact-content",
"friday-ai-host",
"postmessage-actions",
"execution-callback",
......
---
title: 事实 content 字段参考
description: 患者查询接口 17 类事实的输出字段、业务状态、空值和示例。
---
本页配合 [患者查询接口](/docs/integration/patient-facts-lookup) 使用。这里说明的是 **返回的 fact.content**,不是宿主摄入请求。
## 阅读约定
按 `type` 查对应章节;字段表默认展示,JSON 示例点击展开。
- `content` 保留原字段名;外层 `status` 是事实生命周期,`content.status` 是业务状态。
- 兼容字段缺失、null 及未知字段/类型;缺失不等于 false、0、无病或已完成。
- 金额以**分**计;牙位保留文本。来源 ID 仅在对应患者、来源范围内使用。
- 时间轴优先使用事实外层时间;旧 content 的无时区文本不能直接当 UTC。
- 示例为虚构结构,不代表宿主已提供该类数据。内部备注、联系方式及证件类键剔除,自由文本脱敏。
## `consultation_record` 咨询
意向是患者主观意愿,不等于医生诊断。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `consultation_external_id` | string | 源咨询记录标识 |
| `consult_date` | string/null | 咨询日期或时刻,旧记录可能保留日期文本 |
| `source_channel` | string/null | 来源渠道 |
| `intents` | string[] | 患者意向原文列表 |
| `intent_categories` | string[] | PAC 治疗类别列表 |
| `is_first_visit` | boolean/null | 源侧初诊标记;未知为 null |
| `task_director` | string/null | 跟进人原值 |
| `doctor_id` | string/null | 源医生标识 |
| `appointment_external_id` | string/null | 关联源预约标识 |
| `intent` | string/null | 兼容的意向展示串 |
| `content` | string/null | 咨询正文 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "consultation_record",
"content": {
"consultation_external_id": "consult-1",
"intents": [
"洁牙"
],
"intent_categories": [
"preventive"
],
"is_first_visit": true
}
}
```
</details>
## `visit_registration_record` 挂号
标准写入校验使用 registration_external_id,但当前解析器使用 visit_registration_external_id;接口按已存字段返回,不承诺两者同时存在。该摄入差异未在本次修改。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `registration_external_id` | string | 标准挂号标识 |
| `registered_at` | string/null | 挂号时刻 |
| `doctor_id` | string/null | 源医生标识 |
| `visit_registration_external_id` | string | 兼容挂号标识(若已存) |
| `appointment_external_id` | string/null | 关联源预约标识(若已存) |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "visit_registration_record",
"content": {
"registration_external_id": "reg-1",
"registered_at": "2026-10-19T01:00:00.000Z"
}
}
```
</details>
## `visit_reception_record` 接待登记
标准写入校验使用 reception_external_id,当前解析器使用 visit_reception_external_id;返回已有值,不在查询时补造或转换。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `reception_external_id` | string | 标准接待标识 |
| `reception_type` | string/null | 接待类型 |
| `received_at` | string/null | 接待时刻 |
| `visit_reception_external_id` | string | 兼容接待标识(若已存) |
| `receptionist_id` | string/null | 接待人标识(若已存) |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "visit_reception_record",
"content": {
"reception_external_id": "reception-1",
"received_at": "2026-10-19T01:05:00.000Z"
}
}
```
</details>
## `encounter_record` 医生接诊
只表达接诊元信息;诊断、治疗分别使用对应事实。内部 notes 不输出。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `encounter_external_id` | string | 源接诊标识 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `chief_complaint` | string/null | 主诉 |
| `encounter_type` | string/null | 接诊类型原值 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "encounter_record",
"content": {
"encounter_external_id": "enc-1",
"doctor_name": "示例医生",
"chief_complaint": "右下牙疼痛"
}
}
```
</details>
## `diagnosis_record` 诊断
image_ai 表示影像分析来源,不能当作医生确认;content 不会因 kind/status 变化而改成另一种模型。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `code` | string/null | PAC 标准诊断或建议码;未映射时 null,不据此推断无病 |
| `code_source` | string/null | std_code / name_map / llm / image_ai;未知 null |
| `name_zh` | string/null | 原诊断中文名称 |
| `tooth_position` | string/null | 牙位文本,可能多牙位及牙面标记 |
| `severity` | string/null | mild / moderate / severe |
| `onset_date` | string/null | 起病日期原值 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `source_encounter_external_id` | string | 来源接诊引用;源未提供时解析器可能用本记录标识兜底,不保证真实关联 |
| `source_image_external_id / source_analysis_external_id` | string | 可选的影像 / 分析来源标识 |
| `clinical_signals` | object[] | 可选临床信号,见公共嵌套结构 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "diagnosis_record",
"content": {
"code": "K02",
"code_source": "std_code",
"name_zh": "龋病",
"tooth_position": "36",
"source_encounter_external_id": "enc-1"
}
}
```
</details>
## `treatment_record` 治疗与治疗计划
kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍在进行,不能只看 kind 推断治疗完成。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `category` | string | endodontic / restorative / prosthodontic / implant / periodontic / orthodontic / surgical / pediatric / preventive / cosmetic / review |
| `subtype` | string/null | 治疗子类或原文 |
| `tooth_position` | string/null | 牙位文本 |
| `status` | string | 业务进度:planned / in_progress / completed / cancelled / failed |
| `started_at / completed_at` | string/null | 开始 / 完成时刻 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `quantity` | number/null | 数量 |
| `unit_name` | string/null | 计量单位,如颗、次、区 |
| `treat_stages` | string[]/null | 源归一化治疗阶段;对外统一步骤优先使用事实外层 steps |
| `source_encounter_external_id` | string/null | 来源接诊引用 |
| `related_diagnosis_subject_id` | string/null | 关联诊断主体引用 |
| `review_window_days` | number/null | 复查窗口天数 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "treatment_record",
"content": {
"category": "endodontic",
"subtype": "根管充填",
"tooth_position": "36",
"status": "completed",
"completed_at": "2026-10-19T02:00:00.000Z",
"treat_stages": [
"canal_filling"
]
}
}
```
</details>
## `recommendation_record` 医生建议
通常 kind=planned;plannedFor 在当前解析器中可为建议提出时刻,不保证是已预约的复诊日期。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `code` | string/null | PAC 归一建议码;未映射 null |
| `name` | string/null | 建议原文 |
| `source_encounter_external_id` | string/null | 来源接诊引用 |
| `tooth_position` | string/null | 牙位文本 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `extracted_by` | string | rule / llm / doctor_input |
| `confidence` | number/null | 0–1 置信度,未提供 null |
| `source_fact_subject_id` | string/null | 来源事实主体引用 |
| `doctor_suggested_at` | string/null | 建议提出时刻 |
| `window_days` | number/null | 建议时间窗,单位天 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "recommendation_record",
"content": {
"code": null,
"name": "建议复查",
"tooth_position": "36",
"extracted_by": "doctor_input",
"window_days": 30
}
}
```
</details>
## `emr_record` 病历
段落统一以字符串返回;历史字符串可能含序列化 JSON,不保证都可直接 JSON.parse。结构化诊断和治疗在其他事实中,勿将文本再次当作新增事件。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `emr_external_id` | string | 源病历标识 |
| `encounter_external_id` | string/null | 关联源接诊标识 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `illness_desc` | string/null | 主诉或疾病描述 |
| `pre_illness` | string/null | 现病史 |
| `past_history` | string/null | 既往史 |
| `general_condition` | string/null | 一般情况 |
| `exam_findings` | string/null | 检查所见 |
| `aux_exam_findings` | string/null | 可选辅助检查 |
| `diagnosis_text` | string/null | 诊断原文 |
| `treatment_plan` | string/null | 治疗计划原文 |
| `treatment_notes` | string/null | 可选本次治疗原文 |
| `disposal` | string/null | 本次处置原文 |
| `doctor_advice` | string/null | 医嘱 |
| `quality_check` | object/null | 可选质检对象:status(源状态字符串)、rejected(布尔)、reject_reason(原原因,可空) |
| `clinical_signals` | object[] | 可选临床信号,见公共嵌套结构 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "emr_record",
"content": {
"emr_external_id": "emr-1",
"encounter_external_id": "enc-1",
"illness_desc": "右下牙疼痛",
"exam_findings": "36 牙检查所见",
"doctor_advice": "按医嘱复诊"
}
}
```
</details>
## `image_record` 影像
file_url 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `image_external_id` | string | 源影像标识 |
| `encounter_external_id` | string/null | 关联源接诊标识 |
| `modality` | string | pa / bw / pano / cbct / intraoral_photo / scan / other |
| `tooth_positions` | string[]/null | 牙位列表 |
| `finding` | string/null | 影像所见原文 |
| `captured_at` | string/null | 拍摄时刻,来源未提供时为空 |
| `file_url` | string | 可选宿主文件位置原值,可能相对路径或临时地址 |
| `file_name / file_type` | string | 可选文件名 / 文件类型 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "image_record",
"content": {
"image_external_id": "image-1",
"modality": "pano",
"tooth_positions": [
"36"
],
"finding": "影像所见",
"file_name": "example.jpg",
"file_url": "images/example.jpg"
}
}
```
</details>
## `image_analysis_record` 影像分析
外层 active 只说明此快照有效,不代表分析已完成。只有 content.status=completed 时,空 findings 才可解释为该次完成报告无发现;不代表患者没有其他疾病。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `analysis_external_id / image_external_id` | string | 分析 / 对应影像的源标识 |
| `code_source` | string | 固定 image_ai |
| `status` | string | 分析业务状态 pending / completed / failed |
| `analyzed_at` | string/null | 实际分析时刻;未知 null |
| `source_updated_at` | string/null | 源更新时间 |
| `analysis_result` | string/null | 报告原文,可能是 JSON 字符串 |
| `findings` | object[] | 问题快照,字段见下文 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "image_analysis_record",
"content": {
"analysis_external_id": "analysis-1",
"image_external_id": "image-1",
"code_source": "image_ai",
"status": "completed",
"analyzed_at": "2026-10-19T02:05:00.000Z",
"findings": [
{
"externalId": "finding-1",
"code": "K02",
"name": "龋病",
"toothPosition": "36",
"excluded": false
}
]
}
}
```
</details>
## `appointment_record` 预约
这是内容的完整业务枚举,不代表本查询会返回所有状态的记录:外层 cancelled/expired/superseded 已被过滤。staff_notes 不输出。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `appointment_external_id` | string | 源预约标识 |
| `scheduled_at / arrived_at` | string/null | 计划预约 / 实际到诊时刻 |
| `appointment_type` | string/null | 预约类型 |
| `doctor_id / doctor_name` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `resource_name` | string/null | 源排班资源名,也可能是房间或服务 |
| `complaint_category / complaint_text` | string/null | 预约主诉类别 / 原文 |
| `duration_minutes` | number/null | 预约时长,分钟 |
| `cancellation_reason` | string/null | 取消原因(若有) |
| `status` | string/null | scheduled / rescheduled / cancelled / arrived / in_treatment / completed / no_show / walk_in |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "appointment_record",
"content": {
"appointment_external_id": "appointment-1",
"scheduled_at": "2026-10-20T01:00:00.000Z",
"arrived_at": null,
"status": "scheduled",
"doctor_name": "示例医生",
"duration_minutes": 30
}
}
```
</details>
## `order_record` 医嘱单据
当前解析器 total_amount/items[].name 与标准写入校验 amount_cents/items[].item_name 存在差异。本接口不换算、不把缺失当 0;示例是通过标准校验的内容形态,不证明各宿主已成功摄入此类单据。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `order_external_id` | string | 源单据标识 |
| `encounter_external_id` | string/null | 关联源接诊标识 |
| `amount_cents` | number | 标准总金额,整数分 |
| `items` | object[]/null | 标准明细:item_type(technical/material/drug,可缺)、item_code/item_name(字符串,可空)、quantity/unit_price_cents/amount_cents(数字,可空);金额为分 |
| `total_amount / currency / host_status` | number / string / string | 兼容解析器字段(若已存):金额为分、币种、源业务状态 |
| `items[].name / type / treatment_category / amount` | 字符串或数字,可空 | 兼容解析器明细(若已存):项目名、treatment/consumable/medication/other、治疗类别、金额分 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "order_record",
"content": {
"order_external_id": "order-1",
"amount_cents": 12000,
"items": [
{
"item_type": "technical",
"item_name": "治疗项目",
"quantity": 1,
"amount_cents": 12000
}
]
}
}
```
</details>
## `payment_record` 收款
历史旧解析版本的金额口径可能不同;不能仅据缺少 receivable_cents 自动判定折扣或纯权益支付。查询不重写历史事实。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `payment_external_id` | string | 源收款标识 |
| `amount_cents` | number | 实付金额,整数分 |
| `receivable_cents` | number/null | 可选应收金额,分 |
| `discount_cents` | number/null | 可选折扣金额,分 |
| `channel` | string/null | 支付渠道 |
| `insurance_name` | string/null | 商业保险公司名称 |
| `card_type_name / card_name` | string/null | 卡券类型名 / 名称 |
| `settlement_project` | string/null | 结算项目名称 |
| `doctor_id` | string/null | 收费医生标识 |
| `encounter_external_id / related_order_external_id` | string/null | 关联接诊 / 医嘱单据引用 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "payment_record",
"content": {
"payment_external_id": "payment-1",
"amount_cents": 10000,
"receivable_cents": 12000,
"discount_cents": 2000,
"channel": "银行卡"
}
}
```
</details>
## `refund_record` 退费
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `refund_external_id` | string | 源退费标识 |
| `amount_cents` | number | 退费金额,非负整数分,不用负数表达退费 |
| `reason` | string/null | 退费原因 |
| `related_payment_external_id` | string/null | 关联源收款引用 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "refund_record",
"content": {
"refund_external_id": "refund-1",
"amount_cents": 2000,
"reason": "项目调整",
"related_payment_external_id": "payment-1"
}
}
```
</details>
## `recharge_record` 充值
当前解析器 amount/recharge_type 与标准写入校验 amount_cents/card_type 存在差异;兼容字段只说明已存数据如何读,不承诺该摄入分支已对齐。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `recharge_external_id` | string | 源充值标识 |
| `amount_cents` | number | 标准充值金额,整数分 |
| `card_type` | string/null | 标准卡类型 |
| `amount / currency / recharge_type / benefits` | number / string / string或null / JSON | 兼容解析器字段(若已存):金额分、币种、充值类型、权益原值 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "recharge_record",
"content": {
"recharge_external_id": "recharge-1",
"amount_cents": 50000,
"card_type": "储值卡"
}
}
```
</details>
## `complaint_record` 投诉
当前解析器将非有效投诉标为 invalidated,本查询不返回这些快照;解决的投诉仍是历史事实,不等于删除,也不自动意味着免打扰。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `complaint_external_id / number` | string / string或null | 源记录标识 / 投诉编号 |
| `feedback_kind` | string | complaint / praise / neutral / unknown |
| `status` | string/null | open / in_progress / resolved / draft / unknown |
| `category` | string | service / clinical / billing / environment / waiting / other / unknown |
| `channel` | string | sms_survey / hotline / third_party / sales / mini_program / unknown |
| `signal_eligible` | boolean | 是否具备有效投诉信号资格 |
| `content` | string/null | 投诉正文;即 fact.content.content |
| `severity` | string/null | low / medium / high;当前解析器不推测,留 null |
| `review_reason` | string/null | classification_conflict / unknown_feedback_kind / unknown_status 或 null |
| `related_treatment_subject_id / related_order_external_id` | string/null | 关联治疗 / 医嘱引用 |
| `resolved_at / source_updated_at` | string/null | 解决 / 源更新时刻 |
| `follow_ups` | object[] | 跟进列表,字段见下文 |
| `host_status / source_category / source_concern / source_channel` | string/null | 源状态、分类、关注点、渠道原值 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "complaint_record",
"content": {
"complaint_external_id": "complaint-1",
"feedback_kind": "complaint",
"status": "resolved",
"category": "service",
"channel": "hotline",
"signal_eligible": true,
"content": "服务反馈已处理",
"resolved_at": "2026-10-19T03:00:00.000Z",
"follow_ups": []
}
}
```
</details>
## `referral_record` 转介绍
事实挂在被推荐人名下,表示“我是被谁推荐来的”。推荐人不必是患者,不等同于 relations 的亲属关系。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `referral_external_id` | string | 源转介绍标识 |
| `referrer_external_id` | string/null | 推荐人源标识 |
| `referrer_name / referrer_chart_number` | string/null | 推荐人姓名 / 病历号快照 |
| `referrer_type` | string/null | patient / staff / partner / other |
| `channel` | string/null | 推荐渠道 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "referral_record",
"content": {
"referral_external_id": "referral-1",
"referrer_name": "示例推荐人",
"referrer_type": "patient",
"referrer_chart_number": "000456"
}
}
```
</details>
## 公共嵌套结构
### `clinical_signals[]`(病历、诊断)
| 字段 | 类型与含义 |
| --- | --- |
| `code / label` | 字符串;信号码 / 中文标签 |
| `phrase / field` | 字符串;命中原文 / 所在 content 字段名 |
| `span` | 两个整数;原文字符区间 [start,end)。出站脱敏可能改变文本长度,不能保证在脱敏文本上仍精确定位 |
| `negated` | false=阳性陈述;true=明确否认;null=不可判 |
| `severity` | absolute / relative / check;信号等级,不等于事实严重度或医生最终诊断 |
| `contraindicates` | 治疗域数组:implant / surgery / anesthesia / orthodontic / xray |
| `validUntil / reviewAfter` | 可空 ISO 日期;信号失效 / 需复评日期,独立于外层事实有效期 |
| `extractedBy` | 抽取器版本字符串 |
没有该字段不意味着没有禁忌;历史信号可能已过期或需复评,不能仅凭事实仍 active 就当作当前风险。
### `image_analysis_record.findings[]`
| 字段 | 类型与含义 |
| --- | --- |
| `externalId` | 问题源标识,字符串 |
| `code / name` | 可空标准码 / 问题原名称字符串 |
| `patientExternalId / imageExternalId` | 可选来源患者 / 影像引用 |
| `toothPosition / excludedToothPositions` | 可空牙位文本 / 排除牙位文本 |
| `excluded` | 布尔;是否排除整项 |
这是分析报告问题快照,可能含已排除项;可用诊断信号另由 `diagnosis_record` 表达,不能把两者重复计数。
### `complaint_record.follow_ups[]`
| 字段 | 类型与含义 |
| --- | --- |
| `occurredAt` | 可空跟进时刻 |
| `action` | transferred / completed / edited / followed_up / paused / unknown |
| `sourceAction` | 可空源操作原值 |
| `content` | 可空跟进正文,已脱敏 |
| `actorId / actorName` | 可空操作人源标识 / 姓名 |
## 兼容边界
本页按当前内容 schema、解析器和出站脱敏共同核对。挂号、接待、医嘱、充值存在上文标注的解析器/写入 schema 差异;查询只读已落库内容,不修复入库、不伪造缺少字段,也不能用“查不到”证明源系统不存在记录。接入方应以本宿主实际可用数据验收;摄入差异另行修复。
---
title: 患者查询
description: 获取应用令牌,精确查询患者资料、画像、关系和事实。
---
**服务端调用**:使用宿主已有的 `appId/appSecret`,由 PAC 开通查询权限。
权限覆盖**应用所属宿主的全部有效患者(含不同租户/品牌)**。宿主后端须校验使用者权限,密钥和应用令牌不可下发浏览器。
## 1. 获取令牌
`POST /pac/v1/auth/token`
```json
{ "appId": "应用标识", "appSecret": "应用密钥" }
```
成功响应:
```json
{
"code": 0,
"msg": "ok",
"data": { "accessToken": "访问令牌", "tokenType": "Bearer", "expiresIn": 3600 }
}
```
不传 `user`、不需要 `grantType`。令牌有效期 **1 小时**,到期重新获取;仅用于应用查询,不能登录工作台。
<details>
<summary>与原登录的区别</summary>
- 传完整 `user`:原用户登录流程,返回用户令牌、刷新令牌和登录 code。
- `user` 为空、null 或不完整:参数错误,不转为应用访问。
- 用户登录令牌不能调用本查询;FridayAI 原 HMAC 鉴权独立。
- 宿主停用、撤销查询授权或轮换应用密钥后,旧应用令牌失效。
[登录接入](/docs/integration/auth-login) · [鉴权 API 参考](/docs/api/auth/AuthController_exchangeToken)
</details>
## 2. 查询患者
`POST /pac/v1/open/patient-facts/lookup`
```http
Authorization: Bearer <accessToken>
Content-Type: application/json
```
```json
{ "medicalRecordNumber": "000123" }
```
| 可选条件 | 含义 |
| --- | --- |
| `medicalRecordNumber` | 病历号,字符串,保留前导零 |
| `name` | 姓名 |
| `phone` | 患者主档手机号 |
**至少一项,多项取交集,精确匹配。** 只去除首尾空白,不做模糊查询、号码格式转换或家属扩展。无需系统 ID;不支持分页及其他筛选参数,未知参数会拒绝。
## 3. 读取结果
`data.items` 为患者数组;无匹配返回 `[]`。病历号可能重号,不同患者分别返回。
| 每项内容 | 说明 |
| --- | --- |
| `patient` | 基本信息和业务属性;`id` 为 PAC 患者 ID,`externalId` 为宿主患者 ID |
| `persona` | 已保存的当前画像,无画像为 null;查询不触发重算 |
| `relations` | 已存患者关系,无记录为 [];关系人同样带 id、externalId |
| `facts` | 全部当前有效事实,无记录为 [];不分页、不截断 |
- **标识**:用 `patient.id` 区分或缓存结果;`externalId` 不是病历号,也不保证跨品牌唯一,回链宿主须结合自身上下文。
- **事实**:只返回最新有效版本(active / fulfilled),按发生时间倒序,缺失时取计划时间;取消、失效、被替换的版本不返回。
- **状态**:`type` 决定 `content` 结构;外层 `status` 是事实生命周期,`content.status` 才是该类型的业务状态。
- **空值与时间**:未知保持 null,不等于 false 或无风险;时间戳为 UTC,生日和最近到诊日期保持 `YYYY-MM-DD`。
- **数据范围**:返回 PAC 已有数据,不含召回结果;手机号和自由文本脱敏。画像可能滞后于事实。
<details>
<summary>完整响应示例</summary>
```json
{
"code": 0,
"msg": "ok",
"data": {
"items": [
{
"patient": {
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "source-patient-123",
"name": "张三",
"medicalRecordNumber": "000123",
"phone": "138****8000",
"gender": "男",
"birthDate": "1990-01-02",
"preferences": null,
"lastVisitAt": null,
"lastVisitDoctor": null,
"preferredDoctor": null,
"doNotContact": null,
"doNotContactReason": null,
"deceased": null,
"hostFollowUpActive": null,
"tags": null,
"acquisitionChannel": null,
"acquisitionSub": null
},
"persona": null,
"relations": [],
"facts": [
{
"subjectId": "diagnosis:example",
"type": "diagnosis_record",
"kind": "actual",
"status": "active",
"clinicId": "来源诊所标识",
"clinicName": "示例诊所",
"occurredAt": "2026-10-19T02:00:00.000Z",
"plannedFor": null,
"validUntil": null,
"title": "诊断记录",
"summary": null,
"content": { "code": "K02" },
"steps": []
}
]
}
]
}
}
```
</details>
<details>
<summary>患者、画像与关系字段</summary>
| 字段 | 内容 |
| --- | --- |
| `patient` | PAC 患者 ID `id`、宿主患者 ID `externalId`、姓名、病历号、脱敏手机号、性别、出生日期、已脱敏的偏好 `preferences` |
| `patient`(业务属性) | 最近到诊日期 `lastVisitAt`、上次就诊医生 `lastVisitDoctor`、偏好医生 `preferredDoctor`、禁止联系 `doNotContact` 及原因、已故 `deceased`、宿主跟进状态 `hostFollowUpActive`、标签 `tags`、获客渠道 `acquisitionChannel/acquisitionSub` |
| `persona` | 当前已保存画像:`version`、`computedAt`、`refreshedAt`,以及 `features` 数组(`key/description/score/data`) |
| `relations` | 已存患者关系数组,每项为 `relationship` 和关系人基本信息 `patient` |
关系方向为“对方是本人的什么人”:`mother` 表示对方是当前患者的母亲。不推断反向关系,也不展开家属事实。
关系人未解析、已归档或越界时为 `patient: null`;异常跨宿主/租户的关系边不输出。关系人字段为 `id/externalId/name/medicalRecordNumber/phone/gender/birthDate`。
患者业务属性未建立时为 null;已有记录的 false 和空数组保持原值。画像取未被取代的最高版本,`computedAt` 为生成时间,`refreshedAt` 为最近刷新时间。
</details>
<details>
<summary>事实公共字段</summary>
| 字段 | 含义与读取规则 |
| --- | --- |
| `subjectId` | 来源主体的稳定引用,用于溯源。不能单独当全局唯一键,也不要解析其拼接格式作为业务逻辑 |
| `type` | 决定 `content` 的内容结构;完整 17 类及示例见 [事实内容参考](/docs/integration/patient-fact-content) |
| `kind` | `actual` 实际记录;`planned` 计划或建议。actual 不等于治疗已完成 |
| `status` | 事实生命周期,本接口仅 `active`(当前有效)、`fulfilled`(已履行)。不是病情是否存在/治愈,也不等于 content 的业务状态 |
| `clinicId / clinicName` | 来源诊所标识 / 字典名称,可空 |
| `occurredAt` | 实际发生时刻,UTC ISO 8601;未知为 null |
| `plannedFor` | 计划/建议的时间锚,UTC ISO 8601;不构成已发生的证明,建议事实也可能取建议提出时间 |
| `validUntil` | 有效期截止时刻,可空。null 表示未提供,不能推断永久有效;查询不额外按当前时间过滤此字段 |
| `title / summary` | 展示标题 / 摘要,可空,不能依靠文本代替结构化业务判断 |
| `content` | 按 type 定义的内容,保留原标准字段名、历史空值/缺失;已脱敏 |
| `steps` | PAC 标准治疗步骤,仅 `treatment_record` 有值;其他类型为空数组,未知步骤应兼容 |
时间均为空的事实排在最后,同一时间按稳定记录标识排序。此接口提供当前快照;相邻查询中记录缺失,不等于源端删除通知。
</details>
[各类 content 字段与示例](/docs/integration/patient-fact-content) · [查询 API 参考](/docs/api/open/PatientFactsLookupController_lookup)
## 错误处理
**以响应体 `code` 判断成功,不能只看 HTTP 200。** 成功为 `0`。
| code | 含义 |
| --- | --- |
| `10002` | 参数不合法 |
| `10101` | 应用凭据无效 |
| `10106` | 令牌缺失、无效、过期或已撤销 |
| `10107` | 未开通查询权限 |
<details>
<summary>PAC 运维:开通与撤销</summary>
先应用数据库迁移 `20261019170000_open_patient_facts`,再部署对应服务版本。迁移只新增默认关闭的授权字段和患者精确查询索引,不自动开通任何宿主。
```bash
pnpm --filter @pac/service pac:host grant-facts-read <宿主名称>
pnpm --filter @pac/service pac:host revoke-facts-read <宿主名称>
```
开通的是**本宿主全范围只读事实查询**。已有 FridayAI `grant-read/revoke-read` 不受影响。
</details>
......@@ -1767,7 +1767,7 @@
},
"/pac/v1/push/rows": {
"post": {
"description": "Headers 必填 X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature。Body: { source, rows[] }。 加 ?dryRun=1 走**预检**:跑完整管线(transforms+装配+归一)但不落库、不触发重算,响应带各资源样本 canonical —— 对接自测先预检到 failed=0 且 mappingMisses/suspectFields 为 0,再正式推。",
"description": "Headers 必填 X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature。Body: { source, rows[] }。 加 ?dryRun=1 走**预检**:跑 transforms+装配+归一,不写业务数据、不派生事实、不触发重算;保留预检记录及请求原文,响应带各资源样本 canonical —— 对接自测先预检到 failed=0 且 mappingMisses/suspectFields 为 0,再正式推。",
"operationId": "PushController_receiveRows",
"parameters": [
{
......@@ -1783,7 +1783,7 @@
"description": ""
}
},
"summary": "宿主推送原生表行 webhook(形态 A,HMAC 验签;?dryRun=1 预检不落库)",
"summary": "宿主推送原生表行 webhook(形态 A,HMAC 验签;?dryRun=1 预检不写业务数据)",
"tags": [
"push"
]
......@@ -2734,6 +2734,7 @@
},
"/pac/v1/auth/token": {
"post": {
"description": "不传 user:申请应用令牌,需开通宿主患者查询权限,返回 accessToken、tokenType、expiresIn;不用 grantType。\n\n携带完整 user:原用户登录换票,返回用户令牌、refreshToken 和登录 code。空、null 或不完整 user 报参数错误,不退回应用访问。\n\n应用令牌不能登录工作台,用户令牌不能调用应用查询;FridayAI 原 HMAC 鉴权保持独立。\n\n[应用鉴权与患者查询契约](/docs/integration/patient-facts-lookup) · [原用户登录接入](/docs/integration/auth-login)",
"operationId": "AuthController_exchangeToken",
"parameters": [],
"requestBody": {
......@@ -2741,7 +2742,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenExchangeRequestDto"
"$ref": "#/components/schemas/AuthTokenRequestDto"
}
}
}
......@@ -2772,7 +2773,7 @@
]
},
"data": {
"$ref": "#/components/schemas/TokenExchangeResponseDto_Output"
"$ref": "#/components/schemas/AuthTokenResponseDto_Output"
}
}
}
......@@ -2780,7 +2781,7 @@
}
}
},
"summary": "Token Exchange — host backend trades appId+appSecret+user for an accessToken + one-time code",
"summary": "获取令牌 / 用户登录",
"tags": [
"auth"
]
......@@ -3328,6 +3329,66 @@
]
}
},
"/pac/v1/open/patient-facts/lookup": {
"post": {
"description": "使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。\n\n返回患者数组:`patient`(资料)、`persona`(画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。\n\n[调用说明与示例](/docs/integration/patient-facts-lookup) · [事实 content 字段](/docs/integration/patient-fact-content)",
"operationId": "PatientFactsLookupController_lookup",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PatientFactsLookupRequestDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"code",
"msg",
"data"
],
"properties": {
"code": {
"type": "integer",
"enum": [
0
]
},
"msg": {
"type": "string",
"enum": [
"ok"
]
},
"data": {
"$ref": "#/components/schemas/PatientFactsLookupResponseDto_Output"
}
}
}
}
}
}
},
"security": [
{
"accessToken": []
}
],
"summary": "查询患者资料与事实",
"tags": [
"open"
]
}
},
"/pac/v1/open/patients/search": {
"post": {
"description": "Headers:X-PAC-Timestamp / X-PAC-Signature;signature = hex(HMAC-SHA256(`<timestamp>.<rawBody>`, read_secret))。⭐ 宿主在 body 的 host(宿主名)里,验签按它找钥匙;所有只读端点同此。Body:{ host, tenantId, orgScope(必填非空), q?, cursor?, limit? }。q 省略 = 按最近更新倒序翻名册;keyset 分页,游标见 nextCursor。",
......@@ -4027,7 +4088,7 @@
},
{
"name": "auth",
"description": "登录换票、会话、数据权限(orgScope)。"
"description": "应用令牌、用户登录换票、会话及数据权限(orgScope)。"
},
{
"name": "facts",
......@@ -4046,7 +4107,8 @@
"description": "MCP(Model Context Protocol)工具端点。"
},
{
"name": "open"
"name": "open",
"description": "宿主应用患者查询(Bearer)与 FridayAI 开放接口(HMAC);以各端点说明为准。"
},
{
"name": "patient",
......@@ -8495,7 +8557,7 @@
],
"additionalProperties": false
},
"TokenExchangeRequestDto": {
"AuthTokenRequestDto": {
"type": "object",
"properties": {
"appId": {
......@@ -8574,12 +8636,13 @@
},
"required": [
"appId",
"appSecret",
"user"
"appSecret"
],
"additionalProperties": false
},
"TokenExchangeResponseDto_Output": {
"AuthTokenResponseDto_Output": {
"anyOf": [
{
"type": "object",
"properties": {
"accessToken": {
......@@ -8616,6 +8679,33 @@
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"accessToken": {
"type": "string"
},
"tokenType": {
"type": "string",
"enum": [
"Bearer"
]
},
"expiresIn": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"accessToken",
"tokenType",
"expiresIn"
],
"additionalProperties": false
}
]
},
"ExchangeCodeRequestDto": {
"type": "object",
"properties": {
......@@ -9237,6 +9327,355 @@
],
"additionalProperties": false
},
"PatientFactsLookupRequestDto": {
"type": "object",
"properties": {
"medicalRecordNumber": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"phone": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
},
"additionalProperties": false
},
"PatientFactsLookupResponseDto_Output": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"patient": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"description": "PAC 患者 ID;用于区分患者及关联 PAC 结果"
},
"externalId": {
"type": "string",
"description": "宿主患者 ID;须结合宿主自身租户/品牌上下文,不保证宿主内全局唯一"
},
"name": {
"type": "string",
"nullable": true
},
"medicalRecordNumber": {
"type": "string",
"nullable": true
},
"phone": {
"type": "string",
"description": "脱敏手机号",
"nullable": true
},
"gender": {
"type": "string",
"nullable": true
},
"birthDate": {
"type": "string",
"description": "YYYY-MM-DD 业务日期",
"nullable": true
},
"preferences": {},
"lastVisitAt": {
"type": "string",
"description": "最近到诊业务日期 YYYY-MM-DD",
"nullable": true
},
"lastVisitDoctor": {
"type": "string",
"nullable": true
},
"preferredDoctor": {
"type": "string",
"nullable": true
},
"doNotContact": {
"type": "boolean",
"description": "无业务属性记录时为 null,不能当成 false",
"nullable": true
},
"doNotContactReason": {
"type": "string",
"nullable": true
},
"deceased": {
"type": "boolean",
"nullable": true
},
"hostFollowUpActive": {
"type": "boolean",
"nullable": true
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "null=无业务属性记录;[]=已存标签为空",
"nullable": true
},
"acquisitionChannel": {
"type": "string",
"nullable": true
},
"acquisitionSub": {
"type": "string",
"nullable": true
}
},
"required": [
"id",
"externalId",
"name",
"medicalRecordNumber",
"phone",
"gender",
"birthDate",
"preferences",
"lastVisitAt",
"lastVisitDoctor",
"preferredDoctor",
"doNotContact",
"doNotContactReason",
"deceased",
"hostFollowUpActive",
"tags",
"acquisitionChannel",
"acquisitionSub"
],
"additionalProperties": false
},
"persona": {
"type": "object",
"properties": {
"version": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"computedAt": {
"type": "string"
},
"refreshedAt": {
"type": "string",
"nullable": true
},
"features": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"description": {
"type": "string"
},
"score": {
"type": "number",
"nullable": true
},
"data": {}
},
"required": [
"key",
"description",
"score",
"data"
],
"additionalProperties": false
}
}
},
"required": [
"version",
"computedAt",
"refreshedAt",
"features"
],
"additionalProperties": false,
"description": "最近已保存的当前画像,不触发重算;未形成时为 null",
"nullable": true
},
"relations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"relationship": {
"type": "string",
"description": "对方是本人的什么关系,按 PAC 已存方向返回"
},
"patient": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"description": "PAC 患者 ID;用于区分患者及关联 PAC 结果"
},
"externalId": {
"type": "string",
"description": "宿主患者 ID;须结合宿主自身租户/品牌上下文,不保证宿主内全局唯一"
},
"name": {
"type": "string",
"nullable": true
},
"medicalRecordNumber": {
"type": "string",
"nullable": true
},
"phone": {
"type": "string",
"description": "脱敏手机号",
"nullable": true
},
"gender": {
"type": "string",
"nullable": true
},
"birthDate": {
"type": "string",
"description": "YYYY-MM-DD 业务日期",
"nullable": true
}
},
"required": [
"id",
"externalId",
"name",
"medicalRecordNumber",
"phone",
"gender",
"birthDate"
],
"additionalProperties": false,
"description": "关系人未解析、归档或越界时为 null;不展开其事实和画像",
"nullable": true
}
},
"required": [
"relationship",
"patient"
],
"additionalProperties": false
}
},
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subjectId": {
"type": "string",
"description": "来源事实主体标识,跨版本稳定;仅作溯源,不能单独当全局唯一键"
},
"type": {
"type": "string",
"description": "决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型"
},
"kind": {
"type": "string",
"description": "actual=实际记录;planned=计划或建议"
},
"status": {
"type": "string",
"description": "事实生命周期:本接口仅 active/fulfilled;业务进度看对应 content 字段,不代表疾病是否治愈"
},
"clinicId": {
"type": "string",
"nullable": true
},
"clinicName": {
"type": "string",
"nullable": true
},
"occurredAt": {
"type": "string",
"description": "实际发生时刻,UTC ISO 8601;未知为 null",
"nullable": true
},
"plannedFor": {
"type": "string",
"description": "计划或建议的时间锚,非完成证明;UTC ISO 8601",
"nullable": true
},
"validUntil": {
"type": "string",
"description": "有效期截止时刻;null=未提供,不能推断永久有效",
"nullable": true
},
"title": {
"type": "string",
"nullable": true
},
"summary": {
"type": "string",
"nullable": true
},
"content": {
"description": "按事实类型定义的标准化内容,已脱敏"
},
"steps": {
"type": "array",
"items": {
"type": "string"
},
"description": "PAC 标准治疗步骤,仅 treatment_record 有值;其他类型 [],允许新增步骤"
}
},
"required": [
"subjectId",
"type",
"kind",
"status",
"clinicId",
"clinicName",
"occurredAt",
"plannedFor",
"validUntil",
"title",
"summary",
"content",
"steps"
],
"additionalProperties": false
}
}
},
"required": [
"patient",
"persona",
"relations",
"facts"
],
"additionalProperties": false
}
}
},
"required": [
"items"
],
"additionalProperties": false
},
"ApiErrorEnvelope": {
"type": "object",
"description": "Wrapped error response. HTTP status is 200 unless the server crashed (5xx).",
......
ALTER TABLE "hosts" ADD COLUMN "open_facts_read_enabled" BOOLEAN NOT NULL DEFAULT false;
-- 精确病历号/姓名查询避免在宿主患者全集上顺序扫描;phone 已有索引。
CREATE INDEX "patients_host_id_medical_record_number_idx" ON "patients"("host_id", "medical_record_number");
CREATE INDEX "patients_host_id_name_idx" ON "patients"("host_id", "name");
......@@ -69,6 +69,8 @@ model Host {
appId String @unique @map("app_id")
/// appSecret 的 scrypt 哈希(salt:hash 格式),原文从不落库
appSecretHash String @map("app_secret_hash")
/// 应用令牌读取本宿主全部当前患者事实;默认关闭,与 FridayAI HMAC 授权独立。
openFactsReadEnabled Boolean @default(false) @map("open_facts_read_enabled")
/// PAC 对该宿主的出站调用配置;null = PAC 不主动调用宿主(纯 push 接入或不贡献数据)。
///
......@@ -254,6 +256,8 @@ model Patient {
/// 跨同步 upsert 去重 + 集团内 external_id 撞号消歧(同号不同源 = 不同人,靠 source_unit 分开)
@@unique([hostId, tenantId, sourceUnit, externalId])
@@index([hostId, tenantId])
@@index([hostId, medicalRecordNumber])
@@index([hostId, name])
@@index([phone])
/// 召回池热查询路径(active = 是否活跃);合规过滤走 PatientProfile join
@@index([hostId, tenantId, active])
......
......@@ -70,6 +70,8 @@ Commands:
revoke-read <id-or-name> 撤销读方对这个宿主的授权(立即生效)
rotate-read 给所有已授权宿主换一把新读钥匙(前插,旧的 grace 期仍收)
prune-read 读方切到新钥匙后,清掉旧的读钥匙
grant-facts-read <id-or-name> 开通宿主应用事实查询(本宿主全部患者;与 FridayAI HMAC 独立)
revoke-facts-read <id-or-name> 撤销应用事实查询(已有令牌随即不可查询)
deactivate <id-or-name> 软停 host(后续 auth 拒绝)
Examples:
......@@ -288,6 +290,17 @@ async function main() {
break;
}
case 'grant-facts-read':
case 'revoke-facts-read': {
const idOrName = args.positional[0];
if (!idOrName) throw new Error(`usage: pac:host ${args.command} <id-or-name>`);
const id = await resolveHostId(svc, idOrName);
const enabled = args.command === 'grant-facts-read';
await svc.setOpenFactsRead(id, enabled);
console.error(`✓ ${idOrName} 应用事实查询已${enabled ? '开通(本宿主全部患者)' : '撤销'}`);
break;
}
case 'deactivate': {
const idOrName = args.positional[0];
if (!idOrName) {
......
......@@ -53,6 +53,12 @@ export class HostsService {
return host;
}
/** 与 FridayAI 的 HMAC 授权独立;开通后允许应用读取本宿主全部患者当前事实。 */
async setOpenFactsRead(id: string, enabled: boolean): Promise<void> {
await this.getHostOrThrow(id);
await this.prisma.host.update({ where: { id }, data: { openFactsReadEnabled: enabled } });
}
// ─────────────────────────────────────────────────────────
// Create
// ─────────────────────────────────────────────────────────
......
import { createHmac } from 'node:crypto';
import { z } from 'zod';
export const APPLICATION_TOKEN_AUDIENCE = 'pac:open';
export const APPLICATION_TOKEN_TTL_SECONDS = 3600;
export const APPLICATION_FACTS_PERMISSION = 'patient-facts:read';
export const ApplicationTokenPayloadSchema = z.object({
tokenUse: z.literal('application'),
sub: z.string().min(1),
hostId: z.uuid(),
aud: z.literal(APPLICATION_TOKEN_AUDIENCE),
permissions: z.array(z.string()),
credentialVersion: z.string().min(1),
iat: z.number().int(),
exp: z.number().int(),
});
/** 独立用途密钥:旧 WebSocket/MCP 有直接验签入口,应用令牌也不能通过这些入口。
* 从现有主密钥派生,不增加接入方配置,不改变用户令牌签发和验证。
*/
export function applicationTokenSecret(masterSecret: string): string {
return createHmac('sha256', masterSecret).update('pac:application-token:v1').digest('hex');
}
import { AuthTokenRequestSchema } from '@pac/types';
import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common';
import type { Request } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
......@@ -5,10 +6,7 @@ import { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
TenantScope,
TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator';
import { TenantScope, TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AuthService } from './auth.service';
import {
ClientDiagRequestDto,
......@@ -20,8 +18,8 @@ import {
RefreshTokenRequestDto,
RefreshTokenResponseDto,
SessionResponseDto,
TokenExchangeRequestDto,
TokenExchangeResponseDto,
AuthTokenRequestDto,
AuthTokenResponseDto,
} from './dto/auth.dto';
@ApiTags('auth')
......@@ -31,13 +29,18 @@ export class AuthController {
@Public()
@Post('token')
@ZodResponse({ status: 200, type: TokenExchangeResponseDto })
@ZodResponse({ status: 200, type: AuthTokenResponseDto })
@ApiOperation({
summary:
'Token Exchange — host backend trades appId+appSecret+user for an accessToken + one-time code',
summary: '获取令牌 / 用户登录',
description: [
'不传 user:申请应用令牌,需开通宿主患者查询权限,返回 accessToken、tokenType、expiresIn;不用 grantType。',
'携带完整 user:原用户登录换票,返回用户令牌、refreshToken 和登录 code。空、null 或不完整 user 报参数错误,不退回应用访问。',
'应用令牌不能登录工作台,用户令牌不能调用应用查询;FridayAI 原 HMAC 鉴权保持独立。',
'[应用鉴权与患者查询契约](/docs/integration/patient-facts-lookup) · [原用户登录接入](/docs/integration/auth-login)',
].join('\n\n'),
})
exchangeToken(@Body() dto: TokenExchangeRequestDto) {
return this.auth.exchangeToken(dto);
exchangeToken(@Body() dto: AuthTokenRequestDto) {
return this.auth.exchangeToken(AuthTokenRequestSchema.parse(dto));
}
@Public()
......@@ -73,8 +76,7 @@ export class AuthController {
@Post('mock-login')
@ZodResponse({ status: 200, type: MockLoginResponseDto })
@ApiOperation({
summary:
'Mock login(开发 / 试部署用,env 门控)— 按 { tenant, role } 预制 user payload 直产 JWT',
summary: 'Mock login(开发 / 试部署用,env 门控)— 按 { tenant, role } 预制 user payload 直产 JWT',
description:
'env: PAC_ENABLE_MOCK_LOGIN=true 强开 / =false 强关;不设按 NODE_ENV(prod 默认关)。' +
'生产真 host SSO 接入后,把 env 设 false 或删该 endpoint。',
......@@ -88,8 +90,7 @@ export class AuthController {
@ZodResponse({ status: 200, type: MockUsersResponseDto })
@ApiOperation({
summary: '客服花名册(开发 / 试部署用)— 给"快速登录"对话框选人',
description:
'派生自 data/jvs-dw/users.json(回访表 · 近 12 月在岗)。mock 登录禁用时返空数组。',
description: '派生自 data/jvs-dw/users.json(回访表 · 近 12 月在岗)。mock 登录禁用时返空数组。',
})
mockUsers() {
return { users: this.auth.listMockUsers() };
......@@ -110,10 +111,7 @@ export class AuthController {
summary:
'当前会话视图 — 把 JWT.orgScope 展开成 clinicIds/sourceUnits 返给前端(设计 B:JWT 只装通用 orgScope,前端展不开)',
})
async session(
@CurrentUser() user: AccessTokenPayload,
@TenantScope() scope: TenantScopeContext,
) {
async session(@CurrentUser() user: AccessTokenPayload, @TenantScope() scope: TenantScopeContext) {
// 诊所名字典:服务端派生(host.clinicNames)打底,宿主换票传的 dictionary.clinics 覆盖优先。
// 这样前端显示诊所名不再依赖每次登录都传全 —— 服务端补齐 GUID→中文名,登录传的仅作覆盖。
const serverClinicNames = await this.auth.getClinicNames(user.hostId);
......
......@@ -5,6 +5,11 @@ import { ActivityAction, ActivitySurface } from '@pac/types';
import { ActivityService } from '../activity/activity.service';
import { JVS_DW_PRESETS } from './jvs-dw-preset';
import {
AuthTokenRequestSchema,
type AuthTokenRequest,
type AuthTokenResponse,
type ApplicationTokenRequest,
type ApplicationTokenResponse,
ApiCode,
ROLE_PERMISSIONS,
type AccessTokenPayload,
......@@ -20,7 +25,7 @@ import {
type UserRole,
} from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { randomCode, verifySecret } from '@pac/utils';
import { randomCode, verifySecret, sha256 } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { AlertService } from '../../common/alerting/alert.service';
......@@ -29,6 +34,8 @@ import { loadMockUsers } from './mock-users';
import { OrgTreeService } from './org-tree';
import { checkHostAuthPolicy, loadHostAuthPolicy } from './host-auth-policy';
import { APPLICATION_TOKEN_AUDIENCE, APPLICATION_TOKEN_TTL_SECONDS, APPLICATION_FACTS_PERMISSION, applicationTokenSecret } from './application-token';
const CODE_PREFIX = 'pac:exchange-code:';
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
/// TTL 内可重复换、返回同一票(宿主重载/双次加载都不误报「登录已过期」),到点自然失效。
......@@ -137,7 +144,14 @@ export class AuthService {
return (host?.clinicNames as Record<string, string>) ?? {};
}
async exchangeToken(req: TokenExchangeRequest): Promise<TokenExchangeResponse> {
async exchangeToken(req: TokenExchangeRequest): Promise<TokenExchangeResponse>;
async exchangeToken(req: ApplicationTokenRequest): Promise<ApplicationTokenResponse>;
async exchangeToken(req: AuthTokenRequest): Promise<AuthTokenResponse>;
async exchangeToken(input: AuthTokenRequest): Promise<AuthTokenResponse> {
// 服务层也守住分支边界,避免非 HTTP 调用把无效 user 当作应用请求。
const parsed = AuthTokenRequestSchema.safeParse(input);
if (!parsed.success) throw new BizError(ApiCode.CLIENT_VALIDATION_FAILED, '换票参数不合法');
const req = parsed.data;
const host = await this.prisma.host.findUnique({ where: { appId: req.appId } });
if (!host || !host.active) {
throw new BizError(ApiCode.AUTH_INVALID_CREDENTIALS);
......@@ -146,6 +160,22 @@ export class AuthService {
throw new BizError(ApiCode.AUTH_INVALID_CREDENTIALS);
}
if (!('user' in req)) {
if (!host.openFactsReadEnabled) {
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '未开通应用事实查询权限');
}
const accessToken = await this.jwt.signAsync({
tokenUse: 'application', sub: req.appId, hostId: host.id,
permissions: [APPLICATION_FACTS_PERMISSION],
credentialVersion: sha256(host.appSecretHash),
}, {
secret: applicationTokenSecret(this.config.getOrThrow<string>('jwt.secret')),
algorithm: 'HS256', audience: APPLICATION_TOKEN_AUDIENCE,
expiresIn: APPLICATION_TOKEN_TTL_SECONDS,
});
return { accessToken, tokenType: 'Bearer', expiresIn: APPLICATION_TOKEN_TTL_SECONDS };
}
this.assertHostAuthPolicy(host.name, req.user);
// 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。
......
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import {
AuthTokenResponseSchema,
ExchangeCodeRequestSchema,
ExchangeCodeResponseSchema,
MockLoginRequestSchema,
......@@ -13,6 +14,11 @@ import {
TokenExchangeResponseSchema,
} from '@pac/types';
export class AuthTokenRequestDto extends createZodDto(TokenExchangeRequestSchema.partial({ user: true })) {}
export const AuthTokenResponseDto = createZodDto(AuthTokenResponseSchema);
// 联合响应使用构造器而非继承类;显式命名,避免 OpenAPI 出现通用 AugmentedZodDto。
Object.defineProperty(AuthTokenResponseDto, 'name', { value: 'AuthTokenResponseDto' });
export class TokenExchangeRequestDto extends createZodDto(TokenExchangeRequestSchema) {}
export class TokenExchangeResponseDto extends createZodDto(TokenExchangeResponseSchema) {}
export class ExchangeCodeRequestDto extends createZodDto(ExchangeCodeRequestSchema) {}
......
......@@ -15,7 +15,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
validate(payload: AccessTokenPayload): AccessTokenPayload {
if (!payload.sub || !payload.hostId || !payload.tenantId) {
// 应用令牌仅供开放接口使用;旧用户令牌未声明 tokenUse,继续兼容。
const tokenUse = (payload as AccessTokenPayload & { tokenUse?: string }).tokenUse;
if ((tokenUse !== undefined && tokenUse !== 'user') || !payload.sub || !payload.hostId || !payload.tenantId) {
throw new UnauthorizedException('Invalid token payload');
}
return payload;
......
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { sha256 } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import {
ApplicationTokenPayloadSchema,
APPLICATION_TOKEN_AUDIENCE,
APPLICATION_FACTS_PERMISSION,
applicationTokenSecret,
} from '../auth/application-token';
export interface ApplicationPrincipal {
hostId: string;
appId: string;
clinicNames: Record<string, string>;
}
export type ApplicationRequest = Request & { application: ApplicationPrincipal };
@Injectable()
export class ApplicationTokenGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<ApplicationRequest>();
const match = /^Bearer ([^\s]+)$/i.exec(req.headers.authorization ?? '');
if (!match?.[1]) throw new UnauthorizedException('缺少应用访问令牌');
let payload;
try {
payload = ApplicationTokenPayloadSchema.parse(
await this.jwt.verifyAsync(match[1], {
secret: applicationTokenSecret(this.config.getOrThrow<string>('jwt.secret')),
audience: APPLICATION_TOKEN_AUDIENCE,
algorithms: ['HS256'],
}),
);
} catch {
throw new UnauthorizedException('应用访问令牌无效或已过期');
}
const host = await this.prisma.host.findUnique({
where: { id: payload.hostId },
select: {
id: true,
appId: true,
appSecretHash: true,
active: true,
openFactsReadEnabled: true,
clinicNames: true,
},
});
if (
!host?.active ||
host.appId !== payload.sub ||
sha256(host.appSecretHash) !== payload.credentialVersion
) {
throw new UnauthorizedException('应用访问令牌已失效');
}
if (!host.openFactsReadEnabled || !payload.permissions.includes(APPLICATION_FACTS_PERMISSION)) {
throw new ForbiddenException('未开通应用事实查询权限');
}
// 使用独立上下文,⛔ 不冒充用户或交给用户组织范围拦截器。
req.application = {
hostId: host.id,
appId: host.appId,
clinicNames: (host.clinicNames ?? {}) as Record<string, string>,
};
return true;
}
}
import { ApplicationTokenGuard } from './application-token.guard';
import { PatientFactsLookupController } from './patient-facts-lookup.controller';
import { PatientFactsLookupService } from './patient-facts-lookup.service';
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PatientModule } from '../patient/patient.module';
......@@ -19,7 +22,7 @@ import { OpenFactWindowService } from './open-fact-window.service';
*/
@Module({
imports: [AuthModule, PatientModule],
controllers: [OpenPatientsController, OpenOrgController, OpenIdentityController],
providers: [HmacVerifier, OpenProfileService, OpenAppointmentService, OpenFactGate, OpenFactWindowService,],
controllers: [PatientFactsLookupController, OpenPatientsController, OpenOrgController, OpenIdentityController],
providers: [ApplicationTokenGuard, PatientFactsLookupService, HmacVerifier, OpenProfileService, OpenAppointmentService, OpenFactGate, OpenFactWindowService,],
})
export class OpenModule {}
import { Body, Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Public } from '../../common/decorators/public.decorator';
import { ApplicationTokenGuard, type ApplicationRequest } from './application-token.guard';
import {
PatientFactsLookupRequestDto,
PatientFactsLookupResponseDto,
} from './patient-facts-lookup.schema';
import { PatientFactsLookupService } from './patient-facts-lookup.service';
@ApiTags('open')
@ApiBearerAuth('accessToken')
@Controller('open/patient-facts')
export class PatientFactsLookupController {
constructor(private readonly facts: PatientFactsLookupService) {}
@Post('lookup')
@HttpCode(200)
@ZodResponse({ status: 200, type: PatientFactsLookupResponseDto })
@Public()
@UseGuards(ApplicationTokenGuard)
@ApiOperation({
summary: '查询患者资料与事实',
description: [
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。',
'返回患者数组:`patient`(资料)、`persona`(画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。',
'[调用说明与示例](/docs/integration/patient-facts-lookup) · [事实 content 字段](/docs/integration/patient-fact-content)',
].join('\n\n'),
})
lookup(@Req() req: ApplicationRequest, @Body() body: PatientFactsLookupRequestDto) {
return this.facts.lookup(req.application, body);
}
}
import { z } from 'zod';
import { createZodDto } from 'nestjs-zod';
export const PatientFactsLookupSchema = z
.strictObject({
medicalRecordNumber: z.string().trim().min(1).max(128).optional(),
name: z.string().trim().min(1).max(128).optional(),
phone: z.string().trim().min(1).max(64).optional(),
})
.refine((v) => Boolean(v.medicalRecordNumber || v.name || v.phone), {
message: '病历号、姓名、手机号至少提供一个;多条件取交集,均精确匹配',
});
export type PatientFactsLookupRequest = z.infer<typeof PatientFactsLookupSchema>;
export class PatientFactsLookupRequestDto extends createZodDto(PatientFactsLookupSchema) {}
const PatientBasicSchema = z.object({
id: z.string().uuid().describe('PAC 患者 ID;用于区分患者及关联 PAC 结果'),
externalId: z
.string()
.describe('宿主患者 ID;须结合宿主自身租户/品牌上下文,不保证宿主内全局唯一'),
name: z.string().nullable(),
medicalRecordNumber: z.string().nullable(),
phone: z.string().nullable().describe('脱敏手机号'),
gender: z.string().nullable(),
birthDate: z.string().nullable().describe('YYYY-MM-DD 业务日期'),
});
export const PatientFactsLookupResponseSchema = z.object({
items: z.array(
z.object({
patient: PatientBasicSchema.extend({
preferences: z.unknown(),
lastVisitAt: z.string().nullable().describe('最近到诊业务日期 YYYY-MM-DD'),
lastVisitDoctor: z.string().nullable(),
preferredDoctor: z.string().nullable(),
doNotContact: z.boolean().nullable().describe('无业务属性记录时为 null,不能当成 false'),
doNotContactReason: z.string().nullable(),
deceased: z.boolean().nullable(),
hostFollowUpActive: z.boolean().nullable(),
tags: z.array(z.string()).nullable().describe('null=无业务属性记录;[]=已存标签为空'),
acquisitionChannel: z.string().nullable(),
acquisitionSub: z.string().nullable(),
}),
persona: z
.object({
version: z.number().int(),
computedAt: z.string(),
refreshedAt: z.string().nullable(),
features: z.array(
z.object({
key: z.string(),
description: z.string(),
score: z.number().nullable(),
data: z.unknown(),
}),
),
})
.nullable()
.describe('最近已保存的当前画像,不触发重算;未形成时为 null'),
relations: z.array(
z.object({
relationship: z.string().describe('对方是本人的什么关系,按 PAC 已存方向返回'),
patient: PatientBasicSchema.nullable().describe(
'关系人未解析、归档或越界时为 null;不展开其事实和画像',
),
}),
),
facts: z.array(
z.object({
subjectId: z
.string()
.describe('来源事实主体标识,跨版本稳定;仅作溯源,不能单独当全局唯一键'),
type: z
.string()
.describe('决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型'),
kind: z.string().describe('actual=实际记录;planned=计划或建议'),
status: z
.string()
.describe(
'事实生命周期:本接口仅 active/fulfilled;业务进度看对应 content 字段,不代表疾病是否治愈',
),
clinicId: z.string().nullable(),
clinicName: z.string().nullable(),
occurredAt: z.string().nullable().describe('实际发生时刻,UTC ISO 8601;未知为 null'),
plannedFor: z
.string()
.nullable()
.describe('计划或建议的时间锚,非完成证明;UTC ISO 8601'),
validUntil: z
.string()
.nullable()
.describe('有效期截止时刻;null=未提供,不能推断永久有效'),
title: z.string().nullable(),
summary: z.string().nullable(),
content: z.unknown().describe('按事实类型定义的标准化内容,已脱敏'),
steps: z
.array(z.string())
.describe('PAC 标准治疗步骤,仅 treatment_record 有值;其他类型 [],允许新增步骤'),
}),
),
}),
),
});
export type PatientFactsLookupItem = z.infer<
typeof PatientFactsLookupResponseSchema
>['items'][number];
export class PatientFactsLookupResponseDto extends createZodDto(PatientFactsLookupResponseSchema) {}
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import { toOpenFact, type OpenFactRow } from './open-fact';
import { redactText, redactJson } from './open-redact';
import {
PatientFactsLookupSchema,
type PatientFactsLookupRequest,
type PatientFactsLookupItem,
} from './patient-facts-lookup.schema';
import type { ApplicationPrincipal } from './application-token.guard';
type LookupRow = OpenFactRow & {
patient_id: string;
fact_id: string | null;
};
type LookupItem = PatientFactsLookupItem;
const basicSelect = {
id: true,
externalId: true,
name: true,
medicalRecordNumber: true,
phone: true,
gender: true,
birthDate: true,
} as const;
function basicPatient(p: {
id: string;
externalId: string;
name: string | null;
medicalRecordNumber: string | null;
phone: string | null;
gender: string | null;
birthDate: Date | null;
}) {
return {
id: p.id,
externalId: p.externalId,
name: redactText(p.name),
medicalRecordNumber: p.medicalRecordNumber,
phone: p.phone
? p.phone.length > 7
? `${p.phone.slice(0, 3)}****${p.phone.slice(-4)}`
: '****'
: null,
gender: redactText(p.gender),
birthDate: p.birthDate?.toISOString().slice(0, 10) ?? null,
};
}
@Injectable()
export class PatientFactsLookupService {
private readonly logger = new Logger(PatientFactsLookupService.name);
constructor(private readonly prisma: PrismaService) {}
async lookup(
application: ApplicationPrincipal,
input: PatientFactsLookupRequest,
): Promise<{ items: LookupItem[] }> {
const startedAt = Date.now();
const q = PatientFactsLookupSchema.parse(input);
// 多表读取使用同一可重复读快照;⛔ 不在查询接口触发画像重算。
const items = await this.prisma.$transaction(
async (tx) => {
// 不设 LIMIT,不静默截断;以 PAC 患者主键分组,⛔ 不按姓名/电话/病历号合并不同人。
const rows = await tx.$queryRaw<LookupRow[]>(Prisma.sql`
SELECT p.id AS patient_id,
f.id AS fact_id, f.subject_id, f.type, f.kind, f.status, f.clinic_id,
f.occurred_at, f.planned_for, f.valid_until, f.title, f.summary, f.content
FROM patients p
LEFT JOIN patient_facts f ON f.patient_id = p.id
AND f.host_id = p.host_id AND f.tenant_id = p.tenant_id AND f.source_unit = p.source_unit
AND ${currentFactHeadSql('f')}
AND f.status IN ('active', 'fulfilled')
WHERE p.host_id = ${application.hostId}::uuid AND p.active = true
${q.medicalRecordNumber ? Prisma.sql`AND p.medical_record_number = ${q.medicalRecordNumber}` : Prisma.empty}
${q.name ? Prisma.sql`AND p.name = ${q.name}` : Prisma.empty}
${q.phone ? Prisma.sql`AND p.phone = ${q.phone}` : Prisma.empty}
ORDER BY p.id, COALESCE(f.occurred_at, f.planned_for) DESC NULLS LAST, f.id DESC
`);
if (!rows.length) return new Map<string, LookupItem>();
const ids = [...new Set(rows.map((r) => r.patient_id))];
// 一次批量加载,避免按患者循环发 SQL;所有关联对象仍检查宿主与患者身份。
const patients = await tx.patient.findMany({
where: { id: { in: ids }, hostId: application.hostId, active: true },
orderBy: { id: 'asc' },
select: {
hostId: true,
tenantId: true,
sourceUnit: true,
...basicSelect,
preferences: true,
profile: {
select: {
lastVisitAt: true,
lastVisitDoctor: true,
preferredDoctor: true,
doNotContact: true,
doNotContactReason: true,
deceased: true,
hostFollowUpActive: true,
tags: true,
acquisitionChannel: true,
acquisitionSub: true,
},
},
personas: {
where: { hostId: application.hostId, supersededAt: null },
orderBy: [{ version: 'desc' }, { id: 'desc' }],
select: {
tenantId: true,
version: true,
computedAt: true,
refreshedAt: true,
features: {
orderBy: { key: 'asc' },
select: { key: true, description: true, score: true, data: true },
},
},
},
relationsOut: {
where: { hostId: application.hostId },
orderBy: [{ relationship: 'asc' }, { id: 'asc' }],
select: {
tenantId: true,
relationship: true,
relatedPatient: {
select: {
hostId: true,
tenantId: true,
sourceUnit: true,
active: true,
...basicSelect,
},
},
},
},
},
});
const items = new Map<string, LookupItem>();
for (const p of patients) {
const persona = p.personas.find((v) => v.tenantId === p.tenantId);
items.set(p.id, {
patient: {
...basicPatient(p),
preferences: redactJson(p.preferences),
lastVisitAt: p.profile?.lastVisitAt?.toISOString().slice(0, 10) ?? null,
lastVisitDoctor: redactText(p.profile?.lastVisitDoctor),
preferredDoctor: redactText(p.profile?.preferredDoctor),
doNotContact: p.profile?.doNotContact ?? null,
doNotContactReason: redactText(p.profile?.doNotContactReason),
deceased: p.profile?.deceased ?? null,
hostFollowUpActive: p.profile?.hostFollowUpActive ?? null,
tags: p.profile ? p.profile.tags.map((t) => redactText(t) ?? '') : null,
acquisitionChannel: redactText(p.profile?.acquisitionChannel),
acquisitionSub: redactText(p.profile?.acquisitionSub),
},
persona: persona
? {
version: persona.version,
computedAt: persona.computedAt.toISOString(),
refreshedAt: persona.refreshedAt?.toISOString() ?? null,
features: persona.features.map((f) => ({
key: f.key,
description: redactText(f.description) ?? '',
score: f.score,
data: redactJson(f.data),
})),
}
: null,
relations: p.relationsOut
.filter((rel) => rel.tenantId === p.tenantId)
.map((rel) => {
const target = rel.relatedPatient;
// 关系晚绑定只在同一患者身份命名空间解析;⛔ 不猜反向关系或展开关系人的病历。
const visible =
target?.active &&
target.hostId === p.hostId &&
target.tenantId === p.tenantId &&
target.sourceUnit === p.sourceUnit;
return {
relationship: redactText(rel.relationship) ?? '',
patient: visible ? basicPatient(target) : null,
};
}),
facts: [],
});
}
for (const row of rows) {
const item = items.get(row.patient_id);
if (!item) continue;
if (row.fact_id !== null)
item.facts.push({
...toOpenFact(row),
clinicName: row.clinic_id ? (application.clinicNames[row.clinic_id] ?? null) : null,
});
}
return items;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 15000 },
);
// 审计仅记录调用应用、条件字段和数量,⛔ 不记录查询值、令牌或患者正文。
this.logger.log(
JSON.stringify({
event: 'open_patient_facts_lookup',
hostId: application.hostId,
appId: application.appId,
queryFields: Object.keys(q),
patientCount: items.size,
factCount: [...items.values()].reduce((sum, item) => sum + item.facts.length, 0),
durationMs: Date.now() - startedAt,
}),
);
return { items: [...items.values()] };
}
}
......@@ -23,7 +23,8 @@ export const GLOBAL_PREFIX_EXCLUDE = [
/** tag → 中文标签 + 说明(文档站侧栏分组 / Scalar UI 用)。未列出的 tag 原样保留。 */
const TAG_DESCRIPTIONS: Record<string, string> = {
auth: '登录换票、会话、数据权限(orgScope)。',
auth: '应用令牌、用户登录换票、会话及数据权限(orgScope)。',
open: '宿主应用患者查询(Bearer)与 FridayAI 开放接口(HMAC);以各端点说明为准。',
facts: '患者事实写入(事件账本)与查询。',
patient: '患者主档、检索、时间线。',
persona: '患者画像与特征。',
......
import { applicationTokenSecret } from '../src/modules/auth/application-token';
import { JwtService } from '@nestjs/jwt';
import { hashSecret } from '@pac/utils';
import { AuthTokenRequestSchema, ApiCode, UserRole } from '@pac/types';
import { AuthService } from '../src/modules/auth/auth.service';
import { ApplicationTokenGuard } from '../src/modules/open/application-token.guard';
import { JwtStrategy } from '../src/modules/auth/jwt.strategy';
const hostId = '00000000-0000-4000-8000-000000000001';
const secret = 'local-application-jwt-test-key';
const config = {
getOrThrow: (key: string) =>
key === 'jwt.secret' || key === 'jwt.refreshSecret'
? secret
: key === 'jwt.expiresIn'
? '1h'
: key === 'jwt.refreshExpiresIn'
? '7d'
: 60,
};
describe('application token alongside legacy login', () => {
let host: any,
prisma: any,
redis: any,
jwt: JwtService,
service: AuthService,
guard: ApplicationTokenGuard;
beforeEach(() => {
host = {
id: hostId,
name: 'mock',
active: true,
appId: 'test-app',
appSecretHash: hashSecret('test-secret'),
openFactsReadEnabled: true,
clinicNames: {},
};
prisma = {
host: {
findUnique: jest
.fn()
.mockImplementation(async ({ where }) =>
where.appId === host.appId || where.id === host.id ? host : null,
),
},
};
redis = { setEx: jest.fn(), get: jest.fn(), del: jest.fn() };
jwt = new JwtService();
service = new AuthService(
prisma,
redis,
jwt,
config as any,
{} as any,
{} as any,
{ trackServerSide: jest.fn().mockResolvedValue(null) } as any,
);
guard = new ApplicationTokenGuard(jwt, config as any, prisma);
});
const request = { appId: 'test-app', appSecret: 'test-secret' };
function context(token?: string) {
const req = { headers: { authorization: token ? `Bearer ${token}` : undefined } };
return { req, ctx: { switchToHttp: () => ({ getRequest: () => req }) } as any };
}
test.each([null, {}, { userId: 'u' }, '', false])(
'rejects provided invalid user %j without falling back',
async (user) => {
expect(AuthTokenRequestSchema.safeParse({ ...request, user }).success).toBe(false);
await expect(service.exchangeToken({ ...request, user } as any)).rejects.toMatchObject({
code: ApiCode.CLIENT_VALIDATION_FAILED,
});
},
);
it('rejects grantType and unknown request fields', () => {
expect(
AuthTokenRequestSchema.safeParse({ ...request, grantType: 'client_credentials' }).success,
).toBe(false);
});
it('signs a one-hour app-only token without code, refresh, or Redis writes', async () => {
const r = await service.exchangeToken(request);
expect(Object.keys(r).sort()).toEqual(['accessToken', 'expiresIn', 'tokenType']);
expect(r.expiresIn).toBe(3600);
expect(redis.setEx).not.toHaveBeenCalled();
const payload = jwt.verify(r.accessToken, {
secret: applicationTokenSecret(secret),
audience: 'pac:open',
});
expect(payload).toMatchObject({
tokenUse: 'application',
sub: 'test-app',
hostId,
permissions: ['patient-facts:read'],
});
expect(payload.tenantId).toBeUndefined();
expect(() => jwt.verify(r.accessToken, { secret })).toThrow();
const c = context(r.accessToken);
await expect(guard.canActivate(c.ctx)).resolves.toBe(true);
expect((c.req as any).application.hostId).toBe(hostId);
expect((c.req as any).user).toBeUndefined();
expect(() => new JwtStrategy(config as any).validate(payload)).toThrow();
});
it('retains original user login response and user JWT acceptance', async () => {
const r = await service.exchangeToken({
...request,
user: { userId: 'u', tenantId: 't', role: UserRole.LEADER, orgScope: ['clinic'] },
});
expect(r).toMatchObject({
accessToken: expect.any(String),
refreshToken: expect.any(String),
code: expect.any(String),
});
const payload = jwt.verify(r.accessToken, { secret });
expect(new JwtStrategy(config as any).validate(payload).sub).toBe('u');
await expect(guard.canActivate(context(r.accessToken).ctx)).rejects.toThrow('应用访问令牌无效');
});
it('requires explicitly enabled permission but does not alter valid legacy login', async () => {
host.openFactsReadEnabled = false;
await expect(service.exchangeToken(request)).rejects.toMatchObject({
code: ApiCode.AUTH_PERMISSION_DENIED,
});
await expect(
service.exchangeToken({
...request,
user: { userId: 'u', tenantId: 't', role: UserRole.LEADER, orgScope: [] },
}),
).resolves.toHaveProperty('code');
});
it('rejects wrong credentials and disabled hosts', async () => {
await expect(service.exchangeToken({ ...request, appSecret: 'wrong' })).rejects.toMatchObject({
code: ApiCode.AUTH_INVALID_CREDENTIALS,
});
host.active = false;
await expect(service.exchangeToken(request)).rejects.toMatchObject({
code: ApiCode.AUTH_INVALID_CREDENTIALS,
});
});
test.each(['permission', 'disabled', 'rotation'])(
'rejects already issued token after %s',
async (change) => {
const r = await service.exchangeToken(request);
if (change === 'permission') host.openFactsReadEnabled = false;
if (change === 'disabled') host.active = false;
if (change === 'rotation') host.appSecretHash = hashSecret('new-secret');
await expect(guard.canActivate(context(r.accessToken).ctx)).rejects.toThrow();
},
);
it('rejects missing, tampered, expired, and wrong-purpose tokens', async () => {
await expect(guard.canActivate(context().ctx)).rejects.toThrow();
const r = await service.exchangeToken(request);
await expect(guard.canActivate(context(r.accessToken + 'x').ctx)).rejects.toThrow();
const decoded: any = jwt.decode(r.accessToken);
delete decoded.iat;
delete decoded.exp;
const expired = jwt.sign(decoded, { secret: applicationTokenSecret(secret), expiresIn: -1 });
await expect(guard.canActivate(context(expired).ctx)).rejects.toThrow();
const wrongPurpose = jwt.sign(
{ ...decoded, tokenUse: 'user' },
{ secret: applicationTokenSecret(secret), expiresIn: 3600 },
);
await expect(guard.canActivate(context(wrongPurpose).ctx)).rejects.toThrow();
});
});
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { FactType } from '@pac/types';
import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas';
import { toOpenFact } from '../src/modules/open/open-fact';
import { redactJson } from '../src/modules/open/open-redact';
import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-facts-lookup.schema';
const text = readFileSync(
resolve(__dirname, '../../pac-docs/content/docs/integration/patient-fact-content.mdx'),
'utf8',
);
const examples = [...text.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => JSON.parse(m[1]!));
describe('public fact content reference', () => {
it('covers every registered fact type exactly once', () => {
expect(examples.map((e) => e.type).sort()).toEqual(Object.values(FactType).sort());
});
it.each(examples)(
'$type example passes canonical validation and open serialization',
({ type, content }) => {
const schema = FactContentSchemas[type as keyof typeof FactContentSchemas];
const stored = schema.parse(content);
const out = toOpenFact({
subject_id: `${type}:fixture`,
type,
kind: 'actual',
status: 'active',
clinic_id: null,
occurred_at: null,
planned_for: null,
valid_until: null,
title: null,
summary: null,
content: stored,
});
const factSchema = PatientFactsLookupResponseSchema.shape.items.element.shape.facts.element;
expect(factSchema.safeParse({ ...out, clinicName: null }).success).toBe(true);
// 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。
expect(redactJson(content)).toEqual(content);
expect(out.content).toMatchObject(content);
if (type !== 'treatment_record') expect(out.steps).toEqual([]);
},
);
it('documents every standard top-level content field except keys intentionally redacted', () => {
for (const [type, schema] of Object.entries(FactContentSchemas)) {
const section = text.split(`## \`${type}\``)[1]!.split('\n## ')[0]!;
const fields = [...section.matchAll(/\| `([^`]+)` \|/g)].flatMap((m) =>
m[1]!.split(' / ').map((f) => f.split(/[.[]/)[0]!),
);
for (const field of Object.keys(schema.shape)) {
if (['notes', 'staff_notes'].includes(field)) continue;
expect({ type, field, documented: fields.includes(field) }).toEqual({
type,
field,
documented: true,
});
}
}
});
it('keeps lifecycle state, business state and treatment kind distinct', () => {
const planned = toOpenFact({
subject_id: 'treatment:1',
type: 'treatment_record',
kind: 'planned',
status: 'active',
clinic_id: null,
occurred_at: null,
planned_for: new Date('2026-11-01T00:00:00Z'),
valid_until: null,
title: null,
summary: null,
content: { category: 'endodontic', status: 'planned', treat_stages: ['canal_filling'] },
});
expect(planned).toMatchObject({
kind: 'planned',
status: 'active',
content: { status: 'planned' },
steps: ['canal_filling'],
});
});
});
import { buildPacOpenApiDocument } from '../src/openapi/build-document';
import { randomUUID } from 'node:crypto';
import { Controller, Get } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaClient } from '@prisma/client';
import { hashSecret } from '@pac/utils';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { AuthController } from '../src/modules/auth/auth.controller';
import { AuthService } from '../src/modules/auth/auth.service';
import { JwtStrategy } from '../src/modules/auth/jwt.strategy';
import { OrgTreeService } from '../src/modules/auth/org-tree';
import { PatientFactsLookupController } from '../src/modules/open/patient-facts-lookup.controller';
import { PatientFactsLookupService } from '../src/modules/open/patient-facts-lookup.service';
import { ApplicationTokenGuard } from '../src/modules/open/application-token.guard';
import { OpenPatientsController } from '../src/modules/open/open-patients.controller';
import { OpenProfileService } from '../src/modules/open/open-profile.service';
import { OpenAppointmentService } from '../src/modules/open/open-appointment.service';
import { OpenFactWindowService } from '../src/modules/open/open-fact-window.service';
import { PatientService } from '../src/modules/patient/patient.service';
import { HmacVerifier } from '../src/modules/sync/push/hmac-verifier.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { JwtAuthGuard } from '../src/common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../src/common/guards/permissions.guard';
import { WrapResponseInterceptor } from '../src/common/interceptors/wrap-response.interceptor';
import { TenantScopeInterceptor } from '../src/common/interceptors/tenant-scope.interceptor';
import { AllExceptionsFilter } from '../src/common/filters/all-exceptions.filter';
@Controller('local-test')
class ProtectedProbe {
@Get() probe() {
return { ok: true };
}
}
// 只接受显式提供的隔离本地数据库;默认测试不会连接开发库或服务器。
const url = process.env.PAC_OPEN_FACTS_TEST_DATABASE_URL;
const local = url ? new URL(url) : null;
if (
local &&
(!['127.0.0.1', 'localhost'].includes(local.hostname) ||
!local.pathname.startsWith('/pac_open_facts_local_'))
)
throw new Error('Only isolated local fixture databases are allowed');
const suite = url ? describe : describe.skip;
suite('local HTTP + PostgreSQL patient facts acceptance', () => {
let prisma: PrismaClient, app: any, base: string, host: any, otherHost: any, token: string;
const hmacKey = 'local-hmac-read-fixture-key-000000';
const config = {
getOrThrow: (key: string) =>
key === 'jwt.secret'
? 'local-jwt-fixture-secret'
: key === 'jwt.refreshSecret'
? 'local-refresh-fixture-secret'
: key === 'jwt.expiresIn'
? '1h'
: key === 'jwt.refreshExpiresIn'
? '7d'
: 60,
};
const orgTree = {
expandScope: jest.fn().mockResolvedValue({ sourceUnits: ['brand-a'], clinicIds: [] }),
};
async function request(path: string, body?: unknown, accessToken?: string, headers = {}) {
const res = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: {
'content-type': 'application/json',
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return res.json() as Promise<any>;
}
beforeAll(async () => {
prisma = new PrismaClient({ datasourceUrl: url });
host = await prisma.host.create({
data: {
name: `mock-${randomUUID()}`,
appId: `app-${randomUUID()}`,
appSecretHash: hashSecret('local-secret'),
openFactsReadEnabled: true,
pushSecretHashes: [],
readSecrets: [hmacKey],
clinicNames: { clinic: '验收诊所' },
},
});
otherHost = await prisma.host.create({
data: {
name: `other-${randomUUID()}`,
appId: `app-${randomUUID()}`,
appSecretHash: hashSecret('local-other'),
pushSecretHashes: [],
},
});
const makePatient = (owner: string, sourceUnit: string, externalId: string, active = true) =>
prisma.patient.create({
data: {
hostId: owner,
tenantId: 'market',
sourceUnit,
externalId,
name: '验收同名',
phone: '13800138000',
medicalRecordNumber: '000123',
active,
},
});
const a = await makePatient(host.id, 'brand-a', 'a');
const b = await makePatient(host.id, 'brand-b', 'a');
await makePatient(host.id, 'brand-empty', '0000123');
const archived = await makePatient(host.id, 'brand-a', 'archived', false);
const outsider = await makePatient(otherHost.id, 'brand-a', 'outsider');
await prisma.patient.update({
where: { id: a.id },
data: {
gender: '女',
birthDate: new Date('2010-01-02T00:00:00Z'),
preferences: {
language: '中文',
contactWindow: '18:00-20:00',
mobile: '13900139000',
text: '联系13900139000',
},
},
});
await prisma.patientProfile.create({
data: {
patientId: a.id,
lastVisitAt: new Date('2026-10-01T00:00:00Z'),
doNotContact: true,
doNotContactReason: 'opt_out',
hostFollowUpActive: null,
tags: ['VIP', '联系13900139000'],
notes: '内部备注不可外发',
acquisitionChannel: 'word_of_mouth',
},
});
for (const [version, tenantId, supersededAt] of [
[1, 'market', new Date()],
[2, 'market', null],
[3, 'market', null],
[99, 'wrong-tenant', null],
] as const) {
await prisma.persona.create({
data: {
hostId: host.id,
tenantId,
patientId: a.id,
version,
source: 'local-fixture',
supersededAt,
computedAt: new Date('2026-10-18T01:00:00Z'),
refreshedAt: new Date('2026-10-19T02:00:00Z'),
features: {
create: {
key: 'fixture',
description: `版本${version} 联系13900139000`,
score: 0.75,
data: { text: '联系13900139000', phone: '13900139000' },
evidence: {},
},
},
},
});
}
await prisma.patientProfile.create({ data: { patientId: b.id } });
const mother = await prisma.patient.create({
data: {
hostId: host.id,
tenantId: 'market',
sourceUnit: 'brand-a',
externalId: 'mother',
name: '验收母亲',
medicalRecordNumber: '000456',
phone: '13900139000',
gender: '女',
birthDate: new Date('1980-12-31T00:00:00Z'),
},
});
for (const [relationship, target, tenantId] of [
['mother', mother, 'market'],
['father', null, 'market'],
['friend', outsider, 'market'],
['sibling', b, 'market'],
['child', archived, 'market'],
['other', mother, 'wrong-tenant'],
] as const) {
await prisma.patientRelation.create({
data: {
hostId: host.id,
tenantId,
patientId: a.id,
relationship,
relatedExternalId: target?.externalId ?? 'not-synced',
relatedPatientId: target?.id ?? null,
},
});
}
const add = (
p: any,
subjectId: string,
version: number,
status: string,
at: Date | null = new Date('2026-10-01T00:00:00Z'),
) =>
prisma.patientFact.create({
data: {
hostId: p.hostId,
tenantId: p.tenantId,
sourceUnit: p.sourceUnit,
patientId: p.id,
subjectId,
version,
status,
kind: 'actual',
type: 'emr_record',
clinicId: 'clinic',
occurredAt: at,
title: subjectId,
content: { illness_desc: '联系13900139000', staff_notes: '不应外发' },
transactionIds: [],
},
});
await prisma.patientFact.createMany({
data: Array.from({ length: 251 }, (_, i) => ({
hostId: a.hostId,
tenantId: a.tenantId,
sourceUnit: a.sourceUnit,
patientId: a.id,
subjectId: `bulk-${i}`,
version: 1,
status: 'fulfilled',
kind: 'actual',
type: i % 2 ? 'emr_record' : 'diagnosis_record',
occurredAt: new Date(Date.UTC(2025, 0, 1, 0, i)),
content: { text: 'fixture' },
transactionIds: [],
})),
});
await add(a, 'same-subject', 1, 'fulfilled');
await add(a, 'same-subject', 2, 'active', new Date('2026-10-02T00:00:00Z'));
await add(b, 'same-subject', 1, 'fulfilled'); // 品牌同 subject 不互相覆盖
await add(a, 'cancelled-head', 1, 'fulfilled');
await add(a, 'cancelled-head', 2, 'cancelled');
await add(a, 'moved-patient', 1, 'fulfilled');
await add({ ...b, sourceUnit: a.sourceUnit }, 'moved-patient', 2, 'cancelled'); // 新头已改患者,旧患者不能漏出旧版
await add(a, 'no-time', 1, 'active', null);
await add(outsider, 'outside', 1, 'active');
await add(archived, 'archived', 1, 'active');
const redis: any = { setEx: jest.fn(), get: jest.fn(), del: jest.fn() };
const jwt = new JwtService();
const auth = new AuthService(
prisma as any,
redis,
jwt,
config as any,
{} as any,
orgTree as any,
{ trackServerSide: jest.fn().mockResolvedValue(null) } as any,
);
const module = await Test.createTestingModule({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
controllers: [
AuthController,
PatientFactsLookupController,
OpenPatientsController,
ProtectedProbe,
],
providers: [
JwtStrategy,
ApplicationTokenGuard,
PatientFactsLookupService,
HmacVerifier,
{ provide: AuthService, useValue: auth },
{ provide: JwtService, useValue: jwt },
{ provide: ConfigService, useValue: config },
{ provide: PrismaService, useValue: prisma },
{ provide: OrgTreeService, useValue: orgTree },
{
provide: PatientService,
useValue: { browse: jest.fn().mockResolvedValue({ items: [], nextCursor: null }) },
},
{ provide: OpenProfileService, useValue: {} },
{ provide: OpenAppointmentService, useValue: {} },
{ provide: OpenFactWindowService, useValue: {} },
],
}).compile();
app = module.createNestApplication({ logger: false });
app.setGlobalPrefix('pac/v1');
app.useGlobalPipes(new ZodValidationPipe());
const reflector = app.get(Reflector);
app.useGlobalGuards(new JwtAuthGuard(reflector), new PermissionsGuard(reflector));
app.useGlobalInterceptors(
new TenantScopeInterceptor(orgTree as any),
new WrapResponseInterceptor(),
new ZodSerializerInterceptor(reflector),
);
app.useGlobalFilters(new AllExceptionsFilter());
await app.listen(0, '127.0.0.1');
base = await app.getUrl();
const authResult = await request('/pac/v1/auth/token', {
appId: host.appId,
appSecret: 'local-secret',
});
expect(authResult.code).toBe(0);
expect(Object.keys(authResult.data).sort()).toEqual(['accessToken', 'expiresIn', 'tokenType']);
token = authResult.data.accessToken;
}, 30000);
afterAll(async () => {
if (app) await app.close();
if (prisma) {
const ids = [host?.id, otherHost?.id].filter(Boolean);
await prisma.patientRelation.deleteMany({ where: { hostId: { in: ids } } });
await prisma.persona.deleteMany({ where: { hostId: { in: ids } } });
await prisma.patientFact.deleteMany({ where: { hostId: { in: ids } } });
await prisma.patient.deleteMany({ where: { hostId: { in: ids } } });
await prisma.host.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
}
});
it('publishes both token response variants and the exact lookup contract in OpenAPI', () => {
const doc = buildPacOpenApiDocument(app);
const schema: any = doc.components?.schemas;
expect(schema.AuthTokenRequestDto.required).not.toContain('user');
const responseText = JSON.stringify(
Object.entries(schema).filter(([key]) => key.startsWith('AuthTokenResponseDto')),
);
expect(responseText).toContain('tokenType');
expect(responseText).toContain('refreshToken');
expect(doc.paths['/pac/v1/open/patient-facts/lookup']?.post).toBeDefined();
expect(schema.PatientFactsLookupRequestDto.additionalProperties).toBe(false);
expect(Object.keys(schema.PatientFactsLookupRequestDto.properties).sort()).toEqual([
'medicalRecordNumber',
'name',
'phone',
]);
expect(
JSON.stringify(
Object.entries(schema).filter(([key]) => key.startsWith('PatientFactsLookupResponseDto')),
),
).toContain('facts');
});
it('returns all current facts, separate matching patients, no other-host or archived patient', async () => {
const r = await request(
'/pac/v1/open/patient-facts/lookup',
{ medicalRecordNumber: '000123' },
token,
);
expect(r.code).toBe(0);
expect(r.data.items).toHaveLength(3);
expect(
r.data.items.map((i: any) => i.facts.length).sort((a: number, b: number) => a - b),
).toEqual([0, 1, 253]);
const facts = r.data.items.flatMap((i: any) => i.facts);
expect(facts.filter((f: any) => f.subjectId === 'same-subject')).toHaveLength(2);
expect(
facts.some((f: any) =>
['cancelled-head', 'outside', 'archived', 'moved-patient'].includes(f.subjectId),
),
).toBe(false);
const long = r.data.items.find((i: any) => i.facts.length > 200).facts;
expect(long[0].occurredAt).toBe('2026-10-02T00:00:00.000Z');
expect(long.at(-1).subjectId).toBe('no-time');
expect(JSON.stringify(r.data)).not.toContain('13900139000');
expect(JSON.stringify(r.data)).not.toContain('staff_notes');
expect(facts.find((f: any) => f.subjectId === 'same-subject').clinicName).toBe('验收诊所');
});
it('returns saved basic/profile/persona and directed relations with scope and redaction', async () => {
const r = await request(
'/pac/v1/open/patient-facts/lookup',
{ medicalRecordNumber: '000123' },
token,
);
expect(r.code).toBe(0);
const a = r.data.items.find((i: any) => i.facts.length > 200);
expect(a.patient).toMatchObject({
gender: '女',
birthDate: '2010-01-02',
preferences: { language: '中文', text: '联系139****9000' },
});
expect(a.patient).toMatchObject({
lastVisitAt: '2026-10-01',
doNotContact: true,
hostFollowUpActive: null,
acquisitionChannel: 'word_of_mouth',
});
expect(a.patient).not.toHaveProperty('notes');
expect(a.persona).toMatchObject({
version: 3,
computedAt: '2026-10-18T01:00:00.000Z',
refreshedAt: '2026-10-19T02:00:00.000Z',
features: [
{
key: 'fixture',
score: 0.75,
description: '版本3 联系139****9000',
data: { text: '联系139****9000' },
},
],
});
expect(a.relations).toHaveLength(5);
expect(a.relations.find((rel: any) => rel.relationship === 'mother').patient).toEqual({
id: expect.any(String),
externalId: 'mother',
name: '验收母亲',
medicalRecordNumber: '000456',
phone: '139****9000',
gender: '女',
birthDate: '1980-12-31',
});
for (const key of ['father', 'friend', 'sibling', 'child'])
expect(a.relations.find((rel: any) => rel.relationship === key).patient).toBeNull();
const known = r.data.items.find((i: any) => i.facts.length === 1);
expect(known.patient).toMatchObject({ doNotContact: false, deceased: false, tags: [] });
const empty = r.data.items.find((i: any) => i.facts.length === 0);
expect(empty.patient.externalId).toBe('0000123');
expect(empty).not.toHaveProperty('profile');
expect(empty.patient).toMatchObject({
doNotContact: null,
deceased: null,
tags: null,
lastVisitAt: null,
});
expect(a).not.toHaveProperty('profile');
expect(Object.keys(a).sort()).toEqual(['facts', 'patient', 'persona', 'relations']);
expect(empty.persona).toBeNull();
expect(empty.relations).toEqual([]);
const mother = await request(
'/pac/v1/open/patient-facts/lookup',
{ medicalRecordNumber: '000456' },
token,
);
expect(mother.data.items[0].relations).toEqual([]); // 不猜反向 child 关系
expect(JSON.stringify(r)).not.toContain('13900139000');
expect(JSON.stringify(r)).not.toContain('内部备注');
expect(a.patient).toMatchObject({ id: expect.any(String), externalId: 'a' });
expect(a.patient.id).not.toBe(known.patient.id);
expect(known.patient.externalId).toBe('a'); // 源 ID 在不同品牌可相同,PAC ID 不同
expect(a.relations.find((rel: any) => rel.relationship === 'mother').patient).toMatchObject({
id: mother.data.items[0].patient.id,
externalId: mother.data.items[0].patient.externalId,
});
const source = await prisma.patient.findUniqueOrThrow({ where: { id: a.patient.id } });
expect(source.hostId).toBe(host.id);
expect(source.externalId).toBe(a.patient.externalId);
expect(a.persona).not.toHaveProperty('tenantId');
});
it('exact matching and AND conditions, no fuzzy wildcard behavior', async () => {
for (const q of [
{ name: '验收%' },
{ medicalRecordNumber: '123' },
{ name: '验收同名', phone: '13800138001' },
]) {
expect((await request('/pac/v1/open/patient-facts/lookup', q, token)).data.items).toEqual([]);
}
const r = await request(
'/pac/v1/open/patient-facts/lookup',
{ name: '验收同名', phone: '13800138000' },
token,
);
expect(r.data.items).toHaveLength(3);
});
it('rejects broad/unknown selectors and absent auth', async () => {
for (const q of [
{},
{ name: '验收同名', include: ['facts'] },
{ name: '验收同名', hostId: otherHost.id },
]) {
expect((await request('/pac/v1/open/patient-facts/lookup', q, token)).code).not.toBe(0);
}
expect(
(await request('/pac/v1/open/patient-facts/lookup', { name: '验收同名' })).code,
).not.toBe(0);
});
it('keeps original user login and blocks app token from user routes', async () => {
const r = await request('/pac/v1/auth/token', {
appId: host.appId,
appSecret: 'local-secret',
user: { userId: 'u', tenantId: 'market', role: 'leader', orgScope: ['brand-a'] },
});
expect(r.code).toBe(0);
expect(r.data.code).toEqual(expect.any(String));
expect(r.data.refreshToken).toEqual(expect.any(String));
expect((await request('/pac/v1/local-test', undefined, r.data.accessToken)).code).toBe(0);
expect((await request('/pac/v1/local-test', undefined, token)).code).not.toBe(0);
expect(
(await request('/pac/v1/open/patient-facts/lookup', { name: '验收同名' }, r.data.accessToken))
.code,
).not.toBe(0);
for (const user of [null, {}, ''])
expect(
(
await request('/pac/v1/auth/token', {
appId: host.appId,
appSecret: 'local-secret',
user,
})
).code,
).not.toBe(0);
});
it('keeps the original FridayAI HMAC endpoint and does not accept an app token there', async () => {
const body = { host: host.name, tenantId: 'market', orgScope: ['brand-a'], q: '验收同名' };
const timestamp = Math.floor(Date.now() / 1000);
const r = await request('/pac/v1/open/patients/search', body, undefined, {
'x-pac-timestamp': String(timestamp),
'x-pac-signature': HmacVerifier.sign(hmacKey, timestamp, JSON.stringify(body)),
});
expect(r.code).toBe(0);
expect((await request('/pac/v1/open/patients/search', body, token)).code).not.toBe(0);
});
it('permission revocation takes effect on issued tokens, without changing HMAC grants', async () => {
await prisma.host.update({ where: { id: host.id }, data: { openFactsReadEnabled: false } });
expect(
(await request('/pac/v1/open/patient-facts/lookup', { name: '验收同名' }, token)).code,
).not.toBe(0);
expect(
(await request('/pac/v1/auth/token', { appId: host.appId, appSecret: 'local-secret' })).code,
).not.toBe(0);
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).readSecrets).toEqual([
hmacKey,
]);
});
});
import { PatientFactsLookupSchema } from '../src/modules/open/patient-facts-lookup.schema';
import { PatientFactsLookupService } from '../src/modules/open/patient-facts-lookup.service';
describe('patient facts lookup contract', () => {
test.each([
{},
{ name: '' },
{ phone: ' ' },
{ medicalRecordNumber: null },
{ name: 'x', type: 'emr' },
{ name: 'x', cursor: 'next' },
{ name: 'x', hostId: 'other' },
{ name: 'x', sourceUnit: 'a' },
{ name: 'x', include: [] },
])('rejects invalid or unsupported selectors %j', (input) => {
expect(PatientFactsLookupSchema.safeParse(input).success).toBe(false);
});
it('keeps leading zeroes and exact characters', () => {
expect(
PatientFactsLookupSchema.parse({
medicalRecordNumber: ' 00123 ',
name: '张%三',
phone: '13800138000',
}),
).toEqual({ medicalRecordNumber: '00123', name: '张%三', phone: '13800138000' });
});
it('does not merge separate patients, does not cap facts, and redacts content', async () => {
const rows = Array.from({ length: 251 }, (_, i) => ({
patient_id: 'p1',
patient_name: '同名',
medical_record_number: '001',
phone: '13800138000',
fact_id: `${i}`,
subject_id: `s${i}`,
type: 'emr_record',
kind: 'actual',
status: 'active',
clinic_id: 'c',
occurred_at: new Date('2026-10-19T00:00:00Z'),
planned_for: null,
valid_until: null,
title: '病历',
summary: '联系13900139000',
content: { illness_desc: '电话13900139000', staff_notes: '内部' },
}));
const prisma: any = {
$queryRaw: jest
.fn()
.mockResolvedValue([...rows, { ...rows[0], patient_id: 'p2', fact_id: null }]),
};
prisma.$transaction = jest.fn((run) => run(prisma));
prisma.patient = {
findMany: jest.fn().mockResolvedValue(
['p1', 'p2'].map((id) => ({
id,
externalId: 'shared-source-id',
name: '同名',
medicalRecordNumber: '001',
phone: '13800138000',
gender: null,
birthDate: null,
preferences: null,
profile: null,
personas: [],
relationsOut: [],
})),
),
};
const result = await new PatientFactsLookupService(prisma).lookup(
{ hostId: 'h', appId: 'a', clinicNames: { c: '测试诊所' } },
{ name: '同名', phone: '13800138000' },
);
expect(result.items).toHaveLength(2);
expect(result.items.map((i) => i.patient.id)).toEqual(['p1', 'p2']);
expect(result.items.map((i) => i.patient.externalId)).toEqual([
'shared-source-id',
'shared-source-id',
]);
expect(result.items[0]!.facts).toHaveLength(251);
expect(result.items[1]!.facts).toEqual([]);
expect(result.items[0]!.patient.phone).toBe('138****8000');
expect(result.items[0]!.facts[0]).toMatchObject({
clinicName: '测试诊所',
summary: '联系139****9000',
content: { illness_desc: '电话139****9000' },
});
expect(JSON.stringify(result)).not.toContain('staff_notes');
const sql = prisma.$queryRaw.mock.calls[0][0];
expect(sql.sql).not.toMatch(/LIMIT|ILIKE/);
expect(sql.sql).toContain('AND p.name =');
expect(sql.sql).toContain('AND p.phone =');
expect(sql.values).toContain('h');
});
it('returns empty items for no match', async () => {
const prisma: any = { $queryRaw: jest.fn().mockResolvedValue([]) };
prisma.$transaction = jest.fn((run) => run(prisma));
const service = new PatientFactsLookupService(prisma);
await expect(
service.lookup({ hostId: 'h', appId: 'a', clinicNames: {} }, { name: 'nobody' }),
).resolves.toEqual({ items: [] });
});
});
# 应用鉴权与患者查询:本地验收
日期:2026-10-19。初始基于 `main`,发布前已同步至 `b7901c9`,分支 `codex/open-patient-facts`,独立工作区 `/Users/luoqi/.codex/worktrees/open-patient-facts/pac`。
## 最终契约
- `POST /pac/v1/auth/token`:不传 `user` 获取应用令牌,传完整 `user` 保持原登录行为;不用 `grantType`。显式空、null 或不完整 user 报参数错误,不退回应用模式。
- `POST /pac/v1/open/patient-facts/lookup`:病历号、姓名、手机号至少一项,精确匹配,多条件取交集;不分页、不按类型筛选、不静默截断。
- 每位患者返回 `patient / persona / relations / facts`。原 profile 业务属性直接合入 patient,不再暴露独立 profile。
- 无业务属性记录时,patient 中对应字段为 null,包含 doNotContact、deceased、tags;已有记录的 false、空数组保持原值。
- persona 为已保存的当前最高有效版本,包含生成与刷新时间,不触发重算;relations 按本人视角返回已存关系,不猜反向关系、不递归展开家属事实。
- facts 保留必要的时间、来源、展示和内容字段。type 决定 content 结构,kind 区分实际/计划,外层 status 是生命周期,content 中的业务状态按各类型解释。
- 应用权限默认关闭;凭据确定宿主范围,授权为该宿主全部有效患者。应用令牌用途、签名密钥和用户令牌隔离,撤权/停用/密钥轮换对旧应用令牌生效。
- 继续沿用标准事实投影、当前版本判定、出站脱敏、原登录与 FridayAI HMAC 授权;不改摄入、画像计算或召回规则。
文档:
- [接口契约](../../apps/pac-docs/content/docs/integration/patient-facts-lookup.mdx)
- [17 类事实 content 字段及示例](../../apps/pac-docs/content/docs/integration/patient-fact-content.mdx)
## 本次验证
| 项目 | 结果 |
| --- | --- |
| 后端针对性回归 | 9 套、128 项通过 |
| service 源码及测试类型检查 | 通过 |
| 本次修改源码 ESLint | 通过,无错误或警告 |
| 两份接口文档 MDX 编译 | 通过 |
| 文档示例校验 | 17 类完整覆盖;示例通过内容 schema、出站投影与脱敏校验;标准顶层字段有对应说明 |
| OpenAPI 与真实 HTTP | 原用户/应用令牌响应兼容;patient 合并字段通过响应序列化,顶层不再有 profile |
| git diff --check | 通过 |
真实 PostgreSQL + Nest HTTP 联测覆盖:251 条批量事实不截断、当前版本筛选、取消新版本抑制旧版本、事实改归患者、跨宿主隔离、同名不同品牌分组、空事实患者、归档患者排除、前导零、通配符按字面匹配、多条件交集、时间排序与脱敏。新增患者字段覆盖日期不漂移、null/false/空数组区分、当前画像版本与错误租户版本隔离、关系方向、未解析/跨范围/归档关系人。
鉴权覆盖原用户登录、FridayAI HMAC、应用/用户令牌隔离、无授权、撤权及无效令牌。多表读取采用可重复读事务;关联信息批量查询,不逐人发查询。
本轮测试名称:`open-patient-facts-local`、`application-token`、`patient-facts-lookup`、`open-fact-content-doc`、`host-auth-exchange`、`host-auth-policy`、`hmac-read-vs-push-secret`、`open-current-facts`、`open-redact`。
前序实现已在独立库事务内验证迁移:新增授权列默认 false,两个患者精确查询索引创建成功;本轮未新增迁移。
## 隔离与复跑
本次在已有本机 PostgreSQL 容器中创建专用库 `pac_open_facts_local_20261019`,仅使用构造数据,结束后删除。没有访问测试/生产服务器、修改主工作区文件或重启本地业务服务。
真实库测试默认跳过;只有显式设置 `PAC_OPEN_FACTS_TEST_DATABASE_URL`,且目标为本机 `pac_open_facts_local_*` 独立库才运行。复跑先在专用库准备本分支 Prisma schema,再执行对应测试;不要指向开发业务库或远端数据库。
代码、迁移、文档均留在独立工作区,未提交、合并或部署。正式启用需先执行迁移并显式授权宿主,本次没有开通实际宿主。
## 发现与边界
- 本接口全量指已保存的当前有效事实,不含完整历史版本;无记录不证明源端无数据,也不是删除事件通知。
- 无分页遵循本次契约,尚未进行生产规模的大响应压测。
- 应用拥有宿主全范围读取能力,实际使用者权限仍由宿主后端检查,应用令牌不得交给浏览器。
- 当前 `visit_registration`、`visit_reception`、`order`、`recharge` 解析器与写入内容 schema 字段不一致:分别为 visit_registration_external_id/registration_external_id、visit_reception_external_id/reception_external_id、total_amount/amount_cents(另有 items 结构)、amount/amount_cents。文档区分标准字段与兼容输出,本次未改摄入链路;标准示例通过校验不代表这些解析器已对齐或各宿主已摄入成功。
- 返回内容沿用已有脱敏,画像是保存版本,不实时重算,不返回召回结果。
## 同日补充:患者标识返回
`patient` 和 `relations[].patient` 均增加 `id`(PAC 患者 UUID)、`externalId`(宿主患者 ID,原样字符串)。查询参数保持不变;没有增加按 ID 查询。关系人未解析、归档或越界时整个 patient 仍为 null,不泄露标识。
本轮针对性回归 3 套、40 项通过(真实本地 PostgreSQL + HTTP、查询单测、17 类内容文档校验),service 源码/测试类型检查、修改源码 ESLint、接口文档 MDX 编译通过。真实库测试确认:同源 ID 跨品牌的患者拥有不同 PAC ID、返回 ID 与数据库身份一致、关系人返回 ID 与直接查询一致、externalId 前导零保留、越界关系人仍为 null。
专用临时库测试后删除。仍未提交、未合并、未部署,主目录与本地业务服务未动。
## 文档站 API 参考同步
已通过 `pnpm docs:openapi`(Nest preview 模式,不连接数据库/Redis)重新导出文档站的 `openapi/pac.json`,共 90 条路径。新查询生成于 `/docs/api/open/PatientFactsLookupController_lookup`,合并鉴权位于 `/docs/api/auth/AuthController_exchangeToken`。接口说明与详细契约、17 类 content 参考互相链接。
用 Fumadocs 实际 `staticSource` 验证两个虚拟页面均已生成;文档站 `type-check`(含 MDX 生成、Next 路由类型生成及 tsc)通过。生成文件同步带入 main 中已有 push dryRun 说明的最新文字,未修改其行为。未启动/替换主目录文档服务,未部署。
## 提测前复核(2026-10-19)
- 复核应用令牌隔离、宿主范围、患者/事实版本归属、关系越界过滤与脱敏;未发现阻塞提测问题。
- 本轮重新执行:后端源码/测试类型检查通过;8 套单测 120 项 + 独立本地 PostgreSQL/HTTP 8 项,共 128 项通过。临时数据库已删除。
- 文档站类型检查、MDX 与 OpenAPI 生成通过;17 类 content 示例完整保留,重复说明移除,长示例折叠。
- 修改涉及文件 ESLint 无错误;host-admin/hosts.service 原有未使用禁用声明与变量警告 27 项,未做无关清理。
- 测试机发布前健康,磁盘剩余 109G,无 running 摄入任务、无数据库长查询。
- 本次授权只合入 test 并部署测试环境,main 与生产保持原状。真实 FridaySaaS 应用联调与部署结果待后续验收记录。
......@@ -72,6 +72,21 @@ export const TokenExchangeResponseSchema = z.object({
});
export type TokenExchangeResponse = z.infer<typeof TokenExchangeResponseSchema>;
/** 同一个换票入口:无 user 为应用调用;有 user 仍按原登录契约校验。 */
export const ApplicationTokenRequestSchema = TokenExchangeRequestSchema.omit({ user: true });
export type ApplicationTokenRequest = z.infer<typeof ApplicationTokenRequestSchema>;
export const ApplicationTokenResponseSchema = z.strictObject({
accessToken: z.string(),
tokenType: z.literal('Bearer'),
expiresIn: z.number().int().positive(),
});
export type ApplicationTokenResponse = z.infer<typeof ApplicationTokenResponseSchema>;
// 两支均严格校验:user:null / 不完整 user / grantType 不会降级成应用调用。
export const AuthTokenRequestSchema = z.union([TokenExchangeRequestSchema, ApplicationTokenRequestSchema]);
export type AuthTokenRequest = z.infer<typeof AuthTokenRequestSchema>;
export const AuthTokenResponseSchema = z.union([TokenExchangeResponseSchema, ApplicationTokenResponseSchema]);
export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>;
// =============================================================
// Exchange Code (POST /pac/v1/auth/exchange-code)
// =============================================================
......
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