diff --git a/Doc/IM_App_架构设计.html b/Doc/IM_App_架构设计.html
index 698760e..badb54c 100644
--- a/Doc/IM_App_架构设计.html
+++ b/Doc/IM_App_架构设计.html
@@ -2636,7 +2636,15 @@ class UploadFileRequest extends ApiRequestable<UploadResult>
final apiConfigProvider = Provider<ApiConfig>((ref) {
return ApiConfig(
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},
forceLogoutCodes: {30125},
onForceLogout: () { /* 清除登录态,跳转登录页 */ },
@@ -6558,8 +6566,13 @@ final apiConfigProvider = Provider<ApiConfig>((ref) {
return ApiConfig(
baseURL: AppConfig.apiBaseUrl,
platformHeaders: {
- 'Platform': 'Android', // TODO: 运行时从平台 API 获取
- 'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
+ '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}, // 后端约定的 Token 过期错误码
forceLogoutCodes: {30125}, // 后端约定的强制登出错误码
@@ -7626,6 +7639,7 @@ linter:
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
prefer_final_locals: true
+ always_use_package_imports: true # 禁止相对路径 import,一律用 package: 全路径
# 命名规则
camel_case_types: true
@@ -9794,6 +9808,7 @@ flowchart TD
使用 Melos 管理依赖:Mono-Repo 保证版本一致性
编写完整测试:单元测试、集成测试、端到端测试
UI 层使用 ConsumerWidget:通过 ref.watch 监听状态,ref.read 读取 Provider
+import 一律使用 package 全路径:禁止相对路径(../base/colors.dart),统一写 package:im_app/core/ui/base/colors.dart;相对路径文件移动后静默失效,全路径重构安全、跨包引用统一,已由 always_use_package_imports: true 强制执行
diff --git a/apps/im_app/config/config.json b/apps/im_app/config/config.json
index cb89a6e..89c74d4 100644
--- a/apps/im_app/config/config.json
+++ b/apps/im_app/config/config.json
@@ -1,4 +1,6 @@
{
"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"
}
diff --git a/apps/im_app/ios/Runner.xcodeproj/project.pbxproj b/apps/im_app/ios/Runner.xcodeproj/project.pbxproj
index e2932a4..58cbf0e 100644
--- a/apps/im_app/ios/Runner.xcodeproj/project.pbxproj
+++ b/apps/im_app/ios/Runner.xcodeproj/project.pbxproj
@@ -280,10 +280,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
+ inputPaths = (
+ );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
+ outputPaths = (
+ );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -474,6 +478,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17;
@@ -662,6 +667,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17;
@@ -690,6 +696,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17;
diff --git a/apps/im_app/ios/Runner/Info.plist b/apps/im_app/ios/Runner/Info.plist
index e479553..354b014 100644
--- a/apps/im_app/ios/Runner/Info.plist
+++ b/apps/im_app/ios/Runner/Info.plist
@@ -66,5 +66,18 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+ NSAppTransportSecurity
+
+ NSExceptionDomains
+
+ gateway.winwayinfo.com
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+
+
diff --git a/apps/im_app/lib/app/app.dart b/apps/im_app/lib/app/app.dart
index 25640b4..92c5561 100644
--- a/apps/im_app/lib/app/app.dart
+++ b/apps/im_app/lib/app/app.dart
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../core/ui/base/app_theme.dart';
-import 'di/app_providers.dart';
-import 'di/network_provider.dart';
-import 'router/app_router.dart';
+import 'package:im_app/core/ui/base/app_theme.dart';
+import 'package:im_app/app/di/app_providers.dart';
+import 'package:im_app/app/di/network_provider.dart';
+import 'package:im_app/app/router/app_router.dart';
/// 应用根组件
///
diff --git a/apps/im_app/lib/app/bootstrap.dart b/apps/im_app/lib/app/bootstrap.dart
index e72fbb1..15f4f51 100644
--- a/apps/im_app/lib/app/bootstrap.dart
+++ b/apps/im_app/lib/app/bootstrap.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'app.dart';
+import 'package:im_app/app/app.dart';
void bootstrap() {
WidgetsFlutterBinding.ensureInitialized();
diff --git a/apps/im_app/lib/app/di/app_providers.dart b/apps/im_app/lib/app/di/app_providers.dart
index c76c412..cc4d98c 100644
--- a/apps/im_app/lib/app/di/app_providers.dart
+++ b/apps/im_app/lib/app/di/app_providers.dart
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../core/services/app_initializer.dart';
-import 'network_provider.dart';
+import 'package:im_app/core/foundation/device_info.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((ref) {
name: 'NetworkMonitor',
task: () => ref.read(networkMonitorProvider).initialize(),
),
+ // 预取设备 ID / 设备名,platformHeaders 同步读取
+ InitTask(
+ name: 'DeviceInfo',
+ task: DeviceInfo.init,
+ ),
],
deferred: [
// TODO: 推送注册
diff --git a/apps/im_app/lib/app/di/db_provider.dart b/apps/im_app/lib/app/di/db_provider.dart
index ba51c82..3e789dd 100644
--- a/apps/im_app/lib/app/di/db_provider.dart
+++ b/apps/im_app/lib/app/di/db_provider.dart
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.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 生命周期内唯一实例。
///
diff --git a/apps/im_app/lib/app/di/network_provider.dart b/apps/im_app/lib/app/di/network_provider.dart
index be1fd35..fd3fe15 100644
--- a/apps/im_app/lib/app/di/network_provider.dart
+++ b/apps/im_app/lib/app/di/network_provider.dart
@@ -1,15 +1,17 @@
import 'dart:async';
+import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
-import '../../core/foundation/api_paths.dart';
-import '../../core/foundation/config.dart';
-import '../../core/foundation/constants.dart';
-import '../../core/foundation/errors.dart';
-import '../../core/foundation/utils.dart';
-import '../../core/services/network_monitor.dart';
-import '../../core/services/socket_manager.dart';
+import 'package:im_app/core/foundation/api_paths.dart';
+import 'package:im_app/core/foundation/config.dart';
+import 'package:im_app/core/foundation/constants.dart';
+import 'package:im_app/core/foundation/device_info.dart';
+import 'package:im_app/core/foundation/errors.dart';
+import 'package:im_app/core/foundation/utils.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((ref) {
- final monitor = NetworkMonitor(
- onLog: (message, {tag}) {
- // ignore: avoid_print
- print('[${tag ?? 'Network'}] $message');
- },
- );
+ final monitor = NetworkMonitor(onLog: _makeLogger('Network'));
ref.onDispose(() {
monitor.dispose();
@@ -80,15 +77,13 @@ final apiConfigProvider = Provider((ref) {
return ApiConfig(
baseURL: AppConfig.apiBaseUrl,
platformHeaders: {
- 'Platform': 'Android', // TODO: 运行时从 platform API 获取
- 'client-version': '1.0.0', // TODO: 运行时从 package_info 获取
- 'Channel': '', // TODO: 从 AppConfig 读取渠道标识
- 'lang': 'zh-CN', // TODO: 从 l10n_sdk 或系统 locale 动态获取
- },
- tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
- forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
- onForceLogout: () {
- // TODO: 清除登录态,跳转登录页
+ 'platform': DeviceInfo.platform,
+ 'os-type': DeviceInfo.osType.toString(),
+ 'client-version': AppConfig.appVersion,
+ 'channel': AppConfig.channel,
+ 'lang': DeviceInfo.lang,
+ 'device-id': DeviceInfo.deviceId,
+ 'device-name': DeviceInfo.deviceName,
},
onTokenRefresh: () async {
// TODO: App 层刷新 token 逻辑
@@ -98,7 +93,7 @@ final apiConfigProvider = Provider((ref) {
// 通过事件流同步到 WebSocket,避免直接引用 socketManagerProvider 造成循环依赖
tokenStream.add(newToken);
},
- onCheckNetworkAvailable: () async => networkMonitor.isConnected,
+ onCheckNetworkAvailable: _checkNetwork(networkMonitor),
// TODO: 接入 cipher_guard_sdk 后注入请求加密回调。
// 前提:AuthNotifier.login() 中已完成 cipherSdk.setActiveKeyPair(pub, priv)。
// 示例:
@@ -117,16 +112,43 @@ final apiConfigProvider = Provider((ref) {
// return jsonDecode(plaintext) as Map;
// },
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:
null, // TODO: 如后端响应格式非标准,在此归一化为 { code, data, message }
onGetTokenExpiry: parseJwtExpiry,
maxRetries: AppConstants.maxRetries,
retryBaseDelay: AppConstants.retryBaseDelay,
- onLog: (message, {tag}) {
- // ignore: avoid_print
- print('[${tag ?? 'Network'}] $message');
- },
+ onLog: _makeLogger('Network'),
);
});
@@ -146,48 +168,30 @@ final networkSdkApiProvider = Provider((ref) {
/// SDK 内部不调用其他 SDK。
final _socketConfigProvider = Provider((ref) {
final networkMonitor = ref.read(networkMonitorProvider);
+ final apiConfig = ref.read(apiConfigProvider);
return SocketConfig(
maxReconnectAttempts: AppConstants.maxRetries,
maxReconnectDelay: AppConstants.maxReconnectDelay,
unlimitedReconnect: true, // IM 场景始终保持连接
- onBuildConnectUrl:
- null, // TODO: 接入 cipher_guard_sdk 后注入 WS URL 加密(路径/token/cipher 参数)
+ // 接入 cipher_guard_sdk 后改为 cipher=true&type=mode3
+ onBuildConnectUrl: (url, token) {
+ final uri = Uri.parse(url);
+ final params = {
+ ...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 后注入消息加密回调
onDecryptMessage: null, // TODO: 接入 cipher_guard_sdk 后注入消息解密回调
- onBeforeReconnect: () async {
- // SocketClient 内部重连(心跳超时、stream onDone)前调用。
- // 与 SocketManager.onBeforeReconnect 职责相同:检查 token 并按需刷新。
- // 刷新后通过 sync stream 同步传播到 SocketClient._currentToken,
- // 确保随后的 _doConnect() 使用新 token。
- 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;
- },
+ // SocketClient 内部重连(心跳超时 / stream onDone)前调用
+ onBeforeReconnect: () =>
+ _proactiveTokenRefresh(apiConfig, logTag: 'Socket'),
+ onLog: _makeLogger('Socket'),
+ onCheckNetworkAvailable: _checkNetwork(networkMonitor),
);
});
@@ -232,32 +236,11 @@ final socketManagerProvider = Provider((ref) {
wsUrl: _buildWsUrl(AppConfig.apiBaseUrl),
disconnectInBackground: false, // 所有平台后台保活,心跳不停、连接不断
onMessageTransform: null, // TODO: 接入加解密 SDK 后注入解密回调
- onBeforeReconnect: () async {
- // 重连前检查 token 是否即将过期,是则主动刷新
- 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(
- '[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');
- },
+ // SocketManager 层重连(前台恢复 / 网络恢复)前调用
+ onBeforeReconnect: () =>
+ _proactiveTokenRefresh(apiConfig, logTag: 'SocketManager'),
+ onCheckNetworkAvailable: _checkNetwork(networkMonitor),
+ onLog: _makeLogger('SocketManager'),
);
// 监听 token 更新事件 → 同步到 WebSocket
@@ -281,6 +264,47 @@ final socketManagerProvider = Provider((ref) {
// ── 辅助 ──────────────────────────────────────────────────────────────────────
+/// 日志回调工厂,各模块传自己的默认 tag
+///
+/// SDK 内部调用 onLog 时通常已传 tag,defaultTag 仅作兜底。
+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 _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 转换
///
/// https://api.example.com → wss://api.example.com/ws
@@ -392,9 +416,9 @@ String _buildWsUrl(String httpBaseUrl) {
// 「一个接口 = 一个 Request 文件」,严格按层调用,禁止跳层。
//
// ┌──────────────────────────────────────────────────────────────────────────┐
-// │ 文件 & 职责总览 │
+// │ 文件 & 职责总览 │
// ├──────────────────────────────────────────────────────────────────────────┤
-// │ login_request.dart Request + Response DTO(一个端点一个文件) │
+// │ login_request.dart Request + Response DTO(一个端点一个文件) │
// │ auth_repository_impl.dart executeRequest → DTO → Entity + 回调写 Token│
// │ login_usecase.dart 格式校验 → 调 Repository(按需,非必须) │
// │ auth_providers.dart DI 装配(Repository → UseCase 按需) │
diff --git a/apps/im_app/lib/app/router/app_router.dart b/apps/im_app/lib/app/router/app_router.dart
index 2a46496..7ceb675 100644
--- a/apps/im_app/lib/app/router/app_router.dart
+++ b/apps/im_app/lib/app/router/app_router.dart
@@ -3,16 +3,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:im_app/features/chat/view/chat_db_test_page.dart';
-import '../../features/app_tab/view/app_tab.dart';
-import '../../features/chat/view/chat_detail_page.dart';
-import '../../features/chat/view/chat_page.dart';
-import '../../features/contact/view/contact_page.dart';
-import '../../features/login/view/login_page.dart';
-import '../../features/settings/view/settings_page.dart';
-import '../../features/settings/view/theme_view.dart';
-import '../di/app_providers.dart';
-import 'app_route_name.dart';
-import 'guards/auth_guard.dart';
+import 'package:im_app/features/app_tab/view/app_tab.dart';
+import 'package:im_app/features/chat/view/chat_detail_page.dart';
+import 'package:im_app/features/chat/view/chat_page.dart';
+import 'package:im_app/features/contact/view/contact_page.dart';
+import 'package:im_app/features/login/view/login_page.dart';
+import 'package:im_app/features/settings/view/settings_page.dart';
+import 'package:im_app/features/settings/view/theme_view.dart';
+import 'package:im_app/app/di/app_providers.dart';
+import 'package:im_app/app/router/app_route_name.dart';
+import 'package:im_app/app/router/guards/auth_guard.dart';
/// 应用路由 Provider
///
diff --git a/apps/im_app/lib/app/router/guards/.gitkeep b/apps/im_app/lib/app/router/guards/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/im_app/lib/app/router/guards/auth_guard.dart b/apps/im_app/lib/app/router/guards/auth_guard.dart
index 1636457..1cb5ce1 100644
--- a/apps/im_app/lib/app/router/guards/auth_guard.dart
+++ b/apps/im_app/lib/app/router/guards/auth_guard.dart
@@ -1,7 +1,7 @@
import 'package:go_router/go_router.dart';
-import '../../di/app_providers.dart';
-import '../app_route_name.dart';
+import 'package:im_app/app/di/app_providers.dart';
+import 'package:im_app/app/router/app_route_name.dart';
/// 登录守卫
///
diff --git a/apps/im_app/lib/core/foundation/api_paths.dart b/apps/im_app/lib/core/foundation/api_paths.dart
index cbf1bdf..aa045d0 100644
--- a/apps/im_app/lib/core/foundation/api_paths.dart
+++ b/apps/im_app/lib/core/foundation/api_paths.dart
@@ -11,21 +11,23 @@ class ApiPaths {
ApiPaths._();
// ── Auth ──
- static const authLogin = '/auth/login';
- static const authRefreshToken = '/auth/refresh-token';
- static const authLogout = '/auth/logout';
+ static const authSendOtp = '/app/api/auth/vcode/get';
+ static const authVerifyOtp = '/app/api/auth/vcode/check';
+ static const authLogin = '/app/api/auth/login-user';
+ static const authRefreshToken = '/app/api/auth/refresh-token';
+ static const authLogout = '/app/api/auth/logout';
// ── User ──
- static const userProfile = '/user/profile';
- static const userUpdateProfile = '/user/update-profile';
+ static const userProfile = '/app/api/user/profile';
+ static const userUpdateProfile = '/app/api/user/update-profile';
// ── Chat ──
- static const chatSendMessage = '/chat/send-message';
- static const chatHistory = '/chat/history';
+ static const chatSendMessage = '/app/api/chat/send-message';
+ static const chatHistory = '/app/api/chat/history';
// ── Upload ──
- static const uploadFile = '/upload/file';
+ static const uploadFile = '/app/api/upload/file';
// ── WebSocket ──
- static const wsConnect = '/ws';
+ static const wsConnect = '/websock/open';
}
diff --git a/apps/im_app/lib/core/foundation/config.dart b/apps/im_app/lib/core/foundation/config.dart
index f9ea7a7..4e2a145 100644
--- a/apps/im_app/lib/core/foundation/config.dart
+++ b/apps/im_app/lib/core/foundation/config.dart
@@ -7,7 +7,14 @@ class AppConfig {
static const isDev = bool.fromEnvironment('IS_DEV', defaultValue: true);
static const apiBaseUrl = String.fromEnvironment(
'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;
diff --git a/apps/im_app/lib/core/foundation/device_info.dart b/apps/im_app/lib/core/foundation/device_info.dart
new file mode 100644
index 0000000..7d2ec21
--- /dev/null
+++ b/apps/im_app/lib/core/foundation/device_info.dart
@@ -0,0 +1,108 @@
+import 'dart:io';
+import 'dart:ui';
+
+import 'package:device_info_plus/device_info_plus.dart';
+
+/// 设备 / 运行时信息 — 用于构建 HTTP 请求头中的平台字段
+///
+/// 同步 getter(platform / 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 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;
+ }
+
+ /// 设备唯一标识
+ ///
+ /// Android:Build.ID(非持久化硬件 ID,可作为临时标识;如需稳定 ID 后续接入 android_id)
+ /// iOS:identifierForVendor
+ /// macOS:systemGUID
+ /// Windows:deviceId
+ static Future 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 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 '';
+ }
+}
diff --git a/apps/im_app/lib/core/foundation/errors.dart b/apps/im_app/lib/core/foundation/errors.dart
index 983c096..fff0c69 100644
--- a/apps/im_app/lib/core/foundation/errors.dart
+++ b/apps/im_app/lib/core/foundation/errors.dart
@@ -36,22 +36,9 @@ class ApiErrorCodes {
/// 账号在其他设备登录
static const int loggedInAnotherDevice = 30006;
- // ── 错误码集合 ──
+ // ── 验证码(30170-30179)──
- /// Token 过期错误码集合 — 触发自动刷新 Token
- static const Set tokenExpiredCodes = {
- tokenInvalid,
- jwtInvalid,
- sessionInvalid,
- };
+ /// 触发图片验证:data 含各平台 CAPTCHA token(android / ios / web)
+ static const int captchaRequired = 30174;
- /// 强制登出错误码集合 — 触发退出登录流程
- static const Set forceLogoutCodes = {refreshTokenFailed};
-
- /// 踢下线错误码集合 — 触发踢下线 UI 提示
- static const Set kickOffCodes = {
- loggedInAnotherDevice,
- signingMethodError,
- parsingKeyError,
- };
}
diff --git a/apps/im_app/lib/core/presentation/request_guard.dart b/apps/im_app/lib/core/presentation/request_guard.dart
new file mode 100644
index 0000000..41160dc
--- /dev/null
+++ b/apps/im_app/lib/core/presentation/request_guard.dart
@@ -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 {
+///
+/// Future 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 on Notifier {
+ /// 执行 [call],捕获 [ApiError] 后调用 [onError] 写入错误文案,返回 null。
+ /// 成功时返回原始结果,ViewModel 用返回值是否为 null 判断走哪条路径。
+ Future guard(
+ Future Function() call, {
+ required void Function(String message) onError,
+ }) async {
+ try {
+ return await call();
+ } on ApiError catch (e) {
+ onError(e.displayMessage);
+ return null;
+ }
+ }
+}
diff --git a/apps/im_app/lib/core/services/socket_manager.dart b/apps/im_app/lib/core/services/socket_manager.dart
index efc1d8b..8491632 100644
--- a/apps/im_app/lib/core/services/socket_manager.dart
+++ b/apps/im_app/lib/core/services/socket_manager.dart
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:networks_sdk/networks_sdk.dart';
-import 'network_backoff_debouncer.dart';
+import 'package:im_app/core/services/network_backoff_debouncer.dart';
/// 消息预处理回调
///
diff --git a/apps/im_app/lib/core/ui/base/app_theme.dart b/apps/im_app/lib/core/ui/base/app_theme.dart
index 3bab13c..ed6aeb0 100644
--- a/apps/im_app/lib/core/ui/base/app_theme.dart
+++ b/apps/im_app/lib/core/ui/base/app_theme.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
-import 'colors.dart';
-import 'font.dart';
+import 'package:im_app/core/ui/base/colors.dart';
+import 'package:im_app/core/ui/base/font.dart';
/// 主题组装 -- 将 AppColors / AppFont 组装为 ThemeData
///
diff --git a/apps/im_app/lib/core/ui/base/context_theme_ext.dart b/apps/im_app/lib/core/ui/base/context_theme_ext.dart
index fb2f15c..2c1c18c 100644
--- a/apps/im_app/lib/core/ui/base/context_theme_ext.dart
+++ b/apps/im_app/lib/core/ui/base/context_theme_ext.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import 'font.dart';
+import 'package:im_app/core/ui/base/font.dart';
/// 主题样式快捷封装
///
diff --git a/apps/im_app/lib/core/ui/components/app_button.dart b/apps/im_app/lib/core/ui/components/app_button.dart
index 11019b9..0373a72 100644
--- a/apps/im_app/lib/core/ui/components/app_button.dart
+++ b/apps/im_app/lib/core/ui/components/app_button.dart
@@ -1,6 +1,6 @@
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)
///
@@ -117,12 +117,9 @@ class AppButton extends StatelessWidget {
Widget _buildInverse(BuildContext context, Widget label) {
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(
- backgroundColor: bg,
- foregroundColor: fg,
+ backgroundColor: s.onSurface,
+ foregroundColor: s.surface,
);
if (icon != null) {
return FilledButton.icon(
diff --git a/apps/im_app/lib/core/ui/composites/app_dialog.dart b/apps/im_app/lib/core/ui/composites/app_dialog.dart
index f36946e..6491370 100644
--- a/apps/im_app/lib/core/ui/composites/app_dialog.dart
+++ b/apps/im_app/lib/core/ui/composites/app_dialog.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import '../components/app_button.dart';
+import 'package:im_app/core/ui/components/app_button.dart';
/// # AppDialog — 业务确认弹窗(L3 Composite)
///
diff --git a/apps/im_app/lib/data/remote/get_profile_request.dart b/apps/im_app/lib/data/remote/get_profile_request.dart
index c146970..3dd3c10 100644
--- a/apps/im_app/lib/data/remote/get_profile_request.dart
+++ b/apps/im_app/lib/data/remote/get_profile_request.dart
@@ -1,8 +1,8 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
-import '../../../core/foundation/api_paths.dart';
-import '../../../domain/entities/user.dart';
+import 'package:im_app/core/foundation/api_paths.dart';
+import 'package:im_app/domain/entities/user.dart';
part 'get_profile_request.g.dart';
diff --git a/apps/im_app/lib/data/remote/login_request.dart b/apps/im_app/lib/data/remote/login_request.dart
index eaacb11..37e02d2 100644
--- a/apps/im_app/lib/data/remote/login_request.dart
+++ b/apps/im_app/lib/data/remote/login_request.dart
@@ -1,21 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:networks_sdk/networks_sdk.dart';
-import '../../../core/foundation/api_paths.dart';
-import '../../../domain/entities/user.dart';
+import 'package:im_app/core/foundation/api_paths.dart';
+import 'package:im_app/domain/entities/user.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 ★ ) ← 你在这里
-/// → 服务端 POST /auth/login
-/// → SDK 内部 ApiResponseWrapper 拆包 { code, message, data }
-/// → ★ LoginResponse ★ = data 字段,T in APIResponseWrapper ← 也在这里
+/// → 服务端 POST /app/api/auth/login-user
+/// → SDK 拆包 {code, message, data} envelope
+/// → ★ LoginResponse ★ ← 也在这里
/// → LoginResponse.toEntity() → User
/// ```
@@ -100,24 +103,27 @@ class LoginResponse {
@JsonKey(name: 'account_id')
final String accountId;
final LoginProfile profile;
- final String nonce;
@JsonKey(name: 'access_token')
final String accessToken;
@JsonKey(name: 'refresh_token')
final String refreshToken;
@JsonKey(name: 'device_id')
final String deviceId;
+ final String nonce;
@JsonKey(name: 'login_data')
final String loginData;
+ @JsonKey(name: 'is_verified')
+ final bool? isVerified;
const LoginResponse({
required this.accountId,
required this.profile,
- required this.nonce,
required this.accessToken,
required this.refreshToken,
required this.deviceId,
- required this.loginData,
+ this.nonce = '',
+ this.loginData = '',
+ this.isVerified,
});
User toEntity() => profile.toEntity();
@@ -127,11 +133,10 @@ class LoginResponse {
// Request
// ─────────────────────────────────────────────
-/// 登录请求
+/// 使用 vcode_token 完成登录的请求
///
-/// `@ApiRequest` 一个注解搞定一切:
-/// - mixin 自动生成 path / method / requestType / includeToken / toJson
-/// - parameters getter 自动注册 `_$LoginResponseFromJson` 到 SDK 全局注册表
+/// 上游:[VerifyOtpRequest] 返回的 `token` 即 vcodeToken。
+/// 成功后 [LoginResponse.accessToken] 写入 ApiConfig,后续请求自动携带。
@ApiRequest(
path: ApiPaths.authLogin,
method: HttpMethod.post,
@@ -140,8 +145,15 @@ class LoginResponse {
)
class LoginRequest extends ApiRequestable
with _$LoginRequestApi {
- final String email;
- final String password;
+ @JsonKey(name: 'country_code')
+ 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,
+ });
}
diff --git a/apps/im_app/lib/data/remote/logout_request.dart b/apps/im_app/lib/data/remote/logout_request.dart
index 651f940..7640118 100644
--- a/apps/im_app/lib/data/remote/logout_request.dart
+++ b/apps/im_app/lib/data/remote/logout_request.dart
@@ -1,6 +1,6 @@
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';
diff --git a/apps/im_app/lib/data/remote/send_otp_request.dart b/apps/im_app/lib/data/remote/send_otp_request.dart
new file mode 100644
index 0000000..563a129
--- /dev/null
+++ b/apps/im_app/lib/data/remote/send_otp_request.dart
@@ -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? 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
+ 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,
+ });
+}
diff --git a/apps/im_app/lib/data/remote/upload_file_request.dart b/apps/im_app/lib/data/remote/upload_file_request.dart
index 7917ca4..f2d0849 100644
--- a/apps/im_app/lib/data/remote/upload_file_request.dart
+++ b/apps/im_app/lib/data/remote/upload_file_request.dart
@@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:json_annotation/json_annotation.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';
diff --git a/apps/im_app/lib/data/remote/verify_otp_request.dart b/apps/im_app/lib/data/remote/verify_otp_request.dart
new file mode 100644
index 0000000..7415ea9
--- /dev/null
+++ b/apps/im_app/lib/data/remote/verify_otp_request.dart
@@ -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
+ 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,
+ });
+}
diff --git a/apps/im_app/lib/data/repositories/auth_repository_impl.dart b/apps/im_app/lib/data/repositories/auth_repository_impl.dart
index afdec66..8194e4c 100644
--- a/apps/im_app/lib/data/repositories/auth_repository_impl.dart
+++ b/apps/im_app/lib/data/repositories/auth_repository_impl.dart
@@ -1,27 +1,34 @@
import 'package:networks_sdk/networks_sdk.dart';
-import '../../domain/entities/user.dart';
-import '../../domain/repositories/auth_repository.dart';
-import '../remote/login_request.dart';
-import '../remote/logout_request.dart';
+import 'package:im_app/domain/entities/user.dart';
+import 'package:im_app/domain/repositories/auth_repository.dart';
+import 'package:im_app/data/remote/login_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 实现
///
/// implements [AuthRepository] 接口(domain/repositories/ 中定义)。
/// 直接使用 [NetworksSdkApi] 发送请求,将 DTO 转为 Domain Entity。
-/// 后续可加 Local DataSource 实现离线缓存。
///
-/// ## 数据流位置
+/// ## 登录流程
///
/// ```
-/// LoginUseCase.execute(email, password)
-/// → ★ AuthRepositoryImpl.login() ★ ← 你在这里
+/// LoginUseCase.sendOtp(countryCode, contact)
+/// → ★ 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)
-/// → 服务端 POST /auth/login
-/// ← LoginResponse(SDK 已拆包 { code, message, data } envelope)
-/// → _onTokenUpdate(accessToken) ← 回调写入 Token
-/// ← LoginResponse.toEntity() → User ← DTO → Entity 转换在这里
-/// ← User(Domain Entity)
+/// → 服务端 POST /app/api/auth/login-user
+/// ← LoginResponse → _onTokenUpdate(accessToken) → User
/// ```
class AuthRepositoryImpl implements AuthRepository {
final NetworksSdkApi _client;
@@ -34,29 +41,62 @@ class AuthRepositoryImpl implements AuthRepository {
_onTokenUpdate = onTokenUpdate;
@override
- Future login({required String email, required String password}) async {
- final LoginResponse? loginResponse = await _client.executeRequest(
- LoginRequest(email: email, password: password),
+ Future sendOtp({
+ required String countryCode,
+ 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
- Future getCurrentUser() async {
- // TODO: 从本地存储获取用户信息
- return null;
+ Future verifyOtp({
+ required String countryCode,
+ 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 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
Future logout() async {
await _client.executeRequest(LogoutRequest());
- _onTokenUpdate(null); // 回调清除 Token(内存 + 持久化由 Provider 层组合)
+ _onTokenUpdate(null); // 清除 Token(内存 + 持久化由 Provider 层组合)
}
}
diff --git a/apps/im_app/lib/domain/repositories/auth_repository.dart b/apps/im_app/lib/domain/repositories/auth_repository.dart
index c6cd7f4..2940615 100644
--- a/apps/im_app/lib/domain/repositories/auth_repository.dart
+++ b/apps/im_app/lib/domain/repositories/auth_repository.dart
@@ -1,25 +1,43 @@
-import '../entities/user.dart';
+import 'package:im_app/domain/entities/user.dart';
/// 认证 Repository 接口(依赖倒置)
///
/// Domain 层定义 What,Data 层实现 How。
-/// ViewModel 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。
+/// UseCase 依赖此接口,不依赖具体实现 [AuthRepositoryImpl]。
///
-/// ## 数据流位置
+/// ## 登录三步流程
///
/// ```
-/// ViewModel
-/// → ★ AuthRepository.login() ★ ← 你在这里(接口)
-/// → AuthRepositoryImpl.login() ← data/repositories/(实现)
-/// → _client.executeRequest(LoginRequest)
-/// → 服务端
+/// 1. sendOtp(countryCode, contact) → 发送验证码短信
+/// 2. verifyOtp(countryCode, contact, code) → 校验验证码,返回 vcode_token
+/// 3. login(countryCode, contact, vcodeToken) → 用 vcode_token 换 access_token,返回 User
/// ```
abstract interface class AuthRepository {
- /// 登录,返回 Domain Entity [User]
- Future login({required String email, required String password});
+ /// 发送手机验证码短信
+ ///
+ /// 抛 [ApiError] 表示发送失败(手机号格式错误、频率限制等)。
+ Future sendOtp({
+ required String countryCode,
+ required String contact,
+ });
- /// 获取当前登录用户信息
- Future getCurrentUser();
+ /// 校验验证码,成功返回 vcode_token
+ ///
+ /// vcode_token 用于 [login] 的 vcodeToken 参数。
+ Future verifyOtp({
+ required String countryCode,
+ required String contact,
+ required String code,
+ });
+
+ /// 用 vcode_token 完成登录,返回 Domain Entity [User]
+ ///
+ /// 成功后内部回调写入 access_token,后续请求自动携带。
+ Future login({
+ required String countryCode,
+ required String contact,
+ required String vcodeToken,
+ });
/// 退出登录
Future logout();
diff --git a/apps/im_app/lib/features/chat/presentation/chat_view_model.dart b/apps/im_app/lib/features/chat/presentation/chat_view_model.dart
index b5589e1..88f5b83 100644
--- a/apps/im_app/lib/features/chat/presentation/chat_view_model.dart
+++ b/apps/im_app/lib/features/chat/presentation/chat_view_model.dart
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
-import '../../../app/di/app_providers.dart';
-import '../../../app/router/app_route_name.dart';
+import 'package:im_app/app/di/app_providers.dart';
+import 'package:im_app/app/router/app_route_name.dart';
part 'chat_view_model.g.dart';
diff --git a/apps/im_app/lib/features/chat/view/chat_db_test_page.dart b/apps/im_app/lib/features/chat/view/chat_db_test_page.dart
index fe3f90f..6337ac6 100644
--- a/apps/im_app/lib/features/chat/view/chat_db_test_page.dart
+++ b/apps/im_app/lib/features/chat/view/chat_db_test_page.dart
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 {
const ChatDbTestPage({super.key});
diff --git a/apps/im_app/lib/features/chat/view/chat_detail_page.dart b/apps/im_app/lib/features/chat/view/chat_detail_page.dart
index 23709ec..4270356 100644
--- a/apps/im_app/lib/features/chat/view/chat_detail_page.dart
+++ b/apps/im_app/lib/features/chat/view/chat_detail_page.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.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)
///
diff --git a/apps/im_app/lib/features/chat/view/chat_page.dart b/apps/im_app/lib/features/chat/view/chat_page.dart
index 590a65b..a3bedbc 100644
--- a/apps/im_app/lib/features/chat/view/chat_page.dart
+++ b/apps/im_app/lib/features/chat/view/chat_page.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../../core/ui/components/app_button.dart';
-import '../presentation/chat_view_model.dart';
+import 'package:im_app/core/ui/components/app_button.dart';
+import 'package:im_app/features/chat/presentation/chat_view_model.dart';
/// 聊天页(Demo 按钮)
///
diff --git a/apps/im_app/lib/features/login/di/auth_providers.dart b/apps/im_app/lib/features/login/di/auth_providers.dart
index 8802f93..1117074 100644
--- a/apps/im_app/lib/features/login/di/auth_providers.dart
+++ b/apps/im_app/lib/features/login/di/auth_providers.dart
@@ -1,10 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../../app/di/network_provider.dart';
-import '../../../app/di/db_provider.dart';
-import '../../../data/repositories/auth_repository_impl.dart';
-import '../../../domain/repositories/auth_repository.dart';
-import '../usecases/login_usecase.dart';
+import 'package:im_app/app/di/network_provider.dart';
+import 'package:im_app/app/di/db_provider.dart';
+import 'package:im_app/data/repositories/auth_repository_impl.dart';
+import 'package:im_app/domain/repositories/auth_repository.dart';
+import 'package:im_app/features/login/usecases/login_usecase.dart';
/// ## DI 装配:Auth Feature 层
///
diff --git a/apps/im_app/lib/features/login/presentation/login_state.dart b/apps/im_app/lib/features/login/presentation/login_state.dart
index fb11512..6aef117 100644
--- a/apps/im_app/lib/features/login/presentation/login_state.dart
+++ b/apps/im_app/lib/features/login/presentation/login_state.dart
@@ -1,9 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
-import '../../../domain/entities/user.dart';
-
part 'login_state.freezed.dart';
+/// 登录流程的当前步骤
+enum LoginStep {
+ /// 步骤 1:输入手机号
+ phone,
+
+ /// 步骤 2:输入验证码
+ otp,
+}
+
/// 登录页面状态(@freezed 自动生成 copyWith / == / toString)
///
/// 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
-/// 登录成功 → state.copyWith(user: user) isLoading: false, user: User
-/// 格式错误 → state.copyWith(error: '邮箱格式不正确') isLoading: false, error: String
-/// 网络错误 → state.copyWith(error: '网络错误') isLoading: false, error: String
+/// 初始
+/// → LoginState() step: phone, isLoading: false
+/// 点击"获取验证码"
+/// → state.copyWith(isLoading: true)
+/// → 成功: 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
sealed class LoginState with _$LoginState {
- const factory LoginState({
- /// 登录成功后的用户信息(null = 未登录)
- User? user,
+ const LoginState._();
- /// 是否正在请求中(控制 loading 状态 / 按钮禁用)
+ const factory LoginState({
+ /// 当前步骤(手机号输入 or 验证码输入)
+ @Default(LoginStep.phone) LoginStep step,
+
+ /// 国家代码(默认 +65,暂不支持切换)
+ @Default('+65') String countryCode,
+
+ /// 已提交的手机号(步骤 2 用于显示和构建请求)
+ @Default('') String contact,
+
+ /// 是否正在请求中
@Default(false) bool isLoading,
/// 错误信息(null = 无错误)
String? error,
}) = _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';
+ }
}
diff --git a/apps/im_app/lib/features/login/presentation/login_view_model.dart b/apps/im_app/lib/features/login/presentation/login_view_model.dart
index 3ae37a0..8c2074a 100644
--- a/apps/im_app/lib/features/login/presentation/login_view_model.dart
+++ b/apps/im_app/lib/features/login/presentation/login_view_model.dart
@@ -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:riverpod_annotation/riverpod_annotation.dart';
-import 'package:storage_sdk/storage_sdk.dart';
-import '../../../app/di/app_providers.dart';
-import '../di/auth_providers.dart';
-import 'login_state.dart';
+import 'package:im_app/app/di/app_providers.dart';
+import 'package:im_app/features/login/di/auth_providers.dart';
+import 'package:im_app/features/login/presentation/login_state.dart';
part 'login_view_model.g.dart';
/// 登录 ViewModel(@riverpod 自动生成 `loginViewModelProvider`)
///
-/// `@riverpod` 注解 → build_runner 自动生成 `login_view_model.g.dart`,
-/// 其中包含 `loginViewModelProvider`。View 层直接使用:
+/// 管理两步登录流程:手机号 → 验证码 → 完成登录。
///
/// ```dart
/// // View 层读取状态
/// final state = ref.watch(loginViewModelProvider);
///
/// // 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(authRepositoryProvider) ← 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
class LoginViewModel extends _$LoginViewModel {
@override
LoginState build() => const LoginState();
- /// Demo 登录(跳过 API,直接设置登录状态)
+ /// 步骤 1:发送手机验证码
///
- /// 仅用于演示路由守卫行为,正式 UI 就绪后删除此方法,改用 [login]。
- /// 正式 [login] 成功后同样需要调用 [AuthNotifier.login] 更新守卫状态。
- Future demoLogin() async {
- // 防止连点重入:第一次调用未完成前忽略后续调用
+ /// 成功后 step 切换为 [LoginStep.otp],手机号保存到 state 供步骤 2 使用。
+ Future sendOtp(String countryCode, String contact) async {
if (state.isLoading) return;
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 {
- // 读取 mock 数据(loginData.json 结构: { code, message, data: {...} })
- // 手动拆包 data 字段,对应 SDK 内部 ApiResponseWrapper 的行为
- final raw = await rootBundle.loadString('assets/loginData.json');
- final json = jsonDecode(raw) as Map;
- final data = json['data'] as Map;
- final profile = data['profile'] as Map;
- // 生成器生成的 _$XFromJson 是 library 私有函数,外部不可调用。
- // Demo 场景直接从 JSON 字段构建 User,不依赖生成的 fromJson。
- final user = User(
- uid: profile['uid'] as int,
- 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,
+ await ref
+ .read(loginUseCaseProvider)
+ .sendOtp(countryCode: countryCode, contact: contact);
+
+ if (!ref.mounted) return;
+ state = state.copyWith(
+ step: LoginStep.otp,
+ countryCode: countryCode,
+ contact: contact,
+ isLoading: false,
);
-
- // 先完成 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 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) {
- // 格式校验失败(UseCase 层抛出)
+ if (!ref.mounted) return;
state = state.copyWith(error: e.message, isLoading: false);
} on ApiError catch (e) {
- // 网络 / 服务端错误(Repository → SDK 透传)
+ if (!ref.mounted) return;
state = state.copyWith(error: e.displayMessage, isLoading: false);
} catch (e) {
- // 兜底:防止未预期的异常导致 isLoading 死锁
+ if (!ref.mounted) return;
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
+
+ /// 步骤 2+3:校验验证码并完成登录
+ ///
+ /// 成功后调用 [AuthNotifier.login] 触发路由守卫重定向,provider 随即被 dispose。
+ Future 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);
+ }
}
diff --git a/apps/im_app/lib/features/login/usecases/login_usecase.dart b/apps/im_app/lib/features/login/usecases/login_usecase.dart
index 68e76f5..b47fffb 100644
--- a/apps/im_app/lib/features/login/usecases/login_usecase.dart
+++ b/apps/im_app/lib/features/login/usecases/login_usecase.dart
@@ -1,38 +1,36 @@
import 'package:networks_sdk/networks_sdk.dart';
import 'package:storage_sdk/storage_sdk.dart';
-import '../../../core/services/socket_manager.dart';
-import '../../../domain/entities/user.dart';
-import '../../../domain/repositories/auth_repository.dart';
+import 'package:im_app/core/services/socket_manager.dart';
+import 'package:im_app/domain/entities/user.dart';
+import 'package:im_app/domain/repositories/auth_repository.dart';
/// 登录用例
///
/// 封装登录的完整业务流程:
-/// 格式校验 → 调 Repository 登录 → 初始化 WebSocket → 打开本地数据库 → 返回 User
+/// - sendOtp:格式校验 → 发短信
+/// - verifyAndLogin:格式校验 → 校验验证码 → 登录 → 初始化 WebSocket → 打开本地数据库
///
/// ## 为什么需要 UseCase?
///
-/// ViewModel 直接调 Repository 也能跑通,但登录有明确的多步业务规则:
-/// - 格式校验(不发无效请求,省流量、减少服务端压力)
-/// - 登录后初始化 WebSocket 连接
-/// - 登录后按 user id 打开对应的本地数据库
-///
-/// 把这些规则封装在 UseCase 里,ViewModel 只需一行调用。
+/// 登录有明确的多步业务规则,UseCase 把这些规则集中封装,
+/// ViewModel 只需一行调用。
///
/// ## 数据流位置
///
/// ```
-/// LoginViewModel.login(email, password)
-/// → ★ LoginUseCase.execute() ★ ← 你在这里
-/// → 格式校验(邮箱 + 密码)
-/// → AuthRepository.login()
-/// → AuthRepositoryImpl.login()
-/// → _client.executeRequest(LoginRequest)
-/// ← LoginResponse(SDK 已拆包 envelope)
-/// → _onTokenUpdate(accessToken) ← 回调写入 Token(内存 + 持久化,由 Provider 层组合)
-/// ← LoginResponse.toEntity() → User
-/// → SocketManager.connect(token) ← 登录后连接 WebSocket
-/// → StorageSdkApi.openDatabase(user.id) ← 按用户 id 打开本地库
+/// LoginViewModel.sendOtp(countryCode, contact)
+/// → ★ LoginUseCase.sendOtp() ★ ← 你在这里(步骤 1)
+/// → 格式校验(手机号)
+/// → AuthRepository.sendOtp()
+///
+/// LoginViewModel.verifyAndLogin(code)
+/// → ★ LoginUseCase.verifyAndLogin() ★ ← 你在这里(步骤 2+3)
+/// → 格式校验(验证码)
+/// → AuthRepository.verifyOtp() → vcode_token
+/// → AuthRepository.login() → User + token
+/// → SocketManager.connect(token)
+/// → StorageSdkApi.openDatabase(uid)
/// ← User
/// ```
class LoginUseCase {
@@ -54,62 +52,84 @@ class LoginUseCase {
_apiConfig = apiConfig,
_storageApi = storageApi;
- /// 执行登录
- ///
- /// 1. 格式校验 → 不合法直接抛 [FormatException]
- /// 2. 调 Repository 登录 → 拿到 User(token 写入由 Repository 处理)
- /// 3. 用已存入 ApiConfig 的 token 连接 WebSocket
- /// 4. 按 user id 打开本地数据库
+ /// 步骤 1:发送手机验证码
///
/// 抛出:
- /// - [FormatException] — 邮箱或密码格式不合法
- /// - [ApiError] — 网络/服务端错误(由 Repository 透传)
- Future execute({
- required String email,
- required String password,
+ /// - [FormatException] — 手机号格式不合法
+ /// - [ApiError] — 网络/服务端错误
+ Future sendOtp({
+ required String countryCode,
+ required String contact,
}) async {
- // ── 1. 格式校验 ──
- _validateEmail(email);
- _validatePassword(password);
+ _validatePhone(contact);
+ await _authRepository.sendOtp(
+ countryCode: countryCode,
+ contact: contact,
+ );
+ }
- // ── 2. 登录 ──
- final user = await _authRepository.login(email: email, password: password);
+ /// 步骤 2+3:校验验证码并完成登录,返回 [User]
+ ///
+ /// 内部串行:verifyOtp → login → connectWebSocket → openDatabase
+ ///
+ /// 抛出:
+ /// - [FormatException] — 验证码格式不合法
+ /// - [ApiError] — 网络/服务端错误
+ Future verifyAndLogin({
+ required String countryCode,
+ required String contact,
+ required String code,
+ }) async {
+ _validateCode(code);
- // ── 3. 连接 WebSocket ──
- // token 在 Repository 的 _onTokenUpdate 回调中已写入 ApiConfig,
- // 此处直接读取,避免改动现有接口。
+ // 校验验证码,换取 vcode_token
+ 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,
+ );
+
+ // 连接 WebSocket(token 已由 Repository 写入 ApiConfig,直接读取)
final token = _apiConfig.token;
if (token != null && token.isNotEmpty) {
await _socketManager.connect(token: token);
}
- // ── 4. 打开数据库 ──
- // TODO: 当服务端返回整型 uid 时,换成 user.uid;目前用 hashCode 作为临时标识。
- await _storageLifeCycle.openDatabase(user.hashCode);
+ // 按用户 uid 打开本地数据库
+ await _storageLifeCycle.openDatabase(user.uid);
- // TODO: 后续扩展点
- // - 同步联系人列表
- // - 注册推送 token
+ // TODO: 扩展点 — 同步联系人列表、注册推送 token
return user;
}
- void _validateEmail(String email) {
- if (email.trim().isEmpty) {
- throw const FormatException('邮箱不能为空'); // TODO: 接入国际化
+ void _validatePhone(String contact) {
+ final trimmed = contact.trim();
+ if (trimmed.isEmpty) {
+ throw const FormatException('手机号不能为空'); // TODO: 接入国际化
}
- final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
- if (!emailRegex.hasMatch(email.trim())) {
- throw const FormatException('邮箱格式不正确'); // TODO: 接入国际化
+ if (trimmed.length < 7 || trimmed.length > 15) {
+ throw const FormatException('手机号长度不正确'); // TODO: 接入国际化
+ }
+ if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
+ throw const FormatException('手机号只能包含数字'); // TODO: 接入国际化
}
}
- void _validatePassword(String password) {
- if (password.isEmpty) {
- throw const FormatException('密码不能为空'); // TODO: 接入国际化
+ void _validateCode(String code) {
+ final trimmed = code.trim();
+ if (trimmed.isEmpty) {
+ throw const FormatException('验证码不能为空'); // TODO: 接入国际化
}
- if (password.length < 6) {
- throw const FormatException('密码长度不能少于 6 位'); // TODO: 接入国际化
+ if (!RegExp(r'^\d+$').hasMatch(trimmed)) {
+ throw const FormatException('验证码只能包含数字'); // TODO: l10n
}
}
}
diff --git a/apps/im_app/lib/features/login/view/login_page.dart b/apps/im_app/lib/features/login/view/login_page.dart
index c907581..6f40ce2 100644
--- a/apps/im_app/lib/features/login/view/login_page.dart
+++ b/apps/im_app/lib/features/login/view/login_page.dart
@@ -1,45 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../../../core/ui/base/context_theme_ext.dart';
-import '../presentation/login_view_model.dart';
+import 'package:im_app/features/login/presentation/login_state.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]
-/// 触发 [GoRouter.refreshListenable],守卫重新执行并重定向到 /chat。
+/// 步骤 1 [LoginStep.phone]:[LoginPhoneStep] — 输入国家代码 + 手机号
+/// 步骤 2 [LoginStep.otp]:[LoginOtpStep] — 输入验证码完成登录
///
-/// 正式实现时替换为完整登录流程(email/password 输入 → LoginViewModel.login)。
-class LoginPage extends ConsumerWidget {
+/// 页面本身只持有两个 TextEditingController 和三个回调方法,
+/// 具体 UI 由 widgets/ 下的子组件负责。
+class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
- // ref.watch 保持 loginViewModelProvider 存活(AutoDispose 需要至少一个监听者)
+ ConsumerState createState() => _LoginPageState();
+}
+
+class _LoginPageState extends ConsumerState {
+ // 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 s = context.styles;
return Scaffold(
- appBar: AppBar(title: const Text('登录'), automaticallyImplyLeading: false),
- body: Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text('IM_Demo', style: s.titleMedium),
- const SizedBox(height: 8),
- Text(
- '未登录时任意路由均被重定向到此页 \n 主要是为了展示路由守卫的功能 \n 后续路由守卫专门处理各种跳转前的逻辑判断',
- style: s.bodySmall,
- ),
- const SizedBox(height: 32),
- FilledButton(
- onPressed: state.isLoading
- ? null
- : () => ref.read(loginViewModelProvider.notifier).demoLogin(),
- child: const Text('登录'),
- ),
- ],
- ),
+ appBar: AppBar(automaticallyImplyLeading: false, title: const Text('登录')),
+ body: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: state.step == LoginStep.phone
+ ? LoginPhoneStep(
+ phoneCtrl: _phoneCtrl,
+ state: state,
+ onSendOtp: () => _sendOtp(state),
+ )
+ : LoginOtpStep(
+ otpCtrl: _otpCtrl,
+ state: state,
+ onVerifyAndLogin: _verifyAndLogin,
+ onBackToPhone: _backToPhone,
+ ),
),
);
}
diff --git a/apps/im_app/lib/features/login/view/widgets/login_otp_step.dart b/apps/im_app/lib/features/login/view/widgets/login_otp_step.dart
new file mode 100644
index 0000000..557bb57
--- /dev/null
+++ b/apps/im_app/lib/features/login/view/widgets/login_otp_step.dart
@@ -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('返回修改手机号'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/apps/im_app/lib/features/login/view/widgets/login_phone_step.dart b/apps/im_app/lib/features/login/view/widgets/login_phone_step.dart
new file mode 100644
index 0000000..025e34a
--- /dev/null
+++ b/apps/im_app/lib/features/login/view/widgets/login_phone_step.dart
@@ -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('获取验证码'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/apps/im_app/lib/features/settings/di/settings_providers.dart b/apps/im_app/lib/features/settings/di/settings_providers.dart
index 42ab562..0910eb4 100644
--- a/apps/im_app/lib/features/settings/di/settings_providers.dart
+++ b/apps/im_app/lib/features/settings/di/settings_providers.dart
@@ -1,6 +1,6 @@
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 装配
///
diff --git a/apps/im_app/lib/features/settings/presentation/settings_view_model.dart b/apps/im_app/lib/features/settings/presentation/settings_view_model.dart
index 77d4ae9..0e1b59c 100644
--- a/apps/im_app/lib/features/settings/presentation/settings_view_model.dart
+++ b/apps/im_app/lib/features/settings/presentation/settings_view_model.dart
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.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';
diff --git a/apps/im_app/lib/features/settings/presentation/theme_view_model.dart b/apps/im_app/lib/features/settings/presentation/theme_view_model.dart
index 7f4c18e..de1cfa8 100644
--- a/apps/im_app/lib/features/settings/presentation/theme_view_model.dart
+++ b/apps/im_app/lib/features/settings/presentation/theme_view_model.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
-import '../../../app/di/app_providers.dart';
-import '../di/settings_providers.dart';
+import 'package:im_app/app/di/app_providers.dart';
+import 'package:im_app/features/settings/di/settings_providers.dart';
part 'theme_view_model.g.dart';
diff --git a/apps/im_app/lib/features/settings/view/settings_page.dart b/apps/im_app/lib/features/settings/view/settings_page.dart
index 0f136e3..d3e3553 100644
--- a/apps/im_app/lib/features/settings/view/settings_page.dart
+++ b/apps/im_app/lib/features/settings/view/settings_page.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.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';
/// 设置页
///
diff --git a/apps/im_app/lib/features/settings/view/theme_view.dart b/apps/im_app/lib/features/settings/view/theme_view.dart
index 3151cb9..e71208b 100644
--- a/apps/im_app/lib/features/settings/view/theme_view.dart
+++ b/apps/im_app/lib/features/settings/view/theme_view.dart
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../presentation/theme_view_model.dart';
-import 'widgets/settings_section_header.dart';
-import 'widgets/theme_option_tile.dart';
+import 'package:im_app/features/settings/presentation/theme_view_model.dart';
+import 'package:im_app/features/settings/view/widgets/settings_section_header.dart';
+import 'package:im_app/features/settings/view/widgets/theme_option_tile.dart';
/// 主题选择页
///
diff --git a/apps/im_app/lib/features/settings/view/widgets/settings_section_header.dart b/apps/im_app/lib/features/settings/view/widgets/settings_section_header.dart
index bae779a..9d51934 100644
--- a/apps/im_app/lib/features/settings/view/widgets/settings_section_header.dart
+++ b/apps/im_app/lib/features/settings/view/widgets/settings_section_header.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import '../../../../../core/ui/base/context_theme_ext.dart';
+import 'package:im_app/core/ui/base/context_theme_ext.dart';
/// 设置页分组标题
///
diff --git a/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart b/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart
index 92b1df5..9e5a6f6 100644
--- a/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart
+++ b/apps/im_app/lib/features/settings/view/widgets/theme_option_tile.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import '../../../../../core/ui/base/context_theme_ext.dart';
+import 'package:im_app/core/ui/base/context_theme_ext.dart';
/// 单个主题选项行
///
diff --git a/apps/im_app/pubspec.yaml b/apps/im_app/pubspec.yaml
index b0d565e..ae88c79 100644
--- a/apps/im_app/pubspec.yaml
+++ b/apps/im_app/pubspec.yaml
@@ -43,6 +43,9 @@ dependencies:
# 数据库(schema 定义在 im_app,连接/CRUD 封装在 storage_sdk)
drift: ^2.22.0
+ # 设备信息(deviceId / deviceName)
+ device_info_plus: ^11.0.0
+
dev_dependencies:
flutter_test:
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/auth_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/auth_interceptor.dart
index c7f1caa..5e68ca7 100644
--- a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/auth_interceptor.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/auth_interceptor.dart
@@ -21,9 +21,9 @@ class AuthInterceptor extends Interceptor {
options.extra['customHeaders'] as Map?;
// 保留重试请求的原始 Request-ID(幂等性)
- // 重试时 options.headers 中已有 APP-Request-ID,
+ // 重试时 options.headers 中已有 app-request-id,
// 新生成的 headers 会覆盖它导致服务端无法识别为同一请求。
- final existingRequestId = options.headers['APP-Request-ID'] as String?;
+ final existingRequestId = options.headers['app-request-id'] as String?;
// 构建 headers
final headers = config.defaultHeaders(
@@ -33,7 +33,7 @@ class AuthInterceptor extends Interceptor {
// 还原原始 Request-ID
if (existingRequestId != null) {
- headers['APP-Request-ID'] = existingRequestId;
+ headers['app-request-id'] = existingRequestId;
}
options.headers.addAll(headers);
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/logging_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/logging_interceptor.dart
index b6c9555..b265966 100644
--- a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/logging_interceptor.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/logging_interceptor.dart
@@ -3,8 +3,44 @@ import 'dart:convert';
import 'package:dio/dio.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
+///
+/// ...
+/// ────────────────────────────────────────────────────────────
+/// ```
+///
+/// Header 行显示 Request 类名 + 调用文件:行号,直接定位到出问题的调用代码。
+/// Footer 行 `────` 视觉上把相邻请求隔开,成对关系一眼看清。
class LoggingInterceptor extends Interceptor {
final OnLog? onLog;
final bool enabled;
@@ -14,7 +50,12 @@ class LoggingInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (enabled && onLog != null) {
- _logRequestAndResponse(response);
+ _log(
+ opts: response.requestOptions,
+ statusCode: response.statusCode,
+ data: response.data,
+ tag: 'Network',
+ );
}
handler.next(response);
}
@@ -22,59 +63,103 @@ class LoggingInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
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);
}
- void _logRequestAndResponse(Response response) {
- try {
- final logData = {
- 'url': response.requestOptions.uri.toString(),
- 'method': response.requestOptions.method,
- 'request': {
- if (response.requestOptions.data != null)
- 'body': response.requestOptions.data,
- },
- 'response': {
- 'status': response.statusCode,
- if (response.data != null) 'body': response.data,
- },
- };
+ // ─── Log ─────────────────────────────────────────────────────────────────
- 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 =
+ '────────────────────────────────────────────────────────────';
+
+ // Header:Request 类名 + 调用位置
+ 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();
+ }
+ }
+
+ // Request:headers + body 合并成一个 JSON 块
+ buf.writeln('--> ${opts.method} ${opts.uri}');
+ final requestMap = {
+ '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!(
- 'API Request + Response:\n${encoder.convert(logData)}',
- tag: 'Network',
- );
- } catch (e) {
- onLog!(
- 'API: ${response.requestOptions.uri} -> ${response.statusCode}',
- tag: 'Network',
+ '--> ${opts.method} ${opts.uri} | <-- $statusCode',
+ tag: tag,
);
}
}
- void _logError(DioException error) {
- try {
- final logData = {
- 'url': error.requestOptions.uri.toString(),
- 'method': error.requestOptions.method,
- 'type': error.type.toString(),
- 'message': error.message,
- if (error.response != null) ...{
- 'status': error.response!.statusCode,
- 'data': error.response!.data,
- },
- };
+ // ─── Helpers ──────────────────────────────────────────────────────────────
- const encoder = JsonEncoder.withIndent(' ');
- onLog!(
- 'API Error:\n${encoder.convert(logData)}',
- tag: 'Network',
- );
- } catch (e) {
- onLog!('API Error: ${error.message}', tag: 'Network');
+ /// Authorization token 只保留前 16 位,防止 token 泄露到日志
+ Map _sanitizeHeaders(Map headers) {
+ return headers.map((key, value) {
+ if (key.toLowerCase() == 'authorization' &&
+ value is String &&
+ value.length > 16) {
+ 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();
}
}
}
diff --git a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart
index b02bbec..c7cf04f 100644
--- a/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/http/interceptor/retry_interceptor.dart
@@ -3,20 +3,24 @@ import 'dart:math';
import 'package:dio/dio.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/network_callbacks.dart';
/// 重试拦截器
///
/// 两层重试机制:
///
-/// 1. **Token 刷新重试**(onResponse)
-/// 检测 Token 过期响应 → 触发 [TokenRefreshManager] → 用新 Token 重试原请求
+/// 1. **业务错误码处理**(onResponse)
+/// 所有非 0 业务码经 [ApiConfig.onBusinessError] 回调,
+/// App 层返回 [BusinessErrorAction] 枚举告知 SDK 该怎么做:
+/// - refreshToken → 刷新 token 后重试
+/// - forceLogout → 中断请求
+/// - handled → App 已处理,不在 decodeResponse 中抛错
+/// - unhandled → 透传给调用方,decodeResponse 会抛 ApiError
///
/// 2. **瞬态错误重试**(onError)
/// 5xx / 超时 / 连接失败 → 指数退避 + jitter → 自动重试
/// 由 [ApiConfig.maxRetries] 控制(默认 0 = 不启用)
///
-/// 另外在 onResponse 中处理强制登出码和业务错误码。
-///
/// 两层独立运作,可叠加。
class RetryInterceptor extends Interceptor {
final ApiConfig config;
@@ -35,7 +39,7 @@ class RetryInterceptor extends Interceptor {
proactiveRefreshThreshold: config.proactiveRefreshThreshold,
);
- // ── 响应处理(Token 过期 / 强制登出 / 业务错误码)──────────────────────
+ // ── 响应处理(所有非 0 业务码统一走 onBusinessError)──────────────────
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
@@ -46,46 +50,56 @@ class RetryInterceptor extends Interceptor {
final data = response.data as Map;
final code = _parseCode(data['code']);
+ if (code == 0) {
+ handler.next(response);
+ return;
+ }
+
final message = data['message'] as String? ?? '';
final requestPath = response.requestOptions.path;
- // 检查强制登出
- if (config.forceLogoutCodes.contains(code)) {
- config.onLog?.call('Force logout detected (code: $code)', tag: 'Network');
- config.onForceLogout?.call();
- handler.reject(
- DioException(
- requestOptions: response.requestOptions,
- response: response,
- message: 'Force logout (code: $code)',
- ),
- );
+ // 未注册 onBusinessError 时直接放行,由 decodeResponse 抛 ApiError 给调用方
+ if (config.onBusinessError == null) {
+ handler.next(response);
return;
}
- // 检查 Token 过期(跳过已标记为 token 重试的请求,防止递归)
- if (config.tokenExpiredCodes.contains(code) &&
- response.requestOptions.extra['_isTokenRetry'] != true) {
- config.onLog?.call(
- 'Token expired (code: $code), refreshing...',
- tag: 'Network',
- );
- _handleTokenExpired(response, handler);
- 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);
- // 业务错误码拦截:非 0 且不在特殊码集合中
- if (code != 0 && config.onBusinessError != null) {
- final handled = config.onBusinessError!(code, message, requestPath);
- if (handled) {
- // App 层已处理 → 标记,让 decodeResponse 跳过二次抛错
+ case BusinessErrorAction.forceLogout:
+ config.onLog?.call(
+ 'Force logout (code: $code)',
+ tag: 'Network',
+ );
+ handler.reject(
+ DioException(
+ requestOptions: response.requestOptions,
+ response: response,
+ message: 'Force logout (code: $code)',
+ ),
+ );
+
+ case BusinessErrorAction.handled:
+ // App 层已处理(弹窗 / Toast)→ 标记,让 decodeResponse 跳过二次抛错
response.requestOptions.extra['_businessErrorHandled'] = true;
handler.next(response);
- return;
- }
- }
- handler.next(response);
+ case BusinessErrorAction.unhandled:
+ // 未处理,decodeResponse 会抛 ApiError 给调用方
+ handler.next(response);
+ }
}
/// 处理 Token 过期:刷新 + 重试
@@ -97,7 +111,6 @@ class RetryInterceptor extends Interceptor {
if (newToken == null) {
config.onLog?.call('Token refresh failed', tag: 'Network');
- config.onForceLogout?.call();
handler.reject(
DioException(
requestOptions: response.requestOptions,
diff --git a/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart b/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart
index a8c887a..5958eb6 100644
--- a/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart
+++ b/packages/networks_sdk/lib/src/data/datasources/networks_sdk_method_channel_datasource.dart
@@ -58,6 +58,9 @@ class NetworksSdkMethodChannelDataSource {
ApiRequestable request, {
CancelToken? cancelToken,
}) async {
+ // 在首个 await 前捕获调用栈,async 间隙后栈信息会丢失
+ final callerFrame = _callerFrame(StackTrace.current);
+
await _checkNetwork(request.path);
try {
@@ -76,6 +79,8 @@ class NetworksSdkMethodChannelDataSource {
'requestType': request.requestType,
'includeToken': request.includeToken,
'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(' {
final fromJsonFunc = fromJsonRegistry[T];
if (fromJsonFunc == null) {
- throw StateError(
- 'fromJson not registered for type $T. '
- 'Add: final _reg = registerResponse<$T>($T.fromJson);',
- );
+ // void 接口:生成器不注册 fromJson,服务端 data 字段直接忽略
+ // ignore: null_check_on_nullable_type_parameter
+ return null as dynamic;
}
if (fromJsonFunc is T Function(Object?)) {
diff --git a/packages/networks_sdk/lib/src/generator/api_request_generator.dart b/packages/networks_sdk/lib/src/generator/api_request_generator.dart
index d6863ac..6988a92 100644
--- a/packages/networks_sdk/lib/src/generator/api_request_generator.dart
+++ b/packages/networks_sdk/lib/src/generator/api_request_generator.dart
@@ -97,7 +97,24 @@ class ApiRequestGenerator extends GeneratorForAnnotation {
}
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 枚举值
final methodName = _readEnumName(
@@ -133,17 +150,21 @@ class ApiRequestGenerator extends GeneratorForAnnotation {
// 有响应类型:parameters getter 中注册 fromJson(使用生成的私有函数)
// ApiResponseGenerator 在同一 .g.dart 中生成 _$XFromJson,同 library 可访问
- // 无响应类型(void):跳过注册,直接返回 super.parameters
- final parametersBody = hasResponseType
- ? ''' registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
- return super.parameters;'''
- : ' return super.parameters;';
+ // 无响应类型(void):无需注册,不生成 parameters getter(避免 unnecessary_override)
+ final parametersGetter = hasResponseType
+ ? '''
+ @override
+ Map? get parameters {
+ registerResponse<$responseTypeName>(_\$${responseTypeName}FromJson);
+ return super.parameters;
+ }'''
+ : '';
return '''
/// Generated by @ApiRequest for [$className]
mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
@override
- String get path => '$path';
+ String get path => $pathExpression;
@override
HttpMethod get method => HttpMethod.$methodName;
@override
@@ -151,11 +172,7 @@ mixin _\$${className}Api on ApiRequestable<$responseTypeName> {
@override
bool get includeToken => $includeToken;
@override
- Map toJson() => $toJsonBody;
- @override
- Map? get parameters {
-$parametersBody
- }
+ Map toJson() => $toJsonBody;$parametersGetter
}
''';
}
diff --git a/packages/networks_sdk/lib/src/generator/api_response_generator.dart b/packages/networks_sdk/lib/src/generator/api_response_generator.dart
index 946b920..6edf6fc 100644
--- a/packages/networks_sdk/lib/src/generator/api_response_generator.dart
+++ b/packages/networks_sdk/lib/src/generator/api_response_generator.dart
@@ -190,6 +190,9 @@ $params );
if (type.isDartCoreDouble) return '$access as double$q';
if (type.isDartCoreNum) return '$access as num$q';
+ // Map:已经是 JSON Map,直接 cast,无需 fromJson
+ if (type.isDartCoreMap) return '$access as Map$q';
+
// 嵌套对象:调用同一 part 文件中生成的 _$TypeFromJson 私有函数
if (type is InterfaceType) {
final typeName = type.element.name!;
diff --git a/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart b/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart
index f25a939..e64302d 100644
--- a/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart
+++ b/packages/networks_sdk/lib/src/presentation/wiring/api_config.dart
@@ -17,9 +17,6 @@ class ApiConfig {
/// Token 过期时的刷新回调
final OnTokenRefresh? onTokenRefresh;
- /// 需要强制登出时的回调
- final OnForceLogout? onForceLogout;
-
/// Token 更新后的通知回调
///
/// 在 [updateToken] 被调用且新 token 非空时触发。
@@ -61,14 +58,6 @@ class ApiConfig {
/// `{ code, data, message }` 结构。返回 null 表示不变换。
final OnTransformResponse? onTransformResponse;
- // ── 错误码集合 ──
-
- /// App 层定义的 Token 过期错误码集合
- final Set tokenExpiredCodes;
-
- /// App 层定义的强制登出错误码集合
- final Set forceLogoutCodes;
-
// ── 重试配置 ──
/// 瞬态错误最大重试次数(5xx / 超时 / 连接失败)
@@ -110,7 +99,6 @@ class ApiConfig {
this.token,
this.platformHeaders = const {},
this.onTokenRefresh,
- this.onForceLogout,
this.onTokenUpdated,
this.onLog,
this.onCheckNetworkAvailable,
@@ -118,8 +106,6 @@ class ApiConfig {
this.onDecryptResponse,
this.onBusinessError,
this.onTransformResponse,
- this.tokenExpiredCodes = const {},
- this.forceLogoutCodes = const {},
this.maxRetries = 0,
this.retryBaseDelay = const Duration(seconds: 1),
this.tokenRefreshTimeout = const Duration(seconds: 10),
@@ -136,12 +122,12 @@ class ApiConfig {
Map? customHeaders,
}) {
final headers = {
- 'Content-Type': 'application/json; charset=utf-8',
- 'Accept': 'application/json',
- 'Keep-Alive': 'timeout=60',
+ 'content-type': 'application/json; charset=utf-8',
+ 'accept': 'application/json',
+ 'keep-alive': 'timeout=60',
// Unix 时间戳(秒),整数值,非格式化日期字符串
- 'Timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
- 'APP-Request-ID': _generateRequestId(),
+ 'timestamp': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
+ 'app-request-id': _generateRequestId(),
};
// 合并平台 headers(App 层注入的 version、platform 等)
diff --git a/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart b/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart
index df08341..fa3f3e6 100644
--- a/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart
+++ b/packages/networks_sdk/lib/src/presentation/wiring/network_callbacks.dart
@@ -8,9 +8,6 @@ import 'package:networks_sdk/src/domain/entities/encrypted_request.dart';
/// Token 刷新回调,返回新 token;返回 null 表示刷新失败
typedef OnTokenRefresh = Future Function();
-/// 强制登出回调
-typedef OnForceLogout = void Function();
-
// ── Token 生命周期 ──
/// 获取 token 过期时间
@@ -73,11 +70,27 @@ typedef OnDecryptResponse =
// ── 业务错误 ──
-/// 业务错误拦截回调
+/// SDK 层收到 App 层对业务错误码的处置指令
+enum BusinessErrorAction {
+ /// 刷新 token 后重试原请求(原 tokenExpiredCodes 行为)
+ refreshToken,
+
+ /// 强制登出,中断当前请求(原 forceLogoutCodes 行为)
+ forceLogout,
+
+ /// App 层已处理(如全局弹窗/Toast),SDK 正常放行响应,不在 decodeResponse 中抛错
+ handled,
+
+ /// 未处理,SDK 继续正常流程,decodeResponse 会抛 ApiError 给调用方
+ unhandled,
+}
+
+/// 业务错误统一回调
///
-/// App 层统一处理特定错误码,返回 true = 已处理(SDK 不再抛错),
-/// 返回 false = 未处理(SDK 继续正常流程)。
-typedef OnBusinessError = bool Function(int code, String message, String path);
+/// 所有非 0 业务码(token 过期、强制登出、踢下线、普通业务错误)全部经此入口,
+/// App 层通过返回 [BusinessErrorAction] 告诉 SDK 该怎么做。
+typedef OnBusinessError =
+ BusinessErrorAction Function(int code, String message, String path);
/// 响应变换回调
///
diff --git a/pubspec.lock b/pubspec.lock
index ddf3fa5..9e5d8ce 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -273,6 +273,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -1148,6 +1164,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description: