From 6ea9ad097bb7630d476e18018d2ba9d0885d0acf Mon Sep 17 00:00:00 2001 From: liuyun Date: Wed, 21 Oct 2026 11:41:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(record):=20=E7=85=A7=E7=89=87=20presigned?= =?UTF-8?q?=20URL=20=E7=AB=AF=E7=82=B9=EF=BC=88GET=20/record/photo/:id/url?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/common/error-codes.ts | 1 + src/record/record.controller.ts | 7 +++++ src/record/record.service.spec.ts | 44 ++++++++++++++++++++++++++++- src/record/record.service.ts | 9 ++++++ src/storage/storage.service.spec.ts | 10 +++++++ src/storage/storage.service.ts | 5 ++++ 6 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/common/error-codes.ts b/src/common/error-codes.ts index a9add69..f2d791b 100644 --- a/src/common/error-codes.ts +++ b/src/common/error-codes.ts @@ -58,6 +58,7 @@ export const ErrorCodes = { RECORD_MD5_DUP: 45005, ABNORMAL_NOT_FOUND: 45006, PREPAID_CARRYOVER_INVALID: 45007, + RECORD_PHOTO_NOT_FOUND: 45008, // 照片不存在或非本租户(404) // 服务机构端·质控(46) QC_NOT_FOUND: 46001, QC_ALREADY_HANDLED: 46002, diff --git a/src/record/record.controller.ts b/src/record/record.controller.ts index c1771e9..0498872 100644 --- a/src/record/record.controller.ts +++ b/src/record/record.controller.ts @@ -74,6 +74,13 @@ export class RecordController { return this.service.prepaidList(q); } + @Get('photo/:id/url') + @RequirePermissions(Permissions.record.detail) + @AuditLog('record', 'photo-url') + photoUrl(@Param('id') id: string) { + return this.service.photoUrl(id); + } + @Get(':id') @RequirePermissions(Permissions.record.detail) detail(@Param('id') id: string) { diff --git a/src/record/record.service.spec.ts b/src/record/record.service.spec.ts index e98eef0..1d9c505 100644 --- a/src/record/record.service.spec.ts +++ b/src/record/record.service.spec.ts @@ -31,6 +31,7 @@ function makeService( recordItemCreate?: ReturnType; recordItemDeleteMany?: ReturnType; recordPhotoFindMany?: ReturnType; + recordPhotoFindFirst?: ReturnType; recordPhotoCreate?: ReturnType; recordAbnormalFindMany?: ReturnType; svcOrgFindFirst?: ReturnType; @@ -51,6 +52,7 @@ function makeService( const recordItemCreate = overrides.recordItemCreate ?? vi.fn().mockResolvedValue({ id: 'ri1' }); const recordItemDeleteMany = overrides.recordItemDeleteMany ?? vi.fn().mockResolvedValue({ count: 0 }); const recordPhotoFindMany = overrides.recordPhotoFindMany ?? vi.fn().mockResolvedValue([]); + const recordPhotoFindFirst = overrides.recordPhotoFindFirst ?? vi.fn().mockResolvedValue(null); const recordPhotoCreate = overrides.recordPhotoCreate ?? vi.fn().mockResolvedValue({ id: 'rp1' }); const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]); const svcOrgFindFirst = overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue(null); @@ -70,7 +72,7 @@ function makeService( update: recordUpdate, }, recordItem: { findMany: recordItemFindMany, create: recordItemCreate, deleteMany: recordItemDeleteMany }, - recordPhoto: { findMany: recordPhotoFindMany, create: recordPhotoCreate }, + recordPhoto: { findMany: recordPhotoFindMany, findFirst: recordPhotoFindFirst, create: recordPhotoCreate }, recordAbnormal: { findMany: recordAbnormalFindMany }, svcOrg: { findFirst: svcOrgFindFirst }, svcNational: { findFirst: svcNationalFindFirst }, @@ -80,6 +82,7 @@ function makeService( const storage = { buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'), upload: vi.fn().mockResolvedValue('record/T001/1-a.jpg'), + presignGet: vi.fn().mockResolvedValue('http://minio/carelink/record/T001/1-a.jpg?x=1'), }; const service = new RecordService( prisma as unknown as PrismaService, @@ -100,6 +103,7 @@ function makeService( recordItemCreate, recordItemDeleteMany, recordPhotoFindMany, + recordPhotoFindFirst, recordPhotoCreate, recordAbnormalFindMany, svcOrgFindFirst, @@ -469,6 +473,44 @@ describe('RecordService.photoUpload', () => { }); }); +describe('RecordService.photoUrl', () => { + it('找到照片 → 返回 presigned url', async () => { + const recordPhotoFindFirst = vi.fn().mockResolvedValue({ id: 'rp1', filePath: 'record/T001/1-a.jpg' }); + const { service, storage } = makeService({ recordPhotoFindFirst }); + + const res = await runWithRequestContext(ctx, () => service.photoUrl('rp1')); + + expect(recordPhotoFindFirst).toHaveBeenCalledWith({ where: { id: 'rp1' } }); + expect(storage.presignGet).toHaveBeenCalledWith('record/T001/1-a.jpg'); + expect(res).toEqual({ url: 'http://minio/carelink/record/T001/1-a.jpg?x=1' }); + }); + + it('找不到照片 → RECORD_PHOTO_NOT_FOUND(404)', async () => { + const recordPhotoFindFirst = vi.fn().mockResolvedValue(null); + const { service, storage } = makeService({ recordPhotoFindFirst }); + + await expect( + runWithRequestContext(ctx, () => service.photoUrl('missing')), + ).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_PHOTO_NOT_FOUND } }); + + expect(storage.presignGet).not.toHaveBeenCalled(); + }); + + it('跨租户查不到:findFirst 由 $extends 注入 tenantCode,他人租户照片返回 null → 抛错', async () => { + // 生产环境 $extends 会把 where 加上 ctx.tenantCode,跨租户 photo 因此查不到(findFirst 返回 null)。 + const recordPhotoFindFirst = vi.fn().mockResolvedValue(null); + const { service, storage } = makeService({ recordPhotoFindFirst }); + + await expect( + runWithRequestContext(ctx, () => service.photoUrl('other-tenant-photo')), + ).rejects.toMatchObject({ response: { code: ErrorCodes.RECORD_PHOTO_NOT_FOUND } }); + + // 服务层只按 id 查(不含显式 tenantCode),租户隔离由 $extends 统一注入保证。 + expect(recordPhotoFindFirst).toHaveBeenCalledWith({ where: { id: 'other-tenant-photo' } }); + expect(storage.presignGet).not.toHaveBeenCalled(); + }); +}); + describe('RecordService.offlineSync', () => { it('clientMd5 缺失 → 拒绝', async () => { const { service, recordCreate } = makeService(); diff --git a/src/record/record.service.ts b/src/record/record.service.ts index 6ba0795..450131a 100644 --- a/src/record/record.service.ts +++ b/src/record/record.service.ts @@ -276,6 +276,15 @@ export class RecordService { return this.serializeRecord({ ...record, items, photos, abnormals }); } + /** 照片预签名 URL:按 id 查 record_photo(tenantCode 由 $extends 注入,跨租户查不到)→ 生成 GET 预签名地址。 */ + async photoUrl(id: string) { + const photo = await this.prisma.client.recordPhoto.findFirst({ where: { id } }); + if (!photo) { + throw new BusinessException(ErrorCodes.RECORD_PHOTO_NOT_FOUND, '照片不存在', HttpStatus.NOT_FOUND); + } + return { url: await this.storage.presignGet(photo.filePath) }; + } + /** 列表:按老人/护理员/日期/状态筛选。 */ async list(q: ListRecordQuery) { const where: Record = {}; diff --git a/src/storage/storage.service.spec.ts b/src/storage/storage.service.spec.ts index 2d4b386..5da8994 100644 --- a/src/storage/storage.service.spec.ts +++ b/src/storage/storage.service.spec.ts @@ -17,4 +17,14 @@ describe('StorageService', () => { const key = svc.buildKey('T001', 'a.jpg'); expect(key).toMatch(/^elder\/T001\/\d+-a\.jpg$/); }); + + it('presignGet 生成 GET 预签名 URL(默认 1 小时)', async () => { + const presignedGetObject = vi.fn().mockResolvedValue('http://minio/carelink/record/T001/a.jpg?x=1'); + const svc = new StorageService(); + (svc as any).client = { presignedGetObject }; + (svc as any).bucket = 'carelink'; + const url = await svc.presignGet('record/T001/a.jpg'); + expect(presignedGetObject).toHaveBeenCalledWith('carelink', 'record/T001/a.jpg', 3600); + expect(url).toBe('http://minio/carelink/record/T001/a.jpg?x=1'); + }); }); diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts index 971a88e..0de5550 100644 --- a/src/storage/storage.service.ts +++ b/src/storage/storage.service.ts @@ -27,4 +27,9 @@ export class StorageService { await this.client.putObject(this.bucket, key, buffer, buffer.length); return key; } + + /** 生成 GET 预签名 URL(默认 1 小时有效),供前端临时预览/下载。 */ + async presignGet(key: string, expiry = 3600): Promise { + return this.client.presignedGetObject(this.bucket, key, expiry); + } }