Files
iqudo-top1/internal/database/database.go
Eric 1549169735@qq.com da3f16e6db update
2026-09-13 20:08:13 +08:00

305 lines
11 KiB
Go

package database
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Open(cfg config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
Logger: newGORMLogger(log.Named("gorm"), gormlogger.Warn),
})
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get sql database: %w", err)
}
sqlDB.SetMaxIdleConns(cfg.MaxIdleConnections)
sqlDB.SetMaxOpenConns(cfg.MaxOpenConnections)
sqlDB.SetConnMaxLifetime(cfg.ConnectionMaxLifetime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping mysql: %w", err)
}
return db, nil
}
// AutoMigrate synchronizes the application schema from the GORM models.
// GORM only adds missing tables, columns and indexes; it does not drop
// 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
}
if err := dropLegacyKnowledgeSchema(db); err != nil {
return err
}
return db.AutoMigrate(
&model.Tenant{},
&model.User{},
&model.Role{},
&model.TenantMember{},
&model.Scenario{},
&model.ScenarioField{},
&model.ScenarioRule{},
&model.SOP{},
&model.SOPNode{},
&model.SOPEdge{},
&model.ScriptPackage{},
&model.PackageDimension{},
&model.DimensionValue{},
&model.PackageStage{},
&model.StageScript{},
&model.ScriptOption{},
&model.DimensionLinkage{},
&model.PackageAdapter{},
&model.RunDimension{},
&model.ScriptFeedback{},
&model.SOPRun{},
&model.SOPRunEvent{},
&model.PublicRunSession{},
&model.SOPFeedback{},
&model.AuditLog{},
&model.MultiTableOutbox{},
&model.RefreshToken{},
)
}
// dropLegacyKnowledgeSchema removes the knowledge-graph tables and the run
// snapshot column that the script-package rewrite no longer uses.
func dropLegacyKnowledgeSchema(db *gorm.DB) error {
for _, table := range []string{"knowledge_relations", "knowledge_items"} {
if db.Migrator().HasTable(table) {
if err := db.Exec("DROP TABLE " + quoteIdentifier(table)).Error; err != nil {
return fmt.Errorf("drop legacy table %s: %w", table, err)
}
}
}
if db.Migrator().HasTable("sop_runs") && db.Migrator().HasColumn("sop_runs", "knowledge_snapshot") {
if err := db.Exec("ALTER TABLE sop_runs DROP COLUMN knowledge_snapshot").Error; err != nil {
return fmt.Errorf("drop sop_runs.knowledge_snapshot: %w", err)
}
}
return nil
}
// 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
}
func newGORMLogger(log *zap.Logger, level gormlogger.LogLevel) gormlogger.Interface {
return &zapGORMLogger{log: log, level: level}
}
func (l *zapGORMLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
clone := *l
clone.level = level
return &clone
}
func (l *zapGORMLogger) Info(_ context.Context, msg string, data ...interface{}) {
if l.level >= gormlogger.Info {
l.log.Sugar().Infof(msg, data...)
}
}
func (l *zapGORMLogger) Warn(_ context.Context, msg string, data ...interface{}) {
if l.level >= gormlogger.Warn {
l.log.Sugar().Warnf(msg, data...)
}
}
func (l *zapGORMLogger) Error(_ context.Context, msg string, data ...interface{}) {
if l.level >= gormlogger.Error {
l.log.Sugar().Errorf(msg, data...)
}
}
func (l *zapGORMLogger) Trace(_ context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.level == gormlogger.Silent {
return
}
sql, rows := fc()
fields := []zap.Field{zap.Duration("duration", time.Since(begin)), zap.Int64("rows", rows), zap.String("sql", sql)}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error {
l.log.Error("query failed", append(fields, zap.Error(err))...)
return
}
if l.level >= gormlogger.Info {
l.log.Debug("query", fields...)
}
}