From 5b3bad326e8940a10c01753c59768b1a0900cf1d Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Thu, 17 Sep 2026 00:01:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(matchmaker,referral):=20close=20Phase=2013?= =?UTF-8?q?=20KEEP-VERIFY/EXTEND=20=E2=80=94=207=20real=20defects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 未自动进入,等待用户确认。 --- .../matchmaker/MatchmakerManagementView.vue | 16 +- .../referral/ReferralManagementView.vue | 4 +- .../MatchmakerOperatorController.java | 13 +- .../mapper/MatchmakerFollowupMapper.java | 18 +- .../MatchmakerRecommendationMapper.java | 11 + .../mapper/MatchmakerServiceMapper.java | 10 + .../service/MatchmakerServiceService.java | 94 ++++- .../controller/ReferralController.java | 16 +- .../referral/service/ReferralService.java | 66 +++ ...8__align_matchmaker_status_constraints.sql | 42 ++ .../deployment/EntityMigrationParityTest.java | 10 +- .../deployment/MigrationValidationTest.java | 2 +- .../invariants/CoreInvariantsTest.java | 29 +- .../MatchmakerReferralPhase13Test.java | 380 ++++++++++++++++++ .../matchmaker/MatchmakerServiceTest.java | 188 ++++++++- backend/src/test/resources/schema-h2.sql | 53 +++ docs/JIRA_BACKLOG.md | 25 +- ...1-Recovery-Completion-Report-2026-09-16.md | 269 +++++++++++++ docs/STATUS.md | 62 ++- ...阳 V1.1 客户需求基线增量开发与修复计划.md | 46 ++- miniapp/services/referralService.js | 53 +++ 21 files changed, 1336 insertions(+), 71 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V038__align_matchmaker_status_constraints.sql create mode 100644 backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java create mode 100644 docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md create mode 100644 miniapp/services/referralService.js diff --git a/admin-web/src/modules/matchmaker/MatchmakerManagementView.vue b/admin-web/src/modules/matchmaker/MatchmakerManagementView.vue index 4aa28b7..4ed9607 100644 --- a/admin-web/src/modules/matchmaker/MatchmakerManagementView.vue +++ b/admin-web/src/modules/matchmaker/MatchmakerManagementView.vue @@ -72,7 +72,7 @@ diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java index de76567..7aed915 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java @@ -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>> getRecommendations( + @AuthenticationPrincipal OperatorPrincipal principal) { + return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id())); + } + @GetMapping("/services/{id}") public ApiResponse> getServiceDetail( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String id) { - Map detail = matchmakerServiceService.getServiceDetail(id); + // Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情 + Map detail = matchmakerServiceService.getServiceDetail(id, principal.id()); if (detail == null) { return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerFollowupMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerFollowupMapper.java index 2ee49b9..6739f82 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerFollowupMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerFollowupMapper.java @@ -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 { - List 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 findByServiceId(@Param("serviceId") String serviceId); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java index c5b97ac..e89adfa 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java @@ -28,4 +28,15 @@ public interface MatchmakerRecommendationMapper extends BaseMapper findByOperatorId(@Param("operatorId") String operatorId); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java index bb3438e..ae73778 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java @@ -41,4 +41,14 @@ public interface MatchmakerServiceMapper extends BaseMapper 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 findActiveByOperatorAndUser(@Param("operatorId") String operatorId, + @Param("userId") String userId); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java index d7bc58a..3657510 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java @@ -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 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 getServiceDetail(String serviceId) { + public Map getServiceDetail(String serviceId, String requesterId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); if (service == null) return null; + boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId()); + Map 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 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 recs = recommendationMapper.findByService(serviceId); detail.put("recommendations", recs.stream().map(r -> { Map map = new HashMap<>(); @@ -307,9 +324,26 @@ public class MatchmakerServiceService { return map; }).collect(Collectors.toList())); - // 加载跟进记录 - List 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 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> getRecommendationsByOperator(String operatorId) { + return recommendationMapper.findByOperatorId(operatorId).stream() + .map(r -> { + Map 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(); diff --git a/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java b/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java index 401ec68..b85091f 100644 --- a/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java +++ b/backend/src/main/java/com/danyang/match/platform/referral/controller/ReferralController.java @@ -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>> getOperatorReferralHistory( + @AuthenticationPrincipal OperatorPrincipal principal) { + return ApiResponse.ok(referralService.getReferralHistoryByOperator(principal.id())); + } + } diff --git a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java index 5ad53c9..0a4985a 100644 --- a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java +++ b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java @@ -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 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 referToActivity(String matchmakerId, String userId, String activityId, String emergencyContactName, String emergencyContactPhoneEnc) { + assertMatchmakerServesUser(matchmakerId, userId); var existing = activityRegistrationMapper.selectOne( new LambdaQueryWrapper() .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> getReferralHistoryByOperator(String operatorId) { + List> results = new java.util.ArrayList<>(); + appointmentMapper.selectList( + new LambdaQueryWrapper() + .eq(ServiceAppointment::getCreatedByOperatorId, operatorId) + .eq(ServiceAppointment::getSource, Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL) + ).forEach(a -> { + Map 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() + .eq(ActivityRegistration::getCreatedByOperatorId, operatorId) + .eq(ActivityRegistration::getSource, Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL) + ).forEach(r -> { + Map 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; + } } diff --git a/backend/src/main/resources/db/migration/V038__align_matchmaker_status_constraints.sql b/backend/src/main/resources/db/migration/V038__align_matchmaker_status_constraints.sql new file mode 100644 index 0000000..db9522f --- /dev/null +++ b/backend/src/main/resources/db/migration/V038__align_matchmaker_status_constraints.sql @@ -0,0 +1,42 @@ +-- ============================================================ +-- V038: Phase 13 红娘模块 —— 修复 CHECK 约束与代码写入值漂移 +-- +-- 背景(Phase 13 KEEP-VERIFY 复验发现): +-- V009 建立的 chk_rec_status / chk_matchmaker_service_type 白名单 +-- 从未随业务代码扩展,导致真实 PostgreSQL 上: +-- 1) 用户"接受/跳过"红娘推荐 → 写 liked/passed/confirmed_mutual → 违反 chk_rec_status +-- 2) 用户提交红娘服务(不带 serviceType)→ 默认写 recommendation → 违反 chk_matchmaker_service_type +-- 两条主流程在真实库上必然 500,而测试库缺 matchmaker_* 三表故长期静默。 +-- +-- 既有惯例参考:V018 / V020 / V025 均以 DROP CONSTRAINT IF EXISTS + 重加白名单扩展。 +-- 全部语句幂等,可重复执行。 +-- ============================================================ + +-- ---------- 1) matchmaker_recommendations.status ---------- +-- 代码实际写入值(MatchmakerService.java:225/250、checkAndCompleteService:262-264): +-- pending_view(初始)· viewed(查看)· liked(接受)· passed(跳过)· confirmed_mutual(双向成功) +-- 说明:保留 pending_view/viewed/responded 三个历史值以兼容存量数据; +-- 新增 liked/passed/confirmed_mutual 承载 P13 用户回应语义。 +ALTER TABLE matchmaker_recommendations DROP CONSTRAINT IF EXISTS chk_rec_status; + +ALTER TABLE matchmaker_recommendations ADD CONSTRAINT chk_rec_status + CHECK (status IN ( + 'pending_view', 'viewed', 'responded', + 'liked', 'passed', 'confirmed_mutual' + )); + +-- ---------- 2) matchmaker_services.service_type ---------- +-- 代码实际写入值: +-- recommendation —— MatchmakerController 缺省兜底(P13 主流程:红娘推荐对象) +-- referral_counseling / referral_activity / referral_station +-- —— MatchmakerService.java:57-61 注释声明的转介子类型(P13-T01/T02/T03) +-- 原有 7 个值保持不变(与 Constants.MATCHMAKER_SERVICE_TYPE_* 对齐,小程序在用)。 +ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type; + +ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type + CHECK (service_type IN ( + 'recommend_partner', 'review_preference', 'contact_intention', + 'join_activity', 'no_match_help', 'chat_help', 'other', + 'recommendation', + 'referral_counseling', 'referral_activity', 'referral_station' + )); diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java index cfa6b95..9ee70a2 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -62,9 +62,6 @@ class EntityMigrationParityTest { "counseling_followups", "interest_dictionary", "match_rule_configs", - "matchmaker_followups", - "matchmaker_recommendations", - "matchmaker_services", "operator_roles", "operators", "organization_relationships", @@ -102,7 +99,12 @@ class EntityMigrationParityTest { "content_articles", "activities", "user_local_eligibilities", - "local_eligibility_evidences" + "local_eligibility_evidences", + // Phase 13:红娘服务三表此前完全缺失 → matchmaker 模块零真实覆盖(只能写假测试)。 + // 已补入 schema-h2.sql,且从 TEST_SCHEMA_OPTIONAL_TABLES 白名单移除。 + "matchmaker_services", + "matchmaker_recommendations", + "matchmaker_followups" ); /** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */ private static final Set BASE_ENTITY_COLUMNS = diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 14a07ca..a03884e 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.*; class MigrationValidationTest { // 基线守卫:新增 Flyway 迁移时必须同步递增此值 - private static final int LATEST_MIGRATION = 37; + private static final int LATEST_MIGRATION = 38; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java b/backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java index 7708421..0f0ec1d 100644 --- a/backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java +++ b/backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java @@ -7,8 +7,22 @@ import static org.junit.jupiter.api.Assertions.*; class CoreInvariantsTest { + /* + * ⚠️ Phase 13 复验说明(AGENTS.md 禁止名不副实的测试): + * + * 本类**不验证行为**,只做「常量取值口径」守卫 —— 防止 Constants 的取值 + * 与生产 DDL 白名单漂移(这类漂移会在真实 PostgreSQL 上触发 CHECK 约束违反)。 + * 因此每个用例的 DisplayName 已改为如实描述:它守卫的是常量,不是不变量行为。 + * + * 真实行为验证(走服务方法 + 真实落库)见: + * - INV-004 搜索不可见 → DiscoverBlockInvisibilityIntegrationTest + * - INV-005 双确认 → RelationshipProgressPhase12Test + * - INV-007 红娘不代用户 Like → MatchmakerReferralPhase13Test + * - INV-013 咨询正文脱敏 → CounselingPrivacyPhase13Test + */ + @Test - @DisplayName("INV-001: Required条件不会自动放宽") + @DisplayName("常量口径 INV-001: 关系/Match 状态取值固定(行为验证见 ChatPhase09IntegrationTest)") void testRequiredNeverRelaxed() { assertEquals("liked", Constants.RELATION_TYPE_LIKED); assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED); @@ -17,7 +31,7 @@ class CoreInvariantsTest { } @Test - @DisplayName("INV-002: 未Mutual不可聊天") + @DisplayName("常量口径 INV-002: 消息类型/Match 状态取值固定(行为验证见 ChatPhase09IntegrationTest)") void testChatRequiresMutual() { assertEquals("text", Constants.MESSAGE_TYPE_TEXT); assertEquals("active", Constants.MATCH_STATUS_ACTIVE); @@ -25,7 +39,7 @@ class CoreInvariantsTest { } @Test - @DisplayName("INV-003: Block优先级最高") + @DisplayName("常量口径 INV-003: Block 状态取值固定(行为验证见 RelationshipBlockPriorityPhase08Test)") void testBlockOverridesAll() { assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED); assertEquals("liked", Constants.RELATION_TYPE_LIKED); @@ -62,7 +76,7 @@ class CoreInvariantsTest { } @Test - @DisplayName("INV-007: 红娘不能替用户Like") + @DisplayName("常量口径 INV-007: 红娘服务状态取值固定(行为验证见 MatchmakerReferralPhase13Test)") void testMatchmakerCannotLike() { assertEquals("submitted", Constants.MATCHMAKER_STATUS_SUBMITTED); assertEquals("assigned", Constants.MATCHMAKER_STATUS_ASSIGNED); @@ -87,9 +101,12 @@ class CoreInvariantsTest { } @Test - @DisplayName("INV-010: 跨Organization不能越权") + @DisplayName("常量口径 INV-010: Organization 相关常量存在(行为验证待 Phase 19)") void testCrossOrgNoAccess() { - assertTrue(true); + // Phase 13 复验:原为 `assertTrue(true)` 占位,已改为有意义的常量断言。 + // 真正的跨 Organization 越权行为验证属 Phase 19(综合运营后台),尚未实现。 + assertNotNull(Constants.ACTIVITY_STATUS_DRAFT, "活动状态常量必须存在"); + assertNotNull(Constants.REPORT_STATUS_PENDING, "举报状态常量必须存在"); } @Test diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java new file mode 100644 index 0000000..aa33762 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java @@ -0,0 +1,380 @@ +package com.danyang.match.platform.matchmaker; + +import com.danyang.match.platform.activity.entity.ActivityRegistration; +import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; +import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation; +import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.matchmaker.service.MatchmakerService; +import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; +import com.danyang.match.platform.referral.service.ReferralService; +import com.danyang.match.platform.service_appointment.entity.ServiceAppointment; +import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 13 复验:公益红娘转介 —— 真实运行时路径测试。 + * + *

背景:Phase 13 历史文档声称「INV-005 红娘不可代用户操作验证 ✅」,但 + * 全仓库唯一相关测试是 {@code MatchmakerServiceTest#testMatchmakerCannotLike} 的 + * {@code assertTrue(true)} 占位,以及 {@code CoreInvariantsTest#testMatchmakerCannotLike} + * (断言的是 MATCHMAKER_STATUS_* 状态常量字符串,与 Like 无关)。且测试库 + * 此前**完全缺失** matchmaker_* 三表,导致该模块零真实行为覆盖。 + * + *

本测试走真实服务方法 + 真实落库,覆盖计划三条: + *

    + *
  1. 保留红娘服务和人工推荐;验证任何工作人员不能替用户 Like。
  2. + *
  3. 补齐转介咨询/活动/服务站的用户同意、来源追踪和状态记录。
  4. + *
  5. 红娘只能看转介状态和必要摘要,不能读取 counseling followup 正文。
  6. + *
+ * + *

Gate:转介形成可追踪记录但不越权;用户决定权测试通过。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MatchmakerReferralPhase13Test { + + @Autowired private UserMapper userMapper; + @Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper; + @Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper; + @Autowired private MatchmakerServiceMapper matchmakerServiceMapper; + @Autowired private MatchmakerRecommendationMapper recommendationMapper; + @Autowired private MatchmakerService matchmakerService; + @Autowired private MatchmakerServiceService matchmakerServiceService; + @Autowired private ReferralService referralService; + @Autowired private ActivityRegistrationMapper activityRegistrationMapper; + @Autowired private ServiceAppointmentMapper appointmentMapper; + + private static final String MATCHMAKER_OWNER = "op-mm-owner"; + private static final String MATCHMAKER_OTHER = "op-mm-other"; + + private String user; + private String target; + private String serviceId; + + @BeforeEach + void setup() { + user = createUser("mm-user"); + target = createUser("mm-target"); + // 一条归属 MATCHMAKER_OWNER 的红娘服务 + serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED); + } + + /** + * 创建一个「基础资格满足」的用户:正常账号 + 已实名 + 资料已通过。 + * 否则 RelationService.like() 会在 capabilityService.isBaseEligible 处拒绝, + * 导致"用户接受推荐 → 产生 liked"这条真实路径走不通。 + */ + private String createUser(String tag) { + User u = new User(); + u.setWechatOpenid("p13_" + tag + "_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING); + u.setRecommendationEnabled(true); + userMapper.insert(u); + + var profile = new com.danyang.match.platform.profile.entity.UserProfile(); + profile.setUserId(u.getId()); + profile.setDisplayName(tag); + profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED); + userProfileMapper.insert(profile); + + var identity = new com.danyang.match.platform.verification.entity.UserIdentity(); + identity.setUserId(u.getId()); + identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED); + userIdentityMapper.insert(identity); + + return u.getId(); + } + + private String createService(String userId, String operatorId, String status) { + MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); + svc.setId(UUID.randomUUID().toString()); + svc.setUserId(userId); + svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); + svc.setStatus(status); + svc.setAssignedOperatorId(operatorId); + matchmakerServiceMapper.insert(svc); + return svc.getId(); + } + + private MatchmakerRecommendation createRecommendation(String svcId, String targetUserId, + String operatorId) { + MatchmakerRecommendation rec = new MatchmakerRecommendation(); + rec.setId(UUID.randomUUID().toString()); + rec.setServiceId(svcId); + rec.setUserId(user); + rec.setTargetUserId(targetUserId); + rec.setOperatorId(operatorId); + rec.setReason("测试推荐"); + rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW); + recommendationMapper.insert(rec); + return rec; + } + + // ==================== 计划项 1:工作人员不能替用户 Like ==================== + + @Test + @DisplayName("计划项1/INV-005: 红娘创建推荐只产生 pending_view,不产生任何 Like/Match") + void matchmakerRecommendationDoesNotCreateLike() { + MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER); + + assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, rec.getStatus(), + "红娘推荐初始状态必须是 pending_view —— 红娘不替用户做出 Like 决定"); + // 推荐 ≠ 关系:checkAndCompleteService 只认 liked/passed/confirmed_mutual + assertNotEquals(Constants.RECOMMENDATION_STATUS_LIKED, rec.getStatus()); + } + + @Test + @DisplayName("计划项1: 只有用户本人调用 acceptRecommendation 才产生 liked(走真实 Like 路径)") + void onlyUserCanLikeViaRecommendation() { + MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER); + + // 用户本人接受 → 内部调用 relationService.like(user, target) + var result = matchmakerService.acceptRecommendation(rec.getId(), user); + + MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId()); + assertEquals(Constants.RECOMMENDATION_STATUS_LIKED, after.getStatus(), + "用户接受后推荐状态应为 liked(D1:该值必须在 chk_rec_status 白名单内)"); + assertNotNull(after.getRespondedAt(), "接受后必须记录回应时间"); + assertFalse(result.mutual(), "对方尚未 Like,不应形成 Mutual"); + } + + @Test + @DisplayName("计划项1: 红娘无法用别人的用户身份接受推荐(findByIdAndUser 归属校验)") + void matchmakerCannotAcceptOnBehalfOfUser() { + MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER); + + // 红娘(不同 id)尝试以"用户"身份接受 → 归属不匹配,必须抛异常 + assertThrows(IllegalArgumentException.class, + () -> matchmakerService.acceptRecommendation(rec.getId(), MATCHMAKER_OWNER), + "非推荐所属用户不得接受该推荐"); + + MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId()); + assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(), + "越权接受失败后状态必须保持 pending_view"); + } + + @Test + @DisplayName("计划项1: 用户跳过推荐 → passed(D1:该值必须在 chk_rec_status 白名单内)") + void userPassViaRecommendation() { + MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER); + + matchmakerService.passRecommendation(rec.getId(), user); + + MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId()); + assertEquals(Constants.RECOMMENDATION_STATUS_PASSED, after.getStatus()); + } + + // ==================== 计划项 2:转介同意 / 来源追踪 / 状态记录 ==================== + + @Test + @DisplayName("计划项2: 红娘转介活动 —— 落库带 source/requestSource/createdByOperatorId,且需用户确认") + void referToActivityCreatesTrackableRecord() { + Map result = referralService.referToActivity( + MATCHMAKER_OWNER, user, "act-p13-001", "紧急联系人", "enc-phone"); + + assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"), + "转介必须标记为待用户确认 —— 红娘不替用户完成报名"); + + String regId = (String) result.get("registrationId"); + ActivityRegistration reg = activityRegistrationMapper.selectById(regId); + assertNotNull(reg, "转介必须真实落库"); + assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, reg.getSource(), + "来源追踪:source 必须是 matchmaker_referral"); + assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, reg.getRequestSource(), + "来源追踪:requestSource 必须是 matchmaker_referral"); + assertEquals(MATCHMAKER_OWNER, reg.getCreatedByOperatorId(), + "来源追踪:必须记录发起转介的红娘"); + assertNull(reg.getUserConfirmedAt(), "用户尚未确认时 userConfirmedAt 必须为空"); + } + + @Test + @DisplayName("计划项2: 转介服务站 —— 落库带来源追踪,状态为 submitted") + void referToServiceStationCreatesTrackableRecord() { + Map result = referralService.referToServiceStation( + MATCHMAKER_OWNER, user, "loc-p13-001", "counselor_consultation", null, "备注"); + + assertEquals(Boolean.TRUE, result.get("needsUserConfirmation")); + + String apptId = (String) result.get("appointmentId"); + ServiceAppointment appt = appointmentMapper.selectById(apptId); + assertNotNull(appt); + assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, appt.getSource()); + assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, appt.getRequestSource()); + assertEquals(MATCHMAKER_OWNER, appt.getCreatedByOperatorId()); + assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED, appt.getStatus()); + assertNull(appt.getUserConfirmedAt()); + } + + @Test + @DisplayName("计划项2: 用户确认转介活动 → userConfirmedAt 落库,确认动作可追踪") + void userConfirmsActivityReferral() { + Map created = referralService.referToActivity( + MATCHMAKER_OWNER, user, "act-p13-002", "联系人", "enc"); + String regId = (String) created.get("registrationId"); + + Map confirmed = referralService.confirmReferral(user, regId, "activity"); + + assertNotNull(confirmed.get("confirmedAt"), "确认后必须返回 confirmedAt"); + ActivityRegistration reg = activityRegistrationMapper.selectById(regId); + assertNotNull(reg.getUserConfirmedAt(), + "D5:用户确认后 userConfirmedAt 必须落库(确认动作必须在状态上可追踪)"); + } + + @Test + @DisplayName("计划项2: 用户确认转介服务站 → userConfirmedAt 落库且状态进入 pending_confirmation") + void userConfirmsServiceStationReferral() { + Map created = referralService.referToServiceStation( + MATCHMAKER_OWNER, user, "loc-p13-002", "counselor_consultation", null, null); + String apptId = (String) created.get("appointmentId"); + + referralService.confirmReferral(user, apptId, "service_station"); + + ServiceAppointment appt = appointmentMapper.selectById(apptId); + assertNotNull(appt.getUserConfirmedAt()); + assertEquals(Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION, appt.getStatus(), + "服务站转介确认后应进入 pending_confirmation"); + } + + @Test + @DisplayName("计划项2: 他人不能确认不属于自己的转介(用户决定权)") + void otherUserCannotConfirmReferral() { + Map created = referralService.referToActivity( + MATCHMAKER_OWNER, user, "act-p13-003", "联系人", "enc"); + String regId = (String) created.get("registrationId"); + + assertThrows(RuntimeException.class, + () -> referralService.confirmReferral(target, regId, "activity"), + "非目标用户不得确认该转介"); + + assertNull(activityRegistrationMapper.selectById(regId).getUserConfirmedAt(), + "越权确认失败后不得写入 userConfirmedAt"); + } + + @Test + @DisplayName("计划项2: 未自报来源的预约不能被当作转介确认(source 守卫)") + void nonReferralCannotBeConfirmedAsReferral() { + ServiceAppointment appt = new ServiceAppointment(); + appt.setId(UUID.randomUUID().toString()); + appt.setUserId(user); + appt.setServiceType("counselor_consultation"); + appt.setStatus(Constants.APPOINTMENT_STATUS_SUBMITTED); + appt.setSource(Constants.REFERRAL_SOURCE_USER_SELF); // 用户自主预约,非转介 + appointmentMapper.insert(appt); + + assertThrows(RuntimeException.class, + () -> referralService.confirmReferral(user, appt.getId(), "service_station"), + "非转介来源的预约不得走转介确认路径"); + } + + // ==================== 越权防护(复验新增 D3/D4/D5 守卫) ==================== + + @Test + @DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)") + void otherMatchmakerCannotReferUser() { + // MATCHMAKER_OTHER 与该用户之间没有任何服务关系 + assertThrows(IllegalStateException.class, + () -> referralService.referToActivity( + MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"), + "非该用户服务负责人的红娘不得发起转介"); + + assertThrows(IllegalStateException.class, + () -> referralService.referToServiceStation( + MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null), + "非该用户服务负责人的红娘不得发起服务站转介"); + } + + @Test + @DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)") + void otherMatchmakerCannotRecommend() { + assertThrows(IllegalStateException.class, + () -> matchmakerServiceService.recommendPartner( + serviceId, MATCHMAKER_OTHER, target, "越权推荐"), + "非服务负责人不得添加推荐"); + + assertTrue(recommendationMapper.findByService(serviceId).isEmpty(), + "越权推荐失败后不得留下任何推荐记录"); + } + + @Test + @DisplayName("D3: 服务负责人可以正常添加推荐") + void ownerMatchmakerCanRecommend() { + Map result = matchmakerServiceService.recommendPartner( + serviceId, MATCHMAKER_OWNER, target, "合适人选"); + + assertNotNull(result.get("recommendationId")); + List recs = recommendationMapper.findByService(serviceId); + assertEquals(1, recs.size(), "负责人添加的推荐应落库"); + assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, recs.get(0).getStatus(), + "红娘推荐必须是 pending_view(不替用户 Like)"); + } + + @Test + @DisplayName("D3: 不能把服务申请人推荐给自己") + void cannotRecommendUserToSelf() { + assertThrows(IllegalArgumentException.class, + () -> matchmakerServiceService.recommendPartner( + serviceId, MATCHMAKER_OWNER, user, "推荐给自己"), + "不得把服务申请人推荐给自己"); + } + + // ==================== 计划项 3:红娘只读摘要,不读敏感正文 ==================== + + @Test + @DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)") + void nonOwnerSeesSanitizedDetail() { + Map detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER); + + assertNotNull(detail); + assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"), + "非负责人必须被标记为非 owner"); + assertFalse(detail.containsKey("userProfile"), + "INV-013 同类口径:非负责人不得读取服务对象的用户资料"); + assertFalse(detail.containsKey("followups"), + "非负责人不得读取跟进正文"); + assertFalse(detail.containsKey("userId"), + "非负责人不需要知道申请人 userId"); + assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(), + "非负责人只能拿到跟进条数摘要"); + } + + @Test + @DisplayName("计划项3/D4: 服务负责人读详情可获得完整视图(含 userProfile)") + void ownerSeesFullDetail() { + Map detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER); + + assertNotNull(detail); + assertEquals(Boolean.TRUE, detail.get("viewerIsOwner")); + assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId"); + assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录"); + } + + @Test + @DisplayName("计划项3: 红娘服务详情不含 counseling followup 正文(不得跨域泄露)") + void matchmakerDetailNeverExposesCounselingFollowupContent() { + Map detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER); + + // matchmaker 侧的 followups 是 matchmaker_followups(红娘自己的跟进), + // 绝不包含 counseling_followups 的敏感正文。 + assertFalse(detail.containsKey("counselingFollowups"), + "红娘详情不得携带 counseling followup 集合"); + assertFalse(detail.containsKey("counselingFollowupContent"), + "红娘详情不得携带 counseling followup 正文"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerServiceTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerServiceTest.java index a9f350b..7ecc5c0 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerServiceTest.java @@ -1,36 +1,188 @@ package com.danyang.match.platform.matchmaker; import com.danyang.match.platform.shared.constant.Constants; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + import static org.junit.jupiter.api.Assertions.*; +/** + * 红娘服务「常量 ↔ 生产 DDL 白名单」口径守卫。 + * + *

Phase 13 复验说明:本类此前含 {@code testMatchmakerCannotLike()} 占位断言 + * ({@code assertTrue(true); // 占位测试}),对 INV-005 零覆盖 —— AGENTS.md 明令禁止。 + * 已删除该占位。INV-005 的真实行为验证见 + * {@link MatchmakerReferralPhase13Test}(走真实服务方法 + 真实落库)。 + * + *

为什么必须解析真实 DDL,而不是抄一份白名单副本? + * 第一轮负向验证暴露了这个洞:把 {@code chk_rec_status} 回退到 V009 的窄白名单后, + * 「抄副本」版本的守卫**依然全绿** —— 因为它比对的是一份与 DDL 已经脱钩的常量字符串, + * 根本没有对漂移做出任何反应。守卫必须钉在**离真实定义最近的那一层**:直接读迁移 SQL。 + */ class MatchmakerServiceTest { + private static final Path MIGRATION_DIR = + Paths.get("backend/src/main/resources/db/migration"); + + /** + * 从迁移目录中解析指定 CHECK 约束的**最终**白名单值集合。 + * + * 遵循项目既有惯例(V018/V020/V025/V038): + * 扩展白名单的方式是 `DROP CONSTRAINT IF EXISTS x` 再 `ADD CONSTRAINT x CHECK (...)`, + * 因此必须按**文件名顺序**扫描,并让后出现的定义覆盖先前的 —— 取"最终有效"的定义。 + */ + private static Set resolveCheckValues(String constraintName) { + Pattern drop = Pattern.compile( + "DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + constraintName + "\\b", + Pattern.CASE_INSENSITIVE); + Pattern add = Pattern.compile( + "ADD\\s+CONSTRAINT\\s+" + constraintName + "\\s+CHECK\\s*\\((.*?)\\)\\s*;", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + Pattern literal = Pattern.compile("'([^']+)'"); + + Set effective = null; + try (Stream files = Files.list(MIGRATION_DIR)) { + var ordered = files + .filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")) + .sorted((a, b) -> { + int va = Integer.parseInt(a.getFileName().toString() + .substring(1, a.getFileName().toString().indexOf("__"))); + int vb = Integer.parseInt(b.getFileName().toString() + .substring(1, b.getFileName().toString().indexOf("__"))); + return Integer.compare(va, vb); + }) + .toList(); + + for (Path f : ordered) { + String sql = Files.readString(f, StandardCharsets.UTF_8); + if (drop.matcher(sql).find()) { + effective = null; // 旧定义作废 + } + Matcher m = add.matcher(sql); + while (m.find()) { + Set vals = new LinkedHashSet<>(); + Matcher lm = literal.matcher(m.group(1)); + while (lm.find()) { + vals.add(lm.group(1)); + } + if (!vals.isEmpty()) { + effective = vals; + } + } + } + } catch (IOException e) { + fail("读取迁移目录失败: " + e.getMessage()); + } + assertNotNull(effective, + "未能在迁移中找到 CHECK 约束 " + constraintName + " 的定义"); + return effective; + } + + // ---------- 解析器自检:防止守卫因解析失效而「永远为真」 ---------- + @Test - void testServiceTypeConstants() { - assertEquals("recommend_partner", Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); - assertEquals("review_preference", Constants.MATCHMAKER_SERVICE_TYPE_REVIEW_PREFERENCE); - assertEquals("contact_intention", Constants.MATCHMAKER_SERVICE_TYPE_CONTACT_INTENTION); - assertEquals("join_activity", Constants.MATCHMAKER_SERVICE_TYPE_JOIN_ACTIVITY); - assertEquals("no_match_help", Constants.MATCHMAKER_SERVICE_TYPE_NO_MATCH_HELP); - assertEquals("chat_help", Constants.MATCHMAKER_SERVICE_TYPE_CHAT_HELP); - assertEquals("other", Constants.MATCHMAKER_SERVICE_TYPE_OTHER); + @DisplayName("守卫自检:DDL 解析器必须真的能读到 CHECK 白名单(防止守卫失效)") + void parserMustActuallyResolveConstraint() { + Set recStatus = resolveCheckValues("chk_rec_status"); + assertFalse(recStatus.isEmpty(), "解析器必须能从迁移中解析出 chk_rec_status 的值"); + // 若解析不到,下面会拿到 null/空,守卫就会变成"永真",失去意义。 + assertTrue(recStatus.contains("pending_view"), + "解析结果必须包含原始值 pending_view;实际=" + recStatus); + } + + // ---------- 真正的口径守卫 ---------- + + @Test + @DisplayName("D1 守卫:代码写入的推荐状态必须落在 chk_rec_status 最终白名单内") + void recommendationStatusConstantsAreWithinProductionWhitelist() { + Set whitelist = resolveCheckValues("chk_rec_status"); + + // MatchmakerService 在用户接受/跳过推荐时写入的值(liked / passed / confirmed_mutual) + assertAll( + () -> assertTrue(whitelist.contains(Constants.RECOMMENDATION_STATUS_PENDING_VIEW), + "pending_view 不在 chk_rec_status 白名单: " + whitelist), + () -> assertTrue(whitelist.contains(Constants.RECOMMENDATION_STATUS_LIKED), + "liked 不在 chk_rec_status 白名单(真实 PG 上用户接受推荐必崩): " + whitelist), + () -> assertTrue(whitelist.contains(Constants.RECOMMENDATION_STATUS_PASSED), + "passed 不在 chk_rec_status 白名单(真实 PG 上用户跳过推荐必崩): " + whitelist), + () -> assertTrue(whitelist.contains("confirmed_mutual"), + "confirmed_mutual 不在 chk_rec_status 白名单(双向成功必崩): " + whitelist) + ); } @Test - void testServiceStatusConstants() { - assertEquals("submitted", Constants.MATCHMAKER_STATUS_SUBMITTED); - assertEquals("assigned", Constants.MATCHMAKER_STATUS_ASSIGNED); - assertEquals("in_service", Constants.MATCHMAKER_STATUS_IN_SERVICE); - assertEquals("waiting_user", Constants.MATCHMAKER_STATUS_WAITING_USER); - assertEquals("completed", Constants.MATCHMAKER_STATUS_COMPLETED); - assertEquals("cancelled", Constants.MATCHMAKER_STATUS_CANCELLED); + @DisplayName("D2 守卫:Controller 缺省兜底的 recommendation 必须在 chk_matchmaker_service_type 白名单内") + void controllerFallbackServiceTypeIsAllowed() { + Set whitelist = resolveCheckValues("chk_matchmaker_service_type"); + + // MatchmakerController.submit:请求未带 serviceType 时兜底写 "recommendation" + assertTrue(whitelist.contains("recommendation"), + "recommendation 不在 chk_matchmaker_service_type 白名单(真实 PG 上提交红娘服务必崩): " + + whitelist); + // MatchmakerService 注释声明的三个转介子类型 + assertAll( + () -> assertTrue(whitelist.contains("referral_counseling"), + "referral_counseling 不在白名单: " + whitelist), + () -> assertTrue(whitelist.contains("referral_activity"), + "referral_activity 不在白名单: " + whitelist), + () -> assertTrue(whitelist.contains("referral_station"), + "referral_station 不在白名单: " + whitelist) + ); } @Test - void testMatchmakerCannotLike() { - // Core Invariant: 红娘不能替用户Like - assertTrue(true); // 占位测试,逻辑由控制器权限保证 + @DisplayName("service_type 常量必须落在 chk_matchmaker_service_type 白名单内") + void serviceTypeConstantsAreWithinProductionWhitelist() { + Set whitelist = resolveCheckValues("chk_matchmaker_service_type"); + + assertAll( + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_REVIEW_PREFERENCE), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_CONTACT_INTENTION), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_JOIN_ACTIVITY), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_NO_MATCH_HELP), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_CHAT_HELP), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_OTHER), whitelist.toString()) + ); + } + + @Test + @DisplayName("service 状态常量必须落在 chk_matchmaker_status 白名单内") + void serviceStatusConstantsAreWithinProductionWhitelist() { + Set whitelist = resolveCheckValues("chk_matchmaker_status"); + + assertAll( + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_SUBMITTED), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_ASSIGNED), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_IN_SERVICE), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_WAITING_USER), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_COMPLETED), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_CANCELLED), whitelist.toString()) + ); + } + + @Test + @DisplayName("转介来源常量必须落在 chk_source_type 白名单内") + void referralSourceConstantsAreWithinProductionWhitelist() { + Set whitelist = resolveCheckValues("chk_source_type"); + + assertAll( + () -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_USER_SELF), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_ACTIVITY_FOLLOWUP), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_SERVICE_STATION), whitelist.toString()), + () -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString()) + ); } } diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 9106259..3ccd2d2 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -200,3 +200,56 @@ CREATE TABLE activities ( created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE ); +-- ============================================================================ +-- Phase 13(V009 + V021 + V037 + V038):红娘服务三表 +-- 此前测试库完全缺失,导致 matchmaker 模块零真实行为覆盖、只能写假测试。 +-- 约束与生产 V038 对齐(status 白名单已含 liked/passed/confirmed_mutual)。 +-- ============================================================================ + +-- V009: matchmaker_services 红娘服务表 +CREATE TABLE matchmaker_services ( + id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, service_type VARCHAR(32) NOT NULL, + description VARCHAR(1000), status VARCHAR(32) NOT NULL DEFAULT 'submitted', + organization_id VARCHAR(36), assigned_operator_id VARCHAR(36), + assigned_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE, + referral_target_type VARCHAR(32), referral_target_id VARCHAR(36), + referral_source_type VARCHAR(32) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN ( + 'recommend_partner', 'review_preference', 'contact_intention', + 'join_activity', 'no_match_help', 'chat_help', 'other', + 'recommendation', + 'referral_counseling', 'referral_activity', 'referral_station' + )), + CONSTRAINT chk_matchmaker_status CHECK (status IN ( + 'submitted', 'assigned', 'in_service', 'waiting_user', 'completed', 'cancelled' + )) +); + +-- V009: matchmaker_recommendations 红娘推荐表 +CREATE TABLE matchmaker_recommendations ( + id VARCHAR(36) PRIMARY KEY, service_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL, + target_user_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL, reason VARCHAR(1000), + status VARCHAR(32) NOT NULL DEFAULT 'pending_view', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + viewed_at TIMESTAMP WITH TIME ZONE, responded_at TIMESTAMP WITH TIME ZONE, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_rec_status CHECK (status IN ( + 'pending_view', 'viewed', 'responded', + 'liked', 'passed', 'confirmed_mutual' + )) +); + +-- V009: matchmaker_followups 红娘跟进表 +CREATE TABLE matchmaker_followups ( + id VARCHAR(36) PRIMARY KEY, service_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL, + followup_type VARCHAR(32), content VARCHAR(2000) NOT NULL, result_code VARCHAR(64), + next_followup_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE +); + diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 92719a5..59f0bff 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -5,6 +5,24 @@ > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > +> **2026-09-16 Phase 13 复验关闭:** EPIC-P13-V11 已完成。复验中发现并修复 7 处缺陷—— +> **D1(Critical,生产故障)** `matchmaker_recommendations.status` 代码写入 `liked`/`passed`/`confirmed_mutual`, +> 而生产 `V009 chk_rec_status` 只允许 `pending_view/viewed/responded` → **真实 PG 上用户接受/跳过推荐必崩**; +> **D2(Critical,生产故障)** 控制器缺省 `serviceType="recommendation"` 不在 `chk_matchmaker_service_type` 白名单 +> → **提交红娘服务必崩**。两者同修于新增 `V038__align_matchmaker_status_constraints.sql`。 +> **D3(High,越权写)** `recommendPartner` 无归属校验 → 任意红娘可替非本人负责的服务发起推荐、可推荐给自己。 +> **D4(High,越权读)** `getServiceDetail` 无归属校验且返回完整用户资料 → 任意红娘可读任意用户敏感详情。 +> **D5(High,越权写)** `ReferralService` 的 `userId` 取自请求体且**零授权校验** → 可替任意用户发起转介。 +> **D6(High,生产故障)** `MatchmakerFollowupMapper.findByServiceId` 无 `@Select` → `Invalid bound statement`, +> `getServiceDetail` 必崩(**本轮新增测试首次暴露**)。**D7(Medium)** admin-web 5 处 API 契约缺口(404 / C 端端点误用) +> + 小程序缺转介服务层 → 后端补 2 个运营端端点、前端改路径、小程序新增 `referralService.js`。 +> 测试库补 3 张 matchmaker 表(`EntityMigrationParityTest` 移入必需集),迁移基线 37 → 38。 +> **关键教训**:首版常量守卫**硬编码白名单副本**,注入 D1 缺陷后**仍全绿** → 重写为**直接解析 `db/migration/V*.sql`** +> (按文件名顺序取"最终有效"白名单 + 解析器自检);修正后经**第二轮负向验证**证明会红 +> (`Tests run: 23, Failures: 1, Errors: 2 / BUILD FAILURE`)。删除 3 处假测试占位。 +> 证据:`docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 13A 需用户确认后启动。** +> 后端全量 **391 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(4 分 59 秒)。 +> > **2026-09-16 Phase 12 复验关闭:** EPIC-P12-V11 已完成。复验中发现并修复 4 处缺陷—— > **D1(Critical,生产故障 · 同一族系统性缺陷)** 实体与生产迁移**系统性漂移**:23 张表、50 个列在 `db/migration` 中 > 根本不存在,而测试库 `schema-h2.sql` 手写了它们 → 全量测试绿 = **假绿**。其中 11 张表属"INSERT 必崩"级 @@ -19,7 +37,7 @@ > 新增永久守卫 `EntityMigrationParityTest`(5 用例,区分"必崩级/潜伏级",经**两轮负向验证**证明会变红); > **删除 2 个假测试类**(`RelationshipStatusServiceTest`/`RelationshipStatusServiceDoubleConfirmTest` 的 `assertTrue(true)` 占位)。 > 后端全量 **371 tests / 0 failures / 0 errors**。 -> 证据:`docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 13 需用户确认后启动。** +> 证据:`docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`。 > > **2026-09-16 Phase 11 复验关闭:** EPIC-P11-V11 已完成。复验中发现并修复 3 处缺陷—— > **D3(Critical,生产故障)** `NotificationService` 全部 10 种通知使用复合 type(`appointment_submitted` / @@ -60,7 +78,8 @@ | Phase 09 Text Chat 复验 | 已完成(2026-09-16) | 见 `Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处真实缺陷(WebSocket 下行死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天接口 IDOR),后端 328 tests 全绿;Phase 10 尚未获准 | | Phase 10 Image & Chat Safety 复验 | 已完成(2026-09-16) | 见 `Phase-10-V11-Recovery-Completion-Report-2026-09-16.md`;修复 2 处缺陷(图片上传魔数校验死代码致本地资格佐证上传 100% 失败、高风险词写入日志),推翻旧报告 2 项安全声明,后端 334 tests 全绿;Phase 11 尚未获准 | | Phase 11 Notification 复验 | 已完成(2026-09-16) | 见 `Phase-11-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处缺陷(10 种通知 type 违反生产 CHECK 约束致全部落库失败、幂等无 DB 兜底、婚前辅导通知未接线),后端 346 tests 全绿 | -| Phase 12 Relationship Progress 复验 | 已完成(2026-09-16) | 见 `Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处缺陷(**实体↔生产迁移系统性漂移 23 表 50 列**、双路径 episode/NPE、测试库缺 7 表致首页零覆盖、计划项 2 未实现),新增永久守卫 `EntityMigrationParityTest`,删除 2 个假测试类,后端 371 tests 全绿;Phase 13 尚未获准 | +| Phase 12 Relationship Progress 复验 | 已完成(2026-09-16) | 见 `Phase-12-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处缺陷(**实体↔生产迁移系统性漂移 23 表 50 列**、双路径 episode/NPE、测试库缺 7 表致首页零覆盖、计划项 2 未实现),新增永久守卫 `EntityMigrationParityTest`,删除 2 个假测试类,后端 371 tests 全绿 | +| Phase 13 公益红娘转介复验 | 已完成(2026-09-16) | 见 `Phase-13-V11-Recovery-Completion-Report-2026-09-16.md`;修复 7 处缺陷(**2 处生产故障级 CHECK 约束缺口致推荐接受/服务提交必崩**、推荐与转介三处越权、`getServiceDetail` 越权读、Mapper 缺 `@Select` 致必崩、前端 5 处契约缺口),测试库补 3 张 matchmaker 表,迁移基线 V038;新增 `MatchmakerReferralPhase13Test`(17 用例)+ 重写 `MatchmakerServiceTest`(6 用例,解析真实 DDL),删除 3 处假测试,后端 **391 tests 全绿**;**Phase 13A 尚未获准** | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | @@ -94,7 +113,7 @@ | EPIC-P10-V11 | P10 | 聊天安全扩展 | P0 | ✅ | EPIC-P11-V11 | P11 | 公益服务通知扩展 | P0 | ✅ | EPIC-P12-V11 | P12 | 关系状态轻量扩展 | P1 | ✅ -| EPIC-P13-V11 | P13 | 红娘服务转介扩展 | P1 | +| EPIC-P13-V11 | P13 | 红娘服务转介扩展 | P1 | ✅ | EPIC-P13A | P13A | 公益婚恋咨询与婚前辅导 | P1 | | EPIC-P14-V11 | P14 | 多类型公益活动 | P1 | | EPIC-P15-V11 | P15 | 活动签到回归测试 | P1 | diff --git a/docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md b/docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md new file mode 100644 index 0000000..00c3aac --- /dev/null +++ b/docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md @@ -0,0 +1,269 @@ +# Phase 13 V1.1 Recovery Completion Report — 公益红娘转介 + +> 日期:2026-09-16 +> 阶段:Phase 13(Matchmaker Referral — KEEP-VERIFY/EXTEND) +> 分支:`main` | Tag:`phase13-recovery-2026-09-16` +> 结论:**CLOSED(Gate 全项通过)** + +--- + +## 一、计划目标与 Gate + +计划出处:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 第 256-264 行。 + +| 计划项 | 要求 | 结论 | +|---|---|---| +| 计划项 1 | 保留红娘服务和人工推荐;验证任何工作人员不能替用户 Like | ✅ 通过(替换假测试,6 用例行为验证) | +| 计划项 2 | 补齐转介咨询、活动、服务站的用户同意、来源追踪和状态记录 | ✅ 通过(6 用例,真实落库校验) | +| 计划项 3 | 红娘只能看转介状态和必要摘要,不能读取 counseling followup 正文 | ✅ 通过(3 用例 + 越权防护) | + +**Gate:** 转介形成可追踪记录但不越权;用户决定权测试通过。 + +| Gate 断言 | 证据 | 结果 | +|---|---|---| +| 转介形成可追踪记录 | `referToActivityCreatesTrackableRecord` / `referToServiceStationCreatesTrackableRecord`:`source=matchmaker_referral`、`requestSource` 同名、`createdByOperatorId` 落库 | ✅ | +| 转介不越权(用户决定权) | `otherMatchmakerCannotReferUser` / `otherUserCannotConfirmReferral` / `nonReferralCannotBeConfirmedAsReferral` | ✅ | +| 红娘不可替用户 Like | `matchmakerRecommendationDoesNotCreateLike` / `matchmakerCannotAcceptOnBehalfOfUser` / `onlyUserCanLikeViaRecommendation` | ✅ | +| 只读摘要不读正文 | `nonOwnerSeesSanitizedDetail` / `matchmakerDetailNeverExposesCounselingFollowupContent` | ✅ | + +--- + +## 二、复验发现:历史证据全为假测试 + +Phase 13 历史文档声称「**INV-005 红娘不可代用户操作验证 ✅**」。复验发现该结论**没有任何真实证据支撑**: + +| 声称的验证 | 实际情况 | +|---|---| +| `MatchmakerServiceTest#testMatchmakerCannotLike` | `assertTrue(true); // 占位测试` —— 零覆盖 | +| `CoreInvariantsTest#testMatchmakerCannotLike` | 断言 `MATCHMAKER_STATUS_SUBMITTED/ASSIGNED/IN_SERVICE/COMPLETED` 四个**状态常量字符串**,与「红娘不能替用户 Like」**毫无关系**(名不副实的假测试) | +| 测试库覆盖 | `schema-h2.sql` **完全缺失** `matchmaker_services` / `matchmaker_recommendations` / `matchmaker_followups` 三表 → 该模块**零真实行为覆盖** | + +另发现同类:`CoreInvariantsTest#testCrossOrgNoAccess`(INV-010)也是 `assertTrue(true)`。 + +--- + +## 三、缺陷清单(D1~D7) + +### D1 — Critical(生产故障):推荐状态 CHECK 约束与代码写入值漂移 + +- **DDL**:`V009__add_matchmaker_tables.sql:55-57` 的 `chk_rec_status` 只允许 + `pending_view` / `viewed` / `responded`,且**全迁移目录无任何 ALTER/DROP 扩展它**。 +- **代码**:`MatchmakerService.java:225/250` 写入 `liked` / `passed` / `confirmed_mutual`。 +- **后果**:真实 PostgreSQL 上用户点击「接受/跳过」红娘推荐 → 违反 CHECK → **必然 500**。Phase 13 主流程不可用。 +- **修复**:`V038` 用 `DROP CONSTRAINT IF EXISTS` + 重加白名单(含 `liked`/`passed`/`confirmed_mutual`)。 + +### D2 — Critical(生产故障):service_type 缺省兜底值越界 + +- **DDL**:`V009:26-29` 的 `chk_matchmaker_service_type` 只允许 + `recommend_partner` / `review_preference` / `contact_intention` / `join_activity` / + `no_match_help` / `chat_help` / `other`。 +- **代码**:`MatchmakerController.java:47-48` 在请求未带 `serviceType` 时兜底写 `"recommendation"`; + `MatchmakerService.java:57-61` 注释声明 `referral_counseling` / `referral_activity` / `referral_station`。 +- **后果**:真实 PG 上「提交红娘服务」(缺省路径)与三种转介子类型**必然 500**。 +- **修复**:`V038` 扩展白名单纳入这 4 个值。 + +### D3 — High:`recommendPartner` 缺归属校验(越权推荐) + +`MatchmakerServiceService.recommendPartner` 只校验 `service != null`,**无** `assigned_operator_id` 校验 +→ 任何持 `matchmaker.service` 权限的 operator 可对**任意** serviceId 创建推荐。 +(对比 `MatchmakerService.createRecommendation:118-121` 有校验 —— 同项目双路径口径不一致。) + +**修复**:补归属校验 + 「不得推荐用户给自己」。 + +### D4 — High:`getServiceDetail` 无归属校验(越权读) + +任何持 `matchmaker.view` 的 operator 可读**任意**服务详情,含完整用户资料 +(displayName / gender / age / regionName / education / occupation / maritalStatus)。 + +**修复**:改为按调用者授权范围返回 —— 负责人得完整详情;非负责人得脱敏视图 +(无 `userProfile`、无 `userId`、无 followup 正文,仅 `followupCount` 摘要)。 + +### D5 — High:转介无授权校验(越权代操作) + +`ReferralService.referToActivity` / `referToServiceStation` 的 `userId` **直接取自请求体**, +`ReferralService` 甚至**未注入** `MatchmakerServiceMapper` —— 零授权校验。 +任何红娘可把**任意用户**转介到任意活动/服务站,并顺带为该用户创建 +`activity_registrations` / `service_appointments` 记录。 + +**修复**:新增 `assertMatchmakerServesUser`,断言存在进行中的 +`matchmaker_service(assigned_operator_id=红娘, user_id=目标用户)`,否则拒绝。 + +### D6 — High(生产故障):`MatchmakerFollowupMapper.findByServiceId` 缺 `@Select` + +```java +// 修复前 —— 只有声明,MyBatis 无法绑定 +List findByServiceId(String serviceId); +``` + +运行时抛 `Invalid bound statement (not found): +...MatchmakerFollowupMapper.findByServiceId`。调用方 +`MatchmakerServiceService.getServiceDetail:311` 因此**必然 500** —— +红娘工作台「查看服务详情」完全不可用。 + +此前不可见:测试库缺 `matchmaker_followups` 表,无任何测试能走到这条路径。 + +**修复**:补 `@Select` 注解(含 `deleted_at IS NULL` 过滤 + `@Param`)。 + +### D7 — Medium:Admin referral 前端契约缺口 + +| admin-web 调用 | 后端实际 | 结果 | +|---|---|---| +| `GET /referrals/my-history`(ReferralManagementView) | 该端点用 `UserPrincipal`(C 端) | 运营端无"我的用户身份" | +| `GET /matchmaker/services/operator/queue` | `GET /operator/matchmaker/pending` | 404 | +| `GET /matchmaker/recommendations/operator` | **不存在** | 404 | +| `PUT /matchmaker/services/{id}/assign` | `POST /operator/matchmaker/services/{id}/assign` | 404(方法+前缀均不符) | +| `POST /matchmaker/services/{id}/recommendation` | `POST /operator/matchmaker/services/{id}/recommend` | 404(路径+前缀不符) | + +**修复**:后端新增 `GET /referrals/operator/history`(按 `created_by_operator_id` 过滤) +与 `GET /operator/matchmaker/recommendations`(限本人名下服务);前端 5 处调用改为真实路径。 + +--- + +## 四、测试保真度 + +### 假测试清理 + +| 文件 | 问题 | 处理 | +|---|---|---| +| `MatchmakerServiceTest` | 含 `assertTrue(true); // 占位测试` | 重写为**直接解析迁移 SQL** 的口径守卫(6 用例) | +| `CoreInvariantsTest#testMatchmakerCannotLike` | DisplayName 称"红娘不能替用户 Like",实际断言 `MATCHMAKER_STATUS_*` 常量 | DisplayName 如实改为"常量口径 INV-007: 红娘服务状态取值固定",并指向真实验证位置 | +| `CoreInvariantsTest#testCrossOrgNoAccess` | `assertTrue(true)` | 改为有意义的常量断言 + 注明行为验证待 Phase 19 | +| `CoreInvariantsTest` 类头 | —— | 新增注释说明本类**只做常量口径守卫,不验证行为**,并列出各 INV 的真实验证位置 | + +### 新增真实行为测试 `MatchmakerReferralPhase13Test`(17 用例) + +走**真实服务方法 + 真实落库**(`@SpringBootTest` + `@Transactional`): + +| 分组 | 用例 | +|---|---| +| 计划项 1(红娘不代 Like) | `matchmakerRecommendationDoesNotCreateLike`、`onlyUserCanLikeViaRecommendation`、`matchmakerCannotAcceptOnBehalfOfUser`、`userPassViaRecommendation` | +| 计划项 2(同意/来源/状态) | `referToActivityCreatesTrackableRecord`、`referToServiceStationCreatesTrackableRecord`、`userConfirmsActivityReferral`、`userConfirmsServiceStationReferral`、`otherUserCannotConfirmReferral`、`nonReferralCannotBeConfirmedAsReferral` | +| 越权防护(D3/D5) | `otherMatchmakerCannotReferUser`、`otherMatchmakerCannotRecommend`、`ownerMatchmakerCanRecommend`、`cannotRecommendUserToSelf` | +| 计划项 3(只读摘要) | `nonOwnerSeesSanitizedDetail`、`ownerSeesFullDetail`、`matchmakerDetailNeverExposesCounselingFollowupContent` | + +> 注:`createUser` 会补齐 `user_profiles`(approved)+ `user_identities`(verified), +> 使 `capabilityService.isBaseEligible` 通过,从而让「用户接受推荐 → 产生 liked」这条 +> 真实路径可以走通(第一版测试因缺这两张表而假红)。 + +### 测试库补齐 `matchmaker_*` 三表 + +`schema-h2.sql` 新增三表,**约束与 V038 对齐**(含扩展后的 `chk_rec_status` / +`chk_matchmaker_service_type`)。同步从 `EntityMigrationParityTest.TEST_SCHEMA_OPTIONAL_TABLES` +白名单移除这三张表,并加入 `REQUIRED_IN_TEST_SCHEMA`——守卫从此**强制**它们必须存在。 + +### 两轮负向验证(守卫有效性证明) + +**注入缺陷**:把 `chk_rec_status` 回退到 V009 原白名单(DDL + 测试库同步收窄)。 + +**第一轮 —— 暴露守卫缺口:** + +| 守卫 | 结果 | +|---|---| +| `MatchmakerReferralPhase13Test.onlyUserCanLikeViaRecommendation` | ✅ `DataIntegrityViolation`(精准变红) | +| `MatchmakerReferralPhase13Test.userPassViaRecommendation` | ✅ `DataIntegrityViolation` | +| `MatchmakerServiceTest`(**抄副本版**常量守卫) | ❌ **仍然全绿** —— 它比对的是一份与 DDL 脱钩的常量副本,对漂移无反应 | + +**修正**:把常量守卫改为**直接解析 `db/migration/V*.sql`**,按文件名顺序处理 +`DROP CONSTRAINT IF EXISTS` + `ADD CONSTRAINT`,取"最终有效"白名单(解析器另有自检用例 +`parserMustActuallyResolveConstraint` 防止守卫因解析失效而永真)。 + +**第二轮 —— 守卫全部精准变红:** + +``` +[ERROR] MatchmakerServiceTest.recommendationStatusConstantsAreWithinProductionWhitelist:112 + Multiple Failures (3 failures) +[ERROR] MatchmakerReferralPhase13Test.onlyUserCanLikeViaRecommendation:149 » DataIntegrityViolation +[ERROR] MatchmakerReferralPhase13Test.userPassViaRecommendation:178 » DataIntegrityViolation +Tests run: 23, Failures: 1, Errors: 2 +``` + +还原缺陷后 6/6 + 17/17 恢复全绿。 + +--- + +## 五、测试结果 + +### 定向测试(还原缺陷后) + +| 测试类 | 结果 | +|---|---| +| `MatchmakerReferralPhase13Test` | 17/17 ✅ | +| `MatchmakerServiceTest` | 6/6 ✅ | +| `CoreInvariantsTest` | 12/12 ✅ | +| `MigrationValidationTest` | 12/12 ✅ | +| `EntityMigrationParityTest` | 5/5 ✅ | +| **合计** | **51 tests / 0 failures / 0 errors** | + +### 全量回归 + +离线容器(`danyang-match-backend-test:latest`)实测,`mvn -o test` 汇总行: + +``` +[WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3 +[INFO] BUILD SUCCESS +[INFO] Total time: 04:59 min +``` + +| 项 | 值 | +|---|---| +| 用例总数 | **391**(Phase 12 基线 371 → +20) | +| Failures / Errors | **0 / 0** | +| Skipped | 3 | +| 结果 | **BUILD SUCCESS**(4 分 59 秒) | + +> **计数说明(沿用 AGENTS.md 纪律:以实测为准,不预估)**:本轮新增 23 例(`MatchmakerReferralPhase13Test` 17 + +> `MatchmakerServiceTest` 重写后 6),同时删除 3 处占位断言,净增 **+20**(371 → 391)。 +> 汇总行是 `[WARNING]` 而非 `[INFO]`(因含 skipped 用例),grep 时不可只匹配 `^\[INFO\]`。 + +--- + +## 六、变更文件清单 + +| 文件 | 类型 | +|---|---| +| `backend/src/main/resources/db/migration/V038__align_matchmaker_status_constraints.sql` | 新增 | +| `backend/src/test/java/.../matchmaker/MatchmakerReferralPhase13Test.java` | 新增(17 用例) | +| `backend/src/test/java/.../matchmaker/MatchmakerServiceTest.java` | **重写**(删假测试,改为解析 DDL 的守卫) | +| `backend/src/test/java/.../invariants/CoreInvariantsTest.java` | 修改(假测试如实改名) | +| `backend/src/test/resources/schema-h2.sql` | 修改(补 `matchmaker_*` 三表) | +| `backend/src/main/java/.../matchmaker/mapper/MatchmakerFollowupMapper.java` | 修改(D6:补 `@Select`) | +| `backend/src/main/java/.../matchmaker/mapper/MatchmakerRecommendationMapper.java` | 修改(新增 `findByOperatorId`) | +| `backend/src/main/java/.../matchmaker/mapper/MatchmakerServiceMapper.java` | 修改(新增 `findActiveByOperatorAndUser`) | +| `backend/src/main/java/.../matchmaker/service/MatchmakerServiceService.java` | 修改(D3/D4) | +| `backend/src/main/java/.../matchmaker/controller/MatchmakerOperatorController.java` | 修改(D4/D7) | +| `backend/src/main/java/.../referral/service/ReferralService.java` | 修改(D5/D7) | +| `backend/src/main/java/.../referral/controller/ReferralController.java` | 修改(D7 新增运营端端点) | +| `admin-web/src/modules/matchmaker/MatchmakerManagementView.vue` | 修改(D7 契约修正) | +| `admin-web/src/modules/referral/ReferralManagementView.vue` | 修改(D7 契约修正) | +| `miniapp/services/referralService.js` | 新增(转介确认客户端) | +| `backend/src/test/java/.../deployment/EntityMigrationParityTest.java` | 修改(白名单迁移) | +| `backend/src/test/java/.../deployment/MigrationValidationTest.java` | 修改(`LATEST_MIGRATION` 37→38) | +| `docs/STATUS.md`、`docs/JIRA_BACKLOG.md`、本报告 | 文档 | + +--- + +## 七、遗留与 gap(登记,不自动实现) + +| 项 | 说明 | +|---|---| +| **miniapp 转介确认 UI 缺页** | 已补 `referralService.js`(客户端 API 层),但小程序**尚未有页面**承载"我的转介 → 确认"交互。属**新功能开发**(非本轮 verify-extend 范围内),建议单列任务。当前用户只能通过 API 层确认,无 UI 入口。 | +| `miniapp` 服务详情端点缺失 | `matchmakerService.getServiceDetail` 指向 `GET /matchmaker/services/{id}`(用户端),后端**无此端点**(operator 侧才有 `/operator/matchmaker/services/{id}`)。`matchmaker-progress` 页面依赖它。 | +| `matchmaker-service-detail` 页面不存在 | `matchmaker-workbench.js:77` 跳转该页,目录不存在。 | +| `ReferralController.referToServiceStation` 的 `preferredDate` | 占位实现(`Instant.now()`),未解析日期字符串 —— 代码内已有 `// placeholder` 注释。 | +| `matchmaker_services` 的 `referral_target_type/id`、`referral_source_type` | DDL 有列,Java 侧**无写入**(死列)。 | +| `MatchmakerService.createReferral` | 返回内存 record、**不落库**,且无 Controller 端点调用(**死代码**)。真正的转介落库走 `ReferralService`。 | +| `CounselingServiceApp` | **孤儿类**(无任何注入点)。其 `addFollowup` → `toFollowupDTO` 会把 counseling followup 正文**明文回传**,但当前**无 HTTP 端点可达**。计划项 3 目前"因功能缺失而侥幸成立"。**若 Phase 13A 接通该端点,必须同时加脱敏**。 | +| counseling 模块零 `@PreAuthorize` | 权限全靠 Service 层手工判(`getDetail` 的 role 分支)。建议 Phase 13A 统一收口。 | +| `TEST_SCHEMA_OPTIONAL_TABLES` 仍有 30 项 | 每补齐一张应移除一项;`matchmaker_*` 本轮已移除。 | + +--- + +## 八、结论 + +Phase 13 三个计划项与 Gate 全项通过。全量回归 **391 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**。 + +本轮最重要的发现是 **D1/D2/D6 三处「只在真实 PostgreSQL 上炸」的生产故障**,其共同隐蔽机制是: +测试库 `schema-h2.sql` 与生产迁移漂移(缺表 + 缺约束)→ **测试绿而生产崩**。 +这与 Phase 12 发现的 D1 是**同族根因**,本轮把守卫进一步收紧为「直接解析迁移 SQL」。 + +**PHASE 13 CLOSED. Phase 13A 未自动进入,等待用户确认。** diff --git a/docs/STATUS.md b/docs/STATUS.md index d1c16f4..8511f7c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-12 RECOVERY CLOSED / GIT CLEAN`。** +> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-13 RECOVERY CLOSED / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(371 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)复验已关闭**,五轮共修复 16 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 13 需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)复验已关闭**,六轮共修复 23 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 13A 需用户确认后启动。** 最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -21,16 +21,16 @@ | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | -| Flyway 迁移 | V001~V037 | V028~V037 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列) | -| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test` | +| Flyway 迁移 | V001~V038 | V028~V038 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展) | +| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**391 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(4 分 59 秒);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest` | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | | `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | | Miniapp | **验证通过** | 2026-09-16:tsc ✅;**11 核心页全部 TS-only(11/11)**;首批 11 页于 2026-09-16 通过用户验收;discover 筛选重设计已通过 DevTools 验证 | | UI 契约测试 | **13/13 通过** | 2026-09-16 运行:全部退出码 0;含 capability 独立化适配与文档基线口径 | | Git 工作区 | **已清零** | 阶段 0~4 共 24+ commit 可追溯;`git status --porcelain` 0 行 | -| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(371 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16)、实体↔生产迁移漂移修复(V037,2026-09-16) | -| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08-12 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08/09/10/11/12 复验通过并关闭,累计修复 16 处真实缺陷,可推进 Phase 13(**需用户确认后启动**) | +| 核心待清债务 | 0 项 | 已全部清偿:3 页 JS→TS 迁移、backend 全量回归(391 tests)、DevTools 真机截图留档(2026-09-14)、`/me/*` 路径族收敛(2026-09-16)、实体↔生产迁移漂移修复(V037/V038,2026-09-16) | +| UAT / RC / Production | **UI 验收通过,前后端契约已收敛,Phase 08-13 已关闭** | 双端构建/契约/源码全部通过;UI 首批 11 页 2026-09-16 通过用户验收;`/me/*` 契约同轮收敛;Phase 08/09/10/11/12/13 复验通过并关闭,累计修复 23 处真实缺陷,可推进 Phase 13A(**需用户确认后启动**) | --- @@ -41,6 +41,7 @@ | 1.0.0-RC.2 | 2026-09-01 | V1.0 核心功能冻结 | | 1.1.0-dev | 2026-09-08 | V1.1 基线搭建启动(Phase 01 进行中) | | 1.1.0-rc.3 | 2026-09-16 | Phase 08~12 复验关闭;新增 V037 对齐实体↔生产迁移;全量回归 371 tests 全绿 | +| 1.1.0-rc.4 | 2026-09-16 | Phase 13 复验关闭(公益红娘转介,修复 7 处缺陷);新增 V038 扩展 matchmaker CHECK 白名单;全量回归 **391 tests** 全绿 | --- @@ -251,6 +252,53 @@ > 行为验证移至走真实服务方法的 `RelationshipProgressPhase12Test`(14 用例,含并发确认)。 > - 报告:`docs/Phase-12-V11-Recovery-Completion-Report-2026-09-16.md` +### Phase 13 — 公益红娘转介 ✅ 已完成(2026-09-16 KEEP-VERIFY/EXTEND 复验通过) +| 任务 | 状态 | +|------|------| +| 保留红娘服务和人工推荐;任何工作人员不能替用户 Like(INV-005) | ✅ 行为测试 4 项 | +| 补齐转介咨询、活动、服务站的用户同意、来源追踪和状态记录 | ✅ 行为测试 6 项 | +| 红娘只能看转介状态和必要摘要,不能读取 counseling followup 正文(INV-013) | ✅ 行为测试 3 项 | +| 红娘归属校验(推荐/转介不得越权代操作) | ✅ 负向测试 4 项 | + +> **2026-09-16 复验结论(KEEP-VERIFY/EXTEND)**:历史 ✅ 不可信,逐条核对**运行时路径**与**生产约束**后修复 7 处缺陷: +> - **D1(Critical,生产故障)** `matchmaker_recommendations.status` 代码写入 `liked`/`passed`/`confirmed_mutual`, +> 而生产 `V009 chk_rec_status` 只允许 `pending_view/viewed/responded` 且从未扩展 → **真实 PG 上用户接受/跳过 +> 推荐必崩**(`DataIntegrityViolation`)。测试库 `schema-h2.sql` 未复刻该 CHECK,故长期静默。修复: +> `V038__align_matchmaker_status_constraints.sql` 以 `DROP CONSTRAINT IF EXISTS` + `ADD CONSTRAINT` 扩展白名单。 +> - **D2(Critical,生产故障)** `MatchmakerController` 缺省 `serviceType="recommendation"` 不在 +> `chk_matchmaker_service_type` 白名单 → **提交红娘服务必崩**。修复:同 `V038` 扩展(原 7 值 + +> `recommendation`/`referral_counseling`/`referral_activity`/`referral_station`)。 +> - **D3(High,越权)** `MatchmakerServiceService.recommendPartner` 无 `assignedOperatorId` 归属校验 → +> 任意红娘可替非本人负责的服务发起推荐(并可推荐给自己)。修复:补归属校验 + 禁止自我推荐。 +> - **D4(High,越权读)** `getServiceDetail` 无归属校验且返回**完整**用户资料(`userId`/`description`/`userProfile`/ +> `followups`)→ 任意红娘可读任意用户敏感详情。修复:签名加 `requesterId`,负责人得完整视图, +> 非负责人得脱敏视图(去 `userProfile`/`userId`/`followups`,仅 `followupCount` + `viewerIsOwner=false`)。 +> - **D5(High,越权写)** `ReferralService` 的 `userId` 取自**请求体**且**零授权校验** → 任意持 +> `matchmaker.service` 权限者可替任意用户发起转介。修复:新增 `assertMatchmakerServesUser`, +> 在 `referToServiceStation`/`referToActivity` 入口强制校验"存在进行中的服务关系"。 +> - **D6(High,生产故障)** `MatchmakerFollowupMapper.findByServiceId` 只声明方法、**无 `@Select`** → +> 运行时 `Invalid bound statement (not found)`,`getServiceDetail` **必崩**。**该缺陷由本轮新增测试首次暴露** +> (历史测试从未调用该路径)。修复:补 `@Select`。 +> - **D7(Medium,契约缺口)** admin-web 5 处 API 缺口:`/matchmaker/services/operator/queue`、 +> `/matchmaker/recommendations/operator`、`PUT /matchmaker/services/{id}/assign`、 +> `POST /matchmaker/services/{id}/recommendation`、`/referrals/my-history`(C 端端点被运营端误用)。 +> 修复:后端补 `GET /operator/matchmaker/recommendations` 与 `GET /referrals/operator/history` +> 两个运营端端点(含 `MatchmakerServiceMapper.findActiveByOperatorAndUser`、 +> `MatchmakerRecommendationMapper.findByOperatorId`、`ReferralService.getReferralHistoryByOperator`), +> 前端改路径、`assign` 改 `POST` + body `operatorId`;小程序新增 `miniapp/services/referralService.js`。 +> - **测试库盲区修复**:`schema-h2.sql` 补 `matchmaker_services`/`matchmaker_recommendations`/ +> `matchmaker_followups` 三表(约束与 V038 对齐);`EntityMigrationParityTest` 的 3 张 matchmaker 表 +> 从 `TEST_SCHEMA_OPTIONAL_TABLES` 移入 `REQUIRED_IN_TEST_SCHEMA`;`MigrationValidationTest` 基线 37 → 38。 +> - **新增永久守卫** `MatchmakerReferralPhase13Test`(17 用例,`@SpringBootTest` 走真实方法路径)+ 重写 +> `MatchmakerServiceTest`(6 用例)。**关键教训**:首版常量守卫**硬编码白名单副本**,注入 D1 缺陷后 +> **仍全绿**(对漂移零反应)→ 重写为**直接解析 `db/migration/V*.sql`**,按文件名顺序应用 +> `DROP CONSTRAINT IF EXISTS` + `ADD CONSTRAINT` 取"最终有效"白名单,并内置解析器自检用例。 +> 修正后经**第二轮负向验证**证明会红(`Tests run: 23, Failures: 1, Errors: 2 / BUILD FAILURE`)。 +> - **删除假测试**:`MatchmakerServiceTest.testMatchmakerCannotLike` 原为 `assertTrue(true)`; +> `CoreInvariantsTest` 的 INV-007 断言的是 `MATCHMAKER_STATUS_*` 常量(名不副实)、INV-010 亦为 +> `assertTrue(true)` → 已删除占位、如实改名并注明行为验证位置。 +> - 报告:`docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md` + ### V1.1 待实现模块(按 Phase 顺序) | 模块 | 对应 Phase | 状态 | @@ -259,7 +307,7 @@ | Banner 管理(后端+前端) | Phase 19 | ⏳ 待开发 | | 本地资格声明与审核 | Phase 04~05 | ⏳ 待开发 | | 公益红娘服务转介 | Phase 13 | ✅ 已完成 | -| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ⏳ 待开发 | +| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ⏳ 待开发(**需用户确认后启动**) | | 内容管理(婚恋课堂/反诈) | Phase 19 | ⏳ 待开发 | | 专项公益服务项目 | Phase 19 | ⏳ 待开发 | | V1.1新增不变量测试(INV-011~020) | Phase 23 | ⏳ 待开发 | diff --git a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md index 0cbe6e7..ec7a64e 100644 --- a/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md +++ b/docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md @@ -257,12 +257,52 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M **Files:** `matchmaker/**`、`referral/**`、Miniapp 红娘页面、Admin referral 页面。 -- [ ] 保留红娘服务和人工推荐;验证任何工作人员不能替用户 Like。 -- [ ] 补齐转介咨询、活动、服务站的用户同意、来源追踪和状态记录。 -- [ ] 红娘只能看转介状态和必要摘要,不能读取 counseling followup 正文。 +- [x] 保留红娘服务和人工推荐;验证任何工作人员不能替用户 Like。 +- [x] 补齐转介咨询、活动、服务站的用户同意、来源追踪和状态记录。 +- [x] 红娘只能看转介状态和必要摘要,不能读取 counseling followup 正文。 **Gate:** 转介形成可追踪记录但不越权;用户决定权测试通过。 +> **2026-09-16 复验结论(KEEP-VERIFY/EXTEND)**:历史 ✅ 不可信。全仓库唯一的"INV-005 红娘不可代用户操作" +> 证据是 `MatchmakerServiceTest#testMatchmakerCannotLike` 的 `assertTrue(true)` 占位,以及 +> `CoreInvariantsTest#testMatchmakerCannotLike`(断言的是 `MATCHMAKER_STATUS_*` **状态常量字符串**, +> 与 Like 无关)—— 两者均为**名不副实的假测试**;且测试库 `schema-h2.sql` **完全缺失** `matchmaker_*` 三表, +> 该模块**零真实行为覆盖**。逐条核对运行时路径后修复 7 处缺陷: +> - **D1(Critical,生产故障)** `matchmaker_recommendations.status`:代码写 +> `liked`/`passed`/`confirmed_mutual`,而 `V009 chk_rec_status` 只允许 +> `pending_view/viewed/responded`,且**从未被后续迁移扩展** → 真实 PostgreSQL 上 +> 用户"接受/跳过"红娘推荐**必然 500**,P13 主流程不可用。 +> - **D2(Critical,生产故障)** `MatchmakerController.submit` 在请求未带 `serviceType` 时兜底写 +> `"recommendation"`,而 `V009 chk_matchmaker_service_type` 白名单只含 `recommend_partner` 等 7 个值 +> → 真实 PG 上提交红娘服务**必然 500**。(`MatchmakerService` 注释声明的 +> `referral_counseling`/`referral_activity`/`referral_station` 同样越界。) +> - **D3(High)** `MatchmakerServiceService.recommendPartner` 缺 `assigned_operator_id` 归属校验 +> → 任何持 `matchmaker.service` 权限的 operator 可对**任意** serviceId 创建推荐(越权)。 +> - **D4(High)** `getServiceDetail` 无任何归属校验,任何 `matchmaker.view` 持有者可读**任意**服务的 +> 完整用户资料(displayName/gender/age/region/education/occupation/maritalStatus)→ 越权读。 +> - **D5(High)** `ReferralService.referToActivity` / `referToServiceStation` 的 `userId` 直接取自 +> 请求体且**无任何授权校验**(连 `MatchmakerServiceMapper` 都没注入)→ 任意红娘可把**任意用户** +> 转介到任意活动/服务站,并顺带为该用户创建报名/预约(越权代操作)。 +> - **D6(High,生产故障)** `MatchmakerFollowupMapper.findByServiceId` **只有方法声明、没有 `@Select`** +> → MyBatis 运行时抛 `Invalid bound statement (not found)`,`getServiceDetail` 必然 500, +> 红娘工作台"查看服务详情"完全不可用。此前不可见:测试库缺 `matchmaker_followups` 表。 +> - **D7(Medium)** Admin referral 页面契约缺口:`/referrals/my-history`(C 端 UserPrincipal 端点) +> 被运营端调用;`/matchmaker/services/operator/queue`、`/matchmaker/recommendations/operator`、 +> `PUT /matchmaker/services/{id}/assign`、`POST /matchmaker/services/{id}/recommendation` +> 四条路径在后端**均不存在**(404)。 +> 修复:新增 `V038__align_matchmaker_status_constraints.sql`(沿用 V018/V020/V025 的 +> `DROP CONSTRAINT IF EXISTS` + 重加白名单惯例);测试库补 `matchmaker_*` 三表(约束对齐 V038); +> D3/D4/D5 补归属校验与转介授权;D6 补 `@Select`;D7 补两个运营端端点并修正前端调用。 +> **新增守卫** `MatchmakerReferralPhase13Test`(17 用例,走真实服务方法 + 真实落库)替换假测试; +> `MatchmakerServiceTest` 重写为**直接解析迁移 SQL** 的口径守卫(第一轮负向验证暴露"抄副本"守卫 +> 对漂移无反应,已修正);`CoreInvariantsTest` 的 INV-007/INV-010 假断言已如实改名并指向真实验证。 +> **两轮负向验证**:注入 D1(回退 `chk_rec_status` 白名单)→ 行为守卫抛 `DataIntegrityViolation`、 +> 常量守卫报 3 处 failure,守卫有效性得证。 +> +> **全量回归实测**:`[WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3` / `BUILD SUCCESS`(4 分 59 秒)。 +> Phase 12 基线 371 → 391(净 +20)。**Phase 13 已关闭。Phase 13A 未自动进入,等待用户确认。** +> 报告:`docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md` + ### Phase 13A:婚恋咨询与婚前辅导 — REFACTOR/EXTEND **Files:** `counseling/**`、`miniapp/pages/counseling-*`、`miniapp/pages/my-counseling/**`、`miniapp/pages/premarital-*`、`admin-web/src/modules/counseling/CounselingView.vue`、router。 diff --git a/miniapp/services/referralService.js b/miniapp/services/referralService.js new file mode 100644 index 0000000..88d084b --- /dev/null +++ b/miniapp/services/referralService.js @@ -0,0 +1,53 @@ +/** + * 红娘转介服务(Phase 13) + * + * 封装「用户确认转介」的两阶段流程与「我的转介历史」。 + * + * 关键不变量(AGENTS.md): + * - 第 5 条:红娘不可替用户操作。转介由红娘发起(运营端), + * **必须由用户本人在小程序确认** 才算生效 —— 本层只提供用户侧的 + * 确认与查询,不提供任何"代用户确认"的入口。 + * - 转介形成可追踪记录(source=matchmaker_referral + createdByOperatorId + userConfirmedAt)。 + * + * 后端端点: + * GET /api/v1/referrals/my-history — 我的转介历史(UserPrincipal) + * POST /api/v1/referrals/{id}/confirm — 用户确认转介 + */ +const api = require('../utils/api'); + +/** + * 我的转介历史(含是否已确认) + * @returns {Promise>} + */ +function getMyReferralHistory() { + return api.get('/referrals/my-history'); +} + +/** + * 用户确认转介(两阶段流程的第二阶段) + * + * ⚠️ 只有转介的目标用户本人可以确认;他人调用后端会拒绝。 + * @param {string} targetId 转介记录 ID(appointmentId 或 registrationId) + * @param {'service_station'|'activity'} targetType + * @returns {Promise<{ id: string, status: string, confirmedAt: string }>} + */ +function confirmReferral(targetId, targetType) { + return api.post( + `/referrals/${encodeURIComponent(targetId)}/confirm`, + { targetType }, + undefined, + `referral-confirm:${targetId}:${targetType}` + ); +} + +module.exports = { + getMyReferralHistory, + confirmReferral, +};