diff --git a/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java b/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java
index 4bc9a39..fa575e1 100644
--- a/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java
+++ b/backend/src/main/java/com/danyang/match/platform/appeal/service/AppealService.java
@@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
+import com.danyang.match.platform.user.entity.User;
+import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ public class AppealService {
private final AppealMapper appealMapper;
private final RiskActionMapper riskActionMapper;
+ private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService;
@@ -281,6 +284,11 @@ public class AppealService {
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
}
+ // 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
+ // 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
+ // users.account_status=banned/suspended 无人回滚。
+ restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
+
// 通知用户
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核通过",
@@ -371,6 +379,38 @@ public class AppealService {
return "limited";
}
+ /**
+ * 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
+ *
+ *
否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
+ * users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
+ *
+ *
仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
+ */
+ private void restoreAccountStatusIfNoActiveRestriction(String userId) {
+ boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
+ .anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
+ || Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
+ if (stillRestricted) {
+ return;
+ }
+ User user = userMapper.selectById(userId);
+ if (user == null) {
+ return;
+ }
+ String oldStatus = user.getAccountStatus();
+ if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
+ && !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
+ return;
+ }
+ user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
+ userMapper.updateById(user);
+ // audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
+ saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
+ "{\"accountStatus\":\"" + oldStatus + "\"}",
+ "{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
+ }
+
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
@@ -378,6 +418,10 @@ public class AppealService {
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
+ // 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
+ // audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
+ auditLog.setBeforeData(beforeData);
+ auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog);
}
}
diff --git a/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java b/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java
index 1b38c49..6b72b47 100644
--- a/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java
+++ b/backend/src/main/java/com/danyang/match/platform/auth/provider/MockWechatLoginProvider.java
@@ -40,6 +40,17 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
public Map getCodeSession(String code) {
log.debug("[Mock] 微信登录 code: {}", code);
+ // 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
+ // 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
+ if (code != null && code.startsWith("demo_openid_")) {
+ return Map.of(
+ "openid", code,
+ "unionid", "mock_unionid_" + code,
+ "session_key", "mock_session_key",
+ "appid", appId
+ );
+ }
+
// 模拟:相同 code 返回相同 openid
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
? configuredOpenid
diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java
index eda5f66..f799a09 100644
--- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java
+++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java
@@ -310,7 +310,11 @@ public class RecommendationEngineService {
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setScore(java.math.BigDecimal.valueOf(score));
record.setLevel(toLevel(score));
- record.setReasons(String.join(";", reasons));
+ // recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
+ // 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
+ // 触发 PostgreSQL「invalid input syntax for type json」,导致整个
+ // /home 聚合接口 500。改为序列化为 JSON 数组。
+ record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -342,6 +346,16 @@ public class RecommendationEngineService {
}
}
+ /** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
+ private String writeReasonsJson(List reasons) {
+ try {
+ return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
+ } catch (Exception e) {
+ log.warn("序列化推荐理由失败", e);
+ return "[]";
+ }
+ }
+
/**
* 内部评分结果
*/
diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java
index 03d4382..7b6df28 100644
--- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java
+++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.UserProfileService;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
public class RecommendationService {
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
+ private final ObjectMapper objectMapper = new ObjectMapper();
private final MatchEligibilityService eligibilityService;
private final UserProfileMapper userProfileMapper;
@@ -61,7 +63,9 @@ public class RecommendationService {
record.setTargetUserId(targetUserId);
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setLevel(level);
- record.setReasons(new java.util.ArrayList<>(reasons).toString());
+ // recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
+ // (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
+ record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -70,6 +74,16 @@ public class RecommendationService {
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
}
+ /** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
+ private String writeReasonsJson(List reasons) {
+ try {
+ return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
+ } catch (Exception e) {
+ log.warn("序列化推荐理由失败", e);
+ return "[]";
+ }
+ }
+
/**
* 记录用户对推荐的响应(Like/Pass/Block)
*/
diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java
index 84ccd33..f05f9e0 100644
--- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java
+++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerController.java
@@ -54,7 +54,8 @@ public class MatchmakerController {
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
? "recommendation" : req.serviceType;
String description = req.description != null ? req.description : req.requirement;
- return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description));
+ return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
+ req.targetUserId, req.authorizationAccepted));
}
@GetMapping("/services")
@@ -72,6 +73,15 @@ public class MatchmakerController {
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
}
+ @Idempotent
+ @PostMapping("/services/{id}/withdraw")
+ public ApiResponse withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
+ @AuthenticationPrincipal UserPrincipal principal) {
+ if (principal == null) return ApiResponse.unauthorized();
+ matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
+ return ApiResponse.ok();
+ }
+
@GetMapping("/recommendations")
public ApiResponse> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
@@ -111,5 +121,10 @@ public class MatchmakerController {
private String description;
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
private String requirement;
+ private String targetUserId;
+ private Boolean authorizationAccepted;
}
+
+ @Data
+ public static class WithdrawRequest { private String reason; }
}
diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java
index 837ace6..bc80a34 100644
--- a/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java
+++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/entity/MatchmakerServiceRecord.java
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
private String userId;
private String serviceType;
private String description;
+ private String targetUserId;
+ private java.time.Instant authorizationAt;
+ private java.time.Instant withdrawnAt;
+ private String withdrawReason;
private String status;
private String organizationId;
private String assignedOperatorId;
diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java
index 100e3e8..b6db5f6 100644
--- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java
+++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java
@@ -14,13 +14,16 @@ import java.util.Optional;
public interface MatchmakerServiceMapper extends BaseMapper {
@Select("")
@@ -83,6 +86,14 @@ public interface MatchmakerServiceMapper extends BaseMapper findByUser(@Param("userId") String userId, @Param("limit") int limit);
+ @Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
+ "AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
+ long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
+
+ @Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
+ "WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
+ int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
+
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
@Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " +
"WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL")
diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java
index 273e7c8..c5cfbac 100644
--- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java
+++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java
@@ -12,6 +12,8 @@ import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
@@ -55,6 +57,7 @@ import java.util.stream.Collectors;
public class MatchmakerAssignmentService {
private static final String RESOURCE_TYPE = "matchmaker_assignment";
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper;
@@ -87,6 +90,20 @@ public class MatchmakerAssignmentService {
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
+ // 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
+ // 仍在进行中的服务继续按唯一主责约束返回 409。
+ MatchmakerServiceRecord linked = existing.getServiceId() == null
+ ? null : serviceMapper.selectById(existing.getServiceId());
+ if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
+ || Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
+ existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
+ existing.setEndedAt(Instant.now());
+ existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
+ assignmentMapper.updateById(existing);
+ saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
+ auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
+ return;
+ }
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销");
});
@@ -388,10 +405,15 @@ public class MatchmakerAssignmentService {
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
- String summary = "matchmakerOperatorId=" + matchmakerOperatorId
- + ";scope=" + scope
- + ";reason=" + (reason == null ? "" : reason);
- return summary.length() > 500 ? summary.substring(0, 500) : summary;
+ Map summary = Map.of(
+ "matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
+ "scope", scope == null ? "" : scope,
+ "reason", reason == null ? "" : reason);
+ try {
+ return OBJECT_MAPPER.writeValueAsString(summary);
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("audit afterData JSON serialization failed", e);
+ }
}
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java
index 49bfaea..f2c5984 100644
--- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java
+++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerService.java
@@ -64,17 +64,38 @@ public class MatchmakerService {
*/
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
+ return submitServiceRequest(userId, serviceType, description, null, null);
+ }
+
+ @Transactional
+ public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
+ String targetUserId, Boolean authorizationAccepted) {
+ boolean assistance = "recommendation_assistance".equals(serviceType);
+ if (assistance) {
+ if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
+ if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
+ if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
+ if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
+ if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
+ }
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(serviceType);
svc.setDescription(description);
+ svc.setTargetUserId(targetUserId);
+ if (assistance) svc.setAuthorizationAt(Instant.now());
svc.setStatus("submitted");
serviceMapper.insert(svc);
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
return svc;
}
+ @Transactional
+ public void withdrawService(String serviceId, String userId, String reason) {
+ if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
+ }
+
/** 查询我的红娘服务历史 */
public List myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50));
diff --git a/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java b/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java
index 6de30cb..a9a948e 100644
--- a/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java
+++ b/backend/src/main/java/com/danyang/match/platform/risk/controller/ReportController.java
@@ -45,5 +45,26 @@ public class ReportController {
return ApiResponse.ok(reportService.getMyReports(userId));
}
+ /**
+ * 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
+ * GET /api/v1/risk/reports/{reportId}
+ */
+ @GetMapping("/{reportId}")
+ public ApiResponse