feat(record): 照片 presigned URL 端点(GET /record/photo/:id/url)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-21 11:41:37 +08:00
parent 2a4b7003ed
commit 6ea9ad097b
6 changed files with 75 additions and 1 deletions

View File

@ -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,

View File

@ -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) {

View File

@ -31,6 +31,7 @@ function makeService(
recordItemCreate?: ReturnType<typeof vi.fn>;
recordItemDeleteMany?: ReturnType<typeof vi.fn>;
recordPhotoFindMany?: ReturnType<typeof vi.fn>;
recordPhotoFindFirst?: ReturnType<typeof vi.fn>;
recordPhotoCreate?: ReturnType<typeof vi.fn>;
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
svcOrgFindFirst?: ReturnType<typeof vi.fn>;
@ -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();

View File

@ -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<string, any> = {};

View File

@ -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');
});
});

View File

@ -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<string> {
return this.client.presignedGetObject(this.bucket, key, expiry);
}
}