SN三条件门槛:报失/补办/注销统一前置校验与最小化提示(不透露未达标项),web_item_sn 新增 detached_at/own_chest,管理端详情展示逐条判定并拆分强制注销/代办注销

This commit is contained in:
ypsdf1
2026-09-29 20:22:20 +08:00
parent 52e07ec9de
commit fd03189f31
3 changed files with 284 additions and 70 deletions
+30 -6
View File
@@ -4162,14 +4162,32 @@ async function renderSnDetail(el, sn) {
snAdmRow('报失次数', it.lost_count) +
'</table>';
// 注销 / 报失 / 补办统一门槛:三条件逐条判定(管理端专有,玩家端不可见)
const cu = it.custody || null;
if (cu) {
html += '<div style="margin-top:14px;padding:10px 12px;border:1px solid var(--border);' +
'border-radius:8px;background:rgba(255,255,255,.02)">' +
'<div style="font-size:13px;font-weight:600;margin-bottom:6px">🚪 办理门槛(三条件,同时满足才放行)</div>' +
'<table style="width:100%;border-collapse:collapse;font-size:13px">' +
snAdmRow('① 不在本人身上', cu.in_body ? '❌ 在本人身上' : '✅ 不在身上') +
snAdmRow('② 不在本人领地箱子', cu.in_own_chest ? '❌ 在本人领地箱子内' : '✅ 不在') +
snAdmRow('③ 脱离自身管控 &gt; 12 小时',
(cu.detached_ok ? '✅ 达标' : '❌ 未达标') + '(已脱离 ' + escAdmHtml(String(cu.detached_text || '-')) + ',阈值 12 小时)') +
snAdmRow('总判定', cu.pass ? '✅ 达标,可办理' : '❌ 未达标(对玩家只回通用提示)') +
'</table></div>';
}
html += '<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">' +
'<button class="btn" onclick="snAdmCmd(\'locate\')">📍 立即定位</button>' +
'<button class="btn btn-yellow" onclick="snAdmCmd(\'reissue\')">♻️ 强制补发</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'cancel\')">🗑️ 注销</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'cancel\', 1)">🗑️ 强制注销</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'cancel\', 0)">📋 代办注销</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'bind\')">🔒 永久绑定</button>' +
'<button class="btn btn-yellow" onclick="snAdmCmd(\'report_check\')">🚩 报失核查</button>' +
'</div>' +
'<p style="color:var(--dim);font-size:12px;margin-top:8px">命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。</p>';
'<p style="color:var(--dim);font-size:12px;margin-top:8px">命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。' +
'<br><b>强制注销 / 强制补发</b>:豁免上面的三条件门槛,按定义直接执行;' +
'<b>代办注销</b>:按三条件校验,未达标会被拒绝(对玩家只提示"未达到办理条件")。</p>';
html += '<h3 style="margin:18px 0 8px;font-size:14px">📨 命令记录</h3>';
const cmds = d.commands || [];
@@ -4241,20 +4259,26 @@ function snAdmLogTable(rows, heads, mapper) {
return h + '</table></div>';
}
async function snAdmCmd(cmd) {
async function snAdmCmd(cmd, force) {
if (force === undefined || force === null) force = 1;
const sn = snAdm.sn;
if (!sn) return;
const names = { locate: '定位', reissue: '强制补发', cancel: '注销', bind: '永久绑定', report_check: '报失核查' };
const names = { locate: '定位', reissue: '强制补发', cancel: (force ? '强制注销' : '代办注销'), bind: '永久绑定', report_check: '报失核查' };
const warns = {
locate: '立即查询该SN在游戏世界中的位置?',
reissue: '强制补发 ' + sn + '?旧SN作废并签发新SN,玩家需在线。',
cancel: '注销 ' + sn + '?将销毁对应实物并进入1小时冷静期。',
cancel: force
? '强制注销 ' + sn + '?豁免三条件门槛,将销毁对应实物并进入1小时冷静期。'
: '代办注销 ' + sn + '?按三条件门槛校验(不在本人身上 / 不在本人领地箱子 / 脱离自身管控超12小时),未达标会被拒绝。',
bind: '把 ' + sn + ' 永久绑定为非法物品?该SN不可恢复。',
report_check: '立即核查 ' + sn + ' 的当前位置并给出报失结论?'
};
if (!await glassConfirm(warns[cmd] || ('确认对 ' + sn + ' 执行「' + names[cmd] + '」?'))) return;
try {
const r = await snAdmApi('admin_cmd', { cmd: cmd, sn: sn, reason: '管理员手动执行' });
const r = await snAdmApi('admin_cmd', {
cmd: cmd, sn: sn, force: force,
reason: force ? '管理员强制执行' : '管理员代办(按三条件门槛校验)'
});
if (!r.success) { toast(r.message || '下发失败', 'err'); return; }
toast(r.message || '命令已下发', 'ok');
setTimeout(() => renderSnAdmin(document.getElementById('C')), 1200);
+244 -64
View File
@@ -334,9 +334,9 @@ function snPushCatalog() {
(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)
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,:ua)
: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,
@@ -350,6 +350,7 @@ function snPushCatalog() {
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);
@@ -372,6 +373,8 @@ function snPushCatalog() {
$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++;
@@ -520,57 +523,124 @@ function snAckCommands() {
$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();
}
// ★ 业务处理与"标 done"分开包 try:任何一条回执的业务逻辑抛异常,
// 只废这一条,不能连坐把整批回执打断(早先一条 SQL 报错就让
// 后面所有命令永远停在 sent,玩家端十分钟等不到回调)。
try {
// 注销成功 → 记录冷静期(展示用)
// ★ 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();
}
// 补发成功 → 记录旧 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();
// 报失核查结果 → 决定拒绝还是自动补发
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) {
$res = $db->prepare("SELECT sn, player, item_type FROM web_sn_lost WHERE id=:id");
// ★ 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'];
@@ -582,12 +652,14 @@ function snHandleLostCheck($db, $lostId, $result, $now) {
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");
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);
@@ -595,12 +667,14 @@ function snHandleLostCheck($db, $lostId, $result, $now) {
return;
}
// 先抢状态再入队:抢到手才补发,防止并发/自愈路径下重复入队
$h = $db->prepare("UPDATE web_sn_lost SET status='auto_reissue', result=:r, handled_at=:t
WHERE id=:id");
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);
@@ -652,6 +726,9 @@ function snDecorate($db, $row, $viewer) {
$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;
}
@@ -680,6 +757,7 @@ function snQuery() {
$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);
@@ -735,26 +813,120 @@ function snMaskName($name) {
return mb_substr($n, 0, 1) . str_repeat('*', min(6, mb_strlen($n) - 2)) . mb_substr($n, -1);
}
/** 位置是否"确实在别处"(报失 / 补发前置检查) */
function snLocBlocking($db, $sn, $player) {
/**
* 注销 / 报失 / 补办统一门槛的三条件判定(与 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不存在'];
if (!$row) {
return ['block' => true, 'msg' => 'SN不存在', 'row' => null, 'custody' => null];
}
$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];
$custody = snCustodyEval($db, $row, $player);
if ($custody['pass']) {
return ['block' => false, 'msg' => '', 'row' => $row, 'custody' => $custody];
}
if ($lt === 'player' && $lp !== '' && strcasecmp($lp, $player) !== 0) {
return ['block' => true, 'msg' => '无法办理:物品当前在其他玩家 ' . $lp . ' 手上', 'row' => $row];
}
return ['block' => false, 'msg' => '', 'row' => $row];
return ['block' => true, 'msg' => '未达到办理条件,请稍后再试',
'row' => $row, 'custody' => $custody];
}
/** 报失(挂失) */
@@ -775,8 +947,9 @@ function snReportLost() {
error('该SN当前状态(' . snStatusCn($row['status']) . ')不允许报失');
}
// ★ 立即检查位置:在箱子里 / 在别的玩家身上 → 直接拒绝
$chk = snLocBlocking($db, $sn, $player);
// ★ 统一门槛:不在本人身上 ∩ 不在本人领地箱子 ∩ 脱离自身管控 > 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)
@@ -850,7 +1023,8 @@ function snReissue() {
error('该SN状态为' . snStatusCn($row['status']) . ',不可补发');
}
$chk = snLocBlocking($db, $sn, $player);
// 补办与报失 / 注销同口径:三条件统一门槛,不达标只给通用回执
$chk = snCustodyGate($db, $sn, $player);
if ($chk['block']) error($chk['msg']);
// 已有待执行的补发命令 → 避免重复
@@ -884,9 +1058,10 @@ function snCancel() {
if ($row['status'] === 'destroyed') error('该SN已销毁解绑,无需注销');
if ($row['status'] === 'reissued') error('该SN已被新SN替代,无需注销');
// ★ 位置前置检查:物在容器里 / 在别的玩家手上 → 直接拒绝。
// 否则 DB 注销了、箱子里的实物却还能接着用(与补发/报失保持一致)。
$chk = snLocBlocking($db, $sn, $player);
// ★ 统一门槛:物品脱离自身管控(不在本人身上、不在本人领地箱子、>12 小时)
// 才允许注销,否则玩家可拿身上/箱子里的东西反复注销换新。
// 未达标只回通用回执,逐条判定只在管理端 SN 详情里看。
$chk = snCustodyGate($db, $sn, $player);
if ($chk['block']) error($chk['msg']);
$pr = $db->prepare("SELECT COUNT(*) AS c FROM web_sn_commands
@@ -971,6 +1146,8 @@ function snAdminDetail() {
$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");
@@ -1023,9 +1200,12 @@ function snAdminCmd() {
$row = $rr->fetchArray(SQLITE3_ASSOC);
if (!$row) error('SN不存在');
// ★ 管理员强制操作 is_force=1:Java 侧仅本次绕过冷静期(冷静期记录保留)
// ★ is_force=1(默认,强制操作):Java 侧本次绕过冷静期 + 绕过三条件门槛
// (强制注销 / 强制补发按定义豁免"脱离自身管控"三项)。
// 代办操作传 force=0 → 照常按三条件校验,逐条判定见 SN 详情的 custody。
$force = (int)getParam('force', 1) ? 1 : 0;
$id = snEnqueueCmd($db, $cmd, $sn, $row['owner'], $row['item_type'],
$reason !== '' ? $reason : '管理员操作', '', 0, 1);
$reason !== '' ? $reason : '管理员操作', '', 0, $force);
success(['id' => $id], '命令已下发,等待游戏服务器执行(10~30 秒)');
}
@@ -1123,7 +1303,7 @@ function snAdminLostHandle() {
$u->bindValue(':id', $id, SQLITE3_INTEGER);
$u->execute();
snEnqueueCmd($db, 'reissue', $row['sn'], $owner, $itemType,
'人工核查通过补发', 'lost', $id);
'人工核查通过补发', 'lost', $id, 1);
success(null, '已通过,补发命令已下发');
}
}
+10
View File
@@ -839,6 +839,16 @@ function initTables(SQLite3 $db) {
updated_at INTEGER DEFAULT 0
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_item_sn_owner ON web_item_sn(owner, item_type)");
// 脱离自身管控起始时刻(秒;0=仍在本人管辖内)/ 容器是否在本人名下领地
// ——注销、报失、补办统一三条件判定的数据基础,由 Java push_catalog 同步
foreach (['detached_at INTEGER DEFAULT 0',
'own_chest INTEGER DEFAULT 0'] as $snCustodyCol) {
try {
$db->exec("ALTER TABLE web_item_sn ADD COLUMN " . $snCustodyCol);
} catch (\Throwable $e) {
// 列已存在 → 忽略
}
}
// SN 操作日志尾部(Java sn_log 镜像)
$db->exec("CREATE TABLE IF NOT EXISTS web_sn_log (