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

+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/server.iml" filepath="$PROJECT_DIR$/.idea/server.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"cancerai.top/toolbox-server/configs"
"cancerai.top/toolbox-server/internal/middleware"
"cancerai.top/toolbox-server/internal/pkg/websocket"
"cancerai.top/toolbox-server/internal/store"
"cancerai.top/toolbox-server/internal/tools/dual_check"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
func main() {
// === 1. 加载配置 ===
cfg, err := configs.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "[FATAL] load config: %v\n", err)
os.Exit(1)
}
// === 2. 初始化日志 ===
logger, err := initLogger(cfg.Log.Level, cfg.Log.Format)
if err != nil {
fmt.Fprintf(os.Stderr, "[FATAL] init logger: %v\n", err)
os.Exit(1)
}
defer logger.Sync()
logger.Info("config loaded",
zap.String("redis.addr", cfg.Redis.Addr),
zap.String("server.addr", fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)),
)
// === 3. 初始化 Redis ===
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
DialTimeout: 5 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
})
// 验证 Redis 连通性
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
logger.Fatal("redis connection failed", zap.Error(err))
}
logger.Info("redis connected", zap.String("addr", cfg.Redis.Addr))
// === 4. 初始化共享组件 ===
sessionStore := store.NewRedisSessionStore(rdb, time.Duration(cfg.Session.TTL)*time.Second)
wsHub := websocket.NewHub()
// === 5. 初始化 Gin + 中间件 ===
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(middleware.Recover(logger))
r.Use(middleware.RequestID())
r.Use(middleware.Logger(logger))
r.Use(middleware.CORS(cfg.CORS.AllowedOrigins))
r.Use(middleware.RateLimiter(middleware.RateLimitConfig{
GlobalRPS: cfg.RateLimit.GlobalRPS,
PerIPRPS: cfg.RateLimit.PerIPRPS,
PerIPBurst: cfg.RateLimit.PerIPBurst,
}))
// === 6. 健康检查 ===
r.GET("/healthz", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unhealthy",
"redis": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
// === 7. 注册工具路由 ===
v1 := r.Group("/api/v1")
{
dual_check.Register(
v1.Group("/dual-check"),
sessionStore,
wsHub,
logger,
cfg.Session.TTL,
cfg.Session.MaxTextLength,
)
// 未来新增工具示例:
// to_qrcode.Register(v1.Group("/to-qrcode"), ...)
}
// === 8. 启动 HTTP Server ===
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
}
go func() {
logger.Info("server listening", zap.String("addr", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("listen failed", zap.Error(err))
}
}()
// === 9. 优雅关停 ===
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
logger.Info("received shutdown signal", zap.String("signal", sig.String()))
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("http server shutdown error", zap.Error(err))
}
// 关闭 Redis 连接
if err := rdb.Close(); err != nil {
logger.Error("redis close error", zap.Error(err))
}
logger.Info("server exited gracefully")
}
func initLogger(level, format string) (*zap.Logger, error) {
var cfg zap.Config
switch format {
case "console":
cfg = zap.NewDevelopmentConfig()
default:
cfg = zap.NewProductionConfig()
}
switch level {
case "debug":
cfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
case "warn":
cfg.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
case "error":
cfg.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
default:
cfg.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
}
return cfg.Build()
}
+106
View File
@@ -0,0 +1,106 @@
package configs
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Redis RedisConfig `mapstructure:"redis"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Session SessionConfig `mapstructure:"session"`
Log LogConfig `mapstructure:"log"`
CORS CORSConfig `mapstructure:"cors"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
IdleTimeout time.Duration `mapstructure:"idle_timeout"`
}
type RedisConfig struct {
Addr string `mapstructure:"addr"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
}
type RateLimitConfig struct {
GlobalRPS float64 `mapstructure:"global_rps"`
PerIPRPS float64 `mapstructure:"per_ip_rps"`
PerIPBurst int `mapstructure:"burst"`
}
type SessionConfig struct {
TTL int `mapstructure:"ttl"`
MaxTextLength int `mapstructure:"max_text_length"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
type CORSConfig struct {
AllowedOrigins []string `mapstructure:"allowed_origins"`
}
func Load(configPath ...string) (*Config, error) {
v := viper.New()
if len(configPath) > 0 && configPath[0] != "" {
v.SetConfigFile(configPath[0])
} else {
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath("configs/")
v.AddConfigPath("./configs/")
v.AddConfigPath(".")
}
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8080)
v.SetDefault("server.read_timeout", "15s")
v.SetDefault("server.write_timeout", "15s")
v.SetDefault("server.idle_timeout", "60s")
v.SetDefault("redis.addr", "localhost:6379")
v.SetDefault("redis.db", 0)
v.SetDefault("redis.pool_size", 100)
v.SetDefault("rate_limit.global_rps", 1000)
v.SetDefault("rate_limit.per_ip_rps", 50)
v.SetDefault("rate_limit.burst", 100)
v.SetDefault("session.ttl", 900)
v.SetDefault("session.max_text_length", 10000)
v.SetDefault("log.level", "info")
v.SetDefault("log.format", "json")
// 环境变量覆盖(前缀 DUALCHECK_)
// mapstructure 的 "." 和 "_" 都需要替换成 "_",并转大写
v.SetEnvPrefix("DUALCHECK")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
v.AutomaticEnv()
// 尝试读取配置文件(文件不存在不报错,用默认值+环境变量)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintf(os.Stderr, "[config] warning: config file error: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, "[config] config file not found, using defaults + env vars\n")
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cfg, nil
}
+35
View File
@@ -0,0 +1,35 @@
# === DualCheck Server Config ===
# 生产环境敏感配置请用环境变量覆盖,不要把真实值提交到 git
# 环境变量前缀:DUALCHECK_(如 DUALCHECK_REDIS_ADDR)
server:
host: "0.0.0.0"
port: 8080
read_timeout: 15s
write_timeout: 15s
idle_timeout: 60s
redis:
addr: "192.168.64.7:6379"
password: "123456"
db: 0
pool_size: 100
rate_limit:
global_rps: 1000
per_ip_rps: 50
burst: 100
session:
ttl: 900 # 会话过期时间(秒),默认 15 分钟
max_text_length: 10000 # 单条文本最大长度
log:
level: "info" # debug / info / warn / error
format: "json" # json / console
cors:
allowed_origins:
- "http://localhost:3000"
- "http://localhost:8080"
- "http://localhost:5173"
+51
View File
@@ -1,3 +1,54 @@
module cancerai.top/toolbox-server
go 1.26.5
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulule/limiter/v3 v3.11.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+114
View File
@@ -0,0 +1,114 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+60
View File
@@ -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]:")
}
+29
View File
@@ -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),
)
}
}
+64
View File
@@ -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()
}
}
+22
View File
@@ -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()
})
}
+27
View File
@@ -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()
}
}
+16
View File
@@ -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
}
+136
View File
@@ -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
}
}
}
+120
View File
@@ -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
}
+61
View File
@@ -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"
}
+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