操作日志和访问日志
This commit is contained in:
parent
efc079c0e5
commit
12a0ff8afc
36
pc/src/api/accessLog.js
Normal file
36
pc/src/api/accessLog.js
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
45
pc/src/api/operationLog.js
Normal file
45
pc/src/api/operationLog.js
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
367
pc/src/views/system/accessLog/index.vue
Normal file
367
pc/src/views/system/accessLog/index.vue
Normal 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>
|
||||||
@ -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>
|
||||||
111
pc/src/views/system/operationLog/components/StatisticsPanel.vue
Normal file
111
pc/src/views/system/operationLog/components/StatisticsPanel.vue
Normal 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>
|
||||||
349
pc/src/views/system/operationLog/index.vue
Normal file
349
pc/src/views/system/operationLog/index.vue
Normal 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>
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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 根据字典编码获取字典项(用于业务查询)
|
||||||
|
|||||||
247
server/controllers/operation_log.go
Normal file
247
server/controllers/operation_log.go
Normal 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()
|
||||||
|
}
|
||||||
209
server/middleware/operationLog.go
Normal file
209
server/middleware/operationLog.go
Normal 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"
|
||||||
|
}
|
||||||
27
server/models/access_log.go
Normal file
27
server/models/access_log.go
Normal 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"
|
||||||
|
}
|
||||||
32
server/models/audit_log.go
Normal file
32
server/models/audit_log.go
Normal 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"
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
369
server/services/operation_log.go
Normal file
369
server/services/operation_log.go
Normal 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
|
||||||
|
}
|
||||||
32
server/sql/create_audit_log_tables.sql
Normal file
32
server/sql/create_audit_log_tables.sql
Normal 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 '租户ID(0表示平台操作,>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
BIN
server/yunzer_server
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user