Wróć do bloga
Article8 min czytania

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.

Pasujący skrypt

Gotowe na twój serwer, aktualizacje w zestawie.

Gotowy, aby zacząć?

Kup skrypty w naszym sklepie lub dołącz do Discorda, aby uzyskać wsparcie, aktualizacje i sprawdzić nadchodzące nowości.