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:
浅念
2026-09-19 00:40:02 +08:00
parent 8bc50e07ef
commit f975321b67
18 changed files with 3430 additions and 0 deletions
+504
View File
@@ -0,0 +1,504 @@
/// cn-api 客户端(P3 计划 Task 2)。
///
/// - 契约权威:`packages/contracts/openapi.yaml`(servers.url=/v1)。
/// - Bearer 注入:请求拦截器从 [SessionStore] 读取令牌(登录端点豁免)。
/// - 401 处理:清空会话并触发 [onUnauthorized](App 壳注册的监听回调,
/// 负责跳转 /login);并发的后续 401 只通知一次,直至新会话写入。
/// 登录端点自身的 401(凭据错误)不触发会话驱逐。
/// - 错误归一:HTTP 错误体 `{code, message, requestId}` 与网络层失败一律
/// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。
library;
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/models.dart';
import '../features/auth/models.dart';
import '../features/photos/models.dart';
import '../features/uploads/models.dart';
import 'app_exception.dart';
import 'config.dart';
import 'error_codes.dart';
import 'models/cursor_page.dart';
import 'session_store.dart';
class ApiClient {
/// [dio] 可注入测试替身(自定义 HttpClientAdapter);[baseUrl] 缺省
/// 取 [ApiConfig.baseUrl](--dart-define API_BASE 覆盖)。
ApiClient({
required SessionStore sessionStore,
String baseUrl = ApiConfig.baseUrl,
Dio? dio,
}) : _session = sessionStore {
_dio = dio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
),
);
_dio.interceptors.add(
InterceptorsWrapper(onRequest: _attachSession, onError: _handleError),
);
}
late final Dio _dio;
final SessionStore _session;
/// 会话失效(401 驱逐)通知。App 壳启动时注册,例如:
///
/// ```dart
/// ref.read(apiClientProvider).onUnauthorized = () =>
/// navigatorKey.currentState
/// ?.pushNamedAndRemoveUntil('/login', (route) => false);
/// ```
void Function()? onUnauthorized;
/// 去重标记:并发 401 只通知一次;登录/续期成功写新会话后复位。
bool _unauthorizedNotified = false;
/// 暴露内部 Dio(presign PUT 直传等需要绕过统一封装的场景)。
Dio get dio => _dio;
void close({bool force = false}) => _dio.close(force: force);
/// 登录端点:401 = 凭据错误而非会话过期,不驱逐会话、不附加令牌。
static bool _isAuthExemptPath(String path) =>
path.endsWith('/v1/auth/password');
// ── 拦截器 ──
Future<void> _attachSession(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
try {
if (!_isAuthExemptPath(options.path)) {
final session = await _session.read();
if (session != null &&
session.token.isNotEmpty &&
!options.headers.containsKey('Authorization')) {
options.headers['Authorization'] = 'Bearer ${session.token}';
}
}
} on Object {
// 存储读取失败不阻断请求:按未登录裸发,由服务端 401 裁决。
} finally {
handler.next(options);
}
}
Future<void> _handleError(
DioException e,
ErrorInterceptorHandler handler,
) async {
if (e.response?.statusCode == 401 &&
!_isAuthExemptPath(e.requestOptions.path)) {
try {
// 先同步置位去重标记再做异步清除:并发 401 下仅首个触发通知。
final shouldNotify = !_unauthorizedNotified;
_unauthorizedNotified = true;
await _session.clear();
if (shouldNotify) {
onUnauthorized?.call();
}
} on Object {
// 会话清除/通知失败不阻断错误传播(下次 401 仍会重试清除)。
}
}
handler.next(e);
}
// ── 错误映射 ──
/// DioException → [AppException](公开静态,供表驱动单测直接调用)。
///
/// 优先取错误响应体 `{code, message, requestId}`(契约
/// `components.schemas.Error`);无错误体的响应按契约通用兜底
/// (openapi.yaml info.description:401→AUTH_TOKEN_EXPIRED、
/// 500→INTERNAL_ERROR);网络层失败映射客户端伪码(NETWORK_* 等)。
static AppException mapDioException(DioException e) {
final response = e.response;
if (response != null) {
final status = response.statusCode ?? 0;
final data = response.data;
if (data is Map) {
final Object? code = data['code'];
final Object? message = data['message'];
final Object? requestId = data['requestId'];
if (code is String && message is String) {
return AppException(
code: code,
message: message,
requestId: requestId is String ? requestId : null,
statusCode: status,
);
}
}
if (status == 401) {
return const AppException(
code: ErrorCodes.authTokenExpired,
message: '未认证或会话已过期',
statusCode: 401,
);
}
if (status >= 500) {
return AppException(
code: ErrorCodes.internalError,
message: '服务器内部错误',
statusCode: status,
);
}
return AppException(
code: ErrorCodes.httpError,
message: 'HTTP $status 且无契约错误体',
statusCode: status,
);
}
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
return const AppException(
code: ErrorCodes.networkTimeout,
message: '网络超时',
);
case DioExceptionType.cancel:
return const AppException(
code: ErrorCodes.cancelled,
message: '请求已取消',
);
case DioExceptionType.connectionError:
return const AppException(
code: ErrorCodes.networkError,
message: '网络连接失败',
);
case DioExceptionType.badCertificate:
return const AppException(
code: ErrorCodes.networkError,
message: '证书校验失败',
);
case DioExceptionType.badResponse: // 有响应必经上方分支,防御性兜底。
case DioExceptionType.unknown:
return AppException(
code: ErrorCodes.networkError,
message: e.message ?? '未知网络错误',
);
}
}
/// 统一发送:DioException 归一为 AppException 后抛出。
Future<T> _send<T>(Future<T> Function() run) async {
try {
return await run();
} on DioException catch (e) {
throw mapDioException(e);
}
}
// ── auth(契约 tag: auth)──
/// operationId `loginWithPassword`(POST /auth/password)。
///
/// 成功后将会话(令牌/过期时间/账号回显)写入 [SessionStore]。
Future<LoginResponse> login(String account, String password) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/auth/password',
data: {'account': account, 'password': password},
),
);
final result = LoginResponse.fromJson(resp.data!);
await _session.save(
Session(
token: result.sessionToken,
expiresAt: result.expiresAt,
account: account,
),
);
_unauthorizedNotified = false;
return result;
}
/// operationId `refreshSession`(POST /auth/refresh):令牌不变,
/// 滑动续期过期时间——同步回写 [SessionStore]。
Future<RefreshResponse> refresh() async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/auth/refresh'),
);
final result = RefreshResponse.fromJson(resp.data!);
final current = await _session.read();
if (current != null) {
await _session.save(
Session(
token: current.token,
expiresAt: result.expiresAt,
account: current.account,
),
);
}
return result;
}
/// operationId `logout`(POST /auth/logout)。尽力而为:服务端撤销
/// 失败不阻塞本地退出,本地会话恒被清除。
Future<void> logout() async {
try {
await _send(() => _dio.post<void>('/v1/auth/logout'));
} on AppException {
// 尽力而为(计划 Task 3 语义):错误吞掉,本地照常登出。
} finally {
await _session.clear();
_unauthorizedNotified = false;
}
}
// ── albums / members / categories / admin ──
/// operationId `listAlbums`(GET /albums,游标分页)。
Future<CursorPage<AlbumSummary>> listAlbums({String? cursor, int? limit}) =>
_getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit);
/// operationId `createAlbum`(POST /albums → 201)。
Future<Album> createAlbum(AlbumCreateRequest req) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/albums', data: req.toJson()),
);
return Album.fromJson(resp.data!);
}
/// operationId `getAlbum`(GET /albums/{albumId})。
Future<Album> getAlbum(String albumId) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>('/v1/albums/$albumId'),
);
return Album.fromJson(resp.data!);
}
/// operationId `updateAlbum`(PATCH /albums/{albumId},部分更新)。
Future<Album> updateAlbum(String albumId, AlbumUpdateRequest patch) async {
final resp = await _send(
() => _dio.patch<Map<String, dynamic>>(
'/v1/albums/$albumId',
data: patch.toJson(),
),
);
return Album.fromJson(resp.data!);
}
/// operationId `addAlbumMember`(POST /albums/{albumId}/members → 201)。
///
/// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。
Future<Member> addMember(String albumId, String userId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/albums/$albumId/members',
data: {'userId': userId},
),
);
return Member.fromJson(resp.data!);
}
/// operationId `listAlbumMembers`(GET /albums/{albumId}/members)。
Future<CursorPage<Member>> listMembers(
String albumId, {
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/albums/$albumId/members',
Member.fromJson,
cursor: cursor,
limit: limit,
);
/// operationId `removeAlbumMember`
/// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。
Future<void> removeMember(String albumId, String userId) =>
_send(() => _dio.delete<void>('/v1/albums/$albumId/members/$userId'));
/// operationId `lookupUsersByAccount`
/// (GET /admin/users?accountPrefix=,无游标,按账号升序)。
Future<List<AdminUserLookupItem>> lookupUsers(
String accountPrefix, {
int? limit,
}) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>(
'/v1/admin/users',
queryParameters: {
'accountPrefix': accountPrefix,
'limit': ?limit,
},
),
);
final items = resp.data!['items'] as List<dynamic>;
return items
.map(
(dynamic e) => AdminUserLookupItem.fromJson(
e as Map<String, dynamic>,
),
)
.toList();
}
/// operationId `createCategory`
/// (POST /albums/{albumId}/categories → 201)。
Future<Category> createCategory(
String albumId,
CategoryCreateRequest req,
) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/albums/$albumId/categories',
data: req.toJson(),
),
);
return Category.fromJson(resp.data!);
}
/// operationId `updateCategory`(PATCH /categories/{categoryId})。
Future<Category> updateCategory(
String categoryId,
CategoryUpdateRequest patch,
) async {
final resp = await _send(
() => _dio.patch<Map<String, dynamic>>(
'/v1/categories/$categoryId',
data: patch.toJson(),
),
);
return Category.fromJson(resp.data!);
}
/// operationId `deleteCategory`(DELETE /categories/{categoryId} → 204)。
Future<void> deleteCategory(String categoryId) =>
_send(() => _dio.delete<void>('/v1/categories/$categoryId'));
// ── uploads(契约 tag: uploads)──
/// operationId `initUpload`(POST /uploads → 201,clientTaskId 幂等)。
Future<UploadInitResponse> initUpload(UploadInitRequest req) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/uploads', data: req.toJson()),
);
return UploadInitResponse.fromJson(resp.data!);
}
/// operationId `renewUploadCredential`
/// (POST /uploads/{uploadId}/renew,结构同初始化响应)。
Future<UploadInitResponse> renewUpload(String uploadId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/uploads/$uploadId/renew'),
);
return UploadInitResponse.fromJson(resp.data!);
}
/// operationId `completeUpload`
/// (POST /uploads/{uploadId}/complete,幂等核验)。
Future<UploadCompleteResponse> completeUpload(String uploadId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/uploads/$uploadId/complete'),
);
return UploadCompleteResponse.fromJson(resp.data!);
}
/// operationId `listUploads`(GET /uploads,跨设备会话历史,新在前)。
Future<CursorPage<UploadSessionRecord>> listUploads({
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/uploads',
UploadSessionRecord.fromJson,
cursor: cursor,
limit: limit,
);
// ── photos(契约 tag: photos)──
/// operationId `listAlbumPhotos`
/// (GET /albums/{albumId}/photos,管理列表,全状态可见)。
Future<CursorPage<AdminPhoto>> listAlbumPhotos(
String albumId, {
PhotoVisibility? visibility,
String? categoryId,
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/albums/$albumId/photos',
AdminPhoto.fromJson,
cursor: cursor,
limit: limit,
extraQuery: {
if (visibility != null) 'visibility': visibility.wire,
'categoryId': ?categoryId,
},
);
/// operationId `updatePhoto`(PATCH /photos/{photoId}):仅允许
/// hidden ↔ published 切换,非法取值本地即抛 ArgumentError。
Future<PhotoRecord> updatePhotoVisibility(
String photoId,
PhotoVisibility visibility,
) async {
if (visibility != PhotoVisibility.hidden &&
visibility != PhotoVisibility.published) {
throw ArgumentError(
'updatePhotoVisibility 仅允许 hidden/published,收到 '
'${visibility.wire}',
);
}
final resp = await _send(
() => _dio.patch<Map<String, dynamic>>(
'/v1/photos/$photoId',
data: {'visibility': visibility.wire},
),
);
return PhotoRecord.fromJson(resp.data!);
}
/// operationId `deletePhoto`(DELETE /photos/{photoId} → 204 软删除)。
Future<void> deletePhoto(String photoId) =>
_send(() => _dio.delete<void>('/v1/photos/$photoId'));
// ── 通用游标分页 ──
/// 通用游标页请求:cursor/limit 仅在提供时下发,[extraQuery] 携带
/// 领域过滤参数。
Future<CursorPage<T>> _getPage<T>(
String path,
T Function(Map<String, dynamic>) fromJson, {
String? cursor,
int? limit,
Map<String, dynamic>? extraQuery,
}) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>(
path,
queryParameters: {
'cursor': ?cursor,
'limit': ?limit,
...?extraQuery,
},
),
);
return CursorPage.parse(resp.data!, fromJson);
}
}
/// 会话存储 Provider(生产实现:flutter_secure_storage)。
final sessionStoreProvider =
Provider<SessionStore>((ref) => SecureSessionStore());
/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized]
/// 跳转 /login(命名路由,见 lib/app/app.dart)。
final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(sessionStore: ref.watch(sessionStoreProvider));
ref.onDispose(client.close);
return client;
});
+35
View File
@@ -0,0 +1,35 @@
import 'error_codes.dart';
/// 统一 API 异常:HTTP 错误响应体 `{code, message, requestId}`(契约
/// `components.schemas.Error`)与网络层失败一律归一为本类型,映射逻辑见
/// `ApiClient.mapDioException`。
///
/// UI 层按 [code]([ErrorCodes])解析本地化文案;[message] 为服务器/
/// 客户端生成的诊断消息,不作为展示文案依据。
class AppException implements Exception {
const AppException({
required this.code,
required this.message,
this.requestId,
this.statusCode,
});
/// 稳定错误码(契约码或客户端伪码,见 [ErrorCodes])。
final String code;
/// 可读消息(诊断用)。
final String message;
/// 请求追踪 ID(服务端错误体携带时非空)。
final String? requestId;
/// HTTP 状态码(网络层失败/取消时为 null)。
final int? statusCode;
/// 会话过期/未认证(401 语义:令牌失效需重新登录)。
bool get isAuthExpired => code == ErrorCodes.authTokenExpired;
@override
String toString() => 'AppException(code: $code, statusCode: $statusCode, '
'requestId: $requestId, message: $message)';
}
+26
View File
@@ -0,0 +1,26 @@
/// 环境入口配置(P3 计划 Task 2)。
///
/// 值经 `--dart-define=KEY=value` 覆盖;缺省指向 Android 模拟器宿主回环
/// (10.0.2.2 = 模拟器内看到的宿主机 localhost,见计划「模拟器网络回环」
/// 偏差登记)。`String.fromEnvironment` 为编译期常量,不可运行时切换。
class ApiConfig {
const ApiConfig._();
/// cn-api 基地址(不含 `/v1` 前缀——契约 servers.url 为 `/v1`,
/// 由 [ApiClient] 统一拼接到各端点路径)。
static const String baseUrl = String.fromEnvironment(
'API_BASE',
defaultValue: 'http://10.0.2.2:8080',
);
/// viewer 观众端基地址(Task 4 分享二维码链接用)。
static const String viewerBase = String.fromEnvironment(
'VIEWER_BASE',
defaultValue: 'http://10.0.2.2:5175',
);
/// 观众端相册链接:`{VIEWER_BASE}/a/{token}`(Task 4 qr 分享用,
/// viewer 路由约定)。
static String viewerAlbumUrl(String publicToken) =>
'$viewerBase/a/$publicToken';
}
+77
View File
@@ -0,0 +1,77 @@
/// 服务端稳定错误码镜像(P3 计划 Task 2)。
///
/// 1:1 对照 `packages/contracts/src/errors.ts` 的 `ErrorCodes` 与
/// `packages/contracts/openapi.yaml` `components.schemas.Error` 的 enum
/// (17 项)。契约约定:客户端按 code 映射多语言文案(l10n 解析留在 UI
/// 层),`message` 仅供诊断。
///
/// 文件末尾的 [networkError] / [networkTimeout] / [cancelled] /
/// [httpError] 为 **客户端本地伪码**(契约外,仅存在于 AppException,
/// 服务端永不返回)。
abstract final class ErrorCodes {
/// 账号或密码错误(401)。
static const String authInvalidCredentials = 'AUTH_INVALID_CREDENTIALS';
/// 未认证:令牌缺失、无效或已过期(401)。
static const String authTokenExpired = 'AUTH_TOKEN_EXPIRED';
/// 登录失败次数过多(429;Task 13 契约修订,区别于凭据错误)。
static const String authRateLimited = 'AUTH_RATE_LIMITED';
/// 公开互动端点(likes/visits)基础限速 429(P2 Task 1 增补的通用码)。
static const String rateLimited = 'RATE_LIMITED';
/// 已认证但无操作权限(403)。
static const String authForbidden = 'AUTH_FORBIDDEN';
/// 框架/路由层面的资源不存在(404;业务 404 一律携带域码,P1a 增补)。
static const String notFound = 'NOT_FOUND';
/// 相册不存在或当前用户不可见(404,避免枚举)。
static const String albumNotFound = 'ALBUM_NOT_FOUND';
/// 相册不存在或未公开(404,观众端,不区分以避免枚举)。
static const String albumNotPublic = 'ALBUM_NOT_PUBLIC';
/// 相册已关闭上传(409)。
static const String albumUploadDisabled = 'ALBUM_UPLOAD_DISABLED';
/// 该用户已是相册成员(409)。
static const String memberConflict = 'MEMBER_CONFLICT';
/// 上传会话冲突:同 clientTaskId 参数不一致 / 状态不允许续签或完成(409)。
static const String uploadSessionConflict = 'UPLOAD_SESSION_CONFLICT';
/// 完成核验失败:对象缺失、字节数不符或完整性信息不可用(400)。
static const String uploadVerifyFailed = 'UPLOAD_VERIFY_FAILED';
/// 照片不存在(404)。
static const String photoNotFound = 'PHOTO_NOT_FOUND';
/// 照片不存在、未发布或不属于该相册(404,观众端)。
static const String photoNotPublic = 'PHOTO_NOT_PUBLIC';
/// 参数校验失败(400,经全局校验管道)。
static const String validationFailed = 'VALIDATION_FAILED';
/// 服务间 /internal/** HMAC 鉴权失败(401,P2;App 侧永不收到,
/// 仅为与 errors.ts 全量 1:1 对齐而镜像)。
static const String syncAuthFailed = 'SYNC_AUTH_FAILED';
/// 服务器内部错误(500)。
static const String internalError = 'INTERNAL_ERROR';
// ── 以下为客户端本地伪码(契约外,见类注释) ──
/// 网络连接失败(DNS/握手/断网等连接层错误)。
static const String networkError = 'NETWORK_ERROR';
/// 网络超时(连接/发送/接收任一阶段)。
static const String networkTimeout = 'NETWORK_TIMEOUT';
/// 请求被取消。
static const String cancelled = 'CANCELLED';
/// 收到无契约错误体的非 2xx 响应(statusCode 保留在 AppException 上)。
static const String httpError = 'HTTP_ERROR';
}
@@ -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;
}
+106
View File
@@ -0,0 +1,106 @@
/// 会话存储(P3 计划 Task 2):Bearer 令牌 + 过期时间 + 账号。
///
/// 契约来源:`components.schemas.LoginResponse`(sessionToken/expiresAt);
/// account 为登录请求回显(LoginResponse 不含,客户端自持以便界面展示
/// 与账号隔离键使用)。
library;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 会话值对象(不可变)。
class Session {
const Session({required this.token, this.expiresAt, this.account});
/// Bearer 令牌。
final String token;
/// 会话过期时间(服务端 date-time)。
final DateTime? expiresAt;
/// 登录账号(回显)。
final String? account;
/// 令牌是否已过期(无过期时间时视为未过期,交由服务端 401 裁决)。
bool get isExpired =>
expiresAt != null && DateTime.now().isAfter(expiresAt!);
}
/// 会话存储抽象:[ApiClient] 依赖本接口,测试注入内存实现。
abstract class SessionStore {
/// 读取会话;从未登录(或已清除)返回 null。
Future<Session?> read();
/// 写入会话(全量覆盖,含清空可空字段)。
Future<void> save(Session session);
/// 清除会话(登出/401 驱逐)。
Future<void> clear();
}
/// [SessionStore] 的 flutter_secure_storage 生产实现。
///
/// 序列化逻辑独立为静态 [encode]/[decode](纯函数,可单测);平台通道
/// 调用保持最薄。
class SecureSessionStore implements SessionStore {
SecureSessionStore({FlutterSecureStorage? storage})
: _storage = storage ?? FlutterSecureStorage();
/// 安全存储键(App 私有命名空间内)。
static const String kTokenKey = 'session.token';
static const String kExpiresAtKey = 'session.expiresAt';
static const String kAccountKey = 'session.account';
final FlutterSecureStorage _storage;
/// Session → 扁平键值(可空字段缺省不落键)。
static Map<String, String> encode(Session session) => {
kTokenKey: session.token,
if (session.expiresAt != null)
kExpiresAtKey: session.expiresAt!.toUtc().toIso8601String(),
if (session.account != null) kAccountKey: session.account!,
};
/// 扁平键值 → Session;令牌缺失/为空返回 null(无会话语义)。
static Session? decode(Map<String?, String?> values) {
final token = values[kTokenKey];
if (token == null || token.isEmpty) return null;
final expiresAt = values[kExpiresAtKey];
return Session(
token: token,
expiresAt:
expiresAt == null ? null : DateTime.tryParse(expiresAt),
account: values[kAccountKey],
);
}
@override
Future<Session?> read() async {
final values = await _storage.readAll();
return decode(values);
}
@override
Future<void> save(Session session) async {
final encoded = encode(session);
// 先写后删:可空字段缺省时移除旧值,避免残留上一会话的账号/过期时间。
for (final entry in encoded.entries) {
await _storage.write(key: entry.key, value: entry.value);
}
for (final key in _allKeys.where((k) => !encoded.containsKey(k))) {
await _storage.delete(key: key);
}
}
@override
Future<void> clear() async {
for (final key in _allKeys) {
await _storage.delete(key: key);
}
}
static const List<String> _allKeys = [
kTokenKey,
kExpiresAtKey,
kAccountKey,
];
}