如何为已检查物品构建安全的 game inventory system database integration
概要
本文介绍了如何为游戏中的已检查物品(inspected items)构建安全的 game inventory system 数据库集成。文章分析了从检查到存入 inventory 的生命周期,并提供了在 Unreal Engine 中使用 C++ 实现 server-authoritative 验证的核心逻辑。此外,本文还对比了关系型与文档型数据库的 schema 设计,并给出了防范 client-side 作弊的后端验证与同步方案。最后,总结了基于距离校验、幂等 token 等保证数据安全与同步效率的最佳实践。
你的玩家会利用任何漏洞来复制稀有物品,而 client-authoritative 状态转换是他们最喜欢的攻击目标。在恐怖游戏、冒险游戏和 RPG 中,检查物品的行为——比如拾取钥匙、在 3D 空间中旋转它寻找线索,并将其存入玩家 inventory——是一个经典机制。如果你的游戏 client 直接决定何时将物品添加到 inventory 而不经过服务器验证,那么像 Cheat Engine 或 Fiddler 这样简单的 packet injection 工具就能欺骗 client,针对玩家甚至还没见过的物品发送“add item”信号。为了防止这种情况,开发者必须实现一个健壮的 game inventory system database integration,将 client-side 交互与 server-authoritative 逻辑及安全的云端持久化连接起来。
The Inspection-to-Inventory Lifecycle
要构建安全的 inventory 同步,我们首先必须拆解物品的 inspection 生命周期。该序列协调了物理世界中的 actor、本地化的 client-side 检查状态、server-authoritative 的 inventory 组件以及数据库存储。
- Interaction Detection: 玩家在游戏世界中接近一个物理 actor (
AInspectableActor)。line trace 或碰撞体(collision volume)将该对象标记为可交互。 - Inspection Mode Transition: 玩家按下交互键。client 进入本地化的 inspection 状态,锁定角色移动,将对象旋转至专用的 screen-space coordinates 容器中,并渲染 2D 描述悬浮窗(description overlay)。
- Verification Stage: 玩家点击“Take”。client 不会直接将物品添加到 inventory,而是向服务器发送 interaction token 请求。
- Server Validation: 服务器确认玩家处于 actor 的物理交互半径(约 250 Unreal 单位)内,且该 actor 处于激活状态。
- Database Integration: 服务器将物品添加到 inventory 数组中,将更新写入持久化数据库,并为该世界 actor 广播销毁事件(destruction event)。
如果你正在开发一款 multiplayer 游戏,在服务器上管理此状态至关重要。如果未能正确配置自定义 inventory 组件的 replication 和 network authority,许多团队在 Unreal Engine 中就会遇到 multiplayer inventory nightmares with swapped actor component owners in Unreal Engine。
Writing the Unreal Engine C++ Inspection Logic
为了实现这一流程,我们将创建三个组件:一个 interface (IInspectableInterface)、一个可被检查的 actor (AInspectableActor),以及一个 replicated 玩家 inventory 组件 (UInventoryComponent)。
以下是概述 actor 如何接收 inspection 命令的 interface 头文件:
// InspectableInterface.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "InspectableInterface.generated.h"
UINTERFACE(MinimalAPI)
class UInspectableInterface : public UInterface
{
GENERATED_BODY()
};
class HORIZON_GAME_API IInspectableInterface
{
GENERATED_BODY()
public:
virtual void OnInspectStarted(APlayerController* InspectingPlayer) = 0;
virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) = 0;
};
接下来,让我们实现 AInspectableActor 类。该类处理游戏世界中的物理对象,存储其唯一标识符、最大交互距离以及状态。
// InspectableActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InspectableInterface.h"
#include "InspectableActor.generated.h"
UCLASS()
class HORIZON_GAME_API AInspectableActor : public AActor, public IInspectableInterface
{
GENERATED_BODY()
public:
AInspectableActor();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
FString ItemDisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
float MaxInteractionDistance;
virtual void OnInspectStarted(APlayerController* InspectingPlayer) override;
virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) override;
protected:
virtual void BeginPlay() override;
private:
bool bIsBeingInspected;
TWeakObjectPtr<APlayerController> CurrentInspectingPlayer;
};
以下是 inspectable actor 的实现文件:
// InspectableActor.cpp
#include "InspectableActor.h"
#include "GameFramework/PlayerController.h"
AInspectableActor::AInspectableActor()
{
PrimaryActorTick.bCanEverTick = false;
bIsBeingInspected = false;
MaxInteractionDistance = 250.0f;
ItemID = "item_default";
ItemDisplayName = "Generic Item";
}
void AInspectableActor::BeginPlay()
{
Super::BeginPlay();
}
void AInspectableActor::OnInspectStarted(APlayerController* InspectingPlayer)
{
if (!InspectingPlayer || bIsBeingInspected) return;
APawn* PlayerPawn = InspectingPlayer->GetPawn();
if (!PlayerPawn) return;
float Distance = FVector::Dist(PlayerPawn->GetActorLocation(), GetActorLocation());
if (Distance > MaxInteractionDistance)
{
UE_LOG(LogTemp, Warning, TEXT("Player too far to inspect %s"), *GetName());
return;
}
bIsBeingInspected = true;
CurrentInspectingPlayer = InspectingPlayer;
}
void AInspectableActor::OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake)
{
if (InspectingPlayer != CurrentInspectingPlayer.Get()) return;
if (bWantsToTake && HasAuthority())
{
UActorComponent* InvComp = InspectingPlayer->GetComponentByClass(UInventoryComponent::StaticClass());
if (InvComp)
{
UInventoryComponent* Inventory = Cast<UInventoryComponent>(InvComp);
if (Inventory)
{
Inventory->Server_TryAddInspectedItem(this);
}
}
}
bIsBeingInspected = false;
CurrentInspectingPlayer.Reset();
}
现在,让我们创建 UInventoryComponent,它负责管理玩家的 inventory 列表,并通过网络将这些数据 replicate 给 client 玩家。
// InventoryComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InventoryComponent.generated.h"
class AInspectableActor;
USTRUCT(BlueprintType)
struct FInventoryItem
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
int32 Quantity;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
FString InspectedTimestamp;
};
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class HORIZON_GAME_API UInventoryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInventoryComponent();
UFUNCTION(Server, Reliable, WithValidation)
void Server_TryAddInspectedItem(AInspectableActor* TargetActor);
bool AddItemToLocalState(FName InItemID, int32 Quantity);
void SaveInventoryToDatabase();
protected:
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Inventory")
TArray<FInventoryItem> Items;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
以及定义 RPC replication 逻辑和写入验证的实现文件:
// InventoryComponent.cpp
#include "InventoryComponent.cpp"
#include "InspectableActor.h"
#include "Net/UnrealNetwork.h"
UInventoryComponent::UInventoryComponent()
{
SetIsReplicatedByDefault(true);
}
void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UInventoryComponent, Items);
}
bool UInventoryComponent::Server_TryAddInspectedItem_Validate(AInspectableActor* TargetActor)
{
if (!TargetActor) return false;
APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (!OwnerPawn) return false;
// Server-side distance check
float Distance = FVector::Dist(OwnerPawn->GetActorLocation(), TargetActor->GetActorLocation());
return Distance <= TargetActor->MaxInteractionDistance;
}
void UInventoryComponent::Server_TryAddInspectedItem_Implementation(AInspectableActor* TargetActor)
{
if (!TargetActor) return;
AddItemToLocalState(TargetActor->ItemID, 1);
SaveInventoryToDatabase();
TargetActor->Destroy();
}
bool UInventoryComponent::AddItemToLocalState(FName InItemID, int32 Quantity)
{
for (FInventoryItem& Item : Items)
{
if (Item.ItemID == InItemID)
{
Item.Quantity += Quantity;
return true;
}
}
FInventoryItem NewItem;
NewItem.ItemID = InItemID;
NewItem.Quantity = Quantity;
NewItem.InspectedTimestamp = FDateTime::UtcNow().ToString();
Items.Add(NewItem);
return true;
}
void UInventoryComponent::SaveInventoryToDatabase()
{
// Database integration trigger goes here
}
Designing the Inventory Database Schema
一旦服务器验证玩家确实检查了该物品,就必须对这一状态变化进行持久化。在关系型数据库(SQL)和文档型数据库(NoSQL)之间进行选择,会改变你构建 database schemas 的方式。
| 评估指标 | 关系型数据库 (PostgreSQL) | 文档存储 (NoSQL / MongoDB) |
|---|---|---|
| Data Structure | 规范化表,严格的外键约束 | 嵌套的键值文档与数组 |
| Transaction Safety | 开箱即用的完整 ACID 兼容性 | 原子操作限制在单个文档内 |
| Query Complexity | 高(物品查询需要 SQL JOIN 查询) | 低(直接查询玩家 profile) |
| Scalability | 纵向扩展(需要手动 sharding 来扩展) | 横向扩展(内置 sharding 能力) |
Relational Schema (PostgreSQL)
在关系型数据库中,你需要将玩家、全局物品元数据(metadata)以及活跃的 inventory 列表进行解耦,以避免数据冗余。这需要三张表:
CREATE TABLE players (
player_id VARCHAR(64) PRIMARY KEY,
username VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE game_items (
item_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(100) NOT NULL,
item_type VARCHAR(50) DEFAULT 'QuestItem'
);
CREATE TABLE inventory_items (
id SERIAL PRIMARY KEY,
player_id VARCHAR(64) REFERENCES players(player_id) ON DELETE CASCADE,
item_id VARCHAR(64) REFERENCES game_items(item_id),
quantity INTEGER CHECK (quantity > 0),
inspected_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(player_id, item_id)
);
Document Schema (NoSQL)
在文档数据库中,你将玩家的 inventory 存储为玩家核心文档(core document)中的嵌套数组。这允许你的 backend 仅通过单次查询即可检索玩家的完整状态:
{
"_id": "usr_90a82b3d11ef",
"username": "SurvivorX",
"inventory": [
{
"item_id": "key_rusted_01",
"quantity": 1,
"inspected_timestamp": "2026-06-25T00:02:20Z"
},
{
"item_id": "document_diary_03",
"quantity": 1,
"inspected_timestamp": "2026-06-25T00:05:12Z"
}
],
"last_updated": "2026-06-25T00:05:12Z"
}
The Sync Dilemma: Client-Side vs Server-Authoritative Saves
同步此状态会改变玩家的游戏体验以及系统防作弊的鲁棒性。如果 client-side 代码直接保存到数据库,玩家就可以修改内存地址来向数据库写入任意值。
为了防止这种情况,你必须实现 server-authoritative 验证:
- Interaction Tokens: 当玩家开始检查物品时,服务器会生成一个临时的 interaction token(60 秒内有效)并将其注册在活跃的世界 actor 实例上。
- Double-Spend Protection: 当玩家拾取物品时,服务器会消耗该 token。如果收到重复的拾取 packet,服务器将予以拒绝。
- Synchronous Replication: 确保 inventory 的变更立即推送给 client 显示。
此外,为了实时向连接的游戏 client 推送 inventory UI 的视觉更新,依赖繁重的 polling 机制会阻塞服务器的 tick rate。相反,你应该放弃 HTTP polling 并实现专用的 Websockets 连接来进行实时 inventory 同步。
Implementing the Backend Verification Code
如果你选择编写自己的自定义服务器,你需要一个能够安全处理数据库更新的 API endpoint。以下是使用 PostgreSQL 的 Node.js Express backend 代码片段。该脚本处理了 connection pooling、transaction isolation 以及 token consumption 来写入拾取的物品:
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
const dbPool = new Pool({
connectionString: process.env.DATABASE_URL,
});
app.post('/api/v1/inventory/add', async (req, res) => {
const { playerId, itemId, interactionToken } = req.body;
if (!playerId || !itemId || !interactionToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
try {
// 1. Verify interaction token exists and is active
const tokenResult = await dbPool.query(
'SELECT status FROM interactions WHERE token = $1 AND player_id = $2',
[interactionToken, playerId]
);
if (tokenResult.rows.length === 0 || tokenResult.rows[0].status !== 'active') {
return res.status(400).json({ error: 'Invalid or expired interaction token' });
}
// 2. Begin transaction
await dbPool.query('BEGIN');
// Consume the token to prevent double-spending
await dbPool.query(
"UPDATE interactions SET status = 'consumed' WHERE token = $1",
[interactionToken]
);
// Insert or increment inventory record
await dbPool.query(
`INSERT INTO inventory_items (player_id, item_id, quantity, inspected_timestamp)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (player_id, item_id)
DO UPDATE SET quantity = inventory_items.quantity + 1`,
[playerId, itemId]
);
await dbPool.query('COMMIT');
return res.status(200).json({ success: true });
} catch (error) {
await dbPool.query('ROLLBACK');
console.error('Database transaction error:', error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
自己构建这些基础设施会带来很高的开发维护成本。你必须配置 load balancers、部署数据库集群、编写自定义的 client-side 重试管理器,并构建安全的身份验证协议。在写下第一行游戏主循环(game loop)代码之前,这很容易就耗费 4 到 6 周的基础设施开发时间。
Simplifying State Persistence with horizOn
与其编写自定义的 Express 中间件、管理 PostgreSQL 连接池以及处理网络掉线,你可以将这些复杂性卸载给 horizOn。通过 horizOn,你将获得专为游戏开发设计的完全托管的数据库解决方案。
使用其云端数据库功能,你无需编写任何 backend 脚本来处理状态验证。你可以直接使用客户端库从 server-authoritative 的 Unreal Engine 服务器实例中触发持久化数据库写入:
void UInventoryComponent::SaveInventoryToCloud(FName InItemID, int32 InQuantity)
{
TSharedPtr<FJsonObject> RequestData = MakeShareable(new FJsonObject());
RequestData->SetStringField("player_id", PlayerID);
RequestData->SetStringField("item_id", InItemID.ToString());
RequestData->SetNumberField("quantity", InQuantity);
// Single call to [horizOn](https://horizon.pm)'s secure database client
FHorizonClient::Get()->Database("inventories")
->Upsert(RequestData)
->OnSuccess(this, &UInventoryComponent::OnSaveSuccess)
->OnFailure(this, &UInventoryComponent::OnSaveFailure)
->Execute();
}
这会以事务性(transactionally)更新数据库,确保玩家无法在其 client 上伪造数据,并在此玩家遭遇临时网络中断时自动在本地缓存更新。
Best Practices for Persistent Game Inventories
要构建高性能的 inventory 系统,请践行以下最佳实践:
- Enforce Distance-Based Validation on the Server: 在添加物品之前,在服务器端检查 pawn 与 inspectable actor 之间的
FVector::Dist。如果距离在物理上是不可能的,请将其记录为可疑的 client 活动。 - Utilize Idempotent Transaction Tokens: 在开始检查物品时,在服务器端生成唯一的交易 UUID。这可以防止在网络封包因延迟抖动(latency spikes)而重试时发生重复增加的 bug。
- Use Optimistic Local Updates with Server Correction: 为了让 UI 响应更即时,可以在 client-side 立即更新玩家的 inventory 界面,但保持“Pending Confirmation(待确认)”状态,直到服务器返回写入成功的确认。
- Log State Anomalies: 监控 client 尝试拾取在其活动会话(active session)中未被标记为已检查(inspected)物品的频率,因为这是 cheat injection 工具的主要特征。
准备好扩展你的 multiplayer backend 了吗?免费试用 horizOn 或查阅我们的开发者文档以立即开始。
Source: How do you put an item into the inventory after inspecting it?