Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ backups/

# Test files (local only)
backend/test2/
backend/ARCHITECTURE.md
107 changes: 107 additions & 0 deletions backend/WS_PHASE_PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# WebSocket 房间阶段协议(投票阶段信号)

目标:让前端能实时展示“投票阶段开始/结束”的倒计时,并在投票阶段禁用提交画作入口。

## 1) 房间阶段(phase)

后端通过 Socket 下发的 `phase` 字段取值:
- `active`:提交/观赏阶段(允许提交画作;不一定允许投票)
- `voting`:投票阶段(不允许提交画作;允许投票/撤票)
- `gameover`:结算结束

后端落库字段:`rooms.status`。

## 2) 事件:sync:state(进入房间时全量同步)

事件名:`sync:state`

触发:客户端 `room:join` 后,服务端会 emit 给该 socket。

Payload(camelCase,新增字段对旧前端向后兼容):
- `phase: string`(`active`/`voting`/`gameover`)
- `roomId: string`
- `totalItems: number`
- `aiCount: number`
- `turbidity: number`
- `theme: ThemeResponse`
- `items: GameItemData[]`
- `votingStartedAt?: number`(Unix 毫秒时间戳,仅 voting 期存在)
- `votingEndsAt?: number`(Unix 毫秒时间戳,仅 voting 期存在)
- `serverTime: number`(Unix 毫秒时间戳,服务端当前时间)

示例(voting 中):
```json
{
"phase": "voting",
"roomId": "ABCD12",
"totalItems": 8,
"aiCount": 2,
"turbidity": 0.4,
"votingStartedAt": 1769948000000,
"votingEndsAt": 1769948045000,
"serverTime": 1769948012345,
"theme": { "...": "..." },
"items": []
}
```

## 3) 事件:phase:update(房间内广播阶段切换)

事件名:`phase:update`

触发:服务端在以下时机会向 `within(roomId)` 广播给同房间所有连接:
- `active -> voting`:进入投票阶段
- `voting -> active`:投票超时且未结束游戏,重置票数并退出投票阶段
- `* -> gameover`:胜负判定落库 gameover 后立即广播

Payload(camelCase):
- `phase: string`
- `roomId: string`
- `votingStartedAt?: number`(Unix ms)
- `votingEndsAt?: number`(Unix ms)
- `serverTime: number`(Unix ms)

示例(进入 voting):
```json
{
"phase": "voting",
"roomId": "ABCD12",
"votingStartedAt": 1769948000000,
"votingEndsAt": 1769948045000,
"serverTime": 1769948000001
}
```

示例(退出 voting 回到 active):
```json
{
"phase": "active",
"roomId": "ABCD12",
"serverTime": 1769948050000
}
```

示例(gameover):
```json
{
"phase": "gameover",
"roomId": "ABCD12",
"serverTime": 1769948060000
}
```

## 4) 前端倒计时推荐算法

服务端同时提供 `votingEndsAt` 与 `serverTime`,用于抵消客户端/服务器时钟偏差。

推荐做法:
1. 记录收到消息时的本地时间 `clientNowAtReceive = Date.now()` 与消息中的 `serverTime`。
2. 估算服务器与客户端的时间偏移:`offset = serverTime - clientNowAtReceive`。
3. 后续倒计时使用:`remainingMs = votingEndsAt - (Date.now() + offset)`。

若前端不做校时,也可直接:`remainingMs = votingEndsAt - Date.now()`(误差会更大)。

## 5) 禁提交与兼容性说明

- 后端在 `voting` 阶段会拒绝 `POST /api/rooms/:room_code/drawings`(HTTP 400),因此旧前端即便未禁用按钮,也不会提交成功。
- 新增字段/新事件均为增量:旧前端忽略未知字段/事件即可继续运行。
40 changes: 40 additions & 0 deletions backend/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ pub struct Config {
pub wechat_mp_secret: Option<String>,
pub auth_token_ttl_days: i64,
pub dev_auth_enabled: bool,
pub vote_threshold_ratio: f64,
pub vote_min_threshold: i32,
pub human_eliminated_ratio: f64,
pub victory_human_survive_ratio: f64,
pub ai_overflow_delta: i64,
pub min_humans_to_start_voting: i64,
pub voting_duration_seconds: i64,
pub submit_duration_seconds: i64,
}

impl Config {
Expand Down Expand Up @@ -101,6 +109,38 @@ impl Config {
dev_auth_enabled: std::env::var("DEV_AUTH_ENABLED")
.map(|v| v.to_lowercase() == "true")
.unwrap_or(false),
vote_threshold_ratio: std::env::var("VOTE_THRESHOLD_RATIO")
.unwrap_or_else(|_| "0.6".to_string())
.parse()
.context("VOTE_THRESHOLD_RATIO must be a valid float")?,
vote_min_threshold: std::env::var("VOTE_MIN_THRESHOLD")
.unwrap_or_else(|_| "2".to_string())
.parse()
.context("VOTE_MIN_THRESHOLD must be a valid number")?,
human_eliminated_ratio: std::env::var("HUMAN_ELIMINATED_RATIO")
.unwrap_or_else(|_| "0.4".to_string())
.parse()
.context("HUMAN_ELIMINATED_RATIO must be a valid float")?,
victory_human_survive_ratio: std::env::var("VICTORY_HUMAN_SURVIVE_RATIO")
.unwrap_or_else(|_| "0.6".to_string())
.parse()
.context("VICTORY_HUMAN_SURVIVE_RATIO must be a valid float")?,
ai_overflow_delta: std::env::var("AI_OVERFLOW_DELTA")
.unwrap_or_else(|_| "2".to_string())
.parse()
.context("AI_OVERFLOW_DELTA must be a valid number")?,
min_humans_to_start_voting: std::env::var("MIN_HUMANS_TO_START_VOTING")
.unwrap_or_else(|_| "2".to_string())
.parse()
.context("MIN_HUMANS_TO_START_VOTING must be a valid number")?,
voting_duration_seconds: std::env::var("VOTING_DURATION_SECONDS")
.unwrap_or_else(|_| "45".to_string())
.parse()
.context("VOTING_DURATION_SECONDS must be a valid number")?,
submit_duration_seconds: std::env::var("SUBMIT_DURATION_SECONDS")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("SUBMIT_DURATION_SECONDS must be a valid number")?,
})
}
}
7 changes: 6 additions & 1 deletion backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use axum::{
routing::{get, post},
Extension, Router,
};
use socketioxide::handler::ConnectHandler;
use socketioxide::SocketIo;
use sqlx::postgres::PgPoolOptions;
use std::{net::SocketAddr, sync::Arc};
Expand Down Expand Up @@ -68,7 +69,10 @@ async fn main() -> Result<()> {
let (sio_layer, io) = SocketIo::builder().with_state(state.clone()).build_layer();

// 注册 Socket.IO 事件处理器
io.ns("/", ws::socketio_handler::on_connect);
io.ns(
"/",
ws::socketio_handler::on_connect.with(ws::socketio_handler::auth_middleware),
);

// CORS 配置
let cors = CorsLayer::new()
Expand Down Expand Up @@ -128,6 +132,7 @@ fn api_routes(state: Arc<AppState>, io: SocketIo) -> Router {
post(routes::drawings::report_drawing),
)
.route("/auth/wechat_mp/login", post(routes::auth::wechat_mp_login))
.route("/auth/guest/login", post(routes::auth::guest_login))
.route("/auth/dev/login", post(routes::dev_auth::dev_login))
.route("/auth/me", get(routes::auth::me))
.route("/auth/logout", post(routes::auth::logout))
Expand Down
11 changes: 7 additions & 4 deletions backend/src/models/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

use crate::config::Config;

#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Room {
pub id: Uuid,
Expand All @@ -20,10 +22,11 @@ pub struct Room {
}

impl Room {
/// 计算动态投票阈值 (在线人数的 30%)
pub fn vote_threshold(&self) -> i32 {
let dynamic = ((self.online_count as f64) * 0.3).ceil() as i32;
std::cmp::max(4, dynamic)
/// 计算动态投票阈值(在线人数 * 配置比例,且不低于最小阈值)
pub fn vote_threshold(&self, config: &Config) -> i32 {
let ratio = config.vote_threshold_ratio.clamp(0.0, 1.0);
let dynamic = ((self.online_count as f64) * ratio).ceil() as i32;
std::cmp::max(config.vote_min_threshold, dynamic)
Comment on lines 24 to +29
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Room::vote_threshold 的注释仍写“在线人数的 30%”,但实现已改为从 Config.vote_threshold_ratio 读取(默认 0.6)。建议更新注释以反映实际阈值来源/默认值,避免后续误用或调参时产生误解。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 62f7080: comment now reflects config-driven ratio/min-threshold behavior for vote threshold calculation.

}
}

Expand Down
44 changes: 44 additions & 0 deletions backend/src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ pub struct WechatMpLoginResponse {
pub is_new_user: bool,
}

#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuestLoginRequest {
#[serde(default, alias = "deviceToken")]
pub device_token: Option<String>,
// Deprecated compatibility field; ignored for identity binding.
#[serde(default, alias = "device_id")]
pub device_id: Option<String>,
#[serde(default, alias = "session_id", alias = "legacySessionId")]
pub session_id: Option<String>,
}

#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GuestLoginResponse {
pub token: String,
pub user_id: String,
pub is_new_user: bool,
pub is_guest: bool,
pub device_token: String,
}

pub async fn wechat_mp_login(
State(state): State<Arc<AppState>>,
Json(req): Json<WechatMpLoginRequest>,
Expand All @@ -43,6 +65,28 @@ pub async fn wechat_mp_login(
}))
}

pub async fn guest_login(
State(state): State<Arc<AppState>>,
Json(req): Json<GuestLoginRequest>,
) -> Result<Json<GuestLoginResponse>, ApiError> {
let requested_device_token = req.device_token.as_deref().or(req.device_id.as_deref());
let result = auth::login_guest_device(
&state.db,
&state.config,
requested_device_token,
req.session_id.as_deref(),
)
.await?;

Ok(Json(GuestLoginResponse {
token: result.token,
user_id: result.user_id.to_string(),
is_new_user: result.is_new_user,
is_guest: true,
device_token: result.device_token,
}))
}

pub async fn me(
State(state): State<Arc<AppState>>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Expand Down
8 changes: 7 additions & 1 deletion backend/src/routes/drawings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use axum::{
response::{IntoResponse, Response},
Extension, Json,
};
use chrono::Utc;
use rand::{rngs::StdRng, Rng, SeedableRng};
use socketioxide::SocketIo;
use std::sync::Arc;
Expand Down Expand Up @@ -33,6 +34,11 @@ pub async fn create_drawing(
if room.status != "active" {
return Err(ApiError::BadRequest("Room is not active".to_string()));
}
if let Some(voting_ends_at) = room.voting_ends_at {
if Utc::now() < voting_ends_at {
return Err(ApiError::BadRequest("Voting is in progress".to_string()));
}
}

// 获取主题配置
let theme: Theme = sqlx::query_as("SELECT * FROM themes WHERE id = $1")
Expand Down Expand Up @@ -250,7 +256,7 @@ pub async fn vote_drawing(
.fetch_one(&state.db)
.await?;

let threshold = room.vote_threshold();
let threshold = room.vote_threshold(&state.config);

// 注意: Socket.IO 广播由 socketio_handler 的 vote:cast 事件处理

Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/themes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub async fn get_or_create_room_by_theme(

// 查找该主题已有的活跃房间
let existing_room: Option<Room> = sqlx::query_as(
"SELECT r.* FROM rooms r WHERE r.theme_id = $1 AND r.status = 'active' ORDER BY r.created_at DESC LIMIT 1"
"SELECT r.* FROM rooms r WHERE r.theme_id = $1 AND r.status IN ('active', 'voting') ORDER BY r.created_at DESC LIMIT 1"
)
.bind(theme.id)
.fetch_optional(&state.db)
Expand Down
Loading