beta v0.2 dualcheck

This commit is contained in:
cancerai committed 2026-08-30 21:25:22 +08:00
1 parent c137299016
commit 1ec68bacf5
14 files changed
+1591 -359

No files matched your search

+142 -80
View File
@@ -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,14 +246,12 @@ 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?) ?? false,
submitted: m['submitted'] as bool, match: m['match'] as bool?,
match: m['match'] as bool?, );
);
}
} }
class SessionResp { class SessionResp {
@@ -202,6 +259,7 @@ class SessionResp {
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 ?? '请求失败');
}
File diff suppressed because it is too large. Load diff
@@ -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;
}
} }
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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:
+29
View File
@@ -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,
},
}
}
+249 -23
View File
@@ -9,23 +9,34 @@ 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 检查
var ( 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 != "" {
+28 -8
View File
@@ -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)
+84 -37
View File
@@ -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
+21 -4
View File
@@ -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 {
Allow bool `json:"allow"` SessionID string `json:"session_id" binding:"required,min=8,max=64"`
Allow bool `json:"allow"`
}
// SetIgnoreWsRequest 发起方切换"忽略首尾空白"开关
type SetIgnoreWsRequest struct {
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
Ignore bool `json:"ignore"`
} }
+9 -8
View File
@@ -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)
+223 -29
View File
@@ -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] + "..."
+102 -11
View File
@@ -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:
} }
} }