Commit 6e099cc1 by luoqi

feat(open): group persona dimensions and inline image access URLs

parent 15d08d44
Pipeline #3865 failed in 0 seconds
...@@ -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`。
......
...@@ -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": "object",
"properties": {
"key": {
"type": "string",
"description": "稳定画像维度标识,开放集合"
},
"name": {
"type": "string",
"description": "维度展示名称;未知维度回退为 key"
},
"values": {
"type": "array",
"items": {
"type": "string" "type": "string"
}, },
"description": "当前已保存画像的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断" "description": "该维度的展示值,单值也用数组"
}
},
"required": [
"key",
"name",
"values"
],
"additionalProperties": false
},
"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": [
......
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(),
......
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,
}));
}
}
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()];
} }
...@@ -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,],
}) })
......
import { Body, Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Header, HttpCode, 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'),
}) })
......
...@@ -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({
......
...@@ -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({
......
...@@ -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] } } });
......
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);
});
});
...@@ -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);
}); });
......
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', ['有效标签'])]);
}); });
}); });
...@@ -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);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment