Files
whale-town-front-v2/_Core/managers/AuthManager.gd

516 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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