Files
danyang-match/docs/FIX_PLAN.md
T

697 lines
24 KiB
Markdown
Raw Normal View History

2026-09-09 22:05:00 +08:00
# 丹阳公益婚恋服务平台 — 问题修复计划
> 基于 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*