Files
Sdf1_login/api/sn.php
T

1389 lines
61 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
* admin_issue_new 顶层办理签发(只传 玩家ID + 设备类型)
* admin_cmd_result 按命令ID查回执(顶层签发没有详情页可挂)
*/
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;
case 'admin_issue_new': snAdminIssueNew(); break;
case 'admin_cmd_result': snAdminCmdResult(); 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 → 管理员强制操作:
* cancel → 豁免三条件门槛,且不启动 1 小时冷静期(回执侧也不写 web_sn_cooldown);
* reissue → 豁免三条件门槛,申领时本次绕过冷静期校验(已有冷静期记录保留)。
* $isForce = 0(代办 / 玩家申请)→ 照常:三条件门槛 + 1 小时冷静期。
*/
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, detached_at, own_chest, updated_at)
VALUES (:sn,:type,:owner,:status,:issue,:lt,:lp,:lw,:lx,:ly,:lz,
:ct,:il,:ln,:ls,:ctm,:lc,:lss,:remark,:br,:bt,:da,:oc,: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,
detached_at=excluded.detached_at, own_chest=excluded.own_chest,
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(':da', snTs(isset($it['detached_at']) ? $it['detached_at'] : 0), SQLITE3_INTEGER);
$stmt->bindValue(':oc', snInt(isset($it['own_chest']) ? $it['own_chest'] : 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();
snEnsureCmdCols($db);
$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;
$isForce = 0;
$alreadyDone = false;
$res = $db->prepare("SELECT cmd, sn, player, item_type, link_type, link_id, status,
is_force
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'];
$isForce = (int)$row['is_force'];
$alreadyDone = ((string)$row['status'] === 'done');
}
// ★ 幂等:Java 侧只有确认收到才从本地回执缓冲移除,网络抖动会原样重发。
// 已处理过的直接跳过,否则报失核查通过会再入队一次补发、注销会重置冷静期。
if ($alreadyDone) continue;
$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++;
// ★ 业务处理与"标 done"分开包 try:任何一条回执的业务逻辑抛异常,
// 只废这一条,不能连坐把整批回执打断(早先一条 SQL 报错就让
// 后面所有命令永远停在 sent,玩家端十分钟等不到回调)。
try {
// 注销成功 → 更新状态 + 记录冷静期(展示用)
// ★ Java 侧失败就不改状态,避免"后台显示已注销、游戏里实物还能用"
// ★ 2026-09-29 冷静期分流:is_force=1(管理员强制注销)只改状态、
// 不写 web_sn_cooldown —— 强制注销按定义跳过 1 小时冷静期;
// 代办注销 / 玩家注销(is_force=0)照常记录,三条件门槛也不变。
if ($cmd === 'cancel' && $player !== '' && snCmdOk($result)) {
if (!$isForce) {
$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();
}
} catch (\Throwable $e) {
// 这一条的业务没落地 → 把关联报失单转人工,别让它永远挂着"检查中"
if ($linkType === 'lost' && $linkId > 0) {
try {
$h = $db->prepare("UPDATE web_sn_lost SET status='manual', result=:r,
handled_at=:t WHERE id=:id AND status='checking'");
$h->bindValue(':r', '回执处理异常,已转人工:' . $e->getMessage(), SQLITE3_TEXT);
$h->bindValue(':t', $now, SQLITE3_INTEGER);
$h->bindValue(':id', $linkId, SQLITE3_INTEGER);
$h->execute();
} catch (\Throwable $e2) {}
}
}
}
// ★ 自愈:把"命令早已回执、报失单却还停在 checking"的历史卡单补齐。
// 老版本一次 SQL 报错留下的存量数据靠它追平,玩家端刷新即可看到结论。
snHealStuckLost($db, $now);
jsonResponse(['ok' => true, 'n' => $done]);
}
/**
* 自愈停摆的报失单:
* - report_check 已回执 → 按结论推进(拒绝 / 自动补发);
* - report_check 超时或命令缺失 → 转人工,不能让玩家永远等"检查中"。
* 幂等:只处理 status='checking' 的行,snHandleLostCheck 内部不会重复入队。
*/
function snHealStuckLost($db, $now) {
try {
$sql = "SELECT l.id AS lid, l.sn AS lsn, c.status AS cstatus, c.result AS cresult
FROM web_sn_lost l
LEFT JOIN web_sn_commands c
ON c.link_type='lost' AND c.link_id=l.id AND c.cmd='report_check'
WHERE l.status='checking'
ORDER BY l.id DESC LIMIT 50";
$res = $db->query($sql);
if (!$res) return;
$rows = [];
while ($x = $res->fetchArray(SQLITE3_ASSOC)) $rows[] = $x;
foreach ($rows as $x) {
$lid = (int)$x['lid'];
$st = (string)$x['cstatus'];
if ($st === 'done') {
snHandleLostCheck($db, $lid, (string)$x['cresult'], $now);
continue;
}
if ($st === 'timeout' || $st === '') {
$h = $db->prepare("UPDATE web_sn_lost SET status='manual', result=:r,
handled_at=:t WHERE id=:id AND status='checking'");
$h->bindValue(':r', $st === 'timeout'
? '游戏服超时未回执,已转人工核查'
: '未找到对应核查命令,已转人工核查', SQLITE3_TEXT);
$h->bindValue(':t', $now, SQLITE3_INTEGER);
$h->bindValue(':id', $lid, SQLITE3_INTEGER);
$h->execute();
}
// pending / sent → 仍在等待游戏服,保持 checking,下轮再看
}
} catch (\Throwable $e) {}
}
/** 报失核查回执:拒绝 / 放行自动补发 */
function snHandleLostCheck($db, $lostId, $result, $now) {
// ★ web_sn_lost 表没有 item_type 列(早先误写进 SELECT),SQLite 直接抛
// "no such column" → 整批回执在写完 status='done' 之后中断 → 报失单
// 永久停在 checking,玩家端十分钟都等不到回调。列名必须与建表语句一致。
$res = $db->prepare("SELECT sn, player, status FROM web_sn_lost WHERE id=:id");
$res->bindValue(':id', $lostId, SQLITE3_INTEGER);
$rr = $res->execute();
$row = $rr->fetchArray(SQLITE3_ASSOC);
if (!$row) return;
// ★ 幂等闸门:主路径与 snHealStuckLost 自愈都可能调到这里。
// 只有仍是 checking 才允许推进,否则会重复入队一次补发(刷物品)。
if ((string)$row['status'] !== 'checking') 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) {
// UPDATE 带 status='checking' 条件,抢不到说明已被别处结单 → 直接返回
$h = $db->prepare("UPDATE web_sn_lost SET status='rejected', result=:r, handled_at=:t
WHERE id=:id AND status='checking'");
$h->bindValue(':r', $result, SQLITE3_TEXT);
$h->bindValue(':t', $now, SQLITE3_INTEGER);
$h->bindValue(':id', $lostId, SQLITE3_INTEGER);
$h->execute();
if ($db->changes() <= 0) return;
$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 AND status='checking'");
$h->bindValue(':r', $result, SQLITE3_TEXT);
$h->bindValue(':t', $now, SQLITE3_INTEGER);
$h->bindValue(':id', $lostId, SQLITE3_INTEGER);
$h->execute();
if ($db->changes() <= 0) return;
$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);
// 三条件的原始数据位(脱离管控起始时刻 / 是否在本人领地箱子)只对管理端
// 开放——玩家端若拿到 detached_at 就能反推出 12 小时这条硬阈值。
unset($row['detached_at'], $row['own_chest']);
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();
snHealStuckLost($db, snNow());
$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);
}
/**
* 注销 / 报失 / 补办统一门槛的三条件判定(与 Java snCustodyDetail 同口径):
* ① 物品不在本人身上;
* ② 物品不在本人名下领地内的箱子里(只认领地归属);
* ③ 物品脱离自身管控已超过 12 小时(回到管辖内清零,再次脱离重新起算)。
* 返回逐条判定结果——仅供【管理端】展示,对玩家一律只回通用回执。
*/
function snCustodyEval($db, $row, $player) {
$lt = isset($row['loc_type']) ? (string)$row['loc_type'] : '';
$lp = isset($row['loc_player']) ? (string)$row['loc_player'] : '';
// ① 在本人身上:登记位置 + 手持快照(每 10~30 秒上报)双重佐证
$inBody = ($lt === 'player' && $lp !== ''
&& strcasecmp($lp, $player) === 0);
if (!$inBody) {
try {
$h = $db->prepare("SELECT holder FROM web_sn_held WHERE sn=:sn");
$h->bindValue(':sn', $row['sn'], SQLITE3_TEXT);
$hr = $h->execute();
if ($x = $hr->fetchArray(SQLITE3_ASSOC)) {
$holder = (string)$x['holder'];
if ($holder !== '' && strcasecmp($holder, $player) === 0) {
$inBody = true;
}
}
} catch (\Throwable $e) {}
}
// ② 在本人领地的箱子里:own_chest 由 Java 写入;为防同步延迟,坐标现算兜底
// (web_area_lands 与游戏服 area_lands 双向同步,判定规则与 Java getArea 一致)
$inOwnChest = ($lt === 'container') && !empty($row['own_chest']);
if ($lt === 'container' && !$inOwnChest) {
try {
$w = (string)(isset($row['loc_world']) ? $row['loc_world'] : '');
$x = (int)(isset($row['loc_x']) ? $row['loc_x'] : 0);
$y = (int)(isset($row['loc_y']) ? $row['loc_y'] : 0);
$z = (int)(isset($row['loc_z']) ? $row['loc_z'] : 0);
// SELECT * :core.php 建表用 y1/y2、sync.php 用 y_min/y_max,
// 两边历史不一致,不能写死列名
$lq = $db->prepare("SELECT * FROM web_area_lands
WHERE world = :w AND owner = :o");
$lq->bindValue(':w', $w, SQLITE3_TEXT);
$lq->bindValue(':o', $player, SQLITE3_TEXT);
$lqr = $lq->execute();
while ($l = $lqr->fetchArray(SQLITE3_ASSOC)) {
$minX = min((int)$l['x1'], (int)$l['x2']);
$maxX = max((int)$l['x1'], (int)$l['x2']);
$minZ = min((int)$l['z1'], (int)$l['z2']);
$maxZ = max((int)$l['z1'], (int)$l['z2']);
if ($x < $minX || $x > $maxX || $z < $minZ || $z > $maxZ) continue;
// Y 区间(两套列名都试;区间无效则不按 Y 过滤)
if (array_key_exists('y1', $l)) {
$yLo = (int)$l['y1']; $yHi = (int)$l['y2'];
} elseif (array_key_exists('y_min', $l)) {
$yLo = (int)$l['y_min']; $yHi = (int)$l['y_max'];
} else {
$yLo = null; $yHi = null;
}
if ($yLo !== null && $yHi > $yLo
&& ($y < $yLo || $y > $yHi)) continue;
$inOwnChest = true;
break;
}
} catch (\Throwable $e) {}
}
// ③ 脱离自身管控时长(秒;detached_at=0 表示此刻仍在自身管控内)
$det = isset($row['detached_at']) ? (int)$row['detached_at'] : 0;
if ($det <= 0) $det = isset($row['last_seen']) ? (int)$row['last_seen'] : 0;
$since = ($inBody || $inOwnChest) ? 0 : $det;
$held = $since > 0 ? max(0, snNow() - $since) : 0;
$detachedOk = (!$inBody && !$inOwnChest) && $since > 0 && $held > 43200;
$pass = (!$inBody && !$inOwnChest && $detachedOk);
$heldText = ($inBody || $inOwnChest)
? '0(仍在自身管控内)'
: (floor($held / 3600) > 0
? floor($held / 3600) . ' 小时 ' . floor(($held % 3600) / 60) . ' 分'
: floor($held / 60) . ' 分');
return [
'pass' => $pass,
'in_body' => $inBody,
'in_own_chest' => $inOwnChest,
'detached_ok' => $detachedOk,
'detached_since' => $since,
'detached_hours' => $since > 0 ? round($held / 3600, 2) : 0,
'detached_text' => $heldText,
'text' => '在本人身上=' . ($inBody ? '是' : '否')
. ' / 在本人领地箱子=' . ($inOwnChest ? '是' : '否')
. ' / 脱离自身管控=' . $heldText . '(阈值 12 小时)',
];
}
/**
* 办理前置门槛(报失 / 补办 / 注销共用)。
* 达标 → block=false;未达标 → block=true,msg 是给玩家看的【通用】回执,
* 不透露具体是哪一条没达标(最小化信息透露),逐条结果放 custody 供管理端展示。
*/
function snCustodyGate($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不存在', 'row' => null, 'custody' => null];
}
$custody = snCustodyEval($db, $row, $player);
if ($custody['pass']) {
return ['block' => false, 'msg' => '', 'row' => $row, 'custody' => $custody];
}
return ['block' => true, 'msg' => '未达到办理条件,请稍后再试',
'row' => $row, 'custody' => $custody];
}
/** 报失(挂失) */
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']) . ')不允许报失');
}
// ★ 统一门槛:不在本人身上 ∩ 不在本人领地箱子 ∩ 脱离自身管控 > 12 小时。
// 不达标只回通用回执(不说是哪一条),逐条判定只进管理端展示。
$chk = snCustodyGate($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 = snCustodyGate($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替代,无需注销');
// ★ 统一门槛:物品脱离自身管控(不在本人身上、不在本人领地箱子、>12 小时)
// 才允许注销,否则玩家可拿身上/箱子里的东西反复注销换新。
// 未达标只回通用回执,逐条判定只在管理端 SN 详情里看。
$chk = snCustodyGate($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);
// 管理端专属:注销 / 报失 / 补办三条件逐条判定(玩家端拿不到这些字段)
$row['custody'] = snCustodyEval($db, $row, $row['owner']);
$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 / issue */
function snAdminCmd() {
snRequireAdmin();
$cmd = trim(getParam('cmd', ''));
$sn = trim(getParam('sn', ''));
$reason = trim(getParam('reason', ''));
$allowed = ['cancel', 'reissue', 'locate', 'bind', 'report_check', 'issue'];
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不存在');
// issue(办理签发):两条硬校验、分别提示哪条不达标(与玩家端最小化提示相反)。
// error() 直接 exit → 两条提示天然互斥。校验通过才入队,isForce 固定为 0(无豁免)。
if ($cmd === 'issue') {
$st = (string)$row['status'];
if (!in_array($st, array('cancelled', 'destroyed', 'reissued'), true)) {
error('旧SN尚未注销解绑,当前状态:' . snStatusCn($st)
. '。请先完成注销/销毁/补发后再办理');
}
$cdUntil = snCooldownLeft($db, (string)$row['owner']);
$nowTs = snNow();
if ($cdUntil > $nowTs) {
error('注销冷静期未结束,还剩 ' . ceil(($cdUntil - $nowTs) / 60) . ' 分钟');
}
}
// ★ is_force=1(默认,强制操作):Java 侧本次绕过冷静期 + 绕过三条件门槛
// (强制注销 / 强制补发按定义豁免"脱离自身管控"三项)。
// 代办操作传 force=0 → 照常按三条件校验,逐条判定见 SN 详情的 custody。
$force = (int)getParam('force', 1) ? 1 : 0;
// issue 不受 $force 影响(校验是硬的、无豁免);cancel/reissue 等原有 force 语义不变。
$id = snEnqueueCmd($db, $cmd, $sn, $row['owner'], $row['item_type'],
$reason !== '' ? $reason : ($cmd === 'issue' ? '管理员办理签发' : '管理员操作'),
'', 0, $cmd === 'issue' ? 0 : $force);
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();
$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, 1);
success(null, '已通过,补发命令已下发');
}
}