From 7d744da56e5aaaea3e27cdcf07533b527d18c21f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 18:41:54 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=B9=BF=E5=91=8A=E4=BD=8D=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92=E2=80=94=E2=80=94=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E8=AE=A2=EF=BC=88inbox=20=E4=BE=9D=E8=B5=96=E6=9A=82?= =?UTF-8?q?=E5=AD=98=E8=B1=81=E5=85=8D=20ad=20=E5=85=B3=E9=94=AE=E8=A1=A5?= =?UTF-8?q?=E4=B8=81/=E5=B8=B8=E9=87=8F=E6=8C=87=E9=92=88=E7=BA=A0?= =?UTF-8?q?=E6=AD=A3/=E7=BB=84=E4=BB=B6=E6=B5=8B=E8=AF=95=E8=A1=A5?= =?UTF-8?q?=E9=BD=90/compose=20=E5=85=A8=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- docs/plans/2026-09-28-广告位轮播-实施计划.md | 698 +++++++++++++++++++ 1 file changed, 698 insertions(+) create mode 100644 docs/plans/2026-09-28-广告位轮播-实施计划.md diff --git a/docs/plans/2026-09-28-广告位轮播-实施计划.md b/docs/plans/2026-09-28-广告位轮播-实施计划.md new file mode 100644 index 0000000..9438a16 --- /dev/null +++ b/docs/plans/2026-09-28-广告位轮播-实施计划.md @@ -0,0 +1,698 @@ +# 广告位轮播 实施计划 + +> **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 `。 +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` + +- [ ] **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 扩后自动失败——即本步的失败信号。) + +- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter @photo/contracts run test`,预期 FAIL(类型不存在/数组不匹配)。 + +- [ ] **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' + ); +} +``` + +- [ ] **Step 4: 跑测试确认通过** —— 同 Step 2 命令,预期 PASS(含既有全量)。 +- [ ] **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` + +- [ ] **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; +``` +- [ ] **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' } } +``` +- [ ] **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` 全绿。 +- [ ] **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` + 各自新迁移 + +- [ ] **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]) +} +``` +- [ ] **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]) +} +``` +- [ ] **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,无账本表变更)。 +- [ ] **Step 4: 验证** —— `pnpm --filter @photo/db-cn run generate && pnpm --filter @photo/db-ov run generate`(或既有 postinstall 链),`pnpm typecheck` 全绿。 +- [ ] **Step 5: Commit** —— `feat(db): ads 写权威表与 ads_ov 投影表(含迁移)` + +### Task 4: 对象键布局(storage/keys.ts) + +**Files:** Modify `packages/storage/src/keys.ts`;同步设计文档一行(见 Step 3) + +- [ ] **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}`; +} +``` +- [ ] **Step 2: 验证** —— `pnpm --filter @photo/storage run typecheck`(+ 若该包有测试则跑)。 +- [ ] **Step 3: 回填设计文档** —— 设计方案.md §2.1 的终键示例改为 `ads/{adId}/v{version}.{ext}` 并注明原因(禁止覆盖语义)。 +- [ ] **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 全路径) + +- [ ] **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(版本单调不被回滚)。 +- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`,预期 FAIL(entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。 +- [ ] **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——跟随该文件现有导入风格)。 +``` +- [ ] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。 +- [ ] **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`(追加段) + +- [ ] **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。 +``` +- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`(404 路由不存在)。 +- [ ] **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 }; +} +``` +- [ ] **Step 4: 跑测试确认通过**。 +- [ ] **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` + +- [ ] **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→行删。 +``` +- [ ] **Step 2: 跑测试确认失败**。 +- [ ] **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()) +``` +- [ ] **Step 4: 跑测试确认通过**(ads.e2e 全量)。 +- [ ] **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`(追加段) + +- [ ] **Step 1: 写失败测试** —— reconcile.e2e-spec 追加:seed 两行 ads(一全局一定向)→ 以 `?entityType=ad&since=0` 拉对账清单 → 断言 2 条 wire 事件(entityType 'ad'、albumId ''、载荷全量、确定性 eventId 前缀)。 +- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter sync run test`(或该包 e2e 脚本名,对照 services/sync/package.json)。 +- [ ] **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,两处单一出处)。 +- [ ] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed`(既有对账回归不破)。 +- [ ] **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`(追加段) + +- [ ] **Step 1: 写失败测试**(public-ov.e2e 追加,ov app 实例 + prismaOv 直种 ads_ov/albums_ov): +```ts +// ① token 有效 + 全局广告 + 绑定本册广告 + 绑定他册广告 → items 恰含前两者, +// sortOrder 升序、并列 createdAt 升序;imageUrl 现签(URL 含签名参数形态); +// ② 相册不存在/offline → 404 同公开相册码;③ 无广告 → items:[]; +// ④ 匿名 200(security: [])。 +``` +- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- public-ov`。 +- [ ] **Step 3: 实现**: +```ts +// controller(getPublicAlbum 之后): + /** 公开广告列表:全局+绑定本册,现签图 URL(广告位轮播 2026-09-28 设计 §5.2)。 */ + @Get(':token/ads') + listPublicAds(@Param('token') token: string): Promise { + return this.publicService.listPublicAds(token); + } +// service(对照 getPublicAlbum 的相册校验/404 构造与 presignGet 用法 ~L505): + async listPublicAds(token: string): Promise { + 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 }; + } +``` +- [ ] **Step 4: 跑测试确认通过**。 +- [ ] **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 位置与配置) + +- [ ] **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 后暂停。 +- [ ] **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 { /* 同形 */ } +``` +- [ ] **Step 3: AdCarousel.vue**(完整实现;视觉 token 一律 var(--viewer-*)): +```vue + + + +``` +(样式块按上述要点补全为 scoped CSS——实现时对照 viewer 既有组件样式风格。) +- [ ] **Step 4: ActivityPage 集成**: +```ts +import AdCarousel from '../components/ads/AdCarousel.vue'; +import { getPublicAds, type PublicAd } from '../api/client'; +const ads = ref([]); +// 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 = []; +} +``` +模板:`` 与 `` 之间插 ``。 +- [ ] **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}' }`(对照两文件既有结构与导出方式插入)。 +- [ ] **Step 6: 验证** —— `pnpm --filter viewer run test`(含新 spec)+ `pnpm --filter viewer run typecheck` + `pnpm --filter viewer run build`。 +- [ ] **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 注册文件 + +- [ ] **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; +} +``` +- [ ] **Step 2: api/ads.ts**(对照 embedSlots.ts 逐字风格): +```ts +import request from '@/utils/http' +export function fetchAds() { + return request.get({ url: '/v1/admin/ads' }) +} +export function fetchInitAdUpload(data: Api.Ads.UploadInitPayload) { + return request.post({ url: '/v1/admin/ads/uploads', data }) +} +export function fetchCreateAd(data: Api.Ads.CreatePayload) { + return request.post({ url: '/v1/admin/ads', data }) +} +export function fetchUpdateAd(id: string, data: Api.Ads.UpdatePayload) { + return request.patch({ url: `/v1/admin/ads/${id}`, data }) +} +export function fetchDeleteAd(id: string) { + return request.del({ url: `/v1/admin/ads/${id}` }) +} +``` +- [ ] **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。 +- [ ] **Step 4: 路由/菜单/i18n** —— 在 Step 定位到的注册点按 embed-slots 同款登记「广告位」(图标用 ElementPlus 任意广告语义图标,i18n key `route.ads` 等——对照既有菜单多语言落点补 zh-CN/en)。 +- [ ] **Step 5: 验证** —— `pnpm --filter admin run test`(含 adsLogic.spec)+ `pnpm --filter admin run typecheck` + `pnpm --filter admin run build`。 +- [ ] **Step 6: Commit** —— `feat(admin): 广告位管理页——上传/链接/绑定相册/开关/排序` + +### Task 12: 全量回归与红线自查 + +- [ ] **Step 1: 全套件** —— `pnpm typecheck && pnpm lint && pnpm test`(turbo 全包;**先杀游离 dev:worker**)。 +- [ ] **Step 2: e2e 全量** —— `pnpm --filter api run test:e2e`(含新增 ads/public-ov 追加段;health 偶发长跑瞬时失败按既有登记单独重跑确认)。 +- [ ] **Step 3: 红线自查清单** —— ① 账本表无任何 DELETE/TRUNCATE 迁移或代码(`git diff -- '*migration*' | grep -i 'delete\|truncate'` 应只命中 ads 相关新表);② openapi 新 schema 无必填进旧端点;③ flutter 零改动(`git status apps/mobile` 干净);④ viewer 嵌入路由(EmbedPage 树)无 AdCarousel 引用。 +- [ ] **Step 4: Commit**(如有修补)+ 设计文档勾选回填。 + +### Task 13: 部署上线(**需用户明确确认后执行**;顺序硬约束:ov 先 cn 后) + +- [ ] **Step 1: 镜像** —— `git archive HEAD | docker build -f docker/server.Dockerfile -t photo-live-server:-<日期> .` + 双端 web(VITE_VIEWER_BASE/VITE_EMBED_ORIGIN 按 2026-09-24 定稿:cn 镜像两参=outside,ov 镜像两参=outside)。 +- [ ] **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 空转正常**。 +- [ ] **Step 3: cn 后行** —— 传镜像 → `migrate-deploy cn` → `up -d`(api/worker/sync)→ **restart web 容器**(坑位①)→ 后台「广告位」页冒烟(建广告→上传→绑定→保存)。 +- [ ] **Step 4: 端到端验收** —— 观众页(ov 域名)相册页看到轮播/徽标/✕ 关闭(关标签重开重现)/点击新开标签;绑定相册 A 不在相册 B 显示;停用后 ov 副本行删除(`ads_ov` 查询)。 +- [ ] **Step 5: 验收记录 + 记忆更新** —— docs/plans/progress/ 落档;project-current-work 记忆补广告位上线状态。