diff --git a/internal/handler/message_handler.go b/internal/handler/message_handler.go index 5813540..7bd507a 100644 --- a/internal/handler/message_handler.go +++ b/internal/handler/message_handler.go @@ -358,6 +358,37 @@ func (h *MessageHandler) GetMessages(c *gin.Context) { response.Paginated(c, result, total, page, pageSize) } +// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览回填/跳转) +// GET /api/v1/messages/:id +func (h *MessageHandler) GetReplyMessage(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "") + return + } + + messageID := c.Param("id") + if messageID == "" { + response.BadRequest(c, "message id is required") + return + } + + message, err := h.chatService.GetReplyMessage(c.Request.Context(), messageID, userID) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + // 转换为响应格式(标记已过期文件 / 撤回占位等,保持与列表接口一致) + result := h.convertMessagesWithExpiry(c.Request.Context(), []*model.Message{message}) + if len(result) == 0 { + response.InternalServerError(c, "failed to convert message") + return + } + + response.Success(c, result[0]) +} + // SendMessage 发送消息 // POST /api/conversations/:id/messages func (h *MessageHandler) SendMessage(c *gin.Context) { diff --git a/internal/handler/upload_handler.go b/internal/handler/upload_handler.go index 656e627..5f22951 100644 --- a/internal/handler/upload_handler.go +++ b/internal/handler/upload_handler.go @@ -109,8 +109,12 @@ func (h *UploadHandler) UploadFile(c *gin.Context) { } folder := c.DefaultPostForm("folder", "chat") + // 前端(尤其原生端 expo-file-system)无法自定义 multipart filename, + // 文件常被复制到缓存目录并以 UUID 命名,导致 header Filename 丢失真实名。 + // 因此前端额外把真实文件名通过 "name" 字段回传,作为权威展示名。 + displayName := c.PostForm("name") - result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID) + result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID, displayName) if err != nil { // 参数类错误(大小/类型)返回 400,其余返回 500 if file.Size > 0 && file.Size <= 100<<20 { diff --git a/internal/router/router.go b/internal/router/router.go index 25ceed5..9a70d7f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -407,6 +407,8 @@ func (r *Router) setupRoutes() { messages := v1.Group("/messages") messages.Use(authMiddleware) { + // 按 ID 获取被引用消息(引用预览回填/跳转) + messages.GET("/:id", r.MessageHandler.GetReplyMessage) // 撤回/删除消息(统一接口) messages.POST("/delete_msg", middleware.RequireVerified(r.UserService), r.MessageHandler.HandleDeleteMsg) } diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 61a6be2..befa0df 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -55,6 +55,8 @@ type ChatService interface { // 消息扩展功能 RecallMessage(ctx context.Context, messageID string, userID string) error DeleteMessage(ctx context.Context, messageID string, userID string) error + // GetReplyMessage 按 ID 获取被引用消息(鉴权:必须是该会话参与者) + GetReplyMessage(ctx context.Context, messageID string, userID string) (*model.Message, error) // 实时事件相关 SendTyping(ctx context.Context, senderID string, conversationID string) @@ -987,6 +989,23 @@ func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, u return nil } +// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览/跳转的回填) +// 鉴权:调用者必须是该消息所属会话的参与者,否则拒绝(防止 IDOR) +func (s *chatServiceImpl) GetReplyMessage(ctx context.Context, messageID string, userID string) (*model.Message, error) { + message, err := s.repo.GetMessageByID(messageID) + if err != nil { + return nil, errors.New("message not found") + } + + // 验证用户是否是会话参与者 + _, err = s.getParticipant(ctx, message.ConversationID, userID) + if err != nil { + return nil, errors.New("no permission to view this message") + } + + return message, nil +} + // SendTyping 发送正在输入状态 func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) { if s.wsHub == nil { diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index dc3df79..fb79bee 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -41,7 +41,12 @@ type UploadService interface { UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error) UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error) UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error) - UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error) + // UploadFile 上传聊天文件。 + // displayName 为前端显式传入的真实文件名(用于展示); + // 当为空时回退到 multipart header 中的 Filename。 + // 注意:原生端 expo-file-system 的 File.upload() 无法自定义 multipart filename, + // 文件常被复制到缓存目录并以 UUID 命名,导致 Filename 丢失真实名,因此需要显式回传。 + UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string, displayName string) (*UploadFileResult, error) GetURL(ctx context.Context, objectName string) (string, error) Delete(ctx context.Context, objectName string) error GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error) @@ -339,10 +344,40 @@ func validateChatFile(size int64, mimeType string) error { return nil } +// sanitizeFileName 清洗用于展示的文件名: +// - 去除首尾空白与两侧的路径分隔符/点号,避免展示绝对路径或隐藏文件名; +// - 去掉任何目录成分(防止 "a/b.pdf" 这类注入展示层); +// - 限制长度,避免超长名影响存储与列表展示。 +// +// 注意:仅用于"展示名",不影响 S3 对象名(对象名始终为内容哈希)。 +func sanitizeFileName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + // 统一分隔符后只保留文件名部分(basename) + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + // 去除开头多余的 "."(避免 ".file" 之外出现 "..file" 之类异常),但保留正常的隐藏文件名 + name = strings.TrimLeft(name, ".") + name = strings.TrimSpace(name) + if len(name) > 255 { + // 过长时保留扩展名,截断主名 + ext := strings.ToLower(filepath.Ext(name)) + cut := 255 - len(ext) + if cut < 1 { + cut = 1 + } + name = name[:cut] + ext + } + return name +} + // UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3) // folder 用于隔离不同来源(如 chat/post),仅允许字母数字与下划线。 // uploaderID 为上传者用户 ID,用于审计。 -func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string) (*UploadFileResult, error) { +// displayName 为前端显式传入的真实文件名(用于展示), +// 为空时回退到 multipart header 中的 Filename。 +func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string, displayName string) (*UploadFileResult, error) { if file == nil { return nil, fmt.Errorf("文件为空") } @@ -350,6 +385,12 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead return nil, fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20) } + // 选择用于展示的真实文件名:优先用前端显式回传的 displayName, + // 它最贴近用户原始文件名(multipart header 的 Filename 在原生端常被 UUID 覆盖)。 + displayName = sanitizeFileName(displayName) + headerName := sanitizeFileName(file.Filename) + fileName := cmp.Or(displayName, headerName) + // 校验与规范化 folder,避免路径穿越 folder = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { @@ -383,10 +424,15 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead return nil, err } - // 以内容哈希命名,避免重复存储;保留原始扩展名(便于浏览器识别) + // 以内容哈希命名,避免重复存储;扩展名优先取真实文件名(便于浏览器识别) hash := sha256.Sum256(data) hashStr := fmt.Sprintf("%x", hash) - ext := strings.ToLower(filepath.Ext(file.Filename)) + // header Filename 在原生端常为 UUID,因此优先从真实展示名推导扩展名, + // 两者都没有时再从 MIME 推断 + ext := strings.ToLower(filepath.Ext(fileName)) + if ext == "" { + ext = strings.ToLower(filepath.Ext(file.Filename)) + } if ext == "" { // 从 MIME 推断扩展名(mime.ExtensionsByType 在新版本返回 (exts, err)) if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 { @@ -407,7 +453,7 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead record := &model.UploadedFile{ URL: url, ObjectName: objectName, - Name: file.Filename, + Name: fileName, Size: file.Size, MimeType: mimeType, Folder: folder, @@ -420,7 +466,7 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead return &UploadFileResult{ URL: url, - Name: file.Filename, + Name: fileName, Size: file.Size, MimeType: mimeType, }, nil