From 1d862a0dd3aa3026a0fe969b928eef966f2e0b45 Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 27 Sep 2026 02:35:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AB=99=E7=82=B9=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E5=BA=93=E4=B8=8E=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=BA=95=E9=83=A8?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=EF=BC=8C=E6=9C=8D=E5=8A=A1=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A1=A5=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明 --- backend/go.mod | 8 +- backend/go.sum | 13 +- backend/handler/admin_legacy_import.go | 136 +++- backend/handler/auth.go | 7 +- backend/handler/upload.go | 23 +- backend/router/router.go | 98 ++- backend/router/router_test.go | 33 + backend/service/ad.go | 31 +- backend/service/auth.go | 35 +- backend/service/board.go | 21 +- backend/service/board_sidebar_test.go | 69 ++ backend/service/chat.go | 10 +- backend/service/legacyimport.go | 545 ++++++++++++--- backend/service/legacyimport_test.go | 281 ++++++++ backend/service/login_device.go | 39 +- backend/service/login_session_test.go | 276 ++++++++ backend/service/media_library.go | 388 ++++++++++ backend/service/media_library_test.go | 312 +++++++++ backend/service/media_thumbs.go | 114 +++ backend/service/upload.go | 88 ++- backend/service/upload_webp_test.go | 172 +++++ docs/migration-announcement.md | 36 + docs/site-operations.md | 2 +- frontend/app/about/page.tsx | 8 - frontend/app/admin/ads/AdsAdmin.tsx | 136 ++-- frontend/app/admin/badges/BadgesClient.tsx | 660 +++++++++++------- frontend/app/admin/media/MediaLibrary.tsx | 274 ++++++++ frontend/app/admin/media/page.tsx | 49 ++ .../app/admin/settings/import/ImportPanel.tsx | 385 +++++++++- frontend/app/admin/sidebar/SidebarAdmin.tsx | 84 ++- frontend/app/ads/buy/AdsBuyClient.tsx | 22 +- frontend/app/announcement/[id]/page.tsx | 9 - frontend/app/announcements/page.tsx | 8 - frontend/app/chat/ChatShell.tsx | 10 +- frontend/app/chat/[id]/ChatRoomClient.tsx | 116 ++- frontend/app/globals.css | 311 +++++++-- frontend/app/layout.tsx | 10 +- frontend/app/notifications/page.tsx | 7 - frontend/app/p/[slug]/page.tsx | 9 - frontend/app/page.tsx | 46 +- frontend/app/post/[id]/page.tsx | 43 +- frontend/components/AccountGuard.tsx | 6 +- frontend/components/BackToTop.tsx | 10 +- frontend/components/CommentSection.tsx | 6 +- frontend/components/Header.tsx | 161 ++--- frontend/components/ImageInsertModal.tsx | 4 +- frontend/components/MarkdownEditor.tsx | 8 +- frontend/components/MobilePostBar.tsx | 62 -- frontend/components/MobileTabBar.tsx | 192 +++++ frontend/components/NotificationBell.tsx | 64 +- frontend/components/SiteChrome.tsx | 17 +- frontend/components/admin/AdminShell.tsx | 52 +- frontend/components/home/BoardPanels.tsx | 11 +- frontend/components/home/WorkspaceNav.tsx | 159 +++-- .../components/site-doc/SiteDocBreadcrumb.tsx | 43 -- frontend/components/site-doc/SiteDocShell.tsx | 4 - frontend/components/site-doc/index.ts | 2 - frontend/lib/api.ts | 155 +++- frontend/lib/cookies.ts | 2 +- frontend/lib/realtime.ts | 2 +- frontend/lib/userEvents.ts | 2 +- 61 files changed, 4757 insertions(+), 1129 deletions(-) create mode 100644 backend/router/router_test.go create mode 100644 backend/service/board_sidebar_test.go create mode 100644 backend/service/legacyimport_test.go create mode 100644 backend/service/login_session_test.go create mode 100644 backend/service/media_library.go create mode 100644 backend/service/media_library_test.go create mode 100644 backend/service/media_thumbs.go create mode 100644 backend/service/upload_webp_test.go create mode 100644 docs/migration-announcement.md create mode 100644 frontend/app/admin/media/MediaLibrary.tsx create mode 100644 frontend/app/admin/media/page.tsx delete mode 100644 frontend/components/MobilePostBar.tsx create mode 100644 frontend/components/MobileTabBar.tsx delete mode 100644 frontend/components/site-doc/SiteDocBreadcrumb.tsx diff --git a/backend/go.mod b/backend/go.mod index 3292433..6f40307 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 6e44f5e..1407020 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/handler/admin_legacy_import.go b/backend/handler/admin_legacy_import.go index 4dc075b..12712de 100644 --- a/backend/handler/admin_legacy_import.go +++ b/backend/handler/admin_legacy_import.go @@ -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 == "" { diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 5e512bb..150ec29 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -213,8 +213,9 @@ func (h *Handlers) Login(c *gin.Context) { // dev 模式不设 Secure,生产环境需 HTTPS h.Ops.ClearFailure(req.Username) setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode) - // 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则 - // 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现) + // 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧 + // 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等 + // access token 过期或硬刷新才发现) if kicked > 0 { h.Hub.BroadcastUser(user.ID, realtime.Envelope{ Type: realtime.EventSessionReplaced, @@ -309,7 +310,7 @@ func (h *Handlers) MyLoginDevices(c *gin.Context) { } familyID := claims.FamilyID if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil { - if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 { + if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, claims.ID); fid != 0 { familyID = fid } } diff --git a/backend/handler/upload.go b/backend/handler/upload.go index f028ffc..a323e87 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -49,7 +49,7 @@ func (h *Handlers) UploadAvatar(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att}) } -// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP) +// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP / GIF;除 GIF 外自动转存 WebP) func (h *Handlers) UploadImage(c *gin.Context) { claims := middleware.CurrentUser(c) @@ -311,6 +311,27 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": url}) } +// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据) +func (h *Handlers) AdminMediaLibrary(c *gin.Context) { + items, counts, err := h.Upload.AdminMediaLibrary() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取媒体库失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "counts": counts, "total": len(items)}) +} + +// MediaLibraryThumb 媒体库网格缩略图(?u=/uploads/images/xx.webp),失败回退由前端处理 +func (h *Handlers) MediaLibraryThumb(c *gin.Context) { + data, err := h.Upload.MediaThumb(c.Query("u")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "缩略图不可用"}) + return + } + c.Header("Cache-Control", "private, max-age=86400") + c.Data(http.StatusOK, "image/webp", data) +} + func brandTooLarge(slot string) string { if slot == service.BrandSlotFavicon { return "Favicon 不能超过 512KB" diff --git a/backend/router/router.go b/backend/router/router.go index 042b5b2..feaddfb 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -3,6 +3,7 @@ package router import ( "context" "log" + "net" "os" "path/filepath" "strings" @@ -18,6 +19,30 @@ import ( "github.com/gin-gonic/gin" ) +// parseTrustedProxies 解析 TRUSTED_PROXIES(逗号分隔的 IP/CIDR/localhost)。 +// 容忍条目两侧空白与空项,无法解析的条目跳过并告警:编排环境变量多一个空格 +// 不应让整个服务拒绝启动(真实事故:尾随空格致 invalid CIDR 秒退)。 +// 全部非法或未配置时返回 nil,退化为「ClientIP 记 TCP 对端」并触发启动警告。 +func parseTrustedProxies(v string) []string { + var out []string + for _, part := range strings.Split(v, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "localhost" { + out = append(out, part) + continue + } + if _, _, err := net.ParseCIDR(part); err != nil && net.ParseIP(part) == nil { + log.Printf("警告: TRUSTED_PROXIES 条目 %q 无法解析,已忽略", part) + continue + } + out = append(out, part) + } + return out +} + // Setup 初始化路由 func Setup(cfg *config.Config) (*gin.Engine, error) { if cfg.DevMode { @@ -27,12 +52,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } r := gin.New() - var proxies []string - if v := os.Getenv("TRUSTED_PROXIES"); v != "" { - proxies = strings.Split(v, ",") - } else { + proxies := parseTrustedProxies(os.Getenv("TRUSTED_PROXIES")) + if len(proxies) == 0 { // 未配置信任代理时 ClientIP 退化为 TCP 直连对端:反代/容器部署会记录内网地址 - log.Println("警告: TRUSTED_PROXIES 未配置,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12") + log.Println("警告: TRUSTED_PROXIES 未配置或无有效条目,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12") } if err := r.SetTrustedProxies(proxies); err != nil { return nil, err @@ -55,7 +78,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { })) // 服务 - authSvc := service.NewAuthService(model.DB, cfg.JWTSecret) + authSvc := service.NewAuthService(model.DB, cfg.JWTSecret).WithDevMode(cfg.DevMode) boardSvc := service.NewBoardService(model.DB) settingSvc := service.NewSettingService(model.DB) // 等级体系快照随进程启动加载(后台保存时经 SetLevels 刷新,无需重启) @@ -82,6 +105,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) chatSvc := service.NewChatService(model.DB, notifSvc) + // 导入建号绕过注册链路,显式接线:导入用户同样加入默认全站大厅 + legacyImportSvc.WithHallMembership(chatSvc.EnsureDefaultMembership) badgeSvc := service.NewBadgeService(model.DB).WithNotification(notifSvc) visitSvc := service.NewVisitStatsService(model.DB, settingSvc) analyticsSvc := service.NewAnalyticsService(model.DB) @@ -102,35 +127,35 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { go visitSvc.RunRetention(pipeCtx) h := &handler.Handlers{ - Ops: ops, - Cfg: cfg, - Hub: realtime.NewHub(), - Auth: authSvc, - Board: boardSvc, - Post: postSvc, - Comment: commentSvc, - Like: likeSvc, - Favorite: favoriteSvc, - Follow: followSvc, - Notification: notifSvc, - OverviewSvc: overviewSvc, - Checkin: checkinSvc, - Announcement: announcementSvc, - SitePage: sitePageSvc, - Upload: uploadSvc, - PostFile: postFileSvc, - Points: pointsSvc, - Setting: settingSvc, - AdminUser: adminUserSvc, - LegacyImport: legacyImportSvc, - Moderation: moderationSvc, - Chat: chatSvc, - Visit: visitSvc, - Analytics: analyticsSvc, - HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), - Ads: adSvc, - Sidebar: service.NewSidebarService(model.DB), - Badge: badgeSvc, + Ops: ops, + Cfg: cfg, + Hub: realtime.NewHub(), + Auth: authSvc, + Board: boardSvc, + Post: postSvc, + Comment: commentSvc, + Like: likeSvc, + Favorite: favoriteSvc, + Follow: followSvc, + Notification: notifSvc, + OverviewSvc: overviewSvc, + Checkin: checkinSvc, + Announcement: announcementSvc, + SitePage: sitePageSvc, + Upload: uploadSvc, + PostFile: postFileSvc, + Points: pointsSvc, + Setting: settingSvc, + AdminUser: adminUserSvc, + LegacyImport: legacyImportSvc, + Moderation: moderationSvc, + Chat: chatSvc, + Visit: visitSvc, + Analytics: analyticsSvc, + HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), + Ads: adSvc, + Sidebar: service.NewSidebarService(model.DB), + Badge: badgeSvc, LeaderboardSvc: leaderboardSvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) @@ -391,6 +416,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia) staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand) staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia) + // 媒体库:全站图片盘点(只读展示)+ 网格缩略图 + staffAPI.GET("/media-library", authMW.RequirePerm(service.PermSettings), h.AdminMediaLibrary) + staffAPI.GET("/media-library/thumb", authMW.RequirePerm(service.PermSettings), h.MediaLibraryThumb) // 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史 usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers)) diff --git a/backend/router/router_test.go b/backend/router/router_test.go new file mode 100644 index 0000000..700be7f --- /dev/null +++ b/backend/router/router_test.go @@ -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) + } + }) + } +} diff --git a/backend/service/ad.go b/backend/service/ad.go index 3394509..bd657bf 100644 --- a/backend/service/ad.go +++ b/backend/service/ad.go @@ -38,11 +38,12 @@ var ( adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`) ) -// AdDurationOption 可购时长档位 +// AdDurationOption 可购时长档位(图片 / 文字广告分别计价) type AdDurationOption struct { - Days int `json:"days"` - Label string `json:"label"` - PriceHint string `json:"price_hint"` + Days int `json:"days"` + Label string `json:"label"` + PriceHint string `json:"price_hint"` // 图片广告价格 + PriceHintText string `json:"price_hint_text"` // 文字广告价格 } // AdPaymentOption 收款方式(展示二维码,人工确认) @@ -68,11 +69,11 @@ func DefaultAdConfig() AdConfig { Enabled: true, PanelTitle: AdDefaultTitle, Durations: []AdDurationOption{ - {Days: 30, Label: "1 个月", PriceHint: ""}, - {Days: 60, Label: "2 个月", PriceHint: ""}, - {Days: 90, Label: "3 个月", PriceHint: ""}, - {Days: 180, Label: "6 个月", PriceHint: ""}, - {Days: 365, Label: "12 个月", PriceHint: ""}, + {Days: 30, Label: "1 个月", PriceHint: "", PriceHintText: ""}, + {Days: 60, Label: "2 个月", PriceHint: "", PriceHintText: ""}, + {Days: 90, Label: "3 个月", PriceHint: "", PriceHintText: ""}, + {Days: 180, Label: "6 个月", PriceHint: "", PriceHintText: ""}, + {Days: 365, Label: "12 个月", PriceHint: "", PriceHintText: ""}, }, Payments: []AdPaymentOption{}, } @@ -116,6 +117,9 @@ func (s *AdService) loadConfig() (AdConfig, error) { if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" { cfg.Durations[i].PriceHint = "" } + if strings.TrimSpace(cfg.Durations[i].PriceHintText) == "面议" { + cfg.Durations[i].PriceHintText = "" + } } return cfg, nil } @@ -195,7 +199,14 @@ func normalizeAdConfig(in AdConfig) (AdConfig, error) { if utf8.RuneCountInString(price) > 32 { return AdConfig{}, ErrAdInvalid } - durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price}) + priceText := strings.TrimSpace(d.PriceHintText) + if priceText == "面议" { + priceText = "" + } + if utf8.RuneCountInString(priceText) > 32 { + return AdConfig{}, ErrAdInvalid + } + durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price, PriceHintText: priceText}) } out.Durations = durs diff --git a/backend/service/auth.go b/backend/service/auth.go index 3adc6eb..0c1bbb7 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -73,6 +73,7 @@ type AuthService struct { db *gorm.DB jwtSecret []byte encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生) + devMode bool } func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService { @@ -81,6 +82,21 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService { return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]} } +// WithDevMode dev 模式放宽轮转宽限期:开发中 go run / next dev 重启频繁, +// 轮换响应丢失后的重放往往远超 60s 才回来,同链重放不应升级为盗用判定 +func (s *AuthService) WithDevMode(dev bool) *AuthService { + s.devMode = dev + return s +} + +// rotationGrace 轮转宽限期:dev 放宽(见 RefreshRotationGrace 注释) +func (s *AuthService) rotationGrace() time.Duration { + if s.devMode { + return 15 * time.Minute + } + return RefreshRotationGrace +} + // dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名 var dummyPasswordHash, _ = bcrypt.GenerateFromPassword( []byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost) @@ -133,7 +149,8 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e } // Login 用户登录,返回 access token + refresh token + user; -// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知) +// kicked 为本次登录因超出会话上限被淘汰的最旧会话数(多会话并存: +// 新登录不踢旧设备,仅超过 maxActiveSessions 时裁剪;>0 供上层广播告知) func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) { var user model.User if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { @@ -317,7 +334,8 @@ func (s *AuthService) decryptToken(encoded string) (string, error) { } // CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。 -// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。 +// 多会话并存:各设备登录互不影响,新会话构成独立的轮转家族(family_id); +// 返回值 kicked 为因超出会话上限被淘汰的最旧会话行数。 func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) { token := generateRandomToken() cipherText, err := s.encryptToken(token) @@ -342,7 +360,7 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui return "", 0, 0, err } rt.FamilyID = rt.ID - kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID) + kicked := s.enforceSessionCap(userID) return token, rt.FamilyID, kicked, nil } @@ -410,7 +428,7 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin if rt.Revoked { // 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对 - if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace { + if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= s.rotationGrace() { var next model.RefreshToken if e := tx.First(&next, rt.RotatedTo).Error; e == nil && next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) && @@ -423,13 +441,16 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin } } } - // 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族 + // 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP: + // 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期 + // middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线) + fam := sessionFamilyID(rt) if err := tx.Model(&model.RefreshToken{}). - Where("user_id = ? AND revoked = ?", rt.UserID, false). + Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false). Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil { return err } - log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID) + log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam) outErr = ErrRefreshReused return nil } diff --git a/backend/service/board.go b/backend/service/board.go index 007591e..3534e02 100644 --- a/backend/service/board.go +++ b/backend/service/board.go @@ -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, }) } diff --git a/backend/service/board_sidebar_test.go b/backend/service/board_sidebar_test.go new file mode 100644 index 0000000..5ddc466 --- /dev/null +++ b/backend/service/board_sidebar_test.go @@ -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]) + } +} diff --git a/backend/service/chat.go b/backend/service/chat.go index 0c10008..aca9ea7 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -397,7 +397,9 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) { var existing model.ChatRoom err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error if err == nil { - _ = s.ensureMember(existing.ID, meID) + // 关闭私聊会硬删成员行、丢失已读水位;主动重开视为已读历史, + // 否则重建行 last_read=0 会把全部历史消息重算成未读(列表徽标闪现) + _ = s.ensureMember(existing.ID, meID, existing.LastMessageID) _ = s.ensureMember(existing.ID, peerID) return s.RoomDetail(meID, existing.ID, false) } @@ -431,7 +433,8 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) { return s.RoomDetail(meID, room.ID, false) } -func (s *ChatService) ensureMember(roomID, userID uint) error { +// ensureMember 保证成员行存在;重建(如私聊关闭后重开)时可选恢复已读水位 +func (s *ChatService) ensureMember(roomID, userID uint, readWatermark ...uint) error { _, err := s.membership(s.db, roomID, userID) if err == nil { return nil @@ -440,6 +443,9 @@ func (s *ChatService) ensureMember(roomID, userID uint) error { return err } m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember} + if len(readWatermark) > 0 { + m.LastReadMessageID = readWatermark[0] + } if e := s.db.Create(&m).Error; e != nil { return e } diff --git a/backend/service/legacyimport.go b/backend/service/legacyimport.go index 4fb5a3d..7c0e27b 100644 --- a/backend/service/legacyimport.go +++ b/backend/service/legacyimport.go @@ -8,6 +8,8 @@ import ( "os" "path" "path/filepath" + "regexp" + "sort" "strconv" "strings" "time" @@ -20,53 +22,110 @@ import ( "github.com/freefire/jiang13-bbs/model" ) -// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包, +// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包 / 帖子图片包, // 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。 // 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。 -// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。 +// 支持预检(dry-run,不写库,返回用户 / 帖子 / 评论清单供勾选);ImportRecord 去重保证幂等可重复导入。 +// 支持选择性导入:按旧用户勾选是否建号、内容归属(指定已有账号 / 站长)、按帖子 / 评论排除。 +// 帖子图片:正文引用的旧站 /uploads/posts/<名> 从图片包落盘到新站 uploads/images 并改写 URL。 // 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。 const ( // LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量) LegacyDBMaxBytes = 64 << 20 - // LegacyZipMaxBytes 头像压缩包上传上限 + // LegacyZipMaxBytes 头像 / 帖子图片压缩包上传上限 LegacyZipMaxBytes = 128 << 20 // legacyAvatarFileMax 单个头像解压上限 legacyAvatarFileMax = 8 << 20 + // legacyImageFileMax 单张帖子图片解压上限 + legacyImageFileMax = 20 << 20 // legacySignatureMaxRunes 新站 Signature 列上限 legacySignatureMaxRunes = 255 // legacyEmailMaxRunes 新站 Email 列上限 legacyEmailMaxRunes = 128 // legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致) legacyAvatarPrefix = "/uploads/avatars/" + // legacyPostImagePrefix 旧站帖子图片 URL 约定前缀 + legacyPostImagePrefix = "/uploads/posts/" + // legacyImagePrefix 新站帖子图片落盘后的 URL 前缀 + legacyImagePrefix = "/uploads/images/" + // legacyPreviewLimit 预检清单条数上限,防止超大库拖垮响应 + legacyPreviewLimit = 2000 // legacyDetailLimit 报告明细条数上限,防止超长响应 legacyDetailLimit = 50 ) var ( ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库") - ErrLegacyBadZip = errors.New("头像压缩包无法读取") + ErrLegacyBadZip = errors.New("压缩包无法读取") + ErrLegacyBadTarget = errors.New("内容归属目标账号不存在") ) +// legacyPostImageRe 匹配正文中对旧站帖子图片的引用(相对路径或任意域名绝对 URL), +// 捕获组为纯文件名(字符集不含路径分隔符,杜绝目录穿越)。 +var legacyPostImageRe = regexp.MustCompile(`(?:https?://[^()\[\]\s]+)?/uploads/posts/([A-Za-z0-9._\-]+)`) + // LegacyImportSource 受支持的导入来源(handler 按此做路由白名单) func LegacyImportSource(source string) bool { return source == "jiang13" } -// LegacyImportOptions 导入选项 +// LegacyImportOptions 导入选项(skip / 归属参数由 handler 从表单解析) type LegacyImportOptions struct { - WithContent bool // 同时导入板块 / 帖子 / 评论 - DryRun bool // 预检:只统计不写库 + WithContent bool // 同时导入板块 / 帖子 / 评论 + DryRun bool // 预检:只统计不写库,并返回勾选清单 + SkipUserIDs map[uint]bool // 不创建账号的旧用户(内容仍按归属规则落位) + OperatorUsers map[uint]bool // 内容明确归到站长的旧用户 + UserTargetNames map[uint]string // 旧用户ID → 指定已有账号用户名(内容归属) + UserTargetMap map[uint]uint // (内部)UserTargetNames 解析后的目标账号 ID + SkipPostIDs map[uint]bool // 排除的旧帖(其评论自动跳过) + SkipCommentIDs map[uint]bool // 排除的旧评论 } // LegacyImportReport 导入结果摘要(JSON 返回给前端) type LegacyImportReport struct { - DryRun bool `json:"dry_run"` - Users LegacyUserReport `json:"users"` - Boards *LegacyBoardReport `json:"boards,omitempty"` - Posts *LegacyPostReport `json:"posts,omitempty"` - Comments *LegacyCommentReport `json:"comments,omitempty"` - Notes []string `json:"notes,omitempty"` + DryRun bool `json:"dry_run"` + Users LegacyUserReport `json:"users"` + Boards *LegacyBoardReport `json:"boards,omitempty"` + Posts *LegacyPostReport `json:"posts,omitempty"` + Comments *LegacyCommentReport `json:"comments,omitempty"` + UserList []LegacyUserPreview `json:"user_list,omitempty"` // 仅 dry-run:勾选清单 + PostList []LegacyPostPreview `json:"post_list,omitempty"` // 仅 dry-run + CommentList []LegacyCommentPreview `json:"comment_list,omitempty"` // 仅 dry-run + Notes []string `json:"notes,omitempty"` +} + +// LegacyUserPreview 预检清单:旧用户(供勾选是否建号 / 内容归属) +type LegacyUserPreview struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Posts int `json:"posts"` + Comments int `json:"comments"` + Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号) + HasAvatar bool `json:"has_avatar"` +} + +// LegacyPostPreview 预检清单:旧帖(取消勾选即排除) +type LegacyPostPreview struct { + ID uint `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + Board string `json:"board"` + CreatedAt time.Time `json:"created_at"` + Poll bool `json:"poll"` // 投票帖不可迁移 + Published bool `json:"published"` // 非公开帖导入时自动跳过 +} + +// LegacyCommentPreview 预检清单:旧评论(取消勾选即排除) +type LegacyCommentPreview struct { + ID uint `json:"id"` + PostID uint `json:"post_id"` + PostTitle string `json:"post_title"` + Author string `json:"author"` + Excerpt string `json:"excerpt"` + CreatedAt time.Time `json:"created_at"` + Published bool `json:"published"` } // LegacyUserReport 用户导入明细 @@ -74,8 +133,9 @@ type LegacyUserReport struct { Total int `json:"total"` Imported int `json:"imported"` Skipped int `json:"skipped"` + Excluded int `json:"excluded"` // 手动排除建号 AvatarWritten int `json:"avatar_written"` - Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在 + Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在 AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失 Failed []string `json:"failed,omitempty"` } @@ -89,23 +149,31 @@ type LegacyBoardReport struct { // LegacyPostReport 帖子导入明细 type LegacyPostReport struct { - Total int `json:"total"` - Imported int `json:"imported"` - Skipped int `json:"skipped"` // 已导入过(去重记录) - PollsSkipped int `json:"polls_skipped"` - PollTitles []string `json:"poll_titles,omitempty"` - OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长 - Failed []string `json:"failed,omitempty"` + Total int `json:"total"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` // 已导入过(去重记录) + Excluded int `json:"excluded"` // 手动排除 + ExcludedDetail []string `json:"excluded_detail,omitempty"` + PollsSkipped int `json:"polls_skipped"` + PollTitles []string `json:"poll_titles,omitempty"` + OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长 + ImagesWritten int `json:"images_written"` // 正文图片迁移落盘数 + ImagesMissing []string `json:"images_missing,omitempty"` // 包内缺失 / 落盘失败,保留原链接 + Failed []string `json:"failed,omitempty"` } // LegacyCommentReport 评论导入明细 type LegacyCommentReport struct { - Total int `json:"total"` - Imported int `json:"imported"` - Skipped int `json:"skipped"` - SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等 - OwnerFallback []string `json:"owner_fallback,omitempty"` - Failed []string `json:"failed,omitempty"` + Total int `json:"total"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` + Excluded int `json:"excluded"` // 手动排除(含所属帖子被排除) + ExcludedDetail []string `json:"excluded_detail,omitempty"` + SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等 + OwnerFallback []string `json:"owner_fallback,omitempty"` + ImagesWritten int `json:"images_written"` + ImagesMissing []string `json:"images_missing,omitempty"` + Failed []string `json:"failed,omitempty"` } // ===== 旧库行结构(只映射所需列) ===== @@ -176,8 +244,16 @@ func (legacyCommentRow) TableName() string { return "comments" } // LegacyImportService 管理后台「数据导入」 type LegacyImportService struct { db *gorm.DB - uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars + uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars,帖子图片写入 uploads/images md *htmltomarkdown.Converter + ensureHall func(userID uint) error // 建号后加入默认大厅的钩子(可空;与注册链路一致) +} + +// WithHallMembership 注入「加入默认全站大厅」回调(chatSvc.EnsureDefaultMembership)。 +// 导入建号绕过注册链路,需显式接线保持行为一致;错误静默,幂等可补齐。 +func (s *LegacyImportService) WithHallMembership(fn func(userID uint) error) *LegacyImportService { + s.ensureHall = fn + return s } func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService { @@ -189,77 +265,127 @@ func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService } } -// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。 -// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。 -func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) { +// zipPack 旧站文件包(头像 / 帖子图片)索引:纯文件名 → 条目。 +// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下 +// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键, +// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。 +type zipPack struct { + idx map[string]*zip.File + close func() +} + +func openZipPack(zipPath string) (*zipPack, error) { + if zipPath == "" { + return &zipPack{idx: map[string]*zip.File{}, close: func() {}}, nil + } + zr, err := zip.OpenReader(zipPath) + if err != nil { + return nil, ErrLegacyBadZip + } + p := &zipPack{idx: make(map[string]*zip.File, len(zr.File)), close: func() { _ = zr.Close() }} + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + name := strings.ReplaceAll(f.Name, "\\", "/") + parts := strings.Split(name, "/") + var key string + switch { + case len(parts) == 1: + key = parts[0] + case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..": + key = parts[1] + default: + continue + } + if key == "" || key == "." || key == ".." { + continue + } + if _, exists := p.idx[key]; !exists { + p.idx[key] = f + } + } + return p, nil +} + +// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath / imagesZipPath +// 为可选头像包与帖子图片包(空串表示未上传)。operatorID 为执行导入的账号(站长), +// 作者缺失的帖子/评论归属到该账号。 +func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath, imagesZipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) { if err := validateSQLiteMagic(dbPath); err != nil { return nil, err } + // 归属目标:用户名 → 本站已有账号 ID(含软删账号,内容归属不变) + if len(opts.UserTargetNames) > 0 { + resolved := make(map[uint]uint, len(opts.UserTargetNames)) + for oldID, name := range opts.UserTargetNames { + var u model.User + if err := s.db.Unscoped().Where("username = ?", name).First(&u).Error; err != nil { + return nil, fmt.Errorf("%w: %s", ErrLegacyBadTarget, name) + } + resolved[oldID] = u.ID + } + opts.UserTargetNames = nil + opts.UserTargetMap = resolved + } oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err) } + // 导入结束释放 SQLite 句柄,避免 Windows 上文件被占用 + if sqlDB, err := oldDB.DB(); err == nil { + defer func() { _ = sqlDB.Close() }() + } + + avatars, err := openZipPack(zipPath) + if err != nil { + return nil, err + } + defer avatars.close() + images, err := openZipPack(imagesZipPath) + if err != nil { + return nil, err + } + defer images.close() + + if !opts.DryRun { + _ = os.MkdirAll(filepath.Join(s.uploadsDir, "avatars"), 0o755) + _ = os.MkdirAll(filepath.Join(s.uploadsDir, "images"), 0o755) + } rep := &LegacyImportReport{DryRun: opts.DryRun} - rep.Users = s.importUsers(oldDB, zipPath, opts) + if opts.DryRun { + s.buildPreview(oldDB, rep) + } + rep.Users = s.importUsers(oldDB, avatars, opts) if !opts.WithContent { return rep, nil } boardMap, boardRep := s.importBoards(oldDB, opts) rep.Boards = boardRep - rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep) - rep.Comments = s.importComments(oldDB, operatorID, opts, rep) + rep.Posts = s.importPosts(oldDB, boardMap, images, operatorID, opts, rep) + rep.Comments = s.importComments(oldDB, images, operatorID, opts, rep) return rep, nil } // ===== 用户 ===== -func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport { +func (s *LegacyImportService) importUsers(oldDB *gorm.DB, avatars *zipPack, opts LegacyImportOptions) LegacyUserReport { var rows []legacyUserRow if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil { return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}} } - // 头像包索引:文件名 → 条目。 - // 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下 - // (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键, - // 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。 - avatars := map[string]*zip.File{} - if zipPath != "" { - zr, err := zip.OpenReader(zipPath) - if err != nil { - return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}} - } - defer zr.Close() - for _, f := range zr.File { - if f.FileInfo().IsDir() { - continue - } - name := strings.ReplaceAll(f.Name, "\\", "/") - parts := strings.Split(name, "/") - var key string - switch { - case len(parts) == 1: - key = parts[0] - case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..": - key = parts[1] - default: - continue - } - if key == "" || key == "." || key == ".." { - continue - } - if _, exists := avatars[key]; !exists { - avatars[key] = f - } - } - } - rep := LegacyUserReport{Total: len(rows)} avatarDir := filepath.Join(s.uploadsDir, "avatars") for _, u := range rows { + // 手动排除:不创建账号,其内容按归属规则落位(见 resolveAuthor) + if opts.SkipUserIDs[u.ID] { + rep.Excluded++ + continue + } username := strings.TrimSpace(u.Username) if username == "" || u.Password == "" { rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID)) @@ -277,7 +403,7 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L continue } - avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun) + avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars.idx, avatarDir, opts.DryRun) if err != nil { rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err)) continue @@ -308,6 +434,9 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err)) continue } + if s.ensureHall != nil { + _ = s.ensureHall(user.ID) // 加入默认全站大厅(幂等,失败不阻断导入) + } rep.Imported++ } return rep @@ -342,42 +471,111 @@ func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*z return "", false, nil // 包内缺失 } dst := filepath.Join(avatarDir, name) - if _, err := os.Stat(dst); err == nil { - return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过 - } if dryRun { + if _, err := os.Stat(dst); err == nil { + return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过 + } return legacyAvatarPrefix + name, true, nil } + wrote, err := extractZipEntry(f, dst, legacyAvatarFileMax) + if err != nil { + return "", false, err + } + return legacyAvatarPrefix + name, wrote, nil +} + +// extractZipEntry 把 zip 条目解到 dst(先写临时文件再 rename,避免中断留下半截文件)。 +// 目标已存在时不写入直接返回 false(幂等;兼容 Windows 上目标已存在时 Rename 失败的语义)。 +func extractZipEntry(f *zip.File, dst string, max int64) (bool, error) { + if _, err := os.Stat(dst); err == nil { + return false, nil + } src, err := f.Open() if err != nil { - return "", false, err + return false, err } defer src.Close() - // 先写临时文件再 rename,避免中断留下半截文件 - out, err := os.CreateTemp(avatarDir, ".legacy-*") + out, err := os.CreateTemp(filepath.Dir(dst), ".legacy-*") if err != nil { - return "", false, err + return false, err } tmpName := out.Name() defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略 - written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1)) + written, err := io.Copy(out, io.LimitReader(src, max+1)) if closeErr := out.Close(); err == nil { err = closeErr } if err != nil { - return "", false, err + return false, err } - if written > legacyAvatarFileMax { - return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20) + if written > max { + return false, fmt.Errorf("文件超过 %dMB 上限", max>>20) } if err := os.Rename(tmpName, dst); err != nil { - // Windows 上目标已存在时 Rename 失败;视为已存在(幂等) if _, statErr := os.Stat(dst); statErr == nil { - return legacyAvatarPrefix + name, false, nil + return false, nil // Windows 上目标已存在时 Rename 失败;视为已存在(幂等) } - return "", false, err + return false, err } - return legacyAvatarPrefix + name, true, nil + return true, nil +} + +// ===== 帖子 / 评论图片迁移 ===== + +// legacyImageStat 单次导入的图片迁移统计(跨帖子/评论累计) +type legacyImageStat struct { + written int + missing map[string]bool +} + +func newImageStat() *legacyImageStat { + return &legacyImageStat{missing: map[string]bool{}} +} + +// names 返回缺失文件名(排序去重) +func (st *legacyImageStat) names() []string { + out := make([]string, 0, len(st.missing)) + for k := range st.missing { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// migrateContentImages 把内容里引用的旧站帖子图片(/uploads/posts/<名>,含旧域名绝对 URL) +// 从图片包落盘到新站 uploads/images(文件名原样保留),并把 URL 改写为新站相对路径。 +// 包内缺失或落盘失败的引用保留原链接并计入 missing。dryRun 只统计不落盘。 +func (s *LegacyImportService) migrateContentImages(content string, pack *zipPack, dryRun bool, stat *legacyImageStat) string { + if !strings.Contains(content, legacyPostImagePrefix) { + return content + } + return legacyPostImageRe.ReplaceAllStringFunc(content, func(m string) string { + name := legacyPostImageRe.FindStringSubmatch(m)[1] + if name == "." || name == ".." || name != path.Base(name) { + return m + } + f, ok := pack.idx[name] + if !ok { + stat.missing[name] = true + return m + } + dst := filepath.Join(s.uploadsDir, "images", name) + if dryRun { + if _, err := os.Stat(dst); err != nil { + stat.written++ + } + return legacyImagePrefix + name + } + wrote, err := extractZipEntry(f, dst, legacyImageFileMax) + if err != nil { + stat.missing[name] = true + return m + } + if wrote { + stat.written++ + } + return legacyImagePrefix + name + }) } // ===== 板块 ===== @@ -461,7 +659,22 @@ func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint { return m } -func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport { +// resolveAuthor 内容归属:管理员显式指定 > 旧用户名匹配 > 站长兜底。 +// 第二个返回值表示是否因找不到作者而兜底到站长(显式指定不算,用于报告明细)。 +func resolveAuthor(oldUserID uint, opts LegacyImportOptions, userMap map[uint]uint, operatorID uint) (uint, bool) { + if id, ok := opts.UserTargetMap[oldUserID]; ok && id > 0 { + return id, false + } + if opts.OperatorUsers[oldUserID] { + return operatorID, false + } + if id, ok := userMap[oldUserID]; ok { + return id, false + } + return operatorID, true +} + +func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport { out := &LegacyPostReport{} if boardMap == nil || len(boardMap) == 0 { out.Failed = append(out.Failed, "无可用板块映射,帖子未导入") @@ -477,6 +690,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint userMap := s.mapLegacyUsers(oldDB) missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback convFailed := 0 + imgStat := newImageStat() for _, p := range rows { title := strings.TrimSpace(p.Title) @@ -484,6 +698,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID)) continue } + if opts.SkipPostIDs[p.ID] { + out.Excluded++ + out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("%s (手动排除)", title)) + continue + } if p.PostType == "poll" { out.PollsSkipped++ out.PollTitles = append(out.PollTitles, title) @@ -509,13 +728,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint } } - // 作者映射,缺失归站长 - authorID := operatorID - if id, ok := userMap[p.UserID]; ok { - authorID = id - } else if !missingAuthors[p.UserID] { + // 作者归属:显式指定 > 用户名匹配 > 站长兜底 + authorID, fellBack := resolveAuthor(p.UserID, opts, userMap, operatorID) + if fellBack && !missingAuthors[p.UserID] { missingAuthors[p.UserID] = true - out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID)) + out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未匹配到账号,归到站长)", p.UserID)) } // HTML → Markdown;失败保留原文(正文不丢) @@ -527,6 +744,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint convFailed++ } } + content = s.migrateContentImages(content, images, opts.DryRun, imgStat) if opts.DryRun { out.Imported++ @@ -557,13 +775,16 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint if convFailed > 0 { rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed)) } + out.ImagesWritten = imgStat.written + out.ImagesMissing = capList(imgStat.names()) out.OwnerFallback = capList(out.OwnerFallback) + out.ExcludedDetail = capList(out.ExcludedDetail) out.Failed = capList(out.Failed) out.PollTitles = capList(out.PollTitles) return out } -func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport { +func (s *LegacyImportService) importComments(oldDB *gorm.DB, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport { out := &LegacyCommentReport{} var rows []legacyCommentRow if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil { @@ -584,8 +805,20 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op userMap := s.mapLegacyUsers(oldDB) missingAuthors := map[uint]bool{} convFailed := 0 + imgStat := newImageStat() for _, cm := range rows { + // 所属帖子被排除 → 评论自动跳过(无落点) + if opts.SkipPostIDs[cm.PostID] { + out.Excluded++ + out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (所属帖子被排除)", postTitles[cm.PostID], cm.ID)) + continue + } + if opts.SkipCommentIDs[cm.ID] { + out.Excluded++ + out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (手动排除)", postTitles[cm.PostID], cm.ID)) + continue + } if cm.Status != "published" { out.Skipped++ out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status)) @@ -608,12 +841,11 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op } } - authorID := operatorID - if id, ok := userMap[cm.UserID]; ok { - authorID = id - } else if !missingAuthors[cm.UserID] { + // 作者归属:显式指定 > 用户名匹配 > 站长兜底 + authorID, fellBack := resolveAuthor(cm.UserID, opts, userMap, operatorID) + if fellBack && !missingAuthors[cm.UserID] { missingAuthors[cm.UserID] = true - out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID)) + out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未匹配到账号,归到站长)", cm.UserID)) } // HTML → Markdown;失败保留原文(内容不丢) @@ -625,6 +857,7 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op convFailed++ } } + content = s.migrateContentImages(content, images, opts.DryRun, imgStat) if opts.DryRun { out.Imported++ @@ -639,7 +872,10 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op if convFailed > 0 { rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed)) } + out.ImagesWritten = imgStat.written + out.ImagesMissing = capList(imgStat.names()) out.SkippedDetail = capList(out.SkippedDetail) + out.ExcludedDetail = capList(out.ExcludedDetail) out.OwnerFallback = capList(out.OwnerFallback) out.Failed = capList(out.Failed) return out @@ -699,6 +935,117 @@ func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uin }) } +// ===== 预检清单 ===== + +// buildPreview 汇总旧用户 / 帖子 / 评论清单供管理员勾选导入范围,仅 dry-run 调用。 +// 超出 legacyPreviewLimit 的清单截断并记入 Notes。 +func (s *LegacyImportService) buildPreview(oldDB *gorm.DB, rep *LegacyImportReport) { + var users []legacyUserRow + if err := oldDB.Unscoped().Order("id ASC").Find(&users).Error; err != nil { + return + } + var boards []legacyBoardRow + // 与 importBoards/importPosts/importComments 保持一致:不含软删行(用户除外,软删用户内容仍需归属) + _ = oldDB.Order("id ASC").Find(&boards).Error + boardNames := map[uint]string{} + for _, b := range boards { + boardNames[b.ID] = b.Name + } + var posts []legacyPostRow + _ = oldDB.Order("id ASC").Find(&posts).Error + var comments []legacyCommentRow + _ = oldDB.Order("post_id ASC, id ASC").Find(&comments).Error + + authorName := map[uint]string{} + postCount := map[uint]int{} + commentCount := map[uint]int{} + for _, u := range users { + name := strings.TrimSpace(u.Nickname) + if name == "" { + name = strings.TrimSpace(u.Username) + } + authorName[u.ID] = name + } + postTitles := map[uint]string{} + for _, p := range posts { + postCount[p.UserID]++ + postTitles[p.ID] = p.Title + } + for _, cm := range comments { + commentCount[cm.UserID]++ + } + existing := s.existingUsernames() + + for _, u := range users { + rep.UserList = append(rep.UserList, LegacyUserPreview{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Posts: postCount[u.ID], + Comments: commentCount[u.ID], + Exists: existing[strings.ToLower(strings.TrimSpace(u.Username))], + HasAvatar: u.Avatar != "", + }) + } + for _, p := range posts { + rep.PostList = append(rep.PostList, LegacyPostPreview{ + ID: p.ID, + Title: p.Title, + Author: authorName[p.UserID], + Board: boardNames[p.BoardID], + CreatedAt: p.CreatedAt, + Poll: p.PostType == "poll", + Published: p.Status == "published", + }) + } + for _, cm := range comments { + rep.CommentList = append(rep.CommentList, LegacyCommentPreview{ + ID: cm.ID, + PostID: cm.PostID, + PostTitle: postTitles[cm.PostID], + Author: authorName[cm.UserID], + Excerpt: legacyExcerpt(cm.Content, 60), + CreatedAt: cm.CreatedAt, + Published: cm.Status == "published", + }) + } + + if len(rep.UserList) > legacyPreviewLimit { + rep.Notes = append(rep.Notes, fmt.Sprintf("用户清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit)) + rep.UserList = rep.UserList[:legacyPreviewLimit] + } + if len(rep.PostList) > legacyPreviewLimit { + rep.Notes = append(rep.Notes, fmt.Sprintf("帖子清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit)) + rep.PostList = rep.PostList[:legacyPreviewLimit] + } + if len(rep.CommentList) > legacyPreviewLimit { + rep.Notes = append(rep.Notes, fmt.Sprintf("评论清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit)) + rep.CommentList = rep.CommentList[:legacyPreviewLimit] + } +} + +// existingUsernames 本站已有用户名集合(含软删,忽略大小写) +func (s *LegacyImportService) existingUsernames() map[string]bool { + var rows []model.User + if err := s.db.Unscoped().Select("username").Find(&rows).Error; err != nil { + return map[string]bool{} + } + set := make(map[string]bool, len(rows)) + for _, u := range rows { + set[strings.ToLower(u.Username)] = true + } + return set +} + +var legacyHTMLTagRe = regexp.MustCompile(`<[^>]*>`) + +// legacyExcerpt 内容摘要:去 HTML 标签、压平空白后按 rune 截断(预检清单展示用) +func legacyExcerpt(s string, max int) string { + s = legacyHTMLTagRe.ReplaceAllString(s, " ") + s = strings.Join(strings.Fields(s), " ") + return truncateRunesN(s, max) +} + // ===== 工具 ===== // capList 明细条数截断,超出部分以「…等 N 条」收尾 diff --git a/backend/service/legacyimport_test.go b/backend/service/legacyimport_test.go new file mode 100644 index 0000000..66effd6 --- /dev/null +++ b/backend/service/legacyimport_test.go @@ -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: `

看图

`}, + &legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `

正文

`}, + &legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `

草稿

`}, + &legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `

投票

`}, + &legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal", + Content: `

缺图

`}, + &legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published", + Content: `

补一张

`}, + &legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `

顶

`}, + &legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `

前排

`}, + } + 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 +} diff --git a/backend/service/login_device.go b/backend/service/login_device.go index 2623471..241a5fc 100644 --- a/backend/service/login_device.go +++ b/backend/service/login_device.go @@ -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 { diff --git a/backend/service/login_session_test.go b/backend/service/login_session_test.go new file mode 100644 index 0000000..f202a5a --- /dev/null +++ b/backend/service/login_session_test.go @@ -0,0 +1,276 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func newLoginSessionTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func newLoginSessionUser(t *testing.T, db *gorm.DB, username string) *model.User { + t.Helper() + hashed, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash password: %v", err) + } + user := model.User{Username: username, Password: string(hashed)} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + return &user +} + +func countActiveSessions(t *testing.T, db *gorm.DB, userID uint) int64 { + t.Helper() + var n int64 + if err := db.Model(&model.RefreshToken{}). + Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()). + Count(&n).Error; err != nil { + t.Fatalf("count active sessions: %v", err) + } + return n +} + +// TestLoginMultiSessionCoexist 多会话并存:不同设备先后登录互不顶号, +// 各会话的 access token(family_id)均持续有效。 +func TestLoginMultiSessionCoexist(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret") + user := newLoginSessionUser(t, db, "alice") + + uas := []string{"Chrome-Windows", "Edge-Windows", "iPhone-Safari"} + families := make([]uint, 0, len(uas)) + for i, ua := range uas { + access, refresh, _, kicked, err := s.Login("alice", "password123", "1.2.3.4", ua) + if err != nil { + t.Fatalf("login #%d: %v", i+1, err) + } + if kicked != 0 { + t.Fatalf("login #%d 不应踢掉旧会话,kicked=%d", i+1, kicked) + } + if _, err := s.ValidateRefreshToken(refresh); err != nil { + t.Fatalf("login #%d refresh 校验失败: %v", i+1, err) + } + claims, err := s.ParseToken(access) + if err != nil { + t.Fatalf("login #%d 解析 access token: %v", i+1, err) + } + if _, err := s.ValidateClaims(claims); err != nil { + t.Fatalf("login #%d 会话应有效: %v", i+1, err) + } + families = append(families, claims.FamilyID) + } + + if n := countActiveSessions(t, db, user.ID); n != int64(len(uas)) { + t.Fatalf("应共存 %d 个活跃会话,实际 %d", len(uas), n) + } + for _, fam := range families { + if !s.familySessionActive(user.ID, fam) { + t.Fatalf("family=%d 会话应仍有效", fam) + } + } +} + +// TestSessionCapEvictsOldest 会话上限守护:活跃会话超过 maxActiveSessions 时, +// 按 last_used_at 淘汰最旧会话,其余会话不受影响。 +func TestSessionCapEvictsOldest(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret") + user := newLoginSessionUser(t, db, "bob") + + families := make([]uint, 0, maxActiveSessions) + for i := 0; i < maxActiveSessions; i++ { + _, family, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device") + if err != nil { + t.Fatalf("create #%d: %v", i+1, err) + } + if kicked != 0 { + t.Fatalf("create #%d 不应淘汰会话,kicked=%d", i+1, kicked) + } + families = append(families, family) + } + // 人为拉开 last_used_at,保证淘汰顺序确定(第 1 个最旧) + base := time.Now().Add(-2 * time.Hour) + for i, fam := range families { + if err := db.Model(&model.RefreshToken{}).Where("family_id = ?", fam). + Update("last_used_at", base.Add(time.Duration(i)*time.Minute)).Error; err != nil { + t.Fatalf("调整 last_used_at: %v", err) + } + } + + _, _, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device-new") + if err != nil { + t.Fatalf("create #%d: %v", maxActiveSessions+1, err) + } + if kicked != 1 { + t.Fatalf("超出上限应淘汰 1 个最旧会话,kicked=%d", kicked) + } + if n := countActiveSessions(t, db, user.ID); n != maxActiveSessions { + t.Fatalf("淘汰后应剩 %d 个活跃会话,实际 %d", maxActiveSessions, n) + } + if s.familySessionActive(user.ID, families[0]) { + t.Fatal("最旧会话应已被淘汰") + } + for _, fam := range families[1:] { + if !s.familySessionActive(user.ID, fam) { + t.Fatalf("family=%d 会话不应被淘汰", fam) + } + } +} + +// TestRevokeLoginDeviceWithMultiSessions 多会话下剔除设备:仅目标会话失效, +// 当前会话不受影响(沿用既有剔除语义的回归保障)。 +func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret") + user := newLoginSessionUser(t, db, "carol") + + access1, refresh1, _, _, err := s.Login("carol", "password123", "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("login 1: %v", err) + } + claims1, err := s.ParseToken(access1) + if err != nil { + t.Fatalf("parse access 1: %v", err) + } + family1 := claims1.FamilyID + access2, _, _, _, err := s.Login("carol", "password123", "2.2.2.2", "iPhone-Mobile") + if err != nil { + t.Fatalf("login 2: %v", err) + } + claims2, err := s.ParseToken(access2) + if err != nil { + t.Fatalf("parse access 2: %v", err) + } + family2 := claims2.FamilyID + + devices, err := s.ListLoginDevices(user.ID, family1, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("list devices: %v", err) + } + if len(devices) != 2 { + t.Fatalf("应列出 2 台设备,实际 %d", len(devices)) + } + var targetID uint + for _, d := range devices { + if !d.Current { + targetID = d.ID + } + } + if targetID == 0 { + t.Fatal("未找到非当前设备") + } + if err := s.RevokeLoginDevice(user.ID, family1, targetID); err != nil { + t.Fatalf("revoke device: %v", err) + } + + if s.familySessionActive(user.ID, family2) { + t.Fatal("被剔除设备的会话应已失效") + } + if _, err := s.ValidateRefreshToken(refresh1); err != nil { + t.Fatalf("当前设备会话不应受影响: %v", err) + } +} + +// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链 +// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware +// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。 +func TestRefreshReuseRevokesFamilyOnly(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret") + user := newLoginSessionUser(t, db, "dave") + + access1, refresh1, _, _, err := s.Login("dave", "password123", "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("login 1: %v", err) + } + claims1, err := s.ParseToken(access1) + if err != nil { + t.Fatalf("parse access 1: %v", err) + } + access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile") + if err != nil { + t.Fatalf("login 2: %v", err) + } + claims2, err := s.ParseToken(access2) + if err != nil { + t.Fatalf("parse access 2: %v", err) + } + + // 家族1 正常轮转一次:旧行吊销并链接到新行 + if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil { + t.Fatalf("rotate 1: %v", err) + } + + // 把旧行 rotated_at 拨回宽限期之外,模拟「轮换响应丢失后的延迟重放」 + if err := db.Model(&model.RefreshToken{}). + Where("token_hash = ?", hashRefreshToken(refresh1)). + Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil { + t.Fatalf("backdate rotated_at: %v", err) + } + + // 宽限期外重放:判定盗用,仅家族1 被整体吊销 + if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) { + t.Fatalf("期望 ErrRefreshReused,实际 %v", err) + } + if s.familySessionActive(user.ID, claims1.FamilyID) { + t.Fatal("重放所在家族应被整体吊销") + } + if !s.familySessionActive(user.ID, claims2.FamilyID) { + t.Fatal("其他登录设备(家族)不应被牵连") + } + if n := countActiveSessions(t, db, user.ID); n != 1 { + t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n) + } + if _, err := s.ValidateRefreshToken(refresh2); err != nil { + t.Fatalf("其他设备的 refresh 不应受影响: %v", err) + } +} + +// TestRefreshReuseDevGraceReturnsSamePair dev 模式宽限期放宽:轮换后远超 +// 生产宽限窗的同链重放(dev 重启丢响应的常态)仍返回同一对,不升级为盗用判定。 +func TestRefreshReuseDevGraceReturnsSamePair(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret").WithDevMode(true) + newLoginSessionUser(t, db, "eve") + + _, refresh1, _, _, err := s.Login("eve", "password123", "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("login: %v", err) + } + _, newRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("rotate: %v", err) + } + + // rotated_at 拨回生产宽限期之外(仍在 dev 宽限内) + if err := db.Model(&model.RefreshToken{}). + Where("token_hash = ?", hashRefreshToken(refresh1)). + Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil { + t.Fatalf("backdate rotated_at: %v", err) + } + + _, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("dev 宽限期内重放不应报错: %v", err) + } + if replayRefresh != newRefresh { + t.Fatal("dev 宽限期内重放应返回同一对新 token") + } +} diff --git a/backend/service/media_library.go b/backend/service/media_library.go new file mode 100644 index 0000000..a3d24ac --- /dev/null +++ b/backend/service/media_library.go @@ -0,0 +1,388 @@ +package service + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/freefire/jiang13-bbs/model" +) + +// 媒体库(管理后台):盘点站点内全部图片资源。 +// 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并: +// - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖 +// - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中 +// 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。 + +// 媒体库分类:目录名即分类键,前端 tab 直接使用 +var mediaLibraryCategories = []struct { + Key string // 分类键(image/avatar/background/ads/brand) + Dir string // uploads 下的子目录 + Name string // 展示名 +}{ + {"image", "images", "帖子插图"}, + {"avatar", "avatars", "用户头像"}, + {"background", "backgrounds", "站点背景"}, + {"ads", "ads", "广告素材"}, + {"brand", "brand", "品牌资源"}, +} + +// MediaLibraryItem 媒体库条目 +type MediaLibraryItem struct { + URL string `json:"url"` // 展示地址(有 WebP 时为 WebP) + OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存) + Category string `json:"category"` // image/avatar/background/ads/brand + CategoryName string `json:"category_name"` + Name string `json:"name"` // 文件名(含扩展名) + MIME string `json:"mime"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空) + UploadedAt *time.Time `json:"uploaded_at,omitempty"` + SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页) + SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论) +} + +// AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。 +// 返回条目列表与各分类计数(含全部计数由前端按列表累加)。 +func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) { + // 附件元数据索引:URL → 记录(联出上传者昵称) + type attMeta struct { + Kind string + URL string + MIME string + Size int + Width int + Height int + Nickname string + Username string + CreatedAt time.Time + } + var rows []attMeta + if err := s.db.Table("attachments"). + Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username"). + Joins("LEFT JOIN users ON users.id = attachments.user_id"). + Scan(&rows).Error; err != nil { + return nil, nil, err + } + metaByURL := make(map[string]attMeta, len(rows)) + // 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入 + items := make([]MediaLibraryItem, 0, len(rows)) + categoryName := func(key string) string { + for _, cat := range mediaLibraryCategories { + if cat.Key == key { + return cat.Name + } + } + return key + } + for _, r := range rows { + metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据 + if !strings.HasPrefix(r.URL, "/api/media/") { + continue + } + uploader := r.Nickname + if uploader == "" { + uploader = r.Username + } + cat := r.Kind + if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar { + continue // 未知 kind 不进媒体库 + } + ca := r.CreatedAt + items = append(items, MediaLibraryItem{ + URL: r.URL, Category: cat, CategoryName: categoryName(cat), + Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME, + Size: int64(r.Size), Width: r.Width, Height: r.Height, + Uploader: uploader, UploadedAt: &ca, + }) + } + + // 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL + type group struct { + webp *MediaLibraryItem + original *MediaLibraryItem + } + for _, cat := range mediaLibraryCategories { + dir := filepath.Join(s.dir, cat.Dir) + entries, err := os.ReadDir(dir) + if err != nil { + continue // 目录不存在视为空 + } + groups := map[string]*group{} + for _, e := range entries { + if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp", ".gif": + default: + continue + } + url := "/uploads/" + cat.Dir + "/" + e.Name() + item := MediaLibraryItem{ + URL: url, Category: cat.Key, CategoryName: cat.Name, + Name: e.Name(), + MIME: map[string]string{ + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".webp": "image/webp", ".gif": "image/gif", + }[ext], + } + if info, err := e.Info(); err == nil { + item.Size = info.Size() + mt := info.ModTime() + item.UploadedAt = &mt + } + if m, ok := metaByURL[url]; ok { + // 附件记录有上传者与准确创建时间,覆盖磁盘信息 + uploader := m.Nickname + if uploader == "" { + uploader = m.Username + } + ca := m.CreatedAt + item.Uploader = uploader + item.UploadedAt = &ca + if m.Size > 0 { + item.Size = int64(m.Size) + } + if m.Width > 0 { + item.Width, item.Height = m.Width, m.Height + } + } + // 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0) + if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 { + if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil { + w, h, derr := decodeImageSizeReader(f, item.MIME) + _ = f.Close() + if derr == nil { + item.Width, item.Height = w, h + } + } + } + stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + g := groups[stem] + if g == nil { + g = &group{} + groups[stem] = g + } + if ext == ".webp" { + g.webp = &item + } else if g.original == nil { + g.original = &item + } + } + for _, g := range groups { + switch { + case g.webp != nil && g.original != nil: + // 原图与 WebP 并存:只展示 WebP,原图作 original_url + g.webp.OriginalURL = g.original.URL + if g.webp.Size == 0 { + g.webp.Size = g.original.Size + } + items = append(items, *g.webp) + case g.webp != nil: + items = append(items, *g.webp) + default: + items = append(items, *g.original) + } + } + } + + sort.Slice(items, func(i, j int) bool { + ti, tj := items[i].UploadedAt, items[j].UploadedAt + switch { + case ti == nil && tj == nil: + return items[i].URL < items[j].URL + case ti == nil: + return false + case tj == nil: + return true + } + return ti.After(*tj) + }) + + counts := map[string]int{} + for _, it := range items { + counts[it.Category]++ + } + s.attachMediaSources(items) + return items, counts, nil +} + +// attachMediaSources 为条目补来源回链: +// - 头像:users.avatar 精确匹配 → 用户主页 +// - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论 +// +// 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。 +func (s *UploadService) attachMediaSources(items []MediaLibraryItem) { + urlSet := map[string]bool{} + for _, it := range items { + for _, u := range []string{it.URL, it.OriginalURL} { + if strings.HasPrefix(u, "/uploads/") { + urlSet[u] = true + } + } + } + if len(urlSet) == 0 { + return + } + urls := make([]string, 0, len(urlSet)) + for u := range urlSet { + urls = append(urls, u) + } + + type postRef struct { + id uint + title string + } + postByURL := map[string]postRef{} + type commentRef struct { + id, postID uint + } + commentByURL := map[string]commentRef{} + + for i := 0; i < len(urls); i += 40 { + end := i + 40 + if end > len(urls) { + end = len(urls) + } + chunk := urls[i:end] + conds := make([]string, len(chunk)) + args := make([]any, len(chunk)) + for j, u := range chunk { + conds[j] = "content LIKE ?" + args[j] = "%" + u + "%" + } + where := strings.Join(conds, " OR ") + + var prows []struct { + ID uint + Title string + Content string + } + if err := s.db.Model(&model.Post{}).Select("id, title, content"). + Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil { + return + } + for _, p := range prows { + for _, u := range chunk { + if strings.Contains(p.Content, u) { + if _, ok := postByURL[u]; !ok { + postByURL[u] = postRef{id: p.ID, title: p.Title} + } + } + } + } + + var crows []struct { + ID uint + PostID uint + Content string + } + if err := s.db.Model(&model.Comment{}).Select("id, post_id, content"). + Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil { + return + } + for _, cm := range crows { + for _, u := range chunk { + if strings.Contains(cm.Content, u) { + if _, ok := commentByURL[u]; !ok { + commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID} + } + } + } + } + } + + // 评论来源要带宿主帖子标题,一次性补齐 + postTitle := func(id uint) string { + var p model.Post + if err := s.db.Select("id, title").First(&p, id).Error; err != nil { + return "" + } + return p.Title + } + commentPostTitle := map[uint]string{} + for _, cm := range commentByURL { + if _, ok := commentPostTitle[cm.postID]; !ok { + commentPostTitle[cm.postID] = postTitle(cm.postID) + } + } + + // 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户) + userByAvatar := map[string]struct { + ID uint + Nickname, Username string + }{} + var urows []struct { + ID uint + Nickname string + Username string + Avatar string + } + if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar"). + Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil { + for _, u := range urows { + if _, ok := userByAvatar[u.Avatar]; !ok { + userByAvatar[u.Avatar] = struct { + ID uint + Nickname, Username string + }{ID: u.ID, Nickname: u.Nickname, Username: u.Username} + } + } + } + + truncate := func(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" + } + postLabel := func(p postRef) string { + title := p.title + if title == "" { + title = fmt.Sprintf("#%d", p.id) + } + return "帖子《" + truncate(title, 40) + "》" + } + + for i := range items { + it := &items[i] + // 头像使用者优先(头像正文引用场景极少) + if u, ok := userByAvatar[it.URL]; ok { + name := u.Nickname + if name == "" { + name = u.Username + } + it.SourceURL = fmt.Sprintf("/u/%d", u.ID) + it.SourceLabel = "@" + name + " 的头像" + continue + } + for _, u := range []string{it.URL, it.OriginalURL} { + if u == "" || it.SourceURL != "" { + continue + } + if p, ok := postByURL[u]; ok { + it.SourceURL = fmt.Sprintf("/post/%d", p.id) + it.SourceLabel = postLabel(p) + break + } + if cm, ok := commentByURL[u]; ok { + it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id) + title := commentPostTitle[cm.postID] + if title != "" { + it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论" + } else { + it.SourceLabel = "帖子评论" + } + break + } + } + } +} diff --git a/backend/service/media_library_test.go b/backend/service/media_library_test.go new file mode 100644 index 0000000..535a8b7 --- /dev/null +++ b/backend/service/media_library_test.go @@ -0,0 +1,312 @@ +package service + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + "time" + + xwebp "golang.org/x/image/webp" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newMediaLibraryService(t *testing.T) (*UploadService, string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil { + t.Fatalf("migrate: %v", err) + } + dir := t.TempDir() + s := NewUploadService(db, dir) + if err := s.EnsureDir(); err != nil { + t.Fatalf("ensure dir: %v", err) + } + return s, dir +} + +func writeMediaFile(t *testing.T, dir, rel string, data []byte) { + t.Helper() + full := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, data, 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} + +func tinyPNG() []byte { + src := image.NewRGBA(image.Rect(0, 0, 12, 8)) + src.Set(0, 0, color.RGBA{1, 2, 3, 255}) + var buf bytes.Buffer + _ = png.Encode(&buf, src) + return buf.Bytes() +} + +func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) { + s, dir := newMediaLibraryService(t) + + writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes")) + writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes")) + writeMediaFile(t, dir, "images/solo.png", tinyPNG()) + writeMediaFile(t, dir, "images/trash.txt", []byte("not an image")) + writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp")) + writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake")) + + items, counts, err := s.AdminMediaLibrary() + if err != nil { + t.Fatalf("AdminMediaLibrary: %v", err) + } + byURL := map[string]MediaLibraryItem{} + for _, it := range items { + byURL[it.URL] = it + } + + // 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url + pair, ok := byURL["/uploads/images/pair.webp"] + if !ok { + t.Fatalf("pair.webp missing; items=%v", items) + } + if pair.OriginalURL != "/uploads/images/pair.jpg" { + t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL) + } + if _, exists := byURL["/uploads/images/pair.jpg"]; exists { + t.Fatal("pair.jpg should be hidden behind webp") + } + + // 无配对的单文件:保留原格式并解析出宽高 + solo, ok := byURL["/uploads/images/solo.png"] + if !ok { + t.Fatal("solo.png missing") + } + if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 { + t.Fatalf("solo.png meta wrong: %+v", solo) + } + + // 非图片与 .partial 被过滤 + if _, exists := byURL["/uploads/images/trash.txt"]; exists { + t.Fatal("trash.txt should be excluded") + } + if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists { + t.Fatal(".partial should be excluded") + } + + // 广告素材目录计入 ads 分类 + if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" { + t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr) + } + + if counts["image"] != 2 || counts["ads"] != 1 { + t.Fatalf("counts wrong: %v", counts) + } +} + +func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) { + s, dir := newMediaLibraryService(t) + + // 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles) + writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes")) + + created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) + if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil { + t.Fatalf("create user: %v", err) + } + var alice model.User + if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil { + t.Fatalf("load user: %v", err) + } + if err := s.db.Create(&model.Attachment{ + UserID: alice.ID, Kind: model.AttachmentKindImage, + URL: "/uploads/images/abc.webp", MIME: "image/webp", + Size: 4567, Width: 320, Height: 240, CreatedAt: created, + }).Error; err != nil { + t.Fatalf("create attachment: %v", err) + } + if err := s.db.Create(&model.Attachment{ + UserID: alice.ID, Kind: model.AttachmentKindAvatar, + URL: "/api/media/remote01", MIME: "image/webp", + Size: 999, Width: 128, Height: 128, CreatedAt: created, + }).Error; err != nil { + t.Fatalf("create remote attachment: %v", err) + } + + items, counts, err := s.AdminMediaLibrary() + if err != nil { + t.Fatalf("AdminMediaLibrary: %v", err) + } + byURL := map[string]MediaLibraryItem{} + for _, it := range items { + byURL[it.URL] = it + } + + // 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间) + disk, ok := byURL["/uploads/images/abc.webp"] + if !ok { + t.Fatalf("attachment-backed local file missing: %+v", items) + } + if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 { + t.Fatalf("meta not merged: %+v", disk) + } + + // 远程存储对象:不在磁盘上也必须出现在媒体库 + remote, ok := byURL["/api/media/remote01"] + if !ok { + t.Fatal("remote object missing") + } + if remote.Category != "avatar" || remote.Uploader != "爱丽丝" { + t.Fatalf("remote meta wrong: %+v", remote) + } + + if counts["avatar"] != 1 { + t.Fatalf("avatar count wrong: %v", counts) + } +} + +func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) { + s, _ := newMediaLibraryService(t) + + if err := s.db.Create(&model.Attachment{ + UserID: 1, Kind: model.AttachmentKindImage, + URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1, + }).Error; err != nil { + t.Fatalf("create attachment: %v", err) + } + + items, _, err := s.AdminMediaLibrary() + if err != nil { + t.Fatalf("AdminMediaLibrary: %v", err) + } + for _, it := range items { + if it.URL == "/uploads/images/ghost.webp" { + t.Fatal("local-missing file should not be listed") + } + } +} + +func TestAdminMediaLibraryAttachSources(t *testing.T) { + s, dir := newMediaLibraryService(t) + + writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp")) + writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp")) + writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg")) + + // 头像被 bob 使用 + if err := s.db.Create(&model.User{ + Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp", + }).Error; err != nil { + t.Fatalf("create user: %v", err) + } + // 帖子正文引用 p1 + post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 ![a](/uploads/images/p1.webp)"} + if err := s.db.Create(&post).Error; err != nil { + t.Fatalf("create post: %v", err) + } + // 评论引用 c1 + if err := s.db.Create(&model.Comment{ + PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg", + }).Error; err != nil { + t.Fatalf("create comment: %v", err) + } + + items, _, err := s.AdminMediaLibrary() + if err != nil { + t.Fatalf("AdminMediaLibrary: %v", err) + } + byURL := map[string]MediaLibraryItem{} + for _, it := range items { + byURL[it.URL] = it + } + + av, ok := byURL["/uploads/avatars/av1.webp"] + if !ok { + t.Fatal("avatar item missing") + } + if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" { + t.Fatalf("avatar source wrong: %+v", av) + } + + pimg, ok := byURL["/uploads/images/p1.webp"] + if !ok { + t.Fatal("post image missing") + } + if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) || + !strings.Contains(pimg.SourceLabel, "图片来源测试") { + t.Fatalf("post source wrong: %+v", pimg) + } + + cimg, ok := byURL["/uploads/images/c1.jpg"] + if !ok { + t.Fatal("comment image missing") + } + if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) && + !strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) { + t.Fatalf("comment source url wrong: %+v", cimg) + } + if !strings.Contains(cimg.SourceLabel, "下的评论") { + t.Fatalf("comment source label wrong: %+v", cimg) + } +} + +func TestMediaThumb(t *testing.T) { + s, dir := newMediaLibraryService(t) + + // 960×640 PNG → 缩到 480×320 的 WebP + big := image.NewRGBA(image.Rect(0, 0, 960, 640)) + var bigBuf bytes.Buffer + if err := png.Encode(&bigBuf, big); err != nil { + t.Fatalf("encode png: %v", err) + } + writeMediaFile(t, dir, "images/big.png", bigBuf.Bytes()) + + // 小图(≤480)直接回原图 + writeMediaFile(t, dir, "images/small.png", tinyPNG()) + + bigThumb, err := s.MediaThumb("/uploads/images/big.png") + if err != nil { + t.Fatalf("MediaThumb big: %v", err) + } + img, err := xwebp.Decode(bytes.NewReader(bigThumb)) + if err != nil { + t.Fatalf("thumb not webp: %v", err) + } + if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 320 { + t.Fatalf("thumb size wrong: %v", img.Bounds()) + } + + small, err := s.MediaThumb("/uploads/images/small.png") + if err != nil { + t.Fatalf("MediaThumb small: %v", err) + } + if !bytes.Equal(small, tinyPNG()) { + t.Fatal("small image should be returned as-is") + } + + // 缓存命中:第二次调用结果一致 + again, err := s.MediaThumb("/uploads/images/big.png") + if err != nil || !bytes.Equal(again, bigThumb) { + t.Fatalf("cache miss/mismatch: %v", err) + } + + // 非法与越界路径 + if _, err := s.MediaThumb("/api/media/xxx"); err == nil { + t.Fatal("remote url should be rejected") + } + if _, err := s.MediaThumb("/uploads/../secrets/x.png"); err == nil { + t.Fatal("traversal should be rejected") + } + if _, err := s.MediaThumb("/uploads/other/x.png"); err == nil { + t.Fatal("unknown category should be rejected") + } +} diff --git a/backend/service/media_thumbs.go b/backend/service/media_thumbs.go new file mode 100644 index 0000000..2b32566 --- /dev/null +++ b/backend/service/media_thumbs.go @@ -0,0 +1,114 @@ +package service + +import ( + "bytes" + "crypto/sha1" + "encoding/hex" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "strings" + + webpenc "github.com/gen2brain/webp" + "golang.org/x/image/draw" + xwebp "golang.org/x/image/webp" +) + +// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。 +// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存, +// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片; +// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。 + +// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够) +const MediaThumbMaxSide = 480 + +// MediaThumb 返回指定 /uploads/ 图片的缩略图字节。 +func (s *UploadService) MediaThumb(url string) ([]byte, error) { + if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 { + return nil, errors.New("无效地址") + } + rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/"))) + if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") { + return nil, errors.New("无效地址") + } + dir := filepath.Dir(rel) + ok := false + for _, cat := range mediaLibraryCategories { + if cat.Dir == dir { + ok = true + break + } + } + if !ok { + return nil, errors.New("无效地址") + } + ext := strings.ToLower(filepath.Ext(rel)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp", ".gif": + default: + return nil, errors.New("非图片") + } + + src := filepath.Join(s.dir, rel) + info, err := os.Stat(src) + if err != nil || info.IsDir() { + return nil, errors.New("文件不存在") + } + + // 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建 + sum := sha1.Sum([]byte(url)) + thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp") + if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) { + return os.ReadFile(thumbPath) + } + + data, err := os.ReadFile(src) + if err != nil { + return nil, err + } + var img image.Image + if ext == ".webp" { + img, err = xwebp.Decode(bytes.NewReader(data)) + } else { + img, _, err = image.Decode(bytes.NewReader(data)) + } + if err != nil { + return nil, err // 如动图 WebP,调用方回退原图 + } + + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide { + // 小图无需缩放,直接回原图省 CPU + return data, nil + } + nw, nh := w, h + if w >= h { + nw = MediaThumbMaxSide + nh = max(1, h*MediaThumbMaxSide/w) + } else { + nh = MediaThumbMaxSide + nw = max(1, w*MediaThumbMaxSide/h) + } + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + + var buf bytes.Buffer + if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil { + return nil, err + } + // 原子落缓存(失败仅影响下次重复生成,不阻断响应) + if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil { + tmp := thumbPath + ".partial" + if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil { + if err := os.Rename(tmp, thumbPath); err != nil { + _ = os.Remove(tmp) + } + } + } + return buf.Bytes(), nil +} diff --git a/backend/service/upload.go b/backend/service/upload.go index 85bc6f2..4022079 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -19,6 +19,7 @@ import ( "time" "github.com/freefire/jiang13-bbs/model" + webpenc "github.com/gen2brain/webp" "golang.org/x/image/webp" "gorm.io/gorm" ) @@ -29,10 +30,13 @@ const ( AvatarMinDim = 64 AvatarMaxDim = 512 - // 帖子插图:允许 JPEG/PNG/WebP,不强制转码 - ImageMaxBytes = 5 << 20 // 5 MiB + // 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留 + ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效) ImageMaxDim = 4096 + // JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响 + ImageWebPQuality = 80 + // 站点背景图(前台/后台墙纸) BackgroundMaxBytes = 8 << 20 // 8 MiB BackgroundMaxDim = 8192 @@ -190,7 +194,50 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) { return cfg.Width, cfg.Height, nil } -// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image) +// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP: +// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。 +// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。 +func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) { + webpName := strings.TrimSuffix(name, ext) + ".webp" + webpPath := filepath.Join(s.dir, "images", webpName) + webpTmp := webpPath + ".partial" + if s.ops != nil { + release, e := s.ops.BeginTemporary(webpTmp) + if e != nil { + return "", "", 0, e + } + defer release() + } + + f, err := os.Open(tmp) + if err != nil { + return "", "", 0, err + } + img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册 + _ = f.Close() + if err != nil { + return "", "", 0, err + } + var buf bytes.Buffer + if err := webpenc.Encode(&buf, img, webpenc.Options{ + Quality: ImageWebPQuality, + Lossless: ext == ".png", + }); err != nil { + return "", "", 0, err + } + if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil { + _ = os.Remove(webpTmp) + return "", "", 0, err + } + if err := os.Rename(webpTmp, webpPath); err != nil { + _ = os.Remove(webpTmp) + return "", "", 0, err + } + _ = os.Remove(tmp) + return webpName, "image/webp", int64(buf.Len()), nil +} + +// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image) func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) { if src == nil { return nil, errors.New("文件为空") @@ -284,33 +331,48 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment _ = os.Remove(tmp) return nil, errors.New("图片边长不能超过 4096px") } - if err := os.Rename(tmp, fullPath); err != nil { - _ = os.Remove(tmp) - return nil, err + + // JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留 + storeName, storeMime, storeSize := filename, format.mime, total + storePath := fullPath + if format.mime == "image/jpeg" || format.mime == "image/png" { + name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext) + if err != nil { + log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err) + } else { + storeName, storeMime, storeSize = name, mime, size + storePath = filepath.Join(s.dir, "images", name) + } + } + if storePath == fullPath { + if err := os.Rename(tmp, fullPath); err != nil { + _ = os.Remove(tmp) + return nil, err + } } att := &model.Attachment{ UserID: userID, Kind: model.AttachmentKindImage, - URL: "/uploads/images/" + filename, - MIME: format.mime, - Size: int(total), + URL: "/uploads/images/" + storeName, + MIME: storeMime, + Size: int(storeSize), Width: w, Height: h, } if s.ops != nil { - id, e := s.ops.StoreFile(fullPath, att.MIME, true) + id, e := s.ops.StoreFile(storePath, att.MIME, true) if e != nil { - _ = os.Remove(fullPath) + _ = os.Remove(storePath) return nil, e } if id != "" { att.URL = "/api/media/" + id - _ = os.Remove(fullPath) + _ = os.Remove(storePath) } } if err := s.db.Create(att).Error; err != nil { - _ = os.Remove(fullPath) + _ = os.Remove(storePath) return nil, err } return att, nil diff --git a/backend/service/upload_webp_test.go b/backend/service/upload_webp_test.go new file mode 100644 index 0000000..7d6f967 --- /dev/null +++ b/backend/service/upload_webp_test.go @@ -0,0 +1,172 @@ +package service + +import ( + "bytes" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/freefire/jiang13-bbs/model" + webpenc "github.com/gen2brain/webp" + "github.com/glebarez/sqlite" + xwebp "golang.org/x/image/webp" + "gorm.io/gorm" +) + +func newUploadTestService(t *testing.T) (*UploadService, string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Attachment{}); err != nil { + t.Fatalf("migrate: %v", err) + } + dir := t.TempDir() + s := NewUploadService(db, dir) + if err := s.EnsureDir(); err != nil { + t.Fatalf("ensure dir: %v", err) + } + return s, dir +} + +func storedUploadPath(t *testing.T, dir, url string) []byte { + t.Helper() + rel := strings.TrimPrefix(url, "/uploads/") + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("read stored file %s: %v", url, err) + } + return data +} + +func TestSaveImageTranscodesJPEGToWebP(t *testing.T) { + s, dir := newUploadTestService(t) + + src := image.NewRGBA(image.Rect(0, 0, 48, 36)) + for y := 0; y < 36; y++ { + for x := 0; x < 48; x++ { + src.Set(x, y, color.RGBA{uint8(x * 5), uint8(y * 7), 128, 255}) + } + } + var in bytes.Buffer + if err := jpeg.Encode(&in, src, nil); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + if err != nil { + t.Fatalf("SaveImage: %v", err) + } + if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" { + t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME) + } + if att.Width != 48 || att.Height != 36 { + t.Fatalf("dims changed: %dx%d", att.Width, att.Height) + } + + img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL))) + if err != nil { + t.Fatalf("decode stored webp: %v", err) + } + if got := img.Bounds(); got.Dx() != 48 || got.Dy() != 36 { + t.Fatalf("stored webp dims: %v", got) + } +} + +func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) { + s, dir := newUploadTestService(t) + + src := image.NewNRGBA(image.Rect(0, 0, 24, 24)) + for y := 0; y < 24; y++ { + for x := 0; x < 24; x++ { + if y < 12 { + src.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 0}) // 上半透明 + } else { + src.SetNRGBA(x, y, color.NRGBA{12, 34, 56, 255}) + } + } + } + var in bytes.Buffer + if err := png.Encode(&in, src); err != nil { + t.Fatalf("encode png: %v", err) + } + + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + if err != nil { + t.Fatalf("SaveImage: %v", err) + } + if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" { + t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME) + } + + img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL))) + if err != nil { + t.Fatalf("decode stored webp: %v", err) + } + if _, _, _, a := img.At(0, 0).RGBA(); a != 0 { + t.Fatalf("alpha lost at transparent pixel: %d", a) + } + r, g, b, _ := img.At(5, 20).RGBA() + if r>>8 != 12 || g>>8 != 34 || b>>8 != 56 { + t.Fatalf("opaque color changed: %d,%d,%d", r>>8, g>>8, b>>8) + } +} + +func TestSaveImageKeepsGIFAsIs(t *testing.T) { + s, dir := newUploadTestService(t) + + src := image.NewRGBA(image.Rect(0, 0, 16, 16)) + for y := 0; y < 16; y++ { + for x := 0; x < 16; x++ { + src.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 0, 255}) + } + } + var in bytes.Buffer + if err := gif.Encode(&in, src, nil); err != nil { + t.Fatalf("encode gif: %v", err) + } + + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + if err != nil { + t.Fatalf("SaveImage: %v", err) + } + if !strings.HasSuffix(att.URL, ".gif") || att.MIME != "image/gif" { + t.Fatalf("gif should be kept as-is, got url=%s mime=%s", att.URL, att.MIME) + } + data := storedUploadPath(t, dir, att.URL) + magic := string(data[:6]) + if magic != "GIF89a" && magic != "GIF87a" { + t.Fatalf("stored gif magic invalid: %q", magic) + } +} + +func TestSaveImageKeepsWebPAsIs(t *testing.T) { + s, dir := newUploadTestService(t) + + src := image.NewRGBA(image.Rect(0, 0, 20, 10)) + var in bytes.Buffer + if err := webpenc.Encode(&in, src, webpenc.Options{Quality: ImageWebPQuality}); err != nil { + t.Fatalf("encode webp: %v", err) + } + + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + if err != nil { + t.Fatalf("SaveImage: %v", err) + } + if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" { + t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME) + } + if !bytes.Equal(storedUploadPath(t, dir, att.URL), in.Bytes()) { + t.Fatal("webp input should be stored byte-for-byte") + } + if att.Size != in.Len() { + t.Fatalf("size mismatch: att=%d input=%d", att.Size, in.Len()) + } +} diff --git a/docs/migration-announcement.md b/docs/migration-announcement.md new file mode 100644 index 0000000..93f6ee4 --- /dev/null +++ b/docs/migration-announcement.md @@ -0,0 +1,36 @@ +# 旧站迁移公告:账号数据已迁入,旧站将全面搬家 + +各位社区成员: + +**旧网站将在不久后全面迁移到姜十三论坛**,以后我们就在新家见面。 + +## 目前已迁移:用户账号 + +旧站账号数据已完成迁移,包括用户名、昵称、个性签名、邮箱和头像。 + +**无需重新注册**,直接用旧站的**用户名 + 原密码**登录即可——密码是原样迁移的,不需要重置。登录后建议在「账号设置」里核对一下资料。 + +![【图片占位】新站登录页截图](docs/screenshots/migration/login.png) + +## 暂未迁移:帖子与评论 + +旧站的帖子与评论**暂时不会搬过来**。因为直接迁移会丢失旧帖里的图片,迁移方案还在完善中。等完成后我们会发布公告,把大家的帖子完整搬进来,请耐心等待。 + +在此之前,新内容请直接发在新站。 + +## 常见问题 + +**Q:需要重新注册吗?** +A:不需要,用旧站用户名和密码直接登录。 + +**Q:密码忘了怎么办?** +A:联系管理员,核对身份后协助重置。 + +**Q:旧帖会丢失吗?** +A:不会。帖子与评论等迁移方案完善后会完整搬入,届时另行公告。 + +--- + +如有问题或建议,可通过邮件联系:aarbbs@88.com + +—— 姜十三论坛 团队 diff --git a/docs/site-operations.md b/docs/site-operations.md index 4e6e3ea..a551f30 100644 --- a/docs/site-operations.md +++ b/docs/site-operations.md @@ -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、历史远程配置继续可读;界面显示历史引用数,不提供删除历史配置按钮。 diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index 8a66033..4084635 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -16,7 +16,6 @@ import { SiteDocShell, SiteDocIdentity, SiteDocAnnoNav, - SiteDocBreadcrumb, } from "@/components/site-doc"; import { requestCanonical } from "@/lib/canonicalMeta"; @@ -109,13 +108,6 @@ export default async function AboutIndexPage() { className="px-5 sm:px-6 pt-5 pb-4" style={{ borderBottom: "1px solid var(--line)" }} > -

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 +503,90 @@ export default function AdsAdmin({ {cfg.durations.length === 0 ? (

至少保留一个时长档位。

) : ( -
- {cfg.durations.map((d, i) => ( -
- { - const durations = [...cfg.durations]; - durations[i] = { ...d, days: Number(e.target.value) || 1 }; - setCfg({ ...cfg, durations }); - }} - aria-label="天数" - /> - { - const durations = [...cfg.durations]; - durations[i] = { ...d, label: e.target.value }; - setCfg({ ...cfg, durations }); - }} - /> - -
- ))} +
+ + + + + + + + + + + + {cfg.durations.map((d, i) => ( + + + + + + + + ))} + +
天数标签图片价格文字价格操作
+ { + const durations = [...cfg.durations]; + durations[i] = { ...d, days: Number(e.target.value) || 1 }; + setCfg({ ...cfg, durations }); + }} + aria-label="天数" + /> + + { + const durations = [...cfg.durations]; + durations[i] = { ...d, label: e.target.value }; + setCfg({ ...cfg, durations }); + }} + /> + + { + const durations = [...cfg.durations]; + durations[i] = { ...d, price_hint: e.target.value }; + setCfg({ ...cfg, durations }); + }} + aria-label="图片广告价格" + /> + + { + const durations = [...cfg.durations]; + durations[i] = { ...d, price_hint_text: e.target.value }; + setCfg({ ...cfg, durations }); + }} + aria-label="文字广告价格" + /> + + +
)} diff --git a/frontend/app/admin/badges/BadgesClient.tsx b/frontend/app/admin/badges/BadgesClient.tsx index 172395b..ad6584f 100644 --- a/frontend/app/admin/badges/BadgesClient.tsx +++ b/frontend/app/admin/badges/BadgesClient.tsx @@ -1,11 +1,12 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Pencil, Plus, Trash2, Users } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Award, ChevronRight, Plus, Trash2, Users } from "lucide-react"; import Avatar from "@/components/Avatar"; import BadgeChip from "@/components/BadgeChip"; import ConfirmDialog from "@/components/ConfirmDialog"; -import Modal from "@/components/Modal"; +import AdminEmpty from "@/components/admin/AdminEmpty"; +import AdminPageHeader from "@/components/admin/AdminPageHeader"; import { BADGE_ICONS } from "@/lib/badgeIcons"; import { toast } from "@/lib/toast"; import { @@ -32,8 +33,8 @@ export default function BadgesClient() { const [badges, setBadges] = useState([]); const [loading, setLoading] = useState(true); - // 编辑/新建表单 - const [editing, setEditing] = useState(null); + // 主从布局:左侧列表选中项 / 右侧详情(编辑表单 + 持有者) + const [selectedId, setSelectedId] = useState(null); const [creating, setCreating] = useState(false); const [form, setForm] = useState({ name: "", icon: "medal", color: "accent" }); const [saving, setSaving] = useState(false); @@ -43,7 +44,6 @@ export default function BadgesClient() { const [deletingBusy, setDeletingBusy] = useState(false); // 持有者面板 - const [holdersFor, setHoldersFor] = useState(null); const [holders, setHolders] = useState([]); const [holdersLoading, setHoldersLoading] = useState(false); const [userQuery, setUserQuery] = useState(""); @@ -51,6 +51,8 @@ export default function BadgesClient() { const [searching, setSearching] = useState(false); const [grantingId, setGrantingId] = useState(null); + const detailRef = useRef(null); + const reload = useCallback(async () => { try { const res = await apiAdminListBadges(); @@ -66,22 +68,85 @@ export default function BadgesClient() { void reload(); }, [reload]); + const selected = useMemo( + () => badges.find((b) => b.id === selectedId) ?? null, + [badges, selectedId], + ); + const openCreate = () => { setForm({ name: "", icon: "medal", color: "accent" }); - setEditing(null); setCreating(true); + setSelectedId(null); }; - const openEdit = (b: BadgeWithHolders) => { - setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" }); + const selectBadge = (b: BadgeWithHolders, scroll = true) => { setCreating(false); - setEditing(b); + setSelectedId(b.id); + // 窄屏单列时,点选后滚动到右侧详情 + if (scroll && window.matchMedia("(max-width: 1023px)").matches) { + detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + } }; - const closeForm = () => { - setCreating(false); - setEditing(null); - }; + // 首次加载完成后自动选中第一个徽章(删除刷新期间跳过,避免选中已删除项) + useEffect(() => { + if (loading || creating || deletingBusy || selectedId != null) return; + if (badges.length > 0) selectBadge(badges[0], false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, badges, creating, deletingBusy, selectedId]); + + // 选中项被删除时回落到第一个(或清空) + useEffect(() => { + if (creating || loading || selectedId == null) return; + if (!badges.some((b) => b.id === selectedId)) { + setSelectedId(badges.length > 0 ? badges[0].id : null); + } + }, [badges, selectedId, creating, loading]); + + // 选中变化:同步表单草稿并重置搜索(badges 刻意不作为依赖,避免授予/撤销刷新时清掉搜索结果与未保存草稿) + useEffect(() => { + if (creating || selectedId == null) return; + const sel = badges.find((b) => b.id === selectedId); + if (!sel) return; + setForm({ name: sel.name, icon: sel.icon || "medal", color: sel.color || "accent" }); + setUserQuery(""); + setSearchResults([]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId, creating]); + + // 持有者加载:选中变化或徽章列表刷新(授予/撤销后)重新拉取 + useEffect(() => { + if (creating || selectedId == null) { + setHolders([]); + setHoldersLoading(false); + return; + } + let alive = true; + setHoldersLoading(true); + apiAdminBadgeHolders(selectedId) + .then((res) => { + if (alive) setHolders(res.holders ?? []); + }) + .catch((e) => { + if (alive) { + toast(e instanceof Error ? e.message : "获取持有者失败"); + setHolders([]); + } + }) + .finally(() => { + if (alive) setHoldersLoading(false); + }); + return () => { + alive = false; + }; + }, [selectedId, creating, badges]); + + const formDirty = + creating || + (selected != null && + (form.name !== selected.name || + form.icon !== (selected.icon || "medal") || + form.color !== (selected.color || "accent"))); const saveForm = async () => { if (saving) return; @@ -91,14 +156,15 @@ export default function BadgesClient() { } setSaving(true); try { - if (editing) { - await apiAdminUpdateBadge(editing.id, form); - toast("徽章已更新", "ok"); - } else { - await apiAdminCreateBadge(form); + if (creating) { + const created = await apiAdminCreateBadge(form); toast("徽章已创建", "ok"); + setCreating(false); + setSelectedId(created.id); + } else if (selected) { + await apiAdminUpdateBadge(selected.id, form); + toast("徽章已更新", "ok"); } - closeForm(); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); @@ -114,7 +180,7 @@ export default function BadgesClient() { await apiAdminDeleteBadge(deleting.id); toast("徽章已删除", "ok"); setDeleting(null); - if (holdersFor?.id === deleting.id) setHoldersFor(null); + if (selectedId === deleting.id) setSelectedId(null); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "删除失败"); @@ -123,22 +189,6 @@ export default function BadgesClient() { } }; - const openHolders = async (b: BadgeWithHolders) => { - setHoldersFor(b); - setUserQuery(""); - setSearchResults([]); - setHoldersLoading(true); - try { - const res = await apiAdminBadgeHolders(b.id); - setHolders(res.holders ?? []); - } catch (e) { - toast(e instanceof Error ? e.message : "获取持有者失败"); - setHolders([]); - } finally { - setHoldersLoading(false); - } - }; - const searchUsers = async () => { const q = userQuery.trim(); if (!q || searching) return; @@ -161,12 +211,11 @@ export default function BadgesClient() { }; const grant = async (userId: number) => { - if (!holdersFor || grantingId != null) return; + if (!selected || grantingId != null) return; setGrantingId(userId); try { - await apiAdminGrantBadge(holdersFor.id, userId); + await apiAdminGrantBadge(selected.id, userId); toast("已授予", "ok"); - await openHolders(holdersFor); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "授予失败"); @@ -176,254 +225,335 @@ export default function BadgesClient() { }; const revoke = async (userId: number) => { - if (!holdersFor) return; + if (!selected) return; try { - await apiAdminRevokeBadge(holdersFor.id, userId); + await apiAdminRevokeBadge(selected.id, userId); toast("已撤销", "ok"); - await openHolders(holdersFor); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "撤销失败"); } }; + // 授予/撤销后 reload 刷新持有数;这里再同步持有者列表 + useEffect(() => { + if (selectedId == null || creating) return; + setHoldersLoading(true); + apiAdminBadgeHolders(selectedId) + .then((res) => setHolders(res.holders ?? [])) + .catch(() => setHolders([])) + .finally(() => setHoldersLoading(false)); + }, [badges, selectedId, creating]); + const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]); + const nameOk = runeCount(form.name.trim()) >= 1 && runeCount(form.name.trim()) <= MAX_NAME_RUNES; + return (
-
-
-

- 徽章管理 -

-

- 管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。 -

-
- -
- - {loading ? ( -

加载徽章列表…

- ) : badges.length === 0 ? ( -
-

还没有徽章,点击右上角“新建徽章”开始。

-
- ) : ( -
- {badges.map((b, i) => ( -
- - - {b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有 - - - - - -
- ))} -
- )} - - {/* 持有者面板 */} - setHoldersFor(null)} - maxWidthClass="max-w-lg" - > -
- setUserQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void searchUsers(); - }} - placeholder="输入用户名或昵称搜索" - className="flex-1 min-w-0" - /> - -
- {searchResults.length > 0 && ( -
-

搜索结果

-
- {searchResults.map((u) => ( -
- - - {u.nickname || u.username} - @{u.username} - - - {holdersHeldIds.has(u.id) ? ( - 已持有 - ) : ( + } + /> + +
+ {/* 左半屏:徽章列表 */} +
+
+

+ 全部徽章 +

+ {!loading && badges.length > 0 && ( + {badges.length} 枚 + )} +
+ + {loading ? ( +

加载徽章列表…

+ ) : badges.length === 0 ? ( +
+

还没有徽章,点击右上角“新建徽章”开始。

+
+ ) : ( +
    + {badges.map((b, i) => { + const active = !creating && b.id === selectedId; + return ( +
  • +
  • + ); + })} +
+ )} +
+ + {/* 右半屏:编辑表单 + 持有者 */} +
+ {creating || selected ? ( + <> + {/* 编辑/新建表单 */} +
+
+

+ {creating ? "新建徽章" : "编辑徽章"} +

+ {!creating && selected && ( + )}
- ))} -
-
- )} -

当前持有者({holders.length})

- {holdersLoading ? ( -

加载持有者…

- ) : holders.length === 0 ? ( -

暂无人持有。

- ) : ( -
- {holders.map((h) => ( -
- - {h.nickname || h.username} - @{h.username} - - {new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予 - - + ); + })} +
+
+
+ +
+ {BADGE_COLORS.map((c) => { + const active = form.color === c; + return ( + + ); + })} +
+
+
+

预览

+ +
+
+
- 撤销 - -
- ))} -
- )} - + {creating ? ( + + ) : ( + + )} + +
+ - {/* 新建/编辑表单 */} - -
-
- - setForm((f) => ({ ...f, name: e.target.value }))} - maxLength={MAX_NAME_RUNES * 2} - placeholder="如:元老、技术之星" - /> -
-
- -
- {Object.keys(BADGE_ICONS).map((key) => { - const Icon = BADGE_ICONS[key]; - const active = form.icon === key; - return ( - - ); - })} -
-
-
- -
- {BADGE_COLORS.map((c) => { - const active = form.color === c; - return ( - - ); - })} -
-
-
-

预览

- -
+ + + +

+ 持有者 + {holders.length} 人 +

+
+
+
+ setUserQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void searchUsers(); + }} + placeholder="输入用户名或昵称搜索" + className="flex-1 min-w-0" + /> + +
+ {searchResults.length > 0 && ( +
+

搜索结果

+
+ {searchResults.map((u) => ( +
+ + + {u.nickname || u.username} + @{u.username} + + + {holdersHeldIds.has(u.id) ? ( + 已持有 + ) : ( + + )} +
+ ))} +
+
+ )} +

当前持有者({holders.length})

+ {holdersLoading ? ( +

加载持有者…

+ ) : holders.length === 0 ? ( +

暂无人持有。

+ ) : ( +
+ {holders.map((h) => ( +
+ + {h.nickname || h.username} + @{h.username} + + {new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予 + + +
+ ))} +
+ )} +
+ + )} + + ) : ( +
+ } + title={loading ? "加载中…" : "从左侧选择一个徽章"} + description={loading ? undefined : "选中后可在此编辑名称、图标与颜色,管理持有者"} + /> +
+ )}
-
- - -
- +
{/* 删除确认 */} 0 && item.height > 0) return `${item.width} × ${item.height}`; + return "—"; +} + +/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */ +function thumbSrc(item: MediaLibraryItem): string { + if (item.url.startsWith("/uploads/")) { + return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`; + } + return item.url; +} + +/** 管理后台媒体库:全站图片网格 + 分类筛选 + 详情弹窗(WebP 优先,可查看原图) */ +export default function MediaLibrary({ + items, + counts, + total, + initError, +}: { + items: MediaLibraryItem[]; + counts: Record; + total: number; + initError?: string; +}) { + const [category, setCategory] = useState("all"); + const [active, setActive] = useState(null); + const [showOriginal, setShowOriginal] = useState(false); + const [copied, setCopied] = useState(false); + + const filtered = useMemo( + () => (category === "all" ? items : items.filter((i) => i.category === category)), + [items, category] + ); + + const tabs = useMemo(() => { + const list: { key: string; label: string; count: number }[] = [ + { key: "all", label: "全部", count: total }, + ]; + // 按现有条目实际分类生成 tab,保持稳定顺序 + const seen = new Map(); + for (const i of items) seen.set(i.category, i.category_name); + for (const [key, label] of seen) { + list.push({ key, label, count: counts[key] ?? 0 }); + } + return list; + }, [items, counts, total]); + + const openItem = (item: MediaLibraryItem) => { + setActive(item); + setShowOriginal(false); + setCopied(false); + }; + + const copyLink = async (item: MediaLibraryItem) => { + try { + await navigator.clipboard.writeText(`${window.location.origin}${item.url}`); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* 剪贴板不可用静默 */ + } + }; + + if (initError) { + return ( + } + title="媒体库加载失败" + description={initError} + /> + ); + } + + if (total === 0) { + return ( + } + title="暂无图片资源" + description="站点内还没有任何上传图片" + /> + ); + } + + return ( +
+ {/* 分类 tab */} +
+ {tabs.map((t) => { + const on = category === t.key; + return ( + + ); + })} +
+ + {/* 图片网格 */} +
+ {filtered.map((item) => ( + + ))} +
+ + {/* 详情弹窗 */} + setActive(null)} + title={active?.name ?? ""} + description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined} + maxWidthClass="max-w-2xl" + dismissOnOverlay + bodyClassName="" + > + {active && ( +
+
+ {active.name} +
+ + {active.original_url && ( +

+ {showOriginal + ? "当前显示原图(原格式,体积更大)" + : "当前显示 WebP 压缩版;站点内容引用的均为 WebP"} +

+ )} + +
+ {[ + ["分类", active.category_name], + ["尺寸", formatDims(active)], + ["大小", formatBytes(active.size)], + ["格式", active.mime], + ["上传者", active.uploader || "—"], + ["时间", formatTime(active.uploaded_at)], + ].map(([k, v]) => ( +
+
{k}
+
+ {v} +
+
+ ))} + {active.source_url && ( + + )} +
+ +
+ {active.original_url && ( + + )} + + + + 新窗口打开 + +
+
+ )} +
+
+ ); +} diff --git a/frontend/app/admin/media/page.tsx b/frontend/app/admin/media/page.tsx new file mode 100644 index 0000000..8a9d09c --- /dev/null +++ b/frontend/app/admin/media/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { cookies } from "next/headers"; +import MediaLibrary from "./MediaLibrary"; +import { authCookieHeader } from "@/lib/cookies"; +import { fetchAdminMediaLibrary, type MediaLibraryItem } from "@/lib/api"; +import { getMeCached } from "@/lib/serverData"; +import { isSuperOrOwner } from "@/lib/roles"; +import Forbidden from "../Forbidden"; +import AdminPageHeader from "@/components/admin/AdminPageHeader"; +import { Images } from "lucide-react"; + +export const metadata: Metadata = { + title: "媒体库", +}; + +export default async function AdminMediaPage() { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + if (!me.user || !isSuperOrOwner(me.user.role)) { + return ; + } + + let items: MediaLibraryItem[] = []; + let counts: Record = {}; + let total = 0; + let initError = ""; + try { + const res = await fetchAdminMediaLibrary(cookie || undefined); + items = res.items ?? []; + counts = res.counts ?? {}; + total = res.total ?? 0; + } catch (e) { + initError = e instanceof Error ? e.message : "加载失败"; + } + + return ( +
+ + 加载中…

}> + +
+
+ ); +} diff --git a/frontend/app/admin/settings/import/ImportPanel.tsx b/frontend/app/admin/settings/import/ImportPanel.tsx index 526d74e..7ebf643 100644 --- a/frontend/app/admin/settings/import/ImportPanel.tsx +++ b/frontend/app/admin/settings/import/ImportPanel.tsx @@ -7,6 +7,7 @@ import { ClipboardCheck, Database, FileUp, + Images, ListChecks, ListX, Loader2, @@ -22,9 +23,8 @@ import { } from "@/components/admin"; import { apiAdminImportLegacy, - type LegacyCommentReport, type LegacyImportReport, - type LegacyPostReport, + type LegacyUserPreview, } from "@/lib/api"; /** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */ @@ -34,27 +34,40 @@ const SOURCES = [ { key: "typecho", label: "Typecho", enabled: false }, ] as const; +/** 单个旧用户的导入选择:是否建号 + 内容归属 */ +type UserSel = { create: boolean; owner: "auto" | "operator" | "custom"; target: string }; + /** * 站点设置 → 数据导入: - * 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。 + * 上传旧站数据库(+可选头像包 / 帖子图片包)→ 预检(dry-run,返回用户/帖子/评论清单) + * → 勾选导入范围(是否建号、内容归属、排除帖子/评论)→ 确认正式导入。 * 全程幂等:已导入对象自动跳过,可安全重复执行。 */ export default function ImportPanel() { const [source, setSource] = useState("jiang13"); const [dbFile, setDbFile] = useState(null); const [zipFile, setZipFile] = useState(null); + const [imagesZipFile, setImagesZipFile] = useState(null); const [withContent, setWithContent] = useState(true); const [busy, setBusy] = useState<"check" | "import" | null>(null); const [error, setError] = useState(""); - const [report, setReport] = useState(null); + const [preview, setPreview] = useState(null); // 预检清单 + const [report, setReport] = useState(null); // 最近一次执行结果 const dbInputRef = useRef(null); const zipInputRef = useRef(null); + const imagesZipInputRef = useRef(null); + + // 勾选状态(预检后按清单初始化,默认全部导入) + const [userSel, setUserSel] = useState>({}); + const [postChecked, setPostChecked] = useState>(new Set()); + const [commentChecked, setCommentChecked] = useState>(new Set()); // 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置 const [preflighted, setPreflighted] = useState(false); const resetResult = () => { setPreflighted(false); + setPreview(null); setReport(null); setError(""); }; @@ -67,22 +80,73 @@ export default function ImportPanel() { setZipFile(f); resetResult(); }; + const pickImagesZip = (f: File | null) => { + setImagesZipFile(f); + resetResult(); + }; const toggleContent = () => { setWithContent((v) => !v); resetResult(); }; + const initSelections = (rep: LegacyImportReport) => { + const users: Record = {}; + (rep.user_list ?? []).forEach((u) => { + users[u.id] = { create: true, owner: "auto", target: "" }; + }); + setUserSel(users); + setPostChecked(new Set((rep.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id))); + setCommentChecked(new Set((rep.comment_list ?? []).filter((c) => c.published).map((c) => c.id))); + }; + + // 由勾选状态构造请求参数(相对预检清单求差集) + const buildOptions = () => { + const skipUsers: number[] = []; + const operatorUsers: number[] = []; + const userMap: { oldId: number; username: string }[] = []; + Object.entries(userSel).forEach(([idStr, sel]) => { + const id = Number(idStr); + if (sel.owner === "operator") { + skipUsers.push(id); + operatorUsers.push(id); + } else if (sel.owner === "custom" && sel.target.trim()) { + skipUsers.push(id); + userMap.push({ oldId: id, username: sel.target.trim() }); + } else if (!sel.create) { + skipUsers.push(id); + } + }); + return { + withContent, + dryRun: false, + skipUsers, + operatorUsers, + userMap, + skipPosts: (preview?.post_list ?? []) + .filter((p) => p.published && !p.poll && !postChecked.has(p.id)) + .map((p) => p.id), + skipComments: (preview?.comment_list ?? []) + .filter((c) => c.published && !commentChecked.has(c.id)) + .map((c) => c.id), + }; + }; + const run = async (dryRun: boolean) => { if (!dbFile || busy) return; setBusy(dryRun ? "check" : "import"); setError(""); try { - const rep = await apiAdminImportLegacy(source, dbFile, zipFile, { - withContent, + const opts = dryRun ? { withContent, dryRun } : buildOptions(); + const rep = await apiAdminImportLegacy(source, dbFile, zipFile, imagesZipFile, { + ...opts, dryRun, }); setReport(rep); - if (dryRun) setPreflighted(true); + if (dryRun) { + setPreview(rep); + initSelections(rep); + setPreflighted(true); + } } catch (e) { setError(e instanceof Error ? e.message : "导入失败"); } finally { @@ -90,6 +154,25 @@ export default function ImportPanel() { } }; + const setUser = (id: number, patch: Partial) => { + setUserSel((m) => { + const cur = m[id] ?? { create: true, owner: "auto" as const, target: "" }; + const next = { ...cur, ...patch }; + // 归属指定已有账号 / 站长时,账号不再新建 + if (next.owner !== "auto") next.create = false; + return { ...m, [id]: next }; + }); + }; + + const toggleSet = (set: Set, id: number) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }; + + const hasLists = withContent && !!preview?.post_list?.length; + return (
+
@@ -162,7 +254,7 @@ export default function ImportPanel() {
@@ -201,6 +293,38 @@ export default function ImportPanel() {

)} + {preflighted && preview?.user_list && preview.user_list.length > 0 && ( + + )} + {hasLists && preview && ( + setPostChecked((s) => toggleSet(s, id))} + onAll={(on) => + setPostChecked( + on + ? new Set((preview.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id)) + : new Set() + ) + } + /> + )} + {hasLists && preview && !!preview.comment_list?.length && ( + setCommentChecked((s) => toggleSet(s, id))} + onAll={(on) => + setCommentChecked( + on + ? new Set(preview.comment_list!.filter((c) => c.published).map((c) => c.id)) + : new Set() + ) + } + /> + )} + {report && }
@@ -258,6 +382,222 @@ function FilePickRow({ ); } +// ===== 预检后勾选清单 ===== + +const fieldStyle = { background: "var(--panel)", border: "1px solid var(--line)" } as const; + +function UserSelectList({ + list, + sel, + onChange, +}: { + list: LegacyUserPreview[]; + sel: Record; + onChange: (id: number, patch: Partial) => void; +}) { + return ( +
+

+ + 用户({list.length})——取消勾选则不创建账号,其内容按右侧归属落位 +

+
+ {list.map((u) => { + const s = sel[u.id] ?? { create: true, owner: "auto" as const, target: "" }; + return ( +
+ +
+ + {u.username} + + {u.nickname && u.nickname !== u.username && ( + {u.nickname} + )} + {u.exists && ( + + 同名已存在·导入时跳过建号 + + )} +
+ + 帖 {u.posts} · 评 {u.comments} + + + {s.owner === "custom" && ( + onChange(u.id, { target: e.target.value })} + className="rounded-lg px-2 py-1 text-[12.5px] w-36 shrink-0" + style={fieldStyle} + /> + )} +
+ ); + })} +
+
+ ); +} + +function BulkBar({ label, onAll }: { label: string; onAll: (on: boolean) => void }) { + return ( + + {label} + + + + ); +} + +function PostSelectList({ + list, + checked, + onToggle, + onAll, +}: { + list: { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean }[]; + checked: Set; + onToggle: (id: number) => void; + onAll: (on: boolean) => void; +}) { + const selectable = list.filter((p) => p.published && !p.poll).length; + return ( +
+
+

+ + 帖子({selectable}/{list.length} 可导入)——取消勾选即排除,其评论一并跳过 +

+ +
+
+ {list.map((p) => { + const disabled = !p.published || p.poll; + return ( + + ); + })} +
+
+ ); +} + +function CommentSelectList({ + list, + checked, + onToggle, + onAll, +}: { + list: { id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean }[]; + checked: Set; + onToggle: (id: number) => void; + onAll: (on: boolean) => void; +}) { + const selectable = list.filter((c) => c.published).length; + return ( +
+ + + + 评论({selectable}/{list.length} 可导入) + + + +
+ {list.map((c) => ( + + ))} +
+
+ ); +} + +function fmtDate(s: string) { + const d = new Date(s); + return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("zh-CN"); +} + +// ===== 结果报告 ===== + function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) { return (
- 预检结果(未写入任何数据)——确认无误后点击「确认正式导入」 + 预检结果(未写入任何数据)——请在上方勾选导入范围,确认后点击「确认正式导入」 ) : ( <> @@ -296,6 +636,9 @@ function ReportView({ report }: { report: LegacyImportReport }) { } label="旧库用户" value={report.users.total} /> } label={isDry ? "将导入" : "已导入"} value={report.users.imported} /> } label="已存在跳过" value={report.users.skipped} ok={false} /> + {report.users.excluded > 0 && ( + } label="不建号" value={report.users.excluded} ok={false} /> + )} {report.users.avatar_written > 0 && ( } label="头像写入" value={report.users.avatar_written} /> )} @@ -332,29 +675,45 @@ function ReportView({ report }: { report: LegacyImportReport }) { ); } -function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) { +function PostsView({ report, isDry }: { report: NonNullable; isDry: boolean }) { return ( } title="帖子"> } label="旧帖" value={report.total} /> } label={isDry ? "将导入" : "已导入"} value={report.imported} /> } label="已导入跳过" value={report.skipped} ok={false} /> + {report.excluded > 0 && ( + } label="手动排除" value={report.excluded} ok={false} /> + )} + {report.images_written > 0 && ( + } label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} /> + )} {report.polls_skipped > 0 && ( } label="投票帖未迁移" value={report.polls_skipped} ok={false} /> )} + + ); } -function CommentsView({ report, isDry }: { report: LegacyCommentReport; isDry: boolean }) { +function CommentsView({ report, isDry }: { report: NonNullable; isDry: boolean }) { return ( } title="评论"> } label="旧评论" value={report.total} /> } label={isDry ? "将导入" : "已导入"} value={report.imported} /> } label="已导入跳过" value={report.skipped} ok={false} /> + {report.excluded > 0 && ( + } label="手动排除" value={report.excluded} ok={false} /> + )} + {report.images_written > 0 && ( + } label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} /> + )} + + diff --git a/frontend/app/admin/sidebar/SidebarAdmin.tsx b/frontend/app/admin/sidebar/SidebarAdmin.tsx index cd4e000..3c088cf 100644 --- a/frontend/app/admin/sidebar/SidebarAdmin.tsx +++ b/frontend/app/admin/sidebar/SidebarAdmin.tsx @@ -180,6 +180,16 @@ export default function SidebarAdmin({ } }; + // 放弃未保存修改:回到最近一次保存的布局 + const discardChanges = () => { + if (busy) return; + try { + setConfig(JSON.parse(savedJsonRef.current)); + } catch { + /* 忽略:快照损坏时保持现状 */ + } + }; + /* ---------- 自定义工具 ---------- */ const openCreate = () => { @@ -304,16 +314,41 @@ export default function SidebarAdmin({ ); + // 页头与浮动保存条共用的未保存提示 + 操作按钮 + const dirtyDot = ( + + ); + const saveControls = dirty ? ( +
+ + {dirtyDot} + 布局有未保存的修改 + + + +
+ ) : undefined; + return ( -
+ <> +
{SIDES.map(({ scope, icon, desc, sides }) => { const sc = config.scopes[scope] ?? {}; const left = sc.left ?? []; @@ -413,27 +448,34 @@ export default function SidebarAdmin({
-
- {/* 吸底保存栏 */} - {dirty && ( -
-

- - 布局有未保存的修改 -

- -
- )} + {/* 浮动保存条:吸在内容列底部居中,滚动全程可见,不再贴屏幕角落 */} + {dirty && ( +
+
+ + {dirtyDot} + 布局有未保存的修改 + + + +
+
+ )} + + {/* 工具编辑弹层 */} p.id === paymentID); const selectedDuration = cfg?.durations.find((d) => d.days === days); + const selectedPrice = selectedDuration + ? (kind === "text" ? selectedDuration.price_hint_text : selectedDuration.price_hint).trim() + : ""; const canSubmit = !!cfg && cfg.payments.length > 0 && !busy; const kindLabel = kind === "image" ? "图片广告" : "文字广告"; @@ -224,6 +227,7 @@ export default function AdsBuyClient() {

{kindLabel} {selectedDuration ? ` · ${selectedDuration.label}` : ""} + {selectedPrice ? ` · ${selectedPrice}` : ""}

@@ -331,9 +335,17 @@ export default function AdsBuyClient() {
{cfg.durations.map((d) => { const active = days === d.days; + const price = (kind === "text" ? d.price_hint_text : d.price_hint).trim(); return ( - setDays(d.days)} compact> - {d.label.replace(/\s/g, "")} + setDays(d.days)} + compact + priced={!!price} + > + {d.label.replace(/\s/g, "")} + {price ? {price} : null} ); })} @@ -455,17 +467,21 @@ function ChipTab({ onClick, children, compact, + priced, }: { active: boolean; onClick: () => void; children: React.ReactNode; compact?: boolean; + priced?: boolean; }) { return ( diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx index b338c42..8929287 100644 --- a/frontend/app/announcement/[id]/page.tsx +++ b/frontend/app/announcement/[id]/page.tsx @@ -15,7 +15,6 @@ import { SiteDocPageNav, SiteDocAnnoNav, SiteDocMasthead, - SiteDocBreadcrumb, } from "@/components/site-doc"; import { requestCanonical } from "@/lib/canonicalMeta"; import { webPageJsonLd } from "@/lib/jsonLd"; @@ -115,14 +114,6 @@ export default async function AnnouncementPage({ params }: PageProps) { } >
- -

| null>(null); /** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */ const lastSentIdByRoom = useRef>(new Map()); + /** 搜索重开私聊:router.push 完成前 activeRoomId 仍为 null,列表刷新需据此抑制目标会话未读 */ + const enteringRoomIdRef = useRef(null); /** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */ const applyRooms = useCallback( (list: ChatRoomView[]) => { const next = (list || []).map((r) => { - if (activeRoomId && r.id === activeRoomId) { + if ((activeRoomId && r.id === activeRoomId) || r.id === enteringRoomIdRef.current) { return { ...r, unread_count: 0 }; } const sentId = lastSentIdByRoom.current.get(r.id); @@ -302,6 +304,9 @@ export default function ChatShell({ // 进房即清角标;MarkRead 成功后再清一次(防 refresh 抢在已读落库前写回旧未读) useEffect(() => { if (!activeRoomId) return; + if (enteringRoomIdRef.current === activeRoomId) { + enteringRoomIdRef.current = null; + } const clearActiveUnread = () => { setRooms((prev) => prev.map((r) => (r.id === activeRoomId ? { ...r, unread_count: 0 } : r)) @@ -346,6 +351,7 @@ export default function ChatShell({ const openDM = async (uid: number) => { try { const { room } = await apiOpenDirectChat(uid); + enteringRoomIdRef.current = room.id; setQuery(""); setUserHits([]); router.push(`/chat/${room.id}`); @@ -468,7 +474,7 @@ export default function ChatShell({ )}

-
+
{query.trim() && (

(initialMessages); const [hasMore, setHasMore] = useState(initialHasMore); const [loadingMore, setLoadingMore] = useState(false); + // 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负), + // -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数 + const [showJumpBottom, setShowJumpBottom] = useState(false); + const [newBelowCount, setNewBelowCount] = useState(0); const [content, setContent] = useState(""); const [sending, setSending] = useState(false); const [members, setMembers] = useState([]); @@ -550,12 +555,22 @@ export default function ChatRoomClient({ [roomId, isRealMember] ); + // 悬浮钮/新消息引导回底:跳底 + 推进已读 + 清空待看计数 + const jumpToBottom = useCallback(() => { + setNewBelowCount(0); + scrollToBottom(true); + markReadToLatest(); + }, [scrollToBottom, markReadToLatest]); + useEffect(() => { if (!canView) return; realtime.joinRoom(roomId); void refreshMembers(); // 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下) markReadToLatest(); + // 换房时重置「回到底部」悬浮钮状态 + setShowJumpBottom(false); + setNewBelowCount(0); return () => { realtime.leaveRoom(roomId); }; @@ -573,10 +588,13 @@ export default function ChatRoomClient({ requestAnimationFrame(() => { const el = listRef.current; if (!el) return; - // column-reverse:靠近 scrollTop=0 即贴底 - if (el.scrollTop < 120) { + // column-reverse:-scrollTop 为距底部距离,接近 0 即贴底 + if (-el.scrollTop < 120) { scrollToBottom(true); markReadToLatest(); + } else { + // 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示 + setNewBelowCount((c) => c + 1); } }); }); @@ -652,8 +670,9 @@ export default function ChatRoomClient({ requestAnimationFrame(() => { const el = listRef.current; if (!el) return; - // column-reverse:历史插在上方,用高度差补偿保持视口锚点 - el.scrollTop = prevTop + (el.scrollHeight - prevHeight); + // column-reverse:历史插在上方,scrollTop 为负; + // 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动 + el.scrollTop = prevTop - (el.scrollHeight - prevHeight); }); } else { setHasMore(false); @@ -668,13 +687,20 @@ export default function ChatRoomClient({ const handleScroll = useCallback(() => { const el = listRef.current; if (!el) return; - // column-reverse:滚向上方历史 ≈ 接近 max scrollTop - if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) { + // column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离 + const fromBottom = -el.scrollTop; + // 滚向上方历史:距顶部不足 60px 时加载更多 + if (el.scrollHeight - el.clientHeight - fromBottom < 60) { loadMore(); } - if (el.scrollTop < 60) { + if (fromBottom < 60) { markReadToLatest(); } + // 「回到底部」:离开底部一段距离即出现,接近底部时消失 + setShowJumpBottom(fromBottom > 160); + if (fromBottom < 120) { + setNewBelowCount(0); + } }, [loadMore, markReadToLatest]); const syncComposerHeight = () => { @@ -1238,7 +1264,7 @@ export default function ChatRoomClient({

-
+
+
{!canView ? (

@@ -1313,7 +1340,7 @@ export default function ChatRoomClient({ @@ -1435,7 +1462,7 @@ export default function ChatRoomClient({

)}
{ e.preventDefault(); e.stopPropagation(); @@ -1566,7 +1593,7 @@ export default function ChatRoomClient({
)} +
+ {/* 回到底部:飞书式悬浮钮,贴近当前对话框右下角;翻历史期间带新消息计数 */} + {canView && showJumpBottom && ( + + )}
{isRealMember ? ( @@ -1710,10 +1766,16 @@ export default function ChatRoomClient({ : "输入消息,@ 可提醒成员" } maxLength={2000} - className={`j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${ + className={`j13-chat-scroll j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${ content ? " has-value" : "" }`} style={{ color: content ? undefined : "var(--ink)" }} + onFocus={(e) => { + // 键盘弹出兜底:把输入框滚入可视区(个别浏览器 + // interactiveWidget=resizes-content 不生效时仍能聚焦可见) + const composer = e.currentTarget.closest(".j13-chat-composer"); + composer?.scrollIntoView({ block: "end", behavior: "smooth" }); + }} onInput={(e) => { const t = e.currentTarget; t.style.height = "auto"; @@ -1796,7 +1858,7 @@ export default function ChatRoomClient({
-
+
{rightPanel === "members" && ( +

共 {postsData.total} 篇结果 · 按相关度与时间排序

-
+
{postsData.posts.length > 0 ? ( postsData.posts.map((post, i) => ( @@ -231,37 +237,30 @@ export default async function HomePage({ searchParams }: HomePageProps) { if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort; return ( -
+
- + -
+
{/* 左栏:按配置渲染(缺省 = 板块目录) */} {scopeLists.left.length > 0 && ( -
- {breadcrumb} {children}
diff --git a/frontend/components/site-doc/index.ts b/frontend/components/site-doc/index.ts index 507e478..fa4e3d5 100644 --- a/frontend/components/site-doc/index.ts +++ b/frontend/components/site-doc/index.ts @@ -4,6 +4,4 @@ export { default as AboutSiteHeader } from "./AboutSiteHeader"; export { default as SiteDocPageNav } from "./SiteDocPageNav"; export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav"; export { default as SiteDocMasthead } from "./SiteDocMasthead"; -export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb"; -export type { Crumb } from "./SiteDocBreadcrumb"; export type { SiteDocIdentityKind } from "./SiteDocIdentity"; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index e6a23e7..cc8b65a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -13,7 +13,7 @@ import { type FooterLinksAlign, } from "./brand"; import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels"; -import { CSRF_COOKIE } from "./cookies"; +import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies"; import { publishPublicMetaSnapshot } from "./publicMetaSnapshot"; import { emitForceLogout } from "./userEvents"; @@ -306,12 +306,14 @@ export interface PublicSponsorItem { hover_text?: string; } -/** 板块右栏:管理员公开资料 */ +/** 板块右栏:管理员公开资料(含等级与颁发的徽章) */ export interface BoardModerator { id: number; username: string; nickname: string; avatar: string; + level?: number; + badges?: UserBadgeView[]; } /** 板块右栏:板级统计 */ @@ -686,13 +688,13 @@ function clientHeaders(extra: Record = {}): HeadersInit { // ===== Refresh token 自动续期 ===== -let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null; +let refreshPromise: ReturnType | null = null; // 被封禁通知去重:同一登录生命周期内只强制下线/弹一次, // 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示 let bannedNotified = false; -// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。 +// 登录态被剔除通知去重(安全设置剔除设备、会话数超上限被淘汰等)。 // sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效" let sessionEndedNotified = false; let sessionSeen = false; @@ -720,22 +722,25 @@ function detectBannedBody(res: Response): void { .catch(() => {}); } -// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带) -async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> { +// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。 +// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态 +// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见 +async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> { try { const res = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", headers: clientHeaders(), }); - if (res.ok) return { ok: true, banned: false }; + if (res.ok) return { ok: true, banned: false, transient: false }; + if (res.status >= 500) return { ok: false, banned: false, transient: true }; if (res.status === 403) { const data = await res.json().catch(() => ({} as { code?: string })); - if (data.code === "account_banned") return { ok: false, banned: true }; + if (data.code === "account_banned") return { ok: false, banned: true, transient: false }; } - return { ok: false, banned: false }; + return { ok: false, banned: false, transient: false }; } catch { - return { ok: false, banned: false }; + return { ok: false, banned: false, transient: true }; } } @@ -762,9 +767,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise { - const res = await fetch("/api/me", { + let res = await fetch("/api/me", { credentials: "include", cache: "no-store", }); - const data: MeResponse = await res.json(); + let data: MeResponse = await res.json(); if (data.user) sessionSeen = true; // /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径) if (data.banned === true && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); } else if (!data.user && res.ok) { - // 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、 - // 设备剔除等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定 + if (sessionSeen && hasAuthCookieHint()) { + // access cookie 到达 15min MaxAge 会被浏览器删除,/api/me 只能以游客 + // 应答——这不代表会话被剔除(refresh token 仍有效)。先 refresh 恢复 + // access 再复判;真被剔除时 refresh 也会明确失败,结论不变 + const refreshed = await apiRefresh(); + if (refreshed.ok) { + res = await fetch("/api/me", { credentials: "include", cache: "no-store" }); + data = await res.json(); + if (data.user) sessionSeen = true; + } else if (refreshed.transient) { + // 网络/后端暂不可用:无法证明登录态失效,不下结论 + return data; + } + if (refreshed.banned && !bannedNotified) { + bannedNotified = true; + emitForceLogout("banned"); + return data; + } + } + // 曾登录、经复判仍是游客:登录态在别处被剔除(设备剔除、 + // 会话超限淘汰等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定 notifySessionEnded(); } return data; @@ -2243,7 +2268,7 @@ export async function apiUploadAvatar( return res.json(); } -// 上传帖子插图(JPEG / PNG / WebP,≤5MB) +// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外) export async function apiUploadPostImage( file: File | Blob, filename = "image.jpg" @@ -2949,6 +2974,7 @@ export type LegacyUserReport = { total: number; imported: number; skipped: number; + excluded: number; avatar_written: number; conflicts?: string[]; avatar_missing?: string[]; @@ -2965,9 +2991,13 @@ export type LegacyPostReport = { total: number; imported: number; skipped: number; + excluded: number; + excluded_detail?: string[]; polls_skipped: number; poll_titles?: string[]; owner_fallback?: string[]; + images_written: number; + images_missing?: string[]; failed?: string[]; }; @@ -2975,37 +3005,94 @@ export type LegacyCommentReport = { total: number; imported: number; skipped: number; + excluded: number; + excluded_detail?: string[]; skipped_detail?: string[]; owner_fallback?: string[]; + images_written: number; + images_missing?: string[]; failed?: string[]; }; +// ===== 预检清单(dry-run 返回,供勾选导入范围) ===== + +export type LegacyUserPreview = { + id: number; + username: string; + nickname: string; + posts: number; + comments: number; + exists: boolean; + has_avatar: boolean; +}; + +export type LegacyPostPreview = { + id: number; + title: string; + author: string; + board: string; + created_at: string; + poll: boolean; + published: boolean; +}; + +export type LegacyCommentPreview = { + id: number; + post_id: number; + post_title: string; + author: string; + excerpt: string; + created_at: string; + published: boolean; +}; + export type LegacyImportReport = { dry_run: boolean; users: LegacyUserReport; boards?: LegacyBoardReport; posts?: LegacyPostReport; comments?: LegacyCommentReport; + user_list?: LegacyUserPreview[]; + post_list?: LegacyPostPreview[]; + comment_list?: LegacyCommentPreview[]; notes?: string[]; }; export type LegacyImportOptions = { withContent: boolean; dryRun: boolean; + /** 不创建账号的旧用户 ID(内容仍按归属规则落位) */ + skipUsers?: number[]; + /** 内容归到站长的旧用户 ID */ + operatorUsers?: number[]; + /** 旧用户 ID → 指定已有账号用户名(内容归属) */ + userMap?: { oldId: number; username: string }[]; + /** 排除的旧帖 ID(其评论自动跳过) */ + skipPosts?: number[]; + /** 排除的旧评论 ID */ + skipComments?: number[]; }; -/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */ +/** 上传旧站 SQLite 库(必填)、可选头像 / 帖子图片 zip 与勾选参数,导入用户与内容。幂等可重复执行 */ export async function apiAdminImportLegacy( source: string, dbFile: File, zipFile: File | null, + imagesZipFile: File | null, opts: LegacyImportOptions ): Promise { const form = new FormData(); form.append("db_file", dbFile, dbFile.name || "jiang13.db"); if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip"); + if (imagesZipFile) form.append("images_zip", imagesZipFile, imagesZipFile.name || "images.zip"); form.append("with_content", String(opts.withContent)); form.append("dry_run", String(opts.dryRun)); + if (opts.skipUsers?.length) form.append("skip_users", opts.skipUsers.join(",")); + if (opts.operatorUsers?.length) form.append("operator_users", opts.operatorUsers.join(",")); + if (opts.userMap?.length) + form.append("user_map", opts.userMap.map((m) => `${m.oldId}:${m.username}`).join(",")); + if (opts.skipPosts?.length) form.append("skip_posts", opts.skipPosts.join(",")); + if (opts.skipComments?.length) form.append("skip_comments", opts.skipComments.join(",")); const res = await fetchWithRefresh(`/api/admin/import/${source}`, { method: "POST", headers: clientHeaders(), @@ -4101,6 +4188,7 @@ export interface AdDurationOption { days: number; label: string; price_hint: string; + price_hint_text: string; } export interface AdPaymentOption { @@ -4293,6 +4381,35 @@ export async function fetchAdminSidebar( return res.json(); } +/** 媒体库条目(管理后台全站图片盘点) */ +export interface MediaLibraryItem { + url: string; + original_url?: string; // 同名原图(仅当原图与 WebP 并存) + category: string; // image/avatar/background/ads/brand + category_name: string; + name: string; + mime: string; + size: number; + width: number; + height: number; + uploader?: string; + uploaded_at?: string; + source_url?: string; // 来源回链(帖子/评论锚点/用户主页) + source_label?: string; // 来源描述(如:帖子《…》下的评论) +} + +/** SSR 管理端媒体库:全站图片盘点 */ +export async function fetchAdminMediaLibrary( + cookie?: string +): Promise<{ items: MediaLibraryItem[]; counts: Record; total: number }> { + const res = await fetch(`${API_BASE}/api/admin/media-library`, { + ...ssrInit(cookie ? { Cookie: cookie } : undefined), + credentials: "include", + }); + if (!res.ok) throw new Error("加载媒体库失败"); + return res.json(); +} + export async function apiAdminGetSidebar(): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[]; diff --git a/frontend/lib/cookies.ts b/frontend/lib/cookies.ts index 618d7a4..465bdff 100644 --- a/frontend/lib/cookies.ts +++ b/frontend/lib/cookies.ts @@ -34,7 +34,7 @@ export function authCookieHeader(store: CookieReader): string { } /** - * 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」并校准 IP/UA。 + * 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」。 * 其它 SSR 接口不要用这个,避免把 refresh token 带到无关后端请求。 */ export function sessionCookieHeader(store: CookieReader): string { diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index 2151eb4..4b41a96 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -23,7 +23,7 @@ export const RT_CHAT_UNREAD = "chat:unread"; export const RT_NOTIFICATION_NEW = "notification:new"; export const RT_FEED_CHANGED = "feed:changed"; export const RT_MODERATION_CHANGED = "moderation:changed"; -// 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备),推 user:{id} +// 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰),推 user:{id} export const RT_SESSION_REPLACED = "session:replaced"; export interface RtEnvelope { diff --git a/frontend/lib/userEvents.ts b/frontend/lib/userEvents.ts index eba9b2f..93a6ed9 100644 --- a/frontend/lib/userEvents.ts +++ b/frontend/lib/userEvents.ts @@ -33,7 +33,7 @@ export function onUserUpdate(handler: (patch: Partial) => void): () => voi * * 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的 * code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发; - * 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后 + * 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰)经探测确认后 * 以 reason=session_replaced 派发。Header 负责复用登出清理链路 * (清 cookie、游客化、router.refresh),AccountGuard 负责弹出对应告知—— * 状态清理与告知 UI 解耦,避免各请求调用方各自处理导致残留