dual check v0.1

This commit is contained in:
cancerai committed 2026-08-29 23:34:01 +08:00
1 parent 78bad1fa3d
commit c137299016
36 files changed
+3727 -81

No files matched your search

@@ -0,0 +1,15 @@
package dual_check
// BusinessError 业务错误
type BusinessError struct {
Code int
Message string
}
// Error 返回错误描述:优先用自定义 Message,否则回退到 ErrMessage(code)
func (e *BusinessError) Error() string {
if e.Message != "" {
return e.Message
}
return ErrMessage(e.Code)
}
@@ -0,0 +1,31 @@
package dual_check
// 业务错误码
const (
ErrCodeInvalidParam = 4000
ErrCodeSessionNotFound = 1001
ErrCodeSessionExpired = 1002
ErrCodeDuplicateSubmit = 1003
ErrCodeInvalidRole = 1004
ErrCodeSessionCreate = 1005
ErrCodeNicknameExists = 1006 // 昵称已被其他人占用(WS 连接时发现)
ErrCodeNotInSession = 1007 // 该昵称未 join 会话就 submit
)
var errorMessages = map[int]string{
ErrCodeInvalidParam: "invalid parameter",
ErrCodeSessionNotFound: "会话码不存在或已过期",
ErrCodeSessionExpired: "session has expired",
ErrCodeDuplicateSubmit: "该昵称已提交过文本",
ErrCodeInvalidRole: "role must be 'initiator' or 'participant'",
ErrCodeSessionCreate: "failed to create session, please retry",
ErrCodeNicknameExists: "该昵称已在会话中,请换一个",
ErrCodeNotInSession: "该昵称未加入会话",
}
func ErrMessage(code int) string {
if msg, ok := errorMessages[code]; ok {
return msg
}
return "unknown error"
}
+220
View File
@@ -0,0 +1,220 @@
package dual_check
import (
"errors"
"net/http"
"unicode/utf8"
pkgws "cancerai.top/toolbox-server/internal/pkg/websocket"
"cancerai.top/toolbox-server/internal/response"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"go.uber.org/zap"
)
type Handler struct {
service *Service
hub *pkgws.Hub
logger *zap.Logger
maxLen int
validate *validator.Validate
}
func NewHandler(service *Service, hub *pkgws.Hub, logger *zap.Logger, maxTextLen int) *Handler {
return &Handler{
service: service,
hub: hub,
logger: logger,
maxLen: maxTextLen,
validate: validator.New(),
}
}
// Create POST /api/v1/dual-check/create
func (h *Handler) Create(c *gin.Context) {
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
return
}
resp, err := h.service.CreateSession(c.Request.Context(), req.Nickname)
if err != nil {
var be *BusinessError
if errors.As(err, &be) {
response.Fail(c, be.Code, be.Error())
return
}
h.logger.Error("create session error", zap.Error(err))
response.InternalError(c, "create failed")
return
}
response.Success(c, resp)
}
// Submit POST /api/v1/dual-check/submit
func (h *Handler) Submit(c *gin.Context) {
// ContentLength 保护:用 maxLen*4 覆盖 UTF-8 最大 4 字节/字符 + JSON 字段开销
if c.Request.ContentLength > int64(h.maxLen*4+2048) {
response.BadRequest(c, "text too long")
return
}
var req SubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, ErrMessage(ErrCodeInvalidParam))
return
}
if utf8.RuneCountInString(req.Text) > h.maxLen {
response.BadRequest(c, "text too long")
return
}
resp, err := h.service.Submit(c.Request.Context(), &req)
if err != nil {
var be *BusinessError
if errors.As(err, &be) {
response.Fail(c, be.Code, be.Error())
return
}
h.logger.Error("submit error", zap.Error(err),
zap.String("session_id", truncateSessionID(req.SessionID)))
response.InternalError(c, "submit failed")
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 — 参与者加入(登记昵称)
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")
return
}
sess, err := h.service.JoinParticipant(c.Request.Context(), sessionID, req.Nickname)
if err != nil {
var be *BusinessError
if errors.As(err, &be) {
response.Fail(c, be.Code, be.Error())
return
}
h.logger.Error("join participant error", zap.Error(err))
response.InternalError(c, "join failed")
return
}
// 广播 participant_joined 事件
h.hub.Broadcast(sessionID, response.WSParticipantJoined(req.Nickname))
response.Success(c, sess)
}
// GetSession GET /api/v1/dual-check/session/:session_id
func (h *Handler) GetSession(c *gin.Context) {
sessionID := c.Param("session_id")
sess, err := h.service.GetSession(c.Request.Context(), sessionID)
if err != nil {
var be *BusinessError
if errors.As(err, &be) && be.Code == ErrCodeSessionNotFound {
response.NotFound(c, be.Error())
return
}
response.InternalError(c, "get session failed")
return
}
response.Success(c, sess)
}
// CheckSession GET /api/v1/dual-check/check/:session_id
// 前端加入前预校验
func (h *Handler) CheckSession(c *gin.Context) {
sessionID := c.Param("session_id")
info, err := h.service.CheckSession(c.Request.Context(), sessionID)
if err != nil {
response.InternalError(c, "check session failed")
return
}
if !info.Exists {
response.Fail(c, ErrCodeSessionNotFound, ErrMessage(ErrCodeSessionNotFound))
return
}
response.Success(c, info)
}
// SetResubmit POST /api/v1/dual-check/session/:session_id/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)
if err != nil {
var be *BusinessError
if errors.As(err, &be) {
response.Fail(c, be.Code, be.Error())
return
}
h.logger.Error("set resubmit error", zap.Error(err))
response.InternalError(c, "set resubmit failed")
return
}
// 广播开关变化给所有参与者
h.hub.Broadcast(sessionID, response.WSResubmitChanged(req.Allow))
response.Success(c, sess)
}
// EndSession POST /api/v1/dual-check/session/:session_id/end
// 发起方主动结束会话:先广播 session_ended,再 Delete Redis key
func (h *Handler) EndSession(c *gin.Context) {
sessionID := c.Param("session_id")
if sessionID == "" {
response.BadRequest(c, "session_id is required")
return
}
// 先广播,让所有参与方 UI 都弹提示
h.hub.Broadcast(sessionID, response.WSSessionEnded())
// 再删除数据
if err := h.service.EndSession(c.Request.Context(), sessionID); err != nil {
h.logger.Error("end session error", zap.Error(err))
response.InternalError(c, "end session failed")
return
}
response.Success(c, gin.H{"ended": true})
}
// 占位,避免 http.StatusOK 未使用
var _ = http.StatusOK
+86
View File
@@ -0,0 +1,86 @@
// Package dual_check 双验工具后端实现
// 一个发起方(initiator)+ 多个参与方(participant)
// 发起方提交文本作为"标准答案",每个参与方各自提交文本后与发起方比对
package dual_check
import "time"
// ── 创建会话 ──
type CreateRequest struct {
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
}
// CreateResponse 创建成功
type CreateResponse struct {
SessionID string `json:"session_id"`
Nickname string `json:"nickname"`
ExpiresAt time.Time `json:"expires_at"`
TTL int `json:"ttl_seconds"`
}
// ── 提交文本(通用:发起方 & 参与方) ──
type SubmitRequest struct {
SessionID string `json:"session_id" binding:"required,min=8,max=64"`
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
Role string `json:"role" binding:"required,oneof=initiator participant"`
Text string `json:"text" binding:"required"`
}
// SubmitResponse 参与者提交后的结果(如果发起方已提交则 match 已算出)
type SubmitResponse struct {
Submitted bool `json:"submitted"`
InitiatorReady bool `json:"initiator_ready"` // 发起方是否已提交标准答案
Match *bool `json:"match,omitempty"` // 发起方已提交时立即有值
}
// ── 参与者加入 ──
type JoinRequest struct {
Nickname string `json:"nickname" binding:"required,min=1,max=32"`
}
// JoinResponse 返回当前会话全量状态,供参与者初始化 UI
type JoinResponse struct {
InitiatorNickname string `json:"initiator_nickname,omitempty"`
InitiatorReady bool `json:"initiator_ready"`
Participants []ParticipantResp `json:"participants"`
}
// ── 会话全量状态(发起方拉取 / 发起方 WS 初始化) ──
// SessionResponse 发起方看到的会话状态(完整参与者列表)
type SessionResponse struct {
SessionID string `json:"session_id"`
InitiatorNickname string `json:"initiator_nickname"`
InitiatorReady bool `json:"initiator_ready"`
AllowResubmit bool `json:"allow_resubmit"`
Participants []ParticipantResp `json:"participants"`
TTLSeconds int64 `json:"ttl_seconds"`
}
// ParticipantResp 单个参与者的状态
type ParticipantResp struct {
Nickname string `json:"nickname"`
Joined bool `json:"joined"`
Submitted bool `json:"submitted"`
Match *bool `json:"match,omitempty"`
}
// ── 会话校验(加入前预检查) ──
type CheckResponse struct {
Exists bool `json:"exists"`
InitiatorReady bool `json:"initiator_ready"`
AllowResubmit bool `json:"allow_resubmit"`
ParticipantCount int `json:"participant_count"`
TTLSeconds int64 `json:"ttl_seconds"`
}
// ── 发起方设置接口 ──
// SetResubmitRequest 发起方切换"允许参与者重新提交"开关
type SetResubmitRequest struct {
Allow bool `json:"allow"`
}
@@ -0,0 +1,39 @@
package dual_check
import (
"time"
"cancerai.top/toolbox-server/internal/pkg/websocket"
"cancerai.top/toolbox-server/internal/store"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Register 把 DualCheck 工具的所有路由注册到指定 RouterGroup
func Register(
group *gin.RouterGroup,
sessionStore store.SessionStore,
hub *websocket.Hub,
logger *zap.Logger,
ttlSeconds int,
maxTextLen int,
) {
ttl := time.Duration(ttlSeconds) * time.Second
svc := NewService(sessionStore, logger, ttl)
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) // 加入前预校验
// WebSocket 实时推送(带 role + nickname 查询参数)
group.GET("/ws", ws.Handle)
}
+243
View File
@@ -0,0 +1,243 @@
package dual_check
import (
"context"
"errors"
"fmt"
"time"
"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 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)
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,
TTL: int(s.ttl.Seconds()),
}, nil
}
// ── Submit(发起方 or 参与方) ──
func (s *Service) Submit(ctx context.Context, req *SubmitRequest) (*SubmitResponse, error) {
hashHex := hash.SHA256(req.Text)
req.Text = "" // 显式释放明文引用
switch req.Role {
case "initiator":
return s.submitInitiator(ctx, req.SessionID, req.Nickname, hashHex)
case "participant":
return s.submitParticipant(ctx, req.SessionID, req.Nickname, hashHex)
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 {
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 string) (*SubmitResponse, error) {
initReady, match, err := s.store.SubmitParticipant(ctx, sessionID, nickname, hashHex)
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 ──
func (s *Service) JoinParticipant(ctx context.Context, sessionID, nickname 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
}
if snap.InitiatorNickname != "" && snap.InitiatorNickname == nickname {
return nil, &BusinessError{Code: ErrCodeNicknameExists}
}
if err := s.store.JoinParticipant(ctx, sessionID, nickname); err != nil {
if errors.Is(err, store.ErrSessionNotFound) {
return nil, ErrSessionNotFound
}
return nil, err
}
// 返回加入后的最新状态
return s.GetSession(ctx, sessionID)
}
// ── 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
}
ttl := time.Until(snap.ExpiresAt)
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,
Participants: parts,
TTLSeconds: int64(ttl.Seconds()),
}, 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
}
ttl := time.Until(snap.ExpiresAt)
if ttl < 0 {
return &CheckResponse{Exists: false}, nil
}
return &CheckResponse{
Exists: true,
InitiatorReady: snap.InitiatorSubmitted,
AllowResubmit: snap.AllowResubmit,
ParticipantCount: len(snap.Participants),
TTLSeconds: int64(ttl.Seconds()),
}, 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)
}
// ── 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
}
func truncateSessionID(id string) string {
if len(id) > 8 {
return id[:8] + "..."
}
return id
}
@@ -0,0 +1,132 @@
package dual_check
import (
"context"
"encoding/json"
"fmt"
"net/http"
pkgws "cancerai.top/toolbox-server/internal/pkg/websocket"
"cancerai.top/toolbox-server/internal/response"
"github.com/gin-gonic/gin"
gorillaws "github.com/gorilla/websocket"
"go.uber.org/zap"
)
var upgrader = gorillaws.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
type WSHandler struct {
service *Service
hub *pkgws.Hub
logger *zap.Logger
}
func NewWSHandler(service *Service, hub *pkgws.Hub, logger *zap.Logger) *WSHandler {
return &WSHandler{service: service, hub: hub, logger: logger}
}
// Handle GET /api/v1/dual-check/ws?session_id=xxx&role=initiator|participant&nickname=xxx
func (h *WSHandler) Handle(c *gin.Context) {
sessionID := c.Query("session_id")
role := c.Query("role")
nickname := c.Query("nickname")
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "session_id required"})
return
}
if role != "initiator" && role != "participant" {
c.JSON(http.StatusBadRequest, gin.H{"code": 1004, "message": "role must be initiator or participant"})
return
}
if nickname == "" || len(nickname) > 32 {
c.JSON(http.StatusBadRequest, gin.H{"code": 4000, "message": "nickname required (1-32)"})
return
}
// 先校验 session 存在
info, err := h.service.CheckSession(c.Request.Context(), sessionID)
if err != nil || !info.Exists {
c.JSON(http.StatusNotFound, gin.H{"code": 1001, "message": "session not found"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
h.logger.Warn("ws upgrade failed", zap.Error(err))
return
}
wsConn := &pkgws.Conn{
Hub: h.hub,
SessionID: sessionID,
Role: role,
WS: conn,
Send: make(chan []byte, 256),
}
h.hub.Register(wsConn)
// 参与者首次连接:自动登记昵称(幂等)
if role == "participant" {
_, _ = h.service.JoinParticipant(c.Request.Context(), sessionID, nickname)
// 广播 participant_joined 给房间其他人
h.hub.Broadcast(sessionID, response.WSParticipantJoined(nickname))
}
// 推一个 init 消息给新连接者,带上当前全量快照让它自己初始化 UI
if sess, err := h.service.GetSession(c.Request.Context(), sessionID); err == nil {
wsConn.Send <- marshalMsg(response.WSFullState(sess))
}
h.logger.Info("ws connected",
zap.String("session_id", truncateSessionID(sessionID)),
zap.String("role", role),
zap.String("nickname", nickname),
)
go pkgws.WritePump(wsConn)
// ReadPump 里的 Unregister 会触发 participant_left 广播
go h.readLoop(wsConn, nickname)
}
func (h *WSHandler) readLoop(c *pkgws.Conn, nickname string) {
defer func() {
c.WS.Close() // 先关,让 WritePump 也能退
if c.Role == "initiator" {
// 发起方断开:立即结束会话 → 广播给所有参与者 → 删除 Redis
h.hub.Broadcast(c.SessionID, response.WSSessionEnded())
if err := h.service.EndSession(context.Background(), c.SessionID); err != nil {
h.logger.Warn("initiator disconnect end session err",
zap.String("session_id", truncateSessionID(c.SessionID)),
zap.Error(err))
} else {
h.logger.Info("initiator disconnected, session ended",
zap.String("session_id", truncateSessionID(c.SessionID)),
zap.String("nickname", nickname))
}
} else {
// 参与者断开:广播 participant_left
h.hub.Broadcast(c.SessionID, response.WSParticipantLeft(nickname))
}
c.Hub.Unregister(c) // 最后从房间移除
}()
c.WS.SetPongHandler(func(string) error { return nil })
for {
if _, _, err := c.WS.ReadMessage(); err != nil {
return
}
}
}
func marshalMsg(m response.WebSocketMessage) []byte {
b, _ := json.Marshal(m)
return b
}
// 确保 fmt/json 被引用
var _ = fmt.Sprintf