Files
photo-live/docs/plans/2026-09-28-广告位轮播-设计方案.md
T
浅念 539597e9f0 feat(storage): 广告对象键布局(staging + 版本化终键)
adsStagingObjectKey(两步上传 presignPut 直传位)+ adObjectKey
(版本段=entityVersion:换图必然 bump 版本→新键,copyIfAbsent
禁止覆盖语义下天然防重复/防串图);index.ts 按名再导出供 Task 6/7
ads.service 消费。设计方案 §2.1/§3 终键布局同步回填(偏差登记)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 23:01:09 +08:00

12 KiB
Raw Blame History

2026-09-28 广告位轮播 设计方案

状态:设计定稿,待用户复核后转实施计划(本轮只落文档,不动代码)。 需求来源:用户 2026-09-28 提出并两次修订定稿。 关联:docs/plans/2026-09-24-正式环境双地域部署.md(线上双地域形态); P4b 嵌入展示位(embed_slots)为本方案同步链路的模板。

0. 需求与决策记录(用户已拍板)

  1. 管理后台新增「广告位」管理:上传广告图片 + 配置点击跳转链接。
  2. 观众页(相册页)宣传图区域下方新增轮播区:自动轮播广告,点击新开标签跳转。
  3. 轮播区右上角关闭按钮:仅本次访问隐藏(sessionStorage;新开标签/下次访问重现)。
  4. 投放范围:不绑定相册=全局(所有相册显示);绑定=定向(仅绑定的相册显示); 一条广告可绑定多个相册(绑定是可选的)。
  5. 嵌入组件(embed iframe)不显示广告——隔离红线不动。

方案选型:独立 ads 域 + outbox 同步投影(仿 embed_slots 模式),否决 settings JSON (无逐条版本管理)与 OSS 静态 JSON 直读(破坏私有桶+现签模型)。

1. 架构总览

管理后台(admin, cn) ──CRUD──> cn api ──同事务──> ads 表 + outbox_events(ad.*)
                                                  │ sync 推送(2s)/对账(5min)
                                                  ▼
观众页(viewer, ov) <──GET /v1/public/albums/{token}/ads── api-ov <── ads_ov 投影(sync-inbox)
  • cn=写权威(唯一入口:admin 角色);ov=只读投影 + 现签图片 URL(与相册/照片同构)。
  • 广告图存 OSS 私有桶(同 bucket),观众端经 presigned GET(TTL 900s)加载, 不走 worker 变体流水线(与 banner 资产同款:原图直用)。

2. 数据模型

2.1 db-cn:ads(写权威)

model ads {
  id             String   @id @default(cuid())
  title          String?  @db.VarChar(120)  // 后台备注名,不展示观众
  imageObjectKey String   @db.VarChar(512)  // OSS 终键 photo-live/{env}/ads/{adId}/v{version}.{ext}(版本段:copyIfAbsent 禁止覆盖,换图新键——实施计划 Task 4 偏差登记)
  linkUrl        String   @db.VarChar(2048) // 仅 http/https(服务端校验)
  enabled        Boolean  @default(true)
  sortOrder      Int      @default(0)       // 升序;并列按 createdAt 升序
  albumIds       Json?                      // string[] | null;null=全局投放(归一化:空数组存 null)
  entityVersion  Int      @default(1)       // outbox 事件版本
  createdAt      DateTime @default(now()) @db.DateTime(6)
  updatedAt      DateTime @updatedAt @db.DateTime(6)
}
  • 相册域扁平字符串约定:不建 FK、不建绑定表。绑定关系量小且随广告整行走, JSON 列 + 整行快照事件(slot.updated 同款)最简且对账友好。
  • 绑定的相册后续被删除:不做级联清理(该相册观众页已 404,无实际影响; 后台编辑下拉过滤失效项即可——如实登记,非缺陷)。

2.2 db-ov:ads_ov(只读投影,仿 embed_slots_ov)

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) // 排序 tie-break(§5.2:sortOrder 并列时按它稳定排序)
  updatedAt      DateTime @db.DateTime(6)
}
  • 仅展示所需列(enabled=false 不投影:公开端语义=不可见,与 embed_slots 无墓碑同款)。
  • 删除=直接删投影行(tombstone 事件驱动)。
  • 迁移:db-cn / db-ov 各一个迁移文件;sync_inbox.entityType 为 VarChar(32), 新值 'ad' 无需改动。

3. 图片上传(两步式,复用 banner 三段式的核验语义)

  1. POST /v1/admin/ads/uploads {filename, bytes, contentType} → 201 { objectKey(staging), credential{url,method,headers,expiresAt} }; staging 键 photo-live/{env}/ads/staging/{cuid}.{ext}(presignPut 现签)。
  2. 客户端 PUT 直传 OSS(裸 fetch,Content-Type 与申明一致)。
  3. POST /v1/admin/ads / PATCH /v1/admin/ads/{id} 携带 imageStagingKey: 服务端同事务内 head() 核验(存在 + bytes + contentType 与申明一致,banner complete 同口径)→ copyIfAbsent() 到终键 ads/{adId}/v{version}.{ext}(禁止覆盖语义天然防重复)→ 落库 + 写 outbox。 编辑换图:新终键生效,旧终键对象不回收(OSS 生命周期兜底,与 banner 流同款)。
  • StoragePort 现有 presignPut/head/copyIfAbsent 全复用,零新增存储能力。
  • 上传类型白名单沿用 image/jpeg|png|heic;广告图不走变体流水线、原图直出 且轮播多张同屏,字节上限收紧为 ≤2MB(独立于 UPLOAD_MAX_BYTES 的 50MB—— banner 一页一张可宽松,广告按运营素材实际量级收紧;uploads init 即校验)。

4. 同步链(全部为既有模式的机械扩展)

环节 改动 模板
契约 events.ts type 联合加 'ad.updated' | 'ad.deleted';AdUpdatedPayload(全量快照:adId/imageObjectKey/linkUrl/enabled/sortOrder/albumIds/entityVersion/createdAt);isTombstoneOutboxType 加 ad.deleted category.*
outbox 写入 albumId 列 NOT NULL——ad. 事件 albumId 恒写空串 ''(定向广告亦然,绑定关系在载荷 albumIds)*(约定登记;不动账本表结构) —
sender syncEntityTypeOfOutboxType:ad.* → 'ad';entityId 从载荷 adId 推导 slot-state 分支
线格式 internal-sync entityType 联合加 'ad' slot-state
ov inbox sync-inbox.service 加 'ad' 分支:tombstone ∨ enabled=false → 删 ads_ov 行(disabled=公开不可见=无投影行,§2.2);否则 upsert(eventId 幂等 + entityVersion 单调由通用逻辑承担) slot-state→embed_slots_ov
对账 cn sync.service 对账清单加 'ad' 当前态清扫(albumId='';ads 全表量小) category 分支

5. API 契约(openapi.yaml 新增,pnpm gen 再生类型)

5.1 管理端(cn,bearerAuth + admin 角色)

方法 路径 语义
GET /v1/admin/ads 列表(含现签缩略图 URL、投放范围),sortOrder 升序
POST /v1/admin/ads/uploads 上传初始化(staging 预签名)
POST /v1/admin/ads 创建(title?/linkUrl/sortOrder?/albumIds?/imageStagingKey)
PATCH /v1/admin/ads/{adId} 部分更新(同上字段均可选;enabled/sortOrder/绑定变更均 entityVersion+1 重发整行)
DELETE /v1/admin/ads/{adId} 硬删 + ad.deleted(priority=high,删除类优先同 photo.deleted)

校验:linkUrl 必须 http(s) 且 ≤2048;albumIds 元素为存在的 albumId(容失效见 §2.1); imageStagingKey 必须为本服务签发的 staging 键。

5.2 公开端(ov,匿名)

GET /v1/public/albums/{token}/ads → 200 { items: [{ adId, imageUrl, linkUrl }] }

  • token 寻址(公开域惯例,不暴露内部 albumId);相册不存在/offline → 404 ALBUM_NOT_FOUND(与公开相册读同码)。
  • 过滤:enabled 投影 ∧ (albumIds 为 null ∨ 含本相册 id),按 sortOrder 升序、 并列 createdAt 升序;imageUrl=presigned GET(TTL=SIGNED_URL_TTL_SECONDS)。
  • 无广告返回空 items(非 404)。不加独立限速档(观众页单次加载调用一次, 与公开相册读同量级;登记说明)。
  • 量级假设:公开端点为 ads_ov 全表捞 + 应用层过滤,假设广告 ≤ 百条 (运营现实 <20)。远超时再优化(索引/缓存),登记非本次范围。

6. 观众页(viewer)

6.1 数据获取(ActivityPage)

  • album 加载成功后请求本相册广告(复用 api client;相册加载失败/不存在不请求)。
  • 关闭记忆:挂载时读 sessionStorage['viewer:ads-dismissed'];点击 ✕ 写入。 键值含广告指纹不需要(会话级语义=本会话一律不再出)。

6.2 AdCarousel 组件(新建 components/ads/AdCarousel.vue)

  • 位置:AlbumHeader 整块之后、PhotoFeed 之前(信息区/工具栏保持完整)。
  • 行为:多条自动轮播 4s(本地常量,不入契约——纯前端表现);指针悬停/触摸 按下暂停;圆点指示器可点击跳页;移动端左右滑动切换;单条=静态无圆点无自动 切换;items 空=不渲染任何占位。
  • 图片:固定宽高比(后台上传建议 1200×360,object-fit: cover), loading="lazy"。
  • 点击:window.open(linkUrl, '_blank', 'noopener,noreferrer')。
  • 广告标识:轮播图左上角常驻「广告」小标签(i18n:zh-CN=广告 / en=Ad) ——半透明深色底白字小徽标,随轮播切换始终可见;与右上角 ✕ 分侧不重叠。 依据《互联网广告管理办法》可识别性要求(信息流广告标配形态),区域 aria-label 复用同一 i18n 文案。
  • 关闭:右上角 ✕(≥44px 触达区)→ emit close → 页面写 sessionStorage 并卸载。
  • 可访问性:区域 role="region" + i18n 标签("广告",与可见徽标同源);轮播切换 aria-live="off";✕ 与广告均为可聚焦控件。
  • i18n:zh-CN/en 文案齐(现有 vue-i18n 体系)。

7. 管理后台(admin)

  • 新菜单「广告位」(views/ads/):卡片/表格列表——缩略图、备注名、链接、 投放范围(「全局」徽标 / 相册标签,超出截断)、enabled 开关(即时 PATCH)、 上移/下移(与相邻行交换 sortOrder,两次 PATCH)、编辑、删除(二次确认)。
  • 新建/编辑对话框:图片上传(复用 AlbumBannerUpload 的 assetUpload util 模式, 新建 ads 专用 util)、链接输入(https?:// 校验)、备注名、绑定相册多选 (ElSelect multiple + remote 相册搜索,可清空;留空=全局投放,表单提示明示)。
  • 路由/菜单/i18n 注册;仅 admin 可见(与现有管理页同 guard)。

8. 测试策略(对齐仓库 TDD 惯例)

层 用例
contracts 单测 type 联合/tombstone 判定/payload 形状
cn e2e(admin ads) CRUD 全路径;uploads staging→终键(真实 presign PUT,仿 uploads.e2e);outbox 行断言(类型/版本/载荷/albumId='');albumIds 归一化([]→null);linkUrl 非法 400
sync e2e ad.updated→ads_ov 投影+公开端可见;ad.deleted→投影删除;enabled true→false 翻转快照→投影行删除(§4 分支直测);绑定变更→重发后过滤结果变化;版本回放幂等
ov public e2e token 404;全局+定向并集;disabled 不出;sortOrder 排序
viewer vitest 空 items 不渲染;单条静态;多条例假时钟轮播+悬停暂停;✕ 写 sessionStorage 且卸载;点击 window.open spy
admin vitest 列表渲染/开关与排序交互/对话框校验与绑定多选逻辑

9. 不做的(YAGNI,需要时再议)

投放时间窗(上下架时间)、曝光/点击统计、多语言广告内容、绑定相册删除级联清理、 广告独立限速档、广告图变体流水线、CDN。

10. 部署注意(实施时展开)

  • 上线顺序硬约束(2026-09-28 复核发现):ov api 必须先于 cn api 升级。 ov sync-inbox 的 DTO 对 entityType 有 @IsIn(SYNC_ENTITY_TYPES) 枚举校验 (services/api/src/sync-inbox/dto.ts)——旧 ov 代码收到 'ad' 事件会整批 400 拒收(批量数组整体校验,同批 photo 事件连带被拒 → 照片同步停摆至 ov 升级);对账消费路径同理。安全窗口:ov 升级并验证 → cn 升级(才开始可能 产生 ad.* 事件)。web 双端镜像无顺序约束(无广告时 UI 不渲染)。
  • 双库迁移(cn/ov 各自 migrate-deploy)先于新镜像 up;
  • server 镜像 + 双端 web 镜像同 tag 滚动;api 容器重建后 web 一并 restart (既有坑位);
  • 契约变更走 pnpm gen 类型再生入库;
  • 首个广告上线前观众页零改动表现(空 items 不渲染)——可安全先发后配。