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)
|
publicH := handler.NewPublicHandler(db)
|
||||||
adminH := handler.NewAdminHandler(db)
|
adminH := handler.NewAdminHandler(db)
|
||||||
shopH := handler.NewShopHandler(db)
|
shopH := handler.NewShopHandler(db)
|
||||||
|
errorReportH := handler.NewErrorReportHandler(db)
|
||||||
|
|
||||||
// 健康检查(无需认证,用于前端连通性探测)
|
// 健康检查(无需认证,用于前端连通性探测)
|
||||||
r.GET("/health", func(c *gin.Context) {
|
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("/products/:public_id", publicH.GetProduct)
|
||||||
public.GET("/release", publicH.GetRelease)
|
public.GET("/release", publicH.GetRelease)
|
||||||
|
public.POST("/errors", errorReportH.Submit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
// 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作)
|
||||||
@@ -213,6 +215,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
superAdmin.Use(middleware.SuperAdminOnly())
|
superAdmin.Use(middleware.SuperAdminOnly())
|
||||||
{
|
{
|
||||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||||
|
superAdmin.GET("/errors", errorReportH.List)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func autoMigrate(db *gorm.DB) {
|
|||||||
&model.ProductSeriesOption{},
|
&model.ProductSeriesOption{},
|
||||||
&model.ProductSpecOption{},
|
&model.ProductSpecOption{},
|
||||||
&model.ProductImage{},
|
&model.ProductImage{},
|
||||||
|
&model.ErrorReport{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("auto migrate failed: %v", err)
|
log.Fatalf("auto migrate failed: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
|
class ErrorType {
|
||||||
|
static const flutterError = 'flutter_error';
|
||||||
|
static const zoneError = 'zone_error';
|
||||||
|
static const caughtException = 'caught_exception';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorReporter {
|
||||||
|
ErrorReporter._();
|
||||||
|
static final ErrorReporter instance = ErrorReporter._();
|
||||||
|
|
||||||
|
static const _dedupWindowMs = 60 * 1000;
|
||||||
|
static const _maxStackLength = 3000;
|
||||||
|
|
||||||
|
final Map<String, int> _recentMessages = {};
|
||||||
|
Dio? _dio;
|
||||||
|
String? _cachedVersion;
|
||||||
|
|
||||||
|
Dio get _client {
|
||||||
|
_dio ??= Dio(BaseOptions(
|
||||||
|
baseUrl: AppConfig.apiBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
receiveTimeout: const Duration(seconds: 5),
|
||||||
|
));
|
||||||
|
return _dio!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _getVersion() async {
|
||||||
|
if (_cachedVersion != null) return _cachedVersion!;
|
||||||
|
try {
|
||||||
|
final info = await PackageInfo.fromPlatform();
|
||||||
|
_cachedVersion = info.version;
|
||||||
|
} catch (_) {
|
||||||
|
_cachedVersion = 'unknown';
|
||||||
|
}
|
||||||
|
return _cachedVersion!;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPlatform() {
|
||||||
|
if (kIsWeb) return 'web';
|
||||||
|
try {
|
||||||
|
if (Platform.isIOS) return 'ios';
|
||||||
|
if (Platform.isAndroid) return 'android';
|
||||||
|
if (Platform.isMacOS) return 'macos';
|
||||||
|
if (Platform.isWindows) return 'windows';
|
||||||
|
if (Platform.isLinux) return 'linux';
|
||||||
|
} catch (_) {}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _getUserFields() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return {
|
||||||
|
'username': prefs.getString('username') ?? '',
|
||||||
|
'shop_id': int.tryParse(prefs.getString('shop_id') ?? '') ?? 0,
|
||||||
|
'shop_no': prefs.getString('shop_no') ?? '',
|
||||||
|
'role': prefs.getString('role') ?? '',
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return {'username': '', 'shop_id': 0, 'shop_no': '', 'role': ''};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isDuplicate(String message) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final lastTime = _recentMessages[message];
|
||||||
|
if (lastTime != null && now - lastTime < _dedupWindowMs) return true;
|
||||||
|
_recentMessages[message] = now;
|
||||||
|
if (_recentMessages.length > 100) {
|
||||||
|
_recentMessages.removeWhere((_, t) => now - t >= _dedupWindowMs);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void report({
|
||||||
|
required String errorType,
|
||||||
|
required String errorMsg,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) {
|
||||||
|
if (_isDuplicate(errorMsg)) return;
|
||||||
|
unawaited(_doReport(
|
||||||
|
errorType: errorType,
|
||||||
|
errorMsg: errorMsg,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doReport({
|
||||||
|
required String errorType,
|
||||||
|
required String errorMsg,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final version = await _getVersion();
|
||||||
|
final userFields = await _getUserFields();
|
||||||
|
|
||||||
|
String? stack;
|
||||||
|
if (stackTrace != null) {
|
||||||
|
final raw = stackTrace.toString();
|
||||||
|
stack = raw.length > _maxStackLength ? raw.substring(0, _maxStackLength) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _client.post('/public/errors', data: {
|
||||||
|
'error_type': errorType,
|
||||||
|
'app_version': version,
|
||||||
|
'platform': _getPlatform(),
|
||||||
|
'username': userFields['username'],
|
||||||
|
'shop_id': userFields['shop_id'],
|
||||||
|
'shop_no': userFields['shop_no'],
|
||||||
|
'role': userFields['role'],
|
||||||
|
'error_msg': errorMsg,
|
||||||
|
'stack_trace': stack ?? '',
|
||||||
|
'occurred_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// 上报失败静默处理,不影响用户
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 便捷顶层函数,供 catch 块一行调用
|
||||||
|
void reportError(
|
||||||
|
Object error,
|
||||||
|
StackTrace? stack, {
|
||||||
|
String errorType = ErrorType.caughtException,
|
||||||
|
}) {
|
||||||
|
ErrorReporter.instance.report(
|
||||||
|
errorType: errorType,
|
||||||
|
errorMsg: error.toString(),
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../errors/error_reporter.dart';
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
import 'package:printing/printing.dart';
|
import 'package:printing/printing.dart';
|
||||||
@@ -191,7 +192,8 @@ Future<void> printProductLabelImpl({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
reportError(e, st);
|
||||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,7 +432,8 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
|||||||
));
|
));
|
||||||
try {
|
try {
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
reportError(e, st);
|
||||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,7 +487,8 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
|||||||
));
|
));
|
||||||
try {
|
try {
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
reportError(e, st);
|
||||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
import 'core/auth/auth_state.dart';
|
import 'core/auth/auth_state.dart';
|
||||||
|
import 'core/errors/error_reporter.dart';
|
||||||
import 'core/router/app_router.dart';
|
import 'core/router/app_router.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'providers/connectivity_provider.dart';
|
import 'providers/connectivity_provider.dart';
|
||||||
@@ -15,6 +16,11 @@ void main() {
|
|||||||
debugPrint('═══ FlutterError ════════════════════════════');
|
debugPrint('═══ FlutterError ════════════════════════════');
|
||||||
debugPrint(details.exceptionAsString());
|
debugPrint(details.exceptionAsString());
|
||||||
debugPrint(details.stack.toString());
|
debugPrint(details.stack.toString());
|
||||||
|
ErrorReporter.instance.report(
|
||||||
|
errorType: ErrorType.flutterError,
|
||||||
|
errorMsg: details.exceptionAsString(),
|
||||||
|
stackTrace: details.stack,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
@@ -23,6 +29,11 @@ void main() {
|
|||||||
debugPrint('═══ Zone Error ═══════════════════════════════');
|
debugPrint('═══ Zone Error ═══════════════════════════════');
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
debugPrint(stack.toString());
|
debugPrint(stack.toString());
|
||||||
|
ErrorReporter.instance.report(
|
||||||
|
errorType: ErrorType.zoneError,
|
||||||
|
errorMsg: error.toString(),
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user