新增: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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user