dual check v0.1
This commit is contained in:
1 parent
78bad1fa3d
commit
c137299016
36 files changed
+3727
-81
No files matched your search
@@ -0,0 +1,60 @@
|
||||
// Package middleware 提供 HTTP 中间件
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
// - 白名单 origin 精确匹配(生产用)
|
||||
// - 同时自动放行 http(s)://localhost:* 所有端口(开发期 Flutter Web/React Vite 随机端口)
|
||||
// - 不在范围内的请求不设置 Allow-Origin 头,浏览器会自行阻止
|
||||
func CORS(allowedOrigins []string) gin.HandlerFunc {
|
||||
originSet := make(map[string]bool, len(allowedOrigins))
|
||||
for _, o := range allowedOrigins {
|
||||
originSet[strings.TrimRight(o, "/")] = true
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimRight(c.GetHeader("Origin"), "/")
|
||||
if origin == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 是否允许:白名单精确匹配 或 localhost 通配
|
||||
allowed := originSet[origin] || isLocalhostOrigin(origin)
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
if allowed {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
|
||||
c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
|
||||
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if allowed {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Vary", "Origin")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isLocalhostOrigin 匹配 http://localhost:* 和 http://127.0.0.1:*
|
||||
// 开发期 Flutter Web / Vite / CRA 等工具常用随机端口
|
||||
func isLocalhostOrigin(origin string) bool {
|
||||
return strings.HasPrefix(origin, "http://localhost:") ||
|
||||
strings.HasPrefix(origin, "http://127.0.0.1:") ||
|
||||
strings.HasPrefix(origin, "http://[::1]:")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Logger 基于 zap 的请求日志中间件
|
||||
// 只记录 method/path/status/latency/request_id,**不记录请求体**(避免泄露用户输入)
|
||||
func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
latency := time.Since(start)
|
||||
|
||||
logger.Info("HTTP",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.Int("size", c.Writer.Size()),
|
||||
zap.String("remote_ip", c.ClientIP()),
|
||||
zap.String("user_agent", c.Request.UserAgent()),
|
||||
zap.String("request_id", c.GetString("request_id")),
|
||||
zap.Duration("latency", latency),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ulule/limiter/v3"
|
||||
"github.com/ulule/limiter/v3/drivers/store/memory"
|
||||
)
|
||||
|
||||
// RateLimitConfig 限流配置
|
||||
type RateLimitConfig struct {
|
||||
GlobalRPS float64
|
||||
PerIPRPS float64
|
||||
PerIPBurst int
|
||||
}
|
||||
|
||||
// RateLimiter 双维度限流:全局令牌桶 + 单 IP 令牌桶
|
||||
// 开发/测试环境可用内存存储,生产环境建议换 Redis 存储
|
||||
func RateLimiter(cfg RateLimitConfig) gin.HandlerFunc {
|
||||
store := memory.NewStore()
|
||||
|
||||
// 全局限流
|
||||
globalRate := limiter.Rate{
|
||||
Period: time.Second,
|
||||
Limit: int64(cfg.GlobalRPS),
|
||||
}
|
||||
globalLimiter := limiter.New(store, globalRate)
|
||||
|
||||
// 单 IP 限流
|
||||
perIPRate := limiter.Rate{
|
||||
Period: time.Second,
|
||||
Limit: int64(cfg.PerIPRPS),
|
||||
}
|
||||
perIPLimiter := limiter.New(store, perIPRate)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// 1. 检查全局限流
|
||||
globalCtx, err := globalLimiter.Get(c.Request.Context(), "global")
|
||||
if err != nil || globalCtx.Reached {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"code": 4290,
|
||||
"message": "server busy, please retry later",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查单 IP 限流
|
||||
ip := c.ClientIP()
|
||||
ipCtx, err := perIPLimiter.Get(c.Request.Context(), "ip:"+ip)
|
||||
if err != nil || ipCtx.Reached {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"code": 4291,
|
||||
"message": "too many requests from this IP",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Recover 捕获 panic 并返回 500
|
||||
func Recover(logger *zap.Logger) gin.HandlerFunc {
|
||||
return gin.CustomRecovery(func(c *gin.Context, err interface{}) {
|
||||
logger.Error("panic recovered",
|
||||
zap.Any("error", err),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("request_id", c.GetString("request_id")),
|
||||
)
|
||||
c.JSON(500, gin.H{
|
||||
"code": 5000,
|
||||
"message": "internal server error",
|
||||
})
|
||||
c.Abort()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequestID 为每个请求生成唯一 ID,放入 context 和响应头
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 优先使用客户端传来的 X-Request-ID
|
||||
id := c.GetHeader("X-Request-ID")
|
||||
if id == "" {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err == nil {
|
||||
id = hex.EncodeToString(b)
|
||||
} else {
|
||||
id = "unknown"
|
||||
}
|
||||
}
|
||||
c.Set("request_id", id)
|
||||
c.Header("X-Request-ID", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package hash 提供 SHA-256 哈希工具
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// SHA256 返回输入字符串的 SHA-256 十六进制哈希
|
||||
// 注意:故意不加盐(bcrypt 会自动加盐但输出包含盐值),
|
||||
// 因为我们的场景是"判断两段明文是否相同",
|
||||
// 只要对两端的明文做同样的 hash 即可比较,无需加盐。
|
||||
func SHA256(input string) string {
|
||||
h := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package sessionid 提供高熵 session ID 生成器
|
||||
package sessionid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// Generate 生成 128-bit 随机 session ID
|
||||
// 输出为 base64url (无 padding) 编码,共 22 字符
|
||||
// 熵值 2^128 ≈ 3.4e38,暴力枚举不可行
|
||||
func Generate() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package websocket 提供通用的 WebSocket Hub,支持按 session_id 分房间广播
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Hub 管理所有 WebSocket 连接和房间
|
||||
// 每个 session_id 对应一个房间,房间内的连接互相广播消息
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Conn]struct{} // session_id → 连接集合
|
||||
register chan *Conn
|
||||
unregister chan *Conn
|
||||
}
|
||||
|
||||
// Conn 封装一条 WebSocket 连接
|
||||
type Conn struct {
|
||||
Hub *Hub
|
||||
SessionID string
|
||||
Role string // "A" 或 "B",可选
|
||||
RoleMu sync.Mutex
|
||||
WS *websocket.Conn
|
||||
Send chan []byte
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewHub 创建一个 Hub 并启动后台处理协程
|
||||
func NewHub() *Hub {
|
||||
h := &Hub{
|
||||
rooms: make(map[string]map[*Conn]struct{}),
|
||||
register: make(chan *Conn),
|
||||
unregister: make(chan *Conn),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) run() {
|
||||
for {
|
||||
select {
|
||||
case conn := <-h.register:
|
||||
h.mu.Lock()
|
||||
if h.rooms[conn.SessionID] == nil {
|
||||
h.rooms[conn.SessionID] = make(map[*Conn]struct{})
|
||||
}
|
||||
h.rooms[conn.SessionID][conn] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
case conn := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if room, ok := h.rooms[conn.SessionID]; ok {
|
||||
delete(room, conn)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, conn.SessionID)
|
||||
}
|
||||
}
|
||||
// 用 sync.Once 保证 Send channel 只被 close 一次
|
||||
conn.closeOnce.Do(func() { close(conn.Send) })
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register 将连接加入指定 session 的房间
|
||||
func (h *Hub) Register(conn *Conn) {
|
||||
h.register <- conn
|
||||
}
|
||||
|
||||
// Unregister 将连接从房间移除(幂等,可重复调用)
|
||||
func (h *Hub) Unregister(conn *Conn) {
|
||||
h.unregister <- conn
|
||||
}
|
||||
|
||||
// Broadcast 向指定 session 房间内的所有连接发送消息
|
||||
// msg 会被序列化为 JSON
|
||||
func (h *Hub) Broadcast(sessionID string, msg interface{}) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[ws] marshal broadcast msg error: %v", err)
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
if room, ok := h.rooms[sessionID]; ok {
|
||||
for conn := range room {
|
||||
select {
|
||||
case conn.Send <- data:
|
||||
default:
|
||||
// send channel 满了,丢弃,避免阻塞
|
||||
log.Printf("[ws] send channel full for session %s, dropping", sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RoomSize 返回指定 session 房间内的连接数(调试用)
|
||||
func (h *Hub) RoomSize(sessionID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.rooms[sessionID])
|
||||
}
|
||||
|
||||
// WritePump 从 Send channel 读消息并写入 WebSocket
|
||||
// 在独立 goroutine 中运行,直到连接关闭
|
||||
func WritePump(c *Conn) {
|
||||
defer func() {
|
||||
c.Hub.Unregister(c)
|
||||
c.WS.Close()
|
||||
}()
|
||||
for msg := range c.Send {
|
||||
if err := c.WS.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPump 从 WebSocket 读消息并丢弃
|
||||
// 生产环境如果需要客户端主动发消息可以在这里处理
|
||||
// 当前只负责检测断连
|
||||
func ReadPump(c *Conn) {
|
||||
defer func() {
|
||||
c.Hub.Unregister(c)
|
||||
c.WS.Close()
|
||||
}()
|
||||
c.WS.SetPongHandler(func(string) error { return nil })
|
||||
for {
|
||||
if _, _, err := c.WS.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Package response 提供统一的 JSON 响应封装
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response 是所有 API 的统一响应结构
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Success 返回成功响应
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: 0,
|
||||
Message: "ok",
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Fail 返回业务错误(HTTP 200 + 非零业务码)
|
||||
func Fail(c *gin.Context, code int, message string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: code,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// BadRequest 返回参数错误
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, Response{
|
||||
Code: 4000,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// NotFound 返回资源不存在
|
||||
func NotFound(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusNotFound, Response{
|
||||
Code: 4040,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// InternalError 返回服务端内部错误
|
||||
func InternalError(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, Response{
|
||||
Code: 5000,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// WebSocketMessage 是 WebSocket 推送的统一消息格式
|
||||
type WebSocketMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ── WS 事件快捷构造器 ──
|
||||
|
||||
// WSFullState 全量快照推送(发起方 submit 后广播给所有人)
|
||||
func WSFullState(sess interface{}) WebSocketMessage {
|
||||
return WebSocketMessage{Type: "full_state", Data: sess}
|
||||
}
|
||||
|
||||
// WSParticipantJoined 新参与者加入
|
||||
func WSParticipantJoined(nickname string) WebSocketMessage {
|
||||
return WebSocketMessage{
|
||||
Type: "participant_joined",
|
||||
Data: map[string]string{"nickname": nickname},
|
||||
}
|
||||
}
|
||||
|
||||
// WSParticipantLeft 参与者离开
|
||||
func WSParticipantLeft(nickname string) WebSocketMessage {
|
||||
return WebSocketMessage{
|
||||
Type: "participant_left",
|
||||
Data: map[string]string{"nickname": nickname},
|
||||
}
|
||||
}
|
||||
|
||||
// WSParticipantSubmitted 参与者提交文本
|
||||
func WSParticipantSubmitted(nickname string, resp interface{}) WebSocketMessage {
|
||||
return WebSocketMessage{
|
||||
Type: "participant_submitted",
|
||||
Data: map[string]interface{}{
|
||||
"nickname": nickname,
|
||||
"submit": resp,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WSInitiatorReady 发起方刚提交(所有参与者都会收到)
|
||||
func WSInitiatorReady() WebSocketMessage {
|
||||
return WebSocketMessage{Type: "initiator_ready"}
|
||||
}
|
||||
|
||||
// WSSessionExpired 会话过期
|
||||
func WSSessionExpired() WebSocketMessage {
|
||||
return WebSocketMessage{Type: "session_expired"}
|
||||
}
|
||||
|
||||
// WSSessionEnded 会话被发起方主动结束
|
||||
func WSSessionEnded() WebSocketMessage {
|
||||
return WebSocketMessage{Type: "session_ended"}
|
||||
}
|
||||
|
||||
// WSResubmitChanged 发起方切换了"允许参与者重新提交"开关
|
||||
func WSResubmitChanged(allow bool) WebSocketMessage {
|
||||
return WebSocketMessage{
|
||||
Type: "resubmit_changed",
|
||||
Data: map[string]bool{"allow": allow},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 包级错误,业务层可以 errors.Is 检查
|
||||
var (
|
||||
ErrSessionNotFound = errors.New("session not found or expired")
|
||||
ErrDuplicateJoin = errors.New("nickname already joined this session")
|
||||
ErrDuplicateSubmit = errors.New("this nickname has already submitted")
|
||||
)
|
||||
|
||||
const (
|
||||
redisKeyPrefix = "dualcheck:session:"
|
||||
fieldCreatedAt = "created_at"
|
||||
fieldInitHash = "initiator_hash"
|
||||
fieldInitNick = "initiator_nickname"
|
||||
fieldAllowResub = "allow_resubmit"
|
||||
// participant:{nick} 存该参与者的哈希值;participant:{nick}:match 存 "1"/"0"
|
||||
partFieldPrefix = "participant:"
|
||||
)
|
||||
|
||||
// ── 五个 Lua 脚本 ──
|
||||
|
||||
// createLuaScript 原子创建 Hash 类型的会话 key
|
||||
var createLuaScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
if redis.call("EXISTS", key) ~= 0 then
|
||||
return 0
|
||||
end
|
||||
redis.call("HSET", key, "created_at", ARGV[1])
|
||||
redis.call("HSET", key, "allow_resubmit", "0")
|
||||
redis.call("EXPIRE", key, ARGV[2])
|
||||
return 1
|
||||
`)
|
||||
|
||||
// joinParticipantLuaScript 登记参与者昵称(空哈希占位,表示已加入未提交)
|
||||
// KEYS[1] = session key
|
||||
// ARGV[1] = nickname
|
||||
var joinParticipantLuaScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local nick = ARGV[1]
|
||||
local exists = redis.call("EXISTS", key)
|
||||
if exists == 0 then
|
||||
return redis.error_reply("SESSION_NOT_FOUND")
|
||||
end
|
||||
local field = "participant:" .. nick
|
||||
-- 昵称已存在视为已加入(幂等),直接返回 1
|
||||
if redis.call("HEXISTS", key, field) == 1 then
|
||||
return 1
|
||||
end
|
||||
redis.call("HSET", key, field, "")
|
||||
return 1
|
||||
`)
|
||||
|
||||
// submitParticipantLuaScript 参与者提交自己的哈希
|
||||
// 原子完成:检查 → 写入 → 与 initiator_hash 比对(若发起方已提交)
|
||||
// 若已提交但 allow_resubmit=1 则覆盖(重算 match)
|
||||
// KEYS[1] = session key
|
||||
// ARGV[1] = nickname
|
||||
// ARGV[2] = hash hex
|
||||
// 返回 {initiatorSubmitted (0/1), match ("1"/"0"/"")}
|
||||
var submitParticipantLuaScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local nick = ARGV[1]
|
||||
local hash_val = ARGV[2]
|
||||
|
||||
if redis.call("EXISTS", key) == 0 then
|
||||
return redis.error_reply("SESSION_NOT_FOUND")
|
||||
end
|
||||
|
||||
local field = "participant:" .. nick
|
||||
if redis.call("HEXISTS", key, field) == 0 then
|
||||
return redis.error_reply("NICKNAME_NOT_JOINED")
|
||||
end
|
||||
local existing = redis.call("HGET", key, field)
|
||||
if existing ~= "" then
|
||||
-- 已提交过:看是否允许重提交
|
||||
local allow = redis.call("HGET", key, "allow_resubmit")
|
||||
if allow ~= "1" then
|
||||
return redis.error_reply("DUPLICATE_SUBMIT")
|
||||
end
|
||||
end
|
||||
|
||||
-- 写入(覆盖)哈希
|
||||
redis.call("HSET", key, field, hash_val)
|
||||
-- 清除旧的 match 字段,稍后按需重算
|
||||
redis.call("HDEL", key, field .. ":match")
|
||||
|
||||
local init_hash = redis.call("HGET", key, "initiator_hash")
|
||||
if init_hash and init_hash ~= "" then
|
||||
local match = (hash_val == init_hash) and "1" or "0"
|
||||
redis.call("HSET", key, field .. ":match", match)
|
||||
return {1, match}
|
||||
else
|
||||
return {0, ""}
|
||||
end
|
||||
`)
|
||||
|
||||
// submitInitiatorLuaScript 发起方提交标准答案
|
||||
// 写入 initiator_hash 后,遍历所有已提交的 participant,批量计算 match
|
||||
// KEYS[1] = session key
|
||||
// ARGV[1] = nickname
|
||||
// ARGV[2] = initiator hash
|
||||
var submitInitiatorLuaScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local nick = ARGV[1]
|
||||
local init_hash = ARGV[2]
|
||||
|
||||
if redis.call("EXISTS", key) == 0 then
|
||||
return redis.error_reply("SESSION_NOT_FOUND")
|
||||
end
|
||||
|
||||
-- 发起方只允许提交一次(幂等:已提交过则跳过)
|
||||
if redis.call("HEXISTS", key, "initiator_hash") == 1 then
|
||||
return 1
|
||||
end
|
||||
|
||||
redis.call("HSET", key, "initiator_hash", init_hash)
|
||||
redis.call("HSET", key, "initiator_nickname", nick)
|
||||
|
||||
-- 遍历所有 participant:{nick} 且哈希非空的,计算 match
|
||||
local all = redis.call("HGETALL", key)
|
||||
for i = 1, #all, 2 do
|
||||
local field = all[i]
|
||||
local val = all[i + 1]
|
||||
if string.sub(field, 1, #("participant:")) == "participant:"
|
||||
and not string.find(field, ":match$")
|
||||
and val ~= "" then
|
||||
local match = (val == init_hash) and "1" or "0"
|
||||
redis.call("HSET", key, field .. ":match", match)
|
||||
end
|
||||
end
|
||||
return 1
|
||||
`)
|
||||
|
||||
// ── RedisSessionStore 实现 ──
|
||||
|
||||
type RedisSessionStore struct {
|
||||
rdb *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewRedisSessionStore(rdb *redis.Client, defaultTTL time.Duration) *RedisSessionStore {
|
||||
return &RedisSessionStore{rdb: rdb, ttl: defaultTTL}
|
||||
}
|
||||
|
||||
func key(sessionID string) string {
|
||||
return redisKeyPrefix + sessionID
|
||||
}
|
||||
|
||||
// Create 原子创建会话 Hash key
|
||||
func (s *RedisSessionStore) Create(ctx context.Context, sessionID string, ttl time.Duration) error {
|
||||
if ttl <= 0 {
|
||||
ttl = s.ttl
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
ttlSeconds := int64(ttl.Seconds())
|
||||
|
||||
result, err := createLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, now, ttlSeconds).Int64()
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis create session: %w", err)
|
||||
}
|
||||
if result == 0 {
|
||||
return fmt.Errorf("session id collision: %s", sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// JoinParticipant 登记参与者(空哈希占位)
|
||||
func (s *RedisSessionStore) JoinParticipant(ctx context.Context, sessionID, nickname string) error {
|
||||
_, err := joinParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname).Int64()
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "SESSION_NOT_FOUND") {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return fmt.Errorf("redis join participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubmitParticipant 参与者提交自己的文本哈希
|
||||
func (s *RedisSessionStore) SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (bool, *bool, error) {
|
||||
result, err := submitParticipantLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex).Result()
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "SESSION_NOT_FOUND"):
|
||||
return false, nil, ErrSessionNotFound
|
||||
case strings.Contains(msg, "DUPLICATE_SUBMIT"):
|
||||
return false, nil, ErrDuplicateSubmit
|
||||
case strings.Contains(msg, "NICKNAME_NOT_JOINED"):
|
||||
// 参与者没走 join 直接 submit,自动补登记
|
||||
if err := s.JoinParticipant(ctx, sessionID, nickname); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
return s.SubmitParticipant(ctx, sessionID, nickname, hashHex)
|
||||
default:
|
||||
return false, nil, fmt.Errorf("redis submit participant: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
values := result.([]interface{})
|
||||
initSubmitted := values[0].(int64) == 1
|
||||
var match *bool
|
||||
if initSubmitted {
|
||||
b := values[1].(string) == "1"
|
||||
match = &b
|
||||
}
|
||||
return initSubmitted, match, nil
|
||||
}
|
||||
|
||||
// SubmitInitiator 发起方提交标准答案哈希
|
||||
func (s *RedisSessionStore) SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error {
|
||||
_, err := submitInitiatorLuaScript.Run(ctx, s.rdb, []string{key(sessionID)}, nickname, hashHex).Int64()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "SESSION_NOT_FOUND") {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return fmt.Errorf("redis submit initiator: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetResubmit 设置会话是否允许参与者重复提交
|
||||
func (s *RedisSessionStore) SetResubmit(ctx context.Context, sessionID string, allow bool) error {
|
||||
key := key(sessionID)
|
||||
exists, err := s.rdb.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis exists: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
val := "0"
|
||||
if allow {
|
||||
val = "1"
|
||||
}
|
||||
return s.rdb.HSet(ctx, key, fieldAllowResub, val).Err()
|
||||
}
|
||||
|
||||
// Get 获取会话当前状态全量快照
|
||||
func (s *RedisSessionStore) Get(ctx context.Context, sessionID string) (*SessionSnapshot, error) {
|
||||
exists, err := s.rdb.Exists(ctx, key(sessionID)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis exists: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
|
||||
hash, err := s.rdb.HGetAll(ctx, key(sessionID)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis hgetall: %w", err)
|
||||
}
|
||||
|
||||
ttl, err := s.rdb.TTL(ctx, key(sessionID)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redis ttl: %w", err)
|
||||
}
|
||||
|
||||
snap := &SessionSnapshot{
|
||||
SessionID: sessionID,
|
||||
InitiatorHash: hash[fieldInitHash],
|
||||
InitiatorNickname: hash[fieldInitNick],
|
||||
InitiatorSubmitted: hash[fieldInitHash] != "",
|
||||
AllowResubmit: hash[fieldAllowResub] == "1",
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
|
||||
// 收集所有 participant 条目,按 nickname 排序
|
||||
var nicks []string
|
||||
partMap := map[string]string{} // nick → hash
|
||||
for k, v := range hash {
|
||||
if strings.HasPrefix(k, partFieldPrefix) && !strings.HasSuffix(k, ":match") {
|
||||
nick := strings.TrimPrefix(k, partFieldPrefix)
|
||||
nicks = append(nicks, nick)
|
||||
partMap[nick] = v
|
||||
}
|
||||
}
|
||||
sort.Strings(nicks)
|
||||
|
||||
initiatorHash := hash[fieldInitHash]
|
||||
for _, nick := range nicks {
|
||||
h := partMap[nick]
|
||||
p := ParticipantInfo{
|
||||
Nickname: nick,
|
||||
Submitted: h != "",
|
||||
}
|
||||
if h != "" && initiatorHash != "" {
|
||||
m := h == initiatorHash
|
||||
p.Match = &m
|
||||
} else if mStr, ok := hash[partFieldPrefix+nick+":match"]; ok && mStr != "" {
|
||||
m := mStr == "1"
|
||||
p.Match = &m
|
||||
}
|
||||
snap.Participants = append(snap.Participants, p)
|
||||
}
|
||||
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// Delete 删除会话
|
||||
func (s *RedisSessionStore) Delete(ctx context.Context, sessionID string) error {
|
||||
if err := s.rdb.Del(ctx, key(sessionID)).Err(); err != nil {
|
||||
return fmt.Errorf("redis del: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package store 提供会话存储接口
|
||||
// 接口解耦使得可以在测试中用 mock 替换,生产用 Redis
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session 存储的会话数据结构(不含敏感字段)
|
||||
type Session struct {
|
||||
ID string `json:"session_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ParticipantInfo 参与者在全量快照中的状态
|
||||
type ParticipantInfo struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Submitted bool `json:"submitted"`
|
||||
Match *bool `json:"match,omitempty"`
|
||||
}
|
||||
|
||||
// SessionSnapshot Get 返回的全量快照
|
||||
type SessionSnapshot struct {
|
||||
SessionID string `json:"session_id"`
|
||||
InitiatorHash string `json:"-"` // 发起方标准答案哈希,不对外暴露
|
||||
InitiatorNickname string `json:"initiator_nickname"`
|
||||
InitiatorSubmitted bool `json:"initiator_submitted"`
|
||||
AllowResubmit bool `json:"allow_resubmit"`
|
||||
Participants []ParticipantInfo `json:"participants"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// SessionStore 会话存储接口
|
||||
type SessionStore interface {
|
||||
// Create 原子创建会话(SET NX EX)
|
||||
Create(ctx context.Context, id string, ttl time.Duration) error
|
||||
|
||||
// SubmitInitiator 发起方提交标准答案(initiator_hash + initiator_nickname)
|
||||
// 同时立即为所有已提交的参与者计算 match
|
||||
SubmitInitiator(ctx context.Context, sessionID, nickname, hashHex string) error
|
||||
|
||||
// SubmitParticipant 参与者提交自己的文本(按 nickname 唯一标识)
|
||||
// 返回:initiatorSubmitted(发起方是否已提交,是则 match 已算出)、match
|
||||
SubmitParticipant(ctx context.Context, sessionID, nickname, hashHex string) (
|
||||
initiatorSubmitted bool, match *bool, err error)
|
||||
|
||||
// JoinParticipant 参与者加入会话(只登记昵称,不提交文本)
|
||||
// 用于 WS 连接时实时广播 joined 事件
|
||||
JoinParticipant(ctx context.Context, sessionID, nickname string) error
|
||||
|
||||
// SetResubmit 设置是否允许参与者重复提交
|
||||
SetResubmit(ctx context.Context, sessionID string, allow bool) error
|
||||
|
||||
// Get 获取会话当前状态全量快照
|
||||
Get(ctx context.Context, sessionID string) (*SessionSnapshot, error)
|
||||
|
||||
// Delete 删除会话
|
||||
Delete(ctx context.Context, sessionID string) error
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user