beta v0.2 dualcheck
This commit is contained in:
1 parent
c137299016
commit
1ec68bacf5
14 files changed
+1591
-359
No files matched your search
@@ -3,6 +3,7 @@ package dual_check
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
pkgws "cancerai.top/toolbox-server/internal/pkg/websocket"
|
||||
@@ -38,7 +39,11 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
resp, err := h.service.CreateSession(c.Request.Context(), req.Nickname)
|
||||
if err := validateNickname(req.Nickname); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := h.service.CreateSession(c.Request.Context(), req.Nickname, req.BrowserID)
|
||||
if err != nil {
|
||||
var be *BusinessError
|
||||
if errors.As(err, &be) {
|
||||
@@ -64,6 +69,10 @@ func (h *Handler) Submit(c *gin.Context) {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
if err := validateNickname(req.Nickname); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Text) > h.maxLen {
|
||||
response.BadRequest(c, "text too long")
|
||||
return
|
||||
@@ -82,37 +91,33 @@ func (h *Handler) Submit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 广播事件到房间内所有连接(包括发起方和其他参与者)
|
||||
// 广播事件到房间内所有连接
|
||||
switch req.Role {
|
||||
case "initiator":
|
||||
// 发起方刚提交:触发全量快照推送(所有参与者都需要更新)
|
||||
sess, _ := h.service.GetSession(c.Request.Context(), req.SessionID)
|
||||
if sess != nil {
|
||||
h.hub.Broadcast(req.SessionID, response.WSFullState(sess))
|
||||
}
|
||||
case "participant":
|
||||
// 参与者刚提交:推送 participant_submitted 事件
|
||||
// 发起方需要更新该参与者的提交状态 + 可能的 match 结果
|
||||
h.hub.Broadcast(req.SessionID, response.WSParticipantSubmitted(req.Nickname, resp))
|
||||
}
|
||||
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
// Join POST /api/v1/dual-check/join — 参与者加入(登记昵称)
|
||||
// Join POST /api/v1/dual-check/join
|
||||
func (h *Handler) Join(c *gin.Context) {
|
||||
var req JoinRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
sessionID := c.Param("session_id")
|
||||
if sessionID == "" {
|
||||
response.BadRequest(c, "session_id is required")
|
||||
if err := validateNickname(req.Nickname); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.service.JoinParticipant(c.Request.Context(), sessionID, req.Nickname)
|
||||
sess, actualNick, err := h.service.JoinParticipant(c.Request.Context(), req.SessionID, req.Nickname, req.BrowserID)
|
||||
if err != nil {
|
||||
var be *BusinessError
|
||||
if errors.As(err, &be) {
|
||||
@@ -124,16 +129,27 @@ func (h *Handler) Join(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 广播 participant_joined 事件
|
||||
h.hub.Broadcast(sessionID, response.WSParticipantJoined(req.Nickname))
|
||||
h.hub.Broadcast(req.SessionID, response.WSParticipantJoined(actualNick))
|
||||
|
||||
if actualNick != req.Nickname {
|
||||
response.Success(c, gin.H{
|
||||
"session": sess,
|
||||
"actual_nickname": actualNick,
|
||||
"renamed": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
response.Success(c, sess)
|
||||
}
|
||||
|
||||
// GetSession GET /api/v1/dual-check/session/:session_id
|
||||
// GetSession POST /api/v1/dual-check/session
|
||||
func (h *Handler) GetSession(c *gin.Context) {
|
||||
sessionID := c.Param("session_id")
|
||||
sess, err := h.service.GetSession(c.Request.Context(), sessionID)
|
||||
var req SessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
sess, err := h.service.GetSession(c.Request.Context(), req.SessionID)
|
||||
if err != nil {
|
||||
var be *BusinessError
|
||||
if errors.As(err, &be) && be.Code == ErrCodeSessionNotFound {
|
||||
@@ -146,11 +162,14 @@ func (h *Handler) GetSession(c *gin.Context) {
|
||||
response.Success(c, sess)
|
||||
}
|
||||
|
||||
// CheckSession GET /api/v1/dual-check/check/:session_id
|
||||
// 前端加入前预校验
|
||||
// CheckSession POST /api/v1/dual-check/check
|
||||
func (h *Handler) CheckSession(c *gin.Context) {
|
||||
sessionID := c.Param("session_id")
|
||||
info, err := h.service.CheckSession(c.Request.Context(), sessionID)
|
||||
var req SessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
info, err := h.service.CheckSession(c.Request.Context(), req.SessionID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "check session failed")
|
||||
return
|
||||
@@ -162,21 +181,15 @@ func (h *Handler) CheckSession(c *gin.Context) {
|
||||
response.Success(c, info)
|
||||
}
|
||||
|
||||
// SetResubmit POST /api/v1/dual-check/session/:session_id/resubmit
|
||||
// 发起方切换"允许参与者重新提交"开关
|
||||
// SetResubmit POST /api/v1/dual-check/resubmit
|
||||
func (h *Handler) SetResubmit(c *gin.Context) {
|
||||
sessionID := c.Param("session_id")
|
||||
if sessionID == "" {
|
||||
response.BadRequest(c, "session_id is required")
|
||||
return
|
||||
}
|
||||
var req SetResubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.service.SetResubmit(c.Request.Context(), sessionID, req.Allow)
|
||||
sess, err := h.service.SetResubmit(c.Request.Context(), req.SessionID, req.Allow)
|
||||
if err != nil {
|
||||
var be *BusinessError
|
||||
if errors.As(err, &be) {
|
||||
@@ -188,26 +201,49 @@ func (h *Handler) SetResubmit(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 广播开关变化给所有参与者
|
||||
h.hub.Broadcast(sessionID, response.WSResubmitChanged(req.Allow))
|
||||
h.hub.Broadcast(req.SessionID, response.WSResubmitChanged(req.Allow))
|
||||
|
||||
response.Success(c, sess)
|
||||
}
|
||||
|
||||
// EndSession POST /api/v1/dual-check/session/:session_id/end
|
||||
// 发起方主动结束会话:先广播 session_ended,再 Delete Redis key
|
||||
// SetIgnoreWs POST /api/v1/dual-check/ignore-ws
|
||||
func (h *Handler) SetIgnoreWs(c *gin.Context) {
|
||||
var req SetIgnoreWsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.service.SetIgnoreWs(c.Request.Context(), req.SessionID, req.Ignore)
|
||||
if err != nil {
|
||||
var be *BusinessError
|
||||
if errors.As(err, &be) {
|
||||
response.Fail(c, be.Code, be.Error())
|
||||
return
|
||||
}
|
||||
h.logger.Error("set ignore_ws error", zap.Error(err))
|
||||
response.InternalError(c, "set ignore_ws failed")
|
||||
return
|
||||
}
|
||||
|
||||
h.hub.Broadcast(req.SessionID, response.WSIgnoreWsChanged(req.Ignore))
|
||||
|
||||
response.Success(c, sess)
|
||||
}
|
||||
|
||||
// EndSession POST /api/v1/dual-check/end
|
||||
func (h *Handler) EndSession(c *gin.Context) {
|
||||
sessionID := c.Param("session_id")
|
||||
if sessionID == "" {
|
||||
response.BadRequest(c, "session_id is required")
|
||||
var req SessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
|
||||
return
|
||||
}
|
||||
|
||||
// 先广播,让所有参与方 UI 都弹提示
|
||||
h.hub.Broadcast(sessionID, response.WSSessionEnded())
|
||||
h.hub.Broadcast(req.SessionID, response.WSSessionEnded())
|
||||
|
||||
// 再删除数据
|
||||
if err := h.service.EndSession(c.Request.Context(), sessionID); err != nil {
|
||||
if err := h.service.EndSession(c.Request.Context(), req.SessionID); err != nil {
|
||||
h.logger.Error("end session error", zap.Error(err))
|
||||
response.InternalError(c, "end session failed")
|
||||
return
|
||||
@@ -216,5 +252,16 @@ func (h *Handler) EndSession(c *gin.Context) {
|
||||
response.Success(c, gin.H{"ended": true})
|
||||
}
|
||||
|
||||
// 占位,避免 http.StatusOK 未使用
|
||||
// validateNickname 统一的昵称校验:字符数 1-32(按 Unicode rune)
|
||||
func validateNickname(n string) error {
|
||||
n = strings.TrimSpace(n)
|
||||
if utf8.RuneCountInString(n) == 0 {
|
||||
return errors.New("nickname is required")
|
||||
}
|
||||
if utf8.RuneCountInString(n) > 32 {
|
||||
return errors.New("nickname must be 1-32 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = http.StatusOK
|
||||
@@ -8,7 +8,8 @@ import "time"
|
||||
// ── 创建会话 ──
|
||||
|
||||
type CreateRequest struct {
|
||||
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
|
||||
Nickname string `json:"nickname" binding:"required,min=1"`
|
||||
BrowserID string `json:"browser_id"`
|
||||
}
|
||||
|
||||
// CreateResponse 创建成功
|
||||
@@ -23,7 +24,7 @@ type CreateResponse struct {
|
||||
|
||||
type SubmitRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
|
||||
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
|
||||
Nickname string `json:"nickname" binding:"required,min=1"`
|
||||
Role string `json:"role" binding:"required,oneof=initiator participant"`
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
@@ -38,7 +39,9 @@ type SubmitResponse struct {
|
||||
// ── 参与者加入 ──
|
||||
|
||||
type JoinRequest struct {
|
||||
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
|
||||
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
|
||||
Nickname string `json:"nickname" binding:"required,min=1"`
|
||||
BrowserID string `json:"browser_id"`
|
||||
}
|
||||
|
||||
// JoinResponse 返回当前会话全量状态,供参与者初始化 UI
|
||||
@@ -50,12 +53,18 @@ type JoinResponse struct {
|
||||
|
||||
// ── 会话全量状态(发起方拉取 / 发起方 WS 初始化) ──
|
||||
|
||||
// SessionRequest 统一的 session_id 请求体
|
||||
type SessionRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
|
||||
}
|
||||
|
||||
// SessionResponse 发起方看到的会话状态(完整参与者列表)
|
||||
type SessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
InitiatorNickname string `json:"initiator_nickname"`
|
||||
InitiatorReady bool `json:"initiator_ready"`
|
||||
AllowResubmit bool `json:"allow_resubmit"`
|
||||
IgnoreWs bool `json:"ignore_ws"`
|
||||
Participants []ParticipantResp `json:"participants"`
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
}
|
||||
@@ -74,6 +83,7 @@ type CheckResponse struct {
|
||||
Exists bool `json:"exists"`
|
||||
InitiatorReady bool `json:"initiator_ready"`
|
||||
AllowResubmit bool `json:"allow_resubmit"`
|
||||
IgnoreWs bool `json:"ignore_ws"`
|
||||
ParticipantCount int `json:"participant_count"`
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
}
|
||||
@@ -82,5 +92,12 @@ type CheckResponse struct {
|
||||
|
||||
// SetResubmitRequest 发起方切换"允许参与者重新提交"开关
|
||||
type SetResubmitRequest struct {
|
||||
Allow bool `json:"allow"`
|
||||
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
|
||||
Allow bool `json:"allow"`
|
||||
}
|
||||
|
||||
// SetIgnoreWsRequest 发起方切换"忽略首尾空白"开关
|
||||
type SetIgnoreWsRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
|
||||
Ignore bool `json:"ignore"`
|
||||
}
|
||||
@@ -25,14 +25,15 @@ func Register(
|
||||
h := NewHandler(svc, hub, logger, maxTextLen)
|
||||
ws := NewWSHandler(svc, hub, logger)
|
||||
|
||||
// REST API
|
||||
group.POST("/create", h.Create) // 发起方创建会话
|
||||
group.POST("/submit", h.Submit) // 发起方或参与者提交文本
|
||||
group.POST("/session/:session_id/join", h.Join) // 参与者加入(登记昵称)
|
||||
group.POST("/session/:session_id/resubmit", h.SetResubmit) // 发起方切换"允许重提交"开关
|
||||
group.POST("/session/:session_id/end", h.EndSession) // 发起方主动结束会话
|
||||
group.GET("/session/:session_id", h.GetSession) // 拉全量状态
|
||||
group.GET("/check/:session_id", h.CheckSession) // 加入前预校验
|
||||
// REST API — 所有 session_id 走 POST body
|
||||
group.POST("/create", h.Create) // 发起方创建会话
|
||||
group.POST("/submit", h.Submit) // 发起方或参与者提交文本
|
||||
group.POST("/join", h.Join) // 参与者加入(登记昵称)
|
||||
group.POST("/resubmit", h.SetResubmit) // 发起方切换"允许重提交"开关
|
||||
group.POST("/ignore-ws", h.SetIgnoreWs) // 发起方切换"忽略首尾空白"开关
|
||||
group.POST("/end", h.EndSession) // 发起方主动结束会话
|
||||
group.POST("/session", h.GetSession) // 拉全量状态
|
||||
group.POST("/check", h.CheckSession) // 加入前预校验
|
||||
|
||||
// WebSocket 实时推送(带 role + nickname 查询参数)
|
||||
group.GET("/ws", ws.Handle)
|
||||
|
||||
@@ -2,9 +2,13 @@ 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"
|
||||
@@ -32,13 +36,13 @@ func NewService(s store.SessionStore, logger *zap.Logger, ttl time.Duration) *Se
|
||||
|
||||
// ── Create ──
|
||||
|
||||
func (s *Service) CreateSession(ctx context.Context, nickname string) (*CreateResponse, error) {
|
||||
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, s.ttl)
|
||||
err = s.store.Create(ctx, sid, nickname, browserID, s.ttl)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
@@ -59,28 +63,43 @@ func (s *Service) CreateSession(ctx context.Context, nickname string) (*CreateRe
|
||||
SessionID: sid,
|
||||
Nickname: nickname,
|
||||
ExpiresAt: expiresAt,
|
||||
TTL: int(s.ttl.Seconds()),
|
||||
// -1 表示"由发起方在线决定生命周期",Redis TTL 只是兜底
|
||||
TTL: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Submit(发起方 or 参与方) ──
|
||||
|
||||
func (s *Service) Submit(ctx context.Context, req *SubmitRequest) (*SubmitResponse, error) {
|
||||
hashHex := hash.SHA256(req.Text)
|
||||
// 读 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)
|
||||
return s.submitInitiator(ctx, req.SessionID, req.Nickname, hashHex, originalText)
|
||||
case "participant":
|
||||
return s.submitParticipant(ctx, req.SessionID, req.Nickname, hashHex)
|
||||
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 string) (*SubmitResponse, error) {
|
||||
if err := s.store.SubmitInitiator(ctx, sessionID, nickname, hashHex); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -95,8 +114,8 @@ func (s *Service) submitInitiator(ctx context.Context, sessionID, nickname, hash
|
||||
return &SubmitResponse{Submitted: true, InitiatorReady: true}, nil
|
||||
}
|
||||
|
||||
func (s *Service) submitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (*SubmitResponse, error) {
|
||||
initReady, match, err := s.store.SubmitParticipant(ctx, sessionID, nickname, hashHex)
|
||||
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):
|
||||
@@ -126,28 +145,111 @@ func (s *Service) submitParticipant(ctx context.Context, sessionID, nickname, ha
|
||||
|
||||
// ── Join ──
|
||||
|
||||
func (s *Service) JoinParticipant(ctx context.Context, sessionID, nickname string) (*SessionResponse, error) {
|
||||
// 检查昵称是否等于发起方昵称(不允许用发起方的昵称作为参与者)
|
||||
// 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, "", ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if snap.InitiatorNickname != "" && snap.InitiatorNickname == nickname {
|
||||
return nil, &BusinessError{Code: ErrCodeNicknameExists}
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if err := s.store.JoinParticipant(ctx, sessionID, nickname); err != nil {
|
||||
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, "", ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// 返回加入后的最新状态
|
||||
return s.GetSession(ctx, sessionID)
|
||||
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 返回全量状态 ──
|
||||
@@ -160,7 +262,6 @@ func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionRes
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ttl := time.Until(snap.ExpiresAt)
|
||||
parts := make([]ParticipantResp, 0, len(snap.Participants))
|
||||
for _, p := range snap.Participants {
|
||||
parts = append(parts, ParticipantResp{
|
||||
@@ -175,8 +276,10 @@ func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionRes
|
||||
InitiatorNickname: snap.InitiatorNickname,
|
||||
InitiatorReady: snap.InitiatorSubmitted,
|
||||
AllowResubmit: snap.AllowResubmit,
|
||||
IgnoreWs: snap.IgnoreWs,
|
||||
Participants: parts,
|
||||
TTLSeconds: int64(ttl.Seconds()),
|
||||
// -1 表示会话由发起方在线决定生命周期
|
||||
TTLSeconds: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -190,16 +293,14 @@ func (s *Service) CheckSession(ctx context.Context, sessionID string) (*CheckRes
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ttl := time.Until(snap.ExpiresAt)
|
||||
if ttl < 0 {
|
||||
return &CheckResponse{Exists: false}, nil
|
||||
}
|
||||
return &CheckResponse{
|
||||
Exists: true,
|
||||
InitiatorReady: snap.InitiatorSubmitted,
|
||||
AllowResubmit: snap.AllowResubmit,
|
||||
IgnoreWs: snap.IgnoreWs,
|
||||
ParticipantCount: len(snap.Participants),
|
||||
TTLSeconds: int64(ttl.Seconds()),
|
||||
// -1 表示会话由发起方在线决定生命周期
|
||||
TTLSeconds: -1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -221,6 +322,80 @@ func (s *Service) SetResubmit(ctx context.Context, sessionID string, allow bool)
|
||||
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 {
|
||||
@@ -235,6 +410,25 @@ func (s *Service) EndSession(ctx context.Context, sessionID string) error {
|
||||
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] + "..."
|
||||
|
||||
@@ -3,8 +3,11 @@ package dual_check
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
pkgws "cancerai.top/toolbox-server/internal/pkg/websocket"
|
||||
"cancerai.top/toolbox-server/internal/response"
|
||||
@@ -35,6 +38,7 @@ func (h *WSHandler) Handle(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
role := c.Query("role")
|
||||
nickname := c.Query("nickname")
|
||||
browserID := c.Query("browser_id")
|
||||
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "session_id required"})
|
||||
@@ -44,7 +48,7 @@ func (h *WSHandler) Handle(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1004, "message": "role must be initiator or participant"})
|
||||
return
|
||||
}
|
||||
if nickname == "" || len(nickname) > 32 {
|
||||
if nickname == "" || utf8.RuneCountInString(nickname) > 32 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "nickname required (1-32)"})
|
||||
return
|
||||
}
|
||||
@@ -71,11 +75,27 @@ func (h *WSHandler) Handle(c *gin.Context) {
|
||||
}
|
||||
h.hub.Register(wsConn)
|
||||
|
||||
// 参与者首次连接:自动登记昵称(幂等)
|
||||
// 参与者首次连接:自动登记昵称(冲突时自动改名)
|
||||
actualNick := nickname
|
||||
if role == "participant" {
|
||||
_, _ = h.service.JoinParticipant(c.Request.Context(), sessionID, nickname)
|
||||
// 广播 participant_joined 给房间其他人
|
||||
h.hub.Broadcast(sessionID, response.WSParticipantJoined(nickname))
|
||||
_, actual, err := h.service.JoinParticipant(c.Request.Context(), sessionID, nickname, browserID)
|
||||
if err == nil {
|
||||
actualNick = actual
|
||||
if actualNick != nickname {
|
||||
// 自动改名:通知客户端 + 用 actualNick 广播
|
||||
wsConn.Send <- marshalMsg(response.WSNicknameAutoChanged(actualNick))
|
||||
h.logger.Info("ws participant auto-renamed",
|
||||
zap.String("session_id", truncateSessionID(sessionID)),
|
||||
zap.String("original", nickname),
|
||||
zap.String("actual", actualNick))
|
||||
}
|
||||
h.hub.Broadcast(sessionID, response.WSParticipantJoined(actualNick))
|
||||
} else {
|
||||
h.logger.Warn("ws JoinParticipant failed",
|
||||
zap.String("session_id", truncateSessionID(sessionID)),
|
||||
zap.String("nickname", nickname),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 推一个 init 消息给新连接者,带上当前全量快照让它自己初始化 UI
|
||||
@@ -91,10 +111,13 @@ func (h *WSHandler) Handle(c *gin.Context) {
|
||||
|
||||
go pkgws.WritePump(wsConn)
|
||||
// ReadPump 里的 Unregister 会触发 participant_left 广播
|
||||
go h.readLoop(wsConn, nickname)
|
||||
go h.readLoop(wsConn, actualNick)
|
||||
}
|
||||
|
||||
func (h *WSHandler) readLoop(c *pkgws.Conn, nickname string) {
|
||||
func (h *WSHandler) readLoop(c *pkgws.Conn, initialNickname string) {
|
||||
// currentNick 是可变的,rename 成功后更新,保证 defer 中的 participant_left 广播用最新昵称
|
||||
currentNick := initialNickname
|
||||
|
||||
defer func() {
|
||||
c.WS.Close() // 先关,让 WritePump 也能退
|
||||
if c.Role == "initiator" {
|
||||
@@ -107,19 +130,87 @@ func (h *WSHandler) readLoop(c *pkgws.Conn, nickname string) {
|
||||
} else {
|
||||
h.logger.Info("initiator disconnected, session ended",
|
||||
zap.String("session_id", truncateSessionID(c.SessionID)),
|
||||
zap.String("nickname", nickname))
|
||||
zap.String("nickname", currentNick))
|
||||
}
|
||||
} else {
|
||||
// 参与者断开:广播 participant_left
|
||||
h.hub.Broadcast(c.SessionID, response.WSParticipantLeft(nickname))
|
||||
// 参与者断开:先从 Redis 删除字段,再广播 participant_left
|
||||
if err := h.service.RemoveParticipant(context.Background(), c.SessionID, currentNick); err != nil {
|
||||
h.logger.Warn("participant RemoveParticipant err",
|
||||
zap.String("session_id", truncateSessionID(c.SessionID)),
|
||||
zap.String("nickname", currentNick),
|
||||
zap.Error(err))
|
||||
}
|
||||
h.logger.Info("participant disconnected",
|
||||
zap.String("session_id", truncateSessionID(c.SessionID)),
|
||||
zap.String("nickname", currentNick))
|
||||
h.hub.Broadcast(c.SessionID, response.WSParticipantLeft(currentNick))
|
||||
}
|
||||
c.Hub.Unregister(c) // 最后从房间移除
|
||||
}()
|
||||
|
||||
c.WS.SetPongHandler(func(string) error { return nil })
|
||||
for {
|
||||
if _, _, err := c.WS.ReadMessage(); err != nil {
|
||||
_, raw, err := c.WS.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析客户端发来的消息
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
continue // 不是合法 JSON,忽略
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "rename":
|
||||
// 只有参与者允许改昵称
|
||||
if c.Role != "participant" {
|
||||
h.sendError(c, "rename not allowed for role "+c.Role)
|
||||
continue
|
||||
}
|
||||
var data struct {
|
||||
NewNickname string `json:"new_nickname"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &data); err != nil || strings.TrimSpace(data.NewNickname) == "" || utf8.RuneCountInString(data.NewNickname) > 32 {
|
||||
h.sendError(c, "invalid new_nickname")
|
||||
continue
|
||||
}
|
||||
newNick := strings.TrimSpace(data.NewNickname)
|
||||
if newNick == currentNick {
|
||||
continue // 无变化
|
||||
}
|
||||
err := h.service.UpdateParticipantNickname(context.Background(), c.SessionID, currentNick, newNick)
|
||||
if err != nil {
|
||||
var be *BusinessError
|
||||
if errors.As(err, &be) {
|
||||
h.sendError(c, be.Error()) // 优先用自定义 Message,否则回退到 ErrMessage(code)
|
||||
} else {
|
||||
h.sendError(c, "rename failed: "+err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 成功:更新本地昵称 + 广播给房间其他人
|
||||
oldNick := currentNick
|
||||
currentNick = newNick
|
||||
h.hub.Broadcast(c.SessionID, response.WSParticipantRenamed(oldNick, newNick))
|
||||
h.logger.Info("ws rename success",
|
||||
zap.String("session_id", truncateSessionID(c.SessionID)),
|
||||
zap.String("old", oldNick),
|
||||
zap.String("new", newNick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendError 通过 WS 给客户端发送一个 error 消息
|
||||
func (h *WSHandler) sendError(c *pkgws.Conn, errMsg string) {
|
||||
msg := response.WebSocketMessage{Type: "error", Error: errMsg}
|
||||
select {
|
||||
case c.Send <- marshalMsg(msg):
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user