diff --git a/client/lib/modules/dual_check/api.dart b/client/lib/modules/dual_check/api.dart new file mode 100644 index 0000000..2795ce5 --- /dev/null +++ b/client/lib/modules/dual_check/api.dart @@ -0,0 +1,253 @@ +// DualCheck API 封装 +// 后端统一响应格式:{code:0, message, data} +// WebSocket 消息格式:{type, data, error} +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// 后端地址 +const String _baseUrl = kIsWeb + ? 'http://localhost:8080' + : 'http://127.0.0.1:8080'; + +/// WebSocket 地址 +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); + }, + )); + } + + final Dio _dio; + + // ── Create ── + + Future createSession({required String nickname}) async { + final res = await _dio.post>( + '/api/v1/dual-check/create', + data: {'nickname': nickname}, + ); + final d = res.data!['data'] as Map; + return CreateResp( + sessionId: d['session_id'] as String, + nickname: d['nickname'] as String, + ttl: d['ttl_seconds'] as int, + ); + } + + // ── Submit ── + + Future submit({ + required String sessionId, + required String nickname, + required String role, // "initiator" | "participant" + required String text, + }) async { + final res = await _dio.post>( + '/api/v1/dual-check/submit', + data: { + 'session_id': sessionId, + 'nickname': nickname, + 'role': role, + 'text': text, + }, + ); + final d = res.data!['data'] as Map; + return SubmitResp( + submitted: d['submitted'] as bool, + initiatorReady: d['initiator_ready'] as bool, + match: d['match'] as bool?, + ); + } + + // ── CheckSession ── + + Future checkSession(String sessionId) async { + final res = await _dio.get>( + '/api/v1/dual-check/check/$sessionId', + ); + final d = res.data!['data'] as Map; + return CheckResp( + exists: d['exists'] as bool, + initiatorReady: d['initiator_ready'] as bool, + participantCount: d['participant_count'] as int, + ttlSeconds: d['ttl_seconds'] as int, + ); + } + + // ── GetSession 全量快照 ── + + Future getSession(String sessionId) async { + final res = await _dio.get>( + '/api/v1/dual-check/session/$sessionId', + ); + return parseSession(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}, + ); + return parseSession(res.data!['data'] as Map); + } + + // ── EndSession 发起方主动结束会话 ── + + Future endSession(String sessionId) async { + await _dio.post('/api/v1/dual-check/session/$sessionId/end'); + } + + static SessionResp parseSession(Map d) { + final parts = (d['participants'] as List) + .map((p) => ParticipantResp.fromMap(p as Map)) + .toList(); + return SessionResp( + sessionId: d['session_id'] as String, + initiatorNickname: (d['initiator_nickname'] ?? '') as String, + initiatorReady: d['initiator_ready'] as bool, + allowResubmit: (d['allow_resubmit'] as bool?) ?? false, + participants: parts, + ttlSeconds: d['ttl_seconds'] as int, + ); + } + + // ── WS URL ── + + String wsUrl({ + required String sessionId, + required String role, + required String nickname, + }) { + return '$_wsBaseUrl/api/v1/dual-check/ws' + '?session_id=${Uri.encodeQueryComponent(sessionId)}' + '&role=$role' + '&nickname=${Uri.encodeQueryComponent(nickname)}'; + } +} + +// ── 数据模型 ── + +class CreateResp { + final String sessionId; + final String nickname; + final int ttl; + CreateResp({required this.sessionId, required this.nickname, required this.ttl}); +} + +class SubmitResp { + final bool submitted; + final bool initiatorReady; + final bool? match; + SubmitResp({required this.submitted, required this.initiatorReady, this.match}); +} + +class CheckResp { + final bool exists; + final bool initiatorReady; + final int participantCount; + final int ttlSeconds; + CheckResp({ + required this.exists, + required this.initiatorReady, + required this.participantCount, + required this.ttlSeconds, + }); +} + +class ParticipantResp { + final String nickname; + final bool joined; + final bool submitted; + final bool? match; + ParticipantResp({ + required this.nickname, + required this.joined, + 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?, + ); + } +} + +class SessionResp { + final String sessionId; + final String initiatorNickname; + final bool initiatorReady; + final bool allowResubmit; + final List participants; + final int ttlSeconds; + SessionResp({ + required this.sessionId, + required this.initiatorNickname, + required this.initiatorReady, + required this.allowResubmit, + required this.participants, + required this.ttlSeconds, + }); +} + +// ── WS 消息类型 ── + +class WSMessage { + final String type; + final Map data; + final String? 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?, + ); + } +} + +// ── 业务错误处理 ── + +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 341060d..54f05e8 100644 --- a/client/lib/modules/dual_check/dual_check.dart +++ b/client/lib/modules/dual_check/dual_check.dart @@ -1,4 +1,18 @@ -import 'package:flutter/cupertino.dart'; +// DualCheck 主页面(多参与者版) +// 一个发起方(initiator)提交标准答案,多个参与者(participant)各自提交文本 +// 通过 WebSocket 实时同步参与者列表 + 提交状态 + match 结果 +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'api.dart'; +import 'nickname_service.dart'; + +enum _RoleSelect { initiator, participant } class DualCheck extends StatefulWidget { const DualCheck({super.key}); @@ -8,8 +22,963 @@ class DualCheck extends StatefulWidget { } class _DualCheckState extends State { + final _api = DualCheckApi(); + final _nickService = NicknameService.instance; + final _textCtrl = TextEditingController(); + + // ── 会话状态 ── + String? _sessionId; + late String _nickname = '用户'; + late _RoleSelect _myRole = _RoleSelect.initiator; + SessionResp? _session; // 当前会话全量状态(发起方视图) + + // ── WS ── + WebSocketChannel? _ws; + StreamSubscription? _wsSub; + + // ── UI 状态 ── + bool _isLoading = false; + String? _errorMsg; + + @override + void initState() { + super.initState(); + _loadNickname(); + } + + Future _loadNickname() async { + final n = await _nickService.nickname; + if (!mounted) return; + setState(() => _nickname = n); + } + + @override + void dispose() { + _wsSub?.cancel(); + _ws?.sink.close(); + _textCtrl.dispose(); + super.dispose(); + } + + // ── 发起方:创建会话 ── + Future _createSession() async { + setState(() => _isLoading = true); + try { + final resp = await _api.createSession(nickname: _nickname); + setState(() { + _sessionId = resp.sessionId; + _myRole = _RoleSelect.initiator; + }); + // WS 连接(发起方不需要 join,直接连接) + _connectWs(); + } on DioException catch (e) { + setState(() => _errorMsg = isBizError(e) ? bizErrorMessage(e) : '创建失败'); + } catch (e) { + setState(() => _errorMsg = '创建失败:$e'); + } finally { + setState(() => _isLoading = false); + } + } + + // ── 参与方:输入会话码加入(对话框内校验) ── + void _showJoinDialog() { + final ctrl = TextEditingController(); + bool isChecking = false; + String? err; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: const Text('输入会话码加入'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: ctrl, + enabled: !isChecking, + decoration: const InputDecoration( + hintText: '粘贴或输入会话码', + border: OutlineInputBorder(), + ), + ), + if (err != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text(err!, + style: const TextStyle(color: Colors.red, fontSize: 13)), + ), + ], + ), + actions: [ + TextButton( + onPressed: isChecking ? null : () => Navigator.pop(ctx), + child: const Text('取消'), + ), + FilledButton( + onPressed: isChecking + ? null + : () async { + final sid = ctrl.text.trim(); + if (sid.isEmpty) { + set(() => err = '请输入会话码'); + return; + } + set(() { + isChecking = true; + err = null; + }); + try { + await _api.checkSession(sid); + if (!ctx.mounted) return; + Navigator.pop(ctx); + setState(() { + _sessionId = sid; + _myRole = _RoleSelect.participant; + }); + _connectWs(); + } on DioException catch (e) { + set(() { + isChecking = false; + err = isBizError(e) + ? bizErrorMessage(e) + : '无法连接服务器'; + }); + } catch (e) { + set(() { + isChecking = false; + err = '校验失败:$e'; + }); + } + }, + child: isChecking + ? const SizedBox(width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('加入'), + ), + ], + ), + ), + ); + } + + // ── WS 连接 ── + void _connectWs() { + final role = _myRole == _RoleSelect.initiator ? 'initiator' : 'participant'; + final url = _api.wsUrl( + sessionId: _sessionId!, + role: role, + nickname: _nickname, + ); + try { + _ws = WebSocketChannel.connect(Uri.parse(url)); + _wsSub = _ws!.stream.listen(_onWsMessage, onError: (e) { + debugPrint('[DC] ws error: $e'); + }, onDone: () { + debugPrint('[DC] ws closed'); + }); + } catch (e) { + debugPrint('[DC] ws connect fail: $e'); + } + } + + // ── WS 消息处理(事件驱动 UI) ── + void _onWsMessage(dynamic raw) { + try { + final msg = WSMessage.fromJson( + jsonDecode(raw as String) as Map); + switch (msg.type) { + case 'full_state': + final s = DualCheckApi.parseSession(msg.data); + setState(() => _session = s); + case 'participant_joined': + final nick = msg.data['nickname'] as String; + setState(() { + _session ??= SessionResp( + sessionId: _sessionId!, + initiatorNickname: '', + initiatorReady: false, + allowResubmit: false, + participants: [], + ttlSeconds: 0); + final list = List.from(_session!.participants); + if (!list.any((p) => p.nickname == nick)) { + list.add(ParticipantResp( + nickname: nick, joined: true, submitted: false)); + } + _session = _session!.copyWith(participants: list); + }); + case 'participant_left': + final nick = msg.data['nickname'] as String; + setState(() { + if (_session == null) return; + final list = List.from(_session!.participants); + list.removeWhere((p) => p.nickname == nick); + _session = _session!.copyWith(participants: list); + }); + case 'participant_submitted': + final nick = msg.data['nickname'] as String; + final submitData = msg.data['submit'] as Map; + setState(() { + if (_session == null) return; + final list = List.from(_session!.participants); + final idx = list.indexWhere((p) => p.nickname == nick); + if (idx >= 0) { + list[idx] = list[idx].copyWith( + submitted: true, + match: submitData['match'] as bool?, + ); + } + _session = _session!.copyWith(participants: list); + }); + case 'initiator_ready': + setState(() { + if (_session != null) { + _session = _session!.copyWith(initiatorReady: true); + } + }); + case 'resubmit_changed': + final allow = msg.data['allow'] as bool; + setState(() { + _session ??= SessionResp( + sessionId: _sessionId!, + initiatorNickname: '', + initiatorReady: false, + allowResubmit: false, + participants: [], + ttlSeconds: 0); + _session = _session!.copyWith(allowResubmit: allow); + if (allow) _errorMsg = null; // 开关打开时清掉上次"不能重提交"的报错 + }); + case 'session_ended': + // 只有参与者收到才弹窗——发起方自己就是结束者,不需要再提示自己 + if (_myRole == _RoleSelect.participant) _showSessionEnded(); + } + } catch (e) { + debugPrint('[DC] ws parse error: $e'); + } + } + + // ── 提交文本 ── + Future _submit() async { + if (_sessionId == null) return; + final text = _textCtrl.text.trim(); + if (text.isEmpty) { + setState(() => _errorMsg = '请输入内容后再提交'); + return; + } + setState(() { + _isLoading = true; + _errorMsg = null; // 清除上次残留的报错 + }); + try { + final role = _myRole == _RoleSelect.initiator ? 'initiator' : 'participant'; + final resp = await _api.submit( + sessionId: _sessionId!, + nickname: _nickname, + role: role, + text: text, + ); + _textCtrl.clear(); + + // 参与者提交后立刻在本地更新会话状态 + if (_myRole == _RoleSelect.participant) { + setState(() { + _session ??= SessionResp( + sessionId: _sessionId!, + initiatorNickname: '', + initiatorReady: false, + allowResubmit: false, + participants: [], + ttlSeconds: 0); + _session = _session!.copyWith( + initiatorReady: resp.initiatorReady, + ); + }); + // 如果 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('标准答案已提交,等待参与者...')), + ); + } + } on DioException catch (e) { + final code = bizErrorCode(e); + String msg; + if (code == 1003) { + msg = '已提交过,等待发起方开启"允许重新提交"后再试'; + } else { + msg = isBizError(e) ? bizErrorMessage(e) : '提交失败'; + } + setState(() => _errorMsg = msg); + } catch (e) { + setState(() => _errorMsg = '提交失败:$e'); + } finally { + setState(() => _isLoading = false); + } + } + + // ── 重置 ── + void _reset() { + _wsSub?.cancel(); + _ws?.sink.close(); + _textCtrl.clear(); + setState(() { + _ws = null; + _wsSub = null; + _sessionId = null; + _session = null; + _errorMsg = null; + }); + } + + // ── 发起方:主动结束会话(广播给所有人后退出) ── + Future _confirmAndEndSession() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.warning_amber_rounded, size: 40, color: Colors.orange), + title: const Text('确定结束会话?'), + content: const Text('结束后所有参与者都会收到通知并自动退出,无法恢复。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('确定结束'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await _endSession(); + } + } + + Future _endSession() async { + final sid = _sessionId; + if (sid == null) return; + try { + await _api.endSession(sid); + } catch (e) { + // 即使网络失败,后端也可能已成功,直接进入 reset + debugPrint('[DC] endSession api error (ignoring): $e'); + } + if (!mounted) return; + _reset(); + } + + // ── 收到 session_ended WS 事件:弹窗 + 自动退出 ── + void _showSessionEnded() { + // 防重复弹窗(多次 session_ended / 重复 Build) + if (!mounted || _sessionId == null) return; + final sid = _sessionId!; + _reset(); + if (!mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.info_outline, size: 50, color: Colors.blue), + title: const Text('会话已结束'), + content: Text('会话 $sid 已由发起方主动结束,即将返回首页。'), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + } + + // ── 发起方:切换允许重提交开关 ── + Future _toggleResubmit(bool value) async { + final sid = _sessionId; + if (sid == null) return; + setState(() { + _session = _session?.copyWith(allowResubmit: value); + }); + try { + final sess = await _api.setResubmit(sid, allow: value); + if (!mounted) return; + setState(() => _session = sess); + } on DioException catch (e) { + // 失败回滚 + if (!mounted) return; + setState(() { + _session = _session?.copyWith(allowResubmit: !value); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('操作失败:${bizErrorMessage(e)}')), + ); + } + } + + // ── 设置昵称对话框 ── + void _showNicknameDialog() { + final ctrl = TextEditingController(text: _nickname); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('设置昵称'), + content: TextField( + controller: ctrl, + maxLength: 32, + decoration: const InputDecoration( + hintText: '1-32 字符', + border: OutlineInputBorder(), + counterText: '', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + FilledButton( + onPressed: () async { + final ok = await _nickService.setNickname(ctrl.text); + if (ok && ctx.mounted) Navigator.pop(ctx); + setState(() { + if (ok) _nickname = ctrl.text.trim(); + }); + if (ctx.mounted && !ok) { + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar(content: Text('昵称无效')), + ); + } + }, + child: const Text('保存'), + ), + ], + ), + ); + } + + // ── 参与者结果弹窗 ── + void _showMyResult(bool match) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: Icon(match ? Icons.check_circle : Icons.cancel, + 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), + child: const Text('好的')), + ], + ), + ); + } + + void _showParticipantWaiting() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已提交,等待发起方确认答案...')), + ); + } + + // ── UI ── @override Widget build(BuildContext context) { - return const Placeholder(); + // 会话中:PopScope 拦截所有返回方式(AppBar back、系统返回键、Android 物理返回) + final Widget body; + if (_sessionId == null) { + body = _buildRoleSelect(); + } else { + body = PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; // PopScope 允许了 pop + await _handleWillPop(); + }, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildSession(), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('DualCheck 双验'), + centerTitle: true, + actions: [ + IconButton( + tooltip: '设置昵称', + icon: const Icon(Icons.person), + onPressed: _showNicknameDialog, + ), + ], + ), + body: body, + ); + } + + // ── PopScope:拦截返回 ── + Future _handleWillPop() async { + if (_sessionId == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: Icon( + _myRole == _RoleSelect.initiator + ? Icons.warning_amber_rounded + : Icons.exit_to_app, + size: 40, + color: _myRole == _RoleSelect.initiator ? Colors.orange : Colors.blue, + ), + title: Text(_myRole == _RoleSelect.initiator ? '确定退出会话?' : '确定离开会话?'), + content: Text(_myRole == _RoleSelect.initiator + ? '退出将结束会话,所有参与者都会收到通知并自动下线,无法恢复。' + : '离开后将断开连接,不再接收会话消息。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: _myRole == _RoleSelect.initiator ? Colors.red : null, + ), + child: Text(_myRole == _RoleSelect.initiator ? '结束并退出' : '离开'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + if (_myRole == _RoleSelect.initiator) { + await _endSession(); // 广播 session_ended + 删 Redis → 参与者自动下线 + } else { + _reset(); + } + } + } + + // ── 首页:选择角色 ── + Widget _buildRoleSelect() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + const Text('双验', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('多人各输入一段文字,看谁和发起方一致', + 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, + icon: const Icon(Icons.add_circle_outline), + label: Text(_isLoading ? '创建中...' : '我是发起方'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16)), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _isLoading ? null : _showJoinDialog, + icon: const Icon(Icons.login), + label: const Text('我是参与方'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16)), + ), + if (_errorMsg != null) _buildError(_errorMsg!), + const SizedBox(height: 24), + ], + ); + } + + // ── 会话页(发起方和参与方共用) ── + Widget _buildSession() { + if (_myRole == _RoleSelect.initiator) { + return _buildInitiatorView(); + } else { + return _buildParticipantView(); + } + } + + // 发起方视图 + Widget _buildInitiatorView() { + final initReady = _session?.initiatorReady ?? false; + final participants = _session?.participants ?? []; + final initNick = _session?.initiatorNickname ?? _nickname; + + 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( + children: [ + Icon(Icons.check_circle, color: Colors.green.shade700), + const SizedBox(width: 8), + Expanded( + child: Text('标准答案已提交($initNick)', + style: TextStyle(color: Colors.green.shade700)), + ), + ], + ), + ), + ], + 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, + ), + const SizedBox(height: 8), + ElevatedButton(onPressed: _confirmAndEndSession, child: const Text('结束会话')), + ], + ); + } + + Widget _buildParticipantCard(ParticipantResp p) { + IconData icon; + Color color; + String status; + if (!p.submitted) { + icon = Icons.hourglass_empty; + color = Colors.orange; + status = '等待提交'; + } else if (p.match == null) { + icon = Icons.check_circle; + color = Colors.blue; + status = '已提交'; + } else if (p.match == true) { + icon = Icons.check_circle; + color = Colors.green; + status = '正确 ✓'; + } else { + icon = Icons.cancel; + color = Colors.red; + status = '错误 ✗'; + } + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 10), + Expanded( + child: Text( + p.nickname, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text(status, + style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } + + // 参与者视图 + Widget _buildParticipantView() { + final initReady = _session?.initiatorReady ?? false; + final initNick = _session?.initiatorNickname ?? '(发起方)'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSessionBar(), + const SizedBox(height: 16), + + // 发起方状态 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: initReady ? Colors.green.shade50 : Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + initReady + ? Icons.check_circle + : Icons.hourglass_empty, + color: initReady ? Colors.green : Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Text( + initReady + ? '发起方 $initNick 已提交标准答案' + : '发起方 $initNick 还没提交,先提交你自己的也可以', + style: TextStyle( + color: + initReady ? Colors.green.shade700 : Colors.orange.shade700), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 自己的输入框 + 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!), + + const Spacer(), + ElevatedButton(onPressed: _reset, child: const Text('离开会话')), + ], + ); + } + + // ── 通用组件 ── + + Widget _buildSessionBar() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.share, size: 18, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Text( + _sessionId ?? '', + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + ), + ), + IconButton( + tooltip: '复制', + onPressed: () => _copy(_sessionId ?? ''), + icon: const Icon(Icons.copy, size: 18), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _myRole == _RoleSelect.initiator + ? Colors.blue.shade100 + : Colors.orange.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _myRole == _RoleSelect.initiator ? '发起方' : '参与方', + style: TextStyle( + color: _myRole == _RoleSelect.initiator + ? Colors.blue.shade800 + : Colors.orange.shade800, + fontSize: 12, + fontWeight: FontWeight.bold), + ), + ), + ], + ), + ); + } + + Future _copy(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('已复制'))); + } + + Widget _buildError(String msg) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(6)), + child: Row( + children: [ + const Icon(Icons.error_outline, size: 18, color: Colors.red), + const SizedBox(width: 8), + Expanded(child: Text(msg, style: const TextStyle(color: Colors.red))), + ], + ), + ), + ); + } +} + +// ── ParticipantResp.copyWith 辅助扩展 ── + +extension _ParticipantCopy on ParticipantResp { + ParticipantResp copyWith({ + String? nickname, + bool? joined, + bool? submitted, + bool? match, + }) { + return ParticipantResp( + nickname: nickname ?? this.nickname, + joined: joined ?? this.joined, + submitted: submitted ?? this.submitted, + match: match ?? this.match, + ); + } +} + +extension _SessionCopy on SessionResp { + SessionResp copyWith({ + String? sessionId, + String? initiatorNickname, + bool? initiatorReady, + bool? allowResubmit, + List? participants, + int? ttlSeconds, + }) { + return SessionResp( + sessionId: sessionId ?? this.sessionId, + initiatorNickname: initiatorNickname ?? this.initiatorNickname, + initiatorReady: initiatorReady ?? this.initiatorReady, + allowResubmit: allowResubmit ?? this.allowResubmit, + participants: participants ?? this.participants, + ttlSeconds: ttlSeconds ?? this.ttlSeconds, + ); } } diff --git a/client/lib/modules/dual_check/nickname_service.dart b/client/lib/modules/dual_check/nickname_service.dart new file mode 100644 index 0000000..6c5bfce --- /dev/null +++ b/client/lib/modules/dual_check/nickname_service.dart @@ -0,0 +1,33 @@ +// 全局昵称设置 +// 持久化到 SharedPreferences / localStorage,所有工具共享同一个昵称 +import 'package:shared_preferences/shared_preferences.dart'; + +const _keyNickname = 'toolbox.nickname'; + +class NicknameService { + NicknameService._(); + static final NicknameService instance = NicknameService._(); + + SharedPreferences? _prefs; + + Future _ensureInit() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + /// 获取当前昵称,未设置时返回默认昵称 + Future get nickname async { + await _ensureInit(); + final n = _prefs!.getString(_keyNickname); + if (n != null && n.trim().isNotEmpty) return n.trim(); + return '用户'; + } + + /// 设置昵称(1-32 字符,过短返回 false) + Future setNickname(String value) async { + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed.length > 32) return false; + await _ensureInit(); + await _prefs!.setString(_keyNickname, trimmed); + return true; + } +} diff --git a/client/lib/modules/home/home.dart b/client/lib/modules/home/home.dart index c5daefe..e4bdec6 100644 --- a/client/lib/modules/home/home.dart +++ b/client/lib/modules/home/home.dart @@ -1,4 +1,5 @@ import 'package:client/const/font.dart'; +import 'package:client/modules/dual_check/dual_check.dart'; import 'package:client/modules/home/tool_card.dart'; import 'package:client/modules/to_qrcode/to_qrcode.dart'; import 'package:flutter/material.dart'; @@ -11,7 +12,30 @@ class Home extends StatefulWidget { } class _HomeState extends State { - final toolsList = [ToQrcode()]; + // 所有工具注册 —— 新增工具只需在这里加一项 + // 数据驱动:页面 widget + 元信息(图标/名称/描述)分开 + final List<_ToolEntry> _tools = [ + // _ToolEntry( + // page: const ToQrcode(), + // meta: const ToolMeta( + // name: '二维码生成器', + // description: '上传文字、图片即可立即生成二维码', + // icon: Icons.qr_code, + // iconColor: Colors.purple, + // bgColor: Color(0xFFF3E5F5), + // ), + // ), + _ToolEntry( + page: const DualCheck(), + meta: const ToolMeta( + name: 'DualCheck 双验', + description: '两人各输入一段文字,实时比对是否一致', + icon: Icons.verified_user, + iconColor: Colors.teal, + bgColor: Color(0xFFE0F2F1), + ), + ), + ]; @override Widget build(BuildContext context) { @@ -27,10 +51,10 @@ class _HomeState extends State { ), ), centerTitle: true, - actions: [ - CircleAvatar(child: Text("登陆", style: TextStyle(fontSize: 12))), - SizedBox(width: 16), - ], + // actions: [ + // CircleAvatar(child: Text("登陆", style: TextStyle(fontSize: 12))), + // SizedBox(width: 16), + // ], ), body: GridView.builder( padding: EdgeInsets.symmetric(horizontal: 16), @@ -40,23 +64,29 @@ class _HomeState extends State { mainAxisSpacing: 8, crossAxisSpacing: 8, ), - itemCount: toolsList.length, + itemCount: _tools.length, itemBuilder: (BuildContext context, int index) { return GestureDetector( - onTap: () { - enterCard(index); - }, - child: ToolCard(), + onTap: () => _enterCard(index), + child: ToolCard(meta: _tools[index].meta), ); }, ), ); } - void enterCard(int index) { + void _enterCard(int index) { Navigator.push( context, - MaterialPageRoute(builder: ((context) => toolsList[index])), + MaterialPageRoute(builder: (_) => _tools[index].page), ); } } + +/// 内部工具注册条目 +class _ToolEntry { + final Widget page; + final ToolMeta meta; + + _ToolEntry({required this.page, required this.meta}); +} diff --git a/client/lib/modules/home/tool_card.dart b/client/lib/modules/home/tool_card.dart index 66ef67b..8590178 100644 --- a/client/lib/modules/home/tool_card.dart +++ b/client/lib/modules/home/tool_card.dart @@ -2,60 +2,70 @@ import 'package:flutter/material.dart'; import '../../const/font.dart'; -class ToolCard extends StatefulWidget { - const ToolCard({super.key}); +/// 工具元信息 —— 数据驱动首页卡片渲染 +class ToolMeta { + final String name; + final String description; + final IconData icon; + final Color iconColor; + final Color bgColor; - @override - State createState() => _ToolCardState(); + const ToolMeta({ + required this.name, + required this.description, + required this.icon, + this.iconColor = Colors.blue, + this.bgColor = const Color(0xFFF5F5F5), + }); } -class _ToolCardState extends State { +class ToolCard extends StatelessWidget { + const ToolCard({super.key, required this.meta}); + + final ToolMeta meta; + @override Widget build(BuildContext context) { return Card( child: Container( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration(), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( children: [ - Expanded( - child: Image.asset( - "assets/qr_code_logo.png", - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, + // 图标占位区 + Container( + width: 80, + height: double.infinity, + decoration: BoxDecoration( + color: meta.bgColor, + borderRadius: BorderRadius.circular(8), ), + child: Icon(meta.icon, color: meta.iconColor, size: 36), ), - SizedBox(width: 8), + SizedBox(width: 12), Expanded( - child: ClipRect( - clipBehavior: Clip.hardEdge, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "二维码生成器", - style: TextStyle( - fontSize: Font.FONT_SIZE_SUBTITLE, - fontWeight: FontWeight.bold, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + meta.name, + style: TextStyle( + fontSize: Font.FONT_SIZE_SUBTITLE, + fontWeight: FontWeight.bold, ), - SizedBox(height: 8), - Divider(), - SizedBox(height: 8), - Container( - child: Text( - "上传文字,图片即可立即生成二维码", - maxLines: 2, - style: TextStyle( - fontSize: Font.FONT_SIZE_CONTENT, - overflow: TextOverflow.ellipsis, - ), - ), + ), + SizedBox(height: 6), + Divider(height: 1), + SizedBox(height: 6), + Text( + meta.description, + maxLines: 2, + style: TextStyle( + fontSize: Font.FONT_SIZE_CONTENT, + overflow: TextOverflow.ellipsis, + color: Colors.grey.shade600, ), - ], - ), + ), + ], ), ), ], diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/client/pubspec.lock b/client/pubspec.lock index 582d01a..d46ef4e 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.13.1" boolean_selector: @@ -14,7 +14,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" characters: @@ -22,7 +22,7 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" clock: @@ -30,7 +30,7 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" collection: @@ -38,25 +38,65 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" fake_async: dependency: transitive description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -67,7 +107,7 @@ packages: description: name: flutter_lints sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" flutter_test: @@ -75,12 +115,25 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -88,7 +141,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.10" leak_tracker_testing: @@ -96,7 +149,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" lints: @@ -104,7 +157,7 @@ packages: description: name: lints sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" matcher: @@ -112,7 +165,7 @@ packages: description: name: matcher sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.12.19" material_color_utilities: @@ -120,7 +173,7 @@ packages: description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.13.0" meta: @@ -128,17 +181,121 @@ packages: description: name: meta sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" path: dependency: transitive description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -149,7 +306,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.10.2" stack_trace: @@ -157,7 +314,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" stream_channel: @@ -165,7 +322,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" string_scanner: @@ -173,7 +330,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" term_glyph: @@ -181,7 +338,7 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" test_api: @@ -189,25 +346,57 @@ packages: description: name: test_api sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.flutter-io.cn" source: hosted - version: "15.2.0" + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.44.0" diff --git a/client/pubspec.yaml b/client/pubspec.yaml index bfb8bba..1de8447 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -35,6 +35,11 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # HTTP 客户端 + WebSocket + 本地持久化 + dio: ^5.8.0 + web_socket_channel: ^2.4.5 + shared_preferences: ^2.5.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/server/.idea/.gitignore b/server/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/server/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/server/.idea/go.imports.xml b/server/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/server/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/server/.idea/modules.xml b/server/.idea/modules.xml new file mode 100644 index 0000000..fbd90b4 --- /dev/null +++ b/server/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/server/.idea/server.iml b/server/.idea/server.iml new file mode 100644 index 0000000..7c421e3 --- /dev/null +++ b/server/.idea/server.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/server/.idea/vcs.xml b/server/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/server/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go new file mode 100644 index 0000000..591c859 --- /dev/null +++ b/server/cmd/server/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "cancerai.top/toolbox-server/configs" + "cancerai.top/toolbox-server/internal/middleware" + "cancerai.top/toolbox-server/internal/pkg/websocket" + "cancerai.top/toolbox-server/internal/store" + "cancerai.top/toolbox-server/internal/tools/dual_check" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +func main() { + // === 1. 加载配置 === + cfg, err := configs.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "[FATAL] load config: %v\n", err) + os.Exit(1) + } + + // === 2. 初始化日志 === + logger, err := initLogger(cfg.Log.Level, cfg.Log.Format) + if err != nil { + fmt.Fprintf(os.Stderr, "[FATAL] init logger: %v\n", err) + os.Exit(1) + } + defer logger.Sync() + logger.Info("config loaded", + zap.String("redis.addr", cfg.Redis.Addr), + zap.String("server.addr", fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)), + ) + + // === 3. 初始化 Redis === + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.Redis.Addr, + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + PoolSize: cfg.Redis.PoolSize, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + }) + // 验证 Redis 连通性 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := rdb.Ping(ctx).Err(); err != nil { + logger.Fatal("redis connection failed", zap.Error(err)) + } + logger.Info("redis connected", zap.String("addr", cfg.Redis.Addr)) + + // === 4. 初始化共享组件 === + sessionStore := store.NewRedisSessionStore(rdb, time.Duration(cfg.Session.TTL)*time.Second) + wsHub := websocket.NewHub() + + // === 5. 初始化 Gin + 中间件 === + gin.SetMode(gin.ReleaseMode) + r := gin.New() + + r.Use(middleware.Recover(logger)) + r.Use(middleware.RequestID()) + r.Use(middleware.Logger(logger)) + r.Use(middleware.CORS(cfg.CORS.AllowedOrigins)) + r.Use(middleware.RateLimiter(middleware.RateLimitConfig{ + GlobalRPS: cfg.RateLimit.GlobalRPS, + PerIPRPS: cfg.RateLimit.PerIPRPS, + PerIPBurst: cfg.RateLimit.PerIPBurst, + })) + + // === 6. 健康检查 === + r.GET("/healthz", func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + if err := rdb.Ping(ctx).Err(); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "unhealthy", + "redis": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "time": time.Now().Format(time.RFC3339), + }) + }) + + // === 7. 注册工具路由 === + v1 := r.Group("/api/v1") + { + dual_check.Register( + v1.Group("/dual-check"), + sessionStore, + wsHub, + logger, + cfg.Session.TTL, + cfg.Session.MaxTextLength, + ) + // 未来新增工具示例: + // to_qrcode.Register(v1.Group("/to-qrcode"), ...) + } + + // === 8. 启动 HTTP Server === + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + IdleTimeout: cfg.Server.IdleTimeout, + } + + go func() { + logger.Info("server listening", zap.String("addr", addr)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal("listen failed", zap.Error(err)) + } + }() + + // === 9. 优雅关停 === + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + logger.Info("received shutdown signal", zap.String("signal", sig.String())) + + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + logger.Error("http server shutdown error", zap.Error(err)) + } + + // 关闭 Redis 连接 + if err := rdb.Close(); err != nil { + logger.Error("redis close error", zap.Error(err)) + } + + logger.Info("server exited gracefully") +} + +func initLogger(level, format string) (*zap.Logger, error) { + var cfg zap.Config + switch format { + case "console": + cfg = zap.NewDevelopmentConfig() + default: + cfg = zap.NewProductionConfig() + } + + switch level { + case "debug": + cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + case "warn": + cfg.Level = zap.NewAtomicLevelAt(zap.WarnLevel) + case "error": + cfg.Level = zap.NewAtomicLevelAt(zap.ErrorLevel) + default: + cfg.Level = zap.NewAtomicLevelAt(zap.InfoLevel) + } + + return cfg.Build() +} diff --git a/server/configs/config.go b/server/configs/config.go new file mode 100644 index 0000000..700a7f6 --- /dev/null +++ b/server/configs/config.go @@ -0,0 +1,106 @@ +package configs + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Redis RedisConfig `mapstructure:"redis"` + RateLimit RateLimitConfig `mapstructure:"rate_limit"` + Session SessionConfig `mapstructure:"session"` + Log LogConfig `mapstructure:"log"` + CORS CORSConfig `mapstructure:"cors"` +} + +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + ReadTimeout time.Duration `mapstructure:"read_timeout"` + WriteTimeout time.Duration `mapstructure:"write_timeout"` + IdleTimeout time.Duration `mapstructure:"idle_timeout"` +} + +type RedisConfig struct { + Addr string `mapstructure:"addr"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` + PoolSize int `mapstructure:"pool_size"` +} + +type RateLimitConfig struct { + GlobalRPS float64 `mapstructure:"global_rps"` + PerIPRPS float64 `mapstructure:"per_ip_rps"` + PerIPBurst int `mapstructure:"burst"` +} + +type SessionConfig struct { + TTL int `mapstructure:"ttl"` + MaxTextLength int `mapstructure:"max_text_length"` +} + +type LogConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` +} + +type CORSConfig struct { + AllowedOrigins []string `mapstructure:"allowed_origins"` +} + +func Load(configPath ...string) (*Config, error) { + v := viper.New() + + if len(configPath) > 0 && configPath[0] != "" { + v.SetConfigFile(configPath[0]) + } else { + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath("configs/") + v.AddConfigPath("./configs/") + v.AddConfigPath(".") + } + + v.SetDefault("server.host", "0.0.0.0") + v.SetDefault("server.port", 8080) + v.SetDefault("server.read_timeout", "15s") + v.SetDefault("server.write_timeout", "15s") + v.SetDefault("server.idle_timeout", "60s") + v.SetDefault("redis.addr", "localhost:6379") + v.SetDefault("redis.db", 0) + v.SetDefault("redis.pool_size", 100) + 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.max_text_length", 10000) + v.SetDefault("log.level", "info") + v.SetDefault("log.format", "json") + + // 环境变量覆盖(前缀 DUALCHECK_) + // mapstructure 的 "." 和 "_" 都需要替换成 "_",并转大写 + v.SetEnvPrefix("DUALCHECK") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AutomaticEnv() + + // 尝试读取配置文件(文件不存在不报错,用默认值+环境变量) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + fmt.Fprintf(os.Stderr, "[config] warning: config file error: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "[config] config file not found, using defaults + env vars\n") + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + return &cfg, nil +} diff --git a/server/configs/config.yaml b/server/configs/config.yaml new file mode 100644 index 0000000..0b8f94b --- /dev/null +++ b/server/configs/config.yaml @@ -0,0 +1,35 @@ +# === DualCheck Server Config === +# 生产环境敏感配置请用环境变量覆盖,不要把真实值提交到 git +# 环境变量前缀:DUALCHECK_(如 DUALCHECK_REDIS_ADDR) + +server: + host: "0.0.0.0" + port: 8080 + read_timeout: 15s + write_timeout: 15s + idle_timeout: 60s + +redis: + addr: "192.168.64.7:6379" + password: "123456" + db: 0 + pool_size: 100 + +rate_limit: + global_rps: 1000 + per_ip_rps: 50 + burst: 100 + +session: + ttl: 900 # 会话过期时间(秒),默认 15 分钟 + max_text_length: 10000 # 单条文本最大长度 + +log: + level: "info" # debug / info / warn / error + format: "json" # json / console + +cors: + allowed_origins: + - "http://localhost:3000" + - "http://localhost:8080" + - "http://localhost:5173" diff --git a/server/go.mod b/server/go.mod index 5afa648..fc6683b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,3 +1,54 @@ module cancerai.top/toolbox-server go 1.26.5 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.22.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/ulule/limiter/v3 v3.11.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..dbfaa7d --- /dev/null +++ b/server/go.sum @@ -0,0 +1,114 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA= +github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/middleware/cors.go b/server/internal/middleware/cors.go new file mode 100644 index 0000000..140ed1a --- /dev/null +++ b/server/internal/middleware/cors.go @@ -0,0 +1,60 @@ +// Package middleware 提供 HTTP 中间件 +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件 +// - 白名单 origin 精确匹配(生产用) +// - 同时自动放行 http(s)://localhost:* 所有端口(开发期 Flutter Web/React Vite 随机端口) +// - 不在范围内的请求不设置 Allow-Origin 头,浏览器会自行阻止 +func CORS(allowedOrigins []string) gin.HandlerFunc { + originSet := make(map[string]bool, len(allowedOrigins)) + for _, o := range allowedOrigins { + originSet[strings.TrimRight(o, "/")] = true + } + + return func(c *gin.Context) { + origin := strings.TrimRight(c.GetHeader("Origin"), "/") + if origin == "" { + c.Next() + return + } + + // 是否允许:白名单精确匹配 或 localhost 通配 + allowed := originSet[origin] || isLocalhostOrigin(origin) + + if c.Request.Method == http.MethodOptions { + if allowed { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") + c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID") + c.Writer.Header().Set("Access-Control-Max-Age", "86400") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + } + c.AbortWithStatus(http.StatusNoContent) + return + } + + if allowed { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Vary", "Origin") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + } + + c.Next() + } +} + +// isLocalhostOrigin 匹配 http://localhost:* 和 http://127.0.0.1:* +// 开发期 Flutter Web / Vite / CRA 等工具常用随机端口 +func isLocalhostOrigin(origin string) bool { + return strings.HasPrefix(origin, "http://localhost:") || + strings.HasPrefix(origin, "http://127.0.0.1:") || + strings.HasPrefix(origin, "http://[::1]:") +} diff --git a/server/internal/middleware/logger.go b/server/internal/middleware/logger.go new file mode 100644 index 0000000..4e1c8fe --- /dev/null +++ b/server/internal/middleware/logger.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Logger 基于 zap 的请求日志中间件 +// 只记录 method/path/status/latency/request_id,**不记录请求体**(避免泄露用户输入) +func Logger(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + latency := time.Since(start) + + logger.Info("HTTP", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.Int("size", c.Writer.Size()), + zap.String("remote_ip", c.ClientIP()), + zap.String("user_agent", c.Request.UserAgent()), + zap.String("request_id", c.GetString("request_id")), + zap.Duration("latency", latency), + ) + } +} diff --git a/server/internal/middleware/ratelimit.go b/server/internal/middleware/ratelimit.go new file mode 100644 index 0000000..52531c1 --- /dev/null +++ b/server/internal/middleware/ratelimit.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/ulule/limiter/v3" + "github.com/ulule/limiter/v3/drivers/store/memory" +) + +// RateLimitConfig 限流配置 +type RateLimitConfig struct { + GlobalRPS float64 + PerIPRPS float64 + PerIPBurst int +} + +// RateLimiter 双维度限流:全局令牌桶 + 单 IP 令牌桶 +// 开发/测试环境可用内存存储,生产环境建议换 Redis 存储 +func RateLimiter(cfg RateLimitConfig) gin.HandlerFunc { + store := memory.NewStore() + + // 全局限流 + globalRate := limiter.Rate{ + Period: time.Second, + Limit: int64(cfg.GlobalRPS), + } + globalLimiter := limiter.New(store, globalRate) + + // 单 IP 限流 + perIPRate := limiter.Rate{ + Period: time.Second, + Limit: int64(cfg.PerIPRPS), + } + perIPLimiter := limiter.New(store, perIPRate) + + return func(c *gin.Context) { + // 1. 检查全局限流 + globalCtx, err := globalLimiter.Get(c.Request.Context(), "global") + if err != nil || globalCtx.Reached { + c.JSON(http.StatusTooManyRequests, gin.H{ + "code": 4290, + "message": "server busy, please retry later", + }) + c.Abort() + return + } + + // 2. 检查单 IP 限流 + ip := c.ClientIP() + ipCtx, err := perIPLimiter.Get(c.Request.Context(), "ip:"+ip) + if err != nil || ipCtx.Reached { + c.JSON(http.StatusTooManyRequests, gin.H{ + "code": 4291, + "message": "too many requests from this IP", + }) + c.Abort() + return + } + + c.Next() + } +} diff --git a/server/internal/middleware/recover.go b/server/internal/middleware/recover.go new file mode 100644 index 0000000..0241c1c --- /dev/null +++ b/server/internal/middleware/recover.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Recover 捕获 panic 并返回 500 +func Recover(logger *zap.Logger) gin.HandlerFunc { + return gin.CustomRecovery(func(c *gin.Context, err interface{}) { + logger.Error("panic recovered", + zap.Any("error", err), + zap.String("path", c.Request.URL.Path), + zap.String("request_id", c.GetString("request_id")), + ) + c.JSON(500, gin.H{ + "code": 5000, + "message": "internal server error", + }) + c.Abort() + }) +} diff --git a/server/internal/middleware/request_id.go b/server/internal/middleware/request_id.go new file mode 100644 index 0000000..c953404 --- /dev/null +++ b/server/internal/middleware/request_id.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/gin-gonic/gin" +) + +// RequestID 为每个请求生成唯一 ID,放入 context 和响应头 +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + // 优先使用客户端传来的 X-Request-ID + id := c.GetHeader("X-Request-ID") + if id == "" { + b := make([]byte, 12) + if _, err := rand.Read(b); err == nil { + id = hex.EncodeToString(b) + } else { + id = "unknown" + } + } + c.Set("request_id", id) + c.Header("X-Request-ID", id) + c.Next() + } +} diff --git a/server/internal/pkg/hash/hash.go b/server/internal/pkg/hash/hash.go new file mode 100644 index 0000000..40aa277 --- /dev/null +++ b/server/internal/pkg/hash/hash.go @@ -0,0 +1,16 @@ +// Package hash 提供 SHA-256 哈希工具 +package hash + +import ( + "crypto/sha256" + "encoding/hex" +) + +// SHA256 返回输入字符串的 SHA-256 十六进制哈希 +// 注意:故意不加盐(bcrypt 会自动加盐但输出包含盐值), +// 因为我们的场景是"判断两段明文是否相同", +// 只要对两端的明文做同样的 hash 即可比较,无需加盐。 +func SHA256(input string) string { + h := sha256.Sum256([]byte(input)) + return hex.EncodeToString(h[:]) +} diff --git a/server/internal/pkg/sessionid/generator.go b/server/internal/pkg/sessionid/generator.go new file mode 100644 index 0000000..a61af6a --- /dev/null +++ b/server/internal/pkg/sessionid/generator.go @@ -0,0 +1,18 @@ +// Package sessionid 提供高熵 session ID 生成器 +package sessionid + +import ( + "crypto/rand" + "encoding/base64" +) + +// Generate 生成 128-bit 随机 session ID +// 输出为 base64url (无 padding) 编码,共 22 字符 +// 熵值 2^128 ≈ 3.4e38,暴力枚举不可行 +func Generate() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/server/internal/pkg/websocket/hub.go b/server/internal/pkg/websocket/hub.go new file mode 100644 index 0000000..f79d615 --- /dev/null +++ b/server/internal/pkg/websocket/hub.go @@ -0,0 +1,136 @@ +// Package websocket 提供通用的 WebSocket Hub,支持按 session_id 分房间广播 +package websocket + +import ( + "encoding/json" + "log" + "sync" + + "github.com/gorilla/websocket" +) + +// Hub 管理所有 WebSocket 连接和房间 +// 每个 session_id 对应一个房间,房间内的连接互相广播消息 +type Hub struct { + mu sync.RWMutex + rooms map[string]map[*Conn]struct{} // session_id → 连接集合 + register chan *Conn + unregister chan *Conn +} + +// Conn 封装一条 WebSocket 连接 +type Conn struct { + Hub *Hub + SessionID string + Role string // "A" 或 "B",可选 + RoleMu sync.Mutex + WS *websocket.Conn + Send chan []byte + closeOnce sync.Once +} + +// NewHub 创建一个 Hub 并启动后台处理协程 +func NewHub() *Hub { + h := &Hub{ + rooms: make(map[string]map[*Conn]struct{}), + register: make(chan *Conn), + unregister: make(chan *Conn), + } + go h.run() + return h +} + +func (h *Hub) run() { + for { + select { + case conn := <-h.register: + h.mu.Lock() + if h.rooms[conn.SessionID] == nil { + h.rooms[conn.SessionID] = make(map[*Conn]struct{}) + } + h.rooms[conn.SessionID][conn] = struct{}{} + h.mu.Unlock() + + case conn := <-h.unregister: + h.mu.Lock() + if room, ok := h.rooms[conn.SessionID]; ok { + delete(room, conn) + if len(room) == 0 { + delete(h.rooms, conn.SessionID) + } + } + // 用 sync.Once 保证 Send channel 只被 close 一次 + conn.closeOnce.Do(func() { close(conn.Send) }) + h.mu.Unlock() + } + } +} + +// Register 将连接加入指定 session 的房间 +func (h *Hub) Register(conn *Conn) { + h.register <- conn +} + +// Unregister 将连接从房间移除(幂等,可重复调用) +func (h *Hub) Unregister(conn *Conn) { + h.unregister <- conn +} + +// Broadcast 向指定 session 房间内的所有连接发送消息 +// msg 会被序列化为 JSON +func (h *Hub) Broadcast(sessionID string, msg interface{}) { + data, err := json.Marshal(msg) + if err != nil { + log.Printf("[ws] marshal broadcast msg error: %v", err) + return + } + h.mu.RLock() + defer h.mu.RUnlock() + if room, ok := h.rooms[sessionID]; ok { + for conn := range room { + select { + case conn.Send <- data: + default: + // send channel 满了,丢弃,避免阻塞 + log.Printf("[ws] send channel full for session %s, dropping", sessionID) + } + } + } +} + +// RoomSize 返回指定 session 房间内的连接数(调试用) +func (h *Hub) RoomSize(sessionID string) int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.rooms[sessionID]) +} + +// WritePump 从 Send channel 读消息并写入 WebSocket +// 在独立 goroutine 中运行,直到连接关闭 +func WritePump(c *Conn) { + defer func() { + c.Hub.Unregister(c) + c.WS.Close() + }() + for msg := range c.Send { + if err := c.WS.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } +} + +// ReadPump 从 WebSocket 读消息并丢弃 +// 生产环境如果需要客户端主动发消息可以在这里处理 +// 当前只负责检测断连 +func ReadPump(c *Conn) { + defer func() { + c.Hub.Unregister(c) + c.WS.Close() + }() + c.WS.SetPongHandler(func(string) error { return nil }) + for { + if _, _, err := c.WS.ReadMessage(); err != nil { + return + } + } +} diff --git a/server/internal/response/response.go b/server/internal/response/response.go new file mode 100644 index 0000000..201d676 --- /dev/null +++ b/server/internal/response/response.go @@ -0,0 +1,120 @@ +// Package response 提供统一的 JSON 响应封装 +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 是所有 API 的统一响应结构 +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// Success 返回成功响应 +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: "ok", + Data: data, + }) +} + +// Fail 返回业务错误(HTTP 200 + 非零业务码) +func Fail(c *gin.Context, code int, message string) { + c.JSON(http.StatusOK, Response{ + Code: code, + Message: message, + }) +} + +// BadRequest 返回参数错误 +func BadRequest(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, Response{ + Code: 4000, + Message: message, + }) +} + +// NotFound 返回资源不存在 +func NotFound(c *gin.Context, message string) { + c.JSON(http.StatusNotFound, Response{ + Code: 4040, + Message: message, + }) +} + +// InternalError 返回服务端内部错误 +func InternalError(c *gin.Context, message string) { + c.JSON(http.StatusInternalServerError, Response{ + Code: 5000, + Message: message, + }) +} + +// WebSocketMessage 是 WebSocket 推送的统一消息格式 +type WebSocketMessage struct { + Type string `json:"type"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// ── WS 事件快捷构造器 ── + +// WSFullState 全量快照推送(发起方 submit 后广播给所有人) +func WSFullState(sess interface{}) WebSocketMessage { + return WebSocketMessage{Type: "full_state", Data: sess} +} + +// WSParticipantJoined 新参与者加入 +func WSParticipantJoined(nickname string) WebSocketMessage { + return WebSocketMessage{ + Type: "participant_joined", + Data: map[string]string{"nickname": nickname}, + } +} + +// WSParticipantLeft 参与者离开 +func WSParticipantLeft(nickname string) WebSocketMessage { + return WebSocketMessage{ + Type: "participant_left", + Data: map[string]string{"nickname": nickname}, + } +} + +// WSParticipantSubmitted 参与者提交文本 +func WSParticipantSubmitted(nickname string, resp interface{}) WebSocketMessage { + return WebSocketMessage{ + Type: "participant_submitted", + Data: map[string]interface{}{ + "nickname": nickname, + "submit": resp, + }, + } +} + +// WSInitiatorReady 发起方刚提交(所有参与者都会收到) +func WSInitiatorReady() WebSocketMessage { + return WebSocketMessage{Type: "initiator_ready"} +} + +// WSSessionExpired 会话过期 +func WSSessionExpired() WebSocketMessage { + return WebSocketMessage{Type: "session_expired"} +} + +// WSSessionEnded 会话被发起方主动结束 +func WSSessionEnded() WebSocketMessage { + return WebSocketMessage{Type: "session_ended"} +} + +// WSResubmitChanged 发起方切换了"允许参与者重新提交"开关 +func WSResubmitChanged(allow bool) WebSocketMessage { + return WebSocketMessage{ + Type: "resubmit_changed", + Data: map[string]bool{"allow": allow}, + } +} diff --git a/server/internal/store/redis_session_store.go b/server/internal/store/redis_session_store.go new file mode 100644 index 0000000..314f454 --- /dev/null +++ b/server/internal/store/redis_session_store.go @@ -0,0 +1,318 @@ +package store + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// 包级错误,业务层可以 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") +) + +const ( + redisKeyPrefix = "dualcheck:session:" + fieldCreatedAt = "created_at" + fieldInitHash = "initiator_hash" + fieldInitNick = "initiator_nickname" + fieldAllowResub = "allow_resubmit" + // participant:{nick} 存该参与者的哈希值;participant:{nick}:match 存 "1"/"0" + partFieldPrefix = "participant:" +) + +// ── 五个 Lua 脚本 ── + +// createLuaScript 原子创建 Hash 类型的会话 key +var createLuaScript = redis.NewScript(` +local key = KEYS[1] +if redis.call("EXISTS", key) ~= 0 then + return 0 +end +redis.call("HSET", key, "created_at", ARGV[1]) +redis.call("HSET", key, "allow_resubmit", "0") +redis.call("EXPIRE", key, ARGV[2]) +return 1 +`) + +// joinParticipantLuaScript 登记参与者昵称(空哈希占位,表示已加入未提交) +// KEYS[1] = session key +// ARGV[1] = nickname +var joinParticipantLuaScript = redis.NewScript(` +local key = KEYS[1] +local nick = ARGV[1] +local exists = redis.call("EXISTS", key) +if exists == 0 then + return redis.error_reply("SESSION_NOT_FOUND") +end +local field = "participant:" .. nick +-- 昵称已存在视为已加入(幂等),直接返回 1 +if redis.call("HEXISTS", key, field) == 1 then + return 1 +end +redis.call("HSET", key, field, "") +return 1 +`) + +// submitParticipantLuaScript 参与者提交自己的哈希 +// 原子完成:检查 → 写入 → 与 initiator_hash 比对(若发起方已提交) +// 若已提交但 allow_resubmit=1 则覆盖(重算 match) +// KEYS[1] = session key +// ARGV[1] = nickname +// ARGV[2] = hash hex +// 返回 {initiatorSubmitted (0/1), match ("1"/"0"/"")} +var submitParticipantLuaScript = redis.NewScript(` +local key = KEYS[1] +local nick = ARGV[1] +local hash_val = ARGV[2] + +if redis.call("EXISTS", key) == 0 then + return redis.error_reply("SESSION_NOT_FOUND") +end + +local field = "participant:" .. nick +if redis.call("HEXISTS", key, field) == 0 then + return redis.error_reply("NICKNAME_NOT_JOINED") +end +local existing = redis.call("HGET", key, field) +if existing ~= "" then + -- 已提交过:看是否允许重提交 + local allow = redis.call("HGET", key, "allow_resubmit") + if allow ~= "1" then + return redis.error_reply("DUPLICATE_SUBMIT") + end +end + +-- 写入(覆盖)哈希 +redis.call("HSET", key, field, hash_val) +-- 清除旧的 match 字段,稍后按需重算 +redis.call("HDEL", key, field .. ":match") + +local init_hash = redis.call("HGET", key, "initiator_hash") +if init_hash and init_hash ~= "" then + local match = (hash_val == init_hash) and "1" or "0" + redis.call("HSET", key, field .. ":match", match) + return {1, match} +else + return {0, ""} +end +`) + +// submitInitiatorLuaScript 发起方提交标准答案 +// 写入 initiator_hash 后,遍历所有已提交的 participant,批量计算 match +// KEYS[1] = session key +// ARGV[1] = nickname +// ARGV[2] = initiator hash +var submitInitiatorLuaScript = redis.NewScript(` +local key = KEYS[1] +local nick = ARGV[1] +local init_hash = ARGV[2] + +if redis.call("EXISTS", key) == 0 then + return redis.error_reply("SESSION_NOT_FOUND") +end + +-- 发起方只允许提交一次(幂等:已提交过则跳过) +if redis.call("HEXISTS", key, "initiator_hash") == 1 then + return 1 +end + +redis.call("HSET", key, "initiator_hash", init_hash) +redis.call("HSET", key, "initiator_nickname", nick) + +-- 遍历所有 participant:{nick} 且哈希非空的,计算 match +local all = redis.call("HGETALL", key) +for i = 1, #all, 2 do + local field = all[i] + local val = all[i + 1] + if string.sub(field, 1, #("participant:")) == "participant:" + and not string.find(field, ":match$") + and val ~= "" then + local match = (val == init_hash) and "1" or "0" + redis.call("HSET", key, field .. ":match", match) + end +end +return 1 +`) + +// ── RedisSessionStore 实现 ── + +type RedisSessionStore struct { + rdb *redis.Client + ttl time.Duration +} + +func NewRedisSessionStore(rdb *redis.Client, defaultTTL time.Duration) *RedisSessionStore { + return &RedisSessionStore{rdb: rdb, ttl: defaultTTL} +} + +func key(sessionID string) string { + return redisKeyPrefix + sessionID +} + +// Create 原子创建会话 Hash key +func (s *RedisSessionStore) Create(ctx context.Context, sessionID 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() + if err != nil { + return fmt.Errorf("redis create session: %w", err) + } + if result == 0 { + return fmt.Errorf("session id collision: %s", sessionID) + } + return nil +} + +// JoinParticipant 登记参与者(空哈希占位) +func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname string) error { + _, err := joinParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname).Int64() + if err != nil { + msg := err.Error() + if strings.Contains(msg, "SESSION_NOT_FOUND") { + return ErrSessionNotFound + } + return fmt.Errorf("redis join participant: %w", err) + } + 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() + if err != nil { + msg := err.Error() + switch { + case strings.Contains(msg, "SESSION_NOT_FOUND"): + return false, nil, ErrSessionNotFound + case strings.Contains(msg, "DUPLICATE_SUBMIT"): + return false, nil, ErrDuplicateSubmit + case strings.Contains(msg, "NICKNAME_NOT_JOINED"): + // 参与者没走 join 直接 submit,自动补登记 + if err := s.JoinParticipant(ctx, sessionID, nickname); err != nil { + return false, nil, err + } + return s.SubmitParticipant(ctx, sessionID, nickname, hashHex) + default: + return false, nil, fmt.Errorf("redis submit participant: %w", err) + } + } + + values := result.([]interface{}) + initSubmitted := values[0].(int64) == 1 + var match *bool + if initSubmitted { + b := values[1].(string) == "1" + match = &b + } + 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() + if err != nil { + if strings.Contains(err.Error(), "SESSION_NOT_FOUND") { + return ErrSessionNotFound + } + return fmt.Errorf("redis submit initiator: %w", err) + } + return nil +} + +// SetResubmit 设置会话是否允许参与者重复提交 +func (s *RedisSessionStore) SetResubmit(ctx context.Context, sessionID string, allow 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 allow { + val = "1" + } + return s.rdb.HSet(ctx, key, fieldAllowResub, val).Err() +} + +// Get 获取会话当前状态全量快照 +func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*SessionSnapshot, error) { + exists, err := s.rdb.Exists(ctx, key(sessionID)).Result() + if err != nil { + return nil, fmt.Errorf("redis exists: %w", err) + } + if exists == 0 { + return nil, ErrSessionNotFound + } + + hash, err := s.rdb.HGetAll(ctx, key(sessionID)).Result() + if err != nil { + return nil, fmt.Errorf("redis hgetall: %w", err) + } + + ttl, err := s.rdb.TTL(ctx, key(sessionID)).Result() + if err != nil { + return nil, fmt.Errorf("redis ttl: %w", err) + } + + snap := &SessionSnapshot{ + SessionID: sessionID, + InitiatorHash: hash[fieldInitHash], + InitiatorNickname: hash[fieldInitNick], + InitiatorSubmitted: hash[fieldInitHash] != "", + AllowResubmit: hash[fieldAllowResub] == "1", + ExpiresAt: time.Now().Add(ttl), + } + + // 收集所有 participant 条目,按 nickname 排序 + var nicks []string + partMap := map[string]string{} // nick → hash + for k, v := range hash { + if strings.HasPrefix(k, partFieldPrefix) && !strings.HasSuffix(k, ":match") { + nick := strings.TrimPrefix(k, partFieldPrefix) + nicks = append(nicks, nick) + partMap[nick] = v + } + } + sort.Strings(nicks) + + initiatorHash := hash[fieldInitHash] + for _, nick := range nicks { + h := partMap[nick] + p := ParticipantInfo{ + Nickname: nick, + Submitted: h != "", + } + if h != "" && initiatorHash != "" { + m := h == initiatorHash + p.Match = &m + } else if mStr, ok := hash[partFieldPrefix+nick+":match"]; ok && mStr != "" { + m := mStr == "1" + p.Match = &m + } + snap.Participants = append(snap.Participants, p) + } + + return snap, nil +} + +// Delete 删除会话 +func (s *RedisSessionStore) Delete(ctx context.Context, sessionID string) error { + if err := s.rdb.Del(ctx, key(sessionID)).Err(); err != nil { + return fmt.Errorf("redis del: %w", err) + } + return nil +} diff --git a/server/internal/store/session_store.go b/server/internal/store/session_store.go new file mode 100644 index 0000000..5e93e90 --- /dev/null +++ b/server/internal/store/session_store.go @@ -0,0 +1,61 @@ +// Package store 提供会话存储接口 +// 接口解耦使得可以在测试中用 mock 替换,生产用 Redis +package store + +import ( + "context" + "time" +) + +// Session 存储的会话数据结构(不含敏感字段) +type Session struct { + ID string `json:"session_id"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// ParticipantInfo 参与者在全量快照中的状态 +type ParticipantInfo struct { + Nickname string `json:"nickname"` + Submitted bool `json:"submitted"` + Match *bool `json:"match,omitempty"` +} + +// SessionSnapshot Get 返回的全量快照 +type SessionSnapshot struct { + SessionID string `json:"session_id"` + InitiatorHash string `json:"-"` // 发起方标准答案哈希,不对外暴露 + InitiatorNickname string `json:"initiator_nickname"` + InitiatorSubmitted bool `json:"initiator_submitted"` + AllowResubmit bool `json:"allow_resubmit"` + 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 + + // SubmitInitiator 发起方提交标准答案(initiator_hash + initiator_nickname) + // 同时立即为所有已提交的参与者计算 match + SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error + + // SubmitParticipant 参与者提交自己的文本(按 nickname 唯一标识) + // 返回:initiatorSubmitted(发起方是否已提交,是则 match 已算出)、match + SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) ( + initiatorSubmitted bool, match *bool, err error) + + // JoinParticipant 参与者加入会话(只登记昵称,不提交文本) + // 用于 WS 连接时实时广播 joined 事件 + JoinParticipant(ctx context.Context, sessionID, nickname string) error + + // SetResubmit 设置是否允许参与者重复提交 + SetResubmit(ctx context.Context, sessionID string, allow bool) error + + // Get 获取会话当前状态全量快照 + Get(ctx context.Context, sessionID string) (*SessionSnapshot, error) + + // Delete 删除会话 + Delete(ctx context.Context, sessionID string) error +} diff --git a/server/internal/tools/dual_check/business_error.go b/server/internal/tools/dual_check/business_error.go new file mode 100644 index 0000000..a630526 --- /dev/null +++ b/server/internal/tools/dual_check/business_error.go @@ -0,0 +1,15 @@ +package dual_check + +// BusinessError 业务错误 +type BusinessError struct { + Code int + Message string +} + +// Error 返回错误描述:优先用自定义 Message,否则回退到 ErrMessage(code) +func (e *BusinessError) Error() string { + if e.Message != "" { + return e.Message + } + return ErrMessage(e.Code) +} diff --git a/server/internal/tools/dual_check/errors.go b/server/internal/tools/dual_check/errors.go new file mode 100644 index 0000000..4fbadc2 --- /dev/null +++ b/server/internal/tools/dual_check/errors.go @@ -0,0 +1,31 @@ +package dual_check + +// 业务错误码 +const ( + ErrCodeInvalidParam = 4000 + ErrCodeSessionNotFound = 1001 + ErrCodeSessionExpired = 1002 + ErrCodeDuplicateSubmit = 1003 + ErrCodeInvalidRole = 1004 + ErrCodeSessionCreate = 1005 + ErrCodeNicknameExists = 1006 // 昵称已被其他人占用(WS 连接时发现) + ErrCodeNotInSession = 1007 // 该昵称未 join 会话就 submit +) + +var errorMessages = map[int]string{ + ErrCodeInvalidParam: "invalid parameter", + ErrCodeSessionNotFound: "会话码不存在或已过期", + ErrCodeSessionExpired: "session has expired", + ErrCodeDuplicateSubmit: "该昵称已提交过文本", + ErrCodeInvalidRole: "role must be 'initiator' or 'participant'", + ErrCodeSessionCreate: "failed to create session, please retry", + ErrCodeNicknameExists: "该昵称已在会话中,请换一个", + ErrCodeNotInSession: "该昵称未加入会话", +} + +func ErrMessage(code int) string { + if msg, ok := errorMessages[code]; ok { + return msg + } + return "unknown error" +} diff --git a/server/internal/tools/dual_check/handler.go b/server/internal/tools/dual_check/handler.go new file mode 100644 index 0000000..a8b0b85 --- /dev/null +++ b/server/internal/tools/dual_check/handler.go @@ -0,0 +1,220 @@ +package dual_check + +import ( + "errors" + "net/http" + "unicode/utf8" + + pkgws "cancerai.top/toolbox-server/internal/pkg/websocket" + "cancerai.top/toolbox-server/internal/response" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "go.uber.org/zap" +) + +type Handler struct { + service *Service + hub *pkgws.Hub + logger *zap.Logger + maxLen int + validate *validator.Validate +} + +func NewHandler(service *Service, hub *pkgws.Hub, logger *zap.Logger, maxTextLen int) *Handler { + return &Handler{ + service: service, + hub: hub, + logger: logger, + maxLen: maxTextLen, + validate: validator.New(), + } +} + +// Create POST /api/v1/dual-check/create +func (h *Handler) Create(c *gin.Context) { + var req CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) + return + } + resp, err := h.service.CreateSession(c.Request.Context(), req.Nickname) + if err != nil { + var be *BusinessError + if errors.As(err, &be) { + response.Fail(c, be.Code, be.Error()) + return + } + h.logger.Error("create session error", zap.Error(err)) + response.InternalError(c, "create failed") + return + } + response.Success(c, resp) +} + +// Submit POST /api/v1/dual-check/submit +func (h *Handler) Submit(c *gin.Context) { + // ContentLength 保护:用 maxLen*4 覆盖 UTF-8 最大 4 字节/字符 + JSON 字段开销 + if c.Request.ContentLength > int64(h.maxLen*4+2048) { + response.BadRequest(c, "text too long") + return + } + var req SubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, ErrMessage(ErrCodeInvalidParam)) + return + } + if utf8.RuneCountInString(req.Text) > h.maxLen { + response.BadRequest(c, "text too long") + return + } + + resp, err := h.service.Submit(c.Request.Context(), &req) + if err != nil { + var be *BusinessError + if errors.As(err, &be) { + response.Fail(c, be.Code, be.Error()) + return + } + h.logger.Error("submit error", zap.Error(err), + zap.String("session_id", truncateSessionID(req.SessionID))) + response.InternalError(c, "submit failed") + 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 — 参与者加入(登记昵称) +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") + return + } + + sess, err := h.service.JoinParticipant(c.Request.Context(), sessionID, req.Nickname) + if err != nil { + var be *BusinessError + if errors.As(err, &be) { + response.Fail(c, be.Code, be.Error()) + return + } + h.logger.Error("join participant error", zap.Error(err)) + response.InternalError(c, "join failed") + return + } + + // 广播 participant_joined 事件 + h.hub.Broadcast(sessionID, response.WSParticipantJoined(req.Nickname)) + + response.Success(c, sess) +} + +// GetSession GET /api/v1/dual-check/session/:session_id +func (h *Handler) GetSession(c *gin.Context) { + sessionID := c.Param("session_id") + sess, err := h.service.GetSession(c.Request.Context(), sessionID) + if err != nil { + var be *BusinessError + if errors.As(err, &be) && be.Code == ErrCodeSessionNotFound { + response.NotFound(c, be.Error()) + return + } + response.InternalError(c, "get session failed") + return + } + response.Success(c, sess) +} + +// CheckSession GET /api/v1/dual-check/check/:session_id +// 前端加入前预校验 +func (h *Handler) CheckSession(c *gin.Context) { + sessionID := c.Param("session_id") + info, err := h.service.CheckSession(c.Request.Context(), sessionID) + if err != nil { + response.InternalError(c, "check session failed") + return + } + if !info.Exists { + response.Fail(c, ErrCodeSessionNotFound, ErrMessage(ErrCodeSessionNotFound)) + return + } + response.Success(c, info) +} + +// SetResubmit POST /api/v1/dual-check/session/:session_id/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) + if err != nil { + var be *BusinessError + if errors.As(err, &be) { + response.Fail(c, be.Code, be.Error()) + return + } + h.logger.Error("set resubmit error", zap.Error(err)) + response.InternalError(c, "set resubmit failed") + return + } + + // 广播开关变化给所有参与者 + h.hub.Broadcast(sessionID, response.WSResubmitChanged(req.Allow)) + + response.Success(c, sess) +} + +// EndSession POST /api/v1/dual-check/session/:session_id/end +// 发起方主动结束会话:先广播 session_ended,再 Delete Redis key +func (h *Handler) EndSession(c *gin.Context) { + sessionID := c.Param("session_id") + if sessionID == "" { + response.BadRequest(c, "session_id is required") + return + } + + // 先广播,让所有参与方 UI 都弹提示 + h.hub.Broadcast(sessionID, response.WSSessionEnded()) + + // 再删除数据 + if err := h.service.EndSession(c.Request.Context(), sessionID); err != nil { + h.logger.Error("end session error", zap.Error(err)) + response.InternalError(c, "end session failed") + return + } + + response.Success(c, gin.H{"ended": true}) +} + +// 占位,避免 http.StatusOK 未使用 +var _ = http.StatusOK diff --git a/server/internal/tools/dual_check/model.go b/server/internal/tools/dual_check/model.go new file mode 100644 index 0000000..364225b --- /dev/null +++ b/server/internal/tools/dual_check/model.go @@ -0,0 +1,86 @@ +// Package dual_check 双验工具后端实现 +// 一个发起方(initiator)+ 多个参与方(participant) +// 发起方提交文本作为"标准答案",每个参与方各自提交文本后与发起方比对 +package dual_check + +import "time" + +// ── 创建会话 ── + +type CreateRequest struct { + Nickname string `json:"nickname" binding:"required,min=1,max=32"` +} + +// CreateResponse 创建成功 +type CreateResponse struct { + SessionID string `json:"session_id"` + Nickname string `json:"nickname"` + ExpiresAt time.Time `json:"expires_at"` + TTL int `json:"ttl_seconds"` +} + +// ── 提交文本(通用:发起方 & 参与方) ── + +type SubmitRequest struct { + SessionID string `json:"session_id" binding:"required,min=8,max=64"` + Nickname string `json:"nickname" binding:"required,min=1,max=32"` + Role string `json:"role" binding:"required,oneof=initiator participant"` + Text string `json:"text" binding:"required"` +} + +// SubmitResponse 参与者提交后的结果(如果发起方已提交则 match 已算出) +type SubmitResponse struct { + Submitted bool `json:"submitted"` + InitiatorReady bool `json:"initiator_ready"` // 发起方是否已提交标准答案 + Match *bool `json:"match,omitempty"` // 发起方已提交时立即有值 +} + +// ── 参与者加入 ── + +type JoinRequest struct { + Nickname string `json:"nickname" binding:"required,min=1,max=32"` +} + +// JoinResponse 返回当前会话全量状态,供参与者初始化 UI +type JoinResponse struct { + InitiatorNickname string `json:"initiator_nickname,omitempty"` + InitiatorReady bool `json:"initiator_ready"` + Participants []ParticipantResp `json:"participants"` +} + +// ── 会话全量状态(发起方拉取 / 发起方 WS 初始化) ── + +// SessionResponse 发起方看到的会话状态(完整参与者列表) +type SessionResponse struct { + SessionID string `json:"session_id"` + InitiatorNickname string `json:"initiator_nickname"` + InitiatorReady bool `json:"initiator_ready"` + AllowResubmit bool `json:"allow_resubmit"` + Participants []ParticipantResp `json:"participants"` + TTLSeconds int64 `json:"ttl_seconds"` +} + +// ParticipantResp 单个参与者的状态 +type ParticipantResp struct { + Nickname string `json:"nickname"` + Joined bool `json:"joined"` + Submitted bool `json:"submitted"` + Match *bool `json:"match,omitempty"` +} + +// ── 会话校验(加入前预检查) ── + +type CheckResponse struct { + Exists bool `json:"exists"` + InitiatorReady bool `json:"initiator_ready"` + AllowResubmit bool `json:"allow_resubmit"` + ParticipantCount int `json:"participant_count"` + TTLSeconds int64 `json:"ttl_seconds"` +} + +// ── 发起方设置接口 ── + +// SetResubmitRequest 发起方切换"允许参与者重新提交"开关 +type SetResubmitRequest struct { + Allow bool `json:"allow"` +} diff --git a/server/internal/tools/dual_check/routes.go b/server/internal/tools/dual_check/routes.go new file mode 100644 index 0000000..3f04484 --- /dev/null +++ b/server/internal/tools/dual_check/routes.go @@ -0,0 +1,39 @@ +package dual_check + +import ( + "time" + + "cancerai.top/toolbox-server/internal/pkg/websocket" + "cancerai.top/toolbox-server/internal/store" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Register 把 DualCheck 工具的所有路由注册到指定 RouterGroup +func Register( + group *gin.RouterGroup, + sessionStore store.SessionStore, + hub *websocket.Hub, + logger *zap.Logger, + ttlSeconds int, + maxTextLen int, +) { + ttl := time.Duration(ttlSeconds) * time.Second + + svc := NewService(sessionStore, logger, ttl) + 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) // 加入前预校验 + + // 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 new file mode 100644 index 0000000..d0f96de --- /dev/null +++ b/server/internal/tools/dual_check/service.go @@ -0,0 +1,243 @@ +package dual_check + +import ( + "context" + "errors" + "fmt" + "time" + + "cancerai.top/toolbox-server/internal/pkg/hash" + "cancerai.top/toolbox-server/internal/pkg/sessionid" + "cancerai.top/toolbox-server/internal/store" + + "go.uber.org/zap" +) + +var ( + ErrSessionNotFound = &BusinessError{Code: ErrCodeSessionNotFound} + ErrSessionCreate = &BusinessError{Code: ErrCodeSessionCreate} + ErrDuplicateSubmit = &BusinessError{Code: ErrCodeDuplicateSubmit} +) + +// Service 双验业务逻辑层 +type Service struct { + store store.SessionStore + logger *zap.Logger + ttl time.Duration +} + +func NewService(s store.SessionStore, logger *zap.Logger, ttl time.Duration) *Service { + return &Service{store: s, logger: logger, ttl: ttl} +} + +// ── Create ── + +func (s *Service) CreateSession(ctx context.Context, nickname 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) + if err == nil { + break + } + } + } + if err != nil { + s.logger.Error("create session failed", zap.Error(err)) + return nil, ErrSessionCreate + } + + expiresAt := time.Now().Add(s.ttl) + s.logger.Info("session created", + zap.String("session_id", truncateSessionID(sid)), + zap.String("nickname", nickname), + zap.Time("expires_at", expiresAt), + ) + return &CreateResponse{ + SessionID: sid, + Nickname: nickname, + ExpiresAt: expiresAt, + TTL: int(s.ttl.Seconds()), + }, nil +} + +// ── Submit(发起方 or 参与方) ── + +func (s *Service) Submit(ctx context.Context, req *SubmitRequest) (*SubmitResponse, error) { + hashHex := hash.SHA256(req.Text) + req.Text = "" // 显式释放明文引用 + + switch req.Role { + case "initiator": + return s.submitInitiator(ctx, req.SessionID, req.Nickname, hashHex) + case "participant": + return s.submitParticipant(ctx, req.SessionID, req.Nickname, hashHex) + 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 { + if errors.Is(err, store.ErrSessionNotFound) { + return nil, ErrSessionNotFound + } + s.logger.Error("submit initiator failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID))) + return nil, fmt.Errorf("submit initiator: %w", err) + } + s.logger.Info("initiator submitted", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("nickname", nickname), + ) + 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) + if err != nil { + switch { + case errors.Is(err, store.ErrSessionNotFound): + return nil, ErrSessionNotFound + case errors.Is(err, store.ErrDuplicateSubmit): + return nil, ErrDuplicateSubmit + default: + s.logger.Error("submit participant failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("nickname", nickname), + ) + return nil, fmt.Errorf("submit participant: %w", err) + } + } + + s.logger.Info("participant submitted", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("nickname", nickname), + zap.Bool("initiator_ready", initReady), + ) + return &SubmitResponse{ + Submitted: true, + InitiatorReady: initReady, + Match: match, + }, nil +} + +// ── Join ── + +func (s *Service) JoinParticipant(ctx context.Context, sessionID, nickname string) (*SessionResponse, error) { + // 检查昵称是否等于发起方昵称(不允许用发起方的昵称作为参与者) + snap, err := s.store.Get(ctx, sessionID) + if err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return nil, ErrSessionNotFound + } + return nil, err + } + if snap.InitiatorNickname != "" && snap.InitiatorNickname == nickname { + return nil, &BusinessError{Code: ErrCodeNicknameExists} + } + + if err := s.store.JoinParticipant(ctx, sessionID, nickname); err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return nil, ErrSessionNotFound + } + return nil, err + } + + // 返回加入后的最新状态 + return s.GetSession(ctx, sessionID) +} + +// ── GetSession 返回全量状态 ── + +func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionResponse, error) { + snap, err := s.store.Get(ctx, sessionID) + if err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return nil, ErrSessionNotFound + } + return nil, err + } + ttl := time.Until(snap.ExpiresAt) + parts := make([]ParticipantResp, 0, len(snap.Participants)) + for _, p := range snap.Participants { + parts = append(parts, ParticipantResp{ + Nickname: p.Nickname, + Joined: true, + Submitted: p.Submitted, + Match: p.Match, + }) + } + return &SessionResponse{ + SessionID: sessionID, + InitiatorNickname: snap.InitiatorNickname, + InitiatorReady: snap.InitiatorSubmitted, + AllowResubmit: snap.AllowResubmit, + Participants: parts, + TTLSeconds: int64(ttl.Seconds()), + }, nil +} + +// ── CheckSession(加入前预检查) ── + +func (s *Service) CheckSession(ctx context.Context, sessionID string) (*CheckResponse, error) { + snap, err := s.store.Get(ctx, sessionID) + if err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return &CheckResponse{Exists: false}, nil + } + 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, + ParticipantCount: len(snap.Participants), + TTLSeconds: int64(ttl.Seconds()), + }, nil +} + +// ── SetResubmit 发起方切换"允许参与者重新提交"开关 ── + +func (s *Service) SetResubmit(ctx context.Context, sessionID string, allow bool) (*SessionResponse, error) { + if err := s.store.SetResubmit(ctx, sessionID, allow); err != nil { + if errors.Is(err, store.ErrSessionNotFound) { + return nil, ErrSessionNotFound + } + s.logger.Error("set resubmit failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID))) + return nil, fmt.Errorf("set resubmit: %w", err) + } + s.logger.Info("allow_resubmit changed", + zap.String("session_id", truncateSessionID(sessionID)), + zap.Bool("allow", allow), + ) + return s.GetSession(ctx, sessionID) +} + +// ── EndSession 发起方结束会话(删除 Redis key,WS 广播由 handler 完成) ── + +func (s *Service) EndSession(ctx context.Context, sessionID string) error { + if err := s.store.Delete(ctx, sessionID); err != nil { + s.logger.Error("end session failed", zap.Error(err), + zap.String("session_id", truncateSessionID(sessionID))) + return fmt.Errorf("end session: %w", err) + } + s.logger.Info("session ended", + zap.String("session_id", truncateSessionID(sessionID)), + ) + return nil +} + +func truncateSessionID(id string) string { + if len(id) > 8 { + return id[:8] + "..." + } + return id +} diff --git a/server/internal/tools/dual_check/ws_handler.go b/server/internal/tools/dual_check/ws_handler.go new file mode 100644 index 0000000..2d14110 --- /dev/null +++ b/server/internal/tools/dual_check/ws_handler.go @@ -0,0 +1,132 @@ +package dual_check + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + pkgws "cancerai.top/toolbox-server/internal/pkg/websocket" + "cancerai.top/toolbox-server/internal/response" + + "github.com/gin-gonic/gin" + gorillaws "github.com/gorilla/websocket" + "go.uber.org/zap" +) + +var upgrader = gorillaws.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +type WSHandler struct { + service *Service + hub *pkgws.Hub + logger *zap.Logger +} + +func NewWSHandler(service *Service, hub *pkgws.Hub, logger *zap.Logger) *WSHandler { + return &WSHandler{service: service, hub: hub, logger: logger} +} + +// Handle GET /api/v1/dual-check/ws?session_id=xxx&role=initiator|participant&nickname=xxx +func (h *WSHandler) Handle(c *gin.Context) { + sessionID := c.Query("session_id") + role := c.Query("role") + nickname := c.Query("nickname") + + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "session_id required"}) + return + } + if role != "initiator" && role != "participant" { + c.JSON(http.StatusBadRequest, gin.H{"code": 1004, "message": "role must be initiator or participant"}) + return + } + if nickname == "" || len(nickname) > 32 { + c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "nickname required (1-32)"}) + return + } + + // 先校验 session 存在 + info, err := h.service.CheckSession(c.Request.Context(), sessionID) + if err != nil || !info.Exists { + c.JSON(http.StatusNotFound, gin.H{"code": 1001, "message": "session not found"}) + return + } + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + h.logger.Warn("ws upgrade failed", zap.Error(err)) + return + } + + wsConn := &pkgws.Conn{ + Hub: h.hub, + SessionID: sessionID, + Role: role, + WS: conn, + Send: make(chan []byte, 256), + } + h.hub.Register(wsConn) + + // 参与者首次连接:自动登记昵称(幂等) + if role == "participant" { + _, _ = h.service.JoinParticipant(c.Request.Context(), sessionID, nickname) + // 广播 participant_joined 给房间其他人 + h.hub.Broadcast(sessionID, response.WSParticipantJoined(nickname)) + } + + // 推一个 init 消息给新连接者,带上当前全量快照让它自己初始化 UI + if sess, err := h.service.GetSession(c.Request.Context(), sessionID); err == nil { + wsConn.Send <- marshalMsg(response.WSFullState(sess)) + } + + h.logger.Info("ws connected", + zap.String("session_id", truncateSessionID(sessionID)), + zap.String("role", role), + zap.String("nickname", nickname), + ) + + go pkgws.WritePump(wsConn) + // ReadPump 里的 Unregister 会触发 participant_left 广播 + go h.readLoop(wsConn, nickname) +} + +func (h *WSHandler) readLoop(c *pkgws.Conn, nickname string) { + defer func() { + c.WS.Close() // 先关,让 WritePump 也能退 + if c.Role == "initiator" { + // 发起方断开:立即结束会话 → 广播给所有参与者 → 删除 Redis + h.hub.Broadcast(c.SessionID, response.WSSessionEnded()) + if err := h.service.EndSession(context.Background(), c.SessionID); err != nil { + h.logger.Warn("initiator disconnect end session err", + zap.String("session_id", truncateSessionID(c.SessionID)), + zap.Error(err)) + } else { + h.logger.Info("initiator disconnected, session ended", + zap.String("session_id", truncateSessionID(c.SessionID)), + zap.String("nickname", nickname)) + } + } else { + // 参与者断开:广播 participant_left + h.hub.Broadcast(c.SessionID, response.WSParticipantLeft(nickname)) + } + c.Hub.Unregister(c) // 最后从房间移除 + }() + c.WS.SetPongHandler(func(string) error { return nil }) + for { + if _, _, err := c.WS.ReadMessage(); err != nil { + return + } + } +} + +func marshalMsg(m response.WebSocketMessage) []byte { + b, _ := json.Marshal(m) + return b +} + +// 确保 fmt/json 被引用 +var _ = fmt.Sprintf