Files
photo-live/docs/plans/2026-09-28-广告位轮播-设计方案.md
T

182 lines
10 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.
# 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`(写权威)
```prisma
model ads {
id String @id @default(cuid())
title String? @db.VarChar(120) // 后台备注名,不展示观众
imageObjectKey String @db.VarChar(512) // OSS 终键 photo-live/{env}/ads/{adId}.{ext}
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)
```prisma
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
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 一致)→ `copyIfAbsent()` 到终键
`ads/{adId}.{ext}`(禁止覆盖语义天然防重复)→ 落库 + 写 outbox。
编辑换图:新终键生效,旧终键对象不回收(OSS 生命周期兜底,与 banner 流同款)。
- StoragePort 现有 presignPut/head/copyIfAbsent 全复用,零新增存储能力。
- 上传大小/类型约束沿用契约 UPLOAD_MAX_BYTES 与 image/jpeg|png|heic 白名单。
## 4. 同步链(全部为既有模式的机械扩展)
| 环节 | 改动 | 模板 |
| --- | --- | --- |
| 契约 events.ts | type 联合加 `'ad.updated' \| 'ad.deleted'`;`AdUpdatedPayload`(全量快照:adId/imageObjectKey/linkUrl/enabled/sortOrder/albumIds/entityVersion);`isTombstoneOutboxType` 加 ad.deleted | category.* |
| outbox 写入 | albumId 列 NOT NULL,**全局广告无相册 → 写空串 `''`**(约定登记;不动账本表结构) | — |
| sender | `syncEntityTypeOfOutboxType`:ad.* → `'ad'`;entityId 从载荷 `adId` 推导 | slot-state 分支 |
| 线格式 | internal-sync entityType 联合加 `'ad'` | slot-state |
| ov inbox | sync-inbox.service 加 `'ad'` 分支:tombstone→删 ads_ov;否则 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)。不加独立限速档(观众页单次加载调用一次,
与公开相册读同量级;登记说明)。
## 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')`。
- **关闭**:右上角 ✕(≥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→投影删除;绑定变更→重发后过滤结果变化;版本回放幂等 |
| ov public e2e | token 404;全局+定向并集;disabled 不出;sortOrder 排序 |
| viewer vitest | 空 items 不渲染;单条静态;多条例假时钟轮播+悬停暂停;✕ 写 sessionStorage 且卸载;点击 window.open spy |
| admin vitest | 列表渲染/开关与排序交互/对话框校验与绑定多选逻辑 |
## 9. 不做的(YAGNI,需要时再议)
投放时间窗(上下架时间)、曝光/点击统计、多语言广告内容、绑定相册删除级联清理、
广告独立限速档、广告图变体流水线、CDN。
## 10. 部署注意(实施时展开)
- 双库迁移(cn/ov 各自 migrate-deploy)先于新镜像 up;
- server 镜像 + 双端 web 镜像同 tag 滚动;**api 容器重建后 web 一并 restart**
(既有坑位);
- 契约变更走 `pnpm gen` 类型再生入库;
- 首个广告上线前观众页零改动表现(空 items 不渲染)——可安全先发后配。