fix(matchmaker,referral): close Phase 13 KEEP-VERIFY/EXTEND — 7 real defects
KEEP-VERIFY/EXTEND 复验(历史 ✅ 不可信;测试覆盖函数 ≠ 覆盖运行时路径)。
逐条核对运行时路径与生产约束后修复 7 处缺陷,其中 3 处「只在真实 PostgreSQL 上炸」。
D1 (Critical, 生产故障)
matchmaker_recommendations.status 代码写 liked/passed/confirmed_mutual,
而 V009 chk_rec_status 只允许 pending_view/viewed/responded 且从未扩展
→ 真实 PG 上用户接受/跳过推荐必崩。测试库未复刻该 CHECK,故长期静默。
D2 (Critical, 生产故障)
控制器缺省 serviceType="recommendation" 不在 chk_matchmaker_service_type
白名单 → 提交红娘服务必崩。
D1+D2 同修于新增 V038__align_matchmaker_status_constraints.sql
(沿用 V018/V020/V025 的 DROP CONSTRAINT IF EXISTS + 重加白名单惯例)。
D3 (High, 越权写)
MatchmakerServiceService.recommendPartner 无 assignedOperatorId 归属校验,
且可把用户推荐给自己 → 补校验 + 禁止自我推荐。
D4 (High, 越权读)
getServiceDetail 无归属校验且返回完整用户资料(userId/description/
userProfile/followups)→ 签名加 requesterId,按调用者返回完整/脱敏视图。
D5 (High, 越权写)
ReferralService 的 userId 取自请求体且零授权校验 → 新增
assertMatchmakerServesUser,在 referToServiceStation/referToActivity 入口
强制校验存在进行中的服务关系。
D6 (High, 生产故障)
MatchmakerFollowupMapper.findByServiceId 只有声明无 @Select
→ Invalid bound statement (not found),getServiceDetail 必崩。
该缺陷由本轮新增测试首次暴露(历史测试从未调用该路径)→ 补 @Select。
D7 (Medium, 契约缺口)
admin-web 5 处 API 缺口(4 条 404 + C 端端点被运营端误用)+ 小程序缺转介服务层
→ 后端补 GET /operator/matchmaker/recommendations 与
GET /referrals/operator/history 两个运营端端点;前端改路径、
assign 改 POST + body operatorId;新增 miniapp/services/referralService.js。
测试保真度
- 测试库补 matchmaker_services/recommendations/followups 三表(此前该模块零真实覆盖),
约束与 V038 对齐;EntityMigrationParityTest 三表移入 REQUIRED_IN_TEST_SCHEMA;
MigrationValidationTest LATEST_MIGRATION 37 → 38。
- 新增 MatchmakerReferralPhase13Test(17 用例,@SpringBootTest 走真实方法路径 + 真实落库)。
- 重写 MatchmakerServiceTest(6 用例):直接解析 db/migration/V*.sql,按文件名顺序应用
DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT 取"最终有效"白名单,并内置解析器自检。
【关键】首版守卫硬编码白名单副本,注入 D1 后仍全绿(对漂移零反应)→ 必须解析真实 DDL。
- 删除 3 处假测试占位(MatchmakerServiceTest.testMatchmakerCannotLike 的 assertTrue(true);
CoreInvariantsTest INV-007 断言的是状态常量、INV-010 亦为 assertTrue(true)),
已如实改名并注明行为验证位置。
负向验证(两轮)
注入 D1(回退 chk_rec_status 白名单)→ 行为守卫 DataIntegrityViolation
+ 常量守卫 Multiple Failures (3 failures) → Tests run: 23, Failures: 1, Errors: 2
/ BUILD FAILURE,守卫有效性得证。缺陷已还原。
全量回归(离线容器,实测)
[WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3
[INFO] BUILD SUCCESS / Total time: 04:59 min
Phase 12 基线 371 → 391(净 +20)。
报告:docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md
Phase 13A 未自动进入,等待用户确认。
This commit is contained in:
+12
-1
@@ -62,11 +62,22 @@ public class MatchmakerOperatorController {
|
||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
||||
*/
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
||||
}
|
||||
|
||||
@GetMapping("/services/{id}")
|
||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id);
|
||||
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
|
||||
if (detail == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
+17
-1
@@ -3,11 +3,27 @@ package com.danyang.match.platform.matchmaker.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MatchmakerFollowupMapper extends BaseMapper<MatchmakerFollowup> {
|
||||
|
||||
List<MatchmakerFollowup> findByServiceId(String serviceId);
|
||||
/**
|
||||
* 某红娘服务的全部跟进记录。
|
||||
*
|
||||
* Phase 13 复验修复(D7):此前该方法**只有声明、没有 @Select**,
|
||||
* MyBatis 无法绑定 → 运行时抛
|
||||
* {@code Invalid bound statement (not found): ...findByServiceId}。
|
||||
* 调用方 {@code MatchmakerServiceService.getServiceDetail} 因此必然 500,
|
||||
* 红娘工作台"查看服务详情"功能完全不可用。
|
||||
*
|
||||
* 该缺陷此前不可见:测试库 schema-h2.sql 曾完全缺失 matchmaker_followups 表,
|
||||
* 没有任何测试能走到这条路径(Phase 13 已补表并新增行为测试)。
|
||||
*/
|
||||
@Select("SELECT * FROM matchmaker_followups WHERE service_id = #{serviceId} "
|
||||
+ "AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerFollowup> findByServiceId(@Param("serviceId") String serviceId);
|
||||
}
|
||||
|
||||
+11
@@ -28,4 +28,15 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
|
||||
|
||||
@Update("UPDATE matchmaker_recommendations SET viewed_at = NOW(), status = 'viewed' WHERE id = #{id} AND status = 'pending_view'")
|
||||
int markViewed(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
|
||||
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
|
||||
* 避免运营端越权看到他人推荐。
|
||||
*/
|
||||
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
||||
"JOIN matchmaker_services s ON s.id = r.service_id " +
|
||||
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
|
||||
"ORDER BY r.created_at DESC")
|
||||
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
}
|
||||
|
||||
+10
@@ -41,4 +41,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
|
||||
/**
|
||||
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
|
||||
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
|
||||
*/
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
||||
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
||||
@Param("userId") String userId);
|
||||
}
|
||||
|
||||
+74
-20
@@ -129,6 +129,13 @@ public class MatchmakerServiceService {
|
||||
|
||||
/**
|
||||
* 红娘推荐候选人给用户
|
||||
*
|
||||
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。
|
||||
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
|
||||
* 都能对**任意** serviceId 创建推荐(越权)。与
|
||||
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
|
||||
*
|
||||
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> recommendPartner(String serviceId, String operatorId,
|
||||
@@ -136,6 +143,15 @@ public class MatchmakerServiceService {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
|
||||
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
|
||||
if (!operatorId.equals(service.getAssignedOperatorId())) {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
// 不能推荐给自己(服务申请人)
|
||||
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
|
||||
throw new IllegalArgumentException("不能推荐用户给自己");
|
||||
}
|
||||
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
rec.setServiceId(serviceId);
|
||||
rec.setUserId(service.getUserId());
|
||||
@@ -266,36 +282,37 @@ public class MatchmakerServiceService {
|
||||
|
||||
/**
|
||||
* 获取服务详情
|
||||
*
|
||||
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
|
||||
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
|
||||
* **任意** serviceId 的详情,其中包含该用户的完整资料
|
||||
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
|
||||
*
|
||||
* 现语义:
|
||||
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
|
||||
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
|
||||
* (不含 userProfile;followups 正文也不返回,仅给条数)
|
||||
*
|
||||
* @param serviceId 服务 ID
|
||||
* @param requesterId 调用者 operator ID
|
||||
*/
|
||||
public Map<String, Object> getServiceDetail(String serviceId) {
|
||||
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) return null;
|
||||
|
||||
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId());
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("serviceId", service.getId());
|
||||
detail.put("userId", service.getUserId());
|
||||
detail.put("serviceType", service.getServiceType());
|
||||
detail.put("description", service.getDescription());
|
||||
detail.put("status", service.getStatus());
|
||||
detail.put("assignedOperatorId", service.getAssignedOperatorId());
|
||||
detail.put("createdAt", service.getCreatedAt());
|
||||
detail.put("assignedAt", service.getAssignedAt());
|
||||
detail.put("completedAt", service.getCompletedAt());
|
||||
detail.put("viewerIsOwner", isOwner);
|
||||
|
||||
// 加载用户资料
|
||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
||||
Map<String, Object> userProfile = new HashMap<>();
|
||||
userProfile.put("displayName", profile.getDisplayName());
|
||||
userProfile.put("gender", profile.getGender());
|
||||
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
||||
userProfile.put("regionName", profile.getRegionName());
|
||||
userProfile.put("educationCode", profile.getEducationCode());
|
||||
userProfile.put("occupationCode", profile.getOccupationCode());
|
||||
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
||||
detail.put("userProfile", userProfile);
|
||||
});
|
||||
|
||||
// 加载推荐列表
|
||||
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
|
||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||
detail.put("recommendations", recs.stream().map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
@@ -307,9 +324,26 @@ public class MatchmakerServiceService {
|
||||
return map;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
// 加载跟进记录
|
||||
List<MatchmakerFollowup> followups = followupMapper.findByServiceId(serviceId);
|
||||
detail.put("followups", followups);
|
||||
if (isOwner) {
|
||||
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
|
||||
detail.put("userId", service.getUserId());
|
||||
detail.put("description", service.getDescription());
|
||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
||||
Map<String, Object> userProfile = new HashMap<>();
|
||||
userProfile.put("displayName", profile.getDisplayName());
|
||||
userProfile.put("gender", profile.getGender());
|
||||
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
||||
userProfile.put("regionName", profile.getRegionName());
|
||||
userProfile.put("educationCode", profile.getEducationCode());
|
||||
userProfile.put("occupationCode", profile.getOccupationCode());
|
||||
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
||||
detail.put("userProfile", userProfile);
|
||||
});
|
||||
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||
} else {
|
||||
// 非负责人:只给跟进条数作为进度摘要,不返回正文
|
||||
detail.put("followupCount", followupMapper.findByServiceId(serviceId).size());
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
@@ -336,6 +370,26 @@ public class MatchmakerServiceService {
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表)。
|
||||
* 仅返回该 operator 名下服务的推荐,不越权。
|
||||
*/
|
||||
public List<Map<String, Object>> getRecommendationsByOperator(String operatorId) {
|
||||
return recommendationMapper.findByOperatorId(operatorId).stream()
|
||||
.map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", r.getId());
|
||||
map.put("serviceId", r.getServiceId());
|
||||
map.put("userId", r.getUserId());
|
||||
map.put("targetUserId", r.getTargetUserId());
|
||||
map.put("reason", r.getReason());
|
||||
map.put("status", r.getStatus());
|
||||
map.put("createdAt", r.getCreatedAt());
|
||||
return map;
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
|
||||
+15
-1
@@ -77,7 +77,7 @@ public class ReferralController {
|
||||
}
|
||||
|
||||
/**
|
||||
* P13-T04: 查询我的转介历史
|
||||
* P13-T04: 查询我的转介历史(C 端用户)
|
||||
* GET /api/v1/referrals/my-history
|
||||
*/
|
||||
@GetMapping("/my-history")
|
||||
@@ -87,5 +87,19 @@ public class ReferralController {
|
||||
return ApiResponse.ok(referralService.getReferralHistory(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:运营端查看**本红娘发起**的转介记录(红娘工作台/管理台)。
|
||||
* GET /api/v1/referrals/operator/history
|
||||
*
|
||||
* 补 admin-web ReferralManagementView 调用 C 端端点导致的契约缺口;
|
||||
* 按 created_by_operator_id 过滤,不越权看到他人转介。
|
||||
*/
|
||||
@GetMapping("/operator/history")
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<List<Map<String, Object>>> getOperatorReferralHistory(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(referralService.getReferralHistoryByOperator(principal.id()));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+66
@@ -28,11 +28,32 @@ public class ReferralService {
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
|
||||
/**
|
||||
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
|
||||
*
|
||||
* 修复前:userId 直接取自请求体且无任何授权校验 →
|
||||
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
|
||||
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
|
||||
*
|
||||
* 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户),
|
||||
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
||||
*/
|
||||
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("缺少转介目标用户");
|
||||
}
|
||||
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"您不是该用户的服务负责人,无法发起转介"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> referToServiceStation(String matchmakerId, String userId,
|
||||
String locationId, String serviceType,
|
||||
Instant preferredDate, String note) {
|
||||
assertMatchmakerServesUser(matchmakerId, userId);
|
||||
String operatorName = getOperatorName(matchmakerId);
|
||||
ServiceAppointment appt = new ServiceAppointment();
|
||||
appt.setId(UUID.randomUUID().toString());
|
||||
@@ -68,6 +89,7 @@ public class ReferralService {
|
||||
public Map<String, Object> referToActivity(String matchmakerId, String userId,
|
||||
String activityId, String emergencyContactName,
|
||||
String emergencyContactPhoneEnc) {
|
||||
assertMatchmakerServesUser(matchmakerId, userId);
|
||||
var existing = activityRegistrationMapper.selectOne(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getActivityId, activityId)
|
||||
@@ -179,4 +201,48 @@ public class ReferralService {
|
||||
return operatorId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:运营端(红娘/管理台)查看**本人发起**的转介记录。
|
||||
*
|
||||
* 补 admin-web `ReferralManagementView.vue` 调用 `GET /referrals/my-history`(C 端用户端点,
|
||||
* 用 UserPrincipal)导致的契约缺口 —— 运营端没有"我的用户身份"。
|
||||
* 本方法按 created_by_operator_id 过滤,红娘只看得到自己发起的转介。
|
||||
*/
|
||||
public List<Map<String, Object>> getReferralHistoryByOperator(String operatorId) {
|
||||
List<Map<String, Object>> results = new java.util.ArrayList<>();
|
||||
appointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.eq(ServiceAppointment::getCreatedByOperatorId, operatorId)
|
||||
.eq(ServiceAppointment::getSource, Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL)
|
||||
).forEach(a -> {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("type", "service_station");
|
||||
item.put("id", a.getId());
|
||||
item.put("userId", a.getUserId());
|
||||
item.put("serviceType", a.getServiceType());
|
||||
item.put("status", a.getStatus());
|
||||
item.put("createdByOperatorId", a.getCreatedByOperatorId());
|
||||
item.put("userConfirmedAt", a.getUserConfirmedAt());
|
||||
item.put("createdAt", a.getCreatedAt());
|
||||
results.add(item);
|
||||
});
|
||||
activityRegistrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getCreatedByOperatorId, operatorId)
|
||||
.eq(ActivityRegistration::getSource, Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL)
|
||||
).forEach(r -> {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("type", "activity");
|
||||
item.put("id", r.getId());
|
||||
item.put("userId", r.getUserId());
|
||||
item.put("activityId", r.getActivityId());
|
||||
item.put("status", r.getStatus());
|
||||
item.put("createdByOperatorId", r.getCreatedByOperatorId());
|
||||
item.put("userConfirmedAt", r.getUserConfirmedAt());
|
||||
item.put("registeredAt", r.getRegisteredAt());
|
||||
results.add(item);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user