Files
jiang13-bbs/backend/handler/library.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

392 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)})
}