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:
WorkBuddy
2026-09-21 20:34:30 +08:00
parent 60c591fc68
commit c195133680
8 changed files with 730 additions and 15 deletions
@@ -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";
@@ -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),
"同上(测试库侧)");
}
}
@@ -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)");
}
}
}
+14 -3
View File
@@ -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 活动多组织关联表