Files
jiang13-bbs/backend/model/db.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

521 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
&Badge{}, &UserBadge{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
); 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)
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
backfillLibraryCreator(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"},
{"chat_messages", "fk_chat_messages_sender"},
}
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)
}
}
// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入)
// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行;
// 站长不存在(空库)时跳过,等下次启动自愈。
func backfillLibraryCreator(db *gorm.DB) {
var owner User
err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
if err != nil {
log.Printf("[model] 回填书库创建者前置查询失败: %v", err)
return
}
res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID)
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 仅在板块表为空(全新站点首启)时写入默认板块。
// 曾按「名字是否存在」补种:站长改名板块后每次重启都会复活一套默认板块
// (真实事故:0.2.1 重启后默认 4 板块重现)。表里已有任何未删除板块即视为已初始化。
func seedDefaultBoards(db *gorm.DB) {
var count int64
db.Model(&Board{}).Count(&count)
if count > 0 {
return
}
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 i := range defaults {
_ = db.Create(&defaults[i]).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
}