增加用户管理
This commit is contained in:
47
AGENTS.md
47
AGENTS.md
@@ -23,29 +23,24 @@ doudizhu-server/
|
|||||||
## 2. 构建与运行命令
|
## 2. 构建与运行命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 本地开发
|
# 本地开发(需先启动Redis)
|
||||||
|
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||||
cd doudizhu-server/app && go mod tidy && go run ./cmd
|
cd doudizhu-server/app && go mod tidy && go run ./cmd
|
||||||
|
|
||||||
# Docker部署
|
# Docker部署
|
||||||
cd doudizhu-server && docker compose up -d --build
|
cd doudizhu-server && docker compose up -d --build
|
||||||
|
|
||||||
# 代码格式化
|
# 代码格式化(自动格式化所有Go文件)
|
||||||
gofmt -w ./app
|
gofmt -w ./app
|
||||||
|
|
||||||
# 静态检查
|
# 静态检查
|
||||||
go vet ./app/...
|
go vet ./app/...
|
||||||
|
|
||||||
# 运行所有测试
|
# 测试(当前项目暂无测试文件,以下为参考命令)
|
||||||
go test ./app/...
|
# go test ./app/...
|
||||||
|
# go test ./app/internal/game/...
|
||||||
# 运行单个包测试
|
# go test ./app/internal/game/... -run TestCardType
|
||||||
go test ./app/internal/game/...
|
# go test ./app/... -cover
|
||||||
|
|
||||||
# 运行单个测试
|
|
||||||
go test ./app/internal/game/... -run TestCardType
|
|
||||||
|
|
||||||
# 带覆盖率
|
|
||||||
go test ./app/... -cover
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Go代码风格规范
|
## 3. Go代码风格规范
|
||||||
@@ -114,6 +109,29 @@ func (gm *GameManager) CreateRoom(...) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 函数长度
|
||||||
|
|
||||||
|
- **禁止超过150行**,不宜超过50行
|
||||||
|
- 保持函数单一职责,提高可读性和可维护性
|
||||||
|
|
||||||
|
### 代码优雅性
|
||||||
|
|
||||||
|
1. **空行规范**:不同逻辑块之间空一行
|
||||||
|
2. **运算符空格**:双目运算符(`+`, `=`, `==` 等)两端必须加空格
|
||||||
|
3. **禁止多行合并**:禁止使用分号将多行代码合并到一行
|
||||||
|
|
||||||
|
### 介词命名规范(用于函数名)
|
||||||
|
|
||||||
|
| 介词 | 用法 |
|
||||||
|
| ------ | -------------------------------------------------------------------- |
|
||||||
|
| by | 表示动作的执行者或方式,如 `formatByLocale`、`calculateByDate` |
|
||||||
|
| from | 表示数据来源、起点或类型转换源,如 `parseFromJson` |
|
||||||
|
| to | 指明目标位置、接收方或变换终点,如 `sendToServer`、`uploadToCloud` |
|
||||||
|
| with | 说明附加参数或属性,如 `buildWithOptions`、`createWithDefaults` |
|
||||||
|
| in | 描述在某个上下文/环境中执行的动作,如 `runInBackground` |
|
||||||
|
| on | 与事件相关的处理器或触发点,如 `handleOnClick`、`listenOnChange` |
|
||||||
|
| for | 指定目标受众、用途或适用范围,如 `renderForAdmin`、`queryForUser` |
|
||||||
|
|
||||||
## 4. HTTP API响应格式
|
## 4. HTTP API响应格式
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -138,7 +156,8 @@ type ApiResponse struct {
|
|||||||
2. **后端API路由必须在 `/api` 下**
|
2. **后端API路由必须在 `/api` 下**
|
||||||
3. **WebSocket路径**:`/api/ws?roomId=xxx&playerId=xxx`
|
3. **WebSocket路径**:`/api/ws?roomId=xxx&playerId=xxx`
|
||||||
4. **前端不使用npm/react**,采用HTML+CSS+JS
|
4. **前端不使用npm/react**,采用HTML+CSS+JS
|
||||||
5. **JSON字段用下划线命名**,如 `room_id`,但尽量用单词
|
5. **JSON字段用camelCase命名**,如 `roomId`、`playerId`、`cardCount`
|
||||||
|
6. **Go版本**:Go 1.21
|
||||||
|
|
||||||
## 7. 调试
|
## 7. 调试
|
||||||
|
|
||||||
|
|||||||
167
API.md
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
|
```json
|
||||||
@@ -124,6 +274,7 @@
|
|||||||
| chat | 双向 | 聊天消息 |
|
| chat | 双向 | 聊天消息 |
|
||||||
| state | 服务器→客户端 | 游戏状态更新 |
|
| state | 服务器→客户端 | 游戏状态更新 |
|
||||||
| gameOver | 服务器→客户端 | 游戏结束 |
|
| gameOver | 服务器→客户端 | 游戏结束 |
|
||||||
|
| leave | 服务器→客户端 | 玩家离开房间 |
|
||||||
| error | 服务器→客户端 | 错误消息 |
|
| error | 服务器→客户端 | 错误消息 |
|
||||||
|
|
||||||
### 准备
|
### 准备
|
||||||
@@ -169,6 +320,7 @@
|
|||||||
|
|
||||||
### 聊天
|
### 聊天
|
||||||
|
|
||||||
|
客户端发送:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "chat",
|
"type": "chat",
|
||||||
@@ -178,6 +330,19 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
服务器推送:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "chat",
|
||||||
|
"data": {
|
||||||
|
"playerId": "xxx",
|
||||||
|
"playerName": "玩家昵称",
|
||||||
|
"message": "消息内容"
|
||||||
|
},
|
||||||
|
"timestamp": 1708123456
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 游戏状态 (服务器推送)
|
### 游戏状态 (服务器推送)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.21-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
ENV GOPROXY=https://goproxy.cn,direct
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"doudizhu-server/internal/captcha"
|
||||||
|
"doudizhu-server/internal/db"
|
||||||
"doudizhu-server/internal/game"
|
"doudizhu-server/internal/game"
|
||||||
"doudizhu-server/internal/handlers"
|
"doudizhu-server/internal/handlers"
|
||||||
"doudizhu-server/internal/redis"
|
"doudizhu-server/internal/redis"
|
||||||
@@ -10,15 +12,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
redisAddr := os.Getenv("REDIS_ADDR")
|
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||||
if redisAddr == "" {
|
|
||||||
redisAddr = "localhost:6379"
|
|
||||||
}
|
|
||||||
|
|
||||||
redisClient, err := redis.NewClient(redis.Config{
|
redisClient, err := redis.NewClient(redis.Config{
|
||||||
Addr: redisAddr,
|
Addr: redisAddr,
|
||||||
@@ -31,13 +31,74 @@ func main() {
|
|||||||
defer redisClient.Close()
|
defer redisClient.Close()
|
||||||
log.Println("Connected to Redis:", redisAddr)
|
log.Println("Connected to Redis:", redisAddr)
|
||||||
|
|
||||||
|
dbHost := getEnv("DB_HOST", "localhost")
|
||||||
|
dbPort, _ := strconv.Atoi(getEnv("DB_PORT", "5432"))
|
||||||
|
dbUser := getEnv("DB_USER", "postgres")
|
||||||
|
dbPassword := getEnv("DB_PASSWORD", "postgres")
|
||||||
|
dbName := getEnv("DB_NAME", "doudizhu")
|
||||||
|
|
||||||
|
database, err := db.New(db.Config{
|
||||||
|
Host: dbHost,
|
||||||
|
Port: dbPort,
|
||||||
|
User: dbUser,
|
||||||
|
Password: dbPassword,
|
||||||
|
Database: dbName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to PostgreSQL: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
log.Println("Connected to PostgreSQL:", dbHost)
|
||||||
|
|
||||||
gameMgr := game.NewGameManager(redisClient)
|
gameMgr := game.NewGameManager(redisClient)
|
||||||
hub := ws.NewHub(gameMgr)
|
hub := ws.NewHub(gameMgr)
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
h := handlers.NewHandler(gameMgr, hub)
|
captchaMgr := captcha.NewManager(redisClient)
|
||||||
|
h := handlers.NewHandler(gameMgr, hub, redisClient, database, captchaMgr)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/auth/captcha", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
h.GetCaptcha(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/auth/register", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" {
|
||||||
|
h.Register(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" {
|
||||||
|
h.Login(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/auth/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
h.ValidateToken(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" {
|
||||||
|
h.Logout(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/api/rooms", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/rooms", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "POST" {
|
if r.Method == "POST" {
|
||||||
h.CreateRoom(w, r)
|
h.CreateRoom(w, r)
|
||||||
@@ -45,6 +106,23 @@ func main() {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/rooms/current", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
h.GetCurrentRoom(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/rooms/leave", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "POST" {
|
||||||
|
h.LeaveRoom(w, r)
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/api/rooms/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/rooms/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "POST" {
|
if r.Method == "POST" {
|
||||||
h.JoinRoom(w, r)
|
h.JoinRoom(w, r)
|
||||||
@@ -52,6 +130,7 @@ func main() {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/api/ws", h.WebSocket)
|
mux.HandleFunc("/api/ws", h.WebSocket)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -82,3 +161,10 @@ func main() {
|
|||||||
gameMgr.Stop()
|
gameMgr.Stop()
|
||||||
log.Println("Server stopped")
|
log.Println("Server stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
module doudizhu-server
|
module doudizhu-server
|
||||||
|
|
||||||
go 1.21
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gorilla/websocket v1.5.1
|
github.com/gorilla/websocket v1.5.1
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
github.com/redis/go-redis/v9 v9.5.1
|
github.com/redis/go-redis/v9 v9.5.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/net v0.17.0 // indirect
|
golang.org/x/net v0.17.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
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/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
|
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
|
||||||
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
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) {
|
func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Room, *models.Player) {
|
||||||
|
return gm.CreateRoomWithUser("", playerName, maxPlayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) CreateRoomWithUser(userID, playerName string, maxPlayers int) (*models.Room, *models.Player) {
|
||||||
gm.mu.Lock()
|
gm.mu.Lock()
|
||||||
defer gm.mu.Unlock()
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
@@ -270,7 +274,7 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro
|
|||||||
roomID := generateID()
|
roomID := generateID()
|
||||||
playerID := generateID()
|
playerID := generateID()
|
||||||
|
|
||||||
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
|
player := &models.Player{ID: playerID, UserID: userID, Name: playerName, IsOnline: true}
|
||||||
room := &models.Room{
|
room := &models.Room{
|
||||||
ID: roomID,
|
ID: roomID,
|
||||||
Players: []*models.Player{player},
|
Players: []*models.Player{player},
|
||||||
@@ -289,6 +293,10 @@ func (gm *GameManager) CreateRoom(playerName string, maxPlayers int) (*models.Ro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *models.Player, error) {
|
func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *models.Player, error) {
|
||||||
|
return gm.JoinRoomWithUser(roomID, "", playerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) JoinRoomWithUser(roomID, userID, playerName string) (*models.Room, *models.Player, error) {
|
||||||
gm.mu.Lock()
|
gm.mu.Lock()
|
||||||
defer gm.mu.Unlock()
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
@@ -305,7 +313,7 @@ func (gm *GameManager) JoinRoom(roomID, playerName string) (*models.Room, *model
|
|||||||
}
|
}
|
||||||
|
|
||||||
playerID := generateID()
|
playerID := generateID()
|
||||||
player := &models.Player{ID: playerID, Name: playerName, IsOnline: true}
|
player := &models.Player{ID: playerID, UserID: userID, Name: playerName, IsOnline: true}
|
||||||
room.Players = append(room.Players, player)
|
room.Players = append(room.Players, player)
|
||||||
|
|
||||||
gm.saveRoom(ctx, room)
|
gm.saveRoom(ctx, room)
|
||||||
@@ -378,6 +386,25 @@ func (gm *GameManager) MarkPlayerOnline(roomID, playerID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) MarkPlayerOffline(roomID, playerID string) {
|
||||||
|
gm.mu.Lock()
|
||||||
|
defer gm.mu.Unlock()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
room, err := gm.loadRoom(ctx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
p.IsOnline = false
|
||||||
|
gm.saveRoom(ctx, room)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
|
func (gm *GameManager) SetReady(roomID, playerID string, ready bool) error {
|
||||||
gm.mu.Lock()
|
gm.mu.Lock()
|
||||||
defer gm.mu.Unlock()
|
defer gm.mu.Unlock()
|
||||||
@@ -581,6 +608,40 @@ func (gm *GameManager) GetRoom(roomID string) (*models.Room, error) {
|
|||||||
return gm.loadRoom(context.Background(), roomID)
|
return gm.loadRoom(context.Background(), roomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) GetPlayerName(roomID, playerID string) string {
|
||||||
|
gm.mu.RLock()
|
||||||
|
defer gm.mu.RUnlock()
|
||||||
|
|
||||||
|
room, err := gm.loadRoom(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gm *GameManager) GetPlayerIDByUserID(roomID, userID string) string {
|
||||||
|
gm.mu.RLock()
|
||||||
|
defer gm.mu.RUnlock()
|
||||||
|
|
||||||
|
room, err := gm.loadRoom(context.Background(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range room.Players {
|
||||||
|
if p.UserID == userID {
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
|
func (gm *GameManager) GetRoomState(roomID, playerID string) *models.Room {
|
||||||
gm.mu.RLock()
|
gm.mu.RLock()
|
||||||
defer gm.mu.RUnlock()
|
defer gm.mu.RUnlock()
|
||||||
|
|||||||
@@ -1,42 +1,243 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"doudizhu-server/internal/captcha"
|
||||||
|
"doudizhu-server/internal/db"
|
||||||
"doudizhu-server/internal/game"
|
"doudizhu-server/internal/game"
|
||||||
"doudizhu-server/internal/models"
|
"doudizhu-server/internal/models"
|
||||||
|
"doudizhu-server/internal/redis"
|
||||||
"doudizhu-server/internal/ws"
|
"doudizhu-server/internal/ws"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
GameMgr *game.GameManager
|
GameMgr *game.GameManager
|
||||||
Hub *ws.Hub
|
Hub *ws.Hub
|
||||||
|
Rdb *redis.Client
|
||||||
|
DB *db.DB
|
||||||
|
Captcha *captcha.Manager
|
||||||
|
UserTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(gameMgr *game.GameManager, hub *ws.Hub) *Handler {
|
func NewHandler(gameMgr *game.GameManager, hub *ws.Hub, rdb *redis.Client, database *db.DB, captchaMgr *captcha.Manager) *Handler {
|
||||||
return &Handler{GameMgr: gameMgr, Hub: hub}
|
return &Handler{
|
||||||
|
GameMgr: gameMgr,
|
||||||
|
Hub: hub,
|
||||||
|
Rdb: rdb,
|
||||||
|
DB: database,
|
||||||
|
Captcha: captchaMgr,
|
||||||
|
UserTTL: 24 * time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := context.Background()
|
||||||
|
captchaID, imageBase64, err := h.Captcha.Generate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusInternalServerError, "failed to generate captcha")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.jsonSuccess(w, models.CaptchaResponse{
|
||||||
|
CaptchaID: captchaID,
|
||||||
|
Image: imageBase64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" || req.Nickname == "" {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "username, password and nickname required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Username) < 3 || len(req.Username) > 20 {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "username must be 3-20 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Password) < 6 {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "password must be at least 6 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if !h.Captcha.Verify(ctx, req.CaptchaID, strings.ToUpper(req.Captcha)) {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "invalid captcha")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := h.DB.UsernameExists(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusInternalServerError, "failed to check username")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "username already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
ID: generateUserID(),
|
||||||
|
Username: req.Username,
|
||||||
|
Password: hashPassword(req.Password),
|
||||||
|
Nickname: req.Nickname,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.CreateUser(ctx, user); err != nil {
|
||||||
|
h.jsonError(w, http.StatusInternalServerError, "failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := generateToken()
|
||||||
|
h.Rdb.Set(ctx, redis.SessionKey(token), user.ID, h.UserTTL)
|
||||||
|
|
||||||
|
h.jsonSuccess(w, map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"userId": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"nickname": user.Nickname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "username and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if !h.Captcha.Verify(ctx, req.CaptchaID, strings.ToUpper(req.Captcha)) {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, "invalid captcha")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.DB.GetUserByUsername(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, "invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Password != hashPassword(req.Password) {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, "invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := generateToken()
|
||||||
|
h.Rdb.Set(ctx, redis.SessionKey(token), user.ID, h.UserTTL)
|
||||||
|
|
||||||
|
h.jsonSuccess(w, map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"userId": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"nickname": user.Nickname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ValidateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if token == "" {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, "token required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token = strings.TrimPrefix(token, "Bearer ")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var userID string
|
||||||
|
if err := h.Rdb.Get(ctx, redis.SessionKey(token), &userID); err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.DB.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.jsonSuccess(w, map[string]interface{}{
|
||||||
|
"userId": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"nickname": user.Nickname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if token == "" {
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token = strings.TrimPrefix(token, "Bearer ")
|
||||||
|
h.Rdb.Delete(context.Background(), redis.SessionKey(token))
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.authenticate(r)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req models.CreateRoomRequest
|
var req models.CreateRoomRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
h.jsonError(w, http.StatusBadRequest, "invalid request")
|
h.jsonError(w, http.StatusBadRequest, "invalid request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.PlayerName == "" {
|
|
||||||
h.jsonError(w, http.StatusBadRequest, "player name required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.MaxPlayers < 2 || req.MaxPlayers > 10 {
|
if req.MaxPlayers < 2 || req.MaxPlayers > 10 {
|
||||||
req.MaxPlayers = 4
|
req.MaxPlayers = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
room, player := h.GameMgr.CreateRoom(req.PlayerName, req.MaxPlayers)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var currentRoomID string
|
||||||
|
h.Rdb.Get(ctx, redis.UserRoomKey(userID), ¤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})
|
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.authenticate(r)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
roomID := strings.ToLower(extractRoomID(r.URL.Path))
|
roomID := strings.ToLower(extractRoomID(r.URL.Path))
|
||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
h.jsonError(w, http.StatusBadRequest, "room id required")
|
h.jsonError(w, http.StatusBadRequest, "room id required")
|
||||||
@@ -53,18 +254,109 @@ func (h *Handler) JoinRoom(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
room, player, err := h.GameMgr.JoinRoom(roomID, req.PlayerName)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var currentRoomID string
|
||||||
|
h.Rdb.Get(ctx, redis.UserRoomKey(userID), ¤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 {
|
if err != nil {
|
||||||
h.jsonError(w, http.StatusBadRequest, err.Error())
|
h.jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.Rdb.Set(ctx, redis.UserRoomKey(userID), room.ID, redis.RoomTTL)
|
||||||
|
|
||||||
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
|
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": player.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetCurrentRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.authenticate(r)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var roomID string
|
||||||
|
if err := h.Rdb.Get(ctx, redis.UserRoomKey(userID), &roomID); err != nil {
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room, err := h.GameMgr.GetRoom(roomID)
|
||||||
|
if err != nil {
|
||||||
|
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := h.GameMgr.GetPlayerIDByUserID(roomID, userID)
|
||||||
|
if playerID == "" {
|
||||||
|
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.jsonSuccess(w, map[string]string{"roomId": room.ID, "playerId": playerID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) LeaveRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := h.authenticate(r)
|
||||||
|
if err != nil {
|
||||||
|
h.jsonError(w, http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var roomID string
|
||||||
|
if err := h.Rdb.Get(ctx, redis.UserRoomKey(userID), &roomID); err != nil {
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID := h.GameMgr.GetPlayerIDByUserID(roomID, userID)
|
||||||
|
|
||||||
|
if err := h.GameMgr.LeaveRoom(roomID, playerID); err != nil {
|
||||||
|
h.jsonError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Rdb.Delete(ctx, redis.UserRoomKey(userID))
|
||||||
|
h.Hub.BroadcastRoomLeft(roomID, playerID)
|
||||||
|
|
||||||
|
h.jsonSuccess(w, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) WebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
ws.ServeWs(h.Hub, w, r)
|
ws.ServeWs(h.Hub, w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) authenticate(r *http.Request) (string, error) {
|
||||||
|
token := r.Header.Get("Authorization")
|
||||||
|
if token == "" {
|
||||||
|
return "", errors.New("token required")
|
||||||
|
}
|
||||||
|
|
||||||
|
token = strings.TrimPrefix(token, "Bearer ")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var userID string
|
||||||
|
if err := h.Rdb.Get(ctx, redis.SessionKey(token), &userID); err != nil {
|
||||||
|
return "", errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) jsonSuccess(w http.ResponseWriter, data interface{}) {
|
func (h *Handler) jsonSuccess(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(models.ApiResponse{
|
json.NewEncoder(w).Encode(models.ApiResponse{
|
||||||
@@ -88,9 +380,30 @@ func (h *Handler) jsonError(w http.ResponseWriter, status int, msg string) {
|
|||||||
func extractRoomID(path string) string {
|
func extractRoomID(path string) string {
|
||||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" {
|
if p == "rooms" && i+1 < len(parts) && parts[i+1] != "join" && parts[i+1] != "leave" {
|
||||||
return parts[i+1]
|
return parts[i+1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateUserID() string {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
b := make([]byte, 16)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
||||||
|
time.Sleep(time.Nanosecond)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken() string {
|
||||||
|
data := time.Now().String() + generateUserID()
|
||||||
|
hash := sha256.Sum256([]byte(data))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string) string {
|
||||||
|
hash := sha256.Sum256([]byte(password + "doudizhu_salt"))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const (
|
|||||||
|
|
||||||
type Player struct {
|
type Player struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Cards []Card `json:"cards,omitempty"`
|
Cards []Card `json:"cards,omitempty"`
|
||||||
CardCount int `json:"cardCount"`
|
CardCount int `json:"cardCount"`
|
||||||
@@ -101,3 +102,39 @@ type CreateRoomRequest struct {
|
|||||||
type JoinRoomRequest struct {
|
type JoinRoomRequest struct {
|
||||||
PlayerName string `json:"playerName"`
|
PlayerName string `json:"playerName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"-"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Captcha string `json:"captcha"`
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Captcha string `json:"captcha"`
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaResponse struct {
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeaveRoomRequest struct {
|
||||||
|
PlayerID string `json:"playerId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerRoomInfo struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
RoomID string `json:"roomId"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -250,3 +250,23 @@ func RoomTTLKey(roomID string) string {
|
|||||||
func PlayerTTLKey(playerID string) string {
|
func PlayerTTLKey(playerID string) string {
|
||||||
return "player:" + playerID + ":ttl"
|
return "player:" + playerID + ":ttl"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CaptchaKey(captchaID string) string {
|
||||||
|
return "captcha:" + captchaID
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserKey(username string) string {
|
||||||
|
return "user:" + username
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserRoomKey(userID string) string {
|
||||||
|
return "user:" + userID + ":room"
|
||||||
|
}
|
||||||
|
|
||||||
|
func SessionKey(token string) string {
|
||||||
|
return "session:" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserIDToUsernameKey(userID string) string {
|
||||||
|
return "user_id:" + userID + ":username"
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if c.RoomID != "" {
|
if c.RoomID != "" {
|
||||||
h.GameMgr.LeaveRoomWithTTL(c.RoomID, c.ID)
|
h.GameMgr.MarkPlayerOffline(c.RoomID, c.ID)
|
||||||
h.broadcastRoomState(c.RoomID)
|
h.broadcastRoomState(c.RoomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +201,20 @@ func (c *Client) handleMessage(data []byte) {
|
|||||||
c.Hub.broadcastRoomState(c.RoomID)
|
c.Hub.broadcastRoomState(c.RoomID)
|
||||||
|
|
||||||
case models.MsgTypeChat:
|
case models.MsgTypeChat:
|
||||||
c.Hub.broadcastToRoom(c.RoomID, msg)
|
chatMsg, ok := msg.Data.(string)
|
||||||
|
if !ok || chatMsg == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playerName := c.Hub.GameMgr.GetPlayerName(c.RoomID, c.ID)
|
||||||
|
c.Hub.broadcastToRoom(c.RoomID, models.Message{
|
||||||
|
Type: models.MsgTypeChat,
|
||||||
|
Data: map[string]string{
|
||||||
|
"playerId": c.ID,
|
||||||
|
"playerName": playerName,
|
||||||
|
"message": chatMsg,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +313,33 @@ func (h *Hub) broadcastRoomState(roomID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) BroadcastRoomLeft(roomID, playerID string) {
|
||||||
|
h.mu.RLock()
|
||||||
|
clients := make([]*Client, 0)
|
||||||
|
for _, c := range h.Clients {
|
||||||
|
if c.RoomID == roomID {
|
||||||
|
clients = append(clients, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
data, _ := json.Marshal(models.Message{
|
||||||
|
Type: models.MsgTypeLeave,
|
||||||
|
PlayerID: playerID,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, c := range clients {
|
||||||
|
select {
|
||||||
|
case c.Send <- data:
|
||||||
|
case <-c.done:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.broadcastRoomState(roomID)
|
||||||
|
}
|
||||||
|
|
||||||
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||||
roomID := strings.ToLower(r.URL.Query().Get("roomId"))
|
roomID := strings.ToLower(r.URL.Query().Get("roomId"))
|
||||||
playerID := r.URL.Query().Get("playerId")
|
playerID := r.URL.Query().Get("playerId")
|
||||||
|
|||||||
40
compose.yaml
40
compose.yaml
@@ -5,7 +5,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- REDIS_ADDR=redis:6379
|
- REDIS_ADDR=redis:6379
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USER=postgres
|
||||||
|
- DB_PASSWORD=postgres
|
||||||
|
- DB_NAME=doudizhu
|
||||||
depends_on:
|
depends_on:
|
||||||
|
flyway:
|
||||||
|
condition: service_completed_successfully
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
@@ -38,3 +45,36 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=doudizhu
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d doudizhu"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
image: flyway/flyway:10
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./flyway/migrations:/flyway/sql:ro
|
||||||
|
command: >
|
||||||
|
-url=jdbc:postgresql://postgres:5432/doudizhu
|
||||||
|
-user=postgres
|
||||||
|
-password=postgres
|
||||||
|
-connectRetries=10
|
||||||
|
migrate
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|||||||
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}
|
h1{text-align:center;font-size:2rem;color:#ffd700;margin-bottom:20px}
|
||||||
h2{text-align:center;color:#ffd700;margin-bottom:15px}
|
h2{text-align:center;color:#ffd700;margin-bottom:15px}
|
||||||
h3{color:#ffd700;margin-bottom:10px}
|
h3{color:#ffd700;margin-bottom:10px}
|
||||||
|
.auth-tabs{display:flex;justify-content:center;gap:10px;margin-bottom:15px}
|
||||||
|
.auth-tabs .tab{padding:10px 30px;border:none;border-radius:6px 6px 0 0;cursor:pointer;font-size:15px;background:rgba(255,255,255,.1);color:#aaa;transition:.2s}
|
||||||
|
.auth-tabs .tab.active{background:rgba(255,255,255,.2);color:#ffd700}
|
||||||
|
.auth-form{background:rgba(255,255,255,.1);padding:25px;border-radius:0 0 12px 12px;max-width:350px;margin:0 auto 20px}
|
||||||
|
.auth-form input{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px}
|
||||||
|
.auth-form input::placeholder{color:#aaa}
|
||||||
|
.captcha-row{display:flex;gap:8px;margin-bottom:10px}
|
||||||
|
.captcha-row input{flex:1}
|
||||||
|
.captcha-img{height:38px;border-radius:4px;cursor:pointer}
|
||||||
|
.user-info{display:flex;justify-content:center;align-items:center;gap:15px;margin-bottom:15px}
|
||||||
|
.user-info span{color:#ffd700}
|
||||||
.form{background:rgba(255,255,255,.1);padding:25px;border-radius:12px;max-width:350px;margin:0 auto 20px}
|
.form{background:rgba(255,255,255,.1);padding:25px;border-radius:12px;max-width:350px;margin:0 auto 20px}
|
||||||
.form input,.form select{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px}
|
.form input,.form select{width:100%;padding:10px;margin-bottom:10px;border:none;border-radius:6px;background:rgba(255,255,255,.15);color:#fff;font-size:15px}
|
||||||
.form input::placeholder{color:#aaa}
|
.form input::placeholder{color:#aaa}
|
||||||
|
|||||||
@@ -8,10 +8,49 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="lobby" class="screen">
|
<div id="auth" class="screen">
|
||||||
<h1>斗地主残局版</h1>
|
<h1>斗地主残局版</h1>
|
||||||
|
<div class="auth-tabs">
|
||||||
|
<button id="loginTab" class="tab active">登录</button>
|
||||||
|
<button id="registerTab" class="tab">注册</button>
|
||||||
|
</div>
|
||||||
|
<div id="loginForm" class="auth-form">
|
||||||
|
<input type="text" id="loginUsername" placeholder="用户名" maxlength="20">
|
||||||
|
<input type="password" id="loginPassword" placeholder="密码">
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input type="text" id="loginCaptcha" placeholder="验证码" maxlength="4">
|
||||||
|
<img id="loginCaptchaImg" src="" alt="验证码" class="captcha-img">
|
||||||
|
</div>
|
||||||
|
<button id="loginBtn" class="btn primary">登录</button>
|
||||||
|
</div>
|
||||||
|
<div id="registerForm" class="auth-form hidden">
|
||||||
|
<input type="text" id="regUsername" placeholder="用户名 (3-20字符)" maxlength="20">
|
||||||
|
<input type="password" id="regPassword" placeholder="密码 (至少6位)">
|
||||||
|
<input type="text" id="regNickname" placeholder="昵称" maxlength="10">
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input type="text" id="regCaptcha" placeholder="验证码" maxlength="4">
|
||||||
|
<img id="regCaptchaImg" src="" alt="验证码" class="captcha-img">
|
||||||
|
</div>
|
||||||
|
<button id="registerBtn" class="btn primary">注册</button>
|
||||||
|
</div>
|
||||||
|
<div class="rules">
|
||||||
|
<h3>规则</h3>
|
||||||
|
<ul>
|
||||||
|
<li>新增"超人强"为最大单牌</li>
|
||||||
|
<li>每人初始5张牌</li>
|
||||||
|
<li>三张可成顺子/炸弹,两对可成连对</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lobby" class="screen hidden">
|
||||||
|
<h1>斗地主残局版</h1>
|
||||||
|
<div class="user-info">
|
||||||
|
<span id="welcomeUser">欢迎</span>
|
||||||
|
<button id="logoutBtn" class="btn sm">退出登录</button>
|
||||||
|
</div>
|
||||||
<div class="form">
|
<div class="form">
|
||||||
<input type="text" id="playerName" placeholder="昵称" maxlength="10">
|
<input type="text" id="playerName" placeholder="游戏昵称" maxlength="10">
|
||||||
<select id="maxPlayers">
|
<select id="maxPlayers">
|
||||||
<option value="2">2人</option>
|
<option value="2">2人</option>
|
||||||
<option value="3">3人</option>
|
<option value="3">3人</option>
|
||||||
@@ -25,14 +64,6 @@
|
|||||||
<button id="joinBtn" class="btn">加入</button>
|
<button id="joinBtn" class="btn">加入</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rules">
|
|
||||||
<h3>规则</h3>
|
|
||||||
<ul>
|
|
||||||
<li>新增"超人强"为最大单牌</li>
|
|
||||||
<li>每人初始5张牌</li>
|
|
||||||
<li>三张可成顺子/炸弹,两对可成连对</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="waiting" class="screen hidden">
|
<div id="waiting" class="screen hidden">
|
||||||
@@ -41,7 +72,7 @@
|
|||||||
<div id="playerList"></div>
|
<div id="playerList"></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="readyBtn" class="btn primary">准备</button>
|
<button id="readyBtn" class="btn primary">准备</button>
|
||||||
<button id="leaveBtn" class="btn danger">离开</button>
|
<button id="leaveBtn" class="btn danger">离开房间</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
let ws = null, playerId = '', roomId = '', state = null, selected = [];
|
let ws = null, playerId = '', roomId = '', state = null, selected = [];
|
||||||
|
let token = '', userId = '', username = '', nickname = '';
|
||||||
|
let loginCaptchaId = '', regCaptchaId = '';
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
function show(id) {
|
function show(id) {
|
||||||
@@ -13,13 +15,191 @@ function chat(name, msg, sys) {
|
|||||||
$('chatMsgs').scrollTop = 1e6;
|
$('chatMsgs').scrollTop = 1e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCaptcha(type) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/captcha');
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0) {
|
||||||
|
if (type === 'login') {
|
||||||
|
loginCaptchaId = d.data.captchaId;
|
||||||
|
$('loginCaptchaImg').src = d.data.image;
|
||||||
|
} else {
|
||||||
|
regCaptchaId = d.data.captchaId;
|
||||||
|
$('regCaptchaImg').src = d.data.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get captcha:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const usernameVal = $('loginUsername').value.trim();
|
||||||
|
const passwordVal = $('loginPassword').value;
|
||||||
|
const captchaVal = $('loginCaptcha').value.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!usernameVal || !passwordVal || !captchaVal) {
|
||||||
|
alert('请填写完整信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameVal,
|
||||||
|
password: passwordVal,
|
||||||
|
captcha: captchaVal,
|
||||||
|
captchaId: loginCaptchaId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0) {
|
||||||
|
token = d.data.token;
|
||||||
|
userId = d.data.userId;
|
||||||
|
username = d.data.username;
|
||||||
|
nickname = d.data.nickname;
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
localStorage.setItem('userId', userId);
|
||||||
|
localStorage.setItem('username', username);
|
||||||
|
localStorage.setItem('nickname', nickname);
|
||||||
|
showLobby();
|
||||||
|
} else {
|
||||||
|
alert(d.message);
|
||||||
|
getCaptcha('login');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('登录失败: ' + e.message);
|
||||||
|
getCaptcha('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register() {
|
||||||
|
const usernameVal = $('regUsername').value.trim();
|
||||||
|
const passwordVal = $('regPassword').value;
|
||||||
|
const nicknameVal = $('regNickname').value.trim();
|
||||||
|
const captchaVal = $('regCaptcha').value.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!usernameVal || !passwordVal || !nicknameVal || !captchaVal) {
|
||||||
|
alert('请填写完整信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usernameVal.length < 3 || usernameVal.length > 20) {
|
||||||
|
alert('用户名需要3-20个字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordVal.length < 6) {
|
||||||
|
alert('密码至少6位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameVal,
|
||||||
|
password: passwordVal,
|
||||||
|
nickname: nicknameVal,
|
||||||
|
captcha: captchaVal,
|
||||||
|
captchaId: regCaptchaId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0) {
|
||||||
|
token = d.data.token;
|
||||||
|
userId = d.data.userId;
|
||||||
|
username = d.data.username;
|
||||||
|
nickname = d.data.nickname;
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
localStorage.setItem('userId', userId);
|
||||||
|
localStorage.setItem('username', username);
|
||||||
|
localStorage.setItem('nickname', nickname);
|
||||||
|
showLobby();
|
||||||
|
} else {
|
||||||
|
alert(d.message);
|
||||||
|
getCaptcha('register');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('注册失败: ' + e.message);
|
||||||
|
getCaptcha('register');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
token = '';
|
||||||
|
userId = '';
|
||||||
|
username = '';
|
||||||
|
nickname = '';
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('userId');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
localStorage.removeItem('nickname');
|
||||||
|
show('auth');
|
||||||
|
getCaptcha('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateToken() {
|
||||||
|
if (!token) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/validate', {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0) {
|
||||||
|
userId = d.data.userId;
|
||||||
|
username = d.data.username;
|
||||||
|
nickname = d.data.nickname;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCurrentRoom() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rooms/current', {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.code === 0 && d.data && d.data.roomId) {
|
||||||
|
roomId = d.data.roomId;
|
||||||
|
playerId = d.data.playerId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLobby() {
|
||||||
|
$('welcomeUser').textContent = '欢迎, ' + nickname;
|
||||||
|
$('playerName').value = nickname;
|
||||||
|
show('lobby');
|
||||||
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
const name = $('playerName').value.trim();
|
const name = $('playerName').value.trim();
|
||||||
if (!name) { alert('请输入昵称'); return; }
|
if (!name) { alert('请输入昵称'); return; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/rooms', {
|
const res = await fetch('/api/rooms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value})
|
body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value})
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
@@ -43,7 +223,7 @@ async function join() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/rooms/' + rid + '/join', {
|
const res = await fetch('/api/rooms/' + rid + '/join', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({playerName: name})
|
body: JSON.stringify({playerName: name})
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
@@ -59,6 +239,21 @@ async function join() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function leaveRoom() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/rooms/leave', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
if (ws) ws.close();
|
||||||
|
show('lobby');
|
||||||
|
roomId = '';
|
||||||
|
playerId = '';
|
||||||
|
state = null;
|
||||||
|
selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
ws = new WebSocket(proto + '//' + location.host + '/api/ws?roomId=' + roomId + '&playerId=' + playerId);
|
ws = new WebSocket(proto + '//' + location.host + '/api/ws?roomId=' + roomId + '&playerId=' + playerId);
|
||||||
@@ -68,6 +263,7 @@ function connect() {
|
|||||||
if (msg.type === 'state') render(msg.data);
|
if (msg.type === 'state') render(msg.data);
|
||||||
else if (msg.type === 'gameOver') showGameOver(msg.data);
|
else if (msg.type === 'gameOver') showGameOver(msg.data);
|
||||||
else if (msg.type === 'chat') chat(msg.data.playerName, msg.data.message);
|
else if (msg.type === 'chat') chat(msg.data.playerName, msg.data.message);
|
||||||
|
else if (msg.type === 'leave') chat('', '有玩家离开房间', true);
|
||||||
else if (msg.type === 'error') chat('', msg.data, true);
|
else if (msg.type === 'error') chat('', msg.data, true);
|
||||||
};
|
};
|
||||||
ws.onerror = function() { chat('', '连接错误', true); };
|
ws.onerror = function() { chat('', '连接错误', true); };
|
||||||
@@ -308,9 +504,7 @@ function toggleReady() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function leave() {
|
function leave() {
|
||||||
if (ws) ws.close();
|
leaveRoom();
|
||||||
show('lobby');
|
|
||||||
roomId = ''; playerId = ''; state = null; selected = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGameOver(d) {
|
function showGameOver(d) {
|
||||||
@@ -329,11 +523,31 @@ function again() {
|
|||||||
send('ready', {ready: false});
|
send('ready', {ready: false});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
$('loginTab').onclick = function() {
|
||||||
|
$('loginTab').classList.add('active');
|
||||||
|
$('registerTab').classList.remove('active');
|
||||||
|
$('loginForm').classList.remove('hidden');
|
||||||
|
$('registerForm').classList.add('hidden');
|
||||||
|
getCaptcha('login');
|
||||||
|
};
|
||||||
|
$('registerTab').onclick = function() {
|
||||||
|
$('registerTab').classList.add('active');
|
||||||
|
$('loginTab').classList.remove('active');
|
||||||
|
$('registerForm').classList.remove('hidden');
|
||||||
|
$('loginForm').classList.add('hidden');
|
||||||
|
getCaptcha('register');
|
||||||
|
};
|
||||||
|
$('loginCaptchaImg').onclick = function() { getCaptcha('login'); };
|
||||||
|
$('regCaptchaImg').onclick = function() { getCaptcha('register'); };
|
||||||
|
$('loginBtn').onclick = login;
|
||||||
|
$('registerBtn').onclick = register;
|
||||||
|
$('logoutBtn').onclick = logout;
|
||||||
|
|
||||||
$('createBtn').onclick = create;
|
$('createBtn').onclick = create;
|
||||||
$('joinBtn').onclick = join;
|
$('joinBtn').onclick = join;
|
||||||
$('readyBtn').onclick = toggleReady;
|
$('readyBtn').onclick = toggleReady;
|
||||||
$('leaveBtn').onclick = leave;
|
$('leaveBtn').onclick = leaveRoom;
|
||||||
$('playBtn').onclick = play;
|
$('playBtn').onclick = play;
|
||||||
$('passBtn').onclick = pass;
|
$('passBtn').onclick = pass;
|
||||||
$('againBtn').onclick = again;
|
$('againBtn').onclick = again;
|
||||||
@@ -343,4 +557,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
};
|
};
|
||||||
$('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); };
|
$('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); };
|
||||||
$('roomIdInput').onkeypress = function(e) { if (e.key === 'Enter') join(); };
|
$('roomIdInput').onkeypress = function(e) { if (e.key === 'Enter') join(); };
|
||||||
|
|
||||||
|
token = localStorage.getItem('token') || '';
|
||||||
|
userId = localStorage.getItem('userId') || '';
|
||||||
|
username = localStorage.getItem('username') || '';
|
||||||
|
nickname = localStorage.getItem('nickname') || '';
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
var valid = await validateToken();
|
||||||
|
if (valid) {
|
||||||
|
var inRoom = await checkCurrentRoom();
|
||||||
|
if (inRoom) {
|
||||||
|
connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showLobby();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('userId');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
localStorage.removeItem('nickname');
|
||||||
|
}
|
||||||
|
|
||||||
|
show('auth');
|
||||||
|
getCaptcha('login');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user