站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
578 lines
18 KiB
Go
578 lines
18 KiB
Go
package model
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
"github.com/freefire/jiang13-bbs/markdown"
|
||
"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.Default.LogMode(logger.Warn),
|
||
})
|
||
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(
|
||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||
&PostPollVote{}, &PostLotteryEntry{},
|
||
); err != nil {
|
||
return fmt.Errorf("自动迁移失败: %w", err)
|
||
}
|
||
|
||
// 旧帖整帖可见性 → 正文 [hide] 块(幂等)
|
||
if err := migratePostContentAccessToHideBlocks(db); err != nil {
|
||
return fmt.Errorf("正文隐藏块迁移失败: %w", err)
|
||
}
|
||
// 旧式 :::hide / :::timeline → 行级 BBCode(幂等)
|
||
if err := migrateLegacyDirectiveSyntax(db); err != nil {
|
||
return fmt.Errorf("短代码语法迁移失败: %w", err)
|
||
}
|
||
|
||
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
|
||
if err := backfillPointsFromCheckin(db); err != nil {
|
||
return fmt.Errorf("积分余额回填失败: %w", err)
|
||
}
|
||
|
||
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
|
||
// 已存在 owner 时不动数据,保证幂等
|
||
if err := ensureOwnerRole(db); err != nil {
|
||
return fmt.Errorf("站长角色迁移失败: %w", err)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 新表结构就位后删除遗留的明文列
|
||
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)
|
||
}
|
||
seedDefaultBoards(db)
|
||
if err := seedDefaultChatRoom(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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// backfillPointsFromCheckin 把历史签到积分写入 User.Points(幂等:仅 points=0 且有签到)
|
||
func backfillPointsFromCheckin(db *gorm.DB) error {
|
||
type row struct {
|
||
UserID uint
|
||
Total int
|
||
}
|
||
var rows []row
|
||
if err := db.Raw(`
|
||
SELECT c.user_id, COALESCE(SUM(c.points), 0)::int AS total
|
||
FROM checkins c
|
||
INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL AND u.points = 0
|
||
GROUP BY c.user_id
|
||
HAVING COALESCE(SUM(c.points), 0) > 0
|
||
`).Scan(&rows).Error; err != nil {
|
||
return err
|
||
}
|
||
for _, r := range rows {
|
||
err := db.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Model(&User{}).Where("id = ? AND points = 0", r.UserID).
|
||
Update("points", r.Total).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Create(&PointLedger{
|
||
UserID: r.UserID,
|
||
Delta: r.Total,
|
||
Balance: r.Total,
|
||
Reason: PointReasonMigrateCheckin,
|
||
Note: "历史签到积分回填",
|
||
}).Error
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if len(rows) > 0 {
|
||
log.Printf("[model] 已回填 %d 名用户的签到积分余额", len(rows))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// seedDefaultBoards 写入默认板块
|
||
func seedDefaultBoards(db *gorm.DB) {
|
||
defaults := []Board{
|
||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1},
|
||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2},
|
||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3},
|
||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4},
|
||
}
|
||
for _, b := range defaults {
|
||
var count int64
|
||
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
|
||
if count == 0 {
|
||
_ = db.Create(&b).Error
|
||
}
|
||
}
|
||
}
|
||
|
||
// seedDefaultChatRoom 确保存在唯一的全站大厅(公开默认群)
|
||
func seedDefaultChatRoom(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
|
||
}
|
||
|
||
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 [hide] 块(幂等)。
|
||
func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
|
||
var posts []Post
|
||
if err := db.Unscoped().
|
||
Where("content_access IN ? AND deleted_at IS NULL",
|
||
[]string{ContentAccessLogin, ContentAccessReply, ContentAccessPoints}).
|
||
Find(&posts).Error; err != nil {
|
||
return err
|
||
}
|
||
n := 0
|
||
for i := range posts {
|
||
p := &posts[i]
|
||
if markdown.HasHideBlocks(p.Content) {
|
||
continue
|
||
}
|
||
kind := NormalizeContentAccess(p.ContentAccess)
|
||
pts := 0
|
||
if kind == ContentAccessPoints {
|
||
pts = p.AccessPoints
|
||
if pts <= 0 {
|
||
pts = 1
|
||
}
|
||
}
|
||
wrapped := markdown.WrapContentAsHide(kind, pts, p.Content)
|
||
if err := db.Model(&Post{}).Where("id = ?", p.ID).
|
||
Update("content", wrapped).Error; err != nil {
|
||
return err
|
||
}
|
||
n++
|
||
}
|
||
if n > 0 {
|
||
log.Printf("[model] 旧帖可见性已迁入隐藏块:%d 篇", n)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// migrateLegacyDirectiveSyntax 将正文中的 :::hide / :::timeline 改写为 BBCode(幂等)。
|
||
func migrateLegacyDirectiveSyntax(db *gorm.DB) error {
|
||
type row struct {
|
||
ID uint
|
||
Content string
|
||
}
|
||
rewriteTable := func(table string, rows []row) (int, error) {
|
||
n := 0
|
||
for _, r := range rows {
|
||
if !strings.Contains(r.Content, ":::") {
|
||
continue
|
||
}
|
||
next := markdown.RewriteLegacyDirectives(r.Content)
|
||
if next == r.Content {
|
||
continue
|
||
}
|
||
if err := db.Table(table).Where("id = ?", r.ID).
|
||
Update("content", next).Error; err != nil {
|
||
return n, err
|
||
}
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
var posts []row
|
||
if err := db.Model(&Post{}).Unscoped().Select("id", "content").
|
||
Where("content LIKE ?", "%:::%").Find(&posts).Error; err != nil {
|
||
return err
|
||
}
|
||
if n, err := rewriteTable("posts", posts); err != nil {
|
||
return err
|
||
} else if n > 0 {
|
||
log.Printf("[model] 帖子短代码改写:%d 篇", n)
|
||
}
|
||
|
||
var comments []row
|
||
if err := db.Model(&Comment{}).Unscoped().Select("id", "content").
|
||
Where("content LIKE ?", "%:::%").Find(&comments).Error; err != nil {
|
||
return err
|
||
}
|
||
if n, err := rewriteTable("comments", comments); err != nil {
|
||
return err
|
||
} else if n > 0 {
|
||
log.Printf("[model] 评论短代码改写:%d 条", n)
|
||
}
|
||
|
||
var anns []row
|
||
if err := db.Model(&Announcement{}).Unscoped().Select("id", "content").
|
||
Where("content LIKE ?", "%:::%").Find(&anns).Error; err != nil {
|
||
return err
|
||
}
|
||
if n, err := rewriteTable("announcements", anns); err != nil {
|
||
return err
|
||
} else if n > 0 {
|
||
log.Printf("[model] 公告短代码改写:%d 条", n)
|
||
}
|
||
|
||
var pages []row
|
||
if err := db.Model(&SitePage{}).Unscoped().Select("id", "content").
|
||
Where("content LIKE ?", "%:::%").Find(&pages).Error; err != nil {
|
||
return err
|
||
}
|
||
if n, err := rewriteTable("site_pages", pages); err != nil {
|
||
return err
|
||
} else if n > 0 {
|
||
log.Printf("[model] 站点页短代码改写:%d 篇", n)
|
||
}
|
||
|
||
var hist []struct {
|
||
ID uint
|
||
OldContent string
|
||
}
|
||
if err := db.Model(&CommentEditHistory{}).Select("id", "old_content").
|
||
Where("old_content LIKE ?", "%:::%").Find(&hist).Error; err != nil {
|
||
return err
|
||
}
|
||
nHist := 0
|
||
for _, r := range hist {
|
||
next := markdown.RewriteLegacyDirectives(r.OldContent)
|
||
if next == r.OldContent {
|
||
continue
|
||
}
|
||
if err := db.Model(&CommentEditHistory{}).Where("id = ?", r.ID).
|
||
Update("old_content", next).Error; err != nil {
|
||
return err
|
||
}
|
||
nHist++
|
||
}
|
||
if nHist > 0 {
|
||
log.Printf("[model] 评论历史短代码改写:%d 条", nHist)
|
||
}
|
||
return nil
|
||
}
|