2026-09-28 18:09:35 +08:00
|
|
|
|
# 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
|
2026-09-28 18:14:00 +08:00
|
|
|
|
createdAt DateTime @db.DateTime(6) // 排序 tie-break(§5.2:sortOrder 并列时按它稳定排序)
|
2026-09-28 18:09:35 +08:00
|
|
|
|
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`:
|
2026-09-28 18:14:00 +08:00
|
|
|
|
服务端同事务内 `head()` 核验(存在 + bytes + contentType 与申明一致,banner
|
|
|
|
|
|
complete 同口径)→ `copyIfAbsent()` 到终键
|
2026-09-28 18:09:35 +08:00
|
|
|
|
`ads/{adId}.{ext}`(禁止覆盖语义天然防重复)→ 落库 + 写 outbox。
|
|
|
|
|
|
编辑换图:新终键生效,旧终键对象不回收(OSS 生命周期兜底,与 banner 流同款)。
|
|
|
|
|
|
|
|
|
|
|
|
- StoragePort 现有 presignPut/head/copyIfAbsent 全复用,零新增存储能力。
|
2026-09-28 18:24:00 +08:00
|
|
|
|
- 上传类型白名单沿用 image/jpeg|png|heic;**广告图不走变体流水线、原图直出
|
|
|
|
|
|
且轮播多张同屏,字节上限收紧为 ≤2MB**(独立于 UPLOAD_MAX_BYTES 的 50MB——
|
|
|
|
|
|
banner 一页一张可宽松,广告按运营素材实际量级收紧;uploads init 即校验)。
|
2026-09-28 18:09:35 +08:00
|
|
|
|
|
|
|
|
|
|
## 4. 同步链(全部为既有模式的机械扩展)
|
|
|
|
|
|
|
|
|
|
|
|
| 环节 | 改动 | 模板 |
|
|
|
|
|
|
| --- | --- | --- |
|
2026-09-28 18:14:00 +08:00
|
|
|
|
| 契约 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)**(约定登记;不动账本表结构) | — |
|
2026-09-28 18:09:35 +08:00
|
|
|
|
| sender | `syncEntityTypeOfOutboxType`:ad.* → `'ad'`;entityId 从载荷 `adId` 推导 | slot-state 分支 |
|
|
|
|
|
|
| 线格式 | internal-sync entityType 联合加 `'ad'` | slot-state |
|
2026-09-28 18:14:00 +08:00
|
|
|
|
| ov inbox | sync-inbox.service 加 `'ad'` 分支:`tombstone ∨ enabled=false → 删 ads_ov 行`(disabled=公开不可见=无投影行,§2.2);否则 upsert(eventId 幂等 + entityVersion 单调由通用逻辑承担) | slot-state→embed_slots_ov |
|
2026-09-28 18:09:35 +08:00
|
|
|
|
| 对账 | 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)。不加独立限速档(观众页单次加载调用一次,
|
|
|
|
|
|
与公开相册读同量级;登记说明)。
|
2026-09-28 18:24:00 +08:00
|
|
|
|
- **量级假设**:公开端点为 ads_ov 全表捞 + 应用层过滤,假设广告 ≤ 百条
|
|
|
|
|
|
(运营现实 <20)。远超时再优化(索引/缓存),登记非本次范围。
|
2026-09-28 18:09:35 +08:00
|
|
|
|
|
|
|
|
|
|
## 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')`。
|
2026-09-28 18:25:49 +08:00
|
|
|
|
- **广告标识**:轮播图**左上角常驻「广告」小标签**(i18n:zh-CN=广告 / en=Ad)
|
|
|
|
|
|
——半透明深色底白字小徽标,随轮播切换始终可见;与右上角 ✕ 分侧不重叠。
|
|
|
|
|
|
依据《互联网广告管理办法》可识别性要求(信息流广告标配形态),区域 aria-label
|
|
|
|
|
|
复用同一 i18n 文案。
|
2026-09-28 18:09:35 +08:00
|
|
|
|
- **关闭**:右上角 ✕(≥44px 触达区)→ emit close → 页面写 sessionStorage 并卸载。
|
2026-09-28 18:25:49 +08:00
|
|
|
|
- **可访问性**:区域 role="region" + i18n 标签("广告",与可见徽标同源);轮播切换
|
|
|
|
|
|
aria-live="off";✕ 与广告均为可聚焦控件。
|
2026-09-28 18:09:35 +08:00
|
|
|
|
- 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 |
|
2026-09-28 18:16:15 +08:00
|
|
|
|
| sync e2e | ad.updated→ads_ov 投影+公开端可见;ad.deleted→投影删除;**enabled true→false 翻转快照→投影行删除**(§4 分支直测);绑定变更→重发后过滤结果变化;版本回放幂等 |
|
2026-09-28 18:09:35 +08:00
|
|
|
|
| ov public e2e | token 404;全局+定向并集;disabled 不出;sortOrder 排序 |
|
|
|
|
|
|
| viewer vitest | 空 items 不渲染;单条静态;多条例假时钟轮播+悬停暂停;✕ 写 sessionStorage 且卸载;点击 window.open spy |
|
|
|
|
|
|
| admin vitest | 列表渲染/开关与排序交互/对话框校验与绑定多选逻辑 |
|
|
|
|
|
|
|
|
|
|
|
|
## 9. 不做的(YAGNI,需要时再议)
|
|
|
|
|
|
|
|
|
|
|
|
投放时间窗(上下架时间)、曝光/点击统计、多语言广告内容、绑定相册删除级联清理、
|
|
|
|
|
|
广告独立限速档、广告图变体流水线、CDN。
|
|
|
|
|
|
|
|
|
|
|
|
## 10. 部署注意(实施时展开)
|
|
|
|
|
|
|
2026-09-28 18:24:00 +08:00
|
|
|
|
- **上线顺序硬约束(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 不渲染)。
|
2026-09-28 18:09:35 +08:00
|
|
|
|
- 双库迁移(cn/ov 各自 migrate-deploy)先于新镜像 up;
|
|
|
|
|
|
- server 镜像 + 双端 web 镜像同 tag 滚动;**api 容器重建后 web 一并 restart**
|
|
|
|
|
|
(既有坑位);
|
|
|
|
|
|
- 契约变更走 `pnpm gen` 类型再生入库;
|
|
|
|
|
|
- 首个广告上线前观众页零改动表现(空 items 不渲染)——可安全先发后配。
|