FiveM 电话联系人同步:跨设备联系人系统
在FiveM手机、平板和多角色槽之间同步联系人。数据库模式、桥接脚本以及保持号码一致性的最清洁方式。
Agency Scripts
Agency Scripts 创始人兼首席开发者
FiveM中手机数据的挑战
为 FiveM 构建电话系统远不止在玩家屏幕上渲染漂亮的 UI。真正的工程挑战是管理跨会话的持久数据:联系人、消息线程、通话记录、照片和应用设置都需要在服务器重启、角色切换和多角色环境中保持。Agency Phone 从零开始设计,数据完整性是核心原则。每条数据都通过服务器权威的管道流动,客户端请求操作,服务器验证、处理并持久化后再确认给客户端。本文揭示了 Agency Phone 如何处理联系人同步、消息存储、照片共享、通话记录和隐私设计,让开发者和服务器所有者理解产品背后的架构。
联系人存储架构
Agency Phone中的联系人按角色存储,而非按玩家存储。这一点很重要,因为玩家可能在同一服务器上拥有三个角色,每个角色的社交圈完全不同。联系人表使用所有者电话号码和联系人电话号码的复合键,并附加显示名称、头像URL和收藏标志字段。当玩家打开联系人应用时,客户端向服务器发送单个请求,服务器查询数据库并一次性返回完整联系人列表。这避免了每个联系人触发单独数据库查询的瀑布模式,这在拥有数百联系人玩家的服务器上会造成灾难性影响。
-- 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)
实时联系人同步。
当玩家添加、编辑或删除联系人时,变化需立即反映在设备上并持久保存到数据库。Agency Phone 采用乐观更新模式:客户端立即更新本地状态以提供即时反馈,同时将变更发送到服务器。若服务器因验证失败拒绝变更,客户端回滚至先前状态并显示错误。此设计创造了响应迅速且原生感强的用户体验,同时保持服务器对数据的控制权。服务器在必要时也会向其他连接客户端广播相关变更,例如玩家更新个人资料名时,其他玩家的联系人列表同步更新。
-- 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)
消息存储与线程管理
消息是任何电话系统中数据量最大的功能。Agency Phone 将消息组织为由排序后的电话号码对标识的对话线程。这意味着号码 A 与号码 B 之间的对话无论谁发起,始终映射到同一线程。线程内消息按时间顺序存储,包含发送者身份、已读状态及可选附件。线程模型还支持三人及以上的群组消息。群组线程使用创建时生成的独立标识符,每个成员维护自己的已读指针,确保未读计数准确。
-- 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)
照片分享与媒体处理
FiveM电话中的照片分享需要不同于传统网页应用的方法,因为无法直接访问玩家文件系统。Agency Phone通过两种机制处理照片:使用GTA截图功能捕获的游戏内截图,以及玩家从外部图像托管服务粘贴的基于URL的图片。游戏内截图使用原生截图API拍摄,转换为数据URL,并上传到可配置的存储后端。服务器验证文件大小限制和内容类型后保存URL。照片在消息中分享时,仅存储URL引用于消息记录,保持消息表精简。实际图像数据存储在媒体存储后端,可配置为使用本地磁盘、兼容S3的存储或外部图像CDN,具体取决于服务器拥有者的基础设施。
-- 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)
通话记录和历史
通话记录记录每个来电、去电和未接来电的时间戳和持续时间。当玩家发起呼叫时,会创建一个状态为“拨号”的通话记录。如果接收方接听,状态更新为“活动”,并记录开始时间戳。通话结束时,计算通话时长并完成记录。未接来电发生在接收方未在超时期间接听或明确拒绝时。通话记录显示在手机的最近通话标签中,带有通话方向和状态的视觉指示。玩家可以点击未接来电条目立即回拨,或长按将号码添加到联系人。服务器会修剪超过可配置保留期(默认为30天)的通话记录,以防止长期运行服务器中表无限增长。
-- 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
隐私设计
Agency Phone follows privacy-by-design principles throughout its architecture. Phone numbers are generated randomly and are not tied to any real-world identifier. Message content is stored in the database but is only accessible to the sender and recipient through validated server callbacks. There is no global message search that an admin could use to read private conversations without explicit database access. Contact lists are strictly per-character with no cross-character data leakage. When a character is deleted, all associated phone data including contacts, messages, call logs, and photos are cascade-deleted from the database, ensuring no orphaned personal data remains. The photo upload system strips EXIF metadata before storage to prevent unintentional location or device information leaks, though this is more of a best practice than a practical concern in a game environment.
大规模性能
Agency Phone is tested and optimized for servers with 200 or more concurrent players. The key performance strategies include lazy loading message threads so only the most recent conversations are fetched on phone open, with older threads loaded on scroll. Contact lists are cached client-side after the initial fetch and only refreshed when a mutation occurs. Database queries use proper indexes on phone numbers and timestamps to ensure sub-millisecond lookups even on tables with millions of rows. The server maintains an in-memory map of online player phone numbers for instant recipient lookups without database hits. All NUI communication is batched where possible, so opening the messages app triggers one server request that returns threads with their latest message preview rather than making separate requests for each thread. These optimizations ensure the phone remains responsive even during peak server hours when dozens of players are simultaneously sending messages and making calls.