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,
|
RECORD_MD5_DUP: 45005,
|
||||||
ABNORMAL_NOT_FOUND: 45006,
|
ABNORMAL_NOT_FOUND: 45006,
|
||||||
PREPAID_CARRYOVER_INVALID: 45007,
|
PREPAID_CARRYOVER_INVALID: 45007,
|
||||||
|
RECORD_PHOTO_NOT_FOUND: 45008, // 照片不存在或非本租户(404)
|
||||||
// 服务机构端·质控(46)
|
// 服务机构端·质控(46)
|
||||||
QC_NOT_FOUND: 46001,
|
QC_NOT_FOUND: 46001,
|
||||||
QC_ALREADY_HANDLED: 46002,
|
QC_ALREADY_HANDLED: 46002,
|
||||||
|
|||||||
@ -74,6 +74,13 @@ export class RecordController {
|
|||||||
return this.service.prepaidList(q);
|
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')
|
@Get(':id')
|
||||||
@RequirePermissions(Permissions.record.detail)
|
@RequirePermissions(Permissions.record.detail)
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
|
|||||||
@ -31,6 +31,7 @@ function makeService(
|
|||||||
recordItemCreate?: ReturnType<typeof vi.fn>;
|
recordItemCreate?: ReturnType<typeof vi.fn>;
|
||||||
recordItemDeleteMany?: ReturnType<typeof vi.fn>;
|
recordItemDeleteMany?: ReturnType<typeof vi.fn>;
|
||||||
recordPhotoFindMany?: ReturnType<typeof vi.fn>;
|
recordPhotoFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
recordPhotoFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
recordPhotoCreate?: ReturnType<typeof vi.fn>;
|
recordPhotoCreate?: ReturnType<typeof vi.fn>;
|
||||||
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
|
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
|
||||||
svcOrgFindFirst?: 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 recordItemCreate = overrides.recordItemCreate ?? vi.fn().mockResolvedValue({ id: 'ri1' });
|
||||||
const recordItemDeleteMany = overrides.recordItemDeleteMany ?? vi.fn().mockResolvedValue({ count: 0 });
|
const recordItemDeleteMany = overrides.recordItemDeleteMany ?? vi.fn().mockResolvedValue({ count: 0 });
|
||||||
const recordPhotoFindMany = overrides.recordPhotoFindMany ?? vi.fn().mockResolvedValue([]);
|
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 recordPhotoCreate = overrides.recordPhotoCreate ?? vi.fn().mockResolvedValue({ id: 'rp1' });
|
||||||
const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]);
|
const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]);
|
||||||
const svcOrgFindFirst = overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue(null);
|
const svcOrgFindFirst = overrides.svcOrgFindFirst ?? vi.fn().mockResolvedValue(null);
|
||||||
@ -70,7 +72,7 @@ function makeService(
|
|||||||
update: recordUpdate,
|
update: recordUpdate,
|
||||||
},
|
},
|
||||||
recordItem: { findMany: recordItemFindMany, create: recordItemCreate, deleteMany: recordItemDeleteMany },
|
recordItem: { findMany: recordItemFindMany, create: recordItemCreate, deleteMany: recordItemDeleteMany },
|
||||||
recordPhoto: { findMany: recordPhotoFindMany, create: recordPhotoCreate },
|
recordPhoto: { findMany: recordPhotoFindMany, findFirst: recordPhotoFindFirst, create: recordPhotoCreate },
|
||||||
recordAbnormal: { findMany: recordAbnormalFindMany },
|
recordAbnormal: { findMany: recordAbnormalFindMany },
|
||||||
svcOrg: { findFirst: svcOrgFindFirst },
|
svcOrg: { findFirst: svcOrgFindFirst },
|
||||||
svcNational: { findFirst: svcNationalFindFirst },
|
svcNational: { findFirst: svcNationalFindFirst },
|
||||||
@ -80,6 +82,7 @@ function makeService(
|
|||||||
const storage = {
|
const storage = {
|
||||||
buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'),
|
buildKey: vi.fn().mockReturnValue('record/T001/1-a.jpg'),
|
||||||
upload: vi.fn().mockResolvedValue('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(
|
const service = new RecordService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
@ -100,6 +103,7 @@ function makeService(
|
|||||||
recordItemCreate,
|
recordItemCreate,
|
||||||
recordItemDeleteMany,
|
recordItemDeleteMany,
|
||||||
recordPhotoFindMany,
|
recordPhotoFindMany,
|
||||||
|
recordPhotoFindFirst,
|
||||||
recordPhotoCreate,
|
recordPhotoCreate,
|
||||||
recordAbnormalFindMany,
|
recordAbnormalFindMany,
|
||||||
svcOrgFindFirst,
|
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', () => {
|
describe('RecordService.offlineSync', () => {
|
||||||
it('clientMd5 缺失 → 拒绝', async () => {
|
it('clientMd5 缺失 → 拒绝', async () => {
|
||||||
const { service, recordCreate } = makeService();
|
const { service, recordCreate } = makeService();
|
||||||
|
|||||||
@ -276,6 +276,15 @@ export class RecordService {
|
|||||||
return this.serializeRecord({ ...record, items, photos, abnormals });
|
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) {
|
async list(q: ListRecordQuery) {
|
||||||
const where: Record<string, any> = {};
|
const where: Record<string, any> = {};
|
||||||
|
|||||||
@ -17,4 +17,14 @@ describe('StorageService', () => {
|
|||||||
const key = svc.buildKey('T001', 'a.jpg');
|
const key = svc.buildKey('T001', 'a.jpg');
|
||||||
expect(key).toMatch(/^elder\/T001\/\d+-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);
|
await this.client.putObject(this.bucket, key, buffer, buffer.length);
|
||||||
return key;
|
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