feat: 错误自动上报机制
- 后端:error_reports 表 + POST /api/v1/public/errors + GET /api/v1/admin/errors - Flutter:ErrorReporter 单例,60s 去重,fire-and-forget,不影响用户 - main.dart:FlutterError 和 Zone 错误自动上报 - 打印异常:catch 块加 reportError,Windows 打印问题可自动采集堆栈 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ErrorReportHandler struct{ db *gorm.DB }
|
||||
|
||||
func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
|
||||
return &ErrorReportHandler{db: db}
|
||||
}
|
||||
|
||||
type submitErrorRequest struct {
|
||||
ErrorType string `json:"error_type" binding:"required"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Platform string `json:"platform"`
|
||||
Username string `json:"username"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
ShopNo string `json:"shop_no"`
|
||||
Role string `json:"role"`
|
||||
ErrorMsg string `json:"error_msg" binding:"required"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
|
||||
}
|
||||
|
||||
// Submit POST /api/v1/public/errors
|
||||
func (h *ErrorReportHandler) Submit(c *gin.Context) {
|
||||
var req submitErrorRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
occurredAt := time.Now()
|
||||
if req.OccurredAt > 0 {
|
||||
occurredAt = time.UnixMilli(req.OccurredAt)
|
||||
}
|
||||
|
||||
stack := req.StackTrace
|
||||
const maxStack = 4000
|
||||
if len(stack) > maxStack {
|
||||
stack = stack[:maxStack]
|
||||
}
|
||||
|
||||
report := model.ErrorReport{
|
||||
ErrorType: req.ErrorType,
|
||||
AppVersion: req.AppVersion,
|
||||
Platform: req.Platform,
|
||||
Username: req.Username,
|
||||
ShopID: req.ShopID,
|
||||
ShopNo: req.ShopNo,
|
||||
Role: req.Role,
|
||||
ClientIP: c.ClientIP(),
|
||||
ErrorMsg: req.ErrorMsg,
|
||||
StackTrace: stack,
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&report).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": report.ID})
|
||||
}
|
||||
|
||||
// List GET /api/v1/admin/errors (superadmin only)
|
||||
func (h *ErrorReportHandler) List(c *gin.Context) {
|
||||
var q struct {
|
||||
ErrorType string `form:"error_type"`
|
||||
Username string `form:"username"`
|
||||
Platform string `form:"platform"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=50"`
|
||||
}
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if q.PageSize <= 0 || q.PageSize > 200 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
|
||||
db := h.db.Model(&model.ErrorReport{})
|
||||
if q.ErrorType != "" {
|
||||
db = db.Where("error_type = ?", q.ErrorType)
|
||||
}
|
||||
if q.Username != "" {
|
||||
db = db.Where("username LIKE ?", "%"+q.Username+"%")
|
||||
}
|
||||
if q.Platform != "" {
|
||||
db = db.Where("platform = ?", q.Platform)
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
var reports []model.ErrorReport
|
||||
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&reports)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": reports,
|
||||
"total": total,
|
||||
"page": q.Page,
|
||||
"page_size": q.PageSize,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ErrorReport 客户端错误上报记录(系统级,不做多租户隔离)
|
||||
type ErrorReport struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ErrorType string `gorm:"size:30;not null;index" json:"error_type"` // flutter_error / zone_error / caught_exception
|
||||
AppVersion string `gorm:"size:30" json:"app_version"`
|
||||
Platform string `gorm:"size:20" json:"platform"` // ios/android/web/windows/macos
|
||||
Username string `gorm:"size:50;index" json:"username"`
|
||||
ShopID uint64 `gorm:"index" json:"shop_id"`
|
||||
ShopNo string `gorm:"size:50" json:"shop_no"`
|
||||
Role string `gorm:"size:20" json:"role"`
|
||||
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
|
||||
StackTrace string `gorm:"type:text" json:"stack_trace"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
publicH := handler.NewPublicHandler(db)
|
||||
adminH := handler.NewAdminHandler(db)
|
||||
shopH := handler.NewShopHandler(db)
|
||||
errorReportH := handler.NewErrorReportHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -56,6 +57,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
public.GET("/products/:public_id", publicH.GetProduct)
|
||||
public.GET("/release", publicH.GetRelease)
|
||||
public.POST("/errors", errorReportH.Submit)
|
||||
}
|
||||
|
||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||
@@ -213,6 +215,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
superAdmin.Use(middleware.SuperAdminOnly())
|
||||
{
|
||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||
superAdmin.GET("/errors", errorReportH.List)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.ProductSeriesOption{},
|
||||
&model.ProductSpecOption{},
|
||||
&model.ProductImage{},
|
||||
&model.ErrorReport{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("auto migrate failed: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user