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";
|
||||
|
||||
Reference in New Issue
Block a user