新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
CI / flutter-gates (push) Successful in 2m26s
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射 (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等), 未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用 - community_repository:13 路径 19 操作全覆盖;createPost/createComment 必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/ 关注走 PUT/DELETE 语义幂等 - community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射 - toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫, 点赞/收藏共用一套参数化状态机,权威终态对账收敛 - community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 + 游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态 - app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher, 登出同步 reset - 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+26
-1
@@ -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/login_page.dart';
|
||||||
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
import 'package:patbond_flutter/features/auth/splash_page.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/main/main_shell_page.dart';
|
||||||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||||||
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
import 'package:patbond_flutter/features/pets/pet_analytics.dart';
|
||||||
@@ -25,12 +27,14 @@ class App extends StatefulWidget {
|
|||||||
this.sessionManager,
|
this.sessionManager,
|
||||||
this.authRepository,
|
this.authRepository,
|
||||||
this.petsRepository,
|
this.petsRepository,
|
||||||
|
this.communityRepository,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
/// 测试注入口;生产默认走安全存储 + 真实 API。
|
||||||
final SessionManager? sessionManager;
|
final SessionManager? sessionManager;
|
||||||
final AuthRepository? authRepository;
|
final AuthRepository? authRepository;
|
||||||
final PetsRepository? petsRepository;
|
final PetsRepository? petsRepository;
|
||||||
|
final CommunityRepository? communityRepository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<App> createState() => _AppState();
|
State<App> createState() => _AppState();
|
||||||
@@ -41,6 +45,7 @@ class _AppState extends State<App> {
|
|||||||
late final SessionManager sessionManager;
|
late final SessionManager sessionManager;
|
||||||
late final AuthRepository authRepository;
|
late final AuthRepository authRepository;
|
||||||
late final PetsController petsController;
|
late final PetsController petsController;
|
||||||
|
late final CommunityController communityController;
|
||||||
late final PetAnalytics petAnalytics;
|
late final PetAnalytics petAnalytics;
|
||||||
late final HealthRecordAnalytics healthRecordAnalytics;
|
late final HealthRecordAnalytics healthRecordAnalytics;
|
||||||
late final SessionTracker _sessionTracker;
|
late final SessionTracker _sessionTracker;
|
||||||
@@ -88,6 +93,10 @@ class _AppState extends State<App> {
|
|||||||
petsController = PetsController(
|
petsController = PetsController(
|
||||||
repository: widget.petsRepository ?? _buildPetsRepository(),
|
repository: widget.petsRepository ?? _buildPetsRepository(),
|
||||||
);
|
);
|
||||||
|
// T3-12 只装配数据层;Feed segment 的 UI 接线在 T3-14 挂入主壳。
|
||||||
|
communityController = CommunityController(
|
||||||
|
repository: widget.communityRepository ?? _buildCommunityRepository(),
|
||||||
|
);
|
||||||
petAnalytics = PetAnalytics(_analytics.trackEvent);
|
petAnalytics = PetAnalytics(_analytics.trackEvent);
|
||||||
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
|
healthRecordAnalytics = HealthRecordAnalytics(_analytics.trackEvent);
|
||||||
|
|
||||||
@@ -143,6 +152,20 @@ class _AppState extends State<App> {
|
|||||||
return ApiPetsRepository(api: api);
|
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() {
|
AnalyticsPageName? _resolveRootPage() {
|
||||||
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
// 回栈到无名根路由时解析当前认证状态页/主壳 Tab
|
||||||
return switch (sessionManager.status) {
|
return switch (sessionManager.status) {
|
||||||
@@ -153,9 +176,10 @@ class _AppState extends State<App> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _reportAuthStateChange() {
|
void _reportAuthStateChange() {
|
||||||
// 登出即清宠物档案内存副本(跨账号不泄漏;重登后列表页重新拉取)。
|
// 登出即清宠物档案与社区 Feed 内存副本(跨账号不泄漏;重登后重新拉取)。
|
||||||
if (sessionManager.status == AuthStatus.unauthenticated) {
|
if (sessionManager.status == AuthStatus.unauthenticated) {
|
||||||
petsController.reset();
|
petsController.reset();
|
||||||
|
communityController.reset();
|
||||||
}
|
}
|
||||||
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
// 认证状态机切页补点(03 §3.2 非路由曝光 1/2)
|
||||||
final page = switch (sessionManager.status) {
|
final page = switch (sessionManager.status) {
|
||||||
@@ -173,6 +197,7 @@ class _AppState extends State<App> {
|
|||||||
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
WidgetsBinding.instance.removeObserver(_sessionTracker);
|
||||||
appState.dispose();
|
appState.dispose();
|
||||||
petsController.dispose();
|
petsController.dispose();
|
||||||
|
communityController.dispose();
|
||||||
if (widget.sessionManager == null) sessionManager.dispose();
|
if (widget.sessionManager == null) sessionManager.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(pets 域体重 / 健康事件,
|
||||||
|
/// community 域 Feed / 我的帖子 / 评论 / 收藏共用;自 pet_models.dart 上移至 core)。
|
||||||
|
class CursorPage<T> {
|
||||||
|
const CursorPage({
|
||||||
|
required this.items,
|
||||||
|
required this.nextCursor,
|
||||||
|
required this.hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CursorPage.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(Map<String, dynamic>) itemFromJson,
|
||||||
|
) {
|
||||||
|
return CursorPage(
|
||||||
|
items: (json['items'] as List)
|
||||||
|
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
||||||
|
nextCursor: json['nextCursor'] as String?,
|
||||||
|
hasMore: json['hasMore'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<T> items;
|
||||||
|
final String? nextCursor;
|
||||||
|
final bool hasMore;
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ const String patbondPetApiBaseUrl = String.fromEnvironment(
|
|||||||
defaultValue: 'http://127.0.0.1:8083',
|
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 实例。
|
/// 构建全局共用的 Dio 实例。
|
||||||
///
|
///
|
||||||
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ abstract final class ApiCodes {
|
|||||||
|
|
||||||
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
|
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
|
||||||
static const careReminderRuleViolation = 42202;
|
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 调用的类型化异常。页面按类型映射为三层错误呈现
|
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/toggle_sync.dart';
|
||||||
|
|
||||||
|
/// Feed 首屏四态(同 pets 先例)。
|
||||||
|
enum FeedPhase { initial, loading, ready, error }
|
||||||
|
|
||||||
|
/// 尾部加载更多三态(游标累积流新增,pets 无此并发点)。
|
||||||
|
enum LoadMorePhase { idle, loading, error }
|
||||||
|
|
||||||
|
/// community feature 状态控制器(03 号评估 §2 分层:
|
||||||
|
/// Page/Widget → CommunityController → CommunityRepository → ApiClient)。
|
||||||
|
///
|
||||||
|
/// Tab 级单例(app.dart 装配注入主壳):Feed 是游标累积流,且详情页与
|
||||||
|
/// 首页共享同一份帖子内存副本(点赞状态跨页一致),不做页面级 state。
|
||||||
|
/// 评论列表只属详情页,按「页面级状态按页自建」纪律经 [repository]
|
||||||
|
/// 自取,不膨胀本控制器。服务端是唯一事实来源,内存副本仅作展示缓存。
|
||||||
|
class CommunityController extends ChangeNotifier {
|
||||||
|
CommunityController({required this._repository}) {
|
||||||
|
_likeSync = ToggleSync(
|
||||||
|
read: (id) {
|
||||||
|
final post = _postCache[id];
|
||||||
|
if (post != null) {
|
||||||
|
return ToggleReading(active: post.likedByMe, count: post.likeCount);
|
||||||
|
}
|
||||||
|
final card = _cardOrNull(id);
|
||||||
|
if (card == null) return null;
|
||||||
|
return ToggleReading(active: card.likedByMe, count: card.likeCount);
|
||||||
|
},
|
||||||
|
write: (id, active, count) =>
|
||||||
|
_writeInteraction(id, likedByMe: active, likeCount: count),
|
||||||
|
send: (id, target) async {
|
||||||
|
final state = target
|
||||||
|
? await _repository.likePost(id)
|
||||||
|
: await _repository.unlikePost(id);
|
||||||
|
return ToggleOutcome(active: state.liked, count: state.likeCount);
|
||||||
|
},
|
||||||
|
generation: () => _generation,
|
||||||
|
onError: _onToggleError,
|
||||||
|
);
|
||||||
|
_bookmarkSync = ToggleSync(
|
||||||
|
read: (id) {
|
||||||
|
final post = _postCache[id];
|
||||||
|
if (post != null) {
|
||||||
|
return ToggleReading(
|
||||||
|
active: post.bookmarkedByMe,
|
||||||
|
count: post.bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final card = _cardOrNull(id);
|
||||||
|
if (card == null) return null;
|
||||||
|
return ToggleReading(
|
||||||
|
active: card.bookmarkedByMe,
|
||||||
|
count: card.bookmarkCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
write: (id, active, count) =>
|
||||||
|
_writeInteraction(id, bookmarkedByMe: active, bookmarkCount: count),
|
||||||
|
send: (id, target) async {
|
||||||
|
final state = target
|
||||||
|
? await _repository.bookmarkPost(id)
|
||||||
|
: await _repository.unbookmarkPost(id);
|
||||||
|
return ToggleOutcome(
|
||||||
|
active: state.bookmarked,
|
||||||
|
count: state.bookmarkCount,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
generation: () => _generation,
|
||||||
|
onError: _onToggleError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CommunityRepository _repository;
|
||||||
|
|
||||||
|
/// 页面级状态(评论列表、我的帖子、收藏页等)按页直接经仓库取数。
|
||||||
|
CommunityRepository get repository => _repository;
|
||||||
|
|
||||||
|
late final ToggleSync _likeSync;
|
||||||
|
late final ToggleSync _bookmarkSync;
|
||||||
|
|
||||||
|
FeedPhase _phase = FeedPhase.initial;
|
||||||
|
LoadMorePhase _loadMorePhase = LoadMorePhase.idle;
|
||||||
|
List<FeedCard> _feed = const [];
|
||||||
|
String? _nextCursor;
|
||||||
|
bool _hasMore = false;
|
||||||
|
|
||||||
|
/// 首屏加载失败(error 态时非 null)。
|
||||||
|
ApiException? _lastError;
|
||||||
|
|
||||||
|
/// 刷新失败但旧列表被保留时的错误(页面 SnackBar 轻提示后消费)。
|
||||||
|
ApiException? _refreshError;
|
||||||
|
|
||||||
|
/// 加载更多失败(尾部重试条渲染依据)。
|
||||||
|
ApiException? _loadMoreError;
|
||||||
|
|
||||||
|
/// 最近一次点赞/收藏对账失败(T3-15/16 SnackBar 消费)。
|
||||||
|
ApiException? _toggleError;
|
||||||
|
|
||||||
|
/// 刷新代次:整体替换列表后,在途旧代次响应(尾页 / 互动对账)一律丢弃。
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
/// 详情内存副本(详情页与 Feed 卡片互动状态同源)。
|
||||||
|
final Map<String, Post> _postCache = {};
|
||||||
|
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
FeedPhase get phase => _phase;
|
||||||
|
LoadMorePhase get loadMorePhase => _loadMorePhase;
|
||||||
|
|
||||||
|
/// 累积的多页 Feed 缓存(服务端 published_at DESC, id DESC 原样保留)。
|
||||||
|
List<FeedCard> get feed => _feed;
|
||||||
|
bool get hasMore => _hasMore;
|
||||||
|
|
||||||
|
/// ready 且列表为空 → 空态。
|
||||||
|
bool get isEmpty => _phase == FeedPhase.ready && _feed.isEmpty;
|
||||||
|
|
||||||
|
ApiException? get lastError => _lastError;
|
||||||
|
ApiException? get refreshError => _refreshError;
|
||||||
|
ApiException? get loadMoreError => _loadMoreError;
|
||||||
|
ApiException? get toggleError => _toggleError;
|
||||||
|
|
||||||
|
/// 详情内存副本(进入详情页先渲染缓存,再 [getPost] 拉新)。
|
||||||
|
Post? cachedPost(String postId) => _postCache[postId];
|
||||||
|
|
||||||
|
/// 首屏加载 / 下拉刷新:丢弃游标从头拉第一页,成功后**整体替换**累积列表。
|
||||||
|
/// 刷新失败保留旧列表(不清空不闪空态),错误经 [refreshError] 轻提示;
|
||||||
|
/// 首屏(空列表)失败收敛为 error 态供页面渲染 + retry。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_generation += 1;
|
||||||
|
final generation = _generation;
|
||||||
|
_lastError = null;
|
||||||
|
_refreshError = null;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
_loadMoreError = null;
|
||||||
|
if (_feed.isEmpty) _phase = FeedPhase.loading;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.getFeed();
|
||||||
|
if (generation != _generation) return; // 期间又发生过刷新/登出。
|
||||||
|
_feed = page.items;
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_phase = FeedPhase.ready;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
if (_feed.isEmpty) {
|
||||||
|
_lastError = error;
|
||||||
|
_phase = FeedPhase.error;
|
||||||
|
} else {
|
||||||
|
_refreshError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滚动近底加载下一页:携带上一页 nextCursor(keyset 翻页不丢不重)。
|
||||||
|
/// 失败置 [LoadMorePhase.error] 渲染尾部重试条;期间发生过刷新的
|
||||||
|
/// 旧代次响应直接丢弃(避免「刷新后旧尾页追加」的重复/错位)。
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (_phase != FeedPhase.ready ||
|
||||||
|
!_hasMore ||
|
||||||
|
_loadMorePhase == LoadMorePhase.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final generation = _generation;
|
||||||
|
_loadMorePhase = LoadMorePhase.loading;
|
||||||
|
_loadMoreError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
final page = await _repository.getFeed(cursor: _nextCursor);
|
||||||
|
if (generation != _generation) return; // 旧代次尾页,丢弃。
|
||||||
|
_feed = [..._feed, ...page.items];
|
||||||
|
_nextCursor = page.nextCursor;
|
||||||
|
_hasMore = page.hasMore;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
if (generation != _generation) return;
|
||||||
|
_loadMoreError = error;
|
||||||
|
_loadMorePhase = LoadMorePhase.error;
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取帖子详情并同步内存副本(Feed 卡片互动字段一并回写)。
|
||||||
|
Future<Post> getPost(String postId) async {
|
||||||
|
final post = await _repository.getPost(postId);
|
||||||
|
_postCache[postId] = post;
|
||||||
|
_syncCardFromPost(post);
|
||||||
|
_notify();
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞/取消点赞(乐观翻转,终态由 [ToggleSync] 对账收敛,不外抛)。
|
||||||
|
void toggleLike(String postId) => _likeSync.toggle(postId);
|
||||||
|
|
||||||
|
/// 收藏/取消收藏(与点赞同构)。
|
||||||
|
void toggleBookmark(String postId) => _bookmarkSync.toggle(postId);
|
||||||
|
|
||||||
|
/// 消费一次性 toggle 错误(SnackBar 展示后清除)。
|
||||||
|
void clearToggleError() => _toggleError = null;
|
||||||
|
|
||||||
|
/// 登出清空:回 initial 态、清多页缓存与详情副本、丢弃全部在途链,
|
||||||
|
/// 避免上一账号数据跨会话泄漏;重登后主壳重建,Feed 页重新触发 [refresh]。
|
||||||
|
void reset() {
|
||||||
|
_generation += 1;
|
||||||
|
_phase = FeedPhase.initial;
|
||||||
|
_loadMorePhase = LoadMorePhase.idle;
|
||||||
|
_feed = const [];
|
||||||
|
_nextCursor = null;
|
||||||
|
_hasMore = false;
|
||||||
|
_lastError = null;
|
||||||
|
_refreshError = null;
|
||||||
|
_loadMoreError = null;
|
||||||
|
_toggleError = null;
|
||||||
|
_postCache.clear();
|
||||||
|
_likeSync.reset();
|
||||||
|
_bookmarkSync.reset();
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedCard? _cardOrNull(String postId) {
|
||||||
|
for (final card in _feed) {
|
||||||
|
if (card.id == postId) return card;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 互动字段统一写入口:详情副本与 Feed 卡片同步更新后 notify(同帧反馈)。
|
||||||
|
void _writeInteraction(
|
||||||
|
String postId, {
|
||||||
|
bool? likedByMe,
|
||||||
|
int? likeCount,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
int? bookmarkCount,
|
||||||
|
}) {
|
||||||
|
final post = _postCache[postId];
|
||||||
|
if (post != null) {
|
||||||
|
_postCache[postId] = post.copyWithInteraction(
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final index = _feed.indexWhere((card) => card.id == postId);
|
||||||
|
if (index != -1) {
|
||||||
|
_feed = [..._feed]
|
||||||
|
..[index] = _feed[index].copyWithInteraction(
|
||||||
|
likedByMe: likedByMe,
|
||||||
|
likeCount: likeCount,
|
||||||
|
bookmarkedByMe: bookmarkedByMe,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (post != null || index != -1) _notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncCardFromPost(Post post) {
|
||||||
|
final index = _feed.indexWhere((card) => card.id == post.id);
|
||||||
|
if (index == -1) return;
|
||||||
|
_feed = [..._feed]
|
||||||
|
..[index] = _feed[index].copyWithInteraction(
|
||||||
|
likedByMe: post.likedByMe,
|
||||||
|
likeCount: post.likeCount,
|
||||||
|
commentCount: post.commentCount,
|
||||||
|
bookmarkedByMe: post.bookmarkedByMe,
|
||||||
|
bookmarkCount: post.bookmarkCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onToggleError(String postId, Object error) {
|
||||||
|
if (error is ApiException) {
|
||||||
|
_toggleError = error;
|
||||||
|
} else {
|
||||||
|
_toggleError = ApiNetworkException('$error');
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// community / media 域类型化业务异常(契约 v1.3.0 定型的 9 个新错误码,
|
||||||
|
/// 20 号收口报告 §1;40902 乐观锁与 pets 域共码,本域映射为独立类型)。
|
||||||
|
/// 全部继承 [ApiBusinessException],既有按基类捕获的通用错误处理不受影响。
|
||||||
|
|
||||||
|
/// 40301:对可见帖子/评论无相应操作权限(改删他人已发布帖、
|
||||||
|
/// 删他人可见评论——含帖主删他人评论,D3-7 首版不做)。
|
||||||
|
final class PostAccessDeniedException extends ApiBusinessException {
|
||||||
|
const PostAccessDeniedException({required super.message})
|
||||||
|
: super(code: ApiCodes.postAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40403:帖子不存在 / 已软删 / hidden/archived(含作者)/ 他人 draft
|
||||||
|
/// (防枚举,全部情况响应完全一致;互动路径上含作者本人草稿)。
|
||||||
|
final class PostNotFoundException extends ApiBusinessException {
|
||||||
|
const PostNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.postNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40404:评论不存在、已删或所属帖子不可见(防枚举合并)。
|
||||||
|
final class CommentNotFoundException extends ApiBusinessException {
|
||||||
|
const CommentNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.commentNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40405:media asset 不存在、非本人所有或已删(防枚举合并)。
|
||||||
|
final class MediaAssetNotFoundException extends ApiBusinessException {
|
||||||
|
const MediaAssetNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40406:目标用户不存在或已注销(合并不泄露成因)。
|
||||||
|
final class CommunityUserNotFoundException extends ApiBusinessException {
|
||||||
|
const CommunityUserNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.communityUserNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:帖子编辑乐观锁版本冲突(version 过期)。
|
||||||
|
/// 客户端处理:刷新详情取新 version 后重提。
|
||||||
|
final class PostVersionConflictException extends ApiBusinessException {
|
||||||
|
const PostVersionConflictException({required super.message})
|
||||||
|
: super(code: ApiCodes.versionConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40905:同 Idempotency-Key 不同 payload(规范化 request_hash 不符)。
|
||||||
|
/// 客户端每次逻辑提交应换新键,重试间保持不变。
|
||||||
|
final class IdempotencyMismatchException extends ApiBusinessException {
|
||||||
|
const IdempotencyMismatchException({required super.message})
|
||||||
|
: super(code: ApiCodes.idempotencyKeyMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42203:引用了本人所有但非 ready(uploading/failed)状态的 asset。
|
||||||
|
final class MediaNotReadyException extends ApiBusinessException {
|
||||||
|
const MediaNotReadyException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaNotReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42204:自关注(仅 PUT;自取关走 DELETE 的 200 幂等 no-op)。
|
||||||
|
final class SelfFollowException extends ApiBusinessException {
|
||||||
|
const SelfFollowException({required super.message})
|
||||||
|
: super(code: ApiCodes.selfFollow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42205:上传状态不允许确认——对象未上传(保持 uploading 可重试)、
|
||||||
|
/// 大小/类型不符(置 failed 终态)、failed 态再确认(已 ready 幂等 200 除外)。
|
||||||
|
final class MediaUploadStateException extends ApiBusinessException {
|
||||||
|
const MediaUploadStateException({required super.message})
|
||||||
|
: super(code: ApiCodes.mediaUploadStateInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把通用业务异常按 community/media 域错误码升格为类型化异常;
|
||||||
|
/// 未覆盖的码(40000 参数错误、40401 宠物防枚举等)原样返回,沿用通用处理。
|
||||||
|
ApiBusinessException mapCommunityBusinessException(ApiBusinessException error) {
|
||||||
|
return switch (error.code) {
|
||||||
|
ApiCodes.postAccessDenied => PostAccessDeniedException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.postNotFound => PostNotFoundException(message: error.message),
|
||||||
|
ApiCodes.commentNotFound => CommentNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.mediaNotFound => MediaAssetNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.communityUserNotFound => CommunityUserNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.versionConflict => PostVersionConflictException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.idempotencyKeyMismatch => IdempotencyMismatchException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.mediaNotReady => MediaNotReadyException(message: error.message),
|
||||||
|
ApiCodes.selfFollow => SelfFollowException(message: error.message),
|
||||||
|
ApiCodes.mediaUploadStateInvalid => MediaUploadStateException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
_ => error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
/// community / media 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.3.0,
|
||||||
|
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||||||
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||||
|
|
||||||
|
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
|
||||||
|
for (final value in values) {
|
||||||
|
if (value.name == raw) return value;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 $field 取值:$raw');
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateTimeOrNull(Object? value) =>
|
||||||
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
|
/// 帖子分类。ai_creation 为 M4 预留值,仅读侧出现(M3 提交即 400/40000)。
|
||||||
|
enum PostCategory {
|
||||||
|
general('general'),
|
||||||
|
help('help'),
|
||||||
|
aiCreation('ai_creation');
|
||||||
|
|
||||||
|
const PostCategory(this.wire);
|
||||||
|
|
||||||
|
/// 契约线上取值(aiCreation 的枚举名与线上 snake_case 不同,序列化走本值)。
|
||||||
|
final String wire;
|
||||||
|
|
||||||
|
static PostCategory fromJson(String value) {
|
||||||
|
for (final category in values) {
|
||||||
|
if (category.wire == value) return category;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 category 取值:$value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子状态。hidden/archived(运营态)永不出现在响应(对作者与他人一律
|
||||||
|
/// 404/40403),枚举保持两值。
|
||||||
|
enum PostStatus {
|
||||||
|
draft,
|
||||||
|
published;
|
||||||
|
|
||||||
|
static PostStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子可见性。M3 恒 public(followers/private 语义后置,字段保留)。
|
||||||
|
enum PostVisibility {
|
||||||
|
public;
|
||||||
|
|
||||||
|
static PostVisibility fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'visibility');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset 类型。M3 仅 image(视频后置,video/document 为向后新增预留)。
|
||||||
|
enum MediaKind {
|
||||||
|
image;
|
||||||
|
|
||||||
|
static MediaKind fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'kind');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传用途白名单(M3 定型仅 post_image,决定 objectKey 前缀)。
|
||||||
|
enum MediaPurpose {
|
||||||
|
postImage('post_image');
|
||||||
|
|
||||||
|
const MediaPurpose(this.wire);
|
||||||
|
|
||||||
|
final String wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset 状态。deleted 态对外恒 404/40405,不出现在响应。
|
||||||
|
enum MediaAssetStatus {
|
||||||
|
uploading,
|
||||||
|
ready,
|
||||||
|
failed;
|
||||||
|
|
||||||
|
static MediaAssetStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 作者公开摘要(D3-9 方案 B)。正常路径 nickname 恒非空(空昵称由服务端
|
||||||
|
/// 回退为 username,客户端不做回退拼装);nickname 与 avatarUrl 同为 null
|
||||||
|
/// 即「降级/墓碑」形态(作者资料暂不可得或已注销),客户端只需一种占位逻辑。
|
||||||
|
class AuthorSummary {
|
||||||
|
const AuthorSummary({
|
||||||
|
required this.userId,
|
||||||
|
required this.nickname,
|
||||||
|
required this.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthorSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AuthorSummary(
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
nickname: json['nickname'] as String?,
|
||||||
|
// 时效性预签名 GET URL,每次响应现签,不得持久化、过期即重取。
|
||||||
|
avatarUrl: json['avatarUrl'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String? nickname;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
/// 降级/墓碑形态(id-only):页面渲染统一占位。
|
||||||
|
bool get isDegraded => nickname == null && avatarUrl == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子挂接的一张图(响应形态)。url 为时效性预签名 GET(TTL 默认 1 小时),
|
||||||
|
/// 每次响应现签,客户端不得持久化、过期即重取。
|
||||||
|
class PostMediaItem {
|
||||||
|
const PostMediaItem({
|
||||||
|
required this.assetId,
|
||||||
|
required this.position,
|
||||||
|
required this.isCover,
|
||||||
|
required this.url,
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostMediaItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PostMediaItem(
|
||||||
|
assetId: json['assetId'] as String,
|
||||||
|
position: json['position'] as int,
|
||||||
|
isCover: json['isCover'] as bool,
|
||||||
|
url: json['url'] as String,
|
||||||
|
widthPx: json['widthPx'] as int?,
|
||||||
|
heightPx: json['heightPx'] as int?,
|
||||||
|
caption: json['caption'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final int position;
|
||||||
|
final bool isCover;
|
||||||
|
final String url;
|
||||||
|
final int? widthPx;
|
||||||
|
final int? heightPx;
|
||||||
|
final String? caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子挂接的一张图(请求形态)。position 全给或全不给(全给须恰为 0..n-1
|
||||||
|
/// 连续不重复,混合 400/40000);isCover 至多一个 true;caption trim 后 ≤300。
|
||||||
|
class PostMediaAttachRequest {
|
||||||
|
const PostMediaAttachRequest({
|
||||||
|
required this.assetId,
|
||||||
|
this.position,
|
||||||
|
this.isCover,
|
||||||
|
this.caption,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final int? position;
|
||||||
|
final bool? isCover;
|
||||||
|
final String? caption;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'assetId': assetId,
|
||||||
|
if (position != null) 'position': position,
|
||||||
|
if (isCover != null) 'isCover': isCover,
|
||||||
|
if (caption != null) 'caption': caption,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建帖子请求。content 必填(纯文字帖合法,media 空数组或缺席);
|
||||||
|
/// status=published 即创建即发布(服务端写 publishedAt)。
|
||||||
|
class CreatePostRequest {
|
||||||
|
const CreatePostRequest({
|
||||||
|
required this.content,
|
||||||
|
this.title,
|
||||||
|
this.category,
|
||||||
|
this.status,
|
||||||
|
this.petId,
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
final String? title;
|
||||||
|
final PostCategory? category;
|
||||||
|
final PostStatus? status;
|
||||||
|
final String? petId;
|
||||||
|
final List<PostMediaAttachRequest>? media;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'content': content,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (category != null) 'category': category!.wire,
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
if (petId != null) 'petId': petId,
|
||||||
|
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑帖子 / 发布草稿请求(部分更新:缺席字段不变,不支持清空回 null;
|
||||||
|
/// version 乐观锁必带)。
|
||||||
|
///
|
||||||
|
/// - [publish]:`status: published` 状态迁移(draft→published 唯一开放迁移;
|
||||||
|
/// 对已发布帖重复提交为幂等 no-op,弱网重发不报错)。
|
||||||
|
/// - [media] 三态:null = 缺席不动;`[]` = 清空为纯文字帖;非空 = 整组替换。
|
||||||
|
class UpdatePostRequest {
|
||||||
|
const UpdatePostRequest({
|
||||||
|
required this.version,
|
||||||
|
this.title,
|
||||||
|
this.content,
|
||||||
|
this.category,
|
||||||
|
this.petId,
|
||||||
|
this.publish = false,
|
||||||
|
this.media,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? title;
|
||||||
|
final String? content;
|
||||||
|
final PostCategory? category;
|
||||||
|
final String? petId;
|
||||||
|
final bool publish;
|
||||||
|
final List<PostMediaAttachRequest>? media;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (content != null) 'content': content,
|
||||||
|
if (category != null) 'category': category!.wire,
|
||||||
|
if (petId != null) 'petId': petId,
|
||||||
|
if (publish) 'status': PostStatus.published.name,
|
||||||
|
if (media != null) 'media': media!.map((item) => item.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。
|
||||||
|
/// 「内容是否编辑过」以 version 为准(互动计数维护亦会推动 updatedAt)。
|
||||||
|
class Post {
|
||||||
|
const Post({
|
||||||
|
required this.id,
|
||||||
|
required this.author,
|
||||||
|
required this.petId,
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.status,
|
||||||
|
required this.visibility,
|
||||||
|
required this.media,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.bookmarkCount,
|
||||||
|
required this.likedByMe,
|
||||||
|
required this.bookmarkedByMe,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.publishedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Post.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Post(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
petId: json['petId'] as String?,
|
||||||
|
category: PostCategory.fromJson(json['category'] as String),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
content: json['content'] as String,
|
||||||
|
status: PostStatus.fromJson(json['status'] as String),
|
||||||
|
visibility: PostVisibility.fromJson(json['visibility'] as String),
|
||||||
|
media: (json['media'] as List)
|
||||||
|
.map((item) => PostMediaItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
commentCount: json['commentCount'] as int,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
likedByMe: json['likedByMe'] as bool,
|
||||||
|
bookmarkedByMe: json['bookmarkedByMe'] as bool,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
// 仅 published 非空(发布时恰写一次)。
|
||||||
|
publishedAt: _dateTimeOrNull(json['publishedAt']),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final String? petId;
|
||||||
|
final PostCategory category;
|
||||||
|
final String? title;
|
||||||
|
final String content;
|
||||||
|
final PostStatus status;
|
||||||
|
final PostVisibility visibility;
|
||||||
|
final List<PostMediaItem> media;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final bool likedByMe;
|
||||||
|
final bool bookmarkedByMe;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final DateTime? publishedAt;
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
|
||||||
|
Post copyWithInteraction({
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? bookmarkCount,
|
||||||
|
bool? likedByMe,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
}) {
|
||||||
|
return Post(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
petId: petId,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
status: status,
|
||||||
|
visibility: visibility,
|
||||||
|
media: media,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||||
|
likedByMe: likedByMe ?? this.likedByMe,
|
||||||
|
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
version: version,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed / 收藏列表卡片形态(较 Post 裁剪:只带 coverImage + mediaCount,
|
||||||
|
/// 全文恒走帖子详情端点)。publishedAt 恒非空(谓词只放行 published)。
|
||||||
|
class FeedCard {
|
||||||
|
const FeedCard({
|
||||||
|
required this.id,
|
||||||
|
required this.author,
|
||||||
|
required this.category,
|
||||||
|
required this.title,
|
||||||
|
required this.contentPreview,
|
||||||
|
required this.coverImage,
|
||||||
|
required this.mediaCount,
|
||||||
|
required this.likeCount,
|
||||||
|
required this.commentCount,
|
||||||
|
required this.bookmarkCount,
|
||||||
|
required this.likedByMe,
|
||||||
|
required this.bookmarkedByMe,
|
||||||
|
required this.publishedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FeedCard.fromJson(Map<String, dynamic> json) {
|
||||||
|
final cover = json['coverImage'];
|
||||||
|
return FeedCard(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
category: PostCategory.fromJson(json['category'] as String),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
contentPreview: json['contentPreview'] as String,
|
||||||
|
// 封面 = 库中唯一 is_cover 行;纯文字帖为 null。
|
||||||
|
coverImage: cover == null
|
||||||
|
? null
|
||||||
|
: PostMediaItem.fromJson(cover as Map<String, dynamic>),
|
||||||
|
mediaCount: json['mediaCount'] as int,
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
commentCount: json['commentCount'] as int,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
likedByMe: json['likedByMe'] as bool,
|
||||||
|
bookmarkedByMe: json['bookmarkedByMe'] as bool,
|
||||||
|
publishedAt: DateTime.parse(json['publishedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final PostCategory category;
|
||||||
|
final String? title;
|
||||||
|
final String contentPreview;
|
||||||
|
final PostMediaItem? coverImage;
|
||||||
|
final int mediaCount;
|
||||||
|
final int likeCount;
|
||||||
|
final int commentCount;
|
||||||
|
final int bookmarkCount;
|
||||||
|
final bool likedByMe;
|
||||||
|
final bool bookmarkedByMe;
|
||||||
|
final DateTime publishedAt;
|
||||||
|
|
||||||
|
/// 互动字段副本更新(乐观翻转 / 权威终态对账用,其余字段不变)。
|
||||||
|
FeedCard copyWithInteraction({
|
||||||
|
int? likeCount,
|
||||||
|
int? commentCount,
|
||||||
|
int? bookmarkCount,
|
||||||
|
bool? likedByMe,
|
||||||
|
bool? bookmarkedByMe,
|
||||||
|
}) {
|
||||||
|
return FeedCard(
|
||||||
|
id: id,
|
||||||
|
author: author,
|
||||||
|
category: category,
|
||||||
|
title: title,
|
||||||
|
contentPreview: contentPreview,
|
||||||
|
coverImage: coverImage,
|
||||||
|
mediaCount: mediaCount,
|
||||||
|
likeCount: likeCount ?? this.likeCount,
|
||||||
|
commentCount: commentCount ?? this.commentCount,
|
||||||
|
bookmarkCount: bookmarkCount ?? this.bookmarkCount,
|
||||||
|
likedByMe: likedByMe ?? this.likedByMe,
|
||||||
|
bookmarkedByMe: bookmarkedByMe ?? this.bookmarkedByMe,
|
||||||
|
publishedAt: publishedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建评论请求。content trim 后 1~2000;replyToUserId 可选 @ 回复目标
|
||||||
|
/// (单层平铺,无楼中楼)。
|
||||||
|
class CreateCommentRequest {
|
||||||
|
const CreateCommentRequest({required this.content, this.replyToUserId});
|
||||||
|
|
||||||
|
final String content;
|
||||||
|
final String? replyToUserId;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'content': content,
|
||||||
|
if (replyToUserId != null) 'replyToUserId': replyToUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 评论(M3 无评论编辑,不带 updatedAt)。
|
||||||
|
class PostComment {
|
||||||
|
const PostComment({
|
||||||
|
required this.id,
|
||||||
|
required this.postId,
|
||||||
|
required this.author,
|
||||||
|
required this.replyToUser,
|
||||||
|
required this.content,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PostComment.fromJson(Map<String, dynamic> json) {
|
||||||
|
final replyTo = json['replyToUser'];
|
||||||
|
return PostComment(
|
||||||
|
id: json['id'] as String,
|
||||||
|
postId: json['postId'] as String,
|
||||||
|
author: AuthorSummary.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
// @ 回复目标公开摘要(含降级 id-only 形态);非回复为 null。
|
||||||
|
replyToUser: replyTo == null
|
||||||
|
? null
|
||||||
|
: AuthorSummary.fromJson(replyTo as Map<String, dynamic>),
|
||||||
|
content: json['content'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String postId;
|
||||||
|
final AuthorSummary author;
|
||||||
|
final AuthorSummary? replyToUser;
|
||||||
|
final String content;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建上传请求(两步上传第一步)。mimeType 白名单
|
||||||
|
/// image/jpeg|png|webp,byteSize ≤ 10485760(10 MiB,服务端配置项);
|
||||||
|
/// sha256 可选(64 位小写 hex,M3 照收照存不核验)。
|
||||||
|
class CreateMediaUploadRequest {
|
||||||
|
const CreateMediaUploadRequest({
|
||||||
|
required this.kind,
|
||||||
|
required this.purpose,
|
||||||
|
required this.mimeType,
|
||||||
|
required this.byteSize,
|
||||||
|
this.sha256,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaKind kind;
|
||||||
|
final MediaPurpose purpose;
|
||||||
|
final String mimeType;
|
||||||
|
final int byteSize;
|
||||||
|
final String? sha256;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'kind': kind.name,
|
||||||
|
'purpose': purpose.wire,
|
||||||
|
'mimeType': mimeType,
|
||||||
|
'byteSize': byteSize,
|
||||||
|
if (sha256 != null) 'sha256': sha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预签名直传凭据。凭据(uploadUrl 含签名)TTL 默认 10 分钟,过期后
|
||||||
|
/// 重新创建上传;直传必须原样携带 requiredHeaders(Content-Type 已签进
|
||||||
|
/// 签名,改动即被存储侧拒绝)。凭据会过期,不得持久化。
|
||||||
|
class MediaUploadCredentials {
|
||||||
|
const MediaUploadCredentials({
|
||||||
|
required this.assetId,
|
||||||
|
required this.uploadUrl,
|
||||||
|
required this.method,
|
||||||
|
required this.requiredHeaders,
|
||||||
|
required this.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaUploadCredentials.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MediaUploadCredentials(
|
||||||
|
assetId: json['assetId'] as String,
|
||||||
|
uploadUrl: json['uploadUrl'] as String,
|
||||||
|
method: json['method'] as String,
|
||||||
|
requiredHeaders: (json['requiredHeaders'] as Map<String, dynamic>).map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
),
|
||||||
|
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
|
final String uploadUrl;
|
||||||
|
|
||||||
|
/// 契约定型恒为 PUT(enum 单值;直传时按本值发起请求)。
|
||||||
|
final String method;
|
||||||
|
final Map<String, String> requiredHeaders;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// media asset(confirm 后的可引用形态)。url 仅 ready 态非空——时效性
|
||||||
|
/// 预签名 GET(TTL 默认 1 小时),每次响应现签,不得持久化、过期即重取。
|
||||||
|
class MediaAsset {
|
||||||
|
const MediaAsset({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.purpose,
|
||||||
|
required this.mimeType,
|
||||||
|
required this.byteSize,
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.status,
|
||||||
|
required this.url,
|
||||||
|
required this.readyAt,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MediaAsset.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MediaAsset(
|
||||||
|
id: json['id'] as String,
|
||||||
|
kind: MediaKind.fromJson(json['kind'] as String),
|
||||||
|
purpose: json['purpose'] as String,
|
||||||
|
mimeType: json['mimeType'] as String,
|
||||||
|
byteSize: json['byteSize'] as int?,
|
||||||
|
widthPx: json['widthPx'] as int?,
|
||||||
|
heightPx: json['heightPx'] as int?,
|
||||||
|
status: MediaAssetStatus.fromJson(json['status'] as String),
|
||||||
|
url: json['url'] as String?,
|
||||||
|
readyAt: _dateTimeOrNull(json['readyAt']),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final MediaKind kind;
|
||||||
|
final String purpose;
|
||||||
|
final String mimeType;
|
||||||
|
final int? byteSize;
|
||||||
|
final int? widthPx;
|
||||||
|
final int? heightPx;
|
||||||
|
final MediaAssetStatus status;
|
||||||
|
final String? url;
|
||||||
|
final DateTime? readyAt;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞权威终态(乐观更新以此对账回滚,回滚基准取响应值)。
|
||||||
|
class LikeState {
|
||||||
|
const LikeState({required this.liked, required this.likeCount});
|
||||||
|
|
||||||
|
factory LikeState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LikeState(
|
||||||
|
liked: json['liked'] as bool,
|
||||||
|
likeCount: json['likeCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool liked;
|
||||||
|
final int likeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏权威终态(与点赞同构)。
|
||||||
|
class BookmarkState {
|
||||||
|
const BookmarkState({required this.bookmarked, required this.bookmarkCount});
|
||||||
|
|
||||||
|
factory BookmarkState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BookmarkState(
|
||||||
|
bookmarked: json['bookmarked'] as bool,
|
||||||
|
bookmarkCount: json['bookmarkCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool bookmarked;
|
||||||
|
final int bookmarkCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注权威终态;followerCount 为目标用户的粉丝数(实时 COUNT)。
|
||||||
|
class FollowState {
|
||||||
|
const FollowState({required this.following, required this.followerCount});
|
||||||
|
|
||||||
|
factory FollowState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FollowState(
|
||||||
|
following: json['following'] as bool,
|
||||||
|
followerCount: json['followerCount'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool following;
|
||||||
|
final int followerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关注计数(关注数 / 粉丝数 / 我是否已关注;查自己 followedByMe 恒 false)。
|
||||||
|
class FollowStats {
|
||||||
|
const FollowStats({
|
||||||
|
required this.followerCount,
|
||||||
|
required this.followingCount,
|
||||||
|
required this.followedByMe,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FollowStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FollowStats(
|
||||||
|
followerCount: json['followerCount'] as int,
|
||||||
|
followingCount: json['followingCount'] as int,
|
||||||
|
followedByMe: json['followedByMe'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int followerCount;
|
||||||
|
final int followingCount;
|
||||||
|
final bool followedByMe;
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// community / media 域仓库接口(契约 v1.3.0 的 13 路径 / 19 操作全覆盖;
|
||||||
|
/// 页面依赖此抽象,widget / controller 测试注入假实现)。
|
||||||
|
///
|
||||||
|
/// media 两步上传本单只到协议层(创建上传 / 确认上传);预签名 PUT
|
||||||
|
/// 直传对象存储不走业务信封与 Bearer 鉴权,属 T3-13 独立客户端。
|
||||||
|
abstract class CommunityRepository {
|
||||||
|
// ---- media 两步上传(协议层)----
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
);
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId);
|
||||||
|
|
||||||
|
// ---- 帖子 CRUD / 发布 ----
|
||||||
|
Future<Post> createPost(CreatePostRequest request);
|
||||||
|
Future<Post> getPost(String postId);
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request);
|
||||||
|
Future<void> deletePost(String postId);
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Feed(cursor 分页)----
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor});
|
||||||
|
|
||||||
|
// ---- 评论 ----
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
);
|
||||||
|
Future<void> deleteComment(String commentId);
|
||||||
|
|
||||||
|
// ---- 点赞 / 收藏(PUT/DELETE 语义幂等,响应权威终态)----
|
||||||
|
Future<LikeState> likePost(String postId);
|
||||||
|
Future<LikeState> unlikePost(String postId);
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId);
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId);
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor});
|
||||||
|
|
||||||
|
// ---- 关注 ----
|
||||||
|
Future<FollowState> followUser(String userId);
|
||||||
|
Future<FollowState> unfollowUser(String userId);
|
||||||
|
Future<FollowStats> getFollowStats(String userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||||||
|
/// 拦截 + 401/40101 单飞刷新重放);community 域业务错误码升格为类型化异常。
|
||||||
|
///
|
||||||
|
/// 幂等:createPost / createComment 两个 POST 按契约**必带**
|
||||||
|
/// `Idempotency-Key`(1~128 字符;每次逻辑提交换新键;token 刷新后的
|
||||||
|
/// 自动重放沿用同一个键——键在本层每次调用生成一次,重放走同一 headers)。
|
||||||
|
/// 点赞/收藏/关注为 PUT/DELETE 语义幂等,无需幂等键。
|
||||||
|
class ApiCommunityRepository implements CommunityRepository {
|
||||||
|
ApiCommunityRepository({required this._api, this._uuid = const Uuid()});
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
Future<Object?> _request(
|
||||||
|
String path, {
|
||||||
|
String method = 'GET',
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
bool idempotent = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
throw mapCommunityBusinessException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// ---- media ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/media/uploads',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return MediaUploadCredentials.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId) async {
|
||||||
|
// 幂等由服务端保证:已 ready 重复 confirm 返回 200 同一 asset。
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/media/uploads/$assetId/complete',
|
||||||
|
method: 'POST',
|
||||||
|
);
|
||||||
|
return MediaAsset.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- posts ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> createPost(CreatePostRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> getPost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId');
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Post.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePost(String postId) async {
|
||||||
|
await _request('/api/v1/posts/$postId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/me/posts',
|
||||||
|
query: {
|
||||||
|
'limit': ?limit,
|
||||||
|
'cursor': ?cursor,
|
||||||
|
if (status != null) 'status': status.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), Post.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- feed ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/feed',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- comments ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/comments',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), PostComment.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/comments',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return PostComment.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteComment(String commentId) async {
|
||||||
|
// 顶层短路径先例:commentId 全局唯一。
|
||||||
|
await _request('/api/v1/comments/$commentId', method: 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interactions ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> likePost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId/like', method: 'PUT');
|
||||||
|
return LikeState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> unlikePost(String postId) async {
|
||||||
|
final data = await _request('/api/v1/posts/$postId/like', method: 'DELETE');
|
||||||
|
return LikeState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/bookmark',
|
||||||
|
method: 'PUT',
|
||||||
|
);
|
||||||
|
return BookmarkState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/posts/$postId/bookmark',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
|
return BookmarkState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/me/bookmarks',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), FeedCard.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- follows ----
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> followUser(String userId) async {
|
||||||
|
final data = await _request('/api/v1/users/$userId/follow', method: 'PUT');
|
||||||
|
return FollowState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> unfollowUser(String userId) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/users/$userId/follow',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
|
return FollowState.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowStats> getFollowStats(String userId) async {
|
||||||
|
final data = await _request('/api/v1/users/$userId/follow-stats');
|
||||||
|
return FollowStats.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 服务端返回的开关权威终态(LikeState / BookmarkState 的统一投影)。
|
||||||
|
class ToggleOutcome {
|
||||||
|
const ToggleOutcome({required this.active, required this.count});
|
||||||
|
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 内存副本当前读数(乐观翻转与回滚校验的基准)。
|
||||||
|
class ToggleReading {
|
||||||
|
const ToggleReading({required this.active, required this.count});
|
||||||
|
|
||||||
|
final bool active;
|
||||||
|
final int count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点赞 / 收藏共用的乐观更新小状态机(03 号评估 §3 定稿的数据层部分):
|
||||||
|
/// **乐观翻转 + 快照回滚 + 单飞合并意图 + 代次守卫**。字段读写与端点
|
||||||
|
/// 全部参数化,like / bookmark 各持一实例,不复制两份逻辑。
|
||||||
|
///
|
||||||
|
/// 对每个 id 的一轮「操作链」:
|
||||||
|
/// 1. 点击立即经 [write] 翻转内存副本(UI 同帧反馈由持有方 notify);
|
||||||
|
/// 2. 非在途则记快照、发请求(PUT/DELETE 语义幂等,重放安全);
|
||||||
|
/// 在途则只把新意图并入 pendingTarget,**不发新请求**(单飞);
|
||||||
|
/// 3. 成功:pendingTarget 与已确认态不一致 → 以 pendingTarget 补发一次
|
||||||
|
/// (连续快速点击至多两个在途请求,中间抖动全被合并);一致 → 用服务端
|
||||||
|
/// 权威计数覆盖乐观计数(吸收他人并发造成的偏差),清状态;
|
||||||
|
/// 4. 失败:恢复链起点快照(先校验 id 仍可读且当前态仍是本轮乐观目标,
|
||||||
|
/// 避免覆盖新数据),经 [onError] 轻提示,**不自动重试**;
|
||||||
|
/// 5. 代次守卫:响应到达时 [generation] 与链起点不符(期间发生过刷新,
|
||||||
|
/// 列表已被服务端数据整体替换)→ 丢弃该响应,不覆盖不回滚。
|
||||||
|
class ToggleSync {
|
||||||
|
ToggleSync({
|
||||||
|
required this._read,
|
||||||
|
required this._write,
|
||||||
|
required this._send,
|
||||||
|
required this._generation,
|
||||||
|
this._onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ToggleReading? Function(String id) _read;
|
||||||
|
final void Function(String id, bool active, int count) _write;
|
||||||
|
final Future<ToggleOutcome> Function(String id, bool target) _send;
|
||||||
|
final int Function() _generation;
|
||||||
|
final void Function(String id, Object error)? _onError;
|
||||||
|
|
||||||
|
final Map<String, _ToggleChain> _chains = {};
|
||||||
|
|
||||||
|
/// 该 id 是否有请求在途(测试与调试观测口)。
|
||||||
|
bool isInFlight(String id) => _chains.containsKey(id);
|
||||||
|
|
||||||
|
/// 翻转一次。同步完成乐观写入;网络往返在后台收敛,不外抛。
|
||||||
|
void toggle(String id) {
|
||||||
|
final current = _read(id);
|
||||||
|
if (current == null) return; // 已不在列表(刷新剔除),本次点击作废。
|
||||||
|
final target = !current.active;
|
||||||
|
final optimisticCount = target
|
||||||
|
? current.count + 1
|
||||||
|
: (current.count - 1 < 0 ? 0 : current.count - 1);
|
||||||
|
_write(id, target, optimisticCount);
|
||||||
|
|
||||||
|
final chain = _chains[id];
|
||||||
|
if (chain != null) {
|
||||||
|
chain.pendingTarget = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final started = _ToggleChain(
|
||||||
|
snapshot: current,
|
||||||
|
generation: _generation(),
|
||||||
|
target: target,
|
||||||
|
);
|
||||||
|
_chains[id] = started;
|
||||||
|
unawaited(_run(id, started));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出 / 整体刷新清态:丢弃全部链(在途响应因代次或链失配被丢弃)。
|
||||||
|
void reset() => _chains.clear();
|
||||||
|
|
||||||
|
Future<void> _run(String id, _ToggleChain chain) async {
|
||||||
|
while (true) {
|
||||||
|
ToggleOutcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = await _send(id, chain.target);
|
||||||
|
} catch (error) {
|
||||||
|
if (_chains[id] == chain && _generation() == chain.generation) {
|
||||||
|
final current = _read(id);
|
||||||
|
final lastTarget = chain.pendingTarget ?? chain.target;
|
||||||
|
// 回滚前校验:id 仍可读且当前态仍是本轮乐观写入的目标态。
|
||||||
|
if (current != null && current.active == lastTarget) {
|
||||||
|
_write(id, chain.snapshot.active, chain.snapshot.count);
|
||||||
|
}
|
||||||
|
_onError?.call(id, error);
|
||||||
|
}
|
||||||
|
_release(id, chain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_chains[id] != chain || _generation() != chain.generation) {
|
||||||
|
_release(id, chain);
|
||||||
|
return; // 代次不符 / 已被 reset:丢弃响应,不覆盖不回滚。
|
||||||
|
}
|
||||||
|
|
||||||
|
final pending = chain.pendingTarget;
|
||||||
|
if (pending != null && pending != outcome.active) {
|
||||||
|
chain.target = pending;
|
||||||
|
chain.pendingTarget = null;
|
||||||
|
continue; // 以最终意图补发一次。
|
||||||
|
}
|
||||||
|
|
||||||
|
_write(id, outcome.active, outcome.count);
|
||||||
|
_release(id, chain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _release(String id, _ToggleChain chain) {
|
||||||
|
if (_chains[id] == chain) _chains.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一轮操作链的在途状态。
|
||||||
|
class _ToggleChain {
|
||||||
|
_ToggleChain({
|
||||||
|
required this.snapshot,
|
||||||
|
required this.generation,
|
||||||
|
required this.target,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 链起点快照(回滚基准)。
|
||||||
|
final ToggleReading snapshot;
|
||||||
|
|
||||||
|
/// 链起点的刷新代次。
|
||||||
|
final int generation;
|
||||||
|
|
||||||
|
/// 当前在途请求的目标态。
|
||||||
|
bool target;
|
||||||
|
|
||||||
|
/// 在途期间用户新点出的最终意图(完成后据此决定是否补发)。
|
||||||
|
bool? pendingTarget;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||||||
|
|
||||||
/// 物种(创建即定,不可修改)。
|
/// 物种(创建即定,不可修改)。
|
||||||
enum PetSpecies {
|
enum PetSpecies {
|
||||||
dog,
|
dog,
|
||||||
@@ -124,33 +126,6 @@ String dateToJson(DateTime date) {
|
|||||||
DateTime? _dateOrNull(Object? value) =>
|
DateTime? _dateOrNull(Object? value) =>
|
||||||
value == null ? null : DateTime.parse(value as String);
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。
|
|
||||||
class CursorPage<T> {
|
|
||||||
const CursorPage({
|
|
||||||
required this.items,
|
|
||||||
required this.nextCursor,
|
|
||||||
required this.hasMore,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory CursorPage.fromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
T Function(Map<String, dynamic>) itemFromJson,
|
|
||||||
) {
|
|
||||||
return CursorPage(
|
|
||||||
items: (json['items'] as List)
|
|
||||||
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
|
||||||
nextCursor: json['nextCursor'] as String?,
|
|
||||||
hasMore: json['hasMore'] as bool,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<T> items;
|
|
||||||
final String? nextCursor;
|
|
||||||
final bool hasMore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||||||
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
||||||
class Pet {
|
class Pet {
|
||||||
|
|||||||
@@ -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<String> 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<ApiNetworkException>());
|
||||||
|
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<ApiNetworkException>());
|
||||||
|
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<ApiNetworkException>());
|
||||||
|
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<CursorPage<FeedCard>>();
|
||||||
|
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<CursorPage<FeedCard>>();
|
||||||
|
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<void> 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<ApiNetworkException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async {
|
||||||
|
await loadOneCard();
|
||||||
|
final completers = <Completer<LikeState>>[];
|
||||||
|
repo.onLikeToggle = (id, target) {
|
||||||
|
final completer = Completer<LikeState>();
|
||||||
|
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 = <Completer<LikeState>>[];
|
||||||
|
repo.onLikeToggle = (id, target) {
|
||||||
|
final completer = Completer<LikeState>();
|
||||||
|
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<LikeState>();
|
||||||
|
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<LikeState>();
|
||||||
|
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<LikeState>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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': <Object?>[]});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> setUpWith(
|
||||||
|
Future<ResponseBody> 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<void> 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<PostAccessDeniedException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40403 → PostNotFoundException(防枚举合并)', () async {
|
||||||
|
await expectMapped(404, 40403, isA<PostNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40404 → CommentNotFoundException', () async {
|
||||||
|
await expectMapped(404, 40404, isA<CommentNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40405 → MediaAssetNotFoundException', () async {
|
||||||
|
await expectMapped(404, 40405, isA<MediaAssetNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40406 → CommunityUserNotFoundException', () async {
|
||||||
|
await expectMapped(404, 40406, isA<CommunityUserNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40902 → PostVersionConflictException(共码独立类型)', () async {
|
||||||
|
await expectMapped(409, 40902, isA<PostVersionConflictException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40905 → IdempotencyMismatchException', () async {
|
||||||
|
await expectMapped(409, 40905, isA<IdempotencyMismatchException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42203 → MediaNotReadyException', () async {
|
||||||
|
await expectMapped(422, 42203, isA<MediaNotReadyException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42204 → SelfFollowException', () async {
|
||||||
|
await expectMapped(422, 42204, isA<SelfFollowException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42205 → MediaUploadStateException', () async {
|
||||||
|
await expectMapped(422, 42205, isA<MediaUploadStateException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40401(pets 域码)不升格,保持通用 ApiBusinessException', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(404, errorEnvelope(40401, '宠物不存在')),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
repository.createPost(const CreatePostRequest(content: '带宠物')),
|
||||||
|
throwsA(
|
||||||
|
isA<ApiBusinessException>()
|
||||||
|
.having((e) => e.code, 'code', 40401)
|
||||||
|
.having((e) => e, 'type', isNot(isA<PostNotFoundException>())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
|
||||||
|
const error = SelfFollowException(message: '不能关注自己');
|
||||||
|
expect(error, isA<ApiBusinessException>());
|
||||||
|
expect(error.code, ApiCodes.selfFollow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('community 服务基地址常量存在且默认指向 :8084', () {
|
||||||
|
expect(patbondCommunityApiBaseUrl, 'http://127.0.0.1:8084');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> sampleCommentJson({
|
||||||
|
String id = 'c-1',
|
||||||
|
Map<String, dynamic>? replyToUser,
|
||||||
|
}) => {
|
||||||
|
'id': id,
|
||||||
|
'postId': 'p-1',
|
||||||
|
'author': sampleAuthorJson(),
|
||||||
|
'replyToUser': replyToUser,
|
||||||
|
'content': '好可爱!',
|
||||||
|
'createdAt': '2026-09-08T11:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> 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<String, Object?> cursorPageJson(
|
||||||
|
List<Map<String, dynamic>> 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<FeedCard> feedPage(
|
||||||
|
List<FeedCard> 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<String> calls = [];
|
||||||
|
|
||||||
|
Future<CursorPage<FeedCard>> Function(int? limit, String? cursor)? onFeed;
|
||||||
|
Future<LikeState> Function(String postId, bool target)? onLikeToggle;
|
||||||
|
Future<BookmarkState> Function(String postId, bool target)? onBookmarkToggle;
|
||||||
|
Future<Post> Function(String postId)? onGetPost;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) {
|
||||||
|
calls.add('feed:cursor=$cursor');
|
||||||
|
return onFeed!(limit, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> likePost(String postId) {
|
||||||
|
calls.add('like:$postId');
|
||||||
|
return onLikeToggle!(postId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LikeState> unlikePost(String postId) {
|
||||||
|
calls.add('unlike:$postId');
|
||||||
|
return onLikeToggle!(postId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> bookmarkPost(String postId) {
|
||||||
|
calls.add('bookmark:$postId');
|
||||||
|
return onBookmarkToggle!(postId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<BookmarkState> unbookmarkPost(String postId) {
|
||||||
|
calls.add('unbookmark:$postId');
|
||||||
|
return onBookmarkToggle!(postId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> getPost(String postId) {
|
||||||
|
calls.add('getPost:$postId');
|
||||||
|
return onGetPost!(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaUploadCredentials> createMediaUpload(
|
||||||
|
CreateMediaUploadRequest request,
|
||||||
|
) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaAsset> completeMediaUpload(String assetId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> createPost(CreatePostRequest request) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Post> updatePost(String postId, UpdatePostRequest request) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deletePost(String postId) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<Post>> listMyPosts({
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
PostStatus? status,
|
||||||
|
}) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<PostComment>> listComments(
|
||||||
|
String postId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PostComment> createComment(
|
||||||
|
String postId,
|
||||||
|
CreateCommentRequest request,
|
||||||
|
) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteComment(String commentId) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> followUser(String userId) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowState> unfollowUser(String userId) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FollowStats> getFollowStats(String userId) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user