package model import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "log" "os" "time" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) // DB 全局数据库实例 var DB *gorm.DB // InitDB 连接 PostgreSQL 并自动迁移 func InitDB(dsn string) error { db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.New( log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{ SlowThreshold: 200 * time.Millisecond, LogLevel: logger.Warn, IgnoreRecordNotFoundError: true, // First 未命中是业务常态,勿当 Warn 刷屏 Colorful: true, }, ), }) if err != nil { return fmt.Errorf("连接 PostgreSQL 失败: %w", err) } // 旧表 refresh_tokens.token(明文)→ token_hash 体系:必须在 AutoMigrate // 创建 NOT NULL 列/唯一索引之前完成回填 if err := prepareRefreshTokenMigration(db); err != nil { return fmt.Errorf("refresh token 旧数据迁移失败: %w", err) } // 清理前次失败迁移遗留的 FK 约束(constraint:- 标签防止新建,但不删旧的) if err := dropStaleChatFKConstraints(db); err != nil { return fmt.Errorf("清理遗留 FK 约束失败: %w", err) } // 公告 pinned:已有行不能直接 ADD NOT NULL,先加列并回填 false if err := prepareAnnouncementPinnedColumn(db); err != nil { return fmt.Errorf("公告置顶列迁移失败: %w", err) } // refresh_tokens 会话时间列:存量行不能直接 ADD NOT NULL if err := prepareRefreshTokenSessionColumns(db); err != nil { return fmt.Errorf("refresh token 会话列迁移失败: %w", err) } if err := db.AutoMigrate( &TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, &Badge{}, &UserBadge{}, &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &ImportRecord{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } // RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长; // 已存在 owner 时不动数据,保证幂等 if err := ensureOwnerRole(db); err != nil { return fmt.Errorf("站长角色迁移失败: %w", err) } // 用户等级:按积分流水回填累计获得积分(经验值,LevelOf 定级依据)。 // 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund) backfillUserTotalPoints(db) // login_logs.success 早期 default=true 与 GORM 零值省略叠加, // 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正 if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil { return fmt.Errorf("login_logs 默认值修正失败: %w", err) } // 早期 site_settings.value 可能是 varchar(255);广告/赞助商 JSON 会超长 if err := prepareSiteSettingValueText(db); err != nil { return fmt.Errorf("site_settings.value 扩容失败: %w", err) } // 新表结构就位后删除遗留的明文列 if err := dropLegacyRefreshTokenColumn(db); err != nil { return fmt.Errorf("refresh token 旧列清理失败: %w", err) } DB = db if err := db.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_direct_key ON chat_rooms (direct_key) WHERE room_type = 'direct' AND deleted_at IS NULL AND direct_key <> '' `).Error; err != nil { return fmt.Errorf("私聊唯一索引创建失败: %w", err) } // 昵称全站唯一(忽略大小写,软删用户不占用),与用户名同级约束: // 保证 @提及 昵称→用户名 映射无歧义。应用层另有友好错误提示(service/auth.go UpdateProfile)。 // 存量重名会令本索引创建失败(开发期数据可重置)。 if err := db.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_users_nickname_lower ON users (LOWER(nickname)) WHERE deleted_at IS NULL `).Error; err != nil { return fmt.Errorf("昵称唯一索引创建失败(存量数据可能存在重名昵称,请清理或重置数据库): %w", err) } backfillBoardDefaults(db) seedDefaultBoards(db) if err := EnsureDefaultChatRoom(db); err != nil { return fmt.Errorf("默认群聊大厅初始化失败: %w", err) } if err := ensureDefaultChatMemberships(db); err != nil { return fmt.Errorf("默认群聊入群补齐失败: %w", err) } log.Println("[model] PostgreSQL 数据库初始化完成") return nil } // PingDB 检测数据库连接 func PingDB() error { if DB == nil { return fmt.Errorf("数据库未初始化") } sqlDB, err := DB.DB() if err != nil { return err } return sqlDB.Ping() } // dropStaleChatFKConstraints 清理前次迁移失败遗留的 FK 约束。 // GORM AutoMigrate 先建表再加约束:首次迁移时 Notification.Room 的 FK 添加失败, // 但 chat_rooms 表已建好且带 fk_chat_rooms_last_message 约束。 // 模型已改用 constraint:- 防止新建,但 GORM 不会删除已存在的约束,需手动清理。 func dropStaleChatFKConstraints(db *gorm.DB) error { // 对每个遗留约束名,在可能的两张表上都尝试删除(IF EXISTS 保证幂等) pairs := []struct{ table, constraint string }{ {"chat_rooms", "fk_chat_rooms_last_message"}, {"notifications", "fk_notifications_room"}, } for _, p := range pairs { db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint)) } return nil } // prepareAnnouncementPinnedColumn 存量公告加 pinned NOT NULL:先 DEFAULT false 再回填 func prepareAnnouncementPinnedColumn(db *gorm.DB) error { var tableCount int64 if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'announcements'`). Scan(&tableCount).Error; err != nil { return err } if tableCount == 0 { return nil } var hasCol int64 if err := db.Raw(`SELECT count(1) FROM information_schema.columns WHERE table_name = 'announcements' AND column_name = 'pinned'`). Scan(&hasCol).Error; err != nil { return err } if hasCol > 0 { return nil } return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error } // prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等) func prepareSiteSettingValueText(db *gorm.DB) error { var tableCount int64 if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'site_settings'`). Scan(&tableCount).Error; err != nil { return err } if tableCount == 0 { return nil } var dataType string if err := db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'site_settings' AND column_name = 'value'`). Scan(&dataType).Error; err != nil { return err } if dataType == "" || dataType == "text" { return nil } return db.Exec(`ALTER TABLE site_settings ALTER COLUMN value TYPE text`).Error } // prepareRefreshTokenSessionColumns 存量 refresh_tokens 加 logged_in_at / last_used_at: // 先可空加列,用 created_at 回填后再收紧 NOT NULL。 func prepareRefreshTokenSessionColumns(db *gorm.DB) error { var tableCount int64 if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'refresh_tokens'`). Scan(&tableCount).Error; err != nil { return err } if tableCount == 0 { return nil } for _, col := range []string{"logged_in_at", "last_used_at"} { if err := db.Exec(fmt.Sprintf( `ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS %s timestamptz`, col, )).Error; err != nil { return err } if err := db.Exec(fmt.Sprintf( `UPDATE refresh_tokens SET %s = created_at WHERE %s IS NULL`, col, col, )).Error; err != nil { return err } if err := db.Exec(fmt.Sprintf( `ALTER TABLE refresh_tokens ALTER COLUMN %s SET NOT NULL`, col, )).Error; err != nil { return err } } return nil } // prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列, // 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前: // 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败) // 2. 用存量明文回填哈希 // 3. AutoMigrate 随后补唯一索引/其余新列 // // 旧行无法回填 TokenCipher(密钥在 service 层),仅影响该行下一次轮转的 // 并发重放宽限,属一次性边界;轮转后即完全进入新体系。 func prepareRefreshTokenMigration(db *gorm.DB) error { var tableCount int64 if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'refresh_tokens'`). Scan(&tableCount).Error; err != nil { return err } if tableCount == 0 { return nil // 全新数据库,AutoMigrate 直接建新表 } var hasHashCol int64 if err := db.Raw(`SELECT count(1) FROM information_schema.columns WHERE table_name = 'refresh_tokens' AND column_name = 'token_hash'`). Scan(&hasHashCol).Error; err != nil { return err } if hasHashCol > 0 { return nil // 已是新结构 } var hasLegacyCol int64 if err := db.Raw(`SELECT count(1) FROM information_schema.columns WHERE table_name = 'refresh_tokens' AND column_name = 'token'`). Scan(&hasLegacyCol).Error; err != nil { return err } if hasLegacyCol == 0 { return nil } if err := db.Exec(`DELETE FROM refresh_tokens WHERE token IS NULL OR token = ''`).Error; err != nil { return err } if err := db.Exec(`ALTER TABLE refresh_tokens ADD COLUMN token_hash varchar(64) NOT NULL DEFAULT ''`).Error; err != nil { return err } type legacyRow struct { ID uint Token string } var rows []legacyRow if err := db.Raw(`SELECT id, token FROM refresh_tokens`).Scan(&rows).Error; err != nil { return err } for _, r := range rows { sum := sha256.Sum256([]byte(r.Token)) if err := db.Exec( `UPDATE refresh_tokens SET token_hash = ? WHERE id = ?`, hex.EncodeToString(sum[:]), r.ID, ).Error; err != nil { return err } } log.Printf("[model] refresh_tokens 已回填 %d 行 token_hash", len(rows)) return nil } // dropLegacyRefreshTokenColumn 新结构就位后删除明文 token 列(PostgreSQL // 会连带删除该列上的旧唯一索引) func dropLegacyRefreshTokenColumn(db *gorm.DB) error { var hasLegacyCol int64 if err := db.Raw(`SELECT count(1) FROM information_schema.columns WHERE table_name = 'refresh_tokens' AND column_name = 'token'`). Scan(&hasLegacyCol).Error; err != nil { return err } if hasLegacyCol == 0 { return nil } return db.Exec(`ALTER TABLE refresh_tokens DROP COLUMN token`).Error } // ensureOwnerRole 若无站长,则把 id 最小的旧管理员升级为站长; // 连管理员都没有则升级 id 最小的账号。空库(尚无用户)直接返回,等首次注册。 // 禁止 GORM Update+Limit:无 WHERE 主键时会生成全表 UPDATE 并报 WHERE conditions required。 func ensureOwnerRole(db *gorm.DB) error { var ownerCount int64 if err := db.Model(&User{}).Where("role = ?", RoleOwner).Count(&ownerCount).Error; err != nil { return err } if ownerCount > 0 { return nil } var candidate User err := db.Select("id").Where("role = ?", RoleAdmin).Order("id ASC").First(&candidate).Error if errors.Is(err, gorm.ErrRecordNotFound) { err = db.Select("id").Order("id ASC").First(&candidate).Error } if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } if err := db.Model(&User{}).Where("id = ?", candidate.ID).Update("role", RoleOwner).Error; err != nil { return err } log.Println("[model] 已迁移初始账号为站长角色(owner)") return nil } // backfillUserTotalPoints 按积分流水回填累计获得积分(经验值)。 // 口径与 CreditTx 增量一致:仅计正入账,排除悬赏托管退回(bounty_refund)。 // 每次启动执行,幂等自愈;失败仅记日志不阻断启动(等级纯展示,可下次启动修复)。 func backfillUserTotalPoints(db *gorm.DB) { var tableCount int64 if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'point_ledgers'`). Scan(&tableCount).Error; err != nil { log.Printf("[model] 回填累计获得积分前置检查失败: %v", err) return } if tableCount == 0 { return } res := db.Exec(` UPDATE users SET total_points = COALESCE(( SELECT SUM(delta) FROM point_ledgers WHERE point_ledgers.user_id = users.id AND delta > 0 AND reason <> 'bounty_refund' ), 0) WHERE deleted_at IS NULL `) if res.Error != nil { log.Printf("[model] 回填累计获得积分失败: %v", res.Error) return } if res.RowsAffected > 0 { log.Printf("[model] 已按积分流水回填累计获得积分 %d 行", res.RowsAffected) } } // backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。 // 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填 // post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。 func backfillBoardDefaults(db *gorm.DB) { if err := db.Model(&Board{}). Where("post_policy = '' OR post_policy IS NULL"). Updates(map[string]interface{}{ "post_policy": BoardPostPolicyAll, "visible": true, }).Error; err != nil { log.Printf("[model] 板块默认值回填失败: %v", err) } } // seedDefaultBoards 写入默认板块 func seedDefaultBoards(db *gorm.DB) { defaults := []Board{ {Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true}, {Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true}, {Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true}, {Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true}, } for _, b := range defaults { var count int64 db.Model(&Board{}).Where("name = ?", b.Name).Count(&count) if count == 0 { _ = db.Create(&b).Error } } } // EnsureDefaultChatRoom 确保存在唯一的全站大厅(公开默认群)。 // 导出供注册链路在启动播种时因空库跳过后就地补建(自愈,不依赖重启)。 func EnsureDefaultChatRoom(db *gorm.DB) error { var n int64 if err := db.Model(&ChatRoom{}).Where("is_default = ?", true).Count(&n).Error; err != nil { return err } if n > 0 { return nil } var owner User if err := db.Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error; err != nil { // 尚无用户时跳过,等有站长后再由下次启动补齐 if errors.Is(err, gorm.ErrRecordNotFound) { return nil } // 尝试任意用户 if err2 := db.Order("id ASC").First(&owner).Error; err2 != nil { return nil } } room := ChatRoom{ Name: "全站大厅", Description: "所有成员默认加入的公开群聊", OwnerID: owner.ID, RoomType: ChatRoomTypeGroup, IsPrivate: false, IsDefault: true, MemberCount: 0, } if err := db.Create(&room).Error; err != nil { return err } log.Println("[model] 已创建默认群聊「全站大厅」") return nil } // ensureDefaultChatMemberships 把未封禁用户补进默认大厅(幂等) func ensureDefaultChatMemberships(db *gorm.DB) error { var room ChatRoom if err := db.Where("is_default = ?", true).First(&room).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } var userIDs []uint if err := db.Model(&User{}). Where("deleted_at IS NULL AND banned = ?", false). Pluck("id", &userIDs).Error; err != nil { return err } if len(userIDs) == 0 { return nil } var existing []uint if err := db.Model(&ChatRoomMember{}). Where("room_id = ?", room.ID). Pluck("user_id", &existing).Error; err != nil { return err } have := make(map[uint]struct{}, len(existing)) for _, id := range existing { have[id] = struct{}{} } added := 0 for _, uid := range userIDs { if _, ok := have[uid]; ok { continue } role := ChatRoleMember if uid == room.OwnerID { role = ChatRoleOwner } m := ChatRoomMember{RoomID: room.ID, UserID: uid, Role: role} if err := db.Create(&m).Error; err != nil { continue } added++ } if added > 0 { _ = db.Model(&ChatRoom{}).Where("id = ?", room.ID). UpdateColumn("member_count", gorm.Expr( "(SELECT count(*) FROM chat_room_members WHERE room_id = ?)", room.ID, )).Error log.Printf("[model] 默认群聊补齐入群 %d 人", added) } return nil }