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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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. 开发计划 | 里程碑 | 内容 | 验收标准 |