39 changed files with 857 additions and 45 deletions
@ -0,0 +1,52 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.messaging.dto.MessageResponse; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.validation.Valid; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.*; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
|
||||
|
@RestController @RequestMapping("/conversations") @RequiredArgsConstructor |
||||
|
@Tag(name = "消息", description = "会话创建、消息发送、已读管理") |
||||
|
public class ChatResumeController { |
||||
|
private final ChatResumeService service; |
||||
|
private final com.mydreamjob.security.CurrentUserAccessor current; |
||||
|
private final com.mydreamjob.common.logging.RequestIdAccessor requestIds; |
||||
|
|
||||
|
@Operation(summary = "发送固定版本简历卡片", description = "仅投递本人,双方为会话成员且 MATCHED;隐私规则仍生效。clientMessageId 必填,重试复用原值") |
||||
|
@PostMapping("/{id}/resume-messages") |
||||
|
public ApiResponse<MessageResponse> send(@PathVariable Long id, |
||||
|
@Valid @RequestBody ChatResumeService.SendRequest body, HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.send(id,current.requireUserId(),body),requestIds.get(request)); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "授权下载简历快照", description = "每次校验成员、投递归属、认证、匹配、隐私、屏蔽及撤回状态,不返回公开文件地址") |
||||
|
@GetMapping("/{conversationId}/messages/{messageId}/resume") |
||||
|
public ResponseEntity<byte[]> download(@PathVariable Long conversationId,@PathVariable Long messageId) { |
||||
|
var file = service.download(conversationId,messageId,current.requireUserId()); |
||||
|
String type = file.contentType(); |
||||
|
if (!java.util.Set.of("application/pdf","application/msword", |
||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document").contains(type == null ? "" : type)) |
||||
|
type = MediaType.APPLICATION_OCTET_STREAM_VALUE; |
||||
|
return ResponseEntity.ok().contentType(MediaType.parseMediaType(type)) |
||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment() |
||||
|
.filename("resume" + extension(file.fileName()),java.nio.charset.StandardCharsets.UTF_8).build().toString()) |
||||
|
.header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff") |
||||
|
.body(file.bytes()); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "撤销简历卡片授权", description = "仅原发送者,幂等,销毁快照字节并推送 RESUME_REVOKED;不受两分钟文本撤回窗口限制") |
||||
|
@DeleteMapping("/{conversationId}/messages/{messageId}/resume") |
||||
|
public ApiResponse<MessageResponse> revoke(@PathVariable Long conversationId,@PathVariable Long messageId, |
||||
|
HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.revoke(conversationId,messageId,current.requireUserId()),requestIds.get(request)); |
||||
|
} |
||||
|
private String extension(String name) { |
||||
|
int dot = name == null ? -1 : name.lastIndexOf('.'); |
||||
|
return dot < 0 ? "" : name.substring(dot); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.*; |
||||
|
|
||||
|
/** 删除简历/账号时同事务销毁已发送快照;重新上传不能复活旧授权。 */ |
||||
|
@Service @RequiredArgsConstructor |
||||
|
public class ChatResumeLifecycle { |
||||
|
private final MessageRepository messages; |
||||
|
private final ChatResumeSnapshotRepository snapshots; |
||||
|
private final com.mydreamjob.messaging.realtime.ChatEventService events; |
||||
|
|
||||
|
@Transactional(propagation = Propagation.MANDATORY) |
||||
|
public void purgeForUser(Long userId) { |
||||
|
for (Message candidate : messages.findBySenderUserIdAndMessageType(userId, "RESUME")) { |
||||
|
Message message = messages.findCurrentByIdForRecall(candidate.getId()).orElseThrow(); |
||||
|
snapshots.purge(message.getId()); |
||||
|
if (Boolean.TRUE.equals(message.getResumeRevoked())) continue; |
||||
|
message.setResumeRevoked(true); |
||||
|
message.setResumeFilename(null); message.setResumeContentType(null); message.setResumeSize(null); |
||||
|
messages.saveAndFlush(message); |
||||
|
events.record("RESUME_REVOKED", message.getConversationId(), userId, message.getId()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,152 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import com.mydreamjob.application.*; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.company.*; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.profile.*; |
||||
|
import com.mydreamjob.profile.storage.ResumeStorageService; |
||||
|
import com.mydreamjob.common.exception.*; |
||||
|
import com.mydreamjob.messaging.dto.*; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
/** 当前已上传简历的不可变快照;版本标识即 messageId,不接受任意 URL/文件路径。 */ |
||||
|
@Service @RequiredArgsConstructor |
||||
|
public class ChatResumeService { |
||||
|
private final MessagingService messaging; |
||||
|
private final MessageRepository messages; |
||||
|
private final ConversationRepository conversations; |
||||
|
private final ConversationMemberRepository members; |
||||
|
private final ChatResumeSnapshotRepository snapshots; |
||||
|
private final StudentProfileRepository profiles; |
||||
|
private final ResumeStorageService storage; |
||||
|
private final ApplicationRepository applications; |
||||
|
private final JobRepository jobs; |
||||
|
private final CompanyRepository companies; |
||||
|
private final UserRepository users; |
||||
|
private final CompanyResumeAccessPolicy resumeAccess; |
||||
|
private final com.mydreamjob.security.UserRoleGuard roles; |
||||
|
private final com.mydreamjob.safety.BlockRelationRepository blocks; |
||||
|
private final com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository matches; |
||||
|
private final com.mydreamjob.messaging.realtime.ChatEventService events; |
||||
|
private final MessageViewMapper mapper; |
||||
|
@org.springframework.beans.factory.annotation.Value("${app.chat.resume.max-active-bytes:52428800}") |
||||
|
private long maxActiveBytes; |
||||
|
|
||||
|
public record SendRequest(@jakarta.validation.constraints.NotNull Long applicationId, |
||||
|
@jakarta.validation.constraints.NotBlank |
||||
|
@jakarta.validation.constraints.Pattern(regexp = "[A-Za-z0-9_-]{1,64}") String clientMessageId) {} |
||||
|
public record File(String fileName, String contentType, byte[] bytes) { |
||||
|
@Override public String toString() { return "ResumeFile[redacted]"; } |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public MessageResponse send(Long conversationId, Long userId, SendRequest req) { |
||||
|
roles.requireStudent(userId); |
||||
|
// 与替换/删除上传文件串行;持久化之前读取完整内容,失败则事务回滚。
|
||||
|
var profile = profiles.lockByUserId(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); |
||||
|
guard(conversationId, userId, req.applicationId()); |
||||
|
conversations.findLockedById(conversationId).orElseThrow(() -> new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); |
||||
|
var prior = messages.findByConversationIdAndSenderUserIdAndClientMessageId(conversationId,userId,req.clientMessageId()); |
||||
|
if (prior.isPresent()) { |
||||
|
var applicationId = snapshots.applicationId(prior.get().getId()).orElse(null); |
||||
|
if (!"RESUME".equals(prior.get().getMessageType()) |
||||
|
|| !req.applicationId().equals(applicationId)) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST,"clientMessageId already used"); |
||||
|
return view(prior.get(),userId); |
||||
|
} |
||||
|
if (profile.getResumeUrl() == null || profile.getResumeUrl().isBlank()) |
||||
|
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); |
||||
|
byte[] bytes = storage.read(profile.getResumeUrl()); |
||||
|
if (bytes.length == 0 || bytes.length > 10 * 1024 * 1024) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST,"invalid resume size"); |
||||
|
if (maxActiveBytes <= 0 || messages.activeResumeBytes(userId) + bytes.length > maxActiveBytes) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST,"resume snapshot quota exceeded; revoke old cards first"); |
||||
|
// 复用既有发送鉴权、屏蔽、幂等锁及提交后推送;事件读取提交后的卡片字段。
|
||||
|
SendMessageRequest message = new SendMessageRequest(); |
||||
|
message.setConversationId(conversationId); message.setContent("发送了一份简历"); |
||||
|
message.setClientMessageId(req.clientMessageId()); |
||||
|
var result = messaging.sendMessage(userId,message); |
||||
|
Message saved = messages.findById(((Number)result.get("id")).longValue()).orElseThrow(); |
||||
|
saved.setMessageType("RESUME"); |
||||
|
saved.setResumeFilename(com.mydreamjob.profile.storage.ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(),"resume")); |
||||
|
saved.setResumeContentType(profile.getResumeContentType()); saved.setResumeSize((long)bytes.length); |
||||
|
messages.saveAndFlush(saved); |
||||
|
snapshots.save(ChatResumeSnapshot.builder().messageId(saved.getId()).applicationId(req.applicationId()) |
||||
|
.ownerUserId(userId).content(bytes.clone()).build()); |
||||
|
return view(saved,userId); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public File download(Long conversationId, Long messageId, Long userId) { |
||||
|
messaging.getConversation(conversationId,userId); |
||||
|
Message message = message(conversationId,messageId); |
||||
|
if (!"SENT".equals(message.getStatus()) || Boolean.TRUE.equals(message.getResumeRevoked())) |
||||
|
throw new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE); |
||||
|
Long applicationId = snapshots.applicationId(messageId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE)); |
||||
|
guard(conversationId,userId,applicationId); |
||||
|
var snapshot = snapshots.findById(messageId).orElseThrow(); |
||||
|
User owner = users.findById(snapshot.getOwnerUserId()).orElse(null); |
||||
|
if (owner == null || !Boolean.TRUE.equals(owner.getActive()) || snapshot.getContent() == null) |
||||
|
throw new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE); |
||||
|
return new File(message.getResumeFilename(),message.getResumeContentType(),snapshot.getContent().clone()); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public MessageResponse revoke(Long conversationId, Long messageId, Long userId) { |
||||
|
messaging.getConversation(conversationId,userId); |
||||
|
conversations.findLockedById(conversationId).orElseThrow(); |
||||
|
Message message = message(conversationId,messageId); |
||||
|
if (!userId.equals(message.getSenderUserId())) throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
snapshots.purge(messageId); |
||||
|
if (!Boolean.TRUE.equals(message.getResumeRevoked())) { |
||||
|
message.setResumeRevoked(true); |
||||
|
message.setResumeFilename(null); message.setResumeContentType(null); message.setResumeSize(null); |
||||
|
message = messages.saveAndFlush(message); |
||||
|
events.record("RESUME_REVOKED",conversationId,userId,messageId); |
||||
|
} |
||||
|
return view(message,userId); |
||||
|
} |
||||
|
|
||||
|
private Message message(Long conversationId, Long messageId) { |
||||
|
Message message = messages.findCurrentByIdForRecall(messageId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); |
||||
|
if (!conversationId.equals(message.getConversationId()) || !"RESUME".equals(message.getMessageType())) |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
return message; |
||||
|
} |
||||
|
|
||||
|
private void guard(Long conversationId, Long actor, Long applicationId) { |
||||
|
messaging.getConversation(conversationId,actor); |
||||
|
var application = applications.findById(applicationId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); |
||||
|
var job = jobs.findById(application.getJobId()).orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); |
||||
|
var company = VerifiedCompanyGuard.requireVerified(companies.findById(job.getCompanyId()).orElse(null)); |
||||
|
Long student = application.getStudentUserId(), employer = company.getOwnerUserId(); |
||||
|
if ((!actor.equals(student) && !actor.equals(employer)) |
||||
|
|| members.findByConversationId(conversationId).size() != 2 |
||||
|
|| !members.existsByConversationIdAndUserId(conversationId,student) |
||||
|
|| !members.existsByConversationIdAndUserId(conversationId,employer)) |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
if (actor.equals(student)) roles.requireStudent(actor); else roles.requireCompany(actor); |
||||
|
if ("REJECTED".equals(application.getStatus()) || "WITHDRAWN".equals(application.getStatus())) |
||||
|
throw new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE); |
||||
|
if (blocks.existsByBlockerUserIdAndBlockedUserId(student,employer) |
||||
|
|| blocks.existsByBlockerUserIdAndBlockedUserId(employer,student)) |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
var match = matches.findByApplicationId(applicationId).orElse(null); |
||||
|
if (match == null || !"MATCHED".equals(match.getStatus())) |
||||
|
throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); |
||||
|
// 主动发送不会偷偷覆盖学生的 PRIVATE 设置;双方均必须满足当前下载策略。
|
||||
|
resumeAccess.authorizeForDownload(employer,applicationId); |
||||
|
} |
||||
|
|
||||
|
private MessageResponse view(Message message, Long userId) { |
||||
|
return mapper.toResponse(message,userId,conversations.findById(message.getConversationId()) |
||||
|
.map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import jakarta.persistence.*; |
||||
|
import lombok.*; |
||||
|
|
||||
|
/** 文件只存在受控数据库快照中;不参与消息列表查询、日志或实体 toString。 */ |
||||
|
@Entity @Table(name = "chat_resume_snapshots") |
||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor |
||||
|
public class ChatResumeSnapshot { |
||||
|
@Id @Column(name = "message_id") private Long messageId; |
||||
|
@Column(name = "application_id", nullable = false) private Long applicationId; |
||||
|
@Column(name = "owner_user_id", nullable = false) private Long ownerUserId; |
||||
|
@Lob @Column(columnDefinition = "LONGBLOB") private byte[] content; |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import org.springframework.data.jpa.repository.*; |
||||
|
import org.springframework.data.repository.query.Param; |
||||
|
|
||||
|
public interface ChatResumeSnapshotRepository extends JpaRepository<ChatResumeSnapshot, Long> { |
||||
|
@Query("select r.applicationId from ChatResumeSnapshot r where r.messageId = :id") |
||||
|
java.util.Optional<Long> applicationId(@Param("id") Long messageId); |
||||
|
@Modifying(clearAutomatically = true, flushAutomatically = true) |
||||
|
@Query("update ChatResumeSnapshot r set r.content = null where r.messageId = :id") |
||||
|
int purge(@Param("id") Long messageId); |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import jakarta.persistence.*; |
||||
|
import java.util.*; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
/** 对已完成成员鉴权的会话批量补展示摘要;不查询手机号、实名、简历或虚构在线状态。 */ |
||||
|
@Service @RequiredArgsConstructor |
||||
|
public class ConversationSummaryService { |
||||
|
@PersistenceContext private EntityManager em; |
||||
|
|
||||
|
public void enrich(List<Map<String,Object>> views, Long userId) { |
||||
|
if (views.isEmpty()) return; |
||||
|
List<Long> ids = views.stream().map(v -> (Long)v.get("id")).toList(); |
||||
|
Map<Long,List<Object[]>> contacts = new HashMap<>(); |
||||
|
em.createQuery(""" |
||||
|
select m.conversationId, u.nickname, u.avatarUrl, c.name, c.logoUrl |
||||
|
from ConversationMember m join User u on u.id = m.userId |
||||
|
left join Company c on c.ownerUserId = u.id |
||||
|
where m.conversationId in :ids and m.userId <> :userId |
||||
|
order by m.conversationId, m.id |
||||
|
""",Object[].class).setParameter("ids",ids).setParameter("userId",userId) |
||||
|
.getResultList().forEach(r -> contacts.computeIfAbsent((Long)r[0],key -> new ArrayList<>()).add(r)); |
||||
|
Map<Long,Object[]> last = new HashMap<>(); |
||||
|
em.createQuery(""" |
||||
|
select m.conversationId, m.content, m.createdAt, m.senderUserId, m.status, m.messageType, m.resumeRevoked |
||||
|
from Message m where m.conversationId in :ids and not exists |
||||
|
(select n.id from Message n where n.conversationId = m.conversationId and |
||||
|
(n.createdAt > m.createdAt or (n.createdAt = m.createdAt and n.id > m.id))) |
||||
|
""",Object[].class).setParameter("ids",ids).getResultList() |
||||
|
.forEach(r -> last.put((Long)r[0],r)); |
||||
|
for (Map<String,Object> view : views) { |
||||
|
boolean invitation = "TALENT_INVITATION".equals(view.get("type")); |
||||
|
var peers = contacts.getOrDefault(view.get("id"),List.of()); |
||||
|
String name = "会话成员"; |
||||
|
if (peers.size() == 1) { |
||||
|
Object[] peer = peers.get(0); |
||||
|
// 邀约候选人隐藏昵称/头像;公司公开展示名可见,绝不倒查求职者身份。
|
||||
|
name = peer[3] != null ? peer[3].toString() : invitation ? "求职者" : text(peer[1],"求职者"); |
||||
|
if (peer[3] != null) view.put("companyName",peer[3]); |
||||
|
Object avatar = peer[3] != null ? peer[4] : invitation ? null : peer[2]; |
||||
|
if (avatar != null) view.put("avatarUrl",avatar); |
||||
|
} |
||||
|
view.put("contactName",name); |
||||
|
Object[] message = last.get(view.get("id")); |
||||
|
view.put("lastMessage",message == null ? "" : "RECALLED".equals(message[4]) ? MessageViewMapper.RECALLED_PLACEHOLDER |
||||
|
: "RESUME".equals(message[5]) ? Boolean.TRUE.equals(message[6]) ? "[简历已失效]" : "[简历]" : message[1]); |
||||
|
if (message != null) { |
||||
|
view.put("lastMessageAt",message[2]); |
||||
|
view.put("lastMessageSenderScope",Objects.equals(message[3],userId) ? "CURRENT_USER" : "OTHER_PARTICIPANT"); |
||||
|
} |
||||
|
// 单个 DIRECT 会话可能承载多个岗位,不能捏造唯一岗位名。
|
||||
|
view.put("jobContextAvailable",false); |
||||
|
} |
||||
|
} |
||||
|
private String text(Object value,String fallback) { |
||||
|
return value == null || value.toString().isBlank() ? fallback : value.toString(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
ALTER TABLE messages ADD COLUMN message_type VARCHAR(20) NOT NULL DEFAULT 'TEXT'; |
||||
|
ALTER TABLE messages ADD COLUMN resume_filename VARCHAR(255) NULL; |
||||
|
ALTER TABLE messages ADD COLUMN resume_content_type VARCHAR(120) NULL; |
||||
|
ALTER TABLE messages ADD COLUMN resume_size BIGINT NULL; |
||||
|
ALTER TABLE messages ADD COLUMN resume_revoked BOOLEAN NOT NULL DEFAULT FALSE; |
||||
|
CREATE INDEX idx_messages_sender_type ON messages(sender_user_id, message_type); |
||||
|
CREATE TABLE chat_resume_snapshots ( |
||||
|
message_id BIGINT PRIMARY KEY, |
||||
|
application_id BIGINT NOT NULL, |
||||
|
owner_user_id BIGINT NOT NULL, |
||||
|
content LONGBLOB NULL, |
||||
|
CONSTRAINT fk_chat_resume_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, |
||||
|
CONSTRAINT fk_chat_resume_application FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE, |
||||
|
CONSTRAINT fk_chat_resume_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE, |
||||
|
INDEX idx_chat_resume_owner (owner_user_id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
||||
@ -0,0 +1,207 @@ |
|||||
|
package com.mydreamjob.messaging; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.application.*; |
||||
|
import com.mydreamjob.application.dto.ApplicationStatusRequest; |
||||
|
import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; |
||||
|
import com.mydreamjob.privacy.*; |
||||
|
import com.mydreamjob.profile.*; |
||||
|
import com.mydreamjob.profile.storage.ResumeStorageService; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import jakarta.persistence.*; |
||||
|
import org.junit.jupiter.api.*; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
import java.util.*; |
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
||||
|
|
||||
|
@SpringBootTest(properties = "app.chat.resume.max-active-bytes=5") @AutoConfigureMockMvc @ActiveProfiles("integration-test") @Transactional |
||||
|
class ChatWorkflowIntegrationTest { |
||||
|
@Autowired EntityManager em; |
||||
|
@Autowired ChatResumeService resumes; |
||||
|
@Autowired MessagingService messaging; |
||||
|
@Autowired ProfileService profiles; |
||||
|
@Autowired ApplicationService applications; |
||||
|
@Autowired ChatResumeSnapshotRepository snapshots; |
||||
|
@Autowired MessageRepository messages; |
||||
|
@Autowired com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository matches; |
||||
|
@Autowired com.mydreamjob.interview.InterviewService interviews; |
||||
|
@Autowired com.mydreamjob.offer.OfferService offers; |
||||
|
@Autowired MockMvc mvc; |
||||
|
@Autowired com.mydreamjob.auth.AuthSessionService sessions; |
||||
|
@Autowired java.time.Clock clock; |
||||
|
@MockBean RedisTemplate<String,Object> redis; |
||||
|
@MockBean ResumeStorageService storage; |
||||
|
User student, employer, stranger; |
||||
|
Company company; |
||||
|
Application application; |
||||
|
Conversation conversation; |
||||
|
StudentProfile profile; |
||||
|
StudentPrivacySettings privacy; |
||||
|
|
||||
|
@BeforeEach void fixture() { |
||||
|
student=user("候选人昵称",User.Role.STUDENT); |
||||
|
employer=user("招聘人员",User.Role.COMPANY); |
||||
|
stranger=user("第三人",User.Role.STUDENT); |
||||
|
company=Company.builder().ownerUserId(employer.getId()).name("测试企业").verified(true).build(); |
||||
|
em.persist(company); |
||||
|
Job job=Job.builder().companyId(company.getId()).title("研发岗").description("fixture") |
||||
|
.city("上海").employmentType("FULL_TIME").status("PUBLISHED").build(); em.persist(job); |
||||
|
application=Application.builder().jobId(job.getId()).studentUserId(student.getId()).status("SUBMITTED").build(); |
||||
|
em.persist(application); |
||||
|
em.persist(ApplicationMatchRecord.builder().applicationId(application.getId()).jobId(job.getId()) |
||||
|
.companyId(company.getId()).candidateId(student.getId()).companyRequiredCandidateScore(60) |
||||
|
.studentRequiredHrScore(60).candidateInterestScore(90).hrSuitabilityScore(90).status("MATCHED").build()); |
||||
|
conversation=Conversation.builder().type("DIRECT").createdBy(student.getId()).title("测试").build(); em.persist(conversation); |
||||
|
for(User u:List.of(student,employer)) em.persist(ConversationMember.builder() |
||||
|
.conversationId(conversation.getId()).userId(u.getId()).build()); |
||||
|
profile=StudentProfile.builder().userId(student.getId()).resumeUrl("/controlled/original") |
||||
|
.resumeOriginalName("含实名的简历.pdf").resumeContentType("application/pdf").resumeSize(8L).build();em.persist(profile); |
||||
|
privacy=StudentPrivacySettings.builder().studentUserId(student.getId()) |
||||
|
.resumeVisibility(ResumeVisibility.APPLIED_COMPANIES).build();em.persist(privacy); |
||||
|
em.flush(); |
||||
|
when(storage.read(anyString())).thenAnswer(call -> call.getArgument(0).equals("/controlled/original") |
||||
|
? new byte[]{1,2,3} : new byte[]{8,9}); |
||||
|
} |
||||
|
private User user(String name,User.Role role) { |
||||
|
User u=User.builder().phone("139"+Math.abs(System.nanoTime()%1_000_000_000L)) |
||||
|
.nickname(name).role(role).roles(Set.of(role)).verified(true).active(true).build();em.persist(u);return u; |
||||
|
} |
||||
|
private com.mydreamjob.messaging.dto.MessageResponse send(String key) { |
||||
|
return resumes.send(conversation.getId(),student.getId(),new ChatResumeService.SendRequest(application.getId(),key)); |
||||
|
} |
||||
|
private void denied(Runnable action,int code) {assertEquals(code,assertThrows(BusinessException.class,action::run).getErrorCodeInt());} |
||||
|
|
||||
|
@Test void immutableSnapshotRetryAndReplayKeepOriginalBytes() { |
||||
|
var first=send("snapshot-1"); |
||||
|
assertEquals("RESUME",first.getMessageType());assertEquals("resume.pdf",first.getResume().fileName()); |
||||
|
assertFalse(first.getResume().downloadPath().contains("controlled")); |
||||
|
profile.setResumeUrl("/controlled/replacement");em.flush(); |
||||
|
assertArrayEquals(new byte[]{1,2,3},resumes.download(conversation.getId(),first.getId(),employer.getId()).bytes()); |
||||
|
assertEquals(first.getId(),send("snapshot-1").getId()); |
||||
|
var second=send("snapshot-2"); |
||||
|
assertArrayEquals(new byte[]{8,9},resumes.download(conversation.getId(),second.getId(),employer.getId()).bytes()); |
||||
|
assertEquals(2,snapshots.count()); |
||||
|
var replay=messaging.replayEvents(conversation.getId(),employer.getId(),0,100); |
||||
|
assertFalse(((List<?>)replay.get("items")).isEmpty()); |
||||
|
assertEquals("RESUME",messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0).getMessageType()); |
||||
|
} |
||||
|
@Test void revokedSnapshotIsPurgedAndCannotBeResurrectedByRetry() { |
||||
|
var sent=send("revoke-1");resumes.revoke(conversation.getId(),sent.getId(),student.getId()); |
||||
|
assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent()); |
||||
|
denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); |
||||
|
assertFalse(send("revoke-1").getResume().available()); |
||||
|
assertFalse(resumes.revoke(conversation.getId(),sent.getId(),student.getId()).getResume().available()); |
||||
|
} |
||||
|
@Test void recallPurgesBytesAndHidesCardDownload() { |
||||
|
var sent=send("recall-1"); |
||||
|
messaging.recallMessage(student.getId(),conversation.getId(),sent.getId(),null); |
||||
|
assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent()); |
||||
|
denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); |
||||
|
} |
||||
|
@Test void deletingSourcePermanentlyRevokesPreviouslySentSnapshot() { |
||||
|
var sent=send("delete-1");profiles.deleteResume(student.getId()); |
||||
|
profile.setResumeUrl("/controlled/new");em.flush(); |
||||
|
denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); |
||||
|
assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent()); |
||||
|
} |
||||
|
@Test void otherUserCannotDownloadOrRevoke() { |
||||
|
var sent=send("access-1"); |
||||
|
denied(()->resumes.download(conversation.getId(),sent.getId(),stranger.getId()),2002); |
||||
|
denied(()->resumes.revoke(conversation.getId(),sent.getId(),employer.getId()),2002); |
||||
|
} |
||||
|
@Test void currentPrivacyAndCertificationStillApply() { |
||||
|
var sent=send("privacy-1");privacy.setResumeVisibility(ResumeVisibility.PRIVATE);em.flush(); |
||||
|
denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),2002); |
||||
|
} |
||||
|
@Test void noPrivateResumeCanBeSentAndEmployerCannotSendStudentResume() { |
||||
|
privacy.setResumeVisibility(ResumeVisibility.PRIVATE);em.flush(); |
||||
|
denied(()->send("private-1"),2002); |
||||
|
denied(()->resumes.send(conversation.getId(),employer.getId(),new ChatResumeService.SendRequest(application.getId(),"hr-send")),2002); |
||||
|
} |
||||
|
@Test void unverifiedEmployerLosesDownloadAccess() { |
||||
|
var sent=send("cert-1");company.setVerified(false);em.flush(); |
||||
|
assertThrows(BusinessException.class,()->resumes.download(conversation.getId(),sent.getId(),employer.getId())); |
||||
|
} |
||||
|
@Test void earlyRejectionIsTerminalAndBlocksSnapshot() { |
||||
|
var sent=send("reject-1"); |
||||
|
ApplicationStatusRequest request=new ApplicationStatusRequest();request.setStatus("REJECTED"); |
||||
|
assertEquals("REJECTED",applications.updateStatus(company.getId(),application.getId(),request).getStatus()); |
||||
|
assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); |
||||
|
denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); |
||||
|
request.setStatus("INTERVIEW"); |
||||
|
assertThrows(BusinessException.class,()->applications.updateStatus(company.getId(),application.getId(),request)); |
||||
|
} |
||||
|
@Test void textKeyCannotBeReusedForResumeAndViceVersa() { |
||||
|
var sent=send("shared-key"); |
||||
|
com.mydreamjob.messaging.dto.SendMessageRequest text=new com.mydreamjob.messaging.dto.SendMessageRequest(); |
||||
|
text.setConversationId(conversation.getId());text.setContent("发送了一份简历");text.setClientMessageId("shared-key"); |
||||
|
denied(()->messaging.sendMessage(student.getId(),text),1001); |
||||
|
assertNotNull(sent.getId()); |
||||
|
} |
||||
|
@Test void summaryHasRealPeerAndRedactedInvitationView() { |
||||
|
send("summary-1"); |
||||
|
var forStudent=messaging.getConversation(conversation.getId(),student.getId()); |
||||
|
assertEquals("测试企业",forStudent.get("contactName"));assertEquals("[简历]",forStudent.get("lastMessage")); |
||||
|
var forHr=messaging.getConversation(conversation.getId(),employer.getId()); |
||||
|
assertEquals("候选人昵称",forHr.get("contactName"));assertFalse(forHr.containsKey("online")); |
||||
|
conversation.setType("TALENT_INVITATION");em.flush(); |
||||
|
var anonymous=messaging.getConversation(conversation.getId(),employer.getId()); |
||||
|
assertEquals("求职者",anonymous.get("contactName"));assertFalse(anonymous.containsKey("createdBy")); |
||||
|
var message=messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0); |
||||
|
assertNull(message.getSenderUserId());assertEquals("OTHER_PARTICIPANT",message.getSenderScope()); |
||||
|
assertEquals("resume.pdf",message.getResume().fileName()); |
||||
|
} |
||||
|
@Test void downloadIsBinaryAndNeverPublicOrCached() throws Exception { |
||||
|
var sent=send("http-1"); |
||||
|
var token=sessions.createSession(employer,clock.instant(),com.mydreamjob.audit.AuditContext.builder().build()).accessToken(); |
||||
|
String url="/conversations/"+conversation.getId()+"/messages/"+sent.getId()+"/resume"; |
||||
|
mvc.perform(get(url).header("Authorization","Bearer "+token)).andExpect(status().isOk()) |
||||
|
.andExpect(header().string("Cache-Control","no-store")) |
||||
|
.andExpect(content().contentType("application/pdf")).andExpect(content().bytes(new byte[]{1,2,3})); |
||||
|
mvc.perform(get(url)).andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
|
||||
|
@Test void storageQuotaIsBoundedAndRevocationReleasesIt() { |
||||
|
var first=send("quota-1"); |
||||
|
denied(()->send("quota-2"),1001); |
||||
|
resumes.revoke(conversation.getId(),first.getId(),student.getId()); |
||||
|
assertNotNull(send("quota-3").getId()); |
||||
|
} |
||||
|
|
||||
|
@Test void rejectedApplicationCannotCreateInterviewOrSendExistingOfferDraft() { |
||||
|
var offer=new com.mydreamjob.offer.dto.OfferCreateRequest(); |
||||
|
offer.setApplicationId(application.getId());offer.setTitle("测试录用");offer.setWorkCity("上海"); |
||||
|
offer.setDescription("fixture");offer.setExpireAt(java.time.LocalDateTime.now().plusDays(10)); |
||||
|
var draft=offers.createOffer(employer.getId(),offer,null); |
||||
|
var interview=new com.mydreamjob.interview.dto.InterviewCreateRequest(); |
||||
|
interview.setTitle("测试面试");interview.setInterviewType("ONLINE");interview.setMeetingUrl("https://example.com/meeting"); |
||||
|
interview.setStartAt(java.time.LocalDateTime.now().plusDays(1));interview.setEndAt(java.time.LocalDateTime.now().plusDays(1).plusHours(1)); |
||||
|
var scheduled=interviews.createInterview(employer.getId(),application.getId(),interview,null); |
||||
|
ApplicationStatusRequest reject=new ApplicationStatusRequest();reject.setStatus("REJECTED"); |
||||
|
application.setStatus("VIEWED");em.flush(); |
||||
|
applications.updateStatus(company.getId(),application.getId(),reject); |
||||
|
denied(()->interviews.createInterview(employer.getId(),application.getId(),interview,null),8003); |
||||
|
var confirm=new com.mydreamjob.interview.dto.InterviewRespondRequest();confirm.setAction("CONFIRM"); |
||||
|
denied(()->interviews.respondToInterview(student.getId(),scheduled.getId(),confirm,null),8003); |
||||
|
var send=new com.mydreamjob.offer.dto.OfferStatusRequest();send.setStatus("SENT"); |
||||
|
denied(()->offers.transitionStatus(employer.getId(),draft.getId(),send,null),8106); |
||||
|
} |
||||
|
|
||||
|
@Test void exportRuntimeContractForIndependentComparison() throws Exception { |
||||
|
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); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue