43 KiB
铸爱页分类/工艺卡片配置化 设计文档
- 日期: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 秒内生效;跳转路由也配置化(后台维护
route_path+route_params) - 不在本次范围:后台管理 UI 由对接团队实现,本文档仅给出规格约定
一、整体架构
+----------------------+ +-------------------+
| 后台管理 (外部项目) | 直连 | PostgreSQL |
| 写 castlove_* | ──────▶ | castlove_categories |
+----------------------+ | castlove_crafts |
+---------┬---------+
│ GORM 读
▼
+-------------------+
| Go gateway :8080 |
| GET /api/v1/ |
| castlove/config |
| (5s 进程内缓存) |
+---------┬---------+
│ JWT 鉴权
▼
+-------------------+
| uni-app 前端 |
| craft-select.vue |
| route_path 来自 |
| DB 配置 |
+-------------------+
- 数据 source of truth:PostgreSQL 两张表
- 后台直连同库(共用同一 PostgreSQL),不走 Go 业务层
- 前端只读,强制鉴权,进程内 5 秒缓存兜底高 QPS
- 跳转路由从 DB 读取(
castlove_crafts.route_path+route_params),不再由前端cardRoutes硬编码
二、数据库设计
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 定位的稳定 key(star_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,
route_path VARCHAR(255), -- 跳转页面路径,可空(NULL=点开展示"激情开发中" toast)
route_params JSONB, -- query 参数 JSON 对象,可空
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/... 相对路径 |
route_path |
uni-app 页面路径(/pages/castlove/xxx),可为 NULL;为 NULL 时点击弹"激情开发中" toast,不跳转 |
route_params |
query 参数 JSON 对象(如 {"material":"star","from":"config"}),可为 NULL;运行时拼成 ?material=star&from=config 拼到 route_path 后 |
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 行硬编码内容回填,route_path 来自原 cardRoutes 常量映射)
INSERT INTO castlove_crafts (id, category_id, name, image_url, route_path, route_params, sort_order) VALUES
-- 星卡(原 cardRoutes 中有映射)
(10001, 10001, '镭射卡', 'https://<oss>/castlove/leisheka.png', '/pages/castlove/create', NULL, 0),
(10002, 10001, '光栅卡', 'https://<oss>/castlove/guangshanka.png', '/pages/castlove/lenticular/lenticular-create', NULL, 1),
(10003, 10001, '拍立得', 'https://<oss>/castlove/pailide.png', '/pages/castlove/create', NULL, 2),
(10004, 10001, '开发中', 'https://<oss>/castlove/daikaifa.png', NULL, NULL, 3),
(10005, 10001, '撕拉片', 'https://<oss>/castlove/silapian.png', '/pages/castlove/create', NULL, 4),
-- 吧唧(原 cardRoutes 未映射,route_path=NULL,点击走 toast)
(10006, 10002, '超复古', 'https://<oss>/castlove/fugu.png', NULL, NULL, 0),
(10007, 10002, '卡通刺绣', 'https://<oss>/castlove/katongchixiu.png', NULL, NULL, 1),
(10008, 10002, '云母片', 'https://<oss>/castlove/yunmupian.png', NULL, NULL, 2),
(10009, 10002, '开发中', 'https://<oss>/castlove/daikaifa.png', NULL, NULL, 3),
-- 海报(同上,route_path=NULL)
(10010, 10003, '拼豆', 'https://<oss>/castlove/pindou.png', NULL, NULL, 0),
(10011, 10003, '极繁插画', 'https://<oss>/castlove/jinfanchahua.png', NULL, NULL, 1),
(10012, 10003, '街头拼贴', 'https://<oss>/castlove/jietoupintie.png', NULL, NULL, 2),
(10013, 10003, '开发中', 'https://<oss>/castlove/daikaifa.png', NULL, NULL, 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", "route_path": "/pages/castlove/create", "route_params": null, "sort_order": 0 },
{ "id": 10002, "name": "光栅卡", "image_url": "https://oss/.../guangshanka.png", "route_path": "/pages/castlove/lenticular/lenticular-create", "route_params": null, "sort_order": 1 },
{ "id": 10003, "name": "拍立得", "image_url": "https://oss/.../pailide.png", "route_path": "/pages/castlove/create", "route_params": {"material":"star"}, "sort_order": 2 },
{ "id": 10004, "name": "开发中", "image_url": "https://oss/.../daikaifa.png", "route_path": null, "route_params": null, "sort_order": 3 },
{ "id": 10005, "name": "撕拉片", "image_url": "https://oss/.../silapian.png", "route_path": "/pages/castlove/create", "route_params": null, "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 + 组装 + 5s 缓存 + singleflight + sanitizeCraft 兜底校验 |
backend/gateway/repository/castlove_config.go |
GORM 读两张表;CastloveCraft model 新增 RoutePath *string + RouteParams *json.RawMessage \gorm:"type:jsonb"``(推荐零依赖方案,详见 §3.6) |
backend/gateway/dto/castlove_config.go |
响应 DTO:craft 字段含 id / name / image_url / route_path / route_params / sort_order |
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_totalcastlove_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) route_path校验:必须是/开头的绝对路径(uni-app 页面路径约定)且不含?/#;否则清空为 NULL(log warn,不抛错——把"坏路径"降级为"未配置",跟 NULL 行为一致:点击弹 toast,不跳转)route_params校验:必须是 JSON 对象({})或 NULL;非对象类型(数组 / 字符串 / 数字 / null 字面量)一律清空为 NULL(log warn);值为嵌套对象 / 数组时同样清空(约定 5.2 限制 value 只能是标量)route_path为 NULL 时route_params无需处理:前端onCardFrameTap已经在!card.route_path时早 return,params 不会被读取
这些只是"过滤掉,让前端不显示坏数据",不是真正的鉴权或修复;DB 层 NOT NULL 已经挡了大多数,service 层做兜底是为了避免端上崩。
3.6 控制器 / Service 伪代码
// 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
}
// 后台直连写库绕过了 service 校验,这里读出后再做一次兜底
func sanitizeCraft(c *dto.CastloveCraft) *dto.CastloveCraft {
if !strings.HasPrefix(c.ImageURL, "http://") && !strings.HasPrefix(c.ImageURL, "https://") {
log.Warnf("craft %d 丢弃: image_url 不合法", c.ID)
return nil
}
if c.Name == "" {
log.Warnf("craft %d 丢弃: name 为空", c.ID)
return nil
}
// route_path 兜底:必须 / 开头且不含 ? / #
if c.RoutePath != nil && *c.RoutePath != "" {
p := *c.RoutePath
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, "?#") {
log.Warnf("craft %d 丢弃 route_path=%q: 格式不合法", c.ID, p)
c.RoutePath = nil
}
}
// route_params 兜底:必须是 JSON object { ... } 或 NULL
// 不用第三方 JSON 库;标准库 json.Unmarshal 到 map 即可同时校验 "是合法 JSON" + "是 object"
if rp := c.RouteParams; rp != nil {
var m map[string]json.RawMessage
if err := json.Unmarshal(*rp, &m); err != nil {
log.Warnf("craft %d 丢弃 route_params=%s: 非 JSON object (%v)", c.ID, *rp, err)
c.RouteParams = nil
}
}
return c
}
关于 RouteParams 类型的实现选择:
| 方案 | 引入依赖 | 优缺点 |
|---|---|---|
json.RawMessage([]byte) |
无(标准库) | 最轻量;DB JSONB 列直接 gorm:"type:jsonb" 映射;GORM 的 Scanner/Valuer 已经处理好 |
gorm.io/datatypes.JSON |
github.com/gorm.io/datatypes v1.x |
用法更"面向对象",但引入第三方包;本项目其他表没用过,不建议为单字段引入 |
pqtype.NullRawMessage |
跟随 lib/pq |
支持 NULL 区分,过于重量级 |
推荐方案 1(json.RawMessage):DTO 字段定义 RouteParams *json.RawMessage \gorm:"type:jsonb"`,所有读写都直接走 []byte,零额外依赖。如果项目里其他表已用了 datatypes.JSON`,则沿用 §3.3 表里写的方案,保持仓库一致性。
四、后台直连写库约定
后台直接操作 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不强制唯一(不同分类下可重名)
约定 4:image_url
- 必须是 完整 https URL(OSS 域名开头)
- 不要存
/static/...路径 - 后台上传组件统一走 OSS bucket(同 assetService 用的 bucket)
约定 5:sort_order
- 越小越靠前;允许重复(重复时按 id 兜底排)
- 后台 UI 建议提供"拖拽排序"或"上移/下移"按钮
约定 5.1:route_path(替代原 coming_soon)
- 必须是
/开头的绝对路径(uni-app 页面路径约定),例如/pages/castlove/create - 不允许包含
?或#:query 参数应统一放在route_params字段,禁止在 path 里手写 query string(避免前端buildQueryString拼出/x?a=1?b=2这种错乱 URL) - 允许 NULL:NULL 表示该卡片"未配置路由",前端点击时弹"激情开发中" toast,不跳转
- 不允许存相对路径(如
create)、带域名(如https://xxx.com/...)或带?/#的路径—— service 层 §3.5 会兜底清空 - 不再使用
coming_soon字段;需要"敬请期待"占位的卡片,把route_path留空即可
约定 5.2:route_params(JSON 参数)
- 必须是 JSON 对象(
{"key":"value"}),运行时拼成?key=value&...拼到route_path后 - 允许 NULL:表示无参数
- 键名建议 1~32 字符,值仅允许 string / number / boolean,不允许嵌套对象 / 数组(service 层 §3.5 兜底清空;非标量值会被降级为 NULL,行为同"未配置参数")
- 键名禁止
__proto__/constructor/prototype(防 prototype pollution,虽然本场景无 exploitation 路径,但作为通用规范) - 例:
{"material":"star","from":"config"}→ 跳转时/pages/castlove/create?material=star&from=config
约定 5.3:route 字段的最大长度
route_pathVARCHAR(255),超长会写入失败(DB 报错)- 后台 UI 建议给"页面路径"控件加 maxlength=255 限制
约定 6:禁止删除规则
- 带
type_key的分类不允许软删(前端 deep-link 会失效) - 后台 UI 上对这类分类禁用删除按钮
- 如果必须删,先把
type_key清空,保存后再删
约定 6.1:新增 type_key 的 SOP(前后端协调流程)
type_key 是前端硬编码外部入口的契约(mall.vue 传 "star_card" 等字符串),后台不能自己加新 key 直接生效——必须按序操作:
- 前端发版 先在
craft-select.vue/ 父组件相关位置加?type=新key的入口 - 后台开发 在 §5.2 表单的 Type Key 下拉里加新选项(这是硬编码下拉)
- 后台运营 给某个分类绑定新
type_key - 验证 deep-link
?type=新key能正确定位
反过来的顺序会让运营以为"我设了 type_key 但前端入口不工作"。建议把这个流程写到后台 UI 的字段帮助文案里:"新增 type_key 需先联系前端发版"。
约定 6.2:新增 route_path 的 SOP(前后端协调流程)
route_path 是 uni-app 页面路径,目标页面必须已经在前端项目里存在并注册。后台不能自己填一个新路径直接生效——必须按序操作:
- 前端发版 先在
frontend/pages.json里注册新页面(uni-app 路由配置) - 前端发版 实现对应页面组件并测试可独立跳转
- 后台运营 给某张卡片绑定新
route_path(可选填route_params) - 验证 端上点击该卡片能正确跳到新页面,参数拼接正确
反过来的顺序会让运营以为"我填了路径但点击不工作"。建议把这个流程写到后台 UI 的"页面路径"字段帮助文案里:"新增路径需先联系前端发版注册页面"。
约定 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 是前端外部入口的契约,无法配置化)。
删除按钮的禁用条件:type_key IS NOT NULL → 禁用,hover 提示"该分类被外部入口引用,删除前请先清空 Type Key"。
5.3 卡片管理(操作 castlove_crafts)
列表展示(顶部带"按分类筛选"下拉)
| 列 | 字段 | 备注 |
|---|---|---|
| 排序 | sort_order | 同一分类内的相对顺序 |
| 缩略图 | image_url | 80×80 缩略图,点击放大预览 |
| 名称 | name | |
| 所属分类 | category_id → name | |
| 路由 | route_path | 留空显示"—"("激情开发中"占位);有路径显示截断后的路径,点击单元格可复制完整路径 |
| 启用 | is_active | Switch |
| 操作 | — | 编辑 / 软删除 / 拖拽排序 |
新增 / 编辑表单
| 字段 | 控件 | 必填 | 校验 |
|---|---|---|---|
| 所属分类 | select | ✅ | 下拉显示所有未删除分类 |
| 名称 | input | ✅ | 长度 1~64 |
| 图片 | OSS 上传组件 | ✅ | jpg/png/webp;建议 ≤ 1MB;建议正方形 |
| 页面路径 (route_path) | input | ❌ | 必须以 / 开头;留空表示"未配置路由",点击弹 toast 不跳转;maxlength=255;占位文字如 /pages/castlove/create |
| 路由参数 (route_params) | JSON 编辑器 | ❌ | 必须是 JSON 对象({"key":"value"})或留空;运行时拼成 query string;建议提供"格式化校验"按钮 |
| 排序 | number | ✅ | 默认 = 该分类下当前最大 sort_order + 10 |
| 启用 | switch | ✅ | 默认 true |
路由参数 JSON 编辑器行为:
| 校验时机 | 行为 |
|---|---|
| 输入时(实时) | JSON.parse 失败:编辑器红框 + 行号定位 + 红色错误条"第 N 行: Unexpected token";保存按钮 disabled |
| 格式化时 | 点"格式化"按钮:合法 → pretty print 折叠为多行;非法 → 保留原文 + 弹出错误位置 |
| 键名校验 | 命中 __proto__ / constructor / prototype → 红色提示"禁止使用保留键名" |
| 值类型校验 | 值为对象 / 数组 → 该 key 行变红 + 悬浮 tooltip"value 仅支持 string / number / boolean" |
| 整体类型校验 | 顶层是数组 / 字符串 / 数字 / null → 整框变红"必须是 JSON object" |
| 保存时 | 客户端校验通过后再次请求后端,DB JSONB 类型已挡掉非法 JSON 写入;接口 4xx 时把后端错误回显到错误条 |
| 留空 | 等价于 NULL(无参数),保存按钮可点击 |
Valid / Invalid 示例(写到 UI 帮助文案里):
// ✅ Valid
{ "material": "star" }
{ "material": "star", "from": "config", "tab": 2, "vip": true }
// ❌ Invalid: 顶层是数组
[1, 2, 3]
// ❌ Invalid: 顶层是字符串
"hello"
// ❌ Invalid: value 是嵌套对象
{ "filter": { "type": "star" } }
// ❌ Invalid: value 是数组
{ "tags": ["a", "b"] }
// ❌ Invalid: 保留键名
{ "__proto__": { "x": 1 } }
实现建议(对接团队参考):
- 不建议用裸
<textarea>:失去语法高亮和错误位置定位 - 推荐 Vue 生态的
vue-json-editor或vue-codemirror+ JSON 模式 - 如果对接团队用的是 React 后台,可选
react-json-view - 编辑器要做"输入即校验",保存按钮在 JSON 非法时 disabled
- 选型约束:编辑器不能引入 100KB+ 的 monaco(unpkg 体积太大),轻量化方案优先
- 校验实现:实时 onChange 调
JSON.parse,对每个 key 递归检查 value 是否为标量,命中保留键名走Object.prototype.hasOwnProperty.call判断
"未配置路由"卡片在列表中的视觉提示:
route_path留空的卡片,缩略图右上角加一个灰色"开发中"角标(沿用现有daikaifa.png的视觉约定)- 该角标不是独立字段,是前端根据
route_path IS NULL渲染的
图片上传组件行为:
- 用户选择文件 → 调后台 OSS 直传接口 → 拿到
https://oss-bucket.../castlove/{uuid}.{ext} - OSS 路径前缀约定:
castlove/(与assets//avatars/等其他业务并列),具体 bucket 名沿用 assetService 现有配置 - 不存路径,只存完整 URL(约定 4)
- 编辑时把现有
image_url当回显,允许"替换"或"清空再传" - 旧图不主动删 OSS(避免别处引用断链;OSS 生命周期策略另说)
5.4 操作确认弹窗
| 操作 | 是否需要二次确认 |
|---|---|
| 新增 | 否 |
| 编辑 | 否 |
| 切换 is_active | 否(一键开关) |
| 软删除 | ✅ "确认删除该分类/卡片?前端将立即不可见" |
| 修改 type_key | ✅ "修改后,使用该 type_key 的外部入口将定位失败" |
| 修改 route_path(从有→空,或换到不存在的页面) | ✅ "该卡片点击后将不再跳转,请确认目标页面已在 pages.json 中注册" |
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 追加
// === 铸爱工艺配置 ===
/** 获取铸爱页分类与工艺卡片配置(强制鉴权,未登录 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 行 + 其他孤儿引用):
categoryListcardListMapcardListcategoryTypeMap(改用在线findIndex(c => c.type_key === type))- 方法
handleSkip()(原文 227-242 行,模板未绑定的孤儿方法,且内部用this.cardList会报未定义) cardRoutes(路由已配置化到后端castlove_crafts.route_path,前端不再维护这张映射表)
保留(仍是计算属性,与卡片数量计算相关,不依赖被删的硬码数据):
- computed
currentCategoryName/currentCardList/currentCardCount/currentCenterIndex/defaultSelectedIndex - 所有动效相关 state 与方法(
getStackPositions/getCardStyle/getCardStackPosition/ 触摸事件等)
新增 data:
data() {
return {
showMenu: true,
selectedCategoryIndex: 0,
// ↓ 全部来自后端,含 route_path / route_params
categories: [], // [{ id, name, type_key, crafts: [{ id, name, image_url, route_path, route_params, ... }] }]
loading: true,
loadError: '',
// 原有交互/动效状态保留
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"- "开发中"角标改用
v-if="!card.route_path"判断(NULL 视为"未配置")
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=[])会让整个卡片区消失,违反"不要白屏"。新模板必须显式给出 5 个分支:
<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 点击逻辑
改为读 card.route_path / card.route_params,不再依赖前端 cardRoutes 常量:
onCardFrameTap(card) {
// route_path 为空/null → 走 toast,不跳转
if (!card.route_path) {
uni.showToast({ title: '激情开发中', icon: 'none' })
return
}
// 拼 query string
const query = this.buildQueryString(card.route_params)
const url = query ? `${card.route_path}?${query}` : card.route_path
uni.navigateTo({
url,
fail: (err) => {
// 路径写错 / pages.json 未注册 → 兜底 toast,不闪退
console.error('[castlove] navigateTo fail', card.route_path, err)
uni.showToast({ title: '页面不存在', icon: 'none' })
},
})
},
buildQueryString(params) {
if (!params || typeof params !== 'object') return ''
return Object.keys(params)
.filter(k => params[k] !== undefined && params[k] !== null)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`)
.join('&')
},
关键点:
- 不再用
card.name匹配,新增卡片/分类/图片/名称无需前端发版(这就是路由配置化的核心收益) - 但新增跳转的目标页面仍需前端先发版:运营填的
route_path对应的页面必须已经在pages.json中注册并随前端版本上线,否则端上点击会走 §7.2 "页面不存在" 兜底(流程见 §4 约定 6.2) route_params为 NULL / 空对象时直接跳,不拼?uni.navigateTofail 兜底:路径在 pages.json 中不存在时,弹"页面不存在" toast,不闪退(防御后端脏数据穿透 service 层)
七、错误处理 / 边界
7.1 后端
| 情况 | 处理 |
|---|---|
| 两表空 | 返回 { categories: [] },HTTP 200 |
crafts.image_url 空字符串 |
DB NOT NULL 已挡;前端 <image> @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 |
card.route_path 为空 / null / 格式异常 |
"激情开发中" toast(service 层已把异常清空成 null) |
uni.navigateTo 失败(页面未注册 / 路径写错) |
"页面不存在" toast,不闪退(fail 回调兜底) |
route_params 字段类型异常(非对象) |
跳过参数拼接,仅用 route_path 跳转(service 层已清空) |
?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_RoutePathInvalid |
route_path 不以 / 开头 → 响应中该字段被清空成 null(log warn,不抛错) |
TestGetCastloveConfig_RoutePathContainsQueryOrHash |
route_path="/x?a=1" 或 "/x#frag" → 响应中该字段被清空成 null(防止前端拼接出 /x?a=1?b=2) |
TestGetCastloveConfig_RouteParamsNotObject |
route_params 是数组/字符串/数字 → 响应中该字段被清空成 null |
TestGetCastloveConfig_RouteParamsNestedValues |
route_params={"a":{"b":1}}(值为嵌套对象)→ 响应中该字段被清空成 null |
TestGetCastloveConfig_RoutePathNullSkipsParams |
route_path=null + route_params={"k":"v"} → 响应 params 仍返回但前端忽略 |
TestGetCastloveConfig_RoutePathWithParams |
route_path="/x" + route_params={"a":"1","b":"2"} → 响应里两个字段都正确返回 |
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 模式:
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 个新分类 "周边" + 2 张卡片,5 秒内刷新页面 → 右侧菜单出现 "周边",切换后展示 2 张卡片
- 后台把 "星卡" 的
sort_order调到最大 → 5 秒内右侧菜单中 "星卡" 移到底部 - 后台软删 "拍立得" → 5 秒内星卡分类下少了 "拍立得" 一张
- 后台改 "光栅卡" 的
image_url→ 5 秒内卡片图片更新 - deep-link
?type=star_card→ 即使 "星卡" 已经被后台调到第 3 位,仍正确定位 - 删除某分类(无 type_key 的)→ 不影响其他分类显示
- 把全部分类软删 → 整页显示 "暂无内容"
- 模拟 401 → 跳登录页
- 模拟 OSS 图片 404 → 卡片显示占位图,不闪退
- 路由配置化验证:
- 后台给一张新卡片填
route_path="/pages/castlove/create"+route_params={"material":"star"}→ 端上点击后跳转到/pages/castlove/create?material=star,目标页能正确读出 query 参数 - 后台把上面卡片的
route_path改成/pages/castlove/xxx(pages.json 里不存在的页面)→ 端上点击弹"页面不存在" toast,不闪退 - 后台把上面卡片的
route_path清空 → 端上点击弹"激情开发中" toast,不跳转;缩略图右上角出现"开发中"角标 - 后台把
route_params填成[1,2,3](数组,非对象)→ service 层清空成 null,响应里 params 是 null;端上点击仍能跳(只缺参数) - 后台给"吧唧-超复古"(原本 route_path=NULL)补上
route_path="/pages/castlove/create"→ 5 秒内"激情开发中" 角标消失,点击可跳转
- 后台给一张新卡片填
- 新增工艺不发版验证:复制现有 "星卡" 分类,新建一个 "3D 卡片" 分类 + 一张 "全息卡" 卡片(仅在后台配齐,不发版前端)→ 端上 5 秒内可看到新分类、新卡片,并可点击跳转(前提是
route_path对应页面已经在之前的版本里注册过)
8.3 不写单测的部分
- 前端 Vue 组件交互(动效 / touch swipe):已有,uni-app 单测基建薄;用手测覆盖
- 后台管理 UI:对接团队的项目
九、上线 / 回滚
9.1 上线步骤
- 执行 §2.1 / §2.2 / §2.3 的 DDL + 初始数据 SQL
- 上传现有
/static/castlove/*.png到 OSS,拿到 URL,更新castlove_crafts.image_url - 部署后端(gateway)—— 此时端上还在用老代码,不影响
- 后台同学按 §5 规格开发 UI(可与步骤 3 并行)
- 部署前端(craft-select.vue 改造)
- 验收:跑一遍 §8.2 手测脚本
9.2 回滚
- 回滚前端版本即可(老前端不依赖新接口)
- 后端接口可保留(无端上调用就是死代码,不影响)
- DB 表保留(不影响其他业务)
十、关联文件
| 文件 | 关系 |
|---|---|
frontend/pages/castlove/craft-select.vue |
主要改造对象(移除硬码 cardRoutes + 模板分支 + 越界保护 + 删孤儿方法 handleSkip + 点击改用 card.route_path/route_params) |
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 序列同步规则 |