From ff2ab286fb5409cc7567750b73450e60f46a372a Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 1 Oct 2026 03:06:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B9=A6=E5=BA=93=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA/=E5=9B=BE=E7=89=87=E5=8F=98=E4=BD=93/?= =?UTF-8?q?=E4=B9=A6=E7=B1=8D=E6=90=9C=E7=B4=A2/=E5=B0=8F=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E8=BF=90=E8=A1=8C=E6=97=B6=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新 --- .gitignore | 2 + backend/config/config_test.go | 2 +- backend/handler/admin_legacy_import.go | 2 +- backend/handler/admin_user.go | 35 + backend/handler/auth.go | 7 +- backend/handler/board.go | 12 +- backend/handler/chat.go | 71 +- backend/handler/comment.go | 32 - backend/handler/handlers.go | 60 +- backend/handler/library.go | 110 ++- backend/handler/notification.go | 27 +- backend/handler/operations.go | 3 +- backend/handler/overview.go | 28 +- backend/handler/upload.go | 33 +- backend/handler/user.go | 10 +- backend/middleware/auth.go | 27 +- backend/middleware/upload_cache.go | 19 + backend/model/ad.go | 44 +- backend/model/db.go | 27 + backend/model/models.go | 77 ++- backend/realtime/hub.go | 25 +- backend/router/router.go | 45 +- backend/service/actor.go | 132 +++- backend/service/ad.go | 32 +- backend/service/admin_content.go | 61 +- backend/service/admin_user.go | 80 ++- backend/service/chat.go | 82 ++- backend/service/comment.go | 89 ++- backend/service/follow.go | 2 +- backend/service/friendlink.go | 4 +- backend/service/hide_password_cookie.go | 4 +- backend/service/image_variants.go | 276 ++++++++ backend/service/image_variants_test.go | 282 ++++++++ backend/service/leaderboard_test.go | 6 +- backend/service/legacyimport.go | 34 +- backend/service/legacyimport_test.go | 10 +- backend/service/library.go | 186 +++++- backend/service/library_export.go | 369 ++++++++++ backend/service/library_export_test.go | 199 ++++++ backend/service/library_import.go | 631 ++++++++++++++++++ backend/service/library_import_test.go | 354 ++++++++++ backend/service/library_test.go | 55 +- backend/service/media_library.go | 72 +- backend/service/media_library_test.go | 60 +- backend/service/media_thumbs.go | 35 +- backend/service/operations_mail.go | 1 + backend/service/overview.go | 24 +- backend/service/post.go | 58 +- backend/service/post_interact.go | 2 + backend/service/site_page.go | 2 +- backend/service/timeline_release.go | 2 +- backend/service/upload.go | 29 +- backend/service/upload_webp_test.go | 8 +- frontend/app/about/page.tsx | 4 +- frontend/app/admin/ads/AdsAdmin.tsx | 25 +- frontend/app/admin/ads/page.tsx | 4 +- .../admin/announcements/[id]/edit/page.tsx | 4 +- frontend/app/admin/announcements/new/page.tsx | 4 +- frontend/app/admin/announcements/page.tsx | 6 +- .../app/admin/appearance/BgMediaPicker.tsx | 5 +- frontend/app/admin/content/ContentAdmin.tsx | 4 +- .../admin/friend-links/FriendLinksAdmin.tsx | 5 +- frontend/app/admin/layout.tsx | 7 +- frontend/app/admin/library/LibraryAdmin.tsx | 304 +++++++-- .../app/admin/library/LibraryCoverPicker.tsx | 101 ++- .../admin/library/LibrarySectionsPanel.tsx | 1 + frontend/app/admin/library/page.tsx | 4 +- frontend/app/admin/media/MediaLibrary.tsx | 10 +- frontend/app/admin/pages/[id]/edit/page.tsx | 4 +- frontend/app/admin/pages/new/page.tsx | 4 +- frontend/app/admin/pages/page.tsx | 4 +- .../app/admin/settings/BrandMediaPicker.tsx | 10 +- frontend/app/admin/settings/BrandSeoPanel.tsx | 8 +- frontend/app/admin/sidebar/SidebarAdmin.tsx | 149 ++++- .../app/admin/users/[id]/UserAuditClient.tsx | 4 +- frontend/app/ads/buy/AdsBuyClient.tsx | 7 +- frontend/app/announcement/[id]/page.tsx | 12 +- frontend/app/announcements/page.tsx | 18 +- frontend/app/chat/[id]/ChatRoomClient.tsx | 459 ++++++++++++- frontend/app/chat/layout.tsx | 2 +- frontend/app/globals.css | 102 ++- frontend/app/layout.tsx | 12 + frontend/app/library/[slug]/page.tsx | 122 +++- frontend/app/library/[slug]/read/page.tsx | 30 +- frontend/app/library/page.tsx | 157 +---- frontend/app/links/LinksBoard.tsx | 9 +- frontend/app/links/LinksClient.tsx | 5 +- frontend/app/links/page.tsx | 4 +- .../app/notifications/NotificationsClient.tsx | 323 +++++---- frontend/app/notifications/page.tsx | 24 +- frontend/app/p/[slug]/page.tsx | 4 +- frontend/app/page.tsx | 6 +- frontend/app/post/[id]/page.tsx | 59 +- frontend/app/u/[id]/UserProfileContent.tsx | 19 +- frontend/app/u/[id]/page.tsx | 12 +- frontend/components/AcceptedAnswerCard.tsx | 6 +- frontend/components/AdaptiveCoverSlot.tsx | 73 +- frontend/components/Avatar.tsx | 9 +- frontend/components/BookSearch.tsx | 327 +++++++++ frontend/components/BrandLockup.tsx | 20 +- frontend/components/ChatUserCard.tsx | 18 + frontend/components/CommentSection.tsx | 126 ++-- frontend/components/Header.tsx | 4 +- frontend/components/ImageInsertModal.tsx | 5 +- frontend/components/LibrarySearchGrid.tsx | 241 +++++++ frontend/components/LightboxImage.tsx | 16 +- frontend/components/MarkdownEditor.tsx | 13 +- frontend/components/NotificationBell.tsx | 8 +- frontend/components/PinnedPosts.tsx | 13 +- frontend/components/PostInteractPanels.tsx | 14 +- frontend/components/PostRow.tsx | 22 +- frontend/components/RoleAccountModal.tsx | 179 ++++- frontend/components/SiteChrome.tsx | 6 +- frontend/components/StaffUserMenu.tsx | 9 + frontend/components/TipHost.tsx | 67 +- frontend/components/admin/AdminShell.tsx | 10 +- frontend/components/home/CommunityPanels.tsx | 18 +- frontend/components/markdown/MdImage.tsx | 49 +- .../components/sidebar/CustomWidgetBlock.tsx | 60 +- frontend/lib/api.ts | 151 ++++- frontend/lib/bookSearch.test.ts | 92 +++ frontend/lib/bookSearch.ts | 147 ++++ frontend/lib/jsonLd.test.ts | 16 +- frontend/lib/jsonLd.ts | 14 +- frontend/lib/realtime.ts | 13 + frontend/lib/responsiveImage.ts | 52 ++ frontend/lib/roles.ts | 45 ++ frontend/lib/siteUrl.test.ts | 21 - frontend/lib/siteUrl.ts | 15 - frontend/lib/urlStyle.test.ts | 10 + frontend/lib/urlStyle.ts | 10 + frontend/lib/widgetCode.test.ts | 57 +- frontend/lib/widgetCode.ts | 166 +---- frontend/lib/widgetLint.test.ts | 218 ++++++ frontend/lib/widgetLint.ts | 378 +++++++++++ frontend/lib/widgetRuntime.ts | 179 +++++ frontend/middleware.ts | 3 +- frontend/next-env.d.ts | 4 +- frontend/next.config.ts | 11 + frontend/package.json | 4 +- frontend/scripts/extract-font-css.mjs | 79 +++ 141 files changed, 7527 insertions(+), 1407 deletions(-) create mode 100644 backend/middleware/upload_cache.go create mode 100644 backend/service/image_variants.go create mode 100644 backend/service/image_variants_test.go create mode 100644 backend/service/library_export.go create mode 100644 backend/service/library_export_test.go create mode 100644 backend/service/library_import.go create mode 100644 backend/service/library_import_test.go create mode 100644 frontend/components/BookSearch.tsx create mode 100644 frontend/components/LibrarySearchGrid.tsx create mode 100644 frontend/lib/bookSearch.test.ts create mode 100644 frontend/lib/bookSearch.ts create mode 100644 frontend/lib/responsiveImage.ts create mode 100644 frontend/lib/widgetLint.test.ts create mode 100644 frontend/lib/widgetLint.ts create mode 100644 frontend/lib/widgetRuntime.ts create mode 100644 frontend/scripts/extract-font-css.mjs diff --git a/.gitignore b/.gitignore index cc11750..6d54ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ out/ .vercel/ *.tsbuildinfo next-debug.log* +# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成) +frontend/public/fonts/ # ===== Go 构建产物 ===== *.exe diff --git a/backend/config/config_test.go b/backend/config/config_test.go index 0782210..d609fbd 100644 --- a/backend/config/config_test.go +++ b/backend/config/config_test.go @@ -257,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) { func TestParseSiteURLFromIni(t *testing.T) { work := t.TempDir() - body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n" + body := "[security]\nJWT_SECRET = " + testJWTSecret + "\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n" if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil { t.Fatal(err) } diff --git a/backend/handler/admin_legacy_import.go b/backend/handler/admin_legacy_import.go index 12712de..707e8b3 100644 --- a/backend/handler/admin_legacy_import.go +++ b/backend/handler/admin_legacy_import.go @@ -119,7 +119,7 @@ func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bo return "", true // 字段不存在,视为未上传 } if fh.Size > service.LegacyZipMaxBytes { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label+"不能超过 128MB"}) + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label + "不能超过 128MB"}) return "", false } path, err := saveMultipartToTemp(fh, pattern) diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go index 6fa82a6..6cfd157 100644 --- a/backend/handler/admin_user.go +++ b/backend/handler/admin_user.go @@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"user": user}) } +// AdminGetUserPerms 读取账号级渠道权限(超管/站长,授权弹窗回显用) +func (h *Handlers) AdminGetUserPerms(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + perms, err := h.AdminUser.GetPermOverrides(middleware.CurrentActor(c), id) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"perm_overrides": perms}) +} + +// AdminSetUserPerms 授予/撤销账号级渠道权限(超管/站长) +func (h *Handlers) AdminSetUserPerms(c *gin.Context) { + id, ok := parseAdminUserID(c) + if !ok { + return + } + var body struct { + Perms []string `json:"perms"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + user, err := h.AdminUser.SetPermOverrides(middleware.CurrentActor(c), id, body.Perms) + if err != nil { + respondAdminUserError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": user}) +} + // AdminSetUserBan 封禁 / 解封用户 func (h *Handlers) AdminSetUserBan(c *gin.Context) { id, ok := parseAdminUserID(c) diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 83b935f..bb501b9 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -382,8 +382,11 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin "role": user.Role, "board_ids": boardIDs, "can_manage_messages": user.CanManageMessages, - "level": user.Level, - "total_points": user.TotalPoints, + // 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮, + // 最终权限以后端 RequirePerm 校验为准 + "perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages), + "level": user.Level, + "total_points": user.TotalPoints, } if badges != nil { body["badges"] = badges diff --git a/backend/handler/board.go b/backend/handler/board.go index b382e17..fce1750 100644 --- a/backend/handler/board.go +++ b/backend/handler/board.go @@ -51,11 +51,11 @@ func (h *Handlers) BoardSidebar(c *gin.Context) { } } c.JSON(http.StatusOK, gin.H{ - "board": data.Board, - "moderators": data.Moderators, - "stats": data.Stats, - "hot": data.Hot, - "active_users": data.ActiveUsers, - "checkin": data.Checkin, + "board": data.Board, + "moderators": data.Moderators, + "stats": data.Stats, + "hot": data.Hot, + "active_users": data.ActiveUsers, + "checkin": data.Checkin, }) } diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 3db53d2..12b449e 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/model" @@ -42,6 +43,26 @@ func chatRoomID(c *gin.Context) (uint, bool) { return uint(id), true } +// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用 +func formatMuteDurationCN(minutes int) string { + switch { + case minutes >= 30*24*60: + return "30 天" + case minutes >= 7*24*60: + return "7 天" + case minutes >= 3*24*60: + return "3 天" + case minutes >= 24*60: + return "1 天" + case minutes >= 12*60: + return "12 小时" + case minutes >= 60: + return "1 小时" + default: + return strconv.Itoa(minutes) + " 分钟" + } +} + func (h *Handlers) chatOversee(userID uint) bool { actor, err := h.Auth.LoadActor(userID) if err != nil || actor == nil { @@ -322,7 +343,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } -// SetChatMemberMute 禁言/解禁成员 +// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数 func (h *Handlers) SetChatMemberMute(c *gin.Context) { claims := middleware.CurrentUser(c) roomID, ok := chatRoomID(c) @@ -335,17 +356,59 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) { return } var body struct { - Muted bool `json:"muted"` + Muted bool `json:"muted"` + DurationMinutes int `json:"duration_minutes"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } - if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil { + duration := time.Duration(body.DurationMinutes) * time.Minute + targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID)) + if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted}) + // 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表 + var mutedUntil *time.Time + if body.Muted && body.DurationMinutes > 0 { + t := time.Now().Add(duration) + mutedUntil = &t + } + // 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等) + var sysContent string + if body.Muted { + if body.DurationMinutes > 0 { + sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes) + } else { + sysContent = targetName + " 已被禁言 永久" + } + } else { + sysContent = targetName + " 的禁言已被解除" + } + sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent) + // 广播系统消息到房间(sender_id=0,前端按系统消息渲染) + if sysMsg != nil && sysMsg.ID > 0 { + h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ + Type: realtime.EventChatMessage, + Data: gin.H{ + "message": sysMsg, + }, + }) + } + h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ + Type: realtime.EventChatMemberMuted, + Data: gin.H{ + "room_id": roomID, + "user_id": uint(targetID), + "muted": body.Muted, + "muted_until": mutedUntil, + "duration_minutes": body.DurationMinutes, + "target_name": targetName, + "operator_id": claims.ID, + }, + }) + c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes}) } // SetChatMemberRole 站长任命/撤销群管理员 diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 22876a3..8bdfcf2 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -82,38 +82,6 @@ func (h *Handlers) PostComments(c *gin.Context) { }) } -// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致), -// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。 -func (h *Handlers) CommentLocation(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"}) - return - } - cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) - return - } - // 待审/被拒帖子的评论不对公众开放 - var viewerID uint - var loadActor func() *service.Actor - if claims := middleware.CurrentUser(c); claims != nil { - viewerID = claims.ID - loadActor = h.actorLoader(claims.ID) - } - if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - floor := h.Comment.FloorNumber(uint(id), uint(cid)) - if floor <= 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"}) - return - } - c.JSON(http.StatusOK, gin.H{"floor": floor}) -} - // CreateCommentRequest 评论请求 type CreateCommentRequest struct { Content string `json:"content" binding:"required,min=1,max=5000"` diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 9f65280..6217744 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -10,36 +10,36 @@ import ( // Handlers 聚合所有服务引用 type Handlers struct { - Ops *service.Operations - Cfg *config.Config - Hub *realtime.Hub - Auth *service.AuthService - Board *service.BoardService - Post *service.PostService - Comment *service.CommentService - Like *service.LikeService - Favorite *service.FavoriteService - Follow *service.FollowService - Notification *service.NotificationService - OverviewSvc *service.OverviewService - Checkin *service.CheckinService - Announcement *service.AnnouncementService - SitePage *service.SitePageService - Upload *service.UploadService - PostFile *service.PostFileService - Points *service.PointsService - Setting *service.SettingService - AdminUser *service.AdminUserService - LegacyImport *service.LegacyImportService - Moderation *service.ModerationService - Chat *service.ChatService - Visit *service.VisitStatsService - Analytics *service.AnalyticsService - HidePwd *service.HidePasswordCookie - Ads *service.AdService - Sidebar *service.SidebarService - FriendLink *service.FriendLinkService - Badge *service.BadgeService + Ops *service.Operations + Cfg *config.Config + Hub *realtime.Hub + Auth *service.AuthService + Board *service.BoardService + Post *service.PostService + Comment *service.CommentService + Like *service.LikeService + Favorite *service.FavoriteService + Follow *service.FollowService + Notification *service.NotificationService + OverviewSvc *service.OverviewService + Checkin *service.CheckinService + Announcement *service.AnnouncementService + SitePage *service.SitePageService + Upload *service.UploadService + PostFile *service.PostFileService + Points *service.PointsService + Setting *service.SettingService + AdminUser *service.AdminUserService + LegacyImport *service.LegacyImportService + Moderation *service.ModerationService + Chat *service.ChatService + Visit *service.VisitStatsService + Analytics *service.AnalyticsService + HidePwd *service.HidePasswordCookie + Ads *service.AdService + Sidebar *service.SidebarService + FriendLink *service.FriendLinkService + Badge *service.BadgeService LeaderboardSvc *service.LeaderboardService LibrarySvc *service.LibraryService } diff --git a/backend/handler/library.go b/backend/handler/library.go index 97c086c..e481222 100644 --- a/backend/handler/library.go +++ b/backend/handler/library.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "net/url" + "os" "strconv" "github.com/freefire/jiang13-bbs/middleware" @@ -91,7 +92,11 @@ func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) return } - d, err := h.LibrarySvc.Create(&in) + var creatorID uint + if claims := middleware.CurrentUser(c); claims != nil { + creatorID = claims.ID + } + d, err := h.LibrarySvc.Create(&in, creatorID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -242,6 +247,109 @@ func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// ---------- 管理端:导出(迁移 / 备份) ---------- + +// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP; +// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。 +func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) { + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename)) + c.Header("X-Content-Type-Options", "nosniff") + c.Status(http.StatusOK) + if err := exp.WriteZip(c.Writer); err != nil { + _ = c.Error(err) + } +} + +// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP) +func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + exp, err := h.LibrarySvc.BuildBookExport(uint(id)) + if err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"}) + return + } + h.writeLibraryExport(c, exp) +} + +// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引) +func (h *Handlers) AdminExportAllLibrary(c *gin.Context) { + exp, err := h.LibrarySvc.BuildAllExport() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"}) + return + } + h.writeLibraryExport(c, exp) +} + +// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名) +func (h *Handlers) AdminImportLibrary(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + + const overhead = 64 << 10 // multipart 边界开销 + limit := int64(service.LibraryImportMaxBytes) + overhead + if c.Request.ContentLength > limit { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + + fh, err := c.FormFile("file") + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"}) + return + } + if fh.Size > service.LibraryImportMaxBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"}) + return + } + + mode := c.PostForm("mode") + if mode == "" { + mode = service.LibraryImportModeCreate + } + + tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"}) + return + } + defer os.Remove(tmpPath) + + rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID) + if err != nil { + switch { + case errors.Is(err, service.ErrLibraryImportBadZip), + errors.Is(err, service.ErrLibraryImportFormat), + errors.Is(err, service.ErrLibraryImportVer), + errors.Is(err, service.ErrLibraryImportEmpty), + errors.Is(err, service.ErrLibraryImportMode): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()}) + } + return + } + c.JSON(http.StatusOK, gin.H{"report": rep}) +} + // ---------- 管理端:章节 ---------- // AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节) diff --git a/backend/handler/notification.go b/backend/handler/notification.go index 6085b16..b47358a 100644 --- a/backend/handler/notification.go +++ b/backend/handler/notification.go @@ -5,9 +5,18 @@ import ( "strconv" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/gin-gonic/gin" ) +// notificationView 通知列表项:附带关联评论的楼层相对锚点, +// 供前端拼 #comment-{floor}(主楼)或 #comment-{floor}-r{comment_id}(楼中楼)。 +type notificationView struct { + *model.Notification + CommentFloor uint `json:"comment_floor"` + CommentIsRoot bool `json:"comment_is_root"` +} + // Notifications 获取当前用户的通知列表(分页) func (h *Handlers) Notifications(c *gin.Context) { claims := middleware.CurrentUser(c) @@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + commentIDs := make([]uint, 0, len(list)) + for _, n := range list { + if n.CommentID > 0 { + commentIDs = append(commentIDs, n.CommentID) + } + } + anchors := h.Comment.AnchorsByCommentIDs(commentIDs) + views := make([]notificationView, 0, len(list)) + for i := range list { + v := notificationView{Notification: &list[i]} + if a, ok := anchors[list[i].CommentID]; ok { + v.CommentFloor = a.Floor + v.CommentIsRoot = a.IsRoot + } + views = append(views, v) + } c.JSON(http.StatusOK, gin.H{ - "notifications": nonNilSlice(list), + "notifications": nonNilSlice(views), "total": total, "page": page, "size": size, diff --git a/backend/handler/operations.go b/backend/handler/operations.go index 6582943..42a01f3 100644 --- a/backend/handler/operations.go +++ b/backend/handler/operations.go @@ -313,7 +313,8 @@ func (h *Handlers) PublicObject(c *gin.Context) { defer r.Close() c.Header("Content-Type", m) c.Header("X-Content-Type-Options", "nosniff") - c.Header("Cache-Control", "private, no-store") + // 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用) + c.Header("Cache-Control", "public, max-age=86400") c.Status(200) _, _ = io.Copy(c.Writer, r) } diff --git a/backend/handler/overview.go b/backend/handler/overview.go index b4b8c56..784ec6e 100644 --- a/backend/handler/overview.go +++ b/backend/handler/overview.go @@ -29,20 +29,20 @@ func (h *Handlers) Overview(c *gin.Context) { } } c.JSON(http.StatusOK, gin.H{ - "stats": data.Stats, - "hot": data.Hot, - "active_users": data.ActiveUsers, - "boards": data.Boards, - "announcements": data.Announcements, - "announcements_total": data.AnnouncementsTotal, - "sidebar_pages": data.SidebarPages, - "new_users": data.NewUsers, - "checkin": data.Checkin, - "ads": data.Ads, - "ads_panel_title": data.AdsPanelTitle, - "ads_enabled": data.AdsEnabled, - "sponsors": data.Sponsors, + "stats": data.Stats, + "hot": data.Hot, + "active_users": data.ActiveUsers, + "boards": data.Boards, + "announcements": data.Announcements, + "announcements_total": data.AnnouncementsTotal, + "sidebar_pages": data.SidebarPages, + "new_users": data.NewUsers, + "checkin": data.Checkin, + "ads": data.Ads, + "ads_panel_title": data.AdsPanelTitle, + "ads_enabled": data.AdsEnabled, + "sponsors": data.Sponsors, "sponsors_panel_title": data.SponsorsPanelTitle, - "sponsors_enabled": data.SponsorsEnabled, + "sponsors_enabled": data.SponsorsEnabled, }) } diff --git a/backend/handler/upload.go b/backend/handler/upload.go index 7af5ded..b91b54f 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -10,6 +10,7 @@ import ( "time" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -102,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) { } defer f.Close() - att, err := h.Upload.SaveImage(claims.ID, f) + att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source")) if err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { @@ -314,7 +315,9 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": url}) } -// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目 +// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。 +// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制; +// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。 func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) { var req struct { AttachmentID uint `json:"attachment_id"` @@ -328,7 +331,14 @@ func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) return } - att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID) + + // 已是封面类型的图直接复用,避免重复复制产生冗余记录 + if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil { + c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing}) + return + } + + att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -404,6 +414,23 @@ func (h *Handlers) MediaLibraryThumb(c *gin.Context) { c.Data(http.StatusOK, "image/webp", data) } +// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>): +// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底 +func (h *Handlers) ImageVariant(c *gin.Context) { + w, err := strconv.Atoi(c.Query("w")) + if err != nil || !service.IsVariantWidth(w) { + c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"}) + return + } + data, m, err := h.Upload.Variant(c.Query("u"), w) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"}) + return + } + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Data(http.StatusOK, m, data) +} + func brandTooLarge(slot string) string { if slot == service.BrandSlotFavicon { return "Favicon 不能超过 512KB" diff --git a/backend/handler/user.go b/backend/handler/user.go index 68774f0..884a6fa 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -144,11 +144,11 @@ func (h *Handlers) UserProfile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "user": userPayload, "stats": gin.H{ - "post_count": postCount, - "comment_count": commentCount, - "points": pointsTotal, - "streak": streak, - "favorite_count": favoriteCount, + "post_count": postCount, + "comment_count": commentCount, + "points": pointsTotal, + "streak": streak, + "favorite_count": favoriteCount, "following_count": followingCount, }, "posts": nonNilSlice(posts), diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 159963a..d351102 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -85,7 +85,32 @@ func (m *AuthMiddleware) RequireStaff() gin.HandlerFunc { } } -// RequirePerm 功能点鉴权,必须接在 RequireStaff 之后 +// RequireActor 必须登录并加载实时 Actor(不要求管理角色), +// 供账号级渠道权限(非管理角色也可被单独授予)所在路由组使用; +// 功能点鉴权由后续 RequirePerm 完成 +func (m *AuthMiddleware) RequireActor() gin.HandlerFunc { + return func(c *gin.Context) { + claims, ok := m.parseToken(c) + if !ok { + if c.GetBool(AccountBannedKey) { + bannedJSON(c) + return + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + actor, err := m.auth.LoadActor(claims.ID) + if err != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权限执行该操作"}) + return + } + c.Set("user", claims) + c.Set(ActorKey, actor) + c.Next() + } +} + +// RequirePerm 功能点鉴权,必须接在 RequireStaff / RequireActor 之后 func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc { return func(c *gin.Context) { actor := CurrentActor(c) diff --git a/backend/middleware/upload_cache.go b/backend/middleware/upload_cache.go new file mode 100644 index 0000000..bc194c8 --- /dev/null +++ b/backend/middleware/upload_cache.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +// UploadStaticCache 上传文件长缓存:/uploads 下文件名均为上传时随机生成、 +// 写入后不覆盖(SaveAvatar/SaveImage 等均新建文件),URL 内容恒定,可安全 +// immutable。PageSpeed「缓存生命周期」修复项。 +func UploadStaticCache() gin.HandlerFunc { + return func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/uploads/") { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + } + c.Next() + } +} diff --git a/backend/model/ad.go b/backend/model/ad.go index 745e11e..3d714fb 100644 --- a/backend/model/ad.go +++ b/backend/model/ad.go @@ -19,26 +19,26 @@ const ( // Ad 赞助广告(游客可申购,管理员审核后展示于首页侧栏) type Ad struct { - ID uint `gorm:"primaryKey" json:"id"` - Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text - Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired - ContactEmail string `gorm:"size:128;not null" json:"contact_email"` - LinkURL string `gorm:"size:512;not null" json:"link_url"` - ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告 - Title string `gorm:"size:64" json:"title"` // 文字广告 - TextColor string `gorm:"size:16" json:"text_color"` - BgColor string `gorm:"size:16" json:"bg_color"` - DurationDays int `gorm:"not null" json:"duration_days"` - PaymentID string `gorm:"size:64;not null" json:"payment_id"` - BuyerNote string `gorm:"size:200" json:"buyer_note"` - StartsAt *time.Time `json:"starts_at"` - EndsAt *time.Time `json:"ends_at"` - RejectReason string `gorm:"size:200" json:"reject_reason"` - SortOrder int `gorm:"not null;default:0" json:"sort_order"` - SubmitIP string `gorm:"size:45" json:"-"` - ReviewedAt *time.Time `json:"reviewed_at"` - ReviewedBy *uint `json:"reviewed_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text + Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired + ContactEmail string `gorm:"size:128;not null" json:"contact_email"` + LinkURL string `gorm:"size:512;not null" json:"link_url"` + ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告 + Title string `gorm:"size:64" json:"title"` // 文字广告 + TextColor string `gorm:"size:16" json:"text_color"` + BgColor string `gorm:"size:16" json:"bg_color"` + DurationDays int `gorm:"not null" json:"duration_days"` + PaymentID string `gorm:"size:64;not null" json:"payment_id"` + BuyerNote string `gorm:"size:200" json:"buyer_note"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + RejectReason string `gorm:"size:200" json:"reject_reason"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + SubmitIP string `gorm:"size:45" json:"-"` + ReviewedAt *time.Time `json:"reviewed_at"` + ReviewedBy *uint `json:"reviewed_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } diff --git a/backend/model/db.go b/backend/model/db.go index af29fbc..8852b65 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -78,6 +78,9 @@ func InitDB(dsn string) error { // 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund) backfillUserTotalPoints(db) + // 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长 + backfillLibraryCreator(db) + // login_logs.success 早期 default=true 与 GORM 零值省略叠加, // 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正 if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil { @@ -146,6 +149,7 @@ func dropStaleChatFKConstraints(db *gorm.DB) error { pairs := []struct{ table, constraint string }{ {"chat_rooms", "fk_chat_rooms_last_message"}, {"notifications", "fk_notifications_room"}, + {"chat_messages", "fk_chat_messages_sender"}, } for _, p := range pairs { db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint)) @@ -368,6 +372,29 @@ func backfillUserTotalPoints(db *gorm.DB) { } } +// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入) +// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行; +// 站长不存在(空库)时跳过,等下次启动自愈。 +func backfillLibraryCreator(db *gorm.DB) { + var owner User + err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return + } + if err != nil { + log.Printf("[model] 回填书库创建者前置查询失败: %v", err) + return + } + res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID) + if res.Error != nil { + log.Printf("[model] 回填书库创建者失败: %v", res.Error) + return + } + if res.RowsAffected > 0 { + log.Printf("[model] 已回填书库条目创建者为站长 %d 行", res.RowsAffected) + } +} + // backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。 // 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填 // post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。 diff --git a/backend/model/models.go b/backend/model/models.go index a761ca9..cee0098 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -122,6 +122,7 @@ type User struct { Role Role `gorm:"size:16;default:user" json:"role"` Banned bool `gorm:"default:false" json:"banned"` CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管) + PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过 Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额 TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减) Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库) @@ -323,9 +324,9 @@ const ( PointReasonBountyEscrow = "bounty_escrow" PointReasonBountyRefund = "bounty_refund" PointReasonBountyAward = "bounty_award" - PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭) - PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭) - PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成 + PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭) + PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭) + PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成 PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次) PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额) PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭) @@ -486,7 +487,7 @@ type Notification struct { IsRead bool `gorm:"default:false;index" json:"is_read"` CreatedAt time.Time `json:"created_at"` - Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"` + Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"` // PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键 Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"` Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"` @@ -524,19 +525,25 @@ type SitePage struct { // LibraryDoc 书库条目(书籍/文档/纯 HTML 页展示;文件独立目录存储,仅经 API 消费) type LibraryDoc struct { - ID uint `gorm:"primaryKey" json:"id"` - Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"` - Title string `gorm:"size:200;not null" json:"title"` - Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空 - CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images) - Published bool `gorm:"not null;index" json:"published"` - SortOrder int `gorm:"not null;default:0" json:"sort_order"` + ID uint `gorm:"primaryKey" json:"id"` + Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"` + Title string `gorm:"size:200;not null" json:"title"` + Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空 + CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images) + CoverWidth int `gorm:"not null;default:0" json:"cover_width"` // 封面自然宽(0=未知,前端回退探测) + CoverHeight int `gorm:"not null;default:0" json:"cover_height"` // 封面自然高(0=未知,前端回退探测) + Published bool `gorm:"not null;index" json:"published"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` // EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳, // 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外) EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + // Author 书籍作者(自由文本,可填多位,如「鲁迅 译 / 某某注」),仅展示用 + Author string `gorm:"size:100;not null;default:''" json:"author"` + // CreatorID 条目创建者(站点用户;0=历史数据或导入时无对应用户),创建后不变 + CreatorID uint `gorm:"index;not null;default:0" json:"creator_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // LibraryFile 书库文件(html/pdf/epub 等;存 data/library,不进公开静态目录) @@ -590,20 +597,20 @@ type SidebarWidget struct { // FriendLink 友情链接;申请审批制,status=pending 经管理员 approve 后前台可见 type FriendLink struct { - ID uint `gorm:"primarykey" json:"id"` - Name string `gorm:"size:64;not null" json:"name"` - URL string `gorm:"size:512;not null" json:"url"` - Description string `gorm:"size:255" json:"description"` - LogoURL string `gorm:"size:512" json:"logo_url"` - Category string `gorm:"size:32;index" json:"category"` - ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对 - SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` - Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected - ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil - RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + URL string `gorm:"size:512;not null" json:"url"` + Description string `gorm:"size:255" json:"description"` + LogoURL string `gorm:"size:512" json:"logo_url"` + Category string `gorm:"size:32;index" json:"category"` + ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对 + SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` + Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected + ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil + RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // SiteDailyStats 站点日维度访问量(轻量 PV/UV) @@ -652,11 +659,19 @@ const ( AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理) ) +// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用 +const ( + AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认) + AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用) + AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图 +) + // Attachment 用户上传的附件(当前仅头像,全部为 WebP) type Attachment struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"` Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"` + Source string `gorm:"size:24;not null;default:'post'" json:"source"` URL string `gorm:"size:512;not null" json:"url"` MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"` Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准) @@ -736,6 +751,7 @@ type ChatRoomMember struct { Role string `gorm:"size:16;not null;default:member" json:"role"` LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"` Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言 + MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久 PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -747,17 +763,18 @@ type ChatRoomMember struct { type ChatMessage struct { ID uint `gorm:"primaryKey" json:"id"` RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"` - SenderID uint `gorm:"index;not null" json:"sender_id"` + SenderID uint `gorm:"index" json:"sender_id"` // 0 表示系统消息(is_system=true) Content string `gorm:"type:varchar(2000);not null" json:"content"` ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用 ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示 MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用) RecalledAt *time.Time `json:"recalled_at,omitempty"` RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"` + IsSystem bool `gorm:"not null;default:false" json:"is_system"` // 系统提示(如禁言通知),sender_id=0 CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"` + Sender User `gorm:"foreignKey:SenderID;constraint:-" json:"sender,omitempty"` // constraint:- 禁用 FK,支持 sender_id=0 的系统消息 // RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称 Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"` } diff --git a/backend/realtime/hub.go b/backend/realtime/hub.go index f183124..7e2d2c4 100644 --- a/backend/realtime/hub.go +++ b/backend/realtime/hub.go @@ -18,18 +18,19 @@ import ( // 事件类型:前后端共享的单层 JSON 协议契约,禁止手拼 JSON / 双重编码 const ( - EventHello = "hello" // 建连欢迎帧(含在线用户快照) - EventPong = "pong" // 应用层心跳应答 - EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播 - EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间 - EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID} - EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id} - EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id} - EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播 - EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID} - EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标) - EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标) - EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录) + EventHello = "hello" // 建连欢迎帧(含在线用户快照) + EventPong = "pong" // 应用层心跳应答 + EventSettingsChanged = "settings:changed" // 站点设置(主题色)变更,全员广播 + EventPresenceUpdate = "presence:update" // 用户上/下线,仅 staff 房间 + EventChatMessage = "chat:message" // 群聊新消息,推 chat:{roomID} + EventChatMembership = "chat:membership" // 群成员关系变更(被踢/群解散/被邀请),推 user:{id} + EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id} + EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播 + EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID} + EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID} + EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标) + EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标) + EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录) ) // RoomStaff 管理团队房间(板块管理员及以上) diff --git a/backend/router/router.go b/backend/router/router.go index c60626d..5e9219f 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -101,7 +101,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { postFileSvc.WithOperations(ops) pointsSvc := service.NewPointsService(model.DB) leaderboardSvc := service.NewLeaderboardService(model.DB) - librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).WithSetting(settingSvc) + librarySvc := service.NewLibraryService(model.DB, cfg.DataDir). + WithSetting(settingSvc). + WithUploadsDir(filepath.Join(cfg.DataDir, "uploads")) adminUserSvc := service.NewAdminUserService(model.DB) legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) @@ -183,6 +185,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 挂在 OptionalAuth 之后可顺带拿到登录用户 r.Use(middleware.VisitCapture(visitSvc)) + // 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable) + r.Use(middleware.UploadStaticCache()) + // 上传文件静态服务(data/uploads → /uploads) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) @@ -209,7 +214,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword) pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment) pubAPI.GET("/posts/:id/comments", h.PostComments) - pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation) pubAPI.GET("/users/:id", h.UserProfile) pubAPI.GET("/users/:id/comments", h.UserComments) pubAPI.GET("/users/:id/favorites", h.UserFavorites) @@ -226,6 +230,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/library/:slug", h.LibraryDetail) pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload) pubAPI.GET("/site-state", h.SiteState) + // 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存) + pubAPI.GET("/img", h.ImageVariant) pubAPI.GET("/media/:object", h.PublicObject) pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode) pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword) @@ -376,23 +382,34 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问 // (见下方 msgAPI) - // 站点公告文章管理(管理员及以上) - announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) + // 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入, + // 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上, + // 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权) + permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware()) + + // 站点公告文章管理(PermAnnouncements) + announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) announceAPI.GET("/announcements", h.AdminListAnnouncements) announceAPI.POST("/announcements", h.AdminCreateAnnouncement) announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement) announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin) announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement) - announceAPI.GET("/pages", h.AdminListSitePages) - announceAPI.POST("/pages", h.AdminCreateSitePage) - announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage) - announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage) - announceAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage) - // 书库管理(管理员及以上,同公告/单页权限) - libraryAPI := staffAPI.Group("/library", authMW.RequirePerm(service.PermAnnouncements)) + // 站点单页管理(PermPages) + pagesAPI := permAPI.Group("", authMW.RequirePerm(service.PermPages)) + pagesAPI.GET("/pages", h.AdminListSitePages) + pagesAPI.POST("/pages", h.AdminCreateSitePage) + pagesAPI.PUT("/pages/:id", h.AdminUpdateSitePage) + pagesAPI.DELETE("/pages/:id", h.AdminDeleteSitePage) + pagesAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage) + + // 书库管理(PermLibrary) + libraryAPI := permAPI.Group("/library", authMW.RequirePerm(service.PermLibrary)) { libraryAPI.GET("/docs", h.AdminListLibraryDocs) + libraryAPI.GET("/export", h.AdminExportAllLibrary) + libraryAPI.POST("/import", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibrary) + libraryAPI.GET("/docs/:id/export", h.AdminExportLibraryDoc) libraryAPI.POST("/docs", h.AdminCreateLibraryDoc) libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc) libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc) @@ -409,8 +426,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia) } - // 广告位管理(管理员及以上) - adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) + // 广告位管理(PermAds) + adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds)) adsAPI.GET("/ads/config", h.AdminGetAdsConfig) adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig) adsAPI.GET("/ads", h.AdminListAds) @@ -477,6 +494,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { usersAPI.GET("/users", h.AdminListUsers) usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole) usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages) + usersAPI.GET("/users/:id/perms", h.AdminGetUserPerms) + usersAPI.PUT("/users/:id/perms", h.AdminSetUserPerms) usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan) usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs) // 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页 diff --git a/backend/service/actor.go b/backend/service/actor.go index 452ce2b..06a6270 100644 --- a/backend/service/actor.go +++ b/backend/service/actor.go @@ -1,6 +1,8 @@ package service import ( + "encoding/json" + "sort" "strings" "github.com/freefire/jiang13-bbs/model" @@ -10,21 +12,119 @@ import ( // 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。 const ( PermUsers = "users" // 用户与权限管理(超管/站长) - PermAnnouncements = "announcements" // 公告管理(管理员及以上) + PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予) + PermPages = "pages" // 单页管理(管理员及以上;可账号级授予) + PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予) + PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予) PermSettings = "settings" // 站点外观设置(超管/站长) PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限) PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行) PermBoards = "boards" // 板块管理(仅站长) ) +// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。 +// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。 +var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds} + +// ValidPermOverride 权限码是否可被账号级授予 +func ValidPermOverride(p string) bool { + for _, g := range GrantablePerms { + if p == g { + return true + } + } + return false +} + +// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集 +func NormalizePermOverrides(perms []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(perms)) + for _, p := range perms { + p = strings.TrimSpace(p) + if p == "" || seen[p] || !ValidPermOverride(p) { + continue + } + seen[p] = true + out = append(out, p) + } + sort.Strings(out) + return out +} + +// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空) +func ParsePermOverrides(raw string) []string { + if raw == "" { + return []string{} + } + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + return []string{} + } + return NormalizePermOverrides(arr) +} + +// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧) +func SerializePermOverrides(perms []string) string { + norm := NormalizePermOverrides(perms) + b, err := json.Marshal(norm) + if err != nil { + return "[]" + } + return string(b) +} + +// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予) +func roleDefaultPerm(role model.Role, p string) bool { + switch p { + case PermBoards: + return role == model.RoleOwner + case PermUsers, PermSettings: + return role == model.RoleSuperAdmin || role == model.RoleOwner + case PermAnnouncements, PermPages, PermLibrary, PermAds: + return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) + case PermModeration: + return model.IsStaff(role) + } + return false +} + +// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理), +// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。 +func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string { + set := map[string]bool{} + all := append([]string{ + PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds, + PermSettings, PermModeration, PermBoards, + }, GrantablePerms...) + for _, p := range all { + if roleDefaultPerm(role, p) { + set[p] = true + } + } + for _, p := range ParsePermOverrides(permOverridesRaw) { + set[p] = true + } + if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages { + set[PermMessages] = true + } + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out +} + // Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取, // 不依赖 JWT 内的 role claim,角色/授权变更立即生效) type Actor struct { ID uint Username string Role model.Role - BoardIDs []uint // 板块管理员被授权的板块;其他角色为空 - CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段 + BoardIDs []uint // 板块管理员被授权的板块;其他角色为空 + CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段 + ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量) } // IsStaff 是否管理团队成员 @@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool { return a != nil && model.IsStaff(a.Role) } -// HasPerm 是否拥有某后台功能点 +// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予 func (a *Actor) HasPerm(p string) bool { if a == nil { return false } - switch p { - case PermBoards: - return a.Role == model.RoleOwner - case PermUsers, PermSettings: - return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner - case PermAnnouncements: - return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin) - case PermModeration: - return a.IsStaff() - case PermMessages: + if roleDefaultPerm(a.Role, p) { + return true + } + if p == PermMessages { return a.HasSiteMessagePerm() } + for _, e := range a.ExtraPerms { + if e == p { + return true + } + } return false } @@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool { return a.HasPerm(PermUsers) } -// LoadActor 读取用户实时角色与板块授权 +// LoadActor 读取用户实时角色、板块授权与账号级渠道权限 func (s *AuthService) LoadActor(id uint) (*Actor, error) { var u model.User - if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil { + if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil { return nil, err } actor := &Actor{ ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{}, CanManageMessages: u.CanManageMessages, + ExtraPerms: ParsePermOverrides(u.PermOverrides), } if u.Role == model.RoleBoardAdmin { var ids []uint diff --git a/backend/service/ad.go b/backend/service/ad.go index bd657bf..f76af24 100644 --- a/backend/service/ad.go +++ b/backend/service/ad.go @@ -17,25 +17,25 @@ import ( const ( SettingKeyAdsConfig = "ads_config" - AdMaxTitleRunes = 24 - AdMaxNoteRunes = 100 - AdMaxImageShow = 5 - AdMaxTextShow = 6 - AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow - AdDefaultTitle = "自助推广" + AdMaxTitleRunes = 24 + AdMaxNoteRunes = 100 + AdMaxImageShow = 5 + AdMaxTextShow = 6 + AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow + AdDefaultTitle = "自助推广" ) var ( - ErrAdNotFound = errors.New("广告不存在") - ErrAdForbidden = errors.New("无权操作") - ErrAdInvalid = errors.New("广告参数无效") - ErrAdCaptcha = errors.New("验证码错误或已过期") - ErrAdDisabled = errors.New("自助推广暂未开放") - ErrAdBadPayment = errors.New("请选择有效的支付方式") - ErrAdBadDuration = errors.New("请选择有效的投放时长") - hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`) - adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`) - adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`) + ErrAdNotFound = errors.New("广告不存在") + ErrAdForbidden = errors.New("无权操作") + ErrAdInvalid = errors.New("广告参数无效") + ErrAdCaptcha = errors.New("验证码错误或已过期") + ErrAdDisabled = errors.New("自助推广暂未开放") + ErrAdBadPayment = errors.New("请选择有效的支付方式") + ErrAdBadDuration = errors.New("请选择有效的投放时长") + hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`) + adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`) + adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`) ) // AdDurationOption 可购时长档位(图片 / 文字广告分别计价) diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go index 4ba50b1..a32bf8f 100644 --- a/backend/service/admin_content.go +++ b/backend/service/admin_content.go @@ -49,11 +49,11 @@ const ( // AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab) type AdminContentCounts struct { - PostsLive int64 `json:"posts_live"` - PostsPending int64 `json:"posts_pending"` - PostsRejected int64 `json:"posts_rejected"` - PostsDeleted int64 `json:"posts_deleted"` - CommentsLive int64 `json:"comments_live"` + PostsLive int64 `json:"posts_live"` + PostsPending int64 `json:"posts_pending"` + PostsRejected int64 `json:"posts_rejected"` + PostsDeleted int64 `json:"posts_deleted"` + CommentsLive int64 `json:"comments_live"` CommentsPending int64 `json:"comments_pending"` CommentsRejected int64 `json:"comments_rejected"` CommentsDeleted int64 `json:"comments_deleted"` @@ -61,32 +61,34 @@ type AdminContentCounts struct { // AdminContentPost 后台帖子列表项 type AdminContentPost struct { - ID uint `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - CommentCount int `json:"comment_count"` - BoardID uint `json:"board_id"` + ID uint `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Deleted bool `json:"deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + CommentCount int `json:"comment_count"` + BoardID uint `json:"board_id"` Board model.Board `json:"board"` - User model.User `json:"user"` + User model.User `json:"user"` } // AdminContentComment 后台评论列表项 type AdminContentComment struct { - ID uint `json:"id"` - PostID uint `json:"post_id"` - PostTitle string `json:"post_title"` - BoardID uint `json:"board_id"` - Content string `json:"content"` - Status string `json:"status"` - Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口 - Deleted bool `json:"deleted"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint `json:"id"` + PostID uint `json:"post_id"` + PostTitle string `json:"post_title"` + BoardID uint `json:"board_id"` + Content string `json:"content"` + Status string `json:"status"` + Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口 + Deleted bool `json:"deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + Floor uint `json:"floor"` // 所属楼层相对序号 + IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}] Board model.Board `json:"board"` - User model.User `json:"user"` + User model.User `json:"user"` } func normalizeAdminStatus(raw string) AdminContentStatus { @@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri } items := make([]AdminContentComment, 0, len(rows)) + commentIDs := make([]uint, 0, len(rows)) + for _, r := range rows { + commentIDs = append(commentIDs, r.ID) + } + anchors := CommentAnchors(s.db, commentIDs) for _, r := range rows { it := AdminContentComment{ ID: r.ID, @@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri Board: boards[r.BoardID], User: users[r.UserID], } + if a, ok := anchors[r.ID]; ok { + it.Floor = a.Floor + it.IsRoot = a.IsRoot + } if r.DeletedAt.Valid { it.Deleted = true t := r.DeletedAt.Time diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index 415c2c1..b08647b 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -54,6 +54,7 @@ type AdminUserItem struct { BoardIDs []uint `json:"board_ids"` Banned bool `json:"banned"` CanManageMessages bool `json:"can_manage_messages"` + PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予) PostCount int64 `json:"post_count"` CommentCount int64 `json:"comment_count"` Points int `json:"points"` @@ -71,7 +72,7 @@ type AdminUserItem struct { // - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复) type AdminUserSummary struct { Online int64 `json:"online"` - InCooldown int64 `json:"in_cooldown"` + InCooldown int64 `json:"in_cooldown"` Active7d int64 `json:"active_7d"` FailedLogins24h int64 `json:"failed_logins_24h"` Total int64 `json:"total"` @@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem { BoardIDs: []uint{}, Banned: u.Banned, CanManageMessages: u.CanManageMessages, + PermOverrides: ParsePermOverrides(u.PermOverrides), Points: u.Points, TotalPoints: u.TotalPoints, Level: u.Level, @@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R return err } } + // 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导 + if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" { + if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil { + return err + } + } return nil }) if err != nil { @@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R return s.getItem(s.db, targetID) } +// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显) +func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) { + if operator == nil || !operator.HasPerm(PermUsers) { + return nil, ErrCannotAssignRole + } + var u model.User + if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil { + return nil, err + } + return ParsePermOverrides(u.PermOverrides), nil +} + +// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。 +// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带, +// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。 +func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) { + if operator == nil || !operator.HasPerm(PermUsers) { + return nil, ErrCannotAssignRole + } + if operator.ID == targetID { + return nil, ErrAdminSelfAction + } + norm := NormalizePermOverrides(perms) + err := s.db.Transaction(func(tx *gorm.DB) error { + var u model.User + if err := tx.First(&u, targetID).Error; err != nil { + return err + } + if u.Role == model.RoleOwner { + return ErrProtectedOwner + } + // 管理员及以上角色自带全部渠道权限,账号级授予保持为空 + if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) { + norm = []string{} + } + raw := SerializePermOverrides(norm) + if u.PermOverrides == raw { + return nil + } + return tx.Model(&u).Update("perm_overrides", raw).Error + }) + if err != nil { + return nil, err + } + return s.getItem(s.db, targetID) +} + // SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己) func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) { if operator == nil || operator.Role != model.RoleOwner { @@ -564,12 +619,12 @@ func normalizeAuditPage(page, size int) (int, int) { // AdminUserAuditProfile 站长查看用户档案摘要 type AdminUserAuditProfile struct { - User AdminUserItem `json:"user"` - PostsTotal int64 `json:"posts_total"` // 含各状态与软删 - CommentsTotal int64 `json:"comments_total"` // 含各状态与软删 - MessagesTotal int64 `json:"messages_total"` // 含撤回与软删 - PublishedPosts int64 `json:"published_posts"` - PublishedComments int64 `json:"published_comments"` + User AdminUserItem `json:"user"` + PostsTotal int64 `json:"posts_total"` // 含各状态与软删 + CommentsTotal int64 `json:"comments_total"` // 含各状态与软删 + MessagesTotal int64 `json:"messages_total"` // 含撤回与软删 + PublishedPosts int64 `json:"published_posts"` + PublishedComments int64 `json:"published_comments"` } // AdminAuditPostItem 审计帖子行 @@ -594,6 +649,8 @@ type AdminAuditCommentItem struct { Deleted bool `json:"deleted"` CreatedAt time.Time `json:"created_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` + Floor uint `json:"floor"` + IsRoot bool `json:"is_root"` } // AdminAuditMessageItem 审计聊天消息行 @@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil { return nil, 0, page, err } + commentIDs := make([]uint, 0, len(comments)) + for _, c := range comments { + commentIDs = append(commentIDs, c.ID) + } + anchors := CommentAnchors(s.db, commentIDs) items := make([]AdminAuditCommentItem, 0, len(comments)) for _, c := range comments { it := AdminAuditCommentItem{ @@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si Status: c.Status, CreatedAt: c.CreatedAt, } + if a, ok := anchors[c.ID]; ok { + it.Floor = a.Floor + it.IsRoot = a.IsRoot + } if c.DeletedAt.Valid { it.Deleted = true t := c.DeletedAt.Time diff --git a/backend/service/chat.go b/backend/service/chat.go index aca9ea7..32dea2a 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -75,8 +75,8 @@ type RoomView struct { Joined bool `json:"joined"` MyRole string `json:"my_role"` UnreadCount int64 `json:"unread_count"` - Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制) - PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消 + Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制) + PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消 Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct) } @@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR if err != nil { return nil, err } + // 过期禁言自动解除:惰性清理,不依赖定时任务 + if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) { + m.Muted = false + m.MutedUntil = nil + _ = tx.Model(&model.ChatRoomMember{}). + Where("room_id = ? AND user_id = ?", roomID, userID). + Select("muted", "muted_until"). + Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error + } return &m, nil } @@ -758,44 +767,85 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro return s.removeMember(roomID, targetID) } -// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号 -func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error { +// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true), +// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。 +func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) { + msg := &model.ChatMessage{ + RoomID: roomID, + SenderID: 0, + Content: content, + IsSystem: true, + CreatedAt: time.Now(), + } + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(msg).Error; err != nil { + return err + } + return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID). + Updates(map[string]interface{}{ + "last_message_id": msg.ID, + "updated_at": time.Now(), + }).Error + }) + if err != nil { + return nil, err + } + return msg, nil +} + +// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。 +// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。 +// 返回目标用户昵称(供广播系统提示使用)。 +func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) { room, err := s.getRoom(roomID) if err != nil { - return err + return "", err } if room.RoomType == model.ChatRoomTypeDirect { - return ErrChatOwnerOnly + return "", ErrChatOwnerOnly } target, err := s.membership(s.db, roomID, targetID) if err != nil { - return err + return "", err } if target.Role == model.ChatRoleOwner { - return ErrChatCannotMuteOwner + return "", ErrChatCannotMuteOwner } var targetUser model.User - if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil { - return ErrChatUserGone + if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil { + return "", ErrChatUserGone } if targetUser.Role == model.RoleOwner { - return ErrChatCannotMuteOwner + return "", ErrChatCannotMuteOwner } if !oversee { op, err := s.membership(s.db, roomID, operatorID) if err != nil { - return err + return "", err } if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin { - return ErrChatMuteDenied + return "", ErrChatMuteDenied } if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner { - return ErrChatMuteDenied + return "", ErrChatMuteDenied } } - return s.db.Model(&model.ChatRoomMember{}). + var mutedUntil *time.Time + if muted && duration > 0 { + t := time.Now().Add(duration) + mutedUntil = &t + } + if err := s.db.Model(&model.ChatRoomMember{}). Where("room_id = ? AND user_id = ?", roomID, targetID). - Select("muted").Update("muted", muted).Error + Select("muted", "muted_until"). + Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil { + return "", err + } + targetName := targetUser.Nickname + if targetName == "" { + targetName = targetUser.Username + } + return targetName, nil } // SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主 diff --git a/backend/service/comment.go b/backend/service/comment.go index 2ca4b4b..a8ef47d 100644 --- a/backend/service/comment.go +++ b/backend/service/comment.go @@ -44,7 +44,7 @@ func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool { // CommentService 评论服务 type CommentService struct { - db *gorm.DB + db *gorm.DB setting *SettingService } @@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int { return FloorNumber(s.db, postID, commentID) } +// CommentAnchor 评论在所属帖子内的相对锚点: +// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼, +// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。 +type CommentAnchor struct { + Floor uint `json:"floor"` + IsRoot bool `json:"is_root"` +} + +// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1); +// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。 +func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor { + out := make(map[uint]CommentAnchor, len(ids)) + if db == nil || len(ids) == 0 { + return out + } + // Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径 + var targets []model.Comment + if err := db.Unscoped(). + Select("id", "post_id", "parent_id", "root_id", "created_at"). + Where("id IN ?", ids).Find(&targets).Error; err != nil { + return out + } + rootIDSet := make(map[uint]struct{}, len(targets)) + for _, c := range targets { + rid := c.ID + if c.ParentID != nil && c.RootID != nil { + rid = *c.RootID + } + rootIDSet[rid] = struct{}{} + } + rootIDs := make([]uint, 0, len(rootIDSet)) + for rid := range rootIDSet { + rootIDs = append(rootIDs, rid) + } + // 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1 + type aheadRow struct { + RootID uint + Ahead int64 + } + var aheadRows []aheadRow + if err := db.Unscoped().Table("comments AS f"). + Select(`f.id AS root_id, + (SELECT COUNT(*) FROM comments c + WHERE c.post_id = f.post_id AND c.parent_id IS NULL + AND (c.created_at < f.created_at + OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`). + Where("f.id IN ? AND f.parent_id IS NULL", rootIDs). + Scan(&aheadRows).Error; err != nil { + return out + } + floorByRoot := make(map[uint]uint, len(aheadRows)) + for _, r := range aheadRows { + floorByRoot[r.RootID] = uint(r.Ahead) + 1 + } + for _, c := range targets { + isRoot := c.ParentID == nil + rid := c.ID + if !isRoot && c.RootID != nil { + rid = *c.RootID + } + floor := floorByRoot[rid] + if floor == 0 { + continue + } + out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot} + } + return out +} + +// AnchorsByCommentIDs 见包级 CommentAnchors +func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor { + return CommentAnchors(s.db, ids) +} + // applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审); // 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。 func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB { @@ -442,6 +516,8 @@ type UserCommentItem struct { PostID uint `json:"post_id"` PostTitle string `json:"post_title"` Content string `json:"content"` + Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层) + IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}] CreatedAt time.Time `json:"created_at"` } @@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI if items == nil { items = []UserCommentItem{} } + ids := make([]uint, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ID) + } + anchors := s.AnchorsByCommentIDs(ids) + for i := range items { + if a, ok := anchors[items[i].ID]; ok { + items[i].Floor = a.Floor + items[i].IsRoot = a.IsRoot + } + } return items, total, nil } diff --git a/backend/service/follow.go b/backend/service/follow.go index 4362342..76190f3 100644 --- a/backend/service/follow.go +++ b/backend/service/follow.go @@ -9,7 +9,7 @@ import ( // FollowService 关注服务(仅用户;关注时给对方发通知) type FollowService struct { - db *gorm.DB + db *gorm.DB notif *NotificationService } diff --git a/backend/service/friendlink.go b/backend/service/friendlink.go index b8d8945..6e1df49 100644 --- a/backend/service/friendlink.go +++ b/backend/service/friendlink.go @@ -268,8 +268,8 @@ func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) return err } updates := map[string]interface{}{ - "status": status, - "updated_at": time.Now(), + "status": status, + "updated_at": time.Now(), "reject_reason": "", } switch status { diff --git a/backend/service/hide_password_cookie.go b/backend/service/hide_password_cookie.go index d1a984c..0b23a97 100644 --- a/backend/service/hide_password_cookie.go +++ b/backend/service/hide_password_cookie.go @@ -16,8 +16,8 @@ import ( ) const ( - hidePwdCookiePrefix = "j13_hp_" - hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天 + hidePwdCookiePrefix = "j13_hp_" + hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天 hidePwdCookieVersion = "1" ) diff --git a/backend/service/image_variants.go b/backend/service/image_variants.go new file mode 100644 index 0000000..538405c --- /dev/null +++ b/backend/service/image_variants.go @@ -0,0 +1,276 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + webpenc "github.com/gen2brain/webp" + "golang.org/x/image/draw" + xwebp "golang.org/x/image/webp" +) + +// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽> +// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 消费。 +// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。 +// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。 +// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。 + +// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致) +const VariantWebPQuality = 78 + +// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进) +var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920} + +// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸) +func IsVariantWidth(w int) bool { + for _, v := range variantWidths { + if v == w { + return true + } + } + return false +} + +// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁 +var variantGenLocks sync.Map + +// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型) +func extMIME(ext string) string { + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + case ".svg": + return "image/svg+xml" + case ".ico": + return "image/x-icon" + } + return "" +} + +// variantCacheFile 变体缓存路径:.thumbs/v/.w.webp +func (s *UploadService) variantCacheFile(url string, w int) string { + sum := sha1.Sum([]byte(url)) + name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp" + return filepath.Join(s.dir, ".thumbs", "v", name) +} + +// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在 +func variantCacheFresh(cache string, modTime time.Time) bool { + ti, err := os.Stat(cache) + if err != nil || ti.IsDir() { + return false + } + return modTime.IsZero() || !ti.ModTime().Before(modTime) +} + +// Variant 返回源图指定宽度的变体字节与 MIME。 +func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) { + rawURL = strings.TrimSpace(rawURL) + if len(rawURL) == 0 || len(rawURL) > 512 { + return nil, "", errors.New("无效地址") + } + if !IsVariantWidth(width) { + return nil, "", errors.New("不支持的尺寸") + } + + var ( + srcData []byte // 远程源预读字节(本地源在锁内读取) + srcMIME string + srcPath string // 本地源绝对路径 + modTime time.Time + remote bool + cache = s.variantCacheFile(rawURL, width) + ) + + switch { + case strings.HasPrefix(rawURL, "/uploads/"): + rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/"))) + if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") { + return nil, "", errors.New("无效地址") + } + dir := filepath.Dir(rel) + catOK := false + for _, cat := range mediaLibraryCategories { + if cat.Dir == dir { + catOK = true + break + } + } + if !catOK { + return nil, "", errors.New("无效地址") + } + ext := strings.ToLower(filepath.Ext(rel)) + mime := extMIME(ext) + if mime == "" { + return nil, "", errors.New("非图片") + } + srcPath = filepath.Join(s.dir, rel) + info, err := os.Stat(srcPath) + if err != nil || info.IsDir() { + return nil, "", errors.New("文件不存在") + } + // 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存) + if ext == ".gif" || ext == ".svg" || ext == ".ico" { + data, err := os.ReadFile(srcPath) + return data, mime, err + } + srcMIME = mime + modTime = info.ModTime() + + case strings.HasPrefix(rawURL, "/api/media/"): + if s.ops == nil { + return nil, "", errors.New("无效地址") + } + id := strings.TrimPrefix(rawURL, "/api/media/") + if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 { + return nil, "", errors.New("无效地址") + } + remote = true + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + r, mime, e := s.ops.OpenObject(ctx, id, true) + if e != nil { + return nil, "", errors.New("文件暂不可用") + } + defer r.Close() + switch mime { + case "image/jpeg", "image/png", "image/webp": + case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon": + data, err := io.ReadAll(r) + return data, mime, err + default: + return nil, "", errors.New("非图片") + } + srcData, e = io.ReadAll(r) + if e != nil { + return nil, "", e + } + srcMIME = mime + + default: + return nil, "", errors.New("无效地址") + } + + // 快速路径:缓存已新鲜直接返回(无锁) + if variantCacheFresh(cache, modTime) { + if data, err := os.ReadFile(cache); err == nil { + return data, "image/webp", nil + } + } + + // 并发生成去重:同 url+w 只生成一次 + sum := sha1.Sum([]byte(rawURL)) + key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width) + mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{}) + lk := mu.(*sync.Mutex) + lk.Lock() + defer lk.Unlock() + + // double-check:等锁期间可能已由他人生成 + if variantCacheFresh(cache, modTime) { + if data, err := os.ReadFile(cache); err == nil { + return data, "image/webp", nil + } + } + + if !remote { + data, err := os.ReadFile(srcPath) + if err != nil { + return nil, "", err + } + srcData = data + } + + out, m, err := variantFromBytes(srcData, srcMIME, width) + if err != nil { + return nil, "", err // 如动图 WebP,调用方回退原图 + } + // 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应 + if m == "image/webp" { + _ = atomicWriteFile(cache, out) + } + return out, m, nil +} + +// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图); +// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。 +func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) { + img, err := decodeImageBytes(srcData, srcMIME == "image/webp") + if err != nil { + return nil, "", err + } + b := img.Bounds() + if b.Dx() <= width { + return srcData, srcMIME, nil + } + nh := max(1, b.Dy()*width/b.Dx()) + dst := scaleImage(img, width, nh) + out, err := encodeLossyWebP(dst, VariantWebPQuality) + if err != nil { + return nil, "", err + } + return out, "image/webp", nil +} + +// ---------- 与 MediaThumb 共用的图片处理核心 ---------- + +// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器 +func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) { + if isWebP { + return xwebp.Decode(bytes.NewReader(data)) + } + img, _, err := image.Decode(bytes.NewReader(data)) + return img, err +} + +// scaleImage CatmullRom 高质量缩放到指定宽高 +func scaleImage(img image.Image, nw, nh int) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) + return dst +} + +// encodeLossyWebP 有损 WebP 编码 +func encodeLossyWebP(img image.Image, quality int) ([]byte, error) { + var buf bytes.Buffer + if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// atomicWriteFile 临时文件 + rename 原子落盘 +func atomicWriteFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".partial" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} diff --git a/backend/service/image_variants_test.go b/backend/service/image_variants_test.go new file mode 100644 index 0000000..fc82fca --- /dev/null +++ b/backend/service/image_variants_test.go @@ -0,0 +1,282 @@ +package service + +import ( + "bytes" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/freefire/jiang13-bbs/config" + webpenc "github.com/gen2brain/webp" + xwebp "golang.org/x/image/webp" +) + +// solidImg 生成渐变纯色测试图 +func solidImg(w, h int) *image.RGBA { + src := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255}) + } + } + return src +} + +// writeVariantSource 把按编码器生成的文件写到 uploads 目录 +func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) { + t.Helper() + abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/"))) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + f, err := os.Create(abs) + if err != nil { + t.Fatalf("create: %v", err) + } + defer f.Close() + if err := encode(f); err != nil { + t.Fatalf("encode: %v", err) + } +} + +func encodeJPEG(img image.Image) func(io.Writer) error { + return func(w io.Writer) error { + return jpeg.Encode(w, img, &jpeg.Options{Quality: 85}) + } +} + +func encodePNG(img image.Image) func(io.Writer) error { + return func(w io.Writer) error { + return png.Encode(w, img) + } +} + +func encodeWebP(img image.Image) func(io.Writer) error { + return func(w io.Writer) error { + return webpenc.Encode(w, img, webpenc.Options{Quality: 85}) + } +} + +func encodeGIF(img image.Image) func(io.Writer) error { + return func(w io.Writer) error { + return gif.Encode(w, img, nil) + } +} + +// decodeVariant 解码返回的 WebP 变体 +func decodeVariant(t *testing.T, data []byte) image.Image { + t.Helper() + img, err := xwebp.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode variant webp: %v", err) + } + return img +} + +func TestIsVariantWidth(t *testing.T) { + if !IsVariantWidth(48) || !IsVariantWidth(1920) { + t.Fatal("whitelist endpoints should be valid") + } + if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) { + t.Fatal("non-whitelist width should be invalid") + } +} + +func TestVariantRejectsInvalidInput(t *testing.T) { + s, _ := newUploadTestService(t) + + cases := []struct { + name string + url string + w int + }{ + {"非法尺寸", "/uploads/images/a.webp", 100}, + {"路径穿越", "/uploads/images/../../etc/passwd", 96}, + {"非白名单目录", "/uploads/secret/a.webp", 96}, + {"非图片扩展名", "/uploads/images/a.txt", 96}, + {"外部地址", "https://evil.com/a.webp", 96}, + {"空地址", "", 96}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, _, err := s.Variant(tc.url, tc.w); err == nil { + t.Fatal("want error, got nil") + } + }) + } +} + +func TestVariantMissingFile(t *testing.T) { + s, _ := newUploadTestService(t) + if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil { + t.Fatal("missing source should error") + } +} + +func TestVariantPassthroughSmallImage(t *testing.T) { + s, dir := newUploadTestService(t) + url := "/uploads/images/small.webp" + writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48))) + + data, m, err := s.Variant(url, 96) + if err != nil { + t.Fatalf("variant: %v", err) + } + if m != "image/webp" { + t.Fatalf("want webp mime, got %s", m) + } + src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp"))) + if !bytes.Equal(data, src) { + t.Fatal("small image should be returned byte-for-byte") + } +} + +func TestVariantResizesByWidthLandscape(t *testing.T) { + s, dir := newUploadTestService(t) + url := "/uploads/images/wide.jpg" + writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500))) + + data, m, err := s.Variant(url, 384) + if err != nil { + t.Fatalf("variant: %v", err) + } + if m != "image/webp" { + t.Fatalf("want webp, got %s", m) + } + b := decodeVariant(t, data).Bounds() + if b.Dx() != 384 || b.Dy() != 192 { + t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy()) + } +} + +func TestVariantResizesByWidthPortrait(t *testing.T) { + s, dir := newUploadTestService(t) + url := "/uploads/images/tall.png" + writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000))) + + data, _, err := s.Variant(url, 256) + if err != nil { + t.Fatalf("variant: %v", err) + } + b := decodeVariant(t, data).Bounds() + if b.Dx() != 256 || b.Dy() != 512 { + t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy()) + } +} + +func TestVariantPassthroughGIFSVGICO(t *testing.T) { + s, dir := newUploadTestService(t) + + cases := []struct { + name string + url string + mime string + body []byte + }{ + {"gif", "/uploads/images/a.gif", "image/gif", nil}, + {"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("")}, + {"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.body != nil { + writeVariantSource(t, dir, tc.url, func(w io.Writer) error { + _, err := w.Write(tc.body) + return err + }) + } else { + writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16))) + } + data, m, err := s.Variant(tc.url, 96) + if err != nil { + t.Fatalf("variant: %v", err) + } + if m != tc.mime { + t.Fatalf("want %s, got %s", tc.mime, m) + } + src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/")))) + if !bytes.Equal(data, src) { + t.Fatalf("%s should pass through unchanged", tc.name) + } + }) + } +} + +func TestVariantCachedAndReused(t *testing.T) { + s, dir := newUploadTestService(t) + url := "/uploads/images/cache.jpg" + writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500))) + + first, m, err := s.Variant(url, 384) + if err != nil { + t.Fatalf("first variant: %v", err) + } + if m != "image/webp" { + t.Fatalf("want webp, got %s", m) + } + cache := s.variantCacheFile(url, 384) + if info, err := os.Stat(cache); err != nil || info.IsDir() { + t.Fatalf("cache file should exist: %v", err) + } + second, _, err := s.Variant(url, 384) + if err != nil { + t.Fatalf("second variant: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatal("cached variant should be byte-identical") + } +} + +func TestVariantCacheFreshness(t *testing.T) { + _, dir := newUploadTestService(t) + cache := filepath.Join(dir, ".thumbs", "v", "x.webp") + if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + // 远程对象:modTime 零值,存在即新鲜 + if !variantCacheFresh(cache, time.Time{}) { + t.Fatal("remote cache should be fresh when file exists") + } + // 本地源:mtime 新于缓存 → 不新鲜 + future := time.Now().Add(1 * time.Hour) + if variantCacheFresh(cache, future) { + t.Fatal("cache should be stale vs newer source mtime") + } +} + +func TestVariantRemoteRejectsBadID(t *testing.T) { + s, _ := newUploadTestService(t) + s.ops = NewOperations(s.db, &config.Config{}) + + if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil { + t.Fatal("id with slash should be rejected") + } +} + +func TestVariantRemoteUnavailable(t *testing.T) { + s, _ := newUploadTestService(t) + s.ops = NewOperations(s.db, &config.Config{}) + + // 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节 + if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil { + t.Fatal("missing remote object should error") + } +} + +func TestVariantRemoteWithoutOps(t *testing.T) { + s, _ := newUploadTestService(t) + if _, _, err := s.Variant("/api/media/abc", 96); err == nil { + t.Fatal("remote url without ops wired should error") + } +} diff --git a/backend/service/leaderboard_test.go b/backend/service/leaderboard_test.go index b0a70e1..d009461 100644 --- a/backend/service/leaderboard_test.go +++ b/backend/service/leaderboard_test.go @@ -38,9 +38,9 @@ func TestEarnedPointsWindowAndExclusions(t *testing.T) { {UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, {UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now}, {UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)}, - {UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计 - {UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计 - {UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计 + {UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计 + {UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计 + {UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计 {UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外 } if err := db.Create(&ledgers).Error; err != nil { diff --git a/backend/service/legacyimport.go b/backend/service/legacyimport.go index 7c0e27b..6701289 100644 --- a/backend/service/legacyimport.go +++ b/backend/service/legacyimport.go @@ -102,7 +102,7 @@ type LegacyUserPreview struct { Nickname string `json:"nickname"` Posts int `json:"posts"` Comments int `json:"comments"` - Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号) + Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号) HasAvatar bool `json:"has_avatar"` } @@ -142,8 +142,8 @@ type LegacyUserReport struct { // LegacyBoardReport 板块导入明细 type LegacyBoardReport struct { - Created int `json:"created"` // 新建(预检时为「将新建」) - Reused int `json:"reused"` // 复用同名已有板块 + Created int `json:"created"` // 新建(预检时为「将新建」) + Reused int `json:"reused"` // 复用同名已有板块 Names []string `json:"names,omitempty"` // 新建板块名 } @@ -151,7 +151,7 @@ type LegacyBoardReport struct { type LegacyPostReport struct { Total int `json:"total"` Imported int `json:"imported"` - Skipped int `json:"skipped"` // 已导入过(去重记录) + Skipped int `json:"skipped"` // 已导入过(去重记录) Excluded int `json:"excluded"` // 手动排除 ExcludedDetail []string `json:"excluded_detail,omitempty"` PollsSkipped int `json:"polls_skipped"` @@ -751,19 +751,19 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint continue } post := model.Post{ - BoardID: boardID, - UserID: authorID, - Title: truncateRunesN(title, 256), - Content: content, - Tags: p.Tags, - PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion - Pinned: p.Pinned, - Recommended: p.Featured, - Status: model.ContentStatusPublished, - LikeCount: p.LikeCount, - ViewCount: p.ViewCount, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, + BoardID: boardID, + UserID: authorID, + Title: truncateRunesN(title, 256), + Content: content, + Tags: p.Tags, + PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion + Pinned: p.Pinned, + Recommended: p.Featured, + Status: model.ContentStatusPublished, + LikeCount: p.LikeCount, + ViewCount: p.ViewCount, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } if err := s.db.Create(&post).Error; err != nil { out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err)) diff --git a/backend/service/legacyimport_test.go b/backend/service/legacyimport_test.go index 66effd6..eed6692 100644 --- a/backend/service/legacyimport_test.go +++ b/backend/service/legacyimport_test.go @@ -136,9 +136,9 @@ 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 的评论 + 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 { @@ -241,8 +241,8 @@ func TestLegacyImportSelectiveRun(t *testing.T) { func TestLegacyImportSkipPostCascades(t *testing.T) { svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t) opts := LegacyImportOptions{ - WithContent: true, - SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过 + WithContent: true, + SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过 } rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID) if err != nil { diff --git a/backend/service/library.go b/backend/service/library.go index a918210..fef8bfe 100644 --- a/backend/service/library.go +++ b/backend/service/library.go @@ -1,10 +1,12 @@ package service import ( + "bytes" "crypto/rand" "encoding/hex" "errors" "io" + "net/http" "os" "path/filepath" "strconv" @@ -44,7 +46,16 @@ func LibraryExtAllowed(ext string) bool { // 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields) var libraryDocWriteFields = []string{ - "Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto", + "Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight", + "Published", "SortOrder", "EntriesAuto", "Author", "CreatorID", +} + +// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil) +type LibraryCreator struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` } // LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用) @@ -53,27 +64,34 @@ type LibraryDocDetail struct { Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型) Files []model.LibraryFile `json:"files"` Sections []model.LibrarySection `json:"sections"` + Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil) } -// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要) +// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要) type LibraryDocListItem struct { - ID uint `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - CoverURL string `json:"cover_url"` - SortOrder int `json:"sort_order"` - FileCount int `json:"file_count"` - Files []model.LibraryFile `json:"files"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Description string `json:"description"` + CoverURL string `json:"cover_url"` + SortOrder int `json:"sort_order"` + FileCount int `json:"file_count"` + Files []model.LibraryFile `json:"files"` + Creator *LibraryCreator `json:"creator,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // LibraryInput 创建/更新书库条目入参 type LibraryInput struct { Slug string `json:"slug"` Title string `json:"title"` + Author string `json:"author"` // 书籍作者(自由文本,可空) Description string `json:"description"` CoverURL string `json:"cover_url"` + CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知) + CoverHeight int `json:"cover_height"` // 封面自然高(0=未知) Published *bool `json:"published"` SortOrder *int `json:"sort_order"` EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改) @@ -99,6 +117,11 @@ func (in *LibraryInput) normalize() error { return errors.New("标题不能超过 200 字") } + in.Author = strings.TrimSpace(in.Author) + if utf8.RuneCountInString(in.Author) > 100 { + return errors.New("作者不能超过 100 字") + } + in.Description = strings.TrimSpace(in.Description) if utf8.RuneCountInString(in.Description) > libraryDescriptionMax { return errors.New("介绍不能超过 20000 字") @@ -115,8 +138,17 @@ func (in *LibraryInput) normalize() error { func (in *LibraryInput) applyTo(d *model.LibraryDoc) { d.Slug = in.Slug d.Title = in.Title + d.Author = in.Author d.Description = in.Description d.CoverURL = in.CoverURL + // 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测) + if in.CoverURL == "" { + d.CoverWidth = 0 + d.CoverHeight = 0 + } else { + d.CoverWidth = in.CoverWidth + d.CoverHeight = in.CoverHeight + } if in.Published != nil { d.Published = *in.Published } @@ -130,9 +162,10 @@ func (in *LibraryInput) applyTo(d *model.LibraryDoc) { // LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览) type LibraryService struct { - db *gorm.DB - dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html) - setting *SettingService + db *gorm.DB + dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html) + uploadsDir string // data/uploads:封面回填时解析本地封面图 + setting *SettingService } func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService { @@ -145,6 +178,12 @@ func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService { return s } +// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件) +func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService { + s.uploadsDir = uploadsDir + return s +} + func (s *LibraryService) EnsureDir() error { return os.MkdirAll(s.dir, 0o755) } @@ -224,6 +263,7 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail for _, sec := range sections { secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec) } + creatorByID := s.creatorMap(docs) for _, d := range docs { fs := byDoc[d.ID] if fs == nil { @@ -233,12 +273,40 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail if ss == nil { ss = []model.LibrarySection{} } - out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss}) + out = append(out, LibraryDocDetail{ + LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss, + Creator: creatorByID[d.CreatorID], + }) } return out } -func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) { +// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省) +func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator { + ids := make([]uint, 0, len(docs)) + seen := map[uint]bool{} + for _, d := range docs { + if d.CreatorID > 0 && !seen[d.CreatorID] { + seen[d.CreatorID] = true + ids = append(ids, d.CreatorID) + } + } + if len(ids) == 0 { + return map[uint]*LibraryCreator{} + } + var users []model.User + if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil { + return map[uint]*LibraryCreator{} + } + m := make(map[uint]*LibraryCreator, len(users)) + for i := range users { + u := &users[i] + m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar} + } + return m +} + +func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) { if err := in.normalize(); err != nil { return nil, err } @@ -253,6 +321,9 @@ func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) { return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用") } d := &model.LibraryDoc{Published: false, SortOrder: 0} + if len(creatorID) > 0 { + d.CreatorID = creatorID[0] + } in.applyTo(d) if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil { return nil, err @@ -279,12 +350,15 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用") } in.applyTo(&d) - // map 更新确保 false/空串写入(同 SitePage.Update) + // map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列 if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{ "slug": d.Slug, "title": d.Title, + "author": d.Author, "description": d.Description, "cover_url": d.CoverURL, + "cover_width": d.CoverWidth, + "cover_height": d.CoverHeight, "published": d.Published, "sort_order": d.SortOrder, "entries_auto": d.EntriesAuto, @@ -294,6 +368,77 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e return &d, nil } +// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串 +func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) { + rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/") + if rel == "" || rel == coverURL || strings.Contains(rel, "..") { + return "", false + } + abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel)) + root := filepath.Clean(s.uploadsDir) + clean := filepath.Clean(abs) + if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) { + return "", false + } + return clean, true +} + +// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码) +func measureCoverReader(r io.Reader) (w, h int, err error) { + data, err := io.ReadAll(r) + if err != nil { + return 0, 0, err + } + format, err := detectImageFormat(data) + if err != nil { + return 0, 0, err + } + return decodeImageSizeReader(bytes.NewReader(data), format.mime) +} + +// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。 +// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。 +func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) { + if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" { + return + } + url := strings.TrimSpace(d.CoverURL) + var reader io.ReadCloser + switch { + case strings.HasPrefix(url, "/uploads/"): + abs, ok := s.resolveCoverPath(url) + if !ok { + return + } + f, err := os.Open(abs) + if err != nil { + return + } + reader = f + case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"): + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return + } + reader = resp.Body + default: + return + } + w, h, err := measureCoverReader(reader) + reader.Close() + if err != nil || w <= 0 || h <= 0 { + return + } + if err := s.db.Model(d).Updates(map[string]interface{}{ + "cover_width": w, + "cover_height": h, + }).Error; err != nil { + return + } + d.CoverWidth, d.CoverHeight = w, h +} + func (s *LibraryService) Delete(id uint) error { result := s.db.Delete(&model.LibraryDoc{}, id) if result.Error != nil { @@ -349,8 +494,9 @@ func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) { out := make([]LibraryDocListItem, 0, len(details)) for _, d := range details { out = append(out, LibraryDocListItem{ - ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL, - SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files, + ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description, + CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files, + Creator: d.Creator, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, }) } @@ -364,6 +510,8 @@ func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, err if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil { return nil, ErrLibraryNotFound } + // 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁) + s.ensureCoverDimensions(&d) out := s.attachFiles([]model.LibraryDoc{d}) return &out[0], nil } diff --git a/backend/service/library_export.go b/backend/service/library_export.go new file mode 100644 index 0000000..c8b0b9d --- /dev/null +++ b/backend/service/library_export.go @@ -0,0 +1,369 @@ +package service + +// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面), +// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。 +// +// 单本 ZIP 布局: +// +// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录) +// cover. 本地封面(外链封面不内嵌,仅保留 cover_url) +// files/ 附件原文件 +// +// 全库 ZIP 布局: +// +// library.json 索引(每本书的目录位置) +// docs//book.json 各书清单 +// docs//cover. +// docs//files/ + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/freefire/jiang13-bbs/model" +) + +const ( + libraryExportFormat = "jiang13-library" // 全库包 + libraryExportBookFormat = "jiang13-library-book" // 单本书包 + libraryExportVersion = 1 +) + +// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致) +var libraryExportCoverExts = map[string]struct{}{ + "jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {}, +} + +// libraryExportFile 附件清单条目 +type libraryExportFile struct { + Name string `json:"name"` // 原始文件名 + Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空) + Ext string `json:"ext"` + MIME string `json:"mime"` + Size int `json:"size"` + DownloadCount int `json:"download_count"` + SortOrder int `json:"sort_order"` + Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失 +} + +// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key) +type libraryExportSection struct { + Key string `json:"key"` + ParentKey string `json:"parent_key,omitempty"` + Title string `json:"title"` + Content string `json:"content"` + SortOrder int `json:"sort_order"` +} + +// libraryExportBook 单本书的完整清单 +type libraryExportBook struct { + ID uint `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Description string `json:"description"` + CoverURL string `json:"cover_url"` + CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径 + CoverWidth int `json:"cover_width"` + CoverHeight int `json:"cover_height"` + Published bool `json:"published"` + SortOrder int `json:"sort_order"` + EntriesAuto bool `json:"entries_auto"` + + Files []libraryExportFile `json:"files"` + Sections []libraryExportSection `json:"sections"` +} + +// libraryExportIndexEntry 全库索引中的单本概要 +type libraryExportIndexEntry struct { + Slug string `json:"slug"` + Title string `json:"title"` + Dir string `json:"dir"` // 相对 ZIP 根的目录 + FileCount int `json:"file_count"` + SectionCount int `json:"section_count"` +} + +// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引 +type libraryExportManifest struct { + Format string `json:"format"` + FormatVersion int `json:"format_version"` + ExportedAt string `json:"exported_at"` + Doc *libraryExportBook `json:"doc,omitempty"` + Docs []libraryExportIndexEntry `json:"docs,omitempty"` +} + +// preparedFile 已打开、待流式拷入 ZIP 的附件 +type preparedFile struct { + zipPath string + reader *os.File +} + +// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄) +type preparedBook struct { + meta libraryExportBook + dir string // ZIP 内目录前缀(单本包为空串 = 根目录) + coverPath string // ZIP 内封面路径(空 = 不内嵌) + coverReader *os.File + files []preparedFile // 仅磁盘存在的附件 +} + +func (b *preparedBook) closeReaders() { + if b.coverReader != nil { + _ = b.coverReader.Close() + } + for i := range b.files { + _ = b.files[i].reader.Close() + } +} + +// LibraryExport 已完成装载、可直接流式写出的导出包 +type LibraryExport struct { + Filename string + full bool + exportedAt time.Time + books []preparedBook +} + +// Close 释放导出过程中打开的全部文件句柄 +func (e *LibraryExport) Close() { + for i := range e.books { + e.books[i].closeReaders() + } +} + +// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。 +// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。 +func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) { + d, err := s.Get(id) + if err != nil { + return nil, err + } + b, err := s.prepareBook(d, "") + if err != nil { + return nil, err + } + name := d.Slug + if name == "" { + name = "book" + } + return &LibraryExport{ + Filename: name + ".zip", + full: false, + exportedAt: time.Now(), + books: []preparedBook{*b}, + }, nil +} + +// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序) +func (s *LibraryService) BuildAllExport() (*LibraryExport, error) { + var docs []model.LibraryDoc + if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil { + return nil, err + } + details := s.attachFiles(docs) + exp := &LibraryExport{ + Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip", + full: true, + exportedAt: time.Now(), + books: make([]preparedBook, 0, len(details)), + } + for i := range details { + // slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底 + dir := "docs/" + details[i].Slug + "/" + if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) { + exp.Close() + return nil, errors.New("条目 slug 含非法字符,无法导出") + } + b, err := s.prepareBook(&details[i], dir) + if err != nil { + exp.Close() + return nil, err + } + exp.books = append(exp.books, *b) + } + return exp, nil +} + +// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀) +func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) { + b := &preparedBook{dir: dir} + b.meta = libraryExportBook{ + ID: d.ID, + CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339), + Slug: d.Slug, + Title: d.Title, + Author: d.Author, + Description: d.Description, + CoverURL: d.CoverURL, + CoverWidth: d.CoverWidth, + CoverHeight: d.CoverHeight, + Published: d.Published, + SortOrder: d.SortOrder, + EntriesAuto: d.EntriesAuto, + Files: make([]libraryExportFile, 0, len(d.Files)), + Sections: make([]libraryExportSection, 0, len(d.Sections)), + } + + // 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序) + keyByID := make(map[uint]string, len(d.Sections)) + for i, sec := range d.Sections { + keyByID[sec.ID] = "s" + strconv.Itoa(i) + } + for _, sec := range d.Sections { + m := libraryExportSection{ + Key: keyByID[sec.ID], + Title: sec.Title, + Content: sec.Content, + SortOrder: sec.SortOrder, + } + if sec.ParentID != nil { + m.ParentKey = keyByID[*sec.ParentID] + } + b.meta.Sections = append(b.meta.Sections, m) + } + + // 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径 + for _, f := range d.Files { + m := libraryExportFile{ + Name: f.Name, + Ext: f.Ext, + MIME: f.MIME, + Size: f.Size, + DownloadCount: f.DownloadCount, + SortOrder: f.SortOrder, + } + full := filepath.Join(s.dir, f.StoredName) + if r, err := os.Open(full); err == nil { + m.Stored = "files/" + f.StoredName + b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r}) + } else { + m.Missing = true + } + b.meta.Files = append(b.meta.Files, m) + } + + // 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL + if rel, ok := s.resolveCoverPath(d.CoverURL); ok { + if ext := coverExtOf(d.CoverURL); ext != "" { + if r, err := os.Open(rel); err == nil { + b.coverReader = r + b.coverPath = dir + "cover." + ext + b.meta.CoverFile = "cover." + ext + } + } + } + return b, nil +} + +// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。 +func (e *LibraryExport) WriteZip(w io.Writer) error { + defer e.Close() + zw := zip.NewWriter(w) + + index := make([]libraryExportIndexEntry, 0, len(e.books)) + for i := range e.books { + b := &e.books[i] + // 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写 + if e.full { + if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil { + return err + } + } + if b.coverReader != nil { + if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil { + return err + } + } + for j := range b.files { + if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil { + return err + } + } + index = append(index, libraryExportIndexEntry{ + Slug: b.meta.Slug, + Title: b.meta.Title, + Dir: b.dir, + FileCount: len(b.meta.Files), + SectionCount: len(b.meta.Sections), + }) + } + + // 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内) + if e.full { + root := libraryExportManifest{ + Format: libraryExportFormat, + FormatVersion: libraryExportVersion, + ExportedAt: e.exportedAt.UTC().Format(time.RFC3339), + Docs: index, + } + if err := writeZipJSON(zw, "library.json", root); err != nil { + return err + } + } else { + root := libraryExportManifest{ + Format: libraryExportBookFormat, + FormatVersion: libraryExportVersion, + ExportedAt: e.exportedAt.UTC().Format(time.RFC3339), + Doc: &e.books[0].meta, + } + if err := writeZipJSON(zw, "book.json", root); err != nil { + return err + } + } + return zw.Close() +} + +// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名 +func coverExtOf(coverURL string) string { + u := strings.TrimSpace(coverURL) + if i := strings.IndexByte(u, '?'); i >= 0 { + u = u[:i] + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), ".")) + if _, ok := libraryExportCoverExts[ext]; !ok { + return "" + } + return ext +} + +// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读) +func writeZipJSON(zw *zip.Writer, name string, v any) error { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return err + } + hdr := &zip.FileHeader{Name: name, Method: zip.Deflate} + hdr.SetMode(0o644) + f, err := zw.CreateHeader(hdr) + if err != nil { + return err + } + _, err = f.Write(buf.Bytes()) + return err +} + +// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU) +func writeZipFile(zw *zip.Writer, name string, r io.Reader) error { + hdr := &zip.FileHeader{Name: name, Method: zip.Store} + hdr.SetMode(0o644) + f, err := zw.CreateHeader(hdr) + if err != nil { + return err + } + _, err = io.Copy(f, r) + return err +} diff --git a/backend/service/library_export_test.go b/backend/service/library_export_test.go new file mode 100644 index 0000000..5f5a553 --- /dev/null +++ b/backend/service/library_export_test.go @@ -0,0 +1,199 @@ +package service + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false) +func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) { + t.Helper() + for _, f := range zr.File { + if f.Name == name { + rc, err := f.Open() + if err != nil { + t.Fatalf("open zip entry %s: %v", name, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read zip entry %s: %v", name, err) + } + return data, true + } + } + return nil, false +} + +func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader { + t.Helper() + var buf bytes.Buffer + if err := exp.WriteZip(&buf); err != nil { + t.Fatalf("write zip: %v", err) + } + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("read exported zip: %v", err) + } + return zr +} + +// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍 +func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) { + t.Helper() + pub := true + doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"}) + if err != nil { + t.Fatalf("create doc: %v", err) + } + + coverDir := filepath.Join(uploads, "images") + if err := os.MkdirAll(coverDir, 0o755); err != nil { + t.Fatalf("mkdir uploads: %v", err) + } + coverBytes := encodeJPEGBytes(t, 120, 160) + if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil { + t.Fatalf("write cover: %v", err) + } + if _, err := s.Update(doc.ID, &LibraryInput{ + Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍", + CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160, + Published: &pub, + }); err != nil { + t.Fatalf("update cover: %v", err) + } + + chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"}) + if err != nil { + t.Fatalf("create chapter: %v", err) + } + if _, err := s.CreateSection(doc.ID, &SectionInput{ + ParentID: &chapter.ID, Title: "小节", Content: "小节正文", + }); err != nil { + t.Fatalf("create section: %v", err) + } + + f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES"))) + if err != nil { + t.Fatalf("add file: %v", err) + } + return doc.ID, f.StoredName +} + +func TestLibraryExportBook(t *testing.T) { + s, _ := newLibraryTestService(t) + uploads := t.TempDir() + s.WithUploadsDir(uploads) + docID, storedName := seedBookForExport(t, s, uploads) + + exp, err := s.BuildBookExport(docID) + if err != nil { + t.Fatalf("build export: %v", err) + } + defer exp.Close() + if exp.Filename != "dao-jia.zip" { + t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename) + } + zr := buildExportZip(t, exp) + + raw, ok := readZipEntry(t, zr, "book.json") + if !ok { + t.Fatal("缺少 book.json") + } + var manifest libraryExportManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("parse book.json: %v", err) + } + if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion { + t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion) + } + b := manifest.Doc + if b == nil { + t.Fatal("book.json 缺少 doc") + } + if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published { + t.Fatalf("元信息导出不正确: %+v", b) + } + if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 { + t.Fatalf("封面信息导出不正确: %+v", b) + } + if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" { + t.Fatalf("章节树导出不正确: %+v", b.Sections) + } + if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing { + t.Fatalf("附件清单导出不正确: %+v", b.Files) + } + + if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" { + t.Fatalf("附件内容导出不正确(ok=%v)", ok) + } + if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) { + t.Fatalf("封面内容导出不正确(ok=%v)", ok) + } + // 单本包不应出现全库索引 + if _, ok := readZipEntry(t, zr, "library.json"); ok { + t.Fatal("单本包不应包含 library.json") + } +} + +func TestLibraryExportAll(t *testing.T) { + s, _ := newLibraryTestService(t) + uploads := t.TempDir() + s.WithUploadsDir(uploads) + docID, storedName := seedBookForExport(t, s, uploads) + _ = docID + + exp, err := s.BuildAllExport() + if err != nil { + t.Fatalf("build all export: %v", err) + } + defer exp.Close() + zr := buildExportZip(t, exp) + + raw, ok := readZipEntry(t, zr, "library.json") + if !ok { + t.Fatal("缺少 library.json") + } + var manifest libraryExportManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("parse library.json: %v", err) + } + if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 { + t.Fatalf("全库索引异常: %+v", manifest) + } + idx := manifest.Docs[0] + if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 { + t.Fatalf("索引条目异常: %+v", idx) + } + + bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json") + if !ok { + t.Fatal("缺少 docs/dao-jia/book.json") + } + var book libraryExportBook + if err := json.Unmarshal(bookRaw, &book); err != nil { + t.Fatalf("parse nested book.json: %v", err) + } + if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" { + t.Fatalf("嵌套书籍清单异常: %+v", book) + } + if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok { + t.Fatal("缺少嵌套附件") + } + if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok { + t.Fatal("缺少嵌套封面") + } +} + +func TestLibraryExportMissingDoc(t *testing.T) { + s, _ := newLibraryTestService(t) + if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) { + t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err) + } +} diff --git a/backend/service/library_import.go b/backend/service/library_import.go new file mode 100644 index 0000000..f73cd4f --- /dev/null +++ b/backend/service/library_import.go @@ -0,0 +1,631 @@ +package service + +// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。 +// +// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引) +// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来) +// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复) +// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数 +// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL +// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错) + +import ( + "archive/zip" + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截) +const LibraryImportMaxBytes = 512 << 20 + +const ( + LibraryImportModeCreate = "create" // 冲突跳过 + LibraryImportModeOverwrite = "overwrite" // 冲突覆盖 +) + +// 失败原因(前端据此区分冲突,可提示覆盖重导) +const ( + libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式) + libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用 + libraryImportReasonInvalid = "invalid" // 清单数据非法 + libraryImportReasonFailed = "failed" // 落盘/写库失败 +) + +var ( + ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包") + ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏") + ErrLibraryImportVer = errors.New("导出版本不受支持") + ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍") + ErrLibraryImportMode = errors.New("无效的导入模式") +) + +// LibraryImportItem 成功导入的单本结果 +type LibraryImportItem struct { + Slug string `json:"slug"` + Title string `json:"title"` + Action string `json:"action"` // created / overwritten + Sections int `json:"sections"` + Files int `json:"files"` +} + +// LibraryImportFailure 单本书失败明细 +type LibraryImportFailure struct { + Slug string `json:"slug"` + Title string `json:"title"` + Reason string `json:"reason"` + Error string `json:"error"` +} + +// LibraryImportReport 导入报告 +type LibraryImportReport struct { + Mode string `json:"mode"` + Total int `json:"total"` + Imported []LibraryImportItem `json:"imported"` + Failed []LibraryImportFailure `json:"failed"` +} + +// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀) +type bookImportPlan struct { + book libraryExportBook + dir string +} + +// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号) +type plannedSection struct { + key string + parentKey string + title string + content string + sort int +} + +// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。 +func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) { + if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite { + return nil, ErrLibraryImportMode + } + zr, err := zip.OpenReader(zipPath) + if err != nil { + return nil, ErrLibraryImportBadZip + } + defer zr.Close() + + entries := make(map[string]*zip.File, len(zr.File)) + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名 + } + + plans, err := parseImportPlans(entries) + if err != nil { + return nil, err + } + if len(plans) == 0 { + return nil, ErrLibraryImportEmpty + } + + rep := &LibraryImportReport{ + Mode: mode, + Total: len(plans), + Imported: []LibraryImportItem{}, + Failed: []LibraryImportFailure{}, + } + imp := &bookImporter{s: s, entries: entries, userID: userID} + for _, p := range plans { + item, fail := imp.run(p, mode) + if fail != nil { + rep.Failed = append(rep.Failed, *fail) + } else { + rep.Imported = append(rep.Imported, *item) + } + } + return rep, nil +} + +// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件) +func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) { + if rootRaw, ok := entries["library.json"]; ok { + var root libraryExportManifest + if err := readZipJSON(rootRaw, &root); err != nil { + return nil, ErrLibraryImportFormat + } + if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion { + return nil, ErrLibraryImportVer + } + plans := make([]bookImportPlan, 0, len(root.Docs)) + for _, d := range root.Docs { + dir := strings.ReplaceAll(d.Dir, "\\", "/") + if !safeImportDir(dir) { + return nil, ErrLibraryImportFormat + } + raw, ok := entries[path.Clean(dir)+"/book.json"] + if !ok { + return nil, ErrLibraryImportFormat + } + var b libraryExportBook + if err := readZipJSON(raw, &b); err != nil { + return nil, ErrLibraryImportFormat + } + plans = append(plans, bookImportPlan{book: b, dir: dir}) + } + return plans, nil + } + if rootRaw, ok := entries["book.json"]; ok { + var root libraryExportManifest + if err := readZipJSON(rootRaw, &root); err != nil { + return nil, ErrLibraryImportFormat + } + if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil { + return nil, ErrLibraryImportVer + } + return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil + } + return nil, ErrLibraryImportFormat +} + +// safeImportDir 全库包内目录必须是 docs// 形态(slug 与条目同规则) +func safeImportDir(dir string) bool { + if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") { + return false + } + slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/") + if strings.Contains(slug, "/") || strings.Contains(slug, "..") { + return false + } + return sitePageSlugRe.MatchString(slug) +} + +// bookImporter 携带一次批量导入的共享上下文 +type bookImporter struct { + s *LibraryService + entries map[string]*zip.File + userID uint +} + +func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure { + return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg} +} + +// run 导入单本书;返回 item 或 failure(二者互斥) +func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) { + b := p.book + + in, err := buildImportInput(&b) + if err != nil { + return nil, imp.fail(p, libraryImportReasonInvalid, err.Error()) + } + sections, err := buildImportSections(&b) + if err != nil { + return nil, imp.fail(p, libraryImportReasonInvalid, err.Error()) + } + if err := imp.checkPlannedFiles(p); err != nil { + return nil, imp.fail(p, libraryImportReasonInvalid, err.Error()) + } + coverEntry, err := imp.planCover(p) + if err != nil { + return nil, imp.fail(p, libraryImportReasonInvalid, err.Error()) + } + + active, deleted, err := imp.s.slugTaken(b.Slug, 0) + if err != nil { + return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败") + } + + var docID uint + action := "created" + var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件 + var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads) + backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人 + switch { + case active && mode != LibraryImportModeOverwrite: + return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug) + case active: + var existing model.LibraryDoc + if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil { + return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败") + } + docID = existing.ID + // 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者 + backfillCreator = existing.CreatorID == 0 + _ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error + if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok { + oldCoverDisk = disk + } + action = "overwritten" + case deleted: + return nil, imp.fail(p, libraryImportReasonDeleted, + "该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug) + } + + // 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除) + coverDisk := "" + if coverEntry != nil { + url, w, h, disk, ferr := imp.materializeCover(coverEntry) + if ferr != nil { + return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error()) + } + in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk + } + // 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸 + + txErr := imp.s.db.Transaction(func(tx *gorm.DB) error { + if action == "created" { + d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID} + in.applyTo(d) + if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil { + return err + } + docID = d.ID + } else { + var d model.LibraryDoc + if err := tx.First(&d, docID).Error; err != nil { + return err + } + in.applyTo(&d) + updates := map[string]interface{}{ + "slug": d.Slug, + "title": d.Title, + "author": d.Author, + "description": d.Description, + "cover_url": d.CoverURL, + "cover_width": d.CoverWidth, + "cover_height": d.CoverHeight, + "published": d.Published, + "sort_order": d.SortOrder, + "entries_auto": d.EntriesAuto, + } + if backfillCreator { + updates["creator_id"] = imp.userID + } + if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil { + return err + } + if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil { + return err + } + if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil { + return err + } + } + return imp.createSections(tx, docID, sections) + }) + if txErr != nil { + if action == "created" && coverDisk != "" { + _ = os.Remove(coverDisk) + } + return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error()) + } + + // overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果) + for i := range oldFiles { + _ = os.Remove(imp.s.FilePath(&oldFiles[i])) + } + if oldCoverDisk != "" && oldCoverDisk != coverDisk { + _ = os.Remove(oldCoverDisk) + } + + // 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败 + added := 0 + var newFiles []model.LibraryFile + for _, mf := range b.Files { + if mf.Missing || mf.Stored == "" { + continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复 + } + f, fail := imp.addPlannedFile(p, docID, mf) + if fail != nil { + if action == "created" { + imp.rollbackCreated(docID, newFiles, coverDisk) + } + return nil, fail + } + newFiles = append(newFiles, *f) + added++ + } + + return &LibraryImportItem{ + Slug: b.Slug, + Title: b.Title, + Action: action, + Sections: len(sections), + Files: added, + }, nil +} + +// ---------- 清单解析与预检 ---------- + +func buildImportInput(b *libraryExportBook) (*LibraryInput, error) { + pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto + in := &LibraryInput{ + Slug: b.Slug, + Title: b.Title, + Author: b.Author, + Description: b.Description, + CoverURL: b.CoverURL, + CoverWidth: b.CoverWidth, + CoverHeight: b.CoverHeight, + Published: &pub, + SortOrder: &sortOrder, + EntriesAuto: &entriesAuto, + } + if err := in.normalize(); err != nil { + return nil, err + } + return in, nil +} + +// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用 +func buildImportSections(b *libraryExportBook) ([]plannedSection, error) { + if len(b.Sections) > MaxSectionsPerDoc { + return nil, errors.New("章节数量超过上限(最多 200)") + } + keySeen := map[string]bool{} + out := make([]plannedSection, 0, len(b.Sections)) + + chapterSort := map[string]int{} + order := 0 + for _, sec := range b.Sections { + if sec.ParentKey != "" { + continue + } + title := strings.TrimSpace(sec.Title) + if title == "" { + return nil, errors.New("存在标题为空的章节") + } + if err := validateSectionText(title, sec.Content); err != nil { + return nil, err + } + if keySeen[sec.Key] { + return nil, errors.New("章节标识重复:" + sec.Key) + } + keySeen[sec.Key] = true + chapterSort[sec.Key] = order + out = append(out, plannedSection{ + key: sec.Key, title: title, content: sec.Content, sort: order, + }) + order++ + } + + childCount := map[string]int{} + for _, sec := range b.Sections { + if sec.ParentKey == "" { + continue + } + if _, ok := chapterSort[sec.ParentKey]; !ok { + return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节") + } + title := strings.TrimSpace(sec.Title) + if title == "" { + return nil, errors.New("存在标题为空的小节") + } + if err := validateSectionText(title, sec.Content); err != nil { + return nil, err + } + out = append(out, plannedSection{ + key: sec.Key, + parentKey: sec.ParentKey, + title: title, + content: sec.Content, + sort: childCount[sec.ParentKey], + }) + childCount[sec.ParentKey]++ + } + return out, nil +} + +func validateSectionText(title, content string) error { + if len([]rune(title)) > 200 { + return errors.New("章节标题不能超过 200 字:" + title) + } + if len([]rune(content)) > MaxSectionContent { + return errors.New("章节正文不能超过 100000 字:" + title) + } + return nil +} + +// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在 +func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error { + if len(p.book.Files) > MaxLibraryFilesPerDoc { + return errors.New("附件数量超过上限(最多 20 个)") + } + maxB := imp.s.maxBytes() + for _, f := range p.book.Files { + if f.Missing || f.Stored == "" { + continue + } + rel, ok := cleanZipRel(f.Stored) + if !ok || !strings.HasPrefix(rel, "files/") { + return errors.New("附件路径非法:" + f.Stored) + } + name := sanitizeFilename(f.Name) + if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) { + return errors.New("附件格式不受支持:" + f.Name) + } + zf, ok := imp.entries[p.dir+rel] + if !ok { + return errors.New("备份包缺少附件文件:" + f.Name) + } + if zf.UncompressedSize64 > uint64(maxB) { + return errors.New("附件超过大小上限:" + f.Name) + } + } + return nil +} + +// planCover 返回内嵌封面 ZIP 条目(无则 nil) +func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) { + cf := strings.TrimSpace(p.book.CoverFile) + if cf == "" { + return nil, nil + } + rel, ok := cleanZipRel(cf) + if !ok || strings.Contains(rel, "/") { + return nil, errors.New("封面路径非法:" + cf) + } + zf, ok := imp.entries[p.dir+rel] + if !ok { + return nil, errors.New("备份包缺少封面文件") + } + if zf.UncompressedSize64 > uint64(ImageMaxBytes) { + return nil, errors.New("封面不能超过 5MB") + } + return zf, nil +} + +// ---------- 落盘 ---------- + +// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID) +func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error { + keyToID := make(map[string]uint, len(sections)) + for i := range sections { + ps := §ions[i] + if ps.parentKey != "" { + continue + } + sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort} + if err := tx.Create(sec).Error; err != nil { + return err + } + keyToID[ps.key] = sec.ID + } + for i := range sections { + ps := §ions[i] + if ps.parentKey == "" { + continue + } + parentID, ok := keyToID[ps.parentKey] + if !ok { + return errors.New("小节找不到所属章节") + } + sec := &model.LibrarySection{ + DocID: docID, ParentID: &parentID, + Title: ps.title, Content: ps.content, SortOrder: ps.sort, + } + if err := tx.Create(sec).Error; err != nil { + return err + } + } + return nil +} + +// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序 +func (imp *bookImporter) addPlannedFile( + p bookImportPlan, docID uint, mf libraryExportFile, +) (*model.LibraryFile, *LibraryImportFailure) { + rel, _ := cleanZipRel(mf.Stored) + zf, ok := imp.entries[p.dir+rel] + if !ok { + return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name) + } + rc, err := zf.Open() + if err != nil { + return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name) + } + defer rc.Close() + f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1)) + if err != nil { + return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error()) + } + if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID). + UpdateColumns(map[string]interface{}{ + "download_count": mf.DownloadCount, + "sort_order": mf.SortOrder, + }).Error; err != nil { + return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name) + } + return f, nil +} + +// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径 +func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) { + if imp.s.uploadsDir == "" { + return "", 0, 0, "", errors.New("未配置上传目录") + } + rc, err := zf.Open() + if err != nil { + return "", 0, 0, "", err + } + defer rc.Close() + data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1)) + if err != nil { + return "", 0, 0, "", err + } + if int64(len(data)) > ImageMaxBytes { + return "", 0, 0, "", errors.New("封面不能超过 5MB") + } + format, err := detectImageFormat(data) + if err != nil { + return "", 0, 0, "", err + } + width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime) + if err != nil || width < 1 || height < 1 { + return "", 0, 0, "", errors.New("无法解析封面图片") + } + if width > ImageMaxDim || height > ImageMaxDim { + return "", 0, 0, "", errors.New("封面边长不能超过 4096px") + } + nameBytes := make([]byte, 16) + if _, err := rand.Read(nameBytes); err != nil { + return "", 0, 0, "", err + } + filename := hex.EncodeToString(nameBytes) + format.ext + imagesDir := filepath.Join(imp.s.uploadsDir, "images") + if err := os.MkdirAll(imagesDir, 0o755); err != nil { + return "", 0, 0, "", err + } + full := filepath.Join(imagesDir, filename) + if err := os.WriteFile(full, data, 0o644); err != nil { + return "", 0, 0, "", err + } + return "/uploads/images/" + filename, width, height, full, nil +} + +// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug +func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) { + _ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error + _ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error + _ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error + for i := range newFiles { + _ = os.Remove(imp.s.FilePath(&newFiles[i])) + } + if coverDisk != "" { + _ = os.Remove(coverDisk) + } +} + +// ---------- ZIP 工具 ---------- + +// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段 +func cleanZipRel(rel string) (string, bool) { + p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/")) + if p == "." || p == "" || path.IsAbs(p) || p == ".." { + return "", false + } + for _, seg := range strings.Split(p, "/") { + if seg == ".." { + return "", false + } + } + return p, true +} + +func readZipJSON(zf *zip.File, v any) error { + rc, err := zf.Open() + if err != nil { + return err + } + defer rc.Close() + return json.NewDecoder(rc).Decode(v) +} diff --git a/backend/service/library_import_test.go b/backend/service/library_import_test.go new file mode 100644 index 0000000..fb2eda8 --- /dev/null +++ b/backend/service/library_import_test.go @@ -0,0 +1,354 @@ +package service + +import ( + "archive/zip" + "bytes" + "errors" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/freefire/jiang13-bbs/model" +) + +func encodeJPEGBytes(t *testing.T, w, h int) []byte { + t.Helper() + var buf bytes.Buffer + if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} + +// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件) +func writeExportZipFile(t *testing.T, exp *LibraryExport) string { + t.Helper() + p := filepath.Join(t.TempDir(), "export.zip") + f, err := os.Create(p) + if err != nil { + t.Fatalf("create temp zip: %v", err) + } + if err := exp.WriteZip(f); err != nil { + t.Fatalf("write export zip: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close temp zip: %v", err) + } + return p +} + +// writeZipFileMap 手工构造 ZIP(用于非法包测试) +func writeZipFileMap(t *testing.T, entries map[string]string) string { + t.Helper() + buf := new(bytes.Buffer) + zw := zip.NewWriter(buf) + for name, content := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create %s: %v", name, err) + } + if _, err := io.WriteString(w, content); err != nil { + t.Fatalf("zip write %s: %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + p := filepath.Join(t.TempDir(), "in.zip") + if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write temp zip: %v", err) + } + return p +} + +func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) { + t.Helper() + if rep == nil { + t.Fatal("报告为空") + } + if rep.Total != total { + t.Fatalf("total = %d, want %d", rep.Total, total) + } + if len(rep.Failed) != 0 { + t.Fatalf("存在失败记录: %+v", rep.Failed) + } + if len(rep.Imported) != total { + t.Fatalf("imported = %d, want %d", len(rep.Imported), total) + } +} + +// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入 +func TestLibraryImportBookRoundTrip(t *testing.T) { + // ---- 源库 ---- + src, _ := newLibraryTestService(t) + srcUploads := t.TempDir() + src.WithUploadsDir(srcUploads) + docID, storedName := seedBookForExport(t, src, srcUploads) + + // 制造两次下载,验证计数随包保留 + var srcFile model.LibraryFile + if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil { + t.Fatalf("find source file: %v", err) + } + src.IncrDownload(srcFile.ID) + src.IncrDownload(srcFile.ID) + + exp, err := src.BuildBookExport(docID) + if err != nil { + t.Fatalf("build export: %v", err) + } + zipPath := writeExportZipFile(t, exp) + exp.Close() + + // ---- 目标库(空库)---- + dst, _ := newLibraryTestService(t) + dstUploads := t.TempDir() + dst.WithUploadsDir(dstUploads) + + // 1) create 导入 + rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7) + if err != nil { + t.Fatalf("import: %v", err) + } + assertReportOK(t, rep, 1) + item := rep.Imported[0] + if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 { + t.Fatalf("导入条目异常: %+v", item) + } + + var doc model.LibraryDoc + if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil { + t.Fatalf("find imported doc: %v", err) + } + if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published { + t.Fatalf("元信息恢复不正确: %+v", doc) + } + if doc.Author != "老子" { + t.Fatalf("作者恢复不正确: %q", doc.Author) + } + if doc.CreatorID != 7 { + t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID) + } + if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" { + t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL) + } + if doc.CoverWidth != 120 || doc.CoverHeight != 160 { + t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight) + } + coverDisk, ok := dst.resolveCoverPath(doc.CoverURL) + if !ok { + t.Fatalf("封面路径无法解析: %s", doc.CoverURL) + } + coverData, err := os.ReadFile(coverDisk) + if err != nil { + t.Fatalf("封面未落盘: %v", err) + } + if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) { + t.Fatal("封面内容不是 JPEG") + } + + // 章节树:章 + 小节父子关系 + secs, err := dst.ListSections(doc.ID) + if err != nil { + t.Fatalf("list sections: %v", err) + } + if len(secs) != 2 { + t.Fatalf("章节数 = %d, want 2", len(secs)) + } + var chapter, child *model.LibrarySection + for i := range secs { + if secs[i].ParentID == nil { + chapter = &secs[i] + } else { + child = &secs[i] + } + } + if chapter == nil || child == nil { + t.Fatalf("章节层级不正确: %+v", secs) + } + if chapter.Title != "第一章" || chapter.SortOrder != 0 { + t.Fatalf("章信息不正确: %+v", chapter) + } + if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 { + t.Fatalf("小节信息不正确: %+v", child) + } + + // 附件:重新随机落盘、内容与下载计数保留 + var files []model.LibraryFile + if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil { + t.Fatalf("list files: %v", err) + } + if len(files) != 1 { + t.Fatalf("附件数 = %d, want 1", len(files)) + } + nf := files[0] + if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName { + t.Fatalf("附件恢复不正确: %+v", nf) + } + data, err := os.ReadFile(dst.FilePath(&nf)) + if err != nil { + t.Fatalf("附件未落盘: %v", err) + } + if string(data) != "EPUB-FILE-BYTES" { + t.Fatalf("附件内容不正确: %q", data) + } + firstCoverURL := doc.CoverURL + + // 2) 再次 create:冲突跳过 + rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7) + if err != nil { + t.Fatalf("re-import: %v", err) + } + if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 { + t.Fatalf("冲突报告异常: %+v", rep2) + } + if rep2.Failed[0].Reason != libraryImportReasonConflict { + t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason) + } + var docCount int64 + dst.db.Model(&model.LibraryDoc{}).Count(&docCount) + if docCount != 1 { + t.Fatalf("冲突跳过后书库数量异常: %d", docCount) + } + + // 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换 + extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES"))) + if err != nil { + t.Fatalf("add extra: %v", err) + } + extraPath := dst.FilePath(extra) + if _, err := os.Stat(extraPath); err != nil { + t.Fatalf("extra 未先落盘: %v", err) + } + + rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7) + if err != nil { + t.Fatalf("overwrite import: %v", err) + } + assertReportOK(t, rep3, 1) + if rep3.Imported[0].Action != "overwritten" { + t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action) + } + + dst.db.Model(&model.LibraryDoc{}).Count(&docCount) + if docCount != 1 { + t.Fatalf("覆盖后书库数量异常: %d", docCount) + } + var doc2 model.LibraryDoc + if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil { + t.Fatalf("find overwritten doc: %v", err) + } + if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 { + t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err) + } + if doc2.Author != "老子" || doc2.CreatorID != 7 { + t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID) + } + var files2 []model.LibraryFile + if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 { + t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err) + } + if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 { + t.Fatalf("覆盖后附件内容异常: %+v", files2[0]) + } + if _, err := os.Stat(extraPath); !os.IsNotExist(err) { + t.Fatalf("旧附件磁盘文件应被删除,err=%v", err) + } + oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL) + if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) { + t.Fatalf("旧封面磁盘文件应被删除,err=%v", err) + } + if doc2.CoverURL == firstCoverURL { + t.Fatal("覆盖后封面应重新落盘为新文件") + } + newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL) + if _, err := os.ReadFile(newCoverDisk); err != nil { + t.Fatalf("新封面未落盘: %v", err) + } +} + +// TestLibraryImportAll 全库包往返 +func TestLibraryImportAll(t *testing.T) { + src, _ := newLibraryTestService(t) + srcUploads := t.TempDir() + src.WithUploadsDir(srcUploads) + if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil { + t.Fatalf("create second doc: %v", err) + } + seedBookForExport(t, src, srcUploads) + + exp, err := src.BuildAllExport() + if err != nil { + t.Fatalf("build all export: %v", err) + } + zipPath := writeExportZipFile(t, exp) + exp.Close() + + dst, _ := newLibraryTestService(t) + dst.WithUploadsDir(t.TempDir()) + rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1) + if err != nil { + t.Fatalf("import all: %v", err) + } + assertReportOK(t, rep, 2) + slugs := map[string]bool{} + for _, it := range rep.Imported { + slugs[it.Slug] = true + } + if !slugs["dao-jia"] || !slugs["second-book"] { + t.Fatalf("导入书集不正确: %+v", slugs) + } +} + +// TestLibraryImportRejectBadPackage 非法包整体拒绝 +func TestLibraryImportRejectBadPackage(t *testing.T) { + s, _ := newLibraryTestService(t) + s.WithUploadsDir(t.TempDir()) + + badPath := filepath.Join(t.TempDir(), "bad.zip") + if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) { + t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err) + } + + noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"}) + if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) { + t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err) + } + + if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) { + t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err) + } +} + +// TestCleanZipRel 路径穿越校验 +func TestCleanZipRel(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {".", false}, + {"..", false}, + {"../evil", false}, + {"a/../../b", false}, + {"/etc/passwd", false}, + {`docs\..\..\x`, false}, + {"files/a.epub", true}, + {"docs/x/book.json", true}, + {`docs\x/cover.jpg`, true}, + {"a//b", true}, + } + for _, tc := range cases { + _, ok := cleanZipRel(tc.in) + if ok != tc.want { + t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want) + } + } +} diff --git a/backend/service/library_test.go b/backend/service/library_test.go index a11bf69..7fb7ab7 100644 --- a/backend/service/library_test.go +++ b/backend/service/library_test.go @@ -18,7 +18,7 @@ func newLibraryTestService(t *testing.T) (*LibraryService, string) { if err != nil { t.Fatalf("open sqlite: %v", err) } - if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil { t.Fatalf("migrate: %v", err) } dir := t.TempDir() @@ -45,6 +45,7 @@ func TestLibraryNormalize(t *testing.T) { {"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"}, {"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"}, {"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"}, + {"作者超限", func(i *LibraryInput) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"}, {"合法", func(i *LibraryInput) {}, ""}, } for _, tc := range cases { @@ -63,6 +64,58 @@ func TestLibraryNormalize(t *testing.T) { } } +func TestLibraryAuthorAndCreator(t *testing.T) { + s, _ := newLibraryTestService(t) + user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"} + if err := s.db.Create(user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + pub := true + doc, err := s.Create(&LibraryInput{ + Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub, + }, user.ID) + if err != nil { + t.Fatalf("create: %v", err) + } + if doc.Author != "鲁迅" || doc.CreatorID != user.ID { + t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc) + } + + list, err := s.ListPublished() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].Author != "鲁迅" { + t.Fatalf("列表作者异常: %+v", list) + } + if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" { + t.Fatalf("列表创建者摘要异常: %+v", c) + } + + detail, err := s.GetPublishedBySlug("authored") + if err != nil { + t.Fatalf("detail: %v", err) + } + if detail.Creator == nil || detail.Creator.Username != "curator" { + t.Fatalf("详情创建者摘要异常: %+v", detail.Creator) + } + + // 作者可清空;更新不改变创建者归属 + if _, err := s.Update(doc.ID, &LibraryInput{ + Slug: "authored", Title: "署名本", Author: "", Published: &pub, + }); err != nil { + t.Fatalf("update: %v", err) + } + var again model.LibraryDoc + if err := s.db.First(&again, doc.ID).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if again.Author != "" || again.CreatorID != user.ID { + t.Fatalf("更新后作者/创建者异常: %+v", again) + } +} + func TestLibrarySlugUnique(t *testing.T) { s, _ := newLibraryTestService(t) if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil { diff --git a/backend/service/media_library.go b/backend/service/media_library.go index a3d24ac..a88a4a7 100644 --- a/backend/service/media_library.go +++ b/backend/service/media_library.go @@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct { {"brand", "brand", "品牌资源"}, } +// 衍生分类:不对应 uploads 子目录,按附件 source 归类 +const ( + mediaCategoryBook = "book" + mediaCategoryBookName = "书籍" +) + +// mediaCategoryForAttachment 附件在媒体库中的展示分类: +// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”, +// 其余按 kind 对应磁盘目录分类。 +func mediaCategoryForAttachment(kind, source string) (key, name string) { + if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent { + return mediaCategoryBook, mediaCategoryBookName + } + for _, cat := range mediaLibraryCategories { + if cat.Key == kind { + return cat.Key, cat.Name + } + } + return kind, kind +} + // MediaLibraryItem 媒体库条目 type MediaLibraryItem struct { URL string `json:"url"` // 展示地址(有 WebP 时为 WebP) @@ -53,6 +74,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, // 附件元数据索引:URL → 记录(联出上传者昵称) type attMeta struct { Kind string + Source string URL string MIME string Size int @@ -64,7 +86,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, } 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"). + Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username"). Joins("LEFT JOIN users ON users.id = attachments.user_id"). Scan(&rows).Error; err != nil { return nil, nil, err @@ -72,30 +94,22 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, 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 } + if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar { + continue // 未知 kind 不进媒体库 + } uploader := r.Nickname if uploader == "" { uploader = r.Username } - cat := r.Kind - if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar { - continue // 未知 kind 不进媒体库 - } + catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source) ca := r.CreatedAt items = append(items, MediaLibraryItem{ - URL: r.URL, Category: cat, CategoryName: categoryName(cat), + URL: r.URL, Category: catKey, CategoryName: catName, Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME, Size: int64(r.Size), Width: r.Width, Height: r.Height, Uploader: uploader, UploadedAt: &ca, @@ -153,6 +167,11 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, if m.Width > 0 { item.Width, item.Height = m.Width, m.Height } + // 书库封面 / 正文插图改归“书籍”分类 + if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent { + item.Category = mediaCategoryBook + item.CategoryName = mediaCategoryBookName + } } // 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0) if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 { @@ -243,6 +262,8 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) { postByURL := map[string]postRef{} type commentRef struct { id, postID uint + floor uint + isRoot bool } commentByURL := map[string]commentRef{} @@ -299,6 +320,19 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) { } } + // 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id}) + commentIDs := make([]uint, 0, len(commentByURL)) + for _, cm := range commentByURL { + commentIDs = append(commentIDs, cm.id) + } + commentAnchors := CommentAnchors(s.db, commentIDs) + for u, cm := range commentByURL { + if a, ok := commentAnchors[cm.id]; ok { + cm.floor, cm.isRoot = a.Floor, a.IsRoot + commentByURL[u] = cm + } + } + // 评论来源要带宿主帖子标题,一次性补齐 postTitle := func(id uint) string { var p model.Post @@ -374,7 +408,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) { break } if cm, ok := commentByURL[u]; ok { - it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id) + if cm.floor > 0 { + if cm.isRoot { + it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor) + } else { + it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id) + } + } else { + it.SourceURL = fmt.Sprintf("/post/%d", cm.postID) + } title := commentPostTitle[cm.postID] if title != "" { it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论" diff --git a/backend/service/media_library_test.go b/backend/service/media_library_test.go index 535a8b7..df1cb16 100644 --- a/backend/service/media_library_test.go +++ b/backend/service/media_library_test.go @@ -250,8 +250,7 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) { 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)) { + if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) { t.Fatalf("comment source url wrong: %+v", cimg) } if !strings.Contains(cimg.SourceLabel, "下的评论") { @@ -259,6 +258,63 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) { } } +func TestAdminMediaLibraryBookCategory(t *testing.T) { + s, dir := newMediaLibraryService(t) + + // 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图 + writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes")) + + if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil { + t.Fatalf("create user: %v", err) + } + var alice model.User + if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil { + t.Fatalf("load user: %v", err) + } + created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC) + mkAtt := func(source, url string) { + if err := s.db.Create(&model.Attachment{ + UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source, + URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10, + CreatedAt: created, + }).Error; err != nil { + t.Fatalf("create attachment %s: %v", source, err) + } + } + mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp") + mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content") + mkAtt(model.AttachmentSourcePost, "/api/media/post-image") + + items, counts, err := s.AdminMediaLibrary() + if err != nil { + t.Fatalf("AdminMediaLibrary: %v", err) + } + byURL := map[string]MediaLibraryItem{} + for _, it := range items { + byURL[it.URL] = it + } + + // 封面(磁盘)与正文插图(远程)都归“书籍” + for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} { + it, ok := byURL[u] + if !ok { + t.Fatalf("%s missing", u) + } + if it.Category != "book" || it.CategoryName != "书籍" { + t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName) + } + } + + // 普通帖子插图仍归 image + if post := byURL["/api/media/post-image"]; post.Category != "image" { + t.Fatalf("post image should stay image category, got %q", post.Category) + } + + if counts["book"] != 2 || counts["image"] != 1 { + t.Fatalf("counts wrong: %v", counts) + } +} + func TestMediaThumb(t *testing.T) { s, dir := newMediaLibraryService(t) diff --git a/backend/service/media_thumbs.go b/backend/service/media_thumbs.go index 2b32566..ca6624d 100644 --- a/backend/service/media_thumbs.go +++ b/backend/service/media_thumbs.go @@ -1,27 +1,19 @@ 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)由调用方回退原图。 +// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。 // MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够) const MediaThumbMaxSide = 480 @@ -70,12 +62,7 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) { 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)) - } + img, err := decodeImageBytes(data, ext == ".webp") if err != nil { return nil, err // 如动图 WebP,调用方回退原图 } @@ -94,21 +81,13 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) { 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) + dst := scaleImage(img, nw, nh) - var buf bytes.Buffer - if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil { + out, err := encodeLossyWebP(dst, VariantWebPQuality) + if 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 + _ = atomicWriteFile(thumbPath, out) + return out, nil } diff --git a/backend/service/operations_mail.go b/backend/service/operations_mail.go index eb60f31..2aadb7c 100644 --- a/backend/service/operations_mail.go +++ b/backend/service/operations_mail.go @@ -402,6 +402,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) { }) return 0, e } + const ( codeVerifyMaxAttempts = 5 codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致 diff --git a/backend/service/overview.go b/backend/service/overview.go index 9bbd1b8..02dd019 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -72,18 +72,18 @@ type NewUserItem struct { // OverviewData 首页聚合数据 type OverviewData struct { - Stats OverviewStats `json:"stats"` - Hot []PostListItem `json:"hot"` - ActiveUsers []ActiveUser `json:"active_users"` - Boards []BoardCount `json:"boards"` - Announcements []AnnouncementItem `json:"announcements"` - AnnouncementsTotal int64 `json:"announcements_total"` - SidebarPages []SidebarPageItem `json:"sidebar_pages"` - NewUsers []NewUserItem `json:"new_users"` - Checkin *CheckinStatus `json:"checkin,omitempty"` - Ads []PublicAdItem `json:"ads"` - AdsPanelTitle string `json:"ads_panel_title"` - AdsEnabled bool `json:"ads_enabled"` + Stats OverviewStats `json:"stats"` + Hot []PostListItem `json:"hot"` + ActiveUsers []ActiveUser `json:"active_users"` + Boards []BoardCount `json:"boards"` + Announcements []AnnouncementItem `json:"announcements"` + AnnouncementsTotal int64 `json:"announcements_total"` + SidebarPages []SidebarPageItem `json:"sidebar_pages"` + NewUsers []NewUserItem `json:"new_users"` + Checkin *CheckinStatus `json:"checkin,omitempty"` + Ads []PublicAdItem `json:"ads"` + AdsPanelTitle string `json:"ads_panel_title"` + AdsEnabled bool `json:"ads_enabled"` Sponsors []PublicSponsorItem `json:"sponsors"` SponsorsPanelTitle string `json:"sponsors_panel_title"` SponsorsEnabled bool `json:"sponsors_enabled"` diff --git a/backend/service/post.go b/backend/service/post.go index 7254055..e8de543 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -457,37 +457,37 @@ type PostAttachmentDTO struct { // PostDetail 帖子详情(含可见性裁剪与附件) type PostDetail struct { - ID uint `json:"id"` - BoardID uint `json:"board_id"` - UserID uint `json:"user_id"` - Title string `json:"title"` - Content string `json:"content"` - Tags string `json:"tags"` - PostType string `json:"post_type"` - ContentAccess string `json:"content_access"` - AccessPoints int `json:"access_points"` - TypeMeta string `json:"type_meta"` - TypeStatus string `json:"type_status,omitempty"` - Pinned int `json:"pinned"` - Recommended bool `json:"recommended"` - Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) - Status string `json:"status"` - LikeCount int `json:"like_count"` - ViewCount int `json:"view_count"` - CommentCount int `json:"comment_count"` - Liked bool `json:"liked"` - Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充) - Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示) + ID uint `json:"id"` + BoardID uint `json:"board_id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + Tags string `json:"tags"` + PostType string `json:"post_type"` + ContentAccess string `json:"content_access"` + AccessPoints int `json:"access_points"` + TypeMeta string `json:"type_meta"` + TypeStatus string `json:"type_status,omitempty"` + Pinned int `json:"pinned"` + Recommended bool `json:"recommended"` + Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免) + Status string `json:"status"` + LikeCount int `json:"like_count"` + ViewCount int `json:"view_count"` + CommentCount int `json:"comment_count"` + Liked bool `json:"liked"` + Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充) + Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示) // 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) LastReplyAt *time.Time `json:"last_reply_at,omitempty"` // 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框 - NecroReply *NecroReplyHint `json:"necro_reply,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Board model.Board `json:"board"` - User model.User `json:"user"` - ContentLocked bool `json:"content_locked"` - AccessHint string `json:"access_hint,omitempty"` + NecroReply *NecroReplyHint `json:"necro_reply,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Board model.Board `json:"board"` + User model.User `json:"user"` + ContentLocked bool `json:"content_locked"` + AccessHint string `json:"access_hint,omitempty"` Attachments []PostAttachmentDTO `json:"attachments"` Question *QuestionState `json:"question,omitempty"` Poll *PollState `json:"poll,omitempty"` @@ -620,7 +620,7 @@ func buildPostDetail(post *model.Post) *PostDetail { ContentAccess: model.NormalizeContentAccess(post.ContentAccess), AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta, TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta), - Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status, + Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status, LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount, Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt, Board: post.Board, User: post.User, diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index ce85d3e..003181a 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) ( type AcceptedAnswer struct { ID uint `json:"id"` Floor int `json:"floor"` + IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id} Content string `json:"content"` CreatedAt time.Time `json:"created_at"` Deleted bool `json:"deleted,omitempty"` @@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view ans := &AcceptedAnswer{ ID: c.ID, Floor: floor, + IsRoot: c.ParentID == nil, CreatedAt: c.CreatedAt, } staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID diff --git a/backend/service/site_page.go b/backend/service/site_page.go index d7a61a5..41009cd 100644 --- a/backend/service/site_page.go +++ b/backend/service/site_page.go @@ -12,7 +12,7 @@ import ( ) var ( - sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除") ) diff --git a/backend/service/timeline_release.go b/backend/service/timeline_release.go index c91d932..0bb9ff8 100644 --- a/backend/service/timeline_release.go +++ b/backend/service/timeline_release.go @@ -90,7 +90,7 @@ func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGit client := &http.Client{ Timeout: timelineGitHTTPTimeout, Transport: &http.Transport{ - DialContext: publicOnlyDial, + DialContext: publicOnlyDial, TLSHandshakeTimeout: timelineGitHTTPTimeout, ForceAttemptHTTP2: true, }, diff --git a/backend/service/upload.go b/backend/service/upload.go index 0fd3986..da40f61 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -238,7 +238,7 @@ func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, str } // SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image) -func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) { +func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) { if src == nil { return nil, errors.New("文件为空") } @@ -354,6 +354,7 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment att := &model.Attachment{ UserID: userID, Kind: model.AttachmentKindImage, + Source: normalizeImageSource(source), URL: "/uploads/images/" + storeName, MIME: storeMime, Size: int(storeSize), @@ -585,9 +586,20 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri return s.SaveBackground(f) } +// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post +func normalizeImageSource(s string) string { + switch s { + case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent: + return s + default: + return model.AttachmentSourcePost + } +} + // CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件) // 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用 -func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.Attachment, error) { +// source 决定副本的来源标记(如 library_cover) +func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) { var att model.Attachment if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil { return nil, errors.New("图片不存在或不属于你") @@ -600,7 +612,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At return nil, e } defer r.Close() - return s.SaveImage(userID, r) + return s.SaveImage(userID, r, source) } abs, ok := s.safeUploadPath(att.URL) if !ok { @@ -611,7 +623,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At return nil, errors.New("读取图片失败") } defer f.Close() - return s.SaveImage(userID, f) + return s.SaveImage(userID, f, source) } // BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内 @@ -667,6 +679,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) { return list, err } +// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error) +func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) { + var att model.Attachment + if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil { + return nil, err + } + return &att, nil +} + // ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除 var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除") diff --git a/backend/service/upload_webp_test.go b/backend/service/upload_webp_test.go index 7d6f967..71e8aee 100644 --- a/backend/service/upload_webp_test.go +++ b/backend/service/upload_webp_test.go @@ -60,7 +60,7 @@ func TestSaveImageTranscodesJPEGToWebP(t *testing.T) { t.Fatalf("encode jpeg: %v", err) } - att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post") if err != nil { t.Fatalf("SaveImage: %v", err) } @@ -98,7 +98,7 @@ func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) { t.Fatalf("encode png: %v", err) } - att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post") if err != nil { t.Fatalf("SaveImage: %v", err) } @@ -133,7 +133,7 @@ func TestSaveImageKeepsGIFAsIs(t *testing.T) { t.Fatalf("encode gif: %v", err) } - att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post") if err != nil { t.Fatalf("SaveImage: %v", err) } @@ -156,7 +156,7 @@ func TestSaveImageKeepsWebPAsIs(t *testing.T) { t.Fatalf("encode webp: %v", err) } - att, err := s.SaveImage(1, bytes.NewReader(in.Bytes())) + att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post") if err != nil { t.Fatalf("SaveImage: %v", err) } diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index e78de86..f21354b 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -9,7 +9,7 @@ import { } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; -import { isAdminOrAbove } from "@/lib/roles"; +import { hasPerm, PERMS } from "@/lib/roles"; import { formatDate } from "@/lib/format"; import { pageGlyph } from "@/lib/pageGlyph"; import { @@ -94,7 +94,7 @@ export default async function AboutIndexPage() { const announcements = overview?.announcements ?? []; const siteLead = settings.site_description?.trim() || undefined; const urlStyle = settings.url_style; - const canManagePages = !!me.user && isAdminOrAbove(me.user.role); + const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES); const left = ; const right = ; diff --git a/frontend/app/admin/ads/AdsAdmin.tsx b/frontend/app/admin/ads/AdsAdmin.tsx index be84fc1..dad92f1 100644 --- a/frontend/app/admin/ads/AdsAdmin.tsx +++ b/frontend/app/admin/ads/AdsAdmin.tsx @@ -46,6 +46,7 @@ import Modal from "@/components/Modal"; import { AdText } from "@/components/AdText"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; +import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage"; type StatusFilter = "" | "pending" | "active" | "rejected" | "expired"; type SectionId = "overview" | "queue" | "promote" | "sponsors"; @@ -882,8 +883,14 @@ function AdCard({ {/* 预览缩略 */}
{ad.kind === "image" && ad.image_url ? ( + // 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址 // eslint-disable-next-line @next/next/no-img-element - + ) : ad.kind === "text" && ad.title ? (
@@ -990,8 +997,14 @@ function PaymentEditor({ {/* 二维码:点击即上传 */}
@@ -636,56 +799,58 @@ export default function LibraryAdmin({