569 lines
24 KiB
PHP
569 lines
24 KiB
PHP
<?php
|
||
/**
|
||
* 债券在线充值 · 支付平台联控(彩虹易支付 RSA 版)
|
||
*
|
||
* 动作:
|
||
* - create_order : 校验登录态 → 生成订单 → RSA签名 → 返回跳转支付URL
|
||
* - notify : 平台异步回调(GET)→ 平台公钥验签 → 金额校验 → 幂等 → 写 web_transactions(recharge)
|
||
* - query_order : 前端补单/轮询订单状态
|
||
*
|
||
* ★ 安全:本文件不含任何密钥。密钥仅从 pay_secrets.php(git-ignored,仅定义常量)读取;
|
||
* 本地 txt 文件不会被代码引用,仅作人工本地备份。
|
||
*/
|
||
|
||
// 防止 PHP 错误/弃用警告以 HTML 形式污染 JSON / notify 纯文本响应
|
||
@ini_set('display_errors', '0');
|
||
@ini_set('html_errors', '0');
|
||
error_reporting(E_ALL);
|
||
|
||
// 防止任何前置输出污染 JSON / notify 纯文本响应
|
||
ob_start();
|
||
require_once __DIR__ . '/../core.php';
|
||
ob_end_clean();
|
||
|
||
// ===== 非机密常量(可安全提交) =====
|
||
// 彩虹易支付 下单网关
|
||
define('PAY_GATEWAY', 'https://zf.ypshidifu.cn/api/pay/submit');
|
||
// 异步通知地址(平台服务器回调,必须公网可达)
|
||
define('PAY_NOTIFY_URL', 'https://caoyuan.ypshidifu.cn/plugin/api/pay.php?action=notify');
|
||
// 同步跳转地址(玩家付款后浏览器跳回)
|
||
define('PAY_RETURN_URL', 'https://caoyuan.ypshidifu.cn/plugin/player.php?paid=1');
|
||
define('PAY_SIGN_TYPE', 'MD5'); // 提交用 MD5(商户 MD5 密钥 pay_user.key,末尾无点);用户新私钥数据损坏无法自验,暂用 MD5
|
||
|
||
// ===== 0.01 元测试配置(先跑通全链路,后续替换为 recharge_tiers 档位) =====
|
||
define('PAY_TEST_MONEY', '0.01'); // 元(字符串,与回调比对)
|
||
define('PAY_TEST_BONDS', 1); // 对应债券数量
|
||
define('PAY_TEST_NAME', '债券充值-测试0.01元');
|
||
|
||
// ====================================================================
|
||
// 密钥加载(仅使用 pay_secrets.php,严禁引用本地 txt 记录文件)
|
||
// ====================================================================
|
||
function loadPayKeys() {
|
||
$secretsFile = __DIR__ . '/pay_secrets.php';
|
||
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
||
@require_once $secretsFile;
|
||
if (defined('PAY_MCH_PRIVATE_KEY') && defined('PAY_PLATFORM_PUBLIC_KEY')) {
|
||
return [
|
||
'pid' => defined('PAY_PID') ? PAY_PID : '',
|
||
'private_key' => wrapPem(PAY_MCH_PRIVATE_KEY, 'private'),
|
||
'public_key' => wrapPem(PAY_PLATFORM_PUBLIC_KEY, 'public'),
|
||
'md5_key' => defined('PAY_MD5_KEY') ? PAY_MD5_KEY : '',
|
||
];
|
||
}
|
||
}
|
||
return ['pid' => '', 'private_key' => '', 'public_key' => '', 'md5_key' => ''];
|
||
}
|
||
|
||
/** 将裸 base64 包装为 PEM。私钥自动探测 PKCS#1(RSA PRIVATE KEY) / PKCS#8(PRIVATE KEY),公钥用 SubjectPublicKeyInfo(PUBLIC KEY) */
|
||
function wrapPem($b64, $type) {
|
||
$b64 = preg_replace('/\s+/', '', $b64);
|
||
if ($b64 === '') return '';
|
||
// 探测 DER:PKCS#8(PRIVATE KEY) 与 PKCS#1(RSA PRIVATE KEY) 都以
|
||
// SEQUENCE{INTEGER 0(version)} 开头(即 30 82 .. 02 01 00),无法靠前 7 字节区分。
|
||
// 关键在 version 之后紧跟的标签:PKCS#8 是 AlgorithmIdentifier 的 SEQUENCE(0x30),
|
||
// PKCS#1 是 modulus 的 INTEGER(0x02)。据此决定用哪个 PEM 头(opensslLoadKey 仍有兜底重试)。
|
||
$header = 'PUBLIC KEY';
|
||
if ($type === 'private') {
|
||
$raw = @base64_decode($b64, true);
|
||
$isPkcs1 = ($raw !== false && strlen($raw) > 8
|
||
&& $raw[0] === "\x30" && $raw[1] === "\x82"
|
||
&& $raw[4] === "\x02" && $raw[5] === "\x01" && $raw[6] === "\x00"
|
||
&& $raw[7] === "\x02");
|
||
$header = $isPkcs1 ? 'RSA PRIVATE KEY' : 'PRIVATE KEY';
|
||
}
|
||
$body = trim(chunk_split($b64, 64, "\n"));
|
||
return "-----BEGIN $header-----\n$body\n-----END $header-----\n";
|
||
}
|
||
|
||
/**
|
||
* 容错加载密钥:首次用给定 PEM 加载失败后,自动在 PKCS#1<->PKCS#8 头之间切换重试,
|
||
* 彻底规避「密钥格式与 PEM 头不匹配」导致的签名/验签失败(支付链路关键路径,必须健壮)。
|
||
*/
|
||
function opensslLoadKey($pem, $isPrivate) {
|
||
$fn = $isPrivate ? 'openssl_pkey_get_private' : 'openssl_pkey_get_public';
|
||
$key = @$fn($pem);
|
||
if ($key !== false) return $key;
|
||
if (!$isPrivate) return false;
|
||
// 私钥:在 RSA PRIVATE KEY <-> PRIVATE KEY 之间切换重试
|
||
$alt = preg_replace('/-----BEGIN RSA PRIVATE KEY-----/', "-----BEGIN PRIVATE KEY-----", $pem);
|
||
$alt = preg_replace('/-----END RSA PRIVATE KEY-----/', "-----END PRIVATE KEY-----", $alt);
|
||
if ($alt !== $pem && ($key = @$fn($alt)) !== false) return $key;
|
||
$alt = preg_replace('/-----BEGIN PRIVATE KEY-----/', "-----BEGIN RSA PRIVATE KEY-----", $pem);
|
||
$alt = preg_replace('/-----END PRIVATE KEY-----/', "-----END RSA PRIVATE KEY-----", $alt);
|
||
if ($alt !== $pem && ($key = @$fn($alt)) !== false) return $key;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 请求签名:根据 sign_type 选择算法
|
||
* - RSA : 取全部非空参数 → 剔除 sign/sign_type → ASCII 升序 → k=v& 拼接 → 私钥 SHA256WithRSA 签名
|
||
* - MD5 : 同上拼接后追加 &key=商户MD5密钥 → md5(彩虹易支付MD5方式,无需密钥对,可绕过RSA密钥不匹配)
|
||
*/
|
||
function buildSign($params, $keys) {
|
||
$filtered = [];
|
||
foreach ($params as $k => $v) {
|
||
if ($k === 'sign' || $k === 'sign_type') continue;
|
||
if ($v === '' || $v === null) continue;
|
||
$filtered[$k] = $v;
|
||
}
|
||
ksort($filtered, SORT_STRING);
|
||
$parts = [];
|
||
foreach ($filtered as $k => $v) {
|
||
$parts[] = $k . '=' . $v;
|
||
}
|
||
$data = implode('&', $parts);
|
||
|
||
$type = $params['sign_type'] ?? PAY_SIGN_TYPE;
|
||
if ($type === 'RSA') {
|
||
$key = opensslLoadKey($keys['private_key'], true);
|
||
if ($key === false) {
|
||
throw new Exception('商户私钥加载失败: ' . openssl_error_string());
|
||
}
|
||
openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256);
|
||
if (PHP_VERSION_ID < 80000) {
|
||
@openssl_free_key($key);
|
||
}
|
||
return base64_encode($signature);
|
||
}
|
||
// MD5:平台规则为 md5(待签串 + 商户MD5密钥),【无】&key= 后缀(与彩虹易支付源码 Payment::makeSign 一致)
|
||
if (empty($keys['md5_key'])) {
|
||
throw new Exception('商户MD5密钥未配置');
|
||
}
|
||
return md5($data . $keys['md5_key']);
|
||
}
|
||
|
||
/**
|
||
* 回调验签:根据回调 sign_type 选择算法(兼容平台RSA/MD5两种回调)
|
||
*/
|
||
function verifyNotifySign($params, $keys) {
|
||
if (empty($params['sign'])) {
|
||
debugLog('[pay notify] 验签失败: 缺少sign参数', ['params_keys' => array_keys($params)]);
|
||
return false;
|
||
}
|
||
$sign = $params['sign'];
|
||
$filtered = [];
|
||
foreach ($params as $k => $v) {
|
||
// 排除 sign/sign_type(签名字段)和 action(URL路由参数,平台签名不含此项)
|
||
if ($k === 'sign' || $k === 'sign_type' || $k === 'action') continue;
|
||
if ($v === '' || $v === null) continue;
|
||
$filtered[$k] = $v;
|
||
}
|
||
ksort($filtered, SORT_STRING);
|
||
$parts = [];
|
||
foreach ($filtered as $k => $v) {
|
||
$parts[] = $k . '=' . $v;
|
||
}
|
||
$data = implode('&', $parts);
|
||
|
||
$type = $params['sign_type'] ?? PAY_SIGN_TYPE;
|
||
if ($type === 'RSA') {
|
||
if (empty($keys['public_key'])) {
|
||
debugLog('[pay notify] 验签失败: RSA但无公钥', ['type' => $type]);
|
||
return false;
|
||
}
|
||
$key = opensslLoadKey($keys['public_key'], false);
|
||
if ($key === false) {
|
||
debugLog('[pay notify] 验签失败: RSA公钥加载失败', ['public_key_start' => substr($keys['public_key'], 0, 50)]);
|
||
return false;
|
||
}
|
||
$ok = openssl_verify($data, base64_decode($sign), $key, OPENSSL_ALGO_SHA256);
|
||
if (PHP_VERSION_ID < 80000) {
|
||
@openssl_free_key($key);
|
||
}
|
||
debugLog('[pay notify] RSA验签结果', ['data' => $data, 'ok' => $ok]);
|
||
return $ok === 1;
|
||
}
|
||
// MD5:与提交签名规则一致,md5(待签串 + 商户MD5密钥),无 &key= 后缀
|
||
if (empty($keys['md5_key'])) {
|
||
debugLog('[pay notify] 验签失败: MD5但无密钥', ['type' => $type]);
|
||
return false;
|
||
}
|
||
$expected = strtolower(md5($data . $keys['md5_key']));
|
||
$actual = strtolower($sign);
|
||
$ok = ($expected === $actual);
|
||
debugLog('[pay notify] MD5验签', ['data' => $data, 'md5_key' => $keys['md5_key'], 'expected' => $expected, 'actual' => $actual, 'ok' => $ok]);
|
||
return $ok;
|
||
}
|
||
|
||
// ====================================================================
|
||
// 数据表
|
||
// ====================================================================
|
||
function ensurePayOrdersTable($db) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
out_trade_no TEXT UNIQUE,
|
||
trade_no TEXT,
|
||
player_name TEXT NOT NULL,
|
||
tier_id INTEGER DEFAULT 0,
|
||
money TEXT,
|
||
bond_amount INTEGER DEFAULT 0,
|
||
status TEXT DEFAULT 'created',
|
||
name TEXT,
|
||
platform_sign TEXT,
|
||
submit_params TEXT,
|
||
created_at INTEGER,
|
||
paid_at INTEGER
|
||
)");
|
||
// 旧表可能由早期版本创建、不含 submit_params 列;
|
||
// SQLite 不支持 ALTER ... ADD COLUMN IF NOT EXISTS,故用 PRAGMA 探测后补齐,
|
||
// 否则 CREATE TABLE IF NOT EXISTS 不会加列,导致 UPDATE submit_params 报 “no such column”。
|
||
$hasCol = false;
|
||
$res = $db->query("PRAGMA table_info(pay_orders)");
|
||
while ($col = $res->fetchArray(SQLITE3_ASSOC)) {
|
||
if ($col['name'] === 'submit_params') { $hasCol = true; break; }
|
||
}
|
||
if (!$hasCol) {
|
||
$db->exec("ALTER TABLE pay_orders ADD COLUMN submit_params TEXT");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确保充值流水表存在(notify 写入 web_transactions 供 Java 高频定时器拉取)。
|
||
* 该表可能由其它模块创建,这里做幂等兜底,避免新部署时表不存在导致 notify 抛异常返回 fail。
|
||
*/
|
||
function ensureWebTransactionsTable($db) {
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_name TEXT NOT NULL,
|
||
type TEXT,
|
||
amount INTEGER DEFAULT 0,
|
||
operator TEXT,
|
||
reason TEXT,
|
||
detail TEXT,
|
||
status TEXT DEFAULT 'pending',
|
||
created_at INTEGER
|
||
)");
|
||
// 唯一约束:同 detail+type 不允许重复
|
||
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
|
||
}
|
||
|
||
// ====================================================================
|
||
// 动作:create_order
|
||
// ====================================================================
|
||
function createOrder($token) {
|
||
$keys = loadPayKeys();
|
||
if (empty($keys['pid']) || empty($keys['private_key']) || empty($keys['public_key'])) {
|
||
error('支付密钥未配置,请联系管理员', 500);
|
||
}
|
||
|
||
$info = validateTokenSilent($token);
|
||
if (!$info || empty($info['player'])) {
|
||
error('登录状态无效,请重新登录', 401);
|
||
}
|
||
$player = $info['player'];
|
||
|
||
$db = getDB();
|
||
ensurePayOrdersTable($db);
|
||
|
||
// ★ 防重复支付:检查玩家是否有未支付的已有订单(仅5分钟内有效)
|
||
$staleThreshold = time() - 300; // 5分钟过期
|
||
$checkStmt = $db->prepare("SELECT out_trade_no, submit_params, money, bond_amount, created_at FROM pay_orders WHERE player_name = :p AND status = 'created' AND created_at >= :stale ORDER BY created_at DESC LIMIT 1");
|
||
$checkStmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||
$checkStmt->bindValue(':stale', $staleThreshold, SQLITE3_INTEGER);
|
||
$existingRow = $checkStmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if ($existingRow && !empty($existingRow['submit_params'])) {
|
||
// 重用已有订单(返回原有支付链接,不创建新单)
|
||
$scriptBase = preg_replace('/\?.*$/', '', PAY_NOTIFY_URL);
|
||
$payUrl = $scriptBase . '?action=pay_redirect&out_trade_no=' . urlencode($existingRow['out_trade_no']);
|
||
success([
|
||
'pay_url' => $payUrl,
|
||
'out_trade_no' => $existingRow['out_trade_no'],
|
||
'money' => $existingRow['money'],
|
||
'bonds' => (int)$existingRow['bond_amount'],
|
||
'player' => $player,
|
||
'reused' => true,
|
||
], '检测到您有未支付的订单,已为您恢复支付链接');
|
||
}
|
||
// ★ 清理该玩家30分钟前的陈旧created订单(已被实际支付但本地未更新),避免阻塞后续支付
|
||
$cleanStmt = $db->prepare("UPDATE pay_orders SET status = 'expired' WHERE player_name = :p AND status = 'created' AND created_at < :stale");
|
||
$cleanStmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||
$cleanStmt->bindValue(':stale', $staleThreshold, SQLITE3_INTEGER);
|
||
$cleanStmt->execute();
|
||
|
||
// ===== 0.01 元测试档位(固定映射:0.01 元 → 1 债券) =====
|
||
$money = PAY_TEST_MONEY;
|
||
$bonds = PAY_TEST_BONDS;
|
||
$name = PAY_TEST_NAME;
|
||
|
||
$outTradeNo = 'RE' . date('YmdHis') . sprintf('%04d', mt_rand(0, 9999));
|
||
|
||
$stmt = $db->prepare("INSERT INTO pay_orders (out_trade_no, player_name, tier_id, money, bond_amount, status, name, created_at) VALUES (:no, :p, 0, :m, :b, 'created', :n, :t)");
|
||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':m', $money, SQLITE3_TEXT);
|
||
$stmt->bindValue(':b', $bonds, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':n', $name, SQLITE3_TEXT);
|
||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 构造提交参数(原始值,不做 urlencode;sign 在原始值上计算)
|
||
$params = [
|
||
'pid' => $keys['pid'],
|
||
'type' => 'alipay',
|
||
'out_trade_no'=> $outTradeNo,
|
||
'notify_url' => PAY_NOTIFY_URL,
|
||
'return_url' => PAY_RETURN_URL,
|
||
'name' => $name,
|
||
'money' => $money,
|
||
'param' => $player,
|
||
'timestamp' => (string)time(), // 10位Unix时间戳,单位秒,符合彩虹易支付接口要求
|
||
'sign_type' => PAY_SIGN_TYPE,
|
||
];
|
||
$params['sign'] = buildSign($params, $keys);
|
||
|
||
// 保存已签名的提交参数(含 sign),供本地中转页自动 POST 到网关使用
|
||
$stmt = $db->prepare("UPDATE pay_orders SET submit_params = :sp WHERE out_trade_no = :no");
|
||
$stmt->bindValue(':sp', json_encode($params, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT);
|
||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
|
||
// 支付网关要求 POST 提交($_POST['pid']),不能 GET 直接跳转。
|
||
// 返回本地中转页 URL:浏览器打开后由 JS 自动 POST 表单到网关,规避 GET 传输下 notify_url 内 & 被截断的问题。
|
||
$scriptBase = preg_replace('/\?.*$/', '', PAY_NOTIFY_URL);
|
||
$payUrl = $scriptBase . '?action=pay_redirect&out_trade_no=' . urlencode($outTradeNo);
|
||
|
||
success([
|
||
'pay_url' => $payUrl,
|
||
'out_trade_no' => $outTradeNo,
|
||
'money' => $money,
|
||
'bonds' => $bonds,
|
||
'player' => $player,
|
||
], '订单创建成功,正在跳转支付');
|
||
}
|
||
|
||
// ====================================================================
|
||
// 动作:notify(平台异步回调,必须输出纯文本 success)
|
||
// ====================================================================
|
||
function handleNotify() {
|
||
// 清空输出缓冲,确保最终只输出 success/fail
|
||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||
|
||
$params = $_REQUEST; // 平台回调以 GET 方式把签名参数 append 到 notify_url 后,统一用 $_REQUEST 兼容 GET/POST
|
||
// 记录详细的请求信息用于调试
|
||
$requestInfo = [
|
||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||
'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||
'query_string' => $_SERVER['QUERY_STRING'] ?? '',
|
||
'params_count' => count($params),
|
||
'params_keys' => array_keys($params),
|
||
];
|
||
debugLog('[pay notify] 收到异步回调', array_merge($requestInfo, $params));
|
||
|
||
$keys = loadPayKeys();
|
||
|
||
// 1) 验签(兼容平台RSA/MD5回调)
|
||
if (!verifyNotifySign($params, $keys)) {
|
||
debugLog('[pay notify] 验签失败,疑似伪造', ['out_trade_no' => $params['out_trade_no'] ?? '']);
|
||
echo 'fail';
|
||
exit;
|
||
}
|
||
|
||
// 2) 交易状态必须是成功
|
||
if (($params['trade_status'] ?? '') !== 'TRADE_SUCCESS') {
|
||
debugLog('[pay notify] 交易状态非成功: ' . ($params['trade_status'] ?? ''), ['out_trade_no' => $params['out_trade_no'] ?? '']);
|
||
// 非成功状态(如 WAIT_BUYER_PAY)返回 fail,让平台继续重试直到成功
|
||
echo 'fail';
|
||
exit;
|
||
}
|
||
|
||
$outTradeNo = $params['out_trade_no'] ?? '';
|
||
if ($outTradeNo === '') {
|
||
echo 'fail';
|
||
exit;
|
||
}
|
||
|
||
$db = getDB();
|
||
ensurePayOrdersTable($db);
|
||
ensureWebTransactionsTable($db);
|
||
|
||
$stmt = $db->prepare("SELECT * FROM pay_orders WHERE out_trade_no = :no");
|
||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) {
|
||
debugLog('[pay notify] 订单不存在: ' . $outTradeNo);
|
||
// 订单未知也返回 success,避免平台死循环重试无效通知
|
||
echo 'success';
|
||
exit;
|
||
}
|
||
|
||
// 3) 金额校验:回调 money 必须与订单完全一致(防篡改少付多充)
|
||
if (function_exists('bccomp')) {
|
||
$moneyOk = bccomp((string)$row['money'], (string)($params['money'] ?? ''), 2) === 0;
|
||
} else {
|
||
$moneyOk = ((string)$row['money'] === (string)($params['money'] ?? ''));
|
||
}
|
||
if (!$moneyOk) {
|
||
debugLog('[pay notify] 金额不匹配(疑似篡改)', ['order_money' => $row['money'], 'notify_money' => $params['money'] ?? '']);
|
||
echo 'fail';
|
||
exit;
|
||
}
|
||
|
||
// 4) 幂等:已支付则直接返回 success,绝不重复写 web_transactions
|
||
if ($row['status'] === 'paid') {
|
||
echo 'success';
|
||
exit;
|
||
}
|
||
|
||
// 5) 标记订单已支付
|
||
$stmt = $db->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t WHERE out_trade_no=:no");
|
||
$stmt->bindValue(':tn', $params['trade_no'] ?? '', SQLITE3_TEXT);
|
||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
|
||
// 6) 写 web_transactions(recharge) 供 Java 高频定时器拉取 → addBonds 到账
|
||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '支付平台', '在线充值(支付宝)', :d, 'pending', :t)");
|
||
$stmt->bindValue(':p', $row['player_name'], SQLITE3_TEXT);
|
||
$stmt->bindValue(':a', (int)$row['bond_amount'], SQLITE3_INTEGER);
|
||
$stmt->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
|
||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
debugLog('[pay notify] 充值交易已写入', ['player' => $row['player_name'], 'bonds' => $row['bond_amount'], 'out_trade_no' => $outTradeNo]);
|
||
echo 'success';
|
||
exit;
|
||
}
|
||
|
||
// ====================================================================
|
||
// 动作:pay_redirect(本地中转页,浏览器打开后自动 POST 到支付网关)
|
||
// ====================================================================
|
||
function handlePayRedirect() {
|
||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||
$outTradeNo = getParam('out_trade_no');
|
||
if (!$outTradeNo) {
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '缺少订单号';
|
||
exit;
|
||
}
|
||
$db = getDB();
|
||
ensurePayOrdersTable($db);
|
||
$stmt = $db->prepare("SELECT submit_params, status FROM pay_orders WHERE out_trade_no = :no");
|
||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row || empty($row['submit_params'])) {
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '订单不存在或尚未生成支付参数';
|
||
exit;
|
||
}
|
||
$params = json_decode($row['submit_params'], true);
|
||
if (!is_array($params)) {
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '支付参数损坏';
|
||
exit;
|
||
}
|
||
|
||
// 已支付则直接跳回结果页,不再重复提交
|
||
if ($row['status'] === 'paid') {
|
||
header('Location: ' . PAY_RETURN_URL, true, 302);
|
||
exit;
|
||
}
|
||
|
||
$fields = '';
|
||
foreach ($params as $k => $v) {
|
||
$fields .= '<input type="hidden" name="' . htmlspecialchars($k, ENT_QUOTES) . '" value="' . htmlspecialchars($v, ENT_QUOTES) . '">' . "\n";
|
||
}
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>正在跳转支付...</title></head><body>' .
|
||
'<form id="payform" action="' . htmlspecialchars(PAY_GATEWAY, ENT_QUOTES) . '" method="post">' .
|
||
$fields .
|
||
'</form>' .
|
||
'<script>document.getElementById("payform").submit();</script>' .
|
||
'</body></html>';
|
||
exit;
|
||
}
|
||
|
||
// ====================================================================
|
||
// 辅助:后台异步触发支付轮询器(非阻塞)
|
||
// ====================================================================
|
||
function triggerPollerAsync() {
|
||
// ★ 2026-07-15 改用 poller_online.php 补单(绕过 HTTP 回调,CF WAF 拦截 notify)
|
||
$url = 'https://caoyuan.ypshidifu.cn/plugin/api/poller_online.php';
|
||
$ch = curl_init($url);
|
||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
|
||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||
// 不等待结果,后台执行
|
||
curl_exec($ch);
|
||
curl_close($ch);
|
||
}
|
||
|
||
// ====================================================================
|
||
// 动作:query_order(前端补单 / 轮询)
|
||
// ====================================================================
|
||
function queryOrder($token) {
|
||
// 后台触发轮询器(非阻塞),处理 Cloudflare WAF 拦截导致的未通知订单
|
||
@triggerPollerAsync();
|
||
|
||
$info = validateTokenSilent($token);
|
||
if (!$info || empty($info['player'])) {
|
||
error('登录状态无效', 401);
|
||
}
|
||
$outTradeNo = getParam('out_trade_no');
|
||
if (!$outTradeNo) {
|
||
error('缺少订单号');
|
||
}
|
||
$db = getDB();
|
||
ensurePayOrdersTable($db);
|
||
$stmt = $db->prepare("SELECT * FROM pay_orders WHERE out_trade_no=:no AND player_name=:p");
|
||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||
$stmt->bindValue(':p', $info['player'], SQLITE3_TEXT);
|
||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) {
|
||
error('订单不存在');
|
||
}
|
||
$status = $row['status'];
|
||
$message = '订单创建中';
|
||
if ($status === 'paid') {
|
||
$message = '已支付,游戏内债券发放中(若未到账请稍候或联系管理员)';
|
||
}
|
||
success([
|
||
'out_trade_no' => $outTradeNo,
|
||
'status' => $status,
|
||
'money' => $row['money'],
|
||
'bonds' => (int)$row['bond_amount'],
|
||
'paid_at' => $row['paid_at'],
|
||
'created_at' => $row['created_at'],
|
||
], $message);
|
||
}
|
||
|
||
// ====================================================================
|
||
// 入口分发
|
||
// ====================================================================
|
||
$action = getParam('action', '');
|
||
try {
|
||
switch ($action) {
|
||
case 'create_order':
|
||
createOrder(getParam('token'));
|
||
break;
|
||
case 'notify':
|
||
handleNotify(); // 内部已 exit
|
||
break;
|
||
case 'pay_redirect':
|
||
handlePayRedirect(); // 内部已 exit
|
||
break;
|
||
case 'sync_poller':
|
||
syncPoller(getParam('token'));
|
||
break;
|
||
case 'query_order':
|
||
queryOrder(getParam('token'));
|
||
break;
|
||
default:
|
||
error('未知操作: ' . $action);
|
||
}
|
||
// ★ WAL checkpoint:释放WAL锁,减少 database is locked 概率
|
||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||
} catch (\Throwable $e) {
|
||
if ($action === 'notify') {
|
||
// notify 异常时返回 fail,让平台重试
|
||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||
echo 'fail';
|
||
exit;
|
||
}
|
||
http_response_code(500);
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|