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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user