Merge pull request '网络请求打通,ws 打通' (#9) from cody/network_demo into dev

Reviewed-on: https://gitea.winwayinfo.com/CUS-IM/customer-im-client/pulls/9
This commit is contained in:
wangfeng
2026-03-09 19:32:11 +08:00
60 changed files with 1392 additions and 552 deletions

View File

@@ -2636,7 +2636,15 @@ class UploadFileRequest extends ApiRequestable<UploadResult>
final apiConfigProvider = Provider<ApiConfig>((ref) { final apiConfigProvider = Provider<ApiConfig>((ref) {
return ApiConfig( return ApiConfig(
baseURL: AppConfig.apiBaseUrl, baseURL: AppConfig.apiBaseUrl,
platformHeaders: {'Platform': 'Android', 'client-version': '1.0.0'}, platformHeaders: {
'platform': DeviceInfo.platform, // 运行时同步读取DeviceInfo.init() 预取)
'os-type': DeviceInfo.osType.toString(), // Android=1 iOS=2 Windows=3 macOS=4 Linux=5
'client-version': AppConfig.appVersion,
'channel': AppConfig.channel,
'lang': DeviceInfo.lang,
'device-id': DeviceInfo.deviceId,
'device-name': DeviceInfo.deviceName,
},
tokenExpiredCodes: {30002, 30003, 30124}, tokenExpiredCodes: {30002, 30003, 30124},
forceLogoutCodes: {30125}, forceLogoutCodes: {30125},
onForceLogout: () { /* 清除登录态,跳转登录页 */ }, onForceLogout: () { /* 清除登录态,跳转登录页 */ },
@@ -6558,8 +6566,13 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
return ApiConfig( return ApiConfig(
baseURL: AppConfig.apiBaseUrl, baseURL: AppConfig.apiBaseUrl,
platformHeaders: { platformHeaders: {
'Platform': 'Android', // TODO: 运行时从平台 API 获取 'platform': DeviceInfo.platform, // 运行时同步读取DeviceInfo.init() 预取)
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取 'os-type': DeviceInfo.osType.toString(), // Android=1 iOS=2 Windows=3 macOS=4 Linux=5
'client-version': AppConfig.appVersion,
'channel': AppConfig.channel,
'lang': DeviceInfo.lang,
'device-id': DeviceInfo.deviceId,
'device-name': DeviceInfo.deviceName,
}, },
tokenExpiredCodes: {30002, 30003, 30124}, // 后端约定的 Token 过期错误码 tokenExpiredCodes: {30002, 30003, 30124}, // 后端约定的 Token 过期错误码
forceLogoutCodes: {30125}, // 后端约定的强制登出错误码 forceLogoutCodes: {30125}, // 后端约定的强制登出错误码
@@ -7626,6 +7639,7 @@ linter:
prefer_const_declarations: true prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true prefer_const_literals_to_create_immutables: true
prefer_final_locals: true prefer_final_locals: true
always_use_package_imports: true # 禁止相对路径 import一律用 package: 全路径
# 命名规则 # 命名规则
camel_case_types: true camel_case_types: true
@@ -9794,6 +9808,7 @@ flowchart TD
<li><strong>使用 Melos 管理依赖</strong>Mono-Repo 保证版本一致性</li> <li><strong>使用 Melos 管理依赖</strong>Mono-Repo 保证版本一致性</li>
<li><strong>编写完整测试</strong>:单元测试、集成测试、端到端测试</li> <li><strong>编写完整测试</strong>:单元测试、集成测试、端到端测试</li>
<li><strong>UI 层使用 ConsumerWidget</strong>:通过 ref.watch 监听状态ref.read 读取 Provider</li> <li><strong>UI 层使用 ConsumerWidget</strong>:通过 ref.watch 监听状态ref.read 读取 Provider</li>
<li><strong>import 一律使用 package 全路径</strong>:禁止相对路径(<code>../base/colors.dart</code>),统一写 <code>package:im_app/core/ui/base/colors.dart</code>;相对路径文件移动后静默失效,全路径重构安全、跨包引用统一,已由 <code>always_use_package_imports: true</code> 强制执行</li>
</ol> </ol>

View File

@@ -1,4 +1,6 @@
{ {
"IS_DEV": true, "IS_DEV": true,
"API_BASE_URL": "https://dev-api.example.com" "API_BASE_URL": "http://gateway.winwayinfo.com",
"CHANNEL": "15",
"APP_VERSION": "1.0.0"
} }

View File

@@ -280,10 +280,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -474,6 +478,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
@@ -662,6 +667,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
@@ -690,6 +696,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;

View File

@@ -66,5 +66,18 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>gateway.winwayinfo.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/ui/base/app_theme.dart'; import 'package:im_app/core/ui/base/app_theme.dart';
import 'di/app_providers.dart'; import 'package:im_app/app/di/app_providers.dart';
import 'di/network_provider.dart'; import 'package:im_app/app/di/network_provider.dart';
import 'router/app_router.dart'; import 'package:im_app/app/router/app_router.dart';
/// 应用根组件 /// 应用根组件
/// ///

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart'; import 'package:im_app/app/app.dart';
void bootstrap() { void bootstrap() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/app_initializer.dart'; import 'package:im_app/core/foundation/device_info.dart';
import 'network_provider.dart'; import 'package:im_app/core/services/app_initializer.dart';
import 'package:im_app/app/di/network_provider.dart';
// ── 认证 ────────────────────────────────────────────────────────────────────── // ── 认证 ──────────────────────────────────────────────────────────────────────
@@ -122,6 +123,11 @@ final appInitializerProvider = Provider<AppInitializer>((ref) {
name: 'NetworkMonitor', name: 'NetworkMonitor',
task: () => ref.read(networkMonitorProvider).initialize(), task: () => ref.read(networkMonitorProvider).initialize(),
), ),
// 预取设备 ID / 设备名platformHeaders 同步读取
InitTask(
name: 'DeviceInfo',
task: DeviceInfo.init,
),
], ],
deferred: [ deferred: [
// TODO: 推送注册 // TODO: 推送注册

View File

@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:storage_sdk/storage_sdk.dart'; import 'package:storage_sdk/storage_sdk.dart';
import '../../data/local/drift/app_database.dart'; import 'package:im_app/data/local/drift/app_database.dart';
/// 全局单例 StorageSdkApi整个 App 生命周期内唯一实例。 /// 全局单例 StorageSdkApi整个 App 生命周期内唯一实例。
/// ///

View File

@@ -1,15 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import '../../core/foundation/api_paths.dart'; import 'package:im_app/core/foundation/api_paths.dart';
import '../../core/foundation/config.dart'; import 'package:im_app/core/foundation/config.dart';
import '../../core/foundation/constants.dart'; import 'package:im_app/core/foundation/constants.dart';
import '../../core/foundation/errors.dart'; import 'package:im_app/core/foundation/device_info.dart';
import '../../core/foundation/utils.dart'; import 'package:im_app/core/foundation/errors.dart';
import '../../core/services/network_monitor.dart'; import 'package:im_app/core/foundation/utils.dart';
import '../../core/services/socket_manager.dart'; import 'package:im_app/core/services/network_monitor.dart';
import 'package:im_app/core/services/socket_manager.dart';
// ── 网络状态监听 ────────────────────────────────────────────────────────────── // ── 网络状态监听 ──────────────────────────────────────────────────────────────
@@ -35,12 +37,7 @@ import '../../core/services/socket_manager.dart';
/// }); /// });
/// ``` /// ```
final networkMonitorProvider = Provider<NetworkMonitor>((ref) { final networkMonitorProvider = Provider<NetworkMonitor>((ref) {
final monitor = NetworkMonitor( final monitor = NetworkMonitor(onLog: _makeLogger('Network'));
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Network'}] $message');
},
);
ref.onDispose(() { ref.onDispose(() {
monitor.dispose(); monitor.dispose();
@@ -80,15 +77,13 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
return ApiConfig( return ApiConfig(
baseURL: AppConfig.apiBaseUrl, baseURL: AppConfig.apiBaseUrl,
platformHeaders: { platformHeaders: {
'Platform': 'Android', // TODO: 运行时从 platform API 获取 'platform': DeviceInfo.platform,
'client-version': '1.0.0', // TODO: 运行时从 package_info 获取 'os-type': DeviceInfo.osType.toString(),
'Channel': '', // TODO: 从 AppConfig 读取渠道标识 'client-version': AppConfig.appVersion,
'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取 'channel': AppConfig.channel,
}, 'lang': DeviceInfo.lang,
tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes, 'device-id': DeviceInfo.deviceId,
forceLogoutCodes: ApiErrorCodes.forceLogoutCodes, 'device-name': DeviceInfo.deviceName,
onForceLogout: () {
// TODO: 清除登录态,跳转登录页
}, },
onTokenRefresh: () async { onTokenRefresh: () async {
// TODO: App 层刷新 token 逻辑 // TODO: App 层刷新 token 逻辑
@@ -98,7 +93,7 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
// 通过事件流同步到 WebSocket避免直接引用 socketManagerProvider 造成循环依赖 // 通过事件流同步到 WebSocket避免直接引用 socketManagerProvider 造成循环依赖
tokenStream.add(newToken); tokenStream.add(newToken);
}, },
onCheckNetworkAvailable: () async => networkMonitor.isConnected, onCheckNetworkAvailable: _checkNetwork(networkMonitor),
// TODO: 接入 cipher_guard_sdk 后注入请求加密回调。 // TODO: 接入 cipher_guard_sdk 后注入请求加密回调。
// 前提AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。 // 前提AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。
// 示例: // 示例:
@@ -117,16 +112,43 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
// return jsonDecode(plaintext) as Map<String, dynamic>; // return jsonDecode(plaintext) as Map<String, dynamic>;
// }, // },
onDecryptResponse: null, onDecryptResponse: null,
onBusinessError: null, // TODO: 接入业务错误统一处理(弹窗 / Toast / 跳转等) onBusinessError: (code, message, path) {
switch (code) {
// Token 过期SDK 自动刷 token + 重试,业务层无感
case ApiErrorCodes.tokenInvalid:
case ApiErrorCodes.jwtInvalid:
case ApiErrorCodes.sessionInvalid:
return BusinessErrorAction.refreshToken;
// Token 刷新失败 / refresh token 失效:强制登出
case ApiErrorCodes.refreshTokenFailed:
// TODO: 清除登录态,跳转登录页
return BusinessErrorAction.forceLogout;
// 踢下线:账号在其他设备登录、签名/密钥异常
case ApiErrorCodes.loggedInAnotherDevice:
case ApiErrorCodes.signingMethodError:
case ApiErrorCodes.parsingKeyError:
// TODO: 接入全局 Toast/弹窗机制后展示踢下线提示,并跳转登录页
return BusinessErrorAction.handled;
// 触发图片验证:需展示 CAPTCHA 后重发 OTP
// data 中含 android / ios / web 平台 token见 SendOtpCaptchaData
case ApiErrorCodes.captchaRequired:
// TODO: 接入 CAPTCHA SDK验证通过后重发 OTP
return BusinessErrorAction.handled;
default:
// 单接口自行处理ViewModel 的 guard 会收到 ApiError
return BusinessErrorAction.unhandled;
}
},
onTransformResponse: onTransformResponse:
null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message } null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message }
onGetTokenExpiry: parseJwtExpiry, onGetTokenExpiry: parseJwtExpiry,
maxRetries: AppConstants.maxRetries, maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay, retryBaseDelay: AppConstants.retryBaseDelay,
onLog: (message, {tag}) { onLog: _makeLogger('Network'),
// ignore: avoid_print
print('[${tag ?? 'Network'}] $message');
},
); );
}); });
@@ -146,48 +168,30 @@ final networkSdkApiProvider = Provider<NetworksSdkApi>((ref) {
/// SDK 内部不调用其他 SDK。 /// SDK 内部不调用其他 SDK。
final _socketConfigProvider = Provider<SocketConfig>((ref) { final _socketConfigProvider = Provider<SocketConfig>((ref) {
final networkMonitor = ref.read(networkMonitorProvider); final networkMonitor = ref.read(networkMonitorProvider);
final apiConfig = ref.read(apiConfigProvider);
return SocketConfig( return SocketConfig(
maxReconnectAttempts: AppConstants.maxRetries, maxReconnectAttempts: AppConstants.maxRetries,
maxReconnectDelay: AppConstants.maxReconnectDelay, maxReconnectDelay: AppConstants.maxReconnectDelay,
unlimitedReconnect: true, // IM 场景始终保持连接 unlimitedReconnect: true, // IM 场景始终保持连接
onBuildConnectUrl: // 接入 cipher_guard_sdk 后改为 cipher=true&type=mode3
null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数) onBuildConnectUrl: (url, token) {
final uri = Uri.parse(url);
final params = <String, String>{
...uri.queryParameters,
if (token != null) 'token': token, // ignore: use_null_aware_elements
'cipher': 'true',
'type': 'mode2',
};
return uri.replace(queryParameters: params).toString();
},
onEncryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息加密回调 onEncryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息加密回调
onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调 onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调
onBeforeReconnect: () async { // SocketClient 内部重连(心跳超时 / stream onDone前调用
// SocketClient 内部重连心跳超时、stream onDone前调用。 onBeforeReconnect: () =>
// 与 SocketManager.onBeforeReconnect 职责相同:检查 token 并按需刷新。 _proactiveTokenRefresh(apiConfig, logTag: 'Socket'),
// 刷新后通过 sync stream 同步传播到 SocketClient._currentToken onLog: _makeLogger('Socket'),
// 确保随后的 _doConnect() 使用新 token。 onCheckNetworkAvailable: _checkNetwork(networkMonitor),
final apiConfig = ref.read(apiConfigProvider);
final currentToken = apiConfig.token;
if (currentToken == null || apiConfig.onGetTokenExpiry == null) return;
final expiry = apiConfig.onGetTokenExpiry!(currentToken);
if (expiry == null) return;
final remaining = expiry.difference(DateTime.now());
if (remaining > apiConfig.proactiveRefreshThreshold) return;
// ignore: avoid_print
print(
'[Socket] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
);
final newToken = await apiConfig.onTokenRefresh?.call();
if (newToken != null && newToken.isNotEmpty) {
// updateToken → onTokenUpdated → sync stream → manager.updateToken
// → _client.updateToken → socketClient._currentToken 同步更新
apiConfig.updateToken(newToken);
}
},
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'Socket'}] $message');
},
onCheckNetworkAvailable: () async {
return networkMonitor.isConnected;
},
); );
}); });
@@ -232,32 +236,11 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl), wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断 disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调 onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
onBeforeReconnect: () async { // SocketManager 层重连(前台恢复 / 网络恢复)前调用
// 重连前检查 token 是否即将过期,是则主动刷新 onBeforeReconnect: () =>
final currentToken = apiConfig.token; _proactiveTokenRefresh(apiConfig, logTag: 'SocketManager'),
if (currentToken == null || apiConfig.onGetTokenExpiry == null) return; onCheckNetworkAvailable: _checkNetwork(networkMonitor),
onLog: _makeLogger('SocketManager'),
final expiry = apiConfig.onGetTokenExpiry!(currentToken);
if (expiry == null) return;
final remaining = expiry.difference(DateTime.now());
if (remaining > apiConfig.proactiveRefreshThreshold) return;
// ignore: avoid_print
print(
'[SocketManager] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
);
final newToken = await apiConfig.onTokenRefresh?.call();
if (newToken != null && newToken.isNotEmpty) {
// updateToken 触发 onTokenUpdated → tokenStream → socketManager.updateToken
apiConfig.updateToken(newToken);
}
},
onCheckNetworkAvailable: () async => networkMonitor.isConnected,
onLog: (message, {tag}) {
// ignore: avoid_print
print('[${tag ?? 'SocketManager'}] $message');
},
); );
// 监听 token 更新事件 → 同步到 WebSocket // 监听 token 更新事件 → 同步到 WebSocket
@@ -281,6 +264,47 @@ final socketManagerProvider = Provider<SocketManager>((ref) {
// ── 辅助 ────────────────────────────────────────────────────────────────────── // ── 辅助 ──────────────────────────────────────────────────────────────────────
/// 日志回调工厂,各模块传自己的默认 tag
///
/// SDK 内部调用 onLog 时通常已传 tagdefaultTag 仅作兜底。
OnLog _makeLogger(String defaultTag) => (message, {tag}) {
debugPrint('[${tag ?? defaultTag}] $message');
};
/// 网络可用性检查回调HTTP 和 WebSocket 共用
OnCheckNetworkAvailable _checkNetwork(NetworkMonitor monitor) =>
() async => monitor.isConnected;
/// 重连前主动刷新 token距过期不足阈值时提前刷新
///
/// 两处调用:
/// - SocketClient 内部重连(心跳超时 / stream onDone
/// - SocketManager 重连(前台恢复 / 网络恢复)前
///
/// 刷新后通过 onTokenUpdated → sync stream → socketClient._currentToken 同步更新,
/// 确保随后的 _doConnect() 使用新 token。
Future<void> _proactiveTokenRefresh(
ApiConfig config, {
required String logTag,
}) async {
final currentToken = config.token;
if (currentToken == null || config.onGetTokenExpiry == null) return;
final expiry = config.onGetTokenExpiry!(currentToken);
if (expiry == null) return;
final remaining = expiry.difference(DateTime.now());
if (remaining > config.proactiveRefreshThreshold) return;
debugPrint(
'[$logTag] Token expiring in ${remaining.inMinutes}min, refreshing before reconnect',
);
final newToken = await config.onTokenRefresh?.call();
if (newToken != null && newToken.isNotEmpty) {
config.updateToken(newToken);
}
}
/// HTTP baseURL → WebSocket URL 转换 /// HTTP baseURL → WebSocket URL 转换
/// ///
/// https://api.example.com → wss://api.example.com/ws /// https://api.example.com → wss://api.example.com/ws

View File

@@ -3,16 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:im_app/features/chat/view/chat_db_test_page.dart'; import 'package:im_app/features/chat/view/chat_db_test_page.dart';
import '../../features/app_tab/view/app_tab.dart'; import 'package:im_app/features/app_tab/view/app_tab.dart';
import '../../features/chat/view/chat_detail_page.dart'; import 'package:im_app/features/chat/view/chat_detail_page.dart';
import '../../features/chat/view/chat_page.dart'; import 'package:im_app/features/chat/view/chat_page.dart';
import '../../features/contact/view/contact_page.dart'; import 'package:im_app/features/contact/view/contact_page.dart';
import '../../features/login/view/login_page.dart'; import 'package:im_app/features/login/view/login_page.dart';
import '../../features/settings/view/settings_page.dart'; import 'package:im_app/features/settings/view/settings_page.dart';
import '../../features/settings/view/theme_view.dart'; import 'package:im_app/features/settings/view/theme_view.dart';
import '../di/app_providers.dart'; import 'package:im_app/app/di/app_providers.dart';
import 'app_route_name.dart'; import 'package:im_app/app/router/app_route_name.dart';
import 'guards/auth_guard.dart'; import 'package:im_app/app/router/guards/auth_guard.dart';
/// 应用路由 Provider /// 应用路由 Provider
/// ///

View File

@@ -1,7 +1,7 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../di/app_providers.dart'; import 'package:im_app/app/di/app_providers.dart';
import '../app_route_name.dart'; import 'package:im_app/app/router/app_route_name.dart';
/// 登录守卫 /// 登录守卫
/// ///

View File

@@ -11,21 +11,23 @@ class ApiPaths {
ApiPaths._(); ApiPaths._();
// ── Auth ── // ── Auth ──
static const authLogin = '/auth/login'; static const authSendOtp = '/app/api/auth/vcode/get';
static const authRefreshToken = '/auth/refresh-token'; static const authVerifyOtp = '/app/api/auth/vcode/check';
static const authLogout = '/auth/logout'; static const authLogin = '/app/api/auth/login-user';
static const authRefreshToken = '/app/api/auth/refresh-token';
static const authLogout = '/app/api/auth/logout';
// ── User ── // ── User ──
static const userProfile = '/user/profile'; static const userProfile = '/app/api/user/profile';
static const userUpdateProfile = '/user/update-profile'; static const userUpdateProfile = '/app/api/user/update-profile';
// ── Chat ── // ── Chat ──
static const chatSendMessage = '/chat/send-message'; static const chatSendMessage = '/app/api/chat/send-message';
static const chatHistory = '/chat/history'; static const chatHistory = '/app/api/chat/history';
// ── Upload ── // ── Upload ──
static const uploadFile = '/upload/file'; static const uploadFile = '/app/api/upload/file';
// ── WebSocket ── // ── WebSocket ──
static const wsConnect = '/ws'; static const wsConnect = '/websock/open';
} }

View File

@@ -7,7 +7,14 @@ class AppConfig {
static const isDev = bool.fromEnvironment('IS_DEV', defaultValue: true); static const isDev = bool.fromEnvironment('IS_DEV', defaultValue: true);
static const apiBaseUrl = String.fromEnvironment( static const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'https://dev-api.example.com', defaultValue: 'http://gateway.winwayinfo.com',
);
static const channel = String.fromEnvironment('CHANNEL', defaultValue: '10');
static const appVersion = String.fromEnvironment(
'APP_VERSION',
defaultValue: '1.0.0',
); );
static bool get isProd => !isDev; static bool get isProd => !isDev;

View File

@@ -0,0 +1,108 @@
import 'dart:io';
import 'dart:ui';
import 'package:device_info_plus/device_info_plus.dart';
/// 设备 / 运行时信息 — 用于构建 HTTP 请求头中的平台字段
///
/// 同步 getterplatform / lang / osType可直接使用。
/// deviceId / deviceName 需调用 [init()] 预取后才能通过同步 getter 访问。
///
/// 在 `AppInitializer.critical` 中调用 [init()],之后 `platformHeaders` 可同步读取所有字段:
/// ```dart
/// platformHeaders: {
/// 'platform': DeviceInfo.platform,
/// 'lang': DeviceInfo.lang,
/// 'client-version': AppConfig.appVersion,
/// 'channel': AppConfig.channel,
/// 'device-id': DeviceInfo.deviceId,
/// 'device-name': DeviceInfo.deviceName,
/// }
/// ```
// ignore: avoid_classes_with_only_static_members
class DeviceInfo {
DeviceInfo._();
static String _deviceId = '';
static String _deviceName = '';
/// 预取设备 ID / 设备名,缓存后可通过同步 getter 访问。
/// 在 AppInitializer.critical 中调用一次即可。
static Future<void> init() async {
_deviceId = await fetchDeviceId();
_deviceName = await fetchDeviceName();
}
/// 缓存的设备唯一标识(需先调用 [init()]
static String get deviceId => _deviceId;
/// 缓存的设备名称(需先调用 [init()]
static String get deviceName => _deviceName;
/// HTTP `Platform` 请求头(服务端用于区分来源平台)
///
/// 返回值iOS / Android / macOS / Windows / Linux
static String get platform {
if (Platform.isIOS) return 'iOS';
if (Platform.isAndroid) return 'Android';
if (Platform.isMacOS) return 'macOS';
if (Platform.isWindows) return 'Windows';
if (Platform.isLinux) return 'Linux';
return 'Unknown';
}
/// HTTP `lang` 请求头(取系统首选语言,如 "zh-CN"、"en-US"
///
/// 使用 `dart:ui` 的 `PlatformDispatcher`,不依赖 Flutter widget tree。
static String get lang => PlatformDispatcher.instance.locale.toLanguageTag();
/// 操作系统类型编号(与服务端约定一致):
/// 1=Android 2=iOS 3=Windows 4=macOS 5=Linux 6=其他
static int get osType {
if (Platform.isAndroid) return 1;
if (Platform.isIOS) return 2;
if (Platform.isWindows) return 3;
if (Platform.isMacOS) return 4;
if (Platform.isLinux) return 5;
return 6;
}
/// 设备唯一标识
///
/// AndroidBuild.ID非持久化硬件 ID可作为临时标识如需稳定 ID 后续接入 android_id
/// iOSidentifierForVendor
/// macOSsystemGUID
/// WindowsdeviceId
static Future<String> fetchDeviceId() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
return (await plugin.androidInfo).id;
} else if (Platform.isIOS) {
return (await plugin.iosInfo).identifierForVendor ?? '';
} else if (Platform.isMacOS) {
return (await plugin.macOsInfo).systemGUID ?? '';
} else if (Platform.isWindows) {
return (await plugin.windowsInfo).deviceId;
}
return '';
}
/// 设备名称(品牌 + 型号)
static Future<String> fetchDeviceName() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
final info = await plugin.androidInfo;
return '${info.brand} ${info.model}';
} else if (Platform.isIOS) {
final info = await plugin.iosInfo;
return info.utsname.machine;
} else if (Platform.isMacOS) {
return (await plugin.macOsInfo).model;
} else if (Platform.isWindows) {
return (await plugin.windowsInfo).computerName;
} else if (Platform.isLinux) {
return (await plugin.linuxInfo).name;
}
return '';
}
}

View File

@@ -36,22 +36,9 @@ class ApiErrorCodes {
/// 账号在其他设备登录 /// 账号在其他设备登录
static const int loggedInAnotherDevice = 30006; static const int loggedInAnotherDevice = 30006;
// ── 错误码集合 ── // ── 验证码30170-30179──
/// Token 过期错误码集合 — 触发自动刷新 Token /// 触发图片验证data 含各平台 CAPTCHA tokenandroid / ios / web
static const Set<int> tokenExpiredCodes = { static const int captchaRequired = 30174;
tokenInvalid,
jwtInvalid,
sessionInvalid,
};
/// 强制登出错误码集合 — 触发退出登录流程
static const Set<int> forceLogoutCodes = {refreshTokenFailed};
/// 踢下线错误码集合 — 触发踢下线 UI 提示
static const Set<int> kickOffCodes = {
loggedInAnotherDevice,
signingMethodError,
parsingKeyError,
};
} }

View File

@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
/// ViewModel 请求守卫 mixin
///
/// 统一拦截 API 调用的 [ApiError],无需在每个 ViewModel 方法内写 try-catch。
/// [ApiError.displayMessage] 自动映射为可读文案,直接写入 state。
///
/// ## 使用
///
/// ```dart
/// @riverpod
/// class LoginViewModel extends _$LoginViewModel with RequestGuard<LoginState> {
///
/// Future<void> sendOtp(String phone) async {
/// state = state.copyWith(isLoading: true, error: null);
///
/// final ok = await guard(
/// () => ref.read(loginUseCaseProvider).sendOtp(phone),
/// onError: (msg) => state = state.copyWith(isLoading: false, error: msg),
/// );
///
/// if (ok != null) {
/// state = state.copyWith(isLoading: false, step: LoginStep.otpSent);
/// }
/// }
/// }
/// ```
///
/// ## 机制
///
/// ```
/// guard()
/// └─ try: call() → 成功,返回 T
/// └─ on ApiError: onError(e.displayMessage) → 返回 null
///
/// ViewModel: ok != null → 成功路径
/// ok == null → 已由 onError 更新 state.error无需额外处理
/// ```
mixin RequestGuard<S> on Notifier<S> {
/// 执行 [call],捕获 [ApiError] 后调用 [onError] 写入错误文案,返回 null。
/// 成功时返回原始结果ViewModel 用返回值是否为 null 判断走哪条路径。
Future<T?> guard<T>(
Future<T> Function() call, {
required void Function(String message) onError,
}) async {
try {
return await call();
} on ApiError catch (e) {
onError(e.displayMessage);
return null;
}
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import 'network_backoff_debouncer.dart'; import 'package:im_app/core/services/network_backoff_debouncer.dart';
/// 消息预处理回调 /// 消息预处理回调
/// ///

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'colors.dart'; import 'package:im_app/core/ui/base/colors.dart';
import 'font.dart'; import 'package:im_app/core/ui/base/font.dart';
/// 主题组装 -- 将 AppColors / AppFont 组装为 ThemeData /// 主题组装 -- 将 AppColors / AppFont 组装为 ThemeData
/// ///

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'font.dart'; import 'package:im_app/core/ui/base/font.dart';
/// 主题样式快捷封装 /// 主题样式快捷封装
/// ///

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../base/context_theme_ext.dart'; import 'package:im_app/core/ui/base/context_theme_ext.dart';
/// # AppButton — 按钮原子组件L2 Component /// # AppButton — 按钮原子组件L2 Component
/// ///
@@ -117,12 +117,9 @@ class AppButton extends StatelessWidget {
Widget _buildInverse(BuildContext context, Widget label) { Widget _buildInverse(BuildContext context, Widget label) {
final s = context.styles; final s = context.styles;
final isDark = s.isDark;
final bg = isDark ? Colors.white : Colors.black;
final fg = isDark ? Colors.black : Colors.white;
final style = FilledButton.styleFrom( final style = FilledButton.styleFrom(
backgroundColor: bg, backgroundColor: s.onSurface,
foregroundColor: fg, foregroundColor: s.surface,
); );
if (icon != null) { if (icon != null) {
return FilledButton.icon( return FilledButton.icon(

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../components/app_button.dart'; import 'package:im_app/core/ui/components/app_button.dart';
/// # AppDialog — 业务确认弹窗L3 Composite /// # AppDialog — 业务确认弹窗L3 Composite
/// ///

View File

@@ -1,8 +1,8 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart'; import 'package:im_app/core/foundation/api_paths.dart';
import '../../../domain/entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
part 'get_profile_request.g.dart'; part 'get_profile_request.g.dart';

View File

@@ -1,21 +1,24 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart'; import 'package:im_app/core/foundation/api_paths.dart';
import '../../../domain/entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
part 'login_request.g.dart'; part 'login_request.g.dart';
/// # /auth/login — 登录接口 /// # /app/api/auth/login-user — 使用 vcode_token 完成登录
///
/// 流程:发送验证码([SendOtpRequest])→ 校验验证码([VerifyOtpRequest]
/// → ★ 用 vcode_token 登录(本请求)★ → 获得 access_token
/// ///
/// ## 数据流位置 /// ## 数据流位置
/// ///
/// ``` /// ```
/// AuthRepositoryImpl.login(email, password) /// AuthRepositoryImpl.login(countryCode, contact, vcodeToken)
/// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里 /// → _client.executeRequest( ★ LoginRequest ★ ) ← 你在这里
/// → 服务端 POST /auth/login /// → 服务端 POST /app/api/auth/login-user
/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data } /// → SDK 拆包 {code, message, data} envelope
/// → ★ LoginResponse ★ = data 字段T in APIResponseWrapper<T> ← 也在这里 /// → ★ LoginResponse ★ ← 也在这里
/// → LoginResponse.toEntity() → User /// → LoginResponse.toEntity() → User
/// ``` /// ```
@@ -100,24 +103,27 @@ class LoginResponse {
@JsonKey(name: 'account_id') @JsonKey(name: 'account_id')
final String accountId; final String accountId;
final LoginProfile profile; final LoginProfile profile;
final String nonce;
@JsonKey(name: 'access_token') @JsonKey(name: 'access_token')
final String accessToken; final String accessToken;
@JsonKey(name: 'refresh_token') @JsonKey(name: 'refresh_token')
final String refreshToken; final String refreshToken;
@JsonKey(name: 'device_id') @JsonKey(name: 'device_id')
final String deviceId; final String deviceId;
final String nonce;
@JsonKey(name: 'login_data') @JsonKey(name: 'login_data')
final String loginData; final String loginData;
@JsonKey(name: 'is_verified')
final bool? isVerified;
const LoginResponse({ const LoginResponse({
required this.accountId, required this.accountId,
required this.profile, required this.profile,
required this.nonce,
required this.accessToken, required this.accessToken,
required this.refreshToken, required this.refreshToken,
required this.deviceId, required this.deviceId,
required this.loginData, this.nonce = '',
this.loginData = '',
this.isVerified,
}); });
User toEntity() => profile.toEntity(); User toEntity() => profile.toEntity();
@@ -127,11 +133,10 @@ class LoginResponse {
// Request // Request
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
/// 登录请求 /// 使用 vcode_token 完成登录请求
/// ///
/// `@ApiRequest` 一个注解搞定一切: /// 上游:[VerifyOtpRequest] 返回的 `token` 即 vcodeToken。
/// - mixin 自动生成 path / method / requestType / includeToken / toJson /// 成功后 [LoginResponse.accessToken] 写入 ApiConfig后续请求自动携带。
/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表
@ApiRequest( @ApiRequest(
path: ApiPaths.authLogin, path: ApiPaths.authLogin,
method: HttpMethod.post, method: HttpMethod.post,
@@ -140,8 +145,15 @@ class LoginResponse {
) )
class LoginRequest extends ApiRequestable<LoginResponse> class LoginRequest extends ApiRequestable<LoginResponse>
with _$LoginRequestApi { with _$LoginRequestApi {
final String email; @JsonKey(name: 'country_code')
final String password; final String countryCode;
final String contact;
@JsonKey(name: 'vcode_token')
final String vcodeToken;
LoginRequest({required this.email, required this.password}); LoginRequest({
required this.countryCode,
required this.contact,
required this.vcodeToken,
});
} }

View File

@@ -1,6 +1,6 @@
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart'; import 'package:im_app/core/foundation/api_paths.dart';
part 'logout_request.g.dart'; part 'logout_request.g.dart';

View File

@@ -0,0 +1,70 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/foundation/api_paths.dart';
part 'send_otp_request.g.dart';
/// 发送验证码响应
///
/// code: 0 时 [expiryTime] 有值;
/// code: 30174触发图片验证时 [android] / [ios] / [web] / [extras] 有值。
class SendOtpResponse {
/// 验证码有效期(秒)
@JsonKey(name: 'expiry_time')
final int? expiryTime;
/// Android CAPTCHA SDK token
final String? android;
/// iOS CAPTCHA SDK token
final String? ios;
/// Web CAPTCHA SDK token
final String? web;
/// CAPTCHA 平台扩展参数(预留字段)
final Map<String, dynamic>? extras;
const SendOtpResponse({
this.expiryTime,
this.android,
this.ios,
this.web,
this.extras,
});
}
/// # /app/api/auth/vcode/get — 发送手机验证码
///
/// 响应 `data: { "expiry_time": 180 }`,返回验证码有效期(秒)。
///
/// ## 数据流位置
///
/// ```
/// AuthRepositoryImpl.sendOtp(countryCode, contact)
/// → _client.executeRequest( ★ SendOtpRequest ★ ) ← 你在这里
/// → 服务端 POST /app/api/auth/vcode/get
/// → 响应 { expiry_time: 180 } → SendOtpResponse
/// ```
@ApiRequest(
path: ApiPaths.authSendOtp,
method: HttpMethod.post,
responseType: SendOtpResponse,
requestType: ApiRequestType.login,
)
class SendOtpRequest extends ApiRequestable<SendOtpResponse>
with _$SendOtpRequestApi {
@JsonKey(name: 'country_code')
final String countryCode;
final String contact;
/// type=1 表示手机号验证
final int type;
SendOtpRequest({
required this.countryCode,
required this.contact,
this.type = 1,
});
}

View File

@@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import '../../../core/foundation/api_paths.dart'; import 'package:im_app/core/foundation/api_paths.dart';
part 'upload_file_request.g.dart'; part 'upload_file_request.g.dart';

View File

@@ -0,0 +1,61 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/core/foundation/api_paths.dart';
part 'verify_otp_request.g.dart';
/// 校验验证码接口的响应(服务端 data 字段)。
///
/// `token` 是 vcode_token用于后续 login-user 请求换取 access_token。
/// 纯 Dart 类,无需任何注解。`_$VerifyOtpResponseFromJson` 由生成器自动推导生成。
class VerifyOtpResponse {
/// 验证令牌,传给登录接口换取 access_token
final String token;
const VerifyOtpResponse({required this.token});
}
/// # /app/api/auth/vcode/check — 校验手机验证码
///
/// 校验成功后返回 [VerifyOtpResponse.token](即 vcode_token
/// 用于 [LoginRequest] 的 `vcode_token` 字段。
///
/// ## 数据流位置
///
/// ```
/// AuthRepositoryImpl.verifyOtp(countryCode, contact, code)
/// → _client.executeRequest( ★ VerifyOtpRequest ★ ) ← 你在这里
/// → 服务端 POST /app/api/auth/vcode/check
/// → SDK 拆包 {code, message, data} envelope
/// ← ★ VerifyOtpResponse ★ — token 即 vcode_token
/// ```
@ApiRequest(
path: ApiPaths.authVerifyOtp,
method: HttpMethod.post,
responseType: VerifyOtpResponse,
requestType: ApiRequestType.login,
)
class VerifyOtpRequest extends ApiRequestable<VerifyOtpResponse>
with _$VerifyOtpRequestApi {
@JsonKey(name: 'country_code')
final String countryCode;
final String contact;
/// 邮箱(手机号登录传空字符串)
final String email;
/// 用户输入的验证码
final String code;
/// type=1 表示手机号验证
final int type;
VerifyOtpRequest({
required this.countryCode,
required this.contact,
required this.code,
this.email = '',
this.type = 1,
});
}

View File

@@ -1,27 +1,34 @@
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import '../../domain/entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
import '../../domain/repositories/auth_repository.dart'; import 'package:im_app/domain/repositories/auth_repository.dart';
import '../remote/login_request.dart'; import 'package:im_app/data/remote/login_request.dart';
import '../remote/logout_request.dart'; import 'package:im_app/data/remote/logout_request.dart';
import 'package:im_app/data/remote/send_otp_request.dart';
import 'package:im_app/data/remote/verify_otp_request.dart';
/// 认证 Repository 实现 /// 认证 Repository 实现
/// ///
/// implements [AuthRepository] 接口domain/repositories/ 中定义)。 /// implements [AuthRepository] 接口domain/repositories/ 中定义)。
/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。 /// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。
/// 后续可加 Local DataSource 实现离线缓存。
/// ///
/// ## 数据流位置 /// ## 登录流程
/// ///
/// ``` /// ```
/// LoginUseCase.execute(email, password) /// LoginUseCase.sendOtp(countryCode, contact)
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里 /// → ★ AuthRepositoryImpl.sendOtp() ★ ← 你在这里(步骤 1
/// → NetworksSdkApi.executeRequest(SendOtpRequest)
/// → 服务端 POST /app/api/auth/otp/send
///
/// LoginUseCase.verifyAndLogin(countryCode, contact, code)
/// → ★ AuthRepositoryImpl.verifyOtp() ★ ← 你在这里(步骤 2
/// → NetworksSdkApi.executeRequest(VerifyOtpRequest)
/// → 服务端 POST /app/api/auth/vcode/check
/// ← VerifyOtpResponse.token = vcode_token
/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里(步骤 3
/// → NetworksSdkApi.executeRequest(LoginRequest) /// → NetworksSdkApi.executeRequest(LoginRequest)
/// → 服务端 POST /auth/login /// → 服务端 POST /app/api/auth/login-user
/// ← LoginResponseSDK 已拆包 { code, message, data } envelope /// ← LoginResponse → _onTokenUpdate(accessToken) → User
/// → _onTokenUpdate(accessToken) ← 回调写入 Token
/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里
/// ← UserDomain Entity
/// ``` /// ```
class AuthRepositoryImpl implements AuthRepository { class AuthRepositoryImpl implements AuthRepository {
final NetworksSdkApi _client; final NetworksSdkApi _client;
@@ -34,29 +41,62 @@ class AuthRepositoryImpl implements AuthRepository {
_onTokenUpdate = onTokenUpdate; _onTokenUpdate = onTokenUpdate;
@override @override
Future<User> login({required String email, required String password}) async { Future<void> sendOtp({
final LoginResponse? loginResponse = await _client.executeRequest( required String countryCode,
LoginRequest(email: email, password: password), required String contact,
}) async {
await _client.executeRequest(
SendOtpRequest(countryCode: countryCode, contact: contact),
); );
if (loginResponse == null) {
throw Exception('Login failed: empty response');
}
_onTokenUpdate(loginResponse.accessToken);
return loginResponse.toEntity();
} }
@override @override
Future<User?> getCurrentUser() async { Future<String> verifyOtp({
// TODO: 从本地存储获取用户信息 required String countryCode,
return null; required String contact,
required String code,
}) async {
final response = await _client.executeRequest(
VerifyOtpRequest(
countryCode: countryCode,
contact: contact,
code: code,
),
);
if (response == null) {
throw Exception('Verify OTP failed: empty response');
}
return response.token;
}
@override
Future<User> login({
required String countryCode,
required String contact,
required String vcodeToken,
}) async {
final response = await _client.executeRequest(
LoginRequest(
countryCode: countryCode,
contact: contact,
vcodeToken: vcodeToken,
),
);
if (response == null) {
throw Exception('Login failed: empty response');
}
_onTokenUpdate(response.accessToken);
return response.toEntity();
} }
@override @override
Future<void> logout() async { Future<void> logout() async {
await _client.executeRequest(LogoutRequest()); await _client.executeRequest(LogoutRequest());
_onTokenUpdate(null); // 回调清除 Token内存 + 持久化由 Provider 层组合) _onTokenUpdate(null); // 清除 Token内存 + 持久化由 Provider 层组合)
} }
} }

View File

@@ -1,25 +1,43 @@
import '../entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
/// 认证 Repository 接口(依赖倒置) /// 认证 Repository 接口(依赖倒置)
/// ///
/// Domain 层定义 WhatData 层实现 How。 /// Domain 层定义 WhatData 层实现 How。
/// ViewModel 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。 /// UseCase 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。
/// ///
/// ## 数据流位置 /// ## 登录三步流程
/// ///
/// ``` /// ```
/// ViewModel /// 1. sendOtp(countryCode, contact) → 发送验证码短信
/// → ★ AuthRepository.login() ★ ← 你在这里(接口) /// 2. verifyOtp(countryCode, contact, code) → 校验验证码,返回 vcode_token
/// → AuthRepositoryImpl.login() ← data/repositories/(实现) /// 3. login(countryCode, contact, vcodeToken) → 用 vcode_token 换 access_token返回 User
/// → _client.executeRequest(LoginRequest)
/// → 服务端
/// ``` /// ```
abstract interface class AuthRepository { abstract interface class AuthRepository {
/// 登录,返回 Domain Entity [User] /// 发送手机验证码短信
Future<User> login({required String email, required String password}); ///
/// 抛 [ApiError] 表示发送失败(手机号格式错误、频率限制等)。
Future<void> sendOtp({
required String countryCode,
required String contact,
});
/// 获取当前登录用户信息 /// 校验验证码,成功返回 vcode_token
Future<User?> getCurrentUser(); ///
/// vcode_token 用于 [login] 的 vcodeToken 参数。
Future<String> verifyOtp({
required String countryCode,
required String contact,
required String code,
});
/// 用 vcode_token 完成登录,返回 Domain Entity [User]
///
/// 成功后内部回调写入 access_token后续请求自动携带。
Future<User> login({
required String countryCode,
required String contact,
required String vcodeToken,
});
/// 退出登录 /// 退出登录
Future<void> logout(); Future<void> logout();

View File

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/di/app_providers.dart'; import 'package:im_app/app/di/app_providers.dart';
import '../../../app/router/app_route_name.dart'; import 'package:im_app/app/router/app_route_name.dart';
part 'chat_view_model.g.dart'; part 'chat_view_model.g.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart'; import 'package:im_app/features/chat/presentation/chat_db_test_view_model.dart';
import '../../../core/ui/components/app_button.dart'; import 'package:im_app/core/ui/components/app_button.dart';
class ChatDbTestPage extends ConsumerStatefulWidget { class ChatDbTestPage extends ConsumerStatefulWidget {
const ChatDbTestPage({super.key}); const ChatDbTestPage({super.key});

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/ui/base/context_theme_ext.dart'; import 'package:im_app/core/ui/base/context_theme_ext.dart';
/// 会话详情页(路由传参 Demo /// 会话详情页(路由传参 Demo
/// ///

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/ui/components/app_button.dart'; import 'package:im_app/core/ui/components/app_button.dart';
import '../presentation/chat_view_model.dart'; import 'package:im_app/features/chat/presentation/chat_view_model.dart';
/// 聊天页Demo 按钮) /// 聊天页Demo 按钮)
/// ///

View File

@@ -1,10 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/di/network_provider.dart'; import 'package:im_app/app/di/network_provider.dart';
import '../../../app/di/db_provider.dart'; import 'package:im_app/app/di/db_provider.dart';
import '../../../data/repositories/auth_repository_impl.dart'; import 'package:im_app/data/repositories/auth_repository_impl.dart';
import '../../../domain/repositories/auth_repository.dart'; import 'package:im_app/domain/repositories/auth_repository.dart';
import '../usecases/login_usecase.dart'; import 'package:im_app/features/login/usecases/login_usecase.dart';
/// ## DI 装配Auth Feature 层 /// ## DI 装配Auth Feature 层
/// ///

View File

@@ -1,9 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../domain/entities/user.dart';
part 'login_state.freezed.dart'; part 'login_state.freezed.dart';
/// 登录流程的当前步骤
enum LoginStep {
/// 步骤 1输入手机号
phone,
/// 步骤 2输入验证码
otp,
}
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString /// 登录页面状态(@freezed 自动生成 copyWith / == / toString
/// ///
/// ViewModel 通过 `state = state.copyWith(...)` 更新状态, /// ViewModel 通过 `state = state.copyWith(...)` 更新状态,
@@ -12,22 +19,43 @@ part 'login_state.freezed.dart';
/// ## 状态流转 /// ## 状态流转
/// ///
/// ``` /// ```
/// 初始 → LoginState() isLoading: false, user: null, error: null /// 初始
/// 点击登录 → state.copyWith(isLoading: true) isLoading: true /// → LoginState() step: phone, isLoading: false
/// 登录成功 → state.copyWith(user: user) isLoading: false, user: User /// 点击"获取验证码"
/// 格式错误 → state.copyWith(error: '邮箱格式不正确') isLoading: false, error: String /// → state.copyWith(isLoading: true)
/// 网络错误 → state.copyWith(error: '网络错误') isLoading: false, error: String /// → 成功: state.copyWith(step: otp, contact: phone, isLoading: false)
/// → 失败: state.copyWith(error: '...', isLoading: false)
/// 点击"登录"
/// → state.copyWith(isLoading: true)
/// → 成功: authNotifierProvider.login() → 路由守卫重定向
/// → 失败: state.copyWith(error: '...', isLoading: false)
/// ``` /// ```
@freezed @freezed
sealed class LoginState with _$LoginState { sealed class LoginState with _$LoginState {
const factory LoginState({ const LoginState._();
/// 登录成功后的用户信息null = 未登录)
User? user,
/// 是否正在请求中(控制 loading 状态 / 按钮禁用) const factory LoginState({
/// 当前步骤(手机号输入 or 验证码输入)
@Default(LoginStep.phone) LoginStep step,
/// 国家代码(默认 +65暂不支持切换
@Default('+65') String countryCode,
/// 已提交的手机号(步骤 2 用于显示和构建请求)
@Default('') String contact,
/// 是否正在请求中
@Default(false) bool isLoading, @Default(false) bool isLoading,
/// 错误信息null = 无错误) /// 错误信息null = 无错误)
String? error, String? error,
}) = _LoginState; }) = _LoginState;
/// 步骤 2 显示的脱敏手机号,如 "138****0000"
String get maskedContact {
if (contact.length <= 4) return contact;
final tail = contact.substring(contact.length - 4);
final stars = '*' * (contact.length - 4);
return '$stars$tail';
}
} }

View File

@@ -1,133 +1,101 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:im_app/app/di/db_provider.dart';
import 'package:im_app/app/di/user_provider.dart';
import 'package:im_app/domain/entities/user.dart';
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:storage_sdk/storage_sdk.dart';
import '../../../app/di/app_providers.dart'; import 'package:im_app/app/di/app_providers.dart';
import '../di/auth_providers.dart'; import 'package:im_app/features/login/di/auth_providers.dart';
import 'login_state.dart'; import 'package:im_app/features/login/presentation/login_state.dart';
part 'login_view_model.g.dart'; part 'login_view_model.g.dart';
/// 登录 ViewModel@riverpod 自动生成 `loginViewModelProvider` /// 登录 ViewModel@riverpod 自动生成 `loginViewModelProvider`
/// ///
/// `@riverpod` 注解 → build_runner 自动生成 `login_view_model.g.dart` /// 管理两步登录流程:手机号 → 验证码 → 完成登录。
/// 其中包含 `loginViewModelProvider`。View 层直接使用:
/// ///
/// ```dart /// ```dart
/// // View 层读取状态 /// // View 层读取状态
/// final state = ref.watch(loginViewModelProvider); /// final state = ref.watch(loginViewModelProvider);
/// ///
/// // View 层调用方法 /// // View 层调用方法
/// ref.read(loginViewModelProvider.notifier).login(email, password); /// ref.read(loginViewModelProvider.notifier).sendOtp('+86', '13800138000');
/// ref.read(loginViewModelProvider.notifier).verifyAndLogin('123456');
/// ``` /// ```
/// ///
/// ## 手动 vs 自动 Provider 对比 /// ## DI 链路
/// ///
/// ``` /// ```
/// loginViewModelProvider ← @riverpod 自动生成(本文件) /// loginViewModelProvider ← @riverpod 自动生成
/// → ref.read(loginUseCaseProvider) ← di/ 手动装配 /// → ref.read(loginUseCaseProvider) ← di/ 手动装配
/// → ref.read(authRepositoryProvider) ← di/ 手动装配 /// → ref.read(authRepositoryProvider) ← di/ 手动装配
/// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配 /// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配
/// ``` /// ```
///
/// ## 数据流位置
///
/// ```
/// View: ref.read(loginViewModelProvider.notifier).login(email, password)
/// → ★ LoginViewModel.login() ★ ← 你在这里
/// → LoginUseCase.execute() ← 格式校验 + 调 Repository
/// → AuthRepository.login()
/// → _client.executeRequest(LoginRequest)
/// ← LoginResponse → User
/// ← User
/// → state = state.copyWith(user: user) ← 更新状态
/// View: ref.watch → 自动 rebuild ← UI 刷新
/// ```
@riverpod @riverpod
class LoginViewModel extends _$LoginViewModel { class LoginViewModel extends _$LoginViewModel {
@override @override
LoginState build() => const LoginState(); LoginState build() => const LoginState();
/// Demo 登录(跳过 API直接设置登录状态 /// 步骤 1发送手机验证码
/// ///
/// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login] /// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用
/// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。 Future<void> sendOtp(String countryCode, String contact) async {
Future<void> demoLogin() async {
// 防止连点重入:第一次调用未完成前忽略后续调用
if (state.isLoading) return; if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
final storageApi = ref.read(storageSdkProvider);
final storageLifeCycle = storageApi as StorageSdkLifecycle;
final repositoryProvider = ref.read(userRepositoryProvider);
final provider = ref.read(authNotifierProvider);
try { try {
// 读取 mock 数据loginData.json 结构: { code, message, data: {...} } await ref
// 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为 .read(loginUseCaseProvider)
final raw = await rootBundle.loadString('assets/loginData.json'); .sendOtp(countryCode: countryCode, contact: contact);
final json = jsonDecode(raw) as Map<String, dynamic>;
final data = json['data'] as Map<String, dynamic>; if (!ref.mounted) return;
final profile = data['profile'] as Map<String, dynamic>; state = state.copyWith(
// 生成器生成的 _$XFromJson 是 library 私有函数,外部不可调用。 step: LoginStep.otp,
// Demo 场景直接从 JSON 字段构建 User不依赖生成的 fromJson。 countryCode: countryCode,
final user = User( contact: contact,
uid: profile['uid'] as int, isLoading: false,
uuid: profile['uuid'] as String,
lastOnline: profile['last_online'] as int,
profilePic: profile['profile_pic'] as String,
profilePicGaussian: profile['profile_pic_gaussian'] as String,
nickname: profile['nickname'] as String,
contact: profile['contact'] as String,
countryCode: profile['country_code'] as String,
email: profile['email'] as String,
recoveryEmail: profile['recovery_email'] as String,
username: profile['username'] as String,
bio: profile['bio'] as String,
relationship: profile['relationship'] as int,
userAlias: profile['user_alias'] as String?,
hint: profile['hint'] as String,
); );
// 先完成 DB 操作,再标记登录状态(失败时不会误标为已登录)
await storageLifeCycle.openDatabase(user.uid);
// Save user to DB via repository
await repositoryProvider.saveUser(user);
// Trigger auth state
provider.login();
} catch (e) {
// 导航已发生时 provider 已被 dispose静默丢弃不再写 state
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
/// 执行登录
///
/// 1. 设置 loading 状态UI 显示加载指示器、禁用按钮)
/// 2. 调 UseCase格式校验 → 登录 → 返回 User
/// 3. 成功:写入 user失败写入 error
Future<void> login(String email, String password) async {
state = state.copyWith(isLoading: true, error: null);
final provider = ref.read(loginUseCaseProvider);
try {
final user = await provider.execute(email: email, password: password);
state = state.copyWith(user: user, isLoading: false);
} on FormatException catch (e) { } on FormatException catch (e) {
// 格式校验失败UseCase 层抛出) if (!ref.mounted) return;
state = state.copyWith(error: e.message, isLoading: false); state = state.copyWith(error: e.message, isLoading: false);
} on ApiError catch (e) { } on ApiError catch (e) {
// 网络 / 服务端错误Repository → SDK 透传) if (!ref.mounted) return;
state = state.copyWith(error: e.displayMessage, isLoading: false); state = state.copyWith(error: e.displayMessage, isLoading: false);
} catch (e) { } catch (e) {
// 兜底:防止未预期的异常导致 isLoading 死锁 if (!ref.mounted) return;
state = state.copyWith(error: e.toString(), isLoading: false); state = state.copyWith(error: e.toString(), isLoading: false);
} }
} }
/// 步骤 2+3校验验证码并完成登录
///
/// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向provider 随即被 dispose。
Future<void> verifyAndLogin(String code) async {
if (state.isLoading) return;
state = state.copyWith(isLoading: true, error: null);
try {
await ref.read(loginUseCaseProvider).verifyAndLogin(
countryCode: state.countryCode,
contact: state.contact,
code: code,
);
// 成功后触发路由守卫重定向。
// 注意login() 触发导航后 provider 随即被 dispose之后不能再写 state。
if (!ref.mounted) return;
ref.read(authNotifierProvider).login();
} on FormatException catch (e) {
if (!ref.mounted) return;
state = state.copyWith(error: e.message, isLoading: false);
} on ApiError catch (e) {
if (!ref.mounted) return;
state = state.copyWith(error: e.displayMessage, isLoading: false);
} catch (e) {
if (!ref.mounted) return;
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
/// 返回手机号输入步骤(用户想修改手机号)
void backToPhone() {
state = state.copyWith(step: LoginStep.phone, error: null);
}
} }

View File

@@ -1,38 +1,36 @@
import 'package:networks_sdk/networks_sdk.dart'; import 'package:networks_sdk/networks_sdk.dart';
import 'package:storage_sdk/storage_sdk.dart'; import 'package:storage_sdk/storage_sdk.dart';
import '../../../core/services/socket_manager.dart'; import 'package:im_app/core/services/socket_manager.dart';
import '../../../domain/entities/user.dart'; import 'package:im_app/domain/entities/user.dart';
import '../../../domain/repositories/auth_repository.dart'; import 'package:im_app/domain/repositories/auth_repository.dart';
/// 登录用例 /// 登录用例
/// ///
/// 封装登录的完整业务流程: /// 封装登录的完整业务流程:
/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User /// - sendOtp格式校验 → 发短信
/// - verifyAndLogin格式校验 → 校验验证码 → 登录 → 初始化 WebSocket → 打开本地数据库
/// ///
/// ## 为什么需要 UseCase /// ## 为什么需要 UseCase
/// ///
/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则: /// 登录有明确的多步业务规则UseCase 把这些规则集中封装,
/// - 格式校验(不发无效请求,省流量、减少服务端压力) /// ViewModel 只需一行调用。
/// - 登录后初始化 WebSocket 连接
/// - 登录后按 user id 打开对应的本地数据库
///
/// 把这些规则封装在 UseCase 里ViewModel 只需一行调用。
/// ///
/// ## 数据流位置 /// ## 数据流位置
/// ///
/// ``` /// ```
/// LoginViewModel.login(email, password) /// LoginViewModel.sendOtp(countryCode, contact)
/// → ★ LoginUseCase.execute() ★ ← 你在这里 /// → ★ LoginUseCase.sendOtp() ★ ← 你在这里(步骤 1
/// → 格式校验(邮箱 + 密码 /// → 格式校验(手机号
/// → AuthRepository.login() /// → AuthRepository.sendOtp()
/// → AuthRepositoryImpl.login() ///
/// → _client.executeRequest(LoginRequest) /// LoginViewModel.verifyAndLogin(code)
/// LoginResponseSDK 已拆包 envelope /// → ★ LoginUseCase.verifyAndLogin() ★ ← 你在这里(步骤 2+3
/// → _onTokenUpdate(accessToken) ← 回调写入 Token内存 + 持久化,由 Provider 层组合 /// → 格式校验(验证码
/// ← LoginResponse.toEntity() → User /// → AuthRepository.verifyOtp() → vcode_token
/// → SocketManager.connect(token) ← 登录后连接 WebSocket /// → AuthRepository.login() → User + token
/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库 /// → SocketManager.connect(token)
/// → StorageSdkApi.openDatabase(uid)
/// ← User /// ← User
/// ``` /// ```
class LoginUseCase { class LoginUseCase {
@@ -54,62 +52,84 @@ class LoginUseCase {
_apiConfig = apiConfig, _apiConfig = apiConfig,
_storageApi = storageApi; _storageApi = storageApi;
/// 执行登录 /// 步骤 1发送手机验证码
///
/// 1. 格式校验 → 不合法直接抛 [FormatException]
/// 2. 调 Repository 登录 → 拿到 Usertoken 写入由 Repository 处理)
/// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
/// 4. 按 user id 打开本地数据库
/// ///
/// 抛出: /// 抛出:
/// - [FormatException] — 邮箱或密码格式不合法 /// - [FormatException] — 手机号格式不合法
/// - [ApiError] — 网络/服务端错误(由 Repository 透传) /// - [ApiError] — 网络/服务端错误
Future<User> execute({ Future<void> sendOtp({
required String email, required String countryCode,
required String password, required String contact,
}) async { }) async {
// ── 1. 格式校验 ── _validatePhone(contact);
_validateEmail(email); await _authRepository.sendOtp(
_validatePassword(password); countryCode: countryCode,
contact: contact,
);
}
// ── 2. 登录 ── /// 步骤 2+3校验验证码并完成登录返回 [User]
final user = await _authRepository.login(email: email, password: password); ///
/// 内部串行verifyOtp → login → connectWebSocket → openDatabase
///
/// 抛出:
/// - [FormatException] — 验证码格式不合法
/// - [ApiError] — 网络/服务端错误
Future<User> verifyAndLogin({
required String countryCode,
required String contact,
required String code,
}) async {
_validateCode(code);
// ── 3. 连接 WebSocket ── // 校验验证码,换取 vcode_token
// token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig final vcodeToken = await _authRepository.verifyOtp(
// 此处直接读取,避免改动现有接口。 countryCode: countryCode,
contact: contact,
code: code,
);
// 用 vcode_token 登录token 写入由 Repository._onTokenUpdate 回调处理)
final user = await _authRepository.login(
countryCode: countryCode,
contact: contact,
vcodeToken: vcodeToken,
);
// 连接 WebSockettoken 已由 Repository 写入 ApiConfig直接读取
final token = _apiConfig.token; final token = _apiConfig.token;
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
await _socketManager.connect(token: token); await _socketManager.connect(token: token);
} }
// ── 4. 打开数据库 ── // 按用户 uid 打开本地数据库
// TODO: 当服务端返回整型 uid 时,换成 user.uid目前用 hashCode 作为临时标识。 await _storageLifeCycle.openDatabase(user.uid);
await _storageLifeCycle.openDatabase(user.hashCode);
// TODO: 后续扩展点 // TODO: 扩展点 — 同步联系人列表、注册推送 token
// - 同步联系人列表
// - 注册推送 token
return user; return user;
} }
void _validateEmail(String email) { void _validatePhone(String contact) {
if (email.trim().isEmpty) { final trimmed = contact.trim();
throw const FormatException('邮箱不能为空'); // TODO: 接入国际化 if (trimmed.isEmpty) {
throw const FormatException('手机号不能为空'); // TODO: 接入国际化
} }
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); if (trimmed.length < 7 || trimmed.length > 15) {
if (!emailRegex.hasMatch(email.trim())) { throw const FormatException('手机号长度不正确'); // TODO: 接入国际化
throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化 }
if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
throw const FormatException('手机号只能包含数字'); // TODO: 接入国际化
} }
} }
void _validatePassword(String password) { void _validateCode(String code) {
if (password.isEmpty) { final trimmed = code.trim();
throw const FormatException('密码不能为空'); // TODO: 接入国际化 if (trimmed.isEmpty) {
throw const FormatException('验证码不能为空'); // TODO: 接入国际化
} }
if (password.length < 6) { if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化 throw const FormatException('验证码只能包含数字'); // TODO: l10n
} }
} }
} }

View File

@@ -1,44 +1,73 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/ui/base/context_theme_ext.dart'; import 'package:im_app/features/login/presentation/login_state.dart';
import '../presentation/login_view_model.dart'; import 'package:im_app/features/login/presentation/login_view_model.dart';
import 'package:im_app/features/login/view/widgets/login_otp_step.dart';
import 'package:im_app/features/login/view/widgets/login_phone_step.dart';
/// 登录页Demo /// 登录页 — 两步流程:手机号 → 验证码
/// ///
/// 演示 go_router 登录守卫:点击「登录」后经由 [LoginViewModel.demoLogin] /// 步骤 1 [LoginStep.phone][LoginPhoneStep] — 输入国家代码 + 手机号
/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。 /// 步骤 2 [LoginStep.otp][LoginOtpStep] — 输入验证码完成登录
/// ///
/// 正式实现时替换为完整登录流程email/password 输入 → LoginViewModel.login /// 页面本身只持有两个 TextEditingController 和三个回调方法,
class LoginPage extends ConsumerWidget { /// 具体 UI 由 widgets/ 下的子组件负责。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<LoginPage> createState() => _LoginPageState();
// ref.watch 保持 loginViewModelProvider 存活AutoDispose 需要至少一个监听者) }
class _LoginPageState extends ConsumerState<LoginPage> {
// demo 预填,上线前去掉
final _phoneCtrl = TextEditingController(text: '83465308');
final _otpCtrl = TextEditingController(text: '0000');
@override
void dispose() {
_phoneCtrl.dispose();
_otpCtrl.dispose();
super.dispose();
}
void _sendOtp(LoginState state) {
ref
.read(loginViewModelProvider.notifier)
.sendOtp(state.countryCode, _phoneCtrl.text.trim());
}
void _verifyAndLogin() {
ref
.read(loginViewModelProvider.notifier)
.verifyAndLogin(_otpCtrl.text.trim());
}
void _backToPhone() {
_otpCtrl.clear();
ref.read(loginViewModelProvider.notifier).backToPhone();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(loginViewModelProvider); final state = ref.watch(loginViewModelProvider);
final s = context.styles;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('登录'), automaticallyImplyLeading: false), appBar: AppBar(automaticallyImplyLeading: false, title: const Text('登录')),
body: Center( body: Padding(
child: Column( padding: const EdgeInsets.symmetric(horizontal: 32),
mainAxisSize: MainAxisSize.min, child: state.step == LoginStep.phone
children: [ ? LoginPhoneStep(
Text('IM_Demo', style: s.titleMedium), phoneCtrl: _phoneCtrl,
const SizedBox(height: 8), state: state,
Text( onSendOtp: () => _sendOtp(state),
'未登录时任意路由均被重定向到此页 \n 主要是为了展示路由守卫的功能 \n 后续路由守卫专门处理各种跳转前的逻辑判断', )
style: s.bodySmall, : LoginOtpStep(
), otpCtrl: _otpCtrl,
const SizedBox(height: 32), state: state,
FilledButton( onVerifyAndLogin: _verifyAndLogin,
onPressed: state.isLoading onBackToPhone: _backToPhone,
? null
: () => ref.read(loginViewModelProvider.notifier).demoLogin(),
child: const Text('登录'),
),
],
), ),
), ),
); );

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:im_app/features/login/presentation/login_state.dart';
/// 登录步骤 2 — 输入验证码并完成登录
///
/// 纯展示组件,所有交互通过回调传出,不持有任何状态。
///
/// 使用方式:
/// ```dart
/// LoginOtpStep(
/// otpCtrl: _otpCtrl,
/// state: state,
/// onVerifyAndLogin: _verifyAndLogin,
/// onBackToPhone: _backToPhone,
/// )
/// ```
class LoginOtpStep extends StatelessWidget {
const LoginOtpStep({
super.key,
required this.otpCtrl,
required this.state,
required this.onVerifyAndLogin,
required this.onBackToPhone,
});
final TextEditingController otpCtrl;
final LoginState state;
final VoidCallback onVerifyAndLogin;
final VoidCallback onBackToPhone;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'输入验证码',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'验证码已发送至 ${state.countryCode} ${state.maskedContact}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: otpCtrl,
keyboardType: TextInputType.number,
maxLength: 4,
decoration: const InputDecoration(
labelText: '4 位验证码',
border: OutlineInputBorder(),
counterText: '',
),
autofillHints: const [AutofillHints.oneTimeCode],
),
const SizedBox(height: 24),
if (state.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
),
FilledButton(
onPressed: state.isLoading ? null : onVerifyAndLogin,
child: state.isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
const SizedBox(height: 12),
TextButton(
onPressed: state.isLoading ? null : onBackToPhone,
child: const Text('返回修改手机号'),
),
],
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:im_app/features/login/presentation/login_state.dart';
/// 登录步骤 1 — 输入国家代码 + 手机号
///
/// 纯展示组件,所有交互通过回调传出,不持有任何状态。
///
/// 使用方式:
/// ```dart
/// LoginPhoneStep(
/// phoneCtrl: _phoneCtrl,
/// state: state,
/// onSendOtp: () => _sendOtp(state),
/// )
/// ```
class LoginPhoneStep extends StatelessWidget {
const LoginPhoneStep({
super.key,
required this.phoneCtrl,
required this.state,
required this.onSendOtp,
});
final TextEditingController phoneCtrl;
final LoginState state;
final VoidCallback onSendOtp;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'手机号登录',
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(4),
),
child: Text(
state.countryCode,
style: Theme.of(context).textTheme.bodyLarge,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: phoneCtrl,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: '手机号',
border: OutlineInputBorder(),
),
autofillHints: const [AutofillHints.telephoneNumber],
),
),
],
),
const SizedBox(height: 24),
if (state.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
),
FilledButton(
onPressed: state.isLoading ? null : onSendOtp,
child: state.isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('获取验证码'),
),
],
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../usecases/set_theme_usecase.dart'; import 'package:im_app/features/settings/usecases/set_theme_usecase.dart';
/// Settings feature DI 装配 /// Settings feature DI 装配
/// ///

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/router/app_route_name.dart'; import 'package:im_app/app/router/app_route_name.dart';
part 'settings_view_model.g.dart'; part 'settings_view_model.g.dart';

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../app/di/app_providers.dart'; import 'package:im_app/app/di/app_providers.dart';
import '../di/settings_providers.dart'; import 'package:im_app/features/settings/di/settings_providers.dart';
part 'theme_view_model.g.dart'; part 'theme_view_model.g.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../presentation/settings_view_model.dart'; import 'package:im_app/features/settings/presentation/settings_view_model.dart';
/// 设置页 /// 设置页
/// ///

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../presentation/theme_view_model.dart'; import 'package:im_app/features/settings/presentation/theme_view_model.dart';
import 'widgets/settings_section_header.dart'; import 'package:im_app/features/settings/view/widgets/settings_section_header.dart';
import 'widgets/theme_option_tile.dart'; import 'package:im_app/features/settings/view/widgets/theme_option_tile.dart';
/// 主题选择页 /// 主题选择页
/// ///

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../../core/ui/base/context_theme_ext.dart'; import 'package:im_app/core/ui/base/context_theme_ext.dart';
/// 设置页分组标题 /// 设置页分组标题
/// ///

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../../core/ui/base/context_theme_ext.dart'; import 'package:im_app/core/ui/base/context_theme_ext.dart';
/// 单个主题选项行 /// 单个主题选项行
/// ///

View File

@@ -43,6 +43,9 @@ dependencies:
# 数据库schema 定义在 im_app连接/CRUD 封装在 storage_sdk # 数据库schema 定义在 im_app连接/CRUD 封装在 storage_sdk
drift: ^2.22.0 drift: ^2.22.0
# 设备信息deviceId / deviceName
device_info_plus: ^11.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -21,9 +21,9 @@ class AuthInterceptor extends Interceptor {
options.extra['customHeaders'] as Map<String, String>?; options.extra['customHeaders'] as Map<String, String>?;
// 保留重试请求的原始 Request-ID幂等性 // 保留重试请求的原始 Request-ID幂等性
// 重试时 options.headers 中已有 APP-Request-ID // 重试时 options.headers 中已有 app-request-id
// 新生成的 headers 会覆盖它导致服务端无法识别为同一请求。 // 新生成的 headers 会覆盖它导致服务端无法识别为同一请求。
final existingRequestId = options.headers['APP-Request-ID'] as String?; final existingRequestId = options.headers['app-request-id'] as String?;
// 构建 headers // 构建 headers
final headers = config.defaultHeaders( final headers = config.defaultHeaders(
@@ -33,7 +33,7 @@ class AuthInterceptor extends Interceptor {
// 还原原始 Request-ID // 还原原始 Request-ID
if (existingRequestId != null) { if (existingRequestId != null) {
headers['APP-Request-ID'] = existingRequestId; headers['app-request-id'] = existingRequestId;
} }
options.headers.addAll(headers); options.headers.addAll(headers);

View File

@@ -3,8 +3,44 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart'; import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
/// 日志拦截器 /// 请求/响应日志拦截器
/// 统一打印请求 + 响应(一个日志块) ///
/// 每次请求打一条日志,请求和响应合并输出,方便对照:
///
/// ```
/// [Network] ── SendOtpRequest (auth_repository_impl.dart:45)
/// --> POST https://example.com/app/api/auth/otp/send
/// Headers:
/// {
/// "Platform": "Android",
/// ...
/// }
///
/// Body:
/// {
/// "country_code": "+65",
/// "contact": "83465308",
/// "type": 1
/// }
/// <-- 200 https://example.com/app/api/auth/otp/send
/// {
/// "code": 0,
/// "message": "ok"
/// }
/// ────────────────────────────────────────────────────────────
///
/// [Network ERR] ── SendOtpRequest (auth_repository_impl.dart:45)
/// --> POST https://example.com/app/api/auth/otp/send
/// ...
/// <-- 404 https://example.com/app/api/auth/otp/send
/// Type: badResponse
///
/// <html>...</html>
/// ────────────────────────────────────────────────────────────
/// ```
///
/// Header 行显示 Request 类名 + 调用文件:行号,直接定位到出问题的调用代码。
/// Footer 行 `────` 视觉上把相邻请求隔开,成对关系一眼看清。
class LoggingInterceptor extends Interceptor { class LoggingInterceptor extends Interceptor {
final OnLog? onLog; final OnLog? onLog;
final bool enabled; final bool enabled;
@@ -14,7 +50,12 @@ class LoggingInterceptor extends Interceptor {
@override @override
void onResponse(Response response, ResponseInterceptorHandler handler) { void onResponse(Response response, ResponseInterceptorHandler handler) {
if (enabled && onLog != null) { if (enabled && onLog != null) {
_logRequestAndResponse(response); _log(
opts: response.requestOptions,
statusCode: response.statusCode,
data: response.data,
tag: 'Network',
);
} }
handler.next(response); handler.next(response);
} }
@@ -22,59 +63,103 @@ class LoggingInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
if (enabled && onLog != null) { if (enabled && onLog != null) {
_logError(err); _log(
opts: err.requestOptions,
statusCode: err.response?.statusCode,
data: err.response?.data,
errorType: err.type.name,
errorMessage: err.message,
tag: 'Network ERR',
);
} }
handler.next(err); handler.next(err);
} }
void _logRequestAndResponse(Response response) { // ─── Log ─────────────────────────────────────────────────────────────────
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(' '); void _log({
required RequestOptions opts,
required int? statusCode,
required dynamic data,
String? errorType,
String? errorMessage,
required String tag,
}) {
try {
final buf = StringBuffer();
const footer =
'────────────────────────────────────────────────────────────';
// HeaderRequest 类名 + 调用位置
final className = opts.extra['_requestClass'] as String? ?? '';
final callerFrame = opts.extra['_callerFrame'] as String? ?? '';
if (className.isNotEmpty) {
buf.write('── $className');
if (callerFrame.isNotEmpty) {
buf.writeln(' ($callerFrame)');
} else {
buf.writeln();
}
}
// Requestheaders + body 合并成一个 JSON 块
buf.writeln('--> ${opts.method} ${opts.uri}');
final requestMap = <String, dynamic>{
'headers': _sanitizeHeaders(opts.headers),
if (opts.data != null) 'body': opts.data,
};
buf.writeln(_formatBody(requestMap));
// Response
final status = statusCode != null ? '$statusCode' : 'ERR';
buf.writeln('<-- $status ${opts.uri}');
if (errorType != null) {
buf.writeln('Type: $errorType');
}
if (data != null) {
buf.writeln();
buf.writeln(_formatBody(data));
} else if (errorMessage != null && errorType == null) {
// 无响应 body超时、断网等打印错误消息
buf.writeln(errorMessage);
}
// Footer
buf.write(footer);
onLog!(buf.toString(), tag: tag);
} catch (_) {
onLog!( onLog!(
'API Request + Response:\n${encoder.convert(logData)}', '--> ${opts.method} ${opts.uri} | <-- $statusCode',
tag: 'Network', tag: tag,
);
} catch (e) {
onLog!(
'API: ${response.requestOptions.uri} -> ${response.statusCode}',
tag: 'Network',
); );
} }
} }
void _logError(DioException error) { // ─── Helpers ──────────────────────────────────────────────────────────────
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(' '); /// Authorization token 只保留前 16 位,防止 token 泄露到日志
onLog!( Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
'API Error:\n${encoder.convert(logData)}', return headers.map((key, value) {
tag: 'Network', if (key.toLowerCase() == 'authorization' &&
); value is String &&
} catch (e) { value.length > 16) {
onLog!('API Error: ${error.message}', tag: 'Network'); return MapEntry(key, '${value.substring(0, 16)}...');
}
return MapEntry(key, value);
});
}
/// 格式化 body兼容 String / Map / List
String _formatBody(dynamic data) {
try {
if (data is String) {
final parsed = jsonDecode(data);
return const JsonEncoder.withIndent(' ').convert(parsed);
}
return const JsonEncoder.withIndent(' ').convert(data);
} catch (_) {
return data.toString();
} }
} }
} }

View File

@@ -3,20 +3,24 @@ import 'dart:math';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:networks_sdk/src/data/datasources/http/token_refresh_manager.dart'; import 'package:networks_sdk/src/data/datasources/http/token_refresh_manager.dart';
import 'package:networks_sdk/src/presentation/wiring/api_config.dart'; import 'package:networks_sdk/src/presentation/wiring/api_config.dart';
import 'package:networks_sdk/src/presentation/wiring/network_callbacks.dart';
/// 重试拦截器 /// 重试拦截器
/// ///
/// 两层重试机制: /// 两层重试机制:
/// ///
/// 1. **Token 刷新重试**onResponse /// 1. **业务错误码处理**onResponse
/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求 /// 所有非 0 业务码经 [ApiConfig.onBusinessError] 回调,
/// App 层返回 [BusinessErrorAction] 枚举告知 SDK 该怎么做:
/// - refreshToken → 刷新 token 后重试
/// - forceLogout → 中断请求
/// - handled → App 已处理,不在 decodeResponse 中抛错
/// - unhandled → 透传给调用方decodeResponse 会抛 ApiError
/// ///
/// 2. **瞬态错误重试**onError /// 2. **瞬态错误重试**onError
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试 /// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用) /// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
/// ///
/// 另外在 onResponse 中处理强制登出码和业务错误码。
///
/// 两层独立运作,可叠加。 /// 两层独立运作,可叠加。
class RetryInterceptor extends Interceptor { class RetryInterceptor extends Interceptor {
final ApiConfig config; final ApiConfig config;
@@ -35,7 +39,7 @@ class RetryInterceptor extends Interceptor {
proactiveRefreshThreshold: config.proactiveRefreshThreshold, proactiveRefreshThreshold: config.proactiveRefreshThreshold,
); );
// ── 响应处理(Token 过期 / 强制登出 / 业务错误码)────────────────────── // ── 响应处理(所有非 0 业务码统一走 onBusinessError──────────────────
@override @override
void onResponse(Response response, ResponseInterceptorHandler handler) { void onResponse(Response response, ResponseInterceptorHandler handler) {
@@ -46,13 +50,39 @@ class RetryInterceptor extends Interceptor {
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
final code = _parseCode(data['code']); final code = _parseCode(data['code']);
if (code == 0) {
handler.next(response);
return;
}
final message = data['message'] as String? ?? ''; final message = data['message'] as String? ?? '';
final requestPath = response.requestOptions.path; final requestPath = response.requestOptions.path;
// 检查强制登出 // 未注册 onBusinessError 时直接放行,由 decodeResponse 抛 ApiError 给调用方
if (config.forceLogoutCodes.contains(code)) { if (config.onBusinessError == null) {
config.onLog?.call('Force logout detected (code: $code)', tag: 'Network'); handler.next(response);
config.onForceLogout?.call(); return;
}
final action = config.onBusinessError!(code, message, requestPath);
switch (action) {
case BusinessErrorAction.refreshToken:
// 跳过已标记为 token 重试的请求,防止递归
if (response.requestOptions.extra['_isTokenRetry'] == true) {
handler.next(response);
return;
}
config.onLog?.call(
'Token expired (code: $code), refreshing...',
tag: 'Network',
);
_handleTokenExpired(response, handler);
case BusinessErrorAction.forceLogout:
config.onLog?.call(
'Force logout (code: $code)',
tag: 'Network',
);
handler.reject( handler.reject(
DioException( DioException(
requestOptions: response.requestOptions, requestOptions: response.requestOptions,
@@ -60,33 +90,17 @@ class RetryInterceptor extends Interceptor {
message: 'Force logout (code: $code)', message: 'Force logout (code: $code)',
), ),
); );
return;
}
// 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归) case BusinessErrorAction.handled:
if (config.tokenExpiredCodes.contains(code) && // App 层已处理(弹窗 / Toast→ 标记,让 decodeResponse 跳过二次抛错
response.requestOptions.extra['_isTokenRetry'] != true) {
config.onLog?.call(
'Token expired (code: $code), refreshing...',
tag: 'Network',
);
_handleTokenExpired(response, handler);
return;
}
// 业务错误码拦截:非 0 且不在特殊码集合中
if (code != 0 && config.onBusinessError != null) {
final handled = config.onBusinessError!(code, message, requestPath);
if (handled) {
// App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
response.requestOptions.extra['_businessErrorHandled'] = true; response.requestOptions.extra['_businessErrorHandled'] = true;
handler.next(response); handler.next(response);
return;
}
}
case BusinessErrorAction.unhandled:
// 未处理decodeResponse 会抛 ApiError 给调用方
handler.next(response); handler.next(response);
} }
}
/// 处理 Token 过期:刷新 + 重试 /// 处理 Token 过期:刷新 + 重试
Future<void> _handleTokenExpired( Future<void> _handleTokenExpired(
@@ -97,7 +111,6 @@ class RetryInterceptor extends Interceptor {
if (newToken == null) { if (newToken == null) {
config.onLog?.call('Token refresh failed', tag: 'Network'); config.onLog?.call('Token refresh failed', tag: 'Network');
config.onForceLogout?.call();
handler.reject( handler.reject(
DioException( DioException(
requestOptions: response.requestOptions, requestOptions: response.requestOptions,

View File

@@ -58,6 +58,9 @@ class NetworksSdkMethodChannelDataSource {
ApiRequestable<T> request, { ApiRequestable<T> request, {
CancelToken? cancelToken, CancelToken? cancelToken,
}) async { }) async {
// 在首个 await 前捕获调用栈async 间隙后栈信息会丢失
final callerFrame = _callerFrame(StackTrace.current);
await _checkNetwork(request.path); await _checkNetwork(request.path);
try { try {
@@ -76,6 +79,8 @@ class NetworksSdkMethodChannelDataSource {
'requestType': request.requestType, 'requestType': request.requestType,
'includeToken': request.includeToken, 'includeToken': request.includeToken,
'customHeaders': request.customHeaders, 'customHeaders': request.customHeaders,
'_requestClass': request.runtimeType.toString(),
'_callerFrame': callerFrame,
}, },
); );
@@ -275,6 +280,38 @@ class NetworksSdkMethodChannelDataSource {
} }
} }
/// 从调用栈提取 App 层第一个帧(文件名:行号),用于日志定位
///
/// 仅 debug 模式栈帧可读release 模式返回空字符串(由日志层静默忽略)。
static String _callerFrame(StackTrace stack) {
try {
for (final line in stack.toString().split('\n')) {
// 沿用已验证有效的过滤条件,不改动
if (line.isEmpty ||
line.contains('package:networks_sdk') ||
line.contains('package:dio') ||
line.contains('(dart:') || // dart: 内部帧格式是 (dart:async/...),不能用 'dart:' 否则会误杀 .dart:LINE
line.contains('<asynchronous')) {
continue;
}
// 用简单 regex 匹配 (URI:LINE:COL),已被证明可在 Android 上正常工作
final fileMatch = RegExp(r'\((.+):(\d+):\d+\)').firstMatch(line);
if (fileMatch == null) { continue; }
final fileName = fileMatch.group(1)!.split('/').last;
final lineNum = fileMatch.group(2)!;
// 从括号前的文本里取最后一个单词作为 Symbol可能带 #N 前缀)
final before = line.substring(0, fileMatch.start).trim();
final symbolMatch = RegExp(r'(\S+)$').firstMatch(before);
final symbol = symbolMatch?.group(1) ?? '';
return symbol.isNotEmpty ? '$symbol · $fileName:$lineNum' : '$fileName:$lineNum';
}
} catch (_) {}
return '';
}
/// 应用响应变换(如果 App 层注入了 onTransformResponse /// 应用响应变换(如果 App 层注入了 onTransformResponse
void _applyResponseTransform(Response response) { void _applyResponseTransform(Response response) {
final transform = apiClient.config.onTransformResponse; final transform = apiClient.config.onTransformResponse;

View File

@@ -446,12 +446,12 @@ class SocketClient {
// ping/pong 是传输层心跳,不经过业务加解密 // ping/pong 是传输层心跳,不经过业务加解密
// 保证即使加密密钥过期/轮换失败,心跳仍然正常工作 // 保证即使加密密钥过期/轮换失败,心跳仍然正常工作
_channel?.sink.add('ping'); _channel?.sink.add('ping');
_log('♥ ping'); _log(' ping');
// 启动 pong 超时计时器 // 启动 pong 超时计时器
_pongTimeoutTimer = Timer(config.pongTimeout, () { _pongTimeoutTimer = Timer(config.pongTimeout, () {
if (_waitingForPong) { if (_waitingForPong) {
_log('♥ pong timeout, reconnecting...'); _log(' pong timeout, reconnecting...');
_waitingForPong = false; _waitingForPong = false;
_emitError(const SocketError.pingTimeout()); _emitError(const SocketError.pingTimeout());
_doDisconnect(reason: 'Pong timeout'); _doDisconnect(reason: 'Pong timeout');
@@ -461,7 +461,7 @@ class SocketClient {
} }
void _onPongReceived() { void _onPongReceived() {
_log('♥ pong'); _log(' pong');
_waitingForPong = false; _waitingForPong = false;
_pongTimeoutTimer?.cancel(); _pongTimeoutTimer?.cancel();
_pongTimeoutTimer = null; _pongTimeoutTimer = null;

View File

@@ -94,10 +94,9 @@ abstract class ApiRequestable<T> {
final fromJsonFunc = fromJsonRegistry[T]; final fromJsonFunc = fromJsonRegistry[T];
if (fromJsonFunc == null) { if (fromJsonFunc == null) {
throw StateError( // void 接口:生成器不注册 fromJson服务端 data 字段直接忽略
'fromJson not registered for type $T. ' // ignore: null_check_on_nullable_type_parameter
'Add: final _reg = registerResponse<$T>($T.fromJson);', return null as dynamic;
);
} }
if (fromJsonFunc is T Function(Object?)) { if (fromJsonFunc is T Function(Object?)) {

View File

@@ -97,7 +97,24 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
} }
final className = element.name!; final className = element.name!;
final path = annotation.read('path').stringValue;
// 尝试保留原始常量引用(如 ApiPaths.authSendOtp
// 这样修改 ApiPaths 里的值不需要重新跑 gen。
// 若注解传的是字面量字符串,则回退为带引号字符串。
final pathObject = annotation.read('path').objectValue;
final pathVariable = pathObject.variable;
final String pathExpression;
if (pathVariable != null) {
final enclosing = pathVariable.enclosingElement;
if (enclosing is ClassElement) {
pathExpression = '${enclosing.name}.${pathVariable.name!}';
} else {
pathExpression = pathVariable.name!;
}
} else {
final pathValue = pathObject.toStringValue()!;
pathExpression = "'$pathValue'";
}
// 读取 HttpMethod 枚举值 // 读取 HttpMethod 枚举值
final methodName = _readEnumName( final methodName = _readEnumName(
@@ -133,17 +150,21 @@ class ApiRequestGenerator extends GeneratorForAnnotation<ApiRequest> {
// 有响应类型parameters getter 中注册 fromJson使用生成的私有函数 // 有响应类型parameters getter 中注册 fromJson使用生成的私有函数
// ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson同 library 可访问 // ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson同 library 可访问
// 无响应类型void跳过注册,直接返回 super.parameters // 无响应类型void无需注册,不生成 parameters getter避免 unnecessary_override
final parametersBody = hasResponseType final parametersGetter = hasResponseType
? ''' registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson); ? '''
return super.parameters;''' @override
: ' return super.parameters;'; Map<String, dynamic>? get parameters {
registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
return super.parameters;
}'''
: '';
return ''' return '''
/// Generated by @ApiRequest for [$className] /// Generated by @ApiRequest for [$className]
mixin _\$${className}Api on ApiRequestable<$responseTypeName> { mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
@override @override
String get path => '$path'; String get path => $pathExpression;
@override @override
HttpMethod get method => HttpMethod.$methodName; HttpMethod get method => HttpMethod.$methodName;
@override @override
@@ -151,11 +172,7 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
@override @override
bool get includeToken => $includeToken; bool get includeToken => $includeToken;
@override @override
Map<String, dynamic> toJson() => $toJsonBody; Map<String, dynamic> toJson() => $toJsonBody;$parametersGetter
@override
Map<String, dynamic>? get parameters {
$parametersBody
}
} }
'''; ''';
} }

View File

@@ -190,6 +190,9 @@ $params );
if (type.isDartCoreDouble) return '$access as double$q'; if (type.isDartCoreDouble) return '$access as double$q';
if (type.isDartCoreNum) return '$access as num$q'; if (type.isDartCoreNum) return '$access as num$q';
// Map<String, dynamic>:已经是 JSON Map直接 cast无需 fromJson
if (type.isDartCoreMap) return '$access as Map<String, dynamic>$q';
// 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数 // 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数
if (type is InterfaceType) { if (type is InterfaceType) {
final typeName = type.element.name!; final typeName = type.element.name!;

View File

@@ -17,9 +17,6 @@ class ApiConfig {
/// Token 过期时的刷新回调 /// Token 过期时的刷新回调
final OnTokenRefresh? onTokenRefresh; final OnTokenRefresh? onTokenRefresh;
/// 需要强制登出时的回调
final OnForceLogout? onForceLogout;
/// Token 更新后的通知回调 /// Token 更新后的通知回调
/// ///
/// 在 [updateToken] 被调用且新 token 非空时触发。 /// 在 [updateToken] 被调用且新 token 非空时触发。
@@ -61,14 +58,6 @@ class ApiConfig {
/// `{ code, data, message }` 结构。返回 null 表示不变换。 /// `{ code, data, message }` 结构。返回 null 表示不变换。
final OnTransformResponse? onTransformResponse; final OnTransformResponse? onTransformResponse;
// ── 错误码集合 ──
/// App 层定义的 Token 过期错误码集合
final Set<int> tokenExpiredCodes;
/// App 层定义的强制登出错误码集合
final Set<int> forceLogoutCodes;
// ── 重试配置 ── // ── 重试配置 ──
/// 瞬态错误最大重试次数5xx / 超时 / 连接失败) /// 瞬态错误最大重试次数5xx / 超时 / 连接失败)
@@ -110,7 +99,6 @@ class ApiConfig {
this.token, this.token,
this.platformHeaders = const {}, this.platformHeaders = const {},
this.onTokenRefresh, this.onTokenRefresh,
this.onForceLogout,
this.onTokenUpdated, this.onTokenUpdated,
this.onLog, this.onLog,
this.onCheckNetworkAvailable, this.onCheckNetworkAvailable,
@@ -118,8 +106,6 @@ class ApiConfig {
this.onDecryptResponse, this.onDecryptResponse,
this.onBusinessError, this.onBusinessError,
this.onTransformResponse, this.onTransformResponse,
this.tokenExpiredCodes = const {},
this.forceLogoutCodes = const {},
this.maxRetries = 0, this.maxRetries = 0,
this.retryBaseDelay = const Duration(seconds: 1), this.retryBaseDelay = const Duration(seconds: 1),
this.tokenRefreshTimeout = const Duration(seconds: 10), this.tokenRefreshTimeout = const Duration(seconds: 10),
@@ -136,12 +122,12 @@ class ApiConfig {
Map<String, String>? customHeaders, Map<String, String>? customHeaders,
}) { }) {
final headers = <String, String>{ final headers = <String, String>{
'Content-Type': 'application/json; charset=utf-8', 'content-type': 'application/json; charset=utf-8',
'Accept': 'application/json', 'accept': 'application/json',
'Keep-Alive': 'timeout=60', 'keep-alive': 'timeout=60',
// Unix 时间戳(秒),整数值,非格式化日期字符串 // Unix 时间戳(秒),整数值,非格式化日期字符串
'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}', 'timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
'APP-Request-ID': _generateRequestId(), 'app-request-id': _generateRequestId(),
}; };
// 合并平台 headersApp 层注入的 version、platform 等) // 合并平台 headersApp 层注入的 version、platform 等)

View File

@@ -8,9 +8,6 @@ import 'package:networks_sdk/src/domain/entities/encrypted_request.dart';
/// Token 刷新回调,返回新 token返回 null 表示刷新失败 /// Token 刷新回调,返回新 token返回 null 表示刷新失败
typedef OnTokenRefresh = Future<String?> Function(); typedef OnTokenRefresh = Future<String?> Function();
/// 强制登出回调
typedef OnForceLogout = void Function();
// ── Token 生命周期 ── // ── Token 生命周期 ──
/// 获取 token 过期时间 /// 获取 token 过期时间
@@ -73,11 +70,27 @@ typedef OnDecryptResponse =
// ── 业务错误 ── // ── 业务错误 ──
/// 业务错误拦截回调 /// SDK 层收到 App 层对业务错误码的处置指令
enum BusinessErrorAction {
/// 刷新 token 后重试原请求(原 tokenExpiredCodes 行为)
refreshToken,
/// 强制登出,中断当前请求(原 forceLogoutCodes 行为)
forceLogout,
/// App 层已处理(如全局弹窗/ToastSDK 正常放行响应,不在 decodeResponse 中抛错
handled,
/// 未处理SDK 继续正常流程decodeResponse 会抛 ApiError 给调用方
unhandled,
}
/// 业务错误统一回调
/// ///
/// App 层统一处理特定错误码,返回 true = 已处理SDK 不再抛错) /// 所有非 0 业务码token 过期、强制登出、踢下线、普通业务错误)全部经此入口
/// 返回 false = 未处理SDK 继续正常流程) /// App 层通过返回 [BusinessErrorAction] 告诉 SDK 该怎么做
typedef OnBusinessError = bool Function(int code, String message, String path); typedef OnBusinessError =
BusinessErrorAction Function(int code, String message, String path);
/// 响应变换回调 /// 响应变换回调
/// ///

View File

@@ -273,6 +273,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
url: "https://pub.dev"
source: hosted
version: "11.5.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio: dio:
dependency: transitive dependency: transitive
description: description:
@@ -1148,6 +1164,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description: