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

- 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
+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");
}
}