feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/markdown"
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -36,9 +37,14 @@ func InitDB(dsn string) error {
|
||||
return fmt.Errorf("清理遗留 FK 约束失败: %w", err)
|
||||
}
|
||||
|
||||
// 公告 pinned:已有行不能直接 ADD NOT NULL,先加列并回填 false
|
||||
if err := prepareAnnouncementPinnedColumn(db); err != nil {
|
||||
return fmt.Errorf("公告置顶列迁移失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&PostPollVote{}, &PostLotteryEntry{},
|
||||
@@ -46,10 +52,14 @@ func InitDB(dsn string) error {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 旧帖整帖可见性 → 正文 :::hide 块(幂等)
|
||||
// 旧帖整帖可见性 → 正文 [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 {
|
||||
@@ -120,6 +130,28 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
|
||||
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
|
||||
}
|
||||
|
||||
// prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列,
|
||||
// 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前:
|
||||
// 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败)
|
||||
@@ -372,7 +404,7 @@ func ensureDefaultChatMemberships(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 :::hide 块(幂等)。
|
||||
// migratePostContentAccessToHideBlocks 将旧帖整帖可见性包进 [hide] 块(幂等)。
|
||||
func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
|
||||
var posts []Post
|
||||
if err := db.Unscoped().
|
||||
@@ -407,3 +439,98 @@ func migratePostContentAccessToHideBlocks(db *gorm.DB) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func NormalizePostType(t string) string {
|
||||
return PostTypeDiscussion
|
||||
}
|
||||
|
||||
// 正文可见性(由正文 :::hide 块派生;mixed = 多种隐藏类型并存)
|
||||
// 正文可见性(由正文 [hide] 块派生;mixed = 多种隐藏类型并存)
|
||||
const (
|
||||
ContentAccessPublic = "public" // 公开
|
||||
ContentAccessLogin = "login" // 仅登录可见块
|
||||
@@ -388,13 +388,30 @@ type Announcement struct {
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Tag string `gorm:"size:32;not null;default:公告" json:"tag"`
|
||||
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray
|
||||
TagColor string `gorm:"size:16;not null;default:blue" json:"tag_color"` // blue/green/orange/red/purple/gray/teal/cyan/pink/amber/indigo/rose
|
||||
Published bool `gorm:"not null;index" json:"published"` // 显式写入 false;不可用 default:true,否则草稿零值会被 GORM 省略而落成已发布
|
||||
Pinned bool `gorm:"not null;index" json:"pinned"` // 默认 false;写入须 Select,禁止 default:true
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// SitePage 站点单页(功能介绍等;侧栏入口 + /p/:slug)
|
||||
type SitePage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Excerpt string `gorm:"size:300" json:"excerpt"`
|
||||
Published bool `gorm:"not null;index" json:"published"`
|
||||
ShowInSidebar bool `gorm:"not null;index" json:"show_in_sidebar"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
AllowComments bool `gorm:"not null" json:"allow_comments"` // 默认 false;本轮无评论 UI
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||
type SiteSetting struct {
|
||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||
|
||||
Reference in New Issue
Block a user