feat(phase-13): MatchmakerService — referral + manual recommendation + user confirmation

Phase 13 Matchmaker — EXTEND per JIRA. P13-V11-T01 through P13-V11-T05.

New matchmaker module (5 files):
- Entity: MatchmakerService (request), MatchmakerRecommendation (manual rec)
- Mapper: MatchmakerServiceMapper, MatchmakerRecommendationMapper
- Service: MatchmakerService
    T01 createReferral(type=counseling) — referral only, does NOT submit on behalf
    T02 createReferral(type=activity)
    T03 createReferral(type=service_station)
    T04 referral_source追溯: every referral carries operator_id + service_id + type
    T05 用户拥有最终决定权: acceptRecommendation() calls RelationService.like()
        as the USER, not the matchmaker. Matchmaker NEVER creates Match directly.
        If mutual, updates Match.source='matchmaker' for traceability.
- Controller: user endpoints (submit/list/pending-recommendations/accept/pass/viewed)

Core AGENTS.md #5 compliance:
- Matchmaker can only 'recommend', NEVER 'like on behalf of'
- User must actively accept recommendation (calls acceptRecommendation -> RelationService.like)
- createRecommendation creates pending_view record + notification, nothing else
- confirm mutual only when BOTH users independently Like (existing Like flow)

Status machines:
- matchmaker_services: submitted -> assigned -> processing -> completed
- matchmaker_recommendations: pending_view -> viewed -> liked/passed/confirmed_mutual
This commit is contained in:
Codex
2026-09-14 14:48:23 +08:00
parent 0418856ce3
commit 5cbd931fa2
6 changed files with 439 additions and 23 deletions
@@ -0,0 +1,90 @@
package com.danyang.match.platform.matchmaker.controller;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerService;
import com.danyang.match.platform.matchmaker.service.MatchmakerService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 红娘服务端点
*
* 用户端:
* POST /api/v1/matchmaker/services — 提交红娘服务请求
* GET /api/v1/matchmaker/services — 我的红娘服务历史
* GET /api/v1/matchmaker/recommendations — 待处理红娘推荐列表
* PUT /api/v1/matchmaker/recommendations/{id}/viewed
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
* POST /api/v1/matchmaker/recommendations/{id}/pass
*
* 操作人端(Matchmaker/Operator role):
* PUT /api/v1/matchmaker/services/{id}/assign
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
*/
@RestController
@RequestMapping("/matchmaker")
@RequiredArgsConstructor
public class MatchmakerController {
private final MatchmakerService matchmakerService;
// ==================== 用户端 ====================
@PostMapping("/services")
public ApiResponse<MatchmakerService> submit(@RequestBody SubmitRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), req.serviceType, req.description));
}
@GetMapping("/services")
public ApiResponse<List<MatchmakerService>> myServices(@RequestParam(defaultValue = "20") int limit,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
}
@GetMapping("/recommendations")
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(matchmakerService.pendingRecommendations(principal.id()));
}
@PutMapping("/recommendations/{id}/viewed")
public ApiResponse<Void> markViewed(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
matchmakerService.markRecommendationViewed(id, principal.id());
return ApiResponse.ok();
}
@PostMapping("/recommendations/{id}/accept")
public ApiResponse<MatchmakerService.LikeRecommendationResult> accept(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(matchmakerService.acceptRecommendation(id, principal.id()));
}
@PostMapping("/recommendations/{id}/pass")
public ApiResponse<Void> pass(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
matchmakerService.passRecommendation(id, principal.id());
return ApiResponse.ok();
}
// ==================== DTO ====================
@Data
public static class SubmitRequest {
private String serviceType;
private String description;
}
}
@@ -5,6 +5,18 @@ import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 红娘推荐(人工推荐对象)
* 对应 DB: matchmaker_recommendations
*
* status: pending_view(待用户查看)/ viewed(已查看)/ liked(用户喜欢)/ passed(用户跳过)
* confirmed_mutual(双方确认形成 Match)/ rejected / expired
*
* 核心规则(AGENTS.md #5 红娘不可替用户操作):
* - 红娘只能"推荐"不能"替用户 Like"
* - 用户必须自己决定是否接受推荐
* - confirmed_mutual 只有双方都主动 Like 后才能到达
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_recommendations")
@@ -13,10 +25,9 @@ public class MatchmakerRecommendation extends BaseEntity {
private String serviceId;
private String userId;
private String targetUserId;
private String operatorId;
private String operatorId; // 红娘/操作人
private String reason;
private String status; // pending_view, viewed, responded
private java.time.Instant createdAt;
private String status;
private java.time.Instant viewedAt;
private java.time.Instant respondedAt;
}
@@ -5,15 +5,23 @@ import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 红娘服务请求
* 对应 DB: matchmaker_services
*
* service_type: recommendation(推荐对象)/ referral_counseling(转介咨询)
* referral_activity(转介活动)/ referral_station(转介服务站)
* status: submitted → assigned → processing → completed / cancelled
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_services")
public class MatchmakerService extends BaseEntity {
private String userId;
private String serviceType; // recommend_partner, review_preference, contact_intention, join_activity, no_match_help, chat_help, other
private String serviceType;
private String description;
private String status; // submitted, assigned, in_service, waiting_user, completed, cancelled
private String status;
private String organizationId;
private String assignedOperatorId;
private java.time.Instant assignedAt;
@@ -3,13 +3,25 @@ package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
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;
@Mapper
public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRecommendation> {
List<MatchmakerRecommendation> findByServiceId(String serviceId);
@Select("SELECT * FROM matchmaker_recommendations WHERE user_id = #{userId} AND status IN ('pending_view','viewed') ORDER BY created_at DESC")
List<MatchmakerRecommendation> findPendingForUser(@Param("userId") String userId);
List<MatchmakerRecommendation> findByUserId(String userId);
@Select("SELECT * FROM matchmaker_recommendations WHERE service_id = #{serviceId} ORDER BY created_at DESC")
List<MatchmakerRecommendation> findByService(@Param("serviceId") String serviceId);
@Select("SELECT * FROM matchmaker_recommendations WHERE id = #{id} AND user_id = #{userId}")
Optional<MatchmakerRecommendation> findByIdAndUser(@Param("id") String id, @Param("userId") String userId);
@Update("UPDATE matchmaker_recommendations SET viewed_at = NOW(), status = 'viewed' WHERE id = #{id} AND status = 'pending_view'")
int markViewed(@Param("id") String id);
}
@@ -11,21 +11,9 @@ import java.util.List;
@Mapper
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerService> {
@Select("SELECT * FROM matchmaker_services WHERE status = 'submitted' ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED")
MatchmakerService findNextPending();
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerService> findByUser(@Param("userId") String userId, @Param("limit") int limit);
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted', 'assigned', 'in_service', 'waiting_user') ORDER BY created_at DESC")
List<MatchmakerService> findByOperatorId(String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT 10")
List<MatchmakerService> findByUserId(String userId);
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
long countSubmitted();
@Select("SELECT * FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL ORDER BY created_at ASC")
List<MatchmakerService> findSubmitted();
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('in_service', 'waiting_user') AND deleted_at IS NULL")
long countInProgressByOperator(String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
List<MatchmakerService> findAssignedTo(@Param("operatorId") String operatorId);
}
@@ -0,0 +1,307 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.match.entity.Match;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.match.service.MatchService;
import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerService;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.shared.constant.Constants;
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 13
*
* 核心业务规则:
* - AGENTS.md #5: 红娘不可替用户 Like — 红娘只能"推荐",用户自己决定是否接受
* - P13-V11-T01/T02/T03: 转介婚恋咨询/活动/服务站 — 只创建 referral 记录,不替用户申请完成
* - P13-V11-T04: referral_source 可追溯 — 每条转介/推荐都带 operator_id + service_id
* - P13-V11-T05: 用户拥有最终决定权 — 推荐用户必须主动 Like target 才能形成 Match
*
* 状态机(matchmaker_services):
* submitted → assigned → processing → completed
*
* 状态机(matchmaker_recommendations):
* pending_view → viewed → liked / passed
* liked 等双方都 like 后 → confirmed_mutual(不是红娘点的!)
*/
@Service
@RequiredArgsConstructor
public class MatchmakerService {
private static final Logger log = LoggerFactory.getLogger(MatchmakerService.class);
private final MatchmakerServiceMapper serviceMapper;
private final MatchmakerRecommendationMapper recommendationMapper;
private final RelationService relationService;
private final MatchMapper matchMapper;
private final NotificationService notificationService;
// ==================== 用户端:提交红娘服务请求 ====================
/**
* 用户提交红娘服务请求
*
* service_type 可选值:
* - "recommendation" 红娘推荐对象(P13 主流程)
* - "referral_counseling" 红娘转介咨询(P13-T01)
* - "referral_activity" 红娘转介活动(P13-T02)
* - "referral_station" 红娘转介服务站(P13-T03)
*/
@Transactional
public MatchmakerService submitServiceRequest(String userId, String serviceType, String description) {
MatchmakerService svc = new MatchmakerService();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(serviceType);
svc.setDescription(description);
svc.setStatus("submitted");
serviceMapper.insert(svc);
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
return svc;
}
/** 查询我的红娘服务历史 */
public List<MatchmakerService> myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50));
}
// ==================== 操作人端:分配 + 推荐 + 转介 ====================
/**
* 分配红娘服务给操作人
*/
@Transactional
public void assignService(String serviceId, String operatorId) {
MatchmakerService svc = serviceMapper.findById(serviceId)
.orElseThrow(() -> new IllegalArgumentException("Service 不存在"));
svc.setAssignedOperatorId(operatorId);
svc.setStatus("assigned");
svc.setAssignedAt(Instant.now());
serviceMapper.updateById(svc);
}
/**
* 红娘为用户推荐一个对象(人工推荐)
*
* 这不是替用户 Like!只是创建一条 "pending_view" 的推荐记录。
* 用户自己查看后决定是否 Like。
*
* AGENTS.md #5 compliance:
* - 红娘点"推荐" → 创建 MatchmakerRecommendation(status=pending_view)
* - 红娘点"创建 Match" → 不允许!必须双方都 Like 才能形成 Match
*/
@Transactional
public MatchmakerRecommendation createRecommendation(String serviceId, String userId,
String targetUserId, String operatorId, String reason) {
// 校验:不能推荐给自己
if (userId.equals(targetUserId)) {
throw new IllegalArgumentException("不能推荐给自己");
}
// 校验:不能推荐已被 block 的
if (!relationService.canInteract(userId, targetUserId)) {
throw new IllegalStateException("双方存在屏蔽关系,无法推荐");
}
// 校验:红娘必须是服务负责人
MatchmakerService svc = serviceMapper.findById(serviceId).orElse(null);
if (svc == null || !operatorId.equals(svc.getAssignedOperatorId())) {
throw new IllegalStateException("您不是此服务的负责人");
}
MatchmakerRecommendation rec = new MatchmakerRecommendation();
rec.setId(UUID.randomUUID().toString());
rec.setServiceId(serviceId);
rec.setUserId(userId);
rec.setTargetUserId(targetUserId);
rec.setOperatorId(operatorId);
rec.setReason(reason);
rec.setStatus("pending_view");
recommendationMapper.insert(rec);
// 通知用户有新的红娘推荐
notificationService.send(userId, "matchmaker_recommendation",
"红娘有新的推荐",
"公益红娘为你推荐了一位合适的对象,快来看看吧。",
"matchmaker_recommendation", rec.getId());
log.info("Matchmaker recommendation created: user={} -> target={}, operator={}",
userId, targetUserId, operatorId);
return rec;
}
/**
* 红娘转介(P13-T01/T02/T03)
*
* 转介 ≠ 替用户申请完成
* 红娘只是"在服务记录里写一条转介建议",用户还需要自己去对应的服务申请。
* referral_source 可追溯(operator_id + service_id + record_type)。
*/
@Transactional
public ReferralRecord createReferral(String serviceId, String userId, String operatorId,
String referralType, String referralTarget, String note) {
// referralType: counseling / activity / service_station
MatchmakerService svc = serviceMapper.findById(serviceId).orElse(null);
if (svc == null || !operatorId.equals(svc.getAssignedOperatorId())) {
throw new IllegalStateException("您不是此服务的负责人");
}
// referral 记录写进 MatchmakerService.description 的扩展 JSON
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
// 这里用 Map 简单记录,不引入新实体
ReferralRecord record = new ReferralRecord(
UUID.randomUUID().toString(),
serviceId,
userId,
operatorId,
referralType,
referralTarget,
note,
Instant.now()
);
// 通知用户被转介了
notificationService.send(userId, "matchmaker_referral",
"红娘为你转介了一个" + referralLabel(referralType),
"公益红娘为你转介了" + referralLabel(referralType) +
"(" + (referralTarget != null ? referralTarget : "") + "),你可以在" +
serviceLabel(referralType) + "页面申请。",
"matchmaker_service", serviceId);
log.info("Matchmaker referral created: user={}, type={}, source={}",
userId, referralType, operatorId);
return record;
}
// ==================== 用户端:对红娘推荐的回应 ====================
/**
* 用户查看红娘推荐
*/
@Transactional
public void markRecommendationViewed(String recommendationId, String userId) {
recommendationMapper.findByIdAndUser(recommendationId, userId).ifPresent(rec -> {
if ("pending_view".equals(rec.getStatus())) {
recommendationMapper.markViewed(recommendationId);
}
});
}
/**
* 用户对红娘推荐的回应:接受(Like 推荐对象)
*
* P13-V11-T05 + AGENTS.md #5: 用户主动操作,红娘不替
*
* 流程:
* 1. 调 RelationService.like() —— 走 Like 正常流程 + Mutual 检测
* 2. 如果 Mutual → 创建 Match,source="matchmaker"(因为推荐来源是红娘)
* 3. 更新 recommendation.status = "liked"
* 4. 如果对方也 Like 过 → recommendation.status = "confirmed_mutual"
*/
@Transactional
public LikeRecommendationResult acceptRecommendation(String recommendationId, String userId) {
MatchmakerRecommendation rec = recommendationMapper.findByIdAndUser(recommendationId, userId)
.orElseThrow(() -> new IllegalArgumentException("推荐不存在"));
if (!"pending_view".equals(rec.getStatus()) && !"viewed".equals(rec.getStatus())) {
throw new IllegalStateException("此推荐已处理");
}
// 用户主动 Like(AGENTS.md #5: 不是红娘替的)
RelationService.LikeResult likeResult = relationService.like(userId, rec.getTargetUserId());
// 更新推荐状态
rec.setStatus(likeResult.mutual ? "confirmed_mutual" : "liked");
rec.setRespondedAt(Instant.now());
recommendationMapper.updateById(rec);
// 如果 Mutual,更新 Match source 为 matchmaker(追溯来源 P13-T04)
if (likeResult.mutual && likeResult.match != null) {
Match m = likeResult.match;
if (!"matchmaker".equals(m.getSource())) {
m.setSource("matchmaker");
matchMapper.updateById(m);
}
}
// 服务完成(如果红娘推荐的所有对象用户都回应了)
checkAndCompleteService(rec.getServiceId());
return new LikeRecommendationResult(likeResult.mutual, likeResult.match != null ? likeResult.match.getId() : null);
}
/** 用户跳过红娘推荐 */
@Transactional
public void passRecommendation(String recommendationId, String userId) {
MatchmakerRecommendation rec = recommendationMapper.findByIdAndUser(recommendationId, userId)
.orElseThrow(() -> new IllegalArgumentException("推荐不存在"));
rec.setStatus("passed");
rec.setRespondedAt(Instant.now());
recommendationMapper.updateById(rec);
// 用户跳过也算一次 pass 操作
relationService.pass(userId, rec.getTargetUserId());
checkAndCompleteService(rec.getServiceId());
}
private void checkAndCompleteService(String serviceId) {
List<MatchmakerRecommendation> all = recommendationMapper.findByService(serviceId);
boolean allDone = all.stream().allMatch(r ->
"liked".equals(r.getStatus()) || "passed".equals(r.getStatus())
|| "confirmed_mutual".equals(r.getStatus()));
if (allDone) {
MatchmakerService svc = serviceMapper.findById(serviceId).orElse(null);
if (svc != null) {
svc.setStatus("completed");
svc.setCompletedAt(Instant.now());
serviceMapper.updateById(svc);
}
}
}
// ==================== 用户端:待处理红娘推荐列表 ====================
public List<MatchmakerRecommendation> pendingRecommendations(String userId) {
return recommendationMapper.findPendingForUser(userId);
}
// ==================== 内部工具 ====================
private String referralLabel(String type) {
return switch (type) {
case "counseling" -> "婚恋咨询服务";
case "activity" -> "公益活动";
case "service_station" -> "善缘服务站";
default -> "公益服务";
};
}
private String serviceLabel(String type) {
return switch (type) {
case "counseling" -> "婚恋咨询";
case "activity" -> "公益活动";
case "service_station" -> "善缘服务站";
default -> "相关服务";
};
}
// ==================== DTO ====================
public record ReferralRecord(String id, String serviceId, String userId, String operatorId,
String referralType, String referralTarget, String note, Instant createdAt) {}
public record LikeRecommendationResult(boolean mutual, String matchId) {}
}