19bd8c1810
CI / flutter-gates (push) Successful in 2m26s
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射 (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等), 未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用 - community_repository:13 路径 19 操作全覆盖;createPost/createComment 必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/ 关注走 PUT/DELETE 语义幂等 - community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射 - toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫, 点赞/收藏共用一套参数化状态机,权威终态对账收敛 - community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 + 游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态 - app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher, 登出同步 reset - 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
143 lines
4.9 KiB
Dart
143 lines
4.9 KiB
Dart
import 'dart:async';
|
||
|
||
/// 服务端返回的开关权威终态(LikeState / BookmarkState 的统一投影)。
|
||
class ToggleOutcome {
|
||
const ToggleOutcome({required this.active, required this.count});
|
||
|
||
final bool active;
|
||
final int count;
|
||
}
|
||
|
||
/// 内存副本当前读数(乐观翻转与回滚校验的基准)。
|
||
class ToggleReading {
|
||
const ToggleReading({required this.active, required this.count});
|
||
|
||
final bool active;
|
||
final int count;
|
||
}
|
||
|
||
/// 点赞 / 收藏共用的乐观更新小状态机(03 号评估 §3 定稿的数据层部分):
|
||
/// **乐观翻转 + 快照回滚 + 单飞合并意图 + 代次守卫**。字段读写与端点
|
||
/// 全部参数化,like / bookmark 各持一实例,不复制两份逻辑。
|
||
///
|
||
/// 对每个 id 的一轮「操作链」:
|
||
/// 1. 点击立即经 [write] 翻转内存副本(UI 同帧反馈由持有方 notify);
|
||
/// 2. 非在途则记快照、发请求(PUT/DELETE 语义幂等,重放安全);
|
||
/// 在途则只把新意图并入 pendingTarget,**不发新请求**(单飞);
|
||
/// 3. 成功:pendingTarget 与已确认态不一致 → 以 pendingTarget 补发一次
|
||
/// (连续快速点击至多两个在途请求,中间抖动全被合并);一致 → 用服务端
|
||
/// 权威计数覆盖乐观计数(吸收他人并发造成的偏差),清状态;
|
||
/// 4. 失败:恢复链起点快照(先校验 id 仍可读且当前态仍是本轮乐观目标,
|
||
/// 避免覆盖新数据),经 [onError] 轻提示,**不自动重试**;
|
||
/// 5. 代次守卫:响应到达时 [generation] 与链起点不符(期间发生过刷新,
|
||
/// 列表已被服务端数据整体替换)→ 丢弃该响应,不覆盖不回滚。
|
||
class ToggleSync {
|
||
ToggleSync({
|
||
required this._read,
|
||
required this._write,
|
||
required this._send,
|
||
required this._generation,
|
||
this._onError,
|
||
});
|
||
|
||
final ToggleReading? Function(String id) _read;
|
||
final void Function(String id, bool active, int count) _write;
|
||
final Future<ToggleOutcome> Function(String id, bool target) _send;
|
||
final int Function() _generation;
|
||
final void Function(String id, Object error)? _onError;
|
||
|
||
final Map<String, _ToggleChain> _chains = {};
|
||
|
||
/// 该 id 是否有请求在途(测试与调试观测口)。
|
||
bool isInFlight(String id) => _chains.containsKey(id);
|
||
|
||
/// 翻转一次。同步完成乐观写入;网络往返在后台收敛,不外抛。
|
||
void toggle(String id) {
|
||
final current = _read(id);
|
||
if (current == null) return; // 已不在列表(刷新剔除),本次点击作废。
|
||
final target = !current.active;
|
||
final optimisticCount = target
|
||
? current.count + 1
|
||
: (current.count - 1 < 0 ? 0 : current.count - 1);
|
||
_write(id, target, optimisticCount);
|
||
|
||
final chain = _chains[id];
|
||
if (chain != null) {
|
||
chain.pendingTarget = target;
|
||
return;
|
||
}
|
||
final started = _ToggleChain(
|
||
snapshot: current,
|
||
generation: _generation(),
|
||
target: target,
|
||
);
|
||
_chains[id] = started;
|
||
unawaited(_run(id, started));
|
||
}
|
||
|
||
/// 登出 / 整体刷新清态:丢弃全部链(在途响应因代次或链失配被丢弃)。
|
||
void reset() => _chains.clear();
|
||
|
||
Future<void> _run(String id, _ToggleChain chain) async {
|
||
while (true) {
|
||
ToggleOutcome outcome;
|
||
try {
|
||
outcome = await _send(id, chain.target);
|
||
} catch (error) {
|
||
if (_chains[id] == chain && _generation() == chain.generation) {
|
||
final current = _read(id);
|
||
final lastTarget = chain.pendingTarget ?? chain.target;
|
||
// 回滚前校验:id 仍可读且当前态仍是本轮乐观写入的目标态。
|
||
if (current != null && current.active == lastTarget) {
|
||
_write(id, chain.snapshot.active, chain.snapshot.count);
|
||
}
|
||
_onError?.call(id, error);
|
||
}
|
||
_release(id, chain);
|
||
return;
|
||
}
|
||
|
||
if (_chains[id] != chain || _generation() != chain.generation) {
|
||
_release(id, chain);
|
||
return; // 代次不符 / 已被 reset:丢弃响应,不覆盖不回滚。
|
||
}
|
||
|
||
final pending = chain.pendingTarget;
|
||
if (pending != null && pending != outcome.active) {
|
||
chain.target = pending;
|
||
chain.pendingTarget = null;
|
||
continue; // 以最终意图补发一次。
|
||
}
|
||
|
||
_write(id, outcome.active, outcome.count);
|
||
_release(id, chain);
|
||
return;
|
||
}
|
||
}
|
||
|
||
void _release(String id, _ToggleChain chain) {
|
||
if (_chains[id] == chain) _chains.remove(id);
|
||
}
|
||
}
|
||
|
||
/// 一轮操作链的在途状态。
|
||
class _ToggleChain {
|
||
_ToggleChain({
|
||
required this.snapshot,
|
||
required this.generation,
|
||
required this.target,
|
||
});
|
||
|
||
/// 链起点快照(回滚基准)。
|
||
final ToggleReading snapshot;
|
||
|
||
/// 链起点的刷新代次。
|
||
final int generation;
|
||
|
||
/// 当前在途请求的目标态。
|
||
bool target;
|
||
|
||
/// 在途期间用户新点出的最终意图(完成后据此决定是否补发)。
|
||
bool? pendingTarget;
|
||
}
|