Browse Source

feat(profile): 打通个人头像与企业Logo上传读取

fix/mysql57-module-migrations-20261009
Zhang Shun 6 hours ago
parent
commit
9fdb72a8e8
  1. 263
      docs/openapi-rc.json
  2. 90
      docs/profile-images-development-verification-20261027.md
  3. 100
      docs/profile-images-frontend-handover-20261027.md
  4. 29
      docs/verify_openapi.py
  5. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  6. 4
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java
  7. 2
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  8. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  9. 2
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  10. 16
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImage.java
  11. 65
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java
  12. 9
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageRepository.java
  13. 113
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java
  14. 64
      mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageValidator.java
  15. 10
      mydreamjob-server/src/main/resources/db/migration/V63__profile_images.sql
  16. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  17. 4
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  18. 4
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java
  19. 253
      mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java
  20. 70
      mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageMySqlMigrationTest.java
  21. 4
      mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java

263
docs/openapi-rc.json

@ -205,6 +205,248 @@
}
],
"paths": {
"/auth/me/avatar": {
"post": {
"tags": [
"个人资料"
],
"operationId": "uploadAvatar",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/ProfileImageView"
}
}
}
}
}
},
"400": {
"description": "invalid image or missing file"
},
"401": {
"description": "not authenticated"
},
"403": {
"description": "forbidden"
},
"404": {
"description": "not found"
}
},
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"delete": {
"tags": [
"个人资料"
],
"operationId": "deleteAvatar",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "ok"
},
"400": {
"description": "invalid image or missing file"
},
"401": {
"description": "not authenticated"
},
"403": {
"description": "forbidden"
},
"404": {
"description": "not found"
}
}
}
},
"/companies/me/logo": {
"post": {
"tags": [
"个人资料"
],
"operationId": "uploadLogo",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/ProfileImageView"
}
}
}
}
}
},
"400": {
"description": "invalid image or missing file"
},
"401": {
"description": "not authenticated"
},
"403": {
"description": "forbidden"
},
"404": {
"description": "not found"
}
},
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"delete": {
"tags": [
"个人资料"
],
"operationId": "deleteLogo",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "ok"
},
"400": {
"description": "invalid image or missing file"
},
"401": {
"description": "not authenticated"
},
"403": {
"description": "forbidden"
},
"404": {
"description": "not found"
}
}
}
},
"/profile-images/{imageId}": {
"get": {
"tags": [
"个人资料"
],
"operationId": "readProfileImage",
"description": "Logo anonymous; personal avatar requires authenticated self or authorized DIRECT peer. Replaced/deleted URLs return 404.",
"security": [],
"parameters": [
{
"name": "imageId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "JPEG image; Cache-Control=no-store",
"headers": {
"Cache-Control": {
"schema": {
"type": "string",
"enum": [
"no-store"
]
}
}
},
"content": {
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "avatar requires authentication"
},
"403": {
"description": "avatar access denied"
},
"404": {
"description": "image missing or invalidated"
}
}
}
},
"/conversations/{id}/resume-messages": {
"post": {
"tags": [
@ -12785,6 +13027,27 @@
},
"components": {
"schemas": {
"ProfileImageView": {
"type": "object",
"properties": {
"url": {
"type": "string"
},
"version": {
"type": "string"
},
"contentType": {
"type": "string",
"enum": [
"image/jpeg"
]
},
"size": {
"type": "integer",
"format": "int64"
}
}
},
"SendRequest": {
"type": "object",
"properties": {

90
docs/profile-images-development-verification-20261027.md

@ -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
此清单是本轮开发范围,不自动授权提交或发布。独立验收应锁定这些文件的哈希,同时保留原工作区既有改动。

100
docs/profile-images-frontend-handover-20261027.md

@ -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 黑盒与重启,以及前端真机验收须按各自实际结果登记,不能以此文档替代通过证据。

29
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']) == 213, f"Expected 213 paths, got {len(d['paths'])}"
assert len(d['paths']) == 216, f"Expected 216 paths, got {len(d['paths'])}"
paths = d['paths']
@ -673,7 +673,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register', '/auth/identity/select', '/auth/password/setup',
'/auth/company/register', '/auth/identity/select', '/auth/password/setup', '/auth/me/avatar',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
@ -743,7 +743,30 @@ 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 == 261, f"Expected 261 operations, got {operations}"
assert operations == 266, f"Expected 266 operations, got {operations}"
# 个人头像/企业 Logo 独立写入;文件上传不接受客户端 userId 或 URL。
for _route, _upload, _delete in (
('/auth/me/avatar', 'uploadAvatar', 'deleteAvatar'),
('/companies/me/logo', 'uploadLogo', 'deleteLogo'),
):
assert set(paths[_route]) == {'post', 'delete'}
assert paths[_route]['post']['operationId'] == _upload
assert paths[_route]['delete']['operationId'] == _delete
for _method in ('post', 'delete'):
assert paths[_route][_method]['security'] == [{'bearerAuth': []}]
_image_body = paths[_route]['post']['requestBody']
assert _image_body['required'] is True
_image_schema = _image_body['content']['multipart/form-data']['schema']
assert _image_schema['required'] == ['file']
assert set(_image_schema['properties']) == {'file'}
assert _image_schema['properties']['file']['format'] == 'binary'
_image_get = paths['/profile-images/{imageId}']['get']
assert _image_get['operationId'] == 'readProfileImage'
assert _image_get['security'] == [] # Logo 公开;个人头像读取在服务端重新鉴权。
assert {'200', '401', '403', '404'} <= set(_image_get['responses'])
assert 'image/jpeg' in _image_get['responses']['200']['content']
assert _image_get['responses']['200']['headers']['Cache-Control']['schema']['enum'] == ['no-store']
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()

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java

@ -29,7 +29,7 @@ public class IdentityAccessFilter extends OncePerRequestFilter {
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs",
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews",
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs",
"/job-recommendation-taxonomies", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**");
"/job-recommendation-taxonomies", "/profile-images/*", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**");
private final UserRepository users;
private final CurrentUserAccessor currentUser;
private final ObjectMapper mapper;

4
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java

@ -14,6 +14,10 @@ public interface CompanyRepository extends JpaRepository<Company, Long> {
Optional<Company> findByOwnerUserId(Long ownerUserId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Company c where c.ownerUserId = :ownerUserId")
Optional<Company> findByOwnerUserIdForUpdate(@Param("ownerUserId") Long ownerUserId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Company c where c.id = :id")
Optional<Company> findByIdForUpdate(@Param("id") Long id);

2
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java

@ -89,7 +89,7 @@ public class CompanyService {
@Transactional
public Company updateCompany(Long userId, CompanyUpdateRequest req) {
assertCompanyRole(userId);
Company company = companyRepository.findByOwnerUserId(userId)
Company company = companyRepository.findByOwnerUserIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
if (req.getName() != null) company.setName(req.getName());

2
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -115,6 +115,8 @@ public class SecurityConfig {
.requestMatchers("/profile/me/talent-invitations/**").authenticated()
// 企业公开岗位列表(匿名 GET)
.requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll()
// Logo 可公开;个人头像仍由读取服务校验本人/沟通授权。
.requestMatchers(HttpMethod.GET, "/profile-images/*").permitAll()
// 企业公开主页(匿名 GET)
.requestMatchers(HttpMethod.GET, "/companies/*").permitAll()
// 管理后台(需登录+ADMIN角色)

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

@ -42,6 +42,7 @@ public class AccountDeletionCompletionWorker {
private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle;
private final com.mydreamjob.messaging.ChatAttachmentRepository chatAttachments;
private final com.mydreamjob.onlineresume.OnlineResumeRepository onlineResumeRepository;
private final com.mydreamjob.profileimage.ProfileImageRepository profileImages;
/**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
@ -78,6 +79,7 @@ public class AccountDeletionCompletionWorker {
chatAttachments.purgeForSender(rec.getUserId());
chatAttachments.clearMetadataForSender(rec.getUserId());
onlineResumeRepository.deleteById(rec.getUserId());
profileImages.deleteByOwnerUserId(rec.getUserId());
// 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId

16
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImage.java

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

65
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java

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

9
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageRepository.java

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

113
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java

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

64
mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageValidator.java

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

10
mydreamjob-server/src/main/resources/db/migration/V63__profile_images.sql

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

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

@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("200 paths 包含附件及 AI-001 告知").isEqualTo(200);
assertThat(opCount - moduleOperations).as("243 operations 包含附件及 AI-001 告知").isEqualTo(243);
assertThat(pathCount - modulePaths).as("203 paths 包含独立头像/Logo").isEqualTo(203);
assertThat(opCount - moduleOperations).as("248 operations 包含独立头像/Logo").isEqualTo(248);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

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

@ -186,7 +186,7 @@ class UnifiedRegistrationIntegrationTest {
Set<String> publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters",
"/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}",
"/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs",
"/job-recommendation-taxonomies","/auth/me");
"/job-recommendation-taxonomies","/auth/me","/profile-images/{imageId}");
Set<String> accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set","/auth/password/setup",
"/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset",
"/auth/refresh","/auth/sms/send","/auth/company/register");
@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
}
}
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁、附件及 AI-001 含告知操作均受保护").isEqualTo(217);
assertThat(denied - moduleDenied).as("既有身份门禁与头像/Logo写入操作均受保护").isEqualTo(221);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
}

4
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java

@ -144,7 +144,7 @@ class CompanyServiceTest {
mockCompanyUser();
CompanyUpdateRequest req = new CompanyUpdateRequest();
req.setName("新名称"); req.setIndustry("互联网");
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.of(company));
when(companyRepository.findByOwnerUserIdForUpdate(COMPANY_USER_ID)).thenReturn(java.util.Optional.of(company));
when(companyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Company updated = companyService.updateCompany(COMPANY_USER_ID, req);
assertEquals("新名称", updated.getName());
@ -155,7 +155,7 @@ class CompanyServiceTest {
void updateCompany_notFound_shouldThrow() {
mockCompanyUser();
CompanyUpdateRequest req = new CompanyUpdateRequest(); req.setName("x");
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(java.util.Optional.empty());
when(companyRepository.findByOwnerUserIdForUpdate(COMPANY_USER_ID)).thenReturn(java.util.Optional.empty());
assertThrows(BusinessException.class, () -> companyService.updateCompany(COMPANY_USER_ID, req));
}

253
mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java

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

70
mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageMySqlMigrationTest.java

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

4
mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java

@ -140,8 +140,8 @@ class WorkExperienceOptimizationSecurityIntegrationTest {
var response=http.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/v3/api-docs")).GET().build(),HttpResponse.BodyHandlers.ofString());
assertEquals(200,response.statusCode());
var runtime=mapper.readTree(response.body());var contract=mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertEquals(213,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(261,operations(runtime).size());
assertEquals(216,runtime.path("paths").size());
assertEquals(operations(contract),operations(runtime));assertEquals(266,operations(runtime).size());
var path=runtime.path("paths").path("/profile/me/work-experience-optimization").path("post");
assertTrue(path.path("requestBody").path("required").asBoolean());
assertTrue(path.path("security").toString().contains("bearerAuth"));

Loading…
Cancel
Save