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:
wangjia
2026-05-25 21:08:03 +08:00
parent 44ffedb56a
commit 2dbc89b902
7 changed files with 300 additions and 3 deletions
+116
View File
@@ -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,
})
}
+20
View File
@@ -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"`
}
+3
View File
@@ -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)
}
}
}
+1
View File
@@ -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)
+142
View File
@@ -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,
);
}
+7 -3
View File
@@ -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<void> 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<void> 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<void> printStockOutOrderImpl(StockOutOrder order) async {
));
try {
await Printing.layoutPdf(onLayout: (_) async => doc.save());
} catch (e) {
} catch (e, st) {
reportError(e, st);
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
}
}
+11
View File
@@ -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,
);
},
);
}