feat: 分支闯关判分与终局结算(含单测)

choose 校验节点/选项归属与解锁链,终局在 common.WithLock 内按
技术设计 4.3/4.4 结算:首次到达才结算、连续失败限扣、余额不为负、
完美集齐全终局 +20 并解锁计策卡与下一计;路径流水尽力而为。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:47:01 +08:00
co-authored by Claude Opus 4.7
parent d2887bf8a3
commit 414c7de007
8 changed files with 691 additions and 5 deletions
+39
View File
@@ -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
}
+1 -1
View File
@@ -24,7 +24,7 @@ func Register(s *ghttp.Server) {
g.Group("/", func(g *ghttp.RouterGroup) { g.Group("/", func(g *ghttp.RouterGroup) {
g.Middleware(auth.Middleware(secret, "")) g.Middleware(auth.Middleware(secret, ""))
g.Bind(Child, Strategy, Level) g.Bind(Child, Strategy, Level, LevelPlay)
}) })
}) })
} }
+28
View File
@@ -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"`
}
+2 -2
View File
@@ -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 { if err != nil {
return nil, err return nil, err
} }
@@ -164,7 +164,7 @@ func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (
} }
// elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。 // 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()} ids := []int64{levelRec["scene_id"].Int64()}
for _, n := range nodeRecs { for _, n := range nodeRecs {
if cid := n["character_id"].Int64(); cid > 0 { if cid := n["character_id"].Int64(); cid > 0 {
+421
View File
@@ -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
}
}
+135
View File
@@ -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("重复到达最佳仍应标记通关")
}
}
+2 -2
View File
@@ -232,10 +232,10 @@ func (s *strategy) unlockState(ctx context.Context, childId int64, childAge stri
return false, fmt.Sprintf("完成《%s》全部关卡解锁", prevName) return false, fmt.Sprintf("完成《%s》全部关卡解锁", prevName)
} }
// listAll 全部启用计策(内容缓存)。 // listAll 全部启用计策(内容缓存),按分组 + 组内序号排序
func (s *strategy) listAll(ctx context.Context) ([]gdb.Record, error) { func (s *strategy) listAll(ctx context.Context) ([]gdb.Record, error) {
recs, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)). 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 { if err != nil {
return nil, err return nil, err
} }
+63
View File
@@ -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 次、每次间隔 retryIntervalretries=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)
}