feat: 分支闯关判分与终局结算(含单测)
choose 校验节点/选项归属与解锁链,终局在 common.WithLock 内按 技术设计 4.3/4.4 结算:首次到达才结算、连续失败限扣、余额不为负、 完美集齐全终局 +20 并解锁计策卡与下一计;路径流水尽力而为。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("重复到达最佳仍应标记通关")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user