Files
xianyan/docs/toolsapi/scripts/qrcode_ws_relay.dart
Developer 10df6b705c 同步
2026-06-02 03:52:54 +08:00

171 lines
5.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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();
}