19bd8c1810
CI / flutter-gates (push) Successful in 2m26s
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射 (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等), 未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用 - community_repository:13 路径 19 操作全覆盖;createPost/createComment 必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/ 关注走 PUT/DELETE 语义幂等 - community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射 - toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫, 点赞/收藏共用一套参数化状态机,权威终态对账收敛 - community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 + 游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态 - app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher, 登出同步 reset - 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
282 lines
8.1 KiB
Dart
282 lines
8.1 KiB
Dart
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));
|
||
}
|
||
}
|