返回博客
Article4 分钟阅读

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 通信会尽可能批量处理,因此打开消息应用时只需发送一次服务器请求,即可获取包含最新消息预览的会话,而不是为每个会话分别发送请求。这些优化确保即使在服务器高峰时段,数十名玩家同时发送消息和拨打电话,手机仍能保持响应流畅。

匹配的脚本

为你的服务器准备就绪,包含更新。

准备好开始了吗?

在我们的商店获取脚本,或加入 Discord 获取支持、更新以及新功能预告。