2026-07-22 01:24:41 +08:00
package models
import (
2026-07-22 17:09:54 +08:00
_ "embed"
2026-07-22 01:24:41 +08:00
"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 } ,
2026-07-22 17:09:54 +08:00
{ version : 3 , name : "seed_ai_experts" , run : seedAIExperts } ,
2026-07-23 14:07:13 +08:00
{ version : 4 , name : "normalize_ai_expert_categories" , run : normalizeAIExpertCategories } ,
2026-07-22 01:24:41 +08:00
}
for _ , migration := range migrations {
2026-07-22 14:45:41 +08:00
// 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 } )
2026-07-22 01:24:41 +08:00
var applied int64
2026-07-22 14:45:41 +08:00
if err := migrationDatabase . Model ( & SenlinAgentSchemaMigration { } ) . Where ( "version = ?" , migration . version ) . Count ( & applied ) . Error ; err != nil {
2026-07-22 01:24:41 +08:00
return err
}
if applied > 0 {
continue
}
2026-07-22 14:45:41 +08:00
if err := migrationDatabase . Session ( & gorm . Session { NewDB : true } ) . Transaction ( func ( tx * gorm . DB ) error {
2026-07-22 01:24:41 +08:00
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
}
2026-07-22 17:09:54 +08:00
//go:embed experts_catalog.json
var expertCatalogJSON [ ] byte
type embeddedExpertCatalog struct {
2026-07-23 14:07:13 +08:00
Source string ` json:"source" `
Reference string ` json:"reference" `
License string ` json:"license" `
Experts [ ] embeddedExpertCatalogItem ` json:"experts" `
2026-07-22 17:09:54 +08:00
}
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 ) {
2026-07-23 14:07:13 +08:00
catalog , categories , err := loadAIExpertCatalog ( tx )
if err != nil {
return nil , err
2026-07-22 17:09:54 +08:00
}
records := make ( [ ] SenlinAgentAIExpert , 0 , len ( catalog . Experts ) )
for _ , expert := range catalog . Experts {
2026-07-23 14:07:13 +08:00
category := categories [ expert . Category ]
2026-07-22 17:09:54 +08:00
records = append ( records , SenlinAgentAIExpert {
2026-07-23 14:07:13 +08:00
Slug : expert . Slug , CategoryID : category . ID , CategoryIdentity : category . Identity ,
Category : expert . Category , CategoryName : expert . CategoryName ,
2026-07-22 17:09:54 +08:00
Name : expert . Name , Description : expert . Description , Emoji : expert . Emoji ,
Color : expert . Color , SystemPrompt : expert . SystemPrompt , Source : catalog . Source ,
2026-07-23 14:07:13 +08:00
Reference : catalog . Reference , SourceLicense : catalog . License , Enabled : true ,
2026-07-22 17:09:54 +08:00
} )
}
if len ( records ) > 0 {
if err := tx . CreateInBatches ( & records , 25 ) . Error ; err != nil {
return nil , err
}
}
return map [ string ] int { "inserted" : len ( records ) } , nil
}
2026-07-23 14:07:13 +08:00
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
}
2026-07-22 01:24:41 +08:00
func normalizeLegacyProjectIdentifiers ( tx * gorm . DB ) ( map [ string ] int , error ) {
var projects [ ] SenlinAgentProject
2026-07-22 14:45:41 +08:00
if err := tx . Model ( & SenlinAgentProject { } ) . Select ( "id" , "owner_id" , "identifier" ) . Order ( "owner_id asc, id asc" ) . Find ( & projects ) . Error ; err != nil {
2026-07-22 01:24:41 +08:00
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
2026-07-22 14:45:41 +08:00
if err := tx . Model ( & SenlinAgentTag { } ) . Select ( "id" , "identity" , "project_id" , "name" ) . Order ( "project_id asc, name asc, id asc" ) . Find ( & tags ) . Error ; err != nil {
2026-07-22 01:24:41 +08:00
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
}