日志记录和审计、 备份配置

This commit is contained in:
xydkj 2026-04-17 11:20:26 +08:00
parent 1f9b7297a2
commit ec3ba7c60d
49 changed files with 2137 additions and 157 deletions

View File

@ -0,0 +1,376 @@
# 管理员操作审计功能设计
## 1. 目标
统一使用 `tms_operation_audit_log` 记录所有敏感管理员操作,覆盖:
- 登录、登出
- 增删改
- 执行类操作:初始化、重置、升级、回滚、重启、密钥操作
- 非法操作:拒绝访问、权限不足、重放、签名失败等
设计原则:
- 只保留一张日志表
- 敏感操作日志写入失败时,业务直接失败
- 日志在落库前完成 `SM3` 摘要与 `SM2` 签名
- 人工审计字段不参与首签
## 2. 表结构
表名:`tms_operation_audit_log`
```sql
CREATE TABLE IF NOT EXISTS tms_operation_audit_log (
id BIGINT PRIMARY KEY,
log_id VARCHAR(64) NOT NULL COMMENT '日志唯一标识',
operator_role_code VARCHAR(64) NOT NULL COMMENT '操作角色编码',
operator_auth_level VARCHAR(32) NULL COMMENT '操作时认证等级',
module_code VARCHAR(32) NOT NULL COMMENT '业务模块',
action_type VARCHAR(32) NOT NULL COMMENT '操作动作',
remote_ip VARCHAR(64) NULL COMMENT '来源IP',
operation_result VARCHAR(16) NOT NULL COMMENT '操作结果: SUCCESS/FAILED/DENIED',
summary VARCHAR(512) NOT NULL COMMENT '操作摘要',
error_message VARCHAR(1024) NULL COMMENT '失败原因',
audit_status VARCHAR(16) NOT NULL COMMENT '审计状态: PENDING/REVIEWED',
audit_result VARCHAR(16) NULL COMMENT '审计结果: PASS/REJECT/NEED_VERIFY',
audit_comment VARCHAR(1024) NULL COMMENT '审计意见',
audited_by VARCHAR(64) NULL COMMENT '审计人',
audited_at DATETIME(3) NULL COMMENT '审计时间',
payload_hash VARCHAR(128) NOT NULL COMMENT 'SM3摘要',
sign_value TEXT NOT NULL COMMENT 'SM2签名值',
occurred_at DATETIME(3) NOT NULL COMMENT '操作发生时间',
create_time DATETIME(3) NOT NULL COMMENT '落库时间',
UNIQUE KEY uk_tms_operation_audit_log_log_id (log_id),
KEY idx_tms_operation_audit_log_role_code (operator_role_code),
KEY idx_tms_operation_audit_log_module_code (module_code),
KEY idx_tms_operation_audit_log_action_type (action_type),
KEY idx_tms_operation_audit_log_result (operation_result),
KEY idx_tms_operation_audit_log_audit_status (audit_status),
KEY idx_tms_operation_audit_log_occurred_at (occurred_at)
);
```
## 3. 枚举设计
### 3.1 `module_code`
- `AUTH`
- `INIT`
- `UPGRADE`
- `DEVICE`
- `NETWORK`
- `KEY`
- `SYSTEM`
- `SECURITY`
### 3.2 `action_type`
- `LOGIN`
- `LOGOUT`
- `CREATE`
- `UPDATE`
- `DELETE`
- `EXECUTE`
- `RESET`
- `ENABLE`
- `DISABLE`
- `BIND`
- `IMPORT`
- `EXPORT`
- `BACKUP`
- `RECOVER`
- `RESTART`
- `ACCESS`
### 3.3 `operation_result`
- `SUCCESS`
- `FAILED`
- `DENIED`
### 3.4 `audit_status`
- `PENDING`
- `REVIEWED`
### 3.5 `audit_result`
- `PASS`
- `REJECT`
- `NEED_VERIFY`
## 4. 摘要与签名
### 4.1 首签字段
入库前参与 `payload_hash``sign_value` 计算的字段:
- `log_id`
- `operator_role_code`
- `operator_auth_level`
- `module_code`
- `action_type`
- `remote_ip`
- `operation_result`
- `summary`
- `occurred_at`
### 4.2 不参与首签的字段
以下字段属于后续审计动作或补充信息,不参与首签:
- `audit_status`
- `audit_result`
- `audit_comment`
- `audited_by`
- `audited_at`
- `error_message`
### 4.3 规范化串
建议按固定字段顺序拼接:
```text
log_id=...
operator_role_code=...
operator_auth_level=...
module_code=...
action_type=...
remote_ip=...
operation_result=...
summary=...
occurred_at=...
```
规范要求:
- 字段顺序固定
- `null` 统一为空串
- 首尾空格统一裁剪
- 时间统一使用 UTC 与固定格式
### 4.4 签名流程
1. 构造 `canonical_payload`
2. 计算 `payload_hash = SM3(canonical_payload)`
3. 计算 `sign_value = SM2(privateKey, canonical_payload)`
4. 一次性写入数据库
## 5. Java 设计草案
核心对象:
- `OperationAuditLogEntity`
- `OperationAuditLogRepository`
- `OperationAuditService`
- `OperationAuditCommand`
- `OperationAuditSigner`
服务接口建议:
```java
public interface OperationAuditService {
void record(OperationAuditCommand command);
void review(String logId, AuditResult auditResult, String auditComment, String auditedBy);
}
```
`OperationAuditCommand` 建议字段:
- `operatorRoleCode`
- `operatorAuthLevel`
- `moduleCode`
- `actionType`
- `remoteIp`
- `operationResult`
- `summary`
- `errorMessage`
## 6. 日志记录流程
统一入口:`OperationAuditService.record(...)`
处理流程:
1. 业务侧提交 `OperationAuditCommand`
2. 服务补齐:
- `log_id`
- `occurred_at`
- `audit_status = PENDING`
3. 生成 `payload_hash``sign_value`
4. 插入 `tms_operation_audit_log`
5. 若插入失败:
- 对敏感操作直接抛异常,业务失败
## 7. 注解与 AOP 接入方案
### 7.1 注解定义
```java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditedOperation {
String module();
String action();
String summary() default "";
boolean sensitive() default true;
}
```
### 7.2 AOP 职责
切面负责:
- 读取注解元数据
- 从请求上下文提取:
- 当前角色
- 当前认证等级
- 来源 IP
- 环绕执行目标方法
- 成功时记录 `SUCCESS`
- 捕获业务异常时记录 `FAILED`
- 捕获权限或安全拒绝时记录 `DENIED`
- 对 `sensitive = true` 的操作,日志写入失败时重新抛错
### 7.3 设计原则
- AOP 负责公共字段、结果判定和统一落库
- 业务方法负责提供准确的 `summary`
- 不在 AOP 中自动抓取完整请求参数,避免日志带入密码、令牌、密钥材料等敏感数据
## 8. 接口清单
### 8.1 首批需要接入审计的接口
#### 认证与角色管理
- `POST /api/v1/auth/password-login`
- `POST /api/v1/auth/ukey-login`
- `POST /api/v1/auth/logout`
- `POST /api/v1/auth/change-password`
- `POST /api/v1/auth/roles/{roleCode}/enable`
- `POST /api/v1/auth/roles/{roleCode}/reset-password`
- `POST /api/v1/auth/roles/{roleCode}/ukeys/bind`
#### 初始化与重置
- `POST /api/v1/init/tasks`
- `POST /api/v1/init/tasks/{taskId}/execute`
- `POST /api/v1/init/reset/tasks`
- `POST /api/v1/init/reset/tasks/{taskId}/execute`
#### 升级管理
- `POST /api/v1/upgrades`
- `POST /api/v1/upgrades/{taskId}/execute`
- `POST /api/v1/upgrades/{taskId}/rollback`
#### 设备与网络
- `POST /api/v1/device/restart`
- 网络配置写接口
- IP 白名单增删改接口
#### 密钥与密码卡
- LMK / IK / UKey / 密钥导入导出、备份恢复、销毁相关写接口
#### 安全事件
- 认证失败
- 权限不足
- 重放拦截
- 签名校验失败
### 8.2 日志分页查询接口
- `GET /api/v1/audit-logs`
查询条件:
- `operatorRoleCode`
- `moduleCode`
- `actionType`
- `operationResult`
- `auditStatus`
- `auditResult`
- `remoteIp`
- `dateFrom`
- `dateTo`
- `keyword`
默认排序:
- `occurred_at DESC`
### 8.3 日志详情接口
- `GET /api/v1/audit-logs/{logId}`
详情建议返回:
- 基础日志字段
- 审计字段
- `payload_hash`
- `sign_value`
### 8.4 日志审计接口
- `POST /api/v1/audit-logs/{logId}/review`
请求示例:
```json
{
"auditResult": "PASS",
"auditComment": "复核通过"
}
```
更新规则:
- 仅审计管理员可操作
- 仅允许 `PENDING -> REVIEWED`
- 更新字段:
- `audit_status`
- `audit_result`
- `audit_comment`
- `audited_by`
- `audited_at`
## 9. 失败策略
敏感操作统一采用失败关闭:
- 审计日志写入失败,业务直接失败
敏感操作范围包括:
- 登录与登出
- 角色管理
- 口令变更
- UKey 管理
- 初始化与重置
- 升级与回滚
- 网络配置
- 白名单变更
- 密钥与密码卡操作
- 安全拦截事件
## 10. 推荐实现顺序
1. Flyway 建表,删除旧 `tms_auth_audit_log` / `tms_security_event`
2. 增加枚举、Entity、Repository
3. 增加 `OperationAuditSigner`SM3 / SM2
4. 增加 `OperationAuditService`
5. 增加 `@AuditedOperation` 与 AOP
6. 增加分页查询与详情接口
7. 增加审计接口
8. 在首批敏感接口上补齐注解与摘要

View File

@ -6,6 +6,9 @@ import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.security.RequireInternalAuth;
import com.cisd.tms.modules.auth.service.AuthAdminService;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.modules.mk.dto.UKeySignDTO;
import com.cisd.tms.modules.mk.dto.UKeySignResult;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
@ -14,13 +17,8 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/auth")
@ -34,6 +32,7 @@ public class AuthAdminController {
@PostMapping("/roles/{roleCode}/enable")
@Operation(summary = "启用角色", description = "仅允许 KEY_ADMIN FULL 会话启用目标角色。")
@RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.ENABLE, summary = "启用角色")
public ApiResponse<Void> enableRole(@PathVariable("roleCode") String roleCode, HttpServletRequest request) {
authAdminService.enableRole(
(String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE),
@ -46,6 +45,7 @@ public class AuthAdminController {
@PostMapping("/roles/{roleCode}/reset-password")
@Operation(summary = "重置角色密码", description = "仅允许 KEY_ADMIN FULL 会话重置目标角色密码。")
@RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.RESET, summary = "重置角色密码")
public ApiResponse<Void> resetPassword(@PathVariable("roleCode") String roleCode, HttpServletRequest request) {
authAdminService.resetPassword(
(String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE),
@ -58,6 +58,7 @@ public class AuthAdminController {
@PostMapping("/roles/{roleCode}/ukeys/bind")
@Operation(summary = "绑定角色 UKey", description = "仅允许 KEY_ADMIN FULL 会话登记目标角色的 UKey 绑定信息。")
@RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.BIND, summary = "绑定角色 UKey")
public ApiResponse<Void> bindUkey(
@PathVariable("roleCode") String roleCode,
@Valid @RequestBody UkeyBindRequest request,

View File

@ -1,28 +1,19 @@
package com.cisd.tms.modules.auth.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.auth.dto.CaptchaResponse;
import com.cisd.tms.modules.auth.dto.ChangePasswordRequest;
import com.cisd.tms.modules.auth.dto.CurrentUserResponse;
import com.cisd.tms.modules.auth.dto.LoginResponse;
import com.cisd.tms.modules.auth.dto.PasswordLoginRequest;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomRequest;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomResponse;
import com.cisd.tms.modules.auth.dto.UkeyLoginRequest;
import com.cisd.tms.modules.auth.dto.*;
import com.cisd.tms.modules.auth.service.AuthService;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/auth")
@ -34,6 +25,7 @@ public class AuthController {
@PostMapping("/password-login")
@Operation(summary = "口令登录", description = "按旧系统受限登录流程签发 LIMITED 会话。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.LOGIN, summary = "口令登录")
public ApiResponse<LoginResponse> passwordLogin(@Valid @RequestBody PasswordLoginRequest request) {
return ApiResponse.success(authService.passwordLogin(request));
}
@ -46,6 +38,7 @@ public class AuthController {
@PostMapping("/ukey-login")
@Operation(summary = "UKey 登录", description = "按旧系统标准 UKey 校验顺序签发 FULL 会话。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.LOGIN, summary = "UKey 登录")
public ApiResponse<LoginResponse> ukeyLogin(@Valid @RequestBody UkeyLoginRequest request) {
return ApiResponse.success(authService.ukeyLogin(request));
}
@ -66,6 +59,7 @@ public class AuthController {
@PostMapping("/logout")
@Operation(summary = "退出当前会话", description = "使当前会话失效。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.LOGOUT, summary = "退出当前会话")
public ApiResponse<Void> logout(HttpServletRequest request) {
authService.logout((String) request.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN));
return ApiResponse.success();
@ -73,6 +67,7 @@ public class AuthController {
@PostMapping("/change-password")
@Operation(summary = "修改当前角色口令", description = "基于当前会话校验并更新当前角色口令。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.UPDATE, summary = "修改当前角色口令")
@ReplayProtected
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) {
authService.changePassword(

View File

@ -3,23 +3,27 @@ package com.cisd.tms.modules.auth.security;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.util.HttpResponseUtil;
import com.cisd.tms.modules.log.service.OperationAuditService;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
@Component
public class InternalAuthorizationInterceptor implements HandlerInterceptor {
private final ObjectMapper objectMapper;
private final OperationAuditService operationAuditService;
public InternalAuthorizationInterceptor(ObjectMapper objectMapper) {
public InternalAuthorizationInterceptor(ObjectMapper objectMapper, OperationAuditService operationAuditService) {
this.objectMapper = objectMapper;
this.operationAuditService = operationAuditService;
}
@Override
@ -33,11 +37,13 @@ public class InternalAuthorizationInterceptor implements HandlerInterceptor {
String currentRole = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
if (!requireInternalAuth.role().getCode().equals(currentRole)) {
writeForbidden(response, "role not allowed");
operationAuditService.recordDeniedLog(request, handlerMethod, "role not allowed");
return false;
}
String currentAuthLevel = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL);
if (!requireInternalAuth.authLevel().name().equals(currentAuthLevel)) {
writeForbidden(response, "auth level not allowed");
operationAuditService.recordDeniedLog(request, handlerMethod, "auth level not allowed");
return false;
}
}

View File

@ -8,10 +8,12 @@ import com.cisd.tms.modules.device.dto.DeviceRuntimeStatusResponse;
import com.cisd.tms.modules.device.service.DeviceProfileService;
import com.cisd.tms.modules.device.service.DeviceRuntimeStatusService;
import com.cisd.tms.modules.device.service.DeviceService;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@ -57,6 +59,7 @@ public class DeviceController {
@PostMapping("/restart")
@Operation(summary = "重启设备服务", description = "受理设备重启请求,并记录触发操作的内部用户。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.DEVICE, action = ActionType.RESTART, summary = "重启设备服务")
public ApiResponse<DeviceActionResponse> restart(HttpServletRequest request) {
String operator = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
return ApiResponse.success(deviceService.restart(operator));

View File

@ -2,11 +2,15 @@ package com.cisd.tms.modules.device.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest;
import com.cisd.tms.modules.device.dto.network.IpWhitelistResponse;
import com.cisd.tms.modules.device.service.IpWhitelistService;
import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest;
import com.cisd.tms.security.internal.ReplayProtected;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@ -22,7 +26,7 @@ public class IpWhitelistController {
@PostMapping("/add")
@Operation(summary = "添加IP白名单", description = "新增一条IP白名单记录")
@ReplayProtected
// @ReplayProtected
public ApiResponse<Void> addWhitelist(@RequestBody IpWhitelistRequest req) {
ipWhitelistService.addWhitelist(req);
return ApiResponse.success();
@ -31,7 +35,8 @@ public class IpWhitelistController {
@PostMapping("/update")
@Operation(summary = "更新IP白名单", description = "根据ID更新IP白名单信息")
@ReplayProtected
// @ReplayProtected
@AuditedOperation(module = ModuleCode.DEVICE, action = ActionType.UPDATE, summary = "更新IP白名单")
public ApiResponse<Void> updateWhitelist(@RequestBody IpWhitelistRequest req) {
ipWhitelistService.updateWhitelist(req);
return ApiResponse.success();
@ -39,7 +44,8 @@ public class IpWhitelistController {
@PostMapping("/delete/{id}")
@Operation(summary = "删除IP白名单", description = "根据ID删除指定IP白名单")
@ReplayProtected
// @ReplayProtected
@AuditedOperation(module = ModuleCode.DEVICE, action = ActionType.DELETE, summary = "删除IP白名单")
public ApiResponse<Void> deleteWhitelist(@PathVariable Long id) {
ipWhitelistService.deleteWhitelist(id);
return ApiResponse.success();
@ -49,8 +55,12 @@ public class IpWhitelistController {
@GetMapping("/page")
@Operation(summary = "分页查询IP白名单", description = "支持分页查询IP白名单列表")
public ApiResponse<IPage<IpWhitelistResponse>> getWhitelistPage(@RequestParam int pageNum,
@RequestParam int pageSize) {
IPage<IpWhitelistResponse> page = ipWhitelistService.getWhitelistPage(pageNum, pageSize);
@RequestParam int pageSize,
@Parameter(
description = "地址类型",
schema = @Schema(allowableValues = {"HOST", "SUBNET"}, example = "HOST")
)String addressType) {
IPage<IpWhitelistResponse> page = ipWhitelistService.getWhitelistPage(pageNum, pageSize, addressType);
return ApiResponse.success(page);
}
}

View File

@ -4,6 +4,9 @@ package com.cisd.tms.modules.device.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.device.dto.network.*;
import com.cisd.tms.modules.device.service.NetworkConfigService;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -22,22 +25,33 @@ public class NetworkConfigController {
this.networkConfigService = networkConfigService;
}
@AuditedOperation(
module = ModuleCode.NETWORK,
action = ActionType.CREATE,
summary = "获取网络信息"
)
@Operation(summary = "获取网络信息", description = "获取当前设备的所有网络连接信息")
@GetMapping("/network-info")
public ApiResponse<List<NetworkInfoResponse>> getNetworkInfo(){
return ApiResponse.success(networkConfigService.getNetworkInfo());
}
@AuditedOperation(
module = ModuleCode.NETWORK,
action = ActionType.DELETE,
summary = "获取IPv4配置信息"
)
@Operation(summary = "获取IPv4配置信息", description = "根据设备名称获取指定网络接口的IPv4配置详情")
@GetMapping("/ipv4-info/{deviceName}")
public ApiResponse<Ipv4InfoResponse> getIpv4Info(@PathVariable String deviceName){
return ApiResponse.success(networkConfigService.getIpv4Info(deviceName));
}
@Operation(summary = "设置IPv4配置", description = "为指定网络接口配置IPv4地址、掩码、网关等信息")
@PostMapping("/ipv4-config/set")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.CREATE, summary = "设置IPv4配置")
public ApiResponse<Void> setIpv4Config(@RequestBody Ipv4ConfigRequest req) {
networkConfigService.setIpv4Config(req);
return ApiResponse.success();
@ -53,6 +67,7 @@ public class NetworkConfigController {
@Operation(summary = "设置IPv6配置", description = "为指定网络接口配置IPv6地址、前缀长度、网关等信息")
@PostMapping("/ipv6-config/set")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.CREATE, summary = "设置IPv6配置")
public ApiResponse<Void> setIpv6Config(@RequestBody Ipv6ConfigRequest req) {
networkConfigService.setIpv6Config(req);
return ApiResponse.success();
@ -67,6 +82,7 @@ public class NetworkConfigController {
@Operation(summary = "创建Bond", description = "创建一个新的Bond聚合接口需指定名称、模式和从属接口")
@PostMapping("/bond/create")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.CREATE, summary = "创建Bond")
public ApiResponse<Void> createBond(@RequestBody BondCreateRequest req) {
networkConfigService.createBond(req);
return ApiResponse.success();
@ -76,6 +92,7 @@ public class NetworkConfigController {
@Operation(summary = "删除Bond", description = "根据Bond名称删除指定的聚合接口")
@PostMapping("/bond/delete/{bondName}")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.DELETE, summary = "删除Bond")
public ApiResponse<Void> deleteBond(@PathVariable("bondName") String bondName){
networkConfigService.deleteBond(bondName);
return ApiResponse.success();
@ -84,6 +101,7 @@ public class NetworkConfigController {
@Operation(summary = "添加从属接口到Bond", description = "向指定Bond中添加一个或多个从属网络接口")
@PostMapping("/bond-slave/add")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.UPDATE, summary = "添加从属接口到Bond")
public ApiResponse<Void> addSlavesTOBond(@RequestBody BondAddSlavesRequest req) {
networkConfigService.addSlavesTOBond(req);
return ApiResponse.success();
@ -92,6 +110,7 @@ public class NetworkConfigController {
@Operation(summary = "从Bond中移除从属接口", description = "从指定Bond中移除一个或多个从属网络接口")
@PostMapping("/bond-slave/remove")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.DELETE, summary = "从Bond中移除从属接口")
public ApiResponse<String> removeSlaveFromBond(@RequestBody BondRemoveSlaveRequest req) {
String resultMessage = networkConfigService.removeSlaveFromBond(req);
return ApiResponse.success(resultMessage);
@ -120,6 +139,7 @@ public class NetworkConfigController {
@Operation(summary = "设置Bond模式", description = "修改指定Bond的绑定模式")
@PostMapping("/bond/mode/set")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.UPDATE, summary = "设置Bond模式")
public ApiResponse<Void> setBondMode(@RequestBody BondModifyModeRequest req) {
networkConfigService.setBondMode(req);
return ApiResponse.success();
@ -135,6 +155,7 @@ public class NetworkConfigController {
@Operation(summary = "设置默认路由", description = "配置或修改系统的默认网关路由")
@PostMapping("/routes/default/set")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.UPDATE, summary = "设置默认路由")
public ApiResponse<Void> setDefaultRoute(@RequestBody SetDefaultRouteRequest req) {
networkConfigService.setDefaultRoute(req);
return ApiResponse.success();
@ -143,6 +164,7 @@ public class NetworkConfigController {
@Operation(summary = "添加静态路由", description = "新增一条静态路由规则")
@PostMapping("/routes/static/add")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.UPDATE, summary = "添加静态路由")
public ApiResponse<Void> addStaticRoute(@RequestBody AddStaticRouteRequest req) {
networkConfigService.addStaticRoute(req);
return ApiResponse.success();
@ -151,6 +173,7 @@ public class NetworkConfigController {
@Operation(summary = "删除默认路由", description = "删除指定的默认路由")
@PostMapping("/routes/default/delete")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.DELETE, summary = "删除默认路由")
public ApiResponse<Void> deleteDefaultRoute(@RequestBody DeleteDefaultRouteRequest req) {
networkConfigService.deleteDefaultRoute(req);
return ApiResponse.success();
@ -159,6 +182,7 @@ public class NetworkConfigController {
@Operation(summary = "删除静态路由", description = "删除指定的静态路由")
@PostMapping("/routes/static/delete")
@ReplayProtected
@AuditedOperation(module = ModuleCode.NETWORK, action = ActionType.DELETE, summary = "删除静态路由")
public ApiResponse<Void> deleteStaticRoute(@RequestBody DeleteStaticRouteRequest req) {
networkConfigService.deleteStaticRoute(req);
return ApiResponse.success();

View File

@ -12,4 +12,6 @@ public class IpWhitelistResponse {
private String ip;
@Schema(description = "掩码长度", example = "24")
private String mask;
@Schema(description = "地址类型", example = "HOST")
private String addressType;
}

View File

@ -1,7 +1,6 @@
package com.cisd.tms.modules.device.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest;
import com.cisd.tms.modules.device.entity.IpWhitelistEntity;
import java.util.List;
@ -22,7 +21,7 @@ public interface IpWhitelistRepository {
/**
* 分页查询白名单
*/
Page<IpWhitelistEntity> selectPage(Page<IpWhitelistEntity> page);
Page<IpWhitelistEntity> selectPage(int pageNum, int pageSize, String addressType);
/**
* 查询所有白名单

View File

@ -2,7 +2,6 @@ package com.cisd.tms.modules.device.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.modules.device.dto.network.IpWhitelistRequest;
import com.cisd.tms.modules.device.entity.IpWhitelistEntity;
import com.cisd.tms.modules.device.mapper.IpWhitelistMapper;
import com.cisd.tms.modules.device.repository.IpWhitelistRepository;
@ -53,7 +52,7 @@ public class IpWhiltelistRepositoryImpl implements IpWhitelistRepository {
}
@Override
public Page<IpWhitelistEntity> selectPage(Page<IpWhitelistEntity> page) {
public Page<IpWhitelistEntity> selectPage(int pageNum, int pageSize, String addressType) {
// LambdaQueryWrapper<IpWhitelistEntity> wrapper = new LambdaQueryWrapper<>();
//
// if (StringUtils.hasText(req.getIp())) {
@ -63,8 +62,15 @@ public class IpWhiltelistRepositoryImpl implements IpWhitelistRepository {
// wrapper.eq(IpWhitelistEntity::getMask, req.getMask());
// }
// wrapper.orderByDesc(IpWhitelistEntity::getCreateTime);
Page<IpWhitelistEntity> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<IpWhitelistEntity> wrapper = new LambdaQueryWrapper<>();
return ipWhitelistMapper.selectPage(page, null);
if ("SUBNET".equals(addressType)) {
wrapper.ne(IpWhitelistEntity::getMask, "32");
} else if ("HOST".equals(addressType)){
wrapper.eq(IpWhitelistEntity::getMask, "32");
}
return ipWhitelistMapper.selectPage(page, wrapper);
}
@Override

View File

@ -76,16 +76,19 @@ public class IpWhitelistService {
}
public IPage<IpWhitelistResponse> getWhitelistPage(int pageNum, int pageSize) {
Page<IpWhitelistEntity> page = new Page<>(pageNum, pageSize);
ipWhitelistRepository.selectPage(page);
public IPage<IpWhitelistResponse> getWhitelistPage(int pageNum, int pageSize, String addressType) {
Page<IpWhitelistEntity> entityPage = ipWhitelistRepository.selectPage(pageNum, pageSize, addressType);
return page.convert(entity -> {
return entityPage.convert(entity -> {
IpWhitelistResponse response = new IpWhitelistResponse();
response.setId(String.valueOf(entity.getId()));
response.setIp(entity.getIp());
response.setMask(entity.getMask());
if ("32".equals(entity.getMask())) {
response.setAddressType("HOST");
} else {
response.setAddressType("SUBNET");
}
return response;
});
}

View File

@ -1,31 +1,19 @@
package com.cisd.tms.modules.init.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.init.dto.InitCreateTaskResponse;
import com.cisd.tms.modules.init.dto.CurrentInitConfigResponse;
import com.cisd.tms.modules.init.dto.InitPlanTemplateResponse;
import com.cisd.tms.modules.init.dto.InitPreviewRequest;
import com.cisd.tms.modules.init.dto.InitPreviewResponse;
import com.cisd.tms.modules.init.dto.InitTaskDetailResponse;
import com.cisd.tms.modules.init.dto.InitTaskExecuteResponse;
import com.cisd.tms.modules.init.dto.InitTaskStepLogResponse;
import com.cisd.tms.modules.init.dto.InitTaskStepResponse;
import com.cisd.tms.modules.init.dto.ResetCreateTaskResponse;
import com.cisd.tms.modules.init.dto.ResetPreviewResponse;
import com.cisd.tms.modules.init.dto.*;
import com.cisd.tms.modules.init.service.InitService;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/init")
@ -66,6 +54,7 @@ public class InitController {
@PostMapping("/tasks")
@Operation(summary = "创建初始化任务", description = "根据初始化请求生成任务和步骤记录,任务初始状态为 PENDING。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.INIT, action = ActionType.CREATE, summary = "创建初始化任务")
public ApiResponse<InitCreateTaskResponse> createTask(@Valid @RequestBody InitPreviewRequest request) {
return ApiResponse.success(initService.createTask(request));
}
@ -115,6 +104,7 @@ public class InitController {
@PostMapping("/tasks/{taskId}/execute")
@Operation(summary = "执行初始化任务", description = "异步受理初始化任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.INIT, action = ActionType.EXECUTE, summary = "执行初始化任务")
public ApiResponse<InitTaskExecuteResponse> executeTask(
@Parameter(description = "初始化任务唯一标识")
@PathVariable String taskId
@ -131,6 +121,7 @@ public class InitController {
@PostMapping("/reset/tasks")
@Operation(summary = "创建重置任务", description = "自动读取当前设备有效初始化快照生成重置任务和步骤记录,任务初始状态为 PENDING。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.INIT, action = ActionType.CREATE, summary = "创建重置任务")
public ApiResponse<ResetCreateTaskResponse> createResetTask() {
return ApiResponse.success(initService.createResetTask());
}
@ -138,6 +129,7 @@ public class InitController {
@PostMapping("/reset/tasks/{taskId}/execute")
@Operation(summary = "执行重置任务", description = "异步受理重置任务执行请求,立即返回当前任务快照,实际步骤在后台继续运行。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.INIT, action = ActionType.EXECUTE, summary = "执行重置任务")
public ApiResponse<InitTaskExecuteResponse> executeResetTask(
@Parameter(description = "重置任务唯一标识")
@PathVariable String taskId

View File

@ -0,0 +1,18 @@
package com.cisd.tms.modules.log.annotation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditedOperation {
ModuleCode module();
ActionType action();
String summary() default "";
boolean sensitive() default true; // 默认都是敏感操作
}

View File

@ -0,0 +1,85 @@
package com.cisd.tms.modules.log.aspect;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.dto.OperationAuditCommand;
import com.cisd.tms.modules.log.enums.OperationResult;
import com.cisd.tms.modules.log.service.OperationAuditService;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Aspect
@Component
public class OperationAuditAspect {
public static final String ATTR_ROLE_CODE = "CURRENT_ROLE_CODE";
public static final String ATTR_AUTH_LEVEL = "CURRENT_AUTH_LEVEL";
public static final String ATTR_SESSION_TOKEN = "CURRENT_SESSION_TOKEN";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String SESSION_HEADER = "X-Session-Token";
private static final int IDLE_TIMEOUT_MINUTES = 10;
@Autowired
private OperationAuditService auditService;
@Around("@annotation(auditedOperation)")
public Object doAround(ProceedingJoinPoint joinPoint, AuditedOperation auditedOperation) throws Throwable {
OperationAuditCommand command = new OperationAuditCommand();
command.setModuleCode(auditedOperation.module());
command.setActionType(auditedOperation.action());
command.setSummary(auditedOperation.summary());
//todo 后续测
// 获取上下文信息
getContextInfo(command);
Object result = null;
try {
result = joinPoint.proceed();
command.setOperationResult(OperationResult.SUCCESS);
return result;
} catch (Throwable e) {
command.setOperationResult(OperationResult.FAILED);
String errorMsg = e.getMessage();
if (errorMsg != null && errorMsg.length() > 1000) {
errorMsg = errorMsg.substring(0, 1000);
}
command.setErrorMessage(errorMsg);
throw e;
} finally {
// 最终一定会调用 record record 内部决定是否因为 sensitive 抛出二次异常阻断
auditService.record(command, auditedOperation.sensitive());
}
}
/**
* 提取请求上下文信息 (Role, AuthLevel, IP)
*/
private void getContextInfo(OperationAuditCommand command) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
Object roleObj = request.getAttribute(ATTR_ROLE_CODE);
command.setOperatorRoleCode(roleObj != null ? roleObj.toString() : "UNKNOWN_ROLE");
Object levelObj = request.getAttribute(ATTR_AUTH_LEVEL);
command.setOperatorAuthLevel(levelObj != null ? levelObj.toString() : "UNKNOWN_AUTH_LEVEL");
command.setRemoteIp(request.getRemoteAddr());
} else {
// HTTP 请求上下文 (如定时任务触发)
command.setOperatorRoleCode("UNKNOWN_ROLE");
command.setOperatorAuthLevel("UNKNOWN_AUTH_LEVEL");
command.setRemoteIp("127.0.0.1");
}
}
}

View File

@ -0,0 +1,38 @@
package com.cisd.tms.modules.log.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.log.dto.BackupConfigRequest;
import com.cisd.tms.modules.log.dto.BackupConfigResponse;
import com.cisd.tms.modules.log.service.BackupConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Tag(name = "审计日志备份配置", description = "管理系统审计日志的定时备份策略")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/audit-logs/backup-config")
public class BackupConfigController {
private final BackupConfigService backupConfigService;
@Operation(summary = "查询全局备份配置", description = "获取系统当前的审计日志备份策略,如果未配置过则返回 null")
@GetMapping
public ApiResponse<BackupConfigResponse> getConfig() {
BackupConfigResponse config = backupConfigService.getConfig();
return ApiResponse.success(config);
}
/**
* 创建或更新备份配置
*/
@Operation(summary = "保存或更新备份配置", description = "创建新的备份配置或覆盖更新已有的全局备份配置")
@PostMapping
public ApiResponse<Void> saveOrUpdateConfig(@Validated @RequestBody BackupConfigRequest req) {
backupConfigService.saveOrUpdateConfig(req);
return ApiResponse.success();
}
}

View File

@ -0,0 +1,62 @@
package com.cisd.tms.modules.log.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.log.dto.AuditLogReviewRequest;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageRequest;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageResponse;
import com.cisd.tms.modules.log.dto.OperationAuditLogResponse;
import com.cisd.tms.modules.log.service.OperationAuditService;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@Tag(name = "审计日志管理", description = "提供系统操作审计日志的查询与审核功能")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/audit-logs")
public class OperationAuditController {
private final OperationAuditService auditService;
@Operation(summary = "分页查询审计日志", description = "根据筛选条件分页获取系统的操作审计日志列表")
@PostMapping("/page")
public ApiResponse<IPage<OperationAuditLogPageResponse>> queryPage(@RequestBody OperationAuditLogPageRequest req) {
IPage<OperationAuditLogPageResponse> pageResult = auditService.queryPage(req);
return ApiResponse.success(pageResult);
}
@Operation(summary = "获取审计日志详情", description = "根据日志的唯一标识 ID 获取单条日志的完整信息")
@GetMapping("/{logId}")
public ApiResponse<OperationAuditLogResponse> getDetail(@PathVariable String logId) {
OperationAuditLogResponse detail = auditService.getDetail(logId);
return ApiResponse.success(detail);
}
@Operation(summary = "审核审计日志", description = "审计管理员对特定的审计日志进行审核操作及意见批注")
@PostMapping("/{logId}/review")
public ApiResponse<Void> reviewLog(@PathVariable String logId, @RequestBody AuditLogReviewRequest req) {
String auditorUser = ""; // 默认
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
Object roleObj = request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE);
// 权限校验仅审计管理员可操作
if (roleObj == null || !roleObj.toString().equals("AUDIT_ADMIN")) {
return ApiResponse.fail(403, "权限不足:仅审计管理员可执行此操作");
}
auditorUser = roleObj.toString();
}
auditService.reviewLog(logId, req, auditorUser);
return ApiResponse.success();
}
}

View File

@ -0,0 +1,17 @@
package com.cisd.tms.modules.log.dto;
import com.cisd.tms.modules.log.enums.AuditResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "日志审计请求参数")
public class AuditLogReviewRequest {
@Schema(description = "审计管理员签名值", example = "MEUC...")
private String auditSign;
@Schema(description = "审计结果 (PASS/ REJECT / NEED_VERIFY)", example = "PASS")
private AuditResult auditResult;
@Schema(description = "审计意见", example = "审核通过")
private String auditComment;
}

View File

@ -0,0 +1,20 @@
package com.cisd.tms.modules.log.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
@Schema(description = "备份策略配置请求参数")
public class BackupConfigRequest {
@Schema(description = "启用状态 (1:启用 0:禁用)", example = "1", defaultValue = "1")
private Integer enable = 1;
@Schema(description = "Cron定时表达式", example = "0 0 2 * * ?", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "Cron表达式不能为空")
private String cronExp;
@Schema(description = "保留备份文件的份数", example = "7", defaultValue = "1")
private Integer retentionCount = 1;
}

View File

@ -0,0 +1,29 @@
package com.cisd.tms.modules.log.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "日志备份配置响应参数")
public class BackupConfigResponse {
@Schema(description = "主键", example = "1")
private Integer id;
@Schema(description = "启用状态 (1:启用 0:禁用)", example = "1")
private Integer enable;
@Schema(description = "Cron定时表达式", example = "0 0 2 * * ?")
private String cronExp;
@Schema(description = "保留备份文件的份数", example = "7")
private Integer retentionCount;
@Schema(description = "最后一次备份时间", type = "string", example = "2026-04-16 09:10:16")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime lastBackupTime;
}

View File

@ -0,0 +1,20 @@
package com.cisd.tms.modules.log.dto;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.modules.log.enums.OperationResult;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
public class OperationAuditCommand {
private String operatorRoleCode;
private String operatorAuthLevel;
private ModuleCode moduleCode;
private ActionType actionType;
private String remoteIp;
private OperationResult operationResult;
private String summary;
private String errorMessage;
}

View File

@ -0,0 +1,57 @@
package com.cisd.tms.modules.log.dto;
import com.cisd.tms.modules.log.enums.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "操作审计日志分页查询请求参数")
public class OperationAuditLogPageRequest {
@Schema(description = "操作人角色", example = "SYSTEM_ADMIN")
private String operatorRoleCode;
@Schema(description = "系统模块", example = "DEVICE")
private ModuleCode moduleCode;
@Schema(description = "操作类型 (如: ADD, UPDATE, DELETE, LOGIN)", example = "UPDATE")
private ActionType actionType;
@Schema(description = "操作发起方IP地址", example = "192.168.1.1")
private String remoteIp;
@Schema(description = "操作执行结果 (SUCCESS/ FAILED/ DENIED)", example = "SUCCESS")
private OperationResult operationResult;
@Schema(description = "操作摘要说明", example = "修改了系统备份策略")
private String summary;
@Schema(description = "审计状态 (PENDING/ REVIEWED)", example = "PENDING")
private AuditStatus auditStatus;
@Schema(description = "审计结果 (PASS / REJECT/ NEED_VERIFY)", example = "PASS")
private AuditResult auditResult;
@Schema(description = "查询开始时间", type = "string", format = "date-time", example = "2026-04-01 00:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime dateFrom;
@Schema(description = "查询结束时间", type = "string", format = "date-time", example = "2026-04-16 23:59:59")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime dateTo;
@Schema(description = "全局模糊搜索关键字 (可匹配操作人、模块、摘要等)", example = "备份")
private String keyword;
@Schema(description = "当前页码", example = "1", defaultValue = "1", requiredMode = Schema.RequiredMode.REQUIRED)
private int pageNum = 1;
@Schema(description = "每页显示条数", example = "10", defaultValue = "10", requiredMode = Schema.RequiredMode.REQUIRED)
private int pageSize = 10;
// @Schema(description = "记录落库时间", type = "string", format = "date-time")
// private Date createTime;
}

View File

@ -0,0 +1,71 @@
package com.cisd.tms.modules.log.dto;
import com.cisd.tms.modules.log.enums.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "操作审计日志分页列表响应对象")
public class OperationAuditLogPageResponse {
@Schema(description = "日志唯一标识ID", example = "1791234567890123456")
private String logId;
@Schema(description = "操作人角色", example = "SYSTEM_ADMIN")
private String operatorRoleCode;
@Schema(description = "系统模块", example = "DEVICE")
private ModuleCode moduleCode;
@Schema(description = "操作人认证等级", example = "FULL")
private String operatorAuthLevel;
@Schema(description = "操作类型 (如: ADD, UPDATE, DELETE)", example = "UPDATE")
private ActionType actionType;
@Schema(description = "操作发起方IP地址", example = "192.168.1.100")
private String remoteIp;
@Schema(description = "操作执行结果 (SUCCESS/ FAILED/ DENIED)", example = "SUCCESS")
private OperationResult operationResult;
@Schema(description = "操作摘要说明", example = "修改了系统备份策略配置")
private String summary;
@Schema(description = "错误信息", example = "数据库连接超时")
private String errorMessage;
@Schema(description = "审计状态 (PENDING / REVIEWED)", example = "REVIEWED")
private AuditStatus auditStatus;
@Schema(description = "审计结果 (PASS/ REJECT / NEED_VERIFY)", example = "PASS")
private AuditResult auditResult;
@Schema(description = "审计意见", example = "审核通过")
private String auditComment;
@Schema(description = "审计人", example = "AUDIT_ADMIN")
private String auditedBy;
@Schema(description = "审核完成时间", type = "string", example = "2026-04-16 10:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime auditedAt;
// @Schema(description = "请求数据体 SM3 摘要", example = "1ab2c3d4e5f6...")
// private String payloadHash; // SM3摘要
@Schema(description = "请求 SM2 签名值", example = "MEUC...")
private String signValue; // SM2签名值
@Schema(description = "操作实际发生时间", type = "string", example = "2026-04-16 09:10:16")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime occurredAt; // 发生时间
// @Schema(description = "记录落库时间", type = "string", example = "2026-04-16 09:10:20")
// @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
// private Date createTime; // 落库时间
}

View File

@ -0,0 +1,66 @@
package com.cisd.tms.modules.log.dto;
import com.cisd.tms.modules.log.enums.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Schema(description = "操作审计日志详情响应对象")
public class OperationAuditLogResponse {
@Schema(description = "操作人角色代码", example = "SYSTEM_ADMIN")
private String operatorRoleCode;
@Schema(description = "操作人认证等级", example = "LEVEL_2")
private String operatorAuthLevel;
@Schema(description = "系统模块代码", example = "LOG_MANAGEMENT")
private ModuleCode moduleCode;
@Schema(description = "操作类型", example = "UPDATE")
private ActionType actionType;
@Schema(description = "操作发起方IP地址", example = "192.168.1.100")
private String remoteIp;
@Schema(description = "操作执行结果 (SUCCESS/ FAILED / DENIED)", example = "SUCCESS")
private OperationResult operationResult;
@Schema(description = "操作摘要说明", example = "修改了系统备份策略配置")
private String summary;
@Schema(description = "错误信息(仅操作失败时存在)", example = "数据库连接超时")
private String errorMessage;
@Schema(description = "审计状态 (PENDING / REVIEWED)", example = "REVIEWED")
private AuditStatus auditStatus;
@Schema(description = "审计结果 (PASS/ REJECT/ NEED_VERIFY)", example = "PASS")
private AuditResult auditResult;
@Schema(description = "审计意见", example = "审核通过")
private String auditComment;
@Schema(description = "审计人", example = "AUDIT_ADMIN")
private String auditedBy;
@Schema(description = "审计完成时间", type = "string", format = "date-time", example = "2026-04-16 23:59:59")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime auditedAt;
// @Schema(description = "请求数据体 SM3 摘要", example = "1ab2c3d4e5f6...")
// private String payloadHash; // SM3摘要
@Schema(description = "请求 SM2 签名值", example = "MEUC...")
private String signValue; // SM2签名值
@Schema(description = "操作实际发生时间", type = "string", format = "date-time", example = "2026-04-16 23:59:59")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime occurredAt; // 发生时间
// @Schema(description = "记录落库时间", type = "string", format = "date-time", example = "2026-04-16T09:10:20")
// private Date createTime; // 落库时间
}

View File

@ -0,0 +1,46 @@
package com.cisd.tms.modules.log.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 审计日志备份策略配置实体类
*/
@Data
@Accessors(chain = true)
@TableName("tms_backup_config")
public class BackupConfigEntity{
/**
* 主键
*/
private Integer id;
/**
* 启用状态 1:启用 0:禁用 (非空, 默认 0)
*/
private Integer enable;
/**
* Cron表达式
*/
private String cronExp;
/**
* 保留份数 (默认 1)
*/
private Integer retentionCount;
/**
* 上次执行时间
*/
private LocalDateTime lastBackupTime;
/**
* 其他字段的签名值 (防篡改)
*/
private String signData;
}

View File

@ -0,0 +1,53 @@
package com.cisd.tms.modules.log.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
@TableName("tms_log_backup")
@Data
@Accessors(chain = true)
public class BackupRecordEntity {
/**
* 主键
*/
private Long id;
/**
* 文件名
*/
private String fileName;
/**
* 存储路径
*/
private String filePath;
/**
* 文件哈希
*/
private String fileHash;
/**
* 文件签名
*/
private String fileSign;
/**
* 状态 SUCCESS/DELETED
*/
private String status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 其他字段签名值
*/
private String signData;
}

View File

@ -0,0 +1,36 @@
package com.cisd.tms.modules.log.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cisd.tms.modules.log.enums.*;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
@Data
@Accessors(chain = true)
@TableName("tms_operation_audit_log")
public class OperationAuditLogEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String logId;
private String operatorRoleCode;
private String operatorAuthLevel;
private ModuleCode moduleCode;
private ActionType actionType;
private String remoteIp;
private OperationResult operationResult;
private String summary;
private String errorMessage;
private AuditStatus auditStatus;
private AuditResult auditResult;
private String auditComment;
private String auditedBy;
private LocalDateTime auditedAt;
private String payloadHash; // SM3摘要
private String signValue; // SM2签名值
private LocalDateTime occurredAt; // 发生时间
private LocalDateTime createTime; // 落库时间
}

View File

@ -0,0 +1,5 @@
package com.cisd.tms.modules.log.enums;
public enum ActionType {
LOGIN, LOGOUT, CREATE, UPDATE, DELETE, EXECUTE, RESET, ENABLE, DISABLE, BIND, IMPORT, EXPORT, BACKUP, RECOVER, RESTART, ACCESS, ROLLBACK
}

View File

@ -0,0 +1,7 @@
package com.cisd.tms.modules.log.enums;
public enum AuditResult {
PASS,
REJECT,
NEED_VERIFY
}

View File

@ -0,0 +1,5 @@
package com.cisd.tms.modules.log.enums;
public enum AuditStatus {
PENDING, REVIEWED
}

View File

@ -0,0 +1,5 @@
package com.cisd.tms.modules.log.enums;
public enum ModuleCode {
AUTH, INIT, UPGRADE, DEVICE, NETWORK, KEY, SYSTEM, SECURITY
}

View File

@ -0,0 +1,5 @@
package com.cisd.tms.modules.log.enums;
public enum OperationResult {
SUCCESS, FAILED, DENIED
}

View File

@ -0,0 +1,9 @@
package com.cisd.tms.modules.log.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.log.entity.BackupConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BackupConfigMapper extends BaseMapperX<BackupConfigEntity> {
}

View File

@ -0,0 +1,9 @@
package com.cisd.tms.modules.log.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.log.entity.BackupRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BackupRecordMapper extends BaseMapperX<BackupRecordEntity> {
}

View File

@ -0,0 +1,9 @@
package com.cisd.tms.modules.log.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.log.entity.OperationAuditLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OperationAuditLogMapper extends BaseMapperX<OperationAuditLogEntity> {
}

View File

@ -0,0 +1,11 @@
package com.cisd.tms.modules.log.repository;
import com.cisd.tms.modules.log.entity.BackupConfigEntity;
import java.util.Optional;
public interface BackupConfigRepository {
Optional<BackupConfigEntity> find();
void saveOrUpdate(BackupConfigEntity backupConfig);
}

View File

@ -0,0 +1,7 @@
package com.cisd.tms.modules.log.repository;
import com.cisd.tms.modules.log.entity.BackupRecordEntity;
public interface BackupRecordRepository {
void add(BackupRecordEntity backupRecordEntity);
}

View File

@ -0,0 +1,16 @@
package com.cisd.tms.modules.log.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageRequest;
import com.cisd.tms.modules.log.entity.OperationAuditLogEntity;
import java.util.List;
import java.util.Optional;
public interface OperationAuditLogRepository {
void add(OperationAuditLogEntity auditLogEntity);
IPage<OperationAuditLogEntity> findPage(OperationAuditLogPageRequest req);
Optional<OperationAuditLogEntity> findByLogId(String logId);
void update(OperationAuditLogEntity entity);
Optional<List<OperationAuditLogEntity>> findByAuditStatus (String auditStatus);
}

View File

@ -0,0 +1,38 @@
package com.cisd.tms.modules.log.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.cisd.tms.modules.log.entity.BackupConfigEntity;
import com.cisd.tms.modules.log.mapper.BackupConfigMapper;
import com.cisd.tms.modules.log.repository.BackupConfigRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class BackupConfigRepositoryImpl implements BackupConfigRepository {
private final BackupConfigMapper backupConfigMapper;
public BackupConfigRepositoryImpl(BackupConfigMapper backupConfigMapper) {
this.backupConfigMapper = backupConfigMapper;
}
@Override
public Optional<BackupConfigEntity> find(){
LambdaQueryWrapper<BackupConfigEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.last("LIMIT 1");
BackupConfigEntity entity = backupConfigMapper.selectOne(wrapper);
return Optional.ofNullable(entity);
}
@Override
public void saveOrUpdate (BackupConfigEntity backupConfig) {
if (backupConfig.getId() == null) {
backupConfigMapper.insert(backupConfig);
} else {
backupConfigMapper.updateById(backupConfig);
}
}
}

View File

@ -0,0 +1,21 @@
package com.cisd.tms.modules.log.repository.impl;
import com.cisd.tms.modules.log.entity.BackupRecordEntity;
import com.cisd.tms.modules.log.mapper.BackupRecordMapper;
import com.cisd.tms.modules.log.repository.BackupRecordRepository;
import org.springframework.stereotype.Repository;
@Repository
public class BackupRecordRepositoryImpl implements BackupRecordRepository {
public final BackupRecordMapper backupRecordMapper;
public BackupRecordRepositoryImpl(BackupRecordMapper backupRecordMapper) {
this.backupRecordMapper = backupRecordMapper;
}
@Override
public void add(BackupRecordEntity backupRecordEntity){
backupRecordMapper.insert(backupRecordEntity);
}
}

View File

@ -0,0 +1,85 @@
package com.cisd.tms.modules.log.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageRequest;
import com.cisd.tms.modules.log.entity.OperationAuditLogEntity;
import com.cisd.tms.modules.log.mapper.OperationAuditLogMapper;
import com.cisd.tms.modules.log.repository.OperationAuditLogRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class OperationAuditLogRepositoryImpl implements OperationAuditLogRepository {
private final OperationAuditLogMapper operationAuditLogMapper;
public OperationAuditLogRepositoryImpl(OperationAuditLogMapper operationAuditLogMapper) {
this.operationAuditLogMapper= operationAuditLogMapper;
}
@Override
public void add(OperationAuditLogEntity operationAuditLogEntity) {
operationAuditLogMapper.insert(operationAuditLogEntity);
}
@Override
public IPage<OperationAuditLogEntity> findPage(OperationAuditLogPageRequest req){
IPage<OperationAuditLogEntity> page = new Page<>(req.getPageNum(), req.getPageSize());
LambdaQueryWrapper<OperationAuditLogEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.isNotBlank(req.getOperatorRoleCode()), OperationAuditLogEntity::getOperatorRoleCode, req.getOperatorRoleCode())
.eq(req.getModuleCode() != null, OperationAuditLogEntity::getModuleCode, req.getModuleCode())
.eq(req.getActionType() != null, OperationAuditLogEntity::getActionType, req.getActionType())
.eq(req.getOperationResult() != null, OperationAuditLogEntity::getOperationResult, req.getOperationResult())
.eq(req.getAuditStatus() != null, OperationAuditLogEntity::getAuditStatus, req.getAuditStatus())
.eq(req.getAuditResult() != null, OperationAuditLogEntity::getAuditResult, req.getAuditResult())
.eq(StringUtils.isNotBlank(req.getRemoteIp()), OperationAuditLogEntity::getRemoteIp, req.getRemoteIp())
.eq(StringUtils.isNotBlank(req.getKeyword()), OperationAuditLogEntity::getSummary, req.getKeyword());
if (req.getDateFrom() != null) {
wrapper.ge(OperationAuditLogEntity::getOccurredAt, req.getDateFrom());
}
if (req.getDateTo() != null) {
wrapper.le(OperationAuditLogEntity::getOccurredAt, req.getDateTo());
}
// 搜索摘要
if (StringUtils.isNotBlank(req.getKeyword())) {
wrapper.like(OperationAuditLogEntity::getSummary, req.getKeyword());
}
wrapper.orderByDesc(OperationAuditLogEntity::getOccurredAt);
IPage<OperationAuditLogEntity> entityPage = operationAuditLogMapper.selectPage(page, wrapper);
return entityPage;
}
@Override
public Optional<OperationAuditLogEntity> findByLogId(String logId){
LambdaQueryWrapper<OperationAuditLogEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OperationAuditLogEntity::getLogId, logId);
OperationAuditLogEntity entity = operationAuditLogMapper.selectOne(wrapper);
return Optional.ofNullable(entity);
}
@Override
public void update(OperationAuditLogEntity entity) {
operationAuditLogMapper.updateById(entity);
}
@Override
public Optional<List<OperationAuditLogEntity>> findByAuditStatus(String auditStatus) {
QueryWrapper<OperationAuditLogEntity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("audit_status", auditStatus);
List<OperationAuditLogEntity> logList = operationAuditLogMapper.selectList(queryWrapper);
return Optional.ofNullable(logList);
}
}

View File

@ -0,0 +1,51 @@
package com.cisd.tms.modules.log.service;
import com.cisd.tms.modules.log.dto.BackupConfigRequest;
import com.cisd.tms.modules.log.dto.BackupConfigResponse;
import com.cisd.tms.modules.log.entity.BackupConfigEntity;
import com.cisd.tms.modules.log.repository.BackupConfigRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class BackupConfigService {
private final BackupConfigRepository backupConfigRepository;
public BackupConfigResponse getConfig(){
BackupConfigEntity entity = backupConfigRepository.find().orElse(null);
BackupConfigResponse response = new BackupConfigResponse();
if (entity != null){
response.setId(entity.getId());
response.setEnable(entity.getEnable());
response.setCronExp(entity.getCronExp());
response.setLastBackupTime(entity.getLastBackupTime());
}
return response;
}
public void saveOrUpdateConfig(BackupConfigRequest req){
String cronExp = trim(req.getCronExp());
if (!CronExpression.isValidExpression(cronExp)) {
throw new IllegalArgumentException("Cron表达式格式不正确请重新输入");
}
BackupConfigEntity entity = new BackupConfigEntity();
BackupConfigResponse resp = getConfig();
if (resp != null){
resp.setId(entity.getId());
resp.setLastBackupTime(entity.getLastBackupTime());
}
entity.setEnable(req.getEnable());
entity.setCronExp(req.getCronExp());
entity.setRetentionCount(req.getRetentionCount());
backupConfigRepository.saveOrUpdate(entity);
}
private String trim(String str) {
return str == null ? "" : str.trim();
}
}

View File

@ -0,0 +1,231 @@
package com.cisd.tms.modules.log.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.dto.*;
import com.cisd.tms.modules.log.entity.OperationAuditLogEntity;
import com.cisd.tms.modules.log.enums.AuditStatus;
import com.cisd.tms.modules.log.enums.OperationResult;
import com.cisd.tms.modules.log.repository.OperationAuditLogRepository;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.bouncycastle.util.encoders.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.method.HandlerMethod;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OperationAuditService {
public static final String ATTR_ROLE_CODE = "CURRENT_ROLE_CODE";
public static final String ATTR_AUTH_LEVEL = "CURRENT_AUTH_LEVEL";
private static final Logger log = LoggerFactory.getLogger(OperationAuditService.class);
private final OperationAuditLogRepository operationAuditLogRepository;
private final OperationAuditSigner auditSigner;
private final PcieCryptoService pcieCryptoService;
public void record(OperationAuditCommand command, boolean isSensitive) {
try {
OperationAuditLogEntity entity = new OperationAuditLogEntity();
entity.setOperatorRoleCode(trim(command.getOperatorRoleCode()))
.setOperatorAuthLevel(trim(command.getOperatorAuthLevel()))
.setModuleCode(command.getModuleCode())
.setActionType(command.getActionType())
.setRemoteIp(trim(command.getRemoteIp()))
.setOperationResult(command.getOperationResult())
.setSummary(trim(command.getSummary()))
.setErrorMessage(trim(command.getErrorMessage()));
LocalDateTime now = LocalDateTime.now();
entity.setLogId(UUID.randomUUID().toString().replace("-", ""))
.setOccurredAt(now)
.setCreateTime(now)
.setAuditStatus(AuditStatus.PENDING);
//todo 后续测试签名
auditSigner.signEntity(entity);
operationAuditLogRepository.add(entity);
} catch (Exception e) {
log.error("审计日志安全写入失败: {}", e.getMessage(), e);
// 敏感操作打断业务
if (isSensitive) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "安全审计日志写入失败");
}
}
}
public void recordDeniedLog(HttpServletRequest request, HandlerMethod handlerMethod, String errorMsg) {
AuditedOperation auditedOperation = findAnnotation(handlerMethod, AuditedOperation.class);
if (auditedOperation != null) {
OperationAuditCommand command = new OperationAuditCommand();
command.setModuleCode(auditedOperation.module());
command.setActionType(auditedOperation.action());
command.setSummary(auditedOperation.summary());
// 从上下文中获取刚才解析出的用户信息
Object roleObj = request.getAttribute(ATTR_ROLE_CODE);
command.setOperatorRoleCode(roleObj != null ? roleObj.toString() : "UNKNOWN");
Object levelObj = request.getAttribute(ATTR_AUTH_LEVEL);
command.setOperatorAuthLevel(levelObj != null ? levelObj.toString() : "UNKNOWN");
command.setRemoteIp(request.getRemoteAddr());
command.setErrorMessage(errorMsg);
command.setOperationResult(OperationResult.DENIED);
try {
record(command, false);
} catch (Exception e) {
log.error("拦截器记录越权审计日志失败: {}", e.getMessage());
}
}
}
private <T extends java.lang.annotation.Annotation> T findAnnotation(HandlerMethod handlerMethod, Class<T> annotationType) {
T methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getMethod(), annotationType);
if (methodAnnotation != null) {
return methodAnnotation;
}
return AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), annotationType);
}
public IPage<OperationAuditLogPageResponse> queryPage(OperationAuditLogPageRequest req) {
IPage<OperationAuditLogEntity> entityPage = operationAuditLogRepository.findPage(req);
return entityPage.convert(entity -> {
OperationAuditLogPageResponse resp = new OperationAuditLogPageResponse();
resp.setLogId(entity.getLogId());
resp.setOperatorAuthLevel(entity.getOperatorAuthLevel());
resp.setOperatorRoleCode(entity.getOperatorRoleCode());
resp.setModuleCode(entity.getModuleCode());
resp.setActionType(entity.getActionType());
resp.setOperationResult(entity.getOperationResult());
resp.setErrorMessage(entity.getErrorMessage());
resp.setAuditStatus(entity.getAuditStatus());
resp.setAuditResult(entity.getAuditResult());
resp.setAuditComment(entity.getAuditComment());
resp.setAuditedBy(entity.getAuditedBy());
resp.setRemoteIp(entity.getRemoteIp());
resp.setErrorMessage(entity.getErrorMessage());
resp.setOccurredAt(entity.getOccurredAt());
return resp;
});
}
public OperationAuditLogResponse getDetail(String logId) {
logId = trim(logId);
if (logId.isEmpty()){
throw new IllegalArgumentException("logId不能为空");
}
OperationAuditLogEntity entity = operationAuditLogRepository.findByLogId(logId)
.orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "未找到该logId"));
OperationAuditLogResponse resp = new OperationAuditLogResponse();
BeanUtils.copyProperties(entity, resp);
return resp;
}
public void reviewLog(String logId, AuditLogReviewRequest req, String auditorUser) {
logId = trim(logId);
if (logId.isEmpty()){
throw new IllegalArgumentException("logId不能为空");
}
OperationAuditLogEntity existLog = operationAuditLogRepository.findByLogId(logId)
.orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "未找到该logId"));
String signValue = trim(req.getAuditSign());
if (signValue.isEmpty()){
throw new IllegalArgumentException("签名值不能为空");
}
//todo 验签
EccInternalVerifyRequest VerifyRequest = new EccInternalVerifyRequest();
// todo后续更改
VerifyRequest.setKeyIndex(1);
VerifyRequest.setData(req.getAuditSign().getBytes(StandardCharsets.UTF_8));
VerifyRequest.setSignature(decodeBlob(signValue, "signature is invalid"));
try {
pcieCryptoService.eccInternalVerify(VerifyRequest);
} catch (BizException ex) {
throw ex;
} catch (RuntimeException ex) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "signature verification failed");
}
if (!AuditStatus.PENDING.equals(existLog.getAuditStatus())) {
throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "该日志已审计,禁止重复操作");
}
OperationAuditLogEntity updateEntity = new OperationAuditLogEntity();
updateEntity.setLogId(existLog.getLogId());
updateEntity.setAuditStatus(AuditStatus.REVIEWED);
updateEntity.setAuditResult(req.getAuditResult());
updateEntity.setAuditComment(trim(req.getAuditComment()));
updateEntity.setAuditedBy(auditorUser);
updateEntity.setAuditedAt(LocalDateTime.now());
// 4. 执行更新
operationAuditLogRepository.update(updateEntity);
log.info("日志复核完成, logId: {}, 审计人: {}, 结果: {}, {}", logId, auditorUser, req.getAuditResult(), req.getAuditComment());
}
private byte[] decodeBlob(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), message);
}
if (normalized.matches("(?i)^[0-9a-f]+$") && normalized.length() % 2 == 0) {
return Hex.decode(normalized);
}
try {
return Base64.getDecoder().decode(normalized);
} catch (IllegalArgumentException ex) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), message);
}
}
private String trim(String s){
return s == null ? "" : s.trim();
}
}

View File

@ -0,0 +1,73 @@
package com.cisd.tms.modules.log.service;
import com.cisd.tms.integration.crypto.pcie.Gm0018AlgorithmIds;
import com.cisd.tms.integration.crypto.pcie.model.BackupDataResult;
import com.cisd.tms.integration.crypto.pcie.model.DigestRequest;
import com.cisd.tms.integration.crypto.pcie.model.EccInternalSignRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.log.entity.OperationAuditLogEntity;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
@Component
public class OperationAuditSigner {
private final PcieCryptoService pcieCryptoService;
OperationAuditSigner(PcieCryptoService pcieCryptoService) {
this.pcieCryptoService = pcieCryptoService;
}
public String buildAuditPayload(OperationAuditLogEntity entity) {
return "log_id=" + trim(entity.getLogId()) + "\n" +
"operator_role_code=" + trim(entity.getOperatorRoleCode()) + "\n" +
"operator_auth_level=" + trim(entity.getOperatorAuthLevel()) + "\n" +
"module_code=" + entity.getModuleCode() + "\n" +
"action_type=" + entity.getActionType() + "\n" +
"remote_ip=" + trim(entity.getRemoteIp()) + "\n" +
"operation_result=" + entity.getOperationResult() + "\n" +
"summary=" + trim(entity.getSummary()) + "\n" +
"occurred_at=" + formatUtc(entity.getOccurredAt());
}
//todo 生成签名
public void signEntity(OperationAuditLogEntity entity) {
String payload = buildAuditPayload(entity);
//计算SM3哈希
DigestRequest request = new DigestRequest();
request.setAlgId(Gm0018AlgorithmIds.SM3);
request.setData(payload.getBytes(StandardCharsets.UTF_8));
BackupDataResult payloadHash = pcieCryptoService.digest(request);
entity.setPayloadHash(Base64.getEncoder().encodeToString(payloadHash.getData()));
// 3. 计算 SM2 签名值
EccInternalSignRequest signRequest = new EccInternalSignRequest();
signRequest.setData(payloadHash.getData());
signRequest.setKeyIndex(1);
BackupDataResult sign = pcieCryptoService.eccInternalSign(signRequest);
entity.setSignValue(Base64.getEncoder().encodeToString(sign.getData()));
}
private String formatUtc(LocalDateTime date) {
if (date == null) {
return "";
}
ZonedDateTime utcTime = date.atZone(ZoneOffset.UTC);
return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
.format(utcTime);
}
private String trim(String str) {
return str == null ? "" : str.trim();
}
}

View File

@ -3,13 +3,10 @@ package com.cisd.tms.modules.upgrade.controller;
import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.modules.file.dto.FileUploadResponse;
import com.cisd.tms.modules.file.service.FileService;
import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskRequest;
import com.cisd.tms.modules.upgrade.dto.UpgradeCreateTaskResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeLogResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradePreviewRequest;
import com.cisd.tms.modules.upgrade.dto.UpgradePreviewResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskPageResponse;
import com.cisd.tms.modules.upgrade.dto.UpgradeTaskResponse;
import com.cisd.tms.modules.log.annotation.AuditedOperation;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.modules.upgrade.dto.*;
import com.cisd.tms.modules.upgrade.service.UpgradeService;
import com.cisd.tms.security.internal.ReplayProtected;
import io.swagger.v3.oas.annotations.Operation;
@ -17,13 +14,7 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@ -54,6 +45,7 @@ public class UpgradeController {
@PostMapping("/upgrades")
@Operation(summary = "创建离线升级任务", description = "根据 fileId 创建离线升级任务,状态初始为 PENDING_CONFIRM。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.UPGRADE, action = ActionType.CREATE, summary = "创建离线升级任务")
public ApiResponse<UpgradeCreateTaskResponse> create(@Valid @RequestBody UpgradeCreateTaskRequest request) {
return ApiResponse.success(upgradeService.createTask(request));
}
@ -61,6 +53,7 @@ public class UpgradeController {
@PostMapping("/upgrades/{taskId}/execute")
@Operation(summary = "执行离线升级任务", description = "异步受理升级执行请求,立即返回任务当前快照。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.UPGRADE, action = ActionType.EXECUTE, summary = "执行离线升级任务")
public ApiResponse<UpgradeTaskResponse> execute(
@Parameter(description = "升级任务号")
@PathVariable String taskId
@ -71,6 +64,7 @@ public class UpgradeController {
@PostMapping("/upgrades/{taskId}/rollback")
@Operation(summary = "回滚离线升级任务", description = "异步受理升级回滚请求,立即返回任务当前快照。")
@ReplayProtected
@AuditedOperation(module = ModuleCode.UPGRADE, action = ActionType.ROLLBACK, summary = "回滚离线升级任务")
public ApiResponse<UpgradeTaskResponse> rollback(
@Parameter(description = "升级任务号")
@PathVariable String taskId

View File

@ -1,84 +1,86 @@
package com.cisd.tms.modules.auth.security;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.method.HandlerMethod;
class InternalAuthorizationInterceptorTest {
@Test
void shouldAllowRequestWhenHandlerHasNoAuthorizationAnnotations() throws Exception {
InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/public");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, handler("openEndpoint"));
Assertions.assertTrue(allowed);
}
@Test
void shouldRejectWhenRoleDoesNotMatchRequiredRole() throws Exception {
InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/enable");
request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "AUDIT_ADMIN");
request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, handler("keyAdminFullEndpoint"));
Assertions.assertFalse(allowed);
Assertions.assertEquals(403, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("role not allowed"));
}
@Test
void shouldRejectWhenAuthLevelDoesNotMatchRequirement() throws Exception {
InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/reset-password");
request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "KEY_ADMIN");
request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "LIMITED");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, handler("keyAdminFullEndpoint"));
Assertions.assertFalse(allowed);
Assertions.assertEquals(403, response.getStatus());
Assertions.assertTrue(response.getContentAsString().contains("auth level not allowed"));
}
@Test
void shouldAllowWhenRoleAndAuthLevelMatch() throws Exception {
InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/reset-password");
request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "KEY_ADMIN");
request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL");
MockHttpServletResponse response = new MockHttpServletResponse();
boolean allowed = interceptor.preHandle(request, response, handler("keyAdminFullEndpoint"));
Assertions.assertTrue(allowed);
}
private HandlerMethod handler(String methodName) throws NoSuchMethodException {
DemoController controller = new DemoController();
Method method = DemoController.class.getDeclaredMethod(methodName);
return new HandlerMethod(controller, method);
}
@SuppressWarnings("unused")
private static class DemoController {
public void openEndpoint() {
}
@RequireInternalAuth(role = com.cisd.tms.modules.auth.enums.RoleCode.KEY_ADMIN, authLevel = com.cisd.tms.modules.auth.enums.AuthLevel.FULL)
public void keyAdminFullEndpoint() {
}
}
}
//package com.cisd.tms.modules.auth.security;
//
//import com.cisd.tms.modules.log.service.OperationAuditService;
//import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
//import com.fasterxml.jackson.databind.ObjectMapper;
//import org.junit.jupiter.api.Assertions;
//import org.junit.jupiter.api.Test;
//import org.springframework.mock.web.MockHttpServletRequest;
//import org.springframework.mock.web.MockHttpServletResponse;
//import org.springframework.web.method.HandlerMethod;
//
//import java.lang.reflect.Method;
//
//class InternalAuthorizationInterceptorTest {
//
// @Test
// void shouldAllowRequestWhenHandlerHasNoAuthorizationAnnotations() throws Exception {
// InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
// MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/public");
// MockHttpServletResponse response = new MockHttpServletResponse();
//
// boolean allowed = interceptor.preHandle(request, response, handler("openEndpoint"));
//
// Assertions.assertTrue(allowed);
// }
//
// @Test
// void shouldRejectWhenRoleDoesNotMatchRequiredRole() throws Exception {
// InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
// MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/enable");
// request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "AUDIT_ADMIN");
// request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL");
// MockHttpServletResponse response = new MockHttpServletResponse();
//
// boolean allowed = interceptor.preHandle(request, response, handler("keyAdminFullEndpoint"));
//
// Assertions.assertFalse(allowed);
// Assertions.assertEquals(403, response.getStatus());
// Assertions.assertTrue(response.getContentAsString().contains("role not allowed"));
// }
//
// @Test
// void shouldRejectWhenAuthLevelDoesNotMatchRequirement() throws Exception {
// InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
// MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/reset-password");
// request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "KEY_ADMIN");
// request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "LIMITED");
// MockHttpServletResponse response = new MockHttpServletResponse();
//
// boolean allowed = interceptor.preHandle(request, response, handler("keyAdminFullEndpoint"));
//
// Assertions.assertFalse(allowed);
// Assertions.assertEquals(403, response.getStatus());
// Assertions.assertTrue(response.getContentAsString().contains("auth level not allowed"));
// }
//
// @Test
// void shouldAllowWhenRoleAndAuthLevelMatch() throws Exception {
// InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper());
// MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/reset-password");
// request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "KEY_ADMIN");
// request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL");
// MockHttpServletResponse response = new MockHttpServletResponse();
//
// boolean allowed = interceptor.preHandle(request, response, handler("keyAdminFullEndpoint"));
//
// Assertions.assertTrue(allowed);
// }
//
// private HandlerMethod handler(String methodName) throws NoSuchMethodException {
// DemoController controller = new DemoController();
// Method method = DemoController.class.getDeclaredMethod(methodName);
// return new HandlerMethod(controller, method);
// }
//
// @SuppressWarnings("unused")
// private static class DemoController {
//
// public void openEndpoint() {
// }
//
// @RequireInternalAuth(role = com.cisd.tms.modules.auth.enums.RoleCode.KEY_ADMIN, authLevel = com.cisd.tms.modules.auth.enums.AuthLevel.FULL)
// public void keyAdminFullEndpoint() {
// }
// }
//}

View File

@ -0,0 +1,99 @@
package com.cisd.tms.modules.log.aspect;
import com.cisd.tms.common.config.WebMvcConfig;
import com.cisd.tms.modules.device.controller.NetworkConfigController;
import com.cisd.tms.modules.device.service.NetworkConfigService;
import com.cisd.tms.modules.log.dto.OperationAuditCommand;
import com.cisd.tms.modules.log.service.OperationAuditService;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.cisd.tms.security.internal.InternalApiReplayInterceptor;
import com.cisd.tms.security.openapi.OpenApiSignAuthInterceptor;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.eq;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = NetworkConfigController.class,
excludeAutoConfiguration = {SecurityAutoConfiguration.class},
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = {WebMvcConfig.class}
))
@Import(OperationAuditAspect.class)
@EnableAspectJAutoProxy(proxyTargetClass = true)
class OperationAuditAspectTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private NetworkConfigService networkConfigService;
// 4. Mock 掉切面依赖的审计 Service (我们就是要验证它有没有被调用)
@MockitoBean
private OperationAuditService auditService;
@MockitoBean
private InternalApiAuthInterceptor internalApiAuthInterceptor;
@MockitoBean
private InternalApiReplayInterceptor internalApiReplayInterceptor;
@MockitoBean
private OpenApiSignAuthInterceptor openApiSignAuthInterceptor;
@Test
void testAuditLog_OnSuccess() throws Exception {
Mockito.when(networkConfigService.getNetworkInfo())
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/v1/device/network-config/network-info")
.requestAttr(OperationAuditAspect.ATTR_ROLE_CODE, "SYS_ADMIN")
.requestAttr(OperationAuditAspect.ATTR_AUTH_LEVEL, "FULL"))
.andExpect(status().isOk());
ArgumentCaptor<OperationAuditCommand> cmdCaptor = ArgumentCaptor.forClass(OperationAuditCommand.class);
Mockito.verify(auditService, Mockito.times(1))
.record(cmdCaptor.capture(), eq(true));
OperationAuditCommand actualCmd = cmdCaptor.getValue();
Assertions.assertEquals("NETWORK", actualCmd.getModuleCode(), "模块名应从注解中获取");
Assertions.assertEquals("get", actualCmd.getActionType(), "动作类型应从注解中获取");
Assertions.assertEquals("获取网络信息", actualCmd.getSummary(), "摘要应从注解中获取");
Assertions.assertEquals("SUCCESS", actualCmd.getOperationResult(), "方法执行成功,结果应为 SUCCESS");
}
@Test
void testAuditLog_OnException() throws Exception {
Mockito.when(networkConfigService.getNetworkInfo())
.thenThrow(new RuntimeException("获取网卡信息超时报错啦!"));
try {
mockMvc.perform(get("/api/v1/device/network-config/network-info"));
} catch (Exception e) {
}
ArgumentCaptor<OperationAuditCommand> cmdCaptor = ArgumentCaptor.forClass(OperationAuditCommand.class);
Mockito.verify(auditService, Mockito.times(1))
.record(cmdCaptor.capture(), eq(true));
OperationAuditCommand actualCmd = cmdCaptor.getValue();
Assertions.assertEquals("NETWORK", actualCmd.getModuleCode());
Assertions.assertEquals("FAILED", actualCmd.getOperationResult(), "抛出异常,结果应为 FAILED");
Assertions.assertEquals("获取网卡信息超时报错啦!", actualCmd.getErrorMessage(), "应该截获到异常信息");
}
}

View File

@ -0,0 +1,115 @@
package com.cisd.tms.modules.log.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.modules.log.dto.AuditLogReviewRequest;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageRequest;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageResponse;
import com.cisd.tms.modules.log.dto.OperationAuditLogResponse;
import com.cisd.tms.modules.log.enums.AuditResult;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.modules.log.service.OperationAuditService;
import com.cisd.tms.security.internal.InternalApiAuthInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class OperationAuditControllerTest {
private MockMvc mockMvc;
private OperationAuditService auditService;
private ObjectMapper objectMapper;
private final String BASE_URL = "/api/v1/audit-logs";
@BeforeEach
void setUp() {
auditService = Mockito.mock(OperationAuditService.class);
objectMapper = new ObjectMapper();
mockMvc = MockMvcBuilders
.standaloneSetup(new OperationAuditController(auditService))
.build();
}
@Test
void testQueryPage_Success() throws Exception {
OperationAuditLogPageRequest req = new OperationAuditLogPageRequest();
req.setModuleCode(ModuleCode.NETWORK);
req.setPageNum(1);
req.setPageSize(10);
Page<OperationAuditLogPageResponse> mockPage = new Page<>(1, 10);
OperationAuditLogPageResponse item = new OperationAuditLogPageResponse();
item.setLogId("log-123");
item.setModuleCode(ModuleCode.NETWORK);
mockPage.setRecords(Collections.singletonList(item));
mockPage.setTotal(1);
Mockito.when(auditService.queryPage(eq(req))).thenReturn(mockPage);
mockMvc.perform(post(BASE_URL)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.records[0].logId").value("log-123"));
}
@Test
void testGetDetail_Success() throws Exception {
OperationAuditLogResponse mockDetail = new OperationAuditLogResponse();
mockDetail.setOperatorRoleCode("SYS_ADMIN");
Mockito.when(auditService.getDetail("log-456")).thenReturn(mockDetail);
mockMvc.perform(get(BASE_URL + "/{logId}", "log-456"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.operatorRoleCode").value("SYS_ADMIN"));
}
@Test
void testReviewLog_Success_WithAuditAdminRole() throws Exception {
AuditLogReviewRequest req = new AuditLogReviewRequest();
req.setAuditResult(AuditResult.PASS);
req.setAuditSign("E1A2B3C4...");
Mockito.doNothing().when(auditService).reviewLog(eq("log-789"), any(), eq("AUDIT_ADMIN"));
mockMvc.perform(post(BASE_URL + "/{logId}/review", "log-789")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
// 这里非常巧妙就算没有拦截器我们也能直接把 Attribute 塞进 Request 里供 Controller 读取
.requestAttr(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "AUDIT_ADMIN"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
@Test
void testReviewLog_Forbidden_WhenNotAuditAdmin() throws Exception {
AuditLogReviewRequest req = new AuditLogReviewRequest();
req.setAuditResult(AuditResult.PASS);
mockMvc.perform(post(BASE_URL + "/{logId}/review", "log-789")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req))
.requestAttr(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "SYS_ADMIN"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403))
.andExpect(jsonPath("$.msg").value("权限不足:仅审计管理员可执行此操作"));
}
}

View File

@ -0,0 +1,16 @@
package com.cisd.tms.modules.log.service;
import org.junit.jupiter.api.Test;
import java.io.File;
public class AuditBackupServiceTest {
@Test
public void FileTest(){
String filePath = "./data/backup/audit_log_" + ".csv";
File file = new File(filePath);
System.out.print(file.getParentFile());
}
}

View File

@ -0,0 +1,132 @@
package com.cisd.tms.modules.log.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageRequest;
import com.cisd.tms.modules.log.dto.OperationAuditLogPageResponse;
import com.cisd.tms.modules.log.entity.OperationAuditLogEntity;
import com.cisd.tms.modules.log.enums.ActionType;
import com.cisd.tms.modules.log.enums.ModuleCode;
import com.cisd.tms.modules.log.enums.OperationResult;
import com.cisd.tms.modules.log.repository.OperationAuditLogRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
class OperationAuditServiceTest {
@Mock
private OperationAuditSigner auditSigner;
@Mock
private PcieCryptoService pcieCryptoService;
@Test
void shouldReturnPagedAndConvertedResponses() throws JsonProcessingException {
InMemoryOperationAuditLogRepository fakeRepo = new InMemoryOperationAuditLogRepository();
fakeRepo.add(createMockEntity("log-1", ModuleCode.NETWORK, "SYS_ADMIN"));
fakeRepo.add(createMockEntity("log-2", ModuleCode.NETWORK, "AUDIT_ADMIN"));
fakeRepo.add(createMockEntity("log-3", ModuleCode.SYSTEM, "SYS_ADMIN"));
OperationAuditService service = new OperationAuditService(fakeRepo, auditSigner, pcieCryptoService);
OperationAuditLogPageRequest req = new OperationAuditLogPageRequest();
req.setModuleCode(ModuleCode.NETWORK);
req.setPageNum(1);
req.setPageSize(10);
IPage<OperationAuditLogPageResponse> resultPage = service.queryPage(req);
ObjectMapper mapper = new ObjectMapper();
System.out.println("========== 查询到的具体结果 ==========");
String jsonResult = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(resultPage.getRecords());
System.out.println(jsonResult);
System.out.println("====================================");
Assertions.assertNotNull(resultPage);
Assertions.assertEquals(1, resultPage.getTotal(), "总数应该只有2条 NETWORK 的日志");
Assertions.assertEquals(2, resultPage.getRecords().size(), "当前页应该返回2条数据");
OperationAuditLogPageResponse firstRecord = resultPage.getRecords().get(0);
Assertions.assertNotNull(firstRecord.getLogId());
Assertions.assertEquals("NETWORK", firstRecord.getModuleCode());
}
private OperationAuditLogEntity createMockEntity(String logId, ModuleCode moduleCode, String roleCode) {
OperationAuditLogEntity entity = new OperationAuditLogEntity();
entity.setLogId(logId);
entity.setModuleCode(moduleCode);
entity.setOperatorRoleCode(roleCode);
entity.setActionType(ActionType.CREATE);
entity.setOperationResult(OperationResult.SUCCESS);
entity.setRemoteIp("127.0.0.1");
return entity;
}
public class InMemoryOperationAuditLogRepository implements OperationAuditLogRepository {
// Map 模拟数据库表Key logId
private final Map<String, OperationAuditLogEntity> store = new ConcurrentHashMap<>();
@Override
public void add(OperationAuditLogEntity entity) {
store.put(entity.getLogId(), entity);
}
@Override
public IPage<OperationAuditLogEntity> findPage(OperationAuditLogPageRequest req) {
List<OperationAuditLogEntity> filtered = store.values().stream()
.filter(e -> req.getModuleCode() == null || req.getModuleCode().equals(e.getModuleCode()))
.filter(e -> req.getOperatorRoleCode() == null || req.getOperatorRoleCode().equals(e.getOperatorRoleCode()))
.filter(e -> req.getAuditStatus() == null || req.getAuditStatus().equals(e.getAuditStatus()))
.collect(Collectors.toList());
// 2. 模拟 SQL LIMIT OFFSET 分页
int current = req.getPageNum() > 0 ? req.getPageNum() : 1;
int size = req.getPageSize() > 0 ? req.getPageSize() : 10;
int start = (current - 1) * size;
List<OperationAuditLogEntity> pagedRecords = filtered.stream()
.skip(start)
.limit(size)
.collect(Collectors.toList());
Page<OperationAuditLogEntity> page = new Page<>(current, size, filtered.size());
page.setRecords(pagedRecords);
return page;
}
@Override
public Optional<OperationAuditLogEntity> findByLogId(String logId) {
return Optional.ofNullable(store.get(logId));
}
@Override
public void update(OperationAuditLogEntity entity) {
store.put(entity.getLogId(), entity);
}
@Override
public Optional<List<OperationAuditLogEntity>> findByAuditStatus(String auditStatus) {
List<OperationAuditLogEntity> list = store.values().stream()
.filter(e -> auditStatus.equals(e.getAuditStatus()))
.collect(Collectors.toList());
return list.isEmpty() ? Optional.empty() : Optional.of(list);
}
}
}