$parsed['pid'], 'private_key' => wrapPem($parsed['merchant_private'], 'private'), 'public_key' => wrapPem($parsed['platform_public'], 'public'), ]; } } } // 来源 B:服务器部署用的 pay_secrets.php(git-ignored,仅 define 常量,无输出,web 安全) $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'), ]; } } return ['pid' => '', 'private_key' => '', 'public_key' => '']; } /** * 解析「密钥文件_git拉黑.txt」格式: * 商户ID:1001 * 平台公钥: * 商户公钥: * 商户私钥: * MD5密钥: */ function parsePayKeyFile($content) { $lines = preg_split('/\r\n|\r|\n/', (string)$content); $blocks = []; $cur = null; foreach ($lines as $ln) { if (preg_match('/^([\x{4e00}-\x{9fa5}A-Za-z0-9 ]+)\s*[::]\s*(.*)$/u', $ln, $m)) { $cur = trim($m[1]); $blocks[$cur] = $m[2]; } elseif ($cur !== null) { $blocks[$cur] .= $ln; } } $clean = function ($v) { return preg_replace('/\s+/', '', $v ?? ''); }; return [ 'pid' => trim($blocks['商户ID'] ?? ''), 'platform_public' => $clean($blocks['平台公钥'] ?? ''), 'merchant_public' => $clean($blocks['商户公钥'] ?? ''), 'merchant_private'=> $clean($blocks['商户私钥'] ?? ''), 'md5' => trim($blocks['MD5密钥'] ?? ''), ]; } /** 将裸 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; } /** * RSA(SHA256withRSA) 请求签名:取全部非空参数 → 剔除 sign → 参数名 ASCII 升序 → 原始值 k=v& 拼接 → 私钥签名 */ function buildSign($params, $privateKeyPem) { $filtered = []; foreach ($params as $k => $v) { if ($k === 'sign') continue; // 只跳过sign字段,保留sign_type if ($v === '' || $v === null) continue; $filtered[$k] = $v; } ksort($filtered, SORT_STRING); $parts = []; foreach ($filtered as $k => $v) { $parts[] = $k . '=' . $v; } $data = implode('&', $parts); $key = opensslLoadKey($privateKeyPem, true); if ($key === false) { throw new Exception('商户私钥加载失败: ' . openssl_error_string()); } openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256); // 彩虹易支付V2接口使用SHA256WithRSA // PHP 8.0+ 已弃用 openssl_free_key,资源会自动释放;显式调用会触发弃用警告,污染 JSON 响应 if (PHP_VERSION_ID < 80000) { @openssl_free_key($key); } return base64_encode($signature); } /** * RSA(SHA256withRSA) 回调验签:用平台公钥验证(兼容平台新增的扩展字段——取回调全部非空参数参与) */ function verifyNotifySign($params, $publicKeyPem) { if (empty($params['sign']) || empty($publicKeyPem)) return false; $sign = $params['sign']; $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); $key = opensslLoadKey($publicKeyPem, false); if ($key === false) return false; $ok = openssl_verify($data, base64_decode($sign), $key, OPENSSL_ALGO_SHA256); // PHP 8.0+ 已弃用 openssl_free_key,资源会自动释放 if (PHP_VERSION_ID < 80000) { @openssl_free_key($key); } return $ok === 1; } // ==================================================================== // 数据表 // ==================================================================== 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, created_at INTEGER, paid_at INTEGER )"); } // ==================================================================== // 动作: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); // ===== 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['private_key']); // GET 跳转(浏览器跳转付款)。http_build_query 仅做传输层 urlencode,平台解码后还原为原始值参与验签。 $payUrl = PAY_GATEWAY . '?' . http_build_query($params); 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 = $_GET; debugLog('[pay notify] 收到异步回调', $params); $keys = loadPayKeys(); // 1) 验签(平台公钥) if (!verifyNotifySign($params, $keys['public_key'])) { 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); $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; } // ==================================================================== // 动作:query_order(前端补单 / 轮询) // ==================================================================== function queryOrder($token) { $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 '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; }