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
1e0c92f4
Commit
1e0c92f4
authored
Oct 19, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge application patient facts API into test
parents
daff13e7
7bdc1a5c
Pipeline
#3831
failed in 0 seconds
Changes
27
Pipelines
1
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
27 changed files
with
1201 additions
and
26 deletions
+1201
-26
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
+0
-0
apps/pac-docs/content/docs/integration/patient-facts-lookup.mdx
+201
-0
apps/pac-docs/openapi/pac.json
+0
-0
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
+0
-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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
This diff is collapsed.
Click to expand it.
apps/pac-docs/content/docs/integration/patient-facts-lookup.mdx
0 → 100644
View file @
1e0c92f4
---
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 @
1e0c92f4
This diff is collapsed.
Click to expand it.
apps/pac-service/prisma/migrations/20261019170000_open_patient_facts/migration.sql
0 → 100644
View file @
1e0c92f4
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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
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 @
1e0c92f4
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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
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 @
1e0c92f4
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 @
1e0c92f4
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 @
1e0c92f4
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 @
1e0c92f4
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 @
1e0c92f4
...
@@ -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 @
1e0c92f4
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 @
1e0c92f4
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 @
1e0c92f4
This diff is collapsed.
Click to expand it.
apps/pac-service/tests/patient-facts-lookup.spec.ts
0 → 100644
View file @
1e0c92f4
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 @
1e0c92f4
# 应用鉴权与患者查询:本地验收
日期: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 @
1e0c92f4
...
@@ -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