diff --git a/admin.php b/admin.php index f2d8b49..a10c3d1 100644 --- a/admin.php +++ b/admin.php @@ -110,7 +110,8 @@
收银员可登录独立收银台(cashier.php)代玩家购买商品并手动打折,折扣上限在此设置。管理员亦可登录收银台。
+| ID | 账号 | 折扣上限 | 创建时间 | 操作 |
|---|---|---|---|---|
| ${c.id} | +${escAdmHtml(c.username)} | +${c.discount_limit_percent}% | +${c.created_at ? new Date(c.created_at * 1000).toLocaleString('zh-CN') : '-'} | ++ + + | +
diff --git a/api/cashier.php b/api/cashier.php new file mode 100644 index 0000000..6bd7afb --- /dev/null +++ b/api/cashier.php @@ -0,0 +1,276 @@ + 0) { @ob_end_clean(); } +ob_start(); +header('Content-Type: application/json; charset=utf-8'); +header('X-Content-Type-Options: nosniff'); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); + +error_reporting(E_ALL); +ini_set('display_errors', '0'); +ini_set('log_errors', '1'); +ini_set('memory_limit', '256M'); + +if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); } + +register_shutdown_function(function() { + while (ob_get_level() > 0) { + @ob_end_flush(); + } +}); + +if (!extension_loaded('sqlite3')) { + exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE)); +} + +require_once __DIR__ . '/../core.php'; + +if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) { + exit(json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE)); +} + +if (session_status() === PHP_SESSION_NONE) { + session_start(); +} + +$action = getParam('action', 'status'); + +try { +switch ($action) { + case 'login': cashierDoLogin(); break; + case 'logout': cashierDoLogout(); break; + case 'session': cashierSession(); break; + case 'player_check': cashierPlayerCheck(); break; + case 'order_list': cashierOrderList(); break; + case 'cashier_list': cashierList(); break; + case 'add': cashierAdd(); break; + case 'edit': cashierEdit(); break; + case 'delete': cashierDelete(); break; + default: + exit(json_encode(['success' => false, 'message' => '未知操作: ' . $action], JSON_UNESCAPED_UNICODE)); +} +} catch (Exception $e) { + @error_log('[cashier.php] ' . $e->getMessage()); + exit(json_encode(['success' => false, 'message' => '服务器错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE)); +} + +// ===== 收银员登录 ===== +function cashierDoLogin() { + $username = trim(getParam('username', '')); + $password = getParam('password', ''); + if ($username === '' || $password === '') { + exit(json_encode(['success' => false, 'message' => '请输入账号和密码'], JSON_UNESCAPED_UNICODE)); + } + if (cashierLogin($username, $password)) { + $c = getCurrentCashier(); + exit(json_encode([ + 'success' => true, + 'data' => [ + 'username' => $c['username'], + 'discount_limit_percent' => $c['discount_limit_percent'] + ] + ], JSON_UNESCAPED_UNICODE)); + } + exit(json_encode(['success' => false, 'message' => '账号或密码错误'], JSON_UNESCAPED_UNICODE)); +} + +// ===== 收银员登出 ===== +function cashierDoLogout() { + cashierLogout(); + exit(json_encode(['success' => true, 'message' => '已退出登录'], JSON_UNESCAPED_UNICODE)); +} + +// ===== 当前会话信息 ===== +function cashierSession() { + if (isAdminLoggedIn()) { + exit(json_encode([ + 'success' => true, + 'data' => [ + 'role' => 'admin', + 'username' => 'admin', + 'discount_limit_percent' => 100 + ] + ], JSON_UNESCAPED_UNICODE)); + } + if (isCashierLoggedIn()) { + $c = getCurrentCashier(); + exit(json_encode([ + 'success' => true, + 'data' => [ + 'role' => 'cashier', + 'username' => $c['username'], + 'discount_limit_percent' => $c['discount_limit_percent'] + ] + ], JSON_UNESCAPED_UNICODE)); + } + exit(json_encode(['success' => false, 'message' => '未登录'], JSON_UNESCAPED_UNICODE)); +} + +// ===== 查询目标玩家余额/在线状态(收银员或管理员均可)===== +function cashierPlayerCheck() { + requireCashierOrAdminSession(); + $player = trim(getParam('player')); + if (!$player) exit(json_encode(['success' => false, 'message' => '请输入玩家名'], JSON_UNESCAPED_UNICODE)); + if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) { + exit(json_encode(['success' => false, 'message' => '玩家名格式不正确(3-16位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE)); + } + $db = getDB(); + $db->exec("CREATE TABLE IF NOT EXISTS bond_cache (player_name TEXT PRIMARY KEY, amount INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0)"); + try { + $db->exec("INSERT OR IGNORE INTO bond_cache (player_name, amount, updated_at) VALUES ('" . str_replace("'", "''", $player) . "', 0, " . time() . ")"); + $stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name"); + $stmt->bindValue(':name', $player, SQLITE3_TEXT); + $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC); + $balance = $row ? (int)$row['amount'] : 0; + + $online = false; + try { + $ostmt = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :name"); + $ostmt->bindValue(':name', $player, SQLITE3_TEXT); + $orow = $ostmt->execute()->fetchArray(SQLITE3_ASSOC); + $online = $orow ? true : false; + } catch (\Throwable $e) {} + + exit(json_encode([ + 'success' => true, + 'data' => ['player' => $player, 'exists' => true, 'balance' => $balance, 'online' => $online] + ], JSON_UNESCAPED_UNICODE)); + } catch (Exception $e) { + @error_log('[cashier.php] Player check error: ' . $e->getMessage()); + exit(json_encode(['success' => false, 'message' => '查询失败: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE)); + } +} + +// ===== 订单列表 ===== +function cashierOrderList() { + $role = requireCashierOrAdminSession(); + $db = getDB(); + $limit = (int)getParam('limit', 100); + if ($limit <= 0 || $limit > 500) $limit = 100; + $sql = "SELECT * FROM cashier_orders"; + $params = []; + // 收银员只能看自己的订单 + if ($role === 'cashier') { + $c = getCurrentCashier(); + $sql .= " WHERE operator_name = :op"; + $params[':op'] = $c['username']; + } + $sql .= " ORDER BY created_at DESC LIMIT " . (int)$limit; + $stmt = $db->prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v, SQLITE3_TEXT); + } + $result = $stmt->execute(); + $rows = []; + while ($r = $result->fetchArray(SQLITE3_ASSOC)) { + if (!empty($r['items_detail'])) { + $r['items_detail'] = json_decode($r['items_detail'], true) ?: []; + } + $rows[] = $r; + } + exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE)); +} + +// ===== 收银员列表(仅管理员)===== +function cashierList() { + requireAdminSession(); + $db = getDB(); + $result = $db->prepare("SELECT id, username, discount_limit_percent, created_at, created_by FROM cashiers ORDER BY id ASC")->execute(); + $rows = []; + while ($r = $result->fetchArray(SQLITE3_ASSOC)) { + $rows[] = $r; + } + exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE)); +} + +// ===== 新增收银员(仅管理员)===== +function cashierAdd() { + requireAdminSession(); + $username = trim(getParam('username', '')); + $password = getParam('password', ''); + $discountLimit = (int)getParam('discount_limit_percent', 0); + if ($username === '' || $password === '') { + exit(json_encode(['success' => false, 'message' => '账号和密码不能为空'], JSON_UNESCAPED_UNICODE)); + } + if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) { + exit(json_encode(['success' => false, 'message' => '账号格式不正确(3-20位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE)); + } + if ($discountLimit < 0 || $discountLimit > 100) { + exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE)); + } + $db = getDB(); + // 查重 + $chk = $db->prepare("SELECT id FROM cashiers WHERE username = :u"); + $chk->bindValue(':u', $username, SQLITE3_TEXT); + if ($chk->execute()->fetchArray(SQLITE3_ASSOC)) { + exit(json_encode(['success' => false, 'message' => '账号已存在'], JSON_UNESCAPED_UNICODE)); + } + $hash = password_hash($password, PASSWORD_DEFAULT); + $salt = bin2hex(random_bytes(8)); + $stmt = $db->prepare("INSERT INTO cashiers (username, password_hash, salt, discount_limit_percent, created_at, created_by) VALUES (:u,:h,:s,:d,:t,'admin')"); + $stmt->bindValue(':u', $username, SQLITE3_TEXT); + $stmt->bindValue(':h', $hash, SQLITE3_TEXT); + $stmt->bindValue(':s', $salt, SQLITE3_TEXT); + $stmt->bindValue(':d', $discountLimit, SQLITE3_INTEGER); + $stmt->bindValue(':t', time(), SQLITE3_INTEGER); + $stmt->execute(); + exit(json_encode(['success' => true, 'message' => '收银员创建成功'], JSON_UNESCAPED_UNICODE)); +} + +// ===== 编辑收银员(仅管理员):可改密码 / 折扣上限 ===== +function cashierEdit() { + requireAdminSession(); + $id = (int)getParam('id', 0); + if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE)); + $db = getDB(); + $chk = $db->prepare("SELECT id FROM cashiers WHERE id = :id"); + $chk->bindValue(':id', $id, SQLITE3_INTEGER); + if (!$chk->execute()->fetchArray(SQLITE3_ASSOC)) { + exit(json_encode(['success' => false, 'message' => '收银员不存在'], JSON_UNESCAPED_UNICODE)); + } + $sets = []; + $params = [':id' => $id]; + $newPassword = getParam('password', ''); + if ($newPassword !== '') { + $sets[] = "password_hash = :h"; + $params[':h'] = password_hash($newPassword, PASSWORD_DEFAULT); + $sets[] = "salt = :s"; + $params[':s'] = bin2hex(random_bytes(8)); + } + if (getParam('discount_limit_percent') !== null && getParam('discount_limit_percent') !== '') { + $dl = (int)getParam('discount_limit_percent', 0); + if ($dl < 0 || $dl > 100) { + exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE)); + } + $sets[] = "discount_limit_percent = :d"; + $params[':d'] = $dl; + } + if (empty($sets)) { + exit(json_encode(['success' => false, 'message' => '没有可更新的字段'], JSON_UNESCAPED_UNICODE)); + } + $sql = "UPDATE cashiers SET " . implode(', ', $sets) . " WHERE id = :id"; + $stmt = $db->prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v, is_int($v) ? SQLITE3_INTEGER : SQLITE3_TEXT); + } + $stmt->execute(); + exit(json_encode(['success' => true, 'message' => '收银员信息已更新'], JSON_UNESCAPED_UNICODE)); +} + +// ===== 删除收银员(仅管理员)===== +function cashierDelete() { + requireAdminSession(); + $id = (int)getParam('id', 0); + if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE)); + $db = getDB(); + $stmt = $db->prepare("DELETE FROM cashiers WHERE id = :id"); + $stmt->bindValue(':id', $id, SQLITE3_INTEGER); + $stmt->execute(); + exit(json_encode(['success' => true, 'message' => '收银员已删除'], JSON_UNESCAPED_UNICODE)); +} diff --git a/api/shop.php b/api/shop.php index f0acf8d..d1e9f46 100644 --- a/api/shop.php +++ b/api/shop.php @@ -298,13 +298,19 @@ function shopBuyCart($token) { // ★ 双保险验证(与单件购买一致) $isPreview = true; + $isAdminToken = false; $isOnline = false; $isRegistered = false; + $isCashier = false; + $isAdminSession = false; + $operatorDiscountLimit = 0; + $cashierRow = null; 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); @@ -323,6 +329,17 @@ function shopBuyCart($token) { 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; + } } // 结算模式对应的折扣/加价系数 @@ -369,32 +386,56 @@ function shopBuyCart($token) { $totalPrice = (int)round($subtotal * $rate) + $colorFee; $saved = $subtotal - $totalPrice; // 折扣省下的(潜影盒加价+颜色费时为负) - // ★ 安全检查:服务端计算的总额≥1000时,必须已通过密码验证(防止前端绕过) - // 管理员token(admin/all)在上方已跳过密码验证,此处补检 - if ($totalPrice >= 1000 && !$isPreview) { - $tokenInfo = validateToken($token); - $isAdminToken = $tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all'); - if ($isAdminToken && (!$password || trim($password) === '')) { - // 管理员代购大额也需密码确认 - jsonResponse(['success' => false, 'need_password' => true, - 'player' => $player, 'message' => '金额≥1000债券需确认密码'], 401); + // ===== 收银员/管理员手动折扣(服务端强制上限,前端声称不可信)===== + $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); } - if (!$isAdminToken && !isset($accessResult['ok'])) { + $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) { + // 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码 + if (!$password || trim($password) === '' || $password !== ADMIN_PASS) { + jsonResponse(['success' => false, 'need_password' => true, + 'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401); + } + } elseif (!isset($accessResult['ok'])) { // 非管理员且未通过密码验证 → 拒绝 error('大额交易需验证游戏登录密码', 401); } } // 检查余额 - if (!$isPreview && $totalPrice > 0) { + if (!$isPreview && $finalPrice > 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 ($balance < $finalPrice) { + error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $finalPrice . ' 债券'); } } @@ -435,13 +476,15 @@ function shopBuyCart($token) { $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', $totalPrice, SQLITE3_INTEGER); + $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' => $totalPrice, + 'total_price' => $finalPrice, + 'discount_percent' => (int)$discountPercent, + 'discount_amount' => (int)$discountAmount, 'items' => $lines ]; if ($settlement === 'shulker') { @@ -453,6 +496,23 @@ function shopBuyCart($token) { $stmt->bindValue(':time', time(), SQLITE3_INTEGER); $stmt->execute(); + // 记录收银台订单(代购/收银员操作,写入同一事务,复用已开启的$db连接避免触发getDB安全网ROLLBACK) + if ($isCashier || $isAdminToken || $isAdminSession) { + $operatorType = $isCashier ? 'cashier' : 'admin'; + $operatorName = $isCashier ? ($cashierRow['username'] ?? '') : 'admin'; + 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 + ], $db); + } + // 写入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)"); @@ -472,7 +532,10 @@ function shopBuyCart($token) { 'rate' => $rate, 'items' => $lines, 'subtotal' => $subtotal, - 'total_price' => $totalPrice, + 'total_price' => $finalPrice, + 'original_price' => $totalPrice, + 'discount_percent' => (int)$discountPercent, + 'discount_amount' => (int)$discountAmount, 'saved' => $saved, 'player' => $player ], '购物车结算成功'); diff --git a/cashier.php b/cashier.php new file mode 100644 index 0000000..30606e7 --- /dev/null +++ b/cashier.php @@ -0,0 +1,577 @@ + $loggedIn, + 'role' => $initialRole, + 'username' => $initialName, + 'discount_limit' => $initialLimit +], JSON_UNESCAPED_UNICODE); +?> + + +
+ + + + + +
+ + +
+