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