app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
@@ -0,0 +1,10 @@
/// 客户端埋点。来源:conti-docs/13-observability-analytics.md §三。
///
/// 边界约定:业务代码只用 [Analytics] 接口 + `AnalyticsEvent` 常量,
/// **不直接 import 神策 SDK,也不在调用处写事件名字面量**。
library;
export 'src/analytics.dart';
export 'src/analytics_event.dart';
export 'src/noop_analytics.dart';
export 'src/providers.dart';
@@ -0,0 +1,30 @@
/// 埋点门面。来源:13 §三「实现约定:神策 SDK,外面包一层」。
library;
/// 业务代码唯一可见的埋点接口。
///
/// 理由和 `CrashReporter` 一样:测试里能 mock`feature_*` 不多一条对三方
/// SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案
/// 先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件。
///
/// 硬约束:**埋点失败绝不能影响业务**。实现类的每个方法内部都要 try-catch
/// 兜住,任何异常只记日志不外抛。
abstract interface class Analytics {
/// 上报一个事件。[event] 必须取自 `AnalyticsEvent` 常量,不允许字面量。
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]);
/// 注册超级属性(公共属性),注册一次后全局附加。
///
/// `storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点
/// 自己传一定会漏。**门店切换后必须重新注册**(见 11 的级联清单)。
void registerSuperProperties(Map<String, Object?> props);
/// 登录成功后用后端的 `userId` 关联匿名 ID。
void identify(String userId);
/// 登出时断开关联。
///
/// 不调的话,同一台设备上换人登录的数据会串到一起——**门店设备是共用的,
/// 这个场景一定会发生**。
void reset();
}
@@ -0,0 +1,148 @@
/// 客户端事件名与参数名常量表。来源:13 §三「客户端事件表」「命名约定」。
///
/// **客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由
/// 客户端上报。** 按这条筛下来只剩下面这几个——其余 6 类 PRD §22.1 事件
/// (登录、首页曝光、门店切换、采购下单、入库、待办点击)全部由后端从请求
/// 日志出,客户端不重复报。
///
/// 命名:`snake_case``对象_动作` 或 `对象_动作_结果`。结果类用过去式
/// `succeeded` / `failed`),动作类用现在式(`clicked`)。
///
/// **事件名和参数名一旦上线不再改**——改名意味着历史数据断裂,运营报表要
/// 重做。要加维度就加参数。
library;
/// 事件名常量。调用处禁止写字符串字面量:拼写错误编译期发现不了,
/// 在报表里表现为"这个事件怎么没数据"。
abstract final class AnalyticsEvent {
/// 扫码成功。参数:[AnalyticsParam.mode]、[AnalyticsParam.durationMs]。
static const String scanSucceeded = 'scan_succeeded';
/// 扫码失败。参数:[AnalyticsParam.mode]、[AnalyticsParam.failReason]。
///
/// 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到。
static const String scanFailed = 'scan_failed';
/// H5 页关闭。参数:[AnalyticsParam.target]、[AnalyticsParam.stayDurationMs]。
static const String h5Closed = 'h5_closed';
/// H5 白屏 / 超时 / 加载失败。
///
/// 参数:[AnalyticsParam.target]、[AnalyticsParam.errorCode]、
/// [AnalyticsParam.elapsedMs]、[AnalyticsParam.traceId]。
/// 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败
/// 后端看不到**。
static const String h5Failed = 'h5_failed';
/// H5 首屏完成。
///
/// 参数:[AnalyticsParam.target]、[AnalyticsParam.ticketMs]、
/// [AnalyticsParam.loadMs]。
/// **耗时必须拆成两段**:合成一个数字的话,慢了不知道该找 App Backend /
/// F6 / 还是网络——这是这个 App 里最长的一条跨系统链路。
static const String h5FirstPaint = 'h5_first_paint';
/// 客服入口点击。参数:[AnalyticsParam.channel]。
static const String supportClicked = 'support_clicked';
/// 请求失败。
///
/// 参数:[AnalyticsParam.path]、[AnalyticsParam.code]、
/// [AnalyticsParam.httpStatus]、[AnalyticsParam.traceId]。
/// 虽然后端也能看到失败,但**后端看不到"请求根本没发出去"和"响应没收到"**
/// 超时、连接失败、DNS 失败、运营商劫持。门店网络不稳时这类占大头。
static const String apiFailed = 'api_failed';
/// 冷启动完成。参数:[AnalyticsParam.durationMs]。
static const String appColdStart = 'app_cold_start';
/// 登出。参数:[AnalyticsParam.reason]。
///
/// **被动登出没有对应的接口调用**(见 11),所以这一条必须客户端报。
static const String logout = 'logout';
/// 冷启动恢复会话失败。参数:[AnalyticsParam.stage]。
///
/// 卡在读 secure storage 时不产生任何网络请求。
static const String sessionRestoreFailed = 'session_restore_failed';
/// 后端下发了本端路由表里没有的菜单编码(04 §菜单编码到路由的映射)。
///
/// 参数:[AnalyticsParam.code]。
/// 这条事件是**新功能灰度期唯一的可见信号**:后端配了菜单、App 还没发版,
/// 客户端的处理是隐藏该入口——不报的话,现场表现为"菜单配了但看不见",
/// 而两边都以为是对方的问题。后端从请求日志里看不到"客户端没渲染"。
static const String menuCodeUnsupported = 'menu_code_unsupported';
}
/// 事件参数名常量。同样禁止字面量。
abstract final class AnalyticsParam {
/// 扫码模式:`barcode` / `vin` / `plate`。
static const String mode = 'mode';
/// 耗时(毫秒)。
static const String durationMs = 'durationMs';
/// 失败原因。
static const String failReason = 'failReason';
/// H5 业务标识(不是 URL——URL 的 query 里带票据)。
static const String target = 'target';
/// H5 页面停留时长(毫秒)。
static const String stayDurationMs = 'stayDurationMs';
/// 错误码。
static const String errorCode = 'errorCode';
/// 从开始到失败经过的时间(毫秒)。
static const String elapsedMs = 'elapsedMs';
/// 换票耗时(毫秒)。
static const String ticketMs = 'ticketMs';
/// 页面加载耗时(毫秒)。
static const String loadMs = 'loadMs';
/// 链路 ID,与后端 ELK 对齐(见 05)。
static const String traceId = 'traceId';
/// 客服渠道:`hotline` / `dealer` / `o2o`。
static const String channel = 'channel';
/// 接口路径(不含 query)。
static const String path = 'path';
/// 业务错误码。
static const String code = 'code';
/// HTTP 状态码。
static const String httpStatus = 'httpStatus';
/// 登出原因:`userInitiated` / `tokenExpired` / `sessionRevoked`。
static const String reason = 'reason';
/// 会话恢复失败的阶段:`storage` / `me` / `stores`。
static const String stage = 'stage';
}
/// 超级属性(公共属性)的 key。
///
/// 这五个由 `registerSuperProperties` 注册一次全局附加,**不在每个调用点
/// 手写**。门店切换后必须重新注册。
abstract final class AnalyticsSuperProperty {
/// 当前门店 ID。
static const String storeId = 'storeId';
/// 当前角色码。
static const String roleCode = 'roleCode';
/// 环境:dev / uat / prod。
static const String flavor = 'flavor';
/// 版本名。
static const String appVersion = 'appVersion';
/// 构建号。
static const String buildNumber = 'buildNumber';
}
@@ -0,0 +1,61 @@
/// 埋点的空实现与调试实现。
library;
import 'analytics.dart';
/// 什么都不做的埋点实现。
///
/// **神策的采购尚未落地**(见 13 待确认项:公司有没有在用的神策服务)。
/// 接口先定、事件方案先做,这两块工作量与最终用什么 SDK 无关;真接上
/// `sensors_analytics_flutter_plugin` 时只新增一个实现类并改
/// `analyticsProvider` 的 override,调用点一行不动。
class NoopAnalytics implements Analytics {
/// 创建一个什么都不做的埋点实现。
const NoopAnalytics();
@override
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]) {}
@override
void registerSuperProperties(Map<String, Object?> props) {}
@override
void identify(String userId) {}
@override
void reset() {}
}
/// 把事件收集到内存里,供测试断言和 dev 下人工核对。
///
/// **不要在 prod 用**:它只涨不清,且事件参数里可能有业务信息。
class RecordingAnalytics implements Analytics {
/// 创建一个记录型埋点实现。
RecordingAnalytics();
/// 已记录的事件,按调用顺序。
final List<({String event, Map<String, Object?> params})> events =
<({String event, Map<String, Object?> params})>[];
/// 当前已注册的超级属性合集。
final Map<String, Object?> superProperties = <String, Object?>{};
/// 最后一次 [identify] 传入的 userId[reset] 后为 null。
String? currentUserId;
@override
void track(String event, [Map<String, Object?> params = const <String, Object?>{}]) =>
events.add((event: event, params: Map<String, Object?>.of(params)));
@override
void registerSuperProperties(Map<String, Object?> props) => superProperties.addAll(props);
@override
void identify(String userId) => currentUserId = userId;
@override
void reset() {
currentUserId = null;
superProperties.clear();
}
}
@@ -0,0 +1,17 @@
/// core_analytics 的 Riverpod 接线。
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'analytics.dart';
import 'noop_analytics.dart';
/// 全局埋点入口。
///
/// 默认 [NoopAnalytics]。接入神策后由 `app/` 的 `bootstrap()` override——
/// 且必须在**用户同意隐私政策之后**才初始化 SDK(见 13 待确认项),
/// 所以 override 的时机由 `feature_auth` 的协议弹窗流程决定,不能无条件放在
/// 启动最早期。
final Provider<Analytics> analyticsProvider = Provider<Analytics>(
(Ref ref) => const NoopAnalytics(),
);
+19
View File
@@ -0,0 +1,19 @@
name: core_analytics
description: 埋点接口与事件常量。具体 SDK(神策)待定,当前提供 Noop 实现。
publish_to: none
version: 0.1.0
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
core_foundation: ^0.1.0
flutter:
sdk: flutter
flutter_riverpod: ^3.3.2
dev_dependencies:
flutter_lints: ^6.0.0
flutter_test:
sdk: flutter
@@ -0,0 +1,77 @@
import 'package:core_analytics/core_analytics.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AnalyticsEvent 命名约定', () {
// 13 §三:snake_case,结果类过去式,动作类现在式。事件名一旦上线不再改,
// 所以这条测试的作用是在「加新事件」时挡住拼写风格漂移。
const List<String> all = <String>[
AnalyticsEvent.scanSucceeded,
AnalyticsEvent.scanFailed,
AnalyticsEvent.h5Closed,
AnalyticsEvent.h5Failed,
AnalyticsEvent.h5FirstPaint,
AnalyticsEvent.supportClicked,
AnalyticsEvent.apiFailed,
AnalyticsEvent.appColdStart,
AnalyticsEvent.logout,
AnalyticsEvent.sessionRestoreFailed,
];
test('全部是 snake_case', () {
for (final String e in all) {
expect(
RegExp(r'^[a-z][a-z0-9]*(_[a-z0-9]+)*$').hasMatch(e),
isTrue,
reason: '$e 不是 snake_case',
);
}
});
test('没有重名', () {
expect(all.toSet().length, all.length);
});
test('客户端事件表就是这 10 条', () {
// 多一条少一条都要先回到 13 §三的判据:这件事会不会产生一次后端请求?
expect(all.length, 10);
});
});
group('NoopAnalytics', () {
test('所有方法都不抛异常', () {
const Analytics analytics = NoopAnalytics();
expect(
() => analytics
..track(AnalyticsEvent.appColdStart)
..registerSuperProperties(<String, Object?>{'storeId': 1})
..identify('u1')
..reset(),
returnsNormally,
);
});
});
group('RecordingAnalytics', () {
test('记录事件与参数副本', () {
final RecordingAnalytics analytics = RecordingAnalytics();
final Map<String, Object?> params = <String, Object?>{AnalyticsParam.mode: 'vin'};
analytics.track(AnalyticsEvent.scanSucceeded, params);
params[AnalyticsParam.mode] = 'plate';
expect(analytics.events.single.event, AnalyticsEvent.scanSucceeded);
// 存的是副本,调用方后续修改不会污染已记录的事件。
expect(analytics.events.single.params[AnalyticsParam.mode], 'vin');
});
test('reset 同时清掉用户与超级属性', () {
final RecordingAnalytics analytics = RecordingAnalytics()
..identify('u1')
..registerSuperProperties(<String, Object?>{AnalyticsSuperProperty.storeId: 7})
..reset();
expect(analytics.currentUserId, isNull);
expect(analytics.superProperties, isEmpty);
});
});
}