Skip to content

支付异步通知(CP 回调)

Novacoreall 负责在订单支付成功或退款完成后,向游戏 CP 方配置的 回调地址 发起 HTTP POST 异步通知。本文说明 CP 方需要实现的回调接口格式,并提供 PHPGo 验签示例。

说明:Nova 支付体系采用独立的 JSON + HMAC-SHA256 协议,由本服务统一投递。

整体流程

从「创建订单」到「客户端收到付款结果」,典型链路如下(以应用内调起 Google 等商店支付为例)。

图示为 产品视角;其中向游戏服务器下发的 异步 HTTP 回调即本文描述的 CP 回调。退款等场景在服务侧确认后同样会走异步通知与重试,由平台统一投递。

  1. 创建订单:游戏客户端通过 SDK 向 SDK 服务端发起支付/下单,服务端创建订单并将 平台订单号返回客户端(并与 CP 侧的 reference_id 等对账字段关联)。
  2. 商店支付:客户端向 Google 等支付平台调起收银台,用户完成付款后客户端拿到平台返回的 支付 token(或等价票据)
  3. 验单并完成支付:客户端将 token 提交给 SDK 服务端;服务端向商店服务端 校验 token,通过后在本平台 完成支付履约(落单状态、发放权益等)。
  4. 异步通知 CP:SDK 服务端在支付成功确认后(以及退款等在服务侧结案后),向游戏 CP 配置的 notify_url 异步 POST(本文后续字段与签名约定);此为 服务端到服务端,不经过玩家设备。
  5. CP 处理与回包:游戏服务器 验签、发货或更新订单,并应答 HTTP 2xx;非 2xx 将触发投递方重试(具体策略以服务实现为准)。
  6. 客户端结束流程:游戏服务器在完成业务后以 服务端推送、长连接下发或客户端轮询订单等方式,将支付结果告知 游戏客户端,玩家侧流程结束。

与图示对应:玩家在 NovaSDK 完成验单与订单状态确认后,由平台异步向 CP 配置的 notify_url 投递支付/退款结果;图示中的「SDK 服务端」即面向客户端与 CP 的统一接入与结算层。

有效回调地址优先级:订单级 notify_url > 应用后台配置的 notify_url

请求说明

基本信息

项目
方法POST
Content-Typeapplication/json
超时服务端请求超时约 3 秒,请 CP 接口尽快返回
触发场景订单 status = 1(支付成功)或 status = 4(退款)

请求头

请求头说明
NOVA-X-Callback-App-Id应用 ID,与 Body 中 app_id 一致
NOVA-X-Callback-TimestampUTC 毫秒时间戳,与 Body 中 timestamp 一致
NOVA-X-Callback-Sign签名值(小写十六进制)
NOVA-X-Callback-Sign-Method固定为 hmac-sha256

请求体(JSON)

参数名类型说明
order_idstringNova 订单号
app_idnumber应用 ID
uidnumber玩家 UID
reference_idstringCP 订单参考号(对应创建订单时的 reference)
extensionstring扩展字段
timestampnumberUTC 毫秒时间戳
statusnumber订单状态:1 支付成功,4 已退款(与平台订单表一致)
payment_platformstring支付渠道,如 googleapple
goods_idnumber商品 ID,对应创建订单时传入的 goods_id

示例 Body:

json
{
  "order_id": "20250718112706471433",
  "app_id": 10001,
  "uid": 1003,
  "reference_id": "8f8bfa08-6471-ab96-8107-252407b67c80",
  "extension": "8f8bfa08-6471-ab96-8107-252407b67c80",
  "timestamp": 1753174571860,
  "status": 1,
  "payment_platform": "google",
  "goods_id": 1001
}

签名验证

签名密钥为应用后台配置的 app_secret

算法步骤

  1. 取参与签名的参数(不含 sign 字段本身):app_idextensiongoods_idorder_idpayment_platformreference_idstatustimestampuid
  2. 将各参数转为字符串后,按参数名 字典序(升序) 排列。
  3. 拼接为 key1=value1&key2=value2&...(无 URL 编码)。
  4. 对上述字符串做 HMAC-SHA256,密钥为 app_secret,输出 小写十六进制 字符串。
  5. 与请求头 NOVA-X-Callback-Sign 比较(建议大小写不敏感比较)。

参与签名的 timestampapp_iduid 必须与 Body JSON 中的值一致(均为十进制字符串形式参与拼接)。

签名原文示例

参数:

app_id=10001
extension=8f8bfa08-6471-ab96-8107-252407b67c80
goods_id=1001
order_id=20250718112706471433
payment_platform=google
reference_id=8f8bfa08-6471-ab96-8107-252407b67c80
status=1
timestamp=1753174571860
uid=1003

拼接串:

app_id=10001&extension=8f8bfa08-6471-ab96-8107-252407b67c80&goods_id=1001&order_id=20250718112706471433&payment_platform=google&reference_id=8f8bfa08-6471-ab96-8107-252407b67c80&status=1&timestamp=1753174571860&uid=1003

响应要求

条件说明
成功HTTP 状态码 200–299
失败非 2xx 状态码,或请求超时、网络错误

响应 Body 无固定格式要求;服务以 HTTP 状态码 判断是否成功(HTTP 2xx 视为接收成功)。

重试策略

次数间隔(相对上次失败)
第 1 次立即
第 2 次15 秒
第 3 次1 分钟

最多重试 3 次。仍失败则订单 notify_status 标记为失败,可在后台排查或人工补发。可在控制台结合订单维度排查通知失败原因;本地联调可使用下文提供的 HTTP 接收示例与 mock 服务。

PHP 接收示例

php
<?php
declare(strict_types=1);

/**
 * Nova 支付异步通知接收示例
 * 配置:应用后台 app_secret
 */
$appSecret = 'your_app_secret_here';

$rawBody = file_get_contents('php://input') ?: '';
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
    http_response_code(400);
    echo json_encode(['ok' => false, 'message' => 'invalid json']);
    exit;
}

$headerAppId = $_SERVER['HTTP_NOVA_X_CALLBACK_APP_ID'] ?? '';
$headerTimestamp = $_SERVER['HTTP_NOVA_X_CALLBACK_TIMESTAMP'] ?? '';
$headerSign = $_SERVER['HTTP_NOVA_X_CALLBACK_SIGN'] ?? '';
$signMethod = $_SERVER['HTTP_NOVA_X_CALLBACK_SIGN_METHOD'] ?? '';

if (strcasecmp($signMethod, 'hmac-sha256') !== 0) {
    http_response_code(403);
    echo json_encode(['ok' => false, 'message' => 'unsupported sign method']);
    exit;
}

// 参与签名的字段须与 Body 一致,并转为字符串
$signParams = [
    'app_id'            => (string) ($payload['app_id'] ?? ''),
    'extension'         => (string) ($payload['extension'] ?? ''),
    'goods_id'          => (string) ($payload['goods_id'] ?? ''),
    'order_id'          => (string) ($payload['order_id'] ?? ''),
    'payment_platform'  => (string) ($payload['payment_platform'] ?? ''),
    'reference_id'      => (string) ($payload['reference_id'] ?? ''),
    'status'            => (string) ($payload['status'] ?? ''),
    'timestamp'         => (string) ($payload['timestamp'] ?? ''),
    'uid'               => (string) ($payload['uid'] ?? ''),
];

ksort($signParams);

$parts = [];
foreach ($signParams as $key => $value) {
    $parts[] = "{$key}={$value}";
}
$signPlain = implode('&', $parts);
$expectedSign = hash_hmac('sha256', $signPlain, $appSecret);

if (!hash_equals(strtolower($expectedSign), strtolower($headerSign))) {
    http_response_code(403);
    echo json_encode(['ok' => false, 'message' => 'invalid sign']);
    exit;
}

// 可选:校验 Header 与 Body 一致
if ($headerAppId !== (string) $signParams['app_id']
    || $headerTimestamp !== $signParams['timestamp']) {
    http_response_code(400);
    echo json_encode(['ok' => false, 'message' => 'header mismatch']);
    exit;
}

$orderId = $signParams['order_id'];
$referenceId = $signParams['reference_id'];

// TODO: 幂等发货 — 根据 order_id / reference_id 查本地订单,避免重复发货
// if (alreadyDelivered($orderId)) { ... }

http_response_code(200);
echo json_encode(['ok' => true, 'order_id' => $orderId]);

Go 接收示例

go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sort"
	"strings"
)

type callbackPayload struct {
	OrderID          string `json:"order_id"`
	AppID            any    `json:"app_id"`
	UID              any    `json:"uid"`
	ReferenceID      string `json:"reference_id"`
	Extension        string `json:"extension"`
	Timestamp        any    `json:"timestamp"`
	Status           any    `json:"status"`
	PaymentPlatform  string `json:"payment_platform"`
	GoodsID          any    `json:"goods_id"`
}

func main() {
	const appSecret = "your_app_secret_here"

	http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
		defer r.Body.Close()

		var payload callbackPayload
		if err := json.Unmarshal(body, &payload); err != nil {
			http.Error(w, `{"ok":false,"message":"invalid json"}`, http.StatusBadRequest)
			return
		}

		if strings.ToLower(r.Header.Get("NOVA-X-Callback-Sign-Method")) != "hmac-sha256" {
			http.Error(w, `{"ok":false,"message":"unsupported sign method"}`, http.StatusForbidden)
			return
		}

		expected := buildNotifySignature(map[string]string{
			"app_id":            stringify(payload.AppID),
			"extension":         payload.Extension,
			"goods_id":          stringify(payload.GoodsID),
			"order_id":          payload.OrderID,
			"payment_platform":  payload.PaymentPlatform,
			"reference_id":      payload.ReferenceID,
			"status":            stringify(payload.Status),
			"timestamp":         stringify(payload.Timestamp),
			"uid":               stringify(payload.UID),
		}, appSecret)

		received := r.Header.Get("NOVA-X-Callback-Sign")
		if !strings.EqualFold(expected, received) {
			http.Error(w, `{"ok":false,"message":"invalid sign"}`, http.StatusForbidden)
			return
		}

		if r.Header.Get("NOVA-X-Callback-App-Id") != stringify(payload.AppID) ||
			r.Header.Get("NOVA-X-Callback-Timestamp") != stringify(payload.Timestamp) {
			http.Error(w, `{"ok":false,"message":"header mismatch"}`, http.StatusBadRequest)
			return
		}

		// TODO: 幂等发货
		_ = payload.OrderID
		_ = payload.ReferenceID

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{"ok":true}`))
	})

	_ = http.ListenAndServe(":8090", nil)
}

func buildNotifySignature(params map[string]string, secret string) string {
	keys := make([]string, 0, len(params))
	for key := range params {
		keys = append(keys, key)
	}
	sort.Strings(keys)

	parts := make([]string, 0, len(keys))
	for _, key := range keys {
		parts = append(parts, fmt.Sprintf("%s=%s", key, params[key]))
	}

	mac := hmac.New(sha256.New, []byte(secret))
	_, _ = mac.Write([]byte(strings.Join(parts, "&")))
	return fmt.Sprintf("%x", mac.Sum(nil))
}

func stringify(value any) string {
	switch v := value.(type) {
	case string:
		return v
	case float64:
		return fmt.Sprintf("%.0f", v)
	case json.Number:
		return v.String()
	default:
		return fmt.Sprintf("%v", v)
	}
}

下列命令在本地启动一个简易 HTTP 接收端,可与上文 PHP / Go 验签逻辑配合联调:

bash
go run ./cmd/mock-callback-receiver -addr :8090 -app-secret your_app_secret_here

常见问题

1. 如何区分支付成功与退款?

根据 Body 中的 status 判断:1 为支付成功,4 为已退款。也可结合 order_id 查询平台订单做二次校验。

2. 回调一直失败怎么办?

检查:notify_url 是否公网可达、是否返回 2xx、app_secret 是否与后台一致、防火墙是否放行。查看表 nova_app_order_notify 的历史请求记录辅助排查。

3. 幂等性要求

同一 order_id 可能因重试多次回调,CP 必须以 order_id(或 reference_id)做幂等,避免重复发货。