456 lines
13 KiB
Go
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
|
|
}
|