增加用户管理

This commit is contained in:
wtz
2026-02-20 23:39:49 +08:00
parent a272dad5f1
commit af3b805dbf
18 changed files with 1493 additions and 53 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.21-alpine AS builder
FROM golang:1.24-alpine AS builder
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /app
COPY go.mod go.sum ./

View File

@@ -2,6 +2,8 @@ package main
import (
"context"
"doudizhu-server/internal/captcha"
"doudizhu-server/internal/db"
"doudizhu-server/internal/game"
"doudizhu-server/internal/handlers"
"doudizhu-server/internal/redis"
@@ -10,15 +12,13 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
)
func main() {
redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379"
}
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
redisClient, err := redis.NewClient(redis.Config{
Addr: redisAddr,
@@ -31,13 +31,74 @@ func main() {
defer redisClient.Close()
log.Println("Connected to Redis:", redisAddr)
dbHost := getEnv("DB_HOST", "localhost")
dbPort, _ := strconv.Atoi(getEnv("DB_PORT", "5432"))
dbUser := getEnv("DB_USER", "postgres")
dbPassword := getEnv("DB_PASSWORD", "postgres")
dbName := getEnv("DB_NAME", "doudizhu")
database, err := db.New(db.Config{
Host: dbHost,
Port: dbPort,
User: dbUser,
Password: dbPassword,
Database: dbName,
})
if err != nil {
log.Fatalf("Failed to connect to PostgreSQL: %v", err)
}
defer database.Close()
log.Println("Connected to PostgreSQL:", dbHost)
gameMgr := game.NewGameManager(redisClient)
hub := ws.NewHub(gameMgr)
go hub.Run()
h := handlers.NewHandler(gameMgr, hub)
captchaMgr := captcha.NewManager(redisClient)
h := handlers.NewHandler(gameMgr, hub, redisClient, database, captchaMgr)
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/captcha", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
h.GetCaptcha(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/auth/register", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
h.Register(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
h.Login(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/auth/validate", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
h.ValidateToken(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
h.Logout(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/rooms", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
h.CreateRoom(w, r)
@@ -45,6 +106,23 @@ func main() {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/rooms/current", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
h.GetCurrentRoom(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/rooms/leave", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
h.LeaveRoom(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/rooms/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
h.JoinRoom(w, r)
@@ -52,6 +130,7 @@ func main() {
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/ws", h.WebSocket)
srv := &http.Server{
@@ -82,3 +161,10 @@ func main() {
gameMgr.Stop()
log.Println("Server stopped")
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

View File

@@ -1,14 +1,20 @@
module doudizhu-server
go 1.21
go 1.24.0
require (
github.com/gorilla/websocket v1.5.1
github.com/jackc/pgx/v5 v5.8.0
github.com/redis/go-redis/v9 v9.5.1
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

View File

@@ -4,11 +4,37 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,230 @@
package captcha
import (
"bytes"
"context"
"doudizhu-server/internal/redis"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math/rand"
"time"
)
const (
charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
captchaLen = 4
width = 120
height = 40
CaptchaTTL = 5 * time.Minute
)
type Manager struct {
rdb *redis.Client
}
func NewManager(rdb *redis.Client) *Manager {
return &Manager{rdb: rdb}
}
func (m *Manager) Generate(ctx context.Context) (captchaID string, imageBase64 string, err error) {
code := generateCode(captchaLen)
captchaID = generateID()
img := createImage(code)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return "", "", fmt.Errorf("failed to encode captcha image: %w", err)
}
imageBase64 = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
if err := m.rdb.Set(ctx, redis.CaptchaKey(captchaID), code, CaptchaTTL); err != nil {
return "", "", fmt.Errorf("failed to store captcha: %w", err)
}
return captchaID, imageBase64, nil
}
func (m *Manager) Verify(ctx context.Context, captchaID, code string) bool {
var storedCode string
if err := m.rdb.Get(ctx, redis.CaptchaKey(captchaID), &storedCode); err != nil {
return false
}
m.rdb.Delete(ctx, redis.CaptchaKey(captchaID))
return storedCode == code
}
func generateCode(length int) string {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, length)
for i := range b {
b[i] = charset[r.Intn(len(charset))]
}
return string(b)
}
func generateID() string {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
const idCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
for i := range b {
b[i] = idCharset[r.Intn(len(idCharset))]
}
return string(b)
}
func createImage(code string) image.Image {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
img := image.NewRGBA(image.Rect(0, 0, width, height))
bgColor := color.RGBA{
R: uint8(200 + r.Intn(56)),
G: uint8(200 + r.Intn(56)),
B: uint8(200 + r.Intn(56)),
A: 255,
}
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
for i := 0; i < 50; i++ {
x := r.Intn(width)
y := r.Intn(height)
noiseColor := color.RGBA{
R: uint8(r.Intn(256)),
G: uint8(r.Intn(256)),
B: uint8(r.Intn(256)),
A: 255,
}
img.Set(x, y, noiseColor)
}
for i := 0; i < 3; i++ {
x1 := r.Intn(width)
y1 := r.Intn(height)
x2 := r.Intn(width)
y2 := r.Intn(height)
lineColor := color.RGBA{
R: uint8(r.Intn(200)),
G: uint8(r.Intn(200)),
B: uint8(r.Intn(200)),
A: 255,
}
drawLine(img, x1, y1, x2, y2, lineColor)
}
charWidth := width / captchaLen
for i, c := range code {
x := i*charWidth + charWidth/4
y := 8 + r.Intn(10)
textColor := color.RGBA{
R: uint8(r.Intn(150)),
G: uint8(r.Intn(150)),
B: uint8(r.Intn(150)),
A: 255,
}
drawChar(img, x, y, string(c), textColor)
}
return img
}
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
dx := abs(x2 - x1)
dy := abs(y2 - y1)
sx, sy := 1, 1
if x1 >= x2 {
sx = -1
}
if y1 >= y2 {
sy = -1
}
err := dx - dy
for {
img.Set(x1, y1, c)
if x1 == x2 && y1 == y2 {
break
}
e2 := err * 2
if e2 > -dy {
err -= dy
x1 += sx
}
if e2 < dx {
err += dx
y1 += sy
}
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func drawChar(img *image.RGBA, x, y int, char string, c color.Color) {
patterns := map[string][]string{
"A": {" ## ", " # # ", "######", "# #", "# #"},
"B": {"##### ", "# #", "##### ", "# #", "##### "},
"C": {" #####", "# ", "# ", "# ", " #####"},
"D": {"##### ", "# #", "# #", "# #", "##### "},
"E": {"######", "# ", "#### ", "# ", "######"},
"F": {"######", "# ", "#### ", "# ", "# "},
"G": {" #####", "# ", "# ###", "# #", " #####"},
"H": {"# #", "# #", "######", "# #", "# #"},
"J": {" ####", " # ", " # ", "# # ", " ### "},
"K": {"# # ", "# # ", "### ", "# # ", "# # "},
"L": {"# ", "# ", "# ", "# ", "######"},
"M": {"# #", "## ##", "# ## #", "# #", "# #"},
"N": {"# #", "## #", "# # #", "# # #", "# ##"},
"P": {"##### ", "# #", "##### ", "# ", "# "},
"Q": {" #####", "# #", "# # #", "# # ", " #### #"},
"R": {"##### ", "# #", "##### ", "# # ", "# # "},
"S": {" #####", "# ", " #####", " #", "##### "},
"T": {"######", " ## ", " ## ", " ## ", " ## "},
"U": {"# #", "# #", "# #", "# #", " #### "},
"V": {"# #", "# #", " # # ", " # # ", " ## "},
"W": {"# #", "# #", "# ## #", "## ##", "# #"},
"X": {"# #", " # # ", " ## ", " # # ", "# #"},
"Y": {"# #", " # # ", " ## ", " ## ", " ## "},
"Z": {"######", " ## ", " ## ", " ## ", "######"},
"2": {" #####", "# #", " ## ", " # ", "######"},
"3": {" #####", " #", " ####", " #", " #####"},
"4": {"# #", "# #", "######", " #", " #"},
"5": {"######", "# ", "##### ", " #", "##### "},
"6": {" #####", "# ", "##### ", "# #", " #####"},
"7": {"######", " # ", " # ", " # ", " # "},
"8": {" #####", "# #", " #####", "# #", " #####"},
"9": {" #####", "# #", " ######", " #", " #####"},
}
pattern, ok := patterns[char]
if !ok {
return
}
for row, line := range pattern {
for col, ch := range line {
if ch == '#' {
px := x + col*2
py := y + row*2
if px >= 0 && px < width-1 && py >= 0 && py < height-1 {
img.Set(px, py, c)
img.Set(px+1, py, c)
img.Set(px, py+1, c)
img.Set(px+1, py+1, c)
}
}
}
}
}

104
app/internal/db/db.go Normal file
View File

@@ -0,0 +1,104 @@
package db
import (
"context"
"doudizhu-server/internal/models"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Config struct {
Host string
Port int
User string
Password string
Database string
}
type DB struct {
pool *pgxpool.Pool
}
func New(cfg Config) (*DB, error) {
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Database,
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DB{pool: pool}, nil
}
func (d *DB) Close() {
if d.pool != nil {
d.pool.Close()
}
}
func (d *DB) CreateUser(ctx context.Context, user *models.User) error {
query := `
INSERT INTO users (id, username, password, nickname, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)
`
_, err := d.pool.Exec(ctx, query, user.ID, user.Username, user.Password, user.Nickname, time.Now())
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
func (d *DB) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
query := `
SELECT id, username, password, nickname
FROM users
WHERE username = $1
`
user := &models.User{}
err := d.pool.QueryRow(ctx, query, username).Scan(
&user.ID, &user.Username, &user.Password, &user.Nickname,
)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
func (d *DB) GetUserByID(ctx context.Context, id string) (*models.User, error) {
query := `
SELECT id, username, password, nickname
FROM users
WHERE id = $1
`
user := &models.User{}
err := d.pool.QueryRow(ctx, query, id).Scan(
&user.ID, &user.Username, &user.Password, &user.Nickname,
)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
return user, nil
}
func (d *DB) UsernameExists(ctx context.Context, username string) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)`
var exists bool
err := d.pool.QueryRow(ctx, query, username).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to check username: %w", err)
}
return exists, nil
}

View File

@@ -263,6 +263,10 @@ func NewGameManager(rdb *redis.Client) *GameManager {
}
func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) {
return gm.CreateRoomWithUser("", playerName, maxPlayers)
}
func (gm *GameManager) CreateRoomWithUser(userID, playerName string, maxPlayers int) (*models.Room, *models.Player) {
gm.mu.Lock()
defer gm.mu.Unlock()
@@ -270,7 +274,7 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro
roomID := generateID()
playerID := generateID()
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
player := &models.Player{ID: playerID, UserID: userID, Name: playerName, IsOnline: true}
room := &models.Room{
ID: roomID,
Players: []*models.Player{player},
@@ -289,6 +293,10 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro
}
func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *models.Player, error) {
return gm.JoinRoomWithUser(roomID, "", playerName)
}
func (gm *GameManager) JoinRoomWithUser(roomID, userID, playerName string) (*models.Room, *models.Player, error) {
gm.mu.Lock()
defer gm.mu.Unlock()
@@ -305,7 +313,7 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model
}
playerID := generateID()
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
player := &models.Player{ID: playerID, UserID: userID, Name: playerName, IsOnline: true}
room.Players = append(room.Players, player)
gm.saveRoom(ctx, room)
@@ -378,6 +386,25 @@ func (gm *GameManager) MarkPlayerOnline(roomID, playerID string) {
}
}
func (gm *GameManager) MarkPlayerOffline(roomID, playerID string) {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return
}
for _, p := range room.Players {
if p.ID == playerID {
p.IsOnline = false
gm.saveRoom(ctx, room)
return
}
}
}
func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
gm.mu.Lock()
defer gm.mu.Unlock()
@@ -581,6 +608,40 @@ func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) {
return gm.loadRoom(context.Background(), roomID)
}
func (gm *GameManager) GetPlayerName(roomID, playerID string) string {
gm.mu.RLock()
defer gm.mu.RUnlock()
room, err := gm.loadRoom(context.Background(), roomID)
if err != nil {
return ""
}
for _, p := range room.Players {
if p.ID == playerID {
return p.Name
}
}
return ""
}
func (gm *GameManager) GetPlayerIDByUserID(roomID, userID string) string {
gm.mu.RLock()
defer gm.mu.RUnlock()
room, err := gm.loadRoom(context.Background(), roomID)
if err != nil {
return ""
}
for _, p := range room.Players {
if p.UserID == userID {
return p.ID
}
}
return ""
}
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
gm.mu.RLock()
defer gm.mu.RUnlock()

View File

@@ -1,42 +1,243 @@
package handlers
import (
"context"
"crypto/sha256"
"doudizhu-server/internal/captcha"
"doudizhu-server/internal/db"
"doudizhu-server/internal/game"
"doudizhu-server/internal/models"
"doudizhu-server/internal/redis"
"doudizhu-server/internal/ws"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
type Handler struct {
GameMgr *game.GameManager
Hub *ws.Hub
Rdb *redis.Client
DB *db.DB
Captcha *captcha.Manager
UserTTL time.Duration
}
func NewHandler(gameMgr *game.GameManager, hub *ws.Hub) *Handler {
return &Handler{GameMgr: gameMgr, Hub: hub}
func NewHandler(gameMgr *game.GameManager, hub *ws.Hub, rdb *redis.Client, database *db.DB, captchaMgr *captcha.Manager) *Handler {
return &Handler{
GameMgr: gameMgr,
Hub: hub,
Rdb: rdb,
DB: database,
Captcha: captchaMgr,
UserTTL: 24 * time.Hour,
}
}
func (h *Handler) GetCaptcha(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
captchaID, imageBase64, err := h.Captcha.Generate(ctx)
if err != nil {
h.jsonError(w, http.StatusInternalServerError, "failed to generate captcha")
return
}
h.jsonSuccess(w, models.CaptchaResponse{
CaptchaID: captchaID,
Image: imageBase64,
})
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var req models.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Username == "" || req.Password == "" || req.Nickname == "" {
h.jsonError(w, http.StatusBadRequest, "username, password and nickname required")
return
}
if len(req.Username) < 3 || len(req.Username) > 20 {
h.jsonError(w, http.StatusBadRequest, "username must be 3-20 characters")
return
}
if len(req.Password) < 6 {
h.jsonError(w, http.StatusBadRequest, "password must be at least 6 characters")
return
}
ctx := context.Background()
if !h.Captcha.Verify(ctx, req.CaptchaID, strings.ToUpper(req.Captcha)) {
h.jsonError(w, http.StatusBadRequest, "invalid captcha")
return
}
exists, err := h.DB.UsernameExists(ctx, req.Username)
if err != nil {
h.jsonError(w, http.StatusInternalServerError, "failed to check username")
return
}
if exists {
h.jsonError(w, http.StatusBadRequest, "username already exists")
return
}
user := &models.User{
ID: generateUserID(),
Username: req.Username,
Password: hashPassword(req.Password),
Nickname: req.Nickname,
}
if err := h.DB.CreateUser(ctx, user); err != nil {
h.jsonError(w, http.StatusInternalServerError, "failed to create user")
return
}
token := generateToken()
h.Rdb.Set(ctx, redis.SessionKey(token), user.ID, h.UserTTL)
h.jsonSuccess(w, map[string]interface{}{
"token": token,
"userId": user.ID,
"username": user.Username,
"nickname": user.Nickname,
})
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req models.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Username == "" || req.Password == "" {
h.jsonError(w, http.StatusBadRequest, "username and password required")
return
}
ctx := context.Background()
if !h.Captcha.Verify(ctx, req.CaptchaID, strings.ToUpper(req.Captcha)) {
h.jsonError(w, http.StatusBadRequest, "invalid captcha")
return
}
user, err := h.DB.GetUserByUsername(ctx, req.Username)
if err != nil {
h.jsonError(w, http.StatusUnauthorized, "invalid username or password")
return
}
if user.Password != hashPassword(req.Password) {
h.jsonError(w, http.StatusUnauthorized, "invalid username or password")
return
}
token := generateToken()
h.Rdb.Set(ctx, redis.SessionKey(token), user.ID, h.UserTTL)
h.jsonSuccess(w, map[string]interface{}{
"token": token,
"userId": user.ID,
"username": user.Username,
"nickname": user.Nickname,
})
}
func (h *Handler) ValidateToken(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
h.jsonError(w, http.StatusUnauthorized, "token required")
return
}
token = strings.TrimPrefix(token, "Bearer ")
ctx := context.Background()
var userID string
if err := h.Rdb.Get(ctx, redis.SessionKey(token), &userID); err != nil {
h.jsonError(w, http.StatusUnauthorized, "invalid token")
return
}
user, err := h.DB.GetUserByID(ctx, userID)
if err != nil {
h.jsonError(w, http.StatusUnauthorized, "user not found")
return
}
h.jsonSuccess(w, map[string]interface{}{
"userId": user.ID,
"username": user.Username,
"nickname": user.Nickname,
})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
h.jsonSuccess(w, nil)
return
}
token = strings.TrimPrefix(token, "Bearer ")
h.Rdb.Delete(context.Background(), redis.SessionKey(token))
h.jsonSuccess(w, nil)
}
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
userID, err := h.authenticate(r)
if err != nil {
h.jsonError(w, http.StatusUnauthorized, err.Error())
return
}
var req models.CreateRoomRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.PlayerName == "" {
h.jsonError(w, http.StatusBadRequest, "player name required")
return
}
if req.MaxPlayers < 2 || req.MaxPlayers > 10 {
req.MaxPlayers = 4
}
room, player := h.GameMgr.CreateRoom(req.PlayerName, req.MaxPlayers)
ctx := context.Background()
var currentRoomID string
h.Rdb.Get(ctx, redis.UserRoomKey(userID), &currentRoomID)
if currentRoomID != "" {
room, _ := h.GameMgr.GetRoom(currentRoomID)
if room != nil {
h.jsonError(w, http.StatusBadRequest, "you are already in a room, please leave first")
return
}
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
}
room, player := h.GameMgr.CreateRoomWithUser(userID, req.PlayerName, req.MaxPlayers)
h.Rdb.Set(ctx, redis.UserRoomKey(userID), room.ID, redis.RoomTTL)
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
}
func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
userID, err := h.authenticate(r)
if err != nil {
h.jsonError(w, http.StatusUnauthorized, err.Error())
return
}
roomID := strings.ToLower(extractRoomID(r.URL.Path))
if roomID == "" {
h.jsonError(w, http.StatusBadRequest, "room id required")
@@ -53,18 +254,109 @@ func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
return
}
room, player, err := h.GameMgr.JoinRoom(roomID, req.PlayerName)
ctx := context.Background()
var currentRoomID string
h.Rdb.Get(ctx, redis.UserRoomKey(userID), &currentRoomID)
if currentRoomID != "" && currentRoomID != roomID {
room, _ := h.GameMgr.GetRoom(currentRoomID)
if room != nil {
h.jsonError(w, http.StatusBadRequest, "you are already in another room, please leave first")
return
}
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
}
room, player, err := h.GameMgr.JoinRoomWithUser(roomID, userID, req.PlayerName)
if err != nil {
h.jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.Rdb.Set(ctx, redis.UserRoomKey(userID), room.ID, redis.RoomTTL)
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
}
func (h *Handler) GetCurrentRoom(w http.ResponseWriter, r *http.Request) {
userID, err := h.authenticate(r)
if err != nil {
h.jsonError(w, http.StatusUnauthorized, err.Error())
return
}
ctx := context.Background()
var roomID string
if err := h.Rdb.Get(ctx, redis.UserRoomKey(userID), &roomID); err != nil {
h.jsonSuccess(w, nil)
return
}
room, err := h.GameMgr.GetRoom(roomID)
if err != nil {
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
h.jsonSuccess(w, nil)
return
}
playerID := h.GameMgr.GetPlayerIDByUserID(roomID, userID)
if playerID == "" {
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
h.jsonSuccess(w, nil)
return
}
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": playerID})
}
func (h *Handler) LeaveRoom(w http.ResponseWriter, r *http.Request) {
userID, err := h.authenticate(r)
if err != nil {
h.jsonError(w, http.StatusUnauthorized, err.Error())
return
}
ctx := context.Background()
var roomID string
if err := h.Rdb.Get(ctx, redis.UserRoomKey(userID), &roomID); err != nil {
h.jsonSuccess(w, nil)
return
}
playerID := h.GameMgr.GetPlayerIDByUserID(roomID, userID)
if err := h.GameMgr.LeaveRoom(roomID, playerID); err != nil {
h.jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
h.Hub.BroadcastRoomLeft(roomID, playerID)
h.jsonSuccess(w, nil)
}
func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) {
ws.ServeWs(h.Hub, w, r)
}
func (h *Handler) authenticate(r *http.Request) (string, error) {
token := r.Header.Get("Authorization")
if token == "" {
return "", errors.New("token required")
}
token = strings.TrimPrefix(token, "Bearer ")
ctx := context.Background()
var userID string
if err := h.Rdb.Get(ctx, redis.SessionKey(token), &userID); err != nil {
return "", errors.New("invalid token")
}
return userID, nil
}
func (h *Handler) jsonSuccess(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.ApiResponse{
@@ -88,9 +380,30 @@ func (h *Handler) jsonError(w http.ResponseWriter, status int, msg string) {
func extractRoomID(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, p := range parts {
if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" {
if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" && parts[i+1] != "leave" {
return parts[i+1]
}
}
return ""
}
func generateUserID() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 16)
for i := range b {
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
time.Sleep(time.Nanosecond)
}
return string(b)
}
func generateToken() string {
data := time.Now().String() + generateUserID()
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
func hashPassword(password string) string {
hash := sha256.Sum256([]byte(password + "doudizhu_salt"))
return hex.EncodeToString(hash[:])
}

View File

@@ -33,6 +33,7 @@ const (
type Player struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
Name string `json:"name"`
Cards []Card `json:"cards,omitempty"`
CardCount int `json:"cardCount"`
@@ -101,3 +102,39 @@ type CreateRoomRequest struct {
type JoinRoomRequest struct {
PlayerName string `json:"playerName"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
Nickname string `json:"nickname"`
}
type RegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
Captcha string `json:"captcha"`
CaptchaID string `json:"captchaId"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Captcha string `json:"captcha"`
CaptchaID string `json:"captchaId"`
}
type CaptchaResponse struct {
CaptchaID string `json:"captchaId"`
Image string `json:"image"`
}
type LeaveRoomRequest struct {
PlayerID string `json:"playerId"`
}
type PlayerRoomInfo struct {
UserID string `json:"userId"`
RoomID string `json:"roomId"`
}

View File

@@ -250,3 +250,23 @@ func RoomTTLKey(roomID string) string {
func PlayerTTLKey(playerID string) string {
return "player:" + playerID + ":ttl"
}
func CaptchaKey(captchaID string) string {
return "captcha:" + captchaID
}
func UserKey(username string) string {
return "user:" + username
}
func UserRoomKey(userID string) string {
return "user:" + userID + ":room"
}
func SessionKey(token string) string {
return "session:" + token
}
func UserIDToUsernameKey(userID string) string {
return "user_id:" + userID + ":username"
}

View File

@@ -69,7 +69,7 @@ func (h *Hub) Run() {
}
h.mu.Unlock()
if c.RoomID != "" {
h.GameMgr.LeaveRoomWithTTL(c.RoomID, c.ID)
h.GameMgr.MarkPlayerOffline(c.RoomID, c.ID)
h.broadcastRoomState(c.RoomID)
}
@@ -201,7 +201,20 @@ func (c *Client) handleMessage(data []byte) {
c.Hub.broadcastRoomState(c.RoomID)
case models.MsgTypeChat:
c.Hub.broadcastToRoom(c.RoomID, msg)
chatMsg, ok := msg.Data.(string)
if !ok || chatMsg == "" {
return
}
playerName := c.Hub.GameMgr.GetPlayerName(c.RoomID, c.ID)
c.Hub.broadcastToRoom(c.RoomID, models.Message{
Type: models.MsgTypeChat,
Data: map[string]string{
"playerId": c.ID,
"playerName": playerName,
"message": chatMsg,
},
Timestamp: time.Now().Unix(),
})
}
}
@@ -300,6 +313,33 @@ func (h *Hub) broadcastRoomState(roomID string) {
}
}
func (h *Hub) BroadcastRoomLeft(roomID, playerID string) {
h.mu.RLock()
clients := make([]*Client, 0)
for _, c := range h.Clients {
if c.RoomID == roomID {
clients = append(clients, c)
}
}
h.mu.RUnlock()
data, _ := json.Marshal(models.Message{
Type: models.MsgTypeLeave,
PlayerID: playerID,
Timestamp: time.Now().Unix(),
})
for _, c := range clients {
select {
case c.Send <- data:
case <-c.done:
default:
}
}
h.broadcastRoomState(roomID)
}
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
roomID := strings.ToLower(r.URL.Query().Get("roomId"))
playerID := r.URL.Query().Get("playerId")