diff --git a/lib/app/app.dart b/lib/app/app.dart index 1b5aed3..37f5237 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -12,6 +12,8 @@ import 'package:patbond_flutter/features/auth/auth_repository.dart'; import 'package:patbond_flutter/features/auth/login_page.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:patbond_flutter/features/auth/splash_page.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:patbond_flutter/features/main/main_shell_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.dart'; import 'package:patbond_flutter/features/pets/pet_analytics.dart'; @@ -25,12 +27,14 @@ class App extends StatefulWidget { this.sessionManager, this.authRepository, this.petsRepository, + this.communityRepository, }); /// 测试注入口;生产默认走安全存储 + 真实 API。 final SessionManager? sessionManager; final AuthRepository? authRepository; final PetsRepository? petsRepository; + final CommunityRepository? communityRepository; @override State createState() => _AppState(); @@ -41,6 +45,7 @@ class _AppState extends State { late final SessionManager sessionManager; late final AuthRepository authRepository; late final PetsController petsController; + late final CommunityController communityController; late final PetAnalytics petAnalytics; late final HealthRecordAnalytics healthRecordAnalytics; late final SessionTracker _sessionTracker; @@ -88,6 +93,10 @@ class _AppState extends State { petsController = PetsController( repository: widget.petsRepository ?? _buildPetsRepository(), ); + // T3-12 只装配数据层;Feed segment 的 UI 接线在 T3-14 挂入主壳。 + communityController = CommunityController( + repository: widget.communityRepository ?? _buildCommunityRepository(), + ); petAnalytics = PetAnalytics(_analytics.trackEvent); healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent); @@ -143,6 +152,20 @@ class _AppState extends State { return ApiPetsRepository(api: api); } + /// community 服务分端口直连(:8084),token 刷新单飞经共享 [TokenRefresher]。 + CommunityRepository _buildCommunityRepository() { + final dio = buildPatbondDio( + session: sessionManager, + baseUrl: patbondCommunityApiBaseUrl, + ); + final api = ApiClient( + dio: dio, + session: sessionManager, + refresher: _ensureRefresher(), + ); + return ApiCommunityRepository(api: api); + } + AnalyticsPageName? _resolveRootPage() { // 回栈到无名根路由时解析当前认证状态页/主壳 Tab return switch (sessionManager.status) { @@ -153,9 +176,10 @@ class _AppState extends State { } void _reportAuthStateChange() { - // 登出即清宠物档案内存副本(跨账号不泄漏;重登后列表页重新拉取)。 + // 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。 if (sessionManager.status == AuthStatus.unauthenticated) { petsController.reset(); + communityController.reset(); } // 认证状态机切页补点(03 §3.2 非路由曝光 1/2) final page = switch (sessionManager.status) { @@ -173,6 +197,7 @@ class _AppState extends State { WidgetsBinding.instance.removeObserver(_sessionTracker); appState.dispose(); petsController.dispose(); + communityController.dispose(); if (widget.sessionManager == null) sessionManager.dispose(); super.dispose(); } diff --git a/lib/core/models/cursor_page.dart b/lib/core/models/cursor_page.dart new file mode 100644 index 0000000..24005d2 --- /dev/null +++ b/lib/core/models/cursor_page.dart @@ -0,0 +1,27 @@ +/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(pets 域体重 / 健康事件, +/// community 域 Feed / 我的帖子 / 评论 / 收藏共用;自 pet_models.dart 上移至 core)。 +class CursorPage { + const CursorPage({ + required this.items, + required this.nextCursor, + required this.hasMore, + }); + + factory CursorPage.fromJson( + Map json, + T Function(Map) itemFromJson, + ) { + return CursorPage( + items: (json['items'] as List) + .map((item) => itemFromJson(item as Map)) + .toList(), + // 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。 + nextCursor: json['nextCursor'] as String?, + hasMore: json['hasMore'] as bool, + ); + } + + final List items; + final String? nextCursor; + final bool hasMore; +} diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index d85cb12..21a1c70 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -25,6 +25,14 @@ const String patbondPetApiBaseUrl = String.fromEnvironment( defaultValue: 'http://127.0.0.1:8083', ); +/// community 服务基地址(/api/v1/posts、/api/v1/feed、/api/v1/media 等 +/// community/media 域 13 路径):沿用分端口直连模式, +/// `--dart-define=PATBOND_COMMUNITY_API_BASE_URL=...` 注入。 +const String patbondCommunityApiBaseUrl = String.fromEnvironment( + 'PATBOND_COMMUNITY_API_BASE_URL', + defaultValue: 'http://127.0.0.1:8084', +); + /// 构建全局共用的 Dio 实例。 /// /// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成 diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index 0f1d530..7b9576f 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -48,6 +48,36 @@ abstract final class ApiCodes { /// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。 static const careReminderRuleViolation = 42202; + + // ------ community / media 域(契约 v1.3.0 冻结,M3 第二波定型 9 个)------ + + /// 对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论)。 + static const postAccessDenied = 40301; + + /// 帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft + /// (防枚举,全部情况响应一致;互动路径上含作者本人草稿)。 + static const postNotFound = 40403; + + /// 评论不存在、已删或所属帖子不可见(防枚举合并)。 + static const commentNotFound = 40404; + + /// media asset 不存在、非本人所有或已删(防枚举合并)。 + static const mediaNotFound = 40405; + + /// 目标用户不存在或已注销(合并不泄露成因)。 + static const communityUserNotFound = 40406; + + /// 同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。 + static const idempotencyKeyMismatch = 40905; + + /// 引用了本人所有但非 ready(uploading/failed)状态的 asset(HTTP 422)。 + static const mediaNotReady = 42203; + + /// 自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。 + static const selfFollow = 42204; + + /// 上传状态不允许确认(非 uploading 态或对象校验未通过,HTTP 422)。 + static const mediaUploadStateInvalid = 42205; } /// API 调用的类型化异常。页面按类型映射为三层错误呈现 diff --git a/lib/features/community/community_controller.dart b/lib/features/community/community_controller.dart new file mode 100644 index 0000000..7de9e99 --- /dev/null +++ b/lib/features/community/community_controller.dart @@ -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 _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 _postCache = {}; + + bool _disposed = false; + + FeedPhase get phase => _phase; + LoadMorePhase get loadMorePhase => _loadMorePhase; + + /// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。 + List 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 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 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 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(); + } +} diff --git a/lib/features/community/community_exceptions.dart b/lib/features/community/community_exceptions.dart new file mode 100644 index 0000000..d96c224 --- /dev/null +++ b/lib/features/community/community_exceptions.dart @@ -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, + }; +} diff --git a/lib/features/community/community_models.dart b/lib/features/community/community_models.dart new file mode 100644 index 0000000..fbe7462 --- /dev/null +++ b/lib/features/community/community_models.dart @@ -0,0 +1,630 @@ +/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0, +/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException] +/// 以便契约漂移在测试期暴露而非静默吞掉)。 +library; + +export 'package:patbond_flutter/core/models/cursor_page.dart'; + +T _enumFromJson(List 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 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 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 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? media; + + Map 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? media; + + Map 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 json) { + return Post( + id: json['id'] as String, + author: AuthorSummary.fromJson(json['author'] as Map), + 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)) + .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 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 json) { + final cover = json['coverImage']; + return FeedCard( + id: json['id'] as String, + author: AuthorSummary.fromJson(json['author'] as Map), + 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), + 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 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 json) { + final replyTo = json['replyToUser']; + return PostComment( + id: json['id'] as String, + postId: json['postId'] as String, + author: AuthorSummary.fromJson(json['author'] as Map), + // @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。 + replyToUser: replyTo == null + ? null + : AuthorSummary.fromJson(replyTo as Map), + 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 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 json) { + return MediaUploadCredentials( + assetId: json['assetId'] as String, + uploadUrl: json['uploadUrl'] as String, + method: json['method'] as String, + requiredHeaders: (json['requiredHeaders'] as Map).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 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 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 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 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 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 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; +} diff --git a/lib/features/community/community_repository.dart b/lib/features/community/community_repository.dart new file mode 100644 index 0000000..44a3a65 --- /dev/null +++ b/lib/features/community/community_repository.dart @@ -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 createMediaUpload( + CreateMediaUploadRequest request, + ); + Future completeMediaUpload(String assetId); + + // ---- 帖子 CRUD / 发布 ---- + Future createPost(CreatePostRequest request); + Future getPost(String postId); + Future updatePost(String postId, UpdatePostRequest request); + Future deletePost(String postId); + Future> listMyPosts({ + int? limit, + String? cursor, + PostStatus? status, + }); + + // ---- Feed(cursor 分页)---- + Future> getFeed({int? limit, String? cursor}); + + // ---- 评论 ---- + Future> listComments( + String postId, { + int? limit, + String? cursor, + }); + Future createComment( + String postId, + CreateCommentRequest request, + ); + Future deleteComment(String commentId); + + // ---- 点赞 / 收藏(PUT/DELETE 语义幂等,响应权威终态)---- + Future likePost(String postId); + Future unlikePost(String postId); + Future bookmarkPost(String postId); + Future unbookmarkPost(String postId); + Future> listMyBookmarks({int? limit, String? cursor}); + + // ---- 关注 ---- + Future followUser(String userId); + Future unfollowUser(String userId); + Future 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 _request( + String path, { + String method = 'GET', + Object? body, + Map? 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 _asMap(Object? data) => data! as Map; + + // ---- media ---- + + @override + Future createMediaUpload( + CreateMediaUploadRequest request, + ) async { + final data = await _request( + '/api/v1/media/uploads', + method: 'POST', + body: request.toJson(), + ); + return MediaUploadCredentials.fromJson(_asMap(data)); + } + + @override + Future 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 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 getPost(String postId) async { + final data = await _request('/api/v1/posts/$postId'); + return Post.fromJson(_asMap(data)); + } + + @override + Future 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 deletePost(String postId) async { + await _request('/api/v1/posts/$postId', method: 'DELETE'); + } + + @override + Future> 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> 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> 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 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 deleteComment(String commentId) async { + // 顶层短路径先例:commentId 全局唯一。 + await _request('/api/v1/comments/$commentId', method: 'DELETE'); + } + + // ---- interactions ---- + + @override + Future likePost(String postId) async { + final data = await _request('/api/v1/posts/$postId/like', method: 'PUT'); + return LikeState.fromJson(_asMap(data)); + } + + @override + Future unlikePost(String postId) async { + final data = await _request('/api/v1/posts/$postId/like', method: 'DELETE'); + return LikeState.fromJson(_asMap(data)); + } + + @override + Future bookmarkPost(String postId) async { + final data = await _request( + '/api/v1/posts/$postId/bookmark', + method: 'PUT', + ); + return BookmarkState.fromJson(_asMap(data)); + } + + @override + Future unbookmarkPost(String postId) async { + final data = await _request( + '/api/v1/posts/$postId/bookmark', + method: 'DELETE', + ); + return BookmarkState.fromJson(_asMap(data)); + } + + @override + Future> 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 followUser(String userId) async { + final data = await _request('/api/v1/users/$userId/follow', method: 'PUT'); + return FollowState.fromJson(_asMap(data)); + } + + @override + Future unfollowUser(String userId) async { + final data = await _request( + '/api/v1/users/$userId/follow', + method: 'DELETE', + ); + return FollowState.fromJson(_asMap(data)); + } + + @override + Future getFollowStats(String userId) async { + final data = await _request('/api/v1/users/$userId/follow-stats'); + return FollowStats.fromJson(_asMap(data)); + } +} diff --git a/lib/features/community/toggle_sync.dart b/lib/features/community/toggle_sync.dart new file mode 100644 index 0000000..62f63cc --- /dev/null +++ b/lib/features/community/toggle_sync.dart @@ -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 Function(String id, bool target) _send; + final int Function() _generation; + final void Function(String id, Object error)? _onError; + + final Map _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 _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; +} diff --git a/lib/features/pets/pet_models.dart b/lib/features/pets/pet_models.dart index ff063f2..8e51e14 100644 --- a/lib/features/pets/pet_models.dart +++ b/lib/features/pets/pet_models.dart @@ -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 { - const CursorPage({ - required this.items, - required this.nextCursor, - required this.hasMore, - }); - - factory CursorPage.fromJson( - Map json, - T Function(Map) itemFromJson, - ) { - return CursorPage( - items: (json['items'] as List) - .map((item) => itemFromJson(item as Map)) - .toList(), - // 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。 - nextCursor: json['nextCursor'] as String?, - hasMore: json['hasMore'] as bool, - ); - } - - final List items; - final String? nextCursor; - final bool hasMore; -} - /// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。 /// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。 class Pet { diff --git a/test/features/community/community_controller_test.dart b/test/features/community/community_controller_test.dart new file mode 100644 index 0000000..a8b30ef --- /dev/null +++ b/test/features/community/community_controller_test.dart @@ -0,0 +1,372 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/community/community_controller.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; + +import '../../helpers/community_test_helpers.dart'; + +void main() { + late FakeCommunityRepository repo; + late CommunityController controller; + + setUp(() { + repo = FakeCommunityRepository(); + controller = CommunityController(repository: repo); + }); + + tearDown(() => controller.dispose()); + + List feedIds() => controller.feed.map((card) => card.id).toList(); + + group('Feed 四态与多页缓存', () { + test('首屏成功:initial → loading → ready,页数据落位', () async { + repo.onFeed = (limit, cursor) async => + feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true); + + expect(controller.phase, FeedPhase.initial); + final pending = controller.refresh(); + expect(controller.phase, FeedPhase.loading); + await pending; + + expect(controller.phase, FeedPhase.ready); + expect(feedIds(), ['p-1']); + expect(controller.hasMore, isTrue); + expect(controller.isEmpty, isFalse); + }); + + test('首屏失败:error 态供 retry;重试成功恢复 ready', () async { + var fail = true; + repo.onFeed = (limit, cursor) async { + if (fail) throw const ApiNetworkException('断网'); + return feedPage([sampleFeedCard()]); + }; + + await controller.refresh(); + expect(controller.phase, FeedPhase.error); + expect(controller.lastError, isA()); + expect(controller.feed, isEmpty); + + fail = false; + await controller.refresh(); + expect(controller.phase, FeedPhase.ready); + expect(controller.lastError, isNull); + expect(feedIds(), ['p-1']); + }); + + test('刷新失败保留旧列表:不清空不闪空态,错误走 refreshError', () async { + var fail = false; + repo.onFeed = (limit, cursor) async { + if (fail) throw const ApiNetworkException('超时'); + return feedPage([sampleFeedCard()]); + }; + await controller.refresh(); + + fail = true; + await controller.refresh(); + + expect(controller.phase, FeedPhase.ready); + expect(feedIds(), ['p-1']); + expect(controller.refreshError, isA()); + expect(controller.lastError, isNull); + }); + + test('ready 且列表为空 → 空态', () async { + repo.onFeed = (limit, cursor) async => feedPage(const []); + await controller.refresh(); + expect(controller.isEmpty, isTrue); + expect(controller.hasMore, isFalse); + }); + }); + + group('加载更多与游标拼接', () { + test('loadMore 携带上一页 nextCursor,追加不替换;到底后不再发', () async { + repo.onFeed = (limit, cursor) async => switch (cursor) { + null => feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true), + 'c1' => feedPage([sampleFeedCard(id: 'p-2')]), + _ => fail('意外游标:$cursor'), + }; + + await controller.refresh(); + await controller.loadMore(); + + expect(repo.calls, ['feed:cursor=null', 'feed:cursor=c1']); + expect(feedIds(), ['p-1', 'p-2']); + expect(controller.hasMore, isFalse); + expect(controller.loadMorePhase, LoadMorePhase.idle); + + // hasMore=false:不再发请求。 + await controller.loadMore(); + expect(repo.calls, hasLength(2)); + }); + + test('loadMore 失败:error 态保留列表,重试成功恢复', () async { + var fail = true; + repo.onFeed = (limit, cursor) async { + if (cursor == null) { + return feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true); + } + if (fail) throw const ApiNetworkException('超时'); + return feedPage([sampleFeedCard(id: 'p-2')]); + }; + + await controller.refresh(); + await controller.loadMore(); + expect(controller.loadMorePhase, LoadMorePhase.error); + expect(controller.loadMoreError, isA()); + expect(feedIds(), ['p-1']); + + fail = false; + await controller.loadMore(); + expect(controller.loadMorePhase, LoadMorePhase.idle); + expect(feedIds(), ['p-1', 'p-2']); + }); + + test('在途 loadMore 与刷新竞态:旧代次尾页丢弃,不重复不错位', () async { + final tail = Completer>(); + repo.onFeed = (limit, cursor) async { + if (cursor == null) { + return feedPage( + [sampleFeedCard(id: 'p-fresh')], + nextCursor: 'c1', + hasMore: true, + ); + } + return tail.future; + }; + + await controller.refresh(); + final pending = controller.loadMore(); // 在途旧代次尾页。 + await controller.refresh(); // 期间刷新:整体替换 + 代次 +1。 + tail.complete(feedPage([sampleFeedCard(id: 'p-stale')])); + await pending; + + expect(feedIds(), ['p-fresh']); + expect(controller.hasMore, isTrue); // 保持新代次首页的分页状态。 + }); + + test('loading 中重复 loadMore 只发一请求(单飞)', () async { + final tail = Completer>(); + repo.onFeed = (limit, cursor) async => cursor == null + ? feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true) + : tail.future; + + await controller.refresh(); + final first = controller.loadMore(); + final second = controller.loadMore(); // loading 中:直接返回。 + tail.complete(feedPage([sampleFeedCard(id: 'p-2')])); + await first; + await second; + + expect(repo.calls.where((c) => c == 'feed:cursor=c1'), hasLength(1)); + expect(feedIds(), ['p-1', 'p-2']); + }); + }); + + group('ToggleSync 乐观更新(点赞 / 收藏同构)', () { + Future loadOneCard({bool liked = false, int likeCount = 6}) async { + repo.onFeed = (limit, cursor) async => + feedPage([sampleFeedCard(likedByMe: liked, likeCount: likeCount)]); + await controller.refresh(); + } + + test('成功:同帧乐观翻转,权威计数覆盖乐观计数', () async { + await loadOneCard(); + repo.onLikeToggle = (id, target) async => + const LikeState(liked: true, likeCount: 10); // 吸收他人并发 +3。 + + controller.toggleLike('p-1'); + // 同帧反馈:乐观 +1。 + expect(controller.feed.single.likedByMe, isTrue); + expect(controller.feed.single.likeCount, 7); + + await pumpEventQueue(); + // 权威终态覆盖。 + expect(controller.feed.single.likedByMe, isTrue); + expect(controller.feed.single.likeCount, 10); + expect(controller.toggleError, isNull); + }); + + test('失败:恢复链起点快照,错误走 toggleError 轻提示', () async { + await loadOneCard(); + repo.onLikeToggle = (id, target) async => + throw const ApiNetworkException('断网'); + + controller.toggleLike('p-1'); + expect(controller.feed.single.likedByMe, isTrue); + + await pumpEventQueue(); + expect(controller.feed.single.likedByMe, isFalse); + expect(controller.feed.single.likeCount, 6); + expect(controller.toggleError, isA()); + }); + + test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async { + await loadOneCard(); + final completers = >[]; + repo.onLikeToggle = (id, target) { + final completer = Completer(); + completers.add(completer); + return completer.future; + }; + + controller.toggleLike('p-1'); // → true,在途。 + controller.toggleLike('p-1'); // → false,只记意图不发请求。 + expect(repo.calls.where((c) => c.startsWith('like')), hasLength(1)); + expect(controller.feed.single.likedByMe, isFalse); + expect(controller.feed.single.likeCount, 6); + + completers[0].complete(const LikeState(liked: true, likeCount: 7)); + await pumpEventQueue(); + // 确认态 true ≠ 最终意图 false → 补发 DELETE。 + expect(repo.calls, contains('unlike:p-1')); + completers[1].complete(const LikeState(liked: false, likeCount: 6)); + await pumpEventQueue(); + + expect(controller.feed.single.likedByMe, isFalse); + expect(controller.feed.single.likeCount, 6); + // 全程恰两个请求(中间抖动被合并)。 + expect( + repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')), + hasLength(2), + ); + }); + + test('补发目标与终态一致不再发:连点偶数次收敛回原意图', () async { + await loadOneCard(); + final completers = >[]; + repo.onLikeToggle = (id, target) { + final completer = Completer(); + completers.add(completer); + return completer.future; + }; + + controller.toggleLike('p-1'); // → true,在途。 + controller.toggleLike('p-1'); // → false。 + controller.toggleLike('p-1'); // → true(最终意图与在途目标一致)。 + + completers.single.complete(const LikeState(liked: true, likeCount: 20)); + await pumpEventQueue(); + + // 意图 == 确认态:不补发,权威计数覆盖。 + expect( + repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')), + hasLength(1), + ); + expect(controller.feed.single.likedByMe, isTrue); + expect(controller.feed.single.likeCount, 20); + }); + + test('代次守卫:刷新后到达的旧成功响应丢弃,不覆盖新数据', () async { + await loadOneCard(); + final inFlight = Completer(); + repo.onLikeToggle = (id, target) => inFlight.future; + + controller.toggleLike('p-1'); + // 期间刷新:列表被服务端数据整体替换(liked=false count=0)。 + repo.onFeed = (limit, cursor) async => + feedPage([sampleFeedCard(likeCount: 0)]); + await controller.refresh(); + + inFlight.complete(const LikeState(liked: true, likeCount: 99)); + await pumpEventQueue(); + + expect(controller.feed.single.likedByMe, isFalse); + expect(controller.feed.single.likeCount, 0); + }); + + test('代次守卫:刷新后到达的旧失败响应不回滚不提示', () async { + await loadOneCard(); + final inFlight = Completer(); + repo.onLikeToggle = (id, target) => inFlight.future; + + controller.toggleLike('p-1'); + repo.onFeed = (limit, cursor) async => + feedPage([sampleFeedCard(likeCount: 0)]); + await controller.refresh(); + + inFlight.completeError(const ApiNetworkException('超时')); + await pumpEventQueue(); + + expect(controller.feed.single.likedByMe, isFalse); + expect(controller.feed.single.likeCount, 0); + expect(controller.toggleError, isNull); + }); + + test('收藏同构:乐观翻转 + 权威终态覆盖', () async { + await loadOneCard(); + repo.onBookmarkToggle = (id, target) async => + const BookmarkState(bookmarked: true, bookmarkCount: 5); + + controller.toggleBookmark('p-1'); + expect(controller.feed.single.bookmarkedByMe, isTrue); + expect(controller.feed.single.bookmarkCount, 3); + + await pumpEventQueue(); + expect(controller.feed.single.bookmarkCount, 5); + }); + + test('对不在列表的 postId 点击作废:不发请求不崩溃', () async { + await loadOneCard(); + controller.toggleLike('p-nonexistent'); + await pumpEventQueue(); + expect(repo.calls.where((c) => c.startsWith('like')), isEmpty); + }); + }); + + group('详情副本与登出清态', () { + test('getPost:详情入缓存并回写 Feed 卡片互动字段', () async { + repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]); + await controller.refresh(); + repo.onGetPost = (id) async => + Post.fromJson(samplePostJson(likedByMe: true, likeCount: 42)); + + final post = await controller.getPost('p-1'); + + expect(post.likeCount, 42); + expect(controller.cachedPost('p-1')!.likedByMe, isTrue); + expect(controller.feed.single.likedByMe, isTrue); + expect(controller.feed.single.likeCount, 42); + }); + + test('详情副本存在时 toggle 同步详情与卡片两份内存', () async { + repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]); + await controller.refresh(); + repo.onGetPost = (id) async => Post.fromJson(samplePostJson()); + await controller.getPost('p-1'); + repo.onLikeToggle = (id, target) async => + const LikeState(liked: true, likeCount: 7); + + controller.toggleLike('p-1'); + expect(controller.cachedPost('p-1')!.likedByMe, isTrue); + expect(controller.feed.single.likedByMe, isTrue); + + await pumpEventQueue(); + expect(controller.cachedPost('p-1')!.likeCount, 7); + expect(controller.feed.single.likeCount, 7); + }); + + test('reset:清列表 / 游标 / 详情副本回 initial,在途响应作废', () async { + repo.onFeed = (limit, cursor) async => + feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true); + await controller.refresh(); + final inFlight = Completer(); + repo.onLikeToggle = (id, target) => inFlight.future; + controller.toggleLike('p-1'); + + controller.reset(); + + expect(controller.phase, FeedPhase.initial); + expect(controller.feed, isEmpty); + expect(controller.hasMore, isFalse); + expect(controller.cachedPost('p-1'), isNull); + expect(controller.toggleError, isNull); + + // 登出后到达的在途响应作废,不写入任何状态。 + inFlight.complete(const LikeState(liked: true, likeCount: 7)); + await pumpEventQueue(); + expect(controller.feed, isEmpty); + }); + }); +} diff --git a/test/features/community/community_models_test.dart b/test/features/community/community_models_test.dart new file mode 100644 index 0000000..f629e3e --- /dev/null +++ b/test/features/community/community_models_test.dart @@ -0,0 +1,285 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; + +import '../../helpers/community_test_helpers.dart'; + +void main() { + group('响应 DTO 逐字段映射(契约 v1.3.0)', () { + test('Post:完整形态含作者 / media / 互动计数 / publishedAt', () { + final post = Post.fromJson(samplePostJson()); + + expect(post.id, 'p-1'); + expect(post.author.userId, 'u-1'); + expect(post.author.nickname, '毛毛的铲屎官'); + expect(post.petId, 'pet-1'); + expect(post.category, PostCategory.general); + expect(post.title, '今天的豆豆'); + expect(post.content, '晒了一下午太阳。'); + expect(post.status, PostStatus.published); + expect(post.visibility, PostVisibility.public); + expect(post.media.single.assetId, 'a-1'); + expect(post.media.single.isCover, isTrue); + expect(post.likeCount, 6); + expect(post.commentCount, 3); + expect(post.bookmarkCount, 2); + expect(post.likedByMe, isFalse); + expect(post.bookmarkedByMe, isFalse); + expect(post.publishedAt, isNotNull); + expect(post.version, 1); + }); + + test('Post:draft 态 publishedAt 为 null(仅 published 非空)', () { + final post = Post.fromJson(samplePostJson(status: 'draft')); + expect(post.status, PostStatus.draft); + expect(post.publishedAt, isNull); + }); + + test('FeedCard:卡片裁剪形态,纯文字帖 coverImage 为 null', () { + final withCover = FeedCard.fromJson(sampleFeedCardJson()); + expect(withCover.coverImage!.url, contains('X-Amz-Signature')); + expect(withCover.mediaCount, 1); + expect(withCover.contentPreview, '晒了一下午太阳。'); + expect(withCover.publishedAt, DateTime.parse('2026-09-08T10:05:00.000Z')); + + final textOnly = FeedCard.fromJson( + sampleFeedCardJson() + ..['coverImage'] = null + ..['mediaCount'] = 0, + ); + expect(textOnly.coverImage, isNull); + expect(textOnly.mediaCount, 0); + }); + + test('category:ai_creation(读侧预留)按线上 snake_case 解析', () { + final card = FeedCard.fromJson( + sampleFeedCardJson()..['category'] = 'ai_creation', + ); + expect(card.category, PostCategory.aiCreation); + expect(PostCategory.aiCreation.wire, 'ai_creation'); + }); + + test('AuthorSummary:nickname 与 avatarUrl 同为 null 即降级/墓碑形态', () { + final normal = AuthorSummary.fromJson(sampleAuthorJson()); + expect(normal.isDegraded, isFalse); + + final degraded = AuthorSummary.fromJson( + sampleAuthorJson(nickname: null, avatarUrl: null), + ); + expect(degraded.userId, 'u-1'); + expect(degraded.isDegraded, isTrue); + + // 仅缺头像不算降级(无头像 / 头像非 ready 也是 null)。 + final noAvatar = AuthorSummary.fromJson( + sampleAuthorJson(avatarUrl: null), + ); + expect(noAvatar.isDegraded, isFalse); + }); + + test('PostComment:replyToUser 非回复为 null,@ 回复含降级形态', () { + final plain = PostComment.fromJson(sampleCommentJson()); + expect(plain.replyToUser, isNull); + expect(plain.postId, 'p-1'); + + final reply = PostComment.fromJson( + sampleCommentJson( + replyToUser: sampleAuthorJson( + userId: 'u-2', + nickname: null, + avatarUrl: null, + ), + ), + ); + expect(reply.replyToUser!.userId, 'u-2'); + expect(reply.replyToUser!.isDegraded, isTrue); + }); + + test('MediaUploadCredentials:requiredHeaders 原样映射为字符串表', () { + final credentials = MediaUploadCredentials.fromJson( + sampleUploadCredentialsJson(), + ); + expect(credentials.assetId, 'a-1'); + expect(credentials.method, 'PUT'); + expect(credentials.requiredHeaders, {'Content-Type': 'image/jpeg'}); + expect(credentials.expiresAt, DateTime.parse('2026-09-08T10:10:00.000Z')); + }); + + test('MediaAsset:ready 态 url/readyAt 非空,uploading 态为 null', () { + final ready = MediaAsset.fromJson(sampleMediaAssetJson()); + expect(ready.status, MediaAssetStatus.ready); + expect(ready.url, isNotNull); + expect(ready.readyAt, isNotNull); + expect(ready.byteSize, 204800); + + final uploading = MediaAsset.fromJson( + sampleMediaAssetJson(status: 'uploading'), + ); + expect(uploading.status, MediaAssetStatus.uploading); + expect(uploading.url, isNull); + expect(uploading.readyAt, isNull); + }); + + test('LikeState / BookmarkState / FollowState / FollowStats 终态解析', () { + final like = LikeState.fromJson({'liked': true, 'likeCount': 7}); + expect(like.liked, isTrue); + expect(like.likeCount, 7); + + final bookmark = BookmarkState.fromJson({ + 'bookmarked': false, + 'bookmarkCount': 0, + }); + expect(bookmark.bookmarked, isFalse); + expect(bookmark.bookmarkCount, 0); + + final follow = FollowState.fromJson({ + 'following': true, + 'followerCount': 12, + }); + expect(follow.following, isTrue); + expect(follow.followerCount, 12); + + final stats = FollowStats.fromJson({ + 'followerCount': 12, + 'followingCount': 34, + 'followedByMe': false, + }); + expect(stats.followingCount, 34); + expect(stats.followedByMe, isFalse); + }); + + test('未知枚举取值抛 FormatException(契约漂移测试期暴露)', () { + expect( + () => Post.fromJson(samplePostJson()..['status'] = 'hidden'), + throwsFormatException, + ); + expect( + () => FeedCard.fromJson(sampleFeedCardJson()..['category'] = 'topic'), + throwsFormatException, + ); + expect( + () => MediaAsset.fromJson(sampleMediaAssetJson(status: 'deleted')), + throwsFormatException, + ); + expect( + () => Post.fromJson(samplePostJson()..['visibility'] = 'private'), + throwsFormatException, + ); + }); + }); + + group('请求 DTO 序列化', () { + test('CreatePostRequest:可选字段缺席不出现,media 按项序列化', () { + const minimal = CreatePostRequest(content: '纯文字帖'); + expect(minimal.toJson(), {'content': '纯文字帖'}); + + const full = CreatePostRequest( + content: '正文', + title: '标题', + category: PostCategory.help, + status: PostStatus.published, + petId: 'pet-1', + media: [ + PostMediaAttachRequest(assetId: 'a-1', position: 0, isCover: true), + PostMediaAttachRequest(assetId: 'a-2', position: 1, caption: '第二张'), + ], + ); + expect(full.toJson(), { + 'content': '正文', + 'title': '标题', + 'category': 'help', + 'status': 'published', + 'petId': 'pet-1', + 'media': [ + {'assetId': 'a-1', 'position': 0, 'isCover': true}, + {'assetId': 'a-2', 'position': 1, 'caption': '第二张'}, + ], + }); + }); + + test('UpdatePostRequest:media 三态——缺席不动 / [] 清空 / 非空整组替换', () { + const absent = UpdatePostRequest(version: 2, title: '改标题'); + expect(absent.toJson(), {'version': 2, 'title': '改标题'}); + expect(absent.toJson().containsKey('media'), isFalse); + + const clear = UpdatePostRequest(version: 2, media: []); + expect(clear.toJson(), {'version': 2, 'media': []}); + + const replace = UpdatePostRequest( + version: 2, + media: [PostMediaAttachRequest(assetId: 'a-3')], + ); + expect(replace.toJson()['media'], [ + {'assetId': 'a-3'}, + ]); + }); + + test('UpdatePostRequest:publish 即 status: published(唯一开放迁移)', () { + const publish = UpdatePostRequest(version: 1, publish: true); + expect(publish.toJson(), {'version': 1, 'status': 'published'}); + + const noPublish = UpdatePostRequest(version: 1, content: '改正文'); + expect(noPublish.toJson().containsKey('status'), isFalse); + }); + + test('CreateMediaUploadRequest:kind/purpose 按线上取值,sha256 可选', () { + const request = CreateMediaUploadRequest( + kind: MediaKind.image, + purpose: MediaPurpose.postImage, + mimeType: 'image/jpeg', + byteSize: 204800, + ); + expect(request.toJson(), { + 'kind': 'image', + 'purpose': 'post_image', + 'mimeType': 'image/jpeg', + 'byteSize': 204800, + }); + + const withHash = CreateMediaUploadRequest( + kind: MediaKind.image, + purpose: MediaPurpose.postImage, + mimeType: 'image/webp', + byteSize: 1, + sha256: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ); + expect(withHash.toJson()['sha256'], hasLength(64)); + }); + + test('CreateCommentRequest:replyToUserId 缺席不出现', () { + expect(const CreateCommentRequest(content: '好可爱!').toJson(), { + 'content': '好可爱!', + }); + expect( + const CreateCommentRequest( + content: '@回复', + replyToUserId: 'u-2', + ).toJson(), + {'content': '@回复', 'replyToUserId': 'u-2'}, + ); + }); + }); + + group('互动字段副本更新', () { + test('Post.copyWithInteraction 只动互动字段', () { + final post = Post.fromJson(samplePostJson()); + final updated = post.copyWithInteraction(likedByMe: true, likeCount: 7); + expect(updated.likedByMe, isTrue); + expect(updated.likeCount, 7); + expect(updated.bookmarkCount, post.bookmarkCount); + expect(updated.content, post.content); + expect(updated.version, post.version); + }); + + test('FeedCard.copyWithInteraction 只动互动字段', () { + final card = sampleFeedCard(); + final updated = card.copyWithInteraction( + bookmarkedByMe: true, + bookmarkCount: 3, + ); + expect(updated.bookmarkedByMe, isTrue); + expect(updated.bookmarkCount, 3); + expect(updated.likeCount, card.likeCount); + expect(updated.contentPreview, card.contentPreview); + }); + }); +} diff --git a/test/features/community/community_repository_test.dart b/test/features/community/community_repository_test.dart new file mode 100644 index 0000000..758dc3d --- /dev/null +++ b/test/features/community/community_repository_test.dart @@ -0,0 +1,434 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/core/network/api_client.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/core/network/token_refresher.dart'; +import 'package:patbond_flutter/features/auth/session_manager.dart'; +import 'package:patbond_flutter/features/community/community_exceptions.dart'; +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; + +import '../../helpers/auth_test_helpers.dart'; +import '../../helpers/community_test_helpers.dart'; + +void main() { + late SessionManager session; + late FakeHttpAdapter adapter; + late ApiCommunityRepository repository; + + Future setUpWith( + Future Function(RequestOptions) handler, + ) async { + session = SessionManager(store: InMemoryTokenStore()); + await session.updateTokens(sampleTokens(access: 'community-access')); + final dio = buildPatbondDio( + session: session, + baseUrl: 'http://community.local', + ); + adapter = FakeHttpAdapter(handler); + dio.httpClientAdapter = adapter; + final refresher = TokenRefresher(dio: dio, session: session); + repository = ApiCommunityRepository( + api: ApiClient(dio: dio, session: session, refresher: refresher), + ); + } + + group('请求线路(路径 / 方法 / 鉴权 / 参数)', () { + test('createMediaUpload:POST /api/v1/media/uploads,携带 Bearer', () async { + await setUpWith( + (options) async => + jsonResponse(201, okEnvelope(sampleUploadCredentialsJson())), + ); + + final credentials = await repository.createMediaUpload( + const CreateMediaUploadRequest( + kind: MediaKind.image, + purpose: MediaPurpose.postImage, + mimeType: 'image/jpeg', + byteSize: 204800, + ), + ); + + final request = adapter.requests.single; + expect(request.path, '/api/v1/media/uploads'); + expect(request.method, 'POST'); + expect(request.headers['Authorization'], 'Bearer community-access'); + expect(request.data, { + 'kind': 'image', + 'purpose': 'post_image', + 'mimeType': 'image/jpeg', + 'byteSize': 204800, + }); + expect(credentials.uploadUrl, contains('X-Amz-Signature')); + }); + + test('completeMediaUpload:POST /complete 无请求体(服务端幂等)', () async { + await setUpWith( + (options) async => + jsonResponse(200, okEnvelope(sampleMediaAssetJson())), + ); + + final asset = await repository.completeMediaUpload('a-1'); + + final request = adapter.requests.single; + expect(request.path, '/api/v1/media/uploads/a-1/complete'); + expect(request.method, 'POST'); + expect(asset.status, MediaAssetStatus.ready); + }); + + test('帖子 CRUD:POST / GET / PATCH / DELETE 线路', () async { + var call = 0; + await setUpWith((options) async { + call += 1; + return switch (call) { + 1 => jsonResponse(201, okEnvelope(samplePostJson())), + 2 => jsonResponse(200, okEnvelope(samplePostJson())), + 3 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))), + _ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}), + }; + }); + + await repository.createPost(const CreatePostRequest(content: '正文')); + await repository.getPost('p-1'); + final updated = await repository.updatePost( + 'p-1', + const UpdatePostRequest(version: 1, publish: true), + ); + await repository.deletePost('p-1'); + + expect(adapter.requests[0].path, '/api/v1/posts'); + expect(adapter.requests[0].method, 'POST'); + expect(adapter.requests[1].path, '/api/v1/posts/p-1'); + expect(adapter.requests[1].method, 'GET'); + expect(adapter.requests[2].method, 'PATCH'); + expect(adapter.requests[2].data, {'version': 1, 'status': 'published'}); + expect(adapter.requests[3].method, 'DELETE'); + expect(updated.version, 2); + }); + + test('listMyPosts:/api/v1/me/posts 分页 + status 过滤,缺省不传', () async { + await setUpWith( + (options) async => jsonResponse( + 200, + okEnvelope( + cursorPageJson([samplePostJson()], nextCursor: 'c2', hasMore: true), + ), + ), + ); + + final page = await repository.listMyPosts( + limit: 20, + cursor: 'c1', + status: PostStatus.draft, + ); + await repository.listMyPosts(); + + expect(adapter.requests[0].path, '/api/v1/me/posts'); + expect(adapter.requests[0].queryParameters, { + 'limit': 20, + 'cursor': 'c1', + 'status': 'draft', + }); + expect(adapter.requests[1].queryParameters, isEmpty); + expect(page.items.single.id, 'p-1'); + expect(page.nextCursor, 'c2'); + expect(page.hasMore, isTrue); + }); + + test('getFeed:/api/v1/feed 游标分页与 FeedCard 信封解析', () async { + await setUpWith( + (options) async => jsonResponse( + 200, + okEnvelope( + cursorPageJson( + [sampleFeedCardJson()], + nextCursor: 'f2', + hasMore: true, + ), + ), + ), + ); + + final page = await repository.getFeed(limit: 20, cursor: 'f1'); + + final request = adapter.requests.single; + expect(request.path, '/api/v1/feed'); + expect(request.queryParameters, {'limit': 20, 'cursor': 'f1'}); + expect(page.items.single.contentPreview, '晒了一下午太阳。'); + expect(page.nextCursor, 'f2'); + }); + + test('评论:列表分页、创建、顶层短路径删除', () async { + var call = 0; + await setUpWith((options) async { + call += 1; + return switch (call) { + 1 => jsonResponse( + 200, + okEnvelope(cursorPageJson([sampleCommentJson()])), + ), + 2 => jsonResponse(201, okEnvelope(sampleCommentJson())), + _ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}), + }; + }); + + final page = await repository.listComments('p-1', limit: 20); + await repository.createComment( + 'p-1', + const CreateCommentRequest(content: '好可爱!'), + ); + await repository.deleteComment('c-1'); + + expect(adapter.requests[0].path, '/api/v1/posts/p-1/comments'); + expect(adapter.requests[0].queryParameters, {'limit': 20}); + expect(adapter.requests[1].method, 'POST'); + expect(adapter.requests[1].data, {'content': '好可爱!'}); + expect(adapter.requests[2].path, '/api/v1/comments/c-1'); + expect(adapter.requests[2].method, 'DELETE'); + expect(page.items.single.content, '好可爱!'); + expect(page.hasMore, isFalse); + }); + + test('点赞/收藏:PUT 与 DELETE 同路径,返回权威终态', () async { + var call = 0; + await setUpWith((options) async { + call += 1; + return switch (call) { + 1 => jsonResponse(200, okEnvelope({'liked': true, 'likeCount': 7})), + 2 => jsonResponse(200, okEnvelope({'liked': false, 'likeCount': 6})), + 3 => jsonResponse( + 200, + okEnvelope({'bookmarked': true, 'bookmarkCount': 3}), + ), + _ => jsonResponse( + 200, + okEnvelope({'bookmarked': false, 'bookmarkCount': 2}), + ), + }; + }); + + final liked = await repository.likePost('p-1'); + final unliked = await repository.unlikePost('p-1'); + final bookmarked = await repository.bookmarkPost('p-1'); + await repository.unbookmarkPost('p-1'); + + expect(adapter.requests[0].path, '/api/v1/posts/p-1/like'); + expect(adapter.requests[0].method, 'PUT'); + expect(adapter.requests[1].path, '/api/v1/posts/p-1/like'); + expect(adapter.requests[1].method, 'DELETE'); + expect(adapter.requests[2].path, '/api/v1/posts/p-1/bookmark'); + expect(adapter.requests[2].method, 'PUT'); + expect(adapter.requests[3].method, 'DELETE'); + // PUT/DELETE 语义幂等:无 Idempotency-Key。 + for (final request in adapter.requests) { + expect(request.headers.containsKey('Idempotency-Key'), isFalse); + } + expect(liked.liked, isTrue); + expect(liked.likeCount, 7); + expect(unliked.liked, isFalse); + expect(bookmarked.bookmarkCount, 3); + }); + + test('listMyBookmarks:/api/v1/me/bookmarks,项形态 = FeedCard', () async { + await setUpWith( + (options) async => jsonResponse( + 200, + okEnvelope(cursorPageJson([sampleFeedCardJson()])), + ), + ); + + final page = await repository.listMyBookmarks(cursor: 'b1'); + + expect(adapter.requests.single.path, '/api/v1/me/bookmarks'); + expect(adapter.requests.single.queryParameters, {'cursor': 'b1'}); + expect(page.items.single.id, 'p-1'); + }); + + test('关注:PUT / DELETE / follow-stats 线路与终态', () async { + var call = 0; + await setUpWith((options) async { + call += 1; + return switch (call) { + 1 => jsonResponse( + 200, + okEnvelope({'following': true, 'followerCount': 12}), + ), + 2 => jsonResponse( + 200, + okEnvelope({'following': false, 'followerCount': 11}), + ), + _ => jsonResponse( + 200, + okEnvelope({ + 'followerCount': 11, + 'followingCount': 34, + 'followedByMe': false, + }), + ), + }; + }); + + final followed = await repository.followUser('u-2'); + final unfollowed = await repository.unfollowUser('u-2'); + final stats = await repository.getFollowStats('u-2'); + + expect(adapter.requests[0].path, '/api/v1/users/u-2/follow'); + expect(adapter.requests[0].method, 'PUT'); + expect(adapter.requests[1].method, 'DELETE'); + expect(adapter.requests[2].path, '/api/v1/users/u-2/follow-stats'); + expect(adapter.requests[2].method, 'GET'); + expect(followed.following, isTrue); + expect(unfollowed.followerCount, 11); + expect(stats.followingCount, 34); + }); + }); + + group('Idempotency-Key(community 域必带语义)', () { + test('createPost / createComment 必带键,每次逻辑提交换新键', () async { + var call = 0; + await setUpWith((options) async { + call += 1; + return call <= 2 + ? jsonResponse(201, okEnvelope(samplePostJson())) + : jsonResponse(201, okEnvelope(sampleCommentJson())); + }); + + await repository.createPost(const CreatePostRequest(content: '一')); + await repository.createPost(const CreatePostRequest(content: '二')); + await repository.createComment( + 'p-1', + const CreateCommentRequest(content: '三'), + ); + + final keys = adapter.requests + .map((r) => r.headers['Idempotency-Key'] as String?) + .toList(); + expect(keys, everyElement(isNotNull)); + expect(keys, everyElement(isNotEmpty)); + // 每次逻辑提交换新键(契约:1~128 字符,建议 UUID)。 + expect(keys.toSet().length, 3); + expect(keys.every((key) => key!.length <= 128), isTrue); + }); + + test('GET / PATCH / DELETE 不带 Idempotency-Key', () async { + var call = 0; + await setUpWith((options) async { + call += 1; + return switch (call) { + 1 => jsonResponse(200, okEnvelope(samplePostJson())), + 2 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))), + _ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}), + }; + }); + + await repository.getPost('p-1'); + await repository.updatePost('p-1', const UpdatePostRequest(version: 1)); + await repository.deletePost('p-1'); + + for (final request in adapter.requests) { + expect(request.headers.containsKey('Idempotency-Key'), isFalse); + } + }); + + test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async { + await setUpWith((options) async { + if (options.path == '/api/v1/auth/refresh') { + return jsonResponse( + 200, + okEnvelope(tokenDataJson(access: 'new-access')), + ); + } + if (options.headers['Authorization'] == 'Bearer community-access') { + return jsonResponse(401, errorEnvelope(40101, 'token 过期')); + } + return jsonResponse(201, okEnvelope(samplePostJson())); + }); + + await repository.createPost(const CreatePostRequest(content: '正文')); + + final postRequests = adapter.requests + .where((r) => r.path == '/api/v1/posts') + .toList(); + expect(postRequests, hasLength(2)); + expect(postRequests.last.headers['Authorization'], 'Bearer new-access'); + // 刷新重放是同一逻辑提交:同键命中服务端首次结果,不重复建帖。 + expect( + postRequests.first.headers['Idempotency-Key'], + postRequests.last.headers['Idempotency-Key'], + ); + }); + }); + + group('错误码 → 类型化异常映射(v1.3.0 新增 9 码 + 40902)', () { + Future expectMapped(int httpStatus, int code, Matcher matcher) async { + await setUpWith( + (options) async => jsonResponse(httpStatus, errorEnvelope(code)), + ); + await expectLater(repository.getPost('p-x'), throwsA(matcher)); + } + + test('40301 → PostAccessDeniedException', () async { + await expectMapped(403, 40301, isA()); + }); + + test('40403 → PostNotFoundException(防枚举合并)', () async { + await expectMapped(404, 40403, isA()); + }); + + test('40404 → CommentNotFoundException', () async { + await expectMapped(404, 40404, isA()); + }); + + test('40405 → MediaAssetNotFoundException', () async { + await expectMapped(404, 40405, isA()); + }); + + test('40406 → CommunityUserNotFoundException', () async { + await expectMapped(404, 40406, isA()); + }); + + test('40902 → PostVersionConflictException(共码独立类型)', () async { + await expectMapped(409, 40902, isA()); + }); + + test('40905 → IdempotencyMismatchException', () async { + await expectMapped(409, 40905, isA()); + }); + + test('42203 → MediaNotReadyException', () async { + await expectMapped(422, 42203, isA()); + }); + + test('42204 → SelfFollowException', () async { + await expectMapped(422, 42204, isA()); + }); + + test('42205 → MediaUploadStateException', () async { + await expectMapped(422, 42205, isA()); + }); + + test('40401(pets 域码)不升格,保持通用 ApiBusinessException', () async { + await setUpWith( + (options) async => jsonResponse(404, errorEnvelope(40401, '宠物不存在')), + ); + await expectLater( + repository.createPost(const CreatePostRequest(content: '带宠物')), + throwsA( + isA() + .having((e) => e.code, 'code', 40401) + .having((e) => e, 'type', isNot(isA())), + ), + ); + }); + + test('类型化异常仍可按基类 ApiBusinessException 捕获', () { + const error = SelfFollowException(message: '不能关注自己'); + expect(error, isA()); + expect(error.code, ApiCodes.selfFollow); + }); + }); + + test('community 服务基地址常量存在且默认指向 :8084', () { + expect(patbondCommunityApiBaseUrl, 'http://127.0.0.1:8084'); + }); +} diff --git a/test/helpers/community_test_helpers.dart b/test/helpers/community_test_helpers.dart new file mode 100644 index 0000000..6869fc8 --- /dev/null +++ b/test/helpers/community_test_helpers.dart @@ -0,0 +1,237 @@ +import 'package:patbond_flutter/features/community/community_models.dart'; +import 'package:patbond_flutter/features/community/community_repository.dart'; + +/// community 域测试样本 JSON(字段与契约 v1.3.0 逐字一致)。 + +Map sampleAuthorJson({ + String userId = 'u-1', + String? nickname = '毛毛的铲屎官', + String? avatarUrl = 'https://minio.local/avatar.jpg?X-Amz-Signature=sig', +}) => {'userId': userId, 'nickname': nickname, 'avatarUrl': avatarUrl}; + +Map samplePostMediaItemJson({ + String assetId = 'a-1', + int position = 0, + bool isCover = true, +}) => { + 'assetId': assetId, + 'position': position, + 'isCover': isCover, + 'url': 'https://minio.local/p.jpg?X-Amz-Signature=sig', + 'widthPx': 1080, + 'heightPx': 810, + 'caption': '晒太阳', +}; + +Map samplePostJson({ + String id = 'p-1', + String status = 'published', + bool likedByMe = false, + int likeCount = 6, + bool bookmarkedByMe = false, + int bookmarkCount = 2, + int version = 1, +}) => { + 'id': id, + 'author': sampleAuthorJson(), + 'petId': 'pet-1', + 'category': 'general', + 'title': '今天的豆豆', + 'content': '晒了一下午太阳。', + 'status': status, + 'visibility': 'public', + 'media': [samplePostMediaItemJson()], + 'likeCount': likeCount, + 'commentCount': 3, + 'bookmarkCount': bookmarkCount, + 'likedByMe': likedByMe, + 'bookmarkedByMe': bookmarkedByMe, + 'createdAt': '2026-09-08T10:00:00.000Z', + 'updatedAt': '2026-09-08T10:05:00.000Z', + 'publishedAt': status == 'published' ? '2026-09-08T10:05:00.000Z' : null, + 'version': version, +}; + +Map sampleFeedCardJson({ + String id = 'p-1', + bool likedByMe = false, + int likeCount = 6, + bool bookmarkedByMe = false, + int bookmarkCount = 2, +}) => { + 'id': id, + 'author': sampleAuthorJson(), + 'category': 'general', + 'title': '今天的豆豆', + 'contentPreview': '晒了一下午太阳。', + 'coverImage': samplePostMediaItemJson(), + 'mediaCount': 1, + 'likeCount': likeCount, + 'commentCount': 3, + 'bookmarkCount': bookmarkCount, + 'likedByMe': likedByMe, + 'bookmarkedByMe': bookmarkedByMe, + 'publishedAt': '2026-09-08T10:05:00.000Z', +}; + +Map sampleCommentJson({ + String id = 'c-1', + Map? replyToUser, +}) => { + 'id': id, + 'postId': 'p-1', + 'author': sampleAuthorJson(), + 'replyToUser': replyToUser, + 'content': '好可爱!', + 'createdAt': '2026-09-08T11:00:00.000Z', +}; + +Map sampleUploadCredentialsJson() => { + 'assetId': 'a-1', + 'uploadUrl': + 'https://minio.local/patbond-media/post_image/a-1?X-Amz-Signature=sig', + 'method': 'PUT', + 'requiredHeaders': {'Content-Type': 'image/jpeg'}, + 'expiresAt': '2026-09-08T10:10:00.000Z', +}; + +Map sampleMediaAssetJson({String status = 'ready'}) => { + 'id': 'a-1', + 'kind': 'image', + 'purpose': 'post_image', + 'mimeType': 'image/jpeg', + 'byteSize': 204800, + 'widthPx': 1080, + 'heightPx': 810, + 'status': status, + 'url': status == 'ready' + ? 'https://minio.local/p.jpg?X-Amz-Signature=sig' + : null, + 'readyAt': status == 'ready' ? '2026-09-08T10:06:00.000Z' : null, + 'createdAt': '2026-09-08T10:00:00.000Z', +}; + +Map cursorPageJson( + List> items, { + String? nextCursor, + bool hasMore = false, +}) => {'items': items, 'nextCursor': nextCursor, 'hasMore': hasMore}; + +FeedCard sampleFeedCard({ + String id = 'p-1', + bool likedByMe = false, + int likeCount = 6, +}) => FeedCard.fromJson( + sampleFeedCardJson(id: id, likedByMe: likedByMe, likeCount: likeCount), +); + +CursorPage feedPage( + List items, { + String? nextCursor, + bool hasMore = false, +}) => CursorPage(items: items, nextCursor: nextCursor, hasMore: hasMore); + +/// 假仓库:controller 测试注入行为并记录调用(Completer 控时序)。 +/// 未注入 handler 的方法一律 UnimplementedError(误触发即测试失败)。 +class FakeCommunityRepository implements CommunityRepository { + /// 调用日志,如 `feed:cursor=null`、`like:p-1`、`unlike:p-1`。 + final List calls = []; + + Future> Function(int? limit, String? cursor)? onFeed; + Future Function(String postId, bool target)? onLikeToggle; + Future Function(String postId, bool target)? onBookmarkToggle; + Future Function(String postId)? onGetPost; + + @override + Future> getFeed({int? limit, String? cursor}) { + calls.add('feed:cursor=$cursor'); + return onFeed!(limit, cursor); + } + + @override + Future likePost(String postId) { + calls.add('like:$postId'); + return onLikeToggle!(postId, true); + } + + @override + Future unlikePost(String postId) { + calls.add('unlike:$postId'); + return onLikeToggle!(postId, false); + } + + @override + Future bookmarkPost(String postId) { + calls.add('bookmark:$postId'); + return onBookmarkToggle!(postId, true); + } + + @override + Future unbookmarkPost(String postId) { + calls.add('unbookmark:$postId'); + return onBookmarkToggle!(postId, false); + } + + @override + Future getPost(String postId) { + calls.add('getPost:$postId'); + return onGetPost!(postId); + } + + @override + Future createMediaUpload( + CreateMediaUploadRequest request, + ) => throw UnimplementedError(); + + @override + Future completeMediaUpload(String assetId) => + throw UnimplementedError(); + + @override + Future createPost(CreatePostRequest request) => + throw UnimplementedError(); + + @override + Future updatePost(String postId, UpdatePostRequest request) => + throw UnimplementedError(); + + @override + Future deletePost(String postId) => throw UnimplementedError(); + + @override + Future> listMyPosts({ + int? limit, + String? cursor, + PostStatus? status, + }) => throw UnimplementedError(); + + @override + Future> listComments( + String postId, { + int? limit, + String? cursor, + }) => throw UnimplementedError(); + + @override + Future createComment( + String postId, + CreateCommentRequest request, + ) => throw UnimplementedError(); + + @override + Future deleteComment(String commentId) => throw UnimplementedError(); + + @override + Future> listMyBookmarks({int? limit, String? cursor}) => + throw UnimplementedError(); + + @override + Future followUser(String userId) => throw UnimplementedError(); + + @override + Future unfollowUser(String userId) => throw UnimplementedError(); + + @override + Future getFollowStats(String userId) => + throw UnimplementedError(); +}