mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
upload+accessKey
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
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/common/response"
|
||||
"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 {
|
||||
response.RespondFailure(c, ErrNoFileSelected)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
response.RespondFailure(c, ErrOpenFileFailed)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 校验大小
|
||||
if header.Size > maxUploadSize {
|
||||
response.RespondFailure(c, "文件大小不能超过 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 {
|
||||
response.RespondFailure(c, ErrUnsupportedFormat)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 读取文件并计算 Hash
|
||||
hashWriter := sha256.New()
|
||||
var buf bytes.Buffer
|
||||
size, err := io.Copy(&buf, io.TeeReader(file, hashWriter))
|
||||
if err != nil {
|
||||
response.RespondFailure(c, 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 {
|
||||
response.RespondFailure(c, ErrSaveUploadRecordFailed)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath)
|
||||
response.RespondSuccess(c, newUpload)
|
||||
return
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.RespondFailure(c, "文件校验失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 解析可选元数据字段
|
||||
metadataStr := c.DefaultPostForm("metadata", "")
|
||||
var meta model.UploadMetadata
|
||||
if metadataStr != "" {
|
||||
if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil {
|
||||
response.RespondFailure(c, "元数据 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)
|
||||
response.RespondFailure(c, 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)
|
||||
response.RespondFailure(c, 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)
|
||||
response.RespondFailure(c, 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)
|
||||
}
|
||||
response.RespondFailure(c, ErrSaveUploadRecordFailed)
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondSuccess(c, 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 {
|
||||
response.RespondFailure(c, "无效的文件 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
|
||||
}
|
||||
response.RespondFailure(c, "查询文件记录失败")
|
||||
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 {
|
||||
response.RespondFailure(c, "参数绑定失败,请传入有效的文件 ID 数组")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换 ID 列表
|
||||
var ids []uint64
|
||||
for _, idStr := range req.IDs {
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
response.RespondFailure(c, 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 {
|
||||
response.RespondFailure(c, "检索文件记录失败")
|
||||
return
|
||||
}
|
||||
|
||||
if len(uploads) == 0 {
|
||||
response.RespondFailure(c, "没有找到任何有效的文件记录进行打包")
|
||||
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 {
|
||||
response.RespondFailure(c, "参数错误")
|
||||
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 {
|
||||
response.RespondFailure(c, "查询文件数量失败")
|
||||
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 {
|
||||
response.RespondFailure(c, "查询文件列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondSuccess(c, 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 {
|
||||
response.RespondFailure(c, "无效的文件 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
|
||||
}
|
||||
response.RespondFailure(c, "查询文件记录失败")
|
||||
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 {
|
||||
response.RespondFailure(c, "删除文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user