feat: 内容接口(计策/关卡/元素,含缓存与解锁判定)

计策列表/详情、关卡详情(决策树内存组装),内容查询走缓存,
按孩子年龄段过滤关卡,解锁按前置计全部关卡完美判定。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:26:59 +08:00
co-authored by Claude Opus 4.7
parent 0273a81ca7
commit d2887bf8a3
8 changed files with 792 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
package controller
import (
"context"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
"36wisdom/common/auth"
)
type level struct{}
var Level = &level{}
func (c *level) Detail(ctx context.Context, req *dto.LevelDetailReq) (*dto.LevelDetailRes, error) {
detail, err := service.Level.Detail(ctx, auth.GetUid(ctx), req.ChildId, req.LevelId)
if err != nil {
return nil, err
}
res := &dto.LevelDetailRes{
LevelId: detail.LevelId,
Title: detail.Title,
Scene: elementVO(detail.Scene),
SceneContent: detail.SceneContent,
SceneImage: detail.SceneImage,
SceneAudio: detail.SceneAudio,
Entry: nodeVO(detail.Entry),
TotalFinals: detail.TotalFinals,
Perfect: detail.Perfect,
Stars: detail.Stars,
}
return res, nil
}
func elementVO(e *service.Element) *dto.ElementVO {
if e == nil {
return nil
}
return &dto.ElementVO{
Id: e.Id,
EType: e.EType,
Name: e.Name,
Image: e.Image,
Audio: e.Audio,
Description: e.Description,
}
}
func nodeVO(n *service.Node) dto.NodeVO {
vo := dto.NodeVO{
NodeId: n.NodeId,
Title: n.Title,
Content: n.Content,
Image: n.Image,
Audio: n.Audio,
Character: elementVO(n.Character),
InteractionType: n.InteractionType,
Config: n.Config,
NodeType: n.NodeType,
ResultType: n.ResultType,
Options: make([]dto.OptionVO, 0, len(n.Options)),
}
for _, o := range n.Options {
vo.Options = append(vo.Options, dto.OptionVO{
OptionId: o.OptionId,
Text: o.Text,
Prop: elementVO(o.Prop),
})
}
return vo
}
+1 -1
View File
@@ -24,7 +24,7 @@ func Register(s *ghttp.Server) {
g.Group("/", func(g *ghttp.RouterGroup) {
g.Middleware(auth.Middleware(secret, ""))
g.Bind(Child)
g.Bind(Child, Strategy, Level)
})
})
}
+73
View File
@@ -0,0 +1,73 @@
package controller
import (
"context"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
"36wisdom/common/auth"
)
type strategy struct{}
var Strategy = &strategy{}
func (c *strategy) List(ctx context.Context, req *dto.StrategyListReq) (*dto.StrategyListRes, error) {
items, err := service.Strategy.List(ctx, auth.GetUid(ctx), req.ChildId)
if err != nil {
return nil, err
}
res := &dto.StrategyListRes{List: make([]dto.StrategyItem, 0, len(items))}
for _, it := range items {
res.List = append(res.List, dto.StrategyItem{
StrategyId: it.StrategyId,
Name: it.Name,
Pinyin: it.Pinyin,
GroupNo: it.GroupNo,
GroupName: it.GroupName,
Meaning: it.Meaning,
Icon: it.Icon,
SortOrder: it.SortOrder,
Stars: it.Stars,
PerfectCount: it.PerfectCount,
TotalLevels: it.TotalLevels,
Unlocked: it.Unlocked,
UnlockReason: it.UnlockReason,
})
}
return res, nil
}
func (c *strategy) Detail(ctx context.Context, req *dto.StrategyDetailReq) (*dto.StrategyDetailRes, error) {
detail, err := service.Strategy.Detail(ctx, auth.GetUid(ctx), req.ChildId, req.StrategyId)
if err != nil {
return nil, err
}
res := &dto.StrategyDetailRes{
StrategyId: detail.StrategyId,
Name: detail.Name,
Pinyin: detail.Pinyin,
Meaning: detail.Meaning,
GroupName: detail.GroupName,
TeachContent: detail.TeachContent,
TeachImage: detail.TeachImage,
TeachAudio: detail.TeachAudio,
SummaryQ: detail.SummaryQ,
SummaryOptions: detail.SummaryOptions,
Levels: make([]dto.LevelBrief, 0, len(detail.Levels)),
}
for _, lv := range detail.Levels {
res.Levels = append(res.Levels, dto.LevelBrief{
LevelId: lv.LevelId,
Title: lv.Title,
AgeGroup: lv.AgeGroup,
SceneName: lv.SceneName,
Stars: lv.Stars,
Perfect: lv.Perfect,
Unlocked: lv.Unlocked,
ContentVersion: lv.ContentVersion,
ProgressVersion: lv.ProgressVersion,
})
}
return res, nil
}
+51
View File
@@ -0,0 +1,51 @@
package dto
import "github.com/gogf/gf/v2/frame/g"
type LevelDetailReq struct {
g.Meta `path:"/level/detail" method:"get" summary:"关卡详情"`
LevelId int64 `v:"required" json:"level_id"`
ChildId int64 `v:"required" json:"child_id"`
}
type ElementVO struct {
Id int64 `json:"id"`
EType int `json:"e_type"`
Name string `json:"name"`
Image string `json:"image"`
Audio string `json:"audio"`
Description string `json:"description"`
}
type OptionVO struct {
OptionId int64 `json:"option_id"`
Text string `json:"text"`
Prop *ElementVO `json:"prop"`
}
type NodeVO struct {
NodeId int64 `json:"node_id"`
Title string `json:"title"`
Content string `json:"content"`
Image string `json:"image"`
Audio string `json:"audio"`
Character *ElementVO `json:"character"`
InteractionType int `json:"interaction_type"`
Config string `json:"config"`
NodeType int `json:"node_type"`
ResultType int `json:"result_type"`
Options []OptionVO `json:"options"`
}
type LevelDetailRes struct {
LevelId int64 `json:"level_id"`
Title string `json:"title"`
Scene *ElementVO `json:"scene"`
SceneContent string `json:"scene_content"`
SceneImage string `json:"scene_image"`
SceneAudio string `json:"scene_audio"`
Entry NodeVO `json:"entry"`
TotalFinals int `json:"total_finals"`
Perfect bool `json:"perfect"`
Stars int `json:"stars"`
}
+60
View File
@@ -0,0 +1,60 @@
package dto
import "github.com/gogf/gf/v2/frame/g"
type StrategyListReq struct {
g.Meta `path:"/strategy/list" method:"get" summary:"计策列表"`
ChildId int64 `v:"required" json:"child_id"`
}
type StrategyItem struct {
StrategyId int64 `json:"strategy_id"`
Name string `json:"name"`
Pinyin string `json:"pinyin"`
GroupNo int `json:"group_no"`
GroupName string `json:"group_name"`
Meaning string `json:"meaning"`
Icon string `json:"icon"`
SortOrder int `json:"sort_order"`
Stars int `json:"stars"`
PerfectCount int `json:"perfect_count"`
TotalLevels int `json:"total_levels"`
Unlocked bool `json:"unlocked"`
UnlockReason string `json:"unlock_reason"`
}
type StrategyListRes struct {
List []StrategyItem `json:"list"`
}
type StrategyDetailReq struct {
g.Meta `path:"/strategy/detail" method:"get" summary:"计策详情"`
StrategyId int64 `v:"required" json:"strategy_id"`
ChildId int64 `v:"required" json:"child_id"`
}
type LevelBrief struct {
LevelId int64 `json:"level_id"`
Title string `json:"title"`
AgeGroup string `json:"age_group"`
SceneName string `json:"scene_name"`
Stars int `json:"stars"`
Perfect bool `json:"perfect"`
Unlocked bool `json:"unlocked"`
ContentVersion int `json:"content_version"`
ProgressVersion int `json:"progress_version"`
}
type StrategyDetailRes struct {
StrategyId int64 `json:"strategy_id"`
Name string `json:"name"`
Pinyin string `json:"pinyin"`
Meaning string `json:"meaning"`
GroupName string `json:"group_name"`
TeachContent string `json:"teach_content"`
TeachImage string `json:"teach_image"`
TeachAudio string `json:"teach_audio"`
SummaryQ string `json:"summary_q"`
SummaryOptions string `json:"summary_options"`
Levels []LevelBrief `json:"levels"`
}
+13
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
@@ -14,6 +15,18 @@ type child struct{}
var Child = &child{}
// getChildOf 校验孩子归属当前家长并返回档案记录。
func getChildOf(ctx context.Context, parentUid, childId int64) (gdb.Record, error) {
rec, err := dao.Child.Model().Ctx(ctx).WherePri(childId).One()
if err != nil {
return nil, err
}
if rec.IsEmpty() || rec["parent_id"].Int64() != parentUid {
return nil, gerror.New("孩子档案不存在")
}
return rec, nil
}
// ChildItem 孩子列表项(含派生值:完美关卡数)。
type ChildItem struct {
ChildId int64
+202
View File
@@ -1,5 +1,19 @@
package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type level struct{}
var Level = &level{}
// 成长等级:按完美关卡数映射称号(技术设计.md 4.10,派生值不落库)。
var levelTitles = []struct {
MinPerfect int
@@ -24,3 +38,191 @@ func LevelOf(perfectCount int) (level int, title string) {
}
return
}
// Element 元素 VO(场景/人物/道具)。
type Element struct {
Id int64
EType int
Name string
Image string
Audio string
Description string
}
// Option 分支选项 VO。
type Option struct {
OptionId int64
Text string
Prop *Element
}
// Node 关卡节点 VO(决策/终局)。
type Node struct {
NodeId int64
Title string
Content string
Image string
Audio string
Character *Element
InteractionType int
Config string
NodeType int
ResultType int
Options []*Option
}
// LevelDetail 关卡详情:场景 + 入口节点决策树 + 用户进度。
type LevelDetail struct {
LevelId int64
Title string
Scene *Element
SceneContent string
SceneImage string
SceneAudio string
Entry *Node
TotalFinals int
Perfect bool
Stars int
}
// Detail 关卡详情:节点/选项/元素分表取回,内存组装决策树(入口节点)。
func (s *level) Detail(ctx context.Context, parentUid, childId, levelId int64) (*LevelDetail, error) {
child, err := getChildOf(ctx, parentUid, childId)
if err != nil {
return nil, err
}
levelRec, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(levelId).One()
if err != nil {
return nil, err
}
if levelRec.IsEmpty() || levelRec["status"].Int() != consts.StatusEnabled {
return nil, gerror.New("关卡不存在")
}
if levelRec["age_group"].String() != child["age_group"].String() {
return nil, gerror.New("关卡不属于当前年龄段")
}
nodeRecs, err := dao.SceneNode.Model().Ctx(ctx).Cache(contentCache(ctx)).
Where("level_id", levelId).Where("status", consts.StatusEnabled).
Order("sort_order ASC").All()
if err != nil {
return nil, err
}
nodeIds := make([]int64, 0, len(nodeRecs))
for _, n := range nodeRecs {
nodeIds = append(nodeIds, n["id"].Int64())
}
optionRecs := []gdb.Record{}
if len(nodeIds) > 0 {
optionRecs, err = dao.NodeOption.Model().Ctx(ctx).Cache(contentCache(ctx)).
WhereIn("node_id", nodeIds).Where("status", consts.StatusEnabled).
Order("sort_order ASC").All()
if err != nil {
return nil, err
}
}
elements, err := s.elementsOf(ctx, levelRec, nodeRecs, optionRecs)
if err != nil {
return nil, err
}
progressRec, err := dao.UserProgress.Model().Ctx(ctx).
Where("child_id", childId).Where("level_id", levelId).One()
if err != nil {
return nil, err
}
var entryRec gdb.Record
totalFinals := 0
for _, n := range nodeRecs {
if n["is_entry"].Int() == 1 {
entryRec = n
}
if n["result_type"].Int() > 0 {
totalFinals++
}
}
if entryRec.IsEmpty() {
return nil, gerror.New("关卡入口缺失")
}
return &LevelDetail{
LevelId: levelRec["id"].Int64(),
Title: levelRec["title"].String(),
Scene: elements[levelRec["scene_id"].Int64()],
SceneContent: levelRec["scene_content"].String(),
SceneImage: levelRec["scene_image"].String(),
SceneAudio: levelRec["scene_audio"].String(),
Entry: buildNode(entryRec, optionRecs, elements),
TotalFinals: totalFinals,
Perfect: progressRec["perfect"].Int() == 1,
Stars: progressRec["stars"].Int(),
}, nil
}
// elementsOf 汇总关卡涉及的场景/人物/道具元素,批量查询后按 id 索引。
func (s *level) 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 {
ids = append(ids, cid)
}
}
for _, o := range optionRecs {
if pid := o["prop_id"].Int64(); pid > 0 {
ids = append(ids, pid)
}
}
ids = uniqueInt64(ids)
m := make(map[int64]*Element, len(ids))
if len(ids) == 0 {
return m, nil
}
recs, err := dao.Element.Model().Ctx(ctx).Cache(contentCache(ctx)).
WhereIn("id", ids).Where("status", consts.StatusEnabled).All()
if err != nil {
return nil, err
}
for _, r := range recs {
m[r["id"].Int64()] = &Element{
Id: r["id"].Int64(),
EType: r["e_type"].Int(),
Name: r["name"].String(),
Image: r["image"].String(),
Audio: r["audio"].String(),
Description: r["description"].String(),
}
}
return m, nil
}
// buildNode 组装单个节点的选项与人物/道具元素。
func buildNode(nodeRec gdb.Record, optionRecs []gdb.Record, elements map[int64]*Element) *Node {
node := &Node{
NodeId: nodeRec["id"].Int64(),
Title: nodeRec["title"].String(),
Content: nodeRec["content"].String(),
Image: nodeRec["image"].String(),
Audio: nodeRec["audio"].String(),
Character: elements[nodeRec["character_id"].Int64()],
InteractionType: nodeRec["interaction_type"].Int(),
Config: nodeRec["config"].String(),
NodeType: nodeRec["node_type"].Int(),
ResultType: nodeRec["result_type"].Int(),
}
for _, o := range optionRecs {
if o["node_id"].Int64() != node.NodeId {
continue
}
node.Options = append(node.Options, &Option{
OptionId: o["id"].Int64(),
Text: o["text"].String(),
Prop: elements[o["prop_id"].Int64()],
})
}
return node
}
+321
View File
@@ -0,0 +1,321 @@
package service
import (
"context"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/consts"
"36wisdom/biz/dao"
)
type strategy struct{}
var Strategy = &strategy{}
// StrategyItem 计策列表项:内容 + 本用户进度 + 解锁状态。
type StrategyItem struct {
StrategyId int64
Name string
Pinyin string
GroupNo int
GroupName string
Meaning string
Icon string
SortOrder int
Stars int
PerfectCount int
TotalLevels int
Unlocked bool
UnlockReason string
}
// StrategyDetail 计策详情:内容 + 关卡列表(含进度)。
type StrategyDetail struct {
StrategyId int64
Name string
Pinyin string
Meaning string
GroupName string
TeachContent string
TeachImage string
TeachAudio string
SummaryQ string
SummaryOptions string
Levels []*LevelBrief
}
// LevelBrief 关卡概要:进度 + 解锁 + 内容版本(客户端据此判断可重新挑战)。
type LevelBrief struct {
LevelId int64
Title string
AgeGroup string
SceneName string
Stars int
Perfect bool
Unlocked bool
ContentVersion int
ProgressVersion int
}
// contentCache 内容查询缓存选项:TTL 来自配置 database.cache.ttl。
// 内容仅在后台维护,M1 无写操作;后续后台变更后须清对应缓存。
func contentCache(ctx context.Context) gdb.CacheOption {
ttl := g.Cfg().MustGet(ctx, "database.cache.ttl").Int()
if ttl <= 0 {
ttl = 300
}
return gdb.CacheOption{Duration: time.Duration(ttl) * time.Second}
}
// List 计策列表:内容带缓存,进度不带;解锁按前置计全部关卡完美判定。
func (s *strategy) List(ctx context.Context, parentUid, childId int64) ([]*StrategyItem, error) {
child, err := getChildOf(ctx, parentUid, childId)
if err != nil {
return nil, err
}
childAge := child["age_group"].String()
strategies, err := s.listAll(ctx)
if err != nil {
return nil, err
}
strategyIds := make([]int64, 0, len(strategies))
for _, r := range strategies {
strategyIds = append(strategyIds, r["id"].Int64())
}
levels, err := s.listLevels(ctx, strategyIds, childAge)
if err != nil {
return nil, err
}
progress, err := s.progressOfLevels(ctx, childId, levelIdsOf(levels))
if err != nil {
return nil, err
}
// 按计策分组统计,同时维护解锁依赖(线性链:unlock_before 指向前置计)。
stats := make(map[int64]*StrategyItem, len(strategies))
for _, r := range strategies {
it := &StrategyItem{
StrategyId: r["id"].Int64(),
Name: r["name"].String(),
Pinyin: r["pinyin"].String(),
GroupNo: r["group_no"].Int(),
GroupName: r["group_name"].String(),
Meaning: r["meaning"].String(),
Icon: r["icon"].String(),
SortOrder: r["sort_order"].Int(),
}
stats[it.StrategyId] = it
}
for _, lv := range levels {
st := stats[lv["strategy_id"].Int64()]
st.TotalLevels++
p := progress[lv["id"].Int64()]
st.Stars += p["stars"].Int()
if p["perfect"].Int() == 1 {
st.PerfectCount++
}
}
nameOf := make(map[int64]string, len(strategies))
for _, r := range strategies {
nameOf[r["id"].Int64()] = r["name"].String()
}
for _, r := range strategies {
it := stats[r["id"].Int64()]
if prevId := r["unlock_before"].Int64(); prevId == 0 {
it.Unlocked = true
} else if prev, ok := stats[prevId]; ok && prev.PerfectCount == prev.TotalLevels && prev.TotalLevels > 0 {
it.Unlocked = true
} else {
it.UnlockReason = fmt.Sprintf("完成《%s》全部关卡解锁", nameOf[prevId])
}
}
items := make([]*StrategyItem, 0, len(strategies))
for _, r := range strategies {
items = append(items, stats[r["id"].Int64()])
}
return items, nil
}
// Detail 计策详情:含关卡列表与解锁状态。
func (s *strategy) Detail(ctx context.Context, parentUid, childId, strategyId int64) (*StrategyDetail, error) {
child, err := getChildOf(ctx, parentUid, childId)
if err != nil {
return nil, err
}
childAge := child["age_group"].String()
rec, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(strategyId).One()
if err != nil {
return nil, err
}
if rec.IsEmpty() || rec["status"].Int() != consts.StatusEnabled {
return nil, gerror.New("计策不存在")
}
levels, err := s.listLevels(ctx, []int64{strategyId}, childAge)
if err != nil {
return nil, err
}
progress, err := s.progressOfLevels(ctx, childId, levelIdsOf(levels))
if err != nil {
return nil, err
}
unlocked, _ := s.unlockState(ctx, childId, childAge, rec)
sceneNames, err := s.elementNames(ctx, sceneIdsOf(levels))
if err != nil {
return nil, err
}
detail := &StrategyDetail{
StrategyId: rec["id"].Int64(),
Name: rec["name"].String(),
Pinyin: rec["pinyin"].String(),
Meaning: rec["meaning"].String(),
GroupName: rec["group_name"].String(),
TeachContent: rec["teach_content"].String(),
TeachImage: rec["teach_image"].String(),
TeachAudio: rec["teach_audio"].String(),
SummaryQ: rec["summary_q"].String(),
SummaryOptions: rec["summary_options"].String(),
}
for _, lv := range levels {
p := progress[lv["id"].Int64()]
detail.Levels = append(detail.Levels, &LevelBrief{
LevelId: lv["id"].Int64(),
Title: lv["title"].String(),
AgeGroup: lv["age_group"].String(),
SceneName: sceneNames[lv["scene_id"].Int64()],
Stars: p["stars"].Int(),
Perfect: p["perfect"].Int() == 1,
Unlocked: unlocked,
ContentVersion: lv["content_version"].Int(),
ProgressVersion: p["content_version"].Int(),
})
}
return detail, nil
}
// unlockState 计策解锁判定:unlock_before 为空直接解锁,否则前置计全部关卡完美。
func (s *strategy) unlockState(ctx context.Context, childId int64, childAge string, strategyRec gdb.Record) (bool, string) {
prevId := strategyRec["unlock_before"].Int64()
if prevId == 0 {
return true, ""
}
prevName := ""
if r, err := dao.Strategy.Model().Ctx(ctx).Cache(contentCache(ctx)).WherePri(prevId).One(); err == nil {
prevName = r["name"].String()
}
perfect, total := 0, 0
if levels, err := s.listLevels(ctx, []int64{prevId}, childAge); err == nil {
total = len(levels)
if progress, err := s.progressOfLevels(ctx, childId, levelIdsOf(levels)); err == nil {
for _, lv := range levels {
if progress[lv["id"].Int64()]["perfect"].Int() == 1 {
perfect++
}
}
}
}
if total > 0 && perfect == total {
return true, ""
}
return false, fmt.Sprintf("完成《%s》全部关卡解锁", prevName)
}
// 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()
if err != nil {
return nil, err
}
return recs, nil
}
// listLevels 指定计策下、匹配年龄段且启用的关卡(内容缓存)。
func (s *strategy) listLevels(ctx context.Context, strategyIds []int64, ageGroup string) ([]gdb.Record, error) {
recs, err := dao.Level.Model().Ctx(ctx).Cache(contentCache(ctx)).
WhereIn("strategy_id", strategyIds).
Where("status", consts.StatusEnabled).
Where("age_group", ageGroup).
Order("sort_order ASC").All()
if err != nil {
return nil, err
}
return recs, nil
}
// progressOfLevels 孩子的关卡进度(不缓存,随闯关更新)。
func (s *strategy) progressOfLevels(ctx context.Context, childId int64, levelIds []int64) (map[int64]gdb.Record, error) {
m := make(map[int64]gdb.Record, len(levelIds))
if len(levelIds) == 0 {
return m, nil
}
recs, err := dao.UserProgress.Model().Ctx(ctx).
Where("child_id", childId).
WhereIn("level_id", levelIds).All()
if err != nil {
return nil, err
}
for _, r := range recs {
m[r["level_id"].Int64()] = r
}
return m, nil
}
// elementNames 批量取元素名(内容缓存)。
func (s *strategy) elementNames(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.Model().Ctx(ctx).Cache(contentCache(ctx)).
WhereIn("id", ids).Where("status", consts.StatusEnabled).All()
if err != nil {
return nil, err
}
for _, r := range recs {
m[r["id"].Int64()] = r["name"].String()
}
return m, nil
}
func levelIdsOf(levels []gdb.Record) []int64 {
ids := make([]int64, 0, len(levels))
for _, lv := range levels {
ids = append(ids, lv["id"].Int64())
}
return ids
}
func sceneIdsOf(levels []gdb.Record) []int64 {
ids := make([]int64, 0, len(levels))
for _, lv := range levels {
ids = append(ids, lv["scene_id"].Int64())
}
return ids
}
func uniqueInt64(in []int64) []int64 {
seen := make(map[int64]struct{}, len(in))
out := make([]int64, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}