From 373f0fc533a23b2bfff2af4c200b62b80a03c1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 29 Sep 2026 00:44:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/v1/public/albums/{token}/ad?= =?UTF-8?q?s=20=E5=85=AC=E5=BC=80=E5=B9=BF=E5=91=8A=EF=BC=88=E5=85=A8?= =?UTF-8?q?=E5=B1=80+=E5=AE=9A=E5=90=91/=E7=8E=B0=E7=AD=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ov 公开端点(ads-carousel Task 9,设计 §5.2):相册校验复用 findPublicAlbumOv(不存在/offline/紧急停用统一 404 ALBUM_NOT_PUBLIC); 可见集 = 全局投放(albumIds=null)∨ 绑定本相册,sortOrder 升序、并列 createdAt 升序;imageUrl 逐条现签 GET(TTL=SIGNED_URL_TTL_SECONDS)。 匿名无 guard、不加独立限速档(观众页单次加载一次,与公开相册读同量级, 量级假设 ≤ 百条:全表捞 + 应用层 JSON 过滤)。 public-ov.e2e 追加 ⑧:prismaOv 直种 albums_ov/ads_ov——过滤/排序/现签 (可取回真实字节)/404 同码同 path/空 items/匿名 200;9/9 通过, ads.e2e 16/16 回归通过。 Co-Authored-By: Claude Code --- .../api/src/public-ov/public-ov.controller.ts | 11 ++ .../api/src/public-ov/public-ov.service.ts | 32 +++++ services/api/test/public-ov.e2e-spec.ts | 120 ++++++++++++++++++ 3 files changed, 163 insertions(+) diff --git a/services/api/src/public-ov/public-ov.controller.ts b/services/api/src/public-ov/public-ov.controller.ts index c017af4..f9a7ef3 100644 --- a/services/api/src/public-ov/public-ov.controller.ts +++ b/services/api/src/public-ov/public-ov.controller.ts @@ -14,6 +14,7 @@ import { parsePublicPhotoLimit } from '../public/public.controller'; import { parseCursor } from '../shared/pagination'; import { validationFailed } from '../albums/support'; import { + type PublicAdListResponse, type PublicAlbumResponse, type PublicPhotoDetailResponse, type PublicPhotoListResponse, @@ -79,4 +80,14 @@ export class PublicOvController { ): Promise { return this.publicService.getPublicPhoto(token, photoId); } + + /** + * 公开广告列表:全局投放 + 绑定本册,现签图 URL(广告位轮播 2026-09-28 + * 设计 §5.2)。匿名(契约 security: []——观众页单次加载一次,与公开相册读 + * 同量级,不加独立限速档)。路由与 ':token/photos' 无参数冲突(并列段)。 + */ + @Get(':token/ads') + listPublicAds(@Param('token') token: string): Promise { + return this.publicService.listPublicAds(token); + } } diff --git a/services/api/src/public-ov/public-ov.service.ts b/services/api/src/public-ov/public-ov.service.ts index e639eab..50729f4 100644 --- a/services/api/src/public-ov/public-ov.service.ts +++ b/services/api/src/public-ov/public-ov.service.ts @@ -47,6 +47,8 @@ export type PublicPhotoListResponse = components['schemas']['PublicPhotoList']; export type PublicPhotoDetailResponse = components['schemas']['PublicPhotoDetail']; +export type PublicAdListResponse = + components['schemas']['PublicAdListResponse']; type SignedUrlResponse = components['schemas']['SignedUrl']; /** 与 cn public.service 同值(契约 listPublicPhotos limit bounds)。 */ @@ -299,6 +301,36 @@ export class PublicOvService { }; } + /** + * 公开广告列表(广告位轮播 2026-09-28 设计 §5.2):相册校验复用公开相册读 + * 同一入口(findPublicAlbumOv——不存在/offline/紧急停用统一 404 + * ALBUM_NOT_PUBLIC);可见集 = 全局投放(albumIds=null)∨ 绑定本相册。 + * 排序 sortOrder 升序、并列 createdAt 升序(副本 @@index 同序)。imageUrl + * 逐条现签 GET(TTL 同公开读 SIGNED_URL_TTL_SECONDS;过期后重拉本端点刷新, + * 契约语义——无 expiresAt 字段,PublicAd 只返 URL 裸串)。无广告空 items + * (非 404)。量级假设广告 ≤ 百条(设计 §5.2 登记):ads_ov 全表捞 + 应用层 + * 过滤(JSON 列 includes),与公开相册读同量级——不加独立限速档。 + */ + async listPublicAds(token: string): Promise { + const album = await this.findPublicAlbumOv(token); + const rows = await this.prisma.ads_ov.findMany({ + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + const visible = rows.filter( + (r) => + r.albumIds === null || (r.albumIds as string[]).includes(album.albumId), + ); + const ttl = signedUrlTtlSeconds(); + const items = await Promise.all( + visible.map(async (r) => ({ + adId: r.adId, + imageUrl: await this.storage.presignGet(r.imageObjectKey, ttl), + linkUrl: r.linkUrl, + })), + ); + return { items }; + } + /** hot 排序页:快照清单(TTL 缓存)offset 切片 → 现取行(跳过中途被撤下的)。 */ private async listHot( albumId: string, diff --git a/services/api/test/public-ov.e2e-spec.ts b/services/api/test/public-ov.e2e-spec.ts index 07d99b8..535ff3b 100644 --- a/services/api/test/public-ov.e2e-spec.ts +++ b/services/api/test/public-ov.e2e-spec.ts @@ -22,13 +22,20 @@ // ⑥ 相册下线:cn publicEnabled=false → ov 404(与 cn 同码); // ⑦ 紧急停用(Task 5 通道 × 公开读):ov disable → ov 404 而 cn 仍 200 // (ov 本地独立位);enable → 恢复。 +// ⑧ 公开广告列表(ads-carousel Task 9,prismaOv 直种不走同步——投影→读 +// 语义聚焦,同步贯通在 ads.e2e ⑯):全局+绑定本册可见、绑定他册剔除; +// sortOrder 升序、并列 createdAt 升序;imageUrl 现签(可取回真实字节); +// 不存在/offline → 404 ALBUM_NOT_PUBLIC(与公开相册读同码);无广告 → +// items:[](非 404);匿名 200(契约 security: [])。 // 真实 MySQL×2 + MinIO(banner/变体真实 JPEG)+ HMAC 同步链路字节一致。 +import { randomBytes } from 'node:crypto'; import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; import sharp from 'sharp'; import { PrismaClient } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; +import type { Prisma } from '@photo/db-ov'; import { ErrorCodes, signSyncBody } from '@photo/contracts'; import { createStorageFromEnv, @@ -561,4 +568,117 @@ describe('public-ov (e2e)', () => { expect(fetched.status).toBe(200); expect(fetched.headers.get('content-type')).toBe('image/jpeg'); }); + + // ── ads-carousel Task 9 追加段:GET /v1/public/albums/{token}/ads ── + // 本段聚焦副本读语义(设计 §5.2):prismaOv 直种 albums_ov/ads_ov(ads.e2e + // ⑯ 已覆盖同步链路贯通)。匿名:getJson 不带 Authorization(控制器无 guard, + // 契约 security: [])。 + + it('⑧ 公开广告:全局+定向过滤、sortOrder/createdAt 排序、现签 imageUrl;404 同码;空 items;匿名 200', async () => { + // 直种三册:主册(可见)/ 空册(可见,无广告)/ 下线册(publicEnabled=false + // ——isAlbumVisibleOv 任一位不满足即统一 404,与 ⑥ 同语义)。 + const seedAlbumOv = async ( + alias: string, + visible: boolean, + ): Promise<{ albumId: string; token: string }> => { + const suffix = randomBytes(6).toString('hex'); + const albumId = `adov-alb-${alias}-${suffix}`; + const token = `adov-tok-${alias}-${suffix}`; + await prismaOv.albums_ov.create({ + data: { + albumId, + publicToken: token, + publicEnabled: visible, + translations: [], + categories: [], + defaultLocale: 'zh-CN', + entityVersion: 1, + }, + }); + return { albumId, token }; + }; + const main = await seedAlbumOv('main', true); + const empty = await seedAlbumOv('empty', true); + const offline = await seedAlbumOv('off', false); + + // 广告图真实对象(现签 URL 取回真实字节的断言标的;banner 断言同款链路)。 + const adJpeg = await makeJpeg(600, 200, { r: 200, g: 80, b: 80 }); + const tgtKey = 'photo-live/dev/ads/adov-tgt/v1.jpg'; + await storage.put(tgtKey, adJpeg, 'image/jpeg'); + + // 广告行:定向本册(sortOrder 10)+ 全局×2(并列 20 → createdAt 升序决胜) + // + 定向他册(sortOrder 1——若过滤失效将排最前,立即暴露)。 + const seedAdOv = async ( + adId: string, + imageObjectKey: string, + sortOrder: number, + createdAt: string, + albumIds: string[] | null, + ) => + prismaOv.ads_ov.create({ + data: { + adId, + imageObjectKey, + linkUrl: `https://sponsor.example/${adId}`, + sortOrder, + // null → SQL NULL(全局投放;同 sync-inbox ad 分支的 cast 形态)。 + albumIds: albumIds as unknown as Prisma.InputJsonValue, + entityVersion: 1, + createdAt: new Date(createdAt), + updatedAt: new Date(), + }, + }); + + // ③ 无广告 → 空 items(契约:非 404)。前置到种全局广告之前——全局投放 + // 对所有公开册可见,「本册视角无广告」态只能在他册定向阶段构造 + // (顺带强断言:定向他册的广告不外溢到无关联册)。 + await seedAdOv('adov-other', 'photo-live/dev/ads/adov-other/v1.jpg', 1, '2026-09-28T08:00:00.000Z', ['adov-alb-elsewhere']); + const none = await getJson(ovBase, `/v1/public/albums/${empty.token}/ads`); + expect(none.status).toBe(200); + expect(none.body.items).toEqual([]); + + await seedAdOv('adov-tgt', tgtKey, 10, '2026-09-28T10:00:00.000Z', [main.albumId]); + await seedAdOv('adov-ga', 'photo-live/dev/ads/adov-ga/v1.jpg', 20, '2026-09-28T09:00:00.000Z', null); + await seedAdOv('adov-gb', 'photo-live/dev/ads/adov-gb/v1.jpg', 20, '2026-09-28T09:01:00.000Z', null); + + // ① 主册:恰含定向本册 + 全局两行;sortOrder 升序、并列 createdAt 升序。 + const res = await getJson(ovBase, `/v1/public/albums/${main.token}/ads`); + expect(res.status).toBe(200); + expect(res.body.items.map((i: { adId: string }) => i.adId)).toEqual([ + 'adov-tgt', + 'adov-ga', + 'adov-gb', + ]); + // 条目形状精确(契约 PublicAd 三字段,无多余键)+ 现签 URL 形态 + // (绝对 URL + SigV4 查询参数)+ 首条可取回直写对象的真实字节。 + for (const item of res.body.items) { + expect(Object.keys(item).sort()).toEqual(['adId', 'imageUrl', 'linkUrl']); + expect(item.linkUrl).toBe(`https://sponsor.example/${item.adId}`); + expect(item.imageUrl).toMatch(/^https?:\/\//); + expect(item.imageUrl).toContain('X-Amz-Signature='); + } + const fetched = await fetch(res.body.items[0].imageUrl); + expect(fetched.ok).toBe(true); + expect((await fetched.arrayBuffer()).byteLength).toBe(adJpeg.length); + + // ② 不存在 / offline → 404 ALBUM_NOT_PUBLIC;与公开相册读同码同 path + // (幽灵 token 在相册端点同为该码——两入口 404 语义一致的基线)。 + const ghostToken = 'g' + '0'.repeat(23); + for (const path of [ + `/v1/public/albums/${ghostToken}/ads`, + `/v1/public/albums/${offline.token}/ads`, + ]) { + const miss = await getJson(ovBase, path); + expect(miss.status).toBe(404); + expect(miss.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); + } + const ghostAlbum = await getJson(ovBase, `/v1/public/albums/${ghostToken}`); + expect(ghostAlbum.status).toBe(404); + expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); + + // ④ 匿名 200:裸 fetch 不带任何凭证头(契约 security: [] 的直接验证)。 + const anon = await fetch(`${ovBase}/v1/public/albums/${main.token}/ads`); + expect(anon.status).toBe(200); + expect(((await anon.json()) as { items: unknown[] }).items).toHaveLength(3); + }); });