712 lines
31 KiB
PHP
712 lines
31 KiB
PHP
<?php
|
||
/**
|
||
* 商城API
|
||
* GET ?action=list - 商品列表(无需token,公开接口)
|
||
* POST ?action=buy - 购买商品(需token)
|
||
* GET ?action=categories - 分类列表(无需token,公开接口)
|
||
*/
|
||
// 防止任何输出污染JSON响应
|
||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||
require_once __DIR__ . '/../core.php';
|
||
// 再次确保清理前置输出
|
||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||
|
||
$action = getParam('action', 'list');
|
||
$token = getParam('token');
|
||
|
||
try {
|
||
switch ($action) {
|
||
case 'list':
|
||
shopList($token);
|
||
break;
|
||
case 'buy':
|
||
shopBuy($token);
|
||
break;
|
||
case 'buy_cart':
|
||
shopBuyCart($token);
|
||
break;
|
||
case 'cart_config':
|
||
cartConfig();
|
||
break;
|
||
case 'categories':
|
||
shopCategories($token);
|
||
break;
|
||
default:
|
||
error('未知操作: ' . $action);
|
||
}
|
||
|
||
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||
|
||
} catch (\Throwable $e) {
|
||
// ★ 强制回滚可能残留的事务(防止database is locked连锁故障)
|
||
try { $db = getDB(); $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
|
||
while (ob_get_level() > 0) ob_end_clean();
|
||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||
}
|
||
|
||
// ===== 商品列表(公开,无需token) =====
|
||
function shopList($token = null) {
|
||
try {
|
||
$db = getDB();
|
||
$category = getParam('category');
|
||
|
||
if ($category) {
|
||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE category = :cat ORDER BY id");
|
||
$stmt->bindValue(':cat', $category, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
} else {
|
||
$result = $db->query("SELECT * FROM shop_items ORDER BY category, id");
|
||
}
|
||
|
||
$items = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$items[] = $row;
|
||
}
|
||
|
||
// 直接返回数据,不验证token(公开接口)
|
||
$isPreview = !$token;
|
||
if ($token && validateTokenSilent($token)) {
|
||
$isPreview = false;
|
||
}
|
||
|
||
if ($isPreview) {
|
||
preview($items, '预览模式 - 无有效token');
|
||
} else {
|
||
success($items);
|
||
}
|
||
} catch (Exception $e) {
|
||
error('商城加载失败: ' . $e->getMessage(), 500);
|
||
}
|
||
}
|
||
|
||
// validateTokenSilent() 已在 core.php 中定义,不再重复声明
|
||
|
||
// ===== 分类列表 =====
|
||
function shopCategories() {
|
||
$db = getDB();
|
||
$result = $db->query("SELECT DISTINCT category, COUNT(*) as item_count FROM shop_items GROUP BY category");
|
||
$cats = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$cats[] = $row;
|
||
}
|
||
success($cats);
|
||
}
|
||
|
||
// ===== 购买商品 =====
|
||
function shopBuy($token) {
|
||
$itemId = getParam('item_id');
|
||
$amount = (int)getParam('amount', 1);
|
||
$player = getParam('player');
|
||
$password = getParam('password');
|
||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||
|
||
if (!$itemId) error('缺少item_id');
|
||
if (!$player) error('缺少player');
|
||
if ($amount < 1) error('数量无效');
|
||
|
||
// ★ 双保险验证:购买操作需要密码确认
|
||
$isPreview = true;
|
||
$isOnline = false;
|
||
$isRegistered = false;
|
||
|
||
if ($token) {
|
||
// 先检查是否为普通管理token
|
||
$tokenInfo = validateToken($token);
|
||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||
$isPreview = false;
|
||
if (validateToken($token)) {
|
||
validateAndUseToken($token);
|
||
}
|
||
} else {
|
||
// weblogin token → 走双保险验证
|
||
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
|
||
if ($accessResult['ok']) {
|
||
$isPreview = false;
|
||
$player = $accessResult['player'];
|
||
$isOnline = $accessResult['online'] ?? false;
|
||
$isRegistered = $accessResult['registered'] ?? false;
|
||
} elseif ($accessResult['mode'] === 'need_password') {
|
||
jsonResponse([
|
||
'success' => false,
|
||
'need_password' => true,
|
||
'player' => $accessResult['player'],
|
||
'message' => $accessResult['message']
|
||
], 401);
|
||
} elseif ($accessResult['mode'] === 'need_game_login') {
|
||
jsonResponse([
|
||
'success' => false,
|
||
'need_game_login' => true,
|
||
'player' => $accessResult['player'],
|
||
'message' => $accessResult['message']
|
||
], 401);
|
||
} elseif ($accessResult['mode'] === 'need_register') {
|
||
jsonResponse([
|
||
'success' => false,
|
||
'need_register' => true,
|
||
'player' => $accessResult['player'],
|
||
'message' => $accessResult['message']
|
||
], 401);
|
||
} else {
|
||
error($accessResult['message'], 401);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查询商品
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
|
||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$item = $result->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$item) error('商品不存在: ' . $itemId);
|
||
|
||
// 检查库存
|
||
if ($item['stock'] == 0) error('商品已售罄');
|
||
if ($item['stock'] > 0 && $item['stock'] < $amount) error('库存不足');
|
||
|
||
$totalPrice = $item['buy_price'] * $amount;
|
||
|
||
// 检查玩家余额
|
||
if (!$isPreview && $totalPrice > 0) {
|
||
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
|
||
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$balanceResult = $balanceStmt->execute();
|
||
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
|
||
|
||
if (!$balanceRow) {
|
||
error('玩家余额查询失败');
|
||
}
|
||
|
||
$balance = (int)$balanceRow['amount'];
|
||
if ($balance < $totalPrice) {
|
||
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $totalPrice . ' 债券');
|
||
}
|
||
}
|
||
|
||
if ($isPreview) {
|
||
// 预览模式:返回计算结果但不执行
|
||
preview([
|
||
'item_id' => $itemId,
|
||
'item_name' => $item['display_name'],
|
||
'amount' => $amount,
|
||
'unit_price' => $item['buy_price'],
|
||
'total_price' => $totalPrice,
|
||
'stock_before' => $item['stock'],
|
||
'stock_after' => $item['stock'] > 0 ? $item['stock'] - $amount : -1,
|
||
'player' => $player
|
||
], '预览模式 - 购买不会生效');
|
||
}
|
||
|
||
// ★ 实际购买 — 包装在事务中,防止database is locked
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
try {
|
||
// 更新库存(同时写admin_stock确保Java同步)
|
||
if ($item['stock'] > 0) {
|
||
$newStock = $item['stock'] - $amount;
|
||
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
} else {
|
||
// 无限库存,只更新销量
|
||
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
}
|
||
|
||
// 记录交易
|
||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT);
|
||
$stmt->bindValue(':detail', json_encode([
|
||
'item_id' => $itemId,
|
||
'amount' => $amount,
|
||
'unit_price' => $item['buy_price'],
|
||
'tx_id' => bin2hex(random_bytes(8))
|
||
], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// ★ 写入sync_requests(必须在success/exit之前!)
|
||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt2->execute();
|
||
|
||
$db->exec('COMMIT');
|
||
} catch (Exception $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||
error('购买失败: ' . $e->getMessage(), 500);
|
||
}
|
||
|
||
success([
|
||
'item_id' => $itemId,
|
||
'item_name' => $item['display_name'],
|
||
'amount' => $amount,
|
||
'total_price' => $totalPrice,
|
||
'player' => $player
|
||
], '购买成功');
|
||
}
|
||
|
||
// ===== 购物车配置读取(批量优化:单次查询读取所有需要的配置) =====
|
||
function getShopConfig($key, $default = null) {
|
||
try {
|
||
$db = getDB();
|
||
$stmt = $db->prepare("SELECT cfg_value FROM shop_config WHERE cfg_key = :k");
|
||
$stmt->bindValue(':k', $key, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
return $row ? $row['cfg_value'] : $default;
|
||
} catch (Exception $e) {
|
||
return $default;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量读取多个shop_config键(一次查询替代N次getShopConfig调用)
|
||
* @return array key => value (不存在的key不返回)
|
||
*/
|
||
function getShopConfigs(array $keys) {
|
||
if (empty($keys)) return [];
|
||
try {
|
||
$db = getDB();
|
||
$placeholders = implode(',', array_fill(0, count($keys), '?'));
|
||
$stmt = $db->prepare("SELECT cfg_key, cfg_value FROM shop_config WHERE cfg_key IN ($placeholders)");
|
||
foreach ($keys as $i => $k) {
|
||
$stmt->bindValue($i + 1, $k, SQLITE3_TEXT);
|
||
}
|
||
$result = $stmt->execute();
|
||
$map = [];
|
||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||
$map[$row['cfg_key']] = $row['cfg_value'];
|
||
}
|
||
return $map;
|
||
} catch (Exception $e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 返回购物车折扣/加价配置(前端展示用,★ 批量读取)
|
||
function cartConfig() {
|
||
$cfg = getShopConfigs(['cart_backpack_rate', 'cart_shulker_rate', 'packmoney', 'green_discount']);
|
||
success([
|
||
'backpack_rate' => (float)($cfg['cart_backpack_rate'] ?? '0.98'),
|
||
'shulker_rate' => (float)($cfg['cart_shulker_rate'] ?? '1.00'),
|
||
'packmoney' => (float)($cfg['packmoney'] ?? '5'),
|
||
'green_discount' => (float)($cfg['green_discount'] ?? '10')
|
||
]);
|
||
}
|
||
|
||
// ===== 购物车结算(多商品一次性购买) =====
|
||
function shopBuyCart($token) {
|
||
$__t0 = microtime(true);
|
||
debugLog('[buy_cart] 进入请求');
|
||
$rawItems = getParam('items');
|
||
$settlement = getParam('settlement', 'backpack'); // backpack=塞背包, shulker=潜影盒打包
|
||
$shulkerColor = getParam('shulker_color', 'default'); // 潜影盒颜色(default原色免费,其它+2元)
|
||
$payMode = getParam('pay_mode', 'bond'); // bond=债券扣款, cash=现金仅记账
|
||
$player = getParam('player');
|
||
$password = getParam('password');
|
||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||
|
||
if (!$rawItems) error('购物车为空');
|
||
$items = json_decode($rawItems, true);
|
||
if (!is_array($items) || count($items) === 0) error('购物车格式错误');
|
||
|
||
// 校验每一项
|
||
$parsed = [];
|
||
foreach ($items as $it) {
|
||
if (!isset($it['item_id']) || !isset($it['amount'])) error('商品项缺少字段');
|
||
$pid = (string)$it['item_id'];
|
||
$amt = (int)$it['amount'];
|
||
if ($amt < 1) error('数量无效: ' . $pid);
|
||
$parsed[] = ['item_id' => $pid, 'amount' => $amt];
|
||
}
|
||
if (!$player) error('缺少player');
|
||
|
||
// ★ 双保险验证(与单件购买一致)
|
||
$isPreview = true;
|
||
$isAdminToken = false;
|
||
$isOnline = false;
|
||
$isRegistered = false;
|
||
$isCashier = false;
|
||
$isAdminSession = false;
|
||
$operatorDiscountLimit = 0;
|
||
$cashierRow = null;
|
||
$orderNo = '';
|
||
|
||
if ($token) {
|
||
$tokenInfo = validateToken($token);
|
||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||
$isPreview = false;
|
||
$isAdminToken = true; // ★ 记录代购令牌身份,避免下方大额复检重复校验已消费的token导致误判
|
||
if (validateToken($token)) validateAndUseToken($token);
|
||
} else {
|
||
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
|
||
if ($accessResult['ok']) {
|
||
$isPreview = false;
|
||
$player = $accessResult['player'];
|
||
$isOnline = $accessResult['online'] ?? false;
|
||
$isRegistered = $accessResult['registered'] ?? false;
|
||
} elseif (($accessResult['mode'] ?? '') === 'need_password') {
|
||
jsonResponse(['success' => false, 'need_password' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
|
||
} elseif (($accessResult['mode'] ?? '') === 'need_game_login') {
|
||
jsonResponse(['success' => false, 'need_game_login' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
|
||
} elseif (($accessResult['mode'] ?? '') === 'need_register') {
|
||
jsonResponse(['success' => false, 'need_register' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
|
||
} else {
|
||
error($accessResult['message'], 401);
|
||
}
|
||
}
|
||
} else {
|
||
// 收银员/管理员会话代购通路(独立收银台,无需token)
|
||
if (isCashierLoggedIn()) {
|
||
$isPreview = false;
|
||
$isCashier = true;
|
||
$cashierRow = getCurrentCashier();
|
||
$operatorDiscountLimit = (int)($cashierRow['discount_limit_percent'] ?? 0);
|
||
} elseif (isAdminLoggedIn()) {
|
||
$isPreview = false;
|
||
$isAdminSession = true;
|
||
}
|
||
}
|
||
|
||
// 结算模式对应的折扣/加价系数(★ 批量读取所有配置键,1次查询替代4次)
|
||
$cfg = getShopConfigs(['cart_shulker_rate', 'packmoney', 'cart_backpack_rate', 'green_discount']);
|
||
if ($settlement === 'shulker') {
|
||
$rate = (float)($cfg['cart_shulker_rate'] ?? '1.00');
|
||
$modeName = '潜影盒打包';
|
||
// 潜影盒颜色额外收费(原色/紫色免费,其它加收打包费,打包费来自配置 packmoney)
|
||
$packMoney = (float)($cfg['packmoney'] ?? '5');
|
||
$colorFee = ($shulkerColor !== 'default' && $shulkerColor !== 'purple') ? $packMoney : 0;
|
||
// 颜色名称映射
|
||
$colorNames = ['default'=>'原色','purple'=>'原色','white'=>'白色','black'=>'黑色','red'=>'红色','blue'=>'蓝色','green'=>'绿色','yellow'=>'黄色','orange'=>'橙色'];
|
||
$colorName = $colorNames[$shulkerColor] ?? $shulkerColor;
|
||
$ecoPct = 0;
|
||
} else {
|
||
$settlement = 'backpack';
|
||
$rawBackpackRate = (float)($cfg['cart_backpack_rate'] ?? '0.98');
|
||
// ★ 修复(2026-07-08):背包(环保单)模式必须尊重游戏内 green_discount 设置。
|
||
// green_discount 语义:10=不打折,9=9折(×0.9),8.5=8.5折(×0.85)。
|
||
// 游戏内设置了打折(green_discount < 10)时,以游戏折扣为准(如 setgreen 9 → ×0.9),
|
||
// 覆盖默认的 cart_backpack_rate(0.98=98折);未设置(=10)时仍走 cart_backpack_rate 保持原有环保优惠。
|
||
$greenDiscount = (float)($cfg['green_discount'] ?? 10);
|
||
$rate = ($greenDiscount < 10) ? ($greenDiscount / 10) : $rawBackpackRate;
|
||
$modeName = '塞背包(环保单)';
|
||
$colorFee = 0;
|
||
$colorName = '';
|
||
$ecoPct = 0;
|
||
}
|
||
|
||
$db = getDB();
|
||
$lines = [];
|
||
$subtotal = 0;
|
||
foreach ($parsed as &$p) {
|
||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
|
||
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$item = $result->fetchArray(SQLITE3_ASSOC);
|
||
if (!$item) error('商品不存在: ' . $p['item_id']);
|
||
if ($item['stock'] == 0) error('商品已售罄: ' . $item['display_name']);
|
||
if ($item['stock'] > 0 && $item['stock'] < $p['amount']) error('库存不足: ' . $item['display_name'] . '(剩余' . $item['stock'] . ')');
|
||
|
||
// ★ 发货前验证:检查商品material是否为Java端可识别的有效值
|
||
// 防止扣款后发现Java无法匹配该材料(如附魔书ID被误写为material字段)导致钱扣了货没给
|
||
$mat = strtoupper(trim($item['material']));
|
||
$validMaterial = (
|
||
// 标准Bukkit Material:大写字母+数字+下划线,如 DIAMOND_SWORD, ENCHANTED_BOOK
|
||
preg_match('/^[A-Z][A-Z0-9_]+$/', $mat) &&
|
||
// 排除明显不是合法Material的值(如附魔书ID MENDING_I 等——这些应写在id而非material列)
|
||
// ENCHANTED_BOOK 是唯一合法的附魔书Material
|
||
($mat === 'ENCHANTED_BOOK' || !preg_match('/_(I{1,3}|II|III|IV|V|[1-5])$/', $mat))
|
||
);
|
||
// 允许 id 字段是附魔书格式(如 MENDING_I),此时 material 必须是 ENCHANTED_BOOK
|
||
if (!$validMaterial) {
|
||
$pid = strtoupper(trim($p['item_id']));
|
||
$isEnchantedId = preg_match('/^[A-Z][A-Z0-9]*_[IVX12]+$/', $pid);
|
||
if (!($isEnchantedId && $mat === 'ENCHANTED_BOOK') && $mat !== 'ENCHANTED_BOOK') {
|
||
error('商品无法发放(material无效): ' . $item['display_name']
|
||
. ' [material=' . $item['material'] . ', id=' . $p['item_id'] . ']'
|
||
. ' — 请管理员检查该商品的material字段是否正确');
|
||
}
|
||
}
|
||
$lineTotal = (int)$item['buy_price'] * $p['amount'];
|
||
$subtotal += $lineTotal;
|
||
$p['item'] = $item;
|
||
$lines[] = [
|
||
'item_id' => $p['item_id'],
|
||
'name' => $item['display_name'],
|
||
'unit_price' => (int)$item['buy_price'],
|
||
'amount' => $p['amount'],
|
||
'line_total' => $lineTotal
|
||
];
|
||
}
|
||
unset($p);
|
||
|
||
// 先按费率计算,再叠加环保单折扣(不打包/塞背包,折扣率为折数),最后加颜色打包费
|
||
// green_discount 语义:10=不打折,9.9=9.9折(×0.99),9.8=9.8折(×0.98)
|
||
$baseAfterRate = (int)round($subtotal * $rate);
|
||
if ($ecoPct > 0) $baseAfterRate = (int)round($baseAfterRate * $ecoPct / 10);
|
||
$totalPrice = $baseAfterRate + $colorFee;
|
||
$saved = $subtotal - $totalPrice; // 折扣省下的(潜影盒加价+颜色费时为负)
|
||
|
||
// ===== 收银员/管理员手动折扣(服务端强制上限,前端声称不可信)=====
|
||
$discountPercent = 0;
|
||
$discountAmount = 0;
|
||
$finalPrice = $totalPrice;
|
||
$canDiscount = ($isCashier || $isAdminToken || $isAdminSession);
|
||
if ($canDiscount) {
|
||
$reqDiscount = (float)getParam('discount_percent', 0);
|
||
if ($reqDiscount < 0) $reqDiscount = 0;
|
||
if ($reqDiscount > 100) $reqDiscount = 100;
|
||
// 收银员受自身折扣上限限制;管理员无限制(最大100)
|
||
$limit = $isCashier ? $operatorDiscountLimit : 100;
|
||
if ($reqDiscount > $limit) {
|
||
error('折扣超过权限上限(收银员最大可打' . $limit . '% off)', 403);
|
||
}
|
||
$discountPercent = $reqDiscount;
|
||
if ($discountPercent > 0) {
|
||
$discountAmount = (int)round($totalPrice * $discountPercent / 100);
|
||
$finalPrice = $totalPrice - $discountAmount;
|
||
}
|
||
}
|
||
|
||
// ★ 安全检查:服务端计算的实际扣款额≥1000时,必须已通过密码验证(防止前端绕过)
|
||
// 管理员代购令牌(admin/all)已在上方标记$isAdminToken并消费,此处不再重新校验token,
|
||
// 而是校验代购操作者(管理员)登录密码;普通玩家通路走validateWebAccess已在上方完成验证;
|
||
// 收银员通路已通过收银员会话登录,视为已授权,无需额外密码
|
||
if ($finalPrice >= 1000 && !$isPreview) {
|
||
if ($isCashier) {
|
||
// 收银员已登录即授权,无需额外密码
|
||
} elseif ($isAdminToken || $isAdminSession) {
|
||
// 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码
|
||
// ★ 首次部署:ADMIN_PASS 还是占位符 = 没配置密码,此时任何"管理员密码"
|
||
// 都无从校验 → 一律按未通过处理(引导页会要求先设置密码)。
|
||
$passReady = function_exists('secAdminPassConfigured')
|
||
? secAdminPassConfigured()
|
||
: (defined('ADMIN_PASS') && trim((string)ADMIN_PASS) !== ''
|
||
&& trim((string)ADMIN_PASS) !== 'REPLACE_ME_ADMIN_PASSWORD');
|
||
if (!$password || trim($password) === '' || !$passReady || $password !== ADMIN_PASS) {
|
||
jsonResponse(['success' => false, 'need_password' => true,
|
||
'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401);
|
||
}
|
||
} elseif (!isset($accessResult['ok'])) {
|
||
// 非管理员且未通过密码验证 → 拒绝
|
||
error('大额交易需验证游戏登录密码', 401);
|
||
}
|
||
}
|
||
|
||
// ★ 现金收款权限校验:收银员需管理员授予 can_cash 权限
|
||
if ($payMode === 'cash') {
|
||
if ($isCashier && empty($cashierRow['can_cash'])) {
|
||
error('该收银员无现金收款权限(需管理员在收银员管理中开启)', 403);
|
||
}
|
||
// 现金模式:仅记账,不扣玩家债券(跳过余额校验与扣款)
|
||
}
|
||
|
||
// 检查余额(现金模式跳过:仅记账)
|
||
if (!$isPreview && $finalPrice > 0 && $payMode !== 'cash') {
|
||
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
|
||
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$balanceResult = $balanceStmt->execute();
|
||
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
|
||
if (!$balanceRow) error('玩家余额查询失败');
|
||
$balance = (int)$balanceRow['amount'];
|
||
if ($balance < $finalPrice) {
|
||
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $finalPrice . ' 债券');
|
||
}
|
||
}
|
||
|
||
if ($isPreview) {
|
||
preview([
|
||
'settlement' => $settlement,
|
||
'mode_name' => $modeName,
|
||
'rate' => $rate,
|
||
'items' => $lines,
|
||
'subtotal' => $subtotal,
|
||
'total_price' => $totalPrice,
|
||
'saved' => $saved,
|
||
'player' => $player
|
||
], '预览模式 - 结算不会生效');
|
||
}
|
||
|
||
// ★ 实际购买 — 包装在事务中
|
||
$db->exec('BEGIN IMMEDIATE');
|
||
try {
|
||
foreach ($parsed as $p) {
|
||
$item = $p['item'];
|
||
if ($item['stock'] > 0) {
|
||
$newStock = $item['stock'] - $p['amount'];
|
||
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
|
||
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
} else {
|
||
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
|
||
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
|
||
$stmt->execute();
|
||
}
|
||
}
|
||
|
||
// 记录一条合并交易
|
||
$reasonItems = array_map(function ($l) { return $l['name'] . ' x' . $l['amount']; }, $lines);
|
||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_cart', :amount, :reason, :detail, 'pending', :time)");
|
||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt->bindValue(':amount', $finalPrice, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':reason', "购物车结算({$modeName}" . ($colorName ? "-{$colorName}潜影盒" : '') . "): " . implode('、', $reasonItems), SQLITE3_TEXT);
|
||
$detailData = [
|
||
'settlement' => $settlement,
|
||
'rate' => $rate,
|
||
'subtotal' => $subtotal,
|
||
'total_price' => $finalPrice,
|
||
'discount_percent' => (int)$discountPercent,
|
||
'discount_amount' => (int)$discountAmount,
|
||
'pay_mode' => $payMode,
|
||
'items' => $lines,
|
||
'tx_id' => bin2hex(random_bytes(8))
|
||
];
|
||
if ($settlement === 'shulker') {
|
||
$detailData['shulker_color'] = $shulkerColor;
|
||
$detailData['color_name'] = $colorName;
|
||
$detailData['color_fee'] = $colorFee;
|
||
}
|
||
$stmt->bindValue(':detail', json_encode($detailData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
|
||
// 写入sync_requests
|
||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt2->execute();
|
||
|
||
$db->exec('COMMIT');
|
||
} catch (Exception $e) {
|
||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||
error('购物车结算失败: ' . $e->getMessage(), 500);
|
||
}
|
||
|
||
// ★ 购买主流程提交成功后,再记录收银台订单到独立 orders.db。
|
||
// 与 web.db 事务解耦:订单写失败仅影响日志/小票,绝不回滚已成功的库存与发药。
|
||
$orderNo = '';
|
||
if ($isCashier || $isAdminToken || $isAdminSession) {
|
||
$operatorType = $isCashier ? 'cashier' : 'admin';
|
||
$operatorName = $isCashier ? ($cashierRow['username'] ?? '') : 'admin';
|
||
try {
|
||
$orderNo = recordCashierOrder([
|
||
'operator_type' => $operatorType,
|
||
'operator_name' => $operatorName,
|
||
'player_name' => $player,
|
||
'items_detail' => $lines,
|
||
'subtotal' => $subtotal,
|
||
'total_price' => $finalPrice,
|
||
'discount_percent' => (int)$discountPercent,
|
||
'discount_amount' => (int)$discountAmount,
|
||
'settlement' => $settlement,
|
||
'payment_method' => $payMode
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
$orderNo = '';
|
||
debugLog('buy_cart: 订单记录失败(不影响购买): ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
// ★ 推送小票书给在线玩家(写入 web_admin_changes → Java pollAdminChanges 拾取并发放 Written Book)
|
||
// 注意:小票推送失败绝不影响已成功的购买主流程与订单记录,单独容错。
|
||
if (!empty($orderNo) && !empty($player)) {
|
||
try {
|
||
pushReceiptBookToPlayer($player, [
|
||
'order_no' => $orderNo,
|
||
'order_time' => date('Y-m-d H:i:s'),
|
||
'order_player' => $player,
|
||
'operator' => ($isCashier ? ($cashierRow['username'] ?? '') : 'admin'),
|
||
'settlement' => $settlement,
|
||
'pay_method' => $payMode,
|
||
'total_price' => (int)$finalPrice,
|
||
'items_text' => array_map(function($l) { return $l['name'] . ' x' . $l['amount'] . ' ... ' . $l['line_total'] . '债'; }, $lines)
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
debugLog('buy_cart: 小票书推送失败(不影响购买): ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
debugLog('[buy_cart] 完成, 耗时=' . round((microtime(true) - $__t0) * 1000) . 'ms, orderNo=' . ($orderNo ?? ''));
|
||
success([
|
||
'settlement' => $settlement,
|
||
'mode_name' => $modeName,
|
||
'rate' => $rate,
|
||
'items' => $lines,
|
||
'subtotal' => $subtotal,
|
||
'total_price' => $finalPrice,
|
||
'original_price' => $totalPrice,
|
||
'discount_percent' => (int)$discountPercent,
|
||
'discount_amount' => (int)$discountAmount,
|
||
'saved' => $saved,
|
||
'player' => $player,
|
||
'order_no' => $orderNo ?? ''
|
||
], '购物车结算成功');
|
||
}
|
||
|
||
// ===== Weblogin Token验证(用于商城购买)=====
|
||
function validateWebloginTokenForShop($webToken) {
|
||
$db = getDB();
|
||
try {
|
||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||
$result = $stmt->execute();
|
||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||
if (!$row) return false;
|
||
|
||
$createdAt = (int)$row['created_at'];
|
||
$expireSeconds = (int)$row['expire_seconds'];
|
||
if (time() - $createdAt > $expireSeconds) return false;
|
||
|
||
return [
|
||
'player' => $row['player_name'],
|
||
'purpose' => 'weblogin',
|
||
'created_at' => $createdAt,
|
||
'expires_at' => $createdAt + $expireSeconds
|
||
];
|
||
} catch (Exception $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ===== 推送打包小票书给在线玩家(写入 web_admin_changes → Java 拾取发放 Written Book)=====
|
||
function pushReceiptBookToPlayer($playerName, $orderData) {
|
||
try {
|
||
$db = getDB();
|
||
// 确保表存在
|
||
$db->exec("CREATE TABLE IF NOT EXISTS web_admin_changes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
change_type TEXT NOT NULL,
|
||
target_id TEXT NOT NULL,
|
||
target_name TEXT DEFAULT '',
|
||
change_data TEXT DEFAULT '{}',
|
||
created_at INTEGER DEFAULT 0,
|
||
acknowledged INTEGER DEFAULT 0,
|
||
acked_at INTEGER DEFAULT 0,
|
||
status TEXT DEFAULT 'done'
|
||
)");
|
||
$stmt = $db->prepare("INSERT INTO web_admin_changes (change_type, target_id, target_name, change_data, created_at, status)
|
||
VALUES ('give_receipt_book', '0', :player, :data, :time, 'pending')");
|
||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||
$stmt->bindValue(':data', json_encode($orderData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
} catch (Exception $e) {
|
||
error_log('[小票书] 推送小票书失败: ' . $e->getMessage());
|
||
}
|
||
}
|