This commit is contained in:
2026-08-14 12:13:02 +08:00
parent 0e62930c64
commit 285fa01471
71 changed files with 1805 additions and 746 deletions
+3 -2
View File
@@ -29,7 +29,8 @@
## 分层文件对齐与代码模式(硬性要求) ## 分层文件对齐与代码模式(硬性要求)
- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐;虚拟表(向量 vec0 / FTS5)不建独立分层文件,由主表 dao 统一管理 - 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致
- **非表文件一律不进业务分层目录**:路由注册与中间件装配、表初始化列表(建表 + 死表 DROP)直接写在 `main.go`;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件
- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件 - **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件
- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射 - entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射
- dao:单例 `var Xxx = &xxxDao{}`,`init()``CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk) - dao:单例 `var Xxx = &xxxDao{}`,`init()``CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk)
@@ -61,7 +62,7 @@
## 数据访问规范(硬性要求) ## 数据访问规范(硬性要求)
- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。事务放 dao 层方法内,service 层负责编排;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback - **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback
- **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999) - **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999)
- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组 - **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组
- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存 - **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存
-1
View File
@@ -103,7 +103,6 @@
| user_badge | 孩子已获徽章 | | user_badge | 孩子已获徽章 |
| point_log | 积分流水:变动、原因类型、余额快照 | | point_log | 积分流水:变动、原因类型、余额快照 |
| life_task | 生活践行任务:关联计策、任务描述、家长引导语、确认奖励积分 | | life_task | 生活践行任务:关联计策、任务描述、家长引导语、确认奖励积分 |
| life_task_log | 生活任务完成记录:孩子 × 任务、家长确认状态 |
| prize | 奖品:积分价格、库存、类型(虚拟/实物) | | prize | 奖品:积分价格、库存、类型(虚拟/实物) |
| redemption | 兑换记录:孩子 × 奖品、状态流转、兑换码 | | redemption | 兑换记录:孩子 × 奖品、状态流转、兑换码 |
| admin_user | 后台管理员账号 | | admin_user | 后台管理员账号 |
-7
View File
@@ -62,13 +62,6 @@ const (
RedeemReceived = 5 // 已领取(家长确认) RedeemReceived = 5 // 已领取(家长确认)
) )
// 生活任务状态(life_task_log.status
const (
TaskIssued = 1 // 已下发
TaskConfirming = 2 // 待家长确认
TaskConfirmed = 3 // 已确认
)
// 年龄段档位(child.age_group / level.age_group // 年龄段档位(child.age_group / level.age_group
const ( const (
AgeGroup4_6 = "4-6" AgeGroup4_6 = "4-6"
-1
View File
@@ -20,5 +20,4 @@ const (
TableRedemption = "redemption" TableRedemption = "redemption"
TableAdminUser = "admin_user" TableAdminUser = "admin_user"
TableLifeTask = "life_task" TableLifeTask = "life_task"
TableLifeTaskLog = "life_task_log"
) )
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type adminUser struct{}
var AdminUser = &adminUser{}
func (c *adminUser) Get(ctx context.Context, req *dto.AdminUserGetReq) (*dto.AdminUserGetRes, error) {
rec, err := service.AdminUser.GetByUsername(ctx, req.Username)
if err != nil {
return nil, err
}
res := &dto.AdminUserGetRes{}
if !rec.IsEmpty() {
res.AdminUser = adminUserItem(rec)
}
return res, nil
}
func adminUserItem(r gdb.Record) dto.AdminUserItem {
return dto.AdminUserItem{
Id: r["id"].Int64(),
Username: r["username"].String(),
Status: r["status"].Int(),
}
}
+49
View File
@@ -0,0 +1,49 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type badge struct{}
var Badge = &badge{}
func (c *badge) Get(ctx context.Context, req *dto.BadgeGetReq) (*dto.BadgeGetRes, error) {
rec, err := service.Badge.GetByPk(ctx, req.Id)
if err != nil {
return nil, err
}
res := &dto.BadgeGetRes{}
if !rec.IsEmpty() {
res.Badge = badgeItem(rec)
}
return res, nil
}
func (c *badge) List(ctx context.Context, req *dto.BadgeListReq) (*dto.BadgeListRes, error) {
recs, err := service.Badge.ListEnabled(ctx)
if err != nil {
return nil, err
}
res := &dto.BadgeListRes{List: make([]dto.BadgeItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, badgeItem(r))
}
return res, nil
}
func badgeItem(r gdb.Record) dto.BadgeItem {
return dto.BadgeItem{
Id: r["id"].Int64(),
Name: r["name"].String(),
Icon: r["icon"].String(),
CondType: r["cond_type"].Int(),
CondValue: r["cond_value"].Int(),
Status: r["status"].Int(),
}
}
+36
View File
@@ -0,0 +1,36 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type chapterReview struct{}
var ChapterReview = &chapterReview{}
func (c *chapterReview) ListByChild(ctx context.Context, req *dto.ChapterReviewListReq) (*dto.ChapterReviewListRes, error) {
recs, err := service.ChapterReview.ListByChild(ctx, req.ChildId)
if err != nil {
return nil, err
}
res := &dto.ChapterReviewListRes{List: make([]dto.ChapterReviewItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, chapterReviewItem(r))
}
return res, nil
}
func chapterReviewItem(r gdb.Record) dto.ChapterReviewItem {
return dto.ChapterReviewItem{
Id: r["id"].Int64(),
ChildId: r["child_id"].Int64(),
StrategyId: r["strategy_id"].Int64(),
LevelIds: r["level_ids"].String(),
Status: r["status"].Int(),
}
}
+39
View File
@@ -0,0 +1,39 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type element struct{}
var Element = &element{}
func (c *element) ListByIds(ctx context.Context, req *dto.ElementListReq) (*dto.ElementListRes, error) {
recs, err := service.Element.ListEnabledByIds(ctx, req.Ids)
if err != nil {
return nil, err
}
res := &dto.ElementListRes{List: make([]dto.ElementItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, elementItem(r))
}
return res, nil
}
func elementItem(r gdb.Record) dto.ElementItem {
return dto.ElementItem{
Id: r["id"].Int64(),
EType: r["e_type"].Int(),
Name: r["name"].String(),
Image: r["image"].String(),
Audio: r["audio"].String(),
Description: r["description"].String(),
Status: r["status"].Int(),
SortOrder: r["sort_order"].Int(),
}
}
+31 -1
View File
@@ -33,7 +33,7 @@ func (c *level) Detail(ctx context.Context, req *dto.LevelDetailReq) (*dto.Level
return res, nil return res, nil
} }
func elementVO(e *service.Element) *dto.ElementVO { func elementVO(e *service.ElementVO) *dto.ElementVO {
if e == nil { if e == nil {
return nil return nil
} }
@@ -77,3 +77,33 @@ func nodeVO(n *service.Node) dto.NodeVO {
} }
return vo return vo
} }
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
}
-43
View File
@@ -1,43 +0,0 @@
package controller
import (
"context"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
"36wisdom/common/auth"
)
type levelPlay struct{}
var LevelPlay = &levelPlay{}
func (c *levelPlay) Choose(ctx context.Context, req *dto.ChooseReq) (*dto.ChooseRes, error) {
node, settle, err := service.LevelPlay.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
}
+50
View File
@@ -0,0 +1,50 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type lifeTask struct{}
var LifeTask = &lifeTask{}
func (c *lifeTask) Get(ctx context.Context, req *dto.LifeTaskGetReq) (*dto.LifeTaskGetRes, error) {
rec, err := service.LifeTask.GetByPk(ctx, req.Id)
if err != nil {
return nil, err
}
res := &dto.LifeTaskGetRes{}
if !rec.IsEmpty() {
res.LifeTask = lifeTaskItem(rec)
}
return res, nil
}
func (c *lifeTask) List(ctx context.Context, req *dto.LifeTaskListReq) (*dto.LifeTaskListRes, error) {
recs, err := service.LifeTask.ListEnabled(ctx)
if err != nil {
return nil, err
}
res := &dto.LifeTaskListRes{List: make([]dto.LifeTaskItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, lifeTaskItem(r))
}
return res, nil
}
func lifeTaskItem(r gdb.Record) dto.LifeTaskItem {
return dto.LifeTaskItem{
Id: r["id"].Int64(),
StrategyId: r["strategy_id"].Int64(),
Title: r["title"].String(),
Description: r["description"].String(),
Guide: r["guide"].String(),
RewardPoints: r["reward_points"].Int(),
Status: r["status"].Int(),
}
}
+53
View File
@@ -0,0 +1,53 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type nodeOption struct{}
var NodeOption = &nodeOption{}
func (c *nodeOption) Get(ctx context.Context, req *dto.NodeOptionGetReq) (*dto.NodeOptionGetRes, error) {
rec, err := service.NodeOption.GetInNode(ctx, req.OptionId, req.NodeId)
if err != nil {
return nil, err
}
res := &dto.NodeOptionGetRes{}
if !rec.IsEmpty() {
res.NodeOption = nodeOptionItem(rec)
}
return res, nil
}
func (c *nodeOption) ListByNode(ctx context.Context, req *dto.NodeOptionListReq) (*dto.NodeOptionListRes, error) {
recs, err := service.NodeOption.ListEnabledByNode(ctx, req.NodeId)
if err != nil {
return nil, err
}
res := &dto.NodeOptionListRes{List: make([]dto.NodeOptionItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, nodeOptionItem(r))
}
return res, nil
}
func nodeOptionItem(r gdb.Record) dto.NodeOptionItem {
return dto.NodeOptionItem{
Id: r["id"].Int64(),
NodeId: r["node_id"].Int64(),
Text: r["text"].String(),
PropId: r["prop_id"].Int64(),
Audio: r["audio"].String(),
NextNodeId: r["next_node_id"].Int64(),
Feedback: r["feedback"].String(),
FeedbackAudio: r["feedback_audio"].String(),
SortOrder: r["sort_order"].Int(),
Status: r["status"].Int(),
}
}
+52
View File
@@ -0,0 +1,52 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type prize struct{}
var Prize = &prize{}
func (c *prize) Get(ctx context.Context, req *dto.PrizeGetReq) (*dto.PrizeGetRes, error) {
rec, err := service.Prize.GetByPk(ctx, req.Id)
if err != nil {
return nil, err
}
res := &dto.PrizeGetRes{}
if !rec.IsEmpty() {
res.Prize = prizeItem(rec)
}
return res, nil
}
func (c *prize) List(ctx context.Context, req *dto.PrizeListReq) (*dto.PrizeListRes, error) {
recs, err := service.Prize.ListEnabled(ctx)
if err != nil {
return nil, err
}
res := &dto.PrizeListRes{List: make([]dto.PrizeItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, prizeItem(r))
}
return res, nil
}
func prizeItem(r gdb.Record) dto.PrizeItem {
return dto.PrizeItem{
Id: r["id"].Int64(),
Name: r["name"].String(),
Description: r["description"].String(),
Icon: r["icon"].String(),
PType: r["p_type"].Int(),
PointsCost: r["points_cost"].Int(),
Stock: r["stock"].Int(),
Status: r["status"].Int(),
SortOrder: r["sort_order"].Int(),
}
}
+37
View File
@@ -0,0 +1,37 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type redemption struct{}
var Redemption = &redemption{}
func (c *redemption) ListByUser(ctx context.Context, req *dto.RedemptionListReq) (*dto.RedemptionListRes, error) {
recs, err := service.Redemption.ListByUser(ctx, req.UserId)
if err != nil {
return nil, err
}
res := &dto.RedemptionListRes{List: make([]dto.RedemptionItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, redemptionItem(r))
}
return res, nil
}
func redemptionItem(r gdb.Record) dto.RedemptionItem {
return dto.RedemptionItem{
Id: r["id"].Int64(),
UserId: r["user_id"].Int64(),
PrizeId: r["prize_id"].Int64(),
PointsCost: r["points_cost"].Int(),
Status: r["status"].Int(),
Code: r["code"].String(),
}
}
-30
View File
@@ -1,30 +0,0 @@
package controller
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"36wisdom/common/auth"
)
// Register 注册全部路由分组;各业务控制器在对应任务中挂载。
func Register(s *ghttp.Server) {
s.Use(ghttp.MiddlewareHandlerResponse)
s.BindHandler("GET:/ping", func(r *ghttp.Request) {
r.Response.Write("pong")
})
secret := g.Cfg().MustGet(gctx.New(), "auth.secret").String()
// 前台:公开接口(注册/登录),鉴权接口按模块挂载
s.Group("/api", func(g *ghttp.RouterGroup) {
g.Bind(Parent)
g.Group("/", func(g *ghttp.RouterGroup) {
g.Middleware(auth.Middleware(secret, ""))
g.Bind(Child, Strategy, Level, LevelPlay)
})
})
}
+58
View File
@@ -0,0 +1,58 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type sceneNode struct{}
var SceneNode = &sceneNode{}
func (c *sceneNode) Get(ctx context.Context, req *dto.SceneNodeGetReq) (*dto.SceneNodeGetRes, error) {
rec, err := service.SceneNode.GetInLevel(ctx, req.NodeId, req.LevelId)
if err != nil {
return nil, err
}
res := &dto.SceneNodeGetRes{}
if !rec.IsEmpty() {
res.SceneNode = sceneNodeItem(rec)
}
return res, nil
}
func (c *sceneNode) ListByLevel(ctx context.Context, req *dto.SceneNodeListReq) (*dto.SceneNodeListRes, error) {
recs, err := service.SceneNode.ListEnabledByLevel(ctx, req.LevelId)
if err != nil {
return nil, err
}
res := &dto.SceneNodeListRes{List: make([]dto.SceneNodeItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, sceneNodeItem(r))
}
return res, nil
}
func sceneNodeItem(r gdb.Record) dto.SceneNodeItem {
return dto.SceneNodeItem{
Id: r["id"].Int64(),
LevelId: r["level_id"].Int64(),
Title: r["title"].String(),
CharacterId: r["character_id"].Int64(),
Content: r["content"].String(),
Image: r["image"].String(),
Audio: r["audio"].String(),
NodeType: r["node_type"].Int(),
InteractionType: r["interaction_type"].Int(),
Config: r["config"].String(),
Script: r["script"].String(),
ResultType: r["result_type"].Int(),
IsEntry: r["is_entry"].Int(),
SortOrder: r["sort_order"].Int(),
Status: r["status"].Int(),
}
}
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userBadge struct{}
var UserBadge = &userBadge{}
func (c *userBadge) ListByUser(ctx context.Context, req *dto.UserBadgeListReq) (*dto.UserBadgeListRes, error) {
recs, err := service.UserBadge.ListByUser(ctx, req.UserId)
if err != nil {
return nil, err
}
res := &dto.UserBadgeListRes{List: make([]dto.UserBadgeItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, userBadgeItem(r))
}
return res, nil
}
func userBadgeItem(r gdb.Record) dto.UserBadgeItem {
return dto.UserBadgeItem{
Id: r["id"].Int64(),
UserId: r["user_id"].Int64(),
BadgeId: r["badge_id"].Int64(),
}
}
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userCollection struct{}
var UserCollection = &userCollection{}
func (c *userCollection) ListByUser(ctx context.Context, req *dto.UserCollectionListReq) (*dto.UserCollectionListRes, error) {
recs, err := service.UserCollection.ListByUser(ctx, req.UserId)
if err != nil {
return nil, err
}
res := &dto.UserCollectionListRes{List: make([]dto.UserCollectionItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, userCollectionItem(r))
}
return res, nil
}
func userCollectionItem(r gdb.Record) dto.UserCollectionItem {
return dto.UserCollectionItem{
Id: r["id"].Int64(),
UserId: r["user_id"].Int64(),
StrategyId: r["strategy_id"].Int64(),
}
}
+50
View File
@@ -0,0 +1,50 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userProgress struct{}
var UserProgress = &userProgress{}
func (c *userProgress) GetByChildLevel(ctx context.Context, req *dto.UserProgressGetReq) (*dto.UserProgressGetRes, error) {
rec, err := service.UserProgress.GetByChildLevel(ctx, req.ChildId, req.LevelId)
if err != nil {
return nil, err
}
res := &dto.UserProgressGetRes{}
if !rec.IsEmpty() {
res.UserProgress = userProgressItem(rec)
}
return res, nil
}
func (c *userProgress) ListByChild(ctx context.Context, req *dto.UserProgressListReq) (*dto.UserProgressListRes, error) {
recs, err := service.UserProgress.ListByChildLevelIds(ctx, req.ChildId, req.LevelIds)
if err != nil {
return nil, err
}
res := &dto.UserProgressListRes{List: make([]dto.UserProgressItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, userProgressItem(r))
}
return res, nil
}
func userProgressItem(r gdb.Record) dto.UserProgressItem {
return dto.UserProgressItem{
Id: r["id"].Int64(),
ChildId: r["child_id"].Int64(),
LevelId: r["level_id"].Int64(),
Stars: r["stars"].Int(),
Score: r["score"].Int(),
Perfect: r["perfect"].Int(),
ContentVersion: r["content_version"].Int(),
}
}
+37
View File
@@ -0,0 +1,37 @@
package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userRouteLog struct{}
var UserRouteLog = &userRouteLog{}
func (c *userRouteLog) ListByChildLevel(ctx context.Context, req *dto.UserRouteLogListReq) (*dto.UserRouteLogListRes, error) {
recs, err := service.UserRouteLog.ListFinalsByChildLevel(ctx, req.ChildId, req.LevelId)
if err != nil {
return nil, err
}
res := &dto.UserRouteLogListRes{List: make([]dto.UserRouteLogItem, 0, len(recs))}
for _, r := range recs {
res.List = append(res.List, userRouteLogItem(r))
}
return res, nil
}
func userRouteLogItem(r gdb.Record) dto.UserRouteLogItem {
return dto.UserRouteLogItem{
Id: r["id"].Int64(),
ChildId: r["child_id"].Int64(),
LevelId: r["level_id"].Int64(),
NodeId: r["node_id"].Int64(),
OptionId: r["option_id"].Int64(),
ResultType: r["result_type"].Int(),
}
}
+2 -1
View File
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS badge (
cond_type INTEGER NOT NULL, cond_type INTEGER NOT NULL,
cond_value INTEGER NOT NULL, cond_value INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1 status INTEGER NOT NULL DEFAULT 1
);`) );
CREATE INDEX IF NOT EXISTS idx_badge_status ON badge(status);`)
return err return err
} }
+12 -1
View File
@@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS child (
created_at DATETIME, created_at DATETIME,
updated_at DATETIME updated_at DATETIME
); );
CREATE INDEX IF NOT EXISTS idx_child_parent ON child(parent_id);`) CREATE INDEX IF NOT EXISTS idx_child_parent ON child(parent_id);
-- point_log 为 child 的附属流水表(纯表,不建独立分层文件),建表由主表 child 托管(同虚拟表模式)
CREATE TABLE IF NOT EXISTS point_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
change INTEGER NOT NULL,
reason_type INTEGER NOT NULL,
ref_id INTEGER,
balance_after INTEGER NOT NULL,
created_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_point_log_user ON point_log(user_id, created_at);`)
return err return err
} }
-29
View File
@@ -1,29 +0,0 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/common"
)
type lifeTaskLogDao struct{ common.BaseDao }
var LifeTaskLog = &lifeTaskLogDao{BaseDao: common.BaseDao{Table: consts.TableLifeTaskLog}}
func (d *lifeTaskLogDao) Init(ctx context.Context) error {
_, err := g.DB().Exec(ctx, `
CREATE TABLE IF NOT EXISTS life_task_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
task_id INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
confirm_photo TEXT,
confirmed_at DATETIME,
created_at DATETIME,
UNIQUE(child_id, task_id)
);`)
return err
}
-29
View File
@@ -1,29 +0,0 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/common"
)
type pointLogDao struct{ common.BaseDao }
var PointLog = &pointLogDao{BaseDao: common.BaseDao{Table: consts.TablePointLog}}
func (d *pointLogDao) Init(ctx context.Context) error {
_, err := g.DB().Exec(ctx, `
CREATE TABLE IF NOT EXISTS point_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
change INTEGER NOT NULL,
reason_type INTEGER NOT NULL,
ref_id INTEGER,
balance_after INTEGER NOT NULL,
created_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_point_log_user ON point_log(user_id, created_at);`)
return err
}
+2 -1
View File
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS prize (
stock INTEGER NOT NULL DEFAULT 0, stock INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1, status INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 1 sort_order INTEGER NOT NULL DEFAULT 1
);`) );
CREATE INDEX IF NOT EXISTS idx_prize_status ON prize(status, sort_order);`)
return err return err
} }
-40
View File
@@ -1,40 +0,0 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/frame/g"
)
type tableInitializer func(ctx context.Context) error
var tableInitializers = []tableInitializer{
Parent.Init,
Child.Init,
Strategy.Init,
Level.Init,
SceneNode.Init,
NodeOption.Init,
Element.Init,
UserProgress.Init,
ChapterReview.Init,
UserRouteLog.Init,
UserCollection.Init,
Badge.Init,
UserBadge.Init,
PointLog.Init,
Prize.Init,
Redemption.Init,
AdminUser.Init,
LifeTask.Init,
LifeTaskLog.Init,
}
// InitTables 创建全部业务表与索引(IF NOT EXISTS 幂等)。
func InitTables(ctx context.Context) {
for _, init := range tableInitializers {
if err := init(ctx); err != nil {
g.Log().Fatal(ctx, err)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
package dto
type AdminUserItem struct {
Id int64 `json:"id"`
Username string `json:"username"`
Status int `json:"status"`
}
type AdminUserGetReq struct {
Username string `v:"required" json:"username"`
}
type AdminUserGetRes struct {
AdminUser AdminUserItem `json:"admin_user"`
}
+24
View File
@@ -0,0 +1,24 @@
package dto
type BadgeItem 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 BadgeGetReq struct {
Id int64 `v:"required" json:"id"`
}
type BadgeGetRes struct {
Badge BadgeItem `json:"badge"`
}
type BadgeListReq struct{}
type BadgeListRes struct {
List []BadgeItem `json:"list"`
}
+17
View File
@@ -0,0 +1,17 @@
package dto
type ChapterReviewItem struct {
Id int64 `json:"id"`
ChildId int64 `json:"child_id"`
StrategyId int64 `json:"strategy_id"`
LevelIds string `json:"level_ids"`
Status int `json:"status"`
}
type ChapterReviewListReq struct {
ChildId int64 `v:"required" json:"child_id"`
}
type ChapterReviewListRes struct {
List []ChapterReviewItem `json:"list"`
}
+20
View File
@@ -0,0 +1,20 @@
package dto
type ElementItem 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 ElementListReq struct {
Ids []int64 `v:"required" json:"ids"`
}
type ElementListRes struct {
List []ElementItem `json:"list"`
}
+26
View File
@@ -57,3 +57,29 @@ type LevelDetailRes struct {
Perfect bool `json:"perfect"` Perfect bool `json:"perfect"`
Stars int `json:"stars"` Stars int `json:"stars"`
} }
type ChooseReq struct {
g.Meta `path:"/level/choose" method:"post" summary:"分支闯关选择"`
ChildId int64 `v:"required" json:"child_id"`
LevelId int64 `v:"required" json:"level_id"`
NodeId int64 `v:"required" json:"node_id"`
OptionId int64 `v:"required" json:"option_id"`
}
type ChooseRes struct {
Next *NodeVO `json:"next"`
Final *FinalSettle `json:"final"`
}
type FinalSettle struct {
ResultType int `json:"result_type"`
Stars int `json:"stars"`
ScoreDelta int `json:"score_delta"`
Cleared bool `json:"cleared"`
Perfect bool `json:"perfect"`
UnlockNext bool `json:"unlock_next"`
CollectionUnlocked bool `json:"collection_unlocked"`
NewLevel int `json:"new_level"`
BalanceAfter int `json:"balance_after"`
FinalNode *NodeVO `json:"final_node"`
}
-29
View File
@@ -1,29 +0,0 @@
package dto
import "github.com/gogf/gf/v2/frame/g"
type ChooseReq struct {
g.Meta `path:"/level/choose" method:"post" summary:"分支闯关选择"`
ChildId int64 `v:"required" json:"child_id"`
LevelId int64 `v:"required" json:"level_id"`
NodeId int64 `v:"required" json:"node_id"`
OptionId int64 `v:"required" json:"option_id"`
}
type ChooseRes struct {
Next *NodeVO `json:"next"`
Final *FinalSettle `json:"final"`
}
type FinalSettle struct {
ResultType int `json:"result_type"`
Stars int `json:"stars"`
ScoreDelta int `json:"score_delta"`
Cleared bool `json:"cleared"`
Perfect bool `json:"perfect"`
UnlockNext bool `json:"unlock_next"`
CollectionUnlocked bool `json:"collection_unlocked"`
NewLevel int `json:"new_level"`
BalanceAfter int `json:"balance_after"`
FinalNode *NodeVO `json:"final_node"`
}
+25
View File
@@ -0,0 +1,25 @@
package dto
type LifeTaskItem struct {
Id int64 `json:"id"`
StrategyId int64 `json:"strategy_id"`
Title string `json:"title"`
Description string `json:"description"`
Guide string `json:"guide"`
RewardPoints int `json:"reward_points"`
Status int `json:"status"`
}
type LifeTaskGetReq struct {
Id int64 `v:"required" json:"id"`
}
type LifeTaskGetRes struct {
LifeTask LifeTaskItem `json:"life_task"`
}
type LifeTaskListReq struct{}
type LifeTaskListRes struct {
List []LifeTaskItem `json:"list"`
}
+31
View File
@@ -0,0 +1,31 @@
package dto
type NodeOptionItem struct {
Id int64 `json:"id"`
NodeId int64 `json:"node_id"`
Text string `json:"text"`
PropId int64 `json:"prop_id"`
Audio string `json:"audio"`
NextNodeId int64 `json:"next_node_id"`
Feedback string `json:"feedback"`
FeedbackAudio string `json:"feedback_audio"`
SortOrder int `json:"sort_order"`
Status int `json:"status"`
}
type NodeOptionGetReq struct {
OptionId int64 `v:"required" json:"option_id"`
NodeId int64 `v:"required" json:"node_id"`
}
type NodeOptionGetRes struct {
NodeOption NodeOptionItem `json:"node_option"`
}
type NodeOptionListReq struct {
NodeId int64 `v:"required" json:"node_id"`
}
type NodeOptionListRes struct {
List []NodeOptionItem `json:"list"`
}
+27
View File
@@ -0,0 +1,27 @@
package dto
type PrizeItem 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 PrizeGetReq struct {
Id int64 `v:"required" json:"id"`
}
type PrizeGetRes struct {
Prize PrizeItem `json:"prize"`
}
type PrizeListReq struct{}
type PrizeListRes struct {
List []PrizeItem `json:"list"`
}
+18
View File
@@ -0,0 +1,18 @@
package dto
type RedemptionItem struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
PrizeId int64 `json:"prize_id"`
PointsCost int `json:"points_cost"`
Status int `json:"status"`
Code string `json:"code"`
}
type RedemptionListReq struct {
UserId int64 `v:"required" json:"user_id"`
}
type RedemptionListRes struct {
List []RedemptionItem `json:"list"`
}
+36
View File
@@ -0,0 +1,36 @@
package dto
type SceneNodeItem struct {
Id int64 `json:"id"`
LevelId int64 `json:"level_id"`
Title string `json:"title"`
CharacterId int64 `json:"character_id"`
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"`
}
type SceneNodeGetReq struct {
NodeId int64 `v:"required" json:"node_id"`
LevelId int64 `v:"required" json:"level_id"`
}
type SceneNodeGetRes struct {
SceneNode SceneNodeItem `json:"scene_node"`
}
type SceneNodeListReq struct {
LevelId int64 `v:"required" json:"level_id"`
}
type SceneNodeListRes struct {
List []SceneNodeItem `json:"list"`
}
+15
View File
@@ -0,0 +1,15 @@
package dto
type UserBadgeItem struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
BadgeId int64 `json:"badge_id"`
}
type UserBadgeListReq struct {
UserId int64 `v:"required" json:"user_id"`
}
type UserBadgeListRes struct {
List []UserBadgeItem `json:"list"`
}
+15
View File
@@ -0,0 +1,15 @@
package dto
type UserCollectionItem struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
StrategyId int64 `json:"strategy_id"`
}
type UserCollectionListReq struct {
UserId int64 `v:"required" json:"user_id"`
}
type UserCollectionListRes struct {
List []UserCollectionItem `json:"list"`
}
+29
View File
@@ -0,0 +1,29 @@
package dto
type UserProgressItem struct {
Id int64 `json:"id"`
ChildId int64 `json:"child_id"`
LevelId int64 `json:"level_id"`
Stars int `json:"stars"`
Score int `json:"score"`
Perfect int `json:"perfect"`
ContentVersion int `json:"content_version"`
}
type UserProgressGetReq struct {
ChildId int64 `v:"required" json:"child_id"`
LevelId int64 `v:"required" json:"level_id"`
}
type UserProgressGetRes struct {
UserProgress UserProgressItem `json:"user_progress"`
}
type UserProgressListReq struct {
ChildId int64 `v:"required" json:"child_id"`
LevelIds []int64 `v:"required" json:"level_ids"`
}
type UserProgressListRes struct {
List []UserProgressItem `json:"list"`
}
+19
View File
@@ -0,0 +1,19 @@
package dto
type UserRouteLogItem struct {
Id int64 `json:"id"`
ChildId int64 `json:"child_id"`
LevelId int64 `json:"level_id"`
NodeId int64 `json:"node_id"`
OptionId int64 `json:"option_id"`
ResultType int `json:"result_type"`
}
type UserRouteLogListReq struct {
ChildId int64 `v:"required" json:"child_id"`
LevelId int64 `v:"required" json:"level_id"`
}
type UserRouteLogListRes struct {
List []UserRouteLogItem `json:"list"`
}
-14
View File
@@ -1,14 +0,0 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// LifeTaskLog 生活任务完成记录:孩子 × 任务,家长确认状态流转。
type LifeTaskLog struct {
Id int64 `json:"id" orm:"id"`
ChildId int64 `json:"child_id" orm:"child_id"`
TaskId int64 `json:"task_id" orm:"task_id"`
Status int `json:"status" orm:"status"`
ConfirmPhoto string `json:"confirm_photo" orm:"confirm_photo"`
ConfirmedAt *gtime.Time `json:"confirmed_at" orm:"confirmed_at"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
-14
View File
@@ -1,14 +0,0 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// PointLog 积分流水:变动、原因类型、余额快照。
type PointLog struct {
Id int64 `json:"id" orm:"id"`
UserId int64 `json:"user_id" orm:"user_id"`
Change int `json:"change" orm:"change"`
ReasonType int `json:"reason_type" orm:"reason_type"`
RefId int64 `json:"ref_id" orm:"ref_id"`
BalanceAfter int `json:"balance_after" orm:"balance_after"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
+18
View File
@@ -0,0 +1,18 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/dao"
)
type adminUser struct{}
var AdminUser = &adminUser{}
// GetByUsername 按用户名取管理员账号(含密码列,登录校验用)。
func (s *adminUser) GetByUsername(ctx context.Context, username string) (gdb.Record, error) {
return dao.AdminUser.Model().Ctx(ctx).Where("username", username).One()
}
-27
View File
@@ -1,27 +0,0 @@
package service
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/common/auth"
)
// authSvc 鉴权服务:token 签发与解析,密钥统一来自配置 auth.secret。
type authSvc struct{}
var Auth = &authSvc{}
func (s *authSvc) GenerateToken(ctx context.Context, uid int64, role string) (string, error) {
return auth.GenerateToken(secret(ctx), uid, role, consts.AuthExpireSeconds)
}
func (s *authSvc) ParseToken(ctx context.Context, tokenString string) (int64, string, error) {
return auth.ParseToken(secret(ctx), tokenString)
}
func secret(ctx context.Context) string {
return g.Cfg().MustGet(ctx, "auth.secret").String()
}
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type badge struct{}
var Badge = &badge{}
// GetByPk 徽章定义。
func (s *badge) GetByPk(ctx context.Context, id int64) (gdb.Record, error) {
return dao.Badge.GetOneByPk(ctx, id)
}
// 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()
}
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/dao"
)
type chapterReview struct{}
var ChapterReview = &chapterReview{}
// ListByChild 孩子温故记录。
func (s *chapterReview) ListByChild(ctx context.Context, childId int64) ([]gdb.Record, error) {
return dao.ChapterReview.Model().Ctx(ctx).Where("child_id", childId).Order("id DESC").All()
}
// 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,
})
}
+1 -5
View File
@@ -80,11 +80,7 @@ func (s *child) List(ctx context.Context, parentId int64) ([]*ChildItem, error)
} }
perfectMap := make(map[int64]int, len(recs)) perfectMap := make(map[int64]int, len(recs))
rows, err := dao.UserProgress.Model().Ctx(ctx). rows, err := UserProgress.CountPerfectByChildIds(ctx, childIds)
Fields("child_id", "COUNT(*) AS cnt").
Where("perfect", 1).
WhereIn("child_id", childIds).
Group("child_id").All()
if err != nil { if err != nil {
return nil, err return nil, err
} }
+20
View File
@@ -0,0 +1,20 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
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()
}
+374 -19
View File
@@ -2,12 +2,16 @@ package service
import ( import (
"context" "context"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts" "36wisdom/biz/consts"
"36wisdom/biz/dao" "36wisdom/biz/dao"
"36wisdom/common"
) )
type level struct{} type level struct{}
@@ -39,8 +43,8 @@ func LevelOf(perfectCount int) (level int, title string) {
return return
} }
// Element 元素 VO(场景/人物/道具)。 // ElementVO 元素 VO(场景/人物/道具)。
type Element struct { type ElementVO struct {
Id int64 Id int64
EType int EType int
Name string Name string
@@ -58,7 +62,7 @@ type Option struct {
FeedbackPros string FeedbackPros string
FeedbackCons string FeedbackCons string
Audio string Audio string
Prop *Element Prop *ElementVO
} }
// Node 关卡节点 VO(决策/终局)。 // Node 关卡节点 VO(决策/终局)。
@@ -69,7 +73,7 @@ type Node struct {
ContentPinyin string ContentPinyin string
Image string Image string
Audio string Audio string
Character *Element Character *ElementVO
InteractionType int InteractionType int
Config string Config string
Script string Script string
@@ -82,7 +86,7 @@ type Node struct {
type LevelDetail struct { type LevelDetail struct {
LevelId int64 LevelId int64
Title string Title string
Scene *Element Scene *ElementVO
SceneContent string SceneContent string
SceneContentPinyin string SceneContentPinyin string
SceneImage string SceneImage string
@@ -111,9 +115,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (
return nil, gerror.New("关卡不属于当前年龄段") return nil, gerror.New("关卡不属于当前年龄段")
} }
nodeRecs, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)). nodeRecs, err := SceneNode.ListEnabledByLevel(ctx, levelId)
Where("level_id", levelId).Where("status", consts.StatusEnabled).
Order("sort_order ASC").All()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -124,9 +126,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (
optionRecs := []gdb.Record{} optionRecs := []gdb.Record{}
if len(nodeIds) > 0 { if len(nodeIds) > 0 {
optionRecs, err = dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)). optionRecs, err = NodeOption.ListEnabledByNodeIds(ctx, nodeIds)
WhereIn("node_id", nodeIds).Where("status", consts.StatusEnabled).
Order("sort_order ASC").All()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -137,8 +137,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (
return nil, err return nil, err
} }
progressRec, err := dao.UserProgress.Model().Ctx(ctx). progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId)
Where("child_id", childId).Where("level_id", levelId).One()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -173,7 +172,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (
} }
// elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。 // elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。
func elementsOf(ctx context.Context, levelRec gdb.Record, nodeRecs, optionRecs []gdb.Record) (map[int64]*Element, error) { func elementsOf(ctx context.Context, levelRec gdb.Record, nodeRecs, optionRecs []gdb.Record) (map[int64]*ElementVO, error) {
ids := []int64{levelRec["scene_id"].Int64()} ids := []int64{levelRec["scene_id"].Int64()}
for _, n := range nodeRecs { for _, n := range nodeRecs {
if cid := n["character_id"].Int64(); cid > 0 { if cid := n["character_id"].Int64(); cid > 0 {
@@ -187,17 +186,16 @@ func elementsOf(ctx context.Context, levelRec gdb.Record, nodeRecs, optionRecs [
} }
ids = uniqueInt64(ids) ids = uniqueInt64(ids)
m := make(map[int64]*Element, len(ids)) m := make(map[int64]*ElementVO, len(ids))
if len(ids) == 0 { if len(ids) == 0 {
return m, nil return m, nil
} }
recs, err := dao.Element.Model().Ctx(ctx).Cache(contentCache(ctx)). recs, err := Element.ListEnabledByIds(ctx, ids)
WhereIn("id", ids).Where("status", consts.StatusEnabled).All()
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, r := range recs { for _, r := range recs {
m[r["id"].Int64()] = &Element{ m[r["id"].Int64()] = &ElementVO{
Id: r["id"].Int64(), Id: r["id"].Int64(),
EType: r["e_type"].Int(), EType: r["e_type"].Int(),
Name: r["name"].String(), Name: r["name"].String(),
@@ -211,7 +209,7 @@ func elementsOf(ctx context.Context, levelRec gdb.Record, nodeRecs, optionRecs [
} }
// buildNode 组装单个节点的选项与人物/道具元素。 // buildNode 组装单个节点的选项与人物/道具元素。
func buildNode(nodeRec gdb.Record, optionRecs []gdb.Record, elements map[int64]*Element) *Node { func buildNode(nodeRec gdb.Record, optionRecs []gdb.Record, elements map[int64]*ElementVO) *Node {
node := &Node{ node := &Node{
NodeId: nodeRec["id"].Int64(), NodeId: nodeRec["id"].Int64(),
Title: nodeRec["title"].String(), Title: nodeRec["title"].String(),
@@ -242,3 +240,360 @@ func buildNode(nodeRec gdb.Record, optionRecs []gdb.Record, elements map[int64]*
} }
return node 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}
switch resultType {
case consts.ResultBest:
f.Stars = 3
case consts.ResultGood:
f.Stars = 2
default:
f.Stars = 0
}
newNode := !s.ReachedFinals[finalNodeId]
if newNode && !s.LevelCleared {
switch resultType {
case consts.ResultBest:
f.ScoreDelta = consts.PointsBest
case consts.ResultGood:
f.ScoreDelta = consts.PointsGood
default:
if s.FailStreak < consts.FailNoDeductAfter {
f.ScoreDelta = consts.PointsFail
}
}
}
if f.ScoreDelta < 0 && -f.ScoreDelta > s.BalanceAfter {
f.ScoreDelta = -s.BalanceAfter
}
f.Cleared = s.LevelCleared || resultType == consts.ResultBest
reached := len(s.ReachedFinals)
if newNode {
reached++
}
// 完美以进度表标记为准发放一次(日志为尽力而为的记录,可能漂移)
if reached == s.TotalFinals && s.TotalFinals > 0 && !s.PerfectAwarded {
f.Perfect = true
f.ScoreDelta += consts.PointsPerfect
}
f.BalanceAfter = s.BalanceAfter + f.ScoreDelta
return f
}
// 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)
if err != nil {
return nil, nil, err
}
levelRec, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(levelId).One()
if err != nil {
return nil, nil, err
}
if levelRec.IsEmpty() || levelRec["status"].Int() != consts.StatusEnabled {
return nil, nil, gerror.New("关卡不存在")
}
if levelRec["age_group"].String() != child["age_group"].String() {
return nil, nil, gerror.New("关卡不属于当前年龄段")
}
nodeRec, err := SceneNode.GetInLevel(ctx, nodeId, levelId)
if err != nil {
return nil, nil, err
}
if nodeRec.IsEmpty() {
return nil, nil, gerror.New("节点不存在")
}
optionRec, err := NodeOption.GetInNode(ctx, optionId, nodeId)
if err != nil {
return nil, nil, err
}
if optionRec.IsEmpty() {
return nil, nil, gerror.New("选项不存在")
}
nextRec, err := SceneNode.GetInLevel(ctx, optionRec["next_node_id"].Int64(), levelId)
if err != nil {
return nil, nil, err
}
if nextRec.IsEmpty() {
return nil, nil, gerror.New("目标节点不存在")
}
progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId)
if err != nil {
return nil, nil, err
}
if !s.levelUnlocked(ctx, childId, child, levelRec, progressRec) {
return nil, nil, gerror.New("关卡未解锁")
}
if nextRec["result_type"].Int() == consts.ResultNone {
s.logRoute(ctx, childId, levelId, nodeId, optionId, consts.ResultNone)
node, err := nodeOf(ctx, levelRec, nextRec)
if err != nil {
return nil, nil, err
}
return node, nil, nil
}
// v2.1:终局节点剧本先于结算构建(只读查询失败则未结算,无副作用)
finalNode, err := nodeOf(ctx, levelRec, nextRec)
if err != nil {
return nil, 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)
})
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)
if err != nil {
return nil, err
}
return buildNode(nodeRec, optionRecs, elements), nil
}
// 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()
state, err := s.settleState(ctx, childId, levelId)
if err != nil {
return nil, err
}
f := SettleFinal(state, finalRec["id"].Int64(), finalRec["result_type"].Int())
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.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{}}
progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId)
if err != nil {
return state, err
}
state.LevelCleared = progressRec["stars"].Int() >= 3
state.PerfectAwarded = progressRec["perfect"].Int() == 1
childRec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
if err != nil {
return state, err
}
state.BalanceAfter = childRec["points"].Int()
totalFinals, err := SceneNode.CountFinalsByLevel(ctx, levelId)
if err != nil {
return state, err
}
state.TotalFinals = totalFinals
logs, err := 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())
}
nextOf := map[int64]int64{}
if len(optionIds) > 0 {
opts, err := NodeOption.ListByOptionIds(ctx, optionIds)
if err != nil {
return state, err
}
for _, o := range opts {
nextOf[o["id"].Int64()] = o["next_node_id"].Int64()
}
}
for _, l := range logs {
if nid, ok := nextOf[l["option_id"].Int64()]; ok {
state.ReachedFinals[nid] = true
}
}
for i := len(logs) - 1; i >= 0; i-- {
if logs[i]["result_type"].Int() == consts.ResultFail {
state.FailStreak++
} else {
break
}
}
return state, nil
}
// commitSettle 结算事务:进度(最高星 + 完美标记 + 内容版本)、积分流水与余额、计策卡。
func (s *level) commitSettle(ctx context.Context, childId int64, levelRec gdb.Record, f *FinalSettle) error {
levelId := levelRec["id"].Int64()
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 {
return err
}
if f.ScoreDelta != 0 {
childRec, err := tx.Model(consts.TableChild).Ctx(ctx).WherePri(childId).One()
if err != nil {
return err
}
balance := childRec["points"].Int() + f.ScoreDelta
if _, err = tx.Model(consts.TableChild).Ctx(ctx).Data(g.Map{"points": balance}).WherePri(childId).Update(); err != nil {
return err
}
if _, err = tx.Model(consts.TablePointLog).Ctx(ctx).Data(g.Map{
"user_id": childId,
"change": f.ScoreDelta,
"reason_type": consts.ReasonLevel,
"ref_id": levelId,
"balance_after": balance,
}).Insert(); err != nil {
return err
}
f.BalanceAfter = balance
}
return s.collectIfAllPerfect(ctx, tx, childId, levelRec, f)
})
}
// collectIfAllPerfect 本计全部关卡完美 → 写计策卡(防重复:已存在则跳过)。
func (s *level) collectIfAllPerfect(ctx context.Context, tx gdb.TX, childId int64, levelRec gdb.Record, f *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()
if err != nil {
return err
}
for _, lv := range levels {
rec, err := UserProgress.GetInTx(ctx, tx, childId, lv["id"].Int64())
if err != nil {
return err
}
if rec.IsEmpty() || rec["perfect"].Int() != 1 {
return nil
}
}
exists, err := 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 {
return err
}
f.CollectionUnlocked = true
return nil
}
// deriveExtras 结算后派生:计策卡解锁提示、下一计解锁、成长等级。
func (s *level) deriveExtras(ctx context.Context, child gdb.Record, levelRec gdb.Record, f *FinalSettle) {
childId := child["id"].Int64()
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() {
f.UnlockNext = true
}
}
before, err := UserProgress.CountPerfectByChild(ctx, childId)
if err != nil {
return
}
after := before
if f.Perfect {
after++
}
oldLevel, _ := LevelOf(before)
newLevel, _ := LevelOf(after)
if oldLevel != newLevel {
f.NewLevel = newLevel
}
}
-428
View File
@@ -1,428 +0,0 @@
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"
"github.com/gogf/gf/v2/os/gtime"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
"36wisdom/common"
)
type levelPlay struct{}
var LevelPlay = &levelPlay{}
// 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}
switch resultType {
case consts.ResultBest:
f.Stars = 3
case consts.ResultGood:
f.Stars = 2
default:
f.Stars = 0
}
newNode := !s.ReachedFinals[finalNodeId]
if newNode && !s.LevelCleared {
switch resultType {
case consts.ResultBest:
f.ScoreDelta = consts.PointsBest
case consts.ResultGood:
f.ScoreDelta = consts.PointsGood
default:
if s.FailStreak < consts.FailNoDeductAfter {
f.ScoreDelta = consts.PointsFail
}
}
}
if f.ScoreDelta < 0 && -f.ScoreDelta > s.BalanceAfter {
f.ScoreDelta = -s.BalanceAfter
}
f.Cleared = s.LevelCleared || resultType == consts.ResultBest
reached := len(s.ReachedFinals)
if newNode {
reached++
}
// 完美以进度表标记为准发放一次(日志为尽力而为的记录,可能漂移)
if reached == s.TotalFinals && s.TotalFinals > 0 && !s.PerfectAwarded {
f.Perfect = true
f.ScoreDelta += consts.PointsPerfect
}
f.BalanceAfter = s.BalanceAfter + f.ScoreDelta
return f
}
// Choose 分支闯关:校验 node/option 归属与解锁 → 决策节点返回下一节点;
// 终局节点在锁内结算(事务写进度/积分/计策卡)。路径流水尽力而为,失败不阻断。
func (s *levelPlay) Choose(ctx context.Context, parentUid, childId, levelId, nodeId, optionId int64) (*Node, *FinalSettle, error) {
child, err := getChildOf(ctx, parentUid, childId)
if err != nil {
return nil, nil, err
}
levelRec, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(levelId).One()
if err != nil {
return nil, nil, err
}
if levelRec.IsEmpty() || levelRec["status"].Int() != consts.StatusEnabled {
return nil, nil, gerror.New("关卡不存在")
}
if levelRec["age_group"].String() != child["age_group"].String() {
return nil, nil, gerror.New("关卡不属于当前年龄段")
}
nodeRec, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
Where("id", nodeId).Where("level_id", levelId).Where("status", consts.StatusEnabled).One()
if err != nil {
return nil, nil, err
}
if nodeRec.IsEmpty() {
return nil, nil, gerror.New("节点不存在")
}
optionRec, err := dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
Where("id", optionId).Where("node_id", nodeId).Where("status", consts.StatusEnabled).One()
if err != nil {
return nil, nil, err
}
if optionRec.IsEmpty() {
return nil, nil, gerror.New("选项不存在")
}
nextRec, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
Where("id", optionRec["next_node_id"].Int64()).Where("level_id", levelId).Where("status", consts.StatusEnabled).One()
if err != nil {
return nil, nil, err
}
if nextRec.IsEmpty() {
return nil, nil, gerror.New("目标节点不存在")
}
progressRec, err := dao.UserProgress.Model().Ctx(ctx).
Where("child_id", childId).Where("level_id", levelId).One()
if err != nil {
return nil, nil, err
}
if !s.levelUnlocked(ctx, childId, child, levelRec, progressRec) {
return nil, nil, gerror.New("关卡未解锁")
}
if nextRec["result_type"].Int() == consts.ResultNone {
s.logRoute(ctx, childId, levelId, nodeId, optionId, consts.ResultNone)
node, err := nodeOf(ctx, levelRec, nextRec)
if err != nil {
return nil, nil, err
}
return node, nil, nil
}
// v2.1:终局节点剧本先于结算构建(只读查询失败则未结算,无副作用)
finalNode, err := nodeOf(ctx, levelRec, nextRec)
if err != nil {
return nil, 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)
})
if err != nil {
return nil, nil, err
}
settle.FinalNode = finalNode
return nil, settle, nil
}
// levelUnlocked 关卡解锁判定:本关已通关可重玩,否则按解锁链校验计策解锁。
func (s *levelPlay) 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 *levelPlay) logRoute(ctx context.Context, childId, levelId, nodeId, optionId int64, resultType int) {
_, _ = dao.UserRouteLog.InsertAndReturnId(ctx, g.Map{
"child_id": childId,
"level_id": levelId,
"node_id": nodeId,
"option_id": optionId,
"result_type": resultType,
})
}
// nodeOf 组装下一决策节点(选项 + 元素)。
func nodeOf(ctx context.Context, levelRec, nodeRec gdb.Record) (*Node, error) {
optionRecs, err := dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
Where("node_id", nodeRec["id"].Int64()).Where("status", consts.StatusEnabled).
Order("sort_order ASC").All()
if err != nil {
return nil, err
}
elements, err := elementsOf(ctx, levelRec, []gdb.Record{nodeRec}, optionRecs)
if err != nil {
return nil, err
}
return buildNode(nodeRec, optionRecs, elements), nil
}
// settle 终局结算(调用方持锁):锁内重读状态 → 纯函数判分 → 事务写库 → 记录本次终局路径 → 派生结果。
func (s *levelPlay) settle(ctx context.Context, child gdb.Record, levelRec, finalRec gdb.Record, nodeId, optionId int64) (*FinalSettle, error) {
childId := child["id"].Int64()
levelId := levelRec["id"].Int64()
state, err := s.settleState(ctx, childId, levelId)
if err != nil {
return nil, err
}
f := SettleFinal(state, finalRec["id"].Int64(), finalRec["result_type"].Int())
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.deriveExtras(ctx, child, levelRec, &f)
return &f, nil
}
// settleState 锁内重读该关历史状态:终局数、已到达终局、连续失败、余额。
func (s *levelPlay) settleState(ctx context.Context, childId, levelId int64) (SettleState, error) {
state := SettleState{ReachedFinals: map[int64]bool{}}
progressRec, err := dao.UserProgress.Model().Ctx(ctx).
Where("child_id", childId).Where("level_id", levelId).One()
if err != nil {
return state, err
}
state.LevelCleared = progressRec["stars"].Int() >= 3
state.PerfectAwarded = progressRec["perfect"].Int() == 1
childRec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
if err != nil {
return state, err
}
state.BalanceAfter = childRec["points"].Int()
nodeRecs, err := dao.SceneNode.Model().Ctx(ctx).
Where("level_id", levelId).Where("status", consts.StatusEnabled).All()
if err != nil {
return state, err
}
for _, n := range nodeRecs {
if n["result_type"].Int() > 0 {
state.TotalFinals++
}
}
logs, err := dao.UserRouteLog.Model().Ctx(ctx).
Where("child_id", childId).Where("level_id", levelId).
WhereGT("result_type", 0).Order("id ASC").All()
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())
}
nextOf := map[int64]int64{}
if len(optionIds) > 0 {
opts, err := dao.NodeOption.Model().Ctx(ctx).WhereIn("id", optionIds).All()
if err != nil {
return state, err
}
for _, o := range opts {
nextOf[o["id"].Int64()] = o["next_node_id"].Int64()
}
}
for _, l := range logs {
if nid, ok := nextOf[l["option_id"].Int64()]; ok {
state.ReachedFinals[nid] = true
}
}
for i := len(logs) - 1; i >= 0; i-- {
if logs[i]["result_type"].Int() == consts.ResultFail {
state.FailStreak++
} else {
break
}
}
return state, nil
}
// commitSettle 结算事务:进度(最高星 + 完美标记 + 内容版本)、积分流水与余额、计策卡。
func (s *levelPlay) commitSettle(ctx context.Context, childId int64, levelRec gdb.Record, f *FinalSettle) error {
levelId := levelRec["id"].Int64()
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
progressRec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
Where("child_id", childId).Where("level_id", levelId).One()
if err != nil {
return err
}
stars := f.Stars
perfect := 0
if f.Perfect {
perfect = 1
}
if !progressRec.IsEmpty() {
if stars < progressRec["stars"].Int() {
stars = progressRec["stars"].Int()
}
perfect = progressRec["perfect"].Int() | perfect
}
progressData := g.Map{
"stars": stars,
"perfect": perfect,
"content_version": levelRec["content_version"].Int(),
"completed_at": gtime.Now(),
}
if progressRec.IsEmpty() {
progressData["child_id"] = childId
progressData["level_id"] = levelId
_, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(progressData).Insert()
} else {
_, err = tx.Model(consts.TableUserProgress).Ctx(ctx).Data(progressData).
Where("child_id", childId).Where("level_id", levelId).Update()
}
if err != nil {
return err
}
if f.ScoreDelta != 0 {
childRec, err := tx.Model(consts.TableChild).Ctx(ctx).WherePri(childId).One()
if err != nil {
return err
}
balance := childRec["points"].Int() + f.ScoreDelta
if _, err = tx.Model(consts.TableChild).Ctx(ctx).Data(g.Map{"points": balance}).WherePri(childId).Update(); err != nil {
return err
}
if _, err = tx.Model(consts.TablePointLog).Ctx(ctx).Data(g.Map{
"user_id": childId,
"change": f.ScoreDelta,
"reason_type": consts.ReasonLevel,
"ref_id": levelId,
"balance_after": balance,
}).Insert(); err != nil {
return err
}
f.BalanceAfter = balance
}
return s.collectIfAllPerfect(ctx, tx, childId, levelRec, f)
})
}
// collectIfAllPerfect 本计全部关卡完美 → 写计策卡(防重复:已存在则跳过)。
func (s *levelPlay) collectIfAllPerfect(ctx context.Context, tx gdb.TX, childId int64, levelRec gdb.Record, f *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()
if err != nil {
return err
}
for _, lv := range levels {
rec, err := tx.Model(consts.TableUserProgress).Ctx(ctx).
Where("child_id", childId).Where("level_id", lv["id"].Int64()).One()
if err != nil {
return err
}
if rec.IsEmpty() || rec["perfect"].Int() != 1 {
return nil
}
}
exists, err := tx.Model(consts.TableUserCollection).Ctx(ctx).
Where("user_id", childId).Where("strategy_id", strategyId).Count()
if err != nil {
return err
}
if exists > 0 {
return nil
}
if _, err = tx.Model(consts.TableUserCollection).Ctx(ctx).Data(g.Map{
"user_id": childId,
"strategy_id": strategyId,
}).Insert(); err != nil {
return err
}
f.CollectionUnlocked = true
return nil
}
// deriveExtras 结算后派生:计策卡解锁提示、下一计解锁、成长等级。
func (s *levelPlay) deriveExtras(ctx context.Context, child gdb.Record, levelRec gdb.Record, f *FinalSettle) {
childId := child["id"].Int64()
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() {
f.UnlockNext = true
}
}
before, err := dao.UserProgress.Model().Ctx(ctx).
Where("child_id", childId).Where("perfect", 1).Count()
if err != nil {
return
}
after := before
if f.Perfect {
after++
}
oldLevel, _ := LevelOf(before)
newLevel, _ := LevelOf(after)
if oldLevel != newLevel {
f.NewLevel = newLevel
}
}
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type lifeTask struct{}
var LifeTask = &lifeTask{}
// GetByPk 生活践行任务。
func (s *lifeTask) GetByPk(ctx context.Context, id int64) (gdb.Record, error) {
return dao.LifeTask.GetOneByPk(ctx, id)
}
// 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()
}
+39
View File
@@ -0,0 +1,39 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type nodeOption struct{}
var NodeOption = &nodeOption{}
// 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()
}
// 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()
}
// 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()
}
// ListByOptionIds 按选项 id 批量取(终局判定用,不缓存)。
func (s *nodeOption) ListByOptionIds(ctx context.Context, optionIds []int64) ([]gdb.Record, error) {
return dao.NodeOption.Model().Ctx(ctx).WhereIn("id", optionIds).All()
}
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"36wisdom/biz/consts" "36wisdom/biz/consts"
"36wisdom/biz/dao" "36wisdom/biz/dao"
"36wisdom/common/auth"
) )
type parent struct{} type parent struct{}
@@ -38,7 +39,7 @@ func (s *parent) Register(ctx context.Context, phone, password, nickname string)
if err != nil { if err != nil {
return 0, "", err return 0, "", err
} }
token, err = Auth.GenerateToken(ctx, parentId, consts.RoleParent) token, err = auth.GenerateToken(auth.Secret(ctx), parentId, consts.RoleParent, consts.AuthExpireSeconds)
return parentId, token, err return parentId, token, err
} }
@@ -55,6 +56,6 @@ func (s *parent) Login(ctx context.Context, phone, password string) (parentId in
return 0, "", gerror.New("手机号或密码错误") return 0, "", gerror.New("手机号或密码错误")
} }
parentId = rec["id"].Int64() parentId = rec["id"].Int64()
token, err = Auth.GenerateToken(ctx, parentId, consts.RoleParent) token, err = auth.GenerateToken(auth.Secret(ctx), parentId, consts.RoleParent, consts.AuthExpireSeconds)
return parentId, token, err return parentId, token, err
} }
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type prize struct{}
var Prize = &prize{}
// GetByPk 奖品定义。
func (s *prize) GetByPk(ctx context.Context, id int64) (gdb.Record, error) {
return dao.Prize.GetOneByPk(ctx, id)
}
// 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()
}
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/dao"
)
type redemption struct{}
var Redemption = &redemption{}
// 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()
}
// 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,
})
}
+33
View File
@@ -0,0 +1,33 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type sceneNode struct{}
var SceneNode = &sceneNode{}
// 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()
}
// 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()
}
// 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()
}
+2 -5
View File
@@ -265,9 +265,7 @@ func (s *strategy) progressOfLevels(ctx context.Context, childId int64, levelIds
if len(levelIds) == 0 { if len(levelIds) == 0 {
return m, nil return m, nil
} }
recs, err := dao.UserProgress.Model().Ctx(ctx). recs, err := UserProgress.ListByChildLevelIds(ctx, childId, levelIds)
Where("child_id", childId).
WhereIn("level_id", levelIds).All()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -284,8 +282,7 @@ func (s *strategy) elementNames(ctx context.Context, ids []int64) (map[int64]str
if len(ids) == 0 { if len(ids) == 0 {
return m, nil return m, nil
} }
recs, err := dao.Element.Model().Ctx(ctx).Cache(contentCache(ctx)). recs, err := Element.ListEnabledByIds(ctx, ids)
WhereIn("id", ids).Where("status", consts.StatusEnabled).All()
if err != nil { if err != nil {
return nil, err return nil, err
} }
+27
View File
@@ -0,0 +1,27 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/dao"
)
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,
})
}
+36
View File
@@ -0,0 +1,36 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
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()
}
+78
View File
@@ -0,0 +1,78 @@
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"
)
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
}
+33
View File
@@ -0,0 +1,33 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/dao"
)
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()
}
+5
View File
@@ -31,6 +31,11 @@ func GenerateToken(secret string, uid int64, role string, expireSeconds int) (st
return claims.SignedString([]byte(secret)) return claims.SignedString([]byte(secret))
} }
// Secret 鉴权密钥(config.yml auth.secret),token 签发与解析共用入口。
func Secret(ctx context.Context) string {
return g.Cfg().MustGet(ctx, "auth.secret").String()
}
// ParseToken 校验并解析 token,返回 uid 与 role。 // ParseToken 校验并解析 token,返回 uid 与 role。
func ParseToken(secret, tokenString string) (uid int64, role string, err error) { func ParseToken(secret, tokenString string) (uid int64, role string, err error) {
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
+19
View File
@@ -0,0 +1,19 @@
package common
import (
"net/http"
"strings"
"github.com/gogf/gf/v2/net/ghttp"
)
// MiddlewareNoiseSilence 静默浏览器/DevTools 噪音探测路径(favicon、well-known),避免访问日志刷 404
func MiddlewareNoiseSilence(r *ghttp.Request) {
p := r.URL.Path
if p == "/favicon.ico" || strings.HasPrefix(p, "/.well-known/") {
r.Response.WriteHeader(http.StatusNoContent)
r.Exit()
return
}
r.Middleware.Next()
}
+41 -2
View File
@@ -7,11 +7,14 @@ import (
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gctx"
"36wisdom/biz/controller" "36wisdom/biz/controller"
"36wisdom/biz/dao" "36wisdom/biz/dao"
"36wisdom/biz/service/seed" "36wisdom/biz/service/seed"
"36wisdom/common"
"36wisdom/common/auth"
) )
func main() { func main() {
@@ -21,11 +24,28 @@ func main() {
g.Log().Fatal(ctx, err) g.Log().Fatal(ctx, err)
} }
} }
dao.InitTables(ctx) initTables(ctx)
seed.EnsureSeeded(ctx) seed.EnsureSeeded(ctx)
s := g.Server() s := g.Server()
controller.Register(s) // 路由注册与中间件装配(静态,不属业务表分层)
s.Use(common.MiddlewareNoiseSilence)
s.Use(ghttp.MiddlewareHandlerResponse)
s.BindHandler("GET:/ping", func(r *ghttp.Request) {
r.Response.Write("pong")
})
secret := g.Cfg().MustGet(ctx, "auth.secret").String()
// 前台:公开接口(注册/登录),鉴权接口按模块挂载
s.Group("/api", func(g *ghttp.RouterGroup) {
g.Bind(controller.Parent)
g.Group("/", func(g *ghttp.RouterGroup) {
g.Middleware(auth.Middleware(secret, ""))
g.Bind(controller.Child, controller.Strategy, controller.Level)
})
})
// 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口) // 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口)
// 注:根路径须用 SetServerRoot——AddStaticPath("/", ...) 因前缀防误匹配守卫只服务根路径, // 注:根路径须用 SetServerRoot——AddStaticPath("/", ...) 因前缀防误匹配守卫只服务根路径,
// /assets 等子路径全部 404;素材目录 ui-src/static 以 /static 前缀单独挂载 // /assets 等子路径全部 404;素材目录 ui-src/static 以 /static 前缀单独挂载
@@ -37,3 +57,22 @@ func main() {
} }
s.Run() s.Run()
} }
// initTables 建表与死表清理(各 dao 的 Init 幂等;life_task_log 无读写引用整套移除,
// 库内残留表启动时 DROP 与代码保持一致)。
func initTables(ctx context.Context) {
inits := []func(ctx context.Context) error{
dao.Parent.Init, dao.Child.Init, dao.Strategy.Init, dao.Level.Init, dao.SceneNode.Init,
dao.NodeOption.Init, dao.Element.Init, dao.UserProgress.Init, dao.ChapterReview.Init,
dao.UserRouteLog.Init, dao.UserCollection.Init, dao.Badge.Init, dao.UserBadge.Init,
dao.Prize.Init, dao.Redemption.Init, dao.AdminUser.Init, dao.LifeTask.Init,
}
for _, init := range inits {
if err := init(ctx); err != nil {
g.Log().Fatal(ctx, err)
}
}
if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS life_task_log"); err != nil {
g.Log().Fatal(ctx, err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+13 -15
View File
@@ -246,9 +246,9 @@ CREATE TABLE user_badge (
); );
``` ```
### life_task / life_task_log(生活践行任务) ### life_task(生活践行任务)
游戏内践行(情境闯关)之外的真实生活践行:每章完美后任务下发,家长引导孩子实践,家长确认为权威入口(天然防刷)。 游戏内践行(情境闯关)之外的真实生活践行:每章完美后任务下发,家长引导孩子实践,家长确认为权威入口(天然防刷)。孩子 × 任务的执行状态记录(原 life_task_log)无读写引用,已连同建表与库内数据整套移除(启动时 DROP),M2 实现时按孩子 × 任务唯一约束重建。
```sql ```sql
CREATE TABLE life_task ( CREATE TABLE life_task (
@@ -261,21 +261,12 @@ CREATE TABLE life_task (
status INTEGER NOT NULL DEFAULT 1 status INTEGER NOT NULL DEFAULT 1
); );
CREATE INDEX idx_life_task_strategy ON life_task(strategy_id); CREATE INDEX idx_life_task_strategy ON life_task(strategy_id);
CREATE TABLE life_task_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL,
task_id INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1, -- 1 已下发 2 待家长确认 3 已确认
confirm_photo TEXT, -- 家长确认时上传的照片(可选)
confirmed_at DATETIME,
created_at DATETIME,
UNIQUE(child_id, task_id)
);
``` ```
### 积分与奖品 ### 积分与奖品
point_log 为 child 的附属流水表,建表由主表 child 的 dao 托管(豁免规则见 CLAUDE.md「分层文件对齐与代码模式」)。
```sql ```sql
CREATE TABLE point_log ( CREATE TABLE point_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -330,7 +321,7 @@ CREATE TABLE admin_user (
### 4.1 种子数据初始化 ### 4.1 种子数据初始化
- 种子 JSON 用 `go:embed` 打进二进制(`biz/service/seed/`),含:元素库(场景约 15 / 人物约 10 / 道具约 20)、36 计全部内容、每计 1-3 个现代情境关卡(决策树:节点 + 分支选项 + 元素关联)、初始奖品、初始徽章、默认管理员账号 - 种子 JSON 用 `go:embed` 打进二进制(`biz/service/seed/`),含:元素库(场景约 15 / 人物约 10 / 道具约 20)、36 计全部内容、每计 1-3 个现代情境关卡(决策树:节点 + 分支选项 + 元素关联)、初始奖品、初始徽章、默认管理员账号
- 启动时 dao 层(各表 `init()` 内已有 `CREATE TABLE IF NOT EXISTS`)之后,service 层执行 `seed.EnsureSeeded()`:查 `strategy` 空表则按序插入,插入包事务;随后对全部内容调用标注服务生成拼音(见 4.6),TTS 音频异步生成,均只执行一次 - 启动时先建表(main.go 依次调各 dao `Init``CREATE TABLE IF NOT EXISTS` 幂等),随后执行 `seed.EnsureSeeded()`:查 `strategy` 空表则按序插入,插入包事务;随后对全部内容调用标注服务生成拼音(见 4.6),TTS 音频异步生成,均只执行一次
- 后台运营迭代 = 后台改内容 + 可选导出种子;不覆盖用户数据表 - 后台运营迭代 = 后台改内容 + 可选导出种子;不覆盖用户数据表
### 4.2 年龄段分级 ### 4.2 年龄段分级
@@ -491,7 +482,7 @@ config JSON 结构(`scene_node.config`,前端解析渲染):`{"kind":"pro
### 4.12 践行与成长体系 ### 4.12 践行与成长体系
**生活践行任务**(知行合一):章完美后任务下发(life_task 按 strategy 关联)→ 孩子端显示"和爸爸妈妈一起做"卡片 → 家长查看引导语、带孩子实践 → 家长确认(POST /api/task/confirm,可选照片)→ 积分到账 + 状态流转(1 已下发 → 2 待家长确认 → 3 已确认)。防刷:UNIQUE(child_id, task_id) + 家长确认为唯一权威入口 **生活践行任务**(知行合一):章完美后任务下发(life_task 按 strategy 关联)→ 孩子端显示"和爸爸妈妈一起做"卡片 → 家长查看引导语、带孩子实践 → 家长确认(POST /api/task/confirm,可选照片)→ 积分到账 + 状态流转(1 已下发 → 2 待家长确认 → 3 已确认)。防刷:家长确认为唯一权威入口,M2 实现时按 child × task 唯一约束建执行记录表(原 life_task_log 无读写引用,已整套移除)
**成长等级**:按完美关卡数映射称号(规则表在 biz/consts,如小学徒 → 小书童 → 小军师 → 军师 → 大将军),**派生值不落库**——结算接口计算并返回新等级,前端播升级动画;家长报告展示当前称号。 **成长等级**:按完美关卡数映射称号(规则表在 biz/consts,如小学徒 → 小书童 → 小军师 → 军师 → 大将军),**派生值不落库**——结算接口计算并返回新等级,前端播升级动画;家长报告展示当前称号。
@@ -547,6 +538,13 @@ config JSON 结构(`scene_node.config`,前端解析渲染):`{"kind":"pro
- 幂等:仅处理 `script` 为空的节点;草稿已存在跳过(防重跑覆盖人工精修,`--force` 覆盖);`--strategy` 限定计策分批执行 - 幂等:仅处理 `script` 为空的节点;草稿已存在跳过(防重跑覆盖人工精修,`--force` 覆盖);`--strategy` 限定计策分批执行
- 试点:第 1 计 8 节点已导入精修,全量 36 计剧本由主会话按计分批生成 - 试点:第 1 计 8 节点已导入精修,全量 36 计剧本由主会话按计分批生成
### 4.15 分层文件对齐(沿革)
对齐规则(每表一组文件、豁免与归属、非表文件处置)见 CLAUDE.md「分层文件对齐与代码模式」,本节只记沿革:
- **13 张表补全**M1 时仅 5 个模块(parent/child/strategy/level/level_play)有完整分层,其余 15 张只有 entity/dao;一次补齐为每表一组文件,6 张在用表的跨表 DAO 直调收拢到各自 service;补齐的表先只建门面结构,不挂 g.Meta 不注册路由(路由静态,按需暴露)
- **收口(2026-08-14**level_play(闯关流程)并入 level 表文件、表初始化列表并入 main.go、service/auth.go 并入 common/auth——各层严格 = 17 表文件(18 表 point_log 豁免)
## 5. 开发计划 ## 5. 开发计划
| 里程碑 | 内容 | 验收标准 | | 里程碑 | 内容 | 验收标准 |