优化配置,修复 demo bug

1,network 框架完善
2,websocket 机制完善
3,设计文档整理到架构文档
4,脚本,配置完善
This commit is contained in:
Cody
2026-03-07 14:58:10 +08:00
parent f8a118af73
commit 0ee2c8c63c
82 changed files with 2704 additions and 1045 deletions

View File

@@ -0,0 +1,57 @@
/// API 错误码常量
///
/// 集中管理后端业务错误码,避免散落在各处硬编码。
/// 按业务域分组,命名风格对齐后端定义。
///
/// 使用方式:
/// ```dart
/// ApiConfig(
/// tokenExpiredCodes: ApiErrorCodes.tokenExpiredCodes,
/// forceLogoutCodes: ApiErrorCodes.forceLogoutCodes,
/// )
/// ```
class ApiErrorCodes {
ApiErrorCodes._();
// ── 认证30001-30009──
/// Token 无效
static const int tokenInvalid = 30002;
/// JWT 无效
static const int jwtInvalid = 30003;
/// 签名方法错误
static const int signingMethodError = 30008;
/// 密钥解析失败
static const int parsingKeyError = 30009;
/// Session 无效
static const int sessionInvalid = 30124;
/// Refresh Token 失效
static const int refreshTokenFailed = 30125;
/// 账号在其他设备登录
static const int loggedInAnotherDevice = 30006;
// ── 错误码集合 ──
/// Token 过期错误码集合 — 触发自动刷新 Token
static const Set<int> tokenExpiredCodes = {
tokenInvalid,
jwtInvalid,
sessionInvalid,
};
/// 强制登出错误码集合 — 触发退出登录流程
static const Set<int> forceLogoutCodes = {refreshTokenFailed};
/// 踢下线错误码集合 — 触发踢下线 UI 提示
static const Set<int> kickOffCodes = {
loggedInAnotherDevice,
signingMethodError,
parsingKeyError,
};
}

View File

@@ -0,0 +1,32 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
/// JWT token 过期时间解析
///
/// 使用 dart_jsonwebtoken 解码 JWT payload提取 `exp` claim 返回过期时间。
/// 返回 null 表示无法解析(非 JWT 格式或缺少 exp 字段)。
///
/// 只读取 payload不验证签名验证是服务端的事
///
/// 用于 [ApiConfig.onGetTokenExpiry] 回调,启用 token 主动刷新:
/// 距过期不足阈值时提前刷新,避免带过期 token 发请求或重连。
///
/// ```dart
/// final expiry = parseJwtExpiry('eyJhbGci...');
/// if (expiry != null) {
/// final remaining = expiry.difference(DateTime.now());
/// print('Token expires in ${remaining.inMinutes} min');
/// }
/// ```
DateTime? parseJwtExpiry(String token) {
try {
final jwt = JWT.decode(token);
final payload = jwt.payload;
if (payload is! Map<String, dynamic>) return null;
final exp = payload['exp'];
if (exp is! int) return null;
return DateTime.fromMillisecondsSinceEpoch(exp * 1000);
} catch (_) {
return null;
}
}

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:networks_sdk/networks_sdk.dart';
import 'network_backoff_debouncer.dart';
@@ -10,9 +9,8 @@ import 'network_backoff_debouncer.dart';
/// 参考 HTTP 层 onTokenRefresh 的回调注入模式。
/// App 层在 Provider 装配时注入解密/解析逻辑,
/// 不在 SDK 内部调用加解密 SDK。
typedef MessageTransformer = Map<String, dynamic> Function(
Map<String, dynamic> raw,
);
typedef MessageTransformer =
Map<String, dynamic> Function(Map<String, dynamic> raw);
/// WebSocket 连接管理
///
@@ -39,19 +37,26 @@ typedef MessageTransformer = Map<String, dynamic> Function(
///
/// ```
/// 登录成功 → connect(token) → 前置检查 → 建立连接
/// App 进后台 → onEnterBackground() → 断开连接(省电)
/// App 回前台 → onEnterForeground() → 检查网络 → 自动重连
///
/// ── disconnectInBackground = true默认移动端──
/// App 进后台 → onEnterBackground() → 暂停心跳 + 断开连接(省电)
/// App 回前台 → onEnterForeground() → 恢复心跳 → onBeforeReconnect → 重连
///
/// ── disconnectInBackground = false桌面端──
/// App 进后台 → onEnterBackground() → 不操作,完全保活
/// App 回前台 → onEnterForeground() → 不操作(连接始终在线)
///
/// 网络丢失 → handleNetworkLost() → 断开连接
/// 网络恢复 → handleNetworkRestored() → 退避重连(防抖动)
/// 网络恢复 → handleNetworkRestored() → 退避 → onBeforeReconnect → 重连
/// 登出 → disconnect() → 断开连接,清除 token
/// ```
///
/// ## 前置检查策略
///
/// 所有会发起网络操作的方法都先检查前置条件:
/// - connect → 检查网络可用性 + 是否在后台
/// - send / sendString → 检查连接状态 + 是否在后台
/// - onEnterForeground 重连 → 检查网络可用性
/// - connect → 检查网络可用性 + 是否在后台(仅 disconnectInBackground=true 时拦截)
/// - send / sendString → 检查连接状态 + 是否在后台(仅 disconnectInBackground=true 时拦截)
/// - onEnterForeground / 网络恢复重连 → 检查网络可用性 + onBeforeReconnect
class SocketManager {
final NetworksMessagingApi _client;
final String _wsUrl;
@@ -70,6 +75,22 @@ class SocketManager {
/// 连接和重连前调用,无网络时跳过操作并标记恢复时重试。
final Future<bool> Function()? onCheckNetworkAvailable;
/// 重连前回调
///
/// 在 WebSocket 重连前调用前台恢复、网络恢复App 层用于:
/// - 检查并刷新即将过期的 token
/// - 更新连接参数
///
/// 回调完成后才发起实际重连。
final Future<void> Function()? onBeforeReconnect;
/// 进后台时是否断开连接
///
/// true默认— 后台断连省电,由 push 通知兜底,前台恢复时自动重连。
/// false — 后台保持连接(适用于桌面端或需要后台实时推送的场景)。
/// 设为 false 时,后台仅暂停心跳,不主动断连。
final bool disconnectInBackground;
/// 日志回调
final void Function(String message, {String? tag})? onLog;
@@ -104,10 +125,12 @@ class SocketManager {
required NetworksMessagingApi client,
required String wsUrl,
this.onMessageTransform,
this.onBeforeReconnect,
this.disconnectInBackground = true,
this.onCheckNetworkAvailable,
this.onLog,
}) : _client = client,
_wsUrl = wsUrl;
}) : _client = client,
_wsUrl = wsUrl;
// ── 连接 ──────────────────────────────────────────────────────────────────
@@ -124,8 +147,8 @@ class SocketManager {
_reconnectOnForeground = false;
_reconnectOnNetworkRestore = false;
// 前置检查:在后台不连接(省电)
if (_isInBackground) {
// 前置检查:移动端模式下在后台不连接(省电)
if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true;
_log('In background, defer connect to foreground');
return false;
@@ -165,26 +188,47 @@ class SocketManager {
/// 当前是否在后台
bool get isInBackground => _isInBackground;
/// Token 热更新
///
/// 透传给 SocketClient仅更新内部 token不断开连接。
/// 适用于 HTTP 层 token 刷新后同步到 WebSocket 的场景。
void updateToken(String token) {
_lastToken = token;
_client.updateToken(token);
_log('Token updated via SocketManager');
}
// ── 前后台生命周期 ────────────────────────────────────────────────────────
//
// 后台 → 断连(省电省流量)
// 后台 → 断连(省电省流量)或保持连接(桌面端)
// 前台 → 自动重连(如果之前有连接)
/// App 进后台 → 断开连接,标记前台恢复时重连
/// App 进后台
///
/// 由 App 层 WidgetsBindingObserver 在 [AppLifecycleState.paused] 时调用。
/// 后台保持连接会消耗电量和流量,断开后由 push 通知兜底。
///
/// [disconnectInBackground] 为 true 时(默认,移动端):
/// 断开连接 + 暂停心跳,由 push 通知兜底,前台恢复时自动重连。
///
/// [disconnectInBackground] 为 false 时(桌面端):
/// 不断连、不暂停心跳WebSocket 完全保活。
void onEnterBackground() {
_isInBackground = true;
// 取消待执行的前台重连(防止快速 前台→后台 切换导致后台建连)
_foregroundReconnectTimer?.cancel();
_foregroundReconnectTimer = null;
// 同步 SocketClient 内部状态(与 onEnterForeground 对称)
if (!disconnectInBackground) {
// 桌面端模式:不断连、不暂停心跳,完全保活
_log('Entering background, keeping connection alive');
return;
}
// 移动端模式:通知 SocketClient 进后台(暂停心跳)
_client.onEnterBackground();
if (_lastToken == null) return; // 未登录,无需处理
// 与 _handleNetworkLost 保持一致:
// 不仅 connectedconnecting / reconnecting 也要断开,
// 防止 SocketClient 在后台继续尝试连接浪费电量和流量。
if (_client.isConnected ||
@@ -202,7 +246,11 @@ class SocketManager {
/// 重连前检查网络可用性,无网络时延迟到网络恢复事件再连。
void onEnterForeground() {
_isInBackground = false;
_client.onEnterForeground();
// 只在移动端模式(后台曾断连/暂停心跳)时通知 SocketClient 恢复
if (disconnectInBackground) {
_client.onEnterForeground();
}
if (_reconnectOnForeground && _lastToken != null) {
_reconnectOnForeground = false;
@@ -226,7 +274,12 @@ class SocketManager {
_log('Network unavailable, defer reconnect to network restore');
return;
}
_client.connect(_wsUrl, token: _lastToken!);
// 重连前钩子:刷新即将过期的 token 等
await onBeforeReconnect?.call();
// token 可能被 onBeforeReconnect 更新(通过 updateToken 链路同步)
if (_lastToken != null && !_client.isConnected) {
_client.connect(_wsUrl, token: _lastToken!);
}
}
},
);
@@ -275,18 +328,22 @@ class SocketManager {
if (_reconnectOnNetworkRestore && _lastToken != null) {
_reconnectOnNetworkRestore = false;
// 在后台不重连,等前台恢复时再连
if (_isInBackground) {
// 移动端模式:在后台不重连,等前台恢复时再连
if (_isInBackground && disconnectInBackground) {
_reconnectOnForeground = true;
_log('Network restored but in background, defer to foreground');
return;
}
_log('Network restored, scheduling reconnect with backoff');
_networkDebouncer.call(() {
_networkDebouncer.call(() async {
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
_log('Backoff timer fired, reconnecting');
_client.connect(_wsUrl, token: _lastToken!);
// 重连前钩子:刷新即将过期的 token 等
await onBeforeReconnect?.call();
if (!_client.isConnected && _lastToken != null && !_isInBackground) {
_log('Backoff timer fired, reconnecting');
_client.connect(_wsUrl, token: _lastToken!);
}
}
});
}
@@ -308,6 +365,9 @@ class SocketManager {
/// 原始消息流(不经预处理,调试用)
Stream<String> get rawMessageStream => _client.rawMessageStream;
/// 二进制消息流
Stream<dynamic> get binaryMessageStream => _client.binaryMessageStream;
/// 连接状态变化流
Stream<SocketConnectionState> get connectionStateStream =>
_client.connectionStateStream;
@@ -333,6 +393,14 @@ class SocketManager {
return _client.sendString(message);
}
/// 发送二进制数据
///
/// 前置检查:未连接或在后台时不发送。
Future<bool> sendBytes(List<int> bytes) {
if (!_canSend()) return Future.value(false);
return _client.sendBytes(bytes);
}
// ── 释放 ──────────────────────────────────────────────────────────────────
/// 释放所有资源
@@ -355,7 +423,7 @@ class SocketManager {
_log('Not connected, cannot send');
return false;
}
if (_isInBackground) {
if (_isInBackground && disconnectInBackground) {
_log('In background, skip send');
return false;
}

View File

@@ -0,0 +1,30 @@
/// 静态资源路径常量,统一维护,避免路径字符串散落在业务代码中。
///
/// 所有路径须与 pubspec.yaml 的 flutter.assets 声明保持一致。
/// 新增资源:① 文件放入 assets/ ② pubspec.yaml 声明 ③ 此处加常量。
///
/// 渲染逻辑(缓存、占位、错误态)由 core/ui/components/ 下的组件负责,不在此处封装。
///
/// ## 使用
/// ```dart
/// Image.asset(AppAssets.logo)
/// Image.asset(AppAssets.logo, width: 80, fit: BoxFit.cover)
/// ```
abstract final class AppAssets {
AppAssets._();
// ── 品牌 ──────────────────────────────────────────────────
static const logo = 'assets/images/logo.png';
static const logoLight = 'assets/images/logo_light.png';
// ── 占位图 ────────────────────────────────────────────────
static const avatarPlaceholder = 'assets/images/avatar_placeholder.png';
// ── 空状态插图SVG引入 flutter_svg 后启用) ─────────────
// static const emptyChat = 'assets/svg/empty_chat.svg';
// static const emptyContact = 'assets/svg/empty_contact.svg';
// static const emptySearch = 'assets/svg/empty_search.svg';
// ── 动画 ──────────────────────────────────────────────────
// static const loading = 'assets/gif/loading.gif';
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
/// 项目图标常量,统一维护,避免 Icons.xxx 散落在业务代码中。
///
/// 渲染逻辑(大小、颜色、点击态)由调用方负责,不在此处封装。
///
/// ## 使用
/// ```dart
/// Icon(AppIcons.send)
/// Icon(AppIcons.send, size: 20, color: Colors.white)
/// IconButton(icon: Icon(AppIcons.back), onPressed: ...)
/// ```
abstract final class AppIcons {
AppIcons._();
// ── 底部导航 ──────────────────────────────────────────────
static const chat = Icons.chat_bubble_outline_rounded;
static const contact = Icons.people_outline_rounded;
static const settings = Icons.settings_outlined;
// ── 通用操作 ──────────────────────────────────────────────
static const back = Icons.arrow_back_ios_new_rounded;
static const close = Icons.close_rounded;
static const more = Icons.more_horiz_rounded;
static const search = Icons.search_rounded;
static const add = Icons.add_rounded;
// ── 聊天输入区 ────────────────────────────────────────────
static const send = Icons.send_rounded;
static const attach = Icons.attach_file_rounded;
static const emoji = Icons.emoji_emotions_outlined;
static const camera = Icons.camera_alt_outlined;
static const voice = Icons.mic_outlined;
// ── 用户 / 联系人 ─────────────────────────────────────────
static const avatar = Icons.account_circle_outlined;
static const addUser = Icons.person_add_outlined;
// ── 状态反馈 ──────────────────────────────────────────────
static const success = Icons.check_circle_outline_rounded;
static const warning = Icons.warning_amber_rounded;
static const error = Icons.error_outline_rounded;
static const info = Icons.info_outline_rounded;
}