Files
agent/backend/internal/models/migrations.go

260 lines
8.7 KiB
Go

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
}