Files
2026-08-14 13:34:21 +08:00

456 lines
13 KiB
Go

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
}