feat:增加env配置

This commit is contained in:
zerosaturation 2026-06-05 12:26:05 +08:00
parent 6e470a2b56
commit aba8ec6ba7
7 changed files with 1738 additions and 115 deletions

View File

@ -2,9 +2,9 @@
- 日期2026-06-04 - 日期2026-06-04
- 涉及模块:`frontend/pages/castlove/craft-select.vue`、`backend/gateway`、PostgreSQL、后台管理外部项目 - 涉及模块:`frontend/pages/castlove/craft-select.vue`、`backend/gateway`、PostgreSQL、后台管理外部项目
- 现状:`frontend/pages/castlove/craft-select.vue` 第 263~370 行硬编码了 `categoryList / cardListMap / cardList / cardRoutes`,运营同学改图片或名称需要改代码、重新发版 - 现状:`frontend/pages/castlove/craft-select.vue` 第 263~370 行硬编码了 `categoryList / cardListMap / cardList / cardRoutes`,运营同学改图片、名称、跳转路由都需要改代码、重新发版
- 目标:把"分类"和"工艺卡片"做成可配置,运营可通过现有后台管理增删改;端上 5 秒内生效;跳转路由仍由前端维护 - 目标:把"分类"和"工艺卡片"做成可配置,运营可通过现有后台管理增删改;端上 5 秒内生效;**跳转路由也配置化**(后台维护 `route_path` + `route_params`
- 不在本次范围:跳转路由不可配(前端代码中 `cardRoutes` 写死),后台管理 UI 由对接团队实现,本文档仅给出规格约定 - 不在本次范围:后台管理 UI 由对接团队实现,本文档仅给出规格约定
--- ---
@ -29,14 +29,15 @@
+-------------------+ +-------------------+
| uni-app 前端 | | uni-app 前端 |
| craft-select.vue | | craft-select.vue |
| cardRoutes 写死 | | route_path 来自 |
| DB 配置 |
+-------------------+ +-------------------+
``` ```
- 数据 source of truthPostgreSQL 两张表 - 数据 source of truthPostgreSQL 两张表
- 后台直连同库(共用同一 PostgreSQL不走 Go 业务层 - 后台直连同库(共用同一 PostgreSQL不走 Go 业务层
- 前端只读,强制鉴权,进程内 5 秒缓存兜底高 QPS - 前端只读,强制鉴权,进程内 5 秒缓存兜底高 QPS
- 跳转路由 `cardRoutes` 留在前端代码中,按 `card.name` 匹配 - 跳转路由从 DB 读取(`castlove_crafts.route_path` + `route_params`),不再由前端 `cardRoutes` 硬编码
--- ---
@ -85,7 +86,8 @@ CREATE TABLE castlove_crafts (
category_id BIGINT NOT NULL REFERENCES castlove_categories(id), category_id BIGINT NOT NULL REFERENCES castlove_categories(id),
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
image_url TEXT NOT NULL, image_url TEXT NOT NULL,
coming_soon BOOLEAN NOT NULL DEFAULT FALSE, route_path VARCHAR(255), -- 跳转页面路径,可空(NULL=点开展示"激情开发中" toast)
route_params JSONB, -- query 参数 JSON 对象,可空
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -106,7 +108,8 @@ ALTER SEQUENCE castlove_crafts_id_seq RESTART WITH 10000;
| `category_id` | 外键到 `castlove_categories.id` | | `category_id` | 外键到 `castlove_categories.id` |
| `name` | 卡片名("光栅卡" / "镭射卡" / "开发中" / ...),不同分类下可重名 | | `name` | 卡片名("光栅卡" / "镭射卡" / "开发中" / ...),不同分类下可重名 |
| `image_url` | **完整 https URL**OSS 域名开头),不允许 `/static/...` 相对路径 | | `image_url` | **完整 https URL**OSS 域名开头),不允许 `/static/...` 相对路径 |
| `coming_soon` | true 时点击中央卡片弹"敬请期待",不跳转 | | `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` | 同一分类内部的相对顺序 | | `sort_order` | 同一分类内部的相对顺序 |
| `is_active` | 软下线开关 | | `is_active` | 软下线开关 |
| `deleted_at` | 软删除时间戳 | | `deleted_at` | 软删除时间戳 |
@ -121,24 +124,24 @@ INSERT INTO castlove_categories (id, name, type_key, sort_order) VALUES
(10003, '海报', 'poster', 2); (10003, '海报', 'poster', 2);
SELECT setval('castlove_categories_id_seq', (SELECT MAX(id) FROM castlove_categories)); SELECT setval('castlove_categories_id_seq', (SELECT MAX(id) FROM castlove_categories));
-- 卡片(按现有 craft-select.vue 第 263-370 行硬编码内容回填) -- 卡片(按现有 craft-select.vue 第 263-370 行硬编码内容回填route_path 来自原 cardRoutes 常量映射
INSERT INTO castlove_crafts (id, category_id, name, image_url, coming_soon, sort_order) VALUES 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', false, 0), (10001, 10001, '镭射卡', 'https://<oss>/castlove/leisheka.png', '/pages/castlove/create', NULL, 0),
(10002, 10001, '光栅卡', 'https://<oss>/castlove/guangshanka.png', false, 1), (10002, 10001, '光栅卡', 'https://<oss>/castlove/guangshanka.png', '/pages/castlove/lenticular/lenticular-create', NULL, 1),
(10003, 10001, '拍立得', 'https://<oss>/castlove/pailide.png', false, 2), (10003, 10001, '拍立得', 'https://<oss>/castlove/pailide.png', '/pages/castlove/create', NULL, 2),
(10004, 10001, '开发中', 'https://<oss>/castlove/daikaifa.png', true, 3), (10004, 10001, '开发中', 'https://<oss>/castlove/daikaifa.png', NULL, NULL, 3),
(10005, 10001, '撕拉片', 'https://<oss>/castlove/silapian.png', false, 4), (10005, 10001, '撕拉片', 'https://<oss>/castlove/silapian.png', '/pages/castlove/create', NULL, 4),
-- 吧唧 -- 吧唧(原 cardRoutes 未映射,route_path=NULL,点击走 toast)
(10006, 10002, '超复古', 'https://<oss>/castlove/fugu.png', false, 0), (10006, 10002, '超复古', 'https://<oss>/castlove/fugu.png', NULL, NULL, 0),
(10007, 10002, '卡通刺绣', 'https://<oss>/castlove/katongchixiu.png', false, 1), (10007, 10002, '卡通刺绣', 'https://<oss>/castlove/katongchixiu.png', NULL, NULL, 1),
(10008, 10002, '云母片', 'https://<oss>/castlove/yunmupian.png', false, 2), (10008, 10002, '云母片', 'https://<oss>/castlove/yunmupian.png', NULL, NULL, 2),
(10009, 10002, '开发中', 'https://<oss>/castlove/daikaifa.png', true, 3), (10009, 10002, '开发中', 'https://<oss>/castlove/daikaifa.png', NULL, NULL, 3),
-- 海报 -- 海报(同上,route_path=NULL)
(10010, 10003, '拼豆', 'https://<oss>/castlove/pindou.png', false, 0), (10010, 10003, '拼豆', 'https://<oss>/castlove/pindou.png', NULL, NULL, 0),
(10011, 10003, '极繁插画', 'https://<oss>/castlove/jinfanchahua.png', false, 1), (10011, 10003, '极繁插画', 'https://<oss>/castlove/jinfanchahua.png', NULL, NULL, 1),
(10012, 10003, '街头拼贴', 'https://<oss>/castlove/jietoupintie.png', false, 2), (10012, 10003, '街头拼贴', 'https://<oss>/castlove/jietoupintie.png', NULL, NULL, 2),
(10013, 10003, '开发中', 'https://<oss>/castlove/daikaifa.png', true, 3); (10013, 10003, '开发中', 'https://<oss>/castlove/daikaifa.png', NULL, NULL, 3);
SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts)); SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
``` ```
@ -171,11 +174,11 @@ SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
"type_key": "star_card", "type_key": "star_card",
"sort_order": 0, "sort_order": 0,
"crafts": [ "crafts": [
{ "id": 10001, "name": "镭射卡", "image_url": "https://oss/.../leisheka.png", "coming_soon": false, "sort_order": 0 }, { "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", "coming_soon": false, "sort_order": 1 }, { "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", "coming_soon": false, "sort_order": 2 }, { "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", "coming_soon": true, "sort_order": 3 }, { "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", "coming_soon": false, "sort_order": 4 } { "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": 10002, "name": "吧唧", "type_key": "badge", "sort_order": 1, "crafts": [/* ... */] },
@ -196,9 +199,9 @@ SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
| 文件 | 职责 | | 文件 | 职责 |
|---|---| |---|---|
| `backend/gateway/controller/castlove_config.go` | HTTP 控制器:鉴权 → 调 service → 返回 | | `backend/gateway/controller/castlove_config.go` | HTTP 控制器:鉴权 → 调 service → 返回 |
| `backend/gateway/service/castlove_config.go` | 业务逻辑:查 DB + 组装 + 缓存 | | `backend/gateway/service/castlove_config.go` | 业务逻辑:查 DB + 组装 + 5s 缓存 + singleflight + `sanitizeCraft` 兜底校验 |
| `backend/gateway/repository/castlove_config.go` | GORM 读两张表 | | `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 | | `backend/gateway/dto/castlove_config.go` | 响应 DTOcraft 字段含 `id / name / image_url / route_path / route_params / sort_order` |
| `backend/gateway/router/router.go` | 注册路由 `GET /api/v1/castlove/config` | | `backend/gateway/router/router.go` | 注册路由 `GET /api/v1/castlove/config` |
### 3.4 缓存策略 ### 3.4 缓存策略
@ -219,10 +222,13 @@ SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
- 跳过 `image_url` 不以 `http://``https://` 开头的卡片log warn不抛错 - 跳过 `image_url` 不以 `http://``https://` 开头的卡片log warn不抛错
- 跳过 `name` 为空字符串的记录 - 跳过 `name` 为空字符串的记录
- 跳过 `category_id``castlove_categories` 表中找不到的孤儿卡片log error - 跳过 `category_id``castlove_categories` 表中找不到的孤儿卡片log error
- `route_path` 校验:必须是 `/` 开头的绝对路径uni-app 页面路径约定)且不含 `?` / `#`;否则清空为 NULLlog warn**不抛错**——把"坏路径"降级为"未配置",跟 NULL 行为一致:点击弹 toast不跳转
- `route_params` 校验:必须是 JSON 对象(`{}`)或 NULL非对象类型数组 / 字符串 / 数字 / null 字面量)一律清空为 NULLlog warn值为嵌套对象 / 数组时同样清空(约定 5.2 限制 value 只能是标量)
- `route_path` 为 NULL 时 `route_params` 无需处理:前端 `onCardFrameTap` 已经在 `!card.route_path` 时早 returnparams 不会被读取
这些只是"过滤掉,让前端不显示坏数据"不是真正的鉴权或修复DB 层 NOT NULL 已经挡了大多数service 层做兜底是为了避免端上崩。 这些只是"过滤掉,让前端不显示坏数据"不是真正的鉴权或修复DB 层 NOT NULL 已经挡了大多数service 层做兜底是为了避免端上崩。
### 3.6 控制器伪代码 ### 3.6 控制器 / Service 伪代码
```go ```go
// backend/gateway/controller/castlove_config.go // backend/gateway/controller/castlove_config.go
@ -237,8 +243,8 @@ func GetCastloveConfig(c *gin.Context) {
// backend/gateway/service/castlove_config.go // backend/gateway/service/castlove_config.go
var ( var (
cache atomic.Pointer[cachedConfig] cache atomic.Pointer[cachedConfig]
sfGroup singleflight.Group sfGroup singleflight.Group
) )
type cachedConfig struct { type cachedConfig struct {
@ -260,8 +266,51 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
cache.Store(&cachedConfig{data: resp, expiresAt: time.Now().Add(5 * time.Second)}) cache.Store(&cachedConfig{data: resp, expiresAt: time.Now().Add(5 * time.Second)})
return resp, nil 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 表里写的方案,保持仓库一致性。
--- ---
## 四、后台直连写库约定 ## 四、后台直连写库约定
@ -295,6 +344,24 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
- 越小越靠前;允许重复(重复时按 id 兜底排) - 越小越靠前;允许重复(重复时按 id 兜底排)
- 后台 UI 建议提供"拖拽排序"或"上移/下移"按钮 - 后台 UI 建议提供"拖拽排序"或"上移/下移"按钮
### 约定 5.1route_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.2route_paramsJSON 参数)
- 必须是 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.3route 字段的最大长度
- `route_path` VARCHAR(255)超长会写入失败DB 报错)
- 后台 UI 建议给"页面路径"控件加 maxlength=255 限制
### 约定 6禁止删除规则 ### 约定 6禁止删除规则
- **带 `type_key` 的分类不允许软删**(前端 deep-link 会失效) - **带 `type_key` 的分类不允许软删**(前端 deep-link 会失效)
- 后台 UI 上对这类分类禁用删除按钮 - 后台 UI 上对这类分类禁用删除按钮
@ -311,6 +378,17 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
**反过来的顺序会让运营以为"我设了 type_key 但前端入口不工作"**。建议把这个流程写到后台 UI 的字段帮助文案里:"新增 type_key 需先联系前端发版"。 **反过来的顺序会让运营以为"我设了 type_key 但前端入口不工作"**。建议把这个流程写到后台 UI 的字段帮助文案里:"新增 type_key 需先联系前端发版"。
### 约定 6.2:新增 route_path 的 SOP前后端协调流程
`route_path` 是 uni-app 页面路径,**目标页面必须已经在前端项目里存在并注册**。后台**不能**自己填一个新路径直接生效——必须按序操作:
1. **前端发版** 先在 `frontend/pages.json` 里注册新页面uni-app 路由配置)
2. **前端发版** 实现对应页面组件并测试可独立跳转
3. **后台运营** 给某张卡片绑定新 `route_path`(可选填 `route_params`
4. **验证** 端上点击该卡片能正确跳到新页面,参数拼接正确
**反过来的顺序会让运营以为"我填了路径但点击不工作"**。建议把这个流程写到后台 UI 的"页面路径"字段帮助文案里:"新增路径需先联系前端发版注册页面"。
### 约定 7缓存生效时间 ### 约定 7缓存生效时间
- 后端 API 有 5 秒进程内缓存 - 后端 API 有 5 秒进程内缓存
- 后台改完数据,最长 5 秒前端会看到新值 - 后台改完数据,最长 5 秒前端会看到新值
@ -353,7 +431,7 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
| 排序 | number | ✅ | 默认 = 当前最大 sort_order + 10 | | 排序 | number | ✅ | 默认 = 当前最大 sort_order + 10 |
| 启用 | switch | ✅ | 默认 true | | 启用 | switch | ✅ | 默认 true |
**Type Key 下拉项硬编码**为 `[空, star_card, badge, poster]`。新增 deep-link key 时需要后端 + 前端同步改代码(这是"前端自维护路由"的延伸约束)。 **Type Key 下拉项硬编码**为 `[空, star_card, badge, poster]`。新增 deep-link key 时需要后端 + 前端同步改代码(type_key 是前端外部入口的契约,无法配置化)。
**删除按钮的禁用条件**`type_key IS NOT NULL` → 禁用hover 提示"该分类被外部入口引用,删除前请先清空 Type Key"。 **删除按钮的禁用条件**`type_key IS NOT NULL` → 禁用hover 提示"该分类被外部入口引用,删除前请先清空 Type Key"。
@ -367,7 +445,7 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
| 缩略图 | image_url | 80×80 缩略图,点击放大预览 | | 缩略图 | image_url | 80×80 缩略图,点击放大预览 |
| 名称 | name | | | 名称 | name | |
| 所属分类 | category_id → name | | | 所属分类 | category_id → name | |
| 开发中 | coming_soon | 角标显示 | | 路由 | route_path | 留空显示"—""激情开发中"占位);有路径显示截断后的路径,点击单元格可复制完整路径 |
| 启用 | is_active | Switch | | 启用 | is_active | Switch |
| 操作 | — | 编辑 / 软删除 / 拖拽排序 | | 操作 | — | 编辑 / 软删除 / 拖拽排序 |
@ -378,10 +456,60 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
| 所属分类 | select | ✅ | 下拉显示所有未删除分类 | | 所属分类 | select | ✅ | 下拉显示所有未删除分类 |
| 名称 | input | ✅ | 长度 1~64 | | 名称 | input | ✅ | 长度 1~64 |
| 图片 | **OSS 上传组件** | ✅ | jpg/png/webp建议 ≤ 1MB建议正方形 | | 图片 | **OSS 上传组件** | ✅ | jpg/png/webp建议 ≤ 1MB建议正方形 |
| 开发中 | switch | ✅ | 默认 false为 true 时图片可选用 daikaifa 占位 | | 页面路径 (route_path) | input | ❌ | 必须以 `/` 开头;留空表示"未配置路由",点击弹 toast 不跳转maxlength=255占位文字如 `/pages/castlove/create` |
| 路由参数 (route_params) | **JSON 编辑器** | ❌ | 必须是 JSON 对象(`{"key":"value"}`)或留空;运行时拼成 query string建议提供"格式化校验"按钮 |
| 排序 | number | ✅ | 默认 = 该分类下当前最大 sort_order + 10 | | 排序 | number | ✅ | 默认 = 该分类下当前最大 sort_order + 10 |
| 启用 | switch | ✅ | 默认 true | | 启用 | 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 帮助文案里):
```json
// ✅ 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`](https://www.npmjs.com/package/vue-json-editor) 或 [`vue-codemirror`](https://www.npmjs.com/package/vue-codemirror) + JSON 模式
- 如果对接团队用的是 React 后台,可选 [`react-json-view`](https://www.npmjs.com/package/react-json-view)
- 编辑器要做"输入即校验",保存按钮在 JSON 非法时 disabled
- 选型约束:编辑器不能引入 100KB+ 的 monacounpkg 体积太大),轻量化方案优先
- 校验实现:实时 onChange 调 `JSON.parse`,对每个 key 递归检查 value 是否为标量,命中保留键名走 `Object.prototype.hasOwnProperty.call` 判断
**"未配置路由"卡片在列表中的视觉提示**
- `route_path` 留空的卡片,缩略图右上角加一个灰色"开发中"角标(沿用现有 `daikaifa.png` 的视觉约定)
- 该角标不是独立字段,是前端根据 `route_path IS NULL` 渲染的
**图片上传组件行为** **图片上传组件行为**
1. 用户选择文件 → 调后台 OSS 直传接口 → 拿到 `https://oss-bucket.../castlove/{uuid}.{ext}` 1. 用户选择文件 → 调后台 OSS 直传接口 → 拿到 `https://oss-bucket.../castlove/{uuid}.{ext}`
@ -399,6 +527,7 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
| 切换 is_active | 否(一键开关) | | 切换 is_active | 否(一键开关) |
| 软删除 | ✅ "确认删除该分类/卡片?前端将立即不可见" | | 软删除 | ✅ "确认删除该分类/卡片?前端将立即不可见" |
| 修改 type_key | ✅ "修改后,使用该 type_key 的外部入口将定位失败" | | 修改 type_key | ✅ "修改后,使用该 type_key 的外部入口将定位失败" |
| 修改 route_path从有→空或换到不存在的页面 | ✅ "该卡片点击后将不再跳转,请确认目标页面已在 pages.json 中注册" |
### 5.5 性能 / 缓存提示 ### 5.5 性能 / 缓存提示
@ -457,11 +586,11 @@ export function getCastloveConfigApi() {
- `cardList` - `cardList`
- `categoryTypeMap`(改用在线 `findIndex(c => c.type_key === type)` - `categoryTypeMap`(改用在线 `findIndex(c => c.type_key === type)`
- 方法 `handleSkip()`(原文 227-242 行,模板未绑定的孤儿方法,且内部用 `this.cardList` 会报未定义) - 方法 `handleSkip()`(原文 227-242 行,模板未绑定的孤儿方法,且内部用 `this.cardList` 会报未定义)
- `cardRoutes`**路由已配置化**到后端 `castlove_crafts.route_path`,前端不再维护这张映射表)
**保留**(仍是计算属性,与卡片数量计算相关,不依赖被删的硬码数据): **保留**(仍是计算属性,与卡片数量计算相关,不依赖被删的硬码数据):
- computed `currentCategoryName` / `currentCardList` / `currentCardCount` / `currentCenterIndex` / `defaultSelectedIndex` - computed `currentCategoryName` / `currentCardList` / `currentCardCount` / `currentCenterIndex` / `defaultSelectedIndex`
- 所有动效相关 state 与方法(`getStackPositions` / `getCardStyle` / `getCardStackPosition` / 触摸事件等) - 所有动效相关 state 与方法(`getStackPositions` / `getCardStyle` / `getCardStackPosition` / 触摸事件等)
- `cardRoutes`(前端自维护)
**新增 data** **新增 data**
@ -471,19 +600,11 @@ data() {
showMenu: true, showMenu: true,
selectedCategoryIndex: 0, selectedCategoryIndex: 0,
// ↓ 全部来自后端 // ↓ 全部来自后端,含 route_path / route_params
categories: [], // [{ id, name, type_key, crafts: [...] }] categories: [], // [{ id, name, type_key, crafts: [{ id, name, image_url, route_path, route_params, ... }] }]
loading: true, loading: true,
loadError: '', loadError: '',
// ↓ 仍写死在前端,按 craft.name 查
cardRoutes: {
'光栅卡': '/pages/castlove/lenticular/lenticular-create',
'拍立得': '/pages/castlove/create',
'镭射卡': '/pages/castlove/create',
'撕拉片': '/pages/castlove/create',
},
// 原有交互/动效状态保留 // 原有交互/动效状态保留
selectedIndex: 1, selectedIndex: 1,
touchStartY: 0, touchStartY: 0,
@ -561,7 +682,7 @@ selectCategory(index) {
- `v-for="(category, index) in categories"` 替代原来的 `categoryList` - `v-for="(category, index) in categories"` 替代原来的 `categoryList`
- `<image :src="card.image_url" />` 替代原来的 `:src="card.image"` - `<image :src="card.image_url" />` 替代原来的 `:src="card.image"`
- `card.coming_soon` 替代 `card.comingSoon` - "开发中"角标改用 `v-if="!card.route_path"` 判断NULL 视为"未配置"
### 6.7 右侧菜单布局重构 ### 6.7 右侧菜单布局重构
@ -574,7 +695,7 @@ selectCategory(index) {
### 6.8 模板分支loading / error / empty / 正常 ### 6.8 模板分支loading / error / empty / 正常
⚠️ 原模板 `v-if="currentCardList.length"` 在 loading 期间(`categories=[]`)会让整个卡片区消失,**违反"不要白屏"**。新模板必须显式给出 4 个分支: ⚠️ 原模板 `v-if="currentCardList.length"` 在 loading 期间(`categories=[]`)会让整个卡片区消失,**违反"不要白屏"**。新模板必须显式给出 5 个分支:
```html ```html
<view class="cards-container" ...> <view class="cards-container" ...>
@ -621,7 +742,44 @@ selectCategory(index) {
### 6.9 点击逻辑 ### 6.9 点击逻辑
保持不变:`onCardFrameTap` 仍按 `card.name``cardRoutes`,匹配不上 → "激情开发中" toast`coming_soon=true` 走同样 toast。 改为读 `card.route_path` / `card.route_params`,不再依赖前端 `cardRoutes` 常量:
```js
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.navigateTo` fail 兜底:路径在 pages.json 中不存在时,弹"页面不存在" toast不闪退防御后端脏数据穿透 service 层)
--- ---
@ -646,8 +804,9 @@ selectCategory(index) {
| 401 | 全局拦截器跳登录页 | | 401 | 全局拦截器跳登录页 |
| `categories.length === 0` | 整页显示"暂无内容" | | `categories.length === 0` | 整页显示"暂无内容" |
| 某分类 `crafts.length === 0` | 显示 empty-state | | 某分类 `crafts.length === 0` | 显示 empty-state |
| `cardRoutes[card.name]` 找不到 | "激情开发中" toast | | `card.route_path` 为空 / null / 格式异常 | "激情开发中" toastservice 层已把异常清空成 null |
| `card.coming_soon === true` | "敬请期待" toast | | `uni.navigateTo` 失败(页面未注册 / 路径写错) | "页面不存在" toast不闪退`fail` 回调兜底) |
| `route_params` 字段类型异常(非对象) | 跳过参数拼接,仅用 `route_path` 跳转service 层已清空) |
| `?type=star_card` 但已删该 type_key | `findIndex` 返回 -1回退 0 | | `?type=star_card` 但已删该 type_key | `findIndex` 返回 -1回退 0 |
| 图片 URL 加载失败 | `<image>` `@error` 兜底显示占位 | | 图片 URL 加载失败 | `<image>` `@error` 兜底显示占位 |
@ -672,6 +831,12 @@ selectCategory(index) {
| `TestGetCastloveConfig_Unauthorized` | 未带 token → 401 | | `TestGetCastloveConfig_Unauthorized` | 未带 token → 401 |
| `TestGetCastloveConfig_InvalidImageURL` | service 层过滤掉 `image_url` 不带 http(s) 的卡片 | | `TestGetCastloveConfig_InvalidImageURL` | service 层过滤掉 `image_url` 不带 http(s) 的卡片 |
| `TestGetCastloveConfig_OrphanCraft` | service 层过滤掉 `category_id` 找不到的卡片 | | `TestGetCastloveConfig_OrphanCraft` | service 层过滤掉 `category_id` 找不到的卡片 |
| `TestGetCastloveConfig_RoutePathInvalid` | `route_path` 不以 `/` 开头 → 响应中该字段被清空成 nulllog 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_CacheHit` | 第二次走缓存DB 调用次数=1 |
| `TestGetCastloveConfig_CacheExpire` | 5 秒后第三次重新打库(用可注入 clock 而不是真 sleep | | `TestGetCastloveConfig_CacheExpire` | 5 秒后第三次重新打库(用可注入 clock 而不是真 sleep |
| `TestGetCastloveConfig_Concurrent` | 10 并发 → DB 调用 1 次singleflight | | `TestGetCastloveConfig_Concurrent` | 10 并发 → DB 调用 1 次singleflight |
@ -717,6 +882,13 @@ func cleanupTestDB(t *testing.T, db *gorm.DB) {
7. 把全部分类软删 → 整页显示 "暂无内容" 7. 把全部分类软删 → 整页显示 "暂无内容"
8. 模拟 401 → 跳登录页 8. 模拟 401 → 跳登录页
9. 模拟 OSS 图片 404 → 卡片显示占位图,不闪退 9. 模拟 OSS 图片 404 → 卡片显示占位图,不闪退
10. **路由配置化验证**
- 后台给一张新卡片填 `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 秒内"激情开发中" 角标消失,点击可跳转
11. **新增工艺不发版验证**:复制现有 "星卡" 分类,新建一个 "3D 卡片" 分类 + 一张 "全息卡" 卡片(仅在后台配齐,**不发版前端**)→ 端上 5 秒内可看到新分类、新卡片,并可点击跳转(前提是 `route_path` 对应页面已经在之前的版本里注册过)
### 8.3 不写单测的部分 ### 8.3 不写单测的部分
@ -748,7 +920,7 @@ func cleanupTestDB(t *testing.T, db *gorm.DB) {
| 文件 | 关系 | | 文件 | 关系 |
|---|---| |---|---|
| `frontend/pages/castlove/craft-select.vue` | 主要改造对象(移除硬码 + 模板分支 + 越界保护 + 删孤儿方法 handleSkip | | `frontend/pages/castlove/craft-select.vue` | 主要改造对象(移除硬码 `cardRoutes` + 模板分支 + 越界保护 + 删孤儿方法 `handleSkip` + 点击改用 `card.route_path`/`route_params` |
| `frontend/pages/castlove/mall.vue` | 无需改动(仍传 `initialType="star_card"` | | `frontend/pages/castlove/mall.vue` | 无需改动(仍传 `initialType="star_card"` |
| `frontend/pages/square/square.vue` | 无需改动(`mainTabs.type` 仍是 3 个固定字符串) | | `frontend/pages/square/square.vue` | 无需改动(`mainTabs.type` 仍是 3 个固定字符串) |
| `frontend/pages/components/CastloveContent.vue` | 无需改动(同上) | | `frontend/pages/components/CastloveContent.vue` | 无需改动(同上) |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
# 开发环境配置
# HBuilderX「运行」时自动加载;CLI 用 --mode development
VITE_API_BASE_URL=http://192.168.110.60:8080
# WebSocket 地址:如与 API 同源可省略(自动从 VITE_API_BASE_URL 推导 http→ws、https→wss
# 独立部署时直接覆盖例如ws://192.168.110.60:8081
VITE_WS_BASE_URL=ws://192.168.110.60:8080
VITE_USE_MOCK_API=true
VITE_ENV_NAME=development

7
frontend/.env.production Normal file
View File

@ -0,0 +1,7 @@
# 生产环境配置
# HBuilderX「发行」时自动加载;CLI 用 --mode production
VITE_API_BASE_URL=http://api.topfans.online:8080
# WebSocket 地址:生产环境使用 wss与 HTTPS 对应),如 WS 部署在独立端口/域名可覆盖
VITE_WS_BASE_URL=ws://api.topfans.online:8080
VITE_USE_MOCK_API=false
VITE_ENV_NAME=production

4
frontend/.gitignore vendored
View File

@ -18,3 +18,7 @@ Thumbs.db
# 日志 # 日志
*.log *.log
# 本地环境变量覆盖(不入库)
.env.local
.env.*.local

View File

@ -21,11 +21,16 @@
* onMounted(start); onUnmounted(stop) * onMounted(start); onUnmounted(stop)
* // @scroll 事件也调一次 update(),作为双保险 * // @scroll 事件也调一次 update(),作为双保险
*/ */
import { getCurrentInstance } from 'vue'
const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现" const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现"
export function useSpotlight(options = {}) { export function useSpotlight(options = {}) {
const { getElements = () => [] } = options const { getElements = () => [] } = options
// app-plus / Vue3 组件内 createSelectorQuery 必须挂 in(public proxy),否则 selectAll 在 app 端返回空、opacity 从不生效
const pageProxy = getCurrentInstance()?.proxy || null
const seenSet = typeof WeakSet !== "undefined" ? new WeakSet() : new Set() const seenSet = typeof WeakSet !== "undefined" ? new WeakSet() : new Set()
const computeOpacity = (rect, vh) => { const computeOpacity = (rect, vh) => {
@ -105,7 +110,7 @@ export function useSpotlight(options = {}) {
const vh = getVH() const vh = getVH()
try { try {
const query = uni.createSelectorQuery() const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
query.selectAll("[data-spotlight-id]").boundingClientRect() query.selectAll("[data-spotlight-id]").boundingClientRect()
query.exec((res) => { query.exec((res) => {
if (myReq !== reqId) return // 旧请求,丢弃 if (myReq !== reqId) return // 旧请求,丢弃

View File

@ -1,74 +1,44 @@
// API 基础配置 // API 基础配置 — 通过 Vite 注入的环境变量区分开发/生产环境
// 自动检测后端环境:探测开发服务器是否可用,能连通则用开发地址,否则用生产地址 // .env.development → VITE_API_BASE_URL开发
// .env.production → VITE_API_BASE_URL生产
// HBuilderX「运行」= dev「发行」= prod自动加载对应 .env
// CLIvite --mode development|production
const DEV_BASE = 'http://192.168.110.60:8080' // 开发环境 const API_BASE_URL = String(
const PROD_BASE = 'http://101.132.250.62:8080' // 生产环境 import.meta.env.VITE_API_BASE_URL
const HEALTH_URL = DEV_BASE + '/health' ).replace(/\/+$/, '')
// 是否使用模拟数据(开发调试时设为 true后端API准备好后改为 false const USE_MOCK_API = String(import.meta.env.VITE_USE_MOCK_API || 'false').toLowerCase() === 'true'
const USE_MOCK_API = true
// 环境检测状态0=检测中, 1=开发环境, 2=生产环境 const baseURL = API_BASE_URL
let envStatus = 0
let baseURL = PROD_BASE
// 环境检测 Promise确保 getApiBaseUrl / getWebSocketBaseUrl 等待检测完成 /**
const envReadyPromise = new Promise((resolve) => { * WebSocket 基础地址
uni.request({ * 优先读取 VITE_WS_BASE_URL独立部署 WS 时使用未配置则按 API 地址自动推导 httpws / httpswss
url: HEALTH_URL, */
method: 'GET', const WS_BASE_URL = (() => {
timeout: 2000, const configured = import.meta.env.VITE_WS_BASE_URL
success: (res) => { if (configured && String(configured).trim()) {
if (res.statusCode === 200) { return String(configured).replace(/\/+$/, '')
baseURL = DEV_BASE }
envStatus = 1 return baseURL.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
console.log('[API] 使用开发环境地址:', DEV_BASE) })()
} else {
envStatus = 2
console.log('[API] 开发环境返回非200使用生产环境地址:', PROD_BASE)
}
resolve(envStatus)
},
fail: () => {
envStatus = 2
console.log('[API] 开发环境不可用,使用生产环境地址:', PROD_BASE)
resolve(envStatus)
}
})
})
/** 等待环境检测完成(返回 'dev' | 'prod' */ console.log('[API] env:', ENV_NAME, 'baseURL:', baseURL, 'ws:', WS_BASE_URL, 'mock:', USE_MOCK_API)
export async function waitForEnvReady() {
await envReadyPromise
return envStatus === 1 ? 'dev' : 'prod'
}
/** 网关根地址(供 uni.uploadFile 等无法走 request 封装的场景拼接完整 URL */ /** 获取 WebSocket 基础地址(环境变量 VITE_WS_BASE_URL未配置时由 API 地址自动推导) */
export async function getApiBaseUrl() {
await envReadyPromise
return String(baseURL).replace(/\/+$/, '')
}
/** 获取 WebSocket 基础地址(将 http:// 替换为 ws:// */
export async function getWebSocketBaseUrl() { export async function getWebSocketBaseUrl() {
await envReadyPromise return WS_BASE_URL
const httpUrl = String(baseURL).replace(/\/+$/, '')
return httpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
} }
// 兼容旧代码:同步版本在环境检测完成前返回默认值(生产地址) /** 抠图专用 Gateway业务走团队库segment 走本机;如需分离可再加 VITE_SEGMENT_BASE_URL */
export function getApiBaseUrlSync() {
return String(baseURL).replace(/\/+$/, '')
}
/** 抠图专用 Gateway可与 DEV_BASE 不同业务走团队库segment 走本机) */
export function getSegmentApiBaseUrl() { export function getSegmentApiBaseUrl() {
return String(baseURL).replace(/\/+$/, '') return baseURL
} }
/** 镭射专用 GatewayAI 生成 + compositor 走本地,其他业务走远程 */ /** 镭射专用 GatewayAI 生成 + compositor 走本地,其他业务走远程;如需分离可再加 VITE_LASER_BASE_URL */
export function getLaserApiBaseUrl() { export function getLaserApiBaseUrl() {
return String(baseURL).replace(/\/+$/, '') return baseURL
} }
// 模拟网络延迟 // 模拟网络延迟