diff --git a/services/api/src/public/public.controller.ts b/services/api/src/public/public.controller.ts index f43ef9a..26ea6bb 100644 --- a/services/api/src/public/public.controller.ts +++ b/services/api/src/public/public.controller.ts @@ -16,10 +16,12 @@ import { } from '@nestjs/common'; import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; +import { parseCursor } from '../albums/albums.controller'; import { DEFAULT_PUBLIC_PHOTO_PAGE_SIZE, MAX_PUBLIC_PHOTO_PAGE_SIZE, type PublicAlbumResponse, + type PublicPhotoListResponse, PublicService, } from './public.service'; @@ -58,4 +60,24 @@ export class PublicController { ): Promise { return this.publicService.getPublicAlbum(token, locale); } + + /** + * 公开照片列表:仅 published;(publishedAt,id) 稳定倒序键集游标;category + * 限本册分类;locale 不校验取值(非法值静默回退语义,Task 5 决策)。 + */ + @Get(':token/photos') + listPublicPhotos( + @Param('token') token: string, + @Query('category') category: string | undefined, + @Query('locale') locale: string | undefined, + @Query('cursor') cursor: string | undefined, + @Query('limit') limit: string | undefined, + ): Promise { + return this.publicService.listPublicPhotos(token, { + category, + // locale 仅保证「不因非法值报 400」,列表无语言敏感字段,不向下传。 + cursor: parseCursor(cursor), + limit: parsePublicPhotoLimit(limit), + }); + } } diff --git a/services/api/src/public/public.service.ts b/services/api/src/public/public.service.ts index 25cdad5..a7f63ba 100644 --- a/services/api/src/public/public.service.ts +++ b/services/api/src/public/public.service.ts @@ -22,6 +22,12 @@ import type { Prisma } from '@photo/db-cn'; import type { StoragePort } from '@photo/storage'; import { ApiException } from '../api-exception'; import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto'; +import { validationFailed } from '../albums/support'; +import { + decodeKeysetCursor, + encodeKeysetCursor, + keysetWhereDesc, +} from '../shared/cursor'; import { PrismaService } from '../prisma/prisma.service'; import { STORAGE_PORT } from '../uploads/storage.provider'; import { signedUrlTtlSeconds } from '../uploads/support'; @@ -38,6 +44,9 @@ type AlbumRow = Prisma.albumsGetPayload; type AlbumTranslationRow = Prisma.album_translationsGetPayload< Prisma.album_translationsDefaultArgs >; +type PhotoVariantRow = Prisma.photo_variantsGetPayload< + Prisma.photo_variantsDefaultArgs +>; /** 契约 listPublicPhotos:limit 缺省 30,上下界 1..60(§7 建议 30-60)。 */ export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30; @@ -130,6 +139,122 @@ export class PublicService { }; } + /** + * 公开照片列表(Task 23):仅 published,(publishedAt,id) 双列稳定倒序键集 + * 游标(shared/cursor,同批 publishedAt 以 id DESC 决胜)。签名 URL 逐条现签 + * (2×30 次 presign 为 P1a 可接受量级——SigV4 本地计算无网络往返;P5 若成 + * 瓶颈再考虑批量/缩短 TTL 策略,见任务注记)。locale 参数接受但列表响应无 + * 语言敏感字段,语义上仅保证「不因非法值报 400」(Task 5 决策)。 + */ + async listPublicPhotos( + token: string, + query: { + category?: string; + cursor?: string; + limit: number; + }, + ): Promise { + const album = await this.findPublicAlbum(token); + + // category 过滤:须为本相册分类;幽灵/跨册同码 400(契约 category 属查询 + // 参数校验;不区分两种失败,防探测)。 + if (query.category !== undefined && query.category !== '') { + const category = await this.prisma.categories.findUnique({ + where: { id: query.category }, + select: { albumId: true }, + }); + if (category === null || category.albumId !== album.id) { + throw validationFailed('category 不属于该相册'); + } + } + + let decoded: ReturnType = null; + if (query.cursor !== undefined && query.cursor !== '') { + decoded = decodeKeysetCursor(query.cursor); + if (decoded === null) { + throw validationFailed('cursor 不合法'); + } + } + + const where: Prisma.photosWhereInput = { + albumId: album.id, + visibility: 'published', + ...(query.category !== undefined && query.category !== '' + ? { categoryId: query.category } + : {}), + }; + if (decoded !== null) { + Object.assign(where, keysetWhereDesc('publishedAt', 'id', decoded)); + } + const rows = await this.prisma.photos.findMany({ + where, + orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }], + take: query.limit + 1, // 多取一条探测 hasMore(同相册列表) + }); + const hasMore = rows.length > query.limit; + const page = hasMore ? rows.slice(0, query.limit) : rows; + + // 变体行二次查询(扁平外键,应用层组装;发布事务保证 published 照片必有两 + // 变体行)。缺行/缺尺寸属 post-publish 不变量破坏:防御性跳过而非 5xx—— + // 公开页不因单张脏数据整页失败;宽高缺失同理(契约 width/height 必返)。 + const variants = + page.length === 0 + ? [] + : await this.prisma.photo_variants.findMany({ + where: { photoId: { in: page.map((p) => p.id) } }, + }); + const variantsByPhoto = new Map>(); + for (const row of variants) { + let bucket = variantsByPhoto.get(row.photoId); + if (bucket === undefined) { + bucket = new Map(); + variantsByPhoto.set(row.photoId, bucket); + } + bucket.set(row.variant, row); + } + + const items: PublicPhotoListResponse['items'] = []; + for (const photo of page) { + const bucket = variantsByPhoto.get(photo.id); + const thumbRow = bucket?.get('thumb'); + const browseRow = bucket?.get('browse'); + if (thumbRow === undefined || browseRow === undefined) { + continue; + } + // 宽高取 browse 变体尺寸(契约「浏览图宽高,用于列表占位」);变体行缺 + // 尺寸时回退 photos 主行(原始尺寸),两侧均缺 → 同样跳过(必返字段)。 + const width = browseRow.width ?? photo.width; + const height = browseRow.height ?? photo.height; + if (width === null || height === null) { + continue; + } + items.push({ + photoId: photo.id, + width, + height, + thumb: await this.presign(thumbRow.objectKey), + browse: await this.presign(browseRow.objectKey), + likeCount: photo.likeCount, + // published 行的 publishedAt 必非空(worker 发布事务写入);?? 兜底仅 + // 收窄类型,不构成可观察行为。 + publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(), + }); + } + + // 游标取自「DB 页末行」而非「末个发射条目」:被跳过的行不应在下一页重扫 + // (末行被跳过时若以末个发射条目为游标,跳过的行会被反复重取再跳过;取 + // DB 行位置则一次越过)。 + const last = page.at(-1); + const nextCursor = + hasMore && last !== undefined + ? encodeKeysetCursor({ + sort: (last.publishedAt ?? new Date(0)).toISOString(), + id: last.id, + }) + : null; + return { items, nextCursor, hasMore: nextCursor !== null }; + } + /** * 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404 * ALBUM_NOT_PUBLIC。 diff --git a/services/api/test/public.e2e-spec.ts b/services/api/test/public.e2e-spec.ts index c5c1299..8052353 100644 --- a/services/api/test/public.e2e-spec.ts +++ b/services/api/test/public.e2e-spec.ts @@ -12,6 +12,7 @@ // · banner:album_assets(kind=banner) + 真实对象 → 签名 URL 可取回真实字节、 // expiresAt 晚于现在;无资产行 → null; // · viewCount P1a 占位 null;startAt/endAt ISO + timezone 原样返回。 +// Task 23 覆盖(listPublicPhotos,见下方第二个 describe 块头注)。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; @@ -52,8 +53,24 @@ async function makeJpeg( .toBuffer(); } +/** + * cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 + * alias 零_pad 索引参与 ID 字典序:同批创建(时间戳相同或单调)时 ID 升序 == + * 创建序,为 id DESC 决胜断言提供确定性。 + */ +function cuidLike(alias: string): string { + return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`; +} + const KEY_ROOT = 'photo-live/dev'; +/** 与 src/publish.service.ts(worker)variantsObjectKey 同布局的断言字面量。 */ +const variantKey = ( + albumId: string, + photoId: string, + variant: 'thumb' | 'browse', +) => `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`; + interface SeedAlbumOptions { alias: string; defaultLocale?: string; @@ -135,6 +152,84 @@ describe('public (e2e)', () => { .query(query ?? {}); } + function listPublicPhotos( + token: string, + query?: Record, + ) { + return request(app.getHttpServer()) + .get(`/${API_PREFIX}/public/albums/${token}/photos`) + .query(query ?? {}); + } + + /** + * 直种「已发布完成态」照片:photos 行(ready/published/尺寸/entityVersion=2, + * 对齐 worker 发布事务落库形状)+ 两条 photo_variants + MinIO 真实变体对象 + * (thumb 480x300 / browse 800x500,键布局同 worker publish.service)。 + * visibility/publishedAt 可覆写(hidden/deleted/unpublished/同批时间戳用例)。 + */ + async function seedPhoto( + alias: string, + albumId: string, + options: { + categoryId?: string | null; + visibility?: string; + publishedAt?: Date; + withVariants?: boolean; // false = 连变体行/对象都不种(防御性跳过用例) + } = {}, + ): Promise { + const photoId = cuidLike(alias); + const [thumb, browse] = await Promise.all([ + makeJpeg(480, 300), + makeJpeg(800, 500), + ]); + if (options.withVariants !== false) { + await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg'); + await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg'); + } + await prisma.photos.create({ + data: { + id: photoId, + albumId, + uploaderId: `u-${alias}`, + categoryId: options.categoryId ?? null, + objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`, + bytes: browse.length, + width: 800, + height: 500, + uploadedAt: new Date(), + publishedAt: options.publishedAt ?? new Date(), + processingState: 'ready', + visibility: options.visibility ?? 'published', + entityVersion: 2, + }, + }); + if (options.withVariants !== false) { + await prisma.photo_variants.createMany({ + data: [ + { + photoId, + variant: 'thumb', + objectKey: variantKey(albumId, photoId, 'thumb'), + bytes: thumb.length, + version: 1, + width: 480, + height: 300, + }, + { + photoId, + variant: 'browse', + objectKey: variantKey(albumId, photoId, 'browse'), + bytes: browse.length, + version: 1, + width: 800, + height: 500, + }, + ], + }); + } + return photoId; + } + beforeAll(async () => { await resetDb(); const moduleRef = await Test.createTestingModule({ @@ -369,4 +464,288 @@ describe('public (e2e)', () => { expect(res.body.publicToken).toMatch(/^[0-9a-z]{20,32}$/); }); }); + + // Task 23:GET /public/albums/{token}/photos(listPublicPhotos)。 + // 35 张已发布照片跨两分类(catA 20 张 / catB 15 张),publishedAt 显式回写 + // 互不相同的分钟(排序与真实写入时序解耦);另种 hidden/deleted/unpublished + // 各一张(带完整变体)验证永不出现。变体对象为 MinIO 真实 JPEG。 + // 分页确定性:ID 内嵌零_pad 索引(cuidLike),同批创建下 ID 升序==创建序, + // id DESC 决胜可精确断言。 + describe('Task 23:GET /public/albums/{token}/photos(listPublicPhotos)', () => { + const PAGINATION_BASE = Date.parse('2026-09-12T08:00:00.000Z'); + let feedToken: string; + let feedAlbumId: string; + let catAId: string; + let catBId: string; + let feedIds: string[] = []; // indices 0..34,publishedAt 升序(索引大=新) + + beforeAll(async () => { + const feed = await seedAlbum({ + alias: 'pub-feed', + translations: [ + { locale: 'zh-CN', title: '照片流相册' }, + { locale: 'en', title: 'Photo Feed Album' }, + ], + }); + feedAlbumId = feed.id; + feedToken = feed.publicToken; + catAId = await seedCategory(feed.id, 1, [ + { locale: 'zh-CN', name: '日场' }, + { locale: 'en', name: 'Day' }, + ]); + catBId = await seedCategory(feed.id, 2, [ + { locale: 'zh-CN', name: '晚场' }, + { locale: 'en', name: 'Night' }, + ]); + + // 35 张:i=0..34,前 20 张 catA、后 15 张 catB;publishedAt = base + i 分钟。 + const ids: string[] = []; + for (let i = 0; i < 35; i++) { + ids.push( + await seedPhoto(`pl${String(i).padStart(2, '0')}`, feedAlbumId, { + categoryId: i < 20 ? catAId : catBId, + publishedAt: new Date(PAGINATION_BASE + i * 60_000), + }), + ); + } + feedIds = ids; + + // 同 publishedAt 批次:索引 10..14 五张回写同一时间戳(第 12 张的), + // 该批次在排序中连续且按 id DESC(== 索引倒序)决胜。 + await prisma.photos.updateMany({ + where: { id: { in: ids.slice(10, 15) } }, + data: { publishedAt: new Date(PAGINATION_BASE + 12 * 60_000) }, + }); + + // 永不出现的三种状态(带完整变体,排除「无变体被跳过」的干扰归因)。 + await seedPhoto('plhidden', feedAlbumId, { + categoryId: catAId, + visibility: 'hidden', + }); + await seedPhoto('pldeleted', feedAlbumId, { + categoryId: catAId, + visibility: 'deleted', + }); + await seedPhoto('plunpub', feedAlbumId, { + categoryId: catAId, + visibility: 'unpublished', + }); + }); + + it('缺省 limit=30 → 首页 30 条 + nextCursor/hasMore;次页 5 条;两页并集恰 35 张无重复', async () => { + const page1 = await listPublicPhotos(feedToken); + expect(page1.status).toBe(200); + expect(page1.body.items).toHaveLength(30); + expect(page1.body.hasMore).toBe(true); + expect(typeof page1.body.nextCursor).toBe('string'); + + const page2 = await listPublicPhotos(feedToken, { + cursor: page1.body.nextCursor, + }); + expect(page2.status).toBe(200); + expect(page2.body.items).toHaveLength(5); + expect(page2.body.hasMore).toBe(false); + expect(page2.body.nextCursor).toBeNull(); + + const page1Ids = page1.body.items.map((p: { photoId: string }) => p.photoId); + const page2Ids = page2.body.items.map((p: { photoId: string }) => p.photoId); + const union = [...page1Ids, ...page2Ids]; + // 无重复(跨页稳定:键集谓词从不回看已发射行)。 + expect(new Set(union).size).toBe(35); + expect(union.sort()).toEqual([...feedIds].sort()); + // 首页 = 最新 30 张(索引 34..5),次页 = 最旧 5 张(索引 4..0)。 + expect(page2Ids.sort()).toEqual(feedIds.slice(0, 5).sort()); + }); + + it('首页序:publishedAt 降序;末位(第 30 条)为索引 5', async () => { + const page1 = await listPublicPhotos(feedToken); + expect(page1.status).toBe(200); + const ids = page1.body.items.map((p: { photoId: string }) => p.photoId); + // 索引 34..15 publishedAt 互不相同(每分钟一张):严格降序段。 + const expectedHead = []; + for (let i = 34; i >= 15; i--) { + expectedHead.push(feedIds[i]); + } + expect(ids.slice(0, 20)).toEqual(expectedHead); + expect(ids[29]).toBe(feedIds[5]); + }); + + it('同 publishedAt 批次 → id DESC 决胜稳定(批次连续且为索引倒序 14..10)', async () => { + const page1 = await listPublicPhotos(feedToken); + expect(page1.status).toBe(200); + const ids = page1.body.items.map((p: { photoId: string }) => p.photoId); + // 批次时间戳 = base+12min:排序位置在索引 13 之后、11 之前 → 首页内 + // 第 22..26 条(索引 34..15 共 20 条之后)。批次内 id DESC == 索引倒序。 + expect(ids.slice(20, 25)).toEqual([ + feedIds[14], + feedIds[13], + feedIds[12], + feedIds[11], + feedIds[10], + ]); + // publishedAt 相等性自检:批次五张确实同时间戳(否则上面断言是巧合)。 + const batchRows = await prisma.photos.findMany({ + where: { id: { in: feedIds.slice(10, 15) } }, + select: { publishedAt: true }, + }); + expect( + new Set(batchRows.map((r) => (r.publishedAt as Date).getTime())).size, + ).toBe(1); + }); + + it('hidden / deleted / unpublished 照片永不出现', async () => { + const page1 = await listPublicPhotos(feedToken, { limit: 60 }); + expect(page1.status).toBe(200); + expect(page1.body.items).toHaveLength(35); + const visibilityRows = await prisma.photos.findMany({ + where: { albumId: feedAlbumId }, + select: { id: true, visibility: true }, + }); + const nonPublished = visibilityRows + .filter((r) => r.visibility !== 'published') + .map((r) => r.id); + expect(nonPublished).toHaveLength(3); + const ids = page1.body.items.map((p: { photoId: string }) => p.photoId); + for (const id of nonPublished) { + expect(ids).not.toContain(id); + } + }); + + it('条目形状:width/height 数字、thumb 签名 URL 可取回真实字节、expiresAt 未来、likeCount 0', async () => { + const page1 = await listPublicPhotos(feedToken, { limit: 1 }); + expect(page1.status).toBe(200); + const item = page1.body.items[0] as Record; + // 契约 PublicPhoto 必返字段全集。 + expect(Object.keys(item).sort()).toEqual([ + 'browse', + 'height', + 'likeCount', + 'photoId', + 'publishedAt', + 'thumb', + 'width', + ]); + expect(item.photoId).toBe(feedIds[34]); // 最新一张 + expect(typeof item.width).toBe('number'); + expect(typeof item.height).toBe('number'); + expect(item.width).toBe(800); // browse 变体宽高(列表占位语义) + expect(item.height).toBe(500); + expect(item.likeCount).toBe(0); + expect(Number.isNaN(Date.parse(String(item.publishedAt)))).toBe(false); + + const thumb = item.thumb as { url: string; expiresAt: string }; + const browse = item.browse as { url: string; expiresAt: string }; + for (const signed of [thumb, browse]) { + expect(Date.parse(signed.expiresAt)).toBeGreaterThan(Date.now()); + const fetched = await fetch(signed.url); + expect(fetched.status).toBe(200); + expect(fetched.headers.get('content-type')).toBe('image/jpeg'); + expect(Buffer.from(await fetched.arrayBuffer()).length).toBeGreaterThan(0); + } + // thumb 与 browse 是不同对象(不同键 → 不同 URL)。 + expect(thumb.url).not.toBe(browse.url); + }); + + it('category 过滤:仅该分类;跨册/幽灵 category → 400 VALIDATION_FAILED', async () => { + const catB = await listPublicPhotos(feedToken, { + category: catBId, + limit: 60, + }); + expect(catB.status).toBe(200); + expect(catB.body.items).toHaveLength(15); + // catB = 索引 20..34;降序首条为索引 34。 + expect(catB.body.items[0].photoId).toBe(feedIds[34]); + expect(catB.body.items.map((p: { photoId: string }) => p.photoId).sort()) + .toEqual(feedIds.slice(20, 35).sort()); + + // 幽灵分类:契约 400 语义(category 属查询参数校验)。 + const ghost = await listPublicPhotos(feedToken, { category: 'no-such-cat' }); + expect(ghost.status).toBe(400); + expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + // 跨册分类:不属于本相册同样 400(不区分幽灵/跨册,防探测)。 + const other = await seedAlbum({ + alias: 'pub-feed-other', + translations: [{ locale: 'zh-CN', title: '另一本相册' }], + }); + const otherCat = await seedCategory(other.id, 1, [ + { locale: 'zh-CN', name: '别册分类' }, + ]); + const cross = await listPublicPhotos(feedToken, { category: otherCat }); + expect(cross.status).toBe(400); + expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('locale 参数:非法值(fr)静默回退语义 → 200(列表无语言敏感字段,不报 400)', async () => { + const res = await listPublicPhotos(feedToken, { locale: 'fr', limit: 5 }); + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(5); + }); + + it('未公开相册:publicEnabled=false / offline → 404 ALBUM_NOT_PUBLIC', async () => { + const disabled = await seedAlbum({ + alias: 'pub-feed-off', + publicEnabled: false, + translations: [{ locale: 'zh-CN', title: '未公开照片流' }], + }); + const offline = await seedAlbum({ + alias: 'pub-feed-dis', + status: 'offline', + translations: [{ locale: 'zh-CN', title: '下线照片流' }], + }); + for (const token of [disabled.publicToken, offline.publicToken]) { + const res = await listPublicPhotos(token); + expect(res.status).toBe(404); + expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); + } + }); + + it('limit=60 → 200 全量一页;limit=0/61/非整数 → 400;非法 cursor → 400', async () => { + const max = await listPublicPhotos(feedToken, { limit: 60 }); + expect(max.status).toBe(200); + expect(max.body.items).toHaveLength(35); + expect(max.body.hasMore).toBe(false); + + for (const limit of [0, 61, 'abc']) { + const res = await listPublicPhotos(feedToken, { limit }); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } + + const badCursor = await listPublicPhotos(feedToken, { + cursor: 'not-a-cursor', + }); + expect(badCursor.status).toBe(400); + expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('无变体行的已发布照片被防御性跳过(post-publish 不变量破坏时不炸公开页)', async () => { + // 直种一张 published 但无 photo_variants 的照片(模拟发布事务半途/手工 + // 破坏);契约 width/height/thumb/browse 必返,缺变体无法成形 → 跳过而非 5xx。 + const orphanId = cuidLike('plorphan'); + await prisma.photos.create({ + data: { + id: orphanId, + albumId: feedAlbumId, + uploaderId: 'u-plorphan', + objectKey: `${KEY_ROOT}/albums/${feedAlbumId}/originals/${orphanId}.jpg`, + bytes: 1000, + width: 800, + height: 500, + uploadedAt: new Date(), + publishedAt: new Date(PAGINATION_BASE + 99 * 60_000), // 最新 + processingState: 'ready', + visibility: 'published', + entityVersion: 2, + }, + }); + const res = await listPublicPhotos(feedToken, { limit: 60 }); + expect(res.status).toBe(200); + const ids = res.body.items.map((p: { photoId: string }) => p.photoId); + expect(ids).not.toContain(orphanId); + expect(ids).toHaveLength(35); + // 游标不因跳过而错位:nextCursor 依旧 null(后面没有更多行)。 + expect(res.body.nextCursor).toBeNull(); + }); + }); });