充值对账页面+防重复支付+高频补单+轮询器改进

- recharge_orders.php + recharge_orders_api.php: 管理员充值订单对账页面
- admin.php 侧边栏添加充值对账入口
- pay.php: 创建订单时检测已有未支付订单(复用),防重复支付
- player.php: 续支付检测+支付成功后清除本地订单+恢复按钮
- poller_online.php + pay.php: 添加web_transactions唯一索引
- .gitignore: 排除密钥.md

关闭 #1056 #1057 #1055 #1060 部分
This commit is contained in:
ypsdf1
2026-07-15 18:00:11 +08:00
parent 3a53ec5fdc
commit 449976478e
7 changed files with 834 additions and 3 deletions
+3
View File
@@ -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
+6
View File
@@ -140,6 +140,7 @@
<div class="si" data-p="usergroups" onclick="go('usergroups')">👥 用户组</div>
<div class="si" data-p="cashier" onclick="openCashierPage()">🧾 收银台(独立页)</div>
<div class="si" data-p="cashier_manage" onclick="go('cashier_manage')">🧾 收银员管理</div>
<div class="si" data-p="recharge_orders" onclick="openRechargeOrdersPage()">💳 充值对账(独立页)</div>
</div>
<div class="content" id="C"></div>
</div>
@@ -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 = `<div class="card">
<h2>🧾 收银员管理</h2>
+22 -1
View File
@@ -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);
+267
View File
@@ -0,0 +1,267 @@
<?php
/**
* 支付补单器(poller_online.php)— 绕过 Cloudflare WAF 拦截的 HTTP 回调通知
*
* 工作原理:
* 直接读平台 MySQL 中已支付订单(status IN (1,2)) → 与本地 SQLite(pay_orders) 比对 →
* 将未处理的订单写入 web_transactions(recharge, pending) → 供 Java 高频定时器拉取 → addBonds 到账
*
* 触发方式(双保险):
* 1. 玩家在 Web 端查询订单时,pay.php 的 query_order 异步调用本脚本(前端触发)
* 2. Java 定时拉取 web_transactions 前,先调用本脚本(后台定时触发,最可靠)
*
* 完全自包含,不依赖 core.php,避免 500 错误。幂等:重复跑不会重复写流水。
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
while (ob_get_level() > 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();
}
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* 充值订单API(recharge_orders_api.php)
* 提供充值订单查询、统计、筛选功能
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
// 检查管理员登录
if (!isAdminLoggedIn()) {
error('无权限访问', 403);
}
$action = getParam('action', '');
switch ($action) {
case 'list':
handleOrderList();
break;
case 'detail':
handleOrderDetail();
break;
case 'reconcile':
handleReconcile();
break;
default:
error('未知操作');
}
/**
* 获取订单列表
*/
function handleOrderList() {
$db = getDB();
ensurePayOrdersTable($db);
$player = getParam('player', '');
$orderNo = getParam('order_no', '');
$status = getParam('status', '');
$limit = min(200, max(1, (int)getParam('limit', 50)));
// 构建查询
$conditions = [];
$params = [];
if ($player) {
$conditions[] = "player_name LIKE :player";
$params[':player'] = '%' . $player . '%';
}
if ($orderNo) {
$conditions[] = "out_trade_no LIKE :order_no";
$params[':order_no'] = '%' . $orderNo . '%';
}
if ($status) {
$conditions[] = "status = :status";
$params[':status'] = $status;
}
$where = '';
if ($conditions) {
$where = 'WHERE ' . implode(' AND ', $conditions);
}
// 查询订单
$sql = "SELECT * FROM pay_orders $where ORDER BY created_at DESC LIMIT :limit";
$stmt = $db->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");
}
}
+46 -2
View File
@@ -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 = '<span style="color:var(--green)">已打开支付页面…</span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
window.open(payUrl, '_blank');
pollRechargeOrder(false);
} else {
st.innerHTML = '<span style="color:var(--red)">' + (r.message || '获取支付链接失败') + '</span>';
btn.disabled = false;
btn.textContent = '🔄 继续支付';
}
} catch (e) {
st.innerHTML = '<span style="color:var(--red)">请求异常: ' + e.message + '</span>';
btn.disabled = false;
btn.textContent = '🔄 继续支付';
}
};
st.innerHTML = '<span style="color:var(--yellow)">⚠️ 您有一笔未完成的订单,<a href="javascript:void(0)" onclick="document.getElementById(\'rechargeBtn\').click()" style="color:var(--accent)">点此继续支付</a></span>';
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 = '<span style="color:var(--green)">订单已创建,正在跳转支付宝…(如未弹出,请允许浏览器打开新窗口)</span>';
st.innerHTML = '<span style="color:var(--green)">' + (reused ? '恢复支付链接' : '订单已创建') + ',正在跳转支付宝…(如未弹出,请允许浏览器打开新窗口)</span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
const w = window.open(pay_url, '_blank');
if (!w) {
st.innerHTML = '<span style="color:var(--yellow)">浏览器拦截了新窗口,<a href="' + pay_url + '" target="_blank" style="color:var(--accent)">点此手动打开支付页面</a></span>';
}
// ★ 防重复支付:创建订单后按钮永久锁定,只有支付成功后才恢复
pollRechargeOrder(false);
} catch (e) {
st.innerHTML = '<span style="color:var(--red)">请求异常:' + e.message + '</span>';
@@ -1146,6 +1186,10 @@ if ($currentVersion !== $BUILD_VERSION) {
if (res.data.status === 'paid') {
st.innerHTML = '<span style="color:var(--green)">✅ 支付成功!游戏内债券发放中,请稍候刷新「余额查询」。</span>';
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 = '<span style="color:var(--dim)">⏳ 订单处理中(' + (res.message || '等待支付平台通知') + ')…</span>';
}
+315
View File
@@ -0,0 +1,315 @@
<?php
/**
* 充值订单记录页面(管理员对账专用)
* 显示充值订单,支持手动对账、查看支付状态
*/
error_reporting(E_ALL);
ini_set('display_errors', '0');
set_error_handler(function($errno, $errstr, $errfile, $errline) {
$types = [E_WARNING=>'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);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, proxy-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>SDF1 - 充值订单对账</title>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.header { background:linear-gradient(135deg,#1a1e2e,#0d1117); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:50; }
.header h1 { font-size:18px; color:var(--accent); }
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all .2s cubic-bezier(.16,1,.3,1); }
.btn-blue { background:var(--accent); color:#fff; }
.btn-green { background:var(--green); color:#fff; }
.btn-red { background:var(--red); color:#fff; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--dim); }
.btn:hover { opacity:.88; transform:translateY(-1px); }
.container { max-width:1200px; margin:20px auto; padding:0 16px; }
.panel { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:18px; margin-bottom:16px; }
.panel h3 { font-size:15px; color:var(--accent); margin-bottom:12px; display:flex; align-items:center; gap:8px; }
/* 搜索栏 */
.search-bar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
.search-bar input, .search-bar select { padding:9px 12px; background:var(--bg); border:1px solid var(--border); border-radius:7px; color:var(--text); font-size:13px; outline:none; flex:1; min-width:160px; }
.search-bar input:focus { border-color:var(--accent); }
/* 统计卡片 */
.stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:12px; margin-bottom:20px; }
.stat-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:16px; text-align:center; }
.stat-card .stat-value { font-size:28px; font-weight:700; margin-bottom:4px; }
.stat-card .stat-label { font-size:12px; color:var(--dim); }
.stat-card.green .stat-value { color:var(--green); }
.stat-card.yellow .stat-value { color:var(--yellow); }
.stat-card.red .stat-value { color:var(--red); }
.stat-card.blue .stat-value { color:var(--accent); }
/* 订单列表 */
.order-row { font-size:13px; padding:14px; background:var(--bg); border:1px solid var(--border); border-radius:10px; margin-bottom:10px; transition:border-color .2s; cursor:pointer; }
.order-row:hover { border-color:var(--accent); }
.order-row .ot { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
.order-row .ono { color:var(--accent); font-weight:700; font-size:14px; }
.order-row .od { color:var(--dim); font-size:12px; margin-top:4px; line-height:1.5; }
.order-row .op { font-size:11px; color:var(--purple); }
/* 订单详情弹窗 */
#detailOverlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,.55); z-index:6000; justify-content:center; align-items:flex-start; padding:24px 12px; overflow-y:auto; }
#detailOverlay.show { display:flex; }
.detail-card { background:var(--card); border:1px solid var(--border); border-radius:14px; padding:24px; width:520px; max-width:100%; box-shadow:0 16px 48px rgba(0,0,0,.5); }
.detail-card h2 { color:var(--accent); font-size:17px; margin-bottom:14px; }
.detail-card .d-line { display:flex; justify-content:space-between; padding:7px 0; border-bottom:1px solid var(--border); font-size:13px; }
.detail-card .d-line.total { border-bottom:none; font-size:17px; font-weight:700; margin-top:10px; padding-top:12px; border-top:2px solid var(--accent); }
.detail-card .d-total-val { color:var(--green); }
.detail-close { margin-top:16px; text-align:right; }
.tag { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600; }
.tag-created { background:rgba(210,153,34,.15); color:var(--yellow); }
.tag-paid { background:rgba(63,185,80,.15); color:var(--green); }
.tag-failed { background:rgba(248,81,73,.15); color:var(--red); }
.empty-state { text-align:center; color:var(--dim); padding:40px; font-size:14px; }
/* 滚动条 */
*::-webkit-scrollbar { width:8px; }
*::-webkit-scrollbar-track { background:transparent; }
*::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
@media(max-width:600px){ .detail-card { width:100%; padding:16px; } .container{margin:10px auto;padding:0 10px;} .stats{grid-template-columns:1fr 1fr;} }
</style>
</head>
<body>
<div class="header">
<div style="display:flex;align-items:center;gap:12px">
<h1>💳 充值订单对账</h1>
<a href="admin.php" class="btn btn-ghost" style="text-decoration:none;font-size:12px">← 返回管理后台</a>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span class="tag tag-paid">管理员</span>
<button class="btn btn-green" onclick="manualReconcile()">🔄 立即对账</button>
</div>
</div>
<div class="container">
<!-- 统计卡片 -->
<div class="stats">
<div class="stat-card blue">
<div class="stat-value" id="statTotal">-</div>
<div class="stat-label">总订单数</div>
</div>
<div class="stat-card green">
<div class="stat-value" id="statPaid">-</div>
<div class="stat-label">已支付</div>
</div>
<div class="stat-card yellow">
<div class="stat-value" id="statCreated">-</div>
<div class="stat-label">待支付</div>
</div>
<div class="stat-card red">
<div class="stat-value" id="statTotalMoney">-</div>
<div class="stat-label">总金额(元)</div>
</div>
</div>
<!-- 搜索/筛选 -->
<div class="panel">
<h3>🔍 筛选与搜索</h3>
<div class="search-bar">
<input type="text" id="searchPlayer" placeholder="玩家名..." oninput="loadOrders()">
<input type="text" id="searchOrderNo" placeholder="订单号..." oninput="loadOrders()">
<select id="filterStatus" onchange="loadOrders()">
<option value="">全部状态</option>
<option value="paid">已支付</option>
<option value="created">待支付</option>
</select>
<select id="limit" onchange="loadOrders()">
<option value="30">最近 30 条</option>
<option value="50" selected>最近 50 条</option>
<option value="100">最近 100 条</option>
<option value="200">最近 200 条</option>
</select>
</div>
</div>
<!-- 订单列表 -->
<div class="panel">
<h3>📋 充值订单列表</h3>
<div id="orderList"><div class="empty-state">加载中...</div></div>
</div>
</div>
<!-- 订单详情弹窗 -->
<div id="detailOverlay" onclick="if(event.target===this)closeDetail()">
<div class="detail-card">
<h2>订单详情</h2>
<div id="detailContent"></div>
<div class="detail-close">
<button class="btn btn-ghost" onclick="closeDetail()">关闭</button>
</div>
</div>
</div>
<script>
const A = 'api/recharge_orders_api.php';
let orders = [];
// 加载订单列表
async function loadOrders() {
const player = document.getElementById('searchPlayer').value.trim();
const orderNo = document.getElementById('searchOrderNo').value.trim();
const status = document.getElementById('filterStatus').value;
const limit = document.getElementById('limit').value;
try {
const params = new URLSearchParams({ action: 'list', limit });
if (player) params.append('player', player);
if (orderNo) params.append('order_no', orderNo);
if (status) params.append('status', status);
const res = await fetch(A + '?' + params.toString());
const data = await res.json();
if (data.error) {
document.getElementById('orderList').innerHTML = '<div class="empty-state">加载失败: ' + data.error.message + '</div>';
return;
}
orders = data.orders || [];
updateStats(data.stats || {});
renderOrders();
} catch (e) {
console.error('加载失败:', e);
document.getElementById('orderList').innerHTML = '<div class="empty-state">网络错误,请重试</div>';
}
}
// 更新统计
function updateStats(stats) {
document.getElementById('statTotal').textContent = stats.total || 0;
document.getElementById('statPaid').textContent = stats.paid || 0;
document.getElementById('statCreated').textContent = stats.created || 0;
document.getElementById('statTotalMoney').textContent = stats.totalMoney || '0.00';
}
// 渲染订单列表
function renderOrders() {
const container = document.getElementById('orderList');
if (!orders.length) {
container.innerHTML = '<div class="empty-state">暂无订单记录</div>';
return;
}
container.innerHTML = orders.map(o => {
const statusTag = o.status === 'paid'
? '<span class="tag tag-paid">已支付</span>'
: '<span class="tag tag-created">待支付</span>';
const createdTime = new Date(o.created_at * 1000).toLocaleString('zh-CN');
const paidTime = o.paid_at ? new Date(o.paid_at * 1000).toLocaleString('zh-CN') : '-';
return `
<div class="order-row" onclick="showDetail('${o.out_trade_no}')">
<div class="ot">
<span class="ono">${o.out_trade_no}</span>
${statusTag}
</div>
<div class="od">
玩家: ${o.player_name} | 金额: ¥${o.money} | 债券: ${o.bond_amount}
</div>
<div class="op">
创建: ${createdTime} | 支付: ${paidTime}
</div>
</div>
`;
}).join('');
}
// 显示订单详情
function showDetail(outTradeNo) {
const order = orders.find(o => o.out_trade_no === outTradeNo);
if (!order) return;
const createdTime = new Date(order.created_at * 1000).toLocaleString('zh-CN');
const paidTime = order.paid_at ? new Date(order.paid_at * 1000).toLocaleString('zh-CN') : '-';
const content = `
<div class="d-line"><span>订单号</span><span style="color:var(--accent)">${order.out_trade_no}</span></div>
<div class="d-line"><span>玩家名</span><span>${order.player_name}</span></div>
<div class="d-line"><span>支付金额</span><span style="color:var(--green)">¥${order.money}</span></div>
<div class="d-line"><span>债券数量</span><span style="color:var(--yellow)">${order.bond_amount}</span></div>
<div class="d-line"><span>订单状态</span><span>${order.status === 'paid' ? '✅ 已支付' : '⏳ 待支付'}</span></div>
<div class="d-line"><span>平台交易号</span><span>${order.trade_no || '-'}</span></div>
<div class="d-line"><span>创建时间</span><span>${createdTime}</span></div>
<div class="d-line"><span>支付时间</span><span>${paidTime}</span></div>
<div class="d-line"><span>商品名称</span><span>${order.name || '-'}</span></div>
<div class="d-line"><span>签名</span><span style="font-size:11px;color:var(--dim);word-break:break-all">${order.platform_sign || '-'}</span></div>
`;
document.getElementById('detailContent').innerHTML = content;
document.getElementById('detailOverlay').classList.add('show');
}
// 关闭详情弹窗
function closeDetail() {
document.getElementById('detailOverlay').classList.remove('show');
}
// 手动对账
async function manualReconcile() {
if (!confirm('立即对账将查询支付平台已支付订单并补写流水,是否继续?')) return;
try {
const res = await fetch('api/poller_online.php');
const data = await res.json();
if (data.error) {
alert('对账失败: ' + data.error);
} else if (data.result === 'already_running') {
alert('另一个对账进程正在运行,请稍后重试');
} else if (data.result === 'ok') {
alert(`对账完成!处理 ${data.processed} 笔,跳过 ${data.skipped} 笔`);
loadOrders(); // 刷新列表
} else {
alert('对账结果: ' + (data.detail || data.result));
}
} catch (e) {
console.error('对账失败:', e);
alert('对账请求失败,请重试');
}
}
// 页面加载
document.addEventListener('DOMContentLoaded', loadOrders);
// 键盘快捷键
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeDetail();
});
</script>
</body>
</html>