- 引导页协议多语言支持(languageId传递) - 登录页双书名号修复 + 注册页协议勾选 - 个人中心页面多语言(18个翻译键) - 网络断开提示增加关闭/刷新按钮 - 了解我们:新增秋叶qy开发者 + ayk签名修改 + 贡献者精简 + 微风暴微信搜索 - iOS快捷按钮重复修复(删除Info.plist静态定义) - 测试账号123456警告提示 - 扫码登录自动跳转(HTTP轮询+WebSocket双通道) - 登录页老用户按钮改次要色 - Syncfusion图表崩溃修复(DeferredBuilder+animationDuration:0) - macOS标题栏跟随软件夜间模式 - 平台兼容分发渠道弹窗 - 软件著作权图片+交叉水印 - 桌面小部件平台兼容说明默认收起 - iOS/macOS图标更新+名称确认为闲言 - 12个语言文件补全roleNative+7个分发渠道翻译字段
194 lines
5.2 KiB
Dart
194 lines
5.2 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 二维码登录WebSocket推送服务
|
||
/// 创建时间: 2026-06-02
|
||
/// 更新时间: 2026-06-02
|
||
/// 作用: 通过WebSocket长连接接收二维码状态变更推送,替代HTTP轮询
|
||
/// 上次更新: 处理confirmed状态携带token的推送,收到终态后自动取消订阅
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
import 'package:xianyan/core/utils/logger.dart';
|
||
|
||
typedef QrcodeStatusCallback = void Function(Map<String, dynamic> data);
|
||
|
||
class QrcodeWsService {
|
||
QrcodeWsService._();
|
||
|
||
static final QrcodeWsService instance = QrcodeWsService._();
|
||
|
||
WebSocketChannel? _channel;
|
||
bool _isConnected = false;
|
||
Timer? _heartbeatTimer;
|
||
Timer? _reconnectTimer;
|
||
int _reconnectAttempts = 0;
|
||
static const _maxReconnectAttempts = 5;
|
||
|
||
String? _subscribedCode;
|
||
QrcodeStatusCallback? _onStatusUpdate;
|
||
StreamSubscription? _subscription;
|
||
|
||
bool get isConnected => _isConnected;
|
||
|
||
/// 连接WebSocket服务器
|
||
Future<bool> connect() async {
|
||
if (_isConnected) return true;
|
||
|
||
final wsUrl = _resolveWsUrl();
|
||
if (wsUrl.isEmpty) {
|
||
Log.w('QrcodeWsService: WebSocket URL不可用');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
Log.i('QrcodeWsService: 连接 $wsUrl');
|
||
_channel = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||
|
||
await _channel!.ready.timeout(
|
||
const Duration(seconds: 5),
|
||
onTimeout: () => throw TimeoutException('连接超时'),
|
||
);
|
||
|
||
_isConnected = true;
|
||
_reconnectAttempts = 0;
|
||
_startHeartbeat();
|
||
|
||
_subscription = _channel!.stream.listen(
|
||
(data) => _handleMessage(data),
|
||
onDone: () {
|
||
_isConnected = false;
|
||
Log.w('QrcodeWsService: 连接关闭');
|
||
_scheduleReconnect();
|
||
},
|
||
onError: (e) {
|
||
_isConnected = false;
|
||
Log.e('QrcodeWsService: 连接错误 $e');
|
||
_scheduleReconnect();
|
||
},
|
||
);
|
||
|
||
Log.i('QrcodeWsService: 连接成功');
|
||
if (_subscribedCode != null) {
|
||
_sendSubscribe(_subscribedCode!);
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
Log.w('QrcodeWsService: 连接失败 $e');
|
||
_isConnected = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 订阅二维码状态变更
|
||
Future<void> subscribe(String code, QrcodeStatusCallback onStatus) async {
|
||
_subscribedCode = code;
|
||
_onStatusUpdate = onStatus;
|
||
|
||
if (!_isConnected) {
|
||
final ok = await connect();
|
||
if (!ok) {
|
||
Log.w('QrcodeWsService: WebSocket不可用,将使用HTTP轮询降级');
|
||
return;
|
||
}
|
||
}
|
||
|
||
_sendSubscribe(code);
|
||
}
|
||
|
||
/// 取消订阅
|
||
void unsubscribe() {
|
||
if (_isConnected && _subscribedCode != null) {
|
||
_send({'type': 'qrcode_unsubscribe', 'code': _subscribedCode});
|
||
}
|
||
_subscribedCode = null;
|
||
_onStatusUpdate = null;
|
||
}
|
||
|
||
/// 断开连接
|
||
void disconnect() {
|
||
_subscription?.cancel();
|
||
_subscription = null;
|
||
_heartbeatTimer?.cancel();
|
||
_reconnectTimer?.cancel();
|
||
_channel?.sink.close();
|
||
_channel = null;
|
||
_isConnected = false;
|
||
_subscribedCode = null;
|
||
_onStatusUpdate = null;
|
||
_reconnectAttempts = 0;
|
||
Log.i('QrcodeWsService: 已断开');
|
||
}
|
||
|
||
void _sendSubscribe(String code) {
|
||
_send({
|
||
'type': 'qrcode_subscribe',
|
||
'code': code,
|
||
});
|
||
Log.i('QrcodeWsService: 已订阅 $code');
|
||
}
|
||
|
||
void _send(Map<String, dynamic> data) {
|
||
if (!_isConnected || _channel == null) return;
|
||
try {
|
||
_channel!.sink.add(jsonEncode(data));
|
||
} catch (e) {
|
||
Log.w('QrcodeWsService: 发送失败 $e');
|
||
}
|
||
}
|
||
|
||
void _handleMessage(dynamic data) {
|
||
try {
|
||
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
||
final type = json['type'] as String? ?? '';
|
||
|
||
if (type == 'qrcode_status_update') {
|
||
final status = json['status'] as String? ?? '';
|
||
Log.i('QrcodeWsService: 收到状态推送 $status');
|
||
_onStatusUpdate?.call(json);
|
||
if (status == 'confirmed' || status == 'expired' ||
|
||
status == 'cancelled') {
|
||
Log.i('QrcodeWsService: 终态 $status,自动取消订阅');
|
||
unsubscribe();
|
||
}
|
||
} else if (type == 'pong') {
|
||
// heartbeat ack
|
||
}
|
||
} catch (e) {
|
||
Log.w('QrcodeWsService: 消息解析失败 $e');
|
||
}
|
||
}
|
||
|
||
void _startHeartbeat() {
|
||
_heartbeatTimer?.cancel();
|
||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 25), (_) {
|
||
_send({'type': 'ping'});
|
||
});
|
||
}
|
||
|
||
void _scheduleReconnect() {
|
||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||
Log.w('QrcodeWsService: 超过最大重连次数');
|
||
return;
|
||
}
|
||
if (_subscribedCode == null) return;
|
||
|
||
_reconnectAttempts++;
|
||
final delay = Duration(seconds: _reconnectAttempts * 2);
|
||
Log.i('QrcodeWsService: ${delay.inSeconds}秒后重连 (第$_reconnectAttempts次)');
|
||
|
||
_reconnectTimer?.cancel();
|
||
_reconnectTimer = Timer(delay, () async {
|
||
final ok = await connect();
|
||
if (ok && _subscribedCode != null) {
|
||
_sendSubscribe(_subscribedCode!);
|
||
}
|
||
});
|
||
}
|
||
|
||
String _resolveWsUrl() {
|
||
return 'wss://tools.wktyl.com:9443';
|
||
}
|
||
}
|