Files
Sdf1_login/api/sn.php
T

1124 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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, '已通过,补发命令已下发');
}
}