跳到主要内容

网页支付 — PHP SDK

适用于使用 PHP 后端的联合运营(标准)或独家/直接运营(Expub)游戏
包Packagist 上的 alo-game/paymentsdk
最新版本2.2.0(更新日志)
要求PHP ≥ 8.1
实现完整实现网页支付 — 标准(目前仅有英文版)契约——外加网页支付 — Expub(目前仅有英文版)(包含 Expub 共用的移动端内购(目前仅有英文版))

本页包含在 PHP 中集成 WebPay 所需的全部信息。你不需要先阅读网页支付 — 标准或网页支付 — Expub(两页均目前仅有英文版)——那两页描述的是团队手写自己接口时所依据的原始 HTTP 契约。这个 SDK 已经替你实现了两套契约:每次调用都保证字段名正确、签名方案正确、响应格式正确。你只需要编写少量 PHP 函数,永远不必直接接触 HTTP、签名或 JSON。

这个包提供两个相互独立的模块——按你游戏的运营模式二选一,而不是默认两个都装:

  • WebPay(见下文)——面向联合运营(co-pub)游戏,三个钩子,HMAC-SHA256。
  • Expub(见下文)——面向独家/直接运营(expub)游戏,四个钩子,MD5 或 HMAC-SHA256(取决于你的游戏在 Console 中的配置),同时也覆盖该游戏的移动端内购。
不是另一套契约

无论你适用哪个模块,底层用的都是对应运营模式下所有游戏使用的同一套契约——同样的调用,同样的报文格式。这个 SDK 只是让你不必再手写那套报文格式。

SDK 对比手写契约​

手写这套报文契约本 SDK
签名、时间戳校验自己实现、自己测试WebpayHandler/ExpubHandler 已经实现好,已经测试过
字段名(plat_order_num、order_id、productid/productId、……)照着文档手动敲——这里一旦悄悄写错,往往要等到真实玩家的订单失败了才会暴露有类型的 DTO 属性($request->orderId、……)——写错字段名会是 PHP 的致命错误,而不是上线后才发现的静默不一致
响应格式(errcode 报文体 vs. HTTP 状态码)按每套契约自己拼出完全一致的结构CheckUidResult/CreateOrderResult/PaymentReceivedResult 替你构建
你需要写的业务逻辑全部:解析、签名、响应格式,加上你自己的玩家/订单查询只有你自己的玩家/订单查询——各个 on... 钩子
把调用路由到处理方法两种方式都一样——PHP 没有运行时路由器供包挂载两种方式都一样
幂等性(可安全重试)两种方式都是你的责任——SDK 无法知道"已经发货"在你的数据库里意味着什么两种方式都是你的责任

这个 SDK 消除的是纯转录风险的部分——签名方案、字段名、响应格式——而对那些原本就不属于机械劳动的部分不做改变:你自己的数据库查询,以及哪条路由分发到哪个处理方法。它让你需要做对的这部分表面更小、更有类型检查,而不是让你需要思考的代码量变少。

我该用哪个模块?​

规则与契约选择表(目前仅有英文版)一致:取决于你游戏的运营模式,而不是接入时间的早晚。

  • 联合运营(co-pub)游戏 → WebPay 模块。玩家手动输入自己的游戏内 UID,不涉及任何 Alogame 关联账号。
  • 独家/直接运营(expub)游戏 → Expub 模块,同时覆盖它的网页充值页面和移动端内购——玩家此时已经登录了自己的 Alogame 账号,这正是 Alogame 能直接拉取角色列表、而不需要玩家手动输入 UID 的原因。

一个游戏只会是其中一种,不会两者都是——如果不确定该用哪个,写代码之前先问你的 Alogame 运营对接人。

WebPay 模块(网页支付 — 标准)​

流程​

以下每一次调用都由 Alogame 发起。玩家的充值动作从 Alogame Portal 开始——你的服务器从不主动调用 Alogame,只负责响应。

请求与响应参考​

以下内容是实际在线路上传输的数据——SDK 会替你完成全部构建与解析,但了解其结构有助于你排查日志或与 Alogame 运营对接排查不一致的地方。每次调用都是 POST,已签名(x-timestamp + x-signature,参见身份认证,目前仅有英文版),且必须在 8 秒内响应——响应过慢会被视为失败,对 onPaymentReceived 而言意味着会触发重试(见下文)。

onCheckUid — 玩家在充值页面输入 UID​

报文接口: check_uid

CheckUidRequest 属性报文字段类型说明
$uiduidstring玩家输入的游戏内角色 UID
$serverIdserver_idstring|null仅当你实现了 ServerListHookInterface 时才会出现
$gameIdgame_idstring|null仅当 Alogame 将该合作方拆分为多个商店条目时才会出现(较少见——见下方 ServerInfo/平台说明)
{ "uid": "100002078" }

返回 CheckUidResult::found($nickname, $server) 或 CheckUidResult::notFound()。SDK 会替你把它转换成对应的报文响应:

// 找到
{ "errcode": 0, "msg": "success", "data": { "nickname": "DragonSlayer", "server": "Server 1" } }

// 未找到
{ "errcode": 1, "msg": "user not found" }
为什么 "user not found" 这个字符串必须精确

Alogame 判断角色是否缺失,靠的是对 msg 做不区分大小写匹配,只要包含子串 "not found" 或 "not exist" 即可——其他任何内容都会作为通用系统错误展示给玩家,而不是"请检查你的 UID"。CheckUidResult::notFound() 已经返回了符合规则的措辞,你永远不需要自己写这个字符串。

onGetCharacterList — UID 校验通过,玩家选择角色(可选)​

报文接口: get_character_list
需要 SDK ≥ 2.1.0——如果你还在 2.0.x,请先执行 composer update alo-game/paymentsdk,否则下面这个接口并不存在。

只有当你的钩子对象实现了 CharacterListHookInterface 时才会被调用——如果在你的游戏里一个 UID 只对应一个角色,可以完全跳过这一节。与 ServerListHookInterface 相互独立:可以只实现其中一个、两个都实现,或者都不实现。

GetCharacterListRequest 属性报文字段类型说明
$uiduidstringonCheckUid 已经校验通过的 UID——角色列表按它来划分
$serverIdserver_idstring|null仅当你同时实现了 ServerListHookInterface 时才会出现
$gameIdgame_idstring|null较少见——见上方 onCheckUid 的说明
{ "uid": "100002078", "server_id": "1" }

返回一个 CharacterInfo 数组,SDK 会替你转换成对应的报文响应:

{
"errcode": 0,
"data": [
{ "characterId": "c-1001", "name": "DragonSlayer" },
{ "characterId": "c-1002", "name": "IronMage" }
]
}

这里没有需要上报的失败情形,原因和 onGetServerList 一样:不存在的 UID 已经在 onCheckUid 阶段被拒绝了,而一个有效 UID 返回空数组是完全合法的答案(玩家只是看不到角色选择而已)。玩家选中的 characterId 会随下一次 onCreateOrder 调用,以 $request->characterId 的形式传给你。

onCreateOrder — 玩家确认了礼包,尚未进入支付界面​

报文接口: create_order

CreateOrderRequest 属性报文字段类型说明
$orderIdplat_order_numstringAlogame 自己的订单编号——请存下来,onPaymentReceived 会用它作为关联键找回这笔订单
$uiduidstring与 onCheckUid 已经验证过的同一个 UID
$productIdproductidstring你自己的商品 ID,与提交给 Alogame 时完全一致
$amountamountinteger订单总额,单位 VND,不含小数
$sandboxsandboxinteger(0/1)开发/预发布环境为 1,生产环境为 0——由 Alogame 根据环境自动设置,不是你需要配置的项
$serverIdserver_idstring|null仅当按服务器划分时才会出现
$characterIdcharacter_idstring|nullSDK ≥ 2.1.0。 仅当你实现了 CharacterListHookInterface 时才会出现——玩家选中的角色。请发放给这个角色,而不是仅凭 $uid 推断出来的角色
$gameIdgame_idstring|null较少见——见 onCheckUid 下方的说明
{
"uid": "100002078",
"plat_order_num": "260509161539010042",
"productid": "pack_500_gold",
"amount": 49000,
"sandbox": 0
}

返回 CreateOrderResult::created($yourOwnOrderCode)。$yourOwnOrderCode 是必填的——SDK 的报文响应里始终会带上它:

{ "errcode": 0, "msg": "success", "data": { "order_num": "PARTNER-ORD-20260315-001" } }
按 $request->orderId 去重

如果 Alogame 重试了 create_order(网络抖动、超时),同一个 plat_order_num 会到达两次。此时请返回与第一次相同的 order_num——不要为同一个 plat_order_num 重复创建订单。

onPaymentReceived — 支付渠道已确认收款,立即发放道具​

报文接口: payment_notify

PaymentReceivedRequest 属性报文字段类型说明
$orderIdplat_order_numstringAlogame 的订单编号,与 onCreateOrder 中的值相同
$orderCodeorder_numstring你自己的订单编号,即 onCreateOrder 返回的那个
$amountamountinteger订单总额,单位 VND
$gameIdgame_idstring|null较少见——见 onCheckUid 下方的说明

报文体里还带有一个 status 字段(恒为 1,表示"支付成功")——SDK 没有把它暴露在 PaymentReceivedRequest 上,因为目前它没有其他取值。

{
"plat_order_num": "260509161539010042",
"order_num": "PARTNER-ORD-20260315-001",
"amount": 49000,
"status": 1
}

发货完成后返回 PaymentReceivedResult::ok(),失败则返回 PaymentReceivedResult::failed($reason):

// ok
{ "errcode": 0 }
必须保证幂等——这个调用会被自动重试

只要不是 PaymentReceivedResult::ok()——包括你的接口超时——都会触发重试。Alogame 最多会重试 4 次总共:首次调用,之后分别在 60 秒、120 秒、120 秒后重试。

请以 $request->orderCode(或 $request->orderId)为依据匹配,对已经发货过的订单返回 ok()——永远不要重复发放道具,也永远不要对一个已完成的订单返回 failed(),否则重试循环会一直针对一个实际已经完成的订单继续跑下去。

前置条件​

写代码之前,先向你的 Alogame 运营对接人获取:

  • 一个共享的 HMAC secret_key——这是本模块实际使用的唯一凭证(直接传入 WebpayHandler 的构造函数)。game_code 本身从不出现在任何请求或响应体中,SDK 也没有对应的参数——把你的 secret_key/base URL 与某个游戏关联起来的,是 Alogame 自己的 Console 配置,而不是你发送的任何内容。
  • 哪个环境(开发/预发布 还是 生产)会最先调用你

1. 安装​

公开包,无需任何凭证:

composer require alo-game/paymentsdk

源码:github.com/alo-game/alogame-paymentsdk-php(MIT 许可,更新日志)。

2. 实现三个钩子​

这是你唯一需要实现的接口。是 Alogame 调用这些方法——你永远不会调用 Alogame:

use Alogame\PaymentSdk\WebPay\Contracts\WebpayHookInterface;
use Alogame\PaymentSdk\WebPay\Dto\{
CheckUidRequest, CheckUidResult,
CreateOrderRequest, CreateOrderResult,
PaymentReceivedRequest, PaymentReceivedResult,
};

final class MyGameHooks implements WebpayHookInterface
{
public function onCheckUid(CheckUidRequest $request): CheckUidResult
{
$player = MyPlayerRepository::findByUid($request->uid);

return $player
? CheckUidResult::found($player->nickname, $player->serverName)
: CheckUidResult::notFound();
}

public function onCreateOrder(CreateOrderRequest $request): CreateOrderResult
{
// $request->orderId 是 Alogame 自己的引用编号(报文里的 plat_order_num)
// ——存下来,onPaymentReceived 需要靠它找回这笔订单。
$order = MyOrderRepository::create(
alogameOrderId: $request->orderId,
uid: $request->uid,
productId: $request->productId,
amount: $request->amount,
);

return CreateOrderResult::created($order->id);
}

public function onPaymentReceived(PaymentReceivedRequest $request): PaymentReceivedResult
{
$order = MyOrderRepository::findByOwnId($request->orderCode);
if ($order === null) {
return PaymentReceivedResult::failed('unknown order');
}

MyInventory::deliver($order->uid, $order->productId);

return PaymentReceivedResult::ok();
}
}
两个钩子都必须能容忍被调用两次

onPaymentReceived 在未返回 ok 时(包括超时)最多会被重试 4 次——具体时间表和匹配依据见上方请求与响应参考。onCreateOrder 也有同样的要求:带有相同 $request->orderId 的重复调用必须返回与第一次相同的 orderCode,而不是创建第二笔订单。

3. 接好路由​

Alogame 调用的是三个不同的 URL,不是一个——handleCheckUid、handleCreateOrder、handlePaymentReceived 各自响应不同的调用,所以必须有代码来判断每个进来的请求该调用哪一个。这个 SDK 刻意做成了框架无关(它并不知道 Laravel 的路由器,只会做一次原始的 $_SERVER['REQUEST_URI'] 判断),所以这一步路由判断的管线工作留给了你——下面每个分支内部的处理,调用的方法早已帮你完整实现好了。这里展示的是纯 PHP 写法;在 Laravel/Symfony 里 match 会消失,变成三条路由定义,各自调用同一个处理方法:

use Alogame\PaymentSdk\WebPay\WebpayHandler;

$handler = new WebpayHandler(
secret: getenv('ALOGAME_WEBPAY_SECRET'),
hooks: new MyGameHooks(),
onError: fn (\Throwable $e) => error_log($e), // 绝不让 bug 泄漏到线路上
);

// 各自接到自己的路由——对原始 body 签名,永远不要对重新解析后的 body 签名
// (与其他所有契约同样的规则,见身份认证页面)。
$response = match (true) {
$_SERVER['REQUEST_URI'] === '/webpay/check-uid' => $handler->handleCheckUid(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/webpay/create-order' => $handler->handleCreateOrder(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/webpay/payment-received' => $handler->handlePaymentReceived(getallheaders(), file_get_contents('php://input')),
};

http_response_code($response->status);
header('Content-Type: application/json');
echo json_encode($response->body);

4. 可选:多服务器​

只有当你的游戏运行多个服务器、玩家充值前必须先选择一个服务器时才需要。实现第二个接口即可——Alogame 会自动识别,不需要另外配置:

use Alogame\PaymentSdk\WebPay\Contracts\ServerListHookInterface;
use Alogame\PaymentSdk\WebPay\Dto\{GetServerListRequest, ServerInfo};

final class MyGameHooks implements WebpayHookInterface, ServerListHookInterface
{
// ...onCheckUid / onCreateOrder / onPaymentReceived 同上...

public function onGetServerList(GetServerListRequest $request): array
{
return array_map(
static fn ($server) => new ServerInfo($server->id, $server->name),
MyServerRepository::all(),
);
}
}

再接一条第四路由到 $handler->handleGetServerList(...)。如果你的游戏只有一个共享服务器,可以完全跳过这一节。

5. 可选:一个 UID 对应多个角色​

需要 SDK ≥ 2.1.0。 只有当你的一个 UID 可能拥有多个角色、玩家必须选择由哪个角色接收充值时才需要。与第 4 步相互独立——可以只实现其中一个、两个都实现,或者都不实现。识别机制相同:Alogame 看的是接口,而不是某个配置开关。

use Alogame\PaymentSdk\WebPay\Contracts\CharacterListHookInterface;
use Alogame\PaymentSdk\WebPay\Dto\{CharacterInfo, GetCharacterListRequest};

final class MyGameHooks implements WebpayHookInterface, CharacterListHookInterface
{
// ...onCheckUid / onCreateOrder / onPaymentReceived 同上...

public function onGetCharacterList(GetCharacterListRequest $request): array
{
return array_map(
static fn ($character) => new CharacterInfo($character->id, $character->name),
MyCharacterRepository::forUid($request->uid, $request->serverId),
);
}
}

再接一条路由到 $handler->handleGetCharacterList(...)。玩家选中的值会随同一次 onCreateOrder 调用,以 $request->characterId 传给你——请发放给那个角色。如果在你的游戏里一个 UID 只对应一个角色,可以完全跳过这一节。

6. 接好健康检查​

再接一条路由——排在第 4、5 步中你实际需要的那些之后:

$response = $handler->handleHealthCheck(getallheaders(), file_get_contents('php://input'));

Alogame 可以随时调用它(已签名,与其他调用一样)来确认你的接口可访问,并且存档的密钥仍然匹配。请把这个路径连同其他路径一起交给 Alogame 运营。

7. 把接口地址给 Alogame​

按环境把以下信息发给 Alogame 运营:

  • Base URL + 四个必需路径(每个可选的选择器各再加一个:服务器列表、角色列表)
  • 确认前置条件中的 secret_key 已经接入

Alogame 运营会在他们那边配置好 Console——你不需要自己动 Console。

Alogame 的职责边界在哪里

Alogame 这一侧的契约,正是上面请求与响应参考所描述的全部内容:把正确的数据、以正确的格式,发送到你给出的路径,然后读回你的响应说了什么——仅此而已。onCheckUid/onCreateOrder/onPaymentReceived 之间如何处理这些数据(用哪个数据库、哪个玩家、哪笔订单)完全是你后端内部的事,Alogame 无法查看也无法验证。

上线前,Alogame 的 Console 测试工具会用你提供的一个 UID 调用你的 check-uid 路径,并把原始响应展示给你看。这只能证明该路径可达、且这一次调用的响应格式正确——它不能、也无法证明 onCheckUid 对任意 UID 都能正确查到你真实的玩家数据。这部分正确性完全需要你自己在第 8 步之前验证好,就像验证你后端其他任何部分一样。

8. 正式上线​

Alogame 会把你的配置切换为生效状态。从这一刻起,每次调用都是真实的玩家流量。

Expub 模块(网页支付 — Expub + 移动端内购)​

同时覆盖独家/直接运营游戏的网页充值页面和移动端内购——两个渠道完全共用 createOrder_url/exchange_url,所以同样两个钩子会响应两边的调用。

流程​

玩家只有在登录了自己的 Alogame 账号之后才会进入这个流程——与 WebPay 的联合运营流程不同,这里不涉及手动输入 UID。

这里没有 check-uid 这一步

和 WebPay 的 co-pub 流程不同,Alogame 自己的结账流程从不会对 expub 游戏调用 check-uid——玩家已经从 onGetUserList 的响应里选好了角色,uid 是否存在已经在这一步被证明过了。有些合作方的后端出于其他原因已经有独立的 check-uid 接口(比如这个 SDK 出现之前就有,或者他们自己的工具在用)——见下面的 onCheckUid,它是可选的,原因正是这个。

请求与响应参考​

每次调用都是 POST,并使用你的游戏在 Console 中配置的签名策略。ExpubHandler 会根据每个请求的请求头自动识别是哪一种——代码里无需任何配置:

策略Alogame 发送的内容结果的返回方式
MD5(已有的 expub 游戏)Signature 请求头,时间戳以秒为单位放在报文体里——参见身份认证(目前仅有英文版)每种结果对应一个 HTTP 状态码,报文体见下文
HMAC-SHA256(新接入推荐,SDK ≥ 2.2.0)x-timestamp(毫秒)+ x-signature 请求头,报文体里没有 timestamp——参见身份认证始终返回 200,报文为 {"errcode", "msg", "data"}

两种策略下的钩子和 DTO 完全相同——只有报文响应不同,而响应由 SDK 生成。HMAC 下:

钩子返回报文响应
onGetUserList → 角色列表{"errcode":0,"msg":"success","data":[{"uid","characterName","server"}]}
CheckUidResult::found(...) / notFound(){"errcode":0,...,"data":{"characterName","server"}} / {"errcode":1,"msg":"uid not found"}
CreateOrderResult::created($code) 或 duplicate($code){"errcode":0,"msg":"success","data":{"order_num":"<code>"}}
CreateOrderResult::productNotFound() / uidNotFound(){"errcode":1,"msg":"product not found"} / {"errcode":1,"msg":"uid not found"}
PaymentReceivedResult::ok() 或 alreadyProcessed(){"errcode":0,"msg":"success"}
PaymentReceivedResult::orderCodeNotFound(){"errcode":1,"msg":"order_code not found"}

重复订单和已发放的支付返回 errcode 0 是有意为之:它们是对已经成功的操作的重试。HMAC 下网页充值和移动端内购对部分字段的命名也不同(order_id 与 plat_order_num、productId 与 productid、order_code 与 order_num、price 与 amount)——SDK 两种都接受,所以你的钩子看到的始终是相同的 DTO 属性。

从 SDK ≤ 2.1 升级的 HMAC 游戏

2.2.0 之前 ExpubHandler 只校验 MD5。使用旧版本的 HMAC 游戏每次调用都会收到 401 SIGNATURE_INVALID("timestamp" 缺失或过期),因为 HMAC 的时间戳在请求头里,而不在报文体里。执行 composer update alo-game/paymentsdk 即可——无需修改代码。

onGetUserList — 玩家打开充值页面​

报文接口: getUserList_url

GetUserListRequest 属性报文字段类型说明
$userIduserIdstringAlogame 账号的数字 ID——与 Alogame 客户端 SDK 在登录时返回给你游戏的 userId 相同,不是 uuid。该账号关联的所有角色都应该被返回
$extInfoext_infostring|null由 SDK 转发的上下文信息
{ "userId": "1234", "ext_info": "{}" }

返回一个 UserCharacter 数组。空数组也是合法的——一个尚未创建角色的已关联账号也应返回 200。以下为 MD5 响应(HMAC 见上表):

{
"userId": "1234",
"uids": [
{ "uid": "char_001", "server": "Server 1", "characterName": "DragonSlayer" }
]
}

onCheckUid — 可选,仅当你的后端已经有这个接口时才实现​

报文接口: checkUser_url

可选——只有需要时才实现 CheckUidHookInterface

不属于 ExpubHookInterface。Alogame 自己的结账流程从不会对 expub 游戏调用它(见上面流程下的说明)——除非你的 hooks 类同时 implements CheckUidHookInterface,否则 ExpubHandler::handleCheckUid() 会返回 404 NOT_CONFIGURED。只有当你的后端出于其他原因已经有独立的 check-uid 接口时才需要实现它——比如这个 SDK 出现之前就有,或者你自己的工具在调用它——例如 oe-007 的 ?ac=check_uid。

CheckUidRequest 属性报文字段类型说明
$uiduidstring来自 onGetUserList 响应中的角色 UID
$extInfoext_infostring|null由 SDK 转发的上下文信息
{ "uid": "char_001", "ext_info": "{}" }

返回 CheckUidResult::found($characterName, $server) 或 CheckUidResult::notFound():

// 找到 — 200 OK
{ "characterName": "DragonSlayer", "server": "Server 1" }
这里的错误是 HTTP 状态码,不是 errcode

CheckUidResult::notFound() 会产生真正的 404,而不是带错误体的 200——与上面 WebPay 的响应格式正好相反。

onCreateOrder — 同一个钩子同时服务网页充值和移动端内购​

报文接口: createOrder_url —— 与移动端内购(目前仅有英文版)共用

CreateOrderRequest 属性报文字段类型说明
$orderIdorder_idstringAlogame 的订单编号——请存下来,onPaymentReceived 会用它匹配回这笔订单
$uiduidstring玩家在 onGetUserList 响应里选中的角色 UID
$productIdproductIdstring你自己的商品 ID
$amountpriceint|null订单总额——请依据 $osId 区分渠道,而不是依据这个字段是否存在
$serverIdserverIdstring|null仅当游戏按服务器划分时才会出现
$osIdos_idstring|null移动端内购购买时为 "ios"/"android",网页充值时为 null——这是唯一能区分两个渠道的字段
$extInfoext_infostring|null由 SDK 转发的上下文信息
{ "order_id": "alo_ord_1a2b3c4d", "uid": "char_001", "productId": "pack_500_gold", "ext_info": "{}" }

返回 CreateOrderResult::created($yourOwnOrderCode):

// 201 Created
{ "status": "success", "order_code": "PARTNER-ORD-20260315-001" }
按 $request->orderId 去重

如果 Alogame 重试,请返回 CreateOrderResult::duplicate($existingOrderCode)——SDK 会以 409 状态码返回原来的 order_code,绝不会创建新订单。

onPaymentReceived — 立即发放道具​

报文接口: exchange_url —— 与移动端内购(目前仅有英文版)共用

PaymentReceivedRequest 属性报文字段类型说明
$orderCodeorder_codestring你自己的订单编号,来自 onCreateOrder
$extInfoext_infostring|null由 SDK 转发的上下文信息
{ "order_code": "PARTNER-ORD-20260315-001", "ext_info": "{}" }

发货完成后返回 PaymentReceivedResult::ok():

// 200 OK
{ "processingStatus": "completed" }
必须保证幂等

对一笔已经发货过的订单,请返回 PaymentReceivedResult::alreadyProcessed()(409)——永远不要重复发放,也永远不要把一次重复调用当作 orderCodeNotFound()。

前置条件​

  • 一个共享的 secret(MD5 或 HMAC 均使用它),来自你的 Alogame 运营对接人——这是 ExpubHandler 唯一使用的凭证。
  • 确认这个游戏是 expub 而不是 co-pub——如果不确定,见上文我该用哪个模块?。

1. 安装​

与上面 WebPay 使用同一个包,无需单独安装:

composer require alo-game/paymentsdk

2. 实现三个必需的钩子(如果你已经有 check-uid 接口,再加上它)​

use Alogame\PaymentSdk\Expub\Contracts\ExpubHookInterface;
use Alogame\PaymentSdk\Expub\Dto\{
GetUserListRequest, UserCharacter,
CreateOrderRequest, CreateOrderResult,
PaymentReceivedRequest, PaymentReceivedResult,
};

final class MyExpubGameHooks implements ExpubHookInterface
{
public function onGetUserList(GetUserListRequest $request): array
{
// 该 Alogame 账号关联的所有角色——玩家会在 Alogame 那一侧从中选择一个
// 再充值。返回空数组也合法(账号已关联,但还没有角色)。
return MyCharacterRepository::findAllByAlogameUserId($request->userId);
}

public function onCreateOrder(CreateOrderRequest $request): CreateOrderResult
{
// $request->osId 在移动端内购购买时为 "ios"/"android",网页充值时为
// null——同一个方法,如果发货逻辑不同就据此分支。
$existing = MyOrderRepository::findByAlogameOrderId($request->orderId);
if ($existing !== null) {
// Alogame 重试了——返回同一个 order_code,不要创建新订单。
return CreateOrderResult::duplicate($existing->orderCode);
}

$order = MyOrderRepository::create(
alogameOrderId: $request->orderId,
uid: $request->uid,
productId: $request->productId,
);

return CreateOrderResult::created($order->id);
}

public function onPaymentReceived(PaymentReceivedRequest $request): PaymentReceivedResult
{
$order = MyOrderRepository::findByOwnId($request->orderCode);
if ($order === null) {
return PaymentReceivedResult::orderCodeNotFound();
}

MyInventory::deliver($order->uid, $order->productId);

return PaymentReceivedResult::ok();
}
}
两个钩子都必须能容忍被调用两次

与上面 WebPay 同样的要求:onCreateOrder 必须对重复的 $request->orderId 返回同一个 orderCode,onPaymentReceived 必须对已经发货过的 $request->orderCode 返回 ok(),绝不重复发货。

3. 接好路由​

四个不同的 URL,这套契约没有健康检查路由(与 WebPay 不同)——Alogame 目前不会为 expub 游戏调用健康检查:

use Alogame\PaymentSdk\Expub\ExpubHandler;

$handler = new ExpubHandler(
secret: getenv('ALOGAME_EXPUB_SECRET'),
hooks: new MyExpubGameHooks(),
onError: fn (\Throwable $e) => error_log($e),
);

$response = match (true) {
$_SERVER['REQUEST_URI'] === '/expub/get-user-list' => $handler->handleGetUserList(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/expub/check-uid' => $handler->handleCheckUid(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/expub/create-order' => $handler->handleCreateOrder(getallheaders(), file_get_contents('php://input')),
$_SERVER['REQUEST_URI'] === '/expub/payment-received' => $handler->handlePaymentReceived(getallheaders(), file_get_contents('php://input')),
};

http_response_code($response->status);
header('Content-Type: application/json');
echo json_encode($response->body);

不管你有没有实现 CheckUidHookInterface,check-uid 这条路由都照样接好——handleCheckUid() 自己会在你的 hooks 类没实现时返回 404 NOT_CONFIGURED,这两种情况下都是正确的响应。

4. 可选:check-uid​

只有当你的后端出于其他原因已经有独立的 check-uid 接口时才需要——Alogame 自己的结账流程从不会对 expub 游戏调用它(见上面流程)。实现第二个接口——ExpubHandler 会自动识别,不需要额外配置:

use Alogame\PaymentSdk\Expub\Contracts\CheckUidHookInterface;
use Alogame\PaymentSdk\Expub\Dto\{CheckUidRequest, CheckUidResult};

final class MyExpubGameHooks implements ExpubHookInterface, CheckUidHookInterface
{
// ...onGetUserList / onCreateOrder / onPaymentReceived 同上...

public function onCheckUid(CheckUidRequest $request): CheckUidResult
{
$character = MyCharacterRepository::findByUid($request->uid);

return $character
? CheckUidResult::found($character->name, $character->server)
: CheckUidResult::notFound();
}
}

如果你的后端没有理由暴露这个接口,整节跳过即可。

5. 把接口地址给 Alogame​

按环境把 base URL + 全部四个路径发给 Alogame 运营,并确认前置条件中的 secret 已经接入。Alogame 运营会在他们那边配置好 Console。

6. 正式上线​

Alogame 会把你的配置切换为生效状态。从这一刻起,无论是网页充值还是移动端内购,每次调用都是真实的玩家流量。

升级​

运行 composer update 之前先看一下更新日志——这个包运行在你的生产后端里,和其他任何依赖一样需要谨慎对待。两个模块在同一个包里各自独立地升版本,更新日志会注明某次发布具体改动了哪一个。

当前版本:2.2.0。 相对 2.1.x 是纯新增——没有任何需要迁移的地方,所有已有钩子的签名保持不变。Expub 模块现在也支持使用 HMAC-SHA256 签名的游戏(见 Expub 模块的“请求与响应参考”);MD5 游戏不受任何影响。

2.1.0 为 WebPay 模块新增了可选的 onGetCharacterList 钩子(以及 onCreateOrder 上的 $characterId),面向一个 UID 拥有多个角色的游戏。

handleHealthCheck() 会返回 WebPay 模块自身的契约版本(包版本 2.1.0 对应 1.1.0,此前为 1.0.0)——Alogame 支持团队正是通过它来判断你的部署是否已经支持角色列表。

实施清单​

WebPay 模块:

  • composer require alo-game/paymentsdk 成功
  • 已经针对你自己的数据实现了 onCheckUid、onCreateOrder、onPaymentReceived
  • onCreateOrder 和 onPaymentReceived 能安全地接受同一笔订单被重复调用
  • 三条路由已接好(每个可选的选择器——服务器列表、角色列表——各再加一条),且都传递原始请求体
  • 健康检查路由已接好,路径已发给 Alogame 运营
  • Base URL + 路径 + 密钥确认信息已发给 Alogame 运营
  • 已通过 Alogame 上线前的测试调用

Expub 模块:

  • composer require alo-game/paymentsdk 成功
  • 已经针对你自己的数据实现了 onGetUserList、onCreateOrder、onPaymentReceived
  • 实现了 onCheckUid 并加上了 CheckUidHookInterface——仅当你的后端已经有独立的 check-uid 接口时才需要
  • onCreateOrder 对重复的 order_id 返回 duplicate(),而不是创建新订单
  • onPaymentReceived 对重复的 order_code 返回 alreadyProcessed(),而不是二次发货
  • 四条路由已接好,且都传递原始请求体
  • Base URL + 全部四个路径 + 密钥确认信息已发给 Alogame 运营
  • 如果这个游戏有移动端 App,移动端内购已接入同一套 createOrder_url/exchange_url