增加用户管理
This commit is contained in:
47
AGENTS.md
47
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. 调试
|
||||
|
||||
|
||||
167
API.md
167
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
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
26
app/go.sum
26
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=
|
||||
|
||||
230
app/internal/captcha/captcha.go
Normal file
230
app/internal/captcha/captcha.go
Normal 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
104
app/internal/db/db.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
40
compose.yaml
40
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:
|
||||
|
||||
12
flyway/migrations/V1__create_users_table.sql
Normal file
12
flyway/migrations/V1__create_users_table.sql
Normal 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);
|
||||
@@ -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}
|
||||
|
||||
@@ -8,10 +8,49 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="lobby" class="screen">
|
||||
<div id="auth" class="screen">
|
||||
<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">
|
||||
<input type="text" id="playerName" placeholder="昵称" maxlength="10">
|
||||
<input type="text" id="playerName" placeholder="游戏昵称" maxlength="10">
|
||||
<select id="maxPlayers">
|
||||
<option value="2">2人</option>
|
||||
<option value="3">3人</option>
|
||||
@@ -25,14 +64,6 @@
|
||||
<button id="joinBtn" class="btn">加入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rules">
|
||||
<h3>规则</h3>
|
||||
<ul>
|
||||
<li>新增"超人强"为最大单牌</li>
|
||||
<li>每人初始5张牌</li>
|
||||
<li>三张可成顺子/炸弹,两对可成连对</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="waiting" class="screen hidden">
|
||||
@@ -41,7 +72,7 @@
|
||||
<div id="playerList"></div>
|
||||
<div class="actions">
|
||||
<button id="readyBtn" class="btn primary">准备</button>
|
||||
<button id="leaveBtn" class="btn danger">离开</button>
|
||||
<button id="leaveBtn" class="btn danger">离开房间</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
let ws = null, playerId = '', roomId = '', state = null, selected = [];
|
||||
let token = '', userId = '', username = '', nickname = '';
|
||||
let loginCaptchaId = '', regCaptchaId = '';
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
function show(id) {
|
||||
@@ -13,13 +15,191 @@ function chat(name, msg, sys) {
|
||||
$('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() {
|
||||
const name = $('playerName').value.trim();
|
||||
if (!name) { alert('请输入昵称'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/rooms', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value})
|
||||
});
|
||||
const d = await res.json();
|
||||
@@ -43,7 +223,7 @@ async function join() {
|
||||
try {
|
||||
const res = await fetch('/api/rooms/' + rid + '/join', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({playerName: name})
|
||||
});
|
||||
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() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
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);
|
||||
else if (msg.type === 'gameOver') showGameOver(msg.data);
|
||||
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);
|
||||
};
|
||||
ws.onerror = function() { chat('', '连接错误', true); };
|
||||
@@ -308,9 +504,7 @@ function toggleReady() {
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (ws) ws.close();
|
||||
show('lobby');
|
||||
roomId = ''; playerId = ''; state = null; selected = [];
|
||||
leaveRoom();
|
||||
}
|
||||
|
||||
function showGameOver(d) {
|
||||
@@ -329,11 +523,31 @@ function again() {
|
||||
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;
|
||||
$('joinBtn').onclick = join;
|
||||
$('readyBtn').onclick = toggleReady;
|
||||
$('leaveBtn').onclick = leave;
|
||||
$('leaveBtn').onclick = leaveRoom;
|
||||
$('playBtn').onclick = play;
|
||||
$('passBtn').onclick = pass;
|
||||
$('againBtn').onclick = again;
|
||||
@@ -343,4 +557,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
};
|
||||
$('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); };
|
||||
$('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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user