From 201181ca271bcb0088a440488a78d5c4b276cf91 Mon Sep 17 00:00:00 2001 From: ether Date: Sun, 5 Jul 2026 22:30:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20ce-debug=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20P0=20#1=20+=20P1=20#4/#5=20+=20P2=20#9/#10?= =?UTF-8?q?/#12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ce-debug skill 系统化修复 ce-code-review 报告中全部 actionable findings: 1. P0 #1 PreAuthorizeCoverageTest SERVICES 遗漏 + 4 服务 @PreAuthorize 对齐 - 4 个 ServiceImpl 共 12 个 @Transactional 写方法加 @PreAuthorize: * AbilityPackageVersionServiceImpl (5 方法, base:project:manage) * ApprovalFlowRuntimeServiceImpl (4 方法, base:lifecycle:manage) * ContractRoomServiceImpl (1 方法, base:contract:manage) * LookupServiceImpl (2 方法, base:lookup:manage — admin-only 语义) - PreAuthorizeCoverageTest SERVICES 列表补齐 34→38 - 修复 system-user admin 旁路失效风险(@PreAuthorize 缺失 → admin 旁路不触发) 2. P1 #4 AuditConsumerTest 缺失 — 新建 6 个测试覆盖 R4 闭环 - Happy path: 新事件 + save + ACK - 幂等命中: Redis SETNX false / DB count>0 → ACK 不处理 - 异常路径: JSON 解析失败 → NACK / save() 异常 → NACK + SecurityContext 清理 - 关键验证: try-with-resources 异常路径 SecurityContext 已清理(防 RabbitMQ 线程池权限泄漏) 3. P1 #5 V13 migration test 缺失 — 新建 V13SystemUserMigrationTest 5 个测试 - 静态校验 V13__add_system_user.sql INSERT 关键列值: id=0L (= SystemUserSecurityContext.SYSTEM_USER_ID) username='system' (= SystemUserSecurityContext.SYSTEM_USERNAME) status=1 / account_type=1 / deleted=0 - 验证 BCrypt password 格式 + ON DUPLICATE KEY UPDATE 幂等 + R4/OQ5 决策注释可追溯 - ponytail: pms-auth 无 testcontainers 基础设施,静态 SQL 校验捕获编辑回归 4. P2 #9 VALID_FUNC_CODES 三模块重复无 V12 交叉检查 - pms-auth PreAuthorizeCoverageTest 新增 V12 cross-check 测试 - 解析 V12 SQL 提取 function_code(UPDATE + INSERT 模式),验证 VALID_FUNC_CODES 包含全部 - ponytail: 不抽共享 fixture(需 Gradle testFixtures 插件配置),保留 3 模块列表重复, pms-auth 单点 V12 cross-check 捕获漂移 5. P2 #10 GlobalExceptionHandler 缺 AuthenticationException handler - 新增 AuthenticationException → 401 handler(防匿名访问 @PreAuthorize 返回 500) - 2 个测试验证 AuthenticationCredentialsNotFoundException → ResultCode.UNAUTHORIZED 6. P2 #12 AuditConsumer 信任 MQ payload 文档化 - AuditConsumer.onAuditOperation 新增 Trust Boundary Javadoc - 文档化 MQ ingress 信任边界、信任假设(operatorId/module/operationTime 来源)、 不校验原因(MQ 内网隔离 + AuditLogAspect 唯一生产者)、升级路径(MQ 跨集群联邦时需校验) 测试验证: - pms-auth: V13SystemUserMigrationTest 5/5 + PreAuthorizeCoverageTest (含 V12 cross-check) 全部通过 - pms-common: GlobalExceptionHandlerTest (含 AuthenticationException) 全部通过 - pms-base: PreAuthorizeCoverageTest (SERVICES 38 项) 全部通过 - pms-audit: AuditConsumerTest 6/6 + PreAuthorizeCoverageTest 全部通过 - ./gradlew :pms-auth:test :pms-common:test :pms-base:test :pms-audit:test BUILD SUCCESSFUL --- .../com/pms/audit/consumer/AuditConsumer.java | 28 ++- .../pms/audit/consumer/AuditConsumerTest.java | 207 ++++++++++++++++++ .../controller/PreAuthorizeCoverageTest.java | 77 +++++++ .../migration/V13SystemUserMigrationTest.java | 158 +++++++++++++ .../AbilityPackageVersionServiceImpl.java | 6 + .../impl/ApprovalFlowRuntimeServiceImpl.java | 5 + .../service/impl/ContractRoomServiceImpl.java | 2 + .../base/service/impl/LookupServiceImpl.java | 3 + .../controller/PreAuthorizeCoverageTest.java | 19 +- .../exception/GlobalExceptionHandler.java | 22 ++ .../exception/GlobalExceptionHandlerTest.java | 30 +++ 11 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 backend/pms-audit/src/test/java/com/pms/audit/consumer/AuditConsumerTest.java create mode 100644 backend/pms-auth/src/test/java/com/pms/auth/migration/V13SystemUserMigrationTest.java diff --git a/backend/pms-audit/src/main/java/com/pms/audit/consumer/AuditConsumer.java b/backend/pms-audit/src/main/java/com/pms/audit/consumer/AuditConsumer.java index c851b33..ca7c89c 100644 --- a/backend/pms-audit/src/main/java/com/pms/audit/consumer/AuditConsumer.java +++ b/backend/pms-audit/src/main/java/com/pms/audit/consumer/AuditConsumer.java @@ -35,7 +35,33 @@ public class AuditConsumer { private final MqConsumeLogMapper mqConsumeLogMapper; /** - * 消费操作审计日志 + * 消费操作审计日志。 + *

+ * Trust Boundary(P2 #12 文档化):本方法是 MQ ingress 信任边界。 + * 消息来源为各服务 AuditLogAspect AOP 切面({@code com.pms.common.aspect.AuditLogAspect}), + * 通过 RabbitMQ 内部集群({@code spring.rabbitmq.host},不对外暴露)发送。 + *

+ * 信任假设(不在此处校验,由 AOP 切面 + MQ 网络隔离保证): + *

+ *

+ * 不在此处做 payload 校验的原因: + *

    + *
  1. MQ 集群不对外暴露,外部无法注入恶意 payload
  2. + *
  3. AuditLogAspect 是唯一生产者,payload 结构由代码保证
  4. + *
  5. 校验逻辑会与 AuditLogAspect 重复,违反 DRY
  6. + *
+ *

+ * 升级路径:若未来 MQ 集群对外暴露(如跨集群联邦),需在此处增加 payload 校验。 + * 当前依赖 {@code spring.rabbitmq.host} 配置为内网地址 + 防火墙规则隔离。 + *

+ * 异常路径:JSON 解析失败 → ACK 丢弃(消息格式错误,重试无意义); + * 业务异常 → NACK 不重试(避免毒消息循环)。 */ @RabbitListener(queues = AuditConstants.QUEUE_AUDIT_OPERATION) public void onAuditOperation(String message, Channel channel, diff --git a/backend/pms-audit/src/test/java/com/pms/audit/consumer/AuditConsumerTest.java b/backend/pms-audit/src/test/java/com/pms/audit/consumer/AuditConsumerTest.java new file mode 100644 index 0000000..8be87b4 --- /dev/null +++ b/backend/pms-audit/src/test/java/com/pms/audit/consumer/AuditConsumerTest.java @@ -0,0 +1,207 @@ +package com.pms.audit.consumer; + +import com.pms.audit.dto.AuditEventMessage; +import com.pms.audit.service.AuditLogService; +import com.pms.common.entity.MqConsumeLog; +import com.pms.common.mapper.MqConsumeLogMapper; +import com.pms.common.util.JsonUtils; +import com.rabbitmq.client.Channel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * AuditConsumer 单元测试(ce-debug P1 #4) + *

+ * 覆盖场景: + *

+ *

+ * 设计依据:ce-code-review P1 #4 — AuditConsumer 为 R4 system-user admin 旁路核心入口, + * 0 测试覆盖。此测试为 ponytail runnable check,验证 MQ 消费 → SystemUserSecurityContext → + * save() → 幂等/异常路径完整闭环。 + */ +@DisplayName("AuditConsumer 单元测试(R4 system-user admin 旁路入口)") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AuditConsumerTest { + + @Mock + private AuditLogService auditLogService; + @Mock + private StringRedisTemplate redisTemplate; + @Mock + private ValueOperations valueOperations; + @Mock + private MqConsumeLogMapper mqConsumeLogMapper; + @Mock + private Channel channel; + @Mock + private Message amqpMessage; + + @InjectMocks + private AuditConsumer consumer; + + @BeforeEach + void setUp() { + MessageProperties props = new MessageProperties(); + props.setDeliveryTag(1L); + when(amqpMessage.getMessageProperties()).thenReturn(props); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("Happy path: 新事件 → save() 调用 + consumeLog 插入 + ACK") + void shouldSaveAndAckWhenNewEvent() throws Exception { + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(0); + + AuditEventMessage event = buildEvent("evt-001", 1001L, 1001L); + String message = JsonUtils.toJson(event); + + consumer.onAuditOperation(message, channel, amqpMessage); + + verify(auditLogService).save(any(AuditEventMessage.class)); + verify(mqConsumeLogMapper).insert(any(MqConsumeLog.class)); + verify(channel).basicAck(1L, false); + verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean()); + } + + @Test + @DisplayName("幂等命中(Redis): SETNX false → ACK 不处理") + void shouldAckWhenRedisIdempotentHit() throws Exception { + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(false); + + AuditEventMessage event = buildEvent("evt-002", 1002L, 1002L); + String message = JsonUtils.toJson(event); + + consumer.onAuditOperation(message, channel, amqpMessage); + + verify(auditLogService, never()).save(any()); + verify(mqConsumeLogMapper, never()).insert(any(MqConsumeLog.class)); + verify(channel).basicAck(1L, false); + } + + @Test + @DisplayName("幂等命中(DB): count > 0 → ACK 不处理") + void shouldAckWhenDbIdempotentHit() throws Exception { + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(1); + + AuditEventMessage event = buildEvent("evt-003", 1003L, 1003L); + String message = JsonUtils.toJson(event); + + consumer.onAuditOperation(message, channel, amqpMessage); + + verify(auditLogService, never()).save(any()); + verify(mqConsumeLogMapper, never()).insert(any(MqConsumeLog.class)); + verify(channel).basicAck(1L, false); + } + + @Test + @DisplayName("异常: JSON 解析抛异常 → NACK(JsonUtils.fromJson 对非法 JSON 抛异常,进入 catch 块)") + void shouldNackWhenJsonParseThrows() throws Exception { + // 传入非 JSON 字符串,JsonUtils.fromJson 抛异常 → 进入 catch → basicNack + String message = "not-a-valid-json"; + + consumer.onAuditOperation(message, channel, amqpMessage); + + verify(auditLogService, never()).save(any()); + verify(channel).basicNack(1L, false, false); + } + + @Test + @DisplayName("异常: save() 抛异常 → NACK + SecurityContext 清理(try-with-resources 验证)") + void shouldNackAndClearSecurityContextWhenSaveThrows() throws Exception { + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(0); + doThrow(new RuntimeException("DB connection lost")) + .when(auditLogService).save(any(AuditEventMessage.class)); + + AuditEventMessage event = buildEvent("evt-004", 1004L, 1004L); + String message = JsonUtils.toJson(event); + + consumer.onAuditOperation(message, channel, amqpMessage); + + // 验证 NACK + verify(channel).basicNack(1L, false, false); + verify(mqConsumeLogMapper, never()).insert(any(MqConsumeLog.class)); + + // 关键验证:try-with-resources 异常路径 SecurityContext 已清理 + // 防止 RabbitMQ 线程池复用继承 admin 权限(R4 缓解策略核心安全约束) + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .as("save() 异常后 SecurityContext 必须为空,防 RabbitMQ 线程池权限泄漏") + .isNull(); + } + + @Test + @DisplayName("eventId 缺失:fallback 到 operatorId:operationTime 拼装 → save() 调用") + void shouldFallbackEventIdWhenMissing() throws Exception { + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(true); + when(mqConsumeLogMapper.countByEventIdAndGroup(anyString(), anyString())).thenReturn(0); + + // eventId=null,operatorId=2001, operationTime=1700000000000L + AuditEventMessage event = buildEvent(null, 2001L, 1700000000000L); + String message = JsonUtils.toJson(event); + + consumer.onAuditOperation(message, channel, amqpMessage); + + verify(auditLogService).save(any(AuditEventMessage.class)); + verify(channel).basicAck(1L, false); + // 验证 fallback eventId 格式 "audit:operatorId:operationTime" + verify(mqConsumeLogMapper).countByEventIdAndGroup(eq("audit:2001:1700000000000"), eq("audit-consumer")); + } + + private AuditEventMessage buildEvent(String eventId, Long operatorId, Long operationTime) { + AuditEventMessage event = new AuditEventMessage(); + event.setEventId(eventId); + event.setOperatorId(operatorId); + event.setOperatorName("test-user"); + event.setOperatorAccount("test"); + event.setOperatorType(1); + event.setOperationType("CREATE"); + event.setModule("USER"); + event.setOperationDesc("测试操作"); + event.setMethod("UserController.create"); + event.setRequestUrl("/api/v1/users"); + event.setRequestMethod("POST"); + event.setOperationResult("SUCCESS"); + event.setOperationTime(operationTime); + return event; + } +} diff --git a/backend/pms-auth/src/test/java/com/pms/auth/controller/PreAuthorizeCoverageTest.java b/backend/pms-auth/src/test/java/com/pms/auth/controller/PreAuthorizeCoverageTest.java index ad36458..82e437c 100644 --- a/backend/pms-auth/src/test/java/com/pms/auth/controller/PreAuthorizeCoverageTest.java +++ b/backend/pms-auth/src/test/java/com/pms/auth/controller/PreAuthorizeCoverageTest.java @@ -15,8 +15,11 @@ import org.junit.jupiter.params.provider.MethodSource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Set; @@ -172,4 +175,78 @@ class PreAuthorizeCoverageTest { assertThat(VALID_FUNC_CODES).contains( "user_management", "org_management", "role_management", "system_management"); } + + /** + * V12 cross-check(P2 #9):解析 V12 migration SQL 提取实际定义的 function_code, + * 验证 VALID_FUNC_CODES 列表与 V12 一致(避免 V12 加新 func_code 后测试列表漂移)。 + *

+ * V12 定义 8 个 func_code(admin/base/charge/operation/user_management/ + * role_management/org_management/system_management);'viewer' 是 V1 遗留,V12 未 UPDATE + * 但仍在 t_role 中(Q10 决策保留),VALID_FUNC_CODES 单独覆盖。 + *

+ * ponytail: 不抽共享 fixture(需 Gradle testFixtures 插件配置),保留 3 模块列表重复, + * 但 pms-auth 在此提供 V12 单点交叉检查 — V12 加新 func_code 时本测试强制失败, + * 触发开发者同步更新 3 模块的 VALID_FUNC_CODES(pms-base/pms-audit 各自的 selfCheck 也会校验自身使用的 func_code)。 + */ + @Test + @DisplayName("V12 cross-check: VALID_FUNC_CODES 包含 V12 migration 实际定义的全部 function_code") + void validFuncCodes_matchesV12Migration() throws IOException { + String v12Sql = loadV12MigrationSql(); + Set v12DefinedFuncCodes = extractFuncCodesFromV12(v12Sql); + + // V12 必须至少定义 8 个 func_code(4 UPDATE + 4 INSERT) + assertThat(v12DefinedFuncCodes) + .as("V12 应至少定义 8 个 func_code(4 UPDATE + 4 INSERT)") + .hasSizeGreaterThanOrEqualTo(8); + + // VALID_FUNC_CODES 必须包含 V12 实际定义的全部 func_code + assertThat(VALID_FUNC_CODES) + .as("VALID_FUNC_CODES 必须包含 V12 migration 实际定义的全部 function_code(V12 加新 code 时本断言失败,触发同步更新)") + .containsAll(v12DefinedFuncCodes); + + // VALID_FUNC_CODES 还必须包含 'viewer'(V1 遗留,V12 未 UPDATE 但仍合法) + assertThat(VALID_FUNC_CODES) + .as("VALID_FUNC_CODES 必须包含 'viewer'(V1 遗留,V12 未 UPDATE 但 Q10 决策保留)") + .contains("viewer"); + } + + /** 加载 V12 migration SQL 文件(pms-auth 模块 classpath 资源) */ + private String loadV12MigrationSql() throws IOException { + try (InputStream is = getClass().getResourceAsStream( + "/db/migration/V12__realign_function_code_and_add_management_roles.sql")) { + assertThat(is) + .as("V12 migration 文件必须在 classpath: /db/migration/V12__realign_function_code_and_add_management_roles.sql") + .isNotNull(); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 从 V12 SQL 提取实际定义的 function_code 集合: + *

+ * V12 的 INSERT 是单条多 VALUES 元组,第 2 个正则用全局 find() 捕获全部 4 个元组。 + */ + private Set extractFuncCodesFromV12(String sql) { + // UPDATE 模式:`function_code` = 'xxx'(反引号可选,兼容注释中无反引号的提及) + Pattern updatePattern = Pattern.compile( + "`?function_code`?\\s*=\\s*'([^']+)'", Pattern.CASE_INSENSITIVE); + // INSERT 模式:'func_code', 1, UNIX_TIMESTAMP — 匹配每个 VALUES 元组中的 function_code + Pattern insertTuplePattern = Pattern.compile( + "'([^']+)'\\s*,\\s*1\\s*,\\s*UNIX_TIMESTAMP", Pattern.CASE_INSENSITIVE); + + Set codes = new java.util.HashSet<>(); + Matcher updateMatcher = updatePattern.matcher(sql); + while (updateMatcher.find()) { + codes.add(updateMatcher.group(1)); + } + Matcher insertMatcher = insertTuplePattern.matcher(sql); + while (insertMatcher.find()) { + codes.add(insertMatcher.group(1)); + } + return codes; + } } diff --git a/backend/pms-auth/src/test/java/com/pms/auth/migration/V13SystemUserMigrationTest.java b/backend/pms-auth/src/test/java/com/pms/auth/migration/V13SystemUserMigrationTest.java new file mode 100644 index 0000000..e82545a --- /dev/null +++ b/backend/pms-auth/src/test/java/com/pms/auth/migration/V13SystemUserMigrationTest.java @@ -0,0 +1,158 @@ +package com.pms.auth.migration; + +import com.pms.common.security.SystemUserSecurityContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * V13 system-user migration 静态守卫测试(P1 #5 ponytail runnable check) + *

+ * 设计依据:ce-code-review P1 #5 — V13 插入 system 占位用户行(id=0L, username='system'), + * 此行存在性是 R4 system-user admin 旁路方案的根依赖({@link SystemUserSecurityContext#SYSTEM_USER_ID} + * 必须在 t_user 表中存在,否则 setCreatedBy(null) 破坏 NOT NULL 约束)。 + *

+ * 测试策略:pms-auth 模块无 testcontainers/DB 集成测试基础设施,遵循 Mockito-only 单元测试约定。 + * 本测试以静态 SQL 资源校验方式运行:加载 V13__add_system_user.sql 作为 classpath 资源, + * 解析 INSERT 语句验证关键列值与 SystemUserSecurityContext 常量一致。 + *

+ * 守卫范围: + *

    + *
  • id = 0(= {@link SystemUserSecurityContext#SYSTEM_USER_ID})
  • + *
  • username = 'system'(= {@link SystemUserSecurityContext#SYSTEM_USERNAME})
  • + *
  • status = 1(启用,避免 AuthServiceImpl.login 走"账号禁用"分支混淆)
  • + *
  • account_type = 1(普通账号类型)
  • + *
  • deleted = 0(未删除)
  • + *
  • real_name 含"系统占位用户"(标识用途)
  • + *
  • password 为 BCrypt 哈希(非明文,非空)
  • + *
+ *

+ * ponytail: 不引入 H2/testcontainers — 仅校验 SQL 文本静态正确性,DB 实际执行行为由 Flyway 启动时 + * 与 post-deploy 验证 SQL 覆盖。本测试捕获"SQL 编辑回归"风险(如误删 INSERT、改 id/username)。 + */ +@DisplayName("V13 system-user migration 静态守卫测试") +class V13SystemUserMigrationTest { + + private static final String MIGRATION_PATH = "/db/migration/V13__add_system_user.sql"; + + /** + * INSERT INTO `t_user` (...) VALUES (0, NULL, 'system', '$2a$...', '系统占位用户', 1, 1, ...) — + * 提取 VALUES 子句前 6 个值(id, project_id, username, password, real_name, status)。 + * 注释剥离后匹配,允许 SQL 文本跨多行书写。 + */ + private static final Pattern VALUES_PATTERN = Pattern.compile( + "VALUES\\s*\\(\\s*(\\d+)\\s*,\\s*NULL\\s*,\\s*'([^']+)'\\s*,\\s*'([^']+)'\\s*," + + "\\s*'([^']+)'\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + /** SQL 行注释 (-- 至行尾),剥离后便于正则匹配跨行 VALUES */ + private static final Pattern LINE_COMMENT_PATTERN = Pattern.compile("--[^\\n]*\\n"); + + @Test + @DisplayName("V13 migration 文件存在于 classpath") + void migrationFile_existsOnClasspath() throws IOException { + try (InputStream is = getClass().getResourceAsStream(MIGRATION_PATH)) { + assertThat(is) + .as("V13 migration 文件必须在 classpath: %s", MIGRATION_PATH) + .isNotNull(); + } + } + + @Test + @DisplayName("V13 INSERT 包含 system 占位用户行:id=0L, username='system', status=1, account_type=1, deleted=0") + void v13Insert_systemUserRow_matchesSystemUserSecurityContextConstants() throws IOException { + String sql = stripComments(loadMigrationSql()); + Matcher matcher = VALUES_PATTERN.matcher(sql); + assertThat(matcher.find()) + .as("V13 必须包含 INSERT INTO t_user VALUES (0, NULL, 'system', ...) 语句") + .isTrue(); + + // id: 第 1 组 — V13 注释要求 id=0 + String idValue = matcher.group(1); + assertThat(idValue) + .as("V13 system-user 行 id 必须为 0(= SystemUserSecurityContext.SYSTEM_USER_ID)") + .isEqualTo("0"); + + // username: 第 2 组 + String username = matcher.group(2); + assertThat(username) + .as("V13 system-user 行 username 必须为 'system'(= SystemUserSecurityContext.SYSTEM_USERNAME)") + .isEqualTo(SystemUserSecurityContext.SYSTEM_USERNAME); + + // password: 第 3 组(BCrypt 哈希,$2a$10$... 格式) + String password = matcher.group(3); + assertThat(password) + .as("V13 system-user 行 password 必须为 BCrypt 哈希格式($2a$... 起头),非明文") + .startsWith("$2a$"); + + // real_name: 第 4 组 + String realName = matcher.group(4); + assertThat(realName) + .as("V13 system-user 行 real_name 含'系统占位用户'标识用途") + .contains("系统占位用户"); + + // status: 第 5 组 + int status = Integer.parseInt(matcher.group(5)); + assertThat(status) + .as("V13 system-user 行 status=1(启用)") + .isEqualTo(1); + + // account_type: 第 6 组 + int accountType = Integer.parseInt(matcher.group(6)); + assertThat(accountType) + .as("V13 system-user 行 account_type=1(普通账号类型)") + .isEqualTo(1); + } + + @Test + @DisplayName("V13 INSERT 包含 deleted=0 标记(未软删除)") + void v13Insert_systemUserRow_notSoftDeleted() throws IOException { + String sql = stripComments(loadMigrationSql()); + // VALUES 子句末尾倒数第 1 个数字字段是 deleted(见 V13 SQL 列顺序:..., deleted) + // 匹配 ..., 0\n); 模式 — deleted=0 + Pattern deletedPattern = Pattern.compile(",\\s*0\\s*\\)\\s*$", Pattern.MULTILINE); + assertThat(deletedPattern.matcher(sql).find()) + .as("V13 system-user 行 deleted=0(未软删除,避免被 BaseModel 自动过滤)") + .isTrue(); + } + + @Test + @DisplayName("V13 包含 ON DUPLICATE KEY UPDATE(幂等执行)") + void v13Insert_idempotent() throws IOException { + String sql = loadMigrationSql(); + assertThat(sql) + .as("V13 必须包含 ON DUPLICATE KEY UPDATE 保证重复执行幂等") + .containsIgnoringCase("ON DUPLICATE KEY UPDATE"); + } + + @Test + @DisplayName("V13 注释引用 R4/OQ5 决策(可追溯性)") + void v13Comments_referenceR4AndOQ5Decisions() throws IOException { + String sql = loadMigrationSql(); + assertThat(sql) + .as("V13 注释必须引用 R4 system-user 方案(决策可追溯)") + .contains("R4"); + assertThat(sql) + .as("V13 注释必须引用 OQ5 决策(决策可追溯)") + .contains("OQ5"); + } + + private String loadMigrationSql() throws IOException { + try (InputStream is = getClass().getResourceAsStream(MIGRATION_PATH)) { + assertThat(is).as("V13 migration 文件必须在 classpath").isNotNull(); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** 剥离 SQL 行注释 (-- 至行尾),便于正则匹配跨行 VALUES 子句 */ + private String stripComments(String sql) { + return LINE_COMMENT_PATTERN.matcher(sql).replaceAll("\n"); + } +} diff --git a/backend/pms-base/src/main/java/com/pms/base/service/impl/AbilityPackageVersionServiceImpl.java b/backend/pms-base/src/main/java/com/pms/base/service/impl/AbilityPackageVersionServiceImpl.java index 083c6fa..a30ad8d 100644 --- a/backend/pms-base/src/main/java/com/pms/base/service/impl/AbilityPackageVersionServiceImpl.java +++ b/backend/pms-base/src/main/java/com/pms/base/service/impl/AbilityPackageVersionServiceImpl.java @@ -17,6 +17,7 @@ import com.pms.common.security.UserContext; import com.pms.common.util.JsonUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +40,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe private final ProjectAbilityMapper projectAbilityMapper; @Override + @PreAuthorize("hasAuthorityAndAdmin('base:project:manage')") @Transactional(rollbackFor = Exception.class) public AbilityPackageVersion createDraft(Long packageId) { AbilityPackage pkg = abilityPackageMapper.selectById(packageId); @@ -78,6 +80,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:project:manage')") @Transactional(rollbackFor = Exception.class) public void submitForApproval(Long versionId) { AbilityPackageVersion version = loadVersion(versionId); @@ -96,6 +99,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:project:manage')") @Transactional(rollbackFor = Exception.class) public void approve(Long versionId, String comment) { AbilityPackageVersion version = loadVersion(versionId); @@ -135,6 +139,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:project:manage')") @Transactional(rollbackFor = Exception.class) public void reject(Long versionId, String reason) { AbilityPackageVersion version = loadVersion(versionId); @@ -156,6 +161,7 @@ public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionSe } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:project:manage')") @Transactional(rollbackFor = Exception.class) public void delete(Long versionId) { AbilityPackageVersion version = loadVersion(versionId); diff --git a/backend/pms-base/src/main/java/com/pms/base/service/impl/ApprovalFlowRuntimeServiceImpl.java b/backend/pms-base/src/main/java/com/pms/base/service/impl/ApprovalFlowRuntimeServiceImpl.java index 76cf3fe..1a020da 100644 --- a/backend/pms-base/src/main/java/com/pms/base/service/impl/ApprovalFlowRuntimeServiceImpl.java +++ b/backend/pms-base/src/main/java/com/pms/base/service/impl/ApprovalFlowRuntimeServiceImpl.java @@ -22,6 +22,7 @@ import com.pms.common.util.JsonUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -62,6 +63,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic private static final String ACTION_REJECT_PREV = "REJECT_PREV"; @Override + @PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')") @Transactional(rollbackFor = Exception.class) public Long createInstance(Long projectId, String stageTransition) { ApprovalFlowTemplateDTO template = configService.getTemplateByTransition(stageTransition); @@ -101,6 +103,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')") @Transactional(rollbackFor = Exception.class) public ApprovalCompletedEvent approve(Long instanceId, Long userId, String comment) { ApprovalFlowInstance instance = loadInstance(instanceId); @@ -166,6 +169,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')") @Transactional(rollbackFor = Exception.class) public void rejectToInitiator(Long instanceId, Long userId, String comment) { ApprovalFlowInstance instance = loadInstance(instanceId); @@ -182,6 +186,7 @@ public class ApprovalFlowRuntimeServiceImpl implements ApprovalFlowRuntimeServic } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:lifecycle:manage')") @Transactional(rollbackFor = Exception.class) public void rejectToPrevious(Long instanceId, Long userId, String comment) { ApprovalFlowInstance instance = loadInstance(instanceId); diff --git a/backend/pms-base/src/main/java/com/pms/base/service/impl/ContractRoomServiceImpl.java b/backend/pms-base/src/main/java/com/pms/base/service/impl/ContractRoomServiceImpl.java index 89c00cc..3473004 100644 --- a/backend/pms-base/src/main/java/com/pms/base/service/impl/ContractRoomServiceImpl.java +++ b/backend/pms-base/src/main/java/com/pms/base/service/impl/ContractRoomServiceImpl.java @@ -8,6 +8,7 @@ import com.pms.common.constant.CommonConstants; import com.pms.common.security.UserContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +32,7 @@ public class ContractRoomServiceImpl implements ContractRoomService { private final ContractRoomMapper contractRoomMapper; @Override + @PreAuthorize("hasAuthorityAndAdmin('base:contract:manage')") @Transactional(rollbackFor = Exception.class) public void bindRooms(Long contractId, Long projectId, List roomIds) { if (roomIds == null) { diff --git a/backend/pms-base/src/main/java/com/pms/base/service/impl/LookupServiceImpl.java b/backend/pms-base/src/main/java/com/pms/base/service/impl/LookupServiceImpl.java index 02c0915..cb76e56 100644 --- a/backend/pms-base/src/main/java/com/pms/base/service/impl/LookupServiceImpl.java +++ b/backend/pms-base/src/main/java/com/pms/base/service/impl/LookupServiceImpl.java @@ -14,6 +14,7 @@ import com.pms.common.security.UserContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -76,6 +77,7 @@ public class LookupServiceImpl implements LookupService { } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:lookup:manage')") @Transactional(rollbackFor = Exception.class) public Long addProjectItem(Long projectId, String dictCode, String itemCode, String itemName, Integer sort) { if (projectId == null) { @@ -108,6 +110,7 @@ public class LookupServiceImpl implements LookupService { } @Override + @PreAuthorize("hasAuthorityAndAdmin('base:lookup:manage')") @Transactional(rollbackFor = Exception.class) public void disableProjectItem(Long projectId, String dictCode, String itemCode) { if (projectId == null) { diff --git a/backend/pms-base/src/test/java/com/pms/base/controller/PreAuthorizeCoverageTest.java b/backend/pms-base/src/test/java/com/pms/base/controller/PreAuthorizeCoverageTest.java index ad1e762..8068829 100644 --- a/backend/pms-base/src/test/java/com/pms/base/controller/PreAuthorizeCoverageTest.java +++ b/backend/pms-base/src/test/java/com/pms/base/controller/PreAuthorizeCoverageTest.java @@ -1,11 +1,14 @@ package com.pms.base.controller; import com.pms.base.service.impl.AbilityPackageServiceImpl; +import com.pms.base.service.impl.AbilityPackageVersionServiceImpl; +import com.pms.base.service.impl.ApprovalFlowRuntimeServiceImpl; import com.pms.base.service.impl.ArchiveServiceImpl; import com.pms.base.service.impl.ApprovalFlowConfigServiceImpl; import com.pms.base.service.impl.BuildingServiceImpl; import com.pms.base.service.impl.CamChargeServiceImpl; import com.pms.base.service.impl.CommunityActivityServiceImpl; +import com.pms.base.service.impl.ContractRoomServiceImpl; import com.pms.base.service.impl.ContractServiceImpl; import com.pms.base.service.impl.ContractTypeServiceImpl; import com.pms.base.service.impl.DataScopeRuleServiceImpl; @@ -19,6 +22,7 @@ import com.pms.base.service.impl.EnterpriseServiceServiceImpl; import com.pms.base.service.impl.FloorServiceImpl; import com.pms.base.service.impl.LeaseContractServiceImpl; import com.pms.base.service.impl.LifecycleServiceImpl; +import com.pms.base.service.impl.LookupServiceImpl; import com.pms.base.service.impl.MeetingRoomServiceImpl; import com.pms.base.service.impl.OwnerCommitteeServiceImpl; import com.pms.base.service.impl.OwnerRoomServiceImpl; @@ -145,7 +149,13 @@ class PreAuthorizeCoverageTest { ); /** - * U4+U5+U6 范围 @Service 类列表(pms-base 全量 ServiceImpl,34 个)。 + * U4+U5+U6 范围 @Service 类列表(pms-base 全量 ServiceImpl,38 个)。 + *

+ * ce-debug P0 #1 修复:补齐 4 个遗漏的 @Service impl + * - AbilityPackageVersionServiceImpl(5 @Transactional 写方法) + * - ApprovalFlowRuntimeServiceImpl(4 @Transactional 写方法) + * - ContractRoomServiceImpl(1 @Transactional 写方法) + * - LookupServiceImpl(2 @Transactional 写方法) */ private static final List> SERVICES = List.of( // U4 项目域 + 数据域 @@ -154,9 +164,11 @@ class PreAuthorizeCoverageTest { ProjectAbilityServiceImpl.class, LifecycleServiceImpl.class, ApprovalFlowConfigServiceImpl.class, + ApprovalFlowRuntimeServiceImpl.class, DataScopeRuleServiceImpl.class, ArchiveServiceImpl.class, AbilityPackageServiceImpl.class, + AbilityPackageVersionServiceImpl.class, PreHandoverProgressServiceImpl.class, ProjectInitServiceImpl.class, // U5 空间域 @@ -174,6 +186,7 @@ class PreAuthorizeCoverageTest { // U6 合同域 ContractServiceImpl.class, ContractTypeServiceImpl.class, + ContractRoomServiceImpl.class, LeaseContractServiceImpl.class, // U6 设备域 DeviceServiceImpl.class, @@ -188,7 +201,9 @@ class PreAuthorizeCoverageTest { MeetingRoomServiceImpl.class, PublicRevenueServiceImpl.class, WorkshopLeaseServiceImpl.class, - CamChargeServiceImpl.class + CamChargeServiceImpl.class, + // 字典/配置 + LookupServiceImpl.class ); static Stream publicMethods() { diff --git a/backend/pms-common/src/main/java/com/pms/common/exception/GlobalExceptionHandler.java b/backend/pms-common/src/main/java/com/pms/common/exception/GlobalExceptionHandler.java index 9d437ac..6f4919c 100644 --- a/backend/pms-common/src/main/java/com/pms/common/exception/GlobalExceptionHandler.java +++ b/backend/pms-common/src/main/java/com/pms/common/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -131,6 +132,27 @@ public class GlobalExceptionHandler { return Result.error(ResultCode.FORBIDDEN, "无权限访问"); } + /** + * 认证失败(匿名访问 @PreAuthorize 保护方法等) + *

+ * Spring Security 在 SecurityContext 无 Authentication 时,方法级 @PreAuthorize 触发的 + * SpEL 表达式求值会抛 {@link org.springframework.security.authentication.AuthenticationCredentialsNotFoundException} + * (继承 AuthenticationException)。若不显式处理,会落入通用 Exception handler 返回 500。 + *

+ * 标准 Spring Security 语义:AuthenticationException → 401(未认证),AccessDeniedException → 403(已认证无权限)。 + * PR2-PR4 给 100+ 方法加 @PreAuthorize 扩大触发面,此 handler 阻断匿名访问误报 500。 + *

+ * ponytail: 处理 AuthenticationException 父类而非具体子类,覆盖所有认证失败语义, + * 与 ExceptionTranslationFilter 行为一致;@ExceptionHandler 按最具体匹配,BusinessException 优先级更高。 + */ + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public Result handleAuthenticationException(AuthenticationException e, HttpServletRequest request) { + log.warn("认证失败: path={}, userId={}, type={}", + request.getRequestURI(), UserContext.getUserId(), e.getClass().getSimpleName()); + return Result.error(ResultCode.UNAUTHORIZED, "未认证或认证已过期"); + } + /** * 其他未捕获异常 */ diff --git a/backend/pms-common/src/test/java/com/pms/common/exception/GlobalExceptionHandlerTest.java b/backend/pms-common/src/test/java/com/pms/common/exception/GlobalExceptionHandlerTest.java index 893a11b..b169af0 100644 --- a/backend/pms-common/src/test/java/com/pms/common/exception/GlobalExceptionHandlerTest.java +++ b/backend/pms-common/src/test/java/com/pms/common/exception/GlobalExceptionHandlerTest.java @@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; @@ -142,4 +144,32 @@ class GlobalExceptionHandlerTest { assertThat(result.getCode()).isEqualTo(ResultCode.INTERNAL_ERROR.getCode()); assertThat(result.getMessage()).isEqualTo("系统繁忙,请稍后重试"); } + + // ===== ce-debug P2 #10:AuthenticationException → 401(防匿名访问 @PreAuthorize 方法返回 500) ===== + + @Test + @DisplayName("AuthenticationCredentialsNotFoundException → Result.code=401 + message=未认证或认证已过期") + void shouldReturnUnauthorizedWhenAuthenticationCredentialsNotFound() { + SecurityContextHolder.clearContext(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users"); + + Result result = handler.handleAuthenticationException( + new AuthenticationCredentialsNotFoundException("无 Authentication 对象"), request); + + assertThat(result.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode()); + assertThat(result.getMessage()).isEqualTo("未认证或认证已过期"); + } + + @Test + @DisplayName("AuthenticationException 子类 → 统一 401(覆盖 BadCredentialsException 等认证失败语义)") + void shouldReturnUnauthorizedForAnyAuthenticationException() { + SecurityContextHolder.clearContext(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/charge/dashboard"); + + // AuthenticationException 是抽象类,用其具体子类 AuthenticationCredentialsNotFoundException 验证多态分发 + AuthenticationException authEx = new AuthenticationCredentialsNotFoundException("test") {}; + Result result = handler.handleAuthenticationException(authEx, request); + + assertThat(result.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode()); + } }