feat(日志): 新增客户端错误日志收集功能
All checks were successful
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:
shanshanzhong 2025-12-02 20:12:33 -08:00
parent 61cdc0ce23
commit f0439f4f80
15 changed files with 575 additions and 17 deletions

39
doc/说明文档.md Normal file
View File

@ -0,0 +1,39 @@
# 报错日志收集log_message
## 项目规划
- 目标:新增 `log_message` 表与采集/查询接口,用于 APP / PC / Web 客户端错误日志收集与分析。
- 范围:
- 创建 MySQL 表与迁移脚本02105
- 新增客户端上报接口 `POST /v1/common/log/message/report`
- 新增管理端查询接口 `GET /v1/admin/log/message/error/list``GET /v1/admin/log/message/error/detail`
## 实施方案
- 表结构:见 `initialize/migrate/database/02120_log_message.up.sql`
- 模型:`internal/model/logmessage/`(实体、默认 CRUD、筛选
- 路由:在 `internal/handler/routes.go` 注册公共与管理端路由。
- 逻辑:
- 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。
- 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go``getErrorLogMessageDetailLogic.go`
- 类型:`internal/types/types.go` 新增请求/响应结构。
## 进度记录
- 2025-12-02
- 完成表与索引创建迁移文件。
- 完成模型与服务注入。
- 完成公共上报接口与限流、去重逻辑;编译验证通过。
- 完成管理端列表与详情接口;编译验证通过。
- 待办:根据运营需求调整限流阈值与日志保留策略。
## 接口规范
- 上报:`POST /v1/common/log/message/report`
- 请求platform、appVersion、osName、osVersion、deviceId、level、errorCode、message、stack、context、occurredAt
- 响应:`{ code, msg, data: { id } }`
- 管理端列表:`GET /v1/admin/log/message/error/list`
- 筛选platform、level、user_id、device_id、error_code、keyword、start、end分页page、size
- 响应:`{ total, list }`
- 管理端详情:`GET /v1/admin/log/message/error/detail?id=...`
- 响应:完整字段
## 保留策略与安全
- 限流:按设备/IP 每分钟 120 条(可配置)。
- 隐私:避免采集敏感数据;服务端对大字段做长度限制与截断。

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `log_message`;

View File

@ -0,0 +1,27 @@
CREATE TABLE `log_message` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`platform` VARCHAR(32) NOT NULL,
`app_version` VARCHAR(32) NULL,
`os_name` VARCHAR(32) NULL,
`os_version` VARCHAR(32) NULL,
`device_id` VARCHAR(64) NULL,
`user_id` BIGINT NULL DEFAULT NULL,
`session_id` VARCHAR(64) NULL,
`level` TINYINT UNSIGNED NOT NULL DEFAULT 3,
`error_code` VARCHAR(64) NULL,
`message` TEXT NOT NULL,
`stack` MEDIUMTEXT NULL,
`context` JSON NULL,
`client_ip` VARCHAR(45) NULL,
`user_agent` VARCHAR(255) NULL,
`locale` VARCHAR(16) NULL,
`digest` VARCHAR(64) NULL,
`occurred_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_digest` (`digest`),
KEY `idx_platform_time` (`platform`, `created_at`),
KEY `idx_user_time` (`user_id`, `created_at`),
KEY `idx_device_time` (`device_id`, `created_at`),
KEY `idx_error_code` (`error_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

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

View File

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

View File

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

View File

@ -233,6 +233,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Filter traffic log details // Filter traffic log details
adminLogGroupRouter.GET("/traffic/details", adminLog.FilterTrafficLogDetailsHandler(serverCtx)) 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") adminMarketingGroupRouter := router.Group("/v1/admin/marketing")
@ -652,6 +658,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get Tos Content // Get Tos Content
commonGroupRouter.GET("/site/tos", common.GetTosHandler(serverCtx)) 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") publicAnnouncementGroupRouter := router.Group("/v1/public/announcement")

View File

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

View File

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

View File

@ -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 ""
}

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
}

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

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
}

View File

@ -19,6 +19,7 @@ import (
"github.com/perfect-panel/server/internal/model/coupon" "github.com/perfect-panel/server/internal/model/coupon"
"github.com/perfect-panel/server/internal/model/document" "github.com/perfect-panel/server/internal/model/document"
"github.com/perfect-panel/server/internal/model/log" "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/order"
"github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/subscribe"
@ -47,6 +48,7 @@ type ServiceContext struct {
AuthModel auth.Model AuthModel auth.Model
AdsModel ads.Model AdsModel ads.Model
LogModel log.Model LogModel log.Model
LogMessageModel logmessage.Model
NodeModel node.Model NodeModel node.Model
UserModel user.Model UserModel user.Model
OrderModel order.Model OrderModel order.Model
@ -99,6 +101,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
AuthLimiter: authLimiter, AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds), AdsModel: ads.NewModel(db, rds),
LogModel: log.NewModel(db), LogModel: log.NewModel(db),
LogMessageModel: logmessage.NewModel(db),
NodeModel: node.NewModel(db, rds), NodeModel: node.NewModel(db, rds),
AuthModel: auth.NewModel(db, rds), AuthModel: auth.NewModel(db, rds),
UserModel: user.NewModel(db, rds), UserModel: user.NewModel(db, rds),

View File

@ -1234,6 +1234,80 @@ type MessageLog struct {
CreatedAt int64 `json:"created_at"` 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 { type MigrateServerNodeResponse struct {
Succee uint64 `json:"succee"` Succee uint64 `json:"succee"`
Fail uint64 `json:"fail"` Fail uint64 `json:"fail"`