离线收益不是读取 DateTime.Now:Unity 与 Go 怎样共同防止改时间

离线收益由服务器确认的编辑插画

先分清三种时间

休闲游戏常见的离线收益公式很简单:离线秒数乘以每秒产量。但如果 Unity 直接用本机 DateTime.NowDateTime.UtcNow 减去上次退出时间,玩家把系统日期调到七天后,就可能得到七天收益。改用 UTC 只能消除时区差异,不能改变时间仍由设备操作系统提供这一事实。

工程上要区分三类时间。设备墙上时钟适合显示日期;Time.realtimeSinceStartupAsDouble 适合测量进程运行期间的经过时间,不受 timeScale 影响,在移动设备上也包含休眠时间;服务器 UTC 才负责决定余额。进程被杀后,单调时钟基线会丢失,所以它不能替代服务器,也不能跨安装恢复权威状态。

这条边界比“检测玩家是否改过时间”更可靠:客户端根本没有增加货币的权限,自然不必猜测每一种改时间手法。

Unity 只做展示

客户端从登录或领取响应中取得 server_now,同时记录当前单调时钟。之后界面需要刷新倒计时或“预计收益”时,用两者相加得到服务器时间的本地估计。估计只服务于表现层,不能写入背包、任务进度或分析口径中的实际收入。

using System;
using UnityEngine;

public sealed class ServerClock
{
    private double serverUnixAtSync;
    private double realtimeAtSync;

    public void Sync(long serverUnix)
    {
        serverUnixAtSync = serverUnix;
        realtimeAtSync = Time.realtimeSinceStartupAsDouble;
    }

    public long EstimateUnix()
    {
        // 单调时钟只用于界面估算,不参与货币入账
        double elapsed = Math.Max(
            0d,
            Time.realtimeSinceStartupAsDouble - realtimeAtSync);

        return (long)(serverUnixAtSync + elapsed);
    }
}

本地持久化目录可以保存尚未确认的 claim_id、最后一张回执和界面缓存,但这些内容都可被修改,服务端不能把它们当作领取依据。客户端离线期间可以显示“预计获得 1,200 金币”,真正余额必须等服务器回执后刷新。

展示层还要处理校准后的跳变。如果服务器时间与当前估计只差一两秒,可以逐帧平滑修正;差距很大时应立即采用服务器值,并重新请求相关活动状态。无论采用哪种动画,底层余额都直接以响应为准,不能把视觉插值写回业务模型。这样即使设备休眠、系统时间变化或帧率不稳定,用户看到的动画和最终账本也不会互相污染。

启动与恢复同步

业务代码应在游戏启动、重新登录以及从后台恢复时主动同步。Unity 会发送生命周期消息,但不会自动完成网络同步。OnApplicationPause(false) 在初始化阶段也可能被调用,因此不能假设它只代表一次真实恢复;客户端可以做请求中的短暂防抖,服务端接口仍必须幂等。

推荐的客户端流程只有四步:读取或创建一个 claim_id;调用领取接口;用响应中的 server_now 校准展示时钟;收到成功回执后才更新余额并清除本地待确认编号。若超时或断网,保留同一个编号重试,绝不能重新生成编号后再次领取。

下面只展示协议结构,时间、收益和余额均为示例值,不代表实际运营配置:

POST /v1/api.json

{
  "action": "idle.claim",
  "data": {
    "claim_id": "018f..."
  }
}
{
  "code": 0,
  "msg": "success",
  "data": {
    "server_now": 1784422800,
    "elapsed_secs": 14400,
    "reward": 1200,
    "balance": 9860,
    "wallet_version": 42,
    "receipt_id": "01j0..."
  }
}

客户端仅在 wallet_version 不小于本地版本时采用响应中的余额。奖励回执可以重复读取,钱包快照却不能用旧版本覆盖新版本;服务端仍只相信数据库里的最后领取点。

首次创建账号时,服务端应同时初始化 last_claimed_at,不能接受客户端上传的“首次离线起点”。请求超时后界面保持待确认状态;只有明确的业务拒绝,例如账号被封禁或领取规则不存在,才结束本次请求。普通网络失败和服务器暂时错误都沿用原编号重试。

Unity 客户端与 Go 服务端离线收益领取时序

Go 服务端才入账

服务端保存玩家余额、钱包版本、last_claimed_at 和收益规则版本。领取时先锁定玩家行,再检查该玩家是否已有同一 claim_id 的回执。没有回执才按事务数据库时间计算离线秒数,并把结果限制在 0 到运营配置的上限之间。超过上限截断;时区不参与运算。若数据库时间短暂回拨,领取点保持原值,绝不能向过去移动。

下面是刻意省略存储实现的 Go 风格伪代码,重点是事务边界:

func Claim(ctx context.Context, uid uint64, claimID string) (ClaimResult, error) {
    return db.WithTx(ctx, func(tx Tx) (ClaimResult, error) {
        player, err := tx.LockPlayer(ctx, uid)
        if err != nil {
            return ClaimResult{}, err
        }

        dbNow, err := tx.CurrentTime(ctx)
        if err != nil {
            return ClaimResult{}, err
        }

        // 同一领取编号始终返回同一奖励回执
        if receipt, ok := tx.FindReceipt(uid, claimID); ok {
            return NewClaimResult(receipt, player, dbNow), nil
        }

        claimPoint := maxTime(player.LastClaimedAt, dbNow)
        ruleSet := rules.Snapshot()
        seconds := clampSeconds(
            claimPoint.Sub(player.LastClaimedAt),
            0,
            ruleSet.OfflineCap())
        reward := ruleSet.Calculate(player, seconds)

        receipt := NewReceipt(
            uid, claimID, dbNow, seconds, reward, ruleSet.Version())
        updatedPlayer, err := tx.AddBalanceAndMoveClaimPoint(
            uid, reward, claimPoint, ruleSet.Version())
        if err != nil {
            return ClaimResult{}, err
        }
        if err := tx.InsertReceipt(receipt); err != nil {
            return ClaimResult{}, err
        }
        return NewClaimResult(receipt, updatedPlayer, dbNow), nil
    })
}

数据库还要建立 UNIQUE(player_id, claim_id)。余额、领取点和回执必须在同一事务提交;不能先发奖励再异步记录领取点,否则进程崩溃会留下重复领取窗口。回执应保存最终奖励和规则版本,而不是以后按新配置重新计算旧结果。wallet_version 必须随充值、消耗、奖励等所有钱包变更原子递增,不能只在离线领取时更新。

事务之外还需要明确失败语义。数据库提交成功就是领取成功,后续推送、埋点和弹窗通知应通过事务消息或可重试任务派生,不能反过来决定是否回滚余额。监控可以统计领取耗时、重复命中率、截断到上限的比例和负时长归零次数;若某项突然上升,再调查客户端版本、服务器时钟或配置发布,而不是直接处罚玩家。

重试与多设备

最容易漏掉的故障不是改时间,而是“服务端已经提交,响应却在网络中丢失”。客户端若用原 claim_id 重试,服务端返回原奖励回执和当前钱包快照,余额不会再加一次。若每次重试都换编号,幂等设计就失效了。即使旧编号在后续领取完成后再次出现,客户端也用 wallet_version 拒绝更旧的钱包快照。

同一账号在两台设备上同时领取时,两台设备通常会生成不同编号。玩家行锁让请求顺序执行:第一个请求推进 last_claimed_at,第二个请求随后只能得到接近零的新增时长。唯一约束解决同一请求重复提交,行锁解决不同请求争夺同一段离线时间,两者不能互相替代。这个结论要求同一玩家进入同一个事务权威库;多主或多区域写入还要增加单写者路由或等价的串行化机制。

需要完全离线可玩的产品,可以让客户端继续模拟产出,但联网时仍以服务端结算覆盖本地显示。只有确实存在长期无网且必须发放可消费资产的需求,才值得引入签名额度或可验证日志;普通休闲手游优先使用“联网领取”,实现更短,审计也更清楚。

运营规则也要版本化

离线收益上限、产量倍率、付费加成和活动加成都会变化。服务端应在一次领取开始时取得不可变的规则快照,本次上限、计算和版本号全部从同一对象读取,并把 rules_version 写入回执。若产品要求跨配置分段计算,就保存配置生效区间并按时间段累加;不能用当前倍率覆盖整段历史时间。

客户端只展示服务端返回的 elapsed_secsrewardbalance。文案要区分“预计收益”与“已领取”,网络错误时保持原余额并提供重试,不播放已经入账的强反馈。这样客户端表现、服务端账本和运营审计使用同一张回执。

用故障场景验收

测试不能只覆盖正常退出四小时后再登录。至少要验证:设备时间向前和向后拨动;应用切后台再恢复;杀进程后重启;断网后重连;连续点击领取;同一 claim_id 重放;旧回执在新领取后重放;两台设备同时领取;服务端提交后主动丢弃响应;数据库时间回拨与节点偏差;收益上限边界;领取期间规则版本切换。

每个用例都检查三件事:最终余额只增加一次,last_claimed_at 只向服务器时间推进,重复请求返回同一回执。日志记录玩家、领取编号、回执编号、计算秒数、规则版本和事务结果,但不记录认证令牌。

离线收益的正确架构并不是寻找一个“不会被改的客户端时间”。Unity 提供流畅、及时的估算体验,Go 服务端提供唯一、可重试、可审计的货币事实。把展示与入账分开,改时间、多设备和弱网络才会从特殊补丁变成同一套事务模型可以处理的普通输入。