# 丹阳公益婚恋服务平台 — 问题修复计划 > 基于 2026-09-01 代码审查报告,按优先级排列的修复计划。 > 每个修复项包含:问题定位、根因分析、修复方案、验证方法。 --- ## P0 — 阻塞上线(4项) ### P0-1: 修复 `MessagingService.isBlocked()` — Block不变量失效 **文件**: `backend/src/main/java/com/danyang/match/platform/messaging/service/MessagingService.java` **行号**: 313-316 **违反**: INV-003 (Block优先级最高) **根因**: 方法被标记为 `// TODO: Phase 08` 并返回硬编码 `false`,导致拉黑后仍可发送消息。 **修复方案**: ```java // 当前 (错误): private boolean isBlocked(String userId, String targetUserId) { // TODO: Phase 08 的 UserRelationMapper 查询 return false; } // 修复后: private final UserRelationMapper relationMapper; // 在构造器中注入(已有) private boolean isBlocked(String userId, String targetUserId) { return relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent(); } ``` **需要同步修复的位置**: 1. `MessagingService.java:274` — `dto.setCanSend(true);` 改为检查 `!isBlocked()` 2. `RelationshipService.java` — `isBlocked()` 已正确实现,无需修改 **验证**: ```java @Test void testBlockStopsChat() { // A Block B → A发消息给B → 应抛"对方已将你拉黑" relationshipService.block(userA, userB); assertThrows(RuntimeException.class, () -> messagingService.sendMessage(userA, conversationId, "test", null)); } ``` **预计工作量**: 30分钟 --- ### P0-2: 修复 `RelationshipStatusService.confirmSuccess()` — 双确认逻辑缺失 **文件**: `backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipStatusService.java` **行号**: 132-174 **违反**: INV-005 (成功牵手必须双确认) + AGENTS第5条 **根因**: 任何一方调用 `confirmSuccess()` 即直接确认成功,没有检查对方是否也确认了。 **修复方案**: ```java @Transactional public Map confirmSuccess(String userId, String targetUserId) { // 1. 校验有效Match Match match = matchMapper.findActiveMatch(userId, targetUserId) .orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "匹配关系不存在")); // 2. 查找或创建pending请求(基于matchId防重复) RelationshipSuccessRequest request = successRequestMapper.selectOne( new LambdaQueryWrapper() .eq(RelationshipSuccessRequest::getMatchId, match.getId()) .eq(RelationshipSuccessRequest::getDeletedAt, 0) .orderByDesc(RelationshipSuccessRequest::getCreatedAt) .last("LIMIT 1")); if (request == null) { // 无pending请求,先创建 request = new RelationshipSuccessRequest(); request.setMatchId(match.getId()); request.setInitiatedBy(userId); request.setStatus(Constants.SUCCESS_REQUEST_STATUS_PENDING); request.setCreatedAt(Instant.now()); successRequestMapper.insert(request); } // 3. 检查是否已被对方拒绝 if (Constants.SUCCESS_REQUEST_STATUS_DECLINED.equals(request.getStatus())) { throw new BusinessException(ApiResponseCode.CONFLICT, "牵手邀请已被拒绝"); } // 4. 更新请求:记录本次确认人 request.setConfirmedBy(userId); request.setResolvedAt(Instant.now()); // 5. 检查双方是否都已确认 boolean bothConfirmed = userId.equals(request.getInitiatedBy()) && userId.equals(request.getConfirmedBy()) // 发起方也确认了 || targetUserId.equals(request.getInitiatedBy()) && userId.equals(request.getConfirmedBy()); // 对方发起,本方确认 // 更准确:查询是否有另一方的确认记录 RelationshipSuccessRequest otherRequest = successRequestMapper.selectOne( new LambdaQueryWrapper() .eq(RelationshipSuccessRequest::getMatchId, match.getId()) .eq(RelationshipSuccessRequest::getInitiatedBy, targetUserId) .eq(RelationshipSuccessRequest::getStatus, Constants.SUCCESS_REQUEST_STATUS_CONFIRMED) .eq(RelationshipSuccessRequest::getDeletedAt, 0)); if (otherRequest != null) { // 双方都已确认 → 执行成功牵手 match.setStatus(Constants.MATCH_STATUS_SUCCESSFUL); match.setSuccessfulAt(Instant.now()); matchMapper.updateById(match); updateUserRelationshipStatus(userId, Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY); updateUserRelationshipStatus(targetUserId, Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY); stopRecommendations(userId); stopRecommendations(targetUserId); // 更新request状态 request.setStatus(Constants.SUCCESS_REQUEST_STATUS_CONFIRMED); successRequestMapper.updateById(request); if (otherRequest.getStatus() == null || !Constants.SUCCESS_REQUEST_STATUS_CONFIRMED.equals(otherRequest.getStatus())) { otherRequest.setStatus(Constants.SUCCESS_REQUEST_STATUS_CONFIRMED); successRequestMapper.updateById(otherRequest); } notificationService.sendAsync(userId, Constants.NOTIFICATION_TYPE_MATCH, "成功牵手", "恭喜!您和对方已确认成功牵手,平台将暂停双方的婚恋推荐。", "success", match.getId().toString()); notificationService.sendAsync(targetUserId, Constants.NOTIFICATION_TYPE_MATCH, "成功牵手", "恭喜!您和对方已确认成功牵手,平台将暂停双方的婚恋推荐。", "success", match.getId().toString()); saveAuditLog(userId, "SUCCESS_CONFIRM", "match", match.getId(), null, null); saveAuditLog(targetUserId, "SUCCESS_CONFIRM", "match", match.getId(), null, null); log.info("成功牵手确认: userId={}, targetUserId={}, matchId={}", userId, targetUserId, match.getId()); Map result = new HashMap<>(); result.put("matchId", match.getId()); result.put("status", "successful"); result.put("confirmedBy", userId); result.put("confirmedAt", Instant.now()); return result; } else { // 只有一方确认,等待对方 request.setStatus(Constants.SUCCESS_REQUEST_STATUS_CONFIRMED); successRequestMapper.updateById(request); // 通知对方 notificationService.sendAsync(targetUserId, Constants.NOTIFICATION_TYPE_MATCH, "成功牵手邀请", "您的匹配对象邀请您确认成功牵手,是否同意?", "success_request", request.getId().toString()); Map result = new HashMap<>(); result.put("matchId", match.getId()); result.put("status", "waiting_for_other"); result.put("confirmedBy", userId); return result; } } ``` **同时需要完善 `RelationshipStatusService` 中的其他TODO**: ```java // pauseSearching() — 注入UserMapper并更新 @Transactional public void pauseSearching(String userId) { userMapper.update(null, new LambdaUpdateWrapper() .eq(User::getId, userId) .set(User::getRelationshipStatus, Constants.RELATIONSHIP_STATUS_PAUSED) .set(User::getRecommendationEnabled, false)); // ... 通知和审计 } // resumeSearching() — 恢复状态 @Transactional public void resumeSearching(String userId) { userMapper.update(null, new LambdaUpdateWrapper() .eq(User::getId, userId) .set(User::getRelationshipStatus, Constants.RELATIONSHIP_STATUS_SEARCHING) .set(User::getRecommendationEnabled, true)); // ... 通知 } // 注入UserMapper private final UserMapper userMapper; // 添加到构造器参数 // updateUserRelationshipStatus() — 实现 private void updateUserRelationshipStatus(String userId, String status) { userMapper.update(null, new LambdaUpdateWrapper() .eq(User::getId, userId) .set(User::getRelationshipStatus, status)); } // stopRecommendations() — 实现 private void stopRecommendations(String userId) { userMapper.update(null, new LambdaUpdateWrapper() .eq(User::getId, userId) .set(User::getRecommendationEnabled, false)); } ``` **验证**: ```java @Test void testSuccessRequiresBothConfirm() { // A发起成功牵手请求 → 状态应为pending/waiting // B确认后 → 状态应变为successful // A再确认 → 不变(已经确认过) } @Test void testSuccessDeclineCancels() { // A发起 → B拒绝 → A再尝试确认 → 应返回409冲突 } ``` **预计工作量**: 2小时 --- ### P0-3: 修复 `AccountLifecycleService.checkCanWithdraw()` — 空查询导致注销失效 **文件**: `backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java` **行号**: 141-143 **根因**: `new LambdaQueryWrapper<>() {}` 是无条件全表查询,`activeRegistrations` 始终很大,阻止所有用户注销。 **修复方案**: ```java // 当前 (错误): long activeRegistrations = registrationMapper.selectCount( new LambdaQueryWrapper<>() {}).longValue(); // 修复后: long activeRegistrations = registrationMapper.selectCount( new LambdaQueryWrapper() .eq(ActivityRegistration::getUserId, userId) .in(ActivityRegistration::getStatus, Constants.REGISTRATION_STATUS_PENDING, Constants.REGISTRATION_STATUS_APPROVED)); ``` **同时需要修复 `sanitizeUserData()` 中身份证脱敏TODO**: ```java private void sanitizeUserData(String userId) { // 脱敏手机号 User user = userMapper.selectById(userId); if (user != null && user.getPhone() != null) { String phone = user.getPhone(); if (phone.length() >= 4) { user.setPhone(phone.substring(0, phone.length() - 4) + "****"); } else { user.setPhone("****"); } userMapper.updateById(user); } // 脱敏身份证(如有) // TODO: 查询并脱敏 user_identities.id_number(加密存储的需解密后脱敏再重新加密) log.info("敏感数据脱敏: userId={}", userId); } ``` **验证**: ```python # Python集成测试中验证 def test_account_close_allows_when_no_active_services(): # 创建无进行中的用户 → 应允许注销 # 创建有pending活动报名的用户 → 应拒绝注销 ``` **预计工作量**: 30分钟 --- ### P0-4: 修复 `RelationshipService.createMatch()` — 自动创建Conversation **文件**: `backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java` **行号**: 204 **根因**: Match创建后没有建立Conversation,导致Match成功后用户无法立即开始聊天。 **修复方案**: 在 `RelationshipService` 中注入 `MessagingService`,然后在 `createMatch()` 中调用: ```java @RequiredArgsConstructor public class RelationshipService { // ... 已有注入 ... private final MessagingService messagingService; // 新增注入 } private void createMatch(String userId, String targetUserId, String source) { String userA = userId.compareTo(targetUserId) < 0 ? userId : targetUserId; String userB = userId.compareTo(targetUserId) < 0 ? targetUserId : userId; Match existing = matchMapper.findActiveMatch(userId, targetUserId).orElse(null); if (existing != null) { throw new RuntimeException("匹配已存在"); } Match match = new Match(); match.setUserAId(userA); match.setUserBId(userB); match.setStatus(Constants.MATCH_STATUS_ACTIVE); match.setSource(source); match.setMatchedAt(Instant.now()); matchMapper.insert(match); // 自动创建Conversation try { messagingService.createConversation(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已写入DB,会话创建失败不影响Match完整性 } } ``` **⚠️ 注意循环依赖风险**: `RelationshipService` 和 `MessagingService` 互相引用。 - `MessagingService` 已注入 `MatchMapper`(不引用 `RelationshipService`) - `RelationshipService` 引用 `MessagingService` 这是单向依赖,不是循环依赖,可以正常工作。 **验证**: ```java @Test void testMatchAutoCreatesConversation() { // A Like B → 双向Like → 自动创建Conversation Map result = relationshipService.like(userA, userB); assertTrue((Boolean) result.get("matchCreated")); // 验证Conversation存在 Conversation conv = conversationMapper.selectOne( new LambdaQueryWrapper().eq(Conversation::getMatchId, match.getId())); assertNotNull(conv); } ``` **预计工作量**: 1小时 --- ## P1 — 上线前建议修复(4项) ### P1-1: 补全 RecommendationEngine 双向Required过滤 **文件**: `backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java` **行号**: 198-215 **根因**: 仅检查年龄Required,身高/地区/婚姻状况等全部跳过。 **修复方案**: ```java private boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate, UserPreference targetPref, UserProfile myProfile) { // 1. 年龄Required检查 if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) { int candidateAge = calculateAge(candidate.getBirthDate()); if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) { return false; } } if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) { int myAge = calculateAge(myProfile.getBirthDate()); if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) { return false; } } // 2. 身高Required检查 if ("required".equals(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) { if (candidate.getHeightCm() == null || candidate.getHeightCm() < myPref.getMinHeightCm() || candidate.getHeightCm() > myPref.getMaxHeightCm()) { return false; } } if ("required".equals(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) { if (myProfile.getHeightCm() == null || myProfile.getHeightCm() < targetPref.getMinHeightCm() || myProfile.getHeightCm() > targetPref.getMaxHeightCm()) { return false; } } // 3. 地区Required检查 if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) { boolean inScope = false; for (int i = 0; i < myPref.getRegionScope().size(); i++) { if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) { inScope = true; break; } } if (!inScope) return false; } if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) { boolean inScope = false; for (int i = 0; i < targetPref.getRegionScope().size(); i++) { if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) { inScope = true; break; } } if (!inScope) return false; } // 4. 婚姻状况Required检查 if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) { boolean accepted = false; for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) { if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) { accepted = true; break; } } if (!accepted) return false; } if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) { boolean accepted = false; for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) { if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) { accepted = true; break; } } if (!accepted) return false; } return true; } ``` **验证**: ```java @Test void testHeightRequiredFiltered() { // A要求身高≥170cm (required),B身高165cm // B不应出现在A的推荐中 } @Test void testMaritalStatusRequiredFiltered() { // A要求离异 (required),B未婚 // B不应出现在A的推荐中 } ``` **预计工作量**: 2小时 --- ### P1-2: Admin Web添加路由级权限守卫 **文件**: `admin-web/src/router/index.ts` **行号**: 29-35 **根因**: 路由守卫只有登录检查,没有权限检查,任何登录用户可访问所有管理页面。 **修复方案**: ```typescript // 路由权限映射 const ROUTE_PERMISSIONS: Record = { '/users': ['user.view'], '/users/detail': ['user.view', 'user.sensitive.view'], '/organizations': ['organization.manage'], '/operators': ['permission.manage'], '/review': ['review.view', 'review.process'], '/activities/interests': ['activity.review_registration'], '/risk/reports': ['risk.report.view', 'risk.report.process'], '/appeals': ['risk.report.process'], '/analytics': ['analytics.view'], '/audit': ['user.view'], } router.beforeEach((to) => { const authStore = useAuthStore() // 1. 登录检查 if (to.path !== '/login' && !authStore.isLoggedIn) { return { path: '/login', query: { redirect: to.fullPath } } } // 2. 权限检查(仅对需权限的路由) const requiredPerms = ROUTE_PERMISSIONS[to.path] if (requiredPerms) { const userPerms = authStore.permissions const hasPermission = requiredPerms.every(perm => userPerms.includes(perm)) if (!hasPermission) { ElMessage.error('您没有访问该页面的权限') return { path: '/dashboard' } } } document.title = to.meta.title ? `${to.meta.title} - 丹阳公益婚恋` : '丹阳公益婚恋' }) ``` **同时在 `MainLayout.vue` 中根据权限隐藏菜单项**: ```typescript // 菜单权限映射 const MENU_PERMISSIONS: Record = { '用户管理': ['user.view'], '资料审核': ['review.view'], '举报中心': ['risk.report.view'], // ... } ``` **预计工作量**: 1.5小时 --- ### P1-3: 实现消息速率限制 **文件**: `backend/src/main/java/com/danyang/match/platform/messaging/safety/MessageRiskService.java` **行号**: 83-87 **根因**: 消息发送无速率限制,恶意用户可以无限发送骚扰消息。 **修复方案**: ```java @Value("${rate-limit.max-requests-per-window:100}") private int maxRequestsPerWindow; @Value("${rate-limit.window-seconds:60}") private int windowSeconds; @Autowired private StringRedisTemplate redisTemplate; public boolean isRateLimited(String userId) { 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; } ``` **在 `application.yml` 中添加配置**: ```yaml rate-limit: max-requests-per-window: 20 # 每人每分钟20条消息 window-seconds: 60 ``` **预计工作量**: 30分钟 --- ### P1-4: HealthController接入实际依赖检查 **文件**: `backend/src/main/java/com/danyang/match/platform/shared/controller/HealthController.java` **修复方案**: ```java @GetMapping("/health") public ApiResponse> health() { Map details = new HashMap<>(); // 数据库检查 try { healthIndicator.check(); // Spring Boot Actuator的DataSourceHealthIndicator details.put("database", Map.of("status", "up")); } catch (Exception e) { details.put("database", Map.of("status", "down", "error", e.getMessage())); } // Redis检查 try { redisTemplate.opsForValue().set("_health_check_", "ok", 1, TimeUnit.SECONDS); String val = redisTemplate.opsForValue().get("_health_check_"); details.put("redis", Map.of("status", "up".equals(val) ? "up" : "down")); } catch (Exception e) { details.put("redis", Map.of("status", "down", "error", e.getMessage())); } boolean allUp = !details.values().stream() .filter(m -> m instanceof Map) .map(m -> ((Map)m).get("status")) .anyMatch("down"::equals); return ApiResponse.ok(Map.of( "status", allUp ? "UP" : "DEGRADED", "details", details )); } ``` **预计工作量**: 30分钟 --- ## P2 — 后续迭代(5项) ### P2-1: 修复 `RecommendationService` 字段声明顺序 **文件**: `RecommendationService.java:102,114` **操作**: 将 `private final UserProfileService userProfileService;` 移到类顶部与其他 `@Autowired` 字段一起 ### P2-2: 删除未使用的中文命名方法 **文件**: `RelationshipService.java:331` **操作**: 删除 `private void 解除Match()` 方法(已被 `unmatch()` 替代且未被调用) ### P2-3: 完善 AuditLog reason JSON序列化 **文件**: `AccountLifecycleService.java:196` **操作**: 使用 `ObjectMapper.writeValueAsString(Map.of("reason", reason))` 替代字符串拼接 ### P2-4: 实现图片上传Signed URL **文件**: `ChatController.java:66-77, PhotoService.java:51` **操作**: 接入MinIO Presigned URL生成 ### P2-5: 小程序TypeScript迁移 **操作**: 将45个`.js`页面迁移到`.ts`,添加类型定义 --- ## 验证计划 ### 单元测试补充 | 测试文件 | 新增测试用例 | 覆盖不变量 | |----------|-------------|-----------| | `RelationshipServiceTest.java` | block后不可聊天、双确认成功牵手、并发Like单Match | INV-003, INV-004, INV-005 | | `MessagingServiceTest.java` | isBlocked验证、无Match不可发消息 | INV-002, INV-003 | | `RecommendationEngineServiceTest.java` | 身高Required过滤、地区Required过滤 | INV-001 | | `AccountLifecycleServiceTest.java` | 无进行中服务可注销、有pending活动不可注销 | INV-008 | ### 集成测试补充(Python) ```python # tests/invariant_integration_test.py 新增: def test_inv003_block_stops_chat(token, user_a, user_b, conn): """Block后A不可向B发消息""" def test_inv005_success_requires_double_confirm(token, user_a, user_b, conn): """成功牵手需双方确认""" def test_inv001_height_required_filtered(token, user_a, user_b, conn): """身高Required过滤验证""" ``` ### 冒烟测试清单(API) ```bash # 核心流程冒烟测试 curl -X POST /api/v1/auth/wechat/login -d '{"code":"xxx"}' # 登录 curl -H "Authorization: Bearer $TOKEN" /api/v1/recommendations # 推荐 curl -X POST /api/v1/relations/UserB/like # Like curl -X POST /api/v1/relations/UserB/block # Block curl -X POST /api/v1/messages/conversations/{id}/send -d '{}' # 发消息(需先Match) curl -X POST /api/v1/relationship/success/request # 发起成功牵手 curl -X POST /api/v1/relationship/success/confirm # 确认成功牵手 curl -X PUT /api/v1/relationship/pause # 暂停寻找 curl -X PUT /api/v1/account/withdraw # 注销账号 ``` --- ## 修复优先级总结 | 优先级 | 数量 | 预计总工时 | 阻塞项 | |--------|------|-----------|--------| | P0 | 4项 | ~4小时 | 全部 | | P1 | 4项 | ~4小时 | 无 | | P2 | 5项 | ~8小时 | 无 | | **合计** | **13项** | **~16小时** | **4项P0阻塞** | **建议执行顺序**: 1. 先修复全部P0(可并行) 2. P0验证通过后,修复P1 3. P1验证通过后,进入UAT阶段 4. P2可纳入V1.1迭代 --- *最后更新: 2026-09-01 | 基于 CODE_REVIEW_REPORT.md*