feat:增加env配置
This commit is contained in:
parent
6e470a2b56
commit
aba8ec6ba7
@ -2,9 +2,9 @@
|
||||
|
||||
- 日期: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 由对接团队实现,本文档仅给出规格约定
|
||||
- 现状:`frontend/pages/castlove/craft-select.vue` 第 263~370 行硬编码了 `categoryList / cardListMap / cardList / cardRoutes`,运营同学改图片、名称、跳转路由都需要改代码、重新发版
|
||||
- 目标:把"分类"和"工艺卡片"做成可配置,运营可通过现有后台管理增删改;端上 5 秒内生效;**跳转路由也配置化**(后台维护 `route_path` + `route_params`)
|
||||
- 不在本次范围:后台管理 UI 由对接团队实现,本文档仅给出规格约定
|
||||
|
||||
---
|
||||
|
||||
@ -29,14 +29,15 @@
|
||||
+-------------------+
|
||||
| uni-app 前端 |
|
||||
| craft-select.vue |
|
||||
| cardRoutes 写死 |
|
||||
| route_path 来自 |
|
||||
| DB 配置 |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
- 数据 source of truth:PostgreSQL 两张表
|
||||
- 后台直连同库(共用同一 PostgreSQL),不走 Go 业务层
|
||||
- 前端只读,强制鉴权,进程内 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),
|
||||
name VARCHAR(64) 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,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
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` |
|
||||
| `name` | 卡片名("光栅卡" / "镭射卡" / "开发中" / ...),不同分类下可重名 |
|
||||
| `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` | 同一分类内部的相对顺序 |
|
||||
| `is_active` | 软下线开关 |
|
||||
| `deleted_at` | 软删除时间戳 |
|
||||
@ -121,24 +124,24 @@ INSERT INTO castlove_categories (id, name, type_key, sort_order) VALUES
|
||||
(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);
|
||||
-- 卡片(按现有 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));
|
||||
```
|
||||
|
||||
@ -171,11 +174,11 @@ SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
|
||||
"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": 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": [/* ... */] },
|
||||
@ -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/service/castlove_config.go` | 业务逻辑:查 DB + 组装 + 缓存 |
|
||||
| `backend/gateway/repository/castlove_config.go` | GORM 读两张表 |
|
||||
| `backend/gateway/dto/castlove_config.go` | 响应 DTO |
|
||||
| `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 缓存策略
|
||||
@ -219,10 +222,13 @@ SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts));
|
||||
- 跳过 `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 控制器伪代码
|
||||
### 3.6 控制器 / Service 伪代码
|
||||
|
||||
```go
|
||||
// backend/gateway/controller/castlove_config.go
|
||||
@ -237,8 +243,8 @@ func GetCastloveConfig(c *gin.Context) {
|
||||
|
||||
// backend/gateway/service/castlove_config.go
|
||||
var (
|
||||
cache atomic.Pointer[cachedConfig]
|
||||
sfGroup singleflight.Group
|
||||
cache atomic.Pointer[cachedConfig]
|
||||
sfGroup singleflight.Group
|
||||
)
|
||||
|
||||
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)})
|
||||
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 兜底排)
|
||||
- 后台 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_path` VARCHAR(255),超长会写入失败(DB 报错)
|
||||
- 后台 UI 建议给"页面路径"控件加 maxlength=255 限制
|
||||
|
||||
### 约定 6:禁止删除规则
|
||||
- **带 `type_key` 的分类不允许软删**(前端 deep-link 会失效)
|
||||
- 后台 UI 上对这类分类禁用删除按钮
|
||||
@ -311,6 +378,17 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
|
||||
|
||||
**反过来的顺序会让运营以为"我设了 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:缓存生效时间
|
||||
- 后端 API 有 5 秒进程内缓存
|
||||
- 后台改完数据,最长 5 秒前端会看到新值
|
||||
@ -353,7 +431,7 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
|
||||
| 排序 | number | ✅ | 默认 = 当前最大 sort_order + 10 |
|
||||
| 启用 | 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"。
|
||||
|
||||
@ -367,7 +445,7 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
|
||||
| 缩略图 | image_url | 80×80 缩略图,点击放大预览 |
|
||||
| 名称 | name | |
|
||||
| 所属分类 | category_id → name | |
|
||||
| 开发中 | coming_soon | 角标显示 |
|
||||
| 路由 | route_path | 留空显示"—"("激情开发中"占位);有路径显示截断后的路径,点击单元格可复制完整路径 |
|
||||
| 启用 | is_active | Switch |
|
||||
| 操作 | — | 编辑 / 软删除 / 拖拽排序 |
|
||||
|
||||
@ -378,10 +456,60 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
|
||||
| 所属分类 | select | ✅ | 下拉显示所有未删除分类 |
|
||||
| 名称 | input | ✅ | 长度 1~64 |
|
||||
| 图片 | **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 |
|
||||
| 启用 | 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+ 的 monaco(unpkg 体积太大),轻量化方案优先
|
||||
- 校验实现:实时 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}`
|
||||
@ -399,6 +527,7 @@ func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveCon
|
||||
| 切换 is_active | 否(一键开关) |
|
||||
| 软删除 | ✅ "确认删除该分类/卡片?前端将立即不可见" |
|
||||
| 修改 type_key | ✅ "修改后,使用该 type_key 的外部入口将定位失败" |
|
||||
| 修改 route_path(从有→空,或换到不存在的页面) | ✅ "该卡片点击后将不再跳转,请确认目标页面已在 pages.json 中注册" |
|
||||
|
||||
### 5.5 性能 / 缓存提示
|
||||
|
||||
@ -457,11 +586,11 @@ export function getCastloveConfigApi() {
|
||||
- `cardList`
|
||||
- `categoryTypeMap`(改用在线 `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` / 触摸事件等)
|
||||
- `cardRoutes`(前端自维护)
|
||||
|
||||
**新增 data**:
|
||||
|
||||
@ -471,19 +600,11 @@ data() {
|
||||
showMenu: true,
|
||||
selectedCategoryIndex: 0,
|
||||
|
||||
// ↓ 全部来自后端
|
||||
categories: [], // [{ id, name, type_key, crafts: [...] }]
|
||||
// ↓ 全部来自后端,含 route_path / route_params
|
||||
categories: [], // [{ id, name, type_key, crafts: [{ id, name, image_url, route_path, route_params, ... }] }]
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
// ↓ 仍写死在前端,按 craft.name 查
|
||||
cardRoutes: {
|
||||
'光栅卡': '/pages/castlove/lenticular/lenticular-create',
|
||||
'拍立得': '/pages/castlove/create',
|
||||
'镭射卡': '/pages/castlove/create',
|
||||
'撕拉片': '/pages/castlove/create',
|
||||
},
|
||||
|
||||
// 原有交互/动效状态保留
|
||||
selectedIndex: 1,
|
||||
touchStartY: 0,
|
||||
@ -561,7 +682,7 @@ selectCategory(index) {
|
||||
|
||||
- `v-for="(category, index) in categories"` 替代原来的 `categoryList`
|
||||
- `<image :src="card.image_url" />` 替代原来的 `:src="card.image"`
|
||||
- `card.coming_soon` 替代 `card.comingSoon`
|
||||
- "开发中"角标改用 `v-if="!card.route_path"` 判断(NULL 视为"未配置")
|
||||
|
||||
### 6.7 右侧菜单布局重构
|
||||
|
||||
@ -574,7 +695,7 @@ selectCategory(index) {
|
||||
|
||||
### 6.8 模板分支:loading / error / empty / 正常
|
||||
|
||||
⚠️ 原模板 `v-if="currentCardList.length"` 在 loading 期间(`categories=[]`)会让整个卡片区消失,**违反"不要白屏"**。新模板必须显式给出 4 个分支:
|
||||
⚠️ 原模板 `v-if="currentCardList.length"` 在 loading 期间(`categories=[]`)会让整个卡片区消失,**违反"不要白屏"**。新模板必须显式给出 5 个分支:
|
||||
|
||||
```html
|
||||
<view class="cards-container" ...>
|
||||
@ -621,7 +742,44 @@ selectCategory(index) {
|
||||
|
||||
### 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 | 全局拦截器跳登录页 |
|
||||
| `categories.length === 0` | 整页显示"暂无内容" |
|
||||
| 某分类 `crafts.length === 0` | 显示 empty-state |
|
||||
| `cardRoutes[card.name]` 找不到 | "激情开发中" toast |
|
||||
| `card.coming_soon === true` | "敬请期待" toast |
|
||||
| `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` 兜底显示占位 |
|
||||
|
||||
@ -672,6 +831,12 @@ selectCategory(index) {
|
||||
| `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) |
|
||||
@ -717,6 +882,13 @@ func cleanupTestDB(t *testing.T, db *gorm.DB) {
|
||||
7. 把全部分类软删 → 整页显示 "暂无内容"
|
||||
8. 模拟 401 → 跳登录页
|
||||
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 不写单测的部分
|
||||
|
||||
@ -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/square/square.vue` | 无需改动(`mainTabs.type` 仍是 3 个固定字符串) |
|
||||
| `frontend/pages/components/CastloveContent.vue` | 无需改动(同上) |
|
||||
|
||||
1457
docs/superpowers/specs/2026-06-04-statistic-service-design.md
Normal file
1457
docs/superpowers/specs/2026-06-04-statistic-service-design.md
Normal file
File diff suppressed because it is too large
Load Diff
8
frontend/.env.development
Normal file
8
frontend/.env.development
Normal 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
7
frontend/.env.production
Normal 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
4
frontend/.gitignore
vendored
@ -18,3 +18,7 @@ Thumbs.db
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
# 本地环境变量覆盖(不入库)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@ -21,11 +21,16 @@
|
||||
* onMounted(start); onUnmounted(stop)
|
||||
* // @scroll 事件也调一次 update(),作为双保险
|
||||
*/
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现"
|
||||
|
||||
export function useSpotlight(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 computeOpacity = (rect, vh) => {
|
||||
@ -105,7 +110,7 @@ export function useSpotlight(options = {}) {
|
||||
const vh = getVH()
|
||||
|
||||
try {
|
||||
const query = uni.createSelectorQuery()
|
||||
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
|
||||
query.selectAll("[data-spotlight-id]").boundingClientRect()
|
||||
query.exec((res) => {
|
||||
if (myReq !== reqId) return // 旧请求,丢弃
|
||||
|
||||
@ -1,74 +1,44 @@
|
||||
// API 基础配置
|
||||
// 自动检测后端环境:探测开发服务器是否可用,能连通则用开发地址,否则用生产地址
|
||||
// API 基础配置 — 通过 Vite 注入的环境变量区分开发/生产环境
|
||||
// .env.development → VITE_API_BASE_URL(开发)
|
||||
// .env.production → VITE_API_BASE_URL(生产)
|
||||
// HBuilderX:「运行」= dev,「发行」= prod,自动加载对应 .env
|
||||
// CLI:vite --mode development|production
|
||||
|
||||
const DEV_BASE = 'http://192.168.110.60:8080' // 开发环境
|
||||
const PROD_BASE = 'http://101.132.250.62:8080' // 生产环境
|
||||
const HEALTH_URL = DEV_BASE + '/health'
|
||||
const API_BASE_URL = String(
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
).replace(/\/+$/, '')
|
||||
|
||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||
const USE_MOCK_API = true
|
||||
const USE_MOCK_API = String(import.meta.env.VITE_USE_MOCK_API || 'false').toLowerCase() === 'true'
|
||||
|
||||
// 环境检测状态:0=检测中, 1=开发环境, 2=生产环境
|
||||
let envStatus = 0
|
||||
let baseURL = PROD_BASE
|
||||
const baseURL = API_BASE_URL
|
||||
|
||||
// 环境检测 Promise,确保 getApiBaseUrl / getWebSocketBaseUrl 等待检测完成
|
||||
const envReadyPromise = new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: HEALTH_URL,
|
||||
method: 'GET',
|
||||
timeout: 2000,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
baseURL = DEV_BASE
|
||||
envStatus = 1
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
/**
|
||||
* WebSocket 基础地址
|
||||
* 优先读取 VITE_WS_BASE_URL(独立部署 WS 时使用),未配置则按 API 地址自动推导 http→ws / https→wss
|
||||
*/
|
||||
const WS_BASE_URL = (() => {
|
||||
const configured = import.meta.env.VITE_WS_BASE_URL
|
||||
if (configured && String(configured).trim()) {
|
||||
return String(configured).replace(/\/+$/, '')
|
||||
}
|
||||
return baseURL.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
})()
|
||||
|
||||
/** 等待环境检测完成(返回 'dev' | 'prod') */
|
||||
export async function waitForEnvReady() {
|
||||
await envReadyPromise
|
||||
return envStatus === 1 ? 'dev' : 'prod'
|
||||
}
|
||||
console.log('[API] env:', ENV_NAME, 'baseURL:', baseURL, 'ws:', WS_BASE_URL, 'mock:', USE_MOCK_API)
|
||||
|
||||
/** 网关根地址(供 uni.uploadFile 等无法走 request 封装的场景拼接完整 URL) */
|
||||
export async function getApiBaseUrl() {
|
||||
await envReadyPromise
|
||||
return String(baseURL).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** 获取 WebSocket 基础地址(将 http:// 替换为 ws://) */
|
||||
/** 获取 WebSocket 基础地址(环境变量 VITE_WS_BASE_URL,未配置时由 API 地址自动推导) */
|
||||
export async function getWebSocketBaseUrl() {
|
||||
await envReadyPromise
|
||||
const httpUrl = String(baseURL).replace(/\/+$/, '')
|
||||
return httpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
return WS_BASE_URL
|
||||
}
|
||||
|
||||
// 兼容旧代码:同步版本在环境检测完成前返回默认值(生产地址)
|
||||
export function getApiBaseUrlSync() {
|
||||
return String(baseURL).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** 抠图专用 Gateway(可与 DEV_BASE 不同:业务走团队库,segment 走本机) */
|
||||
/** 抠图专用 Gateway(业务走团队库,segment 走本机;如需分离可再加 VITE_SEGMENT_BASE_URL) */
|
||||
export function getSegmentApiBaseUrl() {
|
||||
return String(baseURL).replace(/\/+$/, '')
|
||||
return baseURL
|
||||
}
|
||||
|
||||
/** 镭射专用 Gateway(AI 生成 + compositor 走本地,其他业务走远程) */
|
||||
/** 镭射专用 Gateway(AI 生成 + compositor 走本地,其他业务走远程;如需分离可再加 VITE_LASER_BASE_URL) */
|
||||
export function getLaserApiBaseUrl() {
|
||||
return String(baseURL).replace(/\/+$/, '')
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// 模拟网络延迟
|
||||
|
||||
Loading…
Reference in New Issue
Block a user