feat: 新增系统日志记录、查看与自动清理,VO 转换改用 MapStruct

- 新增访问/登录日志表与拦截器,登录成功/失败均记录,接口访问同时输出到应用日志
- 新增 /api/log/* 查询与 SSE 实时推送,运行日志默认最近 1 小时并支持日期/级别/关键字筛选
- 新增日志定时清理,保留天数可配置(默认 30 天,0 为永久保留)
- 前端新增系统日志页(独立 URL 子路径、标签偏好记忆),入口位于顶部用户下拉菜单
- 实体到视图对象的属性拷贝改用 MapStruct 编译期生成
This commit is contained in:
2026-09-18 17:18:03 +08:00
parent e49b6b7b81
commit e82e0a4ef9
38 changed files with 2464 additions and 153 deletions
@@ -7,6 +7,8 @@ import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.io.IOException;
@@ -79,6 +81,19 @@ public class GlobalExceptionHandler {
return Result.error(404, "资源不存在");
}
/**
* 处理异步请求不可用或超时异常。
*
* <p>SSE 等异步请求在客户端断开后,容器会抛出该异常,此时响应已无法写入,
* 直接忽略即可,避免全局异常处理器尝试写入 JSON 产生额外的错误日志。</p>
*
* @param e 异步请求异常
*/
@ExceptionHandler({AsyncRequestNotUsableException.class, AsyncRequestTimeoutException.class})
public void handleAsyncRequestException(Exception e) {
// 连接已断开,无需处理
}
/**
* 处理未捕获的通用异常。
*
@@ -1,5 +1,6 @@
package cn.rosercode.finance.config;
import cn.rosercode.finance.interceptor.AccessLogInterceptor;
import cn.rosercode.finance.interceptor.JwtInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
@@ -14,24 +15,32 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebConfig implements WebMvcConfigurer {
private final JwtInterceptor jwtInterceptor;
private final AccessLogInterceptor accessLogInterceptor;
/**
* 构造方法,注入 JWT 拦截器。
* 构造方法,注入 JWT 拦截器与访问日志拦截器。
*
* @param jwtInterceptor JWT 拦截器实例
* @param jwtInterceptor JWT 拦截器实例
* @param accessLogInterceptor 访问日志拦截器实例
*/
public WebConfig(JwtInterceptor jwtInterceptor) {
public WebConfig(JwtInterceptor jwtInterceptor, AccessLogInterceptor accessLogInterceptor) {
this.jwtInterceptor = jwtInterceptor;
this.accessLogInterceptor = accessLogInterceptor;
}
/**
* 配置拦截器规则。
* <p>对 /api/** 路径下的请求启用 JWT 认证,排除登录和注册接口。</p>
* <p>先注册访问日志拦截器以覆盖全部 /api/** 请求(含认证失败的请求),
* 再注册 JWT 认证拦截器,排除登录和注册接口。</p>
*
* @param registry 拦截器注册器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 访问日志拦截器需先注册,确保 JWT 认证失败的请求也能记录到访问日志
registry.addInterceptor(accessLogInterceptor)
.addPathPatterns("/api/**");
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
// 登录、注册与状态查询接口不需要认证;数据库状态查询用于前端判断是否已配置
@@ -15,9 +15,15 @@ import lombok.Data;
@JsonIgnoreProperties(ignoreUnknown = true)
public class AppConfig {
/** 默认日志保留天数 */
public static final int DEFAULT_LOG_RETENTION_DAYS = 30;
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
private boolean registrationEnabled = false;
/** 通知链接地址(完整链接,如 https://finance.rosercode.cn/bill-import),为空时通知不附带链接 */
private String notificationUrl;
/** 日志保留天数(访问日志/登录日志),0 或负数表示永久保留,默认 30 天 */
private Integer logRetentionDays = DEFAULT_LOG_RETENTION_DAYS;
}
@@ -3,6 +3,7 @@ package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.config.app.AppConfig;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.dto.LogRetentionRequest;
import cn.rosercode.finance.dto.NotificationUrlRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,7 +16,8 @@ import org.springframework.web.bind.annotation.RestController;
/**
* 应用配置控制器
*
* <p>提供全局应用级配置的读写接口,当前包含通知链接地址(账单导入提醒中的跳转链接)。</p>
* <p>提供全局应用级配置的读写接口,当前包含通知链接地址(账单导入提醒中的跳转链接)
* 与日志保留天数(访问日志/登录日志自动清理)。</p>
*
* @date 2026-09-18 15:55:15
*/
@@ -55,4 +57,31 @@ public class AppConfigController {
appConfigStore.save(config);
return Result.success(config.getNotificationUrl());
}
/**
* 查询日志保留天数。
*
* @return 保留天数,0 表示永久保留
*/
@GetMapping("/log-retention")
@Operation(summary = "查询日志保留天数", description = "读取访问日志与登录日志的自动清理保留天数")
public Result<Integer> getLogRetention() {
Integer days = appConfigStore.load().getLogRetentionDays();
return Result.success(days == null ? AppConfig.DEFAULT_LOG_RETENTION_DAYS : days);
}
/**
* 更新日志保留天数。
*
* @param request 日志保留策略请求
* @return 更新后的保留天数
*/
@PutMapping("/log-retention")
@Operation(summary = "更新日志保留天数", description = "设置访问日志与登录日志的自动清理保留天数,0 表示永久保留")
public Result<Integer> updateLogRetention(@RequestBody LogRetentionRequest request) {
AppConfig config = appConfigStore.load();
config.setLogRetentionDays(request.getDays());
appConfigStore.save(config);
return Result.success(config.getLogRetentionDays());
}
}
@@ -0,0 +1,100 @@
package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.LogQuery;
import cn.rosercode.finance.service.AppLogStreamService;
import cn.rosercode.finance.service.LogService;
import cn.rosercode.finance.vo.AppLogVO;
import cn.rosercode.finance.vo.HttpLogVO;
import cn.rosercode.finance.vo.LoginLogVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
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.servlet.mvc.method.annotation.SseEmitter;
/**
* 系统日志控制器
*
* <p>提供 HTTP 访问日志、登录日志的分页查询与后端运行日志文件查看接口。</p>
*
* @date 2026-09-18 16:53:19
*/
@RestController
@RequestMapping("/api/log")
@Tag(name = "系统日志", description = "访问日志、登录日志与后端运行日志查看接口")
public class LogController {
private final LogService logService;
private final AppLogStreamService appLogStreamService;
public LogController(LogService logService, AppLogStreamService appLogStreamService) {
this.logService = logService;
this.appLogStreamService = appLogStreamService;
}
/**
* 分页查询 HTTP 访问日志。
*
* @param query 查询条件
* @return 分页访问日志
*/
@GetMapping("/http")
@Operation(summary = "分页查询访问日志", description = "按关键字、请求方法、状态码筛选接口访问记录")
public Result<PageResult<HttpLogVO>> pageHttpLogs(LogQuery query) {
return Result.success(logService.pageHttpLogs(query));
}
/**
* 分页查询登录日志。
*
* @param query 查询条件
* @return 分页登录日志
*/
@GetMapping("/login")
@Operation(summary = "分页查询登录日志", description = "按关键字、成功与否筛选登录尝试记录")
public Result<PageResult<LoginLogVO>> pageLoginLogs(LogQuery query) {
return Result.success(logService.pageLoginLogs(query));
}
/**
* 查看后端运行日志。
*
* @param date 日志日期(yyyy-MM-dd),为空表示当前日志文件
* @param level 日志级别过滤,为空表示全部
* @param keyword 关键字过滤,为空表示全部
* @param lines 返回的最大行数,默认 500
* @param since 起始时间戳(epoch 毫秒),为空表示不限制
* @return 运行日志内容
*/
@GetMapping("/app")
@Operation(summary = "查看运行日志", description = "读取后端日志文件,支持按日期、级别、关键字与起始时间筛选")
public Result<AppLogVO> appLog(@RequestParam(required = false) String date,
@RequestParam(required = false) String level,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "500") int lines,
@RequestParam(required = false) Long since) {
return Result.success(logService.readAppLog(date, level, keyword, lines, since));
}
/**
* 实时推送后端运行日志(SSE)。
*
* @param level 日志级别过滤,为空表示全部
* @param keyword 关键字过滤,为空表示全部
* @param lines 初始回放行数,默认 200
* @param since 起始时间戳(epoch 毫秒),为空表示不限制
* @return SSE 发射器
*/
@GetMapping("/app/stream")
@Operation(summary = "实时推送运行日志", description = "以 SSE 持续推送日志文件新增内容,连接建立时先回放最近若干行")
public SseEmitter streamAppLog(@RequestParam(required = false) String level,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "200") int lines,
@RequestParam(required = false) Long since) {
return appLogStreamService.stream(level, keyword, lines, since);
}
}
@@ -0,0 +1,135 @@
package cn.rosercode.finance.converter;
import cn.rosercode.finance.entity.AiUsage;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.entity.NotificationLog;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.User;
import cn.rosercode.finance.vo.AiUsageVO;
import cn.rosercode.finance.vo.CategoryVO;
import cn.rosercode.finance.vo.HttpLogVO;
import cn.rosercode.finance.vo.LoginLogVO;
import cn.rosercode.finance.vo.NotificationChannelVO;
import cn.rosercode.finance.vo.NotificationLogVO;
import cn.rosercode.finance.vo.TagVO;
import cn.rosercode.finance.vo.TransactionVO;
import cn.rosercode.finance.vo.UserVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
import java.util.List;
/**
* 实体与视图对象之间的转换器,由 MapStruct 在编译期生成实现。
*
* <p>同名同类型属性自动映射;含派生逻辑、默认值或需脱敏的字段通过 {@link Mapping} 显式声明。</p>
*
* @date 2026-09-18 17:09:11
*/
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface VoConverter {
/**
* 分类实体转视图对象。
*
* @param category 分类实体
* @return 分类视图对象
*/
CategoryVO toCategoryVO(Category category);
/**
* 标签实体转视图对象。
*
* @param tag 标签实体
* @return 标签视图对象
*/
TagVO toTagVO(Tag tag);
/**
* 标签实体列表转视图对象列表。
*
* @param tags 标签实体列表
* @return 标签视图对象列表
*/
List<TagVO> toTagVOList(List<Tag> tags);
/**
* AI 使用记录转视图对象。
*
* @param usage 使用记录实体
* @return 使用明细视图对象
*/
AiUsageVO toAiUsageVO(AiUsage usage);
/**
* HTTP 访问日志转视图对象。
*
* @param httpLog 访问日志实体
* @return 访问日志视图对象
*/
HttpLogVO toHttpLogVO(HttpLog httpLog);
/**
* 登录日志转视图对象。
*
* @param loginLog 登录日志实体
* @return 登录日志视图对象
*/
LoginLogVO toLoginLogVO(LoginLog loginLog);
/**
* 通知发送日志转视图对象。
*
* @param notificationLog 通知日志实体
* @return 通知日志视图对象
*/
@Mapping(target = "success", expression = "java(Boolean.TRUE.equals(notificationLog.getSuccess()))")
NotificationLogVO toNotificationLogVO(NotificationLog notificationLog);
/**
* 用户实体转视图对象(不含密码等敏感字段)。
*
* @param user 用户实体
* @return 用户视图对象
*/
UserVO toUserVO(User user);
/**
* 交易实体与关联分类转视图对象。
*
* <p>分类名称/图标取自关联分类,未关联时为空字符串;标签列表由调用方另行填充。</p>
*
* @param transaction 交易实体
* @param category 关联分类实体,可为 null
* @return 交易视图对象
*/
@Mapping(target = "id", source = "transaction.id")
@Mapping(target = "type", source = "transaction.type")
@Mapping(target = "createdAt", source = "transaction.createdAt")
@Mapping(target = "categoryName", source = "category.name", defaultValue = "")
@Mapping(target = "categoryIcon", source = "category.icon", defaultValue = "")
@Mapping(target = "remarkHidden",
expression = "java(transaction.getRemarkHidden() == null ? Boolean.FALSE : transaction.getRemarkHidden())")
@Mapping(target = "includeInStats",
expression = "java(transaction.getIncludeInStats() == null ? Boolean.TRUE : transaction.getIncludeInStats())")
@Mapping(target = "tags", ignore = true)
TransactionVO toTransactionVO(Transaction transaction, Category category);
/**
* 通知渠道实体转视图对象。
*
* <p>配置 JSON 解析与密码脱敏由调用方处理,此处仅映射基础字段。</p>
*
* @param channel 渠道实体
* @return 渠道视图对象
*/
@Mapping(target = "enabled", expression = "java(Boolean.TRUE.equals(channel.getEnabled()))")
@Mapping(target = "config", ignore = true)
@Mapping(target = "hasPassword", ignore = true)
NotificationChannelVO toNotificationChannelVO(NotificationChannel channel);
}
@@ -0,0 +1,30 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 日志查询DTO,用于接收访问日志与登录日志的分页筛选条件。
*
* @date 2026-09-18 16:53:07
*/
@Data
public class LogQuery {
/** 当前页码,默认第1页 */
private Integer page = 1;
/** 每页条数,默认20条 */
private Integer size = 20;
/** 关键字,模糊匹配路径/参数/IP/用户名 */
private String keyword;
/** 请求方法筛选(访问日志使用) */
private String method;
/** 响应状态码筛选(访问日志使用) */
private Integer status;
/** 是否成功筛选(登录日志使用) */
private Boolean success;
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 日志保留策略请求参数,用于设置访问日志与登录日志的保留天数。
*
* @date 2026-09-18 17:15:41
*/
@Data
public class LogRetentionRequest {
/** 保留天数,0 或负数表示永久保留 */
private Integer days;
}
@@ -0,0 +1,49 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* HTTP 访问日志实体类,对应数据库表 t_http_log,记录每次接口请求的方法、路径、状态与耗时。
*
* @date 2026-09-18 16:53:05
*/
@Data
@TableName("t_http_log")
public class HttpLog {
/** 日志ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户ID,未登录或认证失败时为空 */
private Long userId;
/** 请求方法 */
private String method;
/** 请求路径 */
private String path;
/** URL 查询参数 */
private String queryString;
/** HTTP 响应状态码 */
private Integer status;
/** 处理耗时(毫秒) */
private Long durationMs;
/** 客户端IP */
private String ip;
/** User-Agent */
private String userAgent;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,43 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 登录日志实体类,对应数据库表 t_login_log,记录每次登录尝试的结果。
*
* @date 2026-09-18 16:53:05
*/
@Data
@TableName("t_login_log")
public class LoginLog {
/** 日志ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户ID,登录失败或用户不存在时为空 */
private Long userId;
/** 登录用户名 */
private String username;
/** 是否成功 */
private Boolean success;
/** 结果描述或失败原因 */
private String message;
/** 客户端IP */
private String ip;
/** User-Agent */
private String userAgent;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,135 @@
package cn.rosercode.finance.interceptor;
import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.service.LogService;
import cn.rosercode.finance.util.RequestUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* HTTP 访问日志拦截器。
*
* <p>在请求开始时记录起始时间,请求完成后根据状态码、耗时等信息写入访问日志。
* 日志写入失败不会影响主请求流程。</p>
*
* @date 2026-09-18 16:53:21
*/
@Component
public class AccessLogInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(AccessLogInterceptor.class);
/** 请求属性名:请求开始时间 */
private static final String START_TIME_ATTR = "accessLogStartTime";
/** 请求路径最大长度 */
private static final int PATH_LIMIT = 255;
/** 查询参数最大长度 */
private static final int QUERY_LIMIT = 1000;
/** 日志输出中请求目标的最大长度 */
private static final int TARGET_LIMIT = 300;
private final LogService logService;
/**
* 构造方法,注入日志服务。
*
* @param logService 日志服务实例
*/
public AccessLogInterceptor(LogService logService) {
this.logService = logService;
}
/**
* 请求预处理:记录请求开始时间。
*
* @param request HTTP 请求对象
* @param response HTTP 响应对象
* @param handler 处理器对象
* @return 始终返回 true,不阻断请求
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(START_TIME_ATTR, System.currentTimeMillis());
return true;
}
/**
* 请求完成后写入访问日志。
*
* @param request HTTP 请求对象
* @param response HTTP 响应对象
* @param handler 处理器对象
* @param ex 处理过程中抛出的异常,可能为 null
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 跳过 CORS 预检请求,避免产生无意义的日志
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return;
}
// 跳过日志相关接口,避免查看/实时读取日志时持续写入访问日志造成自我放大
if (request.getRequestURI().startsWith("/api/log/")) {
return;
}
Object start = request.getAttribute(START_TIME_ATTR);
long duration = start instanceof Long ? System.currentTimeMillis() - (Long) start : 0L;
HttpLog httpLog = new HttpLog();
Object userId = request.getAttribute("userId");
if (userId instanceof Long id) {
httpLog.setUserId(id);
}
httpLog.setMethod(request.getMethod());
httpLog.setPath(truncate(request.getRequestURI(), PATH_LIMIT));
httpLog.setQueryString(truncate(request.getQueryString(), QUERY_LIMIT));
httpLog.setStatus(response.getStatus());
httpLog.setDurationMs(duration);
httpLog.setIp(RequestUtils.getClientIp(request));
httpLog.setUserAgent(RequestUtils.getUserAgent(request));
logService.recordHttpLog(httpLog);
// 同时输出一行到应用日志,便于在前端「运行日志」中实时查看接口调用
log.info("HTTP {} {} -> {} ({}ms) user={} ip={}",
httpLog.getMethod(), buildTarget(request), response.getStatus(), duration,
httpLog.getUserId(), httpLog.getIp());
}
/**
* 构造用于日志输出的请求目标(路径 + 查询参数)。
*
* @param request HTTP 请求
* @return 请求目标字符串
*/
private String buildTarget(HttpServletRequest request) {
String queryString = request.getQueryString();
String target = request.getRequestURI();
if (queryString != null && !queryString.isBlank()) {
target = target + "?" + queryString;
}
return truncate(target, TARGET_LIMIT);
}
/**
* 截断超长字符串。
*
* @param value 原始字符串
* @param limit 最大长度
* @return 截断后的字符串,入参为空时返回 null
*/
private String truncate(String value, int limit) {
if (value == null) {
return null;
}
return value.length() <= limit ? value : value.substring(0, limit);
}
}
@@ -64,6 +64,11 @@ public class JwtInterceptor implements HandlerInterceptor {
token = token.substring(7);
}
// EventSource 无法自定义请求头,允许通过 token 查询参数传递(主要用于 SSE 日志推送)
if (token == null || token.isBlank()) {
token = request.getParameter("token");
}
// Token 为空或验证不通过时,返回 401 未授权响应
if (token == null || !jwtUtil.validateToken(token)) {
response.setContentType("application/json;charset=UTF-8");
@@ -0,0 +1,14 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.HttpLog;
import org.apache.ibatis.annotations.Mapper;
/**
* HTTP 访问日志Mapper接口,继承MyBatis-Plus的BaseMapper,提供访问日志表的基础CRUD操作。
*
* @date 2026-09-18 16:53:06
*/
@Mapper
public interface HttpLogMapper extends BaseMapper<HttpLog> {
}
@@ -0,0 +1,14 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.LoginLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 登录日志Mapper接口,继承MyBatis-Plus的BaseMapper,提供登录日志表的基础CRUD操作。
*
* @date 2026-09-18 16:53:06
*/
@Mapper
public interface LoginLogMapper extends BaseMapper<LoginLog> {
}
@@ -0,0 +1,68 @@
package cn.rosercode.finance.scheduler;
import cn.rosercode.finance.config.app.AppConfig;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.mapper.HttpLogMapper;
import cn.rosercode.finance.mapper.LoginLogMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 日志清理调度器。
*
* <p>每天凌晨按配置的保留天数清理过期的访问日志与登录日志,保留天数读取自应用配置
* {@code config/app.json},默认 30 天;配置为 0 或负数时永久保留、不执行清理。</p>
*
* @date 2026-09-18 17:15:49
*/
@Component
public class LogCleanupScheduler {
private static final Logger log = LoggerFactory.getLogger(LogCleanupScheduler.class);
private final AppConfigStore appConfigStore;
private final HttpLogMapper httpLogMapper;
private final LoginLogMapper loginLogMapper;
public LogCleanupScheduler(AppConfigStore appConfigStore,
HttpLogMapper httpLogMapper,
LoginLogMapper loginLogMapper) {
this.appConfigStore = appConfigStore;
this.httpLogMapper = httpLogMapper;
this.loginLogMapper = loginLogMapper;
}
/**
* 每天凌晨 3 点清理超过保留天数的日志。
*
* <p>数据库未配置或不可用时仅记录调试日志,避免影响定时任务线程。</p>
*/
@Scheduled(cron = "0 0 3 * * ?")
public void cleanup() {
Integer configured = appConfigStore.load().getLogRetentionDays();
int retentionDays = configured == null ? AppConfig.DEFAULT_LOG_RETENTION_DAYS : configured;
if (retentionDays <= 0) {
log.debug("日志保留策略为永久保留,跳过清理");
return;
}
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
try {
int httpDeleted = httpLogMapper.delete(new LambdaQueryWrapper<HttpLog>()
.lt(HttpLog::getCreatedAt, cutoff));
int loginDeleted = loginLogMapper.delete(new LambdaQueryWrapper<LoginLog>()
.lt(LoginLog::getCreatedAt, cutoff));
log.info("日志清理完成(保留 {} 天):删除访问日志 {} 条、登录日志 {} 条",
retentionDays, httpDeleted, loginDeleted);
} catch (Exception e) {
log.debug("跳过日志清理,数据库不可用:{}", e.getMessage());
}
}
}
@@ -0,0 +1,281 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.util.AppLogUtils;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* 应用运行日志实时推送服务。
*
* <p>基于 SSE(Server-Sent Events)将当前日志文件的新增行推送给前端:连接建立时先发送
* {@code meta} 事件回放最近若干行,随后每秒轮询文件增量并按行推送 {@code line} 事件。
* 客户端断开时自动停止对应的轮询任务。</p>
*
* @date 2026-09-18 17:01:07
*/
@Service
public class AppLogStreamService {
/** 增量轮询间隔(毫秒) */
private static final long POLL_INTERVAL_MS = 1000L;
/** 初始回放行数上限 */
private static final int MAX_INITIAL_LINES = 2000;
/** 单次读取的最大字节数 */
private static final int READ_CHUNK_SIZE = 1024 * 1024;
/** 日志推送任务调度器 */
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4, runnable -> {
Thread thread = new Thread(runnable, "app-log-stream");
thread.setDaemon(true);
return thread;
});
/**
* 建立日志实时推送连接。
*
* @param level 日志级别过滤,为空表示全部
* @param keyword 关键字过滤,为空表示全部
* @param lines 初始回放行数
* @param since 起始时间戳(epoch 毫秒),为空表示不限制
* @return SSE 发射器
*/
public SseEmitter stream(String level, String keyword, int lines, Long since) {
int initialLines = Math.min(Math.max(lines, 0), MAX_INITIAL_LINES);
SseEmitter emitter = new SseEmitter(0L);
Path file = AppLogUtils.logDir().resolve(AppLogUtils.CURRENT_LOG_FILE);
String levelFilter = level == null || level.isBlank() ? null : level.trim().toUpperCase();
String keywordFilter = keyword == null || keyword.isBlank() ? null : keyword.trim();
StreamTask task = new StreamTask(emitter, file, levelFilter, keywordFilter, initialLines, since);
task.start();
return emitter;
}
/**
* 应用关闭时停止所有推送任务。
*/
@PreDestroy
public void shutdown() {
scheduler.shutdownNow();
}
/**
* 单个连接的日志流任务。
*/
private class StreamTask {
private final SseEmitter emitter;
private final Path file;
private final String levelFilter;
private final String keywordFilter;
private final int initialLines;
private final Long since;
/** 已读取的字节偏移量 */
private long offset = 0;
/** 尚未构成完整行的字节缓冲 */
private final ByteArrayOutputStream pending = new ByteArrayOutputStream();
private ScheduledFuture<?> future;
private volatile boolean closed = false;
StreamTask(SseEmitter emitter, Path file, String levelFilter, String keywordFilter,
int initialLines, Long since) {
this.emitter = emitter;
this.file = file;
this.levelFilter = levelFilter;
this.keywordFilter = keywordFilter;
this.initialLines = initialLines;
this.since = since;
}
/** 回放初始行并启动增量轮询 */
void start() {
try {
List<String> initial = readInitialLines();
// 先发送元信息,前端据此初始化后再接收回放行
sendMeta(initial.size());
for (String line : initial) {
sendLine(line);
}
} catch (IOException e) {
completeWithError(e);
return;
}
emitter.onCompletion(this::stop);
emitter.onTimeout(this::stop);
emitter.onError(e -> stop());
future = scheduler.scheduleWithFixedDelay(this::poll, POLL_INTERVAL_MS, POLL_INTERVAL_MS,
TimeUnit.MILLISECONDS);
}
/**
* 读取日志文件末尾的初始行并更新读取偏移量。
*
* @return 命中过滤条件的初始行列表(按时间正序)
*/
private List<String> readInitialLines() throws IOException {
if (!Files.exists(file)) {
return List.of();
}
List<String> allLines = Files.readAllLines(file, StandardCharsets.UTF_8);
int from = Math.max(0, allLines.size() - initialLines);
List<String> matched = new ArrayList<>();
for (int i = from; i < allLines.size(); i++) {
String line = allLines.get(i);
// 按起始时间过滤,日志行无法解析时间时视为不匹配
if (since != null) {
Long timestamp = AppLogUtils.extractTimestamp(line);
if (timestamp == null || timestamp < since) {
continue;
}
}
if (matches(line)) {
matched.add(line);
}
}
offset = Files.size(file);
return matched;
}
/**
* 发送元信息事件。
*
* @param total 初始回放命中的行数
*/
private void sendMeta(int total) throws IOException {
String data = "{\"file\":\"" + AppLogUtils.CURRENT_LOG_FILE + "\",\"total\":" + total + "}";
emitter.send(SseEmitter.event().name("meta").data(data));
}
/** 轮询文件增量并推送完整行 */
private void poll() {
if (closed) {
return;
}
try {
if (!Files.exists(file)) {
return;
}
long size = Files.size(file);
if (size < offset) {
// 文件被截断或轮转,重置读取位置
offset = 0;
pending.reset();
}
while (size > offset) {
int chunk = (int) Math.min(size - offset, READ_CHUNK_SIZE);
byte[] buffer = new byte[chunk];
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(offset);
int read = raf.read(buffer);
if (read <= 0) {
break;
}
offset += read;
pending.write(buffer, 0, read);
}
emitCompleteLines();
}
} catch (Exception e) {
// 发送失败通常意味着连接已断开
stop();
}
}
/**
* 发送缓冲区中已完整的行,保留最后一段未结束的行等待下次补全。
*/
private void emitCompleteLines() throws IOException {
byte[] data = pending.toByteArray();
int lastNewline = -1;
for (int i = data.length - 1; i >= 0; i--) {
if (data[i] == '\n') {
lastNewline = i;
break;
}
}
if (lastNewline < 0) {
return;
}
String text = new String(data, 0, lastNewline, StandardCharsets.UTF_8);
pending.reset();
pending.write(data, lastNewline + 1, data.length - lastNewline - 1);
for (String rawLine : text.split("\n")) {
String line = rawLine.endsWith("\r") ? rawLine.substring(0, rawLine.length() - 1) : rawLine;
if (!line.isEmpty() && matches(line)) {
sendLine(line);
}
}
}
/**
* 判断日志行是否满足级别与关键字过滤条件。
*
* @param line 日志行
* @return 命中返回 true
*/
private boolean matches(String line) {
if (levelFilter != null && !levelFilter.equals(AppLogUtils.extractLevel(line))) {
return false;
}
return keywordFilter == null || line.contains(keywordFilter);
}
/**
* 推送一行日志。
*
* @param line 日志行
*/
private void sendLine(String line) throws IOException {
emitter.send(SseEmitter.event().name("line").data(line));
}
/**
* 以错误方式结束连接。
*
* @param e 异常
*/
private void completeWithError(IOException e) {
closed = true;
try {
emitter.completeWithError(e);
} catch (Exception ignored) {
// 连接可能已断开
}
}
/** 停止任务并关闭连接 */
synchronized void stop() {
if (closed) {
return;
}
closed = true;
if (future != null) {
future.cancel(false);
}
try {
emitter.complete();
} catch (Exception ignored) {
// 连接可能已断开
}
}
}
}
@@ -0,0 +1,59 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.LogQuery;
import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.vo.AppLogVO;
import cn.rosercode.finance.vo.HttpLogVO;
import cn.rosercode.finance.vo.LoginLogVO;
/**
* 日志服务接口,负责访问日志与登录日志的写入、分页查询以及应用运行日志的读取。
*
* @date 2026-09-18 16:53:17
*/
public interface LogService {
/**
* 记录一条 HTTP 访问日志。
*
* @param httpLog 访问日志实体
*/
void recordHttpLog(HttpLog httpLog);
/**
* 记录一条登录日志。
*
* @param loginLog 登录日志实体
*/
void recordLoginLog(LoginLog loginLog);
/**
* 分页查询 HTTP 访问日志。
*
* @param query 查询条件
* @return 分页后的访问日志
*/
PageResult<HttpLogVO> pageHttpLogs(LogQuery query);
/**
* 分页查询登录日志。
*
* @param query 查询条件
* @return 分页后的登录日志
*/
PageResult<LoginLogVO> pageLoginLogs(LogQuery query);
/**
* 读取应用运行日志文件内容。
*
* @param date 日志日期(yyyy-MM-dd),为空表示当前日志文件
* @param level 日志级别过滤,为空表示全部
* @param keyword 关键字过滤,为空表示全部
* @param lines 返回的最大行数
* @param since 起始时间戳(epoch 毫秒),为空表示不限制
* @return 运行日志视图对象
*/
AppLogVO readAppLog(String date, String level, String keyword, int lines, Long since);
}
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.entity.AiUsage;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.AiUsageMapper;
@@ -33,9 +34,11 @@ public class AiUsageServiceImpl implements AiUsageService {
private static final int ERROR_MESSAGE_LIMIT = 500;
private final AiUsageMapper aiUsageMapper;
private final VoConverter voConverter;
public AiUsageServiceImpl(AiUsageMapper aiUsageMapper) {
public AiUsageServiceImpl(AiUsageMapper aiUsageMapper, VoConverter voConverter) {
this.aiUsageMapper = aiUsageMapper;
this.voConverter = voConverter;
}
/**
@@ -97,7 +100,7 @@ public class AiUsageServiceImpl implements AiUsageService {
.orderByDesc(AiUsage::getCreatedAt)
.orderByDesc(AiUsage::getId)
.last("LIMIT " + limit))
.stream().map(this::toVO).collect(Collectors.toList());
.stream().map(voConverter::toAiUsageVO).collect(Collectors.toList());
AiUsageResultVO result = new AiUsageResultVO();
result.setSummary(summary);
@@ -135,29 +138,6 @@ public class AiUsageServiceImpl implements AiUsageService {
return summary;
}
/**
* 将使用记录实体转换为视图对象。
*
* @param usage 使用记录实体
* @return 使用明细视图对象
*/
private AiUsageVO toVO(AiUsage usage) {
AiUsageVO vo = new AiUsageVO();
vo.setId(usage.getId());
vo.setType(usage.getType());
vo.setModel(usage.getModel());
vo.setTransactionCount(usage.getTransactionCount());
vo.setPromptTokens(usage.getPromptTokens());
vo.setCompletionTokens(usage.getCompletionTokens());
vo.setTotalTokens(usage.getTotalTokens());
vo.setCachedTokens(usage.getCachedTokens());
vo.setSuccess(usage.getSuccess());
vo.setErrorMessage(usage.getErrorMessage());
vo.setDurationMs(usage.getDurationMs());
vo.setCreatedAt(usage.getCreatedAt());
return vo;
}
/**
* 截断过长的失败原因。
*
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.CategoryRequest;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.mapper.CategoryMapper;
@@ -20,9 +21,11 @@ import java.util.stream.Collectors;
public class CategoryServiceImpl implements CategoryService {
private final CategoryMapper categoryMapper;
private final VoConverter voConverter;
public CategoryServiceImpl(CategoryMapper categoryMapper) {
public CategoryServiceImpl(CategoryMapper categoryMapper, VoConverter voConverter) {
this.categoryMapper = categoryMapper;
this.voConverter = voConverter;
}
/**
@@ -36,7 +39,7 @@ public class CategoryServiceImpl implements CategoryService {
.eq(Category::getUserId, userId)
.orderByAsc(Category::getSortOrder)
.orderByAsc(Category::getId));
return categories.stream().map(this::toVO).collect(Collectors.toList());
return categories.stream().map(voConverter::toCategoryVO).collect(Collectors.toList());
}
/**
@@ -51,7 +54,7 @@ public class CategoryServiceImpl implements CategoryService {
.eq(Category::getType, type)
.orderByAsc(Category::getSortOrder)
.orderByAsc(Category::getId));
return categories.stream().map(this::toVO).collect(Collectors.toList());
return categories.stream().map(voConverter::toCategoryVO).collect(Collectors.toList());
}
/**
@@ -110,20 +113,4 @@ public class CategoryServiceImpl implements CategoryService {
categoryMapper.deleteById(id);
}
/**
* 将分类实体转换为视图对象
*
* @param category 分类实体
* @return 分类视图对象
*/
private CategoryVO toVO(Category category) {
CategoryVO vo = new CategoryVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setType(category.getType());
vo.setIcon(category.getIcon());
vo.setSortOrder(category.getSortOrder());
vo.setCreatedAt(category.getCreatedAt());
return vo;
}
}
@@ -0,0 +1,238 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.LogQuery;
import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.mapper.HttpLogMapper;
import cn.rosercode.finance.mapper.LoginLogMapper;
import cn.rosercode.finance.service.LogService;
import cn.rosercode.finance.util.AppLogUtils;
import cn.rosercode.finance.vo.AppLogVO;
import cn.rosercode.finance.vo.HttpLogVO;
import cn.rosercode.finance.vo.LoginLogVO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
/**
* 日志服务实现类,负责访问日志与登录日志的写入、分页查询以及应用运行日志的读取。
*
* @date 2026-09-18 16:53:29
*/
@Service
public class LogServiceImpl implements LogService {
private static final Logger log = LoggerFactory.getLogger(LogServiceImpl.class);
/** 分页每页最大条数 */
private static final int MAX_PAGE_SIZE = 100;
/** 读取运行日志的最大行数 */
private static final int MAX_APP_LOG_LINES = 2000;
/** 日期参数格式 */
private static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
private final HttpLogMapper httpLogMapper;
private final LoginLogMapper loginLogMapper;
private final VoConverter voConverter;
public LogServiceImpl(HttpLogMapper httpLogMapper, LoginLogMapper loginLogMapper, VoConverter voConverter) {
this.httpLogMapper = httpLogMapper;
this.loginLogMapper = loginLogMapper;
this.voConverter = voConverter;
}
/**
* {@inheritDoc}
*
* 写入失败仅打印告警,不影响主请求流程。
*/
@Override
public void recordHttpLog(HttpLog httpLog) {
try {
httpLogMapper.insert(httpLog);
} catch (Exception e) {
log.warn("记录访问日志失败: {}", e.getMessage());
}
}
/**
* {@inheritDoc}
*
* 写入失败仅打印告警,不影响登录流程。
*/
@Override
public void recordLoginLog(LoginLog loginLog) {
try {
loginLogMapper.insert(loginLog);
} catch (Exception e) {
log.warn("记录登录日志失败: {}", e.getMessage());
}
}
/**
* {@inheritDoc}
*/
@Override
public PageResult<HttpLogVO> pageHttpLogs(LogQuery query) {
LambdaQueryWrapper<HttpLog> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(query.getKeyword())) {
String keyword = query.getKeyword().trim();
wrapper.and(w -> w.like(HttpLog::getPath, keyword)
.or().like(HttpLog::getQueryString, keyword)
.or().like(HttpLog::getIp, keyword));
}
if (StringUtils.hasText(query.getMethod())) {
wrapper.eq(HttpLog::getMethod, query.getMethod().trim().toUpperCase());
}
if (query.getStatus() != null) {
wrapper.eq(HttpLog::getStatus, query.getStatus());
}
wrapper.orderByDesc(HttpLog::getId);
Page<HttpLog> page = new Page<>(safePage(query.getPage()), safeSize(query.getSize()));
Page<HttpLog> result = httpLogMapper.selectPage(page, wrapper);
List<HttpLogVO> records = result.getRecords().stream().map(voConverter::toHttpLogVO).toList();
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*/
@Override
public PageResult<LoginLogVO> pageLoginLogs(LogQuery query) {
LambdaQueryWrapper<LoginLog> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(query.getKeyword())) {
String keyword = query.getKeyword().trim();
wrapper.and(w -> w.like(LoginLog::getUsername, keyword)
.or().like(LoginLog::getIp, keyword)
.or().like(LoginLog::getMessage, keyword));
}
if (query.getSuccess() != null) {
wrapper.eq(LoginLog::getSuccess, query.getSuccess());
}
wrapper.orderByDesc(LoginLog::getId);
Page<LoginLog> page = new Page<>(safePage(query.getPage()), safeSize(query.getSize()));
Page<LoginLog> result = loginLogMapper.selectPage(page, wrapper);
List<LoginLogVO> records = result.getRecords().stream().map(voConverter::toLoginLogVO).toList();
return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize());
}
/**
* {@inheritDoc}
*
* 日志文件位于服务运行目录下的 logs 目录;指定日期时读取对应的滚动日志文件。
*/
@Override
public AppLogVO readAppLog(String date, String level, String keyword, int lines, Long since) {
int limit = Math.min(Math.max(lines, 1), MAX_APP_LOG_LINES);
String fileName = resolveLogFileName(date);
Path logDir = AppLogUtils.logDir();
Path filePath = logDir.resolve(fileName).normalize();
// 防御路径穿越:解析后的路径必须仍位于日志目录内
if (!filePath.startsWith(logDir)) {
throw new BusinessException("非法的日志文件");
}
AppLogVO vo = new AppLogVO();
vo.setFile(fileName);
if (!Files.exists(filePath)) {
vo.setTotal(0);
vo.setLines(List.of());
return vo;
}
try {
List<String> allLines = Files.readAllLines(filePath, StandardCharsets.UTF_8);
String levelFilter = StringUtils.hasText(level) ? level.trim().toUpperCase() : null;
String keywordFilter = StringUtils.hasText(keyword) ? keyword.trim() : null;
List<String> matched = new ArrayList<>();
for (String line : allLines) {
// 按起始时间过滤,日志行无法解析时间时视为不匹配
if (since != null) {
Long timestamp = AppLogUtils.extractTimestamp(line);
if (timestamp == null || timestamp < since) {
continue;
}
}
if (levelFilter != null && !levelFilter.equals(AppLogUtils.extractLevel(line))) {
continue;
}
if (keywordFilter != null && !line.contains(keywordFilter)) {
continue;
}
matched.add(line);
}
// 取末尾 limit 条并倒序展示,最新日志在前
int from = Math.max(0, matched.size() - limit);
List<String> tail = new ArrayList<>(matched.subList(from, matched.size()));
Collections.reverse(tail);
vo.setTotal(matched.size());
vo.setLines(tail);
return vo;
} catch (IOException e) {
throw new BusinessException("读取日志文件失败:" + e.getMessage());
}
}
/**
* 解析日志文件名。
*
* @param date 日志日期(yyyy-MM-dd),为空表示当前日志文件
* @return 日志文件名
*/
private String resolveLogFileName(String date) {
if (!StringUtils.hasText(date)) {
return AppLogUtils.CURRENT_LOG_FILE;
}
String trimmed = date.trim();
if (!DATE_PATTERN.matcher(trimmed).matches()) {
throw new BusinessException("日期格式应为 yyyy-MM-dd");
}
return AppLogUtils.LOG_FILE_PREFIX + "." + trimmed + ".log";
}
/**
* 修正页码,最小为 1。
*
* @param page 原始页码
* @return 安全页码
*/
private long safePage(Integer page) {
return page == null || page < 1 ? 1 : page;
}
/**
* 修正每页条数,范围 1-{@value MAX_PAGE_SIZE}。
*
* @param size 原始每页条数
* @return 安全每页条数
*/
private long safeSize(Integer size) {
if (size == null || size < 1) {
return 20;
}
return Math.min(size, MAX_PAGE_SIZE);
}
}
@@ -1,6 +1,7 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.NotificationChannelRequest;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.enums.NotificationChannelType;
@@ -28,11 +29,14 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
private final NotificationChannelMapper notificationChannelMapper;
private final ObjectMapper objectMapper;
private final VoConverter voConverter;
public NotificationChannelServiceImpl(NotificationChannelMapper notificationChannelMapper,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
VoConverter voConverter) {
this.notificationChannelMapper = notificationChannelMapper;
this.objectMapper = objectMapper;
this.voConverter = voConverter;
}
/**
@@ -124,14 +128,9 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
* @return 渠道视图对象
*/
private NotificationChannelVO toVO(NotificationChannel channel) {
NotificationChannelVO vo = new NotificationChannelVO();
vo.setId(channel.getId());
vo.setType(channel.getType());
vo.setName(channel.getName());
vo.setEnabled(Boolean.TRUE.equals(channel.getEnabled()));
vo.setCreatedAt(channel.getCreatedAt());
vo.setUpdatedAt(channel.getUpdatedAt());
NotificationChannelVO vo = voConverter.toNotificationChannelVO(channel);
// 配置 JSON 解析并移除密码,仅保留是否已配置的标记
Map<String, Object> config = parseConfig(channel.getConfig());
Object password = config.remove("password");
vo.setHasPassword(password != null && !String.valueOf(password).isBlank());
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.entity.NotificationLog;
import cn.rosercode.finance.mapper.NotificationLogMapper;
@@ -44,17 +45,20 @@ public class NotificationServiceImpl implements NotificationService {
private final NotificationSenderFactory notificationSenderFactory;
private final ObjectMapper objectMapper;
private final AppConfigStore appConfigStore;
private final VoConverter voConverter;
public NotificationServiceImpl(NotificationChannelService notificationChannelService,
NotificationLogMapper notificationLogMapper,
NotificationSenderFactory notificationSenderFactory,
ObjectMapper objectMapper,
AppConfigStore appConfigStore) {
AppConfigStore appConfigStore,
VoConverter voConverter) {
this.notificationChannelService = notificationChannelService;
this.notificationLogMapper = notificationLogMapper;
this.notificationSenderFactory = notificationSenderFactory;
this.objectMapper = objectMapper;
this.appConfigStore = appConfigStore;
this.voConverter = voConverter;
}
/**
@@ -131,7 +135,7 @@ public class NotificationServiceImpl implements NotificationService {
.orderByDesc(NotificationLog::getCreatedAt)
.orderByDesc(NotificationLog::getId)
.last("LIMIT " + safeLimit));
return logs.stream().map(this::toVO).toList();
return logs.stream().map(voConverter::toNotificationLogVO).toList();
}
/**
@@ -215,20 +219,4 @@ public class NotificationServiceImpl implements NotificationService {
return message.length() <= MESSAGE_LIMIT ? message : message.substring(0, MESSAGE_LIMIT);
}
/**
* 转换为日志视图对象。
*
* @param log 日志实体
* @return 日志视图对象
*/
private NotificationLogVO toVO(NotificationLog log) {
NotificationLogVO vo = new NotificationLogVO();
vo.setId(log.getId());
vo.setChannelId(log.getChannelId());
vo.setChannelType(log.getChannelType());
vo.setSuccess(Boolean.TRUE.equals(log.getSuccess()));
vo.setMessage(log.getMessage());
vo.setCreatedAt(log.getCreatedAt());
return vo;
}
}
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.TagRequest;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.TransactionTag;
@@ -25,10 +26,12 @@ public class TagServiceImpl implements TagService {
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final VoConverter voConverter;
public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper) {
public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper, VoConverter voConverter) {
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.voConverter = voConverter;
}
/**
@@ -42,7 +45,7 @@ public class TagServiceImpl implements TagService {
.eq(Tag::getUserId, userId)
.orderByAsc(Tag::getSortOrder)
.orderByAsc(Tag::getId));
return tags.stream().map(this::toVO).collect(Collectors.toList());
return tags.stream().map(voConverter::toTagVO).collect(Collectors.toList());
}
/**
@@ -112,19 +115,4 @@ public class TagServiceImpl implements TagService {
tagMapper.deleteById(id);
}
/**
* 将标签实体转换为视图对象
*
* @param tag 标签实体
* @return 标签视图对象
*/
private TagVO toVO(Tag tag) {
TagVO vo = new TagVO();
vo.setId(tag.getId());
vo.setName(tag.getName());
vo.setColor(tag.getColor());
vo.setSortOrder(tag.getSortOrder());
vo.setCreatedAt(tag.getCreatedAt());
return vo;
}
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.BatchCategoryRequest;
import cn.rosercode.finance.dto.BatchTagRequest;
import cn.rosercode.finance.dto.TransactionQuery;
@@ -47,15 +48,18 @@ public class TransactionServiceImpl implements TransactionService {
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final VoConverter voConverter;
public TransactionServiceImpl(TransactionMapper transactionMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper) {
TransactionTagMapper transactionTagMapper,
VoConverter voConverter) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.voConverter = voConverter;
}
/**
@@ -152,7 +156,7 @@ public class TransactionServiceImpl implements TransactionService {
// 将交易记录转换为视图对象,并填充分类名称和图标
Map<Long, Category> finalCategoryMap = categoryMap;
List<TransactionVO> voList = result.getRecords().stream()
.map(t -> toVO(t, finalCategoryMap.get(t.getCategoryId())))
.map(t -> voConverter.toTransactionVO(t, finalCategoryMap.get(t.getCategoryId())))
.collect(Collectors.toList());
// 批量填充标签
fillTags(voList);
@@ -461,29 +465,13 @@ public class TransactionServiceImpl implements TransactionService {
continue;
}
tagsByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
.add(toTagVO(tag));
.add(voConverter.toTagVO(tag));
}
for (TransactionVO vo : voList) {
vo.setTags(tagsByTransaction.getOrDefault(vo.getId(), List.of()));
}
}
/**
* 将标签实体转换为视图对象
*
* @param tag 标签实体
* @return 标签视图对象
*/
private TagVO toTagVO(Tag tag) {
TagVO vo = new TagVO();
vo.setId(tag.getId());
vo.setName(tag.getName());
vo.setColor(tag.getColor());
vo.setSortOrder(tag.getSortOrder());
vo.setCreatedAt(tag.getCreatedAt());
return vo;
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
@@ -522,31 +510,6 @@ public class TransactionServiceImpl implements TransactionService {
return category;
}
/**
* 将交易实体和关联分类转换为视图对象
*
* @param t 交易实体
* @param category 关联的分类实体(可为 null)
* @return 交易视图对象
*/
private TransactionVO toVO(Transaction t, Category category) {
TransactionVO vo = new TransactionVO();
vo.setId(t.getId());
vo.setCategoryId(t.getCategoryId());
vo.setCategoryName(category != null ? category.getName() : "");
vo.setCategoryIcon(category != null ? category.getIcon() : "");
vo.setType(t.getType());
vo.setAmount(t.getAmount());
vo.setPaymentMethod(t.getPaymentMethod());
vo.setRemark(t.getRemark());
vo.setRemarkHidden(t.getRemarkHidden() == null ? Boolean.FALSE : t.getRemarkHidden());
vo.setTransactionTime(t.getTransactionTime());
vo.setIncludeInStats(t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats());
vo.setRawData(t.getRawData());
vo.setCreatedAt(t.getCreatedAt());
return vo;
}
/**
* {@inheritDoc}
*
@@ -2,18 +2,25 @@ package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.LoginRequest;
import cn.rosercode.finance.dto.RegisterRequest;
import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.entity.User;
import cn.rosercode.finance.mapper.UserMapper;
import cn.rosercode.finance.service.LogService;
import cn.rosercode.finance.service.RegistrationService;
import cn.rosercode.finance.service.UserService;
import cn.rosercode.finance.util.JwtUtil;
import cn.rosercode.finance.util.RequestUtils;
import cn.rosercode.finance.vo.LoginVO;
import cn.rosercode.finance.vo.UserVO;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 用户服务实现类,处理用户登录认证、注册和信息查询等业务逻辑。
@@ -25,20 +32,24 @@ public class UserServiceImpl implements UserService {
private final JwtUtil jwtUtil;
private final DefaultDataInitializer defaultDataInitializer;
private final RegistrationService registrationService;
private final LogService logService;
private final VoConverter voConverter;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil, DefaultDataInitializer defaultDataInitializer,
RegistrationService registrationService) {
RegistrationService registrationService, LogService logService, VoConverter voConverter) {
this.userMapper = userMapper;
this.jwtUtil = jwtUtil;
this.defaultDataInitializer = defaultDataInitializer;
this.registrationService = registrationService;
this.logService = logService;
this.voConverter = voConverter;
}
/**
* {@inheritDoc}
*
* 根据用户名查询用户,校验密码后生成 JWT Token 返回。
* 根据用户名查询用户,校验密码后生成 JWT Token 返回;成功与失败均记录登录日志。
*/
@Override
public LoginVO login(LoginRequest request) {
@@ -47,20 +58,23 @@ public class UserServiceImpl implements UserService {
.eq(User::getUsername, request.getUsername()));
if (user == null) {
recordLoginLog(null, request.getUsername(), false, "用户名或密码错误");
throw new BusinessException("用户名或密码错误");
}
// 校验密码是否匹配
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
recordLoginLog(user.getId(), request.getUsername(), false, "用户名或密码错误");
throw new BusinessException("用户名或密码错误");
}
// 生成 JWT Token
String token = jwtUtil.generateToken(user.getId(), user.getUsername());
recordLoginLog(user.getId(), user.getUsername(), true, "登录成功");
LoginVO vo = new LoginVO();
vo.setToken(token);
vo.setUser(toUserVO(user));
vo.setUser(voConverter.toUserVO(user));
return vo;
}
@@ -108,21 +122,44 @@ public class UserServiceImpl implements UserService {
if (user == null) {
throw new BusinessException("用户不存在");
}
return toUserVO(user);
return voConverter.toUserVO(user);
}
/**
* 将用户实体转换为视图对象
* 记录一条登录日志,从当前请求上下文中提取 IP 与 User-Agent。
*
* @param user 用户实体
* @return 用户视图对象
* @param userId 用户ID,可为空
* @param username 登录用户名
* @param success 是否成功
* @param message 结果描述
*/
private UserVO toUserVO(User user) {
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setNickname(user.getNickname());
vo.setCreatedAt(user.getCreatedAt());
return vo;
private void recordLoginLog(Long userId, String username, boolean success, String message) {
try {
LoginLog loginLog = new LoginLog();
loginLog.setUserId(userId);
loginLog.setUsername(username);
loginLog.setSuccess(success);
loginLog.setMessage(message);
HttpServletRequest request = currentRequest();
if (request != null) {
loginLog.setIp(RequestUtils.getClientIp(request));
loginLog.setUserAgent(RequestUtils.getUserAgent(request));
}
logService.recordLoginLog(loginLog);
} catch (Exception e) {
// 登录日志记录失败不应影响登录流程
}
}
/**
* 获取当前线程绑定的 HTTP 请求。
*
* @return 当前请求,非 Web 线程时返回 null
*/
private HttpServletRequest currentRequest() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
}
@@ -0,0 +1,84 @@
package cn.rosercode.finance.util;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 应用日志工具类。
*
* <p>统一维护日志目录与文件名常量,并提供从日志行中提取级别的静态方法,
* 供日志查询与实时推送复用。</p>
*
* @date 2026-09-18 17:01:00
*/
public final class AppLogUtils {
/** 日志目录(相对于服务运行目录) */
public static final String LOG_DIR = "logs";
/** 当前活动日志文件名 */
public static final String CURRENT_LOG_FILE = "finance-app.log";
/** 滚动日志文件名前缀 */
public static final String LOG_FILE_PREFIX = "finance-app";
/** 日志行级别提取正则,匹配「时间 [线程] 级别」格式 */
private static final Pattern LEVEL_PATTERN =
Pattern.compile("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3} \\[[^\\]]*]\\s+(\\w+)\\s");
/** 日志行时间格式 */
private static final DateTimeFormatter TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
/** 日志行时间部分的长度 */
private static final int TIME_LENGTH = 23;
private AppLogUtils() {
}
/**
* 获取日志目录的绝对路径。
*
* @return 规范化后的日志目录路径
*/
public static Path logDir() {
return Paths.get(LOG_DIR).toAbsolutePath().normalize();
}
/**
* 从日志行中提取级别。
*
* @param line 日志行
* @return 级别名称,无法识别时返回 null
*/
public static String extractLevel(String line) {
Matcher matcher = LEVEL_PATTERN.matcher(line);
return matcher.find() ? matcher.group(1) : null;
}
/**
* 从日志行中提取时间戳(毫秒)。
*
* @param line 日志行
* @return 时间戳(epoch 毫秒),无法识别时返回 null
*/
public static Long extractTimestamp(String line) {
if (line == null || line.length() < TIME_LENGTH) {
return null;
}
try {
return LocalDateTime.parse(line.substring(0, TIME_LENGTH), TIME_FORMATTER)
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
} catch (DateTimeParseException e) {
return null;
}
}
}
@@ -0,0 +1,65 @@
package cn.rosercode.finance.util;
import jakarta.servlet.http.HttpServletRequest;
/**
* 请求信息工具类。
*
* <p>提供从 HTTP 请求中提取客户端 IP、User-Agent 等访问日志所需信息的静态方法。</p>
*
* @date 2026-09-18 16:53:04
*/
public final class RequestUtils {
/** 反向代理常见的客户端 IP 请求头,按优先级排列 */
private static final String[] IP_HEADERS = {
"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"
};
/** User-Agent 最大长度,与数据库字段保持一致 */
private static final int USER_AGENT_LIMIT = 500;
private RequestUtils() {
}
/**
* 获取客户端真实 IP。
*
* <p>优先读取反向代理请求头,取其中第一个非 unknown 的地址;均不存在时回退到远端地址。</p>
*
* @param request HTTP 请求
* @return 客户端 IP,无法获取时返回空字符串
*/
public static String getClientIp(HttpServletRequest request) {
if (request == null) {
return "";
}
for (String header : IP_HEADERS) {
String value = request.getHeader(header);
if (value != null && !value.isBlank() && !"unknown".equalsIgnoreCase(value)) {
// X-Forwarded-For 可能是逗号分隔的链路,取第一个地址
int comma = value.indexOf(',');
return (comma > 0 ? value.substring(0, comma) : value).trim();
}
}
String remote = request.getRemoteAddr();
return remote == null ? "" : remote;
}
/**
* 获取 User-Agent,超长时截断。
*
* @param request HTTP 请求
* @return User-Agent 字符串,无法获取时返回空字符串
*/
public static String getUserAgent(HttpServletRequest request) {
if (request == null) {
return "";
}
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) {
return "";
}
return userAgent.length() <= USER_AGENT_LIMIT ? userAgent : userAgent.substring(0, USER_AGENT_LIMIT);
}
}
@@ -0,0 +1,23 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.util.List;
/**
* 应用运行日志视图对象,用于向前端展示后端日志文件内容。
*
* @date 2026-09-18 16:53:09
*/
@Data
public class AppLogVO {
/** 日志文件名 */
private String file;
/** 过滤后命中的总行数 */
private long total;
/** 日志行列表,按时间倒序(最新在前) */
private List<String> lines;
}
@@ -0,0 +1,44 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* HTTP 访问日志视图对象,用于向前端展示接口调用记录。
*
* @date 2026-09-18 16:53:08
*/
@Data
public class HttpLogVO {
/** 日志ID */
private Long id;
/** 用户ID,未登录时为空 */
private Long userId;
/** 请求方法 */
private String method;
/** 请求路径 */
private String path;
/** URL 查询参数 */
private String queryString;
/** HTTP 响应状态码 */
private Integer status;
/** 处理耗时(毫秒) */
private Long durationMs;
/** 客户端IP */
private String ip;
/** User-Agent */
private String userAgent;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,38 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 登录日志视图对象,用于向前端展示登录尝试记录。
*
* @date 2026-09-18 16:53:08
*/
@Data
public class LoginLogVO {
/** 日志ID */
private Long id;
/** 用户ID,登录失败或用户不存在时为空 */
private Long userId;
/** 登录用户名 */
private String username;
/** 是否成功 */
private Boolean success;
/** 结果描述或失败原因 */
private String message;
/** 客户端IP */
private String ip;
/** User-Agent */
private String userAgent;
/** 创建时间 */
private LocalDateTime createdAt;
}
+30
View File
@@ -136,3 +136,33 @@ CREATE TABLE IF NOT EXISTS t_notification_log (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_notification_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
-- HTTP 访问日志表:记录每次接口请求的方法、路径、状态与耗时,便于追溯接口调用
CREATE TABLE IF NOT EXISTS t_http_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
user_id BIGINT COMMENT '用户ID,未登录或认证失败时为空',
method VARCHAR(10) COMMENT '请求方法:GET/POST/PUT/DELETE 等',
path VARCHAR(255) COMMENT '请求路径',
query_string VARCHAR(1000) COMMENT 'URL 查询参数',
status INT COMMENT 'HTTP 响应状态码',
duration_ms BIGINT COMMENT '处理耗时(毫秒)',
ip VARCHAR(64) COMMENT '客户端IP',
user_agent VARCHAR(500) COMMENT 'User-Agent',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_http_log_created (created_at) COMMENT '创建时间索引',
INDEX idx_http_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
-- 登录日志表:记录每次登录尝试的结果,便于审计与排查
CREATE TABLE IF NOT EXISTS t_login_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
user_id BIGINT COMMENT '用户ID,登录失败或用户不存在时为空',
username VARCHAR(50) COMMENT '登录用户名',
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
message VARCHAR(500) COMMENT '结果描述或失败原因',
ip VARCHAR(64) COMMENT '客户端IP',
user_agent VARCHAR(500) COMMENT 'User-Agent',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_login_log_created (created_at) COMMENT '创建时间索引',
INDEX idx_login_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
+28
View File
@@ -176,3 +176,31 @@ WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_notification_channel SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TABLE IF NOT EXISTS t_http_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
method VARCHAR(10),
path VARCHAR(255),
query_string VARCHAR(1000),
status INTEGER,
duration_ms INTEGER,
ip VARCHAR(64),
user_agent VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_http_log_created ON t_http_log (created_at);;
CREATE INDEX IF NOT EXISTS idx_http_log_user_created ON t_http_log (user_id, created_at);;
CREATE TABLE IF NOT EXISTS t_login_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username VARCHAR(50),
success INTEGER NOT NULL DEFAULT 1,
message VARCHAR(500),
ip VARCHAR(64),
user_agent VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_login_log_created ON t_login_log (created_at);;
CREATE INDEX IF NOT EXISTS idx_login_log_user_created ON t_login_log (user_id, created_at);;