feat(record): 照片 presigned URL 端点(GET /record/photo/:id/url)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a4b7003ed
commit
6ea9ad097b
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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> = {};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user