Godot 平台权益缓存:DLC、订阅和购买恢复不能只靠一次回调

设计 Godot 客户端平台权益缓存层,处理 DLC、订阅、购买恢复、离线状态和跨平台差异。

平台权益是一个很容易被低估的客户端系统。Steam DLC、移动端内购、订阅会员、主机平台附加内容,看起来都是“平台回调告诉我有没有购买”。可真实环境里,回调会延迟,网络会断,玩家会离线启动,平台 SDK 会失败,跨设备状态会不一致。Godot 项目如果把权益判断散在商店按钮、角色选择、关卡入口里,迟早会出现玩家已购买却显示未拥有,或者未确认权益就提前解锁内容。

项目里的真实问题

一次 DLC 上线后,玩家购买成功但返回游戏时入口仍然锁着。重启后才恢复。另一些玩家离线启动时,之前买过的皮肤不可用,因为客户端只在启动时向平台请求了一次,失败就当作未拥有。还有一种情况是订阅过期,但客户端缓存没有刷新,仍然展示会员入口。权益不是布尔值,而是带来源、时间、置信度和过期策略的状态。

设计目标

  • 统一入口:所有 DLC、订阅和购买恢复都通过 EntitlementService 查询。
  • 状态分层:拥有、未拥有、未知、恢复中、过期有不同表现。
  • 离线可用:已确认的长期权益可离线使用,短期权益按过期时间处理。
  • 平台隔离:Steam、移动端、主机的 SDK 差异封装在适配层。

这些目标看起来像工程约束,实际是在保护玩家体验。Godot 的开发效率很高,很多功能几行脚本就能跑起来,但一旦进入多人协作和多平台发布,临时脚本会迅速变成隐性状态。这里的做法是把状态、输入、执行和反馈拆开,让每一步都能被测试、记录和回退。

推荐架构

flowchart TD
    A["玩家操作/场景事件"] --> B["EntitlementService"]
    B --> C["平台适配器"]
    B --> D["本地权益缓存"]
    B --> E["购买恢复"]
    B --> F["权益快照广播"]
    C --> Z["状态快照和日志"]
    D --> Z
    E --> Z
    Z --> Y["UI 反馈/运行时执行"]

架构图里的模块不要求都做成独立单例。小项目可以合并实现,大项目可以拆成服务和 Resource。真正重要的是调用方向:业务脚本提交意图,管理器做决策,执行层处理 Godot 节点和资源,最后把结果变成 UI 反馈和日志。只要这个方向稳定,后续替换实现不会牵动整个项目。

关键实现细节

本地缓存里不应该只存 owned: true。至少要记录 entitlement id、状态、来源、确认时间、过期时间、平台订单摘要和缓存版本。长期 DLC 可以在离线时信任最近确认结果;订阅和限时权益必须检查过期时间。
unknown 不是 not_owned。平台请求失败、网络断开或 SDK 未初始化时,状态应该是 unknown。UI 可以显示“正在确认”或“暂时无法验证”,不要直接显示购买按钮。否则已购买玩家会误以为权益丢失。
购买后要进入 pending 状态。平台购买回调、服务器校验和本地缓存写入可能不是同一时刻完成。pending 期间 UI 禁用重复购买,显示恢复中。成功后广播权益变化,所有入口同步刷新。
商店、关卡入口、外观系统和奖励系统都需要权益状态。不要让它们各自调用平台 SDK。EntitlementService 拉取后发布快照,UI 订阅快照变化。这样购买成功后所有入口能同步刷新。

容易踩的坑

把 unknown 当成 not_owned,会伤害已购买玩家。网络失败时应保守展示确认中。
业务 UI 写平台商品 id,会让跨平台映射非常痛苦。内部内容 id 和平台商品 id 必须分开。
恢复购买没有结果摘要,玩家会不知道到底有没有恢复成功。

GDScript 接口草图

class_name EntitlementService
extends Node

var current_state := {}
var version := 0

func request(payload: Dictionary) -> void:
    version += 1
    var token := version
    current_state["phase"] = "pending"
    _run_async(payload, func(result):
        if token != version:
            return
        current_state = _normalize_result(result)
        emit_signal("state_changed", current_state)
    )

func _normalize_result(result: Dictionary) -> Dictionary:
    result["system"] = "godot-platform-entitlement-cache-2026"
    return result

这段代码展示的是接口边界,不是完整实现。真实项目里,payload 应该替换成具体 Resource 或 typed Dictionary,异步回调也要接入错误码、超时和取消。保留 version 或 token 的原因,是 Godot 客户端经常出现旧请求晚于新请求返回的问题,尤其在资源加载、网络和 UI 快速切换场景里。

分阶段落地

第一阶段把所有权益判断改为查询 EntitlementService,不再让 UI 直接问平台 SDK。
第二阶段实现本地缓存、unknown 状态和购买恢复流程。
第三阶段接入服务器校验、跨平台映射和订阅过期刷新。

自动化验证和人工验收

平台 SDK 请求失败时,已确认长期 DLC 离线仍可用,未知权益不误判为未购买。
购买成功、取消、失败、恢复无内容、恢复失败都有不同 UI。
同时触发启动刷新和购买恢复时,旧快照不能覆盖新快照。

观测指标

  • 权益刷新成功率和耗时。
  • unknown 状态出现次数和持续时长。
  • 购买恢复点击率、成功率和失败原因。
  • 权益状态回退或 UI 不同步次数。

指标不必全部做成线上埋点。开发包可以显示完整调试面板,内测包采样关键计数,正式包只保留错误码和聚合结果。关键是让问题出现时有证据,而不是靠“我感觉刚才卡了一下”这种描述反复猜。

上线前检查清单

  • 业务 UI 不直接依赖平台商品 id。
  • 权益缓存记录来源、确认时间和过期时间。
  • unknown 与 not_owned 明确区分。
  • 购买恢复有入口、进度和结果说明。
  • 权益快照有版本号,异步结果不乱序覆盖。

清单要尽量和脚本结合。能自动检查的放进目录级验证,不能自动检查的写进验收步骤。每次事故后都应该补一条规则,哪怕一开始只是人工检查。这样系统会随着项目经验变厚,而不是只靠某个熟悉代码的人记在脑子里。

数据契约和状态模型

权益状态要有明确枚举:ownednot_ownedunknownpendingexpiredrevokedrevoked 用于退款、撤销或平台回收,和自然过期不同。每个状态都有 UI 显示策略和可操作策略。比如 unknown 允许重试验证,expired 可以显示续订,revoked 通常需要隐藏权益并提示客服路径。

缓存里还要保存来源。平台本地确认、服务器确认、离线缓存、恢复购买结果,置信度不同。UI 不一定展示这些来源,但日志和问题反馈必须带上。玩家说“我买了但没有”,客服需要知道客户端当前看到的是平台 unknown 还是服务器 not_owned。

失败处理和离线策略

离线策略要按权益类型区分。永久 DLC 最近确认 owned,可以离线使用;订阅权益必须看 expires_at,过期后不能继续当作 owned;消耗型商品通常不应该只靠本地缓存发放。把这些策略写进 EntitlementPolicy,而不是让每个入口自己判断。

购买流程中断也要恢复。玩家付款后游戏被系统杀掉,下次启动时应该触发 pending 订单查询或购买恢复提示。否则玩家已经付款,但客户端没有处理回调。移动端尤其要重视这个路径,平台购买 UI 离开游戏进程是很常见的。

协作接口

商店 UI、内容入口和奖励系统都只订阅权益快照,不直接调用平台 SDK。平台适配器负责把 Steam、移动端、主机接口转换成统一 EntitlementResult。这样换平台或新增平台时,业务页面不需要改。

测试同学需要一套假平台适配器。它能模拟购买成功、取消、延迟、恢复无内容、退款撤销、SDK 初始化失败。没有假适配器,就只能依赖真实平台沙盒测试,很多异常路径很难稳定复现。

实战案例与复盘

DLC 购买后入口不刷新,是最常见的权益问题。一次测试里,平台回调已经返回成功,但关卡入口仍然显示锁定,因为入口页面只在打开时查询了一次权益。修复后,EntitlementService 发布权益快照变化,入口页面订阅快照并刷新状态。玩家购买完成后不需要重启,也不需要重新进页面。

离线权益也需要仔细设计。永久 DLC 最近确认 owned,可以离线可用;订阅会员则必须看过期时间。一次事故中,客户端把订阅缓存当永久权益,玩家断网后过期订阅仍可使用。修复方式是在缓存里加入 expires_at 和 entitlement type,并由 EntitlementPolicy 判断离线可用性。

复盘权益问题时,要保留来源链路。玩家反馈“买了没到账”,日志里应能看到平台商品 id、内部权益 id、平台回调状态、服务器校验状态、本地缓存状态和 UI 快照版本。缺少任何一环,客服和开发都会互相等待。

上线后的维护策略

权益缓存上线后,维护重点是平台差异。不同平台对退款、订阅过期、离线启动和恢复购买的行为不一样。每接一个新平台,都应该先补适配器测试样本,再接真实 SDK。不要让业务 UI 直接感知这些差异。

灰度开关也要提前准备。任何客户端系统只要影响加载、输入、UI 入口、平台权益或资源选择,都应该能在灰度阶段降低强度或回退到旧策略。回退不是简单关闭功能,而是要保证玩家路径仍然完整。例如系统异常时,可以停用高级策略、保留基础入口、显示降级文案,并把错误码写入日志。没有回退策略的功能,灰度时会让团队非常被动。

责任人要写清楚。一个系统上线后,谁维护配置,谁看指标,谁处理内容接入,谁判断是否回滚,都应该明确。否则问题出现时,大家会先讨论“这归谁管”。Godot 项目里的许多客户端系统横跨程序、策划、美术、运营和 QA,如果没有责任边界,维护成本会比实现成本更高。

文档也不需要写成很重的手册,但至少要有三部分:接入方式、常见错误、验收步骤。接入方式告诉后来的人怎么新增内容;常见错误记录已经踩过的坑;验收步骤保证每次改动都有同样的检查口径。文档越贴近项目真实问题,越不会变成没人看的摆设。

小团队接入版本

小团队可以先支持最简单的长期 DLC:平台确认后写本地缓存,离线可用,启动时刷新。即使没有服务器校验,也要保留状态分层和恢复入口。以后接订阅和服务器时,接口不用推翻。

交付边界

交付标准是玩家买完内容后不用重启就能看到入口刷新,离线启动不会丢已确认权益,恢复购买能给出明确结果。权益系统一旦让玩家不信任,客服成本会迅速上升。

现场演练

现场演练可以模拟三种情况:购买成功但服务器确认延迟、离线启动已购买 DLC、恢复购买没有找到新内容。UI 应分别显示 pending、已拥有可用、没有可恢复内容。只要三种状态都清晰,玩家就不容易误解。

结语

平台权益不是一次回调,而是一套状态管理。Godot 客户端把 SDK 差异、缓存、恢复和 UI 快照集中起来后,DLC、订阅和购买恢复才会表现得像可靠功能,而不是碰运气的外部接口。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页