feat: add rolling logs and separate local and production profiles
This commit is contained in:
1 parent
e22e5c4024
commit
30c5f9f114
17 files changed
+621
-20
No files matched your search
@@ -2,4 +2,6 @@
|
|||||||
target/
|
target/
|
||||||
*.iml
|
*.iml
|
||||||
.hera-local.yml
|
.hera-local.yml
|
||||||
|
.hera-prod.yml
|
||||||
|
logs/
|
||||||
|
|
||||||
@@ -20,12 +20,64 @@ mvn spring-boot:run
|
|||||||
默认地址:`http://localhost:8080`
|
默认地址:`http://localhost:8080`
|
||||||
|
|
||||||
本机敏感配置可放在仓库根目录的 `.hera-local.yml`,使用与 `application.yml` 相同的 YAML
|
本机敏感配置可放在仓库根目录的 `.hera-local.yml`,使用与 `application.yml` 相同的 YAML
|
||||||
层级。后端从项目根目录启动时会自动加载,此文件已加入 Git 忽略清单;服务器使用环境变量配置。
|
层级。默认启用 `local`,从当前启动目录读取该文件;此文件已加入 Git 忽略清单。
|
||||||
|
|
||||||
|
## 环境与部署
|
||||||
|
|
||||||
|
- `application.yml`:公共参数、上传限制与日志策略。
|
||||||
|
- `application-local.yml`:本地环境,MySQL/Redis 默认使用公网 `101.43.139.56`,允许创建缺失表。
|
||||||
|
- `application-prod.yml`:线上环境,MySQL/Redis 默认使用内网 `10.0.8.9`,不自动建表或改表。
|
||||||
|
- `.hera-local.yml`:本机私有配置,仅 `local` 环境加载。
|
||||||
|
- `.hera-prod.yml`:线上私有配置,仅 `prod` 环境加载,不会误读本地配置。
|
||||||
|
|
||||||
|
两套环境不能同时启用。地址和端口可通过 `DB_URL`、`REDIS_HOST`、`REDIS_PORT`、
|
||||||
|
`SERVER_PORT` 覆盖,其他参数仍使用现有环境变量。线上必须配置至少 32 字节、非开发默认值的
|
||||||
|
`hera.jwt-secret`(或 `HERA_JWT_SECRET`),不满足时拒绝启动。短信仍为 mock,正式上线前需接入厂商。
|
||||||
|
环境隔离只隔离配置文件,不会自动创建两套数据库;需要数据隔离时应分别指定数据库和 Redis 逻辑库。
|
||||||
|
|
||||||
|
服务器使用 Java 8,将 JAR 与私有 `.hera-prod.yml` 放在同一启动目录,执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 .hera-prod.yml
|
||||||
|
java -jar hera-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
私有文件字段结构参考 `deploy/hera-prod.yml.example`;可以直接填写真实值,也可以使用环境变量占位符。
|
||||||
|
本机已按现有密钥准备私有 `.hera-prod.yml`,未复制本地的公网连接地址覆盖线上内网地址。
|
||||||
|
这两个私有文件均不进入 Git 或 JAR,不应放在 Nginx 可下载目录中。
|
||||||
|
需要将私有文件置于其他目录时,可设置 `HERA_PROD_CONFIG=optional:file:/etc/hera/prod.yml`
|
||||||
|
或对应的 `HERA_LOCAL_CONFIG`,默认仍从启动目录查找。
|
||||||
|
环境变量占位符只在未被私有文件的同名直接值覆盖时生效;若要通过环境变量管理某字段,
|
||||||
|
请删除私有文件中的直接值或将该值改成对应占位符。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
控制台与文件同时输出,时间使用北京时间。默认路径为启动目录的 `logs/`,
|
||||||
|
可设置 `LOG_PATH=/var/log/hera`,启动用户必须具有目录写权限:
|
||||||
|
|
||||||
|
- `hera.log`:启动信息、请求摘要及异常等全部应用日志。
|
||||||
|
- `hera-error.log`:仅 ERROR 级别日志,错误同时保留在 `hera.log`。
|
||||||
|
- `archive/`:按日期和大小滚动后的 gzip 压缩日志。
|
||||||
|
|
||||||
|
默认每文件 20MB、保留 30 天,每个文件序列独立限制总量为 2GB(两个序列合计最高约 4GB,
|
||||||
|
不含当前活动文件);支持 `LOG_MAX_FILE_SIZE`、`LOG_MAX_HISTORY`、`LOG_TOTAL_SIZE_CAP` 覆盖。
|
||||||
|
应用日志不代替 Nginx 访问日志、指标和告警,磁盘仍需运维监控。
|
||||||
|
|
||||||
|
每个请求返回 `X-Request-Id`,对应日志中的 `requestId`。请求摘要记录方法、路由模板、
|
||||||
|
状态码和耗时,不记录查询串、请求或响应正文、手机号、验证码、Token、Cookie、文件凭证等。
|
||||||
|
未知路由只记 `<unmapped>`,不打印可能包含敏感信息的原始路径。异常日志保留类型、原因链类型和
|
||||||
|
堆栈位置,但不打印异常消息,以免泄露 SQL 参数或包含微信密钥的 URL。
|
||||||
|
默认不启用 SQL 参数与 HTTP wire DEBUG 日志,不建议在线上自行打开此类日志。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -f logs/hera.log
|
||||||
|
tail -f logs/hera-error.log
|
||||||
|
```
|
||||||
|
|
||||||
## Redis
|
## Redis
|
||||||
|
|
||||||
使用 Spring Data Redis 和 Lettuce,依赖版本由 Spring Boot 2.7.18 管理。
|
使用 Spring Data Redis 和 Lettuce,依赖版本由 Spring Boot 2.7.18 管理。
|
||||||
默认地址为 `101.43.139.56:6379`,逻辑库为 `0`,连接与命令超时均为 3 秒。
|
本地默认地址为 `101.43.139.56:6379`,线上为 `10.0.8.9:6379`,逻辑库为 `0`,连接与命令超时均为 3 秒。
|
||||||
支持 `REDIS_HOST`、`REDIS_PORT`、`REDIS_USERNAME`、`REDIS_PASSWORD`、`REDIS_DATABASE`、
|
支持 `REDIS_HOST`、`REDIS_PORT`、`REDIS_USERNAME`、`REDIS_PASSWORD`、`REDIS_DATABASE`、
|
||||||
`REDIS_SSL` 环境变量;本机密码配置在被 Git 忽略的 `.hera-local.yml` 中。
|
`REDIS_SSL` 环境变量;本机密码配置在被 Git 忽略的 `.hera-local.yml` 中。
|
||||||
|
|
||||||
@@ -44,7 +96,8 @@ mvn spring-boot:run
|
|||||||
存储桶为 `hera-1319913268`,地域为 `ap-beijing`,桶权限保持私有读。
|
存储桶为 `hera-1319913268`,地域为 `ap-beijing`,桶权限保持私有读。
|
||||||
运行环境必须配置 `TENCENT_COS_SECRET_ID`、`TENCENT_COS_SECRET_KEY`。
|
运行环境必须配置 `TENCENT_COS_SECRET_ID`、`TENCENT_COS_SECRET_KEY`。
|
||||||
推荐仅授权该桶 `hera/avatar/*`、`hera/files/*` 路径的 `cos:PutObject`、`cos:GetObject` 权限。
|
推荐仅授权该桶 `hera/avatar/*`、`hera/files/*` 路径的 `cos:PutObject`、`cos:GetObject` 权限。
|
||||||
密钥不写入配置文件或 Git。未配置密钥时允许服务启动,文件操作会返回配置错误。
|
密钥只通过环境变量或被忽略的私有配置提供,不进入受版本管理的配置或 Git。
|
||||||
|
未配置 COS 密钥时允许服务启动,文件操作会返回配置错误。
|
||||||
|
|
||||||
- `POST /api/user/avatar`:上传 JPG、PNG、WebP 头像,最大 5MB。
|
- `POST /api/user/avatar`:上传 JPG、PNG、WebP 头像,最大 5MB。
|
||||||
- `POST /api/files`:通过 multipart 字段 `file` 上传普通文件,最大 5MB。
|
- `POST /api/files`:通过 multipart 字段 `file` 上传普通文件,最大 5MB。
|
||||||
@@ -75,8 +128,8 @@ mvn spring-boot:run
|
|||||||
## 开发说明
|
## 开发说明
|
||||||
|
|
||||||
- 当前已默认关闭微信 mock,并接入微信 `jscode2session`。
|
- 当前已默认关闭微信 mock,并接入微信 `jscode2session`。
|
||||||
- 数据持久层使用 MyBatis Mapper XML,首次启动会执行 `schema.sql` 创建缺失的数据表。
|
- 数据持久层使用 MyBatis Mapper XML,本地启动会执行 `schema.sql` 创建缺失的数据表,线上默认不执行。
|
||||||
- 数据库连接默认指向 `101.43.139.56:3306/hera`,生产部署建议通过 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 覆盖。
|
- 本地数据库连接默认指向 `101.43.139.56:3306/hera`,线上默认内网 `10.0.8.9:3306/hera`,可通过环境参数覆盖。
|
||||||
- `WECHAT_APP_SECRET` 请优先使用服务器环境变量配置,不建议提交到公开仓库。
|
- `WECHAT_APP_SECRET` 请优先使用服务器环境变量配置,不建议提交到公开仓库。
|
||||||
- `/api/auth/sms` 开发阶段返回 `mockCode`,接入短信厂商后移除返回值。
|
- `/api/auth/sms` 开发阶段返回 `mockCode`,接入短信厂商后移除返回值。
|
||||||
- 正式上线前需要替换 `hera.jwt-secret`、短信服务实现和协议正文。
|
- 正式上线前需要替换 `hera.jwt-secret`、短信服务实现和协议正文。
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Place a private .hera-prod.yml beside the JAR and replace these placeholders.
|
||||||
|
# Do not place it under a public web directory or commit real values to Git.
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
password: "${DB_PASSWORD}"
|
||||||
|
redis:
|
||||||
|
password: "${REDIS_PASSWORD}"
|
||||||
|
hera:
|
||||||
|
jwt-secret: "${HERA_JWT_SECRET}"
|
||||||
|
wx:
|
||||||
|
app-secret: "${WECHAT_APP_SECRET}"
|
||||||
|
cos:
|
||||||
|
secret-id: "${TENCENT_COS_SECRET_ID}"
|
||||||
|
secret-key: "${TENCENT_COS_SECRET_KEY}"
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.hongzhen.hera.common;
|
package com.hongzhen.hera.common;
|
||||||
|
|
||||||
import com.hongzhen.hera.security.AuthenticationException;
|
import com.hongzhen.hera.security.AuthenticationException;
|
||||||
|
import com.hongzhen.hera.logging.SafeThrowableConverter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.validation.BindException;
|
import org.springframework.validation.BindException;
|
||||||
@@ -8,32 +11,101 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
|
|||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||||
|
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||||
|
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
/** 登录过期与普通业务校验区分,供客户端清理会话并重新登录。 */
|
/** 登录过期与普通业务校验区分,供客户端清理会话并重新登录。 */
|
||||||
@ExceptionHandler(AuthenticationException.class)
|
@ExceptionHandler(AuthenticationException.class)
|
||||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||||
public ApiResponse<Void> handleAuthentication(AuthenticationException exception) {
|
public ApiResponse<Void> handleAuthentication(AuthenticationException exception) {
|
||||||
|
LOGGER.warn("authentication_rejected");
|
||||||
return new ApiResponse<Void>(401, exception.getMessage(), null);
|
return new ApiResponse<Void>(401, exception.getMessage(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务拒绝只记录事件,不记录可能包含外部接口信息的异常消息。
|
||||||
|
*/
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
public ApiResponse<Void> handleBusiness(BusinessException exception) {
|
public ApiResponse<Void> handleBusiness(BusinessException exception) {
|
||||||
|
LOGGER.warn("business_rejected exceptionType={}", exception.getClass().getSimpleName());
|
||||||
return ApiResponse.fail(exception.getMessage());
|
return ApiResponse.fail(exception.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
|
/**
|
||||||
|
* 参数错误不输出请求参数、正文与校验失败值。
|
||||||
|
*/
|
||||||
|
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class,
|
||||||
|
HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
|
||||||
|
MissingServletRequestParameterException.class, MissingServletRequestPartException.class})
|
||||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
public ApiResponse<Void> handleValidation(Exception exception) {
|
public ApiResponse<Void> handleValidation(Exception exception) {
|
||||||
|
LOGGER.warn("validation_rejected exceptionType={}", exception.getClass().getSimpleName());
|
||||||
return ApiResponse.fail("参数不正确");
|
return ApiResponse.fail("参数不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据约束错误不打印 SQL、参数或底层异常消息。
|
||||||
|
*/
|
||||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||||
@ResponseStatus(HttpStatus.CONFLICT)
|
@ResponseStatus(HttpStatus.CONFLICT)
|
||||||
public ApiResponse<Void> handleDataIntegrity(DataIntegrityViolationException exception) {
|
public ApiResponse<Void> handleDataIntegrity(DataIntegrityViolationException exception) {
|
||||||
|
LOGGER.warn("data_integrity_rejected");
|
||||||
return ApiResponse.fail("数据重复或关联关系不正确");
|
return ApiResponse.fail("数据重复或关联关系不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保留框架的标准 HTTP 状态,防止兜底处理把 404、405 等误报为服务故障。
|
||||||
|
*/
|
||||||
|
@ExceptionHandler({HttpRequestMethodNotSupportedException.class, HttpMediaTypeNotSupportedException.class,
|
||||||
|
HttpMediaTypeNotAcceptableException.class, NoHandlerFoundException.class, ResponseStatusException.class})
|
||||||
|
public org.springframework.http.ResponseEntity<ApiResponse<Void>> handleHttpException(Exception exception) {
|
||||||
|
HttpStatus status;
|
||||||
|
if (exception instanceof ResponseStatusException) {
|
||||||
|
status = ((ResponseStatusException) exception).getStatus();
|
||||||
|
} else if (exception instanceof HttpRequestMethodNotSupportedException) {
|
||||||
|
status = HttpStatus.METHOD_NOT_ALLOWED;
|
||||||
|
} else if (exception instanceof HttpMediaTypeNotSupportedException) {
|
||||||
|
status = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
|
||||||
|
} else if (exception instanceof HttpMediaTypeNotAcceptableException) {
|
||||||
|
status = HttpStatus.NOT_ACCEPTABLE;
|
||||||
|
} else {
|
||||||
|
status = HttpStatus.NOT_FOUND;
|
||||||
|
}
|
||||||
|
LOGGER.warn("http_rejected status={} exceptionType={}", status.value(), exception.getClass().getSimpleName());
|
||||||
|
return org.springframework.http.ResponseEntity.status(status)
|
||||||
|
.body(new ApiResponse<Void>(status.value(), status.getReasonPhrase(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传超限不记录文件内容或文件名。
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||||
|
public ApiResponse<Void> handleUploadLimit(MaxUploadSizeExceededException exception) {
|
||||||
|
LOGGER.warn("upload_size_rejected");
|
||||||
|
return new ApiResponse<Void>(HttpStatus.PAYLOAD_TOO_LARGE.value(), "上传文件超过大小限制", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未知异常记录类型与安全定位堆栈,客户端只收到通用错误。
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
public ApiResponse<Void> handleUnexpected(Exception exception) {
|
||||||
|
LOGGER.error("request_failed\n{}", SafeThrowableConverter.stackTrace(exception));
|
||||||
|
return new ApiResponse<Void>(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务暂时不可用,请稍后重试", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.hongzhen.hera.config;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.core.env.Profiles;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防止混用本地与线上配置,线上不允许使用缺失、过短或默认开发 JWT 密钥。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class EnvironmentConfigurationValidator implements InitializingBean {
|
||||||
|
private static final int MIN_JWT_SECRET_BYTES = 32;
|
||||||
|
private final Environment environment;
|
||||||
|
private final HeraProperties properties;
|
||||||
|
|
||||||
|
public EnvironmentConfigurationValidator(Environment environment, HeraProperties properties) {
|
||||||
|
this.environment = environment;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动阶段只报告配置项名称,不将配置值写入异常或日志。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterPropertiesSet() {
|
||||||
|
List<String> profiles = Arrays.asList(environment.getActiveProfiles());
|
||||||
|
if (profiles.contains("local") && profiles.contains("prod")) {
|
||||||
|
throw new IllegalStateException("local 与 prod 环境不能同时启用");
|
||||||
|
}
|
||||||
|
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||||
|
String secret = properties.getJwtSecret();
|
||||||
|
if (secret == null || secret.trim().isEmpty()
|
||||||
|
|| secret.getBytes(StandardCharsets.UTF_8).length < MIN_JWT_SECRET_BYTES
|
||||||
|
|| secret.startsWith("replace-this-development-secret")) {
|
||||||
|
throw new IllegalStateException("线上必须配置独立的 hera.jwt-secret,UTF-8 长度至少 32 字节");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.hongzhen.hera.logging;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import javax.servlet.FilterChain;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import org.springframework.web.servlet.HandlerMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录请求方法、路由模板、状态码与耗时,不记录查询串、正文、Cookie 和认证头。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||||
|
private static final String REQUEST_ID_KEY = "requestId";
|
||||||
|
private static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为每个请求生成服务端追踪号,并在请求结束时恢复线程原有 MDC。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||||
|
String requestId = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
MDC.put(REQUEST_ID_KEY, requestId);
|
||||||
|
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||||
|
long start = System.nanoTime();
|
||||||
|
boolean failed = false;
|
||||||
|
try {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
} catch (ServletException | IOException | RuntimeException exception) {
|
||||||
|
failed = true;
|
||||||
|
LOGGER.error("request_failed\n{}", SafeThrowableConverter.stackTrace(exception));
|
||||||
|
throw exception;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
Object matched = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||||
|
String route = matched == null ? "<unmapped>" : matched.toString();
|
||||||
|
int status = failed ? HttpServletResponse.SC_INTERNAL_SERVER_ERROR : response.getStatus();
|
||||||
|
long duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||||
|
if (status >= HttpServletResponse.SC_INTERNAL_SERVER_ERROR) {
|
||||||
|
LOGGER.error("http_request method={} route={} status={} durationMs={}", request.getMethod(), route, status, duration);
|
||||||
|
} else if (status >= HttpServletResponse.SC_BAD_REQUEST) {
|
||||||
|
LOGGER.warn("http_request method={} route={} status={} durationMs={}", request.getMethod(), route, status, duration);
|
||||||
|
} else {
|
||||||
|
LOGGER.info("http_request method={} route={} status={} durationMs={}", request.getMethod(), route, status, duration);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (previous == null) { MDC.clear(); } else { MDC.setContextMap(previous); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.hongzhen.hera.logging;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.pattern.ThrowableProxyConverter;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import ch.qos.logback.classic.spi.IThrowableProxy;
|
||||||
|
import ch.qos.logback.classic.spi.StackTraceElementProxy;
|
||||||
|
import ch.qos.logback.classic.spi.ThrowableProxy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保留异常类型和定位堆栈,不输出可能包含 SQL 参数、微信密钥或签名 URL 的异常消息。
|
||||||
|
*/
|
||||||
|
public class SafeThrowableConverter extends ThrowableProxyConverter {
|
||||||
|
private static final int MAX_CAUSES = 6;
|
||||||
|
private static final int MAX_FRAMES = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出有界的安全异常堆栈,不调用异常消息格式化逻辑。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String convert(ILoggingEvent event) {
|
||||||
|
return render(event.getThrowableProxy());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供业务日志显式输出安全堆栈,不将原始异常传给其他日志配置。
|
||||||
|
*/
|
||||||
|
public static String stackTrace(Throwable exception) {
|
||||||
|
return render(new ThrowableProxy(exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String render(IThrowableProxy cause) {
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
for (int depth = 0; cause != null && depth < MAX_CAUSES; depth++) {
|
||||||
|
result.append(depth == 0 ? "exception=" : "causedBy=").append(cause.getClassName()).append('\n');
|
||||||
|
StackTraceElementProxy[] frames = cause.getStackTraceElementProxyArray();
|
||||||
|
if (frames != null) {
|
||||||
|
for (int index = 0; index < Math.min(frames.length, MAX_FRAMES); index++) {
|
||||||
|
result.append(" ").append(frames[index].getSTEAsString()).append('\n');
|
||||||
|
}
|
||||||
|
if (frames.length > MAX_FRAMES) { result.append(" ... remaining frames omitted\n"); }
|
||||||
|
}
|
||||||
|
cause = cause.getCause();
|
||||||
|
}
|
||||||
|
if (cause != null) { result.append("... remaining causes omitted\n"); }
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
spring:
|
||||||
|
config:
|
||||||
|
import: "${HERA_LOCAL_CONFIG:optional:file:./.hera-local.yml}"
|
||||||
|
datasource:
|
||||||
|
url: "${DB_URL:jdbc:mysql://101.43.139.56:3306/hera?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}"
|
||||||
|
redis:
|
||||||
|
host: "${REDIS_HOST:101.43.139.56}"
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: always
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
hera:
|
||||||
|
jwt-secret: "${HERA_JWT_SECRET:replace-this-development-secret-replace-this-development-secret}"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
spring:
|
||||||
|
config:
|
||||||
|
import: "${HERA_PROD_CONFIG:optional:file:./.hera-prod.yml}"
|
||||||
|
datasource:
|
||||||
|
url: "${DB_URL:jdbc:mysql://10.0.8.9:3306/hera?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}"
|
||||||
|
redis:
|
||||||
|
host: "${REDIS_HOST:10.0.8.9}"
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: never
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: "${SERVER_PORT:8080}"
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: hera
|
name: hera
|
||||||
|
profiles:
|
||||||
|
default: local
|
||||||
redis:
|
redis:
|
||||||
host: "${REDIS_HOST:101.43.139.56}"
|
|
||||||
port: "${REDIS_PORT:6379}"
|
port: "${REDIS_PORT:6379}"
|
||||||
username: "${REDIS_USERNAME:}"
|
username: "${REDIS_USERNAME:}"
|
||||||
password: "${REDIS_PASSWORD:}"
|
password: "${REDIS_PASSWORD:}"
|
||||||
@@ -21,19 +22,23 @@ spring:
|
|||||||
enabled: false
|
enabled: false
|
||||||
datasource:
|
datasource:
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
url: "${DB_URL:jdbc:mysql://101.43.139.56:3306/hera?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}"
|
|
||||||
username: "${DB_USERNAME:hera_user}"
|
username: "${DB_USERNAME:hera_user}"
|
||||||
password: "${DB_PASSWORD:}"
|
password: "${DB_PASSWORD:}"
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: always
|
|
||||||
encoding: UTF-8
|
encoding: UTF-8
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 5MB
|
max-file-size: 5MB
|
||||||
max-request-size: 5MB
|
max-request-size: 5MB
|
||||||
config:
|
logging:
|
||||||
import: optional:file:./.hera-local.yml
|
file:
|
||||||
|
path: "${LOG_PATH:./logs}"
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
max-file-size: "${LOG_MAX_FILE_SIZE:20MB}"
|
||||||
|
max-history: "${LOG_MAX_HISTORY:30}"
|
||||||
|
total-size-cap: "${LOG_TOTAL_SIZE_CAP:2GB}"
|
||||||
mybatis:
|
mybatis:
|
||||||
mapper-locations: classpath*:/mapper/**/*.xml
|
mapper-locations: classpath*:/mapper/**/*.xml
|
||||||
configuration:
|
configuration:
|
||||||
@@ -46,7 +51,7 @@ hera:
|
|||||||
region: "${TENCENT_COS_REGION:ap-beijing}"
|
region: "${TENCENT_COS_REGION:ap-beijing}"
|
||||||
signed-url-seconds: 900
|
signed-url-seconds: 900
|
||||||
public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/file/}"
|
public-base-url: "${TENCENT_COS_PUBLIC_BASE_URL:http://101.43.139.56/file/}"
|
||||||
jwt-secret: "${HERA_JWT_SECRET:replace-this-development-secret-replace-this-development-secret}"
|
jwt-secret: "${HERA_JWT_SECRET:}"
|
||||||
upload-dir: "${HERA_UPLOAD_DIR:./uploads}"
|
upload-dir: "${HERA_UPLOAD_DIR:./uploads}"
|
||||||
wx:
|
wx:
|
||||||
app-id: "${WECHAT_APP_ID:wxa76a2ef9e8ba612e}"
|
app-id: "${WECHAT_APP_ID:wxa76a2ef9e8ba612e}"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
|
||||||
|
<springProperty scope="context" name="MAX_FILE_SIZE" source="logging.logback.rollingpolicy.max-file-size" defaultValue="20MB"/>
|
||||||
|
<springProperty scope="context" name="MAX_HISTORY" source="logging.logback.rollingpolicy.max-history" defaultValue="30"/>
|
||||||
|
<springProperty scope="context" name="TOTAL_SIZE_CAP" source="logging.logback.rollingpolicy.total-size-cap" defaultValue="2GB"/>
|
||||||
|
<conversionRule conversionWord="safeEx" converterClass="com.hongzhen.hera.logging.SafeThrowableConverter"/>
|
||||||
|
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS,Asia/Shanghai} %-5level [%thread] [requestId=%X{requestId:-none}] %logger{60} - %msg%n%safeEx"/>
|
||||||
|
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder><charset>UTF-8</charset><pattern>${LOG_PATTERN}</pattern></encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="APPLICATION" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_PATH}/hera.log</file>
|
||||||
|
<encoder><charset>UTF-8</charset><pattern>${LOG_PATTERN}</pattern></encoder>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_PATH}/archive/hera.%d{yyyy-MM-dd,Asia/Shanghai}.%i.log.gz</fileNamePattern>
|
||||||
|
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
|
||||||
|
<maxHistory>${MAX_HISTORY}</maxHistory>
|
||||||
|
<totalSizeCap>${TOTAL_SIZE_CAP}</totalSizeCap>
|
||||||
|
<cleanHistoryOnStart>true</cleanHistoryOnStart>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_PATH}/hera-error.log</file>
|
||||||
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"><level>ERROR</level></filter>
|
||||||
|
<encoder><charset>UTF-8</charset><pattern>${LOG_PATTERN}</pattern></encoder>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_PATH}/archive/hera-error.%d{yyyy-MM-dd,Asia/Shanghai}.%i.log.gz</fileNamePattern>
|
||||||
|
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
|
||||||
|
<maxHistory>${MAX_HISTORY}</maxHistory>
|
||||||
|
<totalSizeCap>${TOTAL_SIZE_CAP}</totalSizeCap>
|
||||||
|
<cleanHistoryOnStart>true</cleanHistoryOnStart>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- SQL parameters and HTTP wire traces can contain credentials or personal data. -->
|
||||||
|
<logger name="com.hongzhen.hera.data" level="INFO"/>
|
||||||
|
<logger name="com.hongzhen.hera.user.UserMapper" level="INFO"/>
|
||||||
|
<logger name="com.hongzhen.hera.auth" level="INFO"/>
|
||||||
|
<logger name="org.mybatis" level="WARN"/>
|
||||||
|
<logger name="org.apache.ibatis" level="WARN"/>
|
||||||
|
<logger name="org.springframework.jdbc" level="WARN"/>
|
||||||
|
<logger name="org.springframework.web" level="WARN"/>
|
||||||
|
<logger name="org.apache.http" level="WARN"/>
|
||||||
|
<logger name="io.lettuce.core" level="WARN"/>
|
||||||
|
<logger name="com.qcloud.cos" level="WARN"/>
|
||||||
|
<root level="${LOG_LEVEL_ROOT:-INFO}">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="APPLICATION"/>
|
||||||
|
<appender-ref ref="ERROR"/>
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.hongzhen.hera.config;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
|
||||||
|
import org.springframework.context.support.GenericApplicationContext;
|
||||||
|
import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
|
import org.springframework.core.env.MapPropertySource;
|
||||||
|
import org.springframework.mock.env.MockEnvironment;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证两套环境配置、线上内网地址、建表策略与生产 JWT 启动校验。
|
||||||
|
*/
|
||||||
|
class EnvironmentProfilesTest {
|
||||||
|
@Test
|
||||||
|
void defaultEnvironmentIsLocalAndUsesPublicHost() {
|
||||||
|
try (GenericApplicationContext context = environment(null)) {
|
||||||
|
ConfigurableEnvironment env = context.getEnvironment();
|
||||||
|
assertArrayEquals(new String[]{"local"}, env.getDefaultProfiles());
|
||||||
|
assertTrue(env.getProperty("spring.datasource.url").contains("101.43.139.56"));
|
||||||
|
assertEquals("101.43.139.56", env.getProperty("spring.redis.host"));
|
||||||
|
assertEquals("always", env.getProperty("spring.sql.init.mode"));
|
||||||
|
assertTrue(env.getProperty("hera.jwt-secret").startsWith("replace-this-development-secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void productionUsesInternalHostAndNeverInitializesSchema() {
|
||||||
|
try (GenericApplicationContext context = environment("prod")) {
|
||||||
|
ConfigurableEnvironment env = context.getEnvironment();
|
||||||
|
assertTrue(env.getProperty("spring.datasource.url").contains("10.0.8.9"));
|
||||||
|
assertEquals("10.0.8.9", env.getProperty("spring.redis.host"));
|
||||||
|
assertEquals("never", env.getProperty("spring.sql.init.mode"));
|
||||||
|
assertEquals("", env.getProperty("hera.jwt-secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void productionRejectsWeakSecretsWithoutPrintingTheirValues() {
|
||||||
|
MockEnvironment env = new MockEnvironment();
|
||||||
|
env.setActiveProfiles("prod");
|
||||||
|
HeraProperties properties = new HeraProperties();
|
||||||
|
for (String value : new String[]{"", "short-secret", "replace-this-development-secret-replace-this-development-secret"}) {
|
||||||
|
properties.setJwtSecret(value);
|
||||||
|
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||||
|
() -> new EnvironmentConfigurationValidator(env, properties).afterPropertiesSet());
|
||||||
|
if (!value.isEmpty()) { assertFalse(error.getMessage().contains(value)); }
|
||||||
|
}
|
||||||
|
properties.setJwtSecret("production-test-key-production-test-key");
|
||||||
|
assertDoesNotThrow(() -> new EnvironmentConfigurationValidator(env, properties).afterPropertiesSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localAndProductionCannotBeCombined() {
|
||||||
|
MockEnvironment env = new MockEnvironment();
|
||||||
|
env.setActiveProfiles("local", "prod");
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> new EnvironmentConfigurationValidator(env, new HeraProperties()).afterPropertiesSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private GenericApplicationContext environment(String profile) {
|
||||||
|
GenericApplicationContext context = new GenericApplicationContext();
|
||||||
|
Map<String, Object> values = new HashMap<>();
|
||||||
|
values.put("HERA_LOCAL_CONFIG", "optional:file:./target/nonexistent-profile-test.yml");
|
||||||
|
values.put("HERA_PROD_CONFIG", "optional:file:./target/nonexistent-profile-test.yml");
|
||||||
|
if (profile != null) {
|
||||||
|
values.put("spring.profiles.active", profile);
|
||||||
|
values.put("HERA_JWT_SECRET", "");
|
||||||
|
}
|
||||||
|
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test-profile-overrides", values));
|
||||||
|
new ConfigDataApplicationContextInitializer().initialize(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.hongzhen.hera.logging;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Level;
|
||||||
|
import ch.qos.logback.classic.Logger;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import ch.qos.logback.core.read.ListAppender;
|
||||||
|
import com.hongzhen.hera.common.ApiResponse;
|
||||||
|
import com.hongzhen.hera.common.BusinessException;
|
||||||
|
import com.hongzhen.hera.common.GlobalExceptionHandler;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
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.RestController;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证请求追踪、错误定位与敏感信息不落日志。
|
||||||
|
*/
|
||||||
|
class RequestLoggingTest {
|
||||||
|
private final Logger requestLogger = (Logger) LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||||
|
private final Logger errorLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
private final ListAppender<ILoggingEvent> logs = new ListAppender<ILoggingEvent>() {
|
||||||
|
@Override
|
||||||
|
protected void append(ILoggingEvent event) {
|
||||||
|
event.prepareForDeferredProcessing();
|
||||||
|
super.append(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private Level oldLevel;
|
||||||
|
private MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setup() {
|
||||||
|
logs.start();
|
||||||
|
oldLevel = requestLogger.getLevel();
|
||||||
|
requestLogger.setLevel(Level.INFO);
|
||||||
|
requestLogger.addAppender(logs);
|
||||||
|
errorLogger.addAppender(logs);
|
||||||
|
mvc = MockMvcBuilders.standaloneSetup(new TestController())
|
||||||
|
.setControllerAdvice(new GlobalExceptionHandler()).addFilters(new RequestLoggingFilter()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
requestLogger.detachAppender(logs);
|
||||||
|
errorLogger.detachAppender(logs);
|
||||||
|
requestLogger.setLevel(oldLevel);
|
||||||
|
logs.stop();
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logsRouteTemplateAndTraceWithoutHeadersQueryOrPathValues() throws Exception {
|
||||||
|
MDC.put("requestId", "previous-thread-context");
|
||||||
|
MvcResult result = mvc.perform(get("/test/records/13812345678?token=query-secret")
|
||||||
|
.header("Authorization", "Bearer header-secret").header("X-Request-Id", "untrusted-client-id"))
|
||||||
|
.andReturn();
|
||||||
|
assertEquals(200, result.getResponse().getStatus());
|
||||||
|
String trace = result.getResponse().getHeader("X-Request-Id");
|
||||||
|
assertNotNull(trace);
|
||||||
|
assertTrue(trace.matches("[a-f0-9]{32}"));
|
||||||
|
assertEquals("previous-thread-context", MDC.get("requestId"));
|
||||||
|
ILoggingEvent access = logs.list.stream().filter(event -> event.getFormattedMessage().startsWith("http_request"))
|
||||||
|
.findFirst().get();
|
||||||
|
assertEquals(trace, access.getMDCPropertyMap().get("requestId"));
|
||||||
|
assertTrue(access.getFormattedMessage().contains("route=/test/records/{id}"));
|
||||||
|
assertTrue(access.getFormattedMessage().contains("status=200"));
|
||||||
|
assertNoSensitiveValues("13812345678", "query-secret", "header-secret", "untrusted-client-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorsRetainStackLocationButNeverExposeExceptionMessageOrRequestBody() throws Exception {
|
||||||
|
MvcResult result = mvc.perform(post("/test/fail").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"body-secret\"}")).andReturn();
|
||||||
|
assertEquals(500, result.getResponse().getStatus());
|
||||||
|
assertTrue(result.getResponse().getContentAsString().contains("500"));
|
||||||
|
assertFalse(result.getResponse().getContentAsString().contains("exception-secret"));
|
||||||
|
assertTrue(logs.list.stream().anyMatch(event -> event.getLevel() == Level.ERROR
|
||||||
|
&& event.getFormattedMessage().contains("java.lang.IllegalStateException")
|
||||||
|
&& event.getFormattedMessage().contains("TestController.fail")));
|
||||||
|
assertNoSensitiveValues("body-secret", "exception-secret", "cause-secret");
|
||||||
|
assertNull(MDC.get("requestId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validationAndBusinessRejectionsDoNotPrintValuesAndPreserveHttpStatus() throws Exception {
|
||||||
|
assertEquals(400, mvc.perform(post("/test/fail").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"malformed-secret\"")).andReturn().getResponse().getStatus());
|
||||||
|
assertEquals(400, mvc.perform(get("/test/rejected")).andReturn().getResponse().getStatus());
|
||||||
|
assertEquals(405, mvc.perform(get("/test/fail")).andReturn().getResponse().getStatus());
|
||||||
|
assertNoSensitiveValues("malformed-secret", "business-secret");
|
||||||
|
assertNull(MDC.get("requestId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void safeStackConverterOmitsNestedMessagesAndLimitsOutput() {
|
||||||
|
Throwable nested = new IllegalArgumentException("cause-secret");
|
||||||
|
for (int depth = 0; depth < 12; depth++) { nested = new IllegalStateException("exception-secret", nested); }
|
||||||
|
String result = SafeThrowableConverter.stackTrace(nested);
|
||||||
|
assertTrue(result.contains("java.lang.IllegalStateException"));
|
||||||
|
assertTrue(result.contains("remaining causes omitted"));
|
||||||
|
assertFalse(result.contains("exception-secret"));
|
||||||
|
assertFalse(result.contains("cause-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertNoSensitiveValues(String... values) {
|
||||||
|
for (ILoggingEvent event : logs.list) {
|
||||||
|
for (String value : values) { assertFalse(event.getFormattedMessage().contains(value)); }
|
||||||
|
assertNull(event.getThrowableProxy(), "业务日志不得传递未经处理的原始异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
static class TestController {
|
||||||
|
@GetMapping("/test/records/{id}")
|
||||||
|
public ApiResponse<String> get(@PathVariable String id) { return ApiResponse.ok(id); }
|
||||||
|
|
||||||
|
@PostMapping("/test/fail")
|
||||||
|
public ApiResponse<Void> fail(@RequestBody Map<String, String> body) {
|
||||||
|
throw new IllegalStateException("exception-secret", new IllegalArgumentException("cause-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/test/rejected")
|
||||||
|
public ApiResponse<Void> rejected() { throw new BusinessException("business-secret"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,8 +64,8 @@ class NfcMySqlIntegrationTest {
|
|||||||
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
||||||
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
||||||
yaml.setResources(local.exists()
|
yaml.setResources(local.exists()
|
||||||
? new org.springframework.core.io.Resource[]{new ClassPathResource("application.yml"), local}
|
? new org.springframework.core.io.Resource[]{new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"), local}
|
||||||
: new org.springframework.core.io.Resource[]{new ClassPathResource("application.yml")});
|
: new org.springframework.core.io.Resource[]{new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml")});
|
||||||
Properties values = yaml.getObject();
|
Properties values = yaml.getObject();
|
||||||
assertNotNull(values);
|
assertNotNull(values);
|
||||||
StandardEnvironment env = new StandardEnvironment();
|
StandardEnvironment env = new StandardEnvironment();
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ class CosIntegrationTest {
|
|||||||
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
||||||
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
||||||
if (local.exists()) {
|
if (local.exists()) {
|
||||||
yaml.setResources(new ClassPathResource("application.yml"), local);
|
yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"), local);
|
||||||
} else {
|
} else {
|
||||||
yaml.setResources(new ClassPathResource("application.yml"));
|
yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"));
|
||||||
}
|
}
|
||||||
Properties values = yaml.getObject();
|
Properties values = yaml.getObject();
|
||||||
assertNotNull(values);
|
assertNotNull(values);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class FileGatewayReadIntegrationTest {
|
|||||||
assertTrue(reference.startsWith("cos://hera/avatar/"));
|
assertTrue(reference.startsWith("cos://hera/avatar/"));
|
||||||
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
||||||
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
||||||
yaml.setResources(new ClassPathResource("application.yml"), local);
|
yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"), local);
|
||||||
Properties values = yaml.getObject();
|
Properties values = yaml.getObject();
|
||||||
assertNotNull(values);
|
assertNotNull(values);
|
||||||
StandardEnvironment environment = new StandardEnvironment();
|
StandardEnvironment environment = new StandardEnvironment();
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ class UserAccountSchemaIntegrationTest {
|
|||||||
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
||||||
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
FileSystemResource local = new FileSystemResource(".hera-local.yml");
|
||||||
if (local.exists()) {
|
if (local.exists()) {
|
||||||
yaml.setResources(new ClassPathResource("application.yml"), local);
|
yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"), local);
|
||||||
} else {
|
} else {
|
||||||
yaml.setResources(new ClassPathResource("application.yml"));
|
yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"));
|
||||||
}
|
}
|
||||||
Properties values = yaml.getObject();
|
Properties values = yaml.getObject();
|
||||||
assertNotNull(values);
|
assertNotNull(values);
|
||||||
|
|||||||
Reference in new issue
Block a user