import 'package:patbond_flutter/analytics/page_view_tracker.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/features/community/community_interaction_analytics.dart' show textLengthBucketOf; /// post 域埋点强类型封装(06 号规划 §1.4 发布漏斗五事件 + 媒体上传三段; /// 后端白名单随 api dev@`8089c06` 就绪,22 号报告 §1 键集逐一对齐)。 /// 沿 pet/feed/互动域惯例:枚举编译期锁死,业务代码禁止手拼事件名与属性。 /// /// 隐私纪律(06 §1.3 红线):正文只出分桶不出字数(红线 1);postId / /// assetId 等内容 ID 一律不进 props(红线 2);媒体只报 [MediaType] 与 /// [mediaSizeBucketOf] 分桶,文件名/路径/URL 禁止(红线 4)。 /// /// **不得上报**(22 号 §1 末段锁死为 unknown):`post_impression`、 /// `post_viewed`、`post_like_failed` 等——本文件不提供其封装。 /// 发帖入口(06 §1.4 `post_create_started.entryPoint`)。 /// M3 接 create_tab / feed;topic_detail / pet_detail 随后续页面启用。 enum PostEntryPoint { createTab('create_tab'), feed('feed'), topicDetail('topic_detail'), petDetail('pet_detail'); const PostEntryPoint(this.value); final String value; } /// 草稿保存触发方式(06 §1.4:**自动保存不埋**,防高频)。 enum DraftSaveTrigger { /// 「存草稿」按钮显式保存。 manual('manual'), /// 离开发布页时经「保留草稿?」确认保存。 onExit('on_exit'); const DraftSaveTrigger(this.value); final String value; } /// 发布失败原因(06 §1.4 枚举;`content_rejected` 待拍板未启用, /// `not_found` 取 §1.4「失败枚举基底」的复用条——草稿已被别处删除)。 enum PostPublishFailureReason { validationError('validation_error'), mediaUploadIncomplete('media_upload_incomplete'), notFound('not_found'), rateLimited('rate_limited'), networkError('network_error'), serverError('server_error'); const PostPublishFailureReason(this.value); final String value; } /// 媒体类型(M3 仅 image;video 随视频能力启用)。 enum MediaType { image('image'), video('video'); const MediaType(this.value); final String value; } /// 单文件上传失败原因(06 §1.4 媒体漏斗枚举)。 enum MediaUploadFailureReason { mediaTooLarge('media_too_large'), unsupportedFormat('unsupported_format'), networkError('network_error'), serverError('server_error'), /// 用户在上传途中删格 / 离开发布页作废在途任务。 cancelled('cancelled'); const MediaUploadFailureReason(this.value); final String value; } /// 类型化异常 → 发布失败原因;会话失效返回 null(应用即将回登录页, /// 不作为发布失败上报,feed / 互动域同款口径)。 /// /// 映射取舍(26 号报告 §3 有对照表):42203 恰为 /// [PostPublishFailureReason.mediaUploadIncomplete];40905(同键异 /// payload)归 validation_error——提交内容与幂等键不一致属提交侧问题, /// 非服务端故障;40902(乐观锁,自动刷新 version 重提仍失败)归 /// server_error 兜底。 PostPublishFailureReason? postPublishFailureReasonOf(ApiException error) => switch (error) { ApiNetworkException _ => PostPublishFailureReason.networkError, ApiRateLimitException _ => PostPublishFailureReason.rateLimited, SessionExpiredException _ => null, ApiBusinessException(:final code) => switch (code) { ApiCodes.paramError || ApiCodes.idempotencyKeyMismatch => PostPublishFailureReason.validationError, ApiCodes.mediaNotReady => PostPublishFailureReason.mediaUploadIncomplete, ApiCodes.postNotFound || ApiCodes.mediaNotFound => PostPublishFailureReason.notFound, _ => PostPublishFailureReason.serverError, }, }; /// 媒体大小分桶(06 §1.3 红线 4:不报精确字节数)。 /// `lt_1mb` / `mb_1_5` / `mb_5_20` / `gte_20mb`,以 MiB 为界 /// (与 MediaUploader 的 10 MiB 上限同一进制)。 String mediaSizeBucketOf(int byteSize) { const mib = 1024 * 1024; if (byteSize < mib) return 'lt_1mb'; if (byteSize < 5 * mib) return 'mb_1_5'; if (byteSize < 20 * mib) return 'mb_5_20'; return 'gte_20mb'; } /// 发布漏斗 + 媒体上传三段埋点(22 号白名单 v3 事件 22~29)。 class PostAnalytics { PostAnalytics(this._track); /// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。 final TrackEventFn _track; // ---- 发布漏斗 ---- /// 进入发布页并产生**首次输入**(首个字符或首次选媒体),每次进入记一次。 /// 草稿恢复不算输入(非用户动作,不上报)。 void postCreateStarted({required PostEntryPoint entryPoint}) { _track('post_create_started', {'entryPoint': entryPoint.value}); } /// 草稿保存**成功响应后**;仅显式保存与离开时保存(自动保存不埋)。 void postDraftSaved({ required DraftSaveTrigger trigger, required int mediaCount, }) { _track('post_draft_saved', { 'trigger': trigger.value, 'mediaCount': mediaCount, }); } /// 发布成功响应后(漏斗事件,H5/H6 核心数据源)。 /// /// [durationMs]:`post_create_started` → 发布成功;[textLength] 经 /// [textLengthBucketOf] 分桶后上报,精确字数不出端;[fromDraft] 指 /// 「本次发布基于先前保存/恢复的草稿」(发布内部的建草稿→迁移两步 /// 不算,见 26 号报告 §3)。 void postPublishSucceeded({ required int durationMs, required int mediaCount, required int topicCount, required int textLength, required bool fromDraft, }) { _track('post_publish_succeeded', { 'durationMs': durationMs, 'mediaCount': mediaCount, 'topicCount': topicCount, 'textLengthBucket': textLengthBucketOf(textLength), 'fromDraft': fromDraft, }); } /// 发布失败 / 超时 / 本地校验拦截。 /// /// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位业务码 /// 推导(`code ~/ 100`,pet 域同款);[attemptSeq] 为本次发布会话内第几次 /// 尝试(从 1 起,发布成功或离开发布页后重置)。 void postPublishFailed({ required PostPublishFailureReason reason, required int attemptSeq, int? errorCode, }) { _track('post_publish_failed', { 'failureReason': reason.value, 'attemptSeq': attemptSeq, 'errorCode': ?errorCode, if (errorCode != null && errorCode >= 10000) 'httpStatus': errorCode ~/ 100, }); } /// 删帖成功响应后(单事件风格,无专有属性;失败靠服务端错误率观测)。 /// M3 触点:发布页「不保留草稿」删除服务端草稿。 void postDeleted() { _track('post_deleted', const {}); } // ---- 媒体上传三段(逐文件)---- /// 单个文件开始上传(一次尝试恰一条;重试各记一条)。 /// /// [byteSize] 取**选图原文件**字节数,保证同一次尝试三段事件的 /// `sizeBucket` 一致(压缩产物大小不另开一套桶)。 void mediaUploadStarted({ required MediaType mediaType, required int byteSize, }) { _track('post_media_upload_started', { 'mediaType': mediaType.value, 'sizeBucket': mediaSizeBucketOf(byteSize), }); } /// 单文件上传成功(confirm 返回 ready 后)。 /// [durationMs]:本次尝试 started → ready。 void mediaUploadSucceeded({ required MediaType mediaType, required int byteSize, required int durationMs, }) { _track('post_media_upload_succeeded', { 'mediaType': mediaType.value, 'sizeBucket': mediaSizeBucketOf(byteSize), 'durationMs': durationMs, }); } /// 单文件失败 / 超时 / 用户取消。 void mediaUploadFailed({ required MediaType mediaType, required int byteSize, required MediaUploadFailureReason reason, required int attemptSeq, int? errorCode, }) { _track('post_media_upload_failed', { 'mediaType': mediaType.value, 'sizeBucket': mediaSizeBucketOf(byteSize), 'failureReason': reason.value, 'attemptSeq': attemptSeq, 'errorCode': ?errorCode, if (errorCode != null && errorCode >= 10000) 'httpStatus': errorCode ~/ 100, }); } }