yintai2/app/admin/middleware/operate_log.go

401 lines
10 KiB
Go

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)
}