diff --git a/docs/superpowers/specs/2026-06-04-castlove-config-admin-design.md b/docs/superpowers/specs/2026-06-04-castlove-config-admin-design.md new file mode 100644 index 0000000..eb74f1e --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-castlove-config-admin-design.md @@ -0,0 +1,762 @@ +# 铸爱页分类/工艺卡片配置化 设计文档 + +- 日期:2026-06-04 +- 涉及模块:`frontend/pages/castlove/craft-select.vue`、`backend/gateway`、PostgreSQL、后台管理(外部项目) +- 现状:`frontend/pages/castlove/craft-select.vue` 第 263~370 行硬编码了 `categoryList / cardListMap / cardList / cardRoutes`,运营同学改图片或名称需要改代码、重新发版 +- 目标:把"分类"和"工艺卡片"做成可配置,运营可通过现有后台管理增删改;端上 5 秒内生效;跳转路由仍由前端维护 +- 不在本次范围:跳转路由不可配(前端代码中 `cardRoutes` 写死),后台管理 UI 由对接团队实现,本文档仅给出规格约定 + +--- + +## 一、整体架构 + +``` ++----------------------+ +-------------------+ +| 后台管理 (外部项目) | 直连 | PostgreSQL | +| 写 castlove_* | ──────▶ | castlove_categories | ++----------------------+ | castlove_crafts | + +---------┬---------+ + │ GORM 读 + ▼ + +-------------------+ + | Go gateway :8080 | + | GET /api/v1/ | + | castlove/config | + | (5s 进程内缓存) | + +---------┬---------+ + │ JWT 鉴权 + ▼ + +-------------------+ + | uni-app 前端 | + | craft-select.vue | + | cardRoutes 写死 | + +-------------------+ +``` + +- 数据 source of truth:PostgreSQL 两张表 +- 后台直连同库(共用同一 PostgreSQL),不走 Go 业务层 +- 前端只读,强制鉴权,进程内 5 秒缓存兜底高 QPS +- 跳转路由 `cardRoutes` 留在前端代码中,按 `card.name` 匹配 + +--- + +## 二、数据库设计 + +### 2.1 castlove_categories(分类表) + +```sql +CREATE TABLE castlove_categories ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + type_key VARCHAR(32), -- 外部 deep-link key,可空 + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_castlove_categories_name UNIQUE (name) +); + +CREATE INDEX idx_castlove_categories_sort + ON castlove_categories (sort_order) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX uq_castlove_categories_type_key + ON castlove_categories (type_key) + WHERE type_key IS NOT NULL AND deleted_at IS NULL; + +ALTER SEQUENCE castlove_categories_id_seq RESTART WITH 10000; +``` + +字段说明: + +| 字段 | 说明 | +|---|---| +| `name` | 分类显示名("星卡" / "吧唧" / "海报"),DB 唯一 | +| `type_key` | 用于外部 deep-link 定位的稳定 key(`star_card / badge / poster`),可空;非空时唯一(部分索引,允许多行 NULL) | +| `sort_order` | 排序,越小越靠前;允许重复,重复时按 id 兜底 | +| `is_active` | 软下线开关,前端只展示 true 的 | +| `deleted_at` | 软删除时间戳 | + +### 2.2 castlove_crafts(工艺卡片表) + +```sql +CREATE TABLE castlove_crafts ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES castlove_categories(id), + name VARCHAR(64) NOT NULL, + image_url TEXT NOT NULL, + coming_soon BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_castlove_crafts_cat_sort + ON castlove_crafts (category_id, sort_order) WHERE deleted_at IS NULL; + +ALTER SEQUENCE castlove_crafts_id_seq RESTART WITH 10000; +``` + +字段说明: + +| 字段 | 说明 | +|---|---| +| `category_id` | 外键到 `castlove_categories.id` | +| `name` | 卡片名("光栅卡" / "镭射卡" / "开发中" / ...),不同分类下可重名 | +| `image_url` | **完整 https URL**(OSS 域名开头),不允许 `/static/...` 相对路径 | +| `coming_soon` | true 时点击中央卡片弹"敬请期待",不跳转 | +| `sort_order` | 同一分类内部的相对顺序 | +| `is_active` | 软下线开关 | +| `deleted_at` | 软删除时间戳 | + +### 2.3 初始数据回填 + +```sql +-- 分类 +INSERT INTO castlove_categories (id, name, type_key, sort_order) VALUES + (10001, '星卡', 'star_card', 0), + (10002, '吧唧', 'badge', 1), + (10003, '海报', 'poster', 2); +SELECT setval('castlove_categories_id_seq', (SELECT MAX(id) FROM castlove_categories)); + +-- 卡片(按现有 craft-select.vue 第 263-370 行硬编码内容回填) +INSERT INTO castlove_crafts (id, category_id, name, image_url, coming_soon, sort_order) VALUES + -- 星卡 + (10001, 10001, '镭射卡', 'https:///castlove/leisheka.png', false, 0), + (10002, 10001, '光栅卡', 'https:///castlove/guangshanka.png', false, 1), + (10003, 10001, '拍立得', 'https:///castlove/pailide.png', false, 2), + (10004, 10001, '开发中', 'https:///castlove/daikaifa.png', true, 3), + (10005, 10001, '撕拉片', 'https:///castlove/silapian.png', false, 4), + -- 吧唧 + (10006, 10002, '超复古', 'https:///castlove/fugu.png', false, 0), + (10007, 10002, '卡通刺绣', 'https:///castlove/katongchixiu.png', false, 1), + (10008, 10002, '云母片', 'https:///castlove/yunmupian.png', false, 2), + (10009, 10002, '开发中', 'https:///castlove/daikaifa.png', true, 3), + -- 海报 + (10010, 10003, '拼豆', 'https:///castlove/pindou.png', false, 0), + (10011, 10003, '极繁插画', 'https:///castlove/jinfanchahua.png', false, 1), + (10012, 10003, '街头拼贴', 'https:///castlove/jietoupintie.png', false, 2), + (10013, 10003, '开发中', 'https:///castlove/daikaifa.png', true, 3); +SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts)); +``` + +`` 替换为真实 OSS bucket 域名;初次回填可以先把现有 `/static/castlove/*.png` 文件上传到 OSS 拿到 URL 再替换。 + +--- + +## 三、后端读取 API + +### 3.1 接口定义 + +| 项 | 值 | +|---|---| +| 路径 | `GET /api/v1/castlove/config` | +| 鉴权 | **强制 JWT**(未登录返回 401) | +| 入参 | 无 | +| 缓存 | 进程内 5 秒(带 `singleflight` 防击穿) | + +### 3.2 响应结构 + +```json +{ + "code": 200, + "message": "ok", + "data": { + "categories": [ + { + "id": 10001, + "name": "星卡", + "type_key": "star_card", + "sort_order": 0, + "crafts": [ + { "id": 10001, "name": "镭射卡", "image_url": "https://oss/.../leisheka.png", "coming_soon": false, "sort_order": 0 }, + { "id": 10002, "name": "光栅卡", "image_url": "https://oss/.../guangshanka.png", "coming_soon": false, "sort_order": 1 }, + { "id": 10003, "name": "拍立得", "image_url": "https://oss/.../pailide.png", "coming_soon": false, "sort_order": 2 }, + { "id": 10004, "name": "开发中", "image_url": "https://oss/.../daikaifa.png", "coming_soon": true, "sort_order": 3 }, + { "id": 10005, "name": "撕拉片", "image_url": "https://oss/.../silapian.png", "coming_soon": false, "sort_order": 4 } + ] + }, + { "id": 10002, "name": "吧唧", "type_key": "badge", "sort_order": 1, "crafts": [/* ... */] }, + { "id": 10003, "name": "海报", "type_key": "poster", "sort_order": 2, "crafts": [/* ... */] } + ], + "version": "2026-06-04T10:23:00Z" + } +} +``` + +- 只返回 `is_active=true AND deleted_at IS NULL` 的记录 +- 分类按 `sort_order ASC, id ASC` 排序 +- 卡片在每个分类内同样按 `sort_order ASC, id ASC` 排序 +- `version` 字段是所有相关记录 `updated_at` 的最大值(ISO 8601),便于调试 / 日后做条件请求 + +### 3.3 实现位置 + +| 文件 | 职责 | +|---|---| +| `backend/gateway/controller/castlove_config.go` | HTTP 控制器:鉴权 → 调 service → 返回 | +| `backend/gateway/service/castlove_config.go` | 业务逻辑:查 DB + 组装 + 缓存 | +| `backend/gateway/repository/castlove_config.go` | GORM 读两张表 | +| `backend/gateway/dto/castlove_config.go` | 响应 DTO | +| `backend/gateway/router/router.go` | 注册路由 `GET /api/v1/castlove/config` | + +### 3.4 缓存策略 + +- `sync.Map` + 时间戳,TTL = 5 秒 +- 首次请求或缓存过期 → `singleflight.Group.Do` 包一层,多并发只打一次 DB +- 后台改完数据不需要手动 invalidate,最长 5 秒自然过期 +- 5 秒是平衡(端上体验 vs DB 压力);如果运营反馈"改完看不到要等 5 秒太久",可以调小到 2 秒 +- **可观测性**(建议):在 service 层加 metrics: + - `castlove_config_cache_hit_total` / `castlove_config_cache_miss_total` + - `castlove_config_db_query_duration_ms` + - 排障时可快速判断"是不是缓存击穿"或"DB 慢" + +### 3.5 Service 层最低 validate(兜底) + +后台直连写库,DB 约束已经挡掉了 NULL / 重复 name / 重复 type_key,但 service 层从 DB 读出后仍要做最低限度的 sanity check(防止后台同学绕开 UI 直接 SQL 注入脏数据): + +- 跳过 `image_url` 不以 `http://` 或 `https://` 开头的卡片(log warn,不抛错) +- 跳过 `name` 为空字符串的记录 +- 跳过 `category_id` 在 `castlove_categories` 表中找不到的孤儿卡片(log error) + +这些只是"过滤掉,让前端不显示坏数据",不是真正的鉴权或修复;DB 层 NOT NULL 已经挡了大多数,service 层做兜底是为了避免端上崩。 + +### 3.6 控制器伪代码 + +```go +// backend/gateway/controller/castlove_config.go +func GetCastloveConfig(c *gin.Context) { + cfg, err := castloveConfigSvc.GetConfig(c.Request.Context()) + if err != nil { + response.Fail(c, http.StatusInternalServerError, "配置加载失败") + return + } + response.OK(c, cfg) +} + +// backend/gateway/service/castlove_config.go +var ( + cache atomic.Pointer[cachedConfig] + sfGroup singleflight.Group +) + +type cachedConfig struct { + data *dto.CastloveConfigResp + expiresAt time.Time +} + +func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveConfigResp, error) { + if c := cache.Load(); c != nil && time.Now().Before(c.expiresAt) { + return c.data, nil + } + v, err, _ := sfGroup.Do("castlove_config", func() (interface{}, error) { + return s.loadFromDB(ctx) + }) + if err != nil { + return nil, err + } + resp := v.(*dto.CastloveConfigResp) + cache.Store(&cachedConfig{data: resp, expiresAt: time.Now().Add(5 * time.Second)}) + return resp, nil +} +``` + +--- + +## 四、后台直连写库约定 + +后台直接操作 `castlove_categories` / `castlove_crafts`,没有 Go 业务层把关,约定如下: + +### 约定 1:软删除 +- 删除一律走 `UPDATE ... SET deleted_at = NOW()` +- **禁止** `DELETE FROM` +- 读取 API 已 `WHERE deleted_at IS NULL`,软删后前端立即不可见 + +### 约定 2:序列同步(CLAUDE.md 强制规则) +- 任何手动 `INSERT ... VALUES (id, ...)` 必须末尾跟一句 `SELECT setval(...)`: + ```sql + SELECT setval('castlove_categories_id_seq', (SELECT MAX(id) FROM castlove_categories)); + SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts)); + ``` +- 通过后台正常表单新增(不指定 id,让 GORM 自增)不用手动 setval + +### 约定 3:唯一性 +- `castlove_categories.name` 唯一(DB UNIQUE 约束) +- `castlove_categories.type_key` 非空时唯一(部分索引) +- `castlove_crafts.name` 不强制唯一(不同分类下可重名) + +### 约定 4:image_url +- 必须是 **完整 https URL**(OSS 域名开头) +- 不要存 `/static/...` 路径 +- 后台上传组件统一走 OSS bucket(同 assetService 用的 bucket) + +### 约定 5:sort_order +- 越小越靠前;允许重复(重复时按 id 兜底排) +- 后台 UI 建议提供"拖拽排序"或"上移/下移"按钮 + +### 约定 6:禁止删除规则 +- **带 `type_key` 的分类不允许软删**(前端 deep-link 会失效) +- 后台 UI 上对这类分类禁用删除按钮 +- 如果必须删,先把 `type_key` 清空,保存后再删 + +### 约定 6.1:新增 type_key 的 SOP(前后端协调流程) + +`type_key` 是前端硬编码外部入口的契约(`mall.vue` 传 `"star_card"` 等字符串),后台**不能**自己加新 key 直接生效——必须按序操作: + +1. **前端发版** 先在 `craft-select.vue` / 父组件相关位置加 `?type=新key` 的入口 +2. **后台开发** 在 §5.2 表单的 Type Key 下拉里加新选项(这是硬编码下拉) +3. **后台运营** 给某个分类绑定新 `type_key` +4. **验证** deep-link `?type=新key` 能正确定位 + +**反过来的顺序会让运营以为"我设了 type_key 但前端入口不工作"**。建议把这个流程写到后台 UI 的字段帮助文案里:"新增 type_key 需先联系前端发版"。 + +### 约定 7:缓存生效时间 +- 后端 API 有 5 秒进程内缓存 +- 后台改完数据,最长 5 秒前端会看到新值 +- 不需要任何手动 invalidate + +--- + +## 五、后台管理 UI 规格 + +### 5.1 菜单结构 + +``` +后台管理 +└── 内容配置 + └── 铸爱工艺配置 ← 新增 + ├── 分类管理 (Tab) + └── 卡片管理 (Tab) +``` + +### 5.2 分类管理(操作 `castlove_categories`) + +**列表展示** + +| 列 | 字段 | 备注 | +|---|---|---| +| 排序 | sort_order | 数字升序展示;列头支持拖拽排序 | +| 名称 | name | | +| Type Key | type_key | 留空显示 "—" | +| 卡片数 | (count) | JOIN 查 `castlove_crafts` 计数 | +| 启用 | is_active | Switch 开关 | +| 创建时间 | created_at | | +| 操作 | — | 编辑 / 软删除(带 type_key 的禁用) | + +**新增 / 编辑表单** + +| 字段 | 控件 | 必填 | 校验 | +|---|---|---|---| +| 名称 | input | ✅ | 长度 1~64;与现有未删除分类不重名 | +| Type Key | select | ❌ | 下拉选项 `[空, star_card, badge, poster]` | +| 排序 | number | ✅ | 默认 = 当前最大 sort_order + 10 | +| 启用 | switch | ✅ | 默认 true | + +**Type Key 下拉项硬编码**为 `[空, star_card, badge, poster]`。新增 deep-link key 时需要后端 + 前端同步改代码(这是"前端自维护路由"的延伸约束)。 + +**删除按钮的禁用条件**:`type_key IS NOT NULL` → 禁用,hover 提示"该分类被外部入口引用,删除前请先清空 Type Key"。 + +### 5.3 卡片管理(操作 `castlove_crafts`) + +**列表展示**(顶部带"按分类筛选"下拉) + +| 列 | 字段 | 备注 | +|---|---|---| +| 排序 | sort_order | 同一分类内的相对顺序 | +| 缩略图 | image_url | 80×80 缩略图,点击放大预览 | +| 名称 | name | | +| 所属分类 | category_id → name | | +| 开发中 | coming_soon | 角标显示 | +| 启用 | is_active | Switch | +| 操作 | — | 编辑 / 软删除 / 拖拽排序 | + +**新增 / 编辑表单** + +| 字段 | 控件 | 必填 | 校验 | +|---|---|---|---| +| 所属分类 | select | ✅ | 下拉显示所有未删除分类 | +| 名称 | input | ✅ | 长度 1~64 | +| 图片 | **OSS 上传组件** | ✅ | jpg/png/webp;建议 ≤ 1MB;建议正方形 | +| 开发中 | switch | ✅ | 默认 false;为 true 时图片可选用 daikaifa 占位 | +| 排序 | number | ✅ | 默认 = 该分类下当前最大 sort_order + 10 | +| 启用 | switch | ✅ | 默认 true | + +**图片上传组件行为**: + +1. 用户选择文件 → 调后台 OSS 直传接口 → 拿到 `https://oss-bucket.../castlove/{uuid}.{ext}` +2. **OSS 路径前缀约定**:`castlove/`(与 `assets/` / `avatars/` 等其他业务并列),具体 bucket 名沿用 assetService 现有配置 +3. **不存路径,只存完整 URL**(约定 4) +4. 编辑时把现有 `image_url` 当回显,允许"替换"或"清空再传" +5. 旧图不主动删 OSS(避免别处引用断链;OSS 生命周期策略另说) + +### 5.4 操作确认弹窗 + +| 操作 | 是否需要二次确认 | +|---|---| +| 新增 | 否 | +| 编辑 | 否 | +| 切换 is_active | 否(一键开关) | +| 软删除 | ✅ "确认删除该分类/卡片?前端将立即不可见" | +| 修改 type_key | ✅ "修改后,使用该 type_key 的外部入口将定位失败" | + +### 5.5 性能 / 缓存提示 + +后台编辑后**最长 5 秒**前端生效(后端有进程内缓存)。可在保存成功的 toast 中加一行小字:"最长 5 秒内端上生效",避免运营同学以为没保存上。 + +### 5.6 权限 + +后台需要明确:本菜单"铸爱工艺配置"挂在 **运营管理员** 角色下(与"任务定义管理"、"用户管理"等同级)。如果后台权限模型是 RBAC,需要: +- 新增菜单项 `castlove_config_view`(读权限) / `castlove_config_write`(写权限) +- 默认绑定到现有"超级管理员"和"运营管理员"角色 +- 普通运营 / 客服角色默认不可见 + +如果后台无 RBAC,所有能进后台的人都可改即可,无需此节。 + +--- + +## 六、前端 craft-select.vue 改造 + +### 6.1 修改范围 + +| 文件 | 改动 | +|---|---| +| `frontend/utils/api.js` | 追加 `getCastloveConfigApi()` | +| `frontend/pages/castlove/craft-select.vue` | 移除硬编码 data、改为 API 拉取、布局重构右侧菜单、清理孤儿方法 | + +**确认无须改动的关联文件**(grep 验证): + +| 文件 | 说明 | +|---|---| +| `frontend/pages/castlove/mall.vue` | 仅传 `initialType = "star_card"` 字符串作为 prop | +| `frontend/pages/square/square.vue` | `mainTabs` 中带 `type: 'star_card' / 'badge' / 'poster'`,跳转时拼到 URL query | +| `frontend/pages/components/CastloveContent.vue` | 同上,`mainTabs` 中用相同 3 个字符串 | + +这 3 个父组件**不依赖** `craft-select.vue` 的 `categoryTypeMap`,它们只是传 `type` 字符串。`craft-select.vue` 内部通过 `type_key` 在线匹配后端数据,3 个字符串保持原值即可——后台只要保留 `star_card / badge / poster` 这 3 个 `type_key`,外部入口不受影响。**这就是约定 6"带 type_key 的分类禁止删除"的来源**。 + +### 6.2 utils/api.js 追加 + +```js +// === 铸爱工艺配置 === +/** 获取铸爱页分类与工艺卡片配置(强制鉴权,未登录 401) */ +export function getCastloveConfigApi() { + return request({ + url: '/api/v1/castlove/config', + method: 'GET' + }) +} +``` + +追加到 `utils/api.js` 末尾。`request()` 自动带 token、错误处理、401 跳登录(沿用现有拦截器)。 + +### 6.3 craft-select.vue data() 改造 + +**移除**(原 263-370 行 + 其他孤儿引用): +- `categoryList` +- `cardListMap` +- `cardList` +- `categoryTypeMap`(改用在线 `findIndex(c => c.type_key === type)`) +- 方法 `handleSkip()`(原文 227-242 行,模板未绑定的孤儿方法,且内部用 `this.cardList` 会报未定义) + +**保留**(仍是计算属性,与卡片数量计算相关,不依赖被删的硬码数据): +- computed `currentCategoryName` / `currentCardList` / `currentCardCount` / `currentCenterIndex` / `defaultSelectedIndex` +- 所有动效相关 state 与方法(`getStackPositions` / `getCardStyle` / `getCardStackPosition` / 触摸事件等) +- `cardRoutes`(前端自维护) + +**新增 data**: + +```js +data() { + return { + showMenu: true, + selectedCategoryIndex: 0, + + // ↓ 全部来自后端 + categories: [], // [{ id, name, type_key, crafts: [...] }] + loading: true, + loadError: '', + + // ↓ 仍写死在前端,按 craft.name 查 + cardRoutes: { + '光栅卡': '/pages/castlove/lenticular/lenticular-create', + '拍立得': '/pages/castlove/create', + '镭射卡': '/pages/castlove/create', + '撕拉片': '/pages/castlove/create', + }, + + // 原有交互/动效状态保留 + selectedIndex: 1, + touchStartY: 0, + dragOffset: 0, + isDragging: false, + disableTransition: false, + SWIPE_STEP: 100, + } +} +``` + +### 6.4 生命周期与加载 + +```js +import { getCastloveConfigApi } from '@/utils/api.js' + +async onLoad(options) { + await this.loadConfig() + if (options?.type) this.applyType(options.type) +}, + +async loadConfig() { + try { + this.loading = true + const res = await getCastloveConfigApi() + this.categories = res?.data?.categories || [] + } catch (e) { + this.loadError = e?.message || '配置加载失败' + } finally { + this.loading = false + } +}, + +applyType(type) { + if (!type) return + const idx = this.categories.findIndex(c => c.type_key === type) + if (idx >= 0) this.selectedCategoryIndex = idx + // 找不到则保持 0,不报错 +} +``` + +### 6.5 computed 重写 + selectedIndex 越界保护 + +```js +currentCategoryName() { + return this.categories[this.selectedCategoryIndex]?.name || '' +}, +currentCardList() { + return this.categories[this.selectedCategoryIndex]?.crafts || [] +}, +currentCardCount() { + return this.currentCardList.length +}, +defaultSelectedIndex() { + // 原本固定 = 1(默认第 2 张),但卡片数 < 2 时会越界 + // 卡片 0 张 → 0;卡片 1 张 → 0;卡片 ≥ 2 张 → 1 + return Math.min(1, Math.max(0, this.currentCardCount - 1)) +}, +``` + +**切换分类时同样兜底**(`selectCategory` 里): + +```js +selectCategory(index) { + this.selectedCategoryIndex = index + this.selectedIndex = this.defaultSelectedIndex // 用计算属性而不是写死 1 + this.dragOffset = 0 + this.isDragging = false +} +``` + +后台动态删卡也安全:卡片数变化后 `defaultSelectedIndex` 重新计算。 + +### 6.6 模板调整 + +- `v-for="(category, index) in categories"` 替代原来的 `categoryList` +- `` 替代原来的 `:src="card.image"` +- `card.coming_soon` 替代 `card.comingSoon` + +### 6.7 右侧菜单布局重构 + +原来 `.text-panel` 固定高度 `392rpx`、3 项硬码字号(`font-large` / `font-mid`)。改为: + +- 容器自适应高度:`min-height: 392rpx`,超过 5 项启用 `overflow-y: auto` +- 不再写 `font-large` / `font-mid`,统一字号 +- 选中项放大(已有 `.active` 样式,调整字号即可) +- 箭头按钮:到顶 / 到底时显示禁用态 + +### 6.8 模板分支:loading / error / empty / 正常 + +⚠️ 原模板 `v-if="currentCardList.length"` 在 loading 期间(`categories=[]`)会让整个卡片区消失,**违反"不要白屏"**。新模板必须显式给出 4 个分支: + +```html + + + + + + + + + {{ loadError }} + 点击重试 + + + + + + 暂无内容 + + + + + + {{ currentCategoryName }}敬请期待 + + + + + + + + + +``` + +- `:key` 用 `card.id` 而不是 `index`,后台增删卡片后 Vue 才能正确复用 +- `loading` 期间背景图照常显示(背景在 `.bg-wrapper`,不在 `.cards-container` 内),不会全白屏 +- 右侧菜单 `text-panel` 仍可见,但加 `v-if="!loading && !loadError && categories.length"` + +### 6.9 点击逻辑 + +保持不变:`onCardFrameTap` 仍按 `card.name` 查 `cardRoutes`,匹配不上 → "激情开发中" toast;`coming_soon=true` 走同样 toast。 + +--- + +## 七、错误处理 / 边界 + +### 7.1 后端 + +| 情况 | 处理 | +|---|---| +| 两表空 | 返回 `{ categories: [] }`,HTTP 200 | +| `crafts.image_url` 空字符串 | DB NOT NULL 已挡;前端 `` `@error` 显示占位图 | +| 未带 JWT / token 失效 | 中间件返回 401 | +| DB 连接失败 | 返回 500,server log 打 trace | +| 并发请求(5s 缓存未热) | `singleflight` 防击穿 | +| 缓存里有数据但 DB 宕 | 缓存仍返回,窗口内"降级可用" | + +### 7.2 前端 + +| 情况 | 处理 | +|---|---| +| 网络失败 / 5xx | `loadError` 置错,显示"配置加载失败,下拉重试" | +| 401 | 全局拦截器跳登录页 | +| `categories.length === 0` | 整页显示"暂无内容" | +| 某分类 `crafts.length === 0` | 显示 empty-state | +| `cardRoutes[card.name]` 找不到 | "激情开发中" toast | +| `card.coming_soon === true` | "敬请期待" toast | +| `?type=star_card` 但已删该 type_key | `findIndex` 返回 -1,回退 0 | +| 图片 URL 加载失败 | `` `@error` 兜底显示占位 | + +### 7.3 Loading 体验 + +进入页面到拿到配置(一般 < 200ms),背景图照常显示,卡片区显示半透明 loading 圆圈,**不要白屏**。 + +--- + +## 八、测试策略 + +### 8.1 后端单测 + +`backend/gateway/controller/castlove_config_test.go`: + +| 用例 | 说明 | +|---|---| +| `TestGetCastloveConfig_Empty` | 两表空 → `{categories:[]}` | +| `TestGetCastloveConfig_HappyPath` | 3 分类 N 卡片,顺序按 sort_order | +| `TestGetCastloveConfig_SoftDeleted` | `deleted_at` 非 NULL 不出现 | +| `TestGetCastloveConfig_Inactive` | `is_active=false` 不出现 | +| `TestGetCastloveConfig_Unauthorized` | 未带 token → 401 | +| `TestGetCastloveConfig_InvalidImageURL` | service 层过滤掉 `image_url` 不带 http(s) 的卡片 | +| `TestGetCastloveConfig_OrphanCraft` | service 层过滤掉 `category_id` 找不到的卡片 | +| `TestGetCastloveConfig_CacheHit` | 第二次走缓存,DB 调用次数=1 | +| `TestGetCastloveConfig_CacheExpire` | 5 秒后第三次重新打库(用可注入 clock 而不是真 sleep) | +| `TestGetCastloveConfig_Concurrent` | 10 并发 → DB 调用 1 次(singleflight) | + +**测试 DB 接入方式**(项目现有模式,**不要**引入 testcontainers / sqlmock): + +参考 `backend/services/assetService/repository/asset_repository_test.go` 的 `setupTestDB` / `cleanupTestDB` 模式: + +```go +func setupTestDB(t *testing.T) *gorm.DB { + config := database.Config{ + Host: "localhost", Port: 5432, + User: "haihuizhu", Password: "admin", + DBName: "top-fans", SSLMode: "disable", + TimeZone: "Asia/Shanghai", + } + if err := database.Init(config); err != nil { + t.Skipf("Skipping test: %v", err) // 没有本地 DB 就跳过,不阻断 CI + } + db := database.GetDB() + db.AutoMigrate(&models.CastloveCategory{}, &models.CastloveCraft{}) + cleanupTestDB(t, db) // 跑前清空 + return db +} + +func cleanupTestDB(t *testing.T, db *gorm.DB) { + db.Exec("TRUNCATE castlove_crafts, castlove_categories RESTART IDENTITY CASCADE") +} +``` + +- 用例开头 `db := setupTestDB(t); defer cleanupTestDB(t, db)` +- 跑测试前确保本地有 `top-fans` 库(或 `t.Skipf` 跳过) +- 缓存测试用可注入 `clock`(`time.Now` 替换为接口)而不是 `time.Sleep(5*time.Second)` + +### 8.2 前端手测脚本 + +1. 后台插入 1 个新分类 "周边" + 2 张卡片,5 秒内刷新页面 → 右侧菜单出现 "周边",切换后展示 2 张卡片 +2. 后台把 "星卡" 的 `sort_order` 调到最大 → 5 秒内右侧菜单中 "星卡" 移到底部 +3. 后台软删 "拍立得" → 5 秒内星卡分类下少了 "拍立得" 一张 +4. 后台改 "光栅卡" 的 `image_url` → 5 秒内卡片图片更新 +5. deep-link `?type=star_card` → 即使 "星卡" 已经被后台调到第 3 位,仍正确定位 +6. 删除某分类(无 type_key 的)→ 不影响其他分类显示 +7. 把全部分类软删 → 整页显示 "暂无内容" +8. 模拟 401 → 跳登录页 +9. 模拟 OSS 图片 404 → 卡片显示占位图,不闪退 + +### 8.3 不写单测的部分 + +- 前端 Vue 组件交互(动效 / touch swipe):已有,uni-app 单测基建薄;用手测覆盖 +- 后台管理 UI:对接团队的项目 + +--- + +## 九、上线 / 回滚 + +### 9.1 上线步骤 + +1. 执行 §2.1 / §2.2 / §2.3 的 DDL + 初始数据 SQL +2. 上传现有 `/static/castlove/*.png` 到 OSS,拿到 URL,更新 `castlove_crafts.image_url` +3. 部署后端(gateway)—— 此时端上还在用老代码,不影响 +4. 后台同学按 §5 规格开发 UI(可与步骤 3 并行) +5. 部署前端(craft-select.vue 改造) +6. 验收:跑一遍 §8.2 手测脚本 + +### 9.2 回滚 + +- 回滚前端版本即可(老前端不依赖新接口) +- 后端接口可保留(无端上调用就是死代码,不影响) +- DB 表保留(不影响其他业务) + +--- + +## 十、关联文件 + +| 文件 | 关系 | +|---|---| +| `frontend/pages/castlove/craft-select.vue` | 主要改造对象(移除硬码 + 模板分支 + 越界保护 + 删孤儿方法 handleSkip) | +| `frontend/pages/castlove/mall.vue` | 无需改动(仍传 `initialType="star_card"`) | +| `frontend/pages/square/square.vue` | 无需改动(`mainTabs.type` 仍是 3 个固定字符串) | +| `frontend/pages/components/CastloveContent.vue` | 无需改动(同上) | +| `frontend/utils/api.js` | 追加 `getCastloveConfigApi()` | +| `backend/gateway/controller/castlove_config.go` | 新增 | +| `backend/gateway/service/castlove_config.go` | 新增(含 5s 缓存 + singleflight + 最低 validate) | +| `backend/gateway/repository/castlove_config.go` | 新增 | +| `backend/gateway/dto/castlove_config.go` | 新增 | +| `backend/gateway/router/router.go` | 注册路由 | +| `backend/scripts/migrations/migrate_castlove_config.sql` | 新增 migration(命名遵循现有 `migrate_.sql` 约定,目录与 `migrate_ai_chat_tables.sql` 等同级) | +| `CLAUDE.md` | 遵循 PostgreSQL 序列同步规则 | diff --git a/frontend/static/icon/like-after.png b/frontend/static/icon/like-after.png index cb6d3db..c18d694 100644 Binary files a/frontend/static/icon/like-after.png and b/frontend/static/icon/like-after.png differ diff --git a/frontend/static/icon/like-before.png b/frontend/static/icon/like-before.png index b495011..0d6292e 100644 Binary files a/frontend/static/icon/like-before.png and b/frontend/static/icon/like-before.png differ diff --git a/frontend/static/rank/activity-support-icon/tubiao.png b/frontend/static/rank/activity-support-icon/tubiao.png index 9a4a0e5..1da5b6b 100644 Binary files a/frontend/static/rank/activity-support-icon/tubiao.png and b/frontend/static/rank/activity-support-icon/tubiao.png differ