diff --git a/.gitignore b/.gitignore index d3a11fd..7e4aa70 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ upload_debug.php upload_via_http.php upload_debug.ps1 +# 密钥文件 +密钥.md +pay_secrets.php debug_admin_api.php debug_online.php debug_region.php diff --git a/admin.php b/admin.php index 4905fcf..3a1beb5 100644 --- a/admin.php +++ b/admin.php @@ -140,6 +140,7 @@
👥 用户组
🧾 收银台(独立页)
🧾 收银员管理
+
💳 充值对账(独立页)
@@ -3339,6 +3340,11 @@ function openCashierPage() { window.open('cashier.php', '_blank'); } +// ===== 充值订单对账(独立页面)===== +function openRechargeOrdersPage() { + window.open('recharge_orders.php', '_blank'); +} + async function loadCashierManage(el) { el.innerHTML = `

🧾 收银员管理

diff --git a/api/pay.php b/api/pay.php index 6690961..d1c1fcb 100644 --- a/api/pay.php +++ b/api/pay.php @@ -233,6 +233,8 @@ function ensureWebTransactionsTable($db) { 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)"); } // ==================================================================== @@ -253,6 +255,24 @@ function createOrder($token) { $db = getDB(); ensurePayOrdersTable($db); + // ★ 防重复支付:检查玩家是否有未支付的已有订单 + $checkStmt = $db->prepare("SELECT out_trade_no, submit_params, money, bond_amount FROM pay_orders WHERE player_name = :p AND status = 'created' ORDER BY created_at DESC LIMIT 1"); + $checkStmt->bindValue(':p', $player, SQLITE3_TEXT); + $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, + ], '检测到您有未支付的订单,已为您恢复支付链接'); + } + // ===== 0.01 元测试档位(固定映射:0.01 元 → 1 债券) ===== $money = PAY_TEST_MONEY; $bonds = PAY_TEST_BONDS; @@ -450,7 +470,8 @@ function handlePayRedirect() { // 辅助:后台异步触发支付轮询器(非阻塞) // ==================================================================== function triggerPollerAsync() { - $url = 'https://caoyuan.ypshidifu.cn/plugin/api/pay_poller.php'; + // ★ 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); diff --git a/api/poller_online.php b/api/poller_online.php new file mode 100644 index 0000000..70e5767 --- /dev/null +++ b/api/poller_online.php @@ -0,0 +1,267 @@ + 0) { ob_end_clean(); } + +// ===== 配置 ===== +$PLATFORM_DB_HOST = 'localhost'; +$PLATFORM_DB_NAME = 'caihong'; +$PLATFORM_DB_USER = 'kH3C3LLinNwYdTF5'; +$PLATFORM_DB_PASS = 'sRhsdxrpHBhmSsp8'; +$PLATFORM_DB_PREFIX = 'pay_'; + +// SQLite 数据库路径(与 core.php 一致:/caoyuan.ypshidifu.cn/plugin/db/web.db) +$SQLITE_DB_PATH = __DIR__ . '/../db/web.db'; + +if (!function_exists('debugLog')) { +function debugLog($msg, $ctx = []) { + $ts = date('Y-m-d H:i:s'); + $entry = "[$ts] $msg"; + if ($ctx) $entry .= ' | Context: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE); + $entry .= "\n"; + @file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX); +} +} // end if (!function_exists('debugLog')) + +function getSQLite() { + static $db = null; + if ($db === null) { + $db = new SQLite3($GLOBALS['SQLITE_DB_PATH']); + $db->enableExceptions(true); + $db->exec('PRAGMA journal_mode=WAL'); + $db->exec('PRAGMA busy_timeout=5000'); + // 确保表存在(幂等兜底) + $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 + )"); + $db->exec("CREATE TABLE IF NOT EXISTS web_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_name TEXT, + type TEXT, + amount INTEGER, + operator TEXT, + reason TEXT, + detail TEXT, + status TEXT DEFAULT 'pending', + created_at INTEGER + )"); + // 唯一约束:同 detail+type 不允许重复(防 race condition/重复补单) + @$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)"); + } + return $db; +} + +function getPlatformDB() { + static $pdo = null; + if ($pdo === null) { + $dsn = "mysql:host={$GLOBALS['PLATFORM_DB_HOST']};dbname={$GLOBALS['PLATFORM_DB_NAME']};charset=utf8mb4"; + $pdo = new PDO($dsn, $GLOBALS['PLATFORM_DB_USER'], $GLOBALS['PLATFORM_DB_PASS'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 5, + ]); + } + return $pdo; +} + +/** + * 债券换算(仅当本地 pay_orders 无 bond_amount 时作为兜底估算) + * 0.01元 → 1债券;>=1元 → 金额*10债券 + */ +function estimateBonds($money) { + $m = (float)$money; + if ($m >= 1.0) return (int)($m * 10); + return max(1, (int)($m * 100)); +} + +/** + * 进程锁:防止多个触发源(Java 定时、前端 query_order、管理员手动"立即对账") + * 并发跑补单造成重复写入 / 资源争用。非阻塞,若已被占用则跳过本次。 + * 锁文件独立存在,进程退出后内核自动释放。 + */ +function pollerAcquireLock() { + static $fp = null; + if ($fp === null) { + $lockPath = __DIR__ . '/../db/poller.lock'; + $fp = @fopen($lockPath, 'c'); + } + if ($fp === false) return false; + return flock($fp, LOCK_EX | LOCK_NB); +} + +function pollPaidOrders() { + // 并发保护:已有补单进程在跑则直接跳过,杜绝重复补单 + if (!pollerAcquireLock()) { + echo json_encode(['result' => 'already_running', 'detail' => '另一个补单进程正在运行,本次跳过']); + return; + } + + $sqlite = getSQLite(); + + try { + $pdo = getPlatformDB(); + } catch (\Throwable $e) { + debugLog('[poller_online] 平台数据库连接失败: ' . $e->getMessage()); + echo json_encode(['error' => 'platform_db_connect_failed', 'detail' => $e->getMessage()]); + return; + } + + // 查所有已支付订单 (status=1 或 status=2)。不过滤 notify: + // 平台可能已标记 notify=1 但本地的 DB 未更新(HTTP 回调被 CF WAF 拦截), + // 必须靠本地 SQLite 的 status='created' 来判断是否需要补单。 + $prefix = $GLOBALS['PLATFORM_DB_PREFIX']; + $stmt = $pdo->query("SELECT out_trade_no, trade_no, uid, money, status, notify, param, version, addtime FROM {$prefix}order WHERE status IN (1, 2) ORDER BY addtime ASC LIMIT 50"); + $allOrders = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($allOrders)) { + echo json_encode(['result' => 'no_paid_orders_on_platform']); + return; + } + + // 过滤掉本地已处理(paid)的 + $orders = []; + foreach ($allOrders as $o) { + $check = $sqlite->prepare("SELECT status FROM pay_orders WHERE out_trade_no = :no"); + $check->bindValue(':no', $o['out_trade_no'], SQLITE3_TEXT); + $row = $check->execute()->fetchArray(SQLITE3_ASSOC); + if (!$row || $row['status'] !== 'paid') { + $orders[] = $o; + } + } + + if (empty($orders)) { + echo json_encode(['result' => 'no_pending_orders', 'platform_paid_count' => count($allOrders)]); + return; + } + + $processed = 0; + $skipped = 0; + $now = time(); + + foreach ($orders as $order) { + $outTradeNo = $order['out_trade_no']; + $tradeNo = $order['trade_no']; + $playerName = $order['param'] ?? 'unknown'; + $money = (string)$order['money']; + + // 查本地 pay_orders(拿到正确的 bond_amount,创建订单时按档位设置) + $check = $sqlite->prepare("SELECT status, bond_amount FROM pay_orders WHERE out_trade_no = :no"); + $check->bindValue(':no', $outTradeNo, SQLITE3_TEXT); + $row = $check->execute()->fetchArray(SQLITE3_ASSOC); + + if ($row && $row['status'] === 'paid') { + $skipped++; + markNotified($pdo, $tradeNo); + continue; + } + + // 债券数:优先用本地订单记录的 bond_amount(最准确),否则估算 + $bonds = 0; + if ($row && !empty($row['bond_amount'])) { + $bonds = (int)$row['bond_amount']; + } else { + $bonds = estimateBonds($money); + } + + debugLog('[poller_online] 补单处理', [ + 'out_trade_no' => $outTradeNo, + 'player' => $playerName, + 'money' => $money, + 'bonds' => $bonds, + 'platform_notify'=> $order['notify'], + 'has_local_order'=> $row ? 1 : 0, + ]); + + // 写/更新本地 pay_orders + if (!$row) { + $ins = $sqlite->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :t, :t)"); + $ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT); + $ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT); + $ins->bindValue(':p', $playerName, SQLITE3_TEXT); + $ins->bindValue(':m', $money, SQLITE3_TEXT); + $ins->bindValue(':b', $bonds, SQLITE3_INTEGER); + $ins->bindValue(':t', $now, SQLITE3_INTEGER); + $ins->execute(); + } else { + $upd = $sqlite->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t, bond_amount=:b WHERE out_trade_no=:no AND status != 'paid'"); + $upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT); + $upd->bindValue(':t', $now, SQLITE3_INTEGER); + $upd->bindValue(':b', $bonds, SQLITE3_INTEGER); + $upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT); + $upd->execute(); + } + + // 写 web_transactions(幂等:按 detail=out_trade_no + type=recharge 去重) + $txCheck = $sqlite->prepare("SELECT id FROM web_transactions WHERE detail = :d AND type = 'recharge' LIMIT 1"); + $txCheck->bindValue(':d', $outTradeNo, SQLITE3_TEXT); + $txRow = $txCheck->execute()->fetchArray(SQLITE3_ASSOC); + + if (!$txRow) { + $txIns = $sqlite->prepare("INSERT INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '支付平台(补单)', '在线充值(支付宝)', :d, 'pending', :t)"); + $txIns->bindValue(':p', $playerName, SQLITE3_TEXT); + $txIns->bindValue(':a', $bonds, SQLITE3_INTEGER); + $txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT); + $txIns->bindValue(':t', $now, SQLITE3_INTEGER); + $txIns->execute(); + + debugLog('[poller_online] 充值交易已写入 web_transactions', [ + 'player' => $playerName, 'bonds' => $bonds, + 'out_trade_no' => $outTradeNo, 'money' => $money, + ]); + } + + markNotified($pdo, $tradeNo); + $processed++; + } + + echo json_encode([ + 'result' => 'ok', + 'processed' => $processed, + 'skipped' => $skipped, + 'total' => count($orders), + ]); +} + +function markNotified($pdo, $tradeNo) { + try { + $prefix = $GLOBALS['PLATFORM_DB_PREFIX']; + $stmt = $pdo->prepare("UPDATE {$prefix}order SET notify = 1 WHERE trade_no = ? AND notify = 0"); + $stmt->execute([$tradeNo]); + } catch (\Throwable $e) { + debugLog('[poller_online] 标记notify失败: ' . $e->getMessage(), ['trade_no' => $tradeNo]); + } +} + +// ===== 入口 ===== +// 若被其它脚本 include 并定义了 POLLER_NO_AUTO_RUN,则只暴露函数(getPlatformDB 等), +// 不在此处自动执行补单(由调用方自行决定何时触发)。 +if (!defined('POLLER_NO_AUTO_RUN')) { + header('Content-Type: application/json; charset=utf-8'); + pollPaidOrders(); +} diff --git a/api/recharge_orders_api.php b/api/recharge_orders_api.php new file mode 100644 index 0000000..8c95828 --- /dev/null +++ b/api/recharge_orders_api.php @@ -0,0 +1,175 @@ +prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v, SQLITE3_TEXT); + } + $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); + + $orders = []; + $result = $stmt->execute(); + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $orders[] = $row; + } + + // 统计数据 + $statsSql = "SELECT + COUNT(*) as total, + SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid, + SUM(CASE WHEN status='created' THEN 1 ELSE 0 END) as created, + SUM(CASE WHEN status='paid' THEN CAST(money AS REAL) ELSE 0 END) as totalMoney + FROM pay_orders"; + $statsResult = $db->querySingle($statsSql, true); + + $stats = [ + 'total' => $statsResult['total'] ?? 0, + 'paid' => $statsResult['paid'] ?? 0, + 'created' => $statsResult['created'] ?? 0, + 'totalMoney' => number_format($statsResult['totalMoney'] ?? 0, 2), + ]; + + success(['orders' => $orders, 'stats' => $stats]); +} + +/** + * 获取单个订单详情 + */ +function handleOrderDetail() { + $outTradeNo = getParam('out_trade_no'); + if (!$outTradeNo) { + error('缺少订单号'); + } + + $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) { + error('订单不存在'); + } + + success(['order' => $row]); +} + +/** + * 手动触发对账(调用 poller_online.php) + */ +function handleReconcile() { + // 直接包含 poller_online.php 并调用 pollPaidOrders + define('POLLER_NO_AUTO_RUN', true); + require_once __DIR__ . '/poller_online.php'; + + // 开始对账 + ob_start(); + pollPaidOrders(); + $output = ob_get_clean(); + + // 解析结果 + $result = json_decode($output, true); + if (!$result) { + error('对账脚本执行异常'); + } + + success($result); +} + +/** + * 确保 pay_orders 表存在(从 pay.php 复制) + */ +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 + )"); + + // 检查并添加缺失的列 + $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"); + } +} diff --git a/player.php b/player.php index 25ef22f..16acd4e 100644 --- a/player.php +++ b/player.php @@ -1097,6 +1097,45 @@ if ($currentVersion !== $BUILD_VERSION) { u.searchParams.delete('paid'); history.replaceState({}, '', u); } catch (e) {} + } else if (lastNo) { + // ★ 2026-07-15 续支付检测:有本地缓存订单号且无 paid 回跳,查是否仍在等待支付 + (async () => { + try { + const res = await api('pay.php', {action: 'query_order', out_trade_no: lastNo}); + if (res.success && res.data && res.data.status === 'created') { + const st = document.getElementById('rechargeStatus'); + const btn = document.getElementById('rechargeBtn'); + btn.textContent = '🔄 继续支付 ¥' + (res.data.money || '0.01'); + btn.onclick = async function() { + try { + btn.disabled = true; + btn.textContent = '⏳ 获取支付链接…'; + const r = await api('pay.php', {action: 'create_order'}); + if (r.success && r.data.pay_url) { + localStorage.setItem('sdf1_last_recharge_no', r.data.out_trade_no); + const payUrl = r.data.pay_url; + st.innerHTML = '已打开支付页面…'; + document.getElementById('rechargeQueryBtn').style.display = 'block'; + window.open(payUrl, '_blank'); + pollRechargeOrder(false); + } else { + st.innerHTML = '' + (r.message || '获取支付链接失败') + ''; + btn.disabled = false; + btn.textContent = '🔄 继续支付'; + } + } catch (e) { + st.innerHTML = '请求异常: ' + e.message + ''; + btn.disabled = false; + btn.textContent = '🔄 继续支付'; + } + }; + st.innerHTML = '⚠️ 您有一笔未完成的订单,点此继续支付'; + document.getElementById('rechargeQueryBtn').style.display = 'block'; + } + } catch (e) { + localStorage.removeItem('sdf1_last_recharge_no'); + } + })(); } } @@ -1115,14 +1154,15 @@ if ($currentVersion !== $BUILD_VERSION) { btn.textContent = '🅰️ 支付宝支付 ¥0.01'; return; } - const { pay_url, out_trade_no } = res.data; + const { pay_url, out_trade_no, reused } = res.data; localStorage.setItem('sdf1_last_recharge_no', out_trade_no); - st.innerHTML = '订单已创建,正在跳转支付宝…(如未弹出,请允许浏览器打开新窗口)'; + st.innerHTML = '' + (reused ? '恢复支付链接' : '订单已创建') + ',正在跳转支付宝…(如未弹出,请允许浏览器打开新窗口)'; 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 + ''; @@ -1146,6 +1186,10 @@ if ($currentVersion !== $BUILD_VERSION) { if (res.data.status === 'paid') { st.innerHTML = '✅ 支付成功!游戏内债券发放中,请稍候刷新「余额查询」。'; if (rechargePollTimer) { clearInterval(rechargePollTimer); rechargePollTimer = null; } + // 支付成功后清除本地记录 + 恢复按钮 + localStorage.removeItem('sdf1_last_recharge_no'); + const btn = document.getElementById('rechargeBtn'); + if (btn) { btn.disabled = false; btn.textContent = '🅰️ 支付宝支付 ¥0.01'; btn.onclick = startRecharge; } } else { st.innerHTML = '⏳ 订单处理中(' + (res.message || '等待支付平台通知') + ')…'; } diff --git a/recharge_orders.php b/recharge_orders.php new file mode 100644 index 0000000..1e460ab --- /dev/null +++ b/recharge_orders.php @@ -0,0 +1,315 @@ +'Warning',E_NOTICE=>'Notice',E_USER_ERROR=>'UserError',E_USER_WARNING=>'UserWarning',E_USER_NOTICE=>'UserNotice',E_STRICT=>'Strict',E_DEPRECATED=>'Deprecated',E_RECOVERABLE_ERROR=>'Recoverable']; + $type = isset($types[$errno]) ? $types[$errno] : "Error[$errno]"; + if (file_exists(__DIR__ . '/core.php')) { + if (!function_exists('debugLog')) { + function debugLog($msg) { + @file_put_contents(__DIR__ . '/db/debug.log', "[" . date('Y-m-d H:i:s') . "] [recharge_orders.php] $msg\n", FILE_APPEND | LOCK_EX); + } + } + debugLog("[recharge_orders.php] PHP {$type}: {$errstr} in {$errfile}:{$errline}"); + } + return true; +}); + +require_once __DIR__ . '/core.php'; + +if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); } + +if (session_status() === PHP_SESSION_NONE) session_start(); + +// 检查管理员登录 +if (!isAdminLoggedIn()) { + header('Location: admin.php'); + exit; +} + +$initialState = json_encode([ + 'logged_in' => true, + 'role' => 'admin', + 'username' => 'admin', +], JSON_UNESCAPED_UNICODE); +?> + + + + + + + + +SDF1 - 充值订单对账 + + + + +
+
+

💳 充值订单对账

+ ← 返回管理后台 +
+
+ 管理员 + +
+
+ +
+ +
+
+
-
+
总订单数
+
+
+
-
+
已支付
+
+
+
-
+
待支付
+
+
+
-
+
总金额(元)
+
+
+ + +
+

🔍 筛选与搜索

+ +
+ + +
+

📋 充值订单列表

+
加载中...
+
+
+ + +
+
+

订单详情

+
+
+ +
+
+
+ + + +