refactor(apps): 重构数据层目录结构并新增启动预热编排器

This commit is contained in:
zl-q
2026-03-29 20:26:30 +08:00
parent 33340de8f9
commit 4db9a13bfe
108 changed files with 1653 additions and 1320 deletions
+149
View File
@@ -0,0 +1,149 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'api_exception.dart';
import 'api_interceptor.dart';
import 'i_api_client.dart';
import '../storage/token_storage.dart';
class ApiClient implements IApiClient {
final Dio _dio;
final TokenStorage _tokenStorage;
final ApiInterceptor _interceptor;
factory ApiClient({
required String baseUrl,
required TokenStorage tokenStorage,
Dio? dio,
}) {
final effectiveDio =
dio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
),
);
final interceptor = ApiInterceptor(
tokenStorage: tokenStorage,
dio: effectiveDio,
);
effectiveDio.interceptors.add(interceptor);
return ApiClient._(
dio: effectiveDio,
tokenStorage: tokenStorage,
interceptor: interceptor,
);
}
ApiClient._({
required Dio dio,
required TokenStorage tokenStorage,
required ApiInterceptor interceptor,
}) : _dio = dio,
_tokenStorage = tokenStorage,
_interceptor = interceptor;
Dio get dio => _dio;
void resetInterceptor() {
_interceptor.reset();
}
void setRefreshCallback(Future<bool> Function(String) refresh) {
_interceptor.onTokenRefresh = () async {
final token = await _tokenStorage.getRefreshToken();
if (token == null) return false;
return refresh(token);
};
}
void setAuthFailureCallback(Future<void> Function() onAuthFailure) {
_interceptor.onAuthFailure = onAuthFailure;
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
try {
return await _dio.get<T>(path, options: options);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
@override
Future<Response<T>> post<T>(
String path, {
dynamic data,
Options? options,
}) async {
try {
return await _dio.post<T>(path, data: data, options: options);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
@override
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Options? options,
}) async {
try {
return await _dio.patch<T>(path, data: data, options: options);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
@override
Future<Response<T>> put<T>(
String path, {
dynamic data,
Options? options,
}) async {
try {
return await _dio.put<T>(path, data: data, options: options);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
@override
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Options? options,
}) async {
try {
return await _dio.delete<T>(path, data: data, options: options);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
}) async {
try {
final response = await _dio.get<ResponseBody>(
path,
options: Options(responseType: ResponseType.stream, headers: headers),
);
final responseBody = response.data;
if (responseBody == null) {
return const Stream<String>.empty();
}
return responseBody.stream
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter());
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../core/l10n/l10n.dart';
import 'error_code_mapper.dart';
abstract class ApiException implements Exception {
final String message;
final int? statusCode;
final String? errorCode;
final Map<String, dynamic>? errorParams;
const ApiException(
this.message, {
this.statusCode,
this.errorCode,
this.errorParams,
});
@override
String toString() => message;
factory ApiException.fromDioError(Object error) {
if (error is ApiException) return error;
if (error is DioException) {
final response = error.response;
final statusCode = response?.statusCode;
final data = response?.data;
String detail;
String? errorCode;
Map<String, dynamic>? errorParams;
final decodedData = _normalizeErrorData(data);
if (decodedData is Map<String, dynamic>) {
detail =
(decodedData['detail'] ??
decodedData['message'] ??
decodedData['error'])
?.toString() ??
L10n.current.errorRequestFailed;
final code = decodedData['code'];
if (code is String && code.trim().isNotEmpty) {
errorCode = code;
}
final params = decodedData['params'];
if (params is Map<String, dynamic>) {
errorParams = params;
} else if (params is Map) {
errorParams = params.map(
(key, value) => MapEntry(key.toString(), value),
);
}
} else {
detail = _networkErrorMessage(error);
}
final localized = _localizeError(
detail,
statusCode,
errorCode: errorCode,
errorParams: errorParams,
);
if (statusCode == 401) {
return UnauthorizedException(message: localized, errorCode: errorCode);
}
if (statusCode == 422) {
return ValidationException(
localized,
errors: data,
statusCode: statusCode,
errorCode: errorCode,
errorParams: errorParams,
);
}
return ServerException(
localized,
statusCode: statusCode,
errorCode: errorCode,
errorParams: errorParams,
);
}
return ServerException(L10n.current.errorNetwork);
}
static Map<String, dynamic>? _normalizeErrorData(dynamic data) {
if (data is Map<String, dynamic>) {
return data;
}
if (data is Map) {
return data.map((key, value) => MapEntry(key.toString(), value));
}
if (data is String && data.trim().isNotEmpty) {
try {
final decoded = jsonDecode(data);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.map((key, value) => MapEntry(key.toString(), value));
}
} catch (_) {
return null;
}
}
return null;
}
static String _localizeError(
String detail,
int? statusCode, {
String? errorCode,
Map<String, dynamic>? errorParams,
}) {
final mapped = resolveErrorCodeMessage(errorCode, params: errorParams);
if (mapped != null && mapped.isNotEmpty) {
return mapped;
}
if (statusCode == 403) {
return L10n.current.errorForbidden;
}
if (statusCode == 404) {
return L10n.current.errorNotFound;
}
if (statusCode == 429) {
return L10n.current.errorTooManyRequests;
}
if (statusCode != null && statusCode >= 500) {
return L10n.current.errorServer;
}
return L10n.current.errorGenericSafe;
}
static String _networkErrorMessage(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return L10n.current.errorNetworkTimeout;
}
if (error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.unknown) {
return L10n.current.errorNetworkUnavailable;
}
return L10n.current.errorRequestFailed;
}
}
class ServerException extends ApiException {
const ServerException(
super.message, {
super.statusCode,
super.errorCode,
super.errorParams,
});
}
class UnauthorizedException extends ApiException {
UnauthorizedException({String? message, String? errorCode})
: super(
message ?? L10n.current.errorReLogin,
statusCode: 401,
errorCode: errorCode,
);
}
class ValidationException extends ApiException {
final Map<String, dynamic>? errors;
const ValidationException(
super.message, {
this.errors,
super.statusCode,
super.errorCode,
super.errorParams,
});
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:dio/dio.dart';
import '../storage/token_storage.dart';
class ApiInterceptor extends Interceptor {
final TokenStorage tokenStorage;
final Dio dio;
final Duration refreshFailureCooldown;
Future<bool> Function()? onTokenRefresh;
Future<void> Function()? onAuthFailure;
Future<bool>? _refreshFuture;
Future<void>? _authFailureFuture;
DateTime? _refreshBlockedUntil;
static const _retriedRequestKey = '_auth_retry_once';
static const _refreshPathSuffix = '/api/v1/auth/sessions/refresh';
ApiInterceptor({
required this.tokenStorage,
required this.dio,
this.refreshFailureCooldown = const Duration(seconds: 5),
this.onTokenRefresh,
});
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await tokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final requestOptions = err.requestOptions;
final isUnauthorized = err.response?.statusCode == 401;
final shouldHandleUnauthorized =
isUnauthorized && _isAuthenticatedRequest(requestOptions);
if (err.response?.statusCode == 401 &&
onTokenRefresh != null &&
!_shouldSkipRefresh(requestOptions)) {
final refreshed = await _refreshTokenSingleflight();
if (refreshed) {
final token = await tokenStorage.getAccessToken();
if (token != null) {
final retryHeaders = Map<String, dynamic>.from(requestOptions.headers)
..['Authorization'] = 'Bearer $token';
final retryExtra = Map<String, dynamic>.from(requestOptions.extra)
..[_retriedRequestKey] = true;
final retryOptions = requestOptions.copyWith(
headers: retryHeaders,
extra: retryExtra,
);
try {
final response = await dio.fetch(retryOptions);
handler.resolve(response);
return;
} on DioException {
// Retry failed, proceed with original error.
}
}
} else if (shouldHandleUnauthorized) {
await _notifyAuthFailureSingleflight();
}
} else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) {
await _notifyAuthFailureSingleflight();
}
handler.next(err);
}
bool _isAuthenticatedRequest(RequestOptions options) {
return options.headers['Authorization'] != null;
}
Future<void> _notifyAuthFailureSingleflight() {
final existing = _authFailureFuture;
if (existing != null) {
return existing;
}
final callback = onAuthFailure;
if (callback == null) {
return Future<void>.value();
}
final future = callback().whenComplete(() {
_authFailureFuture = null;
});
_authFailureFuture = future;
return future;
}
bool _shouldSkipRefresh(RequestOptions options) {
final blockedUntil = _refreshBlockedUntil;
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
return true;
}
return _normalizePath(options.path) == _refreshPathSuffix ||
options.extra[_retriedRequestKey] == true;
}
String _normalizePath(String rawPath) {
final parsed = Uri.tryParse(rawPath);
if (parsed == null) {
return rawPath.replaceFirst(RegExp(r'/+$'), '');
}
final normalized = parsed.path.replaceFirst(RegExp(r'/+$'), '');
return normalized.isEmpty ? '/' : normalized;
}
Future<bool> _refreshTokenSingleflight() {
final inflight = _refreshFuture;
if (inflight != null) {
return inflight;
}
final future = onTokenRefresh!().catchError((_) => false).whenComplete(() {
_refreshFuture = null;
});
_refreshFuture = future;
return future.then((refreshed) {
if (refreshed) {
_refreshBlockedUntil = null;
} else {
_refreshBlockedUntil = DateTime.now().add(refreshFailureCooldown);
}
return refreshed;
});
}
void reset() {
_refreshFuture = null;
_authFailureFuture = null;
_refreshBlockedUntil = null;
}
}
@@ -0,0 +1,244 @@
import '../../core/l10n/l10n.dart';
String? mapErrorCodeToL10nKey(
String? errorCode, {
Map<String, dynamic>? params,
}) {
if (errorCode == null || errorCode.isEmpty) {
return null;
}
switch (errorCode) {
case 'AGENT_RUN_INPUT_INVALID':
return 'errorGenericSafe';
case 'AGENT_RUN_MESSAGES_INVALID':
return 'errorGenericSafe';
case 'AGENT_INVALID_LAST_EVENT_ID':
return 'errorAgentInvalidLastEventId';
case 'AGENT_SSE_CONNECTION_LIMIT':
return 'errorAgentSseConnectionLimit';
case 'AGENT_ATTACHMENT_EMPTY':
return 'errorAgentAttachmentEmpty';
case 'AGENT_ATTACHMENT_TOO_LARGE':
return 'errorAgentAttachmentTooLarge';
case 'AGENT_AUDIO_UNSUPPORTED_FORMAT':
return 'errorAgentAudioUnsupportedFormat';
case 'AGENT_AUDIO_TOO_LARGE':
return 'errorAgentAudioTooLarge';
case 'AGENT_AUDIO_EMPTY':
return 'errorAgentAudioEmpty';
case 'AGENT_ASR_UNAVAILABLE':
return 'errorAgentAsrUnavailable';
case 'AGENT_FORBIDDEN':
return 'errorForbidden';
case 'AGENT_PAYLOAD_INVALID':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENTS_TOO_MANY':
return 'errorGenericSafe';
case 'AGENT_SIGNED_IMAGE_URL_INVALID':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_STORAGE_UNAVAILABLE':
return 'errorServer';
case 'AGENT_ATTACHMENT_UNSUPPORTED_TYPE':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_UPLOAD_FAILED':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_BUCKET_INVALID':
return 'errorGenericSafe';
case 'AGENT_ATTACHMENT_PATH_SCOPE_INVALID':
return 'errorGenericSafe';
case 'AGENT_SIGNED_URL_GENERATION_FAILED':
return 'errorGenericSafe';
case 'AGENT_SESSION_ID_INVALID':
return 'errorGenericSafe';
case 'AGENT_SESSION_NOT_FOUND':
return 'errorNotFound';
case 'AGENT_USER_ID_INVALID':
return 'errorGenericSafe';
case 'INVALID_BINARY_URL_HOST':
return 'errorAgentInvalidBinaryUrl';
case 'INVALID_BINARY_URL_BUCKET':
return 'errorAgentInvalidBinaryUrl';
case 'INVALID_BINARY_URL_PATH_SCOPE':
return 'errorAgentInvalidBinaryUrl';
case 'AUTH_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'AUTH_TOO_MANY_REQUESTS':
return 'errorTooManyRequests';
case 'AUTH_VERIFICATION_CODE_INVALID':
return 'errorGenericSafe';
case 'AUTH_REFRESH_TOKEN_INVALID':
return 'errorReLogin';
case 'AUTH_REFRESH_TOKEN_MISSING':
return 'errorReLogin';
case 'AUTH_USER_NOT_FOUND':
return 'errorNotFound';
case 'AUTH_UNAUTHORIZED':
return 'errorReLogin';
case 'JWT_VERIFIER_NOT_CONFIGURED':
return 'errorServer';
case 'AUTOMATION_JOB_LIMIT_EXCEEDED':
return 'errorGenericSafe';
case 'AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN':
return 'errorForbidden';
case 'AUTOMATION_JOB_NOT_FOUND':
return 'errorNotFound';
case 'AUTOMATION_JOB_STORE_UNAVAILABLE':
return 'errorServer';
case 'NOT_FOUND':
return 'errorNotFound';
case 'LOOKUP_FAILED':
return 'errorServer';
case 'INTERNAL_ERROR':
return 'errorServer';
case 'MISSING_RUNTIME_ARGS':
return 'errorGenericSafe';
case 'TOOL_PENDING_APPROVAL':
return 'errorGenericSafe';
case 'TOOL_REJECTED':
return 'errorForbidden';
case 'USER_STORE_UNAVAILABLE':
return 'errorServer';
case 'USER_NOT_FOUND':
return 'errorNotFound';
case 'USER_UPDATE_FIELDS_EMPTY':
return 'errorGenericSafe';
case 'USER_AVATAR_UNSUPPORTED_TYPE':
return 'errorGenericSafe';
case 'USER_AVATAR_TOO_LARGE':
return 'errorGenericSafe';
case 'USER_AVATAR_EMPTY':
return 'errorGenericSafe';
case 'USER_AVATAR_UPLOAD_FAILED':
return 'errorGenericSafe';
case 'USER_AUTH_LOOKUP_UNAVAILABLE':
return 'errorServer';
case 'TODO_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'TODO_NOT_FOUND':
return 'errorNotFound';
case 'TODO_ACCESS_FORBIDDEN':
return 'errorForbidden';
case 'TODO_REORDER_DUPLICATE_ID':
return 'errorGenericSafe';
case 'TODO_STATUS_INVALID':
return 'errorGenericSafe';
case 'TODO_PRIORITY_INVALID':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_INVALID_TIME_RANGE':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_STORE_UNAVAILABLE':
return 'errorServer';
case 'SCHEDULE_ITEM_NOT_FOUND':
return 'errorNotFound';
case 'SCHEDULE_ITEM_START_AT_TIMEZONE_REQUIRED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_PAGE_INVALID':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_PAGE_SIZE_INVALID':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_SHARE_FORBIDDEN':
return 'errorForbidden';
case 'SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_INVITE_ALREADY_PENDING':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_AUTH_LOOKUP_UNAVAILABLE':
return 'errorServer';
case 'SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND':
return 'errorNotFound';
case 'SCHEDULE_ITEM_ACCEPT_SUBSCRIPTION_FAILED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_REJECT_SUBSCRIPTION_FAILED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_DATETIME_TIMEZONE_REQUIRED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_DATETIME_REQUIRED':
return 'errorGenericSafe';
case 'INBOX_MESSAGE_NOT_FOUND':
return 'errorNotFound';
case 'INBOX_MESSAGE_STORE_UNAVAILABLE':
return 'errorServer';
case 'MEMORIES_USER_NOT_FOUND':
return 'errorNotFound';
case 'MEMORIES_WORK_NOT_FOUND':
return 'errorNotFound';
case 'MEMORIES_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'FRIEND_REQUEST_SELF_NOT_ALLOWED':
return 'errorGenericSafe';
case 'FRIEND_ALREADY_ACCEPTED':
return 'errorGenericSafe';
case 'FRIEND_REQUEST_BLOCKED':
return 'errorGenericSafe';
case 'FRIEND_REQUEST_ALREADY_SENT':
return 'errorGenericSafe';
case 'FRIENDSHIP_SERVICE_UNAVAILABLE':
return 'errorServer';
case 'FRIEND_REQUEST_NOT_FOUND':
return 'errorNotFound';
case 'FRIEND_REQUEST_FORBIDDEN':
return 'errorForbidden';
case 'FRIEND_REQUEST_NOT_PENDING':
return 'errorGenericSafe';
case 'FRIEND_INBOX_MESSAGE_NOT_FOUND':
return 'errorNotFound';
case 'FRIENDSHIP_DATA_INVALID':
return 'errorGenericSafe';
case 'FRIENDSHIP_NOT_FOUND':
return 'errorNotFound';
case 'FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED':
return 'errorGenericSafe';
default:
return null;
}
}
String? resolveErrorCodeMessage(
String? errorCode, {
Map<String, dynamic>? params,
}) {
final key = mapErrorCodeToL10nKey(errorCode, params: params);
if (key == null) {
return null;
}
switch (key) {
case 'errorAgentSseConnectionLimit':
return L10n.current.errorAgentSseConnectionLimit;
case 'errorAgentAttachmentEmpty':
return L10n.current.errorAgentAttachmentEmpty;
case 'errorAgentAttachmentTooLarge':
return L10n.current.errorAgentAttachmentTooLarge;
case 'errorAgentAudioEmpty':
return L10n.current.errorAgentAudioEmpty;
case 'errorAgentAudioTooLarge':
return L10n.current.errorAgentAudioTooLarge;
case 'errorAgentAudioUnsupportedFormat':
return L10n.current.errorAgentAudioUnsupportedFormat;
case 'errorAgentAsrUnavailable':
return L10n.current.errorAgentAsrUnavailable;
case 'errorAgentInvalidLastEventId':
return L10n.current.errorAgentInvalidLastEventId;
case 'errorAgentInvalidBinaryUrl':
return L10n.current.errorAgentInvalidBinaryUrl;
case 'errorForbidden':
return L10n.current.errorForbidden;
case 'errorNotFound':
return L10n.current.errorNotFound;
case 'errorTooManyRequests':
return L10n.current.errorTooManyRequests;
case 'errorServer':
return L10n.current.errorServer;
case 'errorGenericSafe':
return L10n.current.errorGenericSafe;
case 'errorReLogin':
return L10n.current.errorReLogin;
default:
return null;
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:dio/dio.dart';
abstract class IApiClient {
Future<Response<T>> get<T>(String path, {Options? options});
Future<Response<T>> post<T>(String path, {dynamic data, Options? options});
Future<Response<T>> put<T>(String path, {dynamic data, Options? options});
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options});
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options});
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
});
}