diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 29c1aa6..83b935f 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -15,15 +15,15 @@ import ( ) var ( - accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟 - refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天 + accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天 + refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天 ) // setAuthCookies 设置认证 cookie: -// - access token(HttpOnly,15min) -// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC +// - access token(HttpOnly,7天) +// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC // 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费) -// - CSRF token(JS 可读,7天,双提交校验) +// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验) // // SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站 // 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求 @@ -273,7 +273,8 @@ func (h *Handlers) Refresh(c *gin.Context) { // Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义, // 不影响该用户其他设备的登录态),并清除所有认证 cookie。 -// access JWT 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。 +// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false, +// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。 func (h *Handlers) Logout(c *gin.Context) { if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" { _ = h.Auth.RevokeRefreshToken(refreshToken) diff --git a/backend/handler/friendlink.go b/backend/handler/friendlink.go new file mode 100644 index 0000000..d27584e --- /dev/null +++ b/backend/handler/friendlink.go @@ -0,0 +1,208 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +// currentUserID 从 gin 上下文取登录用户 ID;RequireAuth 已保证存在 +func currentUserID(c *gin.Context) (uint, bool) { + v, ok := c.Get("user") + if !ok { + return 0, false + } + claims, ok := v.(*service.UserClaims) + if !ok || claims.ID == 0 { + return 0, false + } + return claims.ID, true +} + +// GET /api/friend-links 公开列表(仅 approved) +func (h *Handlers) GetFriendLinks(c *gin.Context) { + links, err := h.FriendLink.ListApproved() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"links": links}) +} + +// POST /api/friend-links/apply 登录用户提交申请 +func (h *Handlers) ApplyFriendLink(c *gin.Context) { + uid, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + var in service.FriendLinkInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + fl, err := h.FriendLink.Apply(in, uid) + if err != nil { + if errors.Is(err, service.ErrFriendLinkInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "提交申请失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"link": fl}) +} + +// GET /api/friend-links/mine 当前登录用户自己的申请记录(含待审/拒绝与拒绝理由) +func (h *Handlers) MyFriendLinks(c *gin.Context) { + uid, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + links, err := h.FriendLink.ListMine(uid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取申请记录失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"links": links}) +} + +// PUT /api/friend-links/:id 申请人修改自己的申请;已通过/已驳回的修改后回退待审 +func (h *Handlers) OwnerUpdateFriendLink(c *gin.Context) { + uid, ok := currentUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + var in service.FriendLinkInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.FriendLink.OwnerUpdate(uint(id), uid, in); err != nil { + if errors.Is(err, service.ErrFriendLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"}) + return + } + if errors.Is(err, service.ErrFriendLinkInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存修改失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// GET /api/admin/friend-links 后台全量列表 +func (h *Handlers) AdminListFriendLinks(c *gin.Context) { + links, err := h.FriendLink.ListAll() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取友情链接失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"links": links}) +} + +// POST /api/admin/friend-links 站长直接新建(status=approved) +func (h *Handlers) AdminCreateFriendLink(c *gin.Context) { + var in service.FriendLinkInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + fl, err := h.FriendLink.AdminCreate(in) + if err != nil { + if errors.Is(err, service.ErrFriendLinkInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建友情链接失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"link": fl}) +} + +// PUT /api/admin/friend-links/:id 全量更新内容(不改 status) +func (h *Handlers) AdminUpdateFriendLink(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + var in service.FriendLinkInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.FriendLink.AdminUpdate(uint(id), in); err != nil { + if errors.Is(err, service.ErrFriendLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"}) + return + } + if errors.Is(err, service.ErrFriendLinkInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": "友情链接参数无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新友情链接失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// POST /api/admin/friend-links/:id/status 审批:body {status, reject_reason?} +func (h *Handlers) AdminSetFriendLinkStatus(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + var body struct { + Status string `json:"status"` + RejectReason string `json:"reject_reason"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.FriendLink.AdminSetStatus(uint(id), body.Status, body.RejectReason); err != nil { + if errors.Is(err, service.ErrFriendLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"}) + return + } + if errors.Is(err, service.ErrFriendLinkInvalid) { + c.JSON(http.StatusBadRequest, gin.H{"error": "审批参数无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新状态失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// DELETE /api/admin/friend-links/:id 软删除 +func (h *Handlers) AdminDeleteFriendLink(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.FriendLink.AdminDelete(uint(id)); err != nil { + if errors.Is(err, service.ErrFriendLinkNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "友情链接不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除友情链接失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index bf2e9b4..9f65280 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -38,8 +38,10 @@ type Handlers struct { HidePwd *service.HidePasswordCookie Ads *service.AdService Sidebar *service.SidebarService + FriendLink *service.FriendLinkService Badge *service.BadgeService LeaderboardSvc *service.LeaderboardService + LibrarySvc *service.LibraryService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/library.go b/backend/handler/library.go new file mode 100644 index 0000000..97c086c --- /dev/null +++ b/backend/handler/library.go @@ -0,0 +1,391 @@ +package handler + +import ( + "errors" + "net/http" + "net/url" + "strconv" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ---------- 公开端 ---------- + +// LibraryList 已发布条目目录 +func (h *Handlers) LibraryList(c *gin.Context) { + list, err := h.LibrarySvc.ListPublished() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)}) +} + +// LibraryDetail 公开条目详情(按 slug,仅已发布) +func (h *Handlers) LibraryDetail(c *gin.Context) { + d, err := h.LibrarySvc.GetPublishedBySlug(c.Param("slug")) + if err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取条目失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"doc": d}) +} + +// LibraryFileDownload 强制下载(attachment;成功后计数) +func (h *Handlers) LibraryFileDownload(c *gin.Context) { + f, ok := h.libraryPublicFile(c) + if !ok { + return + } + h.LibrarySvc.IncrDownload(f.ID) + c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(f.Name)) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + ct := f.MIME + if ct == "" { + ct = "application/octet-stream" + } + c.Header("Content-Type", ct) + c.File(h.LibrarySvc.FilePath(f)) +} + +// libraryPublicFile 公开文件公共校验(存在 + 所属条目已发布) +func (h *Handlers) libraryPublicFile(c *gin.Context) (*model.LibraryFile, bool) { + id, err := strconv.ParseUint(c.Param("fid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"}) + return nil, false + } + f, err := h.LibrarySvc.GetPublicFile(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) + return nil, false + } + return f, true +} + +// ---------- 管理端 ---------- + +// AdminListLibraryDocs 后台条目列表(含未发布) +func (h *Handlers) AdminListLibraryDocs(c *gin.Context) { + list, err := h.LibrarySvc.ListAll() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取书库失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"docs": nonNilSlice(list)}) +} + +// AdminCreateLibraryDoc 新建条目 +func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) { + var in service.LibraryInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + d, err := h.LibrarySvc.Create(&in) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"doc": d}) +} + +// AdminUpdateLibraryDoc 编辑条目 +func (h *Handlers) AdminUpdateLibraryDoc(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + var in service.LibraryInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + d, err := h.LibrarySvc.Update(uint(id), &in) + if err != nil { + if errors.Is(err, service.ErrLibraryNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"doc": d}) +} + +// AdminDeleteLibraryDoc 删除条目(软删) +func (h *Handlers) AdminDeleteLibraryDoc(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + if err := h.LibrarySvc.Delete(uint(id)); err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminPurgeLibraryDoc 彻底删除条目(仅限已软删;清除章节/文件行与磁盘文件) +func (h *Handlers) AdminPurgeLibraryDoc(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + if err := h.LibrarySvc.Purge(uint(id)); err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + if errors.Is(err, service.ErrLibraryNotDeleted) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminUploadLibraryFile 上传文件到条目 +func (h *Handlers) AdminUploadLibraryFile(c *gin.Context) { + docID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + + maxBytes, err := h.Setting.AttachmentMaxBytes() + if err != nil || maxBytes < 1 { + maxBytes = service.FileMaxBytes + } + overhead := int64(64 << 10) // multipart 边界开销 + limit := maxBytes + overhead + if c.Request.ContentLength > limit { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件过大"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"}) + return + } + if file.Size > maxBytes { + mb := int(maxBytes >> 20) + if mb < 1 { + mb = 1 + } + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件不能超过 " + strconv.Itoa(mb) + "MB"}) + return + } + src, err := file.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"}) + return + } + defer src.Close() + + f, err := h.LibrarySvc.AddFile(uint(docID), claims.ID, file.Filename, src) + if err != nil { + if errors.Is(err, service.ErrLibraryExtDenied) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"file": f}) +} + +// AdminDeleteLibraryFile 删除条目文件 +func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("fid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"}) + return + } + if err := h.LibrarySvc.DeleteFile(uint(id)); err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ---------- 管理端:章节 ---------- + +// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节) +func (h *Handlers) AdminCreateLibrarySection(c *gin.Context) { + docID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + var in service.SectionInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + sec, err := h.LibrarySvc.CreateSection(uint(docID), &in) + if err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"section": sec}) +} + +// AdminUpdateLibrarySection 编辑章节标题与正文 +func (h *Handlers) AdminUpdateLibrarySection(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("sid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"}) + return + } + var in service.SectionInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + sec, err := h.LibrarySvc.UpdateSection(uint(id), &in) + if err != nil { + if errors.Is(err, service.ErrSectionNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"section": sec}) +} + +// AdminDeleteLibrarySection 删除章节(章级联删除其下小节) +func (h *Handlers) AdminDeleteLibrarySection(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("sid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"}) + return + } + if err := h.LibrarySvc.DeleteSection(uint(id)); err != nil { + if errors.Is(err, service.ErrSectionNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminMoveLibrarySection 章节上移/下移(同父兄弟间交换顺序) +func (h *Handlers) AdminMoveLibrarySection(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("sid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的章节 ID"}) + return + } + var in struct { + Direction string `json:"direction"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.LibrarySvc.MoveSection(uint(id), in.Direction); err != nil { + if errors.Is(err, service.ErrSectionNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "章节不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminImportLibraryMarkdown 导入 md 并按标题拆章 +func (h *Handlers) AdminImportLibraryMarkdown(c *gin.Context) { + docID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + var in struct { + Filename string `json:"filename"` + Content string `json:"content"` + SplitLevel string `json:"split_level"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + sections, err := h.LibrarySvc.ImportMarkdown(uint(docID), in.Filename, in.Content, in.SplitLevel) + if err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)}) +} + +// AdminImportLibraryMarkdownBatch 批量导入 md:多个文件按传入顺序依次拆章 +func (h *Handlers) AdminImportLibraryMarkdownBatch(c *gin.Context) { + docID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"}) + return + } + var in struct { + Files []service.ImportFileInput `json:"files"` + SplitLevel string `json:"split_level"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + sections, err := h.LibrarySvc.ImportMarkdownBatch(uint(docID), in.Files, in.SplitLevel) + if err != nil { + if errors.Is(err, service.ErrLibraryNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"created": len(sections), "sections": nonNilSlice(sections)}) +} diff --git a/backend/handler/setting.go b/backend/handler/setting.go index ce6dbb9..df1da53 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -73,10 +73,14 @@ type updateSettingsRequest struct { NecroReplyAfterHours *int `json:"necro_reply_after_hours"` NecroReplyPenalty *int `json:"necro_reply_penalty"` - Levels *[]model.LevelDef `json:"levels"` - LevelsFx *bool `json:"levels_fx"` + Levels *[]model.LevelDef `json:"levels"` + LevelsFx *bool `json:"levels_fx"` UrlStyle *string `json:"url_style"` + + MobileTabs *[]string `json:"mobile_tabs"` + + HeaderNav *[]service.HeaderNavItem `json:"header_nav"` } func settingsPayload(saved service.PublicSiteSettings) gin.H { @@ -87,9 +91,9 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "site_name": saved.SiteName, "site_description": saved.SiteDescription, "allow_register": saved.AllowRegister, - "allow_comments": saved.AllowComments, - "comments_require_login": saved.CommentsRequireLogin, - "allow_messages": saved.AllowMessages, + "allow_comments": saved.AllowComments, + "comments_require_login": saved.CommentsRequireLogin, + "allow_messages": saved.AllowMessages, "post_cooldown_hours": saved.PostCooldownHours, "code_block_auto_fold": saved.CodeBlockAutoFold, "code_block_fold_lines": saved.CodeBlockFoldLines, @@ -127,15 +131,19 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "points_reply_daily_cap": saved.PointsReplyDailyCap, "points_recommend_reward": saved.PointsRecommendReward, - "post_edit_lock_hours": saved.PostEditLockHours, - "comment_edit_lock_hours": saved.CommentEditLockHours, - "necro_reply_after_hours": saved.NecroReplyAfterHours, - "necro_reply_penalty": saved.NecroReplyPenalty, + "post_edit_lock_hours": saved.PostEditLockHours, + "comment_edit_lock_hours": saved.CommentEditLockHours, + "necro_reply_after_hours": saved.NecroReplyAfterHours, + "necro_reply_penalty": saved.NecroReplyPenalty, - "levels": saved.Levels, + "levels": saved.Levels, "levels_fx": saved.LevelsFx, "url_style": saved.UrlStyle, + + "mobile_tabs": saved.MobileTabs, + + "header_nav": saved.HeaderNav, } } @@ -159,7 +167,7 @@ func (req *updateSettingsRequest) hasAny() bool { req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil || req.PostEditLockHours != nil || req.CommentEditLockHours != nil || req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil || - req.UrlStyle != nil + req.UrlStyle != nil || req.MobileTabs != nil || req.HeaderNav != nil } // AdminGetSettings 超管读取站点设置(与公开 payload 字段一致) @@ -552,6 +560,26 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.MobileTabs != nil { + if err := h.Setting.SetMobileTabs(*req.MobileTabs); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "移动端底栏配置无效(至少保留一项,且取值须在允许列表内)"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.HeaderNav != nil { + if err := h.Setting.SetHeaderNav(*req.HeaderNav); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "顶部导航配置无效(最多 8 项,内置项须在允许列表内,自定义项名称 1–16 字、地址须为站内路径或 http(s))"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } saved, err := h.Setting.Public() if err != nil { diff --git a/backend/handler/site_page.go b/backend/handler/site_page.go index 5e0bbfd..142560c 100644 --- a/backend/handler/site_page.go +++ b/backend/handler/site_page.go @@ -101,3 +101,25 @@ func (h *Handlers) AdminDeleteSitePage(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) } + +// AdminPurgeSitePage 彻底删除单页(仅限已软删) +func (h *Handlers) AdminPurgeSitePage(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面 ID"}) + return + } + if err := h.SitePage.Purge(uint(id)); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) + return + } + if errors.Is(err, service.ErrSitePageNotDeleted) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/handler/timeline.go b/backend/handler/timeline.go index 0dcbe47..fc7a79f 100644 --- a/backend/handler/timeline.go +++ b/backend/handler/timeline.go @@ -27,3 +27,20 @@ func (h *Handlers) TimelineFromGit(c *gin.Context) { } c.JSON(http.StatusOK, result) } + +// TimelineFromReleases 从 Git releases 页 URL 导入时间线条目(登录 + CSRF + 限流) +func (h *Handlers) TimelineFromReleases(c *gin.Context) { + var req struct { + URLs []string `json:"urls"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + result, err := h.Setting.ImportTimelineFromReleases(req.URLs) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/handler/upload.go b/backend/handler/upload.go index a323e87..7af5ded 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -3,8 +3,11 @@ package handler import ( "errors" "io" + "mime" "net/http" + "path" "strconv" + "time" "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/service" @@ -311,6 +314,75 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"url": url}) } +// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目 +func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) { + var req struct { + AttachmentID uint `json:"attachment_id"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"}) + return + } + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att}) +} + +// brandAliasSlots 固定地址路径段 → 品牌槽位 +var brandAliasSlots = map[string]string{ + "logo-light": service.BrandSlotLight, + "logo-dark": service.BrandSlotDark, + "favicon": service.BrandSlotFavicon, +} + +// BrandImage 品牌图固定对外地址(GET /api/brand/logo-light|logo-dark|favicon): +// 友链等外部引用此地址,站点换图后按设置解析到当前图,地址永不变。 +func (h *Handlers) BrandImage(c *gin.Context) { + slot, ok := brandAliasSlots[c.Param("slot")] + if !ok { + c.Status(http.StatusNotFound) + return + } + var url string + var err error + switch slot { + case service.BrandSlotLight: + url, err = h.Setting.LogoLightURL() + case service.BrandSlotDark: + url, err = h.Setting.LogoDarkURL() + default: + url, err = h.Setting.FaviconURL() + } + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + if url == "" { + c.Status(http.StatusNotFound) + return + } + f, err := h.Upload.OpenBrandFile(url) + if err != nil { + c.Status(http.StatusNotFound) + return + } + defer f.Close() + // 扩展名定 Content-Type(SVG 不能靠嗅探);短缓存让换图在友链侧尽快生效 + if ct := mime.TypeByExtension(path.Ext(url)); ct != "" { + c.Header("Content-Type", ct) + } + c.Header("Cache-Control", "public, max-age=3600") + http.ServeContent(c.Writer, c.Request, path.Base(url), time.Time{}, f) +} + // AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据) func (h *Handlers) AdminMediaLibrary(c *gin.Context) { items, counts, err := h.Upload.AdminMediaLibrary() diff --git a/backend/handler/user.go b/backend/handler/user.go index d031f19..68774f0 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -130,9 +130,9 @@ func (h *Handlers) UserProfile(c *gin.Context) { } // 访客与该用户的关注关系(关注按钮初始态) userPayload["is_following"] = isFollowing - // 邮箱仅本人可见,打码展示(j***@163.com) + // 邮箱仅本人可见;仅本人访问时下发明文(用于账号信息展示与资料表单回填) if viewerID == user.ID && user.Email != "" { - userPayload["email"] = maskEmail(user.Email) + userPayload["email"] = user.Email } // 徽章墙(失败不阻塞资料返回) if h.Badge != nil { @@ -158,21 +158,6 @@ func (h *Handlers) UserProfile(c *gin.Context) { }) } -// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com) -func maskEmail(email string) string { - at := -1 - for i, r := range email { - if r == '@' { - at = i - break - } - } - if at <= 0 { - return "***" - } - return email[:1] + "***" + email[at:] -} - // UserComments 获取用户发表的评论列表(分页,含帖子标题) func (h *Handlers) UserComments(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) diff --git a/backend/model/db.go b/backend/model/db.go index e92ce42..af29fbc 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -62,8 +62,8 @@ func InitDB(dsn string) error { &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, &Badge{}, &UserBadge{}, - &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, - &ImportRecord{}, + &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{}, + &ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } diff --git a/backend/model/models.go b/backend/model/models.go index 5402af0..a761ca9 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -522,6 +522,52 @@ type SitePage struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +// LibraryDoc 书库条目(书籍/文档/纯 HTML 页展示;文件独立目录存储,仅经 API 消费) +type LibraryDoc struct { + ID uint `gorm:"primaryKey" json:"id"` + Slug string `gorm:"size:64;uniqueIndex;not null" json:"slug"` + Title string `gorm:"size:200;not null" json:"title"` + Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空 + CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images) + 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:"-"` +} + +// LibraryFile 书库文件(html/pdf/epub 等;存 data/library,不进公开静态目录) +type LibraryFile struct { + ID uint `gorm:"primaryKey" json:"id"` + DocID uint `gorm:"index;not null;default:0" json:"doc_id"` + UserID uint `gorm:"index;not null" json:"user_id"` // 上传管理员 + Name string `gorm:"size:256;not null" json:"name"` // 原始文件名 + StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名 + Ext string `gorm:"size:16;not null;default:''" json:"ext"` + MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"` + Size int `gorm:"not null;default:0" json:"size"` + DownloadCount int `gorm:"not null;default:0" json:"download_count"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// LibrarySection 书库章节(两级树:ParentID 空=章,非空=节;正文 markdown) +type LibrarySection struct { + ID uint `gorm:"primaryKey" json:"id"` + DocID uint `gorm:"index;not null;default:0" json:"doc_id"` + ParentID *uint `gorm:"index" json:"parent_id"` // nil=章;非空=节,父必须是同条目的章 + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text" json:"content"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + // SiteSetting 站点级键值设置(如历史在线峰值 peak_online) type SiteSetting struct { Key string `gorm:"primaryKey;size:64" json:"key"` @@ -542,6 +588,24 @@ type SidebarWidget struct { UpdatedAt time.Time `json:"updated_at"` } +// FriendLink 友情链接;申请审批制,status=pending 经管理员 approve 后前台可见 +type FriendLink struct { + ID uint `gorm:"primarykey" json:"id"` + Name string `gorm:"size:64;not null" json:"name"` + URL string `gorm:"size:512;not null" json:"url"` + Description string `gorm:"size:255" json:"description"` + LogoURL string `gorm:"size:512" json:"logo_url"` + Category string `gorm:"size:32;index" json:"category"` + ReciprocalURL string `gorm:"size:512" json:"reciprocal_url"` // 申请人填写的回链地址,供站长核对 + SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` + Status string `gorm:"size:16;not null;default:'pending';index" json:"status"` // pending|approved|rejected + ApplicantUserID *uint `gorm:"index" json:"applicant_user_id,omitempty"` // 后台直接新建时为 nil + RejectReason string `gorm:"size:255" json:"reject_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + // SiteDailyStats 站点日维度访问量(轻量 PV/UV) type SiteDailyStats struct { Date time.Time `gorm:"type:date;primaryKey" json:"date"` diff --git a/backend/router/router.go b/backend/router/router.go index 0e7fd6f..c60626d 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -101,6 +101,7 @@ 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) adminUserSvc := service.NewAdminUserService(model.DB) legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) @@ -118,6 +119,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { if err := postFileSvc.EnsureDir(); err != nil { return nil, err } + if err := librarySvc.EnsureDir(); err != nil { + return nil, err + } limiter := service.DefaultRateLimiter() // 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。 @@ -155,8 +159,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), Ads: adSvc, Sidebar: service.NewSidebarService(model.DB), + FriendLink: service.NewFriendLinkService(model.DB), Badge: badgeSvc, LeaderboardSvc: leaderboardSvc, + LibrarySvc: librarySvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) notifSvc.OnNotifyNew = func(userID uint) { @@ -180,6 +186,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 上传文件静态服务(data/uploads → /uploads) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) + // 品牌图固定地址(友链等外部引用):按站点设置解析到当前图,换图不变链 + r.GET("/api/brand/:slot", h.BrandImage) + // 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供) r.GET("/health", h.Health) @@ -194,6 +203,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar) pubAPI.GET("/overview", h.Overview) pubAPI.GET("/sidebar", h.GetSidebar) + pubAPI.GET("/friend-links", h.GetFriendLinks) pubAPI.GET("/posts", h.Posts) pubAPI.GET("/posts/:id", h.PostDetail) pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword) @@ -211,6 +221,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/settings", h.PublicSettings) pubAPI.GET("/leaderboard", h.Leaderboard) pubAPI.GET("/leaderboard/me", h.LeaderboardMe) + // 书库(公开浏览/下载;文件经专用端点消费,不进 /uploads 静态目录) + pubAPI.GET("/library", h.LibraryList) + pubAPI.GET("/library/:slug", h.LibraryDetail) + pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload) pubAPI.GET("/site-state", h.SiteState) pubAPI.GET("/media/:object", h.PublicObject) pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode) @@ -234,6 +248,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.GET("/me/login-devices", h.MyLoginDevices) api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice) api.PUT("/profile", h.UpdateProfile) + // 友情链接申请(登录用户;提交后进 pending 待审批) + api.POST("/friend-links/apply", h.ApplyFriendLink) + api.GET("/friend-links/mine", h.MyFriendLinks) + api.PUT("/friend-links/:id", h.OwnerUpdateFriendLink) api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost) api.PUT("/posts/:id", h.UpdatePost) api.GET("/posts/:id/history", h.PostEditHistory) @@ -281,6 +299,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 时间线:从 Git commits 页导入(登录用户;适配器由超管配置) api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit) + // 时间线:从 Git releases 页导入更新日志 + api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases) // 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403 chatAPI := api.Group("/chat", h.RequireMessagesOpen) @@ -367,6 +387,27 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { 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)) + { + libraryAPI.GET("/docs", h.AdminListLibraryDocs) + libraryAPI.POST("/docs", h.AdminCreateLibraryDoc) + libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc) + libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc) + libraryAPI.DELETE("/docs/:id/purge", h.AdminPurgeLibraryDoc) + libraryAPI.POST("/docs/:id/files", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadLibraryFile) + libraryAPI.DELETE("/files/:fid", h.AdminDeleteLibraryFile) + // 章节(两级树:章 → 节) + libraryAPI.POST("/docs/:id/sections", h.AdminCreateLibrarySection) + libraryAPI.PUT("/sections/:sid", h.AdminUpdateLibrarySection) + libraryAPI.DELETE("/sections/:sid", h.AdminDeleteLibrarySection) + libraryAPI.POST("/sections/:sid/move", h.AdminMoveLibrarySection) + libraryAPI.POST("/docs/:id/import-md", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdown) + libraryAPI.POST("/docs/:id/import-md-batch", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibraryMarkdownBatch) + libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia) + } // 广告位管理(管理员及以上) adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) @@ -400,6 +441,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget) staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget) + // 友情链接管理(超级管理员/站长):CRUD + 审批 + friendLinkAPI := staffAPI.Group("/friend-links", authMW.RequirePerm(service.PermSettings)) + { + friendLinkAPI.GET("", h.AdminListFriendLinks) + friendLinkAPI.POST("", h.AdminCreateFriendLink) + friendLinkAPI.PUT("/:id", h.AdminUpdateFriendLink) + friendLinkAPI.POST("/:id/status", h.AdminSetFriendLinkStatus) + friendLinkAPI.DELETE("/:id", h.AdminDeleteFriendLink) + } + // 站点设置(超级管理员/站长) staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings) opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings)) diff --git a/backend/service/auth.go b/backend/service/auth.go index b4bd0ab..22b3e42 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -24,10 +24,15 @@ const ( // CSRFHeaderName 前端传递 CSRF token 的 header 名 CSRFHeaderName = "X-CSRF-Token" - // AccessTokenTTL access token 有效期(短期,降低被盗窗口) - AccessTokenTTL = 15 * time.Minute - // RefreshTokenTTL refresh token 有效期(长期) - RefreshTokenTTL = 7 * 24 * time.Hour + // AccessTokenTTL access token 有效期。安全性由每请求实时校验 + // (token_version + 封禁 + 会话家族活性)保证,吊销即时生效,故取 7 天: + // 登录后 7 天内即使零轮转也保证不掉线,覆盖移动端长期待机场景。 + // (15min 短期 TTL 时代待机后必删 cookie,唤醒恢复全靠轮转链, + // 是手机端弹"登录已失效"的主因) + AccessTokenTTL = 7 * 24 * time.Hour + // RefreshTokenTTL refresh token 有效期(每次轮转重置 = 活跃用户滑动续期), + // 30 天内有过访问即持续在线;连续 30 天完全不访问才需重新登录 + RefreshTokenTTL = 30 * 24 * time.Hour // RefreshRotationGrace 轮转宽限期:同枚旧 refresh 在此窗口内被并发重放时, // 返回同一个新 token 对(放行),超过窗口的重放视为盗用(OAuth BCP 模式) RefreshRotationGrace = 60 * time.Second diff --git a/backend/service/brand.go b/backend/service/brand.go index 0de2ca9..553567d 100644 --- a/backend/service/brand.go +++ b/backend/service/brand.go @@ -24,6 +24,12 @@ const ( BrandSlotDark = "logo_dark" BrandSlotFavicon = "favicon" + // 固定对外地址:外部(友链等)引用这些路径,站点换图后地址不变。 + // 实际文件仍存随机名,请求时按站点设置解析到当前图。 + BrandAliasPathLight = "/api/brand/logo-light" + BrandAliasPathDark = "/api/brand/logo-dark" + BrandAliasPathFavicon = "/api/brand/favicon" + BrandMarkImageText = "image_text" BrandMarkImage = "image" BrandMarkText = "text" diff --git a/backend/service/brand_store.go b/backend/service/brand_store.go index 839424d..cfd6587 100644 --- a/backend/service/brand_store.go +++ b/backend/service/brand_store.go @@ -183,6 +183,22 @@ func (s *UploadService) BrandFileExists(url string) bool { return err == nil && !info.IsDir() } +// OpenBrandFile 打开品牌图文件供对外服务;未设置或文件缺失返回 os.ErrNotExist +func (s *UploadService) OpenBrandFile(url string) (*os.File, error) { + if BrandRelPath(url) == "" { + return nil, os.ErrNotExist + } + abs, ok := s.safeUploadPath(url) + if !ok { + return nil, os.ErrNotExist + } + f, err := os.Open(abs) + if err != nil { + return nil, os.ErrNotExist + } + return f, nil +} + // RemoveBrandIfUnused 旧品牌图不再被任一槽位引用时删除 func (s *UploadService) RemoveBrandIfUnused(oldURL, light, dark, favicon string) { if oldURL == "" || oldURL == light || oldURL == dark || oldURL == favicon { diff --git a/backend/service/brand_test.go b/backend/service/brand_test.go index 59acfb5..ffe0966 100644 --- a/backend/service/brand_test.go +++ b/backend/service/brand_test.go @@ -1,6 +1,34 @@ package service -import "testing" +import ( + "errors" + "os" + "testing" +) + +func TestOpenBrandFile(t *testing.T) { + s, dir := newMediaLibraryService(t) + png := "/uploads/brand/0123456789abcdef0123456789abcdef.png" + if _, err := s.OpenBrandFile(png); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing file want ErrNotExist, got %v", err) + } + writeMediaFile(t, dir, "brand/0123456789abcdef0123456789abcdef.png", []byte("png")) + f, err := s.OpenBrandFile(png) + if err != nil { + t.Fatal(err) + } + f.Close() + for _, bad := range []string{ + "", + "/uploads/images/0123456789abcdef0123456789abcdef.png", + "/uploads/brand/../avatars/x.png", + "http://evil.test/x.png", + } { + if _, err := s.OpenBrandFile(bad); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("should reject %q, got %v", bad, err) + } + } +} func TestNormalizeBrandURL(t *testing.T) { png := "/uploads/brand/0123456789abcdef0123456789abcdef.png" diff --git a/backend/service/friendlink.go b/backend/service/friendlink.go new file mode 100644 index 0000000..b8d8945 --- /dev/null +++ b/backend/service/friendlink.go @@ -0,0 +1,305 @@ +package service + +import ( + "errors" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// 友情链接状态 +const ( + FriendLinkStatusPending = "pending" + FriendLinkStatusApproved = "approved" + FriendLinkStatusRejected = "rejected" +) + +// 字段长度上限 +const ( + friendLinkMaxName = 64 + friendLinkMaxURL = 512 + friendLinkMaxDescription = 255 + friendLinkMaxLogoURL = 512 + friendLinkMaxCategory = 32 + friendLinkMaxReciprocal = 512 + friendLinkMaxReject = 255 +) + +var ( + // ErrFriendLinkInvalid 参数校验失败 + ErrFriendLinkInvalid = errors.New("友情链接参数无效") + // ErrFriendLinkNotFound 记录不存在 + ErrFriendLinkNotFound = errors.New("友情链接不存在") +) + +// FriendLinkInput 申请/新建/更新共用入参(指针区分「未传」与「显式清空」由调用方决定) +type FriendLinkInput struct { + Name string `json:"name"` + URL string `json:"url"` + Description string `json:"description"` + LogoURL string `json:"logo_url"` + Category string `json:"category"` + ReciprocalURL string `json:"reciprocal_url"` +} + +// FriendLinkService 友情链接申请 + 后台 CRUD/审批 +type FriendLinkService struct { + db *gorm.DB +} + +func NewFriendLinkService(db *gorm.DB) *FriendLinkService { + return &FriendLinkService{db: db} +} + +// validateInput 校验并归一化入参;返回归一化后的值 +func validateFriendLinkInput(in FriendLinkInput) (FriendLinkInput, error) { + name := strings.TrimSpace(in.Name) + if name == "" || utf8.RuneCountInString(name) > friendLinkMaxName { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + rawURL := strings.TrimSpace(in.URL) + if rawURL == "" || len(rawURL) > friendLinkMaxURL { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + if u, err := url.Parse(rawURL); err != nil || u.Scheme == "" || u.Host == "" || + (u.Scheme != "http" && u.Scheme != "https") { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + desc := strings.TrimSpace(in.Description) + if utf8.RuneCountInString(desc) > friendLinkMaxDescription { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + logo := strings.TrimSpace(in.LogoURL) + if len(logo) > friendLinkMaxLogoURL { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + if logo != "" { + if u, err := url.Parse(logo); err != nil || u.Scheme == "" || u.Host == "" || + (u.Scheme != "http" && u.Scheme != "https") { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + } + category := strings.TrimSpace(in.Category) + if utf8.RuneCountInString(category) > friendLinkMaxCategory { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + reciprocal := strings.TrimSpace(in.ReciprocalURL) + if len(reciprocal) > friendLinkMaxReciprocal { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + if reciprocal != "" { + if u, err := url.Parse(reciprocal); err != nil || u.Scheme == "" || u.Host == "" || + (u.Scheme != "http" && u.Scheme != "https") { + return FriendLinkInput{}, ErrFriendLinkInvalid + } + } + return FriendLinkInput{ + Name: name, URL: rawURL, Description: desc, LogoURL: logo, + Category: category, ReciprocalURL: reciprocal, + }, nil +} + +// ListApproved 前台公开列表:仅 approved,按 sort_order ASC, id ASC +func (s *FriendLinkService) ListApproved() ([]model.FriendLink, error) { + var list []model.FriendLink + err := s.db.Where("status = ?", FriendLinkStatusApproved). + Order("sort_order ASC, id ASC").Find(&list).Error + if list == nil { + list = []model.FriendLink{} + } + return list, err +} + +// ListAll 后台全量列表(含 pending/rejected),按 status, sort_order, id +func (s *FriendLinkService) ListAll() ([]model.FriendLink, error) { + var list []model.FriendLink + err := s.db.Order("status ASC, sort_order ASC, id ASC").Find(&list).Error + if list == nil { + list = []model.FriendLink{} + } + return list, err +} + +// ListMine 申请人本人提交的全部申请(含待审/拒绝),按提交时间倒序 +func (s *FriendLinkService) ListMine(userID uint) ([]model.FriendLink, error) { + var list []model.FriendLink + err := s.db.Where("applicant_user_id = ?", userID). + Order("created_at DESC, id DESC").Find(&list).Error + if list == nil { + list = []model.FriendLink{} + } + return list, err +} + +// OwnerUpdate 申请人修改自己的申请(名称/链接/回链/LOGO);非待审状态的记录修改后回退待审 +func (s *FriendLinkService) OwnerUpdate(id, userID uint, in FriendLinkInput) error { + norm, err := validateFriendLinkInput(in) + if err != nil { + return err + } + return s.db.Transaction(func(tx *gorm.DB) error { + var fl model.FriendLink + if err := tx.First(&fl, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrFriendLinkNotFound + } + return err + } + // 仅申请人本人可改;他人访问统一按不存在处理,不暴露记录存在性 + if fl.ApplicantUserID == nil || *fl.ApplicantUserID != userID { + return ErrFriendLinkNotFound + } + updates := map[string]interface{}{ + "name": norm.Name, + "url": norm.URL, + "logo_url": norm.LogoURL, + "reciprocal_url": norm.ReciprocalURL, + "updated_at": time.Now(), + } + if fl.Status != FriendLinkStatusPending { + updates["status"] = FriendLinkStatusPending + updates["reject_reason"] = "" + } + return tx.Model(&fl).Updates(updates).Error + }) +} + +// nextSortOrder 取当前 approved 记录中最大 sort_order + 1(新建/审批通过时使用) +func (s *FriendLinkService) nextSortOrder() (int, error) { + var maxOrder int + err := s.db.Model(&model.FriendLink{}). + Where("status = ?", FriendLinkStatusApproved). + Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder) + if err != nil { + return 0, err + } + return maxOrder + 1, nil +} + +// Apply 用户申请:status=pending +func (s *FriendLinkService) Apply(in FriendLinkInput, applicantUserID uint) (*model.FriendLink, error) { + norm, err := validateFriendLinkInput(in) + if err != nil { + return nil, err + } + uid := applicantUserID + now := time.Now() + fl := &model.FriendLink{ + Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL, + Category: norm.Category, ReciprocalURL: norm.ReciprocalURL, + Status: FriendLinkStatusPending, SortOrder: 0, + ApplicantUserID: &uid, CreatedAt: now, UpdatedAt: now, + } + if err := s.db.Create(fl).Error; err != nil { + return nil, err + } + return fl, nil +} + +// AdminCreate 站长直接新建:status=approved,自动分配 sort_order +func (s *FriendLinkService) AdminCreate(in FriendLinkInput) (*model.FriendLink, error) { + norm, err := validateFriendLinkInput(in) + if err != nil { + return nil, err + } + order, err := s.nextSortOrder() + if err != nil { + return nil, err + } + now := time.Now() + fl := &model.FriendLink{ + Name: norm.Name, URL: norm.URL, Description: norm.Description, LogoURL: norm.LogoURL, + Category: norm.Category, ReciprocalURL: norm.ReciprocalURL, + Status: FriendLinkStatusApproved, SortOrder: order, + CreatedAt: now, UpdatedAt: now, + } + if err := s.db.Create(fl).Error; err != nil { + return nil, err + } + return fl, nil +} + +// AdminUpdate 全量更新内容(不改 status/sort_order/applicant) +func (s *FriendLinkService) AdminUpdate(id uint, in FriendLinkInput) error { + norm, err := validateFriendLinkInput(in) + if err != nil { + return err + } + res := s.db.Model(&model.FriendLink{}).Where("id = ?", id). + Updates(map[string]interface{}{ + "name": norm.Name, + "url": norm.URL, + "description": norm.Description, + "logo_url": norm.LogoURL, + "category": norm.Category, + "reciprocal_url": norm.ReciprocalURL, + "updated_at": time.Now(), + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrFriendLinkNotFound + } + return nil +} + +// AdminSetStatus 审批:approved 时自动补 sort_order;rejected 时写 reject_reason +func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string) error { + status = strings.TrimSpace(status) + if status != FriendLinkStatusApproved && status != FriendLinkStatusRejected && + status != FriendLinkStatusPending { + return ErrFriendLinkInvalid + } + if status == FriendLinkStatusRejected && utf8.RuneCountInString(rejectReason) > friendLinkMaxReject { + return ErrFriendLinkInvalid + } + + return s.db.Transaction(func(tx *gorm.DB) error { + var fl model.FriendLink + if err := tx.First(&fl, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrFriendLinkNotFound + } + return err + } + updates := map[string]interface{}{ + "status": status, + "updated_at": time.Now(), + "reject_reason": "", + } + switch status { + case FriendLinkStatusApproved: + if fl.SortOrder == 0 { + var maxOrder int + if err := tx.Model(&model.FriendLink{}). + Where("status = ? AND id <> ?", FriendLinkStatusApproved, id). + Select("COALESCE(MAX(sort_order), 0)").Row().Scan(&maxOrder); err != nil { + return err + } + updates["sort_order"] = maxOrder + 1 + } + case FriendLinkStatusRejected: + updates["reject_reason"] = rejectReason + case FriendLinkStatusPending: + // 切回 pending 视为下线,不动 sort_order + } + return tx.Model(&fl).Updates(updates).Error + }) +} + +// AdminDelete 软删除 +func (s *FriendLinkService) AdminDelete(id uint) error { + res := s.db.Delete(&model.FriendLink{}, id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrFriendLinkNotFound + } + return nil +} diff --git a/backend/service/header_nav.go b/backend/service/header_nav.go new file mode 100644 index 0000000..eee8e73 --- /dev/null +++ b/backend/service/header_nav.go @@ -0,0 +1,191 @@ +package service + +import ( + "encoding/json" + "strings" + "unicode/utf8" +) + +// 桌面端 Header 主导航(品牌名右侧的菜单)。 +// 配置为有序数组,元素顺序即展示顺序;内置项只存 key(文案/图标/地址由前端注册表派生, +// 支持伪静态风格),自定义项存文案与地址。后续新增内置页面只需在前后端白名单各注册一项。 +const ( + // SettingKeyHeaderNav 缺行/非法=内置默认三项;显式空数组 [] 表示全部隐藏 + SettingKeyHeaderNav = "header_nav" + + HeaderNavItemBuiltin = "builtin" + HeaderNavItemCustom = "custom" + + // MaxHeaderNavItems 菜单项总数上限(内置 + 自定义) + MaxHeaderNavItems = 8 + // MaxHeaderNavLabelRunes 自定义菜单文案长度上限 + MaxHeaderNavLabelRunes = 16 +) + +// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序); +// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。 +var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"} + +// HeaderNavItem 顶部导航菜单项: +// +// builtin:Key 取白名单,Label/URL 留空,地址与高亮由前端按 key 派生; +// custom:管理员自定义,Label 必填,URL 为站内路径或 http(s) 外链,NewTab 控制打开方式。 +type HeaderNavItem struct { + Type string `json:"type"` + Key string `json:"key,omitempty"` + Label string `json:"label,omitempty"` + URL string `json:"url,omitempty"` + NewTab bool `json:"new_tab,omitempty"` +} + +// DefaultHeaderNav 内置默认三项(缺行时回落) +func DefaultHeaderNav() []HeaderNavItem { + out := make([]HeaderNavItem, 0, len(HeaderNavBuiltinKeys)) + for _, k := range HeaderNavBuiltinKeys { + out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k}) + } + return out +} + +func headerNavBuiltinAllowed(k string) bool { + for _, valid := range HeaderNavBuiltinKeys { + if k == valid { + return true + } + } + return false +} + +// headerNavItemIn 持久化读取用:NewTab 用指针区分「未指定(外链默认新开)」与显式 false +type headerNavItemIn struct { + Type string `json:"type"` + Key string `json:"key"` + Label string `json:"label"` + URL string `json:"url"` + NewTab *bool `json:"new_tab"` +} + +// NormalizeHeaderNav 保存前严格校验:类型/内置 key 非法、自定义文案或地址非法直接报错; +// 内置项重复去重(首次出现为准,与 mobile_tabs 一致) +func NormalizeHeaderNav(raw []HeaderNavItem) ([]HeaderNavItem, error) { + if len(raw) > MaxHeaderNavItems { + return nil, ErrInvalidSiteSetting + } + out := make([]HeaderNavItem, 0, len(raw)) + seenBuiltin := make(map[string]struct{}, len(raw)) + for _, item := range raw { + switch strings.TrimSpace(item.Type) { + case HeaderNavItemBuiltin: + k := strings.TrimSpace(strings.ToLower(item.Key)) + if !headerNavBuiltinAllowed(k) { + return nil, ErrInvalidSiteSetting + } + if _, dup := seenBuiltin[k]; dup { + continue + } + seenBuiltin[k] = struct{}{} + out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k}) + case HeaderNavItemCustom: + label := strings.TrimSpace(item.Label) + if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes { + return nil, ErrInvalidSiteSetting + } + u, ok := NormalizeFooterURL(item.URL) + if !ok { + return nil, ErrInvalidSiteSetting + } + out = append(out, HeaderNavItem{ + Type: HeaderNavItemCustom, + Label: label, + URL: u, + NewTab: item.NewTab, + }) + default: + return nil, ErrInvalidSiteSetting + } + } + return out, nil +} + +// parseHeaderNavJSON 读取时宽容:坏 JSON 回落默认;单条非法跳过;内置重复跳过; +// 显式空数组解析为空切片(全部隐藏),由调用方与「缺行」区分 +func parseHeaderNavJSON(raw string) []HeaderNavItem { + var in []headerNavItemIn + if err := json.Unmarshal([]byte(raw), &in); err != nil { + return DefaultHeaderNav() + } + out := make([]HeaderNavItem, 0, len(in)) + seenBuiltin := make(map[string]struct{}, len(in)) + for _, item := range in { + switch strings.TrimSpace(item.Type) { + case HeaderNavItemBuiltin: + k := strings.TrimSpace(strings.ToLower(item.Key)) + if !headerNavBuiltinAllowed(k) { + continue + } + if _, dup := seenBuiltin[k]; dup { + continue + } + seenBuiltin[k] = struct{}{} + out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k}) + case HeaderNavItemCustom: + label := strings.TrimSpace(item.Label) + if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes { + continue + } + u, ok := NormalizeFooterURL(item.URL) + if !ok { + continue + } + newTab := false + if item.NewTab != nil { + newTab = *item.NewTab + } else if isExternalFooterURL(u) { + newTab = true + } + out = append(out, HeaderNavItem{Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: newTab}) + } + if len(out) >= MaxHeaderNavItems { + break + } + } + return out +} + +func headerNavEqual(a, b []HeaderNavItem) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// HeaderNav 读取顶部导航配置;缺行/非法回落默认三项,显式 [] 返回空切片 +func (s *SettingService) HeaderNav() []HeaderNavItem { + v, found, err := s.getValue(SettingKeyHeaderNav) + if err != nil || !found || strings.TrimSpace(v) == "" { + return DefaultHeaderNav() + } + return parseHeaderNavJSON(v) +} + +// SetHeaderNav 校验并保存顶部导航配置;与默认三项完全一致时删键保持「缺行=默认」语义, +// 空数组原样写入(表示全部隐藏) +func (s *SettingService) SetHeaderNav(items []HeaderNavItem) error { + out, err := NormalizeHeaderNav(items) + if err != nil { + return err + } + if headerNavEqual(out, DefaultHeaderNav()) { + return s.deleteKey(SettingKeyHeaderNav) + } + b, err := json.Marshal(out) + if err != nil { + return err + } + return s.putValue(SettingKeyHeaderNav, string(b)) +} diff --git a/backend/service/header_nav_test.go b/backend/service/header_nav_test.go new file mode 100644 index 0000000..72df41b --- /dev/null +++ b/backend/service/header_nav_test.go @@ -0,0 +1,164 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newHeaderNavTestService(t *testing.T) *SettingService { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.SiteSetting{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return NewSettingService(db) +} + +func TestHeaderNavDefaultWhenMissing(t *testing.T) { + s := newHeaderNavTestService(t) + got := s.HeaderNav() + if len(got) != 3 || + got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) || + got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) || + got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) { + t.Fatalf("missing row should fall back to builtin defaults, got %+v", got) + } + pub, err := s.Public() + if err != nil { + t.Fatal(err) + } + if len(pub.HeaderNav) != 3 { + t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav)) + } +} + +func TestSetHeaderNavRoundTrip(t *testing.T) { + s := newHeaderNavTestService(t) + want := []HeaderNavItem{ + {Type: HeaderNavItemBuiltin, Key: "library"}, + {Type: HeaderNavItemCustom, Label: "关于", URL: "/p/about", NewTab: false}, + {Type: HeaderNavItemBuiltin, Key: "home"}, + {Type: HeaderNavItemCustom, Label: "官网", URL: "https://example.com", NewTab: true}, + } + if err := s.SetHeaderNav(want); err != nil { + t.Fatal(err) + } + got := s.HeaderNav() + if len(got) != len(want) { + t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("item %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestSetHeaderNavEmptyHidesAll(t *testing.T) { + s := newHeaderNavTestService(t) + if err := s.SetHeaderNav(nil); err != nil { + t.Fatal(err) + } + if got := s.HeaderNav(); len(got) != 0 { + t.Fatalf("explicit empty list should hide all menus, got %+v", got) + } + var rows int64 + s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows) + if rows != 1 { + t.Fatalf("empty list should be persisted, rows=%d", rows) + } +} + +func TestSetHeaderNavDefaultDeletesKey(t *testing.T) { + s := newHeaderNavTestService(t) + if err := s.SetHeaderNav([]HeaderNavItem{{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}}); err != nil { + t.Fatal(err) + } + if err := s.SetHeaderNav(DefaultHeaderNav()); err != nil { + t.Fatal(err) + } + var rows int64 + s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows) + if rows != 0 { + t.Fatalf("default config should delete the row, rows=%d", rows) + } +} + +func TestNormalizeHeaderNavRejectsInvalid(t *testing.T) { + longLabel := strings.Repeat("字", MaxHeaderNavLabelRunes+1) + cases := [][]HeaderNavItem{ + {{Type: HeaderNavItemBuiltin, Key: "unknown"}}, + {{Type: "weird", Key: "home"}}, + {{Type: HeaderNavItemCustom, Label: "", URL: "/x"}}, + {{Type: HeaderNavItemCustom, Label: longLabel, URL: "/x"}}, + {{Type: HeaderNavItemCustom, Label: "x", URL: "javascript:alert(1)"}}, + {{Type: HeaderNavItemCustom, Label: "x", URL: "relative"}}, + {{Type: HeaderNavItemCustom, Label: "x", URL: "//evil.com/x"}}, + } + for i, items := range cases { + if _, err := NormalizeHeaderNav(items); !errors.Is(err, ErrInvalidSiteSetting) { + t.Fatalf("case %d should fail, got %v", i, err) + } + } + tooMany := make([]HeaderNavItem, MaxHeaderNavItems+1) + for i := range tooMany { + tooMany[i] = HeaderNavItem{Type: HeaderNavItemCustom, Label: "x", URL: "/x"} + } + if _, err := NormalizeHeaderNav(tooMany); !errors.Is(err, ErrInvalidSiteSetting) { + t.Fatalf("over-cap list should fail, got %v", err) + } +} + +func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) { + got, err := NormalizeHeaderNav([]HeaderNavItem{ + {Type: HeaderNavItemBuiltin, Key: "home"}, + {Type: HeaderNavItemBuiltin, Key: " HOME "}, + {Type: HeaderNavItemBuiltin, Key: "library"}, + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Key != "home" || got[1].Key != "library" { + t.Fatalf("builtin duplicates should be deduped, got %+v", got) + } +} + +func TestParseHeaderNavJSONTolerant(t *testing.T) { + // 坏 JSON 回落默认 + if got := parseHeaderNavJSON("{bad"); len(got) != 3 { + t.Fatalf("bad json should fall back to defaults, got %+v", got) + } + // 空数组 = 全部隐藏 + if got := parseHeaderNavJSON("[]"); len(got) != 0 { + t.Fatalf("empty array should be respected, got %+v", got) + } + // 单条非法跳过;内置重复跳过;外链 new_tab 缺省为 true + got := parseHeaderNavJSON(`[ + {"type":"builtin","key":"home"}, + {"type":"builtin","key":"nope"}, + {"type":"builtin","key":"home"}, + {"type":"custom","label":"","url":"/x"}, + {"type":"custom","label":"外链","url":"https://example.com"}, + {"type":"custom","label":"站内","url":"/p/about","new_tab":true} + ]`) + if len(got) != 3 { + t.Fatalf("want 3 valid items, got %+v", got) + } + if got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) { + t.Fatalf("item0 = %+v", got[0]) + } + if got[1].Type != HeaderNavItemCustom || got[1].Label != "外链" || !got[1].NewTab { + t.Fatalf("external custom item should default new_tab=true, got %+v", got[1]) + } + if got[2].Label != "站内" || !got[2].NewTab { + t.Fatalf("explicit new_tab=true should be kept, got %+v", got[2]) + } +} diff --git a/backend/service/leaderboard.go b/backend/service/leaderboard.go index c1a8ae5..426a109 100644 --- a/backend/service/leaderboard.go +++ b/backend/service/leaderboard.go @@ -14,7 +14,7 @@ const ( PeriodMonth = "month" PeriodAll = "all" - MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分 + MetricAll = "all" // 综合:总榜=累计积分,周/月=周期内获得积分 MetricPost = "post" // 发帖最多 MetricComment = "comment" // 回复最多 MetricLike = "like" // 获赞最多 @@ -112,6 +112,41 @@ func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRo return out, nil } +// earnedPoints 周期内累计获得积分:point_ledgers 正入账合计(排除 bounty_refund, +// 与 users.total_points 启动回填口径一致),仅统计未封禁用户。 +// 签到、发帖/回复奖励、被推荐、悬赏采纳等均按实际入账分值计入贡献值 +func (s *LeaderboardService) earnedPoints(from, to *time.Time) (map[uint]int64, error) { + clause := "" + var timeArgs []interface{} + if from != nil { + clause += " AND l.created_at >= ?" + timeArgs = append(timeArgs, *from) + } + if to != nil { + clause += " AND l.created_at < ?" + timeArgs = append(timeArgs, *to) + } + sql := "SELECT l.user_id AS user_id, SUM(l.delta) AS pts FROM point_ledgers l" + + " JOIN users AS u ON u.id = l.user_id" + + " WHERE l.delta > 0 AND l.reason <> ? AND u.banned = ? AND u.deleted_at IS NULL" + clause + + " GROUP BY l.user_id" + args := []interface{}{model.PointReasonBountyRefund, false} + args = append(args, timeArgs...) + + var rows []struct { + UserID uint + Pts int64 + } + if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[uint]int64, len(rows)) + for _, r := range rows { + out[r.UserID] = r.Pts + } + return out, nil +} + // answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数, // 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计) func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) { @@ -167,7 +202,7 @@ func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, e return stats, nil } -// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween) +// scoreOf 细分指标 → 排名得分;「全部」维度走积分口径(见 boardBetween / earnedPoints) func scoreOf(metric string, st statsRow) int64 { switch metric { case MetricPost: @@ -178,9 +213,8 @@ func scoreOf(metric string, st statsRow) int64 { return st.LikeCount case MetricAnswer: return st.AnswerCount - default: - return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount } + return 0 } // entryFromUser 由用户行构造榜单条目(不含统计) @@ -214,7 +248,8 @@ func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *tim } // boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。 -// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。 +// 「全部」维度走积分口径(总榜=total_points,周/月=周期内获得积分,见 earnedPoints), +// 细分维度走统计聚合口径。 func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) { if metric == MetricAll && from == nil { var users []model.User @@ -245,9 +280,29 @@ func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric st if err != nil { return nil, err } - entries := make([]LeaderboardEntry, 0, len(stats)) + // 周/月·全部:贡献值=周期内累计获得积分,候选集并入仅签到/获奖无内容的用户 + usePts := metric == MetricAll && from != nil + pts := map[uint]int64{} + if usePts { + if pts, err = s.earnedPoints(from, to); err != nil { + return nil, err + } + } + cand := make(map[uint]statsRow, len(stats)+len(pts)) for id, r := range stats { + cand[id] = r + } + for id := range pts { + if _, ok := cand[id]; !ok { + cand[id] = statsRow{} + } + } + entries := make([]LeaderboardEntry, 0, len(cand)) + for id, r := range cand { score := scoreOf(metric, r) + if usePts { + score = pts[id] + } if score <= 0 { continue } diff --git a/backend/service/leaderboard_test.go b/backend/service/leaderboard_test.go new file mode 100644 index 0000000..b0a70e1 --- /dev/null +++ b/backend/service/leaderboard_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "testing" + "time" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// 贡献值口径:earnedPoints 只累计窗口内正入账积分(排除 bounty_refund), +// 支出、悬赏退回、封禁用户、窗口外入账均不计 +func TestEarnedPointsWindowAndExclusions(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.User{}, &model.PointLedger{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + now := time.Now() + weekAgo := now.AddDate(0, 0, -7) + users := []*model.User{ + {Username: "earner", Password: "x"}, + {Username: "refund_only", Password: "x"}, + {Username: "banned", Password: "x", Banned: true}, + {Username: "old_earner", Password: "x"}, + } + for _, u := range users { + if err := db.Create(u).Error; err != nil { + t.Fatalf("seed user %s: %v", u.Username, err) + } + } + a, d := users[0].ID, users[3].ID + ledgers := []model.PointLedger{ + {UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, + {UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now}, + {UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)}, + {UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计 + {UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计 + {UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计 + {UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外 + } + if err := db.Create(&ledgers).Error; err != nil { + t.Fatalf("seed ledgers: %v", err) + } + + s := NewLeaderboardService(db) + from := weekAgo + pts, err := s.earnedPoints(&from, nil) + if err != nil { + t.Fatalf("earnedPoints: %v", err) + } + if len(pts) != 1 || pts[a] != 10 { + t.Fatalf("周窗内应仅 earner 计 10 分,got %v", pts) + } + + all, err := s.earnedPoints(nil, nil) + if err != nil { + t.Fatalf("earnedPoints(all): %v", err) + } + if len(all) != 2 || all[a] != 10 || all[d] != 5 { + t.Fatalf("全窗口:earner=10、old_earner=5,退回/封禁不计,got %v", all) + } +} diff --git a/backend/service/library.go b/backend/service/library.go new file mode 100644 index 0000000..a918210 --- /dev/null +++ b/backend/service/library.go @@ -0,0 +1,504 @@ +package service + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// 书库文件扩展名白名单(小写、不含点);附件仅提供下载分发, +// 书籍正文走章节阅读,不做在线预览 +var libraryAllowedExts = map[string]struct{}{ + "html": {}, "htm": {}, "pdf": {}, "epub": {}, "mobi": {}, "azw3": {}, + "zip": {}, "7z": {}, "rar": {}, "txt": {}, "md": {}, + "doc": {}, "docx": {}, "ppt": {}, "pptx": {}, +} + +const ( + MaxLibraryFilesPerDoc = 20 + libraryDescriptionMax = 20000 +) + +var ( + ErrLibraryNotFound = errors.New("书库条目不存在") + ErrLibraryExtDenied = errors.New("不支持该文档格式") + ErrLibraryTooMany = errors.New("文件数量超过上限") + ErrLibraryNotDeleted = errors.New("仅已删除的条目可彻底删除") +) + +// LibraryExtAllowed 上传扩展名是否在书库白名单内 +func LibraryExtAllowed(ext string) bool { + _, ok := libraryAllowedExts[strings.ToLower(strings.TrimPrefix(ext, "."))] + return ok +} + +// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields) +var libraryDocWriteFields = []string{ + "Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto", +} + +// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用) +type LibraryDocDetail struct { + model.LibraryDoc + Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型) + Files []model.LibraryFile `json:"files"` + Sections []model.LibrarySection `json:"sections"` +} + +// 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"` +} + +// LibraryInput 创建/更新书库条目入参 +type LibraryInput struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + CoverURL string `json:"cover_url"` + Published *bool `json:"published"` + SortOrder *int `json:"sort_order"` + EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改) +} + +func (in *LibraryInput) normalize() error { + in.Slug = strings.ToLower(strings.TrimSpace(in.Slug)) + if in.Slug == "" { + return errors.New("slug 不能为空") + } + if len(in.Slug) > 64 { + return errors.New("slug 不能超过 64 字符") + } + if !sitePageSlugRe.MatchString(in.Slug) { + return errors.New("slug 仅允许小写字母、数字与连字符") + } + + in.Title = strings.TrimSpace(in.Title) + if in.Title == "" { + return errors.New("标题不能为空") + } + if utf8.RuneCountInString(in.Title) > 200 { + return errors.New("标题不能超过 200 字") + } + + in.Description = strings.TrimSpace(in.Description) + if utf8.RuneCountInString(in.Description) > libraryDescriptionMax { + return errors.New("介绍不能超过 20000 字") + } + + in.CoverURL = strings.TrimSpace(in.CoverURL) + if utf8.RuneCountInString(in.CoverURL) > 512 { + return errors.New("封面地址过长") + } + + return nil +} + +func (in *LibraryInput) applyTo(d *model.LibraryDoc) { + d.Slug = in.Slug + d.Title = in.Title + d.Description = in.Description + d.CoverURL = in.CoverURL + if in.Published != nil { + d.Published = *in.Published + } + if in.SortOrder != nil { + d.SortOrder = *in.SortOrder + } + if in.EntriesAuto != nil { + d.EntriesAuto = *in.EntriesAuto + } +} + +// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览) +type LibraryService struct { + db *gorm.DB + dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html) + setting *SettingService +} + +func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService { + return &LibraryService{db: db, dir: filepath.Join(dataDir, "library")} +} + +// WithSetting 注入站点设置(文件大小上限复用附件设置) +func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService { + s.setting = setting + return s +} + +func (s *LibraryService) EnsureDir() error { + return os.MkdirAll(s.dir, 0o755) +} + +func (s *LibraryService) FilePath(f *model.LibraryFile) string { + return filepath.Join(s.dir, f.StoredName) +} + +// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引) +// active=在用条目占用;deleted=已删除条目占用 +func (s *LibraryService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) { + q := s.db.Unscoped().Model(&model.LibraryDoc{}).Where("slug = ?", slug).Select("deleted_at") + if excludeID > 0 { + q = q.Where("id <> ?", excludeID) + } + var rows []struct { + DeletedAt gorm.DeletedAt + } + if err := q.Find(&rows).Error; err != nil { + return false, false, err + } + for _, r := range rows { + if r.DeletedAt.Valid { + deleted = true + } else { + active = true + } + } + return active, deleted, nil +} + +// ---------- 条目:管理端 ---------- + +// ListAll 管理后台全部条目(含未发布与已软删,带文件;已删条目仅供查看与彻底删除) +func (s *LibraryService) ListAll() ([]LibraryDocDetail, error) { + var docs []model.LibraryDoc + if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(200).Find(&docs).Error; err != nil { + return nil, err + } + return s.attachFiles(docs), nil +} + +// Get 管理后台按 ID 取条目 +func (s *LibraryService) Get(id uint) (*LibraryDocDetail, error) { + var d model.LibraryDoc + if err := s.db.First(&d, id).Error; err != nil { + return nil, ErrLibraryNotFound + } + out := s.attachFiles([]model.LibraryDoc{d}) + return &out[0], nil +} + +func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail { + out := make([]LibraryDocDetail, 0, len(docs)) + if len(docs) == 0 { + return out + } + ids := make([]uint, 0, len(docs)) + for _, d := range docs { + ids = append(ids, d.ID) + } + var files []model.LibraryFile + if err := s.db.Where("doc_id IN ?", ids). + Order("sort_order ASC, id ASC").Find(&files).Error; err != nil { + files = nil + } + byDoc := make(map[uint][]model.LibraryFile, len(docs)) + for _, f := range files { + byDoc[f.DocID] = append(byDoc[f.DocID], f) + } + var sections []model.LibrarySection + if err := s.db.Where("doc_id IN ?", ids). + Order("sort_order ASC, id ASC").Find(§ions).Error; err != nil { + sections = nil + } + secByDoc := make(map[uint][]model.LibrarySection, len(docs)) + for _, sec := range sections { + secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec) + } + for _, d := range docs { + fs := byDoc[d.ID] + if fs == nil { + fs = []model.LibraryFile{} + } + ss := secByDoc[d.ID] + if ss == nil { + ss = []model.LibrarySection{} + } + out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss}) + } + return out +} + +func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) { + if err := in.normalize(); err != nil { + return nil, err + } + active, deleted, err := s.slugTaken(in.Slug, 0) + if err != nil { + return nil, err + } + if active { + return nil, errors.New("slug 已被占用") + } + if deleted { + return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用") + } + d := &model.LibraryDoc{Published: false, SortOrder: 0} + in.applyTo(d) + if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil { + return nil, err + } + return d, nil +} + +func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, error) { + var d model.LibraryDoc + if err := s.db.First(&d, id).Error; err != nil { + return nil, ErrLibraryNotFound + } + if err := in.normalize(); err != nil { + return nil, err + } + active, deleted, err := s.slugTaken(in.Slug, id) + if err != nil { + return nil, err + } + if active { + return nil, errors.New("slug 已被占用") + } + if deleted { + return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用") + } + in.applyTo(&d) + // map 更新确保 false/空串写入(同 SitePage.Update) + if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{ + "slug": d.Slug, + "title": d.Title, + "description": d.Description, + "cover_url": d.CoverURL, + "published": d.Published, + "sort_order": d.SortOrder, + "entries_auto": d.EntriesAuto, + }).Error; err != nil { + return nil, err + } + return &d, nil +} + +func (s *LibraryService) Delete(id uint) error { + result := s.db.Delete(&model.LibraryDoc{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrLibraryNotFound + } + return nil +} + +// Purge 彻底删除已软删的条目(章节/文件行与磁盘文件一并清除,slug 释放可复用) +func (s *LibraryService) Purge(id uint) error { + var d model.LibraryDoc + if err := s.db.Unscoped().First(&d, id).Error; err != nil { + return ErrLibraryNotFound + } + if !d.DeletedAt.Valid { + return ErrLibraryNotDeleted + } + // 先取出文件清单(含此前单独软删的文件行),事务成功后统一删磁盘 + var files []model.LibraryFile + if err := s.db.Unscoped().Where("doc_id = ?", id).Find(&files).Error; err != nil { + return err + } + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibrarySection{}).Error; err != nil { + return err + } + if err := tx.Unscoped().Where("doc_id = ?", id).Delete(&model.LibraryFile{}).Error; err != nil { + return err + } + return tx.Unscoped().Delete(&model.LibraryDoc{}, id).Error + }) + if err != nil { + return err + } + // 事务外尽力删除磁盘文件(同 DeleteFile 惯例,失败不影响返回) + for i := range files { + _ = os.Remove(s.FilePath(&files[i])) + } + return nil +} + +// ---------- 条目:公开端 ---------- + +// ListPublished 已发布条目目录(含文件概要,不含 description 全文) +func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) { + details, err := s.publishedDetails() + if err != nil { + return nil, err + } + out := make([]LibraryDocListItem, 0, len(details)) + for _, d := range details { + out = append(out, LibraryDocListItem{ + ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL, + SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files, + CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, + }) + } + return out, nil +} + +// GetPublishedBySlug 公开详情(仅已发布) +func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, error) { + slug = strings.ToLower(strings.TrimSpace(slug)) + var d model.LibraryDoc + if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil { + return nil, ErrLibraryNotFound + } + out := s.attachFiles([]model.LibraryDoc{d}) + return &out[0], nil +} + +func (s *LibraryService) publishedDetails() ([]LibraryDocDetail, error) { + var docs []model.LibraryDoc + if err := s.db.Where("published = ?", true). + Order("sort_order ASC, id ASC").Limit(100).Find(&docs).Error; err != nil { + return nil, err + } + return s.attachFiles(docs), nil +} + +// ---------- 文件 ---------- + +func (s *LibraryService) maxBytes() int64 { + if s.setting == nil { + return FileMaxBytes + } + n, err := s.setting.AttachmentMaxBytes() + if err != nil || n < 1 { + return FileMaxBytes + } + return n +} + +// AddFile 上传文件到条目(落盘 data/library + 建行) +func (s *LibraryService) AddFile(docID, userID uint, originalName string, src io.Reader) (*model.LibraryFile, error) { + if src == nil { + return nil, errors.New("文件为空") + } + var doc model.LibraryDoc + if err := s.db.First(&doc, docID).Error; err != nil { + return nil, ErrLibraryNotFound + } + + name := sanitizeFilename(originalName) + if name == "" { + name = "file" + } + ext := strings.TrimPrefix(ExtOfFilename(name), ".") + if !LibraryExtAllowed(ext) { + return nil, ErrLibraryExtDenied + } + + var count int64 + if err := s.db.Model(&model.LibraryFile{}).Where("doc_id = ?", docID).Count(&count).Error; err != nil { + return nil, err + } + if count >= MaxLibraryFilesPerDoc { + return nil, ErrLibraryTooMany + } + + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return nil, err + } + stored := hex.EncodeToString(raw) + "." + ext + full := filepath.Join(s.dir, stored) + + f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return nil, err + } + maxB := s.maxBytes() + written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1)) + _ = f.Close() + if copyErr != nil { + _ = os.Remove(full) + return nil, copyErr + } + if written == 0 { + _ = os.Remove(full) + return nil, errors.New("文件为空") + } + if written > maxB { + _ = os.Remove(full) + mb := int(maxB >> 20) + if mb < 1 { + mb = 1 + } + return nil, errors.New("文件过大:不能超过 " + strconv.Itoa(mb) + "MB") + } + + file := &model.LibraryFile{ + DocID: docID, + UserID: userID, + Name: name, + StoredName: stored, + Ext: ext, + MIME: MimeForAttachmentExt("." + ext), + Size: int(written), + } + if err := s.db.Create(file).Error; err != nil { + _ = os.Remove(full) + return nil, err + } + return file, nil +} + +// DeleteFile 删除文件(软删行 + 磁盘尽力删除) +func (s *LibraryService) DeleteFile(id uint) error { + var f model.LibraryFile + if err := s.db.First(&f, id).Error; err != nil { + return ErrLibraryNotFound + } + if err := s.db.Delete(&f).Error; err != nil { + return err + } + _ = os.Remove(s.FilePath(&f)) + return nil +} + +// GetPublicFile 公开取文件(要求所属条目存在且已发布;软删条目的文件不可达) +func (s *LibraryService) GetPublicFile(id uint) (*model.LibraryFile, error) { + var f model.LibraryFile + if err := s.db.First(&f, id).Error; err != nil { + return nil, ErrLibraryNotFound + } + if f.DocID == 0 { + return nil, ErrLibraryNotFound + } + var n int64 + if err := s.db.Model(&model.LibraryDoc{}). + Where("id = ? AND published = ?", f.DocID, true).Count(&n).Error; err != nil { + return nil, err + } + if n == 0 { + return nil, ErrLibraryNotFound + } + return &f, nil +} + +// IncrDownload 下载计数原子递增 +func (s *LibraryService) IncrDownload(id uint) { + s.db.Model(&model.LibraryFile{}).Where("id = ?", id). + UpdateColumn("download_count", gorm.Expr("download_count + 1")) +} diff --git a/backend/service/library_section.go b/backend/service/library_section.go new file mode 100644 index 0000000..f64bdc0 --- /dev/null +++ b/backend/service/library_section.go @@ -0,0 +1,422 @@ +package service + +import ( + "errors" + "strconv" + "strings" + "unicode/utf8" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// 章节约束:两级树(章 → 节),正文为 markdown +const ( + MaxSectionsPerDoc = 200 // 单条目章+节总数上限 + MaxSectionContent = 100_000 // 单章节正文字符上限 + MaxImportBytes = 5 << 20 // 单个 md 文件字节上限(5MB) + MaxImportFiles = 50 // 单次批量导入文件数上限 + MaxImportTotalBytes = 20 << 20 // 批量导入总字节上限(20MB) +) + +var ErrSectionNotFound = errors.New("章节不存在") + +// SectionInput 创建/更新章节入参(更新仅改标题与正文,父子关系由创建时决定、顺序用 move 调整) +type SectionInput struct { + ParentID *uint `json:"parent_id"` + Title string `json:"title"` + Content string `json:"content"` +} + +func (in *SectionInput) normalize() error { + if in.ParentID != nil && *in.ParentID == 0 { + in.ParentID = nil + } + in.Title = strings.TrimSpace(in.Title) + if in.Title == "" { + return errors.New("章节标题不能为空") + } + if utf8.RuneCountInString(in.Title) > 200 { + return errors.New("章节标题不能超过 200 字") + } + if utf8.RuneCountInString(in.Content) > MaxSectionContent { + return errors.New("章节正文不能超过 100000 字") + } + return nil +} + +func (s *LibraryService) section(id uint) (*model.LibrarySection, error) { + var sec model.LibrarySection + if err := s.db.First(&sec, id).Error; err != nil { + return nil, ErrSectionNotFound + } + return &sec, nil +} + +func (s *LibraryService) sectionCount(docID uint) (int64, error) { + var n int64 + if err := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID).Count(&n).Error; err != nil { + return 0, err + } + return n, nil +} + +// sectionMaxSort 同级兄弟的当前最大 sort_order(无兄弟时 -1) +func (s *LibraryService) sectionMaxSort(docID uint, parentID *uint) (int, error) { + q := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID) + if parentID == nil { + q = q.Where("parent_id IS NULL") + } else { + q = q.Where("parent_id = ?", *parentID) + } + var maxSort int + if err := q.Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort).Error; err != nil { + return 0, err + } + return maxSort, nil +} + +// ListSections 条目下全部章节(平铺,章在前节按组内序) +func (s *LibraryService) ListSections(docID uint) ([]model.LibrarySection, error) { + var secs []model.LibrarySection + if err := s.db.Where("doc_id = ?", docID). + Order("sort_order ASC, id ASC").Find(&secs).Error; err != nil { + return nil, err + } + return secs, nil +} + +// validateParent 两级约束:父必须是同条目下的章(自身不能是节) +func (s *LibraryService) validateParent(docID uint, parentID *uint) error { + if parentID == nil { + return nil + } + parent, err := s.section(*parentID) + if err != nil { + return errors.New("父章节不存在") + } + if parent.DocID != docID { + return errors.New("父章节不属于该条目") + } + if parent.ParentID != nil { + return errors.New("目录最多两级:章下不能再建小节") + } + return nil +} + +func (s *LibraryService) CreateSection(docID uint, in *SectionInput) (*model.LibrarySection, error) { + if err := in.normalize(); err != nil { + return nil, err + } + var doc model.LibraryDoc + if err := s.db.First(&doc, docID).Error; err != nil { + return nil, ErrLibraryNotFound + } + if err := s.validateParent(docID, in.ParentID); err != nil { + return nil, err + } + count, err := s.sectionCount(docID) + if err != nil { + return nil, err + } + if count >= MaxSectionsPerDoc { + return nil, errors.New("章节数量超过上限") + } + maxSort, err := s.sectionMaxSort(docID, in.ParentID) + if err != nil { + return nil, err + } + sec := &model.LibrarySection{ + DocID: docID, + ParentID: in.ParentID, + Title: in.Title, + Content: in.Content, + SortOrder: maxSort + 1, + } + if err := s.db.Create(sec).Error; err != nil { + return nil, err + } + return sec, nil +} + +// UpdateSection 更新标题与正文(父章节与顺序不变) +func (s *LibraryService) UpdateSection(id uint, in *SectionInput) (*model.LibrarySection, error) { + in.ParentID = nil // 忽略传入的 parent:父子关系不在此处变更 + if err := in.normalize(); err != nil { + return nil, err + } + sec, err := s.section(id) + if err != nil { + return nil, err + } + if err := s.db.Model(sec).Updates(map[string]interface{}{ + "title": in.Title, + "content": in.Content, + }).Error; err != nil { + return nil, err + } + return s.section(id) +} + +// DeleteSection 删除章节;若为章则级联软删其下小节 +func (s *LibraryService) DeleteSection(id uint) error { + sec, err := s.section(id) + if err != nil { + return err + } + return s.db.Transaction(func(tx *gorm.DB) error { + if sec.ParentID == nil { + if err := tx.Where("parent_id = ?", sec.ID).Delete(&model.LibrarySection{}).Error; err != nil { + return err + } + } + return tx.Delete(sec).Error + }) +} + +// MoveSection 与同父相邻兄弟交换 sort_order(边界为 no-op) +func (s *LibraryService) MoveSection(id uint, direction string) error { + if direction != "up" && direction != "down" { + return errors.New("无效的移动方向") + } + sec, err := s.section(id) + if err != nil { + return err + } + q := s.db.Where("doc_id = ?", sec.DocID) + if sec.ParentID == nil { + q = q.Where("parent_id IS NULL") + } else { + q = q.Where("parent_id = ?", *sec.ParentID) + } + var sibs []model.LibrarySection + if err := q.Order("sort_order ASC, id ASC").Find(&sibs).Error; err != nil { + return err + } + idx := -1 + for i := range sibs { + if sibs[i].ID == sec.ID { + idx = i + break + } + } + if idx < 0 { + return ErrSectionNotFound + } + var other *model.LibrarySection + if direction == "up" && idx > 0 { + other = &sibs[idx-1] + } + if direction == "down" && idx < len(sibs)-1 { + other = &sibs[idx+1] + } + if other == nil { + return nil // 已在边界,无需移动 + } + a, b := sec.SortOrder, other.SortOrder + if a == b { + b = a + 1 // 相同排序值时保证交换后顺序确实变化 + } + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.LibrarySection{}).Where("id = ?", sec.ID). + UpdateColumn("sort_order", b).Error; err != nil { + return err + } + return tx.Model(&model.LibrarySection{}).Where("id = ?", other.ID). + UpdateColumn("sort_order", a).Error + }) +} + +// ---------- md 拆章导入 ---------- + +type mdChapterChunk struct { + Title string + Body string +} + +// splitMarkdownChapters 按指定级别标题("# " 或 "## ")切块; +// 代码围栏内的 # 行不视为标题;首个标题之前的内容并入第一章正文。 +func splitMarkdownChapters(content, level string) []mdChapterChunk { + prefix := "# " + if level == "h2" { + prefix = "## " + } + var chunks []mdChapterChunk + var pre strings.Builder + bodies := []strings.Builder{} + cur := -1 + inCode := false + text := strings.ReplaceAll(content, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + for _, line := range strings.Split(text, "\n") { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "```") { + inCode = !inCode + } + if !inCode && strings.HasPrefix(t, prefix) { + title := strings.TrimSpace(strings.TrimPrefix(t, prefix)) + title = strings.TrimSpace(strings.TrimRight(title, "#")) + chunks = append(chunks, mdChapterChunk{Title: title}) + bodies = append(bodies, strings.Builder{}) + cur = len(chunks) - 1 + continue + } + if cur < 0 { + pre.WriteString(line) + pre.WriteString("\n") + } else { + bodies[cur].WriteString(line) + bodies[cur].WriteString("\n") + } + } + if len(chunks) == 0 { + return nil + } + out := make([]mdChapterChunk, len(chunks)) + for i := range chunks { + body := bodies[i].String() + if i == 0 && pre.Len() > 0 { + body = pre.String() + body + } + out[i] = mdChapterChunk{Title: chunks[i].Title, Body: strings.TrimSpace(body)} + } + return out +} + +// truncateRunes 按 rune 截断 +func truncateRunes(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + return string([]rune(s)[:n]) +} + +// ImportFileInput 批量导入的单个文件 +type ImportFileInput struct { + Filename string `json:"filename"` + Content string `json:"content"` +} + +// chunksFromFile 把单个文件拆成章块:校验单文件大小,标题回退取文件名; +// splitLevel 为 none 或未命中标题时整篇一章。 +func chunksFromFile(filename, content, splitLevel string) (string, []mdChapterChunk, error) { + if len(content) > MaxImportBytes { + return "", nil, errors.New("文件 " + sanitizeFilename(filename) + " 过大:单个文件不能超过 5MB") + } + base := sanitizeFilename(filename) + if i := strings.LastIndex(base, "."); i > 0 { + base = base[:i] + } + base = strings.TrimSpace(base) + if base == "" { + base = "未命名章节" + } + if utf8.RuneCountInString(base) > 200 { + base = truncateRunes(base, 200) + } + + var chunks []mdChapterChunk + if splitLevel == "none" { + chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}} + } else { + chunks = splitMarkdownChapters(content, splitLevel) + } + if len(chunks) == 0 { + chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}} + } + return base, chunks, nil +} + +// ImportMarkdownBatch 批量导入:多个 md 文件按传入顺序依次拆成顶级章(不生成节)。 +// splitLevel 对每个文件生效;无命中标题的文件回退为单章(标题取文件名)。 +// 任一文件失败整体回滚。单文件导入即长度为 1 的批量。 +func (s *LibraryService) ImportMarkdownBatch(docID uint, files []ImportFileInput, splitLevel string) ([]model.LibrarySection, error) { + var doc model.LibraryDoc + if err := s.db.First(&doc, docID).Error; err != nil { + return nil, ErrLibraryNotFound + } + if splitLevel != "h1" && splitLevel != "h2" && splitLevel != "none" { + return nil, errors.New("无效的拆分方式") + } + if len(files) == 0 { + return nil, errors.New("没有可导入的文件") + } + if len(files) > MaxImportFiles { + return nil, errors.New("单次最多导入 " + strconv.Itoa(MaxImportFiles) + " 个文件") + } + total := 0 + for _, f := range files { + total += len(f.Content) + } + if total > MaxImportTotalBytes { + return nil, errors.New("导入内容总量过大:不能超过 20MB") + } + + // 逐文件拆块(保留文件边界,便于错误提示定位到文件) + type mdFilePlan struct { + Base string + Chunks []mdChapterChunk + } + plan := make([]mdFilePlan, 0, len(files)) + totalChunks := 0 + for _, f := range files { + base, chunks, err := chunksFromFile(f.Filename, f.Content, splitLevel) + if err != nil { + return nil, err + } + plan = append(plan, mdFilePlan{Base: base, Chunks: chunks}) + totalChunks += len(chunks) + } + + count, err := s.sectionCount(docID) + if err != nil { + return nil, err + } + if count+int64(totalChunks) > MaxSectionsPerDoc { + return nil, errors.New("导入后的章节数超过上限(现有 + 导入 ≤ 200)") + } + maxSort, err := s.sectionMaxSort(docID, nil) + if err != nil { + return nil, err + } + + created := make([]model.LibrarySection, 0, totalChunks) + err = s.db.Transaction(func(tx *gorm.DB) error { + offset := 0 + for _, fp := range plan { + for i, ch := range fp.Chunks { + title := strings.TrimSpace(ch.Title) + if title == "" { + title = fp.Base + "(续)" + } + if utf8.RuneCountInString(title) > 200 { + title = truncateRunes(title, 200) + } + if utf8.RuneCountInString(ch.Body) > MaxSectionContent { + return errors.New("「" + fp.Base + "」第 " + strconv.Itoa(i+1) + " 章正文超过 100000 字,请先拆分后再导入") + } + sec := &model.LibrarySection{ + DocID: docID, + Title: title, + Content: ch.Body, + SortOrder: maxSort + 1 + offset + i, + } + if err := tx.Create(sec).Error; err != nil { + return err + } + created = append(created, *sec) + } + offset += len(fp.Chunks) + } + return nil + }) + if err != nil { + return nil, err + } + return created, nil +} + +// ImportMarkdown 单文件导入(批量为长度 1 的特例,保留旧端点兼容) +func (s *LibraryService) ImportMarkdown(docID uint, filename, content, splitLevel string) ([]model.LibrarySection, error) { + return s.ImportMarkdownBatch(docID, []ImportFileInput{{Filename: filename, Content: content}}, splitLevel) +} diff --git a/backend/service/library_test.go b/backend/service/library_test.go new file mode 100644 index 0000000..a11bf69 --- /dev/null +++ b/backend/service/library_test.go @@ -0,0 +1,506 @@ +package service + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newLibraryTestService(t *testing.T) (*LibraryService, string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil { + t.Fatalf("migrate: %v", err) + } + dir := t.TempDir() + s := NewLibraryService(db, dir) + if err := s.EnsureDir(); err != nil { + t.Fatalf("ensure dir: %v", err) + } + return s, dir +} + +func libPub(b bool) *bool { return &b } + +func libInt(v int) *int { return &v } + +func TestLibraryNormalize(t *testing.T) { + cases := []struct { + name string + mutate func(*LibraryInput) + wantErr string + }{ + {"slug 为空", func(i *LibraryInput) { i.Slug = "" }, "slug 不能为空"}, + {"slug 非法", func(i *LibraryInput) { i.Slug = "Bad_Slug" }, "slug 仅允许"}, + {"slug 超长", func(i *LibraryInput) { i.Slug = strings.Repeat("a", 65) }, "slug 不能超过"}, + {"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"}, + {"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"}, + {"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"}, + {"合法", func(i *LibraryInput) {}, ""}, + } + for _, tc := range cases { + in := &LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"} + tc.mutate(in) + err := in.normalize() + if tc.wantErr == "" { + if err != nil { + t.Fatalf("%s: 不应报错,got %v", tc.name, err) + } + continue + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("%s: 期望包含 %q,got %v", tc.name, tc.wantErr, err) + } + } +} + +func TestLibrarySlugUnique(t *testing.T) { + s, _ := newLibraryTestService(t) + if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil { + t.Fatalf("create a: %v", err) + } + if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "B"}); err == nil { + t.Fatal("重复 slug 应报错") + } +} + +func TestLibrarySlugReuseAfterSoftDelete(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, err := s.Create(&LibraryInput{Slug: "test", Title: "测试"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := s.Delete(doc.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + // 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误 + _, err = s.Create(&LibraryInput{Slug: "test", Title: "重建"}) + if err == nil || !strings.Contains(err.Error(), "已删除") { + t.Fatalf("软删后重建应提示已删除条目占用,got %v", err) + } + + // 彻底删除后可重建 + if err := s.Purge(doc.ID); err != nil { + t.Fatalf("purge: %v", err) + } + if _, err := s.Create(&LibraryInput{Slug: "test", Title: "重建"}); err != nil { + t.Fatalf("purge 后重建同 slug 不应报错: %v", err) + } +} + +func TestLibraryPurgeRequiresDeleted(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "alive", Title: "在用"}) + if err := s.Purge(doc.ID); err == nil || !errors.Is(err, ErrLibraryNotDeleted) { + t.Fatalf("未删条目 Purge 应拒绝,got %v", err) + } +} + +func TestLibraryPurgeRemovesFilesAndSections(t *testing.T) { + s, dir := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "purge-me", Title: "待清"}) + f, err := s.AddFile(doc.ID, 1, "x.txt", strings.NewReader("x")) + if err != nil { + t.Fatalf("add file: %v", err) + } + full := filepath.Join(dir, "library", f.StoredName) + if _, err := os.Stat(full); err != nil { + t.Fatalf("文件未落盘: %v", err) + } + if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}); err != nil { + t.Fatalf("create section: %v", err) + } + + if err := s.Delete(doc.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if err := s.Purge(doc.ID); err != nil { + t.Fatalf("purge: %v", err) + } + + var n int64 + s.db.Unscoped().Model(&model.LibraryDoc{}).Count(&n) + if n != 0 { + t.Fatalf("条目行应已清除,got %d", n) + } + s.db.Unscoped().Model(&model.LibraryFile{}).Count(&n) + if n != 0 { + t.Fatalf("文件行应已清除,got %d", n) + } + s.db.Unscoped().Model(&model.LibrarySection{}).Count(&n) + if n != 0 { + t.Fatalf("章节行应已清除,got %d", n) + } + if _, err := os.Stat(full); !os.IsNotExist(err) { + t.Fatalf("磁盘文件应已删除,err=%v", err) + } +} + +func TestLibraryExtWhitelists(t *testing.T) { + if !LibraryExtAllowed("epub") || !LibraryExtAllowed(".md") { + t.Fatal("白名单内扩展名应允许") + } + if LibraryExtAllowed("exe") || LibraryExtAllowed("php") || LibraryExtAllowed("") { + t.Fatal("白名单外扩展名应拒绝") + } +} + +func TestLibraryAddFile(t *testing.T) { + s, dir := newLibraryTestService(t) + doc, err := s.Create(&LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"}) + if err != nil { + t.Fatalf("create: %v", err) + } + + // 白名单内:落盘 + 建行 + f, err := s.AddFile(doc.ID, 1, "如何生活.pdf", strings.NewReader("PDF-DATA")) + if err != nil { + t.Fatalf("add pdf: %v", err) + } + if f.Ext != "pdf" || f.Size != 8 { + t.Fatalf("文件元数据错误: %+v", f) + } + if f.MIME != "application/pdf" { + t.Fatalf("pdf MIME 应为 application/pdf,got %s", f.MIME) + } + if _, err := os.Stat(filepath.Join(dir, "library", f.StoredName)); err != nil { + t.Fatalf("文件未落盘: %v", err) + } + + // html 存储层 MIME 强制 octet-stream(下载安全) + h, err := s.AddFile(doc.ID, 1, "page.html", strings.NewReader("

hi

")) + if err != nil { + t.Fatalf("add html: %v", err) + } + if h.MIME != "application/octet-stream" { + t.Fatalf("html 下载 MIME 应强制 octet-stream,got %s", h.MIME) + } + + // 白名单外拒绝 + if _, err := s.AddFile(doc.ID, 1, "evil.exe", strings.NewReader("MZ")); err == nil { + t.Fatal("exe 应被拒绝") + } + if _, err := s.AddFile(doc.ID, 1, "noext", strings.NewReader("x")); err == nil { + t.Fatal("无扩展名应被拒绝") + } + + // 超过每条目上限 + for i := 0; i < MaxLibraryFilesPerDoc-2; i++ { + if _, err := s.AddFile(doc.ID, 1, "f.txt", strings.NewReader("x")); err != nil { + t.Fatalf("add #%d: %v", i, err) + } + } + if _, err := s.AddFile(doc.ID, 1, "over.txt", strings.NewReader("x")); err == nil { + t.Fatal("超出文件数上限应报错") + } + + // 未发布条目的文件公开不可见 + if _, err := s.GetPublicFile(f.ID); err == nil { + t.Fatal("未发布条目的文件不应公开可见") + } +} + +func TestLibraryPublishedFilterAndOrder(t *testing.T) { + s, _ := newLibraryTestService(t) + if _, err := s.Create(&LibraryInput{Slug: "draft-doc", Title: "草稿"}); err != nil { + t.Fatalf("create draft: %v", err) + } + published, _ := s.Create(&LibraryInput{ + Slug: "live-doc", Title: "已发布", Published: libPub(true), SortOrder: libInt(1), + }) + + if _, err := s.AddFile(published.ID, 1, "b.txt", strings.NewReader("b")); err != nil { + t.Fatalf("add b: %v", err) + } + if _, err := s.AddFile(published.ID, 1, "a.txt", strings.NewReader("a")); err != nil { + t.Fatalf("add a: %v", err) + } + + // 未发布不出现在公开目录 + list, err := s.ListPublished() + if err != nil { + t.Fatalf("ListPublished: %v", err) + } + if len(list) != 1 || list[0].Slug != "live-doc" { + t.Fatalf("公开目录应只含 live-doc,got %+v", list) + } + if list[0].FileCount != 2 { + t.Fatalf("列表应带文件计数 2,got %d", list[0].FileCount) + } + if _, err := s.GetPublishedBySlug("draft-doc"); err == nil { + t.Fatal("未发布条目按 slug 不应可见") + } + + // 软删后公开彻底不可见 + if err := s.Delete(published.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetPublishedBySlug("live-doc"); err == nil { + t.Fatal("软删条目不应可见") + } + + // 管理端仍可见(含未发布;软删条目带 Deleted 标记一并列出) + all, err := s.ListAll() + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(all) != 2 { + t.Fatalf("管理端应含 draft-doc 与软删的 live-doc,got %+v", all) + } + bySlug := map[string]LibraryDocDetail{} + for _, d := range all { + bySlug[d.Slug] = d + } + if d := bySlug["draft-doc"]; d.Deleted { + t.Fatalf("draft-doc 不应标记已删除: %+v", d) + } + if d := bySlug["live-doc"]; !d.Deleted { + t.Fatalf("live-doc 应标记已删除: %+v", d) + } +} + +func TestLibraryFileOrdering(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "order", Title: "排序", Published: libPub(true)}) + // b 先传(id 小),a 设 sort_order 靠前 + b, _ := s.AddFile(doc.ID, 1, "b.txt", strings.NewReader("b")) + a, _ := s.AddFile(doc.ID, 1, "a.txt", strings.NewReader("a")) + s.db.Model(&model.LibraryFile{}).Where("id = ?", a.ID).Update("sort_order", -1) + _ = b + + d, err := s.GetPublishedBySlug("order") + if err != nil { + t.Fatalf("detail: %v", err) + } + if len(d.Files) != 2 || d.Files[0].Name != "a.txt" { + t.Fatalf("文件应按 sort_order,id 排序: %+v", d.Files) + } +} + +func TestLibraryIncrDownload(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "cnt", Title: "计数", Published: libPub(true)}) + f, err := s.AddFile(doc.ID, 1, "x.pdf", strings.NewReader("x")) + if err != nil { + t.Fatalf("add: %v", err) + } + s.IncrDownload(f.ID) + s.IncrDownload(f.ID) + got, err := s.GetPublicFile(f.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.DownloadCount != 2 { + t.Fatalf("下载计数应为 2,got %d", got.DownloadCount) + } +} + +func TestLibraryDeleteFile(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "del", Title: "删除"}) + f, err := s.AddFile(doc.ID, 1, "gone.txt", strings.NewReader("gone")) + if err != nil { + t.Fatalf("add: %v", err) + } + if err := s.DeleteFile(f.ID); err != nil { + t.Fatalf("delete file: %v", err) + } + if err := s.DeleteFile(f.ID); err == nil { + t.Fatal("重复删除应报不存在") + } +} + +// ---------- 章节(两级树) ---------- + +func TestLibrarySectionTwoLevelConstraint(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "book", Title: "书"}) + + ch, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}) + if err != nil { + t.Fatalf("create chapter: %v", err) + } + sec, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "第一节"}) + if err != nil { + t.Fatalf("create section: %v", err) + } + if sec.ParentID == nil || *sec.ParentID != ch.ID { + t.Fatalf("节的父应为章 %d", ch.ID) + } + // 节下不能再挂节 + if _, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &sec.ID, Title: "深层"}); err == nil { + t.Fatal("三级嵌套应被拒绝") + } + // 跨条目父章节 + doc2, _ := s.Create(&LibraryInput{Slug: "book2", Title: "书2"}) + if _, err := s.CreateSection(doc2.ID, &SectionInput{ParentID: &ch.ID, Title: "跨条目"}); err == nil { + t.Fatal("跨条目父章节应被拒绝") + } +} + +func TestLibrarySectionMoveSwap(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "mv", Title: "排序"}) + a, _ := s.CreateSection(doc.ID, &SectionInput{Title: "甲"}) + b, _ := s.CreateSection(doc.ID, &SectionInput{Title: "乙"}) + c, _ := s.CreateSection(doc.ID, &SectionInput{Title: "丙"}) + + if err := s.MoveSection(b.ID, "up"); err != nil { + t.Fatalf("move up: %v", err) + } + secs, _ := s.ListSections(doc.ID) + if secs[0].ID != b.ID || secs[1].ID != a.ID || secs[2].ID != c.ID { + t.Fatalf("上移后顺序应为 乙甲丙: %+v", titles(secs)) + } + // 边界 no-op + if err := s.MoveSection(b.ID, "up"); err != nil { + t.Fatalf("边界上移: %v", err) + } + if err := s.MoveSection(b.ID, "bad"); err == nil { + t.Fatal("非法方向应报错") + } +} + +func titles(secs []model.LibrarySection) []string { + out := make([]string, len(secs)) + for i, sec := range secs { + out[i] = sec.Title + } + return out +} + +func TestLibraryImportMarkdown(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "imp", Title: "导入"}) + + // h2 拆分:围栏内 ## 不拆;首标题前内容并入第一章 + md := "## 前言\n\n引言正文。\n\n```\n## 不是标题\n```\n\n## 第二章\n\n内容二。" + secs, err := s.ImportMarkdown(doc.ID, "how.md", md, "h2") + if err != nil { + t.Fatalf("import: %v", err) + } + if len(secs) != 2 { + t.Fatalf("应拆出 2 章,got %d: %+v", len(secs), titles(secs)) + } + if secs[0].Title != "前言" || !strings.Contains(secs[0].Content, "引言正文") { + t.Fatalf("第一章错误: %+v", secs[0]) + } + if !strings.Contains(secs[0].Content, "不是标题") { + t.Fatal("围栏内的 ## 行应留在第一章正文,不应被拆分") + } + if secs[1].Title != "第二章" { + t.Fatalf("第二章标题错误: %+v", secs[1]) + } + + // 无命中标题 → 回退单章,标题取文件名 + secs2, err := s.ImportMarkdown(doc.ID, "README.md", "只有正文,没有标题。", "h1") + if err != nil { + t.Fatalf("import fallback: %v", err) + } + if len(secs2) != 1 || secs2[0].Title != "README" { + t.Fatalf("应回退单章 README: %+v", titles(secs2)) + } + + // 非法 splitLevel + if _, err := s.ImportMarkdown(doc.ID, "x.md", "x", "h3"); err == nil { + t.Fatal("非法拆分方式应报错") + } +} + +func TestLibraryImportMarkdownBatch(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "impb", Title: "批量导入"}) + + files := []ImportFileInput{ + {Filename: "01-不想早死.md", Content: "## 甲\n\n内容甲。"}, + {Filename: "02-不要慢性自杀.md", Content: "只有正文,没有标题。"}, + {Filename: "03.md", Content: "# 丙\n\n内容丙。"}, + } + secs, err := s.ImportMarkdownBatch(doc.ID, files, "h2") + if err != nil { + t.Fatalf("batch import: %v", err) + } + // 文件1 按 h2 拆出 1 章;文件2、文件3 未命中 ## → 回退单章(标题取文件名) + if len(secs) != 3 { + t.Fatalf("应创建 3 章,got %d: %+v", len(secs), titles(secs)) + } + if secs[0].Title != "甲" { + t.Fatalf("文件1 应按 h2 拆出章: %+v", titles(secs)) + } + if secs[1].Title != "02-不要慢性自杀" { + t.Fatalf("文件2 标题应取文件名: %+v", titles(secs)) + } + if secs[2].Title != "03" { + t.Fatalf("文件3 未命中 ## 应回退单章: %+v", titles(secs)) + } + for i, sec := range secs { + if sec.SortOrder != i { + t.Fatalf("sort_order 应按文件顺序连续: %+v", titles(secs)) + } + } + + // 文件数超限 + many := make([]ImportFileInput, MaxImportFiles+1) + for i := range many { + many[i] = ImportFileInput{Filename: "f.md", Content: "x"} + } + if _, err := s.ImportMarkdownBatch(doc.ID, many, "none"); err == nil { + t.Fatal("文件数超过上限应报错") + } + + // 空文件列表 + if _, err := s.ImportMarkdownBatch(doc.ID, nil, "none"); err == nil { + t.Fatal("空文件列表应报错") + } +} + +func TestLibraryDeleteSectionCascade(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "cas", Title: "级联"}) + ch, _ := s.CreateSection(doc.ID, &SectionInput{Title: "章"}) + sec, _ := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "节"}) + + if err := s.DeleteSection(ch.ID); err != nil { + t.Fatalf("delete chapter: %v", err) + } + // 章与节都被软删 + if _, err := s.section(ch.ID); err == nil { + t.Fatal("章应已删除") + } + if _, err := s.section(sec.ID); err == nil { + t.Fatal("章删除时其下小节应级联删除") + } +} + +func TestLibraryDetailCarriesSections(t *testing.T) { + s, _ := newLibraryTestService(t) + doc, _ := s.Create(&LibraryInput{Slug: "carry", Title: "携带", Published: libPub(true)}) + if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "章一", Content: "正文一"}); err != nil { + t.Fatalf("create: %v", err) + } + d, err := s.GetPublishedBySlug("carry") + if err != nil { + t.Fatalf("detail: %v", err) + } + if len(d.Sections) != 1 || d.Sections[0].Content != "正文一" { + t.Fatalf("公开详情应携带章节: %+v", d.Sections) + } + // 草稿条目不可见 + doc2, _ := s.Create(&LibraryInput{Slug: "draft", Title: "草稿书"}) + if _, err := s.CreateSection(doc2.ID, &SectionInput{Title: "草稿章"}); err != nil { + t.Fatalf("create: %v", err) + } + if _, err := s.GetPublishedBySlug("draft"); err == nil { + t.Fatal("草稿条目不应出现在公开详情") + } +} diff --git a/backend/service/setting.go b/backend/service/setting.go index ec4f13d..73cc2ee 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -66,7 +66,7 @@ const ( SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分 // 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值) - SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5 + SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5 SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7 SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0 SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0 @@ -83,6 +83,10 @@ const ( // SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格) SettingKeyUrlStyle = "url_style" + + // SettingKeyMobileTabs 移动端底栏显示的 tab(JSON []string,元素取值见 MobileTabKeys); + // 缺行/非法/为空视为全量默认 + SettingKeyMobileTabs = "mobile_tabs" ) const ( @@ -118,8 +122,19 @@ const ( // DefaultUrlStyle 标准动态路由风格(/post/123) DefaultUrlStyle = "default" + + // MaxMobileTabs 底栏配置元素上限(与白名单等长) + MaxMobileTabs = 5 ) +// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致) +var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"} + +// DefaultMobileTabs 底栏默认全量配置 +func DefaultMobileTabs() []string { + return append([]string(nil), MobileTabKeys...) +} + // DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) var DefaultAttachmentExts = []string{ "pdf", "txt", "md", "csv", "json", @@ -146,34 +161,34 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // PublicSiteSettings 对前台公开的站点配置(SEO / 注册入口 / 发帖冷静期提示) type PublicSiteSettings struct { - Accent string `json:"accent"` - TrustReviewedPublish bool `json:"trust_reviewed_publish"` - PublishWithoutReview bool `json:"publish_without_review"` - SiteName string `json:"site_name"` - SiteDescription string `json:"site_description"` - AllowRegister bool `json:"allow_register"` - AllowComments bool `json:"allow_comments"` - CommentsRequireLogin bool `json:"comments_require_login"` - AllowMessages bool `json:"allow_messages"` - PostCooldownHours int `json:"post_cooldown_hours"` - CodeBlockAutoFold bool `json:"code_block_auto_fold"` - CodeBlockFoldLines int `json:"code_block_fold_lines"` - UIAnimations bool `json:"ui_animations"` - AnimCodeFold bool `json:"anim_code_fold"` - AnimSmoothScroll bool `json:"anim_smooth_scroll"` - AnimChrome bool `json:"anim_chrome"` - PostLinkNewTab bool `json:"post_link_new_tab"` - AttachmentExtLimit bool `json:"attachment_ext_limit"` - AttachmentExts []string `json:"attachment_exts"` - AttachmentMaxMB int `json:"attachment_max_mb"` - AttachmentMaxCount int `json:"attachment_max_count"` - ImageMaxMB int `json:"image_max_mb"` + Accent string `json:"accent"` + TrustReviewedPublish bool `json:"trust_reviewed_publish"` + PublishWithoutReview bool `json:"publish_without_review"` + SiteName string `json:"site_name"` + SiteDescription string `json:"site_description"` + AllowRegister bool `json:"allow_register"` + AllowComments bool `json:"allow_comments"` + CommentsRequireLogin bool `json:"comments_require_login"` + AllowMessages bool `json:"allow_messages"` + PostCooldownHours int `json:"post_cooldown_hours"` + CodeBlockAutoFold bool `json:"code_block_auto_fold"` + CodeBlockFoldLines int `json:"code_block_fold_lines"` + UIAnimations bool `json:"ui_animations"` + AnimCodeFold bool `json:"anim_code_fold"` + AnimSmoothScroll bool `json:"anim_smooth_scroll"` + AnimChrome bool `json:"anim_chrome"` + PostLinkNewTab bool `json:"post_link_new_tab"` + AttachmentExtLimit bool `json:"attachment_ext_limit"` + AttachmentExts []string `json:"attachment_exts"` + AttachmentMaxMB int `json:"attachment_max_mb"` + AttachmentMaxCount int `json:"attachment_max_count"` + ImageMaxMB int `json:"image_max_mb"` // 内容锁定与旧帖回复(0=关闭;公开保持规则透明) - PostEditLockHours int `json:"post_edit_lock_hours"` - CommentEditLockHours int `json:"comment_edit_lock_hours"` - NecroReplyAfterHours int `json:"necro_reply_after_hours"` - NecroReplyPenalty int `json:"necro_reply_penalty"` + PostEditLockHours int `json:"post_edit_lock_hours"` + CommentEditLockHours int `json:"comment_edit_lock_hours"` + NecroReplyAfterHours int `json:"necro_reply_after_hours"` + NecroReplyPenalty int `json:"necro_reply_penalty"` BgSiteURL string `json:"bg_site_url"` BgSiteMode string `json:"bg_site_mode"` BgAdminURL string `json:"bg_admin_url"` @@ -206,6 +221,12 @@ type PublicSiteSettings struct { // UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html) UrlStyle string `json:"url_style"` + + // MobileTabs 移动端底栏显示的 tab(有序子集,数组顺序即展示顺序);空/非法由读取端回落默认 + MobileTabs []string `json:"mobile_tabs"` + + // HeaderNav 桌面端 Header 主导航(数组顺序即展示顺序);空数组=全部隐藏;缺省=内置三项 + HeaderNav []HeaderNavItem `json:"header_nav"` } // SettingService 站点级键值设置 @@ -310,6 +331,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { BrandLogoFit: BrandLogoFitContain, FooterLinks: []FooterLink{}, UrlStyle: DefaultUrlStyle, + HeaderNav: DefaultHeaderNav(), } accent, err := s.AccentColor() if err != nil { @@ -513,6 +535,9 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { } out.UrlStyle = urlStyle + out.MobileTabs = s.MobileTabs() + out.HeaderNav = s.HeaderNav() + if err := s.fillBrand(&out); err != nil { return out, err } @@ -551,6 +576,84 @@ func (s *SettingService) SetUrlStyle(style string) error { return ErrInvalidSiteSetting } +// normalizeMobileTabs 校验底栏 tab 列表:去空白、去重、只留白名单内元素 +func normalizeMobileTabs(tabs []string) ([]string, bool) { + allowed := make(map[string]struct{}, len(MobileTabKeys)) + for _, k := range MobileTabKeys { + allowed[k] = struct{}{} + } + seen := make(map[string]struct{}, len(tabs)) + out := make([]string, 0, len(tabs)) + for _, t := range tabs { + k := strings.TrimSpace(strings.ToLower(t)) + if k == "" { + continue + } + if _, ok := allowed[k]; !ok { + return nil, false + } + if _, dup := seen[k]; dup { + continue + } + seen[k] = struct{}{} + out = append(out, k) + if len(out) > MaxMobileTabs { + return nil, false + } + } + return out, true +} + +// mobileTabsEqual 判断两个列表是否元素一致(忽略顺序) +func mobileTabsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + set := make(map[string]struct{}, len(b)) + for _, k := range b { + set[k] = struct{}{} + } + for _, k := range a { + if _, ok := set[k]; !ok { + return false + } + } + return true +} + +// MobileTabs 移动端底栏配置;缺行/为空/非法/解析失败回落默认全量 +func (s *SettingService) MobileTabs() []string { + v, found, err := s.getValue(SettingKeyMobileTabs) + if err != nil || !found || strings.TrimSpace(v) == "" { + return DefaultMobileTabs() + } + var tabs []string + if err := json.Unmarshal([]byte(v), &tabs); err != nil { + return DefaultMobileTabs() + } + out, ok := normalizeMobileTabs(tabs) + if !ok || len(out) == 0 { + return DefaultMobileTabs() + } + return out +} + +// SetMobileTabs 校验并保存底栏配置;与默认全量一致时删键保持「缺行=默认」语义 +func (s *SettingService) SetMobileTabs(tabs []string) error { + out, ok := normalizeMobileTabs(tabs) + if !ok || len(out) == 0 { + return ErrInvalidSiteSetting + } + if mobileTabsEqual(out, MobileTabKeys) { + return s.deleteKey(SettingKeyMobileTabs) + } + b, err := json.Marshal(out) + if err != nil { + return err + } + return s.putValue(SettingKeyMobileTabs, string(b)) +} + // AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色 func (s *SettingService) AccentColor() (string, error) { v, found, err := s.getValue(SettingKeyAccent) diff --git a/backend/service/site_page.go b/backend/service/site_page.go index 6131830..d7a61a5 100644 --- a/backend/service/site_page.go +++ b/backend/service/site_page.go @@ -11,7 +11,10 @@ import ( "gorm.io/gorm" ) -var sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var ( + sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除") +) // 写入时显式列出字段(须为独立 Select 参数;逗号拼成一串会被 GORM 当成单个非法字段名) var sitePageWriteFields = []string{ @@ -116,16 +119,27 @@ func (in *SitePageInput) applyTo(p *model.SitePage) { } } -func (s *SitePageService) slugTaken(slug string, excludeID uint) (bool, error) { - q := s.db.Model(&model.SitePage{}).Where("slug = ?", slug) +// slugTaken 查询 slug 占用情况(Unscoped 含软删行:软删行仍占用数据库唯一索引) +// active=在用单页占用;deleted=已删除单页占用 +func (s *SitePageService) slugTaken(slug string, excludeID uint) (active, deleted bool, err error) { + q := s.db.Unscoped().Model(&model.SitePage{}).Where("slug = ?", slug).Select("deleted_at") if excludeID > 0 { q = q.Where("id <> ?", excludeID) } - var n int64 - if err := q.Count(&n).Error; err != nil { - return false, err + var rows []struct { + DeletedAt gorm.DeletedAt } - return n > 0, nil + if err := q.Find(&rows).Error; err != nil { + return false, false, err + } + for _, r := range rows { + if r.DeletedAt.Valid { + deleted = true + } else { + active = true + } + } + return active, deleted, nil } // ListSidebarPublished 首页右栏:已发布且勾选侧栏展示 @@ -179,11 +193,23 @@ func (s *SitePageService) GetPublishedBySlug(slug string) (*model.SitePage, erro return &p, nil } -// ListAll 管理后台全部单页 -func (s *SitePageService) ListAll() ([]model.SitePage, error) { - var list []model.SitePage - err := s.db.Order("sort_order ASC, id ASC").Limit(100).Find(&list).Error - return list, err +// SitePageAdminItem 管理端单页(附 deleted 标记;DeletedAt json:"-" 不出模型) +type SitePageAdminItem struct { + model.SitePage + Deleted bool `json:"deleted"` +} + +// ListAll 管理后台全部单页(含已软删,仅供查看与彻底删除) +func (s *SitePageService) ListAll() ([]SitePageAdminItem, error) { + var pages []model.SitePage + if err := s.db.Unscoped().Order("sort_order ASC, id ASC").Limit(100).Find(&pages).Error; err != nil { + return nil, err + } + out := make([]SitePageAdminItem, 0, len(pages)) + for _, p := range pages { + out = append(out, SitePageAdminItem{SitePage: p, Deleted: p.DeletedAt.Valid}) + } + return out, nil } // Get 管理后台按 ID 取单页 @@ -200,13 +226,16 @@ func (s *SitePageService) Create(in *SitePageInput) (*model.SitePage, error) { if err := in.normalize(nil); err != nil { return nil, err } - taken, err := s.slugTaken(in.Slug, 0) + active, deleted, err := s.slugTaken(in.Slug, 0) if err != nil { return nil, err } - if taken { + if active { return nil, errors.New("slug 已被占用") } + if deleted { + return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用") + } p := &model.SitePage{ Published: false, ShowInSidebar: false, @@ -229,13 +258,16 @@ func (s *SitePageService) Update(id uint, in *SitePageInput) (*model.SitePage, e if err := in.normalize(&p); err != nil { return nil, err } - taken, err := s.slugTaken(in.Slug, id) + active, deleted, err := s.slugTaken(in.Slug, id) if err != nil { return nil, err } - if taken { + if active { return nil, errors.New("slug 已被占用") } + if deleted { + return nil, errors.New("该地址已被已删除单页占用,可在单页管理列表中彻底删除后重新使用") + } in.applyTo(&p) // 用 map 更新,确保 false / 空串也会写入;勿用逗号拼接的 Select+Save(只会改 updated_at) if err := s.db.Model(&model.SitePage{}).Where("id = ?", p.ID).Updates(map[string]interface{}{ @@ -264,3 +296,15 @@ func (s *SitePageService) Delete(id uint) error { } return nil } + +// Purge 彻底删除已软删的单页(slug 释放可复用) +func (s *SitePageService) Purge(id uint) error { + var p model.SitePage + if err := s.db.Unscoped().First(&p, id).Error; err != nil { + return gorm.ErrRecordNotFound + } + if !p.DeletedAt.Valid { + return ErrSitePageNotDeleted + } + return s.db.Unscoped().Delete(&p).Error +} diff --git a/backend/service/site_page_test.go b/backend/service/site_page_test.go new file mode 100644 index 0000000..2b7228d --- /dev/null +++ b/backend/service/site_page_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newSitePageTestService(t *testing.T) *SitePageService { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.SitePage{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return NewSitePageService(db) +} + +func sitePageInput(slug, title string) *SitePageInput { + return &SitePageInput{Slug: slug, Title: title, Content: "正文内容"} +} + +func TestSitePageSlugReuseAfterSoftDelete(t *testing.T) { + s := newSitePageTestService(t) + p, err := s.Create(sitePageInput("test", "测试")) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := s.Delete(p.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + // 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误 + _, err = s.Create(sitePageInput("test", "重建")) + if err == nil || !strings.Contains(err.Error(), "已删除") { + t.Fatalf("软删后重建应提示已删除单页占用,got %v", err) + } + + // 管理端列表含软删行且带 deleted 标记 + all, err := s.ListAll() + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(all) != 1 || !all[0].Deleted || all[0].Slug != "test" { + t.Fatalf("ListAll 应含软删单页且 Deleted=true,got %+v", all) + } + + // 彻底删除后可重建 + if err := s.Purge(p.ID); err != nil { + t.Fatalf("purge: %v", err) + } + if _, err := s.Create(sitePageInput("test", "重建")); err != nil { + t.Fatalf("purge 后重建同 slug 不应报错: %v", err) + } +} + +func TestSitePagePurgeRequiresDeleted(t *testing.T) { + s := newSitePageTestService(t) + p, _ := s.Create(sitePageInput("alive", "在用")) + if err := s.Purge(p.ID); err == nil || !errors.Is(err, ErrSitePageNotDeleted) { + t.Fatalf("未删单页 Purge 应拒绝,got %v", err) + } +} diff --git a/backend/service/timeline_release.go b/backend/service/timeline_release.go new file mode 100644 index 0000000..c91d932 --- /dev/null +++ b/backend/service/timeline_release.go @@ -0,0 +1,371 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" +) + +const ( + timelineReleaseMaxPages = 10 + timelineReleaseMaxItems = 100 +) + +// timelineReleaseSource 内置 Release 适配(Gitea + GitHub) +type timelineReleaseSource struct { + id string + host string // 精确主机或 "*"(通配) + listPath string // releases 页正则 + apiURL string // API 模板 + query map[string]string + headers map[string]string + pagination string // link_header | query_page + dateField string // 优先取的字段:published_at / created_at +} + +var timelineReleaseSources = []timelineReleaseSource{ + { + id: "github_release", + host: "github.com", + listPath: `^/(?P[^/]+)/(?P[^/]+)/releases/?$`, + apiURL: "https://api.github.com/repos/{owner}/{repo}/releases", + query: map[string]string{"per_page": "100", "page": "{page}"}, + headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json"}, + pagination: "link_header", + dateField: "published_at", + }, + { + id: "gitea_release", + host: "*", + listPath: `^/(?P[^/]+)/(?P[^/]+)/releases/?$`, + apiURL: "https://{host}/api/v1/repos/{owner}/{repo}/releases", + query: map[string]string{"limit": "50", "page": "{page}"}, + headers: map[string]string{"User-Agent": "jiang13-bbs", "Accept": "application/json"}, + pagination: "query_page", + dateField: "created_at", + }, +} + +// matchReleaseSource 按 host + path 匹配内置 release 适配 +func matchReleaseSource(host, path string) (*timelineReleaseSource, map[string]string, error) { + for i := range timelineReleaseSources { + src := &timelineReleaseSources[i] + if src.host != "*" && !strings.EqualFold(src.host, host) { + continue + } + if src.host == "*" && strings.EqualFold(host, "github.com") { + continue + } + re, err := regexp.Compile(src.listPath) + if err != nil { + continue + } + if m := re.FindStringSubmatch(path); m != nil { + return src, subexpMap(re, m), nil + } + } + return nil, nil, errors.New("地址不符或主机未配置") +} + +// ImportTimelineFromReleases 按 releases 页 URL 拉取并解析发布记录 +func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGitImportResult, error) { + cleanURLs := make([]string, 0, len(urls)) + for _, u := range urls { + u = strings.TrimSpace(u) + if u != "" { + cleanURLs = append(cleanURLs, u) + } + } + if len(cleanURLs) == 0 { + return nil, errors.New("请提供至少一条 URL") + } + if len(cleanURLs) > 20 { + return nil, errors.New("一次最多 20 条 URL") + } + + client := &http.Client{ + Timeout: timelineGitHTTPTimeout, + Transport: &http.Transport{ + DialContext: publicOnlyDial, + TLSHandshakeTimeout: timelineGitHTTPTimeout, + ForceAttemptHTTP2: true, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errors.New("重定向过多") + } + if err := assertSafeHTTPSURL(req.URL); err != nil { + return err + } + return nil + }, + } + + seenURL := map[string]bool{} + var items []TimelineGitItem + var failMsgs []string + truncated := false + + for _, rawURL := range cleanURLs { + part, partTrunc, err := s.importOneReleaseURL(client, rawURL, seenURL, timelineReleaseMaxItems-len(items)) + if err != nil { + failMsgs = append(failMsgs, fmt.Sprintf("%s:%s", truncateTimelineStr(rawURL, 80), err.Error())) + continue + } + items = append(items, part...) + if partTrunc { + truncated = true + } + if len(items) >= timelineReleaseMaxItems { + truncated = true + break + } + } + + out := &TimelineGitImportResult{Items: items} + if truncated { + out.Warning = fmt.Sprintf("已达上限(最多 %d 条),可再贴后续页 URL", timelineReleaseMaxItems) + } + if len(failMsgs) > 0 { + out.Error = strings.Join(failMsgs, ";") + } + if len(items) == 0 && out.Error == "" { + out.Error = "未能解析出 Release" + } + return out, nil +} + +func (s *SettingService) importOneReleaseURL( + client *http.Client, + rawURL string, + seenURL map[string]bool, + remain int, +) ([]TimelineGitItem, bool, error) { + if remain <= 0 { + return nil, true, nil + } + u, err := url.Parse(rawURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, false, errors.New("URL 无效") + } + if err := assertSafeHTTPSURL(u); err != nil { + return nil, false, err + } + host := strings.ToLower(u.Hostname()) + path := u.EscapedPath() + if path == "" { + path = "/" + } + + src, caps, err := matchReleaseSource(host, path) + if err != nil { + return nil, false, err + } + if !repoNameRe.MatchString(caps["owner"]) || !repoNameRe.MatchString(caps["repo"]) { + return nil, false, errors.New("仓库名非法") + } + + startPage := 1 + if p := u.Query().Get("page"); p != "" { + if n, e := parseIntPage(p); e == nil && n >= 1 { + startPage = n + } + } + + var out []TimelineGitItem + truncated := false + page := startPage + maxPages := timelineReleaseMaxPages + pagesDone := 0 + nextURL := "" + + for pagesDone < maxPages && len(out) < remain { + var apiURL string + if nextURL != "" { + apiURL = nextURL + nextURL = "" + } else { + apiURL = expandTemplate(src.apiURL, host, caps, page) + q := url.Values{} + for k, v := range src.query { + q.Set(k, expandTemplate(v, host, caps, page)) + } + parsed, e := url.Parse(apiURL) + if e != nil { + return out, truncated, errors.New("API URL 无效") + } + if len(q) > 0 { + existing := parsed.Query() + for k, vs := range q { + existing.Set(k, vs[0]) + } + parsed.RawQuery = existing.Encode() + } + apiURL = parsed.String() + } + parsedAPI, err := url.Parse(apiURL) + if err != nil { + return out, truncated, errors.New("API URL 无效") + } + if err := assertSafeHTTPSURL(parsedAPI); err != nil { + return out, truncated, err + } + + headers := http.Header{} + for k, v := range src.headers { + if allowedAdapterHeaders[strings.ToLower(k)] { + headers.Set(k, v) + } + } + if headers.Get("User-Agent") == "" { + headers.Set("User-Agent", "jiang13-bbs") + } + + body, linkNext, status, err := httpGetLimited(client, parsedAPI.String(), headers) + if err != nil { + return out, truncated, err + } + if status == 404 || status == 401 || status == 403 { + return out, truncated, errors.New("无法读取该仓库(私有、不存在或无权访问)") + } + if status == 429 { + return out, truncated, errors.New("远端限流,请稍后再试") + } + if status < 200 || status >= 300 { + return out, truncated, fmt.Errorf("远端返回 %d", status) + } + + pageItems, err := parseReleaseListJSON(body, src, host) + if err != nil { + return out, truncated, err + } + if len(pageItems) == 0 { + break + } + for _, it := range pageItems { + if it.SourceURL != "" && seenURL[it.SourceURL] { + continue + } + if it.SourceURL != "" { + seenURL[it.SourceURL] = true + } + out = append(out, it) + if len(out) >= remain { + truncated = true + break + } + } + pagesDone++ + if src.pagination == "link_header" && linkNext != "" { + nu, e := url.Parse(linkNext) + if e != nil || assertSafeHTTPSURL(nu) != nil { + break + } + nextURL = nu.String() + } else if src.pagination == "query_page" { + page++ + } else { + break + } + } + if pagesDone >= maxPages { + truncated = true + } + return out, truncated, nil +} + +func parseReleaseListJSON(body []byte, src *timelineReleaseSource, host string) ([]TimelineGitItem, error) { + var root any + if err := json.Unmarshal(body, &root); err != nil { + return nil, errors.New("响应非 JSON") + } + arr, ok := root.([]any) + if !ok { + return nil, errors.New("响应不是 Release 列表") + } + var out []TimelineGitItem + for _, el := range arr { + item, ok := mapReleaseObject(el, src, host) + if !ok { + continue + } + out = append(out, item) + } + return out, nil +} + +func mapReleaseObject(el any, src *timelineReleaseSource, host string) (TimelineGitItem, bool) { + m, ok := el.(map[string]any) + if !ok { + return TimelineGitItem{}, false + } + // 跳过草稿 + if draft, ok := m["draft"].(bool); ok && draft { + return TimelineGitItem{}, false + } + name := jsonStringField(m, "name") + tag := jsonStringField(m, "tag_name") + title := strings.TrimSpace(name) + if title == "" { + title = strings.TrimSpace(tag) + } + if title == "" { + return TimelineGitItem{}, false + } + body := jsonStringField(m, "body") + dateRaw := jsonStringField(m, src.dateField) + if dateRaw == "" { + // 兜底用 created_at + dateRaw = jsonStringField(m, "created_at") + } + srcURL := jsonStringField(m, "html_url") + + title = sanitizeTimelinePlain(title, timelineTitleMax) + body = sanitizeTimelinePlain(body, timelineBodyMax) + title = neutralizeDirectivePlain(title) + body = neutralizeDirectivePlain(body) + date := parseCommitDate(dateRaw) + srcURL = sanitizeSourceURL(srcURL, host) + return TimelineGitItem{ + Date: date, + Title: title, + Body: body, + SourceURL: srcURL, + }, true +} + +// jsonStringField 从 map[string]any 取字符串字段,兼容 string/number +func jsonStringField(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + switch x := v.(type) { + case string: + return x + case float64: + return fmt.Sprintf("%v", x) + case json.Number: + return x.String() + default: + return fmt.Sprintf("%v", x) + } +} + +func parseIntPage(s string) (int, error) { + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0, errors.New("page 非数字") + } + n = n*10 + int(r-'0') + if n > 1_000_000 { + return 0, errors.New("page 过大") + } + } + return n, nil +} diff --git a/backend/service/upload.go b/backend/service/upload.go index 4022079..0fd3986 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -585,6 +585,35 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri return s.SaveBackground(f) } +// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件) +// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用 +func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.Attachment, error) { + var att model.Attachment + if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil { + return nil, errors.New("图片不存在或不属于你") + } + if strings.HasPrefix(att.URL, "/api/media/") && s.ops != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + r, _, e := s.ops.OpenObject(ctx, RemoteObjectID(att.URL), true) + if e != nil { + return nil, e + } + defer r.Close() + return s.SaveImage(userID, r) + } + abs, ok := s.safeUploadPath(att.URL) + if !ok { + return nil, errors.New("无效的图片地址") + } + f, err := os.Open(abs) + if err != nil { + return nil, errors.New("读取图片失败") + } + defer f.Close() + return s.SaveImage(userID, f) +} + // BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内 func (s *UploadService) BackgroundFileExists(url string) bool { rel := BgRelPath(url) diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index 9117d9f..e78de86 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -100,13 +100,7 @@ export default async function AboutIndexPage() { const right = ; return ( - 0 ? : null - } - > +
void; + ariaLabel: string; +}) { + return ( + + ); +} + function DetailsInner() { const searchParams = useSearchParams(); // 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x) @@ -86,6 +114,17 @@ function DetailsInner() { const resetPage = () => setPage(1); + const hasFilter = Boolean(kind || path || ip || bot || start || end); + const resetAll = () => { + setKind(""); + setPath(""); + setIp(""); + setBot(""); + setStart(""); + setEnd(""); + setPage(1); + }; + return (

@@ -106,48 +145,38 @@ function DetailsInner() { ariaLabel="按路径筛选" />

-
+
{ setIp(""); resetPage(); }} - placeholder="按 IP 筛选,回车应用" + placeholder="按 IP 精确筛选" ariaLabel="按 IP 筛选" />
-
+
{ setBot(""); resetPage(); }} - placeholder="按爬虫名筛选" + placeholder="按爬虫名精确筛选" ariaLabel="按爬虫名筛选" />
- - + { setStart(v); resetPage(); }} ariaLabel="开始日期" /> + { setEnd(v); resetPage(); }} ariaLabel="结束日期" /> + {hasFilter ? ( + + ) : null}
diff --git a/frontend/app/admin/friend-links/FriendLinksAdmin.tsx b/frontend/app/admin/friend-links/FriendLinksAdmin.tsx new file mode 100644 index 0000000..f0f859b --- /dev/null +++ b/frontend/app/admin/friend-links/FriendLinksAdmin.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Check, + Link2, + Loader2, + Pencil, + Plus, + RotateCcw, + Trash2, + X, +} from "lucide-react"; +import { + apiAdminCreateFriendLink, + apiAdminDeleteFriendLink, + apiAdminListFriendLinks, + apiAdminSetFriendLinkStatus, + apiAdminUpdateFriendLink, + type FriendLink, + type FriendLinkAdminInput, + type FriendLinkStatus, +} from "@/lib/api"; +import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin"; + +interface Props { + initialLinks: FriendLink[]; + initError: string; +} + +const FIELD_CLASS = "w-full rounded-lg px-3 py-2 text-[14px] outline-none transition-colors"; +const FIELD_STYLE = { + background: "var(--panel-2)", + color: "var(--ink)", + border: "1px solid var(--line)", +}; + +const TABS: { key: FriendLinkStatus; label: string }[] = [ + { key: "pending", label: "待审批" }, + { key: "approved", label: "已通过" }, + { key: "rejected", label: "已拒绝" }, +]; + +interface FormState { + name: string; + url: string; + description: string; + logo_url: string; + category: string; + reciprocal_url: string; + sort_order: number; +} + +const EMPTY_FORM: FormState = { + name: "", + url: "", + description: "", + logo_url: "", + category: "", + reciprocal_url: "", + sort_order: 0, +}; + +export default function FriendLinksAdmin({ initialLinks, initError }: Props) { + const [links, setLinks] = useState(initialLinks); + const [tab, setTab] = useState("pending"); + const [editorOpen, setEditorOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(initError); + + const counts = useMemo(() => { + const c = { pending: 0, approved: 0, rejected: 0 } as Record; + for (const l of links) c[l.status] = (c[l.status] || 0) + 1; + return c; + }, [links]); + + const filtered = useMemo( + () => links.filter((l) => l.status === tab).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id), + [links, tab] + ); + + const refresh = async () => { + try { + const res = await apiAdminListFriendLinks(); + setLinks(res.links ?? []); + setError(""); + } catch (e) { + setError(e instanceof Error ? e.message : "加载失败"); + } + }; + + const openCreate = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setEditorOpen(true); + }; + + const openEdit = (l: FriendLink) => { + setEditingId(l.id); + setForm({ + name: l.name, + url: l.url, + description: l.description || "", + logo_url: l.logo_url || "", + category: l.category || "", + reciprocal_url: l.reciprocal_url || "", + sort_order: l.sort_order, + }); + setEditorOpen(true); + }; + + const closeEditor = () => { + setEditorOpen(false); + setEditingId(null); + setForm(EMPTY_FORM); + }; + + const set = (k: keyof FormState, v: string | number) => + setForm((f) => ({ ...f, [k]: v })); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (saving) return; + setSaving(true); + setError(""); + try { + const body: FriendLinkAdminInput = { + name: form.name.trim(), + url: form.url.trim(), + description: form.description.trim() || undefined, + logo_url: form.logo_url.trim() || undefined, + category: form.category.trim() || undefined, + reciprocal_url: form.reciprocal_url.trim() || undefined, + sort_order: form.sort_order, + }; + if (editingId == null) { + await apiAdminCreateFriendLink(body); + } else { + await apiAdminUpdateFriendLink(editingId, body); + } + closeEditor(); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + const setStatus = async (id: number, status: FriendLinkStatus) => { + let rejectReason = ""; + if (status === "rejected") { + const r = window.prompt("请输入拒绝理由(可选)", ""); + if (r === null) return; + rejectReason = r; + } + setBusyId(id); + setError(""); + try { + await apiAdminSetFriendLinkStatus(id, status, rejectReason || undefined); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "操作失败"); + } finally { + setBusyId(null); + } + }; + + const remove = async (id: number) => { + if (!window.confirm("确定删除该友情链接?此操作不可撤销。")) return; + setBusyId(id); + setError(""); + try { + await apiAdminDeleteFriendLink(id); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "删除失败"); + } finally { + setBusyId(null); + } + }; + + return ( + + + 新建 + + } + /> + + {error ?

{error}

: null} + +
+
+ {TABS.map((t) => { + const active = tab === t.key; + const n = counts[t.key] || 0; + return ( + + ); + })} +
+ + {filtered.length === 0 ? ( + } + title={tab === "pending" ? "暂无待审批申请" : tab === "approved" ? "暂无已收录友链" : "暂无被拒绝的申请"} + description={tab === "pending" ? "用户提交的申请会出现在这里" : undefined} + /> + ) : ( +
    + {filtered.map((l) => ( +
  • + {l.logo_url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + + + )} +
    +
    + + {l.name} + + {l.category ? ( + + {l.category} + + ) : null} + #{l.id} · 顺序 {l.sort_order} +
    + {l.description ? ( +

    {l.description}

    + ) : null} + {l.reciprocal_url ? ( +

    + 回链: + + {l.reciprocal_url} + +

    + ) : null} + {l.status === "rejected" && l.reject_reason ? ( +

    拒绝理由:{l.reject_reason}

    + ) : null} +
    +
    + {busyId === l.id ? ( + + ) : null} + {l.status === "pending" ? ( + <> + + + + ) : null} + {l.status === "rejected" ? ( + + ) : null} + {l.status === "approved" ? ( + <> + + + + ) : null} + {l.status === "pending" ? ( + + ) : null} + +
    +
  • + ))} +
+ )} +
+ + {editorOpen ? ( +
+
e.stopPropagation()} + > +

+ {editingId == null ? "新建友情链接" : "编辑友情链接"} +

+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ ) : null} +
+ ); +} diff --git a/frontend/app/admin/friend-links/page.tsx b/frontend/app/admin/friend-links/page.tsx new file mode 100644 index 0000000..3814e24 --- /dev/null +++ b/frontend/app/admin/friend-links/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { cookies } from "next/headers"; +import FriendLinksAdmin from "./FriendLinksAdmin"; +import { authCookieHeader } from "@/lib/cookies"; +import { fetchAdminFriendLinks, type FriendLink } from "@/lib/api"; +import { getMeCached } from "@/lib/serverData"; +import { isSuperOrOwner } from "@/lib/roles"; +import Forbidden from "../Forbidden"; + +export const metadata: Metadata = { + title: "友情链接管理", +}; + +export default async function AdminFriendLinksPage() { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + if (!me.user || !isSuperOrOwner(me.user.role)) { + return ; + } + + let links: FriendLink[] = []; + let initError = ""; + try { + const res = await fetchAdminFriendLinks(cookie || undefined); + links = res.links ?? []; + } catch (e) { + initError = e instanceof Error ? e.message : "加载失败"; + } + + return ( + 加载中…

}> + +
+ ); +} diff --git a/frontend/app/admin/library/ImportMdDialog.tsx b/frontend/app/admin/library/ImportMdDialog.tsx new file mode 100644 index 0000000..ea6cd67 --- /dev/null +++ b/frontend/app/admin/library/ImportMdDialog.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { FileText, Loader2, Upload, X } from "lucide-react"; +import Modal from "@/components/Modal"; +import { toast } from "@/lib/toast"; +import type { LibraryImportMdInput, LibrarySectionItem } from "@/lib/api"; +import { parseEpub } from "./parseEpub"; + +const MAX_IMPORT_BYTES = 5 * 1024 * 1024; // 与后端 MaxImportBytes 一致(单文件) +const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 与后端 MaxImportTotalBytes 一致 +const MAX_FILES = 50; // 与后端 MaxImportFiles 一致 +const MAX_EPUB_BYTES = 50 * 1024 * 1024; // epub 原始文件上限(二进制,图片占大头,转出文本通常很小) +const MAX_CHAPTER_CHARS = 100_000; // 与后端 MaxSectionContent 一致(epub 每目录项即一章) + +type SplitLevel = "h1" | "h2" | "none"; + +const SPLIT_OPTIONS: { value: SplitLevel; label: string; hint: string }[] = [ + { value: "h1", label: "按一级标题(#)", hint: "每个 # 标题拆为一章" }, + { value: "h2", label: "按二级标题(##)", hint: "每个 ## 标题拆为一章" }, + { value: "none", label: "不拆分", hint: "每个文件整篇一章" }, +]; + +interface PendingFile { + name: string; + text: string; +} + +/** 与后端 splitMarkdownChapters 逻辑一致:跳过代码围栏,按所选级别标题取章标题列表 */ +function splitPreview(content: string, level: SplitLevel): string[] { + if (level === "none") return []; + const prefix = level === "h2" ? "## " : "# "; + const titles: string[] = []; + let inCode = false; + let count = 0; + for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) { + const t = raw.trim(); + if (t.startsWith("```")) inCode = !inCode; + if (!inCode && t.startsWith(prefix) && t.length > prefix.length) { + const title = t.slice(prefix.length).replace(/#+\s*$/, "").trim(); + titles.push(title || `第 ${++count} 章`); + } + } + return titles; +} + +/** 单个文件预计拆出的章数(未命中标题回退 1 章,标题取文件名) */ +function splitCount(text: string, level: SplitLevel): number { + if (level === "none") return 1; + const n = splitPreview(text, level).length; + return n > 0 ? n : 1; +} + +export default function ImportMdDialog({ + onImport, + onClose, +}: { + /** 执行导入,返回新建章节(父组件负责刷新与选中第一章);失败时抛错 */ + onImport: (input: LibraryImportMdInput) => Promise; + /** 关闭弹窗;导入成功时带出新建章节,父组件据此选中第一章 */ + onClose: (created?: LibrarySectionItem[]) => void; +}) { + const fileInputRef = useRef(null); + const [files, setFiles] = useState([]); + const [pasted, setPasted] = useState(""); + const [splitLevel, setSplitLevel] = useState("h2"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + // epub 模式:列表已被 epub 解析结果整体替换,按目录顺序每项一章(不参与拆分) + const [fromEpub, setFromEpub] = useState(false); + const [epubBusy, setEpubBusy] = useState(false); + + // 已选文件优先;未选文件时用粘贴内容(视为一个伪文件) + const sourceFiles = useMemo( + () => + files.length > 0 + ? files + : pasted.trim() + ? [{ name: "未命名导入.md", text: pasted }] + : [], + [files, pasted] + ); + + const perFileCounts = useMemo( + () => sourceFiles.map((f) => ({ name: f.name, count: splitCount(f.text, splitLevel) })), + [sourceFiles, splitLevel] + ); + const totalChapters = perFileCounts.reduce((n, f) => n + f.count, 0); + const single = sourceFiles.length === 1 ? sourceFiles[0] : null; + const singleTitles = useMemo( + () => (single && splitLevel !== "none" ? splitPreview(single.text, splitLevel) : []), + [single, splitLevel] + ); + const willFallback = + single != null && splitLevel !== "none" && singleTitles.length === 0 && single.text.trim().length > 0; + + const oversizeFile = sourceFiles.find((f) => f.text.length > MAX_IMPORT_BYTES); + const overFiles = sourceFiles.length > MAX_FILES; + const overTotal = sourceFiles.reduce((n, f) => n + f.text.length, 0) > MAX_TOTAL_BYTES; + // epub 每个目录项即一章,无法再拆;超限章直接阻止导入(与后端校验一致) + const oversizeChapters = fromEpub + ? sourceFiles.filter((f) => f.text.length > MAX_CHAPTER_CHARS) + : []; + + async function pickFiles(list: FileList | null) { + if (!list || list.length === 0) return; + setError(""); + const msgs: string[] = []; + const fileList = Array.from(list); + const epubFiles = fileList.filter((f) => f.name.toLowerCase().endsWith(".epub")); + const mdFiles = fileList.filter((f) => !f.name.toLowerCase().endsWith(".epub")); + + // epub:解析为章节伪文件并整体替换(同一批次不与 md 混选) + if (epubFiles.length > 0) { + setEpubBusy(true); + try { + const chapters: PendingFile[] = []; + for (const f of epubFiles) { + if (f.size > MAX_EPUB_BYTES) { + msgs.push(`超过 50MB 已跳过:${f.name}`); + continue; + } + const parsed = await parseEpub(f); + for (const c of parsed) { + chapters.push({ + name: `${String(chapters.length + 1).padStart(3, "0")}-${c.title || "未命名"}.md`, + text: c.markdown, + }); + } + } + if (chapters.length > 0) { + if (chapters.length > MAX_FILES) { + msgs.push(`epub 解析出 ${chapters.length} 章,超过单次上限,已截取前 ${MAX_FILES} 章`); + } + setFiles(chapters.slice(0, MAX_FILES)); + setSplitLevel("none"); + setFromEpub(true); + } else if (!msgs.length) { + msgs.push("未能从 epub 中解析出章节"); + } + } catch (e) { + msgs.push(e instanceof Error ? e.message : "epub 解析失败"); + } finally { + setEpubBusy(false); + } + } else { + // md/txt:沿用原合并逻辑;当前若是 epub 章节列表则先清空 + const picked: PendingFile[] = []; + const tooBig: string[] = []; + const failed: string[] = []; + for (const f of mdFiles) { + if (f.size > MAX_IMPORT_BYTES) { + tooBig.push(f.name); + continue; + } + try { + picked.push({ name: f.name, text: await f.text() }); + } catch { + failed.push(f.name); + } + } + let truncated = false; + setFiles((prev) => { + const merged = [...(fromEpub ? [] : prev), ...picked]; + // 文件名自然排序,保证 01、02…23 顺序 + merged.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true })); + truncated = merged.length > MAX_FILES; + return merged.slice(0, MAX_FILES); + }); + if (fromEpub && picked.length > 0) { + setFromEpub(false); + msgs.push("已清空 epub 章节,改用所选 Markdown 文件"); + } + if (tooBig.length) msgs.push(`超过 5MB 已跳过:${tooBig.join("、")}`); + if (failed.length) msgs.push(`无法读取:${failed.join("、")}`); + if (truncated) msgs.push(`单次最多 ${MAX_FILES} 个文件,已截取前 ${MAX_FILES} 个`); + } + + if (msgs.length) setError(msgs.join(";")); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + + function removeAt(idx: number) { + setFiles((prev) => prev.filter((_, i) => i !== idx)); + } + + async function submit() { + if (sourceFiles.length === 0) { + setError("请先选择文件或粘贴 Markdown 内容"); + return; + } + if (oversizeFile) { + setError(`单个文件不能超过 5MB:${oversizeFile.name}`); + return; + } + if (overFiles) { + setError(`单次最多导入 ${MAX_FILES} 个文件`); + return; + } + if (overTotal) { + setError("内容总量过大:不能超过 20MB"); + return; + } + if (oversizeChapters.length > 0) { + setError( + `以下章节正文超过 100000 字,请先拆分后再导入:${oversizeChapters.map((f) => f.name).join("、")}` + ); + return; + } + setBusy(true); + setError(""); + try { + const created = await onImport({ + files: sourceFiles.map((f) => ({ filename: f.name, content: f.text })), + split_level: splitLevel, + }); + toast( + sourceFiles.length > 1 + ? `已导入 ${sourceFiles.length} 个文件共 ${created.length} 章` + : `已导入 ${created.length} 章`, + "ok" + ); + onClose(created); + } catch (e) { + setError(e instanceof Error ? e.message : "导入失败"); + } finally { + setBusy(false); + } + } + + const blocked = Boolean(oversizeFile || overFiles || overTotal || oversizeChapters.length > 0); + + return ( + + + + + } + > +
+
+

+ 来源 + {sourceFiles.length > 1 ? `(${sourceFiles.length} 个文件,按文件名顺序导入)` : ""} +

+
+ + {files.length > 0 ? ( + + ) : null} +
+ void pickFiles(e.target.files)} + /> +
+ + {files.length > 0 ? ( +
    + {files.map((f, i) => ( +
  • + + {f.name} + + {f.text.length} 字符 + +
  • + ))} +
+ ) : ( +
+ +