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:
+331
@@ -0,0 +1,331 @@
|
||||
package com.danyang.match.platform.activity;
|
||||
|
||||
import com.danyang.match.platform.activity.dto.ActivityListItemDTO;
|
||||
import com.danyang.match.platform.activity.service.ActivityService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 14:activity 模块「代码常量 ⊆ 生产 CHECK 白名单」守卫。
|
||||
*
|
||||
* <p><b>核心纪律(Phase 13 确立、Phase 13A 加固)</b>:
|
||||
* 守卫必须**直接解析 {@code db/migration/V*.sql} 原文**,绝不能在测试里抄一份白名单常量。
|
||||
* 抄副本的守卫只证明"今天抄的和昨天抄的一样",对 DDL 漂移**零反应**。
|
||||
*
|
||||
* <p><b>CWD 陷阱(Phase 13A 实测)</b>:surefire 的 workingDirectory 是仓库根,
|
||||
* 而 {@code backend/} 是它的**子目录不是祖先** —— 必须同时尝试两种候选后缀,
|
||||
* 且定位失败要**大声抛异常**(静默返回 null 会让守卫变成恒真假绿)。
|
||||
*/
|
||||
class ActivityConstraintGuardTest {
|
||||
|
||||
private static final List<String> MIGRATION_CANDIDATES = List.of(
|
||||
"src/main/resources/db/migration",
|
||||
"backend/src/main/resources/db/migration"
|
||||
);
|
||||
|
||||
private static final Map<String, Set<String>> CACHE = new HashMap<>();
|
||||
|
||||
private static Path locateMigrationDir() {
|
||||
Path cur = Path.of("").toAbsolutePath().normalize();
|
||||
List<String> tried = new ArrayList<>();
|
||||
for (int i = 0; i < 6 && cur != null; i++) {
|
||||
for (String rel : MIGRATION_CANDIDATES) {
|
||||
Path candidate = cur.resolve(rel);
|
||||
tried.add(candidate.toString());
|
||||
if (Files.isDirectory(candidate)) return candidate;
|
||||
}
|
||||
cur = cur.getParent();
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"守卫无法定位迁移目录;CWD=" + Path.of("").toAbsolutePath()
|
||||
+ ";已尝试:\n - " + String.join("\n - ", tried));
|
||||
}
|
||||
|
||||
// ==================== 解析器自检 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("解析器自检: 必须真的解析到 Phase 14 的两个约束(防止守卫恒真)")
|
||||
void parserMustResolveActivityConstraints() {
|
||||
Path dir = locateMigrationDir();
|
||||
long fileCount;
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
fileCount = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")).count();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("列目录失败: " + dir, e);
|
||||
}
|
||||
assertTrue(fileCount >= 30, "解析到的迁移文件过少(" + fileCount + "),目录可能选错了: " + dir);
|
||||
|
||||
for (String c : List.of("chk_activity_type", "chk_org_role")) {
|
||||
Set<String> vals = resolveCheckValues(c);
|
||||
assertNotNull(vals, "解析器失效:未解析到约束 " + c + "(dir=" + dir + ", files=" + fileCount + ")");
|
||||
assertFalse(vals.isEmpty(), "解析器失效:约束 " + c + " 白名单为空");
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> requireWhitelist(String constraintName) {
|
||||
Set<String> allowed = resolveCheckValues(constraintName);
|
||||
assertNotNull(allowed, "守卫失效:未从生产迁移解析到 " + constraintName
|
||||
+ "(迁移目录=" + locateMigrationDir() + ")。");
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// ==================== 守卫 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: VALID_ACTIVITY_TYPES ⊆ 生产 chk_activity_type 白名单")
|
||||
void activityTypeConstantsWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_activity_type");
|
||||
Set<String> codeValues = readStringSetField("VALID_ACTIVITY_TYPES");
|
||||
|
||||
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
|
||||
Set<String> violations = new TreeSet<>(codeValues);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"代码写入的 activity_type 不在生产 CHECK 白名单内,真实 PG 上必然 500:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: 生产白名单 ⊆ 代码常量(新增类型必须同步到代码,否则永远不可达)")
|
||||
void productionWhitelistCoveredByCodeConstants() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_activity_type");
|
||||
Set<String> codeValues = readStringSetField("VALID_ACTIVITY_TYPES");
|
||||
|
||||
Set<String> missing = new TreeSet<>(allowed);
|
||||
missing.removeAll(codeValues);
|
||||
assertTrue(missing.isEmpty(),
|
||||
"生产允许的活动类型未在代码中声明,代码中无法构造该类型:\n - " + String.join("\n - ", missing));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: VALID_ORG_ROLES ⊆ 生产 chk_org_role 白名单")
|
||||
void orgRoleConstantsWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_org_role");
|
||||
Set<String> codeValues = readStringSetField("VALID_ORG_ROLES");
|
||||
|
||||
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
|
||||
Set<String> violations = new TreeSet<>(codeValues);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"代码写入的组织角色不在生产 CHECK 白名单内:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: 三个资格策略集合的取值都必须是合法活动类型")
|
||||
void policySetsUseOnlyValidActivityTypes() throws Exception {
|
||||
Set<String> validTypes = readStringSetField("VALID_ACTIVITY_TYPES");
|
||||
for (String field : List.of("TYPES_REQUIRING_IDENTITY",
|
||||
"TYPES_REQUIRING_LOCAL_ELIGIBILITY",
|
||||
"TYPES_WITH_GENDER_QUOTA")) {
|
||||
Set<String> policy = readStringSetField(field);
|
||||
assertFalse(policy.isEmpty(), field + " 为空,资格策略未落地(Gate 未达成)");
|
||||
Set<String> bad = new TreeSet<>(policy);
|
||||
bad.removeAll(validTypes);
|
||||
assertTrue(bad.isEmpty(), field + " 含非法活动类型: " + bad);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate 守卫: matchmaking 与反诈类的资格策略必须不同")
|
||||
void matchmakingAndAntiFraudPoliciesMustDiffer() throws Exception {
|
||||
Set<String> needIdentity = readStringSetField("TYPES_REQUIRING_IDENTITY");
|
||||
Set<String> needLocal = readStringSetField("TYPES_REQUIRING_LOCAL_ELIGIBILITY");
|
||||
Set<String> genderQuota = readStringSetField("TYPES_WITH_GENDER_QUOTA");
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(needIdentity.contains(Constants.ACTIVITY_TYPE_MATCHMAKING),
|
||||
"matchmaking 必须要求实名"),
|
||||
() -> assertTrue(needLocal.contains(Constants.ACTIVITY_TYPE_MATCHMAKING),
|
||||
"matchmaking 必须要求本地资格"),
|
||||
() -> assertTrue(genderQuota.contains(Constants.ACTIVITY_TYPE_MATCHMAKING),
|
||||
"matchmaking 必须按性别名额扣减"),
|
||||
() -> assertFalse(needIdentity.contains(Constants.ACTIVITY_TYPE_ANTI_FRAUD),
|
||||
"反诈宣传是公益科普,不应要求实名 —— 否则两类活动策略无差异,Gate 未达成"),
|
||||
() -> assertFalse(needLocal.contains(Constants.ACTIVITY_TYPE_ANTI_FRAUD),
|
||||
"反诈宣传不应要求本地资格"),
|
||||
() -> assertFalse(genderQuota.contains(Constants.ACTIVITY_TYPE_ANTI_FRAUD),
|
||||
"反诈宣传不应按性别名额扣减")
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 小程序 / 卡片契约守卫 ====================
|
||||
|
||||
/**
|
||||
* 小程序分类 Tabs 的 code 必须逐字等于生产 {@code chk_activity_type} 白名单取值。
|
||||
*
|
||||
* <p>此前小程序写的是 {@code classroom},生产白名单里只有 {@code relationship_education}
|
||||
* ——「婚恋课堂」Tab 恒为空白页,而**没有任何测试会发现**。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("守卫: 小程序分类 Tabs 的 code ⊆ 生产 chk_activity_type 白名单")
|
||||
void miniappCategoryCodesWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_activity_type");
|
||||
Set<String> codes = readMiniappCategoryCodes();
|
||||
|
||||
assertFalse(codes.isEmpty(), "解析不到小程序分类 code,守卫失效");
|
||||
|
||||
Set<String> bad = new TreeSet<>(codes);
|
||||
bad.remove("all"); // 'all' 是「全部」哨兵,不落库
|
||||
bad.removeAll(allowed);
|
||||
assertTrue(bad.isEmpty(),
|
||||
"小程序分类 Tabs 的 code 与生产活动类型不匹配,点击后必然是空白列表:\n - "
|
||||
+ String.join("\n - ", bad) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产白名单里的每种类型都应在小程序有入口,否则该类型活动用户永远筛不到。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("守卫: 生产活动类型都必须能在小程序 Tabs 中找到")
|
||||
void everyProductionTypeHasMiniappTab() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_activity_type");
|
||||
Set<String> codes = readMiniappCategoryCodes();
|
||||
|
||||
Set<String> missing = new TreeSet<>(allowed);
|
||||
missing.removeAll(codes);
|
||||
assertTrue(missing.isEmpty(),
|
||||
"以下活动类型在小程序分类 Tabs 中无入口,用户永远筛不到:\n - " + String.join("\n - ", missing));
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片组件读到的字段名必须真实存在于 {@link ActivityListItemDTO}。
|
||||
*
|
||||
* <p>此前卡片读 {@code registeredCount} / {@code currentMaleCount} / {@code currentFemaleCount},
|
||||
* 后端一个都没有 —— "报名人数" 恒显示 0。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("守卫: 活动卡片读取的字段名必须存在于列表 DTO")
|
||||
void cardFieldsExistInListItemDto() throws Exception {
|
||||
Set<String> dtoFields = new TreeSet<>();
|
||||
for (java.lang.reflect.Method m : ActivityListItemDTO.class.getDeclaredMethods()) {
|
||||
String n = m.getName();
|
||||
if (n.startsWith("get") && m.getParameterCount() == 0) {
|
||||
dtoFields.add(Character.toLowerCase(n.charAt(3)) + n.substring(4));
|
||||
}
|
||||
if (n.startsWith("is") && m.getParameterCount() == 0 && m.getReturnType() == boolean.class) {
|
||||
dtoFields.add(Character.toLowerCase(n.charAt(2)) + n.substring(3));
|
||||
}
|
||||
}
|
||||
assertFalse(dtoFields.isEmpty(), "解析不到 DTO 字段,守卫失效");
|
||||
|
||||
// 卡片 wxml/js 里直接以 activity.<field> 形式读取的字段
|
||||
Set<String> used = readCardActivityFields();
|
||||
assertFalse(used.isEmpty(), "解析不到卡片读取的字段,守卫失效");
|
||||
|
||||
Set<String> unknown = new TreeSet<>(used);
|
||||
unknown.removeAll(dtoFields);
|
||||
assertTrue(unknown.isEmpty(),
|
||||
"活动卡片读取的字段在 ActivityListItemDTO 中不存在,界面恒为空:\n - "
|
||||
+ String.join("\n - ", unknown) + "\nDTO 字段: " + dtoFields);
|
||||
}
|
||||
|
||||
private static Set<String> readMiniappCategoryCodes() throws IOException {
|
||||
Path ts = resolveRepoFile("miniapp/pages/activity/activity.ts");
|
||||
// 必须匹配 "categories: [" —— 只写 "categories:" 会先命中
|
||||
// ActivityPageData 接口里的 `categories: ActivityCategory[];` 声明(无码值)。
|
||||
String src = Files.readString(ts, StandardCharsets.UTF_8);
|
||||
int start = src.indexOf("categories: [");
|
||||
if (start < 0) throw new IllegalStateException("activity.ts 中找不到 categories 数组字面量");
|
||||
int end = src.indexOf(']', start);
|
||||
String block = src.substring(start, end < 0 ? src.length() : end);
|
||||
Set<String> codes = new TreeSet<>();
|
||||
Matcher m = Pattern.compile("code:\\s*'([^']+)'").matcher(block);
|
||||
while (m.find()) codes.add(m.group(1));
|
||||
return codes;
|
||||
}
|
||||
|
||||
private static Set<String> readCardActivityFields() throws IOException {
|
||||
Set<String> fields = new TreeSet<>();
|
||||
for (String rel : List.of("miniapp/components/activity-card/index.wxml",
|
||||
"miniapp/components/activity-card/index.js")) {
|
||||
String src = Files.readString(resolveRepoFile(rel), StandardCharsets.UTF_8);
|
||||
// 必须先剥注释:注释里出现的 `activity.capacity`(说明"该字段不存在")
|
||||
// 会被正则当成真实读取,造成守卫假阳性。
|
||||
String code = src
|
||||
.replaceAll("(?s)<!--.*?-->", " ")
|
||||
.replaceAll("(?s)/\\*.*?\\*/", " ")
|
||||
.replaceAll("(?m)//.*$", " ");
|
||||
Matcher m = Pattern.compile("activity\\.([A-Za-z_][A-Za-z0-9_]*)").matcher(code);
|
||||
while (m.find()) fields.add(m.group(1));
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** 定位仓库内文件(surefire CWD 可能是仓库根或 backend/) */
|
||||
private static Path resolveRepoFile(String rel) {
|
||||
Path cur = Path.of("").toAbsolutePath().normalize();
|
||||
for (int i = 0; i < 6 && cur != null; i++) {
|
||||
Path direct = cur.resolve(rel);
|
||||
if (Files.isRegularFile(direct)) return direct;
|
||||
Path viaBackend = cur.resolve("backend").resolve(rel);
|
||||
if (Files.isRegularFile(viaBackend)) return viaBackend;
|
||||
cur = cur.getParent();
|
||||
}
|
||||
throw new IllegalStateException("守卫无法定位 " + rel + ";CWD=" + Path.of("").toAbsolutePath());
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
private static Set<String> resolveCheckValues(String constraintName) {
|
||||
if (CACHE.containsKey(constraintName)) return CACHE.get(constraintName);
|
||||
Set<String> result = null;
|
||||
try {
|
||||
Path dir = locateMigrationDir();
|
||||
List<Path> files;
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
files = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
|
||||
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
|
||||
.toList();
|
||||
}
|
||||
for (Path f : files) {
|
||||
String flat = Files.readString(f, StandardCharsets.UTF_8).replaceAll("\\s+", " ");
|
||||
|
||||
Pattern drop = Pattern.compile(
|
||||
"DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + Pattern.quote(constraintName),
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
if (drop.matcher(flat).find()) result = null;
|
||||
|
||||
Pattern add = Pattern.compile(
|
||||
"ADD\\s+CONSTRAINT\\s+" + Pattern.quote(constraintName)
|
||||
+ "\\s+CHECK\\s*\\([^)]*IN\\s*\\(([^)]*)\\)",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Matcher m = add.matcher(flat);
|
||||
if (m.find()) {
|
||||
Set<String> vals = new TreeSet<>();
|
||||
Matcher lit = Pattern.compile("'([^']*)'").matcher(m.group(1));
|
||||
while (lit.find()) vals.add(lit.group(1));
|
||||
result = vals.isEmpty() ? null : vals;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("解析迁移文件失败: " + e.getMessage(), e);
|
||||
}
|
||||
CACHE.put(constraintName, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 反射读常量 ====================
|
||||
|
||||
private static Object readField(String name) throws Exception {
|
||||
Field f = ActivityService.class.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Set<String> readStringSetField(String name) throws Exception {
|
||||
return (Set<String>) readField(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package com.danyang.match.platform.activity;
|
||||
|
||||
import com.danyang.match.platform.activity.dto.ActivityListItemDTO;
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.entity.ActivityOrganization;
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.activity.service.ActivityService;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.organization.entity.Organization;
|
||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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 java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 14:多类型公益活动 —— 真实运行时路径测试。
|
||||
*
|
||||
* <p><b>测试保真度</b>:走真实 Service 方法 + 真实落库;测试库 {@code schema-h2.sql}
|
||||
* 已补齐 V023 的 {@code activity_type} 列、{@code chk_activity_type} /
|
||||
* {@code chk_org_role} 约束,以及 activity_organizations / activity_capacity_groups 两表。
|
||||
* 因此非法类型/角色会在此处即暴露,而不是上到 PostgreSQL 才炸。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ActivityPhase14Test {
|
||||
|
||||
@Autowired private ActivityService activityService;
|
||||
@Autowired private ActivityMapper activityMapper;
|
||||
@Autowired private ActivityRegistrationMapper registrationMapper;
|
||||
@Autowired private ActivityOrganizationMapper activityOrganizationMapper;
|
||||
@Autowired private ActivityCapacityGroupMapper capacityGroupMapper;
|
||||
@Autowired private OrganizationMapper organizationMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private UserProfileMapper userProfileMapper;
|
||||
@Autowired private UserIdentityMapper userIdentityMapper;
|
||||
@Autowired private LocalEligibilityMapper localEligibilityMapper;
|
||||
|
||||
// ==================== D1:Mapper 空壳方法导致三条路径必崩 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("D1 回归: 活动详情不再因 Invalid bound statement 崩溃,且返回真实余量")
|
||||
void activityDetailReturnsRemainingSeats() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 5, null, null, false, null, null);
|
||||
|
||||
Map<String, Object> detail = activityService.getActivityDetail(a.getId(), "u-any");
|
||||
assertNotNull(detail);
|
||||
assertEquals(5, ((Number) detail.get("remainingSeats")).intValue());
|
||||
assertFalse((Boolean) detail.get("isFull"));
|
||||
assertEquals(Constants.ACTIVITY_TYPE_ANTI_FRAUD, detail.get("activityType"));
|
||||
}
|
||||
|
||||
// ==================== D2 + Gate:容量按配置正确扣减(原子)====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 总名额用尽后报名被拒(原子占位,非先读后写)")
|
||||
void registerRespectsTotalLimitAtomically() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 1, null, null, false, null, null);
|
||||
String u1 = newUser("male", 1990);
|
||||
String u2 = newUser("male", 1990);
|
||||
|
||||
assertNotNull(activityService.registerActivity(u1, a.getId(), "c1", "p1"));
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> activityService.registerActivity(u2, a.getId(), "c2", "p2"));
|
||||
assertTrue(ex.getMessage().contains("名额已满"),
|
||||
"第二个报名应因名额已满被拒,实际:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 审核通过同样受名额约束(两个 pending 抢最后一个名额)")
|
||||
void approveRespectsTotalLimitAtomically() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 1, null, null, true, null, null);
|
||||
String u1 = newUser("male", 1990);
|
||||
String u2 = newUser("male", 1990);
|
||||
ActivityRegistration r1 = registerPending(a.getId(), u1);
|
||||
ActivityRegistration r2 = registerPending(a.getId(), u2);
|
||||
|
||||
assertEquals(Constants.REGISTRATION_STATUS_APPROVED,
|
||||
activityService.approveRegistration(r1.getId(), "op-1").getStatus());
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> activityService.approveRegistration(r2.getId(), "op-1"));
|
||||
assertTrue(ex.getMessage().contains("名额已满") || ex.getMessage().contains("状态已变更"),
|
||||
"第二个审核应因名额已满被拒,实际:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: matchmaking 按性别名额扣减(男满后拒男、仍可收女)")
|
||||
void genderQuotaIsEnforcedForMatchmaking() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 1, 1, false, null, null);
|
||||
String m1 = newVerifiedUser("male", 1990);
|
||||
String m2 = newVerifiedUser("male", 1990);
|
||||
String f1 = newVerifiedUser("female", 1990);
|
||||
|
||||
assertNotNull(activityService.registerActivity(m1, a.getId(), "c", "p"));
|
||||
assertNotNull(activityService.registerActivity(f1, a.getId(), "c", "p"));
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> activityService.registerActivity(m2, a.getId(), "c", "p"));
|
||||
assertTrue(ex.getMessage().contains("名额已满"),
|
||||
"男性名额已满后应拒绝第 2 位男性,实际:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 课堂/反诈类不设性别名额,只受总名额约束(策略差异)")
|
||||
void antiFraudIgnoresGenderQuota() {
|
||||
// male_limit / female_limit 均为 null —— 仅 total_limit=2 生效
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 2, null, null, false, null, null);
|
||||
String m1 = newUser("male", 1990);
|
||||
String m2 = newUser("male", 1990);
|
||||
|
||||
assertNotNull(activityService.registerActivity(m1, a.getId(), "c", "p"));
|
||||
assertDoesNotThrow(() -> activityService.registerActivity(m2, a.getId(), "c", "p"),
|
||||
"反诈类活动不设性别名额,两位男性都应可报名");
|
||||
}
|
||||
|
||||
// ==================== D4 + Gate:按类型的资格策略 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: matchmaking 要求实名 + 本地资格(未实名被拒)")
|
||||
void matchmakingRequiresIdentityAndLocalEligibility() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 5, 5, false, null, null);
|
||||
String u = newUser("male", 1990); // 未实名、未核验本地资格
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> activityService.registerActivity(u, a.getId(), "c", "p"));
|
||||
assertTrue(ex.getMessage().contains("实名"),
|
||||
"matchmaking 应要求实名认证,实际:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 反诈类不要求实名 —— 与 matchmaking 策略不同")
|
||||
void antiFraudDoesNotRequireIdentity() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null);
|
||||
String u = newUser("male", 1990); // 未实名
|
||||
|
||||
assertDoesNotThrow(() -> activityService.registerActivity(u, a.getId(), "c", "p"),
|
||||
"反诈宣传是公益科普,不应设实名门槛");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 实名+本地资格齐备时 matchmaking 可报名")
|
||||
void matchmakingAllowsVerifiedUser() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 5, 5, false, null, null);
|
||||
String u = newVerifiedUser("male", 1990);
|
||||
|
||||
Map<String, Object> res = activityService.registerActivity(u, a.getId(), "c", "p");
|
||||
assertEquals(Constants.REGISTRATION_STATUS_APPROVED, res.get("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: resolveEligibility 对 matchmaking 与反诈类返回不同策略")
|
||||
void resolveEligibilityDiffersByType() {
|
||||
Activity match = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, 5, 5, false, null, null);
|
||||
Activity fraud = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null);
|
||||
|
||||
Map<String, Object> m = activityService.resolveEligibility(match);
|
||||
Map<String, Object> f = activityService.resolveEligibility(fraud);
|
||||
|
||||
assertEquals(true, m.get("requiresIdentityVerified"));
|
||||
assertEquals(true, m.get("requiresLocalEligibility"));
|
||||
assertEquals(true, m.get("usesGenderQuota"));
|
||||
|
||||
assertEquals(false, f.get("requiresIdentityVerified"));
|
||||
assertEquals(false, f.get("requiresLocalEligibility"));
|
||||
assertEquals(false, f.get("usesGenderQuota"));
|
||||
}
|
||||
|
||||
/** 年龄校验:原实现为 TODO 空壳,minAge/maxAge 完全不生效 */
|
||||
@Test
|
||||
@DisplayName("D4: 年龄范围真正生效(原为 TODO 空实现)")
|
||||
void ageRangeIsEnforced() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, (short) 30, (short) 45);
|
||||
String tooYoung = newUser("male", LocalDate.now().getYear() - 20);
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> activityService.registerActivity(tooYoung, a.getId(), "c", "p"));
|
||||
assertTrue(ex.getMessage().contains("年龄"), "应拦截年龄不足的报名,实际:" + ex.getMessage());
|
||||
}
|
||||
|
||||
// ==================== D3:白名单校验(生产 CHECK 约束)====================
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 创建活动拒绝非法 activityType(避免撞生产 chk_activity_type)")
|
||||
void createActivityRejectsUnknownType() {
|
||||
Map<String, Object> body = baseAdminBody();
|
||||
body.put("activityType", "speed_dating");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> activityService.createActivityFromAdmin(body, "op-1"));
|
||||
assertTrue(ex.getMessage().contains("非法的活动类型"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 添加组织拒绝非法 role(避免撞生产 chk_org_role)")
|
||||
void addActivityOrganizationRejectsUnknownRole() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null);
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> activityService.addActivityOrganization(a.getId(), "org-1", "sponsor", "op-1"));
|
||||
assertTrue(ex.getMessage().contains("非法的活动组织角色"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 合法类型与角色可被接受")
|
||||
void validTypeAndRoleAreAccepted() {
|
||||
Map<String, Object> body = baseAdminBody();
|
||||
body.put("activityType", Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE);
|
||||
Map<String, Object> created = activityService.createActivityFromAdmin(body, "op-1");
|
||||
assertEquals(Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE, created.get("activityType"));
|
||||
|
||||
Map<String, Object> org = activityService.addActivityOrganization(
|
||||
(String) created.get("id"), "org-1", Constants.ORG_ROLE_VENUE_PARTNER, "op-1");
|
||||
assertEquals(Constants.ORG_ROLE_VENUE_PARTNER, org.get("role"));
|
||||
}
|
||||
|
||||
// ==================== 后台动态表单:字段容错 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("D11: 后台传无时区本地时间不得被静默丢弃(原 Instant.parse 直接失败)")
|
||||
void adminLocalDateTimeIsNotSilentlyDropped() {
|
||||
Map<String, Object> body = baseAdminBody();
|
||||
body.put("startAt", "2030-01-02T10:00:00");
|
||||
body.put("endAt", "2030-01-02T12:00:00");
|
||||
|
||||
Map<String, Object> created = activityService.createActivityFromAdmin(body, "op-1");
|
||||
Activity saved = activityMapper.selectById((String) created.get("id"));
|
||||
assertNotNull(saved);
|
||||
assertEquals(10, saved.getStartAt().atZone(java.time.ZoneId.systemDefault()).getHour(),
|
||||
"运营填写的开始时间必须被真正保存,而不是静默回落成默认时间");
|
||||
assertEquals(12, saved.getEndAt().atZone(java.time.ZoneId.systemDefault()).getHour());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D11: 后台漏填名额/年龄不得 500(原实现 NPE)")
|
||||
void adminFormToleratesMissingOptionalFields() {
|
||||
Map<String, Object> body = new java.util.HashMap<>();
|
||||
body.put("title", "t-" + System.nanoTime());
|
||||
body.put("activityType", Constants.ACTIVITY_TYPE_ANTI_FRAUD);
|
||||
|
||||
Map<String, Object> created = assertDoesNotThrow(
|
||||
() -> activityService.createActivityFromAdmin(body, "op-1"));
|
||||
Activity saved = activityMapper.selectById((String) created.get("id"));
|
||||
assertNotNull(saved);
|
||||
assertNotNull(saved.getStartAt(), "缺省时间应有兜底");
|
||||
assertNull(saved.getMinAge(), "未填年龄门槛应为空(不设限),而非 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D11: 主办组织需回填到 activities.organizer_org_id(原实现留空)")
|
||||
void hostOrganizationIsPersistedOnActivity() {
|
||||
Map<String, Object> body = baseAdminBody();
|
||||
body.put("organizerOrgId", "org-host-1");
|
||||
|
||||
Map<String, Object> created = activityService.createActivityFromAdmin(body, "op-1");
|
||||
Activity saved = activityMapper.selectById((String) created.get("id"));
|
||||
assertEquals("org-host-1", saved.getOrganizerOrgId());
|
||||
assertFalse(activityService.getActivityOrganizations(saved.getId()).isEmpty());
|
||||
}
|
||||
|
||||
// ==================== D5/D6:列表数据真实性 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("D5: 男女人数为真实统计,不再是 approvedCount/2 编造")
|
||||
void listCountsRealGendersNotHalfSplit() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null);
|
||||
// 2 男 1 女 —— 原实现的 /2 会得到 male=1, female=2
|
||||
for (int i = 0; i < 2; i++) {
|
||||
activityService.registerActivity(newUser("male", 1990), a.getId(), "c", "p");
|
||||
}
|
||||
activityService.registerActivity(newUser("female", 1990), a.getId(), "c", "p");
|
||||
|
||||
List<ActivityListItemDTO> list = activityService.getActivities("u-x");
|
||||
ActivityListItemDTO dto = list.stream()
|
||||
.filter(d -> a.getId().equals(d.getActivityId())).findFirst().orElseThrow();
|
||||
|
||||
assertEquals(2, dto.getMaleCount(), "应按 user_profiles.gender 真实统计男性");
|
||||
assertEquals(1, dto.getFemaleCount(), "应按 user_profiles.gender 真实统计女性");
|
||||
assertEquals(3, dto.getRegisteredCount(), "registeredCount 应为真实报名人数(小程序卡片读此字段)");
|
||||
assertEquals(7, dto.getRemainingSeats());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D6: 列表 DTO 携带 activityType(小程序分类 Tabs 依赖)")
|
||||
void listItemsCarryActivityType() {
|
||||
newActivity(Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION, 10, null, null, false, null, null);
|
||||
|
||||
List<ActivityListItemDTO> list = activityService.getActivities("u-x");
|
||||
assertFalse(list.isEmpty());
|
||||
ActivityListItemDTO dto = list.stream()
|
||||
.filter(d -> Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION.equals(d.getActivityType()))
|
||||
.findFirst().orElse(null);
|
||||
assertNotNull(dto, "列表项必须带 activityType,否则小程序分类筛选恒无数据");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D6: 按类型筛选生效,且拒绝非法类型")
|
||||
void getActivitiesByTypeFiltersAndRejectsUnknown() {
|
||||
newActivity(Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE, 10, null, null, false, null, null);
|
||||
|
||||
List<Map<String, Object>> hit = activityService.getActivitiesByType(
|
||||
"u-x", Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE);
|
||||
assertFalse(hit.isEmpty());
|
||||
assertEquals(Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE, hit.get(0).get("activityType"));
|
||||
|
||||
List<Map<String, Object>> miss = activityService.getActivitiesByType(
|
||||
"u-x", Constants.ACTIVITY_TYPE_ANTI_FRAUD);
|
||||
assertTrue(miss.isEmpty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> activityService.getActivitiesByType("u-x", "not_a_type"));
|
||||
}
|
||||
|
||||
// ==================== D10:软删活动导致 NPE ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("D10: 我的活动跳过已软删的活动,不再 NPE")
|
||||
void myActivitiesSkipsDeletedActivity() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_ANTI_FRAUD, 10, null, null, false, null, null);
|
||||
String u = newUser("male", 1990);
|
||||
activityService.registerActivity(u, a.getId(), "c", "p");
|
||||
|
||||
assertFalse(activityService.getMyActivities(u).isEmpty());
|
||||
|
||||
// 软删活动后应跳过而非 NPE
|
||||
activityMapper.deleteById(a.getId());
|
||||
assertDoesNotThrow(() -> activityService.getMyActivities(u));
|
||||
assertTrue(activityService.getMyActivities(u).isEmpty());
|
||||
}
|
||||
|
||||
// ==================== INV-020:主办组织关系不自动授予用户库访问 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-020: 活动组织接口只返回组织信息,不泄漏任何用户数据")
|
||||
void activityOrganizationsExposeNoUserData() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, null, null, false, null, null);
|
||||
Organization org = new Organization();
|
||||
org.setName("丹阳市妇联");
|
||||
org.setType("women_federation");
|
||||
org.setStatus("active");
|
||||
organizationMapper.insert(org);
|
||||
activityService.addActivityOrganization(a.getId(), org.getId(), Constants.ORG_ROLE_HOST, "op-1");
|
||||
|
||||
List<Map<String, Object>> orgs = activityService.getActivityOrganizations(a.getId());
|
||||
assertFalse(orgs.isEmpty());
|
||||
for (Map<String, Object> o : orgs) {
|
||||
assertTrue(o.keySet().stream().noneMatch(k -> k.toLowerCase().contains("user")),
|
||||
"组织接口不得返回任何用户字段,实际键:" + o.keySet());
|
||||
assertEquals("丹阳市妇联", o.get("name"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-020: 报名列表(含用户资料)位于运营端且要求显式 activity.view 权限")
|
||||
void registrationListRequiresOperatorPermission() throws Exception {
|
||||
var cls = com.danyang.match.platform.activity.controller.ActivityOperatorController.class;
|
||||
var pre = cls.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class);
|
||||
assertNotNull(pre, "运营端活动控制器必须有类级 @PreAuthorize,不能依赖粗过滤器");
|
||||
assertTrue(pre.value().contains("activity.view"));
|
||||
|
||||
// 用户端控制器不得暴露报名列表
|
||||
boolean userSideExposesRegistrations = java.util.Arrays.stream(
|
||||
com.danyang.match.platform.activity.controller.ActivityController.class.getDeclaredMethods())
|
||||
.anyMatch(m -> m.getName().toLowerCase().contains("registration")
|
||||
&& m.getName().toLowerCase().contains("list"));
|
||||
assertFalse(userSideExposesRegistrations, "用户端不得提供报名列表接口");
|
||||
}
|
||||
|
||||
// ==================== 多组织与容量分组落库 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("多组织关系与容量分组可正确落库并回读")
|
||||
void multiOrgAndCapacityGroupsPersist() {
|
||||
Activity a = newActivity(Constants.ACTIVITY_TYPE_MATCHMAKING, 10, null, null, false, null, null);
|
||||
|
||||
activityService.addActivityOrganization(a.getId(), "org-a", Constants.ORG_ROLE_HOST, "op-1");
|
||||
activityService.addActivityOrganization(a.getId(), "org-b", Constants.ORG_ROLE_PARTNER, "op-1");
|
||||
assertEquals(2, activityService.getActivityOrganizations(a.getId()).size());
|
||||
|
||||
activityService.addCapacityGroup(a.getId(),
|
||||
Map.of("groupCode", "male_30_40", "capacity", 20, "ruleDefinition", "{\"gender\":\"male\"}"), "op-1");
|
||||
List<Map<String, Object>> groups = activityService.getActivityCapacityGroups(a.getId());
|
||||
assertEquals(1, groups.size());
|
||||
assertEquals("male_30_40", groups.get(0).get("groupCode"));
|
||||
assertEquals(20, ((Number) groups.get(0).get("capacity")).intValue());
|
||||
assertEquals(0, ((Number) groups.get(0).get("approvedCount")).intValue());
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
private Activity newActivity(String type, int totalLimit, Integer maleLimit, Integer femaleLimit,
|
||||
boolean needReview, Short minAge, Short maxAge) {
|
||||
Organization org = new Organization();
|
||||
org.setName("org-" + System.nanoTime());
|
||||
org.setType("other");
|
||||
org.setStatus("active");
|
||||
organizationMapper.insert(org);
|
||||
|
||||
Activity a = new Activity();
|
||||
a.setTitle("act-" + System.nanoTime());
|
||||
a.setOrganizerOrgId(org.getId());
|
||||
a.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
|
||||
a.setActivityType(type);
|
||||
a.setTotalLimit(totalLimit);
|
||||
a.setMaleLimit(maleLimit);
|
||||
a.setFemaleLimit(femaleLimit);
|
||||
a.setNeedReview(needReview);
|
||||
a.setMinAge(minAge);
|
||||
a.setMaxAge(maxAge);
|
||||
a.setStartAt(Instant.now().plusSeconds(3600));
|
||||
a.setEndAt(Instant.now().plusSeconds(7200));
|
||||
activityMapper.insert(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
private String newUser(String gender, int birthYear) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("p14_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
|
||||
UserProfile p = new UserProfile();
|
||||
p.setUserId(u.getId());
|
||||
p.setGender(gender);
|
||||
p.setBirthDate(LocalDate.of(birthYear, 1, 1));
|
||||
userProfileMapper.insert(p);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
/** 已实名 + 已通过本地资格核验的用户 */
|
||||
private String newVerifiedUser(String gender, int birthYear) {
|
||||
String userId = newUser(gender, birthYear);
|
||||
|
||||
UserIdentity id = new UserIdentity();
|
||||
id.setUserId(userId);
|
||||
id.setIdNumberHash("hash-" + System.nanoTime());
|
||||
id.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||
id.setGender(gender);
|
||||
id.setBirthDate(LocalDate.of(birthYear, 1, 1));
|
||||
userIdentityMapper.insert(id);
|
||||
|
||||
LocalEligibility el = new LocalEligibility();
|
||||
el.setUserId(userId);
|
||||
el.setEligibilityType("household");
|
||||
el.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||
localEligibilityMapper.insert(el);
|
||||
return userId;
|
||||
}
|
||||
|
||||
private ActivityRegistration registerPending(String activityId, String userId) {
|
||||
Map<String, Object> r = activityService.registerActivity(userId, activityId, "c", "p");
|
||||
ActivityRegistration reg = new ActivityRegistration();
|
||||
reg.setId((String) r.get("registrationId"));
|
||||
reg.setActivityId(activityId);
|
||||
reg.setUserId(userId);
|
||||
return reg;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseAdminBody() {
|
||||
Map<String, Object> body = new java.util.HashMap<>();
|
||||
body.put("title", "t-" + System.nanoTime());
|
||||
body.put("description", "d");
|
||||
body.put("startAt", Instant.now().plusSeconds(3600).toString());
|
||||
body.put("endAt", Instant.now().plusSeconds(7200).toString());
|
||||
body.put("locationName", "丹阳市");
|
||||
body.put("totalLimit", 10);
|
||||
body.put("minAge", 20);
|
||||
body.put("maxAge", 60);
|
||||
body.put("activityType", Constants.ACTIVITY_TYPE_MATCHMAKING);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
+19
-13
@@ -25,17 +25,23 @@ class ActivityServiceTest {
|
||||
assertEquals("cancelled", Constants.REGISTRATION_STATUS_CANCELLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNoOverbooking() {
|
||||
// Core invariant: 活动最后一个名额不可超卖
|
||||
// 通过事务内原子检查保证
|
||||
assertTrue(true); // 占位测试
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNoDuplicateRegistration() {
|
||||
// Core invariant: 同一用户不能重复报名同一活动
|
||||
// 通过 UNIQUE(activity_id, user_id) 唯一约束保证
|
||||
assertTrue(true); // 占位测试
|
||||
}
|
||||
// ========================================================================
|
||||
// Phase 14 复验:以下两条占位测试已**删除**。
|
||||
//
|
||||
// testNoOverbooking -> assertTrue(true) // 占位测试
|
||||
// testNoDuplicateRegistration -> assertTrue(true) // 占位测试
|
||||
//
|
||||
// 它们是典型的「假绿」:名字覆盖两条核心不变量,实际什么都不验证,
|
||||
// 而真实实现恰恰是坏的 ——
|
||||
// * 超卖保护:ActivityService 调用的是 ActivityMapper.countRemainingCapacity,
|
||||
// 该方法既无 @Select 也无 XML,调用即抛 Invalid bound statement;
|
||||
// 且即便修好,仍是「SELECT 余量 → INSERT」的先读后写,并发必然超卖。
|
||||
// * 重复报名:实际由 UNIQUE(activity_id, user_id) 保证,本测试从未验证过。
|
||||
//
|
||||
// 真正的验证已迁至 ActivityPhase14Test:
|
||||
// registerRespectsTotalLimitAtomically / approveRespectsTotalLimitAtomically /
|
||||
// genderQuotaIsEnforcedForMatchmaking —— 均走真实落库路径。
|
||||
//
|
||||
// 教训:占位测试不是"待补",而是会持续误导的假信号,必须删除而非保留。
|
||||
// ========================================================================
|
||||
}
|
||||
|
||||
+6
-4
@@ -52,10 +52,8 @@ class EntityMigrationParityTest {
|
||||
* 任何人新增链路若依赖白名单外的表,必须先补 schema-h2.sql。
|
||||
*/
|
||||
private static final Set<String> TEST_SCHEMA_OPTIONAL_TABLES = Set.of(
|
||||
"activity_capacity_groups",
|
||||
"activity_interests",
|
||||
"activity_mutual_connection_consents",
|
||||
"activity_organizations",
|
||||
"appeals",
|
||||
"consent_records",
|
||||
"content_categories",
|
||||
@@ -64,7 +62,6 @@ class EntityMigrationParityTest {
|
||||
"operator_roles",
|
||||
"operators",
|
||||
"organization_relationships",
|
||||
"organizations",
|
||||
"permissions",
|
||||
"reports",
|
||||
"review_items",
|
||||
@@ -107,7 +104,12 @@ class EntityMigrationParityTest {
|
||||
// Phase 13A:咨询两表此前 counseling_services 完全缺失、counseling_followups
|
||||
// 仅在可选白名单里(= 实际上没人测)。已补入 schema-h2.sql 并移入必需集合。
|
||||
"counseling_services",
|
||||
"counseling_followups"
|
||||
"counseling_followups",
|
||||
// Phase 14:活动组织与容量分组三表此前全在可选白名单(= 无人测),
|
||||
// 导致 ActivityService 的类型/容量/多组织逻辑零覆盖。已补入 schema-h2.sql。
|
||||
"organizations",
|
||||
"activity_organizations",
|
||||
"activity_capacity_groups"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
|
||||
@@ -217,7 +217,54 @@ CREATE TABLE activities (
|
||||
male_limit INT, female_limit INT, total_limit INT, min_age SMALLINT, max_age SMALLINT,
|
||||
registration_start_at TIMESTAMP WITH TIME ZONE, registration_end_at TIMESTAMP WITH TIME ZONE,
|
||||
need_review BOOLEAN, status VARCHAR(32), created_by VARCHAR(36),
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
-- V023(Phase 14):多类型活动扩展列。此前测试库完全缺失,
|
||||
-- 导致 Phase 14 全部新增逻辑零覆盖,且非法 activity_type 在测试期不可见
|
||||
-- (上到 PostgreSQL 才撞 chk_activity_type 返回 500)。
|
||||
activity_type VARCHAR(64) NOT NULL DEFAULT 'matchmaking',
|
||||
registration_policy CLOB,
|
||||
target_group VARCHAR(200),
|
||||
industry_scope VARCHAR(200),
|
||||
post_event_interest_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT chk_activity_type CHECK (activity_type IN (
|
||||
'matchmaking', 'interest_social', 'relationship_education',
|
||||
'premarital_guidance', 'marriage_custom_culture', 'anti_fraud'
|
||||
))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Phase 14(V001 + V023):活动组织与容量分组
|
||||
-- 此前测试库完全缺失 organizations / activity_organizations / activity_capacity_groups,
|
||||
-- 且 activities 缺 V023 全部新列 —— Phase 14 新增逻辑零覆盖。
|
||||
-- 约束与生产 V023 对齐(chk_org_role 白名单)。
|
||||
-- ============================================================================
|
||||
|
||||
-- V001: organizations 组织表(activity_organizations 的外键目标,且组织名用于活动详情展示)
|
||||
CREATE TABLE organizations (
|
||||
id VARCHAR(36) PRIMARY KEY, name VARCHAR(200), type VARCHAR(32),
|
||||
contact_name VARCHAR(100), contact_phone_enc VARCHAR(500), status VARCHAR(32),
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- V023: activity_organizations 活动多组织关联表
|
||||
CREATE TABLE activity_organizations (
|
||||
id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36) NOT NULL, organization_id VARCHAR(36) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT uk_activity_org UNIQUE (activity_id, organization_id),
|
||||
CONSTRAINT chk_org_role CHECK (role IN ('host', 'organizer', 'co_organizer', 'partner', 'venue_partner'))
|
||||
);
|
||||
|
||||
-- V023: activity_capacity_groups 活动容量分组表
|
||||
CREATE TABLE activity_capacity_groups (
|
||||
id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36) NOT NULL,
|
||||
group_code VARCHAR(32) NOT NULL, rule_definition CLOB,
|
||||
capacity INT NOT NULL DEFAULT 0, approved_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT uk_capacity_group UNIQUE (activity_id, group_code)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user