Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'protocol.dart';
|
||||
import '../device/device_bridge.dart';
|
||||
import '../device/push_bridge.dart';
|
||||
|
||||
class ImIncoming {
|
||||
ImIncoming({
|
||||
required this.from,
|
||||
required this.text,
|
||||
this.kind = 'chat',
|
||||
this.fp,
|
||||
this.seq = 0,
|
||||
this.raw = const {},
|
||||
});
|
||||
final String from;
|
||||
final String text;
|
||||
final String kind;
|
||||
final String? fp;
|
||||
final int seq;
|
||||
final Map<String, dynamic> raw;
|
||||
}
|
||||
|
||||
class _Pending {
|
||||
_Pending(this.p);
|
||||
final Protocal p;
|
||||
int tries = 0;
|
||||
}
|
||||
|
||||
/// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。
|
||||
class ImTcpClient extends ChangeNotifier {
|
||||
Socket? _socket;
|
||||
Uint8List _buf = Uint8List(0);
|
||||
Timer? _hb;
|
||||
Timer? _qos;
|
||||
Timer? _reconnect;
|
||||
bool connected = false;
|
||||
bool loggedIn = false;
|
||||
String lastError = '';
|
||||
String userId = '';
|
||||
int firstLoginTime = 0;
|
||||
final Map<String, _Pending> _pending = {};
|
||||
final List<ImIncoming> inbox = [];
|
||||
final _uuid = const Uuid();
|
||||
String _host = '';
|
||||
int _port = 0;
|
||||
String _token = '';
|
||||
bool _want = false;
|
||||
int _tries = 0;
|
||||
DateTime _lastHbAck = DateTime.now();
|
||||
VoidCallback? onLoggedIn;
|
||||
|
||||
Future<void> connect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String userId,
|
||||
required String token,
|
||||
}) async {
|
||||
_host = host;
|
||||
_port = port;
|
||||
_token = token;
|
||||
this.userId = userId;
|
||||
_want = true;
|
||||
_tries = 0;
|
||||
await _open();
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
_tearDownSocket();
|
||||
lastError = '';
|
||||
try {
|
||||
_socket = await Socket.connect(_host, _port,
|
||||
timeout: const Duration(seconds: 8));
|
||||
connected = true;
|
||||
_tries = 0;
|
||||
notifyListeners();
|
||||
_socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop());
|
||||
firstLoginTime = DateTime.now().millisecondsSinceEpoch;
|
||||
_send(Protocal(
|
||||
type: ClientType.login,
|
||||
from: userId,
|
||||
dataContent: jsonEncode({
|
||||
'loginUserId': userId,
|
||||
'loginToken': _token,
|
||||
'clientKind': 'mobile',
|
||||
'extra': '',
|
||||
'firstLoginTime': firstLoginTime,
|
||||
}),
|
||||
));
|
||||
_hb = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (loggedIn) {
|
||||
if (DateTime.now().difference(_lastHbAck).inSeconds > 25) {
|
||||
_onDrop();
|
||||
return;
|
||||
}
|
||||
_send(Protocal(type: ClientType.keepAlive, from: userId));
|
||||
}
|
||||
});
|
||||
_qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos());
|
||||
} catch (e) {
|
||||
lastError = 'IM 连接失败:$e';
|
||||
connected = false;
|
||||
loggedIn = false;
|
||||
notifyListeners();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _onDrop() {
|
||||
final was = connected || loggedIn;
|
||||
_tearDownSocket();
|
||||
if (was) notifyListeners();
|
||||
if (_want) _scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (!_want) return;
|
||||
_reconnect?.cancel();
|
||||
_tries = (_tries + 1).clamp(1, 12);
|
||||
_reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () {
|
||||
if (_want && !loggedIn) _open();
|
||||
});
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_want = false;
|
||||
_reconnect?.cancel();
|
||||
_reconnect = null;
|
||||
_tearDownSocket();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _tearDownSocket() {
|
||||
_hb?.cancel();
|
||||
_qos?.cancel();
|
||||
_hb = null;
|
||||
_qos = null;
|
||||
try {
|
||||
_socket?.destroy();
|
||||
} catch (_) {}
|
||||
_socket = null;
|
||||
_buf = Uint8List(0);
|
||||
connected = false;
|
||||
loggedIn = false;
|
||||
}
|
||||
|
||||
void bump() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String?> sendChat(String toUserId, String text) async {
|
||||
if (!loggedIn) return null;
|
||||
final fp = _uuid.v4();
|
||||
final p = Protocal(
|
||||
type: ClientType.commonData,
|
||||
from: userId,
|
||||
to: toUserId,
|
||||
dataContent:
|
||||
jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}),
|
||||
qos: true,
|
||||
fp: fp,
|
||||
typeu: 0,
|
||||
sm: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
_pending[fp] = _Pending(p);
|
||||
_send(p);
|
||||
return fp;
|
||||
}
|
||||
|
||||
void _retryQos() {
|
||||
for (final e in [..._pending.entries]) {
|
||||
e.value.tries++;
|
||||
if (e.value.tries > 3) {
|
||||
_pending.remove(e.key);
|
||||
continue;
|
||||
}
|
||||
_send(e.value.p);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(Protocal p) {
|
||||
final s = _socket;
|
||||
if (s == null) return;
|
||||
try {
|
||||
s.add(encodeFrame(p));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onData(List<int> chunk) {
|
||||
_buf = Uint8List.fromList([..._buf, ...chunk]);
|
||||
while (_buf.length >= 4) {
|
||||
final len = ByteData.sublistView(_buf).getUint32(0);
|
||||
if (len <= 0 || len > maxBody) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
if (_buf.length < 4 + len) break;
|
||||
final body = utf8.decode(_buf.sublist(4, 4 + len));
|
||||
_buf = Uint8List.fromList(_buf.sublist(4 + len));
|
||||
try {
|
||||
_handle(Protocal.fromJson(jsonDecode(body) as Map<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
void _handle(Protocal p) {
|
||||
switch (p.type) {
|
||||
case ServerType.login:
|
||||
try {
|
||||
final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent);
|
||||
loggedIn = info is Map && info['code'] == 0;
|
||||
if (loggedIn && info['firstLoginTime'] != null) {
|
||||
firstLoginTime = (info['firstLoginTime'] as num).toInt();
|
||||
}
|
||||
if (!loggedIn) lastError = 'IM 登录被拒绝';
|
||||
} catch (_) {
|
||||
loggedIn = false;
|
||||
}
|
||||
if (loggedIn) {
|
||||
_lastHbAck = DateTime.now();
|
||||
onLoggedIn?.call();
|
||||
}
|
||||
notifyListeners();
|
||||
break;
|
||||
case ServerType.keepAlive:
|
||||
case ServerType.echo:
|
||||
_lastHbAck = DateTime.now();
|
||||
break;
|
||||
case ServerType.kickout:
|
||||
_tearDownSocket();
|
||||
notifyListeners();
|
||||
if (_want) _scheduleReconnect();
|
||||
break;
|
||||
case ClientType.received:
|
||||
_pending.remove(p.dataContent);
|
||||
break;
|
||||
case ClientType.commonData:
|
||||
if (p.qos && p.fp != null) {
|
||||
_send(Protocal(
|
||||
type: ClientType.received,
|
||||
from: userId,
|
||||
to: p.from,
|
||||
dataContent: p.fp!));
|
||||
}
|
||||
String text = p.dataContent;
|
||||
var kind = 'chat';
|
||||
Map<String, dynamic> raw = {};
|
||||
try {
|
||||
raw = Map<String, dynamic>.from(jsonDecode(p.dataContent) as Map);
|
||||
text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}';
|
||||
kind = '${raw['kind'] ?? 'chat'}';
|
||||
} catch (_) {}
|
||||
final seq = (raw['seq'] as num?)?.toInt() ??
|
||||
(p.sm > 0 ? p.sm : 0);
|
||||
final fp = '${raw['fingerprint'] ?? p.fp ?? ''}';
|
||||
if (fp.isNotEmpty) {
|
||||
final dup = inbox.any((e) => e.fp == fp);
|
||||
if (dup) break;
|
||||
}
|
||||
inbox.insert(
|
||||
0,
|
||||
ImIncoming(
|
||||
from: p.from,
|
||||
text: text,
|
||||
kind: kind,
|
||||
fp: fp.isEmpty ? p.fp : fp,
|
||||
seq: seq,
|
||||
raw: raw));
|
||||
final terminalCall = kind == 'call' &&
|
||||
const {'end', 'reject', 'timeout'}
|
||||
.contains('${raw['action'] ?? ''}');
|
||||
if (terminalCall) {
|
||||
unawaited(DeviceBridge.cancelCallNotification());
|
||||
} else if (p.from != userId && kind != 'receipt' && kind != 'signal') {
|
||||
unawaited(PushBridge.showOnline(
|
||||
'${raw['fromName'] ?? raw['title'] ?? '风影办公'}',
|
||||
text,
|
||||
extras: {
|
||||
if (raw['conversationId'] != null)
|
||||
'conversationId': '${raw['conversationId']}',
|
||||
if (raw['kind'] != null) 'kind': '$kind',
|
||||
if (raw['callId'] != null) 'callId': '${raw['callId']}',
|
||||
if (raw['callKind'] != null) 'callKind': '${raw['callKind']}',
|
||||
if (raw['fromName'] != null) 'fromName': '${raw['fromName']}',
|
||||
if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}',
|
||||
if (raw['action'] != null) 'action': '${raw['action']}',
|
||||
},
|
||||
));
|
||||
}
|
||||
if (inbox.length > 200) inbox.removeLast();
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user