diff --git a/api/pay.php b/api/pay.php new file mode 100644 index 0000000..d45f309 --- /dev/null +++ b/api/pay.php @@ -0,0 +1,394 @@ + $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#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; +} diff --git a/player.php b/player.php index 9a9e2a3..25ef22f 100644 --- a/player.php +++ b/player.php @@ -276,6 +276,7 @@ if ($currentVersion !== $BUILD_VERSION) { + @@ -423,7 +424,12 @@ if ($currentVersion !== $BUILD_VERSION) { // ★ 如果不需要密码,直接加载个人中心 if (typeof NEED_PASSWORD !== 'undefined' && !NEED_PASSWORD) { setTimeout(() => { - switchPage('dashboard'); + // 支付回跳 ?paid=1 直接进入充值页确认到账 + if (new URLSearchParams(location.search).get('paid') === '1') { + switchPage('recharge'); + } else { + switchPage('dashboard'); + } // ★ Token跟随Java端配置过期时间自动过期,不续费 // startTokenPolling() 不再调用,仅保留变量声明 }, 100); @@ -1045,6 +1051,116 @@ if ($currentVersion !== $BUILD_VERSION) { else if (page === 'groups') renderGroups(c); else if (page === 'account') renderAccount(c); else if (page === 'ticket') renderTicket(c); + else if (page === 'recharge') renderRecharge(c); + } + + // ==================== 债券在线充值(测试:0.01元 → 1债券) ==================== + function renderRecharge(el) { + el.innerHTML = ` +
+

💳 债券在线充值

+

测试阶段:¥0.01 = 1 债券(仅支付宝)。支付成功后游戏内即时到账。

+ +
+
+ 测试充值档位 + 测试 +
+
+
¥0.01
支付金额
+
→ 1 债券
+
+
+ + +
+

说明:点击后跳转至支付宝完成付款,浏览器会自动跳回本页并自动确认到账。若未自动到账,可点击下方按钮手动查询。

+ +
+ `; + + // 处理支付回跳 ?paid=1 + const paid = new URLSearchParams(location.search).get('paid'); + const lastNo = localStorage.getItem('sdf1_last_recharge_no'); + if (paid === '1') { + const st = document.getElementById('rechargeStatus'); + if (lastNo) { + st.innerHTML = '检测到支付回跳,正在确认到账…'; + document.getElementById('rechargeQueryBtn').style.display = 'block'; + pollRechargeOrder(false); + } else { + st.innerHTML = '已返回,但未找到本地订单记录。如已支付,请在「余额查询」确认或联系管理员。'; + } + // 清除 URL 中的 paid 标记,避免刷新重复提示 + try { + const u = new URL(location.href); + u.searchParams.delete('paid'); + history.replaceState({}, '', u); + } catch (e) {} + } + } + + async function startRecharge() { + const btn = document.getElementById('rechargeBtn'); + const st = document.getElementById('rechargeStatus'); + if (!TOKEN) { toast('请先登录', 'error'); return; } + btn.disabled = true; + btn.textContent = '⏳ 正在创建订单…'; + st.innerHTML = '正在请求支付订单…'; + try { + const res = await api('pay.php', {action: 'create_order'}); + if (!res.success) { + st.innerHTML = '创建订单失败:' + (res.message || '未知错误') + ''; + btn.disabled = false; + btn.textContent = '🅰️ 支付宝支付 ¥0.01'; + return; + } + const { pay_url, out_trade_no } = res.data; + localStorage.setItem('sdf1_last_recharge_no', out_trade_no); + st.innerHTML = '订单已创建,正在跳转支付宝…(如未弹出,请允许浏览器打开新窗口)'; + document.getElementById('rechargeQueryBtn').style.display = 'block'; + const w = window.open(pay_url, '_blank'); + if (!w) { + st.innerHTML = '浏览器拦截了新窗口,点此手动打开支付页面'; + } + pollRechargeOrder(false); + } catch (e) { + st.innerHTML = '请求异常:' + e.message + ''; + btn.disabled = false; + btn.textContent = '🅰️ 支付宝支付 ¥0.01'; + } + } + + let rechargePollTimer = null; + async function pollRechargeOrder(manual) { + const st = document.getElementById('rechargeStatus'); + const no = localStorage.getItem('sdf1_last_recharge_no'); + if (!no) { if (manual) toast('没有可查询的订单', 'error'); return; } + if (!TOKEN) { if (manual) toast('请先登录', 'error'); return; } + if (rechargePollTimer && !manual) return; // 已在轮询 + + const doPoll = async () => { + try { + const res = await api('pay.php', {action: 'query_order', out_trade_no: no}); + if (res.success && res.data) { + if (res.data.status === 'paid') { + st.innerHTML = '✅ 支付成功!游戏内债券发放中,请稍候刷新「余额查询」。'; + if (rechargePollTimer) { clearInterval(rechargePollTimer); rechargePollTimer = null; } + } else { + st.innerHTML = '⏳ 订单处理中(' + (res.message || '等待支付平台通知') + ')…'; + } + } else { + st.innerHTML = '' + (res.message || '查询失败') + ''; + } + } catch (e) { + st.innerHTML = '查询异常:' + e.message + ''; + } + }; + await doPoll(); + if (!manual && !rechargePollTimer) { + rechargePollTimer = setInterval(doPoll, 3000); + setTimeout(() => { if (rechargePollTimer) { clearInterval(rechargePollTimer); rechargePollTimer = null; } }, 120000); + } } // ★ 新增:Token轮询检查(不续期,只检查是否被Java端主动销毁)