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:
SkyJourney
2026-05-14 12:51:05 +08:00
commit 61017f1c39
204 changed files with 15386 additions and 0 deletions
+160
View File
@@ -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-10extra 字段防御性清理 —
// SDK 自动 instrumentationframe 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)'), '***');
}
}
+22
View File
@@ -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,
),
);