mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
534 lines
16 KiB
Go
534 lines
16 KiB
Go
/*
|
||
Copyright 2026 linux.do
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
*/
|
||
|
||
package upload
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/linux-do/credit/internal/apps/oauth"
|
||
"github.com/linux-do/credit/internal/config"
|
||
"github.com/linux-do/credit/internal/db"
|
||
"github.com/linux-do/credit/internal/db/idgen"
|
||
"github.com/linux-do/credit/internal/logger"
|
||
"github.com/linux-do/credit/internal/model"
|
||
"github.com/linux-do/credit/internal/storage"
|
||
"github.com/linux-do/credit/internal/util"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const maxUploadSize = 32 * 1024 * 1024 // 32MB
|
||
|
||
type batchDownloadRequest struct {
|
||
IDs []string `json:"ids" binding:"required,min=1"`
|
||
}
|
||
|
||
// UploadFile 通用上传文件接口
|
||
// @Summary 上传文件
|
||
// @Description 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重
|
||
// @Tags upload
|
||
// @Accept multipart/form-data
|
||
// @Produce json
|
||
// @Param file formData file true "要上传的文件"
|
||
// @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)"
|
||
// @Param metadata formData string false "额外的 JSON 格式元数据"
|
||
// @Security SessionCookie
|
||
// @Success 200 {object} util.ResponseAny{data=model.Upload} "上传成功"
|
||
// @Failure 400 {object} util.ResponseAny "请求参数错误或文件受限"
|
||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||
// @Router /api/v1/upload [post]
|
||
func UploadFile(c *gin.Context) {
|
||
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||
ctx := c.Request.Context()
|
||
|
||
header, err := c.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, util.Err(ErrNoFileSelected))
|
||
return
|
||
}
|
||
|
||
file, err := header.Open()
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, util.Err(ErrOpenFileFailed))
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// 校验大小
|
||
if header.Size > maxUploadSize {
|
||
c.JSON(http.StatusOK, util.Err("文件大小不能超过 32MB"))
|
||
return
|
||
}
|
||
|
||
// 2. 提取文件基本元数据
|
||
origName := header.Filename
|
||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origName), "."))
|
||
if ext == "" {
|
||
ext = "bin"
|
||
}
|
||
|
||
// 3. 校验文件后缀是否在允许的系统配置列表中
|
||
var sc model.SystemConfig
|
||
if err := sc.GetByKey(ctx, model.ConfigKeyUploadAllowedExtensions); err == nil && sc.Value != "" {
|
||
allowedExts := strings.Split(strings.ToLower(sc.Value), ",")
|
||
allowed := false
|
||
for _, allowedExt := range allowedExts {
|
||
if strings.TrimSpace(allowedExt) == ext {
|
||
allowed = true
|
||
break
|
||
}
|
||
}
|
||
if !allowed {
|
||
c.JSON(http.StatusOK, util.Err(ErrUnsupportedFormat))
|
||
return
|
||
}
|
||
}
|
||
|
||
// 4. 读取文件并计算 Hash
|
||
hashWriter := sha256.New()
|
||
var buf bytes.Buffer
|
||
size, err := io.Copy(&buf, io.TeeReader(file, hashWriter))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, util.Err(ErrProcessFileFailed))
|
||
return
|
||
}
|
||
|
||
fileHash := hex.EncodeToString(hashWriter.Sum(nil))
|
||
|
||
mimeType := http.DetectContentType(buf.Bytes()[:min(512, int(size))])
|
||
if mimeType == "application/octet-stream" && header.Header.Get("Content-Type") != "" {
|
||
mimeType = header.Header.Get("Content-Type")
|
||
}
|
||
|
||
// 6. 秒传匹配校验:校验数据库中是否存在相同 Hash 且大小一致的可用文件
|
||
var existing model.Upload
|
||
err = db.DB(ctx).Where("hash = ? AND file_size = ? AND status IN (?, ?)", fileHash, size, model.UploadStatusPending, model.UploadStatusUsed).First(&existing).Error
|
||
if err == nil {
|
||
// 命中了相同文件,直接生成新记录指向已有的存储路径(实现秒传)
|
||
id := idgen.NextUint64ID()
|
||
newUpload := model.Upload{
|
||
ID: id,
|
||
UserID: currUser.ID,
|
||
FileName: origName,
|
||
FilePath: existing.FilePath,
|
||
FileSize: size,
|
||
MimeType: mimeType,
|
||
Extension: ext,
|
||
Hash: fileHash,
|
||
StorageDriver: existing.StorageDriver,
|
||
Type: c.DefaultPostForm("type", "generic"),
|
||
Status: model.UploadStatusUsed,
|
||
Metadata: existing.Metadata,
|
||
}
|
||
|
||
if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
|
||
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
|
||
return
|
||
}
|
||
|
||
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath)
|
||
c.JSON(http.StatusOK, util.OK(newUpload))
|
||
return
|
||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
c.JSON(http.StatusOK, util.Err("文件校验失败"))
|
||
return
|
||
}
|
||
|
||
// 7. 解析可选元数据字段
|
||
metadataStr := c.DefaultPostForm("metadata", "")
|
||
var meta model.UploadMetadata
|
||
if metadataStr != "" {
|
||
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
|
||
c.JSON(http.StatusOK, util.Err("元数据 JSON 格式不合法"))
|
||
return
|
||
}
|
||
}
|
||
|
||
meta.OriginalMime = mimeType
|
||
meta.UserAgent = c.Request.UserAgent()
|
||
meta.ClientIP = c.ClientIP()
|
||
|
||
id := idgen.NextUint64ID()
|
||
subPath := fmt.Sprintf("uploads/%s/%d.%s", time.Now().Format("2006/01/02"), id, ext)
|
||
|
||
var storageDriver string
|
||
|
||
// 8. 写入底层存储驱动 (优先 S3 驱动,无配置或未开启则 fallback 至本地文件)
|
||
if storage.IsEnabled() {
|
||
storageDriver = "s3"
|
||
meta.Bucket = config.Config.S3.Bucket
|
||
fullKey := storage.BuildKey(subPath)
|
||
|
||
err = storage.PutObject(ctx, fullKey, bytes.NewReader(buf.Bytes()), size, mimeType)
|
||
if err != nil {
|
||
logger.ErrorF(ctx, "S3 存储上传失败: %v", err)
|
||
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
|
||
return
|
||
}
|
||
} else {
|
||
storageDriver = "local"
|
||
localDir := filepath.Join("uploads", time.Now().Format("2006/01/02"))
|
||
if err := os.MkdirAll(localDir, 0755); err != nil {
|
||
logger.ErrorF(ctx, "创建本地上传目录失败: %v", err)
|
||
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
|
||
return
|
||
}
|
||
|
||
localPath := filepath.Join(localDir, fmt.Sprintf("%d.%s", id, ext))
|
||
if err := os.WriteFile(localPath, buf.Bytes(), 0644); err != nil {
|
||
logger.ErrorF(ctx, "本地磁盘写入文件失败: %v", err)
|
||
c.JSON(http.StatusOK, util.Err(ErrSaveFileFailed))
|
||
return
|
||
}
|
||
// 统一使用相对路径,方便将来环境移植或备份
|
||
subPath = localPath
|
||
}
|
||
|
||
// 9. 保存文件记录至数据库
|
||
newUpload := model.Upload{
|
||
ID: id,
|
||
UserID: currUser.ID,
|
||
FileName: origName,
|
||
FilePath: subPath,
|
||
FileSize: size,
|
||
MimeType: mimeType,
|
||
Extension: ext,
|
||
Hash: fileHash,
|
||
StorageDriver: storageDriver,
|
||
Type: c.DefaultPostForm("type", "generic"),
|
||
Status: model.UploadStatusUsed,
|
||
Metadata: meta,
|
||
}
|
||
|
||
if err := db.DB(ctx).Create(&newUpload).Error; err != nil {
|
||
// 失败时若为本地存储,可以尝试清理已保存的垃圾文件
|
||
if storageDriver == "local" {
|
||
_ = os.Remove(subPath)
|
||
}
|
||
c.JSON(http.StatusOK, util.Err(ErrSaveUploadRecordFailed))
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, util.OK(newUpload))
|
||
}
|
||
|
||
// DownloadFile 通用单文件下载接口
|
||
// @Summary 下载单文件
|
||
// @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
|
||
// @Tags upload
|
||
// @Produce octet-stream
|
||
// @Param id path string true "文件 ID"
|
||
// @Security SessionCookie
|
||
// @Success 200 {file} file "成功下载文件"
|
||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||
// @Failure 404 {object} util.ResponseAny "文件不存在"
|
||
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||
// @Router /api/v1/upload/download/{id} [get]
|
||
func DownloadFile(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
idStr := c.Param("id")
|
||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
|
||
return
|
||
}
|
||
|
||
var upload model.Upload
|
||
if err := db.DB(ctx).Where("id = ? AND status IN (?, ?)", uploadID, model.UploadStatusPending, model.UploadStatusUsed).First(&upload).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
c.AbortWithStatus(http.StatusNotFound)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
|
||
return
|
||
}
|
||
|
||
// 设置下载 Attachment 响应头 (支持 UTF-8 中文文件名转义)
|
||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(upload.FileName)))
|
||
c.Header("Content-Type", upload.MimeType)
|
||
c.Header("Content-Length", strconv.FormatInt(upload.FileSize, 10))
|
||
|
||
// 根据存储驱动类型提供流式文件服务
|
||
if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) {
|
||
c.File(upload.FilePath)
|
||
return
|
||
}
|
||
|
||
// 从 S3/CDN 加载并返回
|
||
obj, err := storage.GetObjectViaCache(ctx, upload.FilePath)
|
||
if err != nil {
|
||
c.AbortWithStatus(http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
if obj.CachePath != "" {
|
||
c.File(obj.CachePath)
|
||
return
|
||
}
|
||
|
||
defer obj.Body.Close()
|
||
_, _ = io.Copy(c.Writer, obj.Body)
|
||
}
|
||
|
||
// BatchDownloadFiles 批量打包 ZIP 下载接口
|
||
// @Summary 批量打包下载
|
||
// @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突
|
||
// @Tags upload
|
||
// @Accept json
|
||
// @Produce octet-stream
|
||
// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组的请求体"
|
||
// @Security SessionCookie
|
||
// @Success 200 {file} file "成功下载打包后的 ZIP"
|
||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||
// @Failure 500 {object} util.ResponseAny "打包失败"
|
||
// @Router /api/v1/upload/download/batch [post]
|
||
func BatchDownloadFiles(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
|
||
var req batchDownloadRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusOK, util.Err("参数绑定失败,请传入有效的文件 ID 数组"))
|
||
return
|
||
}
|
||
|
||
// 转换 ID 列表
|
||
var ids []uint64
|
||
for _, idStr := range req.IDs {
|
||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, util.Err(fmt.Sprintf("无效的 ID 值: %s", idStr)))
|
||
return
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
|
||
// 查库获取所有匹配且正常的文件记录
|
||
var uploads []model.Upload
|
||
if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil {
|
||
c.JSON(http.StatusOK, util.Err("检索文件记录失败"))
|
||
return
|
||
}
|
||
|
||
if len(uploads) == 0 {
|
||
c.JSON(http.StatusOK, util.Err("没有找到任何有效的文件记录进行打包"))
|
||
return
|
||
}
|
||
|
||
// 设置 ZIP 格式流的响应头
|
||
c.Header("Content-Type", "application/zip")
|
||
c.Header("Content-Disposition", "attachment; filename=\"batch_download.zip\"")
|
||
|
||
// 开启实时 ZIP 压缩器并直接输出给 Response Writer
|
||
zipWriter := zip.NewWriter(c.Writer)
|
||
defer zipWriter.Close()
|
||
|
||
// 用于解决 ZIP 内部文件名称发生碰撞冲突的问题
|
||
usedNames := make(map[string]int)
|
||
|
||
for _, upload := range uploads {
|
||
// 校验防冲突重命名逻辑
|
||
fileName := upload.FileName
|
||
if count, exists := usedNames[fileName]; exists {
|
||
usedNames[fileName] = count + 1
|
||
ext := filepath.Ext(fileName)
|
||
base := strings.TrimSuffix(fileName, ext)
|
||
fileName = fmt.Sprintf("%s_%d%s", base, count, ext)
|
||
} else {
|
||
usedNames[fileName] = 1
|
||
}
|
||
|
||
// 在 ZIP 包内建新条目
|
||
zipFileEntry, err := zipWriter.Create(fileName)
|
||
if err != nil {
|
||
logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err)
|
||
continue
|
||
}
|
||
|
||
// 打开底层文件数据源
|
||
var rc io.ReadCloser
|
||
if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) {
|
||
fileSrc, err := os.Open(upload.FilePath)
|
||
if err != nil {
|
||
logger.ErrorF(ctx, "打包时读取本地文件失败: %v", err)
|
||
continue
|
||
}
|
||
rc = fileSrc
|
||
} else {
|
||
obj, err := storage.GetObject(ctx, upload.FilePath)
|
||
if err != nil {
|
||
logger.ErrorF(ctx, "打包时拉取 S3 文件失败: %v", err)
|
||
continue
|
||
}
|
||
rc = obj.Body
|
||
}
|
||
|
||
// 流式拷贝到 ZIP entry
|
||
_, err = io.Copy(zipFileEntry, rc)
|
||
_ = rc.Close()
|
||
if err != nil {
|
||
logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
type listMyFilesRequest struct {
|
||
Page int `form:"page"`
|
||
PageSize int `form:"page_size"`
|
||
Keyword string `form:"keyword"`
|
||
Type string `form:"type"`
|
||
Extension string `form:"extension"`
|
||
}
|
||
|
||
type listMyFilesResponse struct {
|
||
Total int64 `json:"total"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"page_size"`
|
||
Items []model.Upload `json:"items"`
|
||
}
|
||
|
||
// ListMyFiles 获取当前用户上传的文件列表
|
||
// @Summary 获取我的文件列表
|
||
// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤
|
||
// @Tags upload
|
||
// @Produce json
|
||
// @Param page query int false "页码(默认 1)"
|
||
// @Param page_size query int false "每页数量(默认 20,最大 100)"
|
||
// @Param keyword query string false "文件名关键词(模糊匹配)"
|
||
// @Param type query string false "业务分类过滤"
|
||
// @Param extension query string false "扩展名过滤"
|
||
// @Security SessionCookie
|
||
// @Success 200 {object} util.ResponseAny{data=listMyFilesResponse} "查询成功"
|
||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||
// @Router /api/v1/upload/my [get]
|
||
func ListMyFiles(c *gin.Context) {
|
||
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||
ctx := c.Request.Context()
|
||
|
||
var req listMyFilesRequest
|
||
if err := c.ShouldBindQuery(&req); err != nil {
|
||
c.JSON(http.StatusOK, util.Err("参数错误"))
|
||
return
|
||
}
|
||
if req.Page <= 0 {
|
||
req.Page = 1
|
||
}
|
||
if req.PageSize <= 0 || req.PageSize > 100 {
|
||
req.PageSize = 20
|
||
}
|
||
|
||
query := db.DB(ctx).Model(&model.Upload{}).
|
||
Where("user_id = ? AND status != ?", currUser.ID, model.UploadStatusDeleted)
|
||
|
||
if req.Keyword != "" {
|
||
query = query.Where("file_name ILIKE ?", "%"+req.Keyword+"%")
|
||
}
|
||
if req.Type != "" {
|
||
query = query.Where("type = ?", req.Type)
|
||
}
|
||
if req.Extension != "" {
|
||
query = query.Where("extension = ?", strings.ToLower(req.Extension))
|
||
}
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
c.JSON(http.StatusOK, util.Err("查询文件数量失败"))
|
||
return
|
||
}
|
||
|
||
var items []model.Upload
|
||
offset := (req.Page - 1) * req.PageSize
|
||
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil {
|
||
c.JSON(http.StatusOK, util.Err("查询文件列表失败"))
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, util.OK(listMyFilesResponse{
|
||
Total: total,
|
||
Page: req.Page,
|
||
PageSize: req.PageSize,
|
||
Items: items,
|
||
}))
|
||
}
|
||
|
||
// DeleteFile 软删除文件记录
|
||
// @Summary 删除文件
|
||
// @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象
|
||
// @Tags upload
|
||
// @Produce json
|
||
// @Param id path string true "文件 ID"
|
||
// @Security SessionCookie
|
||
// @Success 200 {object} util.ResponseAny "删除成功"
|
||
// @Failure 403 {object} util.ResponseAny "无权操作"
|
||
// @Failure 404 {object} util.ResponseAny "文件不存在"
|
||
// @Router /api/v1/upload/{id} [delete]
|
||
func DeleteFile(c *gin.Context) {
|
||
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||
ctx := c.Request.Context()
|
||
|
||
idStr := c.Param("id")
|
||
uploadID, err := strconv.ParseUint(idStr, 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, util.Err("无效的文件 ID"))
|
||
return
|
||
}
|
||
|
||
var upload model.Upload
|
||
if err := db.DB(ctx).Where("id = ? AND status != ?", uploadID, model.UploadStatusDeleted).First(&upload).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
c.AbortWithStatus(http.StatusNotFound)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, util.Err("查询文件记录失败"))
|
||
return
|
||
}
|
||
|
||
// 仅允许文件所有者或管理员删除
|
||
if upload.UserID != currUser.ID && !currUser.IsAdmin {
|
||
c.AbortWithStatus(http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil {
|
||
c.JSON(http.StatusOK, util.Err("删除文件失败"))
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, util.OKNil())
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|