Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
7bdc1a5c
Commit
7bdc1a5c
authored
Oct 19, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(open): add application auth and exact patient facts lookup
parent
b7901c93
Pipeline
#3830
failed in 0 seconds
Changes
27
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
27 changed files
with
2851 additions
and
70 deletions
+2851
-70
apps/pac-docs/app/docs/[[...slug]]/page.tsx
+0
-1
apps/pac-docs/content/docs/integration/auth-login.mdx
+3
-0
apps/pac-docs/content/docs/integration/meta.json
+2
-0
apps/pac-docs/content/docs/integration/patient-fact-content.mdx
+638
-0
apps/pac-docs/content/docs/integration/patient-facts-lookup.mdx
+201
-0
apps/pac-docs/openapi/pac.json
+483
-44
apps/pac-service/prisma/migrations/20261019170000_open_patient_facts/migration.sql
+4
-0
apps/pac-service/prisma/schema.prisma
+4
-0
apps/pac-service/src/cli/host-admin.cli.ts
+13
-0
apps/pac-service/src/modules/admin/hosts.service.ts
+6
-0
apps/pac-service/src/modules/auth/application-token.ts
+23
-0
apps/pac-service/src/modules/auth/auth.controller.ts
+17
-19
apps/pac-service/src/modules/auth/auth.service.ts
+32
-2
apps/pac-service/src/modules/auth/dto/auth.dto.ts
+6
-0
apps/pac-service/src/modules/auth/jwt.strategy.ts
+3
-1
apps/pac-service/src/modules/open/application-token.guard.ts
+80
-0
apps/pac-service/src/modules/open/open.module.ts
+5
-2
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
+34
-0
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
+107
-0
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
+218
-0
apps/pac-service/src/openapi/build-document.ts
+2
-1
apps/pac-service/tests/application-token.spec.ts
+163
-0
apps/pac-service/tests/open-fact-content-doc.spec.ts
+82
-0
apps/pac-service/tests/open-patient-facts-local.spec.ts
+529
-0
apps/pac-service/tests/patient-facts-lookup.spec.ts
+102
-0
docs/qa/2026-10-19-open-patient-facts-local.md
+79
-0
packages/types/src/schemas/auth.ts
+15
-0
No files found.
apps/pac-docs/app/docs/[[...slug]]/page.tsx
View file @
7bdc1a5c
...
@@ -24,7 +24,6 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
...
@@ -24,7 +24,6 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
return
(
return
(
<
DocsPage
toc=
{
page
.
data
.
toc
}
full
>
<
DocsPage
toc=
{
page
.
data
.
toc
}
full
>
<
DocsTitle
>
{
page
.
data
.
title
}
</
DocsTitle
>
<
DocsTitle
>
{
page
.
data
.
title
}
</
DocsTitle
>
<
DocsDescription
className=
"mb-0"
>
{
page
.
data
.
description
}
</
DocsDescription
>
<
DocsBody
>
<
DocsBody
>
<
OpenAPIPage
{
...
page
.
data
.
getOpenAPIPageProps
()}
/>
<
OpenAPIPage
{
...
page
.
data
.
getOpenAPIPageProps
()}
/>
</
DocsBody
>
</
DocsBody
>
...
...
apps/pac-docs/content/docs/integration/auth-login.mdx
View file @
7bdc1a5c
...
@@ -4,6 +4,9 @@ description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数
...
@@ -4,6 +4,9 @@ description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数
icon: KeyRound
icon: KeyRound
---
---
同一 `/pac/v1/auth/token` 也支持应用只读查询:仅传 `appId/appSecret`、不传 `user` 即进入应用模式,需事先开通授权。原用户登录流程保持不变,详见[患者事实查询](/docs/integration/patient-facts-lookup)。
> PAC 工作台以 iframe 嵌入宿主系统。用户在宿主侧登录后,宿主**换票**给 PAC —— 传入「身份 + 角色 + 可见范围」,换取一次性 code 打开 PAC 页面。
> PAC 工作台以 iframe 嵌入宿主系统。用户在宿主侧登录后,宿主**换票**给 PAC —— 传入「身份 + 角色 + 可见范围」,换取一次性 code 打开 PAC 页面。
> 接口与字段以 [API 参考 · auth](/docs/api/auth/AuthController_exchangeToken) 的 OpenAPI 定义为准。
> 接口与字段以 [API 参考 · auth](/docs/api/auth/AuthController_exchangeToken) 的 OpenAPI 定义为准。
...
...
apps/pac-docs/content/docs/integration/meta.json
View file @
7bdc1a5c
...
@@ -10,6 +10,8 @@
...
@@ -10,6 +10,8 @@
"channel-push"
,
"channel-push"
,
"friday-push-payload"
,
"friday-push-payload"
,
"auth-login"
,
"auth-login"
,
"patient-facts-lookup"
,
"patient-fact-content"
,
"friday-ai-host"
,
"friday-ai-host"
,
"postmessage-actions"
,
"postmessage-actions"
,
"execution-callback"
,
"execution-callback"
,
...
...
apps/pac-docs/content/docs/integration/patient-fact-content.mdx
0 → 100644
View file @
7bdc1a5c
---
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 差异;查询只读已落库内容,不修复入库、不伪造缺少字段,也不能用“查不到”证明源系统不存在记录。接入方应以本宿主实际可用数据验收;摄入差异另行修复。
apps/pac-docs/content/docs/integration/patient-facts-lookup.mdx
0 → 100644
View file @
7bdc1a5c
---
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>
apps/pac-docs/openapi/pac.json
View file @
7bdc1a5c
...
@@ -1767,7 +1767,7 @@
...
@@ -1767,7 +1767,7 @@
},
},
"/pac/v1/push/rows"
:
{
"/pac/v1/push/rows"
:
{
"post"
:
{
"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"
,
"operationId"
:
"PushController_receiveRows"
,
"parameters"
:
[
"parameters"
:
[
{
{
...
@@ -1783,7 +1783,7 @@
...
@@ -1783,7 +1783,7 @@
"description"
:
""
"description"
:
""
}
}
},
},
"summary"
:
"宿主推送原生表行 webhook(形态 A,HMAC 验签;?dryRun=1 预检不
落库
)"
,
"summary"
:
"宿主推送原生表行 webhook(形态 A,HMAC 验签;?dryRun=1 预检不
写业务数据
)"
,
"tags"
:
[
"tags"
:
[
"push"
"push"
]
]
...
@@ -2734,6 +2734,7 @@
...
@@ -2734,6 +2734,7 @@
},
},
"/pac/v1/auth/token"
:
{
"/pac/v1/auth/token"
:
{
"post"
:
{
"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"
,
"operationId"
:
"AuthController_exchangeToken"
,
"parameters"
:
[],
"parameters"
:
[],
"requestBody"
:
{
"requestBody"
:
{
...
@@ -2741,7 +2742,7 @@
...
@@ -2741,7 +2742,7 @@
"content"
:
{
"content"
:
{
"application/json"
:
{
"application/json"
:
{
"schema"
:
{
"schema"
:
{
"$ref"
:
"#/components/schemas/
TokenExchange
RequestDto"
"$ref"
:
"#/components/schemas/
AuthToken
RequestDto"
}
}
}
}
}
}
...
@@ -2772,7 +2773,7 @@
...
@@ -2772,7 +2773,7 @@
]
]
},
},
"data"
:
{
"data"
:
{
"$ref"
:
"#/components/schemas/
TokenExchange
ResponseDto_Output"
"$ref"
:
"#/components/schemas/
AuthToken
ResponseDto_Output"
}
}
}
}
}
}
...
@@ -2780,7 +2781,7 @@
...
@@ -2780,7 +2781,7 @@
}
}
}
}
},
},
"summary"
:
"
Token Exchange — host backend trades appId+appSecret+user for an accessToken + one-time code
"
,
"summary"
:
"
获取令牌 / 用户登录
"
,
"tags"
:
[
"tags"
:
[
"auth"
"auth"
]
]
...
@@ -3328,6 +3329,66 @@
...
@@ -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"
:
{
"/pac/v1/open/patients/search"
:
{
"post"
:
{
"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。"
,
"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 @@
...
@@ -4027,7 +4088,7 @@
},
},
{
{
"name"
:
"auth"
,
"name"
:
"auth"
,
"description"
:
"
登录换票、会话、
数据权限(orgScope)。"
"description"
:
"
应用令牌、用户登录换票、会话及
数据权限(orgScope)。"
},
},
{
{
"name"
:
"facts"
,
"name"
:
"facts"
,
...
@@ -4046,7 +4107,8 @@
...
@@ -4046,7 +4107,8 @@
"description"
:
"MCP(Model Context Protocol)工具端点。"
"description"
:
"MCP(Model Context Protocol)工具端点。"
},
},
{
{
"name"
:
"open"
"name"
:
"open"
,
"description"
:
"宿主应用患者查询(Bearer)与 FridayAI 开放接口(HMAC);以各端点说明为准。"
},
},
{
{
"name"
:
"patient"
,
"name"
:
"patient"
,
...
@@ -8495,7 +8557,7 @@
...
@@ -8495,7 +8557,7 @@
],
],
"additionalProperties"
:
false
"additionalProperties"
:
false
},
},
"
TokenExchange
RequestDto"
:
{
"
AuthToken
RequestDto"
:
{
"type"
:
"object"
,
"type"
:
"object"
,
"properties"
:
{
"properties"
:
{
"appId"
:
{
"appId"
:
{
...
@@ -8574,47 +8636,75 @@
...
@@ -8574,47 +8636,75 @@
},
},
"required"
:
[
"required"
:
[
"appId"
,
"appId"
,
"appSecret"
,
"appSecret"
"user"
],
],
"additionalProperties"
:
false
"additionalProperties"
:
false
},
},
"TokenExchangeResponseDto_Output"
:
{
"AuthTokenResponseDto_Output"
:
{
"type"
:
"object"
,
"anyOf"
:
[
"properties"
:
{
{
"accessToken"
:
{
"type"
:
"object"
,
"type"
:
"string"
,
"properties"
:
{
"description"
:
"Bearer JWT for subsequent API calls (default 2h TTL)"
"accessToken"
:
{
},
"type"
:
"string"
,
"refreshToken"
:
{
"description"
:
"Bearer JWT for subsequent API calls (default 2h TTL)"
"type"
:
"string"
,
},
"description"
:
"Refresh JWT — exchange before access token expires"
"refreshToken"
:
{
},
"type"
:
"string"
,
"code"
:
{
"description"
:
"Refresh JWT — exchange before access token expires"
"type"
:
"string"
,
},
"description"
:
"One-time exchange code. Pass to the iframe URL; consumed by /auth/exchange-code (default 60s TTL)."
"code"
:
{
},
"type"
:
"string"
,
"expiresIn"
:
{
"description"
:
"One-time exchange code. Pass to the iframe URL; consumed by /auth/exchange-code (default 60s TTL)."
"type"
:
"integer"
,
},
"minimum"
:
-9007199254740991
,
"expiresIn"
:
{
"maximum"
:
9007199254740991
,
"type"
:
"integer"
,
"description"
:
"Access token lifetime in seconds"
"minimum"
:
-9007199254740991
,
"maximum"
:
9007199254740991
,
"description"
:
"Access token lifetime in seconds"
},
"codeExpiresIn"
:
{
"type"
:
"integer"
,
"minimum"
:
-9007199254740991
,
"maximum"
:
9007199254740991
,
"description"
:
"One-time code lifetime in seconds"
}
},
"required"
:
[
"accessToken"
,
"refreshToken"
,
"code"
,
"expiresIn"
,
"codeExpiresIn"
],
"additionalProperties"
:
false
},
},
"codeExpiresIn"
:
{
{
"type"
:
"integer"
,
"type"
:
"object"
,
"minimum"
:
-9007199254740991
,
"properties"
:
{
"maximum"
:
9007199254740991
,
"accessToken"
:
{
"description"
:
"One-time code lifetime in seconds"
"type"
:
"string"
},
"tokenType"
:
{
"type"
:
"string"
,
"enum"
:
[
"Bearer"
]
},
"expiresIn"
:
{
"type"
:
"integer"
,
"exclusiveMinimum"
:
0
,
"maximum"
:
9007199254740991
}
},
"required"
:
[
"accessToken"
,
"tokenType"
,
"expiresIn"
],
"additionalProperties"
:
false
}
}
},
]
"required"
:
[
"accessToken"
,
"refreshToken"
,
"code"
,
"expiresIn"
,
"codeExpiresIn"
],
"additionalProperties"
:
false
},
},
"ExchangeCodeRequestDto"
:
{
"ExchangeCodeRequestDto"
:
{
"type"
:
"object"
,
"type"
:
"object"
,
...
@@ -9237,6 +9327,355 @@
...
@@ -9237,6 +9327,355 @@
],
],
"additionalProperties"
:
false
"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"
:
{
"ApiErrorEnvelope"
:
{
"type"
:
"object"
,
"type"
:
"object"
,
"description"
:
"Wrapped error response. HTTP status is 200 unless the server crashed (5xx)."
,
"description"
:
"Wrapped error response. HTTP status is 200 unless the server crashed (5xx)."
,
...
...
apps/pac-service/prisma/migrations/20261019170000_open_patient_facts/migration.sql
0 → 100644
View file @
7bdc1a5c
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"
);
apps/pac-service/prisma/schema.prisma
View file @
7bdc1a5c
...
@@ -69,6 +69,8 @@ model Host {
...
@@ -69,6 +69,8 @@ model Host {
appId
String
@
unique
@
map
(
"app_id"
)
appId
String
@
unique
@
map
(
"app_id"
)
///
appSecret
的
scrypt
哈希
(
salt
:
hash
格式
),
原文从不落库
///
appSecret
的
scrypt
哈希
(
salt
:
hash
格式
),
原文从不落库
appSecretHash
String
@
map
(
"app_secret_hash"
)
appSecretHash
String
@
map
(
"app_secret_hash"
)
///
应用令牌读取本宿主全部当前患者事实;默认关闭,与
FridayAI
HMAC
授权独立。
openFactsReadEnabled
Boolean
@
default
(
false
)
@
map
(
"open_facts_read_enabled"
)
///
PAC
对该宿主的出站调用配置
;
null
=
PAC
不主动调用宿主
(
纯
push
接入或不贡献数据
)
。
///
PAC
对该宿主的出站调用配置
;
null
=
PAC
不主动调用宿主
(
纯
push
接入或不贡献数据
)
。
///
///
...
@@ -254,6 +256,8 @@ model Patient {
...
@@ -254,6 +256,8 @@ model Patient {
///
跨同步
upsert
去重
+
集团内
external_id
撞号消歧
(
同号不同源
=
不同人
,
靠
source_unit
分开
)
///
跨同步
upsert
去重
+
集团内
external_id
撞号消歧
(
同号不同源
=
不同人
,
靠
source_unit
分开
)
@@
unique
([
hostId
,
tenantId
,
sourceUnit
,
externalId
])
@@
unique
([
hostId
,
tenantId
,
sourceUnit
,
externalId
])
@@
index
([
hostId
,
tenantId
])
@@
index
([
hostId
,
tenantId
])
@@
index
([
hostId
,
medicalRecordNumber
])
@@
index
([
hostId
,
name
])
@@
index
([
phone
])
@@
index
([
phone
])
///
召回池热查询路径
(
active
=
是否活跃
);
合规过滤走
PatientProfile
join
///
召回池热查询路径
(
active
=
是否活跃
);
合规过滤走
PatientProfile
join
@@
index
([
hostId
,
tenantId
,
active
])
@@
index
([
hostId
,
tenantId
,
active
])
...
...
apps/pac-service/src/cli/host-admin.cli.ts
View file @
7bdc1a5c
...
@@ -70,6 +70,8 @@ Commands:
...
@@ -70,6 +70,8 @@ Commands:
revoke-read <id-or-name> 撤销读方对这个宿主的授权(立即生效)
revoke-read <id-or-name> 撤销读方对这个宿主的授权(立即生效)
rotate-read 给所有已授权宿主换一把新读钥匙(前插,旧的 grace 期仍收)
rotate-read 给所有已授权宿主换一把新读钥匙(前插,旧的 grace 期仍收)
prune-read 读方切到新钥匙后,清掉旧的读钥匙
prune-read 读方切到新钥匙后,清掉旧的读钥匙
grant-facts-read <id-or-name> 开通宿主应用事实查询(本宿主全部患者;与 FridayAI HMAC 独立)
revoke-facts-read <id-or-name> 撤销应用事实查询(已有令牌随即不可查询)
deactivate <id-or-name> 软停 host(后续 auth 拒绝)
deactivate <id-or-name> 软停 host(后续 auth 拒绝)
Examples:
Examples:
...
@@ -288,6 +290,17 @@ async function main() {
...
@@ -288,6 +290,17 @@ async function main() {
break
;
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'
:
{
case
'deactivate'
:
{
const
idOrName
=
args
.
positional
[
0
];
const
idOrName
=
args
.
positional
[
0
];
if
(
!
idOrName
)
{
if
(
!
idOrName
)
{
...
...
apps/pac-service/src/modules/admin/hosts.service.ts
View file @
7bdc1a5c
...
@@ -53,6 +53,12 @@ export class HostsService {
...
@@ -53,6 +53,12 @@ export class HostsService {
return
host
;
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
// Create
// ─────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────
...
...
apps/pac-service/src/modules/auth/application-token.ts
0 → 100644
View file @
7bdc1a5c
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'
);
}
apps/pac-service/src/modules/auth/auth.controller.ts
View file @
7bdc1a5c
import
{
AuthTokenRequestSchema
}
from
'@pac/types'
;
import
{
Body
,
Controller
,
Get
,
Post
,
Query
,
Req
}
from
'@nestjs/common'
;
import
{
Body
,
Controller
,
Get
,
Post
,
Query
,
Req
}
from
'@nestjs/common'
;
import
type
{
Request
}
from
'express'
;
import
type
{
Request
}
from
'express'
;
import
{
ApiBearerAuth
,
ApiOperation
,
ApiTags
}
from
'@nestjs/swagger'
;
import
{
ApiBearerAuth
,
ApiOperation
,
ApiTags
}
from
'@nestjs/swagger'
;
...
@@ -5,10 +6,7 @@ import { ZodResponse } from 'nestjs-zod';
...
@@ -5,10 +6,7 @@ import { ZodResponse } from 'nestjs-zod';
import
type
{
AccessTokenPayload
}
from
'@pac/types'
;
import
type
{
AccessTokenPayload
}
from
'@pac/types'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
import
{
CurrentUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
CurrentUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
import
{
TenantScope
,
TenantScopeContext
}
from
'../../common/decorators/tenant-scope.decorator'
;
TenantScope
,
TenantScopeContext
,
}
from
'../../common/decorators/tenant-scope.decorator'
;
import
{
AuthService
}
from
'./auth.service'
;
import
{
AuthService
}
from
'./auth.service'
;
import
{
import
{
ClientDiagRequestDto
,
ClientDiagRequestDto
,
...
@@ -20,8 +18,8 @@ import {
...
@@ -20,8 +18,8 @@ import {
RefreshTokenRequestDto
,
RefreshTokenRequestDto
,
RefreshTokenResponseDto
,
RefreshTokenResponseDto
,
SessionResponseDto
,
SessionResponseDto
,
TokenExchange
RequestDto
,
AuthToken
RequestDto
,
TokenExchange
ResponseDto
,
AuthToken
ResponseDto
,
}
from
'./dto/auth.dto'
;
}
from
'./dto/auth.dto'
;
@
ApiTags
(
'auth'
)
@
ApiTags
(
'auth'
)
...
@@ -31,13 +29,18 @@ export class AuthController {
...
@@ -31,13 +29,18 @@ export class AuthController {
@
Public
()
@
Public
()
@
Post
(
'token'
)
@
Post
(
'token'
)
@
ZodResponse
({
status
:
200
,
type
:
TokenExchange
ResponseDto
})
@
ZodResponse
({
status
:
200
,
type
:
AuthToken
ResponseDto
})
@
ApiOperation
({
@
ApiOperation
({
summary
:
summary
:
'获取令牌 / 用户登录'
,
'Token Exchange — host backend trades appId+appSecret+user for an accessToken + one-time code'
,
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
:
TokenExchange
RequestDto
)
{
exchangeToken
(@
Body
()
dto
:
AuthToken
RequestDto
)
{
return
this
.
auth
.
exchangeToken
(
dto
);
return
this
.
auth
.
exchangeToken
(
AuthTokenRequestSchema
.
parse
(
dto
)
);
}
}
@
Public
()
@
Public
()
...
@@ -73,8 +76,7 @@ export class AuthController {
...
@@ -73,8 +76,7 @@ export class AuthController {
@
Post
(
'mock-login'
)
@
Post
(
'mock-login'
)
@
ZodResponse
({
status
:
200
,
type
:
MockLoginResponseDto
})
@
ZodResponse
({
status
:
200
,
type
:
MockLoginResponseDto
})
@
ApiOperation
({
@
ApiOperation
({
summary
:
summary
:
'Mock login(开发 / 试部署用,env 门控)— 按 { tenant, role } 预制 user payload 直产 JWT'
,
'Mock login(开发 / 试部署用,env 门控)— 按 { tenant, role } 预制 user payload 直产 JWT'
,
description
:
description
:
'env: PAC_ENABLE_MOCK_LOGIN=true 强开 / =false 强关;不设按 NODE_ENV(prod 默认关)。'
+
'env: PAC_ENABLE_MOCK_LOGIN=true 强开 / =false 强关;不设按 NODE_ENV(prod 默认关)。'
+
'生产真 host SSO 接入后,把 env 设 false 或删该 endpoint。'
,
'生产真 host SSO 接入后,把 env 设 false 或删该 endpoint。'
,
...
@@ -88,8 +90,7 @@ export class AuthController {
...
@@ -88,8 +90,7 @@ export class AuthController {
@
ZodResponse
({
status
:
200
,
type
:
MockUsersResponseDto
})
@
ZodResponse
({
status
:
200
,
type
:
MockUsersResponseDto
})
@
ApiOperation
({
@
ApiOperation
({
summary
:
'客服花名册(开发 / 试部署用)— 给"快速登录"对话框选人'
,
summary
:
'客服花名册(开发 / 试部署用)— 给"快速登录"对话框选人'
,
description
:
description
:
'派生自 data/jvs-dw/users.json(回访表 · 近 12 月在岗)。mock 登录禁用时返空数组。'
,
'派生自 data/jvs-dw/users.json(回访表 · 近 12 月在岗)。mock 登录禁用时返空数组。'
,
})
})
mockUsers
()
{
mockUsers
()
{
return
{
users
:
this
.
auth
.
listMockUsers
()
};
return
{
users
:
this
.
auth
.
listMockUsers
()
};
...
@@ -110,10 +111,7 @@ export class AuthController {
...
@@ -110,10 +111,7 @@ export class AuthController {
summary
:
summary
:
'当前会话视图 — 把 JWT.orgScope 展开成 clinicIds/sourceUnits 返给前端(设计 B:JWT 只装通用 orgScope,前端展不开)'
,
'当前会话视图 — 把 JWT.orgScope 展开成 clinicIds/sourceUnits 返给前端(设计 B:JWT 只装通用 orgScope,前端展不开)'
,
})
})
async
session
(
async
session
(@
CurrentUser
()
user
:
AccessTokenPayload
,
@
TenantScope
()
scope
:
TenantScopeContext
)
{
@
CurrentUser
()
user
:
AccessTokenPayload
,
@
TenantScope
()
scope
:
TenantScopeContext
,
)
{
// 诊所名字典:服务端派生(host.clinicNames)打底,宿主换票传的 dictionary.clinics 覆盖优先。
// 诊所名字典:服务端派生(host.clinicNames)打底,宿主换票传的 dictionary.clinics 覆盖优先。
// 这样前端显示诊所名不再依赖每次登录都传全 —— 服务端补齐 GUID→中文名,登录传的仅作覆盖。
// 这样前端显示诊所名不再依赖每次登录都传全 —— 服务端补齐 GUID→中文名,登录传的仅作覆盖。
const
serverClinicNames
=
await
this
.
auth
.
getClinicNames
(
user
.
hostId
);
const
serverClinicNames
=
await
this
.
auth
.
getClinicNames
(
user
.
hostId
);
...
...
apps/pac-service/src/modules/auth/auth.service.ts
View file @
7bdc1a5c
...
@@ -5,6 +5,11 @@ import { ActivityAction, ActivitySurface } from '@pac/types';
...
@@ -5,6 +5,11 @@ import { ActivityAction, ActivitySurface } from '@pac/types';
import
{
ActivityService
}
from
'../activity/activity.service'
;
import
{
ActivityService
}
from
'../activity/activity.service'
;
import
{
JVS_DW_PRESETS
}
from
'./jvs-dw-preset'
;
import
{
JVS_DW_PRESETS
}
from
'./jvs-dw-preset'
;
import
{
import
{
AuthTokenRequestSchema
,
type
AuthTokenRequest
,
type
AuthTokenResponse
,
type
ApplicationTokenRequest
,
type
ApplicationTokenResponse
,
ApiCode
,
ApiCode
,
ROLE_PERMISSIONS
,
ROLE_PERMISSIONS
,
type
AccessTokenPayload
,
type
AccessTokenPayload
,
...
@@ -20,7 +25,7 @@ import {
...
@@ -20,7 +25,7 @@ import {
type
UserRole
,
type
UserRole
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
import
{
BizError
}
from
'../../common/errors/biz-error'
;
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
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
RedisService
}
from
'../../redis/redis.service'
;
import
{
RedisService
}
from
'../../redis/redis.service'
;
import
{
AlertService
}
from
'../../common/alerting/alert.service'
;
import
{
AlertService
}
from
'../../common/alerting/alert.service'
;
...
@@ -29,6 +34,8 @@ import { loadMockUsers } from './mock-users';
...
@@ -29,6 +34,8 @@ import { loadMockUsers } from './mock-users';
import
{
OrgTreeService
}
from
'./org-tree'
;
import
{
OrgTreeService
}
from
'./org-tree'
;
import
{
checkHostAuthPolicy
,
loadHostAuthPolicy
}
from
'./host-auth-policy'
;
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:'
;
const
CODE_PREFIX
=
'pac:exchange-code:'
;
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
/// TTL 内可重复换、返回同一票(宿主重载/双次加载都不误报「登录已过期」),到点自然失效。
/// TTL 内可重复换、返回同一票(宿主重载/双次加载都不误报「登录已过期」),到点自然失效。
...
@@ -137,7 +144,14 @@ export class AuthService {
...
@@ -137,7 +144,14 @@ export class AuthService {
return
(
host
?.
clinicNames
as
Record
<
string
,
string
>
)
??
{};
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
}
});
const
host
=
await
this
.
prisma
.
host
.
findUnique
({
where
:
{
appId
:
req
.
appId
}
});
if
(
!
host
||
!
host
.
active
)
{
if
(
!
host
||
!
host
.
active
)
{
throw
new
BizError
(
ApiCode
.
AUTH_INVALID_CREDENTIALS
);
throw
new
BizError
(
ApiCode
.
AUTH_INVALID_CREDENTIALS
);
...
@@ -146,6 +160,22 @@ export class AuthService {
...
@@ -146,6 +160,22 @@ export class AuthService {
throw
new
BizError
(
ApiCode
.
AUTH_INVALID_CREDENTIALS
);
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
);
this
.
assertHostAuthPolicy
(
host
.
name
,
req
.
user
);
// 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。
// 契约 user 上下文 → token,与 mockLogin 共用同一条 issueTokens(展开 + 签发)。
...
...
apps/pac-service/src/modules/auth/dto/auth.dto.ts
View file @
7bdc1a5c
import
{
createZodDto
}
from
'nestjs-zod'
;
import
{
createZodDto
}
from
'nestjs-zod'
;
import
{
z
}
from
'zod'
;
import
{
z
}
from
'zod'
;
import
{
import
{
AuthTokenResponseSchema
,
ExchangeCodeRequestSchema
,
ExchangeCodeRequestSchema
,
ExchangeCodeResponseSchema
,
ExchangeCodeResponseSchema
,
MockLoginRequestSchema
,
MockLoginRequestSchema
,
...
@@ -13,6 +14,11 @@ import {
...
@@ -13,6 +14,11 @@ import {
TokenExchangeResponseSchema
,
TokenExchangeResponseSchema
,
}
from
'@pac/types'
;
}
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
TokenExchangeRequestDto
extends
createZodDto
(
TokenExchangeRequestSchema
)
{}
export
class
TokenExchangeResponseDto
extends
createZodDto
(
TokenExchangeResponseSchema
)
{}
export
class
TokenExchangeResponseDto
extends
createZodDto
(
TokenExchangeResponseSchema
)
{}
export
class
ExchangeCodeRequestDto
extends
createZodDto
(
ExchangeCodeRequestSchema
)
{}
export
class
ExchangeCodeRequestDto
extends
createZodDto
(
ExchangeCodeRequestSchema
)
{}
...
...
apps/pac-service/src/modules/auth/jwt.strategy.ts
View file @
7bdc1a5c
...
@@ -15,7 +15,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
...
@@ -15,7 +15,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
}
validate
(
payload
:
AccessTokenPayload
):
AccessTokenPayload
{
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'
);
throw
new
UnauthorizedException
(
'Invalid token payload'
);
}
}
return
payload
;
return
payload
;
...
...
apps/pac-service/src/modules/open/application-token.guard.ts
0 → 100644
View file @
7bdc1a5c
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
;
}
}
apps/pac-service/src/modules/open/open.module.ts
View file @
7bdc1a5c
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
{
Module
}
from
'@nestjs/common'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
PatientModule
}
from
'../patient/patient.module'
;
import
{
PatientModule
}
from
'../patient/patient.module'
;
...
@@ -19,7 +22,7 @@ import { OpenFactWindowService } from './open-fact-window.service';
...
@@ -19,7 +22,7 @@ import { OpenFactWindowService } from './open-fact-window.service';
*/
*/
@
Module
({
@
Module
({
imports
:
[
AuthModule
,
PatientModule
],
imports
:
[
AuthModule
,
PatientModule
],
controllers
:
[
OpenPatientsController
,
OpenOrgController
,
OpenIdentityController
],
controllers
:
[
PatientFactsLookupController
,
OpenPatientsController
,
OpenOrgController
,
OpenIdentityController
],
providers
:
[
HmacVerifier
,
OpenProfileService
,
OpenAppointmentService
,
OpenFactGate
,
OpenFactWindowService
,],
providers
:
[
ApplicationTokenGuard
,
PatientFactsLookupService
,
HmacVerifier
,
OpenProfileService
,
OpenAppointmentService
,
OpenFactGate
,
OpenFactWindowService
,],
})
})
export
class
OpenModule
{}
export
class
OpenModule
{}
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
0 → 100644
View file @
7bdc1a5c
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
);
}
}
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
0 → 100644
View file @
7bdc1a5c
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
)
{}
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
0 → 100644
View file @
7bdc1a5c
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
()]
};
}
}
apps/pac-service/src/openapi/build-document.ts
View file @
7bdc1a5c
...
@@ -23,7 +23,8 @@ export const GLOBAL_PREFIX_EXCLUDE = [
...
@@ -23,7 +23,8 @@ export const GLOBAL_PREFIX_EXCLUDE = [
/** tag → 中文标签 + 说明(文档站侧栏分组 / Scalar UI 用)。未列出的 tag 原样保留。 */
/** tag → 中文标签 + 说明(文档站侧栏分组 / Scalar UI 用)。未列出的 tag 原样保留。 */
const
TAG_DESCRIPTIONS
:
Record
<
string
,
string
>
=
{
const
TAG_DESCRIPTIONS
:
Record
<
string
,
string
>
=
{
auth
:
'登录换票、会话、数据权限(orgScope)。'
,
auth
:
'应用令牌、用户登录换票、会话及数据权限(orgScope)。'
,
open
:
'宿主应用患者查询(Bearer)与 FridayAI 开放接口(HMAC);以各端点说明为准。'
,
facts
:
'患者事实写入(事件账本)与查询。'
,
facts
:
'患者事实写入(事件账本)与查询。'
,
patient
:
'患者主档、检索、时间线。'
,
patient
:
'患者主档、检索、时间线。'
,
persona
:
'患者画像与特征。'
,
persona
:
'患者画像与特征。'
,
...
...
apps/pac-service/tests/application-token.spec.ts
0 → 100644
View file @
7bdc1a5c
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
();
});
});
apps/pac-service/tests/open-fact-content-doc.spec.ts
0 → 100644
View file @
7bdc1a5c
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'
],
});
});
});
apps/pac-service/tests/open-patient-facts-local.spec.ts
0 → 100644
View file @
7bdc1a5c
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
,
]);
});
});
apps/pac-service/tests/patient-facts-lookup.spec.ts
0 → 100644
View file @
7bdc1a5c
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
:
[]
});
});
});
docs/qa/2026-10-19-open-patient-facts-local.md
0 → 100644
View file @
7bdc1a5c
# 应用鉴权与患者查询:本地验收
日期: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 应用联调与部署结果待后续验收记录。
packages/types/src/schemas/auth.ts
View file @
7bdc1a5c
...
@@ -72,6 +72,21 @@ export const TokenExchangeResponseSchema = z.object({
...
@@ -72,6 +72,21 @@ export const TokenExchangeResponseSchema = z.object({
});
});
export
type
TokenExchangeResponse
=
z
.
infer
<
typeof
TokenExchangeResponseSchema
>
;
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)
// Exchange Code (POST /pac/v1/auth/exchange-code)
// =============================================================
// =============================================================
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment