feat(logging): add structured audit logging with file export

- Add logback-spring.xml with daily rolling file appenders
- Add structured audit events to RequireRoleAspect
- Add logging export configuration to application.yaml
- Add janino dependency for logback evaluation
- Ignore /log/ export directory
This commit is contained in:
2026-05-24 00:38:11 +08:00
parent 21dc992971
commit 57683ad64c
5 changed files with 96 additions and 7 deletions
+2 -1
View File
@@ -43,4 +43,5 @@ out/
**/application-dev.yaml **/application-dev.yaml
**/application-dev.yml **/application-dev.yml
**/application-dev.properties **/application-dev.properties
**/.env **/.env
/log/
+1
View File
@@ -37,6 +37,7 @@ dependencies {
implementation("io.jsonwebtoken:jjwt-api:0.13.0") implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
runtimeOnly("org.codehaus.janino:janino")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
@@ -1,7 +1,7 @@
package com.msksbr.bookmgr.config package com.msksbr.bookmgr.config
import com.msksbr.bookmgr.annotation.RequireRole import com.msksbr.bookmgr.annotation.RequireRole
import com.msksbr.bookmgr.script.log import com.msksbr.bookmgr.script.audit
import com.msksbr.bookmgr.template.ApiResult import com.msksbr.bookmgr.template.ApiResult
import org.aspectj.lang.ProceedingJoinPoint import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around import org.aspectj.lang.annotation.Around
@@ -43,20 +43,25 @@ class RequireRoleAspect(
val role = request.getAttribute("role") as? String val role = request.getAttribute("role") as? String
if (username == null) { if (username == null) {
log.warn("[AUDIT] unauthenticated | ip={} | path={} | required={}", ip, path, requireRole.role) audit.warn(
"event=CTRL | user=${username ?: "N/A"} | ip={} | op={} | result=unauthenticated | required={}",
ip, path, requireRole.role
)
return ApiResult.unauthorized<Any?>("Missing or invalid token") return ApiResult.unauthorized<Any?>("Missing or invalid token")
} }
val allowedRoles = rolePermissions[role] ?: emptySet() val allowedRoles = rolePermissions[role] ?: emptySet()
if (requireRole.role !in allowedRoles) { if (requireRole.role !in allowedRoles) {
log.warn( audit.warn(
"[AUDIT] access denied | user={} | ip={} | path={} | required={} | actual={}", "event=CTRL | user={} | ip={} | op={} | result=denied | required={} | actual={}",
username, ip, path, requireRole.role, role username, ip, path, requireRole.role, role
) )
return ApiResult.forbidden<Any?>("Access denied: insufficient permissions") return ApiResult.forbidden<Any?>("Access denied: insufficient permissions")
} }
log.info("[AUDIT] access allowed | user={} | ip={} | path={} | role={}", audit.info(
username, ip, path, role) "event=CTRL | user={} | ip={} | op={} | result=allowed | role={}",
username, ip, path, role
)
return joinPoint.proceed() return joinPoint.proceed()
} }
} }
+7
View File
@@ -36,6 +36,13 @@ mybatis-plus:
logging: logging:
level: level:
com.msksbr.bookmgr: ${LOG_LEVEL:INFO} # 包级别日志级别,dev 配置中覆盖为 DEBUG com.msksbr.bookmgr: ${LOG_LEVEL:INFO} # 包级别日志级别,dev 配置中覆盖为 DEBUG
export:
path: ${LOG_PATH:} # 导出根目录,未设置则不写文件
audit-enabled: ${LOG_EXPORT_AUDIT:true} # 审计日志持久化开关,默认开启
runtime-enabled: ${LOG_EXPORT_RUNTIME:false} # 运行日志持久化开关,默认关闭
# 导出路径:
# $LOG_PATH/audit/audit.log ← 审计日志(当天),旧文件压缩为 .gz
# $LOG_PATH/runtime/runtime.log ← 运行日志(当天),旧文件压缩为 .gz
# ---- JWT ---- # ---- JWT ----
jwt: jwt:
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!--
日志导出控制
配置来源:application.yaml → logging.export.*(通过 springProperty 注入)
AUDIT logger → 控制台 + (可选) 文件 audit/audit.log
ROOT / 其他 → 控制台 + (可选) 文件 runtime/runtime.log
按天滚动,旧文件压缩为 .gz
-->
<springProperty scope="context" name="LOG_PATH" source="logging.export.path" defaultValue=""/>
<springProperty scope="context" name="LOG_EXPORT_AUDIT" source="logging.export.audit-enabled" defaultValue="true"/>
<springProperty scope="context" name="LOG_EXPORT_RUNTIME" source="logging.export.runtime-enabled" defaultValue="false"/>
<!-- ====== CONSOLE:运行日志(始终开启) ===== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>
<!-- ====== CONSOLE_AUDIT:审计日志(始终开启) ===== -->
<appender name="CONSOLE_AUDIT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [AUDIT] - %msg%n</pattern>
</encoder>
</appender>
<!-- ====== AUDIT_FILE:审计日志持久化 ===== -->
<appender name="AUDIT_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/audit/audit.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/audit/audit.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>365</maxHistory>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level - %msg%n</pattern>
</encoder>
</appender>
<!-- ====== RUNTIME_FILE:运行日志持久化 ===== -->
<appender name="RUNTIME_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/runtime/runtime.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/runtime/runtime.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- ====== AUDIT Logger:固定名称 "AUDIT",不传播到 root ===== -->
<logger name="AUDIT" level="INFO" additivity="false">
<appender-ref ref="CONSOLE_AUDIT"/>
<if condition='!property("LOG_PATH").isEmpty() &amp;&amp; "true".equals(property("LOG_EXPORT_AUDIT"))'>
<then>
<appender-ref ref="AUDIT_FILE"/>
</then>
</if>
</logger>
<!-- ====== ROOT Logger ===== -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<if condition='!property("LOG_PATH").isEmpty() &amp;&amp; "true".equals(property("LOG_EXPORT_RUNTIME"))'>
<then>
<appender-ref ref="RUNTIME_FILE"/>
</then>
</if>
</root>
</configuration>