diff --git a/admin.php b/admin.php index c7a1238..7cf134a 100644 --- a/admin.php +++ b/admin.php @@ -4069,6 +4069,11 @@ const SN_STATUS_CN = { active: '有效', destroyed: '已销毁解绑', cancelled: '已注销', lost: '报失处理中', illegal: '非法绑定', reissued: '已补发作废' }; +const SN_TYPE_CN = { + menu_snowball: '雪球菜单', land_wand: '区域选择工具', + echo_shard: '回声碎片', pvp_tool: 'PVP圈地棒' +}; +function snTypeCn(t) { return SN_TYPE_CN[t] || t; } const SN_LOST_CN = { checking: '⏳ 核查中', manual: '🧑‍💻 待人工', rejected: '🚫 已驳回', auto_reissue: '♻️ 已补发', done: '✅ 已完成' @@ -4107,6 +4112,7 @@ async function renderSnItems(el) { const pages = Math.max(1, Math.ceil(total / snAdm.size)); let html = '

🔢 SN防伪管理

' + snTabs() + + snIssueBox() + '
' + '' + '' + + '' + + '' + + '
' + + '

' + + '只填 玩家 ID + 设备类型 即可签发。前提:玩家在线 · 该类名下没有有效登记 · 注销冷静期(1小时)已结束;' + + '任一不满足会在 10~30 秒内回执具体原因。命令由游戏服异步执行。

' + + '
'; +} + +/** + * 顶层「办理签发」提交:只带 玩家ID + 设备类型。 + */ +async function snAdmIssueNew() { + const pEl = document.getElementById('snIssuePlayer'); + const tEl = document.getElementById('snIssueType'); + const player = ((pEl && pEl.value) || '').trim(); + const itemType = (tEl && tEl.value) || ''; + if (!player) { toast('请输入玩家 ID', 'err'); return; } + if (!itemType) { toast('请选择设备类型', 'err'); return; } + const ok = await glassConfirm('为玩家 ' + player + ' 办理签发「' + snTypeCn(itemType) + '」?' + + '前提:玩家在线、该类名下无有效登记、注销冷静期已结束;未达标会回执是哪一条。', '\U0001f3ab'); + if (!ok) return; + try { + const r = await snAdmApi('admin_issue_new', { + player: player, item_type: itemType, reason: '管理员顶层办理签发' + }); + if (!r.success) { toast(r.message || '下发失败', 'err'); return; } + toast(r.message || '签发命令已下发', 'ok'); + const cmdId = (r.data && r.data.id) || 0; + if (cmdId) snAdmPollIssue(cmdId, 0); + } catch (e) { + toast('下发失败: ' + e.message, 'err'); + } +} + +/** + * 顶层签发的回执轮询:这条命令没有 SN 详情页可挂,按命令 ID 直查 + * (admin_cmd_result)。3 秒一次、最长 150 秒,与 snAdmPollCmd 同节奏。 + */ +function snAdmPollIssue(cmdId, tries) { + const MAX_TRIES = 50; + setTimeout(async () => { + let c = null; + try { + const r = await snAdmApi('admin_cmd_result', { id: cmdId }); + c = (r.success && r.data) || null; + } catch (e) { /* 网络抖动,下一轮再试 */ } + if (c && (c.status === 'done' || c.status === 'timeout')) { + const res = c.result || ''; + if (c.status === 'timeout') toast('游戏服 3 分钟内未回执,命令已超时', 'err'); + else toast('游戏服已执行:' + (res || '(无结果)'), snCmdLooksBad(res) ? 'err' : 'ok'); + renderSnAdmin(document.getElementById('C')); + return; + } + if (tries < MAX_TRIES) snAdmPollIssue(cmdId, tries + 1); + else { toast('游戏服仍未回执,可稍后刷新查看', 'err'); renderSnAdmin(document.getElementById('C')); } + }, 3000); +} + +/** 回执文本是否算失败(与 PHP snCmdOk 同口径:出现失败类词即按失败提示) */ +function snCmdLooksBad(res) { + return !res || /(失败|拒绝|未|不存在|异常|未知|不在线|超时|已有有效)/.test(res); +} + async function renderSnLost(el) { const r = await snAdmApi('admin_lost_list', { page: 1, size: 100 }); if (!r.success) { el.innerHTML = '

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

'; return; } diff --git a/api/sn.php b/api/sn.php index 5dc77d2..acf7fea 100644 --- a/api/sn.php +++ b/api/sn.php @@ -15,6 +15,8 @@ * 管理端(admin session): * admin_list / admin_detail / admin_cmd / admin_stats * admin_lost_list / admin_lost_handle + * admin_issue_new 顶层办理签发(只传 玩家ID + 设备类型) + * admin_cmd_result 按命令ID查回执(顶层签发没有详情页可挂) */ while (ob_get_level() > 0) { @ob_end_clean(); } @@ -69,6 +71,8 @@ try { case 'admin_stats': snAdminStats(); break; case 'admin_lost_list': snAdminLostList(); break; case 'admin_lost_handle': snAdminLostHandle(); break; + case 'admin_issue_new': snAdminIssueNew(); break; + case 'admin_cmd_result': snAdminCmdResult(); break; default: error('未知操作: ' . $action); @@ -1195,7 +1199,7 @@ function snAdminDetail() { 'losts' => $losts, 'commands' => $cmds, 'held' => $held]); } -/** 管理员下发命令:cancel / reissue / locate / bind / report_check */ +/** 管理员下发命令:cancel / reissue / locate / bind / report_check / issue */ function snAdminCmd() { snRequireAdmin(); $cmd = trim(getParam('cmd', '')); @@ -1238,6 +1242,52 @@ function snAdminCmd() { success(['id' => $id], '命令已下发,等待游戏服务器执行(10~30 秒)'); } +/** + * 顶层「办理签发」:只传 玩家ID + 设备类型,直接为该玩家签发一台新设备。 + * + * 与 admin_cmd 的 issue(先查旧 SN、校验其已注销解绑 + 冷静期)是两条链路: + * 这里根本没有旧 SN,所以不在 PHP 侧做名额/冷静期预判——web_item_sn 是 + * Java 推送的副本(30 秒一刷)可能滞后,权威判定在游戏服本地库。 + * PHP 只做"参数齐不齐、类型认不认识"这两个恒真校验,其余交给 Java issue_new。 + * + * is_force=0:冷静期与「同种类仅 1 个」照常生效,拒绝原因原样回执到管理端。 + */ +function snAdminIssueNew() { + snRequireAdmin(); + $player = trim(getParam('player', '')); + $itemType = trim(getParam('item_type', '')); + $reason = trim(getParam('reason', '')); + if ($player === '') error('缺少玩家ID'); + if ($itemType === '') error('缺少设备类型'); + $known = array('menu_snowball', 'land_wand', 'echo_shard', 'pvp_tool'); + if (!in_array($itemType, $known, true)) { + error('未知设备类型:' . $itemType . '(可选 ' . implode(' / ', $known) . ')'); + } + + $db = getDB(); + $id = snEnqueueCmd($db, 'issue_new', '', $player, $itemType, + $reason !== '' ? $reason : '管理员顶层办理签发', '', 0, 0); + success(['id' => $id], '签发命令已下发,等待游戏服务器执行(10~30 秒)'); +} + +/** + * 按命令 ID 查回执:顶层办理签发没有 SN 详情页可挂(命令的 sn 是空的, + * admin_detail 查不到),只能按 id 直查 status/result。 + */ +function snAdminCmdResult() { + snRequireAdmin(); + $id = (int)getParam('id', 0); + if ($id <= 0) error('缺少id'); + $db = getDB(); + $stmt = $db->prepare("SELECT id, cmd, status, result, created_at + FROM web_sn_commands WHERE id=:id"); + $stmt->bindValue(':id', $id, SQLITE3_INTEGER); + $rr = $stmt->execute(); + $row = $rr->fetchArray(SQLITE3_ASSOC); + if (!$row) error('命令不存在'); + success($row); +} + /** 管理端统计 */ function snAdminStats() { snRequireAdmin();