新增:首页 Feed 接入真实数据——四态/尾部三态/聚合曝光埋点,社区 demo 消亡第一页(T3-14)
CI / flutter-gates (push) Successful in 2m41s
CI / flutter-gates (push) Successful in 2m41s
- home_page Feed segment 改接 CommunityController:骨架屏首载、空态 CTA、 错误横幅重试、下拉刷新(失败保留旧列表 + SnackBar)、触底游标翻页 (失败态只走显式重试,不随滚动风暴自动重打) - PostCard 三形态(单图通栏 4:3 / 多图折叠封面 +N 角标 / 纯文字 6 行)+ PostMediaGrid(列数规则 + ink 80% scrim 精算)+ LikeButton(error 族修订 Colors.red,T3-14 纯展示禁用态,ToggleSync 接线留 T3-15/16)+ FeedSkeleton (呼吸动效尊重减弱动态设置);降级作者统一「宠友」占位 - SignedNetworkImage:预签名 URL 缓存 key 剥离 X-Amz-* 签名参数, RemoteImage 全仓换用(同图不同签名命中同一缓存条目) - feed 域埋点:feed_viewed 聚合曝光(浏览段开/结算于切 Tab、切分段、 退后台、销毁;≥50% 可见 ≥500ms 段内按帖去重,postId 不上报)+ feed_load_failed(refresh/load_more 双路,会话失效不报) - integration_test/feed_live_test.dart:compose 六容器 Linux 桌面真链路 实测入口(PATBOND_FEED_LIVE=1 门控,默认跳过) - 测试 379 → 421(+42):四态/尾部三态/翻页不丢不重/降级作者/曝光结算 时机/缓存 key/事件属性全覆盖;analyze 0,format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||
|
||||
/// 降级作者([AuthorSummary.isDegraded],资料暂不可得或已注销)的
|
||||
/// 统一占位名(05 号规范:占位头像 + 默认名,客户端不做昵称回退拼装)。
|
||||
const degradedAuthorName = '宠友';
|
||||
|
||||
/// 作者展示名:正常路径 nickname 恒非空(服务端已回退 username);
|
||||
/// 降级形态统一 [degradedAuthorName]。
|
||||
String authorDisplayName(AuthorSummary author) =>
|
||||
author.nickname ?? degradedAuthorName;
|
||||
|
||||
/// Feed 卡片元信息的相对时间(正典「2 小时前」语言)。
|
||||
/// [now] 注入口供测试锁定时钟。
|
||||
String feedRelativeTime(DateTime time, {DateTime? now}) {
|
||||
final reference = now ?? DateTime.now();
|
||||
final difference = reference.difference(time.toLocal());
|
||||
if (difference.inMinutes < 1) return '刚刚';
|
||||
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
|
||||
if (difference.inDays < 1) return '${difference.inHours} 小时前';
|
||||
if (difference.inDays < 7) return '${difference.inDays} 天前';
|
||||
final local = time.toLocal();
|
||||
if (local.year == reference.year) return '${local.month}月${local.day}日';
|
||||
return '${local.year}年${local.month}月${local.day}日';
|
||||
}
|
||||
|
||||
/// Feed 加载失败的用户话术(pets 域 petLoadErrorMessage 同构;
|
||||
/// 服务端原始 message 不上屏)。
|
||||
String feedLoadErrorMessage(ApiException? error) => switch (error) {
|
||||
ApiNetworkException _ => '网络异常,请检查网络后重试',
|
||||
ApiRateLimitException _ => '请求过于频繁,请稍后再试',
|
||||
_ => '动态加载失败,请稍后重试',
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:patbond_flutter/analytics/page_view_tracker.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
|
||||
/// feed 域埋点强类型封装(06 号规划 §1.4 字典 v3;后端白名单已随
|
||||
/// api dev@8089c06 就绪)。沿 pet_analytics 惯例:枚举编译期锁死,
|
||||
/// 业务代码禁止手拼事件名与属性;只记行为不记内容(隐私红线:postId
|
||||
/// 等内容 ID 一律不进 props,曝光去重键只存活于客户端内存)。
|
||||
|
||||
/// feed_viewed / feed_load_failed 的 feedTab 枚举(06 §1.4)。
|
||||
/// M3 T3-14 仅接 home;topic / user_posts / favorites 随后续页面启用。
|
||||
enum FeedTab {
|
||||
home('home'),
|
||||
topic('topic'),
|
||||
userPosts('user_posts'),
|
||||
favorites('favorites');
|
||||
|
||||
const FeedTab(this.value);
|
||||
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// feed_load_failed 的 loadType 枚举。
|
||||
enum FeedLoadType {
|
||||
refresh('refresh'),
|
||||
loadMore('load_more');
|
||||
|
||||
const FeedLoadType(this.value);
|
||||
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// feed 加载失败原因(06 v3 失败枚举基底)。网络归并口径同 pet 域:
|
||||
/// 断网/超时/5xx 均并入 network_error,server_error 保留兜底。
|
||||
enum FeedLoadFailureReason {
|
||||
rateLimited('rate_limited'),
|
||||
networkError('network_error'),
|
||||
serverError('server_error');
|
||||
|
||||
const FeedLoadFailureReason(this.value);
|
||||
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 类型化异常 → 失败原因;会话失效返回 null(应用即将回登录页,
|
||||
/// 不作为 Feed 加载失败上报)。
|
||||
FeedLoadFailureReason? feedLoadFailureReasonOf(ApiException error) =>
|
||||
switch (error) {
|
||||
ApiNetworkException _ => FeedLoadFailureReason.networkError,
|
||||
ApiRateLimitException _ => FeedLoadFailureReason.rateLimited,
|
||||
SessionExpiredException _ => null,
|
||||
_ => FeedLoadFailureReason.serverError,
|
||||
};
|
||||
|
||||
class FeedAnalytics {
|
||||
FeedAnalytics(this._track);
|
||||
|
||||
/// 生产传 `AnalyticsService.trackEvent`,测试传录制桩。
|
||||
final TrackEventFn _track;
|
||||
|
||||
/// 一个 Feed 浏览段的聚合曝光(06 §1.2 裁定):离开 Feed(路由跳走 /
|
||||
/// 退后台)时发一条,携带段内曝光卡片数(≥50% 可见 ≥500ms、按帖去重
|
||||
/// 后的**计数**)、翻页数、刷新数与前台停留时长。
|
||||
void feedViewed({
|
||||
required FeedTab feedTab,
|
||||
required int durationMs,
|
||||
required int impressionCount,
|
||||
required int loadMoreCount,
|
||||
required int refreshCount,
|
||||
}) {
|
||||
_track('feed_viewed', {
|
||||
'feedTab': feedTab.value,
|
||||
'durationMs': durationMs,
|
||||
'impressionCount': impressionCount,
|
||||
'loadMoreCount': loadMoreCount,
|
||||
'refreshCount': refreshCount,
|
||||
});
|
||||
}
|
||||
|
||||
/// 刷新或翻页请求失败(M3 验收「分页不丢失不重复」的客户端观测点)。
|
||||
///
|
||||
/// [errorCode] 为业务错误码(网络错误时缺席);[httpStatus] 由五位
|
||||
/// 业务码推导(`code ~/ 100`,pet 域同款口径)。
|
||||
void feedLoadFailed({
|
||||
required FeedTab feedTab,
|
||||
required FeedLoadType loadType,
|
||||
required FeedLoadFailureReason reason,
|
||||
int? errorCode,
|
||||
}) {
|
||||
_track('feed_load_failed', {
|
||||
'feedTab': feedTab.value,
|
||||
'loadType': loadType.value,
|
||||
'failureReason': reason.value,
|
||||
'errorCode': ?errorCode,
|
||||
if (errorCode != null && errorCode >= 10000)
|
||||
'httpStatus': errorCode ~/ 100,
|
||||
});
|
||||
}
|
||||
|
||||
/// [feedLoadFailed] 的异常直通口:会话失效不上报。
|
||||
void feedLoadFailedFrom(
|
||||
ApiException error, {
|
||||
required FeedTab feedTab,
|
||||
required FeedLoadType loadType,
|
||||
}) {
|
||||
final reason = feedLoadFailureReasonOf(error);
|
||||
if (reason == null) return;
|
||||
feedLoadFailed(
|
||||
feedTab: feedTab,
|
||||
loadType: loadType,
|
||||
reason: reason,
|
||||
errorCode: error is ApiBusinessException ? error.code : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:patbond_flutter/features/community/feed_analytics.dart';
|
||||
|
||||
/// 一个「Feed 浏览段」的聚合器(06 号规划 §1.2 裁定的客户端实现)。
|
||||
///
|
||||
/// 段生命周期由页面驱动:进入 Feed(Tab 激活且分段在 Feed)开段;
|
||||
/// 离开(切 Tab / 切分段 / 退后台 / 页面销毁)时 [settle] 结算并经
|
||||
/// [FeedAnalytics.feedViewed] 发**一条**聚合事件。
|
||||
///
|
||||
/// - 曝光判定:卡片可见面积 ≥ [visibleThreshold] 且持续 ≥ [dwell],
|
||||
/// 段内按 postId 去重;postId 只作内存去重键、绝不上报(隐私红线 2),
|
||||
/// 段结束即弃。
|
||||
/// - durationMs 为前台时长(退后台即结算,段天然前台连续),上限
|
||||
/// 截断 30 分钟防挂机污染(06 §1.4 实现注意)。
|
||||
class FeedViewSegment {
|
||||
FeedViewSegment({DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now,
|
||||
_settled = false {
|
||||
_startedAt = _now();
|
||||
}
|
||||
|
||||
static const visibleThreshold = 0.5;
|
||||
static const dwell = Duration(milliseconds: 500);
|
||||
static const maxDuration = Duration(minutes: 30);
|
||||
|
||||
final DateTime Function() _now;
|
||||
late final DateTime _startedAt;
|
||||
|
||||
final Set<String> _impressed = <String>{};
|
||||
final Map<String, Timer> _dwellTimers = <String, Timer>{};
|
||||
int _loadMoreCount = 0;
|
||||
int _refreshCount = 0;
|
||||
bool _settled;
|
||||
|
||||
/// 段内曝光卡片数(已去重;仅测试与结算读取)。
|
||||
int get impressionCount => _impressed.length;
|
||||
|
||||
/// 可见性回报:≥50% 起 500ms 驻留计时,跌破或滚出视口即取消;
|
||||
/// 驻留满即记曝光(去重后不再计时)。
|
||||
void updateVisibility(String postId, double visibleFraction) {
|
||||
if (_settled) return;
|
||||
if (visibleFraction >= visibleThreshold) {
|
||||
if (_impressed.contains(postId) || _dwellTimers.containsKey(postId)) {
|
||||
return;
|
||||
}
|
||||
_dwellTimers[postId] = Timer(dwell, () {
|
||||
_dwellTimers.remove(postId);
|
||||
_impressed.add(postId);
|
||||
});
|
||||
} else {
|
||||
_dwellTimers.remove(postId)?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户触发的下拉刷新 / 失败重试(首屏自动预取不计)。
|
||||
void recordRefresh() {
|
||||
if (!_settled) _refreshCount += 1;
|
||||
}
|
||||
|
||||
/// 触底翻页请求(含尾部失败重试)。
|
||||
void recordLoadMore() {
|
||||
if (!_settled) _loadMoreCount += 1;
|
||||
}
|
||||
|
||||
/// 结算:取消在途驻留计时并冻结计数;幂等(重复调用返回 null,
|
||||
/// 保证一段恰好一条 feed_viewed)。
|
||||
FeedViewSummary? settle() {
|
||||
if (_settled) return null;
|
||||
_settled = true;
|
||||
for (final timer in _dwellTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_dwellTimers.clear();
|
||||
final elapsed = _now().difference(_startedAt);
|
||||
return FeedViewSummary(
|
||||
durationMs: min(elapsed.inMilliseconds, maxDuration.inMilliseconds),
|
||||
impressionCount: _impressed.length,
|
||||
loadMoreCount: _loadMoreCount,
|
||||
refreshCount: _refreshCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [FeedViewSegment.settle] 的结算结果(feed_viewed 专有属性)。
|
||||
class FeedViewSummary {
|
||||
const FeedViewSummary({
|
||||
required this.durationMs,
|
||||
required this.impressionCount,
|
||||
required this.loadMoreCount,
|
||||
required this.refreshCount,
|
||||
});
|
||||
|
||||
final int durationMs;
|
||||
final int impressionCount;
|
||||
final int loadMoreCount;
|
||||
final int refreshCount;
|
||||
|
||||
/// 结算即上报的便捷口。
|
||||
void report(FeedAnalytics analytics, {FeedTab feedTab = FeedTab.home}) {
|
||||
analytics.feedViewed(
|
||||
feedTab: feedTab,
|
||||
durationMs: durationMs,
|
||||
impressionCount: impressionCount,
|
||||
loadMoreCount: loadMoreCount,
|
||||
refreshCount: refreshCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user