a4a97c03c2
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码 demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。 - `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落 显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」 也编码成 null,用户只改昵称就会连头像一起被服务端清掉。 - `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username` **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值 预填,避免把展示约定固化成真实昵称。 - `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级 这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。 - 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口, 未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。 - 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐; 纯空白是校验失败而非隐式清空。 - `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet` (单图、预览确认后才交付 ready assetId,孤儿防护不变)。 - 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository` 此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被 触发;本单是第一个真实消费者,桌面实测即暴露。 测试 526 → 588(+62)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
316 lines
9.4 KiB
Dart
316 lines
9.4 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 / 发布 ----
|
||
/// [idempotencyKey]:调用方持键(T3-17 发布页「同键重放」——网络失败重试
|
||
/// 沿用同键命中服务端首帖,不重复建帖;缺省则本层每次调用换新键)。
|
||
Future<Post> createPost(CreatePostRequest request, {String? idempotencyKey});
|
||
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);
|
||
|
||
// ---- 我的社区数字(主体恒为调用者,路径上无 userId)----
|
||
Future<CommunityStats> getMyCommunityStats();
|
||
}
|
||
|
||
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
|
||
///
|
||
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
|
||
/// `Idempotency-Key`(1~128 字符;每次逻辑提交换新键;token 刷新后的
|
||
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
|
||
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
|
||
///
|
||
/// 端口线路:community 域端点走 community 服务客户端;media 两步上传
|
||
/// 端点由 **user 服务**提供(13 号报告 §2,MediaController 在
|
||
/// patbond-user),经 [mediaApi] 直连——T3-13 真链路实测修正,
|
||
/// 未提供时回落主客户端(既有测试桩场景)。
|
||
class ApiCommunityRepository implements CommunityRepository {
|
||
ApiCommunityRepository({
|
||
required ApiClient api,
|
||
ApiClient? mediaApi,
|
||
this._uuid = const Uuid(),
|
||
}) : _api = api,
|
||
_mediaApi = mediaApi ?? api;
|
||
|
||
final ApiClient _api;
|
||
final ApiClient _mediaApi;
|
||
final Uuid _uuid;
|
||
|
||
Future<Object?> _request(
|
||
String path, {
|
||
String method = 'GET',
|
||
Object? body,
|
||
Map<String, Object?>? query,
|
||
bool idempotent = false,
|
||
String? idempotencyKey,
|
||
bool media = false,
|
||
}) async {
|
||
try {
|
||
return await (media ? _mediaApi : _api).request(
|
||
path,
|
||
method: method,
|
||
body: body,
|
||
query: query,
|
||
headers: idempotent
|
||
? {'Idempotency-Key': idempotencyKey ?? _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(),
|
||
media: true,
|
||
);
|
||
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',
|
||
media: true,
|
||
);
|
||
return MediaAsset.fromJson(_asMap(data));
|
||
}
|
||
|
||
// ---- posts ----
|
||
|
||
@override
|
||
Future<Post> createPost(
|
||
CreatePostRequest request, {
|
||
String? idempotencyKey,
|
||
}) async {
|
||
final data = await _request(
|
||
'/api/v1/posts',
|
||
method: 'POST',
|
||
body: request.toJson(),
|
||
idempotent: true,
|
||
idempotencyKey: idempotencyKey,
|
||
);
|
||
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));
|
||
}
|
||
|
||
// ---- 我的社区数字 ----
|
||
|
||
@override
|
||
Future<CommunityStats> getMyCommunityStats() async {
|
||
final data = await _request('/api/v1/me/community-stats');
|
||
return CommunityStats.fromJson(_asMap(data));
|
||
}
|
||
}
|