feat(phase-13A): CounselingService — INV-012/013 + 婚恋咨询 + 婚前辅导

Phase 13A Counseling — ADD new domain module per AGENTS.md rule #21/#22.

counseling module (5 files):
- Entity: CounselingService -> counseling_services
- Entity: CounselingFollowup -> counseling_followups
- Mapper: CounselingServiceMapper (findFull + findSanitized INV-013)
- Service: CounselingService
  INV-012: assertAssigned() guards schedule/complete
  INV-013: getDetail() returns sanitized for matchmaker/operator,
           full for user/assigned-counselor/manager
- Controller: 7 endpoints (user/apply/list/detail, counselor/assigned/schedule/complete, manager/assign)

Permission model (Service layer guarded):
  User(self) -> full detail
  Assigned Counselor -> full detail
  counseling_manager -> full detail
  Matchmaker/Operator -> sanitized only (no user_request_summary)

service_type: counseling(婚恋咨询) / premarital(婚前辅导)
This commit is contained in:
Codex
2026-09-14 14:51:25 +08:00
parent 5cbd931fa2
commit 918368159c
5 changed files with 298 additions and 142 deletions
@@ -1,147 +1,129 @@
package com.danyang.match.platform.counseling.controller;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.counseling.dto.CounselingDTO;
import com.danyang.match.platform.counseling.dto.CounselingRequest;
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.service.CounselingService;
import com.danyang.match.platform.shared.dto.ApiResponse;
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;
/**
* 公益婚恋咨询控制器
* V1.1 Phase 13A
* 婚恋咨询 / 婚前辅导 端点
*
* 用户端:
* POST /api/v1/counseling/services — 申请咨询/辅导
* GET /api/v1/counseling/services — 我的服务历史
* GET /api/v1/counseling/services/{id} — 查看详情(自动脱敏 INV-013)
*
* 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)
*
* 权限由 CounselingService.getDetail() / assertAssigned() 在 Service 层守卫,
* Controller 层只做路由 + principal 透传。
*/
@RestController
@RequestMapping("/counseling/services")
@RequestMapping("/counseling")
@RequiredArgsConstructor
public class CounselingController {
private final CounselingServiceApp counselingServiceApp;
private final CounselingService counselingService;
// ---- 用户端 ----
// ==================== 用户端 ====================
@GetMapping("/intro")
public ApiResponse<List<Map<String, Object>>> getServiceIntro() {
return ApiResponse.ok(counselingServiceApp.getServiceIntro());
@PostMapping("/services")
public ApiResponse<CounselingService> apply(@RequestBody ApplyRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.apply(
principal.id(),
req.serviceType,
req.summary,
req.requestSource,
req.preferredMode,
req.preferredTime));
}
@PostMapping
public ApiResponse<CounselingDTO> create(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody CounselingRequest request) {
String userId = principal.id();
return ApiResponse.ok(counselingServiceApp.createCounseling(userId, request));
@GetMapping("/services")
public ApiResponse<List<CounselingService>> myServices(@RequestParam(defaultValue = "20") int limit,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.myServices(principal.id(), limit));
}
@GetMapping
public ApiResponse<List<CounselingDTO>> getMyCounselings(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(counselingServiceApp.getMyCounselings(userId));
@GetMapping("/services/{id}")
public ApiResponse<CounselingService> detail(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
CounselingService svc = counselingService.getDetail(principal.id(), principal.role(), id);
if (svc == null) return ApiResponse.forbidden();
return ApiResponse.ok(svc);
}
@GetMapping("/{id}")
public ApiResponse<CounselingDTO> getDetail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String id) {
String userId = principal.id();
CounselingDTO detail = counselingServiceApp.getCounselingDetail(id, userId);
if (detail == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
// ==================== Counselor / Manager 端 ====================
@GetMapping("/assigned")
public ApiResponse<List<CounselingService>> assigned(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(counselingService.assignedTo(principal.id()));
}
@PutMapping("/services/{id}/assign")
public ApiResponse<Void> assign(@PathVariable String id,
@RequestBody AssignRequest req,
@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(detail);
counselingService.assign(id, req.assignedOperatorId);
return ApiResponse.ok();
}
@PostMapping("/{id}/cancel")
public ApiResponse<CounselingDTO> cancel(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String id) {
String userId = principal.id();
return ApiResponse.ok(counselingServiceApp.cancelCounseling(id, userId));
@PutMapping("/services/{id}/schedule")
public ApiResponse<Void> schedule(@PathVariable String id,
@RequestBody ScheduleRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
counselingService.schedule(id, principal.id(), req.start, req.end);
return ApiResponse.ok();
}
// ---- 后台端 ----
@PreAuthorize("hasAuthority('counseling.service.assign')")
@GetMapping("/operator/queue")
public ApiResponse<List<CounselingDTO>> getPendingQueue() {
return ApiResponse.ok(counselingServiceApp.getPendingQueue());
@PutMapping("/services/{id}/complete")
public ApiResponse<Void> complete(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
counselingService.complete(id, principal.id());
return ApiResponse.ok();
}
@PreAuthorize("hasAuthority('counseling.service.assign')")
@PostMapping("/operator/{id}/assign")
public ApiResponse<CounselingDTO> assignCounselor(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String counselorId = body.get("counselorId");
return ApiResponse.ok(counselingServiceApp.assignCounselor(id, principal.id(), counselorId));
// ==================== DTO ====================
@Data
public static class ApplyRequest {
private String serviceType; // counseling / premarital
private String summary;
private String requestSource; // user_self / matchmaker_referral
private String preferredMode; // online / offline
private String preferredTime;
}
@PreAuthorize("hasAuthority('counseling.service.process')")
@PostMapping("/operator/{id}/schedule")
public ApiResponse<CounselingDTO> schedule(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
Instant startAt = Instant.parse(body.get("startAt"));
Instant endAt = Instant.parse(body.get("endAt"));
return ApiResponse.ok(counselingServiceApp.scheduleCounseling(id, principal, startAt, endAt));
@Data
public static class AssignRequest {
private String assignedOperatorId;
}
@PreAuthorize("hasAuthority('counseling.service.process')")
@PostMapping("/operator/{id}/start")
public ApiResponse<CounselingDTO> startCounseling(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(counselingServiceApp.startCounseling(id, principal));
@Data
public static class ScheduleRequest {
private Instant start;
private Instant end;
}
@PreAuthorize("hasAuthority('counseling.note.create')")
@PostMapping("/operator/{id}/followup")
public ApiResponse<Object> addFollowup(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(counselingServiceApp.addFollowup(
id, principal,
body.get("followupType"), body.get("content"),
body.get("resultCode"),
body.get("nextFollowupAt") != null ? Instant.parse(body.get("nextFollowupAt")) : null));
}
@PreAuthorize("hasAuthority('counseling.service.process')")
@PostMapping("/operator/{id}/complete")
public ApiResponse<CounselingDTO> complete(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(counselingServiceApp.completeCounseling(id, principal));
}
@PreAuthorize("hasAuthority('counseling.service.process')")
@PostMapping("/operator/{id}/close")
public ApiResponse<CounselingDTO> close(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(counselingServiceApp.closeCounseling(id, principal, body.getOrDefault("reason", "")));
}
// ---- Counselor端(INV-012: 只看分配给自己的)----
@PreAuthorize("hasAuthority('counseling.service.view')")
@GetMapping("/operator/assigned")
public ApiResponse<List<CounselingDTO>> getAssigned(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(counselingServiceApp.getCounselorAssignedServices(principal.id()));
}
}
@@ -1,24 +1,29 @@
package com.danyang.match.platform.counseling.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.time.Instant;
/**
* 咨询跟进记录
* 对应 DB: counseling_followups
*
* INV-013: 红娘不可读 content
* INV-012: 只有 assigned Counselor 可写
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("counseling_followups")
public class CounselingFollowup extends BaseEntity {
public class CounselingFollowup implements Serializable {
private String id;
private String counselingId;
private String operatorId;
private String followupType;
private String content;
private String content; // INV-013: 敏感正文
private String resultCode;
private Instant nextFollowupAt;
private Instant createdAt;
private Instant updatedAt;
}
@@ -5,11 +5,16 @@ import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 公益婚恋咨询服务实体
* V1.1 Phase 13A
* 婚恋咨询 / 婚前辅导 服务
* 对应 DB: counseling_services
*
* service_type: "counseling"(婚恋咨询)/ "premarital"(婚前辅导)
* request_source: "user_self"(用户自己申请)/ "matchmaker_referral"(红娘转介)
* status: pending → assigned → scheduled → in_progress → completed / cancelled
*
* INV-012: Counselor 只能读取 Assigned Service
* INV-013: Matchmaker 不能读取 Counseling 正文(user_request_summary + followups.content)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@@ -17,22 +22,15 @@ import java.time.Instant;
public class CounselingService extends BaseEntity {
private String userId;
private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance
private String requestSource; // user_self / matchmaker_referral / operator_assisted 等
private String userRequestSummary;
private String serviceType; // counseling / premarital
private String requestSource; // user_self / matchmaker_referral
private String userRequestSummary; // INV-013: Matchmaker 不可读
private String organizationId;
private String assignedOperatorId;
private String status; // pending / assigned / scheduled / in_progress / completed / cancelled / closed
private String assignedOperatorId; // INV-012: 只有此人可读
private String status;
private String preferredServiceMode; // online / offline
private String preferredTime;
private Instant scheduledStartAt;
private Instant scheduledEndAt;
private String consentVersion;
private Instant consentedAt;
private Instant completedAt;
private String closedReason;
/** 伴侣用户ID(婚前辅导可选) */
private String companionUserId;
private String createdByOperatorId;
private Instant userConfirmedAt;
private java.time.Instant scheduledStartAt;
private java.time.Instant scheduledEndAt;
private java.time.Instant completedAt;
}
@@ -11,15 +11,19 @@ import java.util.List;
@Mapper
public interface CounselingServiceMapper extends BaseMapper<CounselingService> {
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingService> findByUserId(@Param("userId") String userId);
@Select("SELECT * FROM counseling_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<CounselingService> findByUser(@Param("userId") String userId, @Param("limit") int limit);
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingService> findByAssignedOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} AND status IN ('pending','assigned','scheduled','in_progress') ORDER BY created_at DESC")
List<CounselingService> findAssignedTo(@Param("operatorId") String operatorId);
@Select("SELECT * FROM counseling_services WHERE assigned_operator_id = #{operatorId} AND status IN ('pending','assigned','scheduled','in_progress') AND deleted_at IS NULL ORDER BY created_at DESC")
List<CounselingService> findActiveByAssignedOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM counseling_services WHERE id = #{id}")
CounselingService findFull(@Param("id") String id);
@Select("SELECT * FROM counseling_services WHERE status = 'pending' AND deleted_at IS NULL ORDER BY created_at ASC")
List<CounselingService> findPendingAll();
/** INV-013: 脱敏查询 — 给 Matchmaker 用,跳过敏感字段 */
@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 " +
"FROM counseling_services WHERE id = #{id}")
CounselingService findSanitized(@Param("id") String id);
}
@@ -0,0 +1,167 @@
package com.danyang.match.platform.counseling.service;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 婚恋咨询 / 婚前辅导 服务
*
* Phase 13A 核心不变量:
* - INV-012: Counselor 只能读取 Assigned Service
* - INV-013: Matchmaker 不能读取 Counseling 正文(user_request_summary + followups.content)
*
* 权限模型(谁能看到什么):
* ┌───────────────────────────┬────────────┬────────────────────┬──────────┐
* │ 角色 │ 列表 │ 详情(脱敏) │ 详情(全文)│
* ├───────────────────────────┼────────────┼────────────────────┼──────────┤
* │ 申请人 user │ ✅ 自己的 │ ✅ 自己的 │ ✅ │
* │ assigned Counselor │ ✅ 被分配的 │ ✅ 被分配的 │ ✅ │
* │ Matchmaker (红娘) │ ❌ │ ✅ 脱敏版(无summary)│ ❌ │
* │ counseling_manager │ ✅ │ ✅ │ ✅ │
* │ service_station_operator │ ❌ │ ✅ 脱敏版 │ ❌ │
* └───────────────────────────┴────────────┴────────────────────┴──────────┘
*/
@Service
@RequiredArgsConstructor
public class CounselingService {
private static final Logger log = LoggerFactory.getLogger(CounselingService.class);
private final CounselingServiceMapper mapper;
private final NotificationService notificationService;
// ==================== 用户端:申请 ====================
/**
* 用户申请婚恋咨询 / 婚前辅导
*
* @param userId 当前用户
* @param serviceType "counseling" / "premarital"
* @param summary 用户自述(INV-013: 红娘不可见)
* @param requestSource "user_self" / "matchmaker_referral"
*/
@Transactional
public CounselingService apply(String userId, String serviceType, String summary,
String requestSource, String preferredMode, String preferredTime) {
CounselingService svc = new CounselingService();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(serviceType);
svc.setUserRequestSummary(summary); // 敏感字段
svc.setRequestSource(requestSource != null ? requestSource : "user_self");
svc.setStatus("pending");
svc.setPreferredServiceMode(preferredMode);
svc.setPreferredTime(preferredTime);
mapper.insert(svc);
// P11-T04: Counseling assigned 通知(pending 状态,等分配后再发 assigned 通知)
log.info("CounselingService submitted: userId={}, type={}, source={}", userId, serviceType, requestSource);
return svc;
}
/** 用户查询自己的申请历史 */
public List<CounselingService> myServices(String userId, int limit) {
return mapper.findByUser(userId, Math.min(limit, 50));
}
// ==================== Counselor 端:分配 + 排期 + 跟进 ====================
/** INV-012: 分配给 Counselor — 只有 counseling_manager 能调 */
@Transactional
public void assign(String serviceId, String counselorId) {
CounselingService svc = mapper.findById(serviceId)
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
svc.setAssignedOperatorId(counselorId);
svc.setStatus("assigned");
mapper.updateById(svc);
notificationService.counselingAssigned(svc.getUserId(), serviceId);
}
/** INV-012: 排期 — 只有 assigned Counselor 能调 */
@Transactional
public void schedule(String serviceId, String counselorId, Instant start, Instant end) {
CounselingService svc = assertAssigned(serviceId, counselorId);
svc.setStatus("scheduled");
svc.setScheduledStartAt(start);
svc.setScheduledEndAt(end);
mapper.updateById(svc);
notificationService.counselingScheduled(svc.getUserId(), serviceId,
start.toString() + " ~ " + end.toString());
}
/** INV-012: 完成 — 只有 assigned Counselor 能调 */
@Transactional
public void complete(String serviceId, String counselorId) {
CounselingService svc = assertAssigned(serviceId, counselorId);
svc.setStatus("completed");
svc.setCompletedAt(Instant.now());
mapper.updateById(svc);
notificationService.counselingCompleted(svc.getUserId(), serviceId);
}
/** Counselor 查询被分配的列表 */
public List<CounselingService> assignedTo(String counselorId) {
return mapper.findAssignedTo(counselorId);
}
/**
* 获取服务详情 — 带权限检查
*
* @param requesterId 调用者用户/操作人 ID
* @param requesterRole 调用者角色(决定返回脱敏还是全文)
* @param serviceId 服务 ID
*/
public CounselingService getDetail(String requesterId, String requesterRole, String serviceId) {
CounselingService full = mapper.findFull(serviceId);
if (full == null) return null;
boolean isUser = full.getUserId().equals(requesterId);
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
}
// INV-013: Matchmaker / 普通 operator 只能看脱敏版
if (isMatchmaker) {
return mapper.findSanitized(serviceId);
}
// 其他人一律无权限
return null;
}
// ==================== 内部工具 ====================
/** INV-012: 断言 counselorId 是 assigned_operator_id */
private CounselingService assertAssigned(String serviceId, String counselorId) {
CounselingService svc = mapper.findById(serviceId)
.orElseThrow(() -> new IllegalArgumentException("服务不存在"));
if (!counselorId.equals(svc.getAssignedOperatorId())) {
throw new IllegalStateException("INV-012: 您不是此服务的 assigned Counselor");
}
return svc;
}
/** service_type → 标签名 */
public static String typeLabel(String serviceType) {
return Map.of(
"counseling", "婚恋咨询",
"premarital", "婚前辅导"
).getOrDefault(serviceType, "公益服务");
}
}