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)}) }