diff --git a/backend/src/main/java/com/danyang/match/platform/organization/service/OrganizationService.java b/backend/src/main/java/com/danyang/match/platform/organization/service/OrganizationService.java index ec148db..4ec6f39 100644 --- a/backend/src/main/java/com/danyang/match/platform/organization/service/OrganizationService.java +++ b/backend/src/main/java/com/danyang/match/platform/organization/service/OrganizationService.java @@ -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; /** * 机构管理服务 + * + *

2026-09-21 修复:三处写操作必然 500(High)

+ * 原实现把机构状态写成 {@code Constants.ACCOUNT_STATUS_NORMAL}("normal") / + * {@code ACCOUNT_STATUS_SUSPENDED}("suspended") —— 那是 {@code users.account_status} 的取值, + * 而 {@code organizations.status} 的 CHECK 白名单(V001 {@code chk_org_status})只有 + * ('active', 'inactive'),于是**创建 / 停用 / 启用三个写操作全部违约**, + * 生产上必然 {@code PSQLException: violates check constraint "chk_org_status"} → HTTP 500。 + * + *

二阶影响:{@code activities.organizer_org_id} 为 NOT NULL —— 无机构即无法新建活动, + * 运营端「建机构 → 建活动 → 报名 → 互选」整条链路的第一环是断的。 + * + *

为何长期未被发现:① 全仓无任何测试引用本类;② 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} 逐值对应。 + * + *

⚠️ 不得在此处单方面增删:{@code OrganizationConstraintParityTest} 会从迁移 DDL + * 解析白名单与本集合比对,任何一侧漂移立即变红(写入非法值原本要打到 PostgreSQL + * 才以 500 暴露,现在由本集合前置拦截为 400)。 + */ + public static final Set ALLOWED_TYPES = Set.of( + "women_federation", + "trade_union", + "charity_center", + "volunteer_team", + "civil_affairs_partner", + "other"); + + /** + * 机构状态白名单 —— 与生产迁移 V001 的 {@code chk_org_status} 逐值对应。 + * + *

注意与 {@code users.account_status} 的区别:后者是 + * normal / reviewing_risk / limited / suspended / banned / closed。混用即本轮修复的缺陷。 + */ + public static final Set 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 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 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(); diff --git a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java index afb07d3..a9bb2cf 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java @@ -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"; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/OrganizationConstraintParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/OrganizationConstraintParityTest.java new file mode 100644 index 0000000..4e41ff2 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/deployment/OrganizationConstraintParityTest.java @@ -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 新增,永久生效)。 + * + *

为什么需要它

+ * {@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)使其成为运营端链路级阻塞。 + * + *

它之所以能长期隐身,是三重盲区叠加:全仓无测试引用该 Service、E2E seed 直连写库绕过 Service、 + * 且**测试库 schema-h2.sql 当时没有复刻这两条 CHECK**(H2 无约束 → 非法值畅通无阻)。 + * 本轮三者一并处置,本守卫负责让第二重盲区不再复发。 + * + *

本守卫把四者变成编译后可验证的不变量

+ * 「生产迁移 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 SPECS = List.of(TYPE, STATUS); + + // ==================== DDL 解析 ==================== + + /** + * 从建表/约束脚本里解析 {@code CONSTRAINT CHECK ( IN ('a', 'b', ...))} 的取值集合。 + * + *

解析不到直接失败 —— 空集合会让下面的 assertEquals 变成假绿。 + * 约束名后加词边界断言,避免 {@code chk_org_status} 误匹配到 + * {@code chk_user_account_status} 这类「名字含子串」的其他约束。 + */ + private static Set 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 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 时落什么值」这条语义在测试期就与生产不同。 + * + *

⚠️ 必须**先切出 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( + "(? 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) value; + } + + // ==================== 断言 ==================== + + @Test + @DisplayName("测试库 schema 与生产迁移声明了相同的机构 CHECK 白名单(此前测试库两条全缺)") + void productionAndTestSchemaDeclareIdenticalOrganizationChecks() throws IOException { + for (ConstraintSpec spec : SPECS) { + Set production = parseCheckValues(PROD_MIGRATION, spec); + Set 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 production = parseCheckValues(PROD_MIGRATION, STATUS); + Set 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 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 orgStatuses = new LinkedHashSet<>(List.of( + Constants.ORG_STATUS_ACTIVE, Constants.ORG_STATUS_INACTIVE)); + + Set 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), + "同上(测试库侧)"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/organization/OrganizationServiceTest.java b/backend/src/test/java/com/danyang/match/platform/organization/OrganizationServiceTest.java new file mode 100644 index 0000000..062d927 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/organization/OrganizationServiceTest.java @@ -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 新增):创建 / 停用 / 启用三个写操作的真实落库行为。 + * + *

缺陷本体

+ * 三处状态分别写成 {@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,无机构即无法建活动。 + * + *

为什么此前全量回归是绿的

+ * 三重盲区叠加:① 全仓无测试引用本类;② E2E seed 直连写库绕过 Service; + * ③ 测试库 schema-h2.sql 当时**没有复刻这两条 CHECK**,H2 上写 'normal' 畅通无阻。 + * 本轮已将 ③ 补齐(见 schema-h2.sql),故本测试现在真的能咬住该缺陷。 + * + *

本类走真实 DB(H2 + schema-h2.sql),断言的是**落库值**而非方法返回值 —— + * 「返回值好看但库里是非法值」正是这类缺陷的形态。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class OrganizationServiceTest { + + @Autowired private OrganizationService organizationService; + @Autowired private OrganizationMapper organizationMapper; + + private static Map params(String name, String type) { + Map 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 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 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.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)"); + } + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 5c3bba8..5ee38c1 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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 活动多组织关联表 diff --git a/docs/PROGRESS-ASSESSMENT-2026-09-21.md b/docs/PROGRESS-ASSESSMENT-2026-09-21.md new file mode 100644 index 0000000..843c7cf --- /dev/null +++ b/docs/PROGRESS-ASSESSMENT-2026-09-21.md @@ -0,0 +1,206 @@ +# 项目进度评估报告 — 2026-09-21 + +> 评估方式:以 `docs/STATUS.md`(权威状态表)为索引,**逐项回溯代码、迁移、测试与 CI 配置实测复核**, +> 不采信文档中的历史「✅」声明。凡本文标注「实测」的结论均可按「核查证据」栏复现。 +> +> 评估基线:`main` @ `60c591f`(2026-09-21 11:13),工作区干净;最新 tag `v101-dod-complete-2026-09-21`。 + +--- + +## 一、总体结论 + +**代码与测试面已具备进入 UAT 的条件;阻塞点是人工确认与环境补绿,外加一处此前登记但未修复的运营端写操作缺陷。** + +| 维度 | 结论 | +|---|---| +| 业务功能 | Phase 01~22 全部 KEEP-VERIFY 复验关闭;Phase 23 批 1~3 完成 | +| 质量门禁 | 后端 741 / 0 失败 / 0 错误 / 3 跳过;UI 契约门禁 18/18;E2E 本机活栈 65 通过 | +| 数据层 | 迁移 V001~V051(51 个文件,无缺号);H2 测试库表漂移盲区已在本轮前多轮补齐 | +| 停点 | **Phase 24(UAT)启动门前** —— 按项目规则须由用户确认,不得自动进入 | +| 风险等级 | **中** —— 无架构级问题,但存在 1 项 High 未修缺陷 + 2 项环境/流程阻塞 | + +--- + +## 二、进度快照 + +### 2.1 Phase 矩阵 + +| Phase 区间 | 状态 | 证据 | +|---|---|---| +| 01~06 基线 / Auth / RBAC / Profile / 审核 / Match Engine | ✅ 关闭 | 各 Phase Completion Report | +| 07 V1.1 首页 + 找对象 | ✅ 关闭 | 2026-09-16 用户验收(基本达标);11 页 UI 验收通过 | +| 08~13A Like/Match/Block · Chat · 图片安全 · 通知 · 关系扩展 · 红娘转介 · 咨询辅导 | ✅ 关闭 | 2026-09-16/17 KEEP-VERIFY,共修复 40+ 真实缺陷 | +| 14~20 多类型活动 · 报名签到 · 互选双确认 · 举报风控 · 申诉 · 运营后台 · Analytics | ✅ 关闭 | 2026-09-17 复验,修复 60+ 缺陷(含 4 处必崩级) | +| 21 账户生命周期 | ✅ 关闭 | 2026-09-18;注销三处 TODO 桩已实现;数据保留流程 V049 落地 | +| 22 Feature Flag | ✅ 关闭 | 死配置已移除;`PUT /feature-flags` 真持久化(V048) | +| 23 可信系统测试重建 | ⚠️ **部分关闭** | 假覆盖归零、E2E 场景真实实现;**仅余 CI 上首次跑绿** | +| 23B E2E 基建 + 横向加固(HTTP 语义 / 身份收敛 / 幂等键) | ✅ 关闭 | 幂等键 30 端点、105 处真实状态码 | +| V1.0.1 配置与规则纠偏 | ✅ 关闭 | rc.21(2026-09-21);governance 六表 + 并发强校验 + canonical key | +| 24 UAT | ⏳ 待启动 | 用例 46 条(9 角色 × 7 路径)已编写;**需用户确认** | +| 25 RC 内容 / 26 生产 Smoke | ⏳ 待运营 | 真实素材未提供;13 项 smoke 待执行 | + +### 2.2 量化指标 + +| 指标 | 实测值 | 核查方式 | +|---|---|---| +| 后端测试 | 741 / 0 failures / 0 errors / 3 skipped | `docs/STATUS.md` rc.21 行(离线容器 `mvn -o test`,命令见计划附 B-9) | +| 测试类文件 | 108 个 | `backend/src/test/**/*.java` 计数 | +| UI 契约门禁 | 18/18(`UI_GUARDS_MIN` 下限守卫防删除报绿) | `ui-tests/` 18 个 `.mjs`;执行器 `scripts/run-ui-contracts.mjs` | +| E2E 用例 | 65 通过(本机真栈真库) | `tests/e2e/` 14 个测试文件 | +| 数据库迁移 | V001~V051,51 个,无缺号重复 | `backend/src/main/resources/db/migration` | +| CI job | 7 个:backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e | `.github/workflows/ci.yml` | +| 待产品决策 | 13 项 + 第三方开放遗留 7 项 | `docs/PENDING_DECISIONS.md`、`docs/governance/open-questions.md` | + +--- + +## 三、关键发现:一处 High 缺陷登记后未修复 + +### 3.1 机构创建 / 停用 / 启用三个写操作必然失败(High,**2026-09-21 已修复,待补跑回归验证**) + +> **处置更新(2026-09-21 当天)**:本项已按第五节 P0-1 执行完毕 —— +> `Constants` 新增 `ORG_STATUS_ACTIVE/INACTIVE`、三处状态值修正、`type` 白名单前置校验、 +> 测试库 `schema-h2.sql` 补两条 CHECK 及 NOT NULL/DEFAULT、 +> 新增 `OrganizationConstraintParityTest` 与 `OrganizationServiceTest`(9 用例)。 +> **但本机无 Docker / 无 JDK(`wsl.exe` 被安全策略阻止),`mvn -o test` 与负向注入未能执行**, +> 故当前状态为「**已修复待验证**」而非「已验证」。 +> 详见 `docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md` 附 B.1 的处置留痕 +> 与 `docs/STATUS.md` 版本行 rc.22。 + +该问题于 2026-09-19 已在实施计划「附 B.1」中登记并实测复现,但截至本次评估(2026-09-21)**代码未改动**。 + +**缺陷本体**(实测复核,2026-09-21): + +| 位置 | 写入值 | 生产约束 | 结果 | +|---|---|---|---| +| `OrganizationService.java:43` `createOrganization` | `ACCOUNT_STATUS_NORMAL` = `"normal"` | `V001:45 chk_org_status CHECK (status IN ('active','inactive'))` | 违约 → 500 | +| `OrganizationService.java:63` `disableOrganization` | `ACCOUNT_STATUS_SUSPENDED` = `"suspended"` | 同上 | 违约 → 500 | +| `OrganizationService.java:73` `enableOrganization` | `ACCOUNT_STATUS_NORMAL` = `"normal"` | 同上 | 违约 → 500 | + +**根因**:`organizations.status` 复用了语义上属于 `users` 表的常量 `Constants.ACCOUNT_STATUS_*`。 +`Constants` 中**不存在**机构状态常量(`ORG_STATUS_ACTIVE/INACTIVE` 均无)—— 典型的「跨表常量误用」, +与项目此前多轮修复的「实体↔迁移漂移」「CHECK 白名单未对齐」同族。 + +**二阶影响(决定优先级)**: +`activities.organizer_org_id` 为 NOT NULL → **无机构则无法创建活动**。 +即运营端「新建机构 → 新建活动 → 用户报名 → 互选」这条链路的第一环是断的。 +而 UAT 用例覆盖活动互选、报名、签到等场景,因此该缺陷**可能阻塞 UAT 执行**。 + +**为什么门禁全绿却没发现**: + +1. 108 个测试类中**没有任何一个**引用 `OrganizationService` / `createOrganization`(实测:0 命中); +2. `tests/e2e/sql/seed_e2e_activity.sql` 是**直接写库**插入机构与活动,绕过了 Service 层 + —— E2E 全绿掩盖了 API 路径的失败; +3. H2 测试库此前长期缺约束复刻,即便有测试也可能静默通过。 + +> 这正好复现项目已总结过两次的模式:**「测试环境用数据为空 / 直连绕过,掩盖代码必崩」**。 + +**附带问题(同文件,Low)**: +- `createOrganization` 与 `updateOrganization` 均**未校验 `type`** 白名单 + (`chk_org_type` 仅允许 women_federation / trade_union / charity_center / volunteer_team / + civil_affairs_partner / other); +- 私有 `saveAuditLog` 未填 `ip` / `userAgent`(Phase 17 只增强了 `AuditLogService.recordSensitiveAccess`)。 + +**建议修法**:新增 `Constants.ORG_STATUS_ACTIVE/INACTIVE`,三处改为 `active` / `inactive`; +补 `type` 白名单校验;新增 `OrganizationServiceTest`(走真实 DB,覆盖创建/停用/启用/type 非法), +另加一条**解析 V001 DDL 取最终白名单**的常量守卫(参照 `MatchmakerReferralPhase13Test` 的写法, +不要硬编码白名单副本 —— 那条教训已记录在 Phase 13 报告中)。修复后需 `git tag` 打点并回读验证。 + +### 3.2 其余已登记未处置项 + +| 项 | 级别 | 说明 | +|---|---|---| +| 用户侧取消预约不校验状态 | Medium | `ServiceAppointmentService.cancelAppointment` 只校验归属,已确认/已完成也能被本人取消;运营侧明确拒绝 `completed`。两侧口径不一致,**需产品裁定**(计划附 B.3) | +| Phase 22 Deployment/Ops 5 项未复验 | Medium | 删除镜像跳过测试的发布路径、生产关 Mock/Seed/Swagger、端口依赖检测、**备份加密 + 真实恢复演练**、Rollback 目标(`REMAINING_WORK.md` B 组) | +| 多实例下 flag 内存值不自动刷新 | Low | 已知边界,需各自重启(Phase 22 报告) | + +--- + +## 四、环境与流程风险 + +### 4.1 Git 在当前执行环境不可用(阻塞提交纪律) + +实测:`git` 报 `fatal: detected dubious ownership in repository`。 +仓库目录属主为 `S-1-5-21-881075190-…`,当前用户为 `CLIMBER/13070`,二者不匹配。 + +**影响**:`AGENTS.md` 的版本控制约定(每批独立 commit + 单段名 tag + `git bundle` 自保 + **回读验证**) +是项目防御「常驻进程删 refs 导致 HEAD 悬空」的核心手段,该链路当前**无法执行**。 +本次评估为只读目的,以 `GIT_CONFIG_COUNT/KEY/VALUE` 环境变量临时注入 `safe.directory` 绕过, +**未修改任何全局配置**。 + +**建议**:由用户决定是否执行 +`git config --global --add safe.directory 'E:/develop/公益婚恋服务平台'`。 +在修复前,任何需要提交的工作都应先确认该前提,否则改动无法被打点保护。 +(另:`feat-ui-v11` 已被 `main` **完全包含**(落后 71 个提交、领先 0),仅为冗余锚点, +可考虑清理,但 `AGENTS.md` 目前要求保留 —— 属文档与事实的轻微不一致。) + +### 4.2 CI 回归防线从未在真实 CI 上验证过 + +7 个 job 均已配置,其中 `ui-contracts`(18 门禁)与 `e2e`(Compose 起全栈 + seed + pytest)配置完整, +但 **E2E 作业从未在 GitHub Actions 上跑绿过一次**(计划 Task 2.3 Step 3)。 +本机已用同一序列模拟实测 65/65,但「本机绿」与「CI 绿」不是同一件事 +—— 此前已发生过「本机有遗留 admin 账号所以本地一直绿、CI 全新库必然 401」的先例。 + +### 4.3 文档体量与自相矛盾 + +`docs/` 91 个文件、`STATUS.md` 单文件 90KB,内含多处「历史声明块(已作废)」并列保留; +`PENDING_DECISIONS.md` 与 `docs/governance/*` 三表职责交叉并依赖「矛盾以 governance 三表为准」的约定; +`REMAINING_WORK.md` 被迫以「原编号索引」兼容历史引用。 + +这是治理的必要代价,但已产生实际成本:文档中出现过与实际严重不符的陈述 +(如 `CHANGELOG.md` 3.3MB vs 实际 4.2KB、指向从未存在的报告文件)。 +**建议**:不再新增并列的「状态类」文档;本评估报告即为一次性快照,结论应回写进 `STATUS.md` 而非另立体系。 + +--- + +## 五、下一步建议 + +### 优先级 P0 — 建议立即做(无需用户决策,纯技术清偿) + +1. **修复机构三个写操作 + 补测试与守卫**(3.1)。✅ **代码已完成(2026-09-21)**; + ⚠️ 验收项中的「全量回归 + 负向注入 + 活栈实测」因本机无 Docker/JDK **未执行**,待在有该环境处补跑。 + 原验收标准:`mvn -o test` 全绿 + 新增用例经负向注入可变红 + 活栈实测 + `POST /operator/organizations` 返回 2xx。 +2. **确认 Git 可用性**(4.1)。决定是否加 `safe.directory`,否则先不要写代码 —— 写了也打不了点。 + +### 优先级 P1 — 需用户/环境配合 + +3. **推送一次 CI,拿到 E2E 与 ui-contracts 的首次真绿**(需有 Docker Hub 出口的 runner)。 + 完成后 Phase 23 可由「部分关闭」转为关闭。 +4. **管理端桌面端目视复验(1440px)**(批 4 Task 4.1)。小程序 375px 增补页面同理。 + 这是当前唯一「代码已就绪但从未被真人看过」的验收项。 +5. **13 项产品决策 + 7 项第三方开放遗留**(批 4 Task 4.2)。 + 建议分批:先决 `OQ-MATCH-001`(推荐阈值)、`OQ-PROFILE-001`(照片上限)、 + `OQ-IDENTITY-001`(活体核验)三项 —— 它们已被配置化,决策后只需改配置值; + Banner 最大活跃数(`OQ-BANNER-001`)是真缺口,需产品定值后**需实现** active-count 校验。 + +### 优先级 P2 — UAT 启动后的并行工作 + +6. **Phase 24(UAT)启动确认** —— 按项目规则须用户明确同意,AI 不得自动进入。 + 启动前建议先完成 P0-1 与 P1-4。 +7. **Phase 22 Deployment/Ops 5 项复验**,其中「备份加密 + 真实恢复演练」是上线硬门槛。 +8. **用户侧取消预约口径统一**(3.2)—— 一次性产品裁定即可关闭。 + +### 优先级 P3 — 运营侧长周期 + +9. Phase 25 真实素材(Banner / 课堂 / 反诈 / 关于我们 / 组织信息),**不得 Seed 假数据**。 +10. Phase 26 上线前 13 项 smoke + RC 冻结(镜像 / DB 版本 / 配置清单 / Rollback 目标)。 + +--- + +## 六、本次评估的核查证据 + +| 结论 | 证据 | +|---|---| +| 代码与测试就绪 | `docs/STATUS.md` 文首「当前有效结论」+ rc.19~rc.21 版本演进行 | +| 机构缺陷未修 | `backend/src/main/java/com/danyang/match/platform/organization/service/OrganizationService.java:43,63,73`;`Constants.java:11,14`;`V001__init_base_tables.sql:45`(三处逐一读源确认) | +| 机构零测试覆盖 | `backend/src/test/**` 全量检索 `OrganizationService` / `createOrganization` → 0 命中(108 个测试类) | +| E2E 绕过 Service 层 | `tests/e2e/sql/seed_e2e_activity.sql` 直插库 | +| CI 配置完整但未验证 | `.github/workflows/ci.yml` 全文;计划 Task 2.3 Step 3 仍为未勾选项 | +| Git 不可用 | `git rev-parse HEAD` 报 `dubious ownership`;属主 SID 与当前用户不一致 | +| 迁移基线 | `db/migration` 计数 51,最新 `V051__canonical_liveness_flag_key.sql` | +| 剩余项清单 | `REMAINING_WORK.md`、`docs/PENDING_DECISIONS.md`、计划「批 4 / 批 5」 | + +--- + +*评估人:WorkBuddy · 创建:2026-09-21* +*性质:一次性快照。结论请回写 `docs/STATUS.md`,本文件不作为长期状态源。* diff --git a/docs/STATUS.md b/docs/STATUS.md index 3bc1455..5437ee9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -63,6 +63,7 @@ | 1.1.0-rc.19 | 2026-09-19 | 批 2/3 收尾完成:E2E 互选 8 + 预约 8 场景真实实现(本机活栈 **65 passed**,负向注入 2 轮)+ INV-011 显式守卫(9 用例);隐私设置接真实落库(V047)+ 5 死开关/2 死配置移除(产品裁定)+ `PUT /feature-flags` 真持久化(V048)+ 5 个无 SQL Mapper 方法补齐;**Task 3.3 四项 + 红娘服务拦截接线**(互选 `expires_at`、随访审计、随访风险检测、Onboarding 确认、注销 blocker 接线 + 竞态兜底);UI 契约门禁 **14→18**(WXML 结构 / WXSS 选择器 / api.js 行为 / miniapp 请求层,并修复小程序 P0 请求层崩溃);全量回归 **712 / 0 / 0 / 3** 全绿(B-9 命令实测) | | 1.1.0-rc.20 | 2026-09-20 | **数据保留流程落地**(产品 2026-09-19 确认:注销后数据保留不得低于 36 个月):V049 `users.closed_at` + `account.retention.purge` 权限(仅 super_admin)+ `AccountConfig.dataRetentionMonths`(36,配置化)+ `DataRetentionService`(人工审核:到期清单 + 幂等匿名化清除 + 审计;保留期内服务端拒绝)+ `DataRetentionGuardTest` 5 用例(负向注入摘掉下限判断恰 1 红);顺带清偿测试库缺 `user_photos` 表(照片软删路径 BadSqlGrammar 盲区,补表 + 移出 ParityTest 可选白名单)与 MyBatis-Plus `updateById` null 不进 SQL 陷阱(改 `LambdaUpdateWrapper.set(null)`);隐私政策 §3.3 / PIA §8 「30 天删除」修订为 36 个月;迁移基线 48→49。**全量回归 717 / 0 / 0 / 3 全绿**;**E2E 本机模拟 CI 序列 65/65 全绿**(宿主机 Python 3.11);UAT 用例 V1.1 版 46 用例(9 角色 × 7 路径)编写完成 | | 1.1.0-rc.21 | 2026-09-21 | **V1.0.1 配置与规则纠偏**(根治「技术默认值被误当产品规则 / 配置存在但未闭环 / 文档代码漂移」):① **照片上限配置化** `profile.photo.maxCount`(默认 6 未产品确认)+ `ProfilePolicy` + 服务器端并发强校验(新增 `PHOTO_LIMIT_EXCEEDED` 码,拒绝先于对象存储上传不留孤儿)+ 前端 `mergeAppConfig` 嵌套键修复(服务端值真正下发);② **活体开关闭环** `IdentityPolicy` + `GET /verification/liveness-status` 真实判定点(默认关不阻断实名主流程,开则 `enhancedVerified` 变化,消除假开关)+ 删死 seed `livenessVerification.enabled`;③ **推荐阈值纠偏**(60 为技术默认非产品确认)+ 规则版本化 **V050** `recommendation_records.threshold_snapshot` + 删死重复配置类 `MatchConfig`;④ 新增 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit / known-risks)并消解 PENDING 阈值/照片矛盾;⑤ 守卫 `PhotoLimitGuardTest` 8 + `IdentityLivenessGuardTest` 5 + `RecommendationThresholdSnapshotGuardTest` 4(均经负向注入可红);⑥ **canonical key 字面化**:`FeatureFlagConfig.KEY_ALIASES`(`identity.liveness.enabled` / 遗留 `livenessVerification.enabled` → 实现字段 `livenessEnabled` 读兼容)+ Controller PUT/DELETE 落库归一 canonical 键 + **V051** 清理遗留键并写入 canonical 行;⑦ **PHOTO-004 真实并发突破实测** `PhotoLimitConcurrencyGuardTest`(3 线程 H2 并发冲顶)——实测咬住「锁照片行+读时计数」在 H2 MVCC 下快照漏读、3/3 全成功突破上限的缺陷,并发控制改为**锁用户行(`UserMapper.lockUserRow` FOR UPDATE)+ 锁后新语句计数**(0 张照片亦有锁可加,补齐「首张并发」缺口),改后恰 1 成功;⑧ `AGENTS.md` 规则 27(配置与规则治理纪律:技术默认值≠产品确认规则 + canonical key 唯一 + 并发上限必须服务器强校验+并发突破测试)。**全量回归 741 / 0 / 0 / 3 全绿(基线 717 + 25 新增 − 1 删死测试);UI 契约门禁 18/18** | +| 1.1.0-rc.22 | 2026-09-21 | **运营端机构管理三处写操作 500 修复**(清偿计划附 B.1 遗留,2026-09-19 登记、本轮处置):`OrganizationService` 创建/停用/启用分别写入 `ACCOUNT_STATUS_NORMAL`("normal") 与 `ACCOUNT_STATUS_SUSPENDED`("suspended") —— 那是 `users.account_status` 的取值,而 `organizations.status` 的 CHECK 白名单(V001 `chk_org_status`)只有 `('active','inactive')` → **三个写操作全部违约必崩**;二阶影响为 `activities.organizer_org_id` NOT NULL ⇒ 无机构即无法建活动(运营端链路级阻塞)。修复:`Constants` 新增 `ORG_STATUS_ACTIVE/INACTIVE`(独立于账号状态);三处改用 `active`/`inactive`;`OrganizationService.ALLOWED_TYPES` 补 `type` 白名单前置校验(非法值 400 取代撞 `chk_org_type` 的 500);「机构不存在」由裸 `RuntimeException`(→500)改为 `NOT_FOUND`(404);异常统一为实际被 `@ExceptionHandler` 捕获的 `GlobalExceptionHandler.BusinessException`(独立类 `shared.exception.BusinessException` **无 handler**,抛它仍会 500 —— 全仓仅 `AnalyticsService` 一处既存误用)。**根因第三重盲区同步补齐**:`schema-h2.sql` 的 `organizations` 此前**未复刻**这两条 CHECK,故 H2 上写 `'normal'` 畅通无阻 —— 已补 CHECK ×2 + NOT NULL + DEFAULT。新增守卫 `OrganizationConstraintParityTest`(生产 DDL ↔ 测试库 ↔ 后端白名单 ↔ Constants 四方比对 + 解析器自检)与 `OrganizationServiceTest`(真实 DB,9 用例)。**⚠️ 未在本机运行测试**:本轮探测确认本机**无 Docker、无 JDK**,`wsl.exe` 被安全策略阻止(同 `known-risks.md` 的 WSL/Docker 故障),`mvn -o test` 与负向注入均无法执行;已用等价正则的独立脚本验证守卫判别力与两处 DDL 一致性(PASS)。**补跑全量回归 + 负向注入前,本项记为「待验证」,不计入已验收。** | --- diff --git a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md index 4614041..78363bc 100644 --- a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md +++ b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md @@ -506,6 +506,28 @@ (`women_federation / trade_union / charity_center / volunteer_team / civil_affairs_partner / other`)。 影响:运营端无法新建机构 → 无机构则**无法新建活动**(`activities.organizer_org_id` NOT NULL)。 + + > **✅ 2026-09-21 已修复(处置留痕)** + > + > - **状态取值**:`Constants` 新增 `ORG_STATUS_ACTIVE` / `ORG_STATUS_INACTIVE` + > (独立于 `users.account_status` 的 `ACCOUNT_STATUS_*`);`createOrganization` / + > `disableOrganization` / `enableOrganization` 三处改为 `active` / `inactive`。 + > - **type 校验**:新增 `OrganizationService.ALLOWED_TYPES` 前置校验,非法值返回 + > `VALIDATION_ERROR`(400),不再撞 `chk_org_type` 变 500。 + > - **附带修正**:「机构不存在」由裸 `RuntimeException`(→500 SERVER_ERROR)改为 + > `NOT_FOUND`(404);异常类型统一为项目实际处理的 `GlobalExceptionHandler.BusinessException` + > (独立类 `shared.exception.BusinessException` 无 handler,抛它仍会 500)。 + > - **测试库盲区已补(缺陷长期隐身的第三重原因)**:`schema-h2.sql` 的 `organizations` + > 此前**没有复刻** `chk_org_type` / `chk_org_status` —— 已补齐两条 CHECK 及 + > NOT NULL / DEFAULT,H2 上写 `'normal'` 现在会真的失败。 + > - **新增守卫**:`OrganizationConstraintParityTest`(生产 DDL ↔ 测试库 ↔ 后端白名单 ↔ + > Constants 四方比对 + 解析器自检)、`OrganizationServiceTest`(走真实 DB, + > 9 个用例覆盖创建/停用/启用/往返/type 合法与非法/名称空白/404 语义)。 + > - ⚠️ **未在本机运行测试**:2026-09-21 探测确认本机**无 Docker、无 JDK**, + > `wsl.exe` 被安全策略阻止(与 `known-risks.md` 的 WSL/Docker 故障一致), + > 故 `mvn -o test` 与负向注入**均未能执行**。已用等价正则的独立脚本验证 + > 守卫机制判别力与两处 DDL 一致性(PASS,脚本留痕于 `.workbuddy/_verify_org_guard.py`)。 + > **补跑全量回归 + 负向注入前,本项不得计入「已验证」。** 2. **互选记录永不过期**(Medium,即批 3 Task 3.3 的 P16-T13) `createMutualConsent` 从不写 `expires_at`(列无默认值),而 `confirmSideIfPending` 的过期闸门 `expires_at IS NULL OR expires_at > NOW()`