438 lines
14 KiB
Go
438 lines
14 KiB
Go
package dual_check
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"errors"
|
||
"fmt"
|
||
"math/big"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"cancerai.top/toolbox-server/internal/pkg/hash"
|
||
"cancerai.top/toolbox-server/internal/pkg/sessionid"
|
||
"cancerai.top/toolbox-server/internal/store"
|
||
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
var (
|
||
ErrSessionNotFound = &BusinessError{Code: ErrCodeSessionNotFound}
|
||
ErrSessionCreate = &BusinessError{Code: ErrCodeSessionCreate}
|
||
ErrDuplicateSubmit = &BusinessError{Code: ErrCodeDuplicateSubmit}
|
||
)
|
||
|
||
// Service 双验业务逻辑层
|
||
type Service struct {
|
||
store store.SessionStore
|
||
logger *zap.Logger
|
||
ttl time.Duration
|
||
}
|
||
|
||
func NewService(s store.SessionStore, logger *zap.Logger, ttl time.Duration) *Service {
|
||
return &Service{store: s, logger: logger, ttl: ttl}
|
||
}
|
||
|
||
// ── Create ──
|
||
|
||
func (s *Service) CreateSession(ctx context.Context, nickname, browserID string) (*CreateResponse, error) {
|
||
var sid string
|
||
var err error
|
||
for i := 0; i < 3; i++ {
|
||
sid, err = sessionid.Generate()
|
||
if err == nil {
|
||
err = s.store.Create(ctx, sid, nickname, browserID, s.ttl)
|
||
if err == nil {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if err != nil {
|
||
s.logger.Error("create session failed", zap.Error(err))
|
||
return nil, ErrSessionCreate
|
||
}
|
||
|
||
expiresAt := time.Now().Add(s.ttl)
|
||
s.logger.Info("session created",
|
||
zap.String("session_id", truncateSessionID(sid)),
|
||
zap.String("nickname", nickname),
|
||
zap.Time("expires_at", expiresAt),
|
||
)
|
||
return &CreateResponse{
|
||
SessionID: sid,
|
||
Nickname: nickname,
|
||
ExpiresAt: expiresAt,
|
||
// -1 表示"由发起方在线决定生命周期",Redis TTL 只是兜底
|
||
TTL: -1,
|
||
}, nil
|
||
}
|
||
|
||
// ── Submit(发起方 or 参与方) ──
|
||
|
||
func (s *Service) Submit(ctx context.Context, req *SubmitRequest) (*SubmitResponse, error) {
|
||
// 读 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 = "" // 显式释放明文引用
|
||
|
||
switch req.Role {
|
||
case "initiator":
|
||
return s.submitInitiator(ctx, req.SessionID, req.Nickname, hashHex, originalText)
|
||
case "participant":
|
||
return s.submitParticipant(ctx, req.SessionID, req.Nickname, hashHex, originalText)
|
||
default:
|
||
return nil, &BusinessError{Code: ErrCodeInvalidRole}
|
||
}
|
||
}
|
||
|
||
func (s *Service) submitInitiator(ctx context.Context, sessionID, nickname, hashHex, originalText string) (*SubmitResponse, error) {
|
||
if err := s.store.SubmitInitiator(ctx, sessionID, nickname, hashHex, originalText); err != nil {
|
||
if errors.Is(err, store.ErrSessionNotFound) {
|
||
return nil, ErrSessionNotFound
|
||
}
|
||
s.logger.Error("submit initiator failed", zap.Error(err),
|
||
zap.String("session_id", truncateSessionID(sessionID)))
|
||
return nil, fmt.Errorf("submit initiator: %w", err)
|
||
}
|
||
s.logger.Info("initiator submitted",
|
||
zap.String("session_id", truncateSessionID(sessionID)),
|
||
zap.String("nickname", nickname),
|
||
)
|
||
return &SubmitResponse{Submitted: true, InitiatorReady: true}, nil
|
||
}
|
||
|
||
func (s *Service) submitParticipant(ctx context.Context, sessionID, nickname, hashHex, originalText string) (*SubmitResponse, error) {
|
||
initReady, match, err := s.store.SubmitParticipant(ctx, sessionID, nickname, hashHex, originalText)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, store.ErrSessionNotFound):
|
||
return nil, ErrSessionNotFound
|
||
case errors.Is(err, store.ErrDuplicateSubmit):
|
||
return nil, ErrDuplicateSubmit
|
||
default:
|
||
s.logger.Error("submit participant failed", zap.Error(err),
|
||
zap.String("session_id", truncateSessionID(sessionID)),
|
||
zap.String("nickname", nickname),
|
||
)
|
||
return nil, fmt.Errorf("submit participant: %w", err)
|
||
}
|
||
}
|
||
|
||
s.logger.Info("participant submitted",
|
||
zap.String("session_id", truncateSessionID(sessionID)),
|
||
zap.String("nickname", nickname),
|
||
zap.Bool("initiator_ready", initReady),
|
||
)
|
||
return &SubmitResponse{
|
||
Submitted: true,
|
||
InitiatorReady: initReady,
|
||
Match: match,
|
||
}, nil
|
||
}
|
||
|
||
// ── Join ──
|
||
|
||
// 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)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrSessionNotFound) {
|
||
return nil, "", ErrSessionNotFound
|
||
}
|
||
return nil, "", err
|
||
}
|
||
|
||
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) {
|
||
return nil, "", ErrSessionNotFound
|
||
}
|
||
return nil, "", err
|
||
}
|
||
|
||
sess, err := 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 返回全量状态 ──
|
||
|
||
func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionResponse, error) {
|
||
snap, err := s.store.Get(ctx, sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrSessionNotFound) {
|
||
return nil, ErrSessionNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
parts := make([]ParticipantResp, 0, len(snap.Participants))
|
||
for _, p := range snap.Participants {
|
||
parts = append(parts, ParticipantResp{
|
||
Nickname: p.Nickname,
|
||
Joined: true,
|
||
Submitted: p.Submitted,
|
||
Match: p.Match,
|
||
})
|
||
}
|
||
return &SessionResponse{
|
||
SessionID: sessionID,
|
||
InitiatorNickname: snap.InitiatorNickname,
|
||
InitiatorReady: snap.InitiatorSubmitted,
|
||
AllowResubmit: snap.AllowResubmit,
|
||
IgnoreWs: snap.IgnoreWs,
|
||
Participants: parts,
|
||
// -1 表示会话由发起方在线决定生命周期
|
||
TTLSeconds: -1,
|
||
}, nil
|
||
}
|
||
|
||
// ── CheckSession(加入前预检查) ──
|
||
|
||
func (s *Service) CheckSession(ctx context.Context, sessionID string) (*CheckResponse, error) {
|
||
snap, err := s.store.Get(ctx, sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrSessionNotFound) {
|
||
return &CheckResponse{Exists: false}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
return &CheckResponse{
|
||
Exists: true,
|
||
InitiatorReady: snap.InitiatorSubmitted,
|
||
AllowResubmit: snap.AllowResubmit,
|
||
IgnoreWs: snap.IgnoreWs,
|
||
ParticipantCount: len(snap.Participants),
|
||
// -1 表示会话由发起方在线决定生命周期
|
||
TTLSeconds: -1,
|
||
}, nil
|
||
}
|
||
|
||
// ── SetResubmit 发起方切换"允许参与者重新提交"开关 ──
|
||
|
||
func (s *Service) SetResubmit(ctx context.Context, sessionID string, allow bool) (*SessionResponse, error) {
|
||
if err := s.store.SetResubmit(ctx, sessionID, allow); err != nil {
|
||
if errors.Is(err, store.ErrSessionNotFound) {
|
||
return nil, ErrSessionNotFound
|
||
}
|
||
s.logger.Error("set resubmit failed", zap.Error(err),
|
||
zap.String("session_id", truncateSessionID(sessionID)))
|
||
return nil, fmt.Errorf("set resubmit: %w", err)
|
||
}
|
||
s.logger.Info("allow_resubmit changed",
|
||
zap.String("session_id", truncateSessionID(sessionID)),
|
||
zap.Bool("allow", allow),
|
||
)
|
||
return s.GetSession(ctx, sessionID)
|
||
}
|
||
|
||
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 完成) ──
|
||
|
||
func (s *Service) EndSession(ctx context.Context, sessionID string) error {
|
||
if err := s.store.Delete(ctx, sessionID); err != nil {
|
||
s.logger.Error("end session failed", zap.Error(err),
|
||
zap.String("session_id", truncateSessionID(sessionID)))
|
||
return fmt.Errorf("end session: %w", err)
|
||
}
|
||
s.logger.Info("session ended",
|
||
zap.String("session_id", truncateSessionID(sessionID)),
|
||
)
|
||
return nil
|
||
}
|
||
|
||
// ── 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 {
|
||
if len(id) > 8 {
|
||
return id[:8] + "..."
|
||
}
|
||
return id
|
||
}
|