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:
root 2026-05-06 18:08:04 +08:00
parent 8c180082d5
commit d861be0d6e
6 changed files with 149 additions and 39 deletions

View File

@ -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)

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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{

View File

@ -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('无法获取预览内容');
}
};