1291 lines
59 KiB
PHP
1291 lines
59 KiB
PHP
<?php
|
||
/**
|
||
* 债券在线充值 · 支付平台联控(彩虹易支付 RSA 版)
|
||
*
|
||
* 动作:
|
||
* - create_order : 校验登录态 → 生成订单 → RSA签名 → 返回跳转支付URL
|
||
* - notify : 平台异步回调(GET)→ 平台公钥验签 → 金额校验 → 幂等 → 写 web_transactions(recharge)
|
||
* - query_order : 前端补单/轮询订单状态
|
||
*
|
||
* ★ 安全:本文件不含任何密钥。密钥仅从 pay_secrets.php(git-ignored,仅定义常量)读取;
|
||
* 本地 txt 文件不会被代码引用,仅作人工本地备份。
|
||
*/
|
||
|
||
// 防止 PHP 错误/弃用警告以 HTML 形式污染 JSON / notify 纯文本响应
|
||
@ini_set('display_errors', '0');
|
||
@ini_set('html_errors', '0');
|
||
error_reporting(E_ALL);
|
||
|
||
// 防止任何前置输出污染 JSON / notify 纯文本响应
|
||
ob_start();
|
||
require_once __DIR__ . '/../core.php';
|
||
ob_end_clean();
|
||
|
||
// ★ 订单前缀 / 订单号生成(读 config.php 的 PAY_ORDER_PREFIX)
|
||
// 创单端与三个补单脚本必须共用这一份,前缀才不会两边对不上。
|
||
require_once __DIR__ . '/pay_config.php';
|
||
|
||
// ===== 非机密常量(可安全提交) =====
|
||
// 彩虹易支付 下单网关
|
||
define('PAY_GATEWAY', 'https://zf.ypshidifu.cn/api/pay/submit');
|
||
// 异步通知地址(平台服务器回调,必须公网可达)
|
||
define('PAY_NOTIFY_URL', 'https://caoyuan.ypshidifu.cn/plugin/api/pay.php?action=notify');
|
||
// 同步跳转地址(玩家付款后浏览器跳回)
|
||
define('PAY_RETURN_URL', 'https://caoyuan.ypshidifu.cn/plugin/player.php?paid=1');
|
||
define('PAY_SIGN_TYPE', 'MD5'); // 提交用 MD5(商户 MD5 密钥 pay_user.key,末尾无点);用户新私钥数据损坏无法自验,暂用 MD5
|
||
|
||
// ===== 0.01 元测试配置(先跑通全链路,后续替换为 recharge_tiers 档位) =====
|
||
define('PAY_TEST_MONEY', '0.01'); // 元(字符串,与回调比对)
|
||
define('PAY_TEST_BONDS', 1); // 对应债券数量
|
||
define('PAY_TEST_NAME', '债券充值-测试0.01元');
|
||
|
||
// ====================================================================
|
||
// 密钥加载(仅使用 pay_secrets.php,严禁引用本地 txt 记录文件)
|
||
// ====================================================================
|
||
function loadPayKeys() {
|
||
$secretsFile = __DIR__ . '/pay_secrets.php';
|
||
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
||
@require_once $secretsFile;
|
||
if (defined('PAY_MCH_PRIVATE_KEY') && defined('PAY_PLATFORM_PUBLIC_KEY')) {
|
||
return [
|
||
'pid' => 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() {
|
||
// ★ 2026-10-08 修复:pay_secrets.php 此前只在 loadPayKeys() 内 require_once,
|
||
// 而 queryOrder 的调用顺序是「先 checkPlatformOrderStatus() 后 queryPlatformAPI()」——
|
||
// 第一次进 getPlatformDB() 时常量还没定义,于是落到下面的硬编码默认密码,
|
||
// 平台库直接 Access denied(2026-10-08 当天 206 次全失败),
|
||
// 路径1(平台MySQL直查)整条废掉 → 只能干等补单器,支付后 15~30 秒才检测到已支付。
|
||
// 这里按需补加载一次(require_once 幂等,已加载自动跳过),
|
||
// 与 poller_online.php 顶层无条件加载保持一致。
|
||
if (!defined('PAY_MYSQL_PASS')) {
|
||
$secretsFile = __DIR__ . '/pay_secrets.php';
|
||
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
||
@require_once $secretsFile;
|
||
}
|
||
}
|
||
$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 .= '<input type="hidden" name="' . htmlspecialchars($k, ENT_QUOTES) . '" value="' . htmlspecialchars($v, ENT_QUOTES) . '">' . "\n";
|
||
}
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>正在跳转支付...</title></head><body>' .
|
||
'<form id="payform" action="' . htmlspecialchars(PAY_GATEWAY, ENT_QUOTES) . '" method="post">' .
|
||
$fields .
|
||
'</form>' .
|
||
'<script>document.getElementById("payform").submit();</script>' .
|
||
'</body></html>';
|
||
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 = false;
|
||
try {
|
||
$apiResult = queryPlatformAPI($outTradeNo);
|
||
} catch (\Throwable $e) {
|
||
debugLog('[queryOrder] 官方API查询异常(不影响主流程,poller 兜底)', [
|
||
'out_trade_no' => $outTradeNo, 'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
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 = false;
|
||
try {
|
||
$apiResult = queryPlatformAPI($outTradeNo);
|
||
} catch (\Throwable $e) {
|
||
debugLog('[queryOrder] 官方API查询异常(不影响主流程,poller 兜底)', [
|
||
'out_trade_no' => $outTradeNo, 'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
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) {
|
||
// ★ 2026-10-08 修复:本文件只有 loadPayKeys(),getPayKeys() 从未定义——
|
||
// 一进来就 Call to undefined function,被入口顶层 catch 转成 HTTP 500,
|
||
// 导致所有「未支付」订单的 query_order 全部 500,路径2 形同虚设。
|
||
$keys = loadPayKeys();
|
||
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 = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
|
||
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
|
||
. '<title>付款凭据</title></head><body style="margin:0;padding:0;background:#f4f6f9;font-family:Segoe UI,Tahoma,sans-serif;">'
|
||
. '<div style="max-width:560px;margin:0 auto;padding:24px;">'
|
||
. '<div style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,0.08);">'
|
||
// 头部
|
||
. '<div style="background:linear-gradient(135deg,#1a7f37,#156d2e);padding:28px 32px;color:#fff;">'
|
||
. '<h1 style="margin:0;font-size:22px;">✅ 付款成功</h1>'
|
||
. '<p style="margin:8px 0 0;opacity:.9;font-size:14px;">Sdf1 Minecraft 充值中心</p></div>'
|
||
// 正文
|
||
. '<div style="padding:32px;">'
|
||
. '<p style="margin:0 0 16px;font-size:15px;color:#333;">亲爱的 <b>' . htmlspecialchars($playerName) . '</b>:</p>'
|
||
. '<p style="margin:0 0 20px;font-size:15px;color:#555;line-height:1.7;">您的充值订单已支付成功,以下是您的付款凭据:</p>'
|
||
// 订单信息卡片
|
||
. '<div style="background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:0 0 24px;">'
|
||
. '<table style="width:100%;border-collapse:collapse;font-size:14px;">'
|
||
. '<tr><td style="padding:8px 0;color:#656d76;width:100px;">订单号</td><td style="padding:8px 0;color:#1f2328;font-weight:600;">' . htmlspecialchars($outTradeNo) . '</td></tr>'
|
||
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">商品</td><td style="padding:8px 0;color:#1f2328;border-top:1px solid #eee;">' . htmlspecialchars($productName) . '</td></tr>'
|
||
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">付款金额</td><td style="padding:8px 0;color:#cf222e;font-weight:600;border-top:1px solid #eee;">¥' . htmlspecialchars($money) . '</td></tr>'
|
||
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">到账债券</td><td style="padding:8px 0;color:#1a7f37;font-weight:600;border-top:1px solid #eee;">' . intval($bondAmount) . ' 债券</td></tr>'
|
||
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">支付时间</td><td style="padding:8px 0;color:#1f2328;border-top:1px solid #eee;">' . $paidTimeStr . '</td></tr>'
|
||
. '</table></div>'
|
||
// 提示
|
||
. '<p style="margin:0 0 12px;font-size:14px;color:#555;line-height:1.7;">债券将在游戏内自动发放,如未到账请稍候或联系管理员。</p>'
|
||
. '<div style="text-align:center;margin:0 0 8px;"><a href="' . htmlspecialchars($orderLink) . '" style="display:inline-block;background:#1a7f37;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-size:14px;font-weight:600;">查看我的订单</a></div>'
|
||
. '<p style="margin:24px 0 0;font-size:12px;color:#999;border-top:1px solid #eee;padding-top:16px;">本邮件由系统自动发送,请勿直接回复。如有疑问请联系管理员。</p>'
|
||
. '</div></div></div></body></html>';
|
||
|
||
// 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;
|
||
}
|