feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -3,14 +3,18 @@ module github.com/freefire/jiang13-bbs
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0
|
||||
github.com/gen2brain/webp v0.6.4
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/minio/minio-go/v7 v7.0.83
|
||||
github.com/yuin/goldmark v1.7.8
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/net v0.45.0
|
||||
golang.org/x/text v0.42.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
@@ -18,7 +22,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
@@ -26,10 +29,10 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -57,7 +60,6 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
|
||||
@@ -18,8 +18,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -47,6 +51,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -104,8 +110,10 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
|
||||
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
@@ -194,8 +202,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -205,6 +213,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -2,11 +2,13 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
@@ -16,10 +18,16 @@ import (
|
||||
// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) =====
|
||||
//
|
||||
// POST /api/admin/import/:source multipart:
|
||||
// - db_file 旧站 SQLite 库(必填)
|
||||
// - avatars_zip 头像压缩包(可选)
|
||||
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
|
||||
// - dry_run "true"/"false",预检模式不写库(默认 false)
|
||||
// - db_file 旧站 SQLite 库(必填)
|
||||
// - avatars_zip 头像压缩包(可选)
|
||||
// - images_zip 帖子图片压缩包(可选,旧站 data/uploads/posts 打包)
|
||||
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
|
||||
// - dry_run "true"/"false",预检模式不写库并返回勾选清单(默认 false)
|
||||
// - skip_users 逗号分隔旧用户ID,不创建账号(内容仍按归属规则落位)
|
||||
// - operator_users 逗号分隔旧用户ID,内容归到站长
|
||||
// - user_map "旧用户ID:本站用户名" 逗号分隔,内容归到指定已有账号
|
||||
// - skip_posts 逗号分隔旧帖ID,排除(其评论自动跳过)
|
||||
// - skip_comments 逗号分隔旧评论ID,排除
|
||||
//
|
||||
// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。
|
||||
|
||||
@@ -32,7 +40,7 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
}
|
||||
|
||||
const overhead = 64 << 10 // multipart 边界与表单开销
|
||||
limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead
|
||||
limit := int64(service.LegacyDBMaxBytes) + 2*int64(service.LegacyZipMaxBytes) + overhead
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
claims := middleware.CurrentUser(c)
|
||||
@@ -50,25 +58,25 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"})
|
||||
return
|
||||
}
|
||||
zipPath := ""
|
||||
if zipFH, ferr := c.FormFile("avatars_zip"); ferr == nil && zipFH != nil && zipFH.Size > 0 {
|
||||
if zipFH.Size > service.LegacyZipMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "头像压缩包不能超过 128MB"})
|
||||
return
|
||||
}
|
||||
zipPath, err = saveMultipartToTemp(zipFH, "legacy-avatars-*.zip")
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "读取头像压缩包失败")
|
||||
return
|
||||
}
|
||||
zipPath, ok := optionalLegacyZip(c, "avatars_zip", "legacy-avatars-*.zip", "头像压缩包")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if zipPath != "" {
|
||||
defer os.Remove(zipPath)
|
||||
} else if ferr != nil {
|
||||
// 表单整体解析失败(超限等);「字段不存在」直接忽略
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(ferr, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
|
||||
return
|
||||
}
|
||||
}
|
||||
imagesZipPath, ok := optionalLegacyZip(c, "images_zip", "legacy-images-*.zip", "帖子图片压缩包")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if imagesZipPath != "" {
|
||||
defer os.Remove(imagesZipPath)
|
||||
}
|
||||
|
||||
userMap, err := parseUserTargetMap(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db")
|
||||
@@ -78,12 +86,17 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
}
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{
|
||||
WithContent: formBool(c, "with_content", true),
|
||||
DryRun: formBool(c, "dry_run", false),
|
||||
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, imagesZipPath, service.LegacyImportOptions{
|
||||
WithContent: formBool(c, "with_content", true),
|
||||
DryRun: formBool(c, "dry_run", false),
|
||||
SkipUserIDs: parseIDSet(c, "skip_users"),
|
||||
OperatorUsers: parseIDSet(c, "operator_users"),
|
||||
UserTargetNames: userMap,
|
||||
SkipPostIDs: parseIDSet(c, "skip_posts"),
|
||||
SkipCommentIDs: parseIDSet(c, "skip_comments"),
|
||||
}, claims.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) {
|
||||
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) || errors.Is(err, service.ErrLegacyBadTarget) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -93,6 +106,75 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||||
}
|
||||
|
||||
// optionalLegacyZip 读取可选 zip 表单项并落临时文件;字段不存在返回空串。
|
||||
// 出错时已写响应,返回 false 中止请求。
|
||||
func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bool) {
|
||||
fh, err := c.FormFile(field)
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
|
||||
return "", false
|
||||
}
|
||||
return "", true // 字段不存在,视为未上传
|
||||
}
|
||||
if fh.Size > service.LegacyZipMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label+"不能超过 128MB"})
|
||||
return "", false
|
||||
}
|
||||
path, err := saveMultipartToTemp(fh, pattern)
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "读取"+label+"失败")
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// parseIDSet 解析逗号分隔的旧对象 ID 列表(如 "3,7,12")
|
||||
func parseIDSet(c *gin.Context, key string) map[uint]bool {
|
||||
v := strings.TrimSpace(c.PostForm(key))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
out := map[uint]bool{}
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
out[uint(id)] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseUserTargetMap 解析 "旧用户ID:本站用户名" 列表(如 "5:bob,7:alice");
|
||||
// 目标账号由 service 在导入时校验并解析为账号 ID。
|
||||
func parseUserTargetMap(c *gin.Context) (map[uint]string, error) {
|
||||
v := strings.TrimSpace(c.PostForm("user_map"))
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[uint]string{}
|
||||
for _, pair := range strings.Split(v, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(pair), ":", 2)
|
||||
if len(kv) != 2 {
|
||||
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
|
||||
}
|
||||
oldID, err := strconv.ParseUint(strings.TrimSpace(kv[0]), 10, 64)
|
||||
name := strings.TrimSpace(kv[1])
|
||||
if err != nil || oldID == 0 || name == "" {
|
||||
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
|
||||
}
|
||||
out[uint(oldID)] = name
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func formBool(c *gin.Context, key string, def bool) bool {
|
||||
v := c.PostForm(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -213,8 +213,9 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||
h.Ops.ClearFailure(req.Username)
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
// 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则
|
||||
// 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现)
|
||||
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
|
||||
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
|
||||
// access token 过期或硬刷新才发现)
|
||||
if kicked > 0 {
|
||||
h.Hub.BroadcastUser(user.ID, realtime.Envelope{
|
||||
Type: realtime.EventSessionReplaced,
|
||||
@@ -309,7 +310,7 @@ func (h *Handlers) MyLoginDevices(c *gin.Context) {
|
||||
}
|
||||
familyID := claims.FamilyID
|
||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil {
|
||||
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 {
|
||||
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, claims.ID); fid != 0 {
|
||||
familyID = fid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
|
||||
}
|
||||
|
||||
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP)
|
||||
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP / GIF;除 GIF 外自动转存 WebP)
|
||||
func (h *Handlers) UploadImage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
|
||||
@@ -311,6 +311,27 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
|
||||
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
|
||||
items, counts, err := h.Upload.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取媒体库失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "counts": counts, "total": len(items)})
|
||||
}
|
||||
|
||||
// MediaLibraryThumb 媒体库网格缩略图(?u=/uploads/images/xx.webp),失败回退由前端处理
|
||||
func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
|
||||
data, err := h.Upload.MediaThumb(c.Query("u"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "缩略图不可用"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "private, max-age=86400")
|
||||
c.Data(http.StatusOK, "image/webp", data)
|
||||
}
|
||||
|
||||
func brandTooLarge(slot string) string {
|
||||
if slot == service.BrandSlotFavicon {
|
||||
return "Favicon 不能超过 512KB"
|
||||
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -18,6 +19,30 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// parseTrustedProxies 解析 TRUSTED_PROXIES(逗号分隔的 IP/CIDR/localhost)。
|
||||
// 容忍条目两侧空白与空项,无法解析的条目跳过并告警:编排环境变量多一个空格
|
||||
// 不应让整个服务拒绝启动(真实事故:尾随空格致 invalid CIDR 秒退)。
|
||||
// 全部非法或未配置时返回 nil,退化为「ClientIP 记 TCP 对端」并触发启动警告。
|
||||
func parseTrustedProxies(v string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if part == "localhost" {
|
||||
out = append(out, part)
|
||||
continue
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(part); err != nil && net.ParseIP(part) == nil {
|
||||
log.Printf("警告: TRUSTED_PROXIES 条目 %q 无法解析,已忽略", part)
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Setup 初始化路由
|
||||
func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
if cfg.DevMode {
|
||||
@@ -27,12 +52,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
var proxies []string
|
||||
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||
proxies = strings.Split(v, ",")
|
||||
} else {
|
||||
proxies := parseTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
|
||||
if len(proxies) == 0 {
|
||||
// 未配置信任代理时 ClientIP 退化为 TCP 直连对端:反代/容器部署会记录内网地址
|
||||
log.Println("警告: TRUSTED_PROXIES 未配置,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
|
||||
log.Println("警告: TRUSTED_PROXIES 未配置或无有效条目,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
|
||||
}
|
||||
if err := r.SetTrustedProxies(proxies); err != nil {
|
||||
return nil, err
|
||||
@@ -55,7 +78,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}))
|
||||
|
||||
// 服务
|
||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret).WithDevMode(cfg.DevMode)
|
||||
boardSvc := service.NewBoardService(model.DB)
|
||||
settingSvc := service.NewSettingService(model.DB)
|
||||
// 等级体系快照随进程启动加载(后台保存时经 SetLevels 刷新,无需重启)
|
||||
@@ -82,6 +105,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||
// 导入建号绕过注册链路,显式接线:导入用户同样加入默认全站大厅
|
||||
legacyImportSvc.WithHallMembership(chatSvc.EnsureDefaultMembership)
|
||||
badgeSvc := service.NewBadgeService(model.DB).WithNotification(notifSvc)
|
||||
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
|
||||
analyticsSvc := service.NewAnalyticsService(model.DB)
|
||||
@@ -102,35 +127,35 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
go visitSvc.RunRetention(pipeCtx)
|
||||
|
||||
h := &handler.Handlers{
|
||||
Ops: ops,
|
||||
Cfg: cfg,
|
||||
Hub: realtime.NewHub(),
|
||||
Auth: authSvc,
|
||||
Board: boardSvc,
|
||||
Post: postSvc,
|
||||
Comment: commentSvc,
|
||||
Like: likeSvc,
|
||||
Favorite: favoriteSvc,
|
||||
Follow: followSvc,
|
||||
Notification: notifSvc,
|
||||
OverviewSvc: overviewSvc,
|
||||
Checkin: checkinSvc,
|
||||
Announcement: announcementSvc,
|
||||
SitePage: sitePageSvc,
|
||||
Upload: uploadSvc,
|
||||
PostFile: postFileSvc,
|
||||
Points: pointsSvc,
|
||||
Setting: settingSvc,
|
||||
AdminUser: adminUserSvc,
|
||||
LegacyImport: legacyImportSvc,
|
||||
Moderation: moderationSvc,
|
||||
Chat: chatSvc,
|
||||
Visit: visitSvc,
|
||||
Analytics: analyticsSvc,
|
||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||
Ads: adSvc,
|
||||
Sidebar: service.NewSidebarService(model.DB),
|
||||
Badge: badgeSvc,
|
||||
Ops: ops,
|
||||
Cfg: cfg,
|
||||
Hub: realtime.NewHub(),
|
||||
Auth: authSvc,
|
||||
Board: boardSvc,
|
||||
Post: postSvc,
|
||||
Comment: commentSvc,
|
||||
Like: likeSvc,
|
||||
Favorite: favoriteSvc,
|
||||
Follow: followSvc,
|
||||
Notification: notifSvc,
|
||||
OverviewSvc: overviewSvc,
|
||||
Checkin: checkinSvc,
|
||||
Announcement: announcementSvc,
|
||||
SitePage: sitePageSvc,
|
||||
Upload: uploadSvc,
|
||||
PostFile: postFileSvc,
|
||||
Points: pointsSvc,
|
||||
Setting: settingSvc,
|
||||
AdminUser: adminUserSvc,
|
||||
LegacyImport: legacyImportSvc,
|
||||
Moderation: moderationSvc,
|
||||
Chat: chatSvc,
|
||||
Visit: visitSvc,
|
||||
Analytics: analyticsSvc,
|
||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||
Ads: adSvc,
|
||||
Sidebar: service.NewSidebarService(model.DB),
|
||||
Badge: badgeSvc,
|
||||
LeaderboardSvc: leaderboardSvc,
|
||||
}
|
||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||
@@ -391,6 +416,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
|
||||
staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand)
|
||||
staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia)
|
||||
// 媒体库:全站图片盘点(只读展示)+ 网格缩略图
|
||||
staffAPI.GET("/media-library", authMW.RequirePerm(service.PermSettings), h.AdminMediaLibrary)
|
||||
staffAPI.GET("/media-library/thumb", authMW.RequirePerm(service.PermSettings), h.MediaLibraryThumb)
|
||||
|
||||
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
|
||||
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
|
||||
|
||||
33
backend/router/router_test.go
Normal file
33
backend/router/router_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseTrustedProxies 覆盖:合法 CIDR/IP/localhost、空白容忍、空项、非法条目跳过
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"未配置", "", nil},
|
||||
{"单个网段", "172.19.0.0/16", []string{"172.19.0.0/16"}},
|
||||
// 真实事故:尾随空格曾导致 invalid CIDR 整进程退出
|
||||
{"尾随空格", "172.19.0.0/16 ", []string{"172.19.0.0/16"}},
|
||||
{"两侧空白与空项", " 172.19.0.0/16 , 10.0.0.5 ,", []string{"172.19.0.0/16", "10.0.0.5"}},
|
||||
{"单个 IP", "10.0.0.5", []string{"10.0.0.5"}},
|
||||
{"localhost", "localhost", []string{"localhost"}},
|
||||
{"非法条目跳过", "172.19.0.0/16,bad-cidr", []string{"172.19.0.0/16"}},
|
||||
{"全部非法", "bad,,worse", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := parseTrustedProxies(c.in)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("parseTrustedProxies(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,12 @@ var (
|
||||
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
|
||||
)
|
||||
|
||||
// AdDurationOption 可购时长档位
|
||||
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)
|
||||
type AdDurationOption struct {
|
||||
Days int `json:"days"`
|
||||
Label string `json:"label"`
|
||||
PriceHint string `json:"price_hint"`
|
||||
Days int `json:"days"`
|
||||
Label string `json:"label"`
|
||||
PriceHint string `json:"price_hint"` // 图片广告价格
|
||||
PriceHintText string `json:"price_hint_text"` // 文字广告价格
|
||||
}
|
||||
|
||||
// AdPaymentOption 收款方式(展示二维码,人工确认)
|
||||
@@ -68,11 +69,11 @@ func DefaultAdConfig() AdConfig {
|
||||
Enabled: true,
|
||||
PanelTitle: AdDefaultTitle,
|
||||
Durations: []AdDurationOption{
|
||||
{Days: 30, Label: "1 个月", PriceHint: ""},
|
||||
{Days: 60, Label: "2 个月", PriceHint: ""},
|
||||
{Days: 90, Label: "3 个月", PriceHint: ""},
|
||||
{Days: 180, Label: "6 个月", PriceHint: ""},
|
||||
{Days: 365, Label: "12 个月", PriceHint: ""},
|
||||
{Days: 30, Label: "1 个月", PriceHint: "", PriceHintText: ""},
|
||||
{Days: 60, Label: "2 个月", PriceHint: "", PriceHintText: ""},
|
||||
{Days: 90, Label: "3 个月", PriceHint: "", PriceHintText: ""},
|
||||
{Days: 180, Label: "6 个月", PriceHint: "", PriceHintText: ""},
|
||||
{Days: 365, Label: "12 个月", PriceHint: "", PriceHintText: ""},
|
||||
},
|
||||
Payments: []AdPaymentOption{},
|
||||
}
|
||||
@@ -116,6 +117,9 @@ func (s *AdService) loadConfig() (AdConfig, error) {
|
||||
if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" {
|
||||
cfg.Durations[i].PriceHint = ""
|
||||
}
|
||||
if strings.TrimSpace(cfg.Durations[i].PriceHintText) == "面议" {
|
||||
cfg.Durations[i].PriceHintText = ""
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -195,7 +199,14 @@ func normalizeAdConfig(in AdConfig) (AdConfig, error) {
|
||||
if utf8.RuneCountInString(price) > 32 {
|
||||
return AdConfig{}, ErrAdInvalid
|
||||
}
|
||||
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price})
|
||||
priceText := strings.TrimSpace(d.PriceHintText)
|
||||
if priceText == "面议" {
|
||||
priceText = ""
|
||||
}
|
||||
if utf8.RuneCountInString(priceText) > 32 {
|
||||
return AdConfig{}, ErrAdInvalid
|
||||
}
|
||||
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price, PriceHintText: priceText})
|
||||
}
|
||||
out.Durations = durs
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtSecret []byte
|
||||
encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生)
|
||||
devMode bool
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||||
@@ -81,6 +82,21 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||||
return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]}
|
||||
}
|
||||
|
||||
// WithDevMode dev 模式放宽轮转宽限期:开发中 go run / next dev 重启频繁,
|
||||
// 轮换响应丢失后的重放往往远超 60s 才回来,同链重放不应升级为盗用判定
|
||||
func (s *AuthService) WithDevMode(dev bool) *AuthService {
|
||||
s.devMode = dev
|
||||
return s
|
||||
}
|
||||
|
||||
// rotationGrace 轮转宽限期:dev 放宽(见 RefreshRotationGrace 注释)
|
||||
func (s *AuthService) rotationGrace() time.Duration {
|
||||
if s.devMode {
|
||||
return 15 * time.Minute
|
||||
}
|
||||
return RefreshRotationGrace
|
||||
}
|
||||
|
||||
// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名
|
||||
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
||||
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
||||
@@ -133,7 +149,8 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 access token + refresh token + user;
|
||||
// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知)
|
||||
// kicked 为本次登录因超出会话上限被淘汰的最旧会话数(多会话并存:
|
||||
// 新登录不踢旧设备,仅超过 maxActiveSessions 时裁剪;>0 供上层广播告知)
|
||||
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
@@ -317,7 +334,8 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
|
||||
}
|
||||
|
||||
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。
|
||||
// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。
|
||||
// 多会话并存:各设备登录互不影响,新会话构成独立的轮转家族(family_id);
|
||||
// 返回值 kicked 为因超出会话上限被淘汰的最旧会话行数。
|
||||
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
|
||||
token := generateRandomToken()
|
||||
cipherText, err := s.encryptToken(token)
|
||||
@@ -342,7 +360,7 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui
|
||||
return "", 0, 0, err
|
||||
}
|
||||
rt.FamilyID = rt.ID
|
||||
kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID)
|
||||
kicked := s.enforceSessionCap(userID)
|
||||
return token, rt.FamilyID, kicked, nil
|
||||
}
|
||||
|
||||
@@ -410,7 +428,7 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
||||
|
||||
if rt.Revoked {
|
||||
// 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对
|
||||
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace {
|
||||
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= s.rotationGrace() {
|
||||
var next model.RefreshToken
|
||||
if e := tx.First(&next, rt.RotatedTo).Error; e == nil &&
|
||||
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
|
||||
@@ -423,13 +441,16 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
||||
}
|
||||
}
|
||||
}
|
||||
// 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族
|
||||
// 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP:
|
||||
// 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期
|
||||
// middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线)
|
||||
fam := sessionFamilyID(rt)
|
||||
if err := tx.Model(&model.RefreshToken{}).
|
||||
Where("user_id = ? AND revoked = ?", rt.UserID, false).
|
||||
Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID)
|
||||
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam)
|
||||
outErr = ErrRefreshReused
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,12 +24,14 @@ type BoardWithCount struct {
|
||||
PostCount int64 `json:"post_count"`
|
||||
}
|
||||
|
||||
// BoardModerator 板块管理员公开资料
|
||||
// BoardModerator 板块管理员公开资料(含等级与颁发的徽章)
|
||||
type BoardModerator struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
|
||||
Badges []model.UserBadge `json:"badges,omitempty"` // 管理员颁发的徽章(Preload("Badges.Badge") 填充)
|
||||
}
|
||||
|
||||
// BoardSidebarStats 板级统计(不含在线)
|
||||
@@ -432,13 +434,14 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 板块管理员:仅 board_admin 且授权本板、未封禁
|
||||
// 板块管理员:仅 board_admin 且授权本板、未封禁(等级由 AfterFind 算,徽章随 Preload 带)
|
||||
var mods []model.User
|
||||
if err := s.db.Table("users").
|
||||
Select("users.id, users.username, users.nickname, users.avatar").
|
||||
if err := s.db.Model(&model.User{}).
|
||||
Select("users.id, users.username, users.nickname, users.avatar, users.total_points").
|
||||
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
|
||||
Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
|
||||
Order("users.id ASC").
|
||||
Preload("Badges.Badge").
|
||||
Find(&mods).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -448,6 +451,8 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
Level: u.Level,
|
||||
Badges: u.Badges,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
69
backend/service/board_sidebar_test.go
Normal file
69
backend/service/board_sidebar_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 板块右栏聚合:管理员应携带等级(AfterFind 按累计积分计算)与颁发的徽章
|
||||
func TestBoardSidebarModeratorsCarryLevelAndBadges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Board{}, &model.UserBoard{},
|
||||
&model.Post{}, &model.Comment{}, &model.Badge{}, &model.UserBadge{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
board := model.Board{Name: "综合讨论"}
|
||||
if err := db.Create(&board).Error; err != nil {
|
||||
t.Fatalf("seed board: %v", err)
|
||||
}
|
||||
// 高积分管理员 + 低积分管理员 + 普通用户(不入列)
|
||||
modHigh := model.User{Username: "mod_high", Password: "x", Role: model.RoleBoardAdmin, TotalPoints: 10000}
|
||||
modLow := model.User{Username: "mod_low", Password: "x", Role: model.RoleBoardAdmin}
|
||||
plain := model.User{Username: "plain", Password: "x"}
|
||||
for _, u := range []*model.User{&modHigh, &modLow, &plain} {
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
for _, uid := range []uint{modHigh.ID, modLow.ID, plain.ID} {
|
||||
if err := db.Create(&model.UserBoard{UserID: uid, BoardID: board.ID}).Error; err != nil {
|
||||
t.Fatalf("seed user_board: %v", err)
|
||||
}
|
||||
}
|
||||
badge := model.Badge{Name: "骨灰玩家", Icon: "medal", Color: "gold"}
|
||||
if err := db.Create(&badge).Error; err != nil {
|
||||
t.Fatalf("seed badge: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.UserBadge{BadgeID: badge.ID, UserID: modHigh.ID, AwardedBy: 1}).Error; err != nil {
|
||||
t.Fatalf("seed user_badge: %v", err)
|
||||
}
|
||||
|
||||
s := NewBoardService(db)
|
||||
data, err := s.Sidebar(board.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Sidebar: %v", err)
|
||||
}
|
||||
if len(data.Moderators) != 2 {
|
||||
t.Fatalf("应只含 2 名 board_admin,got %d", len(data.Moderators))
|
||||
}
|
||||
first := data.Moderators[0]
|
||||
if first.Username != "mod_high" {
|
||||
t.Fatalf("排序错位: %+v", first)
|
||||
}
|
||||
if first.Level <= 1 {
|
||||
t.Fatalf("高积分管理员等级应 >1,got %d", first.Level)
|
||||
}
|
||||
if len(first.Badges) != 1 || first.Badges[0].Badge.Name != "骨灰玩家" {
|
||||
t.Fatalf("徽章未随行加载: %+v", first.Badges)
|
||||
}
|
||||
if data.Moderators[1].Level != 1 || len(data.Moderators[1].Badges) != 0 {
|
||||
t.Fatalf("低积分管理员应为 Lv.1 无徽章: %+v", data.Moderators[1])
|
||||
}
|
||||
}
|
||||
@@ -397,7 +397,9 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
|
||||
var existing model.ChatRoom
|
||||
err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error
|
||||
if err == nil {
|
||||
_ = s.ensureMember(existing.ID, meID)
|
||||
// 关闭私聊会硬删成员行、丢失已读水位;主动重开视为已读历史,
|
||||
// 否则重建行 last_read=0 会把全部历史消息重算成未读(列表徽标闪现)
|
||||
_ = s.ensureMember(existing.ID, meID, existing.LastMessageID)
|
||||
_ = s.ensureMember(existing.ID, peerID)
|
||||
return s.RoomDetail(meID, existing.ID, false)
|
||||
}
|
||||
@@ -431,7 +433,8 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
|
||||
return s.RoomDetail(meID, room.ID, false)
|
||||
}
|
||||
|
||||
func (s *ChatService) ensureMember(roomID, userID uint) error {
|
||||
// ensureMember 保证成员行存在;重建(如私聊关闭后重开)时可选恢复已读水位
|
||||
func (s *ChatService) ensureMember(roomID, userID uint, readWatermark ...uint) error {
|
||||
_, err := s.membership(s.db, roomID, userID)
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -440,6 +443,9 @@ func (s *ChatService) ensureMember(roomID, userID uint) error {
|
||||
return err
|
||||
}
|
||||
m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember}
|
||||
if len(readWatermark) > 0 {
|
||||
m.LastReadMessageID = readWatermark[0]
|
||||
}
|
||||
if e := s.db.Create(&m).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,53 +22,110 @@ import (
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包,
|
||||
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包 / 帖子图片包,
|
||||
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
|
||||
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
|
||||
// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。
|
||||
// 支持预检(dry-run,不写库,返回用户 / 帖子 / 评论清单供勾选);ImportRecord 去重保证幂等可重复导入。
|
||||
// 支持选择性导入:按旧用户勾选是否建号、内容归属(指定已有账号 / 站长)、按帖子 / 评论排除。
|
||||
// 帖子图片:正文引用的旧站 /uploads/posts/<名> 从图片包落盘到新站 uploads/images 并改写 URL。
|
||||
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
|
||||
|
||||
const (
|
||||
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
|
||||
LegacyDBMaxBytes = 64 << 20
|
||||
// LegacyZipMaxBytes 头像压缩包上传上限
|
||||
// LegacyZipMaxBytes 头像 / 帖子图片压缩包上传上限
|
||||
LegacyZipMaxBytes = 128 << 20
|
||||
// legacyAvatarFileMax 单个头像解压上限
|
||||
legacyAvatarFileMax = 8 << 20
|
||||
// legacyImageFileMax 单张帖子图片解压上限
|
||||
legacyImageFileMax = 20 << 20
|
||||
// legacySignatureMaxRunes 新站 Signature 列上限
|
||||
legacySignatureMaxRunes = 255
|
||||
// legacyEmailMaxRunes 新站 Email 列上限
|
||||
legacyEmailMaxRunes = 128
|
||||
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
|
||||
legacyAvatarPrefix = "/uploads/avatars/"
|
||||
// legacyPostImagePrefix 旧站帖子图片 URL 约定前缀
|
||||
legacyPostImagePrefix = "/uploads/posts/"
|
||||
// legacyImagePrefix 新站帖子图片落盘后的 URL 前缀
|
||||
legacyImagePrefix = "/uploads/images/"
|
||||
// legacyPreviewLimit 预检清单条数上限,防止超大库拖垮响应
|
||||
legacyPreviewLimit = 2000
|
||||
// legacyDetailLimit 报告明细条数上限,防止超长响应
|
||||
legacyDetailLimit = 50
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
|
||||
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
|
||||
ErrLegacyBadZip = errors.New("压缩包无法读取")
|
||||
ErrLegacyBadTarget = errors.New("内容归属目标账号不存在")
|
||||
)
|
||||
|
||||
// legacyPostImageRe 匹配正文中对旧站帖子图片的引用(相对路径或任意域名绝对 URL),
|
||||
// 捕获组为纯文件名(字符集不含路径分隔符,杜绝目录穿越)。
|
||||
var legacyPostImageRe = regexp.MustCompile(`(?:https?://[^()\[\]\s]+)?/uploads/posts/([A-Za-z0-9._\-]+)`)
|
||||
|
||||
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
|
||||
func LegacyImportSource(source string) bool {
|
||||
return source == "jiang13"
|
||||
}
|
||||
|
||||
// LegacyImportOptions 导入选项
|
||||
// LegacyImportOptions 导入选项(skip / 归属参数由 handler 从表单解析)
|
||||
type LegacyImportOptions struct {
|
||||
WithContent bool // 同时导入板块 / 帖子 / 评论
|
||||
DryRun bool // 预检:只统计不写库
|
||||
WithContent bool // 同时导入板块 / 帖子 / 评论
|
||||
DryRun bool // 预检:只统计不写库,并返回勾选清单
|
||||
SkipUserIDs map[uint]bool // 不创建账号的旧用户(内容仍按归属规则落位)
|
||||
OperatorUsers map[uint]bool // 内容明确归到站长的旧用户
|
||||
UserTargetNames map[uint]string // 旧用户ID → 指定已有账号用户名(内容归属)
|
||||
UserTargetMap map[uint]uint // (内部)UserTargetNames 解析后的目标账号 ID
|
||||
SkipPostIDs map[uint]bool // 排除的旧帖(其评论自动跳过)
|
||||
SkipCommentIDs map[uint]bool // 排除的旧评论
|
||||
}
|
||||
|
||||
// LegacyImportReport 导入结果摘要(JSON 返回给前端)
|
||||
type LegacyImportReport struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Users LegacyUserReport `json:"users"`
|
||||
Boards *LegacyBoardReport `json:"boards,omitempty"`
|
||||
Posts *LegacyPostReport `json:"posts,omitempty"`
|
||||
Comments *LegacyCommentReport `json:"comments,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Users LegacyUserReport `json:"users"`
|
||||
Boards *LegacyBoardReport `json:"boards,omitempty"`
|
||||
Posts *LegacyPostReport `json:"posts,omitempty"`
|
||||
Comments *LegacyCommentReport `json:"comments,omitempty"`
|
||||
UserList []LegacyUserPreview `json:"user_list,omitempty"` // 仅 dry-run:勾选清单
|
||||
PostList []LegacyPostPreview `json:"post_list,omitempty"` // 仅 dry-run
|
||||
CommentList []LegacyCommentPreview `json:"comment_list,omitempty"` // 仅 dry-run
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyUserPreview 预检清单:旧用户(供勾选是否建号 / 内容归属)
|
||||
type LegacyUserPreview struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Posts int `json:"posts"`
|
||||
Comments int `json:"comments"`
|
||||
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
|
||||
HasAvatar bool `json:"has_avatar"`
|
||||
}
|
||||
|
||||
// LegacyPostPreview 预检清单:旧帖(取消勾选即排除)
|
||||
type LegacyPostPreview struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Board string `json:"board"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Poll bool `json:"poll"` // 投票帖不可迁移
|
||||
Published bool `json:"published"` // 非公开帖导入时自动跳过
|
||||
}
|
||||
|
||||
// LegacyCommentPreview 预检清单:旧评论(取消勾选即排除)
|
||||
type LegacyCommentPreview struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
Author string `json:"author"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// LegacyUserReport 用户导入明细
|
||||
@@ -74,8 +133,9 @@ type LegacyUserReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Excluded int `json:"excluded"` // 手动排除建号
|
||||
AvatarWritten int `json:"avatar_written"`
|
||||
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
|
||||
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
|
||||
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
@@ -89,23 +149,31 @@ type LegacyBoardReport struct {
|
||||
|
||||
// LegacyPostReport 帖子导入明细
|
||||
type LegacyPostReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"` // 已导入过(去重记录)
|
||||
PollsSkipped int `json:"polls_skipped"`
|
||||
PollTitles []string `json:"poll_titles,omitempty"`
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"` // 已导入过(去重记录)
|
||||
Excluded int `json:"excluded"` // 手动排除
|
||||
ExcludedDetail []string `json:"excluded_detail,omitempty"`
|
||||
PollsSkipped int `json:"polls_skipped"`
|
||||
PollTitles []string `json:"poll_titles,omitempty"`
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
|
||||
ImagesWritten int `json:"images_written"` // 正文图片迁移落盘数
|
||||
ImagesMissing []string `json:"images_missing,omitempty"` // 包内缺失 / 落盘失败,保留原链接
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyCommentReport 评论导入明细
|
||||
type LegacyCommentReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"`
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Excluded int `json:"excluded"` // 手动排除(含所属帖子被排除)
|
||||
ExcludedDetail []string `json:"excluded_detail,omitempty"`
|
||||
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"`
|
||||
ImagesWritten int `json:"images_written"`
|
||||
ImagesMissing []string `json:"images_missing,omitempty"`
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// ===== 旧库行结构(只映射所需列) =====
|
||||
@@ -176,8 +244,16 @@ func (legacyCommentRow) TableName() string { return "comments" }
|
||||
// LegacyImportService 管理后台「数据导入」
|
||||
type LegacyImportService struct {
|
||||
db *gorm.DB
|
||||
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars
|
||||
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars,帖子图片写入 uploads/images
|
||||
md *htmltomarkdown.Converter
|
||||
ensureHall func(userID uint) error // 建号后加入默认大厅的钩子(可空;与注册链路一致)
|
||||
}
|
||||
|
||||
// WithHallMembership 注入「加入默认全站大厅」回调(chatSvc.EnsureDefaultMembership)。
|
||||
// 导入建号绕过注册链路,需显式接线保持行为一致;错误静默,幂等可补齐。
|
||||
func (s *LegacyImportService) WithHallMembership(fn func(userID uint) error) *LegacyImportService {
|
||||
s.ensureHall = fn
|
||||
return s
|
||||
}
|
||||
|
||||
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
|
||||
@@ -189,77 +265,127 @@ func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService
|
||||
}
|
||||
}
|
||||
|
||||
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。
|
||||
// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。
|
||||
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
|
||||
// zipPack 旧站文件包(头像 / 帖子图片)索引:纯文件名 → 条目。
|
||||
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
|
||||
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
|
||||
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
|
||||
type zipPack struct {
|
||||
idx map[string]*zip.File
|
||||
close func()
|
||||
}
|
||||
|
||||
func openZipPack(zipPath string) (*zipPack, error) {
|
||||
if zipPath == "" {
|
||||
return &zipPack{idx: map[string]*zip.File{}, close: func() {}}, nil
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, ErrLegacyBadZip
|
||||
}
|
||||
p := &zipPack{idx: make(map[string]*zip.File, len(zr.File)), close: func() { _ = zr.Close() }}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(f.Name, "\\", "/")
|
||||
parts := strings.Split(name, "/")
|
||||
var key string
|
||||
switch {
|
||||
case len(parts) == 1:
|
||||
key = parts[0]
|
||||
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
|
||||
key = parts[1]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if key == "" || key == "." || key == ".." {
|
||||
continue
|
||||
}
|
||||
if _, exists := p.idx[key]; !exists {
|
||||
p.idx[key] = f
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath / imagesZipPath
|
||||
// 为可选头像包与帖子图片包(空串表示未上传)。operatorID 为执行导入的账号(站长),
|
||||
// 作者缺失的帖子/评论归属到该账号。
|
||||
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath, imagesZipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
|
||||
if err := validateSQLiteMagic(dbPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 归属目标:用户名 → 本站已有账号 ID(含软删账号,内容归属不变)
|
||||
if len(opts.UserTargetNames) > 0 {
|
||||
resolved := make(map[uint]uint, len(opts.UserTargetNames))
|
||||
for oldID, name := range opts.UserTargetNames {
|
||||
var u model.User
|
||||
if err := s.db.Unscoped().Where("username = ?", name).First(&u).Error; err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrLegacyBadTarget, name)
|
||||
}
|
||||
resolved[oldID] = u.ID
|
||||
}
|
||||
opts.UserTargetNames = nil
|
||||
opts.UserTargetMap = resolved
|
||||
}
|
||||
oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err)
|
||||
}
|
||||
// 导入结束释放 SQLite 句柄,避免 Windows 上文件被占用
|
||||
if sqlDB, err := oldDB.DB(); err == nil {
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
}
|
||||
|
||||
avatars, err := openZipPack(zipPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer avatars.close()
|
||||
images, err := openZipPack(imagesZipPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer images.close()
|
||||
|
||||
if !opts.DryRun {
|
||||
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "avatars"), 0o755)
|
||||
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "images"), 0o755)
|
||||
}
|
||||
|
||||
rep := &LegacyImportReport{DryRun: opts.DryRun}
|
||||
rep.Users = s.importUsers(oldDB, zipPath, opts)
|
||||
if opts.DryRun {
|
||||
s.buildPreview(oldDB, rep)
|
||||
}
|
||||
rep.Users = s.importUsers(oldDB, avatars, opts)
|
||||
if !opts.WithContent {
|
||||
return rep, nil
|
||||
}
|
||||
boardMap, boardRep := s.importBoards(oldDB, opts)
|
||||
rep.Boards = boardRep
|
||||
rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep)
|
||||
rep.Comments = s.importComments(oldDB, operatorID, opts, rep)
|
||||
rep.Posts = s.importPosts(oldDB, boardMap, images, operatorID, opts, rep)
|
||||
rep.Comments = s.importComments(oldDB, images, operatorID, opts, rep)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ===== 用户 =====
|
||||
|
||||
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport {
|
||||
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, avatars *zipPack, opts LegacyImportOptions) LegacyUserReport {
|
||||
var rows []legacyUserRow
|
||||
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}}
|
||||
}
|
||||
|
||||
// 头像包索引:文件名 → 条目。
|
||||
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
|
||||
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
|
||||
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
|
||||
avatars := map[string]*zip.File{}
|
||||
if zipPath != "" {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}}
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(f.Name, "\\", "/")
|
||||
parts := strings.Split(name, "/")
|
||||
var key string
|
||||
switch {
|
||||
case len(parts) == 1:
|
||||
key = parts[0]
|
||||
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
|
||||
key = parts[1]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if key == "" || key == "." || key == ".." {
|
||||
continue
|
||||
}
|
||||
if _, exists := avatars[key]; !exists {
|
||||
avatars[key] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rep := LegacyUserReport{Total: len(rows)}
|
||||
avatarDir := filepath.Join(s.uploadsDir, "avatars")
|
||||
for _, u := range rows {
|
||||
// 手动排除:不创建账号,其内容按归属规则落位(见 resolveAuthor)
|
||||
if opts.SkipUserIDs[u.ID] {
|
||||
rep.Excluded++
|
||||
continue
|
||||
}
|
||||
username := strings.TrimSpace(u.Username)
|
||||
if username == "" || u.Password == "" {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID))
|
||||
@@ -277,7 +403,7 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
|
||||
continue
|
||||
}
|
||||
|
||||
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun)
|
||||
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars.idx, avatarDir, opts.DryRun)
|
||||
if err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err))
|
||||
continue
|
||||
@@ -308,6 +434,9 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
if s.ensureHall != nil {
|
||||
_ = s.ensureHall(user.ID) // 加入默认全站大厅(幂等,失败不阻断导入)
|
||||
}
|
||||
rep.Imported++
|
||||
}
|
||||
return rep
|
||||
@@ -342,42 +471,111 @@ func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*z
|
||||
return "", false, nil // 包内缺失
|
||||
}
|
||||
dst := filepath.Join(avatarDir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
|
||||
}
|
||||
if dryRun {
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
|
||||
}
|
||||
return legacyAvatarPrefix + name, true, nil
|
||||
}
|
||||
wrote, err := extractZipEntry(f, dst, legacyAvatarFileMax)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return legacyAvatarPrefix + name, wrote, nil
|
||||
}
|
||||
|
||||
// extractZipEntry 把 zip 条目解到 dst(先写临时文件再 rename,避免中断留下半截文件)。
|
||||
// 目标已存在时不写入直接返回 false(幂等;兼容 Windows 上目标已存在时 Rename 失败的语义)。
|
||||
func extractZipEntry(f *zip.File, dst string, max int64) (bool, error) {
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return false, nil
|
||||
}
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return false, err
|
||||
}
|
||||
defer src.Close()
|
||||
// 先写临时文件再 rename,避免中断留下半截文件
|
||||
out, err := os.CreateTemp(avatarDir, ".legacy-*")
|
||||
out, err := os.CreateTemp(filepath.Dir(dst), ".legacy-*")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return false, err
|
||||
}
|
||||
tmpName := out.Name()
|
||||
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
|
||||
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
|
||||
written, err := io.Copy(out, io.LimitReader(src, max+1))
|
||||
if closeErr := out.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
return false, err
|
||||
}
|
||||
if written > legacyAvatarFileMax {
|
||||
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20)
|
||||
if written > max {
|
||||
return false, fmt.Errorf("文件超过 %dMB 上限", max>>20)
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
// Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return legacyAvatarPrefix + name, false, nil
|
||||
return false, nil // Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
|
||||
}
|
||||
return "", false, err
|
||||
return false, err
|
||||
}
|
||||
return legacyAvatarPrefix + name, true, nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ===== 帖子 / 评论图片迁移 =====
|
||||
|
||||
// legacyImageStat 单次导入的图片迁移统计(跨帖子/评论累计)
|
||||
type legacyImageStat struct {
|
||||
written int
|
||||
missing map[string]bool
|
||||
}
|
||||
|
||||
func newImageStat() *legacyImageStat {
|
||||
return &legacyImageStat{missing: map[string]bool{}}
|
||||
}
|
||||
|
||||
// names 返回缺失文件名(排序去重)
|
||||
func (st *legacyImageStat) names() []string {
|
||||
out := make([]string, 0, len(st.missing))
|
||||
for k := range st.missing {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// migrateContentImages 把内容里引用的旧站帖子图片(/uploads/posts/<名>,含旧域名绝对 URL)
|
||||
// 从图片包落盘到新站 uploads/images(文件名原样保留),并把 URL 改写为新站相对路径。
|
||||
// 包内缺失或落盘失败的引用保留原链接并计入 missing。dryRun 只统计不落盘。
|
||||
func (s *LegacyImportService) migrateContentImages(content string, pack *zipPack, dryRun bool, stat *legacyImageStat) string {
|
||||
if !strings.Contains(content, legacyPostImagePrefix) {
|
||||
return content
|
||||
}
|
||||
return legacyPostImageRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
name := legacyPostImageRe.FindStringSubmatch(m)[1]
|
||||
if name == "." || name == ".." || name != path.Base(name) {
|
||||
return m
|
||||
}
|
||||
f, ok := pack.idx[name]
|
||||
if !ok {
|
||||
stat.missing[name] = true
|
||||
return m
|
||||
}
|
||||
dst := filepath.Join(s.uploadsDir, "images", name)
|
||||
if dryRun {
|
||||
if _, err := os.Stat(dst); err != nil {
|
||||
stat.written++
|
||||
}
|
||||
return legacyImagePrefix + name
|
||||
}
|
||||
wrote, err := extractZipEntry(f, dst, legacyImageFileMax)
|
||||
if err != nil {
|
||||
stat.missing[name] = true
|
||||
return m
|
||||
}
|
||||
if wrote {
|
||||
stat.written++
|
||||
}
|
||||
return legacyImagePrefix + name
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 板块 =====
|
||||
@@ -461,7 +659,22 @@ func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
|
||||
// resolveAuthor 内容归属:管理员显式指定 > 旧用户名匹配 > 站长兜底。
|
||||
// 第二个返回值表示是否因找不到作者而兜底到站长(显式指定不算,用于报告明细)。
|
||||
func resolveAuthor(oldUserID uint, opts LegacyImportOptions, userMap map[uint]uint, operatorID uint) (uint, bool) {
|
||||
if id, ok := opts.UserTargetMap[oldUserID]; ok && id > 0 {
|
||||
return id, false
|
||||
}
|
||||
if opts.OperatorUsers[oldUserID] {
|
||||
return operatorID, false
|
||||
}
|
||||
if id, ok := userMap[oldUserID]; ok {
|
||||
return id, false
|
||||
}
|
||||
return operatorID, true
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
|
||||
out := &LegacyPostReport{}
|
||||
if boardMap == nil || len(boardMap) == 0 {
|
||||
out.Failed = append(out.Failed, "无可用板块映射,帖子未导入")
|
||||
@@ -477,6 +690,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
userMap := s.mapLegacyUsers(oldDB)
|
||||
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
|
||||
convFailed := 0
|
||||
imgStat := newImageStat()
|
||||
|
||||
for _, p := range rows {
|
||||
title := strings.TrimSpace(p.Title)
|
||||
@@ -484,6 +698,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
|
||||
continue
|
||||
}
|
||||
if opts.SkipPostIDs[p.ID] {
|
||||
out.Excluded++
|
||||
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("%s (手动排除)", title))
|
||||
continue
|
||||
}
|
||||
if p.PostType == "poll" {
|
||||
out.PollsSkipped++
|
||||
out.PollTitles = append(out.PollTitles, title)
|
||||
@@ -509,13 +728,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
}
|
||||
}
|
||||
|
||||
// 作者映射,缺失归站长
|
||||
authorID := operatorID
|
||||
if id, ok := userMap[p.UserID]; ok {
|
||||
authorID = id
|
||||
} else if !missingAuthors[p.UserID] {
|
||||
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
|
||||
authorID, fellBack := resolveAuthor(p.UserID, opts, userMap, operatorID)
|
||||
if fellBack && !missingAuthors[p.UserID] {
|
||||
missingAuthors[p.UserID] = true
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID))
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未匹配到账号,归到站长)", p.UserID))
|
||||
}
|
||||
|
||||
// HTML → Markdown;失败保留原文(正文不丢)
|
||||
@@ -527,6 +744,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
convFailed++
|
||||
}
|
||||
}
|
||||
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
|
||||
|
||||
if opts.DryRun {
|
||||
out.Imported++
|
||||
@@ -557,13 +775,16 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
if convFailed > 0 {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed))
|
||||
}
|
||||
out.ImagesWritten = imgStat.written
|
||||
out.ImagesMissing = capList(imgStat.names())
|
||||
out.OwnerFallback = capList(out.OwnerFallback)
|
||||
out.ExcludedDetail = capList(out.ExcludedDetail)
|
||||
out.Failed = capList(out.Failed)
|
||||
out.PollTitles = capList(out.PollTitles)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
|
||||
func (s *LegacyImportService) importComments(oldDB *gorm.DB, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
|
||||
out := &LegacyCommentReport{}
|
||||
var rows []legacyCommentRow
|
||||
if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
@@ -584,8 +805,20 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
|
||||
userMap := s.mapLegacyUsers(oldDB)
|
||||
missingAuthors := map[uint]bool{}
|
||||
convFailed := 0
|
||||
imgStat := newImageStat()
|
||||
|
||||
for _, cm := range rows {
|
||||
// 所属帖子被排除 → 评论自动跳过(无落点)
|
||||
if opts.SkipPostIDs[cm.PostID] {
|
||||
out.Excluded++
|
||||
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (所属帖子被排除)", postTitles[cm.PostID], cm.ID))
|
||||
continue
|
||||
}
|
||||
if opts.SkipCommentIDs[cm.ID] {
|
||||
out.Excluded++
|
||||
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (手动排除)", postTitles[cm.PostID], cm.ID))
|
||||
continue
|
||||
}
|
||||
if cm.Status != "published" {
|
||||
out.Skipped++
|
||||
out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status))
|
||||
@@ -608,12 +841,11 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
|
||||
}
|
||||
}
|
||||
|
||||
authorID := operatorID
|
||||
if id, ok := userMap[cm.UserID]; ok {
|
||||
authorID = id
|
||||
} else if !missingAuthors[cm.UserID] {
|
||||
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
|
||||
authorID, fellBack := resolveAuthor(cm.UserID, opts, userMap, operatorID)
|
||||
if fellBack && !missingAuthors[cm.UserID] {
|
||||
missingAuthors[cm.UserID] = true
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID))
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未匹配到账号,归到站长)", cm.UserID))
|
||||
}
|
||||
|
||||
// HTML → Markdown;失败保留原文(内容不丢)
|
||||
@@ -625,6 +857,7 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
|
||||
convFailed++
|
||||
}
|
||||
}
|
||||
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
|
||||
|
||||
if opts.DryRun {
|
||||
out.Imported++
|
||||
@@ -639,7 +872,10 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
|
||||
if convFailed > 0 {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed))
|
||||
}
|
||||
out.ImagesWritten = imgStat.written
|
||||
out.ImagesMissing = capList(imgStat.names())
|
||||
out.SkippedDetail = capList(out.SkippedDetail)
|
||||
out.ExcludedDetail = capList(out.ExcludedDetail)
|
||||
out.OwnerFallback = capList(out.OwnerFallback)
|
||||
out.Failed = capList(out.Failed)
|
||||
return out
|
||||
@@ -699,6 +935,117 @@ func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uin
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 预检清单 =====
|
||||
|
||||
// buildPreview 汇总旧用户 / 帖子 / 评论清单供管理员勾选导入范围,仅 dry-run 调用。
|
||||
// 超出 legacyPreviewLimit 的清单截断并记入 Notes。
|
||||
func (s *LegacyImportService) buildPreview(oldDB *gorm.DB, rep *LegacyImportReport) {
|
||||
var users []legacyUserRow
|
||||
if err := oldDB.Unscoped().Order("id ASC").Find(&users).Error; err != nil {
|
||||
return
|
||||
}
|
||||
var boards []legacyBoardRow
|
||||
// 与 importBoards/importPosts/importComments 保持一致:不含软删行(用户除外,软删用户内容仍需归属)
|
||||
_ = oldDB.Order("id ASC").Find(&boards).Error
|
||||
boardNames := map[uint]string{}
|
||||
for _, b := range boards {
|
||||
boardNames[b.ID] = b.Name
|
||||
}
|
||||
var posts []legacyPostRow
|
||||
_ = oldDB.Order("id ASC").Find(&posts).Error
|
||||
var comments []legacyCommentRow
|
||||
_ = oldDB.Order("post_id ASC, id ASC").Find(&comments).Error
|
||||
|
||||
authorName := map[uint]string{}
|
||||
postCount := map[uint]int{}
|
||||
commentCount := map[uint]int{}
|
||||
for _, u := range users {
|
||||
name := strings.TrimSpace(u.Nickname)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(u.Username)
|
||||
}
|
||||
authorName[u.ID] = name
|
||||
}
|
||||
postTitles := map[uint]string{}
|
||||
for _, p := range posts {
|
||||
postCount[p.UserID]++
|
||||
postTitles[p.ID] = p.Title
|
||||
}
|
||||
for _, cm := range comments {
|
||||
commentCount[cm.UserID]++
|
||||
}
|
||||
existing := s.existingUsernames()
|
||||
|
||||
for _, u := range users {
|
||||
rep.UserList = append(rep.UserList, LegacyUserPreview{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Posts: postCount[u.ID],
|
||||
Comments: commentCount[u.ID],
|
||||
Exists: existing[strings.ToLower(strings.TrimSpace(u.Username))],
|
||||
HasAvatar: u.Avatar != "",
|
||||
})
|
||||
}
|
||||
for _, p := range posts {
|
||||
rep.PostList = append(rep.PostList, LegacyPostPreview{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
Author: authorName[p.UserID],
|
||||
Board: boardNames[p.BoardID],
|
||||
CreatedAt: p.CreatedAt,
|
||||
Poll: p.PostType == "poll",
|
||||
Published: p.Status == "published",
|
||||
})
|
||||
}
|
||||
for _, cm := range comments {
|
||||
rep.CommentList = append(rep.CommentList, LegacyCommentPreview{
|
||||
ID: cm.ID,
|
||||
PostID: cm.PostID,
|
||||
PostTitle: postTitles[cm.PostID],
|
||||
Author: authorName[cm.UserID],
|
||||
Excerpt: legacyExcerpt(cm.Content, 60),
|
||||
CreatedAt: cm.CreatedAt,
|
||||
Published: cm.Status == "published",
|
||||
})
|
||||
}
|
||||
|
||||
if len(rep.UserList) > legacyPreviewLimit {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("用户清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
|
||||
rep.UserList = rep.UserList[:legacyPreviewLimit]
|
||||
}
|
||||
if len(rep.PostList) > legacyPreviewLimit {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("帖子清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
|
||||
rep.PostList = rep.PostList[:legacyPreviewLimit]
|
||||
}
|
||||
if len(rep.CommentList) > legacyPreviewLimit {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("评论清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
|
||||
rep.CommentList = rep.CommentList[:legacyPreviewLimit]
|
||||
}
|
||||
}
|
||||
|
||||
// existingUsernames 本站已有用户名集合(含软删,忽略大小写)
|
||||
func (s *LegacyImportService) existingUsernames() map[string]bool {
|
||||
var rows []model.User
|
||||
if err := s.db.Unscoped().Select("username").Find(&rows).Error; err != nil {
|
||||
return map[string]bool{}
|
||||
}
|
||||
set := make(map[string]bool, len(rows))
|
||||
for _, u := range rows {
|
||||
set[strings.ToLower(u.Username)] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
var legacyHTMLTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// legacyExcerpt 内容摘要:去 HTML 标签、压平空白后按 rune 截断(预检清单展示用)
|
||||
func legacyExcerpt(s string, max int) string {
|
||||
s = legacyHTMLTagRe.ReplaceAllString(s, " ")
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
return truncateRunesN(s, max)
|
||||
}
|
||||
|
||||
// ===== 工具 =====
|
||||
|
||||
// capList 明细条数截断,超出部分以「…等 N 条」收尾
|
||||
|
||||
281
backend/service/legacyimport_test.go
Normal file
281
backend/service/legacyimport_test.go
Normal 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
|
||||
}
|
||||
@@ -163,8 +163,10 @@ func (s *AuthService) LookupFamilyByRefreshPlain(plain string, userID uint) uint
|
||||
return sessionFamilyID(rt)
|
||||
}
|
||||
|
||||
// TouchDeviceFromRefresh 用当前请求的 IP/UA 校准本会话,并回填尚未写入的 family_id。
|
||||
func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint) uint {
|
||||
// TouchDeviceFromRefresh 刷新本会话活跃时间,并回填尚未写入的 family_id。
|
||||
// 会话的 IP/UA 以登录时记录为准,不随访问端环境漂移,
|
||||
// 否则设备指纹会跟着请求变,设备列表在 UA 模拟/代理下会来回跳。
|
||||
func (s *AuthService) TouchDeviceFromRefresh(plain string, userID uint) uint {
|
||||
if plain == "" {
|
||||
return 0
|
||||
}
|
||||
@@ -177,12 +179,6 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
|
||||
}
|
||||
now := time.Now()
|
||||
upd := map[string]any{"last_used_at": now, "updated_at": now}
|
||||
if v := truncateStr(ip, 45); v != "" {
|
||||
upd["ip"] = v
|
||||
}
|
||||
if v := truncateStr(ua, 500); v != "" {
|
||||
upd["user_agent"] = v
|
||||
}
|
||||
fid := sessionFamilyID(rt)
|
||||
if rt.FamilyID == 0 {
|
||||
upd["family_id"] = fid
|
||||
@@ -191,26 +187,29 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
|
||||
return fid
|
||||
}
|
||||
|
||||
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
|
||||
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
|
||||
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
|
||||
// maxActiveSessions 每个用户允许并存的活跃会话(登录设备)上限,
|
||||
// 与设备列表展示上限 loginDeviceLimit 一致;超出时淘汰最久未使用的会话。
|
||||
const maxActiveSessions = 20
|
||||
|
||||
// enforceSessionCap 会话上限守护:多会话并存下新登录不踢旧设备,仅当该用户
|
||||
// 活跃会话超过 maxActiveSessions 时,按 last_used_at 淘汰最旧的会话腾位;
|
||||
// 返回淘汰行数供上层广播告知(新会话 last_used_at 最新,不会被淘汰)。
|
||||
func (s *AuthService) enforceSessionCap(userID uint) int {
|
||||
now := time.Now()
|
||||
var actives []model.RefreshToken
|
||||
if err := s.db.Select("id", "family_id").
|
||||
if err := s.db.Select("id").
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
|
||||
Order("last_used_at DESC, created_at DESC, id DESC").
|
||||
Find(&actives).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
ids := make([]uint, 0)
|
||||
for _, t := range actives {
|
||||
if sessionFamilyID(t) == keepFamily {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
if len(actives) <= maxActiveSessions {
|
||||
return 0
|
||||
}
|
||||
ids := make([]uint, 0, len(actives)-maxActiveSessions)
|
||||
for _, t := range actives[maxActiveSessions:] {
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
|
||||
if res.Error != nil {
|
||||
|
||||
276
backend/service/login_session_test.go
Normal file
276
backend/service/login_session_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newLoginSessionTestDB(t *testing.T) *gorm.DB {
|
||||
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.User{}, &model.RefreshToken{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newLoginSessionUser(t *testing.T, db *gorm.DB, username string) *model.User {
|
||||
t.Helper()
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
user := model.User{Username: username, Password: string(hashed)}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return &user
|
||||
}
|
||||
|
||||
func countActiveSessions(t *testing.T, db *gorm.DB, userID uint) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := db.Model(&model.RefreshToken{}).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
|
||||
Count(&n).Error; err != nil {
|
||||
t.Fatalf("count active sessions: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestLoginMultiSessionCoexist 多会话并存:不同设备先后登录互不顶号,
|
||||
// 各会话的 access token(family_id)均持续有效。
|
||||
func TestLoginMultiSessionCoexist(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret")
|
||||
user := newLoginSessionUser(t, db, "alice")
|
||||
|
||||
uas := []string{"Chrome-Windows", "Edge-Windows", "iPhone-Safari"}
|
||||
families := make([]uint, 0, len(uas))
|
||||
for i, ua := range uas {
|
||||
access, refresh, _, kicked, err := s.Login("alice", "password123", "1.2.3.4", ua)
|
||||
if err != nil {
|
||||
t.Fatalf("login #%d: %v", i+1, err)
|
||||
}
|
||||
if kicked != 0 {
|
||||
t.Fatalf("login #%d 不应踢掉旧会话,kicked=%d", i+1, kicked)
|
||||
}
|
||||
if _, err := s.ValidateRefreshToken(refresh); err != nil {
|
||||
t.Fatalf("login #%d refresh 校验失败: %v", i+1, err)
|
||||
}
|
||||
claims, err := s.ParseToken(access)
|
||||
if err != nil {
|
||||
t.Fatalf("login #%d 解析 access token: %v", i+1, err)
|
||||
}
|
||||
if _, err := s.ValidateClaims(claims); err != nil {
|
||||
t.Fatalf("login #%d 会话应有效: %v", i+1, err)
|
||||
}
|
||||
families = append(families, claims.FamilyID)
|
||||
}
|
||||
|
||||
if n := countActiveSessions(t, db, user.ID); n != int64(len(uas)) {
|
||||
t.Fatalf("应共存 %d 个活跃会话,实际 %d", len(uas), n)
|
||||
}
|
||||
for _, fam := range families {
|
||||
if !s.familySessionActive(user.ID, fam) {
|
||||
t.Fatalf("family=%d 会话应仍有效", fam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionCapEvictsOldest 会话上限守护:活跃会话超过 maxActiveSessions 时,
|
||||
// 按 last_used_at 淘汰最旧会话,其余会话不受影响。
|
||||
func TestSessionCapEvictsOldest(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret")
|
||||
user := newLoginSessionUser(t, db, "bob")
|
||||
|
||||
families := make([]uint, 0, maxActiveSessions)
|
||||
for i := 0; i < maxActiveSessions; i++ {
|
||||
_, family, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device")
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", i+1, err)
|
||||
}
|
||||
if kicked != 0 {
|
||||
t.Fatalf("create #%d 不应淘汰会话,kicked=%d", i+1, kicked)
|
||||
}
|
||||
families = append(families, family)
|
||||
}
|
||||
// 人为拉开 last_used_at,保证淘汰顺序确定(第 1 个最旧)
|
||||
base := time.Now().Add(-2 * time.Hour)
|
||||
for i, fam := range families {
|
||||
if err := db.Model(&model.RefreshToken{}).Where("family_id = ?", fam).
|
||||
Update("last_used_at", base.Add(time.Duration(i)*time.Minute)).Error; err != nil {
|
||||
t.Fatalf("调整 last_used_at: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device-new")
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", maxActiveSessions+1, err)
|
||||
}
|
||||
if kicked != 1 {
|
||||
t.Fatalf("超出上限应淘汰 1 个最旧会话,kicked=%d", kicked)
|
||||
}
|
||||
if n := countActiveSessions(t, db, user.ID); n != maxActiveSessions {
|
||||
t.Fatalf("淘汰后应剩 %d 个活跃会话,实际 %d", maxActiveSessions, n)
|
||||
}
|
||||
if s.familySessionActive(user.ID, families[0]) {
|
||||
t.Fatal("最旧会话应已被淘汰")
|
||||
}
|
||||
for _, fam := range families[1:] {
|
||||
if !s.familySessionActive(user.ID, fam) {
|
||||
t.Fatalf("family=%d 会话不应被淘汰", fam)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeLoginDeviceWithMultiSessions 多会话下剔除设备:仅目标会话失效,
|
||||
// 当前会话不受影响(沿用既有剔除语义的回归保障)。
|
||||
func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret")
|
||||
user := newLoginSessionUser(t, db, "carol")
|
||||
|
||||
access1, refresh1, _, _, err := s.Login("carol", "password123", "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("login 1: %v", err)
|
||||
}
|
||||
claims1, err := s.ParseToken(access1)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access 1: %v", err)
|
||||
}
|
||||
family1 := claims1.FamilyID
|
||||
access2, _, _, _, err := s.Login("carol", "password123", "2.2.2.2", "iPhone-Mobile")
|
||||
if err != nil {
|
||||
t.Fatalf("login 2: %v", err)
|
||||
}
|
||||
claims2, err := s.ParseToken(access2)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access 2: %v", err)
|
||||
}
|
||||
family2 := claims2.FamilyID
|
||||
|
||||
devices, err := s.ListLoginDevices(user.ID, family1, "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("list devices: %v", err)
|
||||
}
|
||||
if len(devices) != 2 {
|
||||
t.Fatalf("应列出 2 台设备,实际 %d", len(devices))
|
||||
}
|
||||
var targetID uint
|
||||
for _, d := range devices {
|
||||
if !d.Current {
|
||||
targetID = d.ID
|
||||
}
|
||||
}
|
||||
if targetID == 0 {
|
||||
t.Fatal("未找到非当前设备")
|
||||
}
|
||||
if err := s.RevokeLoginDevice(user.ID, family1, targetID); err != nil {
|
||||
t.Fatalf("revoke device: %v", err)
|
||||
}
|
||||
|
||||
if s.familySessionActive(user.ID, family2) {
|
||||
t.Fatal("被剔除设备的会话应已失效")
|
||||
}
|
||||
if _, err := s.ValidateRefreshToken(refresh1); err != nil {
|
||||
t.Fatalf("当前设备会话不应受影响: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
|
||||
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
|
||||
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
|
||||
func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret")
|
||||
user := newLoginSessionUser(t, db, "dave")
|
||||
|
||||
access1, refresh1, _, _, err := s.Login("dave", "password123", "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("login 1: %v", err)
|
||||
}
|
||||
claims1, err := s.ParseToken(access1)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access 1: %v", err)
|
||||
}
|
||||
access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
|
||||
if err != nil {
|
||||
t.Fatalf("login 2: %v", err)
|
||||
}
|
||||
claims2, err := s.ParseToken(access2)
|
||||
if err != nil {
|
||||
t.Fatalf("parse access 2: %v", err)
|
||||
}
|
||||
|
||||
// 家族1 正常轮转一次:旧行吊销并链接到新行
|
||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
|
||||
t.Fatalf("rotate 1: %v", err)
|
||||
}
|
||||
|
||||
// 把旧行 rotated_at 拨回宽限期之外,模拟「轮换响应丢失后的延迟重放」
|
||||
if err := db.Model(&model.RefreshToken{}).
|
||||
Where("token_hash = ?", hashRefreshToken(refresh1)).
|
||||
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
|
||||
t.Fatalf("backdate rotated_at: %v", err)
|
||||
}
|
||||
|
||||
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
|
||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
|
||||
t.Fatalf("期望 ErrRefreshReused,实际 %v", err)
|
||||
}
|
||||
if s.familySessionActive(user.ID, claims1.FamilyID) {
|
||||
t.Fatal("重放所在家族应被整体吊销")
|
||||
}
|
||||
if !s.familySessionActive(user.ID, claims2.FamilyID) {
|
||||
t.Fatal("其他登录设备(家族)不应被牵连")
|
||||
}
|
||||
if n := countActiveSessions(t, db, user.ID); n != 1 {
|
||||
t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n)
|
||||
}
|
||||
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
||||
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReuseDevGraceReturnsSamePair dev 模式宽限期放宽:轮换后远超
|
||||
// 生产宽限窗的同链重放(dev 重启丢响应的常态)仍返回同一对,不升级为盗用判定。
|
||||
func TestRefreshReuseDevGraceReturnsSamePair(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret").WithDevMode(true)
|
||||
newLoginSessionUser(t, db, "eve")
|
||||
|
||||
_, refresh1, _, _, err := s.Login("eve", "password123", "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
_, newRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
|
||||
// rotated_at 拨回生产宽限期之外(仍在 dev 宽限内)
|
||||
if err := db.Model(&model.RefreshToken{}).
|
||||
Where("token_hash = ?", hashRefreshToken(refresh1)).
|
||||
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
|
||||
t.Fatalf("backdate rotated_at: %v", err)
|
||||
}
|
||||
|
||||
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("dev 宽限期内重放不应报错: %v", err)
|
||||
}
|
||||
if replayRefresh != newRefresh {
|
||||
t.Fatal("dev 宽限期内重放应返回同一对新 token")
|
||||
}
|
||||
}
|
||||
388
backend/service/media_library.go
Normal file
388
backend/service/media_library.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
// 媒体库(管理后台):盘点站点内全部图片资源。
|
||||
// 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并:
|
||||
// - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖
|
||||
// - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中
|
||||
// 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。
|
||||
|
||||
// 媒体库分类:目录名即分类键,前端 tab 直接使用
|
||||
var mediaLibraryCategories = []struct {
|
||||
Key string // 分类键(image/avatar/background/ads/brand)
|
||||
Dir string // uploads 下的子目录
|
||||
Name string // 展示名
|
||||
}{
|
||||
{"image", "images", "帖子插图"},
|
||||
{"avatar", "avatars", "用户头像"},
|
||||
{"background", "backgrounds", "站点背景"},
|
||||
{"ads", "ads", "广告素材"},
|
||||
{"brand", "brand", "品牌资源"},
|
||||
}
|
||||
|
||||
// MediaLibraryItem 媒体库条目
|
||||
type MediaLibraryItem struct {
|
||||
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
||||
OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存)
|
||||
Category string `json:"category"` // image/avatar/background/ads/brand
|
||||
CategoryName string `json:"category_name"`
|
||||
Name string `json:"name"` // 文件名(含扩展名)
|
||||
MIME string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
|
||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
|
||||
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
|
||||
}
|
||||
|
||||
// AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。
|
||||
// 返回条目列表与各分类计数(含全部计数由前端按列表累加)。
|
||||
func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) {
|
||||
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
||||
type attMeta struct {
|
||||
Kind string
|
||||
URL string
|
||||
MIME string
|
||||
Size int
|
||||
Width int
|
||||
Height int
|
||||
Nickname string
|
||||
Username string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
var rows []attMeta
|
||||
if err := s.db.Table("attachments").
|
||||
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
||||
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
metaByURL := make(map[string]attMeta, len(rows))
|
||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
||||
items := make([]MediaLibraryItem, 0, len(rows))
|
||||
categoryName := func(key string) string {
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Key == key {
|
||||
return cat.Name
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
for _, r := range rows {
|
||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
||||
continue
|
||||
}
|
||||
uploader := r.Nickname
|
||||
if uploader == "" {
|
||||
uploader = r.Username
|
||||
}
|
||||
cat := r.Kind
|
||||
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
|
||||
continue // 未知 kind 不进媒体库
|
||||
}
|
||||
ca := r.CreatedAt
|
||||
items = append(items, MediaLibraryItem{
|
||||
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
|
||||
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||
Uploader: uploader, UploadedAt: &ca,
|
||||
})
|
||||
}
|
||||
|
||||
// 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL
|
||||
type group struct {
|
||||
webp *MediaLibraryItem
|
||||
original *MediaLibraryItem
|
||||
}
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
dir := filepath.Join(s.dir, cat.Dir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue // 目录不存在视为空
|
||||
}
|
||||
groups := map[string]*group{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
url := "/uploads/" + cat.Dir + "/" + e.Name()
|
||||
item := MediaLibraryItem{
|
||||
URL: url, Category: cat.Key, CategoryName: cat.Name,
|
||||
Name: e.Name(),
|
||||
MIME: map[string]string{
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
||||
".webp": "image/webp", ".gif": "image/gif",
|
||||
}[ext],
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
item.Size = info.Size()
|
||||
mt := info.ModTime()
|
||||
item.UploadedAt = &mt
|
||||
}
|
||||
if m, ok := metaByURL[url]; ok {
|
||||
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
||||
uploader := m.Nickname
|
||||
if uploader == "" {
|
||||
uploader = m.Username
|
||||
}
|
||||
ca := m.CreatedAt
|
||||
item.Uploader = uploader
|
||||
item.UploadedAt = &ca
|
||||
if m.Size > 0 {
|
||||
item.Size = int64(m.Size)
|
||||
}
|
||||
if m.Width > 0 {
|
||||
item.Width, item.Height = m.Width, m.Height
|
||||
}
|
||||
}
|
||||
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
||||
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
||||
if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil {
|
||||
w, h, derr := decodeImageSizeReader(f, item.MIME)
|
||||
_ = f.Close()
|
||||
if derr == nil {
|
||||
item.Width, item.Height = w, h
|
||||
}
|
||||
}
|
||||
}
|
||||
stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
||||
g := groups[stem]
|
||||
if g == nil {
|
||||
g = &group{}
|
||||
groups[stem] = g
|
||||
}
|
||||
if ext == ".webp" {
|
||||
g.webp = &item
|
||||
} else if g.original == nil {
|
||||
g.original = &item
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
switch {
|
||||
case g.webp != nil && g.original != nil:
|
||||
// 原图与 WebP 并存:只展示 WebP,原图作 original_url
|
||||
g.webp.OriginalURL = g.original.URL
|
||||
if g.webp.Size == 0 {
|
||||
g.webp.Size = g.original.Size
|
||||
}
|
||||
items = append(items, *g.webp)
|
||||
case g.webp != nil:
|
||||
items = append(items, *g.webp)
|
||||
default:
|
||||
items = append(items, *g.original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
ti, tj := items[i].UploadedAt, items[j].UploadedAt
|
||||
switch {
|
||||
case ti == nil && tj == nil:
|
||||
return items[i].URL < items[j].URL
|
||||
case ti == nil:
|
||||
return false
|
||||
case tj == nil:
|
||||
return true
|
||||
}
|
||||
return ti.After(*tj)
|
||||
})
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
counts[it.Category]++
|
||||
}
|
||||
s.attachMediaSources(items)
|
||||
return items, counts, nil
|
||||
}
|
||||
|
||||
// attachMediaSources 为条目补来源回链:
|
||||
// - 头像:users.avatar 精确匹配 → 用户主页
|
||||
// - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论
|
||||
//
|
||||
// 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。
|
||||
func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
||||
urlSet := map[string]bool{}
|
||||
for _, it := range items {
|
||||
for _, u := range []string{it.URL, it.OriginalURL} {
|
||||
if strings.HasPrefix(u, "/uploads/") {
|
||||
urlSet[u] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(urlSet) == 0 {
|
||||
return
|
||||
}
|
||||
urls := make([]string, 0, len(urlSet))
|
||||
for u := range urlSet {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
|
||||
type postRef struct {
|
||||
id uint
|
||||
title string
|
||||
}
|
||||
postByURL := map[string]postRef{}
|
||||
type commentRef struct {
|
||||
id, postID uint
|
||||
}
|
||||
commentByURL := map[string]commentRef{}
|
||||
|
||||
for i := 0; i < len(urls); i += 40 {
|
||||
end := i + 40
|
||||
if end > len(urls) {
|
||||
end = len(urls)
|
||||
}
|
||||
chunk := urls[i:end]
|
||||
conds := make([]string, len(chunk))
|
||||
args := make([]any, len(chunk))
|
||||
for j, u := range chunk {
|
||||
conds[j] = "content LIKE ?"
|
||||
args[j] = "%" + u + "%"
|
||||
}
|
||||
where := strings.Join(conds, " OR ")
|
||||
|
||||
var prows []struct {
|
||||
ID uint
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
if err := s.db.Model(&model.Post{}).Select("id, title, content").
|
||||
Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range prows {
|
||||
for _, u := range chunk {
|
||||
if strings.Contains(p.Content, u) {
|
||||
if _, ok := postByURL[u]; !ok {
|
||||
postByURL[u] = postRef{id: p.ID, title: p.Title}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var crows []struct {
|
||||
ID uint
|
||||
PostID uint
|
||||
Content string
|
||||
}
|
||||
if err := s.db.Model(&model.Comment{}).Select("id, post_id, content").
|
||||
Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for _, cm := range crows {
|
||||
for _, u := range chunk {
|
||||
if strings.Contains(cm.Content, u) {
|
||||
if _, ok := commentByURL[u]; !ok {
|
||||
commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 评论来源要带宿主帖子标题,一次性补齐
|
||||
postTitle := func(id uint) string {
|
||||
var p model.Post
|
||||
if err := s.db.Select("id, title").First(&p, id).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return p.Title
|
||||
}
|
||||
commentPostTitle := map[uint]string{}
|
||||
for _, cm := range commentByURL {
|
||||
if _, ok := commentPostTitle[cm.postID]; !ok {
|
||||
commentPostTitle[cm.postID] = postTitle(cm.postID)
|
||||
}
|
||||
}
|
||||
|
||||
// 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户)
|
||||
userByAvatar := map[string]struct {
|
||||
ID uint
|
||||
Nickname, Username string
|
||||
}{}
|
||||
var urows []struct {
|
||||
ID uint
|
||||
Nickname string
|
||||
Username string
|
||||
Avatar string
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar").
|
||||
Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil {
|
||||
for _, u := range urows {
|
||||
if _, ok := userByAvatar[u.Avatar]; !ok {
|
||||
userByAvatar[u.Avatar] = struct {
|
||||
ID uint
|
||||
Nickname, Username string
|
||||
}{ID: u.ID, Nickname: u.Nickname, Username: u.Username}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
truncate := func(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
postLabel := func(p postRef) string {
|
||||
title := p.title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("#%d", p.id)
|
||||
}
|
||||
return "帖子《" + truncate(title, 40) + "》"
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
it := &items[i]
|
||||
// 头像使用者优先(头像正文引用场景极少)
|
||||
if u, ok := userByAvatar[it.URL]; ok {
|
||||
name := u.Nickname
|
||||
if name == "" {
|
||||
name = u.Username
|
||||
}
|
||||
it.SourceURL = fmt.Sprintf("/u/%d", u.ID)
|
||||
it.SourceLabel = "@" + name + " 的头像"
|
||||
continue
|
||||
}
|
||||
for _, u := range []string{it.URL, it.OriginalURL} {
|
||||
if u == "" || it.SourceURL != "" {
|
||||
continue
|
||||
}
|
||||
if p, ok := postByURL[u]; ok {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d", p.id)
|
||||
it.SourceLabel = postLabel(p)
|
||||
break
|
||||
}
|
||||
if cm, ok := commentByURL[u]; ok {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
|
||||
title := commentPostTitle[cm.postID]
|
||||
if title != "" {
|
||||
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
||||
} else {
|
||||
it.SourceLabel = "帖子评论"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
312
backend/service/media_library_test.go
Normal file
312
backend/service/media_library_test.go
Normal 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: "看图 "}
|
||||
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")
|
||||
}
|
||||
}
|
||||
114
backend/service/media_thumbs.go
Normal file
114
backend/service/media_thumbs.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"golang.org/x/image/draw"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||||
|
||||
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
||||
const MediaThumbMaxSide = 480
|
||||
|
||||
// MediaThumb 返回指定 /uploads/ 图片的缩略图字节。
|
||||
func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
||||
if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 {
|
||||
return nil, errors.New("无效地址")
|
||||
}
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
|
||||
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return nil, errors.New("无效地址")
|
||||
}
|
||||
dir := filepath.Dir(rel)
|
||||
ok := false
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Dir == dir {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("无效地址")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||||
default:
|
||||
return nil, errors.New("非图片")
|
||||
}
|
||||
|
||||
src := filepath.Join(s.dir, rel)
|
||||
info, err := os.Stat(src)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, errors.New("文件不存在")
|
||||
}
|
||||
|
||||
// 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建
|
||||
sum := sha1.Sum([]byte(url))
|
||||
thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp")
|
||||
if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) {
|
||||
return os.ReadFile(thumbPath)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var img image.Image
|
||||
if ext == ".webp" {
|
||||
img, err = xwebp.Decode(bytes.NewReader(data))
|
||||
} else {
|
||||
img, _, err = image.Decode(bytes.NewReader(data))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err // 如动图 WebP,调用方回退原图
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide {
|
||||
// 小图无需缩放,直接回原图省 CPU
|
||||
return data, nil
|
||||
}
|
||||
nw, nh := w, h
|
||||
if w >= h {
|
||||
nw = MediaThumbMaxSide
|
||||
nh = max(1, h*MediaThumbMaxSide/w)
|
||||
} else {
|
||||
nh = MediaThumbMaxSide
|
||||
nw = max(1, w*MediaThumbMaxSide/h)
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||||
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
|
||||
tmp := thumbPath + ".partial"
|
||||
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
|
||||
if err := os.Rename(tmp, thumbPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"golang.org/x/image/webp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -29,10 +30,13 @@ const (
|
||||
AvatarMinDim = 64
|
||||
AvatarMaxDim = 512
|
||||
|
||||
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
|
||||
ImageMaxBytes = 5 << 20 // 5 MiB
|
||||
// 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
|
||||
ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效)
|
||||
ImageMaxDim = 4096
|
||||
|
||||
// JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响
|
||||
ImageWebPQuality = 80
|
||||
|
||||
// 站点背景图(前台/后台墙纸)
|
||||
BackgroundMaxBytes = 8 << 20 // 8 MiB
|
||||
BackgroundMaxDim = 8192
|
||||
@@ -190,7 +194,50 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP:
|
||||
// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。
|
||||
// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。
|
||||
func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) {
|
||||
webpName := strings.TrimSuffix(name, ext) + ".webp"
|
||||
webpPath := filepath.Join(s.dir, "images", webpName)
|
||||
webpTmp := webpPath + ".partial"
|
||||
if s.ops != nil {
|
||||
release, e := s.ops.BeginTemporary(webpTmp)
|
||||
if e != nil {
|
||||
return "", "", 0, e
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
f, err := os.Open(tmp)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := webpenc.Encode(&buf, img, webpenc.Options{
|
||||
Quality: ImageWebPQuality,
|
||||
Lossless: ext == ".png",
|
||||
}); err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil {
|
||||
_ = os.Remove(webpTmp)
|
||||
return "", "", 0, err
|
||||
}
|
||||
if err := os.Rename(webpTmp, webpPath); err != nil {
|
||||
_ = os.Remove(webpTmp)
|
||||
return "", "", 0, err
|
||||
}
|
||||
_ = os.Remove(tmp)
|
||||
return webpName, "image/webp", int64(buf.Len()), nil
|
||||
}
|
||||
|
||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
||||
if src == nil {
|
||||
return nil, errors.New("文件为空")
|
||||
@@ -284,33 +331,48 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
||||
_ = os.Remove(tmp)
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
if err := os.Rename(tmp, fullPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
|
||||
// JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
|
||||
storeName, storeMime, storeSize := filename, format.mime, total
|
||||
storePath := fullPath
|
||||
if format.mime == "image/jpeg" || format.mime == "image/png" {
|
||||
name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext)
|
||||
if err != nil {
|
||||
log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err)
|
||||
} else {
|
||||
storeName, storeMime, storeSize = name, mime, size
|
||||
storePath = filepath.Join(s.dir, "images", name)
|
||||
}
|
||||
}
|
||||
if storePath == fullPath {
|
||||
if err := os.Rename(tmp, fullPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/" + filename,
|
||||
MIME: format.mime,
|
||||
Size: int(total),
|
||||
URL: "/uploads/images/" + storeName,
|
||||
MIME: storeMime,
|
||||
Size: int(storeSize),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||
id, e := s.ops.StoreFile(storePath, att.MIME, true)
|
||||
if e != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
_ = os.Remove(storePath)
|
||||
return nil, e
|
||||
}
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
_ = os.Remove(fullPath)
|
||||
_ = os.Remove(storePath)
|
||||
}
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
_ = os.Remove(storePath)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
|
||||
172
backend/service/upload_webp_test.go
Normal file
172
backend/service/upload_webp_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"github.com/glebarez/sqlite"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newUploadTestService(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{}); 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 storedUploadPath(t *testing.T, dir, url string) []byte {
|
||||
t.Helper()
|
||||
rel := strings.TrimPrefix(url, "/uploads/")
|
||||
data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatalf("read stored file %s: %v", url, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
|
||||
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
|
||||
for y := 0; y < 36; y++ {
|
||||
for x := 0; x < 48; x++ {
|
||||
src.Set(x, y, color.RGBA{uint8(x * 5), uint8(y * 7), 128, 255})
|
||||
}
|
||||
}
|
||||
var in bytes.Buffer
|
||||
if err := jpeg.Encode(&in, src, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
|
||||
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
|
||||
}
|
||||
if att.Width != 48 || att.Height != 36 {
|
||||
t.Fatalf("dims changed: %dx%d", att.Width, att.Height)
|
||||
}
|
||||
|
||||
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode stored webp: %v", err)
|
||||
}
|
||||
if got := img.Bounds(); got.Dx() != 48 || got.Dy() != 36 {
|
||||
t.Fatalf("stored webp dims: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 24, 24))
|
||||
for y := 0; y < 24; y++ {
|
||||
for x := 0; x < 24; x++ {
|
||||
if y < 12 {
|
||||
src.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 0}) // 上半透明
|
||||
} else {
|
||||
src.SetNRGBA(x, y, color.NRGBA{12, 34, 56, 255})
|
||||
}
|
||||
}
|
||||
}
|
||||
var in bytes.Buffer
|
||||
if err := png.Encode(&in, src); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
|
||||
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
|
||||
}
|
||||
|
||||
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
|
||||
if err != nil {
|
||||
t.Fatalf("decode stored webp: %v", err)
|
||||
}
|
||||
if _, _, _, a := img.At(0, 0).RGBA(); a != 0 {
|
||||
t.Fatalf("alpha lost at transparent pixel: %d", a)
|
||||
}
|
||||
r, g, b, _ := img.At(5, 20).RGBA()
|
||||
if r>>8 != 12 || g>>8 != 34 || b>>8 != 56 {
|
||||
t.Fatalf("opaque color changed: %d,%d,%d", r>>8, g>>8, b>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveImageKeepsGIFAsIs(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
|
||||
src := image.NewRGBA(image.Rect(0, 0, 16, 16))
|
||||
for y := 0; y < 16; y++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
src.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 0, 255})
|
||||
}
|
||||
}
|
||||
var in bytes.Buffer
|
||||
if err := gif.Encode(&in, src, nil); err != nil {
|
||||
t.Fatalf("encode gif: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(att.URL, ".gif") || att.MIME != "image/gif" {
|
||||
t.Fatalf("gif should be kept as-is, got url=%s mime=%s", att.URL, att.MIME)
|
||||
}
|
||||
data := storedUploadPath(t, dir, att.URL)
|
||||
magic := string(data[:6])
|
||||
if magic != "GIF89a" && magic != "GIF87a" {
|
||||
t.Fatalf("stored gif magic invalid: %q", magic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveImageKeepsWebPAsIs(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
|
||||
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
|
||||
var in bytes.Buffer
|
||||
if err := webpenc.Encode(&in, src, webpenc.Options{Quality: ImageWebPQuality}); err != nil {
|
||||
t.Fatalf("encode webp: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
|
||||
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
|
||||
}
|
||||
if !bytes.Equal(storedUploadPath(t, dir, att.URL), in.Bytes()) {
|
||||
t.Fatal("webp input should be stored byte-for-byte")
|
||||
}
|
||||
if att.Size != in.Len() {
|
||||
t.Fatalf("size mismatch: att=%d input=%d", att.Size, in.Len())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user