diff --git a/biz/controller/level_play.go b/biz/controller/level_play.go new file mode 100644 index 0000000..53670cd --- /dev/null +++ b/biz/controller/level_play.go @@ -0,0 +1,39 @@ +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, + } + } + return res, nil +} diff --git a/biz/controller/router.go b/biz/controller/router.go index 0e20127..7396a05 100644 --- a/biz/controller/router.go +++ b/biz/controller/router.go @@ -24,7 +24,7 @@ func Register(s *ghttp.Server) { g.Group("/", func(g *ghttp.RouterGroup) { g.Middleware(auth.Middleware(secret, "")) - g.Bind(Child, Strategy, Level) + g.Bind(Child, Strategy, Level, LevelPlay) }) }) } diff --git a/biz/model/dto/level_play.go b/biz/model/dto/level_play.go new file mode 100644 index 0000000..f7ed72c --- /dev/null +++ b/biz/model/dto/level_play.go @@ -0,0 +1,28 @@ +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"` +} diff --git a/biz/service/level.go b/biz/service/level.go index 81f2e69..fd48858 100644 --- a/biz/service/level.go +++ b/biz/service/level.go @@ -124,7 +124,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) ( } } - elements, err := s.elementsOf(ctx, levelRec, nodeRecs, optionRecs) + elements, err := elementsOf(ctx, levelRec, nodeRecs, optionRecs) if err != nil { return nil, err } @@ -164,7 +164,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) ( } // elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。 -func (s *level) 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]*Element, error) { ids := []int64{levelRec["scene_id"].Int64()} for _, n := range nodeRecs { if cid := n["character_id"].Int64(); cid > 0 { diff --git a/biz/service/level_play.go b/biz/service/level_play.go new file mode 100644 index 0000000..011c4b3 --- /dev/null +++ b/biz/service/level_play.go @@ -0,0 +1,421 @@ +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 +} + +// 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 + } + + // 终局:路径流水在锁内结算后记录,避免状态读到本次到达导致重复结算误判 + 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 + } + 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_play_test.go new file mode 100644 index 0000000..88d83cf --- /dev/null +++ b/biz/service/level_play_test.go @@ -0,0 +1,135 @@ +package service + +import ( + "testing" + + "36wisdom/biz/consts" +) + +func state() SettleState { + return SettleState{ReachedFinals: map[int64]bool{}, TotalFinals: 5, BalanceAfter: 100} +} + +func TestSettleFinal_BestFirstTime(t *testing.T) { + f := SettleFinal(state(), 101, consts.ResultBest) + if f.ScoreDelta != consts.PointsBest { + t.Fatalf("首次最佳应 +%d,实际 %d", consts.PointsBest, f.ScoreDelta) + } + if !f.Cleared { + t.Fatal("到达最佳终局应通关") + } + if f.Stars != 3 { + t.Fatalf("最佳应 3 星,实际 %d", f.Stars) + } + if f.Perfect { + t.Fatal("仅一个终局不应完美") + } + if f.BalanceAfter != 130 { + t.Fatalf("余额应 130,实际 %d", f.BalanceAfter) + } +} + +func TestSettleFinal_GoodFirstTime(t *testing.T) { + f := SettleFinal(state(), 101, consts.ResultGood) + if f.ScoreDelta != consts.PointsGood { + t.Fatalf("首次良好应 +%d,实际 %d", consts.PointsGood, f.ScoreDelta) + } + if f.Cleared { + t.Fatal("良好终局不应通关") + } + if f.Stars != 2 { + t.Fatalf("良好应 2 星,实际 %d", f.Stars) + } +} + +func TestSettleFinal_FailFirstTime(t *testing.T) { + f := SettleFinal(state(), 101, consts.ResultFail) + if f.ScoreDelta != consts.PointsFail { + t.Fatalf("首次失败应 %d,实际 %d", consts.PointsFail, f.ScoreDelta) + } + if f.Stars != 0 { + t.Fatalf("失败应 0 星,实际 %d", f.Stars) + } +} + +func TestSettleFinal_FailStreakLimit(t *testing.T) { + // 连续失败 2 次后不再扣分:第 3 次到达失败终局 delta=0 + s := SettleState{FailStreak: 2, ReachedFinals: map[int64]bool{1: true, 2: true}, TotalFinals: 5, BalanceAfter: 100} + f := SettleFinal(s, 3, consts.ResultFail) + if f.ScoreDelta != 0 { + t.Fatalf("连续失败第 3 次应不再扣分,实际 %d", f.ScoreDelta) + } + // 连续失败第 2 次仍扣 + s2 := SettleState{FailStreak: 1, ReachedFinals: map[int64]bool{1: true}, TotalFinals: 5, BalanceAfter: 100} + f2 := SettleFinal(s2, 2, consts.ResultFail) + if f2.ScoreDelta != consts.PointsFail { + t.Fatalf("连续失败第 2 次应扣 %d,实际 %d", consts.PointsFail, f2.ScoreDelta) + } +} + +func TestSettleFinal_BalanceFloor(t *testing.T) { + s := SettleState{ReachedFinals: map[int64]bool{}, TotalFinals: 5, BalanceAfter: 5} + f := SettleFinal(s, 1, consts.ResultFail) + if f.ScoreDelta != -5 { + t.Fatalf("余额不足应扣到 0(-5),实际 %d", f.ScoreDelta) + } + if f.BalanceAfter != 0 { + t.Fatalf("余额应 0,实际 %d", f.BalanceAfter) + } +} + +func TestSettleFinal_ClearedNoSettle(t *testing.T) { + // 已通关后补分支:只记完成度不结算积分 + s := SettleState{LevelCleared: true, ReachedFinals: map[int64]bool{101: true}, TotalFinals: 5, BalanceAfter: 130} + f := SettleFinal(s, 102, consts.ResultGood) + if f.ScoreDelta != 0 { + t.Fatalf("已通关补分支应不结算,实际 %d", f.ScoreDelta) + } +} + +func TestSettleFinal_Perfect(t *testing.T) { + s := SettleState{ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true}, TotalFinals: 5, BalanceAfter: 100} + f := SettleFinal(s, 5, consts.ResultGood) + if !f.Perfect { + t.Fatal("全部终局到达应完美") + } + if f.ScoreDelta != consts.PointsGood+consts.PointsPerfect { + t.Fatalf("应 +%d(良好+完美),实际 %d", consts.PointsGood+consts.PointsPerfect, f.ScoreDelta) + } +} + +func TestSettleFinal_PerfectOnlyOnce(t *testing.T) { + // 已完美(进度表标记)后重复到达:不再触发完美奖励 + s := SettleState{LevelCleared: true, PerfectAwarded: true, ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true, 5: true}, TotalFinals: 5, BalanceAfter: 100} + f := SettleFinal(s, 5, consts.ResultBest) + if f.Perfect { + t.Fatal("已完美不应重复触发") + } + if f.ScoreDelta != 0 { + t.Fatalf("重复到达应不结算,实际 %d", f.ScoreDelta) + } +} + +func TestSettleFinal_PerfectWithLogDrift(t *testing.T) { + // 日志已集齐但进度未发放(日志尽力而为可能漂移):本次到达应补发完美奖励 + s := SettleState{LevelCleared: true, ReachedFinals: map[int64]bool{1: true, 2: true, 3: true, 4: true, 5: true}, TotalFinals: 5, BalanceAfter: 100} + f := SettleFinal(s, 5, consts.ResultGood) + if !f.Perfect { + t.Fatal("日志集齐但未发放时应补发完美") + } + if f.ScoreDelta != consts.PointsPerfect { + t.Fatalf("应仅 +%d 完美奖励,实际 %d", consts.PointsPerfect, f.ScoreDelta) + } +} + +func TestSettleFinal_RepeatFinal(t *testing.T) { + // 重复路线不重复结算 + s := SettleState{ReachedFinals: map[int64]bool{101: true}, TotalFinals: 5, BalanceAfter: 100} + f := SettleFinal(s, 101, consts.ResultBest) + if f.ScoreDelta != 0 { + t.Fatalf("重复终局应不结算,实际 %d", f.ScoreDelta) + } + if !f.Cleared { + t.Fatal("重复到达最佳仍应标记通关") + } +} diff --git a/biz/service/strategy.go b/biz/service/strategy.go index 7e86cea..2e53b49 100644 --- a/biz/service/strategy.go +++ b/biz/service/strategy.go @@ -232,10 +232,10 @@ func (s *strategy) unlockState(ctx context.Context, childId int64, childAge stri return false, fmt.Sprintf("完成《%s》全部关卡解锁", prevName) } -// listAll 全部启用计策(内容缓存)。 +// listAll 全部启用计策(内容缓存),按分组 + 组内序号排序。 func (s *strategy) listAll(ctx context.Context) ([]gdb.Record, error) { recs, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)). - Where("status", consts.StatusEnabled).Order("sort_order ASC").All() + Where("status", consts.StatusEnabled).Order("group_no ASC, sort_order ASC").All() if err != nil { return nil, err } diff --git a/common/lock.go b/common/lock.go new file mode 100644 index 0000000..86c14ee --- /dev/null +++ b/common/lock.go @@ -0,0 +1,63 @@ +package common + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcache" +) + +// ErrLockHeld 锁被占用:重试 retries 次仍未获取时返回。 +var ErrLockHeld = gerror.New("操作过于频繁,请稍后再试") + +// WithLock 业务互斥锁(唯一入口): +// - expire 必须 > 0,进程崩溃后锁自动过期兜底不死锁;fn 耗时必须小于 expire,fn 内禁止长耗时 IO +// - 拿不到锁重试 retries 次、每次间隔 retryInterval(retries=0 立即失败;ctx 取消/超时同样终止等待) +// - 中间件故障不重试,直接返回错误 +// - 锁实现按配置自动选择:配置了 redis 节点 → redis 锁(跨实例互斥);未配置 → gcache 内存锁(单实例互斥)。 +// redis 后端需引入 github.com/gogf/gf/contrib/redis/v2 适配器并接入 SET NX EX + token 对比删除; +// 当前未配置 redis 时使用内存锁。 +// - 无论 fn 成功、失败还是 panic,锁在函数退出时自动释放 +func WithLock[T any](ctx context.Context, key string, expire time.Duration, retries int, retryInterval time.Duration, fn func() (T, error)) (T, error) { + var zero T + if expire <= 0 { + return zero, gerror.New("锁过期时间必须大于 0") + } + if lockBackendIsRedis(ctx) { + return zero, gerror.New("配置了 redis 锁后端,但当前未接入 contrib/redis 适配器") + } + token := randomToken() + for attempt := 0; ; attempt++ { + ok, err := gcache.SetIfNotExist(ctx, key, token, expire) + if err != nil { + return zero, err + } + if ok { + defer func() { _, _ = gcache.Remove(ctx, key) }() + return fn() + } + if attempt >= retries { + return zero, ErrLockHeld + } + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(retryInterval): + } + } +} + +func lockBackendIsRedis(ctx context.Context) bool { + v, err := g.Cfg().Get(ctx, "redis", nil) + return err == nil && v != nil && !v.IsEmpty() +} + +func randomToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +}