516 lines
18 KiB
GDScript
516 lines
18 KiB
GDScript
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
|