From a272dad5f12a1baf817412a3017511525e458837 Mon Sep 17 00:00:00 2001 From: wtz Date: Fri, 20 Feb 2026 19:41:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E5=AD=98=E5=82=A8=E9=83=A8=E5=88=86?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + app/cmd/main.go | 53 ++++- app/go.mod | 11 +- app/go.sum | 10 + app/internal/game/errors.go | 34 ---- app/internal/game/game.go | 384 +++++++++++++++++++++++++++--------- app/internal/redis/redis.go | 252 +++++++++++++++++++++++ app/internal/ws/hub.go | 80 ++++++-- compose.yaml | 21 ++ 9 files changed, 704 insertions(+), 147 deletions(-) delete mode 100644 app/internal/game/errors.go create mode 100644 app/internal/redis/redis.go diff --git a/README.md b/README.md index 9e6998d..4616b3c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - 后端: Go 1.21 - 前端: HTML + CSS + JavaScript - 通信: WebSocket +- 存储: Redis - 代理: Nginx - 部署: Docker Compose @@ -48,6 +49,7 @@ doudizhu-server/ │ │ ├── models/ # 数据模型 │ │ ├── game/ # 游戏逻辑 │ │ ├── handlers/ # HTTP处理器 +│ │ ├── redis/ # Redis客户端封装 │ │ └── ws/ # WebSocket处理 │ ├── Dockerfile │ └── go.mod @@ -87,6 +89,10 @@ docker compose up -d --build ### 本地运行 ```bash +# 启动Redis (Docker) +docker run -d --name redis -p 6379:6379 redis:7-alpine + +# 运行后端 cd doudizhu-server/app go mod tidy go run ./cmd diff --git a/app/cmd/main.go b/app/cmd/main.go index eec44c0..5c3acf1 100644 --- a/app/cmd/main.go +++ b/app/cmd/main.go @@ -1,15 +1,37 @@ package main import ( + "context" "doudizhu-server/internal/game" "doudizhu-server/internal/handlers" + "doudizhu-server/internal/redis" "doudizhu-server/internal/ws" "log" "net/http" + "os" + "os/signal" + "syscall" + "time" ) 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) go hub.Run() @@ -32,8 +54,31 @@ func main() { }) mux.HandleFunc("/api/ws", h.WebSocket) - log.Println("App server starting on :8080") - if err := http.ListenAndServe(":8080", mux); err != nil { - log.Fatal(err) + srv := &http.Server{ + Addr: ":8080", + Handler: mux, } + + go func() { + log.Println("App server starting on :8080") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + 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") } diff --git a/app/go.mod b/app/go.mod index 13ae5be..0ad8733 100644 --- a/app/go.mod +++ b/app/go.mod @@ -2,6 +2,13 @@ module doudizhu-server 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 +) diff --git a/app/go.sum b/app/go.sum index 272772f..858ca63 100644 --- a/app/go.sum +++ b/app/go.sum @@ -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/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/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= diff --git a/app/internal/game/errors.go b/app/internal/game/errors.go deleted file mode 100644 index 0151f04..0000000 --- a/app/internal/game/errors.go +++ /dev/null @@ -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) -} diff --git a/app/internal/game/game.go b/app/internal/game/game.go index 70d253f..eb77ac3 100644 --- a/app/internal/game/game.go +++ b/app/internal/game/game.go @@ -1,13 +1,31 @@ package game import ( + "context" "doudizhu-server/internal/models" + "doudizhu-server/internal/redis" + "encoding/json" + "errors" "math/rand" "sort" "sync" "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{} func NewCardLogic() *CardLogic { @@ -227,30 +245,31 @@ func (cl *CardLogic) getMainValue(cards []models.Card) int { } type GameManager struct { - rooms map[string]*models.Room - players map[string]*models.Player - deck map[string][]models.Card - discard map[string][]models.Card - mu sync.RWMutex - cl *CardLogic + rdb *redis.Client + cl *CardLogic + mu sync.RWMutex + stopCleanup chan struct{} + stopOnce sync.Once } -func NewGameManager() *GameManager { - return &GameManager{ - rooms: make(map[string]*models.Room), - players: make(map[string]*models.Player), - deck: make(map[string][]models.Card), - discard: make(map[string][]models.Card), - cl: NewCardLogic(), +func NewGameManager(rdb *redis.Client) *GameManager { + gm := &GameManager{ + rdb: rdb, + cl: NewCardLogic(), + stopCleanup: make(chan struct{}), } + go gm.cleanupLoop() + return gm } func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) { gm.mu.Lock() defer gm.mu.Unlock() + ctx := context.Background() roomID := generateID() playerID := generateID() + player := &models.Player{ID: playerID, Name: playerName, IsOnline: true} room := &models.Room{ ID: roomID, @@ -262,10 +281,9 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro deck := gm.cl.CreateDeck() gm.cl.Shuffle(deck) - gm.rooms[roomID] = room - gm.players[playerID] = player - gm.deck[roomID] = deck - gm.discard[roomID] = make([]models.Card, 0) + gm.saveRoom(ctx, room) + gm.saveDeck(ctx, roomID, deck) + gm.saveDiscard(ctx, roomID, []models.Card{}) return room, player } @@ -274,8 +292,9 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model gm.mu.Lock() defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { return nil, nil, ErrRoomNotFound } if len(room.Players) >= room.MaxPlayers { @@ -288,8 +307,8 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model playerID := generateID() player := &models.Player{ID: playerID, Name: playerName, IsOnline: true} room.Players = append(room.Players, player) - gm.players[playerID] = player + gm.saveRoom(ctx, room) return room, player, nil } @@ -297,19 +316,20 @@ func (gm *GameManager) LeaveRoom(roomID, playerID string) error { gm.mu.Lock() defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { return ErrRoomNotFound } for i, p := range room.Players { if p.ID == playerID { room.Players = append(room.Players[:i], room.Players[i+1:]...) - delete(gm.players, playerID) + if len(room.Players) == 0 { - delete(gm.rooms, roomID) - delete(gm.deck, roomID) - delete(gm.discard, roomID) + gm.deleteRoomData(ctx, roomID) + } else { + gm.saveRoom(ctx, room) } return nil } @@ -317,25 +337,68 @@ func (gm *GameManager) LeaveRoom(roomID, playerID string) error { 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 { gm.mu.Lock() defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { return ErrRoomNotFound } - + if room.State == models.RoomStatePlaying { return ErrGameStarted } - + for _, p := range room.Players { if p.ID == playerID { p.IsReady = ready if room.State == models.RoomStateFinished { room.State = models.RoomStateWaiting } + gm.saveRoom(ctx, room) return nil } } @@ -346,8 +409,9 @@ func (gm *GameManager) StartGame(roomID string) error { gm.mu.Lock() defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { return ErrRoomNotFound } if len(room.Players) < 2 { @@ -357,6 +421,8 @@ func (gm *GameManager) StartGame(roomID string) error { return ErrGameStarted } + lastWinner := room.LastWinner + for _, p := range room.Players { p.Cards = make([]models.Card, 0) p.CardCount = 0 @@ -365,29 +431,40 @@ func (gm *GameManager) StartGame(roomID string) error { deck := gm.cl.CreateDeck() gm.cl.Shuffle(deck) - gm.deck[roomID] = deck - gm.discard[roomID] = make([]models.Card, 0) + gm.saveDeck(ctx, roomID, deck) + gm.saveDiscard(ctx, roomID, []models.Card{}) room.State = models.RoomStatePlaying room.RoundCount = 1 - room.CurrentTurn = 0 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 } -func (gm *GameManager) dealCards(roomID string, count int) { - room := gm.rooms[roomID] - deck := gm.deck[roomID] +func (gm *GameManager) dealCards(room *models.Room, count int) { + ctx := context.Background() + deck, _ := gm.loadDeck(ctx, room.ID) for i := 0; i < count; i++ { for _, p := range room.Players { if len(deck) == 0 { - deck = gm.discard[roomID] + deck, _ = gm.loadDiscard(ctx, room.ID) gm.cl.Shuffle(deck) - gm.discard[roomID] = make([]models.Card, 0) + gm.saveDiscard(ctx, room.ID, []models.Card{}) } if len(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 { gm.cl.SortCards(p.Cards) @@ -407,8 +485,9 @@ func (gm *GameManager) PlayCards(roomID, playerID string, cards []models.Card) e gm.mu.Lock() defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { return ErrRoomNotFound } if room.State != models.RoomStatePlaying { @@ -439,15 +518,20 @@ func (gm *GameManager) PlayCards(roomID, playerID string, cards []models.Card) e Cards: cards, 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 { room.LastWinner = playerID 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 } @@ -455,8 +539,9 @@ func (gm *GameManager) Pass(roomID, playerID string) error { gm.mu.Lock() defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { return ErrRoomNotFound } if room.State != models.RoomStatePlaying { @@ -469,18 +554,67 @@ func (gm *GameManager) Pass(roomID, playerID string) error { return ErrCannotPass } - gm.nextTurn(room) + room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players) + // 一轮结束:回到最后出牌者,发牌并让他先出 if room.Players[room.CurrentTurn].ID == room.LastPlay.PlayerID { + winnerID := room.LastPlay.PlayerID room.LastPlay = nil - gm.dealCards(roomID, 1) 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 } -func (gm *GameManager) nextTurn(room *models.Room) { - room.CurrentTurn = (room.CurrentTurn + 1) % len(room.Players) +func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) { + 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 { @@ -516,48 +650,118 @@ func (gm *GameManager) removeCards(player *models.Player, cards []models.Card) { player.Cards = newCards } -func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) { - gm.mu.RLock() - defer gm.mu.RUnlock() - room, ok := gm.rooms[roomID] - if !ok { - return nil, ErrRoomNotFound +func (gm *GameManager) cleanupLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + gm.cleanupExpired() + case <-gm.stopCleanup: + return + } } - return room, nil } -func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room { - gm.mu.RLock() - defer gm.mu.RUnlock() +func (gm *GameManager) cleanupExpired() { + gm.mu.Lock() + defer gm.mu.Unlock() - room, ok := gm.rooms[roomID] - if !ok { + ctx := context.Background() + + 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 + } + 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)) - for i, p := range room.Players { - pp := &models.Player{ - ID: p.ID, - Name: p.Name, - IsReady: p.IsReady, - IsOnline: p.IsOnline, - CardCount: p.CardCount, + gm.rdb.ScanKeys(ctx, "player:*:ttl", func(key string) error { + var expiryTime int64 + if err := gm.rdb.Get(ctx, key, &expiryTime); err != nil { + return nil } - if p.ID == playerID { - pp.Cards = p.Cards + if time.Now().Unix() > expiryTime { + 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) } diff --git a/app/internal/redis/redis.go b/app/internal/redis/redis.go new file mode 100644 index 0000000..9ba70c2 --- /dev/null +++ b/app/internal/redis/redis.go @@ -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" +} diff --git a/app/internal/ws/hub.go b/app/internal/ws/hub.go index 7cb6298..97caa22 100644 --- a/app/internal/ws/hub.go +++ b/app/internal/ws/hub.go @@ -20,11 +20,13 @@ var upgrader = websocket.Upgrader{ } type Client struct { - ID string - RoomID string - Conn *websocket.Conn - Send chan []byte - Hub *Hub + ID string + RoomID string + Conn *websocket.Conn + Send chan []byte + Hub *Hub + done chan struct{} + doneOnce sync.Once } type Hub struct { @@ -33,6 +35,8 @@ type Hub struct { Unregister chan *Client GameMgr *game.GameManager mu sync.RWMutex + stopChan chan struct{} + stopOnce sync.Once } func NewHub(gameMgr *game.GameManager) *Hub { @@ -41,6 +45,7 @@ func NewHub(gameMgr *game.GameManager) *Hub { Register: make(chan *Client, 64), Unregister: make(chan *Client, 64), GameMgr: gameMgr, + stopChan: make(chan struct{}), } } @@ -49,39 +54,71 @@ func (h *Hub) Run() { select { case c := <-h.Register: h.mu.Lock() + if old, exists := h.Clients[c.ID]; exists { + old.close() + delete(h.Clients, old.ID) + } h.Clients[c.ID] = c h.mu.Unlock() + case c := <-h.Unregister: h.mu.Lock() if _, ok := h.Clients[c.ID]; ok { delete(h.Clients, c.ID) - close(c.Send) + c.close() } h.mu.Unlock() if c.RoomID != "" { - h.GameMgr.LeaveRoom(c.RoomID, c.ID) + h.GameMgr.LeaveRoomWithTTL(c.RoomID, c.ID) 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() { defer func() { c.Hub.Unregister <- c - c.Conn.Close() }() + c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.Conn.SetPongHandler(func(string) error { c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) return nil }) + for { - _, data, err := c.Conn.ReadMessage() - if err != nil { - break + select { + case <-c.done: + return + default: + _, data, err := c.Conn.ReadMessage() + if err != nil { + return + } + c.handleMessage(data) } - c.handleMessage(data) } } @@ -91,8 +128,13 @@ func (c *Client) WritePump() { ticker.Stop() c.Conn.Close() }() + for { select { + case <-c.done: + c.Conn.WriteMessage(websocket.CloseMessage, nil) + return + case msg, ok := <-c.Send: c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if !ok { @@ -102,6 +144,7 @@ func (c *Client) WritePump() { if err := c.Conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } + case <-ticker.C: c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { @@ -210,6 +253,7 @@ func (c *Client) sendError(msg string) { }) select { case c.Send <- data: + case <-c.done: default: } } @@ -222,6 +266,7 @@ func (h *Hub) broadcastToRoom(roomID string, msg models.Message) { if c.RoomID == roomID { select { case c.Send <- data: + case <-c.done: default: } } @@ -248,6 +293,7 @@ func (h *Hub) broadcastRoomState(roomID string) { }) select { case c.Send <- data: + case <-c.done: default: } } @@ -274,15 +320,15 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { Conn: conn, Send: make(chan []byte, 256), Hub: hub, + done: make(chan struct{}), } - hub.mu.Lock() - hub.Clients[client.ID] = client - hub.mu.Unlock() + hub.GameMgr.MarkPlayerOnline(roomID, playerID) - go client.WritePump() - go client.ReadPump() + hub.Register <- client time.Sleep(50 * time.Millisecond) + go client.WritePump() + go client.ReadPump() hub.broadcastRoomState(roomID) } diff --git a/compose.yaml b/compose.yaml index 9e193c2..a97ed3e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,6 +4,10 @@ services: restart: unless-stopped environment: - TZ=Asia/Shanghai + - REDIS_ADDR=redis:6379 + depends_on: + redis: + condition: service_healthy nginx: image: nginx:alpine @@ -17,3 +21,20 @@ services: restart: unless-stopped environment: - 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