Initial project
This commit is contained in:
29
packages/networks_sdk/lib/networks_sdk.dart
Normal file
29
packages/networks_sdk/lib/networks_sdk.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
library;
|
||||
|
||||
export 'src/presentation/facade/networks_sdk_api.dart';
|
||||
export 'src/presentation/facade/networks_messaging_api.dart';
|
||||
|
||||
// Wiring - Implementations
|
||||
export 'src/presentation/wiring/networks_messaging_api_impl.dart';
|
||||
|
||||
// Dio 类型重导出(App 层上传 / override decodeResponse 需要,避免直接依赖 dio)
|
||||
export 'package:dio/dio.dart' show FormData, MultipartFile, Response;
|
||||
|
||||
// Config
|
||||
export 'src/presentation/wiring/api_config.dart';
|
||||
export 'src/presentation/wiring/socket_config.dart';
|
||||
export 'src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
// Model
|
||||
export 'src/data/dto/api_requestable.dart';
|
||||
export 'src/data/dto/api_response_wrapper.dart';
|
||||
export 'src/domain/entities/api_error.dart';
|
||||
export 'src/domain/entities/http_method.dart';
|
||||
export 'src/domain/entities/api_request_type.dart';
|
||||
|
||||
// Socket Entities
|
||||
export 'src/domain/entities/socket_connection_state.dart';
|
||||
export 'src/domain/entities/socket_error.dart';
|
||||
|
||||
// Annotations(代码生成)
|
||||
export 'src/annotations/api_request.dart';
|
||||
17
packages/networks_sdk/lib/networks_sdk_method_channel.dart
Normal file
17
packages/networks_sdk/lib/networks_sdk_method_channel.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'networks_sdk_platform_interface.dart';
|
||||
|
||||
/// An implementation of [NetworksSdkPlatform] that uses method channels.
|
||||
class MethodChannelNetworksSdk extends NetworksSdkPlatform {
|
||||
/// The method channel used to interact with the native platform.
|
||||
@visibleForTesting
|
||||
final methodChannel = const MethodChannel('networks_sdk');
|
||||
|
||||
@override
|
||||
Future<String?> getPlatformVersion() async {
|
||||
final version = await methodChannel.invokeMethod<String>('getPlatformVersion');
|
||||
return version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import 'networks_sdk_method_channel.dart';
|
||||
|
||||
abstract class NetworksSdkPlatform extends PlatformInterface {
|
||||
/// Constructs a NetworksSdkPlatform.
|
||||
NetworksSdkPlatform() : super(token: _token);
|
||||
|
||||
static final Object _token = Object();
|
||||
|
||||
static NetworksSdkPlatform _instance = MethodChannelNetworksSdk();
|
||||
|
||||
/// The default instance of [NetworksSdkPlatform] to use.
|
||||
///
|
||||
/// Defaults to [MethodChannelNetworksSdk].
|
||||
static NetworksSdkPlatform get instance => _instance;
|
||||
|
||||
/// Platform-specific implementations should set this with their own
|
||||
/// platform-specific class that extends [NetworksSdkPlatform] when
|
||||
/// they register themselves.
|
||||
static set instance(NetworksSdkPlatform instance) {
|
||||
PlatformInterface.verifyToken(instance, _token);
|
||||
_instance = instance;
|
||||
}
|
||||
|
||||
Future<String?> getPlatformVersion() {
|
||||
throw UnimplementedError('platformVersion() has not been implemented.');
|
||||
}
|
||||
}
|
||||
65
packages/networks_sdk/lib/src/annotations/api_request.dart
Normal file
65
packages/networks_sdk/lib/src/annotations/api_request.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/http_method.dart';
|
||||
|
||||
|
||||
/// API 请求注解 — 标记一个类为 API 请求
|
||||
///
|
||||
/// 配合 `build_runner` 代码生成器,自动生成 `ApiRequestable<T>` 协议实现,
|
||||
/// 使用侧只需定义字段 + 注解,path / method / requestType / includeToken
|
||||
/// 全部由生成器自动提供。
|
||||
///
|
||||
/// ## 使用方式
|
||||
///
|
||||
/// ```dart
|
||||
/// @ApiRequest(
|
||||
/// path: '/auth/login',
|
||||
/// method: HttpMethod.post,
|
||||
/// responseType: LoginData,
|
||||
/// requestType: ApiRequestType.login,
|
||||
/// )
|
||||
/// @JsonSerializable()
|
||||
/// class LoginRequest extends ApiRequestable<LoginData>
|
||||
/// with _$LoginRequestApi {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
///
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 生成器自动生成 `_$LoginRequestApi` mixin,提供:
|
||||
/// - `path` → `'/auth/login'`
|
||||
/// - `method` → `HttpMethod.post`
|
||||
/// - `requestType` → `ApiRequestType.login`
|
||||
/// - `includeToken` → `false`(login 类型自动设为 false)
|
||||
class ApiRequest {
|
||||
/// API 路径(如 `'/auth/login'`)
|
||||
final String path;
|
||||
|
||||
/// HTTP 方法(默认 POST)
|
||||
final HttpMethod method;
|
||||
|
||||
/// 响应类型(用于泛型绑定)
|
||||
final Type responseType;
|
||||
|
||||
/// 请求类型(决定 header 处理方式,默认 request)
|
||||
final ApiRequestType requestType;
|
||||
|
||||
/// 是否在 header 中包含 token(默认根据 requestType 推断:login → false,其余 → true)
|
||||
final bool? includeToken;
|
||||
|
||||
/// 自定义请求头
|
||||
final Map<String, String>? customHeaders;
|
||||
|
||||
const ApiRequest({
|
||||
required this.path,
|
||||
this.method = HttpMethod.post,
|
||||
required this.responseType,
|
||||
this.requestType = ApiRequestType.request,
|
||||
this.includeToken,
|
||||
this.customHeaders,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
class NetworksSdkMethodChannel
|
||||
{
|
||||
// Channel Name
|
||||
static const String channelName = 'networks_sdk';
|
||||
|
||||
//---------------- Flutter call native ----------------
|
||||
|
||||
static const String requestPermission = 'requestPermission';
|
||||
|
||||
|
||||
//---------------- Flutter call native ----------------
|
||||
|
||||
|
||||
|
||||
//---------------- native call Flutter ----------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//---------------- native call Flutter ----------------
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/auth_interceptor.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/logging_interceptor.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/interceptor/retry_interceptor.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
/// REST API 客户端
|
||||
/// 基于 Dio,提供 `executeRequest<T>` 唯一入口
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final client = ApiClient(config: apiConfig);
|
||||
/// final data = await client.executeRequest(LoginRequest(...));
|
||||
/// ```
|
||||
class ApiClient {
|
||||
final ApiConfig config;
|
||||
final Dio _dio;
|
||||
|
||||
ApiClient({
|
||||
required this.config,
|
||||
Dio? dio,
|
||||
List<Interceptor>? additionalInterceptors,
|
||||
}) : _dio = dio ?? Dio() {
|
||||
// 配置默认选项
|
||||
_dio.options = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
);
|
||||
|
||||
// 挂载拦截器(顺序:Auth → 自定义 → Retry → Logging)
|
||||
_dio.interceptors.addAll([
|
||||
AuthInterceptor(config),
|
||||
if (additionalInterceptors != null) ...additionalInterceptors,
|
||||
RetryInterceptor(config: config, dio: _dio),
|
||||
LoggingInterceptor(onLog: config.onLog),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 暴露 Dio 实例(供需要直接操作的场景,如文件上传)
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// DioException → ApiError 映射
|
||||
ApiError mapDioError(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
return const ApiError.timeout();
|
||||
case DioExceptionType.connectionError:
|
||||
return const ApiError.noNetworkConnection();
|
||||
default:
|
||||
if (e.response != null) {
|
||||
return ApiError.apiError(
|
||||
code: e.response!.statusCode ?? 0,
|
||||
message: e.response!.statusMessage ??
|
||||
e.message ??
|
||||
'Request failed',
|
||||
);
|
||||
}
|
||||
return ApiError.networkError(e.message ?? 'Network error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
|
||||
|
||||
/// 认证拦截器
|
||||
/// 自动注入 token 和默认 headers 到每个请求
|
||||
class AuthInterceptor extends Interceptor {
|
||||
final ApiConfig config;
|
||||
|
||||
AuthInterceptor(this.config);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
// 从 options.extra 读取请求元数据
|
||||
final requestType =
|
||||
options.extra['requestType'] as ApiRequestType? ?? ApiRequestType.request;
|
||||
final includeToken = options.extra['includeToken'] as bool? ?? true;
|
||||
final customHeaders =
|
||||
options.extra['customHeaders'] as Map<String, String>?;
|
||||
|
||||
// 保留重试请求的原始 Request-ID(幂等性)
|
||||
// 重试时 options.headers 中已有 APP-Request-ID,
|
||||
// 新生成的 headers 会覆盖它导致服务端无法识别为同一请求。
|
||||
final existingRequestId = options.headers['APP-Request-ID'] as String?;
|
||||
|
||||
// 构建 headers
|
||||
final headers = config.defaultHeaders(
|
||||
includeToken: includeToken && requestType != ApiRequestType.login,
|
||||
customHeaders: customHeaders,
|
||||
);
|
||||
|
||||
// 还原原始 Request-ID
|
||||
if (existingRequestId != null) {
|
||||
headers['APP-Request-ID'] = existingRequestId;
|
||||
}
|
||||
|
||||
options.headers.addAll(headers);
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
|
||||
|
||||
/// 日志拦截器
|
||||
/// 统一打印请求 + 响应(一个日志块)
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
final OnLog? onLog;
|
||||
final bool enabled;
|
||||
|
||||
LoggingInterceptor({this.onLog, this.enabled = true});
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (enabled && onLog != null) {
|
||||
_logRequestAndResponse(response);
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (enabled && onLog != null) {
|
||||
_logError(err);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _logRequestAndResponse(Response response) {
|
||||
try {
|
||||
final logData = {
|
||||
'url': response.requestOptions.uri.toString(),
|
||||
'method': response.requestOptions.method,
|
||||
'request': {
|
||||
if (response.requestOptions.data != null)
|
||||
'body': response.requestOptions.data,
|
||||
},
|
||||
'response': {
|
||||
'status': response.statusCode,
|
||||
if (response.data != null) 'body': response.data,
|
||||
},
|
||||
};
|
||||
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
onLog!(
|
||||
'API Request + Response:\n${encoder.convert(logData)}',
|
||||
tag: 'Network',
|
||||
);
|
||||
} catch (e) {
|
||||
onLog!(
|
||||
'API: ${response.requestOptions.uri} -> ${response.statusCode}',
|
||||
tag: 'Network',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logError(DioException error) {
|
||||
try {
|
||||
final logData = {
|
||||
'url': error.requestOptions.uri.toString(),
|
||||
'method': error.requestOptions.method,
|
||||
'type': error.type.toString(),
|
||||
'message': error.message,
|
||||
if (error.response != null) ...{
|
||||
'status': error.response!.statusCode,
|
||||
'data': error.response!.data,
|
||||
},
|
||||
};
|
||||
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
onLog!(
|
||||
'API Error:\n${encoder.convert(logData)}',
|
||||
tag: 'Network',
|
||||
);
|
||||
} catch (e) {
|
||||
onLog!('API Error: ${error.message}', tag: 'Network');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
|
||||
/// 重试拦截器
|
||||
///
|
||||
/// 两层重试机制:
|
||||
///
|
||||
/// 1. **Token 刷新重试**(onResponse)
|
||||
/// 检测 Token 过期响应 → 触发刷新回调 → 用新 Token 重试原请求
|
||||
///
|
||||
/// 2. **瞬态错误重试**(onError)
|
||||
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
|
||||
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
|
||||
///
|
||||
/// 两层独立运作,可叠加。
|
||||
class RetryInterceptor extends Interceptor {
|
||||
final ApiConfig config;
|
||||
final Dio dio;
|
||||
|
||||
/// Token 刷新锁(防止多个请求同时刷新)
|
||||
bool _isRefreshing = false;
|
||||
Completer<bool>? _refreshCompleter;
|
||||
|
||||
final _random = Random();
|
||||
|
||||
RetryInterceptor({required this.config, required this.dio});
|
||||
|
||||
// ── Token 刷新重试 ────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (response.data is! Map<String, dynamic>) {
|
||||
handler.next(response);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final code = _parseCode(data['code']);
|
||||
|
||||
// 检查强制登出
|
||||
if (config.forceLogoutCodes.contains(code)) {
|
||||
config.onLog?.call(
|
||||
'Force logout detected (code: $code)',
|
||||
tag: 'Network',
|
||||
);
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
message: 'Force logout (code: $code)',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 Token 过期
|
||||
if (config.tokenExpiredCodes.contains(code)) {
|
||||
config.onLog?.call(
|
||||
'Token expired (code: $code), refreshing...',
|
||||
tag: 'Network',
|
||||
);
|
||||
_handleTokenExpired(response, handler);
|
||||
return;
|
||||
}
|
||||
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
/// 处理 Token 过期:刷新 + 重试
|
||||
Future<void> _handleTokenExpired(
|
||||
Response response,
|
||||
ResponseInterceptorHandler handler,
|
||||
) async {
|
||||
final refreshSuccess = await _refreshToken();
|
||||
|
||||
if (!refreshSuccess) {
|
||||
config.onLog?.call('Token refresh failed', tag: 'Network');
|
||||
config.onForceLogout?.call();
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
message: 'Token refresh failed',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新成功,用新 token 重试原请求
|
||||
config.onLog?.call('Token refreshed, retrying...', tag: 'Network');
|
||||
try {
|
||||
final options = response.requestOptions;
|
||||
// 更新 header 中的 token
|
||||
options.headers['token'] = config.token;
|
||||
|
||||
final retryResponse = await dio.fetch(options);
|
||||
handler.resolve(retryResponse);
|
||||
} on DioException catch (e) {
|
||||
handler.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Token 刷新(串行锁)
|
||||
/// 多个请求同时过期时,只刷新一次,其余等待
|
||||
Future<bool> _refreshToken() async {
|
||||
if (_isRefreshing) {
|
||||
// 等待正在进行的刷新
|
||||
return _refreshCompleter?.future ?? Future.value(false);
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
_refreshCompleter = Completer<bool>();
|
||||
|
||||
try {
|
||||
if (config.onTokenRefresh == null) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
final newToken = await config.onTokenRefresh!();
|
||||
final success = newToken != null;
|
||||
|
||||
if (success) {
|
||||
config.updateToken(newToken);
|
||||
}
|
||||
|
||||
_refreshCompleter!.complete(success);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_refreshCompleter!.complete(false);
|
||||
return false;
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
_refreshCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 瞬态错误重试(指数退避 + jitter)────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (config.maxRetries <= 0 || !_isRetryable(err)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final options = err.requestOptions;
|
||||
final attempt = (options.extra['_retryAttempt'] as int?) ?? 0;
|
||||
|
||||
if (attempt >= config.maxRetries) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
options.extra['_retryAttempt'] = attempt + 1;
|
||||
|
||||
final delayMs = _backoffDelay(attempt);
|
||||
config.onLog?.call(
|
||||
'Transient error, retry ${attempt + 1}/${config.maxRetries} '
|
||||
'in ${delayMs}ms: ${options.path}',
|
||||
tag: 'Retry',
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration(milliseconds: delayMs));
|
||||
|
||||
try {
|
||||
handler.resolve(await dio.fetch(options));
|
||||
} on DioException catch (e) {
|
||||
handler.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否可重试的瞬态错误
|
||||
bool _isRetryable(DioException err) {
|
||||
switch (err.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.badResponse:
|
||||
// 5xx 服务端错误可重试
|
||||
final statusCode = err.response?.statusCode;
|
||||
return statusCode != null && statusCode >= 500;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 指数退避 + jitter
|
||||
///
|
||||
/// delay = min(baseDelay * 2^attempt, 30s) + random(0, delay * 25%)
|
||||
int _backoffDelay(int attempt) {
|
||||
final baseMs = config.retryBaseDelay.inMilliseconds;
|
||||
final exponentialMs = min(baseMs * pow(2, attempt).toInt(), 30000);
|
||||
final jitterMs = _random.nextInt((exponentialMs * 0.25).toInt().clamp(1, 7500));
|
||||
return exponentialMs + jitterMs;
|
||||
}
|
||||
|
||||
int _parseCode(dynamic code) {
|
||||
if (code is int) return code;
|
||||
if (code is String) return int.tryParse(code) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/datasources/http/api_client.dart';
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import '../../../networks_sdk_platform_interface.dart';
|
||||
import '../../domain/entities/http_method.dart';
|
||||
|
||||
class NetworksSdkMethodChannelDataSource
|
||||
{
|
||||
final NetworksSdkPlatform platform;
|
||||
|
||||
late ApiClient apiClient;
|
||||
|
||||
NetworksSdkMethodChannelDataSource(this.platform);
|
||||
|
||||
Future<String?> getPlatformVersion() async {
|
||||
return await getPlatformVersion();
|
||||
}
|
||||
|
||||
void initialize(ApiConfig apiConfig){
|
||||
apiClient = ApiClient(config: apiConfig);
|
||||
}
|
||||
|
||||
/// 执行 API 请求 — 唯一入口
|
||||
///
|
||||
/// 流程:网络前置检查 → 构建 URL → 设置元数据 → 执行请求 → 解码响应 → 错误映射
|
||||
/// 拦截器负责:header 注入、Token 刷新重试、日志
|
||||
///
|
||||
/// Upload 类型支持两种模式:
|
||||
/// - 自有后端上传:path 为相对路径,自动拼接 baseURL
|
||||
/// - S3 presigned URL:path 以 http 开头,直接使用全路径
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request) async {
|
||||
// 前置检查:网络不可用时直接抛错,避免无效请求
|
||||
if (apiClient.config.onCheckNetworkAvailable != null) {
|
||||
final available = await apiClient.config.onCheckNetworkAvailable!();
|
||||
if (!available) {
|
||||
apiClient.config.onLog?.call(
|
||||
'Network unavailable, abort request: ${request.path}',
|
||||
tag: 'ApiClient',
|
||||
);
|
||||
throw const ApiError.noNetworkConnection();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Upload 且 path 以 http 开头 → 直接用全路径(S3 presigned URL)
|
||||
// 否则 → 拼接 baseURL
|
||||
final isUpload = request.requestType == ApiRequestType.upload;
|
||||
final path = request.path;
|
||||
final url = (isUpload && path.startsWith('http')) ? path : '${apiClient.config.baseURL}$path';
|
||||
|
||||
// 将请求元数据写入 extra,供拦截器读取
|
||||
final options = Options(
|
||||
method: request.method.value,
|
||||
extra: {
|
||||
'requestType': request.requestType,
|
||||
'includeToken': request.includeToken,
|
||||
'customHeaders': request.customHeaders,
|
||||
},
|
||||
);
|
||||
|
||||
// 访问 parameters 触发代码生成器的 fromJson 注册
|
||||
// (@ApiRequest 生成的 mixin 在 parameters getter 中注册响应类型)
|
||||
final params = request.parameters;
|
||||
|
||||
// GET → queryParameters;POST/PUT/DELETE/PATCH → JSON body;Upload → uploadData
|
||||
final isGet = request.method == HttpMethod.get;
|
||||
final response = await apiClient.dio.request(
|
||||
url,
|
||||
data: isUpload ? request.uploadData : (isGet ? null : params),
|
||||
queryParameters: isGet ? params : null,
|
||||
options: options,
|
||||
);
|
||||
|
||||
// 解码响应(Upload 类型通常需要 override decodeResponse)
|
||||
return request.decodeResponse(response);
|
||||
} on DioException catch (e) {
|
||||
throw apiClient.mapDioError(e);
|
||||
} on ApiError {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw ApiError.unknown(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:networks_sdk/networks_sdk.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// WebSocket 长连接客户端
|
||||
///
|
||||
/// 提供:
|
||||
/// - 连接 / 断连 / 自动重连(指数退避)
|
||||
/// - 双层心跳(底层 ping + 应用层 heartbeat)
|
||||
/// - Stream 输出(消息、连接状态、错误)
|
||||
/// - 生命周期感知(前后台切换)
|
||||
///
|
||||
/// ## 使用方式
|
||||
///
|
||||
/// ```dart
|
||||
/// final client = SocketClient(config: socketConfig);
|
||||
///
|
||||
/// // 连接
|
||||
/// await client.connect('wss://api.example.com/ws', token: 'xxx');
|
||||
///
|
||||
/// // 收消息
|
||||
/// client.messageStream.listen((msg) => print(msg));
|
||||
///
|
||||
/// // 发消息
|
||||
/// await client.send({'type': 'chat', 'data': {...}});
|
||||
///
|
||||
/// // 断连
|
||||
/// await client.disconnect();
|
||||
/// ```
|
||||
class SocketClient {
|
||||
final SocketConfig config;
|
||||
|
||||
// ── 内部状态 ──
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _channelSubscription;
|
||||
SocketConnectionState _connectionState = SocketConnectionState.disconnected;
|
||||
String? _currentUrl;
|
||||
String? _currentToken;
|
||||
bool _manualDisconnect = false;
|
||||
bool _isBackground = false;
|
||||
|
||||
// ── 心跳 ──
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _pongTimeoutTimer;
|
||||
bool _waitingForPong = false;
|
||||
|
||||
// ── 重连 ──
|
||||
int _reconnectAttempts = 0;
|
||||
Timer? _reconnectTimer;
|
||||
final _random = Random();
|
||||
|
||||
// ── Stream Controllers ──
|
||||
final _messageController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _rawMessageController = StreamController<String>.broadcast();
|
||||
final _connectionStateController =
|
||||
StreamController<SocketConnectionState>.broadcast();
|
||||
final _errorController = StreamController<SocketError>.broadcast();
|
||||
|
||||
SocketClient({required this.config});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 公开 API — 连接
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 连接到 WebSocket 服务器
|
||||
///
|
||||
/// [url] — 完整的 WebSocket URL(如 `wss://api.example.com/ws`)
|
||||
/// [token] — 可选,拼接到 URL query 参数
|
||||
Future<bool> connect(String url, {String? token}) async {
|
||||
if (_connectionState == SocketConnectionState.connected ||
|
||||
_connectionState == SocketConnectionState.connecting) {
|
||||
_log('Already connected or connecting, skip');
|
||||
return _connectionState == SocketConnectionState.connected;
|
||||
}
|
||||
|
||||
_currentUrl = url;
|
||||
_currentToken = token;
|
||||
_manualDisconnect = false;
|
||||
|
||||
return _doConnect();
|
||||
}
|
||||
|
||||
/// 断开连接
|
||||
///
|
||||
/// 手动断连不触发自动重连。
|
||||
Future<void> disconnect() async {
|
||||
_manualDisconnect = true;
|
||||
await _doDisconnect(reason: 'Manual disconnect');
|
||||
}
|
||||
|
||||
/// 当前是否已连接
|
||||
bool get isConnected =>
|
||||
_connectionState == SocketConnectionState.connected;
|
||||
|
||||
/// 当前连接状态
|
||||
SocketConnectionState get connectionState => _connectionState;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 公开 API — 发送
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 发送 JSON 消息
|
||||
Future<bool> send(Map<String, dynamic> message) {
|
||||
return sendString(jsonEncode(message));
|
||||
}
|
||||
|
||||
/// 发送原始字符串
|
||||
Future<bool> sendString(String message) async {
|
||||
if (!isConnected || _channel == null) {
|
||||
_emitError(SocketError.sendFailed('Not connected'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
_channel!.sink.add(message);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_emitError(SocketError.sendFailed(e.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 公开 API — Stream 输出
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// 已解析的 JSON 消息流
|
||||
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
||||
|
||||
/// 原始字符串消息流(JSON 解析失败的也走这里)
|
||||
Stream<String> get rawMessageStream => _rawMessageController.stream;
|
||||
|
||||
/// 连接状态变化流
|
||||
Stream<SocketConnectionState> get connectionStateStream =>
|
||||
_connectionStateController.stream;
|
||||
|
||||
/// 错误流
|
||||
Stream<SocketError> get errorStream => _errorController.stream;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 公开 API — 生命周期
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// App 回前台 → 检查连接,断了就重连
|
||||
void onEnterForeground() {
|
||||
_isBackground = false;
|
||||
_log('Enter foreground');
|
||||
|
||||
if (!isConnected && !_manualDisconnect && _currentUrl != null) {
|
||||
_log('Connection lost while in background, reconnecting...');
|
||||
_startReconnect();
|
||||
} else if (isConnected) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
/// App 进后台 → 停止心跳(保持连接)
|
||||
void onEnterBackground() {
|
||||
_isBackground = true;
|
||||
_log('Enter background, pausing heartbeat');
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
/// 释放所有资源
|
||||
Future<void> dispose() async {
|
||||
_manualDisconnect = true;
|
||||
await _doDisconnect(reason: 'Dispose');
|
||||
await _messageController.close();
|
||||
await _rawMessageController.close();
|
||||
await _connectionStateController.close();
|
||||
await _errorController.close();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 内部 — 连接
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<bool> _doConnect() async {
|
||||
final url = _currentUrl;
|
||||
if (url == null || url.isEmpty) {
|
||||
_emitError(SocketError.invalidURL(url ?? ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证 URL
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || (!uri.isScheme('ws') && !uri.isScheme('wss'))) {
|
||||
_emitError(SocketError.invalidURL(url));
|
||||
return false;
|
||||
}
|
||||
|
||||
_updateConnectionState(SocketConnectionState.connecting);
|
||||
_log('Connecting to $url');
|
||||
|
||||
try {
|
||||
// 构建最终 URL(拼接 token)
|
||||
final connectUri = _currentToken != null
|
||||
? uri.replace(
|
||||
queryParameters: {
|
||||
...uri.queryParameters,
|
||||
'token': _currentToken!,
|
||||
},
|
||||
)
|
||||
: uri;
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
connectUri,
|
||||
pingInterval: config.pingInterval,
|
||||
);
|
||||
|
||||
// 等待连接就绪
|
||||
await _channel!.ready.timeout(config.connectTimeout);
|
||||
|
||||
_log('Connected');
|
||||
_updateConnectionState(SocketConnectionState.connected);
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
// 开始监听消息
|
||||
_channelSubscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDone,
|
||||
);
|
||||
|
||||
// 启动心跳
|
||||
_startHeartbeat();
|
||||
|
||||
return true;
|
||||
} on TimeoutException {
|
||||
_log('Connection timeout');
|
||||
_emitError(const SocketError.connectionTimeout());
|
||||
await _doDisconnect(reason: 'Timeout');
|
||||
_startReconnect();
|
||||
return false;
|
||||
} catch (e) {
|
||||
_log('Connection failed: $e');
|
||||
_emitError(SocketError.connectionFailed(e.toString()));
|
||||
await _doDisconnect(reason: e.toString());
|
||||
_startReconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _doDisconnect({String? reason}) async {
|
||||
_stopHeartbeat();
|
||||
_stopReconnectTimer();
|
||||
|
||||
await _channelSubscription?.cancel();
|
||||
_channelSubscription = null;
|
||||
|
||||
try {
|
||||
await _channel?.sink.close();
|
||||
} catch (_) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
_channel = null;
|
||||
|
||||
if (_connectionState != SocketConnectionState.disconnected) {
|
||||
_log('Disconnected${reason != null ? ': $reason' : ''}');
|
||||
_updateConnectionState(SocketConnectionState.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 内部 — 消息处理
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _handleMessage(dynamic data) {
|
||||
if (data is! String) {
|
||||
// 非字符串消息(如二进制),走 rawMessageStream
|
||||
_rawMessageController.add(data.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 pong 心跳回复
|
||||
if (data == 'pong') {
|
||||
_onPongReceived();
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试 JSON 解析
|
||||
try {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
_messageController.add(json);
|
||||
} catch (_) {
|
||||
// JSON 解析失败,走原始消息流
|
||||
_rawMessageController.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleError(Object error) {
|
||||
_log('Stream error: $error');
|
||||
_emitError(SocketError.connectionFailed(error.toString()));
|
||||
}
|
||||
|
||||
void _handleDone() async {
|
||||
_log('Stream closed');
|
||||
await _doDisconnect(reason: 'Stream closed');
|
||||
|
||||
if (!_manualDisconnect) {
|
||||
_startReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 内部 — 心跳
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
|
||||
_heartbeatTimer = Timer.periodic(config.heartbeatInterval, (_) {
|
||||
if (!isConnected || _isBackground) return;
|
||||
_sendPing();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
_waitingForPong = false;
|
||||
}
|
||||
|
||||
void _sendPing() {
|
||||
if (_waitingForPong) return;
|
||||
|
||||
_waitingForPong = true;
|
||||
_channel?.sink.add('ping');
|
||||
|
||||
// 启动 pong 超时计时器
|
||||
_pongTimeoutTimer = Timer(config.pongTimeout, () {
|
||||
if (_waitingForPong) {
|
||||
_log('Pong timeout, reconnecting...');
|
||||
_waitingForPong = false;
|
||||
_emitError(const SocketError.pingTimeout());
|
||||
_doDisconnect(reason: 'Pong timeout');
|
||||
_startReconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onPongReceived() {
|
||||
_waitingForPong = false;
|
||||
_pongTimeoutTimer?.cancel();
|
||||
_pongTimeoutTimer = null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 内部 — 重连(指数退避)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _startReconnect() {
|
||||
if (_manualDisconnect || !config.autoReconnect || _isBackground) return;
|
||||
if (_connectionState == SocketConnectionState.reconnecting) return;
|
||||
|
||||
if (_reconnectAttempts >= config.maxReconnectAttempts) {
|
||||
_log('Max reconnect attempts reached ($_reconnectAttempts)');
|
||||
_reconnectAttempts = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_updateConnectionState(SocketConnectionState.reconnecting);
|
||||
_reconnectAttempts++;
|
||||
|
||||
// 指数退避 + jitter:min(2^attempt * 1s, maxReconnectDelay) + random jitter
|
||||
// jitter 防止多设备同时重连导致服务器瞬间过载(thundering herd)
|
||||
final baseDelayMs = min(
|
||||
pow(2, _reconnectAttempts).toInt() * 1000,
|
||||
config.maxReconnectDelay.inMilliseconds,
|
||||
);
|
||||
final jitterMs = _random.nextInt((baseDelayMs * 0.25).toInt().clamp(1, 7500));
|
||||
final delay = Duration(milliseconds: baseDelayMs + jitterMs);
|
||||
|
||||
_log('Reconnecting in ${delay.inMilliseconds}ms '
|
||||
'(attempt $_reconnectAttempts/${config.maxReconnectAttempts})');
|
||||
|
||||
_reconnectTimer = Timer(delay, () async {
|
||||
// 重连前检查网络
|
||||
if (config.onCheckNetworkAvailable != null) {
|
||||
final available = await config.onCheckNetworkAvailable!();
|
||||
if (!available) {
|
||||
_log('Network unavailable, skip reconnect');
|
||||
_emitError(const SocketError.networkUnavailable());
|
||||
_updateConnectionState(SocketConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_doConnect();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopReconnectTimer() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// 内部 — 辅助
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _updateConnectionState(SocketConnectionState newState) {
|
||||
if (_connectionState == newState) return;
|
||||
_connectionState = newState;
|
||||
_connectionStateController.add(newState);
|
||||
}
|
||||
|
||||
void _emitError(SocketError error) {
|
||||
_errorController.add(error);
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
config.onLog?.call(message, tag: 'Socket');
|
||||
}
|
||||
}
|
||||
160
packages/networks_sdk/lib/src/data/dto/api_requestable.dart
Normal file
160
packages/networks_sdk/lib/src/data/dto/api_requestable.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:networks_sdk/src/data/dto/api_response_wrapper.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_error.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/api_request_type.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/http_method.dart';
|
||||
|
||||
|
||||
/// API 请求基类
|
||||
///
|
||||
/// 使用侧只需:字段 + path + method,其余全部有默认实现。
|
||||
///
|
||||
/// ```dart
|
||||
/// @JsonSerializable()
|
||||
/// class LoginRequest extends ApiRequestable<LoginData> {
|
||||
/// final String email;
|
||||
/// final String password;
|
||||
///
|
||||
/// LoginRequest({required this.email, required this.password});
|
||||
///
|
||||
/// factory LoginRequest.fromJson(Map<String, dynamic> json) =>
|
||||
/// _$LoginRequestFromJson(json);
|
||||
/// @override
|
||||
/// Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
///
|
||||
/// @override
|
||||
/// String get path => '/auth/login';
|
||||
/// @override
|
||||
/// HttpMethod get method => HttpMethod.post;
|
||||
/// @override
|
||||
/// bool get includeToken => false;
|
||||
/// }
|
||||
///
|
||||
/// // 文件顶层注册一次(一行)
|
||||
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// ```
|
||||
abstract class ApiRequestable<T> {
|
||||
/// API 路径(如 '/auth/login')
|
||||
String get path;
|
||||
|
||||
/// HTTP 方法
|
||||
HttpMethod get method;
|
||||
|
||||
/// 序列化为 JSON(由 @JsonSerializable 自动生成)
|
||||
/// 子类 override 返回 `_$XxxToJson(this)` 即可
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// 请求参数 — 默认调用 toJson(),upload 类型返回 null
|
||||
/// 绝大多数情况无需 override
|
||||
Map<String, dynamic>? get parameters =>
|
||||
requestType == ApiRequestType.upload ? null : toJson();
|
||||
|
||||
/// 上传请求的 body — upload 类型 override 此方法
|
||||
///
|
||||
/// 支持多种上传格式:
|
||||
/// - `FormData` — multipart 表单上传到自有后端
|
||||
/// - `Uint8List` — 二进制流上传到 S3 presigned URL
|
||||
/// - 其他 Dio 支持的类型
|
||||
///
|
||||
/// ```dart
|
||||
/// // FormData 上传
|
||||
/// @override
|
||||
/// Object? get uploadData => FormData.fromMap({
|
||||
/// 'file': MultipartFile.fromFileSync(filePath),
|
||||
/// });
|
||||
///
|
||||
/// // 二进制上传(S3 presigned URL)
|
||||
/// @override
|
||||
/// Object? get uploadData => fileBytes; // Uint8List
|
||||
/// ```
|
||||
Object? get uploadData => null;
|
||||
|
||||
/// 自定义 headers(合并时覆盖默认值)
|
||||
Map<String, String>? get customHeaders => null;
|
||||
|
||||
/// 请求类型(默认 request,登录 override 为 login)
|
||||
ApiRequestType get requestType => ApiRequestType.request;
|
||||
|
||||
/// 是否在 header 中包含 token(默认 true)
|
||||
bool get includeToken => true;
|
||||
|
||||
/// 解码响应 — 自动从 fromJson 注册表查找解码器
|
||||
///
|
||||
/// **无需 override**,只要注册了响应类型就能自动解码。
|
||||
/// 支持无响应数据的接口(如 logout):data 为 null 时不查注册表,直接返回 null。
|
||||
T? decodeResponse(Response response) {
|
||||
try {
|
||||
final data = response.data;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
// fromJson 查找延迟到实际需要解码时执行
|
||||
// → 无 data 的接口(logout / delete)不会触发 StateError
|
||||
T fromJsonObject(Object? json) {
|
||||
final fromJsonFunc = fromJsonRegistry[T];
|
||||
|
||||
if (fromJsonFunc == null) {
|
||||
throw StateError(
|
||||
'fromJson not registered for type $T. '
|
||||
'Add: final _reg = registerResponse<$T>($T.fromJson);',
|
||||
);
|
||||
}
|
||||
|
||||
if (fromJsonFunc is T Function(Object?)) {
|
||||
return fromJsonFunc(json);
|
||||
}
|
||||
if (json is Map<String, dynamic>) {
|
||||
final mapFunc = fromJsonFunc as T Function(Map<String, dynamic>);
|
||||
return mapFunc(json);
|
||||
}
|
||||
throw FormatException('Expected Map<String, dynamic>, got ${json.runtimeType}',);
|
||||
}
|
||||
|
||||
final wrapper = ApiResponseWrapper<T>.fromJson(data, fromJsonObject);
|
||||
|
||||
// 业务错误码检查
|
||||
if (wrapper.code != 0) {
|
||||
throw ApiError.apiError(
|
||||
code: wrapper.code,
|
||||
message: wrapper.message ?? 'API error (code: ${wrapper.code})',
|
||||
);
|
||||
}
|
||||
|
||||
return wrapper.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e is ApiError) rethrow;
|
||||
throw ApiError.decodingError(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── fromJson 注册表 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 全局 fromJson 注册表
|
||||
final fromJsonRegistry = <Type, Function>{};
|
||||
|
||||
/// 注册响应类型(标准 `Map<String, dynamic>` 类型)
|
||||
///
|
||||
/// 在定义 Response 类的文件顶层调用一次即可:
|
||||
/// ```dart
|
||||
/// final _reg = registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// ```
|
||||
T Function(Map<String, dynamic>)? registerResponse<T>(T Function(Map<String, dynamic>) fromJson,)
|
||||
{
|
||||
fromJsonRegistry[T] = fromJson;
|
||||
return fromJson;
|
||||
}
|
||||
|
||||
/// 注册响应类型(`Object?` 类型,支持 List 等复杂结构)
|
||||
///
|
||||
/// ```dart
|
||||
/// final _reg = registerResponseObject<DeviceList>(DeviceList.fromJson);
|
||||
/// ```
|
||||
T Function(Object?)? registerResponseObject<T>(
|
||||
T Function(Object?) fromJson,
|
||||
) {
|
||||
fromJsonRegistry[T] = fromJson;
|
||||
return fromJson;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// API 响应信封解析器
|
||||
/// 统一处理 { code, message/msg, data } 格式的服务器响应
|
||||
class ApiResponseWrapper<T> {
|
||||
final int code;
|
||||
final String? message;
|
||||
final T? data;
|
||||
|
||||
const ApiResponseWrapper({
|
||||
required this.code,
|
||||
this.message,
|
||||
this.data,
|
||||
});
|
||||
|
||||
factory ApiResponseWrapper.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object?) fromJsonT,
|
||||
) {
|
||||
// code 字段:兼容 int 和 String
|
||||
final int codeValue;
|
||||
if (json['code'] is int) {
|
||||
codeValue = json['code'] as int;
|
||||
} else if (json['code'] is String) {
|
||||
codeValue = int.tryParse(json['code'] as String) ?? 0;
|
||||
} else {
|
||||
throw FormatException(
|
||||
'Expected int or String for code, got ${json['code'].runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
// message 字段:兼容 message 和 msg
|
||||
final message =
|
||||
json['message'] as String? ?? json['msg'] as String?;
|
||||
|
||||
// 解码 data(null-safe:logout / delete 等接口可能无 data)
|
||||
final rawData = json['data'];
|
||||
final T? decodedData = rawData != null ? fromJsonT(rawData) : null;
|
||||
|
||||
return ApiResponseWrapper<T>(
|
||||
code: codeValue,
|
||||
message: message,
|
||||
data: decodedData,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
/// Data Transfer Object
|
||||
/// - 只負責資料傳輸 / 解析
|
||||
/// - 結構可變
|
||||
/// - 可以依賴 JSON / platform
|
||||
class NetworksSdkPermissionStatusDto {
|
||||
final bool granted;
|
||||
final bool permanentlyDenied;
|
||||
final String? grantedAt; // 通常是 raw string
|
||||
|
||||
NetworksSdkPermissionStatusDto({
|
||||
required this.granted,
|
||||
required this.permanentlyDenied,
|
||||
this.grantedAt,
|
||||
});
|
||||
|
||||
factory NetworksSdkPermissionStatusDto.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
return NetworksSdkPermissionStatusDto(
|
||||
granted: json['granted'] as bool,
|
||||
permanentlyDenied: json['permanentlyDenied'] as bool,
|
||||
grantedAt: json['grantedAt'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:networks_sdk/src/data/datasources/socket/socket_client.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/domain/repositories/networks_messaging_repository.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Messaging Repository Implementation (Data)
|
||||
class NetworksMessagingRepositoryImpl implements NetworksMessagingRepository {
|
||||
SocketClient? _socketClient;
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initialize(SocketConfig config) {
|
||||
_socketClient = SocketClient(config: config);
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
void _checkInitialized() {
|
||||
if (!_isInitialized || _socketClient == null) {
|
||||
throw StateError(
|
||||
'NetworksMessagingRepository not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> connect(String url, {String? token}) {
|
||||
_checkInitialized();
|
||||
return _socketClient!.connect(url, token: token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() {
|
||||
_checkInitialized();
|
||||
return _socketClient!.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isConnected {
|
||||
_checkInitialized();
|
||||
return _socketClient!.isConnected;
|
||||
}
|
||||
|
||||
@override
|
||||
SocketConnectionState get connectionState {
|
||||
_checkInitialized();
|
||||
return _socketClient!.connectionState;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> send(Map<String, dynamic> message) {
|
||||
_checkInitialized();
|
||||
return _socketClient!.send(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> sendString(String message) {
|
||||
_checkInitialized();
|
||||
return _socketClient!.sendString(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get messageStream {
|
||||
_checkInitialized();
|
||||
return _socketClient!.messageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> get rawMessageStream {
|
||||
_checkInitialized();
|
||||
return _socketClient!.rawMessageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SocketConnectionState> get connectionStateStream {
|
||||
_checkInitialized();
|
||||
return _socketClient!.connectionStateStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SocketError> get errorStream {
|
||||
_checkInitialized();
|
||||
return _socketClient!.errorStream;
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnterForeground() {
|
||||
_checkInitialized();
|
||||
_socketClient!.onEnterForeground();
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnterBackground() {
|
||||
_checkInitialized();
|
||||
_socketClient!.onEnterBackground();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_socketClient != null) {
|
||||
await _socketClient!.dispose();
|
||||
_socketClient = null;
|
||||
}
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//Repository Impl
|
||||
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
import '../../domain/repositories/networks_sdk_repository.dart';
|
||||
import '../datasources/networks_sdk_method_channel_datasource.dart';
|
||||
|
||||
class NetworksSdkRepositoryImpl implements NetworksSdkRepository
|
||||
{
|
||||
final NetworksSdkMethodChannelDataSource _datasource;
|
||||
|
||||
const NetworksSdkRepositoryImpl(this._datasource);
|
||||
|
||||
@override
|
||||
Future<String?> platformVersion() {
|
||||
return _datasource.getPlatformVersion();
|
||||
}
|
||||
|
||||
@override
|
||||
void initialize(ApiConfig apiConfig){
|
||||
_datasource.initialize(apiConfig);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request) {
|
||||
return _datasource.executeRequest(request);
|
||||
}
|
||||
}
|
||||
31
packages/networks_sdk/lib/src/domain/entities/api_error.dart
Normal file
31
packages/networks_sdk/lib/src/domain/entities/api_error.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'api_error.freezed.dart';
|
||||
|
||||
/// API 统一错误类型(Freezed 联合类型)
|
||||
@freezed
|
||||
class ApiError with _$ApiError implements Exception {
|
||||
const factory ApiError.noNetworkConnection() = _NoNetworkConnection;
|
||||
const factory ApiError.timeout() = _Timeout;
|
||||
const factory ApiError.networkError(String message) = _NetworkError;
|
||||
const factory ApiError.decodingError(String message) = _DecodingError;
|
||||
const factory ApiError.apiError({
|
||||
required int code,
|
||||
required String message,
|
||||
}) = _ApiError;
|
||||
const factory ApiError.unknown(String? message) = _Unknown;
|
||||
}
|
||||
|
||||
/// 错误类型扩展 - 提供用户可读的错误描述
|
||||
extension ApiErrorExtension on ApiError {
|
||||
String get displayMessage {
|
||||
return when(
|
||||
noNetworkConnection: () => 'No network connection',
|
||||
timeout: () => 'Request timeout',
|
||||
networkError: (message) => 'Network error: $message',
|
||||
decodingError: (message) => 'Decoding error: $message',
|
||||
apiError: (code, message) => message,
|
||||
unknown: (message) => message ?? 'Unknown error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// API 请求类型
|
||||
enum ApiRequestType {
|
||||
/// 普通请求(包含 token header)
|
||||
request,
|
||||
|
||||
/// 登录请求(不包含 token header)
|
||||
login,
|
||||
|
||||
/// 文件上传(multipart,不序列化 parameters)
|
||||
upload,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// HTTP 方法枚举
|
||||
enum HttpMethod {
|
||||
get('GET'),
|
||||
post('POST'),
|
||||
put('PUT'),
|
||||
delete('DELETE'),
|
||||
patch('PATCH');
|
||||
|
||||
final String value;
|
||||
const HttpMethod(this.value);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/// Domain Entity
|
||||
/// - 表達「通知權限狀態」這個業務概念
|
||||
/// - 穩定、可被 Facade 回傳
|
||||
/// - 不要包含 JSON / platform / plugin
|
||||
class NetworksSdkPermissionStatus {
|
||||
final bool isGranted;
|
||||
final bool isPermanentlyDenied;
|
||||
final DateTime? grantedAt;
|
||||
|
||||
const NetworksSdkPermissionStatus({
|
||||
required this.isGranted,
|
||||
required this.isPermanentlyDenied,
|
||||
this.grantedAt,
|
||||
});
|
||||
|
||||
/// 純業務邏輯允許
|
||||
bool get canRequestAgain => !isGranted && !isPermanentlyDenied;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// WebSocket 连接状态
|
||||
enum SocketConnectionState {
|
||||
/// 未连接
|
||||
disconnected,
|
||||
|
||||
/// 连接中
|
||||
connecting,
|
||||
|
||||
/// 已连接
|
||||
connected,
|
||||
|
||||
/// 重连中(断线后自动重连)
|
||||
reconnecting,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'socket_error.freezed.dart';
|
||||
|
||||
/// WebSocket 统一错误类型(Freezed 联合类型)
|
||||
@freezed
|
||||
class SocketError with _$SocketError implements Exception {
|
||||
const factory SocketError.connectionFailed(String message) =
|
||||
_ConnectionFailed;
|
||||
const factory SocketError.connectionTimeout() = _ConnectionTimeout;
|
||||
const factory SocketError.disconnected({String? reason}) = _Disconnected;
|
||||
const factory SocketError.pingTimeout() = _PingTimeout;
|
||||
const factory SocketError.networkUnavailable() = _NetworkUnavailable;
|
||||
const factory SocketError.invalidURL(String url) = _InvalidURL;
|
||||
const factory SocketError.sendFailed(String message) = _SendFailed;
|
||||
const factory SocketError.unknown(String? message) = _Unknown;
|
||||
}
|
||||
|
||||
/// 错误类型扩展 — 提供用户可读的错误描述
|
||||
extension SocketErrorExtension on SocketError {
|
||||
String get displayMessage {
|
||||
return when(
|
||||
connectionFailed: (message) => 'Connection failed: $message',
|
||||
connectionTimeout: () => 'Connection timeout',
|
||||
disconnected: (reason) => reason ?? 'Disconnected',
|
||||
pingTimeout: () => 'Ping timeout',
|
||||
networkUnavailable: () => 'Network unavailable',
|
||||
invalidURL: (url) => 'Invalid URL: $url',
|
||||
sendFailed: (message) => 'Send failed: $message',
|
||||
unknown: (message) => message ?? 'Unknown error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Messaging Repository Interface (Domain)
|
||||
abstract class NetworksMessagingRepository {
|
||||
/// Initialize with config
|
||||
void initialize(SocketConfig config);
|
||||
|
||||
/// Connect to messaging server
|
||||
Future<bool> connect(String url, {String? token});
|
||||
|
||||
/// Disconnect from server
|
||||
Future<void> disconnect();
|
||||
|
||||
/// Check if connected
|
||||
bool get isConnected;
|
||||
|
||||
/// Current connection state
|
||||
SocketConnectionState get connectionState;
|
||||
|
||||
/// Send a JSON message
|
||||
Future<bool> send(Map<String, dynamic> message);
|
||||
|
||||
/// Send a raw string message
|
||||
Future<bool> sendString(String message);
|
||||
|
||||
/// Stream of incoming parsed JSON messages
|
||||
Stream<Map<String, dynamic>> get messageStream;
|
||||
|
||||
/// Stream of raw string messages
|
||||
Stream<String> get rawMessageStream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
Stream<SocketConnectionState> get connectionStateStream;
|
||||
|
||||
/// Stream of errors
|
||||
Stream<SocketError> get errorStream;
|
||||
|
||||
/// Called when app enters foreground
|
||||
void onEnterForeground();
|
||||
|
||||
/// Called when app enters background
|
||||
void onEnterBackground();
|
||||
|
||||
/// Dispose all resources
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Repository Interface(Domain)
|
||||
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
|
||||
abstract class NetworksSdkRepository {
|
||||
Future<String?> platformVersion();
|
||||
|
||||
void initialize(ApiConfig apiConfig);
|
||||
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
//UseCase
|
||||
|
||||
import '../repositories/networks_sdk_repository.dart';
|
||||
|
||||
class PlatformVersion
|
||||
{
|
||||
final NetworksSdkRepository _repository;
|
||||
|
||||
const PlatformVersion(this._repository);
|
||||
|
||||
Future<String?> call() {
|
||||
return _repository.platformVersion();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:analyzer/dart/element/element.dart';
|
||||
import 'package:analyzer/dart/element/type.dart';
|
||||
import 'package:networks_sdk/src/annotations/api_request.dart';
|
||||
import 'package:source_gen/source_gen.dart';
|
||||
import 'package:build/build.dart';
|
||||
|
||||
|
||||
/// @ApiRequest 代码生成器
|
||||
///
|
||||
/// 为标注了 `@ApiRequest` 的类自动生成 mixin,提供:
|
||||
/// - `path`, `method`, `requestType`, `includeToken` 协议实现
|
||||
/// - 自动注册响应类型的 `fromJson`(在 `parameters` getter 中触发,
|
||||
/// 保证首次请求前完成注册,无需手动调用 `registerApiResponses()`)
|
||||
///
|
||||
/// 生成的 mixin 命名规则:`_$<ClassName>Api`
|
||||
///
|
||||
/// 示例输出:
|
||||
/// ```dart
|
||||
/// mixin _$LoginRequestApi on ApiRequestable<LoginData> {
|
||||
/// @override String get path => '/auth/login';
|
||||
/// @override HttpMethod get method => HttpMethod.post;
|
||||
/// @override ApiRequestType get requestType => ApiRequestType.login;
|
||||
/// @override bool get includeToken => false;
|
||||
/// @override
|
||||
/// Map<String, dynamic>? get parameters {
|
||||
/// registerResponse<LoginData>(LoginData.fromJson);
|
||||
/// return super.parameters;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest>
|
||||
{
|
||||
@override
|
||||
String generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep,)
|
||||
{
|
||||
if (element is! ClassElement) {
|
||||
throw InvalidGenerationSourceError(
|
||||
'@ApiRequest can only be applied to classes.',
|
||||
element: element,
|
||||
);
|
||||
}
|
||||
|
||||
final className = element.name;
|
||||
final path = annotation.read('path').stringValue;
|
||||
|
||||
// 读取 HttpMethod 枚举值
|
||||
final methodName = _readEnumName(
|
||||
annotation.read('method').objectValue,
|
||||
'post',
|
||||
);
|
||||
|
||||
// 读取 responseType(用于泛型绑定 + 自动注册 fromJson)
|
||||
final responseType = annotation.read('responseType').typeValue;
|
||||
final responseTypeName = responseType.getDisplayString();
|
||||
|
||||
// 读取 ApiRequestType 枚举值
|
||||
final requestTypeName = _readEnumName(
|
||||
annotation.read('requestType').objectValue,
|
||||
'request',
|
||||
);
|
||||
|
||||
// 读取 includeToken(默认根据 requestType 推断:login → false,其余 → true)
|
||||
final includeTokenReader = annotation.peek('includeToken');
|
||||
final bool includeToken;
|
||||
if (includeTokenReader != null && !includeTokenReader.isNull) {
|
||||
includeToken = includeTokenReader.boolValue;
|
||||
} else {
|
||||
includeToken = requestTypeName != 'login';
|
||||
}
|
||||
|
||||
return '''
|
||||
/// Generated by @ApiRequest for [$className]
|
||||
mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
|
||||
@override
|
||||
String get path => '$path';
|
||||
@override
|
||||
HttpMethod get method => HttpMethod.$methodName;
|
||||
@override
|
||||
ApiRequestType get requestType => ApiRequestType.$requestTypeName;
|
||||
@override
|
||||
bool get includeToken => $includeToken;
|
||||
@override
|
||||
Map<String, dynamic>? get parameters {
|
||||
registerResponse<$responseTypeName>($responseTypeName.fromJson);
|
||||
return super.parameters;
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
/// 从 DartObject 提取枚举常量名称
|
||||
String _readEnumName(dynamic dartObject, String defaultValue) {
|
||||
final index = dartObject.getField('index')?.toIntValue();
|
||||
if (index == null) return defaultValue;
|
||||
|
||||
final type = dartObject.type;
|
||||
if (type is InterfaceType) {
|
||||
final constants = type.element.fields
|
||||
.where((f) => f.isEnumConstant)
|
||||
.toList();
|
||||
if (index < constants.length) {
|
||||
return constants[index].name ?? defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
12
packages/networks_sdk/lib/src/generator/builder.dart
Normal file
12
packages/networks_sdk/lib/src/generator/builder.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:build/build.dart';
|
||||
import 'package:source_gen/source_gen.dart';
|
||||
|
||||
import 'api_request_generator.dart';
|
||||
|
||||
/// @ApiRequest 代码生成器入口
|
||||
///
|
||||
/// 在 `build.yaml` 中注册此 builder,配合 `build_runner` 使用。
|
||||
/// 生成的代码通过 `SharedPartBuilder` 合并到 `.g.dart` 文件中,
|
||||
/// 与 `json_serializable` 等生成器共存。
|
||||
Builder apiRequestBuilder(BuilderOptions options) =>
|
||||
SharedPartBuilder([ApiRequestGenerator()], 'api_request');
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/networks_sdk_wiring.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Messaging API for real-time communication
|
||||
///
|
||||
/// This abstract class provides a technology-agnostic interface for
|
||||
/// real-time messaging. The actual implementation may use WebSocket
|
||||
/// or other transport mechanisms.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```dart
|
||||
/// final messaging = NetworksMessagingApi();
|
||||
/// await messaging.initialize(SocketConfig(...));
|
||||
///
|
||||
/// // Connect to messaging server
|
||||
/// await messaging.connect('wss://api.example.com/ws', token: 'xxx');
|
||||
///
|
||||
/// // Listen for messages
|
||||
/// messaging.messageStream.listen((msg) => print(msg));
|
||||
///
|
||||
/// // Send messages
|
||||
/// await messaging.send({'type': 'chat', 'data': {...}});
|
||||
///
|
||||
/// // Handle connection state
|
||||
/// messaging.connectionStateStream.listen((state) => ...);
|
||||
///
|
||||
/// // Handle errors
|
||||
/// messaging.errorStream.listen((error) => ...);
|
||||
///
|
||||
/// // Lifecycle management
|
||||
/// messaging.onEnterForeground();
|
||||
/// messaging.onEnterBackground();
|
||||
///
|
||||
/// // Cleanup
|
||||
/// await messaging.disconnect();
|
||||
/// await messaging.dispose();
|
||||
/// ```
|
||||
abstract class NetworksMessagingApi
|
||||
{
|
||||
factory NetworksMessagingApi() => NetworksSdkWiring.buildMessagingApi();
|
||||
|
||||
/// Initialize the messaging service with configuration
|
||||
void initialize(SocketConfig config);
|
||||
|
||||
/// Connect to the messaging server
|
||||
///
|
||||
/// [url] - WebSocket URL (e.g., 'wss://api.example.com/ws')
|
||||
/// [token] - Optional authentication token
|
||||
Future<bool> connect(String url, {String? token});
|
||||
|
||||
/// Disconnect from the messaging server
|
||||
///
|
||||
/// Manual disconnect does not trigger auto-reconnect
|
||||
Future<void> disconnect();
|
||||
|
||||
/// Check if currently connected
|
||||
bool get isConnected;
|
||||
|
||||
/// Current connection state
|
||||
SocketConnectionState get connectionState;
|
||||
|
||||
/// Send a JSON message
|
||||
Future<bool> send(Map<String, dynamic> message);
|
||||
|
||||
/// Send a raw string message
|
||||
Future<bool> sendString(String message);
|
||||
|
||||
/// Stream of incoming parsed JSON messages
|
||||
Stream<Map<String, dynamic>> get messageStream;
|
||||
|
||||
/// Stream of raw string messages (including failed JSON parses)
|
||||
Stream<String> get rawMessageStream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
Stream<SocketConnectionState> get connectionStateStream;
|
||||
|
||||
/// Stream of errors
|
||||
Stream<SocketError> get errorStream;
|
||||
|
||||
/// Called when app enters foreground
|
||||
void onEnterForeground();
|
||||
|
||||
/// Called when app enters background
|
||||
void onEnterBackground();
|
||||
|
||||
/// Dispose all resources
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
|
||||
|
||||
import 'package:networks_sdk/src/data/dto/api_requestable.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/networks_sdk_wiring.dart';
|
||||
|
||||
|
||||
/// SDK API
|
||||
abstract class NetworksSdkApi
|
||||
{
|
||||
factory NetworksSdkApi() => NetworksSdkWiring.build();
|
||||
|
||||
Future<String?> platformVersion();
|
||||
|
||||
void initialize(ApiConfig aApiConfig);
|
||||
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
|
||||
import 'network_callbacks.dart';
|
||||
|
||||
/// API 配置
|
||||
/// 非单例,由 App 层构造并注入到 ApiClient
|
||||
class ApiConfig {
|
||||
/// 基础 URL(来自 config.json → AppConfig.apiBaseUrl)
|
||||
String baseURL;
|
||||
|
||||
/// 当前 token(内存持有,App 层负责持久化)
|
||||
String? token;
|
||||
|
||||
/// 平台相关 headers(App 层注入:version、platform、channel 等)
|
||||
Map<String, String> platformHeaders;
|
||||
|
||||
/// Token 过期时的刷新回调
|
||||
final OnTokenRefresh? onTokenRefresh;
|
||||
|
||||
/// 需要强制登出时的回调
|
||||
final OnForceLogout? onForceLogout;
|
||||
|
||||
/// 日志输出回调(不设置则不输出日志)
|
||||
final OnLog? onLog;
|
||||
|
||||
/// 网络可用性查询(App 层注入,请求前调用)
|
||||
///
|
||||
/// 与 [SocketConfig.onCheckNetworkAvailable] 对称。
|
||||
/// 返回 true 表示网络可用,可以发起请求;
|
||||
/// 返回 false 则直接抛 [ApiError.noNetworkConnection],不走网络。
|
||||
final OnCheckNetworkAvailable? onCheckNetworkAvailable;
|
||||
|
||||
/// App 层定义的 Token 过期错误码集合
|
||||
final Set<int> tokenExpiredCodes;
|
||||
|
||||
/// App 层定义的强制登出错误码集合
|
||||
final Set<int> forceLogoutCodes;
|
||||
|
||||
/// 瞬态错误最大重试次数(5xx / 超时 / 连接失败)
|
||||
///
|
||||
/// 0 = 不重试(默认),设为 3 启用重试。
|
||||
/// 与 Token 刷新重试独立,两者可叠加。
|
||||
final int maxRetries;
|
||||
|
||||
/// 重试基础延迟(指数退避起点)
|
||||
///
|
||||
/// 实际延迟 = min(baseDelay * 2^attempt, 30s) + jitter
|
||||
final Duration retryBaseDelay;
|
||||
|
||||
ApiConfig({
|
||||
required this.baseURL,
|
||||
this.token,
|
||||
this.platformHeaders = const {},
|
||||
this.onTokenRefresh,
|
||||
this.onForceLogout,
|
||||
this.onLog,
|
||||
this.onCheckNetworkAvailable,
|
||||
this.tokenExpiredCodes = const {},
|
||||
this.forceLogoutCodes = const {},
|
||||
this.maxRetries = 0,
|
||||
this.retryBaseDelay = const Duration(seconds: 1),
|
||||
});
|
||||
|
||||
/// 构建默认 headers
|
||||
/// [includeToken] — 是否注入 token
|
||||
/// [customHeaders] — 单次请求自定义 header(优先级最高)
|
||||
Map<String, String> defaultHeaders({
|
||||
bool includeToken = true,
|
||||
Map<String, String>? customHeaders,
|
||||
}) {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Accept': 'application/json',
|
||||
'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
|
||||
'APP-Request-ID': _generateRequestId(),
|
||||
};
|
||||
|
||||
// 合并平台 headers(App 层注入的 version、platform 等)
|
||||
headers.addAll(platformHeaders);
|
||||
|
||||
// Token 注入
|
||||
if (includeToken && token != null && token!.isNotEmpty) {
|
||||
headers['token'] = token!;
|
||||
}
|
||||
|
||||
// 单次请求自定义 header(覆盖默认值)
|
||||
if (customHeaders != null) {
|
||||
headers.addAll(customHeaders);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/// 更新 token
|
||||
void updateToken(String? newToken) {
|
||||
token = newToken;
|
||||
}
|
||||
|
||||
/// 更新 base URL
|
||||
void updateBaseURL(String newBaseURL) {
|
||||
baseURL = newBaseURL;
|
||||
}
|
||||
|
||||
/// 生成请求 ID(用于幂等性)
|
||||
String _generateRequestId() {
|
||||
final now = DateTime.now().microsecondsSinceEpoch;
|
||||
return '$now${Object().hashCode}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// 网络层回调类型定义,由 App 层注入 SDK,避免 SDK 直接依赖外部实现。
|
||||
library;
|
||||
|
||||
typedef OnTokenRefresh = Future<String?> Function();
|
||||
|
||||
/// 強制登出回調
|
||||
typedef OnForceLogout = void Function();
|
||||
|
||||
/// 日誌輸出回調
|
||||
typedef OnLog = void Function(String message, {String? tag});
|
||||
|
||||
/// 網路可用性查詢(App 層注入,SDK 在請求前調用)
|
||||
typedef OnCheckNetworkAvailable = Future<bool> Function();
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:networks_sdk/src/data/repositories/networks_messaging_repository_impl.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_connection_state.dart';
|
||||
import 'package:networks_sdk/src/domain/entities/socket_error.dart';
|
||||
import 'package:networks_sdk/src/domain/repositories/networks_messaging_repository.dart';
|
||||
import 'package:networks_sdk/src/presentation/facade/networks_messaging_api.dart';
|
||||
import 'package:networks_sdk/src/presentation/wiring/socket_config.dart';
|
||||
|
||||
/// Implementation of [NetworksMessagingApi] using [NetworksMessagingRepository]
|
||||
class NetworksMessagingApiImpl implements NetworksMessagingApi {
|
||||
NetworksMessagingRepository? _repository;
|
||||
|
||||
@override
|
||||
void initialize(SocketConfig config) {
|
||||
_repository = NetworksMessagingRepositoryImpl();
|
||||
_repository!.initialize(config);
|
||||
}
|
||||
|
||||
void _checkInitialized() {
|
||||
if (_repository == null) {
|
||||
throw StateError(
|
||||
'NetworksMessagingApi not initialized. Call initialize() first.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> connect(String url, {String? token}) {
|
||||
_checkInitialized();
|
||||
return _repository!.connect(url, token: token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() {
|
||||
_checkInitialized();
|
||||
return _repository!.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isConnected {
|
||||
_checkInitialized();
|
||||
return _repository!.isConnected;
|
||||
}
|
||||
|
||||
@override
|
||||
SocketConnectionState get connectionState {
|
||||
_checkInitialized();
|
||||
return _repository!.connectionState;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> send(Map<String, dynamic> message) {
|
||||
_checkInitialized();
|
||||
return _repository!.send(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> sendString(String message) {
|
||||
_checkInitialized();
|
||||
return _repository!.sendString(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get messageStream {
|
||||
_checkInitialized();
|
||||
return _repository!.messageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<String> get rawMessageStream {
|
||||
_checkInitialized();
|
||||
return _repository!.rawMessageStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SocketConnectionState> get connectionStateStream {
|
||||
_checkInitialized();
|
||||
return _repository!.connectionStateStream;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<SocketError> get errorStream {
|
||||
_checkInitialized();
|
||||
return _repository!.errorStream;
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnterForeground() {
|
||||
_checkInitialized();
|
||||
_repository!.onEnterForeground();
|
||||
}
|
||||
|
||||
@override
|
||||
void onEnterBackground() {
|
||||
_checkInitialized();
|
||||
_repository!.onEnterBackground();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_repository != null) {
|
||||
await _repository!.dispose();
|
||||
_repository = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../../../networks_sdk.dart';
|
||||
import 'networks_sdk_core.dart';
|
||||
|
||||
/// SDK API Implementation
|
||||
class NetworksSdkApiImpl implements NetworksSdkApi {
|
||||
final NetworksSdkCore _core;
|
||||
|
||||
NetworksSdkApiImpl({required NetworksSdkCore core}) : _core = core;
|
||||
|
||||
@override
|
||||
Future<String?> platformVersion() => _core.repo.platformVersion();
|
||||
|
||||
@override
|
||||
void initialize(ApiConfig apiConfig) => _core.repo.initialize(apiConfig);
|
||||
|
||||
@override
|
||||
Future<T?> executeRequest<T>(ApiRequestable<T> request) => _core.repo.executeRequest(request);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
import '../../../networks_sdk_platform_interface.dart';
|
||||
import '../../domain/repositories/networks_sdk_repository.dart';
|
||||
|
||||
class NetworksSdkCore
|
||||
{
|
||||
final NetworksSdkPlatform platform;
|
||||
final NetworksSdkRepository repo;
|
||||
|
||||
NetworksSdkCore({
|
||||
required this.platform,
|
||||
required this.repo,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import '../../../networks_sdk.dart';
|
||||
import '../../../networks_sdk_method_channel.dart';
|
||||
import '../../../networks_sdk_platform_interface.dart';
|
||||
import '../../data/datasources/networks_sdk_method_channel_datasource.dart';
|
||||
import '../../data/repositories/networks_sdk_repository_impl.dart';
|
||||
import 'networks_sdk_core.dart';
|
||||
import 'networks_sdk_api_impl.dart';
|
||||
|
||||
/// SDK Wiring - builds all SDK components
|
||||
class NetworksSdkWiring
|
||||
{
|
||||
/// Builds the HTTP API
|
||||
static NetworksSdkApi buildApi() {
|
||||
|
||||
// platform instance(method channel)
|
||||
final platform = NetworksSdkPlatform.instance;
|
||||
if (platform is MethodChannelNetworksSdk) {
|
||||
// platform.init(); // or defer to NotificationApiImpl.init
|
||||
}
|
||||
|
||||
// data layer
|
||||
final ds = NetworksSdkMethodChannelDataSource(platform);
|
||||
final repo = NetworksSdkRepositoryImpl(ds);
|
||||
final core = NetworksSdkCore(platform: platform, repo: repo,);
|
||||
|
||||
return NetworksSdkApiImpl(core: core);
|
||||
}
|
||||
|
||||
/// Builds the messaging API (WebSocket)
|
||||
static NetworksMessagingApi buildMessagingApi() {
|
||||
return NetworksMessagingApiImpl();
|
||||
}
|
||||
|
||||
/// Builds the default SDK instance (HTTP API)
|
||||
/// Use [buildMessagingApi()] separately for messaging features
|
||||
static NetworksSdkApi build() => buildApi();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// WebSocket 配置
|
||||
/// 非单例,由 App 层构造并注入到 SocketClient
|
||||
///
|
||||
/// 与 [ApiConfig] 设计一致:SDK 不依赖 Flutter,
|
||||
/// 网络检测、生命周期等业务逻辑通过回调注入。
|
||||
class SocketConfig {
|
||||
/// 应用层心跳间隔(定时发送 "ping" 字符串)
|
||||
final Duration heartbeatInterval;
|
||||
|
||||
/// 底层 WebSocket ping 间隔(Dart WebSocket 自动管理)
|
||||
final Duration pingInterval;
|
||||
|
||||
/// Pong 超时(超过此时间未收到 pong 则判定连接断开)
|
||||
final Duration pongTimeout;
|
||||
|
||||
/// 连接超时
|
||||
final Duration connectTimeout;
|
||||
|
||||
/// 最大重连次数(0 = 不重连)
|
||||
final int maxReconnectAttempts;
|
||||
|
||||
/// 最大重连延迟(指数退避上限)
|
||||
final Duration maxReconnectDelay;
|
||||
|
||||
/// 是否自动重连
|
||||
final bool autoReconnect;
|
||||
|
||||
/// 日志输出回调(与 ApiConfig.onLog 同签名)
|
||||
final void Function(String message, {String? tag})? onLog;
|
||||
|
||||
/// 网络可用性查询(App 层注入,SDK 在重连前调用)
|
||||
/// 返回 true 表示网络可用,可以尝试重连
|
||||
final Future<bool> Function()? onCheckNetworkAvailable;
|
||||
|
||||
SocketConfig({
|
||||
this.heartbeatInterval = const Duration(seconds: 10),
|
||||
this.pingInterval = const Duration(seconds: 5),
|
||||
this.pongTimeout = const Duration(seconds: 10),
|
||||
this.connectTimeout = const Duration(seconds: 15),
|
||||
this.maxReconnectAttempts = 5,
|
||||
this.maxReconnectDelay = const Duration(seconds: 30),
|
||||
this.autoReconnect = true,
|
||||
this.onLog,
|
||||
this.onCheckNetworkAvailable,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user