From 30c5f9f114a9e1824ff030c19d651f75ed203667 Mon Sep 17 00:00:00 2001 From: zhouzhibo Date: Sun, 18 Oct 2026 00:20:53 +0800 Subject: [PATCH] feat: add rolling logs and separate local and production profiles --- .gitignore | 2 + README.md | 63 +++++++- deploy/hera-prod.yml.example | 14 ++ .../hera/common/GlobalExceptionHandler.java | 74 +++++++++- .../EnvironmentConfigurationValidator.java | 43 ++++++ .../hera/logging/RequestLoggingFilter.java | 66 +++++++++ .../hera/logging/SafeThrowableConverter.java | 47 ++++++ src/main/resources/application-local.yml | 15 ++ src/main/resources/application-prod.yml | 13 ++ src/main/resources/application.yml | 19 ++- src/main/resources/logback-spring.xml | 55 +++++++ .../hera/config/EnvironmentProfilesTest.java | 77 ++++++++++ .../hera/logging/RequestLoggingTest.java | 139 ++++++++++++++++++ .../hera/nfc/NfcMySqlIntegrationTest.java | 4 +- .../hera/storage/CosIntegrationTest.java | 4 +- .../FileGatewayReadIntegrationTest.java | 2 +- .../UserAccountSchemaIntegrationTest.java | 4 +- 17 files changed, 621 insertions(+), 20 deletions(-) create mode 100644 deploy/hera-prod.yml.example create mode 100644 src/main/java/com/hongzhen/hera/config/EnvironmentConfigurationValidator.java create mode 100644 src/main/java/com/hongzhen/hera/logging/RequestLoggingFilter.java create mode 100644 src/main/java/com/hongzhen/hera/logging/SafeThrowableConverter.java create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/com/hongzhen/hera/config/EnvironmentProfilesTest.java create mode 100644 src/test/java/com/hongzhen/hera/logging/RequestLoggingTest.java diff --git a/.gitignore b/.gitignore index 68b64eb..d78d62b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ target/ *.iml .hera-local.yml +.hera-prod.yml +logs/ diff --git a/README.md b/README.md index fb41872..125cba8 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,64 @@ mvn spring-boot:run 默认地址:`http://localhost:8080` 本机敏感配置可放在仓库根目录的 `.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、文件凭证等。 +未知路由只记 ``,不打印可能包含敏感信息的原始路径。异常日志保留类型、原因链类型和 +堆栈位置,但不打印异常消息,以免泄露 SQL 参数或包含微信密钥的 URL。 +默认不启用 SQL 参数与 HTTP wire DEBUG 日志,不建议在线上自行打开此类日志。 + +```bash +tail -f logs/hera.log +tail -f logs/hera-error.log +``` ## Redis 使用 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_SSL` 环境变量;本机密码配置在被 Git 忽略的 `.hera-local.yml` 中。 @@ -44,7 +96,8 @@ mvn spring-boot:run 存储桶为 `hera-1319913268`,地域为 `ap-beijing`,桶权限保持私有读。 运行环境必须配置 `TENCENT_COS_SECRET_ID`、`TENCENT_COS_SECRET_KEY`。 推荐仅授权该桶 `hera/avatar/*`、`hera/files/*` 路径的 `cos:PutObject`、`cos:GetObject` 权限。 -密钥不写入配置文件或 Git。未配置密钥时允许服务启动,文件操作会返回配置错误。 +密钥只通过环境变量或被忽略的私有配置提供,不进入受版本管理的配置或 Git。 +未配置 COS 密钥时允许服务启动,文件操作会返回配置错误。 - `POST /api/user/avatar`:上传 JPG、PNG、WebP 头像,最大 5MB。 - `POST /api/files`:通过 multipart 字段 `file` 上传普通文件,最大 5MB。 @@ -75,8 +128,8 @@ mvn spring-boot:run ## 开发说明 - 当前已默认关闭微信 mock,并接入微信 `jscode2session`。 -- 数据持久层使用 MyBatis Mapper XML,首次启动会执行 `schema.sql` 创建缺失的数据表。 -- 数据库连接默认指向 `101.43.139.56:3306/hera`,生产部署建议通过 `DB_URL`、`DB_USERNAME`、`DB_PASSWORD` 覆盖。 +- 数据持久层使用 MyBatis Mapper XML,本地启动会执行 `schema.sql` 创建缺失的数据表,线上默认不执行。 +- 本地数据库连接默认指向 `101.43.139.56:3306/hera`,线上默认内网 `10.0.8.9:3306/hera`,可通过环境参数覆盖。 - `WECHAT_APP_SECRET` 请优先使用服务器环境变量配置,不建议提交到公开仓库。 - `/api/auth/sms` 开发阶段返回 `mockCode`,接入短信厂商后移除返回值。 - 正式上线前需要替换 `hera.jwt-secret`、短信服务实现和协议正文。 diff --git a/deploy/hera-prod.yml.example b/deploy/hera-prod.yml.example new file mode 100644 index 0000000..bc9c7a2 --- /dev/null +++ b/deploy/hera-prod.yml.example @@ -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}" diff --git a/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java b/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java index f6f7695..4610ea8 100644 --- a/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java +++ b/src/main/java/com/hongzhen/hera/common/GlobalExceptionHandler.java @@ -1,6 +1,9 @@ package com.hongzhen.hera.common; 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.dao.DataIntegrityViolationException; 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.ResponseStatus; 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 public class GlobalExceptionHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); /** 登录过期与普通业务校验区分,供客户端清理会话并重新登录。 */ @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ApiResponse handleAuthentication(AuthenticationException exception) { + LOGGER.warn("authentication_rejected"); return new ApiResponse(401, exception.getMessage(), null); } + /** + * 业务拒绝只记录事件,不记录可能包含外部接口信息的异常消息。 + */ @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleBusiness(BusinessException exception) { + LOGGER.warn("business_rejected exceptionType={}", exception.getClass().getSimpleName()); 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) public ApiResponse handleValidation(Exception exception) { + LOGGER.warn("validation_rejected exceptionType={}", exception.getClass().getSimpleName()); return ApiResponse.fail("参数不正确"); } + /** + * 数据约束错误不打印 SQL、参数或底层异常消息。 + */ @ExceptionHandler(DataIntegrityViolationException.class) @ResponseStatus(HttpStatus.CONFLICT) public ApiResponse handleDataIntegrity(DataIntegrityViolationException exception) { + LOGGER.warn("data_integrity_rejected"); return ApiResponse.fail("数据重复或关联关系不正确"); } + + /** + * 保留框架的标准 HTTP 状态,防止兜底处理把 404、405 等误报为服务故障。 + */ + @ExceptionHandler({HttpRequestMethodNotSupportedException.class, HttpMediaTypeNotSupportedException.class, + HttpMediaTypeNotAcceptableException.class, NoHandlerFoundException.class, ResponseStatusException.class}) + public org.springframework.http.ResponseEntity> 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(status.value(), status.getReasonPhrase(), null)); + } + + /** + * 上传超限不记录文件内容或文件名。 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) + public ApiResponse handleUploadLimit(MaxUploadSizeExceededException exception) { + LOGGER.warn("upload_size_rejected"); + return new ApiResponse(HttpStatus.PAYLOAD_TOO_LARGE.value(), "上传文件超过大小限制", null); + } + + /** + * 未知异常记录类型与安全定位堆栈,客户端只收到通用错误。 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleUnexpected(Exception exception) { + LOGGER.error("request_failed\n{}", SafeThrowableConverter.stackTrace(exception)); + return new ApiResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务暂时不可用,请稍后重试", null); + } } diff --git a/src/main/java/com/hongzhen/hera/config/EnvironmentConfigurationValidator.java b/src/main/java/com/hongzhen/hera/config/EnvironmentConfigurationValidator.java new file mode 100644 index 0000000..83dccbf --- /dev/null +++ b/src/main/java/com/hongzhen/hera/config/EnvironmentConfigurationValidator.java @@ -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 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 字节"); + } + } + } +} diff --git a/src/main/java/com/hongzhen/hera/logging/RequestLoggingFilter.java b/src/main/java/com/hongzhen/hera/logging/RequestLoggingFilter.java new file mode 100644 index 0000000..0ca4ba7 --- /dev/null +++ b/src/main/java/com/hongzhen/hera/logging/RequestLoggingFilter.java @@ -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 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 ? "" : 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); } + } + } + } +} diff --git a/src/main/java/com/hongzhen/hera/logging/SafeThrowableConverter.java b/src/main/java/com/hongzhen/hera/logging/SafeThrowableConverter.java new file mode 100644 index 0000000..38315ad --- /dev/null +++ b/src/main/java/com/hongzhen/hera/logging/SafeThrowableConverter.java @@ -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(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..c81f8b1 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -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}" diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..bfa05df --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 986d78a..66bbf45 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,11 +1,12 @@ --- server: - port: 8080 + port: "${SERVER_PORT:8080}" spring: application: name: hera + profiles: + default: local redis: - host: "${REDIS_HOST:101.43.139.56}" port: "${REDIS_PORT:6379}" username: "${REDIS_USERNAME:}" password: "${REDIS_PASSWORD:}" @@ -21,19 +22,23 @@ spring: enabled: false datasource: 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}" password: "${DB_PASSWORD:}" sql: init: - mode: always encoding: UTF-8 servlet: multipart: max-file-size: 5MB max-request-size: 5MB - config: - import: optional:file:./.hera-local.yml +logging: + 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: mapper-locations: classpath*:/mapper/**/*.xml configuration: @@ -46,7 +51,7 @@ hera: region: "${TENCENT_COS_REGION:ap-beijing}" signed-url-seconds: 900 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}" wx: app-id: "${WECHAT_APP_ID:wxa76a2ef9e8ba612e}" diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..a6c4b44 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + UTF-8${LOG_PATTERN} + + + + ${LOG_PATH}/hera.log + UTF-8${LOG_PATTERN} + + ${LOG_PATH}/archive/hera.%d{yyyy-MM-dd,Asia/Shanghai}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + true + + + + + ${LOG_PATH}/hera-error.log + ERROR + UTF-8${LOG_PATTERN} + + ${LOG_PATH}/archive/hera-error.%d{yyyy-MM-dd,Asia/Shanghai}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/hongzhen/hera/config/EnvironmentProfilesTest.java b/src/test/java/com/hongzhen/hera/config/EnvironmentProfilesTest.java new file mode 100644 index 0000000..d090282 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/config/EnvironmentProfilesTest.java @@ -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 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; + } +} diff --git a/src/test/java/com/hongzhen/hera/logging/RequestLoggingTest.java b/src/test/java/com/hongzhen/hera/logging/RequestLoggingTest.java new file mode 100644 index 0000000..5689ca5 --- /dev/null +++ b/src/test/java/com/hongzhen/hera/logging/RequestLoggingTest.java @@ -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 logs = new ListAppender() { + @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 get(@PathVariable String id) { return ApiResponse.ok(id); } + + @PostMapping("/test/fail") + public ApiResponse fail(@RequestBody Map body) { + throw new IllegalStateException("exception-secret", new IllegalArgumentException("cause-secret")); + } + + @GetMapping("/test/rejected") + public ApiResponse rejected() { throw new BusinessException("business-secret"); } + } +} diff --git a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java index 899e836..77cbc8a 100644 --- a/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java +++ b/src/test/java/com/hongzhen/hera/nfc/NfcMySqlIntegrationTest.java @@ -64,8 +64,8 @@ class NfcMySqlIntegrationTest { YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); FileSystemResource local = new FileSystemResource(".hera-local.yml"); 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 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 ClassPathResource("application-local.yml")}); Properties values = yaml.getObject(); assertNotNull(values); StandardEnvironment env = new StandardEnvironment(); diff --git a/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java b/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java index 10dc6a6..c9916de 100644 --- a/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java +++ b/src/test/java/com/hongzhen/hera/storage/CosIntegrationTest.java @@ -40,9 +40,9 @@ class CosIntegrationTest { YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); FileSystemResource local = new FileSystemResource(".hera-local.yml"); if (local.exists()) { - yaml.setResources(new ClassPathResource("application.yml"), local); + yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"), local); } else { - yaml.setResources(new ClassPathResource("application.yml")); + yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml")); } Properties values = yaml.getObject(); assertNotNull(values); diff --git a/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java b/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java index 0b6b785..0093044 100644 --- a/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java +++ b/src/test/java/com/hongzhen/hera/storage/FileGatewayReadIntegrationTest.java @@ -26,7 +26,7 @@ class FileGatewayReadIntegrationTest { assertTrue(reference.startsWith("cos://hera/avatar/")); YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); 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(); assertNotNull(values); StandardEnvironment environment = new StandardEnvironment(); diff --git a/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java b/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java index acd7e34..d20c453 100644 --- a/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java +++ b/src/test/java/com/hongzhen/hera/user/UserAccountSchemaIntegrationTest.java @@ -28,9 +28,9 @@ class UserAccountSchemaIntegrationTest { YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); FileSystemResource local = new FileSystemResource(".hera-local.yml"); if (local.exists()) { - yaml.setResources(new ClassPathResource("application.yml"), local); + yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml"), local); } else { - yaml.setResources(new ClassPathResource("application.yml")); + yaml.setResources(new ClassPathResource("application.yml"), new ClassPathResource("application-local.yml")); } Properties values = yaml.getObject(); assertNotNull(values);