返回博客

如何为已检查物品构建安全的 game inventory system database integration

发布于 2026年6月25日
如何为已检查物品构建安全的 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 组件以及数据库存储。

  1. Interaction Detection: 玩家在游戏世界中接近一个物理 actor (AInspectableActor)。line trace 或碰撞体(collision volume)将该对象标记为可交互。
  2. Inspection Mode Transition: 玩家按下交互键。client 进入本地化的 inspection 状态,锁定角色移动,将对象旋转至专用的 screen-space coordinates 容器中,并渲染 2D 描述悬浮窗(description overlay)。
  3. Verification Stage: 玩家点击“Take”。client 不会直接将物品添加到 inventory,而是向服务器发送 interaction token 请求。
  4. Server Validation: 服务器确认玩家处于 actor 的物理交互半径(约 250 Unreal 单位)内,且该 actor 处于激活状态。
  5. 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 验证:

  1. Interaction Tokens: 当玩家开始检查物品时,服务器会生成一个临时的 interaction token(60 秒内有效)并将其注册在活跃的世界 actor 实例上。
  2. Double-Spend Protection: 当玩家拾取物品时,服务器会消耗该 token。如果收到重复的拾取 packet,服务器将予以拒绝。
  3. 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 系统,请践行以下最佳实践:

  1. Enforce Distance-Based Validation on the Server: 在添加物品之前,在服务器端检查 pawn 与 inspectable actor 之间的 FVector::Dist。如果距离在物理上是不可能的,请将其记录为可疑的 client 活动。
  2. Utilize Idempotent Transaction Tokens: 在开始检查物品时,在服务器端生成唯一的交易 UUID。这可以防止在网络封包因延迟抖动(latency spikes)而重试时发生重复增加的 bug。
  3. Use Optimistic Local Updates with Server Correction: 为了让 UI 响应更即时,可以在 client-side 立即更新玩家的 inventory 界面,但保持“Pending Confirmation(待确认)”状态,直到服务器返回写入成功的确认。
  4. 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?