Files
2026-08-30 21:25:22 +08:00

545 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"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")
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"
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 脚本 ──
// createLuaScript 原子创建 Hash 类型的会话 key
var createLuaScript = redis.NewScript(`
local key = KEYS[1]
if redis.call("EXISTS", key) ~= 0 then
return 0
end
redis.call("HSET", key, "created_at", ARGV[1])
redis.call("HSET", key, "initiator_nickname", ARGV[2])
redis.call("HSET", key, "initiator_browser_id", ARGV[3])
redis.call("HSET", key, "allow_resubmit", "0")
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")
end
local field = "participant:" .. nick
-- 昵称已存在视为已加入(幂等),直接返回 1
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 比对(若发起方已提交)
// 若已提交但 allow_resubmit=1 则覆盖(重算 match)
// KEYS[1] = session key
// ARGV[1] = nickname
// 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")
end
local field = "participant:" .. nick
if redis.call("HEXISTS", key, field) == 0 then
return redis.error_reply("NICKNAME_NOT_JOINED")
end
local existing = redis.call("HGET", key, field)
if existing ~= "" then
-- 已提交过:看是否允许重提交
local allow = redis.call("HGET", key, "allow_resubmit")
if allow ~= "1" then
return redis.error_reply("DUPLICATE_SUBMIT")
end
end
-- 写入(覆盖)哈希 + 原始文本
redis.call("HSET", key, field, hash_val)
redis.call("HSET", key, field .. ":text", orig_text)
-- 清除旧的 match 字段,稍后按需重算
redis.call("HDEL", key, field .. ":match")
local init_hash = redis.call("HGET", key, "initiator_hash")
if init_hash and init_hash ~= "" then
local match = (hash_val == init_hash) and "1" or "0"
redis.call("HSET", key, field .. ":match", match)
return {1, match}
else
return {0, ""}
end
`)
// submitInitiatorLuaScript 发起方提交标准答案
// 写入 initiator_hash + initiator_text 后,遍历所有已提交的 participant,批量计算 match
// KEYS[1] = session key
// ARGV[1] = nickname
// 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")
end
-- 发起方只允许提交一次(幂等:已提交过则跳过)
if redis.call("HEXISTS", key, "initiator_hash") == 1 then
return 1
end
redis.call("HSET", key, "initiator_hash", init_hash)
redis.call("HSET", key, "initiator_text", orig_text)
redis.call("HSET", key, "initiator_nickname", nick)
-- 遍历所有 participant:{nick} 且哈希非空的,计算 match
local all = redis.call("HGETALL", key)
for i = 1, #all, 2 do
local field = all[i]
local val = all[i + 1]
if string.sub(field, 1, #("participant:")) == "participant:"
and not string.find(field, ":match$")
and not string.find(field, ":text$")
and val ~= "" then
local match = (val == init_hash) and "1" or "0"
redis.call("HSET", key, field .. ":match", match)
end
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 {
rdb *redis.Client
ttl time.Duration
}
func NewRedisSessionStore(rdb *redis.Client, defaultTTL time.Duration) *RedisSessionStore {
return &RedisSessionStore{rdb: rdb, ttl: defaultTTL}
}
func key(sessionID string) string {
return redisKeyPrefix + sessionID
}
// Create 原子创建会话 Hash key
func (s *RedisSessionStore) Create(ctx context.Context, sessionID, 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, initiatorNickname, initiatorBrowserID, ttlSeconds).Int64()
if err != nil {
return fmt.Errorf("redis create session: %w", err)
}
if result == 0 {
return fmt.Errorf("session id collision: %s", sessionID)
}
return nil
}
// JoinParticipant 登记参与者(空哈希占位)
func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname, 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") {
return ErrSessionNotFound
}
return fmt.Errorf("redis join participant: %w", err)
}
return nil
}
// 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 {
case strings.Contains(msg, "SESSION_NOT_FOUND"):
return false, nil, ErrSessionNotFound
case strings.Contains(msg, "DUPLICATE_SUBMIT"):
return false, nil, ErrDuplicateSubmit
case strings.Contains(msg, "NICKNAME_NOT_JOINED"):
// 参与者没走 join 直接 submit,自动补登记
if err := s.JoinParticipant(ctx, sessionID, nickname, ""); err != nil {
return false, nil, err
}
return s.SubmitParticipant(ctx, sessionID, nickname, hashHex, originalText)
default:
return false, nil, fmt.Errorf("redis submit participant: %w", err)
}
}
values := result.([]interface{})
initSubmitted := values[0].(int64) == 1
var match *bool
if initSubmitted {
b := values[1].(string) == "1"
match = &b
}
return initSubmitted, match, nil
}
// SubmitInitiator 发起方提交标准答案哈希(同时存原始文本)
func (s *RedisSessionStore) SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex, 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
}
return fmt.Errorf("redis submit initiator: %w", err)
}
return nil
}
// SetResubmit 设置会话是否允许参与者重复提交
func (s *RedisSessionStore) SetResubmit(ctx context.Context, sessionID string, allow bool) error {
key := key(sessionID)
exists, err := s.rdb.Exists(ctx, key).Result()
if err != nil {
return fmt.Errorf("redis exists: %w", err)
}
if exists == 0 {
return ErrSessionNotFound
}
val := "0"
if allow {
val = "1"
}
return s.rdb.HSet(ctx, key, fieldAllowResub, val).Err()
}
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()
if err != nil {
return nil, fmt.Errorf("redis exists: %w", err)
}
if exists == 0 {
return nil, ErrSessionNotFound
}
hash, err := s.rdb.HGetAll(ctx, key(sessionID)).Result()
if err != nil {
return nil, fmt.Errorf("redis hgetall: %w", err)
}
ttl, err := s.rdb.TTL(ctx, key(sessionID)).Result()
if err != nil {
return nil, fmt.Errorf("redis ttl: %w", err)
}
snap := &SessionSnapshot{
SessionID: sessionID,
InitiatorHash: hash[fieldInitHash],
InitiatorNickname: hash[fieldInitNick],
InitiatorBrowserID: hash[fieldInitBrowser],
InitiatorSubmitted: hash[fieldInitHash] != "",
AllowResubmit: hash[fieldAllowResub] == "1",
IgnoreWs: hash[fieldIgnoreWs] == "1",
ExpiresAt: time.Now().Add(ttl),
}
// 收集所有 participant 条目,按 nickname 排序
var nicks []string
partMap := map[string]string{} // nick → hash
for k, v := range hash {
if strings.HasPrefix(k, partFieldPrefix) &&
!strings.HasSuffix(k, partMatchSuffix) &&
!strings.HasSuffix(k, partTextSuffix) &&
!strings.HasSuffix(k, partBrowserSuffix) {
nick := strings.TrimPrefix(k, partFieldPrefix)
nicks = append(nicks, nick)
partMap[nick] = v
}
}
sort.Strings(nicks)
initiatorHash := hash[fieldInitHash]
for _, nick := range nicks {
h := partMap[nick]
p := ParticipantInfo{
Nickname: nick,
BrowserID: hash[partFieldPrefix+nick+partBrowserSuffix],
Submitted: h != "",
}
if h != "" && initiatorHash != "" {
m := h == initiatorHash
p.Match = &m
} else if mStr, ok := hash[partFieldPrefix+nick+":match"]; ok && mStr != "" {
m := mStr == "1"
p.Match = &m
}
snap.Participants = append(snap.Participants, p)
}
return snap, nil
}
// Delete 删除会话
func (s *RedisSessionStore) Delete(ctx context.Context, sessionID string) error {
if err := s.rdb.Del(ctx, key(sessionID)).Err(); err != nil {
return fmt.Errorf("redis del: %w", err)
}
return nil
}