From 2dbc89b902e7ca8551dee26bd83ed01a00485bb3 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 25 May 2026 21:08:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=94=99=E8=AF=AF=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: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 --- backend/internal/handler/error_report.go | 116 +++++++++++++++++ backend/internal/model/error_report.go | 20 +++ backend/internal/router/router.go | 3 + backend/main.go | 1 + client/lib/core/errors/error_reporter.dart | 142 +++++++++++++++++++++ client/lib/core/utils/print_util_stub.dart | 10 +- client/lib/main.dart | 11 ++ 7 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 backend/internal/handler/error_report.go create mode 100644 backend/internal/model/error_report.go create mode 100644 client/lib/core/errors/error_reporter.dart diff --git a/backend/internal/handler/error_report.go b/backend/internal/handler/error_report.go new file mode 100644 index 0000000..23e8027 --- /dev/null +++ b/backend/internal/handler/error_report.go @@ -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, + }) +} diff --git a/backend/internal/model/error_report.go b/backend/internal/model/error_report.go new file mode 100644 index 0000000..e860328 --- /dev/null +++ b/backend/internal/model/error_report.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 51d2897..ecb6a45 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } } } diff --git a/backend/main.go b/backend/main.go index 2c4f8d6..1a70e07 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) diff --git a/client/lib/core/errors/error_reporter.dart b/client/lib/core/errors/error_reporter.dart new file mode 100644 index 0000000..d6f51ad --- /dev/null +++ b/client/lib/core/errors/error_reporter.dart @@ -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 _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 _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> _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 _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, + ); +} diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index 2a6b7e8..8328f65 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import '../errors/error_reporter.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; @@ -191,7 +192,8 @@ Future printProductLabelImpl({ try { await Printing.layoutPdf(onLayout: (_) async => doc.save()); - } catch (e) { + } catch (e, st) { + reportError(e, st); throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'); } } @@ -430,7 +432,8 @@ Future printStockInOrderImpl(StockInOrder order) async { )); try { await Printing.layoutPdf(onLayout: (_) async => doc.save()); - } catch (e) { + } catch (e, st) { + reportError(e, st); throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'); } } @@ -484,7 +487,8 @@ Future printStockOutOrderImpl(StockOutOrder order) async { )); try { await Printing.layoutPdf(onLayout: (_) async => doc.save()); - } catch (e) { + } catch (e, st) { + reportError(e, st); throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e'); } } diff --git a/client/lib/main.dart b/client/lib/main.dart index dd0dac1..ee56097 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'core/auth/auth_state.dart'; +import 'core/errors/error_reporter.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; import 'providers/connectivity_provider.dart'; @@ -15,6 +16,11 @@ void main() { debugPrint('═══ FlutterError ════════════════════════════'); debugPrint(details.exceptionAsString()); debugPrint(details.stack.toString()); + ErrorReporter.instance.report( + errorType: ErrorType.flutterError, + errorMsg: details.exceptionAsString(), + stackTrace: details.stack, + ); }; runZonedGuarded( @@ -23,6 +29,11 @@ void main() { debugPrint('═══ Zone Error ═══════════════════════════════'); debugPrint(error.toString()); debugPrint(stack.toString()); + ErrorReporter.instance.report( + errorType: ErrorType.zoneError, + errorMsg: error.toString(), + stackTrace: stack, + ); }, ); }