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,312 @@
package service
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"time"
xwebp "golang.org/x/image/webp"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newMediaLibraryService(t *testing.T) (*UploadService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func writeMediaFile(t *testing.T, dir, rel string, data []byte) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, data, 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
func tinyPNG() []byte {
src := image.NewRGBA(image.Rect(0, 0, 12, 8))
src.Set(0, 0, color.RGBA{1, 2, 3, 255})
var buf bytes.Buffer
_ = png.Encode(&buf, src)
return buf.Bytes()
}
func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes"))
writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes"))
writeMediaFile(t, dir, "images/solo.png", tinyPNG())
writeMediaFile(t, dir, "images/trash.txt", []byte("not an image"))
writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp"))
writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake"))
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url
pair, ok := byURL["/uploads/images/pair.webp"]
if !ok {
t.Fatalf("pair.webp missing; items=%v", items)
}
if pair.OriginalURL != "/uploads/images/pair.jpg" {
t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL)
}
if _, exists := byURL["/uploads/images/pair.jpg"]; exists {
t.Fatal("pair.jpg should be hidden behind webp")
}
// 无配对的单文件:保留原格式并解析出宽高
solo, ok := byURL["/uploads/images/solo.png"]
if !ok {
t.Fatal("solo.png missing")
}
if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 {
t.Fatalf("solo.png meta wrong: %+v", solo)
}
// 非图片与 .partial 被过滤
if _, exists := byURL["/uploads/images/trash.txt"]; exists {
t.Fatal("trash.txt should be excluded")
}
if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists {
t.Fatal(".partial should be excluded")
}
// 广告素材目录计入 ads 分类
if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" {
t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr)
}
if counts["image"] != 2 || counts["ads"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles)
writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes"))
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage,
URL: "/uploads/images/abc.webp", MIME: "image/webp",
Size: 4567, Width: 320, Height: 240, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindAvatar,
URL: "/api/media/remote01", MIME: "image/webp",
Size: 999, Width: 128, Height: 128, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create remote attachment: %v", err)
}
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间)
disk, ok := byURL["/uploads/images/abc.webp"]
if !ok {
t.Fatalf("attachment-backed local file missing: %+v", items)
}
if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 {
t.Fatalf("meta not merged: %+v", disk)
}
// 远程存储对象:不在磁盘上也必须出现在媒体库
remote, ok := byURL["/api/media/remote01"]
if !ok {
t.Fatal("remote object missing")
}
if remote.Category != "avatar" || remote.Uploader != "爱丽丝" {
t.Fatalf("remote meta wrong: %+v", remote)
}
if counts["avatar"] != 1 {
t.Fatalf("avatar count wrong: %v", counts)
}
}
func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) {
s, _ := newMediaLibraryService(t)
if err := s.db.Create(&model.Attachment{
UserID: 1, Kind: model.AttachmentKindImage,
URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
for _, it := range items {
if it.URL == "/uploads/images/ghost.webp" {
t.Fatal("local-missing file should not be listed")
}
}
}
func TestAdminMediaLibraryAttachSources(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg"))
// 头像被 bob 使用
if err := s.db.Create(&model.User{
Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp",
}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
// 帖子正文引用 p1
post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 ![a](/uploads/images/p1.webp)"}
if err := s.db.Create(&post).Error; err != nil {
t.Fatalf("create post: %v", err)
}
// 评论引用 c1
if err := s.db.Create(&model.Comment{
PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg",
}).Error; err != nil {
t.Fatalf("create comment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
av, ok := byURL["/uploads/avatars/av1.webp"]
if !ok {
t.Fatal("avatar item missing")
}
if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" {
t.Fatalf("avatar source wrong: %+v", av)
}
pimg, ok := byURL["/uploads/images/p1.webp"]
if !ok {
t.Fatal("post image missing")
}
if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) ||
!strings.Contains(pimg.SourceLabel, "图片来源测试") {
t.Fatalf("post source wrong: %+v", pimg)
}
cimg, ok := byURL["/uploads/images/c1.jpg"]
if !ok {
t.Fatal("comment image missing")
}
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
t.Fatalf("comment source url wrong: %+v", cimg)
}
if !strings.Contains(cimg.SourceLabel, "下的评论") {
t.Fatalf("comment source label wrong: %+v", cimg)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 960×640 PNG → 缩到 480×320 的 WebP
big := image.NewRGBA(image.Rect(0, 0, 960, 640))
var bigBuf bytes.Buffer
if err := png.Encode(&bigBuf, big); err != nil {
t.Fatalf("encode png: %v", err)
}
writeMediaFile(t, dir, "images/big.png", bigBuf.Bytes())
// 小图(≤480)直接回原图
writeMediaFile(t, dir, "images/small.png", tinyPNG())
bigThumb, err := s.MediaThumb("/uploads/images/big.png")
if err != nil {
t.Fatalf("MediaThumb big: %v", err)
}
img, err := xwebp.Decode(bytes.NewReader(bigThumb))
if err != nil {
t.Fatalf("thumb not webp: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 320 {
t.Fatalf("thumb size wrong: %v", img.Bounds())
}
small, err := s.MediaThumb("/uploads/images/small.png")
if err != nil {
t.Fatalf("MediaThumb small: %v", err)
}
if !bytes.Equal(small, tinyPNG()) {
t.Fatal("small image should be returned as-is")
}
// 缓存命中:第二次调用结果一致
again, err := s.MediaThumb("/uploads/images/big.png")
if err != nil || !bytes.Equal(again, bigThumb) {
t.Fatalf("cache miss/mismatch: %v", err)
}
// 非法与越界路径
if _, err := s.MediaThumb("/api/media/xxx"); err == nil {
t.Fatal("remote url should be rejected")
}
if _, err := s.MediaThumb("/uploads/../secrets/x.png"); err == nil {
t.Fatal("traversal should be rejected")
}
if _, err := s.MediaThumb("/uploads/other/x.png"); err == nil {
t.Fatal("unknown category should be rejected")
}
}