diff --git a/pc/src/api/accessLog.js b/pc/src/api/accessLog.js new file mode 100644 index 0000000..32b5487 --- /dev/null +++ b/pc/src/api/accessLog.js @@ -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 } + }) +} diff --git a/pc/src/api/operationLog.js b/pc/src/api/operationLog.js new file mode 100644 index 0000000..d0395c9 --- /dev/null +++ b/pc/src/api/operationLog.js @@ -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 } + }) +} diff --git a/pc/src/views/system/accessLog/index.vue b/pc/src/views/system/accessLog/index.vue new file mode 100644 index 0000000..a6dfac3 --- /dev/null +++ b/pc/src/views/system/accessLog/index.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/pc/src/views/system/operationLog/components/OperationLogDetail.vue b/pc/src/views/system/operationLog/components/OperationLogDetail.vue new file mode 100644 index 0000000..e9c0500 --- /dev/null +++ b/pc/src/views/system/operationLog/components/OperationLogDetail.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/pc/src/views/system/operationLog/components/StatisticsPanel.vue b/pc/src/views/system/operationLog/components/StatisticsPanel.vue new file mode 100644 index 0000000..18d95fb --- /dev/null +++ b/pc/src/views/system/operationLog/components/StatisticsPanel.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/pc/src/views/system/operationLog/index.vue b/pc/src/views/system/operationLog/index.vue new file mode 100644 index 0000000..02ac772 --- /dev/null +++ b/pc/src/views/system/operationLog/index.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/server/controllers/auth.go b/server/controllers/auth.go index 44008c2..250635b 100644 --- a/server/controllers/auth.go +++ b/server/controllers/auth.go @@ -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, diff --git a/server/controllers/dict.go b/server/controllers/dict.go index 3cde5cf..3c95772 100644 --- a/server/controllers/dict.go +++ b/server/controllers/dict.go @@ -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 根据字典编码获取字典项(用于业务查询) diff --git a/server/controllers/operation_log.go b/server/controllers/operation_log.go new file mode 100644 index 0000000..b7c921d --- /dev/null +++ b/server/controllers/operation_log.go @@ -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() +} diff --git a/server/middleware/operationLog.go b/server/middleware/operationLog.go new file mode 100644 index 0000000..7f6d549 --- /dev/null +++ b/server/middleware/operationLog.go @@ -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" +} diff --git a/server/models/access_log.go b/server/models/access_log.go new file mode 100644 index 0000000..ebb3fba --- /dev/null +++ b/server/models/access_log.go @@ -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" +} diff --git a/server/models/audit_log.go b/server/models/audit_log.go new file mode 100644 index 0000000..6180259 --- /dev/null +++ b/server/models/audit_log.go @@ -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" +} diff --git a/server/models/user.go b/server/models/user.go index 1198b80..b1e0f05 100644 --- a/server/models/user.go +++ b/server/models/user.go @@ -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) // 设置空闲连接的最大空闲时间 diff --git a/server/routers/router.go b/server/routers/router.go index 64246d7..a920425 100644 --- a/server/routers/router.go +++ b/server/routers/router.go @@ -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") + } diff --git a/server/services/operation_log.go b/server/services/operation_log.go new file mode 100644 index 0000000..9bce376 --- /dev/null +++ b/server/services/operation_log.go @@ -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 +} diff --git a/server/sql/create_audit_log_tables.sql b/server/sql/create_audit_log_tables.sql new file mode 100644 index 0000000..06e42d3 --- /dev/null +++ b/server/sql/create_audit_log_tables.sql @@ -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='系统操作日志表'; diff --git a/server/yunzer_server b/server/yunzer_server new file mode 100644 index 0000000..1f9ce7f Binary files /dev/null and b/server/yunzer_server differ