fix(security): ce-debug 全量修复 P0 #1 + P1 #4/#5 + P2 #9/#10/#12
Backend CI / Build & Test (pull_request) Waiting to run Details

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
This commit is contained in:
ether 2026-07-05 22:30:25 +08:00
parent 95a7473f66
commit 201181ca27
11 changed files with 554 additions and 3 deletions

View File

@ -35,7 +35,33 @@ public class AuditConsumer {
private final MqConsumeLogMapper mqConsumeLogMapper;
/**
* 消费操作审计日志
* 消费操作审计日志
* <p>
* <b>Trust BoundaryP2 #12 文档化</b>本方法是 MQ ingress 信任边界
* 消息来源为各服务 AuditLogAspect AOP 切面{@code com.pms.common.aspect.AuditLogAspect}
* 通过 RabbitMQ 内部集群{@code spring.rabbitmq.host}不对外暴露发送
* <p>
* 信任假设不在此处校验 AOP 切面 + MQ 网络隔离保证
* <ul>
* <li>{@code operatorId}来自 {@link com.pms.common.security.UserContext}
* {@code HeaderAuthenticationFilter} JWT claims 解析已通过认证</li>
* <li>{@code module}/{@code operation} {@code @AuditLog} 注解静态指定编译期确定</li>
* <li>{@code operationTime} AOP 切面在请求线程生成非用户输入</li>
* <li>{@code requestId} MDC 生成非用户输入</li>
* </ul>
* <p>
* 不在此处做 payload 校验的原因
* <ol>
* <li>MQ 集群不对外暴露外部无法注入恶意 payload</li>
* <li>AuditLogAspect 是唯一生产者payload 结构由代码保证</li>
* <li>校验逻辑会与 AuditLogAspect 重复违反 DRY</li>
* </ol>
* <p>
* 升级路径若未来 MQ 集群对外暴露如跨集群联邦需在此处增加 payload 校验
* 当前依赖 {@code spring.rabbitmq.host} 配置为内网地址 + 防火墙规则隔离
* <p>
* 异常路径JSON 解析失败 ACK 丢弃消息格式错误重试无意义
* 业务异常 NACK 不重试避免毒消息循环
*/
@RabbitListener(queues = AuditConstants.QUEUE_AUDIT_OPERATION)
public void onAuditOperation(String message, Channel channel,

View File

@ -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
* <p>
* 覆盖场景
* <ul>
* <li>Happy path: 新事件 save() 调用 + consumeLog 插入 + ACK</li>
* <li>幂等命中Redis: SETNX false ACK 不处理</li>
* <li>幂等命中DB: count > 0 ACK 不处理</li>
* <li>异常: JSON 解析 null ACK 丢弃</li>
* <li>异常: save() 抛异常 NACK + SecurityContext 清理try-with-resources 验证</li>
* <li>eventId 缺失fallback operatorId:operationTime 拼装 save() 调用</li>
* </ul>
* <p>
* 设计依据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<String, String> 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 解析抛异常 → NACKJsonUtils.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=nulloperatorId=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;
}
}

View File

@ -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-checkP2 #9解析 V12 migration SQL 提取实际定义的 function_code
* 验证 VALID_FUNC_CODES 列表与 V12 一致避免 V12 加新 func_code 后测试列表漂移
* <p>
* V12 定义 8 func_codeadmin/base/charge/operation/user_management/
* role_management/org_management/system_management'viewer' V1 遗留V12 UPDATE
* 但仍在 t_role Q10 决策保留VALID_FUNC_CODES 单独覆盖
* <p>
* ponytail: 不抽共享 fixture Gradle testFixtures 插件配置保留 3 模块列表重复
* pms-auth 在此提供 V12 单点交叉检查 V12 加新 func_code 时本测试强制失败
* 触发开发者同步更新 3 模块的 VALID_FUNC_CODESpms-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<String> v12DefinedFuncCodes = extractFuncCodesFromV12(v12Sql);
// V12 必须至少定义 8 func_code4 UPDATE + 4 INSERT
assertThat(v12DefinedFuncCodes)
.as("V12 应至少定义 8 个 func_code4 UPDATE + 4 INSERT")
.hasSizeGreaterThanOrEqualTo(8);
// VALID_FUNC_CODES 必须包含 V12 实际定义的全部 func_code
assertThat(VALID_FUNC_CODES)
.as("VALID_FUNC_CODES 必须包含 V12 migration 实际定义的全部 function_codeV12 加新 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 集合
* <ul>
* <li>UPDATE 模式`` `function_code` = 'xxx' ``反引号包裹列名</li>
* <li>INSERT 模式INSERT INTO t_role VALUES (id, 'ROLE_CODE', '名称', 'func_code', 1, UNIX_TIMESTAMP()...)
* 匹配 `'func_code',\s*1,\s*UNIX_TIMESTAMP`function_code 后跟 data_scope=1 与时间戳</li>
* </ul>
* V12 INSERT 是单条多 VALUES 元组 2 个正则用全局 find() 捕获全部 4 个元组
*/
private Set<String> 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<String> 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;
}
}

View File

@ -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
* <p>
* 设计依据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 约束
* <p>
* 测试策略pms-auth 模块无 testcontainers/DB 集成测试基础设施遵循 Mockito-only 单元测试约定
* 本测试以静态 SQL 资源校验方式运行加载 V13__add_system_user.sql 作为 classpath 资源
* 解析 INSERT 语句验证关键列值与 SystemUserSecurityContext 常量一致
* <p>
* 守卫范围
* <ul>
* <li>id = 0= {@link SystemUserSecurityContext#SYSTEM_USER_ID}</li>
* <li>username = 'system'= {@link SystemUserSecurityContext#SYSTEM_USERNAME}</li>
* <li>status = 1启用避免 AuthServiceImpl.login "账号禁用"分支混淆</li>
* <li>account_type = 1普通账号类型</li>
* <li>deleted = 0未删除</li>
* <li>real_name "系统占位用户"标识用途</li>
* <li>password BCrypt 哈希非明文非空</li>
* </ul>
* <p>
* 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");
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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<Long> roomIds) {
if (roomIds == null) {

View File

@ -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) {

View File

@ -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 全量 ServiceImpl34
* U4+U5+U6 范围 @Service 类列表pms-base 全量 ServiceImpl38
* <p>
* ce-debug P0 #1 修复补齐 4 个遗漏的 @Service impl
* - AbilityPackageVersionServiceImpl5 @Transactional 写方法
* - ApprovalFlowRuntimeServiceImpl4 @Transactional 写方法
* - ContractRoomServiceImpl1 @Transactional 写方法
* - LookupServiceImpl2 @Transactional 写方法
*/
private static final List<Class<?>> 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<Arguments> publicMethods() {

View File

@ -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 保护方法等
* <p>
* Spring Security SecurityContext Authentication 方法级 @PreAuthorize 触发的
* SpEL 表达式求值会抛 {@link org.springframework.security.authentication.AuthenticationCredentialsNotFoundException}
* 继承 AuthenticationException若不显式处理会落入通用 Exception handler 返回 500
* <p>
* 标准 Spring Security 语义AuthenticationException 401未认证AccessDeniedException 403已认证无权限
* PR2-PR4 100+ 方法加 @PreAuthorize 扩大触发面 handler 阻断匿名访问误报 500
* <p>
* ponytail: 处理 AuthenticationException 父类而非具体子类覆盖所有认证失败语义
* ExceptionTranslationFilter 行为一致@ExceptionHandler 按最具体匹配BusinessException 优先级更高
*/
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public Result<Void> handleAuthenticationException(AuthenticationException e, HttpServletRequest request) {
log.warn("认证失败: path={}, userId={}, type={}",
request.getRequestURI(), UserContext.getUserId(), e.getClass().getSimpleName());
return Result.error(ResultCode.UNAUTHORIZED, "未认证或认证已过期");
}
/**
* 其他未捕获异常
*/

View File

@ -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 #10AuthenticationException 401防匿名访问 @PreAuthorize 方法返回 500 =====
@Test
@DisplayName("AuthenticationCredentialsNotFoundException → Result.code=401 + message=未认证或认证已过期")
void shouldReturnUnauthorizedWhenAuthenticationCredentialsNotFound() {
SecurityContextHolder.clearContext();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users");
Result<Void> 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<Void> result = handler.handleAuthenticationException(authEx, request);
assertThat(result.getCode()).isEqualTo(ResultCode.UNAUTHORIZED.getCode());
}
}