feat(chat): add endpoint to get quoted message by ID
All checks were successful
Build Backend / build (push) Successful in 2m21s
Build Backend / build-docker (push) Successful in 58s

Add GET /api/v1/messages/:id endpoint to retrieve quoted/replied messages
for preview fill and navigation purposes. The endpoint includes authorization
checks ensuring only conversation participants can access the message.

Additionally improve file upload handling for native clients (expo-file-system)
by accepting explicit displayName from frontend, with sanitization to prevent
path traversal and 255-character length limit. File extension detection now
prioritizes the real display name over the multipart header filename.
This commit is contained in:
lafay
2026-07-02 23:22:24 +08:00
parent d8e56e60dc
commit d240485d0e
5 changed files with 109 additions and 7 deletions

View File

@@ -358,6 +358,37 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
response.Paginated(c, result, total, page, pageSize) 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 发送消息 // SendMessage 发送消息
// POST /api/conversations/:id/messages // POST /api/conversations/:id/messages
func (h *MessageHandler) SendMessage(c *gin.Context) { func (h *MessageHandler) SendMessage(c *gin.Context) {

View File

@@ -109,8 +109,12 @@ func (h *UploadHandler) UploadFile(c *gin.Context) {
} }
folder := c.DefaultPostForm("folder", "chat") 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 { if err != nil {
// 参数类错误(大小/类型)返回 400其余返回 500 // 参数类错误(大小/类型)返回 400其余返回 500
if file.Size > 0 && file.Size <= 100<<20 { if file.Size > 0 && file.Size <= 100<<20 {

View File

@@ -407,6 +407,8 @@ func (r *Router) setupRoutes() {
messages := v1.Group("/messages") messages := v1.Group("/messages")
messages.Use(authMiddleware) messages.Use(authMiddleware)
{ {
// 按 ID 获取被引用消息(引用预览回填/跳转)
messages.GET("/:id", r.MessageHandler.GetReplyMessage)
// 撤回/删除消息(统一接口) // 撤回/删除消息(统一接口)
messages.POST("/delete_msg", middleware.RequireVerified(r.UserService), r.MessageHandler.HandleDeleteMsg) messages.POST("/delete_msg", middleware.RequireVerified(r.UserService), r.MessageHandler.HandleDeleteMsg)
} }

View File

@@ -55,6 +55,8 @@ type ChatService interface {
// 消息扩展功能 // 消息扩展功能
RecallMessage(ctx context.Context, messageID string, userID string) error RecallMessage(ctx context.Context, messageID string, userID string) error
DeleteMessage(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) SendTyping(ctx context.Context, senderID string, conversationID string)
@@ -987,6 +989,23 @@ func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, u
return nil 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 发送正在输入状态 // SendTyping 发送正在输入状态
func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) { func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) {
if s.wsHub == nil { if s.wsHub == nil {

View File

@@ -41,7 +41,12 @@ type UploadService interface {
UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error) 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) UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error)
UploadCover(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) GetURL(ctx context.Context, objectName string) (string, error)
Delete(ctx context.Context, objectName string) error Delete(ctx context.Context, objectName string) error
GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err 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 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 // UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3
// folder 用于隔离不同来源(如 chat/post仅允许字母数字与下划线。 // folder 用于隔离不同来源(如 chat/post仅允许字母数字与下划线。
// uploaderID 为上传者用户 ID用于审计。 // 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 { if file == nil {
return nil, fmt.Errorf("文件为空") 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) 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避免路径穿越
folder = strings.Map(func(r rune) rune { folder = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { 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 return nil, err
} }
// 以内容哈希命名,避免重复存储;保留原始扩展名(便于浏览器识别) // 以内容哈希命名,避免重复存储;扩展名优先取真实文件名(便于浏览器识别)
hash := sha256.Sum256(data) hash := sha256.Sum256(data)
hashStr := fmt.Sprintf("%x", hash) 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 == "" { if ext == "" {
// 从 MIME 推断扩展名mime.ExtensionsByType 在新版本返回 (exts, err) // 从 MIME 推断扩展名mime.ExtensionsByType 在新版本返回 (exts, err)
if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 { 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{ record := &model.UploadedFile{
URL: url, URL: url,
ObjectName: objectName, ObjectName: objectName,
Name: file.Filename, Name: fileName,
Size: file.Size, Size: file.Size,
MimeType: mimeType, MimeType: mimeType,
Folder: folder, Folder: folder,
@@ -420,7 +466,7 @@ func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHead
return &UploadFileResult{ return &UploadFileResult{
URL: url, URL: url,
Name: file.Filename, Name: fileName,
Size: file.Size, Size: file.Size,
MimeType: mimeType, MimeType: mimeType,
}, nil }, nil