feat(mobile): api client, session storage and contract-aligned models
P3 计划 Task 2(apps/mobile):
- core/config.dart:API_BASE/VIEWER_BASE 经 --dart-define 覆盖,缺省
10.0.2.2 模拟器宿主回环;viewer 相册链接构造 {VIEWER_BASE}/a/{token}。
- core/error_codes.dart:errors.ts 17 码 1:1 镜像(含注释),另设 4 个
客户端本地伪码(NETWORK_ERROR/NETWORK_TIMEOUT/CANCELLED/HTTP_ERROR)。
- core/api_client.dart:dio 封装——Bearer 注入(登录端点豁免)、401
驱逐(清会话 + onUnauthorized 回调去重,App 壳注册跳 /login)、
DioException/错误体 {code,message,requestId} 归一 AppException、
通用游标分页助手;auth/albums/members/categories/admin/uploads/photos
全端点方法;riverpod provider 出口。
- core/session_store.dart:SessionStore 抽象 + flutter_secure_storage
实现(token/expiresAt/account 三键,encode/decode 纯函数可测)。
- features/{auth,albums,uploads,photos}/models.dart:手写 fromJson/
toJson,逐字段对齐 openapi.yaml(类注释标注契约 schema/operationId);
locale 按字符串透传(zh-CN 标识符不兼容),业务枚举 Dart 化并在
未知值 fail-fast。
- 测试 66 项:17 码表驱动 + 网络/超时/取消 + 兜底映射、游标边界
(含 CursorPageMeta 不变式违约容错)、模型往返(fixture 转写自
openapi.yaml schema 与 api e2e 断言,来源在测试头注释)、拦截器
行为(手写 FakeAdapter:Bearer 注入/401 驱逐/登录豁免/并发去重/
尽力而为 logout/refresh 滑动续期)。flutter analyze 零告警。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
/// 游标分页页(P3 计划 Task 2 的唯一通用分页解析助手)。
|
||||
///
|
||||
/// 契约 `components.schemas.CursorPageMeta` + 各列表响应的 `items` 数组
|
||||
/// (AlbumList / MemberList / UploadSessionList / AdminPhotoList 均为
|
||||
/// allOf 内嵌本结构)。不变式(openapi.yaml):hasMore=true 当且仅当
|
||||
/// nextCursor≠null,两者必须成对填充。
|
||||
///
|
||||
/// [parse] 对违约响应做容错:hasMore=true 但 nextCursor 缺失时按末页处理
|
||||
/// (游标置 null),避免上层「还有下一页却拿不到游标」的死循环。
|
||||
class CursorPage<T> {
|
||||
const CursorPage({
|
||||
required this.items,
|
||||
required this.nextCursor,
|
||||
required this.hasMore,
|
||||
});
|
||||
|
||||
/// 从列表端点响应体解析一页。
|
||||
///
|
||||
/// [fromJson] 为条目模型的解析函数(如 `AlbumSummary.fromJson`)。
|
||||
static CursorPage<T> parse<T>(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
final items = (json['items'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.map((dynamic e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
final rawCursor = json['nextCursor'];
|
||||
final cursor = rawCursor is String && rawCursor.isNotEmpty
|
||||
? rawCursor
|
||||
: null;
|
||||
final declaredMore = json['hasMore'] as bool? ?? false;
|
||||
// 违约容错:无游标即视为末页(见类注释)。
|
||||
final effectiveMore = declaredMore && cursor != null;
|
||||
return CursorPage<T>(
|
||||
items: items,
|
||||
nextCursor: effectiveMore ? cursor : null,
|
||||
hasMore: effectiveMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// 本页条目。
|
||||
final List<T> items;
|
||||
|
||||
/// 下一页游标;null 表示没有更多数据。
|
||||
final String? nextCursor;
|
||||
|
||||
/// 是否还有下一页(与 [nextCursor] 成对)。
|
||||
final bool hasMore;
|
||||
}
|
||||
Reference in New Issue
Block a user