787 lines
28 KiB
Markdown
787 lines
28 KiB
Markdown
# M1 核心闯关实现计划(三十六计小课堂)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 完成 M1:Go+GoFrame 后端(19 张表、种子数据、家长/孩子体系、内容接口、分支闯关判分结算)+ uni-app 前端骨架,H5 端走通「家长注册 → 建孩子档案 → 计策学堂 → 分支闯关 → 完美解锁」全流程。
|
||
|
||
**Architecture:** 严格按 CLAUDE.md 分层 controller → service → dao;路由由 dto `g.Meta` 声明;种子数据 `go:embed` JSON 启动导入;判分/结算核心逻辑在 service 层纯函数化以便单测。
|
||
|
||
**Tech Stack:** Go 1.22+ / GoFrame v2 / SQLite(glebarez/sqlite 或 go-sqlite3)/ uni-app Vue3(H5 先行)。
|
||
|
||
**Spec 依据:** `技术设计.md`(DDL 与规则权威来源)、`README.md`(API 清单)。
|
||
|
||
---
|
||
|
||
## 文件结构(M1 创建)
|
||
|
||
```
|
||
go.mod, main.go, config.yml
|
||
common/base_dao.go, common/auth/auth.go
|
||
biz/consts/table_name.go, biz/consts/status.go, biz/consts/consts.go
|
||
biz/model/entity/(19 张表,每表一文件)
|
||
biz/model/dto/(parent.go, child.go, strategy.go, level.go, points.go, prize.go, common.go)
|
||
biz/dao/(19 个 dao 文件 + base 建表)
|
||
biz/service/seed/seed.go, seed/seed_36_ji.json(embed)
|
||
biz/service/(auth.go, parent.go, child.go, strategy.go, level.go, points.go, level_play.go)
|
||
biz/controller/(router.go, parent.go, child.go, strategy.go, level.go, points.go, prize.go)
|
||
ui-src/(uni-app Vue3 骨架:pages.json, manifest.json, main.js, App.vue, pages/, api/)
|
||
data/, workspace/(运行时,gitignore)
|
||
```
|
||
|
||
19 张表:parent, child, strategy, level, scene_node, node_option, element, user_progress, chapter_review, user_route_log, user_collection, badge, user_badge, point_log, prize, redemption, admin_user, life_task, life_task_log。字段定义以技术设计.md 第 3 节 DDL 为准,本计划不重复。
|
||
|
||
---
|
||
|
||
### Task 1: Go 工程骨架
|
||
|
||
**Files:**
|
||
- Create: `go.mod`, `config.yml`, `main.go`, `common/base_dao.go`, `.gitignore`(更新), `data/.gitkeep`, `workspace/.gitkeep`
|
||
|
||
- [ ] **Step 1: go.mod + 依赖**
|
||
|
||
```bash
|
||
go mod init 36wisdom
|
||
go get github.com/gogf/gf/v2@latest
|
||
go get github.com/glebarez/sqlite@latest
|
||
```
|
||
|
||
- [ ] **Step 2: config.yml**
|
||
|
||
```yaml
|
||
server:
|
||
address: ":8080"
|
||
openapiPath: "/api.json"
|
||
swaggerPath: "/swagger"
|
||
logger:
|
||
level: "info"
|
||
database:
|
||
type: "sqlite"
|
||
link: "data/36wisdom.db"
|
||
cache:
|
||
ttl: 300
|
||
maxSize: 10000
|
||
pool:
|
||
defaultSize: 10
|
||
```
|
||
|
||
- [ ] **Step 3: common/base_dao.go**(DAO 基类,所有 dao 嵌入)
|
||
|
||
```go
|
||
package common
|
||
|
||
import (
|
||
"context"
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
)
|
||
|
||
type BaseDao struct{ Table string }
|
||
|
||
func (d *BaseDao) Model() *gdb.Model { return g.DB().Model(d.Table) }
|
||
|
||
func (d *BaseDao) InsertAndReturnId(ctx context.Context, data gdb.Record) (int64, error) {
|
||
res, err := d.Model().Ctx(ctx).Data(data).Insert()
|
||
if err != nil {
|
||
return 0, 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 gdb.Record) error {
|
||
_, err := d.Model().Ctx(ctx).WherePri(pk).Data(data).Update()
|
||
return err
|
||
}
|
||
|
||
func (d *BaseDao) DeleteByPk(ctx context.Context, pk int64) error {
|
||
_, err := d.Model().Ctx(ctx).WherePri(pk).Delete()
|
||
return err
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: main.go**(GoFrame 引导 + SQLite 初始化 + 数据目录)
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
_ "github.com/glebarez/sqlite"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gctx"
|
||
"os"
|
||
"36wisdom/biz/controller"
|
||
)
|
||
|
||
func main() {
|
||
var ctx context.Context = gctx.New()
|
||
for _, dir := range []string{"data", "workspace"} {
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
g.Log().Fatal(ctx, err)
|
||
}
|
||
}
|
||
s := g.Server()
|
||
controller.Register(s)
|
||
s.Run()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 更新 .gitignore** 追加 `data/`、`workspace/`、`ui-src/dist/`、`admin-src/dist/`
|
||
|
||
- [ ] **Step 6: 编译验证**
|
||
|
||
```bash
|
||
go build ./...
|
||
```
|
||
|
||
Expected: 无输出(成功)。
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add go.mod go.sum config.yml main.go common/ .gitignore data/.gitkeep workspace/.gitkeep
|
||
git commit -m "feat: go 工程骨架(GoFrame + SQLite 引导)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 常量集中(biz/consts)
|
||
|
||
**Files:**
|
||
- Create: `biz/consts/table_name.go`, `biz/consts/status.go`, `biz/consts/consts.go`
|
||
|
||
- [ ] **Step 1: table_name.go**(19 张表名常量,含注释用途)
|
||
|
||
```go
|
||
package consts
|
||
|
||
const (
|
||
TableParent = "parent"
|
||
TableChild = "child"
|
||
TableStrategy = "strategy"
|
||
TableLevel = "level"
|
||
TableSceneNode = "scene_node"
|
||
TableNodeOption = "node_option"
|
||
TableElement = "element"
|
||
TableUserProgress = "user_progress"
|
||
TableChapterReview = "chapter_review"
|
||
TableUserRouteLog = "user_route_log"
|
||
TableUserCollection = "user_collection"
|
||
TableBadge = "badge"
|
||
TableUserBadge = "user_badge"
|
||
TablePointLog = "point_log"
|
||
TablePrize = "prize"
|
||
TableRedemption = "redemption"
|
||
TableAdminUser = "admin_user"
|
||
TableLifeTask = "life_task"
|
||
TableLifeTaskLog = "life_task_log"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: status.go**(状态常量:内容状态 / 节点 / 终局评级 / 互动形态 / 徽章条件 / 积分原因 / 兑换状态 / 任务状态)
|
||
|
||
```go
|
||
package consts
|
||
|
||
// 内容通用状态
|
||
const (
|
||
StatusEnabled = 1
|
||
StatusDisabled = 0
|
||
)
|
||
|
||
// 节点类型
|
||
const (
|
||
NodeDecision = 1
|
||
NodeFinal = 2
|
||
)
|
||
|
||
// 终局评级
|
||
const (
|
||
ResultNone = 0
|
||
ResultFail = 1
|
||
ResultGood = 2
|
||
ResultBest = 3
|
||
)
|
||
|
||
// 互动形态
|
||
const (
|
||
InteractionOption = 1 // 选项选择
|
||
InteractionProp = 2 // 道具选择
|
||
InteractionOrder = 3 // 步骤排序
|
||
InteractionAction = 4 // 动作过关(跳跃/攀爬/躲避/奔跑,config 配子模式)
|
||
InteractionDrag = 5 // 拖拽放置
|
||
InteractionCatch = 6 // 接取收集
|
||
InteractionFind = 7 // 找线索
|
||
InteractionConnect = 8 // 连线配对
|
||
)
|
||
|
||
// 徽章条件类型
|
||
const (
|
||
BadgeCollect = 1 // 集卡数
|
||
BadgeSignIn = 2 // 连续签到
|
||
BadgeClear = 3 // 通关数
|
||
BadgePoints = 4 // 积分
|
||
BadgePerfect = 5 // 完美关卡数
|
||
BadgeReview = 6 // 复习完成数
|
||
)
|
||
|
||
// 积分原因
|
||
const (
|
||
ReasonLevel = 1 // 闯关结算
|
||
ReasonSignIn = 2 // 每日签到
|
||
ReasonRedeem = 3 // 兑换扣减
|
||
ReasonAdmin = 4 // 管理调整
|
||
ReasonReview = 5 // 章末温故
|
||
ReasonSummary = 6 // 智慧总结
|
||
ReasonTask = 7 // 生活任务
|
||
)
|
||
|
||
// 兑换状态
|
||
const (
|
||
RedeemPending = 1 // 待领取(虚拟)
|
||
RedeemWaiting = 2 // 待发货(实物)
|
||
RedeemShipped = 3 // 已发货
|
||
RedeemCanceled = 4 // 已取消
|
||
RedeemReceived = 5 // 已领取(家长确认)
|
||
)
|
||
|
||
// 生活任务状态
|
||
const (
|
||
TaskIssued = 1 // 已下发
|
||
TaskConfirming = 2 // 待家长确认
|
||
TaskConfirmed = 3 // 已确认
|
||
)
|
||
|
||
// 年龄段档位
|
||
const (
|
||
AgeGroup4_6 = "4-6"
|
||
AgeGroup6_8 = "6-8"
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: consts.go**(默认参数:教训分、积分档位、温故关卡数)
|
||
|
||
```go
|
||
package consts
|
||
|
||
const (
|
||
PointsBest = 30
|
||
PointsGood = 10
|
||
PointsFail = -10
|
||
PointsPerfect = 20
|
||
PointsReview = 10
|
||
PointsSummary = 5
|
||
PointsSignIn = 5
|
||
PointsTask = 20
|
||
|
||
FailNoDeductAfter = 2 // 同一关连续失败 N 次后不再扣分
|
||
ReviewLevelCount = 3 // 章末温故抽取计谋数(2-3,取 3 且不超过已学)
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: 编译 + 提交**
|
||
|
||
```bash
|
||
go build ./... && git add biz/consts/ && git commit -m "feat: 常量集中(表名/状态/默认参数)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: entity(19 张表映射)
|
||
|
||
**Files:**
|
||
- Create: `biz/model/entity/*.go`(19 文件,命名 `entity_parent.go` 等,按表名)
|
||
|
||
- [ ] **Step 1: 每表一个 entity 文件**。模式(以 parent 为例):
|
||
|
||
```go
|
||
package entity
|
||
|
||
import "github.com/gogf/gf/v2/os/gtime"
|
||
|
||
type Parent struct {
|
||
Id int64 `json:"id" orm:"id"`
|
||
Openid string `json:"openid" orm:"openid"`
|
||
Phone string `json:"phone" orm:"phone"`
|
||
Password string `json:"-" orm:"password"`
|
||
Nickname string `json:"nickname" orm:"nickname"`
|
||
Avatar string `json:"avatar" orm:"avatar"`
|
||
Status int `json:"status" orm:"status"`
|
||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
|
||
}
|
||
```
|
||
|
||
字段列表严格对应技术设计.md 第 3 节 DDL(含 2026-08-13 迭代新增字段:strategy.teach_content/summary_*、child.daily_limit_minutes、level.content_version、scene_node.interaction_type/config、node_option.prop_id、user_progress.perfect、redemption 状态 5)。password 字段 json 序列化排除。
|
||
|
||
- [ ] **Step 2: 编译**
|
||
|
||
```bash
|
||
go build ./...
|
||
```
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add biz/model/entity/ && git commit -m "feat: 19 张表 entity 映射"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: DAO 层 + 建表(19 个 dao 文件)
|
||
|
||
**Files:**
|
||
- Create: `biz/dao/*.go`(19 文件,命名 `dao_parent.go` 等)
|
||
|
||
- [ ] **Step 1: 每表一个 dao 文件**,模式(以 parent 为例):
|
||
|
||
```go
|
||
package dao
|
||
|
||
import (
|
||
"context"
|
||
"36wisdom/biz/consts"
|
||
"36wisdom/common"
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
)
|
||
|
||
type parentDao struct{ common.BaseDao }
|
||
|
||
var Parent = &parentDao{BaseDao: common.BaseDao{Table: consts.TableParent}}
|
||
|
||
func (d *parentDao) Init(ctx context.Context) {
|
||
_, err := d.Model().Ctx(ctx).Fields(`...`).Create()
|
||
...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: dao 初始化统一入口 `biz/dao/init.go`**:`func InitTables(ctx)` 依序调用 19 个 dao 的建表(`CREATE TABLE IF NOT EXISTS`,DDL 与索引照技术设计.md 第 3 节),strategy/level/scene_node/node_option/element 建表后执行无环校验(拓扑检测 scene_node 图,有环则日志 Fatal)。
|
||
|
||
- [ ] **Step 3: 启动接线**:main.go 的 g.Run 前调用 `dao.InitTables(ctx)`(用 `s.BindHandler("GET /ping", ...)` 先验证服务可用)。
|
||
|
||
- [ ] **Step 4: 编译 + 冒烟启动**
|
||
|
||
```bash
|
||
go build ./... && go run main.go &
|
||
curl -s http://localhost:8080/ping
|
||
```
|
||
|
||
Expected: `pong`;`data/36wisdom.db` 生成。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add biz/dao/ && git commit -m "feat: 19 张表 DAO 与建表初始化"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 种子数据(元素库 + 36 计决策树 + 奖品 + 徽章 + 管理员)
|
||
|
||
**Files:**
|
||
- Create: `biz/service/seed/seed.go`, `biz/service/seed/seed_36_ji.json`
|
||
- Modify: `main.go`(启动调用 EnsureSeeded)
|
||
|
||
- [ ] **Step 1: seed_36_ji.json 结构**(go:embed,数组式按依赖序:elements → strategies → levels → nodes → options → prizes → badges → admin)
|
||
|
||
```json
|
||
{
|
||
"elements": [
|
||
{"e_type": 1, "name": "操场", "description": "孩子们游戏的地方"},
|
||
{"e_type": 2, "name": "小明", "description": "爱踢足球的小男孩"},
|
||
{"e_type": 3, "name": "足球", "description": "圆圆的皮球"}
|
||
],
|
||
"strategies": [
|
||
{
|
||
"name": "声东击西",
|
||
"pinyin": "shēng dōng jī xī",
|
||
"group_no": 1, "group_name": "胜战计",
|
||
"meaning": "假装要往东边去,其实是要往西边",
|
||
"teach_content": "小明想从右边突破,但他先假装往左边跑……",
|
||
"summary_q": "哪个说法总结了「声东击西」?",
|
||
"summary_options": "[{\"text\":\"假装往东其实往西\",\"correct\":true},{\"text\":\"一直往东跑\"},{\"text\":\"站在中间不动\"}]",
|
||
"sort_order": 1,
|
||
"unlock_before": null,
|
||
"levels": [
|
||
{
|
||
"title": "足球场上的假动作", "scene_name": "操场", "age_group": "4-6",
|
||
"scene_content": "比赛最后一分钟,小明带着球冲向球门……",
|
||
"nodes": [
|
||
{"title": "被挡住了", "character_name": "小明", "content": "大个子队员挡在面前,硬冲会被抢走球,怎么办?",
|
||
"interaction_type": 1, "is_entry": 1, "options": [
|
||
{"text": "假装向右跑,骗他转身", "prop_name": "足球", "next_index": 1, "feedback": "好主意!他向右追,你就从左边冲过去"},
|
||
{"text": "硬冲过去", "next_index": 2, "feedback": "硬冲被拦住了,球丢了……"}
|
||
]},
|
||
{"content": "成功了!趁防守转身,你从左边突破射门!", "node_type": 2, "result_type": 3},
|
||
{"content": "球被抢走了……再试一次,换种办法!", "node_type": 2, "result_type": 1}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"prizes": [
|
||
{"name": "小军师徽章", "p_type": 1, "points_cost": 50, "stock": -1},
|
||
{"name": "计策卡册", "p_type": 2, "points_cost": 200, "stock": 10}
|
||
],
|
||
"badges": [
|
||
{"name": "初出茅庐", "cond_type": 3, "cond_value": 1},
|
||
{"name": "小将军", "cond_type": 3, "cond_value": 10}
|
||
],
|
||
"admin": {"username": "admin", "password": "admin123"}
|
||
}
|
||
```
|
||
|
||
节点引用用 `next_index`(数组下标)+ 后台导入时解析为 next_node_id;`node_type` 缺省=1 决策节点;元素通过 `scene_name`/`character_name`/`prop_name` 引用元素库。
|
||
|
||
- [ ] **Step 2: 内容创作要求**:M1 必须内置**全部 36 计**(胜战计/敌战计/攻战计/混战计/并战计/败战计各 6 计),每计至少 1 个情境关卡、每关 5-8 节点(≥2 个决策节点、≥2 个终局,至少含 1 个最佳终局与 1 个非最佳终局),现代场景(操场/超市/公园/家庭/教室/游乐场…),内容遵循技术设计.md「决策树骨架」范式。剩余章节的补充情境(每计 2-3 关)M2 由后台/种子迭代补入。
|
||
|
||
- [ ] **Step 3: seed.go 实现 EnsureSeeded(ctx)**:查 strategy 表非空即跳过;空则事务内按依赖序插入(元素先入库取 id,按 name 索引;节点 `next_index` 转为 `next_node_id`);管理员密码 bcrypt 哈希;全程包事务,失败回滚并日志 Fatal。
|
||
|
||
- [ ] **Step 4: main.go 接线**:`dao.InitTables(ctx)` 后调用 `seed.EnsureSeeded(ctx)`。
|
||
|
||
- [ ] **Step 5: 冒烟验证**
|
||
|
||
```bash
|
||
rm -f data/36wisdom.db && go run main.go &
|
||
sqlite3 data/36wisdom.db "SELECT count(*) FROM strategy; SELECT count(*) FROM scene_node;"
|
||
```
|
||
|
||
Expected: `36`、节点总数 ≥180。
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add biz/service/seed/ main.go && git commit -m "feat: 种子数据(36 计决策树/元素库/奖品/徽章/管理员)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 鉴权中间件(家长 token)
|
||
|
||
**Files:**
|
||
- Create: `common/auth/auth.go`, `biz/service/auth.go`, `biz/controller/router.go`(骨架)
|
||
- Modify: `main.go`
|
||
|
||
- [ ] **Step 1: token 生成与校验**(自签 HS256 JWT,密钥在 config.yml `auth.secret`;依赖 `github.com/golang-jwt/jwt/v5`)
|
||
|
||
```go
|
||
// common/auth/auth.go
|
||
package auth
|
||
|
||
func GenerateToken(secret string, uid int64, role string, expireSeconds int) (string, error)
|
||
func ParseToken(secret, tokenString string) (uid int64, role string, err error)
|
||
```
|
||
|
||
- [ ] **Step 2: 中间件**:`common/auth.Middleware(secret)` 返回 gin 风格 HandlerFunc;从 `Authorization: Bearer <token>` 解析,注入 gctx 键 `authUid`/`authRole`;role=admin 的接口额外校验 role。config.yml 加 `auth: { secret: "...", expire: 604800 }`。
|
||
|
||
- [ ] **Step 3: router.go** 注册分组:`/api`(家长鉴权组)、`/api/admin`(admin 鉴权组,M1 先注册登录接口骨架)、公开 `POST /api/parent/register` `POST /api/parent/login`。GoFrame v2 中控制器注册用 `s.Group("/api", middleware, func(g *ghttp.RouterGroup){ g.Bind(controller.Parent) })`。
|
||
|
||
- [ ] **Step 4: 编译 + 提交**
|
||
|
||
```bash
|
||
go build ./... && git add common/auth/ biz/service/auth.go biz/controller/ config.yml main.go && git commit -m "feat: 家长 token 鉴权中间件与路由分组"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 家长/孩子接口
|
||
|
||
**Files:**
|
||
- Create: `biz/model/dto/parent.go`, `biz/model/dto/child.go`, `biz/service/parent.go`, `biz/service/child.go`, `biz/controller/parent.go`, `biz/controller/child.go`
|
||
|
||
- [ ] **Step 1: dto**(g.Meta 声明路由,v tag 校验):
|
||
|
||
```go
|
||
type RegisterReq struct {
|
||
g.Meta `path:"/api/parent/register" method:"post" summary:"家长注册"`
|
||
Phone string `v:"required|length:11,11|regex:^1[3-9][0-9]{9}$"`
|
||
Password string `v:"required|length:6,32"`
|
||
Nickname string `v:"required|length:1,32"`
|
||
}
|
||
type RegisterRes struct{ Token string; ParentId int64 }
|
||
|
||
type LoginReq struct {
|
||
g.Meta `path:"/api/parent/login" method:"post"`
|
||
Phone string `v:"required"`
|
||
Password string `v:"required"`
|
||
}
|
||
type LoginRes struct{ Token string; ParentId int64 }
|
||
|
||
type ChildCreateReq struct {
|
||
g.Meta `path:"/api/parent/child/create" method:"post"`
|
||
Nickname string `v:"required|length:1,32"`
|
||
AgeGroup string `v:"required|in:4-6,6-8"`
|
||
}
|
||
type ChildCreateRes struct{ ChildId int64 }
|
||
|
||
type ChildUpdateReq struct {
|
||
g.Meta `path:"/api/parent/child/update" method:"post"`
|
||
ChildId int64 `v:"required"`
|
||
Nickname string `v:"length:1,32"`
|
||
AgeGroup string `v:"in:4-6,6-8"`
|
||
DailyLimitMinutes int `v:"min:0|max:600"`
|
||
}
|
||
|
||
type ChildListReq struct {
|
||
g.Meta `path:"/api/parent/child/list" method:"get"`
|
||
}
|
||
type ChildListItem struct {
|
||
ChildId int64; Nickname string; AgeGroup string; Avatar string
|
||
Points int; Level int; DailyLimitMinutes int
|
||
}
|
||
type ChildListRes struct{ List []ChildListItem }
|
||
```
|
||
|
||
- [ ] **Step 2: service**:Register(查重手机号 → 插入 → 生成 token)、Login(校验 bcrypt → token)、ChildCreate(校验 parent 归属自动带 parent_id)、ChildUpdate(校验归属 + 必填项非空校验)、ChildList(含 points)。积分、等级派生(完美关卡数 → 称号映射函数放 `biz/service/level.go`)。
|
||
|
||
- [ ] **Step 3: controller**:方法签名 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,从 ctx 取 authUid 传给 service。
|
||
|
||
- [ ] **Step 4: 冒烟测试**
|
||
|
||
```bash
|
||
curl -s -X POST localhost:8080/api/parent/register -H 'Content-Type: application/json' -d '{"phone":"13800000000","password":"pass123456","nickname":"爸爸"}'
|
||
curl -s -X POST localhost:8080/api/parent/child/create -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"nickname":"小宝","age_group":"4-6"}'
|
||
```
|
||
|
||
Expected: token 与 child_id 正常返回;错误参数返回 400 校验错误。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add biz/model/dto/ biz/service/parent.go biz/service/child.go biz/service/level.go biz/controller/ && git commit -m "feat: 家长注册登录与孩子档案接口"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 内容接口(strategy / level + 缓存)
|
||
|
||
**Files:**
|
||
- Create: `biz/model/dto/strategy.go`, `biz/model/dto/level.go`, `biz/service/strategy.go`, `biz/service/level.go`, `biz/controller/strategy.go`, `biz/controller/level.go`
|
||
|
||
- [ ] **Step 1: dto**
|
||
|
||
```go
|
||
type StrategyListReq struct { g.Meta `path:"/api/strategy/list" method:"get"` }
|
||
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
|
||
}
|
||
type StrategyListRes struct{ List []StrategyItem }
|
||
|
||
type StrategyDetailReq struct { g.Meta `path:"/api/strategy/detail" method:"get"`; StrategyId int64 `v:"required"` }
|
||
type LevelBrief struct { LevelId int64; Title string; AgeGroup string; SceneName string; Stars int; Perfect bool; Unlocked bool; ContentVersion int; ProgressVersion int }
|
||
type StrategyDetailRes struct {
|
||
StrategyId int64; Name string; Pinyin string; Meaning string; GroupName string
|
||
TeachContent string; TeachImage string; TeachAudio string
|
||
SummaryQ string; SummaryOptions string
|
||
Levels []LevelBrief
|
||
}
|
||
|
||
type LevelDetailReq struct { g.Meta `path:"/api/level/detail" method:"get"`; LevelId int64 `v:"required"` }
|
||
type ElementVO struct { Id int64; EType int; Name string; Image string; Audio string; Description string }
|
||
type OptionVO struct { OptionId int64; Text string; Prop *ElementVO }
|
||
type NodeVO struct {
|
||
NodeId int64; Title string; Content string; Image string; Audio string
|
||
Character *ElementVO; InteractionType int; Config string
|
||
NodeType int; ResultType int
|
||
Options []OptionVO
|
||
}
|
||
type LevelDetailRes struct {
|
||
LevelId int64; Title string; Scene *ElementVO; SceneContent string; SceneImage string; SceneAudio string
|
||
Entry NodeVO; TotalFinals int; Perfect bool; Stars int
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: service 聚合规则**(严格单表 SQL + 内存组装,无 JOIN):
|
||
- strategy/list:strategy 全量(缓存,TTL 300s)+ 本用户 user_progress 按 level IN 取星(≤100 分批)→ 按 strategy 分组 → 解锁判断(unlock_before 为空 或 前置计完美关卡数=前置计关卡总数);content_version 不一致的关卡标记"可重新挑战"
|
||
- level/detail:level + scene_node + node_option + element 分表取回内存组装成树(节点拓扑排序按 sort_order),校验入口唯一;返回入口节点 + 终局总数(result_type>0 的节点数)+ 用户 perfect/stars
|
||
- **缓存**:strategy/level 内容缓存(gdb.CacheOption 或 gcache),**写操作(后台)后清对应缓存**——M1 后台未建,种子导入后清一次
|
||
- 年龄段过滤:`4-6` 孩子只看 age_group='4-6' 关卡
|
||
|
||
- [ ] **Step 3: controller** 组装 DTO(entity → DTO 映射在 controller)。
|
||
|
||
- [ ] **Step 4: 冒烟**
|
||
|
||
```bash
|
||
curl -s "localhost:8080/api/strategy/list?child_id=1" -H "Authorization: Bearer $TOKEN"
|
||
curl -s "localhost:8080/api/level/detail?level_id=1" -H "Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
Expected: 36 计列表(首计 unlocked=true,其余 false);关卡返回入口节点与选项。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add biz/model/dto/strategy.go biz/model/dto/level.go biz/service/strategy.go biz/service/level.go biz/controller/strategy.go biz/controller/level.go && git commit -m "feat: 内容接口(计策/关卡/元素,含缓存与解锁判定)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 闯关判分与结算(核心)
|
||
|
||
**Files:**
|
||
- Create: `biz/service/level_play.go`, `biz/controller/level_play.go`, `biz/model/dto/level_play.go`, `biz/service/level_play_test.go`
|
||
|
||
- [ ] **Step 1: dto**
|
||
|
||
```go
|
||
type ChooseReq struct {
|
||
g.Meta `path:"/api/level/choose" method:"post"`
|
||
LevelId int64 `v:"required"`
|
||
NodeId int64 `v:"required"`
|
||
OptionId int64 `v:"required"`
|
||
}
|
||
type ChooseRes struct {
|
||
Next *NodeVO // 下一节点(决策节点)
|
||
Final *FinalSettle // 终局结算(终局节点)
|
||
}
|
||
type FinalSettle struct {
|
||
ResultType int; Stars int; ScoreDelta int
|
||
Cleared bool // 本关是否首次通关(到最佳终局)
|
||
Perfect bool // 是否达成完美
|
||
UnlockNext bool // 是否解锁下一关
|
||
CollectionUnlocked bool // 计策卡解锁
|
||
NewLevel int // 成长等级(升级时非 0)
|
||
BalanceAfter int
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: service 核心逻辑(纯函数可测)**:
|
||
|
||
```go
|
||
// SettleFinal 纯函数:给定历史状态返回结算结果(便于单测)
|
||
type SettleState struct {
|
||
LevelCleared bool // 该关已通关(到过最佳终局)
|
||
FailStreak int // 连续失败次数
|
||
ReachedFinals map[int]bool // 已到达终局 result_type 集合
|
||
TotalFinals int
|
||
BalanceAfter int
|
||
}
|
||
func SettleFinal(s SettleState, resultType int) FinalSettle
|
||
```
|
||
|
||
规则(技术设计 4.3/4.4):
|
||
- 首次到达终局才结算:`ReachedFinals` 含该 result_type 则 delta=0
|
||
- 未通关:Best +30 / Good +10 / Fail -10(连续失败 ≥2 次后不再扣,且余额不为负)
|
||
- 已通关后补分支:delta=0(只记完成度)
|
||
- 最佳终局 → Cleared=true;全部 result_type 到达 → Perfect=true(解锁下一关 +20)
|
||
- 计策卡:本计所有关卡 Perfect 后解锁
|
||
- 成长等级:完美关卡数 → 等级(biz/service/level.go 映射)
|
||
|
||
- [ ] **Step 3: service 流程(Choose)**:
|
||
1. 校验:level 存在、node 属于 level、option 属于 node、关卡已解锁(按解锁链递归校验)
|
||
2. 取 next_node:决策节点 → 返回;终局节点 → 记 user_route_log(node/option/result_type)
|
||
3. 终局结算(事务):结算判定(按上面规则)→ 写 user_progress(取最高星 + perfect + content_version)→ 写 point_log + child.points(delta≠0 时)→ 完美解锁下一关(strategy 内下一关 unlocked 由"前置完美"推导,无需改表)→ 计策卡(user_collection)→ 清用户缓存
|
||
4. 并发防重入:`common.WithLock(ctx, "child:"+childId+":level:"+levelId, 10s, ...)`
|
||
5. 返回 ChooseRes
|
||
|
||
- [ ] **Step 4: 单元测试**(`level_play_test.go`,覆盖结算规则矩阵):
|
||
|
||
```go
|
||
func TestSettleFinal(t *testing.T) {
|
||
// 未通关首次最佳 → +30 Cleared
|
||
// 未通关首次良好 → +10
|
||
// 未通关首次失败 → -10
|
||
// 连续失败第 3 次 → 0
|
||
// 余额不足 → 扣到 0
|
||
// 已通关补分支 → 0
|
||
// 全部终局到达 → Perfect
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试**
|
||
|
||
```bash
|
||
go test ./biz/service/ -run TestSettleFinal -v
|
||
```
|
||
|
||
Expected: PASS。
|
||
|
||
- [ ] **Step 6: 冒烟**(走通一关:首计第一关按最佳路线选 → 得 3 星 +30 分)
|
||
|
||
```bash
|
||
curl -s "localhost:8080/api/level/detail?level_id=1" -H "Authorization: Bearer $TOKEN"
|
||
# 沿入口节点选项选 next_index 为最佳终局的路径逐次 choose
|
||
curl -s -X POST localhost:8080/api/level/choose -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"level_id":1,"node_id":N,"option_id":M}'
|
||
```
|
||
|
||
Expected: 终局返回 Stars=3、ScoreDelta=30。
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add biz/service/level_play.go biz/service/level_play_test.go biz/controller/level_play.go biz/model/dto/level_play.go && git commit -m "feat: 分支闯关判分与终局结算(含单测)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 统一验证
|
||
|
||
- [ ] **Step 1: 全量编译 + 测试**
|
||
|
||
```bash
|
||
go build ./... && go vet ./... && go test ./...
|
||
```
|
||
|
||
- [ ] **Step 2: 冒烟完整流程**(脚本化 curl):注册 → 建孩子 → 列表 → 首计学堂 → 闯关(最佳路线 + 失败路线 + 补分支到完美)→ 解锁第二计 → 校验积分流水。
|
||
|
||
- [ ] **Step 3: 提交收尾**
|
||
|
||
```bash
|
||
git add -A && git commit -m "chore: M1 后端验证通过"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: uni-app 前端骨架(H5 先行)
|
||
|
||
**Files:**
|
||
- Create: `ui-src/`(package.json, vite.config.js, index.html, src/main.js, src/App.vue, src/pages.json, src/manifest.json, src/pages/login/login.vue, src/pages/children/children.vue, src/pages/map/map.vue, src/pages/play/play.vue, src/pages/result/result.vue, src/api/request.js, src/api/index.js, src/store/user.js)
|
||
|
||
- [ ] **Step 1: 工程初始化**(最小 uni-app Vue3 Vite 工程,H5 目标):
|
||
|
||
```bash
|
||
cd ui-src && npm init -y && npm install vue@3 @dcloudio/uni-app@latest @dcloudio/uni-h5@latest vite@5 @vitejs/plugin-vue@latest
|
||
```
|
||
|
||
- [ ] **Step 2: 基础配置**:`pages.json` 注册 5 个页面(login → children → map → play → result);`manifest.json`(appid 占位、h5 配置);vite.config.js 代理 `/api` → `http://localhost:8080`。
|
||
|
||
- [ ] **Step 3: api/request.js**:uni.request 封装(baseURL `/api`、token 注入 `Authorization: Bearer`、401 跳登录、统一错误 toast)。
|
||
|
||
- [ ] **Step 4: 页面**(M1 交互可用、样式从简但卡通化配色):
|
||
- login:手机号 + 密码 + 昵称注册 / 登录
|
||
- children:孩子列表 + 新建孩子(年龄段选择)
|
||
- map:strategy/list 渲染六组地图网格,计策卡图标 + 星星 + 锁定态;点解锁章节 → 学堂弹层(strategy/detail 的 teach_content + 音频播放按钮)→ 进入 play
|
||
- play:level/detail 渲染入口节点 → 选项列表(选择类互动;M1 只实现 interaction_type=1 选项选择,其余类型显示"敬请期待"占位并禁用)→ 点击选项调 choose → 展示 feedback → 下一节点;终局时展示结算(星/分/完美/解锁动画)→ result
|
||
- result:结算页(星数动画、积分变化、计策卡获得、下一关解锁提示)
|
||
|
||
- [ ] **Step 5: H5 端联调**
|
||
|
||
```bash
|
||
cd ui-src && npm run dev:h5
|
||
```
|
||
|
||
浏览器走通:注册 → 建孩子 → 地图 → 学堂 → 闯关(最佳 + 补分支到完美)→ 解锁第二计。
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
cd .. && git add ui-src/ && git commit -m "feat: uni-app 前端骨架(登录/孩子/地图/闯关/结算)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review 记录
|
||
|
||
- **Spec 覆盖**:技术设计 3 节 19 表 ✓(Task 3/4)、4.1 种子 ✓(Task 5)、4.3 学习闭环/判分/完美/温故——M1 覆盖判分与完美(温故与总结在 M2)✓、4.8 鉴权 ✓(Task 6)、API 清单 M1 范围 ✓。M2 内容(温故/总结/任务/徽章/签到/兑换/后台)不在本计划,属 M2 计划。
|
||
- **占位符扫描**:seed JSON 给出完整结构与示例(声东击西整树),其余 35 计在实现时按同范式创作(内容创作任务,结构已定义)。
|
||
- **类型一致性**:`FinalSettle`/`SettleFinal`/`NodeVO`/`OptionVO` 在 Task 8/9 间一致;积分常量与 settle 规则引用 `consts.Points*` 一致。
|