21 changed files with 1097 additions and 13 deletions
@ -0,0 +1,90 @@ |
|||
# 个人头像/企业 Logo:开发验证记录 |
|||
|
|||
日期:2026-10-27。范围:后端开发与定向回归,不是发布验收报告。 |
|||
|
|||
## 源码与边界 |
|||
|
|||
仓库:C:/Users/15642/Desktop/zhilai,HEAD 为 c6ccab54044c7df580a80cb140391c4d2a69c340。本轮开始时已有注销、评分、配置与文档等未提交修改;不把这些已有修改登记为本轮成果,也没有清理或覆盖它们。 |
|||
|
|||
新增 V63,不修改 V1~V62 历史迁移;无新增依赖。图片存数据库,不增加宿主机目录或容器挂载;不修改 MySQL 的既有 32 MiB 配置。未改前端、未提交、未推送、未触发 Jenkins、未发布。 |
|||
|
|||
## 已完成 |
|||
|
|||
- 上传自己的个人头像、上传自己的企业 Logo、分别删除和二进制读取。 |
|||
- JPEG/PNG 扩展名/MIME/真实格式校验,2 MiB、4096 单边、400 万像素上限,最长边 512 重编码 JPEG,去除原始元数据。 |
|||
- 个人头像与公司 Logo 独立;/auth/me 与现有会话摘要读取用户/公司当前引用。 |
|||
- 私有头像只允许本人或符合当前 DIRECT 沟通授权的成员读取;第三方、拉黑、禁用、未选择身份、邀约隐私均有回归。 |
|||
- 替换后新 URL、旧 URL 404、no-store;事务回滚恢复旧图,用户锁串行化并发替换。 |
|||
- 企业普通资料更新也锁定企业行,避免旧快照覆盖并发 Logo 更新。 |
|||
- 账号注销完成时清除二进制,物理删除时外键级联清理。 |
|||
- 静态契约新增 3 paths / 5 operations,总量 216/266;同步受影响计数与身份门禁测试。 |
|||
|
|||
## 定向测试 |
|||
|
|||
以 Surefire XML/txt 为准;下表是不同测试类的汇总,重复执行不累计: |
|||
|
|||
| 测试类 | tests | failures/errors/skipped | |
|||
| --- | ---: | --- | |
|||
| ProfileImageIntegrationTest | 18 | 0/0/0 | |
|||
| CompanyServiceTest | 11 | 0/0/0 | |
|||
| CompanyControllerMvcTest | 1 | 0/0/0 | |
|||
| CompanySizeDisplayIntegrationTest | 8 | 0/0/0 | |
|||
| CompanyResumeDownloadIntegrationTest | 19 | 0/0/0 | |
|||
| UnifiedRegistrationIntegrationTest | 13 | 0/0/0 | |
|||
| WorkExperienceOptimizationSecurityIntegrationTest | 9 | 0/0/0 | |
|||
| AccountDeletionPersistenceIntegrationTest | 9 | 0/0/0 | |
|||
| IdentityPolicyTest | 27 | 0/0/0 | |
|||
| 合计 | 115 | 0/0/0 | |
|||
|
|||
ProfileImageIntegrationTest 校验整份运行时路径/方法集与静态契约一致,额外校验本批五个 operationId。静态 python docs/verify_openapi.py 与 git diff --check 均通过。 |
|||
|
|||
开发阶段第一次编译发现本批 SecurityRequirement 注解缺少必填 name,已修正;当时未执行测试,不把该次结果计入通过。首次静态 verifier 还发现 /auth 路径精确白名单缺少新头像路径,已同步并补专项断言。修正后重新运行,保留 target 下原始开发日志。 |
|||
|
|||
原始输出: |
|||
|
|||
- mydreamjob-server/target/profile-image-test.log:首次编译失败。 |
|||
- mydreamjob-server/target/profile-image-test-run2.log:初轮头像/企业服务回归。 |
|||
- mydreamjob-server/target/profile-image-regression.log:八类扩大回归,88 项。 |
|||
- mydreamjob-server/target/profile-image-final.log:头像与身份策略复跑,45 项;与上表有重叠,不另累计。 |
|||
- mydreamjob-server/target/profile-image-contract-final.log:加强五个 operationId 比对后的头像复跑。 |
|||
- mydreamjob-server/target/surefire-reports/:各类 XML/txt;后续 Maven 执行可能更新,独立验收时应自行归档。 |
|||
|
|||
## 尚未执行 |
|||
|
|||
- ProfileImageMySqlMigrationTest 的 MySQL 8.4 / 5.7.36 两个 V62→V63 专项:Docker 引擎当前不可连接(dockerDesktopLinuxEngine 管道不存在)。测试代码已增加,未执行,不计入通过。 |
|||
- 完整 clean verify。 |
|||
- 同次构建 JAR HTTP 黑盒、重启持久化。 |
|||
- 前端图片组件、刷新/缓存策略、双设备真机联调。 |
|||
|
|||
H2 集成测试运行了 V63,但不能替代双真实 MySQL。当前结论仅为“后端实现及定向回归完成”,不能登记为完整验收通过或已上线。 |
|||
|
|||
## 本轮精确文件范围 |
|||
|
|||
新增: |
|||
|
|||
1. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImage.java |
|||
2. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageRepository.java |
|||
3. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageValidator.java |
|||
4. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java |
|||
5. mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java |
|||
6. mydreamjob-server/src/main/resources/db/migration/V63__profile_images.sql |
|||
7. mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java |
|||
8. mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageMySqlMigrationTest.java |
|||
9. docs/profile-images-frontend-handover-20261027.md |
|||
10. docs/profile-images-development-verification-20261027.md |
|||
|
|||
修改: |
|||
|
|||
11. mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java |
|||
12. mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java |
|||
13. mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java |
|||
14. mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java |
|||
15. mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java |
|||
16. docs/openapi-rc.json |
|||
17. docs/verify_openapi.py |
|||
18. mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java |
|||
19. mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java |
|||
20. mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java |
|||
21. mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java |
|||
|
|||
此清单是本轮开发范围,不自动授权提交或发布。独立验收应锁定这些文件的哈希,同时保留原工作区既有改动。 |
|||
@ -0,0 +1,100 @@ |
|||
# 个人头像与公司 Logo:前端对接 |
|||
|
|||
日期:2026-10-27。本文面向前端开发与联调人员;本批只改后端和契约,不表示已部署或前端已经接入。 |
|||
|
|||
## 1. 展示规则 |
|||
|
|||
| 场景 | 数据来源 | 无图片时 | |
|||
| --- | --- | --- | |
|||
| 本人的个人资料/本人聊天气泡 | GET /auth/me 的 avatarUrl | 个人昵称首字 | |
|||
| 聊天对方为求职者 | 会话列表/详情的 avatarUrl | contactName 首字 | |
|||
| 聊天对方为企业 | 会话列表/详情的 avatarUrl,来源为公司 logoUrl | 公司名称首字 | |
|||
| 企业主页、岗位关联公司卡片 | 企业资料的 logoUrl | 公司名称首字 | |
|||
|
|||
个人头像属于用户,Logo 属于企业,两个资源独立。企业账号修改个人头像不改变公司 Logo;修改 Logo 不改变 HR 本人头像。不要根据 URL 是否为空自行把个人头像替换成企业 Logo,或反过来。 |
|||
|
|||
本人的头像以 /auth/me 为统一来源,本批不改变 GET /profile/me 的空资料返回 {} 约定。会话摘要实时查询用户/公司引用,不从历史消息中固化头像。TALENT_INVITATION 会话继续隐藏候选人个人头像,不因新上传接口而放开隐私。 |
|||
|
|||
## 2. 上传、删除与读取接口 |
|||
|
|||
以下路径相对于 API 基址 /api/v1。所有写请求使用当前账号 access token,身份来自服务端,不接受客户端 userId/companyId 或文件路径。 |
|||
|
|||
| 方法 | 路径 | 权限/作用 | |
|||
| --- | --- | --- | |
|||
| POST | /auth/me/avatar | 已选择身份且有效的 STUDENT/COMPANY/ADMIN;上传自己的个人头像 | |
|||
| DELETE | /auth/me/avatar | 同上;清空个人头像,重复删除成功 | |
|||
| POST | /companies/me/logo | COMPANY 且已有自己的企业资料;上传企业 Logo | |
|||
| DELETE | /companies/me/logo | 同上;清空企业 Logo,重复删除成功 | |
|||
| GET | /profile-images/{imageId} | 公司 Logo 可匿名读;个人头像须鉴权且仅本人或具备当前 DIRECT 沟通授权的成员可读 | |
|||
|
|||
企业不必先通过认证才能维护自己的 Logo,但上传 Logo 不改变企业认证状态,也不放开岗位发布门禁。 |
|||
|
|||
### 上传请求 |
|||
|
|||
multipart/form-data,字段名 file。使用 FormData,不要手动设置 Content-Type(让运行时自动带 boundary),不要把本机 file:// URI 或预设素材 key 作为远程图片 URL 提交。 |
|||
|
|||
允许 JPEG/PNG(扩展名、声明 MIME 与真实解码格式必须匹配),原文件最多 2 MiB;单边最多 4096、总像素最多 400 万。SVG/GIF/WebP/HEIC 等本批不支持。相册素材不满足格式时应在客户端转换并裁剪,或给出明确提示。 |
|||
|
|||
后端去除原图元数据及附加内容,等比例缩小到最长边 512,透明背景转白色,统一保存 JPEG,最大 512 KiB。文件名和原图不保存。 |
|||
|
|||
成功返回 envelope,data 示例: |
|||
|
|||
{ |
|||
"url": "/api/v1/profile-images/00000000-0000-0000-0000-000000000000", |
|||
"version": "00000000-0000-0000-0000-000000000000", |
|||
"contentType": "image/jpeg", |
|||
"size": 12345 |
|||
} |
|||
|
|||
示例 UUID 仅示意;每次上传生成新 UUID 和新 URL。个人上传同时更新 users.avatar_url;Logo 上传更新 companies.logo_url。替换/删除在数据库事务内完成,失败保留旧图与旧引用;不推进在线简历 revision。 |
|||
|
|||
删除成功 data 为 {"deleted":true}。重新取资料时没有头像/Logo 应使用占位图,不能继续展示旧本地缓存。 |
|||
|
|||
### 图片读取 |
|||
|
|||
url 是包含 API context path 的同源相对路径,不是完整域名。用 API 服务 origin 解析,不要再次拼 /api/v1,也不要让已登录下载器向任意外部 URL 附带 Bearer。 |
|||
|
|||
个人头像 GET 必须带 Authorization: Bearer access-token;普通图片组件不会必然继承业务请求层的鉴权,要给同源 source 配置认证请求头,或通过受控下载层取字节。禁止把 Token 拼进 URL。 |
|||
|
|||
返回 image/jpeg,Cache-Control: no-store,X-Content-Type-Options: nosniff。这是图片字节,不是 JSON,不能直接使用只解 JSON envelope 的 request() 当作图片下载器。 |
|||
|
|||
旧 URL 在替换/删除后返回 404。第三方登录账号即使知道私有头像 URL,也不能读取;具备 DIRECT 成员关系还必须满足当前拉黑、撤回及企业接收投递等沟通权限。公司 Logo 是公开企业展示素材,不能用它上传私密资料。 |
|||
|
|||
### 错误状态 |
|||
|
|||
- 400:文件缺失、空文件、格式/MIME/内容不一致、大小或像素超限。 |
|||
- 401:缺少/无效/过期/禁用的登录凭证;匿名读个人头像。 |
|||
- 403:身份不允许、非授权聊天成员、已失去沟通权限。 |
|||
- 404:企业资料不存在、图片不存在/已替换/已删除、图片所属账号停用。 |
|||
- 超出全局 multipart/网关大小上限可能更早被拒绝;前端仍应先限制 2 MiB。 |
|||
|
|||
不要向用户暴露文件系统路径、SQL、Token 或完整异常日志。 |
|||
|
|||
## 3. 缓存与状态同步 |
|||
|
|||
1. 上传成功立即把返回的新 URL 更新到本人资料状态;更新会话页共享状态或使会话列表/详情缓存失效。 |
|||
2. 个人资料页与聊天页复用图片头像组件。先尝试图片 URL,失败/缺失才回退姓名首字;不能把 URL 渲染到 Text。 |
|||
3. 页面重新聚焦时拉取当前 /auth/me、会话列表/详情,避免长期使用路由传参或首次进入时的旧快照。 |
|||
4. 对方设备不会因为本机 setState 自动更新。本批没有新增头像变更 WebSocket 事件;对方通过列表/详情刷新获取新 URL。若要求停留聊天页时即时更新,需要前端刷新策略或另立头像事件协议。 |
|||
5. 旧图收到 404 时只做受控的元信息刷新,不能无限重试旧 URL。新 URL 成功后覆盖显示,失败时使用占位头像。 |
|||
6. 切换账号时清理身份相关状态及私有图片缓存;上一账号迟到的响应不能覆盖新账号。 |
|||
|
|||
本机历史 file://、本地预设头像不自动迁移到后端。需要前端获得可上传的实际文件并调用上传接口;只在个人资料页选中素材不算服务器保存成功。 |
|||
|
|||
## 4. 联调验收 |
|||
|
|||
- 求职者改头像后:个人资料、本人气泡、对方会话列表/详情使用同一新图。 |
|||
- 企业改个人头像不改公司 Logo;改 Logo 后企业主页、岗位公司卡片和对方会话摘要使用新 Logo。 |
|||
- 换设备/重新登录仍能从服务器读取;成功上传前不提示“已同步”。 |
|||
- 连续替换、删除、并发上传均只保留当前版本;旧 URL 不再可读。 |
|||
- 匿名/第三方账号不可读私有头像;合法 DIRECT 成员可读,拉黑后旧 URL 也不可读;邀约隐私保持。 |
|||
- 测试空文件、假图片、MIME 伪装、超限、旧地址404与加载失败占位。 |
|||
- 全流程不改变在线简历内容和 revision,不混用公司 Logo 和个人头像。 |
|||
|
|||
## 5. 存储与验收边界 |
|||
|
|||
新增 V63 迁移,仅创建 profile_images 表,不重写旧迁移,不搬运历史外链。每个 owner/kind 最多一张当前图片,二进制和引用在同一数据库事务内更新;数据库备份需包含该表。账号注销完成时清除其上传图片,物理删除用户/企业时外键级联清理。 |
|||
|
|||
现有企业资料中的历史 logoUrl 外链保持兼容;后端不会下载/代理任意外部图片。历史资料接口另写 logoUrl 后,已不再引用的新上传地址不可读;推荐统一使用本批上传/删除接口维护图片。 |
|||
|
|||
本轮定向验证结果见同目录 profile-images-development-verification-20261027.md。全量 clean verify、双真实 MySQL、同次 JAR 黑盒与重启,以及前端真机验收须按各自实际结果登记,不能以此文档替代通过证据。 |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.*; |
|||
|
|||
/** 仅保存重新编码后的展示图片;与 URL 引用在同一事务提交。 */ |
|||
@Entity |
|||
@Table(name = "profile_images", uniqueConstraints = @UniqueConstraint(columnNames = {"owner_user_id", "kind"})) |
|||
@Getter @Setter @NoArgsConstructor |
|||
public class ProfileImage { |
|||
@Id @Column(length = 36) private String id; |
|||
@Column(name = "owner_user_id", nullable = false) private Long ownerUserId; |
|||
@Column(name = "company_id") private Long companyId; |
|||
@Column(nullable = false, length = 10) private String kind; |
|||
@Lob @Column(nullable = false, columnDefinition = "MEDIUMBLOB") private byte[] content; |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import com.mydreamjob.security.CurrentUserAccessor; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.security.SecurityRequirement; |
|||
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 java.util.Map; |
|||
|
|||
@RestController @RequiredArgsConstructor |
|||
@io.swagger.v3.oas.annotations.tags.Tag(name = "个人资料") |
|||
public class ProfileImageController { |
|||
private final ProfileImageService images; |
|||
private final CurrentUserAccessor currentUser; |
|||
private final RequestIdAccessor requestIds; |
|||
|
|||
@Operation(summary = "上传自己的个人头像", description = "JPEG/PNG ≤2 MiB、≤400万像素;服务端重编码为最长边512的JPEG。只替换个人头像,不修改企业Logo或简历revision。", |
|||
security = @SecurityRequirement(name = "bearerAuth")) |
|||
@PostMapping(value = "/auth/me/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) |
|||
public ApiResponse<ProfileImageService.ImageView> uploadAvatar(@RequestParam("file") MultipartFile file, HttpServletRequest request) { |
|||
return ApiResponse.ok(images.upload(currentUser.requireUserId(), false, file), requestIds.get(request)); |
|||
} |
|||
|
|||
@Operation(summary = "删除自己的个人头像", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@DeleteMapping("/auth/me/avatar") |
|||
public ApiResponse<Map<String, Boolean>> deleteAvatar(HttpServletRequest request) { |
|||
images.delete(currentUser.requireUserId(), false); |
|||
return ApiResponse.ok(Map.of("deleted", true), requestIds.get(request)); |
|||
} |
|||
|
|||
@Operation(summary = "上传自己企业的Logo", description = "仅COMPANY;不要求先完成企业认证,但必须已有自己的企业资料。与个人头像独立。", |
|||
security = @SecurityRequirement(name = "bearerAuth")) |
|||
@PostMapping(value = "/companies/me/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) |
|||
public ApiResponse<ProfileImageService.ImageView> uploadLogo(@RequestParam("file") MultipartFile file, HttpServletRequest request) { |
|||
return ApiResponse.ok(images.upload(currentUser.requireUserId(), true, file), requestIds.get(request)); |
|||
} |
|||
|
|||
@Operation(summary = "删除自己企业的Logo", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@DeleteMapping("/companies/me/logo") |
|||
public ApiResponse<Map<String, Boolean>> deleteLogo(HttpServletRequest request) { |
|||
images.delete(currentUser.requireUserId(), true); |
|||
return ApiResponse.ok(Map.of("deleted", true), requestIds.get(request)); |
|||
} |
|||
|
|||
@Operation(operationId = "readProfileImage", summary = "读取头像或企业Logo", description = "企业Logo匿名可读;个人头像须携带Bearer且仅本人或满足当前沟通授权的DIRECT成员可读,非公开用户头像目录。旧地址替换/删除后404;no-store。") |
|||
@io.swagger.v3.oas.annotations.security.SecurityRequirements |
|||
@GetMapping("/profile-images/{imageId}") |
|||
public ResponseEntity<byte[]> read(@PathVariable String imageId) { |
|||
return ResponseEntity.ok().contentType(MediaType.IMAGE_JPEG) |
|||
.header(HttpHeaders.CACHE_CONTROL, "no-store") |
|||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"profile.jpg\"") |
|||
.header("X-Content-Type-Options", "nosniff") |
|||
.body(images.read(imageId)); |
|||
} |
|||
|
|||
@ExceptionHandler(org.springframework.web.multipart.support.MissingServletRequestPartException.class) |
|||
public ResponseEntity<ApiResponse<Void>> missingFile(HttpServletRequest request) { |
|||
return ResponseEntity.badRequest().body(ApiResponse.badRequest("file is required", requestIds.get(request))); |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import java.util.Optional; |
|||
|
|||
public interface ProfileImageRepository extends JpaRepository<ProfileImage, String> { |
|||
Optional<ProfileImage> findByOwnerUserIdAndKind(Long ownerUserId, String kind); |
|||
void deleteByOwnerUserId(Long ownerUserId); |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.company.*; |
|||
import com.mydreamjob.common.exception.*; |
|||
import com.mydreamjob.messaging.*; |
|||
import com.mydreamjob.security.*; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import java.util.*; |
|||
|
|||
@Service @RequiredArgsConstructor |
|||
public class ProfileImageService { |
|||
private final ProfileImageRepository images; |
|||
private final ProfileImageValidator validator; |
|||
private final UserRepository users; |
|||
private final CompanyRepository companies; |
|||
private final UserRoleGuard roles; |
|||
private final CurrentUserAccessor currentUser; |
|||
private final ConversationMemberRepository members; |
|||
private final ConversationRepository conversations; |
|||
private final MessagingService messaging; |
|||
|
|||
@Value("#{environment.getProperty('server.servlet.context-path', '/api/v1')}") |
|||
private String contextPath; |
|||
|
|||
public record ImageView(String url, String version, String contentType, long size) {} |
|||
|
|||
@Transactional |
|||
public ImageView upload(Long actor, boolean logo, MultipartFile file) { |
|||
User user = lockActor(actor, logo); |
|||
Company company = logo ? lockCompany(actor) : null; |
|||
byte[] content = validator.validate(file); |
|||
// 先 flush 删除以释放唯一键;事务失败会完整恢复旧二进制与引用。
|
|||
String kind = logo ? "LOGO" : "AVATAR"; |
|||
images.findByOwnerUserIdAndKind(actor, kind).ifPresent(images::delete); |
|||
images.flush(); |
|||
ProfileImage image = new ProfileImage(); |
|||
image.setId(UUID.randomUUID().toString()); |
|||
image.setOwnerUserId(actor); |
|||
image.setCompanyId(company == null ? null : company.getId()); |
|||
image.setKind(kind); |
|||
image.setContent(content); |
|||
images.saveAndFlush(image); |
|||
if (logo) company.setLogoUrl(url(image.getId())); |
|||
else user.setAvatarUrl(url(image.getId())); |
|||
return new ImageView(url(image.getId()), image.getId(), "image/jpeg", content.length); |
|||
} |
|||
|
|||
@Transactional |
|||
public void delete(Long actor, boolean logo) { |
|||
User user = lockActor(actor, logo); |
|||
Company company = logo ? lockCompany(actor) : null; |
|||
images.findByOwnerUserIdAndKind(actor, logo ? "LOGO" : "AVATAR").ifPresent(images::delete); |
|||
if (logo) company.setLogoUrl(null); |
|||
else user.setAvatarUrl(null); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public byte[] read(String id) { |
|||
if (id == null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) |
|||
throw notFound(); |
|||
ProfileImage image = images.findById(id).orElseThrow(ProfileImageService::notFound); |
|||
User owner = users.findById(image.getOwnerUserId()).orElseThrow(ProfileImageService::notFound); |
|||
if (!Boolean.TRUE.equals(owner.getActive())) throw notFound(); |
|||
if ("LOGO".equals(image.getKind())) { |
|||
Company company = companies.findById(image.getCompanyId()).orElseThrow(ProfileImageService::notFound); |
|||
if (!Objects.equals(company.getOwnerUserId(), owner.getId()) || !url(id).equals(company.getLogoUrl())) |
|||
throw notFound(); |
|||
} else { |
|||
Long actor = currentUser.requireUserId(); |
|||
roles.requireAny(actor, User.Role.STUDENT, User.Role.COMPANY, User.Role.ADMIN); |
|||
if (!url(id).equals(owner.getAvatarUrl())) throw notFound(); |
|||
if (!actor.equals(owner.getId()) && !canReadPeer(actor, owner.getId())) |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return image.getContent().clone(); |
|||
} |
|||
|
|||
private boolean canReadPeer(Long actor, Long owner) { |
|||
for (ConversationMember member : members.findByUserId(actor)) { |
|||
Long id = member.getConversationId(); |
|||
var conversation = conversations.findById(id).orElse(null); |
|||
if (conversation == null || !"DIRECT".equals(conversation.getType()) |
|||
|| !members.existsByConversationIdAndUserId(id, owner)) continue; |
|||
try { |
|||
// 复用成员、拉黑、撤回与企业接收门禁;邀约会话不释放候选人的个人头像。
|
|||
messaging.assertAttachmentAccess(id, actor); |
|||
return true; |
|||
} catch (BusinessException denied) { |
|||
// 另一条 DIRECT 会话可能仍有合法授权;数据库/系统异常不会在此吞掉。
|
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private User lockActor(Long actor, boolean logo) { |
|||
User user = users.findByIdForUpdate(actor).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
if (logo) roles.requireCompany(user); |
|||
else roles.requireAny(user, User.Role.STUDENT, User.Role.COMPANY, User.Role.ADMIN); |
|||
return user; |
|||
} |
|||
|
|||
private Company lockCompany(Long actor) { |
|||
return companies.findByOwnerUserIdForUpdate(actor).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
|||
} |
|||
|
|||
private String url(String id) { return contextPath + "/profile-images/" + id; } |
|||
private static BusinessException notFound() { return new BusinessException(ErrorCode.PROFILE_NOT_FOUND, "image not found"); } |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import com.mydreamjob.common.exception.*; |
|||
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
import javax.imageio.*; |
|||
import javax.imageio.stream.MemoryCacheImageInputStream; |
|||
import java.awt.*; |
|||
import java.awt.image.BufferedImage; |
|||
import java.io.*; |
|||
import java.util.Locale; |
|||
import java.util.Map; |
|||
|
|||
@Component |
|||
public class ProfileImageValidator { |
|||
public static final int MAX_UPLOAD_BYTES = 2 * 1024 * 1024; |
|||
public static final int MAX_STORED_BYTES = 512 * 1024; |
|||
|
|||
/** 检查真实格式和解码前像素上限,去除 EXIF/附加数据,不存原始图片或文件名。 */ |
|||
public byte[] validate(MultipartFile file) { |
|||
if (file == null || file.isEmpty() || file.getSize() > MAX_UPLOAD_BYTES) throw bad(); |
|||
ResumeFilenameSanitizer.validateUploadName(file.getOriginalFilename()); |
|||
String name = file.getOriginalFilename().toLowerCase(Locale.ROOT); |
|||
String ext = name.substring(Math.max(0, name.lastIndexOf('.'))); |
|||
String format = Map.of(".png", "png", ".jpg", "jpeg", ".jpeg", "jpeg").get(ext); |
|||
String mime = "png".equals(format) ? "image/png" : "image/jpeg"; |
|||
if (format == null || !mime.equals(file.getContentType())) throw bad(); |
|||
try (InputStream stream = file.getInputStream()) { |
|||
byte[] raw = stream.readNBytes(MAX_UPLOAD_BYTES + 1); |
|||
if (raw.length == 0 || raw.length > MAX_UPLOAD_BYTES) throw bad(); |
|||
try (var input = new MemoryCacheImageInputStream(new ByteArrayInputStream(raw))) { |
|||
var readers = ImageIO.getImageReaders(input); |
|||
if (!readers.hasNext()) throw bad(); |
|||
ImageReader reader = readers.next(); |
|||
try { |
|||
reader.setInput(input, true, true); |
|||
if (!format.equalsIgnoreCase(reader.getFormatName())) throw bad(); |
|||
int width = reader.getWidth(0), height = reader.getHeight(0); |
|||
if (width <= 0 || height <= 0 || width > 4096 || height > 4096 |
|||
|| (long) width * height > 4_000_000) throw bad(); |
|||
BufferedImage source = reader.read(0); |
|||
double scale = Math.min(1.0, 512.0 / Math.max(width, height)); |
|||
var output = new BufferedImage(Math.max(1, (int) Math.round(width * scale)), |
|||
Math.max(1, (int) Math.round(height * scale)), BufferedImage.TYPE_INT_RGB); |
|||
Graphics2D graphics = output.createGraphics(); |
|||
try { |
|||
graphics.setColor(Color.WHITE); |
|||
graphics.fillRect(0, 0, output.getWidth(), output.getHeight()); |
|||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); |
|||
graphics.drawImage(source, 0, 0, output.getWidth(), output.getHeight(), null); |
|||
} finally { graphics.dispose(); } |
|||
var bytes = new ByteArrayOutputStream(); |
|||
if (!ImageIO.write(output, "jpeg", bytes) || bytes.size() > MAX_STORED_BYTES) throw bad(); |
|||
return bytes.toByteArray(); |
|||
} finally { reader.dispose(); } |
|||
} |
|||
} catch (IOException | IllegalArgumentException e) { throw bad(); } |
|||
} |
|||
|
|||
private static BusinessException bad() { |
|||
return new BusinessException(ErrorCode.BAD_REQUEST, "invalid image; JPEG/PNG up to 2 MiB and 4 million pixels required"); |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
CREATE TABLE profile_images ( |
|||
id VARCHAR(36) NOT NULL PRIMARY KEY, |
|||
owner_user_id BIGINT NOT NULL, |
|||
company_id BIGINT NULL, |
|||
kind VARCHAR(10) NOT NULL, |
|||
content MEDIUMBLOB NOT NULL, |
|||
CONSTRAINT uk_profile_image_owner_kind UNIQUE (owner_user_id, kind), |
|||
CONSTRAINT fk_profile_image_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_profile_image_company FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
|||
@ -0,0 +1,253 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import com.fasterxml.jackson.databind.*; |
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.company.*; |
|||
import com.mydreamjob.messaging.*; |
|||
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_profile_images;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
@AutoConfigureMockMvc @ActiveProfiles("integration-test") |
|||
class ProfileImageIntegrationTest { |
|||
@Autowired MockMvc mvc; @Autowired ObjectMapper json; |
|||
@Autowired UserRepository users; @Autowired CompanyRepository companies; |
|||
@Autowired ConversationRepository conversations; @Autowired ConversationMemberRepository members; |
|||
@Autowired AuthSessionService sessions; @Autowired Clock clock; |
|||
@Autowired ProfileImageRepository images; @Autowired ProfileImageService service; |
|||
@Autowired org.springframework.transaction.PlatformTransactionManager transactions; |
|||
@Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions; |
|||
@Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker; |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
static final AtomicLong SEQ = new AtomicLong(76000000); |
|||
User student, peer, employer, stranger; String st, pt, et, ot; Company company; |
|||
|
|||
User user(User.Role role) { |
|||
return users.save(User.builder().phone("166" + SEQ.incrementAndGet()).nickname("头像测试") |
|||
.role(role).roles(Set.of(role)).active(true).verified(true).build()); |
|||
} |
|||
String token(User user) { return sessions.createSession(user, clock.instant(), new AuditContext()).accessToken(); } |
|||
@BeforeEach void setup() { |
|||
student = user(User.Role.STUDENT); peer = user(User.Role.STUDENT); |
|||
employer = user(User.Role.COMPANY); stranger = user(User.Role.STUDENT); |
|||
st = token(student); pt = token(peer); et = token(employer); ot = token(stranger); |
|||
company = companies.save(Company.builder().ownerUserId(employer.getId()).name("头像测试企业").verified(true).build()); |
|||
} |
|||
MockMultipartFile image(int width, int height) throws Exception { |
|||
var out = new ByteArrayOutputStream(); |
|||
ImageIO.write(new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB), "png", out); |
|||
return new MockMultipartFile("file", "头像.png", "image/png", out.toByteArray()); |
|||
} |
|||
JsonNode upload(String path, String token) throws Exception { |
|||
var response = mvc.perform(multipart(path).file(image(800, 400)).header("Authorization", "Bearer " + token)) |
|||
.andExpect(status().isOk()).andReturn().getResponse(); |
|||
return json.readTree(response.getContentAsByteArray()).path("data"); |
|||
} |
|||
String path(JsonNode view) { return view.path("url").asText().replaceFirst("^/api/v1", ""); } |
|||
long direct(User a, User b, String type) { |
|||
long id = conversations.save(Conversation.builder().createdBy(a.getId()).type(type).build()).getId(); |
|||
for (User user : List.of(a, b)) members.save(ConversationMember.builder().conversationId(id).userId(user.getId()).build()); |
|||
return id; |
|||
} |
|||
|
|||
@Test void personalRoundTripAndAuthMeUseSameVersion() throws Exception { |
|||
var view = upload("/auth/me/avatar", st); |
|||
assertEquals("image/jpeg", view.path("contentType").asText()); |
|||
assertTrue(view.path("size").asLong() <= ProfileImageValidator.MAX_STORED_BYTES); |
|||
assertEquals("/api/v1" + path(view), users.findById(student.getId()).orElseThrow().getAvatarUrl()); |
|||
mvc.perform(get("/auth/me").header("Authorization", "Bearer " + st)) |
|||
.andExpect(jsonPath("$.data.avatarUrl").value(view.path("url").asText())); |
|||
byte[] bytes = mvc.perform(get(path(view)).header("Authorization", "Bearer " + st)) |
|||
.andExpect(status().isOk()).andExpect(content().contentType("image/jpeg")) |
|||
.andExpect(header().string("Cache-Control", "no-store")) |
|||
.andExpect(header().string("X-Content-Type-Options", "nosniff")) |
|||
.andReturn().getResponse().getContentAsByteArray(); |
|||
var decoded = ImageIO.read(new ByteArrayInputStream(bytes)); |
|||
assertEquals(512, decoded.getWidth()); assertEquals(256, decoded.getHeight()); |
|||
} |
|||
@Test void peerSeesCurrentAvatarAndOldUrlIsInvalidated() throws Exception { |
|||
long cid = direct(student, peer, "DIRECT"); |
|||
var first = upload("/auth/me/avatar", st); |
|||
mvc.perform(get(path(first)).header("Authorization", "Bearer " + pt)).andExpect(status().isOk()); |
|||
var second = upload("/auth/me/avatar", st); |
|||
assertNotEquals(first.path("url"), second.path("url")); |
|||
mvc.perform(get(path(first)).header("Authorization", "Bearer " + pt)).andExpect(status().isNotFound()); |
|||
mvc.perform(get("/conversations/" + cid).header("Authorization", "Bearer " + pt)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.avatarUrl").value(second.path("url").asText())); |
|||
mvc.perform(get("/conversations").header("Authorization", "Bearer " + pt)) |
|||
.andExpect(jsonPath("$.data[0].avatarUrl").value(second.path("url").asText())); |
|||
} |
|||
@Test void avatarAnonymousAndNonMemberDenied() throws Exception { |
|||
var view = upload("/auth/me/avatar", st); |
|||
mvc.perform(get(path(view))).andExpect(status().isUnauthorized()); |
|||
mvc.perform(get(path(view)).header("Authorization", "Bearer " + ot)).andExpect(status().isForbidden()); |
|||
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2))).andExpect(status().isUnauthorized()); |
|||
} |
|||
@Test void invitationCannotRevealCandidateAvatar() throws Exception { |
|||
direct(student, peer, "TALENT_INVITATION"); |
|||
var view = upload("/auth/me/avatar", st); |
|||
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isForbidden()); |
|||
} |
|||
@Test void blockRevokesExistingAvatarUrl() throws Exception { |
|||
direct(student, peer, "DIRECT"); |
|||
var view = upload("/auth/me/avatar", st); |
|||
mvc.perform(put("/blocks/" + student.getId()).header("Authorization", "Bearer " + pt)).andExpect(status().isOk()); |
|||
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isForbidden()); |
|||
} |
|||
@Test void disabledOwnerAndReaderDenied() throws Exception { |
|||
direct(student, peer, "DIRECT"); |
|||
var view = upload("/auth/me/avatar", st); |
|||
peer.setActive(false); users.saveAndFlush(peer); |
|||
mvc.perform(get(path(view)).header("Authorization", "Bearer " + pt)).andExpect(status().isUnauthorized()); |
|||
student.setActive(false); users.saveAndFlush(student); |
|||
mvc.perform(get(path(view))).andExpect(status().isNotFound()); |
|||
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2)).header("Authorization", "Bearer " + st)) |
|||
.andExpect(status().isUnauthorized()); |
|||
} |
|||
@Test void personalAndLogoAreIndependentAndLogoPublic() throws Exception { |
|||
var avatar = upload("/auth/me/avatar", et); |
|||
var logo = upload("/companies/me/logo", et); |
|||
assertNotEquals(avatar.path("url"), logo.path("url")); |
|||
assertEquals(logo.path("url").asText(), companies.findById(company.getId()).orElseThrow().getLogoUrl()); |
|||
assertEquals(avatar.path("url").asText(), users.findById(employer.getId()).orElseThrow().getAvatarUrl()); |
|||
mvc.perform(get(path(logo))).andExpect(status().isOk()); |
|||
mvc.perform(get("/companies/" + company.getId())).andExpect(jsonPath("$.data.logoUrl").value(logo.path("url").asText())); |
|||
mvc.perform(delete("/auth/me/avatar").header("Authorization", "Bearer " + et)).andExpect(status().isOk()); |
|||
mvc.perform(get(path(logo))).andExpect(status().isOk()); |
|||
mvc.perform(get(path(avatar)).header("Authorization", "Bearer " + et)).andExpect(status().isNotFound()); |
|||
} |
|||
@Test void companyConversationUsesLogoNotRecruiterAvatar() throws Exception { |
|||
direct(student, employer, "DIRECT"); |
|||
var avatar = upload("/auth/me/avatar", et); |
|||
var logo = upload("/companies/me/logo", et); |
|||
mvc.perform(get("/conversations").header("Authorization", "Bearer " + st)) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data[0].avatarUrl").value(logo.path("url").asText())); |
|||
assertNotEquals(avatar.path("url"), logo.path("url")); |
|||
} |
|||
@Test void nonCompanyAndCompanyWithoutProfileCannotUploadLogo() throws Exception { |
|||
mvc.perform(multipart("/companies/me/logo").file(image(2, 2)).header("Authorization", "Bearer " + st)) |
|||
.andExpect(status().isForbidden()); |
|||
var noProfile = token(user(User.Role.COMPANY)); |
|||
mvc.perform(multipart("/companies/me/logo").file(image(2, 2)).header("Authorization", "Bearer " + noProfile)) |
|||
.andExpect(status().isNotFound()); |
|||
assertTrue(images.findByOwnerUserIdAndKind(student.getId(), "LOGO").isEmpty()); |
|||
} |
|||
@Test void deletesAreIdempotentAndCannotTouchOtherOwner() throws Exception { |
|||
var own = upload("/auth/me/avatar", st); var other = upload("/auth/me/avatar", pt); |
|||
for (int i = 0; i < 2; i++) mvc.perform(delete("/auth/me/avatar").header("Authorization", "Bearer " + st)) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(get(path(own)).header("Authorization", "Bearer " + st)).andExpect(status().isNotFound()); |
|||
mvc.perform(get(path(other)).header("Authorization", "Bearer " + pt)).andExpect(status().isOk()); |
|||
var logo = upload("/companies/me/logo", et); |
|||
mvc.perform(delete("/companies/me/logo").header("Authorization", "Bearer " + et)).andExpect(status().isOk()); |
|||
mvc.perform(get(path(logo))).andExpect(status().isNotFound()); |
|||
} |
|||
@Test void invalidUploadPreservesOldReferenceAndBytes() throws Exception { |
|||
var view = upload("/auth/me/avatar", st); |
|||
for (var invalid : List.of( |
|||
new MockMultipartFile("file", "bad.png", "image/png", new byte[]{1, 2}), |
|||
new MockMultipartFile("file", "bad.svg", "image/svg+xml", "<svg/>".getBytes()), |
|||
new MockMultipartFile("file", "../bad.png", "image/png", image(2, 2).getBytes()), |
|||
new MockMultipartFile("file", "bad.jpg", "image/jpeg", image(2, 2).getBytes()), |
|||
new MockMultipartFile("file", "big.png", "image/png", new byte[ProfileImageValidator.MAX_UPLOAD_BYTES + 1]), |
|||
image(4097, 1))) { |
|||
mvc.perform(multipart("/auth/me/avatar").file(invalid).header("Authorization", "Bearer " + st)) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
mvc.perform(get(path(view)).header("Authorization", "Bearer " + st)).andExpect(status().isOk()); |
|||
assertEquals(view.path("url").asText(), users.findById(student.getId()).orElseThrow().getAvatarUrl()); |
|||
assertEquals(1, images.findAll().stream().filter(i -> i.getOwnerUserId().equals(student.getId())).count()); |
|||
} |
|||
@Test void missingFileIs400AndMalformedIdIs404() throws Exception { |
|||
mvc.perform(multipart("/auth/me/avatar").header("Authorization", "Bearer " + st)).andExpect(status().isBadRequest()); |
|||
mvc.perform(get("/profile-images/not-a-uuid")).andExpect(status().isNotFound()); |
|||
} |
|||
@Test void transactionRollbackRestoresOldImage() throws Exception { |
|||
var first = upload("/auth/me/avatar", st); |
|||
var tx = new org.springframework.transaction.support.TransactionTemplate(transactions); |
|||
tx.executeWithoutResult(status -> { |
|||
try { service.upload(student.getId(), false, image(5, 5)); } |
|||
catch (Exception e) { throw new IllegalStateException(e); } |
|||
status.setRollbackOnly(); |
|||
}); |
|||
mvc.perform(get(path(first)).header("Authorization", "Bearer " + st)).andExpect(status().isOk()); |
|||
assertEquals(first.path("url").asText(), users.findById(student.getId()).orElseThrow().getAvatarUrl()); |
|||
} |
|||
@Test void concurrentReplacementLeavesOneCurrentImage() throws Exception { |
|||
var pool = Executors.newFixedThreadPool(2); |
|||
try { |
|||
var a = pool.submit(() -> upload("/auth/me/avatar", st)); |
|||
var b = pool.submit(() -> upload("/auth/me/avatar", st)); |
|||
var first = a.get(30, TimeUnit.SECONDS); var second = b.get(30, TimeUnit.SECONDS); |
|||
assertNotEquals(first.path("url"), second.path("url")); |
|||
assertEquals(1, images.findAll().stream().filter(i -> i.getOwnerUserId().equals(student.getId())).count()); |
|||
String current = users.findById(student.getId()).orElseThrow().getAvatarUrl(); |
|||
assertTrue(Set.of(first.path("url").asText(), second.path("url").asText()).contains(current)); |
|||
mvc.perform(get(current.replaceFirst("^/api/v1", "")).header("Authorization", "Bearer " + st)).andExpect(status().isOk()); |
|||
} finally { pool.shutdownNow(); } |
|||
} |
|||
@Test void accountDeletionPurgesImageBytes() throws Exception { |
|||
var view = upload("/auth/me/avatar", st); |
|||
deletions.saveAndFlush(com.mydreamjob.deletion.AccountDeletion.builder().userId(student.getId()) |
|||
.status(com.mydreamjob.deletion.DeletionStatus.PENDING) |
|||
.requestedAt(java.time.LocalDateTime.now().minusDays(9)) |
|||
.effectiveAt(java.time.LocalDateTime.now().minusDays(1)).version(0L).build()); |
|||
assertTrue(deletionWorker.completeIfExpired(student.getId())); |
|||
assertFalse(images.existsById(view.path("version").asText())); |
|||
mvc.perform(get(path(view))).andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
@Test void jpegAcceptedAndPixelBombRejected() throws Exception { |
|||
var out = new ByteArrayOutputStream(); |
|||
ImageIO.write(new BufferedImage(12, 10, BufferedImage.TYPE_INT_RGB), "jpeg", out); |
|||
mvc.perform(multipart("/auth/me/avatar").file(new MockMultipartFile("file", "avatar.jpg", "image/jpeg", out.toByteArray())) |
|||
.header("Authorization", "Bearer " + st)).andExpect(status().isOk()); |
|||
mvc.perform(multipart("/auth/me/avatar").file(image(2001, 2000)) |
|||
.header("Authorization", "Bearer " + st)).andExpect(status().isBadRequest()); |
|||
} |
|||
|
|||
@Test void unknownAndUnassignedUsersCannotWriteOrReadPrivateAvatar() throws Exception { |
|||
var unassigned = user(User.Role.UNASSIGNED); var token = token(unassigned); |
|||
var own = upload("/auth/me/avatar", st); |
|||
mvc.perform(multipart("/auth/me/avatar").file(image(2, 2)).header("Authorization", "Bearer " + token)) |
|||
.andExpect(status().isForbidden()); |
|||
mvc.perform(get(path(own)).header("Authorization", "Bearer " + token)).andExpect(status().isForbidden()); |
|||
} |
|||
|
|||
@Test void runtimeContractContainsOnlyTheFiveNewOperations() throws Exception { |
|||
var response = mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse(); |
|||
var runtime = json.readTree(response.getContentAsByteArray()).path("paths"); |
|||
var contract = json.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("../docs/openapi-rc.json"))).path("paths"); |
|||
assertEquals(216, runtime.size()); |
|||
Set<String> expected = new TreeSet<>(), actual = new TreeSet<>(); |
|||
contract.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> expected.add(m + " " + p.getKey()))); |
|||
runtime.fields().forEachRemaining(p -> p.getValue().fieldNames().forEachRemaining(m -> actual.add(m + " " + p.getKey()))); |
|||
assertEquals(266, actual.size()); assertEquals(expected, actual); |
|||
assertEquals("uploadAvatar", runtime.path("/auth/me/avatar").path("post").path("operationId").asText()); |
|||
for (String route : List.of("/auth/me/avatar", "/companies/me/logo", "/profile-images/{imageId}")) { |
|||
contract.path(route).fields().forEachRemaining(method -> { |
|||
assertEquals(method.getValue().path("operationId"), |
|||
runtime.path(route).path(method.getKey()).path("operationId")); |
|||
}); |
|||
} |
|||
assertFalse(runtime.path("/profile-images/{imageId}").path("get").path("security").toString().contains("bearerAuth")); |
|||
} |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
package com.mydreamjob.profileimage; |
|||
|
|||
import com.mydreamjob.config.MySql57MigrationConfiguration; |
|||
import org.flywaydb.core.Flyway; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import java.sql.*; |
|||
import java.util.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** V62→V63 独立双库验证;无 Docker 时不得登记为迁移通过。 */ |
|||
class ProfileImageMySqlMigrationTest { |
|||
@Test void mysql57() throws Exception { verify("mysql:5.7.36"); } |
|||
@Test void mysql8() throws Exception { verify("mysql:8.4"); } |
|||
private Flyway flyway(MySQLContainer<?> db, String version) { |
|||
var config = Flyway.configure().dataSource(db.getJdbcUrl(), db.getUsername(), db.getPassword()) |
|||
.locations("classpath:db/migration").target(version); |
|||
MySql57MigrationConfiguration.configure(config); |
|||
return config.load(); |
|||
} |
|||
private void verify(String image) throws Exception { |
|||
try (var db = new MySQLContainer<>(image).withDatabaseName("profile_image_migration") |
|||
.withUsername("image_test").withPassword("synthetic-only") |
|||
.withCommand("--max-allowed-packet=33554432", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci") |
|||
.withUrlParam("characterEncoding", "UTF-8") |
|||
.withCreateContainerCmdModifier(c -> c.getHostConfig().withMemory(768L * 1024 * 1024))) { |
|||
db.start(); |
|||
flyway(db, "62").migrate(); |
|||
try (var connection = DriverManager.getConnection(db.getJdbcUrl(), db.getUsername(), db.getPassword()); |
|||
var sql = connection.createStatement()) { |
|||
sql.execute("INSERT INTO users(id,phone,role,is_active,is_verified,avatar_url) VALUES(940001,'19900940001','COMPANY',1,1,'https://example.invalid/old.jpg')"); |
|||
sql.execute("INSERT INTO companies(id,owner_user_id,name,verified,logo_url) VALUES(940001,940001,'migration fixture',1,'https://example.invalid/logo.jpg')"); |
|||
List<String> before = history(sql); |
|||
assertEquals(1, flyway(db, "63").migrate().migrationsExecuted); |
|||
assertEquals(before, history(sql)); |
|||
try (var r = sql.executeQuery("SELECT avatar_url FROM users WHERE id=940001")) { |
|||
assertTrue(r.next()); assertEquals("https://example.invalid/old.jpg", r.getString(1)); |
|||
} |
|||
try (var r = sql.executeQuery("SELECT logo_url FROM companies WHERE id=940001")) { |
|||
assertTrue(r.next()); assertEquals("https://example.invalid/logo.jpg", r.getString(1)); |
|||
} |
|||
String id = UUID.randomUUID().toString(); |
|||
byte[] content = new byte[ProfileImageValidator.MAX_STORED_BYTES]; |
|||
Arrays.fill(content, (byte) 31); |
|||
try (var insert = connection.prepareStatement("INSERT INTO profile_images(id,owner_user_id,kind,content) VALUES(?,940001,'AVATAR',?)")) { |
|||
insert.setString(1, id); insert.setBytes(2, content); assertEquals(1, insert.executeUpdate()); |
|||
} |
|||
try (var r = sql.executeQuery("SELECT content FROM profile_images WHERE id='" + id + "'")) { |
|||
assertTrue(r.next()); assertArrayEquals(content, r.getBytes(1)); |
|||
} |
|||
assertThrows(SQLException.class, () -> sql.execute("INSERT INTO profile_images(id,owner_user_id,kind,content) VALUES('other',940001,'AVATAR',X'00')")); |
|||
assertThrows(SQLException.class, () -> sql.execute("INSERT INTO profile_images(id,owner_user_id,kind,content) VALUES('missing',940002,'AVATAR',X'00')")); |
|||
assertEquals(0, flyway(db, "63").migrate().migrationsExecuted); |
|||
flyway(db, "63").validate(); assertEquals(before, history(sql)); |
|||
sql.execute("DELETE FROM companies WHERE id=940001"); |
|||
sql.execute("DELETE FROM users WHERE id=940001"); |
|||
try (var r = sql.executeQuery("SELECT count(*) FROM profile_images")) { |
|||
assertTrue(r.next()); assertEquals(0, r.getInt(1)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
private List<String> history(Statement sql) throws SQLException { |
|||
List<String> result = new ArrayList<>(); |
|||
try (var r = sql.executeQuery("SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'63' ORDER BY installed_rank")) { |
|||
while (r.next()) result.add(r.getString(1) + "|" + r.getString(2) + "|" + r.getBoolean(3)); |
|||
} |
|||
return result; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue