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

+249 -23
View File
@@ -9,23 +9,34 @@ import (
"time"
"github.com/redis/go-redis/v9"
"cancerai.top/toolbox-server/internal/pkg/hash"
)
// 包级错误,业务层可以 errors.Is 检查
var (
ErrSessionNotFound = errors.New("session not found or expired")
ErrDuplicateJoin = errors.New("nickname already joined this session")
ErrDuplicateSubmit = errors.New("this nickname has already submitted")
ErrSessionNotFound = errors.New("session not found or expired")
ErrDuplicateJoin = errors.New("nickname already joined this session")
ErrDuplicateSubmit = errors.New("this nickname has already submitted")
ErrNicknameConflict = errors.New("nickname conflict (exists or matches initiator)")
ErrOldNotFound = errors.New("old nickname not found in session")
)
const (
redisKeyPrefix = "dualcheck:session:"
fieldCreatedAt = "created_at"
fieldInitHash = "initiator_hash"
fieldInitText = "initiator_text" // 发起方原始文本(trim 前)
fieldInitNick = "initiator_nickname"
fieldInitBrowser = "initiator_browser_id"
fieldAllowResub = "allow_resubmit"
// participant:{nick} 存该参与者的哈希值;participant:{nick}:match 存 "1"/"0"
fieldIgnoreWs = "ignore_ws"
// participant:{nick} 存该参与者的哈希值;participant:{nick}:text 存原始文本;participant:{nick}:match 存 "1"/"0"
// participant:{nick}:browser_id 存该参与者的 browserId
partFieldPrefix = "participant:"
partTextSuffix = ":text"
partMatchSuffix = ":match"
partBrowserSuffix = ":browser_id"
)
// ── 五个 Lua 脚本 ──
@@ -37,17 +48,22 @@ if redis.call("EXISTS", key) ~= 0 then
return 0
end
redis.call("HSET", key, "created_at", ARGV[1])
redis.call("HSET", key, "initiator_nickname", ARGV[2])
redis.call("HSET", key, "initiator_browser_id", ARGV[3])
redis.call("HSET", key, "allow_resubmit", "0")
redis.call("EXPIRE", key, ARGV[2])
redis.call("HSET", key, "ignore_ws", "0")
redis.call("EXPIRE", key, ARGV[4])
return 1
`)
// joinParticipantLuaScript 登记参与者昵称(空哈希占位,表示已加入未提交)
// KEYS[1] = session key
// ARGV[1] = nickname
// ARGV[2] = browserId
var joinParticipantLuaScript = redis.NewScript(`
local key = KEYS[1]
local nick = ARGV[1]
local bid = ARGV[2] or ""
local exists = redis.call("EXISTS", key)
if exists == 0 then
return redis.error_reply("SESSION_NOT_FOUND")
@@ -58,20 +74,25 @@ if redis.call("HEXISTS", key, field) == 1 then
return 1
end
redis.call("HSET", key, field, "")
if bid ~= "" then
redis.call("HSET", key, field .. ":browser_id", bid)
end
return 1
`)
// submitParticipantLuaScript 参与者提交自己的哈希
// 原子完成:检查 → 写入 → 与 initiator_hash 比对(若发起方已提交)
// 原子完成:检查 → 写入哈希+原始文本 → 与 initiator_hash 比对(若发起方已提交)
// 若已提交但 allow_resubmit=1 则覆盖(重算 match)
// KEYS[1] = session key
// ARGV[1] = nickname
// ARGV[2] = hash hex
// ARGV[2] = hash hex(已按当前 ignore_ws 规则 trim + hash)
// ARGV[3] = 原始文本(trim 前)
// 返回 {initiatorSubmitted (0/1), match ("1"/"0"/"")}
var submitParticipantLuaScript = redis.NewScript(`
local key = KEYS[1]
local nick = ARGV[1]
local hash_val = ARGV[2]
local orig_text = ARGV[3]
if redis.call("EXISTS", key) == 0 then
return redis.error_reply("SESSION_NOT_FOUND")
@@ -90,8 +111,9 @@ if existing ~= "" then
end
end
-- 写入(覆盖)哈希
-- 写入(覆盖)哈希 + 原始文本
redis.call("HSET", key, field, hash_val)
redis.call("HSET", key, field .. ":text", orig_text)
-- 清除旧的 match 字段,稍后按需重算
redis.call("HDEL", key, field .. ":match")
@@ -106,14 +128,16 @@ end
`)
// submitInitiatorLuaScript 发起方提交标准答案
// 写入 initiator_hash 后,遍历所有已提交的 participant,批量计算 match
// 写入 initiator_hash + initiator_text 后,遍历所有已提交的 participant,批量计算 match
// KEYS[1] = session key
// ARGV[1] = nickname
// ARGV[2] = initiator hash
// ARGV[2] = initiator hash(已按当前 ignore_ws 规则 trim + hash)
// ARGV[3] = 原始文本(trim 前)
var submitInitiatorLuaScript = redis.NewScript(`
local key = KEYS[1]
local nick = ARGV[1]
local init_hash = ARGV[2]
local orig_text = ARGV[3]
if redis.call("EXISTS", key) == 0 then
return redis.error_reply("SESSION_NOT_FOUND")
@@ -125,6 +149,7 @@ if redis.call("HEXISTS", key, "initiator_hash") == 1 then
end
redis.call("HSET", key, "initiator_hash", init_hash)
redis.call("HSET", key, "initiator_text", orig_text)
redis.call("HSET", key, "initiator_nickname", nick)
-- 遍历所有 participant:{nick} 且哈希非空的,计算 match
@@ -134,6 +159,7 @@ for i = 1, #all, 2 do
local val = all[i + 1]
if string.sub(field, 1, #("participant:")) == "participant:"
and not string.find(field, ":match$")
and not string.find(field, ":text$")
and val ~= "" then
local match = (val == init_hash) and "1" or "0"
redis.call("HSET", key, field .. ":match", match)
@@ -142,6 +168,71 @@ end
return 1
`)
// renameParticipantLuaScript 原子重命名参与者
// 检查:session 存在、old 存在、new 不与 initiator_nickname/已有 participant 冲突
// (但如果 oldNick 的 browserId 和冲突方相同,视为同一个人,跳过冲突)
// 迁移 participant:{old}、participant:{old}:text、participant:{old}:match、participant:{old}:browser_id → new
// KEYS[1] = session key
// ARGV[1] = old nickname
// ARGV[2] = new nickname
// ARGV[3] = old participant's browserId
var renameParticipantLuaScript = redis.NewScript(`
local key = KEYS[1]
local oldNick = ARGV[1]
local newNick = ARGV[2]
local oldBid = ARGV[3] or ""
if redis.call("EXISTS", key) == 0 then
return redis.error_reply("SESSION_NOT_FOUND")
end
-- oldNick 必须存在
local oldField = "participant:" .. oldNick
if redis.call("HEXISTS", key, oldField) == 0 then
return redis.error_reply("OLD_NOT_FOUND")
end
-- newNick 不能等于发起方昵称(除非是同一个浏览器)
local initNick = redis.call("HGET", key, "initiator_nickname")
local initBid = redis.call("HGET", key, "initiator_browser_id") or ""
if initNick and initNick == newNick then
if oldBid == "" or initBid ~= oldBid then
return redis.error_reply("NICKNAME_CONFLICT")
end
end
-- newNick 不能已被其他 participant 占用(除非是同一个浏览器)
local newField = "participant:" .. newNick
if redis.call("HEXISTS", key, newField) == 1 then
local newBid = redis.call("HGET", key, newField .. ":browser_id") or ""
if oldBid == "" or newBid ~= oldBid then
return redis.error_reply("NICKNAME_CONFLICT")
end
end
-- 迁移四个 field
local hashVal = redis.call("HGET", key, oldField)
local textVal = redis.call("HGET", key, oldField .. ":text")
local matchVal = redis.call("HGET", key, oldField .. ":match")
local bidVal = redis.call("HGET", key, oldField .. ":browser_id")
redis.call("HSET", key, newField, hashVal)
if textVal ~= false then
redis.call("HSET", key, newField .. ":text", textVal)
end
if matchVal ~= false then
redis.call("HSET", key, newField .. ":match", matchVal)
end
if bidVal ~= false then
redis.call("HSET", key, newField .. ":browser_id", bidVal)
end
redis.call("HDEL", key, oldField)
redis.call("HDEL", key, oldField .. ":text")
redis.call("HDEL", key, oldField .. ":match")
redis.call("HDEL", key, oldField .. ":browser_id")
return 1
`)
// ── RedisSessionStore 实现 ──
type RedisSessionStore struct {
@@ -158,14 +249,15 @@ func key(sessionID string) string {
}
// Create 原子创建会话 Hash key
func (s *RedisSessionStore) Create(ctx context.Context, sessionID string, ttl time.Duration) error {
func (s *RedisSessionStore) Create(ctx context.Context, sessionID, initiatorNickname, initiatorBrowserID string, ttl time.Duration) error {
if ttl <= 0 {
ttl = s.ttl
}
now := time.Now().Unix()
ttlSeconds := int64(ttl.Seconds())
result, err := createLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, now, ttlSeconds).Int64()
result, err := createLuaScript.Run(ctx, s.rdb, []string{key(sessionID)},
now, initiatorNickname, initiatorBrowserID, ttlSeconds).Int64()
if err != nil {
return fmt.Errorf("redis create session: %w", err)
}
@@ -176,8 +268,8 @@ func (s *RedisSessionStore) Create(ctx context.Context, sessionID string, ttl ti
}
// JoinParticipant 登记参与者(空哈希占位)
func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname string) error {
_, err := joinParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname).Int64()
func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname, browserID string) error {
_, err := joinParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, browserID).Int64()
if err != nil {
msg := err.Error()
if strings.Contains(msg, "SESSION_NOT_FOUND") {
@@ -188,9 +280,9 @@ func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nick
return nil
}
// SubmitParticipant 参与者提交自己的文本哈希
func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (bool, *bool, error) {
result, err := submitParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex).Result()
// SubmitParticipant 参与者提交自己的文本哈希(同时存原始文本)
func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex, originalText string) (bool, *bool, error) {
result, err := submitParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex, originalText).Result()
if err != nil {
msg := err.Error()
switch {
@@ -200,10 +292,10 @@ func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, ni
return false, nil, ErrDuplicateSubmit
case strings.Contains(msg, "NICKNAME_NOT_JOINED"):
// 参与者没走 join 直接 submit,自动补登记
if err := s.JoinParticipant(ctx, sessionID, nickname); err != nil {
if err := s.JoinParticipant(ctx, sessionID, nickname, ""); err != nil {
return false, nil, err
}
return s.SubmitParticipant(ctx, sessionID, nickname, hashHex)
return s.SubmitParticipant(ctx, sessionID, nickname, hashHex, originalText)
default:
return false, nil, fmt.Errorf("redis submit participant: %w", err)
}
@@ -219,9 +311,9 @@ func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, ni
return initSubmitted, match, nil
}
// SubmitInitiator 发起方提交标准答案哈希
func (s *RedisSessionStore) SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error {
_, err := submitInitiatorLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex).Int64()
// SubmitInitiator 发起方提交标准答案哈希(同时存原始文本)
func (s *RedisSessionStore) SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex, originalText string) error {
_, err := submitInitiatorLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex, originalText).Int64()
if err != nil {
if strings.Contains(err.Error(), "SESSION_NOT_FOUND") {
return ErrSessionNotFound
@@ -248,6 +340,134 @@ func (s *RedisSessionStore) SetResubmit(ctx context.Context, sessionID string, a
return s.rdb.HSet(ctx, key, fieldAllowResub, val).Err()
}
func (s *RedisSessionStore) SetIgnoreWs(ctx context.Context, sessionID string, ignore bool) error {
key := key(sessionID)
exists, err := s.rdb.Exists(ctx, key).Result()
if err != nil {
return fmt.Errorf("redis exists: %w", err)
}
if exists == 0 {
return ErrSessionNotFound
}
val := "0"
if ignore {
val = "1"
}
return s.rdb.HSet(ctx, key, fieldIgnoreWs, val).Err()
}
func (s *RedisSessionStore) GetIgnoreWs(ctx context.Context, sessionID string) (bool, error) {
v, err := s.rdb.HGet(ctx, key(sessionID), fieldIgnoreWs).Result()
if err != nil {
if err == redis.Nil {
return false, nil
}
return false, fmt.Errorf("redis hget ignore_ws: %w", err)
}
return v == "1", nil
}
// RecalcMatches 按新的 ignoreWs 规则重新 trim + hash 所有已提交文本并重算 match
func (s *RedisSessionStore) RecalcMatches(ctx context.Context, sessionID string, ignoreWs bool) error {
k := key(sessionID)
all, err := s.rdb.HGetAll(ctx, k).Result()
if err != nil {
return fmt.Errorf("redis hgetall: %w", err)
}
if len(all) == 0 {
return ErrSessionNotFound
}
// 读发起方原始文本
initText := all[fieldInitText]
if initText == "" {
return nil // 发起方还没提交,不需要重算
}
initProcessed := initText
if ignoreWs {
initProcessed = strings.TrimSpace(initText)
}
initHash := hash.SHA256(initProcessed)
pipe := s.rdb.TxPipeline()
pipe.HSet(ctx, k, fieldInitHash, initHash)
// 遍历所有已提交参与者,重算 hash + match
for field, val := range all {
if !strings.HasPrefix(field, partFieldPrefix) {
continue
}
if strings.HasSuffix(field, partMatchSuffix) || strings.HasSuffix(field, partTextSuffix) {
continue
}
if val == "" {
continue
}
textField := field + partTextSuffix
origText := all[textField]
if origText == "" {
continue // 旧数据(没存过原始文本),跳过
}
processed := origText
if ignoreWs {
processed = strings.TrimSpace(origText)
}
newHash := hash.SHA256(processed)
match := "0"
if newHash == initHash {
match = "1"
}
pipe.HSet(ctx, k, field, newHash)
pipe.HSet(ctx, k, field+partMatchSuffix, match)
}
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("redis recalc pipeline: %w", err)
}
return nil
}
// UpdateParticipantNickname 原子重命名参与者
func (s *RedisSessionStore) UpdateParticipantNickname(ctx context.Context, sessionID, oldNick, newNick, oldBrowserID string) error {
_, err := renameParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, oldNick, newNick, oldBrowserID).Int64()
if err != nil {
msg := err.Error()
switch {
case strings.Contains(msg, "SESSION_NOT_FOUND"):
return ErrSessionNotFound
case strings.Contains(msg, "OLD_NOT_FOUND"):
return ErrOldNotFound
case strings.Contains(msg, "NICKNAME_CONFLICT"):
return ErrNicknameConflict
default:
return fmt.Errorf("redis rename participant: %w", err)
}
}
return nil
}
// RemoveParticipant 参与者离开时从 Redis 删除其所有字段
func (s *RedisSessionStore) RemoveParticipant(ctx context.Context, sessionID, nickname string) error {
k := key(sessionID)
exists, err := s.rdb.Exists(ctx, k).Result()
if err != nil {
return fmt.Errorf("redis exists: %w", err)
}
if exists == 0 {
return ErrSessionNotFound
}
pipe := s.rdb.TxPipeline()
pipe.HDel(ctx, k, partFieldPrefix+nickname)
pipe.HDel(ctx, k, partFieldPrefix+nickname+partTextSuffix)
pipe.HDel(ctx, k, partFieldPrefix+nickname+partMatchSuffix)
_, err = pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("redis remove participant: %w", err)
}
return nil
}
// Get 获取会话当前状态全量快照
func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*SessionSnapshot, error) {
exists, err := s.rdb.Exists(ctx, key(sessionID)).Result()
@@ -272,8 +492,10 @@ func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*Session
SessionID: sessionID,
InitiatorHash: hash[fieldInitHash],
InitiatorNickname: hash[fieldInitNick],
InitiatorBrowserID: hash[fieldInitBrowser],
InitiatorSubmitted: hash[fieldInitHash] != "",
AllowResubmit: hash[fieldAllowResub] == "1",
IgnoreWs: hash[fieldIgnoreWs] == "1",
ExpiresAt: time.Now().Add(ttl),
}
@@ -281,7 +503,10 @@ func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*Session
var nicks []string
partMap := map[string]string{} // nick → hash
for k, v := range hash {
if strings.HasPrefix(k, partFieldPrefix) && !strings.HasSuffix(k, ":match") {
if strings.HasPrefix(k, partFieldPrefix) &&
!strings.HasSuffix(k, partMatchSuffix) &&
!strings.HasSuffix(k, partTextSuffix) &&
!strings.HasSuffix(k, partBrowserSuffix) {
nick := strings.TrimPrefix(k, partFieldPrefix)
nicks = append(nicks, nick)
partMap[nick] = v
@@ -294,6 +519,7 @@ func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*Session
h := partMap[nick]
p := ParticipantInfo{
Nickname: nick,
BrowserID: hash[partFieldPrefix+nick+partBrowserSuffix],
Submitted: h != "",
}
if h != "" && initiatorHash != "" {
+28 -8
View File
@@ -17,6 +17,7 @@ type Session struct {
// ParticipantInfo 参与者在全量快照中的状态
type ParticipantInfo struct {
Nickname string `json:"nickname"`
BrowserID string `json:"browser_id,omitempty"`
Submitted bool `json:"submitted"`
Match *bool `json:"match,omitempty"`
}
@@ -26,33 +27,52 @@ type SessionSnapshot struct {
SessionID string `json:"session_id"`
InitiatorHash string `json:"-"` // 发起方标准答案哈希,不对外暴露
InitiatorNickname string `json:"initiator_nickname"`
InitiatorBrowserID string `json:"initiator_browser_id,omitempty"`
InitiatorSubmitted bool `json:"initiator_submitted"`
AllowResubmit bool `json:"allow_resubmit"`
IgnoreWs bool `json:"ignore_ws"` // 是否忽略首尾空白
Participants []ParticipantInfo `json:"participants"`
ExpiresAt time.Time `json:"expires_at"`
}
// SessionStore 会话存储接口
type SessionStore interface {
// Create 原子创建会话(SET NX EX)
Create(ctx context.Context, id string, ttl time.Duration) error
// Create 原子创建会话(SET NX EX),同时记录发起方昵称和 browserId
Create(ctx context.Context, id, initiatorNickname, initiatorBrowserID string, ttl time.Duration) error
// SubmitInitiator 发起方提交标准答案(initiator_hash + initiator_nickname)
// SubmitInitiator 发起方提交标准答案(initiator_hash + initiator_nickname + 原始文本)
// 同时立即为所有已提交的参与者计算 match
SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error
SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex, originalText string) error
// SubmitParticipant 参与者提交自己的文本(按 nickname 唯一标识)
// SubmitParticipant 参与者提交自己的文本(按 nickname 唯一标识,同时存原始文本)
// 返回:initiatorSubmitted(发起方是否已提交,是则 match 已算出)、match
SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (
SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex, originalText string) (
initiatorSubmitted bool, match *bool, err error)
// JoinParticipant 参与者加入会话(只登记昵称,不提交文本)
// 用于 WS 连接时实时广播 joined 事件
JoinParticipant(ctx context.Context, sessionID, nickname string) error
// browserId 用于识别"同一个人",避免跨 tab 昵称冲突
JoinParticipant(ctx context.Context, sessionID, nickname, browserID string) error
// SetResubmit 设置是否允许参与者重复提交
SetResubmit(ctx context.Context, sessionID string, allow bool) error
// SetIgnoreWs 设置比对时是否忽略首尾空白(1=忽略,0=不忽略)
SetIgnoreWs(ctx context.Context, sessionID string, ignore bool) error
// GetIgnoreWs 获取当前会话的 ignore_ws 设置
GetIgnoreWs(ctx context.Context, sessionID string) (bool, error)
// RecalcMatches 按新的 ignoreWs 规则重新 trim + hash 所有已提交文本并重算 match
RecalcMatches(ctx context.Context, sessionID string, ignoreWs bool) error
// UpdateParticipantNickname 原子重命名参与者:将 participant:{old} → participant:{new}
// oldBrowserID 用于判断是否与冲突方是同一个浏览器(跳过冲突)
// 返回业务错误:ErrSessionNotFound / ErrNicknameConflict
UpdateParticipantNickname(ctx context.Context, sessionID, oldNick, newNick, oldBrowserID string) error
// RemoveParticipant 参与者离开时从 Redis 删除其所有字段(hash/text/match)
RemoveParticipant(ctx context.Context, sessionID, nickname string) error
// Get 获取会话当前状态全量快照
Get(ctx context.Context, sessionID string) (*SessionSnapshot, error)