docs: 广告位轮播设计方案(全局+定向投放/两步上传/outbox 同步投影/sessionStorage 关闭)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 18:09:35 +08:00
parent 46f9d93fd3
commit 573a559e4a
@@ -0,0 +1,181 @@
# 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 不渲染)——可安全先发后配。