package models import ( _ "embed" "encoding/json" "fmt" "strings" "time" "gorm.io/gorm" ) // SenlinAgentSchemaMigration 记录已完成的数据修复及审计摘要,确保升级可重入且可追溯。 type SenlinAgentSchemaMigration struct { Version int `gorm:"primaryKey;autoIncrement:false"` Name string `gorm:"not null"` Details string `gorm:"type:text;not null"` AppliedAt time.Time `gorm:"not null"` } func (SenlinAgentSchemaMigration) TableName() string { return "senlin_agent_schema_migrations" } type versionedMigration struct { version int name string run func(*gorm.DB) (map[string]int, error) } func runVersionedMigrations(database *gorm.DB) error { migrations := []versionedMigration{ {version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers}, {version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags}, {version: 3, name: "seed_ai_experts", run: seedAIExperts}, {version: 4, name: "normalize_ai_expert_categories", run: normalizeAIExpertCategories}, } for _, migration := range migrations { // Connection callbacks can provide an initialized Gorm session. Start each // migration from a clean statement so Model/Where clauses used to check the // version table are not inherited by the data-repair transaction. migrationDatabase := database.Session(&gorm.Session{NewDB: true}) var applied int64 if err := migrationDatabase.Model(&SenlinAgentSchemaMigration{}).Where("version = ?", migration.version).Count(&applied).Error; err != nil { return err } if applied > 0 { continue } if err := migrationDatabase.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error { details, err := migration.run(tx) if err != nil { return err } encoded, err := json.Marshal(details) if err != nil { return err } return tx.Create(&SenlinAgentSchemaMigration{ Version: migration.version, Name: migration.name, Details: string(encoded), AppliedAt: time.Now().UTC(), }).Error }); err != nil { return fmt.Errorf("schema migration %d (%s): %w", migration.version, migration.name, err) } } return nil } //go:embed experts_catalog.json var expertCatalogJSON []byte type embeddedExpertCatalog struct { Source string `json:"source"` Reference string `json:"reference"` License string `json:"license"` Experts []embeddedExpertCatalogItem `json:"experts"` } type embeddedExpertCatalogItem struct { Slug string `json:"slug"` Category string `json:"category"` CategoryName string `json:"categoryName"` Name string `json:"name"` Description string `json:"description"` Emoji string `json:"emoji"` Color string `json:"color"` SystemPrompt string `json:"systemPrompt"` } func seedAIExperts(tx *gorm.DB) (map[string]int, error) { catalog, categories, err := loadAIExpertCatalog(tx) if err != nil { return nil, err } records := make([]SenlinAgentAIExpert, 0, len(catalog.Experts)) for _, expert := range catalog.Experts { category := categories[expert.Category] records = append(records, SenlinAgentAIExpert{ Slug: expert.Slug, CategoryID: category.ID, CategoryIdentity: category.Identity, Category: expert.Category, CategoryName: expert.CategoryName, Name: expert.Name, Description: expert.Description, Emoji: expert.Emoji, Color: expert.Color, SystemPrompt: expert.SystemPrompt, Source: catalog.Source, Reference: catalog.Reference, SourceLicense: catalog.License, Enabled: true, }) } if len(records) > 0 { if err := tx.CreateInBatches(&records, 25).Error; err != nil { return nil, err } } return map[string]int{"inserted": len(records)}, nil } func normalizeAIExpertCategories(tx *gorm.DB) (map[string]int, error) { catalog, categories, err := loadAIExpertCatalog(tx) if err != nil { return nil, err } linked := 0 for _, expert := range catalog.Experts { category := categories[expert.Category] result := tx.Model(&SenlinAgentAIExpert{}).Where("slug = ?", expert.Slug).Updates(map[string]any{ "category_id": category.ID, "category_identity": category.Identity, "reference": catalog.Reference, }) if result.Error != nil { return nil, result.Error } linked += int(result.RowsAffected) } return map[string]int{"categories": len(categories), "linked": linked}, nil } func loadAIExpertCatalog(tx *gorm.DB) (embeddedExpertCatalog, map[string]SenlinAgentAIExpertCategory, error) { var catalog embeddedExpertCatalog if err := json.Unmarshal(expertCatalogJSON, &catalog); err != nil { return catalog, nil, fmt.Errorf("decode embedded AI expert catalog: %w", err) } categoryNames := make(map[string]string) categoryOrder := make([]string, 0) for _, expert := range catalog.Experts { if _, exists := categoryNames[expert.Category]; exists { continue } categoryNames[expert.Category] = expert.CategoryName categoryOrder = append(categoryOrder, expert.Category) } categories := make(map[string]SenlinAgentAIExpertCategory, len(categoryOrder)) for _, slug := range categoryOrder { category := SenlinAgentAIExpertCategory{Slug: slug} if err := tx.Where("slug = ?", slug). Attrs(SenlinAgentAIExpertCategory{Name: categoryNames[slug]}). FirstOrCreate(&category).Error; err != nil { return catalog, nil, err } if category.Name != categoryNames[slug] { category.Name = categoryNames[slug] if err := tx.Model(&category).Update("name", category.Name).Error; err != nil { return catalog, nil, err } } categories[slug] = category } return catalog, categories, nil } func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) { var projects []SenlinAgentProject if err := tx.Model(&SenlinAgentProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil { return nil, err } keepers := make(map[uint]map[string]uint) reserved := make(map[uint]map[string]struct{}) for _, project := range projects { identifier := strings.TrimSpace(project.Identifier) if identifier == "" { continue } if keepers[project.OwnerID] == nil { keepers[project.OwnerID] = make(map[string]uint) reserved[project.OwnerID] = make(map[string]struct{}) } if _, exists := keepers[project.OwnerID][identifier]; !exists { keepers[project.OwnerID][identifier] = project.ID } reserved[project.OwnerID][identifier] = struct{}{} } updated := 0 for _, project := range projects { if reserved[project.OwnerID] == nil { reserved[project.OwnerID] = make(map[string]struct{}) } identifier := strings.TrimSpace(project.Identifier) candidate := identifier if identifier == "" { candidate = uniqueIdentifier(fmt.Sprintf("project-%d", project.ID), reserved[project.OwnerID]) } else if keepers[project.OwnerID][identifier] != project.ID { candidate = uniqueIdentifier(fmt.Sprintf("%s-%d", identifier, project.ID), reserved[project.OwnerID]) } reserved[project.OwnerID][candidate] = struct{}{} if candidate == project.Identifier { continue } if err := tx.Model(&SenlinAgentProject{}).Where("id = ?", project.ID).Update("identifier", candidate).Error; err != nil { return nil, err } updated++ } if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uidx_senlin_agent_projects_owner_identifier ON senlin_agent_projects (owner_id, identifier)`).Error; err != nil { return nil, err } return map[string]int{"audited": len(projects), "updated": updated}, nil } func uniqueIdentifier(base string, reserved map[string]struct{}) string { candidate := base for suffix := 2; ; suffix++ { if _, exists := reserved[candidate]; !exists { return candidate } candidate = fmt.Sprintf("%s-%d", base, suffix) } } func deduplicateLegacyProjectTags(tx *gorm.DB) (map[string]int, error) { var tags []SenlinAgentTag if err := tx.Model(&SenlinAgentTag{}).Select("id", "identity", "project_id", "name").Order("project_id asc, name asc, id asc").Find(&tags).Error; err != nil { return nil, err } keepers := make(map[string]SenlinAgentTag) deduplicated := 0 for _, tag := range tags { key := fmt.Sprintf("%d\x00%s", tag.ProjectID, tag.Name) keeper, exists := keepers[key] if !exists { keepers[key] = tag continue } if err := tx.Model(&SenlinAgentTask{}).Where("tag_id = ?", tag.ID).Updates(map[string]any{ "tag_id": keeper.ID, "tag_identity": keeper.Identity, }).Error; err != nil { return nil, err } if err := tx.Where("id = ?", tag.ID).Delete(&SenlinAgentTag{}).Error; err != nil { return nil, err } deduplicated++ } if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uidx_senlin_agent_tags_project_name ON senlin_agent_tags (project_id, name)`).Error; err != nil { return nil, err } return map[string]int{"audited": len(tags), "deduplicated": deduplicated}, nil }