dual check v0.1
This commit is contained in:
1 parent
78bad1fa3d
commit
c137299016
36 files changed
+3712
-66
No files matched your search
@@ -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<String, dynamic> && 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<CreateResp> createSession({required String nickname}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/create',
|
||||
data: {'nickname': nickname},
|
||||
);
|
||||
final d = res.data!['data'] as Map<String, dynamic>;
|
||||
return CreateResp(
|
||||
sessionId: d['session_id'] as String,
|
||||
nickname: d['nickname'] as String,
|
||||
ttl: d['ttl_seconds'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
|
||||
Future<SubmitResp> submit({
|
||||
required String sessionId,
|
||||
required String nickname,
|
||||
required String role, // "initiator" | "participant"
|
||||
required String text,
|
||||
}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/submit',
|
||||
data: {
|
||||
'session_id': sessionId,
|
||||
'nickname': nickname,
|
||||
'role': role,
|
||||
'text': text,
|
||||
},
|
||||
);
|
||||
final d = res.data!['data'] as Map<String, dynamic>;
|
||||
return SubmitResp(
|
||||
submitted: d['submitted'] as bool,
|
||||
initiatorReady: d['initiator_ready'] as bool,
|
||||
match: d['match'] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
// ── CheckSession ──
|
||||
|
||||
Future<CheckResp> checkSession(String sessionId) async {
|
||||
final res = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/check/$sessionId',
|
||||
);
|
||||
final d = res.data!['data'] as Map<String, dynamic>;
|
||||
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<SessionResp> getSession(String sessionId) async {
|
||||
final res = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/session/$sessionId',
|
||||
);
|
||||
return parseSession(res.data!['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ── SetResubmit 发起方切换"允许参与者重新提交" ──
|
||||
|
||||
Future<SessionResp> setResubmit(String sessionId, {required bool allow}) async {
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/dual-check/session/$sessionId/resubmit',
|
||||
data: {'allow': allow},
|
||||
);
|
||||
return parseSession(res.data!['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// ── EndSession 发起方主动结束会话 ──
|
||||
|
||||
Future<void> endSession(String sessionId) async {
|
||||
await _dio.post('/api/v1/dual-check/session/$sessionId/end');
|
||||
}
|
||||
|
||||
static SessionResp parseSession(Map<String, dynamic> d) {
|
||||
final parts = (d['participants'] as List)
|
||||
.map((p) => ParticipantResp.fromMap(p as Map<String, dynamic>))
|
||||
.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<String, dynamic> 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<ParticipantResp> 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<String, dynamic> data;
|
||||
final String? error;
|
||||
|
||||
WSMessage({required this.type, required this.data, this.error});
|
||||
|
||||
factory WSMessage.fromJson(Map<String, dynamic> json) {
|
||||
return WSMessage(
|
||||
type: json['type'] as String,
|
||||
data: (json['data'] as Map<String, dynamic>?) ?? 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 ?? '请求失败');
|
||||
}
|
||||
@@ -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<DualCheck> {
|
||||
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<void> _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<void> _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<String, dynamic>);
|
||||
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<ParticipantResp>.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<ParticipantResp>.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<String, dynamic>;
|
||||
setState(() {
|
||||
if (_session == null) return;
|
||||
final list = List<ParticipantResp>.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<void> _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<void> _confirmAndEndSession() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _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<void> _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<void> _handleWillPop() async {
|
||||
if (_sessionId == null) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _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<ParticipantResp>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _ensureInit() async {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// 获取当前昵称,未设置时返回默认昵称
|
||||
Future<String> get nickname async {
|
||||
await _ensureInit();
|
||||
final n = _prefs!.getString(_keyNickname);
|
||||
if (n != null && n.trim().isNotEmpty) return n.trim();
|
||||
return '用户';
|
||||
}
|
||||
|
||||
/// 设置昵称(1-32 字符,过短返回 false)
|
||||
Future<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Home> {
|
||||
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<Home> {
|
||||
),
|
||||
),
|
||||
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<Home> {
|
||||
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});
|
||||
}
|
||||
@@ -2,62 +2,72 @@ 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<ToolCard> 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<ToolCard> {
|
||||
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,
|
||||
// 图标占位区
|
||||
Container(
|
||||
width: 80,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
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(
|
||||
"二维码生成器",
|
||||
meta.name,
|
||||
style: TextStyle(
|
||||
fontSize: Font.FONT_SIZE_SUBTITLE,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Divider(),
|
||||
SizedBox(height: 8),
|
||||
Container(
|
||||
child: Text(
|
||||
"上传文字,图片即可立即生成二维码",
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
+216
-27
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Generated
+10
@@ -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
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/server.iml" filepath="$PROJECT_DIR$/.idea/server.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="GoModuleSettings" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
+114
@@ -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=
|
||||
@@ -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]:")
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user