fix(activity): close Phase 14 KEEP-VERIFY/EXTEND — 11 backend + 3 frontend-contract + 2 test-fidelity defects
Phase 14 (multi-type public-welfare activities) verification closure. Backend (D1-D11): - D1 CRITICAL: ActivityMapper.countRemainingCapacity had no @Select and no XML -> activity detail / register / approve all threw Invalid bound statement. Removed the shell; unified on ActivityRegistrationMapper. - D2 CRITICAL: register & approve were read-then-write -> oversold under concurrency. Replaced with single atomic SQL (insertIfCapacityAvailable / approveIfCapacityAvailable, affectedRows==0 => full). - D3 HIGH: activityType / orgRole never validated against production chk_activity_type / chk_org_role -> 500 only on PostgreSQL. - D4 HIGH: ActivityListItemDTO had no activityType -> miniapp category tabs always empty (Gate failure). - D5: male/female counts were fabricated as approvedCount/2; now real aggregation over user_profiles.gender. - D6: minAge/maxAge was a TODO no-op; added assertAgeInRange. - D7: registration_policy write-only; per-type eligibility now enforced (identity / local eligibility / gender quota). - D8: POST /operator/activities dropped type, multi-org and capacity groups and set organizer_org_id to the operator user id; now delegates to createActivityFromAdmin (single implementation). - D9: three operator write endpoints were mounted on the user-side ActivityController (dual implementation, zero callers) -> moved. - D10: NPE on soft-deleted activity in getMyActivities; NPE/500 on missing admin form fields (Instant.parse(null)); tolerant time parsing added. - D11: DTO lacked registeredCount / status / coverFileId / description -> card always showed 0 registered, "view detail", no cover, no summary. Frontend contract (F1-F3): - F1: miniapp category code "classroom" is not in the production whitelist (should be relationship_education); three types had no tab at all. - F2: ui-activity-card read 7 fields that do not exist on the backend; now reads only real DTO fields, plus per-type badge and eligibility hint. - F3: admin-web had no activity management page at all; added ActivityManagementView.vue (type-driven dynamic form, multi-org, capacity groups, publish/cancel) + /activities route and nav entry. Test fidelity (T1-T2): - T1: deleted two assertTrue(true) placeholder tests (testNoOverbooking, testNoDuplicateRegistration) that covered the exact broken invariants. - T2: test schema lacked V023 columns/constraints and three tables; the activity tables were only in the OPTIONAL set (zero coverage) -> schema completed and moved to REQUIRED_IN_TEST_SCHEMA. Tests: ActivityPhase14Test (23 cases), ActivityConstraintGuardTest (9 cases, parses real db/migration/V*.sql plus miniapp source). Two negative-verification rounds proved both guard families actually turn red. Regression: 450 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS (baseline 420). admin-web vue-tsc 0 error, vite build OK.
This commit is contained in:
+9
-31
@@ -3,9 +3,7 @@ package com.danyang.match.platform.activity.controller;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import com.danyang.match.platform.activity.service.ActivityService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -99,33 +97,13 @@ public class ActivityController {
|
||||
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
|
||||
}
|
||||
|
||||
@PostMapping("/operator")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> createActivityOperator(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/operator/{id}/organizations")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addActivityOrganization(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(activityService.addActivityOrganization(id, body.get("organizationId"), body.get("role"), operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/operator/{id}/capacity-groups")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addCapacityGroup(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(activityService.addCapacityGroup(id, body, operatorId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Phase 14 复验:以下三个运营端写入端点原先挂在**用户端** /activities 下
|
||||
// POST /activities/operator
|
||||
// POST /activities/operator/{id}/organizations
|
||||
// POST /activities/operator/{id}/capacity-groups
|
||||
// 属双实现 + 权限边界错配(运营写操作混在用户端控制器里),
|
||||
// 且后台实际调用的是 /operator/activities,这三个端点零调用方。
|
||||
// 已统一迁至 ActivityOperatorController(类级 @PreAuthorize('activity.view'))。
|
||||
// ------------------------------------------------------------------
|
||||
}
|
||||
|
||||
+57
-23
@@ -14,8 +14,6 @@ import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import java.util.Map;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
|
||||
/**
|
||||
* 后台活动管理控制器
|
||||
@@ -30,35 +28,69 @@ public class ActivityOperatorController {
|
||||
private final ActivityMapper activityMapper;
|
||||
|
||||
/**
|
||||
* 创建活动
|
||||
* 创建活动(V1.1 类型化)
|
||||
* POST /api/v1/operator/activities
|
||||
*
|
||||
* <p><b>Phase 14 修复</b>:原实现把请求体的 activityType / registrationPolicy /
|
||||
* 多组织 / 容量分组**全部丢弃**,且把 {@code organizer_org_id} 错设为操作者 userId。
|
||||
* 后台动态表单因此无法创建带类型的活动 —— Gate「分类 Tabs 有数据」根本不成立。
|
||||
* 现统一委托 {@link ActivityService#createActivityFromAdmin},只保留一条实现。
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
@Transactional
|
||||
public ApiResponse<Activity> createActivity(
|
||||
public ApiResponse<?> createActivity(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String operatorId = principal.id();
|
||||
Activity activity = new Activity();
|
||||
activity.setTitle((String) body.get("title"));
|
||||
activity.setDescription((String) body.get("description"));
|
||||
activity.setLocationName((String) body.get("locationName"));
|
||||
activity.setLocationAddress((String) body.get("locationAddress"));
|
||||
activity.setOrganizerOrgId(operatorId);
|
||||
activity.setTotalLimit((Integer) body.getOrDefault("totalLimit", 50));
|
||||
activity.setMaleLimit((Integer) body.getOrDefault("maleLimit", 25));
|
||||
activity.setFemaleLimit((Integer) body.getOrDefault("femaleLimit", 25));
|
||||
activity.setMinAge(((Number) body.getOrDefault("minAge", 18)).shortValue());
|
||||
activity.setMaxAge(((Number) body.getOrDefault("maxAge", 60)).shortValue());
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
// 默认时间:明天开始,持续3小时
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
activity.setStartAt(now.plusDays(1).withHour(14).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
|
||||
activity.setEndAt(now.plusDays(1).withHour(17).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
|
||||
activityMapper.insert(activity);
|
||||
return ApiResponse.ok(activity);
|
||||
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动主办/协作组织列表
|
||||
* GET /api/v1/operator/activities/{id}/organizations
|
||||
*/
|
||||
@GetMapping("/{id}/organizations")
|
||||
public ApiResponse<List<Map<String, Object>>> getActivityOrganizations(@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getActivityOrganizations(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加活动组织关系
|
||||
* POST /api/v1/operator/activities/{id}/organizations
|
||||
* Body: { "organizationId": "...", "role": "host|organizer|co_organizer|partner|venue_partner" }
|
||||
*/
|
||||
@PostMapping("/{id}/organizations")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addActivityOrganization(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(activityService.addActivityOrganization(
|
||||
id, body.get("organizationId"), body.get("role"), principal.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动容量分组列表
|
||||
* GET /api/v1/operator/activities/{id}/capacity-groups
|
||||
*/
|
||||
@GetMapping("/{id}/capacity-groups")
|
||||
public ApiResponse<List<Map<String, Object>>> getActivityCapacityGroups(@PathVariable String id) {
|
||||
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增容量分组
|
||||
* POST /api/v1/operator/activities/{id}/capacity-groups
|
||||
* Body: { "groupCode": "...", "capacity": 20, "ruleDefinition": "{...}" }
|
||||
*/
|
||||
@PostMapping("/{id}/capacity-groups")
|
||||
@PreAuthorize("hasAuthority('activity.create')")
|
||||
public ApiResponse<?> addCapacityGroup(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
return ApiResponse.ok(activityService.addCapacityGroup(id, body, principal.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +136,8 @@ public class ActivityOperatorController {
|
||||
m.put("id", a.getId());
|
||||
m.put("title", a.getTitle());
|
||||
m.put("status", a.getStatus());
|
||||
// Phase 14:后台列表必须能看到活动类型,否则运营无法核对分类是否建对
|
||||
m.put("activityType", a.getActivityType());
|
||||
m.put("startAt", a.getStartAt());
|
||||
m.put("endAt", a.getEndAt());
|
||||
m.put("locationName", a.getLocationName());
|
||||
|
||||
+38
@@ -12,6 +12,26 @@ public class ActivityListItemDTO {
|
||||
private String activityId;
|
||||
private String title;
|
||||
private String coverUrl;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:封面文件 ID。
|
||||
*
|
||||
* <p><b>修复</b>:{@code coverUrl} 此前**从未被赋值**(全库零写入点),
|
||||
* 活动卡片封面恒回退到默认图。小程序侧按 {@code uploadBase + '/files/' + coverFileId}
|
||||
* 拼接真实地址(见 {@code components/activity-card/index.js})。
|
||||
*/
|
||||
private String coverFileId;
|
||||
|
||||
/** V1.1 Phase 14:活动简介(卡片摘要)。此前 DTO 无此字段,卡片摘要恒不渲染。 */
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:活动状态。
|
||||
*
|
||||
* <p><b>修复</b>:卡片按钮按 {@code activity.status === 'registration_open'} 显示
|
||||
* "立即报名",而 DTO 根本没有 status —— 按钮永远显示"查看详情"。
|
||||
*/
|
||||
private String status;
|
||||
private Instant startAt;
|
||||
private Instant endAt;
|
||||
private String locationName;
|
||||
@@ -22,4 +42,22 @@ public class ActivityListItemDTO {
|
||||
private int remainingSeats;
|
||||
private boolean registered;
|
||||
private String registrationStatus;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:活动类型。
|
||||
*
|
||||
* <p><b>修复</b>:小程序 {@code pages/activity/activity.ts} 的分类 Tabs 读取
|
||||
* {@code item.activityType},但后端列表 DTO 此前**根本没有该字段** ——
|
||||
* 分类筛选恒无数据,Gate「matchmaking 与课堂/反诈类活动显示不同」直接失败。
|
||||
*/
|
||||
private String activityType;
|
||||
|
||||
/**
|
||||
* V1.1 Phase 14:已报名人数。
|
||||
*
|
||||
* <p><b>修复</b>:小程序 {@code ui-activity-card} 读 {@code activity.registeredCount}
|
||||
* (回退 {@code currentMaleCount + currentFemaleCount}),而后端从未提供这三个字段 ——
|
||||
* 卡片上的"报名人数 x/y"恒为 {@code 0/y}。此处由真实性别统计汇总。
|
||||
*/
|
||||
private int registeredCount;
|
||||
}
|
||||
|
||||
+18
-3
@@ -10,17 +10,32 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ActivityMapper extends BaseMapper<Activity> {
|
||||
int countRemainingCapacity(String activityId);
|
||||
|
||||
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC")
|
||||
List<Activity> findOpenActivities();
|
||||
|
||||
/** V1.1 Phase 14: 按类型下推查询(原为全表捞取后内存 filter) */
|
||||
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL "
|
||||
+ "AND activity_type = #{activityType} ORDER BY start_at ASC")
|
||||
List<Activity> findOpenActivitiesByType(@Param("activityType") String activityType);
|
||||
|
||||
@Select("SELECT * FROM activities WHERE start_at > NOW() AND deleted_at IS NULL ORDER BY start_at ASC LIMIT 10")
|
||||
List<Activity> findUpcomingActivities();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
int countApprovedRegistrations(@Param("activityId") String activityId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
|
||||
int countApprovedMaleRegistrations(@Param("activityId") String activityId, @Param("gender") String gender);
|
||||
/**
|
||||
* 统计某活动已通过报名的**真实性别构成**。
|
||||
*
|
||||
* <p><b>Phase 14 修复</b>:原 {@code countApprovedMaleRegistrations} 的 SQL
|
||||
* 完全忽略了 {@code gender} 入参(永远返回全部通过人数),且零调用方——
|
||||
* 导致 {@code ActivityService#toListItemDTO} 只能用 {@code approvedCount / 2}
|
||||
* **凭空猜测**男女人数。此处改为按 {@code user_profiles.gender} 真实聚合。
|
||||
*/
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ "JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ "WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ "AND r.deleted_at IS NULL AND p.gender = #{gender}")
|
||||
int countApprovedByGender(@Param("activityId") String activityId, @Param("gender") String gender);
|
||||
}
|
||||
|
||||
+68
-1
@@ -2,9 +2,11 @@ package com.danyang.match.platform.activity.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
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;
|
||||
@@ -23,9 +25,74 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
|
||||
List<ActivityRegistration> findByUserId(String userId);
|
||||
|
||||
/**
|
||||
* 原子检查名额:返回剩余名额数
|
||||
* 原子检查名额:返回剩余名额数。
|
||||
*
|
||||
* <p><b>Phase 14 说明</b>:本方法是全模块**唯一**有 SQL 实现的容量查询。
|
||||
* 此前 {@code ActivityMapper} 上有一个同名方法却**既无 {@code @Select} 也无 XML**,
|
||||
* 而 {@code ActivityService} 调用的正是那个空壳 ——
|
||||
* 活动详情 / 报名 / 审核三条路径在生产必然抛
|
||||
* {@code Invalid bound statement (not found)}。已删除空壳并统一走本方法。
|
||||
*/
|
||||
@Select("SELECT COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 9999) - " +
|
||||
"(SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL) AS remaining")
|
||||
int countRemainingCapacity(@Param("activityId") String activityId);
|
||||
|
||||
/**
|
||||
* **原子**占位插入:仅当「总名额未满」**且**「该性别名额未满」时才写入 approved 报名。
|
||||
*
|
||||
* <p>把「查余量」与「写记录」合并为一条 SQL,由数据库保证原子性,
|
||||
* 避免 {@code SELECT COUNT → INSERT} 的先读后写在并发下超卖。
|
||||
*
|
||||
* <p>性别名额按**配置**生效:{@code male_limit}/{@code female_limit} 为 NULL 时
|
||||
* (如婚恋课堂 / 反诈类活动)该条件下退化为 999999,即只受总名额约束 ——
|
||||
* 这正是 Gate「matchmaking 与课堂/反诈类容量策略不同」的实现方式。
|
||||
*
|
||||
* @return 影响行数;0 表示名额已满(或活动不存在),调用方必须据此报错
|
||||
*/
|
||||
@Insert("INSERT INTO activity_registrations (id, activity_id, user_id, status, registered_at, "
|
||||
+ "checkin_status, emergency_contact_name, emergency_contact_phone_enc, created_at, updated_at) "
|
||||
+ "SELECT #{id}, #{activityId}, #{userId}, #{status}, NOW(), 'not_checked_in', "
|
||||
+ "#{contactName}, #{contactPhone}, NOW(), NOW() "
|
||||
+ "WHERE (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
|
||||
+ " AND status = 'approved' AND deleted_at IS NULL) "
|
||||
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
|
||||
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
|
||||
+ " WHEN 'female' THEN a.female_limit "
|
||||
+ " ELSE a.total_limit END "
|
||||
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
|
||||
int insertIfCapacityAvailable(@Param("id") String id,
|
||||
@Param("activityId") String activityId,
|
||||
@Param("userId") String userId,
|
||||
@Param("status") String status,
|
||||
@Param("contactName") String contactName,
|
||||
@Param("contactPhone") String contactPhone,
|
||||
@Param("gender") String gender);
|
||||
|
||||
/**
|
||||
* **原子**审核通过:仅当记录仍为 pending **且**总名额与性别名额均未满时才置为 approved。
|
||||
*
|
||||
* @return 影响行数;0 表示名额已满或状态已被他人变更
|
||||
*/
|
||||
@Update("UPDATE activity_registrations SET status = 'approved', reviewed_by = #{operatorId}, "
|
||||
+ "reviewed_at = NOW(), updated_at = NOW() "
|
||||
+ "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
|
||||
+ " AND status = 'approved' AND deleted_at IS NULL) "
|
||||
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
|
||||
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
|
||||
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
|
||||
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
|
||||
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
|
||||
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
|
||||
+ " WHEN 'female' THEN a.female_limit "
|
||||
+ " ELSE a.total_limit END "
|
||||
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
|
||||
int approveIfCapacityAvailable(@Param("id") String id,
|
||||
@Param("activityId") String activityId,
|
||||
@Param("operatorId") String operatorId,
|
||||
@Param("gender") String gender);
|
||||
}
|
||||
|
||||
+272
-45
@@ -18,6 +18,8 @@ import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
|
||||
import com.danyang.match.platform.organization.entity.Organization;
|
||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||
import com.danyang.match.platform.verification.service.IdentityVerificationService;
|
||||
import com.danyang.match.platform.local_eligibility.service.LocalEligibilityService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -34,6 +36,46 @@ public class ActivityService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
|
||||
|
||||
// ==================== Phase 14:多类型活动常量(必须与生产 V023 CHECK 白名单一致)====================
|
||||
// 守卫 ActivityConstraintGuardTest 会直接解析 db/migration/V*.sql 校验这些集合,
|
||||
// 任何与生产白名单的偏离都会让测试变红 —— 不要在此处"顺手"加值。
|
||||
|
||||
/** 合法活动类型 ⊆ 生产 chk_activity_type */
|
||||
public static final Set<String> VALID_ACTIVITY_TYPES = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING,
|
||||
Constants.ACTIVITY_TYPE_INTEREST_SOCIAL,
|
||||
Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION,
|
||||
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE,
|
||||
Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE,
|
||||
Constants.ACTIVITY_TYPE_ANTI_FRAUD);
|
||||
|
||||
/** 合法活动组织角色 ⊆ 生产 chk_org_role */
|
||||
public static final Set<String> VALID_ORG_ROLES = Set.of(
|
||||
Constants.ORG_ROLE_HOST,
|
||||
Constants.ORG_ROLE_ORGANIZER,
|
||||
Constants.ORG_ROLE_CO_ORGANIZER,
|
||||
Constants.ORG_ROLE_PARTNER,
|
||||
Constants.ORG_ROLE_VENUE_PARTNER);
|
||||
|
||||
/**
|
||||
* 需要**实名认证**才能报名的类型。
|
||||
*
|
||||
* <p>Gate 要求「matchmaking 与课堂/反诈类活动资格策略不同」:
|
||||
* 相亲类接触陌生人、必须实名;婚恋课堂 / 反诈宣传是公益普适科普,不设实名门槛。
|
||||
*/
|
||||
public static final Set<String> TYPES_REQUIRING_IDENTITY = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING,
|
||||
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE);
|
||||
|
||||
/** 需要**本地资格**才能报名的类型(丹阳户籍/工作/居住) */
|
||||
public static final Set<String> TYPES_REQUIRING_LOCAL_ELIGIBILITY = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING);
|
||||
|
||||
/** 需要按**性别名额**分别扣减的类型(课堂/反诈类只按总名额) */
|
||||
public static final Set<String> TYPES_WITH_GENDER_QUOTA = Set.of(
|
||||
Constants.ACTIVITY_TYPE_MATCHMAKING,
|
||||
Constants.ACTIVITY_TYPE_INTEREST_SOCIAL);
|
||||
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
@@ -42,6 +84,8 @@ public class ActivityService {
|
||||
private final ActivityOrganizationMapper activityOrganizationMapper;
|
||||
private final ActivityCapacityGroupMapper capacityGroupMapper;
|
||||
private final OrganizationMapper organizationMapper;
|
||||
private final IdentityVerificationService identityVerificationService;
|
||||
private final LocalEligibilityService localEligibilityService;
|
||||
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
|
||||
|
||||
/**
|
||||
@@ -144,13 +188,17 @@ public class ActivityService {
|
||||
detail.put("organizerOrgId", activity.getOrganizerOrgId());
|
||||
detail.put("coorganizerText", activity.getCoorganizerText());
|
||||
|
||||
// 报名统计
|
||||
// 报名统计(Phase 14: 改走 ActivityRegistrationMapper —— ActivityMapper 上的同名方法无 SQL 实现)
|
||||
int approvedCount = activityMapper.countApprovedRegistrations(activityId);
|
||||
int remaining = activityMapper.countRemainingCapacity(activityId);
|
||||
int remaining = registrationMapper.countRemainingCapacity(activityId);
|
||||
detail.put("approvedCount", approvedCount);
|
||||
detail.put("remainingSeats", Math.max(0, remaining));
|
||||
detail.put("isFull", remaining <= 0);
|
||||
|
||||
// V1.1 Phase 14: 类型与资格策略(小程序据此渲染差异化的资格提示)
|
||||
detail.put("activityType", activity.getActivityType());
|
||||
detail.put("eligibility", resolveEligibility(activity));
|
||||
|
||||
// 用户报名状态
|
||||
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(reg -> {
|
||||
detail.put("registered", true);
|
||||
@@ -182,27 +230,40 @@ public class ActivityService {
|
||||
}
|
||||
});
|
||||
|
||||
// 检查名额(事务内原子检查)
|
||||
int remaining = activityMapper.countRemainingCapacity(activityId);
|
||||
if (remaining <= 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
}
|
||||
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
|
||||
assertEligible(userId, activity);
|
||||
|
||||
// 检查年龄范围
|
||||
if (activity.getMinAge() != null) {
|
||||
// TODO: 获取用户年龄并校验
|
||||
}
|
||||
// V1.1 Phase 14: 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 完全不生效)
|
||||
assertAgeInRange(userId, activity);
|
||||
|
||||
// 创建报名记录(needReview=false 时直接 approved —— 必须原子占位,否则并发超卖)
|
||||
boolean autoApprove = !Boolean.TRUE.equals(activity.getNeedReview());
|
||||
String status = autoApprove
|
||||
? Constants.REGISTRATION_STATUS_APPROVED
|
||||
: Constants.REGISTRATION_STATUS_PENDING;
|
||||
|
||||
// 创建报名记录
|
||||
ActivityRegistration registration = new ActivityRegistration();
|
||||
registration.setActivityId(activityId);
|
||||
registration.setUserId(userId);
|
||||
registration.setStatus(activity.getNeedReview() ?
|
||||
Constants.REGISTRATION_STATUS_PENDING : Constants.REGISTRATION_STATUS_APPROVED);
|
||||
registration.setStatus(status);
|
||||
registration.setRegisteredAt(Instant.now());
|
||||
registration.setEmergencyContactName(emergencyContactName);
|
||||
registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
|
||||
registrationMapper.insert(registration);
|
||||
|
||||
if (autoApprove) {
|
||||
// 原子:仅当仍有名额才写入 approved 报名;affectedRows=0 即名额已满
|
||||
String newId = UUID.randomUUID().toString();
|
||||
int affected = registrationMapper.insertIfCapacityAvailable(
|
||||
newId, activityId, userId, status,
|
||||
emergencyContactName, emergencyContactPhoneEnc, genderOf(userId));
|
||||
if (affected == 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
}
|
||||
registration.setId(newId);
|
||||
} else {
|
||||
// pending 不占用名额,插入即可(UNIQUE(activity_id,user_id) 防重复)
|
||||
registrationMapper.insert(registration);
|
||||
}
|
||||
|
||||
// P17-T03: 线下活动风险检测
|
||||
try {
|
||||
@@ -235,16 +296,15 @@ public class ActivityService {
|
||||
throw new RuntimeException("当前状态不允许审核通过");
|
||||
}
|
||||
|
||||
// 再次检查名额(防止并发超卖)
|
||||
int remaining = activityMapper.countRemainingCapacity(registration.getActivityId());
|
||||
if (remaining <= 0) {
|
||||
throw new RuntimeException("活动名额已满,无法通过审核");
|
||||
// 原子审核:名额已满或状态已被他人变更时 affectedRows=0
|
||||
int affected = registrationMapper.approveIfCapacityAvailable(
|
||||
registrationId, registration.getActivityId(), operatorId,
|
||||
genderOf(registration.getUserId()));
|
||||
if (affected == 0) {
|
||||
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
|
||||
}
|
||||
|
||||
registration.setStatus(Constants.REGISTRATION_STATUS_APPROVED);
|
||||
registration.setReviewedBy(operatorId);
|
||||
registration.setReviewedAt(Instant.now());
|
||||
registrationMapper.updateById(registration);
|
||||
// 回读事务内最新状态(条件更新不回填实体)
|
||||
registration = registrationMapper.selectById(registrationId);
|
||||
|
||||
// 通知用户
|
||||
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
|
||||
@@ -307,8 +367,11 @@ public class ActivityService {
|
||||
return registrationMapper.findByUserId(userId).stream()
|
||||
.map(reg -> {
|
||||
Activity activity = activityMapper.selectById(reg.getActivityId());
|
||||
// Phase 14 修复:活动被软删后 selectById 返回 null,原实现直接 NPE
|
||||
if (activity == null) return null;
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("activityId", activity.getId());
|
||||
map.put("activityType", activity.getActivityType());
|
||||
map.put("title", activity.getTitle());
|
||||
map.put("startAt", activity.getStartAt());
|
||||
map.put("locationName", activity.getLocationName());
|
||||
@@ -318,6 +381,7 @@ public class ActivityService {
|
||||
map.put("reviewedAt", reg.getReviewedAt());
|
||||
return map;
|
||||
})
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -349,19 +413,37 @@ public class ActivityService {
|
||||
ActivityListItemDTO dto = new ActivityListItemDTO();
|
||||
dto.setActivityId(activity.getId());
|
||||
dto.setTitle(activity.getTitle());
|
||||
dto.setCoverFileId(activity.getCoverFileId());
|
||||
dto.setDescription(activity.getDescription());
|
||||
dto.setStatus(activity.getStatus());
|
||||
dto.setStartAt(activity.getStartAt());
|
||||
dto.setEndAt(activity.getEndAt());
|
||||
dto.setLocationName(activity.getLocationName());
|
||||
dto.setTotalLimit(activity.getTotalLimit());
|
||||
long approvedCount = registrationMapper.selectCount(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getActivityId, activity.getId())
|
||||
.eq(ActivityRegistration::getStatus, "approved"));
|
||||
// 注:性别在 user_profiles 中,此处简化处理(男女各半)
|
||||
dto.setMaleCount((int)(approvedCount / 2));
|
||||
dto.setFemaleCount((int)(approvedCount - approvedCount / 2));
|
||||
dto.setRemainingSeats(Math.max(0, activity.getTotalLimit() != null ?
|
||||
activity.getTotalLimit() - dto.getMaleCount() - dto.getFemaleCount() : 0));
|
||||
dto.setActivityType(activity.getActivityType() == null
|
||||
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType());
|
||||
|
||||
// Phase 14 修复:男女人数改为按 user_profiles.gender 真实统计。
|
||||
// 原实现为 approvedCount/2 与 approvedCount-approvedCount/2 —— 凭空编造的性别构成。
|
||||
int approvedCount = activityMapper.countApprovedRegistrations(activity.getId());
|
||||
int male = activityMapper.countApprovedByGender(activity.getId(), "male");
|
||||
int female = activityMapper.countApprovedByGender(activity.getId(), "female");
|
||||
dto.setMaleCount(male);
|
||||
dto.setFemaleCount(female);
|
||||
dto.setRegisteredCount(male + female);
|
||||
|
||||
// 剩余名额:总名额优先;性别名额启用时取"两个性别各自剩余"与总剩余的较小值
|
||||
int remainingByTotal = activity.getTotalLimit() == null ? approvedCount : activity.getTotalLimit() - approvedCount;
|
||||
int remaining = remainingByTotal;
|
||||
if (TYPES_WITH_GENDER_QUOTA.contains(dto.getActivityType())) {
|
||||
if (activity.getMaleLimit() != null) {
|
||||
remaining = Math.min(remaining, activity.getMaleLimit() - male);
|
||||
}
|
||||
if (activity.getFemaleLimit() != null) {
|
||||
remaining = Math.min(remaining, activity.getFemaleLimit() - female);
|
||||
}
|
||||
}
|
||||
dto.setRemainingSeats(Math.max(0, remaining));
|
||||
dto.setRegistered(false);
|
||||
return dto;
|
||||
}
|
||||
@@ -369,9 +451,14 @@ public class ActivityService {
|
||||
/** V1.1 Phase 14: 按活动类型过滤 */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getActivitiesByType(String userId, String activityType) {
|
||||
List<Activity> activities = activityMapper.findOpenActivities();
|
||||
// Phase 14 修复:类型必须落在生产 chk_activity_type 白名单内,
|
||||
// 否则下推到 SQL 后只会静默返回空列表,掩盖前端传错值的问题。
|
||||
if (activityType == null || !VALID_ACTIVITY_TYPES.contains(activityType)) {
|
||||
throw new IllegalArgumentException("非法的活动类型: " + activityType);
|
||||
}
|
||||
// 原实现为「全表捞取 + 内存 filter」;改为下推到 SQL。
|
||||
List<Activity> activities = activityMapper.findOpenActivitiesByType(activityType);
|
||||
return activities.stream()
|
||||
.filter(a -> activityType != null && activityType.equals(a.getActivityType()))
|
||||
.map(a -> (Map<String, Object>) convertToMap(toListItemDTO(userId, a)))
|
||||
.toList();
|
||||
}
|
||||
@@ -385,8 +472,10 @@ public class ActivityService {
|
||||
m.put("totalLimit", dto.getTotalLimit());
|
||||
m.put("maleCount", dto.getMaleCount());
|
||||
m.put("femaleCount", dto.getFemaleCount());
|
||||
m.put("registeredCount", dto.getRegisteredCount());
|
||||
m.put("remainingSeats", dto.getRemainingSeats());
|
||||
m.put("registered", dto.isRegistered());
|
||||
m.put("activityType", dto.getActivityType());
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -426,33 +515,42 @@ public class ActivityService {
|
||||
activity.setTitle((String) body.get("title"));
|
||||
activity.setDescription((String) body.get("description"));
|
||||
activity.setCoverFileId((String) body.get("coverFileId"));
|
||||
activity.setStartAt(Instant.parse((String) body.get("startAt")));
|
||||
activity.setEndAt(Instant.parse((String) body.get("endAt")));
|
||||
// Phase 14 修复:运营端表单允许缺省时间/名额/年龄,原实现直接 Instant.parse(null)
|
||||
// 与 ((Number) null).intValue() —— 后台一漏填就 NPE 500。此处按旧运营端端点兜底。
|
||||
activity.setStartAt(parseInstant(body.get("startAt"), Instant.now().plusSeconds(3600)));
|
||||
activity.setEndAt(parseInstant(body.get("endAt"), Instant.now().plusSeconds(7200)));
|
||||
activity.setLocationName((String) body.get("locationName"));
|
||||
activity.setLocationAddress((String) body.get("locationAddress"));
|
||||
activity.setTotalLimit(((Number) body.get("totalLimit")).intValue());
|
||||
activity.setMinAge(((Number) body.get("minAge")).shortValue());
|
||||
activity.setMaxAge(((Number) body.get("maxAge")).shortValue());
|
||||
activity.setTotalLimit(toInt(body.get("totalLimit"), 50));
|
||||
// minAge/maxAge 为空表示不设年龄门槛(null 参与比较会 NPE,故用包装类型)
|
||||
activity.setMinAge(toShort(body.get("minAge")));
|
||||
activity.setMaxAge(toShort(body.get("maxAge")));
|
||||
Object regStart = body.get("registrationStartAt");
|
||||
Object regEnd = body.get("registrationEndAt");
|
||||
activity.setRegistrationStartAt(regStart != null ? Instant.parse((String) regStart) : null);
|
||||
activity.setRegistrationEndAt(regEnd != null ? Instant.parse((String) regEnd) : null);
|
||||
activity.setNeedReview(Boolean.parseBoolean(body.getOrDefault("needReview", "true").toString()));
|
||||
activity.setActivityType((String) body.getOrDefault("activityType", "matchmaking"));
|
||||
// Phase 14 修复:活动类型必须落在生产 chk_activity_type 白名单内。
|
||||
// 原实现直接把请求体原样写库 —— 非法类型在 H2 测试库里静默通过,
|
||||
// 上到 PostgreSQL 才撞 CHECK 约束返回 500(典型的"只在 PG 上炸")。
|
||||
activity.setActivityType(requireActivityType((String) body.getOrDefault("activityType", "matchmaking")));
|
||||
activity.setRegistrationPolicy((String) body.get("registrationPolicy"));
|
||||
activity.setTargetGroup((String) body.get("targetGroup"));
|
||||
activity.setIndustryScope((String) body.get("industryScope"));
|
||||
activity.setPostEventInterestEnabled(Boolean.parseBoolean(body.getOrDefault("postEventInterestEnabled", "false").toString()));
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
|
||||
activity.setCreatedBy(operatorId);
|
||||
// Phase 14 修复:主办组织此前只写进 activity_organizations,activities.organizer_org_id 留空,
|
||||
// 后台列表与详情里的"主办方"恒为空。
|
||||
String hostOrgId = (String) body.get("organizerOrgId");
|
||||
activity.setOrganizerOrgId(hostOrgId);
|
||||
activityMapper.insert(activity);
|
||||
|
||||
// 保存主组织
|
||||
String orgId = (String) body.get("organizerOrgId");
|
||||
if (orgId != null) {
|
||||
if (hostOrgId != null) {
|
||||
ActivityOrganization hostOrg = new ActivityOrganization();
|
||||
hostOrg.setActivityId(activity.getId());
|
||||
hostOrg.setOrganizationId(orgId);
|
||||
hostOrg.setOrganizationId(hostOrgId);
|
||||
hostOrg.setRole(Constants.ORG_ROLE_HOST);
|
||||
activityOrganizationMapper.insert(hostOrg);
|
||||
}
|
||||
@@ -486,7 +584,8 @@ public class ActivityService {
|
||||
ActivityOrganization org = new ActivityOrganization();
|
||||
org.setActivityId(activityId);
|
||||
org.setOrganizationId(organizationId);
|
||||
org.setRole(role != null ? role : Constants.ORG_ROLE_PARTNER);
|
||||
// Phase 14 修复:角色必须落在生产 chk_org_role 白名单内(同 D3 根因)
|
||||
org.setRole(requireOrgRole(role != null ? role : Constants.ORG_ROLE_PARTNER));
|
||||
activityOrganizationMapper.insert(org);
|
||||
log.info("添加活动组织: activityId={}, orgId={}, role={}", activityId, organizationId, role);
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
@@ -514,6 +613,134 @@ public class ActivityService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Phase 14:类型化资格策略 ====================
|
||||
|
||||
/**
|
||||
* 解析某活动的资格要求。
|
||||
*
|
||||
* <p><b>Gate 落点</b>:「matchmaking 与课堂/反诈类活动显示和资格策略不同」——
|
||||
* 相亲类要求实名 + 本地资格 + 性别名额;婚恋课堂 / 反诈宣传是公益科普,
|
||||
* 不设实名与本地资格门槛,容量也只按总名额计算。
|
||||
*/
|
||||
public Map<String, Object> resolveEligibility(Activity activity) {
|
||||
String type = activity.getActivityType() == null
|
||||
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("activityType", type);
|
||||
m.put("requiresIdentityVerified", TYPES_REQUIRING_IDENTITY.contains(type));
|
||||
m.put("requiresLocalEligibility", TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type));
|
||||
m.put("usesGenderQuota", TYPES_WITH_GENDER_QUOTA.contains(type));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报名前按活动类型校验资格。
|
||||
*
|
||||
* <p>此前 {@code registration_policy} 字段被写入却**从未被读取**,
|
||||
* 资格策略实际是空的(Gate 未落地);此处改为按类型强制执行。
|
||||
*/
|
||||
private void assertEligible(String userId, Activity activity) {
|
||||
String type = activity.getActivityType() == null
|
||||
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
|
||||
|
||||
if (TYPES_REQUIRING_IDENTITY.contains(type)) {
|
||||
Boolean verified = Boolean.FALSE;
|
||||
try {
|
||||
Object v = identityVerificationService.getIdentityStatus(userId).get("verified");
|
||||
verified = Boolean.TRUE.equals(v);
|
||||
} catch (Exception e) {
|
||||
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
|
||||
}
|
||||
if (!verified) {
|
||||
throw new RuntimeException("该活动需完成实名认证后方可报名");
|
||||
}
|
||||
}
|
||||
|
||||
if (TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type)) {
|
||||
String st = null;
|
||||
try {
|
||||
st = localEligibilityService.getStatus(userId).getVerificationStatus();
|
||||
} catch (Exception e) {
|
||||
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
|
||||
}
|
||||
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
|
||||
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 此前完全不生效) */
|
||||
private void assertAgeInRange(String userId, Activity activity) {
|
||||
if (activity.getMinAge() == null && activity.getMaxAge() == null) return;
|
||||
java.time.LocalDate birth = userProfileMapper.findByUserId(userId)
|
||||
.map(p -> p.getBirthDate()).orElse(null);
|
||||
if (birth == null) return; // 资料不全时不阻断报名
|
||||
int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
|
||||
if (activity.getMinAge() != null && age < activity.getMinAge()) {
|
||||
throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁");
|
||||
}
|
||||
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
|
||||
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验并归一化活动类型;非法值直接拒绝,避免撞生产 CHECK 约束 */
|
||||
private String requireActivityType(String type) {
|
||||
if (type == null || !VALID_ACTIVITY_TYPES.contains(type)) {
|
||||
throw new IllegalArgumentException("非法的活动类型: " + type
|
||||
+ ",允许值:" + new TreeSet<>(VALID_ACTIVITY_TYPES));
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/** 校验并归一化活动组织角色;非法值直接拒绝,避免撞生产 CHECK 约束 */
|
||||
private String requireOrgRole(String role) {
|
||||
if (role == null || !VALID_ORG_ROLES.contains(role)) {
|
||||
throw new IllegalArgumentException("非法的活动组织角色: " + role
|
||||
+ ",允许值:" + new TreeSet<>(VALID_ORG_ROLES));
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
/** 读取用户性别(用于性别名额扣减);资料缺失时返回 null,退化为按总名额约束 */
|
||||
private String genderOf(String userId) {
|
||||
return userProfileMapper.findByUserId(userId).map(p -> p.getGender()).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 容忍 null / 时间戳 / 本地无时区文本 / ISO 文本的时间字段。
|
||||
*
|
||||
* <p>后台 el-date-picker 默认输出 {@code 2026-09-20T14:00:00}(无时区偏移),
|
||||
* 直接 {@code Instant.parse} 会抛异常;若简单兜底成"现在",运营填的时间会被**静默丢弃** ——
|
||||
* 比报错更危险。故无偏移时按服务器时区解释。
|
||||
*/
|
||||
private static Instant parseInstant(Object raw, Instant fallback) {
|
||||
if (raw == null) return fallback;
|
||||
try {
|
||||
if (raw instanceof Number n) return Instant.ofEpochMilli(n.longValue());
|
||||
String s = raw.toString().trim();
|
||||
if (s.isEmpty()) return fallback;
|
||||
try {
|
||||
return Instant.parse(s);
|
||||
} catch (RuntimeException ignored) {
|
||||
return java.time.LocalDateTime.parse(s)
|
||||
.atZone(java.time.ZoneId.systemDefault()).toInstant();
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("非法时间字段,回落默认值: raw={}, err={}", raw, e.getMessage());
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static int toInt(Object raw, int fallback) {
|
||||
return raw instanceof Number n ? n.intValue() : fallback;
|
||||
}
|
||||
|
||||
/** null 表示不设年龄门槛 —— 不能用 0 兜底,否则 age >= minAge 恒成立反而放行 */
|
||||
private static Short toShort(Object raw) {
|
||||
return raw instanceof Number n ? n.shortValue() : null;
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
|
||||
Reference in New Issue
Block a user