789 lines
23 KiB
Go
789 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"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/biz/model/domain"
|
|
"36wisdom/biz/model/dto"
|
|
"36wisdom/biz/model/entity"
|
|
"36wisdom/common"
|
|
"36wisdom/common/auth"
|
|
)
|
|
|
|
type level struct{}
|
|
|
|
var Level = &level{}
|
|
|
|
// 成长等级:按完美关卡数映射称号(技术设计.md 4.10,派生值不落库)。
|
|
var levelTitles = []struct {
|
|
MinPerfect int
|
|
Title string
|
|
}{
|
|
{MinPerfect: 0, Title: "小学徒"},
|
|
{MinPerfect: 5, Title: "小书童"},
|
|
{MinPerfect: 10, Title: "小军师"},
|
|
{MinPerfect: 20, Title: "军师"},
|
|
{MinPerfect: 30, Title: "大将军"},
|
|
}
|
|
|
|
// LevelOf 按完美关卡数返回等级(1-5)与称号。
|
|
func LevelOf(perfectCount int) (level int, title string) {
|
|
level = 1
|
|
title = levelTitles[0].Title
|
|
for i, lt := range levelTitles {
|
|
if perfectCount >= lt.MinPerfect {
|
|
level = i + 1
|
|
title = lt.Title
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Detail 关卡详情:节点/选项/元素分表取回,内存组装决策树(入口节点)。
|
|
func (s *level) Detail(ctx context.Context, req *dto.LevelDetailReq) (*dto.LevelDetailRes, error) {
|
|
child, err := getChildOf(ctx, auth.GetUid(ctx), req.ChildId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
levelRec, err := dao.Level.GetByPkCached(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if levelRec == nil || levelRec.Status != consts.StatusEnabled {
|
|
return nil, gerror.New("关卡不存在")
|
|
}
|
|
if levelRec.AgeGroup != child.AgeGroup {
|
|
return nil, gerror.New("关卡不属于当前年龄段")
|
|
}
|
|
|
|
nodeRecs, err := dao.SceneNode.ListEnabledByLevelCached(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nodeIds := make([]int64, 0, len(nodeRecs))
|
|
for _, n := range nodeRecs {
|
|
nodeIds = append(nodeIds, n.Id)
|
|
}
|
|
|
|
var optionRecs []*entity.NodeOption
|
|
if len(nodeIds) > 0 {
|
|
optionRecs, err = dao.NodeOption.ListEnabledByNodeIdsCached(ctx, nodeIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
elements, err := elementsOf(ctx, levelRec, nodeRecs, optionRecs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
progressRec, err := dao.UserProgress.GetByChildLevel(ctx, req.ChildId, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entryRec *entity.SceneNode
|
|
totalFinals := 0
|
|
for _, n := range nodeRecs {
|
|
if n.IsEntry == 1 {
|
|
entryRec = n
|
|
}
|
|
if n.ResultType > 0 {
|
|
totalFinals++
|
|
}
|
|
}
|
|
if entryRec == nil {
|
|
return nil, gerror.New("关卡入口缺失")
|
|
}
|
|
|
|
res := &dto.LevelDetailRes{
|
|
LevelId: levelRec.Id,
|
|
Title: levelRec.Title,
|
|
Scene: elements[levelRec.SceneId],
|
|
SceneContent: levelRec.SceneContent,
|
|
SceneContentPinyin: levelRec.SceneContentPinyin,
|
|
SceneImage: levelRec.SceneImage,
|
|
SceneAudio: levelRec.SceneAudio,
|
|
Entry: *buildNode(entryRec, optionRecs, elements),
|
|
TotalFinals: totalFinals,
|
|
}
|
|
if progressRec != nil {
|
|
res.Perfect = progressRec.Perfect == 1
|
|
res.Stars = progressRec.Stars
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。
|
|
func elementsOf(ctx context.Context, levelRec *entity.Level, nodeRecs []*entity.SceneNode, optionRecs []*entity.NodeOption) (map[int64]*dto.ElementVO, error) {
|
|
ids := []int64{levelRec.SceneId}
|
|
for _, n := range nodeRecs {
|
|
if cid := n.CharacterId; cid > 0 {
|
|
ids = append(ids, cid)
|
|
}
|
|
}
|
|
for _, o := range optionRecs {
|
|
if pid := o.PropId; pid > 0 {
|
|
ids = append(ids, pid)
|
|
}
|
|
}
|
|
ids = uniqueInt64(ids)
|
|
|
|
m := make(map[int64]*dto.ElementVO, len(ids))
|
|
if len(ids) == 0 {
|
|
return m, nil
|
|
}
|
|
recs, err := dao.Element.ListEnabledByIdsCached(ctx, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range recs {
|
|
m[r.Id] = &dto.ElementVO{
|
|
Id: r.Id,
|
|
EType: r.EType,
|
|
Name: r.Name,
|
|
NamePinyin: r.NamePinyin,
|
|
Image: r.Image,
|
|
Audio: r.Audio,
|
|
Description: r.Description,
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// buildNode 组装单个节点的选项与人物/道具元素。
|
|
func buildNode(nodeRec *entity.SceneNode, optionRecs []*entity.NodeOption, elements map[int64]*dto.ElementVO) *dto.NodeVO {
|
|
node := &dto.NodeVO{
|
|
NodeId: nodeRec.Id,
|
|
Title: nodeRec.Title,
|
|
Content: nodeRec.Content,
|
|
ContentPinyin: nodeRec.ContentPinyin,
|
|
Image: nodeRec.Image,
|
|
Audio: nodeRec.Audio,
|
|
Character: elements[nodeRec.CharacterId],
|
|
InteractionType: nodeRec.InteractionType,
|
|
Config: nodeRec.Config,
|
|
Script: nodeRec.Script,
|
|
NodeType: nodeRec.NodeType,
|
|
ResultType: nodeRec.ResultType,
|
|
Options: make([]dto.OptionVO, 0),
|
|
}
|
|
for _, o := range optionRecs {
|
|
if o.NodeId != node.NodeId {
|
|
continue
|
|
}
|
|
node.Options = append(node.Options, dto.OptionVO{
|
|
OptionId: o.Id,
|
|
Text: o.Text,
|
|
TextPinyin: o.TextPinyin,
|
|
FeedbackPros: o.FeedbackPros,
|
|
FeedbackCons: o.FeedbackCons,
|
|
Audio: o.Audio,
|
|
Prop: elements[o.PropId],
|
|
})
|
|
}
|
|
return node
|
|
}
|
|
|
|
// SettleFinal 纯函数判分(技术设计 4.3/4.4,可单测):
|
|
// - 首次到达某终局才结算,重复到达 delta=0
|
|
// - 未通关:最佳 +30 / 良好 +10 / 失败 -10(连续失败 ≥2 次后不再扣;余额扣至 0 不为负)
|
|
// - 已通关后补分支:只记完成度不结算积分
|
|
// - 终局数集齐 → 完美(+20);最佳终局 → 通关
|
|
func SettleFinal(s domain.SettleState, finalNodeId int64, resultType int) dto.FinalSettle {
|
|
f := dto.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, req *dto.ChooseReq) (*dto.ChooseRes, error) {
|
|
child, err := getChildOf(ctx, auth.GetUid(ctx), req.ChildId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
levelRec, err := dao.Level.GetByPkCached(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if levelRec == nil || levelRec.Status != consts.StatusEnabled {
|
|
return nil, gerror.New("关卡不存在")
|
|
}
|
|
if levelRec.AgeGroup != child.AgeGroup {
|
|
return nil, gerror.New("关卡不属于当前年龄段")
|
|
}
|
|
|
|
nodeRec, err := dao.SceneNode.GetInLevelCached(ctx, req.NodeId, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if nodeRec == nil {
|
|
return nil, gerror.New("节点不存在")
|
|
}
|
|
|
|
optionRec, err := dao.NodeOption.GetInNodeCached(ctx, req.OptionId, req.NodeId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if optionRec == nil {
|
|
return nil, gerror.New("选项不存在")
|
|
}
|
|
|
|
nextRec, err := dao.SceneNode.GetInLevelCached(ctx, optionRec.NextNodeId, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if nextRec == nil {
|
|
return nil, gerror.New("目标节点不存在")
|
|
}
|
|
|
|
progressRec, err := dao.UserProgress.GetByChildLevel(ctx, req.ChildId, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !s.levelUnlocked(ctx, req.ChildId, child, levelRec, progressRec) {
|
|
return nil, gerror.New("关卡未解锁")
|
|
}
|
|
|
|
if nextRec.ResultType == consts.ResultNone {
|
|
s.logRoute(ctx, req.ChildId, req.LevelId, req.NodeId, req.OptionId, consts.ResultNone)
|
|
node, err := nodeOf(ctx, levelRec, nextRec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &dto.ChooseRes{Next: node}, nil
|
|
}
|
|
|
|
// v2.1:终局节点剧本先于结算构建(只读查询失败则未结算,无副作用)
|
|
finalNode, err := nodeOf(ctx, levelRec, nextRec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 终局:路径流水在锁内结算后记录,避免状态读到本次到达导致重复结算误判
|
|
settle, err := common.WithLock(ctx, fmt.Sprintf("child:%d:level:%d", req.ChildId, req.LevelId), 10*time.Second, 3, 200*time.Millisecond, func() (*dto.FinalSettle, error) {
|
|
return s.settle(ctx, child, levelRec, nextRec, req.NodeId, req.OptionId)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
settle.FinalNode = finalNode
|
|
return &dto.ChooseRes{Final: settle}, nil
|
|
}
|
|
|
|
// levelUnlocked 关卡解锁判定:本关已通关可重玩,否则按解锁链校验计策解锁。
|
|
func (s *level) levelUnlocked(ctx context.Context, childId int64, child *entity.Child, levelRec *entity.Level, progressRec *entity.UserProgress) bool {
|
|
if progressRec != nil && progressRec.Stars >= 3 {
|
|
return true
|
|
}
|
|
strategyRec, err := dao.Strategy.GetByPkCached(ctx, levelRec.StrategyId)
|
|
if err != nil || strategyRec == nil {
|
|
return false
|
|
}
|
|
unlocked, _ := Strategy.unlockState(ctx, childId, child.AgeGroup, strategyRec)
|
|
return unlocked
|
|
}
|
|
|
|
// logRoute 路径流水(尽力而为,写失败仅记录日志不阻断主流程)。
|
|
func (s *level) logRoute(ctx context.Context, childId, levelId, nodeId, optionId int64, resultType int) {
|
|
if _, err := dao.UserRouteLog.InsertAndReturnId(ctx, g.Map{
|
|
"child_id": childId,
|
|
"level_id": levelId,
|
|
"node_id": nodeId,
|
|
"option_id": optionId,
|
|
"result_type": resultType,
|
|
}); err != nil {
|
|
g.Log().Warning(ctx, "记录路径流水失败", err)
|
|
}
|
|
}
|
|
|
|
// nodeOf 组装下一决策节点(选项 + 元素)。
|
|
func nodeOf(ctx context.Context, levelRec *entity.Level, nodeRec *entity.SceneNode) (*dto.NodeVO, error) {
|
|
optionRecs, err := dao.NodeOption.ListEnabledByNodeCached(ctx, nodeRec.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
elements, err := elementsOf(ctx, levelRec, []*entity.SceneNode{nodeRec}, optionRecs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildNode(nodeRec, optionRecs, elements), nil
|
|
}
|
|
|
|
// settle 终局结算(调用方持锁):锁内重读状态 → 纯函数判分 → 事务写库 → 记录本次终局路径 → 派生结果。
|
|
func (s *level) settle(ctx context.Context, child *entity.Child, levelRec *entity.Level, finalRec *entity.SceneNode, nodeId, optionId int64) (*dto.FinalSettle, error) {
|
|
childId := child.Id
|
|
levelId := levelRec.Id
|
|
|
|
state, err := s.settleState(ctx, childId, levelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := SettleFinal(state, finalRec.Id, finalRec.ResultType)
|
|
|
|
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.ResultType)
|
|
s.deriveExtras(ctx, child, levelRec, &f)
|
|
return &f, nil
|
|
}
|
|
|
|
// settleState 锁内重读该关历史状态:终局数、已到达终局、连续失败、余额。
|
|
func (s *level) settleState(ctx context.Context, childId, levelId int64) (domain.SettleState, error) {
|
|
state := domain.SettleState{ReachedFinals: map[int64]bool{}}
|
|
|
|
progressRec, err := dao.UserProgress.GetByChildLevel(ctx, childId, levelId)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
if progressRec != nil {
|
|
state.LevelCleared = progressRec.Stars >= 3
|
|
state.PerfectAwarded = progressRec.Perfect == 1
|
|
}
|
|
|
|
childRec, err := dao.Child.GetByPk(ctx, childId)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
if childRec != nil {
|
|
state.BalanceAfter = childRec.Points
|
|
}
|
|
|
|
totalFinals, err := dao.SceneNode.CountFinalsByLevel(ctx, levelId)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
state.TotalFinals = totalFinals
|
|
|
|
logs, err := dao.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.OptionId)
|
|
}
|
|
nextOf := map[int64]int64{}
|
|
if len(optionIds) > 0 {
|
|
opts, err := dao.NodeOption.ListByOptionIds(ctx, optionIds)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
for _, o := range opts {
|
|
nextOf[o.Id] = o.NextNodeId
|
|
}
|
|
}
|
|
for _, l := range logs {
|
|
if nid, ok := nextOf[l.OptionId]; ok {
|
|
state.ReachedFinals[nid] = true
|
|
}
|
|
}
|
|
for i := len(logs) - 1; i >= 0; i-- {
|
|
if logs[i].ResultType == consts.ResultFail {
|
|
state.FailStreak++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// commitSettle 结算事务:进度(最高星 + 完美标记 + 内容版本)、积分流水与余额、计策卡。
|
|
func (s *level) commitSettle(ctx context.Context, childId int64, levelRec *entity.Level, f *dto.FinalSettle) error {
|
|
levelId := levelRec.Id
|
|
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 := dao.UserProgress.UpsertInTx(ctx, tx, childId, levelId, stars, perfect, levelRec.ContentVersion); err != nil {
|
|
return err
|
|
}
|
|
|
|
if f.ScoreDelta != 0 {
|
|
childRec, err := dao.Child.GetByPkInTx(ctx, tx, childId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if childRec == nil {
|
|
return gerror.New("孩子不存在")
|
|
}
|
|
balance := childRec.Points + 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 *entity.Level, f *dto.FinalSettle) error {
|
|
if !f.Perfect {
|
|
return nil
|
|
}
|
|
strategyId := levelRec.StrategyId
|
|
levels, err := dao.Level.ListEnabledByStrategyInTx(ctx, tx, strategyId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, lv := range levels {
|
|
rec, err := dao.UserProgress.GetInTx(ctx, tx, childId, lv.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil || rec.Perfect != 1 {
|
|
return nil
|
|
}
|
|
}
|
|
exists, err := dao.UserCollection.ExistsInTx(ctx, tx, childId, strategyId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return nil
|
|
}
|
|
if err = dao.UserCollection.InsertInTx(ctx, tx, childId, strategyId); err != nil {
|
|
return err
|
|
}
|
|
f.CollectionUnlocked = true
|
|
return nil
|
|
}
|
|
|
|
// deriveExtras 结算后派生:计策卡解锁提示、下一计解锁、成长等级。
|
|
func (s *level) deriveExtras(ctx context.Context, child *entity.Child, levelRec *entity.Level, f *dto.FinalSettle) {
|
|
childId := child.Id
|
|
|
|
if f.CollectionUnlocked {
|
|
if next, err := dao.Strategy.GetByUnlockBefore(ctx, levelRec.StrategyId); err == nil && next != nil {
|
|
f.UnlockNext = true
|
|
}
|
|
}
|
|
|
|
before, err := dao.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
|
|
}
|
|
}
|
|
|
|
// ---------- 后台管理 ----------
|
|
|
|
type adminLevel struct{}
|
|
|
|
var AdminLevel = &adminLevel{}
|
|
|
|
// List 指定计策下全部关卡(含下架,后台全量展示),按序号排序。
|
|
func (s *adminLevel) List(ctx context.Context, req *dto.AdminLevelListReq) (*dto.AdminLevelListRes, error) {
|
|
recs, err := dao.Level.ListByStrategyId(ctx, req.StrategyId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int64, 0, len(recs))
|
|
sceneIds := make([]int64, 0, len(recs))
|
|
for _, r := range recs {
|
|
ids = append(ids, r.Id)
|
|
sceneIds = append(sceneIds, r.SceneId)
|
|
}
|
|
counts, err := dao.SceneNode.CountByLevelIds(ctx, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names, err := sceneNames(ctx, sceneIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]*dto.AdminLevelItem, 0, len(recs))
|
|
for _, r := range recs {
|
|
items = append(items, &dto.AdminLevelItem{
|
|
Id: r.Id, StrategyId: r.StrategyId, Title: r.Title,
|
|
SceneId: r.SceneId, SceneName: names[r.SceneId],
|
|
SceneContent: r.SceneContent, SceneImage: r.SceneImage,
|
|
SceneAudio: r.SceneAudio, AgeGroup: r.AgeGroup,
|
|
ContentVersion: r.ContentVersion, SortOrder: r.SortOrder,
|
|
Status: r.Status, NodeCount: counts[r.Id],
|
|
})
|
|
}
|
|
return &dto.AdminLevelListRes{List: items}, nil
|
|
}
|
|
|
|
// sceneNames 批量元素名(含下架,后台全量展示)。
|
|
func sceneNames(ctx context.Context, ids []int64) (map[int64]string, error) {
|
|
m := make(map[int64]string, len(ids))
|
|
ids = uniqueInt64(ids)
|
|
if len(ids) == 0 {
|
|
return m, nil
|
|
}
|
|
recs, err := dao.Element.ListByIds(ctx, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range recs {
|
|
m[r.Id] = r.Name
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// checkStrategy 计策存在性校验(含下架,历史关卡允许继续维护)。
|
|
func checkStrategy(ctx context.Context, strategyId int64) error {
|
|
rec, err := dao.Strategy.GetByPk(ctx, strategyId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil {
|
|
return gerror.New("计策不存在")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Create 新增关卡:场景文案拼音标注,初始 content_version=1,清内容缓存。
|
|
func (s *adminLevel) Create(ctx context.Context, req *dto.AdminLevelCreateReq) (*dto.AdminLevelCreateRes, error) {
|
|
if err := checkStrategy(ctx, req.StrategyId); err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := dao.Level.InsertAndReturnId(ctx, g.Map{
|
|
"strategy_id": req.StrategyId, "title": req.Title,
|
|
"scene_id": req.SceneId, "scene_content": req.SceneContent,
|
|
"scene_content_pinyin": common.AnnotatePinyin(req.SceneContent),
|
|
"scene_image": req.SceneImage, "scene_audio": req.SceneAudio,
|
|
"age_group": req.AgeGroup, "content_version": 1,
|
|
"sort_order": req.SortOrder, "status": consts.StatusEnabled,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
common.InvalidateContentCache(ctx, consts.TableLevel)
|
|
return &dto.AdminLevelCreateRes{Id: id}, nil
|
|
}
|
|
|
|
// Update 编辑关卡:全字段覆盖,拼音重标,content_version+1(前台据此可重新挑战),清内容缓存。
|
|
func (s *adminLevel) Update(ctx context.Context, req *dto.AdminLevelUpdateReq) (*dto.AdminLevelUpdateRes, error) {
|
|
rec, err := dao.Level.GetByPk(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rec == nil {
|
|
return nil, gerror.New("关卡不存在")
|
|
}
|
|
if err = checkStrategy(ctx, req.StrategyId); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = dao.Level.UpdateByPk(ctx, req.Id, g.Map{
|
|
"strategy_id": req.StrategyId, "title": req.Title,
|
|
"scene_id": req.SceneId, "scene_content": req.SceneContent,
|
|
"scene_content_pinyin": common.AnnotatePinyin(req.SceneContent),
|
|
"scene_image": req.SceneImage, "scene_audio": req.SceneAudio,
|
|
"age_group": req.AgeGroup, "sort_order": req.SortOrder,
|
|
"content_version": gdb.Raw("content_version+1"),
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
common.InvalidateContentCache(ctx, consts.TableLevel)
|
|
return &dto.AdminLevelUpdateRes{}, nil
|
|
}
|
|
|
|
// Disable 下架关卡(软删除):内容可见性变化同样 bump content_version。
|
|
func (s *adminLevel) Disable(ctx context.Context, req *dto.AdminLevelDisableReq) (*dto.AdminLevelDisableRes, error) {
|
|
if err := s.setStatus(ctx, req.Id, consts.StatusDisabled); err != nil {
|
|
return nil, err
|
|
}
|
|
return &dto.AdminLevelDisableRes{}, nil
|
|
}
|
|
|
|
// Enable 上架关卡。
|
|
func (s *adminLevel) Enable(ctx context.Context, req *dto.AdminLevelEnableReq) (*dto.AdminLevelEnableRes, error) {
|
|
if err := s.setStatus(ctx, req.Id, consts.StatusEnabled); err != nil {
|
|
return nil, err
|
|
}
|
|
return &dto.AdminLevelEnableRes{}, nil
|
|
}
|
|
|
|
// setStatus 上下架(软删除):bump content_version。
|
|
func (s *adminLevel) setStatus(ctx context.Context, id int64, status int) error {
|
|
rec, err := dao.Level.GetByPk(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec == nil {
|
|
return gerror.New("关卡不存在")
|
|
}
|
|
if err = dao.Level.UpdateByPk(ctx, id, g.Map{
|
|
"status": status,
|
|
"content_version": gdb.Raw("content_version+1"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
common.InvalidateContentCache(ctx, consts.TableLevel)
|
|
return nil
|
|
}
|
|
|
|
// ---------- 关卡统计 ----------
|
|
|
|
type optKey struct{ nodeId, optionId int64 }
|
|
|
|
type adminStats struct{}
|
|
|
|
var AdminStats = &adminStats{}
|
|
|
|
// Level 关卡统计:5 条单表 SQL + 内存组装,不缓存。
|
|
func (s *adminStats) Level(ctx context.Context, req *dto.AdminStatsLevelReq) (*dto.AdminStatsLevelRes, error) {
|
|
levelRec, err := dao.Level.GetByPk(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if levelRec == nil {
|
|
return nil, gerror.New("关卡不存在")
|
|
}
|
|
|
|
players, perfectCount, err := dao.UserProgress.ProgressStatsByLevel(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats := &dto.AdminStatsLevelRes{Players: players, PerfectCount: perfectCount}
|
|
if players > 0 {
|
|
stats.PerfectRate = math.Round(float64(perfectCount)*1000/float64(players)) / 10
|
|
}
|
|
|
|
nodes, err := dao.SceneNode.ListByLevel(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nodeIds := make([]int64, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
nodeIds = append(nodeIds, n.Id)
|
|
}
|
|
opts, err := dao.NodeOption.ListByNodeIds(ctx, nodeIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
optionByKey := make(map[optKey]*dto.AdminStatsOption, len(opts))
|
|
for _, o := range opts {
|
|
os := &dto.AdminStatsOption{OptionId: o.Id, NodeId: o.NodeId, Text: o.Text}
|
|
stats.Options = append(stats.Options, os)
|
|
optionByKey[optKey{os.NodeId, os.OptionId}] = os
|
|
}
|
|
|
|
logs, err := dao.UserRouteLog.RouteStatsByLevel(ctx, req.LevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nodeById := make(map[int64]*dto.AdminStatsNode, len(nodes))
|
|
for _, l := range logs {
|
|
cnt := l.Cnt
|
|
ns := nodeById[l.NodeId]
|
|
if ns == nil {
|
|
ns = &dto.AdminStatsNode{NodeId: l.NodeId}
|
|
nodeById[l.NodeId] = ns
|
|
}
|
|
ns.ReachCount += cnt
|
|
switch l.ResultType {
|
|
case consts.ResultNone:
|
|
ns.None += cnt
|
|
case consts.ResultFail:
|
|
ns.Fail += cnt
|
|
case consts.ResultGood:
|
|
ns.Good += cnt
|
|
case consts.ResultBest:
|
|
ns.Best += cnt
|
|
}
|
|
if l.OptionId > 0 {
|
|
if os := optionByKey[optKey{l.NodeId, l.OptionId}]; os != nil {
|
|
switch l.ResultType {
|
|
case consts.ResultNone:
|
|
os.ChooseCount += cnt
|
|
case consts.ResultFail:
|
|
os.Fail += cnt
|
|
case consts.ResultGood:
|
|
os.Good += cnt
|
|
case consts.ResultBest:
|
|
os.Best += cnt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, n := range nodes {
|
|
ns := nodeById[n.Id]
|
|
if ns == nil {
|
|
ns = &dto.AdminStatsNode{NodeId: n.Id}
|
|
}
|
|
ns.Content = n.Content
|
|
ns.NodeType = n.NodeType
|
|
stats.Nodes = append(stats.Nodes, ns)
|
|
}
|
|
return stats, nil
|
|
}
|