368 lines
9.0 KiB
Go
368 lines
9.0 KiB
Go
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
|
||
}
|
||
fmt.Printf("[oplog-begin] method=%s url=%s\n", method, url)
|
||
|
||
// 解析用户信息(直接从 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 采集请求信息
|
||
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,
|
||
}
|
||
_, err := models.Orm.Insert(logRow)
|
||
if err != nil {
|
||
fmt.Printf("[oplog] INSERT ERROR: %v\n", err)
|
||
} else {
|
||
fmt.Printf("[oplog] INSERTED uid=%d module=%s action=%s\n", uid, module, action)
|
||
}
|
||
}()
|
||
}
|
||
|
||
func strPtr(s string) *string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return &s
|
||
}
|
||
|
||
// 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("/app/%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
|
||
}
|
||
// App 端读接口跳过(只记录写操作:POST/PUT/DELETE)
|
||
if strings.HasPrefix(url, "/app/") && method == "GET" {
|
||
return true
|
||
}
|
||
// App 端 DELETE 由控制器自行记录(需要标题信息)
|
||
if strings.HasPrefix(url, "/app/") && method == "DELETE" {
|
||
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
|
||
}
|
||
}
|
||
}
|