沙盒
PRE 是 Payment Gateway 的沙盒环境。它跑的代码跟生产环境一样,但对应的是独立的数据库、独立的渠道账户与模拟资金,因此你可以在不动用真实金流的情况下构建并验证接入。客户经理应该已经为你开通一组沙盒 merchant_id、一组沙盒 api_key,并将你的来源 IP 加入网络层白名单。如果这三项你没有齐全,先停下来联系客户经理,再继续往下读。
本章假设你已经读过 认证与签名,并在自选语言中已经有可运作的签名实现。
环境 URL
沙盒与生产环境是部署在不同主机名上的独立部署。它们共用 schema、响应信封与错误代码,但其他一切都不共用——订单、余额、渠道路由与凭证全部隔离。
| 沙盒 (PRE) | 生产环境 (OL) | |
|---|---|---|
| API base | https://pre-api.open-pay.co | https://api.open-pay.co |
| 收银台 | https://pre-cashier.open-pay.co | https://cashier.open-pay.co |
| 商户后台 | https://pre-merchant.open-pay.co | https://merchant.open-pay.co |
沙盒凭证会被生产环境拒绝,反之亦然。切换环境只需要改一行 base URL——你的签名代码、JSON schema 与 HTTP 标头都不需要变动。
访问控制
沙盒的来源 IP 控管在 Cloudflare 边缘 完成。请求必须通过这一层,才会抵达应用代码。搞清楚是不是这一层挡掉你,对调试非常重要,因为它的失败特征跟应用层错误很不一样。
Cloudflare Access(网络层)
pre-api.open-pay.co 位于 Cloudflare Access 之后,背后是由 Payment Gateway 后台维护的来源 IP 白名单。从不在白名单上的 IP 发出的请求永远到不了应用程序——Cloudflare 在边缘节点就拦截掉,并回传 TLS handshake 失败,或是一个 HTML 验证页面或封锁页面(不是 JSON 信封)。
Cloudflare 层被挡的特征:HTML 响应(Content-Type: text/html)或 TCP 连接被拒。没有 code 字段可以分支判断,因为 Payment Gateway 的代码根本没跑。
要新增、移除或轮换 Cloudflare Access 白名单上的 IP,联系你的客户经理。不要假设你的沙盒流量从新办公室、新机房、新 CI runner pool 一定能通——在 IP 加进去之前都不会通。
你的第一个沙盒请求
下面是一个对沙盒创建充值订单的完整示例。端点、标头与 body schema 跟生产环境完全相同——只有 base URL 与 api_key 不同。
签名与发送
用 认证与签名 章节提供的 helper 计算签名。参考实现已对后端逐 byte 验证:
#!/usr/bin/env bash
# MD5 signature for Payment Gateway merchant API (bash reference).
#
# Usage:
# ./sign.sh --api-key <key> --params 'k1=v1&k2=v2&...'
set -euo pipefail
API_KEY=""
PARAMS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--api-key) API_KEY="$2"; shift 2 ;;
--params) PARAMS="$2"; shift 2 ;;
*) echo "Unknown flag: $1" >&2; exit 2 ;;
esac
done
# Sort key=value pairs ASCII order (C locale), drop empty trailing field
SORTED=$(printf '%s' "$PARAMS" | tr '&' '\n' | LC_ALL=C sort | grep -v '^$' | tr '\n' '&' | sed 's/&$//')
SIGN_STRING="${SORTED}${API_KEY}"
# MD5: md5sum on Linux/CI, md5 on macOS
if command -v md5sum >/dev/null 2>&1; then
printf '%s' "$SIGN_STRING" | md5sum | cut -d' ' -f1
else
printf '%s' "$SIGN_STRING" | md5
fi#!/usr/bin/env python3
"""Reference implementation: MD5 signature for Payment Gateway merchant API.
Usage:
python sign.py --api-key <key> --params 'k1=v1&k2=v2&...'
Algorithm:
1. Parse params into a dict.
2. Sort keys alphabetically (ASCII order).
3. Build query string 'k=v&k=v', skipping None values.
4. Append api_key directly (no '&').
5. MD5 the UTF-8 bytes; output lowercase hex.
"""
import argparse
import hashlib
import sys
from urllib.parse import parse_qsl
def create_signature(params: dict[str, str], api_key: str) -> str:
sorted_items = sorted(params.items())
query_string = "&".join(f"{k}={v}" for k, v in sorted_items if v is not None)
return hashlib.md5((query_string + api_key).encode("utf-8")).hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--api-key", required=True)
parser.add_argument("--params", required=True)
args = parser.parse_args()
params = dict(parse_qsl(args.params, keep_blank_values=True))
print(create_signature(params, args.api_key))
return 0
if __name__ == "__main__":
sys.exit(main())<?php
/**
* MD5 signature for Payment Gateway merchant API (PHP reference).
*
* Usage:
* php sign.php --api-key=<key> --params='k1=v1&k2=v2&...'
*
* Note: getopt() requires --flag=value form (no space between flag and value).
*/
$opts = getopt('', ['api-key:', 'params:']);
if (!isset($opts['api-key']) || !isset($opts['params'])) {
fwrite(STDERR, "Usage: php sign.php --api-key=<key> --params='k=v&...'\n");
exit(2);
}
parse_str($opts['params'], $params);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) {
if ($v === null) continue;
$parts[] = "$k=$v";
}
echo md5(implode('&', $parts) . $opts['api-key']);#!/usr/bin/env node
/**
* MD5 signature for Payment Gateway merchant API (Node.js / TypeScript reference).
*
* Usage:
* tsx sign.ts --api-key <key> --params 'k1=v1&k2=v2&...'
*/
import { createHash } from "crypto";
function parseArgs(argv: string[]): { apiKey: string; params: string } {
let apiKey = "", params = "";
for (let i = 2; i < argv.length; i++) {
if (argv[i] === "--api-key") apiKey = argv[++i];
else if (argv[i] === "--params") params = argv[++i];
}
if (!apiKey || !params) {
process.stderr.write("Usage: sign.ts --api-key <key> --params 'k=v&...'\n");
process.exit(2);
}
return { apiKey, params };
}
function createSignature(paramsQuery: string, apiKey: string): string {
const parsed = new Map<string, string>();
for (const pair of paramsQuery.split("&")) {
if (!pair) continue;
const eq = pair.indexOf("=");
const k = eq >= 0 ? pair.slice(0, eq) : pair;
const v = eq >= 0 ? pair.slice(eq + 1) : "";
parsed.set(k, v);
}
const sorted = [...parsed.entries()].sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0
);
const query = sorted.map(([k, v]) => `${k}=${v}`).join("&");
return createHash("md5").update(query + apiKey, "utf8").digest("hex");
}
const { apiKey, params } = parseArgs(process.argv);
process.stdout.write(createSignature(params, apiKey));接着发送请求:
# Replace these with the values from your account manager.
API_KEY="pre_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE_URL="https://pre-api.open-pay.co"
TIMESTAMP=$(date +%s)
# Build the params (alphabetical order is for signing, not for transport).
PARAMS="amount=100.00&callback_url=https://your-domain.example/callbacks/deposit¤cy_code=THB&merchant_order_no=SANDBOX-0001×tamp=${TIMESTAMP}"
SIGNATURE=$(./sign.sh --api-key "$API_KEY" --params "$PARAMS")
curl -X POST "${BASE_URL}/api/v1/merchant/orders/deposit" \
-H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-H "X-Timestamp: ${TIMESTAMP}" \
-H "X-Signature: ${SIGNATURE}" \
-d '{
"merchant_order_no": "SANDBOX-0001",
"amount": "100.00",
"currency_code": "THB",
"callback_url": "https://your-domain.example/callbacks/deposit"
}'预期响应
成功创建充值订单会返回 HTTP 201 Created 与标准信封。路由到渠道的订单会带上 payment_url,而 payment_info 会填入付款人应该转账的银行信息:
{
"success": true,
"code": "SUCCESS",
"message": "OK",
"data": {
"order_no": "DEP20260519000001",
"amount": "100.00",
"currency_code": "THB",
"expire_at": "2026-05-19T08:15:00Z",
"payment_url": "https://pre-cashier.open-pay.co/pay/...",
"payment_info": {
"bank_code": "KBANK",
"bank_name": "ธนาคารกสิกรไทย (Kasikorn Bank)",
"account_no": "1234567890",
"account_name": "Mock Payment Co., Ltd.",
"mock": true
},
"bank_code": "KBANK",
"bank_name": "ธนาคารกสิกรไทย (Kasikorn Bank)",
"account_no": "1234567890",
"account_holder": "Mock Payment Co., Ltd."
}
}payment_info 内的 "mock": true 标志是你判别订单被路由到 mock channel 而非真实渠道的保证信号。生产环境的响应永远不会带这个标志。
完整的信封契约(code、message、data)以及非 2xx 状态返回的错误代码目录,见 错误。
沙盒特有的行为
沙盒跑的后端代码跟生产环境一样,但背后的数据与渠道不同。你需要纳入规划的差异:
- 资金是模拟的。 没有真实金流经过沙盒。记到你商户账户上的余额只是账上分录。不要用沙盒去验证账务对账跟银行对账单的一致性。
- 可以使用 mock channel。 Payment Gateway 运维团队可以把你的沙盒商户路由到内部的 mock channel adapter。路由到 mock channel 的订单会返回确定性的泰国银行账户信息,并出现在 Payment Gateway 工程师使用的内部 mock-channel UI 中。你可以用
payment_info.mock === true辨识 mock 订单。MockAdapter 是内部服务——不对商户开放,也没有给商户使用的入口。 - 真实渠道在沙盒中不一定可用。 部分上游渠道(TGCPay、MPay 等)不对外提供沙盒端点。你的沙盒商户可用的渠道集合可能比生产环境小。跟客户经理确认你的沙盒接了哪些渠道,据此安排集成测试。
- 状态转换可以调整。 运维团队可以把沙盒渠道设置为返回指定状态(
PENDING、COMPLETED、FAILED)以利测试。如果你需要在沙盒商户上重现特定失败模式,跟客户经理讲,不要等真实渠道刚好出错。 - 回调照常触发。 沙盒会根据每笔订单上送的
callback_url发送回调,签名方式跟生产环境相同。使用一个你可控的、对外可达的 HTTPS 端点——request-bin、打进你 dev box 的 ngrok tunnel,或是你自家基础设施中的沙盒专用主机都可以。 - 速率限制可能不同。 沙盒通常比生产环境宽松。不要用沙盒验证你是否能保持在生产环境的速率限制以下;压测纪律必须在设计阶段就做,而不是靠对 PRE 连续打点。
- 沙盒数据可能会被清掉。 后台可能定期清理沙盒订单、账户与余额。把沙盒状态当成短暂的。
请求侧没有 is_sandbox 或 mode=test 之类的标志。环境完全由 base URL 与凭证决定。
沙盒与生产环境对照
| 面向 | 沙盒 (PRE) | 生产环境 (OL) |
|---|---|---|
| API base URL | https://pre-api.open-pay.co | https://api.open-pay.co |
| 可用渠道 | mock + 已开放沙盒的真实渠道 | 全部真实渠道 |
| 资金 | 模拟,无真实流动 | 真实流动 |
| 凭证 | 仅沙盒使用的 api_key | 生产环境的 api_key |
| 网络访问 | Cloudflare Access IP 白名单(后台维护) | Cloudflare Access IP 白名单 + 标准认证 |
| 数据生命周期 | 运维可能清掉 | 持久保存,受监管 |
| 速率限制 | 通常较宽松 | 套用生产环境限额 |
| 回调 | 依每笔订单发到你的 callback_url | 依每笔订单发到你的 callback_url |
两栏之间的签名规则、JSON 信封结构、字段名称与错误代码完全相同。你客户端代码在沙盒与生产环境之间有任何差异,那都是配置差异,不是逻辑差异。
变更凭证或 IP
要轮换沙盒 api_key、要在 Cloudflare Access 新增或移除来源 IP、要在你的商户底下开通额外的沙盒子账户:联系客户经理。
不要把沙盒凭证 commit 到版控。它们刻意比生产环境密钥少权限,但风险纪律一样:泄漏的密钥会被轮换,而轮换会中断你的测试。
常见陷阱
下面是商户开始接沙盒时最常见的失败样态。动工前先扫过这份清单——每一项都是真的有人开过工单。
- 拿沙盒
api_key打api.open-pay.co。 生产环境会用401 Unauthorized拒绝沙盒密钥。确认你的 base URL 与密钥前缀对得起来。常见成因是你自家 CI 中 staging 与 production 部署之间的环境变量互相污染。 - 不在 Cloudflare Access 白名单上。 如果你每次请求都得到 HTML 响应(不是 JSON),或 TLS handshake 直接失败,你是在网络层被挡。从一个你确定在白名单上的 IP 测一次;如果那边能通,就是你的新 IP 还没被加进去。联系客户经理。
- 把沙盒余额当真实余额用。 沙盒资金是模拟的。不要用沙盒余额去验证财报跟银行对账单的一致性,也不要拿沙盒吞吐量去推算营收。
- 在生产环境代码路径硬编码沙盒 base URL。 Base URL 从配置读。没人 review 就躺在你 codebase 里的
pre-api.open-pay.co字符串,是「我们不小心把 prod 指到沙盒」这类事故最常见的单一成因。 - 沙盒重置后重用
merchant_order_no。 当后台清理沙盒数据时,你之前用过的 ID 如果重播同一份 fixture 可能撞号。要么把沙盒订单号加上跑次专属的前缀(例如 timestamp 或 CI build ID),要么接受 fixture 重用可能会冒出重复订单错误。 - 把生产环境的回调 URL 指向沙盒流量。 沙盒事件落到你生产环境的回调 handler,会污染你的生产环境日志、生产环境对账报表,可能还会污染你的生产环境账务。每笔沙盒订单都务必提供一个沙盒专用的
callback_url。 - 同一个客户端里混用沙盒与生产环境凭证。 单一 HTTP client 实例同时握两把密钥、靠标志选一把,是脆弱的设计。明确命名后建立两个 client,永远不要让配置「掉到」错的那一个。
- 以为沙盒渠道的时序跟生产环境渠道一样。 Mock channel 在毫秒级响应。生产环境的真实渠道可能要几秒(甚至更久)才确认一笔充值。不要写针对沙盒调过的超时或轮询间隔;要针对你预期支持的最慢真实渠道调。
- 没测 5 分钟 timestamp 视窗。 沙盒对
X-Timestamp视窗的执行跟生产环境一样。如果你的测试 fixture 用冻结时钟,一旦超过 5 分钟漂移,你的签名请求就会开始失败。每个请求都要重新生成X-Timestamp。 - 依
message文字而非code做分支。 错误消息的文字会变。信封中的code字段才是稳定契约。错误代码目录见 错误。 - 在沙盒中忘了验证回调签名。 沙盒回调的签名方式跟生产环境完全一样。如果你在沙盒 handler 跳过签名验证、理由是「反正只是测试」,你会把同一段代码带到生产环境。两个环境都要验。