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/app_theme_ext.dart b/apps/im_app/lib/core/ui/base/app_theme_ext.dart new file mode 100644 index 0000000..3e8cc5d --- /dev/null +++ b/apps/im_app/lib/core/ui/base/app_theme_ext.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; +import 'package:im_app/core/ui/base/shadows.dart'; + + +extension AppThemeExt on BuildContext { + AppShadows get shadows => AppShadows(this); +} \ No newline at end of file diff --git a/apps/im_app/lib/core/ui/base/colors.dart b/apps/im_app/lib/core/ui/base/colors.dart index 6d1b2f5..435e2ad 100644 --- a/apps/im_app/lib/core/ui/base/colors.dart +++ b/apps/im_app/lib/core/ui/base/colors.dart @@ -37,4 +37,8 @@ class AppColors { static const gray800 = Color(0xFF3C4043); static const gray900 = Color(0xFF202124); static const black = Color(0xFF000000); + + // ── Neutral black Scale ───────────────────────────────────────────────────── + static const black12 = Color(0x1F000000); // 12% opacity + static const black60 = Color(0x99000000); // 60% opacity } 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/base/radius.dart b/apps/im_app/lib/core/ui/base/radius.dart new file mode 100644 index 0000000..e93a157 --- /dev/null +++ b/apps/im_app/lib/core/ui/base/radius.dart @@ -0,0 +1,131 @@ +import 'package:flutter/widgets.dart'; + +/// 圆角设计 Token +/// +/// 统一管理项目中的圆角规范,避免在业务代码中直接写 +/// `Radius.circular()` 或 `BorderRadius.circular()` +/// +/// 使用方式: +/// +/// ```dart +/// Container( +/// decoration: BoxDecoration( +/// borderRadius: AppRadius.card, +/// ), +/// ) +/// ``` +/// +/// 设计规范来源: +/// 通常来自 UI 设计系统,例如 +/// 4 / 8 / 12 / 16 / 20 +class AppRadius { + /// 私有构造函数,防止被实例化 + AppRadius._(); + + // ================================ + // 基础 Radius Token + // ================================ + // 用于组合 BorderRadius + + /// 4px 圆角 + static const Radius r4 = Radius.circular(4); + + /// 6px 圆角 + static const Radius r6 = Radius.circular(6); + + /// 8px 圆角(常用于按钮) + static const Radius r8 = Radius.circular(8); + + /// 10px 圆角 + static const Radius r10 = Radius.circular(10); + + /// 12px 圆角(常用于卡片) + static const Radius r12 = Radius.circular(12); + + /// 14px 圆角 + static const Radius r14 = Radius.circular(14); + + /// 16px 圆角(常用于弹窗) + static const Radius r16 = Radius.circular(16); + + /// 18px 圆角 + static const Radius r18 = Radius.circular(18); + + /// 20px 圆角 + static const Radius r20 = Radius.circular(20); + + // ================================ + // 组件级设计 Token + // ================================ + // 推荐优先使用这些,而不是直接使用 brXX + + /// 卡片圆角 + /// + /// 示例:Card / 商品卡片 / 信息卡片 + static const BorderRadius card = BorderRadius.all(r12); + + /// 按钮圆角 + /// + /// 示例:PrimaryButton / SecondaryButton + static const BorderRadius button = BorderRadius.all(r8); + + /// 弹窗圆角 + /// + /// 示例:Dialog / Modal + static const BorderRadius dialog = BorderRadius.all(r16); + + // ================================ + // 通用 BorderRadius + // ================================ + // 当组件 Token 不满足需求时使用 + + static const BorderRadius br4 = BorderRadius.all(r4); + + static const BorderRadius br6 = BorderRadius.all(r6); + + static const BorderRadius br8 = BorderRadius.all(r8); + + static const BorderRadius br10 = BorderRadius.all(r10); + + static const BorderRadius br12 = BorderRadius.all(r12); + + static const BorderRadius br14 = BorderRadius.all(r14); + + static const BorderRadius br16 = BorderRadius.all(r16); + + static const BorderRadius br18 = BorderRadius.all(r18); + + static const BorderRadius br20 = BorderRadius.all(r20); + + // ================================ + // 辅助方法 + // ================================ + // 用于生成顶部或底部圆角 + + /// 生成顶部圆角 + /// + /// 常用于: + /// - BottomSheet + /// - 底部弹窗 + /// - 半屏弹层 + /// + /// 示例: + /// ```dart + /// borderRadius: AppRadius.top(AppRadius.r16) + /// ``` + static BorderRadius top(Radius r) => + BorderRadius.vertical(top: r); + + /// 生成底部圆角 + /// + /// 常用于: + /// - Header + /// - 顶部卡片 + /// + /// 示例: + /// ```dart + /// borderRadius: AppRadius.bottom(AppRadius.r16) + /// ``` + static BorderRadius bottom(Radius r) => + BorderRadius.vertical(bottom: r); +} \ No newline at end of file diff --git a/apps/im_app/lib/core/ui/base/shadows.dart b/apps/im_app/lib/core/ui/base/shadows.dart new file mode 100644 index 0000000..93e6e2e --- /dev/null +++ b/apps/im_app/lib/core/ui/base/shadows.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'colors.dart'; + +/// 阴影 Design Token +/// +/// 统一管理项目中的阴影规范,避免在业务代码中直接书写 `BoxShadow`。 +/// 所有阴影通过 Design Token 提供,保证: +/// +/// - UI 风格统一 +/// - 支持 Dark / Light Mode +/// - 与设计稿(Figma)保持一致 +/// +/// ## 数据流位置 +/// +/// ``` +/// AppColors(颜色常量) +/// → AppShadows(阴影 Token) +/// → Context Extension(context.shadows) +/// → View 层消费 +/// ``` +/// +/// ## 使用示例 +/// +/// ```dart +/// Container( +/// decoration: BoxDecoration( +/// color: Colors.white, +/// boxShadow: context.shadows.bs8, +/// ), +/// ) +/// ``` +/// +/// ## Elevation 体系 +/// +/// 阴影遵循常见 UI 设计系统的层级规范: +/// +/// - **4** : 小卡片 / List Item +/// - **8** : Card / 商品卡片 +/// - **12** : Dropdown / Popover +/// - **16** : Dialog / Modal / 悬浮面板 +class AppShadows { + /// 构造函数,通过 BuildContext 获取当前主题 + AppShadows(this.context); + + /// 当前 Widget 的 BuildContext + /// + /// 用于根据 Theme 判断 Light / Dark Mode, + /// 从而动态获取阴影颜色。 + final BuildContext context; + + /// 内部统一阴影生成方法 + /// + /// 避免重复创建 `BoxShadow` 逻辑, + /// 所有阴影 Token 都通过该方法生成。 + List _shadow({ + required double blur, + required double dy, + }) { + return [ + BoxShadow( + + /// 阴影颜色来自 Design Token + color: _shadowColor, + + /// 模糊半径(影响阴影扩散范围) + blurRadius: blur, + + /// 阴影偏移 + offset: Offset(0, dy), + ) + ]; + } + + /// Elevation 4 + /// + /// 适用场景: + /// - List Item + /// - 小卡片 + List get bs4 => + _shadow( + blur: 4, + dy: 2, + ); + + /// Elevation 8 + /// + /// 适用场景: + /// - Card + /// - 商品卡片 + List get bs8 => + _shadow( + blur: 8, + dy: 4, + ); + + /// Elevation 12 + /// + /// 适用场景: + /// - Dropdown + /// - Popover + List get bs12 => + _shadow( + blur: 12, + dy: 8, + ); + + /// Elevation 16 + /// + /// 适用场景: + /// - Dialog + /// - Modal + /// - Floating Panel + List get bs16 => + _shadow( + blur: 16, + dy: 8, + ); + + /// 阴影颜色 Token + Color get _shadowColor { + final brightness = Theme + .of(context) + .brightness; + + return brightness == Brightness.dark + ? AppColors.black60 + : AppColors.black12; + } +} \ No newline at end of file diff --git a/apps/im_app/lib/core/ui/base/spacing.dart b/apps/im_app/lib/core/ui/base/spacing.dart new file mode 100644 index 0000000..d735c1c --- /dev/null +++ b/apps/im_app/lib/core/ui/base/spacing.dart @@ -0,0 +1,72 @@ +/// 间距设计 Token +/// +/// 统一管理项目中的间距规范,避免在业务代码中直接写 magic number,例如: +/// +/// ❌ 不推荐 +/// ```dart +/// Padding(padding: EdgeInsets.all(16)) +/// ``` +/// +/// ✅ 推荐 +/// ```dart +/// Padding(padding: EdgeInsets.all(AppSpacing.s16)) +/// ``` +/// +/// 常用于: +/// - Padding +/// - Margin +/// - SizedBox +/// - Sliver 间距 +/// +/// 设计规范通常来源于 UI 设计系统,例如: +/// 4 / 8 / 12 / 16 / 24 / 32 +class AppSpacing { + /// 私有构造函数,防止实例化 + AppSpacing._(); + + // ================================ + // 基础间距 Token + // ================================ + + /// 4px 间距(最小间距) + /// + /// 常用于: + /// - icon 与文字之间 + /// - 紧凑布局 + static const double s4 = 4; + + /// 8px 间距(小间距) + /// + /// 常用于: + /// - 列表 item 内间距 + /// - 小组件之间 + static const double s8 = 8; + + /// 12px 间距(中小间距) + /// + /// 常用于: + /// - 表单组件 + /// - 信息块之间 + static const double s12 = 12; + + /// 16px 间距(标准间距) + /// + /// 常用于: + /// - 页面 Padding + /// - Card 内边距 + static const double s16 = 16; + + /// 24px 间距(大间距) + /// + /// 常用于: + /// - 模块之间 + /// - Section 分隔 + static const double s24 = 24; + + /// 32px 间距(超大间距) + /// + /// 常用于: + /// - 页面大区块 + /// - 顶部/底部留白 + static const double s32 = 32; +} \ No newline at end of file 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/domain/repositories/user_repository.dart b/apps/im_app/lib/domain/repositories/user_repository.dart index 40a4106..ca23949 100644 --- a/apps/im_app/lib/domain/repositories/user_repository.dart +++ b/apps/im_app/lib/domain/repositories/user_repository.dart @@ -14,7 +14,7 @@ import 'package:im_app/domain/entities/user.dart'; /// 读取:DB row (DriftUser) → _toEntity() → Domain User /// 监听:DB 变化 → stream → Domain User → UI /// ``` -abstract class UserRepository { +abstract interface class UserRepository { // ── 监听 ───────────────────────────────────────────────────────────────── /// 监听单个用户,DB 变化自动反映 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 70da5b4..103cab7 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..12bc4fb 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,11 @@ 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/app/di/user_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 层 /// @@ -23,6 +24,7 @@ import '../usecases/login_usecase.dart'; /// → ref.read(apiConfigProvider) ← app/di/ 手动装配 /// → ref.read(networkSdkApiProvider) ← app/di/ 手动装配 /// → ref.read(storageSdkProvider) ← app/di/ 手动装配 +/// → ref.read(userRepositoryProvider) ← app/di/ 手动装配 /// ``` // ── Repository ──────────────────────────────────────────────────────────────── @@ -41,7 +43,7 @@ final authRepositoryProvider = Provider((ref) { // TODO: final secureStorage = ref.read(secureStorageProvider); return AuthRepositoryImpl( - client: ref.read(networkSdkApiProvider), // 注入 Facade 接口 + client: ref.read(networkSdkApiProvider), onTokenUpdate: (token) { apiConfig.updateToken(token); // 内存(network_sdk) // TODO: secureStorage.saveToken(token); // 持久化(crypto_sdk) @@ -53,12 +55,13 @@ final authRepositoryProvider = Provider((ref) { /// 登录用例 Provider /// -/// 多步编排:格式校验 → 调接口 → 写 Token → 连接 WebSocket → 打开数据库 +/// 多步编排:格式校验 → 调接口 → 写 Token → 连接 WebSocket → 打开数据库 → 持久化用户 final loginUseCaseProvider = Provider((ref) { return LoginUseCase( authRepository: ref.read(authRepositoryProvider), socketManager: ref.read(socketManagerProvider), apiConfig: ref.read(apiConfigProvider), storageApi: ref.read(storageSdkProvider), + userRepository: ref.read(userRepositoryProvider), ); }); 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 674fd15..51893d5 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,103 @@ -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.insertOrReplaceUser(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..2a28d50 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,38 @@ 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'; +import 'package:im_app/domain/repositories/user_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) +/// → UserRepository.insertOrReplaceUser(user) /// ← User /// ``` class LoginUseCase { @@ -40,6 +40,7 @@ class LoginUseCase { final SocketManager _socketManager; final ApiConfig _apiConfig; final StorageSdkApi _storageApi; + final UserRepository _userRepository; StorageSdkLifecycle get _storageLifeCycle => _storageApi as StorageSdkLifecycle; @@ -49,67 +50,91 @@ class LoginUseCase { required SocketManager socketManager, required ApiConfig apiConfig, required StorageSdkApi storageApi, + required UserRepository userRepository, }) : _authRepository = authRepository, _socketManager = socketManager, _apiConfig = apiConfig, - _storageApi = storageApi; + _storageApi = storageApi, + _userRepository = userRepository; - /// 执行登录 - /// - /// 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 → saveUser + /// + /// 抛出: + /// - [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 + // 持久化登录用户信息 + await _userRepository.insertOrReplaceUser(user); + + // 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: