- api/pay.php: 下单/回调/查询 + RSA(SHA256)签名验签 + pay_orders表 - player.php: 在线充值UI + ?paid=1回跳 + 3s轮询补单 - 密钥仅从 git-ignored 的 pay_secrets.php / 密钥文件_git拉黑.txt 读取,不入库 - fix: 去除 open_basedir 限制的 /pay_keys 候选路径 + @抑制文件访问警告,避免污染响应
395 lines
15 KiB
PHP
395 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* 债券在线充值 · 支付平台联控(彩虹易支付 RSA 版)
|
||
*
|
||
* 动作:
|
||
* - create_order : 校验登录态 → 生成订单 → RSA签名 → 返回跳转支付URL
|
||
* - notify : 平台异步回调(GET)→ 平台公钥验签 → 金额校验 → 幂等 → 写 web_transactions(recharge)
|
||
* - query_order : 前端补单/轮询订单状态
|
||
*
|
||
* ★ 安全:本文件不含任何密钥。密钥从 pay_secrets.php(git-ignored,仅定义常量的 .php)或
|
||
* 本地 git-ignored 的「密钥文件_git拉黑.txt」读取;两者均不会进入版本库,也不会被部署脚本上传。
|
||
*/
|
||
|
||
// 防止任何前置输出污染 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', 'RSA');
|
||
|
||
// ===== 0.01 元测试配置(先跑通全链路,后续替换为 recharge_tiers 档位) =====
|
||
define('PAY_TEST_MONEY', '0.01'); // 元(字符串,与回调比对)
|
||
define('PAY_TEST_BONDS', 1); // 对应债券数量
|
||
define('PAY_TEST_NAME', '债券充值-测试0.01元');
|
||
|
||
// ====================================================================
|
||
// 密钥加载(双来源:本地 txt 优先,服务器 pay_secrets.php 兜底)
|
||
// ====================================================================
|
||
function loadPayKeys() {
|
||
// 来源 A:本地/部署仓库中的 git-ignored 明文文件(源真相,便于改密)
|
||
$txtCandidates = [
|
||
__DIR__ . '/../密钥文件_git拉黑.txt',
|
||
__DIR__ . '/密钥文件_git拉黑.txt',
|
||
];
|
||
foreach ($txtCandidates as $f) {
|
||
// @ 抑制:密钥文件可能不存在(正常情况),避免 open_basedir 等警告污染响应输出
|
||
if (@is_file($f) && @is_readable($f)) {
|
||
$parsed = parsePayKeyFile(@file_get_contents($f));
|
||
if (!empty($parsed['merchant_private']) && !empty($parsed['platform_public'])) {
|
||
return [
|
||
'pid' => $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
|
||
* 平台公钥:<base64>
|
||
* 商户公钥:<base64>
|
||
* 商户私钥:<base64>
|
||
* MD5密钥:<base64>
|
||
*/
|
||
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#8 私钥 / SubjectPublicKeyInfo 公钥) */
|
||
function wrapPem($b64, $type) {
|
||
$b64 = preg_replace('/\s+/', '', $b64);
|
||
if ($b64 === '') return '';
|
||
$header = ($type === 'private') ? 'PRIVATE KEY' : 'PUBLIC KEY';
|
||
$body = trim(chunk_split($b64, 64, "\n"));
|
||
return "-----BEGIN $header-----\n$body\n-----END $header-----\n";
|
||
}
|
||
|
||
/**
|
||
* RSA(SHA256withRSA) 请求签名:取全部非空参数 → 剔除 sign/sign_type → 参数名 ASCII 升序 → 原始值 k=v& 拼接 → 私钥签名
|
||
*/
|
||
function buildSign($params, $privateKeyPem) {
|
||
$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 = openssl_pkey_get_private($privateKeyPem);
|
||
if ($key === false) {
|
||
throw new Exception('商户私钥加载失败: ' . openssl_error_string());
|
||
}
|
||
openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256);
|
||
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 = openssl_pkey_get_public($publicKeyPem);
|
||
if ($key === false) return false;
|
||
$ok = openssl_verify($data, base64_decode($sign), $key, OPENSSL_ALGO_SHA256);
|
||
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(),
|
||
'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;
|
||
}
|