package seed import ( "context" "embed" "encoding/json" "fmt" "io/fs" "sort" "golang.org/x/crypto/bcrypt" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" "36wisdom/biz/consts" "36wisdom/biz/dao" "36wisdom/common" ) //go:embed seed_36_ji/*.json var seedFS embed.FS // ---------- JSON 结构 ---------- type seedFile struct { Elements []seedElement `json:"elements"` Strategies []seedStrategy `json:"strategies"` Prizes []seedPrize `json:"prizes"` Badges []seedBadge `json:"badges"` Admin *seedAdmin `json:"admin"` } type seedElement struct { EType int `json:"e_type"` Name string `json:"name"` Image string `json:"image"` Audio string `json:"audio"` Description string `json:"description"` } type seedStrategy struct { Name string `json:"name"` Pinyin string `json:"pinyin"` GroupNo int `json:"group_no"` GroupName string `json:"group_name"` Meaning string `json:"meaning"` 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"` SummaryAudio string `json:"summary_audio"` Icon string `json:"icon"` SortOrder int `json:"sort_order"` UnlockBefore int64 `json:"unlock_before"` Levels []seedLevel `json:"levels"` } type seedLevel struct { Title string `json:"title"` SceneName string `json:"scene_name"` SceneContent string `json:"scene_content"` SceneImage string `json:"scene_image"` SceneAudio string `json:"scene_audio"` AgeGroup string `json:"age_group"` Nodes []seedNode `json:"nodes"` } type seedNode struct { Title string `json:"title"` CharacterName string `json:"character_name"` Content string `json:"content"` Image string `json:"image"` Audio string `json:"audio"` InteractionType int `json:"interaction_type"` Config string `json:"config"` NodeType int `json:"node_type"` ResultType int `json:"result_type"` IsEntry int `json:"is_entry"` Options []seedOption `json:"options"` } type seedOption struct { Text string `json:"text"` PropName string `json:"prop_name"` Audio string `json:"audio"` NextIndex int `json:"next_index"` Feedback string `json:"feedback"` FeedbackAudio string `json:"feedback_audio"` } type seedPrize struct { Name string `json:"name"` Description string `json:"description"` Icon string `json:"icon"` PType int `json:"p_type"` PointsCost int `json:"points_cost"` Stock int `json:"stock"` SortOrder int `json:"sort_order"` } type seedBadge struct { Name string `json:"name"` Icon string `json:"icon"` CondType int `json:"cond_type"` CondValue int `json:"cond_value"` } type seedAdmin struct { Username string `json:"username"` Password string `json:"password"` } // ---------- 导入 ---------- // EnsureSeeded 幂等导入种子数据:strategy 表非空即跳过。 func EnsureSeeded(ctx context.Context) { count, err := dao.Strategy.Model().Ctx(ctx).Count() if err != nil { g.Log().Fatal(ctx, err) } if count > 0 { g.Log().Info(ctx, "seed: 数据已存在,跳过种子导入") } else { if err := doSeed(ctx); err != nil { g.Log().Fatal(ctx, err) } g.Log().Info(ctx, "seed: 种子数据导入完成") } annotateAll(ctx) ensureInteractions(ctx) } func doSeed(ctx context.Context) (err error) { files, err := loadSeedFiles() if err != nil { return err } tx, err := g.DB().Begin(ctx) if err != nil { return err } defer func() { if err != nil { _ = tx.Rollback() } }() // 1. 元素库:按 (e_type, name) 去重 elementIds := make(map[string]int64) // key: "1:操场" for _, f := range files { for _, el := range f.Elements { if err := insertElement(ctx, tx, elementIds, el); err != nil { return err } } } // 2. 计策 → 关卡 → 节点 → 选项 for _, f := range files { for _, s := range f.Strategies { if err := insertStrategy(ctx, tx, elementIds, s); err != nil { return err } } } // 3. 奖品 / 徽章 / 管理员 for _, f := range files { for _, p := range f.Prizes { if _, err := tx.Model(consts.TablePrize).Ctx(ctx).Data(g.Map{ "name": p.Name, "description": p.Description, "icon": p.Icon, "p_type": p.PType, "points_cost": p.PointsCost, "stock": p.Stock, "status": consts.StatusEnabled, "sort_order": p.SortOrder, }).Insert(); err != nil { return err } } for _, b := range f.Badges { if _, err := tx.Model(consts.TableBadge).Ctx(ctx).Data(g.Map{ "name": b.Name, "icon": b.Icon, "cond_type": b.CondType, "cond_value": b.CondValue, "status": consts.StatusEnabled, }).Insert(); err != nil { return err } } if f.Admin != nil && f.Admin.Username != "" { hash, err := bcrypt.GenerateFromPassword([]byte(f.Admin.Password), bcrypt.DefaultCost) if err != nil { return err } if _, err := tx.Model(consts.TableAdminUser).Ctx(ctx).Data(g.Map{ "username": f.Admin.Username, "password": string(hash), "status": consts.StatusEnabled, }).Insert(); err != nil { return err } } } return tx.Commit() } func insertElement(ctx context.Context, tx gdb.TX, ids map[string]int64, el seedElement) error { key := fmt.Sprintf("%d:%s", el.EType, el.Name) if _, ok := ids[key]; ok { return nil } res, err := tx.Model(consts.TableElement).Ctx(ctx).Data(g.Map{ "e_type": el.EType, "name": el.Name, "name_pinyin": common.AnnotatePinyin(el.Name), "image": el.Image, "audio": el.Audio, "description": el.Description, "description_pinyin": common.AnnotatePinyin(el.Description), "status": consts.StatusEnabled, }).Insert() if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } ids[key] = id return nil } // ensureElement 按 (e_type, name) 引用自动建元素,返回元素 id;name 为空返回 0。 func ensureElement(ctx context.Context, tx gdb.TX, ids map[string]int64, eType int, name string) (int64, error) { if name == "" { return 0, nil } if err := insertElement(ctx, tx, ids, seedElement{EType: eType, Name: name}); err != nil { return 0, err } return ids[fmt.Sprintf("%d:%s", eType, name)], nil } func insertStrategy(ctx context.Context, tx gdb.TX, elementIds map[string]int64, s seedStrategy) error { unlockBefore := gconv.Int64(s.UnlockBefore) data := g.Map{ "name": s.Name, "pinyin": s.Pinyin, "group_no": s.GroupNo, "group_name": s.GroupName, "meaning": s.Meaning, "meaning_pinyin": common.AnnotatePinyin(s.Meaning), "teach_content": s.TeachContent, "teach_content_pinyin": common.AnnotatePinyin(s.TeachContent), "teach_image": s.TeachImage, "teach_audio": s.TeachAudio, "summary_q": s.SummaryQ, "summary_q_pinyin": common.AnnotatePinyin(s.SummaryQ), "summary_options": s.SummaryOptions, "summary_options_pinyin": common.AnnotatePinyin(s.SummaryOptions), "summary_audio": s.SummaryAudio, "icon": s.Icon, "sort_order": s.SortOrder, "status": consts.StatusEnabled, } if unlockBefore > 0 { data["unlock_before"] = unlockBefore } res, err := tx.Model(consts.TableStrategy).Ctx(ctx).Data(data).Insert() if err != nil { return err } strategyId, err := res.LastInsertId() if err != nil { return err } for li, lv := range s.Levels { if err := insertLevel(ctx, tx, elementIds, strategyId, li+1, lv); err != nil { return err } } return nil } func insertLevel(ctx context.Context, tx gdb.TX, elementIds map[string]int64, strategyId int64, sortOrder int, lv seedLevel) error { if len(lv.Nodes) == 0 { return fmt.Errorf("seed: 计策关卡 %s 无节点", lv.Title) } // 节点数组内校验 + 有向无环检测(next_index 边) if err := validateLevelGraph(lv); err != nil { return fmt.Errorf("seed: 关卡「%s」校验失败: %w", lv.Title, err) } sceneId, err := ensureElement(ctx, tx, elementIds, 1, lv.SceneName) if err != nil { return err } ageGroup := lv.AgeGroup if ageGroup == "" { ageGroup = consts.AgeGroup4_6 } res, err := tx.Model(consts.TableLevel).Ctx(ctx).Data(g.Map{ "strategy_id": strategyId, "title": lv.Title, "scene_id": sceneId, "scene_content": lv.SceneContent, "scene_content_pinyin": common.AnnotatePinyin(lv.SceneContent), "scene_image": lv.SceneImage, "scene_audio": lv.SceneAudio, "age_group": ageGroup, "content_version": 1, "sort_order": sortOrder, "status": consts.StatusEnabled, }).Insert() if err != nil { return err } levelId, err := res.LastInsertId() if err != nil { return err } // 节点先入库,按数组下标记录 id nodeIds := make([]int64, len(lv.Nodes)) for i, nd := range lv.Nodes { nodeType := nd.NodeType if nodeType == 0 { nodeType = consts.NodeDecision } interactionType := nd.InteractionType if interactionType == 0 { interactionType = consts.InteractionOption } characterId, err := ensureElement(ctx, tx, elementIds, 2, nd.CharacterName) if err != nil { return err } nres, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{ "level_id": levelId, "title": nd.Title, "character_id": characterId, "content": nd.Content, "image": nd.Image, "audio": nd.Audio, "node_type": nodeType, "interaction_type": interactionType, "config": nd.Config, "result_type": nd.ResultType, "is_entry": nd.IsEntry, "sort_order": i + 1, "status": consts.StatusEnabled, }).Insert() if err != nil { return err } id, err := nres.LastInsertId() if err != nil { return err } nodeIds[i] = id } for i, nd := range lv.Nodes { for oi, opt := range nd.Options { propId, err := ensureElement(ctx, tx, elementIds, 3, opt.PropName) if err != nil { return err } nextNodeId := int64(0) if opt.NextIndex > 0 { nextNodeId = nodeIds[opt.NextIndex] } if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{ "node_id": nodeIds[i], "text": opt.Text, "text_pinyin": common.AnnotatePinyin(opt.Text), "prop_id": propId, "audio": opt.Audio, "next_node_id": nextNodeId, "feedback": opt.Feedback, "feedback_pinyin": common.AnnotatePinyin(opt.Feedback), "feedback_audio": opt.FeedbackAudio, "sort_order": oi + 1, "status": consts.StatusEnabled, }).Insert(); err != nil { return err } } } return nil } // validateLevelGraph 校验关卡节点图:入口唯一、决策节点≥2 选项、终局无选项、终局评级合法、next_index 有界且无环。 func validateLevelGraph(lv seedLevel) error { n := len(lv.Nodes) if n == 0 { return fmt.Errorf("无节点") } entryCount := 0 hasFinal := false adj := make([][]int, n) indegree := make([]int, n) for i, nd := range lv.Nodes { if nd.IsEntry == 1 { entryCount++ } if nd.NodeType == consts.NodeFinal { hasFinal = true if nd.ResultType <= consts.ResultNone || nd.ResultType > consts.ResultBest { return fmt.Errorf("节点 %d 终局评级非法: %d", i, nd.ResultType) } if len(nd.Options) > 0 { return fmt.Errorf("终局节点 %d 不应有选项", i) } } else if nd.NodeType == 0 || nd.NodeType == consts.NodeDecision { if len(nd.Options) < 2 { return fmt.Errorf("决策节点 %d 选项少于 2 个", i) } if nd.ResultType != 0 { return fmt.Errorf("决策节点 %d 不应有终局评级", i) } } else { return fmt.Errorf("节点 %d 类型非法: %d", i, nd.NodeType) } for _, opt := range nd.Options { if opt.NextIndex < 0 || opt.NextIndex >= n { return fmt.Errorf("选项 next_index 越界: %d", opt.NextIndex) } adj[i] = append(adj[i], opt.NextIndex) indegree[opt.NextIndex]++ } } if entryCount != 1 { return fmt.Errorf("入口节点数量应为 1,实际 %d", entryCount) } if !hasFinal { return fmt.Errorf("缺少终局节点") } // Kahn 拓扑排序检测环 queue := make([]int, 0, n) for i, d := range indegree { if d == 0 { queue = append(queue, i) } } visited := 0 for len(queue) > 0 { u := queue[0] queue = queue[1:] visited++ for _, v := range adj[u] { indegree[v]-- if indegree[v] == 0 { queue = append(queue, v) } } } if visited != n { return fmt.Errorf("决策图存在环") } return nil } func loadSeedFiles() ([]*seedFile, error) { entries, err := fs.ReadDir(seedFS, "seed_36_ji") if err != nil { return nil, err } var names []string for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names) // 固定顺序:group1..group6、seed_meta 最后 var files []*seedFile for _, name := range names { data, err := seedFS.ReadFile("seed_36_ji/" + name) if err != nil { return nil, err } var f seedFile if err := json.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("seed 文件 %s 解析失败: %w", name, err) } files = append(files, &f) } return files, nil }