diff --git a/CLAUDE.md b/CLAUDE.md index 50544da..65b5e7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 文件 - entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射 - 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) - **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组 - **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存 diff --git a/README.md b/README.md index 7add10d..1eee49a 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,6 @@ | user_badge | 孩子已获徽章 | | point_log | 积分流水:变动、原因类型、余额快照 | | life_task | 生活践行任务:关联计策、任务描述、家长引导语、确认奖励积分 | -| life_task_log | 生活任务完成记录:孩子 × 任务、家长确认状态 | | prize | 奖品:积分价格、库存、类型(虚拟/实物) | | redemption | 兑换记录:孩子 × 奖品、状态流转、兑换码 | | admin_user | 后台管理员账号 | diff --git a/biz/consts/status.go b/biz/consts/status.go index f1fbaa4..72f15b5 100644 --- a/biz/consts/status.go +++ b/biz/consts/status.go @@ -62,13 +62,6 @@ const ( RedeemReceived = 5 // 已领取(家长确认) ) -// 生活任务状态(life_task_log.status) -const ( - TaskIssued = 1 // 已下发 - TaskConfirming = 2 // 待家长确认 - TaskConfirmed = 3 // 已确认 -) - // 年龄段档位(child.age_group / level.age_group) const ( AgeGroup4_6 = "4-6" diff --git a/biz/consts/table_name.go b/biz/consts/table_name.go index 3368ca1..3f8be0c 100644 --- a/biz/consts/table_name.go +++ b/biz/consts/table_name.go @@ -20,5 +20,4 @@ const ( TableRedemption = "redemption" TableAdminUser = "admin_user" TableLifeTask = "life_task" - TableLifeTaskLog = "life_task_log" ) diff --git a/biz/controller/admin_user.go b/biz/controller/admin_user.go new file mode 100644 index 0000000..2e49d99 --- /dev/null +++ b/biz/controller/admin_user.go @@ -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(), + } +} diff --git a/biz/controller/badge.go b/biz/controller/badge.go new file mode 100644 index 0000000..cd0a0e2 --- /dev/null +++ b/biz/controller/badge.go @@ -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(), + } +} diff --git a/biz/controller/chapter_review.go b/biz/controller/chapter_review.go new file mode 100644 index 0000000..d578660 --- /dev/null +++ b/biz/controller/chapter_review.go @@ -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(), + } +} diff --git a/biz/controller/element.go b/biz/controller/element.go new file mode 100644 index 0000000..71c1e92 --- /dev/null +++ b/biz/controller/element.go @@ -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(), + } +} diff --git a/biz/controller/level.go b/biz/controller/level.go index 889e609..6dafb7f 100644 --- a/biz/controller/level.go +++ b/biz/controller/level.go @@ -33,7 +33,7 @@ func (c *level) Detail(ctx context.Context, req *dto.LevelDetailReq) (*dto.Level return res, nil } -func elementVO(e *service.Element) *dto.ElementVO { +func elementVO(e *service.ElementVO) *dto.ElementVO { if e == nil { return nil } @@ -77,3 +77,33 @@ func nodeVO(n *service.Node) dto.NodeVO { } 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 +} diff --git a/biz/controller/level_play.go b/biz/controller/level_play.go deleted file mode 100644 index 7c6e8a6..0000000 --- a/biz/controller/level_play.go +++ /dev/null @@ -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 -} diff --git a/biz/controller/life_task.go b/biz/controller/life_task.go new file mode 100644 index 0000000..e60fef7 --- /dev/null +++ b/biz/controller/life_task.go @@ -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(), + } +} diff --git a/biz/controller/node_option.go b/biz/controller/node_option.go new file mode 100644 index 0000000..4c4cb5e --- /dev/null +++ b/biz/controller/node_option.go @@ -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(), + } +} diff --git a/biz/controller/prize.go b/biz/controller/prize.go new file mode 100644 index 0000000..18ce0bb --- /dev/null +++ b/biz/controller/prize.go @@ -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(), + } +} diff --git a/biz/controller/redemption.go b/biz/controller/redemption.go new file mode 100644 index 0000000..65774d1 --- /dev/null +++ b/biz/controller/redemption.go @@ -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(), + } +} diff --git a/biz/controller/router.go b/biz/controller/router.go deleted file mode 100644 index 7396a05..0000000 --- a/biz/controller/router.go +++ /dev/null @@ -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) - }) - }) -} diff --git a/biz/controller/scene_node.go b/biz/controller/scene_node.go new file mode 100644 index 0000000..85c1ffa --- /dev/null +++ b/biz/controller/scene_node.go @@ -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(), + } +} diff --git a/biz/controller/user_badge.go b/biz/controller/user_badge.go new file mode 100644 index 0000000..b1dd35c --- /dev/null +++ b/biz/controller/user_badge.go @@ -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(), + } +} diff --git a/biz/controller/user_collection.go b/biz/controller/user_collection.go new file mode 100644 index 0000000..e882899 --- /dev/null +++ b/biz/controller/user_collection.go @@ -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(), + } +} diff --git a/biz/controller/user_progress.go b/biz/controller/user_progress.go new file mode 100644 index 0000000..4a7114b --- /dev/null +++ b/biz/controller/user_progress.go @@ -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(), + } +} diff --git a/biz/controller/user_route_log.go b/biz/controller/user_route_log.go new file mode 100644 index 0000000..7bbead9 --- /dev/null +++ b/biz/controller/user_route_log.go @@ -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(), + } +} diff --git a/biz/dao/dao_badge.go b/biz/dao/dao_badge.go index 26ec854..fe1014c 100644 --- a/biz/dao/dao_badge.go +++ b/biz/dao/dao_badge.go @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS badge ( cond_type INTEGER NOT NULL, cond_value INTEGER NOT NULL, status INTEGER NOT NULL DEFAULT 1 -);`) +); +CREATE INDEX IF NOT EXISTS idx_badge_status ON badge(status);`) return err } diff --git a/biz/dao/dao_child.go b/biz/dao/dao_child.go index 7825e3a..fd6d472 100644 --- a/biz/dao/dao_child.go +++ b/biz/dao/dao_child.go @@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS child ( created_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 } diff --git a/biz/dao/dao_life_task_log.go b/biz/dao/dao_life_task_log.go deleted file mode 100644 index 2069301..0000000 --- a/biz/dao/dao_life_task_log.go +++ /dev/null @@ -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 -} diff --git a/biz/dao/dao_point_log.go b/biz/dao/dao_point_log.go deleted file mode 100644 index db05e22..0000000 --- a/biz/dao/dao_point_log.go +++ /dev/null @@ -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 -} diff --git a/biz/dao/dao_prize.go b/biz/dao/dao_prize.go index 5d308fb..58063aa 100644 --- a/biz/dao/dao_prize.go +++ b/biz/dao/dao_prize.go @@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS prize ( stock INTEGER NOT NULL DEFAULT 0, status 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 } diff --git a/biz/dao/init.go b/biz/dao/init.go deleted file mode 100644 index b55193e..0000000 --- a/biz/dao/init.go +++ /dev/null @@ -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) - } - } -} diff --git a/biz/model/dto/admin_user.go b/biz/model/dto/admin_user.go new file mode 100644 index 0000000..4ac7c44 --- /dev/null +++ b/biz/model/dto/admin_user.go @@ -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"` +} diff --git a/biz/model/dto/badge.go b/biz/model/dto/badge.go new file mode 100644 index 0000000..027a11c --- /dev/null +++ b/biz/model/dto/badge.go @@ -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"` +} diff --git a/biz/model/dto/chapter_review.go b/biz/model/dto/chapter_review.go new file mode 100644 index 0000000..21abc9b --- /dev/null +++ b/biz/model/dto/chapter_review.go @@ -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"` +} diff --git a/biz/model/dto/element.go b/biz/model/dto/element.go new file mode 100644 index 0000000..d90ae3e --- /dev/null +++ b/biz/model/dto/element.go @@ -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"` +} diff --git a/biz/model/dto/level.go b/biz/model/dto/level.go index dea1a7b..6172931 100644 --- a/biz/model/dto/level.go +++ b/biz/model/dto/level.go @@ -57,3 +57,29 @@ type LevelDetailRes struct { Perfect bool `json:"perfect"` 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"` +} diff --git a/biz/model/dto/level_play.go b/biz/model/dto/level_play.go deleted file mode 100644 index 6c3e703..0000000 --- a/biz/model/dto/level_play.go +++ /dev/null @@ -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"` -} diff --git a/biz/model/dto/life_task.go b/biz/model/dto/life_task.go new file mode 100644 index 0000000..4eff68c --- /dev/null +++ b/biz/model/dto/life_task.go @@ -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"` +} diff --git a/biz/model/dto/node_option.go b/biz/model/dto/node_option.go new file mode 100644 index 0000000..96f060b --- /dev/null +++ b/biz/model/dto/node_option.go @@ -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"` +} diff --git a/biz/model/dto/prize.go b/biz/model/dto/prize.go new file mode 100644 index 0000000..53f248e --- /dev/null +++ b/biz/model/dto/prize.go @@ -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"` +} diff --git a/biz/model/dto/redemption.go b/biz/model/dto/redemption.go new file mode 100644 index 0000000..991f99b --- /dev/null +++ b/biz/model/dto/redemption.go @@ -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"` +} diff --git a/biz/model/dto/scene_node.go b/biz/model/dto/scene_node.go new file mode 100644 index 0000000..e01ed24 --- /dev/null +++ b/biz/model/dto/scene_node.go @@ -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"` +} diff --git a/biz/model/dto/user_badge.go b/biz/model/dto/user_badge.go new file mode 100644 index 0000000..315a87d --- /dev/null +++ b/biz/model/dto/user_badge.go @@ -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"` +} diff --git a/biz/model/dto/user_collection.go b/biz/model/dto/user_collection.go new file mode 100644 index 0000000..54fd858 --- /dev/null +++ b/biz/model/dto/user_collection.go @@ -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"` +} diff --git a/biz/model/dto/user_progress.go b/biz/model/dto/user_progress.go new file mode 100644 index 0000000..6fe50bd --- /dev/null +++ b/biz/model/dto/user_progress.go @@ -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"` +} diff --git a/biz/model/dto/user_route_log.go b/biz/model/dto/user_route_log.go new file mode 100644 index 0000000..66576f2 --- /dev/null +++ b/biz/model/dto/user_route_log.go @@ -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"` +} diff --git a/biz/model/entity/entity_life_task_log.go b/biz/model/entity/entity_life_task_log.go deleted file mode 100644 index b634b57..0000000 --- a/biz/model/entity/entity_life_task_log.go +++ /dev/null @@ -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"` -} diff --git a/biz/model/entity/entity_point_log.go b/biz/model/entity/entity_point_log.go deleted file mode 100644 index 741f6a3..0000000 --- a/biz/model/entity/entity_point_log.go +++ /dev/null @@ -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"` -} diff --git a/biz/service/admin_user.go b/biz/service/admin_user.go new file mode 100644 index 0000000..de0d04f --- /dev/null +++ b/biz/service/admin_user.go @@ -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() +} diff --git a/biz/service/auth.go b/biz/service/auth.go deleted file mode 100644 index 981fdde..0000000 --- a/biz/service/auth.go +++ /dev/null @@ -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() -} diff --git a/biz/service/badge.go b/biz/service/badge.go new file mode 100644 index 0000000..de9cfd3 --- /dev/null +++ b/biz/service/badge.go @@ -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() +} diff --git a/biz/service/chapter_review.go b/biz/service/chapter_review.go new file mode 100644 index 0000000..5c60cf2 --- /dev/null +++ b/biz/service/chapter_review.go @@ -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, + }) +} diff --git a/biz/service/child.go b/biz/service/child.go index 11b5cf9..0e4cae5 100644 --- a/biz/service/child.go +++ b/biz/service/child.go @@ -80,11 +80,7 @@ func (s *child) List(ctx context.Context, parentId int64) ([]*ChildItem, error) } perfectMap := make(map[int64]int, len(recs)) - rows, err := dao.UserProgress.Model().Ctx(ctx). - Fields("child_id", "COUNT(*) AS cnt"). - Where("perfect", 1). - WhereIn("child_id", childIds). - Group("child_id").All() + rows, err := UserProgress.CountPerfectByChildIds(ctx, childIds) if err != nil { return nil, err } diff --git a/biz/service/element.go b/biz/service/element.go new file mode 100644 index 0000000..dfd9ece --- /dev/null +++ b/biz/service/element.go @@ -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() +} diff --git a/biz/service/level.go b/biz/service/level.go index 315ab19..fdac19c 100644 --- a/biz/service/level.go +++ b/biz/service/level.go @@ -2,12 +2,16 @@ package service import ( "context" + "fmt" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "36wisdom/biz/consts" "36wisdom/biz/dao" + "36wisdom/common" ) type level struct{} @@ -39,8 +43,8 @@ func LevelOf(perfectCount int) (level int, title string) { return } -// Element 元素 VO(场景/人物/道具)。 -type Element struct { +// ElementVO 元素 VO(场景/人物/道具)。 +type ElementVO struct { Id int64 EType int Name string @@ -58,7 +62,7 @@ type Option struct { FeedbackPros string FeedbackCons string Audio string - Prop *Element + Prop *ElementVO } // Node 关卡节点 VO(决策/终局)。 @@ -69,7 +73,7 @@ type Node struct { ContentPinyin string Image string Audio string - Character *Element + Character *ElementVO InteractionType int Config string Script string @@ -82,7 +86,7 @@ type Node struct { type LevelDetail struct { LevelId int64 Title string - Scene *Element + Scene *ElementVO SceneContent string SceneContentPinyin string SceneImage string @@ -111,9 +115,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) ( return nil, gerror.New("关卡不属于当前年龄段") } - nodeRecs, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)). - Where("level_id", levelId).Where("status", consts.StatusEnabled). - Order("sort_order ASC").All() + nodeRecs, err := SceneNode.ListEnabledByLevel(ctx, levelId) if err != nil { return nil, err } @@ -124,9 +126,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) ( optionRecs := []gdb.Record{} if len(nodeIds) > 0 { - optionRecs, err = dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)). - WhereIn("node_id", nodeIds).Where("status", consts.StatusEnabled). - Order("sort_order ASC").All() + optionRecs, err = NodeOption.ListEnabledByNodeIds(ctx, nodeIds) if err != nil { return nil, err } @@ -137,8 +137,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) ( return nil, err } - progressRec, err := dao.UserProgress.Model().Ctx(ctx). - Where("child_id", childId).Where("level_id", levelId).One() + progressRec, err := UserProgress.GetByChildLevel(ctx, childId, levelId) if err != nil { return nil, err } @@ -173,7 +172,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) ( } // 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()} for _, n := range nodeRecs { 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) - m := make(map[int64]*Element, len(ids)) + m := make(map[int64]*ElementVO, len(ids)) if len(ids) == 0 { return m, nil } - recs, err := dao.Element.Model().Ctx(ctx).Cache(contentCache(ctx)). - WhereIn("id", ids).Where("status", consts.StatusEnabled).All() + recs, err := Element.ListEnabledByIds(ctx, ids) if err != nil { return nil, err } for _, r := range recs { - m[r["id"].Int64()] = &Element{ + m[r["id"].Int64()] = &ElementVO{ Id: r["id"].Int64(), EType: r["e_type"].Int(), Name: r["name"].String(), @@ -211,7 +209,7 @@ func elementsOf(ctx context.Context, levelRec gdb.Record, nodeRecs, optionRecs [ } // 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{ NodeId: nodeRec["id"].Int64(), Title: nodeRec["title"].String(), @@ -242,3 +240,360 @@ func buildNode(nodeRec gdb.Record, optionRecs []gdb.Record, elements map[int64]* } 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 + } +} diff --git a/biz/service/level_play.go b/biz/service/level_play.go deleted file mode 100644 index c8ef7da..0000000 --- a/biz/service/level_play.go +++ /dev/null @@ -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 - } -} diff --git a/biz/service/level_play_test.go b/biz/service/level_test.go similarity index 100% rename from biz/service/level_play_test.go rename to biz/service/level_test.go diff --git a/biz/service/life_task.go b/biz/service/life_task.go new file mode 100644 index 0000000..67dfedb --- /dev/null +++ b/biz/service/life_task.go @@ -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() +} diff --git a/biz/service/node_option.go b/biz/service/node_option.go new file mode 100644 index 0000000..c155ce9 --- /dev/null +++ b/biz/service/node_option.go @@ -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() +} diff --git a/biz/service/parent.go b/biz/service/parent.go index a03604e..3a10984 100644 --- a/biz/service/parent.go +++ b/biz/service/parent.go @@ -9,6 +9,7 @@ import ( "36wisdom/biz/consts" "36wisdom/biz/dao" + "36wisdom/common/auth" ) type parent struct{} @@ -38,7 +39,7 @@ func (s *parent) Register(ctx context.Context, phone, password, nickname string) if err != nil { 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 } @@ -55,6 +56,6 @@ func (s *parent) Login(ctx context.Context, phone, password string) (parentId in return 0, "", gerror.New("手机号或密码错误") } 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 } diff --git a/biz/service/prize.go b/biz/service/prize.go new file mode 100644 index 0000000..e2f234a --- /dev/null +++ b/biz/service/prize.go @@ -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() +} diff --git a/biz/service/redemption.go b/biz/service/redemption.go new file mode 100644 index 0000000..467455c --- /dev/null +++ b/biz/service/redemption.go @@ -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, + }) +} diff --git a/biz/service/scene_node.go b/biz/service/scene_node.go new file mode 100644 index 0000000..f162ff9 --- /dev/null +++ b/biz/service/scene_node.go @@ -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() +} diff --git a/biz/service/strategy.go b/biz/service/strategy.go index d674b33..05c1ce1 100644 --- a/biz/service/strategy.go +++ b/biz/service/strategy.go @@ -265,9 +265,7 @@ func (s *strategy) progressOfLevels(ctx context.Context, childId int64, levelIds if len(levelIds) == 0 { return m, nil } - recs, err := dao.UserProgress.Model().Ctx(ctx). - Where("child_id", childId). - WhereIn("level_id", levelIds).All() + recs, err := UserProgress.ListByChildLevelIds(ctx, childId, levelIds) if err != nil { return nil, err } @@ -284,8 +282,7 @@ func (s *strategy) elementNames(ctx context.Context, ids []int64) (map[int64]str if len(ids) == 0 { return m, nil } - recs, err := dao.Element.Model().Ctx(ctx).Cache(contentCache(ctx)). - WhereIn("id", ids).Where("status", consts.StatusEnabled).All() + recs, err := Element.ListEnabledByIds(ctx, ids) if err != nil { return nil, err } diff --git a/biz/service/user_badge.go b/biz/service/user_badge.go new file mode 100644 index 0000000..473ffe1 --- /dev/null +++ b/biz/service/user_badge.go @@ -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, + }) +} diff --git a/biz/service/user_collection.go b/biz/service/user_collection.go new file mode 100644 index 0000000..18d8f3c --- /dev/null +++ b/biz/service/user_collection.go @@ -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() +} diff --git a/biz/service/user_progress.go b/biz/service/user_progress.go new file mode 100644 index 0000000..7e17297 --- /dev/null +++ b/biz/service/user_progress.go @@ -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 +} diff --git a/biz/service/user_route_log.go b/biz/service/user_route_log.go new file mode 100644 index 0000000..4784cd3 --- /dev/null +++ b/biz/service/user_route_log.go @@ -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() +} diff --git a/common/auth/auth.go b/common/auth/auth.go index 77c2723..e007123 100644 --- a/common/auth/auth.go +++ b/common/auth/auth.go @@ -31,6 +31,11 @@ func GenerateToken(secret string, uid int64, role string, expireSeconds int) (st 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。 func ParseToken(secret, tokenString string) (uid int64, role string, err error) { token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { diff --git a/common/middleware.go b/common/middleware.go new file mode 100644 index 0000000..cea6a40 --- /dev/null +++ b/common/middleware.go @@ -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() +} diff --git a/main.go b/main.go index b21d59f..ffe9b44 100644 --- a/main.go +++ b/main.go @@ -7,11 +7,14 @@ import ( _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gctx" "36wisdom/biz/controller" "36wisdom/biz/dao" "36wisdom/biz/service/seed" + "36wisdom/common" + "36wisdom/common/auth" ) func main() { @@ -21,11 +24,28 @@ func main() { g.Log().Fatal(ctx, err) } } - dao.InitTables(ctx) + initTables(ctx) seed.EnsureSeeded(ctx) 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 统一托管(前后端同端口) // 注:根路径须用 SetServerRoot——AddStaticPath("/", ...) 因前缀防误匹配守卫只服务根路径, // /assets 等子路径全部 404;素材目录 ui-src/static 以 /static 前缀单独挂载 @@ -37,3 +57,22 @@ func main() { } 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) + } +} diff --git a/ui-src/static/generated/char/char_2.png b/ui-src/static/generated/char/char_2.png new file mode 100644 index 0000000..4d5791d Binary files /dev/null and b/ui-src/static/generated/char/char_2.png differ diff --git a/ui-src/static/generated/prop/prop_3.png b/ui-src/static/generated/prop/prop_3.png new file mode 100644 index 0000000..a7d9959 Binary files /dev/null and b/ui-src/static/generated/prop/prop_3.png differ diff --git a/ui-src/static/generated/scene/scene_1.png b/ui-src/static/generated/scene/scene_1.png new file mode 100644 index 0000000..c8a1c30 Binary files /dev/null and b/ui-src/static/generated/scene/scene_1.png differ diff --git a/ui-src/static/generated/strategy/strategy_1.png b/ui-src/static/generated/strategy/strategy_1.png new file mode 100644 index 0000000..b2e75a3 Binary files /dev/null and b/ui-src/static/generated/strategy/strategy_1.png differ diff --git a/技术设计.md b/技术设计.md index 3c70804..f8165f6 100644 --- a/技术设计.md +++ b/技术设计.md @@ -246,9 +246,9 @@ CREATE TABLE user_badge ( ); ``` -### life_task / life_task_log(生活践行任务) +### life_task(生活践行任务) -游戏内践行(情境闯关)之外的真实生活践行:每章完美后任务下发,家长引导孩子实践,家长确认为权威入口(天然防刷)。 +游戏内践行(情境闯关)之外的真实生活践行:每章完美后任务下发,家长引导孩子实践,家长确认为权威入口(天然防刷)。孩子 × 任务的执行状态记录(原 life_task_log)无读写引用,已连同建表与库内数据整套移除(启动时 DROP),M2 实现时按孩子 × 任务唯一约束重建。 ```sql CREATE TABLE life_task ( @@ -261,21 +261,12 @@ CREATE TABLE life_task ( status INTEGER NOT NULL DEFAULT 1 ); 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 CREATE TABLE point_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -330,7 +321,7 @@ CREATE TABLE admin_user ( ### 4.1 种子数据初始化 - 种子 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 年龄段分级 @@ -491,7 +482,7 @@ config JSON 结构(`scene_node.config`,前端解析渲染):`{"kind":"pro ### 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,如小学徒 → 小书童 → 小军师 → 军师 → 大将军),**派生值不落库**——结算接口计算并返回新等级,前端播升级动画;家长报告展示当前称号。 @@ -547,6 +538,13 @@ config JSON 结构(`scene_node.config`,前端解析渲染):`{"kind":"pro - 幂等:仅处理 `script` 为空的节点;草稿已存在跳过(防重跑覆盖人工精修,`--force` 覆盖);`--strategy` 限定计策分批执行 - 试点:第 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. 开发计划 | 里程碑 | 内容 | 验收标准 |