3.3 防双调用与所有权验证:RPC 的安全护栏
为什么 Server RPC 必须验证
客户端是不可信的。Server RPC 的入口本质上是一个「外部输入口」——和 HTTP 接口一样需要校验。反面案例:客户端直接调用 ServerSetHealth(9999)、ServerGrantItem(极品装备, 我自己)。任何 Server RPC 都要假设调用者在撒谎。
三层校验清单
① 所有权验证(几乎每个 Server RPC 都要):
bool AMyCharacter::ServerEquipItem_Validate(int32 SlotIdx)
{
// 最基本:这个 RPC 是这个客户端自己角色的吗?
if (!Controller || Controller != GetOwner()) return false;
return true;
}
更稳的做法是把 Server RPC 放在 PlayerController / PlayerState 上(这些 Actor 天然拥有者=客户端),免去「这个请求是不是本人」的判断。把 RPC 挂在 Pawn 上的风险是:Pawn 可能被别的客户端 possession 或数学上「借用」。
② 参数合法性:数值范围、枚举有效性、引用是否为空。
③ 游戏状态合法性:冷却到了吗?距离够吗?还在施法吗?经济系统扣得起吗?
防双调用(幂等性)
网络重传 + 玩家连点,同一个 Server RPC 可能短时间内到达多次。逻辑要做幂等:
void AMyCharacter::ServerOpenChest_Implementation(int32 ChestId)
{
if (OpenedChests.Contains(ChestId)) return; // 已开过,幂等拦截
OpenedChests.Add(ChestId);
GrantLoot(ChestId);
}
常见模式:
- 令牌/序列号:客户端递增 token,服务器记录最近处理的 token,旧 token 丢弃。
- 状态机守卫:宝箱只能在「Closed」状态被开;开完即「Opened」,重复请求自然拒绝。
- 服务器单一数据源:客户端永远只发「意图」(UseChest),由服务器判断结果,绝不接受结果(AddItem(极品))。
Client RPC 也可能出错
- 给错误的端发:给一个已被销毁的 Pawn 发 Client RPC → 无目标,包被丢弃,可能报 warning 刷屏。
- 客户端重连时的残留 RPC:断线重连过程中,旧的 Client RPC 队列可能在重连后集中到达。关键状态不要依赖「Client RPC 一定先于重连完成」,重要数据用复制属性承载。
Multicast 的权限问题
Multicast 从客户端调用是无效的(框架丢弃)。客户端想办全场事件必须先 Server RPC,由服务器 Multicast。这是新手常见错误:客户端直接调 Multicast 觉得「我本地播了,但别人没看见」。
一个综合护栏示例
// 客户端:请求施法
UFUNCTION(Server, Reliable, WithValidation)
void ServerCastSpell(int32 SpellId, FVector Target);
bool ServerCastSpell_Validate(int32 SpellId, FVector Target)
{
if (SpellId < 0 || SpellId >= SpellDataTable->GetRowCount()) return false;
if (!IsAlive() || IsStunned()) return false;
if (SpellCooldowns.Contains(SpellId)) return false; // 冷却
return true;
}
void ServerCastSpell_Implementation(int32 SpellId, FVector Target)
{
if (!SpendMana(SpellId)) return; // 服务器二次校验(法力)
MulticastPlaySpellVFX(SpellId, Target); // 全场表现
ApplySpellEffects(SpellId, Target); // 真实效果
}
注意层次:Validate 做「请求合法性」过滤,Implementation 里再做一次「状态校验」(因为 Validate 和真实执行之间还有时间差,别的逻辑可能改了状态)。
日志规范:出问题先看这个
给每个关键 RPC 加带端信息的日志:
UE_LOG(LogGame, Log, TEXT("[Server] %s cast %d from %s"),
*GetName(), SpellId, HasAuthority() ? TEXT("Auth") : TEXT("Client"));
线上事故复盘时,「谁在哪个端调了什么、当时 Role 是什么」这条日志能省几小时。
心得:上线前过一遍所有 Server RPC 的 Validate——这是反作弊和防 bug 性价比最高的一次 review。我们曾靠这一步抓到「客户端可以给自己发未拥有的技能」这种测试环境根本想不到的漏洞。
