feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -0,0 +1,281 @@
package service
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// newLegacyTestEnv 建新站内存库 + 旧站 SQLite 文件 + 两个 zip 包,并灌入固定种子数据:
// 用户 alice(#1)/bob(#2)/carol(#3)/dave(#4),板块 闲聊(#10),
// 帖子 #100(alice)/#101(bob)/#102(carol 草稿)/#103(alice 投票)/#104(dave),
// 评论 #200(bob→100)/#201(carol→100)/#202(alice→101)。
func newLegacyTestEnv(t *testing.T) (*LegacyImportService, string, string, string, uint, map[uint]bool) {
t.Helper()
tmp := t.TempDir()
newDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open new db: %v", err)
}
if err := newDB.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.ImportRecord{}); err != nil {
t.Fatalf("migrate new db: %v", err)
}
admin := model.User{Username: "admin", Password: "x", Nickname: "站长", Role: model.RoleOwner}
if err := newDB.Create(&admin).Error; err != nil {
t.Fatalf("seed admin: %v", err)
}
oldPath := filepath.Join(tmp, "old.db")
oldDB, err := gorm.Open(sqlite.Open(oldPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open old db: %v", err)
}
if err := oldDB.AutoMigrate(&legacyUserRow{}, &legacyBoardRow{}, &legacyPostRow{}, &legacyCommentRow{}); err != nil {
t.Fatalf("migrate old db: %v", err)
}
seed := []interface{}{
&legacyUserRow{ID: 1, Username: "alice", Password: "h1", Nickname: "爱丽丝", Avatar: "/uploads/avatars/a1.jpg"},
&legacyUserRow{ID: 2, Username: "bob", Password: "h2", Nickname: "鲍勃"},
&legacyUserRow{ID: 3, Username: "carol", Password: "h3", Nickname: "卡罗尔"},
&legacyUserRow{ID: 4, Username: "dave", Password: "h4"},
&legacyBoardRow{ID: 10, Name: "闲聊"},
&legacyPostRow{ID: 100, BoardID: 10, UserID: 1, Title: "看图帖", Status: "published", PostType: "normal",
Content: `<p>看图</p><p><img src="/uploads/posts/img1.jpg"></p>`},
&legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `<p>正文</p>`},
&legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `<p>草稿</p>`},
&legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `<p>投票</p>`},
&legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal",
Content: `<p>缺图</p><p><img src="/uploads/posts/missing.jpg"></p>`},
&legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published",
Content: `<p>补一张</p><p><img src="https://old.example.com/uploads/posts/img1.jpg"></p>`},
&legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `<p>顶</p>`},
&legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `<p>前排</p>`},
}
for _, row := range seed {
if err := oldDB.Create(row).Error; err != nil {
t.Fatalf("seed old db: %v", err)
}
}
// seed 完成即释放句柄,避免 TempDir 清理时文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
_ = sqlDB.Close()
}
avatarZip := filepath.Join(tmp, "avatars.zip")
writeTestZip(t, avatarZip, map[string]string{"a1.jpg": "fake-avatar"})
imagesZip := filepath.Join(tmp, "images.zip")
writeTestZip(t, imagesZip, map[string]string{"img1.jpg": "fake-image"})
svc := NewLegacyImportService(newDB, filepath.Join(tmp, "uploads"))
// 入厅钩子桩:生产为 PostgreSQL,ChatService.EnsureDefaultMembership 里的
// GREATEST 表达式 SQLite 不支持,故用等价桩验证「建号即触发钩子」的接线
hallMembers := map[uint]bool{}
svc.WithHallMembership(func(id uint) error {
hallMembers[id] = true
return nil
})
return svc, oldPath, avatarZip, imagesZip, admin.ID, hallMembers
}
func writeTestZip(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create zip: %v", err)
}
w := zip.NewWriter(f)
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("zip entry: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("zip write: %v", err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("zip file close: %v", err)
}
}
func TestLegacyImportPreviewLists(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, _, _ := newLegacyTestEnv(t)
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, LegacyImportOptions{WithContent: true, DryRun: true}, 1)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if len(rep.UserList) != 4 || len(rep.PostList) != 5 || len(rep.CommentList) != 3 {
t.Fatalf("预检清单数量不符: users=%d posts=%d comments=%d", len(rep.UserList), len(rep.PostList), len(rep.CommentList))
}
if rep.UserList[0].Username != "alice" || !rep.UserList[0].HasAvatar {
t.Fatal("alice 预检项不符")
}
// 将导入:#100/#101/#104(草稿、投票排除)
if rep.Posts.Imported != 3 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("预检帖子统计不符: %+v", rep.Posts)
}
// dry-run 不落盘
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); !os.IsNotExist(err) {
t.Fatal("预检不应写文件")
}
}
func TestLegacyImportSelectiveRun(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
// 用户:alice / dave 建号,bob / carol 排除
if rep.Users.Imported != 2 || rep.Users.Excluded != 2 {
t.Fatalf("用户统计不符: %+v", rep.Users)
}
// 帖子:#100/#101/#104 导入,#102 草稿跳过,#103 投票
if rep.Posts.Imported != 3 || rep.Posts.Excluded != 0 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
// 评论:#200/#201 导入,#202 排除
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符: %+v", rep.Comments)
}
// 图片:#100 与 #200 各引用 img1.jpg,只落盘一次
if rep.Posts.ImagesWritten != 1 || len(rep.Comments.ImagesMissing) != 0 {
t.Fatalf("图片统计不符: posts=%+v comments=%+v", rep.Posts.ImagesWritten, rep.Comments.ImagesMissing)
}
if len(rep.Posts.ImagesMissing) != 1 || rep.Posts.ImagesMissing[0] != "missing.jpg" {
t.Fatalf("缺失图片应记录: %v", rep.Posts.ImagesMissing)
}
// 内容归属
var post100, post101 model.Post
if err := svc.db.Where("title = ?", "看图帖").First(&post100).Error; err != nil {
t.Fatalf("post100: %v", err)
}
if err := svc.db.Where("title = ?", "普通帖").First(&post101).Error; err != nil {
t.Fatalf("post101: %v", err)
}
var alice model.User
if err := svc.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("alice: %v", err)
}
if post100.UserID != alice.ID {
t.Fatalf("#100 应归 alice: got %d", post100.UserID)
}
if post101.UserID != adminID {
t.Fatalf("#101 应按 user_map 归 admin: got %d", post101.UserID)
}
// bob 的评论 #200 也归 admin;carol 的评论 #201 兜底归 admin
var cm200, cm201 model.Comment
if err := svc.db.Where("content LIKE ?", "%补一张%").First(&cm200).Error; err != nil {
t.Fatalf("cm200: %v", err)
}
if err := svc.db.Where("content LIKE ?", "%顶%").First(&cm201).Error; err != nil {
t.Fatalf("cm201: %v", err)
}
if cm200.UserID != adminID || cm201.UserID != adminID {
t.Fatalf("评论归属不符: cm200=%d cm201=%d", cm200.UserID, cm201.UserID)
}
// 图片 URL 改写 + 文件落盘
if !strings.Contains(post100.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(post100.Content, legacyPostImagePrefix) {
t.Fatalf("#100 图片 URL 未改写: %s", post100.Content)
}
if !strings.Contains(cm200.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(cm200.Content, "old.example.com") {
t.Fatalf("#200 图片 URL 未改写: %s", cm200.Content)
}
// 缺图帖:包内缺失的引用保留原链接,不改写
p104 := post104Content(t, svc)
if !strings.Contains(p104, legacyPostImagePrefix+"missing.jpg") || strings.Contains(p104, legacyImagePrefix) {
t.Fatalf("缺图引用应保留原链接: %s", p104)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); err != nil {
t.Fatalf("img1.jpg 未落盘: %v", err)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "avatars", "a1.jpg")); err != nil {
t.Fatalf("头像未落盘: %v", err)
}
// 导入建号的用户触发入厅钩子;被排除建号的 bob/carol 不触发
var dave model.User
if err := svc.db.Where("username = ?", "dave").First(&dave).Error; err != nil {
t.Fatalf("dave: %v", err)
}
if len(hallMembers) != 2 || !hallMembers[alice.ID] || !hallMembers[dave.ID] {
t.Fatalf("入厅钩子应只对 alice/dave 触发: %v", hallMembers)
}
// 幂等:重复导入全部跳过
rep2, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Users.Imported != 0 || rep2.Posts.Imported != 0 || rep2.Comments.Imported != 0 {
t.Fatalf("重复导入应全部跳过: %+v", rep2)
}
if rep2.Users.Excluded != 2 || rep2.Posts.Skipped != 4 || rep2.Comments.Skipped != 2 {
t.Fatalf("重复导入统计不符: users=%+v posts=%+v comments=%+v", rep2.Users, rep2.Posts, rep2.Comments)
}
}
// TestLegacyImportSkipPostCascades 排除帖子时其评论自动跳过
func TestLegacyImportSkipPostCascades(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
if rep.Posts.Imported != 2 || rep.Posts.Excluded != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符(#202 应随 #101 排除): %+v", rep.Comments)
}
var n int64
svc.db.Model(&model.Post{}).Where("title = ?", "普通帖").Count(&n)
if n != 0 {
t.Fatal("#101 不应导入")
}
}
// TestLegacyImportBadTarget 归属目标账号不存在时报错
func TestLegacyImportBadTarget(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{WithContent: true, UserTargetNames: map[uint]string{2: "ghost"}}
_, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if !errors.Is(err, ErrLegacyBadTarget) {
t.Fatalf("应返回 ErrLegacyBadTarget: %v", err)
}
}
func post104Content(t *testing.T, svc *LegacyImportService) string {
t.Helper()
var p model.Post
if err := svc.db.Where("title = ?", "缺图帖").First(&p).Error; err != nil {
t.Fatalf("post104: %v", err)
}
return p.Content
}