fix(organization): 修复创建机构审计缺操作人;补全机构修复的验证并部署 Docker 测试环境

一、机构审计归属缺陷(本轮活栈验收中新发现)
- 现象:audit_logs 中 CREATE_ORGANIZATION 的 operator_id 7/7 为 NULL,
  而 DISABLE/ENABLE_ORGANIZATION 均正确记录操作人。审计不可归属 = 无效审计。
- 根因:OrganizationController.create 持有 @AuthenticationPrincipal OperatorPrincipal,
  却只把 params 传给 Service,未透传 principal.id() → saveAuditLog(null, ...)。
- 修复:createOrganization(params, operatorId) 签名透传;新增测试断言
  createOrganizationRecordsOperatorInAuditLog 长期守卫。
- 复验:新写入的 7 条 CREATE 审计全部带 operator_id,旧 14 条仍为 NULL(前后对照清晰)。

二、补全 rc.22 的验证缺口(此前记为「已修复待验证」)
- 全量回归 757 / 0 / 0 / 3,BUILD SUCCESS,108 个测试类,6:44(基线 741 + 新增 16)。
- 负向注入:ORG_STATUS_ACTIVE 改回 "normal" → 16 例变 3 失败 + 7 错误
  (守卫三条断言各自命中 + 服务侧 7 例被 H2 CHK_ORG_STATUS 拦下)。
  注入采用「仓库只读挂载 + 容器内副本改写」,工作区哈希前后一致,源码零污染。
- 活栈实测(Docker 全栈):创建/停用/启用均 2xx、非法输入 400、不存在 404、全程零 5xx(15/15)。
- 对已部署栈 pytest tests/e2e → 65 passed。

三、Docker 测试环境部署与工具链固化
- 测试镜像 danyang-match-backend-test 与命名卷 danyang-m2-cache 因 Docker Desktop 重装丢失,
  构建定义此前是临时产物 → 落盘为仓库资产 infrastructure/test-toolchain/Dockerfile。
- 本地栈统一使用 docker compose -p danyang(原项目名 infra 与另一项目撞名,
  其 --remove-orphans 会误删本项目容器;已确认邻项目数据无丢失,见 known-risks KR-09)。

四、UI 门禁脆弱性修复
- ui-document-baseline-contracts.mjs 解析 docker-compose.staging.yml 时报假红
  (「守卫自身失效」);真因是该文件为 CRLF 而守卫正则要求 \n 紧随 container_name 之后
  (prod.yml 是 LF 所以此前能过)。解析改为行尾无关,真实断言未削弱。门禁 18/18。

五、文档
- STATUS.md 新增 rc.23 并将 rc.22 由「待验证」订正为「已验证关闭」。
- 实施计划附 B.1、PROGRESS-ASSESSMENT-2026-09-21.md 同步订正。
- known-risks.md:KR-07 解除(Docker 已恢复)、KR-01/KR-06 按复测订正,
  新增 KR-08(Docker Hub 域名 DNS 被投毒,改用国内镜像源绕行)与 KR-09(compose 项目名撞名)。

验证状态:全量回归 757/0/0/3 全绿;UI 契约门禁 18/18;对已部署栈 E2E 65/65;
负向注入可变红;工作区哈希未被注入过程污染。
未验证:GitHub Actions 首次真绿(仍未推送,见 KR-06)。
This commit is contained in:
WorkBuddy
2026-09-22 00:46:10 +08:00
parent c195133680
commit 8bd1ab804a
9 changed files with 111 additions and 31 deletions
@@ -35,7 +35,7 @@ public class OrganizationController {
@PostMapping
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, Object> params) {
return ApiResponse.ok(organizationService.createOrganization(params));
return ApiResponse.ok(organizationService.createOrganization(params, principal.id()));
}
@PutMapping("/{id}")
@@ -78,8 +78,16 @@ public class OrganizationService {
return organizationMapper.selectById(id);
}
/**
* 创建机构。
*
* <p>⚠️ {@code operatorId} 必须由 Controller 从认证主体透传:本方法会写
* {@code CREATE_ORGANIZATION} 审计行,缺失操作人即审计不可归属。
* 2026-09-21 活栈验收实测发现该参数原先未透传 —— 7 条 CREATE 审计的
* {@code operator_id} 全为 NULL,而停用 / 启用却正确记录,属「同一族缺陷的创建分支」。
*/
@Transactional
public Organization createOrganization(Map<String, Object> params) {
public Organization createOrganization(Map<String, Object> params, String operatorId) {
String name = asNonBlankString(params.get("name"));
if (name == null) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "机构名称不能为空");
@@ -93,7 +101,7 @@ public class OrganizationService {
org.setContactName(asNonBlankString(params.get("contactName")));
org.setStatus(Constants.ORG_STATUS_ACTIVE);
organizationMapper.insert(org);
saveAuditLog(null, "CREATE_ORGANIZATION", "organization", org.getId(), null, null);
saveAuditLog(operatorId, "CREATE_ORGANIZATION", "organization", org.getId(), null, null);
return org;
}
@@ -1,8 +1,11 @@
package com.danyang.match.platform.organization;
import com.danyang.match.platform.organization.entity.Organization;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
import com.danyang.match.platform.organization.service.OrganizationService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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;
@@ -14,6 +17,7 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
@@ -45,6 +49,10 @@ class OrganizationServiceTest {
@Autowired private OrganizationService organizationService;
@Autowired private OrganizationMapper organizationMapper;
@Autowired private AuditLogMapper auditLogMapper;
/** 审计归属断言用的操作人 id(模拟已认证的运营主体) */
private static final String OPERATOR_ID = "op-audit-e2e";
private static Map<String, Object> params(String name, String type) {
Map<String, Object> params = new HashMap<>();
@@ -54,13 +62,29 @@ class OrganizationServiceTest {
}
private Organization createAndReload(String name, String type) {
Organization created = organizationService.createOrganization(params(name, type));
Organization created = organizationService.createOrganization(params(name, type), OPERATOR_ID);
assertNotNull(created.getId(), "机构应被分配 id(AutoFillHandler.insertFill)");
Organization reloaded = organizationMapper.selectById(created.getId());
assertNotNull(reloaded, "机构应真实落库");
return reloaded;
}
// ==================== 审计归属 ====================
@Test
@DisplayName("创建机构:审计日志必须记录操作人(2026-09-21 活栈实测 7/7 条 CREATE 审计 operator_id 为 NULL)")
void createOrganizationRecordsOperatorInAuditLog() {
Organization org = createAndReload("审计归属机构-" + System.nanoTime(), "other");
List<AuditLog> logs = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getResourceId, org.getId())
.eq(AuditLog::getAction, "CREATE_ORGANIZATION"));
assertEquals(1, logs.size(), "创建机构应恰好留下 1 条 CREATE_ORGANIZATION 审计");
assertEquals(OPERATOR_ID, logs.get(0).getOperatorId(),
"审计行必须记录操作人 —— operator_id 为 NULL 的审计不可归属,等于没有审计");
}
// ==================== 三个写操作的落库状态 ====================
@Test
@@ -118,7 +142,7 @@ class OrganizationServiceTest {
@DisplayName("创建机构:非法 type 前置拒绝为业务异常(原实现不校验,会撞 chk_org_type 变 500)")
void createOrganizationRejectsIllegalType() {
BusinessException ex = assertThrows(BusinessException.class,
() -> organizationService.createOrganization(params("非法类型机构", "sponsor")),
() -> organizationService.createOrganization(params("非法类型机构", "sponsor"), OPERATOR_ID),
"非法 type 必须被前置拒绝为 400 业务错误,而不是打到 PostgreSQL 才 500");
assertEquals(ApiResponseCode.VALIDATION_ERROR, ex.getCode());
@@ -144,9 +168,9 @@ class OrganizationServiceTest {
@DisplayName("创建机构:名称为空或纯空白被拒")
void createOrganizationRejectsBlankName() {
assertThrows(BusinessException.class,
() -> organizationService.createOrganization(params(null, "other")));
() -> organizationService.createOrganization(params(null, "other"), OPERATOR_ID));
assertThrows(BusinessException.class,
() -> organizationService.createOrganization(params(" ", "other")));
() -> organizationService.createOrganization(params(" ", "other"), OPERATOR_ID));
}
@Test