新增:pet 域埋点强类型封装(06 号规划字典 v2 三事件)

- pet_create_started / succeeded / failed 三事件(13 号规范 §3.1
  强类型惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性)
- entryPoint 三枚举(post_register_guide 预留);failureReason 四枚举
  (pet_limit_reached 因产品未设上限未纳入;5xx 与断网/超时客户端
  不可区分,并入 network_error)
- errorCode 携带业务码并推导 httpStatus(code ~/ 100);attemptSeq
  记录表单会话内提交序号
- 单元测试 4 个(属性齐备、可空属性缺席语义)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:59:32 +08:00
parent 3179528c57
commit c0a8a56e91
2 changed files with 148 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
/// pet 域埋点强类型封装(06 号规划 §1.4/§4 字典 v2 三事件;沿用
/// 13 号规范 §3.1 惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性)。
///
/// 三事件只覆盖「建宠」漏斗:`page_viewed(pet_form)` 承接到达段(路由
/// 埋点自动采集),started 在表单**首次输入**触发、每次进入记一次;
/// 编辑不设事件(06 §1.4 自觉取舍:短表单漏斗价值低于事件成本)。
/// 建宠表单入口(06 §4pet_create_started.entryPoint 枚举)。
/// post_register_guide 预留:注册后引导流尚未落地,落地时启用。
enum PetCreateEntryPoint {
profileEmptyState('profile_empty_state'),
petList('pet_list'),
postRegisterGuide('post_register_guide');
const PetCreateEntryPoint(this.value);
final String value;
}
/// 建宠失败原因(06 §4 枚举;pet_limit_reached 因产品未设上限未纳入,
/// 若后续拍板设上限随字典增补)。客户端网络层不区分 5xx 与断网/超时
/// (均为 ApiNetworkException),两者并入 network_errorserver_error
/// 保留给无法归类的兜底。
enum PetCreateFailureReason {
validationError('validation_error'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const PetCreateFailureReason(this.value);
final String value;
}
class PetAnalytics {
PetAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
/// 进入建宠表单并产生首次输入(每次进入记一次,表单层负责去重)。
void createStarted({required PetCreateEntryPoint entryPoint}) {
_track('pet_create_started', {'entryPoint': entryPoint.value});
}
/// 建宠接口成功响应(漏斗事件)。
///
/// [durationMs] 为表单打开到成功的耗时;[petIndex] 为该用户第几只宠物
/// (H2 假设的直接数据源,与 pet.pets 事实表交叉验证)。
void createSucceeded({
required int durationMs,
required PetSpecies species,
required int petIndex,
}) {
_track('pet_create_succeeded', {
'durationMs': durationMs,
'species': species.name,
'petIndex': petIndex,
});
}
/// 建宠失败:失败响应 / 超时 / 本地校验拦截。
///
/// [errorCode] 为业务错误码(本地校验/网络错误时缺席);
/// [httpStatus] 由五位业务码推导(`code ~/ 100`);
/// [attemptSeq] 为本次表单会话内第几次提交尝试(从 1 起)。
void createFailed({
required PetCreateFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('pet_create_failed', {
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
}
@@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
void main() {
late List<(String, Map<String, dynamic>?)> events;
late PetAnalytics analytics;
setUp(() {
events = [];
analytics = PetAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
test('pet_create_startedentryPoint 枚举值上报', () {
analytics.createStarted(entryPoint: PetCreateEntryPoint.profileEmptyState);
expect(events.single.$1, 'pet_create_started');
expect(events.single.$2, {'entryPoint': 'profile_empty_state'});
});
test('pet_create_succeededdurationMs/species/petIndex 三属性齐备', () {
analytics.createSucceeded(
durationMs: 1234,
species: PetSpecies.cat,
petIndex: 2,
);
expect(events.single.$1, 'pet_create_succeeded');
expect(events.single.$2, {
'durationMs': 1234,
'species': 'cat',
'petIndex': 2,
});
});
test('pet_create_failed:业务码携带 errorCode 并推导 httpStatus', () {
analytics.createFailed(
reason: PetCreateFailureReason.validationError,
attemptSeq: 2,
errorCode: 40903,
);
expect(events.single.$1, 'pet_create_failed');
expect(events.single.$2, {
'failureReason': 'validation_error',
'attemptSeq': 2,
'errorCode': 40903,
'httpStatus': 409,
});
});
test('pet_create_failed:本地校验/网络失败无 errorCode 时两可空属性缺席', () {
analytics.createFailed(
reason: PetCreateFailureReason.networkError,
attemptSeq: 1,
);
expect(events.single.$2, {
'failureReason': 'network_error',
'attemptSeq': 1,
});
});
}