feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
391
backend/handler/library.go
Normal file
391
backend/handler/library.go
Normal file
@@ -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)})
|
||||
}
|
||||
Reference in New Issue
Block a user