From 6b5ce40140ea23cd09516ff3ec156dad46054a35 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Mar 2026 00:22:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(smoke):=20=E4=BF=AE=E5=A4=8D=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=20AI=20=E5=BB=BA=E8=AE=AE=E9=99=8D=E7=BA=A7=E4=B8=8E?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/database.go | 4 + internal/database/smoke_index_migration.go | 58 +++++++++++++ .../database/smoke_index_migration_test.go | 81 +++++++++++++++++++ internal/smoke/handler/smoke_home_handler.go | 5 +- internal/smoke/model/smoke_ai_next_smoke.go | 2 +- 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 internal/database/smoke_index_migration.go create mode 100644 internal/database/smoke_index_migration_test.go diff --git a/internal/database/database.go b/internal/database/database.go index 52b6624..53138fb 100755 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -98,6 +98,10 @@ func AutoMigrate(models ...interface{}) error { } } } + + if err := repairSmokeAINextSmokeIndexes(DB); err != nil { + return err + } return nil } diff --git a/internal/database/smoke_index_migration.go b/internal/database/smoke_index_migration.go new file mode 100644 index 0000000..3196bea --- /dev/null +++ b/internal/database/smoke_index_migration.go @@ -0,0 +1,58 @@ +package database + +import ( + "fmt" + "slices" + + "gorm.io/gorm" +) + +type mysqlIndexColumn struct { + KeyName string `gorm:"column:Key_name"` + SeqInIndex int `gorm:"column:Seq_in_index"` + ColumnName string `gorm:"column:Column_name"` +} + +func repairSmokeAINextSmokeIndexes(db *gorm.DB) error { + if db == nil { + return nil + } + + const ( + tableName = "fa_smoke_ai_next_smoke" + indexName = "uniq_smoke_ai_next_node" + ) + + var rows []mysqlIndexColumn + if err := db.Raw(fmt.Sprintf("SHOW INDEX FROM `%s` WHERE Key_name = ?", tableName), indexName).Scan(&rows).Error; err != nil { + return fmt.Errorf("inspect %s: %w", indexName, err) + } + + expected := []string{"ai_advice_id", "node_type", "node_at"} + actual := make([]string, 0, len(rows)) + for _, row := range rows { + actual = append(actual, row.ColumnName) + } + + if slices.Equal(actual, expected) { + return nil + } + + if len(rows) > 0 { + if err := db.Exec(fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, indexName)).Error; err != nil { + return fmt.Errorf("drop %s: %w", indexName, err) + } + } + + if err := db.Exec( + fmt.Sprintf( + "ALTER TABLE `%s` ADD UNIQUE KEY `%s` (`ai_advice_id`,`node_type`,`node_at`)", + tableName, + indexName, + ), + ).Error; err != nil { + return fmt.Errorf("create %s: %w", indexName, err) + } + + return nil +} diff --git a/internal/database/smoke_index_migration_test.go b/internal/database/smoke_index_migration_test.go new file mode 100644 index 0000000..a5bc991 --- /dev/null +++ b/internal/database/smoke_index_migration_test.go @@ -0,0 +1,81 @@ +package database + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func newMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + + gdb, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("gorm.Open: %v", err) + } + + return gdb, mock, func() { _ = sqlDB.Close() } +} + +func TestRepairSmokeAINextSmokeIndexesRecreatesBrokenIndex(t *testing.T) { + t.Parallel() + + db, mock, cleanup := newMockDB(t) + defer cleanup() + + mock.ExpectQuery("SHOW INDEX FROM `fa_smoke_ai_next_smoke` WHERE Key_name = \\?"). + WithArgs("uniq_smoke_ai_next_node"). + WillReturnRows( + sqlmock.NewRows([]string{"Key_name", "Seq_in_index", "Column_name"}). + AddRow("uniq_smoke_ai_next_node", 1, "node_type"). + AddRow("uniq_smoke_ai_next_node", 2, "node_at"), + ) + mock.ExpectExec("ALTER TABLE `fa_smoke_ai_next_smoke` DROP INDEX `uniq_smoke_ai_next_node`"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("ALTER TABLE `fa_smoke_ai_next_smoke` ADD UNIQUE KEY `uniq_smoke_ai_next_node` \\(`ai_advice_id`,`node_type`,`node_at`\\)"). + WillReturnResult(sqlmock.NewResult(0, 0)) + + if err := repairSmokeAINextSmokeIndexes(db); err != nil { + t.Fatalf("repairSmokeAINextSmokeIndexes: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} + +func TestRepairSmokeAINextSmokeIndexesKeepsCorrectIndex(t *testing.T) { + t.Parallel() + + db, mock, cleanup := newMockDB(t) + defer cleanup() + + mock.ExpectQuery("SHOW INDEX FROM `fa_smoke_ai_next_smoke` WHERE Key_name = \\?"). + WithArgs("uniq_smoke_ai_next_node"). + WillReturnRows( + sqlmock.NewRows([]string{"Key_name", "Seq_in_index", "Column_name"}). + AddRow("uniq_smoke_ai_next_node", 1, "ai_advice_id"). + AddRow("uniq_smoke_ai_next_node", 2, "node_type"). + AddRow("uniq_smoke_ai_next_node", 3, "node_at"), + ) + + if err := repairSmokeAINextSmokeIndexes(db); err != nil { + t.Fatalf("repairSmokeAINextSmokeIndexes: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} diff --git a/internal/smoke/handler/smoke_home_handler.go b/internal/smoke/handler/smoke_home_handler.go index ba0e965..f49dd56 100644 --- a/internal/smoke/handler/smoke_home_handler.go +++ b/internal/smoke/handler/smoke_home_handler.go @@ -3,6 +3,7 @@ package handler import ( "errors" "fmt" + "log" "math" "net/http" "strings" @@ -140,8 +141,8 @@ func (h *SmokeHandler) Home(c *gin.Context) { case errors.Is(err, smokeservice.ErrNoSmokeLogs): adviceCard.Status = "no_data" default: - c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取 AI 建议失败,请稍后重试")) - return + log.Printf("[smoke_home] ai advice degraded uid=%d date=%s err=%v", user.ID, adviceDate.Format(dateLayout), err) + adviceCard.Status = "unavailable" } } else if record != nil { adviceCard.Message = record.Advice diff --git a/internal/smoke/model/smoke_ai_next_smoke.go b/internal/smoke/model/smoke_ai_next_smoke.go index 187be22..4b59aca 100644 --- a/internal/smoke/model/smoke_ai_next_smoke.go +++ b/internal/smoke/model/smoke_ai_next_smoke.go @@ -12,7 +12,7 @@ type SmokeAINextSmoke struct { UID int `gorm:"column:uid;index:idx_smoke_ai_next_uid_date,priority:1;comment:用户ID" json:"-"` PlanDate time.Time `gorm:"column:plan_date;type:date;index:idx_smoke_ai_next_uid_date,priority:2;comment:计划日期(当天)" json:"plan_date"` - AIAdviceID uint `gorm:"column:ai_advice_id;index:idx_smoke_ai_next_advice,priority:1;comment:关联AI建议ID(fa_smoke_ai_advice.id)" json:"ai_advice_id"` + AIAdviceID uint `gorm:"column:ai_advice_id;index:idx_smoke_ai_next_advice,priority:1;uniqueIndex:uniq_smoke_ai_next_node,priority:1;comment:关联AI建议ID(fa_smoke_ai_advice.id)" json:"ai_advice_id"` // NodeType: not_before / suggested / node NodeType string `gorm:"column:node_type;size:20;index:idx_smoke_ai_next_advice,priority:2;uniqueIndex:uniq_smoke_ai_next_node,priority:2;comment:节点类型" json:"node_type"`