fix(backend): harden final MVP invariants
This commit is contained in:
150
backend/internal/models/migrations.go
Normal file
150
backend/internal/models/migrations.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"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},
|
||||
}
|
||||
for _, migration := range migrations {
|
||||
var applied int64
|
||||
if err := database.Model(&SenlinAgentSchemaMigration{}).Where("version = ?", migration.version).Count(&applied).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if applied > 0 {
|
||||
continue
|
||||
}
|
||||
if err := database.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
|
||||
}
|
||||
|
||||
func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) {
|
||||
var projects []SenlinAgentProject
|
||||
if err := tx.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.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
|
||||
}
|
||||
84
backend/internal/models/migrations_test.go
Normal file
84
backend/internal/models/migrations_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
legacySchema := []string{
|
||||
`CREATE TABLE senlin_agent_projects (id integer primary key, owner_id integer not null, name text not null, identifier text)`,
|
||||
`CREATE TABLE senlin_agent_tags (id integer primary key, identity text, project_id integer not null, name text not null)`,
|
||||
`CREATE TABLE senlin_agent_tasks (id integer primary key, project_id integer not null, created_by integer not null, tag_id integer, tag_identity text, title text not null, status text not null)`,
|
||||
`INSERT INTO senlin_agent_projects (id, owner_id, name, identifier) VALUES
|
||||
(1, 7, 'Blank one', ''),
|
||||
(2, 7, 'Blank two', NULL),
|
||||
(3, 7, 'Keeper', 'DUP'),
|
||||
(4, 7, 'Duplicate', 'DUP'),
|
||||
(5, 7, 'Reserved suffix', 'DUP-4'),
|
||||
(6, 8, 'Other owner', 'DUP')`,
|
||||
`INSERT INTO senlin_agent_tags (id, identity, project_id, name) VALUES
|
||||
(10, 'tag-keeper', 1, 'UI'),
|
||||
(11, 'tag-duplicate', 1, 'UI'),
|
||||
(12, 'tag-other-project', 2, 'UI')`,
|
||||
`INSERT INTO senlin_agent_tasks (id, project_id, created_by, tag_id, tag_identity, title, status)
|
||||
VALUES (20, 1, 7, 11, 'tag-duplicate', 'Keep association', 'open')`,
|
||||
}
|
||||
for _, statement := range legacySchema {
|
||||
require.NoError(t, database.Exec(statement).Error)
|
||||
}
|
||||
|
||||
require.NoError(t, AutoMigrate(database))
|
||||
|
||||
var projects []SenlinAgentProject
|
||||
require.NoError(t, database.Order("id asc").Find(&projects).Error)
|
||||
require.Len(t, projects, 6, "项目迁移不得删除 legacy 行")
|
||||
require.Equal(t, []string{"project-1", "project-2", "DUP", "DUP-4-2", "DUP-4", "DUP"}, projectIdentifiers(projects))
|
||||
|
||||
var tags []SenlinAgentTag
|
||||
require.NoError(t, database.Order("id asc").Find(&tags).Error)
|
||||
require.Equal(t, []uint{10, 12}, tagIDs(tags), "同项目同名标签应保留最早记录,不影响其他项目")
|
||||
var task SenlinAgentTask
|
||||
require.NoError(t, database.First(&task, 20).Error)
|
||||
require.NotNil(t, task.TagID)
|
||||
require.Equal(t, uint(10), *task.TagID)
|
||||
require.NotNil(t, task.TagIdentity)
|
||||
require.Equal(t, "tag-keeper", *task.TagIdentity)
|
||||
|
||||
var migrationCount int64
|
||||
require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error)
|
||||
require.Equal(t, int64(2), migrationCount)
|
||||
var auditDetails []string
|
||||
require.NoError(t, database.Table("senlin_agent_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error)
|
||||
require.Contains(t, auditDetails[0], `"updated":3`)
|
||||
require.Contains(t, auditDetails[1], `"deduplicated":1`)
|
||||
require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again")
|
||||
require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error)
|
||||
require.Equal(t, int64(2), migrationCount)
|
||||
|
||||
require.Error(t, database.Exec(`INSERT INTO senlin_agent_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error)
|
||||
require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error)
|
||||
}
|
||||
|
||||
func projectIdentifiers(projects []SenlinAgentProject) []string {
|
||||
result := make([]string, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
result = append(result, project.Identifier)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tagIDs(tags []SenlinAgentTag) []uint {
|
||||
result := make([]uint, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
result = append(result, tag.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -23,7 +23,8 @@ func New(dsn string) error {
|
||||
}
|
||||
|
||||
func AutoMigrate(database *gorm.DB) error {
|
||||
return database.AutoMigrate(
|
||||
if err := database.AutoMigrate(
|
||||
&SenlinAgentSchemaMigration{},
|
||||
&SenlinAgentUser{},
|
||||
&SenlinAgentProject{},
|
||||
&SenlinAgentInboxItem{},
|
||||
@@ -40,5 +41,8 @@ func AutoMigrate(database *gorm.DB) error {
|
||||
&SenlinAgentAICallLog{},
|
||||
&SenlinAgentAIRateBucket{},
|
||||
&SenlinAgentTaskShare{},
|
||||
)
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return runVersionedMigrations(database)
|
||||
}
|
||||
|
||||
@@ -7,16 +7,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPostgresPing(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_DSN")
|
||||
if dsn == "" {
|
||||
dsn = os.Getenv("DATABASE_URL")
|
||||
}
|
||||
require.NotEmpty(t, dsn, "DATABASE_DSN or DATABASE_URL is required for integration tests")
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
require.NotEmpty(t, dsn, "TEST_DATABASE_URL is required for integration tests and must point to an isolated database")
|
||||
|
||||
database, err := Open(dsn)
|
||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := database.DB()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -5,10 +5,10 @@ import "time"
|
||||
type SenlinAgentProject struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
OwnerID uint `gorm:"index;not null;uniqueIndex:uidx_senlin_agent_projects_owner_identifier,priority:1"`
|
||||
OwnerID uint `gorm:"index;not null"`
|
||||
OwnerIdentity string `gorm:"type:char(36);index"`
|
||||
Name string `gorm:"not null"`
|
||||
Identifier string `gorm:"index;uniqueIndex:uidx_senlin_agent_projects_owner_identifier,priority:2"`
|
||||
Identifier string `gorm:"index"`
|
||||
Icon string
|
||||
Background string
|
||||
Description string
|
||||
|
||||
Reference in New Issue
Block a user