Browse Source

feat(application): unlock communication after company acceptance

fix/mysql57-module-migrations-20261009
Zhang Shun 5 days ago
parent
commit
7f5c22575f
  1. 2730
      docs/openapi-rc.json
  2. 17
      docs/verify_openapi.py
  3. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java
  4. 41
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java
  5. 58
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java
  6. 16
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationCommunicationPolicy.java
  7. 1
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  8. 2
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java
  9. 11
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java
  10. 2
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java
  11. 4
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java
  12. 17
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  13. 4
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  14. 2
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java
  15. 2
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java
  16. 5
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  17. 2
      mydreamjob-server/src/main/resources/db/migration/V61__application_company_acceptance.sql
  18. 189
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceIntegrationTest.java
  19. 26
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlIntegrationTest.java
  20. 56
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlMigrationTest.java
  21. 80
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceServiceTest.java
  22. 31
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationCommunicationPolicyTest.java
  23. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  24. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  25. 15
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java
  26. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  27. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java
  28. 2
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java
  29. 6
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java

2730
docs/openapi-rc.json

File diff suppressed because it is too large

17
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 206, f"Expected 206 paths, got {len(d['paths'])}" assert len(d['paths']) == 207, f"Expected 207 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
@ -719,7 +719,7 @@ print(' S03 company resume download contract validated; candidate DTOs remain r
# 统计 operation 总数并校验当前接口契约基线。 # 统计 operation 总数并校验当前接口契约基线。
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 254, f"Expected 254 operations, got {operations}" assert operations == 255, f"Expected 255 operations, got {operations}"
assert 'post' in paths['/conversations/{id}/resume-messages'] assert 'post' in paths['/conversations/{id}/resume-messages']
assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys() assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys()
assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys() assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys()
@ -978,5 +978,18 @@ _items = resolve_local_ref(d, _search_response['properties']['data']['$ref'])['p
assert _items['type'] == 'array' and _items.get('maxItems') == 10 assert _items['type'] == 'array' and _items.get('maxItems') == 10
assert _items['items']['$ref'] == '#/components/schemas/JobLocationSuggestion' assert _items['items']['$ref'] == '#/components/schemas/JobLocationSuggestion'
print(' Company job-location suggestions: typed candidates, bounded query and error contract verified') print(' Company job-location suggestions: typed candidates, bounded query and error contract verified')
_accept = paths['/applications/{id}/accept']['post']
assert {'200', '401', '403', '404', '409'} <= set(_accept['responses'])
assert not _accept.get('requestBody'), 'Acceptance must not accept caller-provided userId/companyId/scores'
assert '不修改status' in _accept['description'] and '幂等' in _accept['description']
_accept_success = next(iter(_accept['responses']['200']['content'].values()))['schema']
_accept_envelope = resolve_local_ref(d, _accept_success['$ref'])
_accept_view = resolve_local_ref(d, _accept_envelope['properties']['data']['$ref'])
assert set(_accept_view['properties']) == {'applicationId', 'status', 'companyAcceptedAt', 'communicationAllowed'}
assert _accept_view['properties']['communicationAllowed']['type'] == 'boolean'
assert _accept_view['properties']['companyAcceptedAt']['format'] == 'date-time'
assert 'communicationAllowed' in paths['/applications/{id}/match']['get']['description']
assert '无需双向评分达标' in paths['/applications/{id}/conversation']['post']['description']
print(' Company acceptance unlocks communication; typed response and capability contract verified')
print("Valid JSON: True") print("Valid JSON: True")
print("All checks passed!") print("All checks passed!")

4
mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java

@ -56,6 +56,10 @@ public class Application {
@Builder.Default @Builder.Default
private String status = "SUBMITTED"; private String status = "SUBMITTED";
/** 企业明确接受本轮投递;查看简历、评分、旧轮投递均不自动写入。 */
@Column(name = "company_accepted_at")
private LocalDateTime companyAcceptedAt;
@CreatedDate @CreatedDate
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

41
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceController.java

@ -0,0 +1,41 @@
package com.mydreamjob.application;
import com.mydreamjob.audit.AuditContext;
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.tags.Tag;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@RestController
@RequiredArgsConstructor
@Tag(name = "投递")
public class ApplicationAcceptanceController {
private final ApplicationAcceptanceService acceptance;
private final CurrentUserAccessor users;
private final RequestIdAccessor requestIds;
public record AcceptanceView(Long applicationId, String status, LocalDateTime companyAcceptedAt,
boolean communicationAllowed) {}
@Operation(summary = "企业接受投递并解锁沟通", description = "仅已认证的岗位所属企业;无请求体。SUBMITTED/VIEWED/INTERVIEW/HIRED 可接受,拒绝/撤回返回409。接受与招聘状态分离,不修改status、不填写双方评分;幂等返回原companyAcceptedAt。成功后双方可调用 POST /applications/{id}/conversation,无需双向评分达标。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="200",description="data 包含 applicationId、status、companyAcceptedAt、communicationAllowed=true"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="401",description="未登录或会话不可用"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="403",description="角色、企业认证或投递归属不符"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="404",description="投递/企业不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode="409",description="投递已拒绝或撤回,不能接受")
})
@PostMapping("/applications/{id}/accept")
public ResponseEntity<ApiResponse<AcceptanceView>> accept(@PathVariable Long id, HttpServletRequest request) {
Application app = acceptance.accept(users.requireUserId(),id,AuditContext.from(request));
AcceptanceView view = new AcceptanceView(app.getId(),app.getStatus(),app.getCompanyAcceptedAt(),true);
return ResponseEntity.ok(ApiResponse.ok(view,requestIds.get(request)));
}
}

58
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationAcceptanceService.java

@ -0,0 +1,58 @@
package com.mydreamjob.application;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class ApplicationAcceptanceService {
private final UserRoleGuard roles;
private final CompanyRepository companies;
private final ApplicationRepository applications;
private final JobService jobs;
private final ApplicationStatusHistoryRepository history;
private final AuditService audit;
private final NotificationService notifications;
private final Clock clock;
/** 与撤回/状态推进使用相同投递行锁;重试只产生一次接受记录和通知。 */
@Transactional
public Application accept(Long actorUserId, Long applicationId, AuditContext context) {
roles.requireCompany(actorUserId);
Company company = VerifiedCompanyGuard.requireVerified(companies.findByOwnerUserId(actorUserId).orElse(null));
Application app = applications.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobs.getJob(app.getJobId());
if (!company.getId().equals(job.getCompanyId()))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
if (!Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus()))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
// 已录用仍可补记明确接受;已经接受时不覆盖原始时间/审计。
if (app.getCompanyAcceptedAt() != null) return app;
app.setCompanyAcceptedAt(LocalDateTime.now(clock).truncatedTo(java.time.temporal.ChronoUnit.MICROS));
app = applications.save(app);
history.save(ApplicationStatusHistory.builder().applicationId(app.getId())
.operatorUserId(actorUserId).fromStatus(app.getStatus()).toStatus(app.getStatus())
.reason("COMPANY_ACCEPTED: company accepted application; communication unlocked").build());
audit.log(actorUserId,"ACCEPT_APPLICATION","APPLICATION",app.getId(),
"Company accepted application; communication unlocked",context);
notifications.publishEvent(app.getStudentUserId(),"APPLICATION_ACCEPTED","企业已接受投递",
"企业已接受你的投递,可以开始沟通","APPLICATION",app.getId(),"accept-application-"+app.getId());
return app;
}
}

16
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationCommunicationPolicy.java

@ -0,0 +1,16 @@
package com.mydreamjob.application;
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord;
/** 沟通资格与双向评分解耦;终止投递优先拒绝,历史匹配/已录用保持兼容。 */
public final class ApplicationCommunicationPolicy {
private ApplicationCommunicationPolicy() {}
public static boolean allows(Application app, ApplicationMatchRecord legacyMatch) {
if (app == null || app.getStatus() == null
|| !java.util.Set.of("SUBMITTED","VIEWED","INTERVIEW","HIRED").contains(app.getStatus()))
return false;
return app.getCompanyAcceptedAt() != null || "HIRED".equals(app.getStatus())
|| (legacyMatch != null && ApplicationMatchRecord.STATUS_MATCHED.equals(legacyMatch.getStatus()));
}
}

1
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -281,6 +281,7 @@ public class ApplicationService {
view.put("jobId", app.getJobId()); view.put("jobId", app.getJobId());
view.put("studentUserId", app.getStudentUserId()); view.put("studentUserId", app.getStudentUserId());
view.put("status", app.getStatus()); view.put("status", app.getStatus());
view.put("companyAcceptedAt", app.getCompanyAcceptedAt());
view.put("attemptNumber", app.getAttemptNumber()); view.put("attemptNumber", app.getAttemptNumber());
view.put("previousApplicationId", app.getPreviousApplicationId()); view.put("previousApplicationId", app.getPreviousApplicationId());
view.put("createdAt", app.getCreatedAt()); view.put("createdAt", app.getCreatedAt());

2
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java

@ -51,7 +51,7 @@ public class ApplicationMatchController {
return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request))); return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request)));
} }
@Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID") @Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID。附 companyAcceptedAt(明确接受时间,可null)和 communicationAllowed(沟通资格);接受后不要求双向评分达标。匹配 status=CLOSED 可能由 HIRED 引起,不代表 communicationAllowed=false;最终仍校验会话归属、企业认证与屏蔽。")
@GetMapping("/applications/{id}/match") @GetMapping("/applications/{id}/match")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch( public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch(
@PathVariable Long id, @PathVariable Long id,

11
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java

@ -19,11 +19,12 @@ public interface ApplicationMatchRecordRepository extends JpaRepository<Applicat
boolean existsWithdrawnApplication(@Param("companyId") Long companyId, boolean existsWithdrawnApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId); @Param("candidateId") Long candidateId);
/** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */ /** 沟通资格;方法名保留给旧调用方,语义已扩展为企业接受/已录用/历史匹配。 */
@Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r " @Query("SELECT COUNT(a) > 0 FROM Application a JOIN Job j ON j.id = a.jobId "
+ "JOIN Application a ON a.id = r.applicationId " + "WHERE j.companyId = :companyId AND a.studentUserId = :candidateId "
+ "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED' " + "AND a.status IN ('SUBMITTED', 'VIEWED', 'INTERVIEW', 'HIRED') "
+ "AND a.status NOT IN ('REJECTED', 'WITHDRAWN')") + "AND (a.companyAcceptedAt IS NOT NULL OR a.status = 'HIRED' "
+ "OR EXISTS (SELECT r.id FROM ApplicationMatchRecord r WHERE r.applicationId = a.id AND r.status = 'MATCHED'))")
boolean existsMatchedApplication(@Param("companyId") Long companyId, boolean existsMatchedApplication(@Param("companyId") Long companyId,
@Param("candidateId") Long candidateId); @Param("candidateId") Long candidateId);

2
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java

@ -171,6 +171,8 @@ public class BidirectionalMatchService {
v.put("jobId", job.getId()); v.put("jobId", job.getId());
v.put("companyId", job.getCompanyId()); v.put("companyId", job.getCompanyId());
v.put("candidateId", app.getStudentUserId()); v.put("candidateId", app.getStudentUserId());
v.put("companyAcceptedAt", app.getCompanyAcceptedAt());
v.put("communicationAllowed", com.mydreamjob.application.ApplicationCommunicationPolicy.allows(app, record));
if (record != null) { if (record != null) {
v.put("candidateInterestScore", record.getCandidateInterestScore()); v.put("candidateInterestScore", record.getCandidateInterestScore());
v.put("hrSuitabilityScore", record.getHrSuitabilityScore()); v.put("hrSuitabilityScore", record.getHrSuitabilityScore());

4
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java

@ -13,7 +13,7 @@ import java.util.Map;
/** /**
* 匹配后沟通解锁接口:基于 application 创建/获取直聊会话 * 匹配后沟通解锁接口:基于 application 创建/获取直聊会话
*/ */
@Tag(name = "双向匹配沟通", description = "匹配成功后创建或获取直聊会话(幂等)") @Tag(name = "双向匹配沟通", description = "企业接受投递后创建或获取直聊会话(幂等),兼容历史匹配")
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
public class MatchConversationController { public class MatchConversationController {
@ -25,7 +25,7 @@ public class MatchConversationController {
private final MatchConversationService matchConversationService; private final MatchConversationService matchConversationService;
@Operation(summary = "创建/获取匹配会话", description = "仅 MATCHED 双方可调用;重复调用返回同一会话") @Operation(summary = "创建/获取投递沟通会话", description = "仅投递双方;企业接受后即可沟通,无需双向评分达标;历史 MATCHED 或 HIRED 保持兼容。已拒绝/撤回投递返回409/8304,未接受同样返回409/8304;重复调用返回同一会话。企业认证、归属、屏蔽校验保留。GET /applications/{id}/match 的 communicationAllowed 为沟通资格,不应从匹配 status=CLOSED 推断禁止沟通。")
@PostMapping("/applications/{id}/conversation") @PostMapping("/applications/{id}/conversation")
public ResponseEntity<ApiResponse<Map<String, Object>>> createOrGetConversation( public ResponseEntity<ApiResponse<Map<String, Object>>> createOrGetConversation(
@PathVariable Long id, @PathVariable Long id,

17
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java

@ -24,7 +24,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 匹配后沟通解锁:仅 MATCHED 的双方可基于 application 创建/获取直聊会话。 * 企业接受后沟通解锁;兼容历史 MATCHED 和已录用,拒绝/撤回优先禁止。
* *
* <p>幂等:application_match_conversations 唯一约束保证同一投递只解锁一个会话, * <p>幂等:application_match_conversations 唯一约束保证同一投递只解锁一个会话,
* 重复调用返回同一会话。不修改通用 POST /conversations 的会话结构。 * 重复调用返回同一会话。不修改通用 POST /conversations 的会话结构。
@ -41,12 +41,13 @@ public class MatchConversationService {
private final ApplicationMatchConversationRepository conversationLinkRepository; private final ApplicationMatchConversationRepository conversationLinkRepository;
private final ConversationRepository conversationRepository; private final ConversationRepository conversationRepository;
private final ConversationMemberRepository memberRepository; private final ConversationMemberRepository memberRepository;
private final com.mydreamjob.safety.BlockRelationRepository blockRelationRepository;
@Transactional @Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) { public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) {
Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null); Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null);
if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany); if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
Long companyId = job.getCompanyId(); Long companyId = job.getCompanyId();
@ -56,6 +57,7 @@ public class MatchConversationService {
Long otherUserId; Long otherUserId;
if (candidateId.equals(userId)) { if (candidateId.equals(userId)) {
otherUserId = companyRepository.findById(companyId) otherUserId = companyRepository.findById(companyId)
.map(VerifiedCompanyGuard::requireVerified)
.map(Company::getOwnerUserId) .map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else { } else {
@ -66,12 +68,9 @@ public class MatchConversationService {
otherUserId = candidateId; otherUserId = candidateId;
} }
// 必须已 MATCHED // 与撤回/状态推进共用当前读;不能仅根据匹配查询的 CLOSED 展示值判断沟通权限。
if ("WITHDRAWN".equals(app.getStatus()) || "REJECTED".equals(app.getStatus())) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
}
ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null); ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null);
if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) { if (!com.mydreamjob.application.ApplicationCommunicationPolicy.allows(app, record)) {
throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED);
} }
@ -83,6 +82,10 @@ public class MatchConversationService {
} }
// 创建/复用两人之间的 DIRECT 会话(与 MessagingService 语义一致) // 创建/复用两人之间的 DIRECT 会话(与 MessagingService 语义一致)
if (blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(userId, otherUserId)
|| blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(otherUserId, userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN, "message cannot be sent");
}
Conversation conversation = findOrCreateDirectConversation(userId, otherUserId); Conversation conversation = findOrCreateDirectConversation(userId, otherUserId);
try { try {
conversationLinkRepository.save(ApplicationMatchConversation.builder() conversationLinkRepository.save(ApplicationMatchConversation.builder()

4
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -84,8 +84,8 @@ public enum ErrorCode {
// 双向匹配 83xx // 双向匹配 83xx
MATCH_CLOSED(8301, "match is closed"), MATCH_CLOSED(8301, "match is closed"),
MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"), MATCH_SCORES_LOCKED(8302, "match already succeeded, scores are locked"),
MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation between company and candidate requires a matched application"), MATCH_REQUIRED_FOR_CONVERSATION(8303, "direct conversation requires a company-accepted application"),
APPLICATION_NOT_MATCHED(8304, "application is not matched"), APPLICATION_NOT_MATCHED(8304, "application is not accepted for communication"),
// 站内信 83xx // 站内信 83xx
CONVERSATION_NOT_FOUND(8305, "conversation not found"), CONVERSATION_NOT_FOUND(8305, "conversation not found"),
MESSAGE_NOT_FOUND(8306, "message not found"), MESSAGE_NOT_FOUND(8306, "message not found"),

2
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java

@ -17,7 +17,7 @@ public class ChatResumeController {
private final com.mydreamjob.security.CurrentUserAccessor current; private final com.mydreamjob.security.CurrentUserAccessor current;
private final com.mydreamjob.common.logging.RequestIdAccessor requestIds; private final com.mydreamjob.common.logging.RequestIdAccessor requestIds;
@Operation(summary = "发送固定版本简历卡片", description = "仅投递本人,双方为会话成员且 MATCHED;隐私规则仍生效。clientMessageId 必填,重试复用原值") @Operation(summary = "发送固定版本简历卡片", description = "仅投递本人,双方为会话成员且企业已接受投递;兼容历史 MATCHED/HIRED。拒绝/撤回、屏蔽、企业认证和简历隐私规则仍生效。clientMessageId 必填,重试复用原值")
@PostMapping("/{id}/resume-messages") @PostMapping("/{id}/resume-messages")
public ApiResponse<MessageResponse> send(@PathVariable Long id, public ApiResponse<MessageResponse> send(@PathVariable Long id,
@Valid @RequestBody ChatResumeService.SendRequest body, HttpServletRequest request) { @Valid @RequestBody ChatResumeService.SendRequest body, HttpServletRequest request) {

2
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java

@ -143,7 +143,7 @@ public class ChatResumeService {
|| blocks.existsByBlockerUserIdAndBlockedUserId(employer,student)) || blocks.existsByBlockerUserIdAndBlockedUserId(employer,student))
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
var match = matches.findByApplicationId(applicationId).orElse(null); var match = matches.findByApplicationId(applicationId).orElse(null);
if (match == null || !"MATCHED".equals(match.getStatus())) if (!com.mydreamjob.application.ApplicationCommunicationPolicy.allows(application,match))
throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION);
// 主动发送不会偷偷覆盖学生的 PRIVATE 设置;双方均必须满足当前下载策略。 // 主动发送不会偷偷覆盖学生的 PRIVATE 设置;双方均必须满足当前下载策略。
resumeAccess.authorizeForDownload(employer,applicationId); resumeAccess.authorizeForDownload(employer,applicationId);

5
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -118,7 +118,7 @@ public class MessagingService {
} }
// 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话 // 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话
assertNotBlockedBetween(userId, otherId); assertNotBlockedBetween(userId, otherId);
// 新建会话:企业-求职者组合必须先存在双向匹配成功的投递,防止绕过匹配解锁 // 新建会话:企业已接受投递(兼容历史匹配/已录用),不能靠通用接口绕过。
assertCompanyCandidatePairHasMatchedApplication(userId, otherId); assertCompanyCandidatePairHasMatchedApplication(userId, otherId);
Conversation c = conversationRepository.save(Conversation.builder().createdBy(userId).type("DIRECT").build()); Conversation c = conversationRepository.save(Conversation.builder().createdBy(userId).type("DIRECT").build());
memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(userId).build()); memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(userId).build());
@ -128,7 +128,7 @@ public class MessagingService {
/** /**
* 防绕过守卫:仅当双方为"企业账号 + 求职者账号"且确实要新建会话时,要求存在一条 * 防绕过守卫:仅当双方为"企业账号 + 求职者账号"且确实要新建会话时,要求存在一条
* 同时归属双方且状态为 MATCHED 的投递;否则 403。 * 同时归属双方且允许沟通的投递(企业接受/历史匹配/已录用);否则 403。
* 学生-学生、企业-企业及其他组合保持原语义;无法判定身份时(上游已校验存在性)防御性放行。 * 学生-学生、企业-企业及其他组合保持原语义;无法判定身份时(上游已校验存在性)防御性放行。
*/ */
private void assertCompanyCandidatePairHasMatchedApplication(Long userId, Long otherId) { private void assertCompanyCandidatePairHasMatchedApplication(Long userId, Long otherId) {
@ -145,6 +145,7 @@ public class MessagingService {
Long companyUserId = meCompany ? userId : otherId; Long companyUserId = meCompany ? userId : otherId;
Long studentUserId = meCompany ? otherId : userId; Long studentUserId = meCompany ? otherId : userId;
Long companyId = companyRepository.findByOwnerUserId(companyUserId) Long companyId = companyRepository.findByOwnerUserId(companyUserId)
.map(VerifiedCompanyGuard::requireVerified)
.map(Company::getId).orElse(null); .map(Company::getId).orElse(null);
if (companyId == null if (companyId == null
|| !applicationMatchRecordRepository.existsMatchedApplication(companyId, studentUserId)) { || !applicationMatchRecordRepository.existsMatchedApplication(companyId, studentUserId)) {

2
mydreamjob-server/src/main/resources/db/migration/V61__application_company_acceptance.sql

@ -0,0 +1,2 @@
-- 接受与招聘阶段分离;不猜测/回填历史接受时间,不改既有状态、评分、会话。
ALTER TABLE applications ADD COLUMN company_accepted_at DATETIME(6) NULL;

189
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceIntegrationTest.java

@ -0,0 +1,189 @@
package com.mydreamjob.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.*;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.bidirectionalmatch.*;
import com.mydreamjob.company.*;
import com.mydreamjob.job.*;
import com.mydreamjob.messaging.*;
import com.mydreamjob.common.exception.BusinessException;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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 java.time.Clock;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
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.*;
import org.springframework.http.MediaType;
@SpringBootTest(properties="spring.datasource.url=jdbc:h2:mem:integrationdb_accept_chat;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@AutoConfigureMockMvc @ActiveProfiles("integration-test")
class ApplicationAcceptanceIntegrationTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired JobRepository jobs;
@Autowired ApplicationRepository applications;
@Autowired ApplicationAcceptanceService acceptance;
@Autowired ApplicationService applicationService;
@Autowired ApplicationStatusHistoryRepository history;
@Autowired ApplicationMatchRecordRepository matches;
@Autowired MatchConversationService conversations;
@Autowired AuthSessionService sessions;
@Autowired Clock clock;
@Autowired com.mydreamjob.safety.BlockRelationRepository blocks;
@MockBean RedisTemplate<String,Object> redis;
static final AtomicLong PHONE=new AtomicLong(71000000);
User student,employer,other;
Company company;
Job job;
Application app;
String studentToken,employerToken;
User user(User.Role role) {
return users.save(User.builder().phone("166"+PHONE.incrementAndGet()).nickname("隔离接受沟通测试")
.role(role).roles(Set.of(role)).active(true).verified(true).build());
}
String token(User user) { return sessions.createSession(user,clock.instant(),AuditContext.builder().build()).accessToken(); }
@BeforeEach void setup() {
student=user(User.Role.STUDENT);employer=user(User.Role.COMPANY);other=user(User.Role.STUDENT);
company=companies.save(Company.builder().ownerUserId(employer.getId()).name("隔离企业").verified(true).build());
job=jobs.save(Job.builder().companyId(company.getId()).title("仅测试").description("fixture")
.city("上海").employmentType("FULL_TIME").status("PUBLISHED").build());
app=applications.save(Application.builder().jobId(job.getId()).studentUserId(student.getId()).status("SUBMITTED").build());
studentToken=token(student);employerToken=token(employer);
}
String url(String suffix) {return "/applications/"+app.getId()+suffix;}
long conversation(String token) throws Exception {
String json=mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+token))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
return mapper.readTree(json).path("data").path("id").asLong();
}
void accept() { acceptance.accept(employer.getId(),app.getId(),null); }
@Test void httpAcceptWithoutScoresThenBothSidesCanSendAndRead() throws Exception {
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken))
.andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(8304));
mvc.perform(post("/conversations").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON)
.content("{\"participantUserId\":"+student.getId()+"}")).andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(8303));
String response=mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.communicationAllowed").value(true))
.andExpect(jsonPath("$.data.status").value("SUBMITTED")).andReturn().getResponse().getContentAsString();
String acceptedAt=mapper.readTree(response).path("data").path("companyAcceptedAt").asText();
assertFalse(acceptedAt.isBlank());
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isOk())
.andExpect(jsonPath("$.data.companyAcceptedAt").value(acceptedAt));
assertTrue(matches.findByApplicationId(app.getId()).isEmpty());
mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("PENDING_BOTH"))
.andExpect(jsonPath("$.data.communicationAllowed").value(true));
long id=conversation(employerToken);assertEquals(id,conversation(studentToken));
for(String auth:List.of(studentToken,employerToken)) {
mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+auth).contentType(MediaType.APPLICATION_JSON)
.content("{\"conversationId\":"+id+",\"content\":\"隔离双向沟通\",\"clientMessageId\":\"msg-"+auth.hashCode()+"\"}"))
.andExpect(status().isOk());
}
for(String auth:List.of(studentToken,employerToken)) {
mvc.perform(get("/conversations/"+id+"/messages").header("Authorization","Bearer "+auth))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(2));
mvc.perform(get("/conversations/"+id+"/events").header("Authorization","Bearer "+auth))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.items").isNotEmpty());
}
long count=history.findByApplicationIdOrderByCreatedAtAsc(app.getId()).stream()
.filter(h->h.getReason().startsWith("COMPANY_ACCEPTED")).count();
assertEquals(1,count);
}
@Test void historicalHiredHasClosedMatchButAllowsCommunication() throws Exception {
app.setStatus("HIRED");applications.save(app);
mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.status").value("CLOSED"))
.andExpect(jsonPath("$.data.communicationAllowed").value(true));
assertTrue(matches.existsMatchedApplication(company.getId(),student.getId()));
assertTrue(conversation(employerToken)>0);
}
@Test void viewingAloneDoesNotUnlock() {
app.setStatus("VIEWED");applications.save(app);
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId()));
assertThrows(BusinessException.class,()->conversations.createOrGetMatchConversation(employer.getId(),app.getId()));
}
@ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN"})
void terminalWinsOverAcceptance(String status) throws Exception {
accept();app=applications.findById(app.getId()).orElseThrow();app.setStatus(status);applications.save(app);
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isConflict());
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)).andExpect(status().isConflict());
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId()));
mvc.perform(get(url("/match")).header("Authorization","Bearer "+studentToken))
.andExpect(status().isOk()).andExpect(jsonPath("$.data.communicationAllowed").value(false));
}
@Test void studentOtherCompanyAndAnonymousCannotAccept() throws Exception {
mvc.perform(post(url("/accept"))).andExpect(status().isUnauthorized());
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+studentToken)).andExpect(status().isForbidden());
User foreign=user(User.Role.COMPANY);
companies.save(Company.builder().ownerUserId(foreign.getId()).name("别家企业").verified(true).build());
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+token(foreign))).andExpect(status().isForbidden());
assertNull(applications.findById(app.getId()).orElseThrow().getCompanyAcceptedAt());
}
@Test void unverifiedAndDisabledCompaniesCannotAccept() throws Exception {
company.setVerified(false);companies.save(company);
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isForbidden());
company.setVerified(true);companies.save(company);
employer.setActive(false);users.save(employer);
mvc.perform(post(url("/accept")).header("Authorization","Bearer "+employerToken)).andExpect(status().isUnauthorized());
assertNull(applications.findById(app.getId()).orElseThrow().getCompanyAcceptedAt());
}
@Test void concurrentAcceptanceIsIdempotent() throws Exception {
ExecutorService pool=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1);
try {
Callable<java.time.LocalDateTime> call=()->{start.await();return acceptance.accept(employer.getId(),app.getId(),null).getCompanyAcceptedAt();};
var a=pool.submit(call);var b=pool.submit(call);start.countDown();
assertEquals(a.get(15,TimeUnit.SECONDS),b.get(15,TimeUnit.SECONDS));
assertEquals(1,history.findByApplicationIdOrderByCreatedAtAsc(app.getId()).stream()
.filter(h->h.getReason().startsWith("COMPANY_ACCEPTED")).count());
} finally {pool.shutdownNow();}
}
@Test void revokedCompanyCertificationCannotBeBypassedByStudent() throws Exception {
accept();company.setVerified(false);companies.save(company);
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+studentToken)).andExpect(status().isForbidden());
mvc.perform(post("/conversations").header("Authorization","Bearer "+studentToken).contentType(MediaType.APPLICATION_JSON)
.content("{\"participantUserId\":"+employer.getId()+"}")).andExpect(status().isForbidden());
}
@Test void withdrawalAndReapplicationDoNotInheritAcceptance() throws Exception {
accept();long oldId=app.getId();long oldConversation=conversation(employerToken);
applicationService.withdraw(student.getId(),oldId);
var request=new com.mydreamjob.application.dto.ApplicationCreateRequest();request.setJobId(job.getId());
Application next=applicationService.apply(student.getId(),request);
assertNull(next.getCompanyAcceptedAt());assertEquals(oldId,next.getPreviousApplicationId());
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId()));
mvc.perform(post("/conversations/messages").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON)
.content("{\"conversationId\":"+oldConversation+",\"content\":\"撤回后禁止发送\"}")).andExpect(status().isForbidden());
app=next;accept();assertEquals(oldConversation,conversation(employerToken));
}
@Test void runtimeContractIncludesAcceptanceAndCapabilities() throws Exception {
String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
assertTrue(mapper.readTree(json).path("paths").has("/applications/{id}/accept"));
java.nio.file.Files.writeString(java.nio.file.Path.of("target/acceptance-chat-openapi.json"),json);
}
@ParameterizedTest @ValueSource(booleans={true,false})
void acceptedPairCannotBypassEitherDirectionOfBlocking(boolean forward) throws Exception {
accept();
blocks.save(com.mydreamjob.safety.BlockRelation.builder()
.blockerUserId(forward?student.getId():employer.getId())
.blockedUserId(forward?employer.getId():student.getId()).build());
mvc.perform(post(url("/conversation")).header("Authorization","Bearer "+employerToken)).andExpect(status().isForbidden());
mvc.perform(post("/conversations").header("Authorization","Bearer "+employerToken).contentType(MediaType.APPLICATION_JSON)
.content("{\"participantUserId\":"+student.getId()+"}")).andExpect(status().isForbidden());
}
}

26
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlIntegrationTest.java

@ -0,0 +1,26 @@
package com.mydreamjob.application;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/** 同一组真实控制器、权限、接受并发、聊天双向读写与撤回探针,在真实 MySQL 重用。 */
@Testcontainers
@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS)
class ApplicationAcceptanceMySqlIntegrationTest extends ApplicationAcceptanceIntegrationTest {
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>(
System.getProperty("acceptance.mysql.image","mysql:8.4"))
.withDatabaseName("acceptance_chat").withUsername("accepttest").withPassword("synthetic-only")
.withCommand("--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci")
.withUrlParam("characterEncoding","UTF-8")
.withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024));
@DynamicPropertySource static void mysql(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",MYSQL::getJdbcUrl);
registry.add("spring.datasource.username",MYSQL::getUsername);
registry.add("spring.datasource.password",MYSQL::getPassword);
registry.add("spring.datasource.driver-class-name",()->"com.mysql.cj.jdbc.Driver");
registry.add("spring.jpa.properties.hibernate.dialect",()->"org.hibernate.dialect.MySQLDialect");
}
}

56
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceMySqlMigrationTest.java

@ -0,0 +1,56 @@
package com.mydreamjob.application;
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.*;
class ApplicationAcceptanceMySqlMigrationTest {
@Test void mysql57V60ToV61() throws Exception {verify("mysql:5.7.36");}
@Test void mysql84V60ToV61() throws Exception {verify("mysql:8.4");}
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();
}
void verify(String image) throws Exception {
try(var db=new MySQLContainer<>(image).withDatabaseName("acceptance_migration").withUsername("accepttest")
.withUrlParam("characterEncoding","UTF-8")
.withPassword("synthetic-only").withCreateContainerCmdModifier(c->c.getHostConfig().withMemory(768L*1024*1024))) {
db.start();
try(var connection=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());
var sql=connection.createStatement()) {
sql.execute("ALTER DATABASE acceptance_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
flyway(db,"60").migrate();var before=history(sql);
sql.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(940001,'19900881111','COMPANY',1,1),(940002,'19900882222','STUDENT',1,1)");
sql.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(940001,940001,'test',1)");
sql.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status) VALUES(940001,940001,'test','fixture','test','FULL_TIME','PUBLISHED')");
sql.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(940001,940001,940002,'HIRED')");
assertEquals(1,flyway(db,"61").migrate().migrationsExecuted);
assertEquals(before,history(sql));
try(var rows=sql.executeQuery("SELECT status,company_accepted_at FROM applications WHERE id=940001")) {
assertTrue(rows.next());assertEquals("HIRED",rows.getString(1));assertNull(rows.getTimestamp(2));
}
try(var rows=sql.executeQuery("SELECT data_type,datetime_precision,is_nullable FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='applications' AND column_name='company_accepted_at'")) {
assertTrue(rows.next());assertEquals("datetime",rows.getString(1));assertEquals(6,rows.getInt(2));assertEquals("YES",rows.getString(3));
}
sql.execute("UPDATE applications SET company_accepted_at='2026-10-22 12:00:00.123456' WHERE id=940001");
assertEquals(0,flyway(db,"61").migrate().migrationsExecuted);flyway(db,"61").validate();
try(var rows=sql.executeQuery("SELECT CAST(company_accepted_at AS CHAR) FROM applications WHERE id=940001")) {
assertTrue(rows.next());assertTrue(rows.getString(1).endsWith(".123456"));
}
assertEquals(before,history(sql));
}
}
}
List<String> history(Statement sql) throws Exception {
var result=new ArrayList<String>();
try(var rows=sql.executeQuery("SELECT version,checksum,success FROM flyway_schema_history WHERE version<>'61' ORDER BY installed_rank")) {
while(rows.next())result.add(rows.getString(1)+"|"+rows.getString(2)+"|"+rows.getBoolean(3));
}
return result;
}
}

80
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationAcceptanceServiceTest.java

@ -0,0 +1,80 @@
package com.mydreamjob.application;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.company.*;
import com.mydreamjob.job.*;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.security.UserRoleGuard;
import com.mydreamjob.common.exception.BusinessException;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.*;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
@ExtendWith(MockitoExtension.class)
class ApplicationAcceptanceServiceTest {
@Mock UserRoleGuard roles;
@Mock CompanyRepository companies;
@Mock ApplicationRepository applications;
@Mock JobService jobs;
@Mock ApplicationStatusHistoryRepository history;
@Mock AuditService audit;
@Mock NotificationService notifications;
final Clock clock=Clock.fixed(Instant.parse("2026-10-22T12:00:00Z"),ZoneOffset.UTC);
ApplicationAcceptanceService service;
Application app;
@BeforeEach void setup() {
service=new ApplicationAcceptanceService(roles,companies,applications,jobs,history,audit,notifications,clock);
app=Application.builder().id(15L).jobId(3L).studentUserId(62L).status("SUBMITTED").build();
}
void fixture() {
when(companies.findByOwnerUserId(64L)).thenReturn(Optional.of(Company.builder().id(54L).verified(true).build()));
when(applications.findLockedById(15L)).thenReturn(Optional.of(app));
when(jobs.getJob(3L)).thenReturn(Job.builder().id(3L).companyId(54L).build());
}
@ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW","HIRED"})
void acceptIsExplicitAndDoesNotAdvanceStatus(String status) {
fixture();app.setStatus(status);when(applications.save(app)).thenReturn(app);
var result=service.accept(64L,15L,null);
assertEquals(status,result.getStatus());assertEquals(LocalDateTime.now(clock),result.getCompanyAcceptedAt());
verify(roles).requireCompany(64L);
verify(history).save(argThat(h->h.getOperatorUserId().equals(64L)
&&h.getFromStatus().equals(status)&&h.getToStatus().equals(status)));
verify(audit).log(eq(64L),eq("ACCEPT_APPLICATION"),eq("APPLICATION"),eq(15L),anyString(),isNull());
verify(notifications).publishEvent(eq(62L),eq("APPLICATION_ACCEPTED"),anyString(),anyString(),eq("APPLICATION"),eq(15L),eq("accept-application-15"));
}
@Test void repeatedAcceptanceDoesNotOverwriteOrNotifyAgain() {
fixture();var original=LocalDateTime.now(clock).minusDays(1);app.setCompanyAcceptedAt(original);
assertSame(app,service.accept(64L,15L,null));
assertEquals(original,app.getCompanyAcceptedAt());verifyNoInteractions(history,audit,notifications);
verify(applications,never()).save(any());
}
@ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN"})
void terminalCannotBeAcceptedEvenIfPreviouslyAccepted(String status) {
fixture();app.setStatus(status);app.setCompanyAcceptedAt(LocalDateTime.now(clock));
assertThrows(BusinessException.class,()->service.accept(64L,15L,null));
verifyNoInteractions(history,audit,notifications);
}
@Test void anotherCompanyCannotAccept() {
fixture();when(jobs.getJob(3L)).thenReturn(Job.builder().companyId(99L).build());
assertThrows(BusinessException.class,()->service.accept(64L,15L,null));
assertNull(app.getCompanyAcceptedAt());verifyNoInteractions(history,audit,notifications);
}
@Test void unverifiedCompanyCannotAccept() {
when(companies.findByOwnerUserId(64L)).thenReturn(Optional.of(Company.builder().id(54L).verified(false).build()));
assertThrows(BusinessException.class,()->service.accept(64L,15L,null));
verifyNoInteractions(applications,history,audit,notifications);
}
@Test void roleFailureHasNoSideEffects() {
doThrow(new BusinessException(com.mydreamjob.common.exception.ErrorCode.FORBIDDEN)).when(roles).requireCompany(62L);
assertThrows(BusinessException.class,()->service.accept(62L,15L,null));
verifyNoInteractions(companies,applications,history,audit,notifications);
}
}

31
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationCommunicationPolicyTest.java

@ -0,0 +1,31 @@
package com.mydreamjob.application;
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class ApplicationCommunicationPolicyTest {
@ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW"})
void viewingOrEnteringInterviewDoesNotMeanAcceptance(String status) {
assertFalse(ApplicationCommunicationPolicy.allows(Application.builder().status(status).build(),null));
}
@ParameterizedTest @ValueSource(strings={"SUBMITTED","VIEWED","INTERVIEW"})
void acceptedDoesNotRequireAnyScore(String status) {
assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status(status)
.companyAcceptedAt(LocalDateTime.now()).build(),null));
}
@ParameterizedTest @ValueSource(strings={"REJECTED","WITHDRAWN","UNKNOWN"})
void terminalAndUnknownWinOverAcceptanceAndMatch(String status) {
assertFalse(ApplicationCommunicationPolicy.allows(Application.builder().status(status)
.companyAcceptedAt(LocalDateTime.now()).build(),ApplicationMatchRecord.builder().status("MATCHED").build()));
}
@Test void historicalHiredAndMatchedRemainUsable() {
assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status("HIRED").build(),null));
assertTrue(ApplicationCommunicationPolicy.allows(Application.builder().status("VIEWED").build(),
ApplicationMatchRecord.builder().status("MATCHED").build()));
assertFalse(ApplicationCommunicationPolicy.allows(null,null));
}
}

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

@ -503,8 +503,8 @@ class CompanyResumeDownloadIntegrationTest {
} }
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("193 paths 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(193); assertThat(pathCount - modulePaths).as("194 paths 包含首次密码设置、聊天补拉、简历消息、在线简历、企业地点搜索及接受投递").isEqualTo(194);
assertThat(opCount - moduleOperations).as("236 operations 包含首次密码设置、聊天补拉、简历消息、在线简历及企业地点搜索").isEqualTo(236); assertThat(opCount - moduleOperations).as("237 operations 包含首次密码设置、聊天补拉、简历消息、在线简历、企业地点搜索及接受投递").isEqualTo(237);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

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

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
} }
} }
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息、8项在线简历及企业地点搜索操作均受保护").isEqualTo(210); assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉、简历消息、8项在线简历、企业地点搜索及接受投递操作均受保护").isEqualTo(211);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"), java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
} }

15
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java

@ -36,6 +36,7 @@ class MatchConversationServiceTest {
@Mock private ApplicationMatchConversationRepository conversationLinkRepository; @Mock private ApplicationMatchConversationRepository conversationLinkRepository;
@Mock private ConversationRepository conversationRepository; @Mock private ConversationRepository conversationRepository;
@Mock private ConversationMemberRepository memberRepository; @Mock private ConversationMemberRepository memberRepository;
@Mock private com.mydreamjob.safety.BlockRelationRepository blockRelationRepository;
@InjectMocks private MatchConversationService conversationService; @InjectMocks private MatchConversationService conversationService;
@ -64,7 +65,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_notMatched_shouldReturn409() { void createOrGet_notMatched_shouldReturn409() {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(companyRepository.findById(COMPANY_ID)) when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
@ -80,7 +81,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_matched_byStudent_shouldCreateAndLink() { void createOrGet_matched_byStudent_shouldCreateAndLink() {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty());
@ -105,7 +106,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_matched_byCompany_shouldCreate() { void createOrGet_matched_byCompany_shouldCreate() {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty());
@ -127,7 +128,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_repeatCall_shouldReturnExistingLink() { void createOrGet_repeatCall_shouldReturnExistingLink() {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(companyRepository.findById(COMPANY_ID)) when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
@ -147,7 +148,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_concurrentLinkConflict_shouldReturnExisting() { void createOrGet_concurrentLinkConflict_shouldReturnExisting() {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)) when(conversationLinkRepository.findByApplicationId(APPLICATION_ID))
@ -174,7 +175,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_otherStudent_shouldReturn403() { void createOrGet_otherStudent_shouldReturn403() {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findLockedById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(companyRepository.findByOwnerUserId(OTHER_STUDENT_ID)).thenReturn(Optional.empty()); when(companyRepository.findByOwnerUserId(OTHER_STUDENT_ID)).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
@ -184,7 +185,7 @@ class MatchConversationServiceTest {
@Test @Test
void createOrGet_applicationNotFound_shouldReturn404() { void createOrGet_applicationNotFound_shouldReturn404() {
when(applicationRepository.findById(999L)).thenReturn(Optional.empty()); when(applicationRepository.findLockedById(999L)).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> conversationService.createOrGetMatchConversation(STUDENT_ID, 999L)); () -> conversationService.createOrGetMatchConversation(STUDENT_ID, 999L));
assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt());

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

@ -148,8 +148,8 @@ class LxqModulesMySql57IntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(254).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(255).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(206); assertThat(runtime.path("paths").size()).isEqualTo(207);
assertThat(runtime.path("paths").path("/conversations/{id}/events")) assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events")); .isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) { for (String field : Set.of("clientMessageId", "version")) {

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(254).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(255).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(206); assertThat(runtime.path("paths").size()).isEqualTo(207);
assertThat(runtime.path("paths").path("/conversations/{id}/events")) assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events")); .isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) { for (String field : Set.of("clientMessageId", "version")) {

2
mydreamjob-server/src/test/java/com/mydreamjob/job/JobMapLocationIntegrationTest.java

@ -147,7 +147,7 @@ class JobMapLocationIntegrationTest {
@Test void runtimeContractContainsLocationAndSearchRoute() throws Exception { @Test void runtimeContractContainsLocationAndSearchRoute() throws Exception {
String raw=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); String raw=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
var spec=mapper.readTree(raw);assertEquals(206,spec.path("paths").size()); var spec=mapper.readTree(raw);assertEquals(207,spec.path("paths").size());
assertTrue(spec.path("paths").path("/companies/me/job-location-suggestions").has("get")); assertTrue(spec.path("paths").path("/companies/me/job-location-suggestions").has("get"));
var search=spec.path("paths").path("/companies/me/job-location-suggestions").path("get"); var search=spec.path("paths").path("/companies/me/job-location-suggestions").path("get");
assertEquals("searchJobLocationSuggestions",search.path("operationId").asText()); assertEquals("searchJobLocationSuggestions",search.path("operationId").asText());

6
mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java

@ -209,4 +209,10 @@ class ChatWorkflowIntegrationTest {
String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
java.nio.file.Files.writeString(java.nio.file.Path.of("target/workflow-openapi.json"),json); java.nio.file.Files.writeString(java.nio.file.Path.of("target/workflow-openapi.json"),json);
} }
@Test void acceptedApplicationCanSendResumeWithoutMatchScores() {
matches.delete(matches.findByApplicationId(application.getId()).orElseThrow());
application.setCompanyAcceptedAt(java.time.LocalDateTime.now(clock));em.flush();
assertTrue(matches.findByApplicationId(application.getId()).isEmpty());
assertEquals("RESUME",send("accepted-no-match").getMessageType());
}
} }

Loading…
Cancel
Save