release: freeze 2026-10-15 validated release baseline
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
This commit is contained in:
+7
-1
@@ -29,6 +29,10 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
@Value("${wechat.app-id:dev-app-id}")
|
||||
private String appId;
|
||||
|
||||
/** 演示环境可固定到预置用户;生产真实微信登录不使用此 Provider。 */
|
||||
@Value("${wechat.mock.openid:}")
|
||||
private String configuredOpenid;
|
||||
|
||||
// 模拟 openid 映射
|
||||
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -37,7 +41,9 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
log.debug("[Mock] 微信登录 code: {}", code);
|
||||
|
||||
// 模拟:相同 code 返回相同 openid
|
||||
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
|
||||
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
|
||||
? configuredOpenid
|
||||
: codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
return Map.of(
|
||||
"openid", openid,
|
||||
|
||||
@@ -124,6 +124,7 @@ public class OperatorAuthService {
|
||||
result.put("accessToken", accessToken);
|
||||
result.put("refreshToken", refreshToken);
|
||||
result.put("operatorId", operator.getId());
|
||||
result.put("operatorDisplayId", operator.getDisplayId());
|
||||
result.put("name", operator.getName());
|
||||
result.put("username", operator.getUsername());
|
||||
result.put("organizationId", operator.getOrganizationId());
|
||||
@@ -157,6 +158,7 @@ public class OperatorAuthService {
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("operatorId", operator.getId());
|
||||
result.put("operatorDisplayId", operator.getDisplayId());
|
||||
result.put("name", operator.getName());
|
||||
result.put("username", operator.getUsername());
|
||||
result.put("username", operator.getUsername());
|
||||
@@ -204,6 +206,7 @@ public class OperatorAuthService {
|
||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||
result.put("operatorDisplayId", operatorInfo.get("operatorDisplayId"));
|
||||
result.put("name", operatorInfo.get("name"));
|
||||
result.put("username", operatorInfo.get("username"));
|
||||
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||
|
||||
+6
-4
@@ -51,7 +51,9 @@ public class MatchController {
|
||||
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
List<Match> matches = matchMapper.findActiveByUser(principal.id());
|
||||
List<MatchResponse> items = matches.stream().map(MatchResponse::from).collect(Collectors.toList());
|
||||
List<MatchResponse> items = matches.stream()
|
||||
.map(match -> MatchResponse.from(match, principal.id()))
|
||||
.collect(Collectors.toList());
|
||||
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
|
||||
return ApiResponse.ok(CursorPage.all(items));
|
||||
}
|
||||
@@ -67,7 +69,7 @@ public class MatchController {
|
||||
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
|
||||
return ApiResponse.forbidden();
|
||||
}
|
||||
return ApiResponse.ok(MatchResponse.from(m));
|
||||
return ApiResponse.ok(MatchResponse.from(m, principal.id()));
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@@ -166,13 +168,13 @@ public class MatchController {
|
||||
private java.time.Instant lastActivityAt;
|
||||
private Integer episode;
|
||||
|
||||
public static MatchResponse from(Match m) {
|
||||
public static MatchResponse from(Match m, String currentUserId) {
|
||||
MatchResponse r = new MatchResponse();
|
||||
r.id = m.getId();
|
||||
r.source = m.getSource();
|
||||
r.sourceId = m.getSourceId();
|
||||
r.status = m.getStatus();
|
||||
r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方
|
||||
r.peerUserId = currentUserId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
|
||||
r.matchedAt = m.getMatchedAt();
|
||||
r.lastActivityAt = m.getLastActivityAt();
|
||||
r.episode = m.getEpisode();
|
||||
|
||||
+11
@@ -4,6 +4,8 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -31,6 +33,8 @@ import java.util.Map;
|
||||
public class MatchmakerAssignmentController {
|
||||
|
||||
private final MatchmakerAssignmentService assignmentService;
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
|
||||
/**
|
||||
* 创建分配。
|
||||
@@ -132,9 +136,16 @@ public class MatchmakerAssignmentController {
|
||||
}
|
||||
|
||||
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
|
||||
var user = userMapper.findById(a.getUserId()).orElse(null);
|
||||
var profile = userProfileMapper.findByUserId(a.getUserId()).orElse(null);
|
||||
return Map.ofEntries(
|
||||
Map.entry("id", a.getId()),
|
||||
Map.entry("userId", a.getUserId()),
|
||||
Map.entry("userDisplayNumber", user == null || user.getDisplayNumber() == null ? "" : user.getDisplayNumber()),
|
||||
Map.entry("userDisplayName", profile == null || profile.getDisplayName() == null ? "" : profile.getDisplayName()),
|
||||
Map.entry("userGender", profile == null || profile.getGender() == null ? "" : profile.getGender()),
|
||||
Map.entry("userAge", profile == null || profile.getBirthDate() == null ? "" : String.valueOf(java.time.Period.between(profile.getBirthDate(), java.time.LocalDate.now()).getYears())),
|
||||
Map.entry("userRegionName", profile == null || profile.getRegionName() == null ? "" : profile.getRegionName()),
|
||||
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
|
||||
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
|
||||
Map.entry("assignmentScope", a.getAssignmentScope()),
|
||||
|
||||
+8
@@ -75,6 +75,14 @@ public class MatchmakerContactAuthorizationController {
|
||||
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id()));
|
||||
}
|
||||
|
||||
/** 审批岗待处理队列;服务层按平台管理员/组长组织范围过滤。 */
|
||||
@GetMapping("/pending")
|
||||
public ApiResponse<Map<String, Object>> pending(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(contactAuthorizationService.pendingApprovals(
|
||||
principal.id(), principal.permissions()));
|
||||
}
|
||||
|
||||
/** 申请人或审批人撤销(reason 必填)。 */
|
||||
@PostMapping("/{id}/revoke")
|
||||
public ApiResponse<MatchmakerContactAuthorization> revoke(
|
||||
|
||||
+8
@@ -64,6 +64,14 @@ public class MatchmakerController {
|
||||
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
|
||||
}
|
||||
|
||||
@Idempotent
|
||||
@PostMapping("/services/{id}/confirm")
|
||||
public ApiResponse<MatchmakerServiceRecord> confirmService(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
|
||||
}
|
||||
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
|
||||
+14
@@ -26,6 +26,20 @@ import java.util.Map;
|
||||
public class MatchmakerOperatorController {
|
||||
|
||||
private final MatchmakerServiceService matchmakerServiceService;
|
||||
private final com.danyang.match.platform.matchmaker.service.MatchmakerManagementService managementService;
|
||||
|
||||
@GetMapping("/management/services")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<List<Map<String, Object>>> managementServices(@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(managementService.list(principal));
|
||||
}
|
||||
|
||||
@GetMapping("/management/services/{id}")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<Map<String, Object>> managementDetail(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(managementService.detail(principal, id));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public ApiResponse<Map<String, Object>> getDashboard(
|
||||
|
||||
+5
@@ -28,6 +28,11 @@ public interface MatchmakerContactAuthorizationMapper extends BaseMapper<Matchma
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** 审批队列:仅返回待审批申请,组织范围由服务层按审批权限过滤。 */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE status = 'pending_approval' " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at ASC")
|
||||
List<MatchmakerContactAuthorization> findPendingApproval();
|
||||
|
||||
/** 用户待同意队列(pending_consent,C 端同意页数据源) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
|
||||
|
||||
+20
@@ -5,6 +5,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -12,6 +13,20 @@ import java.util.Optional;
|
||||
@Mapper
|
||||
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\", " +
|
||||
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
|
||||
"(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 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>")
|
||||
List<java.util.Map<String, Object>> findManagementServices(@Param("global") boolean global,
|
||||
@Param("organizationId") String organizationId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
|
||||
|
||||
@@ -68,6 +83,11 @@ 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);
|
||||
|
||||
/** 用户确认红娘服务方案:仅允许本人将 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")
|
||||
int confirmWaitingForUser(@Param("serviceId") String serviceId, @Param("userId") String userId);
|
||||
|
||||
/**
|
||||
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
||||
* 状态口径(submitted/assigned/in_service/waiting_user)。
|
||||
|
||||
+21
@@ -16,6 +16,8 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
@@ -78,6 +80,7 @@ public class ContactAuthorizationService {
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
|
||||
/** afterData JSON 序列化(静态初始化,不参与构造注入,避免测试桩连带改动)。 */
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
@@ -372,6 +375,24 @@ public class ContactAuthorizationService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 审批队列:平台管理员全量可见,组长仅可见本组织申请。 */
|
||||
public Map<String, Object> pendingApprovals(String approverOperatorId, Set<String> permissions) {
|
||||
assertApproverAuthority(approverOperatorId, "pending", permissions);
|
||||
List<MatchmakerContactAuthorization> rows = authorizationMapper.findPendingApproval();
|
||||
if (!isFallbackApprover(permissions)) {
|
||||
String approverOrg = operatorMapper.findById(approverOperatorId)
|
||||
.map(Operator::getOrganizationId).orElse(null);
|
||||
rows = rows.stream().filter(row -> operatorMapper.findById(row.getOperatorId())
|
||||
.map(Operator::getOrganizationId)
|
||||
.map(org -> java.util.Objects.equals(org, approverOrg)).orElse(false))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", rows.stream().map(this::toView).collect(Collectors.toList()));
|
||||
result.put("retentionDays", config.getRetentionDays());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** C 端:我的授权列表 + 待同意队列。 */
|
||||
public Map<String, Object> myAuthorizationsForUser(String userId) {
|
||||
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.danyang.match.platform.matchmaker.service;
|
||||
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 管理岗位只读服务进展;不复用普通红娘的本人分配权限,也不授予代操作权限。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerManagementService {
|
||||
private final MatchmakerServiceMapper serviceMapper;
|
||||
private final MatchmakerFollowupMapper followupMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
|
||||
public List<Map<String, Object>> list(OperatorPrincipal principal) {
|
||||
boolean global = principal.hasPermission("matchmaker.assign");
|
||||
if (!global && !principal.hasPermission("matchmaker.team.assign")) {
|
||||
throw new AccessDeniedException("无红娘服务管理权限");
|
||||
}
|
||||
String org = global ? null : operatorMapper.findById(principal.id())
|
||||
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"))
|
||||
.getOrganizationId();
|
||||
return serviceMapper.findManagementServices(global, org);
|
||||
}
|
||||
|
||||
public Map<String, Object> detail(OperatorPrincipal principal, String serviceId) {
|
||||
Map<String, Object> summary = list(principal).stream()
|
||||
.filter(row -> serviceId.equals(String.valueOf(row.get("serviceId"))))
|
||||
.findFirst().orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"));
|
||||
var detail = new HashMap<>(summary);
|
||||
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||
AuditLog audit = new AuditLog();
|
||||
audit.setOperatorId(principal.id());
|
||||
audit.setAction("MATCHMAKER_MANAGEMENT_VIEW");
|
||||
audit.setResourceType("matchmaker_service");
|
||||
audit.setResourceId(serviceId);
|
||||
audit.setPurposeCode("matchmaker.management.view");
|
||||
auditLogMapper.insert(audit);
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -48,6 +48,8 @@ public class MatchmakerService {
|
||||
private final RelationService relationService;
|
||||
private final MatchMapper matchMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper followupMapper;
|
||||
private final com.danyang.match.platform.shared.id.PublicIdMapper publicIdMapper;
|
||||
|
||||
// ==================== 用户端:提交红娘服务请求 ====================
|
||||
|
||||
@@ -78,6 +80,20 @@ public class MatchmakerService {
|
||||
return serviceMapper.findByUser(userId, Math.min(limit, 50));
|
||||
}
|
||||
|
||||
/** 用户本人确认红娘方案,服务恢复为进行中。 */
|
||||
@Transactional
|
||||
public MatchmakerServiceRecord confirmService(String serviceId, String userId) {
|
||||
if (serviceId == null || userId == null) {
|
||||
throw new IllegalArgumentException("服务和用户不能为空");
|
||||
}
|
||||
int updated = serviceMapper.confirmWaitingForUser(serviceId, userId);
|
||||
if (updated != 1) {
|
||||
throw new IllegalStateException("服务不存在或当前状态无需确认");
|
||||
}
|
||||
return serviceMapper.findById(serviceId)
|
||||
.orElseThrow(() -> new IllegalStateException("服务不存在"));
|
||||
}
|
||||
|
||||
// ==================== 操作人端:分配 + 推荐 + 转介 ====================
|
||||
|
||||
/**
|
||||
@@ -157,9 +173,7 @@ public class MatchmakerService {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
|
||||
// referral 记录写进 MatchmakerServiceRecord.description 的扩展 JSON
|
||||
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
|
||||
// 这里用 Map 简单记录,不引入新实体
|
||||
// 转介建议复用跟进记录持久化,独立 ZF 编号不替用户创建申请。
|
||||
ReferralRecord record = new ReferralRecord(
|
||||
UUID.randomUUID().toString(),
|
||||
serviceId,
|
||||
@@ -171,11 +185,22 @@ public class MatchmakerService {
|
||||
Instant.now()
|
||||
);
|
||||
|
||||
var followup = new com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup();
|
||||
followup.setId(record.id());
|
||||
followup.setServiceId(serviceId);
|
||||
followup.setOperatorId(operatorId);
|
||||
followup.setFollowupType("system");
|
||||
followup.setContent("转介建议:" + referralLabel(referralType) + (note == null ? "" : "," + note));
|
||||
followup.setCreatedAt(record.createdAt());
|
||||
followupMapper.insert(followup);
|
||||
String referralNumber = publicIdMapper.nextReferral(record.createdAt());
|
||||
if (publicIdMapper.saveReferral(record.id(), referralNumber) != 1) throw new IllegalStateException("转介编号写入失败");
|
||||
|
||||
// 通知用户被转介了
|
||||
notificationService.send(userId, "matchmaker_referral",
|
||||
"红娘为你转介了一个" + referralLabel(referralType),
|
||||
"公益红娘为你转介了" + referralLabel(referralType) +
|
||||
"(" + (referralTarget != null ? referralTarget : "") + "),你可以在" +
|
||||
"(转介编号:" + referralNumber + "),你可以在" +
|
||||
serviceLabel(referralType) + "页面申请。",
|
||||
"matchmaker_service", serviceId);
|
||||
|
||||
|
||||
+2
@@ -44,6 +44,7 @@ public class OperatorManagementService {
|
||||
List<Map<String, Object>> items = operators.stream().map(op -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", op.getId());
|
||||
m.put("displayId", op.getDisplayId());
|
||||
m.put("name", op.getName());
|
||||
m.put("username", op.getUsername());
|
||||
m.put("status", op.getStatus());
|
||||
@@ -62,6 +63,7 @@ public class OperatorManagementService {
|
||||
@Transactional
|
||||
public Operator createOperator(String name, String username, String password, String organizationId) {
|
||||
Operator operator = new Operator();
|
||||
operator.setId(UUID.randomUUID().toString());
|
||||
operator.setName(name);
|
||||
operator.setUsername(username);
|
||||
operator.setPasswordHash(passwordEncoder.encode(password));
|
||||
|
||||
@@ -54,6 +54,7 @@ public class OperatorService {
|
||||
});
|
||||
|
||||
Operator operator = new Operator();
|
||||
operator.setId(java.util.UUID.randomUUID().toString());
|
||||
operator.setName(name);
|
||||
operator.setUsername(username);
|
||||
operator.setPasswordHash(passwordEncoder.encode(password));
|
||||
|
||||
@@ -15,6 +15,8 @@ public class Operator extends BaseEntity {
|
||||
private String phoneEnc;
|
||||
private String phoneHash;
|
||||
private String username;
|
||||
/** 对外展示编号,内部 id 仍为 UUID。 */
|
||||
private String displayId;
|
||||
private String passwordHash;
|
||||
private String status;
|
||||
private java.time.Instant lastLoginAt;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package com.danyang.match.platform.profile.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.danyang.match.platform.shared.typehandler.JsonNodeTypeHandler;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("user_preferences")
|
||||
@TableName(value = "user_preferences", autoResultMap = true)
|
||||
public class UserPreference extends BaseEntity {
|
||||
|
||||
private String userId;
|
||||
@@ -17,8 +19,10 @@ public class UserPreference extends BaseEntity {
|
||||
private Integer minHeightCm;
|
||||
private Integer maxHeightCm;
|
||||
private String heightPriority;
|
||||
@TableField(typeHandler = JsonNodeTypeHandler.class)
|
||||
private com.fasterxml.jackson.databind.JsonNode regionScope;
|
||||
private String regionPriority;
|
||||
@TableField(typeHandler = JsonNodeTypeHandler.class)
|
||||
private com.fasterxml.jackson.databind.JsonNode maritalStatuses;
|
||||
private String maritalPriority;
|
||||
private String minEducationCode;
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.ResultMap;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -20,5 +21,6 @@ public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
|
||||
* <p>{@code deleted_at IS NULL}:V037 已为 user_preferences 补上软删列。
|
||||
*/
|
||||
@Select("SELECT * FROM user_preferences WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||
@ResultMap("mybatis-plus_UserPreference")
|
||||
Optional<UserPreference> findByUserId(String userId);
|
||||
}
|
||||
|
||||
+3
-2
@@ -227,9 +227,10 @@ public class ReferralService {
|
||||
private String getOperatorName(String operatorId) {
|
||||
try {
|
||||
var operator = operatorMapper.selectById(operatorId);
|
||||
return operator != null ? operator.getName() : operatorId;
|
||||
return operator != null ? operator.getName() : "公益红娘";
|
||||
} catch (Exception e) {
|
||||
return operatorId;
|
||||
log.warn("转介操作员名称查询失败", e);
|
||||
return "公益红娘";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
@org.springframework.core.annotation.Order(0)
|
||||
public class ApiResponseStatusAdvice implements ResponseBodyAdvice<ApiResponse> {
|
||||
|
||||
private static final Map<String, ApiResponseCode> BY_CODE =
|
||||
|
||||
@@ -100,7 +100,9 @@ public class SecurityConfig {
|
||||
"/activities/operator/**",
|
||||
"/service-appointments/operator/**",
|
||||
"/service-programs/operator/**",
|
||||
"/referrals/operator/**")
|
||||
"/referrals/operator/**",
|
||||
"/referrals/service-station",
|
||||
"/referrals/activity")
|
||||
.hasRole("OPERATOR")
|
||||
/**
|
||||
* 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
|
||||
|
||||
@@ -78,7 +78,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
if (token == null) {
|
||||
chain.doFilter(request, response);
|
||||
sendUnauthorized(response);
|
||||
return;
|
||||
}
|
||||
if (!jwtTokenService.validateToken(token)) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Map;
|
||||
|
||||
/** 仅解析已有写接口的人员/活动编号;业务层仍校验原 UUID 的权限与数据范围。 */
|
||||
@RestControllerAdvice
|
||||
@RequiredArgsConstructor
|
||||
public class PublicIdInputAdvice extends RequestBodyAdviceAdapter {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PublicIdMapper mapper;
|
||||
private static final Map<String, String> TYPES = Map.of(
|
||||
"userId", "users", "targetUserId", "users", "reportedUserId", "users",
|
||||
"counselorId", "operators", "assignedOperatorId", "operators",
|
||||
"matchmakerOperatorId", "operators", "newMatchmakerOperatorId", "operators",
|
||||
"activityId", "activities");
|
||||
@Override
|
||||
public boolean supports(MethodParameter method, Type targetType, Class<? extends HttpMessageConverter<?>> converter) {
|
||||
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converter);
|
||||
}
|
||||
@Override
|
||||
public HttpInputMessage beforeBodyRead(HttpInputMessage message, MethodParameter method, Type targetType,
|
||||
Class<? extends HttpMessageConverter<?>> converter) throws IOException {
|
||||
JsonNode body = objectMapper.readTree(message.getBody());
|
||||
resolve(body);
|
||||
byte[] bytes = objectMapper.writeValueAsBytes(body);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.putAll(message.getHeaders());
|
||||
headers.setContentLength(bytes.length);
|
||||
return new HttpInputMessage() {
|
||||
public InputStream getBody() { return new ByteArrayInputStream(bytes); }
|
||||
public HttpHeaders getHeaders() { return headers; }
|
||||
};
|
||||
}
|
||||
void resolve(JsonNode node) {
|
||||
if (node == null) return;
|
||||
if (node.isArray()) { node.forEach(this::resolve); return; }
|
||||
if (!node.isObject()) return;
|
||||
ObjectNode object = (ObjectNode) node;
|
||||
for (var entry : TYPES.entrySet()) {
|
||||
JsonNode value = object.get(entry.getKey());
|
||||
if (value == null || !value.isTextual() || !value.asText().matches("[A-Z]{2}[0-9]{11}")) continue;
|
||||
String id = mapper.resolve(value.asText(), entry.getValue());
|
||||
if (id == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "编号对应的对象不存在或不可用");
|
||||
object.put(entry.getKey(), id);
|
||||
}
|
||||
object.elements().forEachRemaining(child -> { if (child.isContainerNode()) resolve(child); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface PublicIdMapper {
|
||||
@Select({"<script>SELECT id AS \"id\", display_id AS \"displayId\", entity_type AS \"entityType\", referral_display_id AS \"referralDisplayId\" FROM public_display_ids WHERE id IN",
|
||||
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>CAST(#{id} AS text)</foreach></script>"})
|
||||
List<Map<String, Object>> findAll(@Param("ids") List<String> ids);
|
||||
|
||||
@Select("SELECT id FROM public_display_ids WHERE display_id=#{number} AND entity_type=#{entityType}")
|
||||
String resolve(@Param("number") String number, @Param("entityType") String entityType);
|
||||
|
||||
@Select("SELECT public_next_display_id('ZF', (#{createdAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date)")
|
||||
String nextReferral(@Param("createdAt") java.time.Instant createdAt);
|
||||
|
||||
@Update("UPDATE matchmaker_followups SET referral_display_id=#{number} WHERE id=#{id}::uuid AND referral_display_id IS NULL")
|
||||
int saveReferral(@Param("id") String id, @Param("number") String number);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 只补充原响应已允许返回的对象编号;不加载资料,不改变 UUID 与权限。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PublicIdResponse {
|
||||
private final PublicIdMapper mapper;
|
||||
private static final Pattern UUID = Pattern.compile("[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}");
|
||||
private static boolean reference(String name) {
|
||||
return "id".equals(name) || name.endsWith("Id");
|
||||
}
|
||||
public void enrich(JsonNode data) {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
collect(data, ids);
|
||||
if (ids.isEmpty()) return;
|
||||
Map<String, Map<String, Object>> numbers = new HashMap<>();
|
||||
for (var row : mapper.findAll(new ArrayList<>(ids))) {
|
||||
String id = row.get("id").toString();
|
||||
if (numbers.putIfAbsent(id, row) != null) throw new IllegalStateException("跨实体 UUID 冲突,不能确定公开编号");
|
||||
}
|
||||
apply(data, numbers);
|
||||
}
|
||||
private void collect(JsonNode node, Set<String> ids) {
|
||||
if (node.isObject()) node.fields().forEachRemaining(field -> {
|
||||
if (reference(field.getKey()) && field.getValue().isTextual() && UUID.matcher(field.getValue().asText()).matches()) ids.add(field.getValue().asText());
|
||||
else if (field.getValue().isContainerNode()) collect(field.getValue(), ids);
|
||||
});
|
||||
else if (node.isArray()) node.forEach(child -> collect(child, ids));
|
||||
}
|
||||
private void apply(JsonNode node, Map<String, Map<String, Object>> numbers) {
|
||||
if (node.isArray()) { node.forEach(child -> apply(child, numbers)); return; }
|
||||
if (!node.isObject()) return;
|
||||
var object = (ObjectNode) node;
|
||||
List<Map.Entry<String, JsonNode>> fields = new ArrayList<>();
|
||||
object.fields().forEachRemaining(fields::add);
|
||||
for (var field : fields) {
|
||||
String name = field.getKey();
|
||||
JsonNode value = field.getValue();
|
||||
if (value.isContainerNode()) { apply(value, numbers); continue; }
|
||||
if (!reference(name) || !value.isTextual() || !UUID.matcher(value.asText()).matches()) continue;
|
||||
var row = numbers.get(value.asText());
|
||||
String stem = name.equals("id") ? "" : name.substring(0, name.length()-2);
|
||||
boolean user = stem.toLowerCase(Locale.ROOT).endsWith("user") || (row != null && "users".equals(row.get("entityType")));
|
||||
String key = stem.isEmpty() ? "displayId" : stem + (user ? "DisplayNumber" : "DisplayId");
|
||||
Object number = row == null ? null : row.get("displayId");
|
||||
if (row != null && row.get("referralDisplayId") != null && "matchmaker_followups".equals(row.get("entityType"))) number = row.get("referralDisplayId");
|
||||
if (number == null) object.putNull(key); else object.put(key, number.toString());
|
||||
if (stem.isEmpty() && user) object.put("displayNumber", number == null ? null : number.toString());
|
||||
if (stem.isEmpty() && row != null && row.get("referralDisplayId") != null) object.put("referralDisplayId", row.get("referralDisplayId").toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.http.server.*;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
@RequiredArgsConstructor
|
||||
public class PublicIdResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PublicIdResponse publicIds;
|
||||
@Override
|
||||
public boolean supports(MethodParameter method, Class<? extends HttpMessageConverter<?>> converter) {
|
||||
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converter);
|
||||
}
|
||||
@Override
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
public Object beforeBodyWrite(Object body, MethodParameter method, MediaType type,
|
||||
Class<? extends HttpMessageConverter<?>> converter, ServerHttpRequest request, ServerHttpResponse response) {
|
||||
if (body instanceof ApiResponse api && api.isSuccess() && api.getData() != null) {
|
||||
var data = objectMapper.valueToTree(api.getData());
|
||||
publicIds.enrich(data);
|
||||
api.setData(data);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.danyang.match.platform.shared.typehandler;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import org.apache.ibatis.type.MappedJdbcTypes;
|
||||
import org.apache.ibatis.type.MappedTypes;
|
||||
|
||||
import java.sql.CallableStatement;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
|
||||
/** PostgreSQL JSONB 与 Jackson JsonNode 的参数/结果转换。 */
|
||||
@MappedTypes(JsonNode.class)
|
||||
@MappedJdbcTypes(JdbcType.OTHER)
|
||||
public class JsonNodeTypeHandler extends BaseTypeHandler<JsonNode> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType)
|
||||
throws SQLException {
|
||||
String json = parameter.toString();
|
||||
if ("PostgreSQL".equalsIgnoreCase(ps.getConnection().getMetaData().getDatabaseProductName())) {
|
||||
ps.setObject(i, json, Types.OTHER);
|
||||
} else {
|
||||
ps.setString(i, json);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||
return parse(rs.getString(columnName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||
return parse(rs.getString(columnIndex));
|
||||
}
|
||||
|
||||
@Override
|
||||
public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||
return parse(cs.getString(columnIndex));
|
||||
}
|
||||
|
||||
private JsonNode parse(String value) throws SQLException {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
return MAPPER.readTree(value);
|
||||
} catch (Exception ex) {
|
||||
throw new SQLException("JSONB 数据格式无效", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.danyang.match.platform.user;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/** 用户对外展示编号;内部 UUID 仍是唯一主键。 */
|
||||
public final class UserDisplayNumber {
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.BASIC_ISO_DATE;
|
||||
|
||||
private UserDisplayNumber() {}
|
||||
|
||||
public static String format(String gender, LocalDate registeredDate, int genderRank) {
|
||||
if ((!"male".equals(gender) && !"female".equals(gender)) || registeredDate == null || genderRank < 1) {
|
||||
throw new IllegalArgumentException("性别、注册日期或排名无效");
|
||||
}
|
||||
if (genderRank > ("male".equals(gender) ? 500 : 499)) throw new IllegalArgumentException("当日同性序号超过三位编号容量");
|
||||
int sequence = genderRank * 2 - ("male".equals(gender) ? 1 : 0);
|
||||
if (sequence > 999) throw new IllegalArgumentException("当日同性交序超过三位编号容量");
|
||||
return "YH" + DATE.format(registeredDate) + String.format("%03d", sequence);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
|
||||
*/
|
||||
public class UserDetailDTO {
|
||||
private String id;
|
||||
private String displayNumber;
|
||||
private String displayName;
|
||||
private String gender;
|
||||
private Integer age;
|
||||
@@ -34,6 +35,8 @@ public class UserDetailDTO {
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getDisplayNumber() { return displayNumber; }
|
||||
public void setDisplayNumber(String displayNumber) { this.displayNumber = displayNumber; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getGender() { return gender; }
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
|
||||
*/
|
||||
public class UserListItemDTO {
|
||||
private String id;
|
||||
private String displayNumber;
|
||||
private String displayName;
|
||||
private String gender;
|
||||
private Integer age;
|
||||
@@ -19,6 +20,8 @@ public class UserListItemDTO {
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getDisplayNumber() { return displayNumber; }
|
||||
public void setDisplayNumber(String displayNumber) { this.displayNumber = displayNumber; }
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
public String getGender() { return gender; }
|
||||
|
||||
@@ -19,6 +19,8 @@ public class User extends BaseEntity {
|
||||
|
||||
@TableField("phone")
|
||||
private String phone;
|
||||
@TableField("display_number")
|
||||
private String displayNumber;
|
||||
@TableField("account_status")
|
||||
private String accountStatus;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ public class UserService {
|
||||
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
query.and(q -> q.like(User::getId, keyword)
|
||||
.or().like(User::getDisplayNumber, keyword)
|
||||
.or().like(User::getWechatOpenid, keyword));
|
||||
}
|
||||
if (accountStatus != null && !accountStatus.isBlank()) {
|
||||
@@ -71,7 +72,7 @@ public class UserService {
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<User> countQuery = new LambdaQueryWrapper<User>();
|
||||
if (keyword != null && !keyword.isBlank()) countQuery.and(q -> q.like(User::getId, keyword).or().like(User::getWechatOpenid, keyword));
|
||||
if (keyword != null && !keyword.isBlank()) countQuery.and(q -> q.like(User::getId, keyword).or().like(User::getDisplayNumber, keyword).or().like(User::getWechatOpenid, keyword));
|
||||
if (accountStatus != null && !accountStatus.isBlank()) countQuery.eq(User::getAccountStatus, accountStatus);
|
||||
if (profileStatus != null && !profileStatus.isBlank()) {
|
||||
List<String> userIds = userProfileMapper.selectList(
|
||||
@@ -155,6 +156,7 @@ public class UserService {
|
||||
private UserListItemDTO toListItemDTO(User user) {
|
||||
UserListItemDTO dto = new UserListItemDTO();
|
||||
dto.setId(user.getId());
|
||||
dto.setDisplayNumber(user.getDisplayNumber());
|
||||
dto.setAccountStatus(user.getAccountStatus());
|
||||
dto.setRegisteredAt(user.getCreatedAt());
|
||||
dto.setRelationshipStatus(user.getRelationshipStatus());
|
||||
@@ -189,6 +191,7 @@ public class UserService {
|
||||
int reportCount, boolean hasSensitiveView) {
|
||||
UserDetailDTO dto = new UserDetailDTO();
|
||||
dto.setId(user.getId());
|
||||
dto.setDisplayNumber(user.getDisplayNumber());
|
||||
dto.setAccountStatus(user.getAccountStatus());
|
||||
dto.setRelationshipStatus(user.getRelationshipStatus());
|
||||
dto.setRegisteredAt(user.getCreatedAt());
|
||||
|
||||
+12
@@ -7,10 +7,22 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.time.Instant;
|
||||
|
||||
@Mapper
|
||||
public interface UserIdentityMapper extends BaseMapper<UserIdentity> {
|
||||
|
||||
@Select("SELECT pg_advisory_xact_lock(hashtext(#{gender} || ':' || ((#{registeredAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date)::text))")
|
||||
void lockDisplayNumberSlot(@Param("gender") String gender, @Param("registeredAt") Instant registeredAt);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM user_identities i JOIN users u ON u.id = i.user_id "
|
||||
+ "WHERE i.gender = #{gender} AND i.verification_status = 'verified' "
|
||||
+ "AND (u.created_at AT TIME ZONE 'Asia/Shanghai')::date = (#{registeredAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date "
|
||||
+ "AND (u.created_at < #{registeredAt} OR (u.created_at = #{registeredAt} AND u.id < #{userId}::uuid))")
|
||||
int countEarlierSameDayGender(@Param("gender") String gender,
|
||||
@Param("registeredAt") Instant registeredAt,
|
||||
@Param("userId") String userId);
|
||||
|
||||
@Select("SELECT * FROM user_identities WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserIdentity> findByUserId(@Param("userId") String userId);
|
||||
|
||||
|
||||
+2
@@ -119,6 +119,8 @@ public class IdentityVerificationService {
|
||||
identity.setVerifiedAt(java.time.Instant.now());
|
||||
userIdentityMapper.insert(identity);
|
||||
|
||||
// V060 在实名记录写入事务中通过数据库原子计数器生成展示编号。
|
||||
|
||||
// 11. 更新用户表的认证状态标记
|
||||
saveAuditLog(userId, null, "IDENTITY_VERIFY", "user", userId, null, null);
|
||||
log.info("用户实名认证成功: userId={}, age={}, gender={}", userId, age, gender);
|
||||
|
||||
@@ -16,6 +16,8 @@ spring:
|
||||
wechat:
|
||||
app-id: ${WECHAT_APP_ID:}
|
||||
app-secret: ${WECHAT_APP_SECRET:}
|
||||
mock:
|
||||
openid: ${WECHAT_MOCK_OPENID:}
|
||||
|
||||
encryption:
|
||||
key: ${ENCRYPTION_KEY}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_number VARCHAR(32);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_display_number ON users(display_number)
|
||||
WHERE display_number IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
-- 历史用户的编号由实名性别、注册日和当日同性交序生成;未实名或 gender=other 暂不生成。
|
||||
WITH ranked AS (
|
||||
SELECT u.id, i.gender, (u.created_at AT TIME ZONE 'Asia/Shanghai')::date AS registered_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date, i.gender
|
||||
ORDER BY u.created_at, u.id
|
||||
) AS gender_rank
|
||||
FROM users u
|
||||
JOIN user_identities i ON i.user_id = u.id
|
||||
WHERE u.display_number IS NULL
|
||||
AND i.verification_status = 'verified'
|
||||
AND i.gender IN ('male', 'female')
|
||||
AND u.deleted_at IS NULL
|
||||
)
|
||||
UPDATE users u
|
||||
SET display_number = '321181' || TO_CHAR(r.registered_date, 'YYYYMMDD') ||
|
||||
LPAD((r.gender_rank * 2 - CASE WHEN r.gender = 'male' THEN 1 ELSE 0 END)::text, 4, '0')
|
||||
FROM ranked r
|
||||
WHERE u.id = r.id;
|
||||
@@ -0,0 +1,41 @@
|
||||
ALTER TABLE operators ADD COLUMN IF NOT EXISTS display_id VARCHAR(32);
|
||||
|
||||
-- 历史用户编号统一为 YH + 注册日期 + 当日性别序号(男奇女偶)。
|
||||
WITH ranked AS (
|
||||
SELECT u.id, i.gender,
|
||||
(u.created_at AT TIME ZONE 'Asia/Shanghai')::date AS registered_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date, i.gender
|
||||
ORDER BY u.created_at, u.id
|
||||
) AS gender_rank
|
||||
FROM users u JOIN user_identities i ON i.user_id = u.id
|
||||
WHERE u.display_number IS NOT NULL AND i.verification_status = 'verified'
|
||||
AND i.gender IN ('male', 'female') AND u.deleted_at IS NULL
|
||||
)
|
||||
UPDATE users u SET display_number = 'YH' || TO_CHAR(r.registered_date, 'YYYYMMDD') ||
|
||||
LPAD((r.gender_rank * 2 - CASE WHEN r.gender = 'male' THEN 1 ELSE 0 END)::text, 2, '0')
|
||||
FROM ranked r WHERE u.id = r.id AND r.gender_rank <= 50;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_operators_display_id ON operators(display_id)
|
||||
WHERE display_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
-- 运营人员:红娘前缀 HN,其他管理员/运营人员前缀 GL。
|
||||
WITH ranked AS (
|
||||
SELECT o.id,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM operator_roles ort JOIN roles r ON r.id = ort.role_id
|
||||
WHERE ort.operator_id = o.id AND r.code IN ('matchmaker', 'matchmaker_leader')
|
||||
) THEN 'HN' ELSE 'GL' END AS prefix,
|
||||
(o.created_at AT TIME ZONE 'Asia/Shanghai')::date AS created_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (o.created_at AT TIME ZONE 'Asia/Shanghai')::date,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1 FROM operator_roles ort JOIN roles r ON r.id = ort.role_id
|
||||
WHERE ort.operator_id = o.id AND r.code IN ('matchmaker', 'matchmaker_leader')
|
||||
) THEN 'HN' ELSE 'GL' END
|
||||
ORDER BY o.created_at, o.id
|
||||
) AS seq
|
||||
FROM operators o WHERE o.display_id IS NULL AND o.deleted_at IS NULL
|
||||
)
|
||||
UPDATE operators o SET display_id = r.prefix || TO_CHAR(r.created_date, 'YYYYMMDD') || LPAD(r.seq::text, 2, '0')
|
||||
FROM ranked r WHERE o.id = r.id AND r.seq <= 99;
|
||||
@@ -0,0 +1,195 @@
|
||||
-- 公开编号由 PostgreSQL 与领域写入同事务分配;所有 UUID 主外键保持不变。
|
||||
CREATE TABLE public_display_id_counters (
|
||||
prefix VARCHAR(2) NOT NULL,
|
||||
local_date DATE NOT NULL,
|
||||
sequence_group VARCHAR(8) NOT NULL DEFAULT '',
|
||||
last_sequence INTEGER NOT NULL CHECK (last_sequence BETWEEN 1 AND 999),
|
||||
PRIMARY KEY (prefix, local_date, sequence_group)
|
||||
);
|
||||
CREATE TABLE public_display_id_aliases (
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
old_display_id VARCHAR(64) NOT NULL,
|
||||
new_display_id VARCHAR(13),
|
||||
PRIMARY KEY (entity_type, entity_id, old_display_id)
|
||||
);
|
||||
INSERT INTO public_display_id_aliases(entity_type, entity_id, old_display_id)
|
||||
SELECT 'users', id, display_number FROM users WHERE display_number IS NOT NULL
|
||||
UNION ALL SELECT 'operators', id, display_id FROM operators WHERE display_id IS NOT NULL;
|
||||
|
||||
CREATE FUNCTION public_next_display_id(p_prefix TEXT, p_date DATE, p_group TEXT DEFAULT '')
|
||||
RETURNS TEXT LANGUAGE plpgsql AS $$
|
||||
DECLARE n INTEGER; step INTEGER; first_value INTEGER;
|
||||
BEGIN
|
||||
IF p_prefix NOT IN ('YH','HN','GL','RW','HD','FW','TJ','LX','FP','GJ','JB','ZF','YY')
|
||||
OR p_date IS NULL OR p_group IS NULL
|
||||
OR (p_prefix = 'YH' AND p_group NOT IN ('male','female'))
|
||||
OR (p_prefix <> 'YH' AND p_group <> '') THEN
|
||||
RAISE EXCEPTION '公开编号类型、日期或性别无效';
|
||||
END IF;
|
||||
step := CASE WHEN p_prefix = 'YH' THEN 2 ELSE 1 END;
|
||||
first_value := CASE WHEN p_group = 'female' THEN 2 ELSE 1 END;
|
||||
INSERT INTO public_display_id_counters(prefix, local_date, sequence_group, last_sequence)
|
||||
VALUES (p_prefix, p_date, p_group, first_value)
|
||||
ON CONFLICT (prefix, local_date, sequence_group) DO UPDATE
|
||||
SET last_sequence = public_display_id_counters.last_sequence + step
|
||||
WHERE public_display_id_counters.last_sequence + step <= 999
|
||||
RETURNING last_sequence INTO n;
|
||||
IF n IS NULL THEN RAISE EXCEPTION '当日公开编号容量已满'; END IF;
|
||||
RETURN p_prefix || TO_CHAR(p_date, 'YYYYMMDD') || LPAD(n::TEXT, 3, '0');
|
||||
END $$;
|
||||
|
||||
-- 历史数据预检先于回填;包含软删除记录,避免编号复用。
|
||||
DO $$
|
||||
DECLARE item RECORD; overflow_count INTEGER;
|
||||
BEGIN
|
||||
FOR item IN SELECT * FROM (VALUES
|
||||
('activities','HD'),('review_tasks','RW'),('reports','JB'),
|
||||
('matchmaker_services','FW'),('matchmaker_recommendations','TJ'),
|
||||
('matchmaker_contact_authorizations','LX'),('matchmaker_assignments','FP'),
|
||||
('matchmaker_followups','GJ'),('service_appointments','YY')) AS t(tab,prefix)
|
||||
LOOP
|
||||
EXECUTE FORMAT('SELECT COUNT(*) FROM (SELECT 1 FROM %I GROUP BY (created_at AT TIME ZONE ''Asia/Shanghai'')::date HAVING COUNT(*) > 999) s', item.tab) INTO overflow_count;
|
||||
IF overflow_count > 0 THEN RAISE EXCEPTION '历史编号超出容量: %', item.tab; END IF;
|
||||
EXECUTE FORMAT('ALTER TABLE %I ADD COLUMN display_id VARCHAR(13)', item.tab);
|
||||
EXECUTE FORMAT('WITH ranked AS (SELECT id, (created_at AT TIME ZONE ''Asia/Shanghai'')::date AS d, ROW_NUMBER() OVER (PARTITION BY (created_at AT TIME ZONE ''Asia/Shanghai'')::date ORDER BY created_at,id) AS n FROM %I) UPDATE %I t SET display_id = %L || TO_CHAR(r.d,''YYYYMMDD'') || LPAD(r.n::text,3,''0'') FROM ranked r WHERE t.id=r.id', item.tab, item.tab, item.prefix);
|
||||
EXECUTE FORMAT('ALTER TABLE %I ALTER COLUMN display_id SET NOT NULL', item.tab);
|
||||
EXECUTE FORMAT('CREATE UNIQUE INDEX %I ON %I(display_id)', 'uk_' || item.tab || '_public_display_id', item.tab);
|
||||
EXECUTE FORMAT('ALTER TABLE %I ADD CONSTRAINT %I CHECK (display_id ~ %L)', item.tab, 'ck_' || item.tab || '_public_display_id', '^' || item.prefix || '[0-9]{11}$');
|
||||
EXECUTE FORMAT('INSERT INTO public_display_id_counters SELECT %L, (created_at AT TIME ZONE ''Asia/Shanghai'')::date, '''', MAX(RIGHT(display_id,3)::int) FROM %I GROUP BY (created_at AT TIME ZONE ''Asia/Shanghai'')::date', item.prefix, item.tab);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
WITH identities AS (
|
||||
SELECT DISTINCT ON (user_id) user_id, gender FROM user_identities
|
||||
WHERE verification_status='verified' AND gender IN ('male','female')
|
||||
ORDER BY user_id, verified_at DESC NULLS LAST, id
|
||||
), ranked AS (
|
||||
SELECT u.id, i.gender, (u.created_at AT TIME ZONE 'Asia/Shanghai')::date AS d,
|
||||
ROW_NUMBER() OVER (PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date,i.gender ORDER BY u.created_at,u.id) * 2
|
||||
- CASE WHEN i.gender='male' THEN 1 ELSE 0 END AS n
|
||||
FROM users u JOIN identities i ON i.user_id=u.id
|
||||
)
|
||||
UPDATE users u SET display_number='YH'||TO_CHAR(r.d,'YYYYMMDD')||LPAD(r.n::text,3,'0')
|
||||
FROM ranked r WHERE u.id=r.id AND r.n<=999;
|
||||
|
||||
-- 未实名者允许无编号;已有旧号不能静默丢失或截断。
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM users WHERE display_number IS NOT NULL AND display_number !~ '^YH[0-9]{11}$') THEN
|
||||
RAISE EXCEPTION '用户历史编号或性别异常,需核对后再迁移';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM (
|
||||
SELECT u.id, ROW_NUMBER() OVER (PARTITION BY (u.created_at AT TIME ZONE 'Asia/Shanghai')::date, i.gender ORDER BY u.created_at,u.id)*2-CASE WHEN i.gender='male' THEN 1 ELSE 0 END n
|
||||
FROM users u JOIN (SELECT DISTINCT ON(user_id) user_id,gender FROM user_identities WHERE verification_status='verified' AND gender IN ('male','female') ORDER BY user_id,verified_at DESC NULLS LAST,id) i ON i.user_id=u.id
|
||||
) x WHERE n>999) THEN RAISE EXCEPTION '历史用户编号容量不足'; END IF;
|
||||
END $$;
|
||||
CREATE UNIQUE INDEX uk_users_public_display_number ON users(display_number) WHERE display_number IS NOT NULL;
|
||||
ALTER TABLE users ADD CONSTRAINT ck_users_public_display_number CHECK (display_number IS NULL OR display_number ~ '^YH[0-9]{11}$');
|
||||
INSERT INTO public_display_id_counters
|
||||
SELECT 'YH', (created_at AT TIME ZONE 'Asia/Shanghai')::date,
|
||||
CASE WHEN RIGHT(display_number,3)::int % 2=1 THEN 'male' ELSE 'female' END,
|
||||
MAX(RIGHT(display_number,3)::int)
|
||||
FROM users WHERE display_number IS NOT NULL GROUP BY 2,3;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT o.id, (o.created_at AT TIME ZONE 'Asia/Shanghai')::date AS d,
|
||||
CASE WHEN EXISTS(SELECT 1 FROM operator_roles ort JOIN roles r ON r.id=ort.role_id WHERE ort.operator_id=o.id AND r.code IN ('matchmaker','matchmaker_leader')) THEN 'HN' ELSE 'GL' END AS prefix
|
||||
FROM operators o
|
||||
), numbered AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY d,prefix ORDER BY (SELECT created_at FROM operators WHERE id=ranked.id),id) AS n FROM ranked
|
||||
)
|
||||
UPDATE operators o SET display_id=r.prefix||TO_CHAR(r.d,'YYYYMMDD')||LPAD(r.n::text,3,'0') FROM numbered r WHERE o.id=r.id AND r.n<=999;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS(SELECT 1 FROM operators WHERE display_id IS NULL OR display_id !~ '^(HN|GL)[0-9]{11}$') THEN RAISE EXCEPTION '工作人员历史编号容量或日期异常'; END IF;
|
||||
END $$;
|
||||
ALTER TABLE operators ALTER COLUMN display_id SET NOT NULL;
|
||||
ALTER TABLE operators ADD CONSTRAINT ck_operators_public_display_id CHECK (display_id ~ '^(HN|GL)[0-9]{11}$');
|
||||
CREATE UNIQUE INDEX uk_operators_public_display_id ON operators(display_id);
|
||||
INSERT INTO public_display_id_counters SELECT LEFT(display_id,2), (created_at AT TIME ZONE 'Asia/Shanghai')::date,'',MAX(RIGHT(display_id,3)::int) FROM operators GROUP BY 1,2;
|
||||
UPDATE public_display_id_aliases a SET new_display_id=u.display_number FROM users u WHERE a.entity_type='users' AND a.entity_id=u.id;
|
||||
UPDATE public_display_id_aliases a SET new_display_id=o.display_id FROM operators o WHERE a.entity_type='operators' AND a.entity_id=o.id;
|
||||
|
||||
ALTER TABLE service_appointments ADD COLUMN referral_display_id VARCHAR(13);
|
||||
ALTER TABLE activity_registrations ADD COLUMN referral_display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_followups ADD COLUMN referral_display_id VARCHAR(13);
|
||||
WITH referrals AS (
|
||||
SELECT 'service_appointments' tab,id,created_at FROM service_appointments WHERE source='matchmaker_referral'
|
||||
UNION ALL SELECT 'activity_registrations',id,created_at FROM activity_registrations WHERE source='matchmaker_referral'
|
||||
), numbered AS (
|
||||
SELECT *, ROW_NUMBER() OVER(PARTITION BY (created_at AT TIME ZONE 'Asia/Shanghai')::date ORDER BY created_at,tab,id) n FROM referrals
|
||||
)
|
||||
INSERT INTO public_display_id_aliases(entity_type,entity_id,old_display_id,new_display_id)
|
||||
SELECT tab,id,'referral:'||id::text,'ZF'||TO_CHAR(created_at AT TIME ZONE 'Asia/Shanghai','YYYYMMDD')||LPAD(n::text,3,'0') FROM numbered WHERE n<=999;
|
||||
UPDATE service_appointments t SET referral_display_id=a.new_display_id FROM public_display_id_aliases a WHERE a.entity_type='service_appointments' AND a.entity_id=t.id;
|
||||
UPDATE activity_registrations t SET referral_display_id=a.new_display_id FROM public_display_id_aliases a WHERE a.entity_type='activity_registrations' AND a.entity_id=t.id;
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM service_appointments WHERE source='matchmaker_referral' AND referral_display_id IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM activity_registrations WHERE source='matchmaker_referral' AND referral_display_id IS NULL) THEN RAISE EXCEPTION '历史转介编号容量不足'; END IF;
|
||||
END $$;
|
||||
INSERT INTO public_display_id_counters SELECT 'ZF',TO_DATE(SUBSTRING(new_display_id,3,8),'YYYYMMDD'),'',MAX(RIGHT(new_display_id,3)::int) FROM public_display_id_aliases WHERE new_display_id LIKE 'ZF%' GROUP BY 2;
|
||||
|
||||
CREATE FUNCTION public_assign_display_id() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE prefix TEXT;
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' THEN
|
||||
IF OLD.display_id IS DISTINCT FROM NEW.display_id THEN RAISE EXCEPTION '公开编号不可更改'; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
prefix:=TG_ARGV[0];
|
||||
IF prefix='operator' THEN
|
||||
prefix:=CASE WHEN EXISTS(SELECT 1 FROM operator_roles ort JOIN roles r ON r.id=ort.role_id WHERE ort.operator_id=NEW.id AND r.code IN ('matchmaker','matchmaker_leader')) THEN 'HN' ELSE 'GL' END;
|
||||
END IF;
|
||||
NEW.display_id:=public_next_display_id(prefix,(COALESCE(NEW.created_at,NOW()) AT TIME ZONE 'Asia/Shanghai')::date);
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DO $$ DECLARE item RECORD; BEGIN
|
||||
FOR item IN SELECT * FROM (VALUES ('activities','HD'),('review_tasks','RW'),('reports','JB'),('matchmaker_services','FW'),('matchmaker_recommendations','TJ'),('matchmaker_contact_authorizations','LX'),('matchmaker_assignments','FP'),('matchmaker_followups','GJ'),('service_appointments','YY'),('operators','operator')) t(tab,prefix)
|
||||
LOOP EXECUTE FORMAT('CREATE TRIGGER assign_public_display_id BEFORE INSERT OR UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION public_assign_display_id(%L)',item.tab,item.prefix); END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION public_assign_user_number() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE d DATE; number TEXT;
|
||||
BEGIN
|
||||
IF NEW.verification_status='verified' AND NEW.gender IN ('male','female') THEN
|
||||
SELECT display_number,(created_at AT TIME ZONE 'Asia/Shanghai')::date INTO number,d FROM users WHERE id=NEW.user_id FOR UPDATE;
|
||||
IF number IS NULL THEN UPDATE users SET display_number=public_next_display_id('YH',d,NEW.gender) WHERE id=NEW.user_id; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER assign_public_user_number AFTER INSERT OR UPDATE ON user_identities FOR EACH ROW EXECUTE FUNCTION public_assign_user_number();
|
||||
|
||||
CREATE FUNCTION public_assign_referral_number() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='UPDATE' AND OLD.referral_display_id IS NOT NULL AND OLD.referral_display_id IS DISTINCT FROM NEW.referral_display_id THEN RAISE EXCEPTION '转介编号不可更改'; END IF;
|
||||
IF NEW.source='matchmaker_referral' AND NEW.referral_display_id IS NULL THEN NEW.referral_display_id:=public_next_display_id('ZF',(COALESCE(NEW.created_at,NOW()) AT TIME ZONE 'Asia/Shanghai')::date); END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER assign_referral_number BEFORE INSERT OR UPDATE ON service_appointments FOR EACH ROW EXECUTE FUNCTION public_assign_referral_number();
|
||||
CREATE TRIGGER assign_referral_number BEFORE INSERT OR UPDATE ON activity_registrations FOR EACH ROW EXECUTE FUNCTION public_assign_referral_number();
|
||||
CREATE UNIQUE INDEX uk_appointments_referral_number ON service_appointments(referral_display_id);
|
||||
CREATE UNIQUE INDEX uk_registrations_referral_number ON activity_registrations(referral_display_id);
|
||||
CREATE UNIQUE INDEX uk_followups_referral_number ON matchmaker_followups(referral_display_id);
|
||||
ALTER TABLE service_appointments ADD CONSTRAINT ck_appointments_referral_number CHECK (referral_display_id IS NULL OR referral_display_id ~ '^ZF[0-9]{11}$');
|
||||
ALTER TABLE activity_registrations ADD CONSTRAINT ck_registrations_referral_number CHECK (referral_display_id IS NULL OR referral_display_id ~ '^ZF[0-9]{11}$');
|
||||
ALTER TABLE matchmaker_followups ADD CONSTRAINT ck_followups_referral_number CHECK (referral_display_id IS NULL OR referral_display_id ~ '^ZF[0-9]{11}$');
|
||||
|
||||
CREATE FUNCTION public_keep_user_number() RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF OLD.display_number IS NOT NULL AND OLD.display_number IS DISTINCT FROM NEW.display_number THEN RAISE EXCEPTION '用户公开编号不可更改'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER keep_public_user_number BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION public_keep_user_number();
|
||||
|
||||
CREATE VIEW public_display_ids AS
|
||||
SELECT id::text id,display_number display_id,'users'::text entity_type,NULL::text referral_display_id FROM users
|
||||
UNION ALL SELECT id::text,display_id,'operators',NULL FROM operators
|
||||
UNION ALL SELECT id::text,display_id,'activities',NULL FROM activities
|
||||
UNION ALL SELECT id::text,display_id,'review_tasks',NULL FROM review_tasks
|
||||
UNION ALL SELECT id::text,display_id,'reports',NULL FROM reports
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_services',NULL FROM matchmaker_services
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_recommendations',NULL FROM matchmaker_recommendations
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_contact_authorizations',NULL FROM matchmaker_contact_authorizations
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_assignments',NULL FROM matchmaker_assignments
|
||||
UNION ALL SELECT id::text,display_id,'matchmaker_followups',referral_display_id FROM matchmaker_followups
|
||||
UNION ALL SELECT id::text,display_id,'service_appointments',referral_display_id FROM service_appointments
|
||||
UNION ALL SELECT id::text,referral_display_id,'activity_registrations',referral_display_id FROM activity_registrations WHERE referral_display_id IS NOT NULL;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class MockWechatLoginProviderTest {
|
||||
@Test
|
||||
void configuredDemoOpenidAlwaysResolvesToTheExistingDemoUser() {
|
||||
MockWechatLoginProvider provider = new MockWechatLoginProvider();
|
||||
ReflectionTestUtils.setField(provider, "configuredOpenid", "demo_openid_f06");
|
||||
ReflectionTestUtils.setField(provider, "appId", "dev");
|
||||
|
||||
assertEquals("demo_openid_f06", provider.getCodeSession("wx-code-1").get("openid"));
|
||||
assertEquals("demo_openid_f06", provider.getCodeSession("wx-code-2").get("openid"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -493,7 +493,7 @@ class EntityMigrationParityTest {
|
||||
|
||||
for (Path f : entityFiles) {
|
||||
String src = Files.readString(f);
|
||||
Matcher tm = Pattern.compile("@TableName\\(\\s*\"([^\"]+)\"").matcher(src);
|
||||
Matcher tm = Pattern.compile("@TableName\\(\\s*(?:value\\s*=\\s*)?\"([^\"]+)\"").matcher(src);
|
||||
if (!tm.find()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@ class MigrationValidationTest {
|
||||
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
|
||||
// ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表
|
||||
// (六态状态机 + active 部分唯一索引)
|
||||
private static final int LATEST_MIGRATION = 57;
|
||||
// 用户展示编号:V058 users.display_number 及历史数据回填。
|
||||
// 公共身份展示编号:V059 operators.display_id 及历史数据回填。
|
||||
private static final int LATEST_MIGRATION = 60;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ class MatchmakerActionGuardTest {
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private ReferralService referralService;
|
||||
|
||||
private static final String MATCHMAKER_OWNER = "op-ag-owner";
|
||||
private static final String MATCHMAKER_OWNER = "00000000-0000-0000-0000-000000000103";
|
||||
|
||||
private String user;
|
||||
private String target;
|
||||
|
||||
+2
-1
@@ -202,7 +202,8 @@ class MatchmakerAssignmentA1Test {
|
||||
// standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策)
|
||||
MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class);
|
||||
MockMvc mockMvc = MockMvcBuilders
|
||||
.standaloneSetup(new MatchmakerOperatorController(stubService))
|
||||
.standaloneSetup(new MatchmakerOperatorController(stubService,
|
||||
org.mockito.Mockito.mock(com.danyang.match.platform.matchmaker.service.MatchmakerManagementService.class)))
|
||||
.build();
|
||||
|
||||
mockMvc.perform(post("/operator/matchmaker/services/some-id/claim"))
|
||||
|
||||
-2
@@ -297,8 +297,6 @@ class MatchmakerContactAuthorizationTest {
|
||||
assertNotNull(denyAudit, "业务回滚后拒绝审计必须独立事务落库(REQUIRES_NEW)");
|
||||
} finally {
|
||||
// 显式清理(本用例数据真实提交,不随测试事务回滚)
|
||||
auditLogMapper.delete(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getResourceId, auth.getId()));
|
||||
auditLogMapper.delete(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getSubjectUserId, otherUser));
|
||||
authorizationMapper.deleteById(auth.getId());
|
||||
|
||||
+2
-2
@@ -40,8 +40,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@Transactional
|
||||
class MatchmakerContactDisabledTest {
|
||||
|
||||
private static final String OPERATOR_ID = "disabled-test-operator-0001";
|
||||
private static final String USER_ID = "disabled-test-user-0001";
|
||||
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000101";
|
||||
private static final String USER_ID = "00000000-0000-0000-0000-000000000102";
|
||||
|
||||
@Autowired private ContactAuthorizationService contactAuthorizationService;
|
||||
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerManagementService;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerManagementReadTest {
|
||||
@Autowired MatchmakerManagementService service;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
String manager, leader, otherLeader, user, serviceId;
|
||||
|
||||
@BeforeEach void seed() {
|
||||
manager = UUID.randomUUID().toString();
|
||||
leader = UUID.randomUUID().toString();
|
||||
otherLeader = UUID.randomUUID().toString();
|
||||
String org = UUID.randomUUID().toString();
|
||||
String mm = UUID.randomUUID().toString();
|
||||
user = UUID.randomUUID().toString();
|
||||
serviceId = UUID.randomUUID().toString();
|
||||
for (String id : new String[]{manager, leader, otherLeader, mm}) {
|
||||
jdbc.update("INSERT INTO operators(id,name,username,password_hash,organization_id) VALUES(?,?,?,?,?)",
|
||||
id, "验收人员", id, "test", id.equals(otherLeader) ? UUID.randomUUID().toString() : org);
|
||||
}
|
||||
jdbc.update("INSERT INTO users(id,wechat_openid,display_number) VALUES(?,?,?)", user, user, "YH2026101302");
|
||||
jdbc.update("INSERT INTO matchmaker_services(id,user_id,service_type,status,assigned_operator_id) VALUES(?,?,?,?,?)",
|
||||
serviceId, user, "other", "waiting_user", mm);
|
||||
jdbc.update("INSERT INTO matchmaker_followups(id,service_id,operator_id,content) VALUES(?,?,?,?)",
|
||||
UUID.randomUUID().toString(), serviceId, mm, "验收跟进");
|
||||
}
|
||||
|
||||
@Test void managerReadsPersistedFollowupAndAuditsAccess() {
|
||||
var principal = new OperatorPrincipal(manager, Set.of("matchmaker.assign"));
|
||||
var row = service.list(principal).stream().filter(r -> serviceId.equals(r.get("serviceId"))).findFirst().orElseThrow();
|
||||
assertEquals("验收跟进", row.get("latestFollowupContent"));
|
||||
assertEquals("YH2026101302", row.get("userDisplayNumber"));
|
||||
assertFalse(row.containsKey("phone"));
|
||||
var detail = service.detail(principal, serviceId);
|
||||
assertEquals(1, ((java.util.List<?>) detail.get("followups")).size());
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM audit_logs WHERE resource_id=? AND action='MATCHMAKER_MANAGEMENT_VIEW'", Integer.class, serviceId));
|
||||
}
|
||||
|
||||
@Test void leaderCanOnlyReadOwnOrganization() {
|
||||
assertNotNull(service.detail(new OperatorPrincipal(leader, Set.of("matchmaker.team.assign")), serviceId));
|
||||
var other = new OperatorPrincipal(otherLeader, Set.of("matchmaker.team.assign"));
|
||||
assertTrue(service.list(other).isEmpty());
|
||||
assertThrows(BusinessException.class, () -> service.detail(other, serviceId));
|
||||
}
|
||||
|
||||
@Test void ordinaryMatchmakerCannotUseManagementRead() {
|
||||
var ordinary = new OperatorPrincipal(manager, Set.of("matchmaker.view", "matchmaker.service"));
|
||||
assertThrows(AccessDeniedException.class, () -> service.list(ordinary));
|
||||
assertThrows(AccessDeniedException.class, () -> service.detail(ordinary, serviceId));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -61,8 +61,8 @@ class MatchmakerReferralPhase13Test {
|
||||
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
|
||||
@Autowired private ServiceAppointmentMapper appointmentMapper;
|
||||
|
||||
private static final String MATCHMAKER_OWNER = "op-mm-owner";
|
||||
private static final String MATCHMAKER_OTHER = "op-mm-other";
|
||||
private static final String MATCHMAKER_OWNER = "00000000-0000-0000-0000-000000000104";
|
||||
private static final String MATCHMAKER_OTHER = "00000000-0000-0000-0000-000000000105";
|
||||
|
||||
private String user;
|
||||
private String target;
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ class NotificationPhase11IntegrationTest {
|
||||
@Autowired
|
||||
private PlatformTransactionManager txManager;
|
||||
|
||||
private static final String USER = "u-notif-p11";
|
||||
// 与生产 users.id 类型保持一致,避免该通知夹具污染共享 H2 库后触发 UUID 转换告警。
|
||||
private static final String USER = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
private List<Notification> allFor(String userId, String type) {
|
||||
return notificationMapper.selectList(
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ class NotificationTemplateTypeGuardTest {
|
||||
@Autowired
|
||||
private NotificationMapper notificationMapper;
|
||||
|
||||
private static final String USER = "u-notif-guard";
|
||||
// 必须使用与生产 users.id 一致的 UUID;否则该夹具写入 H2 后,后续 UUID 条件查询会触发类型转换告警。
|
||||
private static final String USER = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
private void assertPersisted(String type, String businessId, String label) {
|
||||
List<Notification> rows = notificationMapper.selectList(
|
||||
|
||||
+13
-10
@@ -52,7 +52,7 @@ class OrganizationServiceTest {
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
|
||||
/** 审计归属断言用的操作人 id(模拟已认证的运营主体) */
|
||||
private static final String OPERATOR_ID = "op-audit-e2e";
|
||||
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000106";
|
||||
|
||||
private static Map<String, Object> params(String name, String type) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
@@ -76,9 +76,12 @@ class OrganizationServiceTest {
|
||||
void createOrganizationRecordsOperatorInAuditLog() {
|
||||
Organization org = createAndReload("审计归属机构-" + System.nanoTime(), "other");
|
||||
|
||||
// resource_id 同时承载授权拒绝事件的方法签名,H2 会按 UUID 参数比较时拒绝混合值;
|
||||
// 先按稳定动作取行,再在 Java 侧核对资源,保持测试语义且不改变生产字段。
|
||||
List<AuditLog> logs = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getResourceId, org.getId())
|
||||
.eq(AuditLog::getAction, "CREATE_ORGANIZATION"));
|
||||
.eq(AuditLog::getAction, "CREATE_ORGANIZATION")).stream()
|
||||
.filter(log -> org.getId().equals(log.getResourceId()))
|
||||
.toList();
|
||||
|
||||
assertEquals(1, logs.size(), "创建机构应恰好留下 1 条 CREATE_ORGANIZATION 审计");
|
||||
assertEquals(OPERATOR_ID, logs.get(0).getOperatorId(),
|
||||
@@ -103,7 +106,7 @@ class OrganizationServiceTest {
|
||||
void disableOrganizationPersistsInactiveStatus() {
|
||||
Organization org = createAndReload("待停用机构-" + System.nanoTime(), "charity_center");
|
||||
|
||||
organizationService.disableOrganization(org.getId(), "op-1");
|
||||
organizationService.disableOrganization(org.getId(), OPERATOR_ID);
|
||||
|
||||
Organization after = organizationMapper.selectById(org.getId());
|
||||
assertEquals(Constants.ORG_STATUS_INACTIVE, after.getStatus(),
|
||||
@@ -114,9 +117,9 @@ class OrganizationServiceTest {
|
||||
@DisplayName("启用机构:落库状态回到 active(原写 'normal' —— 不在白名单)")
|
||||
void enableOrganizationPersistsActiveStatus() {
|
||||
Organization org = createAndReload("待启用机构-" + System.nanoTime(), "volunteer_team");
|
||||
organizationService.disableOrganization(org.getId(), "op-1");
|
||||
organizationService.disableOrganization(org.getId(), OPERATOR_ID);
|
||||
|
||||
organizationService.enableOrganization(org.getId(), "op-1");
|
||||
organizationService.enableOrganization(org.getId(), OPERATOR_ID);
|
||||
|
||||
Organization after = organizationMapper.selectById(org.getId());
|
||||
assertEquals(Constants.ORG_STATUS_ACTIVE, after.getStatus(),
|
||||
@@ -128,8 +131,8 @@ class OrganizationServiceTest {
|
||||
void disableThenEnableRoundTripKeepsRecordReadable() {
|
||||
Organization org = createAndReload("往返机构-" + System.nanoTime(), "trade_union");
|
||||
|
||||
organizationService.disableOrganization(org.getId(), "op-1");
|
||||
organizationService.enableOrganization(org.getId(), "op-1");
|
||||
organizationService.disableOrganization(org.getId(), OPERATOR_ID);
|
||||
organizationService.enableOrganization(org.getId(), OPERATOR_ID);
|
||||
|
||||
assertEquals(1, organizationService.listOrganizations().stream()
|
||||
.filter(o -> org.getId().equals(o.getId())).count(),
|
||||
@@ -196,8 +199,8 @@ class OrganizationServiceTest {
|
||||
@DisplayName("机构不存在时:停用 / 启用 / 更新返回 NOT_FOUND 业务码,而非 500")
|
||||
void missingOrganizationYieldsNotFoundCode() {
|
||||
for (Runnable call : java.util.List.<Runnable>of(
|
||||
() -> organizationService.disableOrganization("no-such-org-id", "op-1"),
|
||||
() -> organizationService.enableOrganization("no-such-org-id", "op-1"),
|
||||
() -> organizationService.disableOrganization("no-such-org-id", OPERATOR_ID),
|
||||
() -> organizationService.enableOrganization("no-such-org-id", OPERATOR_ID),
|
||||
() -> organizationService.updateOrganization("no-such-org-id",
|
||||
params("新名称", "other")))) {
|
||||
BusinessException ex = assertThrows(BusinessException.class, call::run);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.danyang.match.platform.review;
|
||||
|
||||
import com.danyang.match.platform.review.service.ReviewService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ReviewDashboardCountTest {
|
||||
@Autowired ReviewService service;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
|
||||
@Test void approvingTaskReducesOutstandingCountAndRemovesItFromQueue() {
|
||||
String task = UUID.randomUUID().toString();
|
||||
String operator = UUID.randomUUID().toString();
|
||||
String user = UUID.randomUUID().toString();
|
||||
jdbc.update("INSERT INTO operators(id,name,username,password_hash) VALUES(?,?,?,?)", operator, "测试审核员", operator, "test");
|
||||
jdbc.update("INSERT INTO users(id,wechat_openid) VALUES(?,?)", user, user);
|
||||
jdbc.update("INSERT INTO review_tasks(id,user_id,review_type,status,created_at) VALUES(?,?,?,'pending',NOW())", task, user, "profile");
|
||||
var before = service.getPendingStats();
|
||||
assertTrue(service.claimTask(operator, task).isPresent());
|
||||
var claimed = service.getPendingStats();
|
||||
assertEquals(before.get("pending") - 1, claimed.get("pending"));
|
||||
assertEquals(before.get("processing") + 1, claimed.get("processing"));
|
||||
service.approve(task, operator, "验收通过");
|
||||
var after = service.getPendingStats();
|
||||
assertEquals(before.get("pending") + before.get("processing") - 1, after.get("pending") + after.get("processing"));
|
||||
assertFalse(service.listPendingTasks(0, 1000).stream().anyMatch(row -> task.equals(row.getId())));
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -309,8 +309,10 @@ class ServiceAppointmentOwnerGuardTest {
|
||||
/**
|
||||
* 审计计数必须走 Mapper,**不能**用原生 JDBC 的独立连接。
|
||||
*
|
||||
* <p>{@code DataScopeGuard.deny()} 通过 {@code auditLogMapper.insert} 写在**当前 Spring 事务内**,
|
||||
* 该行尚未提交;裸 JDBC 新开的连接按 READ_COMMITTED 看不到它,会得到 before == after 的假结论。
|
||||
* <p>{@code DataScopeGuard.deny()} 通过 {@code AuditDenyWriter.insertIndependent}
|
||||
* 以 REQUIRES_NEW 独立事务提交拒绝审计(2026-10 修"回滚吞审计"缺陷),该行对
|
||||
* READ_COMMITTED 的独立连接同样可见;但 Mapper 查询与断言同事务执行,
|
||||
* 语义上仍必须走 Mapper(且测试库一级缓存为 statement 作用域,确保每次真执行)。
|
||||
*/
|
||||
private long dataScopeDeniedCount() {
|
||||
return auditLogMapper.selectCount(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class PublicIdInputTest {
|
||||
@Test
|
||||
void resolvesTypedCodesAndPreservesUuidAndUnrelatedText() {
|
||||
var mapper = mock(PublicIdMapper.class);
|
||||
when(mapper.resolve("YH20261014002", "users")).thenReturn("00000000-0000-0000-0000-000000000001");
|
||||
var objectMapper = new ObjectMapper();
|
||||
var node = objectMapper.createObjectNode().put("targetUserId", "YH20261014002")
|
||||
.put("userId", "00000000-0000-0000-0000-000000000002").put("content", "YH20261014002");
|
||||
new PublicIdInputAdvice(objectMapper, mapper).resolve(node);
|
||||
assertEquals("00000000-0000-0000-0000-000000000001", node.get("targetUserId").asText());
|
||||
assertEquals("00000000-0000-0000-0000-000000000002", node.get("userId").asText());
|
||||
assertEquals("YH20261014002", node.get("content").asText());
|
||||
verify(mapper, times(1)).resolve("YH20261014002", "users");
|
||||
}
|
||||
@Test
|
||||
void cannotResolveOperatorAsUser() {
|
||||
var mapper = mock(PublicIdMapper.class);
|
||||
var objectMapper = new ObjectMapper();
|
||||
var node = objectMapper.createObjectNode().put("targetUserId", "HN20261014001");
|
||||
assertThrows(RuntimeException.class, () -> new PublicIdInputAdvice(objectMapper, mapper).resolve(node));
|
||||
verify(mapper).resolve("HN20261014001", "users");
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class PublicIdMapperReadTest {
|
||||
@Autowired PublicIdMapper mapper;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
@Test
|
||||
void actualMapperReturnsCaseStablePublicFields() {
|
||||
String id = "00000000-0000-0000-0000-000000000991";
|
||||
jdbc.update("INSERT INTO users(id,wechat_openid,display_number) VALUES(?,?,?)",id,"public-id-mapper-test","YH20261014001");
|
||||
var rows = mapper.findAll(List.of(id));
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals(id, rows.get(0).get("id"));
|
||||
assertEquals("YH20261014001", rows.get(0).get("displayId"));
|
||||
assertEquals("users", rows.get(0).get("entityType"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.danyang.match.platform.shared.id;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class PublicIdResponseTest {
|
||||
@Test
|
||||
void addsPublicNumbersWithoutChangingInternalKeysOrPrivateWhitelist() {
|
||||
var mapper = mock(PublicIdMapper.class);
|
||||
String user = "00000000-0000-0000-0000-000000000001";
|
||||
String service = "00000000-0000-0000-0000-000000000002";
|
||||
when(mapper.findAll(anyList())).thenReturn(List.of(
|
||||
Map.of("id", user, "displayId", "YH20261014002", "entityType", "users"),
|
||||
Map.of("id", service, "displayId", "FW20261014001", "entityType", "matchmaker_services")));
|
||||
var json = new ObjectMapper().valueToTree(Map.of("id", service,
|
||||
"recommendations", List.of(Map.of("targetUserId", user))));
|
||||
new PublicIdResponse(mapper).enrich(json);
|
||||
assertEquals(service, json.get("id").asText());
|
||||
assertEquals("FW20261014001", json.get("displayId").asText());
|
||||
var recommendation = json.get("recommendations").get(0);
|
||||
assertEquals(user, recommendation.get("targetUserId").asText());
|
||||
assertEquals("YH20261014002", recommendation.get("targetUserDisplayNumber").asText());
|
||||
assertFalse(recommendation.has("phone"));
|
||||
verify(mapper, times(1)).findAll(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotQueryForPlainTextOrTreatCodesAsAuthorization() {
|
||||
var mapper = mock(PublicIdMapper.class);
|
||||
var json = new ObjectMapper().valueToTree(Map.of("content", "普通文案", "displayId", "FW20261014001"));
|
||||
new PublicIdResponse(mapper).enrich(json);
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPublicNumberNeverFallsBackToUuid() {
|
||||
var mapper = mock(PublicIdMapper.class);
|
||||
when(mapper.findAll(anyList())).thenReturn(List.of());
|
||||
var json = new ObjectMapper().valueToTree(Map.of("resourceId", "00000000-0000-0000-0000-000000000003"));
|
||||
new PublicIdResponse(mapper).enrich(json);
|
||||
assertTrue(json.get("resourceDisplayId").isNull());
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.danyang.match.platform.shared.typehandler;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Types;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class JsonNodeTypeHandlerTest {
|
||||
|
||||
@Test
|
||||
void writesJsonNodeAsPostgresOtherString() throws Exception {
|
||||
PreparedStatement statement = mock(PreparedStatement.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
DatabaseMetaData metadata = mock(DatabaseMetaData.class);
|
||||
when(statement.getConnection()).thenReturn(connection);
|
||||
when(connection.getMetaData()).thenReturn(metadata);
|
||||
when(metadata.getDatabaseProductName()).thenReturn("PostgreSQL");
|
||||
|
||||
new JsonNodeTypeHandler().setNonNullParameter(
|
||||
statement, 1, new ObjectMapper().readTree("[\"丹阳\"]"), JdbcType.OTHER);
|
||||
|
||||
verify(statement).setObject(1, "[\"丹阳\"]", Types.OTHER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsJsonNodeFromDatabaseText() throws Exception {
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
when(resultSet.getString("marital_statuses")).thenReturn("[\"never_married\"]");
|
||||
|
||||
var result = new JsonNodeTypeHandler().getNullableResult(resultSet, "marital_statuses");
|
||||
|
||||
assertEquals("never_married", result.get(0).asText());
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.danyang.match.platform.support;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.test.context.TestContext;
|
||||
import org.springframework.test.context.TestExecutionListener;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* 跨测试隔离:清空 H2 内存测试库中**已提交**的 audit_logs 行。
|
||||
*
|
||||
* <h3>为什么需要</h3>
|
||||
* 拒绝审计改为 {@code REQUIRES_NEW} 独立事务提交({@code AuditDenyWriter.insertIndependent} /
|
||||
* {@code AuthorizationAuditListener})后,测试中触发的每条拒绝审计都会**真实提交**到共享的
|
||||
* H2 内存库({@code DB_CLOSE_DELAY=-1} + reuseForks,整个 JVM 全量回归期间持续存活)——
|
||||
* 这正是该设计要保证的"回滚不吞审计"。副作用是这些行会跨测试残留。
|
||||
*
|
||||
* <h3>残留行的危害(运行时实测)</h3>
|
||||
* 生产代码 {@code UUIDStringTypeHandler} 对合法 UUID 字符串参数以 {@code setObject(i, uuid,
|
||||
* Types.OTHER)} 绑定,在 H2 中成为 UUID 类型值;H2 全表扫描做 {@code VARCHAR 列 = UUID 参数}
|
||||
* 比较时会将存储值逐行强转为 UUID,遇到残留的非 UUID 字符串值(如固定 ID 夹具
|
||||
* "op-ag-owner"、"disabled-test-user-0001",或 {@code AUTHORIZATION_DENIED} 审计的
|
||||
* resourceId=控制器方法签名)即抛 22018 数据转换异常(Spring 翻译为
|
||||
* DataIntegrityViolationException),炸掉后续任何测试的审计计数查询。
|
||||
* 旧行为(同事务写审计)下这些行随测试事务回滚从不落库,因此该问题在 REQUIRES_NEW 改造后才暴露。
|
||||
*
|
||||
* <h3>语义边界</h3>
|
||||
* 每个测试方法结束后(测试事务回滚之后,独立连接 autocommit)删除 audit_logs 全部已提交行。
|
||||
* 测试对审计的断言都在本方法内完成(夹具 ID 每用例唯一,无跨测试审计依赖),删除不影响任何
|
||||
* 用例结论;{@code MatchmakerContactAuthorizationTest.denyAuditSurvivesBusinessRollback} 的
|
||||
* 判别力来自 NOT_SUPPORTED + 业务回滚 + 本方法内断言,同样不受影响。
|
||||
* 仅作用于测试库,不触及产品代码。
|
||||
*/
|
||||
public class CommittedAuditCleanupListener implements TestExecutionListener {
|
||||
|
||||
@Override
|
||||
public void afterTestMethod(TestContext testContext) {
|
||||
try {
|
||||
ApplicationContext context = testContext.getApplicationContext();
|
||||
String[] dataSourceBeans = context.getBeanNamesForType(DataSource.class, false, false);
|
||||
if (dataSourceBeans.length == 0) {
|
||||
return;
|
||||
}
|
||||
DataSource dataSource = context.getBean(DataSource.class);
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate("DELETE FROM audit_logs");
|
||||
}
|
||||
} catch (Exception cleanupFailure) {
|
||||
// 清理失败不改变测试结论:下一用例的清理会再次尝试;
|
||||
// 审计残留只可能造成后续用例偶发类型转换错误,届时以日志定位。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.danyang.match.platform.user;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class UserDisplayNumberTest {
|
||||
|
||||
@Test
|
||||
void formatsMaleAndFemaleNumbersWithDateAndGenderParity() {
|
||||
assertEquals("YH20261012001", UserDisplayNumber.format("male", java.time.LocalDate.of(2026, 10, 12), 1));
|
||||
assertEquals("YH20261012003", UserDisplayNumber.format("male", java.time.LocalDate.of(2026, 10, 12), 2));
|
||||
assertEquals("YH20261012002", UserDisplayNumber.format("female", java.time.LocalDate.of(2026, 10, 12), 1));
|
||||
assertEquals("YH20261012004", UserDisplayNumber.format("female", java.time.LocalDate.of(2026, 10, 12), 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedGenderAndInvalidRank() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> UserDisplayNumber.format("other", java.time.LocalDate.of(2026, 10, 12), 1));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> UserDisplayNumber.format("male", java.time.LocalDate.of(2026, 10, 12), 0));
|
||||
assertEquals("YH20261012999", UserDisplayNumber.format("male", java.time.LocalDate.of(2026, 10, 12), 500));
|
||||
assertEquals("YH20261012998", UserDisplayNumber.format("female", java.time.LocalDate.of(2026, 10, 12), 499));
|
||||
assertThrows(IllegalArgumentException.class, () -> UserDisplayNumber.format("female", java.time.LocalDate.of(2026, 10, 12), 500));
|
||||
assertThrows(IllegalArgumentException.class, () -> UserDisplayNumber.format("male", java.time.LocalDate.of(2026, 10, 12), Integer.MAX_VALUE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# NEGATIVE-INJECTION-1: listener disabled for red proof
|
||||
@@ -13,6 +13,14 @@ spring:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
|
||||
# REQUIRES_NEW 拒绝审计(AuditDenyWriter.insertIndependent)经独立 SqlSession 提交后,
|
||||
# 不会清空外层测试事务 SqlSession 的一级缓存;测试里 before/after 两次同参数 count
|
||||
# 第二次会命中缓存返回 before 值(旧行为假绿的机制来源)。statement 作用域让每次查询
|
||||
# 真执行,与生产 PG READ_COMMITTED 的可见性语义一致。
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
local-cache-scope: statement
|
||||
|
||||
jwt:
|
||||
access-token-secret: test-access-secret-for-unit-tests-only
|
||||
refresh-token-secret: test-refresh-secret-for-unit-tests-only
|
||||
|
||||
@@ -2,7 +2,7 @@ DROP ALL OBJECTS;
|
||||
|
||||
CREATE TABLE users (
|
||||
id VARCHAR(36) PRIMARY KEY, wechat_openid VARCHAR(128) NOT NULL UNIQUE,
|
||||
wechat_unionid VARCHAR(128), phone VARCHAR(32), account_status VARCHAR(32),
|
||||
wechat_unionid VARCHAR(128), phone VARCHAR(32), display_number VARCHAR(32), account_status VARCHAR(32),
|
||||
relationship_status VARCHAR(32), recommendation_enabled BOOLEAN,
|
||||
last_login_at TIMESTAMP WITH TIME ZONE, closed_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE,
|
||||
@@ -544,6 +544,7 @@ CREATE TABLE operators (
|
||||
phone_hash VARCHAR(128),
|
||||
username VARCHAR(100) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_id VARCHAR(32),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
last_login_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
@@ -798,3 +799,38 @@ CREATE TABLE feature_flag_overrides (
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_flag_value_type CHECK (value_type IN ('boolean', 'int', 'string'))
|
||||
);
|
||||
|
||||
-- V060 公开编号读取契约(PostgreSQL 分配器另做并发实测)
|
||||
CREATE TABLE public_display_id_counters (
|
||||
prefix VARCHAR(2) NOT NULL, local_date DATE NOT NULL, sequence_group VARCHAR(8) NOT NULL DEFAULT '',
|
||||
last_sequence INTEGER NOT NULL CHECK(last_sequence BETWEEN 1 AND 999), PRIMARY KEY(prefix,local_date,sequence_group)
|
||||
);
|
||||
CREATE TABLE public_display_id_aliases (
|
||||
entity_type VARCHAR(64) NOT NULL, entity_id VARCHAR(36) NOT NULL, old_display_id VARCHAR(64) NOT NULL,
|
||||
new_display_id VARCHAR(13), PRIMARY KEY(entity_type,entity_id,old_display_id)
|
||||
);
|
||||
ALTER TABLE activities ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE review_tasks ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE reports ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_services ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_recommendations ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_contact_authorizations ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_assignments ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_followups ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE service_appointments ADD COLUMN IF NOT EXISTS display_id VARCHAR(13);
|
||||
ALTER TABLE service_appointments ADD COLUMN IF NOT EXISTS referral_display_id VARCHAR(13);
|
||||
ALTER TABLE activity_registrations ADD COLUMN IF NOT EXISTS referral_display_id VARCHAR(13);
|
||||
ALTER TABLE matchmaker_followups ADD COLUMN IF NOT EXISTS referral_display_id VARCHAR(13);
|
||||
CREATE VIEW public_display_ids AS
|
||||
SELECT CAST(id AS VARCHAR) id, display_number display_id, 'users' entity_type, CAST(NULL AS VARCHAR) referral_display_id FROM users
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'operators', CAST(NULL AS VARCHAR) FROM operators
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'activities', CAST(NULL AS VARCHAR) FROM activities
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'review_tasks', CAST(NULL AS VARCHAR) FROM review_tasks
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'reports', CAST(NULL AS VARCHAR) FROM reports
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'matchmaker_services', CAST(NULL AS VARCHAR) FROM matchmaker_services
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'matchmaker_recommendations', CAST(NULL AS VARCHAR) FROM matchmaker_recommendations
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'matchmaker_contact_authorizations', CAST(NULL AS VARCHAR) FROM matchmaker_contact_authorizations
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'matchmaker_assignments', CAST(NULL AS VARCHAR) FROM matchmaker_assignments
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'matchmaker_followups', referral_display_id FROM matchmaker_followups
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), display_id, 'service_appointments', referral_display_id FROM service_appointments
|
||||
UNION ALL SELECT CAST(id AS VARCHAR), referral_display_id, 'activity_registrations', referral_display_id FROM activity_registrations WHERE referral_display_id IS NOT NULL;
|
||||
|
||||
Reference in New Issue
Block a user