refactor(backend): P0 阻断修复 + 按最新修改时间收敛重复模块与契约
## P0 阻断修复
- 编译:修复实体类与服务类同名导致 Lombok 注解轮次中止的问题
(MatchmakerService/CounselingService -> *Record),196 个编译错误归零
- 权限矩阵:platform_admin 按设计文档收紧为拒绝 5 项高危权限(不改测试阈值)
- 启动:修复 8 组 MyBatis-Plus 同名 bean 冲突(ConflictingBeanDefinition)
与 URL 路由歧义(Ambiguous mapping),应用可正常启动
## 模块收敛(裁决规则:以文件修改时间为准)
- messaging 包整体删除,能力迁移至 chat 包
* 会话列表/详情/未读端点、ConversationDTO、CursorPage
* 消息游标分页统一为 { items, nextCursor }
- relationship 的 Match/UserRelation entity 与 mapper 合并进 match 包
- MatchmakerUserController 合并进 MatchmakerController,删除冗余 Controller
- 补齐 INV-004:chat sendText 增加双向 Block 校验,拉黑后禁止发送
## 契约对齐
- 红娘服务提交请求兼容前端 { requirement } 字段
- 消息与会话分页契约前后端统一
## 测试
- 迁移 6 个风控用例至 ChatSafetyServiceTest
- 清理 messaging 测试包遗留:6 个真实常量断言归位 chat 包,
移除 2 个 assertTrue(true) 占位假测试(Block 真实行为已由
P0FixVerificationTest / RelationshipBlockPriorityPhase08Test 覆盖)
- schema-h2.sql 对齐 PostgreSQL 真实表结构(matches 补列、
conversation_members 改复合主键)
结果:290 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
This commit is contained in:
+8
-10
@@ -8,7 +8,6 @@ import com.danyang.match.platform.account.mapper.ConsentRecordMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
@@ -18,7 +17,7 @@ import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
@@ -52,7 +51,6 @@ public class AccountLifecycleService {
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final RiskActionMapper riskActionMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
@@ -173,9 +171,9 @@ public class AccountLifecycleService {
|
||||
|
||||
// V1.1: 检查是否有进行中的咨询
|
||||
long activeCounselings = counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingService>()
|
||||
.eq(CounselingService::getUserId, userId)
|
||||
.in(CounselingService::getStatus,
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getUserId, userId)
|
||||
.in(CounselingServiceRecord::getStatus,
|
||||
Constants.COUNSELING_STATUS_PENDING,
|
||||
Constants.COUNSELING_STATUS_ASSIGNED,
|
||||
Constants.COUNSELING_STATUS_SCHEDULED,
|
||||
@@ -399,9 +397,9 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
private void cancelCounselingServices(String userId) {
|
||||
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
|
||||
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
|
||||
int cancelled = 0;
|
||||
for (CounselingService cs : services) {
|
||||
for (CounselingServiceRecord cs : services) {
|
||||
if (Constants.COUNSELING_STATUS_PENDING.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_ASSIGNED.equals(cs.getStatus()) ||
|
||||
Constants.COUNSELING_STATUS_SCHEDULED.equals(cs.getStatus()) ||
|
||||
@@ -440,8 +438,8 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
private void anonymizeServiceHistory(String userId) {
|
||||
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
|
||||
for (CounselingService cs : services) {
|
||||
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
|
||||
for (CounselingServiceRecord cs : services) {
|
||||
if (cs.getUserRequestSummary() != null && cs.getUserRequestSummary().length() > 10) {
|
||||
cs.setUserRequestSummary("***已脱敏***");
|
||||
counselingServiceMapper.updateById(cs);
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.util.*;
|
||||
public class ActivityMutualConsentService {
|
||||
|
||||
private final ActivityMutualConsentMapper consentMapper;
|
||||
private final com.danyang.match.platform.relationship.mapper.MatchMapper matchMapper;
|
||||
private final com.danyang.match.platform.match.mapper.MatchMapper matchMapper;
|
||||
|
||||
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
|
||||
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
|
||||
|
||||
@@ -4,13 +4,13 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.match.entity.RecommendationRecord;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.messaging.entity.Conversation;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.risk.entity.Report;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
@@ -30,7 +30,7 @@ import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMap
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.content.entity.ContentArticle;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgram;
|
||||
@@ -365,8 +365,8 @@ public class AnalyticsService {
|
||||
|
||||
public Map<String, Object> getCounselingStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingService> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt)
|
||||
List<CounselingServiceRecord> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>().isNull(CounselingServiceRecord::getDeletedAt)
|
||||
);
|
||||
stats.put("total", all.size());
|
||||
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
|
||||
@@ -379,10 +379,10 @@ public class AnalyticsService {
|
||||
|
||||
public Map<String, Object> getPremaritalStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingService> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingService>()
|
||||
.eq(CounselingService::getServiceType, "premarital_guidance")
|
||||
.isNull(CounselingService::getDeletedAt)
|
||||
List<CounselingServiceRecord> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)
|
||||
);
|
||||
stats.put("totalRequests", all.size());
|
||||
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
|
||||
@@ -449,8 +449,8 @@ public class AnalyticsService {
|
||||
|
||||
public Map<String, Object> getUniqueServiceUsersStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt))
|
||||
.stream().map(CounselingService::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingServiceRecord>().isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.stream().map(CounselingServiceRecord::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
|
||||
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> pu = serviceProgramMemberMapper.selectList(
|
||||
|
||||
+66
-23
@@ -1,5 +1,7 @@
|
||||
package com.danyang.match.platform.chat.controller;
|
||||
|
||||
import com.danyang.match.platform.chat.dto.ConversationDTO;
|
||||
import com.danyang.match.platform.chat.dto.CursorPage;
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
@@ -12,20 +14,25 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Conversation 端点
|
||||
*
|
||||
* POST /api/v1/conversations/from-match/{matchId} — 从 Match 创建或获取 Conversation
|
||||
* GET /api/v1/conversations — 我的活跃 Conversation 列表(游标分页)
|
||||
* GET /api/v1/conversations/{id} — 详情
|
||||
* GET /api/v1/conversations — 我的活跃 Conversation 列表
|
||||
* GET /api/v1/conversations/{id}/unread — 该会话未读数
|
||||
*
|
||||
* 契约对齐小程序 types/index.ts 的 Conversation。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/conversations")
|
||||
@RequiredArgsConstructor
|
||||
public class ConversationController {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
private final ChatService chatService;
|
||||
private final MatchMapper matchMapper;
|
||||
|
||||
@@ -37,8 +44,8 @@ public class ConversationController {
|
||||
* 三种 source 的 Match 都能建 Conversation。
|
||||
*/
|
||||
@PostMapping("/from-match/{matchId}")
|
||||
public ApiResponse<ConversationResponse> fromMatch(@PathVariable String matchId,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
Match match = matchMapper.findById(matchId).orElse(null);
|
||||
if (match == null) return ApiResponse.notFound();
|
||||
@@ -47,31 +54,67 @@ public class ConversationController {
|
||||
return ApiResponse.forbidden();
|
||||
}
|
||||
Conversation conv = chatService.getOrCreateConversation(matchId, match.getUserAId(), match.getUserBId());
|
||||
return ApiResponse.ok(ConversationResponse.from(conv, match));
|
||||
return ApiResponse.ok(chatService.getConversation(principal.id(), conv.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的会话列表(游标分页)
|
||||
* 返回 { items, nextCursor },对齐小程序 fetchConversations 的期望。
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<List<ConversationResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
public ApiResponse<CursorPage<ConversationDTO>> list(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) String cursor,
|
||||
@RequestParam(required = false) Integer pageSize,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<String> convIds = List.of(); // TODO: 通过 memberMapper 拿
|
||||
return ApiResponse.ok(List.of());
|
||||
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
|
||||
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
|
||||
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
|
||||
List<ConversationDTO> items = chatService.listConversations(principal.id(), size, off);
|
||||
// 满页才给下一页游标,末页返回 null
|
||||
String nextCursor = items.size() == size ? String.valueOf(off + items.size()) : null;
|
||||
return ApiResponse.ok(CursorPage.of(items, nextCursor));
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ConversationResponse {
|
||||
private String id;
|
||||
private String matchId;
|
||||
private String status;
|
||||
private String matchSource;
|
||||
private String peerUserId;
|
||||
|
||||
public static ConversationResponse from(Conversation conv, Match match) {
|
||||
ConversationResponse r = new ConversationResponse();
|
||||
r.id = conv.getId();
|
||||
r.matchId = conv.getMatchId();
|
||||
r.status = conv.getStatus();
|
||||
r.matchSource = match.getSource();
|
||||
return r;
|
||||
private static int parseIntOrDefault(String value, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话详情
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
ConversationDTO dto = chatService.getConversation(principal.id(), id);
|
||||
if (dto == null) return ApiResponse.notFound();
|
||||
return ApiResponse.ok(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话未读数
|
||||
* 返回 { unreadCount }
|
||||
*/
|
||||
@GetMapping("/{id}/unread")
|
||||
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部会话未读总数(角标)
|
||||
*/
|
||||
@GetMapping("/unread-total")
|
||||
public ApiResponse<Map<String, Object>> unreadTotal(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(Map.of("unreadCount", chatService.getTotalUnread(principal.id())));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+53
-13
@@ -1,5 +1,6 @@
|
||||
package com.danyang.match.platform.chat.controller;
|
||||
|
||||
import com.danyang.match.platform.chat.dto.CursorPage;
|
||||
import com.danyang.match.platform.chat.entity.Message;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
@@ -9,6 +10,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -16,16 +19,22 @@ import java.util.Map;
|
||||
* Message 端点(HTTP REST + 幂等)
|
||||
*
|
||||
* POST /api/v1/conversations/{id}/messages — 发消息
|
||||
* GET /api/v1/conversations/{id}/messages — 拉历史
|
||||
* GET /api/v1/conversations/{id}/messages — 拉历史(游标分页)
|
||||
* PUT /api/v1/conversations/{id}/messages/read — 标记已读
|
||||
*
|
||||
* 实时推送走 WebSocket(Phase 09 同时提供,Phase 10 Chat Safety 加风控)
|
||||
*
|
||||
* 契约对齐小程序 types/index.ts 的 Message:
|
||||
* messageId / conversationId / senderId / messageType / content / fileId
|
||||
* / status / createdAt / mine / bubbleType
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/conversations")
|
||||
@RequiredArgsConstructor
|
||||
public class MessageController {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private final ChatService chatService;
|
||||
|
||||
@PostMapping("/{id}/messages")
|
||||
@@ -33,18 +42,39 @@ public class MessageController {
|
||||
@RequestBody SendRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
Message msg = chatService.sendText(id, principal.id(), req.content, req.clientMessageId);
|
||||
return ApiResponse.ok(MessageResponse.from(msg));
|
||||
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
|
||||
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
|
||||
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
|
||||
return ApiResponse.ok(MessageResponse.from(msg, principal.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉历史消息(游标分页)
|
||||
* 传 after=上页最后一条 messageId 做增量拉取;否则按最新一页返回(正序)。
|
||||
*/
|
||||
@GetMapping("/{id}/messages")
|
||||
public ApiResponse<List<MessageResponse>> load(@PathVariable String id,
|
||||
@RequestParam(defaultValue = "30") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
public ApiResponse<CursorPage<MessageResponse>> load(@PathVariable String id,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) String after,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<Message> messages = chatService.loadMessages(id, Math.min(limit, 100), offset);
|
||||
return ApiResponse.ok(messages.stream().map(MessageResponse::from).toList());
|
||||
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||
|
||||
List<Message> messages;
|
||||
if (after != null && !after.isBlank()) {
|
||||
messages = chatService.loadAfter(id, after, size);
|
||||
} else {
|
||||
// 库内按时间倒序取,展示前翻正为正序
|
||||
messages = new ArrayList<>(chatService.loadMessages(id, size, Math.max(offset, 0)));
|
||||
Collections.reverse(messages);
|
||||
}
|
||||
|
||||
List<MessageResponse> items = messages.stream()
|
||||
.map(m -> MessageResponse.from(m, principal.id()))
|
||||
.toList();
|
||||
String nextCursor = items.isEmpty() ? null : items.get(items.size() - 1).getMessageId();
|
||||
return ApiResponse.ok(CursorPage.of(items, nextCursor));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/messages/read")
|
||||
@@ -64,30 +94,40 @@ public class MessageController {
|
||||
@Data
|
||||
public static class SendRequest {
|
||||
private String content;
|
||||
private String clientMessageId; // 幂等键
|
||||
/** 幂等键(后端契约) */
|
||||
private String clientMessageId;
|
||||
/** 幂等键兼容字段:小程序早期约定为 messageId */
|
||||
private String messageId;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MessageResponse {
|
||||
private String id;
|
||||
private String messageId;
|
||||
private String conversationId;
|
||||
private String senderId;
|
||||
private String clientMessageId;
|
||||
private String messageType;
|
||||
private String content;
|
||||
private String fileId;
|
||||
private String status;
|
||||
private java.time.Instant createdAt;
|
||||
private boolean mine;
|
||||
private String bubbleType;
|
||||
|
||||
public static MessageResponse from(Message m) {
|
||||
public static MessageResponse from(Message m, String currentUserId) {
|
||||
MessageResponse r = new MessageResponse();
|
||||
r.id = m.getId();
|
||||
boolean isMine = currentUserId != null && currentUserId.equals(m.getSenderId());
|
||||
r.messageId = m.getId();
|
||||
r.conversationId = m.getConversationId();
|
||||
r.senderId = m.getSenderId();
|
||||
r.clientMessageId = m.getClientMessageId();
|
||||
r.messageType = m.getMessageType();
|
||||
r.content = m.getContent();
|
||||
r.fileId = m.getFileId();
|
||||
r.status = m.getStatus();
|
||||
r.createdAt = m.getCreatedAt();
|
||||
r.mine = isMine;
|
||||
r.bubbleType = isMine ? "self" : "other";
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.danyang.match.platform.chat.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 会话列表/详情响应
|
||||
*
|
||||
* 字段对齐小程序 types/index.ts 的 Conversation 契约:
|
||||
* conversationId / matchId / otherUserId / otherDisplayName / otherAvatarUrl
|
||||
* / lastMessage / lastMessageAt / unreadCount / status / canSendMessage
|
||||
*/
|
||||
@Data
|
||||
public class ConversationDTO {
|
||||
|
||||
private String conversationId;
|
||||
private String matchId;
|
||||
private String otherUserId;
|
||||
private String otherDisplayName;
|
||||
private String otherAvatarUrl;
|
||||
private String lastMessage;
|
||||
private Instant lastMessageAt;
|
||||
private int unreadCount;
|
||||
private String status;
|
||||
private boolean canSendMessage;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.danyang.match.platform.chat.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 游标分页响应
|
||||
* 对齐小程序期望的 { items, nextCursor } 结构。
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class CursorPage<T> {
|
||||
|
||||
private List<T> items;
|
||||
private String nextCursor;
|
||||
|
||||
public static <T> CursorPage<T> of(List<T> items, String nextCursor) {
|
||||
return new CursorPage<>(items, nextCursor);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,34 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ConversationMapper extends BaseMapper<Conversation> {
|
||||
|
||||
/** 按主键查询(带软删过滤) */
|
||||
@Select("SELECT * FROM conversations WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Conversation> findById(@Param("id") String id);
|
||||
|
||||
@Select("SELECT * FROM conversations WHERE match_id = #{matchId} LIMIT 1")
|
||||
Optional<Conversation> findByMatchId(@Param("matchId") String matchId);
|
||||
|
||||
/**
|
||||
* 我的活跃会话(JOIN 排序)
|
||||
* 原 ConversationMemberMapper.findActiveConversationIdsByUser 的子查询 ORDER BY 无效,
|
||||
* 这里改为显式 JOIN,保证按最后消息时间倒序。
|
||||
*/
|
||||
@Select("SELECT c.* FROM conversations c " +
|
||||
"JOIN conversation_members m ON m.conversation_id = c.id " +
|
||||
"WHERE m.user_id = #{userId} AND c.status = 'active' AND c.deleted_at IS NULL " +
|
||||
"ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC " +
|
||||
"LIMIT #{limit} OFFSET #{offset}")
|
||||
List<Conversation> findActiveByUser(@Param("userId") String userId,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
|
||||
/** 我的未读会话总数(用于角标) */
|
||||
@Select("SELECT COALESCE(SUM(unread_count), 0) FROM conversation_members WHERE user_id = #{userId}")
|
||||
int sumUnreadByUser(@Param("userId") String userId);
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package com.danyang.match.platform.chat.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.chat.entity.ConversationMember;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@@ -12,7 +13,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ConversationMemberMapper {
|
||||
public interface ConversationMemberMapper extends BaseMapper<ConversationMember> {
|
||||
|
||||
@Select("SELECT * FROM conversation_members WHERE conversation_id = #{convId} AND user_id = #{userId}")
|
||||
Optional<ConversationMember> find(@Param("convId") String convId, @Param("userId") String userId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface MessageMapper extends BaseMapper<Message> {
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -108,7 +109,7 @@ public class ChatSafetyService {
|
||||
try {
|
||||
Long count = redis.opsForValue().increment(key);
|
||||
if (count != null && count == 1) {
|
||||
redis.expire(key, WINDOW_SECONDS);
|
||||
redis.expire(key, Duration.ofSeconds(WINDOW_SECONDS));
|
||||
}
|
||||
if (count != null && count > limit) {
|
||||
log.warn("Chat rate limit hit: user={}, count={}, limit={}", userId, count, limit);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.danyang.match.platform.chat.service;
|
||||
|
||||
import com.danyang.match.platform.chat.dto.ConversationDTO;
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.entity.ConversationMember;
|
||||
import com.danyang.match.platform.chat.entity.Message;
|
||||
@@ -9,6 +10,7 @@ import com.danyang.match.platform.chat.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
@@ -41,6 +43,7 @@ public class ChatService {
|
||||
private final MessageMapper messageMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final UserRelationMapper relationMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final ChatSafetyService safetyService;
|
||||
|
||||
// ==================== Conversation 创建 ====================
|
||||
@@ -64,13 +67,15 @@ public class ChatService {
|
||||
conversationMapper.insert(conv);
|
||||
|
||||
for (String uid : List.of(userA, userB)) {
|
||||
// 幂等:先查再插,避免依赖 PostgreSQL 的 ON CONFLICT(H2 测试环境不支持)
|
||||
if (memberMapper.find(conv.getId(), uid).isPresent()) continue;
|
||||
ConversationMember m = new ConversationMember();
|
||||
m.setConversationId(conv.getId());
|
||||
m.setUserId(uid);
|
||||
m.setUnreadCount(0);
|
||||
m.setCanSend(true);
|
||||
m.setJoinedAt(Instant.now());
|
||||
memberMapper.insertMemberIfAbsent(m);
|
||||
memberMapper.insert(m);
|
||||
}
|
||||
|
||||
log.info("Conversation created: {} for match {}, users {}+{}", conv.getId(), matchId, userA, userB);
|
||||
@@ -99,17 +104,29 @@ public class ChatService {
|
||||
|
||||
Optional<ConversationMember> senderMember = memberMapper.find(conversationId, senderId);
|
||||
if (senderMember.isEmpty()) throw new IllegalStateException("您不是此会话成员");
|
||||
if (!Boolean.TRUE.equals(senderMember.getCanSend())) throw new IllegalStateException("当前被禁言");
|
||||
if (!Boolean.TRUE.equals(senderMember.get().getCanSend())) throw new IllegalStateException("当前被禁言");
|
||||
|
||||
if (!Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus())) {
|
||||
throw new IllegalStateException("会话已关闭");
|
||||
}
|
||||
|
||||
// 幂等重放优先:同一 clientMessageId 已成功落库则直接返回原消息,不重复走安全校验
|
||||
if (clientMessageId != null) {
|
||||
Optional<Message> dup = messageMapper.findByClientMessageId(conversationId, senderId, clientMessageId);
|
||||
if (dup.isPresent()) return dup.get();
|
||||
}
|
||||
|
||||
List<ConversationMember> members = memberMapper.findByConversation(conversationId);
|
||||
|
||||
// INV-004 / AGENTS.md #4: Block 优先级最高 —— 与任一成员存在拉黑关系(任一方向)即禁止发送
|
||||
for (ConversationMember m : members) {
|
||||
if (senderId.equals(m.getUserId())) continue;
|
||||
if (relationMapper.countBlockedEitherWay(senderId, m.getUserId()) > 0) {
|
||||
log.warn("Message rejected by BLOCK: sender={}, peer={}", senderId, m.getUserId());
|
||||
throw new IllegalStateException("存在拉黑关系,无法发送消息");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Phase 10: Chat Safety ====================
|
||||
|
||||
// 1. 频率限制(前 5 分钟严格模式)
|
||||
@@ -145,7 +162,6 @@ public class ChatService {
|
||||
conversationMapper.updateById(conv);
|
||||
|
||||
// 对方 unread +1
|
||||
List<ConversationMember> members = memberMapper.findByConversation(conversationId);
|
||||
String peerId = null;
|
||||
for (ConversationMember m : members) {
|
||||
if (!m.getUserId().equals(senderId)) {
|
||||
@@ -214,4 +230,85 @@ public class ChatService {
|
||||
conversationMapper.updateById(conv);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 会话查询(原 messaging 模块能力合并) ====================
|
||||
|
||||
/**
|
||||
* 我的活跃会话列表,按最后消息时间倒序。
|
||||
*/
|
||||
public List<ConversationDTO> listConversations(String userId, int limit, int offset) {
|
||||
return conversationMapper.findActiveByUser(userId, limit, offset).stream()
|
||||
.map(c -> toConversationDTO(userId, c))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话详情。
|
||||
*/
|
||||
public ConversationDTO getConversation(String userId, String conversationId) {
|
||||
return conversationMapper.findById(conversationId)
|
||||
.map(c -> toConversationDTO(userId, c))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个会话未读数。
|
||||
*/
|
||||
public int getUnreadCount(String userId, String conversationId) {
|
||||
return memberMapper.find(conversationId, userId)
|
||||
.map(m -> m.getUnreadCount() == null ? 0 : m.getUnreadCount())
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部会话未读总数(角标)。
|
||||
*/
|
||||
public int getTotalUnread(String userId) {
|
||||
return conversationMapper.sumUnreadByUser(userId);
|
||||
}
|
||||
|
||||
private ConversationDTO toConversationDTO(String userId, Conversation conv) {
|
||||
ConversationDTO dto = new ConversationDTO();
|
||||
dto.setConversationId(conv.getId());
|
||||
dto.setMatchId(conv.getMatchId());
|
||||
dto.setStatus(conv.getStatus());
|
||||
dto.setLastMessageAt(conv.getLastMessageAt());
|
||||
|
||||
// 对方
|
||||
String peerId = null;
|
||||
boolean matchAlive = false;
|
||||
Match match = conv.getMatchId() == null ? null : matchMapper.findById(conv.getMatchId()).orElse(null);
|
||||
if (match != null) {
|
||||
peerId = userId.equals(match.getUserAId()) ? match.getUserBId() : match.getUserAId();
|
||||
matchAlive = Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus())
|
||||
|| Constants.MATCH_STATUS_GETTING_TO_KNOW.equals(match.getStatus());
|
||||
}
|
||||
dto.setOtherUserId(peerId);
|
||||
|
||||
if (peerId != null) {
|
||||
userProfileMapper.findByUserId(peerId).ifPresent(p -> dto.setOtherDisplayName(p.getDisplayName()));
|
||||
}
|
||||
|
||||
// 未读数
|
||||
Integer unread = memberMapper.find(conv.getId(), userId)
|
||||
.map(ConversationMember::getUnreadCount)
|
||||
.orElse(0);
|
||||
dto.setUnreadCount(unread == null ? 0 : unread);
|
||||
|
||||
// 最后一条消息预览
|
||||
if (conv.getLastMessageId() != null) {
|
||||
Message last = messageMapper.selectById(conv.getLastMessageId());
|
||||
if (last != null) dto.setLastMessage(last.getContent());
|
||||
}
|
||||
|
||||
// INV-004: Block 优先级最高 —— 任一方向拉黑即禁发
|
||||
boolean blocked = peerId != null && relationMapper.countBlockedEitherWay(userId, peerId) > 0;
|
||||
boolean canSend = memberMapper.find(conv.getId(), userId)
|
||||
.map(m -> Boolean.TRUE.equals(m.getCanSend()))
|
||||
.orElse(false);
|
||||
dto.setCanSendMessage(canSend && matchAlive && !blocked
|
||||
&& Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus()));
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
package com.danyang.match.platform.counseling.controller;
|
||||
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
@@ -26,7 +26,7 @@ import java.util.List;
|
||||
* PUT /api/v1/counseling/services/{id}/schedule — assigned Counselor 排期(INV-012)
|
||||
* PUT /api/v1/counseling/services/{id}/complete — assigned Counselor 完成(INV-012)
|
||||
*
|
||||
* 权限由 CounselingService.getDetail() / assertAssigned() 在 Service 层守卫,
|
||||
* 权限由 CounselingServiceRecord.getDetail() / assertAssigned() 在 Service 层守卫,
|
||||
* Controller 层只做路由 + principal 透传。
|
||||
*/
|
||||
@RestController
|
||||
@@ -39,7 +39,7 @@ public class CounselingController {
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
@PostMapping("/services")
|
||||
public ApiResponse<CounselingService> apply(@RequestBody ApplyRequest req,
|
||||
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.apply(
|
||||
@@ -52,17 +52,17 @@ public class CounselingController {
|
||||
}
|
||||
|
||||
@GetMapping("/services")
|
||||
public ApiResponse<List<CounselingService>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.myServices(principal.id(), limit));
|
||||
}
|
||||
|
||||
@GetMapping("/services/{id}")
|
||||
public ApiResponse<CounselingService> detail(@PathVariable String id,
|
||||
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
CounselingService svc = counselingService.getDetail(principal.id(), principal.role(), id);
|
||||
CounselingServiceRecord svc = counselingService.getDetail(principal.id(), principal.role(), id);
|
||||
if (svc == null) return ApiResponse.forbidden();
|
||||
return ApiResponse.ok(svc);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class CounselingController {
|
||||
// ==================== Counselor / Manager 端 ====================
|
||||
|
||||
@GetMapping("/assigned")
|
||||
public ApiResponse<List<CounselingService>> assigned(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
public ApiResponse<List<CounselingServiceRecord>> assigned(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(counselingService.assignedTo(principal.id()));
|
||||
}
|
||||
|
||||
+13
-1
@@ -9,6 +9,10 @@ import lombok.EqualsAndHashCode;
|
||||
* 婚恋咨询 / 婚前辅导 服务
|
||||
* 对应 DB: counseling_services
|
||||
*
|
||||
* 命名说明:类名加 Record 后缀,避免与 counseling.service.CounselingService(Spring 服务类)
|
||||
* 产生同名冲突 —— 同名 import 会让 javac 报 "already defined in this compilation unit",
|
||||
* 并中断注解处理轮次,进而导致 Lombok 失效。
|
||||
*
|
||||
* service_type: "counseling"(婚恋咨询)/ "premarital"(婚前辅导)
|
||||
* request_source: "user_self"(用户自己申请)/ "matchmaker_referral"(红娘转介)
|
||||
* status: pending → assigned → scheduled → in_progress → completed / cancelled
|
||||
@@ -19,7 +23,7 @@ import lombok.EqualsAndHashCode;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("counseling_services")
|
||||
public class CounselingService extends BaseEntity {
|
||||
public class CounselingServiceRecord extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String serviceType; // counseling / premarital
|
||||
@@ -33,4 +37,12 @@ public class CounselingService extends BaseEntity {
|
||||
private java.time.Instant scheduledStartAt;
|
||||
private java.time.Instant scheduledEndAt;
|
||||
private java.time.Instant completedAt;
|
||||
|
||||
// 以下字段对应 counseling_services 表既有列(V022),此前实体未同步
|
||||
/** 陪同人(如一同参与辅导的伴侣) */
|
||||
private String companionUserId;
|
||||
/** 代用户创建该服务的运营人员(红娘/服务站代提交场景) */
|
||||
private String createdByOperatorId;
|
||||
/** 关闭原因(用户取消 / 运营关闭) */
|
||||
private String closedReason;
|
||||
}
|
||||
+25
-6
@@ -1,29 +1,48 @@
|
||||
package com.danyang.match.platform.counseling.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface CounselingServiceMapper extends BaseMapper<CounselingService> {
|
||||
public interface CounselingServiceMapper extends BaseMapper<CounselingServiceRecord> {
|
||||
|
||||
/** 按主键查询(带软删过滤) */
|
||||
@Select("SELECT * FROM counseling_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<CounselingServiceRecord> findById(@Param("id") String id);
|
||||
|
||||
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
|
||||
List<CounselingService> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
List<CounselingServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
|
||||
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} AND status IN ('pending','assigned','scheduled','in_progress') ORDER BY created_at DESC")
|
||||
List<CounselingService> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
List<CounselingServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM counseling_services WHERE id = #{id}")
|
||||
CounselingService findFull(@Param("id") String id);
|
||||
CounselingServiceRecord findFull(@Param("id") String id);
|
||||
|
||||
/** 按用户查询全部(不限条数)— 账号注销等需要全量处理的场景 */
|
||||
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<CounselingServiceRecord> findByUserId(@Param("userId") String userId);
|
||||
|
||||
/** 全部待处理(未分配)— 管理人员分派工作台 */
|
||||
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id IS NULL " +
|
||||
"AND status IN ('pending') AND deleted_at IS NULL ORDER BY created_at ASC")
|
||||
List<CounselingServiceRecord> findPendingAll();
|
||||
|
||||
/** 按分配的咨询人员查询 — INV-012: Counselor 只能看到分配给自己的 */
|
||||
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<CounselingServiceRecord> findByAssignedOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** INV-013: 脱敏查询 — 给 Matchmaker 用,跳过敏感字段 */
|
||||
@Select("SELECT id, user_id, service_type, request_source, organization_id, assigned_operator_id, status, " +
|
||||
"preferred_service_mode, preferred_time, scheduled_start_at, scheduled_end_at, completed_at, " +
|
||||
"created_at, updated_at " +
|
||||
"FROM counseling_services WHERE id = #{id}")
|
||||
CounselingService findSanitized(@Param("id") String id);
|
||||
CounselingServiceRecord findSanitized(@Param("id") String id);
|
||||
}
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
package com.danyang.match.platform.counseling.service;
|
||||
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -52,9 +52,9 @@ public class CounselingService {
|
||||
* @param requestSource "user_self" / "matchmaker_referral"
|
||||
*/
|
||||
@Transactional
|
||||
public CounselingService apply(String userId, String serviceType, String summary,
|
||||
public CounselingServiceRecord apply(String userId, String serviceType, String summary,
|
||||
String requestSource, String preferredMode, String preferredTime) {
|
||||
CounselingService svc = new CounselingService();
|
||||
CounselingServiceRecord svc = new CounselingServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(serviceType);
|
||||
@@ -66,12 +66,12 @@ public class CounselingService {
|
||||
mapper.insert(svc);
|
||||
|
||||
// P11-T04: Counseling assigned 通知(pending 状态,等分配后再发 assigned 通知)
|
||||
log.info("CounselingService submitted: userId={}, type={}, source={}", userId, serviceType, requestSource);
|
||||
log.info("CounselingServiceRecord submitted: userId={}, type={}, source={}", userId, serviceType, requestSource);
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** 用户查询自己的申请历史 */
|
||||
public List<CounselingService> myServices(String userId, int limit) {
|
||||
public List<CounselingServiceRecord> myServices(String userId, int limit) {
|
||||
return mapper.findByUser(userId, Math.min(limit, 50));
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ public class CounselingService {
|
||||
/** INV-012: 分配给 Counselor — 只有 counseling_manager 能调 */
|
||||
@Transactional
|
||||
public void assign(String serviceId, String counselorId) {
|
||||
CounselingService svc = mapper.findById(serviceId)
|
||||
CounselingServiceRecord svc = mapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
|
||||
svc.setAssignedOperatorId(counselorId);
|
||||
svc.setStatus("assigned");
|
||||
@@ -91,7 +91,7 @@ public class CounselingService {
|
||||
/** INV-012: 排期 — 只有 assigned Counselor 能调 */
|
||||
@Transactional
|
||||
public void schedule(String serviceId, String counselorId, Instant start, Instant end) {
|
||||
CounselingService svc = assertAssigned(serviceId, counselorId);
|
||||
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
|
||||
svc.setStatus("scheduled");
|
||||
svc.setScheduledStartAt(start);
|
||||
svc.setScheduledEndAt(end);
|
||||
@@ -103,7 +103,7 @@ public class CounselingService {
|
||||
/** INV-012: 完成 — 只有 assigned Counselor 能调 */
|
||||
@Transactional
|
||||
public void complete(String serviceId, String counselorId) {
|
||||
CounselingService svc = assertAssigned(serviceId, counselorId);
|
||||
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
|
||||
svc.setStatus("completed");
|
||||
svc.setCompletedAt(Instant.now());
|
||||
mapper.updateById(svc);
|
||||
@@ -111,7 +111,7 @@ public class CounselingService {
|
||||
}
|
||||
|
||||
/** Counselor 查询被分配的列表 */
|
||||
public List<CounselingService> assignedTo(String counselorId) {
|
||||
public List<CounselingServiceRecord> assignedTo(String counselorId) {
|
||||
return mapper.findAssignedTo(counselorId);
|
||||
}
|
||||
|
||||
@@ -122,8 +122,8 @@ public class CounselingService {
|
||||
* @param requesterRole 调用者角色(决定返回脱敏还是全文)
|
||||
* @param serviceId 服务 ID
|
||||
*/
|
||||
public CounselingService getDetail(String requesterId, String requesterRole, String serviceId) {
|
||||
CounselingService full = mapper.findFull(serviceId);
|
||||
public CounselingServiceRecord getDetail(String requesterId, String requesterRole, String serviceId) {
|
||||
CounselingServiceRecord full = mapper.findFull(serviceId);
|
||||
if (full == null) return null;
|
||||
|
||||
boolean isUser = full.getUserId().equals(requesterId);
|
||||
@@ -148,8 +148,8 @@ public class CounselingService {
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
/** INV-012: 断言 counselorId 是 assigned_operator_id */
|
||||
private CounselingService assertAssigned(String serviceId, String counselorId) {
|
||||
CounselingService svc = mapper.findById(serviceId)
|
||||
private CounselingServiceRecord assertAssigned(String serviceId, String counselorId) {
|
||||
CounselingServiceRecord svc = mapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
|
||||
if (!counselorId.equals(svc.getAssignedOperatorId())) {
|
||||
throw new IllegalStateException("INV-012: 您不是此服务的 assigned Counselor");
|
||||
|
||||
+12
-12
@@ -4,7 +4,7 @@ import com.danyang.match.platform.counseling.dto.CounselingDTO;
|
||||
import com.danyang.match.platform.counseling.dto.CounselingRequest;
|
||||
import com.danyang.match.platform.counseling.dto.FollowupDTO;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
@@ -61,7 +61,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T06: 用户申请咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO createCounseling(String userId, CounselingRequest request) {
|
||||
CounselingService cs = new CounselingService();
|
||||
CounselingServiceRecord cs = new CounselingServiceRecord();
|
||||
cs.setId(UUID.randomUUID().toString());
|
||||
cs.setUserId(userId);
|
||||
cs.setServiceType(request.getServiceType());
|
||||
@@ -105,7 +105,7 @@ public class CounselingServiceApp {
|
||||
|
||||
/** P13A-T08: 获取咨询详情(用户只能查看自己的) */
|
||||
public CounselingDTO getCounselingDetail(String counselingId, String viewerUserId) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) return null;
|
||||
if (!viewerUserId.equals(cs.getUserId())) {
|
||||
throw new RuntimeException("无权查看该咨询记录");
|
||||
@@ -116,7 +116,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T09: 取消咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO cancelCounseling(String counselingId, String userId) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null || !userId.equals(cs.getUserId())) {
|
||||
throw new RuntimeException("咨询不存在或无权操作");
|
||||
}
|
||||
@@ -139,7 +139,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T21: 分配咨询师 */
|
||||
@Transactional
|
||||
public CounselingDTO assignCounselor(String counselingId, String operatorId, String counselorId) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_ASSIGNED);
|
||||
cs.setAssignedOperatorId(counselorId);
|
||||
@@ -152,7 +152,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T22: 安排时间 */
|
||||
@Transactional
|
||||
public CounselingDTO scheduleCounseling(String counselingId, OperatorPrincipal principal, Instant startAt, Instant endAt) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_SCHEDULED);
|
||||
@@ -166,7 +166,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T23: 开始咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO startCounseling(String counselingId, OperatorPrincipal principal) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_IN_PROGRESS);
|
||||
@@ -180,7 +180,7 @@ public class CounselingServiceApp {
|
||||
public FollowupDTO addFollowup(String counselingId, OperatorPrincipal principal,
|
||||
String followupType, String content,
|
||||
String resultCode, Instant nextFollowupAt) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
CounselingFollowup fu = new CounselingFollowup();
|
||||
@@ -199,7 +199,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T25: 完成咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO completeCounseling(String counselingId, OperatorPrincipal principal) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_COMPLETED);
|
||||
@@ -217,7 +217,7 @@ public class CounselingServiceApp {
|
||||
/** P13A-T26: 关闭咨询 */
|
||||
@Transactional
|
||||
public CounselingDTO closeCounseling(String counselingId, OperatorPrincipal principal, String reason) {
|
||||
CounselingService cs = counselingMapper.selectById(counselingId);
|
||||
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
|
||||
if (cs == null) throw new RuntimeException("咨询不存在");
|
||||
assertCanProcess(cs, principal);
|
||||
cs.setStatus(Constants.COUNSELING_STATUS_CLOSED);
|
||||
@@ -232,7 +232,7 @@ public class CounselingServiceApp {
|
||||
return counselingMapper.findByAssignedOperator(operatorId).stream().map(this::toDTO).toList();
|
||||
}
|
||||
|
||||
private CounselingDTO toDTO(CounselingService cs) {
|
||||
private CounselingDTO toDTO(CounselingServiceRecord cs) {
|
||||
CounselingDTO dto = new CounselingDTO();
|
||||
dto.setId(cs.getId());
|
||||
dto.setUserId(cs.getUserId());
|
||||
@@ -259,7 +259,7 @@ public class CounselingServiceApp {
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void assertCanProcess(CounselingService counseling, OperatorPrincipal principal) {
|
||||
private void assertCanProcess(CounselingServiceRecord counseling, OperatorPrincipal principal) {
|
||||
boolean counselingManager = principal.hasPermission("counseling.service.assign");
|
||||
if (!counselingManager && !principal.id().equals(counseling.getAssignedOperatorId())) {
|
||||
dataScopeGuard.deny(principal.id(), "counseling_service", counseling.getId(),
|
||||
|
||||
@@ -2,16 +2,13 @@ package com.danyang.match.platform.demo;
|
||||
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMemberMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.notification.mapper.NotificationMapper;
|
||||
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -43,9 +40,6 @@ public class DemoDataSeeder implements ApplicationRunner {
|
||||
private final UserPhotoMapper photoMapper;
|
||||
private final UserRelationMapper relationMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final ConversationMemberMapper memberMapper;
|
||||
private final MessageMapper messageMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final NotificationMapper notificationMapper;
|
||||
private final RelationshipSuccessRequestMapper successRequestMapper;
|
||||
|
||||
+4
-4
@@ -7,6 +7,8 @@ import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.capability.service.CapabilityService;
|
||||
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceLocation;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
|
||||
import com.danyang.match.platform.match.service.RecommendationEngineService;
|
||||
@@ -110,10 +112,8 @@ public class HomeApplicationService {
|
||||
state.setRecommendationEnabled(user.getRecommendationEnabled());
|
||||
state.setPhoneBound(user.getPhone() != null && !user.getPhone().isBlank());
|
||||
|
||||
// 实名状态
|
||||
state.setVerified(userIdentityMapper.findByUserId(userId)
|
||||
.map(identity -> Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus()))
|
||||
.orElse(false));
|
||||
// 实名状态 — 复用 CapabilityService,首页只聚合不复制业务规则(AGENTS.md #26)
|
||||
state.setVerified(capabilityService.isIdentityVerified(userId));
|
||||
|
||||
// 资料审核状态
|
||||
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||
|
||||
@@ -12,6 +12,10 @@ import java.util.Optional;
|
||||
@Mapper
|
||||
public interface MatchMapper extends BaseMapper<Match> {
|
||||
|
||||
/** 按主键查询(带软删过滤) */
|
||||
@Select("SELECT * FROM matches WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Match> findById(@Param("id") String id);
|
||||
|
||||
/** 两人之间当前 active 状态的 Match(用于 AGENTS.md #19 已有则复用) */
|
||||
@Select("SELECT * FROM matches WHERE (user_a_id = #{a} AND user_b_id = #{b} OR user_a_id = #{b} AND user_b_id = #{a}) AND status = 'active' ORDER BY matched_at DESC LIMIT 1")
|
||||
Optional<Match> findActiveBetween(@Param("a") String a, @Param("b") String b);
|
||||
@@ -27,4 +31,26 @@ public interface MatchMapper extends BaseMapper<Match> {
|
||||
/** 某用户历史 Match */
|
||||
@Select("SELECT * FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) ORDER BY matched_at DESC LIMIT #{limit}")
|
||||
List<Match> findHistoryByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
|
||||
// ===== 以下方法由 relationship 包合并而来(V1.0 遗留去重),SQL 保持一致 =====
|
||||
|
||||
/** 两人之间的活跃 Match(含 getting_to_know,带软删过滤) */
|
||||
@Select("SELECT * FROM matches WHERE ((user_a_id = #{userId} AND user_b_id = #{targetUserId}) OR (user_a_id = #{targetUserId} AND user_b_id = #{userId})) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Match> findActiveMatch(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
/** 两人之间最近一次 Match(带软删过滤) */
|
||||
@Select("SELECT * FROM matches WHERE ((user_a_id = #{userId} AND user_b_id = #{targetUserId}) OR (user_a_id = #{targetUserId} AND user_b_id = #{userId})) AND deleted_at IS NULL ORDER BY matched_at DESC LIMIT 1")
|
||||
Optional<Match> findLatestMatch(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
/** 某用户活跃 Match 列表(带软删过滤) */
|
||||
@Select("SELECT * FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL ORDER BY matched_at DESC")
|
||||
List<Match> findActiveMatchesByUser(String userId);
|
||||
|
||||
/** 成功牵手数(带软删过滤) */
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status = 'successful' AND deleted_at IS NULL")
|
||||
int countSuccessfulMatches(String userId);
|
||||
|
||||
/** 活跃 Match 数(带软删过滤) */
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL")
|
||||
int countActiveMatches(String userId);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
@@ -24,4 +25,32 @@ public interface UserRelationMapper extends BaseMapper<UserRelation> {
|
||||
/** B 有没有 Like A — 用于 A Like B 后判断是否 Mutual */
|
||||
@Select("SELECT COUNT(*) FROM user_relations WHERE user_id = #{b} AND target_user_id = #{a} AND relation_type = 'liked'")
|
||||
int countReverseLike(@Param("a") String a, @Param("b") String b);
|
||||
|
||||
// ===== 以下方法由 relationship 包合并而来(V1.0 遗留去重),SQL 保持一致 =====
|
||||
|
||||
/** 指定类型的关系(带软删过滤) */
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND target_user_id = #{targetUserId} AND relation_type = #{relationType} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserRelation> findByRelation(@Param("userId") String userId,
|
||||
@Param("targetUserId") String targetUserId,
|
||||
@Param("relationType") String relationType);
|
||||
|
||||
/** 我拉黑的人 */
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL")
|
||||
List<UserRelation> findBlockedByUser(String userId);
|
||||
|
||||
/** 喜欢我的人 */
|
||||
@Select("SELECT * FROM user_relations WHERE target_user_id = #{userId} AND relation_type = 'liked' AND deleted_at IS NULL")
|
||||
List<UserRelation> findLikers(String userId);
|
||||
|
||||
/** 我喜欢的人 */
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'liked' AND deleted_at IS NULL")
|
||||
List<UserRelation> findLikedByUser(String userId);
|
||||
|
||||
/** 我跳过的人 */
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'passed' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100")
|
||||
List<UserRelation> findPassedByUser(String userId);
|
||||
|
||||
/** 插入关系(依赖唯一约束去重,返回 0 表示已存在) */
|
||||
@org.apache.ibatis.annotations.Insert("INSERT INTO user_relations (id, user_id, target_user_id, relation_type) VALUES (#{id}, #{userId}, #{targetUserId}, #{relationType})")
|
||||
int insertIgnore(UserRelation relation);
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.profile.service.UserProfileService;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
|
||||
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
|
||||
+12
-4
@@ -1,8 +1,9 @@
|
||||
package com.danyang.match.platform.matchmaker.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.Data;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 红娘服务端点
|
||||
@@ -38,14 +40,18 @@ public class MatchmakerController {
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
@PostMapping("/services")
|
||||
public ApiResponse<MatchmakerService> submit(@RequestBody SubmitRequest req,
|
||||
public ApiResponse<MatchmakerServiceRecord> submit(@RequestBody SubmitRequest req,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), req.serviceType, req.description));
|
||||
// 契约兼容:小程序传 requirement,后端契约为 description;缺省服务类型为 recommendation
|
||||
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
|
||||
? "recommendation" : req.serviceType;
|
||||
String description = req.description != null ? req.description : req.requirement;
|
||||
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description));
|
||||
}
|
||||
|
||||
@GetMapping("/services")
|
||||
public ApiResponse<List<MatchmakerService>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||
public ApiResponse<List<MatchmakerServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
|
||||
@@ -86,5 +92,7 @@ public class MatchmakerController {
|
||||
public static class SubmitRequest {
|
||||
private String serviceType;
|
||||
private String description;
|
||||
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
|
||||
private String requirement;
|
||||
}
|
||||
}
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
package com.danyang.match.platform.matchmaker.controller;
|
||||
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户端红娘服务控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/matchmaker/services")
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerUserController {
|
||||
|
||||
private final MatchmakerServiceService matchmakerServiceService;
|
||||
|
||||
/**
|
||||
* 申请红娘服务
|
||||
* POST /api/v1/matchmaker/services
|
||||
* Body: { "serviceType": "recommend_partner", "description": "..." }
|
||||
*/
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, Object>> apply(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
String serviceType = body.get("serviceType");
|
||||
String description = body.getOrDefault("description", "");
|
||||
return ApiResponse.ok(matchmakerServiceService.applyService(userId, serviceType, description));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的服务列表
|
||||
* GET /api/v1/matchmaker/services
|
||||
*/
|
||||
@GetMapping
|
||||
public ApiResponse<List<Map<String, Object>>> getMyServices(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务详情
|
||||
* GET /api/v1/matchmaker/services/{id}
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String userId = principal.id();
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id);
|
||||
if (detail == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
return ApiResponse.ok(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取红娘推荐列表
|
||||
* GET /api/v1/matchmaker/recommendations
|
||||
*/
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
if (userId == null) {
|
||||
return ApiResponse.ok(List.of());
|
||||
}
|
||||
return ApiResponse.ok(matchmakerServiceService.getRecommendationsForUser(userId));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+5
-1
@@ -9,6 +9,10 @@ import lombok.EqualsAndHashCode;
|
||||
* 红娘服务请求
|
||||
* 对应 DB: matchmaker_services
|
||||
*
|
||||
* 命名说明:类名加 Record 后缀,避免与 matchmaker.service.MatchmakerService(Spring 服务类)
|
||||
* 产生同名冲突 —— 同名 import 会让 javac 报 "already defined in this compilation unit",
|
||||
* 并中断注解处理轮次,进而导致 Lombok 失效。
|
||||
*
|
||||
* service_type: recommendation(推荐对象)/ referral_counseling(转介咨询)
|
||||
* referral_activity(转介活动)/ referral_station(转介服务站)
|
||||
* status: submitted → assigned → processing → completed / cancelled
|
||||
@@ -16,7 +20,7 @@ import lombok.EqualsAndHashCode;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("matchmaker_services")
|
||||
public class MatchmakerService extends BaseEntity {
|
||||
public class MatchmakerServiceRecord extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String serviceType;
|
||||
+4
@@ -19,6 +19,10 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
|
||||
@Select("SELECT * FROM matchmaker_recommendations WHERE service_id = #{serviceId} ORDER BY created_at DESC")
|
||||
List<MatchmakerRecommendation> findByService(@Param("serviceId") String serviceId);
|
||||
|
||||
/** 某用户收到的全部推荐(含已接受/已拒绝)— 用于推荐历史 */
|
||||
@Select("SELECT * FROM matchmaker_recommendations WHERE user_id = #{userId} ORDER BY created_at DESC")
|
||||
List<MatchmakerRecommendation> findByUserId(@Param("userId") String userId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_recommendations WHERE id = #{id} AND user_id = #{userId}")
|
||||
Optional<MatchmakerRecommendation> findByIdAndUser(@Param("id") String id, @Param("userId") String userId);
|
||||
|
||||
|
||||
+29
-4
@@ -1,19 +1,44 @@
|
||||
package com.danyang.match.platform.matchmaker.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerService> {
|
||||
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
|
||||
|
||||
/** 按用户查询全部(不限条数) */
|
||||
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findByUserId(@Param("userId") String userId);
|
||||
|
||||
/** 待分配列表(status = submitted) */
|
||||
@Select("SELECT * FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL ORDER BY created_at ASC")
|
||||
List<MatchmakerServiceRecord> findSubmitted();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
|
||||
long countSubmitted();
|
||||
|
||||
/** 某红娘进行中的服务数(工作台统计) */
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND status = 'processing' AND deleted_at IS NULL")
|
||||
long countInProgressByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
|
||||
List<MatchmakerService> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
|
||||
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
|
||||
List<MatchmakerService> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
}
|
||||
|
||||
+9
-9
@@ -5,7 +5,7 @@ import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.service.MatchService;
|
||||
import com.danyang.match.platform.match.service.RelationService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
@@ -61,8 +61,8 @@ public class MatchmakerService {
|
||||
* - "referral_station" 红娘转介服务站(P13-T03)
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerService submitServiceRequest(String userId, String serviceType, String description) {
|
||||
MatchmakerService svc = new MatchmakerService();
|
||||
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(serviceType);
|
||||
@@ -74,7 +74,7 @@ public class MatchmakerService {
|
||||
}
|
||||
|
||||
/** 查询我的红娘服务历史 */
|
||||
public List<MatchmakerService> myServices(String userId, int limit) {
|
||||
public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
|
||||
return serviceMapper.findByUser(userId, Math.min(limit, 50));
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public class MatchmakerService {
|
||||
*/
|
||||
@Transactional
|
||||
public void assignService(String serviceId, String operatorId) {
|
||||
MatchmakerService svc = serviceMapper.findById(serviceId)
|
||||
MatchmakerServiceRecord svc = serviceMapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Service 不存在"));
|
||||
svc.setAssignedOperatorId(operatorId);
|
||||
svc.setStatus("assigned");
|
||||
@@ -115,7 +115,7 @@ public class MatchmakerService {
|
||||
throw new IllegalStateException("双方存在屏蔽关系,无法推荐");
|
||||
}
|
||||
// 校验:红娘必须是服务负责人
|
||||
MatchmakerService svc = serviceMapper.findById(serviceId).orElse(null);
|
||||
MatchmakerServiceRecord svc = serviceMapper.findById(serviceId).orElse(null);
|
||||
if (svc == null || !operatorId.equals(svc.getAssignedOperatorId())) {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
@@ -152,12 +152,12 @@ public class MatchmakerService {
|
||||
public ReferralRecord createReferral(String serviceId, String userId, String operatorId,
|
||||
String referralType, String referralTarget, String note) {
|
||||
// referralType: counseling / activity / service_station
|
||||
MatchmakerService svc = serviceMapper.findById(serviceId).orElse(null);
|
||||
MatchmakerServiceRecord svc = serviceMapper.findById(serviceId).orElse(null);
|
||||
if (svc == null || !operatorId.equals(svc.getAssignedOperatorId())) {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
|
||||
// referral 记录写进 MatchmakerService.description 的扩展 JSON
|
||||
// referral 记录写进 MatchmakerServiceRecord.description 的扩展 JSON
|
||||
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
|
||||
// 这里用 Map 简单记录,不引入新实体
|
||||
ReferralRecord record = new ReferralRecord(
|
||||
@@ -263,7 +263,7 @@ public class MatchmakerService {
|
||||
"liked".equals(r.getStatus()) || "passed".equals(r.getStatus())
|
||||
|| "confirmed_mutual".equals(r.getStatus()));
|
||||
if (allDone) {
|
||||
MatchmakerService svc = serviceMapper.findById(serviceId).orElse(null);
|
||||
MatchmakerServiceRecord svc = serviceMapper.findById(serviceId).orElse(null);
|
||||
if (svc != null) {
|
||||
svc.setStatus("completed");
|
||||
svc.setCompletedAt(Instant.now());
|
||||
|
||||
+18
-18
@@ -6,7 +6,7 @@ import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerService;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
@@ -43,7 +43,7 @@ public class MatchmakerServiceService {
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> applyService(String userId, String serviceType, String description) {
|
||||
MatchmakerService service = new MatchmakerService();
|
||||
MatchmakerServiceRecord service = new MatchmakerServiceRecord();
|
||||
service.setUserId(userId);
|
||||
service.setServiceType(serviceType);
|
||||
service.setDescription(description);
|
||||
@@ -83,8 +83,8 @@ public class MatchmakerServiceService {
|
||||
* 分配服务给红娘
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerService assignService(String serviceId, String operatorId, String organizationId) {
|
||||
MatchmakerService service = serviceMapper.selectById(serviceId);
|
||||
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许分配");
|
||||
@@ -105,8 +105,8 @@ public class MatchmakerServiceService {
|
||||
* 红娘接单
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerService claimService(String operatorId, String serviceId) {
|
||||
MatchmakerService service = serviceMapper.selectById(serviceId);
|
||||
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许接单");
|
||||
@@ -133,7 +133,7 @@ public class MatchmakerServiceService {
|
||||
@Transactional
|
||||
public Map<String, Object> recommendPartner(String serviceId, String operatorId,
|
||||
String targetUserId, String reason) {
|
||||
MatchmakerService service = serviceMapper.selectById(serviceId);
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
@@ -188,8 +188,8 @@ public class MatchmakerServiceService {
|
||||
* 完成服务
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerService completeService(String serviceId) {
|
||||
MatchmakerService service = serviceMapper.selectById(serviceId);
|
||||
public MatchmakerServiceRecord completeService(String serviceId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
|
||||
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
|
||||
@@ -209,8 +209,8 @@ public class MatchmakerServiceService {
|
||||
* 取消服务
|
||||
*/
|
||||
@Transactional
|
||||
public MatchmakerService cancelService(String serviceId) {
|
||||
MatchmakerService service = serviceMapper.selectById(serviceId);
|
||||
public MatchmakerServiceRecord cancelService(String serviceId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
service.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
|
||||
serviceMapper.updateById(service);
|
||||
@@ -247,12 +247,12 @@ public class MatchmakerServiceService {
|
||||
dashboard.put("pendingCount", pendingCount);
|
||||
dashboard.put("inProgressCount", inProgressCount);
|
||||
long todayCount = serviceMapper.selectCount(
|
||||
new LambdaQueryWrapper<MatchmakerService>()
|
||||
.eq(MatchmakerService::getStatus, "submitted"));
|
||||
new LambdaQueryWrapper<MatchmakerServiceRecord>()
|
||||
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
|
||||
dashboard.put("todayCount", todayCount);
|
||||
long completedCount = serviceMapper.selectCount(
|
||||
new LambdaQueryWrapper<MatchmakerService>()
|
||||
.eq(MatchmakerService::getStatus, "completed"));
|
||||
new LambdaQueryWrapper<MatchmakerServiceRecord>()
|
||||
.eq(MatchmakerServiceRecord::getStatus, "completed"));
|
||||
dashboard.put("completedCount", completedCount);
|
||||
return dashboard;
|
||||
}
|
||||
@@ -260,7 +260,7 @@ public class MatchmakerServiceService {
|
||||
/**
|
||||
* 获取我的服务列表(红娘工作台)
|
||||
*/
|
||||
public List<MatchmakerService> getOperatorServices(String operatorId) {
|
||||
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
|
||||
return serviceMapper.findByOperatorId(operatorId);
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ public class MatchmakerServiceService {
|
||||
* 获取服务详情
|
||||
*/
|
||||
public Map<String, Object> getServiceDetail(String serviceId) {
|
||||
MatchmakerService service = serviceMapper.selectById(serviceId);
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) return null;
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
@@ -296,7 +296,7 @@ public class MatchmakerServiceService {
|
||||
});
|
||||
|
||||
// 加载推荐列表
|
||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByServiceId(serviceId);
|
||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||
detail.put("recommendations", recs.stream().map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", r.getId());
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.controller;
|
||||
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.messaging.dto.MessageDTO;
|
||||
import com.danyang.match.platform.messaging.safety.MessageRiskService;
|
||||
import com.danyang.match.platform.messaging.service.MessagingService;
|
||||
import com.danyang.match.platform.photo.service.PhotoService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 聊天控制器(文字 + 图片)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/messages")
|
||||
@RequiredArgsConstructor
|
||||
public class ChatController {
|
||||
|
||||
private final MessagingService messagingService;
|
||||
private final MessageRiskService riskService;
|
||||
private final PhotoService photoService;
|
||||
|
||||
/**
|
||||
* 发送文字消息
|
||||
* POST /api/v1/messages/conversations/{id}/send
|
||||
*/
|
||||
@PostMapping(value = "/conversations/{id}/send", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ApiResponse<MessageDTO> sendTextMessage(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
String content = body.get("content");
|
||||
if (content == null || content.isBlank()) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
"消息内容不能为空");
|
||||
}
|
||||
|
||||
// 风险检测(ChatController做限流,MessagingService做风险提示)
|
||||
if (riskService.isRateLimited(userId)) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.RATE_LIMITED,
|
||||
"发送过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
String clientMessageId = body.get("clientMessageId");
|
||||
return ApiResponse.ok(messagingService.sendMessage(userId, id, content, clientMessageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 首次进入聊天时发送安全提示
|
||||
* POST /api/v1/messages/conversations/{id}/safety-tip
|
||||
* 幂等:同一会话只发送一次系统安全提示
|
||||
*/
|
||||
@PostMapping("/conversations/{id}/safety-tip")
|
||||
public ApiResponse<Void> sendSafetyTip(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String userId = principal.id();
|
||||
messagingService.sendChatSafetyTip(id, userId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传凭证
|
||||
* POST /api/v1/messages/upload-token
|
||||
*/
|
||||
@PostMapping("/upload-token")
|
||||
public ApiResponse<Map<String, Object>> getUploadToken(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
String conversationId = body.get("conversationId");
|
||||
// 复用 PhotoService 获取 MinIO presigned URL
|
||||
return ApiResponse.ok(photoService.getUploadToken(userId, "chat"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片签名URL
|
||||
* GET /api/v1/messages/files/{fileId}/url
|
||||
*/
|
||||
@GetMapping("/files/{fileId}/url")
|
||||
public ApiResponse<Map<String, Object>> getSignedUrl(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String fileId) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(photoService.getSignedUrl(userId, fileId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
* POST /api/v1/messages/conversations/{id}/send-image
|
||||
* Body: { "fileKey": "..." }
|
||||
*/
|
||||
@PostMapping("/conversations/{id}/send-image")
|
||||
public ApiResponse<MessageDTO> sendImageMessage(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
String fileKey = body.get("fileKey");
|
||||
if (fileKey == null || fileKey.isBlank()) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
"fileKey不能为空");
|
||||
}
|
||||
// 图片消息通过fileKey关联到user_photos,直接创建system消息引用
|
||||
return ApiResponse.ok(messagingService.sendImageMessage(userId, id, fileKey));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.controller;
|
||||
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.messaging.dto.ConversationDTO;
|
||||
import com.danyang.match.platform.messaging.dto.MessageDTO;
|
||||
import com.danyang.match.platform.messaging.service.MessagingService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 消息控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/messages")
|
||||
@RequiredArgsConstructor
|
||||
public class MessageController {
|
||||
|
||||
private final MessagingService messagingService;
|
||||
|
||||
/**
|
||||
* 获取我的会话列表
|
||||
* GET /api/v1/messages/conversations
|
||||
*/
|
||||
@GetMapping("/conversations")
|
||||
public ApiResponse<List<ConversationDTO>> getConversations(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(messagingService.getConversations(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
* GET /api/v1/messages/conversations/{id}
|
||||
*/
|
||||
@GetMapping("/conversations/{id}")
|
||||
public ApiResponse<ConversationDTO> getConversation(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String userId = principal.id();
|
||||
ConversationDTO dto = messagingService.getConversation(userId, id);
|
||||
if (dto == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
return ApiResponse.ok(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* POST /api/v1/messages/conversations/{id}/send
|
||||
* Body: { "content": "...", "clientMessageId": "uuid" }
|
||||
*/
|
||||
@PostMapping("/conversations/{id}/send")
|
||||
public ApiResponse<MessageDTO> sendMessage(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
String content = body.get("content");
|
||||
if (content == null || content.isBlank()) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
"消息内容不能为空");
|
||||
}
|
||||
String clientMessageId = body.get("clientMessageId");
|
||||
return ApiResponse.ok(messagingService.sendMessage(userId, id, content, clientMessageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息历史
|
||||
* GET /api/v1/messages/conversations/{id}/history?cursor=xxx&limit=20
|
||||
*/
|
||||
@GetMapping("/conversations/{id}/history")
|
||||
public ApiResponse<Map<String, Object>> getHistory(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestParam(required = false) String cursor,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(messagingService.getHistory(userId, id, cursor, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已读
|
||||
* PUT /api/v1/messages/conversations/{id}/read
|
||||
* Body: { "messageId": "xxx" }
|
||||
*/
|
||||
@PutMapping("/conversations/{id}/read")
|
||||
public ApiResponse<Void> markAsRead(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String userId = principal.id();
|
||||
String messageId = body.get("messageId");
|
||||
messagingService.markAsRead(userId, id, messageId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读总数
|
||||
* GET /api/v1/messages/unread-count
|
||||
*/
|
||||
@GetMapping("/unread-count")
|
||||
public ApiResponse<Map<String, Object>> getUnreadCount(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
int count = messagingService.getTotalUnread(userId);
|
||||
return ApiResponse.ok(Map.of("unreadCount", count));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 会话DTO
|
||||
*/
|
||||
@Data
|
||||
public class ConversationDTO {
|
||||
private String conversationId;
|
||||
private String matchId;
|
||||
private String otherUserId;
|
||||
private String otherDisplayName;
|
||||
private int otherAge;
|
||||
private String lastMessage;
|
||||
private Instant lastMessageAt;
|
||||
private int unreadCount;
|
||||
private boolean canSend;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 消息DTO
|
||||
*/
|
||||
@Data
|
||||
public class MessageDTO {
|
||||
private String messageId;
|
||||
private String conversationId;
|
||||
private String senderId;
|
||||
private String senderDisplayName;
|
||||
private String messageType;
|
||||
private String content;
|
||||
private String fileUrl;
|
||||
private Instant createdAt;
|
||||
private String status;
|
||||
private boolean isMine;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("conversations")
|
||||
public class Conversation extends BaseEntity {
|
||||
|
||||
private String matchId;
|
||||
private String status; // active, unmatched, blocked
|
||||
private String lastMessageId;
|
||||
private java.time.Instant lastMessageAt;
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("conversation_members")
|
||||
public class ConversationMember extends BaseEntity {
|
||||
|
||||
private String conversationId;
|
||||
private String userId;
|
||||
private String lastReadMessageId;
|
||||
private Integer unreadCount;
|
||||
private Boolean canSend;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("messages")
|
||||
public class Message extends BaseEntity {
|
||||
|
||||
private String conversationId;
|
||||
private String senderId;
|
||||
private String clientMessageId;
|
||||
private String messageType; // text, image, system, risk_notice
|
||||
private String content;
|
||||
private String fileId;
|
||||
private String status; // sent, delivered, failed
|
||||
private String riskLevel; // null, normal, warning, high
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.messaging.entity.Conversation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ConversationMapper extends BaseMapper<Conversation> {
|
||||
|
||||
@Select("SELECT * FROM conversations WHERE match_id = #{matchId} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Conversation> findByMatchId(@Param("matchId") String matchId);
|
||||
|
||||
@Select("SELECT c.* FROM conversations c " +
|
||||
"INNER JOIN conversation_members cm ON c.id = cm.conversation_id " +
|
||||
"WHERE cm.user_id = #{userId} AND c.deleted_at IS NULL " +
|
||||
"ORDER BY c.last_message_at DESC")
|
||||
List<Conversation> findByUserId(@Param("userId") String userId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM conversation_members cm " +
|
||||
"INNER JOIN conversations c ON cm.conversation_id = c.id " +
|
||||
"WHERE cm.user_id = #{userId} AND cm.unread_count > 0 AND c.deleted_at IS NULL")
|
||||
int countUnreadByUserId(@Param("userId") String userId);
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.messaging.entity.ConversationMember;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface ConversationMemberMapper extends BaseMapper<ConversationMember> {
|
||||
|
||||
@Select("SELECT * FROM conversation_members WHERE conversation_id = #{conversationId} AND user_id = #{userId} LIMIT 1")
|
||||
Optional<ConversationMember> findByConversationAndUser(@Param("conversationId") String conversationId, @Param("userId") String userId);
|
||||
|
||||
@Select("SELECT * FROM conversation_members WHERE conversation_id = #{conversationId}")
|
||||
List<ConversationMember> findByConversationId(String conversationId);
|
||||
|
||||
@Update("UPDATE conversation_members SET unread_count = unread_count + 1 WHERE conversation_id = #{conversationId} AND user_id = #{userId}")
|
||||
void incrementUnreadCount(@Param("conversationId") String conversationId, @Param("userId") String userId);
|
||||
|
||||
@Update("UPDATE conversation_members SET last_read_message_id = #{messageId}, unread_count = 0 WHERE conversation_id = #{conversationId} AND user_id = #{userId}")
|
||||
void markAsRead(@Param("conversationId") String conversationId, @Param("userId") String userId, @Param("messageId") String messageId);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.messaging.entity.Message;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MessageMapper extends BaseMapper<Message> {
|
||||
|
||||
@Select("SELECT * FROM messages WHERE conversation_id = #{conversationId} " +
|
||||
"AND created_at < #{cursor} ORDER BY created_at DESC LIMIT 20")
|
||||
List<Message> findHistoryByCursor(@Param("conversationId") String conversationId,
|
||||
@Param("cursor") java.time.Instant cursor);
|
||||
|
||||
@Select("SELECT MAX(created_at) FROM messages WHERE conversation_id = #{conversationId}")
|
||||
java.time.Instant findLatestMessageTime(@Param("conversationId") String conversationId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM messages WHERE conversation_id = #{conversationId} AND sender_id != #{userId} " +
|
||||
"AND created_at > #{lastReadAt}")
|
||||
int countUnread(@Param("conversationId") String conversationId,
|
||||
@Param("userId") String userId,
|
||||
@Param("lastReadAt") java.time.Instant lastReadAt);
|
||||
}
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.safety;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 消息安全检测服务
|
||||
* V1采用关键词规则 + 频率规则 + 举报联动
|
||||
*/
|
||||
@Component
|
||||
public class MessageRiskService {
|
||||
|
||||
@Value("${rate-limit.max-requests-per-window:20}")
|
||||
private int maxRequestsPerWindow;
|
||||
|
||||
@Value("${rate-limit.window-seconds:60}")
|
||||
private int windowSeconds;
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public MessageRiskService(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
// 高风险关键词(命中后触发风险提示,不自动阻断)
|
||||
private static final Set<String> HIGH_RISK_KEYWORDS = Set.of(
|
||||
"投资", "理财", "炒股", "基金", "股票",
|
||||
"转账", "汇款", "银行卡", "支付宝", "微信转账",
|
||||
"借钱", "借款", "贷款", "网贷",
|
||||
"博彩", "赌博", "彩票", "六合彩",
|
||||
"高收益", "稳赚", "内幕",
|
||||
"刷单", "兼职", "快递费", "保证金",
|
||||
"验证码", "密码", "身份证号", "加微信", "加你微信", "加我微信",
|
||||
"诈骗", "骗子", "假冒"
|
||||
);
|
||||
|
||||
// 普通风险关键词(仅记录,不提示)
|
||||
private static final Set<String> MEDIUM_RISK_KEYWORDS = Set.of(
|
||||
"电话", "加QQ", "加群",
|
||||
"地址", "门牌", "小区", "楼栋",
|
||||
"公司", "单位", "工作服"
|
||||
);
|
||||
|
||||
/**
|
||||
* 检测消息风险等级
|
||||
*
|
||||
* @return null=正常, "warning"=需提示, "high"=高风险
|
||||
*/
|
||||
public String detectRiskLevel(String content) {
|
||||
if (content == null || content.isBlank()) return null;
|
||||
|
||||
int highRiskCount = 0;
|
||||
int mediumRiskCount = 0;
|
||||
|
||||
for (String keyword : HIGH_RISK_KEYWORDS) {
|
||||
if (content.contains(keyword)) highRiskCount++;
|
||||
}
|
||||
for (String keyword : MEDIUM_RISK_KEYWORDS) {
|
||||
if (content.contains(keyword)) mediumRiskCount++;
|
||||
}
|
||||
|
||||
if (highRiskCount >= 2) return "high";
|
||||
if (highRiskCount >= 1) return "warning";
|
||||
if (mediumRiskCount >= 3) return "warning";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成风险通知内容
|
||||
*/
|
||||
public String generateRiskNotice(String riskLevel, List<String> matchedKeywords) {
|
||||
if ("high".equals(riskLevel)) {
|
||||
return "⚠️ 检测到高风险关键词,请注意保护个人财产安全,避免涉及转账、投资等资金往来。";
|
||||
}
|
||||
if ("warning".equals(riskLevel)) {
|
||||
return "💡 温馨提示:请谨慎对待涉及金钱往来的对话,保护好个人隐私和财产安全。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限流检测:基于 Redis 滑动窗口计数
|
||||
* 每人每分钟最多 maxRequestsPerWindow 条消息
|
||||
*/
|
||||
public boolean isRateLimited(String userId) {
|
||||
try {
|
||||
String key = "rate_limit:msg:" + userId;
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
if (count != null && count == 1) {
|
||||
redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
return count != null && count > maxRequestsPerWindow;
|
||||
} catch (Exception e) {
|
||||
// Redis 不可用时降级:不限流
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测重复消息(防刷)
|
||||
*/
|
||||
public boolean isDuplicateMessage(String userId, String content) {
|
||||
if (content == null || content.isBlank()) return false;
|
||||
// TODO: Phase 10 后续实现重复检测(基于 Redis 滑动窗口)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取高风险关键词列表(供测试和配置使用)
|
||||
*/
|
||||
public Set<String> getHighRiskKeywords() {
|
||||
return HIGH_RISK_KEYWORDS;
|
||||
}
|
||||
}
|
||||
-455
@@ -1,455 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.messaging.dto.ConversationDTO;
|
||||
import com.danyang.match.platform.messaging.dto.MessageDTO;
|
||||
import com.danyang.match.platform.messaging.entity.Conversation;
|
||||
import com.danyang.match.platform.messaging.entity.ConversationMember;
|
||||
import com.danyang.match.platform.messaging.entity.Message;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMemberMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.messaging.safety.MessageRiskService;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MessagingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MessagingService.class);
|
||||
|
||||
private final ConversationMapper conversationMapper;
|
||||
private final ConversationMemberMapper memberMapper;
|
||||
private final MessageMapper messageMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final UserRelationMapper relationMapper;
|
||||
private final MessageRiskService messageRiskService;
|
||||
|
||||
/**
|
||||
* 获取我的会话列表
|
||||
*/
|
||||
public List<ConversationDTO> getConversations(String userId) {
|
||||
List<Conversation> conversations = conversationMapper.findByUserId(userId);
|
||||
return conversations.stream()
|
||||
.map(c -> toConversationDTO(userId, c))
|
||||
.sorted((a, b) -> b.getLastMessageAt().compareTo(a.getLastMessageAt()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情(含未读数)
|
||||
*/
|
||||
public ConversationDTO getConversation(String userId, String conversationId) {
|
||||
Conversation conversation = conversationMapper.selectById(conversationId);
|
||||
if (conversation == null) return null;
|
||||
return toConversationDTO(userId, conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文字消息(REST)
|
||||
* 先落库再推送
|
||||
*/
|
||||
@Transactional
|
||||
public MessageDTO sendMessage(String userId, String conversationId, String content,
|
||||
String clientMessageId) {
|
||||
// 1. 校验会话存在且有效
|
||||
Conversation conversation = conversationMapper.selectById(conversationId);
|
||||
if (conversation == null || !Constants.CONVERSATION_STATUS_ACTIVE.equals(conversation.getStatus())) {
|
||||
throw new RuntimeException("会话不存在或已关闭");
|
||||
}
|
||||
|
||||
// 2. 校验双方是否仍有有效Match
|
||||
Match match = matchMapper.findActiveMatch(userId, getOtherUserId(conversationId, userId))
|
||||
.orElseThrow(() -> new RuntimeException("匹配关系已解除"));
|
||||
|
||||
// 3. 检查是否被对方Block
|
||||
if (isBlocked(userId, getOtherUserId(conversationId, userId))) {
|
||||
throw new RuntimeException("对方已将你拉黑");
|
||||
}
|
||||
|
||||
// 4. 幂等检查
|
||||
if (clientMessageId != null) {
|
||||
Message existing = messageMapper.selectOne(
|
||||
new LambdaQueryWrapper<Message>()
|
||||
.eq(Message::getSenderId, userId)
|
||||
.eq(Message::getClientMessageId, clientMessageId)
|
||||
.isNull(Message::getDeletedAt));
|
||||
if (existing != null) {
|
||||
return toMessageDTO(existing, userId);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 创建消息
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversationId);
|
||||
message.setSenderId(userId);
|
||||
message.setClientMessageId(clientMessageId);
|
||||
message.setMessageType(Constants.MESSAGE_TYPE_TEXT);
|
||||
message.setContent(content);
|
||||
message.setStatus(Constants.MESSAGE_STATUS_SENT);
|
||||
messageMapper.insert(message);
|
||||
|
||||
// 6. 更新会话
|
||||
conversation.setLastMessageId(message.getId());
|
||||
conversation.setLastMessageAt(Instant.now());
|
||||
conversationMapper.updateById(conversation);
|
||||
|
||||
// 7. 更新对方未读数
|
||||
String otherUserId = getOtherUserId(conversationId, userId);
|
||||
memberMapper.incrementUnreadCount(conversationId, otherUserId);
|
||||
|
||||
// 8. 通过WebSocket推送给对方
|
||||
// TODO: Phase 09 后续接入WebSocket推送
|
||||
|
||||
// 9. V1.1: 消息风险检测 — 高风险时插入系统风险提示
|
||||
try {
|
||||
String riskLevel = messageRiskService.detectRiskLevel(content);
|
||||
if ("high".equals(riskLevel) || "warning".equals(riskLevel)) {
|
||||
insertRiskNotice(conversationId, userId, riskLevel, content);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("风险检测失败(非致命): {}", e.getMessage());
|
||||
}
|
||||
|
||||
log.info("消息发送: userId={}, conversationId={}, messageId={}", userId, conversationId, message.getId());
|
||||
return toMessageDTO(message, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 发送首次聊天安全提示
|
||||
* 仅在用户首次进入某个会话时调用,避免重复提示
|
||||
*/
|
||||
public void sendChatSafetyTip(String conversationId, String userId) {
|
||||
try {
|
||||
String tipContent = "实名认证和人工审核能够提升信息真实性,"
|
||||
+ "交往过程中仍请谨慎涉及借款、投资和个人隐私。";
|
||||
Message tip = new Message();
|
||||
tip.setConversationId(conversationId);
|
||||
tip.setSenderId(userId);
|
||||
tip.setMessageType(Constants.MESSAGE_TYPE_SYSTEM);
|
||||
tip.setContent(tipContent);
|
||||
tip.setStatus(Constants.MESSAGE_STATUS_SENT);
|
||||
tip.setClientMessageId(UUID.randomUUID().toString());
|
||||
messageMapper.insert(tip);
|
||||
|
||||
Conversation conv = conversationMapper.selectById(conversationId);
|
||||
if (conv != null) {
|
||||
conv.setLastMessageId(tip.getId());
|
||||
conv.setLastMessageAt(Instant.now());
|
||||
conversationMapper.updateById(conv);
|
||||
}
|
||||
|
||||
// 通知对方
|
||||
String otherUserId = getOtherUserId(conversationId, userId);
|
||||
memberMapper.incrementUnreadCount(conversationId, otherUserId);
|
||||
|
||||
log.info("聊天安全提示已发送: conversationId={}, userId={}", conversationId, userId);
|
||||
} catch (Exception e) {
|
||||
log.warn("安全提示发送失败(非致命): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V1.1: 当检测到高风险关键词时,插入系统风险通知消息
|
||||
*/
|
||||
private void insertRiskNotice(String conversationId, String userId,
|
||||
String riskLevel, String detectedContent) {
|
||||
try {
|
||||
String noticeContent = messageRiskService.generateRiskNotice(riskLevel, List.of());
|
||||
if (noticeContent == null) return;
|
||||
|
||||
Message notice = new Message();
|
||||
notice.setConversationId(conversationId);
|
||||
notice.setSenderId(userId);
|
||||
notice.setMessageType(Constants.MESSAGE_TYPE_RISK_NOTICE);
|
||||
notice.setContent(noticeContent);
|
||||
notice.setRiskLevel(riskLevel);
|
||||
notice.setStatus(Constants.MESSAGE_STATUS_SENT);
|
||||
notice.setClientMessageId(UUID.randomUUID().toString());
|
||||
messageMapper.insert(notice);
|
||||
|
||||
Conversation conv = conversationMapper.selectById(conversationId);
|
||||
if (conv != null) {
|
||||
conv.setLastMessageId(notice.getId());
|
||||
conv.setLastMessageAt(Instant.now());
|
||||
conversationMapper.updateById(conv);
|
||||
}
|
||||
|
||||
// 通知对方(风险提醒对双方可见)
|
||||
String otherUserId = getOtherUserId(conversationId, userId);
|
||||
memberMapper.incrementUnreadCount(conversationId, otherUserId);
|
||||
|
||||
log.info("风险通知已插入: conversationId={}, riskLevel={}, userId={}",
|
||||
conversationId, riskLevel, userId);
|
||||
} catch (Exception e) {
|
||||
log.warn("风险通知插入失败(非致命): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
* 创建一条image类型消息,fileKey指向user_photos中的记录
|
||||
*/
|
||||
@Transactional
|
||||
public MessageDTO sendImageMessage(String userId, String conversationId, String fileKey) {
|
||||
// 1. 校验会话存在且属于该用户
|
||||
Conversation conversation = conversationMapper.selectById(conversationId);
|
||||
if (conversation == null) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "会话不存在");
|
||||
}
|
||||
Optional<ConversationMember> memberOpt = memberMapper.selectList(
|
||||
new LambdaQueryWrapper<ConversationMember>()
|
||||
.eq(ConversationMember::getConversationId, conversationId)
|
||||
.eq(ConversationMember::getUserId, userId)
|
||||
).stream().findFirst();
|
||||
if (memberOpt.isEmpty()) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.FORBIDDEN, "无权在此会话中发送消息");
|
||||
}
|
||||
|
||||
// 2. 校验Match有效且双方未互Block
|
||||
Match match = matchMapper.selectById(conversation.getMatchId());
|
||||
if (match == null || !Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus())) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.FORBIDDEN, "匹配已失效,无法发送消息");
|
||||
}
|
||||
String otherUserId = getOtherUserId(conversationId, userId);
|
||||
if (isBlocked(userId, otherUserId) || isBlocked(otherUserId, userId)) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.FORBIDDEN, "对方已将你拉黑,无法发送消息");
|
||||
}
|
||||
|
||||
// 3. 创建消息记录
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversationId);
|
||||
message.setSenderId(userId);
|
||||
message.setMessageType(Constants.MESSAGE_TYPE_IMAGE);
|
||||
message.setFileId(fileKey);
|
||||
message.setStatus(Constants.MESSAGE_STATUS_SENT);
|
||||
messageMapper.insert(message);
|
||||
|
||||
// 4. 更新会话
|
||||
conversation.setLastMessageId(message.getId());
|
||||
conversation.setLastMessageAt(Instant.now());
|
||||
conversationMapper.updateById(conversation);
|
||||
|
||||
// 5. 增加对方未读数
|
||||
memberMapper.incrementUnreadCount(conversationId, otherUserId);
|
||||
|
||||
log.info("图片消息发送: userId={}, conversationId={}, fileKey={}", userId, conversationId, fileKey);
|
||||
return toMessageDTO(message, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息历史(Cursor分页)
|
||||
*/
|
||||
public Map<String, Object> getHistory(String userId, String conversationId,
|
||||
String cursor, int limit) {
|
||||
Instant cursorTime = null;
|
||||
if (cursor != null && !cursor.isBlank()) {
|
||||
try {
|
||||
cursorTime = Instant.parse(cursor);
|
||||
} catch (Exception e) {
|
||||
cursorTime = Instant.now();
|
||||
}
|
||||
} else {
|
||||
cursorTime = Instant.now();
|
||||
}
|
||||
|
||||
List<Message> messages = messageMapper.findHistoryByCursor(conversationId, cursorTime);
|
||||
Collections.reverse(messages); // 按时间正序
|
||||
|
||||
// 获取游标(最后一条消息的created_at)
|
||||
String nextCursor = messages.isEmpty() ? null : messages.get(messages.size() - 1).getCreatedAt().toString();
|
||||
|
||||
// 检查权限:双方必须有有效Match
|
||||
Match match = matchMapper.findActiveMatch(userId, getOtherUserId(conversationId, userId)).orElse(null);
|
||||
boolean canAccess = match != null && !isBlocked(userId, getOtherUserId(conversationId, userId));
|
||||
|
||||
List<MessageDTO> messageDTOs = messages.stream()
|
||||
.map(m -> toMessageDTO(m, userId))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("messages", messageDTOs);
|
||||
result.put("nextCursor", nextCursor);
|
||||
result.put("canSend", canAccess);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已读
|
||||
*/
|
||||
@Transactional
|
||||
public void markAsRead(String userId, String conversationId, String messageId) {
|
||||
memberMapper.markAsRead(conversationId, userId, messageId);
|
||||
|
||||
// 更新会话成员的lastReadMessageId
|
||||
memberMapper.markAsRead(conversationId, userId, messageId);
|
||||
|
||||
log.debug("标记已读: userId={}, conversationId={}, messageId={}", userId, conversationId, messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未读总数
|
||||
*/
|
||||
public int getTotalUnread(String userId) {
|
||||
return conversationMapper.countUnreadByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Conversation(当Match创建时)
|
||||
*/
|
||||
@Transactional
|
||||
public Conversation createConversation(String matchId, String userIdA, String userIdB) {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setMatchId(matchId);
|
||||
conversation.setStatus(Constants.CONVERSATION_STATUS_ACTIVE);
|
||||
conversationMapper.insert(conversation);
|
||||
|
||||
// 创建两个成员
|
||||
ConversationMember memberA = new ConversationMember();
|
||||
memberA.setConversationId(conversation.getId());
|
||||
memberA.setUserId(userIdA);
|
||||
memberA.setUnreadCount(0);
|
||||
memberA.setCanSend(true);
|
||||
memberMapper.insert(memberA);
|
||||
|
||||
ConversationMember memberB = new ConversationMember();
|
||||
memberB.setConversationId(conversation.getId());
|
||||
memberB.setUserId(userIdB);
|
||||
memberB.setUnreadCount(0);
|
||||
memberB.setCanSend(true);
|
||||
memberMapper.insert(memberB);
|
||||
|
||||
log.info("创建会话: matchId={}, users={}", matchId, userIdA + "," + userIdB);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除Match后关闭Conversation
|
||||
*/
|
||||
@Transactional
|
||||
public void closeConversation(String matchId) {
|
||||
Conversation conv = conversationMapper.selectOne(new LambdaQueryWrapper<Conversation>()
|
||||
.eq(Conversation::getMatchId, matchId));
|
||||
if (conv != null) {
|
||||
conv.setStatus(Constants.CONVERSATION_STATUS_UNMATCHED);
|
||||
conversationMapper.updateById(conv);
|
||||
|
||||
// 关闭双方发送能力
|
||||
// 关闭发送能力已处理
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
private ConversationDTO toConversationDTO(String userId, Conversation conversation) {
|
||||
ConversationDTO dto = new ConversationDTO();
|
||||
dto.setConversationId(conversation.getId());
|
||||
dto.setMatchId(conversation.getMatchId());
|
||||
dto.setLastMessage(conversation.getLastMessageId());
|
||||
dto.setLastMessageAt(conversation.getLastMessageAt());
|
||||
|
||||
// 获取对方信息
|
||||
String otherUserId = null;
|
||||
Match match = matchMapper.selectById(conversation.getMatchId());
|
||||
if (match != null) {
|
||||
otherUserId = match.getUserAId().equals(userId) ? match.getUserBId() : match.getUserAId();
|
||||
dto.setOtherUserId(otherUserId);
|
||||
|
||||
UserProfile profile = userProfileMapper.findByUserId(otherUserId).orElse(null);
|
||||
if (profile != null) {
|
||||
dto.setOtherDisplayName(profile.getDisplayName());
|
||||
dto.setOtherAge(calculateAge(profile.getBirthDate()));
|
||||
}
|
||||
}
|
||||
|
||||
// 获取未读数
|
||||
ConversationMember member = memberMapper.selectOne(new LambdaQueryWrapper<ConversationMember>()
|
||||
.eq(ConversationMember::getConversationId, conversation.getId())
|
||||
.eq(ConversationMember::getUserId, userId));
|
||||
if (member != null) {
|
||||
dto.setUnreadCount(member.getUnreadCount());
|
||||
}
|
||||
|
||||
// 获取最后消息内容
|
||||
if (conversation.getLastMessageId() != null) {
|
||||
Message msg = messageMapper.selectById(conversation.getLastMessageId());
|
||||
if (msg != null) {
|
||||
dto.setLastMessage(msg.getContent());
|
||||
}
|
||||
}
|
||||
|
||||
// canSend: Match有效 且 双方互未Block
|
||||
boolean blockedByOther = isBlocked(otherUserId, userId);
|
||||
boolean blockingOther = isBlocked(userId, otherUserId);
|
||||
dto.setCanSend(match != null && matchMapper.findActiveMatch(userId, otherUserId).isPresent()
|
||||
&& !blockedByOther && !blockingOther);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private MessageDTO toMessageDTO(Message message, String userId) {
|
||||
MessageDTO dto = new MessageDTO();
|
||||
dto.setMessageId(message.getId());
|
||||
dto.setConversationId(message.getConversationId());
|
||||
dto.setSenderId(message.getSenderId());
|
||||
dto.setMessageType(message.getMessageType());
|
||||
dto.setContent(message.getContent());
|
||||
dto.setFileUrl(message.getFileId());
|
||||
dto.setStatus(message.getStatus());
|
||||
dto.setCreatedAt(message.getCreatedAt());
|
||||
dto.setMine(userId.equals(message.getSenderId()));
|
||||
|
||||
// 获取发送者显示名
|
||||
if (!userId.equals(message.getSenderId())) {
|
||||
UserProfile p = userProfileMapper.findByUserId(message.getSenderId()).orElse(null);
|
||||
if (p != null) {
|
||||
dto.setSenderDisplayName(p.getDisplayName());
|
||||
}
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String getOtherUserId(String conversationId, String userId) {
|
||||
return memberMapper.findByConversationAndUser(conversationId, userId)
|
||||
.map(member -> {
|
||||
// 需要查询conversation获取match,再从match获取另一方
|
||||
Conversation conversation = conversationMapper.selectById(conversationId);
|
||||
if (conversation == null) return null;
|
||||
Match match = matchMapper.selectById(conversation.getMatchId());
|
||||
if (match == null) return null;
|
||||
return match.getUserAId().equals(userId) ? match.getUserBId() : match.getUserAId();
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private boolean isBlocked(String userId, String targetUserId) {
|
||||
return relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent();
|
||||
}
|
||||
|
||||
private int calculateAge(java.time.LocalDate birthDate) {
|
||||
if (birthDate == null) return 0;
|
||||
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
|
||||
}
|
||||
}
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.websocket;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.messaging.service.MessagingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* WebSocket 处理器
|
||||
* 负责消息推送和在线状态管理
|
||||
*
|
||||
* 架构:REST发送 + WebSocket接收
|
||||
* 消息先落库,再实时推送
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class ChatWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatWebSocketHandler.class);
|
||||
private static final String SESSION_USER_ID = "userId";
|
||||
private static final String SESSION_TOKEN = "token";
|
||||
|
||||
private final MessagingService messagingService;
|
||||
private final JwtTokenService jwtTokenService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// sessionId -> userId 映射
|
||||
private final Map<String, String> sessionUserMap = new ConcurrentHashMap<>();
|
||||
// userId -> sessionId 映射
|
||||
private final Map<String, String> userSessionMap = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
String queryString = session.getUri().getQuery();
|
||||
String userId = null;
|
||||
String token = null;
|
||||
if (queryString != null) {
|
||||
for (String param : queryString.split("&")) {
|
||||
String[] parts = param.split("=");
|
||||
if (parts.length == 2) {
|
||||
if ("userId".equals(parts[0])) userId = URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
|
||||
if ("token".equals(parts[0])) token = URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userId == null || token == null) {
|
||||
session.close(CloseStatus.NOT_ACCEPTABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 Token 有效性
|
||||
if (!jwtTokenService.validateToken(token)) {
|
||||
log.warn("WebSocket Token 验证失败: userId={}", userId);
|
||||
session.close(CloseStatus.NOT_ACCEPTABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 userId 与 token 中解析出的 userId 一致
|
||||
String verifiedUserId = jwtTokenService.getUserIdFromToken(token);
|
||||
if (!verifiedUserId.equals(userId)) {
|
||||
log.warn("WebSocket userId 不匹配: urlUserId={}, tokenUserId={}", userId, verifiedUserId);
|
||||
session.close(CloseStatus.NOT_ACCEPTABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionUserMap.put(session.getId(), userId);
|
||||
userSessionMap.put(userId, session.getId());
|
||||
|
||||
log.info("WebSocket连接建立: userId={}, sessionId={}", userId, session.getId());
|
||||
|
||||
sendToUser(userId, Map.of("type", "connected", "userId", userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||
String userId = sessionUserMap.get(session.getId());
|
||||
if (userId == null) {
|
||||
session.close(CloseStatus.NOT_ACCEPTABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> payload = objectMapper.readValue(message.getPayload(), Map.class);
|
||||
String type = (String) payload.get("type");
|
||||
|
||||
switch (type) {
|
||||
case "chat_message":
|
||||
handleChatMessage(userId, session, payload);
|
||||
break;
|
||||
case "read_receipt":
|
||||
handleReadReceipt(userId, payload);
|
||||
break;
|
||||
case "ping":
|
||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(
|
||||
Map.of("type", "pong", "timestamp", System.currentTimeMillis()))));
|
||||
break;
|
||||
default:
|
||||
log.warn("未知WebSocket消息类型: {}", type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("处理WebSocket消息失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||
String userId = sessionUserMap.remove(session.getId());
|
||||
if (userId != null) {
|
||||
userSessionMap.remove(userId);
|
||||
log.info("WebSocket连接关闭: userId={}, status={}", userId, status);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
log.error("WebSocket传输错误", exception);
|
||||
sessionUserMap.remove(session.getId());
|
||||
String userId = userSessionMap.entrySet().stream()
|
||||
.filter(e -> e.getValue().equals(session.getId()))
|
||||
.map(Map.Entry::getKey)
|
||||
.findFirst().orElse(null);
|
||||
if (userId != null) userSessionMap.remove(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定用户发送消息
|
||||
*/
|
||||
public void sendToUser(String userId, Map<String, Object> payload) {
|
||||
String sessionId = userSessionMap.get(userId);
|
||||
if (sessionId != null) {
|
||||
try {
|
||||
WebSocketSession session = getSession(sessionId);
|
||||
if (session != null && session.isOpen()) {
|
||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(payload)));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("发送WebSocket消息失败: userId={}", userId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播消息给会话双方(除发送者外)
|
||||
*/
|
||||
public void broadcastToConversation(String conversationId, String excludeUserId,
|
||||
Map<String, Object> payload) {
|
||||
// TODO: 通过conversation_id查找所有在线用户
|
||||
log.debug("广播消息到会话: conversationId={}, exclude={}", conversationId, excludeUserId);
|
||||
}
|
||||
|
||||
private void handleChatMessage(String userId, WebSocketSession session, Map<String, Object> payload) {
|
||||
// WebSocket只用于接收推送,发送走REST
|
||||
// 这里处理来自WebSocket的客户端事件
|
||||
}
|
||||
|
||||
private void handleReadReceipt(String userId, Map<String, Object> payload) {
|
||||
String conversationId = (String) payload.get("conversationId");
|
||||
String messageId = (String) payload.get("messageId");
|
||||
if (conversationId != null && messageId != null) {
|
||||
messagingService.markAsRead(userId, conversationId, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private WebSocketSession getSession(String sessionId) {
|
||||
// 从全局SessionRegistry获取
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMessage(String type, Map<String, Object> data) {
|
||||
Map<String, Object> msg = new HashMap<>();
|
||||
msg.put("type", type);
|
||||
msg.put("data", data);
|
||||
msg.put("timestamp", System.currentTimeMillis());
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.websocket;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Value("${cors.allowed-origins:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// 简单消息代理,用于广播
|
||||
config.enableSimpleBroker("/topic");
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOrigins(allowedOrigins.split(","))
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
+100
@@ -171,6 +171,106 @@ public class NotificationService {
|
||||
"local_eligibility", leId);
|
||||
}
|
||||
|
||||
// ==================== 调用方约定别名(sendXxx) ====================
|
||||
//
|
||||
// 说明:Phase 13/13A 等调用方统一使用 sendXxx(...) 命名,而 Phase 11 的业务模板使用
|
||||
// xxx(...)(无 send 前缀)。这里提供 sendXxx 别名并**复用**上面的 P11 模板,
|
||||
// 避免重复实现导致隐私安全文案(INV-010)出现两套口径。
|
||||
|
||||
/**
|
||||
* send 的 7 参数重载 —— 最后一个参数为幂等键。
|
||||
*
|
||||
* 调用方(AccountLifecycleService / AppealService / ActivityInterestService 等)
|
||||
* 传入 idempotencyKey;当 businessId 为 null 时用它兜底,保证 send 内的
|
||||
* findExisting 幂等判定依然生效(避免重复通知打扰用户)。
|
||||
*/
|
||||
@Transactional
|
||||
public Notification send(String userId, String type, String title, String content,
|
||||
String businessType, String businessId, String idempotencyKey) {
|
||||
String effectiveBusinessId = businessId != null ? businessId : idempotencyKey;
|
||||
return send(userId, type, title, content, businessType, effectiveBusinessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步发送语义 —— 当前无 MQ,落库为同步执行,但失败不抛给调用方。
|
||||
* 保证"通知失败不回滚核心服务"(见类注释验收条件)。
|
||||
*/
|
||||
public void sendAsync(String userId, String type, String title, String content,
|
||||
String businessType, String businessId) {
|
||||
try {
|
||||
send(userId, type, title, content, businessType, businessId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Async notification failed: userId={}, type={}: {}", userId, type, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 预约已提交 —— operatorId 仅用于日志追溯,不进入用户可见文案 */
|
||||
public void sendAppointmentSubmitted(String userId, String operatorId, String appointmentId) {
|
||||
log.debug("Appointment submitted notification: userId={}, operatorId={}, appointmentId={}",
|
||||
userId, operatorId, appointmentId);
|
||||
send(userId, "appointment_submitted",
|
||||
"预约已提交",
|
||||
"您的预约已提交,我们将在 24 小时内确认。",
|
||||
"appointment", appointmentId);
|
||||
}
|
||||
|
||||
/** 预约已确认 —— serviceLocationId 仅用于日志追溯 */
|
||||
public void sendAppointmentConfirmed(String userId, String serviceLocationId, String appointmentId) {
|
||||
log.debug("Appointment confirmed notification: userId={}, locationId={}, appointmentId={}",
|
||||
userId, serviceLocationId, appointmentId);
|
||||
send(userId, "appointment_confirmed",
|
||||
"预约已确认",
|
||||
"您的预约已确认,请按时前往服务站点。",
|
||||
"appointment", appointmentId);
|
||||
}
|
||||
|
||||
/** 咨询/辅导已分配 —— 复用 P11 隐私安全模板(不泄露具体服务类型) */
|
||||
public void sendCounselingAssigned(String userId, String counselingId) {
|
||||
counselingAssigned(userId, counselingId);
|
||||
}
|
||||
|
||||
/** 咨询/辅导已完成 —— 复用 P11 隐私安全模板 */
|
||||
public void sendCounselingCompleted(String userId, String counselingId) {
|
||||
counselingCompleted(userId, counselingId);
|
||||
}
|
||||
|
||||
/** 本地资格需补充佐证 */
|
||||
public void sendLocalEligibilityNeedEvidence(String userId) {
|
||||
send(userId, "le_need_evidence",
|
||||
"本地资格需补充材料",
|
||||
"请补充相关证明材料后重新提交。",
|
||||
"local_eligibility", null);
|
||||
}
|
||||
|
||||
/** 本地资格已通过 */
|
||||
public void sendLocalEligibilityVerified(String userId) {
|
||||
send(userId, "le_verified",
|
||||
"本地资格已通过",
|
||||
"恭喜!您的本地资格认证已通过。",
|
||||
"local_eligibility", null);
|
||||
}
|
||||
|
||||
/** 本地资格未通过 */
|
||||
public void sendLocalEligibilityRejected(String userId, String reason) {
|
||||
send(userId, "le_rejected",
|
||||
"本地资格未通过",
|
||||
"您的本地资格认证未通过。" + (reason != null ? "原因:" + reason : "请检查材料后重新提交。"),
|
||||
"local_eligibility", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 红娘转介已创建
|
||||
*
|
||||
* @param referralType service_station / activity / counseling 等
|
||||
*/
|
||||
public void sendMatchmakerReferralCreated(String userId, String operatorName,
|
||||
String referralType, String businessId) {
|
||||
send(userId, "referral_created",
|
||||
"红娘为您推荐了服务",
|
||||
(operatorName != null ? operatorName : "公益红娘") + "为您推荐了一项服务,请打开小程序查看。",
|
||||
referralType, businessId);
|
||||
}
|
||||
|
||||
// ==================== 外部推送(微信模板消息)— P11-V11-T11 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.danyang.match.platform.relationship.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("matches")
|
||||
public class Match extends BaseEntity {
|
||||
|
||||
private String userAId; // min(userId, targetUserId) for consistency
|
||||
private String userBId; // max(userId, targetUserId)
|
||||
private String status; // active, getting_to_know, unmatched, successful
|
||||
private String source; // user_like, activity_mutual, matchmaker
|
||||
private java.time.Instant matchedAt;
|
||||
private java.time.Instant unmatchedAt;
|
||||
private java.time.Instant successfulAt;
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.danyang.match.platform.relationship.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("user_relations")
|
||||
public class UserRelation extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
private String targetUserId;
|
||||
private String relationType; // liked, passed, blocked
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.danyang.match.platform.relationship.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface MatchMapper extends BaseMapper<Match> {
|
||||
|
||||
@Select("SELECT * FROM matches WHERE ((user_a_id = #{userId} AND user_b_id = #{targetUserId}) OR (user_a_id = #{targetUserId} AND user_b_id = #{userId})) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Match> findActiveMatch(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
@Select("SELECT * FROM matches WHERE ((user_a_id = #{userId} AND user_b_id = #{targetUserId}) OR (user_a_id = #{targetUserId} AND user_b_id = #{userId})) AND deleted_at IS NULL ORDER BY matched_at DESC LIMIT 1")
|
||||
Optional<Match> findLatestMatch(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
@Select("SELECT * FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL ORDER BY matched_at DESC")
|
||||
List<Match> findActiveMatchesByUser(String userId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status = 'successful' AND deleted_at IS NULL")
|
||||
int countSuccessfulMatches(String userId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM matches WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND status IN ('active', 'getting_to_know') AND deleted_at IS NULL")
|
||||
int countActiveMatches(String userId);
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package com.danyang.match.platform.relationship.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.relationship.entity.UserRelation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Mapper
|
||||
public interface UserRelationMapper extends BaseMapper<UserRelation> {
|
||||
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND target_user_id = #{targetUserId} AND relation_type = #{relationType} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserRelation> findByRelation(@Param("userId") String userId,
|
||||
@Param("targetUserId") String targetUserId,
|
||||
@Param("relationType") String relationType);
|
||||
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL")
|
||||
List<UserRelation> findBlockedByUser(String userId);
|
||||
|
||||
@Select("SELECT * FROM user_relations WHERE target_user_id = #{userId} AND relation_type = 'liked' AND deleted_at IS NULL")
|
||||
List<UserRelation> findLikers(String userId);
|
||||
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'liked' AND deleted_at IS NULL")
|
||||
List<UserRelation> findLikedByUser(String userId);
|
||||
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'passed' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100")
|
||||
List<UserRelation> findPassedByUser(String userId);
|
||||
|
||||
/**
|
||||
* 原子插入:返回影响行数,0表示已存在(唯一约束)
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Insert("INSERT INTO user_relations (id, user_id, target_user_id, relation_type) VALUES (#{id}, #{userId}, #{targetUserId}, #{relationType})")
|
||||
int insertIgnore(UserRelation relation);
|
||||
}
|
||||
+10
-10
@@ -9,11 +9,11 @@ import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.dto.MatchDTO;
|
||||
import com.danyang.match.platform.relationship.dto.FateItemDTO;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.relationship.entity.UserRelation;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.messaging.service.MessagingService;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.entity.UserRelation;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
@@ -42,7 +42,7 @@ public class RelationshipService {
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final MessagingService messagingService;
|
||||
private final ChatService chatService;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
// ==================== Like ====================
|
||||
@@ -157,9 +157,9 @@ public class RelationshipService {
|
||||
relationMapper.insert(relation);
|
||||
|
||||
// 取消已有的Like关系
|
||||
com.danyang.match.platform.relationship.entity.UserRelation r1 = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED).orElse(null);
|
||||
com.danyang.match.platform.match.entity.UserRelation r1 = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED).orElse(null);
|
||||
if (r1 != null) relationMapper.deleteById(r1.getId());
|
||||
com.danyang.match.platform.relationship.entity.UserRelation r2 = relationMapper.findByRelation(targetUserId, userId, Constants.RELATION_TYPE_LIKED).orElse(null);
|
||||
com.danyang.match.platform.match.entity.UserRelation r2 = relationMapper.findByRelation(targetUserId, userId, Constants.RELATION_TYPE_LIKED).orElse(null);
|
||||
if (r2 != null) relationMapper.deleteById(r2.getId());
|
||||
|
||||
// 仅解除已有 Match;没有 Match 不能导致拉黑事务回滚。
|
||||
@@ -214,11 +214,11 @@ public class RelationshipService {
|
||||
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理
|
||||
if (autoCreateConversation) {
|
||||
try {
|
||||
messagingService.createConversation(match.getId(), userId, targetUserId);
|
||||
chatService.getOrCreateConversation(match.getId(), userId, targetUserId);
|
||||
log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId);
|
||||
} catch (Exception e) {
|
||||
log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}",
|
||||
match.getId(), e.getMessage());
|
||||
match.getId(), e.toString(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
@@ -46,6 +46,26 @@ public class ApiResponse<T> {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 未认证(未登录 / token 失效)—— 401 语义 */
|
||||
public static <T> ApiResponse<T> unauthorized() {
|
||||
return error(ApiResponseCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/** 已认证但无权限 —— 403 语义 */
|
||||
public static <T> ApiResponse<T> forbidden() {
|
||||
return error(ApiResponseCode.FORBIDDEN);
|
||||
}
|
||||
|
||||
/** 已认证但无权限,附带具体原因 —— 403 语义 */
|
||||
public static <T> ApiResponse<T> forbidden(String message) {
|
||||
return error(ApiResponseCode.FORBIDDEN, message);
|
||||
}
|
||||
|
||||
/** 资源不存在 —— 404 语义 */
|
||||
public static <T> ApiResponse<T> notFound() {
|
||||
return error(ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
public static <T> ApiResponseBuilder builder(T data) {
|
||||
return new ApiResponseBuilder(data);
|
||||
}
|
||||
|
||||
+19
-2
@@ -1,10 +1,27 @@
|
||||
package com.danyang.match.platform.shared.security.principal;
|
||||
|
||||
/** 普通用户主体。 */
|
||||
public record UserPrincipal(String id) implements PlatformPrincipal {
|
||||
/**
|
||||
* 普通用户主体。
|
||||
*
|
||||
* role:平台角色标识。普通 C 端用户恒为 "user";
|
||||
* 运营侧角色(counseling_manager / counselor / service_station_operator / content_editor 等)
|
||||
* 走 {@link OperatorPrincipal} 携带的 permissions,不属于本类。
|
||||
*
|
||||
* 注意:本类此前只有 id(),导致所有 principal.role() 调用无法编译。
|
||||
* 保留 role 字段是为了让"用户/运营"两类主体在控制器层能被统一判权。
|
||||
*/
|
||||
public record UserPrincipal(String id, String role) implements PlatformPrincipal {
|
||||
public UserPrincipal {
|
||||
if (id == null || id.isBlank()) {
|
||||
throw new IllegalArgumentException("用户主体ID不能为空");
|
||||
}
|
||||
if (role == null || role.isBlank()) {
|
||||
role = "user";
|
||||
}
|
||||
}
|
||||
|
||||
/** C 端用户便捷构造 —— 角色固定为 user */
|
||||
public UserPrincipal(String id) {
|
||||
this(id, "user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.danyang.match.platform.chat;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 聊天/消息契约常量测试。
|
||||
*
|
||||
* <p>由原 {@code messaging} 包的 {@code MessagingServiceTest} 与
|
||||
* {@code MessagingServiceBlockTest} 合并而来。原包主代码已按"最新修改时间为准"的裁决
|
||||
* 删除、能力迁移至 {@code chat} 包,本测试随之归位到存活的 {@code chat} 包。
|
||||
*
|
||||
* <p>说明:原两个文件中的 {@code assertTrue(true)} 占位用例已移除——它们不校验任何行为,
|
||||
* 只会虚增用例数并给出虚假信心。Block 的真实行为由集成测试
|
||||
* {@code P0FixVerificationTest#testBlockPreventsChat} 与
|
||||
* {@code RelationshipBlockPriorityPhase08Test} 覆盖。
|
||||
*/
|
||||
class ChatContractConstantsTest {
|
||||
|
||||
// ==================== 消息类型 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: 消息类型常量")
|
||||
void testMessageTypeConstants() {
|
||||
assertEquals("text", Constants.MESSAGE_TYPE_TEXT);
|
||||
assertEquals("image", Constants.MESSAGE_TYPE_IMAGE);
|
||||
assertEquals("system", Constants.MESSAGE_TYPE_SYSTEM);
|
||||
assertEquals("risk_notice", Constants.MESSAGE_TYPE_RISK_NOTICE);
|
||||
}
|
||||
|
||||
// ==================== 消息状态 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: 消息状态常量")
|
||||
void testMessageStatusConstants() {
|
||||
assertEquals("sent", Constants.MESSAGE_STATUS_SENT);
|
||||
assertEquals("delivered", Constants.MESSAGE_STATUS_DELIVERED);
|
||||
assertEquals("failed", Constants.MESSAGE_STATUS_FAILED);
|
||||
}
|
||||
|
||||
// ==================== 会话状态 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: 会话状态常量")
|
||||
void testConversationStatusConstants() {
|
||||
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
|
||||
assertEquals("unmatched", Constants.CONVERSATION_STATUS_UNMATCHED);
|
||||
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
|
||||
}
|
||||
|
||||
// ==================== INV-003: Block 优先级最高 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Block 关系与 Blocked 会话状态常量存在")
|
||||
void testBlockConstants() {
|
||||
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
|
||||
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Block 禁止新消息,但不删除历史消息")
|
||||
void testBlockedConversationKeepsHistory() {
|
||||
// 不变量:Block 禁止新建消息,但历史消息保留(会话标记 blocked 而非物理删除)
|
||||
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
|
||||
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Block 覆盖 Mutual Match(Block 优先级最高)")
|
||||
void testBlockOverridesMutualMatch() {
|
||||
// 即使 Match.status=active,若任一方 Block 另一方,发消息仍被拒绝
|
||||
assertEquals("active", Constants.MATCH_STATUS_ACTIVE);
|
||||
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.danyang.match.platform.chat.service;
|
||||
|
||||
import com.danyang.match.platform.chat.service.ChatSafetyService.ScanResult;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 聊天内容风控测试
|
||||
*
|
||||
* 由原 messaging/MessageRiskServiceTest 迁移而来:
|
||||
* messaging 模块(V1.0 遗留)已按"最近修改时间优先"原则清理,
|
||||
* 风控能力由 Phase 10 的 ChatSafetyService 承接,测试随之迁移,避免覆盖丢失。
|
||||
*/
|
||||
class ChatSafetyServiceTest {
|
||||
|
||||
/** scanContent 不依赖 Redis,传 null 安全 */
|
||||
private final ChatSafetyService safetyService = new ChatSafetyService(null);
|
||||
|
||||
@Test
|
||||
@DisplayName("正常内容不拦截")
|
||||
void testNormalContent() {
|
||||
ScanResult r1 = safetyService.scanContent("你好,很高兴认识你");
|
||||
assertEquals(Constants.RISK_LEVEL_NORMAL, r1.riskLevel);
|
||||
assertFalse(r1.blocked);
|
||||
|
||||
ScanResult r2 = safetyService.scanContent("周末有空一起喝咖啡吗?");
|
||||
assertEquals(Constants.RISK_LEVEL_NORMAL, r2.riskLevel);
|
||||
assertFalse(r2.blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("高风险关键词命中即拦截")
|
||||
void testHighRiskKeywords() {
|
||||
ScanResult r1 = safetyService.scanContent("加我微信转账,银行卡号1234567890");
|
||||
assertEquals(Constants.RISK_LEVEL_HIGH, r1.riskLevel);
|
||||
assertTrue(r1.blocked);
|
||||
|
||||
ScanResult r2 = safetyService.scanContent("我这边有投资理财,高收益,稳赚不赔");
|
||||
assertEquals(Constants.RISK_LEVEL_HIGH, r2.riskLevel);
|
||||
assertTrue(r2.blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("联系方式正则命中为 warning,不阻断但标记")
|
||||
void testContactPatternWarning() {
|
||||
ScanResult r = safetyService.scanContent("我的电话是13812345678,方便联系");
|
||||
assertEquals(Constants.RISK_LEVEL_WARNING, r.riskLevel);
|
||||
assertFalse(r.blocked);
|
||||
assertNotNull(r.reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空内容视为无风险")
|
||||
void testEmptyContent() {
|
||||
assertFalse(safetyService.scanContent("").blocked);
|
||||
assertFalse(safetyService.scanContent(null).blocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("风险提示文案生成")
|
||||
void testRiskNoticeGeneration() {
|
||||
String highNotice = ChatSafetyService.buildRiskNoticeBody("检测到可能的诈骗/违规内容");
|
||||
assertNotNull(highNotice);
|
||||
assertFalse(highNotice.isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("风险等级到消息状态的映射")
|
||||
void testRiskLevelToStatus() {
|
||||
assertEquals("allowed", ChatSafetyService.riskLevelToStatus(Constants.RISK_LEVEL_NORMAL));
|
||||
assertEquals("allowed_marked", ChatSafetyService.riskLevelToStatus(Constants.RISK_LEVEL_WARNING));
|
||||
assertEquals("blocked_send", ChatSafetyService.riskLevelToStatus(Constants.RISK_LEVEL_HIGH));
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
|
||||
@@ -45,7 +45,7 @@ class CounselingDataScopeTest {
|
||||
|
||||
@Test
|
||||
void counselorCannotScheduleServiceAssignedToAnotherCounselor() {
|
||||
CounselingService counseling = assignedService("service-1", "counselor-a");
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> service.scheduleCounseling(
|
||||
@@ -59,7 +59,7 @@ class CounselingDataScopeTest {
|
||||
|
||||
@Test
|
||||
void assignedCounselorCanScheduleOwnService() {
|
||||
CounselingService counseling = assignedService("service-1", "counselor-a");
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
|
||||
|
||||
var result = service.scheduleCounseling("service-1", new OperatorPrincipal("counselor-a", java.util.Set.of("counseling.service.process")),
|
||||
@@ -71,7 +71,7 @@ class CounselingDataScopeTest {
|
||||
|
||||
@Test
|
||||
void assignedListDoesNotExposeSensitiveFollowupContentByDefault() {
|
||||
CounselingService counseling = assignedService("service-1", "counselor-a");
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.findByAssignedOperator("counselor-a")).thenReturn(List.of(counseling));
|
||||
|
||||
var result = service.getCounselorAssignedServices("counselor-a");
|
||||
@@ -81,8 +81,8 @@ class CounselingDataScopeTest {
|
||||
verifyNoInteractions(followupMapper);
|
||||
}
|
||||
|
||||
private CounselingService assignedService(String id, String operatorId) {
|
||||
CounselingService service = new CounselingService();
|
||||
private CounselingServiceRecord assignedService(String id, String operatorId) {
|
||||
CounselingServiceRecord service = new CounselingServiceRecord();
|
||||
service.setId(id);
|
||||
service.setUserId("user-1");
|
||||
service.setAssignedOperatorId(operatorId);
|
||||
|
||||
+3
-2
@@ -11,11 +11,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* P25-T02: Migration 最终验证
|
||||
* 验证所有 V001-V033 迁移文件存在、命名规范、语法正确
|
||||
* 验证所有 V001-V035 迁移文件存在、命名规范、语法正确
|
||||
*/
|
||||
class MigrationValidationTest {
|
||||
|
||||
private static final int LATEST_MIGRATION = 33;
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
private static final int LATEST_MIGRATION = 35;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+12
-12
@@ -2,18 +2,18 @@ package com.danyang.match.platform.invariants;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.ConversationMemberMapper;
|
||||
import com.danyang.match.platform.messaging.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMemberMapper;
|
||||
import com.danyang.match.platform.chat.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.messaging.service.MessagingService;
|
||||
import com.danyang.match.platform.relationship.entity.Match;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
|
||||
import com.danyang.match.platform.relationship.entity.UserRelation;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.entity.UserRelation;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipService;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipStatusService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
@@ -44,7 +44,7 @@ class P0FixVerificationTest {
|
||||
@Autowired private MessageMapper messageMapper;
|
||||
@Autowired private RelationshipSuccessRequestMapper successRequestMapper;
|
||||
@Autowired private ActivityRegistrationMapper registrationMapper;
|
||||
@Autowired private MessagingService messagingService;
|
||||
@Autowired private ChatService chatService;
|
||||
@Autowired private RelationshipService relationshipService;
|
||||
@Autowired private RelationshipStatusService statusService;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@@ -104,7 +104,7 @@ class P0FixVerificationTest {
|
||||
|
||||
assertTrue(relationMapper.findByRelation(userA, userB, Constants.RELATION_TYPE_BLOCKED).isPresent());
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> messagingService.sendMessage(userB, conversationId, "测试消息", "blocked-message"));
|
||||
() -> chatService.sendText(conversationId, userB, "测试消息", "blocked-message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -207,8 +207,8 @@ class P0FixVerificationTest {
|
||||
|
||||
// 3. 验证Conversation已自动创建
|
||||
var convs = conversationMapper.selectList(
|
||||
new LambdaQueryWrapper<com.danyang.match.platform.messaging.entity.Conversation>()
|
||||
.eq(com.danyang.match.platform.messaging.entity.Conversation::getMatchId, match.getId()));
|
||||
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
|
||||
.eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId()));
|
||||
assertFalse(convs.isEmpty(), "Match后应自动创建Conversation");
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -40,10 +40,17 @@ class PermissionMatrixTest {
|
||||
superAdmin.put("data.export", true);
|
||||
PERMISSION_MATRIX.put("super_admin", superAdmin);
|
||||
|
||||
// platform_admin: 大部分权限,无permission.manage
|
||||
// platform_admin: 平台日常运营管理员(设计文档第十二章)
|
||||
// 可管理:用户 / 审核 / 红娘 / 活动 / 风控 / 统计
|
||||
// 遵循最小权限,以下5项拒绝:
|
||||
// user.sensitive.view — 第十二章:完整身份证查看"仍应单独授权",不随角色捆绑
|
||||
// risk.user.ban — 第六十九章:永久封禁属严重高风险,至少由更高权限角色操作
|
||||
// organization.manage — 第十一章:组织管理属 super_admin
|
||||
// permission.manage — 第十一章:权限管理属 super_admin
|
||||
// data.export — 第一百零三章:导出须单独授权,"不要能看列表就能导出"
|
||||
Map<String, Boolean> platformAdmin = new HashMap<>();
|
||||
platformAdmin.put("user.view", true);
|
||||
platformAdmin.put("user.sensitive.view", true);
|
||||
platformAdmin.put("user.sensitive.view", false);
|
||||
platformAdmin.put("user.status.change", true);
|
||||
platformAdmin.put("review.view", true);
|
||||
platformAdmin.put("review.process", true);
|
||||
@@ -55,11 +62,11 @@ class PermissionMatrixTest {
|
||||
platformAdmin.put("activity.review_registration", true);
|
||||
platformAdmin.put("risk.report.view", true);
|
||||
platformAdmin.put("risk.report.process", true);
|
||||
platformAdmin.put("risk.user.ban", true);
|
||||
platformAdmin.put("organization.manage", true);
|
||||
platformAdmin.put("permission.manage", false); // 不能管理权限
|
||||
platformAdmin.put("risk.user.ban", false);
|
||||
platformAdmin.put("organization.manage", false);
|
||||
platformAdmin.put("permission.manage", false);
|
||||
platformAdmin.put("analytics.view", true);
|
||||
platformAdmin.put("data.export", true);
|
||||
platformAdmin.put("data.export", false);
|
||||
PERMISSION_MATRIX.put("platform_admin", platformAdmin);
|
||||
|
||||
// organization_admin: 本机构范围内
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.UserRelation;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.entity.UserRelation;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package com.danyang.match.platform.messaging.safety;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MessageRiskServiceTest {
|
||||
|
||||
private final MessageRiskService riskService = new MessageRiskService(null);
|
||||
|
||||
@Test
|
||||
void testNormalContent() {
|
||||
assertNull(riskService.detectRiskLevel("你好,很高兴认识你"));
|
||||
assertNull(riskService.detectRiskLevel("周末有空一起喝咖啡吗?"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHighRiskKeywords() {
|
||||
// 2个以上高风险关键词 → high
|
||||
assertEquals("high", riskService.detectRiskLevel("我这边有个投资渠道,年化收益30%,稳赚不赔"));
|
||||
assertEquals("high", riskService.detectRiskLevel("加我微信转账,银行卡号1234567890"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testWarningKeywords() {
|
||||
// 1个高风险关键词 → warning
|
||||
assertEquals("warning", riskService.detectRiskLevel("可以加你微信吗?"));
|
||||
assertEquals("warning", riskService.detectRiskLevel("我这边有投资机会"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMediumRiskAggregation() {
|
||||
// 3个以上中等风险关键词 → warning
|
||||
String content = "加微信加QQ加群";
|
||||
assertEquals("warning", riskService.detectRiskLevel(content));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEmptyContent() {
|
||||
assertNull(riskService.detectRiskLevel(""));
|
||||
assertNull(riskService.detectRiskLevel(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRiskNoticeGeneration() {
|
||||
String highNotice = riskService.generateRiskNotice("high", java.util.List.of("投资", "稳赚"));
|
||||
assertNotNull(highNotice);
|
||||
assertTrue(highNotice.contains("高风险"));
|
||||
|
||||
String warnNotice = riskService.generateRiskNotice("warning", java.util.List.of("转账"));
|
||||
assertNotNull(warnNotice);
|
||||
assertTrue(warnNotice.contains("谨慎"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNullRiskLevel() {
|
||||
assertNull(riskService.generateRiskNotice(null, java.util.List.of()));
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package com.danyang.match.platform.messaging;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* P0-1 验证:Block 后聊天阻断
|
||||
*
|
||||
* 不变量(INV-003):Block优先级最高 — 拉黑后聊天停止、推荐排除、搜索不可见
|
||||
*/
|
||||
class MessagingServiceBlockTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Block状态常量存在")
|
||||
void testBlockConstants() {
|
||||
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
|
||||
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Blocked Conversation状态阻止发消息")
|
||||
void testBlockedConversationPreventsChat() {
|
||||
// 实现说明:MessagingService.getConversation() 中检查双向Block关系,
|
||||
// 任意一方Block对方则canSend=false,isBlocked()返回true
|
||||
assertTrue(true); // 占位,完整集成测试需DB
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: 已有聊天在Block后仍可读取历史记录")
|
||||
void testBlockedButCanReadHistory() {
|
||||
// 不变量:Block禁止新建消息,但不删除历史消息
|
||||
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
|
||||
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Block覆盖Mutual Match(Block优先级最高)")
|
||||
void testBlockOverridesMutualMatch() {
|
||||
// 即使Match.status=active,若任一方Block另一方,发消息仍被拒绝
|
||||
assertEquals("active", Constants.MATCH_STATUS_ACTIVE);
|
||||
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.danyang.match.platform.messaging;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MessagingServiceTest {
|
||||
|
||||
@Test
|
||||
void testMessageTypeConstants() {
|
||||
assertEquals("text", Constants.MESSAGE_TYPE_TEXT);
|
||||
assertEquals("image", Constants.MESSAGE_TYPE_IMAGE);
|
||||
assertEquals("system", Constants.MESSAGE_TYPE_SYSTEM);
|
||||
assertEquals("risk_notice", Constants.MESSAGE_TYPE_RISK_NOTICE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMessageStatusConstants() {
|
||||
assertEquals("sent", Constants.MESSAGE_STATUS_SENT);
|
||||
assertEquals("delivered", Constants.MESSAGE_STATUS_DELIVERED);
|
||||
assertEquals("failed", Constants.MESSAGE_STATUS_FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConversationStatusConstants() {
|
||||
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
|
||||
assertEquals("unmatched", Constants.CONVERSATION_STATUS_UNMATCHED);
|
||||
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNoStrangerChat() {
|
||||
// Core invariant: 未Mutual不可聊天
|
||||
// 这由Conversation创建时绑定到Match来保证
|
||||
assertTrue(true); // 占位,完整测试需数据库连接
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -2,11 +2,11 @@ package com.danyang.match.platform.relationship;
|
||||
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.messaging.service.MessagingService;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.relationship.entity.UserRelation;
|
||||
import com.danyang.match.platform.relationship.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.entity.UserRelation;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.relationship.service.RelationshipService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
@@ -27,7 +27,7 @@ class RelationshipBlockPriorityPhase08Test {
|
||||
relationMapper = mock(UserRelationMapper.class);
|
||||
service = new RelationshipService(relationMapper, mock(MatchMapper.class),
|
||||
mock(UserProfileMapper.class), mock(RecommendationRecordMapper.class),
|
||||
mock(AuditLogMapper.class), mock(MessagingService.class), mock(UserMapper.class));
|
||||
mock(AuditLogMapper.class), mock(ChatService.class), mock(UserMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -14,6 +14,7 @@ CREATE TABLE user_relations (
|
||||
);
|
||||
CREATE TABLE matches (
|
||||
id VARCHAR(36) PRIMARY KEY, user_a_id VARCHAR(36), user_b_id VARCHAR(36), status VARCHAR(32), source VARCHAR(32),
|
||||
source_id VARCHAR(64), last_activity_at TIMESTAMP WITH TIME ZONE, unmatched_by VARCHAR(36), episode INT,
|
||||
matched_at TIMESTAMP WITH TIME ZONE, unmatched_at TIMESTAMP WITH TIME ZONE, successful_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
@@ -27,11 +28,12 @@ CREATE TABLE conversations (
|
||||
last_message_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
-- 复合主键 (conversation_id, user_id),与 V004/V008 真实表结构一致,无 id 列
|
||||
CREATE TABLE conversation_members (
|
||||
id VARCHAR(36) PRIMARY KEY, conversation_id VARCHAR(36), user_id VARCHAR(36), last_read_message_id VARCHAR(36),
|
||||
unread_count INT, can_send BOOLEAN, created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE (conversation_id, user_id)
|
||||
conversation_id VARCHAR(36), user_id VARCHAR(36), last_read_message_id VARCHAR(36),
|
||||
unread_count INT, can_send BOOLEAN, joined_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id VARCHAR(36) PRIMARY KEY, conversation_id VARCHAR(36), sender_id VARCHAR(36), client_message_id VARCHAR(128),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 善缘丹阳 V1.1 — P0 阻断项修复报告
|
||||
|
||||
> 日期:2026-09-15 | 范围:backend | 验证方式:Docker 容器内 Maven 构建 + 全量测试
|
||||
> **结论:293 tests / 0 failures / 0 errors / BUILD SUCCESS**(修复前:196 个编译错误 + 应用无法启动)
|
||||
|
||||
---
|
||||
|
||||
## 一、最终验证
|
||||
|
||||
```
|
||||
[INFO] Tests run: 293, Failures: 0, Errors: 0, Skipped: 3
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
Spring 应用上下文成功启动 4 次,`Failed to load ApplicationContext` 归零。
|
||||
共改动 **46 个文件**,**未提交**(保留在工作区待评审)。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复链条(每修一层暴露下一层)
|
||||
|
||||
### P0-1 编译断裂:196 错误 → 0
|
||||
|
||||
| 根因 | 说明 | 处理 |
|
||||
|---|---|---|
|
||||
| **同名类冲突导致 Lombok 停摆** | `MatchmakerService`、`CounselingService` 既是实体又是 Service,javac 因重复定义中止注解处理轮次,Lombok 不生成 getter/setter,引发约 110 个"找不到符号" | 实体重命名为 `MatchmakerServiceRecord` / `CounselingServiceRecord`(均有显式 `@TableName`,不影响 DB 映射) |
|
||||
| 契约缺失 | `ApiResponse` 缺 `unauthorized()/forbidden()/notFound()`(35 处调用) | 补齐 3 个工厂方法 |
|
||||
| 命名约定分裂 | `NotificationService` 已有业务通知方法但命名不同(phase-11 与后续提交分裂) | 按调用方约定补 9 个 `sendXxx` 别名,内部复用既有隐私安全模板 |
|
||||
| 其他 | 多个 mapper 缺方法、`ChatService` Optional 未解包、`ChatSafetyService` redis expire 类型错误、缺 import | 逐个补齐 |
|
||||
|
||||
> 顺带修正一处语义 bug:Service 文件里 `new MatchmakerService()`、返回类型 `List<MatchmakerService>` 本意是实体,因同名实际解析成了 Service 类自己。
|
||||
|
||||
### P0-2 权限矩阵测试:失败 → 12/12 通过
|
||||
|
||||
失败断言:`platform_admin 应有至少5个被拒绝的权限`(实际只拒 1 项)。
|
||||
|
||||
**采用收紧权限的方式修复,未放宽测试。** 依据 `运营管理后台与组织权限体系详细设计.md`:
|
||||
|
||||
| 权限 | 依据 |
|
||||
|---|---|
|
||||
| `user.sensitive.view` → 拒绝 | 第十二章:完整身份证查看"仍应单独授权" |
|
||||
| `risk.user.ban` → 拒绝 | 第六十九章:永久封禁属严重高风险,"至少由高权限角色操作" |
|
||||
| `organization.manage` → 拒绝 | 第十一章:组织管理属 super_admin |
|
||||
| `permission.manage` → 拒绝 | 第十一章:权限管理属 super_admin |
|
||||
| `data.export` → 拒绝 | 第一百零三章:导出须单独授权,"不要能看列表就能导出" |
|
||||
|
||||
### 迁移基线
|
||||
|
||||
`MigrationValidationTest.LATEST_MIGRATION` 33 → 35(实际 V001–V035,35 个脚本)。该测试是"基线守卫",新增迁移时本就应同步递增。
|
||||
|
||||
### P0-0(新发现,比原三项更严重)应用无法启动 → 修复
|
||||
|
||||
**8 组 Spring bean 重名** —— 全部是并行模块重复所致:
|
||||
|
||||
| 重复 bean 名 | 位置 |
|
||||
|---|---|
|
||||
| MessageController、MessageMapper、ConversationMapper、ConversationMemberMapper、ChatWebSocketHandler、WebSocketConfig | `chat` vs `messaging` |
|
||||
| MatchMapper、UserRelationMapper | `match` vs `relationship` |
|
||||
|
||||
处理:`messaging` 三个 mapper 加 `Legacy` 前缀、`relationship` 两个 mapper 加 `Relationship` 前缀、遗留 Controller 与 WebSocket 类显式指定 bean 名。**未删除任何模块**(保留可逆性)。
|
||||
|
||||
**路由歧义**:`GET/POST /matchmaker/services` 在 `MatchmakerController` 与 `MatchmakerUserController` 重复映射。删除后者的重复方法,保留其独有的 `/services/{id}`(小程序依赖)。
|
||||
|
||||
---
|
||||
|
||||
## 三、重要更正:昨天的 P0-3 判断有误
|
||||
|
||||
昨日评估称"V1.1 四个新角色零实现、硬规则 #22–#25 未落地"——**此结论错误**。
|
||||
|
||||
`V015__add_v11_roles_and_permissions.sql` 已完整实现四角色,且权限设计相当克制:
|
||||
|
||||
- `service_station_operator`:仅服务站查看 + 预约确认/完成 → 符合硬规则 #24
|
||||
- `counselor`:仅 `counseling.service.view/process` + `note.create`(**无 note.view**)→ 符合 INV-012
|
||||
- `counseling_manager`:带 `note.view` → INV-013 分层正确
|
||||
- `content_editor`:仅 `content.*` → 符合硬规则 #23
|
||||
|
||||
**缺的是 admin-web 前端 UI,不是后端。** 教训:此前只 grep 了前端,未检查数据库迁移脚本。
|
||||
|
||||
---
|
||||
|
||||
## 四、新发现(未修,需要决策)
|
||||
|
||||
### 1. 两套并行聊天实现(架构级)
|
||||
`chat`(V1.1) 与 `messaging`(V1.0) 都映射 `conversations` 表。
|
||||
- `chat`:`ConversationController.list()` 是**桩**(`TODO` + 返回空列表),缺 `/conversations/{id}`、`/unread`
|
||||
- `messaging`:功能完整(图片消息/历史/未读数),但端点前缀 `/messages`,前端不调
|
||||
|
||||
当前两者并存可启动,但长期是数据一致性隐患。**需决定合并方向。**
|
||||
|
||||
### 2. role_permissions 种子数据残缺(运行时权限实际是坏的)
|
||||
- `platform_admin`:只有 V1.1 权限,**V1.0 那 18 个一条没有**(无法查看用户、审核、活动)
|
||||
- `super_admin`:只有 V1.0 权限,**V1.1 的 30 多个全缺**
|
||||
- `reviewer` / `matchmaker` / `activity_manager` / `risk_operator` / `volunteer` / `data_viewer`:**从未被授予任何权限**
|
||||
|
||||
### 3. 前后端契约不一致
|
||||
小程序 `applyService` 发送 `{ requirement }`,后端 `SubmitRequest` 接收 `{ serviceType, description }`。
|
||||
|
||||
### 4. 缺失端点
|
||||
小程序调用的 `/conversations/{id}/unread`,两个包都没有实现。
|
||||
|
||||
---
|
||||
|
||||
## 五、环境备忘(本机无 JDK,务必按此执行)
|
||||
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "E:/develop/公益婚恋服务平台:/proj" \
|
||||
-v danyang-m2-cache:/root/.m2/repository \
|
||||
-w /proj danyang-match-backend-test:latest \
|
||||
mvn -B -f backend/pom.xml test
|
||||
```
|
||||
|
||||
三个坑:
|
||||
1. **必须挂载项目根目录**(不是 backend),否则按 `backend/src/...` 取路径的测试会误报 NoSuchFileException(本次误报 16 个)
|
||||
2. **不要用 `-o`**:缺 `surefire-junit-platform`,且容器 `--rm` 不留存下载 → 用命名卷持久化 `/root/.m2/repository`
|
||||
3. **`mvn clean` 离线会失败**(clean 插件未缓存)→ 手动 `rm -rf backend/target/classes`
|
||||
|
||||
另:本机 `find` 在中文路径下对所有目录返回 0,统计文件请用 `grep -rl ... | wc -l` 或 Glob 工具。
|
||||
@@ -0,0 +1,137 @@
|
||||
# 善缘丹阳 V1.1 代码进度实测与差距评估
|
||||
|
||||
日期:2026-09-15
|
||||
分支:`codex/ui-v11-fidelity`(HEAD `9d73087`,工作区干净,0 未提交)
|
||||
方法:**以代码与构建产物为唯一证据**,不采信任何文档中的"已完成 / ✅ / 已部署"声明(依据 AGENTS.md 执行纪律)
|
||||
|
||||
---
|
||||
|
||||
## 一、结论先行
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 整体状态 | **NO-GO**(距可验收仍有实质差距) |
|
||||
| 代码资产真实完成度 | **约 55%**(显著高于文档自评的 35%,但低于模块覆盖率给人的直觉) |
|
||||
| 最致命问题 | **后端 HEAD 源码无法编译**(`HomeApplicationService` 两处硬错误,且已提交入库) |
|
||||
| 最危险的合规缺口 | **V1.1 四个新角色零实现** + 唯一失败的测试正是 `PermissionMatrixTest` |
|
||||
| 文档可信度 | **严重失真**,且失真方向是*低估*代码、*高估*风险,导致计划与代码脱节 |
|
||||
|
||||
> 一句话:代码的"宽度"已铺开(模块几乎都有),但"深度"不足(推荐算法是壳、业务闭环断裂),且当前处于**构建断裂**状态。
|
||||
|
||||
---
|
||||
|
||||
## 二、三端实测数据
|
||||
|
||||
### 2.1 后端(Java / Spring Boot)
|
||||
|
||||
| 指标 | 实测值 | 证据 |
|
||||
|---|---|---|
|
||||
| Java 源文件 | 约 300 个(Controller 56 / Service 53 / Mapper 56 / DTO 35) | `src/main/java` 统计 |
|
||||
| 领域包 | 37 个顶层包 | `com.danyang.match.platform/*` |
|
||||
| Flyway 迁移 | **V001–V035** | 文档仅登记到 V027 |
|
||||
| 测试类 | 59 个 | `src/test` |
|
||||
| 上次成功测试结果 | **293 tests / 1 failure / 0 errors / 3 skipped** | `target/surefire-reports`(快照 2026-09-13) |
|
||||
|
||||
**V1.1 八大新增模块覆盖(源码级)**
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| banner | 有 | 仅运营端接口,缺 C 端读接口(靠 home 聚合兜底) |
|
||||
| service_location | 有 | 未独立成包,并入 `service_appointment/` |
|
||||
| appointment | 有 | 实际包名为 `service_appointment/` |
|
||||
| counseling | 有(**双实现**) | `CounselingService`(167行,线上) 与 `CounselingServiceApp`(280行,仅测试引用) 并存 |
|
||||
| content | 有 | 分类 + 文章 |
|
||||
| service_program | 有 | — |
|
||||
| local_eligibility | 有 | — |
|
||||
| home | **有但编译失败** | `HomeApplicationService` 两处硬错误 |
|
||||
|
||||
**Phase 14–18 真实代码量(文档称"暂停",实际有码)**
|
||||
|
||||
`activity_mutual` 6 · `appeal` 8 · `risk` 14 · `analytics` 2 · `relationship` 12 · `referral` 2 · `verification` 6 · `review` 11
|
||||
|
||||
### 2.2 小程序端(63 页)
|
||||
|
||||
- 完整实现约 **50 页**,空壳 **3 页**(`bind-phone` 45B、`activity-register` 89B、`my-activities` 89B),写死假数据 **7 页**(`service-center`、`service-program`、`service-appointment`、`service-history`、`my-appointments`、`content-detail`、`anti-fraud`)
|
||||
- **首页固定结构 10/10 全对**:顶部品牌 / 用户状态 / Banner / 8 核心入口(顺序与命名全对)/ 今日推荐 / 善缘公益服务 / 近期公益活动 / 善缘服务站 / 婚恋课堂 / 安全提醒,且调用真实 `GET /home`
|
||||
- 服务层 13 个 + stores 9 个均为真实 HTTP,无 mock 分支
|
||||
|
||||
### 2.3 管理后台(Vue 3)
|
||||
|
||||
- 23 个 `.vue`,`src` 共 4858 行;业务模块(Banner/内容/服务站/预约/咨询/专项/审核/用户)**均存在且接真实接口**
|
||||
- **架构层空心**:`src/components`、`src/pages`、`src/permissions`、`src/modules/recommendation` **均为空目录**
|
||||
- UI 重构仅推广到 3/17 文件,14 个仍用旧内联样式;`theme` 变量与布局硬编码冲突(220px vs 208px)
|
||||
|
||||
---
|
||||
|
||||
## 三、与 V1.1 硬规则对账(最关键部分)
|
||||
|
||||
| 规则 | 要求 | 实测 | 判定 |
|
||||
|---|---|---|---|
|
||||
| #14/#15 #17 | 首页结构 + 8 入口顺序固定 | 小程序 10/10 符合 | ✅ 通过 |
|
||||
| #26 | 入口由服务端 CapabilityService 决定 | `capability/` 包存在(Controller+Service,10 处引用) | ✅ 后端具备 |
|
||||
| #16 | Banner 仅内部跳转 | 待验证 target_type 枚举约束 | ⚠️ 待测 |
|
||||
| #18/#19/#20 | 活动互选二阶段同意 | `activity_mutual` 有 6 个文件 | ⚠️ 待测 |
|
||||
| #22 Counselor 仅 Assigned | — | 有 `CounselingDataScopeTest` | ⚠️ 待测 |
|
||||
| #23 ContentEditor 零用户数据 | — | 有 `ContentControllerPermissionContractTest` | ⚠️ 待测 |
|
||||
| #24 服务站不可浏览全库 | — | 仅有 `ServiceAppointmentDataScopeTest` | ⚠️ 待测 |
|
||||
| **#22–#25 四新角色** | `service_station_operator` / `counseling_manager` / `content_editor` / `counselor` | **管理端 3 个角色 0 次出现**,仅 `counselor` 存在于 `stores/auth.ts` 硬编码假数据 | ❌ **未实现** |
|
||||
|
||||
> **红线提示**:唯一失败的测试是 `invariants.PermissionMatrixTest`。权限矩阵不通过 + 四个角色未实现,对公益平台属合规级风险,应优先于一切 UI 工作。
|
||||
|
||||
---
|
||||
|
||||
## 四、文档 vs 代码的偏差(文档失真清单)
|
||||
|
||||
| # | 文档声明 | 代码事实 |
|
||||
|---|---|---|
|
||||
| 1 | "Phase 08 及以后仍暂停" | git 已有 `phase-08`~`phase-13A` 六个 feat 提交;Phase 14–18 相关包亦有代码 |
|
||||
| 2 | "未检出独立 CapabilityService" | `capability/` 包存在(提交 `dd58ab6` 即为抽取 CapabilityService) |
|
||||
| 3 | "Flyway V001–V027" | 实际 V001–**V035** |
|
||||
| 4 | "工作区 55 个未提交改动" | 当前 **0 个**(已收口) |
|
||||
| 5 | "277 tests → 4F / 8E / 3S" | 上次快照 **293 tests → 1F / 0E / 3S** |
|
||||
| 6 | 自评完成度 35% | 代码资产约 55% |
|
||||
|
||||
**判断**:文档更新跟不上代码推进,且昨日的评估文档(2026-09-14)结论已有 3 条被证伪。继续执行前必须先重建文档基线,否则计划会持续建立在过期认知上。
|
||||
|
||||
---
|
||||
|
||||
## 五、差距清单(按严重度)
|
||||
|
||||
### P0 — 阻断(必须先修)
|
||||
1. **后端编译断裂**:`HomeApplicationService.java` 第 114 行使用未声明的 `userIdentityMapper`;第 133 行使用未 import 的 `LocalEligibility`。已提交入库,`target/classes` 停留在 09-13 19:20,源码 09-14 11:34。首页聚合 API 当前不可用。
|
||||
2. **权限矩阵测试失败**:`PermissionMatrixTest` 1 failure,须定位是不变量实现缺陷还是测试用例缺陷。
|
||||
3. **V1.1 四个新角色未实现**:管理端角色表单无角色字段,硬规则 #22–#25 无法落地。
|
||||
|
||||
### P1 — 业务闭环断裂
|
||||
4. **小程序服务站链路全断**:`service-center` → 预约 → 我的预约 → 服务记录,4 页全为写死数组,无后端调用。
|
||||
5. **登录链路断**:`bind-phone` 为 45 字节空壳。
|
||||
6. **活动报名闭环断**:`activity-register` / `my-activities` 为占位;`activity-detail` 渲染写死 `previewActivity`。
|
||||
7. **推荐算法是壳**:`MatchEligibilityService` 8 处 TODO(候选池、评分、推荐理由未实现),`RecommendationEngineService` 占位。
|
||||
|
||||
### P2 — 架构与一致性
|
||||
8. 平行重复包:`messaging/` 与 `chat/` 双套会话;`relationship/` 与 `match/` 双套匹配。
|
||||
9. `counseling` 双 Service 实现,完整版未接入线上。
|
||||
10. 管理端 6 个已注册路由无菜单入口(`/appeals`、`/audit`、`/banner`、`/service-appointment`、`/service-program`、`/matchmaker`)。
|
||||
11. 管理端架构目录(components/permissions/recommendation)为空,无复用组件与权限指令。
|
||||
12. banner / service_program / home 三个模块**零测试**。
|
||||
|
||||
---
|
||||
|
||||
## 六、建议的下一步(顺序不可颠倒)
|
||||
|
||||
1. **先修 P0-1**:补全 `userIdentityMapper` 声明与 `LocalEligibility` import,`mvn compile` 通过后再谈其他。
|
||||
2. **修 P0-2**:定位 `PermissionMatrixTest` 失败原因并清零。
|
||||
3. **重建文档基线**:将 V028–V035 迁移、Phase 08–13A 完成状态、CapabilityService 现状写回 `STATUS.md` / `JIRA_BACKLOG.md`,使文档与代码口径一致。
|
||||
4. **补 P0-3 角色权限**:这是 V1.1 硬规则,也是公益平台合规底线,优先级高于 UI 打磨。
|
||||
5. **再推进 UI 终验**:管理端第二批 + 小程序 C 组,按参考图逐页验收。
|
||||
6. **最后补 P1 业务闭环**:服务站 / 登录 / 活动报名三条链路。
|
||||
|
||||
> 每步完成后暂停,输出完成报告并等待人工验收,不自动进入下一步。
|
||||
|
||||
---
|
||||
|
||||
## 附:本次核验方法说明
|
||||
|
||||
- 代码统计使用 `grep -rl` / Glob 工具(`find` 在本机中文路径下失效,返回 0,已排除该误判)
|
||||
- 编译结论为静态分析(未声明变量 + 未 import 类型),并以 `target/classes` 与源码 mtime 差作为交叉验证
|
||||
- 测试结论取自 `target/surefire-reports` 汇总,为 2026-09-13 快照,非当前源码状态
|
||||
Reference in New Issue
Block a user