新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
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:
@@ -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 号收口报告 §1;40902 乐观锁与 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);
|
||||
}
|
||||
|
||||
/// 40405:media 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:引用了本人所有但非 ready(uploading/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 恒 public(followers/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 至多一个 true;caption 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~2000;replyToUserId 可选 @ 回复目标
|
||||
/// (单层平铺,无楼中楼)。
|
||||
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|webp,byteSize ≤ 10485760(10 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 分钟,过期后
|
||||
/// 重新创建上传;直传必须原样携带 requiredHeaders(Content-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 asset(confirm 后的可引用形态)。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,
|
||||
});
|
||||
|
||||
// ---- Feed(cursor 分页)----
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||
library;
|
||||
|
||||
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||
|
||||
/// 物种(创建即定,不可修改)。
|
||||
enum PetSpecies {
|
||||
dog,
|
||||
@@ -124,33 +126,6 @@ String dateToJson(DateTime date) {
|
||||
DateTime? _dateOrNull(Object? value) =>
|
||||
value == null ? null : DateTime.parse(value as String);
|
||||
|
||||
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。
|
||||
class CursorPage<T> {
|
||||
const CursorPage({
|
||||
required this.items,
|
||||
required this.nextCursor,
|
||||
required this.hasMore,
|
||||
});
|
||||
|
||||
factory CursorPage.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) itemFromJson,
|
||||
) {
|
||||
return CursorPage(
|
||||
items: (json['items'] as List)
|
||||
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
||||
nextCursor: json['nextCursor'] as String?,
|
||||
hasMore: json['hasMore'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
final List<T> items;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
}
|
||||
|
||||
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||||
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
||||
class Pet {
|
||||
|
||||
Reference in New Issue
Block a user