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 在整个架构中贯彻隐私保护设计原则。电话号码随机生成,不与任何现实世界标识符关联。消息内容存储在数据库中,但只有发送者和接收者可以通过经过验证的服务器回调访问。没有全局消息搜索功能,因此管理员无法在未经明确数据库访问的情况下读取私人对话。联系人列表严格按角色区分,不会发生跨角色数据泄露。删除角色时,其关联的所有手机数据,包括联系人、消息、通话记录和照片,都会通过级联删除从数据库中移除,确保不遗留任何孤立的个人数据。照片上传系统会在存储前剥除 EXIF 元数据,以防意外泄露位置或设备信息,不过在游戏环境中,这更多是一种最佳实践,而非实际风险。
大规模性能
Agency Phone 已针对同时在线玩家达到 200 或更多的服务器进行测试和优化。关键性能策略包括延迟加载消息会话,因此打开手机时只获取最近的对话,较早的会话则在滚动时加载。联系人列表在首次获取后会缓存在客户端,只有发生变更时才会刷新。数据库查询会在电话号码和时间戳上使用适当的索引,即使数据表包含数百万行,也能确保亚毫秒级查找。服务器会在内存中维护在线玩家电话号码映射,以便立即查找收件人,而无需访问数据库。NUI 通信会尽可能批量处理,因此打开消息应用时只需发送一次服务器请求,即可获取包含最新消息预览的会话,而不是为每个会话分别发送请求。这些优化确保即使在服务器高峰时段,数十名玩家同时发送消息和拨打电话,手机仍能保持响应流畅。