release: freeze demo v1.1.0 2026-10-19
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions

This commit is contained in:
WorkBuddy
2026-10-19 20:36:22 +08:00
parent 8b505d9133
commit 69ebf33e18
157 changed files with 21111 additions and 72 deletions
@@ -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。
*
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
*
* <p>仅回滚由处置写入的 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);
}
}
@@ -40,6 +40,17 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
public Map<String, String> 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
@@ -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<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/**
* 内部评分结果
*/
@@ -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<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/**
* 记录用户对推荐的响应(Like/Pass/Block)
*/
@@ -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<Void> 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<List<MatchmakerRecommendation>> 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; }
}
@@ -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;
@@ -14,13 +14,16 @@ import java.util.Optional;
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
"s.description, s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
"tu.display_number AS \"targetUserDisplayNumber\", " +
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
@@ -83,6 +86,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> 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")
@@ -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<String, String> 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) {
@@ -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<MatchmakerServiceRecord> myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50));
@@ -45,5 +45,26 @@ public class ReportController {
return ApiResponse.ok(reportService.getMyReports(userId));
}
/**
* 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
* GET /api/v1/risk/reports/{reportId}
*/
@GetMapping("/{reportId}")
public ApiResponse<Map<String, Object>> getMyReportDetail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String reportId) {
return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId));
}
/**
* 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报)
* POST /api/v1/risk/reports/evidence/upload-token
*/
@PostMapping("/evidence/upload-token")
public ApiResponse<Map<String, Object>> getEvidenceUploadToken(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(reportService.getEvidenceUploadToken(userId));
}
}
@@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.risk.dto.ReportItemDTO;
import com.danyang.match.platform.risk.dto.ReportProcessDTO;
import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
@@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.risk.mapper.RiskEventMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
@@ -30,9 +40,11 @@ import java.time.Instant;
import java.util.Map;
import java.util.List;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/**
* 举报与风控服务
@@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
* - 封禁需要人工复核,不能自动永久封禁
*/
@Service
@RequiredArgsConstructor
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
/** 补充说明字数上限(与前端 maxlength 对齐)。 */
private static final int MAX_DESCRIPTION_LENGTH = 500;
/** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */
private static final int DEFAULT_MAX_EVIDENCE = 9;
/** 运营端按状态列表查询的单页上限。 */
private static final int STATUS_LIST_LIMIT = 200;
/** 高风险举报原因:必须补充文字说明。 */
private static final Set<String> HIGH_RISK_REASONS = Set.of(
Constants.REPORT_REASON_FRAUD,
Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION,
Constants.REPORT_REASON_FAKE_MARITAL_STATUS
);
private final ReportMapper reportMapper;
private final RiskEventMapper riskEventMapper;
private final RiskActionMapper riskActionMapper;
private final UserProfileMapper userProfileMapper;
private final UserMapper userMapper;
private final AuditLogMapper auditLogMapper;
private final AuditLogService auditLogService;
private final NotificationService notificationService;
private final ObjectMapper objectMapper;
/** 证据对象的归属/存在性校验走内网客户端。 */
private final AmazonS3 amazonS3;
/** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
@Value("${object-storage.bucket-private}")
private String privateBucket;
@Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}")
private int maxEvidenceCount;
@Autowired
public ReportService(
ReportMapper reportMapper,
RiskEventMapper riskEventMapper,
RiskActionMapper riskActionMapper,
UserProfileMapper userProfileMapper,
UserMapper userMapper,
AuditLogMapper auditLogMapper,
AuditLogService auditLogService,
NotificationService notificationService,
ObjectMapper objectMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.reportMapper = reportMapper;
this.riskEventMapper = riskEventMapper;
this.riskActionMapper = riskActionMapper;
this.userProfileMapper = userProfileMapper;
this.userMapper = userMapper;
this.auditLogMapper = auditLogMapper;
this.auditLogService = auditLogService;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
}
// 风险评分权重
private static final Map<String, Integer> REASON_SCORES = Map.of(
@@ -75,10 +142,30 @@ public class ReportService {
*/
@Transactional
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 举报对象校验
if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象");
}
// 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
}
// 举报原因白名单(REASON_SCORES 的 key 即合法枚举)
String reasonType = dto.getReasonType();
if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法");
}
// 补充说明长度 + 高风险必填
String description = dto.getDescription() == null ? "" : dto.getDescription().trim();
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)");
}
if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明");
}
// 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象)
List<String> evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds());
// 检查是否已被封禁
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId);
@@ -91,34 +178,35 @@ public class ReportService {
Report report = new Report();
report.setReporterUserId(reporterUserId);
report.setReportedUserId(dto.getReportedUserId());
report.setReasonType(dto.getReasonType());
report.setDescription(dto.getDescription());
if (dto.getEvidenceFileIds() != null) {
report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString());
}
report.setReasonType(reasonType);
report.setDescription(description.isEmpty() ? null : description);
// evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString()
report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds));
report.setStatus(Constants.REPORT_STATUS_PENDING);
reportMapper.insert(report);
// 触发风险事件:被举报用户得分增加
int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5);
int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5);
RiskEvent riskEvent = new RiskEvent();
riskEvent.setUserId(dto.getReportedUserId());
riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT);
riskEvent.setScore(scoreAdd);
riskEvent.setSource("report");
riskEvent.setSourceId(report.getId());
riskEvent.setNote("举报触发,原因: " + dto.getReasonType());
riskEvent.setNote("举报触发,原因: " + reasonType);
riskEventMapper.insert(riskEvent);
// 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充)
if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) {
Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) {
report.setStatus(Constants.REPORT_STATUS_PROCESSING);
reportMapper.updateById(report);
}
// 审计
saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
// 审计(C 端用户主体:写 subject_user_id 且 operator_id=null)
// 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators)
// 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。
auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("reportId", report.getId());
@@ -135,6 +223,45 @@ public class ReportService {
.collect(Collectors.toList());
}
/**
* 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。
* 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。
*/
public Map<String, Object> getMyReportDetail(String userId, String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null || !userId.equals(report.getReporterUserId())) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
return detail;
}
/**
* 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。
* fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。
*/
public Map<String, Object> getEvidenceUploadToken(String userId) {
String fileKey = String.format("reports/%s/evidence/%s.jpg",
userId, UUID.randomUUID().toString().replace("-", ""));
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
token.put("uploadUrl", uploadUrl);
token.put("expiresIn", 3600);
return token;
}
// ==================== 管理端举报处理 ====================
/**
@@ -157,6 +284,13 @@ public class ReportService {
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
// 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录)
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
// 被举报人风险记录
String reportedUserId = report.getReportedUserId();
if (reportedUserId != null) {
@@ -215,7 +349,10 @@ public class ReportService {
}
// 通知被举报用户
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote());
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId);
// 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节)
sendReporterResultNotification(report.getReporterUserId(), result, reportId);
// 高风险举报(婚姻状态欺诈)额外记录风险事件
if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) {
@@ -257,8 +394,20 @@ public class ReportService {
return m;
}).collect(java.util.stream.Collectors.toList());
}
// For other statuses, return empty or implement full query
return List.of();
// 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空)
return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", report.getId());
m.put("reasonType", report.getReasonType());
m.put("reasonText", getReasonText(report.getReasonType()));
m.put("reportedUserId", report.getReportedUserId());
m.put("status", report.getStatus());
m.put("description", report.getDescription());
m.put("createdAt", report.getCreatedAt());
m.put("processResult", report.getProcessResult());
m.put("processNote", report.getProcessNote());
return m;
}).collect(java.util.stream.Collectors.toList());
}
public Map<String, Object> getReportStats() {
@@ -298,6 +447,83 @@ public class ReportService {
// ==================== 私有辅助方法 ====================
/** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */
private List<String> validateEvidence(String reporterUserId, List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return List.of();
}
if (evidenceFileIds.size() > maxEvidenceCount) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"证据截图最多 " + maxEvidenceCount + " 张");
}
String prefix = "reports/" + reporterUserId + "/";
for (String key : evidenceFileIds) {
if (key == null || !key.startsWith(prefix)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, key)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成");
}
}
return evidenceFileIds;
}
/** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */
private String writeEvidenceJson(List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return "[]";
}
try {
return objectMapper.writeValueAsString(evidenceFileIds);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new ServerErrorException("证据序列化失败", e);
}
}
/** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */
private List<String> parseEvidence(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json);
return List.of();
}
}
/** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */
private String signEvidenceUrl(String fileKey) {
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
return amazonS3Signer.generatePresignedUrl(
new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.GET)
.withExpiration(expiration)).toString();
}
/** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */
private String displayNameOf(String userId) {
if (userId == null) return null;
return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null);
}
/** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */
private void sendReporterResultNotification(String reporterUserId, String result, String reportId) {
if (reporterUserId == null) return;
String title = "举报处理结果";
String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result)
? "您提交的举报经核实未发现问题,感谢您的监督。"
: "您提交的举报已处理完成,感谢您对社区安全的贡献。";
try {
// business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串)
notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"report", reportId);
} catch (Exception e) {
log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e);
}
}
private void applyRiskAction(String userId, String result, String note, String operatorId) {
RiskAction action = new RiskAction();
action.setUserId(userId);
@@ -329,6 +555,34 @@ public class ReportService {
action.setEffectiveAt(Instant.now());
action.setReasonDetail(note);
riskActionMapper.insert(action);
// 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 ——
// 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定
// 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。
String newAccountStatus = switch (result) {
case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED;
case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED;
default -> null; // warning / feature_limit 不改变账号状态
};
if (newAccountStatus != null) {
syncAccountStatus(userId, newAccountStatus, operatorId);
}
}
/**
* 把处置结果写入 users.account_status 并留痕。
* 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。
*/
private void syncAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) return;
String oldStatus = user.getAccountStatus();
if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return;
user.setAccountStatus(newStatus);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。
saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}");
}
private String calculateRiskLevel(int score) {
@@ -351,6 +605,8 @@ public class ReportService {
dto.setProcessNote(report.getProcessNote());
// 填充显示名称
dto.setReasonText(getReasonText(report.getReasonType()));
dto.setReporterName(displayNameOf(report.getReporterUserId()));
dto.setReportedName(displayNameOf(report.getReportedUserId()));
return dto;
}
@@ -368,7 +624,7 @@ public class ReportService {
};
}
private void sendProcessNotification(String userId, String result, String note) {
private void sendProcessNotification(String userId, String result, String note, String businessId) {
if (userId == null) return;
String title = "账号处置通知";
String content = switch (result) {
@@ -378,8 +634,9 @@ public class ReportService {
case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。";
default -> "您的账号相关处置已完成。";
};
// business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚)
notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis());
"risk_action", businessId);
}
private void saveAuditLog(String operatorId, String action, String resourceType,
@@ -65,7 +65,8 @@ public class SecurityConfig {
.accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll()
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
.permitAll()
.requestMatchers("/health/**").permitAll()
.requestMatchers("/version").permitAll()
.requestMatchers("/app/config").permitAll()
@@ -39,11 +39,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**",
"/auth/**",
"/api/v1/health/**",
"/health/**",
"/api/v1/version",
"/version",
"/api/v1/operator/auth/**",
"/api/v1/app/**",
"/api/v1/activities"
"/app/**",
"/api/v1/activities",
"/activities"
);
@Override
@@ -51,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
FilterChain chain) throws ServletException, IOException {
String contextPath = request.getContextPath();
String servletPath = request.getServletPath();
if (servletPath == null || servletPath.isEmpty()) {
servletPath = request.getRequestURI();
}
String effectivePath = (contextPath != null && !contextPath.isEmpty())
? contextPath + servletPath : servletPath;
String method = request.getMethod();
if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) {
if ("OPTIONS".equals(method)
|| isPermittedPath(effectivePath)
|| isPermittedPath(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
@@ -136,7 +146,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
private boolean isPermittedPath(String path) {
String normalizedPath = path.startsWith("/api/v1")
? path
: "/api/v1" + (path.startsWith("/") ? path : "/" + path);
return PERMITTED_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
.anyMatch(pattern -> pathMatcher.match(pattern, path)
|| pathMatcher.match(pattern, normalizedPath));
}
}
@@ -2,7 +2,6 @@
-- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL)
-- ============================================================
BEGIN;
SET session_replication_role = 'replica';
-- 1. 组织
INSERT INTO organizations (id, name, type, contact_name, status) VALUES
@@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
(gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active');
-- 2. 工作人员(V1.0原有 + V1.1新增)
INSERT INTO operators (id, organization_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center'
UNION ALL
SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='trade_union'
-- V1.1 新增演示操作员
UNION ALL
SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center'
UNION ALL
SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation';
-- 操作员角色绑定(V1.0原有 + V1.1新增)
@@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty
-- ============================================================
-- 4. 审核任务
-- ============================================================
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version)
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id)
SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved',
(SELECT id FROM operators WHERE username='reviewer01'),
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1,
'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0')
FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05');
-- ============================================================
@@ -331,17 +331,17 @@ VALUES
WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1),
trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1),
mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1)
INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text,
INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text,
start_at, end_at, location_name, location_address,
male_limit, female_limit, total_limit, min_age, max_age,
registration_start_at, registration_end_at, need_review, status, created_by)
VALUES
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)',
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001',
'面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。',
(SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会',
'2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号',
15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)),
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你',
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002',
'七夕节特别策划,浪漫主题相亲活动。',
(SELECT id FROM trade_org), '丹阳市新时代文明实践中心',
'2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号',
@@ -442,7 +442,6 @@ VALUES
('manual_review_needed', '需人工复核', 'quality', true, 7)
ON CONFLICT (code) DO NOTHING;
SET session_replication_role = 'origin';
DROP TABLE IF EXISTS _conv_result;
-- ============================================================
@@ -0,0 +1,487 @@
-- ============================================================
-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL)
-- ============================================================
-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白:
-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、
-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、
-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格),
-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。
--
-- 约束(与任务一致):
-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。
-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING,
-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。
-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。
-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。
-- ============================================================
BEGIN;
-- ============================================================
-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动)
-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条)
-- created_by 引用既有操作员 contenteditor
-- ============================================================
INSERT INTO home_banners
(id, title, subtitle, button_text, target_type, target_value,
audience_scope, sort_order, start_at, end_at, status, created_by)
VALUES
('a1111111-1111-1111-1111-111111110001'::uuid,
'婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导',
'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110002'::uuid,
'婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南',
'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5,
NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110003'::uuid,
'婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询',
'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. 婚恋课堂分类 content_categories(4 个)
-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture),
-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。
-- ============================================================
INSERT INTO content_categories (id, name, code, description, sort_order, status)
SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active'
FROM (VALUES
('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class',
'恋爱沟通、关系经营与择偶知识', 1),
('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud',
'婚恋诈骗识别与防范知识', 2),
('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu',
'婚前准备与关系建立指导', 3),
('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture',
'传统婚俗与文明新风尚', 4)
) AS v(id, name, code, description, sort_order)
WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code);
-- ============================================================
-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题)
-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件)
-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor
-- ============================================================
INSERT INTO content_articles
(id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order)
VALUES
('a3333333-3333-3333-3333-333333330001'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'初次见面聊什么?五个不冷场的话题',
'第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。',
'很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。'
|| E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。'
|| E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。'
|| E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。'
|| E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。'
|| E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。'
|| E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1),
('a3333333-3333-3333-3333-333333330002'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'如何判断两个人是否合适',
'合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。',
'判断两个人是否合适,可以从三个维度观察。'
|| E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。'
|| E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。'
|| E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。'
|| E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2),
('a3333333-3333-3333-3333-333333330003'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'从喜欢到结婚:健康关系的发展节奏',
'好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。',
'从喜欢到结婚,往往要经历几个自然阶段。'
|| E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。'
|| E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。'
|| E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。'
|| E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。'
|| E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。'
|| E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3),
('a3333333-3333-3333-3333-333333330004'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'婚恋反诈指南:识别常见情感骗局',
'刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。',
'婚恋交友中,以下情况需要高度警惕。'
|| E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。'
|| E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。'
|| E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。'
|| E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。'
|| E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。'
|| E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1),
('a3333333-3333-3333-3333-333333330005'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'「杀猪盘」套路全解析',
'温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。',
'「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。'
|| E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。'
|| E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。'
|| E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。'
|| E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。'
|| E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2),
('a3333333-3333-3333-3333-333333330006'::uuid,
(SELECT id FROM content_categories WHERE code = 'premarital_edu'),
'婚前一定要聊清楚的十个问题',
'在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。',
'走进婚姻前,建议和对方认真聊清以下问题。'
|| E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?'
|| E'\n' || '二、婚后居住:和父母同住还是独立居住?'
|| E'\n' || '三、家务分工:如何共同承担家庭事务?'
|| E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?'
|| E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?'
|| E'\n' || '六、职业规划:是否支持对方的职业发展?'
|| E'\n' || '七、冲突处理:吵架时如何避免伤害对方?'
|| E'\n' || '八、双方底线:哪些事情无法接受?'
|| E'\n' || '九、家庭应急:突发状况如何共同应对?'
|| E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?'
|| E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1),
('a3333333-3333-3333-3333-333333330007'::uuid,
(SELECT id FROM content_categories WHERE code = 'custom_culture'),
'婚事新办,文明婚俗新风尚',
'低彩礼、简仪式、重情义,让婚礼回归幸福本身。',
'近年来,婚事新办、喜事简办逐渐成为新风尚。'
|| E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。'
|| E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。'
|| E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。'
|| E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动)
-- ============================================================
INSERT INTO service_locations
(id, name, short_name, address, latitude, longitude,
contact_phone_display, opening_hours_text, service_description,
primary_organization_id, status)
VALUES
('a4444444-4444-4444-4444-444444440001'::uuid,
'丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号',
31.9210000, 119.6090000, '0511-86666888',
'周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)',
'提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'),
('a4444444-4444-4444-4444-444444440002'::uuid,
'丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号',
31.8600000, 119.4900000, '0511-86688866',
'周一至周五 9:00-16:30(法定节假日除外)',
'面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传',
(SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿)
-- 用户端公开列表只展示 status = active
-- ============================================================
INSERT INTO service_programs
(id, name, description, organizer_org_id, start_at, end_at, status, created_by)
VALUES
('a5555555-5555-5555-5555-555555550001'::uuid,
'公益红娘一对一服务计划',
'由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550002'::uuid,
'婚恋反诈宣传进社区',
'联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550003'::uuid,
'婚前辅导公益课堂(筹备中)',
'面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft',
(SELECT id FROM operators WHERE username = 'admin'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 6. 婚恋咨询 counseling_services(2 条)
-- service_type / status 取值对齐生产 CHECK 约束
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
preferred_service_mode, scheduled_start_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
'relationship_guidance', 'user_self',
'离异后重新开始,希望了解如何建立健康的新关系。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'),
('a6666666-6666-6666-6666-666666660002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'),
'emotional_support', 'user_self',
'工作压力较大,希望有人倾听并给出情绪调节建议。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'assigned', 'online', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条)
-- 婚前辅导复用 counseling_services 表,无独立表
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
companion_user_id, scheduled_start_at, user_confirmed_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
'premarital_guidance', 'user_self',
'计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'),
('a6666666-6666-6666-6666-666666660004'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
'premarital_guidance', 'user_self',
'希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'scheduled',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
NOW() + INTERVAL '5 days', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 8. 本地资格 user_local_eligibilities(3 条)
-- eligibility_type / verification_status 对齐生产 CHECK 约束
-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等
-- ============================================================
INSERT INTO user_local_eligibilities
(id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by)
VALUES
('a7777777-7777-7777-7777-777777770001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'),
'household_registration', '321181', 'verified',
NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'),
'employment', '321181', 'verified',
NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'),
'residence', '321100', 'pending_review', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 本地资格佐证 local_eligibility_evidences(2 条)
-- ============================================================
INSERT INTO local_eligibility_evidences
(id, local_eligibility_id, file_key, evidence_type, status)
VALUES
('a8888888-8888-8888-8888-888888880001'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')),
'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'),
('a8888888-8888-8888-8888-888888880002'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')),
'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09)
-- ------------------------------------------------------------
-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为
-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻
-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required
-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。
--
-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向):
-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在
-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college;
-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异),
-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。
-- * 账号满足 RecommendationEngineService.isParticipantEligible:
-- account_status=normal / relationship_status=searching / recommendation_enabled=true /
-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。
-- ============================================================
INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days')
ON CONFLICT DO NOTHING;
INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm,
region_code, region_name, marital_status, children_status,
education_code, occupation_code, smoking_status, drinking_status,
long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes',
'是', '是', '结婚', 93, 'approved'),
('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes',
'是', '是', '结婚', 91, 'approved'),
('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168,
'321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes',
'是', '是', '结婚', 94, 'approved')
ON CONFLICT (user_id) DO NOTHING;
-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容
INSERT INTO user_preferences
(user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority,
region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred')
ON CONFLICT (user_id) DO NOTHING;
-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除
INSERT INTO user_privacy_settings
(user_id, visible_in_recommendations, visible_in_search, show_occupation,
show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true)
ON CONFLICT (user_id) DO NOTHING;
-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识)
INSERT INTO user_identities
(id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at)
VALUES
('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid,
encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07',
'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid,
encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08',
'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid,
encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09',
'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day')
ON CONFLICT DO NOTHING;
-- 照片(头像 + 生活照,与既有演示用户一致)
INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status)
VALUES
('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved')
ON CONFLICT DO NOTHING;
-- ============================================================
-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments)
-- ------------------------------------------------------------
-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在
-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my
-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。
-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id
-- 保持一致(均指向演示红娘 wanghongniang)。
-- ============================================================
INSERT INTO matchmaker_assignments
(id, user_id, matchmaker_operator_id, service_id, assigned_by,
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at)
VALUES
-- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中
('a9944444-4444-4444-4444-444444440001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
-- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中
('a9944444-4444-4444-4444-444444440002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'),
-- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配)
('a9944444-4444-4444-4444-444444440003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:服务已完结', 'ended',
NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 12. 服务站运营人员数据范围(service_location_operators)
-- ------------------------------------------------------------
-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list)
-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source =
-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。
-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围
-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。
-- ============================================================
INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at)
VALUES
((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW())
ON CONFLICT (service_location_id, operator_id) DO NOTHING;
-- ============================================================
-- 验证
-- ============================================================
DO $$
DECLARE
b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER;
sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER;
rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER;
BEGIN
SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO pm_cnt FROM counseling_services
WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL;
SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO rec_cnt FROM users
WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09')
AND deleted_at IS NULL;
SELECT COUNT(*) INTO rec_pref FROM user_preferences p
WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid,
'a9900000-0000-0000-0000-000000000008'::uuid,
'a9900000-0000-0000-0000-000000000009'::uuid);
SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO slo_cnt FROM service_location_operators;
RAISE NOTICE '========================================';
RAISE NOTICE 'V1.1 增量演示数据填充完成!';
RAISE NOTICE '========================================';
RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt;
RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt;
RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt;
RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt;
RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref;
RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt;
RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt;
RAISE NOTICE '========================================';
END $$;
COMMIT;
@@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active');
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active');
INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001');
@@ -0,0 +1,12 @@
-- 指定对象的公益红娘协助申请(不交换联系方式)
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS target_user_id UUID;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS authorization_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS withdraw_reason VARCHAR(500);
ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type;
ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
'recommend_partner', 'review_preference', 'contact_intention', 'join_activity',
'no_match_help', 'chat_help', 'other', 'recommendation',
'recommendation_assistance', 'referral_counseling', 'referral_activity', 'referral_station'
));
CREATE INDEX IF NOT EXISTS idx_matchmaker_target_user ON matchmaker_services(target_user_id, created_at DESC);
@@ -15,6 +15,8 @@ import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -68,6 +70,8 @@ class AppealPhase18IntegrationTest {
@Autowired
private NotificationMapper notificationMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private FeatureFlagConfig featureFlagConfig;
private boolean originalAppealEnabled;
@@ -201,6 +205,44 @@ class AppealPhase18IntegrationTest {
assertEquals("suspended", status.get("accountStatus"));
}
// ==================== 申诉通过回滚 users.account_status(避免"通过仍锁死") ====================
private User insertUser(String accountStatus) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus(accountStatus);
userMapper.insert(user);
return user;
}
@Test
void approveRestoresAccountStatusToNormal() {
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
userMapper.selectById(user.getId()).getAccountStatus(),
"申诉通过且该用户已无其他生效封禁/暂停时,必须把 users.account_status 恢复为 normal");
}
@Test
void approveKeepsAccountStatusWhenOtherRestrictionRemains() {
User user = insertUser(Constants.ACCOUNT_STATUS_BANNED);
RiskAction ban = insertAction(user.getId(), Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
insertAction(user.getId(), Constants.RISK_ACTION_TEMPORARY_SUSPEND, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(user.getId(), ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
userMapper.selectById(user.getId()).getAccountStatus(),
"仍有其他生效处置时,账号状态不得被回滚为 normal");
}
// ==================== Gate B:拒绝后原处置保持有效 ====================
@Test
@@ -40,7 +40,8 @@ class MigrationValidationTest {
// (六态状态机 + active 部分唯一索引)
// 用户展示编号:V058 users.display_number 及历史数据回填。
// 公共身份展示编号:V059 operators.display_id 及历史数据回填。
private static final int LATEST_MIGRATION = 60;
// 红娘协助对象与授权字段:V061 matchmaker_services 扩展。
private static final int LATEST_MIGRATION = 61;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -260,11 +260,11 @@ class MatchmakerAssignmentA1Test {
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
"resourceType 必须是 matchmaker_assignment");
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason,且必须是合法 JSON
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
&& a.getAfterData().contains("matchmakerOperatorId=")
&& a.getAfterData().contains("scope=")
&& a.getAfterData().contains("reason=")),
&& a.getAfterData().contains("\"matchmakerOperatorId\"")
&& a.getAfterData().contains("\"scope\"")
&& a.getAfterData().contains("\"reason\"")),
"afterData 必须记录 operatorId/scope/reason 摘要");
}
@@ -17,6 +17,8 @@ import com.danyang.match.platform.risk.service.ReportService;
import com.danyang.match.platform.risk.service.RiskReferralService;
import com.danyang.match.platform.shared.constant.Constants;
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -76,6 +78,8 @@ class ReportAndRiskPhase17IntegrationTest {
@Autowired
private AuditLogMapper auditLogMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private ReportOperatorController reportOperatorController;
@Autowired
private RiskController riskController;
@@ -164,6 +168,64 @@ class ReportAndRiskPhase17IntegrationTest {
assertEquals(auditBefore + 1, auditAfter, "PROCESS_REPORT 审计应新增一条");
}
// ==================== 处置必须真正生效:同步 users.account_status ====================
/**
* 封禁 / 暂停若只写 risk_actions 而不落 users.account_status,则登录门禁、推荐引擎、
* 匹配资格、能力判定全部不认这个处置 —— 被封禁用户仍可登录、仍出现在推荐/发现中。
* 本测试用真实 users 行锁定「处置 → users.account_status」这条链路。
*/
@Test
void processReportSyncsAccountStatusForBanAndSuspendButNotWarning() {
User banUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
User suspendUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
User warnUser = insertUser(Constants.ACCOUNT_STATUS_NORMAL);
processReportFor(banUser.getId(), Constants.PROCESS_RESULT_BAN);
processReportFor(suspendUser.getId(), Constants.PROCESS_RESULT_TEMPORARY_SUSPEND);
processReportFor(warnUser.getId(), Constants.PROCESS_RESULT_WARNING);
assertEquals(Constants.ACCOUNT_STATUS_BANNED,
userMapper.selectById(banUser.getId()).getAccountStatus(),
"封禁处置必须把 users.account_status 置为 banned,否则封禁不真正生效");
assertEquals(Constants.ACCOUNT_STATUS_SUSPENDED,
userMapper.selectById(suspendUser.getId()).getAccountStatus(),
"暂停处置必须把 users.account_status 置为 suspended");
assertEquals(Constants.ACCOUNT_STATUS_NORMAL,
userMapper.selectById(warnUser.getId()).getAccountStatus(),
"警告处置不改变账号状态");
// 审计 before/after 为 JSONB:必须落合法 JSON 文本(裸字符串在 PG 上会被拒绝)。
AuditLog statusAudit = auditLogMapper.selectOne(new QueryWrapper<AuditLog>()
.eq("action", "ACCOUNT_STATUS_CHANGE").eq("resource_id", banUser.getId()));
assertNotNull(statusAudit, "处置同步账号状态应写入 ACCOUNT_STATUS_CHANGE 审计");
assertTrue(statusAudit.getAfterData().contains("\"accountStatus\":\"banned\""),
"审计 after_data 必须是合法 JSON 且记录新状态,实际: " + statusAudit.getAfterData());
}
private User insertUser(String accountStatus) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus(accountStatus);
userMapper.insert(user);
return user;
}
private void processReportFor(String reportedUserId, String processResult) {
ReportSubmitDTO dto = new ReportSubmitDTO();
dto.setReportedUserId(reportedUserId);
dto.setReasonType(Constants.REPORT_REASON_FRAUD);
dto.setDescription("处置同步账号状态测试");
String reportId = (String) reportService.submitReport(UUID.randomUUID().toString(), dto)
.get("reportId");
ReportProcessDTO pdto = new ReportProcessDTO();
pdto.setProcessResult(processResult);
pdto.setProcessNote("处置同步账号状态测试");
reportService.processReport(reportId, pdto, "op-account-sync");
}
// ==================== D2:人工风险标记必须审计 ====================
@Test
+4 -2
View File
@@ -453,7 +453,9 @@ CREATE TABLE activity_staff_assignments (
-- V009: matchmaker_services 红娘服务表
CREATE TABLE matchmaker_services (
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, service_type VARCHAR(32) NOT NULL,
description VARCHAR(1000), status VARCHAR(32) NOT NULL DEFAULT 'submitted',
description VARCHAR(1000), target_user_id VARCHAR(36), authorization_at TIMESTAMP WITH TIME ZONE,
withdrawn_at TIMESTAMP WITH TIME ZONE, withdraw_reason VARCHAR(500),
status VARCHAR(32) NOT NULL DEFAULT 'submitted',
organization_id VARCHAR(36), assigned_operator_id VARCHAR(36),
assigned_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE,
referral_target_type VARCHAR(32), referral_target_id VARCHAR(36),
@@ -464,7 +466,7 @@ CREATE TABLE matchmaker_services (
CONSTRAINT chk_matchmaker_service_type CHECK (service_type IN (
'recommend_partner', 'review_preference', 'contact_intention',
'join_activity', 'no_match_help', 'chat_help', 'other',
'recommendation',
'recommendation', 'recommendation_assistance',
'referral_counseling', 'referral_activity', 'referral_station'
)),
CONSTRAINT chk_matchmaker_status CHECK (status IN (