新增:互动埋点与乐观更新视觉基建——字典 v3 互动八事件封装 + ToggleSync 成功上报与触点归因 + LikeButton 三层闪烁抑制动画(T3-16)

- community_interaction_analytics:post_liked/unliked、post_favorited/unfavorited、
  comment_create_succeeded/failed、user_followed/unfollowed 强类型封装(22 号
  白名单键集逐一对齐;textLengthBucket 分桶、httpStatus 由五位码推导、
  会话失效不上报);comment_create_started 锁死 unknown 不封装
- CommunityController:toggleLike/toggleBookmark 增 source 触点归因
 (feed / post_detail),成功响应后经注入的 interactionAnalytics 上报
 (乐观翻转与失败不报);adjustCommentCount 评论计数同源写入
 (详情副本与 Feed 卡片一并更新)
- LikeButton 升 Stateful:点按激活 240ms 弹性缩放 + 120ms 图标淡入、
  取消仅颜色渐出;回滚/对账零动画直接跳变、计数与状态成对更新、
  激活动画未播完等播完再跳(05 号 §3.5/§4);减弱动态设置降级瞬变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 13:38:50 +08:00
parent 8aac8c52cc
commit 92524da8e2
5 changed files with 589 additions and 16 deletions
@@ -0,0 +1,137 @@
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
/// 互动域埋点强类型封装(06 号规划 §1.4 字典 v3 互动八事件;后端白名单
/// 已随 api dev@8089c06 就绪,22 号报告 §1)。沿 pet/feed 域惯例:枚举
/// 编译期锁死,业务代码禁止手拼事件名与属性;只记行为不记内容
/// (隐私红线:postId/commentId 等内容 ID 一律不进 props)。
///
/// 点赞/收藏/关注**不埋失败**(06 §1.4 取舍:幂等写入单点交互,失败率
/// 靠服务端错误率观测);`comment_create_started` 被字典锁死 unknown
/// 不得上报(22 号 §1 末段)。
/// 互动触点来源(06 §1.4 source 枚举)。M3 接 feed / post_detail
/// user_profile / follow_list 随后续页面启用。
enum InteractionSource {
feed('feed'),
postDetail('post_detail'),
userProfile('user_profile'),
followList('follow_list');
const InteractionSource(this.value);
final String value;
}
/// 评论创建失败原因(06 §1.4 失败枚举基底)。网络归并口径同 pet/feed 域:
/// 断网/超时/5xx 均并入 network_errorserver_error 保留兜底。
enum CommentCreateFailureReason {
validationError('validation_error'),
notFound('not_found'),
rateLimited('rate_limited'),
networkError('network_error'),
serverError('server_error');
const CommentCreateFailureReason(this.value);
final String value;
}
/// 类型化异常 → 评论失败原因;会话失效返回 null(应用即将回登录页,
/// 不作为评论失败上报,feed 域同款口径)。
CommentCreateFailureReason? commentCreateFailureReasonOf(ApiException error) =>
switch (error) {
ApiNetworkException _ => CommentCreateFailureReason.networkError,
ApiRateLimitException _ => CommentCreateFailureReason.rateLimited,
SessionExpiredException _ => null,
ApiBusinessException(:final code) => switch (code) {
ApiCodes.paramError => CommentCreateFailureReason.validationError,
ApiCodes.postNotFound ||
ApiCodes.commentNotFound ||
ApiCodes.communityUserNotFound => CommentCreateFailureReason.notFound,
_ => CommentCreateFailureReason.serverError,
},
};
/// 正文规模分桶(06 §1.3 隐私红线 1:不报精确字数)。
/// empty / short(≤50) / medium(51500) / long(>500)。
String textLengthBucketOf(int length) {
if (length <= 0) return 'empty';
if (length <= 50) return 'short';
if (length <= 500) return 'medium';
return 'long';
}
class CommunityInteractionAnalytics {
CommunityInteractionAnalytics(this._track);
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
final TrackEventFn _track;
/// 点赞成功响应后(乐观翻转本身不报;单飞合并链每个实际抵达服务端
/// 并成功的状态变更各报一条,与「成功响应后」的字典口径一致)。
void postLiked({required InteractionSource source}) {
_track('post_liked', {'source': source.value});
}
/// 取消点赞成功响应后。
void postUnliked({required InteractionSource source}) {
_track('post_unliked', {'source': source.value});
}
/// 收藏成功响应后。
void postFavorited({required InteractionSource source}) {
_track('post_favorited', {'source': source.value});
}
/// 取消收藏成功响应后。
void postUnfavorited({required InteractionSource source}) {
_track('post_unfavorited', {'source': source.value});
}
/// 评论提交成功响应后。
///
/// [durationMs]:本次输入会话(首个字符输入)→ 成功响应的耗时
/// (评论不设 started 事件,时长随成功事件带出);
/// [textLength] 经 [textLengthBucketOf] 分桶后上报,精确字数不出端。
void commentCreateSucceeded({
required int durationMs,
required bool isReply,
required int textLength,
}) {
_track('comment_create_succeeded', {
'durationMs': durationMs,
'isReply': isReply,
'textLengthBucket': textLengthBucketOf(textLength),
});
}
/// 评论提交失败。
///
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
/// 业务码推导(`code ~/ 100`pet 域同款口径);[attemptSeq] 为本次
/// 输入会话内第几次提交尝试(从 1 起,成功或清空输入后重置)。
void commentCreateFailed({
required CommentCreateFailureReason reason,
required int attemptSeq,
int? errorCode,
}) {
_track('comment_create_failed', {
'failureReason': reason.value,
'attemptSeq': attemptSeq,
'errorCode': ?errorCode,
if (errorCode != null && errorCode >= 10000)
'httpStatus': errorCode ~/ 100,
});
}
/// 关注成功响应后。
void userFollowed({required InteractionSource source}) {
_track('user_followed', {'source': source.value});
}
/// 取关成功响应后。
void userUnfollowed({required InteractionSource source}) {
_track('user_unfollowed', {'source': source.value});
}
}