Compare commits
21 Commits
fix/logout
...
v0.2.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5598e1cbb7 | |||
| 46e0cdc0b3 | |||
| 8cfdfcbbf0 | |||
| 11f2131e4b | |||
| 7912b67ddc | |||
| 99c0f0319d | |||
| 3e98052c16 | |||
| ff2ab286fb | |||
| 7fbd6ba699 | |||
| 4f3f3a265c | |||
| e487bc2e92 | |||
| 3e0689fe98 | |||
| 0c3b3a710c | |||
| 34aee91a13 | |||
| 2f96a36275 | |||
| 7d8c79f61f | |||
| 372bc29b41 | |||
| 023f17ed46 | |||
| 7a44c1f2cf | |||
| 1d862a0dd3 | |||
| 734afbc39f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ out/
|
||||
.vercel/
|
||||
*.tsbuildinfo
|
||||
next-debug.log*
|
||||
# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成)
|
||||
frontend/public/fonts/
|
||||
|
||||
# ===== Go 构建产物 =====
|
||||
*.exe
|
||||
|
||||
@@ -257,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
|
||||
|
||||
func TestParseSiteURLFromIni(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
|
||||
body := "[security]\nJWT_SECRET = " + testJWTSecret + "\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
|
||||
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
}
|
||||
|
||||
// AdminGetUserPerms 读取账号级渠道权限(超管/站长,授权弹窗回显用)
|
||||
func (h *Handlers) AdminGetUserPerms(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
perms, err := h.AdminUser.GetPermOverrides(middleware.CurrentActor(c), id)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"perm_overrides": perms})
|
||||
}
|
||||
|
||||
// AdminSetUserPerms 授予/撤销账号级渠道权限(超管/站长)
|
||||
func (h *Handlers) AdminSetUserPerms(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Perms []string `json:"perms"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
user, err := h.AdminUser.SetPermOverrides(middleware.CurrentActor(c), id, body.Perms)
|
||||
if err != nil {
|
||||
respondAdminUserError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
}
|
||||
|
||||
// AdminSetUserBan 封禁 / 解封用户
|
||||
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
||||
id, ok := parseAdminUserID(c)
|
||||
|
||||
@@ -15,15 +15,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
|
||||
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
|
||||
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天
|
||||
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天
|
||||
)
|
||||
|
||||
// setAuthCookies 设置认证 cookie:
|
||||
// - access token(HttpOnly,15min)
|
||||
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
|
||||
// - access token(HttpOnly,7天)
|
||||
// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC
|
||||
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
|
||||
// - CSRF token(JS 可读,7天,双提交校验)
|
||||
// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验)
|
||||
//
|
||||
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
|
||||
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
|
||||
@@ -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,
|
||||
@@ -241,13 +242,21 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
||||
}
|
||||
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
if errors.Is(err, service.ErrAccountBanned) {
|
||||
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
|
||||
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
|
||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
return
|
||||
}
|
||||
// 其他错误(数据库抖动等):服务暂不可用。保留 cookie 等下次刷新,
|
||||
// 前端按瞬态失败处理(不弹"登录已失效"、不强制重登)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
|
||||
return
|
||||
}
|
||||
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
||||
@@ -264,7 +273,8 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
||||
|
||||
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
|
||||
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
|
||||
// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
|
||||
// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false,
|
||||
// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。
|
||||
func (h *Handlers) Logout(c *gin.Context) {
|
||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
||||
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
||||
@@ -309,7 +319,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
|
||||
}
|
||||
}
|
||||
@@ -372,8 +382,11 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin
|
||||
"role": user.Role,
|
||||
"board_ids": boardIDs,
|
||||
"can_manage_messages": user.CanManageMessages,
|
||||
"level": user.Level,
|
||||
"total_points": user.TotalPoints,
|
||||
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
|
||||
// 最终权限以后端 RequirePerm 校验为准
|
||||
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
|
||||
"level": user.Level,
|
||||
"total_points": user.TotalPoints,
|
||||
}
|
||||
if badges != nil {
|
||||
body["badges"] = badges
|
||||
|
||||
@@ -51,11 +51,11 @@ func (h *Handlers) BoardSidebar(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"board": data.Board,
|
||||
"moderators": data.Moderators,
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"checkin": data.Checkin,
|
||||
"board": data.Board,
|
||||
"moderators": data.Moderators,
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"checkin": data.Checkin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -42,6 +43,26 @@ func chatRoomID(c *gin.Context) (uint, bool) {
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用
|
||||
func formatMuteDurationCN(minutes int) string {
|
||||
switch {
|
||||
case minutes >= 30*24*60:
|
||||
return "30 天"
|
||||
case minutes >= 7*24*60:
|
||||
return "7 天"
|
||||
case minutes >= 3*24*60:
|
||||
return "3 天"
|
||||
case minutes >= 24*60:
|
||||
return "1 天"
|
||||
case minutes >= 12*60:
|
||||
return "12 小时"
|
||||
case minutes >= 60:
|
||||
return "1 小时"
|
||||
default:
|
||||
return strconv.Itoa(minutes) + " 分钟"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) chatOversee(userID uint) bool {
|
||||
actor, err := h.Auth.LoadActor(userID)
|
||||
if err != nil || actor == nil {
|
||||
@@ -322,7 +343,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// SetChatMemberMute 禁言/解禁成员
|
||||
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
|
||||
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
roomID, ok := chatRoomID(c)
|
||||
@@ -335,17 +356,58 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Muted bool `json:"muted"`
|
||||
Muted bool `json:"muted"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil {
|
||||
duration := time.Duration(body.DurationMinutes) * time.Minute
|
||||
targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID))
|
||||
if err != nil {
|
||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted})
|
||||
// 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表
|
||||
var mutedUntil *time.Time
|
||||
if body.Muted && body.DurationMinutes > 0 {
|
||||
t := time.Now().Add(duration)
|
||||
mutedUntil = &t
|
||||
}
|
||||
// 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等)
|
||||
var sysContent string
|
||||
if body.Muted {
|
||||
if body.DurationMinutes > 0 {
|
||||
sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes)
|
||||
} else {
|
||||
sysContent = targetName + " 已被禁言 永久"
|
||||
}
|
||||
} else {
|
||||
sysContent = targetName + " 的禁言已被解除"
|
||||
}
|
||||
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
|
||||
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染);
|
||||
// Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏
|
||||
if sysMsg != nil && sysMsg.ID > 0 {
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||
Type: realtime.EventChatMessage,
|
||||
Data: sysMsg,
|
||||
})
|
||||
}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||
Type: realtime.EventChatMemberMuted,
|
||||
Data: gin.H{
|
||||
"room_id": roomID,
|
||||
"user_id": uint(targetID),
|
||||
"muted": body.Muted,
|
||||
"muted_until": mutedUntil,
|
||||
"duration_minutes": body.DurationMinutes,
|
||||
"target_name": targetName,
|
||||
"operator_id": claims.ID,
|
||||
},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes})
|
||||
}
|
||||
|
||||
// SetChatMemberRole 站长任命/撤销群管理员
|
||||
@@ -619,7 +681,9 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, ne
|
||||
}
|
||||
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
||||
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
|
||||
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览:
|
||||
// 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage
|
||||
// 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。
|
||||
if h.Chat == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,38 +82,6 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
|
||||
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
|
||||
func (h *Handlers) CommentLocation(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||||
return
|
||||
}
|
||||
// 待审/被拒帖子的评论不对公众开放
|
||||
var viewerID uint
|
||||
var loadActor func() *service.Actor
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
viewerID = claims.ID
|
||||
loadActor = h.actorLoader(claims.ID)
|
||||
}
|
||||
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
floor := h.Comment.FloorNumber(uint(id), uint(cid))
|
||||
if floor <= 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"floor": floor})
|
||||
}
|
||||
|
||||
// CreateCommentRequest 评论请求
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||
|
||||
208
backend/handler/friendlink.go
Normal file
208
backend/handler/friendlink.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// currentUserID 从 gin 上下文取登录用户 ID;RequireAuth 已保证存在
|
||||
func currentUserID(c *gin.Context) (uint, bool) {
|
||||
v, ok := c.Get("user")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
claims, ok := v.(*service.UserClaims)
|
||||
if !ok || claims.ID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return claims.ID, true
|
||||
}
|
||||
|
||||
// GET /api/friend-links 公开列表(仅 approved)
|
||||
func (h *Handlers) GetFriendLinks(c *gin.Context) {
|
||||
links, err := h.FriendLink.ListApproved()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"links": links})
|
||||
}
|
||||
|
||||
// POST /api/friend-links/apply 登录用户提交申请
|
||||
func (h *Handlers) ApplyFriendLink(c *gin.Context) {
|
||||
uid, ok := currentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
var in service.FriendLinkInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
fl, err := h.FriendLink.Apply(in, uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "提交申请失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"link": fl})
|
||||
}
|
||||
|
||||
// GET /api/friend-links/mine 当前登录用户自己的申请记录(含待审/拒绝与拒绝理由)
|
||||
func (h *Handlers) MyFriendLinks(c *gin.Context) {
|
||||
uid, ok := currentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
links, err := h.FriendLink.ListMine(uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取申请记录失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"links": links})
|
||||
}
|
||||
|
||||
// PUT /api/friend-links/:id 申请人修改自己的申请;已通过/已驳回的修改后回退待审
|
||||
func (h *Handlers) OwnerUpdateFriendLink(c *gin.Context) {
|
||||
uid, ok := currentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var in service.FriendLinkInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.FriendLink.OwnerUpdate(uint(id), uid, in); err != nil {
|
||||
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存修改失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/admin/friend-links 后台全量列表
|
||||
func (h *Handlers) AdminListFriendLinks(c *gin.Context) {
|
||||
links, err := h.FriendLink.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"links": links})
|
||||
}
|
||||
|
||||
// POST /api/admin/friend-links 站长直接新建(status=approved)
|
||||
func (h *Handlers) AdminCreateFriendLink(c *gin.Context) {
|
||||
var in service.FriendLinkInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
fl, err := h.FriendLink.AdminCreate(in)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建友情链接失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"link": fl})
|
||||
}
|
||||
|
||||
// PUT /api/admin/friend-links/:id 全量更新内容(不改 status)
|
||||
func (h *Handlers) AdminUpdateFriendLink(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var in service.FriendLinkInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.FriendLink.AdminUpdate(uint(id), in); err != nil {
|
||||
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新友情链接失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/admin/friend-links/:id/status 审批:body {status, reject_reason?}
|
||||
func (h *Handlers) AdminSetFriendLinkStatus(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.FriendLink.AdminSetStatus(uint(id), body.Status, body.RejectReason); err != nil {
|
||||
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrFriendLinkInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "审批参数无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新状态失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DELETE /api/admin/friend-links/:id 软删除
|
||||
func (h *Handlers) AdminDeleteFriendLink(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.FriendLink.AdminDelete(uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrFriendLinkNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除友情链接失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -10,36 +10,38 @@ import (
|
||||
|
||||
// Handlers 聚合所有服务引用
|
||||
type Handlers struct {
|
||||
Ops *service.Operations
|
||||
Cfg *config.Config
|
||||
Hub *realtime.Hub
|
||||
Auth *service.AuthService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Like *service.LikeService
|
||||
Favorite *service.FavoriteService
|
||||
Follow *service.FollowService
|
||||
Notification *service.NotificationService
|
||||
OverviewSvc *service.OverviewService
|
||||
Checkin *service.CheckinService
|
||||
Announcement *service.AnnouncementService
|
||||
SitePage *service.SitePageService
|
||||
Upload *service.UploadService
|
||||
PostFile *service.PostFileService
|
||||
Points *service.PointsService
|
||||
Setting *service.SettingService
|
||||
AdminUser *service.AdminUserService
|
||||
LegacyImport *service.LegacyImportService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Visit *service.VisitStatsService
|
||||
Analytics *service.AnalyticsService
|
||||
HidePwd *service.HidePasswordCookie
|
||||
Ads *service.AdService
|
||||
Sidebar *service.SidebarService
|
||||
Badge *service.BadgeService
|
||||
Ops *service.Operations
|
||||
Cfg *config.Config
|
||||
Hub *realtime.Hub
|
||||
Auth *service.AuthService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Like *service.LikeService
|
||||
Favorite *service.FavoriteService
|
||||
Follow *service.FollowService
|
||||
Notification *service.NotificationService
|
||||
OverviewSvc *service.OverviewService
|
||||
Checkin *service.CheckinService
|
||||
Announcement *service.AnnouncementService
|
||||
SitePage *service.SitePageService
|
||||
Upload *service.UploadService
|
||||
PostFile *service.PostFileService
|
||||
Points *service.PointsService
|
||||
Setting *service.SettingService
|
||||
AdminUser *service.AdminUserService
|
||||
LegacyImport *service.LegacyImportService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Visit *service.VisitStatsService
|
||||
Analytics *service.AnalyticsService
|
||||
HidePwd *service.HidePasswordCookie
|
||||
Ads *service.AdService
|
||||
Sidebar *service.SidebarService
|
||||
FriendLink *service.FriendLinkService
|
||||
Badge *service.BadgeService
|
||||
LeaderboardSvc *service.LeaderboardService
|
||||
LibrarySvc *service.LibraryService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
499
backend/handler/library.go
Normal file
499
backend/handler/library.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---------- 公开端 ----------
|
||||
|
||||
// LibraryList 已发布条目目录
|
||||
func (h *Handlers) LibraryList(c *gin.Context) {
|
||||
list, err := h.LibrarySvc.ListPublished()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// LibraryDetail 公开条目详情(按 slug,仅已发布)
|
||||
func (h *Handlers) LibraryDetail(c *gin.Context) {
|
||||
d, err := h.LibrarySvc.GetPublishedBySlug(c.Param("slug"))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取条目失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"doc": d})
|
||||
}
|
||||
|
||||
// LibraryFileDownload 强制下载(attachment;成功后计数)
|
||||
func (h *Handlers) LibraryFileDownload(c *gin.Context) {
|
||||
f, ok := h.libraryPublicFile(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.LibrarySvc.IncrDownload(f.ID)
|
||||
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(f.Name))
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Security-Policy", "sandbox")
|
||||
ct := f.MIME
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
c.Header("Content-Type", ct)
|
||||
c.File(h.LibrarySvc.FilePath(f))
|
||||
}
|
||||
|
||||
// libraryPublicFile 公开文件公共校验(存在 + 所属条目已发布)
|
||||
func (h *Handlers) libraryPublicFile(c *gin.Context) (*model.LibraryFile, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
|
||||
return nil, false
|
||||
}
|
||||
f, err := h.LibrarySvc.GetPublicFile(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return nil, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
// ---------- 管理端 ----------
|
||||
|
||||
// AdminListLibraryDocs 后台条目列表(含未发布)
|
||||
func (h *Handlers) AdminListLibraryDocs(c *gin.Context) {
|
||||
list, err := h.LibrarySvc.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)})
|
||||
}
|
||||
|
||||
// AdminCreateLibraryDoc 新建条目
|
||||
func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
|
||||
var in service.LibraryInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
var creatorID uint
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
creatorID = claims.ID
|
||||
}
|
||||
d, err := h.LibrarySvc.Create(&in, creatorID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"doc": d})
|
||||
}
|
||||
|
||||
// AdminUpdateLibraryDoc 编辑条目
|
||||
func (h *Handlers) AdminUpdateLibraryDoc(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
var in service.LibraryInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
d, err := h.LibrarySvc.Update(uint(id), &in)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"doc": d})
|
||||
}
|
||||
|
||||
// AdminDeleteLibraryDoc 删除条目(软删)
|
||||
func (h *Handlers) AdminDeleteLibraryDoc(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.LibrarySvc.Delete(uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminPurgeLibraryDoc 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件)
|
||||
func (h *Handlers) AdminPurgeLibraryDoc(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.LibrarySvc.Purge(uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrLibraryNotDeleted) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminUploadLibraryFile 上传文件到条目
|
||||
func (h *Handlers) AdminUploadLibraryFile(c *gin.Context) {
|
||||
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
maxBytes, err := h.Setting.AttachmentMaxBytes()
|
||||
if err != nil || maxBytes < 1 {
|
||||
maxBytes = service.FileMaxBytes
|
||||
}
|
||||
overhead := int64(64 << 10) // multipart 边界开销
|
||||
limit := maxBytes + overhead
|
||||
if c.Request.ContentLength > limit {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件过大"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxBytes {
|
||||
mb := int(maxBytes >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件不能超过 " + strconv.Itoa(mb) + "MB"})
|
||||
return
|
||||
}
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
f, err := h.LibrarySvc.AddFile(uint(docID), claims.ID, file.Filename, src)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryExtDenied) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"file": f})
|
||||
}
|
||||
|
||||
// AdminDeleteLibraryFile 删除条目文件
|
||||
func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("fid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.LibrarySvc.DeleteFile(uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ---------- 管理端:导出(迁移 / 备份) ----------
|
||||
|
||||
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
|
||||
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
|
||||
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Status(http.StatusOK)
|
||||
if err := exp.WriteZip(c.Writer); err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
|
||||
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||||
return
|
||||
}
|
||||
h.writeLibraryExport(c, exp)
|
||||
}
|
||||
|
||||
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
|
||||
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
|
||||
exp, err := h.LibrarySvc.BuildAllExport()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||||
return
|
||||
}
|
||||
h.writeLibraryExport(c, exp)
|
||||
}
|
||||
|
||||
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
|
||||
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
const overhead = 64 << 10 // multipart 边界开销
|
||||
limit := int64(service.LibraryImportMaxBytes) + overhead
|
||||
if c.Request.ContentLength > limit {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
|
||||
return
|
||||
}
|
||||
if fh.Size > service.LibraryImportMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := c.PostForm("mode")
|
||||
if mode == "" {
|
||||
mode = service.LibraryImportModeCreate
|
||||
}
|
||||
|
||||
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrLibraryImportBadZip),
|
||||
errors.Is(err, service.ErrLibraryImportFormat),
|
||||
errors.Is(err, service.ErrLibraryImportVer),
|
||||
errors.Is(err, service.ErrLibraryImportEmpty),
|
||||
errors.Is(err, service.ErrLibraryImportMode):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||||
}
|
||||
|
||||
// ---------- 管理端:章节 ----------
|
||||
|
||||
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)
|
||||
func (h *Handlers) AdminCreateLibrarySection(c *gin.Context) {
|
||||
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
var in service.SectionInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
sec, err := h.LibrarySvc.CreateSection(uint(docID), &in)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"section": sec})
|
||||
}
|
||||
|
||||
// AdminUpdateLibrarySection 编辑章节标题与正文
|
||||
func (h *Handlers) AdminUpdateLibrarySection(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
|
||||
return
|
||||
}
|
||||
var in service.SectionInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
sec, err := h.LibrarySvc.UpdateSection(uint(id), &in)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrSectionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"section": sec})
|
||||
}
|
||||
|
||||
// AdminDeleteLibrarySection 删除章节(章级联删除其下小节)
|
||||
func (h *Handlers) AdminDeleteLibrarySection(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.LibrarySvc.DeleteSection(uint(id)); err != nil {
|
||||
if errors.Is(err, service.ErrSectionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminMoveLibrarySection 章节上移/下移(同父兄弟间交换顺序)
|
||||
func (h *Handlers) AdminMoveLibrarySection(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("sid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.LibrarySvc.MoveSection(uint(id), in.Direction); err != nil {
|
||||
if errors.Is(err, service.ErrSectionNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminImportLibraryMarkdown 导入 md 并按标题拆章
|
||||
func (h *Handlers) AdminImportLibraryMarkdown(c *gin.Context) {
|
||||
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
SplitLevel string `json:"split_level"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
sections, err := h.LibrarySvc.ImportMarkdown(uint(docID), in.Filename, in.Content, in.SplitLevel)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
|
||||
}
|
||||
|
||||
// AdminImportLibraryMarkdownBatch 批量导入 md:多个文件按传入顺序依次拆章
|
||||
func (h *Handlers) AdminImportLibraryMarkdownBatch(c *gin.Context) {
|
||||
docID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Files []service.ImportFileInput `json:"files"`
|
||||
SplitLevel string `json:"split_level"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
sections, err := h.LibrarySvc.ImportMarkdownBatch(uint(docID), in.Files, in.SplitLevel)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)})
|
||||
}
|
||||
@@ -5,9 +5,18 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// notificationView 通知列表项:附带关联评论的楼层相对锚点,
|
||||
// 供前端拼 #comment-{floor}(主楼)或 #comment-{floor}-r{comment_id}(楼中楼)。
|
||||
type notificationView struct {
|
||||
*model.Notification
|
||||
CommentFloor uint `json:"comment_floor"`
|
||||
CommentIsRoot bool `json:"comment_is_root"`
|
||||
}
|
||||
|
||||
// Notifications 获取当前用户的通知列表(分页)
|
||||
func (h *Handlers) Notifications(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
@@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
commentIDs := make([]uint, 0, len(list))
|
||||
for _, n := range list {
|
||||
if n.CommentID > 0 {
|
||||
commentIDs = append(commentIDs, n.CommentID)
|
||||
}
|
||||
}
|
||||
anchors := h.Comment.AnchorsByCommentIDs(commentIDs)
|
||||
views := make([]notificationView, 0, len(list))
|
||||
for i := range list {
|
||||
v := notificationView{Notification: &list[i]}
|
||||
if a, ok := anchors[list[i].CommentID]; ok {
|
||||
v.CommentFloor = a.Floor
|
||||
v.CommentIsRoot = a.IsRoot
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": nonNilSlice(list),
|
||||
"notifications": nonNilSlice(views),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
|
||||
@@ -245,8 +245,12 @@ func (h *Handlers) SiteState(c *gin.Context) {
|
||||
c.JSON(503, gin.H{"error": "状态暂不可用"})
|
||||
return
|
||||
}
|
||||
urlStyle, e := h.Setting.UrlStyle()
|
||||
if e != nil {
|
||||
urlStyle = service.DefaultUrlStyle
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL})
|
||||
c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL, "url_style": urlStyle})
|
||||
}
|
||||
func (h *Handlers) SendEmailCode(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -258,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
||||
if errors.Is(e, service.ErrEmailRegistered) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
if !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
@@ -309,7 +317,8 @@ func (h *Handlers) PublicObject(c *gin.Context) {
|
||||
defer r.Close()
|
||||
c.Header("Content-Type", m)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
// 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用)
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Status(200)
|
||||
_, _ = io.Copy(c.Writer, r)
|
||||
}
|
||||
|
||||
@@ -29,20 +29,20 @@ func (h *Handlers) Overview(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"boards": data.Boards,
|
||||
"announcements": data.Announcements,
|
||||
"announcements_total": data.AnnouncementsTotal,
|
||||
"sidebar_pages": data.SidebarPages,
|
||||
"new_users": data.NewUsers,
|
||||
"checkin": data.Checkin,
|
||||
"ads": data.Ads,
|
||||
"ads_panel_title": data.AdsPanelTitle,
|
||||
"ads_enabled": data.AdsEnabled,
|
||||
"sponsors": data.Sponsors,
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"boards": data.Boards,
|
||||
"announcements": data.Announcements,
|
||||
"announcements_total": data.AnnouncementsTotal,
|
||||
"sidebar_pages": data.SidebarPages,
|
||||
"new_users": data.NewUsers,
|
||||
"checkin": data.Checkin,
|
||||
"ads": data.Ads,
|
||||
"ads_panel_title": data.AdsPanelTitle,
|
||||
"ads_enabled": data.AdsEnabled,
|
||||
"sponsors": data.Sponsors,
|
||||
"sponsors_panel_title": data.SponsorsPanelTitle,
|
||||
"sponsors_enabled": data.SponsorsEnabled,
|
||||
"sponsors_enabled": data.SponsorsEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
@@ -16,13 +17,20 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// maxSearchKeywordRunes 帖子搜索关键词长度上限(字符数,与前端搜索框 maxLength 一致)
|
||||
const maxSearchKeywordRunes = 100
|
||||
|
||||
// Posts 获取帖子列表
|
||||
func (h *Handlers) Posts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||
sort := c.DefaultQuery("sort", "latest")
|
||||
keyword := c.Query("keyword")
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
// 搜索词上限:前端输入框同限;超长词 ILIKE 必然无意义结果,静默截断
|
||||
if runes := []rune(keyword); len(runes) > maxSearchKeywordRunes {
|
||||
keyword = string(runes[:maxSearchKeywordRunes])
|
||||
}
|
||||
recommended := c.Query("recommended") == "true"
|
||||
|
||||
var viewerID uint
|
||||
|
||||
@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
|
||||
BrandMark *string `json:"brand_mark"`
|
||||
BrandLogoSize *string `json:"brand_logo_size"`
|
||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||
BrandSloganVisible *bool `json:"brand_slogan_visible"`
|
||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||
|
||||
PointsCheckinBase *int `json:"points_checkin_base"`
|
||||
@@ -73,8 +74,14 @@ type updateSettingsRequest struct {
|
||||
NecroReplyAfterHours *int `json:"necro_reply_after_hours"`
|
||||
NecroReplyPenalty *int `json:"necro_reply_penalty"`
|
||||
|
||||
Levels *[]model.LevelDef `json:"levels"`
|
||||
LevelsFx *bool `json:"levels_fx"`
|
||||
Levels *[]model.LevelDef `json:"levels"`
|
||||
LevelsFx *bool `json:"levels_fx"`
|
||||
|
||||
UrlStyle *string `json:"url_style"`
|
||||
|
||||
MobileTabs *[]string `json:"mobile_tabs"`
|
||||
|
||||
HeaderNav *[]service.HeaderNavItem `json:"header_nav"`
|
||||
}
|
||||
|
||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
@@ -85,9 +92,9 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"site_name": saved.SiteName,
|
||||
"site_description": saved.SiteDescription,
|
||||
"allow_register": saved.AllowRegister,
|
||||
"allow_comments": saved.AllowComments,
|
||||
"comments_require_login": saved.CommentsRequireLogin,
|
||||
"allow_messages": saved.AllowMessages,
|
||||
"allow_comments": saved.AllowComments,
|
||||
"comments_require_login": saved.CommentsRequireLogin,
|
||||
"allow_messages": saved.AllowMessages,
|
||||
"post_cooldown_hours": saved.PostCooldownHours,
|
||||
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
||||
"code_block_fold_lines": saved.CodeBlockFoldLines,
|
||||
@@ -114,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"brand_mark": saved.BrandMark,
|
||||
"brand_logo_size": saved.BrandLogoSize,
|
||||
"brand_logo_fit": saved.BrandLogoFit,
|
||||
"brand_slogan_visible": saved.BrandSloganVisible,
|
||||
"footer_links": saved.FooterLinks,
|
||||
|
||||
"points_checkin_base": saved.PointsCheckinBase,
|
||||
@@ -125,13 +133,19 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
"points_reply_daily_cap": saved.PointsReplyDailyCap,
|
||||
"points_recommend_reward": saved.PointsRecommendReward,
|
||||
|
||||
"post_edit_lock_hours": saved.PostEditLockHours,
|
||||
"comment_edit_lock_hours": saved.CommentEditLockHours,
|
||||
"necro_reply_after_hours": saved.NecroReplyAfterHours,
|
||||
"necro_reply_penalty": saved.NecroReplyPenalty,
|
||||
"post_edit_lock_hours": saved.PostEditLockHours,
|
||||
"comment_edit_lock_hours": saved.CommentEditLockHours,
|
||||
"necro_reply_after_hours": saved.NecroReplyAfterHours,
|
||||
"necro_reply_penalty": saved.NecroReplyPenalty,
|
||||
|
||||
"levels": saved.Levels,
|
||||
"levels": saved.Levels,
|
||||
"levels_fx": saved.LevelsFx,
|
||||
|
||||
"url_style": saved.UrlStyle,
|
||||
|
||||
"mobile_tabs": saved.MobileTabs,
|
||||
|
||||
"header_nav": saved.HeaderNav,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,13 +162,15 @@ func (req *updateSettingsRequest) hasAny() bool {
|
||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||
req.BrandSloganVisible != nil ||
|
||||
req.FooterLinks != nil ||
|
||||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
||||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
||||
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
|
||||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
|
||||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
|
||||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil
|
||||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
|
||||
req.UrlStyle != nil || req.MobileTabs != nil || req.HeaderNav != nil
|
||||
}
|
||||
|
||||
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||
@@ -527,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.BrandSloganVisible != nil {
|
||||
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.FooterLinks != nil {
|
||||
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
@@ -537,6 +559,36 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.UrlStyle != nil {
|
||||
if err := h.Setting.SetUrlStyle(*req.UrlStyle); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "链接风格无效"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.MobileTabs != nil {
|
||||
if err := h.Setting.SetMobileTabs(*req.MobileTabs); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "移动端底栏配置无效(至少保留一项,且取值须在允许列表内)"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.HeaderNav != nil {
|
||||
if err := h.Setting.SetHeaderNav(*req.HeaderNav); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "顶部导航配置无效(最多 8 项,内置项须在允许列表内,自定义项名称 1–16 字、地址须为站内路径或 http(s))"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saved, err := h.Setting.Public()
|
||||
if err != nil {
|
||||
|
||||
@@ -101,3 +101,25 @@ func (h *Handlers) AdminDeleteSitePage(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminPurgeSitePage 彻底删除单页(仅限已软删)
|
||||
func (h *Handlers) AdminPurgeSitePage(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.SitePage.Purge(uint(id)); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrSitePageNotDeleted) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -27,3 +27,20 @@ func (h *Handlers) TimelineFromGit(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// TimelineFromReleases 从 Git releases 页 URL 导入时间线条目(登录 + CSRF + 限流)
|
||||
func (h *Handlers) TimelineFromReleases(c *gin.Context) {
|
||||
var req struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
result, err := h.Setting.ImportTimelineFromReleases(req.URLs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package handler
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -49,7 +53,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)
|
||||
|
||||
@@ -99,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
att, err := h.Upload.SaveImage(claims.ID, f)
|
||||
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
@@ -311,6 +315,122 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
|
||||
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
|
||||
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
|
||||
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
|
||||
var req struct {
|
||||
AttachmentID uint `json:"attachment_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
|
||||
return
|
||||
}
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
|
||||
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
|
||||
}
|
||||
|
||||
// brandAliasSlots 固定地址路径段 → 品牌槽位
|
||||
var brandAliasSlots = map[string]string{
|
||||
"logo-light": service.BrandSlotLight,
|
||||
"logo-dark": service.BrandSlotDark,
|
||||
"favicon": service.BrandSlotFavicon,
|
||||
}
|
||||
|
||||
// BrandImage 品牌图固定对外地址(GET /api/brand/logo-light|logo-dark|favicon):
|
||||
// 友链等外部引用此地址,站点换图后按设置解析到当前图,地址永不变。
|
||||
func (h *Handlers) BrandImage(c *gin.Context) {
|
||||
slot, ok := brandAliasSlots[c.Param("slot")]
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var url string
|
||||
var err error
|
||||
switch slot {
|
||||
case service.BrandSlotLight:
|
||||
url, err = h.Setting.LogoLightURL()
|
||||
case service.BrandSlotDark:
|
||||
url, err = h.Setting.LogoDarkURL()
|
||||
default:
|
||||
url, err = h.Setting.FaviconURL()
|
||||
}
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if url == "" {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
f, err := h.Upload.OpenBrandFile(url)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
// 扩展名定 Content-Type(SVG 不能靠嗅探);短缓存让换图在友链侧尽快生效
|
||||
if ct := mime.TypeByExtension(path.Ext(url)); ct != "" {
|
||||
c.Header("Content-Type", ct)
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
http.ServeContent(c.Writer, c.Request, path.Base(url), time.Time{}, f)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
|
||||
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
|
||||
func (h *Handlers) ImageVariant(c *gin.Context) {
|
||||
w, err := strconv.Atoi(c.Query("w"))
|
||||
if err != nil || !service.IsVariantWidth(w) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
|
||||
return
|
||||
}
|
||||
data, m, err := h.Upload.Variant(c.Query("u"), w)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Data(http.StatusOK, m, data)
|
||||
}
|
||||
|
||||
func brandTooLarge(slot string) string {
|
||||
if slot == service.BrandSlotFavicon {
|
||||
return "Favicon 不能超过 512KB"
|
||||
|
||||
@@ -130,9 +130,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
}
|
||||
// 访客与该用户的关注关系(关注按钮初始态)
|
||||
userPayload["is_following"] = isFollowing
|
||||
// 邮箱仅本人可见,打码展示(j***@163.com)
|
||||
// 邮箱仅本人可见;仅本人访问时下发明文(用于账号信息展示与资料表单回填)
|
||||
if viewerID == user.ID && user.Email != "" {
|
||||
userPayload["email"] = maskEmail(user.Email)
|
||||
userPayload["email"] = user.Email
|
||||
}
|
||||
// 徽章墙(失败不阻塞资料返回)
|
||||
if h.Badge != nil {
|
||||
@@ -144,11 +144,11 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": userPayload,
|
||||
"stats": gin.H{
|
||||
"post_count": postCount,
|
||||
"comment_count": commentCount,
|
||||
"points": pointsTotal,
|
||||
"streak": streak,
|
||||
"favorite_count": favoriteCount,
|
||||
"post_count": postCount,
|
||||
"comment_count": commentCount,
|
||||
"points": pointsTotal,
|
||||
"streak": streak,
|
||||
"favorite_count": favoriteCount,
|
||||
"following_count": followingCount,
|
||||
},
|
||||
"posts": nonNilSlice(posts),
|
||||
@@ -158,21 +158,6 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com)
|
||||
func maskEmail(email string) string {
|
||||
at := -1
|
||||
for i, r := range email {
|
||||
if r == '@' {
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if at <= 0 {
|
||||
return "***"
|
||||
}
|
||||
return email[:1] + "***" + email[at:]
|
||||
}
|
||||
|
||||
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
|
||||
func (h *Handlers) UserComments(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -85,7 +85,32 @@ func (m *AuthMiddleware) RequireStaff() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePerm 功能点鉴权,必须接在 RequireStaff 之后
|
||||
// RequireActor 必须登录并加载实时 Actor(不要求管理角色),
|
||||
// 供账号级渠道权限(非管理角色也可被单独授予)所在路由组使用;
|
||||
// 功能点鉴权由后续 RequirePerm 完成
|
||||
func (m *AuthMiddleware) RequireActor() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, ok := m.parseToken(c)
|
||||
if !ok {
|
||||
if c.GetBool(AccountBannedKey) {
|
||||
bannedJSON(c)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
actor, err := m.auth.LoadActor(claims.ID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权限执行该操作"})
|
||||
return
|
||||
}
|
||||
c.Set("user", claims)
|
||||
c.Set(ActorKey, actor)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePerm 功能点鉴权,必须接在 RequireStaff / RequireActor 之后
|
||||
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
actor := CurrentActor(c)
|
||||
|
||||
19
backend/middleware/upload_cache.go
Normal file
19
backend/middleware/upload_cache.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UploadStaticCache 上传文件长缓存:/uploads 下文件名均为上传时随机生成、
|
||||
// 写入后不覆盖(SaveAvatar/SaveImage 等均新建文件),URL 内容恒定,可安全
|
||||
// immutable。PageSpeed「缓存生命周期」修复项。
|
||||
func UploadStaticCache() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -19,26 +19,26 @@ const (
|
||||
|
||||
// Ad 赞助广告(游客可申购,管理员审核后展示于首页侧栏)
|
||||
type Ad struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
|
||||
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
|
||||
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
|
||||
LinkURL string `gorm:"size:512;not null" json:"link_url"`
|
||||
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
|
||||
Title string `gorm:"size:64" json:"title"` // 文字广告
|
||||
TextColor string `gorm:"size:16" json:"text_color"`
|
||||
BgColor string `gorm:"size:16" json:"bg_color"`
|
||||
DurationDays int `gorm:"not null" json:"duration_days"`
|
||||
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
|
||||
BuyerNote string `gorm:"size:200" json:"buyer_note"`
|
||||
StartsAt *time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at"`
|
||||
RejectReason string `gorm:"size:200" json:"reject_reason"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
SubmitIP string `gorm:"size:45" json:"-"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
ReviewedBy *uint `json:"reviewed_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
|
||||
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
|
||||
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
|
||||
LinkURL string `gorm:"size:512;not null" json:"link_url"`
|
||||
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
|
||||
Title string `gorm:"size:64" json:"title"` // 文字广告
|
||||
TextColor string `gorm:"size:16" json:"text_color"`
|
||||
BgColor string `gorm:"size:16" json:"bg_color"`
|
||||
DurationDays int `gorm:"not null" json:"duration_days"`
|
||||
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
|
||||
BuyerNote string `gorm:"size:200" json:"buyer_note"`
|
||||
StartsAt *time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at"`
|
||||
RejectReason string `gorm:"size:200" json:"reject_reason"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
SubmitIP string `gorm:"size:45" json:"-"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
ReviewedBy *uint `json:"reviewed_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
42
backend/model/board_seed_test.go
Normal file
42
backend/model/board_seed_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 默认板块只在空库播种:站长改名/删除板块后重启不得复活默认板块(0.2.1 事故回归)
|
||||
func TestSeedDefaultBoardsOnlyWhenEmpty(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(&Board{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// 空库首启:播种 4 个默认板块
|
||||
seedDefaultBoards(db)
|
||||
var n int64
|
||||
db.Model(&Board{}).Count(&n)
|
||||
if n != 4 {
|
||||
t.Fatalf("空库应播种 4 个默认板块,实际 %d", n)
|
||||
}
|
||||
|
||||
// 站长改名后重启:不再复活默认板块
|
||||
if err := db.Model(&Board{}).Where("name = ?", "综合讨论").Update("name", "新综合").Error; err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
seedDefaultBoards(db)
|
||||
db.Model(&Board{}).Count(&n)
|
||||
if n != 4 {
|
||||
t.Fatalf("改名后重启不应重复播种,实际 %d 个板块", n)
|
||||
}
|
||||
var revived int64
|
||||
db.Model(&Board{}).Where("name = ?", "综合讨论").Count(&revived)
|
||||
if revived != 0 {
|
||||
t.Fatal("默认板块「综合讨论」被复活")
|
||||
}
|
||||
}
|
||||
@@ -56,14 +56,14 @@ func InitDB(dsn string) error {
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
|
||||
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&Badge{}, &UserBadge{},
|
||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
||||
&ImportRecord{},
|
||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
|
||||
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
@@ -78,6 +78,9 @@ func InitDB(dsn string) error {
|
||||
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
||||
backfillUserTotalPoints(db)
|
||||
|
||||
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
|
||||
backfillLibraryCreator(db)
|
||||
|
||||
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
||||
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
||||
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
||||
@@ -146,6 +149,7 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
|
||||
pairs := []struct{ table, constraint string }{
|
||||
{"chat_rooms", "fk_chat_rooms_last_message"},
|
||||
{"notifications", "fk_notifications_room"},
|
||||
{"chat_messages", "fk_chat_messages_sender"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
|
||||
@@ -368,6 +372,29 @@ func backfillUserTotalPoints(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入)
|
||||
// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行;
|
||||
// 站长不存在(空库)时跳过,等下次启动自愈。
|
||||
func backfillLibraryCreator(db *gorm.DB) {
|
||||
var owner User
|
||||
err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[model] 回填书库创建者前置查询失败: %v", err)
|
||||
return
|
||||
}
|
||||
res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID)
|
||||
if res.Error != nil {
|
||||
log.Printf("[model] 回填书库创建者失败: %v", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("[model] 已回填书库条目创建者为站长 %d 行", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
|
||||
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
||||
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
||||
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
||||
@@ -382,20 +409,23 @@ func backfillBoardDefaults(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultBoards 写入默认板块
|
||||
// seedDefaultBoards 仅在板块表为空(全新站点首启)时写入默认板块。
|
||||
// 曾按「名字是否存在」补种:站长改名板块后每次重启都会复活一套默认板块
|
||||
// (真实事故:0.2.1 重启后默认 4 板块重现)。表里已有任何未删除板块即视为已初始化。
|
||||
func seedDefaultBoards(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&Board{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
defaults := []Board{
|
||||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
}
|
||||
for _, b := range defaults {
|
||||
var count int64
|
||||
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
|
||||
if count == 0 {
|
||||
_ = db.Create(&b).Error
|
||||
}
|
||||
for i := range defaults {
|
||||
_ = db.Create(&defaults[i]).Error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ type User struct {
|
||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||
Banned bool `gorm:"default:false" json:"banned"`
|
||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
|
||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
||||
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
||||
@@ -323,9 +324,9 @@ const (
|
||||
PointReasonBountyEscrow = "bounty_escrow"
|
||||
PointReasonBountyRefund = "bounty_refund"
|
||||
PointReasonBountyAward = "bounty_award"
|
||||
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
|
||||
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
|
||||
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
|
||||
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
|
||||
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
|
||||
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
|
||||
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
|
||||
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
|
||||
PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭)
|
||||
@@ -361,12 +362,13 @@ type PostContentUnlock struct {
|
||||
|
||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||
type PostAttachment struct {
|
||||
ObjectID string `gorm:"size:64;index" json:"-"`
|
||||
ObjectID string `gorm:"size:96;index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
|
||||
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||
Size int `gorm:"not null;default:0" json:"size"`
|
||||
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
||||
@@ -486,7 +488,7 @@ type Notification struct {
|
||||
IsRead bool `gorm:"default:false;index" json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
||||
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
||||
// PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键
|
||||
Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"`
|
||||
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
|
||||
@@ -522,6 +524,58 @@ type SitePage struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// LibraryDoc 书库条目(书籍/文档/纯 HTML 页展示;文件独立目录存储,仅经 API 消费)
|
||||
type LibraryDoc struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"`
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
|
||||
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
|
||||
CoverWidth int `gorm:"not null;default:0" json:"cover_width"` // 封面自然宽(0=未知,前端回退探测)
|
||||
CoverHeight int `gorm:"not null;default:0" json:"cover_height"` // 封面自然高(0=未知,前端回退探测)
|
||||
Published bool `gorm:"not null;index" json:"published"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
|
||||
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
|
||||
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
|
||||
// Author 书籍作者(自由文本,可填多位,如「鲁迅 译 / 某某注」),仅展示用
|
||||
Author string `gorm:"size:100;not null;default:''" json:"author"`
|
||||
// CreatorID 条目创建者(站点用户;0=历史数据或导入时无对应用户),创建后不变
|
||||
CreatorID uint `gorm:"index;not null;default:0" json:"creator_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// LibraryFile 书库文件(html/pdf/epub 等;存 data/library,不进公开静态目录)
|
||||
type LibraryFile struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"` // 上传管理员
|
||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
||||
Ext string `gorm:"size:16;not null;default:''" json:"ext"`
|
||||
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||
Size int `gorm:"not null;default:0" json:"size"`
|
||||
DownloadCount int `gorm:"not null;default:0" json:"download_count"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// LibrarySection 书库章节(两级树:ParentID 空=章,非空=节;正文 markdown)
|
||||
type LibrarySection struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
DocID uint `gorm:"index;not null;default:0" json:"doc_id"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id"` // nil=章;非空=节,父必须是同条目的章
|
||||
Title string `gorm:"size:200;not null" json:"title"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||
type SiteSetting struct {
|
||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||
@@ -542,6 +596,24 @@ type SidebarWidget struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FriendLink 友情链接;申请审批制,status=pending 经管理员 approve 后前台可见
|
||||
type FriendLink struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
LogoURL string `gorm:"size:512" json:"logo_url"`
|
||||
Category string `gorm:"size:32;index" json:"category"`
|
||||
ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对
|
||||
SortOrder int `gorm:"not null;default:0;index" json:"sort_order"`
|
||||
Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected
|
||||
ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil
|
||||
RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
||||
type SiteDailyStats struct {
|
||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||
@@ -588,12 +660,21 @@ const (
|
||||
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
||||
)
|
||||
|
||||
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
||||
// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用
|
||||
const (
|
||||
AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认)
|
||||
AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用)
|
||||
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
||||
)
|
||||
|
||||
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
||||
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
||||
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
||||
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
||||
Width int `gorm:"not null;default:0" json:"width"`
|
||||
@@ -601,6 +682,20 @@ type Attachment struct {
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
|
||||
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
|
||||
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
|
||||
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
|
||||
type Blob struct {
|
||||
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
|
||||
Public bool `gorm:"primaryKey" json:"-"`
|
||||
RefCount int64 `gorm:"not null;default:0" json:"-"`
|
||||
Size int `gorm:"not null;default:0" json:"-"`
|
||||
MIME string `gorm:"size:128;not null;default:''" json:"-"`
|
||||
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
|
||||
CreatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
|
||||
type UserBoard struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -672,6 +767,7 @@ type ChatRoomMember struct {
|
||||
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
||||
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
||||
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
||||
MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久
|
||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -683,17 +779,18 @@ type ChatRoomMember struct {
|
||||
type ChatMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
|
||||
SenderID uint `gorm:"index;not null" json:"sender_id"`
|
||||
SenderID uint `gorm:"index" json:"sender_id"` // 0 表示系统消息(is_system=true)
|
||||
Content string `gorm:"type:varchar(2000);not null" json:"content"`
|
||||
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
|
||||
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
||||
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
||||
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
||||
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
|
||||
IsSystem bool `gorm:"not null;default:false" json:"is_system"` // 系统提示(如禁言通知),sender_id=0
|
||||
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
|
||||
Sender User `gorm:"foreignKey:SenderID;constraint:-" json:"sender,omitempty"` // constraint:- 禁用 FK,支持 sender_id=0 的系统消息
|
||||
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
||||
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ type EmailChallenge struct {
|
||||
|
||||
// Every remote object pins the immutable configuration revision that wrote it.
|
||||
type StoredObject struct {
|
||||
ID string `gorm:"primaryKey;size:64" json:"id"`
|
||||
ID string `gorm:"primaryKey;size:96" json:"id"`
|
||||
ConfigName string `gorm:"size:64;index" json:"-"`
|
||||
Key string `gorm:"size:512" json:"-"`
|
||||
MIME string `gorm:"size:128" json:"-"`
|
||||
|
||||
@@ -18,18 +18,19 @@ import (
|
||||
|
||||
// 事件类型:前后端共享的单层 JSON 协议契约,禁止手拼 JSON / 双重编码
|
||||
const (
|
||||
EventHello = "hello" // 建连欢迎帧(含在线用户快照)
|
||||
EventPong = "pong" // 应用层心跳应答
|
||||
EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播
|
||||
EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间
|
||||
EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID}
|
||||
EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id}
|
||||
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
||||
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||
EventHello = "hello" // 建连欢迎帧(含在线用户快照)
|
||||
EventPong = "pong" // 应用层心跳应答
|
||||
EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播
|
||||
EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间
|
||||
EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID}
|
||||
EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id}
|
||||
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
||||
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||
EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID}
|
||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||
)
|
||||
|
||||
// RoomStaff 管理团队房间(板块管理员及以上)
|
||||
|
||||
@@ -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 刷新,无需重启)
|
||||
@@ -78,10 +101,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
postFileSvc.WithOperations(ops)
|
||||
pointsSvc := service.NewPointsService(model.DB)
|
||||
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
||||
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
|
||||
WithSetting(settingSvc).
|
||||
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
|
||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||
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)
|
||||
@@ -93,45 +121,50 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
if err := postFileSvc.EnsureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := librarySvc.EnsureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limiter := service.DefaultRateLimiter()
|
||||
|
||||
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
|
||||
pipeCtx, pipeCancel := context.WithCancel(context.Background())
|
||||
defer pipeCancel()
|
||||
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
|
||||
// 不用 WithCancel+defer:Setup 返回即 cancel 会让落库协程在启动阶段就退出,事件永远不落库
|
||||
pipeCtx := context.Background()
|
||||
go visitSvc.StartFlusher(pipeCtx)
|
||||
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),
|
||||
FriendLink: service.NewFriendLinkService(model.DB),
|
||||
Badge: badgeSvc,
|
||||
LeaderboardSvc: leaderboardSvc,
|
||||
LibrarySvc: librarySvc,
|
||||
}
|
||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||
notifSvc.OnNotifyNew = func(userID uint) {
|
||||
@@ -152,9 +185,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
||||
r.Use(middleware.VisitCapture(visitSvc))
|
||||
|
||||
// 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable)
|
||||
r.Use(middleware.UploadStaticCache())
|
||||
|
||||
// 上传文件静态服务(data/uploads → /uploads)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
|
||||
// 品牌图固定地址(友链等外部引用):按站点设置解析到当前图,换图不变链
|
||||
r.GET("/api/brand/:slot", h.BrandImage)
|
||||
|
||||
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
||||
r.GET("/health", h.Health)
|
||||
|
||||
@@ -169,12 +208,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||
pubAPI.GET("/overview", h.Overview)
|
||||
pubAPI.GET("/sidebar", h.GetSidebar)
|
||||
pubAPI.GET("/friend-links", h.GetFriendLinks)
|
||||
pubAPI.GET("/posts", h.Posts)
|
||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
||||
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||
pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation)
|
||||
pubAPI.GET("/users/:id", h.UserProfile)
|
||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
||||
@@ -186,7 +225,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/settings", h.PublicSettings)
|
||||
pubAPI.GET("/leaderboard", h.Leaderboard)
|
||||
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
|
||||
// 书库(公开浏览/下载;文件经专用端点消费,不进 /uploads 静态目录)
|
||||
pubAPI.GET("/library", h.LibraryList)
|
||||
pubAPI.GET("/library/:slug", h.LibraryDetail)
|
||||
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
|
||||
pubAPI.GET("/site-state", h.SiteState)
|
||||
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
|
||||
pubAPI.GET("/img", h.ImageVariant)
|
||||
pubAPI.GET("/media/:object", h.PublicObject)
|
||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||
@@ -209,6 +254,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.GET("/me/login-devices", h.MyLoginDevices)
|
||||
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
|
||||
api.PUT("/profile", h.UpdateProfile)
|
||||
// 友情链接申请(登录用户;提交后进 pending 待审批)
|
||||
api.POST("/friend-links/apply", h.ApplyFriendLink)
|
||||
api.GET("/friend-links/mine", h.MyFriendLinks)
|
||||
api.PUT("/friend-links/:id", h.OwnerUpdateFriendLink)
|
||||
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
|
||||
api.PUT("/posts/:id", h.UpdatePost)
|
||||
api.GET("/posts/:id/history", h.PostEditHistory)
|
||||
@@ -256,6 +305,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
|
||||
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
||||
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
|
||||
// 时间线:从 Git releases 页导入更新日志
|
||||
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
|
||||
|
||||
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
||||
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
||||
@@ -331,20 +382,52 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||
// (见下方 msgAPI)
|
||||
|
||||
// 站点公告文章管理(管理员及以上)
|
||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||
// 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入,
|
||||
// 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上,
|
||||
// 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权)
|
||||
permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware())
|
||||
|
||||
// 站点公告文章管理(PermAnnouncements)
|
||||
announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
||||
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
||||
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
||||
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
||||
announceAPI.GET("/pages", h.AdminListSitePages)
|
||||
announceAPI.POST("/pages", h.AdminCreateSitePage)
|
||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||
|
||||
// 广告位管理(管理员及以上)
|
||||
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||
// 站点单页管理(PermPages)
|
||||
pagesAPI := permAPI.Group("", authMW.RequirePerm(service.PermPages))
|
||||
pagesAPI.GET("/pages", h.AdminListSitePages)
|
||||
pagesAPI.POST("/pages", h.AdminCreateSitePage)
|
||||
pagesAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||
pagesAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||
pagesAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
|
||||
|
||||
// 书库管理(PermLibrary)
|
||||
libraryAPI := permAPI.Group("/library", authMW.RequirePerm(service.PermLibrary))
|
||||
{
|
||||
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
|
||||
libraryAPI.GET("/export", h.AdminExportAllLibrary)
|
||||
libraryAPI.POST("/import", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibrary)
|
||||
libraryAPI.GET("/docs/:id/export", h.AdminExportLibraryDoc)
|
||||
libraryAPI.POST("/docs", h.AdminCreateLibraryDoc)
|
||||
libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc)
|
||||
libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc)
|
||||
libraryAPI.DELETE("/docs/:id/purge", h.AdminPurgeLibraryDoc)
|
||||
libraryAPI.POST("/docs/:id/files", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadLibraryFile)
|
||||
libraryAPI.DELETE("/files/:fid", h.AdminDeleteLibraryFile)
|
||||
// 章节(两级树:章 → 节)
|
||||
libraryAPI.POST("/docs/:id/sections", h.AdminCreateLibrarySection)
|
||||
libraryAPI.PUT("/sections/:sid", h.AdminUpdateLibrarySection)
|
||||
libraryAPI.DELETE("/sections/:sid", h.AdminDeleteLibrarySection)
|
||||
libraryAPI.POST("/sections/:sid/move", h.AdminMoveLibrarySection)
|
||||
libraryAPI.POST("/docs/:id/import-md", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdown)
|
||||
libraryAPI.POST("/docs/:id/import-md-batch", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdownBatch)
|
||||
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
|
||||
}
|
||||
|
||||
// 广告位管理(PermAds)
|
||||
adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds))
|
||||
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
||||
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
||||
adsAPI.GET("/ads", h.AdminListAds)
|
||||
@@ -375,6 +458,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
||||
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
||||
|
||||
// 友情链接管理(超级管理员/站长):CRUD + 审批
|
||||
friendLinkAPI := staffAPI.Group("/friend-links", authMW.RequirePerm(service.PermSettings))
|
||||
{
|
||||
friendLinkAPI.GET("", h.AdminListFriendLinks)
|
||||
friendLinkAPI.POST("", h.AdminCreateFriendLink)
|
||||
friendLinkAPI.PUT("/:id", h.AdminUpdateFriendLink)
|
||||
friendLinkAPI.POST("/:id/status", h.AdminSetFriendLinkStatus)
|
||||
friendLinkAPI.DELETE("/:id", h.AdminDeleteFriendLink)
|
||||
}
|
||||
|
||||
// 站点设置(超级管理员/站长)
|
||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||
@@ -391,6 +484,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))
|
||||
@@ -398,6 +494,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
usersAPI.GET("/users", h.AdminListUsers)
|
||||
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
||||
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
||||
usersAPI.GET("/users/:id/perms", h.AdminGetUserPerms)
|
||||
usersAPI.PUT("/users/:id/perms", h.AdminSetUserPerms)
|
||||
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
||||
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
||||
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -10,21 +12,119 @@ import (
|
||||
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
||||
const (
|
||||
PermUsers = "users" // 用户与权限管理(超管/站长)
|
||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
|
||||
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
|
||||
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
|
||||
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
|
||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||
PermBoards = "boards" // 板块管理(仅站长)
|
||||
)
|
||||
|
||||
// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。
|
||||
// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。
|
||||
var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds}
|
||||
|
||||
// ValidPermOverride 权限码是否可被账号级授予
|
||||
func ValidPermOverride(p string) bool {
|
||||
for _, g := range GrantablePerms {
|
||||
if p == g {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集
|
||||
func NormalizePermOverrides(perms []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] || !ValidPermOverride(p) {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空)
|
||||
func ParsePermOverrides(raw string) []string {
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return NormalizePermOverrides(arr)
|
||||
}
|
||||
|
||||
// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧)
|
||||
func SerializePermOverrides(perms []string) string {
|
||||
norm := NormalizePermOverrides(perms)
|
||||
b, err := json.Marshal(norm)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予)
|
||||
func roleDefaultPerm(role model.Role, p string) bool {
|
||||
switch p {
|
||||
case PermBoards:
|
||||
return role == model.RoleOwner
|
||||
case PermUsers, PermSettings:
|
||||
return role == model.RoleSuperAdmin || role == model.RoleOwner
|
||||
case PermAnnouncements, PermPages, PermLibrary, PermAds:
|
||||
return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin)
|
||||
case PermModeration:
|
||||
return model.IsStaff(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理),
|
||||
// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。
|
||||
func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string {
|
||||
set := map[string]bool{}
|
||||
all := append([]string{
|
||||
PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds,
|
||||
PermSettings, PermModeration, PermBoards,
|
||||
}, GrantablePerms...)
|
||||
for _, p := range all {
|
||||
if roleDefaultPerm(role, p) {
|
||||
set[p] = true
|
||||
}
|
||||
}
|
||||
for _, p := range ParsePermOverrides(permOverridesRaw) {
|
||||
set[p] = true
|
||||
}
|
||||
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
|
||||
set[PermMessages] = true
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
||||
type Actor struct {
|
||||
ID uint
|
||||
Username string
|
||||
Role model.Role
|
||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
|
||||
}
|
||||
|
||||
// IsStaff 是否管理团队成员
|
||||
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
|
||||
return a != nil && model.IsStaff(a.Role)
|
||||
}
|
||||
|
||||
// HasPerm 是否拥有某后台功能点
|
||||
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
|
||||
func (a *Actor) HasPerm(p string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
switch p {
|
||||
case PermBoards:
|
||||
return a.Role == model.RoleOwner
|
||||
case PermUsers, PermSettings:
|
||||
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
|
||||
case PermAnnouncements:
|
||||
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
|
||||
case PermModeration:
|
||||
return a.IsStaff()
|
||||
case PermMessages:
|
||||
if roleDefaultPerm(a.Role, p) {
|
||||
return true
|
||||
}
|
||||
if p == PermMessages {
|
||||
return a.HasSiteMessagePerm()
|
||||
}
|
||||
for _, e := range a.ExtraPerms {
|
||||
if e == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
|
||||
return a.HasPerm(PermUsers)
|
||||
}
|
||||
|
||||
// LoadActor 读取用户实时角色与板块授权
|
||||
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
|
||||
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
||||
var u model.User
|
||||
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
|
||||
if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actor := &Actor{
|
||||
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
||||
CanManageMessages: u.CanManageMessages,
|
||||
ExtraPerms: ParsePermOverrides(u.PermOverrides),
|
||||
}
|
||||
if u.Role == model.RoleBoardAdmin {
|
||||
var ids []uint
|
||||
|
||||
@@ -17,32 +17,33 @@ import (
|
||||
const (
|
||||
SettingKeyAdsConfig = "ads_config"
|
||||
|
||||
AdMaxTitleRunes = 24
|
||||
AdMaxNoteRunes = 100
|
||||
AdMaxImageShow = 5
|
||||
AdMaxTextShow = 6
|
||||
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
|
||||
AdDefaultTitle = "自助推广"
|
||||
AdMaxTitleRunes = 24
|
||||
AdMaxNoteRunes = 100
|
||||
AdMaxImageShow = 5
|
||||
AdMaxTextShow = 6
|
||||
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
|
||||
AdDefaultTitle = "自助推广"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAdNotFound = errors.New("广告不存在")
|
||||
ErrAdForbidden = errors.New("无权操作")
|
||||
ErrAdInvalid = errors.New("广告参数无效")
|
||||
ErrAdCaptcha = errors.New("验证码错误或已过期")
|
||||
ErrAdDisabled = errors.New("自助推广暂未开放")
|
||||
ErrAdBadPayment = errors.New("请选择有效的支付方式")
|
||||
ErrAdBadDuration = errors.New("请选择有效的投放时长")
|
||||
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
|
||||
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
|
||||
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
|
||||
ErrAdNotFound = errors.New("广告不存在")
|
||||
ErrAdForbidden = errors.New("无权操作")
|
||||
ErrAdInvalid = errors.New("广告参数无效")
|
||||
ErrAdCaptcha = errors.New("验证码错误或已过期")
|
||||
ErrAdDisabled = errors.New("自助推广暂未开放")
|
||||
ErrAdBadPayment = errors.New("请选择有效的支付方式")
|
||||
ErrAdBadDuration = errors.New("请选择有效的投放时长")
|
||||
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
|
||||
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
|
||||
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
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ const (
|
||||
|
||||
// AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab)
|
||||
type AdminContentCounts struct {
|
||||
PostsLive int64 `json:"posts_live"`
|
||||
PostsPending int64 `json:"posts_pending"`
|
||||
PostsRejected int64 `json:"posts_rejected"`
|
||||
PostsDeleted int64 `json:"posts_deleted"`
|
||||
CommentsLive int64 `json:"comments_live"`
|
||||
PostsLive int64 `json:"posts_live"`
|
||||
PostsPending int64 `json:"posts_pending"`
|
||||
PostsRejected int64 `json:"posts_rejected"`
|
||||
PostsDeleted int64 `json:"posts_deleted"`
|
||||
CommentsLive int64 `json:"comments_live"`
|
||||
CommentsPending int64 `json:"comments_pending"`
|
||||
CommentsRejected int64 `json:"comments_rejected"`
|
||||
CommentsDeleted int64 `json:"comments_deleted"`
|
||||
@@ -61,32 +61,34 @@ type AdminContentCounts struct {
|
||||
|
||||
// AdminContentPost 后台帖子列表项
|
||||
type AdminContentPost struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
BoardID uint `json:"board_id"`
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
BoardID uint `json:"board_id"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// AdminContentComment 后台评论列表项
|
||||
type AdminContentComment struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
BoardID uint `json:"board_id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
BoardID uint `json:"board_id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Floor uint `json:"floor"` // 所属楼层相对序号
|
||||
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
func normalizeAdminStatus(raw string) AdminContentStatus {
|
||||
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
||||
}
|
||||
|
||||
items := make([]AdminContentComment, 0, len(rows))
|
||||
commentIDs := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
commentIDs = append(commentIDs, r.ID)
|
||||
}
|
||||
anchors := CommentAnchors(s.db, commentIDs)
|
||||
for _, r := range rows {
|
||||
it := AdminContentComment{
|
||||
ID: r.ID,
|
||||
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
||||
Board: boards[r.BoardID],
|
||||
User: users[r.UserID],
|
||||
}
|
||||
if a, ok := anchors[r.ID]; ok {
|
||||
it.Floor = a.Floor
|
||||
it.IsRoot = a.IsRoot
|
||||
}
|
||||
if r.DeletedAt.Valid {
|
||||
it.Deleted = true
|
||||
t := r.DeletedAt.Time
|
||||
|
||||
@@ -54,6 +54,7 @@ type AdminUserItem struct {
|
||||
BoardIDs []uint `json:"board_ids"`
|
||||
Banned bool `json:"banned"`
|
||||
CanManageMessages bool `json:"can_manage_messages"`
|
||||
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
|
||||
PostCount int64 `json:"post_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
Points int `json:"points"`
|
||||
@@ -71,7 +72,7 @@ type AdminUserItem struct {
|
||||
// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
|
||||
type AdminUserSummary struct {
|
||||
Online int64 `json:"online"`
|
||||
InCooldown int64 `json:"in_cooldown"`
|
||||
InCooldown int64 `json:"in_cooldown"`
|
||||
Active7d int64 `json:"active_7d"`
|
||||
FailedLogins24h int64 `json:"failed_logins_24h"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
||||
BoardIDs: []uint{},
|
||||
Banned: u.Banned,
|
||||
CanManageMessages: u.CanManageMessages,
|
||||
PermOverrides: ParsePermOverrides(u.PermOverrides),
|
||||
Points: u.Points,
|
||||
TotalPoints: u.TotalPoints,
|
||||
Level: u.Level,
|
||||
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导
|
||||
if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" {
|
||||
if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
||||
return s.getItem(s.db, targetID)
|
||||
}
|
||||
|
||||
// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显)
|
||||
func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) {
|
||||
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||
return nil, ErrCannotAssignRole
|
||||
}
|
||||
var u model.User
|
||||
if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePermOverrides(u.PermOverrides), nil
|
||||
}
|
||||
|
||||
// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。
|
||||
// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带,
|
||||
// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。
|
||||
func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) {
|
||||
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||
return nil, ErrCannotAssignRole
|
||||
}
|
||||
if operator.ID == targetID {
|
||||
return nil, ErrAdminSelfAction
|
||||
}
|
||||
norm := NormalizePermOverrides(perms)
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var u model.User
|
||||
if err := tx.First(&u, targetID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role == model.RoleOwner {
|
||||
return ErrProtectedOwner
|
||||
}
|
||||
// 管理员及以上角色自带全部渠道权限,账号级授予保持为空
|
||||
if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||||
norm = []string{}
|
||||
}
|
||||
raw := SerializePermOverrides(norm)
|
||||
if u.PermOverrides == raw {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&u).Update("perm_overrides", raw).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getItem(s.db, targetID)
|
||||
}
|
||||
|
||||
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
||||
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
||||
if operator == nil || operator.Role != model.RoleOwner {
|
||||
@@ -564,12 +619,12 @@ func normalizeAuditPage(page, size int) (int, int) {
|
||||
|
||||
// AdminUserAuditProfile 站长查看用户档案摘要
|
||||
type AdminUserAuditProfile struct {
|
||||
User AdminUserItem `json:"user"`
|
||||
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
|
||||
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
|
||||
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
|
||||
PublishedPosts int64 `json:"published_posts"`
|
||||
PublishedComments int64 `json:"published_comments"`
|
||||
User AdminUserItem `json:"user"`
|
||||
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
|
||||
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
|
||||
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
|
||||
PublishedPosts int64 `json:"published_posts"`
|
||||
PublishedComments int64 `json:"published_comments"`
|
||||
}
|
||||
|
||||
// AdminAuditPostItem 审计帖子行
|
||||
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Floor uint `json:"floor"`
|
||||
IsRoot bool `json:"is_root"`
|
||||
}
|
||||
|
||||
// AdminAuditMessageItem 审计聊天消息行
|
||||
@@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
||||
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
||||
return nil, 0, page, err
|
||||
}
|
||||
commentIDs := make([]uint, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
commentIDs = append(commentIDs, c.ID)
|
||||
}
|
||||
anchors := CommentAnchors(s.db, commentIDs)
|
||||
items := make([]AdminAuditCommentItem, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
it := AdminAuditCommentItem{
|
||||
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
||||
Status: c.Status,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
if a, ok := anchors[c.ID]; ok {
|
||||
it.Floor = a.Floor
|
||||
it.IsRoot = a.IsRoot
|
||||
}
|
||||
if c.DeletedAt.Valid {
|
||||
it.Deleted = true
|
||||
t := c.DeletedAt.Time
|
||||
|
||||
@@ -24,10 +24,15 @@ const (
|
||||
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
|
||||
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
|
||||
AccessTokenTTL = 15 * time.Minute
|
||||
// RefreshTokenTTL refresh token 有效期(长期)
|
||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
||||
// AccessTokenTTL access token 有效期。安全性由每请求实时校验
|
||||
// (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天:
|
||||
// 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。
|
||||
// (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链,
|
||||
// 是手机端弹"登录已失效"的主因)
|
||||
AccessTokenTTL = 7 * 24 * time.Hour
|
||||
// RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期),
|
||||
// 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录
|
||||
RefreshTokenTTL = 30 * 24 * time.Hour
|
||||
// RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时,
|
||||
// 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式)
|
||||
RefreshRotationGrace = 60 * time.Second
|
||||
@@ -73,6 +78,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,15 +87,33 @@ 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)
|
||||
|
||||
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
||||
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
|
||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 邮箱统一小写归一化,避免大小写变体重复注册
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -109,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
||||
if count > 0 {
|
||||
return errors.New("用户名已被使用")
|
||||
}
|
||||
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
|
||||
if email != "" {
|
||||
var emailCount int64
|
||||
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if emailCount > 0 {
|
||||
return errors.New("该邮箱已被注册")
|
||||
}
|
||||
}
|
||||
var n int64
|
||||
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
||||
return err
|
||||
@@ -133,7 +167,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 {
|
||||
@@ -265,7 +300,9 @@ var (
|
||||
var (
|
||||
ErrRefreshInvalid = errors.New("refresh token 无效")
|
||||
ErrRefreshExpired = errors.New("refresh token 已过期")
|
||||
ErrRefreshReused = errors.New("refresh token 存在重用嫌疑")
|
||||
// errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点
|
||||
// (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效
|
||||
errReplayChainDead = errors.New("refresh token 轮转链已无有效会话")
|
||||
)
|
||||
|
||||
// hashRefreshToken refresh token 只以 SHA-256 形式落库
|
||||
@@ -317,7 +354,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 +380,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
|
||||
}
|
||||
|
||||
@@ -383,7 +421,8 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
|
||||
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
|
||||
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
|
||||
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
|
||||
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
|
||||
// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时
|
||||
// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead)
|
||||
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
|
||||
var (
|
||||
accessToken, newRefresh string
|
||||
@@ -394,6 +433,11 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
||||
var rt model.RefreshToken
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理,
|
||||
// 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登)
|
||||
return err
|
||||
}
|
||||
outErr = ErrRefreshInvalid
|
||||
return nil
|
||||
}
|
||||
@@ -410,7 +454,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,14 +467,24 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
||||
}
|
||||
}
|
||||
}
|
||||
// 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族
|
||||
if err := tx.Model(&model.RefreshToken{}).
|
||||
Where("user_id = ? AND revoked = ?", rt.UserID, false).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
// 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失
|
||||
// (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时
|
||||
// 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器
|
||||
// jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把
|
||||
// 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to
|
||||
// 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。
|
||||
at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now)
|
||||
if healErr == nil {
|
||||
accessToken, newRefresh, outUser = at, plain, healedUser
|
||||
return nil
|
||||
}
|
||||
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID)
|
||||
outErr = ErrRefreshReused
|
||||
if !errors.Is(healErr, errReplayChainDead) {
|
||||
// 封禁等账号级错误透传,让前端给出对应告知
|
||||
outErr = healErr
|
||||
return nil
|
||||
}
|
||||
log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt))
|
||||
outErr = ErrRefreshInvalid
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -489,6 +543,46 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
||||
return accessToken, newRefresh, outUser, nil
|
||||
}
|
||||
|
||||
// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点
|
||||
// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对——
|
||||
// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时
|
||||
// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。
|
||||
// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用
|
||||
// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除
|
||||
// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰)
|
||||
// 之后链上无活头节点,自愈自然失效,不会被绕过。
|
||||
func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) {
|
||||
cur := *rt
|
||||
// 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数
|
||||
for i := 0; cur.Revoked && i < 128; i++ {
|
||||
if cur.RotatedTo == 0 {
|
||||
return "", "", nil, errReplayChainDead
|
||||
}
|
||||
var next model.RefreshToken
|
||||
if err := tx.First(&next, cur.RotatedTo).Error; err != nil {
|
||||
return "", "", nil, errReplayChainDead
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" {
|
||||
return "", "", nil, errReplayChainDead
|
||||
}
|
||||
user, err := s.loadActiveUser(tx, cur.UserID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
plain, err := s.decryptToken(cur.TokenCipher)
|
||||
if err != nil {
|
||||
return "", "", nil, errReplayChainDead
|
||||
}
|
||||
at, err := s.generateToken(user, sessionFamilyID(cur))
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur))
|
||||
return at, plain, user, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
||||
func (s *AuthService) RevokeRefreshToken(token string) error {
|
||||
if token == "" {
|
||||
@@ -600,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
|
||||
if nickCount > 0 {
|
||||
return nil, errors.New("该昵称已被使用")
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email != "" {
|
||||
if !strings.Contains(email, "@") || len(email) > 128 {
|
||||
return nil, errors.New("邮箱格式不正确")
|
||||
}
|
||||
// 邮箱唯一性校验(排除自身)
|
||||
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
|
||||
var count int64
|
||||
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
|
||||
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("该邮箱已被使用")
|
||||
}
|
||||
|
||||
223
backend/service/blob_dedup_test.go
Normal file
223
backend/service/blob_dedup_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newBlobTestDB(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.Blob{}, &model.Attachment{}, &model.PostAttachment{},
|
||||
&model.User{}, &model.Post{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func webpFixtureBytes() []byte {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
|
||||
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
|
||||
var buf bytes.Buffer
|
||||
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
dir := t.TempDir()
|
||||
s := NewUploadService(db, dir)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
data := webpFixtureBytes()
|
||||
|
||||
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 1: %v", err)
|
||||
}
|
||||
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 2: %v", err)
|
||||
}
|
||||
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
|
||||
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
|
||||
}
|
||||
|
||||
var blob model.Blob
|
||||
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
|
||||
t.Fatalf("load blob: %v", err)
|
||||
}
|
||||
if blob.RefCount != 2 {
|
||||
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||
}
|
||||
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
|
||||
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
|
||||
t.Fatalf("shared physical file missing: %v", err)
|
||||
}
|
||||
|
||||
// 删一个引用:文件保留
|
||||
if err := s.DeleteAttachment(1, att1.ID); err != nil {
|
||||
t.Fatalf("DeleteAttachment 1: %v", err)
|
||||
}
|
||||
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
|
||||
if blob.RefCount != 1 {
|
||||
t.Fatalf("refcount want 1, got %d", blob.RefCount)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Fatalf("blob file should survive while referenced: %v", err)
|
||||
}
|
||||
|
||||
// 最后一个引用删除:文件与 blob 行一起回收
|
||||
if err := s.DeleteAttachment(2, att2.ID); err != nil {
|
||||
t.Fatalf("DeleteAttachment 2: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("blob row should be gone, count=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
dir := t.TempDir()
|
||||
s := NewUploadService(db, dir)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
|
||||
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
|
||||
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
|
||||
var in bytes.Buffer
|
||||
if err := jpeg.Encode(&in, src, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
|
||||
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 1: %v", err)
|
||||
}
|
||||
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage 2: %v", err)
|
||||
}
|
||||
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
|
||||
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
|
||||
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
root := t.TempDir()
|
||||
s := NewPostFileService(db, root)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
data := []byte("paid attachment content v1")
|
||||
|
||||
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraftFile 1: %v", err)
|
||||
}
|
||||
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraftFile 2: %v", err)
|
||||
}
|
||||
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
|
||||
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
|
||||
}
|
||||
// 原始文件名仍各自保留
|
||||
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
|
||||
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
|
||||
}
|
||||
blobPath := s.FilePath(a1)
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Fatalf("shared private blob missing: %v", err)
|
||||
}
|
||||
|
||||
var blob model.Blob
|
||||
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
|
||||
t.Fatalf("load private blob: %v", err)
|
||||
}
|
||||
if blob.RefCount != 2 {
|
||||
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||
}
|
||||
|
||||
if err := s.DeleteOwn(1, a1.ID); err != nil {
|
||||
t.Fatalf("DeleteOwn 1: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); err != nil {
|
||||
t.Fatalf("private blob should survive with one ref: %v", err)
|
||||
}
|
||||
if err := s.DeleteOwn(2, a2.ID); err != nil {
|
||||
t.Fatalf("DeleteOwn 2: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
|
||||
db := newBlobTestDB(t)
|
||||
root := t.TempDir()
|
||||
img := NewUploadService(db, filepath.Join(root, "uploads"))
|
||||
pf := NewPostFileService(db, filepath.Join(root, "private"))
|
||||
if err := img.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure upload dir: %v", err)
|
||||
}
|
||||
if err := pf.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure private dir: %v", err)
|
||||
}
|
||||
data := webpFixtureBytes()
|
||||
|
||||
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDraftFile: %v", err)
|
||||
}
|
||||
if att.BlobHash != file.BlobHash {
|
||||
t.Fatalf("test premise: same bytes should produce same hash")
|
||||
}
|
||||
|
||||
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
|
||||
var pubs, privs int64
|
||||
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
|
||||
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
|
||||
if pubs != 1 || privs != 1 {
|
||||
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
|
||||
}
|
||||
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
|
||||
privatePath := pf.FilePath(file)
|
||||
if publicPath == privatePath {
|
||||
t.Fatal("public and private blob must not share a path")
|
||||
}
|
||||
if _, err := os.Stat(publicPath); err != nil {
|
||||
t.Fatalf("public blob missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(privatePath); err != nil {
|
||||
t.Fatalf("private blob missing: %v", err)
|
||||
}
|
||||
}
|
||||
132
backend/service/blob_store.go
Normal file
132
backend/service/blob_store.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 内容寻址存储(content-addressed storage):
|
||||
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
|
||||
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
|
||||
// 公开与私有是独立命名空间(见 model.Blob 注释)。
|
||||
|
||||
// sha256Hex 计算字节的 SHA-256 hex
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
|
||||
func hashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
|
||||
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
|
||||
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
|
||||
for {
|
||||
var b model.Blob
|
||||
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
b = model.Blob{
|
||||
Hash: hash,
|
||||
Public: public,
|
||||
RefCount: 1,
|
||||
Size: size,
|
||||
MIME: mimeType,
|
||||
ObjectID: objectID,
|
||||
}
|
||||
if err := db.Create(&b).Error; err != nil {
|
||||
if isUniqueConflict(err) {
|
||||
continue // 并发首建:改为走计数+1
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&model.Blob{}).
|
||||
Where("hash = ? AND public = ?", hash, public).
|
||||
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
|
||||
}
|
||||
}
|
||||
|
||||
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
|
||||
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
|
||||
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
|
||||
var removed model.Blob
|
||||
var last bool
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var b model.Blob
|
||||
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if b.RefCount <= 1 {
|
||||
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
removed = b
|
||||
last = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.Blob{}).
|
||||
Where("hash = ? AND public = ?", hash, public).
|
||||
UpdateColumn("ref_count", b.RefCount-1).Error
|
||||
})
|
||||
if err != nil {
|
||||
return model.Blob{}, false, err
|
||||
}
|
||||
return removed, last, nil
|
||||
}
|
||||
|
||||
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
|
||||
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
|
||||
// src 与 dst 同一路径时不做操作。
|
||||
func placeLocalBlob(src, dst string) error {
|
||||
if abs, err := filepath.Abs(src); err == nil {
|
||||
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
if src != dst {
|
||||
_ = os.Remove(src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
_ = os.Remove(src)
|
||||
return err
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,28 @@ import (
|
||||
|
||||
// 站点品牌与页脚(公开设置,缺省为空)
|
||||
const (
|
||||
SettingKeySiteWordmark = "site_wordmark"
|
||||
SettingKeySiteSlogan = "site_slogan"
|
||||
SettingKeySiteKeywords = "site_keywords"
|
||||
SettingKeyLogoLightURL = "logo_light_url"
|
||||
SettingKeyLogoDarkURL = "logo_dark_url"
|
||||
SettingKeyFaviconURL = "favicon_url"
|
||||
SettingKeyFooterLinks = "footer_links"
|
||||
SettingKeyBrandMark = "brand_mark"
|
||||
SettingKeyBrandLogoSize = "brand_logo_size"
|
||||
SettingKeyBrandLogoFit = "brand_logo_fit"
|
||||
SettingKeySiteWordmark = "site_wordmark"
|
||||
SettingKeySiteSlogan = "site_slogan"
|
||||
SettingKeySiteKeywords = "site_keywords"
|
||||
SettingKeyLogoLightURL = "logo_light_url"
|
||||
SettingKeyLogoDarkURL = "logo_dark_url"
|
||||
SettingKeyFaviconURL = "favicon_url"
|
||||
SettingKeyFooterLinks = "footer_links"
|
||||
SettingKeyBrandMark = "brand_mark"
|
||||
SettingKeyBrandLogoSize = "brand_logo_size"
|
||||
SettingKeyBrandLogoFit = "brand_logo_fit"
|
||||
SettingKeyBrandSloganVisible = "brand_slogan_visible"
|
||||
|
||||
BrandSlotLight = "logo_light"
|
||||
BrandSlotDark = "logo_dark"
|
||||
BrandSlotFavicon = "favicon"
|
||||
|
||||
// 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。
|
||||
// 实际文件仍存随机名,请求时按站点设置解析到当前图。
|
||||
BrandAliasPathLight = "/api/brand/logo-light"
|
||||
BrandAliasPathDark = "/api/brand/logo-dark"
|
||||
BrandAliasPathFavicon = "/api/brand/favicon"
|
||||
|
||||
BrandMarkImageText = "image_text"
|
||||
BrandMarkImage = "image"
|
||||
BrandMarkText = "text"
|
||||
@@ -37,6 +44,8 @@ const (
|
||||
BrandLogoSize3x1 = "3x1"
|
||||
BrandLogoSize4x1 = "4x1"
|
||||
BrandLogoSize169 = "16x9"
|
||||
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
|
||||
BrandLogoSizeAuto = "auto"
|
||||
|
||||
BrandLogoFitCover = "cover"
|
||||
BrandLogoFitContain = "contain"
|
||||
@@ -108,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "", BrandLogoSizeSQ:
|
||||
return BrandLogoSizeSQ, true
|
||||
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
|
||||
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
|
||||
return strings.TrimSpace(raw), true
|
||||
default:
|
||||
return "", false
|
||||
@@ -361,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
|
||||
out.BrandLogoFit = f
|
||||
}
|
||||
}
|
||||
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
out.BrandSloganVisible = sv == "true"
|
||||
} else {
|
||||
out.BrandSloganVisible = true
|
||||
}
|
||||
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -477,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
|
||||
return s.putValue(SettingKeyBrandLogoFit, v)
|
||||
}
|
||||
|
||||
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
|
||||
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
|
||||
if visible {
|
||||
return s.deleteKey(SettingKeyBrandSloganVisible)
|
||||
}
|
||||
return s.putValue(SettingKeyBrandSloganVisible, "false")
|
||||
}
|
||||
|
||||
func (s *SettingService) setBrandURL(key, slot, url string) error {
|
||||
u, ok := NormalizeBrandURL(slot, url)
|
||||
if !ok {
|
||||
|
||||
@@ -183,6 +183,22 @@ func (s *UploadService) BrandFileExists(url string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// OpenBrandFile 打开品牌图文件供对外服务;未设置或文件缺失返回 os.ErrNotExist
|
||||
func (s *UploadService) OpenBrandFile(url string) (*os.File, error) {
|
||||
if BrandRelPath(url) == "" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
abs, ok := s.safeUploadPath(url)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除
|
||||
func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) {
|
||||
if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon {
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenBrandFile(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
||||
if _, err := s.OpenBrandFile(png); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing file want ErrNotExist, got %v", err)
|
||||
}
|
||||
writeMediaFile(t, dir, "brand/0123456789abcdef0123456789abcdef.png", []byte("png"))
|
||||
f, err := s.OpenBrandFile(png)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"/uploads/images/0123456789abcdef0123456789abcdef.png",
|
||||
"/uploads/brand/../avatars/x.png",
|
||||
"http://evil.test/x.png",
|
||||
} {
|
||||
if _, err := s.OpenBrandFile(bad); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("should reject %q, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBrandURL(t *testing.T) {
|
||||
png := "/uploads/brand/0123456789abcdef0123456789abcdef.png"
|
||||
|
||||
@@ -75,8 +75,8 @@ type RoomView struct {
|
||||
Joined bool `json:"joined"`
|
||||
MyRole string `json:"my_role"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
|
||||
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
|
||||
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
|
||||
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
|
||||
Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 过期禁言自动解除:惰性清理,不依赖定时任务
|
||||
if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) {
|
||||
m.Muted = false
|
||||
m.MutedUntil = nil
|
||||
_ = tx.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ?", roomID, userID).
|
||||
Select("muted", "muted_until").
|
||||
Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@@ -122,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
|
||||
return s.membership(s.db, roomID, userID)
|
||||
}
|
||||
|
||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
|
||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示)
|
||||
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||
out := make(map[uint]int64)
|
||||
if userID == 0 || len(roomIDs) == 0 {
|
||||
@@ -132,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||
s.db.Table("chat_messages AS m").
|
||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
|
||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false).
|
||||
Group("m.room_id").
|
||||
Scan(&rows)
|
||||
for _, r := range rows {
|
||||
@@ -397,7 +406,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 +442,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 +452,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
|
||||
}
|
||||
@@ -752,44 +767,84 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
||||
return s.removeMember(roomID, targetID)
|
||||
}
|
||||
|
||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
|
||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
|
||||
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
|
||||
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
|
||||
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
||||
// 失败返回 nil + error。
|
||||
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
||||
msg := &model.ChatMessage{
|
||||
RoomID: roomID,
|
||||
SenderID: 0,
|
||||
Content: content,
|
||||
IsSystem: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||
Update("updated_at", time.Now()).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。
|
||||
// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。
|
||||
// 返回目标用户昵称(供广播系统提示使用)。
|
||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) {
|
||||
room, err := s.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if room.RoomType == model.ChatRoomTypeDirect {
|
||||
return ErrChatOwnerOnly
|
||||
return "", ErrChatOwnerOnly
|
||||
}
|
||||
target, err := s.membership(s.db, roomID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if target.Role == model.ChatRoleOwner {
|
||||
return ErrChatCannotMuteOwner
|
||||
return "", ErrChatCannotMuteOwner
|
||||
}
|
||||
var targetUser model.User
|
||||
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
|
||||
return ErrChatUserGone
|
||||
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
|
||||
return "", ErrChatUserGone
|
||||
}
|
||||
if targetUser.Role == model.RoleOwner {
|
||||
return ErrChatCannotMuteOwner
|
||||
return "", ErrChatCannotMuteOwner
|
||||
}
|
||||
if !oversee {
|
||||
op, err := s.membership(s.db, roomID, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||
return ErrChatMuteDenied
|
||||
return "", ErrChatMuteDenied
|
||||
}
|
||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
||||
return ErrChatMuteDenied
|
||||
return "", ErrChatMuteDenied
|
||||
}
|
||||
}
|
||||
return s.db.Model(&model.ChatRoomMember{}).
|
||||
var mutedUntil *time.Time
|
||||
if muted && duration > 0 {
|
||||
t := time.Now().Add(duration)
|
||||
mutedUntil = &t
|
||||
}
|
||||
if err := s.db.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
||||
Select("muted").Update("muted", muted).Error
|
||||
Select("muted", "muted_until").
|
||||
Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetName := targetUser.Nickname
|
||||
if targetName == "" {
|
||||
targetName = targetUser.Username
|
||||
}
|
||||
return targetName, nil
|
||||
}
|
||||
|
||||
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
||||
@@ -964,7 +1019,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
|
||||
if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, uid := range mentionIDs {
|
||||
@@ -1053,7 +1108,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
||||
q = q.Where("id < ?", beforeID)
|
||||
}
|
||||
var msgs []model.ChatMessage
|
||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
|
||||
// Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致)
|
||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(msgs) > size
|
||||
@@ -1082,7 +1138,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
||||
return nil, false, err
|
||||
}
|
||||
var msg model.ChatMessage
|
||||
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
|
||||
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, ErrChatMsgNotFound
|
||||
}
|
||||
@@ -1091,6 +1147,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
||||
if msg.RecalledAt != nil {
|
||||
return &msg, false, nil // 幂等
|
||||
}
|
||||
// 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回)
|
||||
if msg.Sender.Role == model.RoleOwner && !oversee {
|
||||
return nil, false, ErrChatRecallDenied
|
||||
}
|
||||
if msg.SenderID == operatorID {
|
||||
if !oversee {
|
||||
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
||||
@@ -1109,7 +1169,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
||||
}
|
||||
msg.RecalledAt = &now
|
||||
msg.RecalledBy = operatorID
|
||||
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
|
||||
// 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息:
|
||||
// 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。
|
||||
var prevID uint
|
||||
_ = s.db.Model(&model.ChatMessage{}).
|
||||
Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false).
|
||||
Order("id DESC").Limit(1).
|
||||
Select("id").Row().Scan(&prevID)
|
||||
_ = s.db.Model(&model.ChatRoom{}).
|
||||
Where("id = ? AND last_message_id = ?", roomID, msg.ID).
|
||||
Update("last_message_id", prevID).Error
|
||||
_ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID)
|
||||
return &msg, true, nil
|
||||
}
|
||||
|
||||
@@ -1289,13 +1359,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
||||
Update("last_read_message_id", messageID).Error
|
||||
}
|
||||
|
||||
// UnreadSummary 当前用户所有群的未读明细与总数
|
||||
// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入)
|
||||
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
||||
var rows []UnreadRoom
|
||||
err := s.db.Table("chat_messages AS m").
|
||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
|
||||
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false).
|
||||
Group("m.room_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -44,7 +44,7 @@ func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool {
|
||||
|
||||
// CommentService 评论服务
|
||||
type CommentService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
|
||||
return FloorNumber(s.db, postID, commentID)
|
||||
}
|
||||
|
||||
// CommentAnchor 评论在所属帖子内的相对锚点:
|
||||
// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼,
|
||||
// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。
|
||||
type CommentAnchor struct {
|
||||
Floor uint `json:"floor"`
|
||||
IsRoot bool `json:"is_root"`
|
||||
}
|
||||
|
||||
// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1);
|
||||
// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。
|
||||
func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor {
|
||||
out := make(map[uint]CommentAnchor, len(ids))
|
||||
if db == nil || len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
// Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径
|
||||
var targets []model.Comment
|
||||
if err := db.Unscoped().
|
||||
Select("id", "post_id", "parent_id", "root_id", "created_at").
|
||||
Where("id IN ?", ids).Find(&targets).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
rootIDSet := make(map[uint]struct{}, len(targets))
|
||||
for _, c := range targets {
|
||||
rid := c.ID
|
||||
if c.ParentID != nil && c.RootID != nil {
|
||||
rid = *c.RootID
|
||||
}
|
||||
rootIDSet[rid] = struct{}{}
|
||||
}
|
||||
rootIDs := make([]uint, 0, len(rootIDSet))
|
||||
for rid := range rootIDSet {
|
||||
rootIDs = append(rootIDs, rid)
|
||||
}
|
||||
// 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1
|
||||
type aheadRow struct {
|
||||
RootID uint
|
||||
Ahead int64
|
||||
}
|
||||
var aheadRows []aheadRow
|
||||
if err := db.Unscoped().Table("comments AS f").
|
||||
Select(`f.id AS root_id,
|
||||
(SELECT COUNT(*) FROM comments c
|
||||
WHERE c.post_id = f.post_id AND c.parent_id IS NULL
|
||||
AND (c.created_at < f.created_at
|
||||
OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`).
|
||||
Where("f.id IN ? AND f.parent_id IS NULL", rootIDs).
|
||||
Scan(&aheadRows).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
floorByRoot := make(map[uint]uint, len(aheadRows))
|
||||
for _, r := range aheadRows {
|
||||
floorByRoot[r.RootID] = uint(r.Ahead) + 1
|
||||
}
|
||||
for _, c := range targets {
|
||||
isRoot := c.ParentID == nil
|
||||
rid := c.ID
|
||||
if !isRoot && c.RootID != nil {
|
||||
rid = *c.RootID
|
||||
}
|
||||
floor := floorByRoot[rid]
|
||||
if floor == 0 {
|
||||
continue
|
||||
}
|
||||
out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AnchorsByCommentIDs 见包级 CommentAnchors
|
||||
func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor {
|
||||
return CommentAnchors(s.db, ids)
|
||||
}
|
||||
|
||||
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
||||
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
||||
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
||||
@@ -442,6 +516,8 @@ type UserCommentItem struct {
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
Content string `json:"content"`
|
||||
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
|
||||
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
||||
if items == nil {
|
||||
items = []UserCommentItem{}
|
||||
}
|
||||
ids := make([]uint, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
anchors := s.AnchorsByCommentIDs(ids)
|
||||
for i := range items {
|
||||
if a, ok := anchors[items[i].ID]; ok {
|
||||
items[i].Floor = a.Floor
|
||||
items[i].IsRoot = a.IsRoot
|
||||
}
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// FollowService 关注服务(仅用户;关注时给对方发通知)
|
||||
type FollowService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
notif *NotificationService
|
||||
}
|
||||
|
||||
|
||||
305
backend/service/friendlink.go
Normal file
305
backend/service/friendlink.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 友情链接状态
|
||||
const (
|
||||
FriendLinkStatusPending = "pending"
|
||||
FriendLinkStatusApproved = "approved"
|
||||
FriendLinkStatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// 字段长度上限
|
||||
const (
|
||||
friendLinkMaxName = 64
|
||||
friendLinkMaxURL = 512
|
||||
friendLinkMaxDescription = 255
|
||||
friendLinkMaxLogoURL = 512
|
||||
friendLinkMaxCategory = 32
|
||||
friendLinkMaxReciprocal = 512
|
||||
friendLinkMaxReject = 255
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrFriendLinkInvalid 参数校验失败
|
||||
ErrFriendLinkInvalid = errors.New("友情链接参数无效")
|
||||
// ErrFriendLinkNotFound 记录不存在
|
||||
ErrFriendLinkNotFound = errors.New("友情链接不存在")
|
||||
)
|
||||
|
||||
// FriendLinkInput 申请/新建/更新共用入参(指针区分「未传」与「显式清空」由调用方决定)
|
||||
type FriendLinkInput struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Category string `json:"category"`
|
||||
ReciprocalURL string `json:"reciprocal_url"`
|
||||
}
|
||||
|
||||
// FriendLinkService 友情链接申请 + 后台 CRUD/审批
|
||||
type FriendLinkService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFriendLinkService(db *gorm.DB) *FriendLinkService {
|
||||
return &FriendLinkService{db: db}
|
||||
}
|
||||
|
||||
// validateInput 校验并归一化入参;返回归一化后的值
|
||||
func validateFriendLinkInput(in FriendLinkInput) (FriendLinkInput, error) {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" || utf8.RuneCountInString(name) > friendLinkMaxName {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
rawURL := strings.TrimSpace(in.URL)
|
||||
if rawURL == "" || len(rawURL) > friendLinkMaxURL {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
if u, err := url.Parse(rawURL); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||
(u.Scheme != "http" && u.Scheme != "https") {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
desc := strings.TrimSpace(in.Description)
|
||||
if utf8.RuneCountInString(desc) > friendLinkMaxDescription {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
logo := strings.TrimSpace(in.LogoURL)
|
||||
if len(logo) > friendLinkMaxLogoURL {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
if logo != "" {
|
||||
if u, err := url.Parse(logo); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||
(u.Scheme != "http" && u.Scheme != "https") {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
}
|
||||
category := strings.TrimSpace(in.Category)
|
||||
if utf8.RuneCountInString(category) > friendLinkMaxCategory {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
reciprocal := strings.TrimSpace(in.ReciprocalURL)
|
||||
if len(reciprocal) > friendLinkMaxReciprocal {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
if reciprocal != "" {
|
||||
if u, err := url.Parse(reciprocal); err != nil || u.Scheme == "" || u.Host == "" ||
|
||||
(u.Scheme != "http" && u.Scheme != "https") {
|
||||
return FriendLinkInput{}, ErrFriendLinkInvalid
|
||||
}
|
||||
}
|
||||
return FriendLinkInput{
|
||||
Name: name, URL: rawURL, Description: desc, LogoURL: logo,
|
||||
Category: category, ReciprocalURL: reciprocal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListApproved 前台公开列表:仅 approved,按 sort_order ASC, id ASC
|
||||
func (s *FriendLinkService) ListApproved() ([]model.FriendLink, error) {
|
||||
var list []model.FriendLink
|
||||
err := s.db.Where("status = ?", FriendLinkStatusApproved).
|
||||
Order("sort_order ASC, id ASC").Find(&list).Error
|
||||
if list == nil {
|
||||
list = []model.FriendLink{}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ListAll 后台全量列表(含 pending/rejected),按 status, sort_order, id
|
||||
func (s *FriendLinkService) ListAll() ([]model.FriendLink, error) {
|
||||
var list []model.FriendLink
|
||||
err := s.db.Order("status ASC, sort_order ASC, id ASC").Find(&list).Error
|
||||
if list == nil {
|
||||
list = []model.FriendLink{}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ListMine 申请人本人提交的全部申请(含待审/拒绝),按提交时间倒序
|
||||
func (s *FriendLinkService) ListMine(userID uint) ([]model.FriendLink, error) {
|
||||
var list []model.FriendLink
|
||||
err := s.db.Where("applicant_user_id = ?", userID).
|
||||
Order("created_at DESC, id DESC").Find(&list).Error
|
||||
if list == nil {
|
||||
list = []model.FriendLink{}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
// OwnerUpdate 申请人修改自己的申请(名称/链接/回链/LOGO);非待审状态的记录修改后回退待审
|
||||
func (s *FriendLinkService) OwnerUpdate(id, userID uint, in FriendLinkInput) error {
|
||||
norm, err := validateFriendLinkInput(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var fl model.FriendLink
|
||||
if err := tx.First(&fl, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrFriendLinkNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 仅申请人本人可改;他人访问统一按不存在处理,不暴露记录存在性
|
||||
if fl.ApplicantUserID == nil || *fl.ApplicantUserID != userID {
|
||||
return ErrFriendLinkNotFound
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"name": norm.Name,
|
||||
"url": norm.URL,
|
||||
"logo_url": norm.LogoURL,
|
||||
"reciprocal_url": norm.ReciprocalURL,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if fl.Status != FriendLinkStatusPending {
|
||||
updates["status"] = FriendLinkStatusPending
|
||||
updates["reject_reason"] = ""
|
||||
}
|
||||
return tx.Model(&fl).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
// nextSortOrder 取当前 approved 记录中最大 sort_order + 1(新建/审批通过时使用)
|
||||
func (s *FriendLinkService) nextSortOrder() (int, error) {
|
||||
var maxOrder int
|
||||
err := s.db.Model(&model.FriendLink{}).
|
||||
Where("status = ?", FriendLinkStatusApproved).
|
||||
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxOrder + 1, nil
|
||||
}
|
||||
|
||||
// Apply 用户申请:status=pending
|
||||
func (s *FriendLinkService) Apply(in FriendLinkInput, applicantUserID uint) (*model.FriendLink, error) {
|
||||
norm, err := validateFriendLinkInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid := applicantUserID
|
||||
now := time.Now()
|
||||
fl := &model.FriendLink{
|
||||
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
|
||||
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
|
||||
Status: FriendLinkStatusPending, SortOrder: 0,
|
||||
ApplicantUserID: &uid, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.Create(fl).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fl, nil
|
||||
}
|
||||
|
||||
// AdminCreate 站长直接新建:status=approved,自动分配 sort_order
|
||||
func (s *FriendLinkService) AdminCreate(in FriendLinkInput) (*model.FriendLink, error) {
|
||||
norm, err := validateFriendLinkInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
order, err := s.nextSortOrder()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
fl := &model.FriendLink{
|
||||
Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL,
|
||||
Category: norm.Category, ReciprocalURL: norm.ReciprocalURL,
|
||||
Status: FriendLinkStatusApproved, SortOrder: order,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.Create(fl).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fl, nil
|
||||
}
|
||||
|
||||
// AdminUpdate 全量更新内容(不改 status/sort_order/applicant)
|
||||
func (s *FriendLinkService) AdminUpdate(id uint, in FriendLinkInput) error {
|
||||
norm, err := validateFriendLinkInput(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := s.db.Model(&model.FriendLink{}).Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"name": norm.Name,
|
||||
"url": norm.URL,
|
||||
"description": norm.Description,
|
||||
"logo_url": norm.LogoURL,
|
||||
"category": norm.Category,
|
||||
"reciprocal_url": norm.ReciprocalURL,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrFriendLinkNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminSetStatus 审批:approved 时自动补 sort_order;rejected 时写 reject_reason
|
||||
func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) error {
|
||||
status = strings.TrimSpace(status)
|
||||
if status != FriendLinkStatusApproved && status != FriendLinkStatusRejected &&
|
||||
status != FriendLinkStatusPending {
|
||||
return ErrFriendLinkInvalid
|
||||
}
|
||||
if status == FriendLinkStatusRejected && utf8.RuneCountInString(rejectReason) > friendLinkMaxReject {
|
||||
return ErrFriendLinkInvalid
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var fl model.FriendLink
|
||||
if err := tx.First(&fl, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrFriendLinkNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
"reject_reason": "",
|
||||
}
|
||||
switch status {
|
||||
case FriendLinkStatusApproved:
|
||||
if fl.SortOrder == 0 {
|
||||
var maxOrder int
|
||||
if err := tx.Model(&model.FriendLink{}).
|
||||
Where("status = ? AND id <> ?", FriendLinkStatusApproved, id).
|
||||
Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder); err != nil {
|
||||
return err
|
||||
}
|
||||
updates["sort_order"] = maxOrder + 1
|
||||
}
|
||||
case FriendLinkStatusRejected:
|
||||
updates["reject_reason"] = rejectReason
|
||||
case FriendLinkStatusPending:
|
||||
// 切回 pending 视为下线,不动 sort_order
|
||||
}
|
||||
return tx.Model(&fl).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDelete 软删除
|
||||
func (s *FriendLinkService) AdminDelete(id uint) error {
|
||||
res := s.db.Delete(&model.FriendLink{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrFriendLinkNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
191
backend/service/header_nav.go
Normal file
191
backend/service/header_nav.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 桌面端 Header 主导航(品牌名右侧的菜单)。
|
||||
// 配置为有序数组,元素顺序即展示顺序;内置项只存 key(文案/图标/地址由前端注册表派生,
|
||||
// 支持伪静态风格),自定义项存文案与地址。后续新增内置页面只需在前后端白名单各注册一项。
|
||||
const (
|
||||
// SettingKeyHeaderNav 缺行/非法=内置默认三项;显式空数组 [] 表示全部隐藏
|
||||
SettingKeyHeaderNav = "header_nav"
|
||||
|
||||
HeaderNavItemBuiltin = "builtin"
|
||||
HeaderNavItemCustom = "custom"
|
||||
|
||||
// MaxHeaderNavItems 菜单项总数上限(内置 + 自定义)
|
||||
MaxHeaderNavItems = 8
|
||||
// MaxHeaderNavLabelRunes 自定义菜单文案长度上限
|
||||
MaxHeaderNavLabelRunes = 16
|
||||
)
|
||||
|
||||
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
|
||||
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
|
||||
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"}
|
||||
|
||||
// HeaderNavItem 顶部导航菜单项:
|
||||
//
|
||||
// builtin:Key 取白名单,Label/URL 留空,地址与高亮由前端按 key 派生;
|
||||
// custom:管理员自定义,Label 必填,URL 为站内路径或 http(s) 外链,NewTab 控制打开方式。
|
||||
type HeaderNavItem struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
NewTab bool `json:"new_tab,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultHeaderNav 内置默认三项(缺行时回落)
|
||||
func DefaultHeaderNav() []HeaderNavItem {
|
||||
out := make([]HeaderNavItem, 0, len(HeaderNavBuiltinKeys))
|
||||
for _, k := range HeaderNavBuiltinKeys {
|
||||
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headerNavBuiltinAllowed(k string) bool {
|
||||
for _, valid := range HeaderNavBuiltinKeys {
|
||||
if k == valid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// headerNavItemIn 持久化读取用:NewTab 用指针区分「未指定(外链默认新开)」与显式 false
|
||||
type headerNavItemIn struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
NewTab *bool `json:"new_tab"`
|
||||
}
|
||||
|
||||
// NormalizeHeaderNav 保存前严格校验:类型/内置 key 非法、自定义文案或地址非法直接报错;
|
||||
// 内置项重复去重(首次出现为准,与 mobile_tabs 一致)
|
||||
func NormalizeHeaderNav(raw []HeaderNavItem) ([]HeaderNavItem, error) {
|
||||
if len(raw) > MaxHeaderNavItems {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
out := make([]HeaderNavItem, 0, len(raw))
|
||||
seenBuiltin := make(map[string]struct{}, len(raw))
|
||||
for _, item := range raw {
|
||||
switch strings.TrimSpace(item.Type) {
|
||||
case HeaderNavItemBuiltin:
|
||||
k := strings.TrimSpace(strings.ToLower(item.Key))
|
||||
if !headerNavBuiltinAllowed(k) {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
if _, dup := seenBuiltin[k]; dup {
|
||||
continue
|
||||
}
|
||||
seenBuiltin[k] = struct{}{}
|
||||
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
|
||||
case HeaderNavItemCustom:
|
||||
label := strings.TrimSpace(item.Label)
|
||||
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
u, ok := NormalizeFooterURL(item.URL)
|
||||
if !ok {
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
out = append(out, HeaderNavItem{
|
||||
Type: HeaderNavItemCustom,
|
||||
Label: label,
|
||||
URL: u,
|
||||
NewTab: item.NewTab,
|
||||
})
|
||||
default:
|
||||
return nil, ErrInvalidSiteSetting
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseHeaderNavJSON 读取时宽容:坏 JSON 回落默认;单条非法跳过;内置重复跳过;
|
||||
// 显式空数组解析为空切片(全部隐藏),由调用方与「缺行」区分
|
||||
func parseHeaderNavJSON(raw string) []HeaderNavItem {
|
||||
var in []headerNavItemIn
|
||||
if err := json.Unmarshal([]byte(raw), &in); err != nil {
|
||||
return DefaultHeaderNav()
|
||||
}
|
||||
out := make([]HeaderNavItem, 0, len(in))
|
||||
seenBuiltin := make(map[string]struct{}, len(in))
|
||||
for _, item := range in {
|
||||
switch strings.TrimSpace(item.Type) {
|
||||
case HeaderNavItemBuiltin:
|
||||
k := strings.TrimSpace(strings.ToLower(item.Key))
|
||||
if !headerNavBuiltinAllowed(k) {
|
||||
continue
|
||||
}
|
||||
if _, dup := seenBuiltin[k]; dup {
|
||||
continue
|
||||
}
|
||||
seenBuiltin[k] = struct{}{}
|
||||
out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k})
|
||||
case HeaderNavItemCustom:
|
||||
label := strings.TrimSpace(item.Label)
|
||||
if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes {
|
||||
continue
|
||||
}
|
||||
u, ok := NormalizeFooterURL(item.URL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
newTab := false
|
||||
if item.NewTab != nil {
|
||||
newTab = *item.NewTab
|
||||
} else if isExternalFooterURL(u) {
|
||||
newTab = true
|
||||
}
|
||||
out = append(out, HeaderNavItem{Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: newTab})
|
||||
}
|
||||
if len(out) >= MaxHeaderNavItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headerNavEqual(a, b []HeaderNavItem) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HeaderNav 读取顶部导航配置;缺行/非法回落默认三项,显式 [] 返回空切片
|
||||
func (s *SettingService) HeaderNav() []HeaderNavItem {
|
||||
v, found, err := s.getValue(SettingKeyHeaderNav)
|
||||
if err != nil || !found || strings.TrimSpace(v) == "" {
|
||||
return DefaultHeaderNav()
|
||||
}
|
||||
return parseHeaderNavJSON(v)
|
||||
}
|
||||
|
||||
// SetHeaderNav 校验并保存顶部导航配置;与默认三项完全一致时删键保持「缺行=默认」语义,
|
||||
// 空数组原样写入(表示全部隐藏)
|
||||
func (s *SettingService) SetHeaderNav(items []HeaderNavItem) error {
|
||||
out, err := NormalizeHeaderNav(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headerNavEqual(out, DefaultHeaderNav()) {
|
||||
return s.deleteKey(SettingKeyHeaderNav)
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.putValue(SettingKeyHeaderNav, string(b))
|
||||
}
|
||||
164
backend/service/header_nav_test.go
Normal file
164
backend/service/header_nav_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHeaderNavTestService(t *testing.T) *SettingService {
|
||||
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.SiteSetting{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewSettingService(db)
|
||||
}
|
||||
|
||||
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
|
||||
s := newHeaderNavTestService(t)
|
||||
got := s.HeaderNav()
|
||||
if len(got) != 3 ||
|
||||
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
|
||||
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
|
||||
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) {
|
||||
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
|
||||
}
|
||||
pub, err := s.Public()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pub.HeaderNav) != 3 {
|
||||
t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetHeaderNavRoundTrip(t *testing.T) {
|
||||
s := newHeaderNavTestService(t)
|
||||
want := []HeaderNavItem{
|
||||
{Type: HeaderNavItemBuiltin, Key: "library"},
|
||||
{Type: HeaderNavItemCustom, Label: "关于", URL: "/p/about", NewTab: false},
|
||||
{Type: HeaderNavItemBuiltin, Key: "home"},
|
||||
{Type: HeaderNavItemCustom, Label: "官网", URL: "https://example.com", NewTab: true},
|
||||
}
|
||||
if err := s.SetHeaderNav(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := s.HeaderNav()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("item %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetHeaderNavEmptyHidesAll(t *testing.T) {
|
||||
s := newHeaderNavTestService(t)
|
||||
if err := s.SetHeaderNav(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.HeaderNav(); len(got) != 0 {
|
||||
t.Fatalf("explicit empty list should hide all menus, got %+v", got)
|
||||
}
|
||||
var rows int64
|
||||
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
|
||||
if rows != 1 {
|
||||
t.Fatalf("empty list should be persisted, rows=%d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetHeaderNavDefaultDeletesKey(t *testing.T) {
|
||||
s := newHeaderNavTestService(t)
|
||||
if err := s.SetHeaderNav([]HeaderNavItem{{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetHeaderNav(DefaultHeaderNav()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows int64
|
||||
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
|
||||
if rows != 0 {
|
||||
t.Fatalf("default config should delete the row, rows=%d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHeaderNavRejectsInvalid(t *testing.T) {
|
||||
longLabel := strings.Repeat("字", MaxHeaderNavLabelRunes+1)
|
||||
cases := [][]HeaderNavItem{
|
||||
{{Type: HeaderNavItemBuiltin, Key: "unknown"}},
|
||||
{{Type: "weird", Key: "home"}},
|
||||
{{Type: HeaderNavItemCustom, Label: "", URL: "/x"}},
|
||||
{{Type: HeaderNavItemCustom, Label: longLabel, URL: "/x"}},
|
||||
{{Type: HeaderNavItemCustom, Label: "x", URL: "javascript:alert(1)"}},
|
||||
{{Type: HeaderNavItemCustom, Label: "x", URL: "relative"}},
|
||||
{{Type: HeaderNavItemCustom, Label: "x", URL: "//evil.com/x"}},
|
||||
}
|
||||
for i, items := range cases {
|
||||
if _, err := NormalizeHeaderNav(items); !errors.Is(err, ErrInvalidSiteSetting) {
|
||||
t.Fatalf("case %d should fail, got %v", i, err)
|
||||
}
|
||||
}
|
||||
tooMany := make([]HeaderNavItem, MaxHeaderNavItems+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = HeaderNavItem{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}
|
||||
}
|
||||
if _, err := NormalizeHeaderNav(tooMany); !errors.Is(err, ErrInvalidSiteSetting) {
|
||||
t.Fatalf("over-cap list should fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) {
|
||||
got, err := NormalizeHeaderNav([]HeaderNavItem{
|
||||
{Type: HeaderNavItemBuiltin, Key: "home"},
|
||||
{Type: HeaderNavItemBuiltin, Key: " HOME "},
|
||||
{Type: HeaderNavItemBuiltin, Key: "library"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Key != "home" || got[1].Key != "library" {
|
||||
t.Fatalf("builtin duplicates should be deduped, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHeaderNavJSONTolerant(t *testing.T) {
|
||||
// 坏 JSON 回落默认
|
||||
if got := parseHeaderNavJSON("{bad"); len(got) != 3 {
|
||||
t.Fatalf("bad json should fall back to defaults, got %+v", got)
|
||||
}
|
||||
// 空数组 = 全部隐藏
|
||||
if got := parseHeaderNavJSON("[]"); len(got) != 0 {
|
||||
t.Fatalf("empty array should be respected, got %+v", got)
|
||||
}
|
||||
// 单条非法跳过;内置重复跳过;外链 new_tab 缺省为 true
|
||||
got := parseHeaderNavJSON(`[
|
||||
{"type":"builtin","key":"home"},
|
||||
{"type":"builtin","key":"nope"},
|
||||
{"type":"builtin","key":"home"},
|
||||
{"type":"custom","label":"","url":"/x"},
|
||||
{"type":"custom","label":"外链","url":"https://example.com"},
|
||||
{"type":"custom","label":"站内","url":"/p/about","new_tab":true}
|
||||
]`)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 valid items, got %+v", got)
|
||||
}
|
||||
if got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) {
|
||||
t.Fatalf("item0 = %+v", got[0])
|
||||
}
|
||||
if got[1].Type != HeaderNavItemCustom || got[1].Label != "外链" || !got[1].NewTab {
|
||||
t.Fatalf("external custom item should default new_tab=true, got %+v", got[1])
|
||||
}
|
||||
if got[2].Label != "站内" || !got[2].NewTab {
|
||||
t.Fatalf("explicit new_tab=true should be kept, got %+v", got[2])
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
hidePwdCookiePrefix = "j13_hp_"
|
||||
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
|
||||
hidePwdCookiePrefix = "j13_hp_"
|
||||
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
|
||||
hidePwdCookieVersion = "1"
|
||||
)
|
||||
|
||||
|
||||
276
backend/service/image_variants.go
Normal file
276
backend/service/image_variants.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"golang.org/x/image/draw"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽>
|
||||
// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 <img srcset> 消费。
|
||||
// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。
|
||||
// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。
|
||||
// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。
|
||||
|
||||
// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致)
|
||||
const VariantWebPQuality = 78
|
||||
|
||||
// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进)
|
||||
var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920}
|
||||
|
||||
// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸)
|
||||
func IsVariantWidth(w int) bool {
|
||||
for _, v := range variantWidths {
|
||||
if v == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁
|
||||
var variantGenLocks sync.Map
|
||||
|
||||
// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型)
|
||||
func extMIME(ext string) string {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// variantCacheFile 变体缓存路径:.thumbs/v/<sha1(url)>.w<w>.webp
|
||||
func (s *UploadService) variantCacheFile(url string, w int) string {
|
||||
sum := sha1.Sum([]byte(url))
|
||||
name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp"
|
||||
return filepath.Join(s.dir, ".thumbs", "v", name)
|
||||
}
|
||||
|
||||
// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在
|
||||
func variantCacheFresh(cache string, modTime time.Time) bool {
|
||||
ti, err := os.Stat(cache)
|
||||
if err != nil || ti.IsDir() {
|
||||
return false
|
||||
}
|
||||
return modTime.IsZero() || !ti.ModTime().Before(modTime)
|
||||
}
|
||||
|
||||
// Variant 返回源图指定宽度的变体字节与 MIME。
|
||||
func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if len(rawURL) == 0 || len(rawURL) > 512 {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
if !IsVariantWidth(width) {
|
||||
return nil, "", errors.New("不支持的尺寸")
|
||||
}
|
||||
|
||||
var (
|
||||
srcData []byte // 远程源预读字节(本地源在锁内读取)
|
||||
srcMIME string
|
||||
srcPath string // 本地源绝对路径
|
||||
modTime time.Time
|
||||
remote bool
|
||||
cache = s.variantCacheFile(rawURL, width)
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(rawURL, "/uploads/"):
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/")))
|
||||
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
dir := filepath.Dir(rel)
|
||||
catOK := false
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Dir == dir {
|
||||
catOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !catOK {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
mime := extMIME(ext)
|
||||
if mime == "" {
|
||||
return nil, "", errors.New("非图片")
|
||||
}
|
||||
srcPath = filepath.Join(s.dir, rel)
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, "", errors.New("文件不存在")
|
||||
}
|
||||
// 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存)
|
||||
if ext == ".gif" || ext == ".svg" || ext == ".ico" {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
return data, mime, err
|
||||
}
|
||||
srcMIME = mime
|
||||
modTime = info.ModTime()
|
||||
|
||||
case strings.HasPrefix(rawURL, "/api/media/"):
|
||||
if s.ops == nil {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
id := strings.TrimPrefix(rawURL, "/api/media/")
|
||||
if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
remote = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
r, mime, e := s.ops.OpenObject(ctx, id, true)
|
||||
if e != nil {
|
||||
return nil, "", errors.New("文件暂不可用")
|
||||
}
|
||||
defer r.Close()
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png", "image/webp":
|
||||
case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon":
|
||||
data, err := io.ReadAll(r)
|
||||
return data, mime, err
|
||||
default:
|
||||
return nil, "", errors.New("非图片")
|
||||
}
|
||||
srcData, e = io.ReadAll(r)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
srcMIME = mime
|
||||
|
||||
default:
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
|
||||
// 快速路径:缓存已新鲜直接返回(无锁)
|
||||
if variantCacheFresh(cache, modTime) {
|
||||
if data, err := os.ReadFile(cache); err == nil {
|
||||
return data, "image/webp", nil
|
||||
}
|
||||
}
|
||||
|
||||
// 并发生成去重:同 url+w 只生成一次
|
||||
sum := sha1.Sum([]byte(rawURL))
|
||||
key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width)
|
||||
mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
lk := mu.(*sync.Mutex)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// double-check:等锁期间可能已由他人生成
|
||||
if variantCacheFresh(cache, modTime) {
|
||||
if data, err := os.ReadFile(cache); err == nil {
|
||||
return data, "image/webp", nil
|
||||
}
|
||||
}
|
||||
|
||||
if !remote {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
srcData = data
|
||||
}
|
||||
|
||||
out, m, err := variantFromBytes(srcData, srcMIME, width)
|
||||
if err != nil {
|
||||
return nil, "", err // 如动图 WebP,调用方回退原图
|
||||
}
|
||||
// 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应
|
||||
if m == "image/webp" {
|
||||
_ = atomicWriteFile(cache, out)
|
||||
}
|
||||
return out, m, nil
|
||||
}
|
||||
|
||||
// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图);
|
||||
// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。
|
||||
func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) {
|
||||
img, err := decodeImageBytes(srcData, srcMIME == "image/webp")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
b := img.Bounds()
|
||||
if b.Dx() <= width {
|
||||
return srcData, srcMIME, nil
|
||||
}
|
||||
nh := max(1, b.Dy()*width/b.Dx())
|
||||
dst := scaleImage(img, width, nh)
|
||||
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return out, "image/webp", nil
|
||||
}
|
||||
|
||||
// ---------- 与 MediaThumb 共用的图片处理核心 ----------
|
||||
|
||||
// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器
|
||||
func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) {
|
||||
if isWebP {
|
||||
return xwebp.Decode(bytes.NewReader(data))
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
return img, err
|
||||
}
|
||||
|
||||
// scaleImage CatmullRom 高质量缩放到指定宽高
|
||||
func scaleImage(img image.Image, nw, nh int) *image.RGBA {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// encodeLossyWebP 有损 WebP 编码
|
||||
func encodeLossyWebP(img image.Image, quality int) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// atomicWriteFile 临时文件 + rename 原子落盘
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".partial"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
282
backend/service/image_variants_test.go
Normal file
282
backend/service/image_variants_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// solidImg 生成渐变纯色测试图
|
||||
func solidImg(w, h int) *image.RGBA {
|
||||
src := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
|
||||
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
f, err := os.Create(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := encode(f); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeJPEG(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
|
||||
}
|
||||
}
|
||||
|
||||
func encodePNG(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return png.Encode(w, img)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeWebP(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
|
||||
}
|
||||
}
|
||||
|
||||
func encodeGIF(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return gif.Encode(w, img, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeVariant 解码返回的 WebP 变体
|
||||
func decodeVariant(t *testing.T, data []byte) image.Image {
|
||||
t.Helper()
|
||||
img, err := xwebp.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode variant webp: %v", err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestIsVariantWidth(t *testing.T) {
|
||||
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
|
||||
t.Fatal("whitelist endpoints should be valid")
|
||||
}
|
||||
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
|
||||
t.Fatal("non-whitelist width should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRejectsInvalidInput(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
w int
|
||||
}{
|
||||
{"非法尺寸", "/uploads/images/a.webp", 100},
|
||||
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
|
||||
{"非白名单目录", "/uploads/secret/a.webp", 96},
|
||||
{"非图片扩展名", "/uploads/images/a.txt", 96},
|
||||
{"外部地址", "https://evil.com/a.webp", 96},
|
||||
{"空地址", "", 96},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantMissingFile(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
|
||||
t.Fatal("missing source should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantPassthroughSmallImage(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/small.webp"
|
||||
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
|
||||
|
||||
data, m, err := s.Variant(url, 96)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
if m != "image/webp" {
|
||||
t.Fatalf("want webp mime, got %s", m)
|
||||
}
|
||||
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
|
||||
if !bytes.Equal(data, src) {
|
||||
t.Fatal("small image should be returned byte-for-byte")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantResizesByWidthLandscape(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/wide.jpg"
|
||||
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||
|
||||
data, m, err := s.Variant(url, 384)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
if m != "image/webp" {
|
||||
t.Fatalf("want webp, got %s", m)
|
||||
}
|
||||
b := decodeVariant(t, data).Bounds()
|
||||
if b.Dx() != 384 || b.Dy() != 192 {
|
||||
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantResizesByWidthPortrait(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/tall.png"
|
||||
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
|
||||
|
||||
data, _, err := s.Variant(url, 256)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
b := decodeVariant(t, data).Bounds()
|
||||
if b.Dx() != 256 || b.Dy() != 512 {
|
||||
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
mime string
|
||||
body []byte
|
||||
}{
|
||||
{"gif", "/uploads/images/a.gif", "image/gif", nil},
|
||||
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
|
||||
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.body != nil {
|
||||
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
|
||||
_, err := w.Write(tc.body)
|
||||
return err
|
||||
})
|
||||
} else {
|
||||
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
|
||||
}
|
||||
data, m, err := s.Variant(tc.url, 96)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
if m != tc.mime {
|
||||
t.Fatalf("want %s, got %s", tc.mime, m)
|
||||
}
|
||||
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
|
||||
if !bytes.Equal(data, src) {
|
||||
t.Fatalf("%s should pass through unchanged", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantCachedAndReused(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/cache.jpg"
|
||||
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||
|
||||
first, m, err := s.Variant(url, 384)
|
||||
if err != nil {
|
||||
t.Fatalf("first variant: %v", err)
|
||||
}
|
||||
if m != "image/webp" {
|
||||
t.Fatalf("want webp, got %s", m)
|
||||
}
|
||||
cache := s.variantCacheFile(url, 384)
|
||||
if info, err := os.Stat(cache); err != nil || info.IsDir() {
|
||||
t.Fatalf("cache file should exist: %v", err)
|
||||
}
|
||||
second, _, err := s.Variant(url, 384)
|
||||
if err != nil {
|
||||
t.Fatalf("second variant: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("cached variant should be byte-identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantCacheFreshness(t *testing.T) {
|
||||
_, dir := newUploadTestService(t)
|
||||
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
|
||||
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 远程对象:modTime 零值,存在即新鲜
|
||||
if !variantCacheFresh(cache, time.Time{}) {
|
||||
t.Fatal("remote cache should be fresh when file exists")
|
||||
}
|
||||
// 本地源:mtime 新于缓存 → 不新鲜
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
if variantCacheFresh(cache, future) {
|
||||
t.Fatal("cache should be stale vs newer source mtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRemoteRejectsBadID(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
s.ops = NewOperations(s.db, &config.Config{})
|
||||
|
||||
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
|
||||
t.Fatal("id with slash should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRemoteUnavailable(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
s.ops = NewOperations(s.db, &config.Config{})
|
||||
|
||||
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
|
||||
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
|
||||
t.Fatal("missing remote object should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRemoteWithoutOps(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
|
||||
t.Fatal("remote url without ops wired should error")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const (
|
||||
PeriodMonth = "month"
|
||||
PeriodAll = "all"
|
||||
|
||||
MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分
|
||||
MetricAll = "all" // 综合:总榜=累计积分,周/月=周期内获得积分
|
||||
MetricPost = "post" // 发帖最多
|
||||
MetricComment = "comment" // 回复最多
|
||||
MetricLike = "like" // 获赞最多
|
||||
@@ -112,6 +112,41 @@ func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// earnedPoints 周期内累计获得积分:point_ledgers 正入账合计(排除 bounty_refund,
|
||||
// 与 users.total_points 启动回填口径一致),仅统计未封禁用户。
|
||||
// 签到、发帖/回复奖励、被推荐、悬赏采纳等均按实际入账分值计入贡献值
|
||||
func (s *LeaderboardService) earnedPoints(from, to *time.Time) (map[uint]int64, error) {
|
||||
clause := ""
|
||||
var timeArgs []interface{}
|
||||
if from != nil {
|
||||
clause += " AND l.created_at >= ?"
|
||||
timeArgs = append(timeArgs, *from)
|
||||
}
|
||||
if to != nil {
|
||||
clause += " AND l.created_at < ?"
|
||||
timeArgs = append(timeArgs, *to)
|
||||
}
|
||||
sql := "SELECT l.user_id AS user_id, SUM(l.delta) AS pts FROM point_ledgers l" +
|
||||
" JOIN users AS u ON u.id = l.user_id" +
|
||||
" WHERE l.delta > 0 AND l.reason <> ? AND u.banned = ? AND u.deleted_at IS NULL" + clause +
|
||||
" GROUP BY l.user_id"
|
||||
args := []interface{}{model.PointReasonBountyRefund, false}
|
||||
args = append(args, timeArgs...)
|
||||
|
||||
var rows []struct {
|
||||
UserID uint
|
||||
Pts int64
|
||||
}
|
||||
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uint]int64, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.UserID] = r.Pts
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数,
|
||||
// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计)
|
||||
func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) {
|
||||
@@ -167,7 +202,7 @@ func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, e
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween)
|
||||
// scoreOf 细分指标 → 排名得分;「全部」维度走积分口径(见 boardBetween / earnedPoints)
|
||||
func scoreOf(metric string, st statsRow) int64 {
|
||||
switch metric {
|
||||
case MetricPost:
|
||||
@@ -178,9 +213,8 @@ func scoreOf(metric string, st statsRow) int64 {
|
||||
return st.LikeCount
|
||||
case MetricAnswer:
|
||||
return st.AnswerCount
|
||||
default:
|
||||
return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// entryFromUser 由用户行构造榜单条目(不含统计)
|
||||
@@ -214,7 +248,8 @@ func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *tim
|
||||
}
|
||||
|
||||
// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。
|
||||
// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。
|
||||
// 「全部」维度走积分口径(总榜=total_points,周/月=周期内获得积分,见 earnedPoints),
|
||||
// 细分维度走统计聚合口径。
|
||||
func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) {
|
||||
if metric == MetricAll && from == nil {
|
||||
var users []model.User
|
||||
@@ -245,9 +280,29 @@ func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]LeaderboardEntry, 0, len(stats))
|
||||
// 周/月·全部:贡献值=周期内累计获得积分,候选集并入仅签到/获奖无内容的用户
|
||||
usePts := metric == MetricAll && from != nil
|
||||
pts := map[uint]int64{}
|
||||
if usePts {
|
||||
if pts, err = s.earnedPoints(from, to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cand := make(map[uint]statsRow, len(stats)+len(pts))
|
||||
for id, r := range stats {
|
||||
cand[id] = r
|
||||
}
|
||||
for id := range pts {
|
||||
if _, ok := cand[id]; !ok {
|
||||
cand[id] = statsRow{}
|
||||
}
|
||||
}
|
||||
entries := make([]LeaderboardEntry, 0, len(cand))
|
||||
for id, r := range cand {
|
||||
score := scoreOf(metric, r)
|
||||
if usePts {
|
||||
score = pts[id]
|
||||
}
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
67
backend/service/leaderboard_test.go
Normal file
67
backend/service/leaderboard_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 贡献值口径:earnedPoints 只累计窗口内正入账积分(排除 bounty_refund),
|
||||
// 支出、悬赏退回、封禁用户、窗口外入账均不计
|
||||
func TestEarnedPointsWindowAndExclusions(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.PointLedger{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
weekAgo := now.AddDate(0, 0, -7)
|
||||
users := []*model.User{
|
||||
{Username: "earner", Password: "x"},
|
||||
{Username: "refund_only", Password: "x"},
|
||||
{Username: "banned", Password: "x", Banned: true},
|
||||
{Username: "old_earner", Password: "x"},
|
||||
}
|
||||
for _, u := range users {
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("seed user %s: %v", u.Username, err)
|
||||
}
|
||||
}
|
||||
a, d := users[0].ID, users[3].ID
|
||||
ledgers := []model.PointLedger{
|
||||
{UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now},
|
||||
{UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now},
|
||||
{UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)},
|
||||
{UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计
|
||||
{UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计
|
||||
{UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计
|
||||
{UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外
|
||||
}
|
||||
if err := db.Create(&ledgers).Error; err != nil {
|
||||
t.Fatalf("seed ledgers: %v", err)
|
||||
}
|
||||
|
||||
s := NewLeaderboardService(db)
|
||||
from := weekAgo
|
||||
pts, err := s.earnedPoints(&from, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("earnedPoints: %v", err)
|
||||
}
|
||||
if len(pts) != 1 || pts[a] != 10 {
|
||||
t.Fatalf("周窗内应仅 earner 计 10 分,got %v", pts)
|
||||
}
|
||||
|
||||
all, err := s.earnedPoints(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("earnedPoints(all): %v", err)
|
||||
}
|
||||
if len(all) != 2 || all[a] != 10 || all[d] != 5 {
|
||||
t.Fatalf("全窗口:earner=10、old_earner=5,退回/封禁不计,got %v", all)
|
||||
}
|
||||
}
|
||||
@@ -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,38 +133,47 @@ 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"`
|
||||
}
|
||||
|
||||
// LegacyBoardReport 板块导入明细
|
||||
type LegacyBoardReport struct {
|
||||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||||
Reused int `json:"reused"` // 复用同名已有板块
|
||||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||||
Reused int `json:"reused"` // 复用同名已有板块
|
||||
Names []string `json:"names,omitempty"` // 新建板块名
|
||||
}
|
||||
|
||||
// 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,25 +744,26 @@ 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++
|
||||
continue
|
||||
}
|
||||
post := model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: authorID,
|
||||
Title: truncateRunesN(title, 256),
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
ViewCount: p.ViewCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
BoardID: boardID,
|
||||
UserID: authorID,
|
||||
Title: truncateRunesN(title, 256),
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
ViewCount: p.ViewCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if err := s.db.Create(&post).Error; err != nil {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))
|
||||
@@ -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
|
||||
}
|
||||
652
backend/service/library.go
Normal file
652
backend/service/library.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 书库文件扩展名白名单(小写、不含点);附件仅提供下载分发,
|
||||
// 书籍正文走章节阅读,不做在线预览
|
||||
var libraryAllowedExts = map[string]struct{}{
|
||||
"html": {}, "htm": {}, "pdf": {}, "epub": {}, "mobi": {}, "azw3": {},
|
||||
"zip": {}, "7z": {}, "rar": {}, "txt": {}, "md": {},
|
||||
"doc": {}, "docx": {}, "ppt": {}, "pptx": {},
|
||||
}
|
||||
|
||||
const (
|
||||
MaxLibraryFilesPerDoc = 20
|
||||
libraryDescriptionMax = 20000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLibraryNotFound = errors.New("书库条目不存在")
|
||||
ErrLibraryExtDenied = errors.New("不支持该文档格式")
|
||||
ErrLibraryTooMany = errors.New("文件数量超过上限")
|
||||
ErrLibraryNotDeleted = errors.New("仅已删除的条目可彻底删除")
|
||||
)
|
||||
|
||||
// LibraryExtAllowed 上传扩展名是否在书库白名单内
|
||||
func LibraryExtAllowed(ext string) bool {
|
||||
_, ok := libraryAllowedExts[strings.ToLower(strings.TrimPrefix(ext, "."))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
|
||||
var libraryDocWriteFields = []string{
|
||||
"Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight",
|
||||
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
|
||||
}
|
||||
|
||||
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
|
||||
type LibraryCreator struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
|
||||
type LibraryDocDetail struct {
|
||||
model.LibraryDoc
|
||||
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
|
||||
Files []model.LibraryFile `json:"files"`
|
||||
Sections []model.LibrarySection `json:"sections"`
|
||||
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
|
||||
}
|
||||
|
||||
// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要)
|
||||
type LibraryDocListItem struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
FileCount int `json:"file_count"`
|
||||
Files []model.LibraryFile `json:"files"`
|
||||
Creator *LibraryCreator `json:"creator,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LibraryInput 创建/更新书库条目入参
|
||||
type LibraryInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"` // 书籍作者(自由文本,可空)
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
|
||||
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
|
||||
Published *bool `json:"published"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
|
||||
}
|
||||
|
||||
func (in *LibraryInput) normalize() error {
|
||||
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
|
||||
if in.Slug == "" {
|
||||
return errors.New("slug 不能为空")
|
||||
}
|
||||
if len(in.Slug) > 64 {
|
||||
return errors.New("slug 不能超过 64 字符")
|
||||
}
|
||||
if !sitePageSlugRe.MatchString(in.Slug) {
|
||||
return errors.New("slug 仅允许小写字母、数字与连字符")
|
||||
}
|
||||
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.Title == "" {
|
||||
return errors.New("标题不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(in.Title) > 200 {
|
||||
return errors.New("标题不能超过 200 字")
|
||||
}
|
||||
|
||||
in.Author = strings.TrimSpace(in.Author)
|
||||
if utf8.RuneCountInString(in.Author) > 100 {
|
||||
return errors.New("作者不能超过 100 字")
|
||||
}
|
||||
|
||||
in.Description = strings.TrimSpace(in.Description)
|
||||
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
|
||||
return errors.New("介绍不能超过 20000 字")
|
||||
}
|
||||
|
||||
in.CoverURL = strings.TrimSpace(in.CoverURL)
|
||||
if utf8.RuneCountInString(in.CoverURL) > 512 {
|
||||
return errors.New("封面地址过长")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
|
||||
d.Slug = in.Slug
|
||||
d.Title = in.Title
|
||||
d.Author = in.Author
|
||||
d.Description = in.Description
|
||||
d.CoverURL = in.CoverURL
|
||||
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
|
||||
if in.CoverURL == "" {
|
||||
d.CoverWidth = 0
|
||||
d.CoverHeight = 0
|
||||
} else {
|
||||
d.CoverWidth = in.CoverWidth
|
||||
d.CoverHeight = in.CoverHeight
|
||||
}
|
||||
if in.Published != nil {
|
||||
d.Published = *in.Published
|
||||
}
|
||||
if in.SortOrder != nil {
|
||||
d.SortOrder = *in.SortOrder
|
||||
}
|
||||
if in.EntriesAuto != nil {
|
||||
d.EntriesAuto = *in.EntriesAuto
|
||||
}
|
||||
}
|
||||
|
||||
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
|
||||
type LibraryService struct {
|
||||
db *gorm.DB
|
||||
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
|
||||
uploadsDir string // data/uploads:封面回填时解析本地封面图
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
|
||||
return &LibraryService{db: db, dir: filepath.Join(dataDir, "library")}
|
||||
}
|
||||
|
||||
// WithSetting 注入站点设置(文件大小上限复用附件设置)
|
||||
func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
|
||||
s.setting = setting
|
||||
return s
|
||||
}
|
||||
|
||||
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
|
||||
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
|
||||
s.uploadsDir = uploadsDir
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LibraryService) EnsureDir() error {
|
||||
return os.MkdirAll(s.dir, 0o755)
|
||||
}
|
||||
|
||||
func (s *LibraryService) FilePath(f *model.LibraryFile) string {
|
||||
return filepath.Join(s.dir, f.StoredName)
|
||||
}
|
||||
|
||||
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
|
||||
// active=在用条目占用;deleted=已删除条目占用
|
||||
func (s *LibraryService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
|
||||
q := s.db.Unscoped().Model(&model.LibraryDoc{}).Where("slug = ?", slug).Select("deleted_at")
|
||||
if excludeID > 0 {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var rows []struct {
|
||||
DeletedAt gorm.DeletedAt
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DeletedAt.Valid {
|
||||
deleted = true
|
||||
} else {
|
||||
active = true
|
||||
}
|
||||
}
|
||||
return active, deleted, nil
|
||||
}
|
||||
|
||||
// ---------- 条目:管理端 ----------
|
||||
|
||||
// ListAll 管理后台全部条目(含未发布与已软删,带文件;已删条目仅供查看与彻底删除)
|
||||
func (s *LibraryService) ListAll() ([]LibraryDocDetail, error) {
|
||||
var docs []model.LibraryDoc
|
||||
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(200).Find(&docs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.attachFiles(docs), nil
|
||||
}
|
||||
|
||||
// Get 管理后台按 ID 取条目
|
||||
func (s *LibraryService) Get(id uint) (*LibraryDocDetail, error) {
|
||||
var d model.LibraryDoc
|
||||
if err := s.db.First(&d, id).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
out := s.attachFiles([]model.LibraryDoc{d})
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail {
|
||||
out := make([]LibraryDocDetail, 0, len(docs))
|
||||
if len(docs) == 0 {
|
||||
return out
|
||||
}
|
||||
ids := make([]uint, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
var files []model.LibraryFile
|
||||
if err := s.db.Where("doc_id IN ?", ids).
|
||||
Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
||||
files = nil
|
||||
}
|
||||
byDoc := make(map[uint][]model.LibraryFile, len(docs))
|
||||
for _, f := range files {
|
||||
byDoc[f.DocID] = append(byDoc[f.DocID], f)
|
||||
}
|
||||
var sections []model.LibrarySection
|
||||
if err := s.db.Where("doc_id IN ?", ids).
|
||||
Order("sort_order ASC, id ASC").Find(§ions).Error; err != nil {
|
||||
sections = nil
|
||||
}
|
||||
secByDoc := make(map[uint][]model.LibrarySection, len(docs))
|
||||
for _, sec := range sections {
|
||||
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
|
||||
}
|
||||
creatorByID := s.creatorMap(docs)
|
||||
for _, d := range docs {
|
||||
fs := byDoc[d.ID]
|
||||
if fs == nil {
|
||||
fs = []model.LibraryFile{}
|
||||
}
|
||||
ss := secByDoc[d.ID]
|
||||
if ss == nil {
|
||||
ss = []model.LibrarySection{}
|
||||
}
|
||||
out = append(out, LibraryDocDetail{
|
||||
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
|
||||
Creator: creatorByID[d.CreatorID],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
|
||||
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
|
||||
ids := make([]uint, 0, len(docs))
|
||||
seen := map[uint]bool{}
|
||||
for _, d := range docs {
|
||||
if d.CreatorID > 0 && !seen[d.CreatorID] {
|
||||
seen[d.CreatorID] = true
|
||||
ids = append(ids, d.CreatorID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[uint]*LibraryCreator{}
|
||||
}
|
||||
var users []model.User
|
||||
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return map[uint]*LibraryCreator{}
|
||||
}
|
||||
m := make(map[uint]*LibraryCreator, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) {
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
active, deleted, err := s.slugTaken(in.Slug, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active {
|
||||
return nil, errors.New("slug 已被占用")
|
||||
}
|
||||
if deleted {
|
||||
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||
}
|
||||
d := &model.LibraryDoc{Published: false, SortOrder: 0}
|
||||
if len(creatorID) > 0 {
|
||||
d.CreatorID = creatorID[0]
|
||||
}
|
||||
in.applyTo(d)
|
||||
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, error) {
|
||||
var d model.LibraryDoc
|
||||
if err := s.db.First(&d, id).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
active, deleted, err := s.slugTaken(in.Slug, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active {
|
||||
return nil, errors.New("slug 已被占用")
|
||||
}
|
||||
if deleted {
|
||||
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||
}
|
||||
in.applyTo(&d)
|
||||
// map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列
|
||||
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
|
||||
"slug": d.Slug,
|
||||
"title": d.Title,
|
||||
"author": d.Author,
|
||||
"description": d.Description,
|
||||
"cover_url": d.CoverURL,
|
||||
"cover_width": d.CoverWidth,
|
||||
"cover_height": d.CoverHeight,
|
||||
"published": d.Published,
|
||||
"sort_order": d.SortOrder,
|
||||
"entries_auto": d.EntriesAuto,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
|
||||
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
|
||||
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
|
||||
return "", false
|
||||
}
|
||||
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
|
||||
root := filepath.Clean(s.uploadsDir)
|
||||
clean := filepath.Clean(abs)
|
||||
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
|
||||
func measureCoverReader(r io.Reader) (w, h int, err error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||
}
|
||||
|
||||
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
|
||||
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
|
||||
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
|
||||
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(d.CoverURL)
|
||||
var reader io.ReadCloser
|
||||
switch {
|
||||
case strings.HasPrefix(url, "/uploads/"):
|
||||
abs, ok := s.resolveCoverPath(url)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reader = f
|
||||
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reader = resp.Body
|
||||
default:
|
||||
return
|
||||
}
|
||||
w, h, err := measureCoverReader(reader)
|
||||
reader.Close()
|
||||
if err != nil || w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.db.Model(d).Updates(map[string]interface{}{
|
||||
"cover_width": w,
|
||||
"cover_height": h,
|
||||
}).Error; err != nil {
|
||||
return
|
||||
}
|
||||
d.CoverWidth, d.CoverHeight = w, h
|
||||
}
|
||||
|
||||
func (s *LibraryService) Delete(id uint) error {
|
||||
result := s.db.Delete(&model.LibraryDoc{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrLibraryNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Purge 彻底删除已软删的条目(章节/文件行与磁盘文件一并清除,slug 释放可复用)
|
||||
func (s *LibraryService) Purge(id uint) error {
|
||||
var d model.LibraryDoc
|
||||
if err := s.db.Unscoped().First(&d, id).Error; err != nil {
|
||||
return ErrLibraryNotFound
|
||||
}
|
||||
if !d.DeletedAt.Valid {
|
||||
return ErrLibraryNotDeleted
|
||||
}
|
||||
// 先取出文件清单(含此前单独软删的文件行),事务成功后统一删磁盘
|
||||
var files []model.LibraryFile
|
||||
if err := s.db.Unscoped().Where("doc_id = ?", id).Find(&files).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Delete(&model.LibraryDoc{}, id).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 事务外尽力删除磁盘文件(同 DeleteFile 惯例,失败不影响返回)
|
||||
for i := range files {
|
||||
_ = os.Remove(s.FilePath(&files[i]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 条目:公开端 ----------
|
||||
|
||||
// ListPublished 已发布条目目录(含文件概要,不含 description 全文)
|
||||
func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
|
||||
details, err := s.publishedDetails()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]LibraryDocListItem, 0, len(details))
|
||||
for _, d := range details {
|
||||
out = append(out, LibraryDocListItem{
|
||||
ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description,
|
||||
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
|
||||
Creator: d.Creator,
|
||||
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPublishedBySlug 公开详情(仅已发布)
|
||||
func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, error) {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
var d model.LibraryDoc
|
||||
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
|
||||
s.ensureCoverDimensions(&d)
|
||||
out := s.attachFiles([]model.LibraryDoc{d})
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) publishedDetails() ([]LibraryDocDetail, error) {
|
||||
var docs []model.LibraryDoc
|
||||
if err := s.db.Where("published = ?", true).
|
||||
Order("sort_order ASC, id ASC").Limit(100).Find(&docs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.attachFiles(docs), nil
|
||||
}
|
||||
|
||||
// ---------- 文件 ----------
|
||||
|
||||
func (s *LibraryService) maxBytes() int64 {
|
||||
if s.setting == nil {
|
||||
return FileMaxBytes
|
||||
}
|
||||
n, err := s.setting.AttachmentMaxBytes()
|
||||
if err != nil || n < 1 {
|
||||
return FileMaxBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AddFile 上传文件到条目(落盘 data/library + 建行)
|
||||
func (s *LibraryService) AddFile(docID, userID uint, originalName string, src io.Reader) (*model.LibraryFile, error) {
|
||||
if src == nil {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
var doc model.LibraryDoc
|
||||
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
|
||||
name := sanitizeFilename(originalName)
|
||||
if name == "" {
|
||||
name = "file"
|
||||
}
|
||||
ext := strings.TrimPrefix(ExtOfFilename(name), ".")
|
||||
if !LibraryExtAllowed(ext) {
|
||||
return nil, ErrLibraryExtDenied
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := s.db.Model(&model.LibraryFile{}).Where("doc_id = ?", docID).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= MaxLibraryFilesPerDoc {
|
||||
return nil, ErrLibraryTooMany
|
||||
}
|
||||
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := hex.EncodeToString(raw) + "." + ext
|
||||
full := filepath.Join(s.dir, stored)
|
||||
|
||||
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxB := s.maxBytes()
|
||||
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, copyErr
|
||||
}
|
||||
if written == 0 {
|
||||
_ = os.Remove(full)
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if written > maxB {
|
||||
_ = os.Remove(full)
|
||||
mb := int(maxB >> 20)
|
||||
if mb < 1 {
|
||||
mb = 1
|
||||
}
|
||||
return nil, errors.New("文件过大:不能超过 " + strconv.Itoa(mb) + "MB")
|
||||
}
|
||||
|
||||
file := &model.LibraryFile{
|
||||
DocID: docID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
Ext: ext,
|
||||
MIME: MimeForAttachmentExt("." + ext),
|
||||
Size: int(written),
|
||||
}
|
||||
if err := s.db.Create(file).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件(软删行 + 磁盘尽力删除)
|
||||
func (s *LibraryService) DeleteFile(id uint) error {
|
||||
var f model.LibraryFile
|
||||
if err := s.db.First(&f, id).Error; err != nil {
|
||||
return ErrLibraryNotFound
|
||||
}
|
||||
if err := s.db.Delete(&f).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(s.FilePath(&f))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPublicFile 公开取文件(要求所属条目存在且已发布;软删条目的文件不可达)
|
||||
func (s *LibraryService) GetPublicFile(id uint) (*model.LibraryFile, error) {
|
||||
var f model.LibraryFile
|
||||
if err := s.db.First(&f, id).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
if f.DocID == 0 {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
var n int64
|
||||
if err := s.db.Model(&model.LibraryDoc{}).
|
||||
Where("id = ? AND published = ?", f.DocID, true).Count(&n).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// IncrDownload 下载计数原子递增
|
||||
func (s *LibraryService) IncrDownload(id uint) {
|
||||
s.db.Model(&model.LibraryFile{}).Where("id = ?", id).
|
||||
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||||
}
|
||||
369
backend/service/library_export.go
Normal file
369
backend/service/library_export.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package service
|
||||
|
||||
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
|
||||
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
|
||||
//
|
||||
// 单本 ZIP 布局:
|
||||
//
|
||||
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
|
||||
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
|
||||
// files/<stored-name> 附件原文件
|
||||
//
|
||||
// 全库 ZIP 布局:
|
||||
//
|
||||
// library.json 索引(每本书的目录位置)
|
||||
// docs/<slug>/book.json 各书清单
|
||||
// docs/<slug>/cover.<ext>
|
||||
// docs/<slug>/files/<stored-name>
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
const (
|
||||
libraryExportFormat = "jiang13-library" // 全库包
|
||||
libraryExportBookFormat = "jiang13-library-book" // 单本书包
|
||||
libraryExportVersion = 1
|
||||
)
|
||||
|
||||
// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致)
|
||||
var libraryExportCoverExts = map[string]struct{}{
|
||||
"jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {},
|
||||
}
|
||||
|
||||
// libraryExportFile 附件清单条目
|
||||
type libraryExportFile struct {
|
||||
Name string `json:"name"` // 原始文件名
|
||||
Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空)
|
||||
Ext string `json:"ext"`
|
||||
MIME string `json:"mime"`
|
||||
Size int `json:"size"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失
|
||||
}
|
||||
|
||||
// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key)
|
||||
type libraryExportSection struct {
|
||||
Key string `json:"key"`
|
||||
ParentKey string `json:"parent_key,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// libraryExportBook 单本书的完整清单
|
||||
type libraryExportBook struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径
|
||||
CoverWidth int `json:"cover_width"`
|
||||
CoverHeight int `json:"cover_height"`
|
||||
Published bool `json:"published"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
EntriesAuto bool `json:"entries_auto"`
|
||||
|
||||
Files []libraryExportFile `json:"files"`
|
||||
Sections []libraryExportSection `json:"sections"`
|
||||
}
|
||||
|
||||
// libraryExportIndexEntry 全库索引中的单本概要
|
||||
type libraryExportIndexEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Dir string `json:"dir"` // 相对 ZIP 根的目录
|
||||
FileCount int `json:"file_count"`
|
||||
SectionCount int `json:"section_count"`
|
||||
}
|
||||
|
||||
// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引
|
||||
type libraryExportManifest struct {
|
||||
Format string `json:"format"`
|
||||
FormatVersion int `json:"format_version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Doc *libraryExportBook `json:"doc,omitempty"`
|
||||
Docs []libraryExportIndexEntry `json:"docs,omitempty"`
|
||||
}
|
||||
|
||||
// preparedFile 已打开、待流式拷入 ZIP 的附件
|
||||
type preparedFile struct {
|
||||
zipPath string
|
||||
reader *os.File
|
||||
}
|
||||
|
||||
// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄)
|
||||
type preparedBook struct {
|
||||
meta libraryExportBook
|
||||
dir string // ZIP 内目录前缀(单本包为空串 = 根目录)
|
||||
coverPath string // ZIP 内封面路径(空 = 不内嵌)
|
||||
coverReader *os.File
|
||||
files []preparedFile // 仅磁盘存在的附件
|
||||
}
|
||||
|
||||
func (b *preparedBook) closeReaders() {
|
||||
if b.coverReader != nil {
|
||||
_ = b.coverReader.Close()
|
||||
}
|
||||
for i := range b.files {
|
||||
_ = b.files[i].reader.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// LibraryExport 已完成装载、可直接流式写出的导出包
|
||||
type LibraryExport struct {
|
||||
Filename string
|
||||
full bool
|
||||
exportedAt time.Time
|
||||
books []preparedBook
|
||||
}
|
||||
|
||||
// Close 释放导出过程中打开的全部文件句柄
|
||||
func (e *LibraryExport) Close() {
|
||||
for i := range e.books {
|
||||
e.books[i].closeReaders()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。
|
||||
// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。
|
||||
func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) {
|
||||
d, err := s.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := s.prepareBook(d, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := d.Slug
|
||||
if name == "" {
|
||||
name = "book"
|
||||
}
|
||||
return &LibraryExport{
|
||||
Filename: name + ".zip",
|
||||
full: false,
|
||||
exportedAt: time.Now(),
|
||||
books: []preparedBook{*b},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序)
|
||||
func (s *LibraryService) BuildAllExport() (*LibraryExport, error) {
|
||||
var docs []model.LibraryDoc
|
||||
if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details := s.attachFiles(docs)
|
||||
exp := &LibraryExport{
|
||||
Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip",
|
||||
full: true,
|
||||
exportedAt: time.Now(),
|
||||
books: make([]preparedBook, 0, len(details)),
|
||||
}
|
||||
for i := range details {
|
||||
// slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底
|
||||
dir := "docs/" + details[i].Slug + "/"
|
||||
if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) {
|
||||
exp.Close()
|
||||
return nil, errors.New("条目 slug 含非法字符,无法导出")
|
||||
}
|
||||
b, err := s.prepareBook(&details[i], dir)
|
||||
if err != nil {
|
||||
exp.Close()
|
||||
return nil, err
|
||||
}
|
||||
exp.books = append(exp.books, *b)
|
||||
}
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀)
|
||||
func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) {
|
||||
b := &preparedBook{dir: dir}
|
||||
b.meta = libraryExportBook{
|
||||
ID: d.ID,
|
||||
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
Slug: d.Slug,
|
||||
Title: d.Title,
|
||||
Author: d.Author,
|
||||
Description: d.Description,
|
||||
CoverURL: d.CoverURL,
|
||||
CoverWidth: d.CoverWidth,
|
||||
CoverHeight: d.CoverHeight,
|
||||
Published: d.Published,
|
||||
SortOrder: d.SortOrder,
|
||||
EntriesAuto: d.EntriesAuto,
|
||||
Files: make([]libraryExportFile, 0, len(d.Files)),
|
||||
Sections: make([]libraryExportSection, 0, len(d.Sections)),
|
||||
}
|
||||
|
||||
// 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序)
|
||||
keyByID := make(map[uint]string, len(d.Sections))
|
||||
for i, sec := range d.Sections {
|
||||
keyByID[sec.ID] = "s" + strconv.Itoa(i)
|
||||
}
|
||||
for _, sec := range d.Sections {
|
||||
m := libraryExportSection{
|
||||
Key: keyByID[sec.ID],
|
||||
Title: sec.Title,
|
||||
Content: sec.Content,
|
||||
SortOrder: sec.SortOrder,
|
||||
}
|
||||
if sec.ParentID != nil {
|
||||
m.ParentKey = keyByID[*sec.ParentID]
|
||||
}
|
||||
b.meta.Sections = append(b.meta.Sections, m)
|
||||
}
|
||||
|
||||
// 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径
|
||||
for _, f := range d.Files {
|
||||
m := libraryExportFile{
|
||||
Name: f.Name,
|
||||
Ext: f.Ext,
|
||||
MIME: f.MIME,
|
||||
Size: f.Size,
|
||||
DownloadCount: f.DownloadCount,
|
||||
SortOrder: f.SortOrder,
|
||||
}
|
||||
full := filepath.Join(s.dir, f.StoredName)
|
||||
if r, err := os.Open(full); err == nil {
|
||||
m.Stored = "files/" + f.StoredName
|
||||
b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r})
|
||||
} else {
|
||||
m.Missing = true
|
||||
}
|
||||
b.meta.Files = append(b.meta.Files, m)
|
||||
}
|
||||
|
||||
// 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL
|
||||
if rel, ok := s.resolveCoverPath(d.CoverURL); ok {
|
||||
if ext := coverExtOf(d.CoverURL); ext != "" {
|
||||
if r, err := os.Open(rel); err == nil {
|
||||
b.coverReader = r
|
||||
b.coverPath = dir + "cover." + ext
|
||||
b.meta.CoverFile = "cover." + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。
|
||||
func (e *LibraryExport) WriteZip(w io.Writer) error {
|
||||
defer e.Close()
|
||||
zw := zip.NewWriter(w)
|
||||
|
||||
index := make([]libraryExportIndexEntry, 0, len(e.books))
|
||||
for i := range e.books {
|
||||
b := &e.books[i]
|
||||
// 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写
|
||||
if e.full {
|
||||
if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if b.coverReader != nil {
|
||||
if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for j := range b.files {
|
||||
if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
index = append(index, libraryExportIndexEntry{
|
||||
Slug: b.meta.Slug,
|
||||
Title: b.meta.Title,
|
||||
Dir: b.dir,
|
||||
FileCount: len(b.meta.Files),
|
||||
SectionCount: len(b.meta.Sections),
|
||||
})
|
||||
}
|
||||
|
||||
// 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内)
|
||||
if e.full {
|
||||
root := libraryExportManifest{
|
||||
Format: libraryExportFormat,
|
||||
FormatVersion: libraryExportVersion,
|
||||
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||
Docs: index,
|
||||
}
|
||||
if err := writeZipJSON(zw, "library.json", root); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
root := libraryExportManifest{
|
||||
Format: libraryExportBookFormat,
|
||||
FormatVersion: libraryExportVersion,
|
||||
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||
Doc: &e.books[0].meta,
|
||||
}
|
||||
if err := writeZipJSON(zw, "book.json", root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名
|
||||
func coverExtOf(coverURL string) string {
|
||||
u := strings.TrimSpace(coverURL)
|
||||
if i := strings.IndexByte(u, '?'); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), "."))
|
||||
if _, ok := libraryExportCoverExts[ext]; !ok {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读)
|
||||
func writeZipJSON(zw *zip.Writer, name string, v any) error {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
hdr.SetMode(0o644)
|
||||
f, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU)
|
||||
func writeZipFile(zw *zip.Writer, name string, r io.Reader) error {
|
||||
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
hdr.SetMode(0o644)
|
||||
f, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
199
backend/service/library_export_test.go
Normal file
199
backend/service/library_export_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
|
||||
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
|
||||
t.Helper()
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open zip entry %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read zip entry %s: %v", name, err)
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := exp.WriteZip(&buf); err != nil {
|
||||
t.Fatalf("write zip: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("read exported zip: %v", err)
|
||||
}
|
||||
return zr
|
||||
}
|
||||
|
||||
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
|
||||
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
|
||||
t.Helper()
|
||||
pub := true
|
||||
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
|
||||
if err != nil {
|
||||
t.Fatalf("create doc: %v", err)
|
||||
}
|
||||
|
||||
coverDir := filepath.Join(uploads, "images")
|
||||
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir uploads: %v", err)
|
||||
}
|
||||
coverBytes := encodeJPEGBytes(t, 120, 160)
|
||||
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
|
||||
t.Fatalf("write cover: %v", err)
|
||||
}
|
||||
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
|
||||
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
|
||||
Published: &pub,
|
||||
}); err != nil {
|
||||
t.Fatalf("update cover: %v", err)
|
||||
}
|
||||
|
||||
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
|
||||
if err != nil {
|
||||
t.Fatalf("create chapter: %v", err)
|
||||
}
|
||||
if _, err := s.CreateSection(doc.ID, &SectionInput{
|
||||
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
|
||||
}); err != nil {
|
||||
t.Fatalf("create section: %v", err)
|
||||
}
|
||||
|
||||
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
|
||||
if err != nil {
|
||||
t.Fatalf("add file: %v", err)
|
||||
}
|
||||
return doc.ID, f.StoredName
|
||||
}
|
||||
|
||||
func TestLibraryExportBook(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
uploads := t.TempDir()
|
||||
s.WithUploadsDir(uploads)
|
||||
docID, storedName := seedBookForExport(t, s, uploads)
|
||||
|
||||
exp, err := s.BuildBookExport(docID)
|
||||
if err != nil {
|
||||
t.Fatalf("build export: %v", err)
|
||||
}
|
||||
defer exp.Close()
|
||||
if exp.Filename != "dao-jia.zip" {
|
||||
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
|
||||
}
|
||||
zr := buildExportZip(t, exp)
|
||||
|
||||
raw, ok := readZipEntry(t, zr, "book.json")
|
||||
if !ok {
|
||||
t.Fatal("缺少 book.json")
|
||||
}
|
||||
var manifest libraryExportManifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatalf("parse book.json: %v", err)
|
||||
}
|
||||
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
|
||||
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
|
||||
}
|
||||
b := manifest.Doc
|
||||
if b == nil {
|
||||
t.Fatal("book.json 缺少 doc")
|
||||
}
|
||||
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
|
||||
t.Fatalf("元信息导出不正确: %+v", b)
|
||||
}
|
||||
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
|
||||
t.Fatalf("封面信息导出不正确: %+v", b)
|
||||
}
|
||||
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
|
||||
t.Fatalf("章节树导出不正确: %+v", b.Sections)
|
||||
}
|
||||
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
|
||||
t.Fatalf("附件清单导出不正确: %+v", b.Files)
|
||||
}
|
||||
|
||||
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
|
||||
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
|
||||
}
|
||||
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
|
||||
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
|
||||
}
|
||||
// 单本包不应出现全库索引
|
||||
if _, ok := readZipEntry(t, zr, "library.json"); ok {
|
||||
t.Fatal("单本包不应包含 library.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryExportAll(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
uploads := t.TempDir()
|
||||
s.WithUploadsDir(uploads)
|
||||
docID, storedName := seedBookForExport(t, s, uploads)
|
||||
_ = docID
|
||||
|
||||
exp, err := s.BuildAllExport()
|
||||
if err != nil {
|
||||
t.Fatalf("build all export: %v", err)
|
||||
}
|
||||
defer exp.Close()
|
||||
zr := buildExportZip(t, exp)
|
||||
|
||||
raw, ok := readZipEntry(t, zr, "library.json")
|
||||
if !ok {
|
||||
t.Fatal("缺少 library.json")
|
||||
}
|
||||
var manifest libraryExportManifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatalf("parse library.json: %v", err)
|
||||
}
|
||||
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
|
||||
t.Fatalf("全库索引异常: %+v", manifest)
|
||||
}
|
||||
idx := manifest.Docs[0]
|
||||
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
|
||||
t.Fatalf("索引条目异常: %+v", idx)
|
||||
}
|
||||
|
||||
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
|
||||
if !ok {
|
||||
t.Fatal("缺少 docs/dao-jia/book.json")
|
||||
}
|
||||
var book libraryExportBook
|
||||
if err := json.Unmarshal(bookRaw, &book); err != nil {
|
||||
t.Fatalf("parse nested book.json: %v", err)
|
||||
}
|
||||
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
|
||||
t.Fatalf("嵌套书籍清单异常: %+v", book)
|
||||
}
|
||||
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
|
||||
t.Fatal("缺少嵌套附件")
|
||||
}
|
||||
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
|
||||
t.Fatal("缺少嵌套封面")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryExportMissingDoc(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
|
||||
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
|
||||
}
|
||||
}
|
||||
631
backend/service/library_import.go
Normal file
631
backend/service/library_import.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package service
|
||||
|
||||
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
|
||||
//
|
||||
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
|
||||
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
|
||||
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
|
||||
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
|
||||
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
|
||||
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
|
||||
const LibraryImportMaxBytes = 512 << 20
|
||||
|
||||
const (
|
||||
LibraryImportModeCreate = "create" // 冲突跳过
|
||||
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
|
||||
)
|
||||
|
||||
// 失败原因(前端据此区分冲突,可提示覆盖重导)
|
||||
const (
|
||||
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
|
||||
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
|
||||
libraryImportReasonInvalid = "invalid" // 清单数据非法
|
||||
libraryImportReasonFailed = "failed" // 落盘/写库失败
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
|
||||
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
|
||||
ErrLibraryImportVer = errors.New("导出版本不受支持")
|
||||
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
|
||||
ErrLibraryImportMode = errors.New("无效的导入模式")
|
||||
)
|
||||
|
||||
// LibraryImportItem 成功导入的单本结果
|
||||
type LibraryImportItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Action string `json:"action"` // created / overwritten
|
||||
Sections int `json:"sections"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
// LibraryImportFailure 单本书失败明细
|
||||
type LibraryImportFailure struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// LibraryImportReport 导入报告
|
||||
type LibraryImportReport struct {
|
||||
Mode string `json:"mode"`
|
||||
Total int `json:"total"`
|
||||
Imported []LibraryImportItem `json:"imported"`
|
||||
Failed []LibraryImportFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
|
||||
type bookImportPlan struct {
|
||||
book libraryExportBook
|
||||
dir string
|
||||
}
|
||||
|
||||
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
|
||||
type plannedSection struct {
|
||||
key string
|
||||
parentKey string
|
||||
title string
|
||||
content string
|
||||
sort int
|
||||
}
|
||||
|
||||
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
|
||||
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
|
||||
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
|
||||
return nil, ErrLibraryImportMode
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, ErrLibraryImportBadZip
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
entries := make(map[string]*zip.File, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
|
||||
}
|
||||
|
||||
plans, err := parseImportPlans(entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return nil, ErrLibraryImportEmpty
|
||||
}
|
||||
|
||||
rep := &LibraryImportReport{
|
||||
Mode: mode,
|
||||
Total: len(plans),
|
||||
Imported: []LibraryImportItem{},
|
||||
Failed: []LibraryImportFailure{},
|
||||
}
|
||||
imp := &bookImporter{s: s, entries: entries, userID: userID}
|
||||
for _, p := range plans {
|
||||
item, fail := imp.run(p, mode)
|
||||
if fail != nil {
|
||||
rep.Failed = append(rep.Failed, *fail)
|
||||
} else {
|
||||
rep.Imported = append(rep.Imported, *item)
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
|
||||
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
|
||||
if rootRaw, ok := entries["library.json"]; ok {
|
||||
var root libraryExportManifest
|
||||
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
|
||||
return nil, ErrLibraryImportVer
|
||||
}
|
||||
plans := make([]bookImportPlan, 0, len(root.Docs))
|
||||
for _, d := range root.Docs {
|
||||
dir := strings.ReplaceAll(d.Dir, "\\", "/")
|
||||
if !safeImportDir(dir) {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
raw, ok := entries[path.Clean(dir)+"/book.json"]
|
||||
if !ok {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
var b libraryExportBook
|
||||
if err := readZipJSON(raw, &b); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
plans = append(plans, bookImportPlan{book: b, dir: dir})
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
if rootRaw, ok := entries["book.json"]; ok {
|
||||
var root libraryExportManifest
|
||||
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
|
||||
return nil, ErrLibraryImportVer
|
||||
}
|
||||
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
|
||||
}
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
|
||||
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
|
||||
func safeImportDir(dir string) bool {
|
||||
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
|
||||
return false
|
||||
}
|
||||
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
|
||||
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
|
||||
return false
|
||||
}
|
||||
return sitePageSlugRe.MatchString(slug)
|
||||
}
|
||||
|
||||
// bookImporter 携带一次批量导入的共享上下文
|
||||
type bookImporter struct {
|
||||
s *LibraryService
|
||||
entries map[string]*zip.File
|
||||
userID uint
|
||||
}
|
||||
|
||||
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
|
||||
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
|
||||
}
|
||||
|
||||
// run 导入单本书;返回 item 或 failure(二者互斥)
|
||||
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
|
||||
b := p.book
|
||||
|
||||
in, err := buildImportInput(&b)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
sections, err := buildImportSections(&b)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
if err := imp.checkPlannedFiles(p); err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
coverEntry, err := imp.planCover(p)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
|
||||
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
|
||||
}
|
||||
|
||||
var docID uint
|
||||
action := "created"
|
||||
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
|
||||
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
|
||||
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
|
||||
switch {
|
||||
case active && mode != LibraryImportModeOverwrite:
|
||||
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
|
||||
case active:
|
||||
var existing model.LibraryDoc
|
||||
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
|
||||
}
|
||||
docID = existing.ID
|
||||
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
|
||||
backfillCreator = existing.CreatorID == 0
|
||||
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
|
||||
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
|
||||
oldCoverDisk = disk
|
||||
}
|
||||
action = "overwritten"
|
||||
case deleted:
|
||||
return nil, imp.fail(p, libraryImportReasonDeleted,
|
||||
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
|
||||
}
|
||||
|
||||
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
|
||||
coverDisk := ""
|
||||
if coverEntry != nil {
|
||||
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
|
||||
if ferr != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
|
||||
}
|
||||
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
|
||||
}
|
||||
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
|
||||
|
||||
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if action == "created" {
|
||||
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
|
||||
in.applyTo(d)
|
||||
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
docID = d.ID
|
||||
} else {
|
||||
var d model.LibraryDoc
|
||||
if err := tx.First(&d, docID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
in.applyTo(&d)
|
||||
updates := map[string]interface{}{
|
||||
"slug": d.Slug,
|
||||
"title": d.Title,
|
||||
"author": d.Author,
|
||||
"description": d.Description,
|
||||
"cover_url": d.CoverURL,
|
||||
"cover_width": d.CoverWidth,
|
||||
"cover_height": d.CoverHeight,
|
||||
"published": d.Published,
|
||||
"sort_order": d.SortOrder,
|
||||
"entries_auto": d.EntriesAuto,
|
||||
}
|
||||
if backfillCreator {
|
||||
updates["creator_id"] = imp.userID
|
||||
}
|
||||
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return imp.createSections(tx, docID, sections)
|
||||
})
|
||||
if txErr != nil {
|
||||
if action == "created" && coverDisk != "" {
|
||||
_ = os.Remove(coverDisk)
|
||||
}
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
|
||||
}
|
||||
|
||||
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
|
||||
for i := range oldFiles {
|
||||
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
|
||||
}
|
||||
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
|
||||
_ = os.Remove(oldCoverDisk)
|
||||
}
|
||||
|
||||
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
|
||||
added := 0
|
||||
var newFiles []model.LibraryFile
|
||||
for _, mf := range b.Files {
|
||||
if mf.Missing || mf.Stored == "" {
|
||||
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
|
||||
}
|
||||
f, fail := imp.addPlannedFile(p, docID, mf)
|
||||
if fail != nil {
|
||||
if action == "created" {
|
||||
imp.rollbackCreated(docID, newFiles, coverDisk)
|
||||
}
|
||||
return nil, fail
|
||||
}
|
||||
newFiles = append(newFiles, *f)
|
||||
added++
|
||||
}
|
||||
|
||||
return &LibraryImportItem{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Action: action,
|
||||
Sections: len(sections),
|
||||
Files: added,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 清单解析与预检 ----------
|
||||
|
||||
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
|
||||
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
|
||||
in := &LibraryInput{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Author: b.Author,
|
||||
Description: b.Description,
|
||||
CoverURL: b.CoverURL,
|
||||
CoverWidth: b.CoverWidth,
|
||||
CoverHeight: b.CoverHeight,
|
||||
Published: &pub,
|
||||
SortOrder: &sortOrder,
|
||||
EntriesAuto: &entriesAuto,
|
||||
}
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
|
||||
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
|
||||
if len(b.Sections) > MaxSectionsPerDoc {
|
||||
return nil, errors.New("章节数量超过上限(最多 200)")
|
||||
}
|
||||
keySeen := map[string]bool{}
|
||||
out := make([]plannedSection, 0, len(b.Sections))
|
||||
|
||||
chapterSort := map[string]int{}
|
||||
order := 0
|
||||
for _, sec := range b.Sections {
|
||||
if sec.ParentKey != "" {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(sec.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("存在标题为空的章节")
|
||||
}
|
||||
if err := validateSectionText(title, sec.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keySeen[sec.Key] {
|
||||
return nil, errors.New("章节标识重复:" + sec.Key)
|
||||
}
|
||||
keySeen[sec.Key] = true
|
||||
chapterSort[sec.Key] = order
|
||||
out = append(out, plannedSection{
|
||||
key: sec.Key, title: title, content: sec.Content, sort: order,
|
||||
})
|
||||
order++
|
||||
}
|
||||
|
||||
childCount := map[string]int{}
|
||||
for _, sec := range b.Sections {
|
||||
if sec.ParentKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := chapterSort[sec.ParentKey]; !ok {
|
||||
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
|
||||
}
|
||||
title := strings.TrimSpace(sec.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("存在标题为空的小节")
|
||||
}
|
||||
if err := validateSectionText(title, sec.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, plannedSection{
|
||||
key: sec.Key,
|
||||
parentKey: sec.ParentKey,
|
||||
title: title,
|
||||
content: sec.Content,
|
||||
sort: childCount[sec.ParentKey],
|
||||
})
|
||||
childCount[sec.ParentKey]++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateSectionText(title, content string) error {
|
||||
if len([]rune(title)) > 200 {
|
||||
return errors.New("章节标题不能超过 200 字:" + title)
|
||||
}
|
||||
if len([]rune(content)) > MaxSectionContent {
|
||||
return errors.New("章节正文不能超过 100000 字:" + title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
|
||||
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
|
||||
if len(p.book.Files) > MaxLibraryFilesPerDoc {
|
||||
return errors.New("附件数量超过上限(最多 20 个)")
|
||||
}
|
||||
maxB := imp.s.maxBytes()
|
||||
for _, f := range p.book.Files {
|
||||
if f.Missing || f.Stored == "" {
|
||||
continue
|
||||
}
|
||||
rel, ok := cleanZipRel(f.Stored)
|
||||
if !ok || !strings.HasPrefix(rel, "files/") {
|
||||
return errors.New("附件路径非法:" + f.Stored)
|
||||
}
|
||||
name := sanitizeFilename(f.Name)
|
||||
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
|
||||
return errors.New("附件格式不受支持:" + f.Name)
|
||||
}
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return errors.New("备份包缺少附件文件:" + f.Name)
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(maxB) {
|
||||
return errors.New("附件超过大小上限:" + f.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
|
||||
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
|
||||
cf := strings.TrimSpace(p.book.CoverFile)
|
||||
if cf == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rel, ok := cleanZipRel(cf)
|
||||
if !ok || strings.Contains(rel, "/") {
|
||||
return nil, errors.New("封面路径非法:" + cf)
|
||||
}
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return nil, errors.New("备份包缺少封面文件")
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
|
||||
return nil, errors.New("封面不能超过 5MB")
|
||||
}
|
||||
return zf, nil
|
||||
}
|
||||
|
||||
// ---------- 落盘 ----------
|
||||
|
||||
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
|
||||
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
|
||||
keyToID := make(map[string]uint, len(sections))
|
||||
for i := range sections {
|
||||
ps := §ions[i]
|
||||
if ps.parentKey != "" {
|
||||
continue
|
||||
}
|
||||
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
keyToID[ps.key] = sec.ID
|
||||
}
|
||||
for i := range sections {
|
||||
ps := §ions[i]
|
||||
if ps.parentKey == "" {
|
||||
continue
|
||||
}
|
||||
parentID, ok := keyToID[ps.parentKey]
|
||||
if !ok {
|
||||
return errors.New("小节找不到所属章节")
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID, ParentID: &parentID,
|
||||
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
|
||||
}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
|
||||
func (imp *bookImporter) addPlannedFile(
|
||||
p bookImportPlan, docID uint, mf libraryExportFile,
|
||||
) (*model.LibraryFile, *LibraryImportFailure) {
|
||||
rel, _ := cleanZipRel(mf.Stored)
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
|
||||
}
|
||||
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"download_count": mf.DownloadCount,
|
||||
"sort_order": mf.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
|
||||
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
|
||||
if imp.s.uploadsDir == "" {
|
||||
return "", 0, 0, "", errors.New("未配置上传目录")
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
if int64(len(data)) > ImageMaxBytes {
|
||||
return "", 0, 0, "", errors.New("封面不能超过 5MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||
if err != nil || width < 1 || height < 1 {
|
||||
return "", 0, 0, "", errors.New("无法解析封面图片")
|
||||
}
|
||||
if width > ImageMaxDim || height > ImageMaxDim {
|
||||
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
|
||||
}
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
full := filepath.Join(imagesDir, filename)
|
||||
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
return "/uploads/images/" + filename, width, height, full, nil
|
||||
}
|
||||
|
||||
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
|
||||
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
|
||||
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
|
||||
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
|
||||
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
|
||||
for i := range newFiles {
|
||||
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
|
||||
}
|
||||
if coverDisk != "" {
|
||||
_ = os.Remove(coverDisk)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ZIP 工具 ----------
|
||||
|
||||
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
|
||||
func cleanZipRel(rel string) (string, bool) {
|
||||
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
|
||||
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
|
||||
return "", false
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func readZipJSON(zf *zip.File, v any) error {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
return json.NewDecoder(rc).Decode(v)
|
||||
}
|
||||
354
backend/service/library_import_test.go
Normal file
354
backend/service/library_import_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
|
||||
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "export.zip")
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp zip: %v", err)
|
||||
}
|
||||
if err := exp.WriteZip(f); err != nil {
|
||||
t.Fatalf("write export zip: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close temp zip: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
|
||||
func writeZipFileMap(t *testing.T, entries map[string]string) string {
|
||||
t.Helper()
|
||||
buf := new(bytes.Buffer)
|
||||
zw := zip.NewWriter(buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, content); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
p := filepath.Join(t.TempDir(), "in.zip")
|
||||
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write temp zip: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
|
||||
t.Helper()
|
||||
if rep == nil {
|
||||
t.Fatal("报告为空")
|
||||
}
|
||||
if rep.Total != total {
|
||||
t.Fatalf("total = %d, want %d", rep.Total, total)
|
||||
}
|
||||
if len(rep.Failed) != 0 {
|
||||
t.Fatalf("存在失败记录: %+v", rep.Failed)
|
||||
}
|
||||
if len(rep.Imported) != total {
|
||||
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
|
||||
func TestLibraryImportBookRoundTrip(t *testing.T) {
|
||||
// ---- 源库 ----
|
||||
src, _ := newLibraryTestService(t)
|
||||
srcUploads := t.TempDir()
|
||||
src.WithUploadsDir(srcUploads)
|
||||
docID, storedName := seedBookForExport(t, src, srcUploads)
|
||||
|
||||
// 制造两次下载,验证计数随包保留
|
||||
var srcFile model.LibraryFile
|
||||
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
|
||||
t.Fatalf("find source file: %v", err)
|
||||
}
|
||||
src.IncrDownload(srcFile.ID)
|
||||
src.IncrDownload(srcFile.ID)
|
||||
|
||||
exp, err := src.BuildBookExport(docID)
|
||||
if err != nil {
|
||||
t.Fatalf("build export: %v", err)
|
||||
}
|
||||
zipPath := writeExportZipFile(t, exp)
|
||||
exp.Close()
|
||||
|
||||
// ---- 目标库(空库)----
|
||||
dst, _ := newLibraryTestService(t)
|
||||
dstUploads := t.TempDir()
|
||||
dst.WithUploadsDir(dstUploads)
|
||||
|
||||
// 1) create 导入
|
||||
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
assertReportOK(t, rep, 1)
|
||||
item := rep.Imported[0]
|
||||
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
|
||||
t.Fatalf("导入条目异常: %+v", item)
|
||||
}
|
||||
|
||||
var doc model.LibraryDoc
|
||||
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
|
||||
t.Fatalf("find imported doc: %v", err)
|
||||
}
|
||||
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
|
||||
t.Fatalf("元信息恢复不正确: %+v", doc)
|
||||
}
|
||||
if doc.Author != "老子" {
|
||||
t.Fatalf("作者恢复不正确: %q", doc.Author)
|
||||
}
|
||||
if doc.CreatorID != 7 {
|
||||
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
|
||||
}
|
||||
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
|
||||
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
|
||||
}
|
||||
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
|
||||
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
|
||||
}
|
||||
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
|
||||
if !ok {
|
||||
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
|
||||
}
|
||||
coverData, err := os.ReadFile(coverDisk)
|
||||
if err != nil {
|
||||
t.Fatalf("封面未落盘: %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
|
||||
t.Fatal("封面内容不是 JPEG")
|
||||
}
|
||||
|
||||
// 章节树:章 + 小节父子关系
|
||||
secs, err := dst.ListSections(doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list sections: %v", err)
|
||||
}
|
||||
if len(secs) != 2 {
|
||||
t.Fatalf("章节数 = %d, want 2", len(secs))
|
||||
}
|
||||
var chapter, child *model.LibrarySection
|
||||
for i := range secs {
|
||||
if secs[i].ParentID == nil {
|
||||
chapter = &secs[i]
|
||||
} else {
|
||||
child = &secs[i]
|
||||
}
|
||||
}
|
||||
if chapter == nil || child == nil {
|
||||
t.Fatalf("章节层级不正确: %+v", secs)
|
||||
}
|
||||
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
|
||||
t.Fatalf("章信息不正确: %+v", chapter)
|
||||
}
|
||||
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
|
||||
t.Fatalf("小节信息不正确: %+v", child)
|
||||
}
|
||||
|
||||
// 附件:重新随机落盘、内容与下载计数保留
|
||||
var files []model.LibraryFile
|
||||
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
|
||||
t.Fatalf("list files: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("附件数 = %d, want 1", len(files))
|
||||
}
|
||||
nf := files[0]
|
||||
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
|
||||
t.Fatalf("附件恢复不正确: %+v", nf)
|
||||
}
|
||||
data, err := os.ReadFile(dst.FilePath(&nf))
|
||||
if err != nil {
|
||||
t.Fatalf("附件未落盘: %v", err)
|
||||
}
|
||||
if string(data) != "EPUB-FILE-BYTES" {
|
||||
t.Fatalf("附件内容不正确: %q", data)
|
||||
}
|
||||
firstCoverURL := doc.CoverURL
|
||||
|
||||
// 2) 再次 create:冲突跳过
|
||||
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("re-import: %v", err)
|
||||
}
|
||||
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
|
||||
t.Fatalf("冲突报告异常: %+v", rep2)
|
||||
}
|
||||
if rep2.Failed[0].Reason != libraryImportReasonConflict {
|
||||
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
|
||||
}
|
||||
var docCount int64
|
||||
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||
if docCount != 1 {
|
||||
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
|
||||
}
|
||||
|
||||
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
|
||||
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
|
||||
if err != nil {
|
||||
t.Fatalf("add extra: %v", err)
|
||||
}
|
||||
extraPath := dst.FilePath(extra)
|
||||
if _, err := os.Stat(extraPath); err != nil {
|
||||
t.Fatalf("extra 未先落盘: %v", err)
|
||||
}
|
||||
|
||||
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("overwrite import: %v", err)
|
||||
}
|
||||
assertReportOK(t, rep3, 1)
|
||||
if rep3.Imported[0].Action != "overwritten" {
|
||||
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
|
||||
}
|
||||
|
||||
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||
if docCount != 1 {
|
||||
t.Fatalf("覆盖后书库数量异常: %d", docCount)
|
||||
}
|
||||
var doc2 model.LibraryDoc
|
||||
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
|
||||
t.Fatalf("find overwritten doc: %v", err)
|
||||
}
|
||||
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
|
||||
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
|
||||
}
|
||||
if doc2.Author != "老子" || doc2.CreatorID != 7 {
|
||||
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
|
||||
}
|
||||
var files2 []model.LibraryFile
|
||||
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
|
||||
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
|
||||
}
|
||||
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
|
||||
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
|
||||
}
|
||||
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
|
||||
}
|
||||
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
|
||||
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
|
||||
}
|
||||
if doc2.CoverURL == firstCoverURL {
|
||||
t.Fatal("覆盖后封面应重新落盘为新文件")
|
||||
}
|
||||
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
|
||||
if _, err := os.ReadFile(newCoverDisk); err != nil {
|
||||
t.Fatalf("新封面未落盘: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryImportAll 全库包往返
|
||||
func TestLibraryImportAll(t *testing.T) {
|
||||
src, _ := newLibraryTestService(t)
|
||||
srcUploads := t.TempDir()
|
||||
src.WithUploadsDir(srcUploads)
|
||||
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
|
||||
t.Fatalf("create second doc: %v", err)
|
||||
}
|
||||
seedBookForExport(t, src, srcUploads)
|
||||
|
||||
exp, err := src.BuildAllExport()
|
||||
if err != nil {
|
||||
t.Fatalf("build all export: %v", err)
|
||||
}
|
||||
zipPath := writeExportZipFile(t, exp)
|
||||
exp.Close()
|
||||
|
||||
dst, _ := newLibraryTestService(t)
|
||||
dst.WithUploadsDir(t.TempDir())
|
||||
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("import all: %v", err)
|
||||
}
|
||||
assertReportOK(t, rep, 2)
|
||||
slugs := map[string]bool{}
|
||||
for _, it := range rep.Imported {
|
||||
slugs[it.Slug] = true
|
||||
}
|
||||
if !slugs["dao-jia"] || !slugs["second-book"] {
|
||||
t.Fatalf("导入书集不正确: %+v", slugs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryImportRejectBadPackage 非法包整体拒绝
|
||||
func TestLibraryImportRejectBadPackage(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
s.WithUploadsDir(t.TempDir())
|
||||
|
||||
badPath := filepath.Join(t.TempDir(), "bad.zip")
|
||||
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
|
||||
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
|
||||
}
|
||||
|
||||
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
|
||||
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
|
||||
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
|
||||
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanZipRel 路径穿越校验
|
||||
func TestCleanZipRel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{".", false},
|
||||
{"..", false},
|
||||
{"../evil", false},
|
||||
{"a/../../b", false},
|
||||
{"/etc/passwd", false},
|
||||
{`docs\..\..\x`, false},
|
||||
{"files/a.epub", true},
|
||||
{"docs/x/book.json", true},
|
||||
{`docs\x/cover.jpg`, true},
|
||||
{"a//b", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, ok := cleanZipRel(tc.in)
|
||||
if ok != tc.want {
|
||||
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
422
backend/service/library_section.go
Normal file
422
backend/service/library_section.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 章节约束:两级树(章 → 节),正文为 markdown
|
||||
const (
|
||||
MaxSectionsPerDoc = 200 // 单条目章+节总数上限
|
||||
MaxSectionContent = 100_000 // 单章节正文字符上限
|
||||
MaxImportBytes = 5 << 20 // 单个 md 文件字节上限(5MB)
|
||||
MaxImportFiles = 50 // 单次批量导入文件数上限
|
||||
MaxImportTotalBytes = 20 << 20 // 批量导入总字节上限(20MB)
|
||||
)
|
||||
|
||||
var ErrSectionNotFound = errors.New("章节不存在")
|
||||
|
||||
// SectionInput 创建/更新章节入参(更新仅改标题与正文,父子关系由创建时决定、顺序用 move 调整)
|
||||
type SectionInput struct {
|
||||
ParentID *uint `json:"parent_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (in *SectionInput) normalize() error {
|
||||
if in.ParentID != nil && *in.ParentID == 0 {
|
||||
in.ParentID = nil
|
||||
}
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.Title == "" {
|
||||
return errors.New("章节标题不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(in.Title) > 200 {
|
||||
return errors.New("章节标题不能超过 200 字")
|
||||
}
|
||||
if utf8.RuneCountInString(in.Content) > MaxSectionContent {
|
||||
return errors.New("章节正文不能超过 100000 字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) section(id uint) (*model.LibrarySection, error) {
|
||||
var sec model.LibrarySection
|
||||
if err := s.db.First(&sec, id).Error; err != nil {
|
||||
return nil, ErrSectionNotFound
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) sectionCount(docID uint) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID).Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// sectionMaxSort 同级兄弟的当前最大 sort_order(无兄弟时 -1)
|
||||
func (s *LibraryService) sectionMaxSort(docID uint, parentID *uint) (int, error) {
|
||||
q := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID)
|
||||
if parentID == nil {
|
||||
q = q.Where("parent_id IS NULL")
|
||||
} else {
|
||||
q = q.Where("parent_id = ?", *parentID)
|
||||
}
|
||||
var maxSort int
|
||||
if err := q.Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxSort, nil
|
||||
}
|
||||
|
||||
// ListSections 条目下全部章节(平铺,章在前节按组内序)
|
||||
func (s *LibraryService) ListSections(docID uint) ([]model.LibrarySection, error) {
|
||||
var secs []model.LibrarySection
|
||||
if err := s.db.Where("doc_id = ?", docID).
|
||||
Order("sort_order ASC, id ASC").Find(&secs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secs, nil
|
||||
}
|
||||
|
||||
// validateParent 两级约束:父必须是同条目下的章(自身不能是节)
|
||||
func (s *LibraryService) validateParent(docID uint, parentID *uint) error {
|
||||
if parentID == nil {
|
||||
return nil
|
||||
}
|
||||
parent, err := s.section(*parentID)
|
||||
if err != nil {
|
||||
return errors.New("父章节不存在")
|
||||
}
|
||||
if parent.DocID != docID {
|
||||
return errors.New("父章节不属于该条目")
|
||||
}
|
||||
if parent.ParentID != nil {
|
||||
return errors.New("目录最多两级:章下不能再建小节")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) CreateSection(docID uint, in *SectionInput) (*model.LibrarySection, error) {
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc model.LibraryDoc
|
||||
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
if err := s.validateParent(docID, in.ParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := s.sectionCount(docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= MaxSectionsPerDoc {
|
||||
return nil, errors.New("章节数量超过上限")
|
||||
}
|
||||
maxSort, err := s.sectionMaxSort(docID, in.ParentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID,
|
||||
ParentID: in.ParentID,
|
||||
Title: in.Title,
|
||||
Content: in.Content,
|
||||
SortOrder: maxSort + 1,
|
||||
}
|
||||
if err := s.db.Create(sec).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// UpdateSection 更新标题与正文(父章节与顺序不变)
|
||||
func (s *LibraryService) UpdateSection(id uint, in *SectionInput) (*model.LibrarySection, error) {
|
||||
in.ParentID = nil // 忽略传入的 parent:父子关系不在此处变更
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec, err := s.section(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(sec).Updates(map[string]interface{}{
|
||||
"title": in.Title,
|
||||
"content": in.Content,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.section(id)
|
||||
}
|
||||
|
||||
// DeleteSection 删除章节;若为章则级联软删其下小节
|
||||
func (s *LibraryService) DeleteSection(id uint) error {
|
||||
sec, err := s.section(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if sec.ParentID == nil {
|
||||
if err := tx.Where("parent_id = ?", sec.ID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(sec).Error
|
||||
})
|
||||
}
|
||||
|
||||
// MoveSection 与同父相邻兄弟交换 sort_order(边界为 no-op)
|
||||
func (s *LibraryService) MoveSection(id uint, direction string) error {
|
||||
if direction != "up" && direction != "down" {
|
||||
return errors.New("无效的移动方向")
|
||||
}
|
||||
sec, err := s.section(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := s.db.Where("doc_id = ?", sec.DocID)
|
||||
if sec.ParentID == nil {
|
||||
q = q.Where("parent_id IS NULL")
|
||||
} else {
|
||||
q = q.Where("parent_id = ?", *sec.ParentID)
|
||||
}
|
||||
var sibs []model.LibrarySection
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&sibs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idx := -1
|
||||
for i := range sibs {
|
||||
if sibs[i].ID == sec.ID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return ErrSectionNotFound
|
||||
}
|
||||
var other *model.LibrarySection
|
||||
if direction == "up" && idx > 0 {
|
||||
other = &sibs[idx-1]
|
||||
}
|
||||
if direction == "down" && idx < len(sibs)-1 {
|
||||
other = &sibs[idx+1]
|
||||
}
|
||||
if other == nil {
|
||||
return nil // 已在边界,无需移动
|
||||
}
|
||||
a, b := sec.SortOrder, other.SortOrder
|
||||
if a == b {
|
||||
b = a + 1 // 相同排序值时保证交换后顺序确实变化
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.LibrarySection{}).Where("id = ?", sec.ID).
|
||||
UpdateColumn("sort_order", b).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.LibrarySection{}).Where("id = ?", other.ID).
|
||||
UpdateColumn("sort_order", a).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- md 拆章导入 ----------
|
||||
|
||||
type mdChapterChunk struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// splitMarkdownChapters 按指定级别标题("# " 或 "## ")切块;
|
||||
// 代码围栏内的 # 行不视为标题;首个标题之前的内容并入第一章正文。
|
||||
func splitMarkdownChapters(content, level string) []mdChapterChunk {
|
||||
prefix := "# "
|
||||
if level == "h2" {
|
||||
prefix = "## "
|
||||
}
|
||||
var chunks []mdChapterChunk
|
||||
var pre strings.Builder
|
||||
bodies := []strings.Builder{}
|
||||
cur := -1
|
||||
inCode := false
|
||||
text := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "```") {
|
||||
inCode = !inCode
|
||||
}
|
||||
if !inCode && strings.HasPrefix(t, prefix) {
|
||||
title := strings.TrimSpace(strings.TrimPrefix(t, prefix))
|
||||
title = strings.TrimSpace(strings.TrimRight(title, "#"))
|
||||
chunks = append(chunks, mdChapterChunk{Title: title})
|
||||
bodies = append(bodies, strings.Builder{})
|
||||
cur = len(chunks) - 1
|
||||
continue
|
||||
}
|
||||
if cur < 0 {
|
||||
pre.WriteString(line)
|
||||
pre.WriteString("\n")
|
||||
} else {
|
||||
bodies[cur].WriteString(line)
|
||||
bodies[cur].WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]mdChapterChunk, len(chunks))
|
||||
for i := range chunks {
|
||||
body := bodies[i].String()
|
||||
if i == 0 && pre.Len() > 0 {
|
||||
body = pre.String() + body
|
||||
}
|
||||
out[i] = mdChapterChunk{Title: chunks[i].Title, Body: strings.TrimSpace(body)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateRunes 按 rune 截断
|
||||
func truncateRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n])
|
||||
}
|
||||
|
||||
// ImportFileInput 批量导入的单个文件
|
||||
type ImportFileInput struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// chunksFromFile 把单个文件拆成章块:校验单文件大小,标题回退取文件名;
|
||||
// splitLevel 为 none 或未命中标题时整篇一章。
|
||||
func chunksFromFile(filename, content, splitLevel string) (string, []mdChapterChunk, error) {
|
||||
if len(content) > MaxImportBytes {
|
||||
return "", nil, errors.New("文件 " + sanitizeFilename(filename) + " 过大:单个文件不能超过 5MB")
|
||||
}
|
||||
base := sanitizeFilename(filename)
|
||||
if i := strings.LastIndex(base, "."); i > 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
base = "未命名章节"
|
||||
}
|
||||
if utf8.RuneCountInString(base) > 200 {
|
||||
base = truncateRunes(base, 200)
|
||||
}
|
||||
|
||||
var chunks []mdChapterChunk
|
||||
if splitLevel == "none" {
|
||||
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
|
||||
} else {
|
||||
chunks = splitMarkdownChapters(content, splitLevel)
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
|
||||
}
|
||||
return base, chunks, nil
|
||||
}
|
||||
|
||||
// ImportMarkdownBatch 批量导入:多个 md 文件按传入顺序依次拆成顶级章(不生成节)。
|
||||
// splitLevel 对每个文件生效;无命中标题的文件回退为单章(标题取文件名)。
|
||||
// 任一文件失败整体回滚。单文件导入即长度为 1 的批量。
|
||||
func (s *LibraryService) ImportMarkdownBatch(docID uint, files []ImportFileInput, splitLevel string) ([]model.LibrarySection, error) {
|
||||
var doc model.LibraryDoc
|
||||
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
if splitLevel != "h1" && splitLevel != "h2" && splitLevel != "none" {
|
||||
return nil, errors.New("无效的拆分方式")
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, errors.New("没有可导入的文件")
|
||||
}
|
||||
if len(files) > MaxImportFiles {
|
||||
return nil, errors.New("单次最多导入 " + strconv.Itoa(MaxImportFiles) + " 个文件")
|
||||
}
|
||||
total := 0
|
||||
for _, f := range files {
|
||||
total += len(f.Content)
|
||||
}
|
||||
if total > MaxImportTotalBytes {
|
||||
return nil, errors.New("导入内容总量过大:不能超过 20MB")
|
||||
}
|
||||
|
||||
// 逐文件拆块(保留文件边界,便于错误提示定位到文件)
|
||||
type mdFilePlan struct {
|
||||
Base string
|
||||
Chunks []mdChapterChunk
|
||||
}
|
||||
plan := make([]mdFilePlan, 0, len(files))
|
||||
totalChunks := 0
|
||||
for _, f := range files {
|
||||
base, chunks, err := chunksFromFile(f.Filename, f.Content, splitLevel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan = append(plan, mdFilePlan{Base: base, Chunks: chunks})
|
||||
totalChunks += len(chunks)
|
||||
}
|
||||
|
||||
count, err := s.sectionCount(docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count+int64(totalChunks) > MaxSectionsPerDoc {
|
||||
return nil, errors.New("导入后的章节数超过上限(现有 + 导入 ≤ 200)")
|
||||
}
|
||||
maxSort, err := s.sectionMaxSort(docID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created := make([]model.LibrarySection, 0, totalChunks)
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
offset := 0
|
||||
for _, fp := range plan {
|
||||
for i, ch := range fp.Chunks {
|
||||
title := strings.TrimSpace(ch.Title)
|
||||
if title == "" {
|
||||
title = fp.Base + "(续)"
|
||||
}
|
||||
if utf8.RuneCountInString(title) > 200 {
|
||||
title = truncateRunes(title, 200)
|
||||
}
|
||||
if utf8.RuneCountInString(ch.Body) > MaxSectionContent {
|
||||
return errors.New("「" + fp.Base + "」第 " + strconv.Itoa(i+1) + " 章正文超过 100000 字,请先拆分后再导入")
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID,
|
||||
Title: title,
|
||||
Content: ch.Body,
|
||||
SortOrder: maxSort + 1 + offset + i,
|
||||
}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = append(created, *sec)
|
||||
}
|
||||
offset += len(fp.Chunks)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// ImportMarkdown 单文件导入(批量为长度 1 的特例,保留旧端点兼容)
|
||||
func (s *LibraryService) ImportMarkdown(docID uint, filename, content, splitLevel string) ([]model.LibrarySection, error) {
|
||||
return s.ImportMarkdownBatch(docID, []ImportFileInput{{Filename: filename, Content: content}}, splitLevel)
|
||||
}
|
||||
559
backend/service/library_test.go
Normal file
559
backend/service/library_test.go
Normal file
@@ -0,0 +1,559 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newLibraryTestService(t *testing.T) (*LibraryService, 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.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
s := NewLibraryService(db, dir)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
return s, dir
|
||||
}
|
||||
|
||||
func libPub(b bool) *bool { return &b }
|
||||
|
||||
func libInt(v int) *int { return &v }
|
||||
|
||||
func TestLibraryNormalize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*LibraryInput)
|
||||
wantErr string
|
||||
}{
|
||||
{"slug 为空", func(i *LibraryInput) { i.Slug = "" }, "slug 不能为空"},
|
||||
{"slug 非法", func(i *LibraryInput) { i.Slug = "Bad_Slug" }, "slug 仅允许"},
|
||||
{"slug 超长", func(i *LibraryInput) { i.Slug = strings.Repeat("a", 65) }, "slug 不能超过"},
|
||||
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
|
||||
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
|
||||
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
|
||||
{"作者超限", func(i *LibraryInput) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"},
|
||||
{"合法", func(i *LibraryInput) {}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
in := &LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"}
|
||||
tc.mutate(in)
|
||||
err := in.normalize()
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: 不应报错,got %v", tc.name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("%s: 期望包含 %q,got %v", tc.name, tc.wantErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryAuthorAndCreator(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
pub := true
|
||||
doc, err := s.Create(&LibraryInput{
|
||||
Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub,
|
||||
}, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if doc.Author != "鲁迅" || doc.CreatorID != user.ID {
|
||||
t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc)
|
||||
}
|
||||
|
||||
list, err := s.ListPublished()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Author != "鲁迅" {
|
||||
t.Fatalf("列表作者异常: %+v", list)
|
||||
}
|
||||
if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" {
|
||||
t.Fatalf("列表创建者摘要异常: %+v", c)
|
||||
}
|
||||
|
||||
detail, err := s.GetPublishedBySlug("authored")
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if detail.Creator == nil || detail.Creator.Username != "curator" {
|
||||
t.Fatalf("详情创建者摘要异常: %+v", detail.Creator)
|
||||
}
|
||||
|
||||
// 作者可清空;更新不改变创建者归属
|
||||
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||
Slug: "authored", Title: "署名本", Author: "", Published: &pub,
|
||||
}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
var again model.LibraryDoc
|
||||
if err := s.db.First(&again, doc.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if again.Author != "" || again.CreatorID != user.ID {
|
||||
t.Fatalf("更新后作者/创建者异常: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibrarySlugUnique(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "B"}); err == nil {
|
||||
t.Fatal("重复 slug 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibrarySlugReuseAfterSoftDelete(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, err := s.Create(&LibraryInput{Slug: "test", Title: "测试"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := s.Delete(doc.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
|
||||
_, err = s.Create(&LibraryInput{Slug: "test", Title: "重建"})
|
||||
if err == nil || !strings.Contains(err.Error(), "已删除") {
|
||||
t.Fatalf("软删后重建应提示已删除条目占用,got %v", err)
|
||||
}
|
||||
|
||||
// 彻底删除后可重建
|
||||
if err := s.Purge(doc.ID); err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if _, err := s.Create(&LibraryInput{Slug: "test", Title: "重建"}); err != nil {
|
||||
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryPurgeRequiresDeleted(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "alive", Title: "在用"})
|
||||
if err := s.Purge(doc.ID); err == nil || !errors.Is(err, ErrLibraryNotDeleted) {
|
||||
t.Fatalf("未删条目 Purge 应拒绝,got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryPurgeRemovesFilesAndSections(t *testing.T) {
|
||||
s, dir := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "purge-me", Title: "待清"})
|
||||
f, err := s.AddFile(doc.ID, 1, "x.txt", strings.NewReader("x"))
|
||||
if err != nil {
|
||||
t.Fatalf("add file: %v", err)
|
||||
}
|
||||
full := filepath.Join(dir, "library", f.StoredName)
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
t.Fatalf("文件未落盘: %v", err)
|
||||
}
|
||||
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}); err != nil {
|
||||
t.Fatalf("create section: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Delete(doc.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if err := s.Purge(doc.ID); err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
|
||||
var n int64
|
||||
s.db.Unscoped().Model(&model.LibraryDoc{}).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("条目行应已清除,got %d", n)
|
||||
}
|
||||
s.db.Unscoped().Model(&model.LibraryFile{}).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("文件行应已清除,got %d", n)
|
||||
}
|
||||
s.db.Unscoped().Model(&model.LibrarySection{}).Count(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("章节行应已清除,got %d", n)
|
||||
}
|
||||
if _, err := os.Stat(full); !os.IsNotExist(err) {
|
||||
t.Fatalf("磁盘文件应已删除,err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryExtWhitelists(t *testing.T) {
|
||||
if !LibraryExtAllowed("epub") || !LibraryExtAllowed(".md") {
|
||||
t.Fatal("白名单内扩展名应允许")
|
||||
}
|
||||
if LibraryExtAllowed("exe") || LibraryExtAllowed("php") || LibraryExtAllowed("") {
|
||||
t.Fatal("白名单外扩展名应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryAddFile(t *testing.T) {
|
||||
s, dir := newLibraryTestService(t)
|
||||
doc, err := s.Create(&LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// 白名单内:落盘 + 建行
|
||||
f, err := s.AddFile(doc.ID, 1, "如何生活.pdf", strings.NewReader("PDF-DATA"))
|
||||
if err != nil {
|
||||
t.Fatalf("add pdf: %v", err)
|
||||
}
|
||||
if f.Ext != "pdf" || f.Size != 8 {
|
||||
t.Fatalf("文件元数据错误: %+v", f)
|
||||
}
|
||||
if f.MIME != "application/pdf" {
|
||||
t.Fatalf("pdf MIME 应为 application/pdf,got %s", f.MIME)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "library", f.StoredName)); err != nil {
|
||||
t.Fatalf("文件未落盘: %v", err)
|
||||
}
|
||||
|
||||
// html 存储层 MIME 强制 octet-stream(下载安全)
|
||||
h, err := s.AddFile(doc.ID, 1, "page.html", strings.NewReader("<h1>hi</h1>"))
|
||||
if err != nil {
|
||||
t.Fatalf("add html: %v", err)
|
||||
}
|
||||
if h.MIME != "application/octet-stream" {
|
||||
t.Fatalf("html 下载 MIME 应强制 octet-stream,got %s", h.MIME)
|
||||
}
|
||||
|
||||
// 白名单外拒绝
|
||||
if _, err := s.AddFile(doc.ID, 1, "evil.exe", strings.NewReader("MZ")); err == nil {
|
||||
t.Fatal("exe 应被拒绝")
|
||||
}
|
||||
if _, err := s.AddFile(doc.ID, 1, "noext", strings.NewReader("x")); err == nil {
|
||||
t.Fatal("无扩展名应被拒绝")
|
||||
}
|
||||
|
||||
// 超过每条目上限
|
||||
for i := 0; i < MaxLibraryFilesPerDoc-2; i++ {
|
||||
if _, err := s.AddFile(doc.ID, 1, "f.txt", strings.NewReader("x")); err != nil {
|
||||
t.Fatalf("add #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.AddFile(doc.ID, 1, "over.txt", strings.NewReader("x")); err == nil {
|
||||
t.Fatal("超出文件数上限应报错")
|
||||
}
|
||||
|
||||
// 未发布条目的文件公开不可见
|
||||
if _, err := s.GetPublicFile(f.ID); err == nil {
|
||||
t.Fatal("未发布条目的文件不应公开可见")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryPublishedFilterAndOrder(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
if _, err := s.Create(&LibraryInput{Slug: "draft-doc", Title: "草稿"}); err != nil {
|
||||
t.Fatalf("create draft: %v", err)
|
||||
}
|
||||
published, _ := s.Create(&LibraryInput{
|
||||
Slug: "live-doc", Title: "已发布", Published: libPub(true), SortOrder: libInt(1),
|
||||
})
|
||||
|
||||
if _, err := s.AddFile(published.ID, 1, "b.txt", strings.NewReader("b")); err != nil {
|
||||
t.Fatalf("add b: %v", err)
|
||||
}
|
||||
if _, err := s.AddFile(published.ID, 1, "a.txt", strings.NewReader("a")); err != nil {
|
||||
t.Fatalf("add a: %v", err)
|
||||
}
|
||||
|
||||
// 未发布不出现在公开目录
|
||||
list, err := s.ListPublished()
|
||||
if err != nil {
|
||||
t.Fatalf("ListPublished: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Slug != "live-doc" {
|
||||
t.Fatalf("公开目录应只含 live-doc,got %+v", list)
|
||||
}
|
||||
if list[0].FileCount != 2 {
|
||||
t.Fatalf("列表应带文件计数 2,got %d", list[0].FileCount)
|
||||
}
|
||||
if _, err := s.GetPublishedBySlug("draft-doc"); err == nil {
|
||||
t.Fatal("未发布条目按 slug 不应可见")
|
||||
}
|
||||
|
||||
// 软删后公开彻底不可见
|
||||
if err := s.Delete(published.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetPublishedBySlug("live-doc"); err == nil {
|
||||
t.Fatal("软删条目不应可见")
|
||||
}
|
||||
|
||||
// 管理端仍可见(含未发布;软删条目带 Deleted 标记一并列出)
|
||||
all, err := s.ListAll()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAll: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("管理端应含 draft-doc 与软删的 live-doc,got %+v", all)
|
||||
}
|
||||
bySlug := map[string]LibraryDocDetail{}
|
||||
for _, d := range all {
|
||||
bySlug[d.Slug] = d
|
||||
}
|
||||
if d := bySlug["draft-doc"]; d.Deleted {
|
||||
t.Fatalf("draft-doc 不应标记已删除: %+v", d)
|
||||
}
|
||||
if d := bySlug["live-doc"]; !d.Deleted {
|
||||
t.Fatalf("live-doc 应标记已删除: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryFileOrdering(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "order", Title: "排序", Published: libPub(true)})
|
||||
// b 先传(id 小),a 设 sort_order 靠前
|
||||
b, _ := s.AddFile(doc.ID, 1, "b.txt", strings.NewReader("b"))
|
||||
a, _ := s.AddFile(doc.ID, 1, "a.txt", strings.NewReader("a"))
|
||||
s.db.Model(&model.LibraryFile{}).Where("id = ?", a.ID).Update("sort_order", -1)
|
||||
_ = b
|
||||
|
||||
d, err := s.GetPublishedBySlug("order")
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if len(d.Files) != 2 || d.Files[0].Name != "a.txt" {
|
||||
t.Fatalf("文件应按 sort_order,id 排序: %+v", d.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryIncrDownload(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "cnt", Title: "计数", Published: libPub(true)})
|
||||
f, err := s.AddFile(doc.ID, 1, "x.pdf", strings.NewReader("x"))
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
s.IncrDownload(f.ID)
|
||||
s.IncrDownload(f.ID)
|
||||
got, err := s.GetPublicFile(f.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.DownloadCount != 2 {
|
||||
t.Fatalf("下载计数应为 2,got %d", got.DownloadCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryDeleteFile(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "del", Title: "删除"})
|
||||
f, err := s.AddFile(doc.ID, 1, "gone.txt", strings.NewReader("gone"))
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if err := s.DeleteFile(f.ID); err != nil {
|
||||
t.Fatalf("delete file: %v", err)
|
||||
}
|
||||
if err := s.DeleteFile(f.ID); err == nil {
|
||||
t.Fatal("重复删除应报不存在")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 章节(两级树) ----------
|
||||
|
||||
func TestLibrarySectionTwoLevelConstraint(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "book", Title: "书"})
|
||||
|
||||
ch, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"})
|
||||
if err != nil {
|
||||
t.Fatalf("create chapter: %v", err)
|
||||
}
|
||||
sec, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "第一节"})
|
||||
if err != nil {
|
||||
t.Fatalf("create section: %v", err)
|
||||
}
|
||||
if sec.ParentID == nil || *sec.ParentID != ch.ID {
|
||||
t.Fatalf("节的父应为章 %d", ch.ID)
|
||||
}
|
||||
// 节下不能再挂节
|
||||
if _, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &sec.ID, Title: "深层"}); err == nil {
|
||||
t.Fatal("三级嵌套应被拒绝")
|
||||
}
|
||||
// 跨条目父章节
|
||||
doc2, _ := s.Create(&LibraryInput{Slug: "book2", Title: "书2"})
|
||||
if _, err := s.CreateSection(doc2.ID, &SectionInput{ParentID: &ch.ID, Title: "跨条目"}); err == nil {
|
||||
t.Fatal("跨条目父章节应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibrarySectionMoveSwap(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "mv", Title: "排序"})
|
||||
a, _ := s.CreateSection(doc.ID, &SectionInput{Title: "甲"})
|
||||
b, _ := s.CreateSection(doc.ID, &SectionInput{Title: "乙"})
|
||||
c, _ := s.CreateSection(doc.ID, &SectionInput{Title: "丙"})
|
||||
|
||||
if err := s.MoveSection(b.ID, "up"); err != nil {
|
||||
t.Fatalf("move up: %v", err)
|
||||
}
|
||||
secs, _ := s.ListSections(doc.ID)
|
||||
if secs[0].ID != b.ID || secs[1].ID != a.ID || secs[2].ID != c.ID {
|
||||
t.Fatalf("上移后顺序应为 乙甲丙: %+v", titles(secs))
|
||||
}
|
||||
// 边界 no-op
|
||||
if err := s.MoveSection(b.ID, "up"); err != nil {
|
||||
t.Fatalf("边界上移: %v", err)
|
||||
}
|
||||
if err := s.MoveSection(b.ID, "bad"); err == nil {
|
||||
t.Fatal("非法方向应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func titles(secs []model.LibrarySection) []string {
|
||||
out := make([]string, len(secs))
|
||||
for i, sec := range secs {
|
||||
out[i] = sec.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLibraryImportMarkdown(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "imp", Title: "导入"})
|
||||
|
||||
// h2 拆分:围栏内 ## 不拆;首标题前内容并入第一章
|
||||
md := "## 前言\n\n引言正文。\n\n```\n## 不是标题\n```\n\n## 第二章\n\n内容二。"
|
||||
secs, err := s.ImportMarkdown(doc.ID, "how.md", md, "h2")
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if len(secs) != 2 {
|
||||
t.Fatalf("应拆出 2 章,got %d: %+v", len(secs), titles(secs))
|
||||
}
|
||||
if secs[0].Title != "前言" || !strings.Contains(secs[0].Content, "引言正文") {
|
||||
t.Fatalf("第一章错误: %+v", secs[0])
|
||||
}
|
||||
if !strings.Contains(secs[0].Content, "不是标题") {
|
||||
t.Fatal("围栏内的 ## 行应留在第一章正文,不应被拆分")
|
||||
}
|
||||
if secs[1].Title != "第二章" {
|
||||
t.Fatalf("第二章标题错误: %+v", secs[1])
|
||||
}
|
||||
|
||||
// 无命中标题 → 回退单章,标题取文件名
|
||||
secs2, err := s.ImportMarkdown(doc.ID, "README.md", "只有正文,没有标题。", "h1")
|
||||
if err != nil {
|
||||
t.Fatalf("import fallback: %v", err)
|
||||
}
|
||||
if len(secs2) != 1 || secs2[0].Title != "README" {
|
||||
t.Fatalf("应回退单章 README: %+v", titles(secs2))
|
||||
}
|
||||
|
||||
// 非法 splitLevel
|
||||
if _, err := s.ImportMarkdown(doc.ID, "x.md", "x", "h3"); err == nil {
|
||||
t.Fatal("非法拆分方式应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryImportMarkdownBatch(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "impb", Title: "批量导入"})
|
||||
|
||||
files := []ImportFileInput{
|
||||
{Filename: "01-不想早死.md", Content: "## 甲\n\n内容甲。"},
|
||||
{Filename: "02-不要慢性自杀.md", Content: "只有正文,没有标题。"},
|
||||
{Filename: "03.md", Content: "# 丙\n\n内容丙。"},
|
||||
}
|
||||
secs, err := s.ImportMarkdownBatch(doc.ID, files, "h2")
|
||||
if err != nil {
|
||||
t.Fatalf("batch import: %v", err)
|
||||
}
|
||||
// 文件1 按 h2 拆出 1 章;文件2、文件3 未命中 ## → 回退单章(标题取文件名)
|
||||
if len(secs) != 3 {
|
||||
t.Fatalf("应创建 3 章,got %d: %+v", len(secs), titles(secs))
|
||||
}
|
||||
if secs[0].Title != "甲" {
|
||||
t.Fatalf("文件1 应按 h2 拆出章: %+v", titles(secs))
|
||||
}
|
||||
if secs[1].Title != "02-不要慢性自杀" {
|
||||
t.Fatalf("文件2 标题应取文件名: %+v", titles(secs))
|
||||
}
|
||||
if secs[2].Title != "03" {
|
||||
t.Fatalf("文件3 未命中 ## 应回退单章: %+v", titles(secs))
|
||||
}
|
||||
for i, sec := range secs {
|
||||
if sec.SortOrder != i {
|
||||
t.Fatalf("sort_order 应按文件顺序连续: %+v", titles(secs))
|
||||
}
|
||||
}
|
||||
|
||||
// 文件数超限
|
||||
many := make([]ImportFileInput, MaxImportFiles+1)
|
||||
for i := range many {
|
||||
many[i] = ImportFileInput{Filename: "f.md", Content: "x"}
|
||||
}
|
||||
if _, err := s.ImportMarkdownBatch(doc.ID, many, "none"); err == nil {
|
||||
t.Fatal("文件数超过上限应报错")
|
||||
}
|
||||
|
||||
// 空文件列表
|
||||
if _, err := s.ImportMarkdownBatch(doc.ID, nil, "none"); err == nil {
|
||||
t.Fatal("空文件列表应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryDeleteSectionCascade(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "cas", Title: "级联"})
|
||||
ch, _ := s.CreateSection(doc.ID, &SectionInput{Title: "章"})
|
||||
sec, _ := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "节"})
|
||||
|
||||
if err := s.DeleteSection(ch.ID); err != nil {
|
||||
t.Fatalf("delete chapter: %v", err)
|
||||
}
|
||||
// 章与节都被软删
|
||||
if _, err := s.section(ch.ID); err == nil {
|
||||
t.Fatal("章应已删除")
|
||||
}
|
||||
if _, err := s.section(sec.ID); err == nil {
|
||||
t.Fatal("章删除时其下小节应级联删除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryDetailCarriesSections(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
doc, _ := s.Create(&LibraryInput{Slug: "carry", Title: "携带", Published: libPub(true)})
|
||||
if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "章一", Content: "正文一"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
d, err := s.GetPublishedBySlug("carry")
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if len(d.Sections) != 1 || d.Sections[0].Content != "正文一" {
|
||||
t.Fatalf("公开详情应携带章节: %+v", d.Sections)
|
||||
}
|
||||
// 草稿条目不可见
|
||||
doc2, _ := s.Create(&LibraryInput{Slug: "draft", Title: "草稿书"})
|
||||
if _, err := s.CreateSection(doc2.ID, &SectionInput{Title: "草稿章"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := s.GetPublishedBySlug("draft"); err == nil {
|
||||
t.Fatal("草稿条目不应出现在公开详情")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
358
backend/service/login_session_test.go
Normal file
358
backend/service/login_session_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是
|
||||
// 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发
|
||||
// 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。
|
||||
func TestRefreshReplayBeyondGraceSelfHeals(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, _, _, _, 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 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2
|
||||
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||
if 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)
|
||||
}
|
||||
|
||||
// 宽限期外重放:自愈放行,返回头节点(当前代)token 对
|
||||
at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("宽限期外重放应自愈放行,实际 %v", err)
|
||||
}
|
||||
if replayRefresh != refresh2 {
|
||||
t.Fatal("自愈应返回轮转链头节点的 refresh token")
|
||||
}
|
||||
if replayUser == nil || replayUser.ID != user.ID {
|
||||
t.Fatal("自愈应返回所属用户")
|
||||
}
|
||||
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
||||
t.Fatalf("头节点 refresh 应保持有效: %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 != 2 {
|
||||
t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n)
|
||||
}
|
||||
if claims, err := s.ParseToken(at); err != nil {
|
||||
t.Fatalf("自愈签发的 access token 应可解析: %v", err)
|
||||
} else if _, err := s.ValidateClaims(claims); err != nil {
|
||||
t.Fatalf("自愈签发的 access token 应有效: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReplayMultiHopHealsToHead 轮转链已前进多代后重放最早一代:
|
||||
// 应沿 rotated_to 链走到头节点,补发最新一代 token 对。
|
||||
func TestRefreshReplayMultiHopHealsToHead(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret")
|
||||
newLoginSessionUser(t, db, "frank")
|
||||
|
||||
_, refresh1, _, _, err := s.Login("frank", "password123", "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("rotate 1: %v", err)
|
||||
}
|
||||
_, refresh3, _, err := s.RotateRefreshToken(refresh2, "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("rotate 2: %v", err)
|
||||
}
|
||||
|
||||
// 各代 rotated_at 均拨回宽限期之外,模拟浏览器长时间后才补投旧 token
|
||||
if err := db.Model(&model.RefreshToken{}).
|
||||
Where("token_hash IN ?", []string{hashRefreshToken(refresh1), hashRefreshToken(refresh2)}).
|
||||
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("多级链重放应自愈到头节点,实际 %v", err)
|
||||
}
|
||||
if replayRefresh != refresh3 {
|
||||
t.Fatal("自愈应返回最新一代(头节点)的 refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReplayDeadChainRejected 轮转链已无有效头节点(家族被整体吊销:
|
||||
// 设备剔除/超限淘汰/改密/封禁)时,宽限期外重放属真实失效,应拒绝并返回
|
||||
// ErrRefreshInvalid,且不得复活任何会话。
|
||||
func TestRefreshReplayDeadChainRejected(t *testing.T) {
|
||||
db := newLoginSessionTestDB(t)
|
||||
s := NewAuthService(db, "test-secret")
|
||||
user := newLoginSessionUser(t, db, "grace")
|
||||
|
||||
_, refresh1, _, _, err := s.Login("grace", "password123", "1.1.1.1", "Chrome-Home")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
// 模拟改密/封禁/剔除设备类处置:家族整体吊销
|
||||
if err := s.RevokeAllUserRefreshTokens(user.ID); err != nil {
|
||||
t.Fatalf("revoke all: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshInvalid) {
|
||||
t.Fatalf("死链重放应返回 ErrRefreshInvalid,实际 %v", err)
|
||||
}
|
||||
if n := countActiveSessions(t, db, user.ID); n != 0 {
|
||||
t.Fatalf("死链自愈不得复活会话,活跃会话应仍为 0,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
449
backend/service/media_library.go
Normal file
449
backend/service/media_library.go
Normal file
@@ -0,0 +1,449 @@
|
||||
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", "品牌资源"},
|
||||
}
|
||||
|
||||
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
|
||||
const (
|
||||
mediaCategoryBook = "book"
|
||||
mediaCategoryBookName = "书籍"
|
||||
)
|
||||
|
||||
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
|
||||
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
|
||||
// 其余按 kind 对应磁盘目录分类。
|
||||
func mediaCategoryForAttachment(kind, source string) (key, name string) {
|
||||
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
|
||||
return mediaCategoryBook, mediaCategoryBookName
|
||||
}
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Key == kind {
|
||||
return cat.Key, cat.Name
|
||||
}
|
||||
}
|
||||
return kind, kind
|
||||
}
|
||||
|
||||
// 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"`
|
||||
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||||
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
|
||||
Source 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.source, 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
|
||||
}
|
||||
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
|
||||
// 上传者/时间取最早一条,计数即该文件的引用次数
|
||||
type urlMeta struct {
|
||||
meta attMeta
|
||||
count int
|
||||
}
|
||||
agg := make(map[string]*urlMeta, len(rows))
|
||||
for _, r := range rows {
|
||||
if a, ok := agg[r.URL]; ok {
|
||||
a.count++
|
||||
if r.CreatedAt.Before(a.meta.CreatedAt) {
|
||||
a.meta = r
|
||||
}
|
||||
} else {
|
||||
agg[r.URL] = &urlMeta{meta: r, count: 1}
|
||||
}
|
||||
}
|
||||
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
|
||||
items := make([]MediaLibraryItem, 0, len(agg))
|
||||
for url, a := range agg {
|
||||
r := a.meta
|
||||
if !strings.HasPrefix(url, "/api/media/") {
|
||||
continue // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||
}
|
||||
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||
continue // 未知 kind 不进媒体库
|
||||
}
|
||||
uploader := r.Nickname
|
||||
if uploader == "" {
|
||||
uploader = r.Username
|
||||
}
|
||||
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||
ca := r.CreatedAt
|
||||
items = append(items, MediaLibraryItem{
|
||||
URL: url, Category: catKey, CategoryName: catName,
|
||||
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
|
||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
|
||||
})
|
||||
}
|
||||
|
||||
// 磁盘扫描五类目录;同名(去扩展名)成组,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 a, ok := agg[url]; ok {
|
||||
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
||||
m := a.meta
|
||||
uploader := m.Nickname
|
||||
if uploader == "" {
|
||||
uploader = m.Username
|
||||
}
|
||||
ca := m.CreatedAt
|
||||
item.Uploader = uploader
|
||||
item.UploadedAt = &ca
|
||||
item.RefCount = a.count
|
||||
if m.Size > 0 {
|
||||
item.Size = int64(m.Size)
|
||||
}
|
||||
if m.Width > 0 {
|
||||
item.Width, item.Height = m.Width, m.Height
|
||||
}
|
||||
// 书库封面 / 正文插图改归“书籍”分类
|
||||
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
|
||||
item.Category = mediaCategoryBook
|
||||
item.CategoryName = mediaCategoryBookName
|
||||
}
|
||||
}
|
||||
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 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
|
||||
floor uint
|
||||
isRoot bool
|
||||
}
|
||||
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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
|
||||
commentIDs := make([]uint, 0, len(commentByURL))
|
||||
for _, cm := range commentByURL {
|
||||
commentIDs = append(commentIDs, cm.id)
|
||||
}
|
||||
commentAnchors := CommentAnchors(s.db, commentIDs)
|
||||
for u, cm := range commentByURL {
|
||||
if a, ok := commentAnchors[cm.id]; ok {
|
||||
cm.floor, cm.isRoot = a.Floor, a.IsRoot
|
||||
commentByURL[u] = cm
|
||||
}
|
||||
}
|
||||
|
||||
// 评论来源要带宿主帖子标题,一次性补齐
|
||||
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 {
|
||||
if cm.floor > 0 {
|
||||
if cm.isRoot {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
|
||||
} else {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
|
||||
}
|
||||
} else {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
|
||||
}
|
||||
title := commentPostTitle[cm.postID]
|
||||
if title != "" {
|
||||
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
||||
} else {
|
||||
it.SourceLabel = "帖子评论"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
444
backend/service/media_library_test.go
Normal file
444
backend/service/media_library_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
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.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
s := NewUploadService(db, dir)
|
||||
if err := s.EnsureDir(); err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
return s, dir
|
||||
}
|
||||
|
||||
func writeMediaFile(t *testing.T, dir, rel string, data []byte) {
|
||||
t.Helper()
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func tinyPNG() []byte {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 12, 8))
|
||||
src.Set(0, 0, color.RGBA{1, 2, 3, 255})
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, src)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes"))
|
||||
writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes"))
|
||||
writeMediaFile(t, dir, "images/solo.png", tinyPNG())
|
||||
writeMediaFile(t, dir, "images/trash.txt", []byte("not an image"))
|
||||
writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp"))
|
||||
writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake"))
|
||||
|
||||
items, counts, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
// 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url
|
||||
pair, ok := byURL["/uploads/images/pair.webp"]
|
||||
if !ok {
|
||||
t.Fatalf("pair.webp missing; items=%v", items)
|
||||
}
|
||||
if pair.OriginalURL != "/uploads/images/pair.jpg" {
|
||||
t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL)
|
||||
}
|
||||
if _, exists := byURL["/uploads/images/pair.jpg"]; exists {
|
||||
t.Fatal("pair.jpg should be hidden behind webp")
|
||||
}
|
||||
|
||||
// 无配对的单文件:保留原格式并解析出宽高
|
||||
solo, ok := byURL["/uploads/images/solo.png"]
|
||||
if !ok {
|
||||
t.Fatal("solo.png missing")
|
||||
}
|
||||
if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 {
|
||||
t.Fatalf("solo.png meta wrong: %+v", solo)
|
||||
}
|
||||
|
||||
// 非图片与 .partial 被过滤
|
||||
if _, exists := byURL["/uploads/images/trash.txt"]; exists {
|
||||
t.Fatal("trash.txt should be excluded")
|
||||
}
|
||||
if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists {
|
||||
t.Fatal(".partial should be excluded")
|
||||
}
|
||||
|
||||
// 广告素材目录计入 ads 分类
|
||||
if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" {
|
||||
t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr)
|
||||
}
|
||||
|
||||
if counts["image"] != 2 || counts["ads"] != 1 {
|
||||
t.Fatalf("counts wrong: %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
// 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles)
|
||||
writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes"))
|
||||
|
||||
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
var alice model.User
|
||||
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: alice.ID, Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/abc.webp", MIME: "image/webp",
|
||||
Size: 4567, Width: 320, Height: 240, CreatedAt: created,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create attachment: %v", err)
|
||||
}
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: alice.ID, Kind: model.AttachmentKindAvatar,
|
||||
URL: "/api/media/remote01", MIME: "image/webp",
|
||||
Size: 999, Width: 128, Height: 128, CreatedAt: created,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create remote attachment: %v", err)
|
||||
}
|
||||
|
||||
items, counts, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
// 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间)
|
||||
disk, ok := byURL["/uploads/images/abc.webp"]
|
||||
if !ok {
|
||||
t.Fatalf("attachment-backed local file missing: %+v", items)
|
||||
}
|
||||
if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 {
|
||||
t.Fatalf("meta not merged: %+v", disk)
|
||||
}
|
||||
|
||||
// 远程存储对象:不在磁盘上也必须出现在媒体库
|
||||
remote, ok := byURL["/api/media/remote01"]
|
||||
if !ok {
|
||||
t.Fatal("remote object missing")
|
||||
}
|
||||
if remote.Category != "avatar" || remote.Uploader != "爱丽丝" {
|
||||
t.Fatalf("remote meta wrong: %+v", remote)
|
||||
}
|
||||
|
||||
if counts["avatar"] != 1 {
|
||||
t.Fatalf("avatar count wrong: %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) {
|
||||
s, _ := newMediaLibraryService(t)
|
||||
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: 1, Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create attachment: %v", err)
|
||||
}
|
||||
|
||||
items, _, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.URL == "/uploads/images/ghost.webp" {
|
||||
t.Fatal("local-missing file should not be listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp"))
|
||||
writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp"))
|
||||
writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg"))
|
||||
|
||||
// 头像被 bob 使用
|
||||
if err := s.db.Create(&model.User{
|
||||
Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
// 帖子正文引用 p1
|
||||
post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 "}
|
||||
if err := s.db.Create(&post).Error; err != nil {
|
||||
t.Fatalf("create post: %v", err)
|
||||
}
|
||||
// 评论引用 c1
|
||||
if err := s.db.Create(&model.Comment{
|
||||
PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create comment: %v", err)
|
||||
}
|
||||
|
||||
items, _, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
av, ok := byURL["/uploads/avatars/av1.webp"]
|
||||
if !ok {
|
||||
t.Fatal("avatar item missing")
|
||||
}
|
||||
if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" {
|
||||
t.Fatalf("avatar source wrong: %+v", av)
|
||||
}
|
||||
|
||||
pimg, ok := byURL["/uploads/images/p1.webp"]
|
||||
if !ok {
|
||||
t.Fatal("post image missing")
|
||||
}
|
||||
if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) ||
|
||||
!strings.Contains(pimg.SourceLabel, "图片来源测试") {
|
||||
t.Fatalf("post source wrong: %+v", pimg)
|
||||
}
|
||||
|
||||
cimg, ok := byURL["/uploads/images/c1.jpg"]
|
||||
if !ok {
|
||||
t.Fatal("comment image missing")
|
||||
}
|
||||
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
|
||||
t.Fatalf("comment source url wrong: %+v", cimg)
|
||||
}
|
||||
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
||||
t.Fatalf("comment source label wrong: %+v", cimg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
|
||||
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
|
||||
|
||||
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
var alice model.User
|
||||
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
mkAtt := func(source, url string) {
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
|
||||
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
|
||||
CreatedAt: created,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create attachment %s: %v", source, err)
|
||||
}
|
||||
}
|
||||
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
|
||||
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
|
||||
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
|
||||
|
||||
items, counts, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
// 封面(磁盘)与正文插图(远程)都归“书籍”
|
||||
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
|
||||
it, ok := byURL[u]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing", u)
|
||||
}
|
||||
if it.Category != "book" || it.CategoryName != "书籍" {
|
||||
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通帖子插图仍归 image
|
||||
if post := byURL["/api/media/post-image"]; post.Category != "image" {
|
||||
t.Fatalf("post image should stay image category, got %q", post.Category)
|
||||
}
|
||||
|
||||
if counts["book"] != 2 || counts["image"] != 1 {
|
||||
t.Fatalf("counts wrong: %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
|
||||
|
||||
mkUser := func(username, nickname string) model.User {
|
||||
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
var u model.User
|
||||
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
alice := mkUser("alice", "爱丽丝")
|
||||
bob := mkUser("bob", "阿Bob")
|
||||
|
||||
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
late := early.Add(2 * time.Hour)
|
||||
|
||||
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
|
||||
mkAtt := func(u model.User, url string, at time.Time) {
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: u.ID, Kind: model.AttachmentKindImage,
|
||||
URL: url, MIME: "image/webp",
|
||||
Size: 100, Width: 10, Height: 10, CreatedAt: at,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create attachment %s: %v", url, err)
|
||||
}
|
||||
}
|
||||
mkAtt(alice, "/uploads/images/dup.webp", early)
|
||||
mkAtt(bob, "/uploads/images/dup.webp", late)
|
||||
mkAtt(bob, "/api/media/rdup", early)
|
||||
mkAtt(alice, "/api/media/rdup", late)
|
||||
|
||||
items, _, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
|
||||
countByURL := map[string]int{}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
countByURL[it.URL]++
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
|
||||
local, ok := byURL["/uploads/images/dup.webp"]
|
||||
if !ok {
|
||||
t.Fatal("dedup local file missing")
|
||||
}
|
||||
if countByURL["/uploads/images/dup.webp"] != 1 {
|
||||
t.Fatal("dedup local file should appear exactly once")
|
||||
}
|
||||
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
|
||||
t.Fatalf("local dedup meta wrong: %+v", local)
|
||||
}
|
||||
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
|
||||
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
|
||||
}
|
||||
|
||||
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
|
||||
remote, ok := byURL["/api/media/rdup"]
|
||||
if !ok {
|
||||
t.Fatal("dedup remote object missing")
|
||||
}
|
||||
if countByURL["/api/media/rdup"] != 1 {
|
||||
t.Fatal("dedup remote object should appear exactly once")
|
||||
}
|
||||
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
|
||||
t.Fatalf("remote dedup meta wrong: %+v", remote)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
93
backend/service/media_thumbs.go
Normal file
93
backend/service/media_thumbs.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||||
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
|
||||
|
||||
// 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
|
||||
}
|
||||
img, err := decodeImageBytes(data, ext == ".webp")
|
||||
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 := scaleImage(img, nw, nh)
|
||||
|
||||
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||||
_ = atomicWriteFile(thumbPath, out)
|
||||
return out, nil
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import (
|
||||
|
||||
type mailPayload struct{ To, Subject, Body string }
|
||||
|
||||
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
|
||||
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
|
||||
|
||||
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
||||
password, e := o.open(c.Password, "mail:password")
|
||||
if e != nil {
|
||||
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
if purpose == "register" {
|
||||
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
|
||||
var n int64
|
||||
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if n > 0 {
|
||||
return 0, ErrEmailRegistered
|
||||
}
|
||||
}
|
||||
code := randomCode()
|
||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -402,6 +415,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
})
|
||||
return 0, e
|
||||
}
|
||||
|
||||
const (
|
||||
codeVerifyMaxAttempts = 5
|
||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
||||
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
|
||||
func blobObjectID(hash string, public bool) string {
|
||||
if public {
|
||||
return "pub_" + hash
|
||||
}
|
||||
return "prv_" + hash
|
||||
}
|
||||
|
||||
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
|
||||
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
|
||||
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
|
||||
v, version, e := o.read(o.db, "storage")
|
||||
if e != nil {
|
||||
return "", errors.New("读取存储配置失败")
|
||||
}
|
||||
c := *v.(*StorageConfig)
|
||||
if c.Backend == "local" {
|
||||
return "", nil
|
||||
}
|
||||
id := blobObjectID(hash, public)
|
||||
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
|
||||
var existing model.StoredObject
|
||||
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
|
||||
return id, nil
|
||||
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return "", e
|
||||
}
|
||||
client, e := o.s3(c)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
f, e := os.Open(path)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
stat, e := f.Stat()
|
||||
if e != nil {
|
||||
_ = f.Close()
|
||||
return "", e
|
||||
}
|
||||
key := c.Prefix + "objects/" + id
|
||||
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
|
||||
_ = f.Close()
|
||||
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
|
||||
}
|
||||
_ = f.Close()
|
||||
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
|
||||
if e = o.db.Create(&obj).Error; e != nil {
|
||||
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
|
||||
return id, nil
|
||||
}
|
||||
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||
return "", errors.New("保存文件记录失败")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
||||
var obj model.StoredObject
|
||||
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||
|
||||
@@ -72,18 +72,18 @@ type NewUserItem struct {
|
||||
|
||||
// OverviewData 首页聚合数据
|
||||
type OverviewData struct {
|
||||
Stats OverviewStats `json:"stats"`
|
||||
Hot []PostListItem `json:"hot"`
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Boards []BoardCount `json:"boards"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
AnnouncementsTotal int64 `json:"announcements_total"`
|
||||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||||
NewUsers []NewUserItem `json:"new_users"`
|
||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||
Ads []PublicAdItem `json:"ads"`
|
||||
AdsPanelTitle string `json:"ads_panel_title"`
|
||||
AdsEnabled bool `json:"ads_enabled"`
|
||||
Stats OverviewStats `json:"stats"`
|
||||
Hot []PostListItem `json:"hot"`
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Boards []BoardCount `json:"boards"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
AnnouncementsTotal int64 `json:"announcements_total"`
|
||||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||||
NewUsers []NewUserItem `json:"new_users"`
|
||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||
Ads []PublicAdItem `json:"ads"`
|
||||
AdsPanelTitle string `json:"ads_panel_title"`
|
||||
AdsEnabled bool `json:"ads_enabled"`
|
||||
Sponsors []PublicSponsorItem `json:"sponsors"`
|
||||
SponsorsPanelTitle string `json:"sponsors_panel_title"`
|
||||
SponsorsEnabled bool `json:"sponsors_enabled"`
|
||||
|
||||
@@ -457,37 +457,37 @@ type PostAttachmentDTO struct {
|
||||
|
||||
// PostDetail 帖子详情(含可见性裁剪与附件)
|
||||
type PostDetail struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
TypeMeta string `json:"type_meta"`
|
||||
TypeStatus string `json:"type_status,omitempty"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
|
||||
Status string `json:"status"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
|
||||
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
TypeMeta string `json:"type_meta"`
|
||||
TypeStatus string `json:"type_status,omitempty"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
|
||||
Status string `json:"status"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
|
||||
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
|
||||
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
|
||||
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
|
||||
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
|
||||
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ContentLocked bool `json:"content_locked"`
|
||||
AccessHint string `json:"access_hint,omitempty"`
|
||||
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ContentLocked bool `json:"content_locked"`
|
||||
AccessHint string `json:"access_hint,omitempty"`
|
||||
Attachments []PostAttachmentDTO `json:"attachments"`
|
||||
Question *QuestionState `json:"question,omitempty"`
|
||||
Poll *PollState `json:"poll,omitempty"`
|
||||
@@ -620,7 +620,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
|
||||
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
|
||||
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
|
||||
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
|
||||
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
|
||||
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
|
||||
Board: post.Board, User: post.User,
|
||||
|
||||
@@ -2,10 +2,12 @@ package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unicode/utf8"
|
||||
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
|
||||
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
|
||||
hasher := sha256.New()
|
||||
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
|
||||
_ = f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
PostID: 0,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
MIME: mimeType,
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
final := absPath(s.dir, hash)
|
||||
var objectID string
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(full, att.MIME, false)
|
||||
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
|
||||
if e != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, e
|
||||
}
|
||||
att.ObjectID = id
|
||||
objectID = id
|
||||
if id != "" {
|
||||
_ = os.Remove(full)
|
||||
}
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
if objectID == "" {
|
||||
if e := placeLocalBlob(full, final); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
PostID: 0,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: hash,
|
||||
BlobHash: hash,
|
||||
ObjectID: objectID,
|
||||
MIME: mimeType,
|
||||
Size: int(written),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Create(att).Error
|
||||
}); err != nil {
|
||||
s.releasePostBlobBestEffort(hash)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
if att.UserID != userID {
|
||||
return ErrAttachmentForbidden
|
||||
}
|
||||
path := absPath(s.dir, att.StoredName)
|
||||
if err := s.db.Delete(&att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if att.BlobHash != "" {
|
||||
return s.releasePostBlob(&att)
|
||||
}
|
||||
if s.ops != nil && att.ObjectID != "" {
|
||||
return s.ops.RemoveObject(att.ObjectID)
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
_ = os.Remove(absPath(s.dir, att.StoredName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
|
||||
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
|
||||
if err != nil || !last {
|
||||
return err
|
||||
}
|
||||
return s.removePrivateBlobObject(b)
|
||||
}
|
||||
|
||||
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
|
||||
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
|
||||
b, last, err := releaseBlobRef(s.db, hash, false)
|
||||
if err != nil {
|
||||
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||
return
|
||||
}
|
||||
if !last {
|
||||
return
|
||||
}
|
||||
if err := s.removePrivateBlobObject(b); err != nil {
|
||||
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
|
||||
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
|
||||
if b.ObjectID != "" && s.ops != nil {
|
||||
return s.ops.RemoveObject(b.ObjectID)
|
||||
}
|
||||
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
||||
type AcceptedAnswer struct {
|
||||
ID uint `json:"id"`
|
||||
Floor int `json:"floor"`
|
||||
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
|
||||
ans := &AcceptedAnswer{
|
||||
ID: c.ID,
|
||||
Floor: floor,
|
||||
IsRoot: c.ParentID == nil,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||
|
||||
@@ -66,7 +66,7 @@ const (
|
||||
SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分
|
||||
|
||||
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
|
||||
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
|
||||
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
|
||||
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
|
||||
SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0
|
||||
SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0
|
||||
@@ -80,6 +80,13 @@ const (
|
||||
|
||||
// SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开)
|
||||
SettingKeyLevelsFx = "levels_fx"
|
||||
|
||||
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
|
||||
SettingKeyUrlStyle = "url_style"
|
||||
|
||||
// SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys);
|
||||
// 缺行/非法/为空视为全量默认
|
||||
SettingKeyMobileTabs = "mobile_tabs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -112,8 +119,22 @@ const (
|
||||
DefaultPointsCheckinBase = 5
|
||||
DefaultPointsStreakEveryDays = 7
|
||||
MaxPointsRule = 100 // 各积分规则值/上限的统一上界
|
||||
|
||||
// DefaultUrlStyle 标准动态路由风格(/post/123)
|
||||
DefaultUrlStyle = "default"
|
||||
|
||||
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
|
||||
MaxMobileTabs = 5
|
||||
)
|
||||
|
||||
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
|
||||
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
|
||||
|
||||
// DefaultMobileTabs 底栏默认全量配置
|
||||
func DefaultMobileTabs() []string {
|
||||
return append([]string(nil), MobileTabKeys...)
|
||||
}
|
||||
|
||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||
var DefaultAttachmentExts = []string{
|
||||
"pdf", "txt", "md", "csv", "json",
|
||||
@@ -127,6 +148,9 @@ var DefaultAttachmentExts = []string{
|
||||
|
||||
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
|
||||
|
||||
// UrlStyles 伪静态风格白名单,与前端 lib/urlStyle.ts 保持一致
|
||||
var UrlStyles = []string{"default", "html", "discuz"}
|
||||
|
||||
// ErrInvalidAccent 主题色格式非法
|
||||
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
|
||||
|
||||
@@ -137,34 +161,34 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
// PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示)
|
||||
type PublicSiteSettings struct {
|
||||
Accent string `json:"accent"`
|
||||
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
|
||||
PublishWithoutReview bool `json:"publish_without_review"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
AllowComments bool `json:"allow_comments"`
|
||||
CommentsRequireLogin bool `json:"comments_require_login"`
|
||||
AllowMessages bool `json:"allow_messages"`
|
||||
PostCooldownHours int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
||||
UIAnimations bool `json:"ui_animations"`
|
||||
AnimCodeFold bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome bool `json:"anim_chrome"`
|
||||
PostLinkNewTab bool `json:"post_link_new_tab"`
|
||||
AttachmentExtLimit bool `json:"attachment_ext_limit"`
|
||||
AttachmentExts []string `json:"attachment_exts"`
|
||||
AttachmentMaxMB int `json:"attachment_max_mb"`
|
||||
AttachmentMaxCount int `json:"attachment_max_count"`
|
||||
ImageMaxMB int `json:"image_max_mb"`
|
||||
Accent string `json:"accent"`
|
||||
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
|
||||
PublishWithoutReview bool `json:"publish_without_review"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
AllowComments bool `json:"allow_comments"`
|
||||
CommentsRequireLogin bool `json:"comments_require_login"`
|
||||
AllowMessages bool `json:"allow_messages"`
|
||||
PostCooldownHours int `json:"post_cooldown_hours"`
|
||||
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
||||
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
||||
UIAnimations bool `json:"ui_animations"`
|
||||
AnimCodeFold bool `json:"anim_code_fold"`
|
||||
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
||||
AnimChrome bool `json:"anim_chrome"`
|
||||
PostLinkNewTab bool `json:"post_link_new_tab"`
|
||||
AttachmentExtLimit bool `json:"attachment_ext_limit"`
|
||||
AttachmentExts []string `json:"attachment_exts"`
|
||||
AttachmentMaxMB int `json:"attachment_max_mb"`
|
||||
AttachmentMaxCount int `json:"attachment_max_count"`
|
||||
ImageMaxMB int `json:"image_max_mb"`
|
||||
|
||||
// 内容锁定与旧帖回复(0=关闭;公开保持规则透明)
|
||||
PostEditLockHours int `json:"post_edit_lock_hours"`
|
||||
CommentEditLockHours int `json:"comment_edit_lock_hours"`
|
||||
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
|
||||
NecroReplyPenalty int `json:"necro_reply_penalty"`
|
||||
PostEditLockHours int `json:"post_edit_lock_hours"`
|
||||
CommentEditLockHours int `json:"comment_edit_lock_hours"`
|
||||
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
|
||||
NecroReplyPenalty int `json:"necro_reply_penalty"`
|
||||
BgSiteURL string `json:"bg_site_url"`
|
||||
BgSiteMode string `json:"bg_site_mode"`
|
||||
BgAdminURL string `json:"bg_admin_url"`
|
||||
@@ -178,6 +202,7 @@ type PublicSiteSettings struct {
|
||||
BrandMark string `json:"brand_mark"`
|
||||
BrandLogoSize string `json:"brand_logo_size"`
|
||||
BrandLogoFit string `json:"brand_logo_fit"`
|
||||
BrandSloganVisible bool `json:"brand_slogan_visible"`
|
||||
FooterLinks []FooterLink `json:"footer_links"`
|
||||
|
||||
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
||||
@@ -194,6 +219,15 @@ type PublicSiteSettings struct {
|
||||
Levels []model.LevelDef `json:"levels"`
|
||||
// LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色)
|
||||
LevelsFx bool `json:"levels_fx"`
|
||||
|
||||
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
|
||||
UrlStyle string `json:"url_style"`
|
||||
|
||||
// MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认
|
||||
MobileTabs []string `json:"mobile_tabs"`
|
||||
|
||||
// HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项
|
||||
HeaderNav []HeaderNavItem `json:"header_nav"`
|
||||
}
|
||||
|
||||
// SettingService 站点级键值设置
|
||||
@@ -297,6 +331,8 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
BrandLogoSize: BrandLogoSizeSQ,
|
||||
BrandLogoFit: BrandLogoFitContain,
|
||||
FooterLinks: []FooterLink{},
|
||||
UrlStyle: DefaultUrlStyle,
|
||||
HeaderNav: DefaultHeaderNav(),
|
||||
}
|
||||
accent, err := s.AccentColor()
|
||||
if err != nil {
|
||||
@@ -494,12 +530,131 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
return out, err
|
||||
}
|
||||
out.BgAdminMode = adminMode
|
||||
urlStyle, err := s.UrlStyle()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.UrlStyle = urlStyle
|
||||
|
||||
out.MobileTabs = s.MobileTabs()
|
||||
out.HeaderNav = s.HeaderNav()
|
||||
|
||||
if err := s.fillBrand(&out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UrlStyle 返回详情页伪静态风格;缺行或值非法时回落 default
|
||||
func (s *SettingService) UrlStyle() (string, error) {
|
||||
v, found, err := s.getValue(SettingKeyUrlStyle)
|
||||
if err != nil {
|
||||
return DefaultUrlStyle, err
|
||||
}
|
||||
if !found {
|
||||
return DefaultUrlStyle, nil
|
||||
}
|
||||
style := strings.TrimSpace(strings.ToLower(v))
|
||||
for _, valid := range UrlStyles {
|
||||
if style == valid {
|
||||
return style, nil
|
||||
}
|
||||
}
|
||||
return DefaultUrlStyle, nil
|
||||
}
|
||||
|
||||
// SetUrlStyle 校验并保存伪静态风格;传 default 表示恢复标准风格(删除该键)
|
||||
func (s *SettingService) SetUrlStyle(style string) error {
|
||||
style = strings.TrimSpace(strings.ToLower(style))
|
||||
if style == DefaultUrlStyle {
|
||||
return s.deleteKey(SettingKeyUrlStyle)
|
||||
}
|
||||
for _, valid := range UrlStyles {
|
||||
if style == valid {
|
||||
return s.putValue(SettingKeyUrlStyle, style)
|
||||
}
|
||||
}
|
||||
return ErrInvalidSiteSetting
|
||||
}
|
||||
|
||||
// normalizeMobileTabs 校验底栏 tab 列表:去空白、去重、只留白名单内元素
|
||||
func normalizeMobileTabs(tabs []string) ([]string, bool) {
|
||||
allowed := make(map[string]struct{}, len(MobileTabKeys))
|
||||
for _, k := range MobileTabKeys {
|
||||
allowed[k] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tabs))
|
||||
out := make([]string, 0, len(tabs))
|
||||
for _, t := range tabs {
|
||||
k := strings.TrimSpace(strings.ToLower(t))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[k]; !ok {
|
||||
return nil, false
|
||||
}
|
||||
if _, dup := seen[k]; dup {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, k)
|
||||
if len(out) > MaxMobileTabs {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// mobileTabsEqual 判断两个列表是否元素一致(忽略顺序)
|
||||
func mobileTabsEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
set := make(map[string]struct{}, len(b))
|
||||
for _, k := range b {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
for _, k := range a {
|
||||
if _, ok := set[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MobileTabs 移动端底栏配置;缺行/为空/非法/解析失败回落默认全量
|
||||
func (s *SettingService) MobileTabs() []string {
|
||||
v, found, err := s.getValue(SettingKeyMobileTabs)
|
||||
if err != nil || !found || strings.TrimSpace(v) == "" {
|
||||
return DefaultMobileTabs()
|
||||
}
|
||||
var tabs []string
|
||||
if err := json.Unmarshal([]byte(v), &tabs); err != nil {
|
||||
return DefaultMobileTabs()
|
||||
}
|
||||
out, ok := normalizeMobileTabs(tabs)
|
||||
if !ok || len(out) == 0 {
|
||||
return DefaultMobileTabs()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetMobileTabs 校验并保存底栏配置;与默认全量一致时删键保持「缺行=默认」语义
|
||||
func (s *SettingService) SetMobileTabs(tabs []string) error {
|
||||
out, ok := normalizeMobileTabs(tabs)
|
||||
if !ok || len(out) == 0 {
|
||||
return ErrInvalidSiteSetting
|
||||
}
|
||||
if mobileTabsEqual(out, MobileTabKeys) {
|
||||
return s.deleteKey(SettingKeyMobileTabs)
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.putValue(SettingKeyMobileTabs, string(b))
|
||||
}
|
||||
|
||||
// AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
|
||||
func (s *SettingService) AccentColor() (string, error) {
|
||||
v, found, err := s.getValue(SettingKeyAccent)
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
var (
|
||||
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
|
||||
)
|
||||
|
||||
// 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名)
|
||||
var sitePageWriteFields = []string{
|
||||
@@ -116,16 +119,27 @@ func (in *SitePageInput) applyTo(p *model.SitePage) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) {
|
||||
q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug)
|
||||
// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引)
|
||||
// active=在用单页占用;deleted=已删除单页占用
|
||||
func (s *SitePageService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) {
|
||||
q := s.db.Unscoped().Model(&model.SitePage{}).Where("slug = ?", slug).Select("deleted_at")
|
||||
if excludeID > 0 {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
if err := q.Count(&n).Error; err != nil {
|
||||
return false, err
|
||||
var rows []struct {
|
||||
DeletedAt gorm.DeletedAt
|
||||
}
|
||||
return n > 0, nil
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DeletedAt.Valid {
|
||||
deleted = true
|
||||
} else {
|
||||
active = true
|
||||
}
|
||||
}
|
||||
return active, deleted, nil
|
||||
}
|
||||
|
||||
// ListSidebarPublished 首页右栏:已发布且勾选侧栏展示
|
||||
@@ -179,11 +193,23 @@ func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, erro
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListAll 管理后台全部单页
|
||||
func (s *SitePageService) ListAll() ([]model.SitePage, error) {
|
||||
var list []model.SitePage
|
||||
err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error
|
||||
return list, err
|
||||
// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型)
|
||||
type SitePageAdminItem struct {
|
||||
model.SitePage
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// ListAll 管理后台全部单页(含已软删,仅供查看与彻底删除)
|
||||
func (s *SitePageService) ListAll() ([]SitePageAdminItem, error) {
|
||||
var pages []model.SitePage
|
||||
if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(100).Find(&pages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SitePageAdminItem, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
out = append(out, SitePageAdminItem{SitePage: p, Deleted: p.DeletedAt.Valid})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get 管理后台按 ID 取单页
|
||||
@@ -200,13 +226,16 @@ func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) {
|
||||
if err := in.normalize(nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken, err := s.slugTaken(in.Slug, 0)
|
||||
active, deleted, err := s.slugTaken(in.Slug, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
if active {
|
||||
return nil, errors.New("slug 已被占用")
|
||||
}
|
||||
if deleted {
|
||||
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||||
}
|
||||
p := &model.SitePage{
|
||||
Published: false,
|
||||
ShowInSidebar: false,
|
||||
@@ -229,13 +258,16 @@ func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, e
|
||||
if err := in.normalize(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken, err := s.slugTaken(in.Slug, id)
|
||||
active, deleted, err := s.slugTaken(in.Slug, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
if active {
|
||||
return nil, errors.New("slug 已被占用")
|
||||
}
|
||||
if deleted {
|
||||
return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用")
|
||||
}
|
||||
in.applyTo(&p)
|
||||
// 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at)
|
||||
if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||||
@@ -264,3 +296,15 @@ func (s *SitePageService) Delete(id uint) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Purge 彻底删除已软删的单页(slug 释放可复用)
|
||||
func (s *SitePageService) Purge(id uint) error {
|
||||
var p model.SitePage
|
||||
if err := s.db.Unscoped().First(&p, id).Error; err != nil {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if !p.DeletedAt.Valid {
|
||||
return ErrSitePageNotDeleted
|
||||
}
|
||||
return s.db.Unscoped().Delete(&p).Error
|
||||
}
|
||||
|
||||
69
backend/service/site_page_test.go
Normal file
69
backend/service/site_page_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newSitePageTestService(t *testing.T) *SitePageService {
|
||||
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.SitePage{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewSitePageService(db)
|
||||
}
|
||||
|
||||
func sitePageInput(slug, title string) *SitePageInput {
|
||||
return &SitePageInput{Slug: slug, Title: title, Content: "正文内容"}
|
||||
}
|
||||
|
||||
func TestSitePageSlugReuseAfterSoftDelete(t *testing.T) {
|
||||
s := newSitePageTestService(t)
|
||||
p, err := s.Create(sitePageInput("test", "测试"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := s.Delete(p.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误
|
||||
_, err = s.Create(sitePageInput("test", "重建"))
|
||||
if err == nil || !strings.Contains(err.Error(), "已删除") {
|
||||
t.Fatalf("软删后重建应提示已删除单页占用,got %v", err)
|
||||
}
|
||||
|
||||
// 管理端列表含软删行且带 deleted 标记
|
||||
all, err := s.ListAll()
|
||||
if err != nil {
|
||||
t.Fatalf("ListAll: %v", err)
|
||||
}
|
||||
if len(all) != 1 || !all[0].Deleted || all[0].Slug != "test" {
|
||||
t.Fatalf("ListAll 应含软删单页且 Deleted=true,got %+v", all)
|
||||
}
|
||||
|
||||
// 彻底删除后可重建
|
||||
if err := s.Purge(p.ID); err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if _, err := s.Create(sitePageInput("test", "重建")); err != nil {
|
||||
t.Fatalf("purge 后重建同 slug 不应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitePagePurgeRequiresDeleted(t *testing.T) {
|
||||
s := newSitePageTestService(t)
|
||||
p, _ := s.Create(sitePageInput("alive", "在用"))
|
||||
if err := s.Purge(p.ID); err == nil || !errors.Is(err, ErrSitePageNotDeleted) {
|
||||
t.Fatalf("未删单页 Purge 应拒绝,got %v", err)
|
||||
}
|
||||
}
|
||||
371
backend/service/timeline_release.go
Normal file
371
backend/service/timeline_release.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
timelineReleaseMaxPages = 10
|
||||
timelineReleaseMaxItems = 100
|
||||
)
|
||||
|
||||
// timelineReleaseSource 内置 Release 适配(Gitea + GitHub)
|
||||
type timelineReleaseSource struct {
|
||||
id string
|
||||
host string // 精确主机或 "*"(通配)
|
||||
listPath string // releases 页正则
|
||||
apiURL string // API 模板
|
||||
query map[string]string
|
||||
headers map[string]string
|
||||
pagination string // link_header | query_page
|
||||
dateField string // 优先取的字段:published_at / created_at
|
||||
}
|
||||
|
||||
var timelineReleaseSources = []timelineReleaseSource{
|
||||
{
|
||||
id: "github_release",
|
||||
host: "github.com",
|
||||
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
|
||||
apiURL: "https://api.github.com/repos/{owner}/{repo}/releases",
|
||||
query: map[string]string{"per_page": "100", "page": "{page}"},
|
||||
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json"},
|
||||
pagination: "link_header",
|
||||
dateField: "published_at",
|
||||
},
|
||||
{
|
||||
id: "gitea_release",
|
||||
host: "*",
|
||||
listPath: `^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/?$`,
|
||||
apiURL: "https://{host}/api/v1/repos/{owner}/{repo}/releases",
|
||||
query: map[string]string{"limit": "50", "page": "{page}"},
|
||||
headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/json"},
|
||||
pagination: "query_page",
|
||||
dateField: "created_at",
|
||||
},
|
||||
}
|
||||
|
||||
// matchReleaseSource 按 host + path 匹配内置 release 适配
|
||||
func matchReleaseSource(host, path string) (*timelineReleaseSource, map[string]string, error) {
|
||||
for i := range timelineReleaseSources {
|
||||
src := &timelineReleaseSources[i]
|
||||
if src.host != "*" && !strings.EqualFold(src.host, host) {
|
||||
continue
|
||||
}
|
||||
if src.host == "*" && strings.EqualFold(host, "github.com") {
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(src.listPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m := re.FindStringSubmatch(path); m != nil {
|
||||
return src, subexpMap(re, m), nil
|
||||
}
|
||||
}
|
||||
return nil, nil, errors.New("地址不符或主机未配置")
|
||||
}
|
||||
|
||||
// ImportTimelineFromReleases 按 releases 页 URL 拉取并解析发布记录
|
||||
func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGitImportResult, error) {
|
||||
cleanURLs := make([]string, 0, len(urls))
|
||||
for _, u := range urls {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
cleanURLs = append(cleanURLs, u)
|
||||
}
|
||||
}
|
||||
if len(cleanURLs) == 0 {
|
||||
return nil, errors.New("请提供至少一条 URL")
|
||||
}
|
||||
if len(cleanURLs) > 20 {
|
||||
return nil, errors.New("一次最多 20 条 URL")
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timelineGitHTTPTimeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: publicOnlyDial,
|
||||
TLSHandshakeTimeout: timelineGitHTTPTimeout,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("重定向过多")
|
||||
}
|
||||
if err := assertSafeHTTPSURL(req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
seenURL := map[string]bool{}
|
||||
var items []TimelineGitItem
|
||||
var failMsgs []string
|
||||
truncated := false
|
||||
|
||||
for _, rawURL := range cleanURLs {
|
||||
part, partTrunc, err := s.importOneReleaseURL(client, rawURL, seenURL, timelineReleaseMaxItems-len(items))
|
||||
if err != nil {
|
||||
failMsgs = append(failMsgs, fmt.Sprintf("%s:%s", truncateTimelineStr(rawURL, 80), err.Error()))
|
||||
continue
|
||||
}
|
||||
items = append(items, part...)
|
||||
if partTrunc {
|
||||
truncated = true
|
||||
}
|
||||
if len(items) >= timelineReleaseMaxItems {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := &TimelineGitImportResult{Items: items}
|
||||
if truncated {
|
||||
out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", timelineReleaseMaxItems)
|
||||
}
|
||||
if len(failMsgs) > 0 {
|
||||
out.Error = strings.Join(failMsgs, ";")
|
||||
}
|
||||
if len(items) == 0 && out.Error == "" {
|
||||
out.Error = "未能解析出 Release"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) importOneReleaseURL(
|
||||
client *http.Client,
|
||||
rawURL string,
|
||||
seenURL map[string]bool,
|
||||
remain int,
|
||||
) ([]TimelineGitItem, bool, error) {
|
||||
if remain <= 0 {
|
||||
return nil, true, nil
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, false, errors.New("URL 无效")
|
||||
}
|
||||
if err := assertSafeHTTPSURL(u); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
path := u.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
src, caps, err := matchReleaseSource(host, path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) {
|
||||
return nil, false, errors.New("仓库名非法")
|
||||
}
|
||||
|
||||
startPage := 1
|
||||
if p := u.Query().Get("page"); p != "" {
|
||||
if n, e := parseIntPage(p); e == nil && n >= 1 {
|
||||
startPage = n
|
||||
}
|
||||
}
|
||||
|
||||
var out []TimelineGitItem
|
||||
truncated := false
|
||||
page := startPage
|
||||
maxPages := timelineReleaseMaxPages
|
||||
pagesDone := 0
|
||||
nextURL := ""
|
||||
|
||||
for pagesDone < maxPages && len(out) < remain {
|
||||
var apiURL string
|
||||
if nextURL != "" {
|
||||
apiURL = nextURL
|
||||
nextURL = ""
|
||||
} else {
|
||||
apiURL = expandTemplate(src.apiURL, host, caps, page)
|
||||
q := url.Values{}
|
||||
for k, v := range src.query {
|
||||
q.Set(k, expandTemplate(v, host, caps, page))
|
||||
}
|
||||
parsed, e := url.Parse(apiURL)
|
||||
if e != nil {
|
||||
return out, truncated, errors.New("API URL 无效")
|
||||
}
|
||||
if len(q) > 0 {
|
||||
existing := parsed.Query()
|
||||
for k, vs := range q {
|
||||
existing.Set(k, vs[0])
|
||||
}
|
||||
parsed.RawQuery = existing.Encode()
|
||||
}
|
||||
apiURL = parsed.String()
|
||||
}
|
||||
parsedAPI, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return out, truncated, errors.New("API URL 无效")
|
||||
}
|
||||
if err := assertSafeHTTPSURL(parsedAPI); err != nil {
|
||||
return out, truncated, err
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
for k, v := range src.headers {
|
||||
if allowedAdapterHeaders[strings.ToLower(k)] {
|
||||
headers.Set(k, v)
|
||||
}
|
||||
}
|
||||
if headers.Get("User-Agent") == "" {
|
||||
headers.Set("User-Agent", "jiang13-bbs")
|
||||
}
|
||||
|
||||
body, linkNext, status, err := httpGetLimited(client, parsedAPI.String(), headers)
|
||||
if err != nil {
|
||||
return out, truncated, err
|
||||
}
|
||||
if status == 404 || status == 401 || status == 403 {
|
||||
return out, truncated, errors.New("无法读取该仓库(私有、不存在或无权访问)")
|
||||
}
|
||||
if status == 429 {
|
||||
return out, truncated, errors.New("远端限流,请稍后再试")
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return out, truncated, fmt.Errorf("远端返回 %d", status)
|
||||
}
|
||||
|
||||
pageItems, err := parseReleaseListJSON(body, src, host)
|
||||
if err != nil {
|
||||
return out, truncated, err
|
||||
}
|
||||
if len(pageItems) == 0 {
|
||||
break
|
||||
}
|
||||
for _, it := range pageItems {
|
||||
if it.SourceURL != "" && seenURL[it.SourceURL] {
|
||||
continue
|
||||
}
|
||||
if it.SourceURL != "" {
|
||||
seenURL[it.SourceURL] = true
|
||||
}
|
||||
out = append(out, it)
|
||||
if len(out) >= remain {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
pagesDone++
|
||||
if src.pagination == "link_header" && linkNext != "" {
|
||||
nu, e := url.Parse(linkNext)
|
||||
if e != nil || assertSafeHTTPSURL(nu) != nil {
|
||||
break
|
||||
}
|
||||
nextURL = nu.String()
|
||||
} else if src.pagination == "query_page" {
|
||||
page++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if pagesDone >= maxPages {
|
||||
truncated = true
|
||||
}
|
||||
return out, truncated, nil
|
||||
}
|
||||
|
||||
func parseReleaseListJSON(body []byte, src *timelineReleaseSource, host string) ([]TimelineGitItem, error) {
|
||||
var root any
|
||||
if err := json.Unmarshal(body, &root); err != nil {
|
||||
return nil, errors.New("响应非 JSON")
|
||||
}
|
||||
arr, ok := root.([]any)
|
||||
if !ok {
|
||||
return nil, errors.New("响应不是 Release 列表")
|
||||
}
|
||||
var out []TimelineGitItem
|
||||
for _, el := range arr {
|
||||
item, ok := mapReleaseObject(el, src, host)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapReleaseObject(el any, src *timelineReleaseSource, host string) (TimelineGitItem, bool) {
|
||||
m, ok := el.(map[string]any)
|
||||
if !ok {
|
||||
return TimelineGitItem{}, false
|
||||
}
|
||||
// 跳过草稿
|
||||
if draft, ok := m["draft"].(bool); ok && draft {
|
||||
return TimelineGitItem{}, false
|
||||
}
|
||||
name := jsonStringField(m, "name")
|
||||
tag := jsonStringField(m, "tag_name")
|
||||
title := strings.TrimSpace(name)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tag)
|
||||
}
|
||||
if title == "" {
|
||||
return TimelineGitItem{}, false
|
||||
}
|
||||
body := jsonStringField(m, "body")
|
||||
dateRaw := jsonStringField(m, src.dateField)
|
||||
if dateRaw == "" {
|
||||
// 兜底用 created_at
|
||||
dateRaw = jsonStringField(m, "created_at")
|
||||
}
|
||||
srcURL := jsonStringField(m, "html_url")
|
||||
|
||||
title = sanitizeTimelinePlain(title, timelineTitleMax)
|
||||
body = sanitizeTimelinePlain(body, timelineBodyMax)
|
||||
title = neutralizeDirectivePlain(title)
|
||||
body = neutralizeDirectivePlain(body)
|
||||
date := parseCommitDate(dateRaw)
|
||||
srcURL = sanitizeSourceURL(srcURL, host)
|
||||
return TimelineGitItem{
|
||||
Date: date,
|
||||
Title: title,
|
||||
Body: body,
|
||||
SourceURL: srcURL,
|
||||
}, true
|
||||
}
|
||||
|
||||
// jsonStringField 从 map[string]any 取字符串字段,兼容 string/number
|
||||
func jsonStringField(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", x)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func parseIntPage(s string) (int, error) {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, errors.New("page 非数字")
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
if n > 1_000_000 {
|
||||
return 0, errors.New("page 过大")
|
||||
}
|
||||
}
|
||||
return n, 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
|
||||
@@ -109,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
||||
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + ".webp"
|
||||
hash := sha256Hex(data)
|
||||
filename := hash + ".webp"
|
||||
fullPath := filepath.Join(s.dir, "avatars", filename)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
url := "/uploads/avatars/" + filename
|
||||
var objectID string
|
||||
writeLocal := func() error {
|
||||
if _, err := os.Stat(fullPath); err == nil {
|
||||
return nil // 同内容头像已落盘,字节必然一致,无需重写
|
||||
}
|
||||
return os.WriteFile(fullPath, data, 0o644)
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindAvatar,
|
||||
URL: "/uploads/avatars/" + filename,
|
||||
MIME: "image/webp",
|
||||
Size: len(data),
|
||||
Width: cfg.Width,
|
||||
Height: cfg.Height,
|
||||
}
|
||||
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||
if err := writeLocal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
|
||||
if e != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, e
|
||||
}
|
||||
objectID = id
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
url = "/api/media/" + id
|
||||
_ = os.Remove(fullPath)
|
||||
}
|
||||
} else if err := writeLocal(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 附件记录与用户头像更新在同一事务内完成
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindAvatar,
|
||||
URL: url,
|
||||
BlobHash: hash,
|
||||
MIME: "image/webp",
|
||||
Size: len(data),
|
||||
Width: cfg.Width,
|
||||
Height: cfg.Height,
|
||||
}
|
||||
// 附件记录、blob 引用与用户头像更新在同一事务内完成
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
|
||||
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
@@ -190,8 +205,51 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
||||
// 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, source string) (*model.Attachment, error) {
|
||||
if src == nil {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
@@ -284,33 +342,71 @@ 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
|
||||
hash, err := hashFile(storePath)
|
||||
if err != nil {
|
||||
_ = os.Remove(storePath)
|
||||
return nil, err
|
||||
}
|
||||
finalName := hash + strings.ToLower(filepath.Ext(storeName))
|
||||
url := "/uploads/images/" + finalName
|
||||
var objectID string
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
|
||||
if e != nil {
|
||||
_ = os.Remove(storePath)
|
||||
return nil, e
|
||||
}
|
||||
objectID = id
|
||||
if id != "" {
|
||||
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
|
||||
url = "/api/media/" + id
|
||||
_ = os.Remove(storePath)
|
||||
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/" + filename,
|
||||
MIME: format.mime,
|
||||
Size: int(total),
|
||||
Width: w,
|
||||
Height: h,
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
Source: normalizeImageSource(source),
|
||||
URL: url,
|
||||
BlobHash: hash,
|
||||
MIME: storeMime,
|
||||
Size: int(storeSize),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
if s.ops != nil {
|
||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
||||
if e != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, e
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id != "" {
|
||||
att.URL = "/api/media/" + id
|
||||
_ = os.Remove(fullPath)
|
||||
}
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return tx.Create(att).Error
|
||||
}); err != nil {
|
||||
s.releaseBlobBestEffort(hash, true, url)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
@@ -523,6 +619,46 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
||||
return s.SaveBackground(f)
|
||||
}
|
||||
|
||||
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
|
||||
func normalizeImageSource(s string) string {
|
||||
switch s {
|
||||
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
|
||||
return s
|
||||
default:
|
||||
return model.AttachmentSourcePost
|
||||
}
|
||||
}
|
||||
|
||||
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
|
||||
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
||||
// source 决定副本的来源标记(如 library_cover)
|
||||
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||
var att model.Attachment
|
||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||
return nil, errors.New("图片不存在或不属于你")
|
||||
}
|
||||
if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer r.Close()
|
||||
return s.SaveImage(userID, r, source)
|
||||
}
|
||||
abs, ok := s.safeUploadPath(att.URL)
|
||||
if !ok {
|
||||
return nil, errors.New("无效的图片地址")
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, errors.New("读取图片失败")
|
||||
}
|
||||
defer f.Close()
|
||||
return s.SaveImage(userID, f, source)
|
||||
}
|
||||
|
||||
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
||||
func (s *UploadService) BackgroundFileExists(url string) bool {
|
||||
rel := BgRelPath(url)
|
||||
@@ -576,6 +712,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
|
||||
return list, err
|
||||
}
|
||||
|
||||
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
|
||||
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||
var att model.Attachment
|
||||
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &att, nil
|
||||
}
|
||||
|
||||
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
||||
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
||||
|
||||
@@ -610,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if att.BlobHash != "" {
|
||||
return s.releaseAttachmentBlob(&att)
|
||||
}
|
||||
|
||||
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||||
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||
@@ -624,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
|
||||
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
|
||||
if err != nil || !last {
|
||||
return err
|
||||
}
|
||||
return s.removePublicBlobObject(b, att.URL)
|
||||
}
|
||||
|
||||
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
|
||||
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
|
||||
b, last, err := releaseBlobRef(s.db, hash, public)
|
||||
if err != nil {
|
||||
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||
return
|
||||
}
|
||||
if !last {
|
||||
return
|
||||
}
|
||||
if err := s.removePublicBlobObject(b, localURL); err != nil {
|
||||
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
|
||||
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
|
||||
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
|
||||
if b.ObjectID != "" && s.ops != nil {
|
||||
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
|
||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if localURL == "" {
|
||||
return nil
|
||||
}
|
||||
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
|
||||
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
||||
|
||||
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{}, &model.Blob{}); 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()), "post")
|
||||
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()), "post")
|
||||
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()), "post")
|
||||
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()), "post")
|
||||
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())
|
||||
}
|
||||
}
|
||||
112
backend/service/url_style_test.go
Normal file
112
backend/service/url_style_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newUrlStyleTestService(t *testing.T) *SettingService {
|
||||
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.SiteSetting{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return NewSettingService(db)
|
||||
}
|
||||
|
||||
func TestUrlStyleDefaultWhenMissing(t *testing.T) {
|
||||
s := newUrlStyleTestService(t)
|
||||
got, err := s.UrlStyle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != DefaultUrlStyle {
|
||||
t.Fatalf("missing row should fall back to %q, got %q", DefaultUrlStyle, got)
|
||||
}
|
||||
pub, err := s.Public()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pub.UrlStyle != DefaultUrlStyle {
|
||||
t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, DefaultUrlStyle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUrlStyleRoundTrip(t *testing.T) {
|
||||
s := newUrlStyleTestService(t)
|
||||
for _, style := range []string{"html", "discuz"} {
|
||||
if err := s.SetUrlStyle(style); err != nil {
|
||||
t.Fatalf("SetUrlStyle(%q): %v", style, err)
|
||||
}
|
||||
got, err := s.UrlStyle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != style {
|
||||
t.Fatalf("UrlStyle() = %q, want %q", got, style)
|
||||
}
|
||||
pub, err := s.Public()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pub.UrlStyle != style {
|
||||
t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUrlStyleDefaultDeletesKey(t *testing.T) {
|
||||
s := newUrlStyleTestService(t)
|
||||
if err := s.SetUrlStyle("html"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 传 default 应删键,恢复「缺行=默认」语义(大小写与空白容错)
|
||||
if err := s.SetUrlStyle(" DEFAULT "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.UrlStyle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != DefaultUrlStyle {
|
||||
t.Fatalf("after reset got %q, want %q", got, DefaultUrlStyle)
|
||||
}
|
||||
var rows int64
|
||||
s.db.Where("key = ?", SettingKeyUrlStyle).Model(&model.SiteSetting{}).Count(&rows)
|
||||
if rows != 0 {
|
||||
t.Fatalf("default should delete the row, rows=%d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUrlStyleRejectsInvalid(t *testing.T) {
|
||||
s := newUrlStyleTestService(t)
|
||||
for _, bad := range []string{"", "pretty", "HTML5", "thread", "../etc"} {
|
||||
if err := s.SetUrlStyle(bad); err == nil {
|
||||
t.Fatalf("SetUrlStyle(%q) should fail", bad)
|
||||
}
|
||||
}
|
||||
// 非法值不应落库
|
||||
if _, err := s.UrlStyle(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUrlStyleInvalidStoredValueFallsBack(t *testing.T) {
|
||||
s := newUrlStyleTestService(t)
|
||||
if err := s.putValue(SettingKeyUrlStyle, "nonsense"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.UrlStyle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != DefaultUrlStyle {
|
||||
t.Fatalf("invalid stored value should fall back to %q, got %q", DefaultUrlStyle, got)
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,26 @@ func TestClassifyReferrer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSearchTerm(t *testing.T) {
|
||||
// 「新人报道」的 UTF-8 百分号编码前缀,模拟 512 字节截断
|
||||
news := "%E6%96%B0%E4%BA%BA%E6%8A%A5%E9%81%93"
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"%E4%BD%A0%E5%A5%BD", "你好"}, // 完整编码
|
||||
{"a+b", "a b"}, // + 按空格解码
|
||||
{"plain", "plain"}, // 无编码
|
||||
{"2222%E5%A6%82%E6%9E%9C", "2222如果"}, // 完整转义结尾
|
||||
{news + "%E6%96", "新人报道"}, // 截断后结尾是完整转义但只是半截字符 → 去非法 UTF-8 尾巴
|
||||
{news + "%E6%96%9", "新人报道"}, // 一位十六进制的残缺转义 → 剥尾重试 + 去半截字符
|
||||
{news + "%", "新人报道"}, // 截断在孤立的 %
|
||||
{news + "%E", "新人报道"}, // 截断在 1 位十六进制
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := decodeSearchTerm(c.raw); got != c.want {
|
||||
t.Errorf("decodeSearchTerm(%q) = %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
@@ -88,11 +91,11 @@ type VisitSourcesKindDay struct {
|
||||
|
||||
// VisitSources 来源分析 Tab
|
||||
type VisitSources struct {
|
||||
RangeDays int `json:"range_days"`
|
||||
Kinds []VisitNamedCount `json:"kinds"`
|
||||
SearchHost []VisitNamedCount `json:"search_hosts"`
|
||||
External []VisitNamedCount `json:"external"`
|
||||
Trend []VisitSourcesKindDay `json:"trend"`
|
||||
RangeDays int `json:"range_days"`
|
||||
Kinds []VisitNamedCount `json:"kinds"`
|
||||
SearchTerms []VisitNamedCount `json:"search_terms"` // 站内搜索词 Top N(/?q= 结果页浏览)
|
||||
External []VisitNamedCount `json:"external"`
|
||||
Trend []VisitSourcesKindDay `json:"trend"`
|
||||
}
|
||||
|
||||
// VisitPages 受访页面 Tab
|
||||
@@ -336,8 +339,7 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
||||
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
||||
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
|
||||
if out.SearchTerms, err = s.siteSearchTerms(start, 10); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
||||
@@ -350,6 +352,64 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// siteSearchTerms 站内搜索词:聚合搜索结果页(/?q=…,path 记录完整 query)的浏览量。
|
||||
// 词以 URL 编码存于 path,按编码串分组后解码展示(站内 encodeURIComponent 编码确定性强)。
|
||||
func (s *VisitStatsService) siteSearchTerms(start time.Time, limit int) ([]VisitNamedCount, error) {
|
||||
const expr = "substring(path from '[?&]q=([^&]*)')"
|
||||
var rows []struct {
|
||||
Key string
|
||||
Count int64
|
||||
}
|
||||
if err := s.db.Model(&model.VisitEvent{}).
|
||||
Select(expr+" AS key, COUNT(*) AS count").
|
||||
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||
Where(expr + " IS NOT NULL AND " + expr + " <> ''").
|
||||
Group("key").Order("count DESC").Limit(limit).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]VisitNamedCount, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
term := decodeSearchTerm(r.Key)
|
||||
if runes := []rune(term); len(runes) > 30 {
|
||||
term = string(runes[:30]) + "…"
|
||||
}
|
||||
out = append(out, VisitNamedCount{Key: term, Label: term, Count: r.Count})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decodeSearchTerm 解码 path 里 q 参数值(URL 编码)。path 512 字节截断可能:
|
||||
// 1) 把 %XX 转义拦腰切断(孤 % / 一位十六进制)→ 整串解码失败,剥掉残缺尾部重试;
|
||||
// 2) 恰好切在多字节字符的转义序列中间 → 解码「成功」但尾部是半截字符,去掉非法 UTF-8 尾巴。
|
||||
func decodeSearchTerm(raw string) string {
|
||||
term := strings.ReplaceAll(raw, "+", " ")
|
||||
dec, err := url.QueryUnescape(term)
|
||||
if err != nil {
|
||||
// 残缺转义(如结尾孤 % 或一位十六进制):剥掉最后一个 % 起的尾部重试
|
||||
if i := strings.LastIndex(term, "%"); i > 0 {
|
||||
dec, err = url.QueryUnescape(term[:i])
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return term
|
||||
}
|
||||
return validUTF8Prefix(dec)
|
||||
}
|
||||
|
||||
// validUTF8Prefix 返回 s 的最长合法 UTF-8 前缀(截断产生的半截多字节字符会被去掉;
|
||||
// 真实的 U+FFFD 字符合法保留,与非法字节可区分)。
|
||||
func validUTF8Prefix(s string) string {
|
||||
for i := 0; i < len(s); {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
return s[:i]
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SourcesTrend 来源按日明细(kind 分列行)
|
||||
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
|
||||
days := parseRangeDays(rangeQ)
|
||||
|
||||
@@ -9,7 +9,10 @@ RUN go mod download
|
||||
COPY backend/ ./
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
# -tags nodynamic:gen2brain/webp 默认经 purego dlopen libwebp.so,会把二进制动态链接到
|
||||
# glibc(INTERP /lib64/ld-linux),Alpine 无法 exec(0.2.1 首版事故)。该标签强制走
|
||||
# wasm2go 转译的纯 Go 实现,保持 CGO_ENABLED=0 全静态。
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -tags nodynamic \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -X github.com/freefire/jiang13-bbs/version.Version=${VERSION} -X github.com/freefire/jiang13-bbs/version.Commit=${COMMIT}" \
|
||||
-o /out/jiang13 ./cmd/jiang13
|
||||
|
||||
36
docs/migration-announcement.md
Normal file
36
docs/migration-announcement.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 旧站迁移公告:账号数据已迁入,旧站将全面搬家
|
||||
|
||||
各位社区成员:
|
||||
|
||||
**旧网站将在不久后全面迁移到姜十三论坛**,以后我们就在新家见面。
|
||||
|
||||
## 目前已迁移:用户账号
|
||||
|
||||
旧站账号数据已完成迁移,包括用户名、昵称、个性签名、邮箱和头像。
|
||||
|
||||
**无需重新注册**,直接用旧站的**用户名 + 原密码**登录即可——密码是原样迁移的,不需要重置。登录后建议在「账号设置」里核对一下资料。
|
||||
|
||||

|
||||
|
||||
## 暂未迁移:帖子与评论
|
||||
|
||||
旧站的帖子与评论**暂时不会搬过来**。因为直接迁移会丢失旧帖里的图片,迁移方案还在完善中。等完成后我们会发布公告,把大家的帖子完整搬进来,请耐心等待。
|
||||
|
||||
在此之前,新内容请直接发在新站。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q:需要重新注册吗?**
|
||||
A:不需要,用旧站用户名和密码直接登录。
|
||||
|
||||
**Q:密码忘了怎么办?**
|
||||
A:联系管理员,核对身份后协助重置。
|
||||
|
||||
**Q:旧帖会丢失吗?**
|
||||
A:不会。帖子与评论等迁移方案完善后会完整搬入,届时另行公告。
|
||||
|
||||
---
|
||||
|
||||
如有问题或建议,可通过邮件联系:aarbbs@88.com
|
||||
|
||||
—— 姜十三论坛 团队
|
||||
@@ -68,7 +68,7 @@
|
||||
- cdn:默认空,可选 HTTPS 基址。只用于已声明公开图片,部署方必须配置其桶/前缀权限。私有文件仍走鉴权下载;CDN 不是将私有桶设为公开的指令。
|
||||
- image_max_mb:默认 5 MiB,范围 1–50;attachment_max_mb:默认 20 MiB,范围 1–100;attachment_max_count:默认每帖 10,范围 1–20。原 API 每次上传一个文件,前端批量仍受每帖上限约束。
|
||||
- attachment_ext_limit:默认 true;attachment_exts:继承原业务白名单,1–80 个小写字母数字扩展名。原站点支持软件安装包和技术文本;这些仍只作为强制下载附件,不能内联执行。
|
||||
- 正文图片复用 JPEG/PNG/WebP 解码检查与最大 4096px;头像保持 2 MiB、64–512px WebP;SVG 仅保留管理员品牌上传能力。附件复用真实图片头校验,伪装/活跃内容降为二进制并强制 attachment/nosniff。
|
||||
- 正文图片复用 JPEG/PNG/WebP/GIF 解码检查与最大 4096px,其中 JPEG/PNG 落盘前统一转存 WebP(JPEG 有损 q80、PNG 无损;GIF 动图与已是 WebP 的原样保留,转码失败降级为保留原格式);头像保持 2 MiB、64–512px WebP;SVG 仅保留管理员品牌上传能力。附件复用真实图片头校验,伪装/活跃内容降为二进制并强制 attachment/nosniff。
|
||||
- 1 MiB=1,048,576 字节;仓库 Nginx 示例与 Next 代理缓冲均为 512 MiB,仍需确认实际部署入口没有更低限制。
|
||||
- 保存 S3 前验证合并后的草稿,在专用 tests/ 前缀上传、读回并删除一个随机测试对象,不扫描桶。失败不更新配置;不会自动切回其他存储。
|
||||
- 每个远程对象保存不可变 storage-N 配置引用与对象键。切换后原本地 URL、历史远程配置继续可读;界面显示历史引用数,不提供删除历史配置按钮。
|
||||
|
||||
@@ -9,16 +9,16 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
import {
|
||||
SiteDocShell,
|
||||
SiteDocIdentity,
|
||||
SiteDocAnnoNav,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { pagePath, type UrlStyle } from "@/lib/urlStyle";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
@@ -31,14 +31,16 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
function PageRow({
|
||||
p,
|
||||
showDivider,
|
||||
urlStyle,
|
||||
}: {
|
||||
p: SitePageSummary;
|
||||
showDivider: boolean;
|
||||
urlStyle: UrlStyle;
|
||||
}) {
|
||||
const Icon = pageGlyph(p.slug);
|
||||
return (
|
||||
<Link
|
||||
href={`/p/${p.slug}`}
|
||||
href={pagePath(urlStyle, p.slug)}
|
||||
className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
|
||||
>
|
||||
@@ -91,31 +93,19 @@ export default async function AboutIndexPage() {
|
||||
]);
|
||||
const announcements = overview?.announcements ?? [];
|
||||
const siteLead = settings.site_description?.trim() || undefined;
|
||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
||||
const urlStyle = settings.url_style;
|
||||
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||
|
||||
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
|
||||
const right = <SiteDocAnnoNav items={announcements} />;
|
||||
|
||||
return (
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
right={right}
|
||||
mobileExtras={
|
||||
announcements.length > 0 ? <SiteDocAnnoNav items={announcements} /> : null
|
||||
}
|
||||
>
|
||||
<SiteDocShell left={left} right={right}>
|
||||
<div className="panel overflow-hidden">
|
||||
<header
|
||||
className="px-5 sm:px-6 pt-5 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<SiteDocBreadcrumb
|
||||
className="mb-3"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "关于本站" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1
|
||||
@@ -154,7 +144,7 @@ export default async function AboutIndexPage() {
|
||||
<p className="meta mt-1.5">有新文档时会显示在这里</p>
|
||||
</div>
|
||||
) : (
|
||||
pages.map((p, i) => <PageRow key={p.id} p={p} showDivider={i > 0} />)
|
||||
pages.map((p, i) => <PageRow key={p.id} p={p} showDivider={i > 0} urlStyle={urlStyle} />)
|
||||
)}
|
||||
</div>
|
||||
</SiteDocShell>
|
||||
|
||||
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loader2, RefreshCw, ShieldAlert } from "lucide-react";
|
||||
import { apiMe, apiRefresh } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
|
||||
/**
|
||||
* 后台布局降级自愈:SSR 侧 /api/me 未识别出用户(access 过期轮转竞态、后端
|
||||
* 瞬断等)时布局只能渲染无壳的降级卡片。本组件挂载后走客户端链路复判——
|
||||
* apiMe 内部会在 401 时经 fetchWithRefresh 自动续期后重试:
|
||||
* - 复判出用户 → router.refresh() 让服务端重出完整后台壳(顶栏/抽屉回归);
|
||||
* - 仍是游客且凭据已被清理 → 会话确实失效,带回归地址去登录页;
|
||||
* - 仍是游客但凭据还在(refresh 瞬态失败/后端暂不可用)→ 转入 offline 态,
|
||||
* 有限次自动重试(间隔 5s),期间随时可手动重试/重新登录。
|
||||
* refresh 成功但服务端仍识别失败时最多重试 3 次,超出转入手动重试,避免循环刷新。
|
||||
*/
|
||||
const MAX_AUTO_REFRESH = 3;
|
||||
// offline 态自动重试次数(间隔 5s,共 50s):后端短暂抖动/重启场景无需用户手动干预
|
||||
const MAX_AUTO_RETRY = 10;
|
||||
const AUTO_RETRY_DELAY_MS = 5000;
|
||||
|
||||
export default function AdminSessionRecover() {
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState<"recovering" | "offline">("recovering");
|
||||
const autoRefreshLeft = useRef(MAX_AUTO_REFRESH);
|
||||
const autoRetryLeft = useRef(MAX_AUTO_RETRY);
|
||||
|
||||
const recover = useCallback(() => {
|
||||
setState("recovering");
|
||||
apiMe()
|
||||
.then(async (res) => {
|
||||
if (res.user) {
|
||||
if (autoRefreshLeft.current > 0) {
|
||||
autoRefreshLeft.current -= 1;
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setState("offline");
|
||||
return;
|
||||
}
|
||||
// apiMe 内部的续期复判以 sessionSeen 为门(本页从未识别出用户时为 false,
|
||||
// 不会触发),这里显式补一轮 refresh:access 竞态/瞬断多数在此恢复
|
||||
const back = encodeURIComponent(location.pathname + location.search);
|
||||
if (hasAuthCookieHint()) {
|
||||
const refreshed = await apiRefresh();
|
||||
if (refreshed.ok) {
|
||||
const again = await apiMe();
|
||||
if (again.user) {
|
||||
if (autoRefreshLeft.current > 0) {
|
||||
autoRefreshLeft.current -= 1;
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setState("offline");
|
||||
return;
|
||||
}
|
||||
setState("offline");
|
||||
return;
|
||||
}
|
||||
if (refreshed.transient) {
|
||||
// 后端暂不可用:无法证明登录态失效,留在原地可手动重试
|
||||
setState("offline");
|
||||
return;
|
||||
}
|
||||
// refresh 明确失效(后端同时下发清 cookie):会话确实终结,回跳登录
|
||||
router.replace(`/login?redirect=${back}`);
|
||||
return;
|
||||
}
|
||||
// 无任何凭据痕迹:直接回跳登录
|
||||
router.replace(`/login?redirect=${back}`);
|
||||
})
|
||||
.catch(() => setState("offline"));
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
recover();
|
||||
}, [recover]);
|
||||
|
||||
// offline 态有限次自动重试:后端短暂抖动/重启期间留在本页轮询恢复,
|
||||
// 次数用尽后仅手动重试可再触发,避免无限轮询;手动重试会重置次数。
|
||||
useEffect(() => {
|
||||
if (state !== "offline") return;
|
||||
if (autoRetryLeft.current <= 0) return;
|
||||
autoRetryLeft.current -= 1;
|
||||
const t = window.setTimeout(recover, AUTO_RETRY_DELAY_MS);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [state, recover]);
|
||||
|
||||
const loginHref =
|
||||
state === "offline" && typeof window !== "undefined"
|
||||
? `/login?redirect=${encodeURIComponent(location.pathname + location.search)}`
|
||||
: "/login";
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full panel p-8 text-center">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||
>
|
||||
<ShieldAlert size={26} />
|
||||
</span>
|
||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
正在恢复登录状态
|
||||
</h1>
|
||||
{state === "recovering" ? (
|
||||
<p className="meta mt-2 text-[13px] inline-flex items-center justify-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||
正在确认会话,稍候将自动进入后台…
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="meta mt-2 text-[13px]">暂时无法确认登录状态,请稍后重试。</p>
|
||||
<div className="flex items-center justify-center gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
autoRefreshLeft.current = MAX_AUTO_REFRESH;
|
||||
autoRetryLeft.current = MAX_AUTO_RETRY;
|
||||
recover();
|
||||
}}
|
||||
className="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden /> 重试
|
||||
</button>
|
||||
<Link href={loginHref} className="btn btn-line">
|
||||
重新登录
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4">
|
||||
<Link href="/" className="meta text-[13px]">
|
||||
返回首页
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { postTypeLabel } from "@/lib/postMeta";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { postPath, userPath } from "@/lib/urlStyle";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import { AdminSegmented } from "@/components/admin";
|
||||
import {
|
||||
AdminChartCard,
|
||||
@@ -67,6 +69,7 @@ export default function DashboardClient({
|
||||
totalUsers?: number;
|
||||
canUsers: boolean;
|
||||
}) {
|
||||
const urlStyle = useUrlStyle();
|
||||
const [range, setRange] = useState<RangeKey>("7d");
|
||||
const [data, setData] = useState(initialAnalytics);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -162,7 +165,7 @@ export default function DashboardClient({
|
||||
newUsers.slice(0, 8).map((u) => (
|
||||
<Link
|
||||
key={u.id}
|
||||
href={`/u/${u.id}`}
|
||||
href={userPath(urlStyle, u.id)}
|
||||
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
@@ -213,7 +216,7 @@ export default function DashboardClient({
|
||||
data.top_viewed.map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/post/${p.id}`}
|
||||
href={postPath(urlStyle, p.id)}
|
||||
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
|
||||
@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
|
||||
import { AdText } from "@/components/AdText";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||
|
||||
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
||||
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
||||
@@ -88,11 +89,11 @@ export default function AdsAdmin({
|
||||
enabled: true,
|
||||
panel_title: "自助推广",
|
||||
durations: [
|
||||
{ days: 30, label: "1 个月", price_hint: "" },
|
||||
{ days: 60, label: "2 个月", price_hint: "" },
|
||||
{ days: 90, label: "3 个月", price_hint: "" },
|
||||
{ days: 180, label: "6 个月", price_hint: "" },
|
||||
{ days: 365, label: "12 个月", price_hint: "" },
|
||||
{ days: 30, label: "1 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 60, label: "2 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 90, label: "3 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 180, label: "6 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 365, label: "12 个月", price_hint: "", price_hint_text: "" },
|
||||
],
|
||||
payments: [],
|
||||
}
|
||||
@@ -489,7 +490,10 @@ export default function AdsAdmin({
|
||||
onClick={() =>
|
||||
setCfg({
|
||||
...cfg,
|
||||
durations: [...cfg.durations, { days: 14, label: "14 天", price_hint: "" }],
|
||||
durations: [
|
||||
...cfg.durations,
|
||||
{ days: 14, label: "14 天", price_hint: "", price_hint_text: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -500,43 +504,90 @@ export default function AdsAdmin({
|
||||
{cfg.durations.length === 0 ? (
|
||||
<p className="meta text-[13px]">至少保留一个时长档位。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{cfg.durations.map((d, i) => (
|
||||
<div key={i} className="j13-ads-row">
|
||||
<input
|
||||
type="number"
|
||||
className="field w-20"
|
||||
value={d.days}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, days: Number(e.target.value) || 1 };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="天数"
|
||||
/>
|
||||
<input
|
||||
className="field min-w-0 flex-1"
|
||||
value={d.label}
|
||||
placeholder="标签,如 1 个月"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, label: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-label="删除档位"
|
||||
onClick={() =>
|
||||
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: "var(--line)" }}>
|
||||
<table className="w-full min-w-[600px] text-left text-[13px]">
|
||||
<thead>
|
||||
<tr className="meta text-[12px]" style={{ background: "var(--surface)" }}>
|
||||
<th className="px-3 py-2 font-medium">天数</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<th className="px-3 py-2 font-medium">图片价格</th>
|
||||
<th className="px-3 py-2 font-medium">文字价格</th>
|
||||
<th className="px-3 py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cfg.durations.map((d, i) => (
|
||||
<tr key={i} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
className="field w-20"
|
||||
value={d.days}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, days: Number(e.target.value) || 1 };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="天数"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.label}
|
||||
placeholder="如 1 个月"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, label: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.price_hint}
|
||||
maxLength={32}
|
||||
placeholder="如 ¥299,可空"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, price_hint: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="图片广告价格"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.price_hint_text}
|
||||
maxLength={32}
|
||||
placeholder="如 ¥99,可空"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, price_hint_text: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="文字广告价格"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-label="删除档位"
|
||||
onClick={() =>
|
||||
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SubBlock>
|
||||
@@ -832,8 +883,14 @@ function AdCard({
|
||||
{/* 预览缩略 */}
|
||||
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
||||
{ad.kind === "image" && ad.image_url ? (
|
||||
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
|
||||
<img
|
||||
src={isOwnImage(ad.image_url) ? variantURL(ad.image_url, pickVariantWidth(88)) : ad.image_url}
|
||||
srcSet={isOwnImage(ad.image_url) ? densitySrcSet(ad.image_url, 88) : undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : ad.kind === "text" && ad.title ? (
|
||||
<div className="flex h-full w-full items-center justify-center p-1.5">
|
||||
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
|
||||
@@ -940,8 +997,14 @@ function PaymentEditor({
|
||||
{/* 二维码:点击即上传 */}
|
||||
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
||||
{payment.qr_url ? (
|
||||
// 96×96 收款码位:本站图走 96/160 变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
|
||||
<img
|
||||
src={isOwnImage(payment.qr_url) ? variantURL(payment.qr_url, pickVariantWidth(96)) : payment.qr_url}
|
||||
srcSet={isOwnImage(payment.qr_url) ? densitySrcSet(payment.qr_url, 96) : undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
<Upload size={16} />
|
||||
@@ -1034,8 +1097,14 @@ function SponsorEditor({
|
||||
{/* Logo:点击即上传 */}
|
||||
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
||||
{item.logo_url ? (
|
||||
// 120×56 Logo 位:本站图走 160/256 变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
|
||||
<img
|
||||
src={isOwnImage(item.logo_url) ? variantURL(item.logo_url, pickVariantWidth(120)) : item.logo_url}
|
||||
srcSet={isOwnImage(item.logo_url) ? densitySrcSet(item.logo_url, 120) : undefined}
|
||||
alt=""
|
||||
className="h-full w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
<Upload size={15} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type SponsorsConfig,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
||||
export default async function AdminAdsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
if (!me.user || !hasPerm(me.user, PERMS.ADS)) {
|
||||
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,34 @@ function kindBadge(kind: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 日期筛选:复用搜索框壳层样式,与工具条同高同视觉 */
|
||||
function DateField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="admin-field-shell admin-search-shell w-full sm:w-44">
|
||||
<span className="shrink-0 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="admin-field-input"
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsInner() {
|
||||
const searchParams = useSearchParams();
|
||||
// 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
|
||||
@@ -86,6 +114,17 @@ function DetailsInner() {
|
||||
|
||||
const resetPage = () => setPage(1);
|
||||
|
||||
const hasFilter = Boolean(kind || path || ip || bot || start || end);
|
||||
const resetAll = () => {
|
||||
setKind("");
|
||||
setPath("");
|
||||
setIp("");
|
||||
setBot("");
|
||||
setStart("");
|
||||
setEnd("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="meta text-[12.5px]">
|
||||
@@ -99,55 +138,45 @@ function DetailsInner() {
|
||||
<div className="w-full sm:w-56">
|
||||
<AdminSearchField
|
||||
value={path}
|
||||
onChange={(v) => { setPath(v); }}
|
||||
onChange={(v) => { setPath(v); resetPage(); }}
|
||||
onSubmit={resetPage}
|
||||
onClear={() => { setPath(""); resetPage(); }}
|
||||
placeholder="路径模糊匹配,回车应用"
|
||||
ariaLabel="按路径筛选"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-44">
|
||||
<div className="w-full sm:w-48">
|
||||
<AdminSearchField
|
||||
value={ip}
|
||||
onChange={setIp}
|
||||
onChange={(v) => { setIp(v); resetPage(); }}
|
||||
onSubmit={resetPage}
|
||||
onClear={() => { setIp(""); resetPage(); }}
|
||||
placeholder="按 IP 筛选,回车应用"
|
||||
placeholder="按 IP 精确筛选"
|
||||
ariaLabel="按 IP 筛选"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-40">
|
||||
<div className="w-full sm:w-44">
|
||||
<AdminSearchField
|
||||
value={bot}
|
||||
onChange={setBot}
|
||||
onChange={(v) => { setBot(v); resetPage(); }}
|
||||
onSubmit={resetPage}
|
||||
onClear={() => { setBot(""); resetPage(); }}
|
||||
placeholder="按爬虫名筛选"
|
||||
placeholder="按爬虫名精确筛选"
|
||||
ariaLabel="按爬虫名筛选"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
从
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={(e) => { setStart(e.target.value); resetPage(); }}
|
||||
className="admin-field-input"
|
||||
style={{ width: 150 }}
|
||||
aria-label="开始日期"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
至
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
|
||||
className="admin-field-input"
|
||||
style={{ width: 150 }}
|
||||
aria-label="结束日期"
|
||||
/>
|
||||
</label>
|
||||
<DateField label="从" value={start} onChange={(v) => { setStart(v); resetPage(); }} ariaLabel="开始日期" />
|
||||
<DateField label="至" value={end} onChange={(v) => { setEnd(v); resetPage(); }} ariaLabel="结束日期" />
|
||||
{hasFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
className="admin-field-shell admin-search-shell w-full sm:w-auto justify-center text-[13px] cursor-pointer transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
||||
import { apiStatsSources, type VisitSources } from "@/lib/api";
|
||||
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
|
||||
import { RangeSegmented, StatsState, TableEmpty, formatInt, refKindLabel } from "../ui";
|
||||
|
||||
/** 来源按日表:固定展示四类主要来源列 */
|
||||
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
|
||||
@@ -49,11 +49,25 @@ export default function SourcesPage() {
|
||||
</AdminChartCard>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
|
||||
{data.search_hosts.length ? (
|
||||
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
|
||||
<AdminChartCard title="站内搜索词 Top 10" hint="搜索结果页(/?q=)浏览量 Top 10">
|
||||
{data.search_terms.length ? (
|
||||
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||
{data.search_terms.map((t, i) => (
|
||||
<li key={`${i}-${t.key}`} className="py-2 flex items-center gap-3 text-[13px]">
|
||||
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={t.key}>
|
||||
{t.key}
|
||||
</span>
|
||||
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||
{formatInt(t.count)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TableEmpty text="暂无搜索引擎来源" />
|
||||
<TableEmpty text="暂无站内搜索" />
|
||||
)}
|
||||
</AdminChartCard>
|
||||
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminPageHeader,
|
||||
@@ -62,6 +63,7 @@ export default function AnnouncementAdmin({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const urlStyle = useUrlStyle();
|
||||
const statusFilter = readStatus(searchParams.get("status"));
|
||||
const [list, setList] = useState(initial);
|
||||
const [loadError, setLoadError] = useState(initError);
|
||||
@@ -215,7 +217,7 @@ export default function AnnouncementAdmin({
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`} title={a.tag}>
|
||||
{a.tag}
|
||||
</span>
|
||||
{a.published ? <code>{announcementPublicPath(a.id)}</code> : null}
|
||||
{a.published ? <code>{announcementPublicPath(a.id, urlStyle)}</code> : null}
|
||||
</p>
|
||||
{summary ? <p className="admin-doc-excerpt" title={summary}>{summary}</p> : null}
|
||||
</td>
|
||||
@@ -241,7 +243,7 @@ export default function AnnouncementAdmin({
|
||||
</Link>
|
||||
{a.published ? (
|
||||
<a
|
||||
href={announcementPublicPath(a.id)}
|
||||
href={announcementPublicPath(a.id, urlStyle)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-doc-textbtn"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors";
|
||||
|
||||
@@ -117,6 +118,7 @@ export default function AnnouncementComposeClient({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const urlStyle = useUrlStyle();
|
||||
const status = readStatus(searchParams.get("status"));
|
||||
const backHref = listHref(status);
|
||||
const loaded = useMemo(() => toForm(initial), [initial]);
|
||||
@@ -146,7 +148,7 @@ export default function AnnouncementComposeClient({
|
||||
};
|
||||
|
||||
const canViewOnline = saved?.published === true && saved.id > 0;
|
||||
const publicPath = saved ? announcementPublicPath(saved.id) : "";
|
||||
const publicPath = saved ? announcementPublicPath(saved.id, urlStyle) : "";
|
||||
|
||||
async function commit() {
|
||||
if (saving) return;
|
||||
@@ -227,7 +229,7 @@ export default function AnnouncementComposeClient({
|
||||
|
||||
async function copyAddress() {
|
||||
if (!saved) return;
|
||||
const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id)}`;
|
||||
const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id, urlStyle)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast("已复制访问地址", "ok");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../../../Forbidden";
|
||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnnouncementColor } from "@/lib/api";
|
||||
import { announcementPath, type UrlStyle } from "@/lib/urlStyle";
|
||||
|
||||
/** 标签色板:与后端 AnnouncementColor 白名单一致(12 色) */
|
||||
export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||
@@ -17,6 +18,6 @@ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[]
|
||||
];
|
||||
|
||||
/** 公告公开路径。前台是 /announcement/[id],列表和线上查看用同一条。 */
|
||||
export function announcementPublicPath(id: number): string {
|
||||
return `/announcement/${id}`;
|
||||
export function announcementPublicPath(id: number, urlStyle: UrlStyle = "default"): string {
|
||||
return announcementPath(urlStyle, id);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { hasPerm, PERMS } from "@/lib/roles";
|
||||
import Forbidden from "../../Forbidden";
|
||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
||||
export default async function AdminAnnouncementNewPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user