// Command migrate_refresh 一次性建表脚本(与 scripts/sql/*.sql 同源,手写 DDL 以保持与项目"SQL 托管表结构"约定一致)。 // // 运行:go run ./scripts/migrate_refresh // 可用环境变量 DB_DSN 覆盖连接串(默认 root:root@127.0.0.1:3306/db_dev)。 package main import ( "log" "os" "gorm.io/driver/mysql" "gorm.io/gorm" ) func main() { dsn := os.Getenv("DB_DSN") if dsn == "" { dsn = "root:root@tcp(127.0.0.1:3306)/db_dev?charset=utf8mb4&parseTime=True&loc=Local" } db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { log.Fatalf("open db: %v", err) } stmt := `CREATE TABLE IF NOT EXISTS refresh_tokens ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at INT UNSIGNED NOT NULL DEFAULT 0, revoked TINYINT UNSIGNED NOT NULL DEFAULT 0, user_agent VARCHAR(255) NOT NULL DEFAULT '', ip VARCHAR(64) NOT NULL DEFAULT '', created_at INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_token_hash (token_hash), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` if err := db.Exec(stmt).Error; err != nil { log.Fatalf("create refresh_tokens: %v", err) } log.Println("✓ refresh_tokens ready") stmtFav := `CREATE TABLE IF NOT EXISTS favorites ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, created_at INT UNSIGNED NOT NULL DEFAULT 0, user_id INT UNSIGNED NOT NULL, target_type VARCHAR(16) NOT NULL DEFAULT '', target_uid VARCHAR(64) NOT NULL DEFAULT '', title VARCHAR(255) NOT NULL DEFAULT '', cover VARCHAR(512) NOT NULL DEFAULT '', brand VARCHAR(255) NOT NULL DEFAULT '', PRIMARY KEY (id), UNIQUE KEY uniq_user_target (user_id, target_uid), KEY idx_user (user_id), KEY idx_user_created (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` if err := db.Exec(stmtFav).Error; err != nil { log.Fatalf("create favorites: %v", err) } // 复合索引(user_id, created_at):优化收藏分页深翻页(10万收藏场景);已存在则忽略报错 if err := db.Exec("ALTER TABLE favorites ADD INDEX idx_user_created (user_id, created_at)").Error; err != nil { log.Printf("ℹ favorites idx_user_created 已存在,可忽略: %v", err) } log.Println("✓ favorites ready") // histories:文章级浏览历史(2026-09-02 收敛)。 // 仅存 user_id + target_uid(带 r=/s= 前缀的对外编码串,前缀即类型)+ viewed_at; // kind/target_type/title/cover/brand 快照已弃用(账户页展示时回查公开详情接口)。 stmtHist := `CREATE TABLE IF NOT EXISTS histories ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, viewed_at INT UNSIGNED NOT NULL DEFAULT 0, user_id INT UNSIGNED NOT NULL, target_uid VARCHAR(64) NOT NULL DEFAULT '', PRIMARY KEY (id), UNIQUE KEY uniq_user_target (user_id, target_uid), KEY idx_user_viewed (user_id, viewed_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` if err := db.Exec(stmtHist).Error; err != nil { log.Fatalf("create histories: %v", err) } // —— 存量库收敛(幂等):清掉旧固定串行(runway-looks/street-snaps),卸掉旧列与旧唯一键,换上新的 —— // 旧结构列/键可能不存在(新库)或已收敛(二次执行),每条失败仅提示、不中断。 trimHist := []string{ "DELETE FROM histories WHERE target_uid NOT LIKE 'r=%' AND target_uid NOT LIKE 's=%'", "ALTER TABLE histories DROP KEY uniq_user_kind_target", "ALTER TABLE histories DROP COLUMN kind", "ALTER TABLE histories DROP COLUMN target_type", "ALTER TABLE histories DROP COLUMN title", "ALTER TABLE histories DROP COLUMN cover", "ALTER TABLE histories DROP COLUMN brand", "ALTER TABLE histories ADD UNIQUE KEY uniq_user_target (user_id, target_uid)", } for _, stmt := range trimHist { if err := db.Exec(stmt).Error; err != nil { log.Printf("ℹ histories 收敛步骤可忽略(可能已收敛/新库): %v", err) } } log.Println("✓ histories ready (article-level: user_id+target_uid+viewed_at)") }