Commit 051fcab8 by luoqi

Merge unified image file references and Meyer file access into test

parents 7d66d551 5d6e909a
Pipeline #3842 failed in 0 seconds
...@@ -62,6 +62,59 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \ ...@@ -62,6 +62,59 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
-d '{"medicalRecordNumber":"000123"}' -d '{"medicalRecordNumber":"000123"}'
``` ```
### 获取影像文件访问地址
`POST /pac/v1/open/image-files/resolve`,使用相同的应用 Bearer 令牌。
从查询结果中取得患者 `patient.id`、影像事实 `facts[].id` 和该影像 `content.fileRefs` 的数组下标。
```json
{
"patientId": "11111111-1111-4111-8111-111111111111",
"imageFactId": "33333333-3333-4333-8333-333333333333",
"fileIndexes": [0, 1]
}
```
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `patientId` | string | PAC 患者 UUID |
| `imageFactId` | string | 当前影像事实版本 UUID,不是源影像 ID |
| `fileIndexes` | integer[] | `fileRefs` 中从 0 开始的下标;每批 1–100 个,不重复 |
成功返回 `data.imageFactId` 与 `data.files`,文件顺序与请求下标一致:
```json
{
"code": 0,
"msg": "ok",
"data": {
"imageFactId": "33333333-3333-4333-8333-333333333333",
"files": [
{
"index": 0,
"reference": {"provider": "meyer", "connectionId": "example", "fileId": "source-file-id", "role": "original"},
"found": true,
"url": "https://files.example.com/image.png?signature=temporary",
"expiresAt": "2026-10-20T14:00:00.000Z"
},
{
"index": 1,
"reference": {"provider": "meyer", "connectionId": "example", "fileId": "source-thumbnail-id", "role": "thumbnail"},
"found": false,
"url": null,
"expiresAt": null
}
]
}
}
```
- 地址仅用于本次访问,以返回的 `expiresAt`(UTC)为准;到期重新请求,不保存为永久原片地址。
- `found=false` 表示来源当前未返回该文件,不表示影像或分析不存在。
- 只支持已配置的文件来源;权限、当前版本或来源校验失败时返回非零 `code`。
- 影像事实版本变化后,重新查询事实和下标。接口不接受任意文件 ID,不修改影像、分析或画像。
## 4. 返回结果 ## 4. 返回结果
以响应体 `code` 判断成功:`0` 成功,其他值按本页“错误处理”处理,不能只看 HTTP 状态。`msg` 为提示字符串,成功数据在 `data` 中。 以响应体 `code` 判断成功:`0` 成功,其他值按本页“错误处理”处理,不能只看 HTTP 状态。`msg` 为提示字符串,成功数据在 `data` 中。
...@@ -345,8 +398,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \ ...@@ -345,8 +398,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
- **时间**:当前美亚路线中,影像的 `occurredAt` 是美亚影像创建时间;诊断的 `occurredAt` 是影像检查时间,均不表示分析完成或采集时间。 - **时间**:当前美亚路线中,影像的 `occurredAt` 是美亚影像创建时间;诊断的 `occurredAt` 是影像检查时间,均不表示分析完成或采集时间。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。 - **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。 - **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **空结果**:分析尚未取得时可能没有对应诊断,不能推断检查正常。当前美亚补充路线在取得有效牙位结果后才一并提供影像与诊断。 - **空结果**:影像可以先返回,分析取得后再补充到对应影像及牙位诊断;分析缺失不能推断检查正常。
- **访问原片**:本示例不提供原片 URL;查看影像仍使用宿主影像入口。`analysis` 返回标准化的综合结论、描述和病症列表,不返回供应商原始响应、OSS ID 或签名凭据。 - **访问文件**:通过影像的 `fileRefs` 获取原图、缩略图或 CT 切片的临时地址,调用方法见上文。`analysis` 只包含综合结论、描述和病症列表。
以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。 以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。
...@@ -523,7 +576,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍 ...@@ -523,7 +576,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="image_record"> <details id="image_record">
<summary>影像 · image_record</summary> <summary>影像 · image_record</summary>
fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。 `fileRefs` 是稳定的文件引用,访问地址通过文件访问接口获取。`fileUrl` 保留宿主原值,可能是相对路径或临时地址;没有文件引用或位置不等于没有影像。
| 字段 | 类型 | 含义 | | 字段 | 类型 | 含义 |
| --- | --- | --- | | --- | --- | --- |
...@@ -536,8 +589,21 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主 ...@@ -536,8 +589,21 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| `capturedAt` | string/null | 拍摄时刻,来源未提供时为空;通常省略;未被外层承接或有差异时保留 | | `capturedAt` | string/null | 拍摄时刻,来源未提供时为空;通常省略;未被外层承接或有差异时保留 |
| `fileUrl` | string | 可选宿主文件位置原值,可能相对路径或临时地址 | | `fileUrl` | string | 可选宿主文件位置原值,可能相对路径或临时地址 |
| `fileName / fileType` | string | 可选文件名 / 文件类型 | | `fileName / fileType` | string | 可选文件名 / 文件类型 |
| `fileRefs` | object[]/null | 可选稳定文件引用;顺序按来源保留,访问时使用数组下标 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 | | `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
**fileRefs[](存在时)**
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `provider` | string | 文件来源标识,如 `meyer`;开放标识 |
| `connectionId` | string | 可选的来源连接标识;原样使用,不含凭据 |
| `fileId` | string | 来源内的稳定文件标识;按不透明字符串使用,不是 URL,也不是影像 ID |
| `role` | string | `original` 原文件;`thumbnail` 缩略图;`slice` CT 切片 |
| `index` | integer | 仅切片提供,从 0 开始的源切片序号;不同于 `fileRefs` 数组下标 |
文件和影像的标识各自独立;分析及诊断仍关联影像 `externalId`。
**analysis(存在时)** **analysis(存在时)**
| 字段 | 类型 | 含义 | | 字段 | 类型 | 含义 |
......
...@@ -26,6 +26,15 @@ PORT=3101 ...@@ -26,6 +26,15 @@ PORT=3101
# local: debug | staging+prod: info # local: debug | staging+prod: info
LOG_LEVEL=info LOG_LEVEL=info
# 可选:美亚影像文件按需访问。配置文件须在服务进程内可读(容器用只读挂载)。
# namespace 对应 fileRefs.connectionId;复用采集配置中的 clinicIds,不自行扩大范围。
# 未配置时事实查询正常,文件访问接口返回来源未配置。
MEYER_CONFIG_PATH=
# Docker 部署可设置现有采集目录,共用 collector.env 和只读 config.json。
PAC_MEYER_RUNTIME_DIR=
MEYER_APP_KEY=
MEYER_APP_SECRET=
# ─── Postgres 初始化(仅 compose 模式用 — postgres 容器启动时读这三个)── # ─── Postgres 初始化(仅 compose 模式用 — postgres 容器启动时读这三个)──
# systemd 模式可忽略(你自己起 postgres 时指定) # systemd 模式可忽略(你自己起 postgres 时指定)
......
...@@ -8,16 +8,19 @@ import { loadMeyerScopes } from '../integrations/meyer/org-mapping'; ...@@ -8,16 +8,19 @@ import { loadMeyerScopes } from '../integrations/meyer/org-mapping';
import { ConfigSchema, InputConfigSchema, hash, imageKey } from '../integrations/meyer/contracts'; import { ConfigSchema, InputConfigSchema, hash, imageKey } from '../integrations/meyer/contracts';
import { MeyerClient } from '../integrations/meyer/client'; import { MeyerClient } from '../integrations/meyer/client';
import { collectMeyer } from '../integrations/meyer/collector'; import { collectMeyer } from '../integrations/meyer/collector';
import { backfillMeyerFileRefs } from '../integrations/meyer/backfill-file-refs';
import { PrismaClient } from '@prisma/client';
import { loadDiagnosisCodes } from '../integrations/meyer/mapper'; import { loadDiagnosisCodes } from '../integrations/meyer/mapper';
import { MeyerStore, type SavedBundle } from '../integrations/meyer/store'; import { MeyerStore, type SavedBundle } from '../integrations/meyer/store';
import type { MeyerSink } from '../integrations/meyer/contracts'; import type { MeyerSink } from '../integrations/meyer/contracts';
export function parseMeyerArgs(argv: string[]) { export function parseMeyerArgs(argv: string[]) {
const args = { config: '', state: '', apply: false, help: false, tick: false, watch: false, imageFiles: '' }; const args = { config: '', state: '', apply: false, help: false, tick: false, watch: false, imageFiles: '', backfillFileRefs: false };
for (const a of argv) { for (const a of argv) {
if (a === '--') continue; if (a === '--') continue;
if (a === '--help' || a === '-h') args.help = true; if (a === '--help' || a === '-h') args.help = true;
else if (a.startsWith('--image-files=')) args.imageFiles = a.slice(14); else if (a.startsWith('--image-files=')) args.imageFiles = a.slice(14);
else if (a === '--backfill-file-refs') args.backfillFileRefs = true;
else if (a === '--tick') args.tick = true; else if (a === '--tick') args.tick = true;
else if (a === '--watch') { args.watch = true; args.tick = true; } else if (a === '--watch') { args.watch = true; args.tick = true; }
else if (a === '--apply') args.apply = true; else if (a === '--apply') args.apply = true;
...@@ -27,13 +30,14 @@ export function parseMeyerArgs(argv: string[]) { ...@@ -27,13 +30,14 @@ export function parseMeyerArgs(argv: string[]) {
} }
if (!args.help && (!args.config || !args.state)) throw new Error('--config and --state-dir are required'); if (!args.help && (!args.config || !args.state)) throw new Error('--config and --state-dir are required');
if (args.imageFiles && (args.tick || args.apply)) throw new Error('--image-files is read-only and cannot combine with apply/tick'); if (args.imageFiles && (args.tick || args.apply)) throw new Error('--image-files is read-only and cannot combine with apply/tick');
if (args.backfillFileRefs && (args.tick || args.imageFiles)) throw new Error('--backfill-file-refs cannot combine with image-files/tick/watch');
if (args.tick && !args.apply) throw new Error('--tick/--watch requires --apply; preview cannot advance cursor'); if (args.tick && !args.apply) throw new Error('--tick/--watch requires --apply; preview cannot advance cursor');
return args; return args;
} }
export async function runMeyerCli(argv: string[]): Promise<number> { export async function runMeyerCli(argv: string[]): Promise<number> {
const args = parseMeyerArgs(argv); const args = parseMeyerArgs(argv);
if (args.help) { if (args.help) {
console.log('meyer-imaging --config=<json> --state-dir=<private-directory> [--apply] [--tick | --watch] [--image-files=<imageId>]\n' + console.log('meyer-imaging --config=<json> --state-dir=<private-directory> [--apply] [--tick | --watch] [--image-files=<imageId>] [--backfill-file-refs]\n' +
'Default: collect and validate only. --apply writes existing Friday patients through canonical fact parsers.\n' + 'Default: collect and validate only. --apply writes existing Friday patients through canonical fact parsers.\n' +
'Required env: MEYER_APP_KEY, MEYER_APP_SECRET; DATABASE_URL is required for automatic organization mapping, including preview.\n' + 'Required env: MEYER_APP_KEY, MEYER_APP_SECRET; DATABASE_URL is required for automatic organization mapping, including preview.\n' +
'Default fixed window; --tick advances private cursor, --watch uses configured interval (default 15 minutes). No reconciliation, deletion, main scheduler, or recall recomputation.'); 'Default fixed window; --tick advances private cursor, --watch uses configured interval (default 15 minutes). No reconciliation, deletion, main scheduler, or recall recomputation.');
...@@ -70,6 +74,14 @@ export async function runMeyerCli(argv: string[]): Promise<number> { ...@@ -70,6 +74,14 @@ export async function runMeyerCli(argv: string[]): Promise<number> {
const previous = await store.read<{ target: string }>('database'); const previous = await store.read<{ target: string }>('database');
if (previous && previous.target !== target) throw new Error('state directory belongs to another PAC database'); if (previous && previous.target !== target) throw new Error('state directory belongs to another PAC database');
if (!previous) await store.write('database', { target }); if (!previous) await store.write('database', { target });
if (args.backfillFileRefs) {
const db = new PrismaClient();
try {
const result = await backfillMeyerFileRefs(db, config, args.apply);
console.log(JSON.stringify({ mode: 'backfill-file-refs', apply: args.apply, ...result }));
return result.conflicts || result.raced ? 1 : 0;
} finally { await db.$disconnect(); }
}
if (args.imageFiles) { if (args.imageFiles) {
const matches: Array<{ key: string; saved: SavedBundle }> = []; const matches: Array<{ key: string; saved: SavedBundle }> = [];
for (const scope of config.scopes) { for (const scope of config.scopes) {
......
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
| PAC 业务记录自动解析诊所 → tenantId/sourceUnit | Friday 既有隔离范围;患者按完整身份键匹配 | | PAC 业务记录自动解析诊所 → tenantId/sourceUnit | Friday 既有隔离范围;患者按完整身份键匹配 |
| patientId / clinicId | patientExternalId / clinicId;空患者不猜测 | | patientId / clinicId | patientExternalId / clinicId;空患者不猜测 |
| imageId | `meyer:<namespace>:<imageId>`,避免与旧影像 ID 撞号 | | imageId | `meyer:<namespace>:<imageId>`,避免与旧影像 ID 撞号 |
| imageOssId / thumbOssId / ctSlices | 通用 image_study.fileRefs,分别为 original / thumbnail / slice;切片保留原顺序与序号 |
| imageCreateTime / modality | image_study.uploadedAt / modality | | imageCreateTime / modality | image_study.uploadedAt / modality |
| teeth[].position / content | diagnosis.toothPosition / name | | teeth[].position / content | diagnosis.toothPosition / name |
| studyTime | diagnosis.occurredAt,表示影像证据的检查时刻 | | studyTime | diagnosis.occurredAt,表示影像证据的检查时刻 |
...@@ -108,7 +109,30 @@ pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/privat ...@@ -108,7 +109,30 @@ pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/privat
从分析 bundle 或影像 image 快照中取原图 imageOssId,并使用对应诊所调用美亚 images/urls。签名 URL 写入私有 access-*.json,终端只显示 found、expiresAt 和文件键,不输出签名。每次执行重新获取,不能把过期地址当作长期业务数据。当前命令只刷新原图,不批量获取 CT 切片。 从分析 bundle 或影像 image 快照中取原图 imageOssId,并使用对应诊所调用美亚 images/urls。签名 URL 写入私有 access-*.json,终端只显示 found、expiresAt 和文件键,不输出签名。每次执行重新获取,不能把过期地址当作长期业务数据。当前命令只刷新原图,不批量获取 CT 切片。
这是运维 CLI 能力,不是公开下载接口;未来若接前端,必须先按宿主患者权限授权,再调用访问解析。美亚 URL 接口只校验诊所文件归属,不能替代患者级权限。 该命令仍是运维入口。应用使用 `POST /pac/v1/open/image-files/resolve` 按已授权患者、当前影像事实和文件下标获取地址;权限与完整患者归属验证在来源请求及缓存读取之前。美亚 URL 接口只校验诊所文件归属,不能替代患者级权限。
### 统一文件引用与应用访问配置
`fileRefs` 的每项包含 `provider=meyer`、`connectionId=namespace`、不透明 `fileId`、`role`,切片另有 `index`。公共结构不使用 OSS 专有命名。ImageParser 统一写入 `patient_facts.content.file_refs`;患者事实查询投影为 `fileRefs`。影像业务 ID、诊断关联及原有宿主 `fileUrl` 不改变。
主服务按需解析文件时需要 `MEYER_CONFIG_PATH`(只读挂载的现行采集配置)、`MEYER_APP_KEY`、`MEYER_APP_SECRET`。配置中 namespace 必须匹配 connectionId,诊所须在 clinicIds 内;每次访问仍从业务数据核实租户与品牌。未配置不影响事实查询和服务启动,文件访问接口返回来源未配置。多个账户不得复用同一 namespace。
Docker 部署在主服务 `.env` 设置 `PAC_MEYER_RUNTIME_DIR` 为现有采集目录。`deploy/deploy-prod.sh` 自动叠加 `docker-compose.meyer.yml`,直接读取同一份 `collector.env`,只读挂载同一份 `config.json`。无须复制密钥;未设置此目录的环境不启用该挂载。手动重建服务时也须叠加此文件并提供该变量。
签名地址仅放在有界的进程缓存中,最多 1000 个,单项最多 5 分钟,且至少提前实际到期时间 30 秒失效。命中缓存仍先鉴权和核实来源范围;凭据或配置变更不复用旧缓存。对外返回 UTC `expiresAt`,不把签名或失效时间写回事实、分析或事务。
### 旧影像补齐引用
```sh
# 默认只读预览:从既有数据库 rawPayload 补齐当前影像的文件引用,不调用美亚。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test --backfill-file-refs
# 核对预览后执行;应先停止占用同一状态目录的采集轮次。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test --backfill-file-refs --apply
```
仅处理配置的宿主、租户、品牌、诊所及 namespace;按当前事实绑定的事务证据校验来源身份。一致且缺失的 `file_refs` 原位补齐;已有不同引用、证据冲突、并发变化均不覆盖并返回非零退出码。无文件 ID 的影像保留为空,不猜地址。
补齐不新增事实版本,不改 `updated_at`、业务时间、分析、诊断或旧事务。新代码在适配入口从冻结原文投影文件引用,旧 bundle 和 canonicalHash 保持原样;补齐后旧快照重放仍幂等,无须重采分析或重算画像/召回。
## 保守配置、断点与自愈 ## 保守配置、断点与自愈
......
import { Prisma, type PrismaClient } from '@prisma/client';
import { ImageStudyCanonicalSchema, SubjectType } from '@pac/types';
import { currentFactHeadSql } from '../../modules/facts/current-fact';
import { mapMeyerFileRefs } from './file-refs';
import { ImageSchema, hash, type MeyerConfig } from './contracts';
interface Candidate {
id: string; subjectId: string; patientId: string; clinicId: string; content: Prisma.JsonValue;
transactionIds: string[]; externalId: string;
}
/** 只补当前影像缺失的 file_refs;不产生事实版本,不改时间、水位、原文或已有分析。 */
export async function backfillMeyerFileRefs(db: PrismaClient, config: MeyerConfig, apply: boolean) {
const host = await db.host.findUnique({ where: { name: config.host }, select: { id: true, active: true } });
if (!host?.active) throw new Error('Friday host missing or inactive');
const result = { examined: 0, eligible: 0, updated: 0, unchanged: 0, noFiles: 0, conflicts: 0, raced: 0 };
for (const scope of config.scopes) {
let after: string | undefined;
for (;;) {
const rows = await db.$queryRaw<Candidate[]>`
SELECT f.id, f.subject_id AS "subjectId", f.patient_id AS "patientId", f.clinic_id AS "clinicId", f.content,
f.transaction_ids AS "transactionIds", p.external_id AS "externalId"
FROM patient_facts f JOIN patients p ON p.id = f.patient_id
AND p.host_id = f.host_id AND p.tenant_id = f.tenant_id AND p.source_unit = f.source_unit
WHERE f.host_id = ${host.id}::uuid AND f.tenant_id = ${scope.tenantId} AND f.source_unit = ${scope.sourceUnit}
AND f.clinic_id = ${scope.clinicId} AND p.active = true AND f.type = 'image_record'
AND f.status = 'active' AND ${currentFactHeadSql('f')}
AND starts_with(f.content->>'image_external_id', ${`meyer:${config.namespace}:`})
${after ? Prisma.sql`AND f.id > ${after}::uuid` : Prisma.empty}
ORDER BY f.id LIMIT 100
`;
if (!rows.length) break;
for (const fact of rows) {
result.examined++;
try {
const evidence = await db.patientTransaction.findMany({ where: {
id: { in: fact.transactionIds }, hostId: host.id, tenantId: scope.tenantId,
patientId: fact.patientId, clinicId: scope.clinicId, subjectType: SubjectType.IMAGE_STUDY,
}, select: { rawPayload: true } });
if (!fact.transactionIds.length || evidence.length !== new Set(fact.transactionIds).size) throw new Error('incomplete evidence');
const all = evidence.map(tx => {
const raw = tx.rawPayload as Record<string, unknown>;
const origin = raw._meyer as Record<string, unknown> | undefined;
const image = ImageSchema.parse(origin?.image);
const canonical = ImageStudyCanonicalSchema.parse(raw);
if (origin?.namespace !== config.namespace || origin?.sourceUnit !== scope.sourceUnit ||
image.clinicId !== scope.clinicId || image.patientId !== fact.externalId ||
canonical.patientExternalId !== fact.externalId || canonical.clinicId !== scope.clinicId ||
canonical.externalId !== `meyer:${config.namespace}:${encodeURIComponent(image.imageId)}` ||
canonical.externalId !== (fact.content as Record<string, unknown>).image_external_id ||
fact.subjectId !== `image_record:${canonical.externalId}`) throw new Error('evidence mismatch');
return mapMeyerFileRefs(config.namespace, image);
});
if (all.some(refs => hash(refs) !== hash(all[0]))) throw new Error('conflicting evidence');
const refs = all[0]!;
if (!refs.length) { result.noFiles++; continue; }
const content = fact.content as Record<string, unknown>;
if (content.file_refs != null) {
if (hash(content.file_refs) !== hash(refs)) throw new Error('existing references differ');
result.unchanged++; continue;
}
result.eligible++;
if (apply) {
// 乐观并发校验:内容、当前版本或状态发生变化就跳过,不能覆盖新版本。
// 仅改 JSON 字段,⛔ 不触碰 updated_at 等画像/召回水位。
const count = await db.$executeRaw`
UPDATE patient_facts f SET content = jsonb_set(f.content, '{file_refs}', ${JSON.stringify(refs)}::jsonb)
WHERE f.id = ${fact.id}::uuid AND f.status = 'active' AND ${currentFactHeadSql('f')}
AND f.host_id = ${host.id}::uuid AND f.tenant_id = ${scope.tenantId} AND f.source_unit = ${scope.sourceUnit}
AND f.patient_id = ${fact.patientId}::uuid AND f.clinic_id = ${scope.clinicId} AND f.subject_id = ${fact.subjectId}
AND f.content = ${JSON.stringify(fact.content)}::jsonb
`;
if (count) result.updated++; else result.raced++;
}
} catch { result.conflicts++; }
}
after = rows[rows.length - 1]!.id;
}
}
return result;
}
import { ImageFileRefsSchema, type ImageFileRef, type ImageStudyCanonical } from '@pac/types';
import type { MeyerImage, MeyerBundle } from './contracts';
import { hash } from './contracts';
/** 只在来源入口解释 OSS 字段;下游只认识统一的文件引用。切片顺序不得排序或去重。 */
export function mapMeyerFileRefs(namespace: string, image: MeyerImage): ImageFileRef[] {
const refs: ImageFileRef[] = [];
const add = (value: unknown, role: ImageFileRef['role'], index?: number) => {
if (value == null || value === '') return;
refs.push({ provider: 'meyer', connectionId: namespace, fileId: value as string, role,
...(index !== undefined ? { index } : {}) });
};
add(image.imageOssId, 'original');
add(image.thumbOssId, 'thumbnail');
if (image.ctSlices != null) {
if (!Array.isArray(image.ctSlices)) throw new Error('invalid Meyer slice references');
image.ctSlices.forEach((id, index) => {
if (typeof id !== 'string' || !id) throw new Error('invalid Meyer slice reference');
add(id, 'slice', index);
});
}
return ImageFileRefsSchema.parse(refs);
}
/** 从冻结的原文补标准字段,⛔ 不改写旧 bundle、事件身份或 canonicalHash。 */
export function projectMeyerImageFiles(bundle: MeyerBundle, image: ImageStudyCanonical): ImageStudyCanonical {
const raw = bundle.raw.image;
if (raw.clinicId !== bundle.scope.clinicId || raw.patientId !== image.patientExternalId ||
image.externalId !== `meyer:${bundle.namespace}:${encodeURIComponent(raw.imageId)}`) {
throw new Error('canonical bundle ownership mismatch');
}
const refs = mapMeyerFileRefs(bundle.namespace, raw);
if (image.fileRefs !== undefined && hash(image.fileRefs) !== hash(refs)) throw new Error('file references differ from retained source');
return refs.length || image.fileRefs !== undefined ? { ...image, fileRefs: refs } : image;
}
import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { readFile } from 'node:fs/promises';
import type { ImageFileRef } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { ImageFileProvider, ImageFileContext, ImageFileAccess } from '../../modules/open/image-file-provider';
import { ConfigSchema, InputConfigSchema, hash, sourceTime } from './contracts';
import { resolveMeyerScopes } from './org-mapping';
import { MeyerClient } from './client';
@Injectable()
export class MeyerImageFileProvider implements ImageFileProvider {
readonly provider = 'meyer';
private readonly clients = new Map<string, MeyerClient>();
private readonly cache = new Map<string, { until: number; access: ImageFileAccess }>();
constructor(private readonly config: ConfigService, private readonly prisma: PrismaService) {}
async resolve(context: ImageFileContext, refs: ImageFileRef[]): Promise<ImageFileAccess[]> {
const path = this.config.get<string>('MEYER_CONFIG_PATH');
const appKey = this.config.get<string>('MEYER_APP_KEY');
const secret = this.config.get<string>('MEYER_APP_SECRET');
if (!path || !appKey?.trim() || !secret?.trim()) throw new ServiceUnavailableException('影像文件来源未配置');
let input: ReturnType<typeof InputConfigSchema.parse>;
try { input = InputConfigSchema.parse(JSON.parse(await readFile(path, 'utf8'))); }
catch { throw new ServiceUnavailableException('影像文件来源配置无效'); }
if (context.hostName !== input.host || !input.clinicIds.includes(context.clinicId) ||
refs.some(r => r.provider !== this.provider || r.connectionId !== input.namespace)) {
throw new ServiceUnavailableException('影像文件来源与配置不匹配');
}
try {
const scopes = await resolveMeyerScopes(this.prisma, [context.clinicId]);
if (scopes[0]?.tenantId !== context.tenantId || scopes[0]?.sourceUnit !== context.sourceUnit) {
throw new Error('source scope changed');
}
const { clinicIds: _clinicIds, ...fields } = input;
const config = ConfigSchema.parse({ ...fields, scopes });
const identity = hash([config, appKey, secret]);
let client = this.clients.get(identity);
if (!client) {
client = new MeyerClient(config, { appKey, secret });
if (this.clients.size >= 16) this.clients.delete(this.clients.keys().next().value!);
this.clients.set(identity, client);
}
const now = Date.now();
for (const [key, entry] of this.cache) if (entry.until <= now) this.cache.delete(key);
const keyOf = (fileId: string) => hash([identity, context.clinicId, fileId]);
const cached = new Map(refs.map(ref => [ref.fileId, this.cache.get(keyOf(ref.fileId))?.access]));
const missing = [...new Set(refs.map(r => r.fileId))].filter(id => !this.cache.has(keyOf(id)));
const fetched = new Map<string, ImageFileAccess>();
if (missing.length) {
const urls = await client.fileUrls(context.clinicId, missing);
for (const item of urls) {
const expiresAt = item.found && item.expiresAt ? sourceTime(item.expiresAt) : null;
const checked = Date.now();
if (item.found && (!expiresAt || Date.parse(expiresAt) <= checked)) throw new Error('expired file URL');
const access = { fileId: item.ossId, found: item.found,
url: item.found ? item.url : null, expiresAt };
fetched.set(item.ossId, access);
if (expiresAt && Date.parse(expiresAt) > checked + 30000) {
if (this.cache.size >= 1000) this.cache.delete(this.cache.keys().next().value!);
this.cache.set(keyOf(item.ossId), { until: Math.min(checked + 300000, Date.parse(expiresAt) - 30000), access });
}
}
}
return refs.map(ref => {
const access = fetched.get(ref.fileId) ?? cached.get(ref.fileId);
if (!access || (access.found && (!access.expiresAt || Date.parse(access.expiresAt) <= Date.now()))) {
throw new Error('file access expired while resolving batch');
}
return access;
});
} catch { throw new BadGatewayException('影像文件来源暂不可用,请稍后重试'); }
}
}
...@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; ...@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import { DiagnosisCanonicalSchema, ImageStudyCanonicalSchema, PACDiagnosisCodeSchema } from '@pac/types'; import { DiagnosisCanonicalSchema, ImageStudyCanonicalSchema, PACDiagnosisCodeSchema } from '@pac/types';
import { resolveMeyerDiagnosisCode } from './diagnosis-mapping'; import { resolveMeyerDiagnosisCode } from './diagnosis-mapping';
import { mapMeyerFileRefs, projectMeyerImageFiles } from './file-refs';
import { hash, sourceTime, type MeyerBundle, type MeyerConfig, type MeyerImage, type MeyerScope, import { hash, sourceTime, type MeyerBundle, type MeyerConfig, type MeyerImage, type MeyerScope,
type MeyerSymptom } from './contracts'; type MeyerSymptom } from './contracts';
...@@ -60,6 +61,7 @@ export function mapMeyerImage(config: MeyerConfig, scope: MeyerScope, image: Mey ...@@ -60,6 +61,7 @@ export function mapMeyerImage(config: MeyerConfig, scope: MeyerScope, image: Mey
if (!image.patientId || !image.imageCreateTime) return null; if (!image.patientId || !image.imageCreateTime) return null;
const uploadedAt = sourceTime(image.imageCreateTime); const uploadedAt = sourceTime(image.imageCreateTime);
if (uploadedAt > observedAt) throw new Error('Meyer image has future time'); if (uploadedAt > observedAt) throw new Error('Meyer image has future time');
const refs = mapMeyerFileRefs(config.namespace, image);
return { version: 2, phase: 'image', namespace: config.namespace, scope, observedAt, return { version: 2, phase: 'image', namespace: config.namespace, scope, observedAt,
timeBasis: 'imageCreateTime', diagnoses: [], raw: { image, symptom: null }, timeBasis: 'imageCreateTime', diagnoses: [], raw: { image, symptom: null },
image: ImageStudyCanonicalSchema.parse({ image: ImageStudyCanonicalSchema.parse({
...@@ -67,6 +69,7 @@ export function mapMeyerImage(config: MeyerConfig, scope: MeyerScope, image: Mey ...@@ -67,6 +69,7 @@ export function mapMeyerImage(config: MeyerConfig, scope: MeyerScope, image: Mey
patientExternalId: image.patientId, clinicId: scope.clinicId, uploadedAt, patientExternalId: image.patientId, clinicId: scope.clinicId, uploadedAt,
modality: ['pa', 'bw', 'pano', 'cbct', 'intraoral_photo', 'scan', 'other'].includes(image.modality ?? '') modality: ['pa', 'bw', 'pano', 'cbct', 'intraoral_photo', 'scan', 'other'].includes(image.modality ?? '')
? image.modality : 'other', fileType: image.imageExt ?? null, ? image.modality : 'other', fileType: image.imageExt ?? null,
...(refs.length ? { fileRefs: refs } : {}),
}) }; }) };
} }
...@@ -77,7 +80,7 @@ export function mapMeyerAnalysis(config: MeyerConfig, scope: MeyerScope, image: ...@@ -77,7 +80,7 @@ export function mapMeyerAnalysis(config: MeyerConfig, scope: MeyerScope, image:
if (!metadata || (!symptom.teeth.length && !symptom.diagnosis.trim() && !symptom.description.trim())) return null; if (!metadata || (!symptom.teeth.length && !symptom.diagnosis.trim() && !symptom.description.trim())) return null;
// 有检查时刻和牙位时沿用原来的诊断映射,保持 v1 已入库事务哈希兼容。 // 有检查时刻和牙位时沿用原来的诊断映射,保持 v1 已入库事务哈希兼容。
const legacy = mapMeyer(config, scope, image, symptom, codes, observedAt); const legacy = mapMeyer(config, scope, image, symptom, codes, observedAt);
if (legacy) return { ...legacy, version: 2, phase: 'analysis' }; if (legacy) return { ...legacy, version: 2, phase: 'analysis', image: projectMeyerImageFiles(legacy, legacy.image) };
if (image.studyTime && sourceTime(image.studyTime) > observedAt) throw new Error('Meyer image has future time'); if (image.studyTime && sourceTime(image.studyTime) > observedAt) throw new Error('Meyer image has future time');
const findings = [...new Map(symptom.teeth.map(t => [hash([t.position, t.content]), t])).entries()] const findings = [...new Map(symptom.teeth.map(t => [hash([t.position, t.content]), t])).entries()]
.sort(([a], [b]) => a.localeCompare(b)); .sort(([a], [b]) => a.localeCompare(b));
......
...@@ -4,6 +4,7 @@ import { PrismaService } from '../../prisma/prisma.service'; ...@@ -4,6 +4,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { ParserPipeline } from '../../modules/sync/pipeline/parser-pipeline.service'; import { ParserPipeline } from '../../modules/sync/pipeline/parser-pipeline.service';
import { TransactionSynthesizer } from '../../modules/sync/pipeline/transaction-synthesizer'; import { TransactionSynthesizer } from '../../modules/sync/pipeline/transaction-synthesizer';
import { hash, type MeyerBundle, type MeyerSink } from './contracts'; import { hash, type MeyerBundle, type MeyerSink } from './contracts';
import { mapMeyerFileRefs, projectMeyerImageFiles } from './file-refs';
/** 标准字段边界之后才调用已有事实内核;不启动 Push/Pull、主档写入或召回任务。 */ /** 标准字段边界之后才调用已有事实内核;不启动 Push/Pull、主档写入或召回任务。 */
@Injectable() @Injectable()
...@@ -16,6 +17,7 @@ export class MeyerPacSink implements MeyerSink { ...@@ -16,6 +17,7 @@ export class MeyerPacSink implements MeyerSink {
if (!host) throw new Error('Friday host does not exist'); if (!host) throw new Error('Friday host does not exist');
const { tenantId, sourceUnit, clinicId } = bundle.scope; const { tenantId, sourceUnit, clinicId } = bundle.scope;
const image = ImageStudyCanonicalSchema.parse(bundle.image); const image = ImageStudyCanonicalSchema.parse(bundle.image);
const projectedImage = projectMeyerImageFiles(bundle, image);
const diagnoses = bundle.diagnoses.map(d => DiagnosisCanonicalSchema.parse(d)); const diagnoses = bundle.diagnoses.map(d => DiagnosisCanonicalSchema.parse(d));
if (![1, 2].includes(bundle.version) || (bundle.version === 1 && !diagnoses.length) || if (![1, 2].includes(bundle.version) || (bundle.version === 1 && !diagnoses.length) ||
(bundle.version === 2 && (!['image', 'analysis'].includes(bundle.phase ?? '') || (bundle.version === 2 && (!['image', 'analysis'].includes(bundle.phase ?? '') ||
...@@ -46,7 +48,9 @@ export class MeyerPacSink implements MeyerSink { ...@@ -46,7 +48,9 @@ export class MeyerPacSink implements MeyerSink {
} }
if ((current?.content as { analysis?: unknown } | undefined)?.analysis) continue; if ((current?.content as { analysis?: unknown } | undefined)?.analysis) continue;
} }
const canonicalHash = hash(item.resource === 'image_study' ? { image, diagnoses } : item.row); // 文件引用是从已留底原文投影的新字段,不改变历史事件的 canonicalHash。
const { fileRefs: _fileRefs, ...frozenImage } = image;
const canonicalHash = hash(item.resource === 'image_study' ? { image: frozenImage, diagnoses } : item.row);
const rawRow = { ...item.row, _meyer: { namespace: bundle.namespace, const rawRow = { ...item.row, _meyer: { namespace: bundle.namespace,
timeBasis: bundle.timeBasis, observedAt: bundle.observedAt, sourceUnit, canonicalHash, timeBasis: bundle.timeBasis, observedAt: bundle.observedAt, sourceUnit, canonicalHash,
image: bundle.raw.image, symptom: bundle.raw.symptom } }; image: bundle.raw.image, symptom: bundle.raw.symptom } };
...@@ -61,11 +65,16 @@ export class MeyerPacSink implements MeyerSink { ...@@ -61,11 +65,16 @@ export class MeyerPacSink implements MeyerSink {
const transaction = await this.prisma.patientTransaction.findFirstOrThrow({ where: { const transaction = await this.prisma.patientTransaction.findFirstOrThrow({ where: {
hostId: host.id, tenantId, sourceEventId: data.sourceEventId, hostId: host.id, tenantId, sourceEventId: data.sourceEventId,
} }); } });
const previous = transaction.rawPayload as { _meyer?: { canonicalHash?: string } }; const previous = transaction.rawPayload as { _meyer?: { canonicalHash?: string; namespace?: string; image?: MeyerBundle['raw']['image'] } };
if (transaction.patientId !== patient.id || transaction.clinicId !== clinicId || if (transaction.patientId !== patient.id || transaction.clinicId !== clinicId ||
previous?._meyer?.canonicalHash !== canonicalHash) throw new Error('existing supplemental snapshot differs; reconciliation is not supported'); previous?._meyer?.canonicalHash !== canonicalHash) throw new Error('existing supplemental snapshot differs; reconciliation is not supported');
if (item.resource === 'image_study' && (!previous._meyer?.image || previous._meyer.namespace !== bundle.namespace ||
hash(mapMeyerFileRefs(bundle.namespace, previous._meyer.image)) !== hash(projectedImage.fileRefs ?? []))) {
throw new Error('existing supplemental snapshot differs; file references changed');
}
const metrics = await this.pipeline.runForTransaction({ const metrics = await this.pipeline.runForTransaction({
transaction: { ...transaction, action: item.action }, canonicalRow: item.row, transaction: { ...transaction, action: item.action },
canonicalRow: item.resource === 'image_study' ? projectedImage : item.row,
}); });
if (!metrics.parserMatched || metrics.factsFailed) throw new Error('PAC fact write failed; retry retained bundle'); if (!metrics.parserMatched || metrics.factsFailed) throw new Error('PAC fact write failed; retry retained bundle');
} }
......
import type { ImageFileRef } from '@pac/types';
export const IMAGE_FILE_PROVIDERS = Symbol('IMAGE_FILE_PROVIDERS');
export interface ImageFileContext {
hostId: string; hostName: string; tenantId: string; sourceUnit: string; clinicId: string;
}
export interface ImageFileAccess { fileId: string; found: boolean; url: string | null; expiresAt: string | null }
export interface ImageFileProvider {
readonly provider: string;
resolve(context: ImageFileContext, refs: ImageFileRef[]): Promise<ImageFileAccess[]>;
}
import { Body, Controller, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import type { Response } from 'express';
import { Public } from '../../common/decorators/public.decorator';
import { ApplicationTokenGuard, type ApplicationRequest } from './application-token.guard';
import { ImageFilesResolveRequestDto, ImageFilesResolveResponseDto } from './image-files.schema';
import { ImageFilesService } from './image-files.service';
@ApiTags('open')
@ApiBearerAuth('accessToken')
@Controller('open/image-files')
export class ImageFilesController {
constructor(private readonly files: ImageFilesService) {}
@Post('resolve')
@HttpCode(200)
@Public()
@UseGuards(ApplicationTokenGuard)
@ZodResponse({ status: 200, type: ImageFilesResolveResponseDto })
@ApiOperation({ summary: '获取影像文件临时访问地址',
description: '使用应用 Bearer 令牌。按患者、当前影像事实及 fileRefs 数组下标取文件;每批 1–100 项。访问前验证宿主与完整患者归属,不接收任意文件 ID。返回 URL 和实际过期时间,不修改事实或分析。' })
resolve(@Req() req: ApplicationRequest, @Body() body: ImageFilesResolveRequestDto,
@Res({ passthrough: true }) response: Response) {
response.setHeader('Cache-Control', 'private, no-store');
return this.files.resolve(req.application, body);
}
}
import { z } from 'zod';
import { createZodDto } from 'nestjs-zod';
import { ImageFileRefSchema } from '@pac/types';
export const ImageFilesResolveSchema = z.strictObject({
patientId: z.string().uuid(),
imageFactId: z.string().uuid(),
fileIndexes: z.array(z.number().int().min(0).max(9999)).min(1).max(100)
.refine(v => new Set(v).size === v.length, 'duplicate file index'),
});
export type ImageFilesResolveRequest = z.infer<typeof ImageFilesResolveSchema>;
export class ImageFilesResolveRequestDto extends createZodDto(ImageFilesResolveSchema) {}
export const ImageFilesResolveResponseSchema = z.object({
imageFactId: z.string().uuid(),
files: z.array(z.object({
index: z.number().int().min(0), reference: ImageFileRefSchema,
found: z.boolean(), url: z.string().url().nullable(), expiresAt: z.iso.datetime().nullable(),
})),
});
export class ImageFilesResolveResponseDto extends createZodDto(ImageFilesResolveResponseSchema) {}
import { BadGatewayException, BadRequestException, Inject, Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { ImageFileRefsSchema } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import type { ApplicationPrincipal } from './application-token.guard';
import { IMAGE_FILE_PROVIDERS, type ImageFileProvider, type ImageFileContext } from './image-file-provider';
import { ImageFilesResolveSchema, type ImageFilesResolveRequest } from './image-files.schema';
@Injectable()
export class ImageFilesService {
constructor(private readonly prisma: PrismaService,
@Inject(IMAGE_FILE_PROVIDERS) private readonly providers: ImageFileProvider[]) {}
async resolve(application: ApplicationPrincipal, input: ImageFilesResolveRequest) {
const q = ImageFilesResolveSchema.parse(input);
const rows = await this.prisma.$queryRaw<Array<ImageFileContext & { content: unknown }>>`
SELECT f.content, f.host_id AS "hostId", h.name AS "hostName", f.tenant_id AS "tenantId",
f.source_unit AS "sourceUnit", f.clinic_id AS "clinicId"
FROM patient_facts f JOIN patients p ON p.id = f.patient_id
AND p.host_id = f.host_id AND p.tenant_id = f.tenant_id AND p.source_unit = f.source_unit
JOIN hosts h ON h.id = f.host_id
WHERE f.id = ${q.imageFactId}::uuid AND f.patient_id = ${q.patientId}::uuid
AND f.host_id = ${application.hostId}::uuid AND p.active = true AND h.active = true
AND f.type = 'image_record' AND f.status = 'active' AND ${currentFactHeadSql('f')}
`;
const fact = rows[0];
if (!fact?.clinicId) throw new NotFoundException('未找到当前可访问的影像');
const refs = ImageFileRefsSchema.safeParse((fact.content as { file_refs?: unknown } | null)?.file_refs);
if (!refs.success || !refs.data.length) throw new NotFoundException('该影像没有可解析的文件引用');
const selected = q.fileIndexes.map(index => {
const reference = refs.data[index];
if (!reference) throw new BadRequestException('影像文件索引无效');
return { index, reference };
});
// 先验证所有来源是否支持,再调用任何供应商。只接受库内引用,不接受任意 OSS ID。
const groups = new Map<string, typeof selected>();
for (const file of selected) {
if (!this.providers.some(p => p.provider === file.reference.provider)) throw new ServiceUnavailableException('影像文件来源未配置');
const key = JSON.stringify([file.reference.provider, file.reference.connectionId]);
groups.set(key, [...groups.get(key) ?? [], file]);
}
const files = new Map<number, { index: number; reference: typeof refs.data[number]; found: boolean; url: string | null; expiresAt: string | null }>();
const { content: _content, ...context } = fact;
for (const group of groups.values()) {
const provider = this.providers.find(p => p.provider === group[0]!.reference.provider)!;
const result = await provider.resolve(context, group.map(x => x.reference));
if (result.length !== group.length || result.some((x, i) => x.fileId !== group[i]!.reference.fileId)) {
throw new BadGatewayException('影像文件来源返回不完整');
}
group.forEach((file, i) => { const access = result[i]!;
files.set(file.index, { ...file, found: access.found, url: access.url, expiresAt: access.expiresAt }); });
}
return { imageFactId: q.imageFactId, files: selected.map(file => files.get(file.index)!) };
}
}
...@@ -9,7 +9,7 @@ const fields: Record<string, string> = { ...@@ -9,7 +9,7 @@ const fields: Record<string, string> = {
treatment_record: 'tooth_position started_at completed_at doctor_id doctor_name unit_name treat_stages source_encounter_external_id related_diagnosis_subject_id review_window_days', treatment_record: 'tooth_position started_at completed_at doctor_id doctor_name unit_name treat_stages source_encounter_external_id related_diagnosis_subject_id review_window_days',
recommendation_record: 'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days', recommendation_record: 'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days',
emr_record: 'emr_external_id encounter_external_id doctor_id doctor_name illness_desc pre_illness past_history general_condition exam_findings aux_exam_findings diagnosis_text treatment_plan treatment_notes doctor_advice quality_check clinical_signals', emr_record: 'emr_external_id encounter_external_id doctor_id doctor_name illness_desc pre_illness past_history general_condition exam_findings aux_exam_findings diagnosis_text treatment_plan treatment_notes doctor_advice quality_check clinical_signals',
image_record: 'image_external_id encounter_external_id tooth_positions captured_at file_url file_name file_type doctor_id doctor_name', image_record: 'image_external_id encounter_external_id tooth_positions captured_at file_url file_name file_type file_refs doctor_id doctor_name',
image_analysis_record: 'analysis_external_id image_external_id code_source analyzed_at source_updated_at analysis_result', image_analysis_record: 'analysis_external_id image_external_id code_source analyzed_at source_updated_at analysis_result',
appointment_record: 'appointment_external_id scheduled_at arrived_at appointment_type doctor_id doctor_name resource_name complaint_category complaint_text duration_minutes cancellation_reason', appointment_record: 'appointment_external_id scheduled_at arrived_at appointment_type doctor_id doctor_name resource_name complaint_category complaint_text duration_minutes cancellation_reason',
order_record: 'order_external_id encounter_external_id amount_cents total_amount host_status', order_record: 'order_external_id encounter_external_id amount_cents total_amount host_status',
......
...@@ -36,6 +36,8 @@ ...@@ -36,6 +36,8 @@
* ⚠️ 加新 fact 类型时,如果它带运营自由文本,**必须往 DENY_KEYS 里加**。 * ⚠️ 加新 fact 类型时,如果它带运营自由文本,**必须往 DENY_KEYS 里加**。
*/ */
import { ImageFileRefsSchema } from '@pac/types';
/** /**
* 整块丢弃的 key(**递归、不分层级**)。 * 整块丢弃的 key(**递归、不分层级**)。
* *
...@@ -96,6 +98,13 @@ export function redactJson(value: unknown, depth = 0): unknown { ...@@ -96,6 +98,13 @@ export function redactJson(value: unknown, depth = 0): unknown {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) { for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (DENY_KEYS.has(k.toLowerCase())) continue; if (DENY_KEYS.has(k.toLowerCase())) continue;
if (k === 'file_refs' || k === 'fileRefs') {
// 仅严格结构化的文件引用保留不透明 ID;不将恰好像手机号的文件 ID 打坏。
// 非法引用不透出,也不允许借此携带自由文本或签名凭据。
const refs = ImageFileRefsSchema.nullable().safeParse(v);
if (refs.success) out[k] = refs.data;
continue;
}
out[k] = redactJson(v, depth + 1); out[k] = redactJson(v, depth + 1);
} }
return out; return out;
......
...@@ -12,6 +12,10 @@ import { OpenProfileService } from './open-profile.service'; ...@@ -12,6 +12,10 @@ import { OpenProfileService } from './open-profile.service';
import { OpenAppointmentService } from './open-appointment.service'; import { OpenAppointmentService } from './open-appointment.service';
import { OpenFactGate } from './open-fact-gate.service'; import { OpenFactGate } from './open-fact-gate.service';
import { OpenFactWindowService } from './open-fact-window.service'; import { OpenFactWindowService } from './open-fact-window.service';
import { ImageFilesController } from './image-files.controller';
import { ImageFilesService } from './image-files.service';
import { IMAGE_FILE_PROVIDERS } from './image-file-provider';
import { MeyerImageFileProvider } from '../../integrations/meyer/image-file-provider';
/** /**
* 只读开放面 —— HMAC 鉴权的系统间调用(当前消费方:friday-ai)。 * 只读开放面 —— HMAC 鉴权的系统间调用(当前消费方:friday-ai)。
...@@ -22,7 +26,9 @@ import { OpenFactWindowService } from './open-fact-window.service'; ...@@ -22,7 +26,9 @@ import { OpenFactWindowService } from './open-fact-window.service';
*/ */
@Module({ @Module({
imports: [AuthModule, PatientModule], imports: [AuthModule, PatientModule],
controllers: [PatientFactsLookupController, OpenPatientsController, OpenOrgController, OpenIdentityController], controllers: [PatientFactsLookupController, ImageFilesController, OpenPatientsController, OpenOrgController, OpenIdentityController],
providers: [ApplicationTokenGuard, PatientFactsLookupService, HmacVerifier, OpenProfileService, OpenAppointmentService, OpenFactGate, OpenFactWindowService,], providers: [ApplicationTokenGuard, PatientFactsLookupService, ImageFilesService, MeyerImageFileProvider,
{ provide: IMAGE_FILE_PROVIDERS, useFactory: (meyer: MeyerImageFileProvider) => [meyer], inject: [MeyerImageFileProvider] },
HmacVerifier, OpenProfileService, OpenAppointmentService, OpenFactGate, OpenFactWindowService,],
}) })
export class OpenModule {} export class OpenModule {}
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema, ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema,
ClinicalSignalsSchema, ClinicalSignalsSchema,
ImageEmbeddedAnalysisSchema, ImageEmbeddedAnalysisSchema,
ImageFileRefsSchema,
FactType, FactType,
PACDiagnosisCodeSchema, PACDiagnosisCodeSchema,
PACTreatmentCategorySchema, PACTreatmentCategorySchema,
...@@ -236,6 +237,7 @@ const EmrRecordContent = z ...@@ -236,6 +237,7 @@ const EmrRecordContent = z
const ImageRecordContent = z const ImageRecordContent = z
.object({ .object({
image_external_id: z.string().min(1), image_external_id: z.string().min(1),
file_refs: ImageFileRefsSchema.nullable().optional(),
analysis: ImageEmbeddedAnalysisSchema.nullable().optional(), analysis: ImageEmbeddedAnalysisSchema.nullable().optional(),
/// 原样留存宿主文件位置,不在摄入层要求完整链接。 /// 原样留存宿主文件位置,不在摄入层要求完整链接。
file_url: z.string().optional(), file_url: z.string().optional(),
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Action, FactKind, FactStatus, FactType, ImageEmbeddedAnalysisSchema } from '@pac/types'; import { Action, FactKind, FactStatus, FactType, ImageEmbeddedAnalysisSchema, ImageFileRefsSchema } from '@pac/types';
import type { FactDraft, Parser, ParserContext } from './parser.interface'; import type { FactDraft, Parser, ParserContext } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util'; import { normalizeToothPosition } from './tooth-position.util';
...@@ -46,6 +46,7 @@ export class ImageParser implements Parser { ...@@ -46,6 +46,7 @@ export class ImageParser implements Parser {
tooth_positions: toothPositions, tooth_positions: toothPositions,
finding, finding,
...(c.analysis !== undefined ? { analysis: ImageEmbeddedAnalysisSchema.nullable().parse(c.analysis) } : {}), ...(c.analysis !== undefined ? { analysis: ImageEmbeddedAnalysisSchema.nullable().parse(c.analysis) } : {}),
...(c.fileRefs !== undefined ? { file_refs: ImageFileRefsSchema.nullable().parse(c.fileRefs) } : {}),
encounter_external_id: encounterExternalId, encounter_external_id: encounterExternalId,
doctor_id: c.doctorId ? String(c.doctorId) : null, doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null, doctor_name: (c.doctorName as string | undefined) ?? null,
......
...@@ -17,9 +17,12 @@ import { PermissionsGuard } from '../../src/common/guards/permissions.guard'; ...@@ -17,9 +17,12 @@ import { PermissionsGuard } from '../../src/common/guards/permissions.guard';
import { WrapResponseInterceptor } from '../../src/common/interceptors/wrap-response.interceptor'; import { WrapResponseInterceptor } from '../../src/common/interceptors/wrap-response.interceptor';
import { TenantScopeInterceptor } from '../../src/common/interceptors/tenant-scope.interceptor'; import { TenantScopeInterceptor } from '../../src/common/interceptors/tenant-scope.interceptor';
import { AllExceptionsFilter } from '../../src/common/filters/all-exceptions.filter'; import { AllExceptionsFilter } from '../../src/common/filters/all-exceptions.filter';
import { ImageFilesController } from '../../src/modules/open/image-files.controller';
import { ImageFilesService } from '../../src/modules/open/image-files.service';
import { IMAGE_FILE_PROVIDERS, type ImageFileProvider } from '../../src/modules/open/image-file-provider';
/** 真实鉴权和查询控制器;只替代未走到的 Redis/组织服务,不加载主应用或后台任务。 */ /** 真实鉴权和查询控制器;只替代未走到的 Redis/组织服务,不加载主应用或后台任务。 */
export async function createMeyerOpenHttp(prisma: PrismaService) { export async function createMeyerOpenHttp(prisma: PrismaService, fileProviders: ImageFileProvider[] = []) {
const config = { getOrThrow: (key: string) => key === 'jwt.secret' ? 'local-meyer-jwt-secret' : 60 }; const config = { getOrThrow: (key: string) => key === 'jwt.secret' ? 'local-meyer-jwt-secret' : 60 };
const orgTree = { expandScope: jest.fn() }; const orgTree = { expandScope: jest.fn() };
const jwt = new JwtService(); const jwt = new JwtService();
...@@ -27,8 +30,9 @@ export async function createMeyerOpenHttp(prisma: PrismaService) { ...@@ -27,8 +30,9 @@ export async function createMeyerOpenHttp(prisma: PrismaService) {
orgTree as any, { trackServerSide: jest.fn().mockResolvedValue(null) } as any); orgTree as any, { trackServerSide: jest.fn().mockResolvedValue(null) } as any);
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })], imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
controllers: [AuthController, PatientFactsLookupController], controllers: [AuthController, PatientFactsLookupController, ImageFilesController],
providers: [JwtStrategy, ApplicationTokenGuard, PatientFactsLookupService, providers: [JwtStrategy, ApplicationTokenGuard, PatientFactsLookupService, ImageFilesService,
{ provide: IMAGE_FILE_PROVIDERS, useValue: fileProviders },
{ provide: AuthService, useValue: auth }, { provide: JwtService, useValue: jwt }, { provide: AuthService, useValue: auth }, { provide: JwtService, useValue: jwt },
{ provide: ConfigService, useValue: config }, { provide: PrismaService, useValue: prisma }, { provide: ConfigService, useValue: config }, { provide: PrismaService, useValue: prisma },
{ provide: OrgTreeService, useValue: orgTree }], { provide: OrgTreeService, useValue: orgTree }],
......
import { ImageFileRefsSchema, ImageStudyCanonicalSchema, Action } from '@pac/types';
import { mapMeyerFileRefs, projectMeyerImageFiles } from '../src/integrations/meyer/file-refs';
import { mapMeyerImage, mapMeyerAnalysis } from '../src/integrations/meyer/mapper';
import { ImageParser } from '../src/modules/sync/pipeline/parsers/image.parser';
import { validateFactContent } from '../src/modules/sync/pipeline/fact-content-schemas';
import { toLookupFact } from '../src/modules/open/lookup-fact';
import { redactJson } from '../src/modules/open/open-redact';
import { meyerBundle, meyerConfig, meyerImage, meyerSymptom, codes } from './meyer-imaging-fixtures';
describe('shared image file references', () => {
test('Meyer source IDs map to uniform roles; CT order and repeated slice IDs are preserved', () => {
const image = { ...meyerImage(), thumbOssId: 'thumb', ctSlices: ['z', 'a', 'z'] };
const refs = mapMeyerFileRefs('test', image);
expect(refs).toEqual([
{ provider: 'meyer', connectionId: 'test', role: 'original', fileId: 'oss-1' },
{ provider: 'meyer', connectionId: 'test', role: 'thumbnail', fileId: 'thumb' },
{ provider: 'meyer', connectionId: 'test', role: 'slice', index: 0, fileId: 'z' },
{ provider: 'meyer', connectionId: 'test', role: 'slice', index: 1, fileId: 'a' },
{ provider: 'meyer', connectionId: 'test', role: 'slice', index: 2, fileId: 'z' },
]);
const c = meyerConfig();
const metadata = mapMeyerImage(c, c.scopes[0]!, image, '2026-10-17T00:00:00Z')!;
const analysis = mapMeyerAnalysis(c, c.scopes[0]!, image, meyerSymptom(), codes, '2026-10-17T00:00:00Z')!;
expect(metadata.image.fileRefs).toEqual(refs); expect(analysis.image.fileRefs).toEqual(refs);
expect(metadata.image).not.toHaveProperty('fileUrl');
expect(refs).not.toEqual(expect.arrayContaining([expect.objectContaining({ url: expect.anything() })]));
expect(() => mapMeyerFileRefs('test', { ...image, ctSlices: ['valid', null] })).toThrow();
});
test('legacy bundles keep their canonical content and hash inputs; projection uses retained identity', () => {
const old = meyerBundle(), frozen = JSON.stringify(old);
const projected = projectMeyerImageFiles(old, old.image);
expect(projected.fileRefs).toHaveLength(1);
expect(old.image).not.toHaveProperty('fileRefs'); expect(JSON.stringify(old)).toBe(frozen);
expect(() => projectMeyerImageFiles({ ...old, raw: { ...old.raw, image: { ...old.raw.image, patientId: 'other' } } }, old.image)).toThrow('ownership');
expect(() => projectMeyerImageFiles(old, { ...old.image, fileRefs: [{ provider: 'meyer', fileId: 'foreign', role: 'original' }] })).toThrow('differ');
});
test('common canonical → parser → fact validation → lookup works for any provider; fileUrl is unchanged', () => {
const refs = [{ provider: 'another-provider', fileId: '13812345678', role: 'original' as const }];
const image = ImageStudyCanonicalSchema.parse({ ...meyerBundle().image, fileRefs: refs, fileUrl: 'tenant/path.png' });
const fact = new ImageParser().parse({ canonicalRow: image, transaction: {
id: 'tx', hostId: 'host', tenantId: 'tenant', patientId: 'patient', action: Action.IMAGE_UPLOADED,
subjectType: 'image', subjectId: image.externalId, clinicId: 'clinic-a', occurredAt: new Date(image.uploadedAt),
} })[0]!;
const content = validateFactContent(fact.type, fact.subjectId, fact.content);
expect(content).toMatchObject({ file_refs: refs, file_url: 'tenant/path.png' });
const publicFact = toLookupFact({ fact_id: 'fact', host_name: 'friday', subject_id: fact.subjectId,
type: fact.type, kind: fact.kind, status: fact.status ?? 'active', clinic_id: 'clinic-a', occurred_at: new Date(image.uploadedAt),
planned_for: null, valid_until: null, title: null, summary: null, content }, '诊所');
expect(publicFact.content).toMatchObject({ fileRefs: refs, fileUrl: 'tenant/path.png' });
expect(publicFact.content).not.toHaveProperty('file_refs');
expect(redactJson({ finding: '电话13812345678', file_refs: refs })).toMatchObject({ finding: '电话138****5678', file_refs: refs });
expect(redactJson({ file_refs: [{ ...refs[0], secret: 'no' }] })).toEqual({});
});
test('invalid roles, credentials, duplicates, whitespace IDs and malformed slices are rejected', () => {
const base = { provider: 'meyer', fileId: 'file', role: 'original' };
for (const refs of [[{ ...base, url: 'https://example.com/signed' }], [{ ...base, role: 'other' }],
[{ ...base, role: 'slice' }], [{ ...base, index: 0 }], [{ ...base, fileId: ' file' }], [base, base]]) {
expect(ImageFileRefsSchema.safeParse(refs).success).toBe(false);
}
expect(ImageFileRefsSchema.parse([])).toEqual([]);
expect(ImageStudyCanonicalSchema.parse(meyerBundle().image)).not.toHaveProperty('fileRefs');
});
});
import { BadRequestException, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { ImageFilesService } from '../src/modules/open/image-files.service';
import { ImageFilesResolveSchema } from '../src/modules/open/image-files.schema';
describe('image file access authorization boundary', () => {
const patientId = '11111111-1111-4111-8111-111111111111', imageFactId = '22222222-2222-4222-8222-222222222222';
const application = { hostId: '33333333-3333-4333-8333-333333333333', appId: 'test', clinicNames: {} };
const ref = { provider: 'test', fileId: 'file', role: 'original' };
const row = { hostId: application.hostId, hostName: 'friday', tenantId: 'tenant', sourceUnit: 'brand',
clinicId: 'clinic', content: { file_refs: [ref] } };
const input = { patientId, imageFactId, fileIndexes: [0] };
test('no image authorization means no provider request, including cached access', async () => {
const db = { $queryRaw: jest.fn().mockResolvedValue([]) };
const provider = { provider: 'test', resolve: jest.fn() };
await expect(new ImageFilesService(db as any, [provider]).resolve(application, input)).rejects.toBeInstanceOf(NotFoundException);
expect(provider.resolve).not.toHaveBeenCalled();
expect(db.$queryRaw).toHaveBeenCalledTimes(1);
});
test('files come only from the authorized fact, in caller order; unavailable files remain explicit', async () => {
const thumb = { ...ref, role: 'thumbnail', fileId: 'thumb' };
const db = { $queryRaw: jest.fn().mockResolvedValue([{ ...row, content: { file_refs: [ref, thumb] } }]) };
const provider = { provider: 'test', resolve: jest.fn(async (_ctx: unknown, refs: Array<{ fileId: string }>) => refs.map((r: any) => ({ fileId: r.fileId,
found: r.fileId !== 'thumb', url: r.fileId === 'thumb' ? null : 'https://example.com/file',
expiresAt: r.fileId === 'thumb' ? null : '2099-01-01T00:00:00.000Z' }))) };
const service = new ImageFilesService(db as any, [provider]);
const result = await service.resolve(application, { ...input, fileIndexes: [1, 0] });
expect(result.files.map(f => f.index)).toEqual([1, 0]);
expect(result.files[0]).toMatchObject({ reference: thumb, found: false, url: null, expiresAt: null });
expect(provider.resolve).toHaveBeenCalledWith(expect.objectContaining({ sourceUnit: 'brand' }), [thumb, ref]);
await expect(service.resolve(application, { ...input, fileIndexes: [2] })).rejects.toBeInstanceOf(BadRequestException);
expect(provider.resolve).toHaveBeenCalledTimes(1);
});
test('arbitrary source IDs, empty/duplicate/oversize requests and unsupported sources fail closed', async () => {
expect(ImageFilesResolveSchema.safeParse({ ...input, ossIds: ['foreign'] }).success).toBe(false);
for (const indexes of [[], [0, 0], [-1], [0.5], Array.from({ length: 101 }, (_, i) => i)]) {
expect(ImageFilesResolveSchema.safeParse({ ...input, fileIndexes: indexes }).success).toBe(false);
}
const db = { $queryRaw: jest.fn().mockResolvedValue([row]) };
await expect(new ImageFilesService(db as any, []).resolve(application, input)).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
import { BadGatewayException, ServiceUnavailableException } from '@nestjs/common';
import { createServer, type Server } from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as orgMapping from '../src/integrations/meyer/org-mapping';
import { MeyerImageFileProvider } from '../src/integrations/meyer/image-file-provider';
import { chinaTime } from '../src/integrations/meyer/scheduler';
import { meyerConfig } from './meyer-imaging-fixtures';
describe('Meyer temporary file URL resolution', () => {
let server: Server, directory: string, provider: MeyerImageFileProvider;
let calls: number, tokens: number, lifetime: number, unavailable: boolean, secret: string;
let clock: jest.SpyInstance, mapping: jest.SpyInstance;
const initial = Date.parse('2026-10-20T02:00:00Z');
const context = { hostId: 'host', hostName: 'friday', tenantId: 'friday-market', sourceUnit: 'brand-a', clinicId: 'clinic-a' };
const ref = { provider: 'meyer', connectionId: 'test', fileId: 'original', role: 'original' as const };
beforeEach(async () => {
calls = 0; tokens = 0; lifetime = 12 * 3600000; unavailable = false; secret = 'local-secret';
clock = jest.spyOn(Date, 'now').mockReturnValue(initial);
mapping = jest.spyOn(orgMapping, 'resolveMeyerScopes').mockResolvedValue(meyerConfig().scopes);
server = createServer(async (req, res) => {
let raw = ''; for await (const chunk of req) raw += chunk;
const body = JSON.parse(raw);
res.setHeader('content-type', 'application/json');
if (req.url?.endsWith('/tokens/pub')) { tokens++;
res.end(JSON.stringify({ code: 200, data: { accessToken: 'local-token', accessExpiresAt: '2099-01-01T00:00:00Z' } })); return; }
calls++;
expect(req.url).toBe('/api/v1/imaging/external/images/urls');
expect(body.clinicId).toBe('clinic-a');
if (unavailable) { res.statusCode = 503; res.end('upstream details local-secret'); return; }
res.end(JSON.stringify({ code: 200, data: { urls: body.ossIds.map((ossId: string) => ({ ossId,
found: ossId !== 'missing', url: ossId === 'missing' ? null : `https://example.com/${ossId}?signature=temporary`,
expiresAt: ossId === 'missing' ? null : chinaTime(Date.now() + lifetime) })) } }));
});
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as { port: number }).port;
directory = await mkdtemp(join(tmpdir(), 'meyer-file-access-'));
const path = join(directory, 'config.json');
const { scopes: _scopes, ...fields } = meyerConfig();
await writeFile(path, JSON.stringify({ ...fields, clinicIds: ['clinic-a'], baseUrl: `http://127.0.0.1:${port}/api/v1` }));
provider = new MeyerImageFileProvider({ get: (key: string) => ({ MEYER_CONFIG_PATH: path,
MEYER_APP_KEY: 'local-key', MEYER_APP_SECRET: secret } as Record<string, string>)[key] } as any, {} as any);
});
afterEach(async () => {
clock.mockRestore(); mapping.mockRestore();
server.closeAllConnections(); await new Promise<void>(r => server.close(() => r()));
await rm(directory, { recursive: true, force: true });
});
test('URLs are fetched in one batch, dates normalized to UTC, tokens reused, cache is separate from facts', async () => {
const input = [ref, { ...ref, role: 'thumbnail' as const, fileId: 'missing' }];
const result = await provider.resolve(context, input);
expect(result).toEqual([
{ fileId: 'original', found: true, url: 'https://example.com/original?signature=temporary', expiresAt: '2026-10-20T14:00:00.000Z' },
{ fileId: 'missing', found: false, url: null, expiresAt: null },
]);
expect(await provider.resolve(context, [ref])).toEqual([result[0]]);
expect(calls).toBe(1); expect(tokens).toBe(1);
expect(mapping).toHaveBeenCalledTimes(2); // 命中缓存仍验证当前来源范围。
expect(ref).not.toHaveProperty('url');
});
test('near-expiry, five-minute cache expiry and credential rotation refresh URLs', async () => {
lifetime = 35000;
await provider.resolve(context, [ref]);
clock.mockReturnValue(initial + 6000);
await provider.resolve(context, [ref]);
expect(calls).toBe(2);
lifetime = 12 * 3600000;
clock.mockReturnValue(initial + 40000);
await provider.resolve(context, [ref]);
clock.mockReturnValue(initial + 40000 + 300001);
await provider.resolve(context, [ref]);
expect(calls).toBe(4);
secret = 'rotated-local-secret';
await provider.resolve(context, [ref]);
expect(calls).toBe(5); expect(tokens).toBe(2);
});
test('source connection and current brand mapping must match even if a URL was cached', async () => {
await provider.resolve(context, [ref]);
await expect(provider.resolve(context, [{ ...ref, connectionId: 'another-account' }])).rejects.toBeInstanceOf(ServiceUnavailableException);
mapping.mockResolvedValue([{ ...meyerConfig().scopes[0], sourceUnit: 'brand-b' }]);
await expect(provider.resolve(context, [ref])).rejects.toBeInstanceOf(BadGatewayException);
expect(calls).toBe(1);
});
test('missing configuration and upstream errors never expose credentials or fabricated links', async () => {
const absent = new MeyerImageFileProvider({ get: () => undefined } as any, {} as any);
await expect(absent.resolve(context, [ref])).rejects.toBeInstanceOf(ServiceUnavailableException);
unavailable = true;
await expect(provider.resolve(context, [ref])).rejects.toThrow('影像文件来源暂不可用');
unavailable = false; lifetime = -1000;
await expect(provider.resolve(context, [ref])).rejects.toThrow('影像文件来源暂不可用');
expect(calls).toBe(2);
});
});
...@@ -19,6 +19,7 @@ import { codes } from './meyer-imaging-fixtures'; ...@@ -19,6 +19,7 @@ import { codes } from './meyer-imaging-fixtures';
import { meyerBundle } from './meyer-imaging-fixtures'; import { meyerBundle } from './meyer-imaging-fixtures';
import { meyerConfig, meyerImage, meyerSymptom } from './meyer-imaging-fixtures'; import { meyerConfig, meyerImage, meyerSymptom } from './meyer-imaging-fixtures';
import { runMeyerCli } from '../src/cli/meyer-imaging.cli'; import { runMeyerCli } from '../src/cli/meyer-imaging.cli';
import { backfillMeyerFileRefs } from '../src/integrations/meyer/backfill-file-refs';
import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
...@@ -67,6 +68,59 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => { ...@@ -67,6 +68,59 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
clinicId: 'clinic-a', action: 'visit_registered', subjectType: 'visit', subjectId: 'host-visit', clinicId: 'clinic-a', action: 'visit_registered', subjectType: 'visit', subjectId: 'host-visit',
rawPayload: {}, payloadHash: 'test', occurredAt: new Date() } }); rawPayload: {}, payloadHash: 'test', occurredAt: new Date() } });
} }
test('file refs backfill preserves versions, watermarks, frozen evidence and analysis; subsequent replay is unchanged', async () => {
const bundle = meyerBundle();
await sink.apply(bundle);
const image = await prisma.patientFact.findFirstOrThrow({ where: { hostId, patientId, type: 'image_record' } });
await prisma.$executeRaw`UPDATE patient_facts SET content = content - 'file_refs' WHERE id = ${image.id}::uuid`;
const before = await prisma.patientFact.findUniqueOrThrow({ where: { id: image.id } });
const txBefore = await prisma.patientTransaction.findMany({ where: { hostId }, orderBy: { id: 'asc' } });
expect(await backfillMeyerFileRefs(prisma, meyerConfig(), false)).toMatchObject({ eligible: 1, updated: 0, conflicts: 0 });
expect((await prisma.patientFact.findUniqueOrThrow({ where: { id: image.id } })).content).not.toHaveProperty('file_refs');
expect(await backfillMeyerFileRefs(prisma, meyerConfig(), true)).toMatchObject({ eligible: 1, updated: 1, conflicts: 0 });
const after = await prisma.patientFact.findUniqueOrThrow({ where: { id: image.id } });
expect(after).toEqual({ ...before, content: { ...before.content as object,
file_refs: [{ provider: 'meyer', connectionId: 'test', fileId: 'oss-1', role: 'original' }] } });
expect(await backfillMeyerFileRefs(prisma, meyerConfig(), true)).toMatchObject({ updated: 0, unchanged: 1 });
await sink.apply(bundle);
expect(await prisma.patientFact.count({ where: { hostId } })).toBe(3);
expect(await prisma.patientTransaction.findMany({ where: { hostId }, orderBy: { id: 'asc' } })).toEqual(txBefore);
expect((await prisma.patientFact.findUniqueOrThrow({ where: { id: image.id } })).updatedAt).toEqual(before.updatedAt);
await prisma.$executeRaw`UPDATE patient_facts SET content = jsonb_set(content, '{file_refs}', '[{"provider":"meyer","fileId":"foreign","role":"original"}]'::jsonb) WHERE id = ${image.id}::uuid`;
expect(await backfillMeyerFileRefs(prisma, meyerConfig(), true)).toMatchObject({ updated: 0, conflicts: 1 });
expect(await prisma.persona.count()).toBe(0); expect(await prisma.followupPlan.count()).toBe(0);
});
test('actual token → lookup → resolve enforces host/patient/current-version boundaries without changing facts', async () => {
await sink.apply(meyerBundle());
await prisma.host.update({ where: { id: hostId }, data: { appSecretHash: hashSecret('files-local'), openFactsReadEnabled: true } });
await prisma.host.update({ where: { id: foreignHostId }, data: { appSecretHash: hashSecret('foreign-local'), openFactsReadEnabled: true } });
const provider = { provider: 'meyer', resolve: jest.fn(async (_context: unknown, refs: Array<{ fileId: string }>) =>
refs.map(ref => ({ fileId: ref.fileId, found: true, url: 'https://example.com/file?temporary=1', expiresAt: '2099-01-01T00:00:00.000Z' }))) };
const open = await createMeyerOpenHttp(prisma, [provider]);
try {
const token = (await open.request('/auth/token', { appId: 'meyer-test', appSecret: 'files-local' })).data.accessToken;
const foreign = (await open.request('/auth/token', { appId: 'other', appSecret: 'foreign-local' })).data.accessToken;
const image = await prisma.patientFact.findFirstOrThrow({ where: { hostId, patientId, type: 'image_record' } });
const body = { patientId, imageFactId: image.id, fileIndexes: [0] };
expect((await open.request('/open/image-files/resolve', body)).code).not.toBe(0);
expect((await open.request('/open/image-files/resolve', body, foreign)).code).not.toBe(0);
expect((await open.request('/open/image-files/resolve', { ...body, patientId: otherPatientId }, token)).code).not.toBe(0);
expect(provider.resolve).not.toHaveBeenCalled();
const access = await open.request('/open/image-files/resolve', body, token);
expect(access).toMatchObject({ code: 0, data: { imageFactId: image.id, files: [{ index: 0, found: true,
reference: { provider: 'meyer', connectionId: 'test', fileId: 'oss-1', role: 'original' } }] } });
expect(provider.resolve).toHaveBeenCalledTimes(1);
expect(await prisma.patientFact.findUniqueOrThrow({ where: { id: image.id } })).toEqual(image);
// 旧 active 行不能绕过更高版本的 cancelled 头。
const { id: _id, ...next } = image;
await prisma.patientFact.create({ data: { ...next, version: image.version + 1, status: 'cancelled' } as any });
expect((await open.request('/open/image-files/resolve', body, token)).code).not.toBe(0);
expect(provider.resolve).toHaveBeenCalledTimes(1);
await prisma.host.update({ where: { id: hostId }, data: { openFactsReadEnabled: false } });
expect((await open.request('/open/image-files/resolve', body, token)).code).not.toBe(0);
expect(provider.resolve).toHaveBeenCalledTimes(1);
} finally { await open.close(); }
});
test('automatic mapping rejects absent, conflicting and cross-tenant records; excludes supplemental evidence', async () => { test('automatic mapping rejects absent, conflicting and cross-tenant records; excludes supplemental evidence', async () => {
await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('missing'); await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('missing');
await sink.apply(meyerBundle()); await sink.apply(meyerBundle());
...@@ -172,6 +226,8 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => { ...@@ -172,6 +226,8 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
expect(await prisma.patientTransaction.count({ where: { hostId } })).toBe(3); expect(await prisma.patientTransaction.count({ where: { hostId } })).toBe(3);
const changed = mapMeyerAnalysis(c, c.scopes[0]!, meyerImage(), { ...meyerSymptom(), diagnosis: 'changed' }, codes, observedAt)!; const changed = mapMeyerAnalysis(c, c.scopes[0]!, meyerImage(), { ...meyerSymptom(), diagnosis: 'changed' }, codes, observedAt)!;
await expect(sink.apply(changed)).rejects.toThrow('snapshot differs'); await expect(sink.apply(changed)).rejects.toThrow('snapshot differs');
const changedFiles = mapMeyerAnalysis(c, c.scopes[0]!, { ...meyerImage(), imageOssId: 'another-patient-file' }, meyerSymptom(), codes, observedAt)!;
await expect(sink.apply(changedFiles)).rejects.toThrow('file references changed');
expect(await prisma.patientFact.count({ where: { hostId } })).toBe(3); expect(await prisma.patientFact.count({ where: { hostId } })).toBe(3);
}); });
test('same external patient/image IDs stay isolated across Friday brands; other host untouched', async () => { test('same external patient/image IDs stay isolated across Friday brands; other host untouched', async () => {
...@@ -215,7 +271,7 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => { ...@@ -215,7 +271,7 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
const moved = meyerBundle(); moved.image.patientExternalId = 'patient-2'; const moved = meyerBundle(); moved.image.patientExternalId = 'patient-2';
moved.diagnoses.forEach(d => { d.patientExternalId = 'patient-2'; }); moved.diagnoses.forEach(d => { d.patientExternalId = 'patient-2'; });
await prisma.patient.create({ data: { hostId, tenantId: 'friday-market', sourceUnit: 'brand-a', externalId: 'patient-2' } }); await prisma.patient.create({ data: { hostId, tenantId: 'friday-market', sourceUnit: 'brand-a', externalId: 'patient-2' } });
await expect(sink.apply(moved)).rejects.toThrow('snapshot differs'); await expect(sink.apply(moved)).rejects.toThrow(/snapshot differs|ownership mismatch/);
expect(await prisma.patientFact.count({ where: { hostId, patientId } })).toBe(3); expect(await prisma.patientFact.count({ where: { hostId, patientId } })).toBe(3);
}); });
...@@ -287,7 +343,8 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => { ...@@ -287,7 +343,8 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
} }
expect(diagnoses.find((d: any) => d.content.name === '龋齿').content).toMatchObject({ code: 'K02', toothPosition: '16' }); expect(diagnoses.find((d: any) => d.content.name === '龋齿').content).toMatchObject({ code: 'K02', toothPosition: '16' });
expect(diagnoses.find((d: any) => d.content.name === '可疑龋').content).toMatchObject({ code: null, toothPosition: '51' }); expect(diagnoses.find((d: any) => d.content.name === '可疑龋').content).toMatchObject({ code: null, toothPosition: '51' });
expect(JSON.stringify(result)).not.toMatch(/signature=|oss-1|confidence|_meyer/); expect(image.content.fileRefs).toEqual([{ provider: 'meyer', connectionId: 'test', fileId: 'oss-1', role: 'original' }]);
expect(JSON.stringify(result)).not.toMatch(/signature=|confidence|_meyer/);
const other = await open.request('/auth/token', { appId: 'other', appSecret: 'local-other-open' }); const other = await open.request('/auth/token', { appId: 'other', appSecret: 'local-other-open' });
expect(other.code).toBe(0); expect(other.code).toBe(0);
expect((await open.request('/open/patient-facts/lookup', query, other.data.accessToken)).data.items).toEqual([]); expect((await open.request('/open/patient-facts/lookup', query, other.data.accessToken)).data.items).toEqual([]);
......
...@@ -82,6 +82,17 @@ main() { ...@@ -82,6 +82,17 @@ main() {
# 托管 DB/Redis:COMPOSE_MANAGED=1 叠加 override(不启自带 PG/Redis,URL 走 .env 的托管端点) # 托管 DB/Redis:COMPOSE_MANAGED=1 叠加 override(不启自带 PG/Redis,URL 走 .env 的托管端点)
local COMPOSE=(docker compose -f docker-compose.prod.yml) local COMPOSE=(docker compose -f docker-compose.prod.yml)
[[ "${COMPOSE_MANAGED:-0}" == "1" ]] && COMPOSE+=(-f docker-compose.managed.yml) [[ "${COMPOSE_MANAGED:-0}" == "1" ]] && COMPOSE+=(-f docker-compose.managed.yml)
# 可选来源配置保存在本机 .env;后续常规部署继续共用采集器的凭证和配置。
local meyer_runtime="${PAC_MEYER_RUNTIME_DIR:-}"
if [[ -z "$meyer_runtime" && -f "$SVC_ENV" ]]; then
meyer_runtime=$(sed -n 's/^PAC_MEYER_RUNTIME_DIR=//p' "$SVC_ENV" | tail -1 | tr -d '\"'"'"'')
fi
if [[ -n "$meyer_runtime" ]]; then
[[ "$meyer_runtime" == /* && -f "$meyer_runtime/collector.env" && -f "$meyer_runtime/config.json" ]] \
|| die "美亚共享运行目录缺少 collector.env 或 config.json"
export PAC_MEYER_RUNTIME_DIR="$meyer_runtime"
COMPOSE+=(-f docker-compose.meyer.yml)
fi
COMPOSE+=(--env-file apps/pac-service/.env --env-file apps/pac-web/.env) COMPOSE+=(--env-file apps/pac-service/.env --env-file apps/pac-web/.env)
local SERVICES=(pac-migrate pac-service pac-web) local SERVICES=(pac-migrate pac-service pac-web)
# 语音 ASR 可选(compose profile=asr),默认不部署;要语音助手用 WITH_ASR=1 bash deploy/deploy-prod.sh # 语音 ASR 可选(compose profile=asr),默认不部署;要语音助手用 WITH_ASR=1 bash deploy/deploy-prod.sh
......
# 可选:主服务与独立美亚采集器共用凭证文件和只读来源配置。
# PAC_MEYER_RUNTIME_DIR 指向现有采集目录;不挂载采集状态或临时链接。
services:
pac-service:
env_file:
- ${PAC_MEYER_RUNTIME_DIR:?Meyer runtime directory required}/collector.env
environment:
MEYER_CONFIG_PATH: /runtime/meyer/config.json
volumes:
- ${PAC_MEYER_RUNTIME_DIR}/config.json:/runtime/meyer/config.json:ro
import { z } from 'zod'; import { z } from 'zod';
import { ImageFileRefsSchema } from './image-files';
import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis'; import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
/** /**
...@@ -357,6 +358,7 @@ export const ImageStudyCanonicalSchema = z ...@@ -357,6 +358,7 @@ export const ImageStudyCanonicalSchema = z
fileUrl: z.string().optional().nullable(), fileUrl: z.string().optional().nullable(),
fileName: z.string().optional().nullable(), fileName: z.string().optional().nullable(),
fileType: z.string().optional().nullable(), fileType: z.string().optional().nullable(),
fileRefs: ImageFileRefsSchema.optional().nullable(),
/// 可选且无默认值,旧影像缺失时不改变内容或哈希。 /// 可选且无默认值,旧影像缺失时不改变内容或哈希。
analysis: ImageEmbeddedAnalysisSchema.nullable().optional(), analysis: ImageEmbeddedAnalysisSchema.nullable().optional(),
}) })
......
import { z } from 'zod';
const key = z.string().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/);
/** 文件 ID 是来源内的不透明标识;签名 URL 和凭据不属于长期事实。 */
export const ImageFileRefSchema = z.strictObject({
provider: key,
connectionId: key.optional(),
fileId: z.string().min(1).max(2048).refine(v => v === v.trim() && !/[\u0000-\u001f\u007f]/.test(v)),
role: z.enum(['original', 'thumbnail', 'slice']),
index: z.number().int().min(0).optional(),
}).superRefine((v, ctx) => {
if ((v.role === 'slice') !== (v.index !== undefined)) ctx.addIssue({
code: 'custom', message: 'slice requires an index; other roles must not have an index', path: ['index'],
});
});
export type ImageFileRef = z.infer<typeof ImageFileRefSchema>;
export const ImageFileRefsSchema = z.array(ImageFileRefSchema).max(10000).superRefine((refs, ctx) => {
const seen = new Set<string>();
refs.forEach((ref, i) => {
const id = JSON.stringify([ref.provider, ref.connectionId ?? '', ref.role, ref.index ?? null, ref.fileId]);
if (seen.has(id)) ctx.addIssue({ code: 'custom', message: 'duplicate file reference', path: [i] });
seen.add(id);
});
});
...@@ -14,3 +14,4 @@ export * from './admin'; ...@@ -14,3 +14,4 @@ export * from './admin';
export * from './canonical'; export * from './canonical';
export * from './image-embedded-analysis'; export * from './image-embedded-analysis';
export * from './reason-signals'; export * from './reason-signals';
export * from './image-files';
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