refactor: harden client runtime and exports

This commit is contained in:
ANG-Server
2026-07-23 00:59:00 +08:00
parent 5d2339a29b
commit fc872fe8af
27 changed files with 902 additions and 856 deletions

View File

@@ -2,67 +2,80 @@ extends Node
signal decor_save_succeeded(item: Dictionary)
signal decor_save_failed(item: Dictionary, message: String)
signal decor_revision_conflict(current_revision: int, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
const MAX_SAVE_RETRIES: int = 3
const RETRY_BASE_DELAY: float = 0.75
var _request: HTTPRequest
var _retryTimer: Timer
var _retry_timer: Timer
var _queue: Array[Dictionary] = []
var _inFlight: Dictionary = {}
var _requestGeneration: int = -1
var _accountGeneration: int = -1
var _in_flight: Dictionary = {}
var _account_generation: int = -1
var _request_serial: int = 0
var _confirmed_revision: int = 0
func _ready() -> void:
_request = HTTPRequest.new()
_request.name = "roomDecorSaveRequest"
_request.timeout = REQUEST_TIMEOUT
_request.request_completed.connect(_on_request_completed)
add_child(_request)
_retry_timer = Timer.new()
_retry_timer.name = "roomDecorSaveRetryTimer"
_retry_timer.one_shot = true
_retry_timer.timeout.connect(_process_next_save)
add_child(_retry_timer)
_retryTimer = Timer.new()
_retryTimer.name = "roomDecorSaveRetryTimer"
_retryTimer.one_shot = true
_retryTimer.timeout.connect(_process_next_save)
add_child(_retryTimer)
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
_account_generation = _current_account_generation()
auth_manager.auth_state_changed.connect(_on_auth_state_changed)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
_accountGeneration = _current_account_generation()
authManager.auth_state_changed.connect(_on_auth_state_changed)
func set_layout_revision(revision: int, clear_pending: bool = true) -> void:
if clear_pending:
clear_pending_saves()
_confirmed_revision = maxi(0, revision)
func get_layout_revision() -> int:
return _confirmed_revision
func clear_pending_saves() -> void:
_request_serial += 1
if is_instance_valid(_retry_timer):
_retry_timer.stop()
_queue.clear()
_in_flight.clear()
func enqueue_save(item: Dictionary) -> bool:
if not _is_authenticated():
decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放")
return false
var decorId := str(item.get("decor_id", "")).strip_edges()
if decorId.is_empty():
var decor_id := str(item.get("decor_id", "")).strip_edges()
if decor_id.is_empty():
decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id")
return false
# Only the newest queued placement for a decor matters.
for index in range(_queue.size() - 1, -1, -1):
var queuedItem: Dictionary = _queue[index].get("item", {})
if str(queuedItem.get("decor_id", "")) == decorId:
var queued_item: Dictionary = _queue[index].get("item", {})
if str(queued_item.get("decor_id", "")) == decor_id:
_queue.remove_at(index)
_queue.append({"item": item.duplicate(true), "retry_count": 0})
_queue.append({
"item": item.duplicate(true),
"retry_count": 0,
"mutation_id": _create_mutation_id(),
})
_process_next_save()
return true
func has_pending_saves() -> bool:
return not _inFlight.is_empty() or not _queue.is_empty() or (_retryTimer != null and not _retryTimer.is_stopped())
return not _in_flight.is_empty() or not _queue.is_empty() or (_retry_timer != null and not _retry_timer.is_stopped())
func _process_next_save() -> void:
if not _inFlight.is_empty() or _queue.is_empty() or not _is_authenticated():
if not _in_flight.is_empty() or _queue.is_empty() or not _is_authenticated():
return
if _retryTimer != null and not _retryTimer.is_stopped():
if _retry_timer != null and not _retry_timer.is_stopped():
return
_inFlight = _queue.pop_front()
_requestGeneration = _current_account_generation()
var item: Dictionary = _inFlight.get("item", {})
_in_flight = _queue.pop_front()
if not _in_flight.has("layout_revision"):
_in_flight["layout_revision"] = _confirmed_revision
_in_flight["mutation_revision"] = _confirmed_revision + 1
var item: Dictionary = _in_flight.get("item", {})
var payload := {
"decor_id": str(item.get("decor_id", "")),
"placed": bool(item.get("placed", false)),
@@ -71,114 +84,106 @@ func _process_next_save() -> void:
"scale": float(item.get("scale", item.get("default_scale", 1.0))),
"rotation_degrees": int(item.get("rotation_degrees", item.get("default_rotation_degrees", 0))),
"z_index": int(item.get("z_index", item.get("default_z_index", 0))),
"layout_revision": int(_in_flight.get("layout_revision", _confirmed_revision)),
"mutation_revision": int(_in_flight.get("mutation_revision", _confirmed_revision + 1)),
"mutation_id": str(_in_flight.get("mutation_id", "")),
}
var decorId := str(item.get("decor_id", "")).uri_encode()
var err := _request.request(
"%s/rooms/me/decor-placements/%s" % [NetworkConfig.get_api_base_url(), decorId],
_auth_headers(),
HTTPClient.METHOD_PUT,
JSON.stringify(payload)
var api_client := get_node_or_null("/root/ApiClient")
if api_client == null or not api_client.has_method("put_json"):
_retry_or_fail("家具位置保存服务不可用")
return
_request_serial += 1
var serial := _request_serial
var generation := _current_account_generation()
var decor_id := str(item.get("decor_id", "")).uri_encode()
api_client.call(
"put_json",
"/rooms/me/decor-placements/%s" % decor_id,
payload,
Callable(self, "_on_save_response").bind(serial, generation),
true
)
if err != OK:
_retry_or_fail("家具位置保存请求发送失败")
func _on_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if _inFlight.is_empty():
func _on_save_response(
success: bool,
response: Dictionary,
error_info: Dictionary,
serial: int,
generation: int
) -> void:
if serial != _request_serial or _in_flight.is_empty():
return
if _requestGeneration != _current_account_generation():
_inFlight.clear()
_requestGeneration = -1
_process_next_save()
if generation != _current_account_generation():
clear_pending_saves()
return
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_retry_or_fail(_read_error_message(body, "家具位置保存失败"))
return
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
var response: Dictionary = json.data
if not bool(response.get("success", true)):
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
if not success:
var message := str(error_info.get("message", "家具位置保存失败"))
if int(error_info.get("response_code", 0)) == 409:
_handle_revision_conflict(error_info, message)
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
savedItem = (dataVariant as Dictionary).duplicate(true)
_retry_or_fail(message)
return
_inFlight.clear()
_requestGeneration = -1
decor_save_succeeded.emit(savedItem)
var saved_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
saved_item = (data_variant as Dictionary).duplicate(true)
var accepted_revision := int(saved_item.get("layout_revision", _in_flight.get("mutation_revision", _confirmed_revision)))
_confirmed_revision = maxi(_confirmed_revision, accepted_revision)
_in_flight.clear()
decor_save_succeeded.emit(saved_item)
_process_next_save()
func _retry_or_fail(message: String) -> void:
if _inFlight.is_empty():
if _in_flight.is_empty():
return
var failedEntry := _inFlight.duplicate(true)
var failedItem: Dictionary = failedEntry.get("item", {})
var decorId := str(failedItem.get("decor_id", ""))
_inFlight.clear()
_requestGeneration = -1
var failed_entry := _in_flight.duplicate(true)
var failed_item: Dictionary = failed_entry.get("item", {})
_in_flight.clear()
if _queue_has_decor(decorId):
_process_next_save()
return
var retryCount := int(failedEntry.get("retry_count", 0)) + 1
if retryCount <= MAX_SAVE_RETRIES and _is_authenticated():
failedEntry["retry_count"] = retryCount
_queue.push_front(failedEntry)
_retryTimer.start(RETRY_BASE_DELAY * pow(2.0, retryCount - 1))
var retry_count := int(failed_entry.get("retry_count", 0)) + 1
if retry_count <= MAX_SAVE_RETRIES and _is_authenticated():
failed_entry["retry_count"] = retry_count
_queue.push_front(failed_entry)
_retry_timer.start(RETRY_BASE_DELAY * pow(2.0, retry_count - 1))
return
decor_save_failed.emit(failedItem.duplicate(true), message)
_process_next_save()
decor_save_failed.emit(failed_item.duplicate(true), message)
_fail_queued_entries("前序装修写入失败,请重新保存")
func _queue_has_decor(decorId: String) -> bool:
for entryVariant in _queue:
if entryVariant is Dictionary:
var item: Dictionary = (entryVariant as Dictionary).get("item", {})
if str(item.get("decor_id", "")) == decorId:
return true
return false
func _handle_revision_conflict(error_info: Dictionary, message: String) -> void:
var current_revision := int(error_info.get("current_revision", _confirmed_revision))
var failed_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
_in_flight.clear()
decor_save_failed.emit(failed_item, message)
_fail_queued_entries(message)
_confirmed_revision = maxi(_confirmed_revision, current_revision)
decor_revision_conflict.emit(current_revision, message)
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
if is_instance_valid(_request):
_request.cancel_request()
if is_instance_valid(_retryTimer):
_retryTimer.stop()
func _fail_queued_entries(message: String) -> void:
for entry in _queue:
var item_variant: Variant = entry.get("item", {})
if item_variant is Dictionary:
decor_save_failed.emit((item_variant as Dictionary).duplicate(true), message)
_queue.clear()
_inFlight.clear()
_requestGeneration = -1
if is_instance_valid(_retry_timer):
_retry_timer.stop()
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _on_auth_state_changed(_is_authenticated_value: bool, _user: Dictionary) -> void:
var current_generation := _current_account_generation()
if current_generation == _account_generation:
return
_account_generation = current_generation
clear_pending_saves()
_confirmed_revision = 0
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
var auth_manager := get_node_or_null("/root/AuthManager")
return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else -1
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else -1
func _read_error_message(body: PackedByteArray, fallback: String) -> String:
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
return fallback
var response: Dictionary = json.data
var messageVariant: Variant = response.get("message", fallback)
if messageVariant is Array:
var parts: Array[String] = []
for part in messageVariant:
parts.append(str(part))
return "; ".join(parts) if not parts.is_empty() else fallback
var message := str(messageVariant).strip_edges()
return message if not message.is_empty() else fallback
func _create_mutation_id() -> String:
return Crypto.new().generate_random_bytes(16).hex_encode()