diff --git a/AGENTS.md b/AGENTS.md index 64e74a7..1af60df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,29 +23,24 @@ doudizhu-server/ ## 2. 构建与运行命令 ```bash -# 本地开发 +# 本地开发(需先启动Redis) +docker run -d --name redis -p 6379:6379 redis:7-alpine cd doudizhu-server/app && go mod tidy && go run ./cmd # Docker部署 cd doudizhu-server && docker compose up -d --build -# 代码格式化 +# 代码格式化(自动格式化所有Go文件) gofmt -w ./app # 静态检查 go vet ./app/... -# 运行所有测试 -go test ./app/... - -# 运行单个包测试 -go test ./app/internal/game/... - -# 运行单个测试 -go test ./app/internal/game/... -run TestCardType - -# 带覆盖率 -go test ./app/... -cover +# 测试(当前项目暂无测试文件,以下为参考命令) +# go test ./app/... +# go test ./app/internal/game/... +# go test ./app/internal/game/... -run TestCardType +# go test ./app/... -cover ``` ## 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响应格式 ```go @@ -138,7 +156,8 @@ type ApiResponse struct { 2. **后端API路由必须在 `/api` 下** 3. **WebSocket路径**:`/api/ws?roomId=xxx&playerId=xxx` 4. **前端不使用npm/react**,采用HTML+CSS+JS -5. **JSON字段用下划线命名**,如 `room_id`,但尽量用单词 +5. **JSON字段用camelCase命名**,如 `roomId`、`playerId`、`cardCount` +6. **Go版本**:Go 1.21 ## 7. 调试 diff --git a/API.md b/API.md index dd486d8..f7b8637 100644 --- a/API.md +++ b/API.md @@ -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 @@ -124,6 +274,7 @@ | chat | 双向 | 聊天消息 | | state | 服务器→客户端 | 游戏状态更新 | | gameOver | 服务器→客户端 | 游戏结束 | +| leave | 服务器→客户端 | 玩家离开房间 | | error | 服务器→客户端 | 错误消息 | ### 准备 @@ -169,6 +320,7 @@ ### 聊天 +客户端发送: ```json { "type": "chat", @@ -178,6 +330,19 @@ } ``` +服务器推送: +```json +{ + "type": "chat", + "data": { + "playerId": "xxx", + "playerName": "玩家昵称", + "message": "消息内容" + }, + "timestamp": 1708123456 +} +``` + ### 游戏状态 (服务器推送) ```json diff --git a/app/Dockerfile b/app/Dockerfile index 982387a..48d053b 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine AS builder +FROM golang:1.24-alpine AS builder ENV GOPROXY=https://goproxy.cn,direct WORKDIR /app COPY go.mod go.sum ./ diff --git a/app/cmd/main.go b/app/cmd/main.go index 5c3acf1..d4a53f5 100644 --- a/app/cmd/main.go +++ b/app/cmd/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "doudizhu-server/internal/captcha" + "doudizhu-server/internal/db" "doudizhu-server/internal/game" "doudizhu-server/internal/handlers" "doudizhu-server/internal/redis" @@ -10,15 +12,13 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" ) func main() { - redisAddr := os.Getenv("REDIS_ADDR") - if redisAddr == "" { - redisAddr = "localhost:6379" - } + redisAddr := getEnv("REDIS_ADDR", "localhost:6379") redisClient, err := redis.NewClient(redis.Config{ Addr: redisAddr, @@ -31,13 +31,74 @@ func main() { defer redisClient.Close() log.Println("Connected to Redis:", redisAddr) + dbHost := getEnv("DB_HOST", "localhost") + dbPort, _ := strconv.Atoi(getEnv("DB_PORT", "5432")) + dbUser := getEnv("DB_USER", "postgres") + dbPassword := getEnv("DB_PASSWORD", "postgres") + dbName := getEnv("DB_NAME", "doudizhu") + + database, err := db.New(db.Config{ + Host: dbHost, + Port: dbPort, + User: dbUser, + Password: dbPassword, + Database: dbName, + }) + if err != nil { + log.Fatalf("Failed to connect to PostgreSQL: %v", err) + } + defer database.Close() + log.Println("Connected to PostgreSQL:", dbHost) + gameMgr := game.NewGameManager(redisClient) hub := ws.NewHub(gameMgr) go hub.Run() - h := handlers.NewHandler(gameMgr, hub) + captchaMgr := captcha.NewManager(redisClient) + h := handlers.NewHandler(gameMgr, hub, redisClient, database, captchaMgr) mux := http.NewServeMux() + + mux.HandleFunc("/api/auth/captcha", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + h.GetCaptcha(w, r) + } else { + http.NotFound(w, r) + } + }) + + mux.HandleFunc("/api/auth/register", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + h.Register(w, r) + } else { + http.NotFound(w, r) + } + }) + + mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + h.Login(w, r) + } else { + http.NotFound(w, r) + } + }) + + mux.HandleFunc("/api/auth/validate", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + h.ValidateToken(w, r) + } else { + http.NotFound(w, r) + } + }) + + mux.HandleFunc("/api/auth/logout", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + h.Logout(w, r) + } else { + http.NotFound(w, r) + } + }) + mux.HandleFunc("/api/rooms", func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { h.CreateRoom(w, r) @@ -45,6 +106,23 @@ func main() { http.NotFound(w, r) } }) + + mux.HandleFunc("/api/rooms/current", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + h.GetCurrentRoom(w, r) + } else { + http.NotFound(w, r) + } + }) + + mux.HandleFunc("/api/rooms/leave", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + h.LeaveRoom(w, r) + } else { + http.NotFound(w, r) + } + }) + mux.HandleFunc("/api/rooms/", func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { h.JoinRoom(w, r) @@ -52,6 +130,7 @@ func main() { http.NotFound(w, r) } }) + mux.HandleFunc("/api/ws", h.WebSocket) srv := &http.Server{ @@ -82,3 +161,10 @@ func main() { gameMgr.Stop() log.Println("Server stopped") } + +func getEnv(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/app/go.mod b/app/go.mod index 0ad8733..c0f456b 100644 --- a/app/go.mod +++ b/app/go.mod @@ -1,14 +1,20 @@ module doudizhu-server -go 1.21 +go 1.24.0 require ( github.com/gorilla/websocket v1.5.1 + github.com/jackc/pgx/v5 v5.8.0 github.com/redis/go-redis/v9 v9.5.1 ) require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect golang.org/x/net v0.17.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect ) diff --git a/app/go.sum b/app/go.sum index 858ca63..d0882d8 100644 --- a/app/go.sum +++ b/app/go.sum @@ -4,11 +4,37 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app/internal/captcha/captcha.go b/app/internal/captcha/captcha.go new file mode 100644 index 0000000..fcb16a3 --- /dev/null +++ b/app/internal/captcha/captcha.go @@ -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) + } + } + } + } +} diff --git a/app/internal/db/db.go b/app/internal/db/db.go new file mode 100644 index 0000000..24d2b20 --- /dev/null +++ b/app/internal/db/db.go @@ -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 +} diff --git a/app/internal/game/game.go b/app/internal/game/game.go index eb77ac3..b2c4ac4 100644 --- a/app/internal/game/game.go +++ b/app/internal/game/game.go @@ -263,6 +263,10 @@ func NewGameManager(rdb *redis.Client) *GameManager { } func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) { + return gm.CreateRoomWithUser("", playerName, maxPlayers) +} + +func (gm *GameManager) CreateRoomWithUser(userID, playerName string, maxPlayers int) (*models.Room, *models.Player) { gm.mu.Lock() defer gm.mu.Unlock() @@ -270,7 +274,7 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro roomID := generateID() playerID := generateID() - player := &models.Player{ID: playerID, Name: playerName, IsOnline: true} + player := &models.Player{ID: playerID, UserID: userID, Name: playerName, IsOnline: true} room := &models.Room{ ID: roomID, Players: []*models.Player{player}, @@ -289,6 +293,10 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro } func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *models.Player, error) { + return gm.JoinRoomWithUser(roomID, "", playerName) +} + +func (gm *GameManager) JoinRoomWithUser(roomID, userID, playerName string) (*models.Room, *models.Player, error) { gm.mu.Lock() defer gm.mu.Unlock() @@ -305,7 +313,7 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model } playerID := generateID() - player := &models.Player{ID: playerID, Name: playerName, IsOnline: true} + player := &models.Player{ID: playerID, UserID: userID, Name: playerName, IsOnline: true} room.Players = append(room.Players, player) gm.saveRoom(ctx, room) @@ -378,6 +386,25 @@ func (gm *GameManager) MarkPlayerOnline(roomID, playerID string) { } } +func (gm *GameManager) MarkPlayerOffline(roomID, playerID string) { + gm.mu.Lock() + defer gm.mu.Unlock() + + ctx := context.Background() + room, err := gm.loadRoom(ctx, roomID) + if err != nil { + return + } + + for _, p := range room.Players { + if p.ID == playerID { + p.IsOnline = false + gm.saveRoom(ctx, room) + return + } + } +} + func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error { gm.mu.Lock() defer gm.mu.Unlock() @@ -581,6 +608,40 @@ func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) { return gm.loadRoom(context.Background(), roomID) } +func (gm *GameManager) GetPlayerName(roomID, playerID string) string { + gm.mu.RLock() + defer gm.mu.RUnlock() + + room, err := gm.loadRoom(context.Background(), roomID) + if err != nil { + return "" + } + + for _, p := range room.Players { + if p.ID == playerID { + return p.Name + } + } + return "" +} + +func (gm *GameManager) GetPlayerIDByUserID(roomID, userID string) string { + gm.mu.RLock() + defer gm.mu.RUnlock() + + room, err := gm.loadRoom(context.Background(), roomID) + if err != nil { + return "" + } + + for _, p := range room.Players { + if p.UserID == userID { + return p.ID + } + } + return "" +} + func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room { gm.mu.RLock() defer gm.mu.RUnlock() diff --git a/app/internal/handlers/handlers.go b/app/internal/handlers/handlers.go index 051f336..9a37629 100644 --- a/app/internal/handlers/handlers.go +++ b/app/internal/handlers/handlers.go @@ -1,42 +1,243 @@ package handlers import ( + "context" + "crypto/sha256" + "doudizhu-server/internal/captcha" + "doudizhu-server/internal/db" "doudizhu-server/internal/game" "doudizhu-server/internal/models" + "doudizhu-server/internal/redis" "doudizhu-server/internal/ws" + "encoding/hex" "encoding/json" + "errors" "net/http" "strings" + "time" ) type Handler struct { GameMgr *game.GameManager Hub *ws.Hub + Rdb *redis.Client + DB *db.DB + Captcha *captcha.Manager + UserTTL time.Duration } -func NewHandler(gameMgr *game.GameManager, hub *ws.Hub) *Handler { - return &Handler{GameMgr: gameMgr, Hub: hub} +func NewHandler(gameMgr *game.GameManager, hub *ws.Hub, rdb *redis.Client, database *db.DB, captchaMgr *captcha.Manager) *Handler { + return &Handler{ + GameMgr: gameMgr, + Hub: hub, + Rdb: rdb, + DB: database, + Captcha: captchaMgr, + UserTTL: 24 * time.Hour, + } +} + +func (h *Handler) GetCaptcha(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + captchaID, imageBase64, err := h.Captcha.Generate(ctx) + if err != nil { + h.jsonError(w, http.StatusInternalServerError, "failed to generate captcha") + return + } + h.jsonSuccess(w, models.CaptchaResponse{ + CaptchaID: captchaID, + Image: imageBase64, + }) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var req models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.jsonError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.Username == "" || req.Password == "" || req.Nickname == "" { + h.jsonError(w, http.StatusBadRequest, "username, password and nickname required") + return + } + + if len(req.Username) < 3 || len(req.Username) > 20 { + h.jsonError(w, http.StatusBadRequest, "username must be 3-20 characters") + return + } + + if len(req.Password) < 6 { + h.jsonError(w, http.StatusBadRequest, "password must be at least 6 characters") + return + } + + ctx := context.Background() + + if !h.Captcha.Verify(ctx, req.CaptchaID, strings.ToUpper(req.Captcha)) { + h.jsonError(w, http.StatusBadRequest, "invalid captcha") + return + } + + exists, err := h.DB.UsernameExists(ctx, req.Username) + if err != nil { + h.jsonError(w, http.StatusInternalServerError, "failed to check username") + return + } + if exists { + h.jsonError(w, http.StatusBadRequest, "username already exists") + return + } + + user := &models.User{ + ID: generateUserID(), + Username: req.Username, + Password: hashPassword(req.Password), + Nickname: req.Nickname, + } + + if err := h.DB.CreateUser(ctx, user); err != nil { + h.jsonError(w, http.StatusInternalServerError, "failed to create user") + return + } + + token := generateToken() + h.Rdb.Set(ctx, redis.SessionKey(token), user.ID, h.UserTTL) + + h.jsonSuccess(w, map[string]interface{}{ + "token": token, + "userId": user.ID, + "username": user.Username, + "nickname": user.Nickname, + }) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req models.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.jsonError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.Username == "" || req.Password == "" { + h.jsonError(w, http.StatusBadRequest, "username and password required") + return + } + + ctx := context.Background() + + if !h.Captcha.Verify(ctx, req.CaptchaID, strings.ToUpper(req.Captcha)) { + h.jsonError(w, http.StatusBadRequest, "invalid captcha") + return + } + + user, err := h.DB.GetUserByUsername(ctx, req.Username) + if err != nil { + h.jsonError(w, http.StatusUnauthorized, "invalid username or password") + return + } + + if user.Password != hashPassword(req.Password) { + h.jsonError(w, http.StatusUnauthorized, "invalid username or password") + return + } + + token := generateToken() + h.Rdb.Set(ctx, redis.SessionKey(token), user.ID, h.UserTTL) + + h.jsonSuccess(w, map[string]interface{}{ + "token": token, + "userId": user.ID, + "username": user.Username, + "nickname": user.Nickname, + }) +} + +func (h *Handler) ValidateToken(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + h.jsonError(w, http.StatusUnauthorized, "token required") + return + } + + token = strings.TrimPrefix(token, "Bearer ") + + ctx := context.Background() + var userID string + if err := h.Rdb.Get(ctx, redis.SessionKey(token), &userID); err != nil { + h.jsonError(w, http.StatusUnauthorized, "invalid token") + return + } + + user, err := h.DB.GetUserByID(ctx, userID) + if err != nil { + h.jsonError(w, http.StatusUnauthorized, "user not found") + return + } + + h.jsonSuccess(w, map[string]interface{}{ + "userId": user.ID, + "username": user.Username, + "nickname": user.Nickname, + }) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + if token == "" { + h.jsonSuccess(w, nil) + return + } + + token = strings.TrimPrefix(token, "Bearer ") + h.Rdb.Delete(context.Background(), redis.SessionKey(token)) + h.jsonSuccess(w, nil) } func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) { + userID, err := h.authenticate(r) + if err != nil { + h.jsonError(w, http.StatusUnauthorized, err.Error()) + return + } + var req models.CreateRoomRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.jsonError(w, http.StatusBadRequest, "invalid request") return } - if req.PlayerName == "" { - h.jsonError(w, http.StatusBadRequest, "player name required") - return - } + if req.MaxPlayers < 2 || req.MaxPlayers > 10 { req.MaxPlayers = 4 } - room, player := h.GameMgr.CreateRoom(req.PlayerName, req.MaxPlayers) + ctx := context.Background() + + var currentRoomID string + h.Rdb.Get(ctx, redis.UserRoomKey(userID), ¤tRoomID) + if currentRoomID != "" { + room, _ := h.GameMgr.GetRoom(currentRoomID) + if room != nil { + h.jsonError(w, http.StatusBadRequest, "you are already in a room, please leave first") + return + } + h.Rdb.Delete(ctx, redis.UserRoomKey(userID)) + } + + room, player := h.GameMgr.CreateRoomWithUser(userID, req.PlayerName, req.MaxPlayers) + + h.Rdb.Set(ctx, redis.UserRoomKey(userID), room.ID, redis.RoomTTL) + h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID}) } func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) { + userID, err := h.authenticate(r) + if err != nil { + h.jsonError(w, http.StatusUnauthorized, err.Error()) + return + } + roomID := strings.ToLower(extractRoomID(r.URL.Path)) if roomID == "" { h.jsonError(w, http.StatusBadRequest, "room id required") @@ -53,18 +254,109 @@ func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) { return } - room, player, err := h.GameMgr.JoinRoom(roomID, req.PlayerName) + ctx := context.Background() + + var currentRoomID string + h.Rdb.Get(ctx, redis.UserRoomKey(userID), ¤tRoomID) + if currentRoomID != "" && currentRoomID != roomID { + room, _ := h.GameMgr.GetRoom(currentRoomID) + if room != nil { + h.jsonError(w, http.StatusBadRequest, "you are already in another room, please leave first") + return + } + h.Rdb.Delete(ctx, redis.UserRoomKey(userID)) + } + + room, player, err := h.GameMgr.JoinRoomWithUser(roomID, userID, req.PlayerName) if err != nil { h.jsonError(w, http.StatusBadRequest, err.Error()) return } + + h.Rdb.Set(ctx, redis.UserRoomKey(userID), room.ID, redis.RoomTTL) + h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID}) } +func (h *Handler) GetCurrentRoom(w http.ResponseWriter, r *http.Request) { + userID, err := h.authenticate(r) + if err != nil { + h.jsonError(w, http.StatusUnauthorized, err.Error()) + return + } + + ctx := context.Background() + var roomID string + if err := h.Rdb.Get(ctx, redis.UserRoomKey(userID), &roomID); err != nil { + h.jsonSuccess(w, nil) + return + } + + room, err := h.GameMgr.GetRoom(roomID) + if err != nil { + h.Rdb.Delete(ctx, redis.UserRoomKey(userID)) + h.jsonSuccess(w, nil) + return + } + + playerID := h.GameMgr.GetPlayerIDByUserID(roomID, userID) + if playerID == "" { + h.Rdb.Delete(ctx, redis.UserRoomKey(userID)) + h.jsonSuccess(w, nil) + return + } + + h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": playerID}) +} + +func (h *Handler) LeaveRoom(w http.ResponseWriter, r *http.Request) { + userID, err := h.authenticate(r) + if err != nil { + h.jsonError(w, http.StatusUnauthorized, err.Error()) + return + } + + ctx := context.Background() + var roomID string + if err := h.Rdb.Get(ctx, redis.UserRoomKey(userID), &roomID); err != nil { + h.jsonSuccess(w, nil) + return + } + + playerID := h.GameMgr.GetPlayerIDByUserID(roomID, userID) + + if err := h.GameMgr.LeaveRoom(roomID, playerID); err != nil { + h.jsonError(w, http.StatusBadRequest, err.Error()) + return + } + + h.Rdb.Delete(ctx, redis.UserRoomKey(userID)) + h.Hub.BroadcastRoomLeft(roomID, playerID) + + h.jsonSuccess(w, nil) +} + func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) { ws.ServeWs(h.Hub, w, r) } +func (h *Handler) authenticate(r *http.Request) (string, error) { + token := r.Header.Get("Authorization") + if token == "" { + return "", errors.New("token required") + } + + token = strings.TrimPrefix(token, "Bearer ") + + ctx := context.Background() + var userID string + if err := h.Rdb.Get(ctx, redis.SessionKey(token), &userID); err != nil { + return "", errors.New("invalid token") + } + + return userID, nil +} + func (h *Handler) jsonSuccess(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(models.ApiResponse{ @@ -88,9 +380,30 @@ func (h *Handler) jsonError(w http.ResponseWriter, status int, msg string) { func extractRoomID(path string) string { parts := strings.Split(strings.Trim(path, "/"), "/") for i, p := range parts { - if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" { + if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" && parts[i+1] != "leave" { return parts[i+1] } } return "" } + +func generateUserID() string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 16) + for i := range b { + b[i] = charset[time.Now().UnixNano()%int64(len(charset))] + time.Sleep(time.Nanosecond) + } + return string(b) +} + +func generateToken() string { + data := time.Now().String() + generateUserID() + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +func hashPassword(password string) string { + hash := sha256.Sum256([]byte(password + "doudizhu_salt")) + return hex.EncodeToString(hash[:]) +} diff --git a/app/internal/models/models.go b/app/internal/models/models.go index c298164..0198396 100644 --- a/app/internal/models/models.go +++ b/app/internal/models/models.go @@ -33,6 +33,7 @@ const ( type Player struct { ID string `json:"id"` + UserID string `json:"userId,omitempty"` Name string `json:"name"` Cards []Card `json:"cards,omitempty"` CardCount int `json:"cardCount"` @@ -101,3 +102,39 @@ type CreateRoomRequest struct { type JoinRoomRequest struct { PlayerName string `json:"playerName"` } + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Password string `json:"-"` + Nickname string `json:"nickname"` +} + +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Captcha string `json:"captcha"` + CaptchaID string `json:"captchaId"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Captcha string `json:"captcha"` + CaptchaID string `json:"captchaId"` +} + +type CaptchaResponse struct { + CaptchaID string `json:"captchaId"` + Image string `json:"image"` +} + +type LeaveRoomRequest struct { + PlayerID string `json:"playerId"` +} + +type PlayerRoomInfo struct { + UserID string `json:"userId"` + RoomID string `json:"roomId"` +} diff --git a/app/internal/redis/redis.go b/app/internal/redis/redis.go index 9ba70c2..b4a40f4 100644 --- a/app/internal/redis/redis.go +++ b/app/internal/redis/redis.go @@ -250,3 +250,23 @@ func RoomTTLKey(roomID string) string { func PlayerTTLKey(playerID string) string { return "player:" + playerID + ":ttl" } + +func CaptchaKey(captchaID string) string { + return "captcha:" + captchaID +} + +func UserKey(username string) string { + return "user:" + username +} + +func UserRoomKey(userID string) string { + return "user:" + userID + ":room" +} + +func SessionKey(token string) string { + return "session:" + token +} + +func UserIDToUsernameKey(userID string) string { + return "user_id:" + userID + ":username" +} diff --git a/app/internal/ws/hub.go b/app/internal/ws/hub.go index 97caa22..563bab6 100644 --- a/app/internal/ws/hub.go +++ b/app/internal/ws/hub.go @@ -69,7 +69,7 @@ func (h *Hub) Run() { } h.mu.Unlock() if c.RoomID != "" { - h.GameMgr.LeaveRoomWithTTL(c.RoomID, c.ID) + h.GameMgr.MarkPlayerOffline(c.RoomID, c.ID) h.broadcastRoomState(c.RoomID) } @@ -201,7 +201,20 @@ func (c *Client) handleMessage(data []byte) { c.Hub.broadcastRoomState(c.RoomID) case models.MsgTypeChat: - c.Hub.broadcastToRoom(c.RoomID, msg) + chatMsg, ok := msg.Data.(string) + if !ok || chatMsg == "" { + return + } + playerName := c.Hub.GameMgr.GetPlayerName(c.RoomID, c.ID) + c.Hub.broadcastToRoom(c.RoomID, models.Message{ + Type: models.MsgTypeChat, + Data: map[string]string{ + "playerId": c.ID, + "playerName": playerName, + "message": chatMsg, + }, + Timestamp: time.Now().Unix(), + }) } } @@ -300,6 +313,33 @@ func (h *Hub) broadcastRoomState(roomID string) { } } +func (h *Hub) BroadcastRoomLeft(roomID, playerID string) { + h.mu.RLock() + clients := make([]*Client, 0) + for _, c := range h.Clients { + if c.RoomID == roomID { + clients = append(clients, c) + } + } + h.mu.RUnlock() + + data, _ := json.Marshal(models.Message{ + Type: models.MsgTypeLeave, + PlayerID: playerID, + Timestamp: time.Now().Unix(), + }) + + for _, c := range clients { + select { + case c.Send <- data: + case <-c.done: + default: + } + } + + h.broadcastRoomState(roomID) +} + func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) { roomID := strings.ToLower(r.URL.Query().Get("roomId")) playerID := r.URL.Query().Get("playerId") diff --git a/compose.yaml b/compose.yaml index a97ed3e..8ebdd4b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,7 +5,14 @@ services: environment: - TZ=Asia/Shanghai - REDIS_ADDR=redis:6379 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=postgres + - DB_PASSWORD=postgres + - DB_NAME=doudizhu depends_on: + flyway: + condition: service_completed_successfully redis: condition: service_healthy @@ -38,3 +45,36 @@ services: retries: 5 environment: - 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: diff --git a/flyway/migrations/V1__create_users_table.sql b/flyway/migrations/V1__create_users_table.sql new file mode 100644 index 0000000..e76de91 --- /dev/null +++ b/flyway/migrations/V1__create_users_table.sql @@ -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); diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css index 1857bab..b1c714d 100644 --- a/nginx/html/css/style.css +++ b/nginx/html/css/style.css @@ -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} h2{text-align:center;color:#ffd700;margin-bottom:15px} 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 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} diff --git a/nginx/html/index.html b/nginx/html/index.html index 40b483a..4f5a234 100644 --- a/nginx/html/index.html +++ b/nginx/html/index.html @@ -8,10 +8,49 @@