Files
36Wisdom/docs/superpowers/plans/2026-08-13-m1-core.md
T
2026-08-13 12:49:36 +08:00

787 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:** 完成 M1Go+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 / SQLiteglebarez/sqlite 或 go-sqlite3/ uni-app Vue3H5 先行)。
**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.jsonembed
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: entity19 张表映射)
**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/liststrategy 全量(缓存,TTL 300s+ 本用户 user_progress 按 level IN 取星(≤100 分批)→ 按 strategy 分组 → 解锁判断(unlock_before 为空 或 前置计完美关卡数=前置计关卡总数);content_version 不一致的关卡标记"可重新挑战"
- level/detaillevel + 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** 组装 DTOentity → 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_lognode/option/result_type
3. 终局结算(事务):结算判定(按上面规则)→ 写 user_progress(取最高星 + perfect + content_version)→ 写 point_log + child.pointsdelta≠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:孩子列表 + 新建孩子(年龄段选择)
- mapstrategy/list 渲染六组地图网格,计策卡图标 + 星星 + 锁定态;点解锁章节 → 学堂弹层(strategy/detail 的 teach_content + 音频播放按钮)→ 进入 play
- playlevel/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*` 一致。