新增: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
@@ -0,0 +1,291 @@
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/toggle_sync.dart';
/// Feed 首屏四态(同 pets 先例)。
enum FeedPhase { initial, loading, ready, error }
/// 尾部加载更多三态(游标累积流新增,pets 无此并发点)。
enum LoadMorePhase { idle, loading, error }
/// community feature 状态控制器(03 号评估 §2 分层:
/// Page/Widget → CommunityController → CommunityRepository → ApiClient)。
///
/// Tab 级单例(app.dart 装配注入主壳):Feed 是游标累积流,且详情页与
/// 首页共享同一份帖子内存副本(点赞状态跨页一致),不做页面级 state。
/// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository]
/// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。
class CommunityController extends ChangeNotifier {
CommunityController({required this._repository}) {
_likeSync = ToggleSync(
read: (id) {
final post = _postCache[id];
if (post != null) {
return ToggleReading(active: post.likedByMe, count: post.likeCount);
}
final card = _cardOrNull(id);
if (card == null) return null;
return ToggleReading(active: card.likedByMe, count: card.likeCount);
},
write: (id, active, count) =>
_writeInteraction(id, likedByMe: active, likeCount: count),
send: (id, target) async {
final state = target
? await _repository.likePost(id)
: await _repository.unlikePost(id);
return ToggleOutcome(active: state.liked, count: state.likeCount);
},
generation: () => _generation,
onError: _onToggleError,
);
_bookmarkSync = ToggleSync(
read: (id) {
final post = _postCache[id];
if (post != null) {
return ToggleReading(
active: post.bookmarkedByMe,
count: post.bookmarkCount,
);
}
final card = _cardOrNull(id);
if (card == null) return null;
return ToggleReading(
active: card.bookmarkedByMe,
count: card.bookmarkCount,
);
},
write: (id, active, count) =>
_writeInteraction(id, bookmarkedByMe: active, bookmarkCount: count),
send: (id, target) async {
final state = target
? await _repository.bookmarkPost(id)
: await _repository.unbookmarkPost(id);
return ToggleOutcome(
active: state.bookmarked,
count: state.bookmarkCount,
);
},
generation: () => _generation,
onError: _onToggleError,
);
}
final CommunityRepository _repository;
/// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。
CommunityRepository get repository => _repository;
late final ToggleSync _likeSync;
late final ToggleSync _bookmarkSync;
FeedPhase _phase = FeedPhase.initial;
LoadMorePhase _loadMorePhase = LoadMorePhase.idle;
List<FeedCard> _feed = const [];
String? _nextCursor;
bool _hasMore = false;
/// 首屏加载失败(error 态时非 null)。
ApiException? _lastError;
/// 刷新失败但旧列表被保留时的错误(页面 SnackBar 轻提示后消费)。
ApiException? _refreshError;
/// 加载更多失败(尾部重试条渲染依据)。
ApiException? _loadMoreError;
/// 最近一次点赞/收藏对账失败(T3-15/16 SnackBar 消费)。
ApiException? _toggleError;
/// 刷新代次:整体替换列表后,在途旧代次响应(尾页 / 互动对账)一律丢弃。
int _generation = 0;
/// 详情内存副本(详情页与 Feed 卡片互动状态同源)。
final Map<String, Post> _postCache = {};
bool _disposed = false;
FeedPhase get phase => _phase;
LoadMorePhase get loadMorePhase => _loadMorePhase;
/// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。
List<FeedCard> get feed => _feed;
bool get hasMore => _hasMore;
/// ready 且列表为空 → 空态。
bool get isEmpty => _phase == FeedPhase.ready && _feed.isEmpty;
ApiException? get lastError => _lastError;
ApiException? get refreshError => _refreshError;
ApiException? get loadMoreError => _loadMoreError;
ApiException? get toggleError => _toggleError;
/// 详情内存副本(进入详情页先渲染缓存,再 [getPost] 拉新)。
Post? cachedPost(String postId) => _postCache[postId];
/// 首屏加载 / 下拉刷新:丢弃游标从头拉第一页,成功后**整体替换**累积列表。
/// 刷新失败保留旧列表(不清空不闪空态),错误经 [refreshError] 轻提示;
/// 首屏(空列表)失败收敛为 error 态供页面渲染 + retry。
Future<void> refresh() async {
_generation += 1;
final generation = _generation;
_lastError = null;
_refreshError = null;
_loadMorePhase = LoadMorePhase.idle;
_loadMoreError = null;
if (_feed.isEmpty) _phase = FeedPhase.loading;
_notify();
try {
final page = await _repository.getFeed();
if (generation != _generation) return; // 期间又发生过刷新/登出。
_feed = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_phase = FeedPhase.ready;
} on ApiException catch (error) {
if (generation != _generation) return;
if (_feed.isEmpty) {
_lastError = error;
_phase = FeedPhase.error;
} else {
_refreshError = error;
}
}
_notify();
}
/// 滚动近底加载下一页:携带上一页 nextCursor(keyset 翻页不丢不重)。
/// 失败置 [LoadMorePhase.error] 渲染尾部重试条;期间发生过刷新的
/// 旧代次响应直接丢弃(避免「刷新后旧尾页追加」的重复/错位)。
Future<void> loadMore() async {
if (_phase != FeedPhase.ready ||
!_hasMore ||
_loadMorePhase == LoadMorePhase.loading) {
return;
}
final generation = _generation;
_loadMorePhase = LoadMorePhase.loading;
_loadMoreError = null;
_notify();
try {
final page = await _repository.getFeed(cursor: _nextCursor);
if (generation != _generation) return; // 旧代次尾页,丢弃。
_feed = [..._feed, ...page.items];
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadMorePhase = LoadMorePhase.idle;
} on ApiException catch (error) {
if (generation != _generation) return;
_loadMoreError = error;
_loadMorePhase = LoadMorePhase.error;
}
_notify();
}
/// 拉取帖子详情并同步内存副本(Feed 卡片互动字段一并回写)。
Future<Post> getPost(String postId) async {
final post = await _repository.getPost(postId);
_postCache[postId] = post;
_syncCardFromPost(post);
_notify();
return post;
}
/// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。
void toggleLike(String postId) => _likeSync.toggle(postId);
/// 收藏/取消收藏(与点赞同构)。
void toggleBookmark(String postId) => _bookmarkSync.toggle(postId);
/// 消费一次性 toggle 错误(SnackBar 展示后清除)。
void clearToggleError() => _toggleError = null;
/// 登出清空:回 initial 态、清多页缓存与详情副本、丢弃全部在途链,
/// 避免上一账号数据跨会话泄漏;重登后主壳重建,Feed 页重新触发 [refresh]。
void reset() {
_generation += 1;
_phase = FeedPhase.initial;
_loadMorePhase = LoadMorePhase.idle;
_feed = const [];
_nextCursor = null;
_hasMore = false;
_lastError = null;
_refreshError = null;
_loadMoreError = null;
_toggleError = null;
_postCache.clear();
_likeSync.reset();
_bookmarkSync.reset();
_notify();
}
FeedCard? _cardOrNull(String postId) {
for (final card in _feed) {
if (card.id == postId) return card;
}
return null;
}
/// 互动字段统一写入口:详情副本与 Feed 卡片同步更新后 notify(同帧反馈)。
void _writeInteraction(
String postId, {
bool? likedByMe,
int? likeCount,
bool? bookmarkedByMe,
int? bookmarkCount,
}) {
final post = _postCache[postId];
if (post != null) {
_postCache[postId] = post.copyWithInteraction(
likedByMe: likedByMe,
likeCount: likeCount,
bookmarkedByMe: bookmarkedByMe,
bookmarkCount: bookmarkCount,
);
}
final index = _feed.indexWhere((card) => card.id == postId);
if (index != -1) {
_feed = [..._feed]
..[index] = _feed[index].copyWithInteraction(
likedByMe: likedByMe,
likeCount: likeCount,
bookmarkedByMe: bookmarkedByMe,
bookmarkCount: bookmarkCount,
);
}
if (post != null || index != -1) _notify();
}
void _syncCardFromPost(Post post) {
final index = _feed.indexWhere((card) => card.id == post.id);
if (index == -1) return;
_feed = [..._feed]
..[index] = _feed[index].copyWithInteraction(
likedByMe: post.likedByMe,
likeCount: post.likeCount,
commentCount: post.commentCount,
bookmarkedByMe: post.bookmarkedByMe,
bookmarkCount: post.bookmarkCount,
);
}
void _onToggleError(String postId, Object error) {
if (error is ApiException) {
_toggleError = error;
} else {
_toggleError = ApiNetworkException('$error');
}
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,102 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// community / media 域类型化业务异常(契约 v1.3.0 定型的 9 个新错误码,
/// 20 号收口报告 §140902 乐观锁与 pets 域共码,本域映射为独立类型)。
/// 全部继承 [ApiBusinessException],既有按基类捕获的通用错误处理不受影响。
/// 40301:对可见帖子/评论无相应操作权限(改删他人已发布帖、
/// 删他人可见评论——含帖主删他人评论,D3-7 首版不做)。
final class PostAccessDeniedException extends ApiBusinessException {
const PostAccessDeniedException({required super.message})
: super(code: ApiCodes.postAccessDenied);
}
/// 40403:帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
/// (防枚举,全部情况响应完全一致;互动路径上含作者本人草稿)。
final class PostNotFoundException extends ApiBusinessException {
const PostNotFoundException({required super.message})
: super(code: ApiCodes.postNotFound);
}
/// 40404:评论不存在、已删或所属帖子不可见(防枚举合并)。
final class CommentNotFoundException extends ApiBusinessException {
const CommentNotFoundException({required super.message})
: super(code: ApiCodes.commentNotFound);
}
/// 40405media asset 不存在、非本人所有或已删(防枚举合并)。
final class MediaAssetNotFoundException extends ApiBusinessException {
const MediaAssetNotFoundException({required super.message})
: super(code: ApiCodes.mediaNotFound);
}
/// 40406:目标用户不存在或已注销(合并不泄露成因)。
final class CommunityUserNotFoundException extends ApiBusinessException {
const CommunityUserNotFoundException({required super.message})
: super(code: ApiCodes.communityUserNotFound);
}
/// 40902:帖子编辑乐观锁版本冲突(version 过期)。
/// 客户端处理:刷新详情取新 version 后重提。
final class PostVersionConflictException extends ApiBusinessException {
const PostVersionConflictException({required super.message})
: super(code: ApiCodes.versionConflict);
}
/// 40905:同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
/// 客户端每次逻辑提交应换新键,重试间保持不变。
final class IdempotencyMismatchException extends ApiBusinessException {
const IdempotencyMismatchException({required super.message})
: super(code: ApiCodes.idempotencyKeyMismatch);
}
/// 42203:引用了本人所有但非 readyuploading/failed)状态的 asset。
final class MediaNotReadyException extends ApiBusinessException {
const MediaNotReadyException({required super.message})
: super(code: ApiCodes.mediaNotReady);
}
/// 42204:自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
final class SelfFollowException extends ApiBusinessException {
const SelfFollowException({required super.message})
: super(code: ApiCodes.selfFollow);
}
/// 42205:上传状态不允许确认——对象未上传(保持 uploading 可重试)、
/// 大小/类型不符(置 failed 终态)、failed 态再确认(已 ready 幂等 200 除外)。
final class MediaUploadStateException extends ApiBusinessException {
const MediaUploadStateException({required super.message})
: super(code: ApiCodes.mediaUploadStateInvalid);
}
/// 把通用业务异常按 community/media 域错误码升格为类型化异常;
/// 未覆盖的码(40000 参数错误、40401 宠物防枚举等)原样返回,沿用通用处理。
ApiBusinessException mapCommunityBusinessException(ApiBusinessException error) {
return switch (error.code) {
ApiCodes.postAccessDenied => PostAccessDeniedException(
message: error.message,
),
ApiCodes.postNotFound => PostNotFoundException(message: error.message),
ApiCodes.commentNotFound => CommentNotFoundException(
message: error.message,
),
ApiCodes.mediaNotFound => MediaAssetNotFoundException(
message: error.message,
),
ApiCodes.communityUserNotFound => CommunityUserNotFoundException(
message: error.message,
),
ApiCodes.versionConflict => PostVersionConflictException(
message: error.message,
),
ApiCodes.idempotencyKeyMismatch => IdempotencyMismatchException(
message: error.message,
),
ApiCodes.mediaNotReady => MediaNotReadyException(message: error.message),
ApiCodes.selfFollow => SelfFollowException(message: error.message),
ApiCodes.mediaUploadStateInvalid => MediaUploadStateException(
message: error.message,
),
_ => error,
};
}
@@ -0,0 +1,630 @@
/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
/// 以便契约漂移在测试期暴露而非静默吞掉)。
library;
export 'package:patbond_flutter/core/models/cursor_page.dart';
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
for (final value in values) {
if (value.name == raw) return value;
}
throw FormatException('未知的 $field 取值:$raw');
}
DateTime? _dateTimeOrNull(Object? value) =>
value == null ? null : DateTime.parse(value as String);
/// 帖子分类。ai_creation 为 M4 预留值,仅读侧出现(M3 提交即 400/40000)。
enum PostCategory {
general('general'),
help('help'),
aiCreation('ai_creation');
const PostCategory(this.wire);
/// 契约线上取值(aiCreation 的枚举名与线上 snake_case 不同,序列化走本值)。
final String wire;
static PostCategory fromJson(String value) {
for (final category in values) {
if (category.wire == value) return category;
}
throw FormatException('未知的 category 取值:$value');
}
}
/// 帖子状态。hidden/archived(运营态)永不出现在响应(对作者与他人一律
/// 404/40403),枚举保持两值。
enum PostStatus {
draft,
published;
static PostStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 帖子可见性。M3 恒 publicfollowers/private 语义后置,字段保留)。
enum PostVisibility {
public;
static PostVisibility fromJson(String value) =>
_enumFromJson(values, value, 'visibility');
}
/// media asset 类型。M3 仅 image(视频后置,video/document 为向后新增预留)。
enum MediaKind {
image;
static MediaKind fromJson(String value) =>
_enumFromJson(values, value, 'kind');
}
/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。
enum MediaPurpose {
postImage('post_image');
const MediaPurpose(this.wire);
final String wire;
}
/// media asset 状态。deleted 态对外恒 404/40405,不出现在响应。
enum MediaAssetStatus {
uploading,
ready,
failed;
static MediaAssetStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 作者公开摘要(D3-9 方案 B)。正常路径 nickname 恒非空(空昵称由服务端
/// 回退为 username,客户端不做回退拼装);nickname 与 avatarUrl 同为 null
/// 即「降级/墓碑」形态(作者资料暂不可得或已注销),客户端只需一种占位逻辑。
class AuthorSummary {
const AuthorSummary({
required this.userId,
required this.nickname,
required this.avatarUrl,
});
factory AuthorSummary.fromJson(Map<String, dynamic> json) {
return AuthorSummary(
userId: json['userId'] as String,
nickname: json['nickname'] as String?,
// 时效性预签名 GET URL,每次响应现签,不得持久化、过期即重取。
avatarUrl: json['avatarUrl'] as String?,
);
}
final String userId;
final String? nickname;
final String? avatarUrl;
/// 降级/墓碑形态(id-only):页面渲染统一占位。
bool get isDegraded => nickname == null && avatarUrl == null;
}
/// 帖子挂接的一张图(响应形态)。url 为时效性预签名 GET(TTL 默认 1 小时),
/// 每次响应现签,客户端不得持久化、过期即重取。
class PostMediaItem {
const PostMediaItem({
required this.assetId,
required this.position,
required this.isCover,
required this.url,
required this.widthPx,
required this.heightPx,
required this.caption,
});
factory PostMediaItem.fromJson(Map<String, dynamic> json) {
return PostMediaItem(
assetId: json['assetId'] as String,
position: json['position'] as int,
isCover: json['isCover'] as bool,
url: json['url'] as String,
widthPx: json['widthPx'] as int?,
heightPx: json['heightPx'] as int?,
caption: json['caption'] as String?,
);
}
final String assetId;
final int position;
final bool isCover;
final String url;
final int? widthPx;
final int? heightPx;
final String? caption;
}
/// 帖子挂接的一张图(请求形态)。position 全给或全不给(全给须恰为 0..n-1
/// 连续不重复,混合 400/40000);isCover 至多一个 truecaption trim 后 ≤300。
class PostMediaAttachRequest {
const PostMediaAttachRequest({
required this.assetId,
this.position,
this.isCover,
this.caption,
});
final String assetId;
final int? position;
final bool? isCover;
final String? caption;
Map<String, Object?> toJson() => {
'assetId': assetId,
if (position != null) 'position': position,
if (isCover != null) 'isCover': isCover,
if (caption != null) 'caption': caption,
};
}
/// 创建帖子请求。content 必填(纯文字帖合法,media 空数组或缺席);
/// status=published 即创建即发布(服务端写 publishedAt)。
class CreatePostRequest {
const CreatePostRequest({
required this.content,
this.title,
this.category,
this.status,
this.petId,
this.media,
});
final String content;
final String? title;
final PostCategory? category;
final PostStatus? status;
final String? petId;
final List<PostMediaAttachRequest>? media;
Map<String, Object?> toJson() => {
'content': content,
if (title != null) 'title': title,
if (category != null) 'category': category!.wire,
if (status != null) 'status': status!.name,
if (petId != null) 'petId': petId,
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
};
}
/// 编辑帖子 / 发布草稿请求(部分更新:缺席字段不变,不支持清空回 null;
/// version 乐观锁必带)。
///
/// - [publish]`status: published` 状态迁移(draft→published 唯一开放迁移;
/// 对已发布帖重复提交为幂等 no-op,弱网重发不报错)。
/// - [media] 三态:null = 缺席不动;`[]` = 清空为纯文字帖;非空 = 整组替换。
class UpdatePostRequest {
const UpdatePostRequest({
required this.version,
this.title,
this.content,
this.category,
this.petId,
this.publish = false,
this.media,
});
final int version;
final String? title;
final String? content;
final PostCategory? category;
final String? petId;
final bool publish;
final List<PostMediaAttachRequest>? media;
Map<String, Object?> toJson() => {
'version': version,
if (title != null) 'title': title,
if (content != null) 'content': content,
if (category != null) 'category': category!.wire,
if (petId != null) 'petId': petId,
if (publish) 'status': PostStatus.published.name,
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
};
}
/// 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。
/// 「内容是否编辑过」以 version 为准(互动计数维护亦会推动 updatedAt)。
class Post {
const Post({
required this.id,
required this.author,
required this.petId,
required this.category,
required this.title,
required this.content,
required this.status,
required this.visibility,
required this.media,
required this.likeCount,
required this.commentCount,
required this.bookmarkCount,
required this.likedByMe,
required this.bookmarkedByMe,
required this.createdAt,
required this.updatedAt,
required this.publishedAt,
required this.version,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
petId: json['petId'] as String?,
category: PostCategory.fromJson(json['category'] as String),
title: json['title'] as String?,
content: json['content'] as String,
status: PostStatus.fromJson(json['status'] as String),
visibility: PostVisibility.fromJson(json['visibility'] as String),
media: (json['media'] as List)
.map((item) => PostMediaItem.fromJson(item as Map<String, dynamic>))
.toList(),
likeCount: json['likeCount'] as int,
commentCount: json['commentCount'] as int,
bookmarkCount: json['bookmarkCount'] as int,
likedByMe: json['likedByMe'] as bool,
bookmarkedByMe: json['bookmarkedByMe'] as bool,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
// 仅 published 非空(发布时恰写一次)。
publishedAt: _dateTimeOrNull(json['publishedAt']),
version: json['version'] as int,
);
}
final String id;
final AuthorSummary author;
final String? petId;
final PostCategory category;
final String? title;
final String content;
final PostStatus status;
final PostVisibility visibility;
final List<PostMediaItem> media;
final int likeCount;
final int commentCount;
final int bookmarkCount;
final bool likedByMe;
final bool bookmarkedByMe;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? publishedAt;
final int version;
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
Post copyWithInteraction({
int? likeCount,
int? commentCount,
int? bookmarkCount,
bool? likedByMe,
bool? bookmarkedByMe,
}) {
return Post(
id: id,
author: author,
petId: petId,
category: category,
title: title,
content: content,
status: status,
visibility: visibility,
media: media,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
likedByMe: likedByMe ?? this.likedByMe,
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
createdAt: createdAt,
updatedAt: updatedAt,
publishedAt: publishedAt,
version: version,
);
}
}
/// Feed / 收藏列表卡片形态(较 Post 裁剪:只带 coverImage + mediaCount
/// 全文恒走帖子详情端点)。publishedAt 恒非空(谓词只放行 published)。
class FeedCard {
const FeedCard({
required this.id,
required this.author,
required this.category,
required this.title,
required this.contentPreview,
required this.coverImage,
required this.mediaCount,
required this.likeCount,
required this.commentCount,
required this.bookmarkCount,
required this.likedByMe,
required this.bookmarkedByMe,
required this.publishedAt,
});
factory FeedCard.fromJson(Map<String, dynamic> json) {
final cover = json['coverImage'];
return FeedCard(
id: json['id'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
category: PostCategory.fromJson(json['category'] as String),
title: json['title'] as String?,
contentPreview: json['contentPreview'] as String,
// 封面 = 库中唯一 is_cover 行;纯文字帖为 null。
coverImage: cover == null
? null
: PostMediaItem.fromJson(cover as Map<String, dynamic>),
mediaCount: json['mediaCount'] as int,
likeCount: json['likeCount'] as int,
commentCount: json['commentCount'] as int,
bookmarkCount: json['bookmarkCount'] as int,
likedByMe: json['likedByMe'] as bool,
bookmarkedByMe: json['bookmarkedByMe'] as bool,
publishedAt: DateTime.parse(json['publishedAt'] as String),
);
}
final String id;
final AuthorSummary author;
final PostCategory category;
final String? title;
final String contentPreview;
final PostMediaItem? coverImage;
final int mediaCount;
final int likeCount;
final int commentCount;
final int bookmarkCount;
final bool likedByMe;
final bool bookmarkedByMe;
final DateTime publishedAt;
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
FeedCard copyWithInteraction({
int? likeCount,
int? commentCount,
int? bookmarkCount,
bool? likedByMe,
bool? bookmarkedByMe,
}) {
return FeedCard(
id: id,
author: author,
category: category,
title: title,
contentPreview: contentPreview,
coverImage: coverImage,
mediaCount: mediaCount,
likeCount: likeCount ?? this.likeCount,
commentCount: commentCount ?? this.commentCount,
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
likedByMe: likedByMe ?? this.likedByMe,
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
publishedAt: publishedAt,
);
}
}
/// 创建评论请求。content trim 后 1~2000replyToUserId 可选 @ 回复目标
/// (单层平铺,无楼中楼)。
class CreateCommentRequest {
const CreateCommentRequest({required this.content, this.replyToUserId});
final String content;
final String? replyToUserId;
Map<String, Object?> toJson() => {
'content': content,
if (replyToUserId != null) 'replyToUserId': replyToUserId,
};
}
/// 评论(M3 无评论编辑,不带 updatedAt)。
class PostComment {
const PostComment({
required this.id,
required this.postId,
required this.author,
required this.replyToUser,
required this.content,
required this.createdAt,
});
factory PostComment.fromJson(Map<String, dynamic> json) {
final replyTo = json['replyToUser'];
return PostComment(
id: json['id'] as String,
postId: json['postId'] as String,
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
// @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。
replyToUser: replyTo == null
? null
: AuthorSummary.fromJson(replyTo as Map<String, dynamic>),
content: json['content'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final String postId;
final AuthorSummary author;
final AuthorSummary? replyToUser;
final String content;
final DateTime createdAt;
}
/// 创建上传请求(两步上传第一步)。mimeType 白名单
/// image/jpeg|png|webpbyteSize ≤ 1048576010 MiB,服务端配置项);
/// sha256 可选(64 位小写 hex,M3 照收照存不核验)。
class CreateMediaUploadRequest {
const CreateMediaUploadRequest({
required this.kind,
required this.purpose,
required this.mimeType,
required this.byteSize,
this.sha256,
});
final MediaKind kind;
final MediaPurpose purpose;
final String mimeType;
final int byteSize;
final String? sha256;
Map<String, Object?> toJson() => {
'kind': kind.name,
'purpose': purpose.wire,
'mimeType': mimeType,
'byteSize': byteSize,
if (sha256 != null) 'sha256': sha256,
};
}
/// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后
/// 重新创建上传;直传必须原样携带 requiredHeadersContent-Type 已签进
/// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。
class MediaUploadCredentials {
const MediaUploadCredentials({
required this.assetId,
required this.uploadUrl,
required this.method,
required this.requiredHeaders,
required this.expiresAt,
});
factory MediaUploadCredentials.fromJson(Map<String, dynamic> json) {
return MediaUploadCredentials(
assetId: json['assetId'] as String,
uploadUrl: json['uploadUrl'] as String,
method: json['method'] as String,
requiredHeaders: (json['requiredHeaders'] as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value as String),
),
expiresAt: DateTime.parse(json['expiresAt'] as String),
);
}
final String assetId;
final String uploadUrl;
/// 契约定型恒为 PUT(enum 单值;直传时按本值发起请求)。
final String method;
final Map<String, String> requiredHeaders;
final DateTime expiresAt;
}
/// media assetconfirm 后的可引用形态)。url 仅 ready 态非空——时效性
/// 预签名 GET(TTL 默认 1 小时),每次响应现签,不得持久化、过期即重取。
class MediaAsset {
const MediaAsset({
required this.id,
required this.kind,
required this.purpose,
required this.mimeType,
required this.byteSize,
required this.widthPx,
required this.heightPx,
required this.status,
required this.url,
required this.readyAt,
required this.createdAt,
});
factory MediaAsset.fromJson(Map<String, dynamic> json) {
return MediaAsset(
id: json['id'] as String,
kind: MediaKind.fromJson(json['kind'] as String),
purpose: json['purpose'] as String,
mimeType: json['mimeType'] as String,
byteSize: json['byteSize'] as int?,
widthPx: json['widthPx'] as int?,
heightPx: json['heightPx'] as int?,
status: MediaAssetStatus.fromJson(json['status'] as String),
url: json['url'] as String?,
readyAt: _dateTimeOrNull(json['readyAt']),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final MediaKind kind;
final String purpose;
final String mimeType;
final int? byteSize;
final int? widthPx;
final int? heightPx;
final MediaAssetStatus status;
final String? url;
final DateTime? readyAt;
final DateTime createdAt;
}
/// 点赞权威终态(乐观更新以此对账回滚,回滚基准取响应值)。
class LikeState {
const LikeState({required this.liked, required this.likeCount});
factory LikeState.fromJson(Map<String, dynamic> json) {
return LikeState(
liked: json['liked'] as bool,
likeCount: json['likeCount'] as int,
);
}
final bool liked;
final int likeCount;
}
/// 收藏权威终态(与点赞同构)。
class BookmarkState {
const BookmarkState({required this.bookmarked, required this.bookmarkCount});
factory BookmarkState.fromJson(Map<String, dynamic> json) {
return BookmarkState(
bookmarked: json['bookmarked'] as bool,
bookmarkCount: json['bookmarkCount'] as int,
);
}
final bool bookmarked;
final int bookmarkCount;
}
/// 关注权威终态;followerCount 为目标用户的粉丝数(实时 COUNT)。
class FollowState {
const FollowState({required this.following, required this.followerCount});
factory FollowState.fromJson(Map<String, dynamic> json) {
return FollowState(
following: json['following'] as bool,
followerCount: json['followerCount'] as int,
);
}
final bool following;
final int followerCount;
}
/// 关注计数(关注数 / 粉丝数 / 我是否已关注;查自己 followedByMe 恒 false)。
class FollowStats {
const FollowStats({
required this.followerCount,
required this.followingCount,
required this.followedByMe,
});
factory FollowStats.fromJson(Map<String, dynamic> json) {
return FollowStats(
followerCount: json['followerCount'] as int,
followingCount: json['followingCount'] as int,
followedByMe: json['followedByMe'] as bool,
);
}
final int followerCount;
final int followingCount;
final bool followedByMe;
}
@@ -0,0 +1,281 @@
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/community/community_exceptions.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:uuid/uuid.dart';
/// community / media 域仓库接口(契约 v1.3.0 的 13 路径 / 19 操作全覆盖;
/// 页面依赖此抽象,widget / controller 测试注入假实现)。
///
/// media 两步上传本单只到协议层(创建上传 / 确认上传);预签名 PUT
/// 直传对象存储不走业务信封与 Bearer 鉴权,属 T3-13 独立客户端。
abstract class CommunityRepository {
// ---- media 两步上传(协议层)----
Future<MediaUploadCredentials> createMediaUpload(
CreateMediaUploadRequest request,
);
Future<MediaAsset> completeMediaUpload(String assetId);
// ---- 帖子 CRUD / 发布 ----
Future<Post> createPost(CreatePostRequest request);
Future<Post> getPost(String postId);
Future<Post> updatePost(String postId, UpdatePostRequest request);
Future<void> deletePost(String postId);
Future<CursorPage<Post>> listMyPosts({
int? limit,
String? cursor,
PostStatus? status,
});
// ---- Feedcursor 分页)----
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor});
// ---- 评论 ----
Future<CursorPage<PostComment>> listComments(
String postId, {
int? limit,
String? cursor,
});
Future<PostComment> createComment(
String postId,
CreateCommentRequest request,
);
Future<void> deleteComment(String commentId);
// ---- 点赞 / 收藏(PUT/DELETE 语义幂等,响应权威终态)----
Future<LikeState> likePost(String postId);
Future<LikeState> unlikePost(String postId);
Future<BookmarkState> bookmarkPost(String postId);
Future<BookmarkState> unbookmarkPost(String postId);
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor});
// ---- 关注 ----
Future<FollowState> followUser(String userId);
Future<FollowState> unfollowUser(String userId);
Future<FollowStats> getFollowStats(String userId);
}
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
///
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
/// `Idempotency-Key`1~128 字符;每次逻辑提交换新键;token 刷新后的
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
class ApiCommunityRepository implements CommunityRepository {
ApiCommunityRepository({required this._api, this._uuid = const Uuid()});
final ApiClient _api;
final Uuid _uuid;
Future<Object?> _request(
String path, {
String method = 'GET',
Object? body,
Map<String, Object?>? query,
bool idempotent = false,
}) async {
try {
return await _api.request(
path,
method: method,
body: body,
query: query,
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
requiresAuth: true,
);
} on ApiBusinessException catch (error) {
throw mapCommunityBusinessException(error);
}
}
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
// ---- media ----
@override
Future<MediaUploadCredentials> createMediaUpload(
CreateMediaUploadRequest request,
) async {
final data = await _request(
'/api/v1/media/uploads',
method: 'POST',
body: request.toJson(),
);
return MediaUploadCredentials.fromJson(_asMap(data));
}
@override
Future<MediaAsset> completeMediaUpload(String assetId) async {
// 幂等由服务端保证:已 ready 重复 confirm 返回 200 同一 asset。
final data = await _request(
'/api/v1/media/uploads/$assetId/complete',
method: 'POST',
);
return MediaAsset.fromJson(_asMap(data));
}
// ---- posts ----
@override
Future<Post> createPost(CreatePostRequest request) async {
final data = await _request(
'/api/v1/posts',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return Post.fromJson(_asMap(data));
}
@override
Future<Post> getPost(String postId) async {
final data = await _request('/api/v1/posts/$postId');
return Post.fromJson(_asMap(data));
}
@override
Future<Post> updatePost(String postId, UpdatePostRequest request) async {
final data = await _request(
'/api/v1/posts/$postId',
method: 'PATCH',
body: request.toJson(),
);
return Post.fromJson(_asMap(data));
}
@override
Future<void> deletePost(String postId) async {
await _request('/api/v1/posts/$postId', method: 'DELETE');
}
@override
Future<CursorPage<Post>> listMyPosts({
int? limit,
String? cursor,
PostStatus? status,
}) async {
final data = await _request(
'/api/v1/me/posts',
query: {
'limit': ?limit,
'cursor': ?cursor,
if (status != null) 'status': status.name,
},
);
return CursorPage.fromJson(_asMap(data), Post.fromJson);
}
// ---- feed ----
@override
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) async {
final data = await _request(
'/api/v1/feed',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
}
// ---- comments ----
@override
Future<CursorPage<PostComment>> listComments(
String postId, {
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/posts/$postId/comments',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), PostComment.fromJson);
}
@override
Future<PostComment> createComment(
String postId,
CreateCommentRequest request,
) async {
final data = await _request(
'/api/v1/posts/$postId/comments',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return PostComment.fromJson(_asMap(data));
}
@override
Future<void> deleteComment(String commentId) async {
// 顶层短路径先例:commentId 全局唯一。
await _request('/api/v1/comments/$commentId', method: 'DELETE');
}
// ---- interactions ----
@override
Future<LikeState> likePost(String postId) async {
final data = await _request('/api/v1/posts/$postId/like', method: 'PUT');
return LikeState.fromJson(_asMap(data));
}
@override
Future<LikeState> unlikePost(String postId) async {
final data = await _request('/api/v1/posts/$postId/like', method: 'DELETE');
return LikeState.fromJson(_asMap(data));
}
@override
Future<BookmarkState> bookmarkPost(String postId) async {
final data = await _request(
'/api/v1/posts/$postId/bookmark',
method: 'PUT',
);
return BookmarkState.fromJson(_asMap(data));
}
@override
Future<BookmarkState> unbookmarkPost(String postId) async {
final data = await _request(
'/api/v1/posts/$postId/bookmark',
method: 'DELETE',
);
return BookmarkState.fromJson(_asMap(data));
}
@override
Future<CursorPage<FeedCard>> listMyBookmarks({
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/me/bookmarks',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
}
// ---- follows ----
@override
Future<FollowState> followUser(String userId) async {
final data = await _request('/api/v1/users/$userId/follow', method: 'PUT');
return FollowState.fromJson(_asMap(data));
}
@override
Future<FollowState> unfollowUser(String userId) async {
final data = await _request(
'/api/v1/users/$userId/follow',
method: 'DELETE',
);
return FollowState.fromJson(_asMap(data));
}
@override
Future<FollowStats> getFollowStats(String userId) async {
final data = await _request('/api/v1/users/$userId/follow-stats');
return FollowStats.fromJson(_asMap(data));
}
}
+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;
}