Files
danyang-match/docs/FIX_PLAN.md
T

697 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 丹阳公益婚恋服务平台 — 问题修复计划
> 基于 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<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**:
```java
// 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));
}
```
**验证**:
```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<ActivityRegistration>()
.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<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,身高/地区/婚姻状况等全部跳过。
**修复方案**:
```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<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` 中根据权限隐藏菜单项**:
```typescript
// 菜单权限映射
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
**根因**: 消息发送无速率限制,恶意用户可以无限发送骚扰消息。
**修复方案**:
```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<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)
```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*