extends Node # ============================================================================ # AuthManager.gd - V2 用户认证管理器 # ============================================================================ # 负责 V2 的正式多用户登录、注册、token 缓存和登出。 # 聊天系统通过这里获取当前用户 access_token,不再在正常游戏流程中共用默认账号。 # ============================================================================ signal auth_state_changed(is_authenticated: bool, user: Dictionary) signal login_succeeded(user: Dictionary) signal login_failed(message: String) signal register_succeeded(user: Dictionary) signal register_failed(message: String) signal email_verification_sent(email: String) signal email_verification_failed(message: String) signal profile_update_succeeded(profile: Dictionary) signal profile_update_failed(message: String) signal logout_completed() const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg" const REQUEST_TIMEOUT: float = 12.0 var _access_token: String = "" var _refresh_token: String = "" var _current_user: Dictionary = {} var _current_profile: Dictionary = {} var _active_requests: Array[HTTPRequest] = [] var _session_generation: int = 0 var _account_generation: int = 0 var _refresh_in_flight: bool = false var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH func _ready() -> void: _load_cached_session() func _exit_tree() -> void: for request in _active_requests: if is_instance_valid(request): request.cancel_request() request.queue_free() _active_requests.clear() func is_authenticated() -> bool: return not _access_token.strip_edges().is_empty() func get_access_token() -> String: return _access_token func get_refresh_token() -> String: return _refresh_token func get_current_user() -> Dictionary: return _current_user.duplicate(true) func get_current_profile() -> Dictionary: return _current_profile.duplicate(true) func get_current_username() -> String: return str(_current_user.get("username", "")) func get_session_generation() -> int: return _session_generation func get_account_generation() -> int: return _account_generation func get_auth_config_path() -> String: return _auth_config_path func login(identifier: String, password: String) -> void: var normalized_identifier := identifier.strip_edges() if normalized_identifier.is_empty(): login_failed.emit("请输入用户名、邮箱或手机号") return if password.is_empty(): login_failed.emit("请输入密码") return _advance_account_generation() _request_json("/auth/login", { "identifier": normalized_identifier, "password": password }, _on_login_response, HTTPClient.METHOD_POST, false, true) func send_email_verification(email: String) -> void: var normalized_email := email.strip_edges() if normalized_email.is_empty(): email_verification_failed.emit("请输入邮箱") return if not _is_valid_email(normalized_email): email_verification_failed.emit("邮箱格式不正确") return _request_json("/auth/send-email-verification", { "email": normalized_email }, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void: if success: email_verification_sent.emit(normalized_email) else: email_verification_failed.emit(str(error_info.get("message", "验证码发送失败"))) ) func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "") -> void: var normalized_username := username.strip_edges() var normalized_nickname := nickname.strip_edges() var normalized_email := email.strip_edges() var normalized_code := email_verification_code.strip_edges() if normalized_username.is_empty(): register_failed.emit("请输入用户名") return if not _is_valid_username(normalized_username): register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50") return if password.length() < 8: register_failed.emit("密码至少 8 位,并需要包含字母和数字") return if not _password_has_letter_and_number(password): register_failed.emit("密码需要同时包含字母和数字") return if normalized_nickname.is_empty(): normalized_nickname = normalized_username if normalized_email.is_empty(): register_failed.emit("请输入邮箱并先获取验证码") return if not _is_valid_email(normalized_email): register_failed.emit("邮箱格式不正确") return if not _is_valid_email_code(normalized_code): register_failed.emit("请输入邮件中的 6 位验证码") return var payload := { "username": normalized_username, "password": password, "nickname": normalized_nickname, "email": normalized_email, "email_verification_code": normalized_code } var normalized_skin_id := skin_id.strip_edges() if not normalized_skin_id.is_empty(): payload["skin_id"] = normalized_skin_id _advance_account_generation() _request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true) func fetch_profile() -> void: if not is_authenticated(): return var playerStateManager := get_node_or_null("/root/PlayerStateManager") if playerStateManager != null and playerStateManager.has_method("refresh_snapshot"): playerStateManager.call("refresh_snapshot") func update_profile(profile_data: Dictionary) -> void: if not is_authenticated(): profile_update_failed.emit("请先登录") return var playerStateManager := get_node_or_null("/root/PlayerStateManager") if playerStateManager == null: profile_update_failed.emit("玩家状态管理器未加载") return var assetPayload := profile_data.duplicate(true) assetPayload.erase("skin_id") assetPayload.erase("settings") if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"): playerStateManager.call( "update_appearance", str(profile_data.get("skin_id", "")), Callable(self, "_on_profile_response") ) if profile_data.has("settings") and playerStateManager.has_method("update_settings"): var settingsVariant: Variant = profile_data.get("settings", {}) if settingsVariant is Dictionary: playerStateManager.call( "update_settings", settingsVariant as Dictionary, Callable(self, "_on_profile_response") ) if not assetPayload.is_empty(): var api := get_node_or_null("/root/ApiClient") if api != null and api.has_method("patch_json"): var requestGeneration := _account_generation api.call( "patch_json", "/player/profile-assets", assetPayload, Callable(self, "_on_profile_assets_response").bind(requestGeneration), true ) else: profile_update_failed.emit("ApiClient 不可用") return if not profile_data.has("skin_id") and not profile_data.has("settings"): profile_update_failed.emit("没有可保存的玩家资料") func refresh_session() -> void: if _refresh_token.strip_edges().is_empty() or _refresh_in_flight: return _refresh_in_flight = true _request_json("/auth/refresh-token", { "refresh_token": _refresh_token }, _on_refresh_response, HTTPClient.METHOD_POST, false, true) func logout() -> void: _clear_session_memory() if FileAccess.file_exists(_auth_config_path): DirAccess.remove_absolute(_auth_config_path) auth_state_changed.emit(false, {}) _emit_event(EventNames.AUTH_LOGOUT, {}) logout_completed.emit() call_deferred("_return_to_auth_scene") func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void: var request := HTTPRequest.new() request.timeout = REQUEST_TIMEOUT request.set_meta("account_bound", accountBound) add_child(request) _active_requests.append(request) var requestGeneration := _account_generation request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _active_requests.erase(request) request.queue_free() if accountBound and requestGeneration != _account_generation: return _handle_json_response(result, response_code, body, callback) ) var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint] var headers := PackedStringArray(["Content-Type: application/json"]) if authenticated and not _access_token.strip_edges().is_empty(): headers.append("Authorization: Bearer %s" % _access_token) var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload) var err := request.request(url, headers, method, body) if err != OK: _active_requests.erase(request) request.queue_free() if not accountBound or requestGeneration == _account_generation: callback.call(false, {}, {"message": "网络请求发送失败: %s" % error_string(err)}) func _handle_json_response(result: int, response_code: int, body: PackedByteArray, callback: Callable) -> void: var body_text := body.get_string_from_utf8() if result != HTTPRequest.RESULT_SUCCESS: callback.call(false, {}, {"message": "网络请求失败: %s" % _http_result_to_string(result)}) return var json := JSON.new() if json.parse(body_text) != OK: callback.call(false, {}, {"message": "服务器响应解析失败"}) return var payload_variant: Variant = json.data if not (payload_variant is Dictionary): callback.call(false, {}, {"message": "服务器响应格式错误"}) return var response: Dictionary = payload_variant var success := response_code >= 200 and response_code < 300 and bool(response.get("success", true)) if success: callback.call(true, response, {}) return callback.call(false, response, { "message": str(response.get("message", "请求失败")), "response_code": response_code, "error_code": str(response.get("error_code", "")) }) func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: if not success: login_failed.emit(str(error_info.get("message", "登录失败"))) return _apply_auth_payload(data) if not is_authenticated(): login_failed.emit("登录响应缺少 access_token") return _emit_event(EventNames.AUTH_LOGIN_SUCCESS, { "user": get_current_user() }) login_succeeded.emit(get_current_user()) auth_state_changed.emit(true, get_current_user()) _refresh_player_snapshot() func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: if not success: register_failed.emit(str(error_info.get("message", "注册失败"))) return _apply_auth_payload(data) if not is_authenticated(): register_failed.emit("注册响应缺少 access_token") return _emit_event(EventNames.AUTH_REGISTER_SUCCESS, { "user": get_current_user() }) register_succeeded.emit(get_current_user()) auth_state_changed.emit(true, get_current_user()) _refresh_player_snapshot() func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: _refresh_in_flight = false if not success: logout() return _apply_auth_payload(data) auth_state_changed.emit(true, get_current_user()) _refresh_player_snapshot() func _apply_auth_payload(payload: Dictionary) -> void: var data_variant: Variant = payload.get("data", {}) if not (data_variant is Dictionary): return var auth_data: Dictionary = data_variant _access_token = str(auth_data.get("access_token", "")).strip_edges() _refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges() var user_variant: Variant = auth_data.get("user", {}) if user_variant is Dictionary: _current_user = user_variant var profile_variant: Variant = auth_data.get("profile", {}) if profile_variant is Dictionary: _current_profile = profile_variant _apply_profile_to_appearance_manager() _session_generation += 1 _save_cached_session() func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: if not success: profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败"))) auth_state_changed.emit(is_authenticated(), get_current_user()) return var data_variant: Variant = data.get("data", {}) if not (data_variant is Dictionary): profile_update_failed.emit("玩家状态响应格式错误") auth_state_changed.emit(is_authenticated(), get_current_user()) return var snapshot := data_variant as Dictionary var playerStateManager := get_node_or_null("/root/PlayerStateManager") if playerStateManager != null and playerStateManager.has_method("apply_snapshot"): playerStateManager.call("apply_snapshot", snapshot) _apply_snapshot_profile_payload(snapshot) profile_update_succeeded.emit(get_current_profile()) auth_state_changed.emit(is_authenticated(), get_current_user()) func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void: if requestGeneration != _account_generation: return if not success: profile_update_failed.emit(str(_error_info.get("message", "玩家资源更新失败"))) auth_state_changed.emit(is_authenticated(), get_current_user()) return var dataVariant: Variant = data.get("data", {}) var playerStateManager := get_node_or_null("/root/PlayerStateManager") if dataVariant is Dictionary and playerStateManager != null and playerStateManager.has_method("apply_snapshot"): var snapshot := dataVariant as Dictionary playerStateManager.call("apply_snapshot", snapshot) _apply_snapshot_profile_payload(snapshot) profile_update_succeeded.emit(get_current_profile()) auth_state_changed.emit(is_authenticated(), get_current_user()) func _apply_profile_to_appearance_manager() -> void: if _current_profile.is_empty(): return var appearanceManager := get_node_or_null("/root/AppearanceManager") if appearanceManager != null and appearanceManager.has_method("apply_account_profile"): appearanceManager.call("apply_account_profile", _current_profile) var settingsVariant: Variant = _current_profile.get("settings", {}) var settingsManager := get_node_or_null("/root/SettingsManager") if settingsVariant is Dictionary and settingsManager != null and settingsManager.has_method("apply_account_settings"): settingsManager.call("apply_account_settings", settingsVariant as Dictionary) func _apply_snapshot_profile_payload(snapshot: Dictionary) -> void: var userVariant: Variant = snapshot.get("user", {}) if userVariant is Dictionary: _current_user = (userVariant as Dictionary).duplicate(true) var profileVariant: Variant = snapshot.get("profile", {}) if not (profileVariant is Dictionary): return var profile := (profileVariant as Dictionary).duplicate(true) var appearance := {} var appearanceVariant: Variant = snapshot.get("appearance", {}) if appearanceVariant is Dictionary: appearance = (appearanceVariant as Dictionary).duplicate(true) var selectedSkinId := str(appearance.get("selected_skin_id", profile.get("selected_skin_id", profile.get("skin_id", "")))).strip_edges() profile["skin_id"] = selectedSkinId profile["owned_skin_ids"] = appearance.get("owned_skin_ids", []) profile["owned_skins"] = appearance.get("owned_skins", []) _current_profile = profile _apply_profile_to_appearance_manager() _save_cached_session() func _refresh_player_snapshot() -> void: var playerStateManager := get_node_or_null("/root/PlayerStateManager") if playerStateManager != null and playerStateManager.has_method("refresh_snapshot"): playerStateManager.call_deferred("refresh_snapshot") func _save_cached_session() -> void: var config := ConfigFile.new() config.set_value("auth", "refresh_token", _refresh_token) config.set_value("auth", "access_token", _access_token) config.set_value("auth", "saved_at", Time.get_unix_time_from_system()) var err := config.save(_auth_config_path) if err != OK: push_warning("AuthManager: 保存本地登录状态失败: %s" % error_string(err)) func _load_cached_session(emit_cached_state: bool = true) -> void: if not FileAccess.file_exists(_auth_config_path): return var config := ConfigFile.new() if config.load(_auth_config_path) != OK: return _refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges() _access_token = str(config.get_value("auth", "access_token", "")).strip_edges() _current_user.clear() _current_profile.clear() if is_authenticated(): _session_generation += 1 _account_generation += 1 if emit_cached_state and is_authenticated(): call_deferred("_emit_cached_auth_state") func _emit_cached_auth_state() -> void: auth_state_changed.emit(true, get_current_user()) func _clear_session_memory() -> void: _access_token = "" _refresh_token = "" _current_user.clear() _current_profile.clear() _advance_account_generation() func _advance_account_generation() -> void: _session_generation += 1 _account_generation += 1 _refresh_in_flight = false for request in _active_requests.duplicate(): if is_instance_valid(request) and bool(request.get_meta("account_bound", false)): request.cancel_request() request.queue_free() _active_requests.erase(request) func _return_to_auth_scene() -> void: var tree := get_tree() if tree == null or tree.current_scene == null: return if tree.current_scene.scene_file_path == "res://scenes/ui/AuthScene.tscn": return var sceneManager := get_node_or_null("/root/SceneManager") if sceneManager != null and sceneManager.has_method("change_scene"): sceneManager.call("change_scene", "auth", false) func _emit_event(event_name: String, data: Variant = null) -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem == null: return eventSystem.call("emit_event", event_name, data) func _is_valid_username(username: String) -> bool: var regex := RegEx.new() regex.compile("^[A-Za-z0-9_]{1,50}$") return regex.search(username) != null func _is_valid_email(email: String) -> bool: var regex := RegEx.new() regex.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") return regex.search(email) != null func _is_valid_email_code(code: String) -> bool: var regex := RegEx.new() regex.compile("^\\d{6}$") return regex.search(code) != null func _password_has_letter_and_number(password: String) -> bool: var has_letter := false var has_number := false for i in range(password.length()): var code := password.unicode_at(i) if (code >= 65 and code <= 90) or (code >= 97 and code <= 122): has_letter = true if code >= 48 and code <= 57: has_number = true return has_letter and has_number func _http_result_to_string(result: int) -> String: match result: HTTPRequest.RESULT_SUCCESS: return "SUCCESS" HTTPRequest.RESULT_TIMEOUT: return "TIMEOUT" HTTPRequest.RESULT_CANT_CONNECT: return "CANT_CONNECT" HTTPRequest.RESULT_CANT_RESOLVE: return "CANT_RESOLVE" HTTPRequest.RESULT_CONNECTION_ERROR: return "CONNECTION_ERROR" HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR: return "TLS_HANDSHAKE_ERROR" _: return "UNKNOWN_%d" % result