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
6e099cc1
Commit
6e099cc1
authored
Oct 23, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(open): group persona dimensions and inline image access URLs
parent
15d08d44
Pipeline
#3865
failed in 0 seconds
Changes
14
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
319 additions
and
38 deletions
+319
-38
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
+37
-9
apps/pac-docs/openapi/pac.json
+37
-3
apps/pac-service/src/modules/open/lookup-content.schema.ts
+11
-2
apps/pac-service/src/modules/open/lookup-image-access.service.ts
+94
-0
apps/pac-service/src/modules/open/open-persona.ts
+12
-5
apps/pac-service/src/modules/open/open.module.ts
+2
-1
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
+3
-2
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
+6
-2
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
+14
-2
apps/pac-service/tests/lookup-contract-postgres.spec.ts
+1
-1
apps/pac-service/tests/lookup-image-access.spec.ts
+67
-0
apps/pac-service/tests/open-patient-enum-doc.spec.ts
+2
-1
apps/pac-service/tests/open-persona.spec.ts
+8
-7
apps/pac-service/tests/patient-facts-lookup.spec.ts
+25
-3
No files found.
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
View file @
6e099cc1
...
@@ -65,10 +65,12 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -65,10 +65,12 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
-d '{"medicalRecordNumber":"000123"}'
-d '{"medicalRecordNumber":"000123"}'
```
```
###
获取影像文件访问
地址
###
刷新文件地址或获取 CT 切片
地址
`POST /pac/v1/open/image-files/resolve`,使用相同的应用 Bearer 令牌。
`POST /pac/v1/open/image-files/resolve`,使用相同的应用 Bearer 令牌。
**患者查询已在 `content.fileRefs` 的原图、缩略图项内附带 `url` 和 `expiresAt`,可直接访问,无需再请求本接口。** 地址为空、过期或需要 CT 切片时,再调用本接口。
从查询结果中取得患者 `patient.id`、影像事实 `facts[].id` 和该影像 `content.fileRefs` 的数组下标。
从查询结果中取得患者 `patient.id`、影像事实 `facts[].id` 和该影像 `content.fileRefs` 的数组下标。
```json
```json
...
@@ -83,7 +85,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -83,7 +85,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| --- | --- | --- |
| --- | --- | --- |
| `patientId` | string | PAC 患者 UUID |
| `patientId` | string | PAC 患者 UUID |
| `imageFactId` | string | 当前影像事实版本 UUID,不是源影像 ID |
| `imageFactId` | string | 当前影像事实版本 UUID,不是源影像 ID |
| `fileIndexes` | integer[] | `fileRefs` 中从 0 开始的下标;每批 1–100 个,不重复 |
| `fileIndexes` | integer[] | `fileRefs` 中从 0 开始的下标;每批 1–100 个,不重复
;每个下标 0–9999,须对应实际文件
|
成功返回 `data.imageFactId` 与 `data.files`,文件顺序与请求下标一致:
成功返回 `data.imageFactId` 与 `data.files`,文件顺序与请求下标一致:
...
@@ -126,7 +128,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -126,7 +128,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| --- | --- | --- |
| --- | --- | --- |
| `data.items` | object[] | 匹配患者列表;无匹配为 `[]`,病历号或姓名重号时返回多项 |
| `data.items` | object[] | 匹配患者列表;无匹配为 `[]`,病历号或姓名重号时返回多项 |
| `items[].patient` | object | 患者基本信息及业务属性 |
| `items[].patient` | object | 患者基本信息及业务属性 |
| `items[].persona` |
string[] | 可直接展示的画像标签;无标签
为 `[]` |
| `items[].persona` |
object[] | 按维度分组的画像;无可展示值
为 `[]` |
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
...
@@ -158,8 +160,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -158,8 +160,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"acquisitionSub": null
"acquisitionSub": null
},
},
"persona": [
"persona": [
"充填治疗"
,
{"key": "potential_treatment", "name": "潜在治疗", "values": ["充填治疗"]}
,
"重要价值"
{"key": "rfm", "name": "价值分群(RFM)", "values": ["重要价值"]}
],
],
"relations": [],
"relations": [],
"facts": [
"facts": [
...
@@ -194,7 +196,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -194,7 +196,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 可空 | 说明 |
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| --- | --- | --- | --- |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `externalId` | string
/null
| 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `externalId` | string | 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `name` | string | 是 | 姓名 |
| `name` | string | 是 | 姓名 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `phone` | string | 是 | 脱敏手机号 |
| `phone` | string | 是 | 脱敏手机号 |
...
@@ -227,7 +229,22 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -227,7 +229,22 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
### 画像 persona
### 画像 persona
返回标签数组,例如 `["充填治疗", "重要价值"]`。标签可能滞后于事实;标签是展示文案,不作为业务代码,也不要依赖数组位置判断业务。
每个对象代表一个画像维度,单值、多值统一使用 `values` 数组。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | string | 稳定维度标识,开放集合,如 potential_treatment、rfm、urgency_level |
| `name` | string | 维度展示名称;未登记的维度回退为 key |
| `values` | string[] | 该维度的展示值;维度内去空去重,不跨维度合并 |
```json
{"persona": [
{"key": "potential_treatment", "name": "潜在治疗", "values": ["根管治疗", "充填治疗"]},
{"key": "urgency_level", "name": "急迫等级", "values": ["中"]}
]}
```
按展示顺序返回;无可展示值的维度不返回。画像可能滞后于事实,查询不触发重算。用 `key` 识别维度,兼容新增 key;`name` 和 `values` 是展示文案,不作为业务枚举,也不依赖数组位置判断业务。
### 患者关系 relations
### 患者关系 relations
...
@@ -409,7 +426,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -409,7 +426,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **空结果**:影像可以先返回,分析取得后再补充到对应影像及牙位诊断;分析缺失不能推断检查正常。
- **空结果**:影像可以先返回,分析取得后再补充到对应影像及牙位诊断;分析缺失不能推断检查正常。
- **访问文件**:通过影像的 `fileRefs` 获取原图、缩略图或 CT 切片的临时地址,调用方法见上文。`analysis` 只包含综合结论、描述和病症列表。
- **访问文件**:通过影像的 `fileRefs` 获取原图、缩略图或 CT 切片的临时地址,
原图、缩略图直接使用查询附带的 `url`;CT 切片或地址刷新
调用方法见上文。`analysis` 只包含综合结论、描述和病症列表。
以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。
以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。
...
@@ -614,7 +631,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -614,7 +631,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="image_record">
<details id="image_record">
<summary>影像 · image_record</summary>
<summary>影像 · image_record</summary>
`fileRefs`
是稳定文件引用,访问地址通过文件访问接口获取
。fileLocation 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
`fileRefs`
保留稳定文件引用;原图、缩略图项同时附带临时 `url`、`expiresAt`。CT 切片保留原引用,按需调用文件访问接口
。fileLocation 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
...
@@ -639,6 +656,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -639,6 +656,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| `fileId` | string | 来源内的稳定文件标识;按不透明字符串使用,不是 URL,也不是影像 ID |
| `fileId` | string | 来源内的稳定文件标识;按不透明字符串使用,不是 URL,也不是影像 ID |
| `role` | string | `original` 原文件;`thumbnail` 缩略图;`slice` CT 切片 |
| `role` | string | `original` 原文件;`thumbnail` 缩略图;`slice` CT 切片 |
| `index` | integer | 仅切片提供,从 0 开始的源切片序号;不同于 `fileRefs` 数组下标 |
| `index` | integer | 仅切片提供,从 0 开始的源切片序号;不同于 `fileRefs` 数组下标 |
| `url` | string/null | 原图、缩略图必返,可直接访问的临时 HTTPS 地址;CT 切片不返回此字段 |
| `expiresAt` | string/null | 地址过期时间,UTC ISO 8601;原图、缩略图必返,CT 切片不返回此字段 |
地址未找到、来源未配置或暂不可用、获取超时时,`url` 和 `expiresAt` 为 null,患者事实及分析仍正常返回;需要时调用文件访问接口重试。查询为地址获取最多额外等待约 8 秒,按诊所及来源批量解析,不保证每次查询所有地址均可获得。临时地址不作为长期数据保存。
```json
{"fileRefs": [
{"provider": "meyer", "connectionId": "example", "fileId": "original-id", "role": "original", "url": "https://files.example.com/image.png?signature=temporary", "expiresAt": "2026-10-23T08:00:00.000Z"},
{"provider": "meyer", "connectionId": "example", "fileId": "slice-id", "role": "slice", "index": 0}
]}
```
文件和影像的标识各自独立;分析及诊断仍关联影像 `externalId`。
文件和影像的标识各自独立;分析及诊断仍关联影像 `externalId`。
...
...
apps/pac-docs/openapi/pac.json
View file @
6e099cc1
...
@@ -3331,7 +3331,7 @@
...
@@ -3331,7 +3331,7 @@
},
},
"/pac/v1/open/patient-facts/lookup"
:
{
"/pac/v1/open/patient-facts/lookup"
:
{
"post"
:
{
"post"
:
{
"description"
:
"使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。
\n\n
返回患者数组:`patient`(资料)、`persona`(
画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页
。
\n\n
[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)"
,
"description"
:
"使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。
\n\n
返回患者数组:`patient`(资料)、`persona`(
key/name/values 画像数组)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。影像原图、缩略图的 fileRefs 附带临时 url/expiresAt;CT 切片按需获取
。
\n\n
[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)"
,
"operationId"
:
"PatientFactsLookupController_lookup"
,
"operationId"
:
"PatientFactsLookupController_lookup"
,
"parameters"
:
[],
"parameters"
:
[],
"requestBody"
:
{
"requestBody"
:
{
...
@@ -9502,9 +9502,32 @@
...
@@ -9502,9 +9502,32 @@
"persona"
:
{
"persona"
:
{
"type"
:
"array"
,
"type"
:
"array"
,
"items"
:
{
"items"
:
{
"type"
:
"string"
"type"
:
"object"
,
"properties"
:
{
"key"
:
{
"type"
:
"string"
,
"description"
:
"稳定画像维度标识,开放集合"
},
"name"
:
{
"type"
:
"string"
,
"description"
:
"维度展示名称;未知维度回退为 key"
},
"values"
:
{
"type"
:
"array"
,
"items"
:
{
"type"
:
"string"
},
"description"
:
"该维度的展示值,单值也用数组"
}
},
"required"
:
[
"key"
,
"name"
,
"values"
],
"additionalProperties"
:
false
},
},
"description"
:
"当前已保存画像
的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断
"
"description"
:
"当前已保存画像
,按维度分组及展示顺序返回;维度内去空去重,不跨维度合并;无可展示值为 [],查询不重算。name 和 values 是展示文案,不是业务枚举
"
},
},
"relations"
:
{
"relations"
:
{
"type"
:
"array"
,
"type"
:
"array"
,
...
@@ -10491,6 +10514,17 @@
...
@@ -10491,6 +10514,17 @@
"type"
:
"integer"
,
"type"
:
"integer"
,
"minimum"
:
0
,
"minimum"
:
0
,
"maximum"
:
9007199254740991
"maximum"
:
9007199254740991
},
"url"
:
{
"type"
:
"string"
,
"format"
:
"uri"
,
"nullable"
:
true
},
"expiresAt"
:
{
"type"
:
"string"
,
"format"
:
"date-time"
,
"pattern"
:
"^(?:(?:
\\
d
\\
d[2468][048]|
\\
d
\\
d[13579][26]|
\\
d
\\
d0[48]|[02468][048]00|[13579][26]00)-02-29|
\\
d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]
\\
d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]
\\
d|30)|(?:02)-(?:0[1-9]|1
\\
d|2[0-8])))T(?:(?:[01]
\\
d|2[0-3]):[0-5]
\\
d(?::[0-5]
\\
d(?:
\\
.
\\
d+)?)?(?:Z))$"
,
"nullable"
:
true
}
}
},
},
"required"
:
[
"required"
:
[
...
...
apps/pac-service/src/modules/open/lookup-content.schema.ts
View file @
6e099cc1
import
{
z
}
from
'zod'
;
import
{
z
}
from
'zod'
;
import
{
ImageFileRefsSchema
}
from
'@pac/types'
;
import
{
ImageFileRefSchema
}
from
'@pac/types'
;
// 仅开放查询增加临时地址;不写入事实,不改变源文件引用。
export
const
LookupFileRefSchema
=
ImageFileRefSchema
.
safeExtend
({
url
:
z
.
string
().
url
().
nullable
().
optional
(),
expiresAt
:
z
.
iso
.
datetime
().
nullable
().
optional
(),
}).
superRefine
((
ref
,
ctx
)
=>
{
if
(
ref
.
role
===
'slice'
&&
(
ref
.
url
!==
undefined
||
ref
.
expiresAt
!==
undefined
))
ctx
.
addIssue
({
code
:
'custom'
,
message
:
'slice URLs are resolved on demand'
});
});
// 新接口专属白名单;不复用入库 passthrough,内部增加字段不会自动扩大公开契约。
// 新接口专属白名单;不复用入库 passthrough,内部增加字段不会自动扩大公开契约。
const
s
=
z
.
string
().
nullable
().
optional
();
const
s
=
z
.
string
().
nullable
().
optional
();
...
@@ -149,7 +158,7 @@ export const LookupContentSchemas = {
...
@@ -149,7 +158,7 @@ export const LookupContentSchemas = {
finding
:
s
,
finding
:
s
,
capturedAt
:
s
,
capturedAt
:
s
,
fileLocation
:
s
,
fileLocation
:
s
,
fileRefs
:
ImageFileRefsSchema
.
nullable
().
optional
(),
fileRefs
:
z
.
array
(
LookupFileRefSchema
).
max
(
10000
)
.
nullable
().
optional
(),
fileName
:
s
,
fileName
:
s
,
fileType
:
s
,
fileType
:
s
,
analysis
:
LookupAnalysisSchema
.
nullable
(),
analysis
:
LookupAnalysisSchema
.
nullable
(),
...
...
apps/pac-service/src/modules/open/lookup-image-access.service.ts
0 → 100644
View file @
6e099cc1
import
{
Inject
,
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
type
{
ImageFileRef
}
from
'@pac/types'
;
import
type
{
PatientFactsLookupItem
}
from
'./patient-facts-lookup.schema'
;
import
{
IMAGE_FILE_PROVIDERS
,
type
ImageFileContext
,
type
ImageFileProvider
}
from
'./image-file-provider'
;
export
const
LOOKUP_IMAGE_ACCESS_BUDGET_MS
=
8000
;
type
File
=
ImageFileRef
&
{
url
?:
string
|
null
;
expiresAt
?:
string
|
null
};
type
Group
=
{
context
:
ImageFileContext
;
provider
:
ImageFileProvider
;
files
:
Map
<
string
,
File
[]
>
};
/** 查询附带原图/缩略图授权;只使用已授权事实快照中的引用,不改变事实与 CT 切片。 */
@
Injectable
()
export
class
LookupImageAccessService
{
private
readonly
logger
=
new
Logger
(
LookupImageAccessService
.
name
);
constructor
(@
Inject
(
IMAGE_FILE_PROVIDERS
)
private
readonly
providers
:
ImageFileProvider
[])
{}
async
enrich
(
items
:
PatientFactsLookupItem
[],
contexts
:
ReadonlyMap
<
string
,
ImageFileContext
>
):
Promise
<
void
>
{
const
groups
=
new
Map
<
string
,
Group
>
();
const
outputs
:
File
[]
=
[];
for
(
const
item
of
items
)
for
(
const
fact
of
item
.
facts
)
{
if
(
fact
.
type
!==
'image_record'
)
continue
;
const
context
=
contexts
.
get
(
fact
.
id
);
for
(
const
ref
of
fact
.
content
.
fileRefs
??
[])
{
if
(
ref
.
role
===
'slice'
)
continue
;
ref
.
url
=
null
;
ref
.
expiresAt
=
null
;
outputs
.
push
(
ref
);
const
provider
=
this
.
providers
.
find
(
p
=>
p
.
provider
===
ref
.
provider
);
if
(
!
context
||
!
provider
)
continue
;
// 患者已经过宿主与身份域校验;跨诊所、品牌、连接的文件绝不能混批。
const
key
=
JSON
.
stringify
([
context
.
hostId
,
context
.
tenantId
,
context
.
sourceUnit
,
context
.
clinicId
,
ref
.
provider
,
ref
.
connectionId
??
''
]);
let
group
=
groups
.
get
(
key
);
if
(
!
group
)
{
group
=
{
context
,
provider
,
files
:
new
Map
()
};
groups
.
set
(
key
,
group
);
}
const
refs
=
group
.
files
.
get
(
ref
.
fileId
)
??
[];
refs
.
push
(
ref
);
group
.
files
.
set
(
ref
.
fileId
,
refs
);
}
}
const
batches
:
Array
<
{
group
:
Group
;
ids
:
string
[]
}
>
=
[];
for
(
const
group
of
groups
.
values
())
{
const
ids
=
[...
group
.
files
.
keys
()];
for
(
let
i
=
0
;
i
<
ids
.
length
;
i
+=
100
)
batches
.
push
({
group
,
ids
:
ids
.
slice
(
i
,
i
+
100
)
});
}
if
(
!
batches
.
length
)
return
;
let
next
=
0
,
closed
=
false
,
failures
=
0
;
const
deadline
=
Date
.
now
()
+
LOOKUP_IMAGE_ACCESS_BUDGET_MS
;
const
worker
=
async
()
=>
{
while
(
!
closed
&&
Date
.
now
()
<
deadline
&&
next
<
batches
.
length
)
{
const
{
group
,
ids
}
=
batches
[
next
++
]
!
;
// 临时地址不能传回稳定引用接口。
const
refs
=
ids
.
map
(
id
=>
{
const
{
url
:
_url
,
expiresAt
:
_expiry
,
...
ref
}
=
group
.
files
.
get
(
id
)
!
[
0
]
!
;
return
ref
;
});
try
{
const
result
=
await
group
.
provider
.
resolve
(
group
.
context
,
refs
);
if
(
closed
||
Date
.
now
()
>=
deadline
)
return
;
if
(
result
.
length
!==
ids
.
length
||
result
.
some
((
r
,
i
)
=>
r
.
fileId
!==
ids
[
i
]))
throw
new
Error
(
'File response mismatch'
);
for
(
const
access
of
result
)
{
if
(
!
access
.
found
||
!
access
.
url
||
!
access
.
expiresAt
)
continue
;
const
expiry
=
Date
.
parse
(
access
.
expiresAt
);
if
(
!
Number
.
isFinite
(
expiry
)
||
expiry
<=
Date
.
now
()
+
5000
||
new
URL
(
access
.
url
).
protocol
!==
'https:'
)
continue
;
for
(
const
ref
of
group
.
files
.
get
(
access
.
fileId
)
!
)
{
ref
.
url
=
access
.
url
;
ref
.
expiresAt
=
new
Date
(
expiry
).
toISOString
();
}
}
}
catch
{
failures
++
;
}
}
};
let
timer
:
ReturnType
<
typeof
setTimeout
>
|
undefined
;
try
{
await
Promise
.
race
([
Promise
.
all
(
Array
.
from
({
length
:
Math
.
min
(
2
,
batches
.
length
)
},
worker
)),
new
Promise
<
void
>
(
resolve
=>
{
timer
=
setTimeout
(
resolve
,
LOOKUP_IMAGE_ACCESS_BUDGET_MS
);
}),
]);
}
finally
{
// 已发出的供应商请求由其自身超时收尾;至多 2 个,⛔ 超时后继续排队或修改返回对象。
closed
=
true
;
if
(
timer
)
clearTimeout
(
timer
);
}
for
(
const
ref
of
outputs
)
{
if
(
ref
.
expiresAt
&&
Date
.
parse
(
ref
.
expiresAt
)
<=
Date
.
now
()
+
5000
)
{
ref
.
url
=
null
;
ref
.
expiresAt
=
null
;
}
}
if
(
failures
||
outputs
.
some
(
ref
=>
!
ref
.
url
))
this
.
logger
.
warn
(
JSON
.
stringify
({
event
:
'open_lookup_image_access_partial'
,
requestedFiles
:
outputs
.
length
,
resolvedFiles
:
outputs
.
filter
(
ref
=>
ref
.
url
).
length
,
failedBatches
:
failures
,
}));
}
}
apps/pac-service/src/modules/open/open-persona.ts
View file @
6e099cc1
import
{
personaFeatureSortKey
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
PERSONA_FEATURE_SPECS
,
personaFeatureSortKey
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
redactText
}
from
'./open-redact'
;
interface
PersonaDisplayFeature
{
interface
PersonaDisplayFeature
{
...
@@ -10,21 +10,28 @@ interface PersonaDisplayFeature {
...
@@ -10,21 +10,28 @@ interface PersonaDisplayFeature {
/** 读取已存画像,展开全部展示标签;不触发计算,也不按工作台首屏白名单截断。 */
/** 读取已存画像,展开全部展示标签;不触发计算,也不按工作台首屏白名单截断。 */
export
function
toOpenPersona
(
export
function
toOpenPersona
(
features
:
readonly
PersonaDisplayFeature
[]
|
null
|
undefined
,
features
:
readonly
PersonaDisplayFeature
[]
|
null
|
undefined
,
):
string
[]
{
):
Array
<
{
key
:
string
;
name
:
string
;
values
:
string
[]
}
>
{
const
ordered
=
[...(
features
??
[])].
sort
((
a
,
b
)
=>
{
const
ordered
=
[...(
features
??
[])].
sort
((
a
,
b
)
=>
{
const
[
ga
,
oa
]
=
personaFeatureSortKey
(
a
.
key
);
const
[
ga
,
oa
]
=
personaFeatureSortKey
(
a
.
key
);
const
[
gb
,
ob
]
=
personaFeatureSortKey
(
b
.
key
);
const
[
gb
,
ob
]
=
personaFeatureSortKey
(
b
.
key
);
return
ga
-
gb
||
oa
-
ob
||
a
.
key
.
localeCompare
(
b
.
key
);
return
ga
-
gb
||
oa
-
ob
||
a
.
key
.
localeCompare
(
b
.
key
);
});
});
const
tags
=
new
Set
<
string
>
();
const
tags
=
new
Map
<
string
,
{
key
:
string
;
name
:
string
;
values
:
string
[]
}
>
();
for
(
const
feature
of
ordered
)
{
for
(
const
feature
of
ordered
)
{
const
labels
=
personaValueLabels
(
feature
.
data
,
feature
.
key
)
??
[
const
labels
=
personaValueLabels
(
feature
.
data
,
feature
.
key
)
??
[
shortPersonaValueLabel
(
feature
.
description
),
shortPersonaValueLabel
(
feature
.
description
),
];
];
for
(
const
label
of
labels
)
{
for
(
const
label
of
labels
)
{
const
text
=
redactText
(
label
)?.
trim
();
const
text
=
redactText
(
label
)?.
trim
();
if
(
text
&&
text
!==
'—'
)
tags
.
add
(
text
);
if
(
text
&&
text
!==
'—'
)
{
let
group
=
tags
.
get
(
feature
.
key
);
if
(
!
group
)
{
group
=
{
key
:
feature
.
key
,
name
:
PERSONA_FEATURE_SPECS
[
feature
.
key
]?.
nameZh
??
feature
.
key
,
values
:
[]
};
tags
.
set
(
feature
.
key
,
group
);
}
if
(
!
group
.
values
.
includes
(
text
))
group
.
values
.
push
(
text
);
}
}
}
}
}
return
[...
tags
];
return
[...
tags
.
values
()
];
}
}
apps/pac-service/src/modules/open/open.module.ts
View file @
6e099cc1
...
@@ -2,6 +2,7 @@ import { ApplicationTokenGuard } from './application-token.guard';
...
@@ -2,6 +2,7 @@ import { ApplicationTokenGuard } from './application-token.guard';
import
{
PatientFactsLookupController
}
from
'./patient-facts-lookup.controller'
;
import
{
PatientFactsLookupController
}
from
'./patient-facts-lookup.controller'
;
import
{
PatientFactsLookupService
}
from
'./patient-facts-lookup.service'
;
import
{
PatientFactsLookupService
}
from
'./patient-facts-lookup.service'
;
import
{
Module
}
from
'@nestjs/common'
;
import
{
Module
}
from
'@nestjs/common'
;
import
{
LookupImageAccessService
}
from
'./lookup-image-access.service'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
PatientModule
}
from
'../patient/patient.module'
;
import
{
PatientModule
}
from
'../patient/patient.module'
;
import
{
HmacVerifier
}
from
'../sync/push/hmac-verifier.service'
;
import
{
HmacVerifier
}
from
'../sync/push/hmac-verifier.service'
;
...
@@ -27,7 +28,7 @@ import { MeyerImageFileProvider } from '../../integrations/meyer/image-file-prov
...
@@ -27,7 +28,7 @@ import { MeyerImageFileProvider } from '../../integrations/meyer/image-file-prov
@
Module
({
@
Module
({
imports
:
[
AuthModule
,
PatientModule
],
imports
:
[
AuthModule
,
PatientModule
],
controllers
:
[
PatientFactsLookupController
,
ImageFilesController
,
OpenPatientsController
,
OpenOrgController
,
OpenIdentityController
],
controllers
:
[
PatientFactsLookupController
,
ImageFilesController
,
OpenPatientsController
,
OpenOrgController
,
OpenIdentityController
],
providers
:
[
ApplicationTokenGuard
,
PatientFactsLookupService
,
ImageFilesService
,
MeyerImageFileProvider
,
providers
:
[
ApplicationTokenGuard
,
PatientFactsLookupService
,
LookupImageAccessService
,
ImageFilesService
,
MeyerImageFileProvider
,
{
provide
:
IMAGE_FILE_PROVIDERS
,
useFactory
:
(
meyer
:
MeyerImageFileProvider
)
=>
[
meyer
],
inject
:
[
MeyerImageFileProvider
]
},
{
provide
:
IMAGE_FILE_PROVIDERS
,
useFactory
:
(
meyer
:
MeyerImageFileProvider
)
=>
[
meyer
],
inject
:
[
MeyerImageFileProvider
]
},
HmacVerifier
,
OpenProfileService
,
OpenAppointmentService
,
OpenFactGate
,
OpenFactWindowService
,],
HmacVerifier
,
OpenProfileService
,
OpenAppointmentService
,
OpenFactGate
,
OpenFactWindowService
,],
})
})
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
View file @
6e099cc1
import
{
Body
,
Controller
,
HttpCode
,
Post
,
Req
,
UseGuards
}
from
'@nestjs/common'
;
import
{
Body
,
Controller
,
H
eader
,
H
ttpCode
,
Post
,
Req
,
UseGuards
}
from
'@nestjs/common'
;
import
{
ApiBearerAuth
,
ApiOperation
,
ApiTags
}
from
'@nestjs/swagger'
;
import
{
ApiBearerAuth
,
ApiOperation
,
ApiTags
}
from
'@nestjs/swagger'
;
import
{
ZodResponse
}
from
'nestjs-zod'
;
import
{
ZodResponse
}
from
'nestjs-zod'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
...
@@ -17,6 +17,7 @@ export class PatientFactsLookupController {
...
@@ -17,6 +17,7 @@ export class PatientFactsLookupController {
@
Post
(
'lookup'
)
@
Post
(
'lookup'
)
@
HttpCode
(
200
)
@
HttpCode
(
200
)
@
Header
(
'Cache-Control'
,
'private, no-store'
)
@
ZodResponse
({
status
:
200
,
type
:
PatientFactsLookupResponseDto
})
@
ZodResponse
({
status
:
200
,
type
:
PatientFactsLookupResponseDto
})
@
Public
()
@
Public
()
@
UseGuards
(
ApplicationTokenGuard
)
@
UseGuards
(
ApplicationTokenGuard
)
...
@@ -24,7 +25,7 @@ export class PatientFactsLookupController {
...
@@ -24,7 +25,7 @@ export class PatientFactsLookupController {
summary
:
'查询患者资料与事实'
,
summary
:
'查询患者资料与事实'
,
description
:
[
description
:
[
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'返回患者数组:`patient`(资料)、`persona`(
画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页
。'
,
'返回患者数组:`patient`(资料)、`persona`(
key/name/values 画像数组)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。影像原图、缩略图的 fileRefs 附带临时 url/expiresAt;CT 切片按需获取
。'
,
'[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)'
,
'[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)'
,
].
join
(
'
\
n
\
n'
),
].
join
(
'
\
n
\
n'
),
})
})
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
View file @
6e099cc1
...
@@ -188,9 +188,13 @@ export const PatientFactsLookupResponseSchema = z.object({
...
@@ -188,9 +188,13 @@ export const PatientFactsLookupResponseSchema = z.object({
.
describe
(
'获客二级渠道,保留宿主原值;未提供时为 null'
),
.
describe
(
'获客二级渠道,保留宿主原值;未提供时为 null'
),
}),
}),
persona
:
z
persona
:
z
.
array
(
z
.
string
())
.
array
(
z
.
object
({
key
:
z
.
string
().
describe
(
'稳定画像维度标识,开放集合'
),
name
:
z
.
string
().
describe
(
'维度展示名称;未知维度回退为 key'
),
values
:
z
.
array
(
z
.
string
()).
describe
(
'该维度的展示值,单值也用数组'
),
}).
strict
())
.
describe
(
.
describe
(
'当前已保存画像
的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断
'
,
'当前已保存画像
,按维度分组及展示顺序返回;维度内去空去重,不跨维度合并;无可展示值为 [],查询不重算。name 和 values 是展示文案,不是业务枚举
'
,
),
),
relations
:
z
.
array
(
relations
:
z
.
array
(
z
.
object
({
z
.
object
({
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
View file @
6e099cc1
...
@@ -7,6 +7,8 @@ import { LookupContentError } from './lookup-narrative';
...
@@ -7,6 +7,8 @@ import { LookupContentError } from './lookup-narrative';
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
import
{
LookupImageAccessService
}
from
'./lookup-image-access.service'
;
import
type
{
ImageFileContext
}
from
'./image-file-provider'
;
import
{
import
{
PatientFactsLookupSchema
,
PatientFactsLookupSchema
,
type
PatientFactsLookupRequest
,
type
PatientFactsLookupRequest
,
...
@@ -17,6 +19,8 @@ import type { ApplicationPrincipal } from './application-token.guard';
...
@@ -17,6 +19,8 @@ import type { ApplicationPrincipal } from './application-token.guard';
type
LookupRow
=
OpenFactRow
&
{
type
LookupRow
=
OpenFactRow
&
{
patient_id
:
string
;
patient_id
:
string
;
host_name
:
string
;
host_name
:
string
;
tenant_id
:
string
;
source_unit
:
string
;
fact_id
:
string
|
null
;
fact_id
:
string
|
null
;
appointment_created_values
?:
unknown
;
appointment_created_values
?:
unknown
;
source_evidence
?:
unknown
;
source_evidence
?:
unknown
;
...
@@ -73,7 +77,7 @@ function dedicatedCsOf(
...
@@ -73,7 +77,7 @@ function dedicatedCsOf(
@
Injectable
()
@
Injectable
()
export
class
PatientFactsLookupService
{
export
class
PatientFactsLookupService
{
private
readonly
logger
=
new
Logger
(
PatientFactsLookupService
.
name
);
private
readonly
logger
=
new
Logger
(
PatientFactsLookupService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
imageAccess
:
LookupImageAccessService
)
{}
async
lookup
(
async
lookup
(
application
:
ApplicationPrincipal
,
application
:
ApplicationPrincipal
,
...
@@ -81,13 +85,14 @@ export class PatientFactsLookupService {
...
@@ -81,13 +85,14 @@ export class PatientFactsLookupService {
):
Promise
<
{
items
:
LookupItem
[]
}
>
{
):
Promise
<
{
items
:
LookupItem
[]
}
>
{
const
startedAt
=
Date
.
now
();
const
startedAt
=
Date
.
now
();
const
q
=
PatientFactsLookupSchema
.
parse
(
input
);
const
q
=
PatientFactsLookupSchema
.
parse
(
input
);
const
imageContexts
=
new
Map
<
string
,
ImageFileContext
>
();
// 多表读取使用同一可重复读快照;⛔ 不在查询接口触发画像重算。
// 多表读取使用同一可重复读快照;⛔ 不在查询接口触发画像重算。
const
items
=
await
this
.
prisma
.
$transaction
(
const
items
=
await
this
.
prisma
.
$transaction
(
async
(
tx
)
=>
{
async
(
tx
)
=>
{
// 不设 LIMIT,不静默截断;以 PAC 患者主键分组,⛔ 不按姓名/电话/病历号合并不同人。
// 不设 LIMIT,不静默截断;以 PAC 患者主键分组,⛔ 不按姓名/电话/病历号合并不同人。
// 预约创建时间只查本版本绑定的证据主键,⛔ 不按姓名或预约号跨患者补配、不回扫账本。
// 预约创建时间只查本版本绑定的证据主键,⛔ 不按姓名或预约号跨患者补配、不回扫账本。
const
rows
=
await
tx
.
$queryRaw
<
LookupRow
[]
>
(
Prisma
.
sql
`
const
rows
=
await
tx
.
$queryRaw
<
LookupRow
[]
>
(
Prisma
.
sql
`
SELECT p.id AS patient_id, h.name AS host_name,
SELECT p.id AS patient_id, h.name AS host_name,
p.tenant_id, p.source_unit,
f.id AS fact_id, f.subject_id, f.type, f.kind, f.status, f.clinic_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,
f.occurred_at, f.planned_for, f.valid_until, f.title, f.summary, f.content,
CASE WHEN f.type = 'appointment_record' AND h.name IN ('friday', 'jvs-dw') THEN (
CASE WHEN f.type = 'appointment_record' AND h.name IN ('friday', 'jvs-dw') THEN (
...
@@ -231,6 +236,11 @@ export class PatientFactsLookupService {
...
@@ -231,6 +236,11 @@ export class PatientFactsLookupService {
throw
error
;
throw
error
;
}
}
}
}
for
(
const
row
of
rows
)
{
if
(
row
.
fact_id
&&
row
.
type
===
'image_record'
&&
row
.
clinic_id
)
imageContexts
.
set
(
row
.
fact_id
,
{
hostId
:
application
.
hostId
,
hostName
:
row
.
host_name
,
tenantId
:
row
.
tenant_id
,
sourceUnit
:
row
.
source_unit
,
clinicId
:
row
.
clinic_id
});
}
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
for
(
const
item
of
items
.
values
())
for
(
const
item
of
items
.
values
())
item
.
facts
.
sort
((
a
,
b
)
=>
{
item
.
facts
.
sort
((
a
,
b
)
=>
{
...
@@ -245,6 +255,8 @@ export class PatientFactsLookupService {
...
@@ -245,6 +255,8 @@ export class PatientFactsLookupService {
},
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
);
);
// 外部文件授权在数据库快照结束后执行,⛔ 不占着事务等待供应商。
await
this
.
imageAccess
.
enrich
([...
items
.
values
()],
imageContexts
);
// 审计仅记录调用应用、条件字段和数量,⛔ 不记录查询值、令牌或患者正文。
// 审计仅记录调用应用、条件字段和数量,⛔ 不记录查询值、令牌或患者正文。
this
.
logger
.
log
(
this
.
logger
.
log
(
JSON
.
stringify
({
JSON
.
stringify
({
...
...
apps/pac-service/tests/lookup-contract-postgres.spec.ts
View file @
6e099cc1
...
@@ -57,7 +57,7 @@ describePg('lookup contract | isolated PostgreSQL and real HTTP', () => {
...
@@ -57,7 +57,7 @@ describePg('lookup contract | isolated PostgreSQL and real HTTP', () => {
},
},
})
})
).
id
;
).
id
;
service
=
new
PatientFactsLookupService
(
db
as
any
);
service
=
new
PatientFactsLookupService
(
db
as
any
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
);
});
});
beforeEach
(
async
()
=>
{
beforeEach
(
async
()
=>
{
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
...
...
apps/pac-service/tests/lookup-image-access.spec.ts
0 → 100644
View file @
6e099cc1
import
{
LookupImageAccessService
,
LOOKUP_IMAGE_ACCESS_BUDGET_MS
}
from
'../src/modules/open/lookup-image-access.service'
;
import
{
LookupFileRefSchema
}
from
'../src/modules/open/lookup-content.schema'
;
const
context
=
{
hostId
:
'h'
,
hostName
:
'friday'
,
tenantId
:
't'
,
sourceUnit
:
'b'
,
clinicId
:
'c'
};
const
ref
=
(
fileId
:
string
,
role
=
'original'
,
extra
=
{})
=>
({
provider
:
'meyer'
,
connectionId
:
'test'
,
fileId
,
role
,
...
extra
});
const
item
=
(
refs
:
any
[],
id
=
'f'
)
=>
({
facts
:
[{
id
,
type
:
'image_record'
,
content
:
{
fileRefs
:
refs
,
analysis
:
{
source
:
'meyer'
}
}
}]
})
as
any
;
const
access
=
(
refs
:
any
[])
=>
refs
.
map
(
r
=>
({
fileId
:
r
.
fileId
,
found
:
true
,
url
:
`https://example.com/
${
r
.
fileId
}
`
,
expiresAt
:
new
Date
(
Date
.
now
()
+
60000
).
toISOString
()
}));
describe
(
'lookup inline image access'
,
()
=>
{
it
(
'batches originals and thumbnails, deduplicates files, leaves CT slices untouched'
,
async
()
=>
{
const
resolve
=
jest
.
fn
(
async
(
_c
,
refs
)
=>
access
(
refs
));
const
items
=
[
item
([
ref
(
'a'
),
ref
(
'b'
,
'thumbnail'
),
ref
(
's'
,
'slice'
,
{
index
:
0
})]),
item
([
ref
(
'a'
)],
'f2'
)];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
],
[
'f2'
,
context
]]));
expect
(
resolve
).
toHaveBeenCalledTimes
(
1
);
expect
(
resolve
.
mock
.
calls
[
0
]
!
[
1
].
map
((
r
:
any
)
=>
r
.
fileId
)).
toEqual
([
'a'
,
'b'
]);
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
).
toBe
(
'https://example.com/a'
);
expect
(
items
[
1
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
).
toBe
(
'https://example.com/a'
);
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
2
]).
toEqual
(
ref
(
's'
,
'slice'
,
{
index
:
0
}));
for
(
const
f
of
items
.
flatMap
(
i
=>
i
.
facts
))
for
(
const
r
of
f
.
content
.
fileRefs
)
expect
(
LookupFileRefSchema
.
safeParse
(
r
).
success
).
toBe
(
true
);
});
it
(
'never combines different clinics, brands or connections, and sends at most 100 IDs'
,
async
()
=>
{
const
resolve
=
jest
.
fn
(
async
(
_c
,
refs
)
=>
access
(
refs
));
const
items
=
[
item
(
Array
.
from
({
length
:
205
},
(
_
,
i
)
=>
ref
(
String
(
i
)))),
item
([
ref
(
'same'
)],
'other'
),
item
([
ref
(
'same'
,
'original'
,
{
connectionId
:
'other'
})],
'conn'
),
item
([
ref
(
'same'
)],
'brand'
)];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
],
[
'other'
,
{
...
context
,
clinicId
:
'other'
}],
[
'conn'
,
context
],
[
'brand'
,
{
...
context
,
sourceUnit
:
'other'
}]]));
expect
(
resolve
).
toHaveBeenCalledTimes
(
6
);
expect
(
resolve
.
mock
.
calls
.
every
(([,
refs
])
=>
refs
.
length
<=
100
)).
toBe
(
true
);
expect
(
resolve
.
mock
.
calls
.
some
(([
ctx
])
=>
ctx
.
clinicId
===
'other'
)).
toBe
(
true
);
expect
(
resolve
.
mock
.
calls
.
some
(([
ctx
])
=>
ctx
.
sourceUnit
===
'other'
)).
toBe
(
true
);
});
it
(
'preserves facts with null URLs for missing, unsupported, failed or mismatched results'
,
async
()
=>
{
const
cases
=
[
jest
.
fn
().
mockRejectedValue
(
new
Error
(
'offline'
)),
jest
.
fn
(
async
(
_c
,
refs
)
=>
refs
.
map
((
r
:
any
)
=>
({
fileId
:
r
.
fileId
,
found
:
false
,
url
:
null
,
expiresAt
:
null
}))),
jest
.
fn
(
async
()
=>
access
([{
fileId
:
'wrong'
}]))];
for
(
const
resolve
of
cases
)
{
const
items
=
[
item
([
ref
(
'a'
)])];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
]]));
expect
(
items
[
0
].
facts
[
0
].
content
).
toMatchObject
({
analysis
:
{
source
:
'meyer'
},
fileRefs
:
[{
url
:
null
,
expiresAt
:
null
}]
});
}
const
resolve
=
jest
.
fn
();
const
items
=
[
item
([
ref
(
'a'
)])];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
());
expect
(
resolve
).
not
.
toHaveBeenCalled
();
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
).
toBeNull
();
});
it
(
'rejects expired or insecure addresses without failing the query'
,
async
()
=>
{
const
resolve
=
jest
.
fn
(
async
(
_c
,
refs
)
=>
access
(
refs
).
map
((
r
,
i
)
=>
i
===
0
?
{
...
r
,
expiresAt
:
new
Date
(
Date
.
now
()
-
1
).
toISOString
()
}
:
{
...
r
,
url
:
'http://example.com/a'
}));
const
items
=
[
item
([
ref
(
'a'
),
ref
(
'b'
)])];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
]]));
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
.
every
((
r
:
any
)
=>
r
.
url
===
null
)).
toBe
(
true
);
});
it
(
'bounds lookup latency and concurrency, and never mutates a response after timeout'
,
async
()
=>
{
jest
.
useFakeTimers
();
try
{
const
pending
:
Array
<
()
=>
void
>
=
[];
const
resolve
=
jest
.
fn
((
_c
,
refs
)
=>
new
Promise
<
any
[]
>
(
done
=>
pending
.
push
(()
=>
done
(
access
(
refs
)))));
const
items
=
[
item
(
Array
.
from
({
length
:
301
},
(
_
,
i
)
=>
ref
(
String
(
i
))))];
const
run
=
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
]]));
expect
(
resolve
).
toHaveBeenCalledTimes
(
2
);
await
jest
.
advanceTimersByTimeAsync
(
LOOKUP_IMAGE_ACCESS_BUDGET_MS
);
await
run
;
const
snapshot
=
JSON
.
stringify
(
items
);
pending
.
forEach
(
done
=>
done
());
await
Promise
.
resolve
();
await
Promise
.
resolve
();
expect
(
resolve
).
toHaveBeenCalledTimes
(
2
);
expect
(
JSON
.
stringify
(
items
)).
toBe
(
snapshot
);
}
finally
{
jest
.
useRealTimers
();
}
});
it
(
'disallows inline CT URLs in the response schema'
,
()
=>
{
expect
(
LookupFileRefSchema
.
safeParse
({
...
ref
(
's'
,
'slice'
,
{
index
:
0
}),
url
:
null
}).
success
).
toBe
(
false
);
});
});
apps/pac-service/tests/open-patient-enum-doc.spec.ts
View file @
6e099cc1
...
@@ -82,7 +82,8 @@ describe('open patient enum documentation', () => {
...
@@ -82,7 +82,8 @@ describe('open patient enum documentation', () => {
expect
(
PatientFactsLookupResponseSchema
.
safeParse
(
response
.
data
).
success
).
toBe
(
true
);
expect
(
PatientFactsLookupResponseSchema
.
safeParse
(
response
.
data
).
success
).
toBe
(
true
);
const
persona
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
persona
;
const
persona
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
persona
;
expect
(
persona
.
safeParse
([]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([
'充填治疗'
]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([{
key
:
'potential_treatment'
,
name
:
'潜在治疗'
,
values
:
[
'充填治疗'
]
}]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([
'充填治疗'
]).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
(
null
).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
(
null
).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
({
features
:
[]
}).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
({
features
:
[]
}).
success
).
toBe
(
false
);
});
});
...
...
apps/pac-service/tests/open-persona.spec.ts
View file @
6e099cc1
import
{
compactPersonaValue
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
PERSONA_FEATURE_SPECS
,
compactPersonaValue
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
toOpenPersona
}
from
'../src/modules/open/open-persona'
;
import
{
toOpenPersona
}
from
'../src/modules/open/open-persona'
;
const
f
=
(
key
:
string
,
description
:
string
|
null
,
data
:
unknown
=
null
)
=>
({
const
f
=
(
key
:
string
,
description
:
string
|
null
,
data
:
unknown
=
null
)
=>
({
...
@@ -7,6 +7,8 @@ const f = (key: string, description: string | null, data: unknown = null) => ({
...
@@ -7,6 +7,8 @@ const f = (key: string, description: string | null, data: unknown = null) => ({
data
,
data
,
});
});
const
group
=
(
key
:
string
,
values
:
string
[])
=>
({
key
,
name
:
PERSONA_FEATURE_SPECS
[
key
]?.
nameZh
??
key
,
values
});
describe
(
'open persona display tags'
,
()
=>
{
describe
(
'open persona display tags'
,
()
=>
{
it
(
'returns arrays for absent and empty personas'
,
()
=>
{
it
(
'returns arrays for absent and empty personas'
,
()
=>
{
expect
(
toOpenPersona
(
null
)).
toEqual
([]);
expect
(
toOpenPersona
(
null
)).
toEqual
([]);
...
@@ -17,8 +19,7 @@ describe('open persona display tags', () => {
...
@@ -17,8 +19,7 @@ describe('open persona display tags', () => {
it
(
'uses current potential-treatment labels and expands all values'
,
()
=>
{
it
(
'uses current potential-treatment labels and expands all values'
,
()
=>
{
const
data
=
{
types
:
[
'filling'
,
'endo'
],
labels
:
[
'潜在补牙'
,
'旧文案'
]
};
const
data
=
{
types
:
[
'filling'
,
'endo'
],
labels
:
[
'潜在补牙'
,
'旧文案'
]
};
expect
(
toOpenPersona
([
f
(
'potential_treatment'
,
'旧描述'
,
data
)])).
toEqual
([
expect
(
toOpenPersona
([
f
(
'potential_treatment'
,
'旧描述'
,
data
)])).
toEqual
([
'充填治疗'
,
group
(
'potential_treatment'
,
[
'充填治疗'
,
'根管治疗'
]),
'根管治疗'
,
]);
]);
expect
(
personaValueLabels
(
data
,
'potential_treatment'
)).
toEqual
([
'充填治疗'
,
'根管治疗'
]);
expect
(
personaValueLabels
(
data
,
'potential_treatment'
)).
toEqual
([
'充填治疗'
,
'根管治疗'
]);
expect
(
compactPersonaValue
(
'旧描述'
,
data
,
'potential_treatment'
)).
toEqual
({
expect
(
compactPersonaValue
(
'旧描述'
,
data
,
'potential_treatment'
)).
toEqual
({
...
@@ -34,7 +35,7 @@ describe('open persona display tags', () => {
...
@@ -34,7 +35,7 @@ describe('open persona display tags', () => {
f
(
'potential_treatment'
,
'旧'
,
{
types
:
[
'filling'
]
}),
f
(
'potential_treatment'
,
'旧'
,
{
types
:
[
'filling'
]
}),
f
(
'new_a'
,
'新增A'
),
f
(
'new_a'
,
'新增A'
),
];
];
const
expected
=
[
'充填治疗'
,
'重要价值'
,
'成年'
,
'新增A'
,
'新增B'
];
const
expected
=
[
group
(
'potential_treatment'
,
[
'充填治疗'
]),
group
(
'rfm'
,
[
'重要价值'
]),
group
(
'age_bracket'
,
[
'成年'
]),
group
(
'new_a'
,
[
'新增A'
]),
group
(
'new_b'
,
[
'新增B'
])
];
expect
(
toOpenPersona
(
features
)).
toEqual
(
expected
);
expect
(
toOpenPersona
(
features
)).
toEqual
(
expected
);
expect
(
toOpenPersona
([...
features
].
reverse
())).
toEqual
(
expected
);
expect
(
toOpenPersona
([...
features
].
reverse
())).
toEqual
(
expected
);
expect
(
features
[
0
]
!
.
key
).
toBe
(
'age_bracket'
);
// 不改传入数组
expect
(
features
[
0
]
!
.
key
).
toBe
(
'age_bracket'
);
// 不改传入数组
...
@@ -48,14 +49,14 @@ describe('open persona display tags', () => {
...
@@ -48,14 +49,14 @@ describe('open persona display tags', () => {
f
(
'b'
,
'联系13900139000'
),
f
(
'b'
,
'联系13900139000'
),
f
(
'c'
,
'不应回退'
,
{
labels
:
[]
}),
f
(
'c'
,
'不应回退'
,
{
labels
:
[]
}),
]),
]),
).
toEqual
([
'种植/手术禁忌'
,
'联系139****9000'
]);
).
toEqual
([
group
(
'a'
,
[
'种植/手术禁忌'
,
'联系139****9000'
]),
group
(
'b'
,
[
'联系139****9000'
])
]);
});
});
it
(
'uses concise descriptions for single values and legacy data without labels'
,
()
=>
{
it
(
'uses concise descriptions for single values and legacy data without labels'
,
()
=>
{
expect
(
expect
(
toOpenPersona
([
f
(
'rfm'
,
'新客/未消费(说明)'
),
f
(
'age_bracket'
,
'成年(35岁)'
)]),
toOpenPersona
([
f
(
'rfm'
,
'新客/未消费(说明)'
),
f
(
'age_bracket'
,
'成年(35岁)'
)]),
).
toEqual
([
'新客'
,
'成年'
]);
).
toEqual
([
group
(
'rfm'
,
[
'新客'
]),
group
(
'age_bracket'
,
[
'成年'
])
]);
expect
(
shortPersonaValueLabel
(
'重要价值 · 距上次 140 天'
)).
toBe
(
'重要价值'
);
expect
(
shortPersonaValueLabel
(
'重要价值 · 距上次 140 天'
)).
toBe
(
'重要价值'
);
expect
(
shortPersonaValueLabel
(
'成年(35岁)'
)).
toBe
(
'成年'
);
expect
(
shortPersonaValueLabel
(
'成年(35岁)'
)).
toBe
(
'成年'
);
expect
(
toOpenPersona
([
f
(
'x'
,
'有效标签 · 说明'
,
{
labels
:
[
null
]
})])).
toEqual
([
'有效标签'
]);
expect
(
toOpenPersona
([
f
(
'x'
,
'有效标签 · 说明'
,
{
labels
:
[
null
]
})])).
toEqual
([
group
(
'x'
,
[
'有效标签'
])
]);
});
});
});
});
apps/pac-service/tests/patient-facts-lookup.spec.ts
View file @
6e099cc1
...
@@ -77,7 +77,7 @@ describe('patient facts lookup contract', () => {
...
@@ -77,7 +77,7 @@ describe('patient facts lookup contract', () => {
})),
})),
),
),
};
};
const
result
=
await
new
PatientFactsLookupService
(
prisma
).
lookup
(
const
result
=
await
new
PatientFactsLookupService
(
prisma
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
).
lookup
(
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{
c
:
'测试诊所'
}
},
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{
c
:
'测试诊所'
}
},
{
name
:
'同名'
,
phone
:
'13800138000'
},
{
name
:
'同名'
,
phone
:
'13800138000'
},
);
);
...
@@ -158,7 +158,7 @@ describe('patient facts lookup contract', () => {
...
@@ -158,7 +158,7 @@ describe('patient facts lookup contract', () => {
},
},
};
};
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
const
result
=
await
new
PatientFactsLookupService
(
prisma
).
lookup
(
const
result
=
await
new
PatientFactsLookupService
(
prisma
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
).
lookup
(
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
medicalRecordNumber
:
'001'
},
{
medicalRecordNumber
:
'001'
},
);
);
...
@@ -169,9 +169,31 @@ describe('patient facts lookup contract', () => {
...
@@ -169,9 +169,31 @@ describe('patient facts lookup contract', () => {
it
(
'returns empty items for no match'
,
async
()
=>
{
it
(
'returns empty items for no match'
,
async
()
=>
{
const
prisma
:
any
=
{
$queryRaw
:
jest
.
fn
().
mockResolvedValue
([])
};
const
prisma
:
any
=
{
$queryRaw
:
jest
.
fn
().
mockResolvedValue
([])
};
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
const
service
=
new
PatientFactsLookupService
(
prisma
);
const
service
=
new
PatientFactsLookupService
(
prisma
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
);
await
expect
(
await
expect
(
service
.
lookup
({
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
name
:
'nobody'
}),
service
.
lookup
({
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
name
:
'nobody'
}),
).
resolves
.
toEqual
({
items
:
[]
});
).
resolves
.
toEqual
({
items
:
[]
});
});
});
});
});
it
(
'enriches only authorized image snapshots after the database transaction closes'
,
async
()
=>
{
const
factId
=
'11111111-1111-4111-8111-111111111111'
;
const
prisma
:
any
=
{
$queryRaw
:
jest
.
fn
().
mockResolvedValue
([{
patient_id
:
'p'
,
host_name
:
'friday'
,
tenant_id
:
't'
,
source_unit
:
'b'
,
fact_id
:
factId
,
subject_id
:
'image:i'
,
type
:
'image_record'
,
kind
:
'actual'
,
status
:
'active'
,
clinic_id
:
'c'
,
occurred_at
:
null
,
planned_for
:
null
,
content
:
{
modality
:
'pano'
,
file_refs
:
[{
provider
:
'meyer'
,
connectionId
:
'test'
,
fileId
:
'a'
,
role
:
'original'
}]
}
}]),
patient
:
{
findMany
:
jest
.
fn
().
mockResolvedValue
([{
id
:
'p'
,
hostId
:
'h'
,
tenantId
:
't'
,
sourceUnit
:
'b'
,
externalId
:
'p'
,
name
:
null
,
medicalRecordNumber
:
'001'
,
phone
:
null
,
gender
:
null
,
birthDate
:
null
,
personas
:
[{
tenantId
:
't'
,
features
:
[{
key
:
'urgency_level'
,
description
:
'中'
,
data
:
null
}]
}],
relationsOut
:
[]
}])
},
};
let
transactionOpen
=
false
;
prisma
.
$transaction
=
async
(
run
:
any
)
=>
{
transactionOpen
=
true
;
const
result
=
await
run
(
prisma
);
transactionOpen
=
false
;
return
result
;
};
const
enrich
=
jest
.
fn
(
async
(
items
,
contexts
)
=>
{
expect
(
transactionOpen
).
toBe
(
false
);
expect
(
contexts
.
get
(
factId
)).
toEqual
({
hostId
:
'h'
,
hostName
:
'friday'
,
tenantId
:
't'
,
sourceUnit
:
'b'
,
clinicId
:
'c'
});
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
=
'https://example.com/a'
;
});
const
result
=
await
new
PatientFactsLookupService
(
prisma
,
{
enrich
}
as
any
).
lookup
({
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
medicalRecordNumber
:
'001'
});
expect
(
result
.
items
[
0
]
!
.
persona
).
toEqual
([{
key
:
'urgency_level'
,
name
:
'急迫等级'
,
values
:
[
'中'
]
}]);
expect
((
result
.
items
[
0
]
!
.
facts
[
0
]
!
.
content
as
any
).
fileRefs
[
0
].
url
).
toBe
(
'https://example.com/a'
);
expect
(
enrich
).
toHaveBeenCalledTimes
(
1
);
});
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