diff --git a/.claude/skills/godot-cli-test-runner/SKILL.md b/.claude/skills/godot-cli-test-runner/SKILL.md new file mode 100644 index 0000000..4633ece --- /dev/null +++ b/.claude/skills/godot-cli-test-runner/SKILL.md @@ -0,0 +1,105 @@ +--- +name: godot-cli-test-runner +description: Run Godot CLI commands for this project with emphasis on headless test execution, script runs, scene runs, and export/debug operations. Use when the user asks to run Godot tests or commands (for example “Godot 跑测试”, “执行 Godot 命令”, “检查 Godot 参数”), troubleshoot CLI failures, or request reusable terminal/CI command templates. +--- + +# Godot CLI Test Runner + +## Overview +Use deterministic Godot CLI workflows for Windows terminal and CI-style execution. Prefer `--headless`, explicit `--path`, and `--log-file` for reproducible diagnostics. + +## Quick Decision +1. Parse-only check script syntax: `--headless --path . --script --check-only`. +2. If test logic depends on autoload singletons (for example `SceneManager`, `LocationManager`, `EventSystem`), do not use direct `--script` as primary validation; use scene/project context first. +3. For isolated script tests without autoload dependencies, run `--headless --path . --script `. +4. Export build by using `--export-release` / `--export-debug` with an existing preset. +5. Diagnose CLI behavior by adding `--verbose` and always writing `--log-file`. + +## Workflow + +### 1. Resolve executable and project path +1. Prefer `godot` from PATH. +2. If not available, use explicit exe path (for this machine typically `D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64_console.exe` or `D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64.exe`). +3. Run from repository root and always pass `--path .` unless intentionally targeting another project. + +### 2. Preflight checks +1. Confirm engine version: `godot --version`. +2. Confirm options when needed: `godot --help`. +3. Confirm project exists: ensure `project.godot` is present under `--path`. +4. Read `project.godot` `[autoload]` and check whether the target test script references those singleton names. +5. Prepare a log output path (for example `.godot/test_xxx.log`) and pass `--log-file`. + +### 3. Execute task type +1. Autoload-dependent validation (preferred when script references global singleton names): +`godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log` +2. Scene-specific validation: +`godot --headless --path . --scene res://scenes/Maps/square.tscn --quit-after 90 --log-file .godot/smoke_square.log` +3. Script test (only for isolated logic or known SceneTree tests): +`godot --headless --path . --script tests/unit/test_xxx.gd --log-file .godot/test_xxx.log` +4. Script syntax only: +`godot --headless --path . --script tests/unit/test_xxx.gd --check-only --log-file .godot/check_xxx.log` +5. Export: +`godot --headless --path . --export-release "Web" web_assets/index.html --log-file .godot/export_web_release.log` + +### 4. Capture and report results +1. Report exit code, key stdout/stderr lines, and failed command. +2. For failures, include one retry variant (for example add `--verbose`, switch explicit exe path, or switch from `--script` to `--scene` context). +3. Keep output concise and actionable. +4. If `--script` fails with missing singleton identifiers, mark it as context mismatch first, not business regression. + +## Command Templates + +### Windows (explicit exe) +```powershell +& "D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64_console.exe" --headless --path . --log-file .godot\test_websocket_close_code.log --script tests/unit/test_websocket_close_code.gd +``` + +### Generic (PATH) +```powershell +godot --headless --path . --log-file .godot/test_websocket_close_code.log --script tests/unit/test_websocket_close_code.gd +``` + +### With extra app args +```powershell +godot --headless --path . --log-file .godot/test_runner.log --script tests/unit/test_runner.gd -- --case websocket --timeout 30 +``` + +## Minimal Runnable Examples +Run from repository root (`--path .`). + +1. Run one scene-level smoke test (autoload-safe): +```powershell +godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log +``` + +2. Run one test script (isolated logic): +```powershell +godot --headless --path . --script tests/unit/test_websocket_close_code.gd --log-file .godot/test_websocket_close_code.log +``` + +3. Run one scene: +```powershell +godot --headless --path . --scene res://scenes/SomeScene.tscn --quit-after 90 --log-file .godot/smoke_scene.log +``` + +4. Parse script only (syntax check): +```powershell +godot --headless --path . --script tests/unit/test_websocket_close_code.gd --check-only --log-file .godot/check_websocket.log +``` + +If `godot` is not in PATH, replace `godot` with explicit exe call: +```powershell +& "D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64.exe" +``` + +## Option Summary Reference +Use `references/godot-cli-commands.md` for categorized option summary and quick recipes based on `godot --help` output. + +## Guardrails +1. Prefer non-interactive commands. +2. Prefer `--headless` for tests and scripts. +3. For this environment, include `--log-file` for reproducible logs and to avoid console build logging issues. +4. Avoid assuming GUT addon exists; check `addons/gut/gut_cmdline.gd` before using GUT command. +5. Use `--check-only` when user requests parse/syntax validation only. +6. For long-running runs, include `--quit-after` when appropriate. +7. Do not classify missing autoload singleton errors in `--script` mode as product regressions until scene/project-context validation is also run. diff --git a/.claude/skills/godot-cli-test-runner/agents/openai.yaml b/.claude/skills/godot-cli-test-runner/agents/openai.yaml new file mode 100644 index 0000000..2197703 --- /dev/null +++ b/.claude/skills/godot-cli-test-runner/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Godot CLI Test Runner" + short_description: "Run Godot tests and command-line workflows" + default_prompt: "Use this skill to run Godot headless tests with autoload-aware strategy, check CLI options, and execute Godot commands with reproducible --log-file diagnostics." diff --git a/.claude/skills/godot-cli-test-runner/references/godot-cli-commands.md b/.claude/skills/godot-cli-test-runner/references/godot-cli-commands.md new file mode 100644 index 0000000..258fc8a --- /dev/null +++ b/.claude/skills/godot-cli-test-runner/references/godot-cli-commands.md @@ -0,0 +1,74 @@ +# Godot CLI Commands Summary + +This reference summarizes the provided `godot --help` output for fast command selection. + +## 1. Core inspection +- Help: `godot --help` +- Version: `godot --version` +- Verbose logs: `godot --verbose` +- Quiet mode: `godot --quiet` + +## 2. Project targeting and run mode +- Point to project directory: `godot --path .` +- Run specific scene: `godot --path . --scene res://scenes/MainScene.tscn` +- Headless mode: `godot --headless --path . ...` +- Quit quickly: `godot --path . --quit` +- Quit after N frames: `godot --path . --quit-after 120` + +## 3. Script execution and tests +- Run script: `godot --headless --path . --script tests/unit/test_xxx.gd` +- Parse-only script check: `godot --headless --path . --script tests/unit/test_xxx.gd --check-only` +- Pass custom user args to script: +`godot --headless --path . --script tests/unit/test_runner.gd -- --case websocket` +- Autoload-safe smoke test (preferred when test uses singleton globals): +`godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log` + +## 4. Debug and diagnostics +- Local debugger: `godot --debug --path .` +- Remote debug: `godot --remote-debug tcp://127.0.0.1:6007 --path .` +- Print FPS: `godot --path . --print-fps` +- Log to file: `godot --path . --log-file logs/godot.log` +- Disable VSync for profiling: `godot --path . --disable-vsync` + +## 5. Display and runtime controls +- Fullscreen: `godot --path . --fullscreen` +- Windowed: `godot --path . --windowed` +- Resolution: `godot --path . --resolution 1920x1080` +- Max FPS: `godot --path . --max-fps 60` +- Fixed FPS: `godot --path . --fixed-fps 60` +- Time scale: `godot --path . --time-scale 0.5` + +## 6. Export operations (editor build only) +- Release export: +`godot --path . --export-release "Web" build/web/index.html` +- Debug export: +`godot --path . --export-debug "Web" build/web/index.html` +- Pack export: +`godot --path . --export-pack "Web" build/web/game.pck` +- Check preset syntax only: +`godot --path . --export-debug "Web" --check-only` + +## 7. Common quick recipes +- Run a unit test script (isolated logic): +`godot --headless --path . --script tests/unit/test_websocket_close_code.gd --log-file .godot/test_websocket_close_code.log` +- Validate script syntax without running: +`godot --headless --path . --script tests/unit/test_websocket_close_code.gd --check-only --log-file .godot/check_websocket.log` +- Run game with verbose logs: +`godot --verbose --path . --log-file .godot/main_verbose.log` +- Run scene and auto-exit after startup checks: +`godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log` + +## 8. Windows explicit executable pattern +When `godot` is not in PATH, call the executable directly: + +```powershell +& "D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64.exe" --headless --path . --script tests/unit/test_websocket_close_code.gd +``` + +## 9. Notes for AI execution +- Prefer `--headless` for tests and scripts in terminal/CI. +- Always include `--path .` for reproducibility. +- Use `--check-only` for parse checks when execution is not needed. +- If a script depends on autoload singleton names from `project.godot` (`SceneManager`, `LocationManager`, `EventSystem`, etc.), validate in scene/project context before concluding regression. +- Prefer `--log-file` for reliable diagnostics and environment-specific logging issues. +- Add `--verbose` when failure context is insufficient. diff --git a/Config/zh_CN.json b/Config/zh_CN.json index 82d2280..2ca8ce4 100644 --- a/Config/zh_CN.json +++ b/Config/zh_CN.json @@ -1,36 +1,36 @@ { "ui": { - "login": "登录", - "register": "注册", - "username": "用户名", - "password": "密码", - "email": "邮箱", - "confirm_password": "确认密码", - "verification_code": "验证码", - "send_code": "发送验证码", - "forgot_password": "忘记密码", - "enter_town": "进入小镇", - "logout": "退出登录" + "login": "登录", + "register": "注册", + "username": "用户名", + "password": "密码", + "email": "邮箱", + "confirm_password": "确认密码", + "verification_code": "验证码", + "send_code": "发送验证码", + "forgot_password": "忘记密码", + "enter_town": "进入小镇", + "logout": "退出登录" }, "messages": { - "login_success": "登录成功!正在进入鲸鱼镇...", - "register_success": "注册成功!欢迎加入鲸鱼镇", - "network_error": "网络连接失败,请检查网络连接", - "invalid_username": "用户名只能包含字母、数字和下划线", - "invalid_email": "请输入有效的邮箱地址", - "password_too_short": "密码长度至少8位", - "password_mismatch": "两次输入的密码不一致", - "verification_code_sent": "验证码已发送到您的邮箱,请查收" + "login_success": "登录成功!正在进入鲸鱼镇...", + "register_success": "注册成功!欢迎加入鲸鱼镇", + "network_error": "网络连接失败,请检查网络连接", + "invalid_username": "用户名只能包含字母、数字和下划线", + "invalid_email": "请输入有效的邮箱地址", + "password_too_short": "密码长度至少8位", + "password_mismatch": "两次输入的密码不一致", + "verification_code_sent": "验证码已发送到您的邮箱,请查收" }, "game": { - "level": "等级", - "coins": "金币", - "experience": "经验", - "energy": "体力", - "explore": "探索小镇", - "inventory": "背包", - "shop": "商店", - "friends": "好友", - "settings": "设置" + "level": "等级", + "coins": "金币", + "experience": "经验", + "energy": "体力", + "explore": "探索小镇", + "inventory": "背包", + "shop": "商店", + "friends": "好友", + "settings": "设置" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 72a21ff..1239342 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,9 @@ WhaleTown/ # 🐋 项目根目录 │ ├── performance/ # ⚡ 性能测试(帧率、内存优化) │ └── api/ # 🌐 API接口测试 └── 🌐 web_assets/ # 🌍 Web导出资源 - ├── html/ # 📄 HTML模板文件 - ├── css/ # 🎨 样式文件 - └── js/ # 📜 JavaScript脚本 + ├── html/ # 📄 HTML模板文件 + ├── css/ # 🎨 样式文件 + └── js/ # 📜 JavaScript脚本 ``` ### 🔧 核心架构说明 diff --git a/_Core/managers/AuthManager.gd b/_Core/managers/AuthManager.gd index 3de2b35..68b6af8 100644 --- a/_Core/managers/AuthManager.gd +++ b/_Core/managers/AuthManager.gd @@ -102,6 +102,7 @@ var _game_token: String = "" # @deprecated 使用 _access_token 替代 # 初始化管理器 func _init() -> void: _load_auth_data() + _connect_network_signals() # 清理资源 func cleanup() -> void: @@ -110,6 +111,8 @@ func cleanup() -> void: NetworkManager.cancel_request(request_id) active_request_ids.clear() + _disconnect_network_signals() + # ============ Token 管理 ============ # 保存 Token 到内存 @@ -675,7 +678,7 @@ func _on_send_login_code_response(success: bool, data: Dictionary, error_info: D func _on_forgot_password_response(success: bool, data: Dictionary, error_info: Dictionary): button_state_changed.emit("forgot_password_btn", false, "忘记密码") - var result = ResponseHandler.handle_send_login_code_response(success, data, error_info) + var result = ResponseHandler.handle_forgot_password_response(success, data, error_info) if result.should_show_toast: show_toast_message.emit(result.message, result.success) @@ -714,10 +717,10 @@ func _can_send_verification_code(email: String) -> bool: if not email_data.sent: return true - var current_time = Time.get_time_dict_from_system() - var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second + var current_timestamp: int = int(Time.get_unix_time_from_system()) + var sent_timestamp: int = int(email_data.get("time", 0)) - return (current_timestamp - email_data.time) >= code_cooldown + return float(current_timestamp - sent_timestamp) >= code_cooldown # 获取剩余冷却时间 func get_remaining_cooldown_time(email: String) -> int: @@ -725,15 +728,15 @@ func get_remaining_cooldown_time(email: String) -> int: return 0 var email_data = verification_codes_sent[email] - var current_time = Time.get_time_dict_from_system() - var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second + var current_timestamp: int = int(Time.get_unix_time_from_system()) + var sent_timestamp: int = int(email_data.get("time", 0)) + var remaining: int = int(code_cooldown - float(current_timestamp - sent_timestamp)) - return int(code_cooldown - (current_timestamp - email_data.time)) + return maxi(0, remaining) # 记录验证码发送状态 func _record_verification_code_sent(email: String): - var current_time = Time.get_time_dict_from_system() - var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second + var current_timestamp: int = int(Time.get_unix_time_from_system()) if not verification_codes_sent.has(email): verification_codes_sent[email] = {} @@ -758,6 +761,30 @@ func _has_sent_verification_code(email: String) -> bool: func _is_valid_identifier(identifier: String) -> bool: return StringUtils.is_valid_email(identifier) or _is_valid_phone(identifier) +# 连接/断开 NetworkManager 请求信号,用于回收 active_request_ids +func _connect_network_signals() -> void: + if not NetworkManager.request_completed.is_connected(_on_network_request_completed): + NetworkManager.request_completed.connect(_on_network_request_completed) + if not NetworkManager.request_failed.is_connected(_on_network_request_failed): + NetworkManager.request_failed.connect(_on_network_request_failed) + +func _disconnect_network_signals() -> void: + if NetworkManager.request_completed.is_connected(_on_network_request_completed): + NetworkManager.request_completed.disconnect(_on_network_request_completed) + if NetworkManager.request_failed.is_connected(_on_network_request_failed): + NetworkManager.request_failed.disconnect(_on_network_request_failed) + +func _on_network_request_completed(request_id: String, _success: bool, _data: Dictionary) -> void: + _remove_active_request_id(request_id) + +func _on_network_request_failed(request_id: String, _error_type: String, _message: String) -> void: + _remove_active_request_id(request_id) + +func _remove_active_request_id(request_id: String) -> void: + var index: int = active_request_ids.find(request_id) + if index != -1: + active_request_ids.remove_at(index) + # 验证手机号格式 func _is_valid_phone(phone: String) -> bool: var regex = RegEx.new() diff --git a/_Core/managers/SceneManager.gd b/_Core/managers/SceneManager.gd index e577432..bb46100 100644 --- a/_Core/managers/SceneManager.gd +++ b/_Core/managers/SceneManager.gd @@ -44,12 +44,6 @@ var _next_spawn_name: String = "" # 下一个场景的出生 # 便于统一管理和修改场景路径 var scene_paths: Dictionary = { "main": "res://scenes/MainScene.tscn", # 主场景 - 游戏入口 - "auth": "res://scenes/ui/LoginWindow.tscn", # 认证场景 - 登录窗口 - "game": "res://scenes/maps/game_scene.tscn", # 游戏场景 - 主要游戏内容 - "battle": "res://scenes/maps/battle_scene.tscn", # 战斗场景 - 战斗系统 - "inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面 - "shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面 - "settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面 "square": "res://scenes/Maps/square.tscn", # 广场地图 "room": "res://scenes/Maps/room.tscn", # 房间地图 "fountain": "res://scenes/Maps/fountain.tscn", # 喷泉地图 diff --git a/_Core/managers/WebSocketManager.gd b/_Core/managers/WebSocketManager.gd index f00cbe1..b768657 100644 --- a/_Core/managers/WebSocketManager.gd +++ b/_Core/managers/WebSocketManager.gd @@ -106,6 +106,9 @@ var _reconnect_timer: Timer = Timer.new() # 是否为正常关闭(非异常断开) var _clean_close: bool = true +# 当前 CLOSED 状态是否已经处理过(防止每帧重复处理 close 事件) +var _closed_state_handled: bool = false + # 心跳定时器 var _heartbeat_timer: Timer = Timer.new() @@ -163,14 +166,20 @@ func _exit_tree() -> void: # ============================================================================ # 连接到游戏服务器 -func connect_to_game_server() -> void: +func connect_to_game_server(is_reconnect_attempt: bool = false) -> void: if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING: push_warning("已经在连接或已连接状态") return - _set_connection_state(ConnectionState.CONNECTING) + if is_reconnect_attempt: + _set_connection_state(ConnectionState.RECONNECTING) + else: + _set_connection_state(ConnectionState.CONNECTING) _clean_close = true - _reconnect_attempt = 0 + _closed_state_handled = false + # 仅在首次/手动连接时重置重连计数,重连流程保持累计尝试次数 + if not is_reconnect_attempt: + _reconnect_attempt = 0 var err: Error = _websocket_peer.connect_to_url(WEBSOCKET_URL) if err != OK: @@ -295,23 +304,40 @@ func _check_websocket_state() -> void: match state: WebSocketPeer.STATE_CONNECTING: + _closed_state_handled = false + # 正在连接 if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING: _set_connection_state(ConnectionState.CONNECTING) WebSocketPeer.STATE_OPEN: + _closed_state_handled = false + # 连接成功 if _connection_state != ConnectionState.CONNECTED: _on_websocket_connected() WebSocketPeer.STATE_CLOSING: + _closed_state_handled = false + # 正在关闭 pass WebSocketPeer.STATE_CLOSED: - # 连接关闭 - var code: int = _websocket_peer.get_close_code() - _on_websocket_closed(code != 0) # code=0 表示正常关闭 + if _closed_state_handled: + return + + _closed_state_handled = true + + # 仅在连接生命周期中发生的关闭才触发关闭处理 + var should_handle_close: bool = ( + _connection_state == ConnectionState.CONNECTED + or _connection_state == ConnectionState.CONNECTING + or _connection_state == ConnectionState.RECONNECTING + ) + if should_handle_close: + var close_code: int = _websocket_peer.get_close_code() + _on_websocket_closed(_is_clean_close_code(close_code)) # WebSocket 连接成功处理 func _on_websocket_connected() -> void: @@ -333,6 +359,11 @@ func _on_websocket_closed(clean_close: bool) -> void: else: _set_connection_state(ConnectionState.DISCONNECTED) +# 判断关闭码是否为干净关闭 +# Godot 中 close_code == -1 表示非干净关闭(异常断开) +func _is_clean_close_code(close_code: int) -> bool: + return close_code != -1 + # ============================================================================ # 内部方法 - 重连机制 # ============================================================================ @@ -375,7 +406,7 @@ func _calculate_reconnect_delay() -> float: # 重连定时器超时处理 func _on_reconnect_timeout() -> void: _clean_close = false - connect_to_game_server() + connect_to_game_server(true) # ============================================================================ # 内部方法 - 心跳机制 diff --git a/claude.md b/claude.md index eb41871..bc67670 100644 --- a/claude.md +++ b/claude.md @@ -66,10 +66,10 @@ ```gdscript extends GutTest func test_event_emission(): - var sender = Node.new() - watch_signals(EventSystem) - EventSystem.emit_event(EventNames.PLAYER_MOVED, {}) - assert_signal_emitted(EventSystem, "event_raised") + var sender = Node.new() + watch_signals(EventSystem) + EventSystem.emit_event(EventNames.PLAYER_MOVED, {}) + assert_signal_emitted(EventSystem, "event_raised") ``` ## 🔄 8. Standard Development Workflow (MANDATORY) @@ -152,13 +152,13 @@ class_name Player # 3. Lifecycle func _physics_process(delta: float) -> void: - _move(delta) + _move(delta) # 4. Private Methods func _move(_delta: float) -> void: - var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") - velocity = dir * move_speed - move_and_slide() + var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down") + velocity = dir * move_speed + move_and_slide() ``` ## 10. 🔄 Plan Mode Protocol (MANDATORY) - **Planning Phase**: @@ -166,9 +166,9 @@ func _move(_delta: float) -> void: - **Execution & Reporting**: - Every time a TODO item is completed, the corresponding `.md` document MUST be updated. - After updating the document, report to the user with the following: - 1. **Completed Items**: What was just finished. - 2. **User Acceptance Rules**: Instructions on how the user can test/verify the current progress. - 3. **Next Step**: The next TODO item to be tackled. + 1. **Completed Items**: What was just finished. + 2. **User Acceptance Rules**: Instructions on how the user can test/verify the current progress. + 3. **Next Step**: The next TODO item to be tackled. - **Strict Confirmation**: - After reporting progress, **Claude MUST stop and wait**. - Do NOT proceed to the next TODO until the user has replied with confirmation/approval. \ No newline at end of file diff --git a/docs/api-documentation.md b/docs/api-documentation.md new file mode 100644 index 0000000..e18004a --- /dev/null +++ b/docs/api-documentation.md @@ -0,0 +1,2495 @@ +# Whale Town 像素游戏服务器 API 文档 + +**版本**: 2.0.0 +**更新时间**: 2026-01-20 + +## � 目录 + +- [概述](#概述) +- [认证机制](#认证机制) +- [通用响应格式](#通用响应格式) +- [错误代码说明](#错误代码说明) +- [API接口列表](#api接口列表) + - [应用状态](#应用状态) + - [用户认证](#用户认证) + - [管理员接口](#管理员接口) + - [用户管理](#用户管理) + - [聊天系统](#聊天系统) + - [位置广播](#位置广播) + - [通知系统](#通知系统) + - [Zulip集成](#zulip集成) + - [数据库管理](#数据库管理) + - [操作日志](#操作日志) + +--- + +## 概述 + +Whale Town 像素游戏服务器提供完整的 RESTful API 和 WebSocket 实时通信接口,支持: + +- 用户认证与授权(JWT) +- 实时聊天和位置广播 +- 管理员后台管理 +- Zulip 第三方集成 +- 通知推送系统 + +**基础URL**: `https://whaletownend.xinghangee.icu` +**WebSocket URL**: `wss://whaletownend.xinghangee.icu/game` + +--- + +## 认证机制 + +### JWT Token 认证 + +大部分接口需要在请求头中携带 JWT Token: + +```http +Authorization: Bearer +``` + +### Token 获取方式 + +1. 用户登录:`POST /auth/login` +2. 用户注册:`POST /auth/register` +3. GitHub OAuth:`POST /auth/github` +4. 验证码登录:`POST /auth/verification-code-login` + +### Token 刷新 + +使用刷新令牌获取新的访问令牌:`POST /auth/refresh-token` + +--- + +## 通用响应格式 + +### 成功响应 + +```json +{ + "success": true, + "data": { ... }, + "message": "操作成功" +} +``` + +### 错误响应 + +```json +{ + "success": false, + "message": "错误描述", + "error_code": "ERROR_CODE" +} +``` + + +--- + +## 错误代码说明 + +| 错误代码 | HTTP状态码 | 说明 | +|---------|-----------|------| +| LOGIN_FAILED | 401 | 登录失败(用户名或密码错误) | +| REGISTER_FAILED | 400/409 | 注册失败(参数错误或用户已存在) | +| TOKEN_REFRESH_FAILED | 401 | Token刷新失败 | +| VERIFICATION_CODE_LOGIN_FAILED | 401 | 验证码登录失败 | +| SEND_CODE_FAILED | 400/404 | 验证码发送失败 | +| RESET_PASSWORD_FAILED | 400 | 密码重置失败 | +| CHANGE_PASSWORD_FAILED | 400/401 | 密码修改失败 | +| TEST_MODE_ONLY | 206 | 测试模式(验证码未真实发送) | +| ADMIN_LOGIN_FAILED | 401/403 | 管理员登录失败 | +| USER_NOT_FOUND | 404 | 用户不存在 | +| TOO_MANY_REQUESTS | 429 | 请求过于频繁 | + +--- + +## API接口列表 + +### 应用状态 + +#### 获取应用状态 + +**接口**: `GET /` + +**描述**: 获取应用的基本运行状态信息,用于健康检查和服务监控 + +**认证**: 不需要 + +**响应示例**: + +```json +{ + "service": "Pixel Game Server", + "version": "2.0.0", + "status": "running", + "timestamp": "2026-01-20T10:00:00.000Z", + "uptime": 86400, + "environment": "production", + "storage_mode": "database" +} +``` + +--- + +### 用户认证 + +#### 1. 用户登录 + +**接口**: `POST /auth/login` + +**描述**: 支持用户名、邮箱或手机号登录 + +**请求体**: + +```json +{ + "identifier": "testuser", + "password": "password123" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "user": { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "phone": null, + "avatar_url": null, + "role": 1, + "created_at": "2026-01-20T10:00:00.000Z" + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "is_new_user": false, + "message": "登录成功" + }, + "message": "登录成功" +} +``` + +**错误响应**: + +- `401`: 用户名或密码错误 +- `403`: 账户被禁用或锁定 +- `429`: 登录尝试过于频繁 + +--- + +#### 2. 用户注册 + +**接口**: `POST /auth/register` + +**描述**: 创建新用户账户,支持邮箱验证 + +**请求体(基础注册)**: + +```json +{ + "username": "newuser", + "password": "password123", + "nickname": "新用户" +} +``` + +**请求体(带邮箱验证)**: + +```json +{ + "username": "newuser", + "password": "password123", + "nickname": "新用户", + "email": "newuser@example.com", + "email_verification_code": "123456" +} +``` + +**成功响应 (201)**: + +```json +{ + "success": true, + "data": { + "user": { + "id": "2", + "username": "newuser", + "nickname": "新用户", + "email": "newuser@example.com", + "phone": null, + "avatar_url": null, + "role": 1, + "created_at": "2026-01-20T10:00:00.000Z" + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "is_new_user": true, + "message": "注册成功" + }, + "message": "注册成功" +} +``` + +**错误响应**: + +- `400`: 验证码错误或参数错误 +- `409`: 用户名或邮箱已存在 +- `429`: 注册请求过于频繁 + +--- + +#### 3. GitHub OAuth 登录 + +**接口**: `POST /auth/github` + +**描述**: 使用 GitHub 账户登录或注册 + +**请求体**: + +```json +{ + "github_id": "12345678", + "username": "octocat", + "nickname": "The Octocat", + "email": "octocat@github.com", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "user": { + "id": "3", + "username": "octocat", + "nickname": "The Octocat", + "email": "octocat@github.com", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "role": 1, + "created_at": "2026-01-20T10:00:00.000Z" + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "is_new_user": false, + "message": "GitHub登录成功" + }, + "message": "GitHub登录成功" +} +``` + +--- + +#### 4. 验证码登录 + +**接口**: `POST /auth/verification-code-login` + +**描述**: 使用邮箱或手机号和验证码进行登录,无需密码 + +**请求体**: + +```json +{ + "identifier": "test@example.com", + "verification_code": "123456" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "user": { ... }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "is_new_user": false, + "message": "验证码登录成功" + }, + "message": "验证码登录成功" +} +``` + +**错误响应**: + +- `401`: 验证码错误或已过期 +- `404`: 用户不存在 + +--- + +#### 5. 发送登录验证码 + +**接口**: `POST /auth/send-login-verification-code` + +**描述**: 向用户邮箱或手机发送登录验证码 + +**请求体**: + +```json +{ + "identifier": "test@example.com" +} +``` + +**成功响应 (200) - 生产环境**: + +```json +{ + "success": true, + "data": { + "is_test_mode": false + }, + "message": "验证码已发送,请查收" +} +``` + +**测试模式响应 (206) - 开发环境**: + +```json +{ + "success": false, + "data": { + "verification_code": "654321", + "is_test_mode": true + }, + "message": "⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码。", + "error_code": "TEST_MODE_ONLY" +} +``` + +**错误响应**: + +- `404`: 用户不存在 +- `429`: 发送频率过高 + +--- + +#### 6. 发送邮箱验证码 + +**接口**: `POST /auth/send-email-verification` + +**描述**: 向指定邮箱发送验证码(用于注册) + +**请求体**: + +```json +{ + "email": "test@example.com" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "is_test_mode": false + }, + "message": "验证码已发送,请查收邮件" +} +``` + +**错误响应**: + +- `409`: 邮箱已被注册 +- `429`: 验证码发送过于频繁 + +--- + +#### 7. 验证邮箱验证码 + +**接口**: `POST /auth/verify-email` + +**描述**: 使用验证码验证邮箱 + +**请求体**: + +```json +{ + "email": "test@example.com", + "verification_code": "123456" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "邮箱验证成功" +} +``` + +**错误响应**: + +- `400`: 验证码错误或已过期 + +--- + +#### 8. 重新发送邮箱验证码 + +**接口**: `POST /auth/resend-email-verification` + +**描述**: 重新向指定邮箱发送验证码 + +**请求体**: + +```json +{ + "email": "test@example.com" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "is_test_mode": false + }, + "message": "验证码已重新发送,请查收邮件" +} +``` + +**错误响应**: + +- `400`: 邮箱已验证,无需重复验证 +- `429`: 发送频率过高 + +--- + +#### 9. 发送密码重置验证码 + +**接口**: `POST /auth/forgot-password` + +**描述**: 向用户邮箱或手机发送密码重置验证码 + +**请求体**: + +```json +{ + "identifier": "test@example.com" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "is_test_mode": false + }, + "message": "验证码已发送,请查收" +} +``` + +**错误响应**: + +- `404`: 用户不存在 +- `429`: 发送频率过高 + +--- + +#### 10. 重置密码 + +**接口**: `POST /auth/reset-password` + +**描述**: 使用验证码重置用户密码 + +**请求体**: + +```json +{ + "identifier": "test@example.com", + "verification_code": "789012", + "new_password": "newpassword123" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "密码重置成功" +} +``` + +**错误响应**: + +- `400`: 验证码错误或参数无效 +- `404`: 用户不存在 +- `429`: 重置请求过于频繁 + +--- + +#### 11. 修改密码 + +**接口**: `PUT /auth/change-password` + +**描述**: 用户修改自己的密码(需要提供旧密码) + +**请求体**: + +```json +{ + "user_id": "1", + "old_password": "oldpassword123", + "new_password": "newpassword123" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "密码修改成功" +} +``` + +**错误响应**: + +- `400`: 请求参数错误 +- `401`: 旧密码不正确 +- `404`: 用户不存在 + +--- + +#### 12. 刷新访问令牌 + +**接口**: `POST /auth/refresh-token` + +**描述**: 使用有效的刷新令牌生成新的访问令牌 + +**请求体**: + +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 3600 + }, + "message": "令牌刷新成功" +} +``` + +**错误响应**: + +- `401`: 刷新令牌无效或已过期 +- `404`: 用户不存在或已被禁用 +- `429`: 刷新请求过于频繁 + + +--- + +### 管理员接口 + +#### 1. 管理员登录 + +**接口**: `POST /admin/auth/login` + +**描述**: 管理员身份认证,仅允许 role=9 的账户登录 + +**请求体**: + +```json +{ + "identifier": "admin", + "password": "Admin123456" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "admin": { + "id": "1", + "username": "admin", + "nickname": "管理员", + "role": 9 + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expires_in": 28800, + "message": "管理员登录成功" + }, + "message": "管理员登录成功" +} +``` + +**错误响应**: + +- `401`: 用户名或密码错误 +- `403`: 权限不足,需要管理员权限 +- `429`: 登录尝试过于频繁 + +--- + +#### 2. 获取用户列表 + +**接口**: `GET /admin/users` + +**描述**: 分页获取系统中的用户列表 + +**认证**: 需要管理员 Token + +**查询参数**: + +- `limit` (可选): 返回数量,默认100 +- `offset` (可选): 偏移量,默认0 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "users": [ + { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "phone": null, + "role": 1, + "status": "active", + "email_verified": true, + "created_at": "2026-01-20T10:00:00.000Z", + "updated_at": "2026-01-20T10:00:00.000Z" + } + ], + "pagination": { + "limit": 100, + "offset": 0, + "total": 1 + } + }, + "message": "用户列表获取成功" +} +``` + +--- + +#### 3. 获取用户详情 + +**接口**: `GET /admin/users/:id` + +**描述**: 根据用户ID获取指定用户的详细信息 + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 用户ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "user": { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "phone": null, + "role": 1, + "status": "active", + "email_verified": true, + "github_id": null, + "avatar_url": null, + "created_at": "2026-01-20T10:00:00.000Z", + "updated_at": "2026-01-20T10:00:00.000Z" + } + }, + "message": "用户详情获取成功" +} +``` + +**错误响应**: + +- `404`: 用户不存在 + +--- + +#### 4. 重置用户密码 + +**接口**: `POST /admin/users/:id/reset-password` + +**描述**: 管理员直接为指定用户设置新密码 + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 用户ID + +**请求体**: + +```json +{ + "newPassword": "newpassword123" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "用户密码重置成功" +} +``` + +**错误响应**: + +- `400`: 密码不符合强度规则 +- `404`: 用户不存在 +- `429`: 操作过于频繁 + +--- + +#### 5. 获取运行时日志 + +**接口**: `GET /admin/logs/runtime` + +**描述**: 从 logs/ 目录读取最近的日志行 + +**认证**: 需要管理员 Token + +**查询参数**: + +- `lines` (可选): 返回行数,默认200,最大2000 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "logs": [ + "[2026-01-20 18:27:35] LOG [NestApplication] Nest application successfully started", + "[2026-01-20 18:27:35] LOG [RouterExplorer] Mapped {/, GET} route" + ], + "total_lines": 2, + "timestamp": "2026-01-20T10:27:44.352Z" + }, + "message": "运行时日志获取成功" +} +``` + +--- + +#### 6. 下载全部运行日志 + +**接口**: `GET /admin/logs/archive` + +**描述**: 将 logs/ 目录打包为 tar.gz 并下载 + +**认证**: 需要管理员 Token + +**响应**: 二进制流(application/gzip) + +--- + +### 用户管理 + +#### 1. 修改用户状态 + +**接口**: `PUT /admin/users/:id/status` + +**描述**: 管理员修改指定用户的账户状态 + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 用户ID + +**请求体**: + +```json +{ + "status": "locked", + "reason": "违规操作" +} +``` + +**支持的状态值**: + +- `active`: 激活 +- `inactive`: 未激活 +- `locked`: 锁定 +- `banned`: 禁用 +- `deleted`: 已删除 +- `pending`: 待审核 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "user": { + "id": "1", + "username": "testuser", + "status": "locked", + "updated_at": "2026-01-20T10:00:00.000Z" + } + }, + "message": "用户状态修改成功" +} +``` + +**错误响应**: + +- `400`: 状态值无效 +- `403`: 权限不足 +- `404`: 用户不存在 +- `429`: 操作过于频繁 + +--- + +#### 2. 批量修改用户状态 + +**接口**: `POST /admin/users/batch-status` + +**描述**: 管理员批量修改多个用户的账户状态 + +**认证**: 需要管理员 Token + +**请求体**: + +```json +{ + "userIds": ["1", "2", "3"], + "status": "active", + "reason": "批量激活" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "updated_count": 3, + "failed_count": 0, + "results": [ + { + "user_id": "1", + "success": true, + "new_status": "active" + }, + { + "user_id": "2", + "success": true, + "new_status": "active" + }, + { + "user_id": "3", + "success": true, + "new_status": "active" + } + ] + }, + "message": "批量状态修改完成" +} +``` + +--- + +#### 3. 获取用户状态统计 + +**接口**: `GET /admin/users/status-stats` + +**描述**: 获取各种用户状态的数量统计信息 + +**认证**: 需要管理员 Token + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "stats": { + "active": 15, + "inactive": 3, + "locked": 2, + "banned": 1, + "deleted": 0, + "pending": 5 + }, + "total": 26 + }, + "message": "用户状态统计获取成功" +} +``` + +--- + +### 聊天系统 + +#### 1. 获取聊天历史记录 + +**接口**: `GET /chat/history` + +**描述**: 获取指定地图的聊天历史记录 + +**认证**: 需要 JWT Token + +**查询参数**: + +- `mapId` (可选): 地图ID +- `limit` (可选): 消息数量限制 +- `offset` (可选): 偏移量 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "messages": [ + { + "id": "msg_123", + "userId": "1", + "username": "testuser", + "nickname": "测试用户", + "content": "Hello World", + "mapId": "map_001", + "timestamp": "2026-01-20T10:00:00.000Z" + } + ], + "total": 100, + "limit": 50, + "offset": 0 + } +} +``` + +--- + +#### 2. 获取聊天系统状态 + +**接口**: `GET /chat/status` + +**描述**: 获取聊天系统的运行状态和统计信息 + +**认证**: 不需要 + +**成功响应 (200)**: + +```json +{ + "websocket": { + "totalConnections": 150, + "authenticatedConnections": 120, + "activeSessions": 120, + "mapPlayerCounts": { + "map_001": 45, + "map_002": 38, + "map_003": 37 + } + }, + "zulip": { + "serverConnected": true, + "serverVersion": "11.4", + "botAccountActive": true, + "availableStreams": 12, + "gameStreams": ["Whale Port", "Pumpkin Valley", "Novice Village"], + "recentMessageCount": 156 + }, + "uptime": 86400, + "memory": { + "used": "128.5 MB", + "total": "256.0 MB", + "percentage": 50.2 + } +} +``` + +--- + +#### 3. 获取 WebSocket 连接信息 + +**接口**: `GET /chat/websocket/info` + +**描述**: 获取 WebSocket 连接配置信息 + +**认证**: 不需要 + +**成功响应 (200)**: + +```json +{ + "websocketUrl": "wss://whaletownend.xinghangee.icu/game", + "protocol": "native-websocket", + "path": "/game", + "supportedEvents": ["login", "chat", "position"], + "supportedResponses": [ + "connected", + "login_success", + "login_error", + "chat_sent", + "chat_error", + "chat_render", + "error" + ], + "authRequired": true, + "tokenType": "JWT" +} +``` + +--- + +### 位置广播 + +#### 1. 创建新会话 + +**接口**: `POST /location-broadcast/sessions` + +**描述**: 创建一个新的游戏会话,用于多人位置广播 + +**认证**: 需要 JWT Token + +**请求体**: + +```json +{ + "sessionId": "session_12345", + "name": "测试会话", + "description": "这是一个测试会话", + "maxUsers": 50, + "allowObservers": true, + "password": "optional_password", + "allowedMaps": ["map_001", "map_002"], + "broadcastRange": 100, + "metadata": { + "custom_field": "value" + } +} +``` + +**成功响应 (201)**: + +```json +{ + "success": true, + "data": { + "sessionId": "session_12345", + "createdAt": 1641024000000, + "config": { + "maxUsers": 50, + "allowObservers": true, + "broadcastRange": 100 + }, + "metadata": { + "custom_field": "value" + } + }, + "message": "会话创建成功" +} +``` + +**错误响应**: + +- `400`: 请求参数错误 +- `409`: 会话ID已存在 + +--- + +#### 2. 查询会话列表 + +**接口**: `GET /location-broadcast/sessions` + +**描述**: 根据条件查询游戏会话列表 + +**认证**: 需要 JWT Token + +**查询参数**: + +- `status` (可选): 会话状态 +- `minUsers` (可选): 最小用户数 +- `maxUsers` (可选): 最大用户数 +- `publicOnly` (可选): 只显示公开会话 +- `offset` (可选): 分页偏移 +- `limit` (可选): 分页大小 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "sessions": [ + { + "sessionId": "session_12345", + "name": "测试会话", + "status": "active", + "currentUsers": 15, + "maxUsers": 50, + "createdAt": 1641024000000 + } + ], + "total": 10, + "page": 1, + "pageSize": 10 + } +} +``` + +--- + +#### 3. 获取会话详情 + +**接口**: `GET /location-broadcast/sessions/:sessionId` + +**描述**: 获取指定会话的详细信息,包括用户列表和位置信息 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `sessionId`: 会话ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "session": { + "sessionId": "session_12345", + "name": "测试会话", + "status": "active", + "currentUsers": 15, + "maxUsers": 50 + }, + "users": [ + { + "userId": "1", + "username": "testuser", + "joinedAt": 1641024000000 + } + ], + "onlineCount": 15, + "activeMaps": ["map_001", "map_002"] + } +} +``` + +**错误响应**: + +- `404`: 会话不存在 + +--- + +#### 4. 更新会话配置 + +**接口**: `PUT /location-broadcast/sessions/:sessionId/config` + +**描述**: 更新指定会话的配置参数(需要管理员权限) + +**认证**: 需要 JWT Token + +**路径参数**: + +- `sessionId`: 会话ID + +**请求体**: + +```json +{ + "maxUsers": 100, + "allowObservers": false, + "broadcastRange": 150 +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "sessionId": "session_12345", + "config": { + "maxUsers": 100, + "allowObservers": false, + "broadcastRange": 150 + } + }, + "message": "会话配置更新成功" +} +``` + +**错误响应**: + +- `403`: 权限不足 +- `404`: 会话不存在 + +--- + +#### 5. 结束会话 + +**接口**: `DELETE /location-broadcast/sessions/:sessionId` + +**描述**: 结束指定的游戏会话(需要管理员权限) + +**认证**: 需要 JWT Token + +**路径参数**: + +- `sessionId`: 会话ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "会话结束成功" +} +``` + +**错误响应**: + +- `403`: 权限不足 +- `404`: 会话不存在 + +--- + +#### 6. 查询位置信息 + +**接口**: `GET /location-broadcast/positions` + +**描述**: 根据条件查询用户位置信息 + +**认证**: 需要 JWT Token + +**查询参数**: + +- `userIds` (可选): 用户ID列表(逗号分隔) +- `mapId` (可选): 地图ID +- `sessionId` (可选): 会话ID +- `centerX` (可选): 范围查询中心X坐标 +- `centerY` (可选): 范围查询中心Y坐标 +- `radius` (可选): 范围查询半径 +- `offset` (可选): 分页偏移 +- `limit` (可选): 分页大小 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "positions": [ + { + "userId": "1", + "username": "testuser", + "mapId": "map_001", + "x": 100, + "y": 200, + "timestamp": 1641024000000 + } + ], + "total": 20, + "timestamp": 1641024000000 + } +} +``` + +--- + +#### 7. 获取位置统计信息 + +**接口**: `GET /location-broadcast/positions/stats` + +**描述**: 获取位置数据的统计信息,包括用户分布、活跃地图等 + +**认证**: 需要 JWT Token + +**查询参数**: + +- `mapId` (可选): 地图ID +- `sessionId` (可选): 会话ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "totalUsers": 100, + "onlineUsers": 85, + "activeMaps": 5, + "mapDistribution": { + "map_001": 35, + "map_002": 28, + "map_003": 22 + }, + "updateFrequency": 2.5, + "timestamp": 1641024000000 + } +} +``` + +--- + +#### 8. 获取用户位置历史 + +**接口**: `GET /location-broadcast/users/:userId/position-history` + +**描述**: 获取指定用户的位置历史记录 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `userId`: 用户ID + +**查询参数**: + +- `mapId` (可选): 地图ID过滤 +- `limit` (可选): 最大记录数 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": [ + { + "userId": "1", + "mapId": "map_001", + "x": 100, + "y": 200, + "timestamp": 1641024000000 + } + ] +} +``` + +**错误响应**: + +- `403`: 权限不足(只能查看自己的历史记录) + +--- + +#### 9. 清理用户数据 + +**接口**: `DELETE /location-broadcast/users/:userId/data` + +**描述**: 清理指定用户的位置广播相关数据(需要管理员权限) + +**认证**: 需要 JWT Token + +**路径参数**: + +- `userId`: 用户ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "用户数据清理成功" +} +``` + +**错误响应**: + +- `403`: 权限不足 + + +--- + +### 通知系统 + +#### 1. 创建通知 + +**接口**: `POST /api/notices` + +**描述**: 创建新的通知消息 + +**认证**: 需要 JWT Token + +**请求体**: + +```json +{ + "title": "系统通知", + "content": "这是一条系统通知消息", + "userId": 1, + "type": "system" +} +``` + +**成功响应 (201)**: + +```json +{ + "id": 1, + "title": "系统通知", + "content": "这是一条系统通知消息", + "userId": 1, + "type": "system", + "isRead": false, + "createdAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +#### 2. 获取通知列表 + +**接口**: `GET /api/notices` + +**描述**: 获取当前用户的通知列表 + +**认证**: 需要 JWT Token + +**查询参数**: + +- `all` (可选): 管理员可设置为 `true` 获取所有通知 + +**成功响应 (200)**: + +```json +[ + { + "id": 1, + "title": "系统通知", + "content": "这是一条系统通知消息", + "userId": 1, + "type": "system", + "isRead": false, + "createdAt": "2026-01-20T10:00:00.000Z" + } +] +``` + +--- + +#### 3. 获取未读通知数量 + +**接口**: `GET /api/notices/unread-count` + +**描述**: 获取当前用户的未读通知数量 + +**认证**: 需要 JWT Token + +**成功响应 (200)**: + +```json +{ + "count": 5 +} +``` + +--- + +#### 4. 获取通知详情 + +**接口**: `GET /api/notices/:id` + +**描述**: 获取指定通知的详细信息 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `id`: 通知ID + +**成功响应 (200)**: + +```json +{ + "id": 1, + "title": "系统通知", + "content": "这是一条系统通知消息", + "userId": 1, + "type": "system", + "isRead": false, + "createdAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +#### 5. 标记通知为已读 + +**接口**: `PATCH /api/notices/:id/read` + +**描述**: 将指定通知标记为已读 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `id`: 通知ID + +**成功响应 (200)**: + +```json +{ + "id": 1, + "title": "系统通知", + "content": "这是一条系统通知消息", + "userId": 1, + "type": "system", + "isRead": true, + "createdAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +#### 6. 发送系统通知 + +**接口**: `POST /api/notices/system` + +**描述**: 发送系统通知给指定用户或所有用户 + +**认证**: 需要 JWT Token(管理员) + +**请求体**: + +```json +{ + "title": "系统维护通知", + "content": "系统将于今晚22:00进行维护", + "userId": 1 +} +``` + +**成功响应 (201)**: + +```json +{ + "id": 2, + "title": "系统维护通知", + "content": "系统将于今晚22:00进行维护", + "userId": 1, + "type": "system", + "isRead": false, + "createdAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +#### 7. 发送广播通知 + +**接口**: `POST /api/notices/broadcast` + +**描述**: 向所有用户发送广播通知 + +**认证**: 需要 JWT Token(管理员) + +**请求体**: + +```json +{ + "title": "重要公告", + "content": "新版本即将上线,敬请期待!" +} +``` + +**成功响应 (201)**: + +```json +{ + "id": 3, + "title": "重要公告", + "content": "新版本即将上线,敬请期待!", + "type": "broadcast", + "isRead": false, + "createdAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +### Zulip集成 + +#### 1. 创建Zulip账号关联 + +**接口**: `POST /zulip-accounts` + +**描述**: 为游戏用户创建与Zulip账号的关联关系 + +**认证**: 需要 JWT Token + +**请求体**: + +```json +{ + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active" +} +``` + +**成功响应 (201)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active", + "createdAt": "2026-01-20T10:00:00.000Z", + "updatedAt": "2026-01-20T10:00:00.000Z" +} +``` + +**错误响应**: + +- `400`: 请求参数错误 +- `409`: 关联已存在 + +--- + +#### 2. 查询Zulip账号关联列表 + +**接口**: `GET /zulip-accounts` + +**描述**: 根据条件查询Zulip账号关联列表 + +**认证**: 需要 JWT Token + +**查询参数**: + +- `gameUserId` (可选): 游戏用户ID +- `zulipUserId` (可选): Zulip用户ID +- `zulipEmail` (可选): Zulip邮箱地址 +- `status` (可选): 账号状态 +- `limit` (可选): 返回数量 +- `offset` (可选): 偏移量 + +**成功响应 (200)**: + +```json +{ + "data": [ + { + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active", + "createdAt": "2026-01-20T10:00:00.000Z" + } + ], + "total": 1, + "limit": 10, + "offset": 0 +} +``` + +--- + +#### 3. 根据ID获取Zulip账号关联 + +**接口**: `GET /zulip-accounts/:id` + +**描述**: 根据关联ID获取Zulip账号关联详情 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `id`: 关联ID + +**成功响应 (200)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active", + "createdAt": "2026-01-20T10:00:00.000Z", + "updatedAt": "2026-01-20T10:00:00.000Z" +} +``` + +**错误响应**: + +- `404`: 关联不存在 + +--- + +#### 4. 根据游戏用户ID获取关联 + +**接口**: `GET /zulip-accounts/game-user/:gameUserId` + +**描述**: 根据游戏用户ID获取Zulip账号关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `gameUserId`: 游戏用户ID + +**成功响应 (200)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active" +} +``` + +--- + +#### 5. 根据Zulip用户ID获取关联 + +**接口**: `GET /zulip-accounts/zulip-user/:zulipUserId` + +**描述**: 根据Zulip用户ID获取账号关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `zulipUserId`: Zulip用户ID + +**成功响应 (200)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active" +} +``` + +--- + +#### 6. 根据Zulip邮箱获取关联 + +**接口**: `GET /zulip-accounts/zulip-email/:zulipEmail` + +**描述**: 根据Zulip邮箱获取账号关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `zulipEmail`: Zulip邮箱地址 + +**成功响应 (200)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Test User", + "status": "active" +} +``` + +--- + +#### 7. 更新Zulip账号关联 + +**接口**: `PUT /zulip-accounts/:id` + +**描述**: 更新指定的Zulip账号关联信息 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `id`: 关联ID + +**请求体**: + +```json +{ + "zulipFullName": "Updated Name", + "status": "inactive" +} +``` + +**成功响应 (200)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Updated Name", + "status": "inactive", + "updatedAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +#### 8. 根据游戏用户ID更新关联 + +**接口**: `PUT /zulip-accounts/game-user/:gameUserId` + +**描述**: 根据游戏用户ID更新Zulip账号关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `gameUserId`: 游戏用户ID + +**请求体**: + +```json +{ + "zulipFullName": "Updated Name", + "status": "active" +} +``` + +**成功响应 (200)**: + +```json +{ + "id": 1, + "gameUserId": "12345", + "zulipUserId": 67890, + "zulipEmail": "user@example.com", + "zulipFullName": "Updated Name", + "status": "active", + "updatedAt": "2026-01-20T10:00:00.000Z" +} +``` + +--- + +#### 9. 删除Zulip账号关联 + +**接口**: `DELETE /zulip-accounts/:id` + +**描述**: 删除指定的Zulip账号关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `id`: 关联ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "Zulip账号关联删除成功" +} +``` + +--- + +#### 10. 根据游戏用户ID删除关联 + +**接口**: `DELETE /zulip-accounts/game-user/:gameUserId` + +**描述**: 根据游戏用户ID删除Zulip账号关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `gameUserId`: 游戏用户ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "Zulip账号关联删除成功" +} +``` + +--- + +#### 11. 获取需要验证的账号列表 + +**接口**: `GET /zulip-accounts/management/verification-needed` + +**描述**: 获取需要验证的Zulip账号列表 + +**认证**: 需要 JWT Token + +**成功响应 (200)**: + +```json +{ + "data": [ + { + "id": 1, + "gameUserId": "12345", + "zulipEmail": "user@example.com", + "status": "pending_verification" + } + ], + "total": 1 +} +``` + +--- + +#### 12. 获取错误状态的账号列表 + +**接口**: `GET /zulip-accounts/management/error-accounts` + +**描述**: 获取处于错误状态的Zulip账号列表 + +**认证**: 需要 JWT Token + +**成功响应 (200)**: + +```json +{ + "data": [ + { + "id": 2, + "gameUserId": "67890", + "zulipEmail": "error@example.com", + "status": "error", + "errorMessage": "连接失败" + } + ], + "total": 1 +} +``` + +--- + +#### 13. 批量更新账号状态 + +**接口**: `PUT /zulip-accounts/management/batch-status` + +**描述**: 批量更新Zulip账号的状态 + +**认证**: 需要 JWT Token + +**请求体**: + +```json +{ + "accountIds": [1, 2, 3], + "status": "active" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "updated": 3, + "message": "批量更新成功" +} +``` + +--- + +#### 14. 获取账号状态统计 + +**接口**: `GET /zulip-accounts/management/statistics` + +**描述**: 获取Zulip账号的状态统计信息 + +**认证**: 需要 JWT Token + +**成功响应 (200)**: + +```json +{ + "total": 100, + "active": 85, + "inactive": 10, + "pending_verification": 3, + "error": 2 +} +``` + +--- + +#### 15. 验证账号有效性 + +**接口**: `POST /zulip-accounts/management/verify` + +**描述**: 验证Zulip账号的有效性 + +**认证**: 需要 JWT Token + +**请求体**: + +```json +{ + "accountId": 1 +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "valid": true, + "message": "账号验证成功" +} +``` + +--- + +#### 16. 检查邮箱是否已存在 + +**接口**: `GET /zulip-accounts/validation/email-exists/:email` + +**描述**: 检查指定邮箱是否已在Zulip中存在 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `email`: 邮箱地址 + +**成功响应 (200)**: + +```json +{ + "exists": true, + "accountId": 1 +} +``` + +--- + +#### 17. 检查Zulip用户ID是否已存在 + +**接口**: `GET /zulip-accounts/validation/zulip-user-exists/:zulipUserId` + +**描述**: 检查指定Zulip用户ID是否已关联 + +**认证**: 需要 JWT Token + +**路径参数**: + +- `zulipUserId`: Zulip用户ID + +**成功响应 (200)**: + +```json +{ + "exists": true, + "accountId": 1 +} +``` + +--- + +### 数据库管理 + +#### 1. 获取用户列表 + +**接口**: `GET /admin/database/users` + +**描述**: 分页获取用户列表(管理员专用) + +**认证**: 需要管理员 Token + +**查询参数**: + +- `limit` (可选): 返回数量,默认20,最大100 +- `offset` (可选): 偏移量,默认0 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": [ + { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "role": 1, + "status": "active", + "created_at": "2026-01-20T10:00:00.000Z" + } + ], + "total": 100, + "limit": 20, + "offset": 0 +} +``` + +--- + +#### 2. 获取用户详情 + +**接口**: `GET /admin/database/users/:id` + +**描述**: 根据用户ID获取详细的用户信息 + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 用户ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "phone": null, + "role": 1, + "status": "active", + "email_verified": true, + "created_at": "2026-01-20T10:00:00.000Z", + "updated_at": "2026-01-20T10:00:00.000Z" + } +} +``` + +--- + +#### 3. 搜索用户 + +**接口**: `GET /admin/database/users/search` + +**描述**: 根据关键词搜索用户,支持用户名、邮箱、昵称模糊匹配 + +**认证**: 需要管理员 Token + +**查询参数**: + +- `keyword`: 搜索关键词 +- `limit` (可选): 返回数量,默认20,最大50 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": [ + { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com" + } + ], + "total": 5 +} +``` + +--- + +#### 4. 创建用户 + +**接口**: `POST /admin/database/users` + +**描述**: 创建新用户(管理员专用) + +**认证**: 需要管理员 Token + +**请求体**: + +```json +{ + "username": "newuser", + "password": "password123", + "nickname": "新用户", + "email": "newuser@example.com", + "role": 1 +} +``` + +**成功响应 (201)**: + +```json +{ + "success": true, + "data": { + "id": "2", + "username": "newuser", + "nickname": "新用户", + "email": "newuser@example.com", + "role": 1, + "created_at": "2026-01-20T10:00:00.000Z" + }, + "message": "用户创建成功" +} +``` + +**错误响应**: + +- `400`: 请求参数错误 +- `409`: 用户名或邮箱已存在 + +--- + +#### 5. 更新用户 + +**接口**: `PUT /admin/database/users/:id` + +**描述**: 根据用户ID更新用户信息 + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 用户ID + +**请求体**: + +```json +{ + "nickname": "更新后的昵称", + "email": "newemail@example.com", + "status": "active" +} +``` + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "id": "1", + "username": "testuser", + "nickname": "更新后的昵称", + "email": "newemail@example.com", + "status": "active", + "updated_at": "2026-01-20T10:00:00.000Z" + }, + "message": "用户更新成功" +} +``` + +**错误响应**: + +- `404`: 用户不存在 + +--- + +#### 6. 删除用户 + +**接口**: `DELETE /admin/database/users/:id` + +**描述**: 根据用户ID删除用户(软删除) + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 用户ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "message": "用户删除成功" +} +``` + +**错误响应**: + +- `404`: 用户不存在 + +--- + +### 操作日志 + +#### 1. 获取操作日志列表 + +**接口**: `GET /admin/operation-logs` + +**描述**: 分页获取管理员操作日志,支持多种过滤条件 + +**认证**: 需要管理员 Token + +**查询参数**: + +- `limit` (可选): 返回数量,默认50,最大200 +- `offset` (可选): 偏移量,默认0 +- `adminUserId` (可选): 管理员用户ID过滤 +- `operationType` (可选): 操作类型过滤(CREATE, UPDATE, DELETE, QUERY) +- `targetType` (可选): 目标类型过滤(users, admin_logs等) +- `operationResult` (可选): 操作结果过滤(SUCCESS, FAILURE) +- `startDate` (可选): 开始日期(ISO格式) +- `endDate` (可选): 结束日期(ISO格式) +- `isSensitive` (可选): 是否敏感操作 + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": [ + { + "id": "log_123", + "adminUserId": "1", + "operationType": "CREATE", + "targetType": "users", + "targetId": "2", + "operationResult": "SUCCESS", + "description": "创建用户", + "isSensitive": true, + "createdAt": "2026-01-20T10:00:00.000Z" + } + ], + "total": 100, + "limit": 50, + "offset": 0 +} +``` + +--- + +#### 2. 获取操作日志详情 + +**接口**: `GET /admin/operation-logs/:id` + +**描述**: 根据日志ID获取操作日志的详细信息 + +**认证**: 需要管理员 Token + +**路径参数**: + +- `id`: 日志ID + +**成功响应 (200)**: + +```json +{ + "success": true, + "data": { + "id": "log_123", + "adminUserId": "1", + "operationType": "CREATE", + "targetType": "users", + "targetId": "2", + "operationResult": "SUCCESS", + "description": "创建用户", + "isSensitive": true, + "metadata": { + "username": "newuser", + "email": "newuser@example.com" + }, + "createdAt": "2026-01-20T10:00:00.000Z" + } +} +``` + +**错误响应**: + +- `404`: 日志不存在 + +--- + +## 📊 版本更新记录 + +### v2.0.0 (2026-01-20) +- **架构升级**: 完整的四层架构实现(Gateway、Business、Core、Database) +- **新增功能**: 位置广播系统、通知系统、Zulip集成 +- **数据库管理**: 完整的管理员数据库管理接口 +- **操作日志**: 管理员操作审计日志系统 +- **Token刷新**: 支持刷新令牌机制 +- **WebSocket**: 完整的实时通信支持 + +### v1.1.2 (2025-12-25) +- **验证码冷却优化**: 注册、密码重置、验证码登录成功后自动清除验证码冷却时间 +- **用户体验提升**: 成功操作后可立即发送新的验证码 + +### v1.1.1 (2025-12-25) +- **邮箱冲突检测优化**: 发送邮箱验证码前检查邮箱是否已被注册 +- **错误处理改进**: 返回409 Conflict状态码和明确错误信息 + +### v1.1.0 (2025-12-25) +- **新增验证码登录功能**: 支持邮箱验证码登录 +- **HTTP状态码修复**: 所有接口返回正确的业务状态码 +- **完善错误处理**: 统一错误响应格式和错误代码 + +--- + +## 🔗 相关资源 + +- [OpenAPI 规范文件](./openapi.yaml) +- [Postman 测试集合](./postman-collection.json) +- [项目架构文档](../ARCHITECTURE.md) +- [开发规范指南](../development/) +- [WebSocket 文档](./websocket-api.md) + +--- + +## 📝 注意事项 + +1. **认证机制**: 大部分接口需要 JWT Token 认证 +2. **频率限制**: 已实现 API 频率限制,请注意请求频率 +3. **测试模式**: 开发环境下邮件服务返回206状态码,验证码在响应中返回 +4. **WebSocket**: 实时功能(聊天、位置广播)推荐使用 WebSocket 接口 +5. **管理员权限**: 管理员接口需要 role=9 的账户权限 +6. **数据格式**: 所有日期时间使用 ISO 8601 格式 +7. **错误处理**: 根据 HTTP 状态码和 error_code 进行错误处理 + +--- + +**文档维护**: 本文档根据后端代码自动生成,如有疑问请参考源代码或联系开发团队。 diff --git a/export_presets.cfg b/export_presets.cfg index e8e91f2..f084949 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -3,7 +3,6 @@ name="Web" platform="Web" runnable=true -advanced_options=false dedicated_server=false custom_features="" export_filter="all_resources" @@ -11,6 +10,11 @@ include_filter="" exclude_filter="" export_path="web_assets/index.html" patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" encryption_include_filters="" encryption_exclude_filters="" seed=0 @@ -40,6 +44,6 @@ progressive_web_app/orientation=0 progressive_web_app/icon_144x144="uid://bwy5r7soxi76a" progressive_web_app/icon_180x180="uid://drpllpsjdiaex" progressive_web_app/icon_512x512="uid://dt817lem3dwee" -progressive_web_app/background_color=Color(0.19215687, 0.42352942, 1, 1) +progressive_web_app/background_color=Color(0.07450981, 0.28627452, 1, 1) threads/emscripten_pool_size=8 threads/godot_pool_size=4 diff --git a/scenes/Maps/BaseLevel.gd b/scenes/Maps/BaseLevel.gd index 4520fc9..bc70791 100644 --- a/scenes/Maps/BaseLevel.gd +++ b/scenes/Maps/BaseLevel.gd @@ -61,8 +61,12 @@ var _player_spawned: bool = false # Track if local player has been spawned var _local_player: Node = null # Reference to the local player node var _position_update_timer: float = 0.0 # Throttle timer for position updates var _current_session_id: String = "" # Cache session ID to avoid race condition on exit +var _is_joining_session: bool = false const POSITION_UPDATE_INTERVAL: float = 0.125 # Send at most 8 times per second const PRIVATE_SCENES = ["room"] # List of scenes that should be private instances +var _join_token_poll_interval: float = 0.5 +var _join_token_wait_timeout: float = 15.0 +var _join_player_wait_timeout_frames: int = 300 func _on_session_joined(data: Dictionary): # 对账远程玩家列表,使用 position.mapId 过滤 @@ -199,26 +203,51 @@ func _update_remote_player_position(user: Dictionary): player.update_position(Vector2(pos.x, pos.y)) func _join_session_with_player(session_id: String): - # 检查是否有Token,如果没有则等待 - if LocationManager._auth_token == "": - # 轮询等待Token就绪 (简单重试机制) - await get_tree().create_timer(0.5).timeout - _join_session_with_player(session_id) + if _is_joining_session: return - # 等待玩家生成完毕 - if not _player_spawned or not _local_player: + _is_joining_session = true + + # 检查是否有 Token,没有则轮询等待(带超时) + var token_wait_elapsed: float = 0.0 + while LocationManager._auth_token == "": + if not is_inside_tree(): + _is_joining_session = false + return + if token_wait_elapsed >= _join_token_wait_timeout: + push_warning("BaseLevel: 等待认证 Token 超时,取消加入会话 %s" % session_id) + _is_joining_session = false + return + await get_tree().create_timer(_join_token_poll_interval).timeout + token_wait_elapsed += _join_token_poll_interval + + # 等待玩家生成完毕(带帧数上限) + var wait_frames: int = 0 + while not _player_spawned or not _local_player: + if not is_inside_tree(): + _is_joining_session = false + return + if wait_frames >= _join_player_wait_timeout_frames: + push_warning("BaseLevel: 等待本地玩家就绪超时,取消加入会话 %s" % session_id) + _is_joining_session = false + return await get_tree().process_frame - _join_session_with_player(session_id) + wait_frames += 1 + + if not is_instance_valid(_local_player): + push_warning("BaseLevel: 本地玩家无效,取消加入会话 %s" % session_id) + _is_joining_session = false return - var pos = _local_player.global_position if is_instance_valid(_local_player) else Vector2.ZERO - + var pos = _local_player.global_position LocationManager.join_session(session_id, pos) # 进入会话后立即同步一次位置 await get_tree().create_timer(0.1).timeout - LocationManager.send_position_update(session_id, pos) + if is_inside_tree(): + LocationManager.send_position_update(session_id, pos) + + _is_joining_session = false func _process(delta): # 发送位置更新 (节流机制) diff --git a/scenes/ui/AuthScene.gd b/scenes/ui/AuthScene.gd index 849f6f2..420d8a7 100644 --- a/scenes/ui/AuthScene.gd +++ b/scenes/ui/AuthScene.gd @@ -91,6 +91,7 @@ var toast_manager: ToastManager # 验证码冷却计时器 var cooldown_timer: Timer = null +var cooldown_email: String = "" # 当前登录模式(从管理器同步) var current_login_mode: AuthManager.LoginMode = AuthManager.LoginMode.PASSWORD @@ -291,9 +292,6 @@ func _on_register_pressed(): func _on_send_code_pressed(): var email = register_email.text.strip_edges() auth_manager.send_email_verification_code(email) - - # 开始冷却计时器 - _start_cooldown_timer(email) # 获取登录验证码按钮 func _on_get_login_code_pressed(): @@ -374,8 +372,10 @@ func _on_controller_register_failed(_message: String): # 验证码发送成功处理 func _on_controller_verification_code_sent(_message: String): - # 验证码发送成功,冷却计时器已经在按钮点击时启动 - pass + var email = auth_manager.current_email + if email == "": + email = register_email.text.strip_edges() + _start_cooldown_timer(email) # 验证码发送失败处理 func _on_controller_verification_code_failed(_message: String): @@ -429,12 +429,20 @@ func _on_controller_show_toast_message(message: String, is_success: bool): # ============ 验证码冷却管理 ============ # 开始冷却计时器 -func _start_cooldown_timer(_email: String): +func _start_cooldown_timer(email: String): if cooldown_timer != null: cooldown_timer.queue_free() + cooldown_timer = null + + cooldown_email = email + if cooldown_email == "": + cooldown_email = register_email.text.strip_edges() send_code_btn.disabled = true - send_code_btn.text = "重新发送(60)" + var remaining_time = auth_manager.get_remaining_cooldown_time(cooldown_email) + if remaining_time <= 0: + remaining_time = 60 + send_code_btn.text = "重新发送(%d)" % remaining_time cooldown_timer = Timer.new() add_child(cooldown_timer) @@ -444,7 +452,8 @@ func _start_cooldown_timer(_email: String): # 冷却计时器超时处理 func _on_cooldown_timer_timeout(): - var remaining_time = auth_manager.get_remaining_cooldown_time(register_email.text.strip_edges()) + var email = cooldown_email if cooldown_email != "" else register_email.text.strip_edges() + var remaining_time = auth_manager.get_remaining_cooldown_time(email) if remaining_time > 0: send_code_btn.text = "重新发送(%d)" % remaining_time @@ -462,6 +471,7 @@ func _reset_verification_button(): cooldown_timer.queue_free() cooldown_timer = null + cooldown_email = "" send_code_btn.disabled = false send_code_btn.text = "发送验证码" diff --git a/tests/auth/auth_ui_test.tscn b/tests/auth/auth_ui_test.tscn index 0285847..ec9ea55 100644 --- a/tests/auth/auth_ui_test.tscn +++ b/tests/auth/auth_ui_test.tscn @@ -1,7 +1,7 @@ [gd_scene load_steps=4 format=3 uid="uid://bvn8y7x2qkqxe"] [ext_resource type="Script" uid="uid://ddb8v5c6aeqe7" path="res://tests/auth/auth_ui_test.gd" id="1_test_script"] -[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/LoginWindow.tscn" id="2_auth_scene"] +[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_auth_scene"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_feedback_info"] bg_color = Color(0.2, 0.5, 0.8, 0.9) diff --git a/tests/unit/test_auth_baselevel_regressions.gd b/tests/unit/test_auth_baselevel_regressions.gd new file mode 100644 index 0000000..cb1f769 --- /dev/null +++ b/tests/unit/test_auth_baselevel_regressions.gd @@ -0,0 +1,185 @@ +extends SceneTree + +# Auth/BaseLevel 回归测试(无需 GUT) +# +# 运行方式: +# "D:\\technology\\biancheng\\Godot\\Godot_v4.5.1-stable_win64.exe" --headless --path . --script tests/unit/test_auth_baselevel_regressions.gd + +var _failures: Array[String] = [] + +class TestBaseLevel: + extends BaseLevel + + func _ready() -> void: + # 测试场景中跳过原始 _ready,避免网络/场景副作用 + pass + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + await _test_forgot_password_response_handler() + await _test_auth_request_tracking_cleanup() + await _test_verification_code_timestamp_and_remaining_time() + await _test_send_code_cooldown_starts_on_success_only() + await _test_join_session_wait_has_timeout_guards() + + if _failures.is_empty(): + print("PASS: test_auth_baselevel_regressions") + quit(0) + return + + for failure in _failures: + push_error(failure) + print("FAIL: test_auth_baselevel_regressions (%d)" % _failures.size()) + quit(1) + +func _test_forgot_password_response_handler() -> void: + var manager := AuthManager.new() + var toast_message: String = "" + + manager.show_toast_message.connect(func(message: String, _is_success: bool): toast_message = message) + + manager._on_forgot_password_response(true, {}, {}) + _assert( + "重置验证码" in toast_message, + "忘记密码成功响应应使用 forgot_password 处理器文案" + ) + + toast_message = "" + manager._on_forgot_password_response(false, {"error_code": "USER_NOT_FOUND", "message": "用户不存在"}, {}) + _assert( + ("用户不存在" in toast_message) and ("请检查邮箱或手机号" in toast_message), + "忘记密码失败响应应使用 forgot_password 错误分支" + ) + + manager.cleanup() + +func _test_auth_request_tracking_cleanup() -> void: + var manager := AuthManager.new() + manager.active_request_ids = ["req_a", "req_b"] + + manager._on_network_request_completed("req_a", true, {}) + _assert( + manager.active_request_ids.size() == 1 and manager.active_request_ids[0] == "req_b", + "请求完成后应从 active_request_ids 中移除对应 request_id" + ) + + manager._on_network_request_failed("req_b", "NETWORK_ERROR", "x") + _assert( + manager.active_request_ids.is_empty(), + "请求失败后也应从 active_request_ids 中移除对应 request_id" + ) + + manager.cleanup() + +func _test_verification_code_timestamp_and_remaining_time() -> void: + var manager := AuthManager.new() + var email := "cooldown_regression@example.com" + + manager._record_verification_code_sent(email) + + var sent_timestamp: int = int(manager.verification_codes_sent[email].get("time", 0)) + _assert( + sent_timestamp > 86400, + "验证码发送时间应记录 Unix 时间戳,避免跨天计算异常" + ) + + manager.verification_codes_sent[email].time = int(Time.get_unix_time_from_system()) - 1000 + _assert( + manager.get_remaining_cooldown_time(email) == 0, + "冷却已过期时,剩余时间应为 0" + ) + + manager.cleanup() + +func _test_send_code_cooldown_starts_on_success_only() -> void: + var packed_scene: PackedScene = load("res://scenes/ui/AuthScene.tscn") + _assert(packed_scene != null, "应能加载 AuthScene.tscn") + if packed_scene == null: + return + + var auth_scene: Control = packed_scene.instantiate() + root.add_child(auth_scene) + await process_frame + + auth_scene.register_email.text = "invalid_email" + auth_scene._on_send_code_pressed() + await process_frame + + _assert( + auth_scene.cooldown_timer == null, + "邮箱不合法时不应启动验证码冷却计时器" + ) + _assert( + not auth_scene.send_code_btn.disabled, + "邮箱不合法时发送按钮不应被锁定" + ) + + # 模拟发送成功回调触发冷却(并补齐 manager 内部冷却记录) + auth_scene.auth_manager._record_verification_code_sent("regression@example.com") + auth_scene._on_controller_verification_code_sent("ok") + + _assert( + auth_scene.cooldown_timer != null, + "验证码发送成功后应启动冷却计时器" + ) + _assert( + auth_scene.send_code_btn.disabled, + "验证码发送成功后应禁用发送按钮" + ) + + auth_scene.queue_free() + await process_frame + +func _test_join_session_wait_has_timeout_guards() -> void: + var level := TestBaseLevel.new() + root.add_child(level) + await process_frame + + var original_token: String = LocationManager._auth_token + var original_poll_interval: float = level._join_token_poll_interval + var original_token_timeout: float = level._join_token_wait_timeout + var original_player_timeout_frames: int = level._join_player_wait_timeout_frames + + level._join_token_poll_interval = 0.01 + level._join_token_wait_timeout = 0.03 + level._join_player_wait_timeout_frames = 2 + + # 场景 1: Token 一直未就绪,函数应在超时后退出,而不是无限递归等待 + LocationManager._auth_token = "" + level._join_session_with_player("test_session") + await create_timer(0.15).timeout + + _assert( + not level._is_joining_session, + "Token 长时间未就绪时应超时退出 join 流程" + ) + + # 场景 2: Token 已就绪但玩家一直未生成,也应在上限后退出 + LocationManager._auth_token = "dummy_token" + level._player_spawned = false + level._local_player = null + level._join_session_with_player("test_session") + await process_frame + await process_frame + await process_frame + await process_frame + + _assert( + not level._is_joining_session, + "玩家长时间未就绪时应在帧上限后退出 join 流程" + ) + + # 恢复现场 + LocationManager._auth_token = original_token + level._join_token_poll_interval = original_poll_interval + level._join_token_wait_timeout = original_token_timeout + level._join_player_wait_timeout_frames = original_player_timeout_frames + + level.queue_free() + await process_frame + +func _assert(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/unit/test_websocket_close_code.gd b/tests/unit/test_websocket_close_code.gd new file mode 100644 index 0000000..ef38c3c --- /dev/null +++ b/tests/unit/test_websocket_close_code.gd @@ -0,0 +1,142 @@ +extends SceneTree + +# WebSocket 关闭码与重连行为测试(无需 GUT) +# +# 运行方式: +# "D:\\technology\\biancheng\\Godot\\Godot_v4.5.1-stable_win64.exe" --headless --path . --script tests/unit/test_websocket_close_code.gd + +var _failures: Array[String] = [] + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var manager := ChatWebSocketManager.new() + root.add_child(manager) + await process_frame + + _test_close_code_mapping(manager) + _test_idle_closed_state_does_not_trigger_reconnect(manager) + _test_reconnect_on_unclean_close(manager) + _test_no_reconnect_on_clean_close(manager) + _test_reconnect_attempt_not_reset_on_reconnect_connect(manager) + _test_reconnection_success_emits_and_resets_attempt(manager) + + manager.queue_free() + await process_frame + + if _failures.is_empty(): + print("PASS: test_websocket_close_code") + quit(0) + return + + for failure in _failures: + push_error(failure) + print("FAIL: test_websocket_close_code (%d)" % _failures.size()) + quit(1) + +func _test_close_code_mapping(manager: ChatWebSocketManager) -> void: + _assert( + not manager._is_clean_close_code(-1), + "close_code=-1 应判定为非干净关闭(异常断开)" + ) + _assert( + manager._is_clean_close_code(1000), + "close_code=1000 应判定为干净关闭" + ) + _assert( + manager._is_clean_close_code(0), + "close_code=0 不应判定为异常断开" + ) + +func _test_idle_closed_state_does_not_trigger_reconnect(manager: ChatWebSocketManager) -> void: + manager.enable_auto_reconnect(true, 5, 3.0) + manager._set_connection_state(ChatWebSocketManager.ConnectionState.DISCONNECTED) + manager._reconnect_attempt = 0 + manager._closed_state_handled = false + + manager._check_websocket_state() + + _assert( + manager.get_connection_state() == ChatWebSocketManager.ConnectionState.DISCONNECTED, + "空闲 DISCONNECTED + STATE_CLOSED 不应触发自动重连" + ) + _assert( + manager._reconnect_attempt == 0, + "空闲关闭态不应消耗重连次数" + ) + +func _test_reconnect_on_unclean_close(manager: ChatWebSocketManager) -> void: + var lost_count: int = 0 + manager.connection_lost.connect(func(): lost_count += 1) + + manager.enable_auto_reconnect(true, 5, 3.0) + manager._reconnect_attempt = 0 + manager._set_connection_state(ChatWebSocketManager.ConnectionState.CONNECTED) + manager._on_websocket_closed(false) + + _assert( + manager.get_connection_state() == ChatWebSocketManager.ConnectionState.RECONNECTING, + "异常断开时应进入 RECONNECTING 状态" + ) + _assert( + manager._reconnect_attempt == 1, + "异常断开时应增加一次重连尝试计数" + ) + _assert( + lost_count == 1, + "异常断开时应发射一次 connection_lost 信号" + ) + + # 清理重连定时器,避免影响后续用例 + if is_instance_valid(manager._reconnect_timer): + manager._reconnect_timer.stop() + +func _test_no_reconnect_on_clean_close(manager: ChatWebSocketManager) -> void: + var reconnect_attempt_before: int = manager._reconnect_attempt + manager._set_connection_state(ChatWebSocketManager.ConnectionState.CONNECTED) + manager._on_websocket_closed(true) + + _assert( + manager.get_connection_state() == ChatWebSocketManager.ConnectionState.DISCONNECTED, + "干净关闭时应回到 DISCONNECTED 状态" + ) + _assert( + manager._reconnect_attempt == reconnect_attempt_before, + "干净关闭时不应增加重连尝试计数" + ) + +func _test_reconnect_attempt_not_reset_on_reconnect_connect(manager: ChatWebSocketManager) -> void: + manager._reconnect_attempt = 3 + manager._set_connection_state(ChatWebSocketManager.ConnectionState.RECONNECTING) + manager.connect_to_game_server(true) + + _assert( + manager._reconnect_attempt == 3, + "重连触发 connect_to_game_server(true) 时不应重置重连尝试计数" + ) + +func _test_reconnection_success_emits_and_resets_attempt(manager: ChatWebSocketManager) -> void: + var success_count: int = 0 + manager.reconnection_succeeded.connect(func(): success_count += 1) + + manager._reconnect_attempt = 2 + manager._set_connection_state(ChatWebSocketManager.ConnectionState.RECONNECTING) + manager._on_websocket_connected() + + _assert( + success_count == 1, + "重连成功时应发射 reconnection_succeeded 信号" + ) + _assert( + manager._reconnect_attempt == 0, + "重连成功时应重置重连尝试计数" + ) + _assert( + manager.get_connection_state() == ChatWebSocketManager.ConnectionState.CONNECTED, + "重连成功后应进入 CONNECTED 状态" + ) + +func _assert(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/serve_web_assets.bat b/tools/serve_web_assets.bat new file mode 100644 index 0000000..bc610f9 --- /dev/null +++ b/tools/serve_web_assets.bat @@ -0,0 +1,40 @@ +@echo off +echo ======================================== +echo 鲸鱼镇 Web服务器 (web_assets) +echo ======================================== +echo. + +set "PORT=8000" + +REM 检查Python +python --version >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [错误] 未找到Python! + echo 请安装Python: https://python.org/downloads + pause + exit /b 1 +) + +REM 检查文件 +if not exist "web_assets\index.html" ( + echo [错误] 未找到 web_assets\index.html + pause + exit /b 1 +) + +echo [启动] 启动服务器... +echo 端口: %PORT% +echo 目录: web_assets +echo. +echo 访问地址: http://localhost:%PORT% +echo. +echo 按 Ctrl+C 停止服务器 +echo. + +REM 启动服务器 +cd web_assets +start http://localhost:%PORT% +python -m http.server %PORT% + +cd .. +pause diff --git a/web_assets/index.html b/web_assets/index.html index 46e5100..e71b65e 100644 --- a/web_assets/index.html +++ b/web_assets/index.html @@ -112,7 +112,7 @@ body {