diff --git a/backend/internal/auditlog/auditlog.go b/backend/internal/auditlog/auditlog.go new file mode 100644 index 0000000..713e694 --- /dev/null +++ b/backend/internal/auditlog/auditlog.go @@ -0,0 +1,56 @@ +package auditlog + +import ( + "encoding/json" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type Meta struct { + IP string + UserAgent string + RequestID string +} + +type Entry struct { + ActorType string + ActorID uint64 + Action string + BizType string + BizID *uint64 + Meta Meta + Detail map[string]any +} + +// Append 在业务事务内写入审计日志,确保审计和业务状态一起提交或回滚。 +func Append(tx *gorm.DB, entry Entry) error { + raw, err := marshalDetail(entry.Detail, entry.Meta.RequestID) + if err != nil { + return err + } + row := model.AuditLog{ + ActorType: entry.ActorType, + ActorID: entry.ActorID, + Action: entry.Action, + BizType: entry.BizType, + BizID: entry.BizID, + IP: entry.Meta.IP, + UserAgent: entry.Meta.UserAgent, + Detail: datatypes.JSON(raw), + } + return tx.Create(&row).Error +} + +func marshalDetail(detail map[string]any, requestID string) ([]byte, error) { + copied := make(map[string]any, len(detail)+1) + for key, value := range detail { + copied[key] = value + } + if requestID != "" { + copied["request_id"] = requestID + } + return json.Marshal(copied) +} diff --git a/backend/internal/logging/logger.go b/backend/internal/logging/logger.go index db113b3..02a4dc3 100644 --- a/backend/internal/logging/logger.go +++ b/backend/internal/logging/logger.go @@ -41,7 +41,7 @@ func New(cfg config.LogConfig) (*zap.Logger, error) { if cfg.EnableFile { writer := newDailyWriter(cfg.Dir, "app", location) cores = append(cores, zapcore.NewCore( - zapcore.NewConsoleEncoder(encoderConfig), + zapcore.NewJSONEncoder(encoderConfig), writer, level, )) diff --git a/backend/internal/middleware/recovery.go b/backend/internal/middleware/recovery.go new file mode 100644 index 0000000..e960742 --- /dev/null +++ b/backend/internal/middleware/recovery.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "fmt" + "net/http" + "runtime/debug" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func Recovery(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if recovered := recover(); recovered != nil { + err := fmt.Errorf("panic: %v", recovered) + _ = c.Error(err) + logger.Error("http panic recovered", + zap.String("request_id", GetRequestID(c)), + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.String("route", c.FullPath()), + zap.String("client_ip", c.ClientIP()), + zap.String("user_agent", c.GetHeader("User-Agent")), + zap.String("panic", fmt.Sprint(recovered)), + zap.ByteString("stack", debug.Stack()), + ) + if !c.Writer.Written() { + response.Error(c, http.StatusInternalServerError, "internal_error", "服务暂时不可用") + } + c.Abort() + } + }() + c.Next() + } +} diff --git a/backend/internal/middleware/request_id.go b/backend/internal/middleware/request_id.go new file mode 100644 index 0000000..05819f6 --- /dev/null +++ b/backend/internal/middleware/request_id.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/gin-gonic/gin" +) + +const ( + RequestIDHeader = "X-Request-ID" + ContextRequestID = "request_id" +) + +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader(RequestIDHeader) + if requestID == "" { + requestID = newRequestID() + } + c.Set(ContextRequestID, requestID) + c.Writer.Header().Set(RequestIDHeader, requestID) + c.Next() + } +} + +func GetRequestID(c *gin.Context) string { + value, ok := c.Get(ContextRequestID) + if !ok { + return "" + } + requestID, ok := value.(string) + if !ok { + return "" + } + return requestID +} + +func newRequestID() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "" + } + return hex.EncodeToString(buf) +} diff --git a/backend/internal/middleware/request_logger.go b/backend/internal/middleware/request_logger.go index ea8cd4e..d04ecba 100644 --- a/backend/internal/middleware/request_logger.go +++ b/backend/internal/middleware/request_logger.go @@ -1,6 +1,7 @@ package middleware import ( + "strings" "time" "github.com/gin-gonic/gin" @@ -12,12 +13,36 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc { start := time.Now() c.Next() - logger.Info("http request", + latency := time.Since(start) + fields := []zap.Field{ + zap.String("request_id", GetRequestID(c)), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path), + zap.String("route", c.FullPath()), zap.Int("status", c.Writer.Status()), - zap.Duration("latency", time.Since(start)), + zap.Float64("latency_ms", float64(latency.Microseconds())/1000), zap.String("client_ip", c.ClientIP()), - ) + zap.String("user_agent", c.GetHeader("User-Agent")), + zap.String("referer", c.GetHeader("Referer")), + zap.Int("response_size", c.Writer.Size()), + } + if userID, ok := c.Get(ContextUserID); ok { + fields = append(fields, zap.Any("user_id", userID)) + } + if adminID, ok := c.Get(ContextAdminID); ok { + fields = append(fields, zap.Any("admin_id", adminID)) + } + if len(c.Errors) > 0 { + fields = append(fields, zap.String("errors", strings.TrimSpace(c.Errors.String()))) + } + + switch { + case c.Writer.Status() >= 500: + logger.Error("http request", fields...) + case c.Writer.Status() >= 400: + logger.Warn("http request", fields...) + default: + logger.Info("http request", fields...) + } } } diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index 95e8d69..f1be3fa 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -104,6 +104,7 @@ func auditMeta(c *gin.Context) AuditMeta { return AuditMeta{ IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), } } diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index c34544a..a400347 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -1,12 +1,11 @@ package adminuser import ( - "encoding/json" "errors" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" - "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -15,10 +14,7 @@ type Repository struct { db *gorm.DB } -type AuditMeta struct { - IP string - UserAgent string -} +type AuditMeta = auditlog.Meta func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} @@ -133,21 +129,15 @@ func (row userRow) toDTO() UserDTO { } func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { - raw, err := json.Marshal(detail) - if err != nil { - return err - } - row := model.AuditLog{ + return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: "user", BizID: &bizID, - IP: meta.IP, - UserAgent: meta.UserAgent, - Detail: datatypes.JSON(raw), - } - return tx.Create(&row).Error + Meta: meta, + Detail: detail, + }) } func IsNotFound(err error) bool { diff --git a/backend/internal/modules/dispute/dto.go b/backend/internal/modules/dispute/dto.go index 1faa778..4dd9a02 100644 --- a/backend/internal/modules/dispute/dto.go +++ b/backend/internal/modules/dispute/dto.go @@ -3,6 +3,8 @@ package dispute import ( "time" + "hfb_sys/backend/internal/auditlog" + "gorm.io/datatypes" ) @@ -36,10 +38,7 @@ type ArbitrateRequest struct { Remark string `json:"remark" binding:"required"` Amount float64 `json:"amount"` } -type AuditMeta struct { - IP string - UserAgent string -} +type AuditMeta = auditlog.Meta type PaginatedResult struct { Items []DisputeDTO `json:"items"` diff --git a/backend/internal/modules/dispute/handler.go b/backend/internal/modules/dispute/handler.go index f6c626d..bf3d4fe 100644 --- a/backend/internal/modules/dispute/handler.go +++ b/backend/internal/modules/dispute/handler.go @@ -100,10 +100,7 @@ func (h *Handler) AdminArbitrate(c *gin.Context) { response.BadRequest(c, "仲裁结果和备注不能为空") return } - item, err := h.service.Arbitrate(adminID, id, req, AuditMeta{ - IP: c.ClientIP(), - UserAgent: c.GetHeader("User-Agent"), - }) + item, err := h.service.Arbitrate(adminID, id, req, auditMeta(c)) if err != nil { writeDisputeError(c, err) return @@ -111,6 +108,14 @@ func (h *Handler) AdminArbitrate(c *gin.Context) { response.OK(c, item) } +func auditMeta(c *gin.Context) AuditMeta { + return AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + } +} + func currentAdminID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextAdminID) if !ok { diff --git a/backend/internal/modules/dispute/repository.go b/backend/internal/modules/dispute/repository.go index c2e9a6d..5cb59b9 100644 --- a/backend/internal/modules/dispute/repository.go +++ b/backend/internal/modules/dispute/repository.go @@ -6,6 +6,7 @@ import ( "math" "time" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" @@ -470,21 +471,15 @@ func disputeType(input string, isCheckoutDispute bool) string { } func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { - raw, err := json.Marshal(detail) - if err != nil { - return err - } - row := model.AuditLog{ + return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: bizType, BizID: &bizID, - IP: meta.IP, - UserAgent: meta.UserAgent, - Detail: datatypes.JSON(raw), - } - return tx.Create(&row).Error + Meta: meta, + Detail: detail, + }) } func IsNotFound(err error) bool { diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 1853334..c696470 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "time" + + "hfb_sys/backend/internal/auditlog" ) type ListingDTO struct { @@ -116,10 +118,7 @@ type AdminActionRequest struct { Reason string `json:"reason" binding:"required"` } -type AuditMeta struct { - IP string - UserAgent string -} +type AuditMeta = auditlog.Meta type ExternalUploadRequest struct { UploadTime int64 `json:"uploadTime"` diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 504eafe..44a7a07 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -174,7 +174,7 @@ func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActio response.BadRequest(c, "操作原因不能为空") return } - item, err := fn(adminID, id, req, AuditMeta{IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent")}) + item, err := fn(adminID, id, req, auditMeta(c)) if err != nil { writeListingError(c, err) return @@ -182,6 +182,14 @@ func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActio response.OK(c, item) } +func auditMeta(c *gin.Context) AuditMeta { + return AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + } +} + func (h *Handler) Approve(c *gin.Context) { id, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index f625949..7a35914 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" @@ -1342,21 +1343,15 @@ func extractListingObjectKey(fileURL string) string { } func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { - raw, err := json.Marshal(detail) - if err != nil { - return err - } - row := model.AuditLog{ + return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: bizType, BizID: &bizID, - IP: meta.IP, - UserAgent: meta.UserAgent, - Detail: datatypes.JSON(raw), - } - return tx.Create(&row).Error + Meta: meta, + Detail: detail, + }) } func IsNotFound(err error) bool { diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 9ebf592..3a6a484 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -3,6 +3,8 @@ package order import ( "time" + "hfb_sys/backend/internal/auditlog" + "gorm.io/datatypes" ) @@ -68,10 +70,7 @@ type AdminActionRequest struct { Reason string `json:"reason" binding:"required"` } -type AuditMeta struct { - IP string - UserAgent string -} +type AuditMeta = auditlog.Meta type HandoffRecordDTO struct { ID uint64 `json:"id"` diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index 171b7d6..d8dec0b 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -110,13 +110,21 @@ func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActio response.BadRequest(c, "操作原因不能为空") return } - if err := fn(adminID, id, req, AuditMeta{IP: c.ClientIP(), UserAgent: c.GetHeader("User-Agent")}); err != nil { + if err := fn(adminID, id, req, auditMeta(c)); err != nil { writeOrderError(c, err) return } response.OK(c, okData) } +func auditMeta(c *gin.Context) AuditMeta { + return AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + } +} + func (h *Handler) Detail(c *gin.Context) { userID, ok := currentUserID(c) if !ok { @@ -375,7 +383,6 @@ func parseID(c *gin.Context) (uint64, bool) { } func writeOrderError(c *gin.Context, err error) { - println("DEBUG ORDER ERROR:", err.Error()) switch { case errors.Is(err, ErrDependencyUnavailable): response.ServiceUnavailable(c, "数据库未连接") diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 33df153..86b2ea9 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" @@ -1536,21 +1537,15 @@ func newOrderNo() (string, error) { } func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error { - raw, err := json.Marshal(detail) - if err != nil { - return err - } - row := model.AuditLog{ + return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: bizType, BizID: &bizID, - IP: meta.IP, - UserAgent: meta.UserAgent, - Detail: datatypes.JSON(raw), - } - return tx.Create(&row).Error + Meta: meta, + Detail: detail, + }) } func IsNotFound(err error) bool { diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index 518ef2e..ac60c06 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -75,10 +75,7 @@ func (h *Handler) Update(c *gin.Context) { response.BadRequest(c, "配置值不能为空") return } - item, err := h.service.Update(adminID, key, req, AuditMeta{ - IP: c.ClientIP(), - UserAgent: c.GetHeader("User-Agent"), - }) + item, err := h.service.Update(adminID, key, req, auditMeta(c)) if err != nil { writeConfigError(c, err) return @@ -86,6 +83,14 @@ func (h *Handler) Update(c *gin.Context) { response.OK(c, item) } +func auditMeta(c *gin.Context) AuditMeta { + return AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + } +} + func currentAdminID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextAdminID) if !ok { diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 95d6526..a7ec406 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -5,9 +5,9 @@ import ( "errors" "strings" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" - "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -16,10 +16,7 @@ type Repository struct { db *gorm.DB } -type AuditMeta struct { - IP string - UserAgent string -} +type AuditMeta = auditlog.Meta type defaultConfig struct { Key string @@ -249,21 +246,15 @@ func isLegacyPublishOptionsValue(value string) bool { } func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { - raw, err := json.Marshal(detail) - if err != nil { - return err - } - row := model.AuditLog{ + return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: "system_config", BizID: &bizID, - IP: meta.IP, - UserAgent: meta.UserAgent, - Detail: datatypes.JSON(raw), - } - return tx.Create(&row).Error + Meta: meta, + Detail: detail, + }) } func toDTO(row model.SystemConfig) ConfigDTO { diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index d9d2b09..f466c4e 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -171,7 +171,6 @@ func ensureAccount(tx *gorm.DB, userID uint64) error { } func applyEntry(account *model.WalletAccount, entry Entry) (float64, error) { - println("DEBUG APPLY:", entry.UserID, "type:", entry.BalanceType, "dir:", entry.Direction, "amt:", fmt.Sprintf("%.2f", entry.Amount), "avail:", fmt.Sprintf("%.2f", account.AvailableBalance), "frozen:", fmt.Sprintf("%.2f", account.FrozenBalance)) entry.Amount = roundWalletMoney(entry.Amount) account.AvailableBalance = roundWalletMoney(account.AvailableBalance) account.FrozenBalance = roundWalletMoney(account.FrozenBalance) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 9117dac..f8ddc3f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -36,8 +36,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } engine := gin.New() - engine.Use(gin.Recovery()) + engine.Use(middleware.RequestID()) engine.Use(middleware.RequestLogger(logger)) + engine.Use(middleware.Recovery(logger)) health := handler.NewHealthHandler() engine.GET("/health", health.Check)