26.9(安全审计修复版)
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,667 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"filecodebox/internal/middleware"
|
||||
"filecodebox/internal/model"
|
||||
"filecodebox/internal/response"
|
||||
"filecodebox/internal/storage"
|
||||
)
|
||||
|
||||
// chunkExpireTTL 分片会话保留时长(M5:预留窗口由 24h 缩短为 2h;
|
||||
// 会话本身保留 24h 支持断点续传,见 janitor 的清理周期)。
|
||||
const chunkExpireTTL = 2 * time.Hour
|
||||
|
||||
// maxChunkSizeBytes 单分片大小上限 32MB(M3:限制 io.ReadAll 内存占用)。
|
||||
const maxChunkSizeBytes = 32 * 1024 * 1024
|
||||
|
||||
// ============ POST /chunk/upload/init 初始化分片会话 ============
|
||||
|
||||
// requireChunkEnabled L4:enableChunk 开关后端强制(此前仅前端隐藏入口,
|
||||
// 开关关闭后 /chunk/* 接口仍可直接调用)。
|
||||
func (d *Deps) requireChunkEnabled(c *gin.Context) bool {
|
||||
if d.Cfg.EnableChunk() {
|
||||
return true
|
||||
}
|
||||
auditRecordFailed(c, d.AuditSvc, "分片上传未启用")
|
||||
response.Fail(c, http.StatusForbidden, "分片上传未启用")
|
||||
return false
|
||||
}
|
||||
|
||||
// chunkInitRequest init 请求体(JSON 或表单)。
|
||||
type chunkInitRequest struct {
|
||||
FileName string `json:"file_name" form:"file_name"`
|
||||
ChunkSize int64 `json:"chunk_size" form:"chunk_size"`
|
||||
FileSize int64 `json:"file_size" form:"file_size"`
|
||||
FileHash string `json:"file_hash" form:"file_hash"`
|
||||
}
|
||||
|
||||
// chunkInit 创建分片上传会话(对齐参考 init_chunk_upload):
|
||||
// 支持断点续传(相同 hash/大小/文件名的未完成会话直接续传)。
|
||||
func (d *Deps) chunkInit(c *gin.Context) {
|
||||
if !d.requireChunkEnabled(c) {
|
||||
return
|
||||
}
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
var req chunkInitRequest
|
||||
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
|
||||
}
|
||||
// 文件类型白名单(无内容可校验,仅名称)
|
||||
if err := validateFileMagic(d.Cfg, safeName, "", nil); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件类型被拒绝")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
chunkSize := req.ChunkSize
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 5 * 1024 * 1024 // 默认 5MB(对齐参考 InitChunkUploadModel)
|
||||
}
|
||||
// M3:单片全部读入内存后再落存储,必须限制单片大小(客户端声明的
|
||||
// chunk_size 上界受策略约束,但策略允许至 10GiB → 显式封顶 32MB)。
|
||||
if chunkSize > maxChunkSizeBytes {
|
||||
auditRecordFailed(c, d.AuditSvc, "chunk_size 超过上限")
|
||||
response.Fail(c, http.StatusBadRequest, fmt.Sprintf("chunk_size 过大,最大为 %d MB", maxChunkSizeBytes>>20))
|
||||
return
|
||||
}
|
||||
if req.FileSize <= 0 {
|
||||
auditRecordFailed(c, d.AuditSvc, "file_size 非法")
|
||||
response.Fail(c, http.StatusBadRequest, "file_size 必须大于 0")
|
||||
return
|
||||
}
|
||||
// 服务端按分片数上限校验总大小(防分片声明绕过)
|
||||
totalChunks := (req.FileSize + chunkSize - 1) / chunkSize
|
||||
maxPossible := totalChunks * chunkSize
|
||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(maxPossible); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
// 断点续传:查找相同 hash+大小+文件名的未完成会话(chunk_index=-1 为会话头)
|
||||
var existing model.UploadChunk
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("chunk_hash = ? AND chunk_index = -1 AND file_size = ? AND file_name = ?",
|
||||
req.FileHash, req.FileSize, safeName).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.SavePath == "" {
|
||||
// 脏会话:清理后按新建处理
|
||||
_ = d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", existing.UploadID).
|
||||
Delete(&model.UploadChunk{}).Error
|
||||
releaseStorage(ctx, d.DB, "chunk:"+existing.UploadID)
|
||||
} else {
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "chunk:"+existing.UploadID, existing.FileSize, chunkExpireTTL); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
uploaded := d.uploadedChunkIndexes(ctx, existing.UploadID)
|
||||
auditUploadEntry(c, existing.UploadID, safeName, req.FileSize, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{
|
||||
"existed": false,
|
||||
"upload_id": existing.UploadID,
|
||||
"chunk_size": existing.ChunkSize,
|
||||
"total_chunks": existing.TotalChunks,
|
||||
"uploaded_chunks": uploaded,
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 新建会话
|
||||
uploadID := uuidHex()
|
||||
resToken := "chunk:" + uploadID
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, resToken, req.FileSize, chunkExpireTTL); err != nil {
|
||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "容量预留失败")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// M5:init 即计入上传限流(此前仅 complete 成功时计数,
|
||||
// 恶意客户端可无限创建会话占用容量预留)
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
_, _, _, _, savePath := buildSavePath(d.Cfg, safeName, uploadID)
|
||||
session := model.UploadChunk{
|
||||
UploadID: uploadID,
|
||||
ChunkIndex: -1,
|
||||
TotalChunks: int(totalChunks),
|
||||
FileSize: req.FileSize,
|
||||
ChunkSize: int(chunkSize),
|
||||
ChunkHash: req.FileHash,
|
||||
FileName: safeName,
|
||||
SavePath: savePath,
|
||||
Engine: d.Store.CurrentName(), // v3:会话归属引擎(分片/合并全程走同一引擎)
|
||||
}
|
||||
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
|
||||
}
|
||||
auditUploadEntry(c, uploadID, safeName, req.FileSize, 0)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{
|
||||
"existed": false,
|
||||
"upload_id": uploadID,
|
||||
"chunk_size": chunkSize,
|
||||
"total_chunks": totalChunks,
|
||||
"uploaded_chunks": []int{},
|
||||
})
|
||||
}
|
||||
|
||||
// uploadedChunkIndexes 查询会话中已完成分片的索引列表。
|
||||
func (d *Deps) uploadedChunkIndexes(ctx context.Context, uploadID string) []int {
|
||||
var rows []model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND completed = ?", uploadID, true).
|
||||
Order("chunk_index ASC").Find(&rows).Error; err != nil {
|
||||
return []int{}
|
||||
}
|
||||
out := make([]int, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, r.ChunkIndex)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============ POST /chunk/upload/{uploadID}/{index}(及扁平兼容)============
|
||||
|
||||
// chunkUploadFlat 扁平模式:POST /chunk/upload,upload_id/chunk_index 走表单或 query。
|
||||
// 多文件字段(chunk/chunks)时按 base_chunk_index 顺序批量接收。
|
||||
func (d *Deps) chunkUploadFlat(c *gin.Context) {
|
||||
c.Params = append(c.Params, gin.Param{Key: "uploadID", Value: resolveUploadID(c)})
|
||||
c.Params = append(c.Params, gin.Param{Key: "chunkIndex", Value: resolveChunkIndex(c)})
|
||||
d.chunkUpload(c)
|
||||
}
|
||||
|
||||
// resolveUploadID 解析 upload_id:路径参数 → multipart 表单 → query。
|
||||
func resolveUploadID(c *gin.Context) string {
|
||||
if v := c.Param("uploadID"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := c.PostForm("upload_id"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Query("upload_id")
|
||||
}
|
||||
|
||||
// resolveChunkIndex 解析 chunk_index:路径参数 → multipart 表单 → query。
|
||||
func resolveChunkIndex(c *gin.Context) string {
|
||||
if v := c.Param("chunkIndex"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := c.PostForm("chunk_index"); v != "" {
|
||||
return v
|
||||
}
|
||||
return c.Query("chunk_index")
|
||||
}
|
||||
|
||||
// chunkUpload 上传单个(或批量)分片(对齐参考 upload_chunk)。
|
||||
// multipart 文件字段:chunk(主)或 file(回退);批量用 chunk[]/chunks 数组 + chunk_index 为起始索引。
|
||||
func (d *Deps) chunkUpload(c *gin.Context) {
|
||||
if !d.requireChunkEnabled(c) {
|
||||
return
|
||||
}
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := resolveUploadID(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "上传会话不存在")
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "chunk:"+uploadID, session.FileSize, chunkExpireTTL); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集分片文件:chunk(单)→ file(回退)→ chunk[]/chunks(批量)
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "multipart 解析失败")
|
||||
response.Fail(c, http.StatusBadRequest, "multipart 表单解析失败")
|
||||
return
|
||||
}
|
||||
files := form.File["chunk"]
|
||||
single := len(files) == 0
|
||||
if single {
|
||||
files = form.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "缺少 chunk 分片字段")
|
||||
response.Fail(c, http.StatusBadRequest, "缺少分片文件字段 chunk")
|
||||
return
|
||||
}
|
||||
|
||||
baseIndex, err := strconv.Atoi(resolveChunkIndex(c))
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "无效的分片索引")
|
||||
response.Fail(c, http.StatusBadRequest, "无效的分片索引")
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]gin.H, 0, len(files))
|
||||
for i, fh := range files {
|
||||
// 单分片模式严格使用请求索引;批量模式从 base 递增
|
||||
idx := baseIndex
|
||||
if !single && len(files) > 1 {
|
||||
idx = baseIndex + i
|
||||
}
|
||||
res, status, msg := d.saveOneChunk(c, ctx, &session, idx, fh)
|
||||
if status != 0 {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, msg)
|
||||
response.Fail(c, status, msg)
|
||||
return
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
// 审计:传输字节数为本次请求分片总和
|
||||
var transferred int64
|
||||
for _, fh := range files {
|
||||
transferred += fh.Size
|
||||
}
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, transferred)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
if len(results) == 1 {
|
||||
response.OK(c, results[0])
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"chunks": results})
|
||||
}
|
||||
|
||||
// saveOneChunk 保存一个分片:查重→读数据→校验→存储→记录。
|
||||
// 返回 (响应体, HTTP错误状态码, 错误信息);成功时状态码为 0。
|
||||
func (d *Deps) saveOneChunk(c *gin.Context, ctx context.Context, session *model.UploadChunk, idx int, fh *multipart.FileHeader) (gin.H, int, string) {
|
||||
if idx < 0 || idx >= session.TotalChunks {
|
||||
return nil, http.StatusBadRequest, "无效的分片索引"
|
||||
}
|
||||
// 已上传分片:断点续传直接跳过
|
||||
var existing model.UploadChunk
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ? AND completed = ?", session.UploadID, idx, true).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return gin.H{"chunk_hash": existing.ChunkHash, "skipped": true, "chunk_index": idx}, 0, ""
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, http.StatusInternalServerError, "查询分片记录失败"
|
||||
}
|
||||
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "分片数据读取失败"
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(f, int64(session.ChunkSize)+1))
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "分片数据读取失败"
|
||||
}
|
||||
// 校验分片大小不超过声明值
|
||||
if int64(len(data)) > int64(session.ChunkSize) {
|
||||
return nil, http.StatusBadRequest,
|
||||
"分片大小超过声明值: 最大 " + strconv.Itoa(session.ChunkSize) + ", 实际 " + strconv.Itoa(len(data))
|
||||
}
|
||||
// 累计大小校验(已传分片数×chunk_size + 当前分片;动态策略上限)
|
||||
var uploadedCount int64
|
||||
_ = d.DB.WithContext(ctx).Model(&model.UploadChunk{}).
|
||||
Where("upload_id = ? AND completed = ?", session.UploadID, true).
|
||||
Count(&uploadedCount).Error
|
||||
if err := d.CurrentUploadPolicy().CheckSize(uploadedCount*int64(session.ChunkSize) + int64(len(data))); err != nil {
|
||||
return nil, http.StatusForbidden, err.Error()
|
||||
}
|
||||
// 首分片做 magic bytes 防伪造
|
||||
if idx == 0 {
|
||||
head := data
|
||||
if len(head) > 64 {
|
||||
head = head[:64]
|
||||
}
|
||||
if err := validateFileMagic(d.Cfg, session.FileName, "", head); err != nil {
|
||||
return nil, http.StatusForbidden, "文件内容校验失败:" + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
chunkHash := hex.EncodeToString(sum[:])
|
||||
if _, err := d.Store.SaveChunk(ctx, session.UploadID, idx, bytes.NewReader(data), session.SavePath); err != nil {
|
||||
return nil, http.StatusInternalServerError, "分片保存失败: " + err.Error()
|
||||
}
|
||||
// 保存成功后再记录(对齐参考:先存储后落库)。
|
||||
// 注意:不能用结构体 Where 条件(GORM 会忽略零值字段,chunk_index=0 会被
|
||||
// 丢弃从而误匹配 -1 会话行),必须用字符串条件 + 完整目标结构体。
|
||||
rec := model.UploadChunk{
|
||||
UploadID: session.UploadID,
|
||||
ChunkIndex: idx,
|
||||
ChunkHash: chunkHash,
|
||||
Completed: true,
|
||||
FileSize: session.FileSize,
|
||||
TotalChunks: session.TotalChunks,
|
||||
ChunkSize: session.ChunkSize,
|
||||
FileName: session.FileName,
|
||||
SavePath: session.SavePath,
|
||||
Engine: session.Engine, // v3:继承会话引擎
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ?", session.UploadID, idx).
|
||||
FirstOrCreate(&rec).Error; err != nil {
|
||||
return nil, http.StatusInternalServerError, "分片记录写入失败"
|
||||
}
|
||||
return gin.H{"chunk_hash": chunkHash, "chunk_index": idx}, 0, ""
|
||||
}
|
||||
|
||||
// ============ GET /chunk/upload/status/{uploadID} ============
|
||||
|
||||
// chunkStatus 查询上传进度(对齐参考 get_upload_status)。
|
||||
func (d *Deps) chunkStatus(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
if uploadID == "" {
|
||||
uploadID = c.Query("upload_id")
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
uploaded := d.uploadedChunkIndexes(ctx, uploadID)
|
||||
var progress float64
|
||||
if session.TotalChunks > 0 {
|
||||
progress = float64(len(uploaded)) / float64(session.TotalChunks) * 100
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"upload_id": uploadID,
|
||||
"file_name": session.FileName,
|
||||
"file_size": session.FileSize,
|
||||
"chunk_size": session.ChunkSize,
|
||||
"total_chunks": session.TotalChunks,
|
||||
"uploaded_chunks": uploaded,
|
||||
"progress": progress,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ POST /chunk/upload/complete/{uploadID} ============
|
||||
|
||||
// chunkCompleteRequest complete 请求体。
|
||||
type chunkCompleteRequest struct {
|
||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||
Code string `json:"code" form:"code"` // v3.1:自定义提取码(4-8 位字母数字,空=随机)
|
||||
}
|
||||
|
||||
// chunkComplete 合并分片并创建分享(对齐参考 complete_upload)。
|
||||
func (d *Deps) chunkComplete(c *gin.Context) {
|
||||
if !d.requireChunkEnabled(c) {
|
||||
return
|
||||
}
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
if !requireUploadLimit(c, d.Limiter) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
if uploadID == "" {
|
||||
uploadID = resolveUploadID(c)
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
auditUploadEntry(c, uploadID, "", 0, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "上传会话不存在")
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
var req chunkCompleteRequest
|
||||
if err := bindJSONOrForm(c, &req); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
exp, err := resolveExpire(d.Cfg, req.ExpireValue, req.ExpireStyle)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "过期策略非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// v3.1:自定义提取码(合并前校验,失败快速返回)
|
||||
if err := validatePickupCode(req.Code); err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if err := reserveStorage(ctx, d.DB, d.Cfg, "chunk:"+uploadID, session.FileSize, chunkExpireTTL); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 分片完整性校验(chunk_index >= 0;-1 为会话头,completed 恒为 false)
|
||||
var completed []model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND completed = ? AND chunk_index >= 0", uploadID, true).
|
||||
Find(&completed).Error; err != nil {
|
||||
respondError(c, errInternal("查询分片记录失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if len(completed) != session.TotalChunks {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "分片不完整")
|
||||
response.Fail(c, http.StatusBadRequest, "分片不完整")
|
||||
return
|
||||
}
|
||||
// 累计大小上限校验(超限清理会话,对齐参考;动态策略上限)
|
||||
if err := d.CurrentUploadPolicy().CheckSize(int64(len(completed)) * int64(session.ChunkSize)); err != nil {
|
||||
if cs, ce := d.storeFor(session.Engine); ce == nil {
|
||||
_ = cs.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
}
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&model.UploadChunk{}).Error
|
||||
releaseStorage(ctx, d.DB, "chunk:"+uploadID)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||
auditRecordFailed(c, d.AuditSvc, "实际上传大小超过限制")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 合并(引擎负责按索引有序合并+SHA256 校验)
|
||||
verifyHash := func(index int) (string, error) {
|
||||
var rec model.UploadChunk
|
||||
err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = ?", uploadID, index).
|
||||
First(&rec).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.ChunkHash, nil
|
||||
}
|
||||
// v3:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
|
||||
mergeStore, sErr := d.storeFor(session.Engine)
|
||||
if sErr != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
||||
auditRecordFailed(c, d.AuditSvc, "存储引擎不可用: "+sErr.Error())
|
||||
respondError(c, mapStorageError(sErr))
|
||||
return
|
||||
}
|
||||
size, fileHash, err := mergeStore.MergeChunks(ctx, uploadID, session.TotalChunks, verifyHash, session.SavePath)
|
||||
if err != nil {
|
||||
_ = mergeStore.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
||||
auditRecordFailed(c, d.AuditSvc, "文件合并失败")
|
||||
respondError(c, mapStorageError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建分享记录(v3.1:支持自定义提取码)
|
||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, req.Code)
|
||||
if err == nil {
|
||||
fc := model.FileCodes{
|
||||
Code: code,
|
||||
FileHash: &fileHash,
|
||||
IsChunked: true,
|
||||
UploadID: &uploadID,
|
||||
Size: session.FileSize,
|
||||
ExpiredAt: exp.ExpiredAt,
|
||||
ExpiredCount: exp.ExpiredCount,
|
||||
UsedCount: exp.UsedCount,
|
||||
Engine: session.Engine, // v3:归属引擎戳
|
||||
}
|
||||
// 拆分路径与文件名(对齐参考:path=dirname(save_path), uuid=basename)
|
||||
dir, name := splitDirBase(session.SavePath)
|
||||
ext := baseExt(name)
|
||||
fc.FilePath = &dir
|
||||
fc.UUIDFileName = &name
|
||||
fc.Prefix = trimExt(name)
|
||||
fc.Suffix = ext
|
||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||
err = mapCodeConflict(err) // v3.1
|
||||
}
|
||||
if err == nil {
|
||||
// 成功:清理分片与记录(走归属引擎)
|
||||
_ = mergeStore.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
_ = d.DB.WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&model.UploadChunk{}).Error
|
||||
}
|
||||
releaseStorage(ctx, d.DB, "chunk:"+uploadID)
|
||||
if err != nil {
|
||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, size)
|
||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||
respondError(c, errInternal("创建分享失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
d.Limiter.Add(c, middleware.LimitUpload)
|
||||
auditUploadEntry(c, code, session.FileName, session.FileSize, size)
|
||||
auditRecordSuccess(c, d.AuditSvc)
|
||||
response.OK(c, gin.H{"code": code, "name": session.FileName})
|
||||
}
|
||||
|
||||
// splitDirBase 拆分相对路径为目录与文件名。
|
||||
func splitDirBase(p string) (dir, base string) {
|
||||
for i := len(p) - 1; i >= 0; i-- {
|
||||
if p[i] == '/' {
|
||||
return p[:i], p[i+1:]
|
||||
}
|
||||
}
|
||||
return "", p
|
||||
}
|
||||
|
||||
// baseExt 提取扩展名(含点)。
|
||||
func baseExt(name string) string {
|
||||
for i := len(name) - 1; i >= 0; i-- {
|
||||
if name[i] == '.' {
|
||||
return name[i:]
|
||||
}
|
||||
if name[i] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trimExt 去除扩展名。
|
||||
func trimExt(name string) string {
|
||||
ext := baseExt(name)
|
||||
return name[:len(name)-len(ext)]
|
||||
}
|
||||
|
||||
// ============ DELETE /chunk/upload/{uploadID} 取消上传 ============
|
||||
|
||||
// chunkCancel 取消上传并清理临时文件(对齐参考 cancel_upload)。
|
||||
func (d *Deps) chunkCancel(c *gin.Context) {
|
||||
if !d.requireShareLogin(c) {
|
||||
return
|
||||
}
|
||||
uploadID := c.Param("uploadID")
|
||||
if uploadID == "" {
|
||||
uploadID = c.Query("upload_id")
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var session model.UploadChunk
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ? AND chunk_index = -1", uploadID).
|
||||
First(&session).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, "上传会话不存在")
|
||||
return
|
||||
}
|
||||
respondError(c, errInternal("查询上传会话失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
if session.SavePath != "" {
|
||||
if cs, ce := d.storeFor(session.Engine); ce == nil {
|
||||
_ = cs.CleanChunks(ctx, uploadID, session.SavePath)
|
||||
}
|
||||
}
|
||||
if err := d.DB.WithContext(ctx).
|
||||
Where("upload_id = ?", uploadID).
|
||||
Delete(&model.UploadChunk{}).Error; err != nil {
|
||||
respondError(c, errInternal("取消上传失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
releaseStorage(ctx, d.DB, "chunk:"+uploadID)
|
||||
response.OK(c, gin.H{"message": "上传已取消"})
|
||||
}
|
||||
Reference in New Issue
Block a user