diff --git a/lib/features/pets/pet_analytics.dart b/lib/features/pets/pet_analytics.dart new file mode 100644 index 0000000..52f988b --- /dev/null +++ b/lib/features/pets/pet_analytics.dart @@ -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 §4:pet_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_error,server_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, + }); + } +} diff --git a/test/features/pets/pet_analytics_test.dart b/test/features/pets/pet_analytics_test.dart new file mode 100644 index 0000000..f298cfb --- /dev/null +++ b/test/features/pets/pet_analytics_test.dart @@ -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?)> events; + late PetAnalytics analytics; + + setUp(() { + events = []; + analytics = PetAnalytics( + (name, [props]) async => events.add((name, props)), + ); + }); + + test('pet_create_started:entryPoint 枚举值上报', () { + analytics.createStarted(entryPoint: PetCreateEntryPoint.profileEmptyState); + + expect(events.single.$1, 'pet_create_started'); + expect(events.single.$2, {'entryPoint': 'profile_empty_state'}); + }); + + test('pet_create_succeeded:durationMs/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, + }); + }); +}