fix(backend): harden final MVP invariants

This commit is contained in:
2026-07-22 01:24:41 +08:00
parent 251b212e06
commit 78d4845688
26 changed files with 977 additions and 175 deletions

View 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
}

View 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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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