Template
chore: 初始化脚手架 — sunny_mochi 企业级 Flutter 模板
Core 基础设施:Flavor 三包体系、Drift+SQLCipher 加密数据库 7 拦截器 Dio 网络栈、CrashReporter 崩溃日志、Sealed Failure 错误体系 5 色板主题系统、Auth 认证骨架(Clean Architecture)、Dev Panel、Mock Adapter 通过验证:flutter analyze(0 errors)、flutter test(全绿)、staging APK 74.8MB
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sunny_mochi/core/config/env.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// 包装 [SentryFlutter.init],统一注入:
|
||||
/// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报,便于 P0 兜底)
|
||||
/// - PII 脱敏(健康类 App 强约束,详见 docs/flutter-architecture-design.md §十一.5)
|
||||
/// - 屏蔽 screenshot / view hierarchy(含敏感页面)
|
||||
///
|
||||
/// 调用方在 main.dart 包一层:
|
||||
/// ```dart
|
||||
/// await SentrySetup.init(appRunner: () => runApp(...));
|
||||
/// ```
|
||||
class SentrySetup {
|
||||
static Future<void> init({required Future<void> Function() appRunner}) async {
|
||||
if (Env.sentryDsn.isEmpty) {
|
||||
// P0 / 本地开发兜底:未配置 DSN 时直接跑应用,不绕道 Sentry init
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Sentry] DSN 为空,跳过 init(仅本地,生产必须配置)');
|
||||
}
|
||||
await appRunner();
|
||||
return;
|
||||
}
|
||||
|
||||
await SentryFlutter.init(
|
||||
(options) {
|
||||
options
|
||||
..dsn = Env.sentryDsn
|
||||
..environment = Env.name
|
||||
..tracesSampleRate = Env.isProd ? 0.2 : 1.0
|
||||
..debug = kDebugMode
|
||||
// === 健康数据 PII 脱敏(强约束)===
|
||||
..sendDefaultPii = false
|
||||
..beforeSend = scrubPii;
|
||||
},
|
||||
appRunner: appRunner,
|
||||
);
|
||||
}
|
||||
|
||||
/// 丢弃可能含敏感数据的字段,仅保留错误归因所需的元信息。
|
||||
///
|
||||
/// 脱敏覆盖:
|
||||
/// - `request.data` / `request.cookies`(可能含登录密码 / token / 手机号)
|
||||
/// - `request.headers` 仅保留白名单
|
||||
/// - `user.email` / `user.ipAddress`(仅保留匿名 userId)
|
||||
/// - `breadcrumbs.message` 内 token / 手机号 / 身份证 / 长 base64 串掩盖
|
||||
///
|
||||
/// **可见**:visible for testing — 直接调用以验证脱敏行为。
|
||||
@visibleForTesting
|
||||
static SentryEvent? scrubPii(SentryEvent event, Hint hint) {
|
||||
// Sentry 9.x:直接 mutate 字段(copyWith 已 deprecated)。
|
||||
event
|
||||
..request = _scrubRequest(event.request)
|
||||
..user = _scrubUser(event.user)
|
||||
..breadcrumbs = _scrubBreadcrumbs(event.breadcrumbs)
|
||||
// R-SEC-3 / 2026-05-10:extra 字段防御性清理 —
|
||||
// SDK 自动 instrumentation(frame tracking 等)可能写入 Widget 名称 / 路由参数,
|
||||
// 路由参数可能含手机号 / userId。用白名单过滤而非全清,保留调试上下文。
|
||||
..extra = _scrubExtra(event.extra);
|
||||
return event;
|
||||
}
|
||||
|
||||
static SentryRequest? _scrubRequest(SentryRequest? request) {
|
||||
if (request == null) return null;
|
||||
return SentryRequest(
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
// queryString / cookies / data 强制清空(可能含 token / 手机号 / 密码 / 健康数据)
|
||||
headers: _scrubHeaders(request.headers),
|
||||
apiTarget: request.apiTarget,
|
||||
);
|
||||
}
|
||||
|
||||
static SentryUser? _scrubUser(SentryUser? user) {
|
||||
if (user == null) return null;
|
||||
// 仅保留匿名 userId(要求上层调用方已哈希);email/IP 强制清空
|
||||
return SentryUser(id: user.id);
|
||||
}
|
||||
|
||||
/// 仅保留非敏感 header(白名单策略)。
|
||||
static Map<String, String>? _scrubHeaders(Map<String, String>? headers) {
|
||||
if (headers == null) return null;
|
||||
const allow = {'content-type', 'accept', 'user-agent', 'x-request-id'};
|
||||
return {
|
||||
for (final e in headers.entries)
|
||||
if (allow.contains(e.key.toLowerCase())) e.key: e.value,
|
||||
};
|
||||
}
|
||||
|
||||
/// 把 breadcrumbs 中 message 内的 token / 手机号 / 身份证 / 长 base64 串掩盖。
|
||||
static List<Breadcrumb>? _scrubBreadcrumbs(List<Breadcrumb>? crumbs) {
|
||||
if (crumbs == null) return null;
|
||||
return crumbs
|
||||
.map(
|
||||
(c) => Breadcrumb(
|
||||
timestamp: c.timestamp,
|
||||
message: c.message == null ? null : maskSensitive(c.message!),
|
||||
category: c.category,
|
||||
level: c.level,
|
||||
type: c.type,
|
||||
// data 中也可能含敏感字段,按白名单清空(仅保留 method/status_code/url 这种)
|
||||
data: _scrubBreadcrumbData(c.data),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _scrubBreadcrumbData(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
const allow = {'method', 'status_code', 'url', 'reason'};
|
||||
return {
|
||||
for (final e in data.entries)
|
||||
if (allow.contains(e.key))
|
||||
e.key: e.value is String ? maskSensitive(e.value as String) : e.value,
|
||||
};
|
||||
}
|
||||
|
||||
/// 仅保留非敏感 extra(白名单 + 字符串值掩盖)。
|
||||
///
|
||||
/// 业务侧 `Sentry.captureException(extra: {...})` 应主动避免传 PII;
|
||||
/// 此函数作为防御层,覆盖 SDK 自动写入场景。
|
||||
static Map<String, dynamic>? _scrubExtra(Map<String, dynamic>? extra) {
|
||||
if (extra == null) return null;
|
||||
// 路由 / Widget 上下文是调试有用且非 PII 的元信息,保留;
|
||||
// 其他业务自定义字段全部清除(业务方明确知道字段含义时应在 captureException 时
|
||||
// 自行 mask 后放进来,再加进 allow 列表)。
|
||||
const allow = {'route', 'route_name', 'widget', 'screen', 'flavor'};
|
||||
return {
|
||||
for (final e in extra.entries)
|
||||
if (allow.contains(e.key))
|
||||
e.key: e.value is String ? maskSensitive(e.value as String) : e.value,
|
||||
};
|
||||
}
|
||||
|
||||
/// 把字符串中的常见敏感模式替换为 `***`。
|
||||
///
|
||||
/// 模式:
|
||||
/// - 手机号:11 位 1 开头数字
|
||||
/// - 身份证:18 位(最后一位可 X)
|
||||
/// - JWT / 长 base64 token:连续 32+ 个 base64url 字符
|
||||
/// - 形如 `Bearer xxx` / `token=xxx` 的整段
|
||||
@visibleForTesting
|
||||
static String maskSensitive(String input) {
|
||||
return input
|
||||
// Bearer / Authorization-style
|
||||
.replaceAll(
|
||||
RegExp(
|
||||
r'(?:bearer\s+|token[=:]\s*|access_token[=:]\s*|refresh_token[=:]\s*)[A-Za-z0-9._\-+/=]{12,}',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'***',
|
||||
)
|
||||
// 长 base64 / JWT
|
||||
.replaceAll(RegExp(r'[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}'), '***')
|
||||
// 中国大陆手机号
|
||||
.replaceAll(RegExp(r'(?<!\d)1[3-9]\d{9}(?!\d)'), '***')
|
||||
// 身份证号
|
||||
.replaceAll(RegExp(r'(?<!\d)\d{17}[\dXx](?!\d)'), '***');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:sunny_mochi/core/config/env.dart' show Env;
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
|
||||
/// 全局 Talker 单例,作为应用日志门面。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// import 'package:sunny_mochi/core/observability/talker_setup.dart';
|
||||
///
|
||||
/// appTalker.info('hello');
|
||||
/// appTalker.error('boom', e, stackTrace);
|
||||
/// ```
|
||||
///
|
||||
/// **Release 包准入**(合规底线):[TalkerSettings.enabled] = `kDebugMode || INTERNAL_BUILD`,
|
||||
/// 用户线 Release 包硬关闭日志记录与设备调试面板(避免敏感数据/PII 写入设备)。
|
||||
/// 详见 docs/flutter-architecture-design.md §十一.5。
|
||||
final Talker appTalker = TalkerFlutter.init(
|
||||
settings: TalkerSettings(
|
||||
enabled: kDebugMode || Env.isInternalBuild,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user