feat(api): GET /v1/public/albums/{token}/ads 公开广告(全局+定向/现签)

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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 00:44:23 +08:00
parent 9ec8226577
commit 373f0fc533
3 changed files with 163 additions and 0 deletions
@@ -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<PublicPhotoDetailResponse> {
return this.publicService.getPublicPhoto(token, photoId);
}
/**
* 公开广告列表:全局投放 + 绑定本册,现签图 URL(广告位轮播 2026-09-28
* 设计 §5.2)。匿名(契约 security: []——观众页单次加载一次,与公开相册读
* 同量级,不加独立限速档)。路由与 ':token/photos' 无参数冲突(并列段)。
*/
@Get(':token/ads')
listPublicAds(@Param('token') token: string): Promise<PublicAdListResponse> {
return this.publicService.listPublicAds(token);
}
}
@@ -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<PublicAdListResponse> {
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,
+120
View File
@@ -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);
});
});