topfans/docs/superpowers/specs/2026-06-04-castlove-config-admin-design.md
2026-06-04 17:58:02 +08:00

763 lines
30 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-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 truthPostgreSQL 两张表
- 后台直连同库(共用同一 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://<oss>/castlove/leisheka.png', false, 0),
(10002, 10001, '光栅卡', 'https://<oss>/castlove/guangshanka.png', false, 1),
(10003, 10001, '拍立得', 'https://<oss>/castlove/pailide.png', false, 2),
(10004, 10001, '开发中', 'https://<oss>/castlove/daikaifa.png', true, 3),
(10005, 10001, '撕拉片', 'https://<oss>/castlove/silapian.png', false, 4),
-- 吧唧
(10006, 10002, '超复古', 'https://<oss>/castlove/fugu.png', false, 0),
(10007, 10002, '卡通刺绣', 'https://<oss>/castlove/katongchixiu.png', false, 1),
(10008, 10002, '云母片', 'https://<oss>/castlove/yunmupian.png', false, 2),
(10009, 10002, '开发中', 'https://<oss>/castlove/daikaifa.png', true, 3),
-- 海报
(10010, 10003, '拼豆', 'https://<oss>/castlove/pindou.png', false, 0),
(10011, 10003, '极繁插画', 'https://<oss>/castlove/jinfanchahua.png', false, 1),
(10012, 10003, '街头拼贴', 'https://<oss>/castlove/jietoupintie.png', false, 2),
(10013, 10003, '开发中', 'https://<oss>/castlove/daikaifa.png', true, 3);
SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
```
`<oss>` 替换为真实 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` 不强制唯一(不同分类下可重名)
### 约定 4image_url
- 必须是 **完整 https URL**OSS 域名开头)
- 不要存 `/static/...` 路径
- 后台上传组件统一走 OSS bucket同 assetService 用的 bucket
### 约定 5sort_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`
- `<image :src="card.image_url" />` 替代原来的 `: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
<view class="cards-container" ...>
<!-- 1. 加载中 -->
<view v-if="loading" class="loading-state">
<image class="loading-spinner" src="/static/common/loading.png" />
</view>
<!-- 2. 加载失败 -->
<view v-else-if="loadError" class="error-state" @tap="loadConfig">
<text class="error-text">{{ loadError }}</text>
<text class="error-hint">点击重试</text>
</view>
<!-- 3. 全空(后台把所有分类都软删了) -->
<view v-else-if="categories.length === 0" class="empty-state">
<image src="/static/castlove/jinqingqidai.png" />
<text>暂无内容</text>
</view>
<!-- 4. 某分类下卡片为空 -->
<view v-else-if="currentCardList.length === 0" class="empty-state">
<image src="/static/castlove/jinqingqidai.png" />
<text class="empty-state-title">{{ currentCategoryName }}敬请期待</text>
</view>
<!-- 5. 正常 -->
<view v-else>
<view
v-for="(card, index) in currentCardList"
:key="card.id"
class="card-item"
...
>
<!-- 卡片渲染逻辑保持不变 -->
</view>
</view>
</view>
```
- `: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 已挡;前端 `<image>` `@error` 显示占位图 |
| 未带 JWT / token 失效 | 中间件返回 401 |
| DB 连接失败 | 返回 500server 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 加载失败 | `<image>` `@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_<purpose>.sql` 约定目录与 `migrate_ai_chat_tables.sql` 等同级 |
| `CLAUDE.md` | 遵循 PostgreSQL 序列同步规则 |