Skip to content

沙盒

PRE 是 Payment Gateway 的沙盒环境。它跑的代码跟生产环境一样,但对应的是独立的数据库、独立的渠道账户与模拟资金,因此你可以在不动用真实金流的情况下构建并验证接入。客户经理应该已经为你开通一组沙盒 merchant_id、一组沙盒 api_key,并将你的来源 IP 加入网络层白名单。如果这三项你没有齐全,先停下来联系客户经理,再继续往下读。

本章假设你已经读过 认证与签名,并在自选语言中已经有可运作的签名实现。

环境 URL

沙盒与生产环境是部署在不同主机名上的独立部署。它们共用 schema、响应信封与错误代码,但其他一切都不共用——订单、余额、渠道路由与凭证全部隔离。

沙盒 (PRE)生产环境 (OL)
API basehttps://pre-api.open-pay.cohttps://api.open-pay.co
收银台https://pre-cashier.open-pay.cohttps://cashier.open-pay.co
商户后台https://pre-merchant.open-pay.cohttps://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 验证:

bash
#!/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
python
#!/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
<?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']);
typescript
#!/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));

接着发送请求:

bash
# 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&currency_code=THB&merchant_order_no=SANDBOX-0001&timestamp=${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 会填入付款人应该转账的银行信息:

json
{
  "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 而非真实渠道的保证信号。生产环境的响应永远不会带这个标志。

完整的信封契约(codemessagedata)以及非 2xx 状态返回的错误代码目录,见 错误

沙盒特有的行为

沙盒跑的后端代码跟生产环境一样,但背后的数据与渠道不同。你需要纳入规划的差异:

  • 资金是模拟的。 没有真实金流经过沙盒。记到你商户账户上的余额只是账上分录。不要用沙盒去验证账务对账跟银行对账单的一致性。
  • 可以使用 mock channel。 Payment Gateway 运维团队可以把你的沙盒商户路由到内部的 mock channel adapter。路由到 mock channel 的订单会返回确定性的泰国银行账户信息,并出现在 Payment Gateway 工程师使用的内部 mock-channel UI 中。你可以用 payment_info.mock === true 辨识 mock 订单。MockAdapter 是内部服务——不对商户开放,也没有给商户使用的入口。
  • 真实渠道在沙盒中不一定可用。 部分上游渠道(TGCPay、MPay 等)不对外提供沙盒端点。你的沙盒商户可用的渠道集合可能比生产环境小。跟客户经理确认你的沙盒接了哪些渠道,据此安排集成测试。
  • 状态转换可以调整。 运维团队可以把沙盒渠道设置为返回指定状态(PENDINGCOMPLETEDFAILED)以利测试。如果你需要在沙盒商户上重现特定失败模式,跟客户经理讲,不要等真实渠道刚好出错。
  • 回调照常触发。 沙盒会根据每笔订单上送的 callback_url 发送回调,签名方式跟生产环境相同。使用一个你可控的、对外可达的 HTTPS 端点——request-bin、打进你 dev box 的 ngrok tunnel,或是你自家基础设施中的沙盒专用主机都可以。
  • 速率限制可能不同。 沙盒通常比生产环境宽松。不要用沙盒验证你是否能保持在生产环境的速率限制以下;压测纪律必须在设计阶段就做,而不是靠对 PRE 连续打点。
  • 沙盒数据可能会被清掉。 后台可能定期清理沙盒订单、账户与余额。把沙盒状态当成短暂的。

请求侧没有 is_sandboxmode=test 之类的标志。环境完全由 base URL 与凭证决定。

沙盒与生产环境对照

面向沙盒 (PRE)生产环境 (OL)
API base URLhttps://pre-api.open-pay.cohttps://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 到版控。它们刻意比生产环境密钥少权限,但风险纪律一样:泄漏的密钥会被轮换,而轮换会中断你的测试。

常见陷阱

下面是商户开始接沙盒时最常见的失败样态。动工前先扫过这份清单——每一项都是真的有人开过工单。

  1. 拿沙盒 api_keyapi.open-pay.co 生产环境会用 401 Unauthorized 拒绝沙盒密钥。确认你的 base URL 与密钥前缀对得起来。常见成因是你自家 CI 中 staging 与 production 部署之间的环境变量互相污染。
  2. 不在 Cloudflare Access 白名单上。 如果你每次请求都得到 HTML 响应(不是 JSON),或 TLS handshake 直接失败,你是在网络层被挡。从一个你确定在白名单上的 IP 测一次;如果那边能通,就是你的新 IP 还没被加进去。联系客户经理。
  3. 把沙盒余额当真实余额用。 沙盒资金是模拟的。不要用沙盒余额去验证财报跟银行对账单的一致性,也不要拿沙盒吞吐量去推算营收。
  4. 在生产环境代码路径硬编码沙盒 base URL。 Base URL 从配置读。没人 review 就躺在你 codebase 里的 pre-api.open-pay.co 字符串,是「我们不小心把 prod 指到沙盒」这类事故最常见的单一成因。
  5. 沙盒重置后重用 merchant_order_no 当后台清理沙盒数据时,你之前用过的 ID 如果重播同一份 fixture 可能撞号。要么把沙盒订单号加上跑次专属的前缀(例如 timestamp 或 CI build ID),要么接受 fixture 重用可能会冒出重复订单错误。
  6. 把生产环境的回调 URL 指向沙盒流量。 沙盒事件落到你生产环境的回调 handler,会污染你的生产环境日志、生产环境对账报表,可能还会污染你的生产环境账务。每笔沙盒订单都务必提供一个沙盒专用的 callback_url
  7. 同一个客户端里混用沙盒与生产环境凭证。 单一 HTTP client 实例同时握两把密钥、靠标志选一把,是脆弱的设计。明确命名后建立两个 client,永远不要让配置「掉到」错的那一个。
  8. 以为沙盒渠道的时序跟生产环境渠道一样。 Mock channel 在毫秒级响应。生产环境的真实渠道可能要几秒(甚至更久)才确认一笔充值。不要写针对沙盒调过的超时或轮询间隔;要针对你预期支持的最慢真实渠道调。
  9. 没测 5 分钟 timestamp 视窗。 沙盒对 X-Timestamp 视窗的执行跟生产环境一样。如果你的测试 fixture 用冻结时钟,一旦超过 5 分钟漂移,你的签名请求就会开始失败。每个请求都要重新生成 X-Timestamp
  10. message 文字而非 code 做分支。 错误消息的文字会变。信封中的 code 字段才是稳定契约。错误代码目录见 错误
  11. 在沙盒中忘了验证回调签名。 沙盒回调的签名方式跟生产环境完全一样。如果你在沙盒 handler 跳过签名验证、理由是「反正只是测试」,你会把同一段代码带到生产环境。两个环境都要验。

交叉参考

  • 认证与签名 — 请求签名、5 分钟 timestamp 视窗,以及四种语言的参考签名实现。
  • 快速开始 — 对沙盒做第一笔充值与回调的端到端走查。
  • 错误 — JSON 信封结构,以及你应该用来做分支的 code 值目录。
  • API 参考 — 完整端点清单、请求/响应 schema,以及字段层级的说明。