Synchronizacja kontaktów telefonu FiveM: Systemy kontaktów między urządzeniami
Synchronizuj kontakty między telefonami FiveM, tabletami i slotami multichar. Wzorce baz danych, skrypty bridge i najczystszy sposób na zachowanie spójności numerów.
Agency Scripts
Założyciel i główny deweloper w Agency Scripts
Wyzwanie związane z danymi telefonu w FiveM
Stworzenie systemu telefonu dla FiveM wymaga znacznie więcej niż wyrenderowanie ładnego UI na ekranie gracza. Prawdziwym wyzwaniem inżynieryjnym jest zarządzanie trwałymi danymi pomiędzy sesjami: kontakty, wątki wiadomości, rejestry połączeń, zdjęcia i ustawienia aplikacji muszą przetrwać restarty serwera, zmiany postaci oraz środowiska multi-character. Agency Phone został zaprojektowany od podstaw z myślą o integralności danych jako kluczowej zasadzie. Każda porcja danych przepływa przez potok server-authoritative, w którym klient żąda akcji, a serwer waliduje, przetwarza i zapisuje je przed wysłaniem potwierdzenia do klienta. Ten artykuł uchyla rąbka tajemnicy na temat tego, jak Agency Phone obsługuje synchronizację kontaktów, przechowywanie wiadomości, udostępnianie zdjęć, rejestrowanie połączeń oraz prywatność z założenia (privacy by design), aby programiści i właściciele serwerów zrozumieli architekturę stojącą za tym produktem.
Architektura przechowywania kontaktów
Kontakty w Agency Phone są zapisywane dla każdej postaci, a nie dla gracza. Ta różnica ma znaczenie, ponieważ gracz może mieć trzy postacie na tym samym serwerze, każdą z zupełnie innym kręgiem znajomych. Tabela kontaktów używa klucza złożonego z numeru telefonu właściciela i numeru telefonu kontaktu, z dodatkowymi polami na nazwę wyświetlaną, URL awatara oraz znacznik ulubionego. Gdy gracz otwiera aplikację kontaktów, klient wysyła jedno żądanie do serwera, który odpytuje bazę danych i zwraca pełną listę kontaktów w jednej paczce. Pozwala to uniknąć efektu kaskady, gdzie każdy kontakt wywołuje osobne zapytanie do bazy danych, co byłoby zabójcze dla serwera z graczami posiadającymi setki kontaktów.
-- How contacts are structured internally
-- Each contact belongs to a specific phone number (character)
local contactSchema = {
owner_number = 'string', -- the character's phone number
contact_number = 'string', -- the saved contact's number
display_name = 'string', -- custom name set by player
avatar = 'string|nil', -- optional avatar URL
is_favorite = 'boolean', -- pinned to top of list
created_at = 'timestamp', -- when contact was added
}
-- Server: fetch all contacts for a character
lib.callback.register('phone:contacts:getAll', function(source)
local phoneNumber = GetPlayerPhoneNumber(source)
if not phoneNumber then return {} end
local contacts = MySQL.query.await([[
SELECT contact_number, display_name, avatar, is_favorite
FROM phone_contacts
WHERE owner_number = ?
ORDER BY is_favorite DESC, display_name ASC
]], { phoneNumber })
return contacts or {}
end)
Synchronizacja kontaktów w czasie rzeczywistym
Gdy gracz dodaje, edytuje lub usuwa kontakt, zmiana musi być natychmiast widoczna na jego urządzeniu i zapisana w bazie danych. Agency Phone korzysta ze wzorca optimistic update: klient natychmiast aktualizuje swój stan lokalny, aby zapewnić błyskawiczną reakcję, wysyłając jednocześnie mutację na serwer. Jeśli serwer odrzuci zmianę z powodu błędu walidacji, klient cofa się do poprzedniego stanu i wyświetla błąd. Zapewnia to płynne doświadczenie użytkownika, które wydaje się natywne, zachowując jednocześnie pełną kontrolę serwera nad danymi. Serwer rozsyła również istotne zmiany do innych połączonych klientów, gdy jest to konieczne, na przykład gdy gracz zaktualizuje swoją nazwę profilu, która pojawia się na listach kontaktów innych graczy.
-- Server: add a new contact with validation
lib.callback.register('phone:contacts:add', function(source, data)
local phoneNumber = GetPlayerPhoneNumber(source)
if not phoneNumber then return { success = false, error = 'NO_PHONE' } end
-- Validate the contact number exists in the system
local numberExists = MySQL.scalar.await(
'SELECT COUNT(*) FROM phone_numbers WHERE number = ?',
{ data.contact_number }
)
if numberExists == 0 then
return { success = false, error = 'NUMBER_NOT_FOUND' }
end
-- Prevent duplicate contacts
local existing = MySQL.scalar.await(
'SELECT COUNT(*) FROM phone_contacts WHERE owner_number = ? AND contact_number = ?',
{ phoneNumber, data.contact_number }
)
if existing > 0 then
return { success = false, error = 'ALREADY_EXISTS' }
end
-- Insert the contact
MySQL.insert.await([[
INSERT INTO phone_contacts (owner_number, contact_number, display_name, avatar)
VALUES (?, ?, ?, ?)
]], { phoneNumber, data.contact_number, data.display_name, data.avatar })
return { success = true }
end)
Przechowywanie wiadomości i wątkowanie
Wiadomości to najbardziej wymagająca pod względem danych funkcja każdego systemu telefonu. Agency Phone organizuje wiadomości w wątki konwersacji identyfikowane przez posortowaną parę numerów telefonów. Oznacza to, że rozmowa między numerem A i numerem B zawsze trafia do tego samego wątku, niezależnie od tego, kto ją zainicjował. Wiadomości w wątku są przechowywane chronologicznie z identyfikacją nadawcy, statusem odczytania i opcjonalnymi załącznikami. Model wątkowania obsługuje również wiadomości grupowe, w których trzy lub więcej numerów uczestniczy we wspólnej konwersacji. Wątki grupowe używają osobnego identyfikatora generowanego podczas tworzenia grupy, a każdy członek zachowuje własny wskaźnik przeczytania, dzięki czemu liczba nieprzeczytanych wiadomości jest precyzyjna dla każdego uczestnika.
-- Message thread resolution
-- Ensures A->B and B->A map to the same conversation
local function GetThreadId(number1, number2)
-- Sort numbers to create a deterministic thread ID
local sorted = { number1, number2 }
table.sort(sorted)
return sorted[1] .. ':' .. sorted[2]
end
-- Server: send a message
lib.callback.register('phone:messages:send', function(source, data)
local senderNumber = GetPlayerPhoneNumber(source)
if not senderNumber then return { success = false } end
local threadId = GetThreadId(senderNumber, data.to)
local messageId = MySQL.insert.await([[
INSERT INTO phone_messages (thread_id, sender_number, recipient_number, content, attachment, sent_at)
VALUES (?, ?, ?, ?, ?, NOW())
]], { threadId, senderNumber, data.to, data.content, data.attachment })
-- Notify recipient if online
local recipientSource = GetPlayerByPhoneNumber(data.to)
if recipientSource then
TriggerClientEvent('phone:messages:receive', recipientSource, {
id = messageId,
thread_id = threadId,
sender = senderNumber,
sender_name = GetContactName(data.to, senderNumber),
content = data.content,
attachment = data.attachment,
sent_at = os.time()
})
end
return { success = true, id = messageId }
end)
Udostępnianie zdjęć i obsługa multimediów
Udostępnianie zdjęć w telefonie FiveM wymaga innego podejścia niż tradycyjne aplikacje internetowe, ponieważ nie masz bezpośredniego dostępu do systemu plików gracza. Agency Phone obsługuje zdjęcia za pomocą dwóch mechanizmów: zrzutów ekranu z gry przechwytywanych za pomocą funkcji zrzutów ekranu GTA oraz obrazów na podstawie URL wklejanych przez graczy z zewnętrznych serwisów hostingowych. Zrzuty ekranu z gry są wykonywane przy użyciu natywnego API zrzutów ekranu, konwertowane na data URL i przesyłane do konfigurowalnego backendu pamięci masowej. Serwer sprawdza limity rozmiaru plików i typ zawartości przed zapisaniem adresu URL. Gdy zdjęcia są udostępniane w wiadomościach, w rekordzie wiadomości zapisywany jest tylko odnośnik URL, co pozwala zachować lekkość tabeli wiadomości. Rzeczywiste dane obrazu znajdują się w backendzie pamięci multimediów, który w zależności od infrastruktury właściciela serwera może być skonfigurowany do korzystania z dysku lokalnego, pamięci zgodnej z S3 lub zewnętrznego CDN obrazów.
-- Server: handle photo upload from in-game camera
lib.callback.register('phone:photos:upload', function(source, imageData)
local phoneNumber = GetPlayerPhoneNumber(source)
if not phoneNumber then return { success = false } end
-- Validate size (max 2MB base64)
if #imageData > 2 * 1024 * 1024 * 1.37 then
return { success = false, error = 'FILE_TOO_LARGE' }
end
-- Generate unique filename
local filename = ('%s_%s.jpg'):format(phoneNumber, os.time())
-- Store via configured backend (webhook, local, S3)
local url = StorageBackend:upload(filename, imageData)
if not url then
return { success = false, error = 'UPLOAD_FAILED' }
end
-- Save photo reference in gallery
MySQL.insert.await([[
INSERT INTO phone_photos (owner_number, url, created_at)
VALUES (?, ?, NOW())
]], { phoneNumber, url })
return { success = true, url = url }
end)
Rejestr i historia połączeń
Rejestry połączeń zapisują każde połączenie przychodzące, wychodzące i nieodebrane wraz ze znacznikami czasu i czasem trwania. Gdy gracz inicjuje połączenie, tworzony jest rekord połączenia ze statusem "dialing". Jeśli odbiorca odbierze, status zmienia się na "active" i rejestrowany jest znacznik czasu rozpoczęcia. Po zakończeniu połączenia obliczany jest czas jego trwania, a rekord zostaje sfinalizowany. Nieodebrane połączenia występują, gdy odbiorca nie odbierze w wyznaczonym czasie lub wyraźnie odrzuci połączenie. Rejestr połączeń jest wyświetlany w zakładce ostatnich połączeń telefonu z wizualnymi wskaźnikami kierunku i statusu połączenia. Gracze mogą dotknąć wpisu nieodebranego połączenia, aby natychmiast oddzwonić, lub przytrzymać dłużej, aby dodać numer do kontaktów. Serwer usuwa rejestry połączeń starsze niż konfigurowalny okres przechowywania, domyślnie 30 dni, aby zapobiec niekontrolowanemu rozrostowi tabeli na serwerach działających przez długi czas.
-- Server: create and manage call records
local activeCalls = {}
function StartCallRecord(callerNumber, receiverNumber)
local callId = MySQL.insert.await([[
INSERT INTO phone_calls (caller_number, receiver_number, status, started_at)
VALUES (?, ?, 'dialing', NOW())
]], { callerNumber, receiverNumber })
activeCalls[callId] = {
caller = callerNumber,
receiver = receiverNumber,
answeredAt = nil
}
return callId
end
function AnswerCall(callId)
if not activeCalls[callId] then return end
activeCalls[callId].answeredAt = os.time()
MySQL.update.await(
'UPDATE phone_calls SET status = ?, answered_at = NOW() WHERE id = ?',
{ 'active', callId }
)
end
function EndCall(callId)
local call = activeCalls[callId]
if not call then return end
local duration = call.answeredAt and (os.time() - call.answeredAt) or 0
local status = call.answeredAt and 'completed' or 'missed'
MySQL.update.await(
'UPDATE phone_calls SET status = ?, duration = ?, ended_at = NOW() WHERE id = ?',
{ status, duration, callId }
)
activeCalls[callId] = nil
end
Privacy by Design
Agency Phone stosuje zasady privacy-by-design w całej swojej architekturze. Numery telefonów są generowane losowo i nie są powiązane z żadnymi identyfikatorami ze świata rzeczywistego. Treść wiadomości jest przechowywana w bazie danych, ale dostęp do niej mają wyłącznie nadawca i odbiorca za pośrednictwem zweryfikowanych wywołań zwrotnych serwera. Nie ma globalnego wyszukiwania wiadomości, którego administrator mógłby użyć do czytania prywatnych rozmów bez bezpośredniego dostępu do bazy danych. Listy kontaktów są przypisane ściśle do postaci, bez wycieku danych między postaciami. Po usunięciu postaci wszystkie powiązane dane telefonu, w tym kontakty, wiadomości, rejestry połączeń i zdjęcia, są usuwane kaskadowo z bazy danych, co gwarantuje, że nie pozostaną żadne osierocone dane osobowe. System przesyłania zdjęć usuwa metadane EXIF przed zapisaniem, aby zapobiec niezamierzonemu wyciekowi lokalizacji lub informacji o urządzeniu, choć w środowisku gry jest to raczej dobra praktyka niż realne zagrożenie.
Wydajność na dużą skalę
Agency Phone został przetestowany i zoptymalizowany pod kątem serwerów z ponad 200 graczami jednocześnie. Kluczowe strategie wydajności obejmują leniwe ładowanie wątków wiadomości, dzięki czemu przy otwieraniu telefonu pobierane są tylko najnowsze rozmowy, a starsze wątki ładują się podczas przewijania. Listy kontaktów są buforowane po stronie klienta po pierwszym pobraniu i odświeżane tylko w przypadku modyfikacji. Zapytania do bazy danych korzystają z odpowiednich indeksów na numerach telefonów i znacznikach czasu, co zapewnia wyszukiwanie poniżej milisekundy nawet w tabelach z milionami wierszy. Serwer utrzymuje w pamięci podręcznej mapę numerów telefonów graczy online, co pozwala na natychmiastowe wyszukiwanie odbiorców bez obciążania bazy danych. Cała komunikacja NUI jest w miarę możliwości grupowana, więc otwarcie aplikacji wiadomości wyzwala jedno żądanie do serwera, które zwraca wątki z podglądem ostatniej wiadomości, zamiast wysyłać osobne zapytania dla każdego wątku. Te optymalizacje sprawiają, że telefon pozostaje responsywny nawet w godzinach szczytu serwera, gdy dziesiątki graczy jednocześnie wysyłają wiadomości i prowadzą rozmowy.