From 59701d162758772fb03a0774bdb30f7fea8d837f Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Mon, 6 Jul 2026 19:27:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=8B=AC=E7=AB=8B=E6=94=B6?= =?UTF-8?q?=E9=93=B6=E5=8F=B0=E9=A1=B5=E9=9D=A2=E4=B8=8E=E6=94=B6=E9=93=B6?= =?UTF-8?q?=E5=91=98=E8=B4=A6=E5=8F=B7=E4=BD=93=E7=B3=BB=EF=BC=9A=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=94=B6=E9=93=B6=E5=91=98/=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=99=BB=E5=BD=95=E3=80=81=E6=89=8B=E5=8A=A8=E6=8A=98?= =?UTF-8?q?=E6=89=A3(=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E4=B8=8A=E9=99=90)=E3=80=81=E8=AE=A2=E5=8D=95=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=EF=BC=9B=E5=90=8C=E6=AD=A5=E4=BF=AE=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E8=B4=AD=E2=89=A51000=E4=BB=8D=E8=AF=AF=E6=8A=A5=E9=9C=80?= =?UTF-8?q?=E7=8E=A9=E5=AE=B6=E5=AF=86=E7=A0=81=E9=97=AE=E9=A2=98(#733=20#?= =?UTF-8?q?734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin.php | 107 ++++++++- api/cashier.php | 276 +++++++++++++++++++++++ api/shop.php | 95 ++++++-- cashier.php | 577 ++++++++++++++++++++++++++++++++++++++++++++++++ core.php | 110 +++++++++ 5 files changed, 1148 insertions(+), 17 deletions(-) create mode 100644 api/cashier.php create mode 100644 cashier.php diff --git a/admin.php b/admin.php index f2d8b49..a10c3d1 100644 --- a/admin.php +++ b/admin.php @@ -110,7 +110,8 @@
📋 工单管理
🏡 领地管理
👥 用户组
-
🧾 收银台
+
🧾 收银台(独立页)
+
🧾 收银员管理
@@ -176,6 +177,7 @@ function go(p) { else if (p==='lands') loadLands(c); else if (p==='usergroups') loadUserGroups(c); else if (p==='cashier') loadCashier(c); + else if (p==='cashier_manage') loadCashierManage(c); } // 检查登录状态 @@ -3165,6 +3167,109 @@ function glassPrompt(label, currentVal, hint = '') { setTimeout(() => input.focus(), 100); }); } + +// ===== 收银员管理(独立收银台账号)===== +function openCashierPage() { + window.open('cashier.php', '_blank'); +} + +async function loadCashierManage(el) { + el.innerHTML = `
+

🧾 收银员管理

+

收银员可登录独立收银台(cashier.php)代玩家购买商品并手动打折,折扣上限在此设置。管理员亦可登录收银台。

+
+

+ 新增收银员

+
+
+
+ +
+
加载中…
+
`; + await cmLoad(); +} + +async function cmAdd() { + const username = (document.getElementById('cmUser').value || '').trim(); + const password = document.getElementById('cmPass').value || ''; + const limit = parseInt(document.getElementById('cmLimit').value || '0', 10); + if (!username || !password) { toast('账号和密码不能为空', 'err'); return; } + if (limit < 0 || limit > 100) { toast('折扣上限需在0-100', 'err'); return; } + try { + const d = await fetch('api/cashier.php?action=add', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + '&discount_limit_percent=' + limit + }).then(r => r.json()); + if (d.success) { toast('收银员已创建', 'ok'); document.getElementById('cmUser').value = ''; document.getElementById('cmPass').value = ''; cmLoad(); } + else toast(d.message || '创建失败', 'err'); + } catch (e) { toast(e.message, 'err'); } +} + +async function cmLoad() { + const wrap = document.getElementById('cmList'); + if (!wrap) return; + try { + const d = await fetch('api/cashier.php?action=cashier_list').then(r => r.json()); + if (!d.success) { wrap.innerHTML = '
' + escAdmHtml(d.message) + '
'; return; } + if (!d.data.length) { wrap.innerHTML = '
暂无收银员
'; return; } + wrap.innerHTML = '' + d.data.map(c => ` + + + + + + + `).join('') + '
ID账号折扣上限创建时间操作
${c.id}${escAdmHtml(c.username)}${c.discount_limit_percent}%${c.created_at ? new Date(c.created_at * 1000).toLocaleString('zh-CN') : '-'} + + +
'; + } catch (e) { wrap.innerHTML = '
' + escAdmHtml(e.message) + '
'; } +} + +async function cmEdit(id, username, curLimit) { + const overlay = document.createElement('div'); + overlay.className = 'glass-alert-overlay show'; + overlay.innerHTML = `
+
✏️
+
编辑收银员:${escAdmHtml(username)}
+
新密码(留空则不修改)
+ +
折扣上限(0-100,当前 ${curLimit}%)
+ +
+ + +
+
`; + document.body.appendChild(overlay); + setTimeout(() => document.getElementById('cmNewPw').focus(), 50); +} + +async function cmEditSave(id, btn) { + const pw = document.getElementById('cmNewPw').value; + const limit = parseInt(document.getElementById('cmNewLimit').value || '0', 10); + if (limit < 0 || limit > 100) { toast('折扣上限需在0-100', 'err'); return; } + const body = new URLSearchParams(); + body.set('id', String(id)); + if (pw) body.set('password', pw); + body.set('discount_limit_percent', String(limit)); + btn.disabled = true; + try { + const d = await fetch('api/cashier.php?action=edit', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }).then(r => r.json()); + if (d.success) { toast('已更新', 'ok'); document.querySelector('.glass-alert-overlay.show')?.remove(); cmLoad(); } + else { toast(d.message || '更新失败', 'err'); btn.disabled = false; } + } catch (e) { toast(e.message, 'err'); btn.disabled = false; } +} + +async function cmDelete(id) { + if (!confirm('确认删除该收银员?此操作不可恢复。')) return; + try { + const d = await fetch('api/cashier.php?action=delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'id=' + id }).then(r => r.json()); + if (d.success) { toast('已删除', 'ok'); cmLoad(); } + else toast(d.message || '删除失败', 'err'); + } catch (e) { toast(e.message, 'err'); } +} 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); +?> + + + + + + + + +SDF1 - 收银台 + + + + + + +
+ +
+ + +
+
+

🧾 SDF1 收银台

+ 操作员: +
+
+ + +
+
+ +
+ +
+
+

👤 目标玩家

+
+ + +
+
+
-
债券余额
+
未知
在线状态
+
+
+ +
+

🛒 购物车

+
点击右侧商品加入购物车
+ +
+ +
+
🎒 塞背包 (98折)
+
📦 潜影盒 (+2元)
+
+ + + +
+ + off +
+ +
+ + + +
+
+
+ + +
+

🛍️ 商品目录

+
+ +
+
+
加载中…
+ +
+

📜 订单记录

+
暂无订单
+
+
+
+ + +
+ + + + diff --git a/core.php b/core.php index 395eed1..d2947be 100644 --- a/core.php +++ b/core.php @@ -599,6 +599,34 @@ function initTables(SQLite3 $db) { synced_at INTEGER DEFAULT 0 )"); + // ===== 收银员账号表 ===== + $db->exec("CREATE TABLE IF NOT EXISTS cashiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT DEFAULT '', + discount_limit_percent INTEGER DEFAULT 0, + created_at INTEGER DEFAULT 0, + created_by TEXT DEFAULT '' + )"); + + // ===== 收银台订单表(代购/收银员操作记录)===== + $db->exec("CREATE TABLE IF NOT EXISTS cashier_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_no TEXT NOT NULL UNIQUE, + operator_type TEXT DEFAULT 'cashier', + operator_name TEXT DEFAULT '', + player_name TEXT NOT NULL, + items_detail TEXT DEFAULT '', + subtotal INTEGER DEFAULT 0, + total_price INTEGER DEFAULT 0, + discount_percent INTEGER DEFAULT 0, + discount_amount INTEGER DEFAULT 0, + settlement TEXT DEFAULT '', + status TEXT DEFAULT 'completed', + created_at INTEGER DEFAULT 0 + )"); + $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); @@ -892,6 +920,88 @@ function isAdminLoggedIn() { return isset($_SESSION['admin_auth']) && $_SESSION['admin_auth']; } +// ===== 收银员认证 ===== + +/** + * 收银员登录:校验用户名+密码,成功写入会话 + */ +function cashierLogin($username, $password) { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + $db = getDB(); + $stmt = $db->prepare("SELECT * FROM cashiers WHERE username = :u"); + $stmt->bindValue(':u', $username, SQLITE3_TEXT); + $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC); + if (!$row) return false; + if (!password_verify($password, $row['password_hash'])) return false; + $_SESSION['cashier_auth'] = true; + $_SESSION['cashier_id'] = (int)$row['id']; + $_SESSION['cashier_name'] = $row['username']; + $_SESSION['cashier_discount_limit'] = (int)$row['discount_limit_percent']; + $_SESSION['cashier_login_time'] = time(); + return true; +} + +function isCashierLoggedIn() { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + return isset($_SESSION['cashier_auth']) && $_SESSION['cashier_auth']; +} + +function getCurrentCashier() { + if (!isCashierLoggedIn()) return null; + return [ + 'id' => $_SESSION['cashier_id'] ?? 0, + 'username' => $_SESSION['cashier_name'] ?? '', + 'discount_limit_percent' => (int)($_SESSION['cashier_discount_limit'] ?? 0) + ]; +} + +/** + * 收银台操作鉴权:管理员或收银员会话均可,返回操作者类型('admin'/'cashier') + */ +function requireCashierOrAdminSession() { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + if (isAdminLoggedIn()) return 'admin'; + if (isCashierLoggedIn()) return 'cashier'; + error('未登录收银台', 401); +} + +function cashierLogout() { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + unset($_SESSION['cashier_auth'], $_SESSION['cashier_id'], $_SESSION['cashier_name'], $_SESSION['cashier_discount_limit'], $_SESSION['cashier_login_time']); +} + +/** + * 记录一笔收银台订单(必须在调用方已开启的事务内执行,使用同一数据库连接) + * @param SQLite3|null $db 调用方已开启事务的连接;传 null 时自动获取(非事务场景) + * @return string 订单号 + */ +function recordCashierOrder($data, $db = null) { + if ($db === null) $db = getDB(); + $orderNo = 'C' . date('YmdHis') . str_pad(mt_rand(0, 999), 3, '0', STR_PAD_LEFT); + $stmt = $db->prepare("INSERT INTO cashier_orders (order_no, operator_type, operator_name, player_name, items_detail, subtotal, total_price, discount_percent, discount_amount, settlement, status, created_at) VALUES (:no,:ot,:on,:pn,:det,:sub,:tp,:dp,:da,:st,'completed',:time)"); + $stmt->bindValue(':no', $orderNo, SQLITE3_TEXT); + $stmt->bindValue(':ot', $data['operator_type'] ?? 'cashier', SQLITE3_TEXT); + $stmt->bindValue(':on', $data['operator_name'] ?? '', SQLITE3_TEXT); + $stmt->bindValue(':pn', $data['player_name'] ?? '', SQLITE3_TEXT); + $stmt->bindValue(':det', json_encode($data['items_detail'] ?? [], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT); + $stmt->bindValue(':sub', (int)($data['subtotal'] ?? 0), SQLITE3_INTEGER); + $stmt->bindValue(':tp', (int)($data['total_price'] ?? 0), SQLITE3_INTEGER); + $stmt->bindValue(':dp', (int)($data['discount_percent'] ?? 0), SQLITE3_INTEGER); + $stmt->bindValue(':da', (int)($data['discount_amount'] ?? 0), SQLITE3_INTEGER); + $stmt->bindValue(':st', $data['settlement'] ?? '', SQLITE3_TEXT); + $stmt->bindValue(':time', time(), SQLITE3_INTEGER); + $stmt->execute(); + return $orderNo; +} + // ===== 定时清理(仅清理过期token,每次请求执行一次) ===== function cleanExpiredTokensLazy() { // 频率限制:每5分钟最多清理一次