操作日志和访问日志

This commit is contained in:
扫地僧 2025-11-11 22:32:46 +08:00
parent efc079c0e5
commit 12a0ff8afc
17 changed files with 2300 additions and 4 deletions

36
pc/src/api/accessLog.js Normal file
View File

@ -0,0 +1,36 @@
import request from '@/utils/request'
// 获取访问日志列表
export function getAccessLogs(params) {
return request({
url: '/api/access-logs',
method: 'get',
params
})
}
// 根据ID获取访问日志详情
export function getAccessLogById(id) {
return request({
url: `/api/access-logs/${id}`,
method: 'get'
})
}
// 获取用户访问统计
export function getUserAccessStats(params) {
return request({
url: '/api/access-logs/user/stats',
method: 'get',
params
})
}
// 清空旧访问日志
export function clearOldAccessLogs(keepDays = 90) {
return request({
url: '/api/access-logs/clear',
method: 'post',
data: { keep_days: keepDays }
})
}

View File

@ -0,0 +1,45 @@
import request from '@/utils/request'
// 获取操作日志列表
export function getOperationLogs(params) {
return request({
url: '/api/operation-logs',
method: 'get',
params
})
}
// 根据ID获取操作日志详情
export function getOperationLogById(id) {
return request({
url: `/api/operation-logs/${id}`,
method: 'get'
})
}
// 获取用户操作统计
export function getUserOperationStats(params) {
return request({
url: '/api/operation-logs/user/stats',
method: 'get',
params
})
}
// 获取租户操作统计
export function getTenantOperationStats(params) {
return request({
url: '/api/operation-logs/tenant/stats',
method: 'get',
params
})
}
// 清空旧日志
export function clearOldLogs(keepDays = 90) {
return request({
url: '/api/operation-logs/clear',
method: 'post',
data: { keep_days: keepDays }
})
}

View File

@ -0,0 +1,367 @@
<template>
<div class="access-log-container">
<!-- 统计面板 -->
<!-- <StatisticsPanel /> -->
<!-- 搜索和操作栏 -->
<el-card shadow="hover" style="margin-bottom: 20px">
<el-form :model="filters" label-width="100px" :inline="true">
<el-form-item label="用户">
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
</el-form-item>
<el-form-item label="模块">
<el-select v-model="filters.module" placeholder="选择模块" clearable>
<el-option label="全部" value="" />
<el-option
v-for="opt in moduleOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="资源类型">
<el-input v-model="filters.resource_type" placeholder="搜索资源类型" clearable />
</el-form-item>
</el-form>
<el-form :model="dateRange" label-width="100px" :inline="true">
<el-form-item label="访问时间">
<el-date-picker
v-model="dateRange.range"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</el-form-item>
</el-form>
<div style="margin-top: 15px">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="showClearDialog">清空日志</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
</el-card>
<!-- 日志列表 -->
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>访问日志列表</span>
<span class="log-count"> {{ total }} </span>
</div>
</template>
<el-table
:data="tableData"
stripe
style="width: 100%; margin-bottom: 20px"
v-loading="loading"
@row-click="handleRowClick"
>
<el-table-column prop="username" label="用户" align="center" width="120" />
<el-table-column prop="module_name" label="模块" align="center" width="120">
<template #default="{ row }">
<el-tag>{{ row.module_name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource_type" label="资源类型" align="center" width="120" />
<el-table-column prop="request_url" label="访问路径" align="center" min-width="200">
<template #default="{ row }">
<el-tooltip :content="row.request_url" placement="top">
<span>{{ row.request_url }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="request_method" label="方法" align="center" width="80">
<template #default="{ row }">
<el-tag :type="getMethodTag(row.request_method)">
{{ row.request_method }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP地址" align="center" width="140" />
<el-table-column prop="duration" label="耗时(ms)" align="center" width="100" />
<el-table-column prop="create_time" label="访问时间" align="center" width="180">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@change="handlePageChange"
/>
</el-card>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="访问日志详情" width="70%">
<el-descriptions v-if="currentRecord" :column="2" border>
<el-descriptions-item label="日志ID">
{{ currentRecord.id }}
</el-descriptions-item>
<el-descriptions-item label="用户">
{{ currentRecord.username }}
</el-descriptions-item>
<el-descriptions-item label="租户ID">
{{ currentRecord.tenant_id }}
</el-descriptions-item>
<el-descriptions-item label="用户ID">
{{ currentRecord.user_id }}
</el-descriptions-item>
<el-descriptions-item label="模块">
{{ currentRecord.module_name }}
</el-descriptions-item>
<el-descriptions-item label="资源类型">
{{ currentRecord.resource_type }}
</el-descriptions-item>
<el-descriptions-item label="资源ID">
{{ currentRecord.resource_id || '-' }}
</el-descriptions-item>
<el-descriptions-item label="访问路径">
{{ currentRecord.request_url }}
</el-descriptions-item>
<el-descriptions-item label="请求方法">
{{ currentRecord.request_method }}
</el-descriptions-item>
<el-descriptions-item label="查询字符串">
{{ currentRecord.query_string || '-' }}
</el-descriptions-item>
<el-descriptions-item label="IP地址">
{{ currentRecord.ip_address }}
</el-descriptions-item>
<el-descriptions-item label="User Agent">
<div style="word-break: break-all; font-size: 12px">
{{ currentRecord.user_agent }}
</div>
</el-descriptions-item>
<el-descriptions-item label="耗时(ms)">
{{ currentRecord.duration }}
</el-descriptions-item>
<el-descriptions-item label="访问时间">
{{ formatTime(currentRecord.create_time) }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 清空对话框 -->
<el-dialog v-model="clearDialogVisible" title="清空日志" width="400px">
<el-form :model="clearForm" label-width="100px">
<el-form-item label="保留天数">
<el-input-number v-model="clearForm.keepDays" :min="0" :max="365" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="clearDialogVisible = false">取消</el-button>
<el-button type="danger" @click="handleClear">确定清空</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAccessLogs, clearOldAccessLogs } from '@/api/accessLog'
import { getAllMenus } from '@/api/menu'
const filters = reactive({
username: '',
module: '',
resource_type: ''
})
const dateRange = reactive({
range: null
})
const tableData = ref([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const detailDialogVisible = ref(false)
const clearDialogVisible = ref(false)
const currentRecord = ref(null)
const clearForm = reactive({
keepDays: 90
})
const moduleOptions = ref([])
//
const formatTime = (time) => {
if (!time) return '-'
const date = new Date(time)
return date.toLocaleString('zh-CN')
}
// HTTP
const getMethodTag = (method) => {
const tagMap = {
GET: 'success',
POST: 'warning',
PUT: 'info',
DELETE: 'danger'
}
return tagMap[method] || 'info'
}
//
const loadMenus = async () => {
try {
const res = await getAllMenus()
if (res.data) {
const menus = res.data.list || res.data || []
const options = menus.map((m) => ({
value: m.path ? m.path.split('/').pop() : m.permission,
label: m.name
}))
moduleOptions.value = options
}
} catch (e) {
console.error('Failed to load menus:', e)
}
}
//
const loadLogs = async () => {
loading.value = true
try {
const params = {
page_num: currentPage.value,
page_size: pageSize.value,
username: filters.username || undefined,
module: filters.module || undefined,
resource_type: filters.resource_type || undefined
}
if (dateRange.range && dateRange.range.length === 2) {
params.start_time = dateRange.range[0].toLocaleString('zh-CN')
params.end_time = dateRange.range[1].toLocaleString('zh-CN')
}
const res = await getAccessLogs(params)
if (res.data) {
tableData.value = res.data
// moduleOptions
tableData.value.forEach((row) => {
const module = moduleOptions.value.find((m) => m.value === row.module)
row.module_name = module ? module.label : row.module
})
total.value = res.total || 0
}
} catch (error) {
ElMessage.error('加载访问日志失败')
console.error(error)
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
currentPage.value = 1
loadLogs()
}
//
const handleReset = () => {
filters.username = ''
filters.module = ''
filters.resource_type = ''
dateRange.range = null
currentPage.value = 1
loadLogs()
}
//
const handleDateChange = () => {
currentPage.value = 1
loadLogs()
}
//
const handlePageChange = () => {
loadLogs()
}
//
const showDetail = (row) => {
currentRecord.value = row
detailDialogVisible.value = true
}
//
const showClearDialog = () => {
clearDialogVisible.value = true
}
//
const handleClear = async () => {
try {
await ElMessageBox.confirm(
`确定要清空 ${clearForm.keepDays} 天前的日志吗?该操作不可撤销。`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await clearOldAccessLogs(clearForm.keepDays)
ElMessage.success('日志清空成功')
clearDialogVisible.value = false
loadLogs()
} catch (e) {
//
}
}
//
const handleExport = () => {
// 使
ElMessage.info('导出功能暂未实现')
}
//
onMounted(async () => {
await loadMenus()
await loadLogs()
})
</script>
<style scoped>
.access-log-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-count {
color: #909399;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,181 @@
<template>
<el-dialog
v-model="dialogVisible"
title="操作日志详情"
width="80%"
@close="closeDialog"
>
<div v-if="logDetail" class="detail-container">
<!-- 基本信息 -->
<el-descriptions :column="2" border>
<el-descriptions-item label="操作ID">{{ logDetail.id }}</el-descriptions-item>
<el-descriptions-item label="用户">{{ logDetail.username }}</el-descriptions-item>
<el-descriptions-item label="模块">{{ logDetail.module }}</el-descriptions-item>
<el-descriptions-item label="操作类型">
<el-tag :type="getOperationTag(logDetail.operation)">{{ logDetail.operation }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="资源类型">{{ logDetail.resource_type }}</el-descriptions-item>
<el-descriptions-item label="资源ID">{{ logDetail.resource_id }}</el-descriptions-item>
<el-descriptions-item label="操作结果">
<el-tag :type="logDetail.status === 1 ? 'success' : 'danger'">
{{ logDetail.status === 1 ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作耗时">{{ logDetail.duration_ms }}ms</el-descriptions-item>
<el-descriptions-item label="请求方法">{{ logDetail.request_method }}</el-descriptions-item>
<el-descriptions-item label="请求IP">{{ logDetail.ip_address }}</el-descriptions-item>
<el-descriptions-item label="操作时间" :span="2">{{ formatTime(logDetail.create_time) }}</el-descriptions-item>
</el-descriptions>
<!-- 请求信息 -->
<el-divider content-position="left">请求信息</el-divider>
<el-descriptions :column="1" border>
<el-descriptions-item label="请求URL">
<div class="url-text">{{ logDetail.request_url }}</div>
</el-descriptions-item>
<el-descriptions-item label="User Agent">
<div class="user-agent-text">{{ logDetail.user_agent }}</div>
</el-descriptions-item>
</el-descriptions>
<!-- 操作说明 -->
<el-divider content-position="left">操作说明</el-divider>
<div class="description-box">{{ logDetail.description || '-' }}</div>
<!-- 修改前后值 -->
<div v-if="logDetail.old_value || logDetail.new_value">
<el-divider content-position="left">数据变更</el-divider>
<el-row :gutter="20">
<el-col :span="12" v-if="logDetail.old_value">
<h4>修改前</h4>
<pre class="json-box">{{ formatJson(logDetail.old_value) }}</pre>
</el-col>
<el-col :span="12" v-if="logDetail.new_value">
<h4>修改后</h4>
<pre class="json-box">{{ formatJson(logDetail.new_value) }}</pre>
</el-col>
</el-row>
</div>
<!-- 错误信息 -->
<div v-if="logDetail.error_message">
<el-divider content-position="left">错误信息</el-divider>
<el-alert
:title="logDetail.error_message"
type="error"
:closable="false"
show-icon
/>
</div>
</div>
<template #footer>
<el-button @click="closeDialog">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { getOperationLogById } from '@/api/operationLog'
import { ElMessage } from 'element-plus'
const dialogVisible = ref(false)
const logDetail = ref(null)
const getOperationTag = (operation) => {
const map = {
'CREATE': 'success',
'READ': 'info',
'UPDATE': 'warning',
'DELETE': 'danger',
'LOGIN': 'primary',
'LOGOUT': 'info'
}
return map[operation] || 'info'
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
const formatJson = (jsonStr) => {
try {
if (typeof jsonStr === 'string') {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
}
return JSON.stringify(jsonStr, null, 2)
} catch {
return jsonStr || '-'
}
}
const openDetail = async (id) => {
try {
const res = await getOperationLogById(id)
if (res.success) {
logDetail.value = res.data
dialogVisible.value = true
}
} catch (error) {
ElMessage.error('加载日志详情失败')
}
}
const closeDialog = () => {
dialogVisible.value = false
logDetail.value = null
}
defineExpose({
openDetail
})
</script>
<style scoped>
.detail-container {
padding: 10px 0;
}
.url-text {
word-break: break-all;
font-family: monospace;
font-size: 12px;
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
}
.user-agent-text {
word-break: break-all;
font-family: monospace;
font-size: 12px;
background: #f5f7fa;
padding: 8px;
border-radius: 4px;
}
.description-box {
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
min-height: 60px;
line-height: 1.6;
}
.json-box {
background: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
overflow-x: auto;
border-left: 3px solid #409eff;
margin: 0;
}
h4 {
margin: 10px 0 5px 0;
color: #303133;
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<el-container>
<!-- 统计面板 -->
<el-card class="statistics-card" shadow="hover">
<template #header>
<div class="card-header">
<span>操作统计</span>
<el-button type="primary" size="small" @click="refreshStats">刷新</el-button>
</div>
</template>
<el-row :gutter="20" v-if="statsData">
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-item">
<div class="stat-label">总操作数</div>
<div class="stat-value">{{ statsData.total_operations || 0 }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-item">
<div class="stat-label">活跃用户</div>
<div class="stat-value">{{ statsData.total_users || 0 }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-item">
<div class="stat-label">查询操作</div>
<div class="stat-value" style="color: #409eff">{{ statsData.query_operations || 0 }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="stat-item">
<div class="stat-label">修改操作</div>
<div class="stat-value" style="color: #e6a23c">{{ statsData.modify_operations || 0 }}</div>
</div>
</el-col>
</el-row>
<!-- 用户排行 -->
<div v-if="topUsers && topUsers.length > 0" style="margin-top: 20px">
<h4>用户操作排行</h4>
<el-table :data="topUsers" stripe max-height="300">
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="operation_count" label="操作次数" width="100" align="center" />
<el-table-column prop="last_operation_time" label="最后操作" width="180" />
</el-table>
</div>
</el-card>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getTenantOperationStats } from '@/api/operationLog'
import { ElMessage } from 'element-plus'
const statsData = ref(null)
const topUsers = ref([])
const days = ref(7)
const loadStats = async () => {
try {
const res = await getTenantOperationStats({ days: days.value })
if (res.success) {
statsData.value = res.data.statistics
topUsers.value = res.data.top_users || []
}
} catch (error) {
ElMessage.error('加载统计数据失败')
}
}
const refreshStats = () => {
loadStats()
}
onMounted(() => {
loadStats()
})
</script>
<style scoped>
.statistics-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-item {
text-align: center;
padding: 15px;
background: #f0f9ff;
border-radius: 4px;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
</style>

View File

@ -0,0 +1,349 @@
<template>
<div class="operation-log-container">
<!-- 统计面板 -->
<!-- <StatisticsPanel /> -->
<!-- 搜索和操作栏 -->
<el-card shadow="hover" style="margin-bottom: 20px">
<el-form :model="filters" label-width="100px" :inline="true">
<el-form-item label="用户">
<el-input v-model="filters.username" placeholder="搜索用户名" clearable />
</el-form-item>
<el-form-item label="模块">
<el-select v-model="filters.module" placeholder="选择模块" clearable>
<el-option label="全部" value="" />
<el-option
v-for="opt in moduleOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="filters.operation" placeholder="选择操作类型" clearable>
<el-option label="全部" value="" />
<el-option label="新增" value="CREATE" />
<el-option label="查询" value="READ" />
<el-option label="修改" value="UPDATE" />
<el-option label="删除" value="DELETE" />
<el-option label="登录" value="LOGIN" />
</el-select>
</el-form-item>
<el-form-item label="操作结果">
<el-select v-model="filters.status" placeholder="选择操作结果" clearable>
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
</el-form>
<el-form :model="dateRange" label-width="100px" :inline="true">
<el-form-item label="操作时间">
<el-date-picker
v-model="dateRange.range"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleDateChange"
/>
</el-form-item>
</el-form>
<div style="margin-top: 15px">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button @click="showClearDialog">清空日志</el-button>
<el-button @click="handleExport">导出</el-button>
</div>
</el-card>
<!-- 日志列表 -->
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>操作日志列表</span>
<span class="log-count"> {{ total }} </span>
</div>
</template>
<el-table
:data="tableData"
stripe
style="width: 100%; margin-bottom: 20px"
v-loading="loading"
@row-click="handleRowClick"
>
<el-table-column prop="username" label="用户" align="center"/>
<el-table-column prop="module_name" label="模块" align="center">
<template #default="{ row }">
<el-tag>{{ row.module_name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="operation" label="操作类型" align="center">
<template #default="{ row }">
<el-tag :type="getOperationTag(row.operation)">{{ row.operation }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource_type" label="资源类型" align="center"/>
<el-table-column prop="status" label="结果" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP地址" align="center"/>
<el-table-column prop="create_time" label="操作时间" align="center">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="handleDetail(row.id)">
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page_num"
v-model:page-size="pagination.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@change="loadLogs"
/>
</el-card>
<!-- 详情对话框 -->
<OperationLogDetail ref="detailRef" />
<!-- 清空日志对话框 -->
<el-dialog v-model="clearDialogVisible" title="清空旧日志" width="400px">
<el-form :model="clearForm" label-width="150px">
<el-form-item label="保留天数">
<el-input-number
v-model="clearForm.keep_days"
:min="1"
:max="365"
controls-position="right"
/>
<span style="color: #909399; font-size: 12px; margin-left: 10px">
将删除超过 {{ clearForm.keep_days }} 天的日志
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="clearDialogVisible = false">取消</el-button>
<el-button type="danger" @click="handleClearLogs">确认清空</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getOperationLogs, clearOldLogs } from '@/api/operationLog'
import { getAllMenus } from '@/api/menu'
import { ElMessage, ElMessageBox } from 'element-plus'
// import StatisticsPanel from './components/StatisticsPanel.vue'
import OperationLogDetail from './components/OperationLogDetail.vue'
const tableData = ref([])
const loading = ref(false)
const total = ref(0)
const detailRef = ref(null)
const clearDialogVisible = ref(false)
const moduleOptions = ref([])
const pagination = reactive({
page_num: 1,
page_size: 20
})
const filters = reactive({
username: '',
module: '',
operation: '',
status: ''
})
const dateRange = reactive({
range: null
})
const clearForm = reactive({
keep_days: 90
})
const getOperationTag = (operation) => {
const map = {
'CREATE': 'success',
'READ': 'info',
'UPDATE': 'warning',
'DELETE': 'danger',
'LOGIN': 'primary',
'LOGOUT': 'info'
}
return map[operation] || 'info'
}
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN')
}
//
const loadMenus = async () => {
try {
const res = await getAllMenus()
if (res && res.success && Array.isArray(res.data)) {
const map = new Map()
res.data.forEach((m) => {
const name = m.name || m.title || ''
let code = ''
// 使 path /system/dict -> dict module
if (m.path) {
const parts = m.path.split('/').filter(Boolean)
code = parts.length ? parts[parts.length - 1] : m.path
} else if (m.permission) {
// 使 permission
code = m.permission
} else {
code = String(m.id)
}
if (code && !map.has(code)) {
map.set(code, name)
}
})
moduleOptions.value = Array.from(map.entries()).map(([value, label]) => ({ value, label }))
}
} catch (error) {
//
console.error('加载菜单失败', error)
}
}
const loadLogs = async () => {
loading.value = true
try {
const params = {
...pagination,
...filters
}
//
if (dateRange.range && dateRange.range.length === 2) {
params.start_time = new Date(dateRange.range[0]).toISOString().split('T')[0]
params.end_time = new Date(dateRange.range[1]).toISOString().split('T')[0]
}
const res = await getOperationLogs(params)
if (res.success) {
tableData.value = res.data || []
total.value = res.total || 0
}
} catch (error) {
ElMessage.error('加载日志失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page_num = 1
loadLogs()
}
const handleReset = () => {
filters.username = ''
filters.module = ''
filters.operation = ''
filters.status = ''
dateRange.range = null
pagination.page_num = 1
loadLogs()
}
const handleDateChange = () => {
handleSearch()
}
const handleRowClick = (row) => {
handleDetail(row.id)
}
const handleDetail = (id) => {
detailRef.value?.openDetail(id)
}
const showClearDialog = () => {
clearDialogVisible.value = true
}
const handleClearLogs = async () => {
ElMessageBox.confirm(
`将删除超过 ${clearForm.keep_days} 天的所有操作日志,此操作无法撤销!`,
'警告',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const res = await clearOldLogs(clearForm.keep_days)
if (res.success) {
ElMessage.success('日志清空成功')
clearDialogVisible.value = false
loadLogs()
}
} catch (error) {
ElMessage.error('清空日志失败')
}
}).catch(() => {
//
})
}
const handleExport = () => {
ElMessage.info('导出功能开发中...')
}
onMounted(async () => {
await loadMenus()
loadLogs()
})
</script>
<style scoped>
.operation-log-container {
padding: 10px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-count {
color: #909399;
font-size: 14px;
}
:deep(.el-table__row) {
cursor: pointer;
}
:deep(.el-table__row:hover) {
background-color: #f5f7fa;
}
</style>

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"server/models" "server/models"
"server/services" "server/services"
"strconv"
"strings" "strings"
"time" "time"
@ -188,6 +189,24 @@ func (c *AuthController) Login() {
_, _ = o.Raw("UPDATE yz_tenant_employees SET last_login_time = ?, last_login_ip = ? WHERE id = ?", loginTime, clientIP, employee.Id).Exec() _, _ = o.Raw("UPDATE yz_tenant_employees SET last_login_time = ?, last_login_ip = ? WHERE id = ?", loginTime, clientIP, employee.Id).Exec()
} }
// 记录登录操作
loginLog := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: usernameForToken,
Module: "auth",
ResourceType: "user",
Operation: "LOGIN",
IpAddress: clientIP,
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: "POST",
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: loginTime,
}
_ = services.AddOperationLog(loginLog)
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"code": 0, "code": 0,
"message": "登录成功", "message": "登录成功",
@ -205,6 +224,43 @@ func (c *AuthController) Login() {
// Logout 处理登出请求 // Logout 处理登出请求
func (c *AuthController) Logout() { func (c *AuthController) Logout() {
// 获取登出的用户信息
userIdStr := c.GetString("user_id")
username := c.GetString("username")
tenantIdStr := c.GetString("tenant_id")
var userId, tenantId int
if userIdStr != "" {
id, err := strconv.Atoi(userIdStr)
if err == nil {
userId = id
}
}
if tenantIdStr != "" {
id, err := strconv.Atoi(tenantIdStr)
if err == nil {
tenantId = id
}
}
// 记录登出操作
logoutLog := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: "auth",
ResourceType: "user",
Operation: "LOGOUT",
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: "POST",
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(logoutLog)
// 在实际应用中这里需要处理JWT或Session的清除 // 在实际应用中这里需要处理JWT或Session的清除
c.Data["json"] = map[string]interface{}{ c.Data["json"] = map[string]interface{}{
"success": true, "success": true,

View File

@ -5,6 +5,7 @@ import (
"server/models" "server/models"
"server/services" "server/services"
"strconv" "strconv"
"time"
"github.com/beego/beego/v2/server/web" "github.com/beego/beego/v2/server/web"
) )
@ -160,6 +161,32 @@ func (c *DictController) AddDictType() {
"data": map[string]interface{}{"id": id}, "data": map[string]interface{}{"id": id},
} }
c.ServeJSON() c.ServeJSON()
// 记录操作日志 - 创建字典类型
go func() {
// 异步写日志,避免阻塞请求响应
rid := int(id)
newVal, _ := json.Marshal(dictType)
log := &models.OperationLog{
TenantId: dictType.TenantId,
UserId: userId,
Username: username,
Module: "dict",
ResourceType: "dict_type",
ResourceId: &rid,
Operation: "CREATE",
Description: "创建字典类型",
NewValue: string(newVal),
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: c.Ctx.Input.Method(),
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(log)
}()
} }
// UpdateDictType 更新字典类型 // UpdateDictType 更新字典类型
@ -194,6 +221,15 @@ func (c *DictController) UpdateDictType() {
} }
} }
// 获取用户ID
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
dictType.Id = id dictType.Id = id
dictType.UpdateBy = username dictType.UpdateBy = username
@ -212,6 +248,31 @@ func (c *DictController) UpdateDictType() {
"message": "更新成功", "message": "更新成功",
} }
c.ServeJSON() c.ServeJSON()
// 记录操作日志 - 更新字典类型
go func() {
newVal, _ := json.Marshal(dictType)
// 旧值可以从服务层获取,如果需要更详细的旧值,可以在 UpdateDictType 前查询并传入
log := &models.OperationLog{
TenantId: dictType.TenantId,
UserId: userId,
Username: username,
Module: "dict",
ResourceType: "dict_type",
ResourceId: &dictType.Id,
Operation: "UPDATE",
Description: "更新字典类型",
NewValue: string(newVal),
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: c.Ctx.Input.Method(),
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(log)
}()
} }
// DeleteDictType 删除字典类型 // DeleteDictType 删除字典类型
@ -227,7 +288,7 @@ func (c *DictController) DeleteDictType() {
return return
} }
// 获取租户ID // 获取租户ID、用户名和用户ID 用于记录日志
tenantIdData := c.Ctx.Input.GetData("tenantId") tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0 tenantId := 0
if tenantIdData != nil { if tenantIdData != nil {
@ -235,6 +296,20 @@ func (c *DictController) DeleteDictType() {
tenantId = tid tenantId = tid
} }
} }
usernameData := c.Ctx.Input.GetData("username")
username := ""
if usernameData != nil {
if u, ok := usernameData.(string); ok {
username = u
}
}
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
err = services.DeleteDictType(id, tenantId) err = services.DeleteDictType(id, tenantId)
if err != nil { if err != nil {
@ -251,6 +326,29 @@ func (c *DictController) DeleteDictType() {
"message": "删除成功", "message": "删除成功",
} }
c.ServeJSON() c.ServeJSON()
// 记录操作日志 - 删除字典类型
go func() {
rid := id
log := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: "dict",
ResourceType: "dict_type",
ResourceId: &rid,
Operation: "DELETE",
Description: "删除字典类型",
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: c.Ctx.Input.Method(),
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(log)
}()
} }
// GetDictItems 获取字典项列表 // GetDictItems 获取字典项列表
@ -380,6 +478,46 @@ func (c *DictController) AddDictItem() {
"data": map[string]interface{}{"id": id}, "data": map[string]interface{}{"id": id},
} }
c.ServeJSON() c.ServeJSON()
// 记录操作日志 - 创建字典项
go func() {
// 获取用户ID和租户ID
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
rid := int(id)
newVal, _ := json.Marshal(dictItem)
log := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: "dict",
ResourceType: "dict_item",
ResourceId: &rid,
Operation: "CREATE",
Description: "创建字典项",
NewValue: string(newVal),
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: c.Ctx.Input.Method(),
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(log)
}()
} }
// UpdateDictItem 更新字典项 // UpdateDictItem 更新字典项
@ -432,6 +570,41 @@ func (c *DictController) UpdateDictItem() {
"message": "更新成功", "message": "更新成功",
} }
c.ServeJSON() c.ServeJSON()
// 记录操作日志 - 更新字典项
go func() {
// 获取用户ID和租户ID
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
tenantId := 0
// 通过字典项id尝试获取所属字典类型并推断租户可选
// 这里先不查询,留 0 或者可从前端传入
newVal, _ := json.Marshal(dictItem)
log := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: "dict",
ResourceType: "dict_item",
ResourceId: &dictItem.Id,
Operation: "UPDATE",
Description: "更新字典项",
NewValue: string(newVal),
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: c.Ctx.Input.Method(),
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(log)
}()
} }
// DeleteDictItem 删除字典项 // DeleteDictItem 删除字典项
@ -462,6 +635,51 @@ func (c *DictController) DeleteDictItem() {
"message": "删除成功", "message": "删除成功",
} }
c.ServeJSON() c.ServeJSON()
// 记录操作日志 - 删除字典项
go func() {
// 获取用户ID和租户ID
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
usernameData := c.Ctx.Input.GetData("username")
username := ""
if usernameData != nil {
if u, ok := usernameData.(string); ok {
username = u
}
}
rid := id
log := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: "dict",
ResourceType: "dict_item",
ResourceId: &rid,
Operation: "DELETE",
Description: "删除字典项",
IpAddress: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Input.Header("User-Agent"),
RequestMethod: c.Ctx.Input.Method(),
RequestUrl: c.Ctx.Input.URL(),
Status: 1,
Duration: 0,
CreateTime: time.Now(),
}
_ = services.AddOperationLog(log)
}()
} }
// GetDictItemsByCode 根据字典编码获取字典项(用于业务查询) // GetDictItemsByCode 根据字典编码获取字典项(用于业务查询)

View File

@ -0,0 +1,247 @@
package controllers
import (
"server/models"
"server/services"
"strconv"
"time"
"github.com/beego/beego/v2/server/web"
)
// OperationLogController 操作日志控制器
type OperationLogController struct {
web.Controller
}
// GetOperationLogs 获取操作日志列表
func (c *OperationLogController) GetOperationLogs() {
// 获取租户ID和用户ID
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
// 获取查询参数
pageNum, _ := c.GetInt("page_num", 1)
pageSize, _ := c.GetInt("page_size", 20)
module := c.GetString("module")
operation := c.GetString("operation")
filterUserId, _ := c.GetInt("user_id", 0)
startTimeStr := c.GetString("start_time")
endTimeStr := c.GetString("end_time")
var startTime, endTime *time.Time
if startTimeStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", startTimeStr); err == nil {
startTime = &t
}
}
if endTimeStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", endTimeStr); err == nil {
endTime = &t
}
}
// 如果指定了用户ID筛选使用该ID否则使用当前用户ID
queryUserId := filterUserId
if queryUserId <= 0 {
queryUserId = userId
}
logs, total, err := services.GetOperationLogs(tenantId, queryUserId, module, operation, startTime, endTime, pageNum, pageSize)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "查询日志失败: " + err.Error(),
}
c.ServeJSON()
return
}
// 为每条日志批量解析模块名称(减少对菜单表的重复查询)
type LogWithModuleName struct {
*models.OperationLog
ModuleName string `json:"module_name"`
}
// 收集唯一 module 列表
moduleSet := make(map[string]struct{})
modules := make([]string, 0)
for _, l := range logs {
m := l.Module
if m == "" {
continue
}
if _, ok := moduleSet[m]; !ok {
moduleSet[m] = struct{}{}
modules = append(modules, m)
}
}
moduleNamesMap := map[string]string{}
if len(modules) > 0 {
if mm, err := services.GetModuleNames(modules); err == nil {
moduleNamesMap = mm
}
}
logsWithName := make([]*LogWithModuleName, len(logs))
for i, log := range logs {
name := ""
if v, ok := moduleNamesMap[log.Module]; ok {
name = v
} else {
// fallback 单个解析(保留老逻辑)
name = services.GetModuleName(log.Module)
}
logsWithName[i] = &LogWithModuleName{
OperationLog: log,
ModuleName: name,
}
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": logsWithName,
"total": total,
"page": pageNum,
"page_size": pageSize,
}
c.ServeJSON()
}
// GetOperationLogById 根据ID获取操作日志
func (c *OperationLogController) GetOperationLogById() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "参数错误",
}
c.ServeJSON()
return
}
log, err := services.GetOperationLogById(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "日志不存在",
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": log,
}
c.ServeJSON()
}
// GetUserStats 获取用户操作统计
func (c *OperationLogController) GetUserStats() {
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
days, _ := c.GetInt("days", 7)
stats, err := services.GetUserOperationStats(tenantId, userId, days)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "查询统计失败: " + err.Error(),
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": stats,
}
c.ServeJSON()
}
// GetTenantStats 获取租户操作统计
func (c *OperationLogController) GetTenantStats() {
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
days, _ := c.GetInt("days", 7)
stats, err := services.GetTenantOperationStats(tenantId, days)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "查询统计失败: " + err.Error(),
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": stats,
}
c.ServeJSON()
}
// ClearOldLogs 清空旧日志
func (c *OperationLogController) ClearOldLogs() {
keepDays, _ := c.GetInt("keep_days", 90)
rowsAffected, err := services.DeleteOldLogs(keepDays)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "删除日志失败: " + err.Error(),
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "删除成功",
"data": map[string]interface{}{
"deleted_count": rowsAffected,
"keep_days": keepDays,
},
}
c.ServeJSON()
}

View File

@ -0,0 +1,209 @@
package middleware
import (
"fmt"
"io"
"server/models"
"server/services"
"strconv"
"strings"
"time"
"github.com/beego/beego/v2/server/web/context"
)
// OperationLogMiddleware 操作日志中间件 - 记录所有的CREATE、UPDATE、DELETE操作
func OperationLogMiddleware(ctx *context.Context) {
// 记录所有重要操作包括修改类POST/PUT/PATCH/DELETE和读取类GET用于统计账户访问功能
method := ctx.Input.Method()
// 获取用户信息和租户信息(由 JWT 中间件设置在 Input.Data 中)
userId := 0
tenantId := 0
username := ""
if v := ctx.Input.GetData("userId"); v != nil {
if id, ok := v.(int); ok {
userId = id
}
}
if v := ctx.Input.GetData("tenantId"); v != nil {
if id, ok := v.(int); ok {
tenantId = id
}
}
if v := ctx.Input.GetData("username"); v != nil {
if s, ok := v.(string); ok {
username = s
}
}
// 如果无法获取用户ID继续记录为匿名访问userId=0以便统计未登录或授权失败的访问
if userId == 0 {
// debug: 输出一些上下文信息,帮助定位为何未能获取 userId
fmt.Printf("OperationLogMiddleware: anonymous request %s %s, Authorization header length=%d\n", method, ctx.Input.URL(), len(ctx.Input.Header("Authorization")))
// 确保 username 有值,便于区分
if username == "" {
username = "anonymous"
}
}
// 读取请求体(对于有请求体的方法)
var requestBody string
if method == "POST" || method == "PUT" || method == "PATCH" {
body, err := io.ReadAll(ctx.Request.Body)
if err == nil {
requestBody = string(body)
// 重置请求体,使其可以被后续处理
ctx.Request.Body = io.NopCloser(strings.NewReader(requestBody))
}
}
startTime := time.Now()
// 使用延迟函数来记录操作
defer func() {
duration := time.Since(startTime)
// 解析操作类型
operation := parseOperationType(method, ctx.Input.URL())
resourceType := parseResourceType(ctx.Input.URL())
resourceId := parseResourceId(ctx.Input.URL())
module := parseModule(ctx.Input.URL())
// 如果是读取/访问行为写入访问日志sys_access_log否则写入操作日志sys_operation_log
if operation == "READ" {
access := &models.AccessLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: module,
ResourceType: resourceType,
ResourceId: &resourceId,
RequestUrl: ctx.Input.URL(),
IpAddress: ctx.Input.IP(),
UserAgent: ctx.Input.Header("User-Agent"),
RequestMethod: method,
Duration: int(duration.Milliseconds()),
}
// 在 QueryString 中记录查询参数和匿名标识
qs := ctx.Request.URL.RawQuery
if qs != "" {
access.QueryString = qs
}
if userId == 0 {
// 将匿名标识拼入 QueryString 以便查询(也可改为独立字段)
if access.QueryString != "" {
access.QueryString = "anonymous=true; " + access.QueryString
} else {
access.QueryString = "anonymous=true"
}
}
if err := services.AddAccessLog(access); err != nil {
fmt.Printf("Failed to save access log: %v\n", err)
}
return
}
// 创建操作日志
log := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: module,
ResourceType: resourceType,
ResourceId: &resourceId,
Operation: operation,
IpAddress: ctx.Input.IP(),
UserAgent: ctx.Input.Header("User-Agent"),
RequestMethod: method,
RequestUrl: ctx.Input.URL(),
Status: 1, // 默认成功,实际应该根据响应状态码更新
Duration: int(duration.Milliseconds()),
CreateTime: time.Now(),
}
// 如果是写操作保存请求体作为新值对于读取操作可以在Description里记录query
if requestBody != "" {
log.NewValue = requestBody
} else if method == "GET" {
// 把查询字符串放到描述里,便于分析访问参数
qs := ctx.Request.URL.RawQuery
if qs != "" {
log.Description = "query=" + qs
}
}
// 标记匿名访问信息(当 userId==0
if userId == 0 {
if log.Description != "" {
log.Description = "anonymous=true; " + log.Description
} else {
log.Description = "anonymous=true"
}
}
// 调用服务层保存日志
if err := services.AddOperationLog(log); err != nil {
fmt.Printf("Failed to save operation log: %v\n", err)
}
}()
}
// parseOperationType 根据HTTP方法解析操作类型
func parseOperationType(method, url string) string {
switch method {
case "POST":
// 检查URL是否包含特定的操作关键字
if strings.Contains(url, "login") {
return "LOGIN"
}
if strings.Contains(url, "logout") {
return "LOGOUT"
}
if strings.Contains(url, "add") || strings.Contains(url, "create") {
return "CREATE"
}
return "CREATE"
case "PUT", "PATCH":
return "UPDATE"
case "DELETE":
return "DELETE"
default:
return "READ"
}
}
// parseResourceType 根据URL解析资源类型
func parseResourceType(url string) string {
parts := strings.Split(strings.TrimPrefix(url, "/api/"), "/")
if len(parts) > 0 {
// 移除复数形式的s
resourceType := strings.TrimSuffix(parts[0], "s")
return resourceType
}
return "unknown"
}
// parseResourceId 从URL中提取资源ID
func parseResourceId(url string) int {
parts := strings.Split(strings.TrimPrefix(url, "/api/"), "/")
if len(parts) >= 2 {
// 尝试解析第二个部分为ID
if id, err := strconv.Atoi(parts[1]); err == nil {
return id
}
}
return 0
}
// parseModule 根据URL解析模块名称
func parseModule(url string) string {
// 返回与 sys_operation_log.module 字段匹配的短code例如 dict、user 等)
parts := strings.Split(strings.TrimPrefix(url, "/api/"), "/")
if len(parts) > 0 {
return strings.ToLower(parts[0])
}
return "unknown"
}

View File

@ -0,0 +1,27 @@
package models
import (
"time"
)
// AccessLog 访问日志(针对读取/浏览/访问行为)
type AccessLog struct {
Id int64 `orm:"auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
UserId int `orm:"column(user_id)" json:"user_id"`
Username string `orm:"column(username)" json:"username"`
Module string `orm:"column(module)" json:"module"`
ResourceType string `orm:"column(resource_type)" json:"resource_type"`
ResourceId *int `orm:"column(resource_id);null" json:"resource_id"`
RequestUrl string `orm:"column(request_url);null" json:"request_url"`
QueryString string `orm:"column(query_string);type(text);null" json:"query_string"`
IpAddress string `orm:"column(ip_address);null" json:"ip_address"`
UserAgent string `orm:"column(user_agent);null" json:"user_agent"`
RequestMethod string `orm:"column(request_method);null" json:"request_method"`
Duration int `orm:"column(duration);null" json:"duration"`
CreateTime time.Time `orm:"column(create_time);auto_now_add" json:"create_time"`
}
func (a *AccessLog) TableName() string {
return "sys_access_log"
}

View File

@ -0,0 +1,32 @@
package models
import (
"time"
)
// OperationLog 通用操作日志
type OperationLog struct {
Id int64 `orm:"auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
UserId int `orm:"column(user_id)" json:"user_id"`
Username string `orm:"column(username)" json:"username"`
Module string `orm:"column(module)" json:"module"`
ResourceType string `orm:"column(resource_type)" json:"resource_type"`
ResourceId *int `orm:"column(resource_id);null" json:"resource_id"`
Operation string `orm:"column(operation)" json:"operation"`
Description string `orm:"column(description);null" json:"description"`
OldValue string `orm:"column(old_value);type(longtext);null" json:"old_value"`
NewValue string `orm:"column(new_value);type(longtext);null" json:"new_value"`
IpAddress string `orm:"column(ip_address);null" json:"ip_address"`
UserAgent string `orm:"column(user_agent);null" json:"user_agent"`
RequestMethod string `orm:"column(request_method);null" json:"request_method"`
RequestUrl string `orm:"column(request_url);null" json:"request_url"`
Status int8 `orm:"column(status)" json:"status"`
ErrorMessage string `orm:"column(error_message);type(text);null" json:"error_message"`
Duration int `orm:"column(duration);null" json:"duration"`
CreateTime time.Time `orm:"column(create_time);auto_now_add" json:"create_time"`
}
func (o *OperationLog) TableName() string {
return "sys_operation_log"
}

View File

@ -46,6 +46,7 @@ func Init(version string) {
orm.RegisterModel(new(KnowledgeTag)) orm.RegisterModel(new(KnowledgeTag))
orm.RegisterModel(new(DictType)) orm.RegisterModel(new(DictType))
orm.RegisterModel(new(DictItem)) orm.RegisterModel(new(DictItem))
orm.RegisterModel(new(OperationLog))
ormConfig, err := beego.AppConfig.String("orm") ormConfig, err := beego.AppConfig.String("orm")
if err != nil { if err != nil {

View File

@ -207,6 +207,15 @@ func init() {
} }
}) })
// 在请求完成后记录操作日志(包括访问/读取行为)
beego.InsertFilter("/api/*", beego.FinishRouter, func(ctx *context.Context) {
// 避免记录操作日志接口自身以免循环
if strings.HasPrefix(ctx.Input.URL(), "/api/operation-logs") {
return
}
middleware.OperationLogMiddleware(ctx)
})
// 添加主页路由 // 添加主页路由
beego.Router("/", &controllers.MainController{}) beego.Router("/", &controllers.MainController{})
@ -329,4 +338,11 @@ func init() {
beego.Router("/api/program-infos/public", &controllers.ProgramInfoController{}, "get:GetProgramInfosPublic") beego.Router("/api/program-infos/public", &controllers.ProgramInfoController{}, "get:GetProgramInfosPublic")
beego.Router("/api/files/public", &controllers.FileController{}, "get:GetFilesPublic") beego.Router("/api/files/public", &controllers.FileController{}, "get:GetFilesPublic")
// 操作日志路由
beego.Router("/api/operation-logs", &controllers.OperationLogController{}, "get:GetOperationLogs")
beego.Router("/api/operation-logs/:id", &controllers.OperationLogController{}, "get:GetOperationLogById")
beego.Router("/api/operation-logs/user/stats", &controllers.OperationLogController{}, "get:GetUserStats")
beego.Router("/api/operation-logs/tenant/stats", &controllers.OperationLogController{}, "get:GetTenantStats")
beego.Router("/api/operation-logs/clear", &controllers.OperationLogController{}, "post:ClearOldLogs")
} }

View File

@ -0,0 +1,369 @@
package services
import (
"fmt"
"server/models"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
)
// GetModuleName 根据module代码获取模块中文名称
// 首先从菜单表查询,如果找不到则使用默认映射
func GetModuleName(module string) string {
if module == "" {
return ""
}
// 从菜单表中查询:优先匹配 permission其次尝试匹配 path 的最后一段(如 /system/dict -> dict
o := orm.NewOrm()
var menuName string
// 先尝试通过 permission 精准匹配
err := o.Raw(
"SELECT name FROM yz_menus WHERE permission = ? AND delete_time IS NULL LIMIT 1",
module,
).QueryRow(&menuName)
if err == nil && menuName != "" {
return menuName
}
// 再尝试通过 path 末段匹配(例如 /system/dict -> dict或 path 包含该段
err = o.Raw(
"SELECT name FROM yz_menus WHERE (path LIKE CONCAT('%/', ?, '/%') OR path LIKE CONCAT('%/', ?)) AND delete_time IS NULL LIMIT 1",
module, module,
).QueryRow(&menuName)
if err == nil && menuName != "" {
return menuName
}
// 如果菜单表中找不到,使用默认映射
defaultMap := map[string]string{
"auth": "认证",
"dict": "字典管理",
"user": "用户管理",
"role": "角色管理",
"permission": "权限管理",
"department": "部门管理",
"employee": "员工管理",
"position": "职位管理",
"tenant": "租户管理",
"system": "系统设置",
"oa": "OA",
"menu": "菜单管理",
"knowledge": "知识库",
}
if name, exists := defaultMap[module]; exists {
return name
}
return module
}
// GetModuleNames 批量获取模块名称,返回 module->name 映射
func GetModuleNames(modules []string) (map[string]string, error) {
result := make(map[string]string)
if len(modules) == 0 {
return result, nil
}
o := orm.NewOrm()
// 1. 尝试通过 permission 精准匹配
placeholders := make([]string, 0)
args := make([]interface{}, 0)
for _, m := range modules {
if m == "" {
continue
}
placeholders = append(placeholders, "?")
args = append(args, m)
}
if len(args) > 0 {
query := "SELECT permission, name FROM yz_menus WHERE permission IN (" + strings.Join(placeholders, ",") + ") AND delete_time IS NULL"
rows := make([]struct {
Permission string
Name string
}, 0)
_, err := o.Raw(query, args...).QueryRows(&rows)
if err == nil {
for _, r := range rows {
if r.Permission != "" {
result[r.Permission] = r.Name
}
}
}
}
// 2. 对于仍未匹配的模块,尝试通过 path 的末段进行模糊匹配
missing := make([]string, 0)
for _, m := range modules {
if m == "" {
continue
}
if _, ok := result[m]; !ok {
missing = append(missing, m)
}
}
if len(missing) > 0 {
// 使用 OR 组合 path LIKE '%/m' OR path LIKE '%/m/%'
conds := make([]string, 0)
args2 := make([]interface{}, 0)
for _, m := range missing {
conds = append(conds, "path LIKE ? OR path LIKE ?")
args2 = append(args2, "%/"+m, "%/"+m+"/%")
}
query2 := "SELECT path, name FROM yz_menus WHERE (" + strings.Join(conds, " OR ") + ") AND delete_time IS NULL"
rows2 := make([]struct {
Path string
Name string
}, 0)
_, err := o.Raw(query2, args2...).QueryRows(&rows2)
if err == nil {
for _, r := range rows2 {
// extract last segment from path
parts := strings.Split(strings.Trim(r.Path, "/"), "/")
if len(parts) > 0 {
key := parts[len(parts)-1]
if key != "" {
if _, exists := result[key]; !exists {
result[key] = r.Name
}
}
}
}
}
}
// 3. 对于仍然没有匹配的,使用默认映射
defaultMap := map[string]string{
"auth": "认证",
"dict": "字典管理",
"user": "用户管理",
"role": "角色管理",
"permission": "权限管理",
"department": "部门管理",
"employee": "员工管理",
"position": "职位管理",
"tenant": "租户管理",
"system": "系统设置",
"oa": "OA",
"menu": "菜单管理",
"knowledge": "知识库",
}
for _, m := range modules {
if m == "" {
continue
}
if _, exists := result[m]; !exists {
if v, ok := defaultMap[m]; ok {
result[m] = v
} else {
result[m] = m
}
}
}
return result, nil
}
// AddOperationLog 添加操作日志
func AddOperationLog(log *models.OperationLog) error {
o := orm.NewOrm()
log.CreateTime = time.Now()
_, err := o.Insert(log)
if err != nil {
return fmt.Errorf("添加操作日志失败: %v", err)
}
return nil
}
// AddAccessLog 添加访问日志(用于记录 GET / READ 行为)
func AddAccessLog(log *models.AccessLog) error {
o := orm.NewOrm()
log.CreateTime = time.Now()
_, err := o.Insert(log)
if err != nil {
return fmt.Errorf("添加访问日志失败: %v", err)
}
return nil
}
// GetOperationLogs 获取操作日志列表
func GetOperationLogs(tenantId int, userId int, module string, operation string, startTime *time.Time, endTime *time.Time, pageNum int, pageSize int) ([]*models.OperationLog, int64, error) {
o := orm.NewOrm()
qs := o.QueryTable("sys_operation_log").Filter("tenant_id", tenantId)
// 用户过滤
if userId > 0 {
qs = qs.Filter("user_id", userId)
}
// 模块过滤
if module != "" {
qs = qs.Filter("module", module)
}
// 操作类型过滤
if operation != "" {
qs = qs.Filter("operation", operation)
}
// 时间范围过滤
if startTime != nil {
qs = qs.Filter("create_time__gte", startTime)
}
if endTime != nil {
qs = qs.Filter("create_time__lte", endTime)
}
// 获取总数
total, err := qs.Count()
if err != nil {
return nil, 0, fmt.Errorf("查询日志总数失败: %v", err)
}
// 分页查询
var logs []*models.OperationLog
offset := (pageNum - 1) * pageSize
_, err = qs.OrderBy("-create_time").Offset(offset).Limit(pageSize).All(&logs)
if err != nil {
return nil, 0, fmt.Errorf("查询操作日志失败: %v", err)
}
return logs, total, nil
}
// GetOperationLogById 根据ID获取操作日志
func GetOperationLogById(id int64) (*models.OperationLog, error) {
o := orm.NewOrm()
log := &models.OperationLog{Id: id}
err := o.Read(log)
if err != nil {
return nil, fmt.Errorf("日志不存在: %v", err)
}
return log, nil
}
// GetUserOperationStats 获取用户操作统计
func GetUserOperationStats(tenantId int, userId int, days int) (map[string]interface{}, error) {
o := orm.NewOrm()
startTime := time.Now().AddDate(0, 0, -days)
// 获取总操作数
var totalCount int64
err := o.Raw(
"SELECT COUNT(*) FROM sys_operation_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ?",
tenantId, userId, startTime,
).QueryRow(&totalCount)
if err != nil {
return nil, err
}
// 获取按模块分组的操作数
type ModuleCount struct {
Module string
Count int
}
var moduleCounts []ModuleCount
_, err = o.Raw(
"SELECT module, COUNT(*) as count FROM sys_operation_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ? GROUP BY module",
tenantId, userId, startTime,
).QueryRows(&moduleCounts)
if err != nil {
return nil, err
}
// 构建结果
stats := map[string]interface{}{
"total_operations": totalCount,
"module_stats": moduleCounts,
"period_days": days,
"from_time": startTime,
"to_time": time.Now(),
}
return stats, nil
}
// GetTenantOperationStats 获取租户操作统计
func GetTenantOperationStats(tenantId int, days int) (map[string]interface{}, error) {
o := orm.NewOrm()
startTime := time.Now().AddDate(0, 0, -days)
// 获取总操作数
var totalCount int64
err := o.Raw(
"SELECT COUNT(*) FROM sys_operation_log WHERE tenant_id = ? AND create_time >= ?",
tenantId, startTime,
).QueryRow(&totalCount)
if err != nil {
return nil, err
}
// 获取按用户分组的操作数Top 10
type UserCount struct {
UserId int
Username string
Count int
}
var userCounts []UserCount
_, err = o.Raw(
"SELECT user_id, username, COUNT(*) as count FROM sys_operation_log WHERE tenant_id = ? AND create_time >= ? GROUP BY user_id, username ORDER BY count DESC LIMIT 10",
tenantId, startTime,
).QueryRows(&userCounts)
if err != nil {
return nil, err
}
// 获取按操作类型分组的操作数
type OperationCount struct {
Operation string
Count int
}
var operationCounts []OperationCount
_, err = o.Raw(
"SELECT operation, COUNT(*) as count FROM sys_operation_log WHERE tenant_id = ? AND create_time >= ? GROUP BY operation",
tenantId, startTime,
).QueryRows(&operationCounts)
if err != nil {
return nil, err
}
// 构建结果
stats := map[string]interface{}{
"total_operations": totalCount,
"top_users": userCounts,
"operation_breakdown": operationCounts,
"period_days": days,
"from_time": startTime,
"to_time": time.Now(),
}
return stats, nil
}
// DeleteOldLogs 删除旧日志(保留指定天数)
func DeleteOldLogs(keepDays int) (int64, error) {
o := orm.NewOrm()
cutoffTime := time.Now().AddDate(0, 0, -keepDays)
result, err := o.Raw("DELETE FROM sys_operation_log WHERE create_time < ?", cutoffTime).Exec()
if err != nil {
return 0, fmt.Errorf("删除旧日志失败: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("获取受影响行数失败: %v", err)
}
return rowsAffected, nil
}

View File

@ -0,0 +1,32 @@
-- 通用操作日志表(记录所有用户在所有模块的操作)
CREATE TABLE `sys_operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`tenant_id` int(11) NOT NULL DEFAULT 0 COMMENT '租户ID0表示平台操作>0表示租户操作',
`user_id` int(11) NOT NULL COMMENT '操作用户ID',
`username` varchar(50) NOT NULL COMMENT '操作用户名',
`module` varchar(100) NOT NULL COMMENT '操作模块user/tenant/dict/role等',
`resource_type` varchar(50) NOT NULL COMMENT '资源类型如User/Tenant/Dict等',
`resource_id` int(11) NULL COMMENT '资源ID如被操作的用户ID、租户ID等',
`operation` varchar(20) NOT NULL COMMENT '操作类型CREATE/UPDATE/DELETE/LOGIN/LOGOUT/VIEW等',
`description` varchar(500) NULL COMMENT '操作描述',
`old_value` longtext NULL COMMENT '修改前的值JSON格式用于UPDATE操作',
`new_value` longtext NULL COMMENT '修改后的值JSON格式用于UPDATE操作',
`ip_address` varchar(50) NULL COMMENT 'IP地址',
`user_agent` varchar(500) NULL COMMENT '用户代理信息',
`request_method` varchar(10) NULL COMMENT '请求方法GET/POST/PUT/DELETE等',
`request_url` varchar(500) NULL COMMENT '请求URL',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1-成功0-失败',
`error_message` text NULL COMMENT '错误信息',
`duration` int(11) NULL COMMENT '执行时长(毫秒)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_module` (`module`),
KEY `idx_resource_type` (`resource_type`),
KEY `idx_resource_id` (`resource_id`),
KEY `idx_operation` (`operation`),
KEY `idx_create_time` (`create_time`),
KEY `idx_tenant_user_time` (`tenant_id`, `user_id`, `create_time`),
KEY `idx_tenant_module_time` (`tenant_id`, `module`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统操作日志表';

BIN
server/yunzer_server Normal file

Binary file not shown.