Files
book-comic-library/backend/cmd/webui/handlers/uploads.go
T
XingfenD fbd5cd243d feat(upload): extract upload subsystem + move sweep to scanner ticker (B16)
- internal/upload owns chunked-upload domain logic (fingerprint resume,
  part tmp+rename, assemble, session sweep) and the shared UniquePath
  helper used by both single-file and chunked completion paths
- handlers/uploads.go is now HTTP-only: bind params, call upload.U, map
  sentinel errors to the unchanged status/code/message contract
- B16: session sweep moved out of the UploadInit request path onto the
  scanner ticker via a Sweeper hook (upload.U satisfies it)
- unit tests for the package without PG/Redis; contract pinned by the
  existing handler integration tests (all green)
2026-09-14 22:00:25 +08:00

137 lines
4.2 KiB
Go

package handlers
import (
"errors"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"booklib/internal/ports"
"booklib/internal/upload"
)
// 分片上传:HTTP 层只做参数绑定与错误映射,域逻辑(指纹续传/分片落盘/拼接/清扫)
// 全部在 internal/upload;会话清扫由 scanner ticker 接管(B16),不在请求路径。
// maxChunkBytes 与 upload 包内常量同值,作为 PutPart 的防御性读取上限。
const maxChunkBytes = 32 << 20
func (h *H) UploadInit(c *gin.Context) {
lib, ok := h.getLibrary(c)
if !ok {
return
}
if _, ok := h.libRoot(c, lib); !ok {
return
}
var req struct {
Name string `json:"name"`
Size int64 `json:"size"`
ChunkSize int64 `json:"chunkSize"`
}
if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "name, size required")
return
}
uid, e := h.up.Init(c, lib.ID, req.Name, req.Size, req.ChunkSize)
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
}
func (h *H) UploadStatus(c *gin.Context) {
recv, e := h.up.Status(c, c.Param("uid"))
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusOK, gin.H{"received": recv})
}
func (h *H) UploadPart(c *gin.Context) {
uid := c.Param("uid")
idx, e := strconv.ParseInt(c.Param("index"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad part index")
return
}
// maxSize 防御性上限:分片声明大小由会话 meta 决定,这里再垫一层 32MB 全局上限
e = h.up.PutPart(c, uid, idx, c.Request.Body, maxChunkBytes)
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (h *H) UploadComplete(c *gin.Context) {
uid := c.Param("uid")
libID, e := h.up.LibraryID(c, uid)
if e != nil {
h.mapUploadErr(c, e)
return
}
lib, e := h.st.GetLibrary(c, libID)
if e != nil {
dbErr(c, e)
return
}
root, ok := h.libRoot(c, lib)
if !ok {
return
}
rel, e := h.up.Complete(c, uid, root)
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": rel})
}
// mapUploadErr 把 upload 包的 sentinel 错误映射为原契约的 status/code/message。
func (h *H) mapUploadErr(c *gin.Context, e error) {
switch {
case errors.Is(e, ports.ErrTooLarge):
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
case errors.Is(e, upload.ErrBadName):
err(c, http.StatusBadRequest, "bad_request", "bad name")
case errors.Is(e, upload.ErrBadFormat):
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
case errors.Is(e, upload.ErrBadSize):
err(c, http.StatusBadRequest, "bad_request", "bad size")
case errors.Is(e, upload.ErrBadChunk):
err(c, http.StatusBadRequest, "bad_request", "chunkSize must be <= 33554432")
case errors.Is(e, upload.ErrBadUploadID):
err(c, http.StatusBadRequest, "bad_request", "bad upload id")
case errors.Is(e, upload.ErrBadIndex):
err(c, http.StatusBadRequest, "bad_request", "bad part index")
case errors.Is(e, upload.ErrPartTooBig):
err(c, http.StatusRequestEntityTooLarge, "too_large", "part exceeds declared size")
case errors.Is(e, upload.ErrPartSizeMismatch):
err(c, http.StatusRequestEntityTooLarge, "too_large", "part size mismatch")
case errors.Is(e, ports.ErrNotFound):
err(c, http.StatusNotFound, "not_found", "no such upload")
case errors.Is(e, upload.ErrCorrupt):
err(c, http.StatusInternalServerError, "internal", "corrupt session")
case errors.Is(e, ports.ErrIncomplete):
err(c, http.StatusBadRequest, "bad_request", "upload incomplete; missing or corrupt parts, re-upload them")
case errors.Is(e, ports.ErrSizeMismatch):
err(c, http.StatusBadRequest, "bad_request", "total size mismatch")
case errors.Is(e, os.ErrInvalid), errors.Is(e, os.ErrExist):
err(c, http.StatusForbidden, "forbidden", e.Error())
case errors.Is(e, os.ErrPermission), errors.Is(e, os.ErrClosed):
err(c, http.StatusInternalServerError, "internal", "io error")
default:
var oe *upload.OpError
if errors.As(e, &oe) {
err(c, http.StatusInternalServerError, "internal", oe.Op)
return
}
err(c, http.StatusInternalServerError, "internal", "upload failed")
}
}