From 0283c250e4406635d46086610bf0e15dddb57f0f Mon Sep 17 00:00:00 2001 From: huang <1724659546@qq.com> Date: Wed, 26 Nov 2025 22:51:58 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84seeder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/infra/database/seeder.go | 528 +----------------- .../infra/database/seeder/nutrient_seeder.go | 268 +++++++++ .../seeder/pig_nutrient_requirement_seeder.go | 281 ++++++++++ internal/infra/database/seeder/utils.go | 18 + 4 files changed, 570 insertions(+), 525 deletions(-) create mode 100644 internal/infra/database/seeder/nutrient_seeder.go create mode 100644 internal/infra/database/seeder/pig_nutrient_requirement_seeder.go create mode 100644 internal/infra/database/seeder/utils.go diff --git a/internal/infra/database/seeder.go b/internal/infra/database/seeder.go index fd895ed..82357d1 100644 --- a/internal/infra/database/seeder.go +++ b/internal/infra/database/seeder.go @@ -1,17 +1,14 @@ package database import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" "os" "path/filepath" "strings" + "git.huangwc.com/pig/pig-farm-controller/internal/infra/database/seeder" "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" - "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" "github.com/tidwall/gjson" "gorm.io/gorm" @@ -95,9 +92,9 @@ func SeedFromPreset(ctx context.Context, db *gorm.DB, presetDir string) error { var seederFunc SeederFunc switch dataTypeStr { case "nutrient": - seederFunc = seedNutrients + seederFunc = seeder.SeedNutrients case "pig_nutrient_requirements": - seederFunc = seedPigNutrientRequirements + seederFunc = seeder.SeedPigNutrientRequirements default: logger.Warnf("警告: 存在未知的 type: '%s',已跳过", dataTypeStr) continue @@ -127,522 +124,3 @@ func SeedFromPreset(ctx context.Context, db *gorm.DB, presetDir string) error { return nil // 提交事务 }) } - -// rawMaterialInfo 用于临时存储解析后的原料描述和价格信息。 -type rawMaterialInfo struct { - Description string - UnitPrice float32 -} - -// seedNutrients 先严格校验JSON源文件,然后以“有则跳过”的模式播种数据。 -func seedNutrients(ctx context.Context, tx *gorm.DB, jsonData []byte) error { - logger := logs.GetLogger(ctx) - - // 检查 Nutrient 表是否为空,如果非空则跳过播种 - isEmpty, err := isTableEmpty(tx, &models.Nutrient{}) - if err != nil { - return fmt.Errorf("检查 Nutrient 表是否为空失败: %w", err) - } - if !isEmpty { - logger.Info("已存在原料数据, 跳过数据播种") - return nil - } - - // 1. 严格校验JSON文件,检查内部重复键 - if err := validateAndParseNutrientJSON(jsonData); err != nil { - return fmt.Errorf("JSON源文件校验失败: %w", err) - } - - // 2. 解析简介信息 - descriptionsNode := gjson.GetBytes(jsonData, "descriptions") - rawMaterialInfos := make(map[string]rawMaterialInfo) - nutrientDescriptions := make(map[string]string) - - if descriptionsNode.Exists() { - // 解析 raw_materials 描述和价格 - descriptionsNode.Get("raw_materials").ForEach(func(key, value gjson.Result) bool { - rawMaterialInfos[key.String()] = rawMaterialInfo{ - Description: value.Get("descriptions").String(), - UnitPrice: float32(value.Get("unit_price").Float()), - } - return true - }) - descriptionsNode.Get("nutrients").ForEach(func(key, value gjson.Result) bool { - nutrientDescriptions[key.String()] = value.String() - return true - }) - } - - // 3. 将通过校验的、干净的数据写入数据库 - dataNode := gjson.GetBytes(jsonData, "data") - dataNode.ForEach(func(rawMaterialKey, rawMaterialValue gjson.Result) bool { - rawMaterialName := rawMaterialKey.String() - var rawMaterial models.RawMaterial - - // 获取原料的描述和价格信息 - info := rawMaterialInfos[rawMaterialName] - - // 将 Description 和 ReferencePrice 放入 Create 对象中 - err = tx.Where(models.RawMaterial{Name: rawMaterialName}). - FirstOrCreate(&rawMaterial, models.RawMaterial{ - Name: rawMaterialName, - Description: info.Description, - ReferencePrice: info.UnitPrice, - }).Error - if err != nil { - // 返回 false 停止 ForEach 遍历 - return false - } - - rawMaterialValue.ForEach(func(nutrientKey, nutrientValue gjson.Result) bool { - nutrientName := nutrientKey.String() - value := float32(nutrientValue.Float()) - - var nutrient models.Nutrient - // 将 Description 放入 Create 对象中 - err = tx.Where(models.Nutrient{Name: nutrientName}). - FirstOrCreate(&nutrient, models.Nutrient{ - Name: nutrientName, - Description: nutrientDescriptions[nutrientName], - }).Error - if err != nil { - // 返回 false 停止 ForEach 遍历 - return false - } - - linkData := models.RawMaterialNutrient{ - RawMaterialID: rawMaterial.ID, - NutrientID: nutrient.ID, - } - // 使用 FirstOrCreate 确保关联的唯一性 - err = tx.Where(linkData).FirstOrCreate(&linkData, models.RawMaterialNutrient{ - RawMaterialID: linkData.RawMaterialID, - NutrientID: linkData.NutrientID, - Value: value, - }).Error - if err != nil { - // 返回 false 停止 ForEach 遍历 - return false - } - return true - }) - return err == nil // 如果内部遍历有错误,则停止外部遍历 - }) - - return err // 返回捕获到的错误 -} - -// seedPigNutrientRequirements 先严格校验JSON源文件,然后以“有则跳过”的模式播种数据。 -func seedPigNutrientRequirements(ctx context.Context, tx *gorm.DB, jsonData []byte) error { - logger := logs.GetLogger(ctx) - - // 检查 PigBreed 表是否为空,如果非空则跳过播种 - isEmpty, err := isTableEmpty(tx, &models.PigBreed{}) - if err != nil { - return fmt.Errorf("检查 PigBreed 表是否为空失败: %w", err) - } - if !isEmpty { - logger.Info("已存在猪种数据, 跳过数据播种") - return nil - } - - // 1. 严格校验JSON文件,检查内部重复键 - if err := validateAndParsePigNutrientRequirementJSON(jsonData); err != nil { - return fmt.Errorf("JSON源文件校验失败: %w", err) - } - - // 2. 解析简介信息 - descriptionsNode := gjson.GetBytes(jsonData, "descriptions") - pigBreedDescriptions := make(map[string]models.PigBreed) - pigAgeStageDescriptions := make(map[string]models.PigAgeStage) - pigTypeDescriptions := make(map[string]map[string]models.PigType) - - if descriptionsNode.Exists() { - // 解析 pig_breeds 描述 - descriptionsNode.Get("pig_breeds").ForEach(func(key, value gjson.Result) bool { - var pb models.PigBreed - pb.Name = key.String() - pb.Description = value.Get("description").String() - pb.ParentInfo = value.Get("parent_info").String() - pb.AppearanceFeatures = value.Get("appearance_features").String() - pb.BreedAdvantages = value.Get("breed_advantages").String() - pb.BreedDisadvantages = value.Get("breed_disadvantages").String() - pigBreedDescriptions[key.String()] = pb - return true - }) - - // 解析 pig_age_stages 描述 - descriptionsNode.Get("pig_age_stages").ForEach(func(key, value gjson.Result) bool { - var pas models.PigAgeStage - pas.Name = key.String() - pas.Description = value.String() - pigAgeStageDescriptions[key.String()] = pas - return true - }) - - // 解析 pig_breed_age_stages (PigType) 描述 - descriptionsNode.Get("pig_breed_age_stages").ForEach(func(breedKey, breedValue gjson.Result) bool { - if _, ok := pigTypeDescriptions[breedKey.String()]; !ok { - pigTypeDescriptions[breedKey.String()] = make(map[string]models.PigType) - } - breedValue.ForEach(func(ageStageKey, ageStageValue gjson.Result) bool { - var pt models.PigType - pt.Description = ageStageValue.Get("description").String() - pt.DailyFeedIntake = float32(ageStageValue.Get("daily_feed_intake").Float()) - pt.DailyGainWeight = float32(ageStageValue.Get("daily_gain_weight").Float()) - pt.MinDays = uint32(ageStageValue.Get("min_days").Uint()) - pt.MaxDays = uint32(ageStageValue.Get("max_days").Uint()) - pt.MinWeight = float32(ageStageValue.Get("min_weight").Float()) - pt.MaxWeight = float32(ageStageValue.Get("max_weight").Float()) - pigTypeDescriptions[breedKey.String()][ageStageKey.String()] = pt - return true - }) - return true - }) - } - - // 3. 将通过校验的、干净的数据写入数据库 - dataNode := gjson.GetBytes(jsonData, "data") - dataNode.ForEach(func(breedKey, breedValue gjson.Result) bool { - breedName := breedKey.String() - var pigBreed models.PigBreed - // 查找或创建 PigBreed - pbDesc := pigBreedDescriptions[breedName] - err = tx.Where(models.PigBreed{Name: breedName}). - FirstOrCreate(&pigBreed, models.PigBreed{ - Name: breedName, - Description: pbDesc.Description, - ParentInfo: pbDesc.ParentInfo, - AppearanceFeatures: pbDesc.AppearanceFeatures, - BreedAdvantages: pbDesc.BreedAdvantages, - BreedDisadvantages: pbDesc.BreedDisadvantages, - }).Error - if err != nil { - return false - } - - breedValue.ForEach(func(ageStageKey, ageStageValue gjson.Result) bool { - ageStageName := ageStageKey.String() - var pigAgeStage models.PigAgeStage - // 查找或创建 PigAgeStage - pasDesc := pigAgeStageDescriptions[ageStageName] - err = tx.Where(models.PigAgeStage{Name: ageStageName}). - FirstOrCreate(&pigAgeStage, models.PigAgeStage{ - Name: ageStageName, - Description: pasDesc.Description, - }).Error - - if err != nil { - return false - } - - var pigType models.PigType - // 查找或创建 PigType - ptDesc := pigTypeDescriptions[breedName][ageStageName] - err = tx.Where(models.PigType{BreedID: pigBreed.ID, AgeStageID: pigAgeStage.ID}). - FirstOrCreate(&pigType, models.PigType{ - BreedID: pigBreed.ID, - AgeStageID: pigAgeStage.ID, - Description: ptDesc.Description, - DailyFeedIntake: ptDesc.DailyFeedIntake, - DailyGainWeight: ptDesc.DailyGainWeight, - MinDays: ptDesc.MinDays, - MaxDays: ptDesc.MaxDays, - MinWeight: ptDesc.MinWeight, - MaxWeight: ptDesc.MaxWeight, - }).Error - if err != nil { - return false - } - - ageStageValue.ForEach(func(nutrientKey, nutrientValue gjson.Result) bool { - nutrientName := nutrientKey.String() - minReq := float32(nutrientValue.Get("min_requirement").Float()) - maxReq := float32(nutrientValue.Get("max_requirement").Float()) - - var nutrient models.Nutrient - // 查找或创建 Nutrient (这里假设 Nutrient 已经在 seedNutrients 中处理,但为了健壮性,再次 FirstOrCreate) - err = tx.Where(models.Nutrient{Name: nutrientName}). - FirstOrCreate(&nutrient, models.Nutrient{ - Name: nutrientName, - // Description 字段在 nutrient seeder 中处理,这里不设置 - }).Error - if err != nil { - return false - } - - linkData := models.PigNutrientRequirement{ - PigTypeID: pigType.ID, - NutrientID: nutrient.ID, - MinRequirement: minReq, - MaxRequirement: maxReq, - } - // 使用 FirstOrCreate 确保关联的唯一性 - err = tx.Where(models.PigNutrientRequirement{ - PigTypeID: pigType.ID, - NutrientID: nutrient.ID, - }).FirstOrCreate(&linkData, linkData).Error - if err != nil { - return false - } - return true - }) - return err == nil // 如果内部遍历有错误,则停止外部遍历 - }) - return err == nil // 如果内部遍历有错误,则停止外部遍历 - }) - return err // 返回捕获到的错误 -} - -// validateAndParsePigNutrientRequirementJSON 严格校验猪营养需求JSON文件 -func validateAndParsePigNutrientRequirementJSON(jsonData []byte) error { - dataNode := gjson.GetBytes(jsonData, "data") - if !dataNode.Exists() { - return errors.New("JSON文件中缺少 'data' 字段") - } - if !dataNode.IsObject() { - return errors.New("'data' 字段必须是一个JSON对象") - } - - decoder := json.NewDecoder(bytes.NewReader([]byte(dataNode.Raw))) - decoder.UseNumber() - - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("'data' 字段解析起始符失败: %v", err) - } - - seenBreeds := make(map[string]bool) - - for decoder.More() { - // 解析 PigBreed 名称 - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("解析猪品种名称失败: %w", err) - } - breedName := t.(string) - if seenBreeds[breedName] { - return fmt.Errorf("猪品种名称 '%s' 重复", breedName) - } - seenBreeds[breedName] = true - - // 解析该品种的年龄阶段对象 - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("期望猪品种 '%s' 的值是一个JSON对象", breedName) - } - - seenAgeStages := make(map[string]bool) - - for decoder.More() { - // 解析 PigAgeStage 名称 - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("在猪品种 '%s' 中解析年龄阶段名称失败: %w", breedName, err) - } - ageStageName := t.(string) - if seenAgeStages[ageStageName] { - return fmt.Errorf("在猪品种 '%s' 中, 年龄阶段名称 '%s' 重复", breedName, ageStageName) - } - seenAgeStages[ageStageName] = true - - // 解析该年龄阶段的营养成分对象 - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("期望年龄阶段 '%s' 的值是一个JSON对象", ageStageName) - } - - seenNutrients := make(map[string]bool) - - for decoder.More() { - // 解析 Nutrient 名称 - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("在年龄阶段 '%s' 中解析营养素名称失败: %w", ageStageName, err) - } - nutrientName := t.(string) - if seenNutrients[nutrientName] { - return fmt.Errorf("在年龄阶段 '%s' 中, 营养素名称 '%s' 重复", ageStageName, nutrientName) - } - seenNutrients[nutrientName] = true - - // 解析 min_requirement 和 max_requirement 对象 - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("期望营养素 '%s' 的值是一个JSON对象", nutrientName) - } - - for decoder.More() { - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("解析营养素 '%s' 的需求键失败: %w", nutrientName, err) - } - // key := t.(string) // 校验时不需要使用 key 的值 - - t, err = decoder.Token() - if err != nil { - return fmt.Errorf("解析营养素 '%s' 的需求值失败: %w", nutrientName, err) - } - if _, ok := t.(json.Number); !ok { - return fmt.Errorf("期望营养素 '%s' 的需求值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t) - } - } - if t, err := decoder.Token(); err != nil || t != json.Delim('}') { - return fmt.Errorf("解析营养素 '%s' 的值结束符 '}' 失败", nutrientName) - } - } - if t, err := decoder.Token(); err != nil || t != json.Delim('}') { - return fmt.Errorf("解析年龄阶段 '%s' 的值结束符 '}' 失败", ageStageName) - } - } - if t, err := decoder.Token(); err != nil || t != json.Delim('}') { - return fmt.Errorf("解析猪品种 '%s' 的值结束符 '}' 失败", breedName) - } - } - return nil -} - -// validateAndParseNutrientJSON 严格校验JSON文件 -func validateAndParseNutrientJSON(jsonData []byte) error { - descriptionsNode := gjson.GetBytes(jsonData, "descriptions") - if !descriptionsNode.Exists() { - return errors.New("JSON文件中缺少 'descriptions' 字段") - } - if !descriptionsNode.IsObject() { - return errors.New("'descriptions' 字段必须是一个JSON对象") - } - - rawMaterialsNode := descriptionsNode.Get("raw_materials") - if !rawMaterialsNode.Exists() { - return errors.New("JSON文件中缺少 'descriptions.raw_materials' 字段") - } - if !rawMaterialsNode.IsObject() { - return errors.New("'descriptions.raw_materials' 字段必须是一个JSON对象") - } - - // 使用 json.Decoder 严格校验 raw_materials 的结构 - decoder := json.NewDecoder(bytes.NewReader([]byte(rawMaterialsNode.Raw))) - decoder.UseNumber() - - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("'descriptions.raw_materials' 字段解析起始符失败: %v", err) - } - - seenRawMaterials := make(map[string]bool) - - for decoder.More() { - // 1. 解析原料名称 - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("解析原料名称失败: %w", err) - } - rawMaterialName := t.(string) - if seenRawMaterials[rawMaterialName] { - return fmt.Errorf("原料名称 '%s' 重复", rawMaterialName) - } - seenRawMaterials[rawMaterialName] = true - - // 2. 解析该原料的描述和价格对象 - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName) - } - - for decoder.More() { - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("解析原料 '%s' 内部键失败: %w", rawMaterialName, err) - } - key := t.(string) - - switch key { - case "descriptions": - t, err = decoder.Token() - if err != nil { - return fmt.Errorf("解析原料 '%s' 的 'descriptions' 值失败: %w", rawMaterialName, err) - } - if _, ok := t.(string); !ok { - return fmt.Errorf("期望原料 '%s' 的 'descriptions' 值是字符串, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t) - } - case "unit_price": - t, err = decoder.Token() - if err != nil { - return fmt.Errorf("解析原料 '%s' 的 'unit_price' 值失败: %w", rawMaterialName, err) - } - if _, ok := t.(json.Number); !ok { - return fmt.Errorf("期望原料 '%s' 的 'unit_price' 值是数字, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t) - } - default: - // 忽略其他未知字段,但仍需读取其值以继续解析 - if _, err := decoder.Token(); err != nil { - return fmt.Errorf("解析原料 '%s' 的未知键 '%s' 的值失败: %w", rawMaterialName, key, err) - } - } - } - - // 读取原料描述和价格对象的 "}" - if t, err := decoder.Token(); err != nil || t != json.Delim('}') { - return fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName) - } - } - - // 校验 data 节点 - dataNode := gjson.GetBytes(jsonData, "data") - if !dataNode.Exists() { - return errors.New("JSON文件中缺少 'data' 字段") - } - if !dataNode.IsObject() { - return errors.New("'data' 字段必须是一个JSON对象") - } - - // 重新初始化 decoder 用于 data 节点的校验 - decoder = json.NewDecoder(bytes.NewReader([]byte(dataNode.Raw))) - decoder.UseNumber() - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return errors.New("'data' 字段解析起始符失败") - } - - seenRawMaterials = make(map[string]bool) // 重置 seenRawMaterials 用于 data 节点校验 - - for decoder.More() { - // 1. 解析原料名称 - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("解析原料名称失败: %w", err) - } - rawMaterialName := t.(string) - if seenRawMaterials[rawMaterialName] { - return fmt.Errorf("原料名称 '%s' 重复", rawMaterialName) - } - seenRawMaterials[rawMaterialName] = true - - // 2. 解析该原料的营养成分对象 - if t, err := decoder.Token(); err != nil || t != json.Delim('{') { - return fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName) - } - - seenNutrients := make(map[string]bool) - for decoder.More() { - // 解析营养素名称 - t, err := decoder.Token() - if err != nil { - return fmt.Errorf("在原料 '%s' 中解析营养素名称失败: %w", rawMaterialName, err) - } - nutrientName := t.(string) - if seenNutrients[nutrientName] { - return fmt.Errorf("在原料 '%s' 中, 营养素名称 '%s' 重复", rawMaterialName, nutrientName) - } - seenNutrients[nutrientName] = true - - // 解析营养素含量 - t, err = decoder.Token() - if err != nil { - return fmt.Errorf("在原料 '%s' 中解析营养素 '%s' 的含量值失败: %w", rawMaterialName, nutrientName, err) - } - if _, ok := t.(json.Number); !ok { - return fmt.Errorf("期望营养素 '%s' 的含量值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t) - } - } - - // 读取营养成分对象的 "}" - if t, err := decoder.Token(); err != nil || t != json.Delim('}') { - return fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName) - } - } - return nil -} diff --git a/internal/infra/database/seeder/nutrient_seeder.go b/internal/infra/database/seeder/nutrient_seeder.go new file mode 100644 index 0000000..8eb9f3b --- /dev/null +++ b/internal/infra/database/seeder/nutrient_seeder.go @@ -0,0 +1,268 @@ +package seeder + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" + "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" + + "github.com/tidwall/gjson" + "gorm.io/gorm" +) + +// rawMaterialInfo 用于临时存储解析后的原料描述和价格信息。 +type rawMaterialInfo struct { + Description string + UnitPrice float32 +} + +// SeedNutrients 先严格校验JSON源文件,然后以“有则跳过”的模式播种数据。 +func SeedNutrients(ctx context.Context, tx *gorm.DB, jsonData []byte) error { + logger := logs.GetLogger(ctx) + + // 检查 Nutrient 表是否为空,如果非空则跳过播种 + isEmpty, err := isTableEmpty(tx, &models.Nutrient{}) + if err != nil { + return fmt.Errorf("检查 Nutrient 表是否为空失败: %w", err) + } + if !isEmpty { + logger.Info("已存在原料数据, 跳过数据播种") + return nil + } + + // 1. 严格校验JSON文件,检查内部重复键 + if err := validateAndParseNutrientJSON(jsonData); err != nil { + return fmt.Errorf("JSON源文件校验失败: %w", err) + } + + // 2. 解析简介信息 + descriptionsNode := gjson.GetBytes(jsonData, "descriptions") + rawMaterialInfos := make(map[string]rawMaterialInfo) + nutrientDescriptions := make(map[string]string) + + if descriptionsNode.Exists() { + // 解析 raw_materials 描述和价格 + descriptionsNode.Get("raw_materials").ForEach(func(key, value gjson.Result) bool { + rawMaterialInfos[key.String()] = rawMaterialInfo{ + Description: value.Get("descriptions").String(), + UnitPrice: float32(value.Get("unit_price").Float()), + } + return true + }) + descriptionsNode.Get("nutrients").ForEach(func(key, value gjson.Result) bool { + nutrientDescriptions[key.String()] = value.String() + return true + }) + } + + // 3. 将通过校验的、干净的数据写入数据库 + dataNode := gjson.GetBytes(jsonData, "data") + dataNode.ForEach(func(rawMaterialKey, rawMaterialValue gjson.Result) bool { + rawMaterialName := rawMaterialKey.String() + var rawMaterial models.RawMaterial + + // 获取原料的描述和价格信息 + info := rawMaterialInfos[rawMaterialName] + + // 将 Description 和 ReferencePrice 放入 Create 对象中 + err = tx.Where(models.RawMaterial{Name: rawMaterialName}). + FirstOrCreate(&rawMaterial, models.RawMaterial{ + Name: rawMaterialName, + Description: info.Description, + ReferencePrice: info.UnitPrice, + }).Error + if err != nil { + // 返回 false 停止 ForEach 遍历 + return false + } + + rawMaterialValue.ForEach(func(nutrientKey, nutrientValue gjson.Result) bool { + nutrientName := nutrientKey.String() + value := float32(nutrientValue.Float()) + + var nutrient models.Nutrient + // 将 Description 放入 Create 对象中 + err = tx.Where(models.Nutrient{Name: nutrientName}). + FirstOrCreate(&nutrient, models.Nutrient{ + Name: nutrientName, + Description: nutrientDescriptions[nutrientName], + }).Error + if err != nil { + // 返回 false 停止 ForEach 遍历 + return false + } + + linkData := models.RawMaterialNutrient{ + RawMaterialID: rawMaterial.ID, + NutrientID: nutrient.ID, + } + // 使用 FirstOrCreate 确保关联的唯一性 + err = tx.Where(linkData).FirstOrCreate(&linkData, models.RawMaterialNutrient{ + RawMaterialID: linkData.RawMaterialID, + NutrientID: linkData.NutrientID, + Value: value, + }).Error + if err != nil { + // 返回 false 停止 ForEach 遍历 + return false + } + return true + }) + return err == nil // 如果内部遍历有错误,则停止外部遍历 + }) + + return err // 返回捕获到的错误 +} + +// validateAndParseNutrientJSON 严格校验JSON文件 +func validateAndParseNutrientJSON(jsonData []byte) error { + descriptionsNode := gjson.GetBytes(jsonData, "descriptions") + if !descriptionsNode.Exists() { + return errors.New("JSON文件中缺少 'descriptions' 字段") + } + if !descriptionsNode.IsObject() { + return errors.New("'descriptions' 字段必须是一个JSON对象") + } + + rawMaterialsNode := descriptionsNode.Get("raw_materials") + if !rawMaterialsNode.Exists() { + return errors.New("JSON文件中缺少 'descriptions.raw_materials' 字段") + } + if !rawMaterialsNode.IsObject() { + return errors.New("'descriptions.raw_materials' 字段必须是一个JSON对象") + } + + // 使用 json.Decoder 严格校验 raw_materials 的结构 + decoder := json.NewDecoder(bytes.NewReader([]byte(rawMaterialsNode.Raw))) + decoder.UseNumber() + + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("'descriptions.raw_materials' 字段解析起始符失败: %v", err) + } + + seenRawMaterials := make(map[string]bool) + + for decoder.More() { + // 1. 解析原料名称 + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("解析原料名称失败: %w", err) + } + rawMaterialName := t.(string) + if seenRawMaterials[rawMaterialName] { + return fmt.Errorf("原料名称 '%s' 重复", rawMaterialName) + } + seenRawMaterials[rawMaterialName] = true + + // 2. 解析该原料的描述和价格对象 + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName) + } + + for decoder.More() { + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("解析原料 '%s' 内部键失败: %w", rawMaterialName, err) + } + key := t.(string) + + switch key { + case "descriptions": + t, err = decoder.Token() + if err != nil { + return fmt.Errorf("解析原料 '%s' 的 'descriptions' 值失败: %w", rawMaterialName, err) + } + if _, ok := t.(string); !ok { + return fmt.Errorf("期望原料 '%s' 的 'descriptions' 值是字符串, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t) + } + case "unit_price": + t, err = decoder.Token() + if err != nil { + return fmt.Errorf("解析原料 '%s' 的 'unit_price' 值失败: %w", rawMaterialName, err) + } + if _, ok := t.(json.Number); !ok { + return fmt.Errorf("期望原料 '%s' 的 'unit_price' 值是数字, 但实际得到的类型是 %T, 值为 '%v'", rawMaterialName, t, t) + } + default: + // 忽略其他未知字段,但仍需读取其值以继续解析 + if _, err := decoder.Token(); err != nil { + return fmt.Errorf("解析原料 '%s' 的未知键 '%s' 的值失败: %w", rawMaterialName, key, err) + } + } + } + + // 读取原料描述和价格对象的 "}" + if t, err := decoder.Token(); err != nil || t != json.Delim('}') { + return fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName) + } + } + + // 校验 data 节点 + dataNode := gjson.GetBytes(jsonData, "data") + if !dataNode.Exists() { + return errors.New("JSON文件中缺少 'data' 字段") + } + if !dataNode.IsObject() { + return errors.New("'data' 字段必须是一个JSON对象") + } + + // 重新初始化 decoder 用于 data 节点的校验 + decoder = json.NewDecoder(bytes.NewReader([]byte(dataNode.Raw))) + decoder.UseNumber() + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return errors.New("'data' 字段解析起始符失败") + } + + seenRawMaterials = make(map[string]bool) // 重置 seenRawMaterials 用于 data 节点校验 + + for decoder.More() { + // 1. 解析原料名称 + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("解析原料名称失败: %w", err) + } + rawMaterialName := t.(string) + if seenRawMaterials[rawMaterialName] { + return fmt.Errorf("原料名称 '%s' 重复", rawMaterialName) + } + seenRawMaterials[rawMaterialName] = true + + // 2. 解析该原料的营养成分对象 + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName) + } + + seenNutrients := make(map[string]bool) + for decoder.More() { + // 解析营养素名称 + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("在原料 '%s' 中解析营养素名称失败: %w", rawMaterialName, err) + } + nutrientName := t.(string) + if seenNutrients[nutrientName] { + return fmt.Errorf("在原料 '%s' 中, 营养素名称 '%s' 重复", rawMaterialName, nutrientName) + } + seenNutrients[nutrientName] = true + + // 解析营养素含量 + t, err = decoder.Token() + if err != nil { + return fmt.Errorf("在原料 '%s' 中解析营养素 '%s' 的含量值失败: %w", rawMaterialName, nutrientName, err) + } + if _, ok := t.(json.Number); !ok { + return fmt.Errorf("期望营养素 '%s' 的含量值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t) + } + } + + // 读取营养成分对象的 "}" + if t, err := decoder.Token(); err != nil || t != json.Delim('}') { + return fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName) + } + } + return nil +} diff --git a/internal/infra/database/seeder/pig_nutrient_requirement_seeder.go b/internal/infra/database/seeder/pig_nutrient_requirement_seeder.go new file mode 100644 index 0000000..fbd361b --- /dev/null +++ b/internal/infra/database/seeder/pig_nutrient_requirement_seeder.go @@ -0,0 +1,281 @@ +package seeder + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + "git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" + "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" + + "github.com/tidwall/gjson" + "gorm.io/gorm" +) + +// SeedPigNutrientRequirements 先严格校验JSON源文件,然后以“有则跳过”的模式播种数据。 +func SeedPigNutrientRequirements(ctx context.Context, tx *gorm.DB, jsonData []byte) error { + logger := logs.GetLogger(ctx) + + // 检查 PigBreed 表是否为空,如果非空则跳过播种 + isEmpty, err := isTableEmpty(tx, &models.PigBreed{}) + if err != nil { + return fmt.Errorf("检查 PigBreed 表是否为空失败: %w", err) + } + if !isEmpty { + logger.Info("已存在猪种数据, 跳过数据播种") + return nil + } + + // 1. 严格校验JSON文件,检查内部重复键 + if err := validateAndParsePigNutrientRequirementJSON(jsonData); err != nil { + return fmt.Errorf("JSON源文件校验失败: %w", err) + } + + // 2. 解析简介信息 + descriptionsNode := gjson.GetBytes(jsonData, "descriptions") + pigBreedDescriptions := make(map[string]models.PigBreed) + pigAgeStageDescriptions := make(map[string]models.PigAgeStage) + pigTypeDescriptions := make(map[string]map[string]models.PigType) + + if descriptionsNode.Exists() { + // 解析 pig_breeds 描述 + descriptionsNode.Get("pig_breeds").ForEach(func(key, value gjson.Result) bool { + var pb models.PigBreed + pb.Name = key.String() + pb.Description = value.Get("description").String() + pb.ParentInfo = value.Get("parent_info").String() + pb.AppearanceFeatures = value.Get("appearance_features").String() + pb.BreedAdvantages = value.Get("breed_advantages").String() + pb.BreedDisadvantages = value.Get("breed_disadvantages").String() + pigBreedDescriptions[key.String()] = pb + return true + }) + + // 解析 pig_age_stages 描述 + descriptionsNode.Get("pig_age_stages").ForEach(func(key, value gjson.Result) bool { + var pas models.PigAgeStage + pas.Name = key.String() + pas.Description = value.String() + pigAgeStageDescriptions[key.String()] = pas + return true + }) + + // 解析 pig_breed_age_stages (PigType) 描述 + descriptionsNode.Get("pig_breed_age_stages").ForEach(func(breedKey, breedValue gjson.Result) bool { + if _, ok := pigTypeDescriptions[breedKey.String()]; !ok { + pigTypeDescriptions[breedKey.String()] = make(map[string]models.PigType) + } + breedValue.ForEach(func(ageStageKey, ageStageValue gjson.Result) bool { + var pt models.PigType + pt.Description = ageStageValue.Get("description").String() + pt.DailyFeedIntake = float32(ageStageValue.Get("daily_feed_intake").Float()) + pt.DailyGainWeight = float32(ageStageValue.Get("daily_gain_weight").Float()) + pt.MinDays = uint32(ageStageValue.Get("min_days").Uint()) + pt.MaxDays = uint32(ageStageValue.Get("max_days").Uint()) + pt.MinWeight = float32(ageStageValue.Get("min_weight").Float()) + pt.MaxWeight = float32(ageStageValue.Get("max_weight").Float()) + pigTypeDescriptions[breedKey.String()][ageStageKey.String()] = pt + return true + }) + return true + }) + } + + // 3. 将通过校验的、干净的数据写入数据库 + dataNode := gjson.GetBytes(jsonData, "data") + dataNode.ForEach(func(breedKey, breedValue gjson.Result) bool { + breedName := breedKey.String() + var pigBreed models.PigBreed + // 查找或创建 PigBreed + pbDesc := pigBreedDescriptions[breedName] + err = tx.Where(models.PigBreed{Name: breedName}). + FirstOrCreate(&pigBreed, models.PigBreed{ + Name: breedName, + Description: pbDesc.Description, + ParentInfo: pbDesc.ParentInfo, + AppearanceFeatures: pbDesc.AppearanceFeatures, + BreedAdvantages: pbDesc.BreedAdvantages, + BreedDisadvantages: pbDesc.BreedDisadvantages, + }).Error + if err != nil { + return false + } + + breedValue.ForEach(func(ageStageKey, ageStageValue gjson.Result) bool { + ageStageName := ageStageKey.String() + var pigAgeStage models.PigAgeStage + // 查找或创建 PigAgeStage + pasDesc := pigAgeStageDescriptions[ageStageName] + err = tx.Where(models.PigAgeStage{Name: ageStageName}). + FirstOrCreate(&pigAgeStage, models.PigAgeStage{ + Name: ageStageName, + Description: pasDesc.Description, + }).Error + + if err != nil { + return false + } + + var pigType models.PigType + // 查找或创建 PigType + ptDesc := pigTypeDescriptions[breedName][ageStageName] + err = tx.Where(models.PigType{BreedID: pigBreed.ID, AgeStageID: pigAgeStage.ID}). + FirstOrCreate(&pigType, models.PigType{ + BreedID: pigBreed.ID, + AgeStageID: pigAgeStage.ID, + Description: ptDesc.Description, + DailyFeedIntake: ptDesc.DailyFeedIntake, + DailyGainWeight: ptDesc.DailyGainWeight, + MinDays: ptDesc.MinDays, + MaxDays: ptDesc.MaxDays, + MinWeight: ptDesc.MinWeight, + MaxWeight: ptDesc.MaxWeight, + }).Error + if err != nil { + return false + } + + ageStageValue.ForEach(func(nutrientKey, nutrientValue gjson.Result) bool { + nutrientName := nutrientKey.String() + minReq := float32(nutrientValue.Get("min_requirement").Float()) + maxReq := float32(nutrientValue.Get("max_requirement").Float()) + + var nutrient models.Nutrient + // 查找或创建 Nutrient (这里假设 Nutrient 已经在 SeedNutrients 中处理,但为了健壮性,再次 FirstOrCreate) + err = tx.Where(models.Nutrient{Name: nutrientName}). + FirstOrCreate(&nutrient, models.Nutrient{ + Name: nutrientName, + // Description 字段在 nutrient seeder 中处理,这里不设置 + }).Error + if err != nil { + return false + } + + linkData := models.PigNutrientRequirement{ + PigTypeID: pigType.ID, + NutrientID: nutrient.ID, + MinRequirement: minReq, + MaxRequirement: maxReq, + } + // 使用 FirstOrCreate 确保关联的唯一性 + err = tx.Where(models.PigNutrientRequirement{ + PigTypeID: pigType.ID, + NutrientID: nutrient.ID, + }).FirstOrCreate(&linkData, linkData).Error + if err != nil { + return false + } + return true + }) + return err == nil // 如果内部遍历有错误,则停止外部遍历 + }) + return err == nil // 如果内部遍历有错误,则停止外部遍历 + }) + return err // 返回捕获到的错误 +} + +// validateAndParsePigNutrientRequirementJSON 严格校验猪营养需求JSON文件 +func validateAndParsePigNutrientRequirementJSON(jsonData []byte) error { + dataNode := gjson.GetBytes(jsonData, "data") + if !dataNode.Exists() { + return errors.New("JSON文件中缺少 'data' 字段") + } + if !dataNode.IsObject() { + return errors.New("'data' 字段必须是一个JSON对象") + } + + decoder := json.NewDecoder(bytes.NewReader([]byte(dataNode.Raw))) + decoder.UseNumber() + + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("'data' 字段解析起始符失败: %v", err) + } + + seenBreeds := make(map[string]bool) + + for decoder.More() { + // 解析 PigBreed 名称 + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("解析猪品种名称失败: %w", err) + } + breedName := t.(string) + if seenBreeds[breedName] { + return fmt.Errorf("猪品种名称 '%s' 重复", breedName) + } + seenBreeds[breedName] = true + + // 解析该品种的年龄阶段对象 + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("期望猪品种 '%s' 的值是一个JSON对象", breedName) + } + + seenAgeStages := make(map[string]bool) + + for decoder.More() { + // 解析 PigAgeStage 名称 + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("在猪品种 '%s' 中解析年龄阶段名称失败: %w", breedName, err) + } + ageStageName := t.(string) + if seenAgeStages[ageStageName] { + return fmt.Errorf("在猪品种 '%s' 中, 年龄阶段名称 '%s' 重复", breedName, ageStageName) + } + seenAgeStages[ageStageName] = true + + // 解析该年龄阶段的营养成分对象 + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("期望年龄阶段 '%s' 的值是一个JSON对象", ageStageName) + } + + seenNutrients := make(map[string]bool) + + for decoder.More() { + // 解析 Nutrient 名称 + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("在年龄阶段 '%s' 中解析营养素名称失败: %w", ageStageName, err) + } + nutrientName := t.(string) + if seenNutrients[nutrientName] { + return fmt.Errorf("在年龄阶段 '%s' 中, 营养素名称 '%s' 重复", ageStageName, nutrientName) + } + seenNutrients[nutrientName] = true + + // 解析 min_requirement 和 max_requirement 对象 + if t, err := decoder.Token(); err != nil || t != json.Delim('{') { + return fmt.Errorf("期望营养素 '%s' 的值是一个JSON对象", nutrientName) + } + + for decoder.More() { + t, err := decoder.Token() + if err != nil { + return fmt.Errorf("解析营养素 '%s' 的需求键失败: %w", nutrientName, err) + } + // key := t.(string) // 校验时不需要使用 key 的值 + + t, err = decoder.Token() + if err != nil { + return fmt.Errorf("解析营养素 '%s' 的需求值失败: %w", nutrientName, err) + } + if _, ok := t.(json.Number); !ok { + return fmt.Errorf("期望营养素 '%s' 的需求值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t) + } + } + if t, err := decoder.Token(); err != nil || t != json.Delim('}') { + return fmt.Errorf("解析营养素 '%s' 的值结束符 '}' 失败", nutrientName) + } + } + if t, err := decoder.Token(); err != nil || t != json.Delim('}') { + return fmt.Errorf("解析年龄阶段 '%s' 的值结束符 '}' 失败", ageStageName) + } + } + if t, err := decoder.Token(); err != nil || t != json.Delim('}') { + return fmt.Errorf("解析猪品种 '%s' 的值结束符 '}' 失败", breedName) + } + } + return nil +} diff --git a/internal/infra/database/seeder/utils.go b/internal/infra/database/seeder/utils.go new file mode 100644 index 0000000..9281580 --- /dev/null +++ b/internal/infra/database/seeder/utils.go @@ -0,0 +1,18 @@ +package seeder + +import ( + "fmt" + + "gorm.io/gorm" +) + +// isTableEmpty 检查给定模型对应的数据库表是否为空。 +// 注意:此函数需要从 database 包中移动过来,或者在 seeder 包中重新定义, +// 为了避免循环依赖,这里选择在 seeder 包中重新定义。 +func isTableEmpty(tx *gorm.DB, model interface{}) (bool, error) { + var count int64 + if err := tx.Model(model).Count(&count).Error; err != nil { + return false, fmt.Errorf("查询表记录数失败: %w", err) + } + return count == 0, nil +}