1124 lines
47 KiB
PHP
1124 lines
47 KiB
PHP
<?php
|
||
/**
|
||
* SN 防刷系统 API
|
||
*
|
||
* Java -> 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;
|
||
}
|
||
|
||
/**
|
||
* web_sn_commands.is_force 列懒迁移(管理员强制操作标记)。
|
||
* 老库没有这列,写入/读取前各补一次;每个请求最多一次 PRAGMA。
|
||
*/
|
||
function snEnsureCmdCols($db) {
|
||
static $checked = false;
|
||
if ($checked) return;
|
||
$has = false;
|
||
try {
|
||
$r = $db->query("PRAGMA table_info(web_sn_commands)");
|
||
if ($r) {
|
||
while ($row = $r->fetchArray(SQLITE3_ASSOC)) {
|
||
if (isset($row['name']) && $row['name'] === 'is_force') {
|
||
$has = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {}
|
||
if (!$has) {
|
||
try {
|
||
$db->exec("ALTER TABLE web_sn_commands ADD COLUMN is_force INTEGER DEFAULT 0");
|
||
} catch (\Throwable $e) {}
|
||
}
|
||
$checked = true;
|
||
}
|
||
|
||
/**
|
||
* 下发一条命令给 Java。
|
||
* $isForce = 1 → 管理员强制操作,Java 侧仅本次绕过冷静期(冷静期记录保留)。
|
||
*/
|
||
function snEnqueueCmd($db, $cmd, $sn, $player, $itemType, $reason,
|
||
$linkType = '', $linkId = 0, $isForce = 0) {
|
||
snEnsureCmdCols($db);
|
||
$stmt = $db->prepare(
|
||
"INSERT INTO web_sn_commands
|
||
(cmd, sn, player, item_type, reason, link_type, link_id, is_force, status, created_at)
|
||
VALUES (:cmd, :sn, :player, :type, :reason, :lt, :li, :f, '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(':f', (int)$isForce, SQLITE3_INTEGER);
|
||
$stmt->bindValue(':ts', snNow(), SQLITE3_INTEGER);
|
||
$stmt->execute();
|
||
return (int)$db->lastInsertRowID();
|
||
}
|
||
|
||
/**
|
||
* Java 侧命令执行结论是否算成功(据以决定要不要改 web 状态)。
|
||
* 失败样例:SN不存在 / 注销失败 / 玩家不在线,待其上线后再补发 /
|
||
* 补发申领被拒(见玩家提示) / 补发登记成功但发放失败 / 执行异常: ...
|
||
*/
|
||
function snCmdOk($result) {
|
||
$r = trim((string)$result);
|
||
if ($r === '') return false;
|
||
$bad0 = array('SN不存在', '注销失败', '玩家不在线', '补发申领被拒',
|
||
'补发登记成功', '执行异常', '未知命令');
|
||
foreach ($bad0 as $b) {
|
||
if (mb_strpos($r, $b) === 0) return false;
|
||
}
|
||
if (mb_strpos($r, '失败') !== false) return false;
|
||
if (mb_strpos($r, '拒绝') === 0) return false;
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// 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=CASE WHEN excluded.cancel_time > 0
|
||
THEN excluded.cancel_time ELSE cancel_time END,
|
||
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) {}
|
||
|
||
snEnsureCmdCols($db);
|
||
$out = [];
|
||
$res = $db->query("SELECT id, cmd, sn, player, item_type, reason, is_force
|
||
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'],
|
||
'force' => (int)$row['is_force'],
|
||
];
|
||
$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++;
|
||
|
||
// 注销成功 → 记录冷静期(展示用)
|
||
// ★ Java 侧失败就不改状态,避免"后台显示已注销、游戏里实物还能用"
|
||
if ($cmd === 'cancel' && $player !== '' && snCmdOk($result)) {
|
||
$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' && snCmdOk($result)) {
|
||
$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) {
|
||
// 成功 → 结单;失败 → 转人工,别假装已补发
|
||
$lostStatus = snCmdOk($result) ? 'done' : 'manual';
|
||
$h = $db->prepare("UPDATE web_sn_lost SET status=:s, result=:r, handled_at=:t
|
||
WHERE id=:id");
|
||
$h->bindValue(':s', $lostStatus, SQLITE3_TEXT);
|
||
$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替代,无需注销');
|
||
|
||
// ★ 位置前置检查:物在容器里 / 在别的玩家手上 → 直接拒绝。
|
||
// 否则 DB 注销了、箱子里的实物却还能接着用(与补发/报失保持一致)。
|
||
$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='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不存在');
|
||
|
||
// ★ 管理员强制操作 is_force=1:Java 侧仅本次绕过冷静期(冷静期记录保留)
|
||
$id = snEnqueueCmd($db, $cmd, $sn, $row['owner'], $row['item_type'],
|
||
$reason !== '' ? $reason : '管理员操作', '', 0, 1);
|
||
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, '已通过,补发命令已下发');
|
||
}
|
||
}
|