diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java b/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java index b0b0853..3cf9221 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java @@ -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>> getServiceIntro() { - return ApiResponse.ok(counselingServiceApp.getServiceIntro()); + @PostMapping("/services") + public ApiResponse 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 create( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody CounselingRequest request) { - String userId = principal.id(); - return ApiResponse.ok(counselingServiceApp.createCounseling(userId, request)); + @GetMapping("/services") + public ApiResponse> 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> getMyCounselings( - @AuthenticationPrincipal UserPrincipal principal) { - String userId = principal.id(); - return ApiResponse.ok(counselingServiceApp.getMyCounselings(userId)); + @GetMapping("/services/{id}") + public ApiResponse 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 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> assigned(@AuthenticationPrincipal UserPrincipal principal) { + if (principal == null) return ApiResponse.unauthorized(); + return ApiResponse.ok(counselingService.assignedTo(principal.id())); + } + + @PutMapping("/services/{id}/assign") + public ApiResponse 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 cancel( - @AuthenticationPrincipal UserPrincipal principal, - @PathVariable String id) { - String userId = principal.id(); - return ApiResponse.ok(counselingServiceApp.cancelCounseling(id, userId)); + @PutMapping("/services/{id}/schedule") + public ApiResponse 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> getPendingQueue() { - return ApiResponse.ok(counselingServiceApp.getPendingQueue()); + @PutMapping("/services/{id}/complete") + public ApiResponse 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 assignCounselor( - @AuthenticationPrincipal OperatorPrincipal principal, - @PathVariable String id, - @RequestBody Map 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 schedule( - @AuthenticationPrincipal OperatorPrincipal principal, - @PathVariable String id, - @RequestBody Map 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 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 addFollowup( - @AuthenticationPrincipal OperatorPrincipal principal, - @PathVariable String id, - @RequestBody Map 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 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 close( - @AuthenticationPrincipal OperatorPrincipal principal, - @PathVariable String id, - @RequestBody Map body) { - return ApiResponse.ok(counselingServiceApp.closeCounseling(id, principal, body.getOrDefault("reason", ""))); - } - - // ---- Counselor端(INV-012: 只看分配给自己的)---- - @PreAuthorize("hasAuthority('counseling.service.view')") - @GetMapping("/operator/assigned") - public ApiResponse> getAssigned( - @AuthenticationPrincipal OperatorPrincipal principal) { - return ApiResponse.ok(counselingServiceApp.getCounselorAssignedServices(principal.id())); - } - - } diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingFollowup.java b/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingFollowup.java index f66c347..0a3251e 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingFollowup.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingFollowup.java @@ -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; } diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingService.java b/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingService.java index 0b1398c..c37a25a 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingService.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingService.java @@ -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; } diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/mapper/CounselingServiceMapper.java b/backend/src/main/java/com/danyang/match/platform/counseling/mapper/CounselingServiceMapper.java index cf36537..22fa388 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/mapper/CounselingServiceMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/mapper/CounselingServiceMapper.java @@ -11,15 +11,19 @@ import java.util.List; @Mapper public interface CounselingServiceMapper extends BaseMapper { - @Select("SELECT * FROM counseling_services WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY created_at DESC") - List findByUserId(@Param("userId") String userId); + @Select("SELECT * FROM counseling_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}") + List 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 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 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 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 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); } diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java b/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java new file mode 100644 index 0000000..09d1f9e --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/counseling/service/CounselingService.java @@ -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 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 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, "公益服务"); + } +}