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

438 lines
14 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 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
}