增加用户管理

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

@@ -23,29 +23,24 @@ doudizhu-server/
## 2. 构建与运行命令 ## 2. 构建与运行命令
```bash ```bash
# 本地开发 # 本地开发需先启动Redis
docker run -d --name redis -p 6379:6379 redis:7-alpine
cd doudizhu-server/app && go mod tidy && go run ./cmd cd doudizhu-server/app && go mod tidy && go run ./cmd
# Docker部署 # Docker部署
cd doudizhu-server && docker compose up -d --build cd doudizhu-server && docker compose up -d --build
# 代码格式化 # 代码格式化自动格式化所有Go文件
gofmt -w ./app gofmt -w ./app
# 静态检查 # 静态检查
go vet ./app/... go vet ./app/...
# 运行所有测试 # 测试(当前项目暂无测试文件,以下为参考命令)
go test ./app/... # go test ./app/...
# go test ./app/internal/game/...
# 运行单个包测试 # go test ./app/internal/game/... -run TestCardType
go test ./app/internal/game/... # go test ./app/... -cover
# 运行单个测试
go test ./app/internal/game/... -run TestCardType
# 带覆盖率
go test ./app/... -cover
``` ```
## 3. Go代码风格规范 ## 3. Go代码风格规范
@@ -114,6 +109,29 @@ func (gm *GameManager) CreateRoom(...) {
} }
``` ```
### 函数长度
- **禁止超过150行**不宜超过50行
- 保持函数单一职责,提高可读性和可维护性
### 代码优雅性
1. **空行规范**:不同逻辑块之间空一行
2. **运算符空格**:双目运算符(`+`, `=`, `==` 等)两端必须加空格
3. **禁止多行合并**:禁止使用分号将多行代码合并到一行
### 介词命名规范(用于函数名)
| 介词 | 用法 |
| ------ | -------------------------------------------------------------------- |
| by | 表示动作的执行者或方式,如 `formatByLocale``calculateByDate` |
| from | 表示数据来源、起点或类型转换源,如 `parseFromJson` |
| to | 指明目标位置、接收方或变换终点,如 `sendToServer``uploadToCloud` |
| with | 说明附加参数或属性,如 `buildWithOptions``createWithDefaults` |
| in | 描述在某个上下文/环境中执行的动作,如 `runInBackground` |
| on | 与事件相关的处理器或触发点,如 `handleOnClick``listenOnChange` |
| for | 指定目标受众、用途或适用范围,如 `renderForAdmin``queryForUser` |
## 4. HTTP API响应格式 ## 4. HTTP API响应格式
```go ```go
@@ -138,7 +156,8 @@ type ApiResponse struct {
2. **后端API路由必须在 `/api` 下** 2. **后端API路由必须在 `/api` 下**
3. **WebSocket路径**`/api/ws?roomId=xxx&playerId=xxx` 3. **WebSocket路径**`/api/ws?roomId=xxx&playerId=xxx`
4. **前端不使用npm/react**采用HTML+CSS+JS 4. **前端不使用npm/react**采用HTML+CSS+JS
5. **JSON字段用下划线命名**,如 `room_id`,但尽量用单词 5. **JSON字段用camelCase命名**,如 `roomId``playerId``cardCount`
6. **Go版本**Go 1.21
## 7. 调试 ## 7. 调试

167
API.md
View File

@@ -27,7 +27,110 @@
--- ---
## HTTP API ## 认证相关 API
### 获取验证码
**GET** `/api/auth/captcha`
响应:
```json
{
"status": 200,
"code": 0,
"message": "success",
"data": {
"captchaId": "abc123",
"image": "data:image/png;base64,..."
}
}
```
### 用户注册
**POST** `/api/auth/register`
请求体:
```json
{
"username": "testuser",
"password": "123456",
"nickname": "测试玩家",
"captcha": "ABCD",
"captchaId": "abc123"
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 用户名3-20字符 |
| password | string | 是 | 密码至少6位 |
| nickname | string | 是 | 昵称 |
| captcha | string | 是 | 验证码 |
| captchaId | string | 是 | 验证码ID |
响应:
```json
{
"status": 200,
"code": 0,
"message": "success",
"data": {
"token": "xxx",
"userId": "xxx",
"username": "testuser",
"nickname": "测试玩家"
}
}
```
### 用户登录
**POST** `/api/auth/login`
请求体:
```json
{
"username": "testuser",
"password": "123456",
"captcha": "ABCD",
"captchaId": "abc123"
}
```
响应: 同注册
### 验证Token
**GET** `/api/auth/validate`
请求头: `Authorization: Bearer {token}`
响应:
```json
{
"status": 200,
"code": 0,
"message": "success",
"data": {
"userId": "xxx",
"username": "testuser",
"nickname": "测试玩家"
}
}
```
### 退出登录
**POST** `/api/auth/logout`
请求头: `Authorization: Bearer {token}`
---
## 房间相关 API
> 注意: 所有房间相关 API 需要在请求头中携带 `Authorization: Bearer {token}`
### 创建房间 ### 创建房间
@@ -83,6 +186,53 @@
} }
``` ```
### 离开房间
**POST** `/api/rooms/leave`
请求头: `Authorization: Bearer {token}`
响应:
```json
{
"status": 200,
"code": 0,
"message": "success",
"data": null
}
```
### 获取当前房间
**GET** `/api/rooms/current`
请求头: `Authorization: Bearer {token}`
用于检查用户当前是否在房间中。如果用户之前创建了房间但未离开,可以获取房间信息并自动重新连接。
响应(在房间中):
```json
{
"status": 200,
"code": 0,
"message": "success",
"data": {
"roomId": "a1b2c3d4",
"playerId": "i9j0k1l2"
}
}
```
响应(不在房间中):
```json
{
"status": 200,
"code": 0,
"message": "success",
"data": null
}
```
### 错误响应示例 ### 错误响应示例
```json ```json
@@ -124,6 +274,7 @@
| chat | 双向 | 聊天消息 | | chat | 双向 | 聊天消息 |
| state | 服务器→客户端 | 游戏状态更新 | | state | 服务器→客户端 | 游戏状态更新 |
| gameOver | 服务器→客户端 | 游戏结束 | | gameOver | 服务器→客户端 | 游戏结束 |
| leave | 服务器→客户端 | 玩家离开房间 |
| error | 服务器→客户端 | 错误消息 | | error | 服务器→客户端 | 错误消息 |
### 准备 ### 准备
@@ -169,6 +320,7 @@
### 聊天 ### 聊天
客户端发送:
```json ```json
{ {
"type": "chat", "type": "chat",
@@ -178,6 +330,19 @@
} }
``` ```
服务器推送:
```json
{
"type": "chat",
"data": {
"playerId": "xxx",
"playerName": "玩家昵称",
"message": "消息内容"
},
"timestamp": 1708123456
}
```
### 游戏状态 (服务器推送) ### 游戏状态 (服务器推送)
```json ```json

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 ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"doudizhu-server/internal/captcha"
"doudizhu-server/internal/db"
"doudizhu-server/internal/game" "doudizhu-server/internal/game"
"doudizhu-server/internal/handlers" "doudizhu-server/internal/handlers"
"doudizhu-server/internal/redis" "doudizhu-server/internal/redis"
@@ -10,15 +12,13 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"syscall" "syscall"
"time" "time"
) )
func main() { func main() {
redisAddr := os.Getenv("REDIS_ADDR") redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
if redisAddr == "" {
redisAddr = "localhost:6379"
}
redisClient, err := redis.NewClient(redis.Config{ redisClient, err := redis.NewClient(redis.Config{
Addr: redisAddr, Addr: redisAddr,
@@ -31,13 +31,74 @@ func main() {
defer redisClient.Close() defer redisClient.Close()
log.Println("Connected to Redis:", redisAddr) 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) gameMgr := game.NewGameManager(redisClient)
hub := ws.NewHub(gameMgr) hub := ws.NewHub(gameMgr)
go hub.Run() go hub.Run()
h := handlers.NewHandler(gameMgr, hub) captchaMgr := captcha.NewManager(redisClient)
h := handlers.NewHandler(gameMgr, hub, redisClient, database, captchaMgr)
mux := http.NewServeMux() 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) { mux.HandleFunc("/api/rooms", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" { if r.Method == "POST" {
h.CreateRoom(w, r) h.CreateRoom(w, r)
@@ -45,6 +106,23 @@ func main() {
http.NotFound(w, r) 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) { mux.HandleFunc("/api/rooms/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" { if r.Method == "POST" {
h.JoinRoom(w, r) h.JoinRoom(w, r)
@@ -52,6 +130,7 @@ func main() {
http.NotFound(w, r) http.NotFound(w, r)
} }
}) })
mux.HandleFunc("/api/ws", h.WebSocket) mux.HandleFunc("/api/ws", h.WebSocket)
srv := &http.Server{ srv := &http.Server{
@@ -82,3 +161,10 @@ func main() {
gameMgr.Stop() gameMgr.Stop()
log.Println("Server stopped") 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 module doudizhu-server
go 1.21 go 1.24.0
require ( require (
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/jackc/pgx/v5 v5.8.0
github.com/redis/go-redis/v9 v9.5.1 github.com/redis/go-redis/v9 v9.5.1
) )
require ( require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/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/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 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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/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 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 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 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=
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) { 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() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
@@ -270,7 +274,7 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro
roomID := generateID() roomID := generateID()
playerID := 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{ room := &models.Room{
ID: roomID, ID: roomID,
Players: []*models.Player{player}, 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) { 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() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
@@ -305,7 +313,7 @@ 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, UserID: userID, Name: playerName, IsOnline: true}
room.Players = append(room.Players, player) room.Players = append(room.Players, player)
gm.saveRoom(ctx, room) 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 { func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
gm.mu.Lock() gm.mu.Lock()
defer gm.mu.Unlock() defer gm.mu.Unlock()
@@ -581,6 +608,40 @@ func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) {
return gm.loadRoom(context.Background(), roomID) 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 { func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
gm.mu.RLock() gm.mu.RLock()
defer gm.mu.RUnlock() defer gm.mu.RUnlock()

View File

@@ -1,42 +1,243 @@
package handlers package handlers
import ( import (
"context"
"crypto/sha256"
"doudizhu-server/internal/captcha"
"doudizhu-server/internal/db"
"doudizhu-server/internal/game" "doudizhu-server/internal/game"
"doudizhu-server/internal/models" "doudizhu-server/internal/models"
"doudizhu-server/internal/redis"
"doudizhu-server/internal/ws" "doudizhu-server/internal/ws"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"strings" "strings"
"time"
) )
type Handler struct { type Handler struct {
GameMgr *game.GameManager GameMgr *game.GameManager
Hub *ws.Hub Hub *ws.Hub
Rdb *redis.Client
DB *db.DB
Captcha *captcha.Manager
UserTTL time.Duration
} }
func NewHandler(gameMgr *game.GameManager, hub *ws.Hub) *Handler { func NewHandler(gameMgr *game.GameManager, hub *ws.Hub, rdb *redis.Client, database *db.DB, captchaMgr *captcha.Manager) *Handler {
return &Handler{GameMgr: gameMgr, Hub: hub} 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) { 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 var req models.CreateRoomRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.jsonError(w, http.StatusBadRequest, "invalid request") h.jsonError(w, http.StatusBadRequest, "invalid request")
return return
} }
if req.PlayerName == "" {
h.jsonError(w, http.StatusBadRequest, "player name required")
return
}
if req.MaxPlayers < 2 || req.MaxPlayers > 10 { if req.MaxPlayers < 2 || req.MaxPlayers > 10 {
req.MaxPlayers = 4 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}) h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
} }
func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) { 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)) roomID := strings.ToLower(extractRoomID(r.URL.Path))
if roomID == "" { if roomID == "" {
h.jsonError(w, http.StatusBadRequest, "room id required") h.jsonError(w, http.StatusBadRequest, "room id required")
@@ -53,18 +254,109 @@ func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
h.jsonError(w, http.StatusBadRequest, err.Error()) h.jsonError(w, http.StatusBadRequest, err.Error())
return return
} }
h.Rdb.Set(ctx, redis.UserRoomKey(userID), room.ID, redis.RoomTTL)
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID}) 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) { func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) {
ws.ServeWs(h.Hub, w, r) 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{}) { func (h *Handler) jsonSuccess(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.ApiResponse{ 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 { func extractRoomID(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/") parts := strings.Split(strings.Trim(path, "/"), "/")
for i, p := range parts { 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 parts[i+1]
} }
} }
return "" 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 { type Player struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Cards []Card `json:"cards,omitempty"` Cards []Card `json:"cards,omitempty"`
CardCount int `json:"cardCount"` CardCount int `json:"cardCount"`
@@ -101,3 +102,39 @@ type CreateRoomRequest struct {
type JoinRoomRequest struct { type JoinRoomRequest struct {
PlayerName string `json:"playerName"` 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 { func PlayerTTLKey(playerID string) string {
return "player:" + playerID + ":ttl" 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() h.mu.Unlock()
if c.RoomID != "" { if c.RoomID != "" {
h.GameMgr.LeaveRoomWithTTL(c.RoomID, c.ID) h.GameMgr.MarkPlayerOffline(c.RoomID, c.ID)
h.broadcastRoomState(c.RoomID) h.broadcastRoomState(c.RoomID)
} }
@@ -201,7 +201,20 @@ func (c *Client) handleMessage(data []byte) {
c.Hub.broadcastRoomState(c.RoomID) c.Hub.broadcastRoomState(c.RoomID)
case models.MsgTypeChat: 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) { func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
roomID := strings.ToLower(r.URL.Query().Get("roomId")) roomID := strings.ToLower(r.URL.Query().Get("roomId"))
playerID := r.URL.Query().Get("playerId") playerID := r.URL.Query().Get("playerId")

View File

@@ -5,7 +5,14 @@ services:
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- DB_HOST=postgres
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=postgres
- DB_NAME=doudizhu
depends_on: depends_on:
flyway:
condition: service_completed_successfully
redis: redis:
condition: service_healthy condition: service_healthy
@@ -38,3 +45,36 @@ services:
retries: 5 retries: 5
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=doudizhu
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d doudizhu"]
interval: 5s
timeout: 3s
retries: 5
flyway:
image: flyway/flyway:10
depends_on:
postgres:
condition: service_healthy
volumes:
- ./flyway/migrations:/flyway/sql:ro
command: >
-url=jdbc:postgresql://postgres:5432/doudizhu
-user=postgres
-password=postgres
-connectRetries=10
migrate
volumes:
postgres_data:

View File

@@ -0,0 +1,12 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(32) PRIMARY KEY,
username VARCHAR(20) NOT NULL UNIQUE,
password VARCHAR(64) NOT NULL,
nickname VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create index on username for faster lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);

View File

@@ -7,6 +7,17 @@ body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#1a1a2e,
h1{text-align:center;font-size:2rem;color:#ffd700;margin-bottom:20px} h1{text-align:center;font-size:2rem;color:#ffd700;margin-bottom:20px}
h2{text-align:center;color:#ffd700;margin-bottom:15px} h2{text-align:center;color:#ffd700;margin-bottom:15px}
h3{color:#ffd700;margin-bottom:10px} h3{color:#ffd700;margin-bottom:10px}
.auth-tabs{display:flex;justify-content:center;gap:10px;margin-bottom:15px}
.auth-tabs .tab{padding:10px 30px;border:none;border-radius:6px 6px 0 0;cursor:pointer;font-size:15px;background:rgba(255,255,255,.1);color:#aaa;transition:.2s}
.auth-tabs .tab.active{background:rgba(255,255,255,.2);color:#ffd700}
.auth-form{background:rgba(255,255,255,.1);padding:25px;border-radius:0 0 12px 12px;max-width:350px;margin:0 auto 20px}
.auth-form input{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px}
.auth-form input::placeholder{color:#aaa}
.captcha-row{display:flex;gap:8px;margin-bottom:10px}
.captcha-row input{flex:1}
.captcha-img{height:38px;border-radius:4px;cursor:pointer}
.user-info{display:flex;justify-content:center;align-items:center;gap:15px;margin-bottom:15px}
.user-info span{color:#ffd700}
.form{background:rgba(255,255,255,.1);padding:25px;border-radius:12px;max-width:350px;margin:0 auto 20px} .form{background:rgba(255,255,255,.1);padding:25px;border-radius:12px;max-width:350px;margin:0 auto 20px}
.form input,.form select{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px} .form input,.form select{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px}
.form input::placeholder{color:#aaa} .form input::placeholder{color:#aaa}

View File

@@ -8,10 +8,49 @@
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<div id="lobby" class="screen"> <div id="auth" class="screen">
<h1>斗地主残局版</h1> <h1>斗地主残局版</h1>
<div class="auth-tabs">
<button id="loginTab" class="tab active">登录</button>
<button id="registerTab" class="tab">注册</button>
</div>
<div id="loginForm" class="auth-form">
<input type="text" id="loginUsername" placeholder="用户名" maxlength="20">
<input type="password" id="loginPassword" placeholder="密码">
<div class="captcha-row">
<input type="text" id="loginCaptcha" placeholder="验证码" maxlength="4">
<img id="loginCaptchaImg" src="" alt="验证码" class="captcha-img">
</div>
<button id="loginBtn" class="btn primary">登录</button>
</div>
<div id="registerForm" class="auth-form hidden">
<input type="text" id="regUsername" placeholder="用户名 (3-20字符)" maxlength="20">
<input type="password" id="regPassword" placeholder="密码 (至少6位)">
<input type="text" id="regNickname" placeholder="昵称" maxlength="10">
<div class="captcha-row">
<input type="text" id="regCaptcha" placeholder="验证码" maxlength="4">
<img id="regCaptchaImg" src="" alt="验证码" class="captcha-img">
</div>
<button id="registerBtn" class="btn primary">注册</button>
</div>
<div class="rules">
<h3>规则</h3>
<ul>
<li>新增"超人强"为最大单牌</li>
<li>每人初始5张牌</li>
<li>三张可成顺子/炸弹,两对可成连对</li>
</ul>
</div>
</div>
<div id="lobby" class="screen hidden">
<h1>斗地主残局版</h1>
<div class="user-info">
<span id="welcomeUser">欢迎</span>
<button id="logoutBtn" class="btn sm">退出登录</button>
</div>
<div class="form"> <div class="form">
<input type="text" id="playerName" placeholder="昵称" maxlength="10"> <input type="text" id="playerName" placeholder="游戏昵称" maxlength="10">
<select id="maxPlayers"> <select id="maxPlayers">
<option value="2">2人</option> <option value="2">2人</option>
<option value="3">3人</option> <option value="3">3人</option>
@@ -25,14 +64,6 @@
<button id="joinBtn" class="btn">加入</button> <button id="joinBtn" class="btn">加入</button>
</div> </div>
</div> </div>
<div class="rules">
<h3>规则</h3>
<ul>
<li>新增"超人强"为最大单牌</li>
<li>每人初始5张牌</li>
<li>三张可成顺子/炸弹,两对可成连对</li>
</ul>
</div>
</div> </div>
<div id="waiting" class="screen hidden"> <div id="waiting" class="screen hidden">
@@ -41,7 +72,7 @@
<div id="playerList"></div> <div id="playerList"></div>
<div class="actions"> <div class="actions">
<button id="readyBtn" class="btn primary">准备</button> <button id="readyBtn" class="btn primary">准备</button>
<button id="leaveBtn" class="btn danger">离开</button> <button id="leaveBtn" class="btn danger">离开房间</button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,6 @@
let ws = null, playerId = '', roomId = '', state = null, selected = []; let ws = null, playerId = '', roomId = '', state = null, selected = [];
let token = '', userId = '', username = '', nickname = '';
let loginCaptchaId = '', regCaptchaId = '';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
function show(id) { function show(id) {
@@ -13,13 +15,191 @@ function chat(name, msg, sys) {
$('chatMsgs').scrollTop = 1e6; $('chatMsgs').scrollTop = 1e6;
} }
function authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
}
async function getCaptcha(type) {
try {
const res = await fetch('/api/auth/captcha');
const d = await res.json();
if (d.code === 0) {
if (type === 'login') {
loginCaptchaId = d.data.captchaId;
$('loginCaptchaImg').src = d.data.image;
} else {
regCaptchaId = d.data.captchaId;
$('regCaptchaImg').src = d.data.image;
}
}
} catch (e) {
console.error('Failed to get captcha:', e);
}
}
async function login() {
const usernameVal = $('loginUsername').value.trim();
const passwordVal = $('loginPassword').value;
const captchaVal = $('loginCaptcha').value.trim().toUpperCase();
if (!usernameVal || !passwordVal || !captchaVal) {
alert('请填写完整信息');
return;
}
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: usernameVal,
password: passwordVal,
captcha: captchaVal,
captchaId: loginCaptchaId
})
});
const d = await res.json();
if (d.code === 0) {
token = d.data.token;
userId = d.data.userId;
username = d.data.username;
nickname = d.data.nickname;
localStorage.setItem('token', token);
localStorage.setItem('userId', userId);
localStorage.setItem('username', username);
localStorage.setItem('nickname', nickname);
showLobby();
} else {
alert(d.message);
getCaptcha('login');
}
} catch (e) {
alert('登录失败: ' + e.message);
getCaptcha('login');
}
}
async function register() {
const usernameVal = $('regUsername').value.trim();
const passwordVal = $('regPassword').value;
const nicknameVal = $('regNickname').value.trim();
const captchaVal = $('regCaptcha').value.trim().toUpperCase();
if (!usernameVal || !passwordVal || !nicknameVal || !captchaVal) {
alert('请填写完整信息');
return;
}
if (usernameVal.length < 3 || usernameVal.length > 20) {
alert('用户名需要3-20个字符');
return;
}
if (passwordVal.length < 6) {
alert('密码至少6位');
return;
}
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: usernameVal,
password: passwordVal,
nickname: nicknameVal,
captcha: captchaVal,
captchaId: regCaptchaId
})
});
const d = await res.json();
if (d.code === 0) {
token = d.data.token;
userId = d.data.userId;
username = d.data.username;
nickname = d.data.nickname;
localStorage.setItem('token', token);
localStorage.setItem('userId', userId);
localStorage.setItem('username', username);
localStorage.setItem('nickname', nickname);
showLobby();
} else {
alert(d.message);
getCaptcha('register');
}
} catch (e) {
alert('注册失败: ' + e.message);
getCaptcha('register');
}
}
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: authHeaders()
});
} catch (e) {}
token = '';
userId = '';
username = '';
nickname = '';
localStorage.removeItem('token');
localStorage.removeItem('userId');
localStorage.removeItem('username');
localStorage.removeItem('nickname');
show('auth');
getCaptcha('login');
}
async function validateToken() {
if (!token) return false;
try {
const res = await fetch('/api/auth/validate', {
headers: authHeaders()
});
const d = await res.json();
if (d.code === 0) {
userId = d.data.userId;
username = d.data.username;
nickname = d.data.nickname;
return true;
}
} catch (e) {}
return false;
}
async function checkCurrentRoom() {
try {
const res = await fetch('/api/rooms/current', {
headers: authHeaders()
});
const d = await res.json();
if (d.code === 0 && d.data && d.data.roomId) {
roomId = d.data.roomId;
playerId = d.data.playerId;
return true;
}
} catch (e) {}
return false;
}
async function showLobby() {
$('welcomeUser').textContent = '欢迎, ' + nickname;
$('playerName').value = nickname;
show('lobby');
}
async function create() { async function create() {
const name = $('playerName').value.trim(); const name = $('playerName').value.trim();
if (!name) { alert('请输入昵称'); return; } if (!name) { alert('请输入昵称'); return; }
try { try {
const res = await fetch('/api/rooms', { const res = await fetch('/api/rooms', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: authHeaders(),
body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value}) body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value})
}); });
const d = await res.json(); const d = await res.json();
@@ -43,7 +223,7 @@ async function join() {
try { try {
const res = await fetch('/api/rooms/' + rid + '/join', { const res = await fetch('/api/rooms/' + rid + '/join', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: authHeaders(),
body: JSON.stringify({playerName: name}) body: JSON.stringify({playerName: name})
}); });
const d = await res.json(); const d = await res.json();
@@ -59,6 +239,21 @@ async function join() {
} }
} }
async function leaveRoom() {
try {
await fetch('/api/rooms/leave', {
method: 'POST',
headers: authHeaders()
});
} catch (e) {}
if (ws) ws.close();
show('lobby');
roomId = '';
playerId = '';
state = null;
selected = [];
}
function connect() { function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/api/ws?roomId=' + roomId + '&playerId=' + playerId); ws = new WebSocket(proto + '//' + location.host + '/api/ws?roomId=' + roomId + '&playerId=' + playerId);
@@ -68,6 +263,7 @@ function connect() {
if (msg.type === 'state') render(msg.data); if (msg.type === 'state') render(msg.data);
else if (msg.type === 'gameOver') showGameOver(msg.data); else if (msg.type === 'gameOver') showGameOver(msg.data);
else if (msg.type === 'chat') chat(msg.data.playerName, msg.data.message); else if (msg.type === 'chat') chat(msg.data.playerName, msg.data.message);
else if (msg.type === 'leave') chat('', '有玩家离开房间', true);
else if (msg.type === 'error') chat('', msg.data, true); else if (msg.type === 'error') chat('', msg.data, true);
}; };
ws.onerror = function() { chat('', '连接错误', true); }; ws.onerror = function() { chat('', '连接错误', true); };
@@ -308,9 +504,7 @@ function toggleReady() {
} }
function leave() { function leave() {
if (ws) ws.close(); leaveRoom();
show('lobby');
roomId = ''; playerId = ''; state = null; selected = [];
} }
function showGameOver(d) { function showGameOver(d) {
@@ -329,11 +523,31 @@ function again() {
send('ready', {ready: false}); send('ready', {ready: false});
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', async function() {
$('loginTab').onclick = function() {
$('loginTab').classList.add('active');
$('registerTab').classList.remove('active');
$('loginForm').classList.remove('hidden');
$('registerForm').classList.add('hidden');
getCaptcha('login');
};
$('registerTab').onclick = function() {
$('registerTab').classList.add('active');
$('loginTab').classList.remove('active');
$('registerForm').classList.remove('hidden');
$('loginForm').classList.add('hidden');
getCaptcha('register');
};
$('loginCaptchaImg').onclick = function() { getCaptcha('login'); };
$('regCaptchaImg').onclick = function() { getCaptcha('register'); };
$('loginBtn').onclick = login;
$('registerBtn').onclick = register;
$('logoutBtn').onclick = logout;
$('createBtn').onclick = create; $('createBtn').onclick = create;
$('joinBtn').onclick = join; $('joinBtn').onclick = join;
$('readyBtn').onclick = toggleReady; $('readyBtn').onclick = toggleReady;
$('leaveBtn').onclick = leave; $('leaveBtn').onclick = leaveRoom;
$('playBtn').onclick = play; $('playBtn').onclick = play;
$('passBtn').onclick = pass; $('passBtn').onclick = pass;
$('againBtn').onclick = again; $('againBtn').onclick = again;
@@ -343,4 +557,29 @@ document.addEventListener('DOMContentLoaded', function() {
}; };
$('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); }; $('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); };
$('roomIdInput').onkeypress = function(e) { if (e.key === 'Enter') join(); }; $('roomIdInput').onkeypress = function(e) { if (e.key === 'Enter') join(); };
token = localStorage.getItem('token') || '';
userId = localStorage.getItem('userId') || '';
username = localStorage.getItem('username') || '';
nickname = localStorage.getItem('nickname') || '';
if (token) {
var valid = await validateToken();
if (valid) {
var inRoom = await checkCurrentRoom();
if (inRoom) {
connect();
return;
}
showLobby();
return;
}
localStorage.removeItem('token');
localStorage.removeItem('userId');
localStorage.removeItem('username');
localStorage.removeItem('nickname');
}
show('auth');
getCaptcha('login');
}); });