1
This commit is contained in:
@@ -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 等待上)
|
||||
|
||||
@@ -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) |
|
||||
|
||||
## 使用说明
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>三十六计·管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1929
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="upload-field">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="doUpload"
|
||||
:accept="accept"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-button size="small" :loading="loading">{{ label }}:上传</el-button>
|
||||
</el-upload>
|
||||
<span v-if="modelValue" class="preview">
|
||||
<el-image v-if="kind === 'image'" :src="modelValue" style="width: 56px; height: 56px" fit="cover" />
|
||||
<el-button v-else link type="primary" @click="playAudio">试听</el-button>
|
||||
<el-button link type="danger" @click="$emit('update:modelValue', '')">清除</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '../request'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
kind: { type: String, default: 'image' }, // image | audio
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const loading = ref(false)
|
||||
const accept = props.kind === 'image' ? 'image/*' : 'audio/*'
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const max = props.kind === 'image' ? 10 * 1024 * 1024 : 20 * 1024 * 1024
|
||||
if (file.size > max) {
|
||||
ElMessage.error('文件过大')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const doUpload = async ({ file }) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const data = await request.post('/upload', fd)
|
||||
emit('update:modelValue', data.url)
|
||||
ElMessage.success('上传成功')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const playAudio = () => {
|
||||
const a = new Audio(props.modelValue)
|
||||
a.play()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-field { display: flex; align-items: center; gap: 8px; }
|
||||
.preview { display: inline-flex; align-items: center; gap: 4px; }
|
||||
</style>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="200px">
|
||||
<div class="logo">三十六计·后台</div>
|
||||
<el-menu :default-active="$route.path" router background-color="#1f2d3d" text-color="#c0c4cc" active-text-color="#409eff">
|
||||
<el-menu-item index="/strategy"><span>计策管理</span></el-menu-item>
|
||||
<el-menu-item index="/level"><span>关卡管理</span></el-menu-item>
|
||||
<el-menu-item index="/scene-node"><span>场景节点</span></el-menu-item>
|
||||
<el-menu-item index="/node-option"><span>分支选项</span></el-menu-item>
|
||||
<el-menu-item index="/element"><span>元素库</span></el-menu-item>
|
||||
<el-menu-item index="/prize"><span>奖品管理</span></el-menu-item>
|
||||
<el-menu-item index="/badge"><span>徽章管理</span></el-menu-item>
|
||||
<el-menu-item index="/life-task"><span>生活任务</span></el-menu-item>
|
||||
<el-menu-item index="/parent"><span>家长管理</span></el-menu-item>
|
||||
<el-menu-item index="/child"><span>孩子管理</span></el-menu-item>
|
||||
<el-menu-item index="/redemption"><span>兑换管理</span></el-menu-item>
|
||||
<el-menu-item index="/stats"><span>关卡统计</span></el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<span>{{ $route.meta.title }}</span>
|
||||
<el-button link type="primary" @click="logout">退出登录</el-button>
|
||||
</el-header>
|
||||
<el-main><router-view /></el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const logout = () => {
|
||||
localStorage.removeItem('admin_token')
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { height: 100vh; }
|
||||
.logo { height: 60px; line-height: 60px; text-align: center; color: #fff; font-weight: 600; background: #1f2d3d; }
|
||||
.el-aside { background: #1f2d3d; }
|
||||
.el-menu { border-right: none; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e4e7ed; }
|
||||
</style>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filterEType" style="width: 140px" @change="load()">
|
||||
<el-option label="全部" :value="0" />
|
||||
<el-option v-for="(v, k) in eTypeMap" :key="k" :label="v" :value="Number(k)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="openCreate({ e_type: 1, sort_order: 1 })">新增元素</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">{{ eTypeMap[row.e_type] }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="name_pinyin" label="拼音" min-width="160" />
|
||||
<el-table-column label="图片" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.image" :src="row.image" style="width: 40px; height: 40px" fit="cover" :preview-src-list="[row.image]" preview-teleported />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="sort_order" label="序号" width="70" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, ['id', 'e_type', 'name', 'image', 'audio', 'description', 'sort_order'])">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑元素' : '新增元素'" width="560px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="类型" required>
|
||||
<el-radio-group v-model="form.e_type">
|
||||
<el-radio v-for="(v, k) in eTypeMap" :key="k" :value="Number(k)">{{ v }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="图片"><UploadField v-model="form.image" kind="image" label="图片" /></el-form-item>
|
||||
<el-form-item label="音频"><UploadField v-model="form.audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="序号"><el-input-number v-model="form.sort_order" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { eTypeMap, statusTag, statusText } from '../constants'
|
||||
import UploadField from '../components/UploadField.vue'
|
||||
|
||||
const filterEType = ref(0)
|
||||
const crud = useCrud('/element')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
load({ e_type: 0 })
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-form-item label="计策">
|
||||
<el-select v-model="filterStrategy" style="width: 200px" @change="load({ strategy_id: filterStrategy })">
|
||||
<el-option v-for="s in strategies" :key="s.id" :label="`${s.name} (#${s.id})`" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :disabled="!filterStrategy" @click="openCreate({ strategy_id: filterStrategy, age_group: '4-6', sort_order: 1 })">新增关卡</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="title" label="标题" min-width="160" />
|
||||
<el-table-column prop="scene_name" label="场景" width="100" />
|
||||
<el-table-column prop="age_group" label="年龄" width="80" />
|
||||
<el-table-column label="版本" width="80">
|
||||
<template #default="{ row }"><el-tag size="small">v{{ row.content_version }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="node_count" label="节点数" width="80" />
|
||||
<el-table-column prop="sort_order" label="序号" width="70" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, EDIT_KEYS)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑关卡' : '新增关卡'" width="680px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="计策" required>
|
||||
<el-select v-model="form.strategy_id" style="width: 100%">
|
||||
<el-option v-for="s in strategies" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" required><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="场景元素ID"><el-input-number v-model="form.scene_id" :min="0" /></el-form-item>
|
||||
<el-form-item label="场景文案"><el-input v-model="form.scene_content" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="场景图"><UploadField v-model="form.scene_image" kind="image" label="场景图" /></el-form-item>
|
||||
<el-form-item label="场景音频"><UploadField v-model="form.scene_audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="年龄段" required>
|
||||
<el-radio-group v-model="form.age_group">
|
||||
<el-radio value="4-6">4-6 岁</el-radio>
|
||||
<el-radio value="6-8">6-8 岁</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="序号"><el-input-number v-model="form.sort_order" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { statusTag, statusText } from '../constants'
|
||||
import UploadField from '../components/UploadField.vue'
|
||||
import request from '../request'
|
||||
|
||||
const EDIT_KEYS = ['id', 'strategy_id', 'title', 'scene_id', 'scene_content', 'scene_image', 'scene_audio', 'age_group', 'sort_order']
|
||||
const strategies = ref([])
|
||||
const filterStrategy = ref(0)
|
||||
const crud = useCrud('/level')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
|
||||
request.get('/strategy/list').then((d) => {
|
||||
strategies.value = (d && d.list) || []
|
||||
if (strategies.value.length) {
|
||||
filterStrategy.value = strategies.value[0].id
|
||||
load({ strategy_id: filterStrategy.value })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-button type="primary" @click="openCreate({ reward_points: 5 })">新增任务</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="strategy_name" label="关联计策" min-width="140" />
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="reward_points" label="奖励积分" width="90" />
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="guide" label="引导" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, EDIT_KEYS)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑任务' : '新增任务'" width="600px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="关联计策" required>
|
||||
<el-select v-model="form.strategy_id" style="width: 100%">
|
||||
<el-option v-for="s in strategies" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" required><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="引导"><el-input v-model="form.guide" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="奖励积分"><el-input-number v-model="form.reward_points" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { statusTag, statusText } from '../constants'
|
||||
import request from '../request'
|
||||
|
||||
const EDIT_KEYS = ['id', 'strategy_id', 'title', 'description', 'guide', 'reward_points']
|
||||
const strategies = ref([])
|
||||
const crud = useCrud('/life-task')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
load()
|
||||
request.get('/strategy/list').then((d) => {
|
||||
strategies.value = (d && d.list) || []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="login-wrap">
|
||||
<el-card class="login-card">
|
||||
<h2>三十六计·管理后台</h2>
|
||||
<el-form :model="form" @keyup.enter="submit">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.username" placeholder="用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" style="width: 100%" :loading="loading" @click="submit">登 录</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '../request'
|
||||
|
||||
const router = useRouter()
|
||||
const form = reactive({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request.post('/login', form)
|
||||
localStorage.setItem('admin_token', data.token)
|
||||
router.push('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap { display: flex; justify-content: center; align-items: center; height: 100vh; background: #f0f2f5; }
|
||||
.login-card { width: 360px; padding: 12px 8px; }
|
||||
.login-card h2 { text-align: center; margin: 8px 0 24px; }
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-form-item label="计策">
|
||||
<el-select v-model="filterStrategy" style="width: 180px" @change="onStrategyChange">
|
||||
<el-option v-for="s in strategies" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关卡">
|
||||
<el-select v-model="filterLevel" style="width: 200px" @change="onLevelChange">
|
||||
<el-option v-for="l in levels" :key="l.id" :label="`${l.title} (#${l.id})`" :value="l.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="节点">
|
||||
<el-select v-model="filterNode" style="width: 180px" @change="load({ node_id: filterNode })">
|
||||
<el-option v-for="n in nodes" :key="n.id" :label="`${n.title || n.content.slice(0, 10)} (#${n.id})`" :value="n.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :disabled="!filterNode" @click="openCreate({ node_id: filterNode, sort_order: 1 })">新增选项</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="node_title" label="所属节点" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="text" label="选项文本" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="prop_name" label="道具" width="90" />
|
||||
<el-table-column prop="next_node_title" label="下一节点" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="feedback" label="反馈" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="sort_order" label="序号" width="70" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, EDIT_KEYS)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑选项' : '新增选项'" width="640px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="选项文本" required><el-input v-model="form.text" /></el-form-item>
|
||||
<el-form-item label="下一节点ID" required><el-input-number v-model="form.next_node_id" :min="1" /></el-form-item>
|
||||
<el-form-item label="道具ID"><el-input-number v-model="form.prop_id" :min="0" /></el-form-item>
|
||||
<el-form-item label="选项音频"><UploadField v-model="form.audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="反馈文本"><el-input v-model="form.feedback" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="反馈音频"><UploadField v-model="form.feedback_audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="序号"><el-input-number v-model="form.sort_order" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { statusTag, statusText } from '../constants'
|
||||
import UploadField from '../components/UploadField.vue'
|
||||
import request from '../request'
|
||||
|
||||
const EDIT_KEYS = ['id', 'node_id', 'text', 'prop_id', 'audio', 'next_node_id', 'feedback', 'feedback_audio', 'sort_order']
|
||||
const strategies = ref([])
|
||||
const levels = ref([])
|
||||
const nodes = ref([])
|
||||
const filterStrategy = ref(0)
|
||||
const filterLevel = ref(0)
|
||||
const filterNode = ref(0)
|
||||
const crud = useCrud('/node-option')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
|
||||
const onStrategyChange = async () => {
|
||||
filterLevel.value = 0
|
||||
filterNode.value = 0
|
||||
levels.value = []
|
||||
nodes.value = []
|
||||
const d = await request.get('/level/list', { params: { strategy_id: filterStrategy.value } })
|
||||
levels.value = (d && d.list) || []
|
||||
if (levels.value.length) {
|
||||
filterLevel.value = levels.value[0].id
|
||||
await onLevelChange()
|
||||
}
|
||||
}
|
||||
|
||||
const onLevelChange = async () => {
|
||||
filterNode.value = 0
|
||||
nodes.value = []
|
||||
const d = await request.get('/scene-node/list', { params: { level_id: filterLevel.value } })
|
||||
nodes.value = (d && d.list) || []
|
||||
if (nodes.value.length) {
|
||||
filterNode.value = nodes.value[0].id
|
||||
load({ node_id: filterNode.value })
|
||||
}
|
||||
}
|
||||
|
||||
request.get('/strategy/list').then((d) => {
|
||||
strategies.value = (d && d.list) || []
|
||||
if (strategies.value.length) {
|
||||
filterStrategy.value = strategies.value[0].id
|
||||
onStrategyChange()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="140" />
|
||||
<el-table-column label="头像" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-avatar v-if="row.avatar" :src="row.avatar" :size="40" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="child_count" label="孩子数" width="80" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="注册时间" width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import request from '../request'
|
||||
import { statusTag, statusText } from '../constants'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await request.get('/parent/list')
|
||||
list.value = (d && d.list) || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
load()
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-button type="primary" @click="openCreate({ p_type: 1, points_cost: 10, stock: -1, sort_order: 1 })">新增奖品</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.p_type === 1 ? 'primary' : 'warning'" size="small">{{ pTypeMap[row.p_type] }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="points_cost" label="所需积分" width="90" />
|
||||
<el-table-column label="库存" width="80">
|
||||
<template #default="{ row }">{{ row.stock < 0 ? '不限量' : row.stock }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.icon" :src="row.icon" style="width: 40px; height: 40px" fit="cover" :preview-src-list="[row.icon]" preview-teleported />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="sort_order" label="序号" width="70" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, EDIT_KEYS)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑奖品' : '新增奖品'" width="560px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="类型" required>
|
||||
<el-radio-group v-model="form.p_type">
|
||||
<el-radio :value="1">虚拟</el-radio>
|
||||
<el-radio :value="2">实物</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="所需积分" required><el-input-number v-model="form.points_cost" :min="1" /></el-form-item>
|
||||
<el-form-item label="库存"><el-input-number v-model="form.stock" :min="-1" />(-1 = 不限量)</el-form-item>
|
||||
<el-form-item label="图标"><UploadField v-model="form.icon" kind="image" label="图标" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="序号"><el-input-number v-model="form.sort_order" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { pTypeMap, statusTag, statusText } from '../constants'
|
||||
import UploadField from '../components/UploadField.vue'
|
||||
|
||||
const EDIT_KEYS = ['id', 'name', 'description', 'icon', 'p_type', 'points_cost', 'stock', 'sort_order']
|
||||
const crud = useCrud('/prize')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
load()
|
||||
</script>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterStatus" style="width: 130px" @change="load()">
|
||||
<el-option label="全部" :value="0" />
|
||||
<el-option v-for="(v, k) in redeemStatusMap" :key="k" :label="v" :value="Number(k)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="load">刷新</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="child_nickname" label="孩子" min-width="110" />
|
||||
<el-table-column prop="prize_name" label="奖品" min-width="130" />
|
||||
<el-table-column prop="points_cost" label="积分" width="70" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="redeemTag(row.status)" size="small">{{ redeemStatusMap[row.status] }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" label="兑换码" min-width="150" />
|
||||
<el-table-column prop="created_at" label="兑换时间" width="170" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 1 || row.status === 2" link type="primary" @click="openShip(row)">发货</el-button>
|
||||
<el-button v-if="row.status === 3" link type="success" @click="receive(row)">确认领取</el-button>
|
||||
<el-button v-if="row.status === 1 || row.status === 2" link type="danger" @click="cancel(row)">取消(退积分)</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="shipVisible" title="兑换发货" width="480px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="兑换码">
|
||||
<el-input v-model="shipCode" placeholder="留空自动生成(36ZH- + 8位大写随机)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="shipVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="ship">确认发货</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '../request'
|
||||
import { redeemStatusMap } from '../constants'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const filterStatus = ref(0)
|
||||
const shipVisible = ref(false)
|
||||
const shipId = ref(0)
|
||||
const shipCode = ref('')
|
||||
|
||||
const redeemTag = (s) => ({ 1: 'primary', 2: 'warning', 3: 'success', 4: 'info', 5: '' })[s] || 'info'
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await request.get('/redemption/list', { params: filterStatus.value ? { status: filterStatus.value } : {} })
|
||||
list.value = (d && d.list) || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
load()
|
||||
|
||||
const openShip = (row) => {
|
||||
shipId.value = row.id
|
||||
shipCode.value = ''
|
||||
shipVisible.value = true
|
||||
}
|
||||
|
||||
const ship = async () => {
|
||||
await request.post('/redemption/ship', { id: shipId.value, code: shipCode.value })
|
||||
ElMessage.success('发货成功')
|
||||
shipVisible.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
const receive = async (row) => {
|
||||
await request.post('/redemption/receive', { id: row.id })
|
||||
ElMessage.success('已确认领取')
|
||||
load()
|
||||
}
|
||||
|
||||
const cancel = async (row) => {
|
||||
await ElMessageBox.confirm(`确认取消该兑换?积分 ${row.points_cost} 将退回孩子账户`, '提示', { type: 'warning' })
|
||||
await request.post('/redemption/cancel', { id: row.id })
|
||||
ElMessage.success('已取消,积分已退回')
|
||||
load()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-form-item label="计策">
|
||||
<el-select v-model="filterStrategy" style="width: 180px" @change="onStrategyChange">
|
||||
<el-option v-for="s in strategies" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关卡">
|
||||
<el-select v-model="filterLevel" style="width: 200px" @change="load({ level_id: filterLevel })">
|
||||
<el-option v-for="l in levels" :key="l.id" :label="`${l.title} (#${l.id})`" :value="l.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :disabled="!filterLevel" @click="openCreate({ level_id: filterLevel, node_type: 1, sort_order: 1 })">新增节点</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="类型" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.node_type === 1 ? 'primary' : 'warning'" size="small">{{ nodeTypeMap[row.node_type] }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="120" />
|
||||
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="互动" width="110">
|
||||
<template #default="{ row }">{{ interactionMap[row.interaction_type] || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="终局评级" width="90">
|
||||
<template #default="{ row }">{{ resultMap[row.result_type] }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="character_name" label="角色" width="80" />
|
||||
<el-table-column label="入口" width="60">
|
||||
<template #default="{ row }">{{ row.is_entry === 1 ? '是' : '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="option_count" label="选项数" width="80" />
|
||||
<el-table-column prop="sort_order" label="序号" width="70" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, EDIT_KEYS)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑节点' : '新增节点'" width="680px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="内容" required><el-input v-model="form.content" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="节点类型" required>
|
||||
<el-radio-group v-model="form.node_type">
|
||||
<el-radio :value="1">决策</el-radio>
|
||||
<el-radio :value="2">终局</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="互动形态">
|
||||
<el-select v-model="form.interaction_type" style="width: 100%" :disabled="form.node_type === 2">
|
||||
<el-option v-for="(v, k) in interactionMap" :key="k" :label="v" :value="Number(k)" />
|
||||
<el-option label="无" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="终局评级">
|
||||
<el-select v-model="form.result_type" style="width: 100%" :disabled="form.node_type === 1">
|
||||
<el-option v-for="(v, k) in resultMap" :key="k" :label="v" :value="Number(k)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色ID"><el-input-number v-model="form.character_id" :min="0" /></el-form-item>
|
||||
<el-form-item label="入口节点">
|
||||
<el-radio-group v-model="form.is_entry">
|
||||
<el-radio :value="0">否</el-radio>
|
||||
<el-radio :value="1">是</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片"><UploadField v-model="form.image" kind="image" label="图片" /></el-form-item>
|
||||
<el-form-item label="音频"><UploadField v-model="form.audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="配置"><el-input v-model="form.config" type="textarea" :rows="2" placeholder="动作过关等子模式配置,JSON 或文本" /></el-form-item>
|
||||
<el-form-item label="剧本"><el-input v-model="form.script" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="序号"><el-input-number v-model="form.sort_order" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { nodeTypeMap, interactionMap, resultMap, statusTag, statusText } from '../constants'
|
||||
import UploadField from '../components/UploadField.vue'
|
||||
import request from '../request'
|
||||
|
||||
const EDIT_KEYS = ['id', 'level_id', 'title', 'character_id', 'content', 'image', 'audio', 'node_type', 'interaction_type', 'config', 'script', 'result_type', 'is_entry', 'sort_order']
|
||||
const strategies = ref([])
|
||||
const levels = ref([])
|
||||
const filterStrategy = ref(0)
|
||||
const filterLevel = ref(0)
|
||||
const crud = useCrud('/scene-node')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
|
||||
const onStrategyChange = async () => {
|
||||
filterLevel.value = 0
|
||||
levels.value = []
|
||||
const d = await request.get('/level/list', { params: { strategy_id: filterStrategy.value } })
|
||||
levels.value = (d && d.list) || []
|
||||
if (levels.value.length) {
|
||||
filterLevel.value = levels.value[0].id
|
||||
load({ level_id: filterLevel.value })
|
||||
}
|
||||
}
|
||||
|
||||
request.get('/strategy/list').then((d) => {
|
||||
strategies.value = (d && d.list) || []
|
||||
if (strategies.value.length) {
|
||||
filterStrategy.value = strategies.value[0].id
|
||||
onStrategyChange()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-form-item label="计策">
|
||||
<el-select v-model="filterStrategy" style="width: 180px" @change="onStrategyChange">
|
||||
<el-option v-for="s in strategies" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关卡">
|
||||
<el-select v-model="filterLevel" style="width: 220px" @change="loadStats">
|
||||
<el-option v-for="l in levels" :key="l.id" :label="`${l.title} (#${l.id})`" :value="l.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template v-if="stats">
|
||||
<el-row :gutter="16" style="margin-bottom: 16px">
|
||||
<el-col :span="8">
|
||||
<el-card><el-statistic title="参与孩子数" :value="stats.players" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card><el-statistic title="完美通关数" :value="stats.perfect_count" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card><el-statistic title="完美率" :value="stats.perfect_rate" suffix="%" /></el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card style="margin-bottom: 16px">
|
||||
<template #header>节点统计</template>
|
||||
<el-table :data="stats.nodes" border size="small" max-height="360">
|
||||
<el-table-column prop="node_id" label="节点ID" width="80" />
|
||||
<el-table-column label="类型" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.node_type === 1 ? 'primary' : 'warning'" size="small">{{ nodeTypeMap[row.node_type] }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="reach_count" label="到达次数" width="90" />
|
||||
<el-table-column label="离开(非终局)" width="110">
|
||||
<template #default="{ row }">{{ row.none }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="失败终局" width="90">
|
||||
<template #default="{ row }"><span style="color: #f56c6c">{{ row.fail }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="良好终局" width="90">
|
||||
<template #default="{ row }"><span style="color: #e6a23c">{{ row.good }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最佳终局" width="90">
|
||||
<template #default="{ row }"><span style="color: #67c23a">{{ row.best }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header>选项统计</template>
|
||||
<el-table :data="stats.options" border size="small" max-height="360">
|
||||
<el-table-column prop="node_id" label="节点ID" width="80" />
|
||||
<el-table-column prop="option_id" label="选项ID" width="80" />
|
||||
<el-table-column prop="text" label="选项" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="choose_count" label="选择次数" width="90" />
|
||||
<el-table-column label="失败终局" width="90">
|
||||
<template #default="{ row }"><span style="color: #f56c6c">{{ row.fail }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="良好终局" width="90">
|
||||
<template #default="{ row }"><span style="color: #e6a23c">{{ row.good }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最佳终局" width="90">
|
||||
<template #default="{ row }"><span style="color: #67c23a">{{ row.best }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
<el-empty v-else description="请选择关卡查看统计" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import request from '../request'
|
||||
import { nodeTypeMap } from '../constants'
|
||||
|
||||
const strategies = ref([])
|
||||
const levels = ref([])
|
||||
const filterStrategy = ref(0)
|
||||
const filterLevel = ref(0)
|
||||
const stats = ref(null)
|
||||
|
||||
const onStrategyChange = async () => {
|
||||
filterLevel.value = 0
|
||||
stats.value = null
|
||||
levels.value = []
|
||||
const d = await request.get('/level/list', { params: { strategy_id: filterStrategy.value } })
|
||||
levels.value = (d && d.list) || []
|
||||
if (levels.value.length) {
|
||||
filterLevel.value = levels.value[0].id
|
||||
loadStats()
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
stats.value = await request.get('/stats/level', { params: { level_id: filterLevel.value } })
|
||||
}
|
||||
|
||||
request.get('/strategy/list').then((d) => {
|
||||
strategies.value = (d && d.list) || []
|
||||
if (strategies.value.length) {
|
||||
filterStrategy.value = strategies.value[0].id
|
||||
onStrategyChange()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form inline>
|
||||
<el-button type="primary" @click="openCreate({ group_no: 1, sort_order: 1 })">新增计策</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" width="140" />
|
||||
<el-table-column prop="pinyin" label="拼音" min-width="150" />
|
||||
<el-table-column prop="group_no" label="组" width="60" />
|
||||
<el-table-column prop="group_name" label="组名" width="100" />
|
||||
<el-table-column prop="meaning" label="释义" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="unlock_before" label="前置" width="70" />
|
||||
<el-table-column prop="level_count" label="关卡数" width="80" />
|
||||
<el-table-column prop="sort_order" label="序号" width="70" />
|
||||
<el-table-column label="状态" width="70">
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)" size="small">{{ statusText(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row, EDIT_KEYS)">编辑</el-button>
|
||||
<el-button link :type="row.status === 1 ? 'danger' : 'success'" @click="toggleStatus(row)">{{ row.status === 1 ? '下架' : '上架' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑计策' : '新增计策'" width="680px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="分组编号" required><el-input-number v-model="form.group_no" :min="1" :max="6" /></el-form-item>
|
||||
<el-form-item label="组名"><el-input v-model="form.group_name" /></el-form-item>
|
||||
<el-form-item label="释义"><el-input v-model="form.meaning" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="教学内容"><el-input v-model="form.teach_content" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="教学图"><UploadField v-model="form.teach_image" kind="image" label="教学图" /></el-form-item>
|
||||
<el-form-item label="教学音频"><UploadField v-model="form.teach_audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="总结问题"><el-input v-model="form.summary_q" /></el-form-item>
|
||||
<el-form-item label="总结选项"><el-input v-model="form.summary_options" placeholder="多个选项用 | 分隔" /></el-form-item>
|
||||
<el-form-item label="总结音频"><UploadField v-model="form.summary_audio" kind="audio" label="音频" /></el-form-item>
|
||||
<el-form-item label="图标"><UploadField v-model="form.icon" kind="image" label="图标" /></el-form-item>
|
||||
<el-form-item label="解锁前置"><el-input-number v-model="form.unlock_before" :min="0" /></el-form-item>
|
||||
<el-form-item label="序号"><el-input-number v-model="form.sort_order" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useCrud } from '../composables/useCrud'
|
||||
import { statusTag, statusText } from '../constants'
|
||||
import UploadField from '../components/UploadField.vue'
|
||||
|
||||
const EDIT_KEYS = ['id', 'name', 'group_no', 'group_name', 'meaning', 'teach_content', 'teach_image', 'teach_audio', 'summary_q', 'summary_options', 'summary_audio', 'icon', 'sort_order', 'unlock_before']
|
||||
const crud = useCrud('/strategy')
|
||||
const { list, loading, dialogVisible, isEdit, form, load, openCreate, openEdit, save, toggleStatus } = crud
|
||||
load()
|
||||
</script>
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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 位大写随机
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+15
-47
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+34
-91
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+10
-10
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+26
-57
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 // 结算前余额
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
|
||||
+84
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+194
-50
@@ -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
|
||||
}
|
||||
|
||||
+94
-5
@@ -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
|
||||
}
|
||||
|
||||
+431
-242
@@ -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
|
||||
}
|
||||
|
||||
+11
-10
@@ -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)
|
||||
|
||||
+100
-10
@@ -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
|
||||
}
|
||||
|
||||
+248
-19
@@ -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
|
||||
}
|
||||
|
||||
+61
-23
@@ -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
|
||||
}
|
||||
|
||||
+104
-10
@@ -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
|
||||
}
|
||||
|
||||
+140
-12
@@ -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
|
||||
}
|
||||
|
||||
+222
-15
@@ -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
|
||||
}
|
||||
|
||||
+243
-167
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+43
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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:<table>@` 前缀过滤 → 批量删,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. 开发计划
|
||||
|
||||
| 里程碑 | 内容 | 验收标准 |
|
||||
|
||||
Reference in New Issue
Block a user