fix(counseling): close Phase 13A KEEP-VERIFY/EXTEND — 9 real defects + 4 test-infra defects

Phase 13A(婚恋咨询与婚前辅导)复验关闭。历史 ✅ 不可信:counseling 模块存在双实现,
线上权威 CounselingService(167 行)零测试,唯一被测的 CounselingServiceApp(280 行)
是孤儿类(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除。

生产缺陷(D1~D9):
- D1 Critical 生产故障 · INV-013 泄露:咨询正文 userRequestSummary 原文被写入 WARN 日志
  (同 Phase 10 D2 一族)→ 改为只记 serviceId + riskLevel,绝不记正文。
- D2 High 越权:申请 userId 取自请求体而非认证主体 → Controller 强制 principal.id()。
- D3 High 并发:分配用「先读后写」→ 两管理员并发分配静默覆盖;
  改 assignIfPending 条件更新(WHERE status='pending' AND assigned_operator_id IS NULL),
  affectedRows=0 明确报错。
- D4 High 越权读:counseling_manager 经 getDetail 可读 followups 正文 → INV-013 绕过通道;
  manager 只加回 userRequestSummary,不装配 followups。
- D5 Medium:无状态机 → 「已完成」可被再取消;新增 ALLOWED_TRANSITIONS + assertTransition。
- D6 Medium 生产故障:GET /counseling/services/intro 后端不存在(且被 /services/{id} 吞掉)
  → 小程序 intro 页 100% 无数据;补端点并声明在变量路径之前。
- D7 Medium 契约:Admin 调用 5 条不存在的 /services/operator/*(404)+ 缺四态
  → 后端补 8 个运营端端点(@PreAuthorize),前端整体重写 + 注册 /counseling 路由。
- D8 Medium 契约:小程序字段名错(userRequestSummary/preferredServiceMode)+ 无幂等
  + my-counseling 未检查 success(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法。
- D9 Medium 测试保真度:测试库 counseling 两表零 CHECK 约束,
  counseling_services 未列入 EntityMigrationParityTest 任何集合(= 零覆盖)。

测试基建缺陷(T1~T4,不修即恒真假绿):
- T1 守卫找不到 DDL:surefire workingDirectory = ${project.basedir}/.. 使 CWD 为仓库根,
  且 backend/ 是子目录非祖先 → 改为候选后缀 + 逐级向上定位,失败大声抛异常。
- T2 全限定 @com.baomidou...TableField(exist=false) 漏解析(假漂移警报)→ 正则放宽 + 回归守卫。
- T3 OperatorEndpointPermissionCoverageTest 扫到私有辅助方法(假阳性)→ 只扫描真实端点 + 自检。
- T4 两处测试自身逻辑缺陷:陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度。

迁移:新增 V039(counseling 幂等键 + 三个 CHECK 白名单显式固化),基线 38 → 39。

负向验证两轮(均已还原,grep -c NEG-VAL = 0):
- ① DDL 常量守卫:从 V039 移除 'scheduled' → 常量守卫精准变红
  (证明解析的是真实 DDL 而非副本)。
- ② 行为守卫:注入「并发回退先读后写 / manager 读 followups / 状态机守卫失效」
  → 三个用例各自精准变红。

测试:新增 CounselingPhase13ATest(25)+ CounselingConstraintGuardTest(5,解析真实 DDL);
删除孤儿类及其 3 个 DTO、删除测死代码的 CounselingDataScopeTest。
全量回归:Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS(4:49)。
基线 391 → 420(净 +29)。admin-web vue-tsc EXIT=0;miniapp tsc EXIT=0。

Tag: phase13a-recovery-2026-09-17
Report: docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
This commit is contained in:
WorkBuddy
2026-09-17 01:25:12 +08:00
parent 5b3bad326e
commit 7a2693b239
26 changed files with 2208 additions and 700 deletions
@@ -1,33 +1,48 @@
package com.danyang.match.platform.counseling.controller;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.service.CounselingService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 婚恋咨询 / 婚前辅导 端点
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
*
* 用户端:
* POST /api/v1/counseling/services — 申请咨询/辅导
* GET /api/v1/counseling/services — 我的服务历史
* GET /api/v1/counseling/services/{id} — 查看详情(自动脱敏 INV-013)
* 用户端(UserPrincipal):
* POST /counseling/services — 申请(userId 取自**认证主体**,非请求体)
* GET /counseling/services — 我的服务历史
* GET /counseling/services/intro — 服务介绍(**必须声明在 /{id} 之前**)
* GET /counseling/services/{id} — 详情(自动脱敏 INV-013)
* POST /counseling/services/{id}/cancel — 取消(仅本人 + 合法状态迁移)
* POST /counseling/services/{id}/companion-confirm— 婚前辅导伴侣本人确认(INV-005)
*
* Counselor / Manager 端:
* GET /api/v1/counseling/assigned — 我被分配的(INV-012)
* PUT /api/v1/counseling/services/{id}/assign — counseling_manager 分配
* PUT /api/v1/counseling/services/{id}/schedule — assigned Counselor 排期(INV-012)
* PUT /api/v1/counseling/services/{id}/complete — assigned Counselor 完成(INV-012)
* 运营端(OperatorPrincipal,对应 Admin Web 契约):
* GET /counseling/services/operator/queue — 待分配队列(manager)
* GET /counseling/services/operator/assigned — 我被分配的(INV-012)
* POST /counseling/services/operator/{id}/assign — 分配(counseling.service.assign)
* POST /counseling/services/operator/{id}/schedule— 排期(assigned)
* POST /counseling/services/operator/{id}/start — 开始(assigned)
* POST /counseling/services/operator/{id}/followup— 添加跟进(assigned)
* POST /counseling/services/operator/{id}/complete— 完成(assigned)
* GET /counseling/services/operator/{id} — 运营端详情(脱敏/全文按角色)
*
* 权限由 CounselingServiceRecord.getDetail() / assertAssigned() 在 Service 层守卫,
* Controller 层只做路由 + principal 透传。
* 路由顺序注意:`/services/intro` 是**字面量**路径,必须声明在 `/services/{id}` **之前**,
* 否则会被 `{id}` 吞掉(intro 被当作 serviceId 解析)。
*
* 权限双保险:Service 层做数据域校验(INV-012 / INV-013),Controller 层加 @PreAuthorize
* 做粗粒度权限门禁。两层都必须有 —— 只有 Controller 会漏掉数据域,只有 Service 会漏掉接口级鉴权。
*/
@RestController
@RequestMapping("/counseling")
@@ -40,90 +55,197 @@ public class CounselingController {
@PostMapping("/services")
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.apply(
principal.id(),
principal.id(), // 修复:userId 来自认证主体,不取请求体
req.serviceType,
req.summary,
req.summary != null ? req.summary : req.userRequestSummary,
req.requestSource,
req.preferredMode,
req.preferredTime));
req.preferredMode != null ? req.preferredMode : req.preferredServiceMode,
req.preferredTime,
req.companionUserId,
req.idempotencyKey));
}
@GetMapping("/services")
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
@AuthenticationPrincipal UserPrincipal principal) {
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.myServices(principal.id(), limit));
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
// 列表场景剥离正文与 followups(INV-013)
list.forEach(CounselingService::sanitize);
return ApiResponse.ok(list);
}
/**
* 服务介绍 —— **必须声明在 /services/{id} 之前**,否则被 {id} 吞掉。
* 该端点原本缺省,导致两个小程序 intro 页 100% 拿不到数据。
*/
@GetMapping("/services/intro")
public ApiResponse<List<Map<String, Object>>> intro() {
return ApiResponse.ok(counselingService.getServiceIntro());
}
@GetMapping("/services/{id}")
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
CounselingServiceRecord svc = counselingService.getDetail(principal.id(), principal.role(), id);
if (svc == null) return ApiResponse.forbidden();
return ApiResponse.ok(svc);
}
// ==================== Counselor / Manager 端 ====================
@GetMapping("/assigned")
public ApiResponse<List<CounselingServiceRecord>> assigned(@AuthenticationPrincipal UserPrincipal principal) {
@PostMapping("/services/{id}/cancel")
public ApiResponse<CounselingServiceRecord> cancel(@PathVariable String id,
@RequestBody(required = false) CancelRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.assignedTo(principal.id()));
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
req != null ? req.reason : null));
}
@PutMapping("/services/{id}/assign")
public ApiResponse<Void> assign(@PathVariable String id,
@RequestBody AssignRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
/** INV-005:婚前辅导伴侣**本人**确认 —— companionUserId 取自认证主体,不可代确认 */
@PostMapping("/services/{id}/companion-confirm")
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
// INV-012 加强:assign 只能由 counseling_manager 执行
if (!"counseling_manager".equals(principal.role())) {
return ApiResponse.forbidden("只有咨询管理人员可以分配服务");
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
}
// ==================== 运营端(Admin Web 契约) ====================
@GetMapping("/services/operator/queue")
@PreAuthorize("hasAuthority('counseling.service.assign')")
public ApiResponse<List<CounselingServiceRecord>> operatorQueue(@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
List<CounselingServiceRecord> list = counselingService.pendingQueue();
list.forEach(CounselingService::sanitize);
return ApiResponse.ok(list);
}
@GetMapping("/services/operator/assigned")
@PreAuthorize("hasAuthority('counseling.service.process')")
public ApiResponse<List<CounselingServiceRecord>> operatorAssigned(@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
List<CounselingServiceRecord> list = counselingService.assignedTo(principal.id());
// INV-013:列表不装配 followups 正文
list.forEach(CounselingService::sanitize);
return ApiResponse.ok(list);
}
@GetMapping("/services/operator/{id}")
@PreAuthorize("hasAuthority('counseling.service.view')")
public ApiResponse<CounselingServiceRecord> operatorDetail(@PathVariable String id,
@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
CounselingServiceRecord svc = counselingService.getDetailForCounselor(
principal.id(), roleOf(principal), id);
if (svc == null) return ApiResponse.forbidden();
return ApiResponse.ok(svc);
}
@PostMapping("/services/operator/{id}/assign")
@PreAuthorize("hasAuthority('counseling.service.assign')")
public ApiResponse<CounselingServiceRecord> assign(@PathVariable String id,
@RequestBody AssignRequest req,
@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
// 兼容前端两种字段名(counselorId / assignedOperatorId)
String counselorId = req.counselorId != null ? req.counselorId : req.assignedOperatorId;
if (counselorId == null || counselorId.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "必须指定咨询师");
}
counselingService.assign(id, req.assignedOperatorId);
return ApiResponse.ok();
return ApiResponse.ok(counselingService.assign(id, counselorId));
}
@PutMapping("/services/{id}/schedule")
public ApiResponse<Void> schedule(@PathVariable String id,
@RequestBody ScheduleRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
@PostMapping("/services/operator/{id}/schedule")
@PreAuthorize("hasAuthority('counseling.service.process')")
public ApiResponse<CounselingServiceRecord> schedule(@PathVariable String id,
@RequestBody ScheduleRequest req,
@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
counselingService.schedule(id, principal.id(), req.start, req.end);
return ApiResponse.ok();
Instant start = req.startAt != null ? req.startAt : req.start;
Instant end = req.endAt != null ? req.endAt : req.end;
return ApiResponse.ok(counselingService.schedule(id, principal.id(), start, end));
}
@PutMapping("/services/{id}/complete")
public ApiResponse<Void> complete(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
@PostMapping("/services/operator/{id}/start")
@PreAuthorize("hasAuthority('counseling.service.process')")
public ApiResponse<CounselingServiceRecord> start(@PathVariable String id,
@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
counselingService.complete(id, principal.id());
return ApiResponse.ok();
return ApiResponse.ok(counselingService.start(id, principal.id()));
}
@PostMapping("/services/operator/{id}/followup")
@PreAuthorize("hasAuthority('counseling.note.create')")
public ApiResponse<CounselingFollowup> followup(@PathVariable String id,
@RequestBody FollowupRequest req,
@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.addFollowup(
id, principal.id(), req.followupType, req.content, req.resultCode, req.nextFollowupAt));
}
@PostMapping("/services/operator/{id}/complete")
@PreAuthorize("hasAuthority('counseling.service.process')")
public ApiResponse<CounselingServiceRecord> complete(@PathVariable String id,
@AuthenticationPrincipal OperatorPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.complete(id, principal.id()));
}
/** OperatorPrincipal 没有直接暴露 role,用权限推导角色语义(供 Service 层分层用) */
private String roleOf(OperatorPrincipal principal) {
if (principal.hasPermission("counseling.service.assign")) return "counseling_manager";
return "counselor";
}
// ==================== DTO ====================
@Data
public static class ApplyRequest {
private String serviceType; // counseling / premarital
private String serviceType; // 白名单:见 V022 chk_counseling_service_type
/** 用户自述(INV-013 敏感)。兼容前端 summary / userRequestSummary 两种命名。 */
private String summary;
private String requestSource; // user_self / matchmaker_referral
private String preferredMode; // online / offline
private String userRequestSummary;
private String requestSource; // user_self / matchmaker_referral / ...
/** 兼容前端 preferredMode / preferredServiceMode 两种命名。 */
private String preferredMode;
private String preferredServiceMode;
private String preferredTime;
/** 婚前辅导:平台内伴侣 user id(须对方本人确认,INV-005) */
private String companionUserId;
private String idempotencyKey;
}
@Data
public static class CancelRequest {
private String reason;
}
@Data
public static class AssignRequest {
/** 兼容前端两种字段名 */
private String counselorId;
private String assignedOperatorId;
}
@Data
public static class ScheduleRequest {
/** 兼容前端两种字段名 */
private Instant startAt;
private Instant endAt;
private Instant start;
private Instant end;
}
@Data
public static class FollowupRequest {
private String followupType;
private String content;
private String resultCode;
private Instant nextFollowupAt;
}
}
@@ -1,31 +0,0 @@
package com.danyang.match.platform.counseling.dto;
import lombok.Data;
import java.time.Instant;
import java.util.List;
/**
* 咨询响应DTO
*/
@Data
public class CounselingDTO {
private String id;
private String userId;
private String serviceType;
private String requestSource;
private String userRequestSummary;
private String status;
private String assignedOperatorId;
private String assignedOperatorName;
private String preferredServiceMode;
private String preferredTime;
private Instant scheduledStartAt;
private Instant scheduledEndAt;
private String companionUserId;
private String companionUserName;
private Instant completedAt;
private String closedReason;
private Instant createdAt;
private Instant updatedAt;
private List<FollowupDTO> followups;
}
@@ -1,18 +0,0 @@
package com.danyang.match.platform.counseling.dto;
import lombok.Data;
/**
* 用户申请咨询请求
*/
@Data
public class CounselingRequest {
private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance
private String requestSource;
private String userRequestSummary;
private String preferredServiceMode;
private String preferredTime;
/** 伴侣用户ID(婚前辅导时可选填写) */
private String companionUserId;
private String createdByOperatorId;
}
@@ -1,14 +0,0 @@
package com.danyang.match.platform.counseling.dto;
import lombok.Data;
import java.time.Instant;
@Data
public class FollowupDTO {
private String id;
private String followupType;
private String content;
private String resultCode;
private Instant nextFollowupAt;
private Instant createdAt;
}
@@ -45,4 +45,31 @@ public class CounselingServiceRecord extends BaseEntity {
private String createdByOperatorId;
/** 关闭原因(用户取消 / 运营关闭) */
private String closedReason;
/** 同意书版本(合规留痕) */
private String consentVersion;
/** 同意时间 */
private java.time.Instant consentedAt;
/**
* 客户端幂等键。
*
* 刻意**不放在 BaseEntity**:BaseEntity 已被 47 个实体继承,
* 在其中加列会一次性要求 47 张表都补列(侵入面过大且无必要)。
* 幂等语义按业务模块各自按需声明。
* 唯一性由 V039 的 (user_id, idempotency_key) 约束保证。
*/
private String idempotencyKey;
/** 伴侣本人确认时间(INV-005:只有受邀伴侣本人可写) */
private java.time.Instant userConfirmedAt;
/**
* 跟进记录 —— **敏感(INV-013)**。
*
* 仅「完整视图」路径(申请者本人 / assigned Counselor)才装配该字段;
* 脱敏路径(红娘、普通 operator、manager)必须为 null。
* 该字段不对应 counseling_services 的任何列,禁止让 MyBatis-Plus 写库。
*/
@com.baomidou.mybatisplus.annotation.TableField(exist = false)
private java.util.List<CounselingFollowup> followups;
}
@@ -3,6 +3,7 @@ package com.danyang.match.platform.counseling.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -12,4 +13,11 @@ public interface CounselingFollowupMapper extends BaseMapper<CounselingFollowup>
@Select("SELECT * FROM counseling_followups WHERE counseling_id = #{counselingId} ORDER BY created_at DESC")
List<CounselingFollowup> findByCounselingId(String counselingId);
/**
* INV-013:跟进记录正文只能经由**完整视图**(申请者本人 / assigned Counselor)读取。
* 语义与 {@link #findByCounselingId} 一致,方法名按服务层意图命名。
*/
@Select("SELECT * FROM counseling_followups WHERE counseling_id = #{serviceId} ORDER BY created_at DESC")
List<CounselingFollowup> findByServiceId(@Param("serviceId") String serviceId);
}
@@ -5,6 +5,7 @@ import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
@@ -39,10 +40,30 @@ public interface CounselingServiceMapper extends BaseMapper<CounselingServiceRec
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingServiceRecord> findByAssignedOperator(@Param("operatorId") String operatorId);
/** INV-013: 脱敏查询 — 给 Matchmaker 用,跳过敏感字段 */
/** INV-013: 脱敏查询 — 给 Matchmaker / manager 用,跳过敏感字段 */
@Select("SELECT id, user_id, service_type, request_source, organization_id, assigned_operator_id, status, " +
"preferred_service_mode, preferred_time, scheduled_start_at, scheduled_end_at, completed_at, " +
"created_at, updated_at " +
"companion_user_id, user_confirmed_at, created_at, updated_at " +
"FROM counseling_services WHERE id = #{id}")
CounselingServiceRecord findSanitized(@Param("id") String id);
/**
* 幂等查询:同一用户 + 同一幂等键只应存在一条记录。
*/
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} " +
"AND idempotency_key = #{idempotencyKey} AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
CounselingServiceRecord findByIdempotencyKey(@Param("userId") String userId,
@Param("idempotencyKey") String idempotencyKey);
/**
* 并发安全的分配:**条件更新**。
*
* 只在状态仍为 pending 且尚未分配时生效;返回受影响行数。
* 两个管理员并发分配同一单时,仅一个能拿到 affected=1,
* 另一个拿到 0 → 服务层明确报"已被分配",而不是静默覆盖对方的分派。
*/
@Update("UPDATE counseling_services SET assigned_operator_id = #{counselorId}, status = 'assigned', " +
"updated_at = NOW() WHERE id = #{serviceId} AND status = 'pending' " +
"AND assigned_operator_id IS NULL AND deleted_at IS NULL")
int assignIfPending(@Param("serviceId") String serviceId, @Param("counselorId") String counselorId);
}
@@ -1,8 +1,12 @@
package com.danyang.match.platform.counseling.service;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.risk.service.RiskReferralService;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -12,23 +16,33 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* 婚恋咨询 / 婚前辅导 服务
* 婚恋咨询 / 婚前辅导 服务(Phase 13A 权威实现)
*
* Phase 13A 核心不变量:
* - INV-012: Counselor 只能读取 Assigned Service
* - INV-013: Matchmaker 不能读取 Counseling 正文(user_request_summary + followups.content)
* 权威性说明:
* 本类是 counseling 模块**唯一的业务实现**。`CounselingServiceApp` 是历史孤儿类
* (无任何注入点、无 HTTP 端点可达),其流程已合并至此,该类已被删除。
* 新增改动**不要**再往 CounselingServiceApp 里加。
*
* ── 不变量 ──────────────────────────────────────────────────────────────
* INV-012 Counselor 只能读取与操作 Assigned Service
* INV-013 Matchmaker / 普通 operator 不能读取 Counseling 正文
* (user_request_summary + followups.content),仅可见脱敏摘要
* INV-005 任何工作人员不能替用户确认(婚前辅导伴侣须由对方本人确认)
* ────────────────────────────────────────────────────────────────────────
*
* 权限模型(谁能看到什么):
* ┌───────────────────────────┬────────────┬────────────────────┬──────────┐
* │ 角色 │ 列表 │ 详情(脱敏) │ 详情(全文)│
* │ 角色 │ 列表 │ 详情(脱敏) │ 详情(全文)│
* ├───────────────────────────┼────────────┼────────────────────┼──────────┤
* │ 申请人 user │ ✅ 自己的 │ ✅ 自己的 │ ✅ │
* │ 申请人 user │ ✅ 自己的 │ ✅ 自己的 │ ✅ │
* │ assigned Counselor │ ✅ 被分配的 │ ✅ 被分配的 │ ✅ │
* │ Matchmaker (红娘) │ ❌ │ ✅ 脱敏版(无summary)│ ❌ │
* │ counseling_manager │ ✅ │ ✅ │ ✅ │
* │ counselor 非本人 │ ❌ │ ❌ │ ❌ │
* │ Matchmaker (红娘) │ ❌ │ ✅ 脱敏版(无正文) │ ❌ │
* │ counseling_manager │ ✅ 队列 │ ✅ 含申请摘要 │ ❌(无followups)│
* │ service_station_operator │ ❌ │ ✅ 脱敏版 │ ❌ │
* └───────────────────────────┴────────────┴────────────────────┴──────────┘
*/
@@ -38,22 +52,65 @@ public class CounselingService {
private static final Logger log = LoggerFactory.getLogger(CounselingService.class);
/** 合法状态机:key = 当前状态,value = 允许迁移到的状态集合 */
private static final Map<String, Set<String>> ALLOWED_TRANSITIONS = Map.of(
"pending", Set.of("assigned", "cancelled", "closed"),
"assigned", Set.of("scheduled", "in_progress", "cancelled", "closed"),
"scheduled", Set.of("in_progress", "cancelled", "closed"),
"in_progress", Set.of("completed", "closed"),
"completed", Set.of(),
"cancelled", Set.of(),
"closed", Set.of()
);
/** service_type 白名单 — 与 V022 chk_counseling_service_type 对齐 */
private static final Set<String> VALID_SERVICE_TYPES = Set.of(
"relationship_guidance", "emotional_support",
"premarital_guidance", "anti_fraud_guidance");
/** request_source 白名单 — 与 V022 chk_request_source 对齐 */
private static final Set<String> VALID_REQUEST_SOURCES = Set.of(
"user_self", "matchmaker_referral", "activity_followup",
"service_station", "operator_assisted");
private final CounselingServiceMapper mapper;
private final CounselingFollowupMapper followupMapper;
private final NotificationService notificationService;
private final RiskReferralService riskReferralService;
private final DataScopeGuard dataScopeGuard;
// ==================== 用户端:申请 ====================
/**
* 用户申请婚恋咨询 / 婚前辅导
*
* @param userId 当前用户
* @param serviceType "counseling" / "premarital"
* @param summary 用户自述(INV-013: 红娘不可见)
* @param requestSource "user_self" / "matchmaker_referral"
* @param userId 当前用户 —— 必须由**认证主体**传入,绝不可取自请求体
* @param serviceType 白名单内取值
* @param summary 用户自述(INV-013: 红娘不可见,且**不得写入日志**)
* @param requestSource 白名单内取值
* @param idempotencyKey 幂等键;同一用户 + 同一键重复提交返回既有记录
*/
@Transactional
public CounselingServiceRecord apply(String userId, String serviceType, String summary,
String requestSource, String preferredMode, String preferredTime) {
String requestSource, String preferredMode,
String preferredTime, String companionUserId,
String idempotencyKey) {
if (serviceType == null || !VALID_SERVICE_TYPES.contains(serviceType)) {
throw new IllegalArgumentException("不支持的服务类型: " + serviceType);
}
if (requestSource != null && !VALID_REQUEST_SOURCES.contains(requestSource)) {
throw new IllegalArgumentException("不支持的申请来源: " + requestSource);
}
// 幂等:同用户 + 同幂等键 → 直接返回既有记录,不重复落库
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
CounselingServiceRecord existing = mapper.findByIdempotencyKey(userId, idempotencyKey);
if (existing != null) {
log.info("咨询申请幂等命中: userId={}, key={}", userId, idempotencyKey);
return existing;
}
}
CounselingServiceRecord svc = new CounselingServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
@@ -63,10 +120,28 @@ public class CounselingService {
svc.setStatus("pending");
svc.setPreferredServiceMode(preferredMode);
svc.setPreferredTime(preferredTime);
svc.setCompanionUserId(companionUserId);
if (idempotencyKey != null && !idempotencyKey.isBlank()) {
svc.setIdempotencyKey(idempotencyKey);
}
mapper.insert(svc);
// P11-T04: Counseling assigned 通知(pending 状态,等分配后再发 assigned 通知)
log.info("CounselingServiceRecord submitted: userId={}, type={}, source={}", userId, serviceType, requestSource);
// P17-T01: 咨询内容风险检测。
// INV-013 加固:**绝不记录 summary 原文**,只记风险级别与记录 ID。
// (修复前:log.warn("... summary={}", summary) 会把正文写进日志 → 同 Phase 10 D2 一族。)
try {
String riskLevel = riskReferralService.detectCounselingRisk(userId, summary);
if ("high".equals(riskLevel)) {
log.warn("咨询内容触发高危风险: userId={}, serviceId={}, riskLevel=high",
userId, svc.getId());
}
} catch (Exception e) {
log.warn("咨询风险检测异常: serviceId={}, err={}", svc.getId(), e.getMessage());
}
// P11-T04: 该状态为 pending,等分配后再发 assigned 通知
log.info("CounselingServiceRecord submitted: userId={}, type={}, source={}",
userId, serviceType, svc.getRequestSource());
return svc;
}
@@ -75,84 +150,233 @@ public class CounselingService {
return mapper.findByUser(userId, Math.min(limit, 50));
}
// ==================== Counselor 端:分配 + 排期 + 跟进 ====================
// ==================== 用户端:取消 ====================
/** INV-012: 分配给 Counselor — 只有 counseling_manager 能调 */
/**
* 用户取消自己的咨询申请
* 仅 pending / assigned / scheduled 可取消(合法状态迁移)。
*/
@Transactional
public void assign(String serviceId, String counselorId) {
public CounselingServiceRecord cancel(String serviceId, String userId, String reason) {
CounselingServiceRecord svc = mapper.findById(serviceId)
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
svc.setAssignedOperatorId(counselorId);
svc.setStatus("assigned");
if (!userId.equals(svc.getUserId())) {
// 不泄露"记录存在但非你所有"这一事实
throw new IllegalStateException("服务不存在");
}
assertTransition(svc.getStatus(), "cancelled");
svc.setStatus("cancelled");
svc.setClosedReason(reason);
mapper.updateById(svc);
notificationService.counselingAssigned(svc.getUserId(), serviceId);
return svc;
}
// ==================== Counselor 端:分配 + 排期 + 跟进 ====================
/**
* INV-012: 分配给 Counselor — 只有 counseling_manager 能调
*
* 并发加固:使用**条件更新**(WHERE status='pending')而非"先读后写",
* 两个管理员同时分配同一单时,只有一个能成功,另一个影响行数为 0 → 明确报错。
*/
@Transactional
public CounselingServiceRecord assign(String serviceId, String counselorId) {
mapper.findById(serviceId)
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
int affected = mapper.assignIfPending(serviceId, counselorId);
if (affected == 0) {
// 条件更新未命中 = 已被他人分派或状态已变 —— 不能盲目覆盖
throw new IllegalStateException("该服务已被分配或状态已变更,请刷新后重试");
}
CounselingServiceRecord updated = mapper.findById(serviceId).orElseThrow();
notificationService.counselingAssigned(updated.getUserId(), serviceId);
return updated;
}
/** INV-012: 排期 — 只有 assigned Counselor 能调 */
@Transactional
public void schedule(String serviceId, String counselorId, Instant start, Instant end) {
public CounselingServiceRecord schedule(String serviceId, String counselorId, Instant start, Instant end) {
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
if (start == null || end == null) {
throw new IllegalArgumentException("排期时间不能为空");
}
if (!end.isAfter(start)) {
throw new IllegalArgumentException("结束时间必须晚于开始时间");
}
assertTransition(svc.getStatus(), "scheduled");
svc.setStatus("scheduled");
svc.setScheduledStartAt(start);
svc.setScheduledEndAt(end);
mapper.updateById(svc);
// Phase 11:婚前辅导(premarital)与婚恋咨询共用排期流程,但通知类型分开,
// 便于后续按服务类型做差异化触达与统计(P11-V11-T07)。
if ("premarital".equals(svc.getServiceType())) {
notificationService.premaritalScheduled(svc.getUserId(), serviceId,
start.toString() + " ~ " + end.toString());
// Phase 11:婚前辅导(premarital_guidance)与婚恋咨询共用排期流程,但通知类型分开。
String time = start + " ~ " + end;
if ("premarital_guidance".equals(svc.getServiceType())) {
notificationService.premaritalScheduled(svc.getUserId(), serviceId, time);
} else {
notificationService.counselingScheduled(svc.getUserId(), serviceId,
start.toString() + " ~ " + end.toString());
notificationService.counselingScheduled(svc.getUserId(), serviceId, time);
}
return svc;
}
/** INV-012: 开始服务 — 只有 assigned Counselor 能调 */
@Transactional
public CounselingServiceRecord start(String serviceId, String counselorId) {
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
assertTransition(svc.getStatus(), "in_progress");
svc.setStatus("in_progress");
mapper.updateById(svc);
return svc;
}
/** INV-012: 完成 — 只有 assigned Counselor 能调 */
@Transactional
public void complete(String serviceId, String counselorId) {
public CounselingServiceRecord complete(String serviceId, String counselorId) {
CounselingServiceRecord svc = assertAssigned(serviceId, counselorId);
assertTransition(svc.getStatus(), "completed");
svc.setStatus("completed");
svc.setCompletedAt(Instant.now());
mapper.updateById(svc);
notificationService.counselingCompleted(svc.getUserId(), serviceId);
return svc;
}
/** Counselor 查询被分配的列表 */
/** INV-012: 添加跟进记录 — 只有 assigned Counselor 能调 */
@Transactional
public CounselingFollowup addFollowup(String serviceId, String counselorId,
String followupType, String content,
String resultCode, Instant nextFollowupAt) {
assertAssigned(serviceId, counselorId);
if (content == null || content.isBlank()) {
throw new IllegalArgumentException("跟进内容不能为空");
}
CounselingFollowup fu = new CounselingFollowup();
fu.setId(UUID.randomUUID().toString());
fu.setCounselingId(serviceId);
fu.setOperatorId(counselorId);
fu.setFollowupType(followupType);
fu.setContent(content); // 敏感:INV-013 受保护,仅 assigned Counselor / 申请者本人可读
fu.setResultCode(resultCode);
fu.setNextFollowupAt(nextFollowupAt);
followupMapper.insert(fu);
return fu;
}
/** Counselor 查询被分配的列表(INV-012) */
public List<CounselingServiceRecord> assignedTo(String counselorId) {
return mapper.findAssignedTo(counselorId);
}
/** Manager 查询待分配队列 */
public List<CounselingServiceRecord> pendingQueue() {
return mapper.findPendingAll();
}
/**
* 获取服务详情 — 带权限检查
*
* @param requesterId 调用者用户/操作人 ID
* @param requesterId 调用者用户/操作人 ID
* @param requesterRole 调用者角色(决定返回脱敏还是全文)
* @param serviceId 服务 ID
*/
public CounselingServiceRecord getDetail(String requesterId, String requesterRole, String serviceId) {
CounselingServiceRecord full = mapper.findFull(serviceId);
if (full == null) return null;
boolean isUser = full.getUserId().equals(requesterId);
boolean isUser = requesterId.equals(full.getUserId());
boolean isAssignedCounselor = requesterId.equals(full.getAssignedOperatorId());
boolean isManager = "counseling_manager".equals(requesterRole);
boolean isMatchmaker = "matchmaker".equals(requesterRole) || "operator".equals(requesterRole);
// 有权限看全文的情况
if (isUser || isAssignedCounselor || isManager) {
return full; // 完整返回,包含 user_request_summary
// 完整视图:仅申请者本人与 assigned Counselor
if (isUser || isAssignedCounselor) {
return attachFollowups(full);
}
// INV-013: Matchmaker / 普通 operator 只能看脱敏版
// INV-013: Matchmaker / 普通 operator 只能看脱敏版(无 user_request_summary,无 followups)
if (isMatchmaker) {
return mapper.findSanitized(serviceId);
}
// counseling_manager:可看申请摘要(用于分派判断),
// 但**不返回 followups 正文** —— 避免经理成为 INV-013 的绕过通道。
if (isManager) {
CounselingServiceRecord sanitized = mapper.findSanitized(serviceId);
if (sanitized != null) {
sanitized.setUserRequestSummary(full.getUserRequestSummary());
}
return sanitized;
}
// 其他人一律无权限
return null;
}
// ==================== 内部工具 ====================
/**
* INV-012 的读侧:Counselor 只能取自己被分配的服务。
* 非 assigned counselor 请求他人服务 → DataScopeGuard 拒绝并记审计。
*/
public CounselingServiceRecord getDetailForCounselor(String requesterId, String requesterRole, String serviceId) {
CounselingServiceRecord svc = getDetail(requesterId, requesterRole, serviceId);
if (svc == null) return null;
boolean isAssignedCounselor = requesterId.equals(svc.getAssignedOperatorId());
boolean isUser = requesterId.equals(svc.getUserId());
boolean isManagerOrMatchmaker = "counseling_manager".equals(requesterRole)
|| "matchmaker".equals(requesterRole) || "operator".equals(requesterRole);
if (!isAssignedCounselor && !isUser && !isManagerOrMatchmaker) {
dataScopeGuard.deny(requesterId, "counseling_service", serviceId,
"INV-012: 只能查看分配给自己的咨询服务");
return null;
}
return svc;
}
// ==================== 婚前辅导:伴侣确认 ====================
/**
* INV-005: 邀请平台内伴侣参与婚前辅导 —— **必须由伴侣本人确认**,不得由他人代确认。
*
* @param serviceId 服务 ID
* @param companionUserId 当前登录用户(待确认为伴侣的一方,取自认证主体)
*/
@Transactional
public CounselingServiceRecord confirmCompanion(String serviceId, String companionUserId) {
CounselingServiceRecord svc = mapper.findById(serviceId)
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
if (!"premarital_guidance".equals(svc.getServiceType())) {
throw new IllegalStateException("仅婚前辅导支持伴侣确认");
}
if (svc.getCompanionUserId() == null || !svc.getCompanionUserId().equals(companionUserId)) {
throw new IllegalStateException("您不是该服务的受邀伴侣");
}
if (svc.getUserConfirmedAt() != null) {
return svc; // 幂等:已确认则直接返回
}
svc.setUserConfirmedAt(Instant.now());
mapper.updateById(svc);
return svc;
}
// ==================== 脱敏 / 内部工具 ====================
/** 列表/摘要场景:剥离 followups 与正文,仅保留必要字段(INV-013) */
public static void sanitize(CounselingServiceRecord svc) {
if (svc == null) return;
svc.setUserRequestSummary(null);
svc.setFollowups(null);
}
private CounselingServiceRecord attachFollowups(CounselingServiceRecord svc) {
// INV-013:仅完整视图路径才加载 followups 正文
svc.setFollowups(followupMapper.findByServiceId(svc.getId()));
return svc;
}
/** INV-012: 断言 counselorId 是 assigned_operator_id */
private CounselingServiceRecord assertAssigned(String serviceId, String counselorId) {
@@ -164,11 +388,35 @@ public class CounselingService {
return svc;
}
/** 合法状态迁移断言 */
private void assertTransition(String from, String to) {
Set<String> allowed = ALLOWED_TRANSITIONS.getOrDefault(from, Set.of());
if (!allowed.contains(to)) {
throw new IllegalStateException("非法的状态迁移: " + from + " → " + to);
}
}
/** service_type → 标签名 */
public static String typeLabel(String serviceType) {
return Map.of(
"counseling", "婚恋咨询",
"premarital", "婚前辅导"
"relationship_guidance", "恋爱困惑指导",
"emotional_support", "情感支持",
"premarital_guidance", "婚前辅导",
"anti_fraud_guidance", "婚恋反诈指导"
).getOrDefault(serviceType, "公益服务");
}
/** P13A-T05: 咨询介绍(原 CounselingServiceApp.getServiceIntro) */
public List<Map<String, Object>> getServiceIntro() {
return List.of(
Map.of("type", "relationship_guidance", "name", "恋爱困惑指导",
"description", "针对恋爱中的困惑、沟通问题提供专业指导"),
Map.of("type", "emotional_support", "name", "情感支持",
"description", "为情感困扰、焦虑情绪提供陪伴式倾听与疏导"),
Map.of("type", "premarital_guidance", "name", "婚前辅导",
"description", "为即将步入婚姻的情侣提供婚前沟通与相处指导,可邀请伴侣共同参与"),
Map.of("type", "anti_fraud_guidance", "name", "婚恋反诈指导",
"description", "提供婚恋诈骗识别与防范指导")
);
}
}
@@ -1,292 +0,0 @@
package com.danyang.match.platform.counseling.service;
import com.danyang.match.platform.counseling.dto.CounselingDTO;
import com.danyang.match.platform.counseling.dto.CounselingRequest;
import com.danyang.match.platform.counseling.dto.FollowupDTO;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 公益婚恋咨询服务
* V1.1 Phase 13A
* INV-012: Counselor只能访问被分配的服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CounselingServiceApp {
private final CounselingServiceMapper counselingMapper;
private final CounselingFollowupMapper followupMapper;
private final OperatorMapper operatorMapper;
private final NotificationService notificationService;
private final UserMapper userMapper;
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
private final DataScopeGuard dataScopeGuard;
/** P13A-T05: 获取咨询介绍 */
public List<Map<String, Object>> getServiceIntro() {
List<Map<String, Object>> services = new ArrayList<>();
services.add(Map.of("type", "relationship_guidance", "name", "恋爱困惑指导",
"description", "针对恋爱中的困惑、沟通问题提供专业指导"));
services.add(Map.of("type", "emotional_support", "name", "情感支持",
"description", "为情感困扰、焦虑情绪提供心理支持"));
services.add(Map.of("type", "premarital_guidance", "name", "婚前辅导",
"description", "为即将步入婚姻的情侣提供婚前指导,可邀请伴侣共同参与"));
services.add(Map.of("type", "anti_fraud_guidance", "name", "婚恋反诈指导",
"description", "提供婚恋诈骗识别与防范指导"));
return services;
}
/** P13A-T06: 用户申请咨询 */
@Transactional
public CounselingDTO createCounseling(String userId, CounselingRequest request) {
CounselingServiceRecord cs = new CounselingServiceRecord();
cs.setId(UUID.randomUUID().toString());
cs.setUserId(userId);
cs.setServiceType(request.getServiceType());
cs.setRequestSource(request.getRequestSource() != null ? request.getRequestSource() : "user_self");
cs.setUserRequestSummary(request.getUserRequestSummary());
cs.setPreferredServiceMode(request.getPreferredServiceMode());
cs.setPreferredTime(request.getPreferredTime());
cs.setCompanionUserId(request.getCompanionUserId());
cs.setStatus(Constants.COUNSELING_STATUS_PENDING);
cs.setCreatedByOperatorId(request.getCreatedByOperatorId());
cs.setCreatedAt(Instant.now());
counselingMapper.insert(cs);
// P17-T01: 咨询内容风险检测
try {
String riskLevel = riskReferralService.detectCounselingRisk(userId, request.getUserRequestSummary());
if ("high".equals(riskLevel)) {
log.warn("咨询内容触发高危风险: userId={}, summary={}", userId, request.getUserRequestSummary());
}
} catch (Exception e) {
log.warn("咨询风险检测异常: {}", e.getMessage());
}
try {
// Skip notification for operator users (operators are not in users table)
User platformUser = userMapper.findById(userId).orElse(null);
if (platformUser != null) {
notificationService.sendCounselingAssigned(userId, cs.getId());
} else {
log.info("跳过咨询通知: userId={} 不是平台用户", userId);
}
} catch (Exception e) {
log.warn("发送咨询通知失败(不影响主流程): {}", e.getMessage());
}
return toDTO(cs);
}
/** P13A-T07: 获取我的咨询列表 */
public List<CounselingDTO> getMyCounselings(String userId) {
return counselingMapper.findByUserId(userId).stream().map(this::toDTO).toList();
}
/** P13A-T08: 获取咨询详情(用户只能查看自己的) */
public CounselingDTO getCounselingDetail(String counselingId, String viewerUserId) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) return null;
if (!viewerUserId.equals(cs.getUserId())) {
throw new RuntimeException("无权查看该咨询记录");
}
return toDTO(cs);
}
/** P13A-T09: 取消咨询 */
@Transactional
public CounselingDTO cancelCounseling(String counselingId, String userId) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null || !userId.equals(cs.getUserId())) {
throw new RuntimeException("咨询不存在或无权操作");
}
if (!"pending".equals(cs.getStatus()) && !"assigned".equals(cs.getStatus())) {
throw new RuntimeException("当前状态不允许取消");
}
cs.setStatus(Constants.COUNSELING_STATUS_CANCELLED);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
// ---- 后台管理方法 ----
/** P13A-T19: 待分配队列 */
public List<CounselingDTO> getPendingQueue() {
return counselingMapper.findPendingAll().stream().map(this::toDTO).toList();
}
/** P13A-T21: 分配咨询师 */
@Transactional
public CounselingDTO assignCounselor(String counselingId, String operatorId, String counselorId) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
cs.setStatus(Constants.COUNSELING_STATUS_ASSIGNED);
cs.setAssignedOperatorId(counselorId);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
log.info("分配咨询师: counselingId={}, counselorId={}", counselingId, counselorId);
return toDTO(cs);
}
/** P13A-T22: 安排时间 */
@Transactional
public CounselingDTO scheduleCounseling(String counselingId, OperatorPrincipal principal, Instant startAt, Instant endAt) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
assertCanProcess(cs, principal);
cs.setStatus(Constants.COUNSELING_STATUS_SCHEDULED);
cs.setScheduledStartAt(startAt);
cs.setScheduledEndAt(endAt);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
// Phase 11:管理端排期同样需要触达用户;婚前辅导与婚恋咨询分开通知类型。
// 通知失败不回滚排期事实(send 内部对 sendExternal 已做隔离)。
try {
String time = startAt + " ~ " + endAt;
if ("premarital".equals(cs.getServiceType())) {
notificationService.premaritalScheduled(cs.getUserId(), counselingId, time);
} else {
notificationService.counselingScheduled(cs.getUserId(), counselingId, time);
}
} catch (Exception e) {
log.warn("排期通知发送失败: counselingId={}, err={}", counselingId, e.getMessage());
}
return toDTO(cs);
}
/** P13A-T23: 开始咨询 */
@Transactional
public CounselingDTO startCounseling(String counselingId, OperatorPrincipal principal) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
assertCanProcess(cs, principal);
cs.setStatus(Constants.COUNSELING_STATUS_IN_PROGRESS);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
/** P13A-T24: 添加跟进记录 */
@Transactional
public FollowupDTO addFollowup(String counselingId, OperatorPrincipal principal,
String followupType, String content,
String resultCode, Instant nextFollowupAt) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
assertCanProcess(cs, principal);
CounselingFollowup fu = new CounselingFollowup();
fu.setId(UUID.randomUUID().toString());
fu.setCounselingId(counselingId);
fu.setOperatorId(principal.id());
fu.setFollowupType(followupType);
fu.setContent(content);
fu.setResultCode(resultCode);
fu.setNextFollowupAt(nextFollowupAt);
fu.setCreatedAt(Instant.now());
followupMapper.insert(fu);
return toFollowupDTO(fu);
}
/** P13A-T25: 完成咨询 */
@Transactional
public CounselingDTO completeCounseling(String counselingId, OperatorPrincipal principal) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
assertCanProcess(cs, principal);
cs.setStatus(Constants.COUNSELING_STATUS_COMPLETED);
cs.setCompletedAt(Instant.now());
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
try {
notificationService.sendCounselingCompleted(cs.getUserId(), cs.getId());
} catch (Exception e) {
log.warn("发送完成通知失败: {}", e.getMessage());
}
return toDTO(cs);
}
/** P13A-T26: 关闭咨询 */
@Transactional
public CounselingDTO closeCounseling(String counselingId, OperatorPrincipal principal, String reason) {
CounselingServiceRecord cs = counselingMapper.selectById(counselingId);
if (cs == null) throw new RuntimeException("咨询不存在");
assertCanProcess(cs, principal);
cs.setStatus(Constants.COUNSELING_STATUS_CLOSED);
cs.setClosedReason(reason);
cs.setUpdatedAt(Instant.now());
counselingMapper.updateById(cs);
return toDTO(cs);
}
/** P13A-T27: Counselor只获取被分配的服务(INV-012) */
public List<CounselingDTO> getCounselorAssignedServices(String operatorId) {
return counselingMapper.findByAssignedOperator(operatorId).stream().map(this::toDTO).toList();
}
private CounselingDTO toDTO(CounselingServiceRecord cs) {
CounselingDTO dto = new CounselingDTO();
dto.setId(cs.getId());
dto.setUserId(cs.getUserId());
dto.setServiceType(cs.getServiceType());
dto.setRequestSource(cs.getRequestSource());
dto.setUserRequestSummary(cs.getUserRequestSummary());
dto.setStatus(cs.getStatus());
dto.setAssignedOperatorId(cs.getAssignedOperatorId());
dto.setPreferredServiceMode(cs.getPreferredServiceMode());
dto.setPreferredTime(cs.getPreferredTime());
dto.setScheduledStartAt(cs.getScheduledStartAt());
dto.setScheduledEndAt(cs.getScheduledEndAt());
dto.setCompanionUserId(cs.getCompanionUserId());
dto.setCompletedAt(cs.getCompletedAt());
dto.setClosedReason(cs.getClosedReason());
dto.setCreatedAt(cs.getCreatedAt());
dto.setUpdatedAt(cs.getUpdatedAt());
if (cs.getAssignedOperatorId() != null) {
Operator op = operatorMapper.selectById(cs.getAssignedOperatorId());
if (op != null) dto.setAssignedOperatorName(op.getName());
}
return dto;
}
private void assertCanProcess(CounselingServiceRecord counseling, OperatorPrincipal principal) {
boolean counselingManager = principal.hasPermission("counseling.service.assign");
if (!counselingManager && !principal.id().equals(counseling.getAssignedOperatorId())) {
dataScopeGuard.deny(principal.id(), "counseling_service", counseling.getId(),
"只能处理分配给自己的咨询服务");
}
}
private FollowupDTO toFollowupDTO(CounselingFollowup fu) {
FollowupDTO dto = new FollowupDTO();
dto.setId(fu.getId());
dto.setFollowupType(fu.getFollowupType());
dto.setContent(fu.getContent());
dto.setResultCode(fu.getResultCode());
dto.setNextFollowupAt(fu.getNextFollowupAt());
dto.setCreatedAt(fu.getCreatedAt());
return dto;
}
}
@@ -0,0 +1,58 @@
-- ============================================================
-- V039: Phase 13A 婚恋咨询 —— 补 counseling 幂等键 + 扩展 request_source 白名单
--
-- 背景(Phase 13A REFACTOR/EXTEND):
-- 1) 计划项 2 要求「申请接口补幂等」—— 客户端重复提交(网络重试 / 用户连点)
-- 必须收敛为一条记录。此前 counseling_services 无幂等键列,
-- 纯靠 Service 层 SELECT-then-INSERT,并发下会写入重复申请。
-- 2) request_source 白名单需要容纳「内容/活动后续跟进」等既有语义来源。
--
-- 幂等语义:同一 user_id + 同一 idempotency_key 只允许一条有效记录。
-- 用 partial unique index 表达(仅约束 idempotency_key 非空的行),
-- 与 V036 的通知幂等索引同一手法。
-- ⚠️ H2 不支持 partial index,测试库 schema-h2.sql 用普通 UNIQUE 表达
-- (PG/H2 的标准 UNIQUE 均允许多行 NULL 共存,语义等价)。
--
-- 全部语句幂等,可重复执行。
-- ============================================================
-- ---------- 1) 幂等键列 ----------
ALTER TABLE counseling_services ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128);
-- 同一用户 + 同一幂等键唯一(仅约束非空值)
CREATE UNIQUE INDEX IF NOT EXISTS uk_counseling_idem
ON counseling_services (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- ---------- 2) request_source 白名单(保持与 V022 一致,显式重述以防漂移) ----------
-- V022 已建立 chk_request_source,此处按既有惯例 (V018/V020/V025/V038)
-- 用 DROP + 重加的方式显式固化「最终有效」白名单,使守卫解析结果稳定。
ALTER TABLE counseling_services DROP CONSTRAINT IF EXISTS chk_request_source;
ALTER TABLE counseling_services ADD CONSTRAINT chk_request_source
CHECK (request_source IN (
'user_self', 'matchmaker_referral', 'activity_followup',
'service_station', 'operator_assisted'
));
-- ---------- 3) 状态白名单(同上,显式固化合法状态集合) ----------
ALTER TABLE counseling_services DROP CONSTRAINT IF EXISTS chk_counseling_status;
ALTER TABLE counseling_services ADD CONSTRAINT chk_counseling_status
CHECK (status IN (
'pending', 'assigned', 'scheduled', 'in_progress',
'completed', 'cancelled', 'closed'
));
-- ---------- 4) service_type 白名单(同上) ----------
ALTER TABLE counseling_services DROP CONSTRAINT IF EXISTS chk_counseling_service_type;
ALTER TABLE counseling_services ADD CONSTRAINT chk_counseling_service_type
CHECK (service_type IN (
'relationship_guidance', 'emotional_support',
'premarital_guidance', 'anti_fraud_guidance'
));
-- ---------- 5) 伴侣确认语义(INV-005)----------
-- user_confirmed_at 由**伴侣本人**写入,非工作人员代填。
COMMENT ON COLUMN counseling_services.user_confirmed_at
IS 'INV-005: 婚前辅导受邀伴侣本人的确认时间,仅本人可写,工作人员不得代确认';
COMMENT ON COLUMN counseling_services.idempotency_key
IS '客户端幂等键;同一 user_id + idempotency_key 唯一';
@@ -0,0 +1,255 @@
package com.danyang.match.platform.counseling;
import com.danyang.match.platform.counseling.service.CounselingService;
import org.junit.jupiter.api.*;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 13A:counseling 模块「代码常量 ⊆ 生产 CHECK 白名单」守卫。
*
* <p><b>核心纪律(Phase 13 确立的第四层测试保真度)</b>:
* 守卫必须**直接解析 {@code db/migration/V*.sql} 原文**,绝不能在测试里抄一份白名单常量。
* 抄副本的守卫只证明"今天抄的和昨天抄的一样",对 DDL 漂移**零反应** ——
* Phase 13 首版就是这么写的,注入真实缺陷后依然全绿,属于**永不失效的假守卫**。
*
* <p>解析规则:约束扩展在本项目是既有惯例(V018/V020/V025/V038/V039),
* 一律 {@code DROP CONSTRAINT IF EXISTS} + {@code ADD CONSTRAINT}。
* 因此解析器**必须按文件名顺序**依次应用这两条语句,后者覆盖前者,
* 最终得到的那份才是"最终有效"白名单。
*
* <p>每个守卫都配一个**解析器自检**用例:若解析失效(返回 null/空),
* 守卫会立刻失败,而不是"因为没解析到所以断言恒真"。
*/
class CounselingConstraintGuardTest {
/**
* 迁移目录可能出现的候选相对后缀(相对 CWD)。
*
* <p><b>踩坑记录(Phase 13A 实测)</b>:本类首版直接写死
* {@code Path.of("src/main/resources/db/migration")},结果 5 个用例**全部因解析不到约束而失败**。
* 根因是 {@code pom.xml} 里 surefire 显式配置了
* {@code <workingDirectory>${project.basedir}/..</workingDirectory>} ——
* 测试进程的 CWD 是**仓库根**({@code 公益婚恋服务平台/})而**不是** {@code backend/}。
*
* <p>更隐蔽的是:{@code backend/} 是 CWD 的**子目录**而非祖先,
* 所以"只向上回溯找 {@code src/...}"也找不到(实测报错 CWD=/proj)。
* 必须同时尝试 {@code backend/src/...}。
*
* <p>教训:守卫类测试**绝不能假定 CWD**;定位失败要**大声失败**,
* 绝不能静默返回 null 让守卫变成"恒真假绿"。
*/
private static final List<String> MIGRATION_CANDIDATES = List.of(
"src/main/resources/db/migration",
"backend/src/main/resources/db/migration"
);
/** 按文件名顺序解析出的「约束名 → 最终有效取值集合」缓存 */
private static final Map<String, Set<String>> CACHE = new HashMap<>();
/**
* 定位 {@code db/migration} 目录:从 CWD 起逐级向上,每一级都尝试
* {@link #MIGRATION_CANDIDATES} 里的所有候选后缀。
*/
private static Path locateMigrationDir() {
Path cur = Path.of("").toAbsolutePath().normalize();
List<String> tried = new ArrayList<>();
for (int i = 0; i < 6 && cur != null; i++) {
for (String rel : MIGRATION_CANDIDATES) {
Path candidate = cur.resolve(rel);
tried.add(candidate.toString());
if (Files.isDirectory(candidate)) return candidate;
}
cur = cur.getParent();
}
throw new IllegalStateException(
"守卫无法定位迁移目录;CWD=" + Path.of("").toAbsolutePath()
+ ";已尝试:\n - " + String.join("\n - ", tried)
+ "\n守卫必须解析**真实** DDL 原文,定位失败即视为测试基建错误,不得跳过。");
}
// ==================== 解析器自检 ====================
@Test
@DisplayName("解析器自检: 必须真的解析到 counseling 相关约束(防止守卫因解析失效而恒真)")
void parserMustResolveCounselingConstraints() {
// 先证明"确实读到了迁移目录、且读到了足够多的文件"——
// 否则"解析不到约束"到底是 DDL 真没有、还是路径没找对,无法区分。
Path dir = locateMigrationDir();
long fileCount;
try (Stream<Path> s = Files.list(dir)) {
fileCount = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")).count();
} catch (IOException e) {
throw new IllegalStateException("列目录失败: " + dir, e);
}
assertTrue(fileCount >= 30,
"解析到的迁移文件过少(" + fileCount + "),目录可能选错了: " + dir);
for (String c : List.of("chk_counseling_service_type", "chk_counseling_status", "chk_request_source")) {
Set<String> vals = resolveCheckValues(c);
assertNotNull(vals, "解析器失效:未解析到约束 " + c + "(dir=" + dir + ", files=" + fileCount + ")");
assertFalse(vals.isEmpty(), "解析器失效:约束 " + c + " 白名单为空");
}
}
/** 读取生产白名单;为 null 时抛出可读的失败信息,避免下游 NPE 掩盖真实原因。 */
private static Set<String> requireWhitelist(String constraintName) {
Set<String> allowed = resolveCheckValues(constraintName);
assertNotNull(allowed,
"守卫失效:未从生产迁移解析到 " + constraintName
+ "(迁移目录=" + locateMigrationDir() + ")。"
+ "若该约束确实不存在,说明 DDL 丢了硬约束,应作为缺陷修复而不是放宽守卫。");
return allowed;
}
// ==================== 守卫 ====================
@Test
@DisplayName("守卫: CounselingService 的 service_type 常量 ⊆ 生产 chk_counseling_service_type 白名单")
void serviceTypeConstantsWithinProductionWhitelist() throws Exception {
Set<String> allowed = requireWhitelist("chk_counseling_service_type");
Set<String> codeValues = readStringSetField(CounselingService.class, "VALID_SERVICE_TYPES");
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
Set<String> violations = new TreeSet<>(codeValues);
violations.removeAll(allowed);
assertTrue(violations.isEmpty(),
"代码写入的 service_type 不在生产 CHECK 白名单内,真实 PG 上必然失败:\n - "
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
}
@Test
@DisplayName("守卫: CounselingService 的 request_source 常量 ⊆ 生产 chk_request_source 白名单")
void requestSourceConstantsWithinProductionWhitelist() throws Exception {
Set<String> allowed = requireWhitelist("chk_request_source");
Set<String> codeValues = readStringSetField(CounselingService.class, "VALID_REQUEST_SOURCES");
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
Set<String> violations = new TreeSet<>(codeValues);
violations.removeAll(allowed);
assertTrue(violations.isEmpty(),
"代码写入的 request_source 不在生产 CHECK 白名单内:\n - "
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
}
@Test
@DisplayName("守卫: 状态机 ALLOWED_TRANSITIONS 的状态取值 ⊆ 生产 chk_counseling_status 白名单")
void statusMachineWithinProductionWhitelist() throws Exception {
Set<String> allowed = requireWhitelist("chk_counseling_status");
// 状态机的 key(from)与 value(to)都必须落在白名单内
@SuppressWarnings("unchecked")
Map<String, Set<String>> machine =
(Map<String, Set<String>>) readField(CounselingService.class, "ALLOWED_TRANSITIONS");
assertNotNull(machine, "解析不到状态机常量,守卫失效");
assertFalse(machine.isEmpty());
Set<String> used = new TreeSet<>();
machine.forEach((from, tos) -> {
used.add(from);
used.addAll(tos);
});
Set<String> violations = new TreeSet<>(used);
violations.removeAll(allowed);
assertTrue(violations.isEmpty(),
"状态机使用了不在生产 CHECK 白名单内的状态值:\n - "
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
}
@Test
@DisplayName("守卫: 状态机必须覆盖全部合法状态,且终态不可再迁移")
void statusMachineCoversAllStates() throws Exception {
Set<String> allowed = requireWhitelist("chk_counseling_status");
@SuppressWarnings("unchecked")
Map<String, Set<String>> machine =
(Map<String, Set<String>>) readField(CounselingService.class, "ALLOWED_TRANSITIONS");
Set<String> missing = new TreeSet<>(allowed);
missing.removeAll(machine.keySet());
assertTrue(missing.isEmpty(), "状态机未声明这些合法状态的迁移规则:" + missing);
// 终态:completed / cancelled / closed 不得再有出边
for (String terminal : List.of("completed", "cancelled", "closed")) {
assertTrue(machine.getOrDefault(terminal, Set.of()).isEmpty(),
terminal + " 应为终态,不得再迁移到其它状态");
}
}
// ==================== 解析实现 ====================
/**
* 按文件名顺序解析 db/migration/V*.sql,返回某 CHECK 约束的「最终有效」取值集合。
*
* 处理两类语句(本项目既有惯例):
* <pre>
* ALTER TABLE x DROP CONSTRAINT IF EXISTS chk_foo;
* ALTER TABLE x ADD CONSTRAINT chk_foo CHECK (col IN ('a','b','c'));
* </pre>
* 后者覆盖前者。
*/
private static Set<String> resolveCheckValues(String constraintName) {
if (CACHE.containsKey(constraintName)) return CACHE.get(constraintName);
Set<String> result = null;
try {
Path dir = locateMigrationDir();
List<Path> files;
try (Stream<Path> s = Files.list(dir)) {
files = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
.toList();
}
for (Path f : files) {
String sql = Files.readString(f, StandardCharsets.UTF_8);
// 归一化空白,便于跨行匹配
String flat = sql.replaceAll("\\s+", " ");
// DROP CONSTRAINT IF EXISTS chk_foo
Pattern drop = Pattern.compile(
"DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + Pattern.quote(constraintName),
Pattern.CASE_INSENSITIVE);
if (drop.matcher(flat).find()) {
// 记录"已被 drop",但真正的生效值由后续 ADD 决定;若之后没有 ADD,则为空
result = null;
}
// ADD CONSTRAINT chk_foo CHECK (col IN ('a','b',...))
Pattern add = Pattern.compile(
"ADD\\s+CONSTRAINT\\s+" + Pattern.quote(constraintName)
+ "\\s+CHECK\\s*\\([^)]*IN\\s*\\(([^)]*)\\)",
Pattern.CASE_INSENSITIVE);
Matcher m = add.matcher(flat);
if (m.find()) {
Set<String> vals = new TreeSet<>();
Matcher lit = Pattern.compile("'([^']*)'").matcher(m.group(1));
while (lit.find()) vals.add(lit.group(1));
result = vals.isEmpty() ? null : vals;
}
}
} catch (IOException e) {
throw new IllegalStateException("解析迁移文件失败: " + e.getMessage(), e);
}
CACHE.put(constraintName, result);
return result;
}
// ==================== 反射读常量 ====================
private static Object readField(Class<?> type, String name) throws Exception {
Field f = type.getDeclaredField(name);
f.setAccessible(true);
return f.get(null);
}
@SuppressWarnings("unchecked")
private static Set<String> readStringSetField(Class<?> type, String name) throws Exception {
return (Set<String>) readField(type, name);
}
}
@@ -1,93 +0,0 @@
package com.danyang.match.platform.counseling;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.risk.service.RiskReferralService;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CounselingDataScopeTest {
@Mock CounselingServiceMapper counselingMapper;
@Mock CounselingFollowupMapper followupMapper;
@Mock OperatorMapper operatorMapper;
@Mock NotificationService notificationService;
@Mock UserMapper userMapper;
@Mock RiskReferralService riskReferralService;
@Mock AuditLogMapper auditLogMapper;
private CounselingServiceApp service;
@BeforeEach
void setUp() {
service = new CounselingServiceApp(counselingMapper, followupMapper, operatorMapper,
notificationService, userMapper, riskReferralService, new DataScopeGuard(auditLogMapper));
}
@Test
void counselorCannotScheduleServiceAssignedToAnotherCounselor() {
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
assertThrows(AccessDeniedException.class, () -> service.scheduleCounseling(
"service-1", new OperatorPrincipal("counselor-b", java.util.Set.of("counseling.service.process")), Instant.parse("2026-09-10T01:00:00Z"),
Instant.parse("2026-09-10T02:00:00Z")));
verify(counselingMapper, never()).updateById(any());
verify(auditLogMapper).insert(argThat(audit ->
"DATA_SCOPE_DENIED".equals(audit.getAction())
&& "counselor-b".equals(audit.getOperatorId())));
}
@Test
void assignedCounselorCanScheduleOwnService() {
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
var result = service.scheduleCounseling("service-1", new OperatorPrincipal("counselor-a", java.util.Set.of("counseling.service.process")),
Instant.parse("2026-09-10T01:00:00Z"), Instant.parse("2026-09-10T02:00:00Z"));
assertEquals("scheduled", result.getStatus());
verify(counselingMapper).updateById(counseling);
}
@Test
void assignedListDoesNotExposeSensitiveFollowupContentByDefault() {
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.findByAssignedOperator("counselor-a")).thenReturn(List.of(counseling));
var result = service.getCounselorAssignedServices("counselor-a");
assertEquals(1, result.size());
assertTrue(result.getFirst().getFollowups() == null || result.getFirst().getFollowups().isEmpty());
verifyNoInteractions(followupMapper);
}
private CounselingServiceRecord assignedService(String id, String operatorId) {
CounselingServiceRecord service = new CounselingServiceRecord();
service.setId(id);
service.setUserId("user-1");
service.setAssignedOperatorId(operatorId);
service.setStatus("assigned");
service.setCreatedAt(Instant.now());
return service;
}
}
@@ -0,0 +1,430 @@
package com.danyang.match.platform.counseling;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.counseling.service.CounselingService;
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.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 13A:婚恋咨询与婚前辅导 —— 真实运行时路径测试。
*
* <p>覆盖计划 6 项与 Gate:「申请→分配→预约→服务→完成→反馈」真实闭环 +
* 所有隐私负向用例(INV-012 / INV-013 / INV-005)。
*
* <p>测试保真度(第四层,Phase 13 确立):本测试走**真实 Service 方法**与**真实落库**,
* 且测试库 {@code schema-h2.sql} 已补齐 counseling 三表的生产 CHECK 约束
* (V022 + V039),因此状态机越界、非法 service_type 等错误会在测试期即暴露。
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class CounselingPhase13ATest {
@Autowired private CounselingService counselingService;
@Autowired private CounselingServiceMapper mapper;
private static final String USER = "u-counsel-1";
private static final String COMPANION = "u-companion-1";
private static final String COUNSELOR_A = "op-counselor-a";
private static final String COUNSELOR_B = "op-counselor-b";
// ==================== Gate:申请 → 分配 → 预约 → 服务 → 完成 → 反馈 真实闭环 ====================
@Test
@DisplayName("Gate: 申请→分配→排期→开始→跟进→完成 全链路真实闭环")
void fullLifecycleClosedLoop() {
// 1. 申请
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "最近沟通上有些困惑",
"user_self", "online", "工作日晚间", null, null);
assertEquals("pending", svc.getStatus());
assertEquals(USER, svc.getUserId());
// 2. 分配
CounselingServiceRecord assigned = counselingService.assign(svc.getId(), COUNSELOR_A);
assertEquals("assigned", assigned.getStatus());
assertEquals(COUNSELOR_A, assigned.getAssignedOperatorId());
// 3. 排期
Instant start = Instant.parse("2026-10-01T10:00:00Z");
Instant end = Instant.parse("2026-10-01T11:00:00Z");
CounselingServiceRecord scheduled = counselingService.schedule(svc.getId(), COUNSELOR_A, start, end);
assertEquals("scheduled", scheduled.getStatus());
assertEquals(start, scheduled.getScheduledStartAt());
// 4. 开始服务
assertEquals("in_progress", counselingService.start(svc.getId(), COUNSELOR_A).getStatus());
// 5. 跟进(反馈)
CounselingFollowup fu = counselingService.addFollowup(
svc.getId(), COUNSELOR_A, "online_chat", "已完成首次沟通,情绪有所缓解", "ok", null);
assertNotNull(fu.getId());
// 6. 完成
CounselingServiceRecord done = counselingService.complete(svc.getId(), COUNSELOR_A);
assertEquals("completed", done.getStatus());
assertNotNull(done.getCompletedAt());
// 闭环可追溯:详情里能看到跟进记录
CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId());
assertNotNull(detail.getFollowups());
assertEquals(1, detail.getFollowups().size());
}
// ==================== 计划项 2:userId 取自认证主体 + 幂等 + 取消 + 状态迁移 ====================
@Test
@DisplayName("apply: userId 由入参(认证主体)决定,请求体无法伪造")
void applyUsesAuthenticatedPrincipalUserId() {
CounselingServiceRecord svc = counselingService.apply(
USER, "emotional_support", "有点焦虑", "user_self", "online", null, null, null);
// 落库的 userId 必须是传入的主体 ID
CounselingServiceRecord inDb = mapper.findById(svc.getId()).orElseThrow();
assertEquals(USER, inDb.getUserId());
}
@Test
@DisplayName("apply: 同一幂等键重复提交只落一条记录")
void applyIsIdempotentByKey() {
String key = "idem-key-001";
CounselingServiceRecord first = counselingService.apply(
USER, "relationship_guidance", "同一份申请", "user_self", "online", null, null, key);
CounselingServiceRecord second = counselingService.apply(
USER, "relationship_guidance", "同一份申请", "user_self", "online", null, null, key);
assertEquals(first.getId(), second.getId(), "同一幂等键必须返回同一条记录");
long count = mapper.findByUserId(USER).stream()
.filter(s -> key.equals(s.getIdempotencyKey())).count();
assertEquals(1, count, "幂等键只应落一条记录");
}
@Test
@DisplayName("apply: 非法 service_type 被拒绝(与生产 CHECK 白名单一致)")
void applyRejectsInvalidServiceType() {
assertThrows(IllegalArgumentException.class, () -> counselingService.apply(
USER, "not_a_real_type", "x", "user_self", "online", null, null, null));
}
@Test
@DisplayName("apply: 非法 request_source 被拒绝")
void applyRejectsInvalidRequestSource() {
assertThrows(IllegalArgumentException.class, () -> counselingService.apply(
USER, "relationship_guidance", "x", "hacked_source", "online", null, null, null));
}
@Test
@DisplayName("cancel: 仅申请人本人可取消,他人取消被拒")
void onlyOwnerCanCancel() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "待取消", "user_self", "online", null, null, null);
assertThrows(IllegalStateException.class,
() -> counselingService.cancel(svc.getId(), "someone-else", "越权取消"));
CounselingServiceRecord cancelled = counselingService.cancel(svc.getId(), USER, "不需要了");
assertEquals("cancelled", cancelled.getStatus());
}
@Test
@DisplayName("状态迁移: 已完成的服务不可再取消(非法迁移被拒)")
void completedServiceCannotBeCancelled() {
CounselingServiceRecord svc = seedCompleted();
assertThrows(IllegalStateException.class,
() -> counselingService.cancel(svc.getId(), USER, "事后反悔"));
}
@Test
@DisplayName("状态迁移: 未分配的服务不可直接排期")
void pendingServiceCannotBeScheduled() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "未分配", "user_self", "online", null, null, null);
assertThrows(IllegalStateException.class, () -> counselingService.schedule(
svc.getId(), COUNSELOR_A,
Instant.parse("2026-10-01T10:00:00Z"), Instant.parse("2026-10-01T11:00:00Z")));
}
@Test
@DisplayName("状态迁移: 排期时间必须 end > start")
void scheduleRequiresValidRange() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "时间校验", "user_self", "online", null, null, null);
counselingService.assign(svc.getId(), COUNSELOR_A);
assertThrows(IllegalArgumentException.class, () -> counselingService.schedule(
svc.getId(), COUNSELOR_A,
Instant.parse("2026-10-01T11:00:00Z"), Instant.parse("2026-10-01T10:00:00Z")));
}
// ==================== 计划项 3:分配并发安全 ====================
@Test
@DisplayName("assign: 并发分配同一单 —— 第二个分配者被明确拒绝,不覆盖第一个")
void concurrentAssignDoesNotSilentlyOverwrite() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "并发分配", "user_self", "online", null, null, null);
CounselingServiceRecord first = counselingService.assign(svc.getId(), COUNSELOR_A);
assertEquals(COUNSELOR_A, first.getAssignedOperatorId());
// 第二次分配必须失败(条件更新 WHERE status='pending' 未命中)
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> counselingService.assign(svc.getId(), COUNSELOR_B));
assertTrue(ex.getMessage().contains("已被分配"), "错误信息应说明冲突原因");
// 关键:第一个分配者的归属**未被覆盖**
CounselingServiceRecord inDb = mapper.findById(svc.getId()).orElseThrow();
assertEquals(COUNSELOR_A, inDb.getAssignedOperatorId(), "不得静默覆盖先到者的分派");
}
// ==================== INV-012:Counselor 只能读写 Assigned Service ====================
@Test
@DisplayName("INV-012: 非 assigned Counselor 不能排期他人服务")
void nonAssignedCounselorCannotSchedule() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "归属校验", "user_self", "online", null, null, null);
counselingService.assign(svc.getId(), COUNSELOR_A);
assertThrows(IllegalStateException.class, () -> counselingService.schedule(
svc.getId(), COUNSELOR_B,
Instant.parse("2026-10-01T10:00:00Z"), Instant.parse("2026-10-01T11:00:00Z")));
}
@Test
@DisplayName("INV-012: 非 assigned Counselor 不能添加跟进")
void nonAssignedCounselorCannotAddFollowup() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "跟进归属", "user_self", "online", null, null, null);
counselingService.assign(svc.getId(), COUNSELOR_A);
assertThrows(IllegalStateException.class, () -> counselingService.addFollowup(
svc.getId(), COUNSELOR_B, "phone_call", "越权写入", null, null));
}
@Test
@DisplayName("INV-012: 非 assigned Counselor 不能完成他人服务")
void nonAssignedCounselorCannotComplete() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "完成归属", "user_self", "online", null, null, null);
counselingService.assign(svc.getId(), COUNSELOR_A);
assertThrows(IllegalStateException.class,
() -> counselingService.complete(svc.getId(), COUNSELOR_B));
}
@Test
@DisplayName("INV-012: assignedTo 只返回分配给自己的服务")
void assignedListOnlyContainsOwnServices() {
CounselingServiceRecord mine = counselingService.apply(
USER, "relationship_guidance", "我的", "user_self", "online", null, null, null);
CounselingServiceRecord other = counselingService.apply(
"u-counsel-2", "emotional_support", "别人的", "user_self", "online", null, null, null);
counselingService.assign(mine.getId(), COUNSELOR_A);
counselingService.assign(other.getId(), COUNSELOR_B);
List<CounselingServiceRecord> list = counselingService.assignedTo(COUNSELOR_A);
assertTrue(list.stream().anyMatch(s -> mine.getId().equals(s.getId())));
assertFalse(list.stream().anyMatch(s -> other.getId().equals(s.getId())),
"不得返回分配给其他咨询师的服务");
}
// ==================== INV-013:不得读取咨询正文 ====================
@Test
@DisplayName("INV-013: 申请者本人可见完整正文与跟进记录")
void ownerSeesFullContent() {
CounselingServiceRecord svc = seedInProgressWithFollowup();
CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId());
assertNotNull(detail);
assertEquals("敏感正文内容", detail.getUserRequestSummary(), "本人应看到自己的申请正文");
assertNotNull(detail.getFollowups());
assertEquals(1, detail.getFollowups().size(), "本人应看到跟进记录");
}
@Test
@DisplayName("INV-013: assigned Counselor 可见完整正文与跟进记录")
void assignedCounselorSeesFullContent() {
CounselingServiceRecord svc = seedInProgressWithFollowup();
CounselingServiceRecord detail = counselingService.getDetail(COUNSELOR_A, "counselor", svc.getId());
assertNotNull(detail);
assertEquals("敏感正文内容", detail.getUserRequestSummary());
assertNotNull(detail.getFollowups());
assertEquals(1, detail.getFollowups().size());
}
@Test
@DisplayName("INV-013: 红娘只能看脱敏版 —— 无正文、无跟进记录")
void matchmakerSeesSanitizedDetail() {
CounselingServiceRecord svc = seedInProgressWithFollowup();
CounselingServiceRecord detail = counselingService.getDetail("op-matchmaker-1", "matchmaker", svc.getId());
assertNotNull(detail, "红娘应能查看必要摘要(状态/类型)");
assertNull(detail.getUserRequestSummary(), "INV-013: 红娘不得读取咨询正文");
assertNull(detail.getFollowups(), "INV-013: 红娘不得读取跟进正文");
// 必要字段仍可见,便于红娘判断转介状态
assertEquals(svc.getStatus(), detail.getStatus());
assertEquals(svc.getServiceType(), detail.getServiceType());
}
@Test
@DisplayName("INV-013: 普通 operator 只能看脱敏版")
void plainOperatorSeesSanitizedDetail() {
CounselingServiceRecord svc = seedInProgressWithFollowup();
CounselingServiceRecord detail = counselingService.getDetail("op-plain-1", "operator", svc.getId());
assertNotNull(detail);
assertNull(detail.getUserRequestSummary());
assertNull(detail.getFollowups());
}
@Test
@DisplayName("INV-013: counseling_manager 可见申请摘要但**不得**见跟进正文")
void managerSeesSummaryButNotFollowups() {
CounselingServiceRecord svc = seedInProgressWithFollowup();
CounselingServiceRecord detail = counselingService.getDetail("op-mgr-1", "counseling_manager", svc.getId());
assertNotNull(detail);
assertEquals("敏感正文内容", detail.getUserRequestSummary(), "经理需要摘要以判断分派");
assertNull(detail.getFollowups(), "INV-013: 经理不得成为阅读跟进正文的绕过通道");
}
@Test
@DisplayName("INV-013: 陌生人无权查看详情")
void strangerGetsNothing() {
CounselingServiceRecord svc = seedInProgressWithFollowup();
assertNull(counselingService.getDetail("op-total-stranger", "unknown_role", svc.getId()));
}
@Test
@DisplayName("INV-013: 列表与队列路径均不携带正文(sanitize 生效)")
void listPathsAreSanitized() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "列表脱敏验证", "user_self", "online", null, null, null);
List<CounselingServiceRecord> queue = counselingService.pendingQueue();
assertTrue(queue.stream().anyMatch(s -> svc.getId().equals(s.getId())));
// pendingQueue 返回的实体未装配 followups;Controller 层再调 sanitize 清正文
queue.forEach(CounselingService::sanitize);
queue.stream().filter(s -> svc.getId().equals(s.getId())).forEach(s -> {
assertNull(s.getUserRequestSummary(), "队列路径必须清理正文");
assertNull(s.getFollowups());
});
}
// ==================== INV-005:婚前辅导伴侣须本人确认 ====================
@Test
@DisplayName("INV-005: 受邀伴侣本人可确认参与婚前辅导")
void companionCanConfirmBySelf() {
CounselingServiceRecord svc = counselingService.apply(
USER, "premarital_guidance", "婚前辅导申请",
"user_self", "offline", null, COMPANION, null);
CounselingServiceRecord confirmed = counselingService.confirmCompanion(svc.getId(), COMPANION);
assertNotNull(confirmed.getUserConfirmedAt(), "伴侣本人确认后应写入确认时间");
}
@Test
@DisplayName("INV-005: 工作人员不得替伴侣确认(非受邀者确认被拒)")
void staffCannotConfirmOnBehalfOfCompanion() {
CounselingServiceRecord svc = counselingService.apply(
USER, "premarital_guidance", "婚前辅导申请",
"user_self", "offline", null, COMPANION, null);
// 工作人员(operator)尝试确认 → 拒绝
assertThrows(IllegalStateException.class,
() -> counselingService.confirmCompanion(svc.getId(), "op-staff-1"),
"INV-005: 任何工作人员不得替用户确认");
// 申请人自己也不能替伴侣确认
assertThrows(IllegalStateException.class,
() -> counselingService.confirmCompanion(svc.getId(), USER),
"INV-005: 申请人本人亦不能替伴侣确认");
}
@Test
@DisplayName("INV-005: 非婚前辅导服务不支持伴侣确认")
void companionConfirmOnlyForPremarital() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "普通咨询",
"user_self", "online", null, COMPANION, null);
assertThrows(IllegalStateException.class,
() -> counselingService.confirmCompanion(svc.getId(), COMPANION));
}
@Test
@DisplayName("INV-005: 伴侣确认幂等 —— 重复确认不报错、且不改变首次确认时间")
void companionConfirmIsIdempotent() {
CounselingServiceRecord svc = counselingService.apply(
USER, "premarital_guidance", "幂等确认",
"user_self", "offline", null, COMPANION, null);
Instant first = counselingService.confirmCompanion(svc.getId(), COMPANION).getUserConfirmedAt();
assertNotNull(first);
// 第二次确认必须命中短路分支(userConfirmedAt != null → 直接返回),
// 不得再次 setUserConfirmedAt(now) 覆盖首次时间。
//
// ⚠️ 精度坑:H2/PG 的 timestamp 精度低于 Instant.now() 的纳秒。
// 首次返回值来自内存实体(纳秒),二次返回值若来自 DB 回读则被截断到微秒,
// 直接 assertEquals 会因 470ns 级差异误报。因此比较"截断到毫秒"的表现值,
// 并额外断言两次返回的**时间差 < 1 秒**(真正要防的是"被覆盖成新时间")。
Instant second = counselingService.confirmCompanion(svc.getId(), COMPANION).getUserConfirmedAt();
assertNotNull(second);
assertEquals(first.truncatedTo(java.time.temporal.ChronoUnit.MILLIS),
second.truncatedTo(java.time.temporal.ChronoUnit.MILLIS),
"重复确认不应改变已写入的确认时间");
}
// ==================== 辅助 ====================
/**
* 造一条「已分配 → 进行中 → 有 1 条跟进」的记录。
*
* <p><b>踩坑记录</b>:本方法最初直接返回 {@code apply()} 的返回值。
* 但那个对象是**测试内存里的旧快照**(status 仍是 {@code pending}),
* 而 {@code getDetail(...)} 是**重新查库**得到的({@code in_progress})。
* 于是 {@code assertEquals(svc.getStatus(), detail.getStatus())} 拿"陈旧内存值"比"最新库值",
* 必然误报 —— 属于测试自身缺陷,不是生产缺陷。
* 现改为返回**重新查库**的实例,保证两侧同源。
*/
private CounselingServiceRecord seedInProgressWithFollowup() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "敏感正文内容", "user_self", "online", null, null, null);
counselingService.assign(svc.getId(), COUNSELOR_A);
counselingService.start(svc.getId(), COUNSELOR_A);
counselingService.addFollowup(svc.getId(), COUNSELOR_A, "phone_call",
"跟进正文内容(敏感)", "ok", null);
return reload(svc.getId());
}
private CounselingServiceRecord seedCompleted() {
CounselingServiceRecord svc = counselingService.apply(
USER, "relationship_guidance", "已完成服务", "user_self", "online", null, null, null);
counselingService.assign(svc.getId(), COUNSELOR_A);
counselingService.start(svc.getId(), COUNSELOR_A);
counselingService.complete(svc.getId(), COUNSELOR_A);
return reload(svc.getId());
}
/** 以申请者身份重新查库,拿到与断言侧同源的实体快照 */
private CounselingServiceRecord reload(String serviceId) {
CounselingServiceRecord fresh = counselingService.getDetail(USER, "user", serviceId);
assertNotNull(fresh, "重载失败:serviceId=" + serviceId);
return fresh;
}
}
@@ -59,7 +59,6 @@ class EntityMigrationParityTest {
"appeals",
"consent_records",
"content_categories",
"counseling_followups",
"interest_dictionary",
"match_rule_configs",
"operator_roles",
@@ -104,7 +103,11 @@ class EntityMigrationParityTest {
// 已补入 schema-h2.sql,且从 TEST_SCHEMA_OPTIONAL_TABLES 白名单移除。
"matchmaker_services",
"matchmaker_recommendations",
"matchmaker_followups"
"matchmaker_followups",
// Phase 13A:咨询两表此前 counseling_services 完全缺失、counseling_followups
// 仅在可选白名单里(= 实际上没人测)。已补入 schema-h2.sql 并移入必需集合。
"counseling_services",
"counseling_followups"
);
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
private static final Set<String> BASE_ENTITY_COLUMNS =
@@ -309,6 +312,27 @@ class EntityMigrationParityTest {
);
}
/**
* 回归守卫:{@code @TableField(exist = false)} 的**全限定写法**也必须被排除。
*
* <p>Phase 13A 实测发现的解析器缺陷:旧正则只认 {@code @TableField(...)},
* 遇到 {@code @com.baomidou.mybatisplus.annotation.TableField(exist = false)}
* 就会把非持久化字段当成真实列,随后报出「生产缺列」的**假警报**。
* 若有人为消警报而去生产加一个无用列,就是把测试保真度反噬成生产污染。
*/
@Test
@DisplayName("解析器自检:全限定 @TableField(exist=false) 必须被识别为非持久化字段")
void fullyQualifiedTableFieldExistFalseMustBeExcluded() throws IOException {
Map<String, Set<String>> cols = parseEntityColumns();
Set<String> cs = cols.get("counseling_services");
assertNotNull(cs, "未识别 CounselingServiceRecord 实体(@TableName(\"counseling_services\"))");
assertFalse(cs.contains("followups"),
"全限定 @TableField(exist = false) 未被排除 —— 非持久化字段 followups "
+ "被当成了 counseling_services 的真实列,会产生假漂移警报。"
+ "请检查 exist=false 排除正则是否覆盖全限定注解写法。");
}
// ==================== 解析实现 ====================
/** 解析 db/migration/V*.sql 的 CREATE TABLE 列定义 + ALTER TABLE ADD COLUMN。 */
@@ -452,8 +476,13 @@ class EntityMigrationParityTest {
// 所有 private 字段
Set<String> excluded = new LinkedHashSet<>();
// ⚠️ Phase 13A 加固:注解可能写成**全限定名**
// @com.baomidou.mybatisplus.annotation.TableField(exist = false)
// 旧正则只匹配简单形式 @TableField(...),导致全限定写法被漏掉 →
// 非持久化字段被误判为"生产缺列"(假警报),或反向掩盖真实漂移。
// 这里允许注解名前任意包路径:([\w.]*TableField)。
Matcher noPersist = Pattern.compile(
"@TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
"@[\\w.]*TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
+ "private\\s+[^;]*?\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*;",
Pattern.DOTALL).matcher(clean);
while (noPersist.find()) {
@@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.*;
class MigrationValidationTest {
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
private static final int LATEST_MIGRATION = 38;
private static final int LATEST_MIGRATION = 39;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -52,7 +52,18 @@ class OperatorEndpointPermissionCoverageTest {
for (Class<?> controller : CONTROLLERS) {
boolean operatorController = paths(controller).stream().anyMatch(this::isOperatorPath);
for (Method method : controller.getDeclaredMethods()) {
boolean operatorMethod = paths(method).stream().anyMatch(this::isOperatorPath);
// ⚠️ Phase 13A 修正:本测试的语义是「**每一个运营端接口**都要有显式 Permission」,
// 因此必须先确认该方法是**真正的 HTTP 端点**(带 @RequestMapping/@GetMapping/
// @PostMapping 等 → findMergedAnnotation(RequestMapping) 能取到路径)。
// 修复前会扫到**私有辅助方法**:CounselingController.roleOf(OperatorPrincipal)
// 只是把 principal 映射成角色字符串的内部工具,不是端点,也不可能加 @PreAuthorize,
// 却因"参数里含 OperatorPrincipal"被误判为未受保护的接口(假阳性)。
List<String> methodPaths = paths(method);
if (methodPaths.isEmpty()) {
continue; // 非端点(私有工具方法等)→ 不在本测试职责范围
}
boolean operatorMethod = methodPaths.stream().anyMatch(this::isOperatorPath);
boolean operatorPrincipal = Arrays.stream(method.getParameterTypes())
.anyMatch(OperatorPrincipal.class::equals);
if (!operatorController && !operatorMethod && !operatorPrincipal) {
@@ -68,6 +79,23 @@ class OperatorEndpointPermissionCoverageTest {
assertEquals(List.of(), missing, "以下运营端接口缺少显式 Permission;不能依赖 /operator/** 粗过滤器");
}
/**
* 自检:必须真的扫到了运营端接口,否则本测试可能因"一个方法都没匹配上"而恒真。
*/
@Test
void guardMustActuallyInspectOperatorEndpoints() {
long operatorEndpointCount = 0;
for (Class<?> controller : CONTROLLERS) {
for (Method method : controller.getDeclaredMethods()) {
if (paths(method).stream().anyMatch(this::isOperatorPath)) {
operatorEndpointCount++;
}
}
}
org.junit.jupiter.api.Assertions.assertTrue(operatorEndpointCount >= 10,
"扫描到的运营端接口数量异常偏少(" + operatorEndpointCount + "),守卫可能已失效");
}
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
+21 -1
View File
@@ -113,7 +113,27 @@ CREATE TABLE counseling_services (
scheduled_end_at TIMESTAMP WITH TIME ZONE, consent_version VARCHAR(32), consented_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE, closed_reason VARCHAR(500), companion_user_id VARCHAR(36),
created_by_operator_id VARCHAR(36), user_confirmed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
idempotency_key VARCHAR(128),
-- 生产约束(V022 + V039):此前测试库完全没有 CHECK → 约束漂移盲区(Phase 11/13 同族根因)
CONSTRAINT chk_counseling_service_type CHECK (service_type IN (
'relationship_guidance', 'emotional_support', 'premarital_guidance', 'anti_fraud_guidance'
)),
CONSTRAINT chk_counseling_status CHECK (status IN (
'pending', 'assigned', 'scheduled', 'in_progress', 'completed', 'cancelled', 'closed'
)),
CONSTRAINT chk_request_source CHECK (request_source IN (
'user_self', 'matchmaker_referral', 'activity_followup', 'service_station', 'operator_assisted'
)),
-- V039 幂等索引:H2 无 partial index,用标准 UNIQUE 表达(PG/H2 均允许多行 NULL 共存,语义等价)
CONSTRAINT uk_counseling_idem UNIQUE (user_id, idempotency_key)
);
-- V022 / V039: counseling_followups 表(跟进正文属 INV-013 敏感数据)
CREATE TABLE counseling_followups (
id VARCHAR(36) PRIMARY KEY, counseling_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL,
followup_type VARCHAR(32), content CLOB NOT NULL, result_code VARCHAR(64),
next_followup_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE service_program_members (
id VARCHAR(36) PRIMARY KEY, program_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32),