fix: remove legacy knowledge card tables and compatibility code

This commit is contained in:
Eric 1549169735@qq.com
2026-08-20 21:58:04 +08:00
parent ebaf9b0b05
commit 6185e6cd49
36 changed files with 545 additions and 546 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
@@ -43,6 +44,17 @@ func Open(cfg config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
// existing columns, which keeps production data safe during application
// upgrades.
func AutoMigrate(db *gorm.DB) error {
// The application schema intentionally has no foreign keys. Remove any
// constraints left by older installations before dropping legacy tables.
if err := dropAllForeignKeys(db); err != nil {
return err
}
if err := dropUnusedLegacyTables(db); err != nil {
return err
}
if err := prepareSingleVersionSchema(db); err != nil {
return err
}
return db.AutoMigrate(
&model.Tenant{},
&model.User{},
@@ -52,11 +64,8 @@ func AutoMigrate(db *gorm.DB) error {
&model.ScenarioField{},
&model.ScenarioRule{},
&model.SOP{},
&model.SOPVersion{},
&model.SOPNode{},
&model.SOPEdge{},
&model.KnowledgeCard{},
&model.KnowledgeCardVersion{},
&model.KnowledgeItem{},
&model.KnowledgeRelation{},
&model.SOPRun{},
@@ -69,6 +78,154 @@ func AutoMigrate(db *gorm.DB) error {
)
}
// dropUnusedLegacyTables removes schema objects that are no longer part of
// the scenario knowledge graph. Drop the version table before its parent so
// this remains safe for databases that still have a historical constraint.
func dropUnusedLegacyTables(db *gorm.DB) error {
for _, table := range []string{"knowledge_card_versions", "knowledge_cards"} {
if !db.Migrator().HasTable(table) {
continue
}
if err := db.Exec("DROP TABLE " + quoteIdentifier(table)).Error; err != nil {
return fmt.Errorf("drop unused table %s: %w", table, err)
}
}
return nil
}
// prepareSingleVersionSchema performs the data-preserving part of the SOP
// version removal. GORM does not rename or drop columns, so existing installs
// need their current published version copied onto the single SOP graph.
func prepareSingleVersionSchema(db *gorm.DB) error {
if !db.Migrator().HasTable("sops") {
return nil
}
// Foreign keys are intentionally not part of the application schema. Drop
// historical constraints before GORM rebuilds any tables during migration;
// old version references may contain rows that no longer have a parent.
if err := dropAllForeignKeys(db); err != nil {
return err
}
if !db.Migrator().HasColumn("sops", "start_node_key") {
if err := db.Exec("ALTER TABLE sops ADD COLUMN start_node_key VARCHAR(64) NOT NULL DEFAULT 'start'").Error; err != nil {
return fmt.Errorf("add sops.start_node_key: %w", err)
}
}
if db.Migrator().HasTable("sop_versions") {
if err := db.Exec("UPDATE sops s JOIN sop_versions sv ON sv.id = (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = s.id AND sv2.status = 'published') SET s.start_node_key = COALESCE(NULLIF(sv.start_node_key, ''), NULLIF(s.start_node_key, ''), 'start')").Error; err != nil {
return fmt.Errorf("copy SOP start nodes: %w", err)
}
}
for _, table := range []string{"sop_nodes", "sop_edges", "sop_runs"} {
if !db.Migrator().HasTable(table) {
continue
}
if !db.Migrator().HasColumn(table, "sop_id") {
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN sop_id BIGINT UNSIGNED NULL", table)).Error; err != nil {
return fmt.Errorf("add %s.sop_id: %w", table, err)
}
}
if db.Migrator().HasTable("sop_versions") && db.Migrator().HasColumn(table, "sop_version_id") {
if err := db.Exec(fmt.Sprintf("UPDATE %s x JOIN sop_versions sv ON sv.id = x.sop_version_id SET x.sop_id = sv.sop_id", table)).Error; err != nil {
return fmt.Errorf("copy %s SOP references: %w", table, err)
}
}
}
if db.Migrator().HasTable("sop_versions") {
if err := db.Exec("DELETE n FROM sop_nodes n JOIN sop_versions sv ON sv.id = n.sop_version_id WHERE sv.status <> 'published' OR sv.id <> (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = sv.sop_id AND sv2.status = 'published')").Error; err != nil {
return fmt.Errorf("remove historical SOP nodes: %w", err)
}
if err := db.Exec("DELETE e FROM sop_edges e JOIN sop_versions sv ON sv.id = e.sop_version_id WHERE sv.status <> 'published' OR sv.id <> (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = sv.sop_id AND sv2.status = 'published')").Error; err != nil {
return fmt.Errorf("remove historical SOP edges: %w", err)
}
}
if err := removeLegacySOPVersionColumns(db); err != nil {
return err
}
return nil
}
// removeLegacySOPVersionColumns completes the single-version migration. The
// old columns are not represented by the GORM models, so AutoMigrate cannot
// remove them. Leaving a NOT NULL sop_version_id behind makes inserts fail.
func removeLegacySOPVersionColumns(db *gorm.DB) error {
for _, table := range []string{"sop_nodes", "sop_edges", "sop_runs"} {
if !db.Migrator().HasTable(table) {
continue
}
if !db.Migrator().HasColumn(table, "sop_id") {
continue
}
var missing int64
if err := db.Table(table).Where("sop_id IS NULL").Count(&missing).Error; err != nil {
return fmt.Errorf("check %s SOP references: %w", table, err)
}
if missing > 0 {
return fmt.Errorf("cannot remove %s.sop_version_id: %d rows have no sop_id", table, missing)
}
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN sop_id BIGINT UNSIGNED NOT NULL", quoteIdentifier(table))).Error; err != nil {
return fmt.Errorf("make %s.sop_id required: %w", table, err)
}
if !db.Migrator().HasColumn(table, "sop_version_id") {
continue
}
if err := dropIndexesForColumn(db, table, "sop_version_id"); err != nil {
return err
}
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN sop_version_id", quoteIdentifier(table))).Error; err != nil {
return fmt.Errorf("drop %s.sop_version_id: %w", table, err)
}
}
if db.Migrator().HasTable("sop_versions") {
if err := db.Exec("DROP TABLE sop_versions").Error; err != nil {
return fmt.Errorf("drop sop_versions: %w", err)
}
}
return nil
}
func dropIndexesForColumn(db *gorm.DB, table, column string) error {
var indexes []struct {
Name string `gorm:"column:index_name"`
}
if err := db.Raw("SELECT DISTINCT INDEX_NAME AS index_name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND INDEX_NAME <> 'PRIMARY'", table, column).Scan(&indexes).Error; err != nil {
return fmt.Errorf("list %s.%s indexes: %w", table, column, err)
}
for _, index := range indexes {
if index.Name == "" {
continue
}
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s DROP INDEX %s", quoteIdentifier(table), quoteIdentifier(index.Name))).Error; err != nil {
return fmt.Errorf("drop index %s.%s: %w", table, index.Name, err)
}
}
return nil
}
func dropAllForeignKeys(db *gorm.DB) error {
var constraints []struct {
TableName string `gorm:"column:table_name"`
ConstraintName string `gorm:"column:constraint_name"`
}
if err := db.Raw("SELECT TABLE_NAME AS table_name, CONSTRAINT_NAME AS constraint_name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE = 'FOREIGN KEY'").Scan(&constraints).Error; err != nil {
return fmt.Errorf("list foreign keys: %w", err)
}
for _, constraint := range constraints {
if constraint.TableName == "" || constraint.ConstraintName == "" {
continue
}
query := fmt.Sprintf("ALTER TABLE %s DROP FOREIGN KEY %s", quoteIdentifier(constraint.TableName), quoteIdentifier(constraint.ConstraintName))
if err := db.Exec(query).Error; err != nil {
return fmt.Errorf("drop foreign key %s.%s: %w", constraint.TableName, constraint.ConstraintName, err)
}
}
return nil
}
func quoteIdentifier(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
type zapGORMLogger struct {
log *zap.Logger
level gormlogger.LogLevel