新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
369 lines
11 KiB
Go
369 lines
11 KiB
Go
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: "看图 "}
|
||
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-1", post.ID) {
|
||
t.Fatalf("comment source url wrong: %+v", cimg)
|
||
}
|
||
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
||
t.Fatalf("comment source label wrong: %+v", cimg)
|
||
}
|
||
}
|
||
|
||
func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
||
s, dir := newMediaLibraryService(t)
|
||
|
||
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
|
||
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
|
||
|
||
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)
|
||
}
|
||
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||
mkAtt := func(source, url string) {
|
||
if err := s.db.Create(&model.Attachment{
|
||
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
|
||
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
|
||
CreatedAt: created,
|
||
}).Error; err != nil {
|
||
t.Fatalf("create attachment %s: %v", source, err)
|
||
}
|
||
}
|
||
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
|
||
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
|
||
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
|
||
|
||
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
|
||
}
|
||
|
||
// 封面(磁盘)与正文插图(远程)都归“书籍”
|
||
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
|
||
it, ok := byURL[u]
|
||
if !ok {
|
||
t.Fatalf("%s missing", u)
|
||
}
|
||
if it.Category != "book" || it.CategoryName != "书籍" {
|
||
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
|
||
}
|
||
}
|
||
|
||
// 普通帖子插图仍归 image
|
||
if post := byURL["/api/media/post-image"]; post.Category != "image" {
|
||
t.Fatalf("post image should stay image category, got %q", post.Category)
|
||
}
|
||
|
||
if counts["book"] != 2 || counts["image"] != 1 {
|
||
t.Fatalf("counts wrong: %v", counts)
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|