fix(smoke): 修复首页 AI 建议降级与节点索引

This commit is contained in:
root
2026-03-14 00:22:28 +08:00
parent 4b52b6eefe
commit 6b5ce40140
5 changed files with 147 additions and 3 deletions
+4
View File
@@ -98,6 +98,10 @@ func AutoMigrate(models ...interface{}) error {
}
}
}
if err := repairSmokeAINextSmokeIndexes(DB); err != nil {
return err
}
return nil
}
@@ -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
}
@@ -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)
}
}
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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"`