将存储部分迁移到Redis

This commit is contained in:
wtz
2026-02-20 19:41:02 +08:00
parent 13d2b0e1dc
commit a272dad5f1
9 changed files with 704 additions and 147 deletions

View File

@@ -35,6 +35,7 @@
- 后端: Go 1.21 - 后端: Go 1.21
- 前端: HTML + CSS + JavaScript - 前端: HTML + CSS + JavaScript
- 通信: WebSocket - 通信: WebSocket
- 存储: Redis
- 代理: Nginx - 代理: Nginx
- 部署: Docker Compose - 部署: Docker Compose
@@ -48,6 +49,7 @@ doudizhu-server/
│ │ ├── models/ # 数据模型 │ │ ├── models/ # 数据模型
│ │ ├── game/ # 游戏逻辑 │ │ ├── game/ # 游戏逻辑
│ │ ├── handlers/ # HTTP处理器 │ │ ├── handlers/ # HTTP处理器
│ │ ├── redis/ # Redis客户端封装
│ │ └── ws/ # WebSocket处理 │ │ └── ws/ # WebSocket处理
│ ├── Dockerfile │ ├── Dockerfile
│ └── go.mod │ └── go.mod
@@ -87,6 +89,10 @@ docker compose up -d --build
### 本地运行 ### 本地运行
```bash ```bash
# 启动Redis (Docker)
docker run -d --name redis -p 6379:6379 redis:7-alpine
# 运行后端
cd doudizhu-server/app cd doudizhu-server/app
go mod tidy go mod tidy
go run ./cmd go run ./cmd

View File

@@ -1,15 +1,37 @@
package main package main
import ( import (
"context"
"doudizhu-server/internal/game" "doudizhu-server/internal/game"
"doudizhu-server/internal/handlers" "doudizhu-server/internal/handlers"
"doudizhu-server/internal/redis"
"doudizhu-server/internal/ws" "doudizhu-server/internal/ws"
"log" "log"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time"
) )
func main() { func main() {
gameMgr := game.NewGameManager() redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379"
}
redisClient, err := redis.NewClient(redis.Config{
Addr: redisAddr,
Password: "",
DB: 0,
})
if err != nil {
log.Fatalf("Failed to connect to Redis: %v", err)
}
defer redisClient.Close()
log.Println("Connected to Redis:", redisAddr)
gameMgr := game.NewGameManager(redisClient)
hub := ws.NewHub(gameMgr) hub := ws.NewHub(gameMgr)
go hub.Run() go hub.Run()
@@ -32,8 +54,31 @@ func main() {
}) })
mux.HandleFunc("/api/ws", h.WebSocket) mux.HandleFunc("/api/ws", h.WebSocket)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
go func() {
log.Println("App server starting on :8080") log.Println("App server starting on :8080")
if err := http.ListenAndServe(":8080", mux); err != nil { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err) log.Fatal(err)
} }
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
hub.Stop()
gameMgr.Stop()
log.Println("Server stopped")
} }

View File

@@ -2,6 +2,13 @@ module doudizhu-server
go 1.21 go 1.21
require github.com/gorilla/websocket v1.5.1 require (
github.com/gorilla/websocket v1.5.1
github.com/redis/go-redis/v9 v9.5.1
)
require golang.org/x/net v0.17.0 // indirect require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
golang.org/x/net v0.17.0 // indirect
)

View File

@@ -1,4 +1,14 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
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/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 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
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=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=

View File

@@ -1,34 +0,0 @@
package game
import (
"crypto/rand"
"doudizhu-server/internal/models"
"encoding/hex"
"errors"
"fmt"
)
var (
ErrRoomNotFound = errors.New("room not found")
ErrRoomFull = errors.New("room is full")
ErrGameStarted = errors.New("game already started")
ErrGameNotStarted = errors.New("game not started")
ErrPlayerNotFound = errors.New("player not found")
ErrNotEnoughPlayers = errors.New("not enough players")
ErrPlayerNotReady = errors.New("player not ready")
ErrNotYourTurn = errors.New("not your turn")
ErrCardsNotInHand = errors.New("cards not in hand")
ErrInvalidCardType = errors.New("invalid card type")
ErrCannotBeat = errors.New("cannot beat last play")
ErrCannotPass = errors.New("cannot pass")
)
func generateID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func cardKey(c models.Card) string {
return fmt.Sprintf("%d_%d", c.Suit, c.Value)
}

View File

@@ -1,13 +1,31 @@
package game package game
import ( import (
"context"
"doudizhu-server/internal/models" "doudizhu-server/internal/models"
"doudizhu-server/internal/redis"
"encoding/json"
"errors"
"math/rand" "math/rand"
"sort" "sort"
"sync" "sync"
"time" "time"
) )
var (
ErrRoomNotFound = errors.New("room not found")
ErrRoomFull = errors.New("room is full")
ErrGameStarted = errors.New("game already started")
ErrGameNotStarted = errors.New("game not started")
ErrNotYourTurn = errors.New("not your turn")
ErrPlayerNotFound = errors.New("player not found")
ErrCardsNotInHand = errors.New("cards not in hand")
ErrInvalidCardType = errors.New("invalid card type")
ErrCannotBeat = errors.New("cannot beat last play")
ErrCannotPass = errors.New("cannot pass")
ErrNotEnoughPlayers = errors.New("not enough players")
)
type CardLogic struct{} type CardLogic struct{}
func NewCardLogic() *CardLogic { func NewCardLogic() *CardLogic {
@@ -227,30 +245,31 @@ func (cl *CardLogic) getMainValue(cards []models.Card) int {
} }
type GameManager struct { type GameManager struct {
rooms map[string]*models.Room rdb *redis.Client
players map[string]*models.Player
deck map[string][]models.Card
discard map[string][]models.Card
mu sync.RWMutex
cl *CardLogic cl *CardLogic
mu sync.RWMutex
stopCleanup chan struct{}
stopOnce sync.Once
} }
func NewGameManager() *GameManager { func NewGameManager(rdb *redis.Client) *GameManager {
return &GameManager{ gm := &GameManager{
rooms: make(map[string]*models.Room), rdb: rdb,
players: make(map[string]*models.Player),
deck: make(map[string][]models.Card),
discard: make(map[string][]models.Card),
cl: NewCardLogic(), cl: NewCardLogic(),
stopCleanup: make(chan struct{}),
} }
go gm.cleanupLoop()
return gm
} }
func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) { func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) {
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
ctx := context.Background()
roomID := generateID() roomID := generateID()
playerID := generateID() playerID := generateID()
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true} player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
room := &models.Room{ room := &models.Room{
ID: roomID, ID: roomID,
@@ -262,10 +281,9 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro
deck := gm.cl.CreateDeck() deck := gm.cl.CreateDeck()
gm.cl.Shuffle(deck) gm.cl.Shuffle(deck)
gm.rooms[roomID] = room gm.saveRoom(ctx, room)
gm.players[playerID] = player gm.saveDeck(ctx, roomID, deck)
gm.deck[roomID] = deck gm.saveDiscard(ctx, roomID, []models.Card{})
gm.discard[roomID] = make([]models.Card, 0)
return room, player return room, player
} }
@@ -274,8 +292,9 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok { room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return nil, nil, ErrRoomNotFound return nil, nil, ErrRoomNotFound
} }
if len(room.Players) >= room.MaxPlayers { if len(room.Players) >= room.MaxPlayers {
@@ -288,8 +307,8 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model
playerID := generateID() playerID := generateID()
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true} player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
room.Players = append(room.Players, player) room.Players = append(room.Players, player)
gm.players[playerID] = player
gm.saveRoom(ctx, room)
return room, player, nil return room, player, nil
} }
@@ -297,19 +316,20 @@ func (gm *GameManager) LeaveRoom(roomID, playerID string) error {
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok { room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound return ErrRoomNotFound
} }
for i, p := range room.Players { for i, p := range room.Players {
if p.ID == playerID { if p.ID == playerID {
room.Players = append(room.Players[:i], room.Players[i+1:]...) room.Players = append(room.Players[:i], room.Players[i+1:]...)
delete(gm.players, playerID)
if len(room.Players) == 0 { if len(room.Players) == 0 {
delete(gm.rooms, roomID) gm.deleteRoomData(ctx, roomID)
delete(gm.deck, roomID) } else {
delete(gm.discard, roomID) gm.saveRoom(ctx, room)
} }
return nil return nil
} }
@@ -317,12 +337,54 @@ func (gm *GameManager) LeaveRoom(roomID, playerID string) error {
return ErrPlayerNotFound return ErrPlayerNotFound
} }
func (gm *GameManager) LeaveRoomWithTTL(roomID, playerID string) error {
gm.mu.Lock()
defer gm.mu.Unlock()
ctx := context.Background()
room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound
}
for _, p := range room.Players {
if p.ID == playerID {
p.IsOnline = false
gm.saveRoom(ctx, room)
gm.rdb.Set(ctx, redis.PlayerTTLKey(playerID), time.Now().Add(redis.PlayerTTL).Unix(), redis.PlayerTTL)
return nil
}
}
return ErrPlayerNotFound
}
func (gm *GameManager) MarkPlayerOnline(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 = true
gm.saveRoom(ctx, room)
gm.rdb.Delete(ctx, redis.PlayerTTLKey(playerID))
return
}
}
}
func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error { func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok { room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound return ErrRoomNotFound
} }
@@ -336,6 +398,7 @@ func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
if room.State == models.RoomStateFinished { if room.State == models.RoomStateFinished {
room.State = models.RoomStateWaiting room.State = models.RoomStateWaiting
} }
gm.saveRoom(ctx, room)
return nil return nil
} }
} }
@@ -346,8 +409,9 @@ func (gm *GameManager) StartGame(roomID string) error {
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok { room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound return ErrRoomNotFound
} }
if len(room.Players) < 2 { if len(room.Players) < 2 {
@@ -357,6 +421,8 @@ func (gm *GameManager) StartGame(roomID string) error {
return ErrGameStarted return ErrGameStarted
} }
lastWinner := room.LastWinner
for _, p := range room.Players { for _, p := range room.Players {
p.Cards = make([]models.Card, 0) p.Cards = make([]models.Card, 0)
p.CardCount = 0 p.CardCount = 0
@@ -365,29 +431,40 @@ func (gm *GameManager) StartGame(roomID string) error {
deck := gm.cl.CreateDeck() deck := gm.cl.CreateDeck()
gm.cl.Shuffle(deck) gm.cl.Shuffle(deck)
gm.deck[roomID] = deck gm.saveDeck(ctx, roomID, deck)
gm.discard[roomID] = make([]models.Card, 0) gm.saveDiscard(ctx, roomID, []models.Card{})
room.State = models.RoomStatePlaying room.State = models.RoomStatePlaying
room.RoundCount = 1 room.RoundCount = 1
room.CurrentTurn = 0
room.LastPlay = nil room.LastPlay = nil
room.LastWinner = ""
gm.dealCards(roomID, 5) if lastWinner != "" {
for i, p := range room.Players {
if p.ID == lastWinner {
room.CurrentTurn = i
break
}
}
} else {
room.CurrentTurn = 0
}
gm.dealCards(room, 5)
gm.saveRoom(ctx, room)
return nil return nil
} }
func (gm *GameManager) dealCards(roomID string, count int) { func (gm *GameManager) dealCards(room *models.Room, count int) {
room := gm.rooms[roomID] ctx := context.Background()
deck := gm.deck[roomID] deck, _ := gm.loadDeck(ctx, room.ID)
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
for _, p := range room.Players { for _, p := range room.Players {
if len(deck) == 0 { if len(deck) == 0 {
deck = gm.discard[roomID] deck, _ = gm.loadDiscard(ctx, room.ID)
gm.cl.Shuffle(deck) gm.cl.Shuffle(deck)
gm.discard[roomID] = make([]models.Card, 0) gm.saveDiscard(ctx, room.ID, []models.Card{})
} }
if len(deck) > 0 { if len(deck) > 0 {
p.Cards = append(p.Cards, deck[0]) p.Cards = append(p.Cards, deck[0])
@@ -395,7 +472,8 @@ func (gm *GameManager) dealCards(roomID string, count int) {
} }
} }
} }
gm.deck[roomID] = deck
gm.saveDeck(ctx, room.ID, deck)
for _, p := range room.Players { for _, p := range room.Players {
gm.cl.SortCards(p.Cards) gm.cl.SortCards(p.Cards)
@@ -407,8 +485,9 @@ func (gm *GameManager) PlayCards(roomID, playerID string, cards []models.Card) e
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok { room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound return ErrRoomNotFound
} }
if room.State != models.RoomStatePlaying { if room.State != models.RoomStatePlaying {
@@ -439,15 +518,20 @@ func (gm *GameManager) PlayCards(roomID, playerID string, cards []models.Card) e
Cards: cards, Cards: cards,
CardType: cardType, CardType: cardType,
} }
gm.discard[roomID] = append(gm.discard[roomID], cards...)
discard, _ := gm.loadDiscard(ctx, roomID)
discard = append(discard, cards...)
gm.saveDiscard(ctx, roomID, discard)
if len(player.Cards) == 0 { if len(player.Cards) == 0 {
room.LastWinner = playerID room.LastWinner = playerID
room.State = models.RoomStateFinished room.State = models.RoomStateFinished
return nil gm.rdb.Set(ctx, redis.RoomTTLKey(roomID), time.Now().Add(redis.RoomTTL).Unix(), redis.RoomTTL)
} else {
room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players)
} }
gm.nextTurn(room) gm.saveRoom(ctx, room)
return nil return nil
} }
@@ -455,8 +539,9 @@ func (gm *GameManager) Pass(roomID, playerID string) error {
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok { room, err := gm.loadRoom(ctx, roomID)
if err != nil {
return ErrRoomNotFound return ErrRoomNotFound
} }
if room.State != models.RoomStatePlaying { if room.State != models.RoomStatePlaying {
@@ -469,18 +554,67 @@ func (gm *GameManager) Pass(roomID, playerID string) error {
return ErrCannotPass return ErrCannotPass
} }
gm.nextTurn(room) room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players)
// 一轮结束:回到最后出牌者,发牌并让他先出
if room.Players[room.CurrentTurn].ID == room.LastPlay.PlayerID { if room.Players[room.CurrentTurn].ID == room.LastPlay.PlayerID {
winnerID := room.LastPlay.PlayerID
room.LastPlay = nil room.LastPlay = nil
gm.dealCards(roomID, 1)
room.RoundCount++ room.RoundCount++
gm.dealCards(room, 1)
for i, p := range room.Players {
if p.ID == winnerID {
room.CurrentTurn = i
break
} }
}
}
gm.saveRoom(ctx, room)
return nil return nil
} }
func (gm *GameManager) nextTurn(room *models.Room) { func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) {
room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players) gm.mu.RLock()
defer gm.mu.RUnlock()
return gm.loadRoom(context.Background(), roomID)
}
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
gm.mu.RLock()
defer gm.mu.RUnlock()
room, err := gm.loadRoom(context.Background(), roomID)
if err != nil {
return nil
}
players := make([]*models.Player, len(room.Players))
for i, p := range room.Players {
pp := &models.Player{
ID: p.ID,
Name: p.Name,
IsReady: p.IsReady,
IsOnline: p.IsOnline,
CardCount: p.CardCount,
}
if p.ID == playerID {
pp.Cards = p.Cards
}
players[i] = pp
}
return &models.Room{
ID: room.ID,
Players: players,
CurrentTurn: room.CurrentTurn,
State: room.State,
LastPlay: room.LastPlay,
LastWinner: room.LastWinner,
RoundCount: room.RoundCount,
MaxPlayers: room.MaxPlayers,
}
} }
func (gm *GameManager) hasCards(player *models.Player, cards []models.Card) bool { func (gm *GameManager) hasCards(player *models.Player, cards []models.Card) bool {
@@ -516,48 +650,118 @@ func (gm *GameManager) removeCards(player *models.Player, cards []models.Card) {
player.Cards = newCards player.Cards = newCards
} }
func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) { func (gm *GameManager) cleanupLoop() {
gm.mu.RLock() ticker := time.NewTicker(5 * time.Minute)
defer gm.mu.RUnlock() defer ticker.Stop()
room, ok := gm.rooms[roomID]
if !ok { for {
return nil, ErrRoomNotFound select {
case <-ticker.C:
gm.cleanupExpired()
case <-gm.stopCleanup:
return
}
} }
return room, nil
} }
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room { func (gm *GameManager) cleanupExpired() {
gm.mu.RLock() gm.mu.Lock()
defer gm.mu.RUnlock() defer gm.mu.Unlock()
room, ok := gm.rooms[roomID] ctx := context.Background()
if !ok {
gm.rdb.ScanKeys(ctx, "room:*:ttl", func(key string) error {
var expiryTime int64
if err := gm.rdb.Get(ctx, key, &expiryTime); err != nil {
return nil return nil
} }
if time.Now().Unix() > expiryTime {
roomID := key[len("room:") : len(key)-len(":ttl")]
gm.deleteRoomData(ctx, roomID)
}
return nil
})
players := make([]*models.Player, len(room.Players)) gm.rdb.ScanKeys(ctx, "player:*:ttl", func(key string) error {
for i, p := range room.Players { var expiryTime int64
pp := &models.Player{ if err := gm.rdb.Get(ctx, key, &expiryTime); err != nil {
ID: p.ID, return nil
Name: p.Name,
IsReady: p.IsReady,
IsOnline: p.IsOnline,
CardCount: p.CardCount,
} }
if p.ID == playerID { if time.Now().Unix() > expiryTime {
pp.Cards = p.Cards playerID := key[len("player:") : len(key)-len(":ttl")]
} gm.rdb.Delete(ctx, redis.PlayerKey(playerID), redis.PlayerTTLKey(playerID))
players[i] = pp
}
return &models.Room{
ID: room.ID,
Players: players,
CurrentTurn: room.CurrentTurn,
State: room.State,
LastPlay: room.LastPlay,
LastWinner: room.LastWinner,
RoundCount: room.RoundCount,
MaxPlayers: room.MaxPlayers,
} }
return nil
})
}
func (gm *GameManager) Stop() {
gm.stopOnce.Do(func() {
close(gm.stopCleanup)
})
}
func (gm *GameManager) saveRoom(ctx context.Context, room *models.Room) {
gm.rdb.Set(ctx, redis.RoomKey(room.ID), room, redis.RoomTTL)
}
func (gm *GameManager) loadRoom(ctx context.Context, roomID string) (*models.Room, error) {
var room models.Room
if err := gm.rdb.Get(ctx, redis.RoomKey(roomID), &room); err != nil {
return nil, err
}
return &room, nil
}
func (gm *GameManager) saveDeck(ctx context.Context, roomID string, deck []models.Card) {
gm.rdb.Set(ctx, redis.RoomDeckKey(roomID), deck, redis.RoomTTL)
}
func (gm *GameManager) loadDeck(ctx context.Context, roomID string) ([]models.Card, error) {
var deck []models.Card
if err := gm.rdb.Get(ctx, redis.RoomDeckKey(roomID), &deck); err != nil {
return nil, err
}
return deck, nil
}
func (gm *GameManager) saveDiscard(ctx context.Context, roomID string, discard []models.Card) {
gm.rdb.Set(ctx, redis.RoomDiscardKey(roomID), discard, redis.RoomTTL)
}
func (gm *GameManager) loadDiscard(ctx context.Context, roomID string) ([]models.Card, error) {
var discard []models.Card
if err := gm.rdb.Get(ctx, redis.RoomDiscardKey(roomID), &discard); err != nil {
return nil, err
}
return discard, nil
}
func (gm *GameManager) deleteRoomData(ctx context.Context, roomID string) {
room, err := gm.loadRoom(ctx, roomID)
if err == nil {
for _, p := range room.Players {
gm.rdb.Delete(ctx, redis.PlayerKey(p.ID), redis.PlayerTTLKey(p.ID))
}
}
gm.rdb.Delete(ctx,
redis.RoomKey(roomID),
redis.RoomDeckKey(roomID),
redis.RoomDiscardKey(roomID),
redis.RoomTTLKey(roomID),
)
}
func generateID() string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
func cardKey(c models.Card) string {
data, _ := json.Marshal(c)
return string(data)
} }

252
app/internal/redis/redis.go Normal file
View File

@@ -0,0 +1,252 @@
package redis
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type Config struct {
Addr string
Password string
DB int
}
type Client struct {
rdb *redis.Client
}
func NewClient(cfg Config) (*Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to redis: %w", err)
}
return &Client{rdb: rdb}, nil
}
func (c *Client) Close() error {
return c.rdb.Close()
}
func (c *Client) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal value: %w", err)
}
return c.rdb.Set(ctx, key, data, ttl).Err()
}
func (c *Client) Get(ctx context.Context, key string, dest interface{}) error {
data, err := c.rdb.Get(ctx, key).Bytes()
if err != nil {
return err
}
if err := json.Unmarshal(data, dest); err != nil {
return fmt.Errorf("failed to unmarshal value: %w", err)
}
return nil
}
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
result, err := c.rdb.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return result > 0, nil
}
func (c *Client) Delete(ctx context.Context, keys ...string) error {
return c.rdb.Del(ctx, keys...).Err()
}
func (c *Client) SetTTL(ctx context.Context, key string, ttl time.Duration) error {
return c.rdb.Expire(ctx, key, ttl).Err()
}
func (c *Client) GetTTL(ctx context.Context, key string) (time.Duration, error) {
return c.rdb.TTL(ctx, key).Result()
}
func (c *Client) LPush(ctx context.Context, key string, values ...interface{}) error {
data := make([]interface{}, len(values))
for i, v := range values {
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("failed to marshal list value: %w", err)
}
data[i] = b
}
return c.rdb.LPush(ctx, key, data...).Err()
}
func (c *Client) RPush(ctx context.Context, key string, values ...interface{}) error {
data := make([]interface{}, len(values))
for i, v := range values {
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("failed to marshal list value: %w", err)
}
data[i] = b
}
return c.rdb.RPush(ctx, key, data...).Err()
}
func (c *Client) LRange(ctx context.Context, key string, start, stop int64, dest interface{}) error {
results, err := c.rdb.LRange(ctx, key, start, stop).Result()
if err != nil {
return err
}
data, err := json.Marshal(results)
if err != nil {
return fmt.Errorf("failed to marshal results: %w", err)
}
return json.Unmarshal(data, dest)
}
func (c *Client) LLen(ctx context.Context, key string) (int64, error) {
return c.rdb.LLen(ctx, key).Result()
}
func (c *Client) LPop(ctx context.Context, key string) (string, error) {
return c.rdb.LPop(ctx, key).Result()
}
func (c *Client) LTrim(ctx context.Context, key string, start, stop int64) error {
return c.rdb.LTrim(ctx, key, start, stop).Err()
}
func (c *Client) LSet(ctx context.Context, key string, index int64, value interface{}) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal value: %w", err)
}
return c.rdb.LSet(ctx, key, index, data).Err()
}
func (c *Client) SAdd(ctx context.Context, key string, members ...interface{}) error {
data := make([]interface{}, len(members))
for i, m := range members {
b, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("failed to marshal set member: %w", err)
}
data[i] = b
}
return c.rdb.SAdd(ctx, key, data...).Err()
}
func (c *Client) SMembers(ctx context.Context, key string) ([]string, error) {
return c.rdb.SMembers(ctx, key).Result()
}
func (c *Client) SRem(ctx context.Context, key string, members ...interface{}) error {
data := make([]interface{}, len(members))
for i, m := range members {
b, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("failed to marshal set member: %w", err)
}
data[i] = b
}
return c.rdb.SRem(ctx, key, data...).Err()
}
func (c *Client) HSet(ctx context.Context, key string, field string, value interface{}) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal hash value: %w", err)
}
return c.rdb.HSet(ctx, key, field, data).Err()
}
func (c *Client) HGet(ctx context.Context, key, field string, dest interface{}) error {
data, err := c.rdb.HGet(ctx, key, field).Bytes()
if err != nil {
return err
}
return json.Unmarshal(data, dest)
}
func (c *Client) HDel(ctx context.Context, key string, fields ...string) error {
return c.rdb.HDel(ctx, key, fields...).Err()
}
func (c *Client) HGetAll(ctx context.Context, key string) (map[string]string, error) {
return c.rdb.HGetAll(ctx, key).Result()
}
func (c *Client) HExists(ctx context.Context, key, field string) (bool, error) {
return c.rdb.HExists(ctx, key, field).Result()
}
func (c *Client) Keys(ctx context.Context, pattern string) ([]string, error) {
return c.rdb.Keys(ctx, pattern).Result()
}
func (c *Client) ScanKeys(ctx context.Context, pattern string, callback func(key string) error) error {
var cursor uint64
for {
var keys []string
var err error
keys, cursor, err = c.rdb.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return err
}
for _, key := range keys {
if err := callback(key); err != nil {
return err
}
}
if cursor == 0 {
break
}
}
return nil
}
const (
RoomTTL = 30 * time.Minute
PlayerTTL = 10 * time.Minute
)
func RoomKey(roomID string) string {
return "room:" + roomID
}
func PlayerKey(playerID string) string {
return "player:" + playerID
}
func RoomPlayersKey(roomID string) string {
return "room:" + roomID + ":players"
}
func RoomDeckKey(roomID string) string {
return "room:" + roomID + ":deck"
}
func RoomDiscardKey(roomID string) string {
return "room:" + roomID + ":discard"
}
func RoomTTLKey(roomID string) string {
return "room:" + roomID + ":ttl"
}
func PlayerTTLKey(playerID string) string {
return "player:" + playerID + ":ttl"
}

View File

@@ -25,6 +25,8 @@ type Client struct {
Conn *websocket.Conn Conn *websocket.Conn
Send chan []byte Send chan []byte
Hub *Hub Hub *Hub
done chan struct{}
doneOnce sync.Once
} }
type Hub struct { type Hub struct {
@@ -33,6 +35,8 @@ type Hub struct {
Unregister chan *Client Unregister chan *Client
GameMgr *game.GameManager GameMgr *game.GameManager
mu sync.RWMutex mu sync.RWMutex
stopChan chan struct{}
stopOnce sync.Once
} }
func NewHub(gameMgr *game.GameManager) *Hub { func NewHub(gameMgr *game.GameManager) *Hub {
@@ -41,6 +45,7 @@ func NewHub(gameMgr *game.GameManager) *Hub {
Register: make(chan *Client, 64), Register: make(chan *Client, 64),
Unregister: make(chan *Client, 64), Unregister: make(chan *Client, 64),
GameMgr: gameMgr, GameMgr: gameMgr,
stopChan: make(chan struct{}),
} }
} }
@@ -49,40 +54,72 @@ func (h *Hub) Run() {
select { select {
case c := <-h.Register: case c := <-h.Register:
h.mu.Lock() h.mu.Lock()
if old, exists := h.Clients[c.ID]; exists {
old.close()
delete(h.Clients, old.ID)
}
h.Clients[c.ID] = c h.Clients[c.ID] = c
h.mu.Unlock() h.mu.Unlock()
case c := <-h.Unregister: case c := <-h.Unregister:
h.mu.Lock() h.mu.Lock()
if _, ok := h.Clients[c.ID]; ok { if _, ok := h.Clients[c.ID]; ok {
delete(h.Clients, c.ID) delete(h.Clients, c.ID)
close(c.Send) c.close()
} }
h.mu.Unlock() h.mu.Unlock()
if c.RoomID != "" { if c.RoomID != "" {
h.GameMgr.LeaveRoom(c.RoomID, c.ID) h.GameMgr.LeaveRoomWithTTL(c.RoomID, c.ID)
h.broadcastRoomState(c.RoomID) h.broadcastRoomState(c.RoomID)
} }
case <-h.stopChan:
h.mu.Lock()
for _, c := range h.Clients {
c.close()
}
h.Clients = make(map[string]*Client)
h.mu.Unlock()
return
} }
} }
} }
func (h *Hub) Stop() {
h.stopOnce.Do(func() {
close(h.stopChan)
})
}
func (c *Client) close() {
c.doneOnce.Do(func() {
close(c.done)
})
}
func (c *Client) ReadPump() { func (c *Client) ReadPump() {
defer func() { defer func() {
c.Hub.Unregister <- c c.Hub.Unregister <- c
c.Conn.Close()
}() }()
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.Conn.SetPongHandler(func(string) error { c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil return nil
}) })
for { for {
select {
case <-c.done:
return
default:
_, data, err := c.Conn.ReadMessage() _, data, err := c.Conn.ReadMessage()
if err != nil { if err != nil {
break return
} }
c.handleMessage(data) c.handleMessage(data)
} }
}
} }
func (c *Client) WritePump() { func (c *Client) WritePump() {
@@ -91,8 +128,13 @@ func (c *Client) WritePump() {
ticker.Stop() ticker.Stop()
c.Conn.Close() c.Conn.Close()
}() }()
for { for {
select { select {
case <-c.done:
c.Conn.WriteMessage(websocket.CloseMessage, nil)
return
case msg, ok := <-c.Send: case msg, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok { if !ok {
@@ -102,6 +144,7 @@ func (c *Client) WritePump() {
if err := c.Conn.WriteMessage(websocket.TextMessage, msg); err != nil { if err := c.Conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return return
} }
case <-ticker.C: case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
@@ -210,6 +253,7 @@ func (c *Client) sendError(msg string) {
}) })
select { select {
case c.Send <- data: case c.Send <- data:
case <-c.done:
default: default:
} }
} }
@@ -222,6 +266,7 @@ func (h *Hub) broadcastToRoom(roomID string, msg models.Message) {
if c.RoomID == roomID { if c.RoomID == roomID {
select { select {
case c.Send <- data: case c.Send <- data:
case <-c.done:
default: default:
} }
} }
@@ -248,6 +293,7 @@ func (h *Hub) broadcastRoomState(roomID string) {
}) })
select { select {
case c.Send <- data: case c.Send <- data:
case <-c.done:
default: default:
} }
} }
@@ -274,15 +320,15 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
Conn: conn, Conn: conn,
Send: make(chan []byte, 256), Send: make(chan []byte, 256),
Hub: hub, Hub: hub,
done: make(chan struct{}),
} }
hub.mu.Lock() hub.GameMgr.MarkPlayerOnline(roomID, playerID)
hub.Clients[client.ID] = client
hub.mu.Unlock()
go client.WritePump() hub.Register <- client
go client.ReadPump()
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
go client.WritePump()
go client.ReadPump()
hub.broadcastRoomState(roomID) hub.broadcastRoomState(roomID)
} }

View File

@@ -4,6 +4,10 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- REDIS_ADDR=redis:6379
depends_on:
redis:
condition: service_healthy
nginx: nginx:
image: nginx:alpine image: nginx:alpine
@@ -17,3 +21,20 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--maxmemory 128mb
--maxmemory-policy volatile-ttl
--save ""
--appendonly no
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
environment:
- TZ=Asia/Shanghai