24 changed files with 930 additions and 12 deletions
@ -0,0 +1,119 @@ |
|||
# 聊天图片、文件消息接入 |
|||
|
|||
适用:企业与求职者的 DIRECT 会话。本文对应本批后端候选源码;尚未提交、推送或部署,线上 #94 还没有这些新接口。联调前确认本批已部署。 |
|||
|
|||
基础路径沿用项目 API_BASE_URL `/api/v1`。全部接口均要求 `Authorization: Bearer <accessToken>`,不能把 Token 放在 URL。现有文字、简历卡片接口保持原用法。普通 Emoji 仍走文字消息。 |
|||
|
|||
## 1. 上传并发送 |
|||
|
|||
`POST /conversations/{id}/attachment-messages` |
|||
|
|||
请求类型 `multipart/form-data`,字段: |
|||
|
|||
| 字段 | 必填 | 内容 | |
|||
| --- | --- | --- | |
|||
| `file` | 是 | 一个文件;文件名和 MIME 必须正确 | |
|||
| `kind` | 是 | `IMAGE` 或 `FILE`,大写 | |
|||
| `clientMessageId` | 是 | 客户端生成,1–64 位字母、数字、`_`、`-` | |
|||
|
|||
调用一次即上传并发送一条消息,没有单独“先上传再发 attachmentId”的步骤。使用项目现有鉴权请求封装;传 FormData 时不要手动写缺少 boundary 的 Content-Type。 |
|||
|
|||
成功:HTTP 200,沿用 `{code:200,data:MessageResponse,requestId:...}` 信封。`IMAGE` 和 `FILE` 消息都在既有 `attachment` 字段返回元数据。例如(示意数据): |
|||
|
|||
```json |
|||
{ |
|||
"id": 123, |
|||
"clientMessageId": "image_abc123", |
|||
"conversationId": 10, |
|||
"senderUserId": 62, |
|||
"messageType": "IMAGE", |
|||
"content": "[图片]", |
|||
"status": "SENT", |
|||
"version": 1, |
|||
"attachment": { |
|||
"fileName": "office.png", |
|||
"contentType": "image/png", |
|||
"size": 18420, |
|||
"sha256": "服务端实际保存图片的SHA256", |
|||
"width": 1280, |
|||
"height": 720, |
|||
"available": true, |
|||
"downloadPath": "/conversations/10/messages/123/attachment", |
|||
"previewPath": "/conversations/10/messages/123/attachment/preview", |
|||
"thumbnailPath": "/conversations/10/messages/123/attachment/thumbnail" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`FILE` 的 `previewPath`、`thumbnailPath`、`width`、`height` 为空或省略。以上路径均相对 `/api/v1`,不是公开下载链接。不要自行拼接本地文件路径或对象存储地址。 |
|||
|
|||
### 文件范围 |
|||
|
|||
- 图片:JPEG(`.jpg/.jpeg`)与 PNG;实际解码校验,最多 1600 万像素、任一边长不超过 8192。首版不支持 GIF、WebP、HEIC、SVG,前端选取时过滤或在明确告知后转换为支持格式。 |
|||
- 普通文件:PDF、DOC、DOCX、UTF-8 TXT;不支持 XLS/XLSX、ZIP、安装包或可执行文件。 |
|||
- 正确 MIME 分别为 `image/jpeg`、`image/png`、`application/pdf`、`application/msword`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document`、`text/plain`。 |
|||
- 单文件最多 10 MiB(10,485,760 字节),空文件拒绝。会话有效附件默认累计最多 100 MiB。缩略图最长边 320,JPEG 格式。 |
|||
- 图片重新编码以移除 EXIF 等附加元数据,`size/sha256` 对应保存后的图片,可能与客户端原文件不同。 |
|||
- 文档执行扩展名、MIME、签名及 DOCX 压缩包结构检查;这不等于病毒扫描。 |
|||
|
|||
### 重试与错误 |
|||
|
|||
发送前生成 clientMessageId,网络超时后重试必须使用相同会话、账号、文件字节、文件名、MIME、kind 和 clientMessageId。相同请求返回原消息,不重复入库或推送;相同 key 改文件、改类型或与文字/简历消息混用返回 400。不要为一次自动重试重新生成 key。 |
|||
|
|||
- HTTP 400:缺字段、非法类型、伪造内容、尺寸/业务大小超限、重复 key 内容不一致或会话配额耗尽;显示后端错误并保留重试/重新选择入口。 |
|||
- HTTP 413:Servlet 层拦截超大 multipart,请提示文件过大。 |
|||
- HTTP 401:未登录、会话失效或账号禁用。 |
|||
- HTTP 403:非成员、屏蔽、企业未认证、当前无有效投递沟通权限,或不是 DIRECT 会话。 |
|||
- HTTP 404:会话/消息不存在、附件已撤回/清除或发送者账号已禁用。 |
|||
|
|||
失败不能显示“已发送”。上传进度是否展示由前端传输层实现;当前后端不提供断点续传、分片或服务器进度接口。 |
|||
|
|||
## 2. 缩略图、预览和下载 |
|||
|
|||
| 接口 | 返回 | |
|||
| --- | --- | |
|||
| `GET /conversations/{conversationId}/messages/{messageId}/attachment` | 附件原始保存内容,Content-Disposition 为 attachment | |
|||
| `GET .../attachment/preview` | 仅 IMAGE,重新编码的全尺寸图片,inline | |
|||
| `GET .../attachment/thumbnail` | 仅 IMAGE,JPEG 缩略图,inline | |
|||
|
|||
均返回二进制,不经过 JSON 解包;响应头包括真实 Content-Type、Content-Length、`Cache-Control: no-store` 和 `X-Content-Type-Options: nosniff`。 |
|||
|
|||
列表优先加载 thumbnail,点击后鉴权加载 preview;文件消息显示 fileName、size 和下载操作。图片请求也要带 Authorization,不能只把受保护地址直接丢给不带鉴权的图片组件。 |
|||
|
|||
若先下载到 App 私有缓存再预览,应按账号隔离,退出账号、切换账号、撤回和失效时清除相关缓存;不要复制到公共相册或公共目录后仍宣称能被服务端撤回。已下载到设备或截图的内容,服务端不能远程收回。 |
|||
|
|||
每次下载重新检查会话成员、企业认证、有效沟通投递、屏蔽、消息状态和发送者有效性。列表里的 `available:true` 只代表列表读取时附件尚存,不保证之后每次请求仍获授权。 |
|||
|
|||
## 3. 消息列表、实时事件、撤回 |
|||
|
|||
现有 `GET /conversations/{id}/messages` 及 `GET /conversations/{id}/events` 会返回同样的 MessageResponse/attachment。WebSocket STOMP 的 `MESSAGE_CREATED`、`MESSAGE_RECALLED` 事件也使用同一结构。 |
|||
|
|||
- 上传成功响应和实时事件可能都到达:按 `message.id + version` 合并,不能直接 append 两次。 |
|||
- 重连后使用既有游标补拉,补拉成功合并后才推进 nextCursor;不要用实时事件 eventId 直接覆盖补拉游标。 |
|||
- `IMAGE/FILE` 走对应气泡;保留 TEXT 和 RESUME 的现有渲染。 |
|||
- 撤回沿用 `POST /conversations/{conversationId}/messages/{messageId}/recall`:仅本人,沿用两分钟窗口。 |
|||
- 撤回成功后删除服务端原件/缩略图,消息保留“消息已撤回”,attachment.available=false,地址与元数据清空,前端停止展示/加载旧图。 |
|||
|
|||
## 4. 现有前端修改位置 |
|||
|
|||
- `app/chat/[id].tsx`:把图片/文件按钮的占位提示替换为选择器;新增图片气泡、文件卡片、预览及下载反馈。 |
|||
- `src/types/message.ts`:扩展消息类型与 attachment 元数据,兼容无 attachment 的旧消息。 |
|||
- `src/api/api.ts`:multipart 发送、鉴权二进制请求与错误转换;所有请求沿用当前登录态。 |
|||
- `src/hooks/useConversation.ts`:发送状态、稳定 clientMessageId、重试、按 ID/版本合并、实时事件与重连补拉。 |
|||
|
|||
页面只处理展示和交互,请求与状态编排仍放在 API/Hook 层。地图功能不在本批范围。 |
|||
|
|||
## 5. 联调验收 |
|||
|
|||
1. 企业、求职者在已允许沟通的投递内互发 PNG/JPEG/TXT/PDF,双方当前聊天页立即出现且不重复。 |
|||
2. 缩略图、全图、文件名/大小正确,下载字节与后端 sha256 一致;图片按服务端重新编码后的摘要核对。 |
|||
3. 超时重试复用 key,只产生一条;换文件复用同 key 被拒绝。 |
|||
4. 切账号、陌生账号、另一会话 ID、屏蔽、撤回、禁用账号无法继续读取附件。 |
|||
5. 接收方断网后重连补拉,退出重进及后端重启后历史附件仍在。 |
|||
6. 空文件、超限文件、伪装 PNG、非法文档和不支持格式均明确失败,页面无假成功或白屏。 |
|||
|
|||
## 6. 后端部署前提 |
|||
|
|||
需迁移 V62(新表 chat_attachments 与消息元数据列)。二进制在数据库中和消息一起提交,无需开放公开文件目录。MySQL max_allowed_packet 应至少 16 MiB,代理上传限制应允许约 11 MiB 的 multipart 请求;业务单文件仍受 10 MiB 限制。生产部署、代理配置修改需单独授权。 |
|||
|
|||
Servlet 上传边界至少为单文件 10 MiB、请求 11 MiB;既有更大的配置予以保留。会话业务配额可配置 `app.chat.attachments.max-conversation-bytes`(默认 104857600)。首次版本采用限额与撤回释放空间,尚无长期附件管理/任意时间删除接口。 |
|||
@ -0,0 +1,13 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.*; |
|||
|
|||
/** 二进制与列表元数据分表,和消息在同一事务提交;无公开 URL 或客户端路径。 */ |
|||
@Entity @Table(name="chat_attachments") |
|||
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor |
|||
public class ChatAttachment { |
|||
@Id @Column(name="message_id") private Long messageId; |
|||
@Lob @Column(nullable=false,columnDefinition="LONGBLOB") private byte[] content; |
|||
@Lob @Column(columnDefinition="LONGBLOB") private byte[] thumbnail; |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.messaging.dto.MessageResponse; |
|||
import com.mydreamjob.security.CurrentUserAccessor; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.*; |
|||
import org.springframework.web.bind.annotation.*; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
|
|||
@RestController @RequestMapping("/conversations") @RequiredArgsConstructor @Tag(name="消息") |
|||
public class ChatAttachmentController { |
|||
private final ChatAttachmentService service; |
|||
private final CurrentUserAccessor current; |
|||
private final RequestIdAccessor requestIds; |
|||
@Operation(summary="上传并发送图片或文件消息",description="仅有当前沟通权限的 DIRECT 成员。multipart 字段 file、kind=IMAGE/FILE、clientMessageId 必填;同一重试必须保持三者不变。单文件 10 MiB;IMAGE 支持 JPEG/PNG、最多 1600 万像素且边长≤8192,去除元数据并生成最长边320的JPEG缩略图;FILE支持 PDF/DOC/DOCX/UTF-8 TXT。默认会话有效附件总量100 MiB。成功原子提交消息与字节,复用 MESSAGE_CREATED 推送和游标补拉;不返回公开URL。") |
|||
@PostMapping(value="/{id}/attachment-messages",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) |
|||
public ApiResponse<MessageResponse> send(@PathVariable Long id,@RequestParam String kind, |
|||
@RequestParam String clientMessageId,@RequestPart MultipartFile file,HttpServletRequest request) { |
|||
return ApiResponse.ok(service.send(id,current.requireUserId(),kind,clientMessageId,file),requestIds.get(request)); |
|||
} |
|||
@Operation(summary="鉴权下载消息附件",description="每次重新校验成员、企业认证、有效投递和屏蔽,撤回或发送者禁用后不可下载;返回 no-store/nosniff/attachment,不支持公开URL或Token查询参数。") |
|||
@GetMapping("/{conversationId}/messages/{messageId}/attachment") |
|||
public ResponseEntity<byte[]> download(@PathVariable Long conversationId,@PathVariable Long messageId) { |
|||
return response(service.download(conversationId,messageId,current.requireUserId(),"download"),false); |
|||
} |
|||
@Operation(summary="鉴权读取图片预览",description="仅IMAGE,返回已重新编码去除元数据的图片;鉴权与下载相同。") |
|||
@GetMapping("/{conversationId}/messages/{messageId}/attachment/preview") |
|||
public ResponseEntity<byte[]> preview(@PathVariable Long conversationId,@PathVariable Long messageId) { |
|||
return response(service.download(conversationId,messageId,current.requireUserId(),"preview"),true); |
|||
} |
|||
@Operation(summary="鉴权读取图片缩略图",description="仅IMAGE,最长边320的JPEG,鉴权与下载相同。") |
|||
@GetMapping("/{conversationId}/messages/{messageId}/attachment/thumbnail") |
|||
public ResponseEntity<byte[]> thumbnail(@PathVariable Long conversationId,@PathVariable Long messageId) { |
|||
return response(service.download(conversationId,messageId,current.requireUserId(),"thumbnail"),true); |
|||
} |
|||
private ResponseEntity<byte[]> response(ChatAttachmentService.Download file,boolean inline) { |
|||
return ResponseEntity.ok().contentType(MediaType.parseMediaType(file.type())) |
|||
.header(HttpHeaders.CONTENT_DISPOSITION,(inline?ContentDisposition.inline():ContentDisposition.attachment()) |
|||
.filename(file.name(),java.nio.charset.StandardCharsets.UTF_8).build().toString()) |
|||
.header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff") |
|||
.header("Content-Security-Policy","default-src 'none'; sandbox") |
|||
.contentLength(file.content().length).body(file.content()); |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import org.springframework.data.jpa.repository.*; |
|||
import org.springframework.data.repository.query.Param; |
|||
|
|||
public interface ChatAttachmentRepository extends JpaRepository<ChatAttachment,Long> { |
|||
@Modifying |
|||
@Query("delete from ChatAttachment a where a.messageId = :id") |
|||
void purge(@Param("id") Long messageId); |
|||
|
|||
@Modifying |
|||
@Query("delete from ChatAttachment a where a.messageId in (select m.id from Message m where m.senderUserId = :id)") |
|||
void purgeForSender(@Param("id") Long userId); |
|||
|
|||
@Modifying(clearAutomatically=true,flushAutomatically=true) |
|||
@Query("update Message m set m.attachmentFilename=null, m.attachmentContentType=null, " |
|||
+ "m.attachmentSize=null, m.attachmentSha256=null, m.attachmentWidth=null, m.attachmentHeight=null, " |
|||
+ "m.version=m.version+1 where m.senderUserId=:id and m.messageType in ('IMAGE','FILE') and m.attachmentSize is not null") |
|||
void clearMetadataForSender(@Param("id") Long userId); |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.messaging.dto.*; |
|||
import com.mydreamjob.common.exception.*; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import java.util.*; |
|||
import java.nio.charset.StandardCharsets; |
|||
|
|||
@Service @RequiredArgsConstructor |
|||
public class ChatAttachmentService { |
|||
private final MessagingService messaging; |
|||
private final ConversationRepository conversations; |
|||
private final MessageRepository messages; |
|||
private final ChatAttachmentRepository attachments; |
|||
private final ChatAttachmentValidator validator; |
|||
private final MessageViewMapper mapper; |
|||
private final UserRepository users; |
|||
@org.springframework.beans.factory.annotation.Value("${app.chat.attachments.max-conversation-bytes:104857600}") |
|||
private long maxConversationBytes; |
|||
public record Download(String name,String type,byte[] content,boolean image) { |
|||
@Override public String toString(){return "ChatDownload[redacted]";} |
|||
} |
|||
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) |
|||
public MessageResponse send(Long conversationId,Long actor,String kind,String clientId,MultipartFile file) { |
|||
messaging.assertAttachmentAccess(conversationId,actor); |
|||
if(clientId==null || !clientId.matches("[A-Za-z0-9_-]{1,64}")) bad(); |
|||
var validated=validator.validate(kind,file); |
|||
String requestHash=hash(fileBytes(file)); |
|||
String fingerprint=hash((kind+"\n"+validated.name()+"\n"+validated.type()+"\n"+requestHash).getBytes(StandardCharsets.UTF_8)); |
|||
conversations.findLockedById(conversationId).orElseThrow(()->new BusinessException(ErrorCode.CONVERSATION_NOT_FOUND)); |
|||
// 等待行锁后重新校验,防止等待期间会话授权已经变化。
|
|||
messaging.assertAttachmentAccess(conversationId,actor); |
|||
var prior=messages.findByConversationIdAndSenderUserIdAndClientMessageId(conversationId,actor,clientId); |
|||
if(prior.isPresent()) { |
|||
if(!kind.equals(prior.get().getMessageType()) || !fingerprint.equals(prior.get().getRequestContentHash())) bad(); |
|||
return mapper.toResponse(prior.get(),actor,false); |
|||
} |
|||
if(maxConversationBytes<=0 || messages.activeAttachmentBytes(conversationId)+validated.content().length>maxConversationBytes) |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST,"conversation attachment quota exceeded"); |
|||
var request=new SendMessageRequest();request.setConversationId(conversationId); |
|||
request.setClientMessageId(clientId);request.setContent("IMAGE".equals(kind)?"[图片]":"[文件]"); |
|||
var sent=messaging.sendMessage(actor,request); |
|||
Message message=messages.findById(((Number)sent.get("id")).longValue()).orElseThrow(); |
|||
message.setMessageType(kind);message.setRequestContentHash(fingerprint); |
|||
message.setAttachmentFilename(validated.name());message.setAttachmentContentType(validated.type()); |
|||
message.setAttachmentSize((long)validated.content().length);message.setAttachmentSha256(hash(validated.content())); |
|||
message.setAttachmentWidth(validated.width());message.setAttachmentHeight(validated.height()); |
|||
messages.saveAndFlush(message); |
|||
attachments.saveAndFlush(ChatAttachment.builder().messageId(message.getId()).content(validated.content()).thumbnail(validated.thumbnail()).build()); |
|||
return mapper.toResponse(message,actor,false); |
|||
} |
|||
@Transactional(readOnly=true) |
|||
public Download download(Long conversationId,Long messageId,Long actor,String variant) { |
|||
messaging.assertAttachmentAccess(conversationId,actor); |
|||
Message message=messages.findById(messageId).orElseThrow(()->new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); |
|||
if(!conversationId.equals(message.getConversationId())) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
boolean image="IMAGE".equals(message.getMessageType()); |
|||
if((!image && !"FILE".equals(message.getMessageType())) || !"SENT".equals(message.getStatus())) |
|||
throw new BusinessException(ErrorCode.MESSAGE_NOT_FOUND,"attachment unavailable"); |
|||
var sender=users.findById(message.getSenderUserId()).orElse(null); |
|||
if(sender==null || !Boolean.TRUE.equals(sender.getActive())) throw new BusinessException(ErrorCode.MESSAGE_NOT_FOUND); |
|||
if(!"download".equals(variant) && !image) throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
var stored=attachments.findById(messageId).orElseThrow(()->new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); |
|||
return "thumbnail".equals(variant) |
|||
? new Download("thumbnail.jpg","image/jpeg",stored.getThumbnail().clone(),true) |
|||
: new Download(message.getAttachmentFilename(),message.getAttachmentContentType(),stored.getContent().clone(),image); |
|||
} |
|||
private byte[] fileBytes(MultipartFile file) { |
|||
try(var input=file.getInputStream()) {return input.readNBytes((int)ChatAttachmentValidator.MAX_BYTES+1);} |
|||
catch(java.io.IOException e){throw new BusinessException(ErrorCode.BAD_REQUEST);} |
|||
} |
|||
private String hash(byte[] bytes) { |
|||
try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));} |
|||
catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException(impossible);} |
|||
} |
|||
private static void bad(){throw new BusinessException(ErrorCode.BAD_REQUEST,"clientMessageId already used or invalid");} |
|||
} |
|||
@ -0,0 +1,75 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.common.exception.*; |
|||
import com.mydreamjob.profile.storage.*; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import lombok.RequiredArgsConstructor; |
|||
import javax.imageio.*; |
|||
import javax.imageio.stream.MemoryCacheImageInputStream; |
|||
import java.awt.image.BufferedImage; |
|||
import java.io.*; |
|||
import java.nio.charset.*; |
|||
import java.util.*; |
|||
|
|||
@Component @RequiredArgsConstructor |
|||
public class ChatAttachmentValidator { |
|||
private final ResumeFileValidator documents; |
|||
public static final long MAX_BYTES=10L*1024*1024; |
|||
public record Validated(String name,String type,byte[] content,byte[] thumbnail,Integer width,Integer height) { |
|||
@Override public String toString(){return "ValidatedAttachment[redacted]";} |
|||
} |
|||
public Validated validate(String kind,MultipartFile file) { |
|||
if (kind==null || !Set.of("IMAGE","FILE").contains(kind) || file==null || file.isEmpty() || file.getSize()>MAX_BYTES) bad(); |
|||
ResumeFilenameSanitizer.validateUploadName(file.getOriginalFilename()); |
|||
String name=file.getOriginalFilename().trim(); |
|||
String ext=name.substring(Math.max(0,name.lastIndexOf('.'))).toLowerCase(Locale.ROOT); |
|||
try { |
|||
byte[] raw; |
|||
try(InputStream input=file.getInputStream()){raw=input.readNBytes((int)MAX_BYTES+1);} |
|||
if(raw.length==0 || raw.length>MAX_BYTES) bad(); |
|||
if("IMAGE".equals(kind)) { |
|||
String mime=Map.of(".jpg","image/jpeg",".jpeg","image/jpeg",".png","image/png").get(ext); |
|||
if(mime==null || !mime.equals(file.getContentType())) bad(); |
|||
try(var input=new MemoryCacheImageInputStream(new ByteArrayInputStream(raw))) { |
|||
var readers=ImageIO.getImageReaders(input); |
|||
if(!readers.hasNext()) bad(); |
|||
ImageReader reader=readers.next(); |
|||
try { |
|||
reader.setInput(input,true,true); |
|||
String format=reader.getFormatName(); |
|||
if(!("image/png".equals(mime)?"png".equalsIgnoreCase(format):"jpeg".equalsIgnoreCase(format))) bad(); |
|||
int w=reader.getWidth(0),h=reader.getHeight(0); |
|||
if(w<=0 || h<=0 || w>8192 || h>8192 || (long)w*h>16_000_000) bad(); |
|||
BufferedImage decoded=reader.read(0); |
|||
// 重新编码,去除 EXIF/附加数据;缩略图最长边 320,保持比例。
|
|||
String output="image/png".equals(mime)?"png":"jpeg"; |
|||
byte[] clean=encode(decoded,output); |
|||
if(clean.length>MAX_BYTES) bad(); |
|||
double ratio=Math.min(1.0,320.0/Math.max(w,h)); |
|||
var thumb=new BufferedImage(Math.max(1,(int)Math.round(w*ratio)),Math.max(1,(int)Math.round(h*ratio)),BufferedImage.TYPE_INT_RGB); |
|||
var graphics=thumb.createGraphics(); |
|||
try {graphics.setColor(java.awt.Color.WHITE);graphics.fillRect(0,0,thumb.getWidth(),thumb.getHeight()); |
|||
graphics.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION,java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR); |
|||
graphics.drawImage(decoded,0,0,thumb.getWidth(),thumb.getHeight(),null); |
|||
} finally {graphics.dispose();} |
|||
return new Validated(name,mime,clean,encode(thumb,"jpeg"),w,h); |
|||
} finally {reader.dispose();} |
|||
} |
|||
} |
|||
if(Set.of(".pdf",".doc",".docx").contains(ext)) documents.validate(file); |
|||
else if(".txt".equals(ext) && "text/plain".equals(file.getContentType())) { |
|||
String text=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) |
|||
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(raw)).toString(); |
|||
if(text.codePoints().anyMatch(c->Character.isISOControl(c) && c!='\n' && c!='\r' && c!='\t')) bad(); |
|||
} else bad(); |
|||
return new Validated(name,file.getContentType(),raw,null,null,null); |
|||
} catch(IOException | IllegalArgumentException e) {throw new BusinessException(ErrorCode.BAD_REQUEST,"invalid attachment");} |
|||
} |
|||
private byte[] encode(BufferedImage image,String format) throws IOException { |
|||
var bytes=new ByteArrayOutputStream(); |
|||
if(!ImageIO.write(image,format,bytes)) bad(); |
|||
return bytes.toByteArray(); |
|||
} |
|||
private static void bad(){throw new BusinessException(ErrorCode.BAD_REQUEST,"invalid attachment type, size or content");} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import jakarta.servlet.MultipartConfigElement; |
|||
import org.springframework.context.annotation.*; |
|||
import org.springframework.boot.autoconfigure.web.servlet.MultipartProperties; |
|||
|
|||
/** Spring 默认单文件1MB小于业务10MiB,统一设定Servlet边界;业务验证器仍独立限制。 */ |
|||
@Configuration |
|||
@org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication(type=org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type.SERVLET) |
|||
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(prefix="spring.servlet.multipart",name="enabled",matchIfMissing=true) |
|||
@org.springframework.boot.context.properties.EnableConfigurationProperties(MultipartProperties.class) |
|||
public class ChatMultipartConfiguration { |
|||
@Bean |
|||
MultipartConfigElement multipartConfigElement(MultipartProperties properties) { |
|||
return new MultipartConfigElement(properties.getLocation(),Math.max(properties.getMaxFileSize().toBytes(),10L*1024*1024), |
|||
Math.max(properties.getMaxRequestSize().toBytes(),11L*1024*1024),(int)properties.getFileSizeThreshold().toBytes()); |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
ALTER TABLE messages ADD COLUMN attachment_filename VARCHAR(255) NULL; |
|||
ALTER TABLE messages ADD COLUMN attachment_content_type VARCHAR(120) NULL; |
|||
ALTER TABLE messages ADD COLUMN attachment_size BIGINT NULL; |
|||
ALTER TABLE messages ADD COLUMN attachment_sha256 VARCHAR(64) NULL; |
|||
ALTER TABLE messages ADD COLUMN attachment_width INT NULL; |
|||
ALTER TABLE messages ADD COLUMN attachment_height INT NULL; |
|||
|
|||
CREATE TABLE chat_attachments ( |
|||
message_id BIGINT NOT NULL PRIMARY KEY, |
|||
content LONGBLOB NOT NULL, |
|||
thumbnail LONGBLOB NULL, |
|||
CONSTRAINT fk_chat_attachment_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
|||
@ -0,0 +1,153 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.fasterxml.jackson.databind.*; |
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import org.junit.jupiter.api.*; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import java.time.Clock; |
|||
import java.util.*; |
|||
import java.util.concurrent.*; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
import java.io.*; |
|||
import javax.imageio.ImageIO; |
|||
import java.awt.image.BufferedImage; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
@SpringBootTest(properties="spring.datasource.url=jdbc:h2:mem:integrationdb_chat_files;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
@AutoConfigureMockMvc @ActiveProfiles("integration-test") |
|||
class ChatAttachmentIntegrationTest { |
|||
@Autowired MockMvc mvc; @Autowired ObjectMapper mapper; |
|||
@Autowired UserRepository users; @Autowired ConversationRepository conversations; |
|||
@Autowired ConversationMemberRepository members; @Autowired MessageRepository messages; |
|||
@Autowired ChatAttachmentRepository attachments; @Autowired AuthSessionService sessions; |
|||
@Autowired Clock clock; |
|||
@Autowired ChatAttachmentService service; |
|||
@Autowired org.springframework.transaction.PlatformTransactionManager transactions; |
|||
@MockBean RedisTemplate<String,Object> redis; |
|||
static final AtomicLong SEQ=new AtomicLong(75000000); |
|||
User a,b,other;String at,bt,ot;long cid; |
|||
User user(){return users.save(User.builder().phone("166"+SEQ.incrementAndGet()).nickname("附件测试") |
|||
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build());} |
|||
String token(User u){return sessions.createSession(u,clock.instant(),new AuditContext()).accessToken();} |
|||
@BeforeEach void setup(){ |
|||
a=user();b=user();other=user();at=token(a);bt=token(b);ot=token(other); |
|||
cid=conversations.save(Conversation.builder().title("附件隔离测试").type("DIRECT").createdBy(a.getId()).build()).getId(); |
|||
for(User u:List.of(a,b))members.save(ConversationMember.builder().conversationId(cid).userId(u.getId()).build()); |
|||
} |
|||
MockMultipartFile text(String value){return new MockMultipartFile("file","说明.txt","text/plain",value.getBytes(java.nio.charset.StandardCharsets.UTF_8));} |
|||
MockMultipartFile image() throws Exception { |
|||
var out=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(640,480,BufferedImage.TYPE_INT_RGB),"png",out); |
|||
return new MockMultipartFile("file","图片.png","image/png",out.toByteArray()); |
|||
} |
|||
JsonNode send(MockMultipartFile file,String kind,String id) throws Exception { |
|||
var response=mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(file) |
|||
.param("kind",kind).param("clientMessageId",id).header("Authorization","Bearer "+at)) |
|||
.andExpect(status().isOk()).andReturn().getResponse(); |
|||
return mapper.readTree(response.getContentAsByteArray()).path("data"); |
|||
} |
|||
String attachment(long id){return "/conversations/"+cid+"/messages/"+id+"/attachment";} |
|||
@Test void fileRoundTripListsAndReplayContainTypedMetadata() throws Exception { |
|||
var sent=send(text("hello中文"),"FILE","file-1");long id=sent.path("id").asLong(); |
|||
assertEquals("FILE",sent.path("messageType").asText());assertEquals("说明.txt",sent.path("attachment").path("fileName").asText()); |
|||
mvc.perform(get(attachment(id)).header("Authorization","Bearer "+bt)).andExpect(status().isOk()) |
|||
.andExpect(header().string("Cache-Control","no-store")).andExpect(header().string("X-Content-Type-Options","nosniff")) |
|||
.andExpect(content().bytes("hello中文".getBytes(java.nio.charset.StandardCharsets.UTF_8))); |
|||
mvc.perform(get("/conversations/"+cid+"/messages").header("Authorization","Bearer "+bt)) |
|||
.andExpect(jsonPath("$.data.items[0].attachment.downloadPath").value(attachment(id))); |
|||
mvc.perform(get("/conversations/"+cid+"/events").header("Authorization","Bearer "+bt)) |
|||
.andExpect(jsonPath("$.data.items[0].type").value("MESSAGE_CREATED")) |
|||
.andExpect(jsonPath("$.data.items[0].payload.messageType").value("FILE")); |
|||
mvc.perform(get(attachment(id)+"/preview").header("Authorization","Bearer "+bt)).andExpect(status().isBadRequest()); |
|||
} |
|||
@Test void imageHasOriginalAndThumbnailAndDimensions() throws Exception { |
|||
var sent=send(image(),"IMAGE","image-1");long id=sent.path("id").asLong(); |
|||
assertEquals(640,sent.path("attachment").path("width").asInt()); |
|||
var thumb=mvc.perform(get(attachment(id)+"/thumbnail").header("Authorization","Bearer "+bt)).andExpect(status().isOk()) |
|||
.andExpect(content().contentType("image/jpeg")).andReturn().getResponse().getContentAsByteArray(); |
|||
var decoded=ImageIO.read(new ByteArrayInputStream(thumb));assertEquals(320,decoded.getWidth());assertEquals(240,decoded.getHeight()); |
|||
mvc.perform(get(attachment(id)+"/preview").header("Authorization","Bearer "+bt)).andExpect(status().isOk()) |
|||
.andExpect(content().contentType("image/png")); |
|||
} |
|||
@Test void sameKeyRetryAndConcurrentRetryNeverDuplicate() throws Exception { |
|||
var first=send(text("retry"),"FILE","same"); |
|||
var pool=Executors.newFixedThreadPool(2); |
|||
try { |
|||
var one=pool.submit(()->send(text("retry"),"FILE","same"));var two=pool.submit(()->send(text("retry"),"FILE","same")); |
|||
assertEquals(first.path("id"),one.get(20,TimeUnit.SECONDS).path("id"));assertEquals(first.path("id"),two.get(20,TimeUnit.SECONDS).path("id")); |
|||
} finally {pool.shutdownNow();} |
|||
assertEquals(1,messages.findByConversationIdOrderByCreatedAtDesc(cid,org.springframework.data.domain.PageRequest.of(0,10)).getTotalElements()); |
|||
mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(text("changed")).param("kind","FILE").param("clientMessageId","same") |
|||
.header("Authorization","Bearer "+at)).andExpect(status().isBadRequest()); |
|||
mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+at).contentType("application/json") |
|||
.content("{\"conversationId\":"+cid+",\"content\":\"[文件]\",\"clientMessageId\":\"same\"}" )).andExpect(status().isBadRequest()); |
|||
} |
|||
@Test void thirdPartyAnonymousAndWrongConversationCannotRead() throws Exception { |
|||
long id=send(text("secret"),"FILE","private").path("id").asLong(); |
|||
mvc.perform(get(attachment(id))).andExpect(status().isUnauthorized()); |
|||
mvc.perform(get(attachment(id)).header("Authorization","Bearer "+ot)).andExpect(status().isForbidden()); |
|||
mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(text("x")).param("kind","FILE").param("clientMessageId","x") |
|||
.header("Authorization","Bearer "+ot)).andExpect(status().isForbidden()); |
|||
} |
|||
@Test void blockingAndDisabledTokensDenyFiles() throws Exception { |
|||
long id=send(text("private"),"FILE","block").path("id").asLong(); |
|||
mvc.perform(put("/blocks/"+a.getId()).header("Authorization","Bearer "+bt)).andExpect(status().isOk()); |
|||
mvc.perform(get(attachment(id)).header("Authorization","Bearer "+bt)).andExpect(status().isForbidden()); |
|||
mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(image()).param("kind","IMAGE").param("clientMessageId","blocked") |
|||
.header("Authorization","Bearer "+at)).andExpect(status().isForbidden()); |
|||
a.setActive(false);users.saveAndFlush(a); |
|||
mvc.perform(get(attachment(id)).header("Authorization","Bearer "+at)).andExpect(status().isUnauthorized()); |
|||
} |
|||
@Test void recallPurgesBytesAndReplaysUnavailableMetadata() throws Exception { |
|||
long id=send(image(),"IMAGE","recall").path("id").asLong(); |
|||
mvc.perform(post("/conversations/"+cid+"/messages/"+id+"/recall").header("Authorization","Bearer "+at)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.attachment.available").value(false)); |
|||
assertFalse(attachments.existsById(id)); |
|||
mvc.perform(get(attachment(id)).header("Authorization","Bearer "+bt)).andExpect(status().isNotFound()); |
|||
assertNull(messages.findById(id).orElseThrow().getAttachmentFilename()); |
|||
} |
|||
@Test void invalidContentAndKindAreAtomic() throws Exception { |
|||
for(var file:List.of(new MockMultipartFile("file","x.png","image/png",new byte[]{1,2}),new MockMultipartFile("file","../x.png","image/png",new byte[]{1}))) { |
|||
mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(file).param("kind","IMAGE").param("clientMessageId","invalid")) |
|||
.andExpect(status().isUnauthorized()); |
|||
mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(file).param("kind","IMAGE").param("clientMessageId","invalid") |
|||
.header("Authorization","Bearer "+at)).andExpect(status().isBadRequest()); |
|||
} |
|||
assertEquals(0,messages.findByConversationIdOrderByCreatedAtDesc(cid,org.springframework.data.domain.PageRequest.of(0,1)).getTotalElements()); |
|||
} |
|||
@Test void quotaDenialIsAtomicAndWrongConversationIsDenied() throws Exception { |
|||
org.springframework.test.util.ReflectionTestUtils.setField(service,"maxConversationBytes",1L); |
|||
try { |
|||
mvc.perform(multipart("/conversations/"+cid+"/attachment-messages").file(text("too-large-for-quota")) |
|||
.param("kind","FILE").param("clientMessageId","quota").header("Authorization","Bearer "+at)).andExpect(status().isBadRequest()); |
|||
} finally {org.springframework.test.util.ReflectionTestUtils.setField(service,"maxConversationBytes",104857600L);} |
|||
assertEquals(0,messages.findByConversationIdOrderByCreatedAtDesc(cid,org.springframework.data.domain.PageRequest.of(0,1)).getTotalElements()); |
|||
long id=send(text("ok"),"FILE","cross").path("id").asLong(); |
|||
long another=conversations.save(Conversation.builder().title("other").type("DIRECT").createdBy(a.getId()).build()).getId(); |
|||
members.save(ConversationMember.builder().conversationId(another).userId(a.getId()).build()); |
|||
mvc.perform(get("/conversations/"+another+"/messages/"+id+"/attachment").header("Authorization","Bearer "+at)).andExpect(status().isForbidden()); |
|||
} |
|||
@Test void runtimeContractCanBeExported() throws Exception { |
|||
String raw=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8); |
|||
assertTrue(mapper.readTree(raw).path("paths").has("/conversations/{id}/attachment-messages")); |
|||
String output=System.getProperty("chat.attachment.runtimeContract");if(output!=null)java.nio.file.Files.writeString(java.nio.file.Path.of(output),raw); |
|||
} |
|||
@Test void deletedAccountCleanupRemovesBinaryAndPersonalFilename() throws Exception { |
|||
long id=send(text("private"),"FILE","delete-owner").path("id").asLong(); |
|||
new org.springframework.transaction.support.TransactionTemplate(transactions).executeWithoutResult(tx->{ |
|||
attachments.purgeForSender(a.getId());attachments.clearMetadataForSender(a.getId()); |
|||
}); |
|||
assertFalse(attachments.existsById(id));assertNull(messages.findById(id).orElseThrow().getAttachmentFilename()); |
|||
mvc.perform(get("/conversations/"+cid+"/messages").header("Authorization","Bearer "+bt)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items[0].attachment.available").value(false)); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import org.springframework.test.context.*; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.*; |
|||
|
|||
@Testcontainers |
|||
@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) |
|||
class ChatAttachmentMySqlIntegrationTest extends ChatAttachmentIntegrationTest { |
|||
@Container static final MySQLContainer<?> MYSQL=new MySQLContainer<>(System.getProperty("attachment.mysql.image","mysql:8.4")) |
|||
.withDatabaseName("chat_files").withUsername("chatfiles").withPassword("synthetic-only") |
|||
.withCommand("--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci","--max-allowed-packet=32M") |
|||
.withUrlParam("characterEncoding","UTF-8").withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024)); |
|||
@DynamicPropertySource static void properties(DynamicPropertyRegistry r) { |
|||
r.add("spring.datasource.url",MYSQL::getJdbcUrl);r.add("spring.datasource.username",MYSQL::getUsername);r.add("spring.datasource.password",MYSQL::getPassword); |
|||
r.add("spring.datasource.driver-class-name",()->"com.mysql.cj.jdbc.Driver"); |
|||
r.add("spring.jpa.properties.hibernate.dialect",()->"org.hibernate.dialect.MySQLDialect"); |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.profile.storage.ResumeFileValidator; |
|||
import org.junit.jupiter.api.*; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.ValueSource; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
class ChatAttachmentValidatorTest { |
|||
ChatAttachmentValidator validator=new ChatAttachmentValidator(new ResumeFileValidator()); |
|||
@ParameterizedTest @ValueSource(strings={"a.exe","a.html","a.svg","a.zip","a.xlsx","a.jpg"}) |
|||
void unsupportedFileExtensionsRejected(String name) { |
|||
assertThrows(BusinessException.class,()->validator.validate("FILE",new MockMultipartFile("file",name,"application/octet-stream",new byte[]{1}))); |
|||
} |
|||
@Test void oversizedAndEmptyRejectedBeforeDecode() { |
|||
assertThrows(BusinessException.class,()->validator.validate("FILE",new MockMultipartFile("file","a.txt","text/plain",new byte[10*1024*1024+1]))); |
|||
assertThrows(BusinessException.class,()->validator.validate("FILE",new MockMultipartFile("file","a.txt","text/plain",new byte[0]))); |
|||
} |
|||
@Test void invalidUtf8OrNulRejected() { |
|||
for(var bytes:new byte[][]{new byte[]{(byte)0xff},new byte[]{65,0,66}}) |
|||
assertThrows(BusinessException.class,()->validator.validate("FILE",new MockMultipartFile("file","a.txt","text/plain",bytes))); |
|||
} |
|||
@Test void fakeImageAndMimeMismatchRejected() { |
|||
assertThrows(BusinessException.class,()->validator.validate("IMAGE",new MockMultipartFile("file","a.png","image/png",new byte[]{1,2,3}))); |
|||
assertThrows(BusinessException.class,()->validator.validate("IMAGE",new MockMultipartFile("file","a.png","image/jpeg",new byte[]{1,2,3}))); |
|||
} |
|||
@Test void oversizedPngDimensionsRejectedBeforeAllocatingPixels() throws Exception { |
|||
var out=new java.io.ByteArrayOutputStream(); |
|||
javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(9000,1,java.awt.image.BufferedImage.TYPE_INT_RGB),"png",out); |
|||
assertThrows(BusinessException.class,()->validator.validate("IMAGE",new MockMultipartFile("file","a.png","image/png",out.toByteArray()))); |
|||
} |
|||
@Test void jpegReencodedAndThumbnailIsReadable() throws Exception { |
|||
var out=new java.io.ByteArrayOutputStream();javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(800,600,java.awt.image.BufferedImage.TYPE_INT_RGB),"jpeg",out); |
|||
var result=validator.validate("IMAGE",new MockMultipartFile("file","a.jpg","image/jpeg",out.toByteArray())); |
|||
assertEquals("image/jpeg",result.type());assertEquals(800,result.width()); |
|||
assertEquals(320,javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(result.thumbnail())).getWidth()); |
|||
assertNotNull(javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(result.content()))); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue