package middleware import ( "encoding/json" "fmt" "strconv" "strings" "time" "server/models" "server/pkg/jwtutil" "github.com/beego/beego/v2/server/web/context" ) const ( oplogStartKey = "__oplog_start" oplogReqBodyKey = "__oplog_req_body" ) // BeginOperationLog 在 BeforeRouter 采集请求信息 func BeginOperationLog(ctx *context.Context) { url := ctx.Input.URL() method := ctx.Input.Method() if shouldSkipLogging(method, url) { return } // 解析用户信息(直接从 header 解析,不依赖 JWT 中间件设置的 context) var uid uint64 var tid *uint64 if auth := ctx.Request.Header.Get("Authorization"); auth != "" { parts := strings.SplitN(auth, " ", 2) if len(parts) == 2 && parts[0] == "Bearer" { if claims, err := jwtutil.ParseToken(parts[1]); err == nil { uid = uint64(claims.UserID) if claims.TenantId > 0 { tidVal := uint64(claims.TenantId) tid = &tidVal } } } } // 匿名读请求不算操作:官网页面浏览和公开接口每次访问都落库, // 审计表会被 uid=0/module=unknown/action=查询 的流水淹掉。写操作仍照常记录。 if method == "GET" && uid == 0 { return } // 采集请求信息 reqBody := "" if rb := ctx.Input.RequestBody; len(rb) > 0 { reqBody = truncateString(maskSensitive(string(rb)), 5000) } else if q := strings.TrimSpace(ctx.Request.URL.RawQuery); q != "" { reqBody = truncateString(maskSensitive(q), 5000) } module := parseModule(url) action := parseAction(method, url) ip := truncateString(ctx.Input.IP(), 50) userAgent := truncateString(ctx.Request.Header.Get("User-Agent"), 500) // 异步写入数据库 go func() { time.Sleep(100 * time.Millisecond) // 等待响应完成 logRow := &models.SystemOperationLog{ Tid: tid, UserID: uid, Module: module, Action: action, Method: method, URL: truncateString(url, 255), IP: ip, UserAgent: userAgent, RequestData: strPtr(reqBody), Status: 1, ExecutionTime: 0, } if _, err := models.Orm.Insert(logRow); err != nil { fmt.Printf("[oplog] INSERT ERROR: %v\n", err) } }() } func strPtr(s string) *string { if s == "" { return nil } return &s } // isMobileAppPath 判断是否属于移动端(/api/)功能接口前缀集合。 // 登录器公开接口(/api/getcard、/api/cursor/*、/api/softwareupgrade/*、 // /api/schedule/reminder/ack 等)不在此集合内。 func isMobileAppPath(url string) bool { mobilePrefixes := []string{ "/api/oa/", "/api/notebook/", "/api/currentUser", "/api/activity/", "/api/home", "/api/quick-menus", } // /api/schedule 需区分:/api/schedule/reminder/ack 属登录器,排除 if strings.HasPrefix(url, "/api/schedule") { return !strings.HasPrefix(url, "/api/schedule/reminder/ack") } for _, p := range mobilePrefixes { if strings.HasPrefix(url, p) { return true } } return false } // WriteDeleteLog 控制器删除后直接写操作日志(带标题) func WriteDeleteLog(userID uint64, tid int, module, title string) { moduleCN := oplogModuleLabel(module) desc := fmt.Sprintf("您删除了%s:【%s】", moduleCN, truncateString(title, 20)) tidVal := uint64(tid) logRow := &models.SystemOperationLog{ UserID: userID, Tid: &tidVal, Module: module, Action: "删除", Method: "DELETE", URL: fmt.Sprintf("/api/%s", module), Status: 1, RequestData: &desc, ExecutionTime: 0, } models.Orm.Insert(logRow) } func oplogModuleLabel(m string) string { switch m { case "notebook": return "记事本" case "schedule": return "日程提醒" default: return m } } // FinishOperationLog 在 FinishRouter 统一落库到 yz_system_operation_log func FinishOperationLog(ctx *context.Context) { url := ctx.Input.URL() method := ctx.Input.Method() fmt.Printf("[oplog-finish] method=%s url=%s\n", method, url) if shouldSkipLogging(method, url) { fmt.Printf("[oplog-finish] SKIPPED\n") return } start, _ := ctx.Input.GetData(oplogStartKey).(time.Time) if start.IsZero() { start = time.Now() } execSec := float64(time.Since(start).Milliseconds()) / 1000.0 uid := parseUint64FromCtx(ctx.Input.GetData("userId")) tidVal := parseUint64FromCtx(ctx.Input.GetData("tenantId")) // 如果 context 中没有用户信息,尝试从 Authorization header 解析 if uid == 0 { if auth := ctx.Request.Header.Get("Authorization"); auth != "" { parts := strings.SplitN(auth, " ", 2) if len(parts) == 2 && parts[0] == "Bearer" { if claims, err := jwtutil.ParseToken(parts[1]); err == nil { uid = uint64(claims.UserID) if claims.TenantId > 0 { tidVal = uint64(claims.TenantId) } } } } } var tid *uint64 if tidVal > 0 { tid = &tidVal } module := parseModule(url) action := parseAction(method, url) ip := ctx.Input.IP() userAgent := truncateString(ctx.Input.Header("User-Agent"), 500) status := int8(1) if code := ctx.ResponseWriter.Status; code >= 400 { status = 0 } var reqData *string if v, ok := ctx.Input.GetData(oplogReqBodyKey).(string); ok && strings.TrimSpace(v) != "" { reqData = &v } else if q := strings.TrimSpace(ctx.Request.URL.RawQuery); q != "" { q = truncateString(maskSensitive(q), 5000) reqData = &q } var respData *string if code := ctx.ResponseWriter.Status; code >= 400 { msg := "HTTP " + strconv.Itoa(code) respData = &msg } var errMsg *string if status == 0 { msg := "请求失败" if respData != nil { msg = *respData } errMsg = &msg } logRow := &models.SystemOperationLog{ Tid: tid, UserID: uid, Module: module, Action: action, Method: method, URL: truncateString(url, 255), IP: truncateString(ip, 50), UserAgent: userAgent, RequestData: reqData, ResponseData: respData, Status: status, ErrorMessage: errMsg, ExecutionTime: execSec, } fmt.Printf("[oplog] method=%s url=%s uid=%d tid=%v module=%s action=%s\n", method, url, uid, tid, module, action) _, err := models.Orm.Insert(logRow) if err != nil { fmt.Printf("[oplog] INSERT ERROR: %v\n", err) } } func parseAction(method, url string) string { u := strings.ToLower(url) if strings.Contains(u, "login") { return "登录" } if strings.Contains(u, "logout") { return "退出" } if strings.Contains(u, "upload") { return "上传" } switch method { case "POST": if strings.Contains(u, "delete") { return "删除" } if strings.Contains(u, "update") || strings.Contains(u, "edit") || strings.Contains(u, "rename") { return "编辑" } if strings.Contains(u, "create") || strings.Contains(u, "add") { return "新增" } return "提交" case "PUT", "PATCH": return "编辑" case "DELETE": return "删除" default: return "查询" } } func parseModule(url string) string { path := strings.Trim(strings.ToLower(url), "/") parts := strings.Split(path, "/") if len(parts) >= 2 { return truncateString(parts[1], 50) } if len(parts) == 1 && parts[0] != "" { return truncateString(parts[0], 50) } return "unknown" } func shouldSkipLogging(method, url string) bool { skipPatterns := []string{ "/static/", "/uploads/", "/favicon.ico", "/health", "/ping", } for _, pattern := range skipPatterns { if strings.HasPrefix(url, pattern) { return true } } // 高频噪声接口:默认跳过(可按需再扩充) if method == "GET" { noisyExact := map[string]bool{ "/platform/currentUser": true, "/platform/allmenu": true, "/platform/getOpenVerify": true, // 若未来改名/迁移可再调整 } if noisyExact[url] { return true } // 菜单详情/列表类:频率高且多为前端路由加载 if strings.HasPrefix(url, "/platform/menu/") { return true } // 登录页极验配置轮询/获取(不影响关键业务) if strings.HasPrefix(url, "/platform/login/getGeetest") || strings.HasPrefix(url, "/platform/login/getOpenVerify") { return true } // 客户端高频版本检查 if strings.HasPrefix(url, "/api/softwareupgrade/check") { return true } // 工作台天气实况(代理外部数据源,非业务写操作) if strings.HasPrefix(url, "/backend/weather/") { return true } // 移动端(已并入 /api/)读接口跳过(只记录写操作:POST/PUT/DELETE) if isMobileAppPath(url) { return true } } return false } func parseUint64FromCtx(v interface{}) uint64 { switch x := v.(type) { case int: if x > 0 { return uint64(x) } case int64: if x > 0 { return uint64(x) } case uint64: return x case float64: if x > 0 { return uint64(x) } } return 0 } func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } func maskSensitive(s string) string { // 尝试 JSON 脱敏(失败则返回原文) var obj interface{} if err := json.Unmarshal([]byte(s), &obj); err != nil { return s } maskInObj(&obj) bs, err := json.Marshal(obj) if err != nil { return s } return string(bs) } func maskInObj(v *interface{}) { switch t := (*v).(type) { case map[string]interface{}: for k, val := range t { lk := strings.ToLower(k) if lk == "password" || lk == "pwd" || lk == "token" || lk == "api_key" || lk == "api_secret" || lk == "authorization" { t[k] = "***" continue } tmp := val maskInObj(&tmp) t[k] = tmp } case []interface{}: for i := range t { tmp := t[i] maskInObj(&tmp) t[i] = tmp } } }