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