diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 04b586a..57dd431 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -5326,7 +5326,7 @@ "个人资料" ], "summary": "更新学生资料", - "description": "仅更新传入的非 null 字段;不存在则自动创建", + "description": "仅更新传入的非 null 字段;不存在则自动创建。已有在线简历时必须携带其最新 revision,缺少或过期返回409/6002;成功写入推进版本,须重新 GET 在线简历获取最新版本。尚无在线简历记录的旧客户端可省略 revision 或传0。", "operationId": "updateMyProfile", "requestBody": { "content": { @@ -6309,7 +6309,7 @@ "求职偏好" ], "summary": "保存求职偏好", - "description": "仅 STUDENT;upsert,完全替换传入字段", + "description": "仅 STUDENT;upsert,非 null 字段部分更新,列表[]清空;薪资以元/月计,按与已存值合并后的区间校验。已有在线简历时必须携带其最新 revision,缺少或过期返回409/6002;成功写入推进版本,须重新 GET 在线简历获取最新版本。尚无在线简历记录时可省略 revision 或传0。", "operationId": "savePreferences", "requestBody": { "content": { @@ -15987,6 +15987,12 @@ "StudentPreferenceRequest": { "type": "object", "properties": { + "revision": { + "type": "integer", + "format": "int64", + "minimum": 0, + "description": "已保存在线简历时必填,取自 GET /profile/me/online-resume;缺少或过期返回409/6002。尚无在线记录时可省略或传0。成功后重新GET获取新版本。" + }, "expectedSalaryMin": { "type": "integer", "format": "int32", @@ -16048,6 +16054,12 @@ "StudentProfileUpdateRequest": { "type": "object", "properties": { + "revision": { + "type": "integer", + "format": "int64", + "minimum": 0, + "description": "已保存在线简历时必填,取自 GET /profile/me/online-resume;缺少或过期返回409/6002。尚无在线记录时可省略或传0。成功后重新GET获取新版本。" + }, "realName": { "type": "string", "maxLength": 50, @@ -21557,6 +21569,11 @@ "type": "object", "additionalProperties": false, "properties": { + "experienceType": { + "type": ["string", "null"], + "pattern": "^(FULL_TIME|INTERNSHIP)$", + "description": "经历类型:FULL_TIME 正式工作、INTERNSHIP 实习;省略或null表示未区分,不推断历史记录。条目PUT为完整替换,保留类型须回传该字段。" + }, "id": { "type": "string", "pattern": "[a-f0-9\\-]{36}" diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index ccfe250..376d9e9 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -904,5 +904,21 @@ assert len({item['$ref'] for item in _entry['oneOf']}) == 4 for _candidate in ('CandidateItemView', 'CandidateDetailView'): assert not ({'phone', 'email'} & set(_online_schemas[_candidate]['properties'])) print(' Online resume: 4 paths / 8 operations, contacts and experience schemas verified') +_work_type = _online_schemas['OnlineResumeWork']['properties']['experienceType'] +assert set(_work_type['type']) == {'string', 'null'} +assert _work_type['pattern'] == '^(FULL_TIME|INTERNSHIP)$' +assert 'experienceType' not in _online_schemas['OnlineResumeWork'].get('required', []) +assert '未区分' in _work_type['description'] +for _legacy_schema in ('StudentProfileUpdateRequest', 'StudentPreferenceRequest'): + _schema = _online_schemas[_legacy_schema] + _revision = _schema['properties']['revision'] + assert _revision['type'] == 'integer' and _revision['format'] == 'int64' + assert _revision['minimum'] == 0 + assert '409/6002' in _revision['description'] + assert 'revision' not in _schema.get('required', []), '无在线记录的旧客户端仍可省略版本' +for _legacy_path in ('/profile/me', '/profile/me/preferences'): + assert '409/6002' in paths[_legacy_path]['put']['description'] + assert 'revision' in paths[_legacy_path]['put']['description'] +print(' Legacy profile/preference writes share online resume revision; conditional requirement documented') print("Valid JSON: True") print("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java index de88799..efde76f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java @@ -77,6 +77,10 @@ public class OnlineResumeContent { public static class Work extends DatedEntry { @NotBlank @Size(max = 150) private String company; @NotBlank @Size(max = 120) private String position; + @Pattern(regexp = "^(FULL_TIME|INTERNSHIP)$") + @io.swagger.v3.oas.annotations.media.Schema(types = {"string", "null"}, pattern = "^(FULL_TIME|INTERNSHIP)$", + description = "经历类型:FULL_TIME 正式工作、INTERNSHIP 实习;省略或null表示未区分,不推断历史记录。条目PUT为完整替换,保留类型须回传该字段。") + private String experienceType; } @Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeProject", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE) public static class Project extends DatedEntry { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java index 87745dd..ac41976 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java @@ -32,7 +32,11 @@ public class OnlineResumeDocxWriter { lines.add("教育经历"); for (var e : c.getEducationExperiences()) { lines.add(join(e.getSchool(), e.getMajor(), e.getDegree(), dates(e))); lines.add(e.getDescription()); } lines.add("工作经历"); - for (var e : c.getWorkExperiences()) { lines.add(join(e.getCompany(), e.getPosition(), dates(e))); lines.add(e.getDescription()); } + for (var e : c.getWorkExperiences()) { + String type = "INTERNSHIP".equals(e.getExperienceType()) ? "实习" + : "FULL_TIME".equals(e.getExperienceType()) ? "正式工作" : "类型未区分"; + lines.add(join(e.getCompany(), e.getPosition(), type, dates(e))); lines.add(e.getDescription()); + } lines.add("项目经历"); for (var e : c.getProjectExperiences()) { lines.add(join(e.getName(), e.getRole(), dates(e))); lines.add(e.getDescription()); } lines.add("技能特长"); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java index 7a793f3..37c51fa 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java @@ -29,7 +29,7 @@ public class OnlineResumeService { private final UserRoleGuard roles; private final ObjectMapper mapper; private final Validator validator; - @PersistenceContext private EntityManager em; + private final OnlineResumeWriteGuard writeGuard; @Transactional(readOnly = true) public OnlineResumeView get(Long userId) { @@ -40,8 +40,8 @@ public class OnlineResumeService { @Transactional public OnlineResumeView save(Long userId, OnlineResumeSaveRequest request) { - User u = lockUser(userId); - OnlineResume row = resumes.findById(userId).orElse(null); + User u = writeGuard.lockStudent(userId); + OnlineResume row = writeGuard.lockResume(userId); OnlineResumeView current = view(u, row); checkRevision(current.revision(), request.revision()); return persist(u, row, request.content(), current.content()); @@ -51,8 +51,8 @@ public class OnlineResumeService { @Transactional public OnlineResumeView editEntry(Long userId, String section, String id, Long revision, JsonNode entry, boolean delete) { - User u = lockUser(userId); - OnlineResume row = resumes.findById(userId).orElse(null); + User u = writeGuard.lockStudent(userId); + OnlineResume row = writeGuard.lockResume(userId); OnlineResumeView current = view(u, row); checkRevision(current.revision(), revision); OnlineResumeContent content = copy(current.content()); @@ -88,14 +88,6 @@ public class OnlineResumeService { if (index < 0) list.add(value); else list.set(index, value); } - private User lockUser(Long userId) { - // 锁定已存在的用户行,首次创建也有稳定锁对象;不锁定其他用户。 - User u = em.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); - if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND); - roles.requireStudent(u); - return u; - } - private void checkRevision(long actual, Long expected) { if (expected == null || actual != expected) throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeWriteGuard.java b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeWriteGuard.java new file mode 100644 index 0000000..631b6d6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeWriteGuard.java @@ -0,0 +1,64 @@ +package com.mydreamjob.onlineresume; + +import com.mydreamjob.auth.User; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.security.UserRoleGuard; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +/** 新旧简历写入口共用用户锁和在线简历版本;必须在调用者事务内执行。 */ +@Service +@RequiredArgsConstructor +@Transactional(propagation = Propagation.MANDATORY) +public class OnlineResumeWriteGuard { + private final UserRoleGuard roles; + private final OnlineResumeRepository resumes; + @PersistenceContext private EntityManager em; + + public User lockStudent(Long userId) { + User user = em.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); + if (user == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND); + em.refresh(user, LockModeType.PESSIMISTIC_WRITE); + roles.requireStudent(user); + return user; + } + + /** 锁定读避免 MySQL REPEATABLE READ 旧快照,也刷新一级缓存中的旧版本。 */ + public OnlineResume lockResume(Long userId) { + OnlineResume row = em.find(OnlineResume.class, userId, LockModeType.PESSIMISTIC_WRITE); + if (row != null) em.refresh(row, LockModeType.PESSIMISTIC_WRITE); + return row; + } + + public OnlineResume beforeLegacyWrite(Long userId, Long expectedRevision) { + lockStudent(userId); + OnlineResume row = lockResume(userId); + if (row == null) { + if (expectedRevision != null && expectedRevision != 0L) conflict(); + } else if (expectedRevision == null || expectedRevision != row.getRevision()) { + conflict(); + } + return row; + } + + /** 只在旧接口成功写入的同一事务内推进版本;不自动创建在线简历记录。 */ + public void afterLegacyWrite(OnlineResume row) { + if (row == null) return; + row.setRevision(Math.addExact(row.getRevision(), 1)); + row.setUpdatedAt(LocalDateTime.now().truncatedTo(ChronoUnit.MICROS)); + resumes.saveAndFlush(row); + } + + private void conflict() { + throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java index 24d5b57..8cf0f91 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java @@ -32,7 +32,11 @@ public class PreferenceController { return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } - @Operation(summary = "保存求职偏好", description = "仅 STUDENT;upsert,完全替换传入字段") + @Operation(summary = "保存求职偏好", description = "仅 STUDENT;upsert,仅更新非 null 字段,薪资单位为元/月;" + + "按合并后的期望薪资区间校验,非法返回 400/1001 且无部分更新。" + + "已有在线简历时必须携带最近 GET /profile/me/online-resume 的 revision,缺少或过期返回 409/6002;" + + "成功写入推进在线简历 revision,随后重新 GET 获取最新版本。" + + "未建立在线简历时兼容不传 revision,显式 revision 只能为 0。") @PutMapping("/preferences") public ResponseEntity>> savePreferences( @Valid @RequestBody StudentPreferenceRequest req, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java index 9ff2007..855f7f4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java @@ -30,6 +30,7 @@ public class PreferenceService { private final UserRepository userRepository; /** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */ private final CandidateScoringRefreshRequestService scoringRefreshRequestService; + private final com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard; private static final ObjectMapper mapper = new ObjectMapper(); // ── 角色校验 ────────────────────────────────────────────── @@ -53,12 +54,23 @@ public class PreferenceService { public Map savePreferences(Long studentUserId, StudentPreferenceRequest req) { assertStudentRole(studentUserId); - StudentPreference pref = preferenceRepository.findByStudentUserId(studentUserId) + var onlineResume = onlineResumeWriteGuard.beforeLegacyWrite(studentUserId, req.getRevision()); + StudentPreference pref = preferenceRepository.lockByStudentUserId(studentUserId) .orElse(StudentPreference.builder() .studentUserId(studentUserId) .remoteAllowed(false) .build()); + // 部分更新必须校验合并后的区间,且在修改任何受管实体字段前拒绝,避免部分写入。 + Integer salaryMin = req.getExpectedSalaryMin() == null + ? pref.getExpectedSalaryMin() : req.getExpectedSalaryMin(); + Integer salaryMax = req.getExpectedSalaryMax() == null + ? pref.getExpectedSalaryMax() : req.getExpectedSalaryMax(); + if (salaryMin != null && salaryMin < 0 || salaryMax != null && salaryMax < 0 + || salaryMin != null && salaryMax != null && salaryMin > salaryMax) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid merged salary range"); + } + if (req.getExpectedSalaryMin() != null) pref.setExpectedSalaryMin(req.getExpectedSalaryMin()); if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax()); if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities())); @@ -70,6 +82,7 @@ public class PreferenceService { StudentPreference saved = preferenceRepository.save(pref); // R01-D4-1:偏好更新影响 COMPLIANCE 维度 → 登记当前周期刷新。 scoringRefreshRequestService.requestRefresh(studentUserId, LocalDateTime.now()); + onlineResumeWriteGuard.afterLegacyWrite(onlineResume); return toView(saved); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java index e0d5ce0..50e834b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java @@ -9,4 +9,8 @@ import java.util.Optional; public interface StudentPreferenceRepository extends JpaRepository { Optional findByStudentUserId(Long studentUserId); + + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("select p from StudentPreference p where p.studentUserId = :userId") + Optional lockByStudentUserId(@org.springframework.data.repository.query.Param("userId") Long userId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java index 2c14b95..08748a8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java @@ -11,6 +11,11 @@ import java.util.List; @Data public class StudentPreferenceRequest { + /** 已保存在线简历时必填;从 GET /profile/me/online-resume 获取。 */ + @jakarta.validation.constraints.PositiveOrZero + @io.swagger.v3.oas.annotations.media.Schema(minimum = "0", description = "已保存在线简历时必填,取自 GET /profile/me/online-resume;缺少或过期返回409/6002。尚无在线记录时可省略或传0。成功后重新GET获取新版本。") + private Long revision; + @Min(value = 0, message = "expectedSalaryMin must not be negative") private Integer expectedSalaryMin; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java index 70c4948..53c7a11 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java @@ -92,6 +92,9 @@ public class ProfileController { @Operation( summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建。" + + "已有在线简历时必须携带最近 GET /profile/me/online-resume 的 revision," + + "缺少或过期返回 409/6002;成功写入推进在线简历 revision,随后重新 GET 获取最新版本。" + + "未建立在线简历时兼容不传 revision,显式 revision 只能为 0。" + "角色:仅 STUDENT(COMPANY/ADMIN 返回 403)。" + "安全约束:请求体中的 resumeUrl 为只读字段,客户端不得设置——" + "缺省或显式 null 表示不修改现有简历引用;" diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index 3ba3b6f..aa19012 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -42,6 +42,7 @@ public class ProfileService { private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; /** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */ private final CandidateScoringRefreshRequestService scoringRefreshRequestService; + private final com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -69,7 +70,8 @@ public class ProfileService { throw new BusinessException(ErrorCode.BAD_REQUEST, "resumeUrl cannot be modified through profile update"); } - StudentProfile profile = profileRepository.findByUserId(userId) + var onlineResume = onlineResumeWriteGuard.beforeLegacyWrite(userId, req.getRevision()); + StudentProfile profile = profileRepository.lockByUserId(userId) .orElse(StudentProfile.builder().userId(userId).build()); if (req.getRealName() != null) profile.setRealName(req.getRealName()); if (req.getBio() != null) profile.setBio(req.getBio()); @@ -81,6 +83,7 @@ public class ProfileService { StudentProfile saved = profileRepository.save(profile); // R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。 scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now()); + onlineResumeWriteGuard.afterLegacyWrite(onlineResume); return saved; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java index 8646b98..8163fcd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java @@ -11,6 +11,11 @@ import java.util.List; @Data public class StudentProfileUpdateRequest { + /** 已保存在线简历时必填;从 GET /profile/me/online-resume 获取。 */ + @jakarta.validation.constraints.PositiveOrZero + @io.swagger.v3.oas.annotations.media.Schema(minimum = "0", description = "已保存在线简历时必填,取自 GET /profile/me/online-resume;缺少或过期返回409/6002。尚无在线记录时可省略或传0。成功后重新GET获取新版本。") + private Long revision; + @Size(max = 50, message = "real name must be at most 50 characters") private String realName; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java index ccfa2df..fa52a1a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java @@ -37,6 +37,8 @@ class OnlineResumeIntegrationTest { @Autowired StudentPreferenceRepository preferences; @Autowired OnlineResumeRepository resumes; @Autowired OnlineResumeService service; + @Autowired ProfileService profileService; + @Autowired org.springframework.transaction.PlatformTransactionManager transactionManager; @Autowired com.mydreamjob.dataexport.PersonalDataCollector collector; @Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions; @Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker; @@ -208,8 +210,9 @@ class OnlineResumeIntegrationTest { @Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained() throws Exception { data(save(0,content())); mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) - .content("{\"realName\":\"既有接口更新\",\"skills\":[\"Java\",\"SQL\"]}")).andExpect(status().isOk()); + .content("{\"revision\":1,\"realName\":\"既有接口更新\",\"skills\":[\"Java\",\"SQL\"]}")).andExpect(status().isOk()); JsonNode d=read();assertEquals("既有接口更新",d.at("/content/basicInfo/realName").asText()); + assertEquals(2,d.path("revision").asLong()); assertEquals(2,d.at("/content/skills").size());assertEquals("PROFICIENT",d.at("/content/skills/0/level").asText()); } @Test void runtimeOpenApiDocumentsOnlineResumeAndPrivateContactFields() throws Exception { @@ -226,7 +229,245 @@ class OnlineResumeIntegrationTest { assertFalse(doc.at("/components/schemas/"+name+"/additionalProperties").asBoolean(true)); JsonNode candidate=doc.at("/components/schemas/CandidateDetailView/properties"); assertFalse(candidate.has("phone"));assertFalse(candidate.has("email")); + for(String name:List.of("StudentProfileUpdateRequest","StudentPreferenceRequest")) { + JsonNode revision=doc.at("/components/schemas/"+name+"/properties/revision"); + assertEquals("integer",revision.path("type").asText()); + assertEquals("int64",revision.path("format").asText()); + assertEquals(0,revision.path("minimum").asInt(-1)); + assertTrue(revision.path("description").asText().contains("409/6002")); + } + for(String path:List.of("/profile/me","/profile/me/preferences")) + assertTrue(doc.path("paths").path(path).path("put").path("description").asText().contains("409/6002")); String destination=System.getProperty("qsp.onlineResume.openapiOutput"); if(destination!=null)java.nio.file.Files.writeString(java.nio.file.Path.of(destination),json,StandardCharsets.UTF_8); } + + @Test void legacyWritesRequireCurrentRevisionAndFailuresAreAtomic() throws Exception { + JsonNode original=data(save(0,content())); + for(String endpoint:List.of("/profile/me","/profile/me/preferences")) { + for(String body:List.of("{}","{\"revision\":0}","{\"revision\":9}")) { + mvc.perform(put(endpoint).header("Authorization",token).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002)); + assertEquals(original,read()); + } + } + mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content("{\"revision\":-1}")).andExpect(status().isBadRequest()); + assertEquals(original,read()); + } + + @Test void legacyPreferenceWriteAdvancesRevisionAndBlocksStaleFullSave() throws Exception { + data(save(0,content())); + data(mvc.perform(put("/profile/me/preferences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content("{\"revision\":1,\"expectedSalaryMin\":500,\"preferredCities\":[]}"))); + JsonNode latest=read(); + assertEquals(2,latest.path("revision").asLong()); + assertEquals(500,latest.at("/content/jobIntention/expectedSalaryMin").asInt()); + assertEquals(0,latest.at("/content/jobIntention/preferredCities").size()); + assertEquals(20000,latest.at("/content/jobIntention/expectedSalaryMax").asInt()); + assertEquals("合成企业",latest.at("/content/workExperiences/0/company").asText()); + save(1,content()).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002)); + assertEquals(latest,read()); + } + + @Test void legacyPartialSalaryCannotCreateInvalidMergedRangeOrAdvanceRevision() throws Exception { + JsonNode original=data(save(0,content())); + for(String invalid:List.of("{\"revision\":1,\"expectedSalaryMin\":25000,\"preferredCities\":[\"不能写入\"]}", + "{\"revision\":1,\"expectedSalaryMax\":5000}")) { + mvc.perform(put("/profile/me/preferences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON).content(invalid)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(1001)); + assertEquals(original,read()); + } + } + + @Test void legacyClientsWithoutOnlineRecordRemainCompatibleAndReadsDoNotCreateRecord() throws Exception { + read();assertFalse(resumes.existsById(user.getId())); + data(mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content("{\"realName\":\"旧客户端\"}"))); + data(mvc.perform(put("/profile/me/preferences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content("{\"expectedSalaryMin\":500}"))); + assertFalse(resumes.existsById(user.getId())); + assertEquals(0,read().path("revision").asLong()); + assertEquals("旧客户端",read().at("/content/basicInfo/realName").asText()); + mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content("{\"revision\":7,\"realName\":\"不能覆盖\"}")) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002)); + } + + @Test void legacyClearIsSynchronizedAndAttachmentIsNeverChanged() throws Exception { + profiles.save(StudentProfile.builder().userId(user.getId()).resumeUrl(user.getId()+"_unchanged0001.pdf") + .resumeOriginalName("合成.pdf").resumeSize(42L).build()); + data(save(0,content())); + data(mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content("{\"revision\":1,\"realName\":\"\",\"bio\":\"\",\"skills\":[]}"))); + JsonNode d=read();assertEquals(2,d.path("revision").asLong()); + assertEquals("",d.at("/content/basicInfo/realName").asText()); + assertEquals("",d.at("/content/selfEvaluation").asText()); + assertEquals(0,d.at("/content/skills").size()); + assertEquals(42,d.at("/attachment/size").asInt()); + assertEquals(user.getId()+"_unchanged0001.pdf",profiles.findByUserId(user.getId()).orElseThrow().getResumeUrl()); + } + + @Test void legacyRevisionIsBoundToAuthenticatedUserNotInjectedIdentity() throws Exception { + JsonNode original=data(save(0,content()));User other=createUser(User.Role.STUDENT); + service.save(other.getId(),new OnlineResumeSaveRequest(0L,content())); + data(mvc.perform(put("/profile/me").header("Authorization",bearer(other)) + .param("userId",user.getId().toString()).contentType(MediaType.APPLICATION_JSON) + .content("{\"revision\":1,\"realName\":\"其他账号更新\"}"))); + assertEquals(original,read()); + assertEquals("其他账号更新",service.get(other.getId()).content().getBasicInfo().getRealName()); + assertEquals(2,service.get(other.getId()).revision()); + } + + @Test void concurrentLegacyAndOnlineSaveHaveExactlyOneWinner() throws Exception { + for(boolean preference:List.of(false,true)) { + long revision=data(save(read().path("revision").asLong(),content())).path("revision").asLong(); + var pool=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1); + try { + var legacy=pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS)); + String body=preference?"{\"revision\":"+revision+",\"expectedSalaryMin\":500}": + "{\"revision\":"+revision+",\"realName\":\"并发旧入口\"}"; + return mapper.readTree(mvc.perform(put(preference?"/profile/me/preferences":"/profile/me") + .header("Authorization",token).contentType(MediaType.APPLICATION_JSON).content(body)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("code").asInt();}); + var online=pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS)); + return mapper.readTree(save(revision,content()).andReturn().getResponse() + .getContentAsString(StandardCharsets.UTF_8)).path("code").asInt();}); + start.countDown();var results=List.of(legacy.get(30,TimeUnit.SECONDS),online.get(30,TimeUnit.SECONDS)); + assertEquals(1,results.stream().filter(x->x==200).count()); + assertEquals(1,results.stream().filter(x->x==6002).count()); + assertEquals(revision+1,read().path("revision").asLong()); + } finally {pool.shutdownNow();} + } + } + + @Test void staleTransactionSnapshotCannotBypassCurrentRevisionCheck() throws Exception { + data(save(0,content()));var pool=Executors.newSingleThreadExecutor(); + var readReady=new CountDownLatch(1);var changed=new CountDownLatch(1); + try { + var stale=pool.submit(()->{ + var ex=assertThrows(com.mydreamjob.common.exception.BusinessException.class,()-> + new org.springframework.transaction.support.TransactionTemplate(transactionManager).execute(s->{ + assertEquals(1,service.get(user.getId()).revision());readReady.countDown(); + try {assertTrue(changed.await(10,TimeUnit.SECONDS));} catch(InterruptedException e){throw new RuntimeException(e);} + var req=new com.mydreamjob.profile.dto.StudentProfileUpdateRequest();req.setRevision(1L);req.setRealName("旧快照不能覆盖"); + profileService.updateProfile(user.getId(),req);return null; + })); + return ex.getErrorCodeInt(); + }); + assertTrue(readReady.await(10,TimeUnit.SECONDS));data(save(1,content()));changed.countDown(); + assertEquals(6002,stale.get(30,TimeUnit.SECONDS));assertEquals(2,read().path("revision").asLong()); + } finally {changed.countDown();pool.shutdownNow();} + } + + @Test void logoutRevokesAccessAndRefreshWhileOtherSessionsRemainUsable() throws Exception { + var a=sessions.createSession(user,java.time.Instant.now(),new com.mydreamjob.audit.AuditContext()); + var b=sessions.createSession(user,java.time.Instant.now(),new com.mydreamjob.audit.AuditContext()); + String bearerA="Bearer "+a.accessToken(),bearerB="Bearer "+b.accessToken(); + data(mvc.perform(post("/auth/logout").header("Authorization",bearerA))); + mvc.perform(get(PATH).header("Authorization",bearerA)).andExpect(status().isUnauthorized()); + mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsBytes(Map.of("refreshToken",a.refreshToken())))).andExpect(status().isUnauthorized()); + data(mvc.perform(get(PATH).header("Authorization",bearerB))); + data(mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsBytes(Map.of("refreshToken",b.refreshToken()))))); + } + + @Test void workExperienceTypesPersistAcrossFullSaveAndNewSession() throws Exception { + long revision=0; + for(String type:List.of("FULL_TIME","INTERNSHIP")) { + var c=content();c.getWorkExperiences().get(0).setExperienceType(type); + JsonNode saved=data(save(revision,c));revision++; + assertEquals(type,saved.at("/content/workExperiences/0/experienceType").asText()); + JsonNode fresh=data(mvc.perform(get(PATH).header("Authorization",bearer(user)))); + assertEquals(saved,fresh);assertEquals(revision,fresh.path("revision").asLong()); + } + } + + @Test void workEntryCrudCanChangeAndClearTypeWithSharedRevision() throws Exception { + var work=content().getWorkExperiences().get(0);work.setExperienceType("INTERNSHIP"); + JsonNode created=data(mvc.perform(post(PATH+"/work-experiences").header("Authorization",token) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",0,"entry",work))))); + String id=created.at("/content/workExperiences/0/id").asText(); + assertEquals("INTERNSHIP",data(mvc.perform(get(PATH+"/work-experiences/"+id).header("Authorization",token))) + .path("experienceType").asText()); + work.setExperienceType("FULL_TIME"); + JsonNode updated=data(mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",1,"entry",work))))); + assertEquals("FULL_TIME",updated.at("/content/workExperiences/0/experienceType").asText()); + var omitted=mapper.valueToTree(work);((com.fasterxml.jackson.databind.node.ObjectNode)omitted).remove("experienceType"); + JsonNode cleared=data(mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",2,"entry",omitted))))); + assertUnclassified(cleared.at("/content/workExperiences/0/experienceType")); + ((com.fasterxml.jackson.databind.node.ObjectNode)omitted).putNull("experienceType"); + cleared=data(mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",3,"entry",omitted))))); + assertUnclassified(cleared.at("/content/workExperiences/0/experienceType")); + mvc.perform(put(PATH+"/work-experiences/"+id).header("Authorization",token) + .contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsBytes(Map.of("revision",2,"entry",work)))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002)); + assertEquals(cleared,read()); + data(mvc.perform(delete(PATH+"/work-experiences/"+id).param("revision","4").header("Authorization",token))); + assertEquals(0,read().at("/content/workExperiences").size()); + } + + @Test void invalidWorkExperienceTypesAreRejectedAtomicallyByBothWriteRoutes() throws Exception { + JsonNode original=data(save(0,content())); + for(String invalid:List.of("","PART_TIME","internship"," INTERNSHIP ","实习")) { + var c=content();c.getWorkExperiences().get(0).setExperienceType(invalid); + save(1,c).andExpect(status().isBadRequest()); + mvc.perform(post(PATH+"/work-experiences").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsBytes(Map.of("revision",1,"entry",c.getWorkExperiences().get(0))))) + .andExpect(status().isBadRequest()); + assertEquals(original,read()); + } + } + + @Test void historicalWorkWithoutTypeRemainsUnclassifiedWithoutReadSideEffects() throws Exception { + var old=(com.fasterxml.jackson.databind.node.ObjectNode)mapper.valueToTree(content()); + ((com.fasterxml.jackson.databind.node.ObjectNode)old.path("workExperiences").get(0)).remove("experienceType"); + var row=new OnlineResume();row.setUserId(user.getId());row.setRevision(7); + row.setContentJson(mapper.writeValueAsString(old));row.setUpdatedAt(java.time.LocalDateTime.now());resumes.saveAndFlush(row); + String before=resumes.findById(user.getId()).orElseThrow().getContentJson(); + JsonNode current=read();assertUnclassified(current.at("/content/workExperiences/0/experienceType")); + assertEquals(7,current.path("revision").asLong()); + assertEquals(before,resumes.findById(user.getId()).orElseThrow().getContentJson()); + var c=mapper.treeToValue(current.path("content"),OnlineResumeContent.class); + JsonNode saved=data(save(7,c));assertUnclassified(saved.at("/content/workExperiences/0/experienceType")); + assertEquals("合成企业",saved.at("/content/workExperiences/0/company").asText()); + } + + @Test void workExperienceTypeIsAccountScopedAndIncludedInDocx() throws Exception { + var c=content();c.getWorkExperiences().get(0).setExperienceType("INTERNSHIP"); + var formal=new OnlineResumeContent.Work();formal.setCompany("正式企业");formal.setPosition("开发"); + formal.setStartDate(LocalDate.of(2024,1,1));formal.setOngoing(true);formal.setExperienceType("FULL_TIME");c.getWorkExperiences().add(formal); + var unknown=new OnlineResumeContent.Work();unknown.setCompany("未分类企业");unknown.setPosition("开发"); + unknown.setStartDate(LocalDate.of(2024,1,1));unknown.setOngoing(true);c.getWorkExperiences().add(unknown); + data(save(0,c)); + User other=createUser(User.Role.STUDENT); + assertEquals(0,data(mvc.perform(get(PATH+"/work-experiences").header("Authorization",bearer(other)))).size()); + byte[] bytes=mvc.perform(get(PATH+"/download").header("Authorization",token)).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsByteArray(); + String xml=null;try(var zip=new ZipInputStream(new ByteArrayInputStream(bytes),StandardCharsets.UTF_8)) { + java.util.zip.ZipEntry e;while((e=zip.getNextEntry())!=null)if(e.getName().equals("word/document.xml"))xml=new String(zip.readAllBytes(),StandardCharsets.UTF_8); + } + assertNotNull(xml);assertTrue(xml.contains("实习"));assertTrue(xml.contains("正式工作"));assertTrue(xml.contains("类型未区分")); + } + + @Test void runtimeContractDocumentsOptionalNullableWorkExperienceType() throws Exception { + JsonNode doc=mapper.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)); + JsonNode work=doc.at("/components/schemas/OnlineResumeWork"); + JsonNode type=work.path("properties").path("experienceType"); + Set types=new HashSet<>();type.path("type").forEach(t->types.add(t.asText())); + assertEquals(Set.of("string","null"),types); + assertEquals("^(FULL_TIME|INTERNSHIP)$",type.path("pattern").asText()); + assertTrue(type.path("description").asText().contains("未区分")); + work.path("required").forEach(required->assertNotEquals("experienceType",required.asText())); + } + + private void assertUnclassified(JsonNode type) { + // 全局 NON_NULL 序列化可省略空字段;二者都不能被误认成 FULL_TIME。 + assertTrue(type.isMissingNode() || type.isNull(), "未分类经历不得推断为正式工作或实习"); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java index 226b7f2..61c614c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java @@ -36,6 +36,7 @@ class PreferenceServiceTest { /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ @Mock private UserRoleGuard userRoleGuard; + @Mock private com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard; @InjectMocks private PreferenceService preferenceService; @@ -48,6 +49,8 @@ class PreferenceServiceTest { void setUp() { // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); + lenient().when(preferenceRepository.lockByStudentUserId(any())) + .thenAnswer(inv -> preferenceRepository.findByStudentUserId(inv.getArgument(0))); pref = StudentPreference.builder() .id(1L).studentUserId(STUDENT_ID) @@ -163,6 +166,54 @@ class PreferenceServiceTest { // ── 薪资范围错误 ───────────────────────────────────────── + @Test + void partialSalaryMin_mustBeValidatedAgainstStoredMaximum_withoutPartialMutation() { + mockStudentRole(STUDENT_ID); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(pref)); + StudentPreferenceRequest req = new StudentPreferenceRequest(); + req.setExpectedSalaryMin(25000); + req.setPreferredCities(List.of("不应写入")); + + BusinessException ex = assertThrows(BusinessException.class, + () -> preferenceService.savePreferences(STUDENT_ID, req)); + + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + assertEquals(10000, pref.getExpectedSalaryMin()); + assertEquals(20000, pref.getExpectedSalaryMax()); + assertEquals("[\"杭州\",\"上海\"]", pref.getPreferredCities()); + verify(preferenceRepository, never()).save(any()); + verifyNoInteractions(scoringRefreshRequestService); + } + + @Test + void partialSalaryMax_mustBeValidatedAgainstStoredMinimum() { + mockStudentRole(STUDENT_ID); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(pref)); + StudentPreferenceRequest req = new StudentPreferenceRequest(); + req.setExpectedSalaryMax(5000); + + BusinessException ex = assertThrows(BusinessException.class, + () -> preferenceService.savePreferences(STUDENT_ID, req)); + + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); + assertEquals(20000, pref.getExpectedSalaryMax()); + verify(preferenceRepository, never()).save(any()); + } + + @Test + void partialSalaryWithinStoredRange_isSavedInYuanWithoutGuessingUnits() { + mockStudentRole(STUDENT_ID); + when(preferenceRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of(pref)); + when(preferenceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + StudentPreferenceRequest req = new StudentPreferenceRequest(); + req.setExpectedSalaryMin(500); + + var result = preferenceService.savePreferences(STUDENT_ID, req); + + assertEquals(500, result.get("expectedSalaryMin")); + assertEquals(20000, result.get("expectedSalaryMax")); + } + @Test void savePreferences_salaryMinGreaterThanMax_shouldBeInvalid() { StudentPreferenceRequest req = buildRequest(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java index cdf4b80..e665c5f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java @@ -30,6 +30,7 @@ class ProfileServiceTest { */ @Mock private com.mydreamjob.security.UserRoleGuard userRoleGuard; + @Mock private com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard; @InjectMocks private ProfileService profileService; @@ -38,6 +39,8 @@ class ProfileServiceTest { @BeforeEach void setUp() { + lenient().when(profileRepository.lockByUserId(any())) + .thenAnswer(inv -> profileRepository.findByUserId(inv.getArgument(0))); profile = StudentProfile.builder() .id(1L) .userId(10L) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java index 2c1aca4..e897414 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java @@ -56,6 +56,7 @@ class ResumeOwnershipServiceTest { @Mock private UserRoleGuard userRoleGuard; @Mock private ChatResumeLifecycle chatResumeLifecycle; @Mock private CandidateScoringRefreshRequestService scoringRefreshRequestService; + @Mock private com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard; @Spy private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); @Spy private ResumeOwnershipVerifier resumeOwnershipVerifier = new ResumeOwnershipVerifier(); @@ -118,7 +119,7 @@ class ResumeOwnershipServiceTest { String existing = STUDENT + "_keep0001.pdf"; StudentProfile profile = StudentProfile.builder() .id(1L).userId(STUDENT).realName("张三").resumeUrl(existing).build(); - when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); + when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); StudentProfile saved = profileService.updateProfile(STUDENT, request("李四"));