feat: 新增系统日志记录、查看与自动清理,VO 转换改用 MapStruct
- 新增访问/登录日志表与拦截器,登录成功/失败均记录,接口访问同时输出到应用日志 - 新增 /api/log/* 查询与 SSE 实时推送,运行日志默认最近 1 小时并支持日期/级别/关键字筛选 - 新增日志定时清理,保留天数可配置(默认 30 天,0 为永久保留) - 前端新增系统日志页(独立 URL 子路径、标签偏好记忆),入口位于顶部用户下拉菜单 - 实体到视图对象的属性拷贝改用 MapStruct 编译期生成
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<T> {
|
||||
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 })
|
||||
}
|
||||
@@ -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: 'ダッシュボード' },
|
||||
|
||||
@@ -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'), // 系统日志(访问日志/登录日志/运行日志)
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
<script setup lang="ts">
|
||||
// 系统日志页:查看接口访问日志、登录日志与后端运行日志
|
||||
import { ref, computed, h, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
useMessage, NCard, NTabs, NTabPane, NDataTable, NPagination, NInput, NSelect,
|
||||
NButton, NSpace, NTag, NIcon, NInputNumber, NEmpty, NText, NSwitch,
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
|
||||
import { pageHttpLogs, pageLoginLogs, getAppLog, getLogRetention, updateLogRetention } from '../api/log'
|
||||
import type { HttpLog, LoginLog, AppLog } from '../api/log'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 当前激活的标签页,取自 URL 子路径(/logs/http、/logs/login、/logs/app)
|
||||
const activeTab = ref<string>((route.params.tab as string) || 'http')
|
||||
|
||||
// 标签页偏好存储键,与路由 /logs 的重定向逻辑保持一致
|
||||
const LOGS_TAB_KEY = 'logs.tab'
|
||||
|
||||
// 日志保留天数(0 表示永久保留),默认 30 天
|
||||
const logRetention = ref(30)
|
||||
|
||||
/** 日志保留天数选项 */
|
||||
const retentionOptions = computed(() => [
|
||||
{ label: t('log.retention30'), value: 30 },
|
||||
{ label: t('log.retention90'), value: 90 },
|
||||
{ label: t('log.retention180'), value: 180 },
|
||||
{ label: t('log.retentionForever'), value: 0 },
|
||||
])
|
||||
|
||||
// ---------------- 访问日志 ----------------
|
||||
const httpRecords = ref<HttpLog[]>([])
|
||||
const httpLoading = ref(false)
|
||||
const httpPage = ref(1)
|
||||
const httpSize = ref(20)
|
||||
const httpTotal = ref(0)
|
||||
const httpKeyword = ref('')
|
||||
const httpMethod = ref<string | null>(null)
|
||||
const httpStatus = ref<number | null>(null)
|
||||
|
||||
// ---------------- 登录日志 ----------------
|
||||
const loginRecords = ref<LoginLog[]>([])
|
||||
const loginLoading = ref(false)
|
||||
const loginPage = ref(1)
|
||||
const loginSize = ref(20)
|
||||
const loginTotal = ref(0)
|
||||
const loginKeyword = ref('')
|
||||
const loginSuccess = ref<boolean | null>(null)
|
||||
|
||||
// ---------------- 运行日志 ----------------
|
||||
const appData = ref<AppLog | null>(null)
|
||||
const appLoading = ref(false)
|
||||
const appDate = ref('')
|
||||
const appLevel = ref<string | null>(null)
|
||||
const appKeyword = ref('')
|
||||
const appLines = ref<number | null>(500)
|
||||
// 时间范围(毫秒),默认最近 1 小时;0 表示全部
|
||||
const appRange = ref(3600000)
|
||||
// 实时获取:开启后通过 SSE 订阅日志新增内容
|
||||
const appRealtime = ref(false)
|
||||
// SSE 连接
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
/** 实时模式下缓冲的最大行数 */
|
||||
const MAX_REALTIME_LINES = 2000
|
||||
|
||||
/** 请求方法下拉选项 */
|
||||
const methodOptions = ['GET', 'POST', 'PUT', 'DELETE'].map((value) => ({ label: value, value }))
|
||||
|
||||
/** 状态码下拉选项 */
|
||||
const statusOptions = [200, 201, 400, 401, 403, 404, 500].map((value) => ({ label: String(value), value }))
|
||||
|
||||
/** 登录结果下拉选项 */
|
||||
const loginResultOptions = computed(() => [
|
||||
{ label: t('log.success'), value: true },
|
||||
{ label: t('log.failed'), value: false },
|
||||
])
|
||||
|
||||
/** 日志级别下拉选项 */
|
||||
const levelOptions = ['ERROR', 'WARN', 'INFO', 'DEBUG'].map((value) => ({ label: value, value }))
|
||||
|
||||
/** 运行日志时间范围下拉选项 */
|
||||
const rangeOptions = computed(() => [
|
||||
{ label: t('log.range1h'), value: 3600000 },
|
||||
{ label: t('log.range6h'), value: 21600000 },
|
||||
{ label: t('log.range24h'), value: 86400000 },
|
||||
{ label: t('log.rangeAll'), value: 0 },
|
||||
])
|
||||
|
||||
/** 计算运行日志查询的起始时间戳,范围为全部时返回 undefined */
|
||||
function appSince(): number | undefined {
|
||||
return appRange.value > 0 ? Date.now() - appRange.value : undefined
|
||||
}
|
||||
|
||||
/** 格式化后端时间字符串用于展示 */
|
||||
function formatTime(value: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : ''
|
||||
}
|
||||
|
||||
/** 状态码对应的标签颜色 */
|
||||
function statusTagType(status: number | null) {
|
||||
if (status == null) return 'default'
|
||||
if (status >= 500) return 'error'
|
||||
if (status >= 400) return 'warning'
|
||||
if (status >= 300) return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
/** 请求方法对应的标签颜色 */
|
||||
function methodTagType(method: string | null) {
|
||||
switch (method) {
|
||||
case 'GET': return 'info'
|
||||
case 'POST': return 'success'
|
||||
case 'PUT': return 'warning'
|
||||
case 'DELETE': return 'error'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
/** 访问日志表格列 */
|
||||
const httpColumns = computed<DataTableColumns<HttpLog>>(() => [
|
||||
{
|
||||
title: t('log.time'), key: 'createdAt', width: 165,
|
||||
render: (row) => h('span', { class: 'cell-time' }, formatTime(row.createdAt)),
|
||||
},
|
||||
{
|
||||
title: t('log.method'), key: 'method', width: 90, align: 'center',
|
||||
render: (row) => (row.method
|
||||
? h(NTag, { type: methodTagType(row.method), size: 'small', bordered: false }, { default: () => row.method })
|
||||
: '-'),
|
||||
},
|
||||
{
|
||||
title: t('log.path'), key: 'path', minWidth: 180, ellipsis: { tooltip: true },
|
||||
},
|
||||
{
|
||||
title: t('log.queryString'), key: 'queryString', minWidth: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.queryString || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.status'), key: 'status', width: 90, align: 'center',
|
||||
render: (row) => (row.status == null
|
||||
? '-'
|
||||
: h(NTag, { type: statusTagType(row.status), size: 'small', bordered: false }, { default: () => String(row.status) })),
|
||||
},
|
||||
{
|
||||
title: t('log.duration'), key: 'durationMs', width: 100, align: 'right',
|
||||
render: (row) => (row.durationMs == null ? '-' : `${row.durationMs} ms`),
|
||||
},
|
||||
{
|
||||
title: t('log.user'), key: 'userId', width: 90, align: 'center',
|
||||
render: (row) => (row.userId == null ? t('log.anonymous') : `#${row.userId}`),
|
||||
},
|
||||
{
|
||||
title: t('log.ip'), key: 'ip', width: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.ip || '-',
|
||||
},
|
||||
])
|
||||
|
||||
/** 登录日志表格列 */
|
||||
const loginColumns = computed<DataTableColumns<LoginLog>>(() => [
|
||||
{
|
||||
title: t('log.time'), key: 'createdAt', width: 165,
|
||||
render: (row) => h('span', { class: 'cell-time' }, formatTime(row.createdAt)),
|
||||
},
|
||||
{
|
||||
title: t('log.username'), key: 'username', width: 140,
|
||||
render: (row) => row.username || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.result'), key: 'success', width: 90, align: 'center',
|
||||
render: (row) => h(NTag, { type: row.success ? 'success' : 'error', size: 'small', bordered: false },
|
||||
{ default: () => (row.success ? t('log.success') : t('log.failed')) }),
|
||||
},
|
||||
{
|
||||
title: t('log.message'), key: 'message', minWidth: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.message || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.ip'), key: 'ip', width: 140, ellipsis: { tooltip: true },
|
||||
render: (row) => row.ip || '-',
|
||||
},
|
||||
{
|
||||
title: t('log.userAgent'), key: 'userAgent', minWidth: 180, ellipsis: { tooltip: true },
|
||||
render: (row) => row.userAgent || '-',
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载访问日志 */
|
||||
async function loadHttp() {
|
||||
httpLoading.value = true
|
||||
try {
|
||||
const res: any = await pageHttpLogs({
|
||||
page: httpPage.value,
|
||||
size: httpSize.value,
|
||||
keyword: httpKeyword.value.trim() || undefined,
|
||||
method: httpMethod.value || undefined,
|
||||
status: httpStatus.value ?? undefined,
|
||||
})
|
||||
httpRecords.value = res.data.records || []
|
||||
httpTotal.value = res.data.total || 0
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.loadFailed'))
|
||||
} finally {
|
||||
httpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载登录日志 */
|
||||
async function loadLogin() {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const res: any = await pageLoginLogs({
|
||||
page: loginPage.value,
|
||||
size: loginSize.value,
|
||||
keyword: loginKeyword.value.trim() || undefined,
|
||||
success: loginSuccess.value ?? undefined,
|
||||
})
|
||||
loginRecords.value = res.data.records || []
|
||||
loginTotal.value = res.data.total || 0
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.loadFailed'))
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载运行日志快照(非实时模式) */
|
||||
async function loadApp() {
|
||||
appLoading.value = true
|
||||
try {
|
||||
const res: any = await getAppLog({
|
||||
date: appDate.value.trim() || undefined,
|
||||
level: appLevel.value || undefined,
|
||||
keyword: appKeyword.value.trim() || undefined,
|
||||
lines: appLines.value || 500,
|
||||
since: appSince(),
|
||||
})
|
||||
appData.value = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.loadFailed'))
|
||||
} finally {
|
||||
appLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭 SSE 实时连接 */
|
||||
function stopRealtime() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 建立 SSE 实时连接,订阅运行日志新增内容 */
|
||||
function startRealtime() {
|
||||
stopRealtime()
|
||||
|
||||
const params = new URLSearchParams()
|
||||
// EventSource 无法自定义请求头,token 通过查询参数传递
|
||||
params.set('token', userStore.token || '')
|
||||
if (appLevel.value) {
|
||||
params.set('level', appLevel.value)
|
||||
}
|
||||
if (appKeyword.value.trim()) {
|
||||
params.set('keyword', appKeyword.value.trim())
|
||||
}
|
||||
params.set('lines', String(appLines.value || 500))
|
||||
const since = appSince()
|
||||
if (since !== undefined) {
|
||||
params.set('since', String(since))
|
||||
}
|
||||
|
||||
const source = new EventSource(`/api/log/app/stream?${params.toString()}`)
|
||||
eventSource = source
|
||||
|
||||
// 元信息事件:初始化文件名与初始命中行数
|
||||
source.addEventListener('meta', (e: MessageEvent) => {
|
||||
try {
|
||||
const meta = JSON.parse(e.data)
|
||||
appData.value = { file: meta.file || '', total: meta.total || 0, lines: [] }
|
||||
} catch {
|
||||
appData.value = { file: '', total: 0, lines: [] }
|
||||
}
|
||||
})
|
||||
|
||||
// 日志行事件:按时间正序推送,插入列表头部实现最新在前
|
||||
source.addEventListener('line', (e: MessageEvent) => {
|
||||
if (!appData.value) {
|
||||
appData.value = { file: '', total: 0, lines: [] }
|
||||
}
|
||||
appData.value.lines.unshift(e.data)
|
||||
appData.value.total++
|
||||
if (appData.value.lines.length > MAX_REALTIME_LINES) {
|
||||
appData.value.lines.length = MAX_REALTIME_LINES
|
||||
}
|
||||
})
|
||||
|
||||
source.onerror = () => {
|
||||
// 服务端关闭连接(如认证失败)时停止实时并提示,避免无限重连
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
stopRealtime()
|
||||
if (appRealtime.value) {
|
||||
appRealtime.value = false
|
||||
message.error(t('log.loadFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实时开关变化时建立/关闭 SSE 连接(仅在运行日志标签页生效)
|
||||
watch(appRealtime, (enabled) => {
|
||||
if (activeTab.value !== 'app') {
|
||||
return
|
||||
}
|
||||
if (enabled) {
|
||||
startRealtime()
|
||||
} else {
|
||||
stopRealtime()
|
||||
loadApp()
|
||||
}
|
||||
})
|
||||
|
||||
// URL 子路径变化(如浏览器前进/后退)时同步标签页
|
||||
watch(() => route.params.tab, (tab) => {
|
||||
const target = (tab as string) || 'http'
|
||||
if (target !== activeTab.value) {
|
||||
activeTab.value = target
|
||||
}
|
||||
})
|
||||
|
||||
// 标签页切换:记住偏好、加载对应数据、同步 URL 子路径、控制实时连接
|
||||
watch(activeTab, (tab) => {
|
||||
// 保存当前标签页偏好,供下次点击「系统日志」时恢复
|
||||
localStorage.setItem(LOGS_TAB_KEY, tab)
|
||||
if (tab === 'http') {
|
||||
loadHttp()
|
||||
} else if (tab === 'login') {
|
||||
loadLogin()
|
||||
} else if (tab === 'app') {
|
||||
if (appRealtime.value) {
|
||||
startRealtime()
|
||||
} else {
|
||||
loadApp()
|
||||
}
|
||||
}
|
||||
// 离开运行日志时关闭 SSE 连接
|
||||
if (tab !== 'app') {
|
||||
stopRealtime()
|
||||
}
|
||||
// URL 未反映当前标签时更新,避免与路由同步互相触发
|
||||
if ((route.params.tab as string) !== tab) {
|
||||
router.push(`/logs/${tab}`)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(stopRealtime)
|
||||
|
||||
/** 访问日志查询:重置到第一页 */
|
||||
function searchHttp() {
|
||||
httpPage.value = 1
|
||||
loadHttp()
|
||||
}
|
||||
|
||||
/** 登录日志查询:重置到第一页 */
|
||||
function searchLogin() {
|
||||
loginPage.value = 1
|
||||
loadLogin()
|
||||
}
|
||||
|
||||
/** 加载日志保留天数配置 */
|
||||
async function loadRetention() {
|
||||
try {
|
||||
const res: any = await getLogRetention()
|
||||
logRetention.value = res.data ?? 30
|
||||
} catch {
|
||||
// 读取失败时保持默认值
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存日志保留天数配置 */
|
||||
async function saveRetention(days: number) {
|
||||
try {
|
||||
await updateLogRetention(days)
|
||||
message.success(t('log.retentionSaved'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('log.retentionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRetention)
|
||||
|
||||
/** 运行日志行级别样式 */
|
||||
function lineClass(line: string) {
|
||||
if (line.includes(' ERROR ')) return 'log-line log-error'
|
||||
if (line.includes(' WARN ')) return 'log-line log-warn'
|
||||
if (line.includes(' DEBUG ')) return 'log-line log-debug'
|
||||
return 'log-line'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logs-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('log.title') }}</h2>
|
||||
<NSpace align="center" :size="8">
|
||||
<NText depth="3">{{ t('log.retention') }}</NText>
|
||||
<NSelect
|
||||
v-model:value="logRetention"
|
||||
:options="retentionOptions"
|
||||
style="width: 130px;"
|
||||
@update:value="saveRetention"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<NCard :bordered="false">
|
||||
<NTabs v-model:value="activeTab" type="line" animated>
|
||||
<!-- 访问日志 -->
|
||||
<NTabPane name="http" :tab="t('log.httpTab')">
|
||||
<NSpace align="center" :size="12" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="httpKeyword"
|
||||
:placeholder="t('log.keywordPlaceholder')"
|
||||
clearable
|
||||
style="width: 220px;"
|
||||
@keyup.enter="searchHttp"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="httpMethod"
|
||||
:options="methodOptions"
|
||||
:placeholder="t('log.method')"
|
||||
clearable
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="httpStatus"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('log.status')"
|
||||
clearable
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NButton type="primary" @click="searchHttp">
|
||||
<template #icon><NIcon :component="SearchOutline" /></template>
|
||||
{{ t('log.search') }}
|
||||
</NButton>
|
||||
<NButton :loading="httpLoading" @click="loadHttp">
|
||||
<template #icon><NIcon :component="RefreshOutline" /></template>
|
||||
{{ t('log.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="httpColumns"
|
||||
:data="httpRecords"
|
||||
:loading="httpLoading"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:striped="true"
|
||||
:scroll-x="1000"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty :description="t('log.noData')" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
|
||||
<NSpace justify="end" style="margin-top: 12px;">
|
||||
<NPagination
|
||||
v-model:page="httpPage"
|
||||
v-model:page-size="httpSize"
|
||||
:item-count="httpTotal"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
show-size-picker
|
||||
@update:page="loadHttp"
|
||||
@update:page-size="searchHttp"
|
||||
/>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
|
||||
<!-- 登录日志 -->
|
||||
<NTabPane name="login" :tab="t('log.loginTab')">
|
||||
<NSpace align="center" :size="12" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="loginKeyword"
|
||||
:placeholder="t('log.keywordPlaceholder')"
|
||||
clearable
|
||||
style="width: 220px;"
|
||||
@keyup.enter="searchLogin"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="loginSuccess"
|
||||
:options="loginResultOptions"
|
||||
:placeholder="t('log.result')"
|
||||
clearable
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NButton type="primary" @click="searchLogin">
|
||||
<template #icon><NIcon :component="SearchOutline" /></template>
|
||||
{{ t('log.search') }}
|
||||
</NButton>
|
||||
<NButton :loading="loginLoading" @click="loadLogin">
|
||||
<template #icon><NIcon :component="RefreshOutline" /></template>
|
||||
{{ t('log.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDataTable
|
||||
:columns="loginColumns"
|
||||
:data="loginRecords"
|
||||
:loading="loginLoading"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:striped="true"
|
||||
:scroll-x="900"
|
||||
>
|
||||
<template #empty>
|
||||
<NEmpty :description="t('log.noData')" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
|
||||
<NSpace justify="end" style="margin-top: 12px;">
|
||||
<NPagination
|
||||
v-model:page="loginPage"
|
||||
v-model:page-size="loginSize"
|
||||
:item-count="loginTotal"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
show-size-picker
|
||||
@update:page="loadLogin"
|
||||
@update:page-size="searchLogin"
|
||||
/>
|
||||
</NSpace>
|
||||
</NTabPane>
|
||||
|
||||
<!-- 运行日志 -->
|
||||
<NTabPane name="app" :tab="t('log.appTab')">
|
||||
<NSpace align="center" :size="12" style="margin-bottom: 12px;">
|
||||
<NInput
|
||||
v-model:value="appDate"
|
||||
:placeholder="t('log.datePlaceholder')"
|
||||
clearable
|
||||
:disabled="appRealtime"
|
||||
style="width: 180px;"
|
||||
@keyup.enter="loadApp"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="appRange"
|
||||
:options="rangeOptions"
|
||||
:disabled="appRealtime"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="appLevel"
|
||||
:options="levelOptions"
|
||||
:placeholder="t('log.level')"
|
||||
clearable
|
||||
:disabled="appRealtime"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="appKeyword"
|
||||
:placeholder="t('log.keyword')"
|
||||
clearable
|
||||
:disabled="appRealtime"
|
||||
style="width: 200px;"
|
||||
@keyup.enter="loadApp"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="appLines"
|
||||
:min="1"
|
||||
:max="2000"
|
||||
:placeholder="t('log.lines')"
|
||||
:disabled="appRealtime"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NButton type="primary" :loading="appLoading" :disabled="appRealtime" @click="loadApp()">
|
||||
<template #icon><NIcon :component="RefreshOutline" /></template>
|
||||
{{ t('log.refresh') }}
|
||||
</NButton>
|
||||
<NSpace align="center" :size="6">
|
||||
<NSwitch v-model:value="appRealtime" size="small" />
|
||||
<NText depth="3">{{ t('log.realtime') }}</NText>
|
||||
</NSpace>
|
||||
<NText depth="3">
|
||||
{{ t('log.file') }}: {{ appData?.file || '-' }}
|
||||
</NText>
|
||||
<NText depth="3">
|
||||
{{ t('log.totalLines', { count: appData?.total ?? 0 }) }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
|
||||
<div class="log-viewer">
|
||||
<NEmpty v-if="!appLoading && !(appData?.lines?.length)" :description="t('log.appEmpty')" />
|
||||
<div v-for="(line, index) in appData?.lines ?? []" :key="index" :class="lineClass(line)">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.logs-page > :deep(.n-card) {
|
||||
border: 1px solid #efeff5;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.cell-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.log-viewer {
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.log-line {
|
||||
color: #d4d4d4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-warn {
|
||||
color: #e6b455;
|
||||
}
|
||||
.log-error {
|
||||
color: #f07178;
|
||||
}
|
||||
.log-debug {
|
||||
color: #7f9aa8;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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+创建时间联合索引'
|
||||
);
|
||||
|
||||
@@ -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);;
|
||||
|
||||
Reference in New Issue
Block a user