24 KiB
丹阳公益婚恋服务平台 — 问题修复计划
基于 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();
}
需要同步修复的位置:
MessagingService.java:274—dto.setCanSend(true);改为检查!isBlocked()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阻塞 |
建议执行顺序:
- 先修复全部P0(可并行)
- P0验证通过后,修复P1
- P1验证通过后,进入UAT阶段
- P2可纳入V1.1迭代
最后更新: 2026-09-01 | 基于 CODE_REVIEW_REPORT.md