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); // ★ 防重复支付:检查玩家是否有未支付的已有订单(仅30分钟内有效) $staleThreshold = time() - 1800; // 30分钟过期 $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 .= '' . "\n"; } header('Content-Type: text/html; charset=utf-8'); echo '正在跳转支付...' . '
' . $fields . '
' . '' . ''; 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'], ], $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 '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; }