9892b65a19
CI / flutter-gates (push) Successful in 3m9s
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目 (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay, 删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready) - 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿 已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、 网络失败同键重放不重复建帖;40902 自动取新 version 重提一次 - 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新 一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点) - 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典 v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq; pageName 枚举补 post_form - PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 + 进度覆盖层 + 失败整格重试;页级上传汇总条 - create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts / publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态 CTA 改为 push 真实发布页 - 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态 九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控) - flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过 (选图上传 → 发布 → Feed 置顶 → 第二客户端可见) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
8.3 KiB
Dart
240 lines
8.3 KiB
Dart
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,
|
||
});
|
||
}
|
||
}
|