2026-09-28 18:41:54 +08:00
# 广告位轮播 实施计划
> **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`
2026-09-28 23:03:32 +08:00
- [x] **Step 1: 写失败测试** —— 在 events.test.ts 对照 slot 的既有断言补:
2026-09-28 18:41:54 +08:00
```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 扩后自动失败——即本步的失败信号。)
2026-09-28 23:03:32 +08:00
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter @photo/contracts run test` ,预期 FAIL(类型不存在/数组不匹配)。
2026-09-28 18:41:54 +08:00
2026-09-28 23:03:32 +08:00
- [x] **Step 3: 实现** —— events.ts 四处:
2026-09-28 18:41:54 +08:00
```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'
);
}
```
2026-09-28 23:03:32 +08:00
- [x] **Step 4: 跑测试确认通过** —— 同 Step 2 命令,预期 PASS(含既有全量)。
- [x] **Step 5: Commit** —— `feat(contracts): ad.updated/ad.deleted 事件类型与 ad 实体映射`
2026-09-28 18:41:54 +08:00
### Task 2: 契约 openapi + 常量 + 类型再生
**Files:** Modify `packages/contracts/openapi.yaml` 、`src/constants.ts` ; gen 产物 `src/generated/openapi.d.ts`
2026-09-28 23:03:32 +08:00
- [x] **Step 1: constants.ts 加广告图上限** (注意:照片域上限真实位置是
2026-09-28 18:41:54 +08:00
`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 ;
```
2026-09-28 23:03:32 +08:00
- [x] **Step 2: openapi.yaml** :
2026-09-28 18:41:54 +08:00
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' } }
```
2026-09-28 23:03:32 +08:00
- [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,类型再生`
2026-09-28 18:41:54 +08:00
### Task 3: 双库模型与迁移
**Files:** Modify `packages/db-cn/prisma/schema.prisma` 、`packages/db-ov/prisma/schema.prisma` + 各自新迁移
2026-09-28 23:03:32 +08:00
- [x] **Step 1: db-cn schema 追加** (对照 embed_slots 模型位置与注释风格):
2026-09-28 18:41:54 +08:00
```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])
}
```
2026-09-28 23:03:32 +08:00
- [x] **Step 2: db-ov schema 追加** ( embed_slots_ov 之后):
2026-09-28 18:41:54 +08:00
```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])
}
```
2026-09-28 23:03:32 +08:00
- [x] **Step 3: 生成迁移** (本地 infra compose 已起):
2026-09-28 18:41:54 +08:00
```bash
pnpm db:migrate -- --name ads
pnpm db-ov:migrate -- --name ads_ov
```
预期:各生成 `migrations/2026xxxxx_ads*/migration.sql` (仅 CREATE TABLE/INDEX,无账本表变更)。
2026-09-28 23:03:32 +08:00
- [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 投影表(含迁移)`
2026-09-28 18:41:54 +08:00
### Task 4: 对象键布局(storage/keys.ts)
**Files:** Modify `packages/storage/src/keys.ts` ;同步设计文档一行(见 Step 3)
2026-09-28 23:03:32 +08:00
- [x] **Step 1: 实现** (对照 variantsObjectKey 风格;**与设计 §2.1 的差异**:终键带版本段——copyIfAbsent 禁止覆盖,换图必须新键):
2026-09-28 18:41:54 +08:00
```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 } ` ;
}
```
2026-09-28 23:03:32 +08:00
- [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 + 版本化终键)`
2026-09-28 18:41:54 +08:00
### 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 全路径)
2026-09-29 00:22:13 +08:00
- [x] **Step 1: 写失败测试** —— 新建 `services/api/test/ads.e2e-spec.ts` ,骨架对照 embed.e2e-spec.ts( AppCnModule + ov PrismaClient + drainSender helper 复制其实现):
2026-09-28 18:41:54 +08:00
```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(版本单调不被回滚)。
2026-09-29 00:22:13 +08:00
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e` ,预期 FAIL( entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。
- [x] **Step 3: 实现** :
2026-09-28 18:41:54 +08:00
```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——跟随该文件现有导入风格)。
```
2026-09-29 00:22:13 +08:00
- [x] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。
- [x] **Step 5: Commit** —— `feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)`
2026-09-28 18:41:54 +08:00
### 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` (追加段)
2026-09-29 00:22:13 +08:00
- [x] **Step 1: 写失败测试** ( ads.e2e-spec.ts 追加):
2026-09-28 18:41:54 +08:00
```ts
// uploads init: admin 201 返回 staging 键+credential( URL 可真实 PUT,对照
// uploads.e2e 的 presign 断言写法);非 admin 403;匿名 401; bytes>2MB 400;
// contentType 非白名单 400;坏扩展名 400。
```
2026-09-29 00:22:13 +08:00
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e` ( 404 路由不存在)。
- [x] **Step 3: 实现** :
2026-09-28 18:41:54 +08:00
```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 };
}
```
2026-09-29 00:22:13 +08:00
- [x] **Step 4: 跑测试确认通过** 。
- [x] **Step 5: Commit** —— `feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only) `
2026-09-28 18:41:54 +08:00
### Task 7: cn 服务端——CRUD + outbox + audit
**Files:** Modify `services/api/src/ads/ads.service.ts` ; Test `services/api/test/ads.e2e-spec.ts`
2026-09-29 00:22:13 +08:00
- [x] **Step 1: 写失败测试** (追加;真实 MinIO PUT 仿 uploads.e2e):
2026-09-28 18:41:54 +08:00
```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→行删。
```
2026-09-29 00:22:13 +08:00
- [x] **Step 2: 跑测试确认失败** 。
- [x] **Step 3: 实现** ads.service.ts:
2026-09-28 18:41:54 +08:00
```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())
```
2026-09-29 00:22:13 +08:00
- [x] **Step 4: 跑测试确认通过** ( ads.e2e 全量)。
- [x] **Step 5: Commit** —— `feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图`
2026-09-28 18:41:54 +08:00
### 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` (追加段)
2026-09-29 00:22:13 +08:00
- [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: 实现** :
2026-09-28 18:41:54 +08:00
```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,两处单一出处)。
2026-09-29 00:22:13 +08:00
- [x] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed` (既有对账回归不破)。
- [x] **Step 5: Commit** —— `feat(sync): 对账清单 ad 实体当前态清扫`
2026-09-28 18:41:54 +08:00
### 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` (追加段)
2026-09-29 01:59:57 +08:00
- [x] **Step 1: 写失败测试** ( public-ov.e2e 追加,ov app 实例 + prismaOv 直种 ads_ov/albums_ov):
2026-09-28 18:41:54 +08:00
```ts
// ① token 有效 + 全局广告 + 绑定本册广告 + 绑定他册广告 → items 恰含前两者,
// sortOrder 升序、并列 createdAt 升序;imageUrl 现签(URL 含签名参数形态);
// ② 相册不存在/offline → 404 同公开相册码;③ 无广告 → items:[];
// ④ 匿名 200( security: [])。
```
2026-09-29 01:59:57 +08:00
- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- public-ov` 。
- [x] **Step 3: 实现** :
2026-09-28 18:41:54 +08:00
```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 };
}
```
2026-09-29 01:59:57 +08:00
- [x] **Step 4: 跑测试确认通过** 。
- [x] **Step 5: Commit** —— `feat(api): GET /v1/public/albums/{token}/ads 公开广告(全局+定向/现签)`
2026-09-28 18:41:54 +08:00
### 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 位置与配置)
2026-09-29 01:59:57 +08:00
- [x] **Step 1: 纯逻辑模块 + 失败测试** ( carousel.ts:轮播推进/环形回绕/暂停语义纯函数,便于 vitest 直测):
2026-09-28 18:41:54 +08:00
```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 后暂停。
2026-09-29 01:59:57 +08:00
- [x] **Step 2: client.ts** —— 类型 + 请求(对照 getPublicAlbum 的既有写法逐字模仿其 apiRequest 用法):
2026-09-28 18:41:54 +08:00
```ts
export type PublicAdListResponse = components [ 'schemas' ][ 'PublicAdListResponse' ];
export type PublicAd = components [ 'schemas' ][ 'PublicAd' ];
export async function getPublicAds ( token : string ) : Promise < PublicAdListResponse > { /* 同形 */ }
```
2026-09-29 01:59:57 +08:00
- [x] **Step 3: AdCarousel.vue** (完整实现;视觉 token 一律 var(--viewer-*)):
2026-09-28 18:41:54 +08:00
```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 重置为透明按钮占满 ; 徽标左上半透明深色底白字小圆角 ;
✕ 右上 ≥ 44 px 触达 ; 圆点底部居中 ; 全部颜色 / 圆角用 -- viewer -* token 。 -->
```
(样式块按上述要点补全为 scoped CSS——实现时对照 viewer 既有组件样式风格。)
2026-09-29 01:59:57 +08:00
- [x] **Step 4: ActivityPage 集成** :
2026-09-28 18:41:54 +08:00
```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" />` 。
2026-09-29 01:59:57 +08:00
- [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 自动/暂停/滑动/徽标/会话级关闭`
2026-09-28 18:41:54 +08:00
### 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 注册文件
2026-09-29 01:59:57 +08:00
- [x] **Step 1: 纯逻辑 + 失败测试** ( adsLogic.ts, vitest——对照 utils/system/embedSlots.spec.ts 模式):
2026-09-28 18:41:54 +08:00
```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 ;
}
```
2026-09-29 01:59:57 +08:00
- [x] **Step 2: api/ads.ts** (对照 embedSlots.ts 逐字风格):
2026-09-28 18:41:54 +08:00
```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 } ` })
}
```
2026-09-29 01:59:57 +08:00
- [x] **Step 3: 页面** ( views/system/ads/index.vue + AdsFormDialog.vue,对照 embed-slots/index.vue + SlotFormDialog.vue 结构):
2026-09-28 18:41:54 +08:00
- 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。
2026-09-29 01:59:57 +08:00
- [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): 广告位管理页——上传/链接/绑定相册/开关/排序`
2026-09-28 18:41:54 +08:00
### Task 12: 全量回归与红线自查
2026-09-29 01:59:57 +08:00
- [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** (如有修补)+ 设计文档勾选回填。
2026-09-28 18:41:54 +08:00
### Task 13: 部署上线(**需用户明确确认后执行**;顺序硬约束:ov 先 cn 后)
2026-09-29 09:35:37 +08:00
- [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 表)→
2026-09-28 18:41:54 +08:00
`docker compose -f /opt/photo-live/deploy/docker-compose-ov.yml up -d` ( api-ov 获
'ad' 消费能力;编排文件在服务器 deploy 目录,非仓库根)→ 验证 `/v1/health` +
**旧 cn 未发事件期间 ov 空转正常** 。
2026-09-29 09:35:37 +08:00
- [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 记忆补广告位上线状态。