Browse Source

feat(chat): add authenticated image and file attachments

fix/mysql57-module-migrations-20261009
Zhang Shun 5 days ago
parent
commit
af80abddf7
  1. 119
      docs/chat-attachments-frontend-handover-20261023.md
  2. 237
      docs/openapi-rc.json
  3. 23
      docs/verify_openapi.py
  4. 3
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  5. 13
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachment.java
  6. 49
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentController.java
  7. 20
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentRepository.java
  8. 81
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentService.java
  9. 75
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentValidator.java
  10. 18
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatMultipartConfiguration.java
  11. 13
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java
  12. 3
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java
  13. 14
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java
  14. 27
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  15. 5
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java
  16. 13
      mydreamjob-server/src/main/resources/db/migration/V62__chat_attachments.sql
  17. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  18. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  19. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  20. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java
  21. 2
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java
  22. 153
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentIntegrationTest.java
  23. 19
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentMySqlIntegrationTest.java
  24. 41
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentValidatorTest.java

119
docs/chat-attachments-frontend-handover-20261023.md

@ -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)。首次版本采用限额与撤回释放空间,尚无长期附件管理/任意时间删除接口。

237
docs/openapi-rc.json

@ -12324,6 +12324,202 @@
}
}
}
},
"/conversations/{id}/attachment-messages": {
"post": {
"tags": [
"消息"
],
"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。",
"operationId": "send_1",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "kind",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "clientMessageId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"format": "binary"
}
},
"required": [
"file"
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseMessageResponse"
}
}
}
}
}
}
},
"/conversations/{conversationId}/messages/{messageId}/attachment": {
"get": {
"tags": [
"消息"
],
"summary": "鉴权下载消息附件",
"description": "每次重新校验成员、企业认证、有效投递和屏蔽,撤回或发送者禁用后不可下载;返回 no-store/nosniff/attachment,不支持公开URL或Token查询参数。",
"operationId": "download_2",
"parameters": [
{
"name": "conversationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "messageId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
}
}
}
},
"/conversations/{conversationId}/messages/{messageId}/attachment/thumbnail": {
"get": {
"tags": [
"消息"
],
"summary": "鉴权读取图片缩略图",
"description": "仅IMAGE,最长边320的JPEG,鉴权与下载相同。",
"operationId": "thumbnail",
"parameters": [
{
"name": "conversationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "messageId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
}
}
}
},
"/conversations/{conversationId}/messages/{messageId}/attachment/preview": {
"get": {
"tags": [
"消息"
],
"summary": "鉴权读取图片预览",
"description": "仅IMAGE,返回已重新编码去除元数据的图片;鉴权与下载相同。",
"operationId": "preview",
"parameters": [
{
"name": "conversationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "messageId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
}
}
}
}
},
"components": {
@ -18993,6 +19189,9 @@
},
"resume": {
"$ref": "#/components/schemas/ResumeCard"
},
"attachment": {
"$ref": "#/components/schemas/Attachment"
}
}
},
@ -24761,6 +24960,44 @@
"format": "date-time"
}
}
},
"Attachment": {
"type": "object",
"properties": {
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"size": {
"type": "integer",
"format": "int64"
},
"sha256": {
"type": "string"
},
"width": {
"type": "integer",
"format": "int32"
},
"height": {
"type": "integer",
"format": "int32"
},
"available": {
"type": "boolean"
},
"downloadPath": {
"type": "string"
},
"previewPath": {
"type": "string"
},
"thumbnailPath": {
"type": "string"
}
}
}
},
"securitySchemes": {

23
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 207, f"Expected 207 paths, got {len(d['paths'])}"
assert len(d['paths']) == 211, f"Expected 211 paths, got {len(d['paths'])}"
paths = d['paths']
@ -719,7 +719,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 255, f"Expected 255 operations, got {operations}"
assert operations == 259, f"Expected 259 operations, got {operations}"
assert 'post' in paths['/conversations/{id}/resume-messages']
assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys()
assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys()
@ -991,5 +991,24 @@ assert _accept_view['properties']['companyAcceptedAt']['format'] == 'date-time'
assert 'communicationAllowed' in paths['/applications/{id}/match']['get']['description']
assert '无需双向评分达标' in paths['/applications/{id}/conversation']['post']['description']
print(' Company acceptance unlocks communication; typed response and capability contract verified')
_attachment_paths = {
'/conversations/{id}/attachment-messages': 'post',
'/conversations/{conversationId}/messages/{messageId}/attachment': 'get',
'/conversations/{conversationId}/messages/{messageId}/attachment/preview': 'get',
'/conversations/{conversationId}/messages/{messageId}/attachment/thumbnail': 'get',
}
for _path, _method in _attachment_paths.items():
assert _method in paths[_path]
_upload = paths['/conversations/{id}/attachment-messages']['post']
_multipart = _upload['requestBody']['content']['multipart/form-data']['schema']
if '$ref' in _multipart:
_multipart = resolve_local_ref(d, _multipart['$ref'])
assert 'file' in _multipart['properties']
assert {'kind', 'clientMessageId'} <= {p['name'] for p in _upload['parameters']}
_message = d['components']['schemas']['MessageResponse']
_attachment = resolve_local_ref(d, _message['properties']['attachment']['$ref'])
assert set(_attachment['properties']) == {'fileName','contentType','size','sha256','width','height','available','downloadPath','previewPath','thumbnailPath'}
assert _attachment['properties']['available']['type'] == 'boolean'
print(' Chat image/file multipart, authenticated binary reads and typed metadata verified')
print("Valid JSON: True")
print("All checks passed!")

3
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java

@ -40,6 +40,7 @@ public class AccountDeletionCompletionWorker {
private final AuthSessionService authSessionService;
private final Clock clock;
private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle;
private final com.mydreamjob.messaging.ChatAttachmentRepository chatAttachments;
private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository;
/**
@ -74,6 +75,8 @@ public class AccountDeletionCompletionWorker {
dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
chatResumeLifecycle.purgeForUser(rec.getUserId());
chatAttachments.purgeForSender(rec.getUserId());
chatAttachments.clearMetadataForSender(rec.getUserId());
onlineResumeRepository.deleteById(rec.getUserId());
// 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",

13
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachment.java

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

49
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentController.java

@ -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());
}
}

20
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentRepository.java

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

81
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentService.java

@ -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");}
}

75
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentValidator.java

@ -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");}
}

18
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatMultipartConfiguration.java

@ -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());
}
}

13
mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java

@ -51,6 +51,19 @@ public class Message {
@Column(name = "resume_revoked", nullable = false)
private Boolean resumeRevoked = false;
@Column(name = "attachment_filename", length = 255)
private String attachmentFilename;
@Column(name = "attachment_content_type", length = 120)
private String attachmentContentType;
@Column(name = "attachment_size")
private Long attachmentSize;
@Column(name = "attachment_sha256", length = 64)
private String attachmentSha256;
@Column(name = "attachment_width")
private Integer attachmentWidth;
@Column(name = "attachment_height")
private Integer attachmentHeight;
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "SENT";

3
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java

@ -16,6 +16,9 @@ import java.util.List;
@Repository
public interface MessageRepository extends JpaRepository<Message, Long>, MessageUnreadQueries {
@Query("select coalesce(sum(m.attachmentSize),0) from Message m where m.conversationId = :id and m.status = 'SENT'")
long activeAttachmentBytes(@Param("id") Long conversationId);
List<Message> findBySenderUserIdAndMessageType(Long senderUserId, String messageType);
@Query("select coalesce(sum(m.resumeSize),0) from Message m where m.senderUserId = :userId "

14
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java

@ -30,6 +30,20 @@ public class MessageViewMapper {
message.getResumeSize(), available, available ? "/conversations/" + message.getConversationId()
+ "/messages/" + message.getId() + "/resume" : null));
}
if ("IMAGE".equals(message.getMessageType()) || "FILE".equals(message.getMessageType())) {
boolean available = !recalled && message.getAttachmentSize() != null;
String path = "/conversations/" + message.getConversationId() + "/messages/" + message.getId() + "/attachment";
boolean image = "IMAGE".equals(message.getMessageType());
builder.messageType(message.getMessageType()).attachment(new MessageResponse.Attachment(
available ? message.getAttachmentFilename() : null,
available ? message.getAttachmentContentType() : null,
available ? message.getAttachmentSize() : null,
available ? message.getAttachmentSha256() : null,
available ? message.getAttachmentWidth() : null,
available ? message.getAttachmentHeight() : null,
available, available ? path : null, available && image ? path + "/preview" : null,
available && image ? path + "/thumbnail" : null));
}
if (talentInvitation) {
builder.senderScope(Objects.equals(message.getSenderUserId(), currentUserId)
? "CURRENT_USER" : "OTHER_PARTICIPANT");

27
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -52,6 +52,7 @@ public class MessagingService {
private final ObjectMapper objectMapper;
private final com.mydreamjob.messaging.realtime.ChatEventService chatEvents;
private final ChatResumeSnapshotRepository resumeSnapshots;
private final ChatAttachmentRepository attachments;
private final ConversationSummaryService conversationSummaries;
private static final int MAX_PAGE_SIZE = 50;
@ -231,8 +232,8 @@ public class MessagingService {
var existing = messageRepository.findByConversationIdAndSenderUserIdAndClientMessageId(
req.getConversationId(), userId, req.getClientMessageId());
if (existing.isPresent()) {
if ("RESUME".equals(existing.get().getMessageType()))
throw new BusinessException(ErrorCode.BAD_REQUEST, "clientMessageId belongs to a resume message");
if (!"TEXT".equals(existing.get().getMessageType()))
throw new BusinessException(ErrorCode.BAD_REQUEST, "clientMessageId belongs to a non-text message");
if (!Objects.equals(existing.get().getRequestContentHash(), contentHash(req.getContent())))
throw new BusinessException(ErrorCode.BAD_REQUEST, "clientMessageId already used");
return toView(existing.get(), userId);
@ -283,6 +284,20 @@ public class MessagingService {
.build();
}
/** 附件首次发送与每次读取都重新检查当前会话权限,不能靠旧下载地址绕过屏蔽/撤回。 */
public void assertAttachmentAccess(Long conversationId, Long userId) {
getConversation(conversationId, userId);
if (!"DIRECT".equals(requireConversation(conversationId).getType()))
throw new BusinessException(ErrorCode.FORBIDDEN, "attachments require a DIRECT conversation");
for (ConversationMember member : memberRepository.findByConversationId(conversationId)) {
if (!member.getUserId().equals(userId)) {
assertNotBlockedBetween(userId, member.getUserId());
assertWithdrawnPairUnlocked(userId, member.getUserId());
assertCompanyCandidatePairHasMatchedApplication(userId, member.getUserId());
}
}
}
@Transactional
public void markRead(Long conversationId, Long userId) {
requireVerifiedIfCompany(userId);
@ -371,6 +386,7 @@ public class MessagingService {
}
if ("RESUME".equals(message.getMessageType())) resumeSnapshots.purge(messageId);
if ("IMAGE".equals(message.getMessageType()) || "FILE".equals(message.getMessageType())) attachments.purge(messageId);
auditService.log(currentUserId, "MESSAGE_RECALLED", "MESSAGE", messageId,
recallAuditDetails(messageId, conversationId, conversation.getType(), auditContext),
@ -381,6 +397,12 @@ public class MessagingService {
recalled.setResumeContentType(null); recalled.setResumeSize(null);
recalled = messageRepository.saveAndFlush(recalled);
}
if ("IMAGE".equals(recalled.getMessageType()) || "FILE".equals(recalled.getMessageType())) {
recalled.setAttachmentFilename(null); recalled.setAttachmentContentType(null);
recalled.setAttachmentSize(null); recalled.setAttachmentSha256(null);
recalled.setAttachmentWidth(null); recalled.setAttachmentHeight(null);
recalled = messageRepository.saveAndFlush(recalled);
}
chatEvents.record("MESSAGE_RECALLED", conversationId, currentUserId, messageId);
return toMessageResponse(recalled, currentUserId, conversation);
}
@ -473,6 +495,7 @@ public class MessagingService {
v.put("createdAt", response.getCreatedAt());
if (response.getMessageType() != null) v.put("messageType", response.getMessageType());
if (response.getResume() != null) v.put("resume", response.getResume());
if (response.getAttachment() != null) v.put("attachment", response.getAttachment());
if (response.getIsRead() != null) v.put("isRead", response.getIsRead());
return v;
}

5
mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java

@ -32,6 +32,11 @@ public class MessageResponse {
private Boolean isRead;
private String messageType;
private ResumeCard resume;
private Attachment attachment;
public record Attachment(String fileName, String contentType, Long size, String sha256,
Integer width, Integer height, boolean available,
String downloadPath, String previewPath, String thumbnailPath) {}
/** 只含受鉴权接口相对路径,绝不含磁盘路径、学生 ID 或公开文件 URL。 */
public record ResumeCard(String fileName, String contentType, Long size,

13
mydreamjob-server/src/main/resources/db/migration/V62__chat_attachments.sql

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

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("194 paths 包含首次密码设置、聊天补拉、简历消息、在线简历、企业地点搜索及接受投递").isEqualTo(194);
assertThat(opCount - moduleOperations).as("237 operations 包含首次密码设置、聊天补拉、简历消息、在线简历、企业地点搜索及接受投递").isEqualTo(237);
assertThat(pathCount - modulePaths).as("198 paths 包含图片及文件消息").isEqualTo(198);
assertThat(opCount - moduleOperations).as("241 operations 包含图片及文件消息").isEqualTo(241);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
}
}
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息、8项在线简历、企业地点搜索及接受投递操作均受保护").isEqualTo(211);
assertThat(denied - moduleDenied).as("既有身份门禁及图片文件附件操作均受保护").isEqualTo(215);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
}

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

@ -148,8 +148,8 @@ class LxqModulesMySql57IntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(255).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(207);
assertThat(operations(runtime)).hasSize(259).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(211);
assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) {

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(255).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(207);
assertThat(operations(runtime)).hasSize(259).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(211);
assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) {

2
mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java

@ -147,7 +147,7 @@ class JobMapLocationIntegrationTest {
@Test void runtimeContractContainsLocationAndSearchRoute() throws Exception {
String raw=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
var spec=mapper.readTree(raw);assertEquals(207,spec.path("paths").size());
var spec=mapper.readTree(raw);assertEquals(211,spec.path("paths").size());
assertTrue(spec.path("paths").path("/companies/me/job-location-suggestions").has("get"));
var search=spec.path("paths").path("/companies/me/job-location-suggestions").path("get");
assertEquals("searchJobLocationSuggestions",search.path("operationId").asText());

153
mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentIntegrationTest.java

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

19
mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentMySqlIntegrationTest.java

@ -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");
}
}

41
mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentValidatorTest.java

@ -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…
Cancel
Save