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

30 KiB
Raw Blame History

铸爱页分类/工艺卡片配置化 设计文档

  • 日期2026-06-04
  • 涉及模块:frontend/pages/castlove/craft-select.vuebackend/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分类表

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 定位的稳定 keystar_card / badge / poster),可空;非空时唯一(部分索引,允许多行 NULL
sort_order 排序,越小越靠前;允许重复,重复时按 id 兜底
is_active 软下线开关,前端只展示 true 的
deleted_at 软删除时间戳

2.2 castlove_crafts工艺卡片表

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 URLOSS 域名开头),不允许 /static/... 相对路径
coming_soon true 时点击中央卡片弹"敬请期待",不跳转
sort_order 同一分类内部的相对顺序
is_active 软下线开关
deleted_at 软删除时间戳

2.3 初始数据回填

-- 分类
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 响应结构

{
  "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_idcastlove_categories 表中找不到的孤儿卡片log error

这些只是"过滤掉,让前端不显示坏数据"不是真正的鉴权或修复DB 层 NOT NULL 已经挡了大多数service 层做兜底是为了避免端上崩。

3.6 控制器伪代码

// 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(...)
    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 URLOSS 域名开头)
  • 不要存 /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.vuecategoryTypeMap,它们只是传 type 字符串。craft-select.vue 内部通过 type_key 在线匹配后端数据3 个字符串保持原值即可——后台只要保留 star_card / badge / poster 这 3 个 type_key,外部入口不受影响。这就是约定 6"带 type_key 的分类禁止删除"的来源

6.2 utils/api.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

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 生命周期与加载

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 越界保护

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 里):

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 个分支:

<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>
  • :keycard.id 而不是 index,后台增删卡片后 Vue 才能正确复用
  • loading 期间背景图照常显示(背景在 .bg-wrapper,不在 .cards-container 内),不会全白屏
  • 右侧菜单 text-panel 仍可见,但加 v-if="!loading && !loadError && categories.length"

6.9 点击逻辑

保持不变:onCardFrameTap 仍按 card.namecardRoutes,匹配不上 → "激情开发中" toastcoming_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.gosetupTestDB / cleanupTestDB 模式:

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 跳过)
  • 缓存测试用可注入 clocktime.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 序列同步规则