同步
This commit is contained in:
170
docs/toolsapi/scripts/qrcode_ws_relay.dart
Normal file
170
docs/toolsapi/scripts/qrcode_ws_relay.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 二维码登录WebSocket中继服务器
|
||||
/// 创建时间: 2026-06-02
|
||||
/// 更新时间: 2026-06-02
|
||||
/// 作用: WebSocket中继服务器,推送二维码状态变更给订阅客户端
|
||||
/// 上次更新: 初始创建,基于shelf_web_socket
|
||||
///
|
||||
/// 部署方式:
|
||||
/// dart run qrcode_ws_relay.dart --port 9444
|
||||
///
|
||||
/// 架构:
|
||||
/// 1. 客户端连接 ws://host:9444 并发送 {"type":"qrcode_subscribe","code":"xxx"}
|
||||
/// 2. PHP后端在qrcodeConfirm/qrcodeCancel时调用 POST /notify
|
||||
/// 3. 中继服务器将状态变更推送给订阅了对应code的客户端
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:shelf/shelf.dart' as shelf;
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import 'package:shelf_web_socket/shelf_web_socket.dart';
|
||||
|
||||
final _subscribers = <String, List<WebSocketChannel>>{};
|
||||
final _codeToChannels = <String, List<WebSocketChannel>>{};
|
||||
|
||||
void main(List<String> args) async {
|
||||
final port = int.tryParse(args.isNotEmpty ? args.first : '9444') ?? 9444;
|
||||
|
||||
final router = Router();
|
||||
|
||||
router.get('/ws', webSocketHandler((WebSocketChannel ws) {
|
||||
String? subscribedCode;
|
||||
|
||||
ws.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
final type = json['type'] as String? ?? '';
|
||||
|
||||
if (type == 'qrcode_subscribe') {
|
||||
final code = json['code'] as String? ?? '';
|
||||
if (code.isNotEmpty) {
|
||||
subscribedCode = code;
|
||||
_codeToChannels.putIfAbsent(code, () => []).add(ws);
|
||||
print('[subscribe] code=$code total=${_codeToChannels[code]?.length}');
|
||||
ws.sink.add(jsonEncode({
|
||||
'type': 'qrcode_subscribed',
|
||||
'code': code,
|
||||
}));
|
||||
}
|
||||
} else if (type == 'qrcode_unsubscribe') {
|
||||
final code = json['code'] as String? ?? '';
|
||||
if (code.isNotEmpty) {
|
||||
_codeToChannels[code]?.remove(ws);
|
||||
if (_codeToChannels[code]?.isEmpty ?? false) {
|
||||
_codeToChannels.remove(code);
|
||||
}
|
||||
subscribedCode = null;
|
||||
print('[unsubscribe] code=$code');
|
||||
}
|
||||
} else if (type == 'ping') {
|
||||
ws.sink.add(jsonEncode({'type': 'pong'}));
|
||||
}
|
||||
} catch (e) {
|
||||
print('[error] parse failed: $e');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (subscribedCode != null) {
|
||||
_codeToChannels[subscribedCode]?.remove(ws);
|
||||
if (_codeToChannels[subscribedCode]?.isEmpty ?? false) {
|
||||
_codeToChannels.remove(subscribedCode);
|
||||
}
|
||||
print('[disconnect] code=$subscribedCode');
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
print('[error] $e');
|
||||
if (subscribedCode != null) {
|
||||
_codeToChannels[subscribedCode]?.remove(ws);
|
||||
}
|
||||
},
|
||||
);
|
||||
}));
|
||||
|
||||
router.post('/notify', (shelf.Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
final code = json['code'] as String? ?? '';
|
||||
final status = json['status'] as String? ?? '';
|
||||
final token = json['token'] as String?;
|
||||
|
||||
if (code.isEmpty || status.isEmpty) {
|
||||
return shelf.Response(400, body: jsonEncode({'error': 'code and status required'}));
|
||||
}
|
||||
|
||||
final channels = _codeToChannels[code];
|
||||
if (channels == null || channels.isEmpty) {
|
||||
return shelf.Response.ok(jsonEncode({'sent': 0, 'message': 'no subscribers'}));
|
||||
}
|
||||
|
||||
final message = jsonEncode({
|
||||
'type': 'qrcode_status_update',
|
||||
'code': code,
|
||||
'status': status,
|
||||
if (token != null) 'token': token,
|
||||
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
|
||||
var sent = 0;
|
||||
final toRemove = <WebSocketChannel>[];
|
||||
for (final ch in channels) {
|
||||
try {
|
||||
ch.sink.add(message);
|
||||
sent++;
|
||||
} catch (e) {
|
||||
toRemove.add(ch);
|
||||
}
|
||||
}
|
||||
for (final ch in toRemove) {
|
||||
channels.remove(ch);
|
||||
}
|
||||
|
||||
print('[notify] code=$code status=$status sent=$sent');
|
||||
return shelf.Response.ok(jsonEncode({'sent': sent}));
|
||||
} catch (e) {
|
||||
return shelf.Response(500, body: jsonEncode({'error': e.toString()}));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', (shelf.Request request) {
|
||||
final stats = <String, int>{};
|
||||
for (final entry in _codeToChannels.entries) {
|
||||
stats[entry.key] = entry.value.length;
|
||||
}
|
||||
return shelf.Response.ok(jsonEncode({
|
||||
'total_codes': _codeToChannels.length,
|
||||
'subscribers': stats,
|
||||
}));
|
||||
});
|
||||
|
||||
final handler = const shelf.Pipeline()
|
||||
.addMiddleware(shelf.logRequests())
|
||||
.addHandler(router.call);
|
||||
|
||||
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, port);
|
||||
print('🚀 QR Code WebSocket Relay running on ws://0.0.0.0:$port/ws');
|
||||
print('📡 Notify endpoint: POST http://0.0.0.0:$port/notify');
|
||||
print('📊 Stats endpoint: GET http://0.0.0.0:$port/stats');
|
||||
}
|
||||
|
||||
// WebSocketChannel stub for standalone server
|
||||
// When running as standalone, import web_socket_channel directly
|
||||
class WebSocketChannel {
|
||||
final Stream<dynamic> stream;
|
||||
final WebSocketSink sink;
|
||||
WebSocketChannel(this.stream, this.sink);
|
||||
}
|
||||
|
||||
class WebSocketSink {
|
||||
final Function(String) _add;
|
||||
final Function() _close;
|
||||
WebSocketSink(this._add, this._close);
|
||||
void add(String data) => _add(data);
|
||||
void close() => _close();
|
||||
}
|
||||
Reference in New Issue
Block a user