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:
+170
-48
@@ -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;
|
||||
}
|
||||
-18
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
|
||||
+23
-2
@@ -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);
|
||||
}
|
||||
|
||||
+289
-41
@@ -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", "提供婚恋诈骗识别与防范指导")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-292
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user