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 @@
🧾 收银员管理
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 - 充值订单对账
+
+
+
+
+
+
+
+
+
+
+
+
+
+