feat(api): listUploads 会话条目内嵌照片预览(现签 thumb/browse)
- 契约 UploadSessionPhoto 增可选 preview(PhotoPreview;旧客户端零 break) - list() 批量查 photo_variants,uploader 视角签发:thumb+browse 齐备且 未隐藏/删除才签(「隐藏是运营动作」口径从严;无变体→字段缺省) - presign 本地 SigV4 无网络开销,TTL 同全局 900s - 顺手修 batch-ops ⑦:交接后上传 bytes 撞查重改 71007(登记旧账清偿) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3763,6 +3763,8 @@ components:
|
|||||||
$ref: '#/components/schemas/PhotoProcessingState'
|
$ref: '#/components/schemas/PhotoProcessingState'
|
||||||
visibility:
|
visibility:
|
||||||
$ref: '#/components/schemas/PhotoVisibility'
|
$ref: '#/components/schemas/PhotoVisibility'
|
||||||
|
preview:
|
||||||
|
$ref: '#/components/schemas/PhotoPreview'
|
||||||
|
|
||||||
UploadSessionRecord:
|
UploadSessionRecord:
|
||||||
type: object
|
type: object
|
||||||
@@ -4039,6 +4041,8 @@ components:
|
|||||||
相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除;
|
相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除;
|
||||||
隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null
|
隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null
|
||||||
(PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。
|
(PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。
|
||||||
|
亦用于上传会话历史(UploadSessionPhoto.preview,uploader 本人视角):
|
||||||
|
变体齐备且未隐藏/未删除时签发,否则字段缺省。
|
||||||
required: [thumb, browse]
|
required: [thumb, browse]
|
||||||
properties:
|
properties:
|
||||||
thumb:
|
thumb:
|
||||||
|
|||||||
@@ -1839,6 +1839,7 @@ export interface components {
|
|||||||
readonly photoId: string;
|
readonly photoId: string;
|
||||||
processingState: components["schemas"]["PhotoProcessingState"];
|
processingState: components["schemas"]["PhotoProcessingState"];
|
||||||
visibility: components["schemas"]["PhotoVisibility"];
|
visibility: components["schemas"]["PhotoVisibility"];
|
||||||
|
preview?: components["schemas"]["PhotoPreview"];
|
||||||
};
|
};
|
||||||
UploadSessionRecord: {
|
UploadSessionRecord: {
|
||||||
/** @description 上传会话 ID。 */
|
/** @description 上传会话 ID。 */
|
||||||
@@ -1987,6 +1988,8 @@ export interface components {
|
|||||||
* 相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除;
|
* 相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除;
|
||||||
* 隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null
|
* 隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null
|
||||||
* (PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。
|
* (PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。
|
||||||
|
* 亦用于上传会话历史(UploadSessionPhoto.preview,uploader 本人视角):
|
||||||
|
* 变体齐备且未隐藏/未删除时签发,否则字段缺省。
|
||||||
*/
|
*/
|
||||||
PhotoPreview: {
|
PhotoPreview: {
|
||||||
thumb: components["schemas"]["SignedUrl"];
|
thumb: components["schemas"]["SignedUrl"];
|
||||||
|
|||||||
@@ -511,6 +511,18 @@ export class UploadsService {
|
|||||||
});
|
});
|
||||||
const photoById = new Map(photos.map((photo) => [photo.id, photo]));
|
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 last = page.at(-1);
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
hasMore && last !== undefined
|
hasMore && last !== undefined
|
||||||
@@ -520,7 +532,8 @@ export class UploadsService {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
items: page.map((row) => {
|
items: await Promise.all(
|
||||||
|
page.map(async (row) => {
|
||||||
const photo = photoById.get(row.photoId);
|
const photo = photoById.get(row.photoId);
|
||||||
const record: UploadSessionRecord = {
|
const record: UploadSessionRecord = {
|
||||||
uploadId: row.id,
|
uploadId: row.id,
|
||||||
@@ -538,13 +551,41 @@ export class UploadsService {
|
|||||||
visibility: (photo?.visibility ?? 'unpublished') as UploadSessionRecord['photo']['visibility'],
|
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;
|
return record;
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
nextCursor,
|
nextCursor,
|
||||||
hasMore: nextCursor !== null,
|
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):只读批量——用户点上传(确认
|
* 查重预检(「上传查重前置到入队前」Task 1):只读批量——用户点上传(确认
|
||||||
* 入队)时刻预判服务端历史重复,跨设备/重装/改名均可查到。判定谓词与 init
|
* 入队)时刻预判服务端历史重复,跨设备/重装/改名均可查到。判定谓词与 init
|
||||||
|
|||||||
@@ -652,7 +652,8 @@ describe('batch hide + owner transfer (e2e)', () => {
|
|||||||
expect(oldOwnerManage.status).toBe(403);
|
expect(oldOwnerManage.status).toBe(403);
|
||||||
expect(oldOwnerManage.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
expect(oldOwnerManage.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
// 保留协作者仍可上传(成员行有效即「已加入的相册」)。
|
// 保留协作者仍可上传(成员行有效即「已加入的相册」)。bytes=71007
|
||||||
|
// 避开本册 ownerA 已传照片的字节数(init ③.5 查重会 409 拦同 bytes)。
|
||||||
const upload = await request(cnBase)
|
const upload = await request(cnBase)
|
||||||
.post('/v1/uploads')
|
.post('/v1/uploads')
|
||||||
.set('Authorization', `Bearer ${ownerAToken}`)
|
.set('Authorization', `Bearer ${ownerAToken}`)
|
||||||
@@ -660,7 +661,7 @@ describe('batch hide + owner transfer (e2e)', () => {
|
|||||||
albumId: albumAId,
|
albumId: albumAId,
|
||||||
clientTaskId: 'bo-upload-after-transfer',
|
clientTaskId: 'bo-upload-after-transfer',
|
||||||
filename: 'after-transfer.jpg',
|
filename: 'after-transfer.jpg',
|
||||||
bytes: 1000,
|
bytes: 71007,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
});
|
});
|
||||||
expect(upload.status).toBe(201);
|
expect(upload.status).toBe(201);
|
||||||
|
|||||||
@@ -876,6 +876,72 @@ describe('uploads renew & complete & history (e2e)', () => {
|
|||||||
expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
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<string, Item>(
|
||||||
|
(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 () => {
|
it('BullMQ 入队:complete 后 photo-process 队列恰有一条对应 job(重复 complete 不重复入队)', async () => {
|
||||||
// C-bytes(重试成功)与 C-happy 各完成一次 → 各入队一次;jobId 以 photoId
|
// C-bytes(重试成功)与 C-happy 各完成一次 → 各入队一次;jobId 以 photoId
|
||||||
// 派生去重。等待短暂时间让 enqueue 落 Redis(生产者为 await,理论即时)。
|
// 派生去重。等待短暂时间让 enqueue 落 Redis(生产者为 await,理论即时)。
|
||||||
|
|||||||
Reference in New Issue
Block a user