# 铸爱页分类/工艺卡片配置化 设计文档 - 日期: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(分类表) ```sql CREATE TABLE castlove_categories ( id BIGSERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL, type_key VARCHAR(32), -- 外部 deep-link key,可空 sort_order INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, CONSTRAINT uq_castlove_categories_name UNIQUE (name) ); CREATE INDEX idx_castlove_categories_sort ON castlove_categories (sort_order) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uq_castlove_categories_type_key ON castlove_categories (type_key) WHERE type_key IS NOT NULL AND deleted_at IS NULL; ALTER SEQUENCE castlove_categories_id_seq RESTART WITH 10000; ``` 字段说明: | 字段 | 说明 | |---|---| | `name` | 分类显示名("星卡" / "吧唧" / "海报"),DB 唯一 | | `type_key` | 用于外部 deep-link 定位的稳定 key(`star_card / badge / poster`),可空;非空时唯一(部分索引,允许多行 NULL) | | `sort_order` | 排序,越小越靠前;允许重复,重复时按 id 兜底 | | `is_active` | 软下线开关,前端只展示 true 的 | | `deleted_at` | 软删除时间戳 | ### 2.2 castlove_crafts(工艺卡片表) ```sql CREATE TABLE castlove_crafts ( id BIGSERIAL PRIMARY KEY, category_id BIGINT NOT NULL REFERENCES castlove_categories(id), name VARCHAR(64) NOT NULL, image_url TEXT NOT NULL, 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 初始数据回填 ```sql -- 分类 INSERT INTO castlove_categories (id, name, type_key, sort_order) VALUES (10001, '星卡', 'star_card', 0), (10002, '吧唧', 'badge', 1), (10003, '海报', 'poster', 2); SELECT setval('castlove_categories_id_seq', (SELECT MAX(id) FROM castlove_categories)); -- 卡片(按现有 craft-select.vue 第 263-370 行硬编码内容回填,route_path 来自原 cardRoutes 常量映射) INSERT INTO castlove_crafts (id, category_id, name, image_url, route_path, route_params, sort_order) VALUES -- 星卡(原 cardRoutes 中有映射) (10001, 10001, '镭射卡', 'https:///castlove/leisheka.png', '/pages/castlove/create', NULL, 0), (10002, 10001, '光栅卡', 'https:///castlove/guangshanka.png', '/pages/castlove/lenticular/lenticular-create', NULL, 1), (10003, 10001, '拍立得', 'https:///castlove/pailide.png', '/pages/castlove/create', NULL, 2), (10004, 10001, '开发中', 'https:///castlove/daikaifa.png', NULL, NULL, 3), (10005, 10001, '撕拉片', 'https:///castlove/silapian.png', '/pages/castlove/create', NULL, 4), -- 吧唧(原 cardRoutes 未映射,route_path=NULL,点击走 toast) (10006, 10002, '超复古', 'https:///castlove/fugu.png', NULL, NULL, 0), (10007, 10002, '卡通刺绣', 'https:///castlove/katongchixiu.png', NULL, NULL, 1), (10008, 10002, '云母片', 'https:///castlove/yunmupian.png', NULL, NULL, 2), (10009, 10002, '开发中', 'https:///castlove/daikaifa.png', NULL, NULL, 3), -- 海报(同上,route_path=NULL) (10010, 10003, '拼豆', 'https:///castlove/pindou.png', NULL, NULL, 0), (10011, 10003, '极繁插画', 'https:///castlove/jinfanchahua.png', NULL, NULL, 1), (10012, 10003, '街头拼贴', 'https:///castlove/jietoupintie.png', NULL, NULL, 2), (10013, 10003, '开发中', 'https:///castlove/daikaifa.png', NULL, NULL, 3); SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts)); ``` `` 替换为真实 OSS bucket 域名;初次回填可以先把现有 `/static/castlove/*.png` 文件上传到 OSS 拿到 URL 再替换。 --- ## 三、后端读取 API ### 3.1 接口定义 | 项 | 值 | |---|---| | 路径 | `GET /api/v1/castlove/config` | | 鉴权 | **强制 JWT**(未登录返回 401) | | 入参 | 无 | | 缓存 | 进程内 5 秒(带 `singleflight` 防击穿) | ### 3.2 响应结构 ```json { "code": 200, "message": "ok", "data": { "categories": [ { "id": 10001, "name": "星卡", "type_key": "star_card", "sort_order": 0, "crafts": [ { "id": 10001, "name": "镭射卡", "image_url": "https://oss/.../leisheka.png", "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_total` - `castlove_config_db_query_duration_ms` - 排障时可快速判断"是不是缓存击穿"或"DB 慢" ### 3.5 Service 层最低 validate(兜底) 后台直连写库,DB 约束已经挡掉了 NULL / 重复 name / 重复 type_key,但 service 层从 DB 读出后仍要做最低限度的 sanity check(防止后台同学绕开 UI 直接 SQL 注入脏数据): - 跳过 `image_url` 不以 `http://` 或 `https://` 开头的卡片(log warn,不抛错) - 跳过 `name` 为空字符串的记录 - 跳过 `category_id` 在 `castlove_categories` 表中找不到的孤儿卡片(log error) - `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 伪代码 ```go // backend/gateway/controller/castlove_config.go func GetCastloveConfig(c *gin.Context) { cfg, err := castloveConfigSvc.GetConfig(c.Request.Context()) if err != nil { response.Fail(c, http.StatusInternalServerError, "配置加载失败") return } response.OK(c, cfg) } // backend/gateway/service/castlove_config.go var ( cache atomic.Pointer[cachedConfig] sfGroup singleflight.Group ) type cachedConfig struct { data *dto.CastloveConfigResp expiresAt time.Time } func (s *CastloveConfigService) GetConfig(ctx context.Context) (*dto.CastloveConfigResp, error) { if c := cache.Load(); c != nil && time.Now().Before(c.expiresAt) { return c.data, nil } v, err, _ := sfGroup.Do("castlove_config", func() (interface{}, error) { return s.loadFromDB(ctx) }) if err != nil { return nil, err } resp := v.(*dto.CastloveConfigResp) cache.Store(&cachedConfig{data: resp, expiresAt: time.Now().Add(5 * time.Second)}) return resp, nil } // 后台直连写库绕过了 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(...)`: ```sql SELECT setval('castlove_categories_id_seq', (SELECT MAX(id) FROM castlove_categories)); SELECT setval('castlove_crafts_id_seq', (SELECT MAX(id) FROM castlove_crafts)); ``` - 通过后台正常表单新增(不指定 id,让 GORM 自增)不用手动 setval ### 约定 3:唯一性 - `castlove_categories.name` 唯一(DB UNIQUE 约束) - `castlove_categories.type_key` 非空时唯一(部分索引) - `castlove_crafts.name` 不强制唯一(不同分类下可重名) ### 约定 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_path` VARCHAR(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 直接生效——必须按序操作: 1. **前端发版** 先在 `craft-select.vue` / 父组件相关位置加 `?type=新key` 的入口 2. **后台开发** 在 §5.2 表单的 Type Key 下拉里加新选项(这是硬编码下拉) 3. **后台运营** 给某个分类绑定新 `type_key` 4. **验证** deep-link `?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:缓存生效时间 - 后端 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 帮助文案里): ```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 } } ``` **实现建议**(对接团队参考): - 不建议用裸 `