diff --git a/build.gradle b/build.gradle index 7aee8c4..d9a671f 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,12 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // MapStruct:编译期生成实体与视图对象之间的属性映射实现 + implementation 'org.mapstruct:mapstruct:1.6.3' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + // 使 MapStruct 能识别 Lombok 生成的 getter/setter + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/frontend/src/api/log.ts b/frontend/src/api/log.ts new file mode 100644 index 0000000..9d7bfa7 --- /dev/null +++ b/frontend/src/api/log.ts @@ -0,0 +1,85 @@ +// 系统日志相关 API 接口:访问日志、登录日志与后端运行日志 +import request from './request' + +/** HTTP 访问日志 */ +export interface HttpLog { + id: number + userId: number | null + method: string | null + path: string | null + queryString: string | null + status: number | null + durationMs: number | null + ip: string | null + userAgent: string | null + createdAt: string +} + +/** 登录日志 */ +export interface LoginLog { + id: number + userId: number | null + username: string | null + success: boolean + message: string | null + ip: string | null + userAgent: string | null + createdAt: string +} + +/** 分页结果 */ +export interface LogPageResult { + records: T[] + total: number + page: number + size: number +} + +/** 应用运行日志 */ +export interface AppLog { + file: string + total: number + lines: string[] +} + +/** 分页查询 HTTP 访问日志 */ +export function pageHttpLogs(params: { + page: number + size: number + keyword?: string + method?: string + status?: number +}) { + return request.get('/log/http', { params }) +} + +/** 分页查询登录日志 */ +export function pageLoginLogs(params: { + page: number + size: number + keyword?: string + success?: boolean +}) { + return request.get('/log/login', { params }) +} + +/** 读取后端运行日志 */ +export function getAppLog(params: { + date?: string + level?: string + keyword?: string + lines: number + since?: number +}) { + return request.get('/log/app', { params }) +} + +/** 获取日志保留天数(0 表示永久保留) */ +export function getLogRetention() { + return request.get('/app/log-retention') +} + +/** 更新日志保留天数(0 表示永久保留) */ +export function updateLogRetention(days: number) { + return request.put('/app/log-retention', { days }) +} diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 9c9ae88..104fc91 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -82,6 +82,7 @@ export default { transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' }, categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' }, tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' }, + logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' }, settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' }, user: { zh: '用户', en: 'User', ja: 'ユーザー' }, logout: { zh: '退出', en: 'Sign Out', ja: 'ログアウト' }, @@ -216,6 +217,54 @@ export default { back: { zh: '返回设置', en: 'Back to Settings', ja: '設定に戻る' }, }, + // 系统日志 + log: { + title: { zh: '系统日志', en: 'System Logs', ja: 'システムログ' }, + httpTab: { zh: '访问日志', en: 'Access Logs', ja: 'アクセスログ' }, + loginTab: { zh: '登录日志', en: 'Login Logs', ja: 'ログインログ' }, + appTab: { zh: '运行日志', en: 'Application Logs', ja: 'アプリログ' }, + keyword: { zh: '关键字', en: 'Keyword', ja: 'キーワード' }, + keywordPlaceholder: { zh: '路径 / 参数 / IP', en: 'Path / Query / IP', ja: 'パス / クエリ / IP' }, + method: { zh: '请求方法', en: 'Method', ja: 'メソッド' }, + status: { zh: '状态码', en: 'Status', ja: 'ステータス' }, + result: { zh: '结果', en: 'Result', ja: '結果' }, + level: { zh: '级别', en: 'Level', ja: 'レベル' }, + date: { zh: '日期', en: 'Date', ja: '日付' }, + datePlaceholder: { zh: 'yyyy-MM-dd,留空为当前', en: 'yyyy-MM-dd, blank for current', ja: 'yyyy-MM-dd、空欄で現在' }, + range1h: { zh: '最近1小时', en: 'Last 1h', ja: '直近1時間' }, + range6h: { zh: '最近6小时', en: 'Last 6h', ja: '直近6時間' }, + range24h: { zh: '最近24小时', en: 'Last 24h', ja: '直近24時間' }, + rangeAll: { zh: '全部', en: 'All', ja: 'すべて' }, + lines: { zh: '行数', en: 'Lines', ja: '行数' }, + file: { zh: '文件', en: 'File', ja: 'ファイル' }, + totalLines: { zh: '命中 {count} 行', en: '{count} line(s) matched', ja: '{count} 行が一致' }, + search: { zh: '查询', en: 'Search', ja: '検索' }, + refresh: { zh: '刷新', en: 'Refresh', ja: '更新' }, + realtime: { zh: '实时', en: 'Live', ja: 'リアルタイム' }, + time: { zh: '时间', en: 'Time', ja: '時刻' }, + path: { zh: '路径', en: 'Path', ja: 'パス' }, + queryString: { zh: '参数', en: 'Query', ja: 'クエリ' }, + duration: { zh: '耗时', en: 'Duration', ja: '所要時間' }, + ip: { zh: 'IP', en: 'IP', ja: 'IP' }, + user: { zh: '用户', en: 'User', ja: 'ユーザー' }, + username: { zh: '用户名', en: 'Username', ja: 'ユーザー名' }, + userAgent: { zh: 'User-Agent', en: 'User-Agent', ja: 'User-Agent' }, + message: { zh: '说明', en: 'Message', ja: '説明' }, + success: { zh: '成功', en: 'Success', ja: '成功' }, + failed: { zh: '失败', en: 'Failed', ja: '失敗' }, + anonymous: { zh: '未登录', en: 'Anonymous', ja: '未ログイン' }, + noData: { zh: '暂无日志', en: 'No logs', ja: 'ログがありません' }, + appEmpty: { zh: '暂无运行日志', en: 'No application logs', ja: 'アプリログがありません' }, + retention: { zh: '日志保留', en: 'Log retention', ja: 'ログ保持' }, + retention30: { zh: '30 天', en: '30 days', ja: '30日' }, + retention90: { zh: '90 天', en: '90 days', ja: '90日' }, + retention180: { zh: '180 天', en: '180 days', ja: '180日' }, + retentionForever: { zh: '永久保留', en: 'Forever', ja: '永久保持' }, + retentionSaved: { zh: '日志保留策略已保存', en: 'Log retention saved', ja: 'ログ保持を保存しました' }, + retentionFailed: { zh: '保存日志保留策略失败', en: 'Failed to save log retention', ja: 'ログ保持の保存に失敗しました' }, + loadFailed: { zh: '加载日志失败', en: 'Failed to load logs', ja: 'ログの読み込みに失敗しました' }, + }, + // 统计面板 dashboard: { title: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4c52d13..aba736f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -63,6 +63,20 @@ const router = createRouter({ name: 'AiUsage', component: () => import('../views/AiUsage.vue'), // 模型使用情况 }, + { + path: 'logs', + // 跳转到上次访问的日志标签页,无记录时默认访问日志 + redirect: () => { + const tabs = ['http', 'login', 'app'] + const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('logs.tab') : null + return `/logs/${saved && tabs.includes(saved) ? saved : 'http'}` + }, + }, + { + path: 'logs/:tab(http|login|app)', + name: 'Logs', + component: () => import('../views/Logs.vue'), // 系统日志(访问日志/登录日志/运行日志) + }, ], }, ], diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index c483b65..c72ea69 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui' import type { MenuOption } from 'naive-ui' -import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5' +import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5' import { useUserStore } from '../store/user' import LocaleSwitch from '../components/LocaleSwitch.vue' @@ -45,13 +45,16 @@ function handleMenuUpdate(key: string) { // 顶部用户下拉菜单配置项(随语言切换响应式更新) const userMenuOptions = computed(() => [ + { label: t('layout.logs'), key: 'logs', icon: renderIcon(DocumentTextOutline) }, { label: t('layout.settings'), key: 'settings', icon: renderIcon(SettingsOutline) }, { label: t('layout.logout'), key: 'logout', icon: renderIcon(LogOutOutline) }, ]) -/** 用户下拉菜单选择事件:跳转系统设置或退出登录 */ +/** 用户下拉菜单选择事件:跳转系统日志/系统设置或退出登录 */ function handleUserMenuSelect(key: string) { - if (key === 'settings') { + if (key === 'logs') { + router.push('/logs') + } else if (key === 'settings') { router.push('/settings') } else if (key === 'logout') { handleLogout() diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..8839856 --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,655 @@ + + + + + diff --git a/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java b/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java index f346a1a..de330bc 100644 --- a/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java +++ b/src/main/java/cn/rosercode/finance/common/GlobalExceptionHandler.java @@ -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, "资源不存在"); } + /** + * 处理异步请求不可用或超时异常。 + * + *

SSE 等异步请求在客户端断开后,容器会抛出该异常,此时响应已无法写入, + * 直接忽略即可,避免全局异常处理器尝试写入 JSON 产生额外的错误日志。

+ * + * @param e 异步请求异常 + */ + @ExceptionHandler({AsyncRequestNotUsableException.class, AsyncRequestTimeoutException.class}) + public void handleAsyncRequestException(Exception e) { + // 连接已断开,无需处理 + } + /** * 处理未捕获的通用异常。 * diff --git a/src/main/java/cn/rosercode/finance/config/WebConfig.java b/src/main/java/cn/rosercode/finance/config/WebConfig.java index 5feb230..9720bda 100644 --- a/src/main/java/cn/rosercode/finance/config/WebConfig.java +++ b/src/main/java/cn/rosercode/finance/config/WebConfig.java @@ -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; } /** * 配置拦截器规则。 - *

对 /api/** 路径下的请求启用 JWT 认证,排除登录和注册接口。

+ *

先注册访问日志拦截器以覆盖全部 /api/** 请求(含认证失败的请求), + * 再注册 JWT 认证拦截器,排除登录和注册接口。

* * @param registry 拦截器注册器 */ @Override public void addInterceptors(InterceptorRegistry registry) { + // 访问日志拦截器需先注册,确保 JWT 认证失败的请求也能记录到访问日志 + registry.addInterceptor(accessLogInterceptor) + .addPathPatterns("/api/**"); + registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") // 登录、注册与状态查询接口不需要认证;数据库状态查询用于前端判断是否已配置 diff --git a/src/main/java/cn/rosercode/finance/config/app/AppConfig.java b/src/main/java/cn/rosercode/finance/config/app/AppConfig.java index 0c2bad8..a656f47 100644 --- a/src/main/java/cn/rosercode/finance/config/app/AppConfig.java +++ b/src/main/java/cn/rosercode/finance/config/app/AppConfig.java @@ -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; } diff --git a/src/main/java/cn/rosercode/finance/controller/AppConfigController.java b/src/main/java/cn/rosercode/finance/controller/AppConfigController.java index 11a4c64..a6c131f 100644 --- a/src/main/java/cn/rosercode/finance/controller/AppConfigController.java +++ b/src/main/java/cn/rosercode/finance/controller/AppConfigController.java @@ -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; /** * 应用配置控制器 * - *

提供全局应用级配置的读写接口,当前包含通知链接地址(账单导入提醒中的跳转链接)。

+ *

提供全局应用级配置的读写接口,当前包含通知链接地址(账单导入提醒中的跳转链接) + * 与日志保留天数(访问日志/登录日志自动清理)。

* * @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 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 updateLogRetention(@RequestBody LogRetentionRequest request) { + AppConfig config = appConfigStore.load(); + config.setLogRetentionDays(request.getDays()); + appConfigStore.save(config); + return Result.success(config.getLogRetentionDays()); + } } diff --git a/src/main/java/cn/rosercode/finance/controller/LogController.java b/src/main/java/cn/rosercode/finance/controller/LogController.java new file mode 100644 index 0000000..e372576 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/controller/LogController.java @@ -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; + +/** + * 系统日志控制器 + * + *

提供 HTTP 访问日志、登录日志的分页查询与后端运行日志文件查看接口。

+ * + * @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> pageHttpLogs(LogQuery query) { + return Result.success(logService.pageHttpLogs(query)); + } + + /** + * 分页查询登录日志。 + * + * @param query 查询条件 + * @return 分页登录日志 + */ + @GetMapping("/login") + @Operation(summary = "分页查询登录日志", description = "按关键字、成功与否筛选登录尝试记录") + public Result> 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 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); + } +} diff --git a/src/main/java/cn/rosercode/finance/converter/VoConverter.java b/src/main/java/cn/rosercode/finance/converter/VoConverter.java new file mode 100644 index 0000000..b86b862 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/converter/VoConverter.java @@ -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 在编译期生成实现。 + * + *

同名同类型属性自动映射;含派生逻辑、默认值或需脱敏的字段通过 {@link Mapping} 显式声明。

+ * + * @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 toTagVOList(List 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); + + /** + * 交易实体与关联分类转视图对象。 + * + *

分类名称/图标取自关联分类,未关联时为空字符串;标签列表由调用方另行填充。

+ * + * @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); + + /** + * 通知渠道实体转视图对象。 + * + *

配置 JSON 解析与密码脱敏由调用方处理,此处仅映射基础字段。

+ * + * @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); +} diff --git a/src/main/java/cn/rosercode/finance/dto/LogQuery.java b/src/main/java/cn/rosercode/finance/dto/LogQuery.java new file mode 100644 index 0000000..6c759b1 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/LogQuery.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/finance/dto/LogRetentionRequest.java b/src/main/java/cn/rosercode/finance/dto/LogRetentionRequest.java new file mode 100644 index 0000000..1a639b1 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/LogRetentionRequest.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/finance/entity/HttpLog.java b/src/main/java/cn/rosercode/finance/entity/HttpLog.java new file mode 100644 index 0000000..c12ca49 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/HttpLog.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/finance/entity/LoginLog.java b/src/main/java/cn/rosercode/finance/entity/LoginLog.java new file mode 100644 index 0000000..1e86179 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/entity/LoginLog.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/finance/interceptor/AccessLogInterceptor.java b/src/main/java/cn/rosercode/finance/interceptor/AccessLogInterceptor.java new file mode 100644 index 0000000..7c2fc27 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/interceptor/AccessLogInterceptor.java @@ -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 访问日志拦截器。 + * + *

在请求开始时记录起始时间,请求完成后根据状态码、耗时等信息写入访问日志。 + * 日志写入失败不会影响主请求流程。

+ * + * @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); + } +} diff --git a/src/main/java/cn/rosercode/finance/interceptor/JwtInterceptor.java b/src/main/java/cn/rosercode/finance/interceptor/JwtInterceptor.java index e95dca9..703e032 100644 --- a/src/main/java/cn/rosercode/finance/interceptor/JwtInterceptor.java +++ b/src/main/java/cn/rosercode/finance/interceptor/JwtInterceptor.java @@ -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"); diff --git a/src/main/java/cn/rosercode/finance/mapper/HttpLogMapper.java b/src/main/java/cn/rosercode/finance/mapper/HttpLogMapper.java new file mode 100644 index 0000000..128ca1d --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/HttpLogMapper.java @@ -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 { +} diff --git a/src/main/java/cn/rosercode/finance/mapper/LoginLogMapper.java b/src/main/java/cn/rosercode/finance/mapper/LoginLogMapper.java new file mode 100644 index 0000000..71238ac --- /dev/null +++ b/src/main/java/cn/rosercode/finance/mapper/LoginLogMapper.java @@ -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 { +} diff --git a/src/main/java/cn/rosercode/finance/scheduler/LogCleanupScheduler.java b/src/main/java/cn/rosercode/finance/scheduler/LogCleanupScheduler.java new file mode 100644 index 0000000..8f81111 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/scheduler/LogCleanupScheduler.java @@ -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; + +/** + * 日志清理调度器。 + * + *

每天凌晨按配置的保留天数清理过期的访问日志与登录日志,保留天数读取自应用配置 + * {@code config/app.json},默认 30 天;配置为 0 或负数时永久保留、不执行清理。

+ * + * @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 点清理超过保留天数的日志。 + * + *

数据库未配置或不可用时仅记录调试日志,避免影响定时任务线程。

+ */ + @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() + .lt(HttpLog::getCreatedAt, cutoff)); + int loginDeleted = loginLogMapper.delete(new LambdaQueryWrapper() + .lt(LoginLog::getCreatedAt, cutoff)); + log.info("日志清理完成(保留 {} 天):删除访问日志 {} 条、登录日志 {} 条", + retentionDays, httpDeleted, loginDeleted); + } catch (Exception e) { + log.debug("跳过日志清理,数据库不可用:{}", e.getMessage()); + } + } +} diff --git a/src/main/java/cn/rosercode/finance/service/AppLogStreamService.java b/src/main/java/cn/rosercode/finance/service/AppLogStreamService.java new file mode 100644 index 0000000..f32e185 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/AppLogStreamService.java @@ -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; + +/** + * 应用运行日志实时推送服务。 + * + *

基于 SSE(Server-Sent Events)将当前日志文件的新增行推送给前端:连接建立时先发送 + * {@code meta} 事件回放最近若干行,随后每秒轮询文件增量并按行推送 {@code line} 事件。 + * 客户端断开时自动停止对应的轮询任务。

+ * + * @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 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 readInitialLines() throws IOException { + if (!Files.exists(file)) { + return List.of(); + } + List allLines = Files.readAllLines(file, StandardCharsets.UTF_8); + int from = Math.max(0, allLines.size() - initialLines); + List 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) { + // 连接可能已断开 + } + } + } +} diff --git a/src/main/java/cn/rosercode/finance/service/LogService.java b/src/main/java/cn/rosercode/finance/service/LogService.java new file mode 100644 index 0000000..f2b4dc9 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/LogService.java @@ -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 pageHttpLogs(LogQuery query); + + /** + * 分页查询登录日志。 + * + * @param query 查询条件 + * @return 分页后的登录日志 + */ + PageResult 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); +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiUsageServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiUsageServiceImpl.java index 6c4494c..8390523 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/AiUsageServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/AiUsageServiceImpl.java @@ -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; - } - /** * 截断过长的失败原因。 * diff --git a/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java index 26a9601..ff069dd 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/CategoryServiceImpl.java @@ -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; - } } diff --git a/src/main/java/cn/rosercode/finance/service/impl/LogServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/LogServiceImpl.java new file mode 100644 index 0000000..927a6f2 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/LogServiceImpl.java @@ -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 pageHttpLogs(LogQuery query) { + LambdaQueryWrapper 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 page = new Page<>(safePage(query.getPage()), safeSize(query.getSize())); + Page result = httpLogMapper.selectPage(page, wrapper); + List records = result.getRecords().stream().map(voConverter::toHttpLogVO).toList(); + return new PageResult<>(records, result.getTotal(), result.getCurrent(), result.getSize()); + } + + /** + * {@inheritDoc} + */ + @Override + public PageResult pageLoginLogs(LogQuery query) { + LambdaQueryWrapper 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 page = new Page<>(safePage(query.getPage()), safeSize(query.getSize())); + Page result = loginLogMapper.selectPage(page, wrapper); + List 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 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 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 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); + } + +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java index 1d85d7e..37e4ae7 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java @@ -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 config = parseConfig(channel.getConfig()); Object password = config.remove("password"); vo.setHasPassword(password != null && !String.valueOf(password).isBlank()); diff --git a/src/main/java/cn/rosercode/finance/service/impl/NotificationServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/NotificationServiceImpl.java index b7a805e..0105035 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/NotificationServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/NotificationServiceImpl.java @@ -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; - } } diff --git a/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java index 9eaf19b..41480a2 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/TagServiceImpl.java @@ -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; - } } diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java index 9b3f9e2..e928691 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java @@ -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 finalCategoryMap = categoryMap; List 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} * diff --git a/src/main/java/cn/rosercode/finance/service/impl/UserServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/UserServiceImpl.java index de70b76..dc510a4 100644 --- a/src/main/java/cn/rosercode/finance/service/impl/UserServiceImpl.java +++ b/src/main/java/cn/rosercode/finance/service/impl/UserServiceImpl.java @@ -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(); + } + } diff --git a/src/main/java/cn/rosercode/finance/util/AppLogUtils.java b/src/main/java/cn/rosercode/finance/util/AppLogUtils.java new file mode 100644 index 0000000..d0c0eeb --- /dev/null +++ b/src/main/java/cn/rosercode/finance/util/AppLogUtils.java @@ -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; + +/** + * 应用日志工具类。 + * + *

统一维护日志目录与文件名常量,并提供从日志行中提取级别的静态方法, + * 供日志查询与实时推送复用。

+ * + * @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; + } + } +} diff --git a/src/main/java/cn/rosercode/finance/util/RequestUtils.java b/src/main/java/cn/rosercode/finance/util/RequestUtils.java new file mode 100644 index 0000000..cc015ce --- /dev/null +++ b/src/main/java/cn/rosercode/finance/util/RequestUtils.java @@ -0,0 +1,65 @@ +package cn.rosercode.finance.util; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 请求信息工具类。 + * + *

提供从 HTTP 请求中提取客户端 IP、User-Agent 等访问日志所需信息的静态方法。

+ * + * @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。 + * + *

优先读取反向代理请求头,取其中第一个非 unknown 的地址;均不存在时回退到远端地址。

+ * + * @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); + } +} diff --git a/src/main/java/cn/rosercode/finance/vo/AppLogVO.java b/src/main/java/cn/rosercode/finance/vo/AppLogVO.java new file mode 100644 index 0000000..b33553d --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/AppLogVO.java @@ -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 lines; +} diff --git a/src/main/java/cn/rosercode/finance/vo/HttpLogVO.java b/src/main/java/cn/rosercode/finance/vo/HttpLogVO.java new file mode 100644 index 0000000..37f4534 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/HttpLogVO.java @@ -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; +} diff --git a/src/main/java/cn/rosercode/finance/vo/LoginLogVO.java b/src/main/java/cn/rosercode/finance/vo/LoginLogVO.java new file mode 100644 index 0000000..cd3300e --- /dev/null +++ b/src/main/java/cn/rosercode/finance/vo/LoginLogVO.java @@ -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; +} diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql index c09e7d7..e419218 100644 --- a/src/main/resources/sql/init.sql +++ b/src/main/resources/sql/init.sql @@ -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+创建时间联合索引' +); diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql index 3f7e575..f3393b0 100644 --- a/src/main/resources/sql/schema-sqlite.sql +++ b/src/main/resources/sql/schema-sqlite.sql @@ -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);;