From 18bd1e9b19995e0cc85c32573cfac00a496aeec7 Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Tue, 29 Sep 2026 11:16:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SN=E9=98=B2=E4=BC=AA=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=20api/sn.php=E3=80=818=E5=BC=A0SN=E6=95=B0=E6=8D=AE=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E7=8E=A9=E5=AE=B6=E7=AB=AF/=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AFSN=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin.php | 371 ++++++++++++++++++ api/sn.php | 1059 ++++++++++++++++++++++++++++++++++++++++++++++++++++ core.php | 116 ++++++ player.php | 223 +++++++++++ 4 files changed, 1769 insertions(+) create mode 100644 api/sn.php diff --git a/admin.php b/admin.php index 4f78095..ccb0e08 100644 --- a/admin.php +++ b/admin.php @@ -164,6 +164,7 @@
📈 在线曲线
🔑 密码重置审核
📋 工单管理
+
🔢 SN防伪管理
🏡 领地管理
👥 用户组
🧾 收银台(独立页)
@@ -235,6 +236,7 @@ function go(p) { else if (p==='online_curve') loadOnlineCurve(c); else if (p==='reset_requests') loadResetRequests(c); else if (p==='tickets') loadTickets(c); + else if (p==='sn') loadSn(c); else if (p==='lands') loadLands(c); else if (p==='usergroups') loadUserGroups(c); else if (p==='cashier') loadCashier(c); @@ -3987,6 +3989,375 @@ async function loadOnlineCurve(el) { } catch (e) { el.innerHTML = '
网络错误: '+escAdmHtml(e.message)+'
'; } } +// ==================== SN 防伪管理 ==================== +const snAdm = { tab: 'items', kw: '', status: '', page: 1, size: 20, sn: '' }; + +async function snAdmApi(action, params = {}) { + const url = new URL('api/sn.php', location.href); + url.searchParams.set('action', action); + for (const [k, v] of Object.entries(params)) { + if (v === '' || v === null || v === undefined) continue; + url.searchParams.set(k, v); + } + const res = await fetch(url, { credentials: 'same-origin' }); + return await res.json(); +} + +async function loadSn(el) { + snAdm.tab = 'items'; + snAdm.page = 1; + snAdm.kw = ''; + snAdm.status = ''; + await renderSnAdmin(el); +} + +async function renderSnAdmin(el) { + el.innerHTML = '

加载中...

'; + try { + if (snAdm.tab === 'lost') { await renderSnLost(el); return; } + if (snAdm.tab === 'stats') { await renderSnStats(el); return; } + if (snAdm.tab === 'detail') { await renderSnDetail(el, snAdm.sn); return; } + await renderSnItems(el); + } catch (e) { + el.innerHTML = '

SN管理加载失败: ' + escAdmHtml(e.message) + '

'; + } +} + +function snTabs() { + const t = snAdm.tab; + const base = (t === 'detail' ? 'items' : t); + const mk = (k, label) => '
' + label + '
'; + return '
' + + mk('items', '📦 设备列表') + + mk('lost', '🚨 报失工单') + + mk('stats', '📊 统计') + + '
'; +} + +function snAdmTab(tab) { + snAdm.tab = tab; + snAdm.page = 1; + renderSnAdmin(document.getElementById('C')); +} + +const SN_STATUS_CN = { + active: '有效', destroyed: '已销毁解绑', cancelled: '已注销', + lost: '报失处理中', illegal: '非法绑定', reissued: '已补发作废' +}; +const SN_LOST_CN = { + checking: '⏳ 核查中', manual: '🧑‍💻 待人工', rejected: '🚫 已驳回', + auto_reissue: '♻️ 已补发', done: '✅ 已完成' +}; + +function snStatusCn(s) { return SN_STATUS_CN[s] || s; } + +function snAdmSearch() { + snAdm.kw = (document.getElementById('snKw') || {}).value || ''; + snAdm.status = (document.getElementById('snSt') || {}).value || ''; + snAdm.page = 1; + renderSnAdmin(document.getElementById('C')); +} + +function snAdmPage(p) { + snAdm.page = p; + renderSnAdmin(document.getElementById('C')); +} + +async function renderSnItems(el) { + const r = await snAdmApi('admin_list', { + kw: snAdm.kw, status: snAdm.status, page: snAdm.page, size: snAdm.size + }); + if (!r.success) { el.innerHTML = '

' + escAdmHtml(r.message || '加载失败') + '

'; return; } + const d = r.data || {}; + const list = d.list || []; + const total = d.total || 0; + const pages = Math.max(1, Math.ceil(total / snAdm.size)); + + let html = '

🔢 SN防伪管理

' + snTabs() + + '
' + + '' + + '' + + '' + + '
'; + + if (!list.length) { + html += '

暂无数据。游戏服每 30 秒推送一次目录快照。

'; + el.innerHTML = html; + return; + } + + html += '
' + + '' + + '' + + '' + + ''; + for (const row of list) { + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
SN码类型状态主人位置签发时间操作
' + escAdmHtml(row.sn) + '' + escAdmHtml(row.type_cn || '') + '' + escAdmHtml(row.status_cn || snStatusCn(row.status)) + '' + escAdmHtml(row.owner || '') + '' + escAdmHtml(row.loc_desc || '') + '' + escAdmHtml(row.issue_time_str || '') + '' + + '' + + '
'; + + html += '
' + + '' + + '第 ' + snAdm.page + ' / ' + pages + ' 页 · 共 ' + total + ' 条' + + '' + + '
'; + el.innerHTML = html; +} + +function snAdmDetail(sn) { + snAdm.tab = 'detail'; + snAdm.sn = sn; + renderSnAdmin(document.getElementById('C')); +} + +function snAdmBack() { + snAdm.tab = 'items'; + renderSnAdmin(document.getElementById('C')); +} + +function snFmtTs(ts) { + const n = parseInt(ts, 10) || 0; + if (!n) return ''; + const t = n >= 100000000000 ? Math.floor(n / 1000) : n; + const d = new Date(t * 1000); + const p = x => (x < 10 ? '0' : '') + x; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); +} + +async function renderSnDetail(el, sn) { + const r = await snAdmApi('admin_detail', { sn: sn }); + if (!r.success) { + el.innerHTML = '

' + escAdmHtml(r.message || '加载失败') + '

' + + '
'; + return; + } + const d = r.data || {}; + const it = d.sn || {}; + let html = '
' + + '
' + + '

🔢 ' + escAdmHtml(it.sn || sn) + '

' + + '
' + + '' + + snAdmRow('类型', it.type_cn) + + snAdmRow('状态', it.status_cn) + + snAdmRow('主人', it.owner) + + snAdmRow('签发时间', it.issue_time_str) + + snAdmRow('当前位置', it.loc_desc) + + snAdmRow('绑定原因', it.bind_reason ? (it.bind_reason + ' @ ' + snFmtTs(it.bind_time)) : '-') + + snAdmRow('报失次数', it.lost_count) + + '
'; + + html += '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '

命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。

'; + + html += '

📨 命令记录

'; + const cmds = d.commands || []; + if (!cmds.length) html += '

暂无

'; + else { + html += '
' + + '' + + ''; + for (const c of cmds) { + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
ID命令状态原因结果时间
' + c.id + '' + escAdmHtml(c.cmd) + '' + escAdmHtml(c.status) + '' + escAdmHtml(c.reason) + '' + escAdmHtml(c.result || '') + '' + snFmtTs(c.created_at) + '
'; + } + + html += '

📜 操作日志

' + snAdmLogTable(d.logs || [], ['时间', '动作', '玩家', '说明'], r => [ + snFmtTs(r.time), r.action, r.player, r.detail + ]); + + html += '

📦 出入库登记

' + snAdmLogTable(d.stock || [], ['时间', '动作', '容器', '坐标', '领地', '玩家'], r => [ + snFmtTs(r.time), r.action, r.container_type || '-', + (r.world || '') + ' ' + r.x + ',' + r.y + ',' + r.z, + r.in_land ? (r.land_name || '是') : '-', r.player || '-' + ]); + + const losts = d.losts || []; + html += '

🚨 报失记录

' + + snAdmLogTable(losts, ['时间', '状态', '次数', '原因', '结论'], r => [ + snFmtTs(r.created_at), SN_LOST_CN[r.status] || r.status, r.report_count, r.reason || '-', r.result || '-' + ]); + + if (d.held) { + html += '

📡 最近上报(手持快照)

' + + '

持有者 ' + escAdmHtml(d.held.holder || '-') + + ' · 主人 ' + escAdmHtml(d.held.owner || '-') + + ' · ' + (d.held.held ? '手持中' : '在背包') + + ' · ' + snFmtTs(d.held.time) + '

'; + } + + html += '
'; + el.innerHTML = html; +} + +function snAdmRow(k, v) { + return '' + + '' + escAdmHtml(k) + '' + + '' + escAdmHtml(v == null || v === '' ? '-' : String(v)) + ''; +} + +function snAdmLogTable(rows, heads, mapper) { + if (!rows.length) return '

暂无记录

'; + let h = '
' + + ''; + for (const t of heads) h += ''; + h += ''; + for (const r of rows) { + const cells = mapper(r); + h += ''; + for (const c of cells) { + h += ''; + } + h += ''; + } + return h + '
' + t + '
' + escAdmHtml(c == null ? '' : String(c)) + '
'; +} + +async function snAdmCmd(cmd) { + const sn = snAdm.sn; + if (!sn) return; + const names = { locate: '定位', reissue: '强制补发', cancel: '注销', bind: '永久绑定', report_check: '报失核查' }; + const warns = { + locate: '立即查询该SN在游戏世界中的位置?', + reissue: '强制补发 ' + sn + '?旧SN作废并签发新SN,玩家需在线。', + cancel: '注销 ' + sn + '?将销毁对应实物并进入1小时冷静期。', + bind: '把 ' + sn + ' 永久绑定为非法物品?该SN不可恢复。', + report_check: '立即核查 ' + sn + ' 的当前位置并给出报失结论?' + }; + if (!await glassConfirm(warns[cmd] || ('确认对 ' + sn + ' 执行「' + names[cmd] + '」?'))) return; + try { + const r = await snAdmApi('admin_cmd', { cmd: cmd, sn: sn, reason: '管理员手动执行' }); + if (!r.success) { toast(r.message || '下发失败', 'err'); return; } + toast(r.message || '命令已下发', 'ok'); + setTimeout(() => renderSnAdmin(document.getElementById('C')), 1200); + } catch (e) { + toast('下发失败: ' + e.message, 'err'); + } +} + +async function renderSnLost(el) { + const r = await snAdmApi('admin_lost_list', { page: 1, size: 100 }); + if (!r.success) { el.innerHTML = '

' + escAdmHtml(r.message || '加载失败') + '

'; return; } + const list = (r.data && r.data.list) || []; + const total = (r.data && r.data.total) || 0; + + let html = '

🚨 SN 报失工单 (' + total + ')

' + snTabs(); + if (!list.length) { + html += '

暂无报失工单。

'; + el.innerHTML = html; + return; + } + html += '
' + + '' + + '' + + '' + + ''; + for (const row of list) { + const pending = (row.status === 'checking' || row.status === 'manual'); + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
IDSN类型玩家状态次数原因 / 结论时间操作
' + row.id + '' + + '' + escAdmHtml(row.sn) + '' + escAdmHtml(row.type_cn || '') + '' + escAdmHtml(row.player) + '' + escAdmHtml(SN_LOST_CN[row.status] || row.status) + '' + (row.report_count || 1) + '' + escAdmHtml((row.reason || '') + (row.result ? ' → ' + row.result : '')) + '' + snFmtTs(row.created_at) + ''; + if (pending) { + html += ' ' + + ''; + } else { + html += '已处理'; + } + html += '
'; + el.innerHTML = html; +} + +async function snAdmLostHandle(id, act) { + const tip = act === 'approve' + ? '通过该报失工单?将立即向玩家下发补发命令。' + : '驳回该报失工单?'; + if (!await glassConfirm(tip)) return; + let reason = act === 'approve' ? '人工核查通过' : '人工核查未通过'; + if (act === 'reject') { + try { + const v = await showModal('驳回报失', '请填写驳回原因', '人工核查未通过'); + if (v === false || v === null || v === undefined) return; + if (String(v).trim()) reason = String(v).trim(); + } catch (e) { /* 保持默认原因 */ } + } + try { + const r = await snAdmApi('admin_lost_handle', { id: id, act: act, reason: reason }); + if (!r.success) { toast(r.message || '处理失败', 'err'); return; } + toast(r.message || '处理成功', 'ok'); + renderSnAdmin(document.getElementById('C')); + } catch (e) { + toast('处理失败: ' + e.message, 'err'); + } +} + +async function renderSnStats(el) { + const r = await snAdmApi('admin_stats', {}); + if (!r.success) { el.innerHTML = '

' + escAdmHtml(r.message || '加载失败') + '

'; return; } + const d = r.data || {}; + let html = '

📊 SN 统计

' + snTabs() + + '' + + snAdmRow('SN 总数', d.total || 0) + + snAdmRow('待处理报失', d.lost_pending || 0) + + snAdmRow('事件条数', d.events || 0) + + '
' + + '

按状态

'; + const bs = d.by_status || {}; + if (!Object.keys(bs).length) html += '

暂无数据

'; + else { + html += ''; + for (const [k, v] of Object.entries(bs)) html += snAdmRow(snStatusCn(k) + ' (' + k + ')', v); + html += '
'; + } + html += '

按类型

'; + const bt = d.by_type || {}; + if (!Object.keys(bt).length) html += '

暂无数据

'; + else { + html += ''; + for (const [k, v] of Object.entries(bt)) html += snAdmRow(k, v); + html += '
'; + } + html += '
'; + el.innerHTML = html; +} + // 切换页面时清理定时器 const _origGo = go; go = function(p) { diff --git a/api/sn.php b/api/sn.php new file mode 100644 index 0000000..017ca6b --- /dev/null +++ b/api/sn.php @@ -0,0 +1,1059 @@ + PHP(secret 校验): + * push_held 手持/背包 SN 快照(每 10~30 秒) + * push_events 事件流(拾取归属不符 / 销毁 / 非法处理) + * push_catalog 目录快照(物品全量 + 日志 + 出入库尾部) + * pull_commands 拉取待执行命令(注销 / 补发 / 报失核查 / 永久绑定) + * ack_commands 回传命令执行结果 + * + * 玩家端(token 校验): + * my_list / query / report_lost / reissue / cancel + * + * 管理端(admin session): + * admin_list / admin_detail / admin_cmd / admin_stats + * admin_lost_list / admin_lost_handle + */ + +while (ob_get_level() > 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'); + +error_reporting(E_ALL); +ini_set('display_errors', '0'); +ini_set('log_errors', '1'); + +if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); } + +register_shutdown_function(function () { + while (ob_get_level() > 0) { @ob_end_flush(); } +}); + +require_once __DIR__ . '/../core.php'; + +if (session_status() === PHP_SESSION_NONE) { session_start(); } + +// ★ POST body 只读一次(PHP 流不可重复读取) +$SN_BODY = null; +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + $raw = @file_get_contents('php://input'); + if ($raw) { $SN_BODY = json_decode($raw, true); } +} + +$action = getParam('action', ''); + +try { + switch ($action) { + // ===== Java -> PHP ===== + case 'push_held': snPushHeld(); break; + case 'push_events': snPushEvents(); break; + case 'push_catalog': snPushCatalog(); break; + case 'pull_commands': snPullCommands(); break; + case 'ack_commands': snAckCommands(); break; + + // ===== 玩家端 ===== + case 'my_list': snMyList(); break; + case 'query': snQuery(); break; + case 'report_lost': snReportLost(); break; + case 'reissue': snReissue(); break; + case 'cancel': snCancel(); break; + + // ===== 管理端 ===== + case 'admin_list': snAdminList(); break; + case 'admin_detail': snAdminDetail(); break; + case 'admin_cmd': snAdminCmd(); break; + case 'admin_stats': snAdminStats(); break; + case 'admin_lost_list': snAdminLostList(); break; + case 'admin_lost_handle': snAdminLostHandle(); break; + + default: + error('未知操作: ' . $action); + } + try { walCheckpoint(); } catch (\Throwable $ignored) {} +} catch (\Throwable $e) { + debugLog('[sn.php] ' . $action . ' 异常: ' . $e->getMessage()); + error('服务器错误: ' . $e->getMessage()); +} + +// ============================================================ +// 通用工具 +// ============================================================ + +/** Java 侧通信校验 */ +function snRequireSecret() { + $secret = getParam('secret', ''); + if ($secret === '' || $secret !== SECRET_KEY) { + error('密钥错误', 403); + } +} + +/** 玩家端 token 校验,返回玩家名 */ +function snRequirePlayer() { + $token = getParam('token', ''); + $info = validateTokenSilent($token); + if (!$info) error('登录状态失效,请重新登录', 401); + $player = isset($info['player']) ? $info['player'] : (isset($info['player_name']) ? $info['player_name'] : ''); + if ($player === '') error('无法识别玩家身份', 401); + return $player; +} + +function snRequireAdmin() { + if (!isAdminLoggedIn()) error('未登录管理后台', 401); +} + +function snBodyArray() { + global $SN_BODY; + if (!is_array($SN_BODY)) return []; + // 兼容 ["a","b"] 与 {"items":[...]} + return $SN_BODY; +} + +function snStr($v, $def = '') { + if ($v === null) return $def; + if (is_bool($v)) return $v ? '1' : '0'; + return (string)$v; +} + +function snInt($v, $def = 0) { + if ($v === null || $v === '') return $def; + return (int)$v; +} + +function snNow() { return time(); } + +/** + * Java 侧时间戳是毫秒(System.currentTimeMillis),PHP 统一按秒存储与展示。 + * 只在 Java 推送入口归一:数值 >= 1e11 视为毫秒。 + */ +function snTs($v) { + $n = (int)$v; + if ($n >= 100000000000) $n = (int)floor($n / 1000); + return $n; +} + +/** 物品类型中文名 */ +function snTypeName($t) { + $m = [ + 'menu_snowball' => '雪球菜单', + 'land_wand' => '区域选择工具', + 'echo_shard' => '回声碎片', + 'pvp_tool' => 'PVP圈地棒', + ]; + return isset($m[$t]) ? $m[$t] : $t; +} + +/** 状态中文名 */ +function snStatusCn($s) { + $m = [ + 'active' => '有效', + 'destroyed' => '已销毁解绑', + 'cancelled' => '已注销', + 'lost' => '报失处理中', + 'illegal' => '非法永久绑定', + 'reissued' => '已补发作废', + ]; + return isset($m[$s]) ? $m[$s] : $s; +} + +/** 下发一条命令给 Java */ +function snEnqueueCmd($db, $cmd, $sn, $player, $itemType, $reason, + $linkType = '', $linkId = 0) { + $stmt = $db->prepare( + "INSERT INTO web_sn_commands + (cmd, sn, player, item_type, reason, link_type, link_id, status, created_at) + VALUES (:cmd, :sn, :player, :type, :reason, :lt, :li, 'pending', :ts)"); + $stmt->bindValue(':cmd', $cmd, SQLITE3_TEXT); + $stmt->bindValue(':sn', $sn, SQLITE3_TEXT); + $stmt->bindValue(':player', $player, SQLITE3_TEXT); + $stmt->bindValue(':type', $itemType, SQLITE3_TEXT); + $stmt->bindValue(':reason', $reason, SQLITE3_TEXT); + $stmt->bindValue(':lt', $linkType, SQLITE3_TEXT); + $stmt->bindValue(':li', (int)$linkId, SQLITE3_INTEGER); + $stmt->bindValue(':ts', snNow(), SQLITE3_INTEGER); + $stmt->execute(); + return (int)$db->lastInsertRowID(); +} + +// ============================================================ +// Java -> PHP +// ============================================================ + +/** 手持/背包 SN 快照(任务6:每 10~30 秒上报) */ +function snPushHeld() { + snRequireSecret(); + $db = getDB(); + $rows = snBodyArray(); + $n = 0; + $now = snNow(); + foreach ($rows as $r) { + if (!is_array($r)) continue; + $sn = snStr(isset($r['sn']) ? $r['sn'] : ''); + if ($sn === '') continue; + $holder = snStr(isset($r['holder']) ? $r['holder'] : ''); + $stmt = $db->prepare( + "INSERT INTO web_sn_held (sn, player, holder, owner, item_type, held, time) + VALUES (:sn, :player, :holder, :owner, :type, :held, :time) + ON CONFLICT(sn) DO UPDATE SET + player=excluded.player, holder=excluded.holder, + owner=excluded.owner, item_type=excluded.item_type, + held=excluded.held, time=excluded.time"); + $stmt->bindValue(':sn', $sn, SQLITE3_TEXT); + $stmt->bindValue(':player', snStr(isset($r['player']) ? $r['player'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':holder', $holder, SQLITE3_TEXT); + $stmt->bindValue(':owner', snStr(isset($r['owner']) ? $r['owner'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':type', snStr(isset($r['item_type']) ? $r['item_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':held', snInt(isset($r['held']) ? $r['held'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':time', snTs(isset($r['time']) ? $r['time'] : 0) ?: $now, SQLITE3_INTEGER); + $stmt->execute(); + + // 手持 → 立刻刷新位置镜像,报失核查更准 + $held = snInt(isset($r['held']) ? $r['held'] : 0); + $type = snStr(isset($r['item_type']) ? $r['item_type'] : ''); + if ($held) { + $u = $db->prepare( + "UPDATE web_item_sn SET loc_type='player', loc_player=:p, + last_seen=:t, updated_at=:t2 WHERE sn=:sn"); + $u->bindValue(':p', $holder, SQLITE3_TEXT); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':t2', $now, SQLITE3_INTEGER); + $u->bindValue(':sn', $sn, SQLITE3_TEXT); + $u->execute(); + } + $n++; + } + jsonResponse(['ok' => true, 'n' => $n]); +} + +/** 事件流(拾取归属不符 / 销毁 / 非法处理) */ +function snPushEvents() { + snRequireSecret(); + $db = getDB(); + $rows = snBodyArray(); + $n = 0; + $now = snNow(); + foreach ($rows as $r) { + if (!is_array($r)) continue; + $act = snStr(isset($r['action']) ? $r['action'] : ''); + $sn = snStr(isset($r['sn']) ? $r['sn'] : ''); + if ($act === '' || $sn === '') continue; + $stmt = $db->prepare( + "INSERT INTO web_sn_event (action, sn, player, detail, created_at) + VALUES (:a, :sn, :p, :d, :t)"); + $stmt->bindValue(':a', $act, SQLITE3_TEXT); + $stmt->bindValue(':sn', $sn, SQLITE3_TEXT); + $stmt->bindValue(':p', snStr(isset($r['player']) ? $r['player'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':d', snStr(isset($r['detail']) ? $r['detail'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':t', snTs(isset($r['created_at']) ? $r['created_at'] : 0) ?: $now, SQLITE3_INTEGER); + $stmt->execute(); + + // 状态即时反映(目录快照到达前先给前端一个正确状态) + $newStatus = null; + if ($act === 'destroyed') $newStatus = 'destroyed'; + elseif (in_array($act, ['pickup_mismatch', 'illegal_craft', 'illegal_burn'], true)) $newStatus = 'illegal'; + if ($newStatus !== null) { + $u = $db->prepare("UPDATE web_item_sn SET status=:s, updated_at=:t WHERE sn=:sn"); + $u->bindValue(':s', $newStatus, SQLITE3_TEXT); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':sn', $sn, SQLITE3_TEXT); + $u->execute(); + } + $n++; + } + jsonResponse(['ok' => true, 'n' => $n]); +} + +/** 目录快照:物品全量 + 日志 + 出入库尾部 */ +function snPushCatalog() { + snRequireSecret(); + $db = getDB(); + $body = snBodyArray(); + $now = snNow(); + $n = 0; + + $items = isset($body['items']) && is_array($body['items']) ? $body['items'] : []; + foreach ($items as $it) { + if (!is_array($it)) continue; + $sn = snStr(isset($it['sn']) ? $it['sn'] : ''); + if ($sn === '') continue; + $stmt = $db->prepare( + "INSERT INTO web_item_sn + (sn, item_type, owner, status, issue_time, loc_type, loc_player, + loc_world, loc_x, loc_y, loc_z, container_type, in_land, land_name, + last_seen, cancel_time, lost_count, lost_state, remark, + bind_reason, bind_time, updated_at) + VALUES (:sn,:type,:owner,:status,:issue,:lt,:lp,:lw,:lx,:ly,:lz, + :ct,:il,:ln,:ls,:ctm,:lc,:lss,:remark,:br,:bt,:ua) + ON CONFLICT(sn) DO UPDATE SET + item_type=excluded.item_type, owner=excluded.owner, + status=excluded.status, issue_time=excluded.issue_time, + loc_type=excluded.loc_type, loc_player=excluded.loc_player, + loc_world=excluded.loc_world, loc_x=excluded.loc_x, + loc_y=excluded.loc_y, loc_z=excluded.loc_z, + container_type=excluded.container_type, in_land=excluded.in_land, + land_name=excluded.land_name, last_seen=excluded.last_seen, + cancel_time=excluded.cancel_time, lost_count=excluded.lost_count, + lost_state=excluded.lost_state, remark=excluded.remark, + bind_reason=excluded.bind_reason, bind_time=excluded.bind_time, + updated_at=excluded.updated_at"); + $stmt->bindValue(':sn', $sn, SQLITE3_TEXT); + $stmt->bindValue(':type', snStr(isset($it['item_type']) ? $it['item_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':owner', snStr(isset($it['owner']) ? $it['owner'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':status', snStr(isset($it['status']) ? $it['status'] : 'active', 'active'), SQLITE3_TEXT); + $stmt->bindValue(':issue', snTs(isset($it['issue_time']) ? $it['issue_time'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':lt', snStr(isset($it['loc_type']) ? $it['loc_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':lp', snStr(isset($it['loc_player']) ? $it['loc_player'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':lw', snStr(isset($it['loc_world']) ? $it['loc_world'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':lx', snInt(isset($it['loc_x']) ? $it['loc_x'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ly', snInt(isset($it['loc_y']) ? $it['loc_y'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':lz', snInt(isset($it['loc_z']) ? $it['loc_z'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ct', snStr(isset($it['container_type']) ? $it['container_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':il', snInt(isset($it['in_land']) ? $it['in_land'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ln', snStr(isset($it['land_name']) ? $it['land_name'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':ls', snTs(isset($it['last_seen']) ? $it['last_seen'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ctm', snTs(isset($it['cancel_time']) ? $it['cancel_time'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':lc', snInt(isset($it['lost_count']) ? $it['lost_count'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':lss', snStr(isset($it['lost_state']) ? $it['lost_state'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':remark', snStr(isset($it['remark']) ? $it['remark'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':br', snStr(isset($it['bind_reason']) ? $it['bind_reason'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':bt', snTs(isset($it['bind_time']) ? $it['bind_time'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ua', $now, SQLITE3_INTEGER); + $stmt->execute(); + $n++; + } + + $logs = isset($body['logs']) && is_array($body['logs']) ? $body['logs'] : []; + foreach ($logs as $l) { + if (!is_array($l)) continue; + $id = snInt(isset($l['id']) ? $l['id'] : 0); + if ($id <= 0) continue; + $stmt = $db->prepare( + "INSERT INTO web_sn_log (id, sn, item_type, action, player, detail, time) + VALUES (:id,:sn,:type,:act,:p,:d,:t) + ON CONFLICT(id) DO UPDATE SET + sn=excluded.sn, item_type=excluded.item_type, action=excluded.action, + player=excluded.player, detail=excluded.detail, time=excluded.time"); + $stmt->bindValue(':id', $id, SQLITE3_INTEGER); + $stmt->bindValue(':sn', snStr(isset($l['sn']) ? $l['sn'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':type', snStr(isset($l['item_type']) ? $l['item_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':act', snStr(isset($l['action']) ? $l['action'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':p', snStr(isset($l['player']) ? $l['player'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':d', snStr(isset($l['detail']) ? $l['detail'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':t', snTs(isset($l['time']) ? $l['time'] : 0), SQLITE3_INTEGER); + $stmt->execute(); + } + + $stock = isset($body['stock']) && is_array($body['stock']) ? $body['stock'] : []; + foreach ($stock as $s) { + if (!is_array($s)) continue; + $id = snInt(isset($s['id']) ? $s['id'] : 0); + if ($id <= 0) continue; + $stmt = $db->prepare( + "INSERT INTO web_sn_stock (id, sn, item_type, action, world, x, y, z, + container_type, in_land, land_name, player, time) + VALUES (:id,:sn,:type,:act,:w,:x,:y,:z,:ct,:il,:ln,:p,:t) + ON CONFLICT(id) DO UPDATE SET + sn=excluded.sn, item_type=excluded.item_type, action=excluded.action, + world=excluded.world, x=excluded.x, y=excluded.y, z=excluded.z, + container_type=excluded.container_type, in_land=excluded.in_land, + land_name=excluded.land_name, player=excluded.player, time=excluded.time"); + $stmt->bindValue(':id', $id, SQLITE3_INTEGER); + $stmt->bindValue(':sn', snStr(isset($s['sn']) ? $s['sn'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':type', snStr(isset($s['item_type']) ? $s['item_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':act', snStr(isset($s['action']) ? $s['action'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':w', snStr(isset($s['world']) ? $s['world'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':x', snInt(isset($s['x']) ? $s['x'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':y', snInt(isset($s['y']) ? $s['y'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':z', snInt(isset($s['z']) ? $s['z'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ct', snStr(isset($s['container_type']) ? $s['container_type'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':il', snInt(isset($s['in_land']) ? $s['in_land'] : 0), SQLITE3_INTEGER); + $stmt->bindValue(':ln', snStr(isset($s['land_name']) ? $s['land_name'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':p', snStr(isset($s['player']) ? $s['player'] : ''), SQLITE3_TEXT); + $stmt->bindValue(':t', snTs(isset($s['time']) ? $s['time'] : 0), SQLITE3_INTEGER); + $stmt->execute(); + } + + jsonResponse(['ok' => true, 'items' => $n, + 'logs' => count($logs), 'stock' => count($stock)]); +} + +/** 拉取待执行命令(返回裸 JSON 数组,Java 侧直接 parseJsonArray) */ +function snPullCommands() { + snRequireSecret(); + $db = getDB(); + $now = snNow(); + + // 超时未回执的命令标记为超时(不自动重放,避免重复补发) + try { + $db->exec("UPDATE web_sn_commands SET status='timeout' + WHERE status='sent' AND sent_at < " . ($now - 180)); + } catch (\Throwable $e) {} + + $out = []; + $res = $db->query("SELECT id, cmd, sn, player, item_type, reason + FROM web_sn_commands WHERE status='pending' + ORDER BY id ASC LIMIT 20"); + if ($res) { + while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $out[] = [ + 'id' => (int)$row['id'], + 'cmd' => (string)$row['cmd'], + 'sn' => (string)$row['sn'], + 'player' => (string)$row['player'], + 'item_type' => (string)$row['item_type'], + 'reason' => (string)$row['reason'], + ]; + $u = $db->prepare("UPDATE web_sn_commands SET status='sent', sent_at=:t WHERE id=:id"); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':id', (int)$row['id'], SQLITE3_INTEGER); + $u->execute(); + } + } + + while (ob_get_level() > 0) { @ob_end_clean(); } + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($out, JSON_UNESCAPED_UNICODE); + exit; +} + +/** 回传命令执行结果 */ +function snAckCommands() { + snRequireSecret(); + $db = getDB(); + $rows = snBodyArray(); + $now = snNow(); + $done = 0; + + foreach ($rows as $r) { + if (!is_array($r)) continue; + $id = snInt(isset($r['id']) ? $r['id'] : 0); + $result = snStr(isset($r['result']) ? $r['result'] : ''); + if ($id <= 0) continue; + + $cmd = ''; + $sn = ''; + $player = ''; + $itemType = ''; + $linkType = ''; + $linkId = 0; + $res = $db->prepare("SELECT cmd, sn, player, item_type, link_type, link_id + FROM web_sn_commands WHERE id = :id"); + $res->bindValue(':id', $id, SQLITE3_INTEGER); + $rr = $res->execute(); + if ($row = $rr->fetchArray(SQLITE3_ASSOC)) { + $cmd = (string)$row['cmd']; + $sn = (string)$row['sn']; + $player = (string)$row['player']; + $itemType = (string)$row['item_type']; + $linkType = (string)$row['link_type']; + $linkId = (int)$row['link_id']; + } + + $u = $db->prepare("UPDATE web_sn_commands SET status='done', result=:r, done_at=:t + WHERE id=:id"); + $u->bindValue(':r', $result, SQLITE3_TEXT); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':id', $id, SQLITE3_INTEGER); + $u->execute(); + $done++; + + // 注销成功 → 记录冷静期(展示用) + if ($cmd === 'cancel' && $player !== '') { + $c = $db->prepare("INSERT INTO web_sn_cooldown (player, until) VALUES (:p, :u) + ON CONFLICT(player) DO UPDATE SET until=excluded.until"); + $c->bindValue(':p', $player, SQLITE3_TEXT); + $c->bindValue(':u', $now + 3600, SQLITE3_INTEGER); + $c->execute(); + $s = $db->prepare("UPDATE web_item_sn SET status='cancelled', cancel_time=:t, + updated_at=:t2 WHERE sn=:sn"); + $s->bindValue(':t', $now, SQLITE3_INTEGER); + $s->bindValue(':t2', $now, SQLITE3_INTEGER); + $s->bindValue(':sn', $sn, SQLITE3_TEXT); + $s->execute(); + } + + // 补发成功 → 记录旧 SN 已作废 + if ($cmd === 'reissue') { + $s = $db->prepare("UPDATE web_item_sn SET status='reissued', updated_at=:t WHERE sn=:sn"); + $s->bindValue(':t', $now, SQLITE3_INTEGER); + $s->bindValue(':sn', $sn, SQLITE3_TEXT); + $s->execute(); + } + + // 报失核查结果 → 决定拒绝还是自动补发 + if ($cmd === 'report_check' && $linkType === 'lost' && $linkId > 0) { + snHandleLostCheck($db, $linkId, $result, $now); + } + if ($cmd === 'reissue' && $linkType === 'lost' && $linkId > 0) { + $h = $db->prepare("UPDATE web_sn_lost SET status='done', result=:r, handled_at=:t + WHERE id=:id"); + $h->bindValue(':r', $result, SQLITE3_TEXT); + $h->bindValue(':t', $now, SQLITE3_INTEGER); + $h->bindValue(':id', $linkId, SQLITE3_INTEGER); + $h->execute(); + } + } + + jsonResponse(['ok' => true, 'n' => $done]); +} + +/** 报失核查回执:拒绝 / 放行自动补发 */ +function snHandleLostCheck($db, $lostId, $result, $now) { + $res = $db->prepare("SELECT sn, player, item_type FROM web_sn_lost WHERE id=:id"); + $res->bindValue(':id', $lostId, SQLITE3_INTEGER); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) return; + + $sn = (string)$row['sn']; + $player = (string)$row['player']; + + $itemType = ''; + $s2 = $db->prepare("SELECT item_type FROM web_item_sn WHERE sn=:sn"); + $s2->bindValue(':sn', $sn, SQLITE3_TEXT); + $r2 = $s2->execute(); + if ($row2 = $r2->fetchArray(SQLITE3_ASSOC)) $itemType = (string)$row2['item_type']; + + if (mb_strpos($result, '拒绝') === 0) { + $h = $db->prepare("UPDATE web_sn_lost SET status='rejected', result=:r, handled_at=:t + WHERE id=:id"); + $h->bindValue(':r', $result, SQLITE3_TEXT); + $h->bindValue(':t', $now, SQLITE3_INTEGER); + $h->bindValue(':id', $lostId, SQLITE3_INTEGER); + $h->execute(); + $u = $db->prepare("UPDATE web_item_sn SET lost_state='rejected', updated_at=:t WHERE sn=:sn"); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':sn', $sn, SQLITE3_TEXT); + $u->execute(); + return; + } + + $h = $db->prepare("UPDATE web_sn_lost SET status='auto_reissue', result=:r, handled_at=:t + WHERE id=:id"); + $h->bindValue(':r', $result, SQLITE3_TEXT); + $h->bindValue(':t', $now, SQLITE3_INTEGER); + $h->bindValue(':id', $lostId, SQLITE3_INTEGER); + $h->execute(); + + $u = $db->prepare("UPDATE web_item_sn SET lost_state='reissuing', updated_at=:t WHERE sn=:sn"); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':sn', $sn, SQLITE3_TEXT); + $u->execute(); + + snEnqueueCmd($db, 'reissue', $sn, $player, $itemType, + '报失核查通过,自动补发', 'lost', $lostId); +} + +// ============================================================ +// 玩家端 +// ============================================================ + +/** 我的 SN 列表 */ +function snMyList() { + $player = snRequirePlayer(); + $db = getDB(); + $out = []; + $res = $db->prepare("SELECT * FROM web_item_sn WHERE owner = :p + ORDER BY issue_time DESC"); + $res->bindValue(':p', $player, SQLITE3_TEXT); + $rr = $res->execute(); + while ($row = $rr->fetchArray(SQLITE3_ASSOC)) { + $out[] = snDecorate($db, $row, $player); + } + + $cd = snCooldownLeft($db, $player); + success(['list' => $out, 'cooldown_until' => $cd, + 'cooldown_left' => max(0, $cd - snNow())]); +} + +function snCooldownLeft($db, $player) { + try { + $res = $db->prepare("SELECT until FROM web_sn_cooldown WHERE player=:p"); + $res->bindValue(':p', $player, SQLITE3_TEXT); + $rr = $res->execute(); + if ($row = $rr->fetchArray(SQLITE3_ASSOC)) return (int)$row['until']; + } catch (\Throwable $e) {} + return 0; +} + +/** 给一行 SN 补充展示字段 */ +function snDecorate($db, $row, $viewer) { + $row['type_cn'] = snTypeName($row['item_type']); + $row['status_cn'] = snStatusCn($row['status']); + $row['issue_time_str'] = $row['issue_time'] ? date('Y-m-d H:i:s', (int)$row['issue_time']) : ''; + $row['last_seen_str'] = $row['last_seen'] ? date('Y-m-d H:i:s', (int)$row['last_seen']) : ''; + $row['is_owner'] = ($viewer !== null + && strcasecmp($row['owner'], $viewer) === 0); + $row['loc_desc'] = snLocDesc($row); + return $row; +} + +function snLocDesc($row) { + $lt = isset($row['loc_type']) ? $row['loc_type'] : ''; + $lp = isset($row['loc_player']) ? $row['loc_player'] : ''; + if ($lt === 'container') { + $d = '容器内(' . (isset($row['container_type']) ? $row['container_type'] : '') . ') @ ' + . (isset($row['loc_world']) ? $row['loc_world'] : '') . ' ' + . (isset($row['loc_x']) ? $row['loc_x'] : 0) . ',' + . (isset($row['loc_y']) ? $row['loc_y'] : 0) . ',' + . (isset($row['loc_z']) ? $row['loc_z'] : 0); + if (!empty($row['in_land'])) $d .= '(领地 ' . (isset($row['land_name']) ? $row['land_name'] : '') . ')'; + return $d; + } + if ($lt === 'player') { + return $lp !== '' ? '玩家 ' . $lp . ' 身上' : '玩家身上'; + } + if ($lt === 'dropped') return '已丢弃在地面'; + return $lt !== '' ? $lt : '未知'; +} + +/** 单个 SN 查询(玩家端) */ +function snQuery() { + $player = snRequirePlayer(); + $sn = trim(getParam('sn', '')); + if ($sn === '') error('请输入SN码'); + $db = getDB(); + + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn = :sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('SN不存在,请核对后重试'); + + $isOwner = (strcasecmp($row['owner'], $player) === 0); + + $detail = snDecorate($db, $row, $player); + // 非本人只返回脱敏信息 + if (!$isOwner) { + $detail = [ + 'sn' => $row['sn'], + 'type_cn' => snTypeName($row['item_type']), + 'status' => $row['status'], + 'status_cn' => snStatusCn($row['status']), + 'is_owner' => false, + 'owner_mask' => snMaskName($row['owner']), + 'loc_desc' => '非本人物品,位置不展示', + ]; + } else { + $logs = []; + $lr = $db->prepare("SELECT * FROM web_sn_log WHERE sn=:sn ORDER BY time DESC LIMIT 50"); + $lr->bindValue(':sn', $sn, SQLITE3_TEXT); + $lrr = $lr->execute(); + while ($x = $lrr->fetchArray(SQLITE3_ASSOC)) $logs[] = $x; + + $stock = []; + $sr = $db->prepare("SELECT * FROM web_sn_stock WHERE sn=:sn ORDER BY time DESC LIMIT 50"); + $sr->bindValue(':sn', $sn, SQLITE3_TEXT); + $srr = $sr->execute(); + while ($x = $srr->fetchArray(SQLITE3_ASSOC)) $stock[] = $x; + + $losts = []; + $hr = $db->prepare("SELECT * FROM web_sn_lost WHERE sn=:sn ORDER BY id DESC LIMIT 20"); + $hr->bindValue(':sn', $sn, SQLITE3_TEXT); + $hrr = $hr->execute(); + while ($x = $hrr->fetchArray(SQLITE3_ASSOC)) $losts[] = $x; + + $detail['logs'] = $logs; + $detail['stock'] = $stock; + $detail['losts'] = $losts; + $detail['cooldown_left'] = max(0, snCooldownLeft($db, $player) - snNow()); + } + + success($detail); +} + +function snMaskName($name) { + $n = (string)$name; + if (mb_strlen($n) <= 2) return mb_substr($n, 0, 1) . '*'; + return mb_substr($n, 0, 1) . str_repeat('*', min(6, mb_strlen($n) - 2)) . mb_substr($n, -1); +} + +/** 位置是否"确实在别处"(报失 / 补发前置检查) */ +function snLocBlocking($db, $sn, $player) { + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn=:sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) return ['block' => true, 'msg' => 'SN不存在']; + + $lt = $row['loc_type']; + $lp = $row['loc_player']; + if ($lt === 'container') { + $msg = '物品当前在容器内:' . $row['container_type'] . ' @ ' + . $row['loc_world'] . ' ' . $row['loc_x'] . ',' . $row['loc_y'] . ',' . $row['loc_z'] + . (!empty($row['in_land']) ? '(领地 ' . $row['land_name'] . ')' : ''); + return ['block' => true, 'msg' => '无法办理:' . $msg, 'row' => $row]; + } + if ($lt === 'player' && $lp !== '' && strcasecmp($lp, $player) !== 0) { + return ['block' => true, 'msg' => '无法办理:物品当前在其他玩家 ' . $lp . ' 手上', 'row' => $row]; + } + return ['block' => false, 'msg' => '', 'row' => $row]; +} + +/** 报失(挂失) */ +function snReportLost() { + $player = snRequirePlayer(); + $sn = trim(getParam('sn', '')); + $reason = trim(getParam('reason', '')); + if ($sn === '') error('请输入SN码'); + $db = getDB(); + + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn=:sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('SN不存在'); + if (strcasecmp($row['owner'], $player) !== 0) error('只能对自己名下的物品报失'); + if (!in_array($row['status'], ['active', 'lost'], true)) { + error('该SN当前状态(' . snStatusCn($row['status']) . ')不允许报失'); + } + + // ★ 立即检查位置:在箱子里 / 在别的玩家身上 → 直接拒绝 + $chk = snLocBlocking($db, $sn, $player); + if ($chk['block']) { + $db->exec("INSERT INTO web_sn_lost (sn, player, reason, status, result, + report_count, created_at, handled_at) + VALUES ('" . SQLite3::escapeString($sn) . "', + '" . SQLite3::escapeString($player) . "', + '" . SQLite3::escapeString($reason) . "', + 'rejected', + '" . SQLite3::escapeString($chk['msg']) . "', + 1, " . snNow() . ", " . snNow() . ")"); + error($chk['msg']); + } + + // ★ 短时间多次报失 → 转人工核查 + $since = snNow() - 1800; + $cnt = 0; + $cr = $db->prepare("SELECT COUNT(*) AS c FROM web_sn_lost + WHERE sn=:sn AND created_at > :t AND status IN ('checking','auto_reissue')"); + $cr->bindValue(':sn', $sn, SQLITE3_TEXT); + $cr->bindValue(':t', $since, SQLITE3_INTEGER); + $crr = $cr->execute(); + if ($row2 = $crr->fetchArray(SQLITE3_ASSOC)) $cnt = (int)$row2['c']; + $total = $cnt + 1; + + if ($total >= 3) { + $stmt = $db->prepare( + "INSERT INTO web_sn_lost (sn, player, reason, status, result, report_count, created_at) + VALUES (:sn, :p, :r, 'manual', '短时间内多次报失,已转人工核查', :c, :t)"); + $stmt->bindValue(':sn', $sn, SQLITE3_TEXT); + $stmt->bindValue(':p', $player, SQLITE3_TEXT); + $stmt->bindValue(':r', $reason, SQLITE3_TEXT); + $stmt->bindValue(':c', $total, SQLITE3_INTEGER); + $stmt->bindValue(':t', snNow(), SQLITE3_INTEGER); + $stmt->execute(); + success(['status' => 'manual', 'count' => $total], + '30分钟内已报失 ' . $total . ' 次,已转人工核查,请联系管理员'); + } + + // 正常流程:登记 + 下发位置核查命令 + $stmt = $db->prepare( + "INSERT INTO web_sn_lost (sn, player, reason, status, result, report_count, created_at) + VALUES (:sn, :p, :r, 'checking', '', :c, :t)"); + $stmt->bindValue(':sn', $sn, SQLITE3_TEXT); + $stmt->bindValue(':p', $player, SQLITE3_TEXT); + $stmt->bindValue(':r', $reason, SQLITE3_TEXT); + $stmt->bindValue(':c', $total, SQLITE3_INTEGER); + $stmt->bindValue(':t', snNow(), SQLITE3_INTEGER); + $stmt->execute(); + $lostId = (int)$db->lastInsertRowID(); + + snEnqueueCmd($db, 'report_check', $sn, $player, $row['item_type'], + '玩家报失核查', 'lost', $lostId); + + success(['status' => 'checking', 'id' => $lostId, 'count' => $total], + '报失已提交,系统正在核对物品位置,核查通过后会自动补发新SN'); +} + +/** 申请补发(本人确认确实丢失) */ +function snReissue() { + $player = snRequirePlayer(); + $sn = trim(getParam('sn', '')); + if ($sn === '') error('请输入SN码'); + $db = getDB(); + + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn=:sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('SN不存在'); + if (strcasecmp($row['owner'], $player) !== 0) error('只能操作自己名下的物品'); + if (!in_array($row['status'], ['active', 'lost'], true)) { + error('该SN状态为' . snStatusCn($row['status']) . ',不可补发'); + } + + $chk = snLocBlocking($db, $sn, $player); + if ($chk['block']) error($chk['msg']); + + // 已有待执行的补发命令 → 避免重复 + $pr = $db->prepare("SELECT COUNT(*) AS c FROM web_sn_commands + WHERE sn=:sn AND cmd='reissue' AND status IN ('pending','sent')"); + $pr->bindValue(':sn', $sn, SQLITE3_TEXT); + $prr = $pr->execute(); + if ($r2 = $prr->fetchArray(SQLITE3_ASSOC)) { + if ((int)$r2['c'] > 0) error('已有补发请求正在处理中,请稍候'); + } + + snEnqueueCmd($db, 'reissue', $sn, $player, $row['item_type'], '玩家申请补发'); + success(null, '补发申请已提交,玩家在线时将立即签发新SN并发放新物品'); +} + +/** 注销:销毁对应物品 + 1 小时冷静期 */ +function snCancel() { + $player = snRequirePlayer(); + $sn = trim(getParam('sn', '')); + $reason = trim(getParam('reason', '')); + if ($sn === '') error('请输入SN码'); + $db = getDB(); + + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn=:sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('SN不存在'); + if (strcasecmp($row['owner'], $player) !== 0) error('只能操作自己名下的物品'); + if ($row['status'] === 'cancelled') error('该SN已注销'); + if ($row['status'] === 'destroyed') error('该SN已销毁解绑,无需注销'); + if ($row['status'] === 'reissued') error('该SN已被新SN替代,无需注销'); + + $pr = $db->prepare("SELECT COUNT(*) AS c FROM web_sn_commands + WHERE sn=:sn AND cmd='cancel' AND status IN ('pending','sent')"); + $pr->bindValue(':sn', $sn, SQLITE3_TEXT); + $prr = $pr->execute(); + if ($r2 = $prr->fetchArray(SQLITE3_ASSOC)) { + if ((int)$r2['c'] > 0) error('注销请求正在处理中'); + } + + $cdLeft = max(0, snCooldownLeft($db, $player) - snNow()); + if ($cdLeft > 0) { + error('处于注销冷静期,还剩 ' . ceil($cdLeft / 60) . ' 分钟'); + } + + snEnqueueCmd($db, 'cancel', $sn, $player, $row['item_type'], + $reason !== '' ? $reason : '玩家注销'); + success(['cooldown_seconds' => 3600], + '注销申请已提交:将立即销毁对应物品,并进入 1 小时冷静期,期间禁止申领新设备'); +} + +// ============================================================ +// 管理端 +// ============================================================ + +/** SN 列表(分页 + 搜索) */ +function snAdminList() { + snRequireAdmin(); + $db = getDB(); + $kw = trim(getParam('kw', '')); + $status = trim(getParam('status', '')); + $type = trim(getParam('type', '')); + $page = max(1, (int)getParam('page', 1)); + $size = min(100, max(1, (int)getParam('size', 20))); + + $where = []; + $bind = []; + if ($kw !== '') { + $where[] = '(sn LIKE :kw OR owner LIKE :kw OR loc_player LIKE :kw OR land_name LIKE :kw)'; + $bind[':kw'] = '%' . $kw . '%'; + } + if ($status !== '') { $where[] = 'status = :st'; $bind[':st'] = $status; } + if ($type !== '') { $where[] = 'item_type = :ty'; $bind[':ty'] = $type; } + $sql = 'FROM web_item_sn' . (count($where) ? ' WHERE ' . implode(' AND ', $where) : ''); + + $cnt = $db->prepare('SELECT COUNT(*) AS c ' . $sql); + foreach ($bind as $k => $v) $cnt->bindValue($k, $v, SQLITE3_TEXT); + $cr = $cnt->execute(); + $total = 0; + if ($r = $cr->fetchArray(SQLITE3_ASSOC)) $total = (int)$r['c']; + + $offset = ($page - 1) * $size; + $q = $db->prepare('SELECT * ' . $sql . ' ORDER BY issue_time DESC LIMIT ' . (int)$size + . ' OFFSET ' . (int)$offset); + foreach ($bind as $k => $v) $q->bindValue($k, $v, SQLITE3_TEXT); + $rr = $q->execute(); + + $list = []; + while ($row = $rr->fetchArray(SQLITE3_ASSOC)) { + $row['type_cn'] = snTypeName($row['item_type']); + $row['status_cn'] = snStatusCn($row['status']); + $row['issue_time_str'] = $row['issue_time'] ? date('Y-m-d H:i:s', (int)$row['issue_time']) : ''; + $row['loc_desc'] = snLocDesc($row); + $list[] = $row; + } + success(['list' => $list, 'total' => $total, 'page' => $page, 'size' => $size]); +} + +/** SN 详情(含日志 / 出入库 / 报失) */ +function snAdminDetail() { + snRequireAdmin(); + $sn = trim(getParam('sn', '')); + if ($sn === '') error('缺少sn'); + $db = getDB(); + + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn=:sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('SN不存在'); + $row['type_cn'] = snTypeName($row['item_type']); + $row['status_cn'] = snStatusCn($row['status']); + $row['issue_time_str'] = $row['issue_time'] ? date('Y-m-d H:i:s', (int)$row['issue_time']) : ''; + $row['loc_desc'] = snLocDesc($row); + + $logs = []; + $lr = $db->prepare("SELECT * FROM web_sn_log WHERE sn=:sn ORDER BY time DESC LIMIT 100"); + $lr->bindValue(':sn', $sn, SQLITE3_TEXT); + $lrr = $lr->execute(); + while ($x = $lrr->fetchArray(SQLITE3_ASSOC)) $logs[] = $x; + + $stock = []; + $sr = $db->prepare("SELECT * FROM web_sn_stock WHERE sn=:sn ORDER BY time DESC LIMIT 100"); + $sr->bindValue(':sn', $sn, SQLITE3_TEXT); + $srr = $sr->execute(); + while ($x = $srr->fetchArray(SQLITE3_ASSOC)) $stock[] = $x; + + $losts = []; + $hr = $db->prepare("SELECT * FROM web_sn_lost WHERE sn=:sn ORDER BY id DESC LIMIT 20"); + $hr->bindValue(':sn', $sn, SQLITE3_TEXT); + $hrr = $hr->execute(); + while ($x = $hrr->fetchArray(SQLITE3_ASSOC)) $losts[] = $x; + + $cmds = []; + $qr = $db->prepare("SELECT * FROM web_sn_commands WHERE sn=:sn ORDER BY id DESC LIMIT 20"); + $qr->bindValue(':sn', $sn, SQLITE3_TEXT); + $qrr = $qr->execute(); + while ($x = $qrr->fetchArray(SQLITE3_ASSOC)) $cmds[] = $x; + + $held = null; + $hr2 = $db->prepare("SELECT * FROM web_sn_held WHERE sn=:sn"); + $hr2->bindValue(':sn', $sn, SQLITE3_TEXT); + $hr2r = $hr2->execute(); + if ($x = $hr2r->fetchArray(SQLITE3_ASSOC)) $held = $x; + + success(['sn' => $row, 'logs' => $logs, 'stock' => $stock, + 'losts' => $losts, 'commands' => $cmds, 'held' => $held]); +} + +/** 管理员下发命令:cancel / reissue / locate / bind / report_check */ +function snAdminCmd() { + snRequireAdmin(); + $cmd = trim(getParam('cmd', '')); + $sn = trim(getParam('sn', '')); + $reason = trim(getParam('reason', '')); + $allowed = ['cancel', 'reissue', 'locate', 'bind', 'report_check']; + if (!in_array($cmd, $allowed, true)) error('非法命令'); + if ($sn === '') error('缺少sn'); + + $db = getDB(); + $res = $db->prepare("SELECT * FROM web_item_sn WHERE sn=:sn"); + $res->bindValue(':sn', $sn, SQLITE3_TEXT); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('SN不存在'); + + $id = snEnqueueCmd($db, $cmd, $sn, $row['owner'], $row['item_type'], + $reason !== '' ? $reason : '管理员操作'); + success(['id' => $id], '命令已下发,等待游戏服务器执行(10~30 秒)'); +} + +/** 管理端统计 */ +function snAdminStats() { + snRequireAdmin(); + $db = getDB(); + $out = ['total' => 0, 'by_status' => [], 'by_type' => [], 'lost_pending' => 0, 'events' => 0]; + $r = $db->query("SELECT status, COUNT(*) AS c FROM web_item_sn GROUP BY status"); + if ($r) { while ($x = $r->fetchArray(SQLITE3_ASSOC)) { $out['by_status'][$x['status']] = (int)$x['c']; $out['total'] += (int)$x['c']; } } + $r = $db->query("SELECT item_type, COUNT(*) AS c FROM web_item_sn GROUP BY item_type"); + if ($r) { while ($x = $r->fetchArray(SQLITE3_ASSOC)) $out['by_type'][$x['item_type']] = (int)$x['c']; } + $r = $db->query("SELECT COUNT(*) AS c FROM web_sn_lost WHERE status IN ('checking','manual')"); + if ($r) { if ($x = $r->fetchArray(SQLITE3_ASSOC)) $out['lost_pending'] = (int)$x['c']; } + $r = $db->query("SELECT COUNT(*) AS c FROM web_sn_event"); + if ($r) { if ($x = $r->fetchArray(SQLITE3_ASSOC)) $out['events'] = (int)$x['c']; } + success($out); +} + +/** 报失工单列表 */ +function snAdminLostList() { + snRequireAdmin(); + $db = getDB(); + $status = trim(getParam('status', '')); + $page = max(1, (int)getParam('page', 1)); + $size = min(100, max(1, (int)getParam('size', 20))); + + $where = ''; + $bind = []; + if ($status !== '') { $where = ' WHERE status = :st'; $bind[':st'] = $status; } + + $cnt = $db->prepare('SELECT COUNT(*) AS c FROM web_sn_lost' . $where); + foreach ($bind as $k => $v) $cnt->bindValue($k, $v, SQLITE3_TEXT); + $cr = $cnt->execute(); + $total = 0; + if ($r = $cr->fetchArray(SQLITE3_ASSOC)) $total = (int)$r['c']; + + $q = $db->prepare('SELECT * FROM web_sn_lost' . $where . ' ORDER BY id DESC LIMIT ' + . (int)$size . ' OFFSET ' . (int)(($page - 1) * $size)); + foreach ($bind as $k => $v) $q->bindValue($k, $v, SQLITE3_TEXT); + $rr = $q->execute(); + $list = []; + while ($row = $rr->fetchArray(SQLITE3_ASSOC)) { + $row['created_str'] = date('Y-m-d H:i:s', (int)$row['created_at']); + $t = ''; + $tr = $db->prepare("SELECT item_type FROM web_item_sn WHERE sn=:sn"); + $tr->bindValue(':sn', $row['sn'], SQLITE3_TEXT); + $trr = $tr->execute(); + if ($x = $trr->fetchArray(SQLITE3_ASSOC)) $t = snTypeName($x['item_type']); + $row['type_cn'] = $t; + $list[] = $row; + } + success(['list' => $list, 'total' => $total, 'page' => $page, 'size' => $size]); +} + +/** 人工处理报失:approve=通过补发 / reject=驳回 */ +function snAdminLostHandle() { + snRequireAdmin(); + $id = (int)getParam('id', 0); + $act = trim(getParam('act', '')); + $reason = trim(getParam('reason', '')); + if ($id <= 0) error('缺少id'); + if (!in_array($act, ['approve', 'reject'], true)) error('非法操作'); + + $db = getDB(); + $res = $db->prepare("SELECT * FROM web_sn_lost WHERE id=:id"); + $res->bindValue(':id', $id, SQLITE3_INTEGER); + $rr = $res->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('工单不存在'); + if (!in_array($row['status'], ['checking', 'manual'], true)) { + error('该工单已处理:' . $row['status']); + } + + $now = snNow(); + $itemType = ''; + $tr = $db->prepare("SELECT item_type, owner FROM web_item_sn WHERE sn=:sn"); + $tr->bindValue(':sn', $row['sn'], SQLITE3_TEXT); + $trr = $tr->execute(); + $owner = $row['player']; + if ($x = $trr->fetchArray(SQLITE3_ASSOC)) { $itemType = $x['item_type']; $owner = $x['owner']; } + + if ($act === 'reject') { + $msg = $reason !== '' ? $reason : '人工核查未通过'; + $u = $db->prepare("UPDATE web_sn_lost SET status='rejected', result=:r, handled_at=:t WHERE id=:id"); + $u->bindValue(':r', $msg, SQLITE3_TEXT); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':id', $id, SQLITE3_INTEGER); + $u->execute(); + success(null, '已驳回报失'); + } else { + $u = $db->prepare("UPDATE web_sn_lost SET status='auto_reissue', result=:r, handled_at=:t WHERE id=:id"); + $u->bindValue(':r', $reason !== '' ? $reason : '人工核查通过', SQLITE3_TEXT); + $u->bindValue(':t', $now, SQLITE3_INTEGER); + $u->bindValue(':id', $id, SQLITE3_INTEGER); + $u->execute(); + snEnqueueCmd($db, 'reissue', $row['sn'], $owner, $itemType, + '人工核查通过补发', 'lost', $id); + success(null, '已通过,补发命令已下发'); + } +} diff --git a/core.php b/core.php index 0ffce95..d2b8350 100644 --- a/core.php +++ b/core.php @@ -811,6 +811,122 @@ function initTables(SQLite3 $db) { // 列已存在 → 忽略 } + + // ===== SN 防刷系统(PHP 端镜像 + 报失/命令队列)===== + // 物品 SN 全量镜像:由 Java 定时 push_catalog 同步 + $db->exec("CREATE TABLE IF NOT EXISTS web_item_sn ( + sn TEXT PRIMARY KEY, + item_type TEXT DEFAULT '', + owner TEXT DEFAULT '', + status TEXT DEFAULT 'active', + issue_time INTEGER DEFAULT 0, + loc_type TEXT DEFAULT '', + loc_player TEXT DEFAULT '', + loc_world TEXT DEFAULT '', + loc_x INTEGER DEFAULT 0, + loc_y INTEGER DEFAULT 0, + loc_z INTEGER DEFAULT 0, + container_type TEXT DEFAULT '', + in_land INTEGER DEFAULT 0, + land_name TEXT DEFAULT '', + last_seen INTEGER DEFAULT 0, + cancel_time INTEGER DEFAULT 0, + lost_count INTEGER DEFAULT 0, + lost_state TEXT DEFAULT '', + remark TEXT DEFAULT '', + bind_reason TEXT DEFAULT '', + bind_time INTEGER DEFAULT 0, + updated_at INTEGER DEFAULT 0 + )"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_web_item_sn_owner ON web_item_sn(owner, item_type)"); + + // SN 操作日志尾部(Java sn_log 镜像) + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_log ( + id INTEGER PRIMARY KEY, + sn TEXT DEFAULT '', + item_type TEXT DEFAULT '', + action TEXT DEFAULT '', + player TEXT DEFAULT '', + detail TEXT DEFAULT '', + time INTEGER DEFAULT 0 + )"); + + // 出入库登记(容器坐标 / 是否在领地 / 领地名) + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_stock ( + id INTEGER PRIMARY KEY, + sn TEXT DEFAULT '', + item_type TEXT DEFAULT '', + action TEXT DEFAULT '', + world TEXT DEFAULT '', + x INTEGER DEFAULT 0, + y INTEGER DEFAULT 0, + z INTEGER DEFAULT 0, + container_type TEXT DEFAULT '', + in_land INTEGER DEFAULT 0, + land_name TEXT DEFAULT '', + player TEXT DEFAULT '', + time INTEGER DEFAULT 0 + )"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_stock_sn ON web_sn_stock(sn, time)"); + + // 手持/背包 SN 快照(每 10~30 秒上报) + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_held ( + sn TEXT PRIMARY KEY, + player TEXT DEFAULT '', + holder TEXT DEFAULT '', + owner TEXT DEFAULT '', + item_type TEXT DEFAULT '', + held INTEGER DEFAULT 0, + time INTEGER DEFAULT 0 + )"); + + // Java -> PHP 事件流(拾取归属不符 / 销毁 / 非法处理) + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT DEFAULT '', + sn TEXT DEFAULT '', + player TEXT DEFAULT '', + detail TEXT DEFAULT '', + created_at INTEGER DEFAULT 0 + )"); + + // 玩家报失工单 + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_lost ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sn TEXT DEFAULT '', + player TEXT DEFAULT '', + reason TEXT DEFAULT '', + status TEXT DEFAULT 'checking', + result TEXT DEFAULT '', + report_count INTEGER DEFAULT 1, + created_at INTEGER DEFAULT 0, + handled_at INTEGER DEFAULT 0 + )"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_lost_sn ON web_sn_lost(sn, created_at)"); + + // PHP -> Java 命令队列(注销 / 补发 / 报失核查 / 永久绑定) + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cmd TEXT DEFAULT '', + sn TEXT DEFAULT '', + player TEXT DEFAULT '', + item_type TEXT DEFAULT '', + reason TEXT DEFAULT '', + link_type TEXT DEFAULT '', + link_id INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + result TEXT DEFAULT '', + created_at INTEGER DEFAULT 0, + sent_at INTEGER DEFAULT 0, + done_at INTEGER DEFAULT 0 + )"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_web_sn_cmd_status ON web_sn_commands(status, id)"); + + // 冷静期(注销后 1 小时禁止申领,展示用;真正生效在 Java 侧) + $db->exec("CREATE TABLE IF NOT EXISTS web_sn_cooldown ( + player TEXT PRIMARY KEY, + until INTEGER DEFAULT 0 + )"); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); diff --git a/player.php b/player.php index f3e7934..11abe4c 100644 --- a/player.php +++ b/player.php @@ -280,6 +280,7 @@ if ($currentVersion !== $BUILD_VERSION) { +
@@ -1052,6 +1053,7 @@ if ($currentVersion !== $BUILD_VERSION) { else if (page === 'groups') renderGroups(c); else if (page === 'account') renderAccount(c); else if (page === 'ticket') renderTicket(c); + else if (page === 'sn') renderSn(c); else if (page === 'recharge') renderRecharge(c); else if (page === 'orders') renderMyOrders(c); } @@ -4003,6 +4005,227 @@ async function clearAllMemberPerms(landName, targetPlayer) { } } + // ==================== SN 防伪设备管理 ==================== + var snState = { view: 'list', sn: '' }; + + async function renderSn(el) { + if (snState.view === 'detail' && snState.sn) { await renderSnDetail(el, snState.sn); return; } + snState.view = 'list'; + await renderSnList(el); + } + + function snStatusBadge(s) { + const m = { + active: ['有效', 'var(--green)'], + destroyed: ['已销毁解绑', 'var(--dim)'], + cancelled: ['已注销', 'var(--yellow)'], + lost: ['报失处理中', 'var(--yellow)'], + illegal: ['非法绑定', 'var(--red)'], + reissued: ['已补发作废', 'var(--dim)'] + }; + const v = m[s] || [s, 'var(--dim)']; + return '' + v[0] + ''; + } + + async function renderSnList(el) { + el.innerHTML = '

🔢 SN设备管理

加载中...

'; + try { + const r = await api('sn.php', { action: 'my_list' }); + if (!r.success) { + el.innerHTML = '

🔢 SN设备管理

' + escHtml(r.message || '加载失败') + '

'; + return; + } + const d = r.data || {}; + const list = d.list || []; + const cdLeft = d.cooldown_left || 0; + let html = '
' + + '

🔢 我的SN设备 (' + list.length + ')

' + + '

每件插件签发的设备都带有唯一SN码(玩家名 + 10位时间戳 + 2位识别码)。游戏内使用 /printer item 可导出SN记录,/printer 查看流水。

'; + if (cdLeft > 0) { + html += '
⚠️ 注销冷静期剩余 ' + Math.ceil(cdLeft / 60) + ' 分钟,期间无法申领新设备。
'; + } + html += '
' + + '' + + '' + + '
'; + + if (!list.length) { + html += '

暂无名下设备。可在游戏内申领雪球菜单 / 区域选择工具 / 回声碎片 / PVP圈地棒。

'; + } else { + html += '
' + + '' + + '' + + ''; + for (const row of list) { + const canDo = row.is_owner && (row.status === 'active' || row.status === 'lost'); + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
SN码类型状态位置签发时间操作
' + + '' + escHtml(row.sn) + '' + escHtml(row.type_cn || '') + '' + snStatusBadge(row.status) + '' + escHtml(row.loc_desc || '') + '' + escHtml(row.issue_time_str || '') + ''; + if (canDo) { + html += ' ' + + ' ' + + ''; + } + html += '
'; + } + html += '
'; + el.innerHTML = html; + } catch (e) { + el.innerHTML = '

加载失败: ' + escHtml(e.message) + '

'; + } + } + + function snOpen(sn) { + snState.view = 'detail'; + snState.sn = sn; + renderSn(document.getElementById('content')); + } + + async function snDoAction(action, sn) { + const box = document.getElementById('snQueryInput'); + sn = sn || (box ? box.value.trim() : ''); + if (!sn) { toast('请输入SN码', 'error'); return; } + if (action !== 'query') { + const tips = { + report_lost: '确定对 ' + sn + ' 报失吗?系统会立即核对物品位置:若在容器内或在其他玩家身上将被拒绝;确认丢失后自动补发新SN。', + reissue: '确定申请补发 ' + sn + ' 吗?旧SN将作废,并签发带新SN的同类物品。', + cancel: '确定注销 ' + sn + ' 吗?将立即销毁对应实物,并进入1小时冷静期,期间无法申领新设备。' + }; + if (!await glassConfirm(tips[action] || '确认执行该操作?')) return; + } + try { + const r = await api('sn.php', { action: action, sn: sn }); + if (!r.success) { toast(r.message || '操作失败', 'error'); return; } + toast(r.message || '操作成功'); + if (action === 'query') snOpen(sn); + else renderSn(document.getElementById('content')); + } catch (e) { + toast('操作失败: ' + e.message, 'error'); + } + } + + function snBack() { + snState.view = 'list'; + snState.sn = ''; + renderSn(document.getElementById('content')); + } + + async function renderSnDetail(el, sn) { + el.innerHTML = '

SN详情

加载中...

'; + try { + const r = await api('sn.php', { action: 'query', sn: sn }); + if (!r.success) { + el.innerHTML = '

' + escHtml(r.message || '查询失败') + '

' + + '
'; + return; + } + const d = r.data || {}; + const head = '
' + + '

🔢 ' + escHtml(d.sn || sn) + '

' + + '
'; + + let html = '
' + head; + if (d.is_owner === false) { + html += '

该SN不属于你,仅展示脱敏信息。

' + + '' + + snRow('类型', d.type_cn) + snRow('状态', d.status_cn) + snRow('持有人', d.owner_mask) + + '
'; + el.innerHTML = html; + return; + } + + const canDo = (d.status === 'active' || d.status === 'lost'); + html += '' + + snRow('类型', d.type_cn) + + snRow('状态', d.status_cn) + + snRow('签发时间', d.issue_time_str) + + snRow('最近出现', d.last_seen_str) + + snRow('当前位置', d.loc_desc) + + '
'; + if (canDo) { + html += '
' + + '' + + '' + + '' + + '
'; + } + + html += '

📜 操作日志

'; + const logs = d.logs || []; + if (!logs.length) html += '

暂无记录

'; + else { + html += '
' + + ''; + for (const l of logs) { + html += '' + + '' + + '' + + '' + + ''; + } + html += '
时间动作玩家说明
' + escHtml(snFmtTime(l.time)) + '' + escHtml(l.action) + '' + escHtml(l.player) + '' + escHtml(l.detail) + '
'; + } + + html += '

📦 出入库登记

'; + const stock = d.stock || []; + if (!stock.length) html += '

暂无记录

'; + else { + html += '
' + + ''; + for (const st of stock) { + const land = st.in_land ? (st.land_name || '是') : '-'; + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
时间动作容器坐标领地玩家
' + escHtml(snFmtTime(st.time)) + '' + escHtml(st.action) + '' + escHtml(st.container_type || '-') + '' + escHtml((st.world || '') + ' ' + st.x + ',' + st.y + ',' + st.z) + '' + escHtml(land) + '' + escHtml(st.player || '-') + '
'; + } + + const losts = d.losts || []; + if (losts.length) { + html += '

🚨 报失记录

'; + } + + html += '
'; + el.innerHTML = html; + } catch (e) { + el.innerHTML = '

加载失败: ' + escHtml(e.message) + '

' + + '
'; + } + } + + function snRow(k, v) { + return '' + + '' + escHtml(k) + '' + + '' + escHtml(v == null ? '' : String(v)) + ''; + } + + function snFmtTime(ts) { + const n = parseInt(ts, 10) || 0; + if (!n) return ''; + const t = n >= 100000000000 ? Math.floor(n / 1000) : n; + const d = new Date(t * 1000); + const p = x => (x < 10 ? '0' : '') + x; + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); + } + function escHtml(s) { return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }