完善日志和审计链路

This commit is contained in:
yml
2026-06-02 21:40:23 +08:00
parent 1325c8fb89
commit 6dcea2d56c
20 changed files with 243 additions and 90 deletions
+56
View File
@@ -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)
}
+1 -1
View File
@@ -41,7 +41,7 @@ func New(cfg config.LogConfig) (*zap.Logger, error) {
if cfg.EnableFile { if cfg.EnableFile {
writer := newDailyWriter(cfg.Dir, "app", location) writer := newDailyWriter(cfg.Dir, "app", location)
cores = append(cores, zapcore.NewCore( cores = append(cores, zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig), zapcore.NewJSONEncoder(encoderConfig),
writer, writer,
level, level,
)) ))
+38
View File
@@ -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()
}
}
+45
View File
@@ -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)
}
+28 -3
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -12,12 +13,36 @@ func RequestLogger(logger *zap.Logger) gin.HandlerFunc {
start := time.Now() start := time.Now()
c.Next() 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("method", c.Request.Method),
zap.String("path", c.Request.URL.Path), zap.String("path", c.Request.URL.Path),
zap.String("route", c.FullPath()),
zap.Int("status", c.Writer.Status()), 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("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...)
}
} }
} }
@@ -104,6 +104,7 @@ func auditMeta(c *gin.Context) AuditMeta {
return AuditMeta{ return AuditMeta{
IP: c.ClientIP(), IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"), UserAgent: c.GetHeader("User-Agent"),
RequestID: middleware.GetRequestID(c),
} }
} }
@@ -1,12 +1,11 @@
package adminuser package adminuser
import ( import (
"encoding/json"
"errors" "errors"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -15,10 +14,7 @@ type Repository struct {
db *gorm.DB db *gorm.DB
} }
type AuditMeta struct { type AuditMeta = auditlog.Meta
IP string
UserAgent string
}
func NewRepository(db *gorm.DB) *Repository { func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db} 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 { func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
raw, err := json.Marshal(detail) return auditlog.Append(tx, auditlog.Entry{
if err != nil {
return err
}
row := model.AuditLog{
ActorType: "admin", ActorType: "admin",
ActorID: actorID, ActorID: actorID,
Action: action, Action: action,
BizType: "user", BizType: "user",
BizID: &bizID, BizID: &bizID,
IP: meta.IP, Meta: meta,
UserAgent: meta.UserAgent, Detail: detail,
Detail: datatypes.JSON(raw), })
}
return tx.Create(&row).Error
} }
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
+3 -4
View File
@@ -3,6 +3,8 @@ package dispute
import ( import (
"time" "time"
"hfb_sys/backend/internal/auditlog"
"gorm.io/datatypes" "gorm.io/datatypes"
) )
@@ -36,10 +38,7 @@ type ArbitrateRequest struct {
Remark string `json:"remark" binding:"required"` Remark string `json:"remark" binding:"required"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
type AuditMeta struct { type AuditMeta = auditlog.Meta
IP string
UserAgent string
}
type PaginatedResult struct { type PaginatedResult struct {
Items []DisputeDTO `json:"items"` Items []DisputeDTO `json:"items"`
+9 -4
View File
@@ -100,10 +100,7 @@ func (h *Handler) AdminArbitrate(c *gin.Context) {
response.BadRequest(c, "仲裁结果和备注不能为空") response.BadRequest(c, "仲裁结果和备注不能为空")
return return
} }
item, err := h.service.Arbitrate(adminID, id, req, AuditMeta{ item, err := h.service.Arbitrate(adminID, id, req, auditMeta(c))
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
})
if err != nil { if err != nil {
writeDisputeError(c, err) writeDisputeError(c, err)
return return
@@ -111,6 +108,14 @@ func (h *Handler) AdminArbitrate(c *gin.Context) {
response.OK(c, item) 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) { func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID) value, ok := c.Get(middleware.ContextAdminID)
if !ok { if !ok {
+5 -10
View File
@@ -6,6 +6,7 @@ import (
"math" "math"
"time" "time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/wallet" "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 { 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) return auditlog.Append(tx, auditlog.Entry{
if err != nil {
return err
}
row := model.AuditLog{
ActorType: "admin", ActorType: "admin",
ActorID: actorID, ActorID: actorID,
Action: action, Action: action,
BizType: bizType, BizType: bizType,
BizID: &bizID, BizID: &bizID,
IP: meta.IP, Meta: meta,
UserAgent: meta.UserAgent, Detail: detail,
Detail: datatypes.JSON(raw), })
}
return tx.Create(&row).Error
} }
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
+3 -4
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"time" "time"
"hfb_sys/backend/internal/auditlog"
) )
type ListingDTO struct { type ListingDTO struct {
@@ -116,10 +118,7 @@ type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
} }
type AuditMeta struct { type AuditMeta = auditlog.Meta
IP string
UserAgent string
}
type ExternalUploadRequest struct { type ExternalUploadRequest struct {
UploadTime int64 `json:"uploadTime"` UploadTime int64 `json:"uploadTime"`
+9 -1
View File
@@ -174,7 +174,7 @@ func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActio
response.BadRequest(c, "操作原因不能为空") response.BadRequest(c, "操作原因不能为空")
return 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 { if err != nil {
writeListingError(c, err) writeListingError(c, err)
return return
@@ -182,6 +182,14 @@ func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActio
response.OK(c, item) 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) { func (h *Handler) Approve(c *gin.Context) {
id, ok := parseID(c) id, ok := parseID(c)
if !ok { if !ok {
+5 -10
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification" "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 { 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) return auditlog.Append(tx, auditlog.Entry{
if err != nil {
return err
}
row := model.AuditLog{
ActorType: "admin", ActorType: "admin",
ActorID: actorID, ActorID: actorID,
Action: action, Action: action,
BizType: bizType, BizType: bizType,
BizID: &bizID, BizID: &bizID,
IP: meta.IP, Meta: meta,
UserAgent: meta.UserAgent, Detail: detail,
Detail: datatypes.JSON(raw), })
}
return tx.Create(&row).Error
} }
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
+3 -4
View File
@@ -3,6 +3,8 @@ package order
import ( import (
"time" "time"
"hfb_sys/backend/internal/auditlog"
"gorm.io/datatypes" "gorm.io/datatypes"
) )
@@ -68,10 +70,7 @@ type AdminActionRequest struct {
Reason string `json:"reason" binding:"required"` Reason string `json:"reason" binding:"required"`
} }
type AuditMeta struct { type AuditMeta = auditlog.Meta
IP string
UserAgent string
}
type HandoffRecordDTO struct { type HandoffRecordDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
+9 -2
View File
@@ -110,13 +110,21 @@ func (h *Handler) adminAction(c *gin.Context, fn func(uint64, uint64, AdminActio
response.BadRequest(c, "操作原因不能为空") response.BadRequest(c, "操作原因不能为空")
return 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) writeOrderError(c, err)
return return
} }
response.OK(c, okData) 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) { func (h *Handler) Detail(c *gin.Context) {
userID, ok := currentUserID(c) userID, ok := currentUserID(c)
if !ok { if !ok {
@@ -375,7 +383,6 @@ func parseID(c *gin.Context) (uint64, bool) {
} }
func writeOrderError(c *gin.Context, err error) { func writeOrderError(c *gin.Context, err error) {
println("DEBUG ORDER ERROR:", err.Error())
switch { switch {
case errors.Is(err, ErrDependencyUnavailable): case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接") response.ServiceUnavailable(c, "数据库未连接")
+5 -10
View File
@@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"time" "time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/notification" "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 { 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) return auditlog.Append(tx, auditlog.Entry{
if err != nil {
return err
}
row := model.AuditLog{
ActorType: "admin", ActorType: "admin",
ActorID: actorID, ActorID: actorID,
Action: action, Action: action,
BizType: bizType, BizType: bizType,
BizID: &bizID, BizID: &bizID,
IP: meta.IP, Meta: meta,
UserAgent: meta.UserAgent, Detail: detail,
Detail: datatypes.JSON(raw), })
}
return tx.Create(&row).Error
} }
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
@@ -75,10 +75,7 @@ func (h *Handler) Update(c *gin.Context) {
response.BadRequest(c, "配置值不能为空") response.BadRequest(c, "配置值不能为空")
return return
} }
item, err := h.service.Update(adminID, key, req, AuditMeta{ item, err := h.service.Update(adminID, key, req, auditMeta(c))
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
})
if err != nil { if err != nil {
writeConfigError(c, err) writeConfigError(c, err)
return return
@@ -86,6 +83,14 @@ func (h *Handler) Update(c *gin.Context) {
response.OK(c, item) 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) { func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID) value, ok := c.Get(middleware.ContextAdminID)
if !ok { if !ok {
@@ -5,9 +5,9 @@ import (
"errors" "errors"
"strings" "strings"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -16,10 +16,7 @@ type Repository struct {
db *gorm.DB db *gorm.DB
} }
type AuditMeta struct { type AuditMeta = auditlog.Meta
IP string
UserAgent string
}
type defaultConfig struct { type defaultConfig struct {
Key string 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 { func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error {
raw, err := json.Marshal(detail) return auditlog.Append(tx, auditlog.Entry{
if err != nil {
return err
}
row := model.AuditLog{
ActorType: "admin", ActorType: "admin",
ActorID: actorID, ActorID: actorID,
Action: action, Action: action,
BizType: "system_config", BizType: "system_config",
BizID: &bizID, BizID: &bizID,
IP: meta.IP, Meta: meta,
UserAgent: meta.UserAgent, Detail: detail,
Detail: datatypes.JSON(raw), })
}
return tx.Create(&row).Error
} }
func toDTO(row model.SystemConfig) ConfigDTO { func toDTO(row model.SystemConfig) ConfigDTO {
@@ -171,7 +171,6 @@ func ensureAccount(tx *gorm.DB, userID uint64) error {
} }
func applyEntry(account *model.WalletAccount, entry Entry) (float64, 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) entry.Amount = roundWalletMoney(entry.Amount)
account.AvailableBalance = roundWalletMoney(account.AvailableBalance) account.AvailableBalance = roundWalletMoney(account.AvailableBalance)
account.FrozenBalance = roundWalletMoney(account.FrozenBalance) account.FrozenBalance = roundWalletMoney(account.FrozenBalance)
+2 -1
View File
@@ -36,8 +36,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
} }
engine := gin.New() engine := gin.New()
engine.Use(gin.Recovery()) engine.Use(middleware.RequestID())
engine.Use(middleware.RequestLogger(logger)) engine.Use(middleware.RequestLogger(logger))
engine.Use(middleware.Recovery(logger))
health := handler.NewHealthHandler() health := handler.NewHealthHandler()
engine.GET("/health", health.Check) engine.GET("/health", health.Check)