fix(organization): 机构三处写操作必崩 500 修复 + type 白名单 + 测试库约束补齐
缺陷本体(实施计划附 B.1,2026-09-19 登记并实测复现,本轮处置):
OrganizationService 的 createOrganization / disableOrganization / enableOrganization
分别写入 Constants.ACCOUNT_STATUS_NORMAL("normal") 与 ACCOUNT_STATUS_SUSPENDED("suspended")。
那是 users.account_status 的取值,而 organizations.status 的 CHECK 白名单
(V001 chk_org_status)只有 ('active','inactive') → 三个写操作全部违约,
生产必崩 PSQLException violates check constraint "chk_org_status" → HTTP 500。
二阶影响:activities.organizer_org_id NOT NULL ⇒ 无机构即无法新建活动,
运营端「建机构 → 建活动 → 报名 → 互选」链路的第一环是断的。
修复:
- Constants 新增 ORG_STATUS_ACTIVE / ORG_STATUS_INACTIVE(独立于 users.account_status,
附注释说明两者白名单不同,禁止互相复用)
- 三处状态值改为 ORG_STATUS_ACTIVE / ORG_STATUS_INACTIVE
- 新增 OrganizationService.ALLOWED_TYPES + type 白名单前置校验:
非法值返回 VALIDATION_ERROR(400),不再打到 PostgreSQL 才 500
- 「机构不存在」由裸 RuntimeException(→500 SERVER_ERROR) 改为 NOT_FOUND(404)
- 异常类型统一为 GlobalExceptionHandler.BusinessException —— 独立类
shared.exception.BusinessException 没有 @ExceptionHandler,抛它仍会退化成 500
根因第三重盲区(缺陷长期隐身的直接原因):
schema-h2.sql 的 organizations 表此前未复刻这两条 CHECK,H2 上写 'normal' 畅通无阻。
已补齐 chk_org_type / chk_org_status 及 NOT NULL / DEFAULT,与生产 DDL 逐值一致。
新增守卫:
- OrganizationConstraintParityTest:生产迁移 DDL ↔ 测试库 schema ↔ 后端白名单 ↔
Constants 四方比对 + 解析器自检(虚构约束名必须解析失败,防断言静默永真)。
其中 status DEFAULT 的解析必须先把范围切到 organizations 建表块 —— 全文件搜索会
误命中 users.account_status(本轮自检脚本实测踩到并修正该缺陷)。
- OrganizationServiceTest:走真实 DB 的 H2 集成测试,9 用例覆盖创建/停用/启用/
停启往返/六个合法 type/非法 type/名称空白/404 语义,断言落库值而非返回值。
⚠️ 未在本机运行测试:本轮探测确认本机无 Docker、无 JDK,wsl.exe 被安全策略阻止
(与 known-risks.md 的 WSL/Docker 故障一致),mvn -o test 与负向注入均无法执行。
已用等价正则的独立脚本验证守卫判别力与两处 DDL 一致性(PASS,脚本留痕
.workbuddy/_verify_org_guard.py)。
补跑全量回归 + 负向注入前,本项记为「待验证」,不得计入已验收。
This commit is contained in:
+98
-12
@@ -6,6 +6,8 @@ import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.organization.entity.Organization;
|
||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -13,14 +15,56 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 机构管理服务
|
||||
*
|
||||
* <h3>2026-09-21 修复:三处写操作必然 500(High)</h3>
|
||||
* 原实现把机构状态写成 {@code Constants.ACCOUNT_STATUS_NORMAL}("normal") /
|
||||
* {@code ACCOUNT_STATUS_SUSPENDED}("suspended") —— 那是 {@code users.account_status} 的取值,
|
||||
* 而 {@code organizations.status} 的 CHECK 白名单(V001 {@code chk_org_status})只有
|
||||
* <b>('active', 'inactive')</b>,于是**创建 / 停用 / 启用三个写操作全部违约**,
|
||||
* 生产上必然 {@code PSQLException: violates check constraint "chk_org_status"} → HTTP 500。
|
||||
*
|
||||
* <p>二阶影响:{@code activities.organizer_org_id} 为 NOT NULL —— 无机构即无法新建活动,
|
||||
* 运营端「建机构 → 建活动 → 报名 → 互选」整条链路的第一环是断的。
|
||||
*
|
||||
* <p>为何长期未被发现:① 全仓无任何测试引用本类;② E2E 的
|
||||
* {@code tests/e2e/sql/seed_e2e_activity.sql} 直接写库插入机构,绕过本 Service,
|
||||
* 测试全绿掩盖了 API 路径的失败;③ 测试库 schema-h2.sql 的 {@code organizations}
|
||||
* 表当时没有复刻这两条 CHECK(非法值在测试期畅通无阻)。
|
||||
* 三者已在本轮一并处置,并由 {@code OrganizationConstraintParityTest} 长期守卫。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrganizationService {
|
||||
|
||||
/**
|
||||
* 机构类型白名单 —— 与生产迁移 V001 的 {@code chk_org_type} 逐值对应。
|
||||
*
|
||||
* <p>⚠️ 不得在此处单方面增删:{@code OrganizationConstraintParityTest} 会从迁移 DDL
|
||||
* 解析白名单与本集合比对,任何一侧漂移立即变红(写入非法值原本要打到 PostgreSQL
|
||||
* 才以 500 暴露,现在由本集合前置拦截为 400)。
|
||||
*/
|
||||
public static final Set<String> ALLOWED_TYPES = Set.of(
|
||||
"women_federation",
|
||||
"trade_union",
|
||||
"charity_center",
|
||||
"volunteer_team",
|
||||
"civil_affairs_partner",
|
||||
"other");
|
||||
|
||||
/**
|
||||
* 机构状态白名单 —— 与生产迁移 V001 的 {@code chk_org_status} 逐值对应。
|
||||
*
|
||||
* <p>注意与 {@code users.account_status} 的区别:后者是
|
||||
* normal / reviewing_risk / limited / suspended / banned / closed。混用即本轮修复的缺陷。
|
||||
*/
|
||||
public static final Set<String> ALLOWED_STATUSES = Set.of(
|
||||
Constants.ORG_STATUS_ACTIVE,
|
||||
Constants.ORG_STATUS_INACTIVE);
|
||||
|
||||
private final OrganizationMapper organizationMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
|
||||
@@ -36,11 +80,18 @@ public class OrganizationService {
|
||||
|
||||
@Transactional
|
||||
public Organization createOrganization(Map<String, Object> params) {
|
||||
String name = asNonBlankString(params.get("name"));
|
||||
if (name == null) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "机构名称不能为空");
|
||||
}
|
||||
String type = asNonBlankString(params.get("type"));
|
||||
requireAllowedType(type);
|
||||
|
||||
Organization org = new Organization();
|
||||
org.setName((String) params.get("name"));
|
||||
org.setType((String) params.get("type"));
|
||||
org.setContactName((String) params.get("contactName"));
|
||||
org.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
org.setName(name);
|
||||
org.setType(type);
|
||||
org.setContactName(asNonBlankString(params.get("contactName")));
|
||||
org.setStatus(Constants.ORG_STATUS_ACTIVE);
|
||||
organizationMapper.insert(org);
|
||||
saveAuditLog(null, "CREATE_ORGANIZATION", "organization", org.getId(), null, null);
|
||||
return org;
|
||||
@@ -49,18 +100,34 @@ public class OrganizationService {
|
||||
@Transactional
|
||||
public void updateOrganization(String id, Map<String, Object> params) {
|
||||
Organization org = organizationMapper.selectById(id);
|
||||
if (org == null) throw new RuntimeException("机构不存在");
|
||||
if (params.get("name") != null) org.setName((String) params.get("name"));
|
||||
if (params.get("type") != null) org.setType((String) params.get("type"));
|
||||
if (params.get("contactName") != null) org.setContactName((String) params.get("contactName"));
|
||||
if (org == null) {
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "机构不存在");
|
||||
}
|
||||
if (params.get("name") != null) {
|
||||
String name = asNonBlankString(params.get("name"));
|
||||
if (name == null) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "机构名称不能为空");
|
||||
}
|
||||
org.setName(name);
|
||||
}
|
||||
if (params.get("type") != null) {
|
||||
String type = asNonBlankString(params.get("type"));
|
||||
requireAllowedType(type);
|
||||
org.setType(type);
|
||||
}
|
||||
if (params.get("contactName") != null) {
|
||||
org.setContactName(asNonBlankString(params.get("contactName")));
|
||||
}
|
||||
organizationMapper.updateById(org);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void disableOrganization(String id, String operatorId) {
|
||||
Organization org = organizationMapper.selectById(id);
|
||||
if (org == null) throw new RuntimeException("机构不存在");
|
||||
org.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
|
||||
if (org == null) {
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "机构不存在");
|
||||
}
|
||||
org.setStatus(Constants.ORG_STATUS_INACTIVE);
|
||||
org.setUpdatedAt(Instant.now());
|
||||
organizationMapper.updateById(org);
|
||||
saveAuditLog(operatorId, "DISABLE_ORGANIZATION", "organization", id, null, null);
|
||||
@@ -69,13 +136,32 @@ public class OrganizationService {
|
||||
@Transactional
|
||||
public void enableOrganization(String id, String operatorId) {
|
||||
Organization org = organizationMapper.selectById(id);
|
||||
if (org == null) throw new RuntimeException("机构不存在");
|
||||
org.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
if (org == null) {
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "机构不存在");
|
||||
}
|
||||
org.setStatus(Constants.ORG_STATUS_ACTIVE);
|
||||
org.setUpdatedAt(Instant.now());
|
||||
organizationMapper.updateById(org);
|
||||
saveAuditLog(operatorId, "ENABLE_ORGANIZATION", "organization", id, null, null);
|
||||
}
|
||||
|
||||
/** 去空白后取非空字符串;非字符串 / 空白串一律视为「未提供」。 */
|
||||
private static String asNonBlankString(Object value) {
|
||||
if (!(value instanceof String raw)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
/** 类型必须落在生产 CHECK 白名单内,否则前置返回 400,而不是打到 PostgreSQL 才 500。 */
|
||||
private static void requireAllowedType(String type) {
|
||||
if (type == null || !ALLOWED_TYPES.contains(type)) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
|
||||
"机构类型不合法:" + type + ",允许值 " + ALLOWED_TYPES);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
|
||||
@@ -228,6 +228,13 @@ public final class Constants {
|
||||
public static final String MUTUAL_CONSENT_STATUS_ACCEPTED = "accepted";
|
||||
public static final String MUTUAL_CONSENT_STATUS_DECLINED = "declined";
|
||||
|
||||
// 机构状态(V001 organizations)
|
||||
// ⚠️ 不得复用 users 的 ACCOUNT_STATUS_*:organizations.status 的 CHECK 白名单
|
||||
// 只有 ('active', 'inactive'),而 account_status 是 normal/reviewing_risk/... 六值。
|
||||
// 2026-09-21 修复的机构三处写操作 500 即源于此误用(见 OrganizationConstraintParityTest)。
|
||||
public static final String ORG_STATUS_ACTIVE = "active";
|
||||
public static final String ORG_STATUS_INACTIVE = "inactive";
|
||||
|
||||
// 活动组织角色(V1.1 Phase 14)
|
||||
public static final String ORG_ROLE_HOST = "host";
|
||||
public static final String ORG_ROLE_ORGANIZER = "organizer";
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.danyang.match.platform.deployment;
|
||||
|
||||
import com.danyang.match.platform.organization.service.OrganizationService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.stream.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 机构约束一致性守卫(2026-09-21 新增,永久生效)。
|
||||
*
|
||||
* <h3>为什么需要它</h3>
|
||||
* {@code OrganizationService} 曾把机构状态写成 {@code Constants.ACCOUNT_STATUS_NORMAL}("normal") /
|
||||
* {@code ACCOUNT_STATUS_SUSPENDED}("suspended") —— 那是 {@code users.account_status} 的取值,
|
||||
* 而 {@code organizations.status} 的 CHECK 白名单只有 ('active', 'inactive')。
|
||||
* 后果:创建 / 停用 / 启用三个写操作**全部违约**,生产必崩
|
||||
* ({@code violates check constraint "chk_org_status"} → 500),
|
||||
* 而「无机构即无法建活动」({@code activities.organizer_org_id} NOT NULL)使其成为运营端链路级阻塞。
|
||||
*
|
||||
* <p>它之所以能长期隐身,是三重盲区叠加:全仓无测试引用该 Service、E2E seed 直连写库绕过 Service、
|
||||
* 且**测试库 schema-h2.sql 当时没有复刻这两条 CHECK**(H2 无约束 → 非法值畅通无阻)。
|
||||
* 本轮三者一并处置,本守卫负责让第二重盲区不再复发。
|
||||
*
|
||||
* <h3>本守卫把四者变成编译后可验证的不变量</h3>
|
||||
* 「生产迁移 DDL」↔「测试库 schema」↔「后端白名单」↔「Constants 状态常量」——
|
||||
* 任何一处漂移立即变红,不必再靠人工比对。
|
||||
* 与 {@code BannerConstraintParityTest} / {@code ServiceLocationAnnouncementConstraintParityTest}
|
||||
* 同族同模式;纯文本 + 反射实现,不启动 Spring 上下文,秒级完成,适合入门禁。
|
||||
*/
|
||||
class OrganizationConstraintParityTest {
|
||||
|
||||
private static final Path PROD_MIGRATION =
|
||||
Paths.get("backend/src/main/resources/db/migration/V001__init_base_tables.sql");
|
||||
private static final Path TEST_SCHEMA = Paths.get("backend/src/test/resources/schema-h2.sql");
|
||||
|
||||
/** 与 V001 的两条 CHECK 一一对应:约束名 / 列名 / 后端白名单字段名 */
|
||||
private record ConstraintSpec(String name, String column, String backendField) {}
|
||||
|
||||
private static final ConstraintSpec TYPE =
|
||||
new ConstraintSpec("chk_org_type", "type", "ALLOWED_TYPES");
|
||||
private static final ConstraintSpec STATUS =
|
||||
new ConstraintSpec("chk_org_status", "status", "ALLOWED_STATUSES");
|
||||
|
||||
private static final List<ConstraintSpec> SPECS = List.of(TYPE, STATUS);
|
||||
|
||||
// ==================== DDL 解析 ====================
|
||||
|
||||
/**
|
||||
* 从建表/约束脚本里解析 {@code CONSTRAINT <name> CHECK (<column> IN ('a', 'b', ...))} 的取值集合。
|
||||
*
|
||||
* <p>解析不到直接失败 —— 空集合会让下面的 assertEquals 变成假绿。
|
||||
* 约束名后加词边界断言,避免 {@code chk_org_status} 误匹配到
|
||||
* {@code chk_user_account_status} 这类「名字含子串」的其他约束。
|
||||
*/
|
||||
private static Set<String> parseCheckValues(Path sqlFile, ConstraintSpec spec) throws IOException {
|
||||
String sql = Files.readString(sqlFile);
|
||||
Pattern pattern = Pattern.compile(
|
||||
"CONSTRAINT\\s+" + Pattern.quote(spec.name()) + "(?![A-Za-z0-9_])\\s+CHECK\\s*\\(\\s*"
|
||||
+ Pattern.quote(spec.column()) + "\\s+IN\\s*\\(([^)]*)\\)",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
Matcher matcher = pattern.matcher(sql);
|
||||
assertTrue(matcher.find(),
|
||||
"未能从 " + sqlFile + " 解析出 " + spec.name()
|
||||
+ " —— 解析失败会让本守卫静默失效,必须显式报错(缺约束是缺陷,不是可跳过项)");
|
||||
|
||||
Set<String> values = Pattern.compile("'([^']+)'").matcher(matcher.group(1)).results()
|
||||
.map(MatchResult::group)
|
||||
.map(quoted -> quoted.substring(1, quoted.length() - 1))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
|
||||
assertFalse(values.isEmpty(), spec.name() + " 解析出空取值集合,守卫会变成假绿");
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 {@code organizations} 表 {@code status} 列的 DEFAULT 字面量。
|
||||
* 用于保真度断言:生产是 {@code DEFAULT 'active'},测试库若漏了 DEFAULT,
|
||||
* 「不传 status 时落什么值」这条语义在测试期就与生产不同。
|
||||
*
|
||||
* <p>⚠️ 必须**先切出 organizations 建表块再在块内匹配** —— 直接在全文件搜
|
||||
* {@code status VARCHAR(32) NOT NULL DEFAULT '...'} 会误命中
|
||||
* {@code users.account_status}(DEFAULT 'normal')、{@code operators.status} 等
|
||||
* 「以 status 结尾的同型列」,得出与 organizations 无关的值。
|
||||
* 本轮自检脚本实测踩到过:全文件匹配在生产侧返回 'normal'、测试库侧返回 'pending',
|
||||
* 两个值都不是 organizations 的。
|
||||
*/
|
||||
private static String parseStatusDefault(Path sqlFile) throws IOException {
|
||||
String sql = Files.readString(sqlFile);
|
||||
Pattern tablePattern = Pattern.compile(
|
||||
"CREATE\\s+TABLE(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s+organizations\\s*\\((.*?)\\);",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
Matcher table = tablePattern.matcher(sql);
|
||||
assertTrue(table.find(),
|
||||
"未能从 " + sqlFile + " 定位 organizations 建表块 —— 定位失败会让本断言静默失效");
|
||||
|
||||
Pattern defaultPattern = Pattern.compile(
|
||||
"(?<![A-Za-z0-9_])status\\s+VARCHAR\\s*\\(\\s*32\\s*\\)\\s+NOT\\s+NULL\\s+DEFAULT\\s+'([^']+)'",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = defaultPattern.matcher(table.group(1));
|
||||
assertTrue(matcher.find(),
|
||||
"未能从 " + sqlFile + " 的 organizations 建表块解析出 status 的 DEFAULT");
|
||||
return matcher.group(1);
|
||||
}
|
||||
|
||||
// ==================== 后端白名单(反射) ====================
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Set<String> backendWhitelist(String fieldName) throws Exception {
|
||||
Field field = OrganizationService.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
Object value = field.get(null);
|
||||
assertNotNull(value, "OrganizationService." + fieldName + " 不存在或为 null");
|
||||
return (Set<String>) value;
|
||||
}
|
||||
|
||||
// ==================== 断言 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("测试库 schema 与生产迁移声明了相同的机构 CHECK 白名单(此前测试库两条全缺)")
|
||||
void productionAndTestSchemaDeclareIdenticalOrganizationChecks() throws IOException {
|
||||
for (ConstraintSpec spec : SPECS) {
|
||||
Set<String> production = parseCheckValues(PROD_MIGRATION, spec);
|
||||
Set<String> test = parseCheckValues(TEST_SCHEMA, spec);
|
||||
assertEquals(production, test,
|
||||
spec.name() + " 在生产迁移与测试库 schema-h2.sql 之间漂移:"
|
||||
+ "生产=" + production + ",测试库=" + test
|
||||
+ "。测试库缺约束 = 该非法值路径零覆盖,上生产才以 500 暴露。");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("后端白名单与生产 DDL 逐值一致(多值会打到 PG 才 500,缺值会误拒合法配置)")
|
||||
void backendWhitelistsMatchProductionDdl() throws Exception {
|
||||
for (ConstraintSpec spec : SPECS) {
|
||||
assertEquals(parseCheckValues(PROD_MIGRATION, spec), backendWhitelist(spec.backendField()),
|
||||
"OrganizationService." + spec.backendField() + " 与生产 " + spec.name() + " 不一致");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Constants 机构状态常量落在生产白名单内,且白名单每个值都有常量覆盖")
|
||||
void orgStatusConstantsCoverProductionWhitelist() throws IOException {
|
||||
Set<String> production = parseCheckValues(PROD_MIGRATION, STATUS);
|
||||
Set<String> declared = new LinkedHashSet<>(List.of(
|
||||
Constants.ORG_STATUS_ACTIVE, Constants.ORG_STATUS_INACTIVE));
|
||||
|
||||
assertTrue(production.containsAll(declared),
|
||||
"Constants 中的机构状态常量取值超出生产白名单:" + declared + " ⊄ " + production);
|
||||
assertTrue(declared.containsAll(production),
|
||||
"生产白名单存在无对应常量的取值:" + production + " ⊄ " + declared
|
||||
+ " —— 新增状态必须同时补常量,否则调用方只能硬编码字符串");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("机构状态常量与 users.account_status 常量不相交(本轮缺陷的根因:跨表常量误用)")
|
||||
void orgStatusMustNotReuseAccountStatusValues() {
|
||||
Set<String> accountStatuses = new LinkedHashSet<>(List.of(
|
||||
Constants.ACCOUNT_STATUS_NORMAL,
|
||||
Constants.ACCOUNT_STATUS_REVIEWING_RISK,
|
||||
Constants.ACCOUNT_STATUS_LIMITED,
|
||||
Constants.ACCOUNT_STATUS_SUSPENDED,
|
||||
Constants.ACCOUNT_STATUS_BANNED,
|
||||
Constants.ACCOUNT_STATUS_CLOSED));
|
||||
Set<String> orgStatuses = new LinkedHashSet<>(List.of(
|
||||
Constants.ORG_STATUS_ACTIVE, Constants.ORG_STATUS_INACTIVE));
|
||||
|
||||
Set<String> overlap = new LinkedHashSet<>(orgStatuses);
|
||||
overlap.retainAll(accountStatuses);
|
||||
assertTrue(overlap.isEmpty(),
|
||||
"机构状态与账号状态取值出现交集 " + overlap
|
||||
+ " —— 一旦交集非空,「把 account_status 常量用给 organizations」这类误用"
|
||||
+ "就不再会被 CHECK 约束拦下,本守卫必须保持两者可区分");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试库复刻了 organizations.status 的 DEFAULT 'active'(保真度:不传值时的落库语义)")
|
||||
void testSchemaPreservesStatusDefault() throws IOException {
|
||||
assertEquals(parseStatusDefault(PROD_MIGRATION), parseStatusDefault(TEST_SCHEMA),
|
||||
"organizations.status 的 DEFAULT 在生产与测试库之间漂移");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("探测方式自检:虚构约束名必须解析失败(防断言静默永真)")
|
||||
void parserSelfCheckRejectsUnknownConstraint() throws IOException {
|
||||
ConstraintSpec ghost = new ConstraintSpec("chk_org_does_not_exist", "type", "ALLOWED_TYPES");
|
||||
assertThrows(AssertionError.class, () -> parseCheckValues(PROD_MIGRATION, ghost),
|
||||
"解析一个不存在的约束竟然成功 —— 说明解析逻辑恒真,本守卫全部断言都不可信");
|
||||
assertThrows(AssertionError.class, () -> parseCheckValues(TEST_SCHEMA, ghost),
|
||||
"同上(测试库侧)");
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.danyang.match.platform.organization;
|
||||
|
||||
import com.danyang.match.platform.organization.entity.Organization;
|
||||
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
|
||||
import com.danyang.match.platform.organization.service.OrganizationService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import 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.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 机构生命周期守卫(2026-09-21 新增):创建 / 停用 / 启用三个写操作的真实落库行为。
|
||||
*
|
||||
* <h3>缺陷本体</h3>
|
||||
* 三处状态分别写成 {@code Constants.ACCOUNT_STATUS_NORMAL}("normal") 与
|
||||
* {@code ACCOUNT_STATUS_SUSPENDED}("suspended"),而 {@code organizations.status} 的
|
||||
* CHECK 白名单(V001 {@code chk_org_status})只有 ('active', 'inactive') ——
|
||||
* 三个写操作在生产**全部违约**,{@code violates check constraint "chk_org_status"} → 500。
|
||||
* 二阶影响:{@code activities.organizer_org_id} NOT NULL,无机构即无法建活动。
|
||||
*
|
||||
* <h3>为什么此前全量回归是绿的</h3>
|
||||
* 三重盲区叠加:① 全仓无测试引用本类;② E2E seed 直连写库绕过 Service;
|
||||
* ③ 测试库 schema-h2.sql 当时**没有复刻这两条 CHECK**,H2 上写 'normal' 畅通无阻。
|
||||
* 本轮已将 ③ 补齐(见 schema-h2.sql),故本测试现在真的能咬住该缺陷。
|
||||
*
|
||||
* <p>本类走真实 DB(H2 + schema-h2.sql),断言的是**落库值**而非方法返回值 ——
|
||||
* 「返回值好看但库里是非法值」正是这类缺陷的形态。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class OrganizationServiceTest {
|
||||
|
||||
@Autowired private OrganizationService organizationService;
|
||||
@Autowired private OrganizationMapper organizationMapper;
|
||||
|
||||
private static Map<String, Object> params(String name, String type) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("name", name);
|
||||
params.put("type", type);
|
||||
return params;
|
||||
}
|
||||
|
||||
private Organization createAndReload(String name, String type) {
|
||||
Organization created = organizationService.createOrganization(params(name, type));
|
||||
assertNotNull(created.getId(), "机构应被分配 id(AutoFillHandler.insertFill)");
|
||||
Organization reloaded = organizationMapper.selectById(created.getId());
|
||||
assertNotNull(reloaded, "机构应真实落库");
|
||||
return reloaded;
|
||||
}
|
||||
|
||||
// ==================== 三个写操作的落库状态 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("创建机构:落库状态为 active(原写 'normal' —— 不在 chk_org_status 白名单,生产必 500)")
|
||||
void createOrganizationPersistsActiveStatus() {
|
||||
Organization reloaded = createAndReload("丹阳市妇联-" + System.nanoTime(), "women_federation");
|
||||
|
||||
assertEquals(Constants.ORG_STATUS_ACTIVE, reloaded.getStatus(),
|
||||
"机构 status 必须落在 chk_org_status 白名单 ('active','inactive') 内");
|
||||
assertTrue(OrganizationService.ALLOWED_STATUSES.contains(reloaded.getStatus()),
|
||||
"落库状态必须属于机构状态白名单 —— 出现 'normal'/'suspended' 即跨表常量误用复发");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("停用机构:落库状态为 inactive(原写 'suspended' —— 不在白名单)")
|
||||
void disableOrganizationPersistsInactiveStatus() {
|
||||
Organization org = createAndReload("待停用机构-" + System.nanoTime(), "charity_center");
|
||||
|
||||
organizationService.disableOrganization(org.getId(), "op-1");
|
||||
|
||||
Organization after = organizationMapper.selectById(org.getId());
|
||||
assertEquals(Constants.ORG_STATUS_INACTIVE, after.getStatus(),
|
||||
"停用后必须是 inactive —— 原实现的 'suspended' 会撞 CHECK 约束");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("启用机构:落库状态回到 active(原写 'normal' —— 不在白名单)")
|
||||
void enableOrganizationPersistsActiveStatus() {
|
||||
Organization org = createAndReload("待启用机构-" + System.nanoTime(), "volunteer_team");
|
||||
organizationService.disableOrganization(org.getId(), "op-1");
|
||||
|
||||
organizationService.enableOrganization(org.getId(), "op-1");
|
||||
|
||||
Organization after = organizationMapper.selectById(org.getId());
|
||||
assertEquals(Constants.ORG_STATUS_ACTIVE, after.getStatus(),
|
||||
"启用后必须回到 active —— 停用/启用往返应可闭环");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("停用→启用往返后机构仍可被读出(状态流转不破坏记录)")
|
||||
void disableThenEnableRoundTripKeepsRecordReadable() {
|
||||
Organization org = createAndReload("往返机构-" + System.nanoTime(), "trade_union");
|
||||
|
||||
organizationService.disableOrganization(org.getId(), "op-1");
|
||||
organizationService.enableOrganization(org.getId(), "op-1");
|
||||
|
||||
assertEquals(1, organizationService.listOrganizations().stream()
|
||||
.filter(o -> org.getId().equals(o.getId())).count(),
|
||||
"往返流转后机构应仍存在于列表且唯一");
|
||||
}
|
||||
|
||||
// ==================== type 白名单 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("创建机构:非法 type 前置拒绝为业务异常(原实现不校验,会撞 chk_org_type 变 500)")
|
||||
void createOrganizationRejectsIllegalType() {
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> organizationService.createOrganization(params("非法类型机构", "sponsor")),
|
||||
"非法 type 必须被前置拒绝为 400 业务错误,而不是打到 PostgreSQL 才 500");
|
||||
|
||||
assertEquals(ApiResponseCode.VALIDATION_ERROR, ex.getCode());
|
||||
assertTrue(organizationMapper.selectList(null).stream()
|
||||
.noneMatch(o -> "sponsor".equals(o.getType())),
|
||||
"被拒的机构不得落库");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("创建机构:白名单内每个合法 type 都能真实建库(后端白名单 ⊆ DB 约束)")
|
||||
void createOrganizationAcceptsEveryLegalType() {
|
||||
Set<String> legalTypes = new LinkedHashSet<>(OrganizationService.ALLOWED_TYPES);
|
||||
assertFalse(legalTypes.isEmpty(), "机构类型白名单不得为空");
|
||||
|
||||
for (String type : legalTypes) {
|
||||
Organization reloaded = createAndReload("合法类型-" + type + "-" + System.nanoTime(), type);
|
||||
assertEquals(type, reloaded.getType(),
|
||||
"合法 type " + type + " 应能真实落库 —— 失败说明后端白名单存在 DB 不接受的值");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("创建机构:名称为空或纯空白被拒")
|
||||
void createOrganizationRejectsBlankName() {
|
||||
assertThrows(BusinessException.class,
|
||||
() -> organizationService.createOrganization(params(null, "other")));
|
||||
assertThrows(BusinessException.class,
|
||||
() -> organizationService.createOrganization(params(" ", "other")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("更新机构:非法 type 被拒且不落库,合法 type 真实生效")
|
||||
void updateOrganizationValidatesType() {
|
||||
Organization org = createAndReload("待更新机构-" + System.nanoTime(), "other");
|
||||
|
||||
assertThrows(BusinessException.class,
|
||||
() -> organizationService.updateOrganization(org.getId(), params(null, "sponsor")));
|
||||
assertEquals("other", organizationMapper.selectById(org.getId()).getType(),
|
||||
"非法 type 被拒后原值不得被改写");
|
||||
|
||||
Map<String, Object> update = new HashMap<>();
|
||||
update.put("type", "civil_affairs_partner");
|
||||
organizationService.updateOrganization(org.getId(), update);
|
||||
assertEquals("civil_affairs_partner", organizationMapper.selectById(org.getId()).getType(),
|
||||
"合法 type 更新应真实生效");
|
||||
}
|
||||
|
||||
// ==================== 不存在的机构:语义正确的 404 而非 500 ====================
|
||||
|
||||
@Test
|
||||
@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.updateOrganization("no-such-org-id",
|
||||
params("新名称", "other")))) {
|
||||
BusinessException ex = assertThrows(BusinessException.class, call::run);
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, ex.getCode(),
|
||||
"资源不存在应为 NOT_FOUND(原实现抛裸 RuntimeException → 500 SERVER_ERROR)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,11 +392,22 @@ CREATE TABLE activities (
|
||||
-- ============================================================================
|
||||
|
||||
-- V001: organizations 组织表(activity_organizations 的外键目标,且组织名用于活动详情展示)
|
||||
-- ⚠️ 2026-09-21 补:此前本表**未复刻生产的两条 CHECK**,导致 OrganizationService
|
||||
-- 写入 users 表的 'normal' / 'suspended' 在测试期畅通无阻(H2 无约束),
|
||||
-- 上 PostgreSQL 才以 violates check constraint "chk_org_status" → 500 暴露。
|
||||
-- 与 Phase 11/13/15/18/19 属同一族「生产有约束 / 测试库没有」的漂移盲区。
|
||||
-- 取值必须与 V001 chk_org_type / chk_org_status 逐值一致,由
|
||||
-- OrganizationConstraintParityTest 长期比对(含 NOT NULL / DEFAULT 保真度)。
|
||||
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),
|
||||
id VARCHAR(36) PRIMARY KEY, name VARCHAR(200) NOT NULL, type VARCHAR(32) NOT NULL,
|
||||
contact_name VARCHAR(100), contact_phone_enc VARCHAR(500),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT chk_org_type CHECK (type IN (
|
||||
'women_federation', 'trade_union', 'charity_center', 'volunteer_team', 'civil_affairs_partner', 'other'
|
||||
)),
|
||||
CONSTRAINT chk_org_status CHECK (status IN ('active', 'inactive'))
|
||||
);
|
||||
|
||||
-- V023: activity_organizations 活动多组织关联表
|
||||
|
||||
Reference in New Issue
Block a user