迁移配置文件, 实现从json文件中读取原材料营养预设值, 并自动写入数据库
This commit is contained in:
@@ -14,9 +14,10 @@ import (
|
||||
|
||||
// Application 是整个应用的核心,封装了所有组件和生命周期。
|
||||
type Application struct {
|
||||
Config *config.Config
|
||||
Ctx context.Context
|
||||
API *api.API
|
||||
cfgPath string
|
||||
Config *config.Config
|
||||
Ctx context.Context
|
||||
API *api.API
|
||||
|
||||
Infra *Infrastructure
|
||||
Domain *DomainServices
|
||||
@@ -68,12 +69,13 @@ func NewApplication(configPath string) (*Application, error) {
|
||||
|
||||
// 4. 组装 Application 对象
|
||||
app := &Application{
|
||||
Config: cfg,
|
||||
Ctx: selfCtx,
|
||||
API: apiServer,
|
||||
Infra: infra,
|
||||
Domain: domain,
|
||||
App: appServices,
|
||||
cfgPath: configPath,
|
||||
Config: cfg,
|
||||
Ctx: selfCtx,
|
||||
API: apiServer,
|
||||
Infra: infra,
|
||||
Domain: domain,
|
||||
App: appServices,
|
||||
}
|
||||
|
||||
return app, nil
|
||||
@@ -90,7 +92,7 @@ func (app *Application) Start() error {
|
||||
}
|
||||
|
||||
// 2. 初始化应用状态 (清理、刷新任务等)
|
||||
if err := app.initializeState(startCtx); err != nil {
|
||||
if err := app.initializeState(startCtx, app.cfgPath); err != nil {
|
||||
return fmt.Errorf("初始化应用状态失败: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/database"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
|
||||
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
|
||||
@@ -12,27 +14,35 @@ import (
|
||||
|
||||
// initializeState 在应用启动时准备其初始数据状态。
|
||||
// 它遵循一个严格的顺序:清理 -> 更新 -> 刷新,以确保数据的一致性和正确性。
|
||||
func (app *Application) initializeState(ctx context.Context) error {
|
||||
func (app *Application) initializeState(ctx context.Context, cfgPath string) error {
|
||||
appCtx, logger := logs.Trace(ctx, app.Ctx, "InitializeState")
|
||||
|
||||
// 1. 清理所有上次运行时遗留的待执行任务和相关日志。
|
||||
// 1. 播种预设数据
|
||||
logger.Info("开始播种预设数据...")
|
||||
presetDir := filepath.Join(filepath.Dir(cfgPath), "presets-data")
|
||||
if err := database.SeedFromPreset(appCtx, app.Infra.storage.GetDB(appCtx), presetDir); err != nil {
|
||||
return fmt.Errorf("预设数据播种失败: %w", err)
|
||||
}
|
||||
logger.Info("预设数据播种成功。")
|
||||
|
||||
// 2. 清理所有上次运行时遗留的待执行任务和相关日志。
|
||||
// 这一步必须在任何可能修改计划结构的操作之前执行,以避免外键约束冲突。
|
||||
if err := app.cleanupStaleTasksAndLogs(appCtx); err != nil {
|
||||
return fmt.Errorf("清理过期的任务及日志失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 清理待采集任务 (非致命错误)。
|
||||
// 3. 清理待采集任务 (非致命错误)。
|
||||
if err := app.initializePendingCollections(appCtx); err != nil {
|
||||
logger.Errorw("清理待采集任务时发生非致命错误", "error", err)
|
||||
}
|
||||
|
||||
// 3. 初始化并更新系统计划。
|
||||
// 4. 初始化并更新系统计划。
|
||||
// 此时,所有旧的待执行任务已被清除,可以安全地更新计划结构。
|
||||
if err := app.initializeSystemPlans(ctx); err != nil {
|
||||
return fmt.Errorf("初始化预定义系统计划失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 最后,根据最新的计划状态,统一刷新所有计划的触发器。
|
||||
// 5. 最后,根据最新的计划状态,统一刷新所有计划的触发器。
|
||||
// 这一步确保了新创建或更新的系统计划能够被正确地调度。
|
||||
logger.Info("正在刷新所有计划的触发器...")
|
||||
if err := app.Domain.planService.RefreshPlanTriggers(appCtx); err != nil {
|
||||
|
||||
203
internal/infra/database/seeder.go
Normal file
203
internal/infra/database/seeder.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// SeederFunc 定义了处理一种特定类型预设数据文件的函数签名。
|
||||
type SeederFunc func(tx *gorm.DB, jsonData []byte) error
|
||||
|
||||
// SeedFromPreset 是一个通用的数据播种函数。
|
||||
// 它会读取指定目录下的所有 .json 文件,并根据文件内容中的 "type" 字段进行分发。
|
||||
// 同时,它会校验所有必需的预设类型是否都已成功加载。
|
||||
func SeedFromPreset(ctx context.Context, db *gorm.DB, presetDir string) error {
|
||||
logger := logs.TraceLogger(ctx, ctx, "SeedFromPreset")
|
||||
|
||||
// 定义必须存在的预设数据类型
|
||||
requiredTypes := []string{"nutrient"}
|
||||
processedTypes := make(map[string]bool)
|
||||
|
||||
// 用于检测重复的 type
|
||||
typeToFileMap := make(map[string]string)
|
||||
|
||||
files, err := os.ReadDir(presetDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取预设数据目录 '%s' 失败: %w", presetDir, err)
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(presetDir, file.Name())
|
||||
jsonData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取文件 '%s' 失败: %w", filePath, err)
|
||||
}
|
||||
|
||||
dataType := gjson.GetBytes(jsonData, "type")
|
||||
if !dataType.Exists() {
|
||||
logger.Warnf("警告: 文件 '%s' 中缺少 'type' 字段,已跳过\n", filePath)
|
||||
continue
|
||||
}
|
||||
dataTypeStr := dataType.String()
|
||||
|
||||
if existingFile, found := typeToFileMap[dataTypeStr]; found {
|
||||
return fmt.Errorf("预设数据校验失败: type '%s' 在文件 '%s' 和 '%s' 中重复定义", dataTypeStr, existingFile, filePath)
|
||||
}
|
||||
typeToFileMap[dataTypeStr] = filePath
|
||||
|
||||
var seederFunc SeederFunc
|
||||
switch dataTypeStr {
|
||||
case "nutrient":
|
||||
seederFunc = seedNutrients
|
||||
default:
|
||||
logger.Warnf("警告: 文件 '%s' 中存在未知的 type: '%s',已跳过\n", filePath, dataTypeStr)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := seederFunc(tx, jsonData); err != nil {
|
||||
return fmt.Errorf("处理文件 '%s' (type: %s) 时发生错误: %w", filePath, dataTypeStr, err)
|
||||
}
|
||||
processedTypes[dataTypeStr] = true
|
||||
}
|
||||
|
||||
// 校验所有必需的类型是否都已处理
|
||||
var missingTypes []string
|
||||
for _, reqType := range requiredTypes {
|
||||
if !processedTypes[reqType] {
|
||||
missingTypes = append(missingTypes, reqType)
|
||||
}
|
||||
}
|
||||
if len(missingTypes) > 0 {
|
||||
return fmt.Errorf("预设数据校验失败: 缺少必需的预设文件类型: [%s]", strings.Join(missingTypes, ", "))
|
||||
}
|
||||
|
||||
return nil // 提交事务
|
||||
})
|
||||
}
|
||||
|
||||
// seedNutrients 先严格校验JSON源文件,然后以“有则跳过”的模式播种数据。
|
||||
func seedNutrients(tx *gorm.DB, jsonData []byte) error {
|
||||
// 1. 严格校验JSON文件,检查内部重复键
|
||||
parsedData, err := validateAndParseNutrientJSON(jsonData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON源文件校验失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 将通过校验的、干净的数据写入数据库
|
||||
for rawMaterialName, nutrients := range parsedData {
|
||||
var rawMaterial models.RawMaterial
|
||||
if err := tx.Where(models.RawMaterial{Name: rawMaterialName}).FirstOrCreate(&rawMaterial).Error; err != nil {
|
||||
return fmt.Errorf("预设原料 '%s' 失败: %w", rawMaterialName, err)
|
||||
}
|
||||
|
||||
for nutrientName, value := range nutrients {
|
||||
var nutrient models.Nutrient
|
||||
if err := tx.Where(models.Nutrient{Name: nutrientName}).FirstOrCreate(&nutrient).Error; err != nil {
|
||||
return fmt.Errorf("预设营养素 '%s' 失败: %w", nutrientName, err)
|
||||
}
|
||||
|
||||
linkData := models.RawMaterialNutrient{
|
||||
RawMaterialID: rawMaterial.ID,
|
||||
NutrientID: nutrient.ID,
|
||||
}
|
||||
// 使用 FirstOrCreate 确保关联的唯一性
|
||||
if err := tx.Where(linkData).FirstOrCreate(&linkData, models.RawMaterialNutrient{
|
||||
RawMaterialID: linkData.RawMaterialID,
|
||||
NutrientID: linkData.NutrientID,
|
||||
Value: value,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("为原料 '%s' 和营养素 '%s' 创建关联失败: %w", rawMaterialName, nutrientName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAndParseNutrientJSON 使用 json.Decoder 手动解析,以捕获重复的键。
|
||||
func validateAndParseNutrientJSON(jsonData []byte) (map[string]map[string]float32, error) {
|
||||
dataNode := gjson.GetBytes(jsonData, "data")
|
||||
if !dataNode.Exists() {
|
||||
return nil, errors.New("JSON文件中缺少 'data' 字段")
|
||||
}
|
||||
if !dataNode.IsObject() {
|
||||
return nil, 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 nil, errors.New("'data' 字段解析起始符失败")
|
||||
}
|
||||
|
||||
result := make(map[string]map[string]float32)
|
||||
seenRawMaterials := make(map[string]bool)
|
||||
|
||||
for decoder.More() {
|
||||
// 1. 解析原料名称
|
||||
t, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析原料名称失败: %w", err)
|
||||
}
|
||||
rawMaterialName := t.(string)
|
||||
if seenRawMaterials[rawMaterialName] {
|
||||
return nil, fmt.Errorf("原料名称 '%s' 重复", rawMaterialName)
|
||||
}
|
||||
seenRawMaterials[rawMaterialName] = true
|
||||
|
||||
// 2. 解析该原料的营养成分对象
|
||||
if t, err := decoder.Token(); err != nil || t != json.Delim('{') {
|
||||
return nil, fmt.Errorf("期望原料 '%s' 的值是一个JSON对象", rawMaterialName)
|
||||
}
|
||||
|
||||
nutrients := make(map[string]float32)
|
||||
seenNutrients := make(map[string]bool)
|
||||
for decoder.More() {
|
||||
// 解析营养素名称
|
||||
t, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("在原料 '%s' 中解析营养素名称失败: %w", rawMaterialName, err)
|
||||
}
|
||||
nutrientName := t.(string)
|
||||
if seenNutrients[nutrientName] {
|
||||
return nil, fmt.Errorf("在原料 '%s' 中, 营养素名称 '%s' 重复", rawMaterialName, nutrientName)
|
||||
}
|
||||
seenNutrients[nutrientName] = true
|
||||
|
||||
// 解析营养素含量
|
||||
t, err = decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("在原料 '%s' 中解析营养素 '%s' 的含量值失败: %w", rawMaterialName, nutrientName, err)
|
||||
}
|
||||
if value, ok := t.(json.Number); ok {
|
||||
f64, _ := value.Float64()
|
||||
nutrients[nutrientName] = float32(f64)
|
||||
} else {
|
||||
return nil, fmt.Errorf("期望营养素 '%s' 的含量值是数字, 但实际得到的类型是 %T, 值为 '%v'", nutrientName, t, t)
|
||||
}
|
||||
}
|
||||
// 读取营养成分对象的 "}"
|
||||
if t, err := decoder.Token(); err != nil || t != json.Delim('}') {
|
||||
return nil, fmt.Errorf("解析原料 '%s' 的值结束符 '}' 失败", rawMaterialName)
|
||||
}
|
||||
result[rawMaterialName] = nutrients
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -18,14 +18,6 @@ const (
|
||||
StockLogSourceFermentEnd StockLogSourceType = "发酵入库" // 发酵料产出,作为新原料计入库存
|
||||
)
|
||||
|
||||
// NutrientType 定义了营养素的分类,用于配方优化和成本控制。
|
||||
type NutrientType string
|
||||
|
||||
const (
|
||||
PositiveNutrient NutrientType = "正面营养" // 希望在配方中最大化的营养素,如蛋白质、能量
|
||||
NegativeNutrient NutrientType = "负面营养" // 需要控制上限的营养素,如粗纤维、霉菌毒素
|
||||
)
|
||||
|
||||
// RawMaterial 代表一种原料的静态定义,是系统中的原料字典。
|
||||
type RawMaterial struct {
|
||||
Model
|
||||
@@ -45,9 +37,8 @@ func (RawMaterial) TableName() string {
|
||||
// 约定:宏量营养素(粗蛋白等)单位为百分比(%),微量元素(氨基酸等)单位为毫克/千克(mg/kg)。
|
||||
type Nutrient struct {
|
||||
Model
|
||||
Name string `gorm:"size:100;unique;not null;comment:营养素名称"`
|
||||
Type NutrientType `gorm:"size:50;not null;comment:营养素类型 (正面营养/负面营养)"`
|
||||
Description string `gorm:"size:255;comment:描述"`
|
||||
Name string `gorm:"size:100;unique;not null;comment:营养素名称"`
|
||||
Description string `gorm:"size:255;comment:描述"`
|
||||
}
|
||||
|
||||
func (Nutrient) TableName() string {
|
||||
|
||||
Reference in New Issue
Block a user