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 }