新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
500 lines
16 KiB
Go
500 lines
16 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"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
|
||
}
|
||
var creatorID uint
|
||
if claims := middleware.CurrentUser(c); claims != nil {
|
||
creatorID = claims.ID
|
||
}
|
||
d, err := h.LibrarySvc.Create(&in, creatorID)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
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})
|
||
}
|
||
|
||
// ---------- 管理端:导出(迁移 / 备份) ----------
|
||
|
||
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
|
||
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
|
||
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
|
||
c.Header("Content-Type", "application/zip")
|
||
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
|
||
c.Header("X-Content-Type-Options", "nosniff")
|
||
c.Status(http.StatusOK)
|
||
if err := exp.WriteZip(c.Writer); err != nil {
|
||
_ = c.Error(err)
|
||
}
|
||
}
|
||
|
||
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
|
||
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||
return
|
||
}
|
||
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
|
||
if err != nil {
|
||
if errors.Is(err, service.ErrLibraryNotFound) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||
return
|
||
}
|
||
h.writeLibraryExport(c, exp)
|
||
}
|
||
|
||
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
|
||
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
|
||
exp, err := h.LibrarySvc.BuildAllExport()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||
return
|
||
}
|
||
h.writeLibraryExport(c, exp)
|
||
}
|
||
|
||
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
|
||
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
if claims == nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||
return
|
||
}
|
||
|
||
const overhead = 64 << 10 // multipart 边界开销
|
||
limit := int64(service.LibraryImportMaxBytes) + overhead
|
||
if c.Request.ContentLength > limit {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"})
|
||
return
|
||
}
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||
|
||
fh, err := c.FormFile("file")
|
||
if err != nil {
|
||
var maxErr *http.MaxBytesError
|
||
if errors.As(err, &maxErr) {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
|
||
return
|
||
}
|
||
if fh.Size > service.LibraryImportMaxBytes {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
|
||
return
|
||
}
|
||
|
||
mode := c.PostForm("mode")
|
||
if mode == "" {
|
||
mode = service.LibraryImportModeCreate
|
||
}
|
||
|
||
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
|
||
return
|
||
}
|
||
defer os.Remove(tmpPath)
|
||
|
||
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, service.ErrLibraryImportBadZip),
|
||
errors.Is(err, service.ErrLibraryImportFormat),
|
||
errors.Is(err, service.ErrLibraryImportVer),
|
||
errors.Is(err, service.ErrLibraryImportEmpty),
|
||
errors.Is(err, service.ErrLibraryImportMode):
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
default:
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||
}
|
||
|
||
// ---------- 管理端:章节 ----------
|
||
|
||
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)
|
||
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)})
|
||
}
|