feat(日志): 新增客户端错误日志收集功能
Build docker and publish / build (20.15.1) (push) Successful in 6m50s

- 创建 log_message 表用于存储客户端错误日志
- 实现客户端日志上报接口 POST /v1/common/log/message/report
- 添加管理端日志查询接口 GET /v1/admin/log/message/error/list 和 GET /v1/admin/log/message/error/detail
- 实现日志指纹去重和限流机制
- 完善相关模型、逻辑和文档说明
This commit is contained in:
2025-12-02 20:12:33 -08:00
parent 61cdc0ce23
commit f0439f4f80
15 changed files with 575 additions and 17 deletions
@@ -0,0 +1,17 @@
package log
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/log"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
func GetErrorLogMessageDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
id := c.Query("id")
l := log.NewGetErrorLogMessageDetailLogic(c.Request.Context(), svcCtx)
resp, err := l.GetErrorLogMessageDetail(id)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,19 @@
package log
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/log"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
func GetErrorLogMessageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetErrorLogMessageListRequest
_ = c.ShouldBind(&req)
l := log.NewGetErrorLogMessageListLogic(c.Request.Context(), svcCtx)
resp, err := l.GetErrorLogMessageList(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,24 @@
package common
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
func ReportLogMessageHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.ReportLogMessageRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx)
resp, err := l.ReportLogMessage(&req, c)
result.HttpResult(c, resp, err)
}
}
+9
View File
@@ -233,6 +233,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Filter traffic log details
adminLogGroupRouter.GET("/traffic/details", adminLog.FilterTrafficLogDetailsHandler(serverCtx))
// Error message log list
adminLogGroupRouter.GET("/message/error/list", adminLog.GetErrorLogMessageListHandler(serverCtx))
// Error message log detail
adminLogGroupRouter.GET("/message/error/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx))
}
adminMarketingGroupRouter := router.Group("/v1/admin/marketing")
@@ -652,6 +658,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get Tos Content
commonGroupRouter.GET("/site/tos", common.GetTosHandler(serverCtx))
// Report client error log
commonGroupRouter.POST("/log/message/report", common.ReportLogMessageHandler(serverCtx))
}
publicAnnouncementGroupRouter := router.Group("/v1/public/announcement")
@@ -0,0 +1,49 @@
package log
import (
"context"
"strconv"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type GetErrorLogMessageDetailLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetErrorLogMessageDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetErrorLogMessageDetailLogic {
return &GetErrorLogMessageDetailLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
}
func (l *GetErrorLogMessageDetailLogic) GetErrorLogMessageDetail(idStr string) (resp *types.GetErrorLogMessageDetailResponse, err error) {
if idStr == "" { return &types.GetErrorLogMessageDetailResponse{}, nil }
id, _ := strconv.ParseInt(idStr, 10, 64)
row, err := l.svcCtx.LogMessageModel.FindOne(l.ctx, id)
if err != nil { return nil, err }
var uid int64
if row.UserId != nil { uid = *row.UserId }
var occurred int64
if row.OccurredAt != nil { occurred = row.OccurredAt.UnixMilli() }
return &types.GetErrorLogMessageDetailResponse{
Id: row.Id,
Platform: row.Platform,
AppVersion: row.AppVersion,
OsName: row.OsName,
OsVersion: row.OsVersion,
DeviceId: row.DeviceId,
UserId: uid,
SessionId: row.SessionId,
Level: row.Level,
ErrorCode: row.ErrorCode,
Message: row.Message,
Stack: row.Stack,
ClientIP: row.ClientIP,
UserAgent: row.UserAgent,
Locale: row.Locale,
OccurredAt: occurred,
CreatedAt: row.CreatedAt.UnixMilli(),
}, nil
}
@@ -0,0 +1,59 @@
package log
import (
"context"
"time"
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type GetErrorLogMessageListLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetErrorLogMessageListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetErrorLogMessageListLogic {
return &GetErrorLogMessageListLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
}
func (l *GetErrorLogMessageListLogic) GetErrorLogMessageList(req *types.GetErrorLogMessageListRequest) (resp *types.GetErrorLogMessageListResponse, err error) {
var start, end time.Time
if req.Start > 0 { start = time.UnixMilli(req.Start) }
if req.End > 0 { end = time.UnixMilli(req.End) }
rows, total, err := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{
Page: req.Page,
Size: req.Size,
Platform: req.Platform,
Level: req.Level,
UserID: req.UserId,
DeviceID: req.DeviceId,
ErrorCode: req.ErrorCode,
Keyword: req.Keyword,
Start: start,
End: end,
})
if err != nil { return nil, err }
list := make([]types.ErrorLogMessage, 0, len(rows))
for _, r := range rows {
var uid int64
if r.UserId != nil { uid = *r.UserId }
list = append(list, types.ErrorLogMessage{
Id: r.Id,
Platform: r.Platform,
AppVersion: r.AppVersion,
OsName: r.OsName,
OsVersion: r.OsVersion,
DeviceId: r.DeviceId,
UserId: uid,
SessionId: r.SessionId,
Level: r.Level,
ErrorCode: r.ErrorCode,
Message: r.Message,
CreatedAt: r.CreatedAt.UnixMilli(),
})
}
return &types.GetErrorLogMessageListResponse{ Total: total, List: list }, nil
}
@@ -0,0 +1,108 @@
package common
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net"
"strings"
"time"
"github.com/gin-gonic/gin"
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type ReportLogMessageLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewReportLogMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportLogMessageLogic {
return &ReportLogMessageLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
}
func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequest, c *gin.Context) (resp *types.ReportLogMessageResponse, err error) {
ip := clientIP(c)
ua := c.GetHeader("User-Agent")
locale := c.GetHeader("Accept-Language")
// 简单限流:设备ID优先,其次IP
limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId)
if limitKey == "logmsg:" { limitKey = "logmsg:" + ip }
count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result()
if count == 1 { _ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err() }
if count > 120 { // 每分钟最多120条
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
}
// 指纹生成
h := sha256.New()
h.Write([]byte(strings.Join([]string{req.Message, req.Stack, req.ErrorCode, req.AppVersion, req.Platform}, "|")))
digest := hex.EncodeToString(h.Sum(nil))
var ctxStr string
if req.Context != nil {
if b, e := json.Marshal(req.Context); e == nil {
ctxStr = string(b)
}
}
var occurredAt *time.Time
if req.OccurredAt > 0 {
t := time.UnixMilli(req.OccurredAt)
occurredAt = &t
}
var userIdPtr *int64
if req.UserId > 0 { userIdPtr = &req.UserId }
row := &logmessage.LogMessage{
Platform: req.Platform,
AppVersion: req.AppVersion,
OsName: req.OsName,
OsVersion: req.OsVersion,
DeviceId: req.DeviceId,
UserId: userIdPtr,
SessionId: req.SessionId,
Level: req.Level,
ErrorCode: req.ErrorCode,
Message: safeTruncate(req.Message, 1024*64),
Stack: safeTruncate(req.Stack, 1024*1024),
Context: ctxStr,
ClientIP: ip,
UserAgent: safeTruncate(ua, 255),
Locale: safeTruncate(locale, 16),
Digest: digest,
OccurredAt: occurredAt,
}
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
// 唯一指纹冲突时尝试查询已有记录返回ID
ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{ Keyword: req.Message, Page: 1, Size: 1 })
if findErr == nil && len(ex) > 0 {
return &types.ReportLogMessageResponse{ Id: ex[0].Id }, nil
}
l.Errorf("[ReportLogMessage] insert error: %v", err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err)
}
return &types.ReportLogMessageResponse{ Id: row.Id }, nil
}
func safeTruncate(s string, n int) string {
if len(s) <= n { return s }
return s[:n]
}
func clientIP(c *gin.Context) string {
ip := c.ClientIP()
if ip != "" { return ip }
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
if err == nil && host != "" { return host }
return ""
}
+50
View File
@@ -0,0 +1,50 @@
package logmessage
import (
"context"
"gorm.io/gorm"
)
type (
Model interface {
logMessageModel
customLogMessageModel
}
logMessageModel interface {
Insert(ctx context.Context, data *LogMessage) error
FindOne(ctx context.Context, id int64) (*LogMessage, error)
Update(ctx context.Context, data *LogMessage) error
Delete(ctx context.Context, id int64) error
}
customModel struct{
*defaultModel
}
defaultModel struct{
*gorm.DB
}
)
func newDefaultModel(db *gorm.DB) *defaultModel {
return &defaultModel{DB: db}
}
func (m *defaultModel) Insert(ctx context.Context, data *LogMessage) error {
return m.WithContext(ctx).Create(data).Error
}
func (m *defaultModel) FindOne(ctx context.Context, id int64) (*LogMessage, error) {
var v LogMessage
err := m.WithContext(ctx).Where("id = ?", id).First(&v).Error
if err != nil {
return nil, err
}
return &v, nil
}
func (m *defaultModel) Update(ctx context.Context, data *LogMessage) error {
return m.WithContext(ctx).Where("`id` = ?", data.Id).Save(data).Error
}
func (m *defaultModel) Delete(ctx context.Context, id int64) error {
return m.WithContext(ctx).Where("`id` = ?", id).Delete(&LogMessage{}).Error
}
+27
View File
@@ -0,0 +1,27 @@
package logmessage
import "time"
type LogMessage struct {
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
Platform string `gorm:"type:varchar(32);not null"`
AppVersion string `gorm:"type:varchar(32);default:null"`
OsName string `gorm:"type:varchar(32);default:null"`
OsVersion string `gorm:"type:varchar(32);default:null"`
DeviceId string `gorm:"type:varchar(64);default:null"`
UserId *int64 `gorm:"type:bigint;default:null"`
SessionId string `gorm:"type:varchar(64);default:null"`
Level uint8 `gorm:"type:tinyint(1);not null;default:3"`
ErrorCode string `gorm:"type:varchar(64);default:null"`
Message string `gorm:"type:text;not null"`
Stack string `gorm:"type:mediumtext;default:null"`
Context string `gorm:"type:json;default:null"`
ClientIP string `gorm:"type:varchar(45);default:null"`
UserAgent string `gorm:"type:varchar(255);default:null"`
Locale string `gorm:"type:varchar(16);default:null"`
Digest string `gorm:"type:varchar(64);uniqueIndex:uniq_digest;default:null"`
OccurredAt *time.Time `gorm:"type:datetime;default:null"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
}
func (LogMessage) TableName() string { return "log_message" }
+52
View File
@@ -0,0 +1,52 @@
package logmessage
import (
"context"
"time"
"gorm.io/gorm"
)
func NewModel(db *gorm.DB) Model {
return &customModel{ defaultModel: newDefaultModel(db) }
}
type FilterParams struct {
Page int
Size int
Platform string
Level uint8
UserID int64
DeviceID string
ErrorCode string
Keyword string
Start time.Time
End time.Time
}
type customLogMessageModel interface {
Filter(ctx context.Context, filter *FilterParams) ([]*LogMessage, int64, error)
}
func (m *customModel) Filter(ctx context.Context, filter *FilterParams) ([]*LogMessage, int64, error) {
tx := m.WithContext(ctx).Model(&LogMessage{}).Order("id DESC")
if filter == nil {
filter = &FilterParams{ Page: 1, Size: 10 }
}
if filter.Page < 1 { filter.Page = 1 }
if filter.Size < 1 { filter.Size = 10 }
if filter.Platform != "" { tx = tx.Where("`platform` = ?", filter.Platform) }
if filter.Level != 0 { tx = tx.Where("`level` = ?", filter.Level) }
if filter.UserID != 0 { tx = tx.Where("`user_id` = ?", filter.UserID) }
if filter.DeviceID != "" { tx = tx.Where("`device_id` = ?", filter.DeviceID) }
if filter.ErrorCode != "" { tx = tx.Where("`error_code` = ?", filter.ErrorCode) }
if !filter.Start.IsZero() { tx = tx.Where("`created_at` >= ?", filter.Start) }
if !filter.End.IsZero() { tx = tx.Where("`created_at` <= ?", filter.End) }
if filter.Keyword != "" {
like := "%" + filter.Keyword + "%"
tx = tx.Where("`message` LIKE ? OR `stack` LIKE ?", like, like)
}
var total int64
var rows []*LogMessage
err := tx.Count(&total).Limit(filter.Size).Offset((filter.Page-1)*filter.Size).Find(&rows).Error
return rows, total, err
}
+20 -17
View File
@@ -19,6 +19,7 @@ import (
"github.com/perfect-panel/server/internal/model/coupon"
"github.com/perfect-panel/server/internal/model/document"
"github.com/perfect-panel/server/internal/model/log"
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/model/subscribe"
@@ -44,14 +45,15 @@ type ServiceContext struct {
ExchangeRate float64
//NodeCache *cache.NodeCacheClient
AuthModel auth.Model
AdsModel ads.Model
LogModel log.Model
NodeModel node.Model
UserModel user.Model
OrderModel order.Model
ClientModel client.Model
TicketModel ticket.Model
AuthModel auth.Model
AdsModel ads.Model
LogModel log.Model
LogMessageModel logmessage.Model
NodeModel node.Model
UserModel user.Model
OrderModel order.Model
ClientModel client.Model
TicketModel ticket.Model
//ServerModel server.Model
SystemModel system.Model
CouponModel coupon.Model
@@ -96,15 +98,16 @@ func NewServiceContext(c config.Config) *ServiceContext {
Queue: NewAsynqClient(c),
ExchangeRate: 1.0,
//NodeCache: cache.NewNodeCacheClient(rds),
AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds),
LogModel: log.NewModel(db),
NodeModel: node.NewModel(db, rds),
AuthModel: auth.NewModel(db, rds),
UserModel: user.NewModel(db, rds),
OrderModel: order.NewModel(db, rds),
ClientModel: client.NewSubscribeApplicationModel(db),
TicketModel: ticket.NewModel(db, rds),
AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds),
LogModel: log.NewModel(db),
LogMessageModel: logmessage.NewModel(db),
NodeModel: node.NewModel(db, rds),
AuthModel: auth.NewModel(db, rds),
UserModel: user.NewModel(db, rds),
OrderModel: order.NewModel(db, rds),
ClientModel: client.NewSubscribeApplicationModel(db),
TicketModel: ticket.NewModel(db, rds),
//ServerModel: server.NewModel(db, rds),
SystemModel: system.NewModel(db, rds),
CouponModel: coupon.NewModel(db, rds),
+74
View File
@@ -1234,6 +1234,80 @@ type MessageLog struct {
CreatedAt int64 `json:"created_at"`
}
type ReportLogMessageRequest struct {
Platform string `json:"platform" validate:"required"`
AppVersion string `json:"appVersion"`
OsName string `json:"osName"`
OsVersion string `json:"osVersion"`
DeviceId string `json:"deviceId"`
UserId int64 `json:"userId"`
SessionId string `json:"sessionId"`
Level uint8 `json:"level"`
ErrorCode string `json:"errorCode"`
Message string `json:"message" validate:"required"`
Stack string `json:"stack"`
Context map[string]interface{} `json:"context"`
OccurredAt int64 `json:"occurredAt"`
}
type ReportLogMessageResponse struct {
Id int64 `json:"id"`
}
type GetErrorLogMessageListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Platform string `form:"platform"`
Level uint8 `form:"level"`
UserId int64 `form:"user_id"`
DeviceId string `form:"device_id"`
ErrorCode string `form:"error_code"`
Keyword string `form:"keyword"`
Start int64 `form:"start"`
End int64 `form:"end"`
}
type ErrorLogMessage struct {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
CreatedAt int64 `json:"created_at"`
}
type GetErrorLogMessageListResponse struct {
Total int64 `json:"total"`
List []ErrorLogMessage `json:"list"`
}
type GetErrorLogMessageDetailResponse struct {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Stack string `json:"stack"`
Context map[string]interface{} `json:"context"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"`
}
type MigrateServerNodeResponse struct {
Succee uint64 `json:"succee"`
Fail uint64 `json:"fail"`