新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
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>
This commit is contained in:
2026-09-09 12:01:25 +08:00
parent 66f983d680
commit 19bd8c1810
14 changed files with 2867 additions and 28 deletions
+142
View File
@@ -0,0 +1,142 @@
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;
}