diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 5d6b6e8..a3a1fd9 100644 --- a/docs/openapi-rc.json +++ b/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": { diff --git a/docs/profile-images-development-verification-20261027.md b/docs/profile-images-development-verification-20261027.md new file mode 100644 index 0000000..1d926af --- /dev/null +++ b/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 + +此清单是本轮开发范围,不自动授权提交或发布。独立验收应锁定这些文件的哈希,同时保留原工作区既有改动。 diff --git a/docs/profile-images-frontend-handover-20261027.md b/docs/profile-images-frontend-handover-20261027.md new file mode 100644 index 0000000..3a82ae2 --- /dev/null +++ b/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 黑盒与重启,以及前端真机验收须按各自实际结果登记,不能以此文档替代通过证据。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 0938915..a2336fe 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 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() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java index 34f6e91..e1f1e35 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java @@ -29,7 +29,7 @@ public class IdentityAccessFilter extends OncePerRequestFilter { private static final Set 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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java index b91caf9..dd043cd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java @@ -14,6 +14,10 @@ public interface CompanyRepository extends JpaRepository { Optional findByOwnerUserId(Long ownerUserId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from Company c where c.ownerUserId = :ownerUserId") + Optional findByOwnerUserIdForUpdate(@Param("ownerUserId") Long ownerUserId); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select c from Company c where c.id = :id") Optional findByIdForUpdate(@Param("id") Long id); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java index 0424e84..65f08c0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java +++ b/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()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index df49efc..6d6bf2f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/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角色) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index d6c2372..d0af7c2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/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 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImage.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImage.java new file mode 100644 index 0000000..b62f3a5 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageController.java new file mode 100644 index 0000000..0f443f0 --- /dev/null +++ b/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 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> 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 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> 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 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> missingFile(HttpServletRequest request) { + return ResponseEntity.badRequest().body(ApiResponse.badRequest("file is required", requestIds.get(request))); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageRepository.java new file mode 100644 index 0000000..47eb23f --- /dev/null +++ b/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 { + Optional findByOwnerUserIdAndKind(Long ownerUserId, String kind); + void deleteByOwnerUserId(Long ownerUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageService.java new file mode 100644 index 0000000..261166e --- /dev/null +++ b/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"); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageValidator.java b/mydreamjob-server/src/main/java/com/mydreamjob/profileimage/ProfileImageValidator.java new file mode 100644 index 0000000..02a3be0 --- /dev/null +++ b/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"); + } +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V63__profile_images.sql b/mydreamjob-server/src/main/resources/db/migration/V63__profile_images.sql new file mode 100644 index 0000000..249a8be --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index be1a1ad..d5955d9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest { } assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("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(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index e10b5f5..c461e7a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -186,7 +186,7 @@ class UnifiedRegistrationIntegrationTest { Set 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 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()+"}"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java index 212dda0..4d2fecf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyServiceTest.java +++ b/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)); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageIntegrationTest.java new file mode 100644 index 0000000..fdbcd78 --- /dev/null +++ b/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 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", "".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 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")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageMySqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profileimage/ProfileImageMySqlMigrationTest.java new file mode 100644 index 0000000..61d61b4 --- /dev/null +++ b/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 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 history(Statement sql) throws SQLException { + List 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java index 7becc70..99993ea 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/optimization/WorkExperienceOptimizationSecurityIntegrationTest.java +++ b/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"));