Browse Source

fix(resume): protect legacy writes with revision and classify internships

fix/mysql57-module-migrations-20261009
Zhang Shun 6 days ago
parent
commit
d5889ba98c
  1. 21
      docs/openapi-rc.json
  2. 16
      docs/verify_openapi.py
  3. 4
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java
  4. 6
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java
  5. 18
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java
  6. 64
      mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeWriteGuard.java
  7. 6
      mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java
  8. 15
      mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java
  9. 4
      mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java
  10. 5
      mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java
  11. 3
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
  12. 5
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java
  13. 5
      mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java
  14. 243
      mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java
  15. 51
      mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java
  16. 3
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java
  17. 3
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java

21
docs/openapi-rc.json

@ -5326,7 +5326,7 @@
"个人资料" "个人资料"
], ],
"summary": "更新学生资料", "summary": "更新学生资料",
"description": "仅更新传入的非 null 字段;不存在则自动创建", "description": "仅更新传入的非 null 字段;不存在则自动创建。已有在线简历时必须携带其最新 revision,缺少或过期返回409/6002;成功写入推进版本,须重新 GET 在线简历获取最新版本。尚无在线简历记录的旧客户端可省略 revision 或传0。",
"operationId": "updateMyProfile", "operationId": "updateMyProfile",
"requestBody": { "requestBody": {
"content": { "content": {
@ -6309,7 +6309,7 @@
"求职偏好" "求职偏好"
], ],
"summary": "保存求职偏好", "summary": "保存求职偏好",
"description": "仅 STUDENT;upsert,完全替换传入字段", "description": "仅 STUDENT;upsert,非 null 字段部分更新,列表[]清空;薪资以元/月计,按与已存值合并后的区间校验。已有在线简历时必须携带其最新 revision,缺少或过期返回409/6002;成功写入推进版本,须重新 GET 在线简历获取最新版本。尚无在线简历记录时可省略 revision 或传0。",
"operationId": "savePreferences", "operationId": "savePreferences",
"requestBody": { "requestBody": {
"content": { "content": {
@ -15987,6 +15987,12 @@
"StudentPreferenceRequest": { "StudentPreferenceRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
"revision": {
"type": "integer",
"format": "int64",
"minimum": 0,
"description": "已保存在线简历时必填,取自 GET /profile/me/online-resume;缺少或过期返回409/6002。尚无在线记录时可省略或传0。成功后重新GET获取新版本。"
},
"expectedSalaryMin": { "expectedSalaryMin": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@ -16048,6 +16054,12 @@
"StudentProfileUpdateRequest": { "StudentProfileUpdateRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
"revision": {
"type": "integer",
"format": "int64",
"minimum": 0,
"description": "已保存在线简历时必填,取自 GET /profile/me/online-resume;缺少或过期返回409/6002。尚无在线记录时可省略或传0。成功后重新GET获取新版本。"
},
"realName": { "realName": {
"type": "string", "type": "string",
"maxLength": 50, "maxLength": 50,
@ -21557,6 +21569,11 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"experienceType": {
"type": ["string", "null"],
"pattern": "^(FULL_TIME|INTERNSHIP)$",
"description": "经历类型:FULL_TIME 正式工作、INTERNSHIP 实习;省略或null表示未区分,不推断历史记录。条目PUT为完整替换,保留类型须回传该字段。"
},
"id": { "id": {
"type": "string", "type": "string",
"pattern": "[a-f0-9\\-]{36}" "pattern": "[a-f0-9\\-]{36}"

16
docs/verify_openapi.py

@ -904,5 +904,21 @@ assert len({item['$ref'] for item in _entry['oneOf']}) == 4
for _candidate in ('CandidateItemView', 'CandidateDetailView'): for _candidate in ('CandidateItemView', 'CandidateDetailView'):
assert not ({'phone', 'email'} & set(_online_schemas[_candidate]['properties'])) assert not ({'phone', 'email'} & set(_online_schemas[_candidate]['properties']))
print(' Online resume: 4 paths / 8 operations, contacts and experience schemas verified') 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("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

4
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeContent.java

@ -77,6 +77,10 @@ public class OnlineResumeContent {
public static class Work extends DatedEntry { public static class Work extends DatedEntry {
@NotBlank @Size(max = 150) private String company; @NotBlank @Size(max = 150) private String company;
@NotBlank @Size(max = 120) private String position; @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) @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 { public static class Project extends DatedEntry {

6
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeDocxWriter.java

@ -32,7 +32,11 @@ public class OnlineResumeDocxWriter {
lines.add("教育经历"); lines.add("教育经历");
for (var e : c.getEducationExperiences()) { lines.add(join(e.getSchool(), e.getMajor(), e.getDegree(), dates(e))); lines.add(e.getDescription()); } for (var e : c.getEducationExperiences()) { lines.add(join(e.getSchool(), e.getMajor(), e.getDegree(), dates(e))); lines.add(e.getDescription()); }
lines.add("工作经历"); 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("项目经历"); lines.add("项目经历");
for (var e : c.getProjectExperiences()) { lines.add(join(e.getName(), e.getRole(), dates(e))); lines.add(e.getDescription()); } for (var e : c.getProjectExperiences()) { lines.add(join(e.getName(), e.getRole(), dates(e))); lines.add(e.getDescription()); }
lines.add("技能特长"); lines.add("技能特长");

18
mydreamjob-server/src/main/java/com/mydreamjob/onlineresume/OnlineResumeService.java

@ -29,7 +29,7 @@ public class OnlineResumeService {
private final UserRoleGuard roles; private final UserRoleGuard roles;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Validator validator; private final Validator validator;
@PersistenceContext private EntityManager em; private final OnlineResumeWriteGuard writeGuard;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public OnlineResumeView get(Long userId) { public OnlineResumeView get(Long userId) {
@ -40,8 +40,8 @@ public class OnlineResumeService {
@Transactional @Transactional
public OnlineResumeView save(Long userId, OnlineResumeSaveRequest request) { public OnlineResumeView save(Long userId, OnlineResumeSaveRequest request) {
User u = lockUser(userId); User u = writeGuard.lockStudent(userId);
OnlineResume row = resumes.findById(userId).orElse(null); OnlineResume row = writeGuard.lockResume(userId);
OnlineResumeView current = view(u, row); OnlineResumeView current = view(u, row);
checkRevision(current.revision(), request.revision()); checkRevision(current.revision(), request.revision());
return persist(u, row, request.content(), current.content()); return persist(u, row, request.content(), current.content());
@ -51,8 +51,8 @@ public class OnlineResumeService {
@Transactional @Transactional
public OnlineResumeView editEntry(Long userId, String section, String id, Long revision, public OnlineResumeView editEntry(Long userId, String section, String id, Long revision,
JsonNode entry, boolean delete) { JsonNode entry, boolean delete) {
User u = lockUser(userId); User u = writeGuard.lockStudent(userId);
OnlineResume row = resumes.findById(userId).orElse(null); OnlineResume row = writeGuard.lockResume(userId);
OnlineResumeView current = view(u, row); OnlineResumeView current = view(u, row);
checkRevision(current.revision(), revision); checkRevision(current.revision(), revision);
OnlineResumeContent content = copy(current.content()); OnlineResumeContent content = copy(current.content());
@ -88,14 +88,6 @@ public class OnlineResumeService {
if (index < 0) list.add(value); else list.set(index, value); 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) { private void checkRevision(long actual, Long expected) {
if (expected == null || actual != expected) if (expected == null || actual != expected)
throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT); throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT);

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

6
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))); 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") @PutMapping("/preferences")
public ResponseEntity<ApiResponse<Map<String, Object>>> savePreferences( public ResponseEntity<ApiResponse<Map<String, Object>>> savePreferences(
@Valid @RequestBody StudentPreferenceRequest req, @Valid @RequestBody StudentPreferenceRequest req,

15
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java

@ -30,6 +30,7 @@ public class PreferenceService {
private final UserRepository userRepository; private final UserRepository userRepository;
/** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */ /** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
private final com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard;
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
// ── 角色校验 ────────────────────────────────────────────── // ── 角色校验 ──────────────────────────────────────────────
@ -53,12 +54,23 @@ public class PreferenceService {
public Map<String, Object> savePreferences(Long studentUserId, StudentPreferenceRequest req) { public Map<String, Object> savePreferences(Long studentUserId, StudentPreferenceRequest req) {
assertStudentRole(studentUserId); assertStudentRole(studentUserId);
StudentPreference pref = preferenceRepository.findByStudentUserId(studentUserId) var onlineResume = onlineResumeWriteGuard.beforeLegacyWrite(studentUserId, req.getRevision());
StudentPreference pref = preferenceRepository.lockByStudentUserId(studentUserId)
.orElse(StudentPreference.builder() .orElse(StudentPreference.builder()
.studentUserId(studentUserId) .studentUserId(studentUserId)
.remoteAllowed(false) .remoteAllowed(false)
.build()); .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.getExpectedSalaryMin() != null) pref.setExpectedSalaryMin(req.getExpectedSalaryMin());
if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax()); if (req.getExpectedSalaryMax() != null) pref.setExpectedSalaryMax(req.getExpectedSalaryMax());
if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities())); if (req.getPreferredCities() != null) pref.setPreferredCities(toJson(req.getPreferredCities()));
@ -70,6 +82,7 @@ public class PreferenceService {
StudentPreference saved = preferenceRepository.save(pref); StudentPreference saved = preferenceRepository.save(pref);
// R01-D4-1:偏好更新影响 COMPLIANCE 维度 → 登记当前周期刷新。 // R01-D4-1:偏好更新影响 COMPLIANCE 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, LocalDateTime.now()); scoringRefreshRequestService.requestRefresh(studentUserId, LocalDateTime.now());
onlineResumeWriteGuard.afterLegacyWrite(onlineResume);
return toView(saved); return toView(saved);
} }

4
mydreamjob-server/src/main/java/com/mydreamjob/preference/StudentPreferenceRepository.java

@ -9,4 +9,8 @@ import java.util.Optional;
public interface StudentPreferenceRepository extends JpaRepository<StudentPreference, Long> { public interface StudentPreferenceRepository extends JpaRepository<StudentPreference, Long> {
Optional<StudentPreference> findByStudentUserId(Long studentUserId); Optional<StudentPreference> 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<StudentPreference> lockByStudentUserId(@org.springframework.data.repository.query.Param("userId") Long userId);
} }

5
mydreamjob-server/src/main/java/com/mydreamjob/preference/dto/StudentPreferenceRequest.java

@ -11,6 +11,11 @@ import java.util.List;
@Data @Data
public class StudentPreferenceRequest { 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") @Min(value = 0, message = "expectedSalaryMin must not be negative")
private Integer expectedSalaryMin; private Integer expectedSalaryMin;

3
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java

@ -92,6 +92,9 @@ public class ProfileController {
@Operation( @Operation(
summary = "更新学生资料", summary = "更新学生资料",
description = "仅更新传入的非 null 字段;不存在则自动创建。" description = "仅更新传入的非 null 字段;不存在则自动创建。"
+ "已有在线简历时必须携带最近 GET /profile/me/online-resume 的 revision,"
+ "缺少或过期返回 409/6002;成功写入推进在线简历 revision,随后重新 GET 获取最新版本。"
+ "未建立在线简历时兼容不传 revision,显式 revision 只能为 0。"
+ "角色:仅 STUDENT(COMPANY/ADMIN 返回 403)。" + "角色:仅 STUDENT(COMPANY/ADMIN 返回 403)。"
+ "安全约束:请求体中的 resumeUrl 为只读字段,客户端不得设置——" + "安全约束:请求体中的 resumeUrl 为只读字段,客户端不得设置——"
+ "缺省或显式 null 表示不修改现有简历引用;" + "缺省或显式 null 表示不修改现有简历引用;"

5
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java

@ -42,6 +42,7 @@ public class ProfileService {
private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle;
/** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */ /** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
private final com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@ -69,7 +70,8 @@ public class ProfileService {
throw new BusinessException(ErrorCode.BAD_REQUEST, throw new BusinessException(ErrorCode.BAD_REQUEST,
"resumeUrl cannot be modified through profile update"); "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()); .orElse(StudentProfile.builder().userId(userId).build());
if (req.getRealName() != null) profile.setRealName(req.getRealName()); if (req.getRealName() != null) profile.setRealName(req.getRealName());
if (req.getBio() != null) profile.setBio(req.getBio()); if (req.getBio() != null) profile.setBio(req.getBio());
@ -81,6 +83,7 @@ public class ProfileService {
StudentProfile saved = profileRepository.save(profile); StudentProfile saved = profileRepository.save(profile);
// R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。 // R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now()); scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now());
onlineResumeWriteGuard.afterLegacyWrite(onlineResume);
return saved; return saved;
} }

5
mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java

@ -11,6 +11,11 @@ import java.util.List;
@Data @Data
public class StudentProfileUpdateRequest { 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") @Size(max = 50, message = "real name must be at most 50 characters")
private String realName; private String realName;

243
mydreamjob-server/src/test/java/com/mydreamjob/onlineresume/OnlineResumeIntegrationTest.java

@ -37,6 +37,8 @@ class OnlineResumeIntegrationTest {
@Autowired StudentPreferenceRepository preferences; @Autowired StudentPreferenceRepository preferences;
@Autowired OnlineResumeRepository resumes; @Autowired OnlineResumeRepository resumes;
@Autowired OnlineResumeService service; @Autowired OnlineResumeService service;
@Autowired ProfileService profileService;
@Autowired org.springframework.transaction.PlatformTransactionManager transactionManager;
@Autowired com.mydreamjob.dataexport.PersonalDataCollector collector; @Autowired com.mydreamjob.dataexport.PersonalDataCollector collector;
@Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions; @Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions;
@Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker; @Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker;
@ -208,8 +210,9 @@ class OnlineResumeIntegrationTest {
@Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained() throws Exception { @Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained() throws Exception {
data(save(0,content())); data(save(0,content()));
mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) 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()); 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()); assertEquals(2,d.at("/content/skills").size());assertEquals("PROFICIENT",d.at("/content/skills/0/level").asText());
} }
@Test void runtimeOpenApiDocumentsOnlineResumeAndPrivateContactFields() throws Exception { @Test void runtimeOpenApiDocumentsOnlineResumeAndPrivateContactFields() throws Exception {
@ -226,7 +229,245 @@ class OnlineResumeIntegrationTest {
assertFalse(doc.at("/components/schemas/"+name+"/additionalProperties").asBoolean(true)); assertFalse(doc.at("/components/schemas/"+name+"/additionalProperties").asBoolean(true));
JsonNode candidate=doc.at("/components/schemas/CandidateDetailView/properties"); JsonNode candidate=doc.at("/components/schemas/CandidateDetailView/properties");
assertFalse(candidate.has("phone"));assertFalse(candidate.has("email")); 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"); 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); 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<String> 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(), "未分类经历不得推断为正式工作或实习");
}
} }

51
mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java

@ -36,6 +36,7 @@ class PreferenceServiceTest {
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock @Mock
private UserRoleGuard userRoleGuard; private UserRoleGuard userRoleGuard;
@Mock private com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard;
@InjectMocks @InjectMocks
private PreferenceService preferenceService; private PreferenceService preferenceService;
@ -48,6 +49,8 @@ class PreferenceServiceTest {
void setUp() { void setUp() {
// R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。 // R01-B/B1:让 guard 桩委托真实实现,保持既有 403/404 断言不变。
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository);
lenient().when(preferenceRepository.lockByStudentUserId(any()))
.thenAnswer(inv -> preferenceRepository.findByStudentUserId(inv.getArgument(0)));
pref = StudentPreference.builder() pref = StudentPreference.builder()
.id(1L).studentUserId(STUDENT_ID) .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 @Test
void savePreferences_salaryMinGreaterThanMax_shouldBeInvalid() { void savePreferences_salaryMinGreaterThanMax_shouldBeInvalid() {
StudentPreferenceRequest req = buildRequest(); StudentPreferenceRequest req = buildRequest();

3
mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java

@ -30,6 +30,7 @@ class ProfileServiceTest {
*/ */
@Mock @Mock
private com.mydreamjob.security.UserRoleGuard userRoleGuard; private com.mydreamjob.security.UserRoleGuard userRoleGuard;
@Mock private com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard;
@InjectMocks @InjectMocks
private ProfileService profileService; private ProfileService profileService;
@ -38,6 +39,8 @@ class ProfileServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
lenient().when(profileRepository.lockByUserId(any()))
.thenAnswer(inv -> profileRepository.findByUserId(inv.getArgument(0)));
profile = StudentProfile.builder() profile = StudentProfile.builder()
.id(1L) .id(1L)
.userId(10L) .userId(10L)

3
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java

@ -56,6 +56,7 @@ class ResumeOwnershipServiceTest {
@Mock private UserRoleGuard userRoleGuard; @Mock private UserRoleGuard userRoleGuard;
@Mock private ChatResumeLifecycle chatResumeLifecycle; @Mock private ChatResumeLifecycle chatResumeLifecycle;
@Mock private CandidateScoringRefreshRequestService scoringRefreshRequestService; @Mock private CandidateScoringRefreshRequestService scoringRefreshRequestService;
@Mock private com.mydreamjob.onlineresume.OnlineResumeWriteGuard onlineResumeWriteGuard;
@Spy private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); @Spy private ResumeFileValidator resumeFileValidator = new ResumeFileValidator();
@Spy private ResumeOwnershipVerifier resumeOwnershipVerifier = new ResumeOwnershipVerifier(); @Spy private ResumeOwnershipVerifier resumeOwnershipVerifier = new ResumeOwnershipVerifier();
@ -118,7 +119,7 @@ class ResumeOwnershipServiceTest {
String existing = STUDENT + "_keep0001.pdf"; String existing = STUDENT + "_keep0001.pdf";
StudentProfile profile = StudentProfile.builder() StudentProfile profile = StudentProfile.builder()
.id(1L).userId(STUDENT).realName("张三").resumeUrl(existing).build(); .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)); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
StudentProfile saved = profileService.updateProfile(STUDENT, request("李四")); StudentProfile saved = profileService.updateProfile(STUDENT, request("李四"));

Loading…
Cancel
Save