Files
SkyJourney b8b63282ee fix: 清除 platform-flutter 残留代码,重构文档与开发者体验
P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值
P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表
P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图
2026-05-14 13:21:06 +08:00

161 lines
6.1 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 跳过实际上报)
/// - PII 脱敏(屏蔽手机号 / token / 身份证等敏感数据)
/// - 屏蔽 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.isRelease ? 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)'), '***');
}
}