From 573a559e4ae1328c5f78e2cd9d6e94c7cda95443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 18:09:35 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=B9=BF=E5=91=8A=E4=BD=8D=E8=BD=AE?= =?UTF-8?q?=E6=92=AD=E8=AE=BE=E8=AE=A1=E6=96=B9=E6=A1=88=EF=BC=88=E5=85=A8?= =?UTF-8?q?=E5=B1=80+=E5=AE=9A=E5=90=91=E6=8A=95=E6=94=BE/=E4=B8=A4?= =?UTF-8?q?=E6=AD=A5=E4=B8=8A=E4=BC=A0/outbox=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=8A=95=E5=BD=B1/sessionStorage=20=E5=85=B3=E9=97=AD=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 | 181 +++++++++++++++++++ 1 file changed, 181 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..1100257 --- /dev/null +++ b/docs/plans/2026-09-28-广告位轮播-设计方案.md @@ -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 不渲染)——可安全先发后配。