订单前缀配置化+新订单号格式,补单脚本同读前缀;新增online_heartbeat心跳与sync_webtx_receipts回执补推端点
This commit is contained in:
+20
-5
@@ -21,6 +21,10 @@ ob_start();
|
|||||||
require_once __DIR__ . '/../core.php';
|
require_once __DIR__ . '/../core.php';
|
||||||
ob_end_clean();
|
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_GATEWAY', 'https://zf.ypshidifu.cn/api/pay/submit');
|
||||||
@@ -353,7 +357,12 @@ function createOrder($token, $productId = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$outTradeNo = 'RE' . date('YmdHis') . sprintf('%04d', mt_rand(0, 9999));
|
// ★ 订单号格式(2026-10-06):前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名
|
||||||
|
// 例:RE20261006171418Q101youpaishidifu
|
||||||
|
// 前缀读 config.php 的 PAY_ORDER_PREFIX(多服隔离,不再写死 'RE');
|
||||||
|
// 加玩家名是为了在共用的平台 pay_order 表里一眼看出这单属于谁。
|
||||||
|
// 长度护栏与玩家名净化见 pay_config.php 的 payMakeOrderNo()。
|
||||||
|
$outTradeNo = payMakeOrderNo($player);
|
||||||
|
|
||||||
// 构造提交参数(原始值,不做 urlencode;sign 在原始值上计算)
|
// 构造提交参数(原始值,不做 urlencode;sign 在原始值上计算)
|
||||||
$params = [
|
$params = [
|
||||||
@@ -631,10 +640,16 @@ function queryOrder($token) {
|
|||||||
|
|
||||||
// ★ 本地订单不存在 → 双路查询(MySQL + 官方API),如果平台已支付则同步到本地
|
// ★ 本地订单不存在 → 双路查询(MySQL + 官方API),如果平台已支付则同步到本地
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
// ★ 2026-10-02 归属校验:本方订单号只可能是 createOrder 生成的 RE 前缀。
|
// ★ 2026-10-02 归属校验:本方订单号只可能是 createOrder 生成的配置前缀订单。
|
||||||
// 不是 RE 开头 = 平台其他商户/项目的订单 → 一概不查平台、不建本地单、不写流水。
|
// 不是本方前缀 = 平台其他商户/项目的订单 → 一概不查平台、不建本地单、不写流水。
|
||||||
if (strncmp((string)$outTradeNo, 'RE', 2) !== 0) {
|
// ★ 2026-10-06:前缀改为读 config.php 的 PAY_ORDER_PREFIX(原来写死 'RE',
|
||||||
debugLog('[queryOrder] 非本方订单号,拒绝查询平台', ['out_trade_no' => $outTradeNo, 'player' => $info['player']]);
|
// 测试服改成别的前缀后,这行不跟着改就会把自己的单也拒绝掉)。
|
||||||
|
if (!payOrderIsOurs($outTradeNo)) {
|
||||||
|
debugLog('[queryOrder] 非本方订单号,拒绝查询平台', [
|
||||||
|
'out_trade_no' => $outTradeNo,
|
||||||
|
'expect_prefix' => payOrderPrefix(),
|
||||||
|
'player' => $info['player'],
|
||||||
|
]);
|
||||||
error('订单不存在,请确认订单号是否正确');
|
error('订单不存在,请确认订单号是否正确');
|
||||||
}
|
}
|
||||||
debugLog('[queryOrder] 本地订单不存在,尝试双路查询同步', ['out_trade_no' => $outTradeNo, 'player' => $info['player']]);
|
debugLog('[queryOrder] 本地订单不存在,尝试双路查询同步', ['out_trade_no' => $outTradeNo, 'player' => $info['player']]);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 收银台订单前缀 / 订单号生成 —— 统一读配置的公共小工具(2026-10-06)
|
||||||
|
*
|
||||||
|
* 背景:平台 MySQL 的 pay_order 是多商户共用表,创单与三个补单脚本此前都把 'RE'
|
||||||
|
* 前缀写死在各自代码里。生产服 / 测试服共用一个前缀就会互相捡单 —— 测试服
|
||||||
|
* 补单器把生产服的已支付单补进测试库,反过来也一样(就是这次串数据的根因)。
|
||||||
|
*
|
||||||
|
* 现在前缀收口到 config.php 的 PAY_ORDER_PREFIX:
|
||||||
|
* pay.php 创单时用它拼订单号
|
||||||
|
* poller_online.php 补单时用它 LIKE 扫平台 + strncmp 兜底校验
|
||||||
|
* pay_poller.php 同上
|
||||||
|
* recharge_orders_api.php 同上
|
||||||
|
* ★ 创单端与补单端必须读同一份配置:创单用什么前缀,补单器就用什么前缀扫平台,
|
||||||
|
* 两边一旦不一致就会「创出来的单补不回来」。这四个文件都要跟着改,只改一个会出事。
|
||||||
|
*
|
||||||
|
* ★ 本文件不依赖 core.php,任何脚本(含自包含的 poller)都能直接 require。
|
||||||
|
* config.php 由 security.php 加载;若仍缺常量(线上老版 config.php),一律回退
|
||||||
|
* 'RE' 默认值,行为与改造前完全一致,保证向后兼容。
|
||||||
|
*
|
||||||
|
* ★ 债券换算(estimateBonds)不在本文件:金额→债券由后台「充值商店配置」
|
||||||
|
* (shop_configs.bond_reward) 决定,代码不干预比例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('payOrderPrefix')) {
|
||||||
|
/**
|
||||||
|
* 订单号前缀(永不返回空串;未定义/为空一律回退 'RE')
|
||||||
|
* 只保留字母数字:它要拼进 SQL 的 LIKE '前缀%',也与平台侧约定一致。
|
||||||
|
*/
|
||||||
|
function payOrderPrefix() {
|
||||||
|
$p = defined('PAY_ORDER_PREFIX') ? trim((string)PAY_ORDER_PREFIX) : '';
|
||||||
|
if ($p === '') return 'RE';
|
||||||
|
$p = preg_replace('/[^A-Za-z0-9]/', '', $p);
|
||||||
|
return $p === '' ? 'RE' : $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('payOrderPrefixLen')) {
|
||||||
|
/** 前缀长度(strncmp 用,避免写死 2) */
|
||||||
|
function payOrderPrefixLen() {
|
||||||
|
return strlen(payOrderPrefix());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('payOrderIsOurs')) {
|
||||||
|
/**
|
||||||
|
* 判断一个 out_trade_no 是否本端订单(补单器的兜底校验)
|
||||||
|
* @param string $outNo
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
function payOrderIsOurs($outNo) {
|
||||||
|
$p = payOrderPrefix();
|
||||||
|
return strncmp((string)$outNo, $p, strlen($p)) === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('payOrderPrefixLike')) {
|
||||||
|
/** SQL 片段:out_trade_no LIKE '前缀%'(前缀只含字母数字,无需转义) */
|
||||||
|
function payOrderPrefixLike() {
|
||||||
|
return payOrderPrefix() . '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('payMakeOrderNo')) {
|
||||||
|
/**
|
||||||
|
* 生成本端订单号:前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名
|
||||||
|
* 例:RE20261006171418Q101youpaishidifu
|
||||||
|
*
|
||||||
|
* 加玩家名是为了在共用的 pay_order 表里一眼看出这单属于谁,排查串服问题最快。
|
||||||
|
* 玩家名只保留 [A-Za-z0-9_](Minecraft 用户名规则),其余字符剔除;
|
||||||
|
* 若剔除后为空则整段省略,保证订单号前缀/时间戳/校验码绝不被破坏 ——
|
||||||
|
* 前缀一旦被截断,补单器就再也认不出这单了。
|
||||||
|
*
|
||||||
|
* @param string $playerName 下单玩家名
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function payMakeOrderNo($playerName = '') {
|
||||||
|
$p = payOrderPrefix();
|
||||||
|
$stamp = date('YmdHis'); // 14位:20261006171418
|
||||||
|
$code = strtoupper(substr(str_shuffle('ABCDEFGHJKLMNPQRSTUVWXYZ23456789'), 0, 4)); // 4位校验码(去掉易混的 I/O/0/1)
|
||||||
|
|
||||||
|
// 玩家名净化:只留字母数字下划线
|
||||||
|
$name = preg_replace('/[^A-Za-z0-9_]/', '', (string)$playerName);
|
||||||
|
if ($name === '') $name = '';
|
||||||
|
|
||||||
|
$no = $p . $stamp . $code . $name;
|
||||||
|
|
||||||
|
// 长度护栏:平台 out_trade_no 默认 varchar(64)。超长时优先裁短玩家名段,
|
||||||
|
// 绝不裁前缀/时间戳/校验码。
|
||||||
|
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
|
||||||
|
if ($max > 0 && strlen($no) > $max) {
|
||||||
|
$fixed = strlen($p) + strlen($stamp) + strlen($code); // 不可裁部分
|
||||||
|
$allow = $max - $fixed;
|
||||||
|
if ($allow <= 0) {
|
||||||
|
// 极端情况(前缀配得极长):宁可退回不含玩家名的最短形态
|
||||||
|
$no = $p . $stamp . $code;
|
||||||
|
} else {
|
||||||
|
$no = $p . $stamp . $code . substr($name, 0, $allow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $no;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -27,6 +27,12 @@ $secretsFile = __DIR__ . '/pay_secrets.php';
|
|||||||
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
||||||
@require_once $secretsFile;
|
@require_once $secretsFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 订单前缀读配置(2026-10-06 多服隔离):创单端 pay.php 用什么前缀,这里就扫什么前缀。
|
||||||
|
// 此前写死 'RE',测试服与生产服共用一个前缀 → 互相捡单串数据。
|
||||||
|
// ★★ 改前缀时,pay.php / poller_online.php / pay_poller.php / recharge_orders_api.php
|
||||||
|
// 四个文件必须一起改(它们现在都读同一个 PAY_ORDER_PREFIX,只要 config.php 改了就同步生效)。
|
||||||
|
require_once __DIR__ . '/pay_config.php';
|
||||||
$PLATFORM_DB_HOST = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
|
$PLATFORM_DB_HOST = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
|
||||||
$PLATFORM_DB_NAME = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
|
$PLATFORM_DB_NAME = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
|
||||||
$PLATFORM_DB_USER = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
|
$PLATFORM_DB_USER = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
|
||||||
@@ -203,12 +209,17 @@ function pollPaidOrders() {
|
|||||||
// 绝大多数是其他商户/项目的单(纯数字单号)。以前不加过滤整表拉,平台每来一单
|
// 绝大多数是其他商户/项目的单(纯数字单号)。以前不加过滤整表拉,平台每来一单
|
||||||
// 就被我们"捡走"、凭空写进本地 pay_orders + web_transactions,产生玩家=unknown
|
// 就被我们"捡走"、凭空写进本地 pay_orders + web_transactions,产生玩家=unknown
|
||||||
// 的垃圾充值单(Java 每次拉交易都刷屏处理它们),还顺手把别人的订单 notify
|
// 的垃圾充值单(Java 每次拉交易都刷屏处理它们),还顺手把别人的订单 notify
|
||||||
// 改成 1 —— 纯属多管闲事。本方单号由 pay.php createOrder 生成、固定 RE 前缀,
|
// 改成 1 —— 纯属多管闲事。本方单号由 pay.php createOrder 生成,前缀取自
|
||||||
|
// config.php 的 PAY_ORDER_PREFIX(生产 'RE' / 测试服 'RT' 等,各部署各配),
|
||||||
// 平台侧据此过滤,PHP 侧再兜底校验一次;平台其他订单一概不读不写。
|
// 平台侧据此过滤,PHP 侧再兜底校验一次;平台其他订单一概不读不写。
|
||||||
// 仍不过滤 notify:平台可能已标记 notify=1 但本地 DB 未更新(HTTP 回调被
|
// 仍不过滤 notify:平台可能已标记 notify=1 但本地 DB 未更新(HTTP 回调被
|
||||||
// CF WAF 拦截),是否补单以本地 pay_orders 状态为准。
|
// CF WAF 拦截),是否补单以本地 pay_orders 状态为准。
|
||||||
$prefix = $PLATFORM_DB_PREFIX;
|
$prefix = $PLATFORM_DB_PREFIX;
|
||||||
$stmt = $pdo->query("SELECT out_trade_no, trade_no, uid, money, status, notify, param, version, addtime FROM `{$prefix}order` WHERE out_trade_no LIKE 'RE%' AND status IN (1, 2) ORDER BY addtime ASC LIMIT 50");
|
// ★ 2026-10-06:扫描前缀改为读 config.php 的 PAY_ORDER_PREFIX(原来写死 'RE')
|
||||||
|
$orderPrefix = payOrderPrefix();
|
||||||
|
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, notify, param, version, addtime FROM `{$prefix}order` WHERE out_trade_no LIKE :pfx AND status IN (1, 2) ORDER BY addtime ASC LIMIT 50");
|
||||||
|
$stmt->bindValue(':pfx', $orderPrefix . '%', PDO::PARAM_STR);
|
||||||
|
$stmt->execute();
|
||||||
$allOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$allOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (empty($allOrders)) {
|
if (empty($allOrders)) {
|
||||||
@@ -216,12 +227,12 @@ function pollPaidOrders() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只保留本方订单(RE 前缀兜底校验),再过滤掉本地已处理(paid)的
|
// 只保留本方订单(前缀兜底校验,与创单端同一份配置),再过滤掉本地已处理(paid)的
|
||||||
$orders = [];
|
$orders = [];
|
||||||
$foreign = 0;
|
$foreign = 0;
|
||||||
foreach ($allOrders as $o) {
|
foreach ($allOrders as $o) {
|
||||||
$outNo = (string)$o['out_trade_no'];
|
$outNo = (string)$o['out_trade_no'];
|
||||||
if (strncmp($outNo, 'RE', 2) !== 0) {
|
if (!payOrderIsOurs($outNo)) {
|
||||||
$foreign++; // 非本方订单:不建单、不写流水、不改它的 notify
|
$foreign++; // 非本方订单:不建单、不写流水、不改它的 notify
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
|||||||
@require_once $secretsFile;
|
@require_once $secretsFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 订单前缀读配置(2026-10-06 多服隔离):与 pay.php 创单端共用同一份 PAY_ORDER_PREFIX
|
||||||
|
require_once __DIR__ . '/pay_config.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取平台MySQL数据库连接(集中凭据管理)
|
* 获取平台MySQL数据库连接(集中凭据管理)
|
||||||
* 注意:poller_online.php 也有同名函数,此处用不同名称避免 redeclare 冲突
|
* 注意:poller_online.php 也有同名函数,此处用不同名称避免 redeclare 冲突
|
||||||
@@ -296,9 +299,14 @@ function handleSyncFromPlatform() {
|
|||||||
|
|
||||||
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,整表拉 200 条
|
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,整表拉 200 条
|
||||||
// 会把其他商户/项目的单(纯数字单号)也同步成本地充值单,凭空造出
|
// 会把其他商户/项目的单(纯数字单号)也同步成本地充值单,凭空造出
|
||||||
// 玩家=unknown 的垃圾订单。本方单号固定 RE 前缀(pay.php createOrder 生成),
|
// 玩家=unknown 的垃圾订单。本方单号前缀取自 config.php 的 PAY_ORDER_PREFIX
|
||||||
// 平台侧据此过滤,PHP 侧再兜底校验;平台其他订单一概不碰。
|
// (pay.php createOrder 用同一个值生成),平台侧据此过滤,PHP 侧再兜底校验;
|
||||||
$stmt = $pdo->query("SELECT out_trade_no, trade_no, uid, money, status, param, addtime FROM `pay_order` WHERE out_trade_no LIKE 'RE%' AND status IN (1, 2) ORDER BY addtime DESC LIMIT 200");
|
// 平台其他订单一概不碰。
|
||||||
|
// ★ 2026-10-06:前缀改为读配置(原来写死 'RE',测试服改前缀后这里不跟着改
|
||||||
|
// 就会把生产服的单同步进来)。
|
||||||
|
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, param, addtime FROM `pay_order` WHERE out_trade_no LIKE :pfx AND status IN (1, 2) ORDER BY addtime DESC LIMIT 200");
|
||||||
|
$stmt->bindValue(':pfx', payOrderPrefixLike(), PDO::PARAM_STR);
|
||||||
|
$stmt->execute();
|
||||||
$platformOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$platformOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
$synced = 0;
|
$synced = 0;
|
||||||
@@ -309,13 +317,17 @@ function handleSyncFromPlatform() {
|
|||||||
|
|
||||||
foreach ($platformOrders as $po) {
|
foreach ($platformOrders as $po) {
|
||||||
$outTradeNo = $po['out_trade_no'];
|
$outTradeNo = $po['out_trade_no'];
|
||||||
if (strncmp((string)$outTradeNo, 'RE', 2) !== 0) {
|
// ★ 兜底校验:与创单端读同一份 PAY_ORDER_PREFIX
|
||||||
|
if (!payOrderIsOurs($outTradeNo)) {
|
||||||
$foreign++; // 非本方订单:不建单、不写流水
|
$foreign++; // 非本方订单:不建单、不写流水
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$player = $po['param'] ?? 'unknown';
|
$player = $po['param'] ?? 'unknown';
|
||||||
$tradeNo = $po['trade_no'] ?? '';
|
$tradeNo = $po['trade_no'] ?? '';
|
||||||
$money = (string)$po['money'];
|
$money = (string)$po['money'];
|
||||||
|
// ★ 债券换算保持原始行为(2026-10-06 用户指示:代码不干预换算比例,
|
||||||
|
// 金额→券数由后台「面板-充值商店配置」决定,运维自行调整)。
|
||||||
|
// 本行仅在本地无档位记录时作兜底估算,公式与改造前完全一致。
|
||||||
$bonds = ($money >= '1.0') ? (int)($money * 10) : max(1, (int)($money * 100));
|
$bonds = ($money >= '1.0') ? (int)($money * 10) : max(1, (int)($money * 100));
|
||||||
|
|
||||||
// 查本地是否已有
|
// 查本地是否已有
|
||||||
|
|||||||
+221
-1
@@ -118,6 +118,16 @@ switch ($action) {
|
|||||||
case 'sync_online_players':
|
case 'sync_online_players':
|
||||||
syncOnlinePlayers();
|
syncOnlinePlayers();
|
||||||
break;
|
break;
|
||||||
|
// ★ 2026-10-06 在线保活心跳:名单无变化时 Java 走这条轻量出口刷 last_seen,
|
||||||
|
// 否则 admin 后台会因为心跳超 120 秒而强制显示 0 人。
|
||||||
|
case 'online_heartbeat':
|
||||||
|
onlineHeartbeat();
|
||||||
|
break;
|
||||||
|
// ★ 2026-10-06 充值回执补推(一式两份 · 多退少补):Java 发现 PHP 侧 web_transactions
|
||||||
|
// 缺了它手上的镜像流水时,POST 这里重建。跟随全量同步调度器热同步。
|
||||||
|
case 'sync_webtx_receipts':
|
||||||
|
syncWebTxReceipts();
|
||||||
|
break;
|
||||||
case 'sync_daily_logins':
|
case 'sync_daily_logins':
|
||||||
syncDailyLogins();
|
syncDailyLogins();
|
||||||
break;
|
break;
|
||||||
@@ -1386,6 +1396,23 @@ function alignmentCatConfig($cat) {
|
|||||||
// (玩家已下线但没推过)必须靠一次全量推送才能清掉,所以 extra 要触发推送
|
// (玩家已下线但没推过)必须靠一次全量推送才能清掉,所以 extra 要触发推送
|
||||||
'push_on_extra' => 1,
|
'push_on_extra' => 1,
|
||||||
),
|
),
|
||||||
|
// ★ 充值回执(2026-10-06 一式两份):Java 侧持 web_tx_receipts 镜像。
|
||||||
|
// 两边按 id 点差集、多退少补:
|
||||||
|
// PHP 缺(missing)→ Java 补推重建流水,解决「运行中清空 PHP 后充值记录回不来」
|
||||||
|
// PHP 多(extra) → 收回给 Java 补齐基线
|
||||||
|
// ★ where 限定与 Java 侧写镜像的范围【必须完全一致】(recharge/pay_recharge 两种):
|
||||||
|
// 范围不一致会导致补推的行永远落在对账集合之外 → 每轮都判 missing → 无限补推。
|
||||||
|
// 不能对账的类型(cdk/shop_refund 等)本就不归充值回执管,各走各的链路。
|
||||||
|
'webtx' => array(
|
||||||
|
'table' => 'web_transactions',
|
||||||
|
'keycols' => array('id'),
|
||||||
|
'map' => array(),
|
||||||
|
'cols' => array('id', 'player_name', 'type', 'amount', 'detail', 'created_at'),
|
||||||
|
'hashcols' => array('player_name', 'type', 'amount', 'detail'),
|
||||||
|
'intcols' => array('amount'),
|
||||||
|
'pull' => 1,
|
||||||
|
'where' => "type IN ('recharge','pay_recharge')",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return isset($cfgs[$cat]) ? $cfgs[$cat] : null;
|
return isset($cfgs[$cat]) ? $cfgs[$cat] : null;
|
||||||
@@ -1454,6 +1481,24 @@ function checkAlignment() {
|
|||||||
$cfg = alignmentCatConfig($cat);
|
$cfg = alignmentCatConfig($cat);
|
||||||
if ($cfg === null) error('未知的对账类别: ' . $cat);
|
if ($cfg === null) error('未知的对账类别: ' . $cat);
|
||||||
|
|
||||||
|
// ★ 2026-10-06 心跳兜底:Java 端对「online」类别的对账请求,本身就证明
|
||||||
|
// 插件活着、且它手上那份在线名单刚刚跟 PHP 点过一遍。既然点过了,
|
||||||
|
// 就同步刷新 online_player_hb —— 这样即使专门的心跳请求丢了,
|
||||||
|
// 只要对账链路通,后台也不会因为 120 秒无 sync_online_players 而归零。
|
||||||
|
// (只对 online 类刷:别的类别跟在线数据无关,不该顺手改它的语义。)
|
||||||
|
if ($cat === 'online') {
|
||||||
|
try {
|
||||||
|
$hbDb = getDB();
|
||||||
|
$hbDb->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||||||
|
$hbStmt = $hbDb->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :t)");
|
||||||
|
$hbStmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||||||
|
$hbStmt->execute();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
@error_log("[checkAlignment] online心跳兜底刷新失败: " . $e->getMessage());
|
||||||
|
// 失败不影响对账本身
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Java 报上来的本机 key + 指纹 ----
|
// ---- Java 报上来的本机 key + 指纹 ----
|
||||||
$javaHash = array();
|
$javaHash = array();
|
||||||
$records = getParam('records');
|
$records = getParam('records');
|
||||||
@@ -1473,7 +1518,11 @@ function checkAlignment() {
|
|||||||
$db = getDB();
|
$db = getDB();
|
||||||
$db->exec('PRAGMA busy_timeout=6000');
|
$db->exec('PRAGMA busy_timeout=6000');
|
||||||
try {
|
try {
|
||||||
$res = $db->query('SELECT * FROM ' . $cfg['table']);
|
// ★ where 仅来自 alignmentCatConfig 的硬编码常量(非用户输入),可直接拼接。
|
||||||
|
// 没配 where 的类别行为与改造前完全一致。
|
||||||
|
$sql = 'SELECT * FROM ' . $cfg['table'];
|
||||||
|
if (!empty($cfg['where'])) $sql .= ' WHERE ' . $cfg['where'];
|
||||||
|
$res = $db->query($sql);
|
||||||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||||
$k = alignmentKeyOf($cfg, $row);
|
$k = alignmentKeyOf($cfg, $row);
|
||||||
if ($k === '') continue;
|
if ($k === '') continue;
|
||||||
@@ -2088,6 +2137,177 @@ function syncOnlinePlayers() {
|
|||||||
success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家");
|
success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 在线心跳(2026-10-06 新增,Java 轻量保活端点)=====
|
||||||
|
/**
|
||||||
|
* 背景:admin 后台显示在线人数前会检查 online_player_hb(120 秒内才算新鲜),
|
||||||
|
* 而这个心跳**只在 sync_online_players 被调用时**才更新。
|
||||||
|
* 2026-10-06 起 Java 端 syncOnlinePlayers() 走了「双向对账」:在线名单与 PHP
|
||||||
|
* 完全一致时一条数据都不发就 return —— 于是玩家稳定在线、名单不变时,PHP 永远
|
||||||
|
* 收不到 sync_online_players → 心跳停在 120 秒前 → 后台强制显示 0 人,
|
||||||
|
* 而 online_players 表里的人还在(清表动作本身也在 sync_online_players 里),
|
||||||
|
* 所以下一轮对账仍判「一致」→ 仍然不发 → **永不自愈**。
|
||||||
|
*
|
||||||
|
* 本端点就是给那条「零发送」路径用的保活出口:只刷心跳时间戳,绝不改 online_players
|
||||||
|
* 的数据(名单没变本来就不该动),请求体极小(GET + secret),不会被 URL 长度限制打回。
|
||||||
|
*
|
||||||
|
* 心跳语义 = 「Java 插件还活着、且它手上的在线名单与 PHP 一致」,
|
||||||
|
* 因此收到心跳即认为在线数据可信,admin 侧照常展示 online_players 现有内容。
|
||||||
|
*/
|
||||||
|
function onlineHeartbeat() {
|
||||||
|
$secret = getParam('secret');
|
||||||
|
$token = getParam('token');
|
||||||
|
// 与 syncOnlinePlayers 同款认证:SECRET_KEY 或 sync 用途 token 都接受
|
||||||
|
$authed = false;
|
||||||
|
if ($secret && $secret === SECRET_KEY) {
|
||||||
|
$authed = true;
|
||||||
|
} elseif ($token) {
|
||||||
|
$info = validateToken($token);
|
||||||
|
if ($info && ($info['purpose'] === 'admin' || $info['purpose'] === 'all' || $info['purpose'] === 'sync')) {
|
||||||
|
$authed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$authed) error('认证失败');
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$fresh = 0;
|
||||||
|
$count = 0;
|
||||||
|
try {
|
||||||
|
$db = getDB();
|
||||||
|
$db->exec('PRAGMA busy_timeout=5000');
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER NOT NULL)");
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
|
||||||
|
|
||||||
|
// ★ 只刷新心跳,不动 online_players:名单是否变化由 Java 端对账裁决,
|
||||||
|
// 这里插手反而会把「Java 报的人数」和「对账结果」搅在一起。
|
||||||
|
$hb = $db->prepare("INSERT OR REPLACE INTO online_player_hb (id, last_seen) VALUES (1, :t)");
|
||||||
|
$hb->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||||
|
$hb->execute();
|
||||||
|
|
||||||
|
$r = $db->query("SELECT COUNT(*) AS cnt FROM online_players");
|
||||||
|
$row = $r ? $r->fetchArray(SQLITE3_ASSOC) : null;
|
||||||
|
$count = (int)($row['cnt'] ?? 0);
|
||||||
|
$fresh = $now;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
@error_log("[onlineHeartbeat] 刷新失败: " . $e->getMessage());
|
||||||
|
// 心跳失败不致命:Java 下轮仍会重试
|
||||||
|
error('心跳写入失败: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
success([
|
||||||
|
'last_seen' => $fresh,
|
||||||
|
'server_time' => $now,
|
||||||
|
'online_count'=> $count,
|
||||||
|
// 供 Java 端判断自己是否已超过 PHP 的新鲜阈值(120 秒)
|
||||||
|
'hb_ttl' => 120,
|
||||||
|
], 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 充值回执补推(Java插件 → PHP,一式两份 · 多退少补) =====
|
||||||
|
/**
|
||||||
|
* ★ 2026-10-06 Bug1 热同步的 PHP 接收端。
|
||||||
|
*
|
||||||
|
* 背景:充值流水一式两份 —— PHP 持 web_transactions(type=recharge/pay_recharge),
|
||||||
|
* Java 持 web_tx_receipts 镜像。运行中 PHP 后台数据被清空时,Java 侧对账点出差集
|
||||||
|
* (PHP 缺、Java 有)后把缺的行 POST 到这里重建,实现「多退少补」的热回补。
|
||||||
|
*
|
||||||
|
* 三条铁律(改动前先读完):
|
||||||
|
* 1. status 一律写死 'processed'。这些流水 Java 早已 confirm 过(镜像只在 confirm
|
||||||
|
* 之后落),写成 pending 会被 pull_pending_transactions 再拉一次 → 重复发货/重复加钱。
|
||||||
|
* 2. INSERT OR REPLACE 按主键 id 幂等:重复补推、并发补推都不会产生重复流水。
|
||||||
|
* 注意 web_transactions.id 是显式主键(非 AUTOINCREMENT 语义冲突),Java 推来的
|
||||||
|
* id 就是原行 id,直接带上即可;id 与现存行撞车时整行替换 —— 指纹哈希已保证
|
||||||
|
* 撞车行在对账口径下就是同一条。
|
||||||
|
* 3. 只收 type IN ('recharge','pay_recharge'):与 alignmentCatConfig('webtx') 的 where
|
||||||
|
* 严格一致。收了别的类型,该行永远落在对账集合之外 → 下轮仍判 missing → 无限补推。
|
||||||
|
*
|
||||||
|
* 认证与 syncDailyLogins 同款(SECRET_KEY),请求体 {"secret":...,"receipts":[...]}。
|
||||||
|
*/
|
||||||
|
function syncWebTxReceipts() {
|
||||||
|
$secret = getParam('secret');
|
||||||
|
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||||
|
|
||||||
|
$receipts = getParam('receipts');
|
||||||
|
if ($receipts === null) {
|
||||||
|
// POST body 走 php://input(与 syncDailyLogins 同款双通道)
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw) {
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (isset($decoded['receipts'])) $receipts = $decoded['receipts'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($receipts === null || !is_array($receipts)) {
|
||||||
|
error('缺少receipts数据');
|
||||||
|
}
|
||||||
|
if (count($receipts) === 0) {
|
||||||
|
success(['inserted' => 0, 'skipped' => 0], '无需补推');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$db->exec('PRAGMA busy_timeout=6000');
|
||||||
|
// 表随数据一起被清掉/删表的极端情况:先确保表在,再谈补推
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, player_name TEXT NOT NULL, type TEXT NOT NULL, amount INTEGER NOT NULL, operator TEXT DEFAULT '', reason TEXT, detail TEXT, status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, processed_at INTEGER)");
|
||||||
|
// processed_at 列可能在旧库缺失(历史 ALTER 迁移路径),补齐防 INSERT 报错
|
||||||
|
$hasProcessedAt = false;
|
||||||
|
$pr = $db->query("PRAGMA table_info(web_transactions)");
|
||||||
|
while ($pr && ($c = $pr->fetchArray(SQLITE3_ASSOC))) {
|
||||||
|
if (isset($c['name']) && $c['name'] === 'processed_at') { $hasProcessedAt = true; break; }
|
||||||
|
}
|
||||||
|
if ($pr) $pr->finalize();
|
||||||
|
if (!$hasProcessedAt) {
|
||||||
|
try { $db->exec("ALTER TABLE web_transactions ADD COLUMN processed_at INTEGER"); }
|
||||||
|
catch (\Throwable $e) { /* 并发下另一请求已加过,忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$inserted = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$db->exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("INSERT OR REPLACE INTO web_transactions (id, player_name, type, amount, operator, reason, detail, status, created_at, processed_at) VALUES (:id, :player, :type, :amount, :operator, :reason, :detail, 'processed', :ctime, :ptime)");
|
||||||
|
foreach ($receipts as $r) {
|
||||||
|
if (!is_array($r)) { $skipped++; continue; }
|
||||||
|
$id = (int)($r['id'] ?? 0);
|
||||||
|
$player = trim((string)($r['player_name'] ?? ''));
|
||||||
|
$type = (string)($r['type'] ?? '');
|
||||||
|
$amount = (int)($r['amount'] ?? 0);
|
||||||
|
$detail = (string)($r['detail'] ?? '');
|
||||||
|
$ctime = (int)($r['created_at'] ?? $now);
|
||||||
|
|
||||||
|
// 铁律3:类型必须落在 webtx 对账 where 里,否则补了也白补(还会无限补推)
|
||||||
|
if ($id <= 0 || $player === '' || !in_array($type, array('recharge', 'pay_recharge'), true)) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ctime <= 0) $ctime = $now;
|
||||||
|
|
||||||
|
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||||
|
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||||
|
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||||
|
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||||
|
// operator/reason 是 Java 镜像没有的展示字段:按正常充值流水的口径补默认值,
|
||||||
|
// 不参与指纹(hashcols 只含 player_name/type/amount/detail),不影响对账。
|
||||||
|
$stmt->bindValue(':operator', 'Java回执补推', SQLITE3_TEXT);
|
||||||
|
$stmt->bindValue(':reason', '充值流水回补(一式两份)', SQLITE3_TEXT);
|
||||||
|
$stmt->bindValue(':detail', $detail, SQLITE3_TEXT);
|
||||||
|
$stmt->bindValue(':ctime', $ctime, SQLITE3_INTEGER);
|
||||||
|
$stmt->bindValue(':ptime', $now, SQLITE3_INTEGER);
|
||||||
|
$stmt->execute();
|
||||||
|
$inserted++;
|
||||||
|
}
|
||||||
|
$db->exec("COMMIT");
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
try { $db->exec("ROLLBACK"); } catch (\Throwable $e2) {}
|
||||||
|
@error_log("[syncWebTxReceipts] Error: " . $e->getMessage());
|
||||||
|
error('回执补推失败: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inserted > 0) {
|
||||||
|
@error_log("[syncWebTxReceipts] Java补推充值流水 " . $inserted . " 条(跳过 " . $skipped . " 条),status 均为 processed");
|
||||||
|
}
|
||||||
|
success(['inserted' => $inserted, 'skipped' => $skipped], "补推{$inserted}条充值流水");
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 同步每日登录记录(Java插件推送) =====
|
// ===== 同步每日登录记录(Java插件推送) =====
|
||||||
function syncDailyLogins() {
|
function syncDailyLogins() {
|
||||||
$secret = getParam('secret');
|
$secret = getParam('secret');
|
||||||
|
|||||||
+23
@@ -27,6 +27,29 @@ define('DB_PATH', __DIR__ . '/db/web.db');
|
|||||||
// 订单记录纯属 PHP 收银台自己的数据,Java 永不读写此库
|
// 订单记录纯属 PHP 收银台自己的数据,Java 永不读写此库
|
||||||
define('ORDERS_DB_PATH', __DIR__ . '/db/orders.db');
|
define('ORDERS_DB_PATH', __DIR__ . '/db/orders.db');
|
||||||
|
|
||||||
|
// ===== 收银台订单号(2026-10-06 多服隔离)=====
|
||||||
|
// ★ 平台 MySQL 的 pay_order 是多商户/多服务器共用表。以前创单和补单脚本都把 'RE'
|
||||||
|
// 写死在代码里,结果生产服和测试服用同一个前缀 —— 测试服的补单器每 20 秒轮询一次,
|
||||||
|
// 就把生产服的已支付单也捡走补进了测试库(互相串数据)。
|
||||||
|
// ★ 现在创单(pay.php)与三个补单脚本(poller_online / pay_poller / recharge_orders_api)
|
||||||
|
// 统一读下面这个常量:前缀一致才互认,各部署各改各的即可隔离。
|
||||||
|
// ★ 注意:改前缀只影响「新订单」与「补单器还认哪些单」,历史订单数据不受影响;
|
||||||
|
// 若某次改了前缀,改动前已生成、尚未补单的订单将不再被本端补单器匹配(按需保留)。
|
||||||
|
define('PAY_ORDER_PREFIX', 'RE'); // 生产服 'RE',测试服请改成例如 'RT'
|
||||||
|
|
||||||
|
// ★ 订单号格式(pay.php createOrder):
|
||||||
|
// 前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名
|
||||||
|
// 例:RE20261006171418Q101youpaishidifu
|
||||||
|
// 加玩家名是为了在共用的 pay_order 表里一眼看出这单属于谁,排查串服问题时最快。
|
||||||
|
// 只允许字母/数字/下划线(Minecraft 用户名规则),其余字符会被剔除。
|
||||||
|
// ★ PAY_ORDER_MAX_LEN:平台 out_trade_no 字段长度护栏(彩虹易支付标准为 varchar(64))。
|
||||||
|
// 超长时自动裁短玩家名段,绝不截断前缀/时间戳/校验码 —— 前缀被截会导致补单器再也认不出这单。
|
||||||
|
define('PAY_ORDER_MAX_LEN', 64);
|
||||||
|
|
||||||
|
// ★ 债券换算不在这里配:金额→债券完全由后台「面板-充值商店配置」(shop_configs.bond_reward)
|
||||||
|
// 决定,代码不干预比例;仅在本地订单记录缺失时才用 poller_online.php 里的兜底估算公式。
|
||||||
|
// 运维想改成 1 元 10 券还是 1 元 100 券,直接在后台充值商店里调即可。
|
||||||
|
|
||||||
// ===== 管理员认证 =====
|
// ===== 管理员认证 =====
|
||||||
define('ADMIN_USER', 'admin');
|
define('ADMIN_USER', 'admin');
|
||||||
define('ADMIN_PASS', 'REPLACE_ME_ADMIN_PASSWORD'); // 管理员密码
|
define('ADMIN_PASS', 'REPLACE_ME_ADMIN_PASSWORD'); // 管理员密码
|
||||||
|
|||||||
Reference in New Issue
Block a user