diff --git a/docs/chat-attachments-frontend-handover-20261023.md b/docs/chat-attachments-frontend-handover-20261023.md new file mode 100644 index 0000000..4775822 --- /dev/null +++ b/docs/chat-attachments-frontend-handover-20261023.md @@ -0,0 +1,119 @@ +# 聊天图片、文件消息接入 + +适用:企业与求职者的 DIRECT 会话。本文对应本批后端候选源码;尚未提交、推送或部署,线上 #94 还没有这些新接口。联调前确认本批已部署。 + +基础路径沿用项目 API_BASE_URL `/api/v1`。全部接口均要求 `Authorization: Bearer `,不能把 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)。首次版本采用限额与撤回释放空间,尚无长期附件管理/任意时间删除接口。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 2e2703f..d75a588 100644 --- a/docs/openapi-rc.json +++ b/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": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 9125e84..91856f1 100644 --- a/docs/verify_openapi.py +++ b/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!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 8a8b8af..d6c2372 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/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", diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachment.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachment.java new file mode 100644 index 0000000..8cd30d4 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentController.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentController.java new file mode 100644 index 0000000..38ed2e8 --- /dev/null +++ b/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 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 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 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 thumbnail(@PathVariable Long conversationId,@PathVariable Long messageId) { + return response(service.download(conversationId,messageId,current.requireUserId(),"thumbnail"),true); + } + private ResponseEntity 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()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentRepository.java new file mode 100644 index 0000000..e9dd09b --- /dev/null +++ b/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 { + @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); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentService.java new file mode 100644 index 0000000..33d2572 --- /dev/null +++ b/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");} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentValidator.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatAttachmentValidator.java new file mode 100644 index 0000000..8e2dfa0 --- /dev/null +++ b/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");} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatMultipartConfiguration.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatMultipartConfiguration.java new file mode 100644 index 0000000..a343773 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java index 5d032a1..c0a7f5b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java +++ b/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"; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java index beb1609..5e8e9f3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java @@ -16,6 +16,9 @@ import java.util.List; @Repository public interface MessageRepository extends JpaRepository, 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 findBySenderUserIdAndMessageType(Long senderUserId, String messageType); @Query("select coalesce(sum(m.resumeSize),0) from Message m where m.senderUserId = :userId " diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java index 23f7f0c..9d6f759 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java +++ b/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"); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index 2115123..9b8bc8c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java index e0d4713..e65fd86 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java +++ b/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, diff --git a/mydreamjob-server/src/main/resources/db/migration/V62__chat_attachments.sql b/mydreamjob-server/src/main/resources/db/migration/V62__chat_attachments.sql new file mode 100644 index 0000000..6e0032e --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index eb1da33..4efa558 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index b5b0cbc..0e60551 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/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()+"}"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java index 23f4962..9205fbc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/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")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java index 3c83bf5..3ebd4a9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java +++ b/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")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java index 3926170..a032f14 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java +++ b/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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentIntegrationTest.java new file mode 100644 index 0000000..2bc510b --- /dev/null +++ b/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 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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentMySqlIntegrationTest.java new file mode 100644 index 0000000..6b46c64 --- /dev/null +++ b/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"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentValidatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatAttachmentValidatorTest.java new file mode 100644 index 0000000..173967a --- /dev/null +++ b/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()))); + } +}