Files
photo-live/docs/plans/2026-09-28-广告位轮播-实施计划.md
T

699 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 广告位轮播 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 管理后台维护全局/定向广告(图+链接+排序+开关),经 outbox 同步到 ov,观众页相册头部下方轮播展示(4s 自动/悬停暂停/圆点/滑动/新开标签/左上角「广告」徽标/✕ 会话级关闭)。
**Architecture:** 独立 ads 域仿 embed_slots 模式:cn `ads` 表写权威(CRUD + 两步上传 + outbox `ad.updated`/`ad.deleted`)→ sender 推送 → ov `ads_ov` 投影(inbox 消费,tombstone/disabled=删行)→ `GET /v1/public/albums/{token}/ads` 现签出图。设计全文见 [2026-09-28-广告位轮播-设计方案.md](2026-09-28-广告位轮播-设计方案.md)(含 §10 部署顺序硬约束:**ov 必须先于 cn 上线**)。
**Tech Stack:** NestJS + Prisma(MySQL 8.4) + class-validator / pnpm monorepo + openapi-typescript / Vue3 viewer(vue-i18n) + ElementPlus admin / jest + vitest。
---
## 红线与环境前置(每个任务开始前默读)
1. **同步账本(outbox/sync_inbox/sync_state)不清理不重置**;本计划只 INSERT 新事件行。
2. **新契约字段全部 optional**、新端点不影响旧客户端(红线 5)。
3. 本地 e2e 前置:`docker compose -f infra/docker-compose.yml up -d`(MySQL/Redis/MinIO);
**确认无游离 `pnpm dev:worker` 进程**(它会抢消费照片队列造成假失败——记忆教训)。
4. e2e 跑法:`pnpm --filter api run test:e2e`(services/api);单测 `pnpm --filter api run test`。
5. commit 规约:中文 conventional commits,结尾 `Co-Authored-By: Claude Code <noreply@anthropic.com>`。
6. 关键模板文件(写代码前先读对应模板):
- outbox 事务写入:services/api/src/embed/embed.service.ts(`tx.outbox_events.create` + `audit_logs`)
- 上传核验:services/api/src/albums/assets.service.ts(init 权限/限额/白名单 + complete head 三核)
- inbox 分支:services/api/src/sync-inbox/sync-inbox.service.ts(slot-state 分支 ~L414)
- 公开端点:services/api/src/public-ov/public-ov.controller.ts + public-ov.service.ts(presignGet ~L505)
- admin 页面:apps/admin/src/views/system/embed-slots/(index.vue + FormDialog 结构)
- sync e2e:services/api/test/embed.e2e-spec.ts(`drainSender()` + `prismaOv` 断言)
## 文件结构总览
| 域 | 新建 | 修改 |
| --- | --- | --- |
| contracts | — | src/events.ts、src/constants.ts、openapi.yaml、src/generated/openapi.d.ts(gen) |
| storage | — | src/keys.ts(广告键布局) |
| db | migrations/2026…_ads | packages/db-cn/prisma/schema.prisma、packages/db-ov/prisma/schema.prisma + 对应迁移 |
| api(cn) | src/ads/{module,controller,service,dto}.ts、test/ads.e2e-spec.ts | app-cn.module.ts、src/sync/sync.service.ts、src/sync/dto.ts、src/sync-inbox/dto.ts、src/sync-inbox/sync-inbox.service.ts、src/sync/payloads.ts |
| api(ov) | — | src/public-ov/{controller,service}.ts、test/public-ov.e2e-spec.ts |
| sync | — | src/sender.ts |
| viewer | src/components/ads/AdCarousel.vue、src/ads/carousel.ts、测试 | src/api/client.ts、src/pages/ActivityPage.vue、src/i18n/locales/{zh-CN,en}.ts |
| admin | src/api/ads.ts、src/types/api/ads.d.ts、src/views/system/ads/{index.vue,AdsFormDialog.vue}、src/utils/ads/adsLogic.ts+spec | 路由/菜单注册(Task 11 定位)、locales |
---
### Task 1: 契约事件类型(contracts/events.ts)
**Files:** Modify `packages/contracts/src/events.ts`;Test `packages/contracts/src/__tests__/events.test.ts`
- [x] **Step 1: 写失败测试** —— 在 events.test.ts 对照 slot 的既有断言补:
```ts
// ad.* 映射与墓碑(广告位轮播 2026-09-28)
it('ad.updated/ad.deleted → entityType ad;ad.deleted 为墓碑', () => {
expect(syncEntityTypeOfOutboxType('ad.updated')).toBe('ad');
expect(syncEntityTypeOfOutboxType('ad.deleted')).toBe('ad');
expect(isTombstoneOutboxType('ad.deleted')).toBe(true);
expect(isTombstoneOutboxType('ad.updated')).toBe(false);
});
```
(该文件已有的 Equal 双向锁测试会在 union 扩后自动失败——即本步的失败信号。)
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter @photo/contracts run test`,预期 FAIL(类型不存在/数组不匹配)。
- [x] **Step 3: 实现** —— events.ts 四处:
```ts
// ① OutboxEvent type 联合('slot.updated' 后追加)
| 'ad.updated'
| 'ad.deleted'
// ② 载荷接口(SlotSettingsPayload 之后、OUTBOX_EVENT_TYPES 之前)
// —— 广告位(2026-09-28 设计 §4)——
// ad.updated 载荷:ads_ov 投影重建所需的**全量快照**——每次管理面变更整行替换
// (slot.updated 同款);adId 供发送器推导 entityId(outbox 表无此列)。
// albumIds null=全局投放;enabled=false 消费侧按「删投影行」处理(无墓碑下线)。
export interface AdUpdatedPayload {
adId: string;
imageObjectKey: string;
linkUrl: string;
enabled: boolean;
sortOrder: number;
albumIds: string[] | null;
createdAt: string; // ISO UTC;ov 排序 tie-break(设计 §5.2)
entityVersion: number;
}
// ad.deleted 载荷:墓碑(ov 删 ads_ov 行且不被旧事件复活);cn 行已删,
// entityVersion 为删除时刻终值(末次版本+1)。
export interface AdDeletedPayload {
adId: string;
entityVersion: number;
}
// ③ OUTBOX_EVENT_TYPES 数组追加两项(satisfies 锁同步)
'ad.updated',
'ad.deleted',
// ④ SyncEntityType 联合 + switch + 墓碑
export type SyncEntityType = ... | 'ad';
case 'ad.updated':
case 'ad.deleted':
return 'ad';
export function isTombstoneOutboxType(type: OutboxEvent['type']): boolean {
return (
type === 'category.deleted' ||
type === 'photo.deleted' ||
type === 'ad.deleted'
);
}
```
- [x] **Step 4: 跑测试确认通过** —— 同 Step 2 命令,预期 PASS(含既有全量)。
- [x] **Step 5: Commit** —— `feat(contracts): ad.updated/ad.deleted 事件类型与 ad 实体映射`
### Task 2: 契约 openapi + 常量 + 类型再生
**Files:** Modify `packages/contracts/openapi.yaml`、`src/constants.ts`;gen 产物 `src/generated/openapi.d.ts`
- [x] **Step 1: constants.ts 加广告图上限**(注意:照片域上限真实位置是
`services/api/src/uploads/support.ts` 的 `DEFAULT_UPLOAD_MAX_BYTES`(env 可覆盖),
不在 contracts——AD_UPLOAD_MAX_BYTES 落 contracts 是因为 openapi 描述引用它且
无需 env 覆盖;注释里写清这一对照关系,勿照搬 support.ts 的惰性读 env 模式):
```ts
/** 广告图字节上限(2MB):广告不走变体流水线、原图直出且轮播多张同屏,
* 比 UPLOAD_MAX_BYTES(50MB,照片域)收紧(设计 §3)。 */
export const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
```
- [x] **Step 2: openapi.yaml**:
1. `SyncEntityType` enum(~L4791)加 `'ad'`;
2. tags 加 `ads`;
3. 新路径(对照 /admin/embed-slots 与 /public/albums/{token}/photos 的既有写法/错误引用):
- `GET /admin/ads` → `AdminAdListResponse`(bearerAuth)
- `POST /admin/ads/uploads`(filename/bytes/contentType → `AdUploadInitResponse`,bytes 上限引用 AD_UPLOAD_MAX_BYTES 数值 2097152 并描述注明)
- `POST /admin/ads`(`AdCreateRequest` → 201 `AdminAdItem`)
- `PATCH /admin/ads/{adId}`(`AdUpdateRequest` → 200 `AdminAdItem`)
- `DELETE /admin/ads/{adId}`(204)
- `GET /public/albums/{token}/ads`(security: [] → 200 `PublicAdListResponse`;404 同公开相册)
4. schemas(components/schemas,对照相邻 schema 风格):
```yaml
AdminAdItem:
type: object
required: [id, imageObjectKey, imageUrl, linkUrl, enabled, sortOrder, albumIds, createdAt, updatedAt]
properties:
id: { type: string }
title: { type: [string, 'null'], description: 后台备注名(不展示观众) }
imageObjectKey: { type: string }
imageUrl: { type: string, description: 现签 GET(SIGNED_URL_TTL_SECONDS) }
linkUrl: { type: string }
enabled: { type: boolean }
sortOrder: { type: integer }
albumIds:
description: 绑定相册 id 列表;null=全局投放(未绑定)
type: [array, 'null']
items: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
AdminAdListResponse:
type: object
required: [items]
properties:
items: { type: array, items: { $ref: '#/components/schemas/AdminAdItem' } }
AdCreateRequest:
type: object
required: [linkUrl, imageStagingKey, imageBytes, imageContentType]
properties:
title: { type: string, maxLength: 120 }
linkUrl: { type: string, maxLength: 2048, pattern: '^https?://' }
imageStagingKey: { type: string, description: POST /admin/ads/uploads 返回的 staging 对象键 }
imageBytes: { type: integer, minimum: 1, maximum: 2097152 }
imageContentType: { type: string, enum: [image/jpeg, image/png, image/heic] }
sortOrder: { type: integer, minimum: 0 }
albumIds: { type: array, items: { type: string, maxLength: 64 }, description: 未提供或空=全局 }
AdUpdateRequest:
type: object
description: 部分更新;albumIds 未提供=不动,null=转全局,数组=整体替换
properties: # 同 AdCreateRequest 但全部可选,另加
enabled: { type: boolean }
# imageStagingKey/imageBytes/imageContentType 三者必须同时提供(换图)
AdUploadInitRequest:
type: object
required: [filename, bytes, contentType]
properties:
filename: { type: string, maxLength: 255 }
bytes: { type: integer, minimum: 1, maximum: 2097152 }
contentType: { type: string, enum: [image/jpeg, image/png, image/heic] }
AdUploadInitResponse:
type: object
required: [objectKey, credential]
properties:
objectKey: { type: string, description: staging 键 photo-live/{env}/ads/staging/{cuid}.{ext} }
credential: { $ref: '#/components/schemas/UploadCredential' }
PublicAd:
type: object
required: [adId, imageUrl, linkUrl]
properties:
adId: { type: string }
imageUrl: { type: string }
linkUrl: { type: string }
PublicAdListResponse:
type: object
required: [items]
properties:
items: { type: array, items: { $ref: '#/components/schemas/PublicAd' } }
```
- [x] **Step 3: 校验与再生** —— `pnpm --filter @photo/contracts run lint:api`(redocly,预期 0 error)→ `pnpm --filter @photo/contracts run gen` → `pnpm --filter @photo/contracts run typecheck && pnpm --filter @photo/contracts run test` 全绿。
- [x] **Step 4: Commit** —— `feat(contracts): 广告位 openapi 端点/schema 与 AD_UPLOAD_MAX_BYTES,类型再生`
### Task 3: 双库模型与迁移
**Files:** Modify `packages/db-cn/prisma/schema.prisma`、`packages/db-ov/prisma/schema.prisma` + 各自新迁移
- [x] **Step 1: db-cn schema 追加**(对照 embed_slots 模型位置与注释风格):
```prisma
// —— 广告位域(2026-09-28 设计 §2.1):全局/定向投放,写权威 ——
// albumIds null=全局(归一化:空数组存 null);绑定相册删除不级联清理
// (该相册观众页已 404,后台编辑过滤失效项——设计 §2.1 登记)。
model ads {
id String @id @default(cuid())
title String? @db.VarChar(120)
imageObjectKey String @db.VarChar(512)
linkUrl String @db.VarChar(2048)
enabled Boolean @default(true)
sortOrder Int @default(0)
albumIds Json?
entityVersion Int @default(1)
createdAt DateTime @default(now()) @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
@@index([sortOrder, createdAt])
}
```
- [x] **Step 2: db-ov schema 追加**(embed_slots_ov 之后):
```prisma
// —— 广告位副本(cn ads 的展示投影,2026-09-28):无墓碑——ad.deleted 墓碑与
// enabled=false 快照都表达为「删行」(公开不可见);createdAt 为 cn 原值
// (排序 tie-break,非本机生成)。
model ads_ov {
adId String @id @db.VarChar(32)
imageObjectKey String @db.VarChar(512)
linkUrl String @db.VarChar(2048)
sortOrder Int
albumIds Json?
entityVersion Int
createdAt DateTime @db.DateTime(6)
updatedAt DateTime @db.DateTime(6)
@@index([sortOrder, createdAt])
}
```
- [x] **Step 3: 生成迁移**(本地 infra compose 已起):
```bash
pnpm db:migrate -- --name ads
pnpm db-ov:migrate -- --name ads_ov
```
预期:各生成 `migrations/2026xxxxx_ads*/migration.sql`(仅 CREATE TABLE/INDEX,无账本表变更)。
- [x] **Step 4: 验证** —— `pnpm --filter @photo/db-cn run generate && pnpm --filter @photo/db-ov run generate`(或既有 postinstall 链),`pnpm typecheck` 全绿。
- [x] **Step 5: Commit** —— `feat(db): ads 写权威表与 ads_ov 投影表(含迁移)`
### Task 4: 对象键布局(storage/keys.ts)
**Files:** Modify `packages/storage/src/keys.ts`;同步设计文档一行(见 Step 3)
- [x] **Step 1: 实现**(对照 variantsObjectKey 风格;**与设计 §2.1 的差异**:终键带版本段——copyIfAbsent 禁止覆盖,换图必须新键):
```ts
/** 广告图 staging 键(两步上传第一步:presignPut 直传位)。 */
export function adsStagingObjectKey(cuid: string, ext: string): string {
return `${OBJECT_KEY_ROOT}/ads/staging/${cuid}.${ext}`;
}
/** 广告图终键(版本段=entityVersion:换图必然 bump 版本 → 新键,
* copyIfAbsent 禁止覆盖语义下天然防重复/防串图)。 */
export function adObjectKey(adId: string, version: number, ext: string): string {
return `${OBJECT_KEY_ROOT}/ads/${adId}/v${version}.${ext}`;
}
```
- [x] **Step 2: 验证** —— `pnpm --filter @photo/storage run typecheck`(+ 若该包有测试则跑)。
- [x] **Step 3: 回填设计文档** —— 设计方案.md §2.1 的终键示例改为 `ads/{adId}/v{version}.{ext}` 并注明原因(禁止覆盖语义)。
- [x] **Step 4: Commit** —— `feat(storage): 广告对象键布局(staging + 版本化终键)`
### Task 5: ov 消费链(inbox 分支 + sender 推导 + 两处 SYNC_ENTITY_TYPES)
**Files:** Modify `services/api/src/sync-inbox/dto.ts`、`services/api/src/sync-inbox/sync-inbox.service.ts`、`services/sync/src/sender.ts`、`services/api/src/sync/payloads.ts`;Test `services/api/test/embed.e2e-spec.ts` 邻新建 `ads.e2e-spec.ts`(本任务先建同步断言骨架,Task 7 补 CRUD 全路径)
- [x] **Step 1: 写失败测试** —— 新建 `services/api/test/ads.e2e-spec.ts`,骨架对照 embed.e2e-spec.ts(AppCnModule + ov PrismaClient + drainSender helper 复制其实现):
```ts
// 广告位 e2e(2026-09-28 设计 §8):cn CRUD → outbox → drainSender 推送 →
// ads_ov 投影/版本账本断言;public-ov 端到端断言在 public-ov.e2e-spec 补。
// 本任务(Task 5)先落「事件→投影」直断:手工以 AdUpdatedPayload 构造
// outbox 行(绕过 CRUD——Task 7 才有),drainSender 后断言 ads_ov。
```
用例(每条独立 it):
1. `ad.updated` 快照(enabled=true, albumIds=null)→ drainSender → `prismaOv.ads_ov` 行就位(全字段)+ `sync_state {entityType:'ad', version}` 就位;
2. 再推 `ad.updated` v2(enabled=false)→ 行被删(disabled=无投影);
3. 再推 `ad.updated` v3(enabled=true)→ 行复活(同 adId upsert);
4. `ad.deleted` 墓碑 v4 → 行删除;
5. 旧版本重放(v2 迟到于 v3 后)→ 行保持 v3(版本单调不被回滚)。
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`,预期 FAIL(entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。
- [x] **Step 3: 实现**:
```ts
// ① sync-inbox/dto.ts:SYNC_ENTITY_TYPES 数组追加 'ad'
// ②【关键,缺此步全链路不通】decideAndApply ③依赖检查豁免(~L173-185):
// 现逻辑:entityType 非 'album' 且非 'slot-state' 时,若 albums_ov 查不到
// event.albumId 对应相册 → 事件**暂存**等相册到达。ad 事件 albumId 恒为 ''
// → albums_ov 永远查不到 → 所有 ad 事件被永久暂存、永不投影(且
// sweepStaged 只由真实 albumId 的 album 事件触发,无解除路径)。slot-state
// 豁免的既有注释(L173-176)记录的正是这一空 albumId 危害——ad 同款豁免:
// 在豁免条件(形如 entityType !== 'album' && entityType !== 'slot-state')
// 追加 && entityType !== 'ad',并补一行注释「ad 无相册依赖(albumId 恒空串,
// 绑定关系在载荷 albumIds),直接应用」。
// ③ slot-state 分支之后、链尾 return 前,新增 ad 应用分支:
// ad(广告位 2026-09-28):全量快照直投 ads_ov;墓碑或 enabled=false 快照
// 均表达为删行(disabled=公开不可见=无投影,设计 §2.2/§4)。albumIds
// 整体替换;createdAt 取 cn 原值(排序 tie-break,非本机时钟)。
if (event.entityType === 'ad') {
const p = event.payload as unknown as AdUpdatedPayload;
if (event.tombstone || p.enabled === false) {
await tx.ads_ov.deleteMany({ where: { adId: event.entityId } });
} else {
await tx.ads_ov.upsert({
where: { adId: event.entityId },
create: {
adId: event.entityId,
imageObjectKey: p.imageObjectKey,
linkUrl: p.linkUrl,
sortOrder: p.sortOrder,
albumIds: p.albumIds as unknown as Prisma.InputJsonValue,
entityVersion: event.entityVersion,
createdAt: new Date(p.createdAt),
updatedAt: new Date(),
},
update: {
imageObjectKey: p.imageObjectKey,
linkUrl: p.linkUrl,
sortOrder: p.sortOrder,
albumIds: p.albumIds as unknown as Prisma.InputJsonValue,
entityVersion: event.entityVersion,
updatedAt: new Date(),
},
});
}
return undefined;
}
// sender.ts toWireEvent(slot-state 分支后,泛化 categoryId/assetId 兜底前):
} else if (entityType === 'ad') {
if (typeof payload.adId !== 'string') {
throw new Error(`outbox 事件 ${row.id}(${type})载荷缺 adId,无法推导 entityId`);
}
entityId = payload.adId;
}
// sync/payloads.ts:对照 SlotStateUpdatedPayload 导出 AdUpdatedPayload 的
// ov 侧类型再导出(或直接 import @photo/contracts——跟随该文件现有导入风格)。
```
- [x] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。
- [x] **Step 5: Commit** —— `feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)`
### Task 6: cn 服务端——上传初始化(ads module 上半)
**Files:** Create `services/api/src/ads/{ads.module.ts,ads.controller.ts,ads.service.ts,dto.ts}`;Modify `services/api/src/app-cn.module.ts`;Test `services/api/test/ads.e2e-spec.ts`(追加段)
- [x] **Step 1: 写失败测试**(ads.e2e-spec.ts 追加):
```ts
// uploads init:admin 201 返回 staging 键+credential(URL 可真实 PUT,对照
// uploads.e2e 的 presign 断言写法);非 admin 403;匿名 401;bytes>2MB 400;
// contentType 非白名单 400;坏扩展名 400。
```
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`(404 路由不存在)。
- [x] **Step 3: 实现**:
```ts
// dto.ts:AdUploadInitRequestDto(filename/bytes/contentType @IsIn 白名单)、
// AdCreateRequestDto、AdUpdateRequestDto——字段与 openapi Task 2 一一对应;
// linkUrl @Matches(/^https?:\/\//, {message:'linkUrl 必须以 http(s):// 开头'}) @MaxLength(2048)。
// ads.module.ts:
@Module({
imports: [UploadsModule], // 取 STORAGE_PORT(uploads/storage.provider.ts;同 EmbedModule 装配口径)
controllers: [AdsController],
providers: [AdsService],
})
export class AdsModule {}
// app-cn.module.ts imports 追加 AdsModule(EmbedModule 之后)。
// ads.controller.ts(对照 embed.controller.ts):
@Controller('admin/ads')
@UseGuards(SessionGuard)
export class AdsController {
constructor(private readonly adsService: AdsService) {}
@Get() list(@Req() req) { return this.adsService.list(userOf(req)); }
@Post('uploads') initUpload(@Req() req, @Body() dto: AdUploadInitRequestDto) {
return this.adsService.initUpload(userOf(req), dto);
}
@Post() create(@Req() req, @Body() dto: AdCreateRequestDto) {
return this.adsService.create(userOf(req), dto);
}
@Patch(':adId') update(@Req() req, @Param('adId') adId: string, @Body() dto: AdUpdateRequestDto) {
return this.adsService.update(userOf(req), adId, dto);
}
@Delete(':adId') remove(@Req() req, @Param('adId') adId: string) {
return this.adsService.remove(userOf(req), adId);
}
}
// ads.service.ts(本任务先落 initUpload + requireAdmin;CRUD Task 7):
async initUpload(user: AuthUser, dto: AdUploadInitRequestDto) {
this.requireAdmin(user);
if (dto.bytes > AD_UPLOAD_MAX_BYTES) {
throw validationFailed(`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`);
}
const ext = extOfFilename(dto.filename); // 复用 albums/support 或 uploads/support 的既有工具
if (ext === null) throw validationFailed('文件扩展名不接受');
const objectKey = adsStagingObjectKey(cuidLikeId(), ext); // uploads/support.cuidLikeId
const credential = await this.storage.presignPut(objectKey, dto.contentType, dto.bytes);
return { objectKey, credential };
}
```
- [x] **Step 4: 跑测试确认通过**。
- [x] **Step 5: Commit** —— `feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)`
### Task 7: cn 服务端——CRUD + outbox + audit
**Files:** Modify `services/api/src/ads/ads.service.ts`;Test `services/api/test/ads.e2e-spec.ts`
- [x] **Step 1: 写失败测试**(追加;真实 MinIO PUT 仿 uploads.e2e):
```ts
// ① 创建:上传真图(getBytes 生成最小 JPEG 或复用 uploads.e2e 的测试字节)→
// POST /admin/ads → 201:行就位(albumIds 空归一 null)、outbox 行
// {type:'ad.updated', albumId:'', entityVersion:1}、audit_logs('ad.create')、
// imageUrl 为现签 URL;
// ② albumIds 定向:绑定两相册 → 载荷 albumIds=[a,b];传 [] 归一 null;
// 绑定不存在的相册 400;
// ③ linkUrl 非法(javascript:、超长)400;
// ④ PATCH:title/enabled/sortOrder/albumIds 各自 bump entityVersion+1;停用
// (true→false)outbox priority='high';不提供字段不动(albumIds 缺省不碰);
// 换图(新 staging)→ 终键 v{N+1} 新对象(head 可见);
// ⑤ DELETE:行删 + outbox {type:'ad.deleted', priority:'high', entityVersion=末+1};
// 再删 404(同码语义对照 embed);
// ⑥ 权限:非 admin 全端点 403;匿名 401;
// ⑦ 同步贯通(衔接 Task 5 骨架改真路径):创建→drainSender→ads_ov 行就位;
// 停用→drainSender→行删;删除→drainSender→行删。
```
- [x] **Step 2: 跑测试确认失败**。
- [x] **Step 3: 实现** ads.service.ts:
```ts
// 核心片段(完整对照 embed.service.ts 的事务/audit/payload 构造风格):
private buildPayload(ad: AdRow): AdUpdatedPayload {
return {
adId: ad.id,
imageObjectKey: ad.imageObjectKey,
linkUrl: ad.linkUrl,
enabled: ad.enabled,
sortOrder: ad.sortOrder,
albumIds: (ad.albumIds as string[] | null) ?? null,
createdAt: ad.createdAt.toISOString(),
entityVersion: ad.entityVersion,
};
}
// create:requireAdmin → 校验 linkUrl/albumIds(存在性 count 校验+去重+[]→null)
// → head(staging)(null/bytes≠imageBytes/contentType≠imageContentType → 400 UPLOAD_VERIFY_FAILED)
// → $transaction: tx.ads.create({data:{..., imageObjectKey: adObjectKey(id=v1占位)}})
// ※ id 先 cuidLikeId() 应用侧生成(uploads 同款:事务内一次 insert 成形)
// → storage.copyIfAbsent(staging, adObjectKey(adId, 1, ext))
// → tx.outbox_events.create({type:'ad.updated', albumId:'', entityVersion:1,
// payload: buildPayload(row)})
// → tx.audit_logs.create({actorId, action:'ad.create', resourceId: adId})
// update:requireAdmin → 行不存在 404 → 可选换图(同 head+copy 到 v{next} 键)
// → tx.ads.update({...提供字段, entityVersion:{increment:1}})
// → outbox(enabled true→false 时 priority='high')+ audit 'ad.update'
// remove:requireAdmin → 行不存在 404 → tx: delete 行 + outbox {type:'ad.deleted',
// albumId:'', entityVersion: row.entityVersion+1, priority:'high',
// payload:{adId, entityVersion: row.entityVersion+1}} + audit 'ad.delete'
// list:requireAdmin → findMany orderBy [{sortOrder:'asc'},{createdAt:'asc'}]
// → Promise.all map imageUrl = storage.presignGet(imageObjectKey, signedUrlTtl())
```
- [x] **Step 4: 跑测试确认通过**(ads.e2e 全量)。
- [x] **Step 5: Commit** —— `feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图`
### Task 8: cn 对账 'ad' 清扫
**Files:** Modify `services/api/src/sync/dto.ts`(SYNC_ENTITY_TYPES)、`services/api/src/sync/sync.service.ts`;Test `services/sync/test/reconcile.e2e-spec.ts`(追加段)
- [x] **Step 1: 写失败测试** —— reconcile.e2e-spec 追加:seed 两行 ads(一全局一定向)→ 以 `?entityType=ad&since=0` 拉对账清单 → 断言 2 条 wire 事件(entityType 'ad'、albumId ''、载荷全量、确定性 eventId 前缀)。
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter sync run test`(或该包 e2e 脚本名,对照 services/sync/package.json)。
- [x] **Step 3: 实现**:
```ts
// sync/dto.ts:SYNC_ENTITY_TYPES 追加 'ad'
// sync.service.ts reconcile(asset 分支之后):
} else if (query.entityType === 'ad') {
const rows = await this.prisma.ads.findMany({
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
// 广告无相册归属:albumId 恒空串(与 outbox 写入口径一致,设计 §4)
events = rows.map((row) =>
this.toWireEvent('ad', row.id, '', row.entityVersion, false,
buildAdUpdatedPayload(row) /* 对照本文件既有 payload 构造或复用 ads.service 的 builder(提取到共享位置) */),
);
}
```
注意:payload builder 若与 ads.service 重复,提取到 `services/api/src/ads/ad-payload.ts` 共享(DRY,两处单一出处)。
- [x] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed`(既有对账回归不破)。
- [x] **Step 5: Commit** —— `feat(sync): 对账清单 ad 实体当前态清扫`
### Task 9: ov 公开端点
**Files:** Modify `services/api/src/public-ov/public-ov.controller.ts`、`public-ov.service.ts`;Test `services/api/test/public-ov.e2e-spec.ts`(追加段)
- [x] **Step 1: 写失败测试**(public-ov.e2e 追加,ov app 实例 + prismaOv 直种 ads_ov/albums_ov):
```ts
// ① token 有效 + 全局广告 + 绑定本册广告 + 绑定他册广告 → items 恰含前两者,
// sortOrder 升序、并列 createdAt 升序;imageUrl 现签(URL 含签名参数形态);
// ② 相册不存在/offline → 404 同公开相册码;③ 无广告 → items:[];
// ④ 匿名 200(security: [])。
```
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- public-ov`。
- [x] **Step 3: 实现**:
```ts
// controller(getPublicAlbum 之后):
/** 公开广告列表:全局+绑定本册,现签图 URL(广告位轮播 2026-09-28 设计 §5.2)。 */
@Get(':token/ads')
listPublicAds(@Param('token') token: string): Promise<PublicAdListResponse> {
return this.publicService.listPublicAds(token);
}
// service(对照 getPublicAlbum 的相册校验/404 构造与 presignGet 用法 ~L505):
async listPublicAds(token: string): Promise<PublicAdListResponse> {
const album = /* 同 getPublicAlbum 的 albums_ov 按 publicToken 查询与 active 校验(复用其私有查询或提取) */;
if (album === null) throw /* 同码 404 */;
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 items = await Promise.all(
visible.map(async (r) => ({
adId: r.adId,
imageUrl: await this.storage.presignGet(r.imageObjectKey, this.signedUrlTtl()),
linkUrl: r.linkUrl,
})),
);
return { items };
}
```
- [x] **Step 4: 跑测试确认通过**。
- [x] **Step 5: Commit** —— `feat(api): GET /v1/public/albums/{token}/ads 公开广告(全局+定向/现签)`
### Task 10: viewer——AdCarousel 与页面集成
**Files:** Create `apps/viewer/src/components/ads/AdCarousel.vue`、`apps/viewer/src/ads/carousel.ts`;Modify `apps/viewer/src/api/client.ts`、`apps/viewer/src/pages/ActivityPage.vue`、`apps/viewer/src/i18n/locales/{zh-CN,en}.ts`;Test `apps/viewer/src/ads/carousel.spec.ts`(vitest,对照 viewer 既有 spec 位置与配置)
- [x] **Step 1: 纯逻辑模块 + 失败测试**(carousel.ts:轮播推进/环形回绕/暂停语义纯函数,便于 vitest 直测):
```ts
/** 轮播推进:环形(末→首);单条恒 0(不切换)。 */
export function nextIndex(count: number, current: number, step: 1 | -1 = 1): number {
if (count <= 1) return 0;
return (current + step + count) % count;
}
/** 滑动手势判定:水平位移 ≥40px 且水平分量大于垂直分量才视为翻页。 */
export function swipeStep(dx: number, dy: number): 1 | -1 | 0 {
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return 0;
return dx < 0 ? 1 : -1;
}
```
测试:nextIndex 环形/单条;swipeStep 阈值/方向/斜向忽略。先跑 FAIL(文件不存在)再实现 PASS。
**组件级测试(补齐设计 §8 viewer 用例)**:`apps/viewer/src/__tests__/ad-carousel.spec.ts`
——对照既有 `apps/viewer/src/__tests__/embed-carousel.spec.ts` 的 @vue/test-utils
模式覆盖:空 items 不渲染(父级 v-if 已拦,组件层断言 props 契约);单条静态
(无圆点导航节点);多条圆点点击切换;✕ 点击 emit close;slide 点击调
window.open spy(jest.fn 挂 global);autoplay 用 vi.useFakeTimers 推进 4s 断言
index 前进、pointerenter 后暂停。
- [x] **Step 2: client.ts** —— 类型 + 请求(对照 getPublicAlbum 的既有写法逐字模仿其 apiRequest 用法):
```ts
export type PublicAdListResponse = components['schemas']['PublicAdListResponse'];
export type PublicAd = components['schemas']['PublicAd'];
export async function getPublicAds(token: string): Promise<PublicAdListResponse> { /* 同形 */ }
```
- [x] **Step 3: AdCarousel.vue**(完整实现;视觉 token 一律 var(--viewer-*)):
```vue
<script setup lang="ts">
// 广告轮播(设计 §6.2):4s 自动/悬停与触摸暂停/圆点/滑动/新开标签;
// 左上角「广告」徽标(合规可识别性);右上角 ✕ 会话级关闭(emit close,
// sessionStorage 写入在 ActivityPage——组件保持无副作用)。单条静态无圆点。
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { nextIndex, swipeStep } from '../../ads/carousel';
const props = defineProps<{ ads: { adId: string; imageUrl: string; linkUrl: string }[] }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const { t } = useI18n();
const index = ref(0);
const paused = ref(false);
const ROTATE_MS = 4000;
let timer: ReturnType<typeof setInterval> | undefined;
const current = computed(() => props.ads[index.value] ?? props.ads[0]);
const multi = computed(() => props.ads.length > 1);
function go(step: 1 | -1) { index.value = nextIndex(props.ads.length, index.value, step); }
function open(ad: { linkUrl: string }) { window.open(ad.linkUrl, '_blank', 'noopener,noreferrer'); }
let touchX = 0; let touchY = 0;
function onTouchStart(e: TouchEvent) { paused.value = true; touchX = e.touches[0].clientX; touchY = e.touches[0].clientY; }
function onTouchEnd(e: TouchEvent) {
const s = swipeStep(e.changedTouches[0].clientX - touchX, e.changedTouches[0].clientY - touchY);
if (s !== 0) go(s);
paused.value = false;
}
onMounted(() => { timer = setInterval(() => { if (!paused.value && multi.value) go(1); }, ROTATE_MS); });
onBeforeUnmount(() => { if (timer !== undefined) clearInterval(timer); });
</script>
<template>
<section class="ad-carousel" role="region" :aria-label="t('ads.label')"
@pointerenter="paused = true" @pointerleave="paused = false"
@touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd">
<button type="button" class="ad-slide" :aria-label="t('ads.open')" @click="open(current!)">
<img :src="current!.imageUrl" alt="" loading="lazy" draggable="false" />
</button>
<span class="ad-badge">{{ t('ads.label') }}</span>
<button type="button" class="ad-close" :aria-label="t('ads.close')" @click="emit('close')">✕</button>
<nav v-if="multi" class="ad-dots" :aria-label="t('ads.dots')">
<button v-for="(ad, i) in ads" :key="ad.adId" type="button"
:class="{ active: i === index }" :aria-label="t('ads.goto', { n: i + 1 })" @click="index = i" />
</nav>
</section>
</template>
<!-- 样式要点:宽高比 1200/360(aspect-ratio: 10 / 3);img object-fit: cover
100% 宽高;slide 重置为透明按钮占满;徽标左上半透明深色底白字小圆角;
✕ 右上 ≥44px 触达;圆点底部居中;全部颜色/圆角用 --viewer-* token。 -->
```
(样式块按上述要点补全为 scoped CSS——实现时对照 viewer 既有组件样式风格。)
- [x] **Step 4: ActivityPage 集成**:
```ts
import AdCarousel from '../components/ads/AdCarousel.vue';
import { getPublicAds, type PublicAd } from '../api/client';
const ads = ref<PublicAd[]>([]);
// load() 成功分支(album.value = data 之后)追加:
// 广告独立拉取:失败静默(广告缺席不阻塞相册浏览);会话级关闭记忆先行。
ads.value = [];
if (sessionStorage.getItem('viewer:ads-dismissed') !== '1') {
getPublicAds(token.value).then((r) => { ads.value = r.items; }).catch(() => { ads.value = []; });
}
function dismissAds(): void {
sessionStorage.setItem('viewer:ads-dismissed', '1');
ads.value = [];
}
```
模板:`<AlbumHeader … />` 与 `<PhotoFeed … />` 之间插 `<AdCarousel v-if="ads.length > 0" :ads="ads" @close="dismissAds" />`。
- [x] **Step 5: i18n** —— locales/zh-CN.ts:`ads: { label: '广告', close: '关闭广告', open: '打开广告链接', dots: '轮播位置', goto: '第 {n} 张' }`;en:`ads: { label: 'Ad', close: 'Close ads', open: 'Open ad link', dots: 'Carousel position', goto: 'Go to {n}' }`(对照两文件既有结构与导出方式插入)。
- [x] **Step 6: 验证** —— `pnpm --filter viewer run test`(含新 spec)+ `pnpm --filter viewer run typecheck` + `pnpm --filter viewer run build`。
- [x] **Step 7: Commit** —— `feat(viewer): 相册页广告轮播——4s 自动/暂停/滑动/徽标/会话级关闭`
### Task 11: admin——广告位管理页
**Files:** Create `apps/admin/src/api/ads.ts`、`apps/admin/src/types/api/ads.d.ts`、`apps/admin/src/utils/ads/adsLogic.ts` + `adsLogic.spec.ts`、`apps/admin/src/views/system/ads/{index.vue,AdsFormDialog.vue}`;Modify 路由/菜单注册点、locales;先定位:`grep -rn "embed-slots" apps/admin/src/router apps/admin/src/config` 找到 route+menu 注册文件
- [x] **Step 1: 纯逻辑 + 失败测试**(adsLogic.ts,vitest——对照 utils/system/embedSlots.spec.ts 模式):
```ts
/** 投放范围标签:全局 → 'global';定向 → 'targeted'。 */
export function scopeKind(albumIds: string[] | null): 'global' | 'targeted' {
return albumIds === null || albumIds.length === 0 ? 'global' : 'targeted';
}
/** 提交前归一化:去重 + 空数组→null(服务端语义对齐)。 */
export function normalizeAlbumIds(v: string[] | null): string[] | null {
if (v === null) return null;
const uniq = [...new Set(v)];
return uniq.length === 0 ? null : uniq;
}
/** 表单链接校验(与服务端 pattern 同源描述)。 */
export function isValidAdLinkUrl(u: string): boolean {
return /^https?:\/\/.{3,2041}$/.test(u) && u.length <= 2048;
}
```
- [x] **Step 2: api/ads.ts**(对照 embedSlots.ts 逐字风格):
```ts
import request from '@/utils/http'
export function fetchAds() {
return request.get<Api.Ads.ListResponse>({ url: '/v1/admin/ads' })
}
export function fetchInitAdUpload(data: Api.Ads.UploadInitPayload) {
return request.post<Api.Ads.UploadInitResponse>({ url: '/v1/admin/ads/uploads', data })
}
export function fetchCreateAd(data: Api.Ads.CreatePayload) {
return request.post<Api.Ads.Item>({ url: '/v1/admin/ads', data })
}
export function fetchUpdateAd(id: string, data: Api.Ads.UpdatePayload) {
return request.patch<Api.Ads.Item>({ url: `/v1/admin/ads/${id}`, data })
}
export function fetchDeleteAd(id: string) {
return request.del<void>({ url: `/v1/admin/ads/${id}` })
}
```
- [x] **Step 3: 页面**(views/system/ads/index.vue + AdsFormDialog.vue,对照 embed-slots/index.vue + SlotFormDialog.vue 结构):
- index.vue:ElCard 列表——缩略图(行内 imageUrl)、title、linkUrl、投放范围(scopeKind → 「全局」ElTag / 绑定相册数)、enabled ElSwitch(change→fetchUpdateAd({enabled}))、上移/下移(与相邻行互换 sortOrder:两次 fetchUpdateAd,完成后重拉列表)、编辑、删除(ElMessageBox.confirm → fetchDeleteAd)。
- AdsFormDialog.vue:新建/编辑双态——图片上传(fetchInitAdUpload → 裸 fetch PUT(仿 AlbumBannerUpload 的直传写法,Content-Type 与申明一致)→ 持 stagingKey/bytes/contentType 于表单态)、链接输入(isValidAdLinkUrl 规则)、备注名、绑定相册 ElSelect multiple filterable remote(数据源 GET /v1/albums 分页接口——对照相册管理页既有调用)、提交 normalizeAlbumIds 后 fetchCreateAd/fetchUpdateAd。
- 上传控件展示建议文案:1200×360、≤2MB、jpeg/png/heic。
- [x] **Step 4: 路由/菜单/i18n** —— 在 Step 定位到的注册点按 embed-slots 同款登记「广告位」(图标用 ElementPlus 任意广告语义图标,i18n key `route.ads` 等——对照既有菜单多语言落点补 zh-CN/en)。
- [x] **Step 5: 验证** —— `pnpm --filter admin run test`(含 adsLogic.spec)+ `pnpm --filter admin run typecheck` + `pnpm --filter admin run build`。
- [x] **Step 6: Commit** —— `feat(admin): 广告位管理页——上传/链接/绑定相册/开关/排序`
### Task 12: 全量回归与红线自查
- [x] **Step 1: 全套件** —— `pnpm typecheck && pnpm lint && pnpm test`(turbo 全包;**先杀游离 dev:worker**)。
- [x] **Step 2: e2e 全量** —— `pnpm --filter api run test:e2e`(含新增 ads/public-ov 追加段;health 偶发长跑瞬时失败按既有登记单独重跑确认)。
- [x] **Step 3: 红线自查清单** —— ① 账本表无任何 DELETE/TRUNCATE 迁移或代码(`git diff -- '*migration*' | grep -i 'delete\|truncate'` 应只命中 ads 相关新表);② openapi 新 schema 无必填进旧端点;③ flutter 零改动(`git status apps/mobile` 干净);④ viewer 嵌入路由(EmbedPage 树)无 AdCarousel 引用。
- [x] **Step 4: Commit**(如有修补)+ 设计文档勾选回填。
### Task 13: 部署上线(**需用户明确确认后执行**;顺序硬约束:ov 先 cn 后)
- [x] **Step 1: 镜像** —— `git archive HEAD | docker build -f docker/server.Dockerfile -t photo-live-server:<sha>-<日期> .` + 双端 web(VITE_VIEWER_BASE/VITE_EMBED_ORIGIN 按 2026-09-24 定稿:cn 镜像两参=outside,ov 镜像两参=outside)。
- [x] **Step 2: ov 先行** —— 传镜像 → `migrate-deploy ov`(新 ads_ov 表)→
`docker compose -f /opt/photo-live/deploy/docker-compose-ov.yml up -d`(api-ov 获
'ad' 消费能力;编排文件在服务器 deploy 目录,非仓库根)→ 验证 `/v1/health` +
**旧 cn 未发事件期间 ov 空转正常**。
- [x] **Step 3: cn 后行** —— 传镜像 → `migrate-deploy cn` → `up -d`(api/worker/sync)→ **restart web 容器**(坑位①)→ 后台「广告位」页冒烟(建广告→上传→绑定→保存)。
- [x] **Step 4: 端到端验收** —— 观众页(ov 域名)相册页看到轮播/徽标/✕ 关闭(关标签重开重现)/点击新开标签;绑定相册 A 不在相册 B 显示;停用后 ov 副本行删除(`ads_ov` 查询)。
- [x] **Step 5: 验收记录 + 记忆更新** —— docs/plans/progress/ 落档;project-current-work 记忆补广告位上线状态。