diff --git a/client/lib/modules/dual_check/api.dart b/client/lib/modules/dual_check/api.dart index 2795ce5..2dcf02c 100644 --- a/client/lib/modules/dual_check/api.dart +++ b/client/lib/modules/dual_check/api.dart @@ -1,50 +1,40 @@ // DualCheck API 封装 -// 后端统一响应格式:{code:0, message, data} -// WebSocket 消息格式:{type, data, error} +import 'dart:convert'; +import 'package:flutter/foundation.dart' show kIsWeb; + import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -/// 后端地址 -const String _baseUrl = kIsWeb - ? 'http://localhost:8080' - : 'http://127.0.0.1:8080'; - -/// WebSocket 地址 +// ── URL ── +const String _baseUrl = + kIsWeb ? 'http://localhost:8080' : 'http://127.0.0.1:8080'; const String _wsBaseUrl = kIsWeb ? 'ws://localhost:8080' : 'ws://127.0.0.1:8080'; class DualCheckApi { - DualCheckApi() - : _dio = Dio(BaseOptions( - baseUrl: _baseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - )) { - _dio.interceptors.add(InterceptorsWrapper( - onResponse: (response, handler) { - final data = response.data; - if (data is Map && data['code'] != 0) { - handler.reject(DioException( - requestOptions: response.requestOptions, - response: response, - type: DioExceptionType.badResponse, - error: _BizError(data['code'] as int, data['message'] as String), - )); - return; - } - handler.next(response); - }, - )); - } + DualCheckApi._(); + static final DualCheckApi instance = DualCheckApi._(); - final Dio _dio; + final Dio _dio = Dio(BaseOptions( + baseUrl: _baseUrl, + contentType: 'application/json', + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + )) + ..interceptors.add(_BizErrorInterceptor()); // ── Create ── - Future createSession({required String nickname}) async { + Future createSession({ + required String nickname, + required String browserId, + }) async { final res = await _dio.post>( '/api/v1/dual-check/create', - data: {'nickname': nickname}, + data: { + 'nickname': nickname, + 'browser_id': browserId, + }, ); final d = res.data!['data'] as Map; return CreateResp( @@ -82,8 +72,9 @@ class DualCheckApi { // ── CheckSession ── Future checkSession(String sessionId) async { - final res = await _dio.get>( - '/api/v1/dual-check/check/$sessionId', + final res = await _dio.post>( + '/api/v1/dual-check/check', + data: {'session_id': sessionId}, ); final d = res.data!['data'] as Map; return CheckResp( @@ -97,18 +88,42 @@ class DualCheckApi { // ── GetSession 全量快照 ── Future getSession(String sessionId) async { - final res = await _dio.get>( - '/api/v1/dual-check/session/$sessionId', + final res = await _dio.post>( + '/api/v1/dual-check/session', + data: {'session_id': sessionId}, ); return parseSession(res.data!['data'] as Map); } + // ── Join 参与者加入登记 ── + + Future> joinParticipant({ + required String sessionId, + required String nickname, + }) async { + final res = await _dio.post>( + '/api/v1/dual-check/join', + data: {'session_id': sessionId, 'nickname': nickname}, + ); + return res.data!['data'] as Map; + } + // ── SetResubmit 发起方切换"允许参与者重新提交" ── Future setResubmit(String sessionId, {required bool allow}) async { final res = await _dio.post>( - '/api/v1/dual-check/session/$sessionId/resubmit', - data: {'allow': allow}, + '/api/v1/dual-check/resubmit', + data: {'session_id': sessionId, 'allow': allow}, + ); + return parseSession(res.data!['data'] as Map); + } + + // ── SetIgnoreWs 发起方切换"忽略首尾空白" ── + + Future setIgnoreWs(String sessionId, {required bool ignore}) async { + final res = await _dio.post>( + '/api/v1/dual-check/ignore-ws', + data: {'session_id': sessionId, 'ignore': ignore}, ); return parseSession(res.data!['data'] as Map); } @@ -116,7 +131,7 @@ class DualCheckApi { // ── EndSession 发起方主动结束会话 ── Future endSession(String sessionId) async { - await _dio.post('/api/v1/dual-check/session/$sessionId/end'); + await _dio.post('/api/v1/dual-check/end', data: {'session_id': sessionId}); } static SessionResp parseSession(Map d) { @@ -128,6 +143,7 @@ class DualCheckApi { initiatorNickname: (d['initiator_nickname'] ?? '') as String, initiatorReady: d['initiator_ready'] as bool, allowResubmit: (d['allow_resubmit'] as bool?) ?? false, + ignoreWs: (d['ignore_ws'] as bool?) ?? false, participants: parts, ttlSeconds: d['ttl_seconds'] as int, ); @@ -139,14 +155,57 @@ class DualCheckApi { required String sessionId, required String role, required String nickname, + required String browserId, }) { return '$_wsBaseUrl/api/v1/dual-check/ws' '?session_id=${Uri.encodeQueryComponent(sessionId)}' '&role=$role' - '&nickname=${Uri.encodeQueryComponent(nickname)}'; + '&nickname=${Uri.encodeQueryComponent(nickname)}' + '&browser_id=${Uri.encodeQueryComponent(browserId)}'; } } +// ── Dio 拦截器:把后端业务错误转成 DioException ── + +class _BizErrorInterceptor extends Interceptor { + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final data = response.data; + if (data is Map) { + final code = data['code']; + if (code != null && code != 0) { + final msg = data['message']?.toString() ?? '请求失败'; + handler.reject(DioException( + requestOptions: response.requestOptions, + response: response, + error: _BizError(code as int, msg), + type: DioExceptionType.badResponse, + )); + return; + } + } + handler.next(response); + } +} + +class _BizError { + final int code; + final String message; + const _BizError(this.code, this.message); + @override + String toString() => 'BusinessError($code): $message'; +} + +bool isBizError(DioException e) => e.error is _BizError; +int? bizErrorCode(DioException e) { + final err = e.error; + return err is _BizError ? err.code : null; +} +String bizErrorMessage(DioException e) { + final err = e.error; + return err is _BizError ? err.message : (e.message ?? '请求失败'); +} + // ── 数据模型 ── class CreateResp { @@ -187,14 +246,12 @@ class ParticipantResp { required this.submitted, this.match, }); - factory ParticipantResp.fromMap(Map m) { - return ParticipantResp( - nickname: m['nickname'] as String, - joined: m['joined'] as bool? ?? true, - submitted: m['submitted'] as bool, - match: m['match'] as bool?, - ); - } + factory ParticipantResp.fromMap(Map m) => ParticipantResp( + nickname: m['nickname'] as String, + joined: (m['joined'] as bool?) ?? true, + submitted: (m['submitted'] as bool?) ?? false, + match: m['match'] as bool?, + ); } class SessionResp { @@ -202,6 +259,7 @@ class SessionResp { final String initiatorNickname; final bool initiatorReady; final bool allowResubmit; + final bool ignoreWs; final List participants; final int ttlSeconds; SessionResp({ @@ -209,45 +267,49 @@ class SessionResp { required this.initiatorNickname, required this.initiatorReady, required this.allowResubmit, + required this.ignoreWs, required this.participants, required this.ttlSeconds, }); + + SessionResp copyWith({ + String? sessionId, + String? initiatorNickname, + bool? initiatorReady, + bool? allowResubmit, + bool? ignoreWs, + List? participants, + int? ttlSeconds, + }) => + SessionResp( + sessionId: sessionId ?? this.sessionId, + initiatorNickname: initiatorNickname ?? this.initiatorNickname, + initiatorReady: initiatorReady ?? this.initiatorReady, + allowResubmit: allowResubmit ?? this.allowResubmit, + ignoreWs: ignoreWs ?? this.ignoreWs, + participants: participants ?? this.participants, + ttlSeconds: ttlSeconds ?? this.ttlSeconds, + ); } -// ── WS 消息类型 ── +// ── WS 消息解析 ── class WSMessage { final String type; final Map data; - final String? error; + final String error; + WSMessage({required this.type, required this.data, this.error = ''}); - WSMessage({required this.type, required this.data, this.error}); - - factory WSMessage.fromJson(Map json) { - return WSMessage( - type: json['type'] as String, - data: (json['data'] as Map?) ?? const {}, - error: json['error'] as String?, - ); + factory WSMessage.fromJson(String raw) { + try { + final m = jsonDecode(raw) as Map; + return WSMessage( + type: m['type'] as String? ?? '', + data: (m['data'] as Map?) ?? {}, + error: (m['error'] as String?) ?? '', + ); + } catch (_) { + return WSMessage(type: '', data: {}); + } } } - -// ── 业务错误处理 ── - -class _BizError implements Exception { - _BizError(this.code, this.message); - final int code; - final String message; - @override - String toString() => '[biz:$code] $message'; -} - -bool isBizError(DioException e) => e.error is _BizError; -int? bizErrorCode(DioException e) { - final err = e.error; - return err is _BizError ? err.code : null; -} -String bizErrorMessage(DioException e) { - final err = e.error; - return err is _BizError ? err.message : (e.message ?? '请求失败'); -} diff --git a/client/lib/modules/dual_check/dual_check.dart b/client/lib/modules/dual_check/dual_check.dart index 54f05e8..ec4f063 100644 --- a/client/lib/modules/dual_check/dual_check.dart +++ b/client/lib/modules/dual_check/dual_check.dart @@ -3,6 +3,7 @@ // 通过 WebSocket 实时同步参与者列表 + 提交状态 + match 结果 import 'dart:async'; import 'dart:convert'; +import 'dart:html' if (dart.library.io) 'package:flutter/foundation.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; @@ -21,29 +22,62 @@ class DualCheck extends StatefulWidget { State createState() => _DualCheckState(); } -class _DualCheckState extends State { - final _api = DualCheckApi(); +class _DualCheckState extends State with WidgetsBindingObserver { + final _api = DualCheckApi.instance; final _nickService = NicknameService.instance; final _textCtrl = TextEditingController(); // ── 会话状态 ── String? _sessionId; - late String _nickname = '用户'; + late String _nickname = '用户'; // 本地显示昵称(由 NicknameService 驱动) + String _serverNick = ''; // 服务端已知的昵称(与 server 同步) late _RoleSelect _myRole = _RoleSelect.initiator; SessionResp? _session; // 当前会话全量状态(发起方视图) // ── WS ── WebSocketChannel? _ws; StreamSubscription? _wsSub; + bool _wsStreamDone = false; // WS 底层 stream 是否已关闭 + + // ── 浏览器/APP 生命周期 ── + bool _pageHidden = false; // 页面是否被隐藏过(paused)——用于识别浏览器手势返回等场景 + + // ── rename 失败回滚 ── + String? _renamePendingOldNick; // 发送 rename 前暂存当前昵称,若后端报错则回滚 + String? _renamePendingNewNick; // 发送 rename 前暂存目标昵称,用于去重 // ── UI 状态 ── bool _isLoading = false; String? _errorMsg; + String? _mySubmittedText; // 自己提交过的文本(发起方提交后展示用) + bool _initTextExpanded = false; // 发起方已提交文本是否展开 + bool _sessionEndedShown = false; // 防重复弹 session_ended 弹窗 + bool _mySubmitted = false; // 参与者:自己是否已提交 + bool? _myMatch; // 参与者:自己的 match 结果(null=发起方还没提交) + + StreamSubscription? _nickSub; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + _setupBrowserLifecycle(); _loadNickname(); + // 开启跨标签页同步(仅 Web 生效) + _nickService.enableCrossTabSync(); + // 监听昵称变化(本地修改 + 其他标签页修改都会触发) + _nickSub = _nickService.onChanged.listen((n) { + if (!mounted) return; + final oldNick = _nickname; + setState(() => _nickname = n); + // 如果正在会话中且是参与者,通知后端改名 + if (_sessionId != null && _myRole == _RoleSelect.participant && oldNick != n) { + debugPrint('[DC] onChanged: old=$oldNick new=$n sessionId=$_sessionId ws=$_ws'); + _sendRename(n); + } else { + debugPrint('[DC] onChanged skip: sessionId=$_sessionId role=$_myRole old=$oldNick new=$n'); + } + }); } Future _loadNickname() async { @@ -54,17 +88,109 @@ class _DualCheckState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + _teardownBrowserLifecycle(); + _nickSub?.cancel(); _wsSub?.cancel(); _ws?.sink.close(); _textCtrl.dispose(); super.dispose(); } + // ── dart:html 浏览器事件监听(处理 Safari bfcache 手势返回恢复) ── + // visibilitychange:切 tab 触发(切走 hidden,切回 visible) + // pageshow:页面显示时触发(包括 bfcache 恢复!Safari 手势拉回来必触发) + // pagehide:页面隐藏时触发(包括 bfcache 冻结!Safari 手势后退必触发) + void _setupBrowserLifecycle() { + try { + // ignore: undefined_function —— dart:html 仅在 Web 编译时可用 + document.onVisibilityChange.listen((_) { + final hidden = document.hidden ?? false; + debugPrint('[DC] html visibilitychange hidden=$hidden'); + if (hidden) { + _pageHidden = true; + } else { + _checkAndExit(); + } + }); + // ignore: undefined_function + window.onPageShow.listen((e) { + // persisted=true 表示从 bfcache 恢复(Safari 手势拉回来的场景) + final persisted = (e as dynamic).persisted as bool? ?? false; + debugPrint('[DC] html pageshow persisted=$persisted pageHidden=$_pageHidden wsDone=$_wsStreamDone'); + if (persisted) { + _pageHidden = true; // bfcache 恢复,标记为隐藏过 + } + _checkAndExit(); + }); + // ignore: undefined_function + window.onPageHide.listen((e) { + final persisted = (e as dynamic).persisted as bool? ?? false; + debugPrint('[DC] html pagehide persisted=$persisted'); + if (persisted) { + _pageHidden = true; + } + }); + debugPrint('[DC] browser lifecycle listeners installed'); + } catch (_) { + // 非 Web 平台(iOS/Android native)忽略 + debugPrint('[DC] dart:html not available, skip browser lifecycle setup'); + } + } + + void _teardownBrowserLifecycle() { + try { + // ignore: undefined_function + document.onVisibilityChange.drain(); + // ignore: undefined_function + window.onPageShow.drain(); + // ignore: undefined_function + window.onPageHide.drain(); + } catch (_) {} + } + + // 统一的退出检查:只要"页面被浏览器隐藏过" + "WS 已断" → 强制退出 + void _checkAndExit() { + if (_pageHidden && _wsStreamDone && _sessionId != null && mounted) { + debugPrint('[DC] _checkAndExit → force exit (pageHidden+wsDone)'); + _reset(); + Navigator.of(context).pop(); + } else if (_pageHidden && _wsStreamDone) { + debugPrint('[DC] _checkAndExit: conditions met but not mounted or no session'); + } else { + debugPrint('[DC] _checkAndExit: no exit. pageHidden=$_pageHidden wsDone=$_wsStreamDone session=$_sessionId'); + } + } + + // ── WidgetsBindingObserver 生命周期:识别浏览器手势返回后又恢复的场景 ── + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { + _pageHidden = true; + debugPrint('[DC] lifecycle: page hidden (paused/inactive)'); + } else if (state == AppLifecycleState.resumed) { + debugPrint('[DC] lifecycle: page resumed, pageHidden=$_pageHidden wsDone=$_wsStreamDone session=$_sessionId'); + if (_pageHidden) { + _pageHidden = false; + // 关键:页面被浏览器隐藏过(手势返回/切标签),恢复后 WS 已断 → 强制退出 + if (_wsStreamDone && _sessionId != null) { + debugPrint('[DC] page was hidden + WS closed → force exit to home'); + _reset(); + if (mounted) { + Navigator.of(context).pop(); + } + } + } + } + } + // ── 发起方:创建会话 ── Future _createSession() async { setState(() => _isLoading = true); try { - final resp = await _api.createSession(nickname: _nickname); + final bid = await _nickService.browserId; + final resp = await _api.createSession(nickname: _nickname, browserId: bid); setState(() { _sessionId = resp.sessionId; _myRole = _RoleSelect.initiator; @@ -72,9 +198,9 @@ class _DualCheckState extends State { // WS 连接(发起方不需要 join,直接连接) _connectWs(); } on DioException catch (e) { - setState(() => _errorMsg = isBizError(e) ? bizErrorMessage(e) : '创建失败'); + setState(() => _errorMsg = _friendlyError(e, '创建失败')); } catch (e) { - setState(() => _errorMsg = '创建失败:$e'); + setState(() => _errorMsg = _friendlyError(e, '创建失败')); } finally { setState(() => _isLoading = false); } @@ -96,6 +222,7 @@ class _DualCheckState extends State { children: [ TextField( controller: ctrl, + autofocus: true, enabled: !isChecking, decoration: const InputDecoration( hintText: '粘贴或输入会话码', @@ -116,14 +243,20 @@ class _DualCheckState extends State { child: const Text('取消'), ), FilledButton( - onPressed: isChecking - ? null - : () async { - final sid = ctrl.text.trim(); + onPressed: isChecking + ? null + : () async { + String sid = ctrl.text.trim(); if (sid.isEmpty) { set(() => err = '请输入会话码'); return; } + // 如果粘贴的是 URL,尝试提取 session_id + final extracted = _extractSessionId(sid); + if (extracted != null) { + sid = extracted; + ctrl.text = sid; + } set(() { isChecking = true; err = null; @@ -163,19 +296,32 @@ class _DualCheckState extends State { } // ── WS 连接 ── - void _connectWs() { + Future _connectWs() async { final role = _myRole == _RoleSelect.initiator ? 'initiator' : 'participant'; + // 连接时用当前本地昵称;如果参与方被自动改名,server 会在 full_state 或 nickname_auto_changed 里通知 + _serverNick = _nickname; + final bid = await _nickService.browserId; final url = _api.wsUrl( sessionId: _sessionId!, role: role, nickname: _nickname, + browserId: bid, ); try { _ws = WebSocketChannel.connect(Uri.parse(url)); + _wsStreamDone = false; _wsSub = _ws!.stream.listen(_onWsMessage, onError: (e) { debugPrint('[DC] ws error: $e'); }, onDone: () { - debugPrint('[DC] ws closed'); + debugPrint('[DC] ws stream done (closed)'); + _wsStreamDone = true; + // 兜底:如果页面之前已被隐藏过(Safari 手势后退 bfcache 场景), + // WS onDone 可能在 resumed 之后才触发(时序问题),这里立即退出 + if (_pageHidden && _sessionId != null && mounted) { + debugPrint('[DC] ws done after page hidden → force exit'); + _reset(); + Navigator.of(context).pop(); + } }); } catch (e) { debugPrint('[DC] ws connect fail: $e'); @@ -183,14 +329,28 @@ class _DualCheckState extends State { } // ── WS 消息处理(事件驱动 UI) ── - void _onWsMessage(dynamic raw) { + Future _onWsMessage(dynamic raw) async { try { - final msg = WSMessage.fromJson( - jsonDecode(raw as String) as Map); + final msg = WSMessage.fromJson(raw as String); switch (msg.type) { case 'full_state': final s = DualCheckApi.parseSession(msg.data); - setState(() => _session = s); + // 在参与者列表中找到自己,提取 match 结果 + bool? myMatch; + for (final p in s.participants) { + if (p.nickname == _nickname && p.match != null) { + myMatch = p.match; + break; + } + } + setState(() { + _session = s; + if (myMatch != null) _myMatch = myMatch; + }); + // 如果之前已提交但发起方后来才提交 → 弹结果 + if (_mySubmitted && myMatch != null) { + _showMyResult(myMatch); + } case 'participant_joined': final nick = msg.data['nickname'] as String; setState(() { @@ -199,6 +359,7 @@ class _DualCheckState extends State { initiatorNickname: '', initiatorReady: false, allowResubmit: false, + ignoreWs: false, participants: [], ttlSeconds: 0); final list = List.from(_session!.participants); @@ -245,14 +406,105 @@ class _DualCheckState extends State { initiatorNickname: '', initiatorReady: false, allowResubmit: false, + ignoreWs: false, participants: [], ttlSeconds: 0); _session = _session!.copyWith(allowResubmit: allow); - if (allow) _errorMsg = null; // 开关打开时清掉上次"不能重提交"的报错 + if (allow) _errorMsg = null; + }); + case 'ignore_ws_changed': + final ignore = msg.data['ignore'] as bool; + setState(() { + _session ??= SessionResp( + sessionId: _sessionId!, + initiatorNickname: '', + initiatorReady: false, + allowResubmit: false, + ignoreWs: false, + participants: [], + ttlSeconds: 0); + _session = _session!.copyWith(ignoreWs: ignore); }); case 'session_ended': // 只有参与者收到才弹窗——发起方自己就是结束者,不需要再提示自己 if (_myRole == _RoleSelect.participant) _showSessionEnded(); + case 'participant_nickname_changed': + final oldNick = msg.data['old_nickname'] as String; + final newNick = msg.data['new_nickname'] as String; + setState(() { + if (_session == null) return; + final list = List.from(_session!.participants); + final idx = list.indexWhere((p) => p.nickname == oldNick); + if (idx >= 0) { + list[idx] = list[idx].copyWith(nickname: newNick); + _session = _session!.copyWith(participants: list); + } + }); + // 如果改名的是自己,更新 _serverNick 并清 pending + if (_myRole == _RoleSelect.participant && + (_renamePendingNewNick == newNick || _serverNick == oldNick)) { + _serverNick = newNick; + _renamePendingOldNick = null; + _renamePendingNewNick = null; + } + case 'nickname_auto_changed': + // 服务端因昵称冲突自动改名,只发给改名的那个客户端 + final newNick = msg.data['nickname'] as String; + final oldNick = _nickname; + debugPrint('[DC] nickname auto changed: $oldNick → $newNick'); + setState(() { + _nickname = newNick; + _serverNick = newNick; // 服务端已经用这个名字了 + _nickService.setNickname(newNick); // 持久化 + // 如果 _session 已初始化,更新自己在 participants 里的记录 + if (_session != null) { + final list = List.from(_session!.participants); + final idx = list.indexWhere((p) => p.nickname == oldNick); + if (idx >= 0) { + list[idx] = list[idx].copyWith(nickname: newNick); + _session = _session!.copyWith(participants: list); + } + } + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + _snack('🔔 昵称冲突,已自动改为 $newNick'), + ); + } + case 'error': + final errMsg = msg.error; + if (errMsg.isNotEmpty) { + debugPrint('[DC] ws error from server: $errMsg'); + // 如果是 rename 请求期间收到的错误,回滚本地昵称 + bool needRollback = _renamePendingOldNick != null; + final rollbackTo = _renamePendingOldNick; + final pendingNew = _renamePendingNewNick; + _renamePendingOldNick = null; + _renamePendingNewNick = null; + + if (needRollback && rollbackTo != null) { + debugPrint('[DC] rename failed, rolling back to: $rollbackTo'); + setState(() { + _nickname = rollbackTo; + _serverNick = rollbackTo; // 恢复 serverNick + _nickService.setNickname(rollbackTo); // 不 await,避免阻塞 SnackBar + // 如果 _session 的 participants 里有 pending 的新昵称,恢复回来 + if (_session != null && pendingNew != null) { + final list = List.from(_session!.participants); + final idx = list.indexWhere((p) => p.nickname == pendingNew); + if (idx >= 0) { + list[idx] = list[idx].copyWith(nickname: rollbackTo); + _session = _session!.copyWith(participants: list); + } + } + }); + } + if (_sessionId != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + _snack(errMsg, icon: Icons.error_outline, color: Colors.red), + ); + } + } } } catch (e) { debugPrint('[DC] ws parse error: $e'); @@ -262,7 +514,7 @@ class _DualCheckState extends State { // ── 提交文本 ── Future _submit() async { if (_sessionId == null) return; - final text = _textCtrl.text.trim(); + final text = _textCtrl.text; if (text.isEmpty) { setState(() => _errorMsg = '请输入内容后再提交'); return; @@ -279,7 +531,8 @@ class _DualCheckState extends State { role: role, text: text, ); - _textCtrl.clear(); + // 只清发起方的输入框(发起方提交后有只读预览卡片展示文本,参与者要保留原输入方便重提) + if (_myRole == _RoleSelect.initiator) _textCtrl.clear(); // 参与者提交后立刻在本地更新会话状态 if (_myRole == _RoleSelect.participant) { @@ -289,24 +542,22 @@ class _DualCheckState extends State { initiatorNickname: '', initiatorReady: false, allowResubmit: false, + ignoreWs: false, participants: [], ttlSeconds: 0); _session = _session!.copyWith( initiatorReady: resp.initiatorReady, ); + _mySubmitted = true; + _myMatch = resp.match; // null 表示发起方还没提交 }); - // 如果 initiator 已提交,可以直接显示 match + // 如果 initiator 已提交,可以直接显示 match 弹窗 if (resp.initiatorReady && resp.match != null) { _showMyResult(resp.match!); - } else { - _showParticipantWaiting(); } } else { - // 发起方提交后等 WS full_state 来刷新 - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('标准答案已提交,等待参与者...')), - ); + // 发起方提交后等 WS full_state 来刷新;同时保存自己的文本用于只读展示 + setState(() => _mySubmittedText = text); } } on DioException catch (e) { final code = bizErrorCode(e); @@ -324,17 +575,81 @@ class _DualCheckState extends State { } } + // 从粘贴的 URL/文本中提取会话码 + // 支持的格式: + // http://host/?session_id=M_xxx + // http://host/api/v1/dual-check/session/M_xxx + // /api/v1/dual-check/session/M_xxx + // session_id=M_xxx + // M_xxx → 原样返回 null(不是 URL) + String? _extractSessionId(String input) { + // 1. 先试 query param + try { + final uri = Uri.tryParse(input); + if (uri != null) { + if (uri.queryParameters['session_id'] case final sid?) return sid; + // 2. 再试 path 里的 session/XXX 或 check/XXX 段 + final segs = uri.pathSegments; + for (int i = 0; i < segs.length - 1; i++) { + if (segs[i] == 'session' || segs[i] == 'check') return segs[i + 1]; + } + } + } catch (_) {} + // 3. 纯文本匹配 session_id=XXX + final m = RegExp(r'session[_\-]?id=([^\s&]+)', caseSensitive: false).firstMatch(input); + if (m != null) return m.group(1); + return null; + } + + // 通过 WS 发送 rename 消息(仅参与者用) + void _sendRename(String newNick) { + if (_ws == null) { + debugPrint('[DC] _sendRename SKIP: ws is null'); + return; + } + // 重要:必须和 _serverNick 比较,不能和 _nickname 比。 + // 因为 _nickService.setNickname() 会同步触发 onChanged 把 _nickname 改成 newNick, + // 这时再和 _nickname 比永远相等,WS 消息就发不出去了。 + if (newNick == _serverNick) { + debugPrint('[DC] _sendRename SKIP: same as server-known nick ($_serverNick)'); + return; + } + // 暂存旧昵称,用于后端报错时回滚 + _renamePendingOldNick = _serverNick; // 用 serverNick 而不是 _nickname + _renamePendingNewNick = newNick; + try { + final msg = jsonEncode({ + 'type': 'rename', + 'data': {'new_nickname': newNick}, + }); + debugPrint('[DC] _sendRename → $msg'); + _ws!.sink.add(msg); + } catch (e) { + debugPrint('[DC] _sendRename FAIL: $e'); + _renamePendingOldNick = null; + _renamePendingNewNick = null; + } + } + // ── 重置 ── void _reset() { _wsSub?.cancel(); _ws?.sink.close(); _textCtrl.clear(); + _renamePendingOldNick = null; + _renamePendingNewNick = null; + _serverNick = ''; setState(() { _ws = null; _wsSub = null; _sessionId = null; _session = null; _errorMsg = null; + _mySubmittedText = null; + _initTextExpanded = false; + _sessionEndedShown = false; + _mySubmitted = false; + _myMatch = null; }); } @@ -378,13 +693,12 @@ class _DualCheckState extends State { } // ── 收到 session_ended WS 事件:弹窗 + 自动退出 ── - void _showSessionEnded() { - // 防重复弹窗(多次 session_ended / 重复 Build) - if (!mounted || _sessionId == null) return; + void _showSessionEnded() async { + // 防重复:收到多个 session_ended / 重复触发都只弹一次 + if (!mounted || _sessionId == null || _sessionEndedShown) return; + _sessionEndedShown = true; final sid = _sessionId!; - _reset(); - if (!mounted) return; - showDialog( + await showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( @@ -399,6 +713,8 @@ class _DualCheckState extends State { ], ), ); + if (!mounted) return; + _reset(); } // ── 发起方:切换允许重提交开关 ── @@ -419,7 +735,28 @@ class _DualCheckState extends State { _session = _session?.copyWith(allowResubmit: !value); }); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('操作失败:${bizErrorMessage(e)}')), + _snack('操作失败:${bizErrorMessage(e)}', icon: Icons.error_outline, color: Colors.red), + ); + } + } + + Future _toggleIgnoreWs(bool value) async { + final sid = _sessionId; + if (sid == null) return; + setState(() { + _session = _session?.copyWith(ignoreWs: value); + }); + try { + final sess = await _api.setIgnoreWs(sid, ignore: value); + if (!mounted) return; + setState(() => _session = sess); + } on DioException catch (e) { + if (!mounted) return; + setState(() { + _session = _session?.copyWith(ignoreWs: !value); + }); + ScaffoldMessenger.of(context).showSnackBar( + _snack('操作失败:${bizErrorMessage(e)}', icon: Icons.error_outline, color: Colors.red), ); } } @@ -427,12 +764,14 @@ class _DualCheckState extends State { // ── 设置昵称对话框 ── void _showNicknameDialog() { final ctrl = TextEditingController(text: _nickname); + final capturedOldNick = _nickname; // 捕获旧昵称(setNickname 是异步的,onChanged 也会 set,这里做个备份) showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('设置昵称'), content: TextField( controller: ctrl, + autofocus: true, maxLength: 32, decoration: const InputDecoration( hintText: '1-32 字符', @@ -447,16 +786,23 @@ class _DualCheckState extends State { ), FilledButton( onPressed: () async { - final ok = await _nickService.setNickname(ctrl.text); + final newNick = ctrl.text.trim(); + final ok = await _nickService.setNickname(newNick); if (ok && ctx.mounted) Navigator.pop(ctx); setState(() { - if (ok) _nickname = ctrl.text.trim(); + if (ok) _nickname = newNick; }); if (ctx.mounted && !ok) { ScaffoldMessenger.of(ctx).showSnackBar( - const SnackBar(content: Text('昵称无效')), + _snack('昵称无效', icon: Icons.error_outline, color: Colors.red), ); } + // 双保险:保存成功后直接通知后端(不管 onChanged 有没有触发) + if (ok && capturedOldNick != newNick && + _sessionId != null && _myRole == _RoleSelect.participant) { + debugPrint('[DC] dialog save: old=$capturedOldNick new=$newNick → _sendRename'); + _sendRename(newNick); + } }, child: const Text('保存'), ), @@ -474,7 +820,6 @@ class _DualCheckState extends State { size: 60, color: match ? Colors.green : Colors.red), title: Text(match ? '🎉 完全一致' : '❌ 不相同', style: TextStyle(color: match ? Colors.green : Colors.red)), - content: Text('你的昵称:$_nickname'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), @@ -484,12 +829,6 @@ class _DualCheckState extends State { ); } - void _showParticipantWaiting() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已提交,等待发起方确认答案...')), - ); - } - // ── UI ── @override Widget build(BuildContext context) { @@ -518,11 +857,13 @@ class _DualCheckState extends State { title: const Text('DualCheck 双验'), centerTitle: true, actions: [ - IconButton( - tooltip: '设置昵称', - icon: const Icon(Icons.person), - onPressed: _showNicknameDialog, - ), + // 发起者进入会话后,不允许修改发起者昵称,隐藏按钮 + if (_sessionId == null || _myRole != _RoleSelect.initiator) + IconButton( + tooltip: '设置昵称', + icon: const Icon(Icons.person), + onPressed: _showNicknameDialog, + ), ], ), body: body, @@ -584,27 +925,6 @@ class _DualCheckState extends State { textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)), const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon(Icons.person, color: Colors.grey), - const SizedBox(width: 8), - const Text('当前昵称:'), - Text(_nickname, - style: const TextStyle(fontWeight: FontWeight.bold)), - const Spacer(), - TextButton( - onPressed: _showNicknameDialog, - child: const Text('修改'), - ), - ], - ), - ), const Spacer(), ElevatedButton.icon( onPressed: _isLoading ? null : _createSession, @@ -645,103 +965,161 @@ class _DualCheckState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildSessionBar(), - const SizedBox(height: 16), - // 发起方输入框(标准答案) - if (!initReady) ...[ - TextField( - controller: _textCtrl, - maxLines: 4, - maxLength: 10000, - decoration: const InputDecoration( - hintText: '请输入你的答案(标准答案)...', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - ElevatedButton( - onPressed: _isLoading ? null : _submit, - child: _isLoading - ? const CircularProgressIndicator(strokeWidth: 2, color: Colors.white) - : const Text('提交标准答案'), - ), - if (_errorMsg != null) _buildError(_errorMsg!), - ] else ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.green.shade50, - borderRadius: BorderRadius.circular(8), - ), - child: Row( + // ── 上半部分:可滚 ── + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(Icons.check_circle, color: Colors.green.shade700), - const SizedBox(width: 8), - Expanded( - child: Text('标准答案已提交($initNick)', - style: TextStyle(color: Colors.green.shade700)), + _buildSessionBar(), + const SizedBox(height: 16), + if (!initReady) ...[ + TextField( + controller: _textCtrl, + maxLines: 4, + maxLength: 10000, + decoration: const InputDecoration( + hintText: '请输入你的答案(标准答案)...', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _isLoading ? null : _submit, + child: _isLoading + ? const CircularProgressIndicator(strokeWidth: 2, color: Colors.white) + : const Text('提交标准答案'), + ), + if (_errorMsg != null) _buildError(_errorMsg!), + ] else ...[ + Container( + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 8, 6), + child: Row( + children: [ + Icon(Icons.check_circle, color: Colors.green.shade700, size: 18), + const SizedBox(width: 6), + Expanded( + child: Text('标准答案已提交($initNick)', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.w600)), + ), + if (_mySubmittedText != null && _mySubmittedText!.length > 80) + TextButton.icon( + onPressed: () => + setState(() => _initTextExpanded = !_initTextExpanded), + icon: Icon( + _initTextExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 18, + ), + label: Text(_initTextExpanded ? '收起' : '展开'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: Colors.green.shade700), + ), + if (_mySubmittedText != null && _mySubmittedText!.isNotEmpty) + IconButton( + tooltip: '复制内容', + icon: Icon(Icons.copy, size: 18, color: Colors.green.shade700), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => _copy(_mySubmittedText!), + ), + ], + ), + ), + if (_mySubmittedText != null) + Container( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Text( + _mySubmittedText!, + maxLines: _initTextExpanded ? null : 6, + style: TextStyle( + color: Colors.green.shade900, height: 1.5), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + Row( + children: [ + const Text('参与者', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Text('${participants.length} 人', + style: TextStyle( + color: Colors.blue.shade800, + fontSize: 12, + fontWeight: FontWeight.bold)), + ), + ], ), + const SizedBox(height: 8), + if (participants.isEmpty) + Container( + height: 160, + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.group_outlined, + size: 48, color: Colors.grey.shade300), + const SizedBox(height: 8), + const Text('暂无参与者,把会话码分享给他们吧', + style: TextStyle(color: Colors.grey)), + ], + ), + ) + else + SizedBox( + height: 200, + child: ListView.separated( + itemCount: participants.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) => + _buildParticipantCard(participants[i]), + ), + ), + const SizedBox(height: 16), ], ), ), - ], - const SizedBox(height: 24), - - // 参与者列表 - Row( - children: [ - const Text('参与者', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const Spacer(), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.blue.shade100, - borderRadius: BorderRadius.circular(12), - ), - child: Text('${participants.length} 人', - style: TextStyle( - color: Colors.blue.shade800, - fontSize: 12, - fontWeight: FontWeight.bold)), - ), - ], ), - const SizedBox(height: 8), - if (participants.isEmpty) - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.group_outlined, - size: 48, color: Colors.grey.shade300), - const SizedBox(height: 8), - const Text('暂无参与者,把会话码分享给他们吧', - style: TextStyle(color: Colors.grey)), - ], - ), - ), - ) - else - Expanded( - child: ListView.separated( - itemCount: participants.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (_, i) => - _buildParticipantCard(participants[i]), - ), - ), - - // 发起方控制区 + // ── 底部控制区:永远贴底 ── SwitchListTile.adaptive( contentPadding: const EdgeInsets.symmetric(horizontal: 4), title: const Text('允许参与者重新提交'), - subtitle: const Text('开启后参与者可以覆盖之前的提交结果'), value: _session?.allowResubmit ?? false, onChanged: _toggleResubmit, ), + SwitchListTile.adaptive( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + title: const Text('比对时忽略首尾空白'), + value: _session?.ignoreWs ?? false, + onChanged: _toggleIgnoreWs, + ), const SizedBox(height: 8), ElevatedButton(onPressed: _confirmAndEndSession, child: const Text('结束会话')), ], @@ -843,6 +1221,44 @@ class _DualCheckState extends State { ), const SizedBox(height: 16), + // 自己的状态条(提交后显示) + if (_mySubmitted) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _myMatch == null + ? Colors.blue.shade50 + : (_myMatch! ? Colors.green.shade50 : Colors.red.shade50), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + _myMatch == null + ? Icons.hourglass_empty + : (_myMatch! ? Icons.check_circle : Icons.cancel), + color: _myMatch == null + ? Colors.blue + : (_myMatch! ? Colors.green : Colors.red)), + const SizedBox(width: 8), + Expanded( + child: Text( + _myMatch == null + ? '已提交,等待发起方提交标准答案后比对...' + : (_myMatch! ? '✅ 你的答案和发起方完全一致' : '❌ 你的答案和发起方不一致'), + style: TextStyle( + color: _myMatch == null + ? Colors.blue.shade700 + : (_myMatch! + ? Colors.green.shade700 + : Colors.red.shade700)), + ), + ), + ], + ), + ), + if (_mySubmitted) const SizedBox(height: 16), + // 自己的输入框 TextField( controller: _textCtrl, @@ -864,7 +1280,30 @@ class _DualCheckState extends State { if (_errorMsg != null) _buildError(_errorMsg!), const Spacer(), - ElevatedButton(onPressed: _reset, child: const Text('离开会话')), + ElevatedButton( + onPressed: () async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.exit_to_app, size: 40, color: Colors.blue), + title: const Text('确定离开会话?'), + content: const Text('离开后将断开连接,不再接收会话消息。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('离开'), + ), + ], + ), + ); + if (ok == true && mounted) _reset(); + }, + child: const Text('离开会话'), + ), ], ); } @@ -922,7 +1361,28 @@ class _DualCheckState extends State { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('已复制'))); + .showSnackBar(_snack('已复制', icon: Icons.check_circle, color: Colors.green)); + } + + /// 统一的 SnackBar:圆角 + 图标 + 主题色 + SnackBar _snack(String text, {IconData? icon, Color? color, Duration duration = const Duration(seconds: 2)}) { + final bg = color ?? Colors.grey.shade800; + return SnackBar( + content: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 8), + ], + Expanded(child: Text(text, style: const TextStyle(color: Colors.white))), + ], + ), + backgroundColor: bg, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + duration: duration, + ); } Widget _buildError(String msg) { @@ -969,6 +1429,7 @@ extension _SessionCopy on SessionResp { String? initiatorNickname, bool? initiatorReady, bool? allowResubmit, + bool? ignoreWs, List? participants, int? ttlSeconds, }) { @@ -977,8 +1438,29 @@ extension _SessionCopy on SessionResp { initiatorNickname: initiatorNickname ?? this.initiatorNickname, initiatorReady: initiatorReady ?? this.initiatorReady, allowResubmit: allowResubmit ?? this.allowResubmit, + ignoreWs: ignoreWs ?? this.ignoreWs, participants: participants ?? this.participants, ttlSeconds: ttlSeconds ?? this.ttlSeconds, ); } } + +// ── 全局辅助:把 DioException 转成用户友好的提示 ── +String _friendlyError(Object e, String fallback) { + if (e is DioException) { + switch (e.type) { + case DioExceptionType.connectionError: + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return '无法连接到服务器'; + case DioExceptionType.cancel: + return '请求已取消'; + case DioExceptionType.badResponse: + return isBizError(e) ? bizErrorMessage(e) : '服务器返回错误'; + default: + return isBizError(e) ? bizErrorMessage(e) : fallback; + } + } + return '$fallback:$e'; +} diff --git a/client/lib/modules/dual_check/nickname_service.dart b/client/lib/modules/dual_check/nickname_service.dart index 6c5bfce..d4c3731 100644 --- a/client/lib/modules/dual_check/nickname_service.dart +++ b/client/lib/modules/dual_check/nickname_service.dart @@ -1,8 +1,13 @@ // 全局昵称设置 // 持久化到 SharedPreferences / localStorage,所有工具共享同一个昵称 +// Web 平台自动监听 localStorage 变化 → 跨标签页实时同步 +import 'dart:async'; +import 'dart:html' as html show window, StorageEvent; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:shared_preferences/shared_preferences.dart'; const _keyNickname = 'toolbox.nickname'; +const _keyBrowserId = 'toolbox.browser_id'; class NicknameService { NicknameService._(); @@ -10,8 +15,60 @@ class NicknameService { SharedPreferences? _prefs; + /// 浏览器唯一 ID:同浏览器跨 tab 共享(localStorage 作用域),跨浏览器不同 + /// 用于后端识别"同一个人",避免昵称冲突时自动改名 + String? _browserId; + + /// 昵称变化广播流:本地 setNickname 触发 + Web 上其他标签页修改也触发 + final _streamController = StreamController.broadcast(); + Stream get onChanged => _streamController.stream; + + StreamSubscription? _webStorageSub; + Future _ensureInit() async { _prefs ??= await SharedPreferences.getInstance(); + // 惰性生成 browserId:首次访问时若没有就生成 uuid 存起来 + if (_browserId == null) { + final existing = _prefs!.getString(_keyBrowserId); + if (existing != null && existing.isNotEmpty) { + _browserId = existing; + } else { + _browserId = _genBrowserId(); + await _prefs!.setString(_keyBrowserId, _browserId!); + } + } + } + + /// 获取浏览器 ID(稳定值,同浏览器跨 tab 相同) + Future get browserId async { + await _ensureInit(); + return _browserId!; + } + + /// 生成短 browserId:base62(0-9A-Za-z),去掉特殊字符方便 URL 使用 + /// 12 字符足够唯一(62^12 ≈ 3e21 种可能) + String _genBrowserId() { + const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + final rand = DateTime.now().microsecond.hashCode; + final buf = StringBuffer(); + int seed = rand ^ DateTime.now().millisecondsSinceEpoch; + for (int i = 0; i < 12; i++) { + seed = (seed * 1103515245 + 12345) & 0x7FFFFFFF; + buf.write(chars[seed % chars.length]); + } + return buf.toString(); + } + + /// 开启跨标签页同步(仅 Web 生效;非 Web 空操作) + /// 在 widget 的 initState 里调用一次即可 + void enableCrossTabSync() { + if (!kIsWeb || _webStorageSub != null) return; + _webStorageSub = html.window.onStorage.listen((html.StorageEvent event) async { + if (event.key == _keyNickname) { + final n = await nickname; + _streamController.add(n); + } + }); } /// 获取当前昵称,未设置时返回默认昵称 @@ -28,6 +85,12 @@ class NicknameService { if (trimmed.isEmpty || trimmed.length > 32) return false; await _ensureInit(); await _prefs!.setString(_keyNickname, trimmed); + _streamController.add(trimmed); return true; } + + void dispose() { + _webStorageSub?.cancel(); + _webStorageSub = null; + } } diff --git a/server/cmd/server/server b/server/cmd/server/server new file mode 100755 index 0000000..f47c293 Binary files /dev/null and b/server/cmd/server/server differ diff --git a/server/configs/config.go b/server/configs/config.go index 700a7f6..b8ab0b1 100644 --- a/server/configs/config.go +++ b/server/configs/config.go @@ -77,7 +77,7 @@ func Load(configPath ...string) (*Config, error) { v.SetDefault("rate_limit.global_rps", 1000) v.SetDefault("rate_limit.per_ip_rps", 50) v.SetDefault("rate_limit.burst", 100) - v.SetDefault("session.ttl", 900) + v.SetDefault("session.ttl", 604800) v.SetDefault("session.max_text_length", 10000) v.SetDefault("log.level", "info") v.SetDefault("log.format", "json") diff --git a/server/configs/config.yaml b/server/configs/config.yaml index 0b8f94b..28c7f2b 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -21,7 +21,7 @@ rate_limit: burst: 100 session: - ttl: 900 # 会话过期时间(秒),默认 15 分钟 + ttl: 604800 # 会话 Redis TTL(秒),默认 7 天。实际生命周期由发起方在线决定(发起方断开即结束),TTL 只是兜底防止发起方异常下线时 Redis 泄漏 max_text_length: 10000 # 单条文本最大长度 log: diff --git a/server/internal/response/response.go b/server/internal/response/response.go index 201d676..8d89897 100644 --- a/server/internal/response/response.go +++ b/server/internal/response/response.go @@ -118,3 +118,32 @@ func WSResubmitChanged(allow bool) WebSocketMessage { Data: map[string]bool{"allow": allow}, } } + +// WSIgnoreWsChanged 发起方切换了"忽略首尾空白"开关 +func WSIgnoreWsChanged(ignore bool) WebSocketMessage { + return WebSocketMessage{ + Type: "ignore_ws_changed", + Data: map[string]bool{"ignore": ignore}, + } +} + +// WSParticipantRenamed 参与者修改了昵称(old → new) +func WSParticipantRenamed(oldNick, newNick string) WebSocketMessage { + return WebSocketMessage{ + Type: "participant_nickname_changed", + Data: map[string]string{ + "old_nickname": oldNick, + "new_nickname": newNick, + }, + } +} + +// WSNicknameAutoChanged 服务端因昵称冲突自动改名后通知该客户端 +func WSNicknameAutoChanged(nick string) WebSocketMessage { + return WebSocketMessage{ + Type: "nickname_auto_changed", + Data: map[string]string{ + "nickname": nick, + }, + } +} diff --git a/server/internal/store/redis_session_store.go b/server/internal/store/redis_session_store.go index 314f454..8e51584 100644 --- a/server/internal/store/redis_session_store.go +++ b/server/internal/store/redis_session_store.go @@ -9,23 +9,34 @@ import ( "time" "github.com/redis/go-redis/v9" + + "cancerai.top/toolbox-server/internal/pkg/hash" ) // 包级错误,业务层可以 errors.Is 检查 var ( - ErrSessionNotFound = errors.New("session not found or expired") - ErrDuplicateJoin = errors.New("nickname already joined this session") - ErrDuplicateSubmit = errors.New("this nickname has already submitted") + ErrSessionNotFound = errors.New("session not found or expired") + ErrDuplicateJoin = errors.New("nickname already joined this session") + ErrDuplicateSubmit = errors.New("this nickname has already submitted") + ErrNicknameConflict = errors.New("nickname conflict (exists or matches initiator)") + ErrOldNotFound = errors.New("old nickname not found in session") ) const ( redisKeyPrefix = "dualcheck:session:" fieldCreatedAt = "created_at" fieldInitHash = "initiator_hash" + fieldInitText = "initiator_text" // 发起方原始文本(trim 前) fieldInitNick = "initiator_nickname" + fieldInitBrowser = "initiator_browser_id" fieldAllowResub = "allow_resubmit" - // participant:{nick} 存该参与者的哈希值;participant:{nick}:match 存 "1"/"0" + fieldIgnoreWs = "ignore_ws" + // participant:{nick} 存该参与者的哈希值;participant:{nick}:text 存原始文本;participant:{nick}:match 存 "1"/"0" + // participant:{nick}:browser_id 存该参与者的 browserId partFieldPrefix = "participant:" + partTextSuffix = ":text" + partMatchSuffix = ":match" + partBrowserSuffix = ":browser_id" ) // ── 五个 Lua 脚本 ── @@ -37,17 +48,22 @@ if redis.call("EXISTS", key) ~= 0 then return 0 end redis.call("HSET", key, "created_at", ARGV[1]) +redis.call("HSET", key, "initiator_nickname", ARGV[2]) +redis.call("HSET", key, "initiator_browser_id", ARGV[3]) redis.call("HSET", key, "allow_resubmit", "0") -redis.call("EXPIRE", key, ARGV[2]) +redis.call("HSET", key, "ignore_ws", "0") +redis.call("EXPIRE", key, ARGV[4]) return 1 `) // joinParticipantLuaScript 登记参与者昵称(空哈希占位,表示已加入未提交) // KEYS[1] = session key // ARGV[1] = nickname +// ARGV[2] = browserId var joinParticipantLuaScript = redis.NewScript(` local key = KEYS[1] local nick = ARGV[1] +local bid = ARGV[2] or "" local exists = redis.call("EXISTS", key) if exists == 0 then return redis.error_reply("SESSION_NOT_FOUND") @@ -58,20 +74,25 @@ if redis.call("HEXISTS", key, field) == 1 then return 1 end redis.call("HSET", key, field, "") +if bid ~= "" then + redis.call("HSET", key, field .. ":browser_id", bid) +end return 1 `) // submitParticipantLuaScript 参与者提交自己的哈希 -// 原子完成:检查 → 写入 → 与 initiator_hash 比对(若发起方已提交) +// 原子完成:检查 → 写入哈希+原始文本 → 与 initiator_hash 比对(若发起方已提交) // 若已提交但 allow_resubmit=1 则覆盖(重算 match) // KEYS[1] = session key // ARGV[1] = nickname -// ARGV[2] = hash hex +// ARGV[2] = hash hex(已按当前 ignore_ws 规则 trim + hash) +// ARGV[3] = 原始文本(trim 前) // 返回 {initiatorSubmitted (0/1), match ("1"/"0"/"")} var submitParticipantLuaScript = redis.NewScript(` local key = KEYS[1] local nick = ARGV[1] local hash_val = ARGV[2] +local orig_text = ARGV[3] if redis.call("EXISTS", key) == 0 then return redis.error_reply("SESSION_NOT_FOUND") @@ -90,8 +111,9 @@ if existing ~= "" then end end --- 写入(覆盖)哈希 +-- 写入(覆盖)哈希 + 原始文本 redis.call("HSET", key, field, hash_val) +redis.call("HSET", key, field .. ":text", orig_text) -- 清除旧的 match 字段,稍后按需重算 redis.call("HDEL", key, field .. ":match") @@ -106,14 +128,16 @@ end `) // submitInitiatorLuaScript 发起方提交标准答案 -// 写入 initiator_hash 后,遍历所有已提交的 participant,批量计算 match +// 写入 initiator_hash + initiator_text 后,遍历所有已提交的 participant,批量计算 match // KEYS[1] = session key // ARGV[1] = nickname -// ARGV[2] = initiator hash +// ARGV[2] = initiator hash(已按当前 ignore_ws 规则 trim + hash) +// ARGV[3] = 原始文本(trim 前) var submitInitiatorLuaScript = redis.NewScript(` local key = KEYS[1] local nick = ARGV[1] local init_hash = ARGV[2] +local orig_text = ARGV[3] if redis.call("EXISTS", key) == 0 then return redis.error_reply("SESSION_NOT_FOUND") @@ -125,6 +149,7 @@ if redis.call("HEXISTS", key, "initiator_hash") == 1 then end redis.call("HSET", key, "initiator_hash", init_hash) +redis.call("HSET", key, "initiator_text", orig_text) redis.call("HSET", key, "initiator_nickname", nick) -- 遍历所有 participant:{nick} 且哈希非空的,计算 match @@ -134,6 +159,7 @@ for i = 1, #all, 2 do local val = all[i + 1] if string.sub(field, 1, #("participant:")) == "participant:" and not string.find(field, ":match$") + and not string.find(field, ":text$") and val ~= "" then local match = (val == init_hash) and "1" or "0" redis.call("HSET", key, field .. ":match", match) @@ -142,6 +168,71 @@ end return 1 `) +// renameParticipantLuaScript 原子重命名参与者 +// 检查:session 存在、old 存在、new 不与 initiator_nickname/已有 participant 冲突 +// (但如果 oldNick 的 browserId 和冲突方相同,视为同一个人,跳过冲突) +// 迁移 participant:{old}、participant:{old}:text、participant:{old}:match、participant:{old}:browser_id → new +// KEYS[1] = session key +// ARGV[1] = old nickname +// ARGV[2] = new nickname +// ARGV[3] = old participant's browserId +var renameParticipantLuaScript = redis.NewScript(` +local key = KEYS[1] +local oldNick = ARGV[1] +local newNick = ARGV[2] +local oldBid = ARGV[3] or "" + +if redis.call("EXISTS", key) == 0 then + return redis.error_reply("SESSION_NOT_FOUND") +end + +-- oldNick 必须存在 +local oldField = "participant:" .. oldNick +if redis.call("HEXISTS", key, oldField) == 0 then + return redis.error_reply("OLD_NOT_FOUND") +end + +-- newNick 不能等于发起方昵称(除非是同一个浏览器) +local initNick = redis.call("HGET", key, "initiator_nickname") +local initBid = redis.call("HGET", key, "initiator_browser_id") or "" +if initNick and initNick == newNick then + if oldBid == "" or initBid ~= oldBid then + return redis.error_reply("NICKNAME_CONFLICT") + end +end + +-- newNick 不能已被其他 participant 占用(除非是同一个浏览器) +local newField = "participant:" .. newNick +if redis.call("HEXISTS", key, newField) == 1 then + local newBid = redis.call("HGET", key, newField .. ":browser_id") or "" + if oldBid == "" or newBid ~= oldBid then + return redis.error_reply("NICKNAME_CONFLICT") + end +end + +-- 迁移四个 field +local hashVal = redis.call("HGET", key, oldField) +local textVal = redis.call("HGET", key, oldField .. ":text") +local matchVal = redis.call("HGET", key, oldField .. ":match") +local bidVal = redis.call("HGET", key, oldField .. ":browser_id") + +redis.call("HSET", key, newField, hashVal) +if textVal ~= false then + redis.call("HSET", key, newField .. ":text", textVal) +end +if matchVal ~= false then + redis.call("HSET", key, newField .. ":match", matchVal) +end +if bidVal ~= false then + redis.call("HSET", key, newField .. ":browser_id", bidVal) +end +redis.call("HDEL", key, oldField) +redis.call("HDEL", key, oldField .. ":text") +redis.call("HDEL", key, oldField .. ":match") +redis.call("HDEL", key, oldField .. ":browser_id") +return 1 +`) + // ── RedisSessionStore 实现 ── type RedisSessionStore struct { @@ -158,14 +249,15 @@ func key(sessionID string) string { } // Create 原子创建会话 Hash key -func (s *RedisSessionStore) Create(ctx context.Context, sessionID string, ttl time.Duration) error { +func (s *RedisSessionStore) Create(ctx context.Context, sessionID, initiatorNickname, initiatorBrowserID string, ttl time.Duration) error { if ttl <= 0 { ttl = s.ttl } now := time.Now().Unix() ttlSeconds := int64(ttl.Seconds()) - result, err := createLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, now, ttlSeconds).Int64() + result, err := createLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, + now, initiatorNickname, initiatorBrowserID, ttlSeconds).Int64() if err != nil { return fmt.Errorf("redis create session: %w", err) } @@ -176,8 +268,8 @@ func (s *RedisSessionStore) Create(ctx context.Context, sessionID string, ttl ti } // JoinParticipant 登记参与者(空哈希占位) -func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname string) error { - _, err := joinParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname).Int64() +func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname, browserID string) error { + _, err := joinParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, browserID).Int64() if err != nil { msg := err.Error() if strings.Contains(msg, "SESSION_NOT_FOUND") { @@ -188,9 +280,9 @@ func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nick return nil } -// SubmitParticipant 参与者提交自己的文本哈希 -func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (bool, *bool, error) { - result, err := submitParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex).Result() +// SubmitParticipant 参与者提交自己的文本哈希(同时存原始文本) +func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex, originalText string) (bool, *bool, error) { + result, err := submitParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex, originalText).Result() if err != nil { msg := err.Error() switch { @@ -200,10 +292,10 @@ func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, ni return false, nil, ErrDuplicateSubmit case strings.Contains(msg, "NICKNAME_NOT_JOINED"): // 参与者没走 join 直接 submit,自动补登记 - if err := s.JoinParticipant(ctx, sessionID, nickname); err != nil { + if err := s.JoinParticipant(ctx, sessionID, nickname, ""); err != nil { return false, nil, err } - return s.SubmitParticipant(ctx, sessionID, nickname, hashHex) + return s.SubmitParticipant(ctx, sessionID, nickname, hashHex, originalText) default: return false, nil, fmt.Errorf("redis submit participant: %w", err) } @@ -219,9 +311,9 @@ func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, ni return initSubmitted, match, nil } -// SubmitInitiator 发起方提交标准答案哈希 -func (s *RedisSessionStore) SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error { - _, err := submitInitiatorLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex).Int64() +// SubmitInitiator 发起方提交标准答案哈希(同时存原始文本) +func (s *RedisSessionStore) SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex, originalText string) error { + _, err := submitInitiatorLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex, originalText).Int64() if err != nil { if strings.Contains(err.Error(), "SESSION_NOT_FOUND") { return ErrSessionNotFound @@ -248,6 +340,134 @@ func (s *RedisSessionStore) SetResubmit(ctx context.Context, sessionID string, a return s.rdb.HSet(ctx, key, fieldAllowResub, val).Err() } +func (s *RedisSessionStore) SetIgnoreWs(ctx context.Context, sessionID string, ignore bool) error { + key := key(sessionID) + exists, err := s.rdb.Exists(ctx, key).Result() + if err != nil { + return fmt.Errorf("redis exists: %w", err) + } + if exists == 0 { + return ErrSessionNotFound + } + val := "0" + if ignore { + val = "1" + } + return s.rdb.HSet(ctx, key, fieldIgnoreWs, val).Err() +} + +func (s *RedisSessionStore) GetIgnoreWs(ctx context.Context, sessionID string) (bool, error) { + v, err := s.rdb.HGet(ctx, key(sessionID), fieldIgnoreWs).Result() + if err != nil { + if err == redis.Nil { + return false, nil + } + return false, fmt.Errorf("redis hget ignore_ws: %w", err) + } + return v == "1", nil +} + +// RecalcMatches 按新的 ignoreWs 规则重新 trim + hash 所有已提交文本并重算 match +func (s *RedisSessionStore) RecalcMatches(ctx context.Context, sessionID string, ignoreWs bool) error { + k := key(sessionID) + all, err := s.rdb.HGetAll(ctx, k).Result() + if err != nil { + return fmt.Errorf("redis hgetall: %w", err) + } + if len(all) == 0 { + return ErrSessionNotFound + } + + // 读发起方原始文本 + initText := all[fieldInitText] + if initText == "" { + return nil // 发起方还没提交,不需要重算 + } + initProcessed := initText + if ignoreWs { + initProcessed = strings.TrimSpace(initText) + } + initHash := hash.SHA256(initProcessed) + + pipe := s.rdb.TxPipeline() + pipe.HSet(ctx, k, fieldInitHash, initHash) + + // 遍历所有已提交参与者,重算 hash + match + for field, val := range all { + if !strings.HasPrefix(field, partFieldPrefix) { + continue + } + if strings.HasSuffix(field, partMatchSuffix) || strings.HasSuffix(field, partTextSuffix) { + continue + } + if val == "" { + continue + } + textField := field + partTextSuffix + origText := all[textField] + if origText == "" { + continue // 旧数据(没存过原始文本),跳过 + } + processed := origText + if ignoreWs { + processed = strings.TrimSpace(origText) + } + newHash := hash.SHA256(processed) + match := "0" + if newHash == initHash { + match = "1" + } + pipe.HSet(ctx, k, field, newHash) + pipe.HSet(ctx, k, field+partMatchSuffix, match) + } + + _, err = pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("redis recalc pipeline: %w", err) + } + return nil +} + +// UpdateParticipantNickname 原子重命名参与者 +func (s *RedisSessionStore) UpdateParticipantNickname(ctx context.Context, sessionID, oldNick, newNick, oldBrowserID string) error { + _, err := renameParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, oldNick, newNick, oldBrowserID).Int64() + if err != nil { + msg := err.Error() + switch { + case strings.Contains(msg, "SESSION_NOT_FOUND"): + return ErrSessionNotFound + case strings.Contains(msg, "OLD_NOT_FOUND"): + return ErrOldNotFound + case strings.Contains(msg, "NICKNAME_CONFLICT"): + return ErrNicknameConflict + default: + return fmt.Errorf("redis rename participant: %w", err) + } + } + return nil +} + +// RemoveParticipant 参与者离开时从 Redis 删除其所有字段 +func (s *RedisSessionStore) RemoveParticipant(ctx context.Context, sessionID, nickname string) error { + k := key(sessionID) + exists, err := s.rdb.Exists(ctx, k).Result() + if err != nil { + return fmt.Errorf("redis exists: %w", err) + } + if exists == 0 { + return ErrSessionNotFound + } + pipe := s.rdb.TxPipeline() + pipe.HDel(ctx, k, partFieldPrefix+nickname) + pipe.HDel(ctx, k, partFieldPrefix+nickname+partTextSuffix) + pipe.HDel(ctx, k, partFieldPrefix+nickname+partMatchSuffix) + _, err = pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("redis remove participant: %w", err) + } + return nil +} + // Get 获取会话当前状态全量快照 func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*SessionSnapshot, error) { exists, err := s.rdb.Exists(ctx, key(sessionID)).Result() @@ -272,8 +492,10 @@ func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*Session SessionID: sessionID, InitiatorHash: hash[fieldInitHash], InitiatorNickname: hash[fieldInitNick], + InitiatorBrowserID: hash[fieldInitBrowser], InitiatorSubmitted: hash[fieldInitHash] != "", AllowResubmit: hash[fieldAllowResub] == "1", + IgnoreWs: hash[fieldIgnoreWs] == "1", ExpiresAt: time.Now().Add(ttl), } @@ -281,7 +503,10 @@ func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*Session var nicks []string partMap := map[string]string{} // nick → hash for k, v := range hash { - if strings.HasPrefix(k, partFieldPrefix) && !strings.HasSuffix(k, ":match") { + if strings.HasPrefix(k, partFieldPrefix) && + !strings.HasSuffix(k, partMatchSuffix) && + !strings.HasSuffix(k, partTextSuffix) && + !strings.HasSuffix(k, partBrowserSuffix) { nick := strings.TrimPrefix(k, partFieldPrefix) nicks = append(nicks, nick) partMap[nick] = v @@ -294,6 +519,7 @@ func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*Session h := partMap[nick] p := ParticipantInfo{ Nickname: nick, + BrowserID: hash[partFieldPrefix+nick+partBrowserSuffix], Submitted: h != "", } if h != "" && initiatorHash != "" { diff --git a/server/internal/store/session_store.go b/server/internal/store/session_store.go index 5e93e90..bbcdd55 100644 --- a/server/internal/store/session_store.go +++ b/server/internal/store/session_store.go @@ -17,6 +17,7 @@ type Session struct { // ParticipantInfo 参与者在全量快照中的状态 type ParticipantInfo struct { Nickname string `json:"nickname"` + BrowserID string `json:"browser_id,omitempty"` Submitted bool `json:"submitted"` Match *bool `json:"match,omitempty"` } @@ -26,33 +27,52 @@ type SessionSnapshot struct { SessionID string `json:"session_id"` InitiatorHash string `json:"-"` // 发起方标准答案哈希,不对外暴露 InitiatorNickname string `json:"initiator_nickname"` + InitiatorBrowserID string `json:"initiator_browser_id,omitempty"` InitiatorSubmitted bool `json:"initiator_submitted"` AllowResubmit bool `json:"allow_resubmit"` + IgnoreWs bool `json:"ignore_ws"` // 是否忽略首尾空白 Participants []ParticipantInfo `json:"participants"` ExpiresAt time.Time `json:"expires_at"` } // SessionStore 会话存储接口 type SessionStore interface { - // Create 原子创建会话(SET NX EX) - Create(ctx context.Context, id string, ttl time.Duration) error + // Create 原子创建会话(SET NX EX),同时记录发起方昵称和 browserId + Create(ctx context.Context, id, initiatorNickname, initiatorBrowserID string, ttl time.Duration) error - // SubmitInitiator 发起方提交标准答案(initiator_hash + initiator_nickname) + // SubmitInitiator 发起方提交标准答案(initiator_hash + initiator_nickname + 原始文本) // 同时立即为所有已提交的参与者计算 match - SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error + SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex, originalText string) error - // SubmitParticipant 参与者提交自己的文本(按 nickname 唯一标识) + // SubmitParticipant 参与者提交自己的文本(按 nickname 唯一标识,同时存原始文本) // 返回:initiatorSubmitted(发起方是否已提交,是则 match 已算出)、match - SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) ( + SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex, originalText string) ( initiatorSubmitted bool, match *bool, err error) // JoinParticipant 参与者加入会话(只登记昵称,不提交文本) - // 用于 WS 连接时实时广播 joined 事件 - JoinParticipant(ctx context.Context, sessionID, nickname string) error + // browserId 用于识别"同一个人",避免跨 tab 昵称冲突 + JoinParticipant(ctx context.Context, sessionID, nickname, browserID string) error // SetResubmit 设置是否允许参与者重复提交 SetResubmit(ctx context.Context, sessionID string, allow bool) error + // SetIgnoreWs 设置比对时是否忽略首尾空白(1=忽略,0=不忽略) + SetIgnoreWs(ctx context.Context, sessionID string, ignore bool) error + + // GetIgnoreWs 获取当前会话的 ignore_ws 设置 + GetIgnoreWs(ctx context.Context, sessionID string) (bool, error) + + // RecalcMatches 按新的 ignoreWs 规则重新 trim + hash 所有已提交文本并重算 match + RecalcMatches(ctx context.Context, sessionID string, ignoreWs bool) error + + // UpdateParticipantNickname 原子重命名参与者:将 participant:{old} → participant:{new} + // oldBrowserID 用于判断是否与冲突方是同一个浏览器(跳过冲突) + // 返回业务错误:ErrSessionNotFound / ErrNicknameConflict + UpdateParticipantNickname(ctx context.Context, sessionID, oldNick, newNick, oldBrowserID string) error + + // RemoveParticipant 参与者离开时从 Redis 删除其所有字段(hash/text/match) + RemoveParticipant(ctx context.Context, sessionID, nickname string) error + // Get 获取会话当前状态全量快照 Get(ctx context.Context, sessionID string) (*SessionSnapshot, error) diff --git a/server/internal/tools/dual_check/handler.go b/server/internal/tools/dual_check/handler.go index a8b0b85..7bc84a8 100644 --- a/server/internal/tools/dual_check/handler.go +++ b/server/internal/tools/dual_check/handler.go @@ -3,6 +3,7 @@ package dual_check import ( "errors" "net/http" + "strings" "unicode/utf8" pkgws "cancerai.top/toolbox-server/internal/pkg/websocket" @@ -38,7 +39,11 @@ func (h *Handler) Create(c *gin.Context) { response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) return } - resp, err := h.service.CreateSession(c.Request.Context(), req.Nickname) + if err := validateNickname(req.Nickname); err != nil { + response.BadRequest(c, err.Error()) + return + } + resp, err := h.service.CreateSession(c.Request.Context(), req.Nickname, req.BrowserID) if err != nil { var be *BusinessError if errors.As(err, &be) { @@ -64,6 +69,10 @@ func (h *Handler) Submit(c *gin.Context) { response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) return } + if err := validateNickname(req.Nickname); err != nil { + response.BadRequest(c, err.Error()) + return + } if utf8.RuneCountInString(req.Text) > h.maxLen { response.BadRequest(c, "text too long") return @@ -82,37 +91,33 @@ func (h *Handler) Submit(c *gin.Context) { return } - // 广播事件到房间内所有连接(包括发起方和其他参与者) + // 广播事件到房间内所有连接 switch req.Role { case "initiator": - // 发起方刚提交:触发全量快照推送(所有参与者都需要更新) sess, _ := h.service.GetSession(c.Request.Context(), req.SessionID) if sess != nil { h.hub.Broadcast(req.SessionID, response.WSFullState(sess)) } case "participant": - // 参与者刚提交:推送 participant_submitted 事件 - // 发起方需要更新该参与者的提交状态 + 可能的 match 结果 h.hub.Broadcast(req.SessionID, response.WSParticipantSubmitted(req.Nickname, resp)) } response.Success(c, resp) } -// Join POST /api/v1/dual-check/join — 参与者加入(登记昵称) +// Join POST /api/v1/dual-check/join func (h *Handler) Join(c *gin.Context) { var req JoinRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) return } - sessionID := c.Param("session_id") - if sessionID == "" { - response.BadRequest(c, "session_id is required") + if err := validateNickname(req.Nickname); err != nil { + response.BadRequest(c, err.Error()) return } - sess, err := h.service.JoinParticipant(c.Request.Context(), sessionID, req.Nickname) + sess, actualNick, err := h.service.JoinParticipant(c.Request.Context(), req.SessionID, req.Nickname, req.BrowserID) if err != nil { var be *BusinessError if errors.As(err, &be) { @@ -124,16 +129,27 @@ func (h *Handler) Join(c *gin.Context) { return } - // 广播 participant_joined 事件 - h.hub.Broadcast(sessionID, response.WSParticipantJoined(req.Nickname)) + h.hub.Broadcast(req.SessionID, response.WSParticipantJoined(actualNick)) + if actualNick != req.Nickname { + response.Success(c, gin.H{ + "session": sess, + "actual_nickname": actualNick, + "renamed": true, + }) + return + } response.Success(c, sess) } -// GetSession GET /api/v1/dual-check/session/:session_id +// GetSession POST /api/v1/dual-check/session func (h *Handler) GetSession(c *gin.Context) { - sessionID := c.Param("session_id") - sess, err := h.service.GetSession(c.Request.Context(), sessionID) + var req SessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) + return + } + sess, err := h.service.GetSession(c.Request.Context(), req.SessionID) if err != nil { var be *BusinessError if errors.As(err, &be) && be.Code == ErrCodeSessionNotFound { @@ -146,11 +162,14 @@ func (h *Handler) GetSession(c *gin.Context) { response.Success(c, sess) } -// CheckSession GET /api/v1/dual-check/check/:session_id -// 前端加入前预校验 +// CheckSession POST /api/v1/dual-check/check func (h *Handler) CheckSession(c *gin.Context) { - sessionID := c.Param("session_id") - info, err := h.service.CheckSession(c.Request.Context(), sessionID) + var req SessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) + return + } + info, err := h.service.CheckSession(c.Request.Context(), req.SessionID) if err != nil { response.InternalError(c, "check session failed") return @@ -162,21 +181,15 @@ func (h *Handler) CheckSession(c *gin.Context) { response.Success(c, info) } -// SetResubmit POST /api/v1/dual-check/session/:session_id/resubmit -// 发起方切换"允许参与者重新提交"开关 +// SetResubmit POST /api/v1/dual-check/resubmit func (h *Handler) SetResubmit(c *gin.Context) { - sessionID := c.Param("session_id") - if sessionID == "" { - response.BadRequest(c, "session_id is required") - return - } var req SetResubmitRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) return } - sess, err := h.service.SetResubmit(c.Request.Context(), sessionID, req.Allow) + sess, err := h.service.SetResubmit(c.Request.Context(), req.SessionID, req.Allow) if err != nil { var be *BusinessError if errors.As(err, &be) { @@ -188,26 +201,49 @@ func (h *Handler) SetResubmit(c *gin.Context) { return } - // 广播开关变化给所有参与者 - h.hub.Broadcast(sessionID, response.WSResubmitChanged(req.Allow)) + h.hub.Broadcast(req.SessionID, response.WSResubmitChanged(req.Allow)) response.Success(c, sess) } -// EndSession POST /api/v1/dual-check/session/:session_id/end -// 发起方主动结束会话:先广播 session_ended,再 Delete Redis key +// SetIgnoreWs POST /api/v1/dual-check/ignore-ws +func (h *Handler) SetIgnoreWs(c *gin.Context) { + var req SetIgnoreWsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) + return + } + + sess, err := h.service.SetIgnoreWs(c.Request.Context(), req.SessionID, req.Ignore) + if err != nil { + var be *BusinessError + if errors.As(err, &be) { + response.Fail(c, be.Code, be.Error()) + return + } + h.logger.Error("set ignore_ws error", zap.Error(err)) + response.InternalError(c, "set ignore_ws failed") + return + } + + h.hub.Broadcast(req.SessionID, response.WSIgnoreWsChanged(req.Ignore)) + + response.Success(c, sess) +} + +// EndSession POST /api/v1/dual-check/end func (h *Handler) EndSession(c *gin.Context) { - sessionID := c.Param("session_id") - if sessionID == "" { - response.BadRequest(c, "session_id is required") + var req SessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) return } // 先广播,让所有参与方 UI 都弹提示 - h.hub.Broadcast(sessionID, response.WSSessionEnded()) + h.hub.Broadcast(req.SessionID, response.WSSessionEnded()) // 再删除数据 - if err := h.service.EndSession(c.Request.Context(), sessionID); err != nil { + if err := h.service.EndSession(c.Request.Context(), req.SessionID); err != nil { h.logger.Error("end session error", zap.Error(err)) response.InternalError(c, "end session failed") return @@ -216,5 +252,16 @@ func (h *Handler) EndSession(c *gin.Context) { response.Success(c, gin.H{"ended": true}) } -// 占位,避免 http.StatusOK 未使用 +// validateNickname 统一的昵称校验:字符数 1-32(按 Unicode rune) +func validateNickname(n string) error { + n = strings.TrimSpace(n) + if utf8.RuneCountInString(n) == 0 { + return errors.New("nickname is required") + } + if utf8.RuneCountInString(n) > 32 { + return errors.New("nickname must be 1-32 characters") + } + return nil +} + var _ = http.StatusOK diff --git a/server/internal/tools/dual_check/model.go b/server/internal/tools/dual_check/model.go index 364225b..8ddf4c7 100644 --- a/server/internal/tools/dual_check/model.go +++ b/server/internal/tools/dual_check/model.go @@ -8,7 +8,8 @@ import "time" // ── 创建会话 ── type CreateRequest struct { - Nickname string `json:"nickname" binding:"required,min=1,max=32"` + Nickname string `json:"nickname" binding:"required,min=1"` + BrowserID string `json:"browser_id"` } // CreateResponse 创建成功 @@ -23,7 +24,7 @@ type CreateResponse struct { type SubmitRequest struct { SessionID string `json:"session_id" binding:"required,min=8,max=64"` - Nickname string `json:"nickname" binding:"required,min=1,max=32"` + Nickname string `json:"nickname" binding:"required,min=1"` Role string `json:"role" binding:"required,oneof=initiator participant"` Text string `json:"text" binding:"required"` } @@ -38,7 +39,9 @@ type SubmitResponse struct { // ── 参与者加入 ── type JoinRequest struct { - Nickname string `json:"nickname" binding:"required,min=1,max=32"` + SessionID string `json:"session_id" binding:"required,min=8,max=64"` + Nickname string `json:"nickname" binding:"required,min=1"` + BrowserID string `json:"browser_id"` } // JoinResponse 返回当前会话全量状态,供参与者初始化 UI @@ -50,12 +53,18 @@ type JoinResponse struct { // ── 会话全量状态(发起方拉取 / 发起方 WS 初始化) ── +// SessionRequest 统一的 session_id 请求体 +type SessionRequest struct { + SessionID string `json:"session_id" binding:"required,min=8,max=64"` +} + // SessionResponse 发起方看到的会话状态(完整参与者列表) type SessionResponse struct { SessionID string `json:"session_id"` InitiatorNickname string `json:"initiator_nickname"` InitiatorReady bool `json:"initiator_ready"` AllowResubmit bool `json:"allow_resubmit"` + IgnoreWs bool `json:"ignore_ws"` Participants []ParticipantResp `json:"participants"` TTLSeconds int64 `json:"ttl_seconds"` } @@ -74,6 +83,7 @@ type CheckResponse struct { Exists bool `json:"exists"` InitiatorReady bool `json:"initiator_ready"` AllowResubmit bool `json:"allow_resubmit"` + IgnoreWs bool `json:"ignore_ws"` ParticipantCount int `json:"participant_count"` TTLSeconds int64 `json:"ttl_seconds"` } @@ -82,5 +92,12 @@ type CheckResponse struct { // SetResubmitRequest 发起方切换"允许参与者重新提交"开关 type SetResubmitRequest struct { - Allow bool `json:"allow"` + SessionID string `json:"session_id" binding:"required,min=8,max=64"` + Allow bool `json:"allow"` +} + +// SetIgnoreWsRequest 发起方切换"忽略首尾空白"开关 +type SetIgnoreWsRequest struct { + SessionID string `json:"session_id" binding:"required,min=8,max=64"` + Ignore bool `json:"ignore"` } diff --git a/server/internal/tools/dual_check/routes.go b/server/internal/tools/dual_check/routes.go index 3f04484..8eb4a99 100644 --- a/server/internal/tools/dual_check/routes.go +++ b/server/internal/tools/dual_check/routes.go @@ -25,14 +25,15 @@ func Register( h := NewHandler(svc, hub, logger, maxTextLen) ws := NewWSHandler(svc, hub, logger) - // REST API - group.POST("/create", h.Create) // 发起方创建会话 - group.POST("/submit", h.Submit) // 发起方或参与者提交文本 - group.POST("/session/:session_id/join", h.Join) // 参与者加入(登记昵称) - group.POST("/session/:session_id/resubmit", h.SetResubmit) // 发起方切换"允许重提交"开关 - group.POST("/session/:session_id/end", h.EndSession) // 发起方主动结束会话 - group.GET("/session/:session_id", h.GetSession) // 拉全量状态 - group.GET("/check/:session_id", h.CheckSession) // 加入前预校验 + // REST API — 所有 session_id 走 POST body + group.POST("/create", h.Create) // 发起方创建会话 + group.POST("/submit", h.Submit) // 发起方或参与者提交文本 + group.POST("/join", h.Join) // 参与者加入(登记昵称) + group.POST("/resubmit", h.SetResubmit) // 发起方切换"允许重提交"开关 + group.POST("/ignore-ws", h.SetIgnoreWs) // 发起方切换"忽略首尾空白"开关 + group.POST("/end", h.EndSession) // 发起方主动结束会话 + group.POST("/session", h.GetSession) // 拉全量状态 + group.POST("/check", h.CheckSession) // 加入前预校验 // WebSocket 实时推送(带 role + nickname 查询参数) group.GET("/ws", ws.Handle) diff --git a/server/internal/tools/dual_check/service.go b/server/internal/tools/dual_check/service.go index d0f96de..6e678fd 100644 --- a/server/internal/tools/dual_check/service.go +++ b/server/internal/tools/dual_check/service.go @@ -2,9 +2,13 @@ package dual_check import ( "context" + "crypto/rand" "errors" "fmt" + "math/big" + "strings" "time" + "unicode/utf8" "cancerai.top/toolbox-server/internal/pkg/hash" "cancerai.top/toolbox-server/internal/pkg/sessionid" @@ -32,13 +36,13 @@ func NewService(s store.SessionStore, logger *zap.Logger, ttl time.Duration) *Se // ── Create ── -func (s *Service) CreateSession(ctx context.Context, nickname string) (*CreateResponse, error) { +func (s *Service) CreateSession(ctx context.Context, nickname, browserID string) (*CreateResponse, error) { var sid string var err error for i := 0; i < 3; i++ { sid, err = sessionid.Generate() if err == nil { - err = s.store.Create(ctx, sid, s.ttl) + err = s.store.Create(ctx, sid, nickname, browserID, s.ttl) if err == nil { break } @@ -59,28 +63,43 @@ func (s *Service) CreateSession(ctx context.Context, nickname string) (*CreateRe SessionID: sid, Nickname: nickname, ExpiresAt: expiresAt, - TTL: int(s.ttl.Seconds()), + // -1 表示"由发起方在线决定生命周期",Redis TTL 只是兜底 + TTL: -1, }, nil } // ── Submit(发起方 or 参与方) ── func (s *Service) Submit(ctx context.Context, req *SubmitRequest) (*SubmitResponse, error) { - hashHex := hash.SHA256(req.Text) + // 读 ignore_ws 设置,如果开启则 trim 首尾空白再 hash + ignoreWs, err := s.store.GetIgnoreWs(ctx, req.SessionID) + if err != nil { + if !errors.Is(err, store.ErrSessionNotFound) { + s.logger.Warn("GetIgnoreWs failed, defaulting to false", zap.Error(err)) + } + ignoreWs = false + } + originalText := req.Text // 保存原始文本(trim 前),用于未来切换 ignore_ws 时重算 + text := req.Text + if ignoreWs { + text = strings.TrimSpace(text) + } + + hashHex := hash.SHA256(text) req.Text = "" // 显式释放明文引用 switch req.Role { case "initiator": - return s.submitInitiator(ctx, req.SessionID, req.Nickname, hashHex) + return s.submitInitiator(ctx, req.SessionID, req.Nickname, hashHex, originalText) case "participant": - return s.submitParticipant(ctx, req.SessionID, req.Nickname, hashHex) + return s.submitParticipant(ctx, req.SessionID, req.Nickname, hashHex, originalText) default: return nil, &BusinessError{Code: ErrCodeInvalidRole} } } -func (s *Service) submitInitiator(ctx context.Context, sessionID, nickname, hashHex string) (*SubmitResponse, error) { - if err := s.store.SubmitInitiator(ctx, sessionID, nickname, hashHex); err != nil { +func (s *Service) submitInitiator(ctx context.Context, sessionID, nickname, hashHex, originalText string) (*SubmitResponse, error) { + if err := s.store.SubmitInitiator(ctx, sessionID, nickname, hashHex, originalText); err != nil { if errors.Is(err, store.ErrSessionNotFound) { return nil, ErrSessionNotFound } @@ -95,8 +114,8 @@ func (s *Service) submitInitiator(ctx context.Context, sessionID, nickname, hash return &SubmitResponse{Submitted: true, InitiatorReady: true}, nil } -func (s *Service) submitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (*SubmitResponse, error) { - initReady, match, err := s.store.SubmitParticipant(ctx, sessionID, nickname, hashHex) +func (s *Service) submitParticipant(ctx context.Context, sessionID, nickname, hashHex, originalText string) (*SubmitResponse, error) { + initReady, match, err := s.store.SubmitParticipant(ctx, sessionID, nickname, hashHex, originalText) if err != nil { switch { case errors.Is(err, store.ErrSessionNotFound): @@ -126,28 +145,111 @@ func (s *Service) submitParticipant(ctx context.Context, sessionID, nickname, ha // ── Join ── -func (s *Service) JoinParticipant(ctx context.Context, sessionID, nickname string) (*SessionResponse, error) { - // 检查昵称是否等于发起方昵称(不允许用发起方的昵称作为参与者) +// JoinParticipant 参与者加入会话,自动解决昵称冲突 +// 返回 (sess, actualNick, err) — actualNick 是实际登记的昵称(可能是自动改名后的) +func (s *Service) JoinParticipant(ctx context.Context, sessionID, nickname, participantBrowserID string) (*SessionResponse, string, error) { snap, err := s.store.Get(ctx, sessionID) if err != nil { if errors.Is(err, store.ErrSessionNotFound) { - return nil, ErrSessionNotFound + return nil, "", ErrSessionNotFound } - return nil, err - } - if snap.InitiatorNickname != "" && snap.InitiatorNickname == nickname { - return nil, &BusinessError{Code: ErrCodeNicknameExists} + return nil, "", err } - if err := s.store.JoinParticipant(ctx, sessionID, nickname); err != nil { + actualNick := nickname + // 检查冲突:发起方昵称 或 已有参与者昵称 + // 但如果"冲突方"的 browserId 和参与者相同(同一个浏览器的不同 tab),视为同一个人不冲突 + if isNicknameTaken(snap, nickname, participantBrowserID) { + actualNick = generateSafeNickname(nickname, snap, 5, participantBrowserID) + s.logger.Info("nickname auto-renamed on join", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("original", nickname), + zap.String("actual", actualNick), + zap.String("browser_id", participantBrowserID)) + } + + if err := s.store.JoinParticipant(ctx, sessionID, actualNick, participantBrowserID); err != nil { if errors.Is(err, store.ErrSessionNotFound) { - return nil, ErrSessionNotFound + return nil, "", ErrSessionNotFound } - return nil, err + return nil, "", err } - // 返回加入后的最新状态 - return s.GetSession(ctx, sessionID) + sess, err := s.GetSession(ctx, sessionID) + return sess, actualNick, err +} + +// isNicknameTaken 检查昵称是否被发起方或已有参与者占用 +// participantBrowserID:正在加入的人的 browserId——如果"冲突方"的 browserId 和他相同,视为同一个人,不冲突 +func isNicknameTaken(snap *store.SessionSnapshot, nickname, participantBrowserID string) bool { + if snap.InitiatorNickname != "" && snap.InitiatorNickname == nickname { + // 发起方和参与者是同一个浏览器 → 同一个人 → 不冲突 + if snap.InitiatorBrowserID != "" && snap.InitiatorBrowserID == participantBrowserID { + return false + } + return true + } + for _, p := range snap.Participants { + if p.Nickname == nickname { + // 已有参与者和新参与者是同一个浏览器 → 同一个人 → 不冲突 + if p.BrowserID != "" && p.BrowserID == participantBrowserID { + return false + } + return true + } + } + return false +} + +// generateSafeNickname 生成不冲突的新昵称:原名-XXX(XXX 是 3 位随机数字) +// 如果原名太长,截断后再加 -XXX,保证总长不超过 32 字符 +func generateSafeNickname(original string, snap *store.SessionSnapshot, maxRetries int, participantBrowserID string) string { + const suffixLen = 4 // "-" + 3 digits + const maxRunes = 32 + + // 算 base(加上 suffix 后不超过 32 runes) + origRunes := utf8.RuneCountInString(original) + var base string + if origRunes+suffixLen <= maxRunes { + base = original + } else { + // 截断到 maxRunes - suffixLen 个 rune + runes := []rune(original) + base = string(runes[:maxRunes-suffixLen]) + } + + for i := 0; i < maxRetries; i++ { + suffix := randomDigits(3) + candidate := base + "-" + suffix + if !isNicknameTaken(snap, candidate, participantBrowserID) { + return candidate + } + } + // 极端情况还冲突,再多加一位 + for i := 0; i < maxRetries; i++ { + suffix := randomDigits(4) + candidate := base + "-" + suffix + if !isNicknameTaken(snap, candidate, participantBrowserID) { + return candidate + } + } + return base + "-" + randomDigits(3) // 兜底 +} + +// randomDigits 返回 n 位随机数字字符串(crypto/rand 安全) +func randomDigits(n int) string { + const digits = "0123456789" + b := make([]byte, n) + for i := 0; i < n; i++ { + idx, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + // 兜底用伪随机 + b[i] = digits[time.Now().UnixNano()%10] + } else { + b[i] = digits[idx.Int64()] + } + } + return string(b) } // ── GetSession 返回全量状态 ── @@ -160,7 +262,6 @@ func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionRes } return nil, err } - ttl := time.Until(snap.ExpiresAt) parts := make([]ParticipantResp, 0, len(snap.Participants)) for _, p := range snap.Participants { parts = append(parts, ParticipantResp{ @@ -175,8 +276,10 @@ func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionRes InitiatorNickname: snap.InitiatorNickname, InitiatorReady: snap.InitiatorSubmitted, AllowResubmit: snap.AllowResubmit, + IgnoreWs: snap.IgnoreWs, Participants: parts, - TTLSeconds: int64(ttl.Seconds()), + // -1 表示会话由发起方在线决定生命周期 + TTLSeconds: -1, }, nil } @@ -190,16 +293,14 @@ func (s *Service) CheckSession(ctx context.Context, sessionID string) (*CheckRes } return nil, err } - ttl := time.Until(snap.ExpiresAt) - if ttl < 0 { - return &CheckResponse{Exists: false}, nil - } return &CheckResponse{ Exists: true, InitiatorReady: snap.InitiatorSubmitted, AllowResubmit: snap.AllowResubmit, + IgnoreWs: snap.IgnoreWs, ParticipantCount: len(snap.Participants), - TTLSeconds: int64(ttl.Seconds()), + // -1 表示会话由发起方在线决定生命周期 + TTLSeconds: -1, }, nil } @@ -221,6 +322,80 @@ func (s *Service) SetResubmit(ctx context.Context, sessionID string, allow bool) return s.GetSession(ctx, sessionID) } +func (s *Service) SetIgnoreWs(ctx context.Context, sessionID string, ignore bool) (*SessionResponse, error) { + if err := s.store.SetIgnoreWs(ctx, sessionID, ignore); err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return nil, ErrSessionNotFound + } + s.logger.Error("set ignore_ws failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID))) + return nil, fmt.Errorf("set ignore_ws: %w", err) + } + // 切换后按新规则重新计算所有已提交的 match + if err := s.store.RecalcMatches(ctx, sessionID, ignore); err != nil { + s.logger.Warn("recalc matches failed (non-fatal)", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID))) + } + s.logger.Info("ignore_ws changed", + zap.String("session_id", truncateSessionID(sessionID)), + zap.Bool("ignore", ignore), + ) + return s.GetSession(ctx, sessionID) +} + +// ── UpdateParticipantNickname 参与者修改自己的昵称 ── + +func (s *Service) UpdateParticipantNickname(ctx context.Context, sessionID, oldNick, newNick string) error { + if oldNick == newNick { + return nil // 无变化 + } + // 先查会话,拿到 oldNick 的 browserId(用于判断是否和冲突方是同一个浏览器) + snap, err := s.store.Get(ctx, sessionID) + if err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return ErrSessionNotFound + } + return err + } + + // 找 oldNick 的 browserId + var oldBrowserID string + for _, p := range snap.Participants { + if p.Nickname == oldNick { + oldBrowserID = p.BrowserID + break + } + } + + // 发起方昵称冲突检测(同浏览器跳过) + if snap.InitiatorNickname == newNick { + if snap.InitiatorBrowserID == "" || snap.InitiatorBrowserID != oldBrowserID { + return &BusinessError{Code: ErrCodeNicknameExists, Message: "该昵称已被发起方使用"} + } + } + err = s.store.UpdateParticipantNickname(ctx, sessionID, oldNick, newNick, oldBrowserID) + if err != nil { + switch { + case errors.Is(err, store.ErrSessionNotFound): + return ErrSessionNotFound + case errors.Is(err, store.ErrOldNotFound): + return &BusinessError{Code: ErrCodeNotInSession} + case errors.Is(err, store.ErrNicknameConflict): + return &BusinessError{Code: ErrCodeNicknameExists, Message: "该昵称已被其他参与者使用"} + default: + s.logger.Error("update participant nickname failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID))) + return fmt.Errorf("update nickname: %w", err) + } + } + s.logger.Info("participant renamed", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("old", oldNick), + zap.String("new", newNick), + ) + return nil +} + // ── EndSession 发起方结束会话(删除 Redis key,WS 广播由 handler 完成) ── func (s *Service) EndSession(ctx context.Context, sessionID string) error { @@ -235,6 +410,25 @@ func (s *Service) EndSession(ctx context.Context, sessionID string) error { return nil } +// ── RemoveParticipant 参与者主动离开(从 Redis 删除其字段) ── + +func (s *Service) RemoveParticipant(ctx context.Context, sessionID, nickname string) error { + err := s.store.RemoveParticipant(ctx, sessionID, nickname) + if err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return ErrSessionNotFound + } + s.logger.Warn("remove participant failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("nickname", nickname)) + return err + } + s.logger.Info("participant removed", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("nickname", nickname)) + return nil +} + func truncateSessionID(id string) string { if len(id) > 8 { return id[:8] + "..." diff --git a/server/internal/tools/dual_check/ws_handler.go b/server/internal/tools/dual_check/ws_handler.go index 2d14110..f05a040 100644 --- a/server/internal/tools/dual_check/ws_handler.go +++ b/server/internal/tools/dual_check/ws_handler.go @@ -3,8 +3,11 @@ package dual_check import ( "context" "encoding/json" + "errors" "fmt" "net/http" + "strings" + "unicode/utf8" pkgws "cancerai.top/toolbox-server/internal/pkg/websocket" "cancerai.top/toolbox-server/internal/response" @@ -35,6 +38,7 @@ func (h *WSHandler) Handle(c *gin.Context) { sessionID := c.Query("session_id") role := c.Query("role") nickname := c.Query("nickname") + browserID := c.Query("browser_id") if sessionID == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "session_id required"}) @@ -44,7 +48,7 @@ func (h *WSHandler) Handle(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 1004, "message": "role must be initiator or participant"}) return } - if nickname == "" || len(nickname) > 32 { + if nickname == "" || utf8.RuneCountInString(nickname) > 32 { c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "nickname required (1-32)"}) return } @@ -71,11 +75,27 @@ func (h *WSHandler) Handle(c *gin.Context) { } h.hub.Register(wsConn) - // 参与者首次连接:自动登记昵称(幂等) + // 参与者首次连接:自动登记昵称(冲突时自动改名) + actualNick := nickname if role == "participant" { - _, _ = h.service.JoinParticipant(c.Request.Context(), sessionID, nickname) - // 广播 participant_joined 给房间其他人 - h.hub.Broadcast(sessionID, response.WSParticipantJoined(nickname)) + _, actual, err := h.service.JoinParticipant(c.Request.Context(), sessionID, nickname, browserID) + if err == nil { + actualNick = actual + if actualNick != nickname { + // 自动改名:通知客户端 + 用 actualNick 广播 + wsConn.Send <- marshalMsg(response.WSNicknameAutoChanged(actualNick)) + h.logger.Info("ws participant auto-renamed", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("original", nickname), + zap.String("actual", actualNick)) + } + h.hub.Broadcast(sessionID, response.WSParticipantJoined(actualNick)) + } else { + h.logger.Warn("ws JoinParticipant failed", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("nickname", nickname), + zap.Error(err)) + } } // 推一个 init 消息给新连接者,带上当前全量快照让它自己初始化 UI @@ -91,10 +111,13 @@ func (h *WSHandler) Handle(c *gin.Context) { go pkgws.WritePump(wsConn) // ReadPump 里的 Unregister 会触发 participant_left 广播 - go h.readLoop(wsConn, nickname) + go h.readLoop(wsConn, actualNick) } -func (h *WSHandler) readLoop(c *pkgws.Conn, nickname string) { +func (h *WSHandler) readLoop(c *pkgws.Conn, initialNickname string) { + // currentNick 是可变的,rename 成功后更新,保证 defer 中的 participant_left 广播用最新昵称 + currentNick := initialNickname + defer func() { c.WS.Close() // 先关,让 WritePump 也能退 if c.Role == "initiator" { @@ -107,19 +130,87 @@ func (h *WSHandler) readLoop(c *pkgws.Conn, nickname string) { } else { h.logger.Info("initiator disconnected, session ended", zap.String("session_id", truncateSessionID(c.SessionID)), - zap.String("nickname", nickname)) + zap.String("nickname", currentNick)) } } else { - // 参与者断开:广播 participant_left - h.hub.Broadcast(c.SessionID, response.WSParticipantLeft(nickname)) + // 参与者断开:先从 Redis 删除字段,再广播 participant_left + if err := h.service.RemoveParticipant(context.Background(), c.SessionID, currentNick); err != nil { + h.logger.Warn("participant RemoveParticipant err", + zap.String("session_id", truncateSessionID(c.SessionID)), + zap.String("nickname", currentNick), + zap.Error(err)) + } + h.logger.Info("participant disconnected", + zap.String("session_id", truncateSessionID(c.SessionID)), + zap.String("nickname", currentNick)) + h.hub.Broadcast(c.SessionID, response.WSParticipantLeft(currentNick)) } c.Hub.Unregister(c) // 最后从房间移除 }() + c.WS.SetPongHandler(func(string) error { return nil }) for { - if _, _, err := c.WS.ReadMessage(); err != nil { + _, raw, err := c.WS.ReadMessage() + if err != nil { return } + + // 解析客户端发来的消息 + var msg struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + continue // 不是合法 JSON,忽略 + } + + switch msg.Type { + case "rename": + // 只有参与者允许改昵称 + if c.Role != "participant" { + h.sendError(c, "rename not allowed for role "+c.Role) + continue + } + var data struct { + NewNickname string `json:"new_nickname"` + } + if err := json.Unmarshal(msg.Data, &data); err != nil || strings.TrimSpace(data.NewNickname) == "" || utf8.RuneCountInString(data.NewNickname) > 32 { + h.sendError(c, "invalid new_nickname") + continue + } + newNick := strings.TrimSpace(data.NewNickname) + if newNick == currentNick { + continue // 无变化 + } + err := h.service.UpdateParticipantNickname(context.Background(), c.SessionID, currentNick, newNick) + if err != nil { + var be *BusinessError + if errors.As(err, &be) { + h.sendError(c, be.Error()) // 优先用自定义 Message,否则回退到 ErrMessage(code) + } else { + h.sendError(c, "rename failed: "+err.Error()) + } + continue + } + // 成功:更新本地昵称 + 广播给房间其他人 + oldNick := currentNick + currentNick = newNick + h.hub.Broadcast(c.SessionID, response.WSParticipantRenamed(oldNick, newNick)) + h.logger.Info("ws rename success", + zap.String("session_id", truncateSessionID(c.SessionID)), + zap.String("old", oldNick), + zap.String("new", newNick), + ) + } + } +} + +// sendError 通过 WS 给客户端发送一个 error 消息 +func (h *WSHandler) sendError(c *pkgws.Conn, errMsg string) { + msg := response.WebSocketMessage{Type: "error", Error: errMsg} + select { + case c.Send <- marshalMsg(msg): + default: } }