From d861be0d6e7a13ba07995565ab1daaaa2e25f928 Mon Sep 17 00:00:00 2001 From: root <1772105645@qq.com> Date: Wed, 6 May 2026 18:08:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=86=85=E5=AE=B9=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DMarkdown=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- cmd/server/main.go | 9 +- internal/api/endpoints/file_endpoint.go | 103 ++++++++++++++---- internal/api/handlers/query_handlers.go | 19 ++++ internal/domain/repository/file_repository.go | 3 + .../infrastructure/s3/file_repository_impl.go | 17 +++ web/index.html | 37 ++++--- 6 files changed, 149 insertions(+), 39 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 219689b..f55027a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/internal/api/endpoints/file_endpoint.go b/internal/api/endpoints/file_endpoint.go index a20a384..6bccfe8 100644 --- a/internal/api/endpoints/file_endpoint.go +++ b/internal/api/endpoints/file_endpoint.go @@ -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) { diff --git a/internal/api/handlers/query_handlers.go b/internal/api/handlers/query_handlers.go index c50549e..fd1c548 100644 --- a/internal/api/handlers/query_handlers.go +++ b/internal/api/handlers/query_handlers.go @@ -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 diff --git a/internal/domain/repository/file_repository.go b/internal/domain/repository/file_repository.go index da64886..ea758ea 100644 --- a/internal/domain/repository/file_repository.go +++ b/internal/domain/repository/file_repository.go @@ -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 diff --git a/internal/infrastructure/s3/file_repository_impl.go b/internal/infrastructure/s3/file_repository_impl.go index 2c37843..57c24ac 100644 --- a/internal/infrastructure/s3/file_repository_impl.go +++ b/internal/infrastructure/s3/file_repository_impl.go @@ -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{ diff --git a/web/index.html b/web/index.html index 3693fcf..e4dddd0 100644 --- a/web/index.html +++ b/web/index.html @@ -58,6 +58,9 @@

RustFS 文件管理系统

+ + 对接指南 + API 文档 @@ -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('无法获取预览内容'); } };