file-system/internal/api/endpoints/file_endpoint.go
root d861be0d6e feat: 新增文件文本内容接口,修复Markdown预览
- 新增 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>
2026-05-06 18:08:42 +08:00

422 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()})
}
}