- 新增 GET /files/content 接口,后端直接读取S3文件文本内容返回 - Repository 新增 GetFileContent 方法 - CQRS: 新增 GetFileContentQuery / GetFileContentHandler - 前端 Markdown 预览改为调用后端接口获取内容,用 marked.js 渲染 - 解决 presigned URL CORS 和下载头导致 MD 文件无法预览的问题 - config.go: AuthAPIKey 默认值恢复为 xn001624. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
422 lines
14 KiB
Go
422 lines
14 KiB
Go
package endpoints
|
||
|
||
import (
|
||
"file-system/internal/api/handlers"
|
||
"file-system/internal/api/requests"
|
||
"file-system/internal/api/validators"
|
||
"file-system/internal/common"
|
||
"file-system/internal/domain/repository"
|
||
"file-system/internal/infrastructure/mediator"
|
||
"io"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type FileEndpoint struct {
|
||
Mediator *mediator.Mediator
|
||
UploadValidator *validators.UploadFileValidator
|
||
DownloadValidator *validators.DownloadFileValidator
|
||
NewFeaturesValidator *validators.NewFeaturesValidator
|
||
}
|
||
|
||
func NewFileEndpoint(m *mediator.Mediator, uv *validators.UploadFileValidator, dv *validators.DownloadFileValidator, nfv *validators.NewFeaturesValidator) *FileEndpoint {
|
||
return &FileEndpoint{
|
||
Mediator: m,
|
||
UploadValidator: uv,
|
||
DownloadValidator: dv,
|
||
NewFeaturesValidator: nfv,
|
||
}
|
||
}
|
||
|
||
// UploadFile godoc
|
||
// @Summary 上传文件 (简单上传)
|
||
// @Description 上传小文件到指定的存储桶,支持 multipart/form-data 格式
|
||
// @Tags 文件操作
|
||
// @Accept multipart/form-data
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param bucket_name formData string true "存储桶名称"
|
||
// @Param file formData file true "要上传的文件"
|
||
// @Success 200 {object} map[string]string "上传成功消息"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/upload [post]
|
||
func (e *FileEndpoint) UploadFile(c *gin.Context) {
|
||
var req requests.UploadFileRequest
|
||
// 绑定参数
|
||
if err := c.ShouldBind(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 验证参数
|
||
if err := e.UploadValidator.Validate(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 打开文件流
|
||
file, err := req.File.Open()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// 构建 Command
|
||
cmd := handlers.UploadFileCommand{
|
||
BucketName: req.BucketName,
|
||
FileName: req.File.Filename,
|
||
Data: file,
|
||
}
|
||
|
||
// 调用 Mediator
|
||
result, err := mediator.Send[handlers.UploadFileCommand, string](e.Mediator, c.Request.Context(), cmd)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": result})
|
||
}
|
||
|
||
// DownloadFile godoc
|
||
// @Summary 下载文件
|
||
// @Description 从指定的存储桶下载文件,返回文件流
|
||
// @Tags 文件操作
|
||
// @Accept json
|
||
// @Produce octet-stream
|
||
// @Security ApiKeyAuth
|
||
// @Param bucket_name query string true "存储桶名称"
|
||
// @Param object_key query string true "对象键(文件名)"
|
||
// @Success 200 {file} file "文件流"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/download [get]
|
||
func (e *FileEndpoint) DownloadFile(c *gin.Context) {
|
||
var req requests.DownloadFileRequest
|
||
if err := c.ShouldBind(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if err := e.DownloadValidator.Validate(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
query := handlers.DownloadFileQuery{
|
||
BucketName: req.BucketName,
|
||
ObjectKey: req.ObjectKey,
|
||
}
|
||
|
||
result, err := mediator.Send[handlers.DownloadFileQuery, io.ReadCloser](e.Mediator, c.Request.Context(), query)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
defer result.Close()
|
||
|
||
c.Header("Content-Disposition", "attachment; filename="+req.ObjectKey)
|
||
c.Header("Content-Type", "application/octet-stream")
|
||
io.Copy(c.Writer, result)
|
||
}
|
||
|
||
// ListFiles godoc
|
||
// @Summary 文件列表 (分页)
|
||
// @Description 分页查询存储桶中的文件,支持前缀筛选和分页
|
||
// @Tags 文件操作
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param bucket_name query string true "存储桶名称"
|
||
// @Param prefix query string false "文件名前缀筛选"
|
||
// @Param max_keys query int false "每页数量(默认20)"
|
||
// @Param token query string false "分页Token(下一页的凭证)"
|
||
// @Success 200 {object} repository.ListFilesResult
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/list [get]
|
||
func (e *FileEndpoint) ListFiles(c *gin.Context) {
|
||
var req requests.ListFilesRequest
|
||
if err := c.ShouldBind(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if err := e.NewFeaturesValidator.ValidateListFiles(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var token *string
|
||
if req.Token != "" {
|
||
token = &req.Token
|
||
}
|
||
|
||
query := handlers.ListFilesQuery{
|
||
BucketName: req.BucketName,
|
||
Prefix: req.Prefix,
|
||
MaxKeys: req.MaxKeys,
|
||
Token: token,
|
||
}
|
||
|
||
result, err := mediator.Send[handlers.ListFilesQuery, *repository.ListFilesResult](e.Mediator, c.Request.Context(), query)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, result)
|
||
}
|
||
|
||
// GetPreviewURL godoc
|
||
// @Summary 获取预览链接
|
||
// @Description 生成文件的临时预览链接(24小时有效),支持图片/视频/文档等
|
||
// @Tags 文件操作
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param bucket_name query string true "存储桶名称"
|
||
// @Param object_key query string true "对象键(文件名)"
|
||
// @Success 200 {object} map[string]string "返回预览 URL"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/preview [get]
|
||
func (e *FileEndpoint) GetPreviewURL(c *gin.Context) {
|
||
var req requests.GetFilePreviewRequest
|
||
if err := c.ShouldBind(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if err := e.NewFeaturesValidator.ValidatePreview(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
query := handlers.GetFilePreviewQuery{
|
||
BucketName: req.BucketName,
|
||
ObjectKey: req.ObjectKey,
|
||
Expiry: 24 * time.Hour,
|
||
}
|
||
|
||
result, err := mediator.Send[handlers.GetFilePreviewQuery, string](e.Mediator, c.Request.Context(), query)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"url": result})
|
||
}
|
||
|
||
// GetFileContent godoc
|
||
// @Summary 获取文件文本内容
|
||
// @Description 读取文件的文本内容,用于 Markdown 等文本文件的在线预览
|
||
// @Tags 文件操作
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param bucket_name query string true "存储桶名称"
|
||
// @Param object_key query string true "对象键(文件名)"
|
||
// @Success 200 {object} map[string]string "返回文件文本内容"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/content [get]
|
||
func (e *FileEndpoint) GetFileContent(c *gin.Context) {
|
||
var req requests.GetFilePreviewRequest
|
||
if err := c.ShouldBind(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if err := e.NewFeaturesValidator.ValidatePreview(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
query := handlers.GetFileContentQuery{
|
||
BucketName: req.BucketName,
|
||
ObjectKey: req.ObjectKey,
|
||
}
|
||
|
||
result, err := mediator.Send[handlers.GetFileContentQuery, string](e.Mediator, c.Request.Context(), query)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"content": result})
|
||
}
|
||
|
||
// InitMultipart godoc
|
||
// @Summary 初始化分片上传
|
||
// @Description 开始一个新的大文件分片上传任务,返回 upload_id 用于后续分片上传
|
||
// @Tags 大文件上传
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param request body requests.InitMultipartRequest true "请求参数"
|
||
// @Success 200 {object} map[string]string "返回 upload_id"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/multipart/init [post]
|
||
func (e *FileEndpoint) InitMultipart(c *gin.Context) {
|
||
var req requests.InitMultipartRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := e.NewFeaturesValidator.ValidateInitMultipart(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
cmd := handlers.InitMultipartCommand{BucketName: req.BucketName, ObjectKey: req.ObjectKey}
|
||
result, err := mediator.Send[handlers.InitMultipartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"upload_id": result})
|
||
}
|
||
|
||
// UploadPart godoc
|
||
// @Summary 上传分片
|
||
// @Description 上传单个文件分片,建议每个分片 5MB,支持失败重试
|
||
// @Tags 大文件上传
|
||
// @Accept multipart/form-data
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param bucket_name formData string true "存储桶名称"
|
||
// @Param object_key formData string true "对象键"
|
||
// @Param upload_id formData string true "上传ID(由初始化接口返回)"
|
||
// @Param part_number formData int true "分片序号(从1开始)"
|
||
// @Param file formData file true "分片文件数据"
|
||
// @Success 200 {object} map[string]string "返回 ETag"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/multipart/part [put]
|
||
func (e *FileEndpoint) UploadPart(c *gin.Context) {
|
||
var req requests.UploadPartRequest
|
||
if err := c.ShouldBind(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := e.NewFeaturesValidator.ValidateUploadPart(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
file, err := req.File.Open()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open part file"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
cmd := handlers.UploadPartCommand{
|
||
BucketName: req.BucketName,
|
||
ObjectKey: req.ObjectKey,
|
||
UploadId: req.UploadId,
|
||
PartNumber: req.PartNumber,
|
||
Data: file,
|
||
}
|
||
result, err := mediator.Send[handlers.UploadPartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"etag": result})
|
||
}
|
||
|
||
// CompleteMultipart godoc
|
||
// @Summary 完成分片上传
|
||
// @Description 合并所有分片完成上传,需传入所有分片的 PartNumber 和 ETag
|
||
// @Tags 大文件上传
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param request body requests.CompleteMultipartRequest true "请求参数"
|
||
// @Success 200 {object} map[string]string "返回文件位置"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/multipart/complete [post]
|
||
func (e *FileEndpoint) CompleteMultipart(c *gin.Context) {
|
||
var req requests.CompleteMultipartRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := e.NewFeaturesValidator.ValidateCompleteMultipart(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
cmd := handlers.CompleteMultipartCommand{
|
||
BucketName: req.BucketName,
|
||
ObjectKey: req.ObjectKey,
|
||
UploadId: req.UploadId,
|
||
Parts: req.Parts,
|
||
}
|
||
result, err := mediator.Send[handlers.CompleteMultipartCommand, string](e.Mediator, c.Request.Context(), cmd)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"location": result})
|
||
}
|
||
|
||
// DeleteFile godoc
|
||
// @Summary 删除文件
|
||
// @Description 从指定的存储桶删除文件,此操作不可恢复
|
||
// @Tags 文件操作
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Security ApiKeyAuth
|
||
// @Param request body requests.DeleteFileRequest true "请求参数"
|
||
// @Success 200 {object} map[string]string "删除成功消息"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失"
|
||
// @Failure 500 {object} map[string]string "服务器内部错误"
|
||
// @Router /files/delete [delete]
|
||
func (e *FileEndpoint) DeleteFile(c *gin.Context) {
|
||
var req requests.DeleteFileRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if err := e.NewFeaturesValidator.ValidateDeleteFile(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
cmd := handlers.DeleteFileCommand{
|
||
BucketName: req.BucketName,
|
||
ObjectKey: req.ObjectKey,
|
||
}
|
||
|
||
result, err := mediator.Send[handlers.DeleteFileCommand, string](e.Mediator, c.Request.Context(), cmd)
|
||
if err != nil {
|
||
handleError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": result})
|
||
}
|
||
|
||
func handleError(c *gin.Context, err error) {
|
||
if be, ok := err.(*common.BusinessException); ok {
|
||
c.JSON(be.Code, gin.H{"error": be.Message})
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
}
|
||
}
|