操作日志和访问日志

This commit is contained in:
2025-11-11 22:32:46 +08:00
parent efc079c0e5
commit 12a0ff8afc
17 changed files with 2300 additions and 4 deletions
+56
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"server/models"
"server/services"
"strconv"
"strings"
"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()
}
// 记录登录操作
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{}{
"code": 0,
"message": "登录成功",
@@ -205,6 +224,43 @@ func (c *AuthController) Login() {
// 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的清除
c.Data["json"] = map[string]interface{}{
"success": true,
+219 -1
View File
@@ -5,6 +5,7 @@ import (
"server/models"
"server/services"
"strconv"
"time"
"github.com/beego/beego/v2/server/web"
)
@@ -160,6 +161,32 @@ func (c *DictController) AddDictType() {
"data": map[string]interface{}{"id": id},
}
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 更新字典类型
@@ -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.UpdateBy = username
@@ -212,6 +248,31 @@ func (c *DictController) UpdateDictType() {
"message": "更新成功",
}
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 删除字典类型
@@ -227,7 +288,7 @@ func (c *DictController) DeleteDictType() {
return
}
// 获取租户ID
// 获取租户ID、用户名和用户ID 用于记录日志
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
@@ -235,6 +296,20 @@ func (c *DictController) DeleteDictType() {
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)
if err != nil {
@@ -251,6 +326,29 @@ func (c *DictController) DeleteDictType() {
"message": "删除成功",
}
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 获取字典项列表
@@ -380,6 +478,46 @@ func (c *DictController) AddDictItem() {
"data": map[string]interface{}{"id": id},
}
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 更新字典项
@@ -432,6 +570,41 @@ func (c *DictController) UpdateDictItem() {
"message": "更新成功",
}
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 删除字典项
@@ -462,6 +635,51 @@ func (c *DictController) DeleteDictItem() {
"message": "删除成功",
}
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 根据字典编码获取字典项(用于业务查询)
+247
View File
@@ -0,0 +1,247 @@
package controllers
import (
"server/models"
"server/services"
"strconv"
"time"
"github.com/beego/beego/v2/server/web"
)
// OperationLogController 操作日志控制器
type OperationLogController struct {
web.Controller
}
// GetOperationLogs 获取操作日志列表
func (c *OperationLogController) GetOperationLogs() {
// 获取租户ID和用户ID
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
// 获取查询参数
pageNum, _ := c.GetInt("page_num", 1)
pageSize, _ := c.GetInt("page_size", 20)
module := c.GetString("module")
operation := c.GetString("operation")
filterUserId, _ := c.GetInt("user_id", 0)
startTimeStr := c.GetString("start_time")
endTimeStr := c.GetString("end_time")
var startTime, endTime *time.Time
if startTimeStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", startTimeStr); err == nil {
startTime = &t
}
}
if endTimeStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", endTimeStr); err == nil {
endTime = &t
}
}
// 如果指定了用户ID筛选,使用该ID,否则使用当前用户ID
queryUserId := filterUserId
if queryUserId <= 0 {
queryUserId = userId
}
logs, total, err := services.GetOperationLogs(tenantId, queryUserId, module, operation, startTime, endTime, pageNum, pageSize)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "查询日志失败: " + err.Error(),
}
c.ServeJSON()
return
}
// 为每条日志批量解析模块名称(减少对菜单表的重复查询)
type LogWithModuleName struct {
*models.OperationLog
ModuleName string `json:"module_name"`
}
// 收集唯一 module 列表
moduleSet := make(map[string]struct{})
modules := make([]string, 0)
for _, l := range logs {
m := l.Module
if m == "" {
continue
}
if _, ok := moduleSet[m]; !ok {
moduleSet[m] = struct{}{}
modules = append(modules, m)
}
}
moduleNamesMap := map[string]string{}
if len(modules) > 0 {
if mm, err := services.GetModuleNames(modules); err == nil {
moduleNamesMap = mm
}
}
logsWithName := make([]*LogWithModuleName, len(logs))
for i, log := range logs {
name := ""
if v, ok := moduleNamesMap[log.Module]; ok {
name = v
} else {
// fallback 单个解析(保留老逻辑)
name = services.GetModuleName(log.Module)
}
logsWithName[i] = &LogWithModuleName{
OperationLog: log,
ModuleName: name,
}
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": logsWithName,
"total": total,
"page": pageNum,
"page_size": pageSize,
}
c.ServeJSON()
}
// GetOperationLogById 根据ID获取操作日志
func (c *OperationLogController) GetOperationLogById() {
idStr := c.Ctx.Input.Param(":id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "参数错误",
}
c.ServeJSON()
return
}
log, err := services.GetOperationLogById(id)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "日志不存在",
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": log,
}
c.ServeJSON()
}
// GetUserStats 获取用户操作统计
func (c *OperationLogController) GetUserStats() {
userIdData := c.Ctx.Input.GetData("userId")
userId := 0
if userIdData != nil {
if uid, ok := userIdData.(int); ok {
userId = uid
}
}
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
days, _ := c.GetInt("days", 7)
stats, err := services.GetUserOperationStats(tenantId, userId, days)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "查询统计失败: " + err.Error(),
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": stats,
}
c.ServeJSON()
}
// GetTenantStats 获取租户操作统计
func (c *OperationLogController) GetTenantStats() {
tenantIdData := c.Ctx.Input.GetData("tenantId")
tenantId := 0
if tenantIdData != nil {
if tid, ok := tenantIdData.(int); ok {
tenantId = tid
}
}
days, _ := c.GetInt("days", 7)
stats, err := services.GetTenantOperationStats(tenantId, days)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "查询统计失败: " + err.Error(),
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"data": stats,
}
c.ServeJSON()
}
// ClearOldLogs 清空旧日志
func (c *OperationLogController) ClearOldLogs() {
keepDays, _ := c.GetInt("keep_days", 90)
rowsAffected, err := services.DeleteOldLogs(keepDays)
if err != nil {
c.Data["json"] = map[string]interface{}{
"success": false,
"message": "删除日志失败: " + err.Error(),
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"success": true,
"message": "删除成功",
"data": map[string]interface{}{
"deleted_count": rowsAffected,
"keep_days": keepDays,
},
}
c.ServeJSON()
}
+209
View File
@@ -0,0 +1,209 @@
package middleware
import (
"fmt"
"io"
"server/models"
"server/services"
"strconv"
"strings"
"time"
"github.com/beego/beego/v2/server/web/context"
)
// OperationLogMiddleware 操作日志中间件 - 记录所有的CREATE、UPDATE、DELETE操作
func OperationLogMiddleware(ctx *context.Context) {
// 记录所有重要操作,包括修改类(POST/PUT/PATCH/DELETE)和读取类(GET)用于统计账户访问功能
method := ctx.Input.Method()
// 获取用户信息和租户信息(由 JWT 中间件设置在 Input.Data 中)
userId := 0
tenantId := 0
username := ""
if v := ctx.Input.GetData("userId"); v != nil {
if id, ok := v.(int); ok {
userId = id
}
}
if v := ctx.Input.GetData("tenantId"); v != nil {
if id, ok := v.(int); ok {
tenantId = id
}
}
if v := ctx.Input.GetData("username"); v != nil {
if s, ok := v.(string); ok {
username = s
}
}
// 如果无法获取用户ID,继续记录为匿名访问(userId=0),以便统计未登录或授权失败的访问
if userId == 0 {
// debug: 输出一些上下文信息,帮助定位为何未能获取 userId
fmt.Printf("OperationLogMiddleware: anonymous request %s %s, Authorization header length=%d\n", method, ctx.Input.URL(), len(ctx.Input.Header("Authorization")))
// 确保 username 有值,便于区分
if username == "" {
username = "anonymous"
}
}
// 读取请求体(对于有请求体的方法)
var requestBody string
if method == "POST" || method == "PUT" || method == "PATCH" {
body, err := io.ReadAll(ctx.Request.Body)
if err == nil {
requestBody = string(body)
// 重置请求体,使其可以被后续处理
ctx.Request.Body = io.NopCloser(strings.NewReader(requestBody))
}
}
startTime := time.Now()
// 使用延迟函数来记录操作
defer func() {
duration := time.Since(startTime)
// 解析操作类型
operation := parseOperationType(method, ctx.Input.URL())
resourceType := parseResourceType(ctx.Input.URL())
resourceId := parseResourceId(ctx.Input.URL())
module := parseModule(ctx.Input.URL())
// 如果是读取/访问行为,写入访问日志(sys_access_log),否则写入操作日志(sys_operation_log
if operation == "READ" {
access := &models.AccessLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: module,
ResourceType: resourceType,
ResourceId: &resourceId,
RequestUrl: ctx.Input.URL(),
IpAddress: ctx.Input.IP(),
UserAgent: ctx.Input.Header("User-Agent"),
RequestMethod: method,
Duration: int(duration.Milliseconds()),
}
// 在 QueryString 中记录查询参数和匿名标识
qs := ctx.Request.URL.RawQuery
if qs != "" {
access.QueryString = qs
}
if userId == 0 {
// 将匿名标识拼入 QueryString 以便查询(也可改为独立字段)
if access.QueryString != "" {
access.QueryString = "anonymous=true; " + access.QueryString
} else {
access.QueryString = "anonymous=true"
}
}
if err := services.AddAccessLog(access); err != nil {
fmt.Printf("Failed to save access log: %v\n", err)
}
return
}
// 创建操作日志
log := &models.OperationLog{
TenantId: tenantId,
UserId: userId,
Username: username,
Module: module,
ResourceType: resourceType,
ResourceId: &resourceId,
Operation: operation,
IpAddress: ctx.Input.IP(),
UserAgent: ctx.Input.Header("User-Agent"),
RequestMethod: method,
RequestUrl: ctx.Input.URL(),
Status: 1, // 默认成功,实际应该根据响应状态码更新
Duration: int(duration.Milliseconds()),
CreateTime: time.Now(),
}
// 如果是写操作,保存请求体作为新值;对于读取操作可以在Description里记录query
if requestBody != "" {
log.NewValue = requestBody
} else if method == "GET" {
// 把查询字符串放到描述里,便于分析访问参数
qs := ctx.Request.URL.RawQuery
if qs != "" {
log.Description = "query=" + qs
}
}
// 标记匿名访问信息(当 userId==0)
if userId == 0 {
if log.Description != "" {
log.Description = "anonymous=true; " + log.Description
} else {
log.Description = "anonymous=true"
}
}
// 调用服务层保存日志
if err := services.AddOperationLog(log); err != nil {
fmt.Printf("Failed to save operation log: %v\n", err)
}
}()
}
// parseOperationType 根据HTTP方法解析操作类型
func parseOperationType(method, url string) string {
switch method {
case "POST":
// 检查URL是否包含特定的操作关键字
if strings.Contains(url, "login") {
return "LOGIN"
}
if strings.Contains(url, "logout") {
return "LOGOUT"
}
if strings.Contains(url, "add") || strings.Contains(url, "create") {
return "CREATE"
}
return "CREATE"
case "PUT", "PATCH":
return "UPDATE"
case "DELETE":
return "DELETE"
default:
return "READ"
}
}
// parseResourceType 根据URL解析资源类型
func parseResourceType(url string) string {
parts := strings.Split(strings.TrimPrefix(url, "/api/"), "/")
if len(parts) > 0 {
// 移除复数形式的s
resourceType := strings.TrimSuffix(parts[0], "s")
return resourceType
}
return "unknown"
}
// parseResourceId 从URL中提取资源ID
func parseResourceId(url string) int {
parts := strings.Split(strings.TrimPrefix(url, "/api/"), "/")
if len(parts) >= 2 {
// 尝试解析第二个部分为ID
if id, err := strconv.Atoi(parts[1]); err == nil {
return id
}
}
return 0
}
// parseModule 根据URL解析模块名称
func parseModule(url string) string {
// 返回与 sys_operation_log.module 字段匹配的短code(例如 dict、user 等)
parts := strings.Split(strings.TrimPrefix(url, "/api/"), "/")
if len(parts) > 0 {
return strings.ToLower(parts[0])
}
return "unknown"
}
+27
View File
@@ -0,0 +1,27 @@
package models
import (
"time"
)
// AccessLog 访问日志(针对读取/浏览/访问行为)
type AccessLog struct {
Id int64 `orm:"auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
UserId int `orm:"column(user_id)" json:"user_id"`
Username string `orm:"column(username)" json:"username"`
Module string `orm:"column(module)" json:"module"`
ResourceType string `orm:"column(resource_type)" json:"resource_type"`
ResourceId *int `orm:"column(resource_id);null" json:"resource_id"`
RequestUrl string `orm:"column(request_url);null" json:"request_url"`
QueryString string `orm:"column(query_string);type(text);null" json:"query_string"`
IpAddress string `orm:"column(ip_address);null" json:"ip_address"`
UserAgent string `orm:"column(user_agent);null" json:"user_agent"`
RequestMethod string `orm:"column(request_method);null" json:"request_method"`
Duration int `orm:"column(duration);null" json:"duration"`
CreateTime time.Time `orm:"column(create_time);auto_now_add" json:"create_time"`
}
func (a *AccessLog) TableName() string {
return "sys_access_log"
}
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"time"
)
// OperationLog 通用操作日志
type OperationLog struct {
Id int64 `orm:"auto" json:"id"`
TenantId int `orm:"column(tenant_id)" json:"tenant_id"`
UserId int `orm:"column(user_id)" json:"user_id"`
Username string `orm:"column(username)" json:"username"`
Module string `orm:"column(module)" json:"module"`
ResourceType string `orm:"column(resource_type)" json:"resource_type"`
ResourceId *int `orm:"column(resource_id);null" json:"resource_id"`
Operation string `orm:"column(operation)" json:"operation"`
Description string `orm:"column(description);null" json:"description"`
OldValue string `orm:"column(old_value);type(longtext);null" json:"old_value"`
NewValue string `orm:"column(new_value);type(longtext);null" json:"new_value"`
IpAddress string `orm:"column(ip_address);null" json:"ip_address"`
UserAgent string `orm:"column(user_agent);null" json:"user_agent"`
RequestMethod string `orm:"column(request_method);null" json:"request_method"`
RequestUrl string `orm:"column(request_url);null" json:"request_url"`
Status int8 `orm:"column(status)" json:"status"`
ErrorMessage string `orm:"column(error_message);type(text);null" json:"error_message"`
Duration int `orm:"column(duration);null" json:"duration"`
CreateTime time.Time `orm:"column(create_time);auto_now_add" json:"create_time"`
}
func (o *OperationLog) TableName() string {
return "sys_operation_log"
}
+4 -3
View File
@@ -46,6 +46,7 @@ func Init(version string) {
orm.RegisterModel(new(KnowledgeTag))
orm.RegisterModel(new(DictType))
orm.RegisterModel(new(DictItem))
orm.RegisterModel(new(OperationLog))
ormConfig, err := beego.AppConfig.String("orm")
if err != nil {
@@ -79,9 +80,9 @@ func Init(version string) {
}
// 设置连接池参数
dbConn.SetMaxIdleConns(10) // 设置空闲连接池中连接的最大数量
dbConn.SetMaxOpenConns(100) // 设置打开数据库连接的最大数量
dbConn.SetConnMaxLifetime(30 * time.Minute) // 设置连接可复用的最大时间(30分钟,避免连接过期)
dbConn.SetMaxIdleConns(10) // 设置空闲连接池中连接的最大数量
dbConn.SetMaxOpenConns(100) // 设置打开数据库连接的最大数量
dbConn.SetConnMaxLifetime(30 * time.Minute) // 设置连接可复用的最大时间(30分钟,避免连接过期)
// 注意:SetConnMaxIdleTime 在 Go 1.15+ 和 database/sql 1.4+ 中可用
// 如果版本不支持,可以移除这一行
// dbConn.SetConnMaxIdleTime(10 * time.Minute) // 设置空闲连接的最大空闲时间
+16
View File
@@ -207,6 +207,15 @@ func init() {
}
})
// 在请求完成后记录操作日志(包括访问/读取行为)
beego.InsertFilter("/api/*", beego.FinishRouter, func(ctx *context.Context) {
// 避免记录操作日志接口自身以免循环
if strings.HasPrefix(ctx.Input.URL(), "/api/operation-logs") {
return
}
middleware.OperationLogMiddleware(ctx)
})
// 添加主页路由
beego.Router("/", &controllers.MainController{})
@@ -329,4 +338,11 @@ func init() {
beego.Router("/api/program-infos/public", &controllers.ProgramInfoController{}, "get:GetProgramInfosPublic")
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
View File
@@ -0,0 +1,369 @@
package services
import (
"fmt"
"server/models"
"strings"
"time"
"github.com/beego/beego/v2/client/orm"
)
// GetModuleName 根据module代码获取模块中文名称
// 首先从菜单表查询,如果找不到则使用默认映射
func GetModuleName(module string) string {
if module == "" {
return ""
}
// 从菜单表中查询:优先匹配 permission,其次尝试匹配 path 的最后一段(如 /system/dict -> dict
o := orm.NewOrm()
var menuName string
// 先尝试通过 permission 精准匹配
err := o.Raw(
"SELECT name FROM yz_menus WHERE permission = ? AND delete_time IS NULL LIMIT 1",
module,
).QueryRow(&menuName)
if err == nil && menuName != "" {
return menuName
}
// 再尝试通过 path 末段匹配(例如 /system/dict -> dict)或 path 包含该段
err = o.Raw(
"SELECT name FROM yz_menus WHERE (path LIKE CONCAT('%/', ?, '/%') OR path LIKE CONCAT('%/', ?)) AND delete_time IS NULL LIMIT 1",
module, module,
).QueryRow(&menuName)
if err == nil && menuName != "" {
return menuName
}
// 如果菜单表中找不到,使用默认映射
defaultMap := map[string]string{
"auth": "认证",
"dict": "字典管理",
"user": "用户管理",
"role": "角色管理",
"permission": "权限管理",
"department": "部门管理",
"employee": "员工管理",
"position": "职位管理",
"tenant": "租户管理",
"system": "系统设置",
"oa": "OA",
"menu": "菜单管理",
"knowledge": "知识库",
}
if name, exists := defaultMap[module]; exists {
return name
}
return module
}
// GetModuleNames 批量获取模块名称,返回 module->name 映射
func GetModuleNames(modules []string) (map[string]string, error) {
result := make(map[string]string)
if len(modules) == 0 {
return result, nil
}
o := orm.NewOrm()
// 1. 尝试通过 permission 精准匹配
placeholders := make([]string, 0)
args := make([]interface{}, 0)
for _, m := range modules {
if m == "" {
continue
}
placeholders = append(placeholders, "?")
args = append(args, m)
}
if len(args) > 0 {
query := "SELECT permission, name FROM yz_menus WHERE permission IN (" + strings.Join(placeholders, ",") + ") AND delete_time IS NULL"
rows := make([]struct {
Permission string
Name string
}, 0)
_, err := o.Raw(query, args...).QueryRows(&rows)
if err == nil {
for _, r := range rows {
if r.Permission != "" {
result[r.Permission] = r.Name
}
}
}
}
// 2. 对于仍未匹配的模块,尝试通过 path 的末段进行模糊匹配
missing := make([]string, 0)
for _, m := range modules {
if m == "" {
continue
}
if _, ok := result[m]; !ok {
missing = append(missing, m)
}
}
if len(missing) > 0 {
// 使用 OR 组合 path LIKE '%/m' OR path LIKE '%/m/%'
conds := make([]string, 0)
args2 := make([]interface{}, 0)
for _, m := range missing {
conds = append(conds, "path LIKE ? OR path LIKE ?")
args2 = append(args2, "%/"+m, "%/"+m+"/%")
}
query2 := "SELECT path, name FROM yz_menus WHERE (" + strings.Join(conds, " OR ") + ") AND delete_time IS NULL"
rows2 := make([]struct {
Path string
Name string
}, 0)
_, err := o.Raw(query2, args2...).QueryRows(&rows2)
if err == nil {
for _, r := range rows2 {
// extract last segment from path
parts := strings.Split(strings.Trim(r.Path, "/"), "/")
if len(parts) > 0 {
key := parts[len(parts)-1]
if key != "" {
if _, exists := result[key]; !exists {
result[key] = r.Name
}
}
}
}
}
}
// 3. 对于仍然没有匹配的,使用默认映射
defaultMap := map[string]string{
"auth": "认证",
"dict": "字典管理",
"user": "用户管理",
"role": "角色管理",
"permission": "权限管理",
"department": "部门管理",
"employee": "员工管理",
"position": "职位管理",
"tenant": "租户管理",
"system": "系统设置",
"oa": "OA",
"menu": "菜单管理",
"knowledge": "知识库",
}
for _, m := range modules {
if m == "" {
continue
}
if _, exists := result[m]; !exists {
if v, ok := defaultMap[m]; ok {
result[m] = v
} else {
result[m] = m
}
}
}
return result, nil
}
// AddOperationLog 添加操作日志
func AddOperationLog(log *models.OperationLog) error {
o := orm.NewOrm()
log.CreateTime = time.Now()
_, err := o.Insert(log)
if err != nil {
return fmt.Errorf("添加操作日志失败: %v", err)
}
return nil
}
// AddAccessLog 添加访问日志(用于记录 GET / READ 行为)
func AddAccessLog(log *models.AccessLog) error {
o := orm.NewOrm()
log.CreateTime = time.Now()
_, err := o.Insert(log)
if err != nil {
return fmt.Errorf("添加访问日志失败: %v", err)
}
return nil
}
// GetOperationLogs 获取操作日志列表
func GetOperationLogs(tenantId int, userId int, module string, operation string, startTime *time.Time, endTime *time.Time, pageNum int, pageSize int) ([]*models.OperationLog, int64, error) {
o := orm.NewOrm()
qs := o.QueryTable("sys_operation_log").Filter("tenant_id", tenantId)
// 用户过滤
if userId > 0 {
qs = qs.Filter("user_id", userId)
}
// 模块过滤
if module != "" {
qs = qs.Filter("module", module)
}
// 操作类型过滤
if operation != "" {
qs = qs.Filter("operation", operation)
}
// 时间范围过滤
if startTime != nil {
qs = qs.Filter("create_time__gte", startTime)
}
if endTime != nil {
qs = qs.Filter("create_time__lte", endTime)
}
// 获取总数
total, err := qs.Count()
if err != nil {
return nil, 0, fmt.Errorf("查询日志总数失败: %v", err)
}
// 分页查询
var logs []*models.OperationLog
offset := (pageNum - 1) * pageSize
_, err = qs.OrderBy("-create_time").Offset(offset).Limit(pageSize).All(&logs)
if err != nil {
return nil, 0, fmt.Errorf("查询操作日志失败: %v", err)
}
return logs, total, nil
}
// GetOperationLogById 根据ID获取操作日志
func GetOperationLogById(id int64) (*models.OperationLog, error) {
o := orm.NewOrm()
log := &models.OperationLog{Id: id}
err := o.Read(log)
if err != nil {
return nil, fmt.Errorf("日志不存在: %v", err)
}
return log, nil
}
// GetUserOperationStats 获取用户操作统计
func GetUserOperationStats(tenantId int, userId int, days int) (map[string]interface{}, error) {
o := orm.NewOrm()
startTime := time.Now().AddDate(0, 0, -days)
// 获取总操作数
var totalCount int64
err := o.Raw(
"SELECT COUNT(*) FROM sys_operation_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ?",
tenantId, userId, startTime,
).QueryRow(&totalCount)
if err != nil {
return nil, err
}
// 获取按模块分组的操作数
type ModuleCount struct {
Module string
Count int
}
var moduleCounts []ModuleCount
_, err = o.Raw(
"SELECT module, COUNT(*) as count FROM sys_operation_log WHERE tenant_id = ? AND user_id = ? AND create_time >= ? GROUP BY module",
tenantId, userId, startTime,
).QueryRows(&moduleCounts)
if err != nil {
return nil, err
}
// 构建结果
stats := map[string]interface{}{
"total_operations": totalCount,
"module_stats": moduleCounts,
"period_days": days,
"from_time": startTime,
"to_time": time.Now(),
}
return stats, nil
}
// GetTenantOperationStats 获取租户操作统计
func GetTenantOperationStats(tenantId int, days int) (map[string]interface{}, error) {
o := orm.NewOrm()
startTime := time.Now().AddDate(0, 0, -days)
// 获取总操作数
var totalCount int64
err := o.Raw(
"SELECT COUNT(*) FROM sys_operation_log WHERE tenant_id = ? AND create_time >= ?",
tenantId, startTime,
).QueryRow(&totalCount)
if err != nil {
return nil, err
}
// 获取按用户分组的操作数(Top 10)
type UserCount struct {
UserId int
Username string
Count int
}
var userCounts []UserCount
_, err = o.Raw(
"SELECT user_id, username, COUNT(*) as count FROM sys_operation_log WHERE tenant_id = ? AND create_time >= ? GROUP BY user_id, username ORDER BY count DESC LIMIT 10",
tenantId, startTime,
).QueryRows(&userCounts)
if err != nil {
return nil, err
}
// 获取按操作类型分组的操作数
type OperationCount struct {
Operation string
Count int
}
var operationCounts []OperationCount
_, err = o.Raw(
"SELECT operation, COUNT(*) as count FROM sys_operation_log WHERE tenant_id = ? AND create_time >= ? GROUP BY operation",
tenantId, startTime,
).QueryRows(&operationCounts)
if err != nil {
return nil, err
}
// 构建结果
stats := map[string]interface{}{
"total_operations": totalCount,
"top_users": userCounts,
"operation_breakdown": operationCounts,
"period_days": days,
"from_time": startTime,
"to_time": time.Now(),
}
return stats, nil
}
// DeleteOldLogs 删除旧日志(保留指定天数)
func DeleteOldLogs(keepDays int) (int64, error) {
o := orm.NewOrm()
cutoffTime := time.Now().AddDate(0, 0, -keepDays)
result, err := o.Raw("DELETE FROM sys_operation_log WHERE create_time < ?", cutoffTime).Exec()
if err != nil {
return 0, fmt.Errorf("删除旧日志失败: %v", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("获取受影响行数失败: %v", err)
}
return rowsAffected, nil
}
+32
View File
@@ -0,0 +1,32 @@
-- 通用操作日志表(记录所有用户在所有模块的操作)
CREATE TABLE `sys_operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志ID',
`tenant_id` int(11) NOT NULL DEFAULT 0 COMMENT '租户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='系统操作日志表';
Binary file not shown.