增加登录日志功能
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/**
|
||||
* 获取登录日志列表
|
||||
* @param {Object} params 查询参数
|
||||
* @param {number} params.page 页码
|
||||
* @param {number} params.pageSize 每页数量
|
||||
* @param {string} params.keyword 关键词搜索(账号/姓名/租户/IP)
|
||||
* @param {string} params.status 状态筛选
|
||||
* @param {string} params.loginType 登录方式筛选
|
||||
* @param {string} params.startTime 开始时间
|
||||
* @param {string} params.endTime 结束时间
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getLoginLogs(params) {
|
||||
return request({
|
||||
url: "/backend/loginLogs",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录日志详情
|
||||
* @param {number|string} id 日志ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function getLoginLogDetail(id) {
|
||||
return request({
|
||||
url: `/backend/loginLogs/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除登录日志
|
||||
* @param {number|string} id 日志ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function deleteLoginLog(id) {
|
||||
return request({
|
||||
url: `/backend/loginLogs/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除登录日志
|
||||
* @param {Array} ids 日志ID数组
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function batchDeleteLoginLogs(ids) {
|
||||
return request({
|
||||
url: "/backend/loginLogs/batchDelete",
|
||||
method: "post",
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="登录日志详情" size="50%">
|
||||
<div class="log-detail" v-if="log">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="日志ID" label-width="150px">
|
||||
{{ log.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录状态" label-width="150px">
|
||||
<el-tag :type="log.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ log.status === 1 ? "成功" : "失败" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID" label-width="150px">
|
||||
{{ log.user_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户姓名" label-width="150px">
|
||||
{{ log.user_name || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录账号" label-width="150px">
|
||||
{{ log.account }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="租户名称" label-width="150px">
|
||||
{{ log.tenant_name || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录方式" label-width="150px">
|
||||
<el-tag type="primary" size="small">
|
||||
{{ getLoginTypeLabel(log.login_type) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="登录时间" label-width="150px">
|
||||
{{ log.create_time }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址" label-width="150px">
|
||||
{{ log.ip || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结果信息" label-width="150px">
|
||||
{{ log.message || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户代理" :span="2" label-width="150px">
|
||||
<div class="user-agent-text">{{ log.user_agent || "无" }}</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<div v-else class="loading-container">
|
||||
<el-empty description="加载中..." />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { getLoginLogDetail } from "@/api/loginLog";
|
||||
|
||||
interface LoginLog {
|
||||
id: number;
|
||||
tid: number | null;
|
||||
user_id: number;
|
||||
account: string;
|
||||
user_name: string;
|
||||
tenant_name: string;
|
||||
login_type: string;
|
||||
status: number;
|
||||
message: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
logId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const log = ref<LoginLog | null>(null);
|
||||
|
||||
// 登录方式标签
|
||||
const getLoginTypeLabel = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
password: "密码登录",
|
||||
sms: "短信验证码",
|
||||
email: "邮箱验证码",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 获取日志详情
|
||||
const fetchLogDetail = async (logId?: number) => {
|
||||
const targetId = logId || props.logId;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.value = null; // 清空之前的数据
|
||||
try {
|
||||
const res = await getLoginLogDetail(targetId);
|
||||
if (res.code === 200) {
|
||||
log.value = res.data;
|
||||
} else {
|
||||
console.error("获取登录日志详情失败:", res?.msg || "未知错误");
|
||||
log.value = null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("获取登录日志详情失败", error);
|
||||
log.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal && props.logId) {
|
||||
fetchLogDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit("update:modelValue", false);
|
||||
log.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
open: (logId: number) => {
|
||||
if (logId) {
|
||||
fetchLogDetail(logId);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.log-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-agent-text {
|
||||
word-break: break-all;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>登录日志</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filterForm.keyword"
|
||||
placeholder="搜索账号、姓名、租户、IP"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filterForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="成功" value="1" />
|
||||
<el-option label="失败" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录方式">
|
||||
<el-select
|
||||
v-model="filterForm.loginType"
|
||||
placeholder="请选择登录方式"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="密码登录" value="password" />
|
||||
<el-option label="短信验证码" value="sms" />
|
||||
<el-option label="邮箱验证码" value="email" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 350px"
|
||||
@change="handleDateRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 登录日志列表 -->
|
||||
<el-table
|
||||
:data="logs"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="id" label="ID" width="60" align="center" />
|
||||
<el-table-column
|
||||
prop="user_name"
|
||||
label="用户姓名"
|
||||
width="110"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="account"
|
||||
label="登录账号"
|
||||
width="130"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="tenant_name"
|
||||
label="租户名称"
|
||||
width="130"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="login_type" label="登录方式" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag type="primary" size="small">
|
||||
{{ getLoginTypeLabel(scope.row.login_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="scope.row.status === 1 ? 'success' : 'danger'"
|
||||
size="small"
|
||||
>
|
||||
{{ scope.row.status === 1 ? "成功" : "失败" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="message"
|
||||
label="结果信息"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="ip" label="IP地址" width="130" align="center" />
|
||||
<el-table-column
|
||||
prop="create_time"
|
||||
label="登录时间"
|
||||
width="180"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleViewDetail(scope.row)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 批量删除 -->
|
||||
<div class="batch-bar" v-if="selectedLogs.length > 0">
|
||||
<el-button type="danger" plain @click="handleBatchDelete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除 ({{ selectedLogs.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<DetailDialog
|
||||
ref="detailDialogRef"
|
||||
v-model="detailDialogVisible"
|
||||
:log-id="currentLogId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Refresh, Delete, Search } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getLoginLogs,
|
||||
deleteLoginLog,
|
||||
batchDeleteLoginLogs,
|
||||
} from "@/api/loginLog";
|
||||
import DetailDialog from "./components/detail.vue";
|
||||
|
||||
interface LoginLog {
|
||||
id: number;
|
||||
tid: number | null;
|
||||
user_id: number;
|
||||
account: string;
|
||||
user_name: string;
|
||||
tenant_name: string;
|
||||
login_type: string;
|
||||
status: number;
|
||||
message: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const logs = ref<LoginLog[]>([]);
|
||||
const selectedLogs = ref<LoginLog[]>([]);
|
||||
|
||||
// 筛选表单
|
||||
const filterForm = ref({
|
||||
keyword: "",
|
||||
status: "",
|
||||
loginType: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
});
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null);
|
||||
|
||||
// 详情对话框
|
||||
const detailDialogVisible = ref(false);
|
||||
const detailDialogRef = ref<any>(null);
|
||||
const currentLogId = ref<number | undefined>(undefined);
|
||||
|
||||
// 登录方式标签
|
||||
const getLoginTypeLabel = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
password: "密码登录",
|
||||
sms: "短信验证码",
|
||||
email: "邮箱验证码",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 获取登录日志列表
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
|
||||
if (filterForm.value.keyword) {
|
||||
params.keyword = filterForm.value.keyword;
|
||||
}
|
||||
if (filterForm.value.status !== "") {
|
||||
params.status = filterForm.value.status;
|
||||
}
|
||||
if (filterForm.value.loginType) {
|
||||
params.loginType = filterForm.value.loginType;
|
||||
}
|
||||
if (filterForm.value.startTime) {
|
||||
params.startTime = filterForm.value.startTime;
|
||||
}
|
||||
if (filterForm.value.endTime) {
|
||||
params.endTime = filterForm.value.endTime;
|
||||
}
|
||||
|
||||
const res = await getLoginLogs(params);
|
||||
if (res.code === 200) {
|
||||
logs.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
ElMessage.error(res.msg || "获取登录日志失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "获取登录日志失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
filterForm.value = {
|
||||
keyword: "",
|
||||
status: "",
|
||||
loginType: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
};
|
||||
dateRange.value = null;
|
||||
page.value = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 时间范围变化
|
||||
const handleDateRangeChange = (val: [string, string] | null) => {
|
||||
if (val && val.length === 2) {
|
||||
filterForm.value.startTime = val[0];
|
||||
filterForm.value.endTime = val[1];
|
||||
} else {
|
||||
filterForm.value.startTime = "";
|
||||
filterForm.value.endTime = "";
|
||||
}
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const handlePageChange = (val: number) => {
|
||||
page.value = val;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 每页数量改变
|
||||
const handleSizeChange = (val: number) => {
|
||||
pageSize.value = val;
|
||||
page.value = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
// 选择改变
|
||||
const handleSelectionChange = (selection: LoginLog[]) => {
|
||||
selectedLogs.value = selection;
|
||||
};
|
||||
|
||||
// 删除单条
|
||||
const handleDelete = (log: LoginLog) => {
|
||||
ElMessageBox.confirm("确定删除该条登录日志吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteLoginLog(log.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchLogs();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "删除失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "删除失败");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
const ids = selectedLogs.value.map((item) => item.id);
|
||||
ElMessageBox.confirm(
|
||||
`确定删除选中的 ${ids.length} 条登录日志吗?`,
|
||||
"提示",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await batchDeleteLoginLogs(ids);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("批量删除成功");
|
||||
fetchLogs();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "批量删除失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "批量删除失败");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (log: LoginLog) => {
|
||||
currentLogId.value = log.id;
|
||||
detailDialogVisible.value = true;
|
||||
nextTick(() => {
|
||||
if (detailDialogRef.value && detailDialogRef.value.open) {
|
||||
detailDialogRef.value.open(log.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
.filter-form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-bar {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,13 @@ func (c *BackendAuthController) serveJSON(data map[string]interface{}) {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// recordLoginLog 记录登录日志(成功/失败均记录,失败不影响登录流程)
|
||||
func (c *BackendAuthController) recordLoginLog(tid *uint64, userID uint64, account, userName, tenantName, loginType string, status int8, message string) {
|
||||
ip := c.Ctx.Input.IP()
|
||||
userAgent := c.Ctx.Request.UserAgent()
|
||||
RecordLoginLog(tid, userID, account, userName, tenantName, loginType, status, message, ip, userAgent)
|
||||
}
|
||||
|
||||
// LoginBackend backend 登录(需要租户)
|
||||
func (c *BackendAuthController) LoginBackend() {
|
||||
var req backendAuthLoginRequest
|
||||
@@ -93,10 +100,14 @@ func (c *BackendAuthController) LoginBackend() {
|
||||
|
||||
token, loginUser, err := services.BackendLogin(req.TenantName, req.Account, req.Password)
|
||||
if err != nil {
|
||||
c.recordLoginLog(nil, 0, req.Account, "", req.TenantName, "password", 0, err.Error())
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tid := loginUser.Tid
|
||||
c.recordLoginLog(&tid, loginUser.ID, loginUser.Account, loginUser.Name, req.TenantName, "password", 1, "登录成功")
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "登录成功",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// BackendLoginLogController 登录日志(yz_system_login_log)
|
||||
type BackendLoginLogController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendLoginLogController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendLoginLogController) backendClaims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("认证信息格式错误")
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的token")
|
||||
}
|
||||
if claims.UserType != "backend" {
|
||||
return nil, fmt.Errorf("无权访问")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// RecordLoginLog 写入一条登录日志(登录成功/失败均记录,失败不影响登录流程)。
|
||||
func RecordLoginLog(tid *uint64, userID uint64, account, userName, tenantName, loginType string, status int8, message string, ip, userAgent string) {
|
||||
if len(userAgent) > 500 {
|
||||
userAgent = userAgent[:500]
|
||||
}
|
||||
if len(message) > 255 {
|
||||
message = message[:255]
|
||||
}
|
||||
row := &models.SystemLoginLog{
|
||||
Tid: tid,
|
||||
UserID: userID,
|
||||
Account: account,
|
||||
UserName: userName,
|
||||
TenantName: tenantName,
|
||||
LoginType: loginType,
|
||||
Status: status,
|
||||
Message: message,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
fmt.Println("record login log error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List GET /backend/loginLogs?page=1&pageSize=20&keyword=&status=&loginType=&startTime=&endTime=
|
||||
func (c *BackendLoginLogController) List() {
|
||||
if _, err := c.backendClaims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||
loginType := strings.TrimSpace(c.GetString("loginType"))
|
||||
startTimeStr := strings.TrimSpace(c.GetString("startTime"))
|
||||
endTimeStr := strings.TrimSpace(c.GetString("endTime"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemLoginLog)).Filter("delete_time__isnull", true)
|
||||
|
||||
cond := orm.NewCondition()
|
||||
needCond := false
|
||||
|
||||
if statusStr != "" {
|
||||
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||
cond = cond.And("status", st)
|
||||
needCond = true
|
||||
}
|
||||
}
|
||||
if loginType != "" {
|
||||
cond = cond.And("login_type", loginType)
|
||||
needCond = true
|
||||
}
|
||||
if keyword != "" {
|
||||
kw := orm.NewCondition().
|
||||
Or("account__icontains", keyword).
|
||||
Or("user_name__icontains", keyword).
|
||||
Or("tenant_name__icontains", keyword).
|
||||
Or("ip__icontains", keyword)
|
||||
if uid, err := strconv.ParseUint(keyword, 10, 64); err == nil && uid > 0 {
|
||||
kw = kw.Or("user_id", uid)
|
||||
}
|
||||
cond = cond.AndCond(kw)
|
||||
needCond = true
|
||||
}
|
||||
if t, err := parseTimeFlexible(startTimeStr); err == nil && !t.IsZero() {
|
||||
cond = cond.And("create_time__gte", t)
|
||||
needCond = true
|
||||
}
|
||||
if t, err := parseTimeFlexible(endTimeStr); err == nil && !t.IsZero() {
|
||||
cond = cond.And("create_time__lte", t)
|
||||
needCond = true
|
||||
}
|
||||
|
||||
if needCond {
|
||||
qs = qs.SetCond(cond)
|
||||
}
|
||||
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取登录日志失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var rows []models.SystemLoginLog
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取登录日志失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for i := range rows {
|
||||
item := map[string]interface{}{
|
||||
"id": rows[i].ID,
|
||||
"tid": rows[i].Tid,
|
||||
"user_id": rows[i].UserID,
|
||||
"account": rows[i].Account,
|
||||
"user_name": rows[i].UserName,
|
||||
"tenant_name": rows[i].TenantName,
|
||||
"login_type": rows[i].LoginType,
|
||||
"status": rows[i].Status,
|
||||
"message": rows[i].Message,
|
||||
"ip": rows[i].IP,
|
||||
"user_agent": rows[i].UserAgent,
|
||||
"create_time": rows[i].CreateTime.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Detail GET /backend/loginLogs/:id
|
||||
func (c *BackendLoginLogController) Detail() {
|
||||
if _, err := c.backendClaims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
var row models.SystemLoginLog
|
||||
err = models.Orm.QueryTable(new(models.SystemLoginLog)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, "记录不存在")
|
||||
return
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"tid": row.Tid,
|
||||
"user_id": row.UserID,
|
||||
"account": row.Account,
|
||||
"user_name": row.UserName,
|
||||
"tenant_name": row.TenantName,
|
||||
"login_type": row.LoginType,
|
||||
"status": row.Status,
|
||||
"message": row.Message,
|
||||
"ip": row.IP,
|
||||
"user_agent": row.UserAgent,
|
||||
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/loginLogs/:id
|
||||
func (c *BackendLoginLogController) Delete() {
|
||||
if _, err := c.backendClaims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
idStr := c.Ctx.Input.Param(":id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.jsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
n, err := models.Orm.QueryTable(new(models.SystemLoginLog)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{"delete_time": now})
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.jsonErr(404, 404, "记录不存在")
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
type backendLoginLogBatchDeletePayload struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchDelete POST /backend/loginLogs/batchDelete
|
||||
func (c *BackendLoginLogController) BatchDelete() {
|
||||
if _, err := c.backendClaims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var p backendLoginLogBatchDeletePayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if len(p.IDs) == 0 {
|
||||
c.jsonErr(400, 400, "请选择要删除的日志")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.SystemLoginLog)).
|
||||
Filter("id__in", p.IDs).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{"delete_time": now})
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "批量删除失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func Init(_ string) {
|
||||
new(SystemFilesCategory),
|
||||
new(SystemSMSTask),
|
||||
new(SystemOperationLog),
|
||||
new(SystemLoginLog),
|
||||
new(SystemDomainPool),
|
||||
new(SystemTenantDomain),
|
||||
new(SystemModules),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemLoginLog 登录日志表 yz_system_login_log
|
||||
type SystemLoginLog struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid *uint64 `orm:"column(tid);null" json:"tid"`
|
||||
UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"`
|
||||
Account string `orm:"column(account);size(64);default('')" json:"account"`
|
||||
UserName string `orm:"column(user_name);size(64);default('')" json:"user_name"`
|
||||
TenantName string `orm:"column(tenant_name);size(64);default('')" json:"tenant_name"`
|
||||
LoginType string `orm:"column(login_type);size(20);default('password')" json:"login_type"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||
Message string `orm:"column(message);size(255);default('')" json:"message"`
|
||||
IP string `orm:"column(ip);size(50);default('')" json:"ip"`
|
||||
UserAgent string `orm:"column(user_agent);size(500);default('')" json:"user_agent"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *SystemLoginLog) TableName() string {
|
||||
return "yz_system_login_log"
|
||||
}
|
||||
@@ -41,6 +41,11 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/operationLogs/:id", &controllers.BackendOperationLogController{}, "get:Detail;delete:Delete")
|
||||
beego.Router("/backend/operationLogs/batchDelete", &controllers.BackendOperationLogController{}, "post:BatchDelete")
|
||||
|
||||
// 登录日志(yz_system_login_log)
|
||||
beego.Router("/backend/loginLogs", &controllers.BackendLoginLogController{}, "get:List")
|
||||
beego.Router("/backend/loginLogs/:id", &controllers.BackendLoginLogController{}, "get:Detail;delete:Delete")
|
||||
beego.Router("/backend/loginLogs/batchDelete", &controllers.BackendLoginLogController{}, "post:BatchDelete")
|
||||
|
||||
// 租户站点设置
|
||||
beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos")
|
||||
beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 登录日志表
|
||||
CREATE TABLE IF NOT EXISTS `yz_system_login_log` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`tid` bigint(20) unsigned DEFAULT NULL COMMENT '租户ID',
|
||||
`user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
|
||||
`account` varchar(64) NOT NULL DEFAULT '' COMMENT '登录账号',
|
||||
`user_name` varchar(64) NOT NULL DEFAULT '' COMMENT '用户姓名',
|
||||
`tenant_name` varchar(64) NOT NULL DEFAULT '' COMMENT '租户名称',
|
||||
`login_type` varchar(20) NOT NULL DEFAULT 'password' COMMENT '登录方式: password-密码, sms-短信验证码, email-邮箱验证码',
|
||||
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '登录状态: 0-失败, 1-成功',
|
||||
`message` varchar(255) NOT NULL DEFAULT '' COMMENT '结果信息/失败原因',
|
||||
`ip` varchar(50) NOT NULL DEFAULT '' COMMENT '登录IP',
|
||||
`user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '浏览器User-Agent',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '登录时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid` (`tid`),
|
||||
KEY `idx_account` (`account`),
|
||||
KEY `idx_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统登录日志表';
|
||||
Reference in New Issue
Block a user