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>
This commit is contained in:
parent
8c180082d5
commit
d861be0d6e
@ -19,10 +19,14 @@ import (
|
||||
)
|
||||
|
||||
// @title RustFS File System API
|
||||
// @version 1.1
|
||||
// @version 1.2
|
||||
// @description RustFS 文件存储系统 API,支持分片上传、文件预览、分页查询等高级功能。
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name X-API-Key
|
||||
// @description 在请求头中传入 API 密钥进行身份验证
|
||||
func main() {
|
||||
cfg := common.LoadConfig()
|
||||
|
||||
@ -44,6 +48,7 @@ func main() {
|
||||
uploadPartHandler := handlers.NewUploadPartHandler(s3Repo)
|
||||
completeMultipartHandler := handlers.NewCompleteMultipartHandler(s3Repo)
|
||||
deleteFileHandler := handlers.NewDeleteFileHandler(s3Repo)
|
||||
fileContentHandler := handlers.NewGetFileContentHandler(s3Repo)
|
||||
loginHandler := handlers.NewLoginHandler(cfg.AuthAPIKey)
|
||||
|
||||
// Register Handlers
|
||||
@ -59,6 +64,7 @@ func main() {
|
||||
mediator.Register[handlers.UploadPartCommand, string](m, uploadPartHandler)
|
||||
mediator.Register[handlers.CompleteMultipartCommand, string](m, completeMultipartHandler)
|
||||
mediator.Register[handlers.DeleteFileCommand, string](m, deleteFileHandler)
|
||||
mediator.Register[handlers.GetFileContentQuery, string](m, fileContentHandler)
|
||||
mediator.Register[handlers.LoginQuery, handlers.LoginResult](m, loginHandler)
|
||||
|
||||
// Validators
|
||||
@ -100,6 +106,7 @@ func main() {
|
||||
api.GET("/files/download", fileEndpoint.DownloadFile)
|
||||
api.GET("/files/list", fileEndpoint.ListFiles)
|
||||
api.GET("/files/preview", fileEndpoint.GetPreviewURL)
|
||||
api.GET("/files/content", fileEndpoint.GetFileContent)
|
||||
|
||||
// Delete file
|
||||
api.DELETE("/files/delete", fileEndpoint.DeleteFile)
|
||||
|
||||
@ -32,14 +32,16 @@ func NewFileEndpoint(m *mediator.Mediator, uv *validators.UploadFileValidator, d
|
||||
|
||||
// UploadFile godoc
|
||||
// @Summary 上传文件 (简单上传)
|
||||
// @Description 上传小文件到指定的存储桶
|
||||
// @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) {
|
||||
@ -83,14 +85,16 @@ func (e *FileEndpoint) UploadFile(c *gin.Context) {
|
||||
|
||||
// DownloadFile godoc
|
||||
// @Summary 下载文件
|
||||
// @Description 从指定的存储桶下载文件
|
||||
// @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) {
|
||||
@ -124,17 +128,19 @@ func (e *FileEndpoint) DownloadFile(c *gin.Context) {
|
||||
|
||||
// ListFiles godoc
|
||||
// @Summary 文件列表 (分页)
|
||||
// @Description 分页查询存储桶中的文件
|
||||
// @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 "每页数量"
|
||||
// @Param token query string false "分页Token"
|
||||
// @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 500 {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/list [get]
|
||||
func (e *FileEndpoint) ListFiles(c *gin.Context) {
|
||||
var req requests.ListFilesRequest
|
||||
@ -170,15 +176,17 @@ func (e *FileEndpoint) ListFiles(c *gin.Context) {
|
||||
|
||||
// GetPreviewURL godoc
|
||||
// @Summary 获取预览链接
|
||||
// @Description 生成文件的临时预览链接 (24小时有效)
|
||||
// @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
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @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
|
||||
@ -206,16 +214,57 @@ func (e *FileEndpoint) GetPreviewURL(c *gin.Context) {
|
||||
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 开始一个新的大文件分片上传任务
|
||||
// @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 500 {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/init [post]
|
||||
func (e *FileEndpoint) InitMultipart(c *gin.Context) {
|
||||
var req requests.InitMultipartRequest
|
||||
@ -239,18 +288,20 @@ func (e *FileEndpoint) InitMultipart(c *gin.Context) {
|
||||
|
||||
// UploadPart godoc
|
||||
// @Summary 上传分片
|
||||
// @Description 上传单个文件分片
|
||||
// @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 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 500 {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/part [put]
|
||||
func (e *FileEndpoint) UploadPart(c *gin.Context) {
|
||||
var req requests.UploadPartRequest
|
||||
@ -287,14 +338,16 @@ func (e *FileEndpoint) UploadPart(c *gin.Context) {
|
||||
|
||||
// CompleteMultipart godoc
|
||||
// @Summary 完成分片上传
|
||||
// @Description 合并所有分片完成上传
|
||||
// @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 500 {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
|
||||
@ -323,13 +376,15 @@ func (e *FileEndpoint) CompleteMultipart(c *gin.Context) {
|
||||
|
||||
// DeleteFile godoc
|
||||
// @Summary 删除文件
|
||||
// @Description 从指定的存储桶删除文件
|
||||
// @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) {
|
||||
|
||||
@ -22,6 +22,12 @@ type GetFilePreviewQuery struct {
|
||||
Expiry time.Duration
|
||||
}
|
||||
|
||||
// GetFileContentQuery 获取文件文本内容查询
|
||||
type GetFileContentQuery struct {
|
||||
BucketName string
|
||||
ObjectKey string
|
||||
}
|
||||
|
||||
type InitMultipartCommand struct {
|
||||
BucketName string
|
||||
ObjectKey string
|
||||
@ -68,6 +74,19 @@ func (h *GetFilePreviewHandler) Handle(ctx context.Context, q GetFilePreviewQuer
|
||||
return h.Repo.GeneratePresignedURL(ctx, q.BucketName, q.ObjectKey, q.Expiry)
|
||||
}
|
||||
|
||||
// GetFileContentHandler 获取文件文本内容处理器
|
||||
type GetFileContentHandler struct {
|
||||
Repo repository.FileRepository
|
||||
}
|
||||
|
||||
func NewGetFileContentHandler(repo repository.FileRepository) *GetFileContentHandler {
|
||||
return &GetFileContentHandler{Repo: repo}
|
||||
}
|
||||
|
||||
func (h *GetFileContentHandler) Handle(ctx context.Context, q GetFileContentQuery) (string, error) {
|
||||
return h.Repo.GetFileContent(ctx, q.BucketName, q.ObjectKey)
|
||||
}
|
||||
|
||||
// DeleteFileCommand 删除文件命令
|
||||
type DeleteFileCommand struct {
|
||||
BucketName string
|
||||
|
||||
@ -31,6 +31,9 @@ type FileRepository interface {
|
||||
ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*ListFilesResult, error)
|
||||
GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error)
|
||||
|
||||
// 获取文件文本内容(用于文本文件预览)
|
||||
GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error)
|
||||
|
||||
// 删除文件
|
||||
DeleteFile(ctx context.Context, bucketName string, objectKey string) error
|
||||
|
||||
|
||||
@ -85,6 +85,23 @@ func (r *S3FileRepository) ListObjects(ctx context.Context, bucketName string) (
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// GetFileContent 获取文件文本内容(用于 Markdown 等文本文件预览)
|
||||
func (r *S3FileRepository) GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error) {
|
||||
resp, err := r.client.Client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func (r *S3FileRepository) DeleteFile(ctx context.Context, bucketName string, objectKey string) error {
|
||||
_, err := r.client.Client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
|
||||
@ -58,6 +58,9 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
|
||||
<div>
|
||||
<a href="/web/guide.html" target="_blank" class="btn btn-outline-info me-2">
|
||||
<i class="fas fa-plug me-1"></i>对接指南
|
||||
</a>
|
||||
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-book me-1"></i>API 文档
|
||||
</a>
|
||||
@ -480,26 +483,32 @@
|
||||
// Preview
|
||||
const previewFile = async (key) => {
|
||||
try {
|
||||
const res = await api.get('/files/preview', {
|
||||
params: { bucket_name: currentBucket.value, object_key: key }
|
||||
});
|
||||
previewUrl.value = res.data.url;
|
||||
|
||||
const ext = key.split('.').pop().toLowerCase();
|
||||
if (['md', 'markdown'].includes(ext)) {
|
||||
// Markdown 文件:获取内容并渲染
|
||||
// Markdown 文件:通过后端接口获取文本内容,前端渲染
|
||||
previewUrl.value = 'loading';
|
||||
previewType.value = 'markdown';
|
||||
const mdRes = await axios.get(res.data.url);
|
||||
markdownHtml.value = marked.parse(mdRes.data);
|
||||
} else if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
|
||||
previewType.value = 'image';
|
||||
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
|
||||
previewType.value = 'video';
|
||||
const res = await api.get('/files/content', {
|
||||
params: { bucket_name: currentBucket.value, object_key: key }
|
||||
});
|
||||
markdownHtml.value = marked.parse(res.data.content);
|
||||
} else {
|
||||
previewType.value = 'other';
|
||||
// 其他文件:获取 presigned URL 预览
|
||||
const res = await api.get('/files/preview', {
|
||||
params: { bucket_name: currentBucket.value, object_key: key }
|
||||
});
|
||||
previewUrl.value = res.data.url;
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
|
||||
previewType.value = 'image';
|
||||
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
|
||||
previewType.value = 'video';
|
||||
} else {
|
||||
previewType.value = 'other';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert('无法获取预览链接');
|
||||
previewUrl.value = null;
|
||||
alert('无法获取预览内容');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user