Files
danyang-match/docs/FIX_PLAN.md
T

24 KiB
Raw Blame History

丹阳公益婚恋服务平台 — 问题修复计划

基于 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,导致拉黑后仍可发送消息。

修复方案:

// 当前 (错误):
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() 已正确实现,无需修改

验证:

@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() 即直接确认成功,没有检查对方是否也确认了。

修复方案:

@Transactional
public Map<String, Object> 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<RelationshipSuccessRequest>()
                    .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<RelationshipSuccessRequest>()
                    .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<String, Object> 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<String, Object> result = new HashMap<>();
        result.put("matchId", match.getId());
        result.put("status", "waiting_for_other");
        result.put("confirmedBy", userId);
        return result;
    }
}

同时需要完善 RelationshipStatusService 中的其他TODO:

// pauseSearching() — 注入UserMapper并更新
@Transactional
public void pauseSearching(String userId) {
    userMapper.update(null, new LambdaUpdateWrapper<User>()
            .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<User>()
            .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<User>()
            .eq(User::getId, userId)
            .set(User::getRelationshipStatus, status));
}

// stopRecommendations() — 实现
private void stopRecommendations(String userId) {
    userMapper.update(null, new LambdaUpdateWrapper<User>()
            .eq(User::getId, userId)
            .set(User::getRecommendationEnabled, false));
}

验证:

@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 始终很大,阻止所有用户注销。

修复方案:

// 当前 (错误):
long activeRegistrations = registrationMapper.selectCount(
        new LambdaQueryWrapper<>() {}).longValue();

// 修复后:
long activeRegistrations = registrationMapper.selectCount(
        new LambdaQueryWrapper<ActivityRegistration>()
                .eq(ActivityRegistration::getUserId, userId)
                .in(ActivityRegistration::getStatus,
                        Constants.REGISTRATION_STATUS_PENDING,
                        Constants.REGISTRATION_STATUS_APPROVED));

同时需要修复 sanitizeUserData() 中身份证脱敏TODO:

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集成测试中验证
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() 中调用:

@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

这是单向依赖,不是循环依赖,可以正常工作。

验证:

@Test
void testMatchAutoCreatesConversation() {
    // A Like B → 双向Like → 自动创建Conversation
    Map<String, Object> result = relationshipService.like(userA, userB);
    assertTrue((Boolean) result.get("matchCreated"));
    // 验证Conversation存在
    Conversation conv = conversationMapper.selectOne(
        new LambdaQueryWrapper<Conversation>().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,身高/地区/婚姻状况等全部跳过。

修复方案:

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

验证:

@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

根因: 路由守卫只有登录检查,没有权限检查,任何登录用户可访问所有管理页面。

修复方案:

// 路由权限映射
const ROUTE_PERMISSIONS: Record<string, string[]> = {
  '/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 中根据权限隐藏菜单项:

// 菜单权限映射
const MENU_PERMISSIONS: Record<string, string[]> = {
  '用户管理': ['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

根因: 消息发送无速率限制,恶意用户可以无限发送骚扰消息。

修复方案:

@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 中添加配置:

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

修复方案:

@GetMapping("/health")
public ApiResponse<Map<String, Object>> health() {
    Map<String, Object> 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)

# 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)

# 核心流程冒烟测试
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