From 0a131a34bf4a8696860621dec5524296ac60f9f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com>
Date: Fri, 14 Aug 2026 16:11:10 +0800
Subject: [PATCH] 1
---
CLAUDE.md | 22 +-
README.md | 44 +-
admin-src/index.html | 12 +
admin-src/package-lock.json | 1929 ++++++++++++++++++++++
admin-src/package.json | 21 +
admin-src/src/components/UploadField.vue | 63 +
admin-src/src/composables/useCrud.js | 58 +
admin-src/src/layout/index.vue | 46 +
admin-src/src/main.js | 11 +
admin-src/src/request.js | 34 +
admin-src/src/router/index.js | 36 +
admin-src/src/views/Element.vue | 69 +
admin-src/src/views/Level.vue | 81 +
admin-src/src/views/LifeTask.vue | 59 +
admin-src/src/views/Login.vue | 48 +
admin-src/src/views/NodeOption.vue | 107 ++
admin-src/src/views/Parent.vue | 38 +
admin-src/src/views/Prize.vue | 67 +
admin-src/src/views/Redemption.vue | 99 ++
admin-src/src/views/SceneNode.vue | 122 ++
admin-src/src/views/Stats.vue | 110 ++
admin-src/src/views/Strategy.vue | 61 +
admin-src/vite.config.js | 15 +
biz/consts/consts.go | 11 +
biz/controller/admin_user.go | 19 +
biz/controller/badge.go | 31 +
biz/controller/child.go | 62 +-
biz/controller/element.go | 31 +
biz/controller/level.go | 125 +-
biz/controller/life_task.go | 31 +
biz/controller/node_option.go | 31 +
biz/controller/parent.go | 20 +-
biz/controller/prize.go | 31 +
biz/controller/redemption.go | 27 +
biz/controller/scene_node.go | 31 +
biz/controller/strategy.go | 83 +-
biz/dao/dao_admin_user.go | 6 +
biz/dao/dao_badge.go | 17 +
biz/dao/dao_child.go | 62 +
biz/dao/dao_element.go | 32 +
biz/dao/dao_level.go | 80 +
biz/dao/dao_life_task.go | 17 +
biz/dao/dao_node_option.go | 66 +
biz/dao/dao_parent.go | 11 +
biz/dao/dao_prize.go | 25 +
biz/dao/dao_redemption.go | 40 +
biz/dao/dao_scene_node.go | 54 +
biz/dao/dao_strategy.go | 35 +
biz/dao/dao_user_badge.go | 6 +
biz/dao/dao_user_collection.go | 23 +
biz/dao/dao_user_progress.go | 108 +-
biz/dao/dao_user_route_log.go | 36 +-
biz/model/domain/level.go | 11 +
biz/model/dto/admin_user.go | 25 +
biz/model/dto/badge.go | 58 +
biz/model/dto/child.go | 50 +
biz/model/dto/element.go | 63 +
biz/model/dto/level.go | 108 ++
biz/model/dto/life_task.go | 62 +
biz/model/dto/node_option.go | 74 +
biz/model/dto/parent.go | 21 +
biz/model/dto/prize.go | 67 +
biz/model/dto/redemption.go | 48 +
biz/model/dto/scene_node.go | 88 +
biz/model/dto/strategy.go | 103 +-
biz/model/entity/entity_element.go | 18 +-
biz/model/entity/entity_level.go | 23 +-
biz/model/entity/entity_node_option.go | 24 +-
biz/model/entity/entity_scene_node.go | 1 +
biz/model/entity/entity_strategy.go | 36 +-
biz/service/admin_user.go | 115 ++
biz/service/badge.go | 94 +-
biz/service/chapter_review.go | 19 +-
biz/service/child.go | 244 ++-
biz/service/element.go | 99 +-
biz/service/level.go | 673 +++++---
biz/service/level_test.go | 21 +-
biz/service/life_task.go | 110 +-
biz/service/node_option.go | 267 ++-
biz/service/parent.go | 84 +-
biz/service/prize.go | 114 +-
biz/service/redemption.go | 152 +-
biz/service/scene_node.go | 237 ++-
biz/service/strategy.go | 410 +++--
biz/service/user_badge.go | 23 +-
biz/service/user_collection.go | 32 +-
biz/service/user_progress.go | 74 +-
biz/service/user_route_log.go | 29 +-
common/base_dao.go | 47 +-
common/content_cache.go | 17 +
common/pinyin.go | 40 +
main.go | 18 +-
技术设计.md | 16 +
93 files changed, 7089 insertions(+), 1029 deletions(-)
create mode 100644 admin-src/index.html
create mode 100644 admin-src/package-lock.json
create mode 100644 admin-src/package.json
create mode 100644 admin-src/src/components/UploadField.vue
create mode 100644 admin-src/src/composables/useCrud.js
create mode 100644 admin-src/src/layout/index.vue
create mode 100644 admin-src/src/main.js
create mode 100644 admin-src/src/request.js
create mode 100644 admin-src/src/router/index.js
create mode 100644 admin-src/src/views/Element.vue
create mode 100644 admin-src/src/views/Level.vue
create mode 100644 admin-src/src/views/LifeTask.vue
create mode 100644 admin-src/src/views/Login.vue
create mode 100644 admin-src/src/views/NodeOption.vue
create mode 100644 admin-src/src/views/Parent.vue
create mode 100644 admin-src/src/views/Prize.vue
create mode 100644 admin-src/src/views/Redemption.vue
create mode 100644 admin-src/src/views/SceneNode.vue
create mode 100644 admin-src/src/views/Stats.vue
create mode 100644 admin-src/src/views/Strategy.vue
create mode 100644 admin-src/vite.config.js
create mode 100644 biz/model/domain/level.go
create mode 100644 common/content_cache.go
diff --git a/CLAUDE.md b/CLAUDE.md
index ff39418..c011097 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,7 +8,7 @@
|---|---|---|
| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装 | 不得依赖业务模块包;新增跨模块通用能力放这里 |
| biz/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小(consts.go) | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 |
-| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口 |
+| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) |
| biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 |
| biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 |
| biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao |
@@ -21,23 +21,37 @@
| 层 | 目录 | 职责 | 禁止 |
|---|---|---|---|
-| controller | biz/controller | 接收参数(依赖 DTO `v` tag 自动校验)、调用 service、组装返回值 | 直接调用 dao;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 |
-| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao | 直接写 HTTP 响应(例外见下) |
-| dao | biz/dao | 单表数据访问,每表一个文件 | 业务逻辑 |
+| controller | biz/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 |
+| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 |
+| dao | biz/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 |
**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。
+**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。
+
+**教训(此前偏离原因,开发时引以为戒)**:
+1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传"
+2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段
+3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式
+
## 分层文件对齐与代码模式(硬性要求)
- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致
- **非表文件一律不进业务分层目录**:路由注册与中间件装配、表初始化列表(建表 + 死表 DROP)直接写在 `main.go`;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件
- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件
- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射
+- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑
+- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record)
- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk)
- controller:结构体名决定路由前缀(如 `parent` → `/parent`),接口定义在 dto(`g.Meta` 携带 path/method/summary)
- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE
- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值"
+## 错误处理规范(硬性要求)
+
+- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过
+- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错
+
## 并发规范
- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上)
diff --git a/README.md b/README.md
index b33fb3b..afcecd4 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,7 @@
| 兑换管理 | 兑换记录、实物发货/确认、兑换码生成 |
| 生活任务管理 | 生活践行任务配置(关联计策、任务文案、奖励积分) |
| 数据统计 | 节点卡点分析:各节点/选项选择分布、失败终局到达率,按关卡聚合 |
+| 素材上传 | 图片/音频素材统一上传(白名单校验 → /uploads 返回 URL),内容与元素库引用 |
## 数据表清单
@@ -147,23 +148,38 @@
| POST /api/prize/redeem | POST | 积分兑换奖品 |
| GET /api/redemption/list | GET | 我的兑换记录 |
-### 后台(`/api/admin/` 前缀,管理员鉴权)
+### 后台(`/api/admin/` 前缀,管理员鉴权;登录公开)
| 接口 | 方法 | 说明 |
|---|---|---|
-| POST /api/admin/login | POST | 管理员登录 |
-| GET/POST /api/admin/strategy/… | GET/POST | 计策 CRUD |
-| GET/POST /api/admin/level/… | GET/POST | 关卡 CRUD |
-| GET/POST /api/admin/node/… | GET/POST | 情境节点(决策/终局)CRUD |
-| GET/POST /api/admin/option/… | GET/POST | 分支选项 CRUD |
-| GET/POST /api/admin/element/… | GET/POST | 元素库(场景/人物/道具)CRUD |
-| GET/POST /api/admin/task/… | GET/POST | 生活任务 CRUD |
-| GET /api/admin/stats/level | GET | 节点卡点统计(选择分布、失败率) |
-| GET/POST /api/admin/prize/… | GET/POST | 奖品 CRUD |
-| GET/POST /api/admin/badge/… | GET/POST | 徽章 CRUD |
-| GET /api/admin/user/list | GET | 用户列表 |
-| GET /api/admin/redemption/list | GET | 兑换记录 |
-| POST /api/admin/redemption/ship | POST | 实物发货/确认 |
+| POST /api/admin/login | POST | 管理员登录(返回 token) |
+| GET /api/admin/strategy/list | GET | 计策列表(含关卡数) |
+| POST /api/admin/strategy/create | POST | 新增计策 |
+| POST /api/admin/strategy/update | POST | 编辑计策 |
+| POST /api/admin/strategy/disable / enable | POST | 下架/上架计策(软删除) |
+| GET /api/admin/level/list | GET | 关卡列表(按计策,含场景名/节点数) |
+| POST /api/admin/level/create / update / disable / enable | POST | 关卡增改与上下架(改动内容版本号 +1) |
+| GET /api/admin/scene-node/list | POST | 节点列表(按关卡,含角色名/选项数) |
+| POST /api/admin/scene-node/create / update / disable / enable | POST | 情境节点(决策/终局)增改与上下架 |
+| GET /api/admin/node-option/list | GET | 分支选项列表(按节点,含道具名/下一节点) |
+| POST /api/admin/node-option/create / update / disable / enable | POST | 分支选项增改与上下架 |
+| GET /api/admin/element/list | GET | 元素库列表(场景/人物/道具,可按类型筛) |
+| POST /api/admin/element/create / update / disable / enable | POST | 元素增改与上下架 |
+| GET /api/admin/prize/list | GET | 奖品列表 |
+| POST /api/admin/prize/create / update / disable / enable | POST | 奖品增改与上下架 |
+| GET /api/admin/badge/list | GET | 徽章列表 |
+| POST /api/admin/badge/create / update / disable / enable | POST | 徽章增改与上下架 |
+| GET /api/admin/life-task/list | GET | 生活任务列表 |
+| POST /api/admin/life-task/create / update / disable / enable | POST | 生活任务增改与上下架 |
+| GET /api/admin/parent/list | GET | 家长列表(含孩子数) |
+| GET /api/admin/child/list | GET | 孩子列表(含积分/完美数/当前进度) |
+| GET /api/admin/child/detail | GET | 孩子详情(进度明细 + 汇总) |
+| GET /api/admin/redemption/list | GET | 兑换记录(可按状态/奖品筛) |
+| POST /api/admin/redemption/ship | POST | 兑换发货(生成兑换码) |
+| POST /api/admin/redemption/receive | POST | 确认领取 |
+| POST /api/admin/redemption/cancel | POST | 取消兑换(退积分) |
+| GET /api/admin/stats/level | GET | 关卡卡点统计(节点触达/选项选择/结果分布) |
+| POST /api/admin/upload | POST | 素材上传(图片/音频 → /uploads 返回 URL) |
## 使用说明
diff --git a/admin-src/index.html b/admin-src/index.html
new file mode 100644
index 0000000..6cf2b24
--- /dev/null
+++ b/admin-src/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 三十六计·管理后台
+
+
+
+
+
+
diff --git a/admin-src/package-lock.json b/admin-src/package-lock.json
new file mode 100644
index 0000000..f76bc2f
--- /dev/null
+++ b/admin-src/package-lock.json
@@ -0,0 +1,1929 @@
+{
+ "name": "admin-src",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "admin-src",
+ "version": "0.1.0",
+ "dependencies": {
+ "axios": "^1.8.4",
+ "element-plus": "^2.9.5",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.3",
+ "vite": "^6.3.5"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@ctrl/tinycolor": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+ "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@element-plus/icons-vue": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+ "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.2.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@popperjs/core": {
+ "name": "@sxzz/popperjs-es",
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+ "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.21",
+ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+ "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
+ "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/shared": "3.5.41",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
+ "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.41",
+ "@vue/shared": "3.5.41"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
+ "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/compiler-core": "3.5.41",
+ "@vue/compiler-dom": "3.5.41",
+ "@vue/compiler-ssr": "3.5.41",
+ "@vue/shared": "3.5.41",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.19",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
+ "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.41",
+ "@vue/shared": "3.5.41"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
+ "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.41"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
+ "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.41",
+ "@vue/shared": "3.5.41"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
+ "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.41",
+ "@vue/runtime-core": "3.5.41",
+ "@vue/shared": "3.5.41",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
+ "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.41",
+ "@vue/runtime-dom": "3.5.41",
+ "@vue/shared": "3.5.41"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
+ "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
+ "license": "MIT"
+ },
+ "node_modules/@vueuse/core": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
+ "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.21",
+ "@vueuse/metadata": "14.3.0",
+ "@vueuse/shared": "14.3.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
+ "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "14.3.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
+ "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
+ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.6",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.21",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/element-plus": {
+ "version": "2.14.4",
+ "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.4.tgz",
+ "integrity": "sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@ctrl/tinycolor": "^4.2.0",
+ "@element-plus/icons-vue": "^2.3.2",
+ "@floating-ui/dom": "^1.8.0",
+ "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+ "@types/lodash": "^4.17.24",
+ "@types/lodash-es": "^4.17.12",
+ "@vueuse/core": "14.3.0",
+ "async-validator": "^4.2.5",
+ "dayjs": "^1.11.20",
+ "lodash": "^4.18.1",
+ "lodash-es": "^4.18.1",
+ "lodash-unified": "^1.0.3",
+ "memoize-one": "^6.0.0",
+ "normalize-wheel-es": "^1.2.0",
+ "vue-component-type-helpers": "^3.3.9"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.7"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-unified": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
+ "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/lodash-es": "*",
+ "lodash": "*",
+ "lodash-es": "*"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "license": "MIT"
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/normalize-wheel-es": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+ "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.62.4",
+ "@rollup/rollup-android-arm64": "4.62.4",
+ "@rollup/rollup-darwin-arm64": "4.62.4",
+ "@rollup/rollup-darwin-x64": "4.62.4",
+ "@rollup/rollup-freebsd-arm64": "4.62.4",
+ "@rollup/rollup-freebsd-x64": "4.62.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
+ "@rollup/rollup-linux-x64-musl": "4.62.4",
+ "@rollup/rollup-openbsd-x64": "4.62.4",
+ "@rollup/rollup-openharmony-arm64": "4.62.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.41",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
+ "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.41",
+ "@vue/compiler-sfc": "3.5.41",
+ "@vue/runtime-dom": "3.5.41",
+ "@vue/server-renderer": "3.5.41",
+ "@vue/shared": "3.5.41"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-component-type-helpers": {
+ "version": "3.3.9",
+ "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz",
+ "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==",
+ "license": "MIT"
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ }
+ }
+}
diff --git a/admin-src/package.json b/admin-src/package.json
new file mode 100644
index 0000000..6708b16
--- /dev/null
+++ b/admin-src/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "admin-src",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "axios": "^1.8.4",
+ "element-plus": "^2.9.5",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.2.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/admin-src/src/components/UploadField.vue b/admin-src/src/components/UploadField.vue
new file mode 100644
index 0000000..365a5fa
--- /dev/null
+++ b/admin-src/src/components/UploadField.vue
@@ -0,0 +1,63 @@
+
+
+
+ {{ label }}:上传
+
+
+
+ 试听
+ 清除
+
+
+
+
+
+
+
diff --git a/admin-src/src/composables/useCrud.js b/admin-src/src/composables/useCrud.js
new file mode 100644
index 0000000..b199a33
--- /dev/null
+++ b/admin-src/src/composables/useCrud.js
@@ -0,0 +1,58 @@
+import { reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import request from '../request'
+
+// 通用五件套(list/create/update/disable/enable)页面状态与操作
+export function useCrud(prefix) {
+ const list = ref([])
+ const loading = ref(false)
+ const dialogVisible = ref(false)
+ const isEdit = ref(false)
+ const form = reactive({})
+
+ const load = async (params = {}) => {
+ loading.value = true
+ try {
+ const data = await request.get(`${prefix}/list`, { params })
+ list.value = (data && data.list) || []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const resetForm = () => {
+ Object.keys(form).forEach((k) => delete form[k])
+ }
+
+ const openCreate = (blank) => {
+ resetForm()
+ Object.assign(form, blank)
+ isEdit.value = false
+ dialogVisible.value = true
+ }
+
+ const openEdit = (row, keys) => {
+ resetForm()
+ keys.forEach((k) => {
+ form[k] = row[k] ?? ''
+ })
+ isEdit.value = true
+ dialogVisible.value = true
+ }
+
+ const save = async () => {
+ await request.post(isEdit.value ? `${prefix}/update` : `${prefix}/create`, { ...form })
+ ElMessage.success('保存成功')
+ dialogVisible.value = false
+ load()
+ }
+
+ const toggleStatus = async (row) => {
+ const target = row.status === 1 ? 'disable' : 'enable'
+ await request.post(`${prefix}/${target}`, { id: row.id })
+ ElMessage.success(row.status === 1 ? '已下架' : '已上架')
+ load()
+ }
+
+ return { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus }
+}
diff --git a/admin-src/src/layout/index.vue b/admin-src/src/layout/index.vue
new file mode 100644
index 0000000..5160528
--- /dev/null
+++ b/admin-src/src/layout/index.vue
@@ -0,0 +1,46 @@
+
+
+
+ 三十六计·后台
+
+ 计策管理
+ 关卡管理
+ 场景节点
+ 分支选项
+ 元素库
+ 奖品管理
+ 徽章管理
+ 生活任务
+ 家长管理
+ 孩子管理
+ 兑换管理
+ 关卡统计
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-src/src/main.js b/admin-src/src/main.js
new file mode 100644
index 0000000..65601e2
--- /dev/null
+++ b/admin-src/src/main.js
@@ -0,0 +1,11 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import 'element-plus/dist/index.css'
+import App from './App.vue'
+import router from './router'
+
+const app = createApp(App)
+app.use(router)
+app.use(ElementPlus, { locale: zhCn })
+app.mount('#app')
diff --git a/admin-src/src/request.js b/admin-src/src/request.js
new file mode 100644
index 0000000..a5eec20
--- /dev/null
+++ b/admin-src/src/request.js
@@ -0,0 +1,34 @@
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+import router from './router'
+
+const request = axios.create({ baseURL: '/api/admin', timeout: 15000 })
+
+request.interceptors.request.use((config) => {
+ const token = localStorage.getItem('admin_token')
+ if (token) config.headers.Authorization = `Bearer ${token}`
+ return config
+})
+
+request.interceptors.response.use(
+ (resp) => {
+ const data = resp.data
+ if (data.code !== 0) {
+ ElMessage.error(data.message || '请求失败')
+ return Promise.reject(new Error(data.message))
+ }
+ return data.data
+ },
+ (err) => {
+ if (err.response && err.response.status === 401) {
+ localStorage.removeItem('admin_token')
+ router.push('/login')
+ ElMessage.error('登录已过期,请重新登录')
+ } else {
+ ElMessage.error(err.response?.data?.message || err.message || '网络错误')
+ }
+ return Promise.reject(err)
+ },
+)
+
+export default request
diff --git a/admin-src/src/router/index.js b/admin-src/src/router/index.js
new file mode 100644
index 0000000..6d4c911
--- /dev/null
+++ b/admin-src/src/router/index.js
@@ -0,0 +1,36 @@
+import { createRouter, createWebHashHistory } from 'vue-router'
+import Layout from '../layout/index.vue'
+
+const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [
+ { path: '/login', component: () => import('../views/Login.vue') },
+ {
+ path: '/',
+ component: Layout,
+ redirect: '/strategy',
+ children: [
+ { path: 'strategy', component: () => import('../views/Strategy.vue'), meta: { title: '计策管理' } },
+ { path: 'level', component: () => import('../views/Level.vue'), meta: { title: '关卡管理' } },
+ { path: 'scene-node', component: () => import('../views/SceneNode.vue'), meta: { title: '场景节点' } },
+ { path: 'node-option', component: () => import('../views/NodeOption.vue'), meta: { title: '分支选项' } },
+ { path: 'element', component: () => import('../views/Element.vue'), meta: { title: '元素库' } },
+ { path: 'prize', component: () => import('../views/Prize.vue'), meta: { title: '奖品管理' } },
+ { path: 'badge', component: () => import('../views/Badge.vue'), meta: { title: '徽章管理' } },
+ { path: 'life-task', component: () => import('../views/LifeTask.vue'), meta: { title: '生活任务' } },
+ { path: 'parent', component: () => import('../views/Parent.vue'), meta: { title: '家长管理' } },
+ { path: 'child', component: () => import('../views/Child.vue'), meta: { title: '孩子管理' } },
+ { path: 'redemption', component: () => import('../views/Redemption.vue'), meta: { title: '兑换管理' } },
+ { path: 'stats', component: () => import('../views/Stats.vue'), meta: { title: '关卡统计' } },
+ ],
+ },
+ ],
+})
+
+router.beforeEach((to) => {
+ if (to.path !== '/login' && !localStorage.getItem('admin_token')) {
+ return '/login'
+ }
+})
+
+export default router
diff --git a/admin-src/src/views/Element.vue b/admin-src/src/views/Element.vue
new file mode 100644
index 0000000..b3b48e9
--- /dev/null
+++ b/admin-src/src/views/Element.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+ 新增元素
+
+
+
+
+
+ {{ eTypeMap[row.e_type] }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+ {{ v }}
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/src/views/Level.vue b/admin-src/src/views/Level.vue
new file mode 100644
index 0000000..6767d58
--- /dev/null
+++ b/admin-src/src/views/Level.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+ 新增关卡
+
+
+
+
+
+
+
+
+ v{{ row.content_version }}
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4-6 岁
+ 6-8 岁
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/src/views/LifeTask.vue b/admin-src/src/views/LifeTask.vue
new file mode 100644
index 0000000..b4de3b9
--- /dev/null
+++ b/admin-src/src/views/LifeTask.vue
@@ -0,0 +1,59 @@
+
+
+
+ 新增任务
+
+
+
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/src/views/Login.vue b/admin-src/src/views/Login.vue
new file mode 100644
index 0000000..119fcf9
--- /dev/null
+++ b/admin-src/src/views/Login.vue
@@ -0,0 +1,48 @@
+
+
+
+ 三十六计·管理后台
+
+
+
+
+
+
+
+ 登 录
+
+
+
+
+
+
+
+
diff --git a/admin-src/src/views/NodeOption.vue b/admin-src/src/views/NodeOption.vue
new file mode 100644
index 0000000..7d4ea6d
--- /dev/null
+++ b/admin-src/src/views/NodeOption.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增选项
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/src/views/Parent.vue b/admin-src/src/views/Parent.vue
new file mode 100644
index 0000000..097e200
--- /dev/null
+++ b/admin-src/src/views/Parent.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+
+
+
+
diff --git a/admin-src/src/views/Prize.vue b/admin-src/src/views/Prize.vue
new file mode 100644
index 0000000..62a9a22
--- /dev/null
+++ b/admin-src/src/views/Prize.vue
@@ -0,0 +1,67 @@
+
+
+
+ 新增奖品
+
+
+
+
+
+
+ {{ pTypeMap[row.p_type] }}
+
+
+
+ {{ row.stock < 0 ? '不限量' : row.stock }}
+
+
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+ 虚拟
+ 实物
+
+
+
+ (-1 = 不限量)
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/src/views/Redemption.vue b/admin-src/src/views/Redemption.vue
new file mode 100644
index 0000000..5620851
--- /dev/null
+++ b/admin-src/src/views/Redemption.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+ {{ redeemStatusMap[row.status] }}
+
+
+
+
+
+
+ 发货
+ 确认领取
+ 取消(退积分)
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认发货
+
+
+
+
+
+
diff --git a/admin-src/src/views/SceneNode.vue b/admin-src/src/views/SceneNode.vue
new file mode 100644
index 0000000..efde62c
--- /dev/null
+++ b/admin-src/src/views/SceneNode.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新增节点
+
+
+
+
+
+ {{ nodeTypeMap[row.node_type] }}
+
+
+
+
+ {{ interactionMap[row.interaction_type] || '-' }}
+
+
+ {{ resultMap[row.result_type] }}
+
+
+
+ {{ row.is_entry === 1 ? '是' : '' }}
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+
+ 决策
+ 终局
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 否
+ 是
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/src/views/Stats.vue b/admin-src/src/views/Stats.vue
new file mode 100644
index 0000000..dc0bf86
--- /dev/null
+++ b/admin-src/src/views/Stats.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 节点统计
+
+
+
+ {{ nodeTypeMap[row.node_type] }}
+
+
+
+
+ {{ row.none }}
+
+
+ {{ row.fail }}
+
+
+ {{ row.good }}
+
+
+ {{ row.best }}
+
+
+
+
+
+ 选项统计
+
+
+
+
+
+
+ {{ row.fail }}
+
+
+ {{ row.good }}
+
+
+ {{ row.best }}
+
+
+
+
+
+
+
+
+
diff --git a/admin-src/src/views/Strategy.vue b/admin-src/src/views/Strategy.vue
new file mode 100644
index 0000000..ec06e10
--- /dev/null
+++ b/admin-src/src/views/Strategy.vue
@@ -0,0 +1,61 @@
+
+
+
+ 新增计策
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText(row.status) }}
+
+
+
+ 编辑
+ {{ row.status === 1 ? '下架' : '上架' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
diff --git a/admin-src/vite.config.js b/admin-src/vite.config.js
new file mode 100644
index 0000000..145f61d
--- /dev/null
+++ b/admin-src/vite.config.js
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// base 与生产托管对齐:后端 AddStaticPath("/admin", "admin-src/dist")
+export default defineConfig({
+ base: '/admin/',
+ plugins: [vue()],
+ server: {
+ port: 5174,
+ proxy: {
+ '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true },
+ },
+ },
+ build: { outDir: 'dist' },
+})
diff --git a/biz/consts/consts.go b/biz/consts/consts.go
index 0c5c707..1aa8d04 100644
--- a/biz/consts/consts.go
+++ b/biz/consts/consts.go
@@ -18,3 +18,14 @@ const (
ReviewLevelCount = 3 // 章末温故抽取计谋数(2-3,取 3 且不超过已学)
AuthExpireSeconds = 7 * 24 * 3600 // 家长 token 有效期(7 天)
)
+
+// 后台素材上传(技术设计.md 4.17)
+const (
+ UploadMaxImageBytes = 10 * 1024 * 1024 // 图片 ≤10MB
+ UploadMaxAudioBytes = 20 * 1024 * 1024 // 音频 ≤20MB
+)
+
+// 兑换码
+const (
+ RedeemCodePrefix = "36ZH-" // 实物兑换发货生成,前缀 + 8 位大写随机
+)
diff --git a/biz/controller/admin_user.go b/biz/controller/admin_user.go
index 27d1376..fc987a4 100644
--- a/biz/controller/admin_user.go
+++ b/biz/controller/admin_user.go
@@ -1,5 +1,24 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type adminUser struct{}
var AdminUser = &adminUser{}
+
+func (c *adminUser) Login(ctx context.Context, req *dto.AdminLoginReq) (*dto.AdminLoginRes, error) {
+ return service.AdminUser.Login(ctx, req)
+}
+
+type adminUpload struct{}
+
+var AdminUpload = &adminUpload{}
+
+func (c *adminUpload) Upload(ctx context.Context, req *dto.AdminUploadReq) (*dto.AdminUploadRes, error) {
+ return service.AdminUser.Upload(ctx, req)
+}
diff --git a/biz/controller/badge.go b/biz/controller/badge.go
index 6fb185c..7727117 100644
--- a/biz/controller/badge.go
+++ b/biz/controller/badge.go
@@ -1,5 +1,36 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type badge struct{}
var Badge = &badge{}
+
+type adminBadge struct{}
+
+var AdminBadge = &adminBadge{}
+
+func (c *adminBadge) List(ctx context.Context, req *dto.AdminBadgeListReq) (*dto.AdminBadgeListRes, error) {
+ return service.AdminBadge.List(ctx, req)
+}
+
+func (c *adminBadge) Create(ctx context.Context, req *dto.AdminBadgeCreateReq) (*dto.AdminBadgeCreateRes, error) {
+ return service.AdminBadge.Create(ctx, req)
+}
+
+func (c *adminBadge) Update(ctx context.Context, req *dto.AdminBadgeUpdateReq) (*dto.AdminBadgeUpdateRes, error) {
+ return service.AdminBadge.Update(ctx, req)
+}
+
+func (c *adminBadge) Disable(ctx context.Context, req *dto.AdminBadgeDisableReq) (*dto.AdminBadgeDisableRes, error) {
+ return service.AdminBadge.Disable(ctx, req)
+}
+
+func (c *adminBadge) Enable(ctx context.Context, req *dto.AdminBadgeEnableReq) (*dto.AdminBadgeEnableRes, error) {
+ return service.AdminBadge.Enable(ctx, req)
+}
diff --git a/biz/controller/child.go b/biz/controller/child.go
index 600fb44..573d26d 100644
--- a/biz/controller/child.go
+++ b/biz/controller/child.go
@@ -3,11 +3,8 @@ package controller
import (
"context"
- "github.com/gogf/gf/v2/frame/g"
-
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
- "36wisdom/common/auth"
)
type child struct{}
@@ -15,54 +12,25 @@ type child struct{}
var Child = &child{}
func (c *child) Create(ctx context.Context, req *dto.ChildCreateReq) (*dto.ChildCreateRes, error) {
- childId, err := service.Child.Create(ctx, auth.GetUid(ctx), req.Nickname, req.AgeGroup)
- if err != nil {
- return nil, err
- }
- return &dto.ChildCreateRes{ChildId: childId}, nil
+ return service.Child.Create(ctx, req)
}
func (c *child) Update(ctx context.Context, req *dto.ChildUpdateReq) (*dto.ChildUpdateRes, error) {
- data := g.Map{}
- if req.Nickname != "" {
- data["nickname"] = req.Nickname
- }
- if req.Avatar != "" {
- data["avatar"] = req.Avatar
- }
- if req.AgeGroup != "" {
- data["age_group"] = req.AgeGroup
- }
- if req.DailyLimitMinutes > 0 {
- data["daily_limit_minutes"] = req.DailyLimitMinutes
- }
- if len(data) == 0 {
- return &dto.ChildUpdateRes{}, nil
- }
- if err := service.Child.Update(ctx, auth.GetUid(ctx), req.ChildId, data); err != nil {
- return nil, err
- }
- return &dto.ChildUpdateRes{}, nil
+ return service.Child.Update(ctx, req)
}
func (c *child) List(ctx context.Context, req *dto.ChildListReq) (*dto.ChildListRes, error) {
- items, err := service.Child.List(ctx, auth.GetUid(ctx))
- if err != nil {
- return nil, err
- }
- res := &dto.ChildListRes{List: make([]dto.ChildListItem, 0, len(items))}
- for _, it := range items {
- res.List = append(res.List, dto.ChildListItem{
- ChildId: it.ChildId,
- Nickname: it.Nickname,
- Avatar: it.Avatar,
- AgeGroup: it.AgeGroup,
- Points: it.Points,
- Level: it.Level,
- LevelTitle: it.LevelTitle,
- PerfectCount: it.PerfectCount,
- DailyLimitMinutes: it.DailyLimitMinutes,
- })
- }
- return res, nil
+ return service.Child.List(ctx, req)
+}
+
+type adminChild struct{}
+
+var AdminChild = &adminChild{}
+
+func (c *adminChild) List(ctx context.Context, req *dto.AdminChildListReq) (*dto.AdminChildListRes, error) {
+ return service.AdminChild.List(ctx, req)
+}
+
+func (c *adminChild) Detail(ctx context.Context, req *dto.AdminChildDetailReq) (*dto.AdminChildDetailRes, error) {
+ return service.AdminChild.Detail(ctx, req)
}
diff --git a/biz/controller/element.go b/biz/controller/element.go
index b8b89ee..09b0633 100644
--- a/biz/controller/element.go
+++ b/biz/controller/element.go
@@ -1,5 +1,36 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type element struct{}
var Element = &element{}
+
+type adminElement struct{}
+
+var AdminElement = &adminElement{}
+
+func (c *adminElement) List(ctx context.Context, req *dto.AdminElementListReq) (*dto.AdminElementListRes, error) {
+ return service.AdminElement.List(ctx, req)
+}
+
+func (c *adminElement) Create(ctx context.Context, req *dto.AdminElementCreateReq) (*dto.AdminElementCreateRes, error) {
+ return service.AdminElement.Create(ctx, req)
+}
+
+func (c *adminElement) Update(ctx context.Context, req *dto.AdminElementUpdateReq) (*dto.AdminElementUpdateRes, error) {
+ return service.AdminElement.Update(ctx, req)
+}
+
+func (c *adminElement) Disable(ctx context.Context, req *dto.AdminElementDisableReq) (*dto.AdminElementDisableRes, error) {
+ return service.AdminElement.Disable(ctx, req)
+}
+
+func (c *adminElement) Enable(ctx context.Context, req *dto.AdminElementEnableReq) (*dto.AdminElementEnableRes, error) {
+ return service.AdminElement.Enable(ctx, req)
+}
diff --git a/biz/controller/level.go b/biz/controller/level.go
index 6dafb7f..fe15693 100644
--- a/biz/controller/level.go
+++ b/biz/controller/level.go
@@ -5,7 +5,6 @@ import (
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
- "36wisdom/common/auth"
)
type level struct{}
@@ -13,97 +12,41 @@ type level struct{}
var Level = &level{}
func (c *level) Detail(ctx context.Context, req *dto.LevelDetailReq) (*dto.LevelDetailRes, error) {
- detail, err := service.Level.Detail(ctx, auth.GetUid(ctx), req.ChildId, req.LevelId)
- if err != nil {
- return nil, err
- }
- res := &dto.LevelDetailRes{
- LevelId: detail.LevelId,
- Title: detail.Title,
- Scene: elementVO(detail.Scene),
- SceneContent: detail.SceneContent,
- SceneContentPinyin: detail.SceneContentPinyin,
- SceneImage: detail.SceneImage,
- SceneAudio: detail.SceneAudio,
- Entry: nodeVO(detail.Entry),
- TotalFinals: detail.TotalFinals,
- Perfect: detail.Perfect,
- Stars: detail.Stars,
- }
- return res, nil
-}
-
-func elementVO(e *service.ElementVO) *dto.ElementVO {
- if e == nil {
- return nil
- }
- return &dto.ElementVO{
- Id: e.Id,
- EType: e.EType,
- Name: e.Name,
- NamePinyin: e.NamePinyin,
- Image: e.Image,
- Audio: e.Audio,
- Description: e.Description,
- }
-}
-
-func nodeVO(n *service.Node) dto.NodeVO {
- vo := dto.NodeVO{
- NodeId: n.NodeId,
- Title: n.Title,
- Content: n.Content,
- ContentPinyin: n.ContentPinyin,
- Image: n.Image,
- Audio: n.Audio,
- Character: elementVO(n.Character),
- InteractionType: n.InteractionType,
- Config: n.Config,
- Script: n.Script,
- NodeType: n.NodeType,
- ResultType: n.ResultType,
- Options: make([]dto.OptionVO, 0, len(n.Options)),
- }
- for _, o := range n.Options {
- vo.Options = append(vo.Options, dto.OptionVO{
- OptionId: o.OptionId,
- Text: o.Text,
- TextPinyin: o.TextPinyin,
- FeedbackPros: o.FeedbackPros,
- FeedbackCons: o.FeedbackCons,
- Audio: o.Audio,
- Prop: elementVO(o.Prop),
- })
- }
- return vo
+ return service.Level.Detail(ctx, req)
}
func (c *level) Choose(ctx context.Context, req *dto.ChooseReq) (*dto.ChooseRes, error) {
- node, settle, err := service.Level.Choose(ctx, auth.GetUid(ctx), req.ChildId, req.LevelId, req.NodeId, req.OptionId)
- if err != nil {
- return nil, err
- }
- res := &dto.ChooseRes{}
- if node != nil {
- vo := nodeVO(node)
- res.Next = &vo
- }
- if settle != nil {
- res.Final = &dto.FinalSettle{
- ResultType: settle.ResultType,
- Stars: settle.Stars,
- ScoreDelta: settle.ScoreDelta,
- Cleared: settle.Cleared,
- Perfect: settle.Perfect,
- UnlockNext: settle.UnlockNext,
- CollectionUnlocked: settle.CollectionUnlocked,
- NewLevel: settle.NewLevel,
- BalanceAfter: settle.BalanceAfter,
- }
- if settle.FinalNode != nil {
- vo := nodeVO(settle.FinalNode)
- res.Final.FinalNode = &vo
- }
- }
- return res, nil
+ return service.Level.Choose(ctx, req)
+}
+
+type adminLevel struct{}
+
+var AdminLevel = &adminLevel{}
+
+func (c *adminLevel) List(ctx context.Context, req *dto.AdminLevelListReq) (*dto.AdminLevelListRes, error) {
+ return service.AdminLevel.List(ctx, req)
+}
+
+func (c *adminLevel) Create(ctx context.Context, req *dto.AdminLevelCreateReq) (*dto.AdminLevelCreateRes, error) {
+ return service.AdminLevel.Create(ctx, req)
+}
+
+func (c *adminLevel) Update(ctx context.Context, req *dto.AdminLevelUpdateReq) (*dto.AdminLevelUpdateRes, error) {
+ return service.AdminLevel.Update(ctx, req)
+}
+
+func (c *adminLevel) Disable(ctx context.Context, req *dto.AdminLevelDisableReq) (*dto.AdminLevelDisableRes, error) {
+ return service.AdminLevel.Disable(ctx, req)
+}
+
+func (c *adminLevel) Enable(ctx context.Context, req *dto.AdminLevelEnableReq) (*dto.AdminLevelEnableRes, error) {
+ return service.AdminLevel.Enable(ctx, req)
+}
+
+type adminStats struct{}
+
+var AdminStats = &adminStats{}
+
+func (c *adminStats) Level(ctx context.Context, req *dto.AdminStatsLevelReq) (*dto.AdminStatsLevelRes, error) {
+ return service.AdminStats.Level(ctx, req)
}
diff --git a/biz/controller/life_task.go b/biz/controller/life_task.go
index 241aea4..70f64cd 100644
--- a/biz/controller/life_task.go
+++ b/biz/controller/life_task.go
@@ -1,5 +1,36 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type lifeTask struct{}
var LifeTask = &lifeTask{}
+
+type adminLifeTask struct{}
+
+var AdminLifeTask = &adminLifeTask{}
+
+func (c *adminLifeTask) List(ctx context.Context, req *dto.AdminLifeTaskListReq) (*dto.AdminLifeTaskListRes, error) {
+ return service.AdminLifeTask.List(ctx, req)
+}
+
+func (c *adminLifeTask) Create(ctx context.Context, req *dto.AdminLifeTaskCreateReq) (*dto.AdminLifeTaskCreateRes, error) {
+ return service.AdminLifeTask.Create(ctx, req)
+}
+
+func (c *adminLifeTask) Update(ctx context.Context, req *dto.AdminLifeTaskUpdateReq) (*dto.AdminLifeTaskUpdateRes, error) {
+ return service.AdminLifeTask.Update(ctx, req)
+}
+
+func (c *adminLifeTask) Disable(ctx context.Context, req *dto.AdminLifeTaskDisableReq) (*dto.AdminLifeTaskDisableRes, error) {
+ return service.AdminLifeTask.Disable(ctx, req)
+}
+
+func (c *adminLifeTask) Enable(ctx context.Context, req *dto.AdminLifeTaskEnableReq) (*dto.AdminLifeTaskEnableRes, error) {
+ return service.AdminLifeTask.Enable(ctx, req)
+}
diff --git a/biz/controller/node_option.go b/biz/controller/node_option.go
index 5cc3dcc..b963296 100644
--- a/biz/controller/node_option.go
+++ b/biz/controller/node_option.go
@@ -1,5 +1,36 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type nodeOption struct{}
var NodeOption = &nodeOption{}
+
+type adminNodeOption struct{}
+
+var AdminNodeOption = &adminNodeOption{}
+
+func (c *adminNodeOption) List(ctx context.Context, req *dto.AdminNodeOptionListReq) (*dto.AdminNodeOptionListRes, error) {
+ return service.AdminNodeOption.List(ctx, req)
+}
+
+func (c *adminNodeOption) Create(ctx context.Context, req *dto.AdminNodeOptionCreateReq) (*dto.AdminNodeOptionCreateRes, error) {
+ return service.AdminNodeOption.Create(ctx, req)
+}
+
+func (c *adminNodeOption) Update(ctx context.Context, req *dto.AdminNodeOptionUpdateReq) (*dto.AdminNodeOptionUpdateRes, error) {
+ return service.AdminNodeOption.Update(ctx, req)
+}
+
+func (c *adminNodeOption) Disable(ctx context.Context, req *dto.AdminNodeOptionDisableReq) (*dto.AdminNodeOptionDisableRes, error) {
+ return service.AdminNodeOption.Disable(ctx, req)
+}
+
+func (c *adminNodeOption) Enable(ctx context.Context, req *dto.AdminNodeOptionEnableReq) (*dto.AdminNodeOptionEnableRes, error) {
+ return service.AdminNodeOption.Enable(ctx, req)
+}
diff --git a/biz/controller/parent.go b/biz/controller/parent.go
index cbd3821..8d97b49 100644
--- a/biz/controller/parent.go
+++ b/biz/controller/parent.go
@@ -12,17 +12,17 @@ type parent struct{}
var Parent = &parent{}
func (c *parent) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) {
- parentId, token, err := service.Parent.Register(ctx, req.Phone, req.Password, req.Nickname)
- if err != nil {
- return nil, err
- }
- return &dto.RegisterRes{Token: token, ParentId: parentId}, nil
+ return service.Parent.Register(ctx, req)
}
func (c *parent) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) {
- parentId, token, err := service.Parent.Login(ctx, req.Phone, req.Password)
- if err != nil {
- return nil, err
- }
- return &dto.LoginRes{Token: token, ParentId: parentId}, nil
+ return service.Parent.Login(ctx, req)
+}
+
+type adminParent struct{}
+
+var AdminParent = &adminParent{}
+
+func (c *adminParent) List(ctx context.Context, req *dto.AdminParentListReq) (*dto.AdminParentListRes, error) {
+ return service.AdminParent.List(ctx, req)
}
diff --git a/biz/controller/prize.go b/biz/controller/prize.go
index 11a9f0c..fa0d56a 100644
--- a/biz/controller/prize.go
+++ b/biz/controller/prize.go
@@ -1,5 +1,36 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type prize struct{}
var Prize = &prize{}
+
+type adminPrize struct{}
+
+var AdminPrize = &adminPrize{}
+
+func (c *adminPrize) List(ctx context.Context, req *dto.AdminPrizeListReq) (*dto.AdminPrizeListRes, error) {
+ return service.AdminPrize.List(ctx, req)
+}
+
+func (c *adminPrize) Create(ctx context.Context, req *dto.AdminPrizeCreateReq) (*dto.AdminPrizeCreateRes, error) {
+ return service.AdminPrize.Create(ctx, req)
+}
+
+func (c *adminPrize) Update(ctx context.Context, req *dto.AdminPrizeUpdateReq) (*dto.AdminPrizeUpdateRes, error) {
+ return service.AdminPrize.Update(ctx, req)
+}
+
+func (c *adminPrize) Disable(ctx context.Context, req *dto.AdminPrizeDisableReq) (*dto.AdminPrizeDisableRes, error) {
+ return service.AdminPrize.Disable(ctx, req)
+}
+
+func (c *adminPrize) Enable(ctx context.Context, req *dto.AdminPrizeEnableReq) (*dto.AdminPrizeEnableRes, error) {
+ return service.AdminPrize.Enable(ctx, req)
+}
diff --git a/biz/controller/redemption.go b/biz/controller/redemption.go
index c1eba2a..e2baec6 100644
--- a/biz/controller/redemption.go
+++ b/biz/controller/redemption.go
@@ -1,5 +1,32 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type redemption struct{}
var Redemption = &redemption{}
+
+type adminRedemption struct{}
+
+var AdminRedemption = &adminRedemption{}
+
+func (c *adminRedemption) List(ctx context.Context, req *dto.AdminRedemptionListReq) (*dto.AdminRedemptionListRes, error) {
+ return service.AdminRedemption.List(ctx, req)
+}
+
+func (c *adminRedemption) Ship(ctx context.Context, req *dto.AdminRedemptionShipReq) (*dto.AdminRedemptionShipRes, error) {
+ return service.AdminRedemption.Ship(ctx, req)
+}
+
+func (c *adminRedemption) Receive(ctx context.Context, req *dto.AdminRedemptionReceiveReq) (*dto.AdminRedemptionReceiveRes, error) {
+ return service.AdminRedemption.Receive(ctx, req)
+}
+
+func (c *adminRedemption) Cancel(ctx context.Context, req *dto.AdminRedemptionCancelReq) (*dto.AdminRedemptionCancelRes, error) {
+ return service.AdminRedemption.Cancel(ctx, req)
+}
diff --git a/biz/controller/scene_node.go b/biz/controller/scene_node.go
index 22c8cba..52d6d19 100644
--- a/biz/controller/scene_node.go
+++ b/biz/controller/scene_node.go
@@ -1,5 +1,36 @@
package controller
+import (
+ "context"
+
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/service"
+)
+
type sceneNode struct{}
var SceneNode = &sceneNode{}
+
+type adminSceneNode struct{}
+
+var AdminSceneNode = &adminSceneNode{}
+
+func (c *adminSceneNode) List(ctx context.Context, req *dto.AdminSceneNodeListReq) (*dto.AdminSceneNodeListRes, error) {
+ return service.AdminSceneNode.List(ctx, req)
+}
+
+func (c *adminSceneNode) Create(ctx context.Context, req *dto.AdminSceneNodeCreateReq) (*dto.AdminSceneNodeCreateRes, error) {
+ return service.AdminSceneNode.Create(ctx, req)
+}
+
+func (c *adminSceneNode) Update(ctx context.Context, req *dto.AdminSceneNodeUpdateReq) (*dto.AdminSceneNodeUpdateRes, error) {
+ return service.AdminSceneNode.Update(ctx, req)
+}
+
+func (c *adminSceneNode) Disable(ctx context.Context, req *dto.AdminSceneNodeDisableReq) (*dto.AdminSceneNodeDisableRes, error) {
+ return service.AdminSceneNode.Disable(ctx, req)
+}
+
+func (c *adminSceneNode) Enable(ctx context.Context, req *dto.AdminSceneNodeEnableReq) (*dto.AdminSceneNodeEnableRes, error) {
+ return service.AdminSceneNode.Enable(ctx, req)
+}
diff --git a/biz/controller/strategy.go b/biz/controller/strategy.go
index 7fbfb48..39749db 100644
--- a/biz/controller/strategy.go
+++ b/biz/controller/strategy.go
@@ -5,7 +5,6 @@ import (
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
- "36wisdom/common/auth"
)
type strategy struct{}
@@ -13,63 +12,33 @@ type strategy struct{}
var Strategy = &strategy{}
func (c *strategy) List(ctx context.Context, req *dto.StrategyListReq) (*dto.StrategyListRes, error) {
- items, err := service.Strategy.List(ctx, auth.GetUid(ctx), req.ChildId)
- if err != nil {
- return nil, err
- }
- res := &dto.StrategyListRes{List: make([]dto.StrategyItem, 0, len(items))}
- for _, it := range items {
- res.List = append(res.List, dto.StrategyItem{
- StrategyId: it.StrategyId,
- Name: it.Name,
- Pinyin: it.Pinyin,
- GroupNo: it.GroupNo,
- GroupName: it.GroupName,
- Meaning: it.Meaning,
- Icon: it.Icon,
- SortOrder: it.SortOrder,
- Stars: it.Stars,
- PerfectCount: it.PerfectCount,
- TotalLevels: it.TotalLevels,
- Unlocked: it.Unlocked,
- UnlockReason: it.UnlockReason,
- })
- }
- return res, nil
+ return service.Strategy.List(ctx, req)
}
func (c *strategy) Detail(ctx context.Context, req *dto.StrategyDetailReq) (*dto.StrategyDetailRes, error) {
- detail, err := service.Strategy.Detail(ctx, auth.GetUid(ctx), req.ChildId, req.StrategyId)
- if err != nil {
- return nil, err
- }
- res := &dto.StrategyDetailRes{
- StrategyId: detail.StrategyId,
- Name: detail.Name,
- Pinyin: detail.Pinyin,
- Meaning: detail.Meaning,
- MeaningPinyin: detail.MeaningPinyin,
- GroupName: detail.GroupName,
- TeachContent: detail.TeachContent,
- TeachContentPinyin: detail.TeachContentPinyin,
- TeachImage: detail.TeachImage,
- TeachAudio: detail.TeachAudio,
- SummaryQ: detail.SummaryQ,
- SummaryOptions: detail.SummaryOptions,
- Levels: make([]dto.LevelBrief, 0, len(detail.Levels)),
- }
- for _, lv := range detail.Levels {
- res.Levels = append(res.Levels, dto.LevelBrief{
- LevelId: lv.LevelId,
- Title: lv.Title,
- AgeGroup: lv.AgeGroup,
- SceneName: lv.SceneName,
- Stars: lv.Stars,
- Perfect: lv.Perfect,
- Unlocked: lv.Unlocked,
- ContentVersion: lv.ContentVersion,
- ProgressVersion: lv.ProgressVersion,
- })
- }
- return res, nil
+ return service.Strategy.Detail(ctx, req)
+}
+
+type adminStrategy struct{}
+
+var AdminStrategy = &adminStrategy{}
+
+func (c *adminStrategy) List(ctx context.Context, req *dto.AdminStrategyListReq) (*dto.AdminStrategyListRes, error) {
+ return service.AdminStrategy.List(ctx, req)
+}
+
+func (c *adminStrategy) Create(ctx context.Context, req *dto.AdminStrategyCreateReq) (*dto.AdminStrategyCreateRes, error) {
+ return service.AdminStrategy.Create(ctx, req)
+}
+
+func (c *adminStrategy) Update(ctx context.Context, req *dto.AdminStrategyUpdateReq) (*dto.AdminStrategyUpdateRes, error) {
+ return service.AdminStrategy.Update(ctx, req)
+}
+
+func (c *adminStrategy) Disable(ctx context.Context, req *dto.AdminStrategyDisableReq) (*dto.AdminStrategyDisableRes, error) {
+ return service.AdminStrategy.Disable(ctx, req)
+}
+
+func (c *adminStrategy) Enable(ctx context.Context, req *dto.AdminStrategyEnableReq) (*dto.AdminStrategyEnableRes, error) {
+ return service.AdminStrategy.Enable(ctx, req)
}
diff --git a/biz/dao/dao_admin_user.go b/biz/dao/dao_admin_user.go
index 8928ca0..b2683cc 100644
--- a/biz/dao/dao_admin_user.go
+++ b/biz/dao/dao_admin_user.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -24,3 +25,8 @@ CREATE TABLE IF NOT EXISTS admin_user (
);`)
return err
}
+
+// GetByUsername 按用户名查管理员(登录用);不存在返回 nil, nil。
+func (d *adminUserDao) GetByUsername(ctx context.Context, username string) (*entity.AdminUser, error) {
+ return common.GetOne[entity.AdminUser](d.Model().Ctx(ctx).Where("username", username))
+}
diff --git a/biz/dao/dao_badge.go b/biz/dao/dao_badge.go
index fe1014c..8960d4b 100644
--- a/biz/dao/dao_badge.go
+++ b/biz/dao/dao_badge.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -26,3 +27,19 @@ CREATE TABLE IF NOT EXISTS badge (
CREATE INDEX IF NOT EXISTS idx_badge_status ON badge(status);`)
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *badgeDao) GetByPk(ctx context.Context, id int64) (*entity.Badge, error) {
+ return common.GetOne[entity.Badge](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// ListEnabledCached 启用徽章(内容缓存),按 id 排序。
+func (d *badgeDao) ListEnabledCached(ctx context.Context) ([]*entity.Badge, error) {
+ return common.GetList[entity.Badge](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("status", consts.StatusEnabled).Order("id ASC"))
+}
+
+// ListAll 全部徽章(含下架),按 id 排序。
+func (d *badgeDao) ListAll(ctx context.Context) ([]*entity.Badge, error) {
+ return common.GetList[entity.Badge](d.Model().Ctx(ctx).Order("id ASC"))
+}
diff --git a/biz/dao/dao_child.go b/biz/dao/dao_child.go
index fd6d472..fa6f20f 100644
--- a/biz/dao/dao_child.go
+++ b/biz/dao/dao_child.go
@@ -3,9 +3,11 @@ package dao
import (
"context"
+ "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -41,3 +43,63 @@ CREATE TABLE IF NOT EXISTS point_log (
CREATE INDEX IF NOT EXISTS idx_point_log_user ON point_log(user_id, created_at);`)
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *childDao) GetByPk(ctx context.Context, id int64) (*entity.Child, error) {
+ return common.GetOne[entity.Child](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// GetByPkInTx 事务内主键查询;不存在返回 nil, nil。
+func (d *childDao) GetByPkInTx(ctx context.Context, tx gdb.TX, id int64) (*entity.Child, error) {
+ rec, err := tx.Model(consts.TableChild).Ctx(ctx).WherePri(id).One()
+ if err != nil {
+ return nil, err
+ }
+ if rec.IsEmpty() {
+ return nil, nil
+ }
+ dst := &entity.Child{}
+ if err = rec.Struct(dst); err != nil {
+ return nil, err
+ }
+ return dst, nil
+}
+
+// ListByParent 家长名下的孩子列表,按 id 升序。
+func (d *childDao) ListByParent(ctx context.Context, parentId int64) ([]*entity.Child, error) {
+ return common.GetList[entity.Child](d.Model().Ctx(ctx).Where("parent_id", parentId).Order("id ASC"))
+}
+
+// ListAll 孩子列表(parentId>0 时按家长筛),按 id 倒序。
+func (d *childDao) ListAll(ctx context.Context, parentId int64) ([]*entity.Child, error) {
+ m := d.Model().Ctx(ctx)
+ if parentId > 0 {
+ m = m.Where("parent_id", parentId)
+ }
+ return common.GetList[entity.Child](m.Order("id DESC"))
+}
+
+// ListByIds 按 id 批量取(无缓存)。
+func (d *childDao) ListByIds(ctx context.Context, ids []int64) ([]*entity.Child, error) {
+ if len(ids) == 0 {
+ return []*entity.Child{}, nil
+ }
+ return common.GetList[entity.Child](d.Model().Ctx(ctx).WhereIn("id", ids))
+}
+
+// CountByParentIds 各家长孩子数(单表 GROUP BY 聚合)。
+func (d *childDao) CountByParentIds(ctx context.Context, parentIds []int64) (map[int64]int, error) {
+ m := make(map[int64]int, len(parentIds))
+ if len(parentIds) == 0 {
+ return m, nil
+ }
+ recs, err := d.Model().Ctx(ctx).WhereIn("parent_id", parentIds).Group("parent_id").
+ Fields("parent_id, COUNT(*) AS cnt").All()
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r["parent_id"].Int64()] = r["cnt"].Int()
+ }
+ return m, nil
+}
diff --git a/biz/dao/dao_element.go b/biz/dao/dao_element.go
index fab0bec..8d9550a 100644
--- a/biz/dao/dao_element.go
+++ b/biz/dao/dao_element.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -32,3 +33,34 @@ CREATE INDEX IF NOT EXISTS idx_element_type ON element(e_type);`)
common.EnsureColumn(ctx, consts.TableElement, "description_pinyin", "TEXT")
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *elementDao) GetByPk(ctx context.Context, id int64) (*entity.Element, error) {
+ return common.GetOne[entity.Element](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// ListEnabledByIdsCached 按 id 批量取启用元素(内容缓存)。
+func (d *elementDao) ListEnabledByIdsCached(ctx context.Context, ids []int64) ([]*entity.Element, error) {
+ if len(ids) == 0 {
+ return []*entity.Element{}, nil
+ }
+ return common.GetList[entity.Element](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ WhereIn("id", ids).Where("status", consts.StatusEnabled))
+}
+
+// ListAll 元素列表(eType=0 全部),按类型 + 序号排序。
+func (d *elementDao) ListAll(ctx context.Context, eType int) ([]*entity.Element, error) {
+ m := d.Model().Ctx(ctx)
+ if eType > 0 {
+ m = m.Where("e_type", eType)
+ }
+ return common.GetList[entity.Element](m.Order("e_type ASC, sort_order ASC"))
+}
+
+// ListByIds 按 id 批量取(含下架,无缓存)。
+func (d *elementDao) ListByIds(ctx context.Context, ids []int64) ([]*entity.Element, error) {
+ if len(ids) == 0 {
+ return []*entity.Element{}, nil
+ }
+ return common.GetList[entity.Element](d.Model().Ctx(ctx).WhereIn("id", ids))
+}
diff --git a/biz/dao/dao_level.go b/biz/dao/dao_level.go
index 52b40b8..645d526 100644
--- a/biz/dao/dao_level.go
+++ b/biz/dao/dao_level.go
@@ -3,9 +3,11 @@ package dao
import (
"context"
+ "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -33,3 +35,81 @@ CREATE INDEX IF NOT EXISTS idx_level_strategy ON level(strategy_id, sort_order);
common.EnsureColumn(ctx, consts.TableLevel, "scene_content_pinyin", "TEXT")
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *levelDao) GetByPk(ctx context.Context, id int64) (*entity.Level, error) {
+ return common.GetOne[entity.Level](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// GetByPkCached 主键查询(内容缓存);不存在返回 nil, nil。
+func (d *levelDao) GetByPkCached(ctx context.Context, id int64) (*entity.Level, error) {
+ return common.GetOne[entity.Level](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).WherePri(id))
+}
+
+// ListEnabledByStrategyIds 指定计策下、匹配年龄段且启用的关卡(内容缓存)。
+func (d *levelDao) ListEnabledByStrategyIds(ctx context.Context, strategyIds []int64, ageGroup string) ([]*entity.Level, error) {
+ if len(strategyIds) == 0 {
+ return []*entity.Level{}, nil
+ }
+ return common.GetList[entity.Level](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ WhereIn("strategy_id", strategyIds).Where("status", consts.StatusEnabled).
+ Where("age_group", ageGroup).Order("sort_order ASC"))
+}
+
+// ListByStrategyId 指定计策下全部关卡(含下架,后台全量展示),按序号排序。
+func (d *levelDao) ListByStrategyId(ctx context.Context, strategyId int64) ([]*entity.Level, error) {
+ return common.GetList[entity.Level](d.Model().Ctx(ctx).Where("strategy_id", strategyId).Order("sort_order ASC"))
+}
+
+// ListByIds 按 id 批量取(含下架)。
+func (d *levelDao) ListByIds(ctx context.Context, ids []int64) ([]*entity.Level, error) {
+ if len(ids) == 0 {
+ return []*entity.Level{}, nil
+ }
+ return common.GetList[entity.Level](d.Model().Ctx(ctx).WhereIn("id", ids))
+}
+
+// ListEnabledByStrategyInTx 事务内取指定计策下启用关卡(完美判定用)。
+func (d *levelDao) ListEnabledByStrategyInTx(ctx context.Context, tx gdb.TX, strategyId int64) ([]*entity.Level, error) {
+ recs, err := tx.Model(consts.TableLevel).Ctx(ctx).
+ Where("strategy_id", strategyId).Where("status", consts.StatusEnabled).All()
+ if err != nil {
+ return nil, err
+ }
+ if len(recs) == 0 {
+ return []*entity.Level{}, nil
+ }
+ var items []*entity.Level
+ if err = recs.Structs(&items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+// CountEnabledByStrategyIds 各计策启用关卡数(单表 GROUP BY 聚合)。
+func (d *levelDao) CountEnabledByStrategyIds(ctx context.Context, strategyIds []int64) (map[int64]int, error) {
+ m := make(map[int64]int, len(strategyIds))
+ if len(strategyIds) == 0 {
+ return m, nil
+ }
+ recs, err := d.Model().Ctx(ctx).WhereIn("strategy_id", strategyIds).
+ Where("status", consts.StatusEnabled).Group("strategy_id").
+ Fields("strategy_id, COUNT(*) AS cnt").All()
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r["strategy_id"].Int64()] = r["cnt"].Int()
+ }
+ return m, nil
+}
+
+// ProgressStatsByLevel 关卡参与孩子数与完美通关数(单表聚合)。
+func (d *levelDao) ProgressStatsByLevel(ctx context.Context, levelId int64) (players, perfectCount int, err error) {
+ rec, err := d.Model().Ctx(ctx).Fields("COUNT(*) AS total, COALESCE(SUM(perfect), 0) AS perfect").
+ Where("level_id", levelId).One()
+ if err != nil {
+ return 0, 0, err
+ }
+ return rec["total"].Int(), rec["perfect"].Int(), nil
+}
diff --git a/biz/dao/dao_life_task.go b/biz/dao/dao_life_task.go
index e5cc78a..5dca657 100644
--- a/biz/dao/dao_life_task.go
+++ b/biz/dao/dao_life_task.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -27,3 +28,19 @@ CREATE TABLE IF NOT EXISTS life_task (
CREATE INDEX IF NOT EXISTS idx_life_task_strategy ON life_task(strategy_id);`)
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *lifeTaskDao) GetByPk(ctx context.Context, id int64) (*entity.LifeTask, error) {
+ return common.GetOne[entity.LifeTask](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// ListEnabledCached 启用任务(内容缓存),按 id 排序。
+func (d *lifeTaskDao) ListEnabledCached(ctx context.Context) ([]*entity.LifeTask, error) {
+ return common.GetList[entity.LifeTask](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("status", consts.StatusEnabled).Order("id ASC"))
+}
+
+// ListAll 全部生活任务(含下架),按 id 排序。
+func (d *lifeTaskDao) ListAll(ctx context.Context) ([]*entity.LifeTask, error) {
+ return common.GetList[entity.LifeTask](d.Model().Ctx(ctx).Order("id ASC"))
+}
diff --git a/biz/dao/dao_node_option.go b/biz/dao/dao_node_option.go
index 0109695..7194624 100644
--- a/biz/dao/dao_node_option.go
+++ b/biz/dao/dao_node_option.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -38,3 +39,68 @@ CREATE INDEX IF NOT EXISTS idx_option_node ON node_option(node_id);`)
common.EnsureColumn(ctx, consts.TableNodeOption, "feedback_cons", "TEXT")
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *nodeOptionDao) GetByPk(ctx context.Context, id int64) (*entity.NodeOption, error) {
+ return common.GetOne[entity.NodeOption](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// GetInNodeCached 节点下启用选项(内容缓存);不存在返回 nil, nil。
+func (d *nodeOptionDao) GetInNodeCached(ctx context.Context, optionId, nodeId int64) (*entity.NodeOption, error) {
+ return common.GetOne[entity.NodeOption](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("id", optionId).Where("node_id", nodeId).Where("status", consts.StatusEnabled))
+}
+
+// ListEnabledByNodeCached 节点下启用选项(内容缓存),按 sort_order 升序。
+func (d *nodeOptionDao) ListEnabledByNodeCached(ctx context.Context, nodeId int64) ([]*entity.NodeOption, error) {
+ return common.GetList[entity.NodeOption](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("node_id", nodeId).Where("status", consts.StatusEnabled).Order("sort_order ASC"))
+}
+
+// ListEnabledByNodeIdsCached 批量节点下启用选项(内容缓存),按 sort_order 升序。
+func (d *nodeOptionDao) ListEnabledByNodeIdsCached(ctx context.Context, nodeIds []int64) ([]*entity.NodeOption, error) {
+ if len(nodeIds) == 0 {
+ return []*entity.NodeOption{}, nil
+ }
+ return common.GetList[entity.NodeOption](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ WhereIn("node_id", nodeIds).Where("status", consts.StatusEnabled).Order("sort_order ASC"))
+}
+
+// ListByOptionIds 按选项 id 批量取(终局判定用,不缓存)。
+func (d *nodeOptionDao) ListByOptionIds(ctx context.Context, optionIds []int64) ([]*entity.NodeOption, error) {
+ if len(optionIds) == 0 {
+ return []*entity.NodeOption{}, nil
+ }
+ return common.GetList[entity.NodeOption](d.Model().Ctx(ctx).WhereIn("id", optionIds))
+}
+
+// ListByNode 节点下全部选项(含下架),按序号排序。
+func (d *nodeOptionDao) ListByNode(ctx context.Context, nodeId int64) ([]*entity.NodeOption, error) {
+ return common.GetList[entity.NodeOption](d.Model().Ctx(ctx).Where("node_id", nodeId).Order("sort_order ASC"))
+}
+
+// ListByNodeIds 按节点 id 批量取(环检测用,不缓存)。
+func (d *nodeOptionDao) ListByNodeIds(ctx context.Context, nodeIds []int64) ([]*entity.NodeOption, error) {
+ if len(nodeIds) == 0 {
+ return []*entity.NodeOption{}, nil
+ }
+ return common.GetList[entity.NodeOption](d.Model().Ctx(ctx).
+ WhereIn("node_id", nodeIds).Order("node_id ASC, sort_order ASC"))
+}
+
+// CountByNodeIds 各节点选项数(单表 GROUP BY 聚合)。
+func (d *nodeOptionDao) CountByNodeIds(ctx context.Context, nodeIds []int64) (map[int64]int, error) {
+ m := make(map[int64]int, len(nodeIds))
+ if len(nodeIds) == 0 {
+ return m, nil
+ }
+ recs, err := d.Model().Ctx(ctx).WhereIn("node_id", nodeIds).Group("node_id").
+ Fields("node_id, COUNT(*) AS cnt").All()
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r["node_id"].Int64()] = r["cnt"].Int()
+ }
+ return m, nil
+}
diff --git a/biz/dao/dao_parent.go b/biz/dao/dao_parent.go
index 46a5e82..e6bef1b 100644
--- a/biz/dao/dao_parent.go
+++ b/biz/dao/dao_parent.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -29,3 +30,13 @@ CREATE TABLE IF NOT EXISTS parent (
CREATE INDEX IF NOT EXISTS idx_parent_openid ON parent(openid);`)
return err
}
+
+// GetByPhone 按手机号查询(无缓存);不存在返回 nil, nil。
+func (d *parentDao) GetByPhone(ctx context.Context, phone string) (*entity.Parent, error) {
+ return common.GetOne[entity.Parent](d.Model().Ctx(ctx).Where("phone", phone))
+}
+
+// ListAll 全部家长,按 id 倒序。
+func (d *parentDao) ListAll(ctx context.Context) ([]*entity.Parent, error) {
+ return common.GetList[entity.Parent](d.Model().Ctx(ctx).Order("id DESC"))
+}
diff --git a/biz/dao/dao_prize.go b/biz/dao/dao_prize.go
index 58063aa..5f60953 100644
--- a/biz/dao/dao_prize.go
+++ b/biz/dao/dao_prize.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -29,3 +30,27 @@ CREATE TABLE IF NOT EXISTS prize (
CREATE INDEX IF NOT EXISTS idx_prize_status ON prize(status, sort_order);`)
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *prizeDao) GetByPk(ctx context.Context, id int64) (*entity.Prize, error) {
+ return common.GetOne[entity.Prize](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// ListEnabledCached 启用奖品(内容缓存),按 sort_order 升序。
+func (d *prizeDao) ListEnabledCached(ctx context.Context) ([]*entity.Prize, error) {
+ return common.GetList[entity.Prize](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("status", consts.StatusEnabled).Order("sort_order ASC"))
+}
+
+// ListAll 全部奖品(含下架),按类型 + 序号排序。
+func (d *prizeDao) ListAll(ctx context.Context) ([]*entity.Prize, error) {
+ return common.GetList[entity.Prize](d.Model().Ctx(ctx).Order("p_type ASC, sort_order ASC"))
+}
+
+// ListByIds 按 id 批量取(无缓存)。
+func (d *prizeDao) ListByIds(ctx context.Context, ids []int64) ([]*entity.Prize, error) {
+ if len(ids) == 0 {
+ return []*entity.Prize{}, nil
+ }
+ return common.GetList[entity.Prize](d.Model().Ctx(ctx).WhereIn("id", ids))
+}
diff --git a/biz/dao/dao_redemption.go b/biz/dao/dao_redemption.go
index 897b4e2..2cff46d 100644
--- a/biz/dao/dao_redemption.go
+++ b/biz/dao/dao_redemption.go
@@ -3,9 +3,11 @@ package dao
import (
"context"
+ "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -28,3 +30,41 @@ CREATE TABLE IF NOT EXISTS redemption (
CREATE INDEX IF NOT EXISTS idx_redemption_user ON redemption(user_id, created_at);`)
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *redemptionDao) GetByPk(ctx context.Context, id int64) (*entity.Redemption, error) {
+ return common.GetOne[entity.Redemption](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// GetByPkInTx 事务内主键查询;不存在返回 nil, nil。
+func (d *redemptionDao) GetByPkInTx(ctx context.Context, tx gdb.TX, id int64) (*entity.Redemption, error) {
+ rec, err := tx.Model(consts.TableRedemption).Ctx(ctx).WherePri(id).One()
+ if err != nil {
+ return nil, err
+ }
+ if rec.IsEmpty() {
+ return nil, nil
+ }
+ dst := &entity.Redemption{}
+ if err = rec.Struct(dst); err != nil {
+ return nil, err
+ }
+ return dst, nil
+}
+
+// ListByUser 孩子兑换记录(按创建时间倒序)。
+func (d *redemptionDao) ListByUser(ctx context.Context, userId int64) ([]*entity.Redemption, error) {
+ return common.GetList[entity.Redemption](d.Model().Ctx(ctx).Where("user_id", userId).Order("id DESC"))
+}
+
+// List 兑换记录(status/prizeId 可选过滤),按创建时间倒序。
+func (d *redemptionDao) List(ctx context.Context, status int, prizeId int64) ([]*entity.Redemption, error) {
+ m := d.Model().Ctx(ctx)
+ if status > 0 {
+ m = m.Where("status", status)
+ }
+ if prizeId > 0 {
+ m = m.Where("prize_id", prizeId)
+ }
+ return common.GetList[entity.Redemption](m.Order("id DESC"))
+}
diff --git a/biz/dao/dao_scene_node.go b/biz/dao/dao_scene_node.go
index a4a9bd4..c9ce74d 100644
--- a/biz/dao/dao_scene_node.go
+++ b/biz/dao/dao_scene_node.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -38,3 +39,56 @@ CREATE INDEX IF NOT EXISTS idx_node_level ON scene_node(level_id);`)
common.EnsureColumn(ctx, consts.TableSceneNode, "script", "TEXT")
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *sceneNodeDao) GetByPk(ctx context.Context, id int64) (*entity.SceneNode, error) {
+ return common.GetOne[entity.SceneNode](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// GetInLevelCached 关卡内启用节点(内容缓存);不存在返回 nil, nil。
+func (d *sceneNodeDao) GetInLevelCached(ctx context.Context, nodeId, levelId int64) (*entity.SceneNode, error) {
+ return common.GetOne[entity.SceneNode](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("id", nodeId).Where("level_id", levelId).Where("status", consts.StatusEnabled))
+}
+
+// ListEnabledByLevelCached 关卡内启用节点(内容缓存),按 sort_order 升序。
+func (d *sceneNodeDao) ListEnabledByLevelCached(ctx context.Context, levelId int64) ([]*entity.SceneNode, error) {
+ return common.GetList[entity.SceneNode](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("level_id", levelId).Where("status", consts.StatusEnabled).Order("sort_order ASC"))
+}
+
+// CountFinalsByLevel 关卡内终局节点数(result_type > 0,不缓存)。
+func (d *sceneNodeDao) CountFinalsByLevel(ctx context.Context, levelId int64) (int, error) {
+ return d.Model().Ctx(ctx).Where("level_id", levelId).
+ Where("status", consts.StatusEnabled).WhereGT("result_type", 0).Count()
+}
+
+// ListByLevel 关卡下全部节点(含下架),按序号排序。
+func (d *sceneNodeDao) ListByLevel(ctx context.Context, levelId int64) ([]*entity.SceneNode, error) {
+ return common.GetList[entity.SceneNode](d.Model().Ctx(ctx).Where("level_id", levelId).Order("sort_order ASC"))
+}
+
+// ListByIds 按 id 批量取(无缓存)。
+func (d *sceneNodeDao) ListByIds(ctx context.Context, ids []int64) ([]*entity.SceneNode, error) {
+ if len(ids) == 0 {
+ return []*entity.SceneNode{}, nil
+ }
+ return common.GetList[entity.SceneNode](d.Model().Ctx(ctx).WhereIn("id", ids))
+}
+
+// CountByLevelIds 各关卡节点数(单表 GROUP BY 聚合)。
+func (d *sceneNodeDao) CountByLevelIds(ctx context.Context, levelIds []int64) (map[int64]int, error) {
+ m := make(map[int64]int, len(levelIds))
+ if len(levelIds) == 0 {
+ return m, nil
+ }
+ recs, err := d.Model().Ctx(ctx).WhereIn("level_id", levelIds).Group("level_id").
+ Fields("level_id, COUNT(*) AS cnt").All()
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r["level_id"].Int64()] = r["cnt"].Int()
+ }
+ return m, nil
+}
diff --git a/biz/dao/dao_strategy.go b/biz/dao/dao_strategy.go
index 109c65c..c1948b6 100644
--- a/biz/dao/dao_strategy.go
+++ b/biz/dao/dao_strategy.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -44,3 +45,37 @@ CREATE INDEX IF NOT EXISTS idx_strategy_group ON strategy(group_no, sort_order);
common.EnsureColumn(ctx, consts.TableStrategy, "summary_options_pinyin", "TEXT")
return err
}
+
+// GetByPk 主键查询(无缓存);不存在返回 nil, nil。
+func (d *strategyDao) GetByPk(ctx context.Context, id int64) (*entity.Strategy, error) {
+ return common.GetOne[entity.Strategy](d.Model().Ctx(ctx).WherePri(id))
+}
+
+// GetByPkCached 主键查询(内容缓存);不存在返回 nil, nil。
+func (d *strategyDao) GetByPkCached(ctx context.Context, id int64) (*entity.Strategy, error) {
+ return common.GetOne[entity.Strategy](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).WherePri(id))
+}
+
+// ListEnabled 启用计策(内容缓存),按分组 + 组内序号排序。
+func (d *strategyDao) ListEnabled(ctx context.Context) ([]*entity.Strategy, error) {
+ return common.GetList[entity.Strategy](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).
+ Where("status", consts.StatusEnabled).Order("group_no ASC, sort_order ASC"))
+}
+
+// ListAll 全部计策(含下架),按分组 + 组内序号排序。
+func (d *strategyDao) ListAll(ctx context.Context) ([]*entity.Strategy, error) {
+ return common.GetList[entity.Strategy](d.Model().Ctx(ctx).Order("group_no ASC, sort_order ASC"))
+}
+
+// ListByIds 按 id 批量取(无缓存)。
+func (d *strategyDao) ListByIds(ctx context.Context, ids []int64) ([]*entity.Strategy, error) {
+ if len(ids) == 0 {
+ return []*entity.Strategy{}, nil
+ }
+ return common.GetList[entity.Strategy](d.Model().Ctx(ctx).WhereIn("id", ids))
+}
+
+// GetByUnlockBefore 按解锁前置计策反查下一计(内容缓存);不存在返回 nil, nil。
+func (d *strategyDao) GetByUnlockBefore(ctx context.Context, strategyId int64) (*entity.Strategy, error) {
+ return common.GetOne[entity.Strategy](d.Model().Ctx(ctx).Cache(d.ContentCache(ctx)).Where("unlock_before", strategyId))
+}
diff --git a/biz/dao/dao_user_badge.go b/biz/dao/dao_user_badge.go
index e5b924b..8d312fe 100644
--- a/biz/dao/dao_user_badge.go
+++ b/biz/dao/dao_user_badge.go
@@ -6,6 +6,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -24,3 +25,8 @@ CREATE TABLE IF NOT EXISTS user_badge (
);`)
return err
}
+
+// ListByUser 孩子已获徽章(不缓存),按 id 升序。
+func (d *userBadgeDao) ListByUser(ctx context.Context, userId int64) ([]*entity.UserBadge, error) {
+ return common.GetList[entity.UserBadge](d.Model().Ctx(ctx).Where("user_id", userId).Order("id ASC"))
+}
diff --git a/biz/dao/dao_user_collection.go b/biz/dao/dao_user_collection.go
index fc278ce..9c9b037 100644
--- a/biz/dao/dao_user_collection.go
+++ b/biz/dao/dao_user_collection.go
@@ -3,9 +3,11 @@ package dao
import (
"context"
+ "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -24,3 +26,24 @@ CREATE TABLE IF NOT EXISTS user_collection (
);`)
return err
}
+
+// ExistsInTx 事务内判断孩子是否已收集该计策。
+func (d *userCollectionDao) ExistsInTx(ctx context.Context, tx gdb.TX, userId, strategyId int64) (bool, error) {
+ n, err := tx.Model(consts.TableUserCollection).Ctx(ctx).
+ Where("user_id", userId).Where("strategy_id", strategyId).Count()
+ return n > 0, err
+}
+
+// InsertInTx 事务内写入计策卡收集。
+func (d *userCollectionDao) InsertInTx(ctx context.Context, tx gdb.TX, userId, strategyId int64) error {
+ _, err := tx.Model(consts.TableUserCollection).Ctx(ctx).Data(g.Map{
+ "user_id": userId,
+ "strategy_id": strategyId,
+ }).Insert()
+ return err
+}
+
+// ListByUser 孩子已收集计策列表(不缓存)。
+func (d *userCollectionDao) ListByUser(ctx context.Context, userId int64) ([]*entity.UserCollection, error) {
+ return common.GetList[entity.UserCollection](d.Model().Ctx(ctx).Where("user_id", userId))
+}
diff --git a/biz/dao/dao_user_progress.go b/biz/dao/dao_user_progress.go
index a0474ba..2861bb4 100644
--- a/biz/dao/dao_user_progress.go
+++ b/biz/dao/dao_user_progress.go
@@ -3,9 +3,12 @@ package dao
import (
"context"
+ "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
@@ -25,6 +28,109 @@ CREATE TABLE IF NOT EXISTS user_progress (
content_version INTEGER NOT NULL DEFAULT 1,
completed_at DATETIME,
UNIQUE(child_id, level_id)
-);`)
+);
+CREATE INDEX IF NOT EXISTS idx_progress_level ON user_progress(level_id);`)
+ return err
+}
+
+// GetByChildLevel 孩子某关进度(不缓存);不存在返回 nil, nil。
+func (d *userProgressDao) GetByChildLevel(ctx context.Context, childId, levelId int64) (*entity.UserProgress, error) {
+ return common.GetOne[entity.UserProgress](d.Model().Ctx(ctx).
+ Where("child_id", childId).Where("level_id", levelId))
+}
+
+// ListByChildLevelIds 孩子多关进度(不缓存)。
+func (d *userProgressDao) ListByChildLevelIds(ctx context.Context, childId int64, levelIds []int64) ([]*entity.UserProgress, error) {
+ if len(levelIds) == 0 {
+ return []*entity.UserProgress{}, nil
+ }
+ return common.GetList[entity.UserProgress](d.Model().Ctx(ctx).
+ Where("child_id", childId).WhereIn("level_id", levelIds))
+}
+
+// ListByChild 孩子全部闯关进度(不缓存),按 level_id 升序。
+func (d *userProgressDao) ListByChild(ctx context.Context, childId int64) ([]*entity.UserProgress, error) {
+ return common.GetList[entity.UserProgress](d.Model().Ctx(ctx).
+ Where("child_id", childId).Order("level_id ASC"))
+}
+
+// CountPerfectByChild 孩子完美通关关卡数。
+func (d *userProgressDao) CountPerfectByChild(ctx context.Context, childId int64) (int, error) {
+ return d.Model().Ctx(ctx).Where("child_id", childId).Where("perfect", 1).Count()
+}
+
+// CountPerfectByChildIds 批量孩子的完美通关数(按 child_id 分组聚合)。
+func (d *userProgressDao) CountPerfectByChildIds(ctx context.Context, childIds []int64) (map[int64]int, error) {
+ m := make(map[int64]int, len(childIds))
+ if len(childIds) == 0 {
+ return m, nil
+ }
+ recs, err := d.Model().Ctx(ctx).Fields("child_id", "COUNT(*) AS cnt").
+ Where("perfect", 1).WhereIn("child_id", childIds).Group("child_id").All()
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r["child_id"].Int64()] = r["cnt"].Int()
+ }
+ return m, nil
+}
+
+// ProgressStatsByLevel 关卡参与人数与完美通关数(单表聚合)。
+func (d *userProgressDao) ProgressStatsByLevel(ctx context.Context, levelId int64) (players, perfectCount int, err error) {
+ rec, err := d.Model().Ctx(ctx).
+ Fields("COUNT(*) AS players, SUM(CASE WHEN perfect = 1 THEN 1 ELSE 0 END) AS perfect_cnt").
+ Where("level_id", levelId).One()
+ if err != nil {
+ return 0, 0, err
+ }
+ if rec.IsEmpty() {
+ return 0, 0, nil
+ }
+ return rec["players"].Int(), rec["perfect_cnt"].Int(), nil
+}
+
+// GetInTx 事务内读孩子某关进度;不存在返回 nil, nil。
+func (d *userProgressDao) GetInTx(ctx context.Context, tx gdb.TX, childId, levelId int64) (*entity.UserProgress, error) {
+ rec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
+ Where("child_id", childId).Where("level_id", levelId).One()
+ if err != nil {
+ return nil, err
+ }
+ if rec.IsEmpty() {
+ return nil, nil
+ }
+ dst := &entity.UserProgress{}
+ if err = rec.Struct(dst); err != nil {
+ return nil, err
+ }
+ return dst, nil
+}
+
+// UpsertInTx 事务内合并写入进度:星星取历史最高、完美取并集;不存在则插入。
+func (d *userProgressDao) UpsertInTx(ctx context.Context, tx gdb.TX, childId, levelId int64, stars, perfect, contentVersion int) error {
+ rec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
+ Where("child_id", childId).Where("level_id", levelId).One()
+ if err != nil {
+ return err
+ }
+ if stars < rec["stars"].Int() {
+ stars = rec["stars"].Int()
+ }
+ perfect = rec["perfect"].Int() | perfect
+ data := g.Map{
+ "stars": stars,
+ "perfect": perfect,
+ "content_version": contentVersion,
+ "completed_at": gtime.Now(),
+ }
+ if rec.IsEmpty() {
+ data["child_id"] = childId
+ data["level_id"] = levelId
+ _, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(data).Insert()
+ } else {
+ _, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(data).
+ Where("child_id", childId).Where("level_id", levelId).Update()
+ }
return err
}
diff --git a/biz/dao/dao_user_route_log.go b/biz/dao/dao_user_route_log.go
index 8863bc3..fcb05b1 100644
--- a/biz/dao/dao_user_route_log.go
+++ b/biz/dao/dao_user_route_log.go
@@ -6,9 +6,18 @@ import (
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
)
+// RouteStat 路径流水聚合行(关卡统计用,单表 GROUP BY 结果)。
+type RouteStat struct {
+ NodeId int64 `json:"node_id" orm:"node_id"`
+ OptionId int64 `json:"option_id" orm:"option_id"`
+ ResultType int `json:"result_type" orm:"result_type"`
+ Cnt int `json:"cnt" orm:"cnt"`
+}
+
type userRouteLogDao struct{ common.BaseDao }
var UserRouteLog = &userRouteLogDao{BaseDao: common.BaseDao{Table: consts.TableUserRouteLog}}
@@ -24,6 +33,31 @@ CREATE TABLE IF NOT EXISTS user_route_log (
result_type INTEGER NOT NULL DEFAULT 0,
created_at DATETIME
);
-CREATE INDEX IF NOT EXISTS idx_route_log_child ON user_route_log(child_id, level_id, created_at);`)
+CREATE INDEX IF NOT EXISTS idx_route_log_child ON user_route_log(child_id, level_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_route_log_level ON user_route_log(level_id, node_id, option_id);`)
return err
}
+
+// ListFinalsByChildLevel 孩子某关的终局路径(result_type > 0,按 id 升序,不缓存)。
+func (d *userRouteLogDao) ListFinalsByChildLevel(ctx context.Context, childId, levelId int64) ([]*entity.UserRouteLog, error) {
+ return common.GetList[entity.UserRouteLog](d.Model().Ctx(ctx).
+ Where("child_id", childId).Where("level_id", levelId).
+ WhereGT("result_type", 0).Order("id ASC"))
+}
+
+// RouteStatsByLevel 关卡路径流水聚合(node × option × result_type 计数,不缓存)。
+func (d *userRouteLogDao) RouteStatsByLevel(ctx context.Context, levelId int64) ([]*RouteStat, error) {
+ recs, err := d.Model().Ctx(ctx).Fields("node_id, option_id, result_type, COUNT(*) AS cnt").
+ Where("level_id", levelId).Group("node_id, option_id, result_type").All()
+ if err != nil {
+ return nil, err
+ }
+ if len(recs) == 0 {
+ return []*RouteStat{}, nil
+ }
+ var items []*RouteStat
+ if err = recs.Structs(&items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/biz/model/domain/level.go b/biz/model/domain/level.go
new file mode 100644
index 0000000..9e54deb
--- /dev/null
+++ b/biz/model/domain/level.go
@@ -0,0 +1,11 @@
+package domain
+
+// SettleState 结算输入:该关历史状态(由进度 + 路径流水推导,锁内读取)。
+type SettleState struct {
+ LevelCleared bool // 该关已通关(到过最佳终局)
+ FailStreak int // 连续失败终局次数(非失败终局打断连续)
+ ReachedFinals map[int64]bool // 已到达终局节点 id 集合
+ TotalFinals int // 该关终局节点总数
+ PerfectAwarded bool // 完美奖励是否已发放(进度表为准)
+ BalanceAfter int // 结算前余额
+}
diff --git a/biz/model/dto/admin_user.go b/biz/model/dto/admin_user.go
index 76d3a17..ff8543b 100644
--- a/biz/model/dto/admin_user.go
+++ b/biz/model/dto/admin_user.go
@@ -1 +1,26 @@
package dto
+
+import (
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/net/ghttp"
+)
+
+type AdminLoginReq struct {
+ g.Meta `path:"/login" method:"post" summary:"管理员登录"`
+ Username string `v:"required|length:1,64" json:"username"`
+ Password string `v:"required|length:6,64" json:"password"`
+}
+
+type AdminLoginRes struct {
+ Token string `json:"token"`
+ AdminId int64 `json:"admin_id"`
+}
+
+type AdminUploadReq struct {
+ g.Meta `path:"/upload" method:"post" summary:"素材上传" consumes:"multipart/form-data"`
+ File *ghttp.UploadFile `json:"file" v:"required"`
+}
+
+type AdminUploadRes struct {
+ Url string `json:"url"`
+}
diff --git a/biz/model/dto/badge.go b/biz/model/dto/badge.go
index 76d3a17..6da8893 100644
--- a/biz/model/dto/badge.go
+++ b/biz/model/dto/badge.go
@@ -1 +1,59 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ---------- 后台管理 ----------
+
+type AdminBadgeItem struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ CondType int `json:"cond_type"`
+ CondValue int `json:"cond_value"`
+ Status int `json:"status"`
+}
+
+type AdminBadgeListReq struct {
+ g.Meta `path:"/badge/list" method:"get" summary:"徽章列表"`
+}
+
+type AdminBadgeListRes struct {
+ List []*AdminBadgeItem `json:"list"`
+}
+
+type AdminBadgeCreateReq struct {
+ g.Meta `path:"/badge/create" method:"post" summary:"新增徽章"`
+ Name string `v:"required|length:1,64" json:"name"`
+ Icon string `json:"icon"`
+ CondType int `v:"required|in:1,2,3,4,5,6" json:"cond_type"`
+ CondValue int `v:"required|min:1" json:"cond_value"`
+}
+
+type AdminBadgeCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminBadgeUpdateReq struct {
+ g.Meta `path:"/badge/update" method:"post" summary:"编辑徽章"`
+ Id int64 `v:"required|min:1" json:"id"`
+ Name string `v:"required|length:1,64" json:"name"`
+ Icon string `json:"icon"`
+ CondType int `v:"required|in:1,2,3,4,5,6" json:"cond_type"`
+ CondValue int `v:"required|min:1" json:"cond_value"`
+}
+
+type AdminBadgeUpdateRes struct{}
+
+type AdminBadgeDisableReq struct {
+ g.Meta `path:"/badge/disable" method:"post" summary:"下架徽章"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminBadgeDisableRes struct{}
+
+type AdminBadgeEnableReq struct {
+ g.Meta `path:"/badge/enable" method:"post" summary:"上架徽章"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminBadgeEnableRes struct{}
diff --git a/biz/model/dto/child.go b/biz/model/dto/child.go
index 824d3d1..2a57c75 100644
--- a/biz/model/dto/child.go
+++ b/biz/model/dto/child.go
@@ -42,3 +42,53 @@ type ChildListItem struct {
type ChildListRes struct {
List []ChildListItem `json:"list"`
}
+
+// ---------- 后台管理 ----------
+
+type AdminChildItem struct {
+ Id int64 `json:"id"`
+ ParentId int64 `json:"parent_id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ AgeGroup string `json:"age_group"`
+ Points int `json:"points"`
+ DailyLimitMinutes int `json:"daily_limit_minutes"`
+ Status int `json:"status"`
+ CreatedAt string `json:"created_at"`
+ PerfectCount int `json:"perfect_count"`
+ Level int `json:"level"`
+ LevelTitle string `json:"level_title"`
+}
+
+type AdminChildListReq struct {
+ g.Meta `path:"/child/list" method:"get" summary:"孩子列表"`
+ ParentId int64 `json:"parent_id"` // 可选,按家长筛
+}
+
+type AdminChildListRes struct {
+ List []*AdminChildItem `json:"list"`
+}
+
+type AdminProgressItem struct {
+ LevelId int64 `json:"level_id"`
+ LevelTitle string `json:"level_title"`
+ StrategyId int64 `json:"strategy_id"`
+ StrategyName string `json:"strategy_name"`
+ Stars int `json:"stars"`
+ Perfect bool `json:"perfect"`
+ ContentVersion int `json:"content_version"`
+ CompletedAt string `json:"completed_at"`
+}
+
+type AdminChildDetailReq struct {
+ g.Meta `path:"/child/detail" method:"get" summary:"孩子详情"`
+ ChildId int64 `v:"required|min:1" json:"child_id"`
+}
+
+type AdminChildDetailRes struct {
+ Child *AdminChildItem `json:"child"`
+ Passed int `json:"passed"` // 通关关数
+ Perfect int `json:"perfect"` // 完美关数
+ Stars int `json:"stars"` // 星星总数
+ Progress []*AdminProgressItem `json:"progress"`
+}
diff --git a/biz/model/dto/element.go b/biz/model/dto/element.go
index 76d3a17..179b852 100644
--- a/biz/model/dto/element.go
+++ b/biz/model/dto/element.go
@@ -1 +1,64 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+type AdminElementItem struct {
+ Id int64 `json:"id"`
+ EType int `json:"e_type"`
+ Name string `json:"name"`
+ Image string `json:"image"`
+ Audio string `json:"audio"`
+ Description string `json:"description"`
+ Status int `json:"status"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminElementListReq struct {
+ g.Meta `path:"/element/list" method:"get" summary:"元素库列表"`
+ EType int `json:"e_type" v:"in:0,1,2,3"` // 0=全部,1=场景,2=人物,3=道具
+}
+
+type AdminElementListRes struct {
+ List []*AdminElementItem `json:"list"`
+}
+
+type AdminElementCreateReq struct {
+ g.Meta `path:"/element/create" method:"post" summary:"新增元素"`
+ EType int `v:"required|in:1,2,3" json:"e_type"`
+ Name string `v:"required|length:1,64" json:"name"`
+ Image string `json:"image"`
+ Audio string `json:"audio"`
+ Description string `json:"description"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminElementCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminElementUpdateReq struct {
+ g.Meta `path:"/element/update" method:"post" summary:"编辑元素"`
+ Id int64 `v:"required|min:1" json:"id"`
+ EType int `v:"required|in:1,2,3" json:"e_type"`
+ Name string `v:"required|length:1,64" json:"name"`
+ Image string `json:"image"`
+ Audio string `json:"audio"`
+ Description string `json:"description"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminElementUpdateRes struct{}
+
+type AdminElementDisableReq struct {
+ g.Meta `path:"/element/disable" method:"post" summary:"下架元素"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminElementDisableRes struct{}
+
+type AdminElementEnableReq struct {
+ g.Meta `path:"/element/enable" method:"post" summary:"上架元素"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminElementEnableRes struct{}
diff --git a/biz/model/dto/level.go b/biz/model/dto/level.go
index 6172931..5c9ac7c 100644
--- a/biz/model/dto/level.go
+++ b/biz/model/dto/level.go
@@ -83,3 +83,111 @@ type FinalSettle struct {
BalanceAfter int `json:"balance_after"`
FinalNode *NodeVO `json:"final_node"`
}
+
+// ---------- 后台管理 ----------
+
+type AdminLevelItem struct {
+ Id int64 `json:"id"`
+ StrategyId int64 `json:"strategy_id"`
+ Title string `json:"title"`
+ SceneId int64 `json:"scene_id"`
+ SceneName string `json:"scene_name"`
+ SceneContent string `json:"scene_content"`
+ SceneImage string `json:"scene_image"`
+ SceneAudio string `json:"scene_audio"`
+ AgeGroup string `json:"age_group"`
+ ContentVersion int `json:"content_version"`
+ SortOrder int `json:"sort_order"`
+ Status int `json:"status"`
+ NodeCount int `json:"node_count"`
+}
+
+type AdminLevelListReq struct {
+ g.Meta `path:"/level/list" method:"get" summary:"关卡列表(按计策)"`
+ StrategyId int64 `v:"required|min:1" json:"strategy_id"`
+}
+
+type AdminLevelListRes struct {
+ List []*AdminLevelItem `json:"list"`
+}
+
+type AdminLevelCreateReq struct {
+ g.Meta `path:"/level/create" method:"post" summary:"新增关卡"`
+ StrategyId int64 `v:"required|min:1" json:"strategy_id"`
+ Title string `v:"required|length:1,64" json:"title"`
+ SceneId int64 `json:"scene_id"`
+ SceneContent string `json:"scene_content"`
+ SceneImage string `json:"scene_image"`
+ SceneAudio string `json:"scene_audio"`
+ AgeGroup string `v:"required|in:4-6,6-8" json:"age_group"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminLevelCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminLevelUpdateReq struct {
+ g.Meta `path:"/level/update" method:"post" summary:"编辑关卡"`
+ Id int64 `v:"required|min:1" json:"id"`
+ StrategyId int64 `v:"required|min:1" json:"strategy_id"`
+ Title string `v:"required|length:1,64" json:"title"`
+ SceneId int64 `json:"scene_id"`
+ SceneContent string `json:"scene_content"`
+ SceneImage string `json:"scene_image"`
+ SceneAudio string `json:"scene_audio"`
+ AgeGroup string `v:"required|in:4-6,6-8" json:"age_group"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminLevelUpdateRes struct{}
+
+type AdminLevelDisableReq struct {
+ g.Meta `path:"/level/disable" method:"post" summary:"下架关卡"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminLevelDisableRes struct{}
+
+type AdminLevelEnableReq struct {
+ g.Meta `path:"/level/enable" method:"post" summary:"上架关卡"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminLevelEnableRes struct{}
+
+// ---------- 关卡统计 ----------
+
+type AdminStatsNode struct {
+ NodeId int64 `json:"node_id"`
+ Content string `json:"content"`
+ NodeType int `json:"node_type"`
+ ReachCount int `json:"reach_count"`
+ None int `json:"none"`
+ Fail int `json:"fail"`
+ Good int `json:"good"`
+ Best int `json:"best"`
+}
+
+type AdminStatsOption struct {
+ OptionId int64 `json:"option_id"`
+ NodeId int64 `json:"node_id"`
+ Text string `json:"text"`
+ ChooseCount int `json:"choose_count"`
+ Fail int `json:"fail"`
+ Good int `json:"good"`
+ Best int `json:"best"`
+}
+
+type AdminStatsLevelReq struct {
+ g.Meta `path:"/stats/level" method:"get" summary:"关卡统计"`
+ LevelId int64 `v:"required|min:1" json:"level_id"`
+}
+
+type AdminStatsLevelRes struct {
+ Players int `json:"players"`
+ PerfectCount int `json:"perfect_count"`
+ PerfectRate float64 `json:"perfect_rate"`
+ Nodes []*AdminStatsNode `json:"nodes"`
+ Options []*AdminStatsOption `json:"options"`
+}
diff --git a/biz/model/dto/life_task.go b/biz/model/dto/life_task.go
index 76d3a17..322e95c 100644
--- a/biz/model/dto/life_task.go
+++ b/biz/model/dto/life_task.go
@@ -1 +1,63 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ---------- 后台管理 ----------
+
+type AdminLifeTaskItem struct {
+ Id int64 `json:"id"`
+ StrategyId int64 `json:"strategy_id"`
+ StrategyName string `json:"strategy_name"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Guide string `json:"guide"`
+ RewardPoints int `json:"reward_points"`
+ Status int `json:"status"`
+}
+
+type AdminLifeTaskListReq struct {
+ g.Meta `path:"/life-task/list" method:"get" summary:"生活任务列表"`
+}
+
+type AdminLifeTaskListRes struct {
+ List []*AdminLifeTaskItem `json:"list"`
+}
+
+type AdminLifeTaskCreateReq struct {
+ g.Meta `path:"/life-task/create" method:"post" summary:"新增生活任务"`
+ StrategyId int64 `v:"required|min:1" json:"strategy_id"`
+ Title string `v:"required|length:1,64" json:"title"`
+ Description string `json:"description"`
+ Guide string `json:"guide"`
+ RewardPoints int `v:"min:1" json:"reward_points"`
+}
+
+type AdminLifeTaskCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminLifeTaskUpdateReq struct {
+ g.Meta `path:"/life-task/update" method:"post" summary:"编辑生活任务"`
+ Id int64 `v:"required|min:1" json:"id"`
+ StrategyId int64 `v:"required|min:1" json:"strategy_id"`
+ Title string `v:"required|length:1,64" json:"title"`
+ Description string `json:"description"`
+ Guide string `json:"guide"`
+ RewardPoints int `v:"min:1" json:"reward_points"`
+}
+
+type AdminLifeTaskUpdateRes struct{}
+
+type AdminLifeTaskDisableReq struct {
+ g.Meta `path:"/life-task/disable" method:"post" summary:"下架生活任务"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminLifeTaskDisableRes struct{}
+
+type AdminLifeTaskEnableReq struct {
+ g.Meta `path:"/life-task/enable" method:"post" summary:"上架生活任务"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminLifeTaskEnableRes struct{}
diff --git a/biz/model/dto/node_option.go b/biz/model/dto/node_option.go
index 76d3a17..7a79508 100644
--- a/biz/model/dto/node_option.go
+++ b/biz/model/dto/node_option.go
@@ -1 +1,75 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ---------- 后台管理 ----------
+
+type AdminNodeOptionItem struct {
+ Id int64 `json:"id"`
+ NodeId int64 `json:"node_id"`
+ NodeTitle string `json:"node_title"`
+ Text string `json:"text"`
+ PropId int64 `json:"prop_id"`
+ PropName string `json:"prop_name"`
+ Audio string `json:"audio"`
+ NextNodeId int64 `json:"next_node_id"`
+ NextNodeTitle string `json:"next_node_title"`
+ Feedback string `json:"feedback"`
+ FeedbackAudio string `json:"feedback_audio"`
+ SortOrder int `json:"sort_order"`
+ Status int `json:"status"`
+}
+
+type AdminNodeOptionListReq struct {
+ g.Meta `path:"/node-option/list" method:"get" summary:"分支选项列表(按节点)"`
+ NodeId int64 `v:"required|min:1" json:"node_id"`
+}
+
+type AdminNodeOptionListRes struct {
+ List []*AdminNodeOptionItem `json:"list"`
+}
+
+type AdminNodeOptionCreateReq struct {
+ g.Meta `path:"/node-option/create" method:"post" summary:"新增分支选项"`
+ NodeId int64 `v:"required|min:1" json:"node_id"`
+ Text string `v:"required|length:1,128" json:"text"`
+ PropId int64 `json:"prop_id"`
+ Audio string `json:"audio"`
+ NextNodeId int64 `v:"required|min:1" json:"next_node_id"`
+ Feedback string `json:"feedback"`
+ FeedbackAudio string `json:"feedback_audio"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminNodeOptionCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminNodeOptionUpdateReq struct {
+ g.Meta `path:"/node-option/update" method:"post" summary:"编辑分支选项"`
+ Id int64 `v:"required|min:1" json:"id"`
+ NodeId int64 `v:"required|min:1" json:"node_id"`
+ Text string `v:"required|length:1,128" json:"text"`
+ PropId int64 `json:"prop_id"`
+ Audio string `json:"audio"`
+ NextNodeId int64 `v:"required|min:1" json:"next_node_id"`
+ Feedback string `json:"feedback"`
+ FeedbackAudio string `json:"feedback_audio"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminNodeOptionUpdateRes struct{}
+
+type AdminNodeOptionDisableReq struct {
+ g.Meta `path:"/node-option/disable" method:"post" summary:"下架分支选项"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminNodeOptionDisableRes struct{}
+
+type AdminNodeOptionEnableReq struct {
+ g.Meta `path:"/node-option/enable" method:"post" summary:"上架分支选项"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminNodeOptionEnableRes struct{}
diff --git a/biz/model/dto/parent.go b/biz/model/dto/parent.go
index 4c2cd79..9f37b75 100644
--- a/biz/model/dto/parent.go
+++ b/biz/model/dto/parent.go
@@ -24,3 +24,24 @@ type LoginRes struct {
Token string `json:"token"`
ParentId int64 `json:"parent_id"`
}
+
+// ---------- 后台管理 ----------
+
+type AdminParentItem struct {
+ Id int64 `json:"id"`
+ Phone string `json:"phone"`
+ Openid string `json:"openid"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ Status int `json:"status"`
+ CreatedAt string `json:"created_at"`
+ ChildCount int `json:"child_count"`
+}
+
+type AdminParentListReq struct {
+ g.Meta `path:"/parent/list" method:"get" summary:"家长列表(含孩子数)"`
+}
+
+type AdminParentListRes struct {
+ List []*AdminParentItem `json:"list"`
+}
diff --git a/biz/model/dto/prize.go b/biz/model/dto/prize.go
index 76d3a17..4cd9612 100644
--- a/biz/model/dto/prize.go
+++ b/biz/model/dto/prize.go
@@ -1 +1,68 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ---------- 后台管理 ----------
+
+type AdminPrizeItem struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ PType int `json:"p_type"`
+ PointsCost int `json:"points_cost"`
+ Stock int `json:"stock"`
+ Status int `json:"status"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminPrizeListReq struct {
+ g.Meta `path:"/prize/list" method:"get" summary:"奖品列表"`
+}
+
+type AdminPrizeListRes struct {
+ List []*AdminPrizeItem `json:"list"`
+}
+
+type AdminPrizeCreateReq struct {
+ g.Meta `path:"/prize/create" method:"post" summary:"新增奖品"`
+ Name string `v:"required|length:1,64" json:"name"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ PType int `v:"required|in:1,2" json:"p_type"` // 1=虚拟,2=实物
+ PointsCost int `v:"required|min:1" json:"points_cost"`
+ Stock int `v:"min:-1" json:"stock"` // -1=不限量
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminPrizeCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminPrizeUpdateReq struct {
+ g.Meta `path:"/prize/update" method:"post" summary:"编辑奖品"`
+ Id int64 `v:"required|min:1" json:"id"`
+ Name string `v:"required|length:1,64" json:"name"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ PType int `v:"required|in:1,2" json:"p_type"`
+ PointsCost int `v:"required|min:1" json:"points_cost"`
+ Stock int `v:"min:-1" json:"stock"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminPrizeUpdateRes struct{}
+
+type AdminPrizeDisableReq struct {
+ g.Meta `path:"/prize/disable" method:"post" summary:"下架奖品"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminPrizeDisableRes struct{}
+
+type AdminPrizeEnableReq struct {
+ g.Meta `path:"/prize/enable" method:"post" summary:"上架奖品"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminPrizeEnableRes struct{}
diff --git a/biz/model/dto/redemption.go b/biz/model/dto/redemption.go
index 76d3a17..d4b6e7d 100644
--- a/biz/model/dto/redemption.go
+++ b/biz/model/dto/redemption.go
@@ -1 +1,49 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ---------- 后台管理 ----------
+
+type AdminRedemptionItem struct {
+ Id int64 `json:"id"`
+ ChildId int64 `json:"child_id"`
+ ChildNickname string `json:"child_nickname"`
+ PrizeId int64 `json:"prize_id"`
+ PrizeName string `json:"prize_name"`
+ PointsCost int `json:"points_cost"`
+ Status int `json:"status"`
+ Code string `json:"code"`
+ CreatedAt string `json:"created_at"`
+}
+
+type AdminRedemptionListReq struct {
+ g.Meta `path:"/redemption/list" method:"get" summary:"兑换记录列表"`
+ Status int `json:"status"`
+ PrizeId int64 `json:"prize_id"`
+}
+
+type AdminRedemptionListRes struct {
+ List []*AdminRedemptionItem `json:"list"`
+}
+
+type AdminRedemptionShipReq struct {
+ g.Meta `path:"/redemption/ship" method:"post" summary:"兑换发货"`
+ Id int64 `v:"required|min:1" json:"id"`
+ Code string `json:"code"`
+}
+
+type AdminRedemptionShipRes struct{}
+
+type AdminRedemptionReceiveReq struct {
+ g.Meta `path:"/redemption/receive" method:"post" summary:"确认领取"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminRedemptionReceiveRes struct{}
+
+type AdminRedemptionCancelReq struct {
+ g.Meta `path:"/redemption/cancel" method:"post" summary:"取消兑换(退积分)"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminRedemptionCancelRes struct{}
diff --git a/biz/model/dto/scene_node.go b/biz/model/dto/scene_node.go
index 76d3a17..9b1276c 100644
--- a/biz/model/dto/scene_node.go
+++ b/biz/model/dto/scene_node.go
@@ -1 +1,89 @@
package dto
+
+import "github.com/gogf/gf/v2/frame/g"
+
+// ---------- 后台管理 ----------
+
+type AdminSceneNodeItem struct {
+ Id int64 `json:"id"`
+ LevelId int64 `json:"level_id"`
+ Title string `json:"title"`
+ CharacterId int64 `json:"character_id"`
+ CharacterName string `json:"character_name"`
+ Content string `json:"content"`
+ Image string `json:"image"`
+ Audio string `json:"audio"`
+ NodeType int `json:"node_type"`
+ InteractionType int `json:"interaction_type"`
+ Config string `json:"config"`
+ Script string `json:"script"`
+ ResultType int `json:"result_type"`
+ IsEntry int `json:"is_entry"`
+ SortOrder int `json:"sort_order"`
+ Status int `json:"status"`
+ OptionCount int `json:"option_count"`
+}
+
+type AdminSceneNodeListReq struct {
+ g.Meta `path:"/scene-node/list" method:"get" summary:"节点列表(按关卡)"`
+ LevelId int64 `v:"required|min:1" json:"level_id"`
+}
+
+type AdminSceneNodeListRes struct {
+ List []*AdminSceneNodeItem `json:"list"`
+}
+
+type AdminSceneNodeCreateReq struct {
+ g.Meta `path:"/scene-node/create" method:"post" summary:"新增情境节点"`
+ LevelId int64 `v:"required|min:1" json:"level_id"`
+ Title string `json:"title"`
+ CharacterId int64 `json:"character_id"`
+ Content string `v:"required" json:"content"`
+ Image string `json:"image"`
+ Audio string `json:"audio"`
+ NodeType int `v:"required|in:1,2" json:"node_type"`
+ InteractionType int `json:"interaction_type"`
+ Config string `json:"config"`
+ Script string `json:"script"`
+ ResultType int `v:"in:0,1,2,3" json:"result_type"`
+ IsEntry int `v:"in:0,1" json:"is_entry"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminSceneNodeCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminSceneNodeUpdateReq struct {
+ g.Meta `path:"/scene-node/update" method:"post" summary:"编辑情境节点"`
+ Id int64 `v:"required|min:1" json:"id"`
+ LevelId int64 `v:"required|min:1" json:"level_id"`
+ Title string `json:"title"`
+ CharacterId int64 `json:"character_id"`
+ Content string `v:"required" json:"content"`
+ Image string `json:"image"`
+ Audio string `json:"audio"`
+ NodeType int `v:"required|in:1,2" json:"node_type"`
+ InteractionType int `json:"interaction_type"`
+ Config string `json:"config"`
+ Script string `json:"script"`
+ ResultType int `v:"in:0,1,2,3" json:"result_type"`
+ IsEntry int `v:"in:0,1" json:"is_entry"`
+ SortOrder int `json:"sort_order"`
+}
+
+type AdminSceneNodeUpdateRes struct{}
+
+type AdminSceneNodeDisableReq struct {
+ g.Meta `path:"/scene-node/disable" method:"post" summary:"下架情境节点"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminSceneNodeDisableRes struct{}
+
+type AdminSceneNodeEnableReq struct {
+ g.Meta `path:"/scene-node/enable" method:"post" summary:"上架情境节点"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminSceneNodeEnableRes struct{}
diff --git a/biz/model/dto/strategy.go b/biz/model/dto/strategy.go
index 053641a..4835051 100644
--- a/biz/model/dto/strategy.go
+++ b/biz/model/dto/strategy.go
@@ -34,15 +34,15 @@ type StrategyDetailReq struct {
}
type LevelBrief struct {
- LevelId int64 `json:"level_id"`
- Title string `json:"title"`
- AgeGroup string `json:"age_group"`
- SceneName string `json:"scene_name"`
- Stars int `json:"stars"`
- Perfect bool `json:"perfect"`
- Unlocked bool `json:"unlocked"`
- ContentVersion int `json:"content_version"`
- ProgressVersion int `json:"progress_version"`
+ LevelId int64 `json:"level_id"`
+ Title string `json:"title"`
+ AgeGroup string `json:"age_group"`
+ SceneName string `json:"scene_name"`
+ Stars int `json:"stars"`
+ Perfect bool `json:"perfect"`
+ Unlocked bool `json:"unlocked"`
+ ContentVersion int `json:"content_version"`
+ ProgressVersion int `json:"progress_version"`
}
type StrategyDetailRes struct {
@@ -60,3 +60,88 @@ type StrategyDetailRes struct {
SummaryOptions string `json:"summary_options"`
Levels []LevelBrief `json:"levels"`
}
+
+// ---------- 后台管理 ----------
+
+type AdminStrategyItem struct {
+ Id int64 `json:"id"`
+ Name string `json:"name"`
+ Pinyin string `json:"pinyin"`
+ GroupNo int `json:"group_no"`
+ GroupName string `json:"group_name"`
+ Meaning string `json:"meaning"`
+ TeachContent string `json:"teach_content"`
+ TeachImage string `json:"teach_image"`
+ TeachAudio string `json:"teach_audio"`
+ SummaryQ string `json:"summary_q"`
+ SummaryOptions string `json:"summary_options"`
+ SummaryAudio string `json:"summary_audio"`
+ Icon string `json:"icon"`
+ SortOrder int `json:"sort_order"`
+ UnlockBefore int64 `json:"unlock_before"`
+ Status int `json:"status"`
+ LevelCount int `json:"level_count"`
+}
+
+type AdminStrategyListReq struct {
+ g.Meta `path:"/strategy/list" method:"get" summary:"计策列表(含关卡数)"`
+}
+
+type AdminStrategyListRes struct {
+ List []*AdminStrategyItem `json:"list"`
+}
+
+type AdminStrategyCreateReq struct {
+ g.Meta `path:"/strategy/create" method:"post" summary:"新增计策"`
+ Name string `v:"required|length:1,32" json:"name"`
+ GroupNo int `v:"required|min:1|max:6" json:"group_no"`
+ GroupName string `json:"group_name"`
+ Meaning string `json:"meaning"`
+ TeachContent string `json:"teach_content"`
+ TeachImage string `json:"teach_image"`
+ TeachAudio string `json:"teach_audio"`
+ SummaryQ string `json:"summary_q"`
+ SummaryOptions string `json:"summary_options"`
+ SummaryAudio string `json:"summary_audio"`
+ Icon string `json:"icon"`
+ SortOrder int `json:"sort_order"`
+ UnlockBefore int64 `json:"unlock_before"`
+}
+
+type AdminStrategyCreateRes struct {
+ Id int64 `json:"id"`
+}
+
+type AdminStrategyUpdateReq struct {
+ g.Meta `path:"/strategy/update" method:"post" summary:"编辑计策"`
+ Id int64 `v:"required|min:1" json:"id"`
+ Name string `v:"required|length:1,32" json:"name"`
+ GroupNo int `v:"required|min:1|max:6" json:"group_no"`
+ GroupName string `json:"group_name"`
+ Meaning string `json:"meaning"`
+ TeachContent string `json:"teach_content"`
+ TeachImage string `json:"teach_image"`
+ TeachAudio string `json:"teach_audio"`
+ SummaryQ string `json:"summary_q"`
+ SummaryOptions string `json:"summary_options"`
+ SummaryAudio string `json:"summary_audio"`
+ Icon string `json:"icon"`
+ SortOrder int `json:"sort_order"`
+ UnlockBefore int64 `json:"unlock_before"`
+}
+
+type AdminStrategyUpdateRes struct{}
+
+type AdminStrategyDisableReq struct {
+ g.Meta `path:"/strategy/disable" method:"post" summary:"下架计策"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminStrategyDisableRes struct{}
+
+type AdminStrategyEnableReq struct {
+ g.Meta `path:"/strategy/enable" method:"post" summary:"上架计策"`
+ Id int64 `v:"required|min:1" json:"id"`
+}
+
+type AdminStrategyEnableRes struct{}
diff --git a/biz/model/entity/entity_element.go b/biz/model/entity/entity_element.go
index 99e31c5..21d97c7 100644
--- a/biz/model/entity/entity_element.go
+++ b/biz/model/entity/entity_element.go
@@ -2,12 +2,14 @@ package entity
// Element 元素库:场景(环境)/ 人物 / 道具,决策树的结构性骨架。
type Element struct {
- Id int64 `json:"id" orm:"id"`
- EType int `json:"e_type" orm:"e_type"`
- Name string `json:"name" orm:"name"`
- Image string `json:"image" orm:"image"`
- Audio string `json:"audio" orm:"audio"`
- Description string `json:"description" orm:"description"`
- Status int `json:"status" orm:"status"`
- SortOrder int `json:"sort_order" orm:"sort_order"`
+ Id int64 `json:"id" orm:"id"`
+ EType int `json:"e_type" orm:"e_type"`
+ Name string `json:"name" orm:"name"`
+ NamePinyin string `json:"name_pinyin" orm:"name_pinyin"`
+ Image string `json:"image" orm:"image"`
+ Audio string `json:"audio" orm:"audio"`
+ Description string `json:"description" orm:"description"`
+ DescriptionPinyin string `json:"description_pinyin" orm:"description_pinyin"`
+ Status int `json:"status" orm:"status"`
+ SortOrder int `json:"sort_order" orm:"sort_order"`
}
diff --git a/biz/model/entity/entity_level.go b/biz/model/entity/entity_level.go
index a482d9a..ee98f86 100644
--- a/biz/model/entity/entity_level.go
+++ b/biz/model/entity/entity_level.go
@@ -2,15 +2,16 @@ package entity
// Level 情境关卡:每计 1-5 关,关联环境场景元素,带内容版本号。
type Level struct {
- Id int64 `json:"id" orm:"id"`
- StrategyId int64 `json:"strategy_id" orm:"strategy_id"`
- Title string `json:"title" orm:"title"`
- SceneId int64 `json:"scene_id" orm:"scene_id"`
- SceneContent string `json:"scene_content" orm:"scene_content"`
- SceneImage string `json:"scene_image" orm:"scene_image"`
- SceneAudio string `json:"scene_audio" orm:"scene_audio"`
- AgeGroup string `json:"age_group" orm:"age_group"`
- ContentVersion int `json:"content_version" orm:"content_version"`
- SortOrder int `json:"sort_order" orm:"sort_order"`
- Status int `json:"status" orm:"status"`
+ Id int64 `json:"id" orm:"id"`
+ StrategyId int64 `json:"strategy_id" orm:"strategy_id"`
+ Title string `json:"title" orm:"title"`
+ SceneId int64 `json:"scene_id" orm:"scene_id"`
+ SceneContent string `json:"scene_content" orm:"scene_content"`
+ SceneContentPinyin string `json:"scene_content_pinyin" orm:"scene_content_pinyin"`
+ SceneImage string `json:"scene_image" orm:"scene_image"`
+ SceneAudio string `json:"scene_audio" orm:"scene_audio"`
+ AgeGroup string `json:"age_group" orm:"age_group"`
+ ContentVersion int `json:"content_version" orm:"content_version"`
+ SortOrder int `json:"sort_order" orm:"sort_order"`
+ Status int `json:"status" orm:"status"`
}
diff --git a/biz/model/entity/entity_node_option.go b/biz/model/entity/entity_node_option.go
index 24e8a4b..b72e542 100644
--- a/biz/model/entity/entity_node_option.go
+++ b/biz/model/entity/entity_node_option.go
@@ -2,14 +2,18 @@ package entity
// NodeOption 分支选项:行为文本 + 使用道具 + 指向下一节点 + 即时点评。
type NodeOption struct {
- Id int64 `json:"id" orm:"id"`
- NodeId int64 `json:"node_id" orm:"node_id"`
- Text string `json:"text" orm:"text"`
- PropId int64 `json:"prop_id" orm:"prop_id"`
- Audio string `json:"audio" orm:"audio"`
- NextNodeId int64 `json:"next_node_id" orm:"next_node_id"`
- Feedback string `json:"feedback" orm:"feedback"`
- FeedbackAudio string `json:"feedback_audio" orm:"feedback_audio"`
- SortOrder int `json:"sort_order" orm:"sort_order"`
- Status int `json:"status" orm:"status"`
+ Id int64 `json:"id" orm:"id"`
+ NodeId int64 `json:"node_id" orm:"node_id"`
+ Text string `json:"text" orm:"text"`
+ TextPinyin string `json:"text_pinyin" orm:"text_pinyin"`
+ PropId int64 `json:"prop_id" orm:"prop_id"`
+ Audio string `json:"audio" orm:"audio"`
+ NextNodeId int64 `json:"next_node_id" orm:"next_node_id"`
+ Feedback string `json:"feedback" orm:"feedback"`
+ FeedbackPinyin string `json:"feedback_pinyin" orm:"feedback_pinyin"`
+ FeedbackPros string `json:"feedback_pros" orm:"feedback_pros"`
+ FeedbackCons string `json:"feedback_cons" orm:"feedback_cons"`
+ FeedbackAudio string `json:"feedback_audio" orm:"feedback_audio"`
+ SortOrder int `json:"sort_order" orm:"sort_order"`
+ Status int `json:"status" orm:"status"`
}
diff --git a/biz/model/entity/entity_scene_node.go b/biz/model/entity/entity_scene_node.go
index d0bce2e..ce036bd 100644
--- a/biz/model/entity/entity_scene_node.go
+++ b/biz/model/entity/entity_scene_node.go
@@ -7,6 +7,7 @@ type SceneNode struct {
Title string `json:"title" orm:"title"`
CharacterId int64 `json:"character_id" orm:"character_id"`
Content string `json:"content" orm:"content"`
+ ContentPinyin string `json:"content_pinyin" orm:"content_pinyin"`
Image string `json:"image" orm:"image"`
Audio string `json:"audio" orm:"audio"`
NodeType int `json:"node_type" orm:"node_type"`
diff --git a/biz/model/entity/entity_strategy.go b/biz/model/entity/entity_strategy.go
index 6050472..4446d42 100644
--- a/biz/model/entity/entity_strategy.go
+++ b/biz/model/entity/entity_strategy.go
@@ -2,20 +2,24 @@ package entity
// Strategy 计策:六套分组,含计策学堂与智慧总结内容。
type Strategy struct {
- Id int64 `json:"id" orm:"id"`
- Name string `json:"name" orm:"name"`
- Pinyin string `json:"pinyin" orm:"pinyin"`
- GroupNo int `json:"group_no" orm:"group_no"`
- GroupName string `json:"group_name" orm:"group_name"`
- Meaning string `json:"meaning" orm:"meaning"`
- TeachContent string `json:"teach_content" orm:"teach_content"`
- TeachImage string `json:"teach_image" orm:"teach_image"`
- TeachAudio string `json:"teach_audio" orm:"teach_audio"`
- SummaryQ string `json:"summary_q" orm:"summary_q"`
- SummaryOptions string `json:"summary_options" orm:"summary_options"`
- SummaryAudio string `json:"summary_audio" orm:"summary_audio"`
- Icon string `json:"icon" orm:"icon"`
- SortOrder int `json:"sort_order" orm:"sort_order"`
- UnlockBefore int64 `json:"unlock_before" orm:"unlock_before"`
- Status int `json:"status" orm:"status"`
+ Id int64 `json:"id" orm:"id"`
+ Name string `json:"name" orm:"name"`
+ Pinyin string `json:"pinyin" orm:"pinyin"`
+ GroupNo int `json:"group_no" orm:"group_no"`
+ GroupName string `json:"group_name" orm:"group_name"`
+ Meaning string `json:"meaning" orm:"meaning"`
+ MeaningPinyin string `json:"meaning_pinyin" orm:"meaning_pinyin"`
+ TeachContent string `json:"teach_content" orm:"teach_content"`
+ TeachContentPinyin string `json:"teach_content_pinyin" orm:"teach_content_pinyin"`
+ TeachImage string `json:"teach_image" orm:"teach_image"`
+ TeachAudio string `json:"teach_audio" orm:"teach_audio"`
+ SummaryQ string `json:"summary_q" orm:"summary_q"`
+ SummaryQPinyin string `json:"summary_q_pinyin" orm:"summary_q_pinyin"`
+ SummaryOptions string `json:"summary_options" orm:"summary_options"`
+ SummaryOptionsPinyin string `json:"summary_options_pinyin" orm:"summary_options_pinyin"`
+ SummaryAudio string `json:"summary_audio" orm:"summary_audio"`
+ Icon string `json:"icon" orm:"icon"`
+ SortOrder int `json:"sort_order" orm:"sort_order"`
+ UnlockBefore int64 `json:"unlock_before" orm:"unlock_before"`
+ Status int `json:"status" orm:"status"`
}
diff --git a/biz/service/admin_user.go b/biz/service/admin_user.go
index b0a3f40..9912b00 100644
--- a/biz/service/admin_user.go
+++ b/biz/service/admin_user.go
@@ -1,5 +1,120 @@
package service
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ "golang.org/x/crypto/bcrypt"
+
+ "36wisdom/biz/consts"
+ "36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/common/auth"
+)
+
type adminUser struct{}
var AdminUser = &adminUser{}
+
+// Login 管理员登录:用户名 + bcrypt 密码校验,签发 admin 角色 token。
+// token 有效期读 auth.expire 配置(≤0 回退 consts 默认)。
+func (s *adminUser) Login(ctx context.Context, req *dto.AdminLoginReq) (*dto.AdminLoginRes, error) {
+ rec, err := dao.AdminUser.GetByUsername(ctx, req.Username)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Status != consts.StatusEnabled {
+ return nil, gerror.New("用户名或密码错误")
+ }
+ if err = bcrypt.CompareHashAndPassword([]byte(rec.Password), []byte(req.Password)); err != nil {
+ return nil, gerror.New("用户名或密码错误")
+ }
+ expire := g.Cfg().MustGet(ctx, "auth.expire", consts.AuthExpireSeconds).Int()
+ if expire <= 0 {
+ expire = consts.AuthExpireSeconds
+ }
+ token, err := auth.GenerateToken(auth.Secret(ctx), rec.Id, consts.RoleAdmin, expire)
+ if err != nil {
+ return nil, err
+ }
+ return &dto.AdminLoginRes{Token: token, AdminId: rec.Id}, nil
+}
+
+// uploadAllow 扩展名白名单 → 素材分类(技术设计.md 4.17)。
+var uploadAllow = map[string]string{
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".webp": "image", ".gif": "image",
+ ".mp3": "audio", ".wav": "audio", ".m4a": "audio", ".ogg": "audio",
+}
+
+// Upload 素材上传:白名单 + 内容嗅探双校验,落盘 workspace/uploads/{分类}/YYYYMMDD_<随机>.ext,
+// 返回 /uploads/... 访问路径。
+func (s *adminUser) Upload(ctx context.Context, req *dto.AdminUploadReq) (*dto.AdminUploadRes, error) {
+ f := req.File
+ if f == nil || f.FileHeader == nil {
+ return nil, gerror.New("未收到上传文件")
+ }
+ ext := strings.ToLower(filepath.Ext(f.FileHeader.Filename))
+ cat, ok := uploadAllow[ext]
+ if !ok {
+ return nil, gerror.New("不支持的文件类型(png/jpg/jpeg/webp/gif/mp3/wav/m4a/ogg)")
+ }
+ maxBytes := int64(consts.UploadMaxImageBytes)
+ if cat == "audio" {
+ maxBytes = consts.UploadMaxAudioBytes
+ }
+ if f.FileHeader.Size > maxBytes {
+ return nil, gerror.Newf("文件超过大小限制(%dMB)", maxBytes>>20)
+ }
+
+ fh, err := f.Open()
+ if err != nil {
+ return nil, gerror.Wrap(err, "读取上传文件失败")
+ }
+ data, err := io.ReadAll(fh)
+ if err != nil {
+ return nil, gerror.Wrap(err, "读取上传文件失败")
+ }
+ if err = fh.Close(); err != nil {
+ return nil, gerror.Wrap(err, "关闭上传文件失败")
+ }
+ if len(data) == 0 {
+ return nil, gerror.New("上传文件为空")
+ }
+ if mime := http.DetectContentType(data); !contentTypeMatch(cat, mime) {
+ return nil, gerror.New("文件内容与扩展名不符")
+ }
+
+ randBytes := make([]byte, 6)
+ if _, err = rand.Read(randBytes); err != nil {
+ return nil, err
+ }
+ name := time.Now().Format("20060102") + "_" + hex.EncodeToString(randBytes) + ext
+ dir := filepath.Join("workspace", "uploads", cat)
+ if err = os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ if err = os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
+ return nil, err
+ }
+ return &dto.AdminUploadRes{Url: "/uploads/" + cat + "/" + name}, nil
+}
+
+// contentTypeMatch 内容嗅探校验:image 分类须 image/*;audio 分类须 audio/* 或 video/*(m4a 被识别为 video/mp4)。
+func contentTypeMatch(cat, mime string) bool {
+ if mime == "" || mime == "application/octet-stream" {
+ return false
+ }
+ if cat == "image" {
+ return strings.HasPrefix(mime, "image/")
+ }
+ return strings.HasPrefix(mime, "audio/") || strings.HasPrefix(mime, "video/")
+}
diff --git a/biz/service/badge.go b/biz/service/badge.go
index de9cfd3..09af120 100644
--- a/biz/service/badge.go
+++ b/biz/service/badge.go
@@ -3,23 +3,97 @@ package service
import (
"context"
- "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/common"
)
-type badge struct{}
+// ---------- 后台管理 ----------
-var Badge = &badge{}
+type adminBadge struct{}
-// GetByPk 徽章定义。
-func (s *badge) GetByPk(ctx context.Context, id int64) (gdb.Record, error) {
- return dao.Badge.GetOneByPk(ctx, id)
+var AdminBadge = &adminBadge{}
+
+// List 全部徽章(含下架),按 id 排序。
+func (s *adminBadge) List(ctx context.Context, req *dto.AdminBadgeListReq) (*dto.AdminBadgeListRes, error) {
+ recs, err := dao.Badge.ListAll(ctx)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminBadgeItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminBadgeItem{
+ Id: r.Id, Name: r.Name, Icon: r.Icon,
+ CondType: r.CondType, CondValue: r.CondValue,
+ Status: r.Status,
+ })
+ }
+ return &dto.AdminBadgeListRes{List: items}, nil
}
-// ListEnabled 启用徽章定义(内容缓存)。
-func (s *badge) ListEnabled(ctx context.Context) ([]gdb.Record, error) {
- return dao.Badge.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("status", consts.StatusEnabled).Order("id ASC").All()
+// Create 新增徽章,清内容缓存。
+func (s *adminBadge) Create(ctx context.Context, req *dto.AdminBadgeCreateReq) (*dto.AdminBadgeCreateRes, error) {
+ id, err := dao.Badge.InsertAndReturnId(ctx, g.Map{
+ "name": req.Name, "icon": req.Icon, "cond_type": req.CondType, "cond_value": req.CondValue,
+ "status": consts.StatusEnabled,
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableBadge)
+ return &dto.AdminBadgeCreateRes{Id: id}, nil
+}
+
+// Update 编辑徽章,清内容缓存。
+func (s *adminBadge) Update(ctx context.Context, req *dto.AdminBadgeUpdateReq) (*dto.AdminBadgeUpdateRes, error) {
+ rec, err := dao.Badge.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("徽章不存在")
+ }
+ if err = dao.Badge.UpdateByPk(ctx, req.Id, g.Map{
+ "name": req.Name, "icon": req.Icon, "cond_type": req.CondType, "cond_value": req.CondValue,
+ }); err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableBadge)
+ return &dto.AdminBadgeUpdateRes{}, nil
+}
+
+// setStatus 上下架(软删除)。
+func (s *adminBadge) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.Badge.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("徽章不存在")
+ }
+ if err = dao.Badge.UpdateByPk(ctx, id, g.Map{"status": status}); err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableBadge)
+ return nil
+}
+
+// Disable 下架徽章。
+func (s *adminBadge) Disable(ctx context.Context, req *dto.AdminBadgeDisableReq) (*dto.AdminBadgeDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminBadgeDisableRes{}, nil
+}
+
+// Enable 上架徽章。
+func (s *adminBadge) Enable(ctx context.Context, req *dto.AdminBadgeEnableReq) (*dto.AdminBadgeEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminBadgeEnableRes{}, nil
}
diff --git a/biz/service/chapter_review.go b/biz/service/chapter_review.go
index 7f9c4c0..076fed8 100644
--- a/biz/service/chapter_review.go
+++ b/biz/service/chapter_review.go
@@ -1,23 +1,6 @@
package service
-import (
- "context"
-
- "github.com/gogf/gf/v2/frame/g"
-
- "36wisdom/biz/dao"
-)
-
+// ChapterReview 温故记录数据访问已由 dao.ChapterReview 承载,此文件仅为分层对齐保留。
type chapterReview struct{}
var ChapterReview = &chapterReview{}
-
-// Insert 创建温故记录。
-func (s *chapterReview) Insert(ctx context.Context, childId, strategyId int64, levelIds string) (int64, error) {
- return dao.ChapterReview.InsertAndReturnId(ctx, g.Map{
- "child_id": childId,
- "strategy_id": strategyId,
- "level_ids": levelIds,
- "status": 1,
- })
-}
diff --git a/biz/service/child.go b/biz/service/child.go
index 0e4cae5..0ac8048 100644
--- a/biz/service/child.go
+++ b/biz/service/child.go
@@ -3,105 +3,249 @@ package service
import (
"context"
- "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/model/entity"
+ "36wisdom/common/auth"
)
type child struct{}
var Child = &child{}
-// getChildOf 校验孩子归属当前家长并返回档案记录。
-func getChildOf(ctx context.Context, parentUid, childId int64) (gdb.Record, error) {
- rec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
+// getChildOf 校验孩子归属当前家长并返回档案。
+func getChildOf(ctx context.Context, parentUid, childId int64) (*entity.Child, error) {
+ rec, err := dao.Child.GetByPk(ctx, childId)
if err != nil {
return nil, err
}
- if rec.IsEmpty() || rec["parent_id"].Int64() != parentUid {
+ if rec == nil || rec.ParentId != parentUid {
return nil, gerror.New("孩子档案不存在")
}
return rec, nil
}
-// ChildItem 孩子列表项(含派生值:完美关卡数)。
-type ChildItem struct {
- ChildId int64
- Nickname string
- Avatar string
- AgeGroup string
- Points int
- Level int
- LevelTitle string
- PerfectCount int
- DailyLimitMinutes int
-}
-
// Create 新建孩子档案。
-func (s *child) Create(ctx context.Context, parentId int64, nickname, ageGroup string) (int64, error) {
- return dao.Child.InsertAndReturnId(ctx, g.Map{
- "parent_id": parentId,
- "nickname": nickname,
- "age_group": ageGroup,
+func (s *child) Create(ctx context.Context, req *dto.ChildCreateReq) (*dto.ChildCreateRes, error) {
+ id, err := dao.Child.InsertAndReturnId(ctx, g.Map{
+ "parent_id": auth.GetUid(ctx),
+ "nickname": req.Nickname,
+ "age_group": req.AgeGroup,
"status": consts.StatusEnabled,
})
+ if err != nil {
+ return nil, err
+ }
+ return &dto.ChildCreateRes{ChildId: id}, nil
}
-// Update 更新孩子档案:仅本人名下档案可改。
-func (s *child) Update(ctx context.Context, parentId, childId int64, data g.Map) error {
- rec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
+// Update 更新孩子档案:仅本人名下档案可改,非空字段覆盖。
+func (s *child) Update(ctx context.Context, req *dto.ChildUpdateReq) (*dto.ChildUpdateRes, error) {
+ rec, err := dao.Child.GetByPk(ctx, req.ChildId)
if err != nil {
- return err
+ return nil, err
}
- if rec.IsEmpty() || rec["parent_id"].Int64() != parentId {
- return gerror.New("孩子档案不存在")
+ if rec == nil || rec.ParentId != auth.GetUid(ctx) {
+ return nil, gerror.New("孩子档案不存在")
}
- return dao.Child.UpdateByPk(ctx, childId, data)
+ data := g.Map{}
+ if req.Nickname != "" {
+ data["nickname"] = req.Nickname
+ }
+ if req.Avatar != "" {
+ data["avatar"] = req.Avatar
+ }
+ if req.AgeGroup != "" {
+ data["age_group"] = req.AgeGroup
+ }
+ if req.DailyLimitMinutes > 0 {
+ data["daily_limit_minutes"] = req.DailyLimitMinutes
+ }
+ if len(data) == 0 {
+ return &dto.ChildUpdateRes{}, nil
+ }
+ if err = dao.Child.UpdateByPk(ctx, req.ChildId, data); err != nil {
+ return nil, err
+ }
+ return &dto.ChildUpdateRes{}, nil
}
// List 家长名下的孩子列表,perfect 数经 user_progress 批量汇总。
-func (s *child) List(ctx context.Context, parentId int64) ([]*ChildItem, error) {
- recs, err := dao.Child.Model().Ctx(ctx).Where("parent_id", parentId).Order("id ASC").All()
+func (s *child) List(ctx context.Context, req *dto.ChildListReq) (*dto.ChildListRes, error) {
+ parentId := auth.GetUid(ctx)
+ recs, err := dao.Child.ListByParent(ctx, parentId)
if err != nil {
return nil, err
}
- items := make([]*ChildItem, 0, len(recs))
+ items := make([]dto.ChildListItem, 0, len(recs))
if len(recs) == 0 {
- return items, nil
+ return &dto.ChildListRes{List: items}, nil
}
childIds := make([]int64, 0, len(recs))
for _, r := range recs {
- childIds = append(childIds, r["id"].Int64())
+ childIds = append(childIds, r.Id)
}
-
- perfectMap := make(map[int64]int, len(recs))
- rows, err := UserProgress.CountPerfectByChildIds(ctx, childIds)
+ perfectMap, err := dao.UserProgress.CountPerfectByChildIds(ctx, childIds)
if err != nil {
return nil, err
}
- for _, r := range rows {
- perfectMap[r["child_id"].Int64()] = r["cnt"].Int()
- }
for _, r := range recs {
- perfect := perfectMap[r["id"].Int64()]
+ perfect := perfectMap[r.Id]
level, title := LevelOf(perfect)
- items = append(items, &ChildItem{
- ChildId: r["id"].Int64(),
- Nickname: r["nickname"].String(),
- Avatar: r["avatar"].String(),
- AgeGroup: r["age_group"].String(),
- Points: r["points"].Int(),
+ items = append(items, dto.ChildListItem{
+ ChildId: r.Id,
+ Nickname: r.Nickname,
+ Avatar: r.Avatar,
+ AgeGroup: r.AgeGroup,
+ Points: r.Points,
Level: level,
LevelTitle: title,
PerfectCount: perfect,
- DailyLimitMinutes: r["daily_limit_minutes"].Int(),
+ DailyLimitMinutes: r.DailyLimitMinutes,
})
}
- return items, nil
+ return &dto.ChildListRes{List: items}, nil
+}
+
+// ---------- 后台管理 ----------
+
+type adminChild struct{}
+
+var AdminChild = &adminChild{}
+
+// List 孩子列表(parent_id 可选筛),含完美数与成长等级。
+func (s *adminChild) List(ctx context.Context, req *dto.AdminChildListReq) (*dto.AdminChildListRes, error) {
+ recs, err := dao.Child.ListAll(ctx, req.ParentId)
+ if err != nil {
+ return nil, err
+ }
+ childIds := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ childIds = append(childIds, r.Id)
+ }
+ perfects, err := perfectCounts(ctx, childIds)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminChildItem, 0, len(recs))
+ for _, r := range recs {
+ item := childItemOf(r)
+ item.PerfectCount = perfects[r.Id]
+ item.Level, item.LevelTitle = LevelOf(item.PerfectCount)
+ items = append(items, item)
+ }
+ return &dto.AdminChildListRes{List: items}, nil
+}
+
+// childItemOf 孩子档案 → 管理项(完美数与成长等级由调用方补填)。
+func childItemOf(r *entity.Child) *dto.AdminChildItem {
+ return &dto.AdminChildItem{
+ Id: r.Id, ParentId: r.ParentId,
+ Nickname: r.Nickname, Avatar: r.Avatar,
+ AgeGroup: r.AgeGroup, Points: r.Points,
+ DailyLimitMinutes: r.DailyLimitMinutes,
+ Status: r.Status, CreatedAt: timeString(r.CreatedAt),
+ }
+}
+
+// perfectCounts 各孩子完美通关数(复用前台统计)。
+func perfectCounts(ctx context.Context, childIds []int64) (map[int64]int, error) {
+ return dao.UserProgress.CountPerfectByChildIds(ctx, childIds)
+}
+
+// childNicknames 批量孩子昵称,返回 id → 昵称。
+func childNicknames(ctx context.Context, childIds []int64) (map[int64]string, error) {
+ m := make(map[int64]string, len(childIds))
+ childIds = uniqueInt64(childIds)
+ if len(childIds) == 0 {
+ return m, nil
+ }
+ recs, err := dao.Child.ListByIds(ctx, childIds)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r.Id] = r.Nickname
+ }
+ return m, nil
+}
+
+// timeString 时间格式化(空值回退空串)。
+func timeString(t *gtime.Time) string {
+ if t == nil {
+ return ""
+ }
+ return t.String()
+}
+
+// Detail 孩子详情:档案 + 全部闯关进度 + 汇总统计。
+func (s *adminChild) Detail(ctx context.Context, req *dto.AdminChildDetailReq) (*dto.AdminChildDetailRes, error) {
+ child, err := dao.Child.GetByPk(ctx, req.ChildId)
+ if err != nil {
+ return nil, err
+ }
+ if child == nil {
+ return nil, gerror.New("孩子不存在")
+ }
+ recs, err := dao.UserProgress.ListByChild(ctx, req.ChildId)
+ if err != nil {
+ return nil, err
+ }
+ levelIds := make([]int64, 0, len(recs))
+ for _, p := range recs {
+ levelIds = append(levelIds, p.LevelId)
+ }
+ levels, err := dao.Level.ListByIds(ctx, levelIds)
+ if err != nil {
+ return nil, err
+ }
+ levelById := make(map[int64]*entity.Level, len(levels))
+ strategyIds := make([]int64, 0, len(levels))
+ for _, lv := range levels {
+ levelById[lv.Id] = lv
+ strategyIds = append(strategyIds, lv.StrategyId)
+ }
+ strategies, err := dao.Strategy.ListByIds(ctx, strategyIds)
+ if err != nil {
+ return nil, err
+ }
+ strategyNames := make(map[int64]string, len(strategies))
+ for _, st := range strategies {
+ strategyNames[st.Id] = st.Name
+ }
+
+ item := childItemOf(child)
+ perfect, stars := 0, 0
+ for _, p := range recs {
+ if p.Perfect == 1 {
+ perfect++
+ }
+ stars += p.Stars
+ }
+ item.PerfectCount = perfect
+ detail := &dto.AdminChildDetailRes{
+ Child: item, Passed: len(recs), Perfect: perfect, Stars: stars,
+ Progress: make([]*dto.AdminProgressItem, 0, len(recs)),
+ }
+ for _, p := range recs {
+ title, sid := "", int64(0)
+ if lv := levelById[p.LevelId]; lv != nil {
+ title, sid = lv.Title, lv.StrategyId
+ }
+ detail.Progress = append(detail.Progress, &dto.AdminProgressItem{
+ LevelId: p.LevelId, LevelTitle: title,
+ StrategyId: sid, StrategyName: strategyNames[sid],
+ Stars: p.Stars, Perfect: p.Perfect == 1,
+ ContentVersion: p.ContentVersion, CompletedAt: timeString(p.CompletedAt),
+ })
+ }
+ return detail, nil
}
diff --git a/biz/service/element.go b/biz/service/element.go
index dfd9ece..e8c3957 100644
--- a/biz/service/element.go
+++ b/biz/service/element.go
@@ -3,18 +3,107 @@ package service
import (
"context"
- "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/common"
)
type element struct{}
var Element = &element{}
-// ListEnabledByIds 按 id 批量取启用元素(内容缓存)。
-func (s *element) ListEnabledByIds(ctx context.Context, ids []int64) ([]gdb.Record, error) {
- return dao.Element.Model().Ctx(ctx).Cache(contentCache(ctx)).
- WhereIn("id", ids).Where("status", consts.StatusEnabled).All()
+// ---------- 后台管理 ----------
+
+type adminElement struct{}
+
+var AdminElement = &adminElement{}
+
+// List 元素列表(e_type=0 全部),按类型 + 序号排序。
+func (s *adminElement) List(ctx context.Context, req *dto.AdminElementListReq) (*dto.AdminElementListRes, error) {
+ recs, err := dao.Element.ListAll(ctx, req.EType)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminElementItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminElementItem{
+ Id: r.Id, EType: r.EType, Name: r.Name,
+ Image: r.Image, Audio: r.Audio,
+ Description: r.Description,
+ Status: r.Status, SortOrder: r.SortOrder,
+ })
+ }
+ return &dto.AdminElementListRes{List: items}, nil
+}
+
+// Create 新增元素:拼音一次性标注后入库,清内容缓存。
+func (s *adminElement) Create(ctx context.Context, req *dto.AdminElementCreateReq) (*dto.AdminElementCreateRes, error) {
+ id, err := dao.Element.InsertAndReturnId(ctx, g.Map{
+ "e_type": req.EType, "name": req.Name, "name_pinyin": common.AnnotatePinyin(req.Name),
+ "image": req.Image, "audio": req.Audio,
+ "description": req.Description, "description_pinyin": common.AnnotatePinyin(req.Description),
+ "sort_order": req.SortOrder, "status": consts.StatusEnabled,
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableElement)
+ return &dto.AdminElementCreateRes{Id: id}, nil
+}
+
+// Update 编辑元素:全字段覆盖,拼音重标,清内容缓存。
+func (s *adminElement) Update(ctx context.Context, req *dto.AdminElementUpdateReq) (*dto.AdminElementUpdateRes, error) {
+ rec, err := dao.Element.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("元素不存在")
+ }
+ if err = dao.Element.UpdateByPk(ctx, req.Id, g.Map{
+ "e_type": req.EType, "name": req.Name, "name_pinyin": common.AnnotatePinyin(req.Name),
+ "image": req.Image, "audio": req.Audio,
+ "description": req.Description, "description_pinyin": common.AnnotatePinyin(req.Description),
+ "sort_order": req.SortOrder,
+ }); err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableElement)
+ return &dto.AdminElementUpdateRes{}, nil
+}
+
+// Disable 下架元素(软删除,引用它的内容前台自动隐藏)。
+func (s *adminElement) Disable(ctx context.Context, req *dto.AdminElementDisableReq) (*dto.AdminElementDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminElementDisableRes{}, nil
+}
+
+// Enable 上架元素。
+func (s *adminElement) Enable(ctx context.Context, req *dto.AdminElementEnableReq) (*dto.AdminElementEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminElementEnableRes{}, nil
+}
+
+// setStatus 上下架(软删除)。
+func (s *adminElement) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.Element.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("元素不存在")
+ }
+ if err = dao.Element.UpdateByPk(ctx, id, g.Map{"status": status}); err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableElement)
+ return nil
}
diff --git a/biz/service/level.go b/biz/service/level.go
index fdac19c..2c1b6e1 100644
--- a/biz/service/level.go
+++ b/biz/service/level.go
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
+ "math"
"time"
"github.com/gogf/gf/v2/database/gdb"
@@ -11,7 +12,11 @@ import (
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/domain"
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/model/entity"
"36wisdom/common"
+ "36wisdom/common/auth"
)
type level struct{}
@@ -43,90 +48,36 @@ func LevelOf(perfectCount int) (level int, title string) {
return
}
-// ElementVO 元素 VO(场景/人物/道具)。
-type ElementVO struct {
- Id int64
- EType int
- Name string
- NamePinyin string
- Image string
- Audio string
- Description string
-}
-
-// Option 分支选项 VO。
-type Option struct {
- OptionId int64
- Text string
- TextPinyin string
- FeedbackPros string
- FeedbackCons string
- Audio string
- Prop *ElementVO
-}
-
-// Node 关卡节点 VO(决策/终局)。
-type Node struct {
- NodeId int64
- Title string
- Content string
- ContentPinyin string
- Image string
- Audio string
- Character *ElementVO
- InteractionType int
- Config string
- Script string
- NodeType int
- ResultType int
- Options []*Option
-}
-
-// LevelDetail 关卡详情:场景 + 入口节点决策树 + 用户进度。
-type LevelDetail struct {
- LevelId int64
- Title string
- Scene *ElementVO
- SceneContent string
- SceneContentPinyin string
- SceneImage string
- SceneAudio string
- Entry *Node
- TotalFinals int
- Perfect bool
- Stars int
-}
-
// Detail 关卡详情:节点/选项/元素分表取回,内存组装决策树(入口节点)。
-func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (*LevelDetail, error) {
- child, err := getChildOf(ctx, parentUid, childId)
+func (s *level) Detail(ctx context.Context, req *dto.LevelDetailReq) (*dto.LevelDetailRes, error) {
+ child, err := getChildOf(ctx, auth.GetUid(ctx), req.ChildId)
if err != nil {
return nil, err
}
- levelRec, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(levelId).One()
+ levelRec, err := dao.Level.GetByPkCached(ctx, req.LevelId)
if err != nil {
return nil, err
}
- if levelRec.IsEmpty() || levelRec["status"].Int() != consts.StatusEnabled {
+ if levelRec == nil || levelRec.Status != consts.StatusEnabled {
return nil, gerror.New("关卡不存在")
}
- if levelRec["age_group"].String() != child["age_group"].String() {
+ if levelRec.AgeGroup != child.AgeGroup {
return nil, gerror.New("关卡不属于当前年龄段")
}
- nodeRecs, err := SceneNode.ListEnabledByLevel(ctx, levelId)
+ nodeRecs, err := dao.SceneNode.ListEnabledByLevelCached(ctx, req.LevelId)
if err != nil {
return nil, err
}
nodeIds := make([]int64, 0, len(nodeRecs))
for _, n := range nodeRecs {
- nodeIds = append(nodeIds, n["id"].Int64())
+ nodeIds = append(nodeIds, n.Id)
}
- optionRecs := []gdb.Record{}
+ var optionRecs []*entity.NodeOption
if len(nodeIds) > 0 {
- optionRecs, err = NodeOption.ListEnabledByNodeIds(ctx, nodeIds)
+ optionRecs, err = dao.NodeOption.ListEnabledByNodeIdsCached(ctx, nodeIds)
if err != nil {
return nil, err
}
@@ -137,141 +88,121 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (
return nil, err
}
- progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId)
+ progressRec, err := dao.UserProgress.GetByChildLevel(ctx, req.ChildId, req.LevelId)
if err != nil {
return nil, err
}
- var entryRec gdb.Record
+ var entryRec *entity.SceneNode
totalFinals := 0
for _, n := range nodeRecs {
- if n["is_entry"].Int() == 1 {
+ if n.IsEntry == 1 {
entryRec = n
}
- if n["result_type"].Int() > 0 {
+ if n.ResultType > 0 {
totalFinals++
}
}
- if entryRec.IsEmpty() {
+ if entryRec == nil {
return nil, gerror.New("关卡入口缺失")
}
- return &LevelDetail{
- LevelId: levelRec["id"].Int64(),
- Title: levelRec["title"].String(),
- Scene: elements[levelRec["scene_id"].Int64()],
- SceneContent: levelRec["scene_content"].String(),
- SceneContentPinyin: levelRec["scene_content_pinyin"].String(),
- SceneImage: levelRec["scene_image"].String(),
- SceneAudio: levelRec["scene_audio"].String(),
- Entry: buildNode(entryRec, optionRecs, elements),
+ res := &dto.LevelDetailRes{
+ LevelId: levelRec.Id,
+ Title: levelRec.Title,
+ Scene: elements[levelRec.SceneId],
+ SceneContent: levelRec.SceneContent,
+ SceneContentPinyin: levelRec.SceneContentPinyin,
+ SceneImage: levelRec.SceneImage,
+ SceneAudio: levelRec.SceneAudio,
+ Entry: *buildNode(entryRec, optionRecs, elements),
TotalFinals: totalFinals,
- Perfect: progressRec["perfect"].Int() == 1,
- Stars: progressRec["stars"].Int(),
- }, nil
+ }
+ if progressRec != nil {
+ res.Perfect = progressRec.Perfect == 1
+ res.Stars = progressRec.Stars
+ }
+ return res, nil
}
// elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。
-func elementsOf(ctx context.Context, levelRec gdb.Record, nodeRecs, optionRecs []gdb.Record) (map[int64]*ElementVO, error) {
- ids := []int64{levelRec["scene_id"].Int64()}
+func elementsOf(ctx context.Context, levelRec *entity.Level, nodeRecs []*entity.SceneNode, optionRecs []*entity.NodeOption) (map[int64]*dto.ElementVO, error) {
+ ids := []int64{levelRec.SceneId}
for _, n := range nodeRecs {
- if cid := n["character_id"].Int64(); cid > 0 {
+ if cid := n.CharacterId; cid > 0 {
ids = append(ids, cid)
}
}
for _, o := range optionRecs {
- if pid := o["prop_id"].Int64(); pid > 0 {
+ if pid := o.PropId; pid > 0 {
ids = append(ids, pid)
}
}
ids = uniqueInt64(ids)
- m := make(map[int64]*ElementVO, len(ids))
+ m := make(map[int64]*dto.ElementVO, len(ids))
if len(ids) == 0 {
return m, nil
}
- recs, err := Element.ListEnabledByIds(ctx, ids)
+ recs, err := dao.Element.ListEnabledByIdsCached(ctx, ids)
if err != nil {
return nil, err
}
for _, r := range recs {
- m[r["id"].Int64()] = &ElementVO{
- Id: r["id"].Int64(),
- EType: r["e_type"].Int(),
- Name: r["name"].String(),
- NamePinyin: r["name_pinyin"].String(),
- Image: r["image"].String(),
- Audio: r["audio"].String(),
- Description: r["description"].String(),
+ m[r.Id] = &dto.ElementVO{
+ Id: r.Id,
+ EType: r.EType,
+ Name: r.Name,
+ NamePinyin: r.NamePinyin,
+ Image: r.Image,
+ Audio: r.Audio,
+ Description: r.Description,
}
}
return m, nil
}
// buildNode 组装单个节点的选项与人物/道具元素。
-func buildNode(nodeRec gdb.Record, optionRecs []gdb.Record, elements map[int64]*ElementVO) *Node {
- node := &Node{
- NodeId: nodeRec["id"].Int64(),
- Title: nodeRec["title"].String(),
- Content: nodeRec["content"].String(),
- ContentPinyin: nodeRec["content_pinyin"].String(),
- Image: nodeRec["image"].String(),
- Audio: nodeRec["audio"].String(),
- Character: elements[nodeRec["character_id"].Int64()],
- InteractionType: nodeRec["interaction_type"].Int(),
- Config: nodeRec["config"].String(),
- Script: nodeRec["script"].String(),
- NodeType: nodeRec["node_type"].Int(),
- ResultType: nodeRec["result_type"].Int(),
+func buildNode(nodeRec *entity.SceneNode, optionRecs []*entity.NodeOption, elements map[int64]*dto.ElementVO) *dto.NodeVO {
+ node := &dto.NodeVO{
+ NodeId: nodeRec.Id,
+ Title: nodeRec.Title,
+ Content: nodeRec.Content,
+ ContentPinyin: nodeRec.ContentPinyin,
+ Image: nodeRec.Image,
+ Audio: nodeRec.Audio,
+ Character: elements[nodeRec.CharacterId],
+ InteractionType: nodeRec.InteractionType,
+ Config: nodeRec.Config,
+ Script: nodeRec.Script,
+ NodeType: nodeRec.NodeType,
+ ResultType: nodeRec.ResultType,
+ Options: make([]dto.OptionVO, 0),
}
for _, o := range optionRecs {
- if o["node_id"].Int64() != node.NodeId {
+ if o.NodeId != node.NodeId {
continue
}
- node.Options = append(node.Options, &Option{
- OptionId: o["id"].Int64(),
- Text: o["text"].String(),
- TextPinyin: o["text_pinyin"].String(),
- FeedbackPros: o["feedback_pros"].String(),
- FeedbackCons: o["feedback_cons"].String(),
- Audio: o["audio"].String(),
- Prop: elements[o["prop_id"].Int64()],
+ node.Options = append(node.Options, dto.OptionVO{
+ OptionId: o.Id,
+ Text: o.Text,
+ TextPinyin: o.TextPinyin,
+ FeedbackPros: o.FeedbackPros,
+ FeedbackCons: o.FeedbackCons,
+ Audio: o.Audio,
+ Prop: elements[o.PropId],
})
}
return node
}
-// SettleState 结算输入:该关历史状态(由进度 + 路径流水推导,锁内读取)。
-type SettleState struct {
- LevelCleared bool // 该关已通关(到过最佳终局)
- FailStreak int // 连续失败终局次数(非失败终局打断连续)
- ReachedFinals map[int64]bool // 已到达终局节点 id 集合
- TotalFinals int // 该关终局节点总数
- PerfectAwarded bool // 完美奖励是否已发放(进度表为准)
- BalanceAfter int // 结算前余额
-}
-
-// FinalSettle 结算结果(service 层领域值,controller 映射为 dto)。
-type FinalSettle struct {
- ResultType int
- Stars int
- ScoreDelta int
- Cleared bool
- Perfect bool
- UnlockNext bool
- CollectionUnlocked bool
- NewLevel int
- BalanceAfter int
- FinalNode *Node // 终局节点(含 script,v2.1 前端演绎结局台词后跳结算)
-}
-
// SettleFinal 纯函数判分(技术设计 4.3/4.4,可单测):
// - 首次到达某终局才结算,重复到达 delta=0
// - 未通关:最佳 +30 / 良好 +10 / 失败 -10(连续失败 ≥2 次后不再扣;余额扣至 0 不为负)
// - 已通关后补分支:只记完成度不结算积分
// - 终局数集齐 → 完美(+20);最佳终局 → 通关
-func SettleFinal(s SettleState, finalNodeId int64, resultType int) FinalSettle {
- f := FinalSettle{ResultType: resultType, BalanceAfter: s.BalanceAfter}
+func SettleFinal(s domain.SettleState, finalNodeId int64, resultType int) dto.FinalSettle {
+ f := dto.FinalSettle{ResultType: resultType, BalanceAfter: s.BalanceAfter}
switch resultType {
case consts.ResultBest:
f.Stars = 3
@@ -316,106 +247,113 @@ func SettleFinal(s SettleState, finalNodeId int64, resultType int) FinalSettle {
// Choose 分支闯关:校验 node/option 归属与解锁 → 决策节点返回下一节点;
// 终局节点在锁内结算(事务写进度/积分/计策卡)。路径流水尽力而为,失败不阻断。
-func (s *level) Choose(ctx context.Context, parentUid, childId, levelId, nodeId, optionId int64) (*Node, *FinalSettle, error) {
- child, err := getChildOf(ctx, parentUid, childId)
+func (s *level) Choose(ctx context.Context, req *dto.ChooseReq) (*dto.ChooseRes, error) {
+ child, err := getChildOf(ctx, auth.GetUid(ctx), req.ChildId)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- levelRec, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(levelId).One()
+ levelRec, err := dao.Level.GetByPkCached(ctx, req.LevelId)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- if levelRec.IsEmpty() || levelRec["status"].Int() != consts.StatusEnabled {
- return nil, nil, gerror.New("关卡不存在")
+ if levelRec == nil || levelRec.Status != consts.StatusEnabled {
+ return nil, gerror.New("关卡不存在")
}
- if levelRec["age_group"].String() != child["age_group"].String() {
- return nil, nil, gerror.New("关卡不属于当前年龄段")
+ if levelRec.AgeGroup != child.AgeGroup {
+ return nil, gerror.New("关卡不属于当前年龄段")
}
- nodeRec, err := SceneNode.GetInLevel(ctx, nodeId, levelId)
+ nodeRec, err := dao.SceneNode.GetInLevelCached(ctx, req.NodeId, req.LevelId)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- if nodeRec.IsEmpty() {
- return nil, nil, gerror.New("节点不存在")
+ if nodeRec == nil {
+ return nil, gerror.New("节点不存在")
}
- optionRec, err := NodeOption.GetInNode(ctx, optionId, nodeId)
+ optionRec, err := dao.NodeOption.GetInNodeCached(ctx, req.OptionId, req.NodeId)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- if optionRec.IsEmpty() {
- return nil, nil, gerror.New("选项不存在")
+ if optionRec == nil {
+ return nil, gerror.New("选项不存在")
}
- nextRec, err := SceneNode.GetInLevel(ctx, optionRec["next_node_id"].Int64(), levelId)
+ nextRec, err := dao.SceneNode.GetInLevelCached(ctx, optionRec.NextNodeId, req.LevelId)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- if nextRec.IsEmpty() {
- return nil, nil, gerror.New("目标节点不存在")
+ if nextRec == nil {
+ return nil, gerror.New("目标节点不存在")
}
- progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId)
+ progressRec, err := dao.UserProgress.GetByChildLevel(ctx, req.ChildId, req.LevelId)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- if !s.levelUnlocked(ctx, childId, child, levelRec, progressRec) {
- return nil, nil, gerror.New("关卡未解锁")
+ if !s.levelUnlocked(ctx, req.ChildId, child, levelRec, progressRec) {
+ return nil, gerror.New("关卡未解锁")
}
- if nextRec["result_type"].Int() == consts.ResultNone {
- s.logRoute(ctx, childId, levelId, nodeId, optionId, consts.ResultNone)
+ if nextRec.ResultType == consts.ResultNone {
+ s.logRoute(ctx, req.ChildId, req.LevelId, req.NodeId, req.OptionId, consts.ResultNone)
node, err := nodeOf(ctx, levelRec, nextRec)
if err != nil {
- return nil, nil, err
+ return nil, err
}
- return node, nil, nil
+ return &dto.ChooseRes{Next: node}, nil
}
// v2.1:终局节点剧本先于结算构建(只读查询失败则未结算,无副作用)
finalNode, err := nodeOf(ctx, levelRec, nextRec)
if err != nil {
- return nil, nil, err
+ return nil, err
}
// 终局:路径流水在锁内结算后记录,避免状态读到本次到达导致重复结算误判
- settle, err := common.WithLock(ctx, fmt.Sprintf("child:%d:level:%d", childId, levelId), 10*time.Second, 3, 200*time.Millisecond, func() (*FinalSettle, error) {
- return s.settle(ctx, child, levelRec, nextRec, nodeId, optionId)
+ settle, err := common.WithLock(ctx, fmt.Sprintf("child:%d:level:%d", req.ChildId, req.LevelId), 10*time.Second, 3, 200*time.Millisecond, func() (*dto.FinalSettle, error) {
+ return s.settle(ctx, child, levelRec, nextRec, req.NodeId, req.OptionId)
})
- if err != nil {
- return nil, nil, err
- }
- settle.FinalNode = finalNode
- return nil, settle, nil
-}
-
-// levelUnlocked 关卡解锁判定:本关已通关可重玩,否则按解锁链校验计策解锁。
-func (s *level) levelUnlocked(ctx context.Context, childId int64, child gdb.Record, levelRec, progressRec gdb.Record) bool {
- if progressRec["stars"].Int() >= 3 {
- return true
- }
- strategyRec, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).
- WherePri(levelRec["strategy_id"].Int64()).One()
- if err != nil {
- return false
- }
- unlocked, _ := Strategy.unlockState(ctx, childId, child["age_group"].String(), strategyRec)
- return unlocked
-}
-
-// logRoute 路径流水(尽力而为,写失败不阻断主流程)。
-func (s *level) logRoute(ctx context.Context, childId, levelId, nodeId, optionId int64, resultType int) {
- _ = UserRouteLog.Append(ctx, childId, levelId, nodeId, optionId, resultType)
-}
-
-// nodeOf 组装下一决策节点(选项 + 元素)。
-func nodeOf(ctx context.Context, levelRec, nodeRec gdb.Record) (*Node, error) {
- optionRecs, err := NodeOption.ListEnabledByNode(ctx, nodeRec["id"].Int64())
if err != nil {
return nil, err
}
- elements, err := elementsOf(ctx, levelRec, []gdb.Record{nodeRec}, optionRecs)
+ settle.FinalNode = finalNode
+ return &dto.ChooseRes{Final: settle}, nil
+}
+
+// levelUnlocked 关卡解锁判定:本关已通关可重玩,否则按解锁链校验计策解锁。
+func (s *level) levelUnlocked(ctx context.Context, childId int64, child *entity.Child, levelRec *entity.Level, progressRec *entity.UserProgress) bool {
+ if progressRec != nil && progressRec.Stars >= 3 {
+ return true
+ }
+ strategyRec, err := dao.Strategy.GetByPkCached(ctx, levelRec.StrategyId)
+ if err != nil || strategyRec == nil {
+ return false
+ }
+ unlocked, _ := Strategy.unlockState(ctx, childId, child.AgeGroup, strategyRec)
+ return unlocked
+}
+
+// logRoute 路径流水(尽力而为,写失败仅记录日志不阻断主流程)。
+func (s *level) logRoute(ctx context.Context, childId, levelId, nodeId, optionId int64, resultType int) {
+ if _, err := dao.UserRouteLog.InsertAndReturnId(ctx, g.Map{
+ "child_id": childId,
+ "level_id": levelId,
+ "node_id": nodeId,
+ "option_id": optionId,
+ "result_type": resultType,
+ }); err != nil {
+ g.Log().Warning(ctx, "记录路径流水失败", err)
+ }
+}
+
+// nodeOf 组装下一决策节点(选项 + 元素)。
+func nodeOf(ctx context.Context, levelRec *entity.Level, nodeRec *entity.SceneNode) (*dto.NodeVO, error) {
+ optionRecs, err := dao.NodeOption.ListEnabledByNodeCached(ctx, nodeRec.Id)
+ if err != nil {
+ return nil, err
+ }
+ elements, err := elementsOf(ctx, levelRec, []*entity.SceneNode{nodeRec}, optionRecs)
if err != nil {
return nil, err
}
@@ -423,75 +361,79 @@ func nodeOf(ctx context.Context, levelRec, nodeRec gdb.Record) (*Node, error) {
}
// settle 终局结算(调用方持锁):锁内重读状态 → 纯函数判分 → 事务写库 → 记录本次终局路径 → 派生结果。
-func (s *level) settle(ctx context.Context, child gdb.Record, levelRec, finalRec gdb.Record, nodeId, optionId int64) (*FinalSettle, error) {
- childId := child["id"].Int64()
- levelId := levelRec["id"].Int64()
+func (s *level) settle(ctx context.Context, child *entity.Child, levelRec *entity.Level, finalRec *entity.SceneNode, nodeId, optionId int64) (*dto.FinalSettle, error) {
+ childId := child.Id
+ levelId := levelRec.Id
state, err := s.settleState(ctx, childId, levelId)
if err != nil {
return nil, err
}
- f := SettleFinal(state, finalRec["id"].Int64(), finalRec["result_type"].Int())
+ f := SettleFinal(state, finalRec.Id, finalRec.ResultType)
if f.ScoreDelta != 0 || f.Cleared || f.Perfect {
if err = s.commitSettle(ctx, childId, levelRec, &f); err != nil {
return nil, err
}
}
- s.logRoute(ctx, childId, levelId, nodeId, optionId, finalRec["result_type"].Int())
+ s.logRoute(ctx, childId, levelId, nodeId, optionId, finalRec.ResultType)
s.deriveExtras(ctx, child, levelRec, &f)
return &f, nil
}
// settleState 锁内重读该关历史状态:终局数、已到达终局、连续失败、余额。
-func (s *level) settleState(ctx context.Context, childId, levelId int64) (SettleState, error) {
- state := SettleState{ReachedFinals: map[int64]bool{}}
+func (s *level) settleState(ctx context.Context, childId, levelId int64) (domain.SettleState, error) {
+ state := domain.SettleState{ReachedFinals: map[int64]bool{}}
- progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId)
+ progressRec, err := dao.UserProgress.GetByChildLevel(ctx, childId, levelId)
if err != nil {
return state, err
}
- state.LevelCleared = progressRec["stars"].Int() >= 3
- state.PerfectAwarded = progressRec["perfect"].Int() == 1
+ if progressRec != nil {
+ state.LevelCleared = progressRec.Stars >= 3
+ state.PerfectAwarded = progressRec.Perfect == 1
+ }
- childRec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
+ childRec, err := dao.Child.GetByPk(ctx, childId)
if err != nil {
return state, err
}
- state.BalanceAfter = childRec["points"].Int()
+ if childRec != nil {
+ state.BalanceAfter = childRec.Points
+ }
- totalFinals, err := SceneNode.CountFinalsByLevel(ctx, levelId)
+ totalFinals, err := dao.SceneNode.CountFinalsByLevel(ctx, levelId)
if err != nil {
return state, err
}
state.TotalFinals = totalFinals
- logs, err := UserRouteLog.ListFinalsByChildLevel(ctx, childId, levelId)
+ logs, err := dao.UserRouteLog.ListFinalsByChildLevel(ctx, childId, levelId)
if err != nil {
return state, err
}
// 日志记录的是选择节点(node_id)+ 选项;终局节点经选项的 next_node_id 解析
optionIds := make([]int64, 0, len(logs))
for _, l := range logs {
- optionIds = append(optionIds, l["option_id"].Int64())
+ optionIds = append(optionIds, l.OptionId)
}
nextOf := map[int64]int64{}
if len(optionIds) > 0 {
- opts, err := NodeOption.ListByOptionIds(ctx, optionIds)
+ opts, err := dao.NodeOption.ListByOptionIds(ctx, optionIds)
if err != nil {
return state, err
}
for _, o := range opts {
- nextOf[o["id"].Int64()] = o["next_node_id"].Int64()
+ nextOf[o.Id] = o.NextNodeId
}
}
for _, l := range logs {
- if nid, ok := nextOf[l["option_id"].Int64()]; ok {
+ if nid, ok := nextOf[l.OptionId]; ok {
state.ReachedFinals[nid] = true
}
}
for i := len(logs) - 1; i >= 0; i-- {
- if logs[i]["result_type"].Int() == consts.ResultFail {
+ if logs[i].ResultType == consts.ResultFail {
state.FailStreak++
} else {
break
@@ -501,24 +443,27 @@ func (s *level) settleState(ctx context.Context, childId, levelId int64) (Settle
}
// commitSettle 结算事务:进度(最高星 + 完美标记 + 内容版本)、积分流水与余额、计策卡。
-func (s *level) commitSettle(ctx context.Context, childId int64, levelRec gdb.Record, f *FinalSettle) error {
- levelId := levelRec["id"].Int64()
+func (s *level) commitSettle(ctx context.Context, childId int64, levelRec *entity.Level, f *dto.FinalSettle) error {
+ levelId := levelRec.Id
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
stars := f.Stars
perfect := 0
if f.Perfect {
perfect = 1
}
- if err := UserProgress.UpsertInTx(ctx, tx, childId, levelId, stars, perfect, levelRec["content_version"].Int()); err != nil {
+ if err := dao.UserProgress.UpsertInTx(ctx, tx, childId, levelId, stars, perfect, levelRec.ContentVersion); err != nil {
return err
}
if f.ScoreDelta != 0 {
- childRec, err := tx.Model(consts.TableChild).Ctx(ctx).WherePri(childId).One()
+ childRec, err := dao.Child.GetByPkInTx(ctx, tx, childId)
if err != nil {
return err
}
- balance := childRec["points"].Int() + f.ScoreDelta
+ if childRec == nil {
+ return gerror.New("孩子不存在")
+ }
+ balance := childRec.Points + f.ScoreDelta
if _, err = tx.Model(consts.TableChild).Ctx(ctx).Data(g.Map{"points": balance}).WherePri(childId).Update(); err != nil {
return err
}
@@ -539,33 +484,32 @@ func (s *level) commitSettle(ctx context.Context, childId int64, levelRec gdb.Re
}
// collectIfAllPerfect 本计全部关卡完美 → 写计策卡(防重复:已存在则跳过)。
-func (s *level) collectIfAllPerfect(ctx context.Context, tx gdb.TX, childId int64, levelRec gdb.Record, f *FinalSettle) error {
+func (s *level) collectIfAllPerfect(ctx context.Context, tx gdb.TX, childId int64, levelRec *entity.Level, f *dto.FinalSettle) error {
if !f.Perfect {
return nil
}
- strategyId := levelRec["strategy_id"].Int64()
- levels, err := tx.Model(consts.TableLevel).Ctx(ctx).
- Where("strategy_id", strategyId).Where("status", consts.StatusEnabled).All()
+ strategyId := levelRec.StrategyId
+ levels, err := dao.Level.ListEnabledByStrategyInTx(ctx, tx, strategyId)
if err != nil {
return err
}
for _, lv := range levels {
- rec, err := UserProgress.GetInTx(ctx, tx, childId, lv["id"].Int64())
+ rec, err := dao.UserProgress.GetInTx(ctx, tx, childId, lv.Id)
if err != nil {
return err
}
- if rec.IsEmpty() || rec["perfect"].Int() != 1 {
+ if rec == nil || rec.Perfect != 1 {
return nil
}
}
- exists, err := UserCollection.ExistsInTx(ctx, tx, childId, strategyId)
+ exists, err := dao.UserCollection.ExistsInTx(ctx, tx, childId, strategyId)
if err != nil {
return err
}
if exists {
return nil
}
- if err = UserCollection.InsertInTx(ctx, tx, childId, strategyId); err != nil {
+ if err = dao.UserCollection.InsertInTx(ctx, tx, childId, strategyId); err != nil {
return err
}
f.CollectionUnlocked = true
@@ -573,17 +517,16 @@ func (s *level) collectIfAllPerfect(ctx context.Context, tx gdb.TX, childId int6
}
// deriveExtras 结算后派生:计策卡解锁提示、下一计解锁、成长等级。
-func (s *level) deriveExtras(ctx context.Context, child gdb.Record, levelRec gdb.Record, f *FinalSettle) {
- childId := child["id"].Int64()
+func (s *level) deriveExtras(ctx context.Context, child *entity.Child, levelRec *entity.Level, f *dto.FinalSettle) {
+ childId := child.Id
if f.CollectionUnlocked {
- if next, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("unlock_before", levelRec["strategy_id"].Int64()).One(); err == nil && !next.IsEmpty() {
+ if next, err := dao.Strategy.GetByUnlockBefore(ctx, levelRec.StrategyId); err == nil && next != nil {
f.UnlockNext = true
}
}
- before, err := UserProgress.CountPerfectByChild(ctx, childId)
+ before, err := dao.UserProgress.CountPerfectByChild(ctx, childId)
if err != nil {
return
}
@@ -597,3 +540,249 @@ func (s *level) deriveExtras(ctx context.Context, child gdb.Record, levelRec gdb
f.NewLevel = newLevel
}
}
+
+// ---------- 后台管理 ----------
+
+type adminLevel struct{}
+
+var AdminLevel = &adminLevel{}
+
+// List 指定计策下全部关卡(含下架,后台全量展示),按序号排序。
+func (s *adminLevel) List(ctx context.Context, req *dto.AdminLevelListReq) (*dto.AdminLevelListRes, error) {
+ recs, err := dao.Level.ListByStrategyId(ctx, req.StrategyId)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(recs))
+ sceneIds := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ ids = append(ids, r.Id)
+ sceneIds = append(sceneIds, r.SceneId)
+ }
+ counts, err := dao.SceneNode.CountByLevelIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ names, err := sceneNames(ctx, sceneIds)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminLevelItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminLevelItem{
+ Id: r.Id, StrategyId: r.StrategyId, Title: r.Title,
+ SceneId: r.SceneId, SceneName: names[r.SceneId],
+ SceneContent: r.SceneContent, SceneImage: r.SceneImage,
+ SceneAudio: r.SceneAudio, AgeGroup: r.AgeGroup,
+ ContentVersion: r.ContentVersion, SortOrder: r.SortOrder,
+ Status: r.Status, NodeCount: counts[r.Id],
+ })
+ }
+ return &dto.AdminLevelListRes{List: items}, nil
+}
+
+// sceneNames 批量元素名(含下架,后台全量展示)。
+func sceneNames(ctx context.Context, ids []int64) (map[int64]string, error) {
+ m := make(map[int64]string, len(ids))
+ ids = uniqueInt64(ids)
+ if len(ids) == 0 {
+ return m, nil
+ }
+ recs, err := dao.Element.ListByIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r.Id] = r.Name
+ }
+ return m, nil
+}
+
+// checkStrategy 计策存在性校验(含下架,历史关卡允许继续维护)。
+func checkStrategy(ctx context.Context, strategyId int64) error {
+ rec, err := dao.Strategy.GetByPk(ctx, strategyId)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("计策不存在")
+ }
+ return nil
+}
+
+// Create 新增关卡:场景文案拼音标注,初始 content_version=1,清内容缓存。
+func (s *adminLevel) Create(ctx context.Context, req *dto.AdminLevelCreateReq) (*dto.AdminLevelCreateRes, error) {
+ if err := checkStrategy(ctx, req.StrategyId); err != nil {
+ return nil, err
+ }
+ id, err := dao.Level.InsertAndReturnId(ctx, g.Map{
+ "strategy_id": req.StrategyId, "title": req.Title,
+ "scene_id": req.SceneId, "scene_content": req.SceneContent,
+ "scene_content_pinyin": common.AnnotatePinyin(req.SceneContent),
+ "scene_image": req.SceneImage, "scene_audio": req.SceneAudio,
+ "age_group": req.AgeGroup, "content_version": 1,
+ "sort_order": req.SortOrder, "status": consts.StatusEnabled,
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableLevel)
+ return &dto.AdminLevelCreateRes{Id: id}, nil
+}
+
+// Update 编辑关卡:全字段覆盖,拼音重标,content_version+1(前台据此可重新挑战),清内容缓存。
+func (s *adminLevel) Update(ctx context.Context, req *dto.AdminLevelUpdateReq) (*dto.AdminLevelUpdateRes, error) {
+ rec, err := dao.Level.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("关卡不存在")
+ }
+ if err = checkStrategy(ctx, req.StrategyId); err != nil {
+ return nil, err
+ }
+ if err = dao.Level.UpdateByPk(ctx, req.Id, g.Map{
+ "strategy_id": req.StrategyId, "title": req.Title,
+ "scene_id": req.SceneId, "scene_content": req.SceneContent,
+ "scene_content_pinyin": common.AnnotatePinyin(req.SceneContent),
+ "scene_image": req.SceneImage, "scene_audio": req.SceneAudio,
+ "age_group": req.AgeGroup, "sort_order": req.SortOrder,
+ "content_version": gdb.Raw("content_version+1"),
+ }); err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableLevel)
+ return &dto.AdminLevelUpdateRes{}, nil
+}
+
+// Disable 下架关卡(软删除):内容可见性变化同样 bump content_version。
+func (s *adminLevel) Disable(ctx context.Context, req *dto.AdminLevelDisableReq) (*dto.AdminLevelDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminLevelDisableRes{}, nil
+}
+
+// Enable 上架关卡。
+func (s *adminLevel) Enable(ctx context.Context, req *dto.AdminLevelEnableReq) (*dto.AdminLevelEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminLevelEnableRes{}, nil
+}
+
+// setStatus 上下架(软删除):bump content_version。
+func (s *adminLevel) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.Level.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("关卡不存在")
+ }
+ if err = dao.Level.UpdateByPk(ctx, id, g.Map{
+ "status": status,
+ "content_version": gdb.Raw("content_version+1"),
+ }); err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableLevel)
+ return nil
+}
+
+// ---------- 关卡统计 ----------
+
+type optKey struct{ nodeId, optionId int64 }
+
+type adminStats struct{}
+
+var AdminStats = &adminStats{}
+
+// Level 关卡统计:5 条单表 SQL + 内存组装,不缓存。
+func (s *adminStats) Level(ctx context.Context, req *dto.AdminStatsLevelReq) (*dto.AdminStatsLevelRes, error) {
+ levelRec, err := dao.Level.GetByPk(ctx, req.LevelId)
+ if err != nil {
+ return nil, err
+ }
+ if levelRec == nil {
+ return nil, gerror.New("关卡不存在")
+ }
+
+ players, perfectCount, err := dao.UserProgress.ProgressStatsByLevel(ctx, req.LevelId)
+ if err != nil {
+ return nil, err
+ }
+ stats := &dto.AdminStatsLevelRes{Players: players, PerfectCount: perfectCount}
+ if players > 0 {
+ stats.PerfectRate = math.Round(float64(perfectCount)*1000/float64(players)) / 10
+ }
+
+ nodes, err := dao.SceneNode.ListByLevel(ctx, req.LevelId)
+ if err != nil {
+ return nil, err
+ }
+ nodeIds := make([]int64, 0, len(nodes))
+ for _, n := range nodes {
+ nodeIds = append(nodeIds, n.Id)
+ }
+ opts, err := dao.NodeOption.ListByNodeIds(ctx, nodeIds)
+ if err != nil {
+ return nil, err
+ }
+ optionByKey := make(map[optKey]*dto.AdminStatsOption, len(opts))
+ for _, o := range opts {
+ os := &dto.AdminStatsOption{OptionId: o.Id, NodeId: o.NodeId, Text: o.Text}
+ stats.Options = append(stats.Options, os)
+ optionByKey[optKey{os.NodeId, os.OptionId}] = os
+ }
+
+ logs, err := dao.UserRouteLog.RouteStatsByLevel(ctx, req.LevelId)
+ if err != nil {
+ return nil, err
+ }
+ nodeById := make(map[int64]*dto.AdminStatsNode, len(nodes))
+ for _, l := range logs {
+ cnt := l.Cnt
+ ns := nodeById[l.NodeId]
+ if ns == nil {
+ ns = &dto.AdminStatsNode{NodeId: l.NodeId}
+ nodeById[l.NodeId] = ns
+ }
+ ns.ReachCount += cnt
+ switch l.ResultType {
+ case consts.ResultNone:
+ ns.None += cnt
+ case consts.ResultFail:
+ ns.Fail += cnt
+ case consts.ResultGood:
+ ns.Good += cnt
+ case consts.ResultBest:
+ ns.Best += cnt
+ }
+ if l.OptionId > 0 {
+ if os := optionByKey[optKey{l.NodeId, l.OptionId}]; os != nil {
+ switch l.ResultType {
+ case consts.ResultNone:
+ os.ChooseCount += cnt
+ case consts.ResultFail:
+ os.Fail += cnt
+ case consts.ResultGood:
+ os.Good += cnt
+ case consts.ResultBest:
+ os.Best += cnt
+ }
+ }
+ }
+ }
+ for _, n := range nodes {
+ ns := nodeById[n.Id]
+ if ns == nil {
+ ns = &dto.AdminStatsNode{NodeId: n.Id}
+ }
+ ns.Content = n.Content
+ ns.NodeType = n.NodeType
+ stats.Nodes = append(stats.Nodes, ns)
+ }
+ return stats, nil
+}
diff --git a/biz/service/level_test.go b/biz/service/level_test.go
index 88d83cf..35566fc 100644
--- a/biz/service/level_test.go
+++ b/biz/service/level_test.go
@@ -4,10 +4,11 @@ import (
"testing"
"36wisdom/biz/consts"
+ "36wisdom/biz/model/domain"
)
-func state() SettleState {
- return SettleState{ReachedFinals: map[int64]bool{}, TotalFinals: 5, BalanceAfter: 100}
+func state() domain.SettleState {
+ return domain.SettleState{ReachedFinals: map[int64]bool{}, TotalFinals: 5, BalanceAfter: 100}
}
func TestSettleFinal_BestFirstTime(t *testing.T) {
@@ -54,13 +55,13 @@ func TestSettleFinal_FailFirstTime(t *testing.T) {
func TestSettleFinal_FailStreakLimit(t *testing.T) {
// 连续失败 2 次后不再扣分:第 3 次到达失败终局 delta=0
- s := SettleState{FailStreak: 2, ReachedFinals: map[int64]bool{1: true, 2: true}, TotalFinals: 5, BalanceAfter: 100}
+ s := domain.SettleState{FailStreak: 2, ReachedFinals: map[int64]bool{1: true, 2: true}, TotalFinals: 5, BalanceAfter: 100}
f := SettleFinal(s, 3, consts.ResultFail)
if f.ScoreDelta != 0 {
t.Fatalf("连续失败第 3 次应不再扣分,实际 %d", f.ScoreDelta)
}
// 连续失败第 2 次仍扣
- s2 := SettleState{FailStreak: 1, ReachedFinals: map[int64]bool{1: true}, TotalFinals: 5, BalanceAfter: 100}
+ s2 := domain.SettleState{FailStreak: 1, ReachedFinals: map[int64]bool{1: true}, TotalFinals: 5, BalanceAfter: 100}
f2 := SettleFinal(s2, 2, consts.ResultFail)
if f2.ScoreDelta != consts.PointsFail {
t.Fatalf("连续失败第 2 次应扣 %d,实际 %d", consts.PointsFail, f2.ScoreDelta)
@@ -68,7 +69,7 @@ func TestSettleFinal_FailStreakLimit(t *testing.T) {
}
func TestSettleFinal_BalanceFloor(t *testing.T) {
- s := SettleState{ReachedFinals: map[int64]bool{}, TotalFinals: 5, BalanceAfter: 5}
+ s := domain.SettleState{ReachedFinals: map[int64]bool{}, TotalFinals: 5, BalanceAfter: 5}
f := SettleFinal(s, 1, consts.ResultFail)
if f.ScoreDelta != -5 {
t.Fatalf("余额不足应扣到 0(-5),实际 %d", f.ScoreDelta)
@@ -80,7 +81,7 @@ func TestSettleFinal_BalanceFloor(t *testing.T) {
func TestSettleFinal_ClearedNoSettle(t *testing.T) {
// 已通关后补分支:只记完成度不结算积分
- s := SettleState{LevelCleared: true, ReachedFinals: map[int64]bool{101: true}, TotalFinals: 5, BalanceAfter: 130}
+ s := domain.SettleState{LevelCleared: true, ReachedFinals: map[int64]bool{101: true}, TotalFinals: 5, BalanceAfter: 130}
f := SettleFinal(s, 102, consts.ResultGood)
if f.ScoreDelta != 0 {
t.Fatalf("已通关补分支应不结算,实际 %d", f.ScoreDelta)
@@ -88,7 +89,7 @@ func TestSettleFinal_ClearedNoSettle(t *testing.T) {
}
func TestSettleFinal_Perfect(t *testing.T) {
- s := SettleState{ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true}, TotalFinals: 5, BalanceAfter: 100}
+ s := domain.SettleState{ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true}, TotalFinals: 5, BalanceAfter: 100}
f := SettleFinal(s, 5, consts.ResultGood)
if !f.Perfect {
t.Fatal("全部终局到达应完美")
@@ -100,7 +101,7 @@ func TestSettleFinal_Perfect(t *testing.T) {
func TestSettleFinal_PerfectOnlyOnce(t *testing.T) {
// 已完美(进度表标记)后重复到达:不再触发完美奖励
- s := SettleState{LevelCleared: true, PerfectAwarded: true, ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true, 5: true}, TotalFinals: 5, BalanceAfter: 100}
+ s := domain.SettleState{LevelCleared: true, PerfectAwarded: true, ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true, 5: true}, TotalFinals: 5, BalanceAfter: 100}
f := SettleFinal(s, 5, consts.ResultBest)
if f.Perfect {
t.Fatal("已完美不应重复触发")
@@ -112,7 +113,7 @@ func TestSettleFinal_PerfectOnlyOnce(t *testing.T) {
func TestSettleFinal_PerfectWithLogDrift(t *testing.T) {
// 日志已集齐但进度未发放(日志尽力而为可能漂移):本次到达应补发完美奖励
- s := SettleState{LevelCleared: true, ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true, 5: true}, TotalFinals: 5, BalanceAfter: 100}
+ s := domain.SettleState{LevelCleared: true, ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true, 5: true}, TotalFinals: 5, BalanceAfter: 100}
f := SettleFinal(s, 5, consts.ResultGood)
if !f.Perfect {
t.Fatal("日志集齐但未发放时应补发完美")
@@ -124,7 +125,7 @@ func TestSettleFinal_PerfectWithLogDrift(t *testing.T) {
func TestSettleFinal_RepeatFinal(t *testing.T) {
// 重复路线不重复结算
- s := SettleState{ReachedFinals: map[int64]bool{101: true}, TotalFinals: 5, BalanceAfter: 100}
+ s := domain.SettleState{ReachedFinals: map[int64]bool{101: true}, TotalFinals: 5, BalanceAfter: 100}
f := SettleFinal(s, 101, consts.ResultBest)
if f.ScoreDelta != 0 {
t.Fatalf("重复终局应不结算,实际 %d", f.ScoreDelta)
diff --git a/biz/service/life_task.go b/biz/service/life_task.go
index 67dfedb..bd917da 100644
--- a/biz/service/life_task.go
+++ b/biz/service/life_task.go
@@ -3,23 +3,113 @@ package service
import (
"context"
- "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/common"
)
-type lifeTask struct{}
+// ---------- 后台管理 ----------
-var LifeTask = &lifeTask{}
+type adminLifeTask struct{}
-// GetByPk 生活践行任务。
-func (s *lifeTask) GetByPk(ctx context.Context, id int64) (gdb.Record, error) {
- return dao.LifeTask.GetOneByPk(ctx, id)
+var AdminLifeTask = &adminLifeTask{}
+
+// List 全部生活任务(含下架),按 id 排序。
+func (s *adminLifeTask) List(ctx context.Context, req *dto.AdminLifeTaskListReq) (*dto.AdminLifeTaskListRes, error) {
+ recs, err := dao.LifeTask.ListAll(ctx)
+ if err != nil {
+ return nil, err
+ }
+ strategyIds := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ strategyIds = append(strategyIds, r.StrategyId)
+ }
+ names, err := strategyNames(ctx, strategyIds)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminLifeTaskItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminLifeTaskItem{
+ Id: r.Id, StrategyId: r.StrategyId, StrategyName: names[r.StrategyId],
+ Title: r.Title, Description: r.Description,
+ Guide: r.Guide, RewardPoints: r.RewardPoints,
+ Status: r.Status,
+ })
+ }
+ return &dto.AdminLifeTaskListRes{List: items}, nil
}
-// ListEnabled 启用任务(内容缓存)。
-func (s *lifeTask) ListEnabled(ctx context.Context) ([]gdb.Record, error) {
- return dao.LifeTask.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("status", consts.StatusEnabled).Order("id ASC").All()
+// Create 新增生活任务:计策存在校验,清内容缓存。
+func (s *adminLifeTask) Create(ctx context.Context, req *dto.AdminLifeTaskCreateReq) (*dto.AdminLifeTaskCreateRes, error) {
+ if err := checkStrategy(ctx, req.StrategyId); err != nil {
+ return nil, err
+ }
+ id, err := dao.LifeTask.InsertAndReturnId(ctx, g.Map{
+ "strategy_id": req.StrategyId, "title": req.Title, "description": req.Description,
+ "guide": req.Guide, "reward_points": req.RewardPoints, "status": consts.StatusEnabled,
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableLifeTask)
+ return &dto.AdminLifeTaskCreateRes{Id: id}, nil
+}
+
+// Update 编辑生活任务,清内容缓存。
+func (s *adminLifeTask) Update(ctx context.Context, req *dto.AdminLifeTaskUpdateReq) (*dto.AdminLifeTaskUpdateRes, error) {
+ rec, err := dao.LifeTask.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("生活任务不存在")
+ }
+ if err = checkStrategy(ctx, req.StrategyId); err != nil {
+ return nil, err
+ }
+ if err = dao.LifeTask.UpdateByPk(ctx, req.Id, g.Map{
+ "strategy_id": req.StrategyId, "title": req.Title, "description": req.Description,
+ "guide": req.Guide, "reward_points": req.RewardPoints,
+ }); err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableLifeTask)
+ return &dto.AdminLifeTaskUpdateRes{}, nil
+}
+
+// setStatus 上下架(软删除)。
+func (s *adminLifeTask) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.LifeTask.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("生活任务不存在")
+ }
+ if err = dao.LifeTask.UpdateByPk(ctx, id, g.Map{"status": status}); err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableLifeTask)
+ return nil
+}
+
+// Disable 下架生活任务。
+func (s *adminLifeTask) Disable(ctx context.Context, req *dto.AdminLifeTaskDisableReq) (*dto.AdminLifeTaskDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminLifeTaskDisableRes{}, nil
+}
+
+// Enable 上架生活任务。
+func (s *adminLifeTask) Enable(ctx context.Context, req *dto.AdminLifeTaskEnableReq) (*dto.AdminLifeTaskEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminLifeTaskEnableRes{}, nil
}
diff --git a/biz/service/node_option.go b/biz/service/node_option.go
index c155ce9..5e49ece 100644
--- a/biz/service/node_option.go
+++ b/biz/service/node_option.go
@@ -4,36 +4,265 @@ import (
"context"
"github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/model/entity"
+ "36wisdom/common"
)
-type nodeOption struct{}
+// ---------- 后台管理 ----------
-var NodeOption = &nodeOption{}
+type adminNodeOption struct{}
-// GetInNode 节点下启用选项(按选项 id + 节点 id 精确查询)。
-func (s *nodeOption) GetInNode(ctx context.Context, optionId, nodeId int64) (gdb.Record, error) {
- return dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("id", optionId).Where("node_id", nodeId).Where("status", consts.StatusEnabled).One()
+var AdminNodeOption = &adminNodeOption{}
+
+// List 指定节点下全部选项(含下架),按序号排序。
+func (s *adminNodeOption) List(ctx context.Context, req *dto.AdminNodeOptionListReq) (*dto.AdminNodeOptionListRes, error) {
+ recs, err := dao.NodeOption.ListByNode(ctx, req.NodeId)
+ if err != nil {
+ return nil, err
+ }
+ nodeIds := make([]int64, 0, len(recs)*2)
+ propIds := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ nodeIds = append(nodeIds, r.NodeId, r.NextNodeId)
+ propIds = append(propIds, r.PropId)
+ }
+ titles, err := nodeTitles(ctx, nodeIds)
+ if err != nil {
+ return nil, err
+ }
+ names, err := elementNamesAll(ctx, propIds)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminNodeOptionItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminNodeOptionItem{
+ Id: r.Id, NodeId: r.NodeId, NodeTitle: titles[r.NodeId],
+ Text: r.Text, PropId: r.PropId, PropName: names[r.PropId],
+ Audio: r.Audio, NextNodeId: r.NextNodeId, NextNodeTitle: titles[r.NextNodeId],
+ Feedback: r.Feedback, FeedbackAudio: r.FeedbackAudio,
+ SortOrder: r.SortOrder, Status: r.Status,
+ })
+ }
+ return &dto.AdminNodeOptionListRes{List: items}, nil
}
-// ListEnabledByNode 节点下启用选项,按 sort_order 升序(内容缓存)。
-func (s *nodeOption) ListEnabledByNode(ctx context.Context, nodeId int64) ([]gdb.Record, error) {
- return dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("node_id", nodeId).Where("status", consts.StatusEnabled).
- Order("sort_order ASC").All()
+// nodeTitles 批量节点标题(无标题回退内容前 20 字)。
+func nodeTitles(ctx context.Context, ids []int64) (map[int64]string, error) {
+ m := make(map[int64]string, len(ids))
+ ids = uniqueInt64(ids)
+ if len(ids) == 0 {
+ return m, nil
+ }
+ recs, err := dao.SceneNode.ListByIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ t := r.Title
+ if t == "" {
+ t = r.Content
+ if len([]rune(t)) > 20 {
+ t = string([]rune(t)[:20]) + "…"
+ }
+ }
+ m[r.Id] = t
+ }
+ return m, nil
}
-// ListEnabledByNodeIds 批量节点下启用选项,按 sort_order 升序(内容缓存)。
-func (s *nodeOption) ListEnabledByNodeIds(ctx context.Context, nodeIds []int64) ([]gdb.Record, error) {
- return dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
- WhereIn("node_id", nodeIds).Where("status", consts.StatusEnabled).
- Order("sort_order ASC").All()
+// checkNext 下一节点校验:同关存在 + 沿 next 链无环(步数 ≤ 本关节点数,超限即有环)。
+func checkNext(ctx context.Context, nodeId int64, nodeRec *entity.SceneNode, nextNodeId int64) error {
+ levelId := nodeRec.LevelId
+ rec, err := dao.SceneNode.GetByPk(ctx, nextNodeId)
+ if err != nil {
+ return err
+ }
+ if rec == nil || rec.LevelId != levelId {
+ return gerror.New("下一节点不存在或不属于同一关卡")
+ }
+ nodeRecs, err := dao.SceneNode.ListByLevel(ctx, levelId)
+ if err != nil {
+ return err
+ }
+ nodeIds := make([]int64, 0, len(nodeRecs))
+ for _, r := range nodeRecs {
+ nodeIds = append(nodeIds, r.Id)
+ }
+ optRecs, err := dao.NodeOption.ListByNodeIds(ctx, nodeIds)
+ if err != nil {
+ return err
+ }
+ nextByNode := make(map[int64]int64, len(optRecs))
+ for _, r := range optRecs {
+ nextByNode[r.NodeId] = r.NextNodeId
+ }
+ cur := nextNodeId
+ for step := 0; step <= len(nodeIds)+1; step++ {
+ if cur == nodeId {
+ return gerror.New("选项指向形成循环,无法到达终局")
+ }
+ nxt, ok := nextByNode[cur]
+ if !ok {
+ return nil
+ }
+ cur = nxt
+ }
+ return gerror.New("选项指向形成循环,无法到达终局")
}
-// ListByOptionIds 按选项 id 批量取(终局判定用,不缓存)。
-func (s *nodeOption) ListByOptionIds(ctx context.Context, optionIds []int64) ([]gdb.Record, error) {
- return dao.NodeOption.Model().Ctx(ctx).WhereIn("id", optionIds).All()
+// checkProp 道具元素存在性校验(prop_id>0 时)。
+func checkProp(ctx context.Context, propId int64) error {
+ if propId <= 0 {
+ return nil
+ }
+ rec, err := dao.Element.GetByPk(ctx, propId)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("道具元素不存在")
+ }
+ return nil
+}
+
+// bumpLevelVersion 事务内 bump 选项所属关卡的内容版本。
+func bumpLevelVersion(ctx context.Context, tx gdb.TX, nodeId int64) error {
+ nodeRec, err := dao.SceneNode.GetByPk(ctx, nodeId)
+ if err != nil {
+ return err
+ }
+ if nodeRec == nil {
+ return gerror.New("节点不存在")
+ }
+ _, err = tx.Model(consts.TableLevel).Ctx(ctx).WherePri(nodeRec.LevelId).
+ Data(g.Map{"content_version": gdb.Raw("content_version+1")}).Update()
+ return err
+}
+
+// Create 新增选项:同关 + 环校验,拼音标注,bump 关卡版本,清内容缓存。
+func (s *adminNodeOption) Create(ctx context.Context, req *dto.AdminNodeOptionCreateReq) (*dto.AdminNodeOptionCreateRes, error) {
+ nodeRec, err := dao.SceneNode.GetByPk(ctx, req.NodeId)
+ if err != nil {
+ return nil, err
+ }
+ if nodeRec == nil {
+ return nil, gerror.New("节点不存在")
+ }
+ if err = checkProp(ctx, req.PropId); err != nil {
+ return nil, err
+ }
+ if err = checkNext(ctx, req.NodeId, nodeRec, req.NextNodeId); err != nil {
+ return nil, err
+ }
+ var id int64
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ res, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{
+ "node_id": req.NodeId, "text": req.Text, "text_pinyin": common.AnnotatePinyin(req.Text),
+ "prop_id": req.PropId, "audio": req.Audio,
+ "next_node_id": req.NextNodeId,
+ "feedback": req.Feedback, "feedback_pinyin": common.AnnotatePinyin(req.Feedback),
+ "feedback_audio": req.FeedbackAudio, "sort_order": req.SortOrder,
+ "status": consts.StatusEnabled,
+ }).Insert()
+ if err != nil {
+ return err
+ }
+ id, err = res.LastInsertId()
+ if err != nil {
+ return err
+ }
+ return bumpLevelVersion(ctx, tx, req.NodeId)
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableNodeOption, consts.TableLevel)
+ return &dto.AdminNodeOptionCreateRes{Id: id}, nil
+}
+
+// Update 编辑选项:全字段覆盖,拼音重标,bump 关卡版本,清内容缓存。
+func (s *adminNodeOption) Update(ctx context.Context, req *dto.AdminNodeOptionUpdateReq) (*dto.AdminNodeOptionUpdateRes, error) {
+ rec, err := dao.NodeOption.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("选项不存在")
+ }
+ nodeRec, err := dao.SceneNode.GetByPk(ctx, req.NodeId)
+ if err != nil {
+ return nil, err
+ }
+ if nodeRec == nil {
+ return nil, gerror.New("节点不存在")
+ }
+ if err = checkProp(ctx, req.PropId); err != nil {
+ return nil, err
+ }
+ if err = checkNext(ctx, req.NodeId, nodeRec, req.NextNodeId); err != nil {
+ return nil, err
+ }
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).WherePri(req.Id).Data(g.Map{
+ "node_id": req.NodeId, "text": req.Text, "text_pinyin": common.AnnotatePinyin(req.Text),
+ "prop_id": req.PropId, "audio": req.Audio,
+ "next_node_id": req.NextNodeId,
+ "feedback": req.Feedback, "feedback_pinyin": common.AnnotatePinyin(req.Feedback),
+ "feedback_audio": req.FeedbackAudio, "sort_order": req.SortOrder,
+ }).Update(); err != nil {
+ return err
+ }
+ return bumpLevelVersion(ctx, tx, req.NodeId)
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableNodeOption, consts.TableLevel)
+ return &dto.AdminNodeOptionUpdateRes{}, nil
+}
+
+// setStatus 上下架(软删除):bump 关卡版本。
+func (s *adminNodeOption) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.NodeOption.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("选项不存在")
+ }
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).WherePri(id).
+ Data(g.Map{"status": status}).Update(); err != nil {
+ return err
+ }
+ return bumpLevelVersion(ctx, tx, rec.NodeId)
+ })
+ if err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableNodeOption, consts.TableLevel)
+ return nil
+}
+
+// Disable 下架选项。
+func (s *adminNodeOption) Disable(ctx context.Context, req *dto.AdminNodeOptionDisableReq) (*dto.AdminNodeOptionDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminNodeOptionDisableRes{}, nil
+}
+
+// Enable 上架选项。
+func (s *adminNodeOption) Enable(ctx context.Context, req *dto.AdminNodeOptionEnableReq) (*dto.AdminNodeOptionEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminNodeOptionEnableRes{}, nil
}
diff --git a/biz/service/parent.go b/biz/service/parent.go
index 3a10984..8afabf6 100644
--- a/biz/service/parent.go
+++ b/biz/service/parent.go
@@ -9,6 +9,7 @@ import (
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
"36wisdom/common/auth"
)
@@ -17,45 +18,82 @@ type parent struct{}
var Parent = &parent{}
// Register 家长注册:手机号唯一、bcrypt 落库、签发 token。
-func (s *parent) Register(ctx context.Context, phone, password, nickname string) (parentId int64, token string, err error) {
- rec, err := dao.Parent.Model().Ctx(ctx).Where("phone", phone).One()
+func (s *parent) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) {
+ rec, err := dao.Parent.GetByPhone(ctx, req.Phone)
if err != nil {
- return 0, "", err
+ return nil, err
}
- if !rec.IsEmpty() {
- return 0, "", gerror.Newf("手机号 %s 已注册", phone)
+ if rec != nil {
+ return nil, gerror.Newf("手机号 %s 已注册", req.Phone)
}
- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
- return 0, "", err
+ return nil, err
}
- parentId, err = dao.Parent.InsertAndReturnId(ctx, g.Map{
- "phone": phone,
+ parentId, err := dao.Parent.InsertAndReturnId(ctx, g.Map{
+ "phone": req.Phone,
"password": string(hash),
- "nickname": nickname,
+ "nickname": req.Nickname,
"status": consts.StatusEnabled,
})
if err != nil {
- return 0, "", err
+ return nil, err
}
- token, err = auth.GenerateToken(auth.Secret(ctx), parentId, consts.RoleParent, consts.AuthExpireSeconds)
- return parentId, token, err
+ token, err := auth.GenerateToken(auth.Secret(ctx), parentId, consts.RoleParent, consts.AuthExpireSeconds)
+ if err != nil {
+ return nil, err
+ }
+ return &dto.RegisterRes{Token: token, ParentId: parentId}, nil
}
// Login 家长登录:校验手机号与密码,签发 token。
-func (s *parent) Login(ctx context.Context, phone, password string) (parentId int64, token string, err error) {
- rec, err := dao.Parent.Model().Ctx(ctx).Where("phone", phone).One()
+func (s *parent) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) {
+ rec, err := dao.Parent.GetByPhone(ctx, req.Phone)
if err != nil {
- return 0, "", err
+ return nil, err
}
- if rec.IsEmpty() {
- return 0, "", gerror.New("手机号或密码错误")
+ if rec == nil {
+ return nil, gerror.New("手机号或密码错误")
}
- if err = bcrypt.CompareHashAndPassword([]byte(rec["password"].String()), []byte(password)); err != nil {
- return 0, "", gerror.New("手机号或密码错误")
+ if err = bcrypt.CompareHashAndPassword([]byte(rec.Password), []byte(req.Password)); err != nil {
+ return nil, gerror.New("手机号或密码错误")
}
- parentId = rec["id"].Int64()
- token, err = auth.GenerateToken(auth.Secret(ctx), parentId, consts.RoleParent, consts.AuthExpireSeconds)
- return parentId, token, err
+ token, err := auth.GenerateToken(auth.Secret(ctx), rec.Id, consts.RoleParent, consts.AuthExpireSeconds)
+ if err != nil {
+ return nil, err
+ }
+ return &dto.LoginRes{Token: token, ParentId: rec.Id}, nil
+}
+
+// ---------- 后台管理 ----------
+
+type adminParent struct{}
+
+var AdminParent = &adminParent{}
+
+// List 全部家长(含各家长孩子数)。
+func (s *adminParent) List(ctx context.Context, req *dto.AdminParentListReq) (*dto.AdminParentListRes, error) {
+ recs, err := dao.Parent.ListAll(ctx)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ ids = append(ids, r.Id)
+ }
+ counts, err := dao.Child.CountByParentIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminParentItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminParentItem{
+ Id: r.Id, Phone: r.Phone, Openid: r.Openid,
+ Nickname: r.Nickname, Avatar: r.Avatar,
+ Status: r.Status, CreatedAt: timeString(r.CreatedAt),
+ ChildCount: counts[r.Id],
+ })
+ }
+ return &dto.AdminParentListRes{List: items}, nil
}
diff --git a/biz/service/prize.go b/biz/service/prize.go
index e2f234a..661e5bf 100644
--- a/biz/service/prize.go
+++ b/biz/service/prize.go
@@ -3,23 +3,117 @@ package service
import (
"context"
- "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/common"
)
-type prize struct{}
+// ---------- 后台管理 ----------
-var Prize = &prize{}
+type adminPrize struct{}
-// GetByPk 奖品定义。
-func (s *prize) GetByPk(ctx context.Context, id int64) (gdb.Record, error) {
- return dao.Prize.GetOneByPk(ctx, id)
+var AdminPrize = &adminPrize{}
+
+// List 全部奖品(含下架),按类型 + 序号排序。
+func (s *adminPrize) List(ctx context.Context, req *dto.AdminPrizeListReq) (*dto.AdminPrizeListRes, error) {
+ recs, err := dao.Prize.ListAll(ctx)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminPrizeItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminPrizeItem{
+ Id: r.Id, Name: r.Name, Description: r.Description,
+ Icon: r.Icon, PType: r.PType, PointsCost: r.PointsCost,
+ Stock: r.Stock, Status: r.Status, SortOrder: r.SortOrder,
+ })
+ }
+ return &dto.AdminPrizeListRes{List: items}, nil
}
-// ListEnabled 可兑换奖品(启用态,按 sort_order 升序,内容缓存)。
-func (s *prize) ListEnabled(ctx context.Context) ([]gdb.Record, error) {
- return dao.Prize.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("status", consts.StatusEnabled).Order("sort_order ASC").All()
+// prizeNames 批量奖品名。
+func prizeNames(ctx context.Context, prizeIds []int64) (map[int64]string, error) {
+ m := make(map[int64]string, len(prizeIds))
+ prizeIds = uniqueInt64(prizeIds)
+ if len(prizeIds) == 0 {
+ return m, nil
+ }
+ recs, err := dao.Prize.ListByIds(ctx, prizeIds)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r.Id] = r.Name
+ }
+ return m, nil
+}
+
+// Create 新增奖品,清内容缓存。
+func (s *adminPrize) Create(ctx context.Context, req *dto.AdminPrizeCreateReq) (*dto.AdminPrizeCreateRes, error) {
+ id, err := dao.Prize.InsertAndReturnId(ctx, g.Map{
+ "name": req.Name, "description": req.Description, "icon": req.Icon,
+ "p_type": req.PType, "points_cost": req.PointsCost, "stock": req.Stock,
+ "sort_order": req.SortOrder, "status": consts.StatusEnabled,
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TablePrize)
+ return &dto.AdminPrizeCreateRes{Id: id}, nil
+}
+
+// Update 编辑奖品,清内容缓存。
+func (s *adminPrize) Update(ctx context.Context, req *dto.AdminPrizeUpdateReq) (*dto.AdminPrizeUpdateRes, error) {
+ rec, err := dao.Prize.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("奖品不存在")
+ }
+ if err = dao.Prize.UpdateByPk(ctx, req.Id, g.Map{
+ "name": req.Name, "description": req.Description, "icon": req.Icon,
+ "p_type": req.PType, "points_cost": req.PointsCost, "stock": req.Stock,
+ "sort_order": req.SortOrder,
+ }); err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TablePrize)
+ return &dto.AdminPrizeUpdateRes{}, nil
+}
+
+// setStatus 上下架(软删除)。
+func (s *adminPrize) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.Prize.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("奖品不存在")
+ }
+ if err = dao.Prize.UpdateByPk(ctx, id, g.Map{"status": status}); err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TablePrize)
+ return nil
+}
+
+// Disable 下架奖品。
+func (s *adminPrize) Disable(ctx context.Context, req *dto.AdminPrizeDisableReq) (*dto.AdminPrizeDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminPrizeDisableRes{}, nil
+}
+
+// Enable 上架奖品。
+func (s *adminPrize) Enable(ctx context.Context, req *dto.AdminPrizeEnableReq) (*dto.AdminPrizeEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminPrizeEnableRes{}, nil
}
diff --git a/biz/service/redemption.go b/biz/service/redemption.go
index 467455c..2c744b7 100644
--- a/biz/service/redemption.go
+++ b/biz/service/redemption.go
@@ -2,28 +2,156 @@ package service
import (
"context"
+ "crypto/rand"
"github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gtime"
+ "36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
)
-type redemption struct{}
+// ---------- 后台管理 ----------
-var Redemption = &redemption{}
+type adminRedemption struct{}
-// ListByUser 孩子兑换记录(按创建时间倒序)。
-func (s *redemption) ListByUser(ctx context.Context, userId int64) ([]gdb.Record, error) {
- return dao.Redemption.Model().Ctx(ctx).Where("user_id", userId).Order("id DESC").All()
+var AdminRedemption = &adminRedemption{}
+
+// List 兑换记录(status/prize_id 可选过滤),按创建时间倒序。
+func (s *adminRedemption) List(ctx context.Context, req *dto.AdminRedemptionListReq) (*dto.AdminRedemptionListRes, error) {
+ recs, err := dao.Redemption.List(ctx, req.Status, req.PrizeId)
+ if err != nil {
+ return nil, err
+ }
+ childIds := make([]int64, 0, len(recs))
+ prizeIds := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ childIds = append(childIds, r.UserId)
+ prizeIds = append(prizeIds, r.PrizeId)
+ }
+ nicknames, err := childNicknames(ctx, childIds)
+ if err != nil {
+ return nil, err
+ }
+ names, err := prizeNames(ctx, prizeIds)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminRedemptionItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminRedemptionItem{
+ Id: r.Id, ChildId: r.UserId, ChildNickname: nicknames[r.UserId],
+ PrizeId: r.PrizeId, PrizeName: names[r.PrizeId],
+ PointsCost: r.PointsCost, Status: r.Status,
+ Code: r.Code, CreatedAt: timeString(r.CreatedAt),
+ })
+ }
+ return &dto.AdminRedemptionListRes{List: items}, nil
}
-// Insert 创建兑换记录。
-func (s *redemption) Insert(ctx context.Context, userId, prizeId int64, pointsCost int) (int64, error) {
- return dao.Redemption.InsertAndReturnId(ctx, g.Map{
- "user_id": userId,
- "prize_id": prizeId,
- "points_cost": pointsCost,
- "status": 1,
+// Ship 发货(待领取/待发货 → 已发货):未填兑换码则生成(前缀 + 8 位大写随机)。
+func (s *adminRedemption) Ship(ctx context.Context, req *dto.AdminRedemptionShipReq) (*dto.AdminRedemptionShipRes, error) {
+ rec, err := dao.Redemption.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("兑换记录不存在")
+ }
+ if rec.Status != consts.RedeemPending && rec.Status != consts.RedeemWaiting {
+ return nil, gerror.New("当前状态不可发货")
+ }
+ code := req.Code
+ if code == "" {
+ if code, err = genRedeemCode(); err != nil {
+ return nil, err
+ }
+ }
+ if err = dao.Redemption.UpdateByPk(ctx, req.Id, g.Map{
+ "status": consts.RedeemShipped, "code": code, "updated_at": gtime.Now(),
+ }); err != nil {
+ return nil, err
+ }
+ return &dto.AdminRedemptionShipRes{}, nil
+}
+
+// Receive 确认领取(已发货 → 已领取)。
+func (s *adminRedemption) Receive(ctx context.Context, req *dto.AdminRedemptionReceiveReq) (*dto.AdminRedemptionReceiveRes, error) {
+ rec, err := dao.Redemption.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("兑换记录不存在")
+ }
+ if rec.Status != consts.RedeemShipped {
+ return nil, gerror.New("仅已发货记录可确认领取")
+ }
+ if err = dao.Redemption.UpdateByPk(ctx, req.Id, g.Map{
+ "status": consts.RedeemReceived, "updated_at": gtime.Now(),
+ }); err != nil {
+ return nil, err
+ }
+ return &dto.AdminRedemptionReceiveRes{}, nil
+}
+
+// Cancel 取消兑换(待领取/待发货 → 已取消):事务内退回积分并记流水。
+func (s *adminRedemption) Cancel(ctx context.Context, req *dto.AdminRedemptionCancelReq) (*dto.AdminRedemptionCancelRes, error) {
+ err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ rec, err := dao.Redemption.GetByPkInTx(ctx, tx, req.Id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("兑换记录不存在")
+ }
+ if rec.Status != consts.RedeemPending && rec.Status != consts.RedeemWaiting {
+ return gerror.New("当前状态不可取消")
+ }
+ childId := rec.UserId
+ if _, err = tx.Model(consts.TableRedemption).Ctx(ctx).Data(g.Map{
+ "status": consts.RedeemCanceled, "updated_at": gtime.Now(),
+ }).WherePri(req.Id).Update(); err != nil {
+ return err
+ }
+ childRec, err := dao.Child.GetByPkInTx(ctx, tx, childId)
+ if err != nil {
+ return err
+ }
+ if childRec == nil {
+ return gerror.New("孩子不存在")
+ }
+ balance := childRec.Points + rec.PointsCost
+ if _, err = tx.Model(consts.TableChild).Ctx(ctx).Data(g.Map{"points": balance}).WherePri(childId).Update(); err != nil {
+ return err
+ }
+ _, err = tx.Model(consts.TablePointLog).Ctx(ctx).Data(g.Map{
+ "user_id": childId,
+ "change": rec.PointsCost,
+ "reason_type": consts.ReasonAdmin,
+ "ref_id": req.Id,
+ "balance_after": balance,
+ }).Insert()
+ return err
})
+ if err != nil {
+ return nil, err
+ }
+ return &dto.AdminRedemptionCancelRes{}, nil
+}
+
+// genRedeemCode 生成实物兑换码:前缀 + 8 位大写字母数字。
+func genRedeemCode() (string, error) {
+ const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ return "", gerror.Wrap(err, "生成兑换码失败")
+ }
+ for i := range b {
+ b[i] = chars[int(b[i])%len(chars)]
+ }
+ return consts.RedeemCodePrefix + string(b), nil
}
diff --git a/biz/service/scene_node.go b/biz/service/scene_node.go
index f162ff9..5c6a586 100644
--- a/biz/service/scene_node.go
+++ b/biz/service/scene_node.go
@@ -4,30 +4,237 @@ import (
"context"
"github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/common"
)
-type sceneNode struct{}
+// ---------- 后台管理 ----------
-var SceneNode = &sceneNode{}
+type adminSceneNode struct{}
-// GetInLevel 关卡内启用节点(按节点 id + 关卡 id 精确查询)。
-func (s *sceneNode) GetInLevel(ctx context.Context, nodeId, levelId int64) (gdb.Record, error) {
- return dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("id", nodeId).Where("level_id", levelId).Where("status", consts.StatusEnabled).One()
+var AdminSceneNode = &adminSceneNode{}
+
+// List 指定关卡下全部节点(含下架),按序号排序。
+func (s *adminSceneNode) List(ctx context.Context, req *dto.AdminSceneNodeListReq) (*dto.AdminSceneNodeListRes, error) {
+ recs, err := dao.SceneNode.ListByLevel(ctx, req.LevelId)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(recs))
+ characterIds := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ ids = append(ids, r.Id)
+ characterIds = append(characterIds, r.CharacterId)
+ }
+ counts, err := dao.NodeOption.CountByNodeIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ names, err := elementNamesAll(ctx, characterIds)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminSceneNodeItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminSceneNodeItem{
+ Id: r.Id, LevelId: r.LevelId, Title: r.Title,
+ CharacterId: r.CharacterId, CharacterName: names[r.CharacterId],
+ Content: r.Content, Image: r.Image, Audio: r.Audio,
+ NodeType: r.NodeType, InteractionType: r.InteractionType,
+ Config: r.Config, Script: r.Script,
+ ResultType: r.ResultType, IsEntry: r.IsEntry,
+ SortOrder: r.SortOrder, Status: r.Status,
+ OptionCount: counts[r.Id],
+ })
+ }
+ return &dto.AdminSceneNodeListRes{List: items}, nil
}
-// ListEnabledByLevel 关卡内启用节点,按 sort_order 升序(内容缓存)。
-func (s *sceneNode) ListEnabledByLevel(ctx context.Context, levelId int64) ([]gdb.Record, error) {
- return dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("level_id", levelId).Where("status", consts.StatusEnabled).
- Order("sort_order ASC").All()
+// elementNamesAll 批量元素名(含下架,后台列表展示用)。
+func elementNamesAll(ctx context.Context, ids []int64) (map[int64]string, error) {
+ m := make(map[int64]string, len(ids))
+ ids = uniqueInt64(ids)
+ if len(ids) == 0 {
+ return m, nil
+ }
+ recs, err := dao.Element.ListByIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r.Id] = r.Name
+ }
+ return m, nil
}
-// CountFinalsByLevel 关卡内终局节点数(result_type > 0,不缓存)。
-func (s *sceneNode) CountFinalsByLevel(ctx context.Context, levelId int64) (int, error) {
- return dao.SceneNode.Model().Ctx(ctx).
- Where("level_id", levelId).Where("status", consts.StatusEnabled).WhereGT("result_type", 0).Count()
+// checkLevel 关卡存在性校验。
+func checkLevel(ctx context.Context, levelId int64) error {
+ rec, err := dao.Level.GetByPk(ctx, levelId)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("关卡不存在")
+ }
+ return nil
+}
+
+// checkCharacter 角色元素存在性校验(character_id>0 时)。
+func checkCharacter(ctx context.Context, characterId int64) error {
+ if characterId <= 0 {
+ return nil
+ }
+ rec, err := dao.Element.GetByPk(ctx, characterId)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("角色元素不存在")
+ }
+ return nil
+}
+
+// checkInteractionType 互动形态校验:决策节点须为有效形态,终局节点允许 0。
+func checkInteractionType(nodeType, interactionType int) error {
+ if nodeType == consts.NodeDecision && (interactionType < consts.InteractionOption || interactionType > consts.InteractionConnect) {
+ return gerror.New("决策节点的互动形态须在 1-8")
+ }
+ return nil
+}
+
+// Create 新增节点:事务内(校验 → 入口清位 → 写节点 → bump 关卡版本 → 失效缓存)。
+func (s *adminSceneNode) Create(ctx context.Context, req *dto.AdminSceneNodeCreateReq) (*dto.AdminSceneNodeCreateRes, error) {
+ if err := checkLevel(ctx, req.LevelId); err != nil {
+ return nil, err
+ }
+ if err := checkCharacter(ctx, req.CharacterId); err != nil {
+ return nil, err
+ }
+ if err := checkInteractionType(req.NodeType, req.InteractionType); err != nil {
+ return nil, err
+ }
+ var id int64
+ err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ if req.IsEntry == 1 {
+ if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Where("level_id", req.LevelId).
+ Data(g.Map{"is_entry": 0}).Update(); err != nil {
+ return err
+ }
+ }
+ res, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{
+ "level_id": req.LevelId, "title": req.Title, "character_id": req.CharacterId,
+ "content": req.Content, "content_pinyin": common.AnnotatePinyin(req.Content),
+ "image": req.Image, "audio": req.Audio,
+ "node_type": req.NodeType, "interaction_type": req.InteractionType,
+ "config": req.Config, "script": req.Script, "result_type": req.ResultType,
+ "is_entry": req.IsEntry, "sort_order": req.SortOrder, "status": consts.StatusEnabled,
+ }).Insert()
+ if err != nil {
+ return err
+ }
+ id, err = res.LastInsertId()
+ if err != nil {
+ return err
+ }
+ _, err = tx.Model(consts.TableLevel).Ctx(ctx).WherePri(req.LevelId).
+ Data(g.Map{"content_version": gdb.Raw("content_version+1")}).Update()
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableSceneNode, consts.TableLevel)
+ return &dto.AdminSceneNodeCreateRes{Id: id}, nil
+}
+
+// Update 编辑节点:全字段覆盖,入口清位,bump 关卡版本,清内容缓存。
+func (s *adminSceneNode) Update(ctx context.Context, req *dto.AdminSceneNodeUpdateReq) (*dto.AdminSceneNodeUpdateRes, error) {
+ rec, err := dao.SceneNode.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("节点不存在")
+ }
+ if err = checkLevel(ctx, req.LevelId); err != nil {
+ return nil, err
+ }
+ if err = checkCharacter(ctx, req.CharacterId); err != nil {
+ return nil, err
+ }
+ if err = checkInteractionType(req.NodeType, req.InteractionType); err != nil {
+ return nil, err
+ }
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ if req.IsEntry == 1 {
+ if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Where("level_id", req.LevelId).
+ WhereNot("id", req.Id).Data(g.Map{"is_entry": 0}).Update(); err != nil {
+ return err
+ }
+ }
+ if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx).WherePri(req.Id).Data(g.Map{
+ "level_id": req.LevelId, "title": req.Title, "character_id": req.CharacterId,
+ "content": req.Content, "content_pinyin": common.AnnotatePinyin(req.Content),
+ "image": req.Image, "audio": req.Audio,
+ "node_type": req.NodeType, "interaction_type": req.InteractionType,
+ "config": req.Config, "script": req.Script, "result_type": req.ResultType,
+ "is_entry": req.IsEntry, "sort_order": req.SortOrder,
+ }).Update(); err != nil {
+ return err
+ }
+ _, err := tx.Model(consts.TableLevel).Ctx(ctx).WherePri(req.LevelId).
+ Data(g.Map{"content_version": gdb.Raw("content_version+1")}).Update()
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableSceneNode, consts.TableLevel)
+ return &dto.AdminSceneNodeUpdateRes{}, nil
+}
+
+// setStatus 上下架(软删除):bump 关卡版本。
+func (s *adminSceneNode) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.SceneNode.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("节点不存在")
+ }
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx).WherePri(id).
+ Data(g.Map{"status": status}).Update(); err != nil {
+ return err
+ }
+ _, err := tx.Model(consts.TableLevel).Ctx(ctx).WherePri(rec.LevelId).
+ Data(g.Map{"content_version": gdb.Raw("content_version+1")}).Update()
+ return err
+ })
+ if err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableSceneNode, consts.TableLevel)
+ return nil
+}
+
+// Disable 下架节点。
+func (s *adminSceneNode) Disable(ctx context.Context, req *dto.AdminSceneNodeDisableReq) (*dto.AdminSceneNodeDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminSceneNodeDisableRes{}, nil
+}
+
+// Enable 上架节点。
+func (s *adminSceneNode) Enable(ctx context.Context, req *dto.AdminSceneNodeEnableReq) (*dto.AdminSceneNodeEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminSceneNodeEnableRes{}, nil
}
diff --git a/biz/service/strategy.go b/biz/service/strategy.go
index 05c1ce1..015a1d0 100644
--- a/biz/service/strategy.go
+++ b/biz/service/strategy.go
@@ -3,228 +3,174 @@ package service
import (
"context"
"fmt"
- "time"
- "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
+ "36wisdom/biz/model/dto"
+ "36wisdom/biz/model/entity"
+ "36wisdom/common"
+ "36wisdom/common/auth"
)
type strategy struct{}
var Strategy = &strategy{}
-// StrategyItem 计策列表项:内容 + 本用户进度 + 解锁状态。
-type StrategyItem struct {
- StrategyId int64
- Name string
- Pinyin string
- GroupNo int
- GroupName string
- Meaning string
- Icon string
- SortOrder int
- Stars int
- PerfectCount int
- TotalLevels int
- Unlocked bool
- UnlockReason string
-}
-
-// StrategyDetail 计策详情:内容 + 关卡列表(含进度)。
-type StrategyDetail struct {
- StrategyId int64
- Name string
- Pinyin string
- Meaning string
- MeaningPinyin string
- GroupName string
- TeachContent string
- TeachContentPinyin string
- TeachImage string
- TeachAudio string
- SummaryQ string
- SummaryOptions string
- Levels []*LevelBrief
-}
-
-// LevelBrief 关卡概要:进度 + 解锁 + 内容版本(客户端据此判断可重新挑战)。
-type LevelBrief struct {
- LevelId int64
- Title string
- AgeGroup string
- SceneName string
- Stars int
- Perfect bool
- Unlocked bool
- ContentVersion int
- ProgressVersion int
-}
-
-// contentCache 内容查询缓存选项:TTL 来自配置 database.cache.ttl。
-// 内容仅在后台维护,M1 无写操作;后续后台变更后须清对应缓存。
-func contentCache(ctx context.Context) gdb.CacheOption {
- ttl := g.Cfg().MustGet(ctx, "database.cache.ttl").Int()
- if ttl <= 0 {
- ttl = 300
- }
- return gdb.CacheOption{Duration: time.Duration(ttl) * time.Second}
-}
-
// List 计策列表:内容带缓存,进度不带;解锁按前置计全部关卡完美判定。
-func (s *strategy) List(ctx context.Context, parentUid, childId int64) ([]*StrategyItem, error) {
- child, err := getChildOf(ctx, parentUid, childId)
- if err != nil {
- return nil, err
- }
- childAge := child["age_group"].String()
-
- strategies, err := s.listAll(ctx)
+func (s *strategy) List(ctx context.Context, req *dto.StrategyListReq) (*dto.StrategyListRes, error) {
+ child, err := getChildOf(ctx, auth.GetUid(ctx), req.ChildId)
if err != nil {
return nil, err
}
+ strategies, err := dao.Strategy.ListEnabled(ctx)
+ if err != nil {
+ return nil, err
+ }
strategyIds := make([]int64, 0, len(strategies))
for _, r := range strategies {
- strategyIds = append(strategyIds, r["id"].Int64())
+ strategyIds = append(strategyIds, r.Id)
}
- levels, err := s.listLevels(ctx, strategyIds, childAge)
+ levels, err := dao.Level.ListEnabledByStrategyIds(ctx, strategyIds, child.AgeGroup)
if err != nil {
return nil, err
}
- progress, err := s.progressOfLevels(ctx, childId, levelIdsOf(levels))
+ progress, err := s.progressOfLevels(ctx, req.ChildId, levelIdsOf(levels))
if err != nil {
return nil, err
}
// 按计策分组统计,同时维护解锁依赖(线性链:unlock_before 指向前置计)。
- stats := make(map[int64]*StrategyItem, len(strategies))
+ stats := make(map[int64]*dto.StrategyItem, len(strategies))
for _, r := range strategies {
- it := &StrategyItem{
- StrategyId: r["id"].Int64(),
- Name: r["name"].String(),
- Pinyin: r["pinyin"].String(),
- GroupNo: r["group_no"].Int(),
- GroupName: r["group_name"].String(),
- Meaning: r["meaning"].String(),
- Icon: r["icon"].String(),
- SortOrder: r["sort_order"].Int(),
+ stats[r.Id] = &dto.StrategyItem{
+ StrategyId: r.Id,
+ Name: r.Name,
+ Pinyin: r.Pinyin,
+ GroupNo: r.GroupNo,
+ GroupName: r.GroupName,
+ Meaning: r.Meaning,
+ Icon: r.Icon,
+ SortOrder: r.SortOrder,
}
- stats[it.StrategyId] = it
}
for _, lv := range levels {
- st := stats[lv["strategy_id"].Int64()]
+ st := stats[lv.StrategyId]
st.TotalLevels++
- p := progress[lv["id"].Int64()]
- st.Stars += p["stars"].Int()
- if p["perfect"].Int() == 1 {
- st.PerfectCount++
+ if p := progress[lv.Id]; p != nil {
+ st.Stars += p.Stars
+ if p.Perfect == 1 {
+ st.PerfectCount++
+ }
}
}
nameOf := make(map[int64]string, len(strategies))
for _, r := range strategies {
- nameOf[r["id"].Int64()] = r["name"].String()
+ nameOf[r.Id] = r.Name
}
for _, r := range strategies {
- it := stats[r["id"].Int64()]
- if prevId := r["unlock_before"].Int64(); prevId == 0 {
+ it := stats[r.Id]
+ if r.UnlockBefore == 0 {
it.Unlocked = true
- } else if prev, ok := stats[prevId]; ok && prev.PerfectCount == prev.TotalLevels && prev.TotalLevels > 0 {
+ } else if prev, ok := stats[r.UnlockBefore]; ok && prev.TotalLevels > 0 && prev.PerfectCount == prev.TotalLevels {
it.Unlocked = true
} else {
- it.UnlockReason = fmt.Sprintf("完成《%s》全部关卡解锁", nameOf[prevId])
+ it.UnlockReason = fmt.Sprintf("完成《%s》全部关卡解锁", nameOf[r.UnlockBefore])
}
}
- items := make([]*StrategyItem, 0, len(strategies))
+ items := make([]dto.StrategyItem, 0, len(strategies))
for _, r := range strategies {
- items = append(items, stats[r["id"].Int64()])
+ items = append(items, *stats[r.Id])
}
- return items, nil
+ return &dto.StrategyListRes{List: items}, nil
}
// Detail 计策详情:含关卡列表与解锁状态。
-func (s *strategy) Detail(ctx context.Context, parentUid, childId, strategyId int64) (*StrategyDetail, error) {
- child, err := getChildOf(ctx, parentUid, childId)
+func (s *strategy) Detail(ctx context.Context, req *dto.StrategyDetailReq) (*dto.StrategyDetailRes, error) {
+ child, err := getChildOf(ctx, auth.GetUid(ctx), req.ChildId)
if err != nil {
return nil, err
}
- childAge := child["age_group"].String()
- rec, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(strategyId).One()
+ rec, err := dao.Strategy.GetByPkCached(ctx, req.StrategyId)
if err != nil {
return nil, err
}
- if rec.IsEmpty() || rec["status"].Int() != consts.StatusEnabled {
+ if rec == nil || rec.Status != consts.StatusEnabled {
return nil, gerror.New("计策不存在")
}
- levels, err := s.listLevels(ctx, []int64{strategyId}, childAge)
+ levels, err := dao.Level.ListEnabledByStrategyIds(ctx, []int64{req.StrategyId}, child.AgeGroup)
if err != nil {
return nil, err
}
- progress, err := s.progressOfLevels(ctx, childId, levelIdsOf(levels))
+ progress, err := s.progressOfLevels(ctx, req.ChildId, levelIdsOf(levels))
if err != nil {
return nil, err
}
- unlocked, _ := s.unlockState(ctx, childId, childAge, rec)
+ unlocked, _ := s.unlockState(ctx, req.ChildId, child.AgeGroup, rec)
sceneNames, err := s.elementNames(ctx, sceneIdsOf(levels))
if err != nil {
return nil, err
}
- detail := &StrategyDetail{
- StrategyId: rec["id"].Int64(),
- Name: rec["name"].String(),
- Pinyin: rec["pinyin"].String(),
- Meaning: rec["meaning"].String(),
- MeaningPinyin: rec["meaning_pinyin"].String(),
- GroupName: rec["group_name"].String(),
- TeachContent: rec["teach_content"].String(),
- TeachContentPinyin: rec["teach_content_pinyin"].String(),
- TeachImage: rec["teach_image"].String(),
- TeachAudio: rec["teach_audio"].String(),
- SummaryQ: rec["summary_q"].String(),
- SummaryOptions: rec["summary_options"].String(),
+ detail := &dto.StrategyDetailRes{
+ StrategyId: rec.Id,
+ Name: rec.Name,
+ Pinyin: rec.Pinyin,
+ Meaning: rec.Meaning,
+ MeaningPinyin: rec.MeaningPinyin,
+ GroupName: rec.GroupName,
+ TeachContent: rec.TeachContent,
+ TeachContentPinyin: rec.TeachContentPinyin,
+ TeachImage: rec.TeachImage,
+ TeachAudio: rec.TeachAudio,
+ SummaryQ: rec.SummaryQ,
+ SummaryOptions: rec.SummaryOptions,
+ Levels: make([]dto.LevelBrief, 0, len(levels)),
}
for _, lv := range levels {
- p := progress[lv["id"].Int64()]
- detail.Levels = append(detail.Levels, &LevelBrief{
- LevelId: lv["id"].Int64(),
- Title: lv["title"].String(),
- AgeGroup: lv["age_group"].String(),
- SceneName: sceneNames[lv["scene_id"].Int64()],
- Stars: p["stars"].Int(),
- Perfect: p["perfect"].Int() == 1,
+ stars, perfect, progressVersion := 0, false, 0
+ if p := progress[lv.Id]; p != nil {
+ stars, perfect, progressVersion = p.Stars, p.Perfect == 1, p.ContentVersion
+ }
+ detail.Levels = append(detail.Levels, dto.LevelBrief{
+ LevelId: lv.Id,
+ Title: lv.Title,
+ AgeGroup: lv.AgeGroup,
+ SceneName: sceneNames[lv.SceneId],
+ Stars: stars,
+ Perfect: perfect,
Unlocked: unlocked,
- ContentVersion: lv["content_version"].Int(),
- ProgressVersion: p["content_version"].Int(),
+ ContentVersion: lv.ContentVersion,
+ ProgressVersion: progressVersion,
})
}
return detail, nil
}
// unlockState 计策解锁判定:unlock_before 为空直接解锁,否则前置计全部关卡完美。
-func (s *strategy) unlockState(ctx context.Context, childId int64, childAge string, strategyRec gdb.Record) (bool, string) {
- prevId := strategyRec["unlock_before"].Int64()
+func (s *strategy) unlockState(ctx context.Context, childId int64, childAge string, strategyRec *entity.Strategy) (bool, string) {
+ prevId := strategyRec.UnlockBefore
if prevId == 0 {
return true, ""
}
prevName := ""
- if r, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(prevId).One(); err == nil {
- prevName = r["name"].String()
+ if r, err := dao.Strategy.GetByPkCached(ctx, prevId); err == nil && r != nil {
+ prevName = r.Name
}
perfect, total := 0, 0
- if levels, err := s.listLevels(ctx, []int64{prevId}, childAge); err == nil {
+ if levels, err := dao.Level.ListEnabledByStrategyIds(ctx, []int64{prevId}, childAge); err == nil {
total = len(levels)
if progress, err := s.progressOfLevels(ctx, childId, levelIdsOf(levels)); err == nil {
for _, lv := range levels {
- if progress[lv["id"].Int64()]["perfect"].Int() == 1 {
+ if p := progress[lv.Id]; p != nil && p.Perfect == 1 {
perfect++
}
}
@@ -236,41 +182,15 @@ func (s *strategy) unlockState(ctx context.Context, childId int64, childAge stri
return false, fmt.Sprintf("完成《%s》全部关卡解锁", prevName)
}
-// listAll 全部启用计策(内容缓存),按分组 + 组内序号排序。
-func (s *strategy) listAll(ctx context.Context) ([]gdb.Record, error) {
- recs, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).
- Where("status", consts.StatusEnabled).Order("group_no ASC, sort_order ASC").All()
- if err != nil {
- return nil, err
- }
- return recs, nil
-}
-
-// listLevels 指定计策下、匹配年龄段且启用的关卡(内容缓存)。
-func (s *strategy) listLevels(ctx context.Context, strategyIds []int64, ageGroup string) ([]gdb.Record, error) {
- recs, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).
- WhereIn("strategy_id", strategyIds).
- Where("status", consts.StatusEnabled).
- Where("age_group", ageGroup).
- Order("sort_order ASC").All()
- if err != nil {
- return nil, err
- }
- return recs, nil
-}
-
// progressOfLevels 孩子的关卡进度(不缓存,随闯关更新)。
-func (s *strategy) progressOfLevels(ctx context.Context, childId int64, levelIds []int64) (map[int64]gdb.Record, error) {
- m := make(map[int64]gdb.Record, len(levelIds))
- if len(levelIds) == 0 {
- return m, nil
- }
- recs, err := UserProgress.ListByChildLevelIds(ctx, childId, levelIds)
+func (s *strategy) progressOfLevels(ctx context.Context, childId int64, levelIds []int64) (map[int64]*entity.UserProgress, error) {
+ recs, err := dao.UserProgress.ListByChildLevelIds(ctx, childId, levelIds)
if err != nil {
return nil, err
}
+ m := make(map[int64]*entity.UserProgress, len(recs))
for _, r := range recs {
- m[r["level_id"].Int64()] = r
+ m[r.LevelId] = r
}
return m, nil
}
@@ -282,28 +202,28 @@ func (s *strategy) elementNames(ctx context.Context, ids []int64) (map[int64]str
if len(ids) == 0 {
return m, nil
}
- recs, err := Element.ListEnabledByIds(ctx, ids)
+ recs, err := dao.Element.ListEnabledByIdsCached(ctx, ids)
if err != nil {
return nil, err
}
for _, r := range recs {
- m[r["id"].Int64()] = r["name"].String()
+ m[r.Id] = r.Name
}
return m, nil
}
-func levelIdsOf(levels []gdb.Record) []int64 {
+func levelIdsOf(levels []*entity.Level) []int64 {
ids := make([]int64, 0, len(levels))
for _, lv := range levels {
- ids = append(ids, lv["id"].Int64())
+ ids = append(ids, lv.Id)
}
return ids
}
-func sceneIdsOf(levels []gdb.Record) []int64 {
+func sceneIdsOf(levels []*entity.Level) []int64 {
ids := make([]int64, 0, len(levels))
for _, lv := range levels {
- ids = append(ids, lv["scene_id"].Int64())
+ ids = append(ids, lv.SceneId)
}
return ids
}
@@ -320,3 +240,159 @@ func uniqueInt64(in []int64) []int64 {
}
return out
}
+
+// ---------- 后台管理 ----------
+
+type adminStrategy struct{}
+
+var AdminStrategy = &adminStrategy{}
+
+// List 全部计策(含启用关卡数),按分组 + 序号排序。
+func (s *adminStrategy) List(ctx context.Context, req *dto.AdminStrategyListReq) (*dto.AdminStrategyListRes, error) {
+ recs, err := dao.Strategy.ListAll(ctx)
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(recs))
+ for _, r := range recs {
+ ids = append(ids, r.Id)
+ }
+ counts, err := dao.Level.CountEnabledByStrategyIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*dto.AdminStrategyItem, 0, len(recs))
+ for _, r := range recs {
+ items = append(items, &dto.AdminStrategyItem{
+ Id: r.Id, Name: r.Name, Pinyin: r.Pinyin,
+ GroupNo: r.GroupNo, GroupName: r.GroupName,
+ Meaning: r.Meaning, TeachContent: r.TeachContent,
+ TeachImage: r.TeachImage, TeachAudio: r.TeachAudio,
+ SummaryQ: r.SummaryQ, SummaryOptions: r.SummaryOptions,
+ SummaryAudio: r.SummaryAudio, Icon: r.Icon,
+ SortOrder: r.SortOrder, UnlockBefore: r.UnlockBefore,
+ Status: r.Status, LevelCount: counts[r.Id],
+ })
+ }
+ return &dto.AdminStrategyListRes{List: items}, nil
+}
+
+// strategyNames 批量计策名。
+func strategyNames(ctx context.Context, ids []int64) (map[int64]string, error) {
+ m := make(map[int64]string, len(ids))
+ ids = uniqueInt64(ids)
+ if len(ids) == 0 {
+ return m, nil
+ }
+ recs, err := dao.Strategy.ListByIds(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range recs {
+ m[r.Id] = r.Name
+ }
+ return m, nil
+}
+
+// checkUnlockBefore 解锁前置校验:>0 时目标须存在且非自身。
+func checkUnlockBefore(ctx context.Context, id, unlockBefore int64) error {
+ if unlockBefore <= 0 {
+ return nil
+ }
+ if id > 0 && unlockBefore == id {
+ return gerror.New("前置计策不能是自身")
+ }
+ rec, err := dao.Strategy.GetByPk(ctx, unlockBefore)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("前置计策不存在")
+ }
+ return nil
+}
+
+// Create 新增计策:名称转拼音、文本一次性拼音标注后入库,清内容缓存。
+func (s *adminStrategy) Create(ctx context.Context, req *dto.AdminStrategyCreateReq) (*dto.AdminStrategyCreateRes, error) {
+ if err := checkUnlockBefore(ctx, 0, req.UnlockBefore); err != nil {
+ return nil, err
+ }
+ id, err := dao.Strategy.InsertAndReturnId(ctx, g.Map{
+ "name": req.Name, "pinyin": common.ToPinyinPlain(req.Name),
+ "group_no": req.GroupNo, "group_name": req.GroupName,
+ "meaning": req.Meaning, "meaning_pinyin": common.AnnotatePinyin(req.Meaning),
+ "teach_content": req.TeachContent, "teach_content_pinyin": common.AnnotatePinyin(req.TeachContent),
+ "teach_image": req.TeachImage, "teach_audio": req.TeachAudio,
+ "summary_q": req.SummaryQ, "summary_q_pinyin": common.AnnotatePinyin(req.SummaryQ),
+ "summary_options": req.SummaryOptions, "summary_options_pinyin": common.AnnotatePinyin(req.SummaryOptions),
+ "summary_audio": req.SummaryAudio, "icon": req.Icon,
+ "sort_order": req.SortOrder, "unlock_before": req.UnlockBefore,
+ "status": consts.StatusEnabled,
+ })
+ if err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableStrategy)
+ return &dto.AdminStrategyCreateRes{Id: id}, nil
+}
+
+// Update 编辑计策:全字段覆盖,拼音重标,清内容缓存。
+func (s *adminStrategy) Update(ctx context.Context, req *dto.AdminStrategyUpdateReq) (*dto.AdminStrategyUpdateRes, error) {
+ rec, err := dao.Strategy.GetByPk(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil {
+ return nil, gerror.New("计策不存在")
+ }
+ if err = checkUnlockBefore(ctx, req.Id, req.UnlockBefore); err != nil {
+ return nil, err
+ }
+ if err = dao.Strategy.UpdateByPk(ctx, req.Id, g.Map{
+ "name": req.Name, "pinyin": common.ToPinyinPlain(req.Name),
+ "group_no": req.GroupNo, "group_name": req.GroupName,
+ "meaning": req.Meaning, "meaning_pinyin": common.AnnotatePinyin(req.Meaning),
+ "teach_content": req.TeachContent, "teach_content_pinyin": common.AnnotatePinyin(req.TeachContent),
+ "teach_image": req.TeachImage, "teach_audio": req.TeachAudio,
+ "summary_q": req.SummaryQ, "summary_q_pinyin": common.AnnotatePinyin(req.SummaryQ),
+ "summary_options": req.SummaryOptions, "summary_options_pinyin": common.AnnotatePinyin(req.SummaryOptions),
+ "summary_audio": req.SummaryAudio, "icon": req.Icon,
+ "sort_order": req.SortOrder, "unlock_before": req.UnlockBefore,
+ }); err != nil {
+ return nil, err
+ }
+ common.InvalidateContentCache(ctx, consts.TableStrategy)
+ return &dto.AdminStrategyUpdateRes{}, nil
+}
+
+// Disable 下架计策(软删除)。
+func (s *adminStrategy) Disable(ctx context.Context, req *dto.AdminStrategyDisableReq) (*dto.AdminStrategyDisableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminStrategyDisableRes{}, nil
+}
+
+// Enable 上架计策。
+func (s *adminStrategy) Enable(ctx context.Context, req *dto.AdminStrategyEnableReq) (*dto.AdminStrategyEnableRes, error) {
+ if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
+ return nil, err
+ }
+ return &dto.AdminStrategyEnableRes{}, nil
+}
+
+// setStatus 上下架(软删除)。
+func (s *adminStrategy) setStatus(ctx context.Context, id int64, status int) error {
+ rec, err := dao.Strategy.GetByPk(ctx, id)
+ if err != nil {
+ return err
+ }
+ if rec == nil {
+ return gerror.New("计策不存在")
+ }
+ if err = dao.Strategy.UpdateByPk(ctx, id, g.Map{"status": status}); err != nil {
+ return err
+ }
+ common.InvalidateContentCache(ctx, consts.TableStrategy)
+ return nil
+}
diff --git a/biz/service/user_badge.go b/biz/service/user_badge.go
index 473ffe1..4b8029c 100644
--- a/biz/service/user_badge.go
+++ b/biz/service/user_badge.go
@@ -1,27 +1,6 @@
package service
-import (
- "context"
-
- "github.com/gogf/gf/v2/database/gdb"
- "github.com/gogf/gf/v2/frame/g"
-
- "36wisdom/biz/dao"
-)
-
+// UserBadge 徽章数据访问已由 dao.UserBadge 承载,此文件仅为分层对齐保留。
type userBadge struct{}
var UserBadge = &userBadge{}
-
-// ListByUser 孩子已获徽章。
-func (s *userBadge) ListByUser(ctx context.Context, userId int64) ([]gdb.Record, error) {
- return dao.UserBadge.Model().Ctx(ctx).Where("user_id", userId).Order("id ASC").All()
-}
-
-// Insert 记录孩子获得徽章。
-func (s *userBadge) Insert(ctx context.Context, userId, badgeId int64) (int64, error) {
- return dao.UserBadge.InsertAndReturnId(ctx, g.Map{
- "user_id": userId,
- "badge_id": badgeId,
- })
-}
diff --git a/biz/service/user_collection.go b/biz/service/user_collection.go
index 18d8f3c..4d04d00 100644
--- a/biz/service/user_collection.go
+++ b/biz/service/user_collection.go
@@ -1,36 +1,6 @@
package service
-import (
- "context"
-
- "github.com/gogf/gf/v2/database/gdb"
- "github.com/gogf/gf/v2/frame/g"
-
- "36wisdom/biz/consts"
- "36wisdom/biz/dao"
-)
-
+// UserCollection 收集数据访问已由 dao.UserCollection 承载,此文件仅为分层对齐保留。
type userCollection struct{}
var UserCollection = &userCollection{}
-
-// ExistsInTx 事务内判断孩子是否已收集该计策。
-func (s *userCollection) ExistsInTx(ctx context.Context, tx gdb.TX, userId, strategyId int64) (bool, error) {
- n, err := tx.Model(consts.TableUserCollection).Ctx(ctx).
- Where("user_id", userId).Where("strategy_id", strategyId).Count()
- return n > 0, err
-}
-
-// InsertInTx 事务内写入计策卡收集。
-func (s *userCollection) InsertInTx(ctx context.Context, tx gdb.TX, userId, strategyId int64) error {
- _, err := tx.Model(consts.TableUserCollection).Ctx(ctx).Data(g.Map{
- "user_id": userId,
- "strategy_id": strategyId,
- }).Insert()
- return err
-}
-
-// ListByUser 孩子已收集计策列表。
-func (s *userCollection) ListByUser(ctx context.Context, userId int64) ([]gdb.Record, error) {
- return dao.UserCollection.Model().Ctx(ctx).Where("user_id", userId).All()
-}
diff --git a/biz/service/user_progress.go b/biz/service/user_progress.go
index 7e17297..9c59c24 100644
--- a/biz/service/user_progress.go
+++ b/biz/service/user_progress.go
@@ -1,78 +1,6 @@
package service
-import (
- "context"
-
- "github.com/gogf/gf/v2/database/gdb"
- "github.com/gogf/gf/v2/frame/g"
- "github.com/gogf/gf/v2/os/gtime"
-
- "36wisdom/biz/consts"
- "36wisdom/biz/dao"
-)
-
+// UserProgress 闯关进度数据访问已由 dao.UserProgress 承载,此文件仅为分层对齐保留。
type userProgress struct{}
var UserProgress = &userProgress{}
-
-// GetByChildLevel 孩子某关进度(不缓存,随闯关更新)。
-func (s *userProgress) GetByChildLevel(ctx context.Context, childId, levelId int64) (gdb.Record, error) {
- return dao.UserProgress.Model().Ctx(ctx).
- Where("child_id", childId).Where("level_id", levelId).One()
-}
-
-// ListByChildLevelIds 孩子多关进度(不缓存)。
-func (s *userProgress) ListByChildLevelIds(ctx context.Context, childId int64, levelIds []int64) ([]gdb.Record, error) {
- return dao.UserProgress.Model().Ctx(ctx).
- Where("child_id", childId).
- WhereIn("level_id", levelIds).All()
-}
-
-// CountPerfectByChild 孩子完美通关关卡数。
-func (s *userProgress) CountPerfectByChild(ctx context.Context, childId int64) (int, error) {
- return dao.UserProgress.Model().Ctx(ctx).
- Where("child_id", childId).Where("perfect", 1).Count()
-}
-
-// CountPerfectByChildIds 批量孩子的完美通关数(按 child_id 分组)。
-func (s *userProgress) CountPerfectByChildIds(ctx context.Context, childIds []int64) ([]gdb.Record, error) {
- return dao.UserProgress.Model().Ctx(ctx).
- Fields("child_id", "COUNT(*) AS cnt").
- Where("perfect", 1).
- WhereIn("child_id", childIds).
- Group("child_id").All()
-}
-
-// GetInTx 事务内读孩子某关进度。
-func (s *userProgress) GetInTx(ctx context.Context, tx gdb.TX, childId, levelId int64) (gdb.Record, error) {
- return tx.Model(consts.TableUserProgress).Ctx(ctx).
- Where("child_id", childId).Where("level_id", levelId).One()
-}
-
-// UpsertInTx 事务内合并写入进度:星星取历史最高、完美取并集;不存在则插入。
-func (s *userProgress) UpsertInTx(ctx context.Context, tx gdb.TX, childId, levelId int64, stars, perfect, contentVersion int) error {
- rec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
- Where("child_id", childId).Where("level_id", levelId).One()
- if err != nil {
- return err
- }
- if stars < rec["stars"].Int() {
- stars = rec["stars"].Int()
- }
- perfect = rec["perfect"].Int() | perfect
- data := g.Map{
- "stars": stars,
- "perfect": perfect,
- "content_version": contentVersion,
- "completed_at": gtime.Now(),
- }
- if rec.IsEmpty() {
- data["child_id"] = childId
- data["level_id"] = levelId
- _, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(data).Insert()
- } else {
- _, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(data).
- Where("child_id", childId).Where("level_id", levelId).Update()
- }
- return err
-}
diff --git a/biz/service/user_route_log.go b/biz/service/user_route_log.go
index 4784cd3..c0e602c 100644
--- a/biz/service/user_route_log.go
+++ b/biz/service/user_route_log.go
@@ -1,33 +1,6 @@
package service
-import (
- "context"
-
- "github.com/gogf/gf/v2/database/gdb"
- "github.com/gogf/gf/v2/frame/g"
-
- "36wisdom/biz/dao"
-)
-
+// UserRouteLog 路径流水数据访问已由 dao.UserRouteLog 承载,此文件仅为分层对齐保留。
type userRouteLog struct{}
var UserRouteLog = &userRouteLog{}
-
-// Append 追加一条决策路径流水。
-func (s *userRouteLog) Append(ctx context.Context, childId, levelId, nodeId, optionId int64, resultType int) error {
- _, err := dao.UserRouteLog.InsertAndReturnId(ctx, g.Map{
- "child_id": childId,
- "level_id": levelId,
- "node_id": nodeId,
- "option_id": optionId,
- "result_type": resultType,
- })
- return err
-}
-
-// ListFinalsByChildLevel 孩子某关的终局路径(result_type > 0,按 id 升序,不缓存)。
-func (s *userRouteLog) ListFinalsByChildLevel(ctx context.Context, childId, levelId int64) ([]gdb.Record, error) {
- return dao.UserRouteLog.Model().Ctx(ctx).
- Where("child_id", childId).Where("level_id", levelId).
- WhereGT("result_type", 0).Order("id ASC").All()
-}
diff --git a/common/base_dao.go b/common/base_dao.go
index 6976f17..2574c1c 100644
--- a/common/base_dao.go
+++ b/common/base_dao.go
@@ -2,6 +2,7 @@ package common
import (
"context"
+ "time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
@@ -22,11 +23,49 @@ func (d *BaseDao) InsertAndReturnId(ctx context.Context, data g.Map) (int64, err
return res.LastInsertId()
}
-func (d *BaseDao) GetOneByPk(ctx context.Context, pk int64) (gdb.Record, error) {
- return d.Model().Ctx(ctx).WherePri(pk).One()
-}
-
func (d *BaseDao) UpdateByPk(ctx context.Context, pk int64, data g.Map) error {
_, err := d.Model().Ctx(ctx).WherePri(pk).Data(data).Update()
return err
}
+
+// ContentCache 内容查询缓存选项:TTL 来自配置 database.cache.ttl,≤0 回退 300 秒。
+func (d *BaseDao) ContentCache(ctx context.Context) gdb.CacheOption {
+ ttl := g.Cfg().MustGet(ctx, "database.cache.ttl").Int()
+ if ttl <= 0 {
+ ttl = 300
+ }
+ return gdb.CacheOption{Duration: time.Duration(ttl) * time.Second}
+}
+
+// GetOne 执行单行查询,用 goframe 自带转换(Record.Struct,orm tag 优先)转为结构体;
+// 记录不存在返回 nil, nil。dao 层把构建好的 model(含条件/排序/缓存)传入。
+func GetOne[T any](m *gdb.Model) (*T, error) {
+ rec, err := m.One()
+ if err != nil {
+ return nil, err
+ }
+ if rec.IsEmpty() {
+ return nil, nil
+ }
+ dst := new(T)
+ if err = rec.Struct(dst); err != nil {
+ return nil, err
+ }
+ return dst, nil
+}
+
+// GetList 执行多行查询,用 goframe 自带转换(Result.Structs)转为结构体切片;无记录返回空切片。
+func GetList[T any](m *gdb.Model) ([]*T, error) {
+ recs, err := m.All()
+ if err != nil {
+ return nil, err
+ }
+ if len(recs) == 0 {
+ return []*T{}, nil
+ }
+ var items []*T
+ if err = recs.Structs(&items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/common/content_cache.go b/common/content_cache.go
new file mode 100644
index 0000000..bcdb1e2
--- /dev/null
+++ b/common/content_cache.go
@@ -0,0 +1,17 @@
+package common
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// InvalidateContentCache 内容写操作提交后统一失效查询缓存(技术设计.md 4.17)。
+// 表名由调用方(service 层)传入:gdb 缓存 key 按表名前缀组织,ClearCache 按表批量删除,
+// 后台改内容、前台读缓存立即取新值,无需等待 TTL。
+func InvalidateContentCache(ctx context.Context, tables ...string) {
+ core := g.DB().GetCore()
+ for _, t := range tables {
+ core.ClearCache(ctx, t)
+ }
+}
diff --git a/common/pinyin.go b/common/pinyin.go
index f80297f..9c33777 100644
--- a/common/pinyin.go
+++ b/common/pinyin.go
@@ -65,3 +65,43 @@ func AnnotatePinyin(text string) string {
b, _ := json.Marshal(out)
return string(b)
}
+
+// ToPinyinPlain 文本 → 空格分隔带声调拼音串(词表最长匹配优先,未命中逐字转),
+// 与种子数据 strategy.pinyin 格式一致;用于写时标注 name → pinyin。
+func ToPinyinPlain(text string) string {
+ runes := []rune(text)
+ if len(runes) == 0 {
+ return ""
+ }
+ words := make([]string, 0, len(consts.PinyinOverrides))
+ for w := range consts.PinyinOverrides {
+ words = append(words, w)
+ }
+ sort.Slice(words, func(i, j int) bool { return len(words[i]) > len(words[j]) })
+
+ parts := make([]string, 0, len(runes))
+ args := pinyin.Args{Style: pinyin.Tone}
+ for i := 0; i < len(runes); {
+ matched := false
+ for _, w := range words {
+ n := len([]rune(w))
+ if i+n > len(runes) || string(runes[i:i+n]) != w {
+ continue
+ }
+ parts = append(parts, strings.Fields(consts.PinyinOverrides[w])...)
+ i += n
+ matched = true
+ break
+ }
+ if matched {
+ continue
+ }
+ if unicode.Is(unicode.Han, runes[i]) {
+ if pys := pinyin.SinglePinyin(runes[i], args); len(pys) > 0 {
+ parts = append(parts, pys[0])
+ }
+ }
+ i++
+ }
+ return strings.Join(parts, " ")
+}
diff --git a/main.go b/main.go
index 59866b5..46e44a8 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,7 @@ import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
+ "36wisdom/biz/consts"
"36wisdom/biz/controller"
"36wisdom/biz/dao"
"36wisdom/common"
@@ -20,7 +21,7 @@ import (
func main() {
var ctx context.Context = gctx.New()
- for _, dir := range []string{"data", "workspace"} {
+ for _, dir := range []string{"data", "workspace", "workspace/uploads/image", "workspace/uploads/audio"} {
if err := os.MkdirAll(dir, 0o755); err != nil {
g.Log().Fatal(ctx, err)
}
@@ -54,6 +55,15 @@ func main() {
g.Bind(controller.Child, controller.Strategy, controller.Level)
})
})
+ // 后台:登录公开,其余 admin 角色鉴权
+ s.Group("/api/admin", func(g *ghttp.RouterGroup) {
+ g.Bind(controller.AdminUser)
+
+ g.Group("/", func(g *ghttp.RouterGroup) {
+ g.Middleware(auth.Middleware(secret, consts.RoleAdmin))
+ g.Bind(controller.AdminUpload, controller.AdminElement, controller.AdminStrategy, controller.AdminLevel, controller.AdminSceneNode, controller.AdminNodeOption, controller.AdminPrize, controller.AdminBadge, controller.AdminLifeTask, controller.AdminParent, controller.AdminChild, controller.AdminRedemption, controller.AdminStats)
+ })
+ })
// 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口)
// 注:根路径须用 SetServerRoot——AddStaticPath("/", ...) 因前缀防误匹配守卫只服务根路径,
// /assets 等子路径全部 404;素材目录 ui-src/static 以 /static 前缀单独挂载
@@ -63,6 +73,12 @@ func main() {
if _, err := os.Stat("ui-src/static"); err == nil {
s.AddStaticPath("/static", "ui-src/static")
}
+ // 后台上传素材:/uploads 前缀(可变内容,不强缓存)
+ s.AddStaticPath("/uploads", "workspace/uploads")
+ // 管理后台产物 admin-src/dist(hash 路由,由 /admin 前缀托管)
+ if _, err := os.Stat("admin-src/dist"); err == nil {
+ s.AddStaticPath("/admin", "admin-src/dist")
+ }
s.Run()
}
diff --git a/技术设计.md b/技术设计.md
index 2ac3351..cab9bb6 100644
--- a/技术设计.md
+++ b/技术设计.md
@@ -577,6 +577,22 @@ config JSON 结构(`scene_node.config`,前端解析渲染):`{"kind":"pro
- 前端:StoryPlayer 播第 N 句时预载第 N+1 句(speech.js `prefetch`,H5 用 `new Audio().preload='auto'`,App/小程序用 `createInnerAudioContext` 预载)——单句 mp3 仅 20-50KB,句间衔接不卡
- App/小程序本地文件缓存(首播落沙盒/文件系统)留 M4 多端发布时做
+### 4.17 后台管理(/api/admin,管理员鉴权)
+
+**路由与鉴权**:`/api/admin` 分组——`login` 公开,其余全部挂 `auth.Middleware(secret, consts.RoleAdmin)`(RoleAdmin="admin" 首次投入使用)。admin 接口用独立 `adminXxx` 控制器结构体(各表 controller 文件内并列定义,同文件多结构体不破坏分层文件对齐),只绑 admin 组,不泄露进前台组。
+
+**登录**:username + bcrypt 密码校验(admin_user 表,种子默认 admin/admin123)→ `auth.GenerateToken(secret, uid, RoleAdmin, expire)`,expire 读 `auth.expire` 配置(≤0 回退常量)。
+
+**缓存失效(后台改内容 → 前台立即可见)**:gcache 无按前缀删除,用 gdb `Core.ClearCache(ctx, table)`(KeyStrings 全量取 key → 按 `SelectCache:@` 前缀过滤 → 批量删,gf v2.10.2 源码确认)。`common/content_cache.go` 的 `InvalidateContentCache` 统一失效 8 个内容表(strategy/level/scene_node/node_option/element/prize/badge/life_task),内容写方法提交后调用一次。理由:strategy 列表 / level 详情是跨表内存组装(level→node→option→element),单表失效易漏;内存 key 总量几十级,全清成本可忽略。用户数据(parent/child/progress/redemption)admin 查询不设缓存,无失效义务。
+
+**内容 CRUD**:五件套 list/create/update/disable/enable,删除一律软删除(status=0 下架,无物理删除——user_progress/redemption 引用 id,下架即前台消失、可逆)。内容写时一次性拼音重标(复用 `common.AnnotatePinyin`,strategy.name 用 `ToPinyinPlain`);node/option 及 level 自身改动在事务内 `content_version+1`(`gdb.Raw("content_version+1")` 原子自增,前台按版本号提示重新挑战)。校验:level 须 strategy 存在、node 须 level 存在、option 须 node 存在且 next_node_id 同关(内存环检测:沿 next 走 ≤ 关节点数步)、character_id/prop_id>0 时元素存在、is_entry=1 时同事务清同关其他入口。
+
+**兑换状态机**:1待领取/2待发货 → ship({1,2}→3 已发货,code 缺省 `36ZH-`+大写8位随机) → receive(3→5 已领取);cancel({1,2}→4 已取消,事务内退积分:读 child.points → UPDATE + 插入 point_log{reason_type=ReasonAdmin=4})。状态校验在事务内读行防并发竞态。
+
+**关卡卡点统计**:5 条单表 SQL + 内存组装——level、scene_node、node_option(≤100 分批 IN)、`user_route_log GROUP BY node_id,option_id,result_type` 聚合、`user_progress GROUP BY perfect`;不缓存(实时)。dao Init 补 `idx_route_log_level(level_id)`、`idx_progress_level(level_id)`。
+
+**上传**:multipart → `workspace/uploads/{image|audio}/YYYYMMDD_<随机>.ext`(运行时数据与代码分离,不落 git 跟踪的 ui-src/static),`/uploads` 静态挂载(不带 immutable 头);白名单 png/jpg/jpeg/webp/gif/mp3/wav/m4a/ogg(扩展名 + `http.DetectContentType` 双校验),图片 ≤10MB、音频 ≤20MB。后台上传素材供 H5/Web 引用;多端发布时素材走打包/CDN 策略另行决定。
+
## 5. 开发计划
| 里程碑 | 内容 | 验收标准 |