FiveM Telefon Kişi Senkronizasyonu: Cihazlar Arası Kişi Sistemleri
FiveM telefonları, tabletler ve multichar slotları arasında kişileri senkronize edin. Veritabanı desenleri, bridge scriptleri ve numaraları tutarlı tutmanın en temiz yolu.
Agency Scripts
Agency Scripts Kurucusu ve Baş Geliştiricisi
FiveM'de Telefon Verilerinin Zorluğu
FiveM için bir telefon sistemi geliştirmek, oyuncunun ekranında güzel bir UI render almaktan çok daha fazlasını gerektirir. Asıl mühendislik zorluğu, oturumlar arasında kalıcı verileri yönetmektir: Kişiler, mesaj dizileri, arama kayıtları, fotoğraflar ve uygulama ayarlarının tümünün sunucu yeniden başlatmalarından, karakter değişikliklerinden ve çoklu karakter ortamlarından sorunsuzca çıkması gerekir. Agency Phone, temel bir ilke olarak veri bütünlüğü gözetilerek sıfırdan tasarlandı. Her veri parçası, istemcinin eylemleri talep ettiği ve sunucunun istemciye geri onaylamadan önce bunları doğruladığı, işlediği ve kalıcı hale getirdiği server-authoritative bir işlem hattından geçer. Bu makale: geliştiricilerin ve sunucu sahiplerinin ürünün arkasındaki mimariyi anlamaları için Agency Phone'un kişi senkronizasyonunu, mesaj depolamasını, fotoğraf paylaşımını, arama kaydını ve tasarımdan gelen gizliliği nasıl ele aldığını ayrıntılarıyla gözler önüne seriyor.
Kişi Depolama Mimarisi
Agency Phone içindeki kişiler oyuncu başına değil, karakter başına saklanır. Bu ayrım önemlidir çünkü bir oyuncunun aynı sunucuda her biri tamamen farklı sosyal çevrelere sahip üç karakteri olabilir. Kişi tablosu, sahibinin telefon numarası ile kişinin telefon numarasından oluşan bileşik bir anahtar kullanır; ayrıca görünen ad, avatar URL ve favori işareti için ek alanlar içerir. Bir oyuncu kişiler uygulamasını açtığında, istemci sunucuya tek bir istek gönderir; sunucu veritabanını sorgular ve tüm kişi listesini tek bir toplu işlemde döndürür. Bu durum, her kişinin kendi veritabanı sorgusunu tetiklediği ve yüzlerce kişiye sahip oyuncuları barındıran bir sunucuda yıkıcı olabilecek kademeli sorgu modelini önler.
-- 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)
Gerçek Zamanlı Kişi Senkronizasyonu
Bir oyuncu bir kişiyi eklediğinde, düzenlediğinde veya sildiğinde, bu değişikliğin hemen cihazına yansıması ve veritabanına kaydedilmesi gerekir. Agency Phone iyimser bir güncelleme deseni kullanır: istemci anında geri bildirim vermek için yerel durumunu hemen güncellerken, eşzamanlı olarak mutasyonu sunucuya gönderir. Sunucu doğrulama hatası nedeniyle değişikliği reddederse, istemci önceki durumuna geri döner ve bir hata görüntüler. Bu, veriler üzerindeki sunucu yetkisini korurken yerel hissettiren duyarlı bir kullanıcı deneyimi yaratır. Sunucu ayrıca gerektiğinde, örneğin bir oyuncu diğer oyuncuların kişi listelerinde görünen kendi profil adını güncellediğinde, ilgili değişiklikleri bağlı diğer istemcilere de yayınlar.
-- 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)
Mesaj Depolama ve İleti Dizileri
Mesajlar, herhangi bir telefon sisteminin en yoğun veri kullanan özelliğidir. Agency Phone, mesajları sıralanmış bir telefon numarası çiftiyle tanımlanan konuşma dizileri halinde düzenler. Bu, kimin başlattığından bağımsız olarak A numarası ile B numarası arasındaki konuşmanın her zaman aynı diziyle eşleştiği anlamına gelir. Bir dizi içindeki mesajlar: gönderen kimliği, okundu durumu ve isteğe bağlı eklerle birlikte kronolojik olarak saklanır. Dizi modeli, üç veya daha fazla numaranın paylaşılan bir sohbete katıldığı grup mesajlarını da destekler. Grup dizileri, grup oluşturulduğunda üretilen ayrı bir tanımlayıcı kullanır ve her üye kendi okuma işaretçisini korur, böylece katılımcı başına okunmamış mesaj sayısı her zaman doğru kalır.
-- 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)
Fotoğraf Paylaşımı ve Medya Yönetimi
FiveM telefonunda fotoğraf paylaşımı, oyuncunun dosya sistemine doğrudan erişemediğiniz için geleneksel web uygulamalarından farklı bir yaklaşım gerektirir. Agency Phone fotoğrafları iki mekanizmayla işler: GTA ekran görüntüsü işlevi kullanılarak yakalanan oyun içi ekran görüntüleri ve oyuncuların harici görsel barındırma hizmetlerinden yapıştırdığı URL tabanlı görseller. Oyun içi ekran görüntüleri yerel ekran görüntüsü API'si kullanılarak alınır, bir data URL formatına dönüştürülür ve yapılandırılabilir bir depolama arka ucuna yüklenir. Sunucu, URL'yi kaydetmeden önce dosya boyutu sınırlarını ve içerik türünü doğrular. Fotoğraflar mesajlarda paylaşıldığında, mesaj kaydında yalnızca URL referansı saklanır ve böylece mesajlar tablosu yalın tutulur. Asıl görsel verisi, sunucu sahibinin altyapısına bağlı olarak yerel disk, S3 uyumlu depolama veya harici bir görsel CDN'i kullanacak şekilde yapılandırılabilen medya depolama arka ucunda barındırılır.
-- 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)
Arama Kayıtları ve Geçmiş
Arama kayıtları; gelen, giden ve cevapsız tüm aramaları zaman damgaları ve süreleriyle kaydeder. Bir oyuncu arama başlattığında, "dialing" durumunda bir arama kaydı oluşturulur. Alıcı cevap verirse durum "active" olarak güncellenir ve başlangıç zaman damgası kaydedilir. Arama sona erdiğinde süre hesaplanır ve kayıt sonlandırılır. Cevapsız aramalar, alıcının zaman aşımı süresi içinde cevap vermemesi veya aramayı doğrudan reddetmesi durumunda oluşur. Arama kaydı, telefonun son aramalar sekmesinde arama yönü ve durumu için görsel göstergelerle görüntülenir. Oyuncular, hemen geri aramak için cevapsız arama kaydına dokunabilir veya numarayı kişilere eklemek için basılı tutabilir. Sunucu, uzun süredir çalışan sunucularda tablonun süresiz büyümesini önlemek için varsayılan olarak 30 gün olan yapılandırılabilir bir saklama süresinden eski arama kayıtlarını temizler.
-- 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
Tasarım Gereği Gizlilik
Agency Phone, mimarisi genelinde privacy-by-design prensiplerini takip eder. Telefon numaraları rastgele oluşturulur ve gerçek dünyadaki hiçbir tanımlayıcıyla bağlantılı değildir. Mesaj içeriği veritabanında saklanır, ancak yalnızca doğrulanmış sunucu callback'leri aracılığıyla gönderen ve alıcı tarafından erişilebilir. Bir yöneticinin açık veritabanı erişimi olmadan özel konuşmaları okumak için kullanabileceği global bir mesaj araması yoktur. Kişi listeleri kesinlikle karakter başınadır ve karakterler arası veri sızıntısı olmaz. Bir karakter silindiğinde kişiler, mesajlar, arama kayıtları ve fotoğraflar dahil olmak üzere ilişkili tüm telefon verileri veritabanından cascade-deleted olarak silinir ve sahipsiz kişisel verilerin kalmaması sağlanır. Fotoğraf yükleme sistemi, depolamadan önce EXIF meta verilerini temizleyerek istenmeyen konum veya cihaz bilgisi sızıntılarını önler; gerçi bu durum bir oyun ortamında pratik bir endişeden ziyade iyi bir uygulamadır.
Büyük Ölçekte Performans
Agency Phone, aynı anda 200 veya daha fazla oyuncuya sahip sunucular için test edilmiş ve optimize edilmiştir. Temel performans stratejileri arasında, telefon açıldığında yalnızca en son konuşmaların getirilmesi ve eski mesaj geçmişlerinin kaydırma sırasında yüklenmesi için mesaj dizilerinin gecikmeli yüklenmesi (lazy loading) yer alır. Kişi listeleri ilk alımdan sonra istemci tarafında önbelleğe alınır ve yalnızca bir değişiklik olduğunda yenilenir. Veritabanı sorguları, milyonlarca satırlık tablolarda bile milisaniyenin altında aramalar sağlamak için telefon numaraları ve zaman damgalarında uygun indeksler kullanır. Sunucu, veritabanına erişmeden anında alıcı araması yapabilmek için çevrim içi oyuncu telefon numaralarının bellek içi (in-memory) bir haritasını tutar. Tüm NUI iletişimi mümkün olan yerlerde toplu olarak işlenir; böylece mesajlar uygulamasının açılması, her mesaj dizisi için ayrı istekler göndermek yerine en son mesaj önizlemesiyle birlikte dizileri döndüren tek bir sunucu isteği tetikler. Bu optimizasyonlar, onlarca oyuncunun aynı anda mesaj gönderip arama yaptığı yoğun sunucu saatlerinde bile telefonun hızlı ve duyarlı kalmasını sağlar.