defined('PAY_PID') ? PAY_PID : '',
'private_key' => wrapPem(PAY_MCH_PRIVATE_KEY, 'private'),
'public_key' => wrapPem(PAY_PLATFORM_PUBLIC_KEY, 'public'),
'md5_key' => defined('PAY_MD5_KEY') ? PAY_MD5_KEY : '',
];
}
}
return ['pid' => '', 'private_key' => '', 'public_key' => '', 'md5_key' => ''];
}
/**
* 获取平台MySQL数据库连接(集中凭据管理)
* 凭据从 pay_secrets.php 读取,不再硬编码
*/
function getPlatformDB() {
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_TIMEOUT => 10, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return $pdo;
}
/** 将裸 base64 包装为 PEM。私钥自动探测 PKCS#1(RSA PRIVATE KEY) / PKCS#8(PRIVATE KEY),公钥用 SubjectPublicKeyInfo(PUBLIC KEY) */
function wrapPem($b64, $type) {
$b64 = preg_replace('/\s+/', '', $b64);
if ($b64 === '') return '';
// 探测 DER:PKCS#8(PRIVATE KEY) 与 PKCS#1(RSA PRIVATE KEY) 都以
// SEQUENCE{INTEGER 0(version)} 开头(即 30 82 .. 02 01 00),无法靠前 7 字节区分。
// 关键在 version 之后紧跟的标签:PKCS#8 是 AlgorithmIdentifier 的 SEQUENCE(0x30),
// PKCS#1 是 modulus 的 INTEGER(0x02)。据此决定用哪个 PEM 头(opensslLoadKey 仍有兜底重试)。
$header = 'PUBLIC KEY';
if ($type === 'private') {
$raw = @base64_decode($b64, true);
$isPkcs1 = ($raw !== false && strlen($raw) > 8
&& $raw[0] === "\x30" && $raw[1] === "\x82"
&& $raw[4] === "\x02" && $raw[5] === "\x01" && $raw[6] === "\x00"
&& $raw[7] === "\x02");
$header = $isPkcs1 ? 'RSA PRIVATE KEY' : 'PRIVATE KEY';
}
$body = trim(chunk_split($b64, 64, "\n"));
return "-----BEGIN $header-----\n$body\n-----END $header-----\n";
}
/**
* 容错加载密钥:首次用给定 PEM 加载失败后,自动在 PKCS#1<->PKCS#8 头之间切换重试,
* 彻底规避「密钥格式与 PEM 头不匹配」导致的签名/验签失败(支付链路关键路径,必须健壮)。
*/
function opensslLoadKey($pem, $isPrivate) {
$fn = $isPrivate ? 'openssl_pkey_get_private' : 'openssl_pkey_get_public';
$key = @$fn($pem);
if ($key !== false) return $key;
if (!$isPrivate) return false;
// 私钥:在 RSA PRIVATE KEY <-> PRIVATE KEY 之间切换重试
$alt = preg_replace('/-----BEGIN RSA PRIVATE KEY-----/', "-----BEGIN PRIVATE KEY-----", $pem);
$alt = preg_replace('/-----END RSA PRIVATE KEY-----/', "-----END PRIVATE KEY-----", $alt);
if ($alt !== $pem && ($key = @$fn($alt)) !== false) return $key;
$alt = preg_replace('/-----BEGIN PRIVATE KEY-----/', "-----BEGIN RSA PRIVATE KEY-----", $pem);
$alt = preg_replace('/-----END PRIVATE KEY-----/', "-----END RSA PRIVATE KEY-----", $alt);
if ($alt !== $pem && ($key = @$fn($alt)) !== false) return $key;
return false;
}
/**
* 请求签名:根据 sign_type 选择算法
* - RSA : 取全部非空参数 → 剔除 sign/sign_type → ASCII 升序 → k=v& 拼接 → 私钥 SHA256WithRSA 签名
* - MD5 : 同上拼接后追加 &key=商户MD5密钥 → md5(彩虹易支付MD5方式,无需密钥对,可绕过RSA密钥不匹配)
*/
function buildSign($params, $keys) {
$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);
$type = $params['sign_type'] ?? PAY_SIGN_TYPE;
if ($type === 'RSA') {
$key = opensslLoadKey($keys['private_key'], true);
if ($key === false) {
throw new Exception('商户私钥加载失败: ' . openssl_error_string());
}
openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256);
if (PHP_VERSION_ID < 80000) {
@openssl_free_key($key);
}
return base64_encode($signature);
}
// MD5:平台规则为 md5(待签串 + 商户MD5密钥),【无】&key= 后缀(与彩虹易支付源码 Payment::makeSign 一致)
if (empty($keys['md5_key'])) {
throw new Exception('商户MD5密钥未配置');
}
return md5($data . $keys['md5_key']);
}
/**
* 回调验签:根据回调 sign_type 选择算法(兼容平台RSA/MD5两种回调)
*/
function verifyNotifySign($params, $keys) {
if (empty($params['sign'])) {
debugLog('[pay notify] 验签失败: 缺少sign参数', ['params_keys' => array_keys($params)]);
return false;
}
$sign = $params['sign'];
$filtered = [];
foreach ($params as $k => $v) {
// 排除 sign/sign_type(签名字段)和 action(URL路由参数,平台签名不含此项)
if ($k === 'sign' || $k === 'sign_type' || $k === 'action') 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);
$type = $params['sign_type'] ?? PAY_SIGN_TYPE;
if ($type === 'RSA') {
if (empty($keys['public_key'])) {
debugLog('[pay notify] 验签失败: RSA但无公钥', ['type' => $type]);
return false;
}
$key = opensslLoadKey($keys['public_key'], false);
if ($key === false) {
debugLog('[pay notify] 验签失败: RSA公钥加载失败', ['public_key_start' => substr($keys['public_key'], 0, 50)]);
return false;
}
$ok = openssl_verify($data, base64_decode($sign), $key, OPENSSL_ALGO_SHA256);
if (PHP_VERSION_ID < 80000) {
@openssl_free_key($key);
}
debugLog('[pay notify] RSA验签结果', ['data' => $data, 'ok' => $ok]);
return $ok === 1;
}
// MD5:与提交签名规则一致,md5(待签串 + 商户MD5密钥),无 &key= 后缀
if (empty($keys['md5_key'])) {
debugLog('[pay notify] 验签失败: MD5但无密钥', ['type' => $type]);
return false;
}
$expected = strtolower(md5($data . $keys['md5_key']));
$actual = strtolower($sign);
$ok = ($expected === $actual);
debugLog('[pay notify] MD5验签', ['data' => $data, 'md5_key' => $keys['md5_key'], 'expected' => $expected, 'actual' => $actual, 'ok' => $ok]);
return $ok;
}
// ====================================================================
// 数据表
// ====================================================================
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
)");
// 旧表可能由早期版本创建、不含 submit_params 列;
// SQLite 不支持 ALTER ... ADD COLUMN IF NOT EXISTS,故用 PRAGMA 探测后补齐,
// 否则 CREATE TABLE IF NOT EXISTS 不会加列,导致 UPDATE submit_params 报 “no such column”。
$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");
}
}
/**
* 确保充值流水表存在(notify 写入 web_transactions 供 Java 高频定时器拉取)。
* 该表可能由其它模块创建,这里做幂等兜底,避免新部署时表不存在导致 notify 抛异常返回 fail。
*/
function ensureWebTransactionsTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
type TEXT,
amount INTEGER DEFAULT 0,
operator TEXT,
reason TEXT,
detail TEXT,
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)");
}
// ====================================================================
// 动作:create_order
// ====================================================================
function createOrder($token, $productId = 0) {
$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 = getOrdersDB();
ensurePayOrdersTable($db);
// ★ 防重复支付:检查玩家是否有未支付的已有订单(仅5分钟内有效)
$staleThreshold = time() - 300; // 5分钟过期
$checkStmt = $db->prepare("SELECT out_trade_no, submit_params, money, bond_amount, created_at FROM pay_orders WHERE player_name = :p AND status = 'created' AND created_at >= :stale ORDER BY created_at DESC LIMIT 1");
$checkStmt->bindValue(':p', $player, SQLITE3_TEXT);
$checkStmt->bindValue(':stale', $staleThreshold, SQLITE3_INTEGER);
$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,
], '检测到您有未支付的订单,已为您恢复支付链接');
}
// ★ 清理该玩家30分钟前的陈旧created订单(已被实际支付但本地未更新),避免阻塞后续支付
$cleanStmt = $db->prepare("UPDATE pay_orders SET status = 'expired' WHERE player_name = :p AND status = 'created' AND created_at < :stale");
$cleanStmt->bindValue(':p', $player, SQLITE3_TEXT);
$cleanStmt->bindValue(':stale', $staleThreshold, SQLITE3_INTEGER);
$cleanStmt->execute();
// ===== 获取商品信息 =====
$money = PAY_TEST_MONEY; // 默认测试价格
$bonds = PAY_TEST_BONDS; // 默认测试债券
$name = PAY_TEST_NAME; // 默认测试名称
if ($productId > 0) {
// 从shop_configs表获取商品信息
$stmt = $db->prepare("SELECT * FROM shop_configs WHERE id = :id AND is_active = 1");
$stmt->bindValue(':id', $productId, SQLITE3_INTEGER);
$product = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if ($product) {
// 检查库存
if ($product['stock'] == 0) {
error('商品已售罄');
}
// ★ 检查限时折扣是否已过期
$actualPrice = $product['price'];
$offerExpired = false;
if (!empty($product['temporary_offer']) && !empty($product['offer_expire'])) {
$expireInfo = parseExpireDate($product['offer_expire']);
if (!$expireInfo['valid']) {
// 折扣已过期 → 使用原价,不使用折扣价
$offerExpired = true;
}
}
// 使用折扣价格(如果折扣未过期)
if (!$offerExpired && isset($product['discount_price']) && $product['discount_price'] > 0) {
$actualPrice = $product['discount_price'];
}
$money = number_format($actualPrice, 2, '.', '');
$name = $product['item_name'];
// 解析债券范围
$bondReward = $product['bond_reward'];
if (preg_match('/^(\d+)-(\d+)$/', $bondReward, $m)) {
// 随机范围内的债券数量
$bondMin = intval($m[1]);
$bondMax = intval($m[2]);
$bonds = mt_rand($bondMin, $bondMax);
} else {
$bonds = intval($bondReward);
}
// 更新库存(如果不是无限库存)
if ($product['stock'] > 0) {
$updateStmt = $db->prepare("UPDATE shop_configs SET stock = stock - 1 WHERE id = :id AND stock > 0");
$updateStmt->bindValue(':id', $productId, SQLITE3_INTEGER);
$updateStmt->execute();
}
}
}
// ★ 订单号格式(2026-10-06):前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名
// 例:RE20261006171418Q101youpaishidifu
// 前缀读 config.php 的 PAY_ORDER_PREFIX(多服隔离,不再写死 'RE');
// 加玩家名是为了在共用的平台 pay_order 表里一眼看出这单属于谁。
// 长度护栏与玩家名净化见 pay_config.php 的 payMakeOrderNo()。
$outTradeNo = payMakeOrderNo($player);
// 构造提交参数(原始值,不做 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(), // 10位Unix时间戳,单位秒,符合彩虹易支付接口要求
'sign_type' => PAY_SIGN_TYPE,
];
$params['sign'] = buildSign($params, $keys);
$submitParamsJson = json_encode($params, JSON_UNESCAPED_SLASHES);
// ★ 一次性 INSERT:submit_params 直接写入,避免 INSERT/UPDATE 之间被锁或异常导致空参数
$now = time();
$stmt = $db->prepare("INSERT INTO pay_orders (out_trade_no, player_name, tier_id, money, bond_amount, status, name, submit_params, created_at) VALUES (:no, :p, 0, :m, :b, 'created', :n, :sp, :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(':sp', $submitParamsJson, SQLITE3_TEXT);
$stmt->bindValue(':t', $now, SQLITE3_INTEGER);
$stmt->execute();
debugLog('[createOrder] 订单创建成功', ['out_trade_no' => $outTradeNo, 'player' => $player, 'submit_params_len' => strlen($submitParamsJson)]);
// 支付网关要求 POST 提交($_POST['pid']),不能 GET 直接跳转。
// 返回本地中转页 URL:浏览器打开后由 JS 自动 POST 表单到网关,规避 GET 传输下 notify_url 内 & 被截断的问题。
$scriptBase = preg_replace('/\?.*$/', '', PAY_NOTIFY_URL);
$payUrl = $scriptBase . '?action=pay_redirect&out_trade_no=' . urlencode($outTradeNo);
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 = $_REQUEST; // 平台回调以 GET 方式把签名参数 append 到 notify_url 后,统一用 $_REQUEST 兼容 GET/POST
// 记录详细的请求信息用于调试
$requestInfo = [
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'query_string' => $_SERVER['QUERY_STRING'] ?? '',
'params_count' => count($params),
'params_keys' => array_keys($params),
];
debugLog('[pay notify] 收到异步回调', array_merge($requestInfo, $params));
$keys = loadPayKeys();
// 1) 验签(兼容平台RSA/MD5回调)
if (!verifyNotifySign($params, $keys)) {
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 = getOrdersDB(); // orders.db — pay_orders
ensurePayOrdersTable($db);
$webDb = getDB(); // web.db — web_transactions
ensureWebTransactionsTable($webDb);
$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 . ',尝试从平台同步');
// 本地没有订单 → 尝试从平台MySQL补建
$platformResult = checkPlatformOrderStatus($outTradeNo);
if (is_array($platformResult) && $platformResult['status'] === 'paid') {
$now = time();
$platformPlayer = $platformResult['player'] ?? ($params['param'] ?? 'unknown');
$bonds = PAY_TEST_BONDS;
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, name, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :n, '', :t, :t)");
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$ins->bindValue(':tn', $platformResult['trade_no'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':p', $platformPlayer, SQLITE3_TEXT);
$ins->bindValue(':m', $platformResult['money'] ?? '0.01', SQLITE3_TEXT);
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
$ins->bindValue(':n', PAY_TEST_NAME, SQLITE3_TEXT);
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
$ins->execute();
// 写 web_transactions → web.db
$txIns = $webDb->prepare("INSERT INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '支付平台(callback同步)', '在线充值(支付宝)', :d, 'pending', :t)");
$txIns->bindValue(':p', $platformPlayer, SQLITE3_TEXT);
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
debugLog('[pay notify] callback同步成功', ['out_trade_no' => $outTradeNo, 'player' => $platformPlayer]);
// 发送付款凭据邮件(异步静默)
try {
sendPaymentReceiptEmail($platformPlayer, $outTradeNo, $platformResult['money'] ?? '0.01', $bonds, '在线充值', $now);
} catch (\Throwable $e) {
debugLog('[pay notify] callback同步邮件发送失败', ['player' => $platformPlayer, 'error' => $e->getMessage()]);
}
echo 'success';
exit;
}
// 平台也没有 → 返回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) 标记订单已支付(orders.db)
$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 到账 → web.db
$stmt = $webDb->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]);
// 7) 发送付款凭据邮件(异步静默,不影响主流程)
try {
sendPaymentReceiptEmail($row['player_name'], $outTradeNo, $row['money'], (int)$row['bond_amount'], $row['name'] ?? '在线充值', time());
} catch (\Throwable $e) {
debugLog('[pay notify] 邮件发送失败(不影响主流程)', ['player' => $row['player_name'], 'error' => $e->getMessage()]);
}
echo 'success';
exit;
}
// ====================================================================
// 动作:pay_redirect(本地中转页,浏览器打开后自动 POST 到支付网关)
// ====================================================================
function handlePayRedirect() {
while (ob_get_level() > 0) { ob_end_clean(); }
$outTradeNo = getParam('out_trade_no');
if (!$outTradeNo) {
debugLog('[pay_redirect] 缺少订单号');
header('Content-Type: text/html; charset=utf-8');
echo '缺少订单号';
exit;
}
$db = getOrdersDB();
ensurePayOrdersTable($db);
$stmt = $db->prepare("SELECT submit_params, status, player_name FROM pay_orders WHERE out_trade_no = :no");
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) {
debugLog('[pay_redirect] 订单不存在', ['out_trade_no' => $outTradeNo]);
header('Content-Type: text/html; charset=utf-8');
echo '订单不存在';
exit;
}
if (empty($row['submit_params'])) {
debugLog('[pay_redirect] submit_params为空', ['out_trade_no' => $outTradeNo, 'status' => $row['status'], 'player' => $row['player_name']]);
header('Content-Type: text/html; charset=utf-8');
echo '支付参数尚未生成,请返回重试';
exit;
}
$params = json_decode($row['submit_params'], true);
if (!is_array($params)) {
header('Content-Type: text/html; charset=utf-8');
echo '支付参数损坏';
exit;
}
// 已支付则直接跳回结果页,不再重复提交
if ($row['status'] === 'paid') {
header('Location: ' . PAY_RETURN_URL, true, 302);
exit;
}
$fields = '';
foreach ($params as $k => $v) {
$fields .= '' . "\n";
}
header('Content-Type: text/html; charset=utf-8');
echo '
正在跳转支付...' .
'' .
'' .
'';
exit;
}
// ====================================================================
// 辅助:后台异步触发支付轮询器(非阻塞)
// ====================================================================
function triggerPollerAsync() {
// ★ 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);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
// 不等待结果,后台执行
curl_exec($ch);
curl_close($ch);
}
// ====================================================================
// 动作:query_order(前端补单 / 轮询)
// ====================================================================
function queryOrder($token) {
$info = validateTokenSilent($token);
if (!$info || empty($info['player'])) {
error('登录状态无效', 401);
}
$outTradeNo = getParam('out_trade_no');
if (!$outTradeNo) {
error('缺少订单号');
}
$db = getOrdersDB();
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);
// ★ 本地订单不存在 → 双路查询(MySQL + 官方API),如果平台已支付则同步到本地
if (!$row) {
// ★ 2026-10-08 Round D(用户指示):不再按「前缀码」判定归属。
// 创单时本端 payMakeOrderNo 生成的订单号原样入库 pay_orders,组合规则
// (pay.md) 由使用者自定义,订单号形态任意(哪怕 123456、玩家名打头)。
// 补单/查询认「本地 pay_orders 是否已有该完整订单号」——本地存在即本端单;
// 若本地无记录,再用本端有效前缀做一次兜底(防拿他人平台的任意单号来查)。
// 两个平台不可能共用同一订单号,按完整订单号精确匹配即为安全网。
$hasPrefixOurs = payOrderIsOurs($outTradeNo);
if (!$hasPrefixOurs) {
debugLog('[queryOrder] 本地无此订单号且非本端前缀,仍放行平台直查(按完整订单号精确匹配)', [
'out_trade_no' => $outTradeNo,
'expect_prefix' => payOrderPrefix(),
'player' => $info['player'],
]);
}
debugLog('[queryOrder] 本地订单不存在,尝试双路查询同步', ['out_trade_no' => $outTradeNo, 'player' => $info['player']]);
// 路径1:平台MySQL直查
debugLog('[queryOrder] 路径1: 平台MySQL直查', ['out_trade_no' => $outTradeNo]);
$platformResult = checkPlatformOrderStatus($outTradeNo);
$isPaid = is_array($platformResult) && $platformResult['status'] === 'paid';
debugLog('[queryOrder] MySQL查询结果', [
'out_trade_no' => $outTradeNo,
'result' => $platformResult,
'is_paid' => $isPaid,
]);
// 路径2:MySQL失败或未支付 → 官方API查询
if (!$isPaid) {
debugLog('[queryOrder] 路径2: MySQL未找到已支付记录,尝试官方API', ['out_trade_no' => $outTradeNo]);
$apiResult = queryPlatformAPI($outTradeNo);
debugLog('[queryOrder] 官方API查询结果', [
'out_trade_no' => $outTradeNo,
'result' => $apiResult,
]);
if (is_array($apiResult) && $apiResult['status'] === 'paid') {
$platformResult = $apiResult;
$isPaid = true;
debugLog('[queryOrder] 官方API查询到已支付', ['out_trade_no' => $outTradeNo]);
}
}
if ($isPaid) {
// 平台已支付 → 创建本地订单并标记paid
$now = time();
$platformPlayer = $platformResult['player'] ?: $info['player'];
$bonds = PAY_TEST_BONDS;
$money = $platformResult['money'] ?? PAY_TEST_MONEY;
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, name, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :n, '', :t, :t)");
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$ins->bindValue(':tn', $platformResult['trade_no'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':p', $platformPlayer, SQLITE3_TEXT);
$ins->bindValue(':m', $money, SQLITE3_TEXT);
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
$ins->bindValue(':n', PAY_TEST_NAME, SQLITE3_TEXT);
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
$ins->execute();
// 写 web_transactions → web.db
$webDb = getDB();
$txCheck = $webDb->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 = $webDb->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', $platformPlayer, SQLITE3_TEXT);
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
}
debugLog('[queryOrder] 平台同步成功', ['out_trade_no' => $outTradeNo, 'player' => $platformPlayer]);
// 发送付款凭据邮件(异步静默)
try {
sendPaymentReceiptEmail($platformPlayer, $outTradeNo, $money, $bonds, '在线充值', $now);
} catch (\Throwable $e) {
debugLog('[queryOrder] 平台同步邮件发送失败', ['player' => $platformPlayer, 'error' => $e->getMessage()]);
}
success([
'out_trade_no' => $outTradeNo,
'status' => 'paid',
'money' => $money,
'bonds' => $bonds,
'paid_at' => $now,
'created_at' => $now,
], '已支付,游戏内债券发放中(若未到账请稍候或联系管理员)');
return;
}
// 平台也没有 → 真的不存在
debugLog('[queryOrder] 平台也无此订单', ['out_trade_no' => $outTradeNo]);
error('订单不存在,请确认订单号是否正确');
}
// ★ 2026-07-15 核心修复:如果本地还是 created,双路查询(MySQL + 官方API)
// 不依赖 poller 补单,确保支付成功后立即返回 paid 状态
$status = $row['status'];
if ($status !== 'paid') {
// 路径1:平台MySQL直查
$platformResult = checkPlatformOrderStatus($outTradeNo);
$platformPaid = is_array($platformResult) && $platformResult['status'] === 'paid';
// 路径2:MySQL失败或未支付 → 官方API查询
if (!$platformPaid) {
debugLog('[queryOrder] MySQL未支付,尝试官方API', ['out_trade_no' => $outTradeNo]);
$apiResult = queryPlatformAPI($outTradeNo);
if (is_array($apiResult) && $apiResult['status'] === 'paid') {
$platformResult = $apiResult;
$platformPaid = true;
}
}
if ($platformPaid) {
// 平台已支付,立即更新本地状态
$now = time();
$upd = $db->prepare("UPDATE pay_orders SET status='paid', paid_at=:t WHERE out_trade_no=:no AND status != 'paid'");
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$upd->execute();
// 写 web_transactions(幂等)→ web.db
$webDb = getDB();
$txCheck = $webDb->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 = $webDb->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', $info['player'], SQLITE3_TEXT);
$txIns->bindValue(':a', (int)$row['bond_amount'], SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
}
$status = 'paid';
debugLog('[queryOrder] 直查平台补单成功', ['out_trade_no' => $outTradeNo, 'player' => $info['player']]);
// 发送付款凭据邮件(异步静默)
try {
sendPaymentReceiptEmail($info['player'], $outTradeNo, $row['money'] ?? '0.01', (int)$row['bond_amount'], $row['name'] ?? '在线充值', $now);
} catch (\Throwable $e) {
debugLog('[queryOrder] 邮件发送失败(不影响主流程)', ['player' => $info['player'], 'error' => $e->getMessage()]);
}
} else {
// 检查是否已过期(5分钟)
$nowSec = time();
if ($row['created_at'] && ($nowSec - $row['created_at']) > 300) {
$upd = $db->prepare("UPDATE pay_orders SET status='expired' WHERE out_trade_no=:no AND status='created'");
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$upd->execute();
$status = 'expired';
}
}
}
$message = '订单创建中';
if ($status === 'paid') {
$message = '已支付,游戏内债券发放中(若未到账请稍候或联系管理员)';
} elseif ($status === 'expired') {
$message = '订单已过期(超过5分钟),请重新下单';
}
success([
'out_trade_no' => $outTradeNo,
'status' => $status,
'money' => $row['money'],
'bonds' => (int)$row['bond_amount'],
'paid_at' => $row['paid_at'],
'created_at' => $row['created_at'],
], $message);
}
/**
* 直接查平台MySQL检查订单状态(不依赖poller)
* @return string 'paid'|'created'|'not_found'
*/
function checkPlatformOrderStatus($outTradeNo) {
try {
$pdo = getPlatformDB();
$stmt = $pdo->prepare("SELECT status, trade_no, money, param, addtime FROM `pay_order` WHERE out_trade_no = ? LIMIT 1");
$stmt->execute([$outTradeNo]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return 'not_found';
if (in_array($row['status'], [1, 2])) {
return ['status' => 'paid', 'trade_no' => $row['trade_no'], 'money' => $row['money'], 'player' => $row['param'] ?? ''];
}
return 'not_paid';
} catch (\Throwable $e) {
debugLog('[checkPlatformOrderStatus] 查询失败', ['error' => $e->getMessage(), 'out_trade_no' => $outTradeNo]);
return 'error';
}
}
/**
* 通过官方支付API查询订单状态(MD5签名)
* @param string $outTradeNo 商户订单号
* @return array|bool 成功返回 ['status'=>'paid', 'trade_no'=>..., 'money'=>..., 'player'=>...],失败返回 false
*/
function queryPlatformAPI($outTradeNo) {
$keys = getPayKeys();
if (empty($keys['pid']) || empty($keys['md5_key'])) {
debugLog('[queryPlatformAPI] 缺少pid或md5_key', ['pid' => $keys['pid'] ?? '']);
return false;
}
try {
$params = [
'pid' => $keys['pid'],
'out_trade_no' => $outTradeNo,
'timestamp' => (string)time(),
'sign_type' => 'MD5',
];
$params['sign'] = buildSign($params, $keys);
debugLog('[queryPlatformAPI] 开始查询', [
'out_trade_no' => $outTradeNo,
'pid' => $keys['pid'],
'timestamp' => $params['timestamp'],
'sign_type' => 'MD5',
]);
$url = 'https://zf.ypshidifu.cn/api/pay/query';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$resp = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$elapsed = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
curl_close($ch);
debugLog('[queryPlatformAPI] curl完成', [
'http_code' => $code,
'elapsed_s' => round($elapsed, 3),
'error' => $err ?: 'none',
'resp_len' => strlen($resp ?? ''),
]);
if ($err) {
debugLog('[queryPlatformAPI] curl失败', ['error' => $err, 'out_trade_no' => $outTradeNo]);
return false;
}
// 记录完整响应(前500字符)
debugLog('[queryPlatformAPI] 响应内容', [
'out_trade_no' => $outTradeNo,
'response' => substr($resp ?? '', 0, 500),
]);
$data = json_decode($resp, true);
if (!$data || ($data['code'] ?? -1) !== 0) {
debugLog('[queryPlatformAPI] 接口返回非成功', [
'code' => $data['code'] ?? 'null',
'msg' => $data['msg'] ?? '',
'out_trade_no' => $outTradeNo,
]);
return false;
}
// status: 0=未支付, 1=已支付, 2=已退款, 3=已冻结, 4=预授权
$st = (int)($data['status'] ?? -1);
debugLog('[queryPlatformAPI] 订单状态', [
'status' => $st,
'status_text' => ['0'=>'未支付','1'=>'已支付','2'=>'已退款','3'=>'已冻结','4'=>'预授权'][$st] ?? '未知',
'out_trade_no' => $outTradeNo,
'trade_no' => $data['trade_no'] ?? '',
'money' => $data['money'] ?? '',
'param' => $data['param'] ?? '',
]);
if ($st === 1) {
return [
'status' => 'paid',
'trade_no' => $data['trade_no'] ?? '',
'money' => $data['money'] ?? '',
'player' => $data['param'] ?? '',
];
}
return false;
} catch (\Throwable $e) {
debugLog('[queryPlatformAPI] 异常', [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => basename($e->getFile()),
'line' => $e->getLine(),
'out_trade_no' => $outTradeNo,
]);
return false;
}
}
// ====================================================================
// 动作:find_recent_order(localStorage丢失时的兜底:查最近订单)
// ====================================================================
function findRecentOrder($token) {
$info = validateTokenSilent($token);
if (!$info || empty($info['player'])) {
error('登录状态无效', 401);
}
$db = getOrdersDB();
ensurePayOrdersTable($db);
// 查最近5分钟内的订单(优先created,再paid)
$fiveMinAgo = time() - 300;
$stmt = $db->prepare("SELECT out_trade_no, status, created_at, money, bond_amount, paid_at FROM pay_orders WHERE player_name=:p AND created_at > :t ORDER BY created_at DESC LIMIT 5");
$stmt->bindValue(':p', $info['player'], SQLITE3_TEXT);
$stmt->bindValue(':t', $fiveMinAgo, SQLITE3_INTEGER);
$rows = [];
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $row;
}
if (empty($rows)) {
// 本地没有 → 查平台MySQL
// 扩大到30分钟
$thirtyMinAgo = time() - 1800;
$stmt2 = $db->prepare("SELECT out_trade_no, status, created_at, money, bond_amount, paid_at FROM pay_orders WHERE player_name=:p AND created_at > :t ORDER BY created_at DESC LIMIT 5");
$stmt2->bindValue(':p', $info['player'], SQLITE3_TEXT);
$stmt2->bindValue(':t', $thirtyMinAgo, SQLITE3_INTEGER);
$result2 = $stmt2->execute();
while ($row = $result2->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $row;
}
}
success(['orders' => $rows, 'player' => $info['player']]);
}
// ====================================================================
// 动作:get_shop_products — 获取上架商品列表(前端充值页面使用)
// ====================================================================
/**
* 解析有效期日期(支持yyyy-MM-dd和中文格式如"2026年7月16日")
*/
function parseExpireDate($expireStr) {
if (empty($expireStr)) {
return ['valid' => true, 'timestamp' => 0, 'display' => '长期有效'];
}
$expireStr = trim($expireStr);
// yyyy-MM-dd:当天0:00:00即过期(如2026-07-17则17号0点就过期)
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $expireStr)) {
$time = strtotime($expireStr); // 当天0:00:00
if ($time !== false) {
$now = time();
return [
'valid' => $time > $now, // 严格大于,当天0点即过期
'timestamp' => $time,
'display' => date('Y-m-d', $time) . ($time > $now ? ' 到期' : ' 已过期')
];
}
}
// 中文格式(如"2026年7月16日"):同样当天0:00:00即过期
if (preg_match('/(\d{4})年(\d{1,2})月(\d{1,2})日/', $expireStr, $matches)) {
$time = mktime(0, 0, 0, intval($matches[2]), intval($matches[3]), intval($matches[1])); // 当天0:00:00
if ($time !== false) {
$now = time();
return [
'valid' => $time > $now, // 严格大于,当天0点即过期
'timestamp' => $time,
'display' => date('Y-m-d', $time) . ($time > $now ? ' 到期' : ' 已过期')
];
}
}
// 无法解析 → 视为无效
return ['valid' => false, 'timestamp' => 0, 'display' => '日期格式错误: ' . $expireStr];
}
// ====================================================================
// 邮件:支付成功后发送付款凭据邮件
// ====================================================================
/**
* 发送付款凭据邮件给玩家(仅在玩家绑定了邮箱时发送)
* @param string $playerName 玩家名
* @param string $outTradeNo 订单号
* @param string $money 付款金额
* @param int $bondAmount 到账债券
* @param string $productName 商品名称
* @param int $paidAt 支付时间(Unix时间戳)
*/
function sendPaymentReceiptEmail($playerName, $outTradeNo, $money, $bondAmount, $productName, $paidAt) {
// 1) 查询玩家邮箱
$db = getDB();
try {
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
} catch (\Throwable $e) {
debugLog('[sendPaymentReceiptEmail] 查询邮箱失败', ['player' => $playerName, 'error' => $e->getMessage()]);
return;
}
if (!$row || empty($row['email'])) {
debugLog('[sendPaymentReceiptEmail] 玩家未绑定邮箱,跳过', ['player' => $playerName]);
return;
}
$email = trim($row['email']);
// 简单邮箱格式校验
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
debugLog('[sendPaymentReceiptEmail] 邮箱格式无效', ['player' => $playerName, 'email' => $email]);
return;
}
// 2) 构建邮件内容
$paidTimeStr = date('Y-m-d H:i:s', $paidAt);
$baseUrl = getBaseUrl();
$orderLink = $baseUrl . '/player.php?page=orders';
$subject = '[Sdf1] 付款凭据 - 订单 ' . $outTradeNo;
$html = ''
. ''
. '付款凭据'
. ''
. '
'
// 头部
. '
'
. '
✅ 付款成功
'
. '
Sdf1 Minecraft 充值中心
'
// 正文
. '
'
. '
亲爱的 ' . htmlspecialchars($playerName) . ':
'
. '
您的充值订单已支付成功,以下是您的付款凭据:
'
// 订单信息卡片
. '
'
. '
'
. '| 订单号 | ' . htmlspecialchars($outTradeNo) . ' |
'
. '| 商品 | ' . htmlspecialchars($productName) . ' |
'
. '| 付款金额 | ¥' . htmlspecialchars($money) . ' |
'
. '| 到账债券 | ' . intval($bondAmount) . ' 债券 |
'
. '| 支付时间 | ' . $paidTimeStr . ' |
'
. '
'
// 提示
. '
债券将在游戏内自动发放,如未到账请稍候或联系管理员。
'
. '
'
. '
本邮件由系统自动发送,请勿直接回复。如有疑问请联系管理员。
'
. '
';
// 3) 发送邮件
$headers = "From: " . SMTP_SENDER_NAME . " <" . SMTP_USER . ">\r\n"
. "Reply-To: " . SMTP_USER . "\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/html; charset=UTF-8\r\n";
smtpSendEmail(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, $email, $subject, $html, $headers, SMTP_USE_SSL);
debugLog('[sendPaymentReceiptEmail] 邮件发送成功', ['player' => $playerName, 'email' => $email, 'out_trade_no' => $outTradeNo]);
}
function getShopProducts() {
$db = getOrdersDB();
// 确保shop_configs表存在
$db->exec("CREATE TABLE IF NOT EXISTS shop_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_name TEXT NOT NULL DEFAULT '',
stock INTEGER NOT NULL DEFAULT -1,
temporary_offer TEXT NOT NULL DEFAULT '',
offer_expire TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
discount_price REAL NOT NULL DEFAULT 0,
bond_reward TEXT NOT NULL DEFAULT '1-1',
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)");
// 兼容旧表:检查并添加discount_price字段
$hasCol = false;
$cols = $db->query("PRAGMA table_info(shop_configs)");
while ($col = $cols->fetchArray(SQLITE3_ASSOC)) {
if ($col['name'] === 'discount_price') { $hasCol = true; break; }
}
if (!$hasCol) {
$db->exec("ALTER TABLE shop_configs ADD COLUMN discount_price REAL NOT NULL DEFAULT 0");
}
// 查询上架商品(is_active=1)
$stmt = $db->query("SELECT * FROM shop_configs WHERE is_active = 1 ORDER BY id ASC");
$products = [];
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
// ★ 售罄(stock=0)不隐藏:仍返回给前端展示,仅禁止购买。
// 只有 is_active=0(下架)才不返回(见上方 WHERE 条件)。
// 解析债券范围(支持 "1-10" 格式)
$bondReward = $row['bond_reward'];
if (preg_match('/^(\d+)-(\d+)$/', $bondReward, $m)) {
$bondMin = intval($m[1]);
$bondMax = intval($m[2]);
} else {
$bondMin = $bondMax = intval($bondReward);
}
// 解析有效期
$expireInfo = parseExpireDate($row['offer_expire']);
// 计算实际显示价格(如果有折扣价格且折扣未过期且折扣价格>0)
$displayPrice = $row['price'];
$discountActive = false;
if (!empty($row['temporary_offer']) && !empty($row['offer_expire'])) {
$discountActive = $expireInfo['valid'];
}
if ($discountActive && isset($row['discount_price']) && $row['discount_price'] > 0) {
$displayPrice = $row['discount_price'];
}
$products[] = [
'id' => $row['id'],
'item_name' => $row['item_name'],
'stock' => $row['stock'],
'temporary_offer' => $row['temporary_offer'],
'offer_expire' => $row['offer_expire'],
'expire_valid' => $expireInfo['valid'],
'expire_display' => $expireInfo['display'],
'price' => $row['price'],
'discount_price' => $row['discount_price'] ?? 0,
'discount_active' => $discountActive,
'display_price' => $displayPrice,
'bond_reward' => $bondReward,
'bond_min' => $bondMin,
'bond_max' => $bondMax,
'is_active' => $row['is_active']
];
}
success(['products' => $products]);
}
// ====================================================================
// 玩家订单查询
// ====================================================================
function getPlayerOrders($token) {
$info = validateTokenSilent($token);
if (!$info || empty($info['player'])) {
error('登录状态无效', 401);
}
$player = $info['player'];
$db = getOrdersDB();
ensurePayOrdersTable($db);
// 查询该玩家的所有订单(最近50条)
$stmt = $db->prepare("SELECT out_trade_no, trade_no, player_name, money, bond_amount, status, name, created_at, paid_at FROM pay_orders WHERE player_name = :p ORDER BY created_at DESC LIMIT 50");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$orders = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$orders[] = [
'out_trade_no' => $row['out_trade_no'],
'trade_no' => $row['trade_no'] ?? '',
'money' => $row['money'],
'bond_amount' => (int)$row['bond_amount'],
'status' => $row['status'],
'name' => $row['name'] ?? '在线充值',
'created_at' => (int)$row['created_at'],
'paid_at' => $row['paid_at'] ? (int)$row['paid_at'] : null,
];
}
success(['orders' => $orders, 'player' => $player]);
}
// ====================================================================
// 入口分发
// ====================================================================
$action = getParam('action', '');
try {
switch ($action) {
case 'create_order':
createOrder(getParam('token'), intval(getParam('product_id', 0)));
break;
case 'notify':
handleNotify(); // 内部已 exit
break;
case 'pay_redirect':
handlePayRedirect(); // 内部已 exit
break;
case 'sync_poller':
syncPoller(getParam('token'));
break;
case 'query_order':
queryOrder(getParam('token'));
break;
case 'find_recent_order':
findRecentOrder(getParam('token'));
break;
case 'get_shop_products':
getShopProducts();
break;
case 'my_orders':
getPlayerOrders(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;
}