diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 2ee8391..8ef830b 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -3763,6 +3763,8 @@ components: $ref: '#/components/schemas/PhotoProcessingState' visibility: $ref: '#/components/schemas/PhotoVisibility' + preview: + $ref: '#/components/schemas/PhotoPreview' UploadSessionRecord: type: object @@ -4039,6 +4041,8 @@ components: 相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除; 隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null (PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。 + 亦用于上传会话历史(UploadSessionPhoto.preview,uploader 本人视角): + 变体齐备且未隐藏/未删除时签发,否则字段缺省。 required: [thumb, browse] properties: thumb: diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index e164e10..0b01405 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -1839,6 +1839,7 @@ export interface components { readonly photoId: string; processingState: components["schemas"]["PhotoProcessingState"]; visibility: components["schemas"]["PhotoVisibility"]; + preview?: components["schemas"]["PhotoPreview"]; }; UploadSessionRecord: { /** @description 上传会话 ID。 */ @@ -1987,6 +1988,8 @@ export interface components { * 相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除; * 隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null * (PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。 + * 亦用于上传会话历史(UploadSessionPhoto.preview,uploader 本人视角): + * 变体齐备且未隐藏/未删除时签发,否则字段缺省。 */ PhotoPreview: { thumb: components["schemas"]["SignedUrl"]; diff --git a/services/api/src/uploads/uploads.service.ts b/services/api/src/uploads/uploads.service.ts index ca1b8d3..357622a 100644 --- a/services/api/src/uploads/uploads.service.ts +++ b/services/api/src/uploads/uploads.service.ts @@ -511,6 +511,18 @@ export class UploadsService { }); const photoById = new Map(photos.map((photo) => [photo.id, photo])); + // 预览变体批量查询(会话历史缩略图,2026-09-29):thumb+browse 齐备才签。 + const variantRows = + page.length === 0 + ? [] + : await this.prisma.photo_variants.findMany({ + where: { photoId: { in: page.map((row) => row.photoId) } }, + select: { photoId: true, variant: true, objectKey: true }, + }); + const variantKeyOf = new Map( + variantRows.map((v) => [`${v.photoId}:${v.variant}`, v.objectKey]), + ); + const last = page.at(-1); const nextCursor = hasMore && last !== undefined @@ -520,31 +532,60 @@ export class UploadsService { }) : null; return { - items: page.map((row) => { - const photo = photoById.get(row.photoId); - const record: UploadSessionRecord = { - uploadId: row.id, - albumId: row.albumId, - clientTaskId: row.clientTaskId, - state: row.state as UploadSessionRecord['state'], - expectedBytes: row.expectedBytes, - contentType: row.contentType, - createdAt: row.createdAt.toISOString(), - sourceDeviceId: row.sourceDeviceId, - photo: { - photoId: row.photoId, - processingState: - (photo?.processingState ?? 'pending_upload') as UploadSessionRecord['photo']['processingState'], - visibility: (photo?.visibility ?? 'unpublished') as UploadSessionRecord['photo']['visibility'], - }, - }; - return record; - }), + items: await Promise.all( + page.map(async (row) => { + const photo = photoById.get(row.photoId); + const record: UploadSessionRecord = { + uploadId: row.id, + albumId: row.albumId, + clientTaskId: row.clientTaskId, + state: row.state as UploadSessionRecord['state'], + expectedBytes: row.expectedBytes, + contentType: row.contentType, + createdAt: row.createdAt.toISOString(), + sourceDeviceId: row.sourceDeviceId, + photo: { + photoId: row.photoId, + processingState: + (photo?.processingState ?? 'pending_upload') as UploadSessionRecord['photo']['processingState'], + visibility: (photo?.visibility ?? 'unpublished') as UploadSessionRecord['photo']['visibility'], + }, + }; + // 预览签发(uploader 本人视角):thumb/browse 变体齐备且未隐藏/删除 + // 才签(对齐「隐藏是运营动作」;管理端 manager 可签的口径在此从严—— + // 避免按相册批量鉴权扇出)。处理未完成(无变体)→ 字段缺省,App 回退 + // 占位图标。SigV4 本地计算,无网络副作用。 + const thumbKey = variantKeyOf.get(`${row.photoId}:thumb`); + const browseKey = variantKeyOf.get(`${row.photoId}:browse`); + if ( + thumbKey !== undefined && + browseKey !== undefined && + photo?.visibility !== 'hidden' && + photo?.visibility !== 'deleted' + ) { + record.photo.preview = { + thumb: await this.presignVariantUrl(thumbKey), + browse: await this.presignVariantUrl(browseKey), + }; + } + return record; + }), + ), nextCursor, hasMore: nextCursor !== null, }; } + /** 现签变体短期 GET 凭证(TTL 惰性读取;同 photos.service 预览模式)。 */ + private async presignVariantUrl(objectKey: string): Promise<{ + url: string; + expiresAt: string; + }> { + const ttl = signedUrlTtlSeconds(); + const url = await this.storage.presignGet(objectKey, ttl); + return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() }; + } + /** * 查重预检(「上传查重前置到入队前」Task 1):只读批量——用户点上传(确认 * 入队)时刻预判服务端历史重复,跨设备/重装/改名均可查到。判定谓词与 init diff --git a/services/api/test/batch-ops.e2e-spec.ts b/services/api/test/batch-ops.e2e-spec.ts index 9f50759..664dbbc 100644 --- a/services/api/test/batch-ops.e2e-spec.ts +++ b/services/api/test/batch-ops.e2e-spec.ts @@ -652,7 +652,8 @@ describe('batch hide + owner transfer (e2e)', () => { expect(oldOwnerManage.status).toBe(403); expect(oldOwnerManage.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); - // 保留协作者仍可上传(成员行有效即「已加入的相册」)。 + // 保留协作者仍可上传(成员行有效即「已加入的相册」)。bytes=71007 + // 避开本册 ownerA 已传照片的字节数(init ③.5 查重会 409 拦同 bytes)。 const upload = await request(cnBase) .post('/v1/uploads') .set('Authorization', `Bearer ${ownerAToken}`) @@ -660,7 +661,7 @@ describe('batch hide + owner transfer (e2e)', () => { albumId: albumAId, clientTaskId: 'bo-upload-after-transfer', filename: 'after-transfer.jpg', - bytes: 1000, + bytes: 71007, contentType: 'image/jpeg', }); expect(upload.status).toBe(201); diff --git a/services/api/test/uploads.e2e-spec.ts b/services/api/test/uploads.e2e-spec.ts index 31ae24a..4166929 100644 --- a/services/api/test/uploads.e2e-spec.ts +++ b/services/api/test/uploads.e2e-spec.ts @@ -876,6 +876,72 @@ describe('uploads renew & complete & history (e2e)', () => { expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); + it('GET /uploads preview:变体齐备且未隐藏/删除 → 现签 thumb/browse;无变体或 hidden → 无 preview 字段', async () => { + // C-happy:补齐 thumb/browse 变体 + ready/published → 应签发。 + await prisma.photo_variants.createMany({ + data: (['thumb', 'browse'] as const).map((variant) => ({ + photoId: cHappy.photoId, + variant, + objectKey: `photo-live/dev/albums/${albumId}/variants/${cHappy.photoId}/1/${variant}.jpg`, + bytes: 100, + version: 1, + width: 480, + height: 300, + })), + }); + await prisma.photos.update({ + where: { id: cHappy.photoId }, + data: { processingState: 'ready', visibility: 'published' }, + }); + // C-bytes:变体齐备但 hidden → 不签(「隐藏是运营动作」,历史回退占位)。 + await prisma.photo_variants.createMany({ + data: (['thumb', 'browse'] as const).map((variant) => ({ + photoId: cBytes.photoId, + variant, + objectKey: `photo-live/dev/albums/${albumId}/variants/${cBytes.photoId}/1/${variant}.jpg`, + bytes: 100, + version: 1, + width: 480, + height: 300, + })), + }); + await prisma.photos.update({ + where: { id: cBytes.photoId }, + data: { processingState: 'ready', visibility: 'hidden' }, + }); + + const res = await listUploads(memberToken, { limit: 10 }); + expect(res.status).toBe(200); + type Item = { + clientTaskId: string; + photo: { + preview?: { + thumb: { url: string; expiresAt: string }; + browse: { url: string; expiresAt: string }; + }; + }; + }; + const byTask = new Map( + (res.body.items as Item[]).map((i) => [i.clientTaskId, i]), + ); + + // 签发:URL 含变体对象键 + 签名参数;expiresAt 为 ISO 时间串。 + const happy = byTask.get('C-happy')!; + expect(happy.photo.preview).toBeDefined(); + expect(happy.photo.preview!.thumb.url).toContain( + `/variants/${cHappy.photoId}/1/thumb.jpg?`, + ); + expect(happy.photo.preview!.browse.url).toContain( + `/variants/${cHappy.photoId}/1/browse.jpg?`, + ); + expect(typeof happy.photo.preview!.thumb.expiresAt).toBe('string'); + + // 无变体(未处理)与 hidden → 无 preview 字段(App 回退占位图标)。 + expect(byTask.get('C-type')!.photo.preview).toBeUndefined(); + expect(byTask.get('C-bytes')!.photo.preview).toBeUndefined(); + expect(byTask.get('R1')!.photo.preview).toBeUndefined(); + }); + it('BullMQ 入队:complete 后 photo-process 队列恰有一条对应 job(重复 complete 不重复入队)', async () => { // C-bytes(重试成功)与 C-happy 各完成一次 → 各入队一次;jobId 以 photoId // 派生去重。等待短暂时间让 enqueue 落 Redis(生产者为 await,理论即时)。