FileCodeBox Go 重写版 v2.5.6(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务: - 安全审计全部修复(docs/security-audit-2026-09-05.md): bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、 全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、 管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、 会话默认 7 天、限流缓存故障降级、robots.txt 端点等 - 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固 - Redis 支持库号(FCB_REDIS_DB / redis://…/db URL) - 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、 提码 5 位起、chunk 32MiB 上限、admin 审计动作等) 验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/storage"
|
||||
)
|
||||
|
||||
// presignSessionExpires 预签名会话有效期(对齐参考 PRESIGN_SESSION_EXPIRES=900 秒)。
|
||||
const presignSessionExpires = 900
|
||||
|
||||
// getValidSession 校验并返回预签名会话(对齐参考 _get_valid_session):
|
||||
// 不存在 404、已过期删除后 404、mode 不符 400。
|
||||
func (d *Deps) getValidSession(c *gin.Context, uploadID, expectedMode string) (*model.PresignUploadSession, error) {
|
||||
ctx := c.Request.Context()
|
||||
var session model.PresignUploadSession
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", uploadID).First(&session).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errNotFound("上传会话不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errInternal("查询上传会话失败: " + err.Error())
|
||||
}
|
||||
if session.IsExpired(time.Now()) {
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
return nil, errNotFound("上传会话已过期")
|
||||
}
|
||||
if expectedMode != "" && session.Mode != expectedMode {
|
||||
return nil, errBadRequest("此会话不支持" + expectedMode + "模式")
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// ============ POST /presign/upload/init 初始化预签名上传 ============
|
||||
|
||||
// presignInitRequest init 请求体(对齐参考 PresignUploadInitRequest)。
|
||||
type presignInitRequest struct {
|
||||
FileName string `json:"file_name" form:"file_name"`
|
||||
FileSize int64 `json:"file_size" form:"file_size"`
|
||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||
Code string `json:"code" form:"code"` // v3.1:自定义提取码(init 时校验,完成时落库)
|
||||
}
|
||||
|
||||
// presignInit 初始化预签名上传(对齐参考 presign_upload_init):
|
||||
// 引擎支持直链(S3)返回 direct + 预签名 PUT URL;否则返回 proxy + 代理地址。
|
||||
func (d *Deps) presignInit(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
var req presignInitRequest
|
||||
if err := bindJSONOrForm(c, &req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
safeName := storage.SanitizeFileName(req.FileName)
|
||||
if safeName == "" {
|
||||
auditRecordFailed(c, d.AuditSvc, "文件名非法")
|
||||
response.Fail(c, http.StatusBadRequest, "文件名非法")
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
|
||||
if err := validatePickupCode(req.Code); err != nil {
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateFileMagic(d.Cfg, safeName, "", nil); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件类型被拒绝")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(req.FileSize); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// M2:直传 confirm 的实际大小校验依赖真实对象,0/负值声明直接拒绝
|
||||
if req.FileSize <= 0 {
|
||||
auditRecordFailed(c, d.AuditSvc, "file_size 非法")
|
||||
response.Fail(c, http.StatusBadRequest, "file_size 必须大于 0")
|
||||
return
|
||||
}
|
||||
if req.ExpireValue <= 0 {
|
||||
req.ExpireValue = 1
|
||||
}
|
||||
if req.ExpireStyle == "" {
|
||||
req.ExpireStyle = "day"
|
||||
}
|
||||
if _, err := resolveExpire(d.Cfg, req.ExpireValue, req.ExpireStyle); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "过期策略非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
uploadID := uuidHex()
|
||||
resToken := "presign:" + uploadID
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, resToken, req.FileSize, presignSessionExpires*time.Second); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "容量预留失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
dirPath, _, _, _, savePath := buildSavePath(d.Cfg, safeName, uploadID)
|
||||
mode := "proxy"
|
||||
uploadURL := "/presign/upload/proxy/" + uploadID
|
||||
putURL, err := d.Store.PresignPutURL(ctx, savePath, presignSessionExpires)
|
||||
switch {
|
||||
case err == nil:
|
||||
mode = "direct"
|
||||
uploadURL = putURL
|
||||
case errors.Is(err, storage.ErrNotSupported):
|
||||
// 引擎不支持直传:代理模式
|
||||
default:
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "生成预签名失败")
|
||||
respondError(c, mapStorageError(err))
|
||||
return
|
||||
}
|
||||
|
||||
session := model.PresignUploadSession{
|
||||
UploadID: uploadID,
|
||||
FileName: safeName,
|
||||
FileSize: req.FileSize,
|
||||
SavePath: savePath,
|
||||
Mode: mode,
|
||||
Engine: d.Store.CurrentName(), // v3:会话归属引擎
|
||||
ExpireValue: req.ExpireValue,
|
||||
ExpireStyle: req.ExpireStyle,
|
||||
ExpiresAt: time.Now().Add(presignSessionExpires * time.Second),
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
|
||||
releaseStorage(ctx, d.DB, resToken)
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "会话创建失败")
|
||||
respondError(c, errInternal("创建上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, uploadID, safeName, req.FileSize, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
detail := gin.H{
|
||||
"upload_id": uploadID,
|
||||
"upload_url": uploadURL,
|
||||
"mode": mode,
|
||||
"expires_in": presignSessionExpires,
|
||||
"file_path": dirPath,
|
||||
}
|
||||
if mode == "proxy" {
|
||||
detail["proxy_upload_url"] = uploadURL
|
||||
detail["legacy_proxy_upload_url"] = "/api" + uploadURL
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
// ============ PUT /presign/upload/proxy/{uploadID} 代理上传 ============
|
||||
|
||||
// presignProxy 代理模式上传(对齐参考 presign_upload_proxy):
|
||||
// 服务器接收文件并转存到存储引擎,随后立即创建分享记录。
|
||||
func (d *Deps) presignProxy(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
session, err := d.getValidSession(c, uploadID, "proxy")
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码随代理上传表单携带(init 时已预校验)
|
||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "presign:"+uploadID, session.FileSize, presignSessionExpires*time.Second); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "缺少 file 字段")
|
||||
response.Fail(c, http.StatusBadRequest, "缺少上传文件 file 字段")
|
||||
return
|
||||
}
|
||||
// 动态策略快照(与 share/chunk 上传路径一致,消除会话窗口内的策略滞后)
|
||||
maxSize := d.CurrentUploadPolicy().MaxFileSize
|
||||
if maxSize > 0 && fh.Size > maxSize {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "大小超过限制")
|
||||
response.Fail(c, http.StatusForbidden, fmt.Sprintf("大小超过限制,最大为%s", humanSize(maxSize)))
|
||||
return
|
||||
}
|
||||
// 文件大小与声明不符(±1KB 容差,对齐参考)
|
||||
if abs64(fh.Size-session.FileSize) > 1024 {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小与声明不符")
|
||||
response.Fail(c, http.StatusBadRequest, "文件大小与声明不符")
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err == nil {
|
||||
defer func() { _ = f.Close() }()
|
||||
if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil {
|
||||
// v3:落盘走会话归属引擎
|
||||
var ps storage.Storage
|
||||
ps, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
err = sErr
|
||||
} else if _, err = ps.SaveFile(ctx, f, session.SavePath); err != nil {
|
||||
// 落盘失败,err 交给统一错误处理
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
|
||||
if isStorageErr(err) {
|
||||
respondError(c, mapStorageError(err))
|
||||
} else {
|
||||
respondError(c, errInternal("文件保存失败: "+err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
code, err := d.createRecordFromSession(c, session, c.PostForm("code"))
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
if err != nil {
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, session.FileName, session.FileSize, fh.Size)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": session.FileName})
|
||||
}
|
||||
|
||||
// ============ POST /presign/upload/confirm/{uploadID} 直传确认 ============
|
||||
|
||||
// presignConfirm 直传确认(对齐参考 presign_upload_confirm):
|
||||
// 客户端完成 S3 直传后调用,校验文件已存在并创建分享记录。
|
||||
func (d *Deps) presignConfirm(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
session, err := d.getValidSession(c, uploadID, "direct")
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, err.Error())
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "presign:"+uploadID, session.FileSize, presignSessionExpires*time.Second); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// v3:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
|
||||
psCheck, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "存储引擎不可用: "+sErr.Error())
|
||||
respondError(c, mapStorageError(sErr))
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
|
||||
customCode := c.Query("code")
|
||||
if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
var fin struct {
|
||||
Code string `json:"code" form:"code"`
|
||||
}
|
||||
if err := bindJSONOrForm(c, &fin); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
customCode = fin.Code
|
||||
}
|
||||
|
||||
exists, err := psCheck.FileExists(ctx, session.SavePath)
|
||||
if err == nil && !exists {
|
||||
err = errNotFound("文件未上传或上传失败")
|
||||
}
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件未上传或上传失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// M2 修复:直传内容不经过服务器,confirm 必须核实实际大小与内容类型。
|
||||
meta, head, hErr := psCheck.HeadMeta(ctx, session.SavePath, 64)
|
||||
if hErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件信息读取失败")
|
||||
respondError(c, mapStorageError(hErr))
|
||||
return
|
||||
}
|
||||
if meta == nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件未上传或上传失败")
|
||||
respondError(c, errNotFound("文件未上传或上传失败"))
|
||||
return
|
||||
}
|
||||
// 大小上限:实际大小超过策略上限 → 删除对象并 403(防绕过 max_file_size / storageLimit)
|
||||
maxSize := d.CurrentUploadPolicy().MaxFileSize
|
||||
if maxSize > 0 && meta.Size > maxSize {
|
||||
_ = psCheck.DeleteFile(ctx, session.SavePath)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "实际文件大小超过限制")
|
||||
respondError(c, errForbidden(fmt.Sprintf("大小超过限制,最大为%s", humanSize(maxSize))))
|
||||
return
|
||||
}
|
||||
// 大小与声明不符(±1KB 容差,对齐 proxy 模式):超差删除对象并 400
|
||||
if abs64(meta.Size-session.FileSize) > 1024 {
|
||||
_ = psCheck.DeleteFile(ctx, session.SavePath)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小与声明不符")
|
||||
respondError(c, errBadRequest("文件大小与声明不符"))
|
||||
return
|
||||
}
|
||||
// 内容类型防伪造(对齐 proxy 模式 magic bytes 校验)
|
||||
if err := validateFileMagic(d.Cfg, session.FileName, "", head); err != nil {
|
||||
_ = psCheck.DeleteFile(ctx, session.SavePath)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件内容校验失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := d.createRecordFromSession(c, session, customCode)
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
if err != nil {
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, session.FileName, session.FileSize, session.FileSize)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": session.FileName})
|
||||
}
|
||||
|
||||
// createRecordFromSession 依据预签名会话创建分享记录(对齐参考 create_file_record)。
|
||||
func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUploadSession, customCode string) (string, error) {
|
||||
exp, err := resolveExpire(d.Cfg, session.ExpireValue, session.ExpireStyle)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// v3.1:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
|
||||
if err := validatePickupCode(customCode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, customCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir, name := splitDirBase(session.SavePath)
|
||||
ext := baseExt(name)
|
||||
fc := model.FileCodes{
|
||||
Code: code,
|
||||
Prefix: trimExt(name),
|
||||
Suffix: ext,
|
||||
UUIDFileName: &name,
|
||||
FilePath: &dir,
|
||||
Size: session.FileSize,
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: session.Engine, // v3:归属引擎戳
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||
return "", mapCodeConflict(err) // v3.1:并发占用自定义码 → 友好 400
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ============ GET /presign/upload/status/{uploadID} ============
|
||||
|
||||
// presignStatus 查询预签名会话状态(对齐参考 presign_upload_status)。
|
||||
func (d *Deps) presignStatus(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
ctx := c.Request.Context()
|
||||
var session model.PresignUploadSession
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", uploadID).First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"upload_id": session.UploadID,
|
||||
"file_name": session.FileName,
|
||||
"file_size": session.FileSize,
|
||||
"mode": session.Mode,
|
||||
"created_at": session.CreatedAt.Format(time.RFC3339),
|
||||
"expires_at": session.ExpiresAt.Format(time.RFC3339),
|
||||
"is_expired": session.IsExpired(time.Now()),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ DELETE /presign/upload/{uploadID} 取消会话 ============
|
||||
|
||||
// presignCancel 取消预签名上传会话(对齐参考 presign_upload_cancel):
|
||||
// 直传模式尽力清理已直传的文件。
|
||||
func (d *Deps) presignCancel(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
ctx := c.Request.Context()
|
||||
session, err := d.getValidSession(c, uploadID, "")
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if session.Mode == "direct" {
|
||||
// v3:清理走会话归属引擎
|
||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||
if exists, eErr := ps.FileExists(ctx, session.SavePath); eErr == nil && exists {
|
||||
_ = ps.DeleteFile(ctx, session.SavePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).
|
||||
Delete(&model.PresignUploadSession{}).Error; err != nil {
|
||||
respondError(c, errInternal("取消上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||
response.OK(c, gin.H{"message": "上传会话已取消"})
|
||||
}
|
||||
|
||||
// ============ 杂项 ============
|
||||
|
||||
// abs64 绝对值。
|
||||
func abs64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isStorageErr 判断是否为存储层哨兵错误(含 %w 包装)。
|
||||
func isStorageErr(err error) bool {
|
||||
return err != nil && (errors.Is(err, storage.ErrNotFound) ||
|
||||
errors.Is(err, storage.ErrInvalidPath) ||
|
||||
errors.Is(err, storage.ErrUnavailable) ||
|
||||
errors.Is(err, storage.ErrNotSupported) ||
|
||||
errors.Is(err, storage.ErrRangeNotSatisfiable) ||
|
||||
errors.Is(err, storage.ErrHashMismatch))
|
||||
}
|
||||
Reference in New Issue
Block a user