package middleware import ( "bytes" "encoding/json" "io" "net/http" "strings" "time" "unicode/utf8" "yintai-api/app/common/cache" "yintai-api/app/common/dao" "yintai-api/app/common/model" "yintai-api/base/global" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" ) const ( operateBodyLimit = 8000 operateQueryLimit = 1024 operateHeaderLimit = 8000 operateMessageLimit = 512 operateAccountLimit = 64 operateForwardLimit = 512 operatePathLimit = 255 operateActionLimit = 64 ) // 写操作、登录、退出、获取验证码,以及所有失败请求都记。成功的普通查询不记。 var operateActions = map[string]string{ "POST /yt/user/login": "登录", "GET /yt/user/logout": "退出", "GET /yt/user/captcha": "获取验证码", "POST /yt/user": "创建用户", "PUT /yt/user": "修改用户", "DELETE /yt/user": "删除用户", "GET /yt/user": "查询用户", "POST /yt/resource": "创建资源", "PUT /yt/resource": "修改资源", "DELETE /yt/resource": "删除资源", "GET /yt/resource": "查询资源", "POST /yt/role": "创建角色", "PUT /yt/role": "修改角色", "DELETE /yt/role": "删除角色", "GET /yt/role": "查询角色", "GET /yt/role/:id": "查询角色", "GET /yt/role/account/:id": "查询角色账号", "GET /yt/translate": "翻译", "POST /yt/category": "创建分类", "PUT /yt/category": "修改分类", "DELETE /yt/category": "删除分类", "GET /yt/category": "查询分类", "POST /yt/process": "创建历程", "PUT /yt/process": "修改历程", "DELETE /yt/process": "删除历程", "GET /yt/process": "查询历程", "GET /yt/process/:id": "查询历程", "POST /yt/news": "创建新闻", "PUT /yt/news": "修改新闻", "DELETE /yt/news": "删除新闻", "GET /yt/news": "查询新闻", "GET /yt/news/:id": "查询新闻", "POST /yt/job": "创建招聘", "PUT /yt/job": "修改招聘", "DELETE /yt/job": "删除招聘", "GET /yt/job": "查询招聘", "GET /yt/job/:id": "查询招聘", "POST /yt/doc": "创建文档", "PUT /yt/doc": "修改文档", "DELETE /yt/doc": "删除文档", "GET /yt/doc": "查询文档", "GET /yt/doc/:id": "查询文档", "POST /yt/page": "创建页面", "PUT /yt/page": "修改页面", "DELETE /yt/page": "删除页面", "GET /yt/page": "查询页面", "POST /yt/upload": "上传文件", "GET /yt/upload/token": "获取上传凭证", } var saveOperateLog = func(row *model.AdminOperateLog) error { if global.GetDB() == nil { return errDBNotReady } return dao.NewAdminOperateLog().Create(row) } var loadOperateUser = func(token string) *model.UserWithRole { token = strings.TrimSpace(token) if token == "" { return nil } client := global.GetRedis() if client == nil { return nil } val := client.Get(cache.NewKeys().User() + token).Val() if val == "" { return nil } var user model.UserWithRole if err := json.Unmarshal([]byte(val), &user); err != nil { return nil } return &user } type operateDBError string func (e operateDBError) Error() string { return string(e) } const errDBNotReady operateDBError = "admin operate log: db not ready" type bodyWriter struct { gin.ResponseWriter buf bytes.Buffer } func (w *bodyWriter) Write(b []byte) (int, error) { w.buf.Write(b) return w.ResponseWriter.Write(b) } func (w *bodyWriter) WriteString(s string) (int, error) { w.buf.WriteString(s) return w.ResponseWriter.WriteString(s) } // OperateLog 必须注册在 Recovery 之前。业务错误是 panic 抛出的, // Recovery 接住并写完响应之后,这里才能把失败也记下来。 func OperateLog() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() reqBody := readOperateBody(c) user := loadOperateUser(c.GetHeader("token")) writer := &bodyWriter{ResponseWriter: c.Writer} c.Writer = writer c.Next() resp := writer.buf.Bytes() if user == nil && c.FullPath() == "/yt/user/login" { if code, _, ok := parseOperateResult(resp); ok && code == 0 { if token := loginToken(resp); token != "" { if loaded := loadOperateUser(token); loaded != nil { user = loaded } } } } row := collectOperateLog(c, start, reqBody, resp, user) if row == nil { return } if err := saveOperateLog(row); err != nil { log.Errorf("admin operate log %s %s: %v", row.Method, row.Path, err) } } } func collectOperateLog(c *gin.Context, start time.Time, reqBody, resp []byte, user *model.UserWithRole) *model.AdminOperateLog { route := c.FullPath() if route == "" { route = c.Request.URL.Path } code, message, _ := parseOperateResult(resp) success := 0 if code == 0 { success = 1 } if !shouldRecord(c.Request.Method, route, success == 1) { return nil } account := "" var userID, roleID int64 if user != nil { account = user.Account userID = user.ID roleID = user.RoleID } else { account = accountFromBody(reqBody) } cost := int(time.Since(start).Milliseconds()) if cost < 0 { cost = 0 } return &model.AdminOperateLog{ Account: cut(strings.TrimSpace(account), operateAccountLimit), UserID: userID, RoleID: roleID, CreateTime: start.Unix(), CostMs: cost, IP: cut(c.ClientIP(), 64), ForwardedFor: cut(c.GetHeader("X-Forwarded-For"), operateForwardLimit), Method: cut(c.Request.Method, 16), Path: cut(c.Request.URL.Path, operatePathLimit), Query: cut(c.Request.URL.RawQuery, operateQueryLimit), Request: requestText(c, reqBody), Action: cut(operateAction(c.Request.Method, route), operateActionLimit), Code: code, Message: cut(message, operateMessageLimit), Response: responseText(route, resp), Success: success, Header: headerText(c.Request.Header), } } func shouldRecord(method, route string, success bool) bool { if !success { return true } switch method { case http.MethodPost, http.MethodPut, http.MethodDelete: return true } return route == "/yt/user/logout" || route == "/yt/user/captcha" } func operateAction(method, route string) string { if name, ok := operateActions[method+" "+route]; ok { return name } return method + " " + route } func readOperateBody(c *gin.Context) []byte { if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") { return nil } if c.Request.Body == nil { return nil } body, err := io.ReadAll(c.Request.Body) _ = c.Request.Body.Close() if err != nil { c.Request.Body = io.NopCloser(bytes.NewReader(nil)) return nil } c.Request.Body = io.NopCloser(bytes.NewReader(body)) return body } func requestText(c *gin.Context, body []byte) string { if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") { return cut(multipartRequest(c), operateBodyLimit) } return maskAndCut(body, false) } func responseText(route string, body []byte) string { return maskAndCut(body, route == "/yt/user/captcha") } func multipartRequest(c *gin.Context) string { form := c.Request.MultipartForm if form == nil { return "" } fields := make(map[string][]string, len(form.Value)) for key, values := range form.Value { copied := append([]string(nil), values...) if strings.EqualFold(key, "token") { for i := range copied { copied[i] = maskToken(copied[i]) } } fields[key] = copied } files := make(map[string][]map[string]any, len(form.File)) for key, headers := range form.File { items := make([]map[string]any, 0, len(headers)) for _, header := range headers { items = append(items, map[string]any{ "filename": header.Filename, "size": header.Size, }) } files[key] = items } raw, err := json.Marshal(map[string]any{"fields": fields, "files": files}) if err != nil { return "" } return string(raw) } func parseOperateResult(body []byte) (int, string, bool) { if len(bytes.TrimSpace(body)) == 0 { return -1, "", false } var rsp struct { Code int `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(body, &rsp); err != nil { return -1, "", false } return rsp.Code, rsp.Message, true } func accountFromBody(body []byte) string { var req struct { Account string `json:"account"` } if err := json.Unmarshal(body, &req); err != nil { return "" } return strings.TrimSpace(req.Account) } func loginToken(body []byte) string { var rsp struct { Data struct { Token string `json:"token"` } `json:"data"` } if err := json.Unmarshal(body, &rsp); err != nil { return "" } return rsp.Data.Token } func maskAndCut(body []byte, omitImage bool) string { if len(bytes.TrimSpace(body)) == 0 { return "" } var value any if err := json.Unmarshal(body, &value); err != nil { return cut(string(body), operateBodyLimit) } maskJSON(value, omitImage) raw, err := json.Marshal(value) if err != nil { return cut(string(body), operateBodyLimit) } return cut(string(raw), operateBodyLimit) } func maskJSON(value any, omitImage bool) { switch node := value.(type) { case map[string]any: for key, item := range node { switch { case strings.EqualFold(key, "token"): node[key] = maskToken(asString(item)) case omitImage && strings.EqualFold(key, "image"): node[key] = "[omitted]" default: maskJSON(item, omitImage) } } case []any: for _, item := range node { maskJSON(item, omitImage) } } } func headerText(header http.Header) string { copied := make(map[string]string, len(header)) for key, values := range header { val := strings.Join(values, ",") switch strings.ToLower(key) { case "token", "cookie", "authorization", "set-cookie": val = maskToken(val) } copied[key] = val } raw, err := json.Marshal(copied) if err != nil { return "" } return cut(string(raw), operateHeaderLimit) } func maskToken(token string) string { if len(token) <= 12 { return "****" } return token[:4] + "****" + token[len(token)-4:] } func asString(value any) string { text, _ := value.(string) return text } func cut(s string, n int) string { if len(s) <= n { return s } b := []byte(s[:n]) for len(b) > 0 && !utf8.Valid(b) { b = b[:len(b)-1] } return string(b) }