From d4748c4e6264b03de4c75c908d67b51d76d423c8 Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Thu, 22 Oct 2026 16:43:00 +0800 Subject: [PATCH] =?UTF-8?q?3.11=E5=86=85=E6=B5=8B=E7=89=88=EF=BC=9A?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E6=9B=B2=E7=BA=BF=E9=87=87=E9=9B=86=E4=B8=8A?= =?UTF-8?q?=E6=94=B6core=E5=85=A8=E7=AB=99=E5=94=AF=E4=B8=80=E5=85=A5?= =?UTF-8?q?=E5=8F=A3+=E9=A2=86=E5=9C=B0=E7=AE=A1=E7=90=86=E5=88=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core.php 新增 onlineCurveEnsureSnapshot:心跳判据(>120秒记0防停服假曲线)、60秒节流、保留7天、source列迁移 - sync.php 三挂点(sync_online_players/online_heartbeat/对账兜底)自动留样本,不再依赖有人开页面 - online_curve.php 三本地函数改薄壳+max字段改真实最大值+文案改Java同步自动采集语义 - admin.php 领地列表/权限商店分页渲染:每页10/20/30预制+自定义不封顶+localStorage记忆;曲线文案同步 --- admin.php | 221 +++++++++++++++++++++++++++++++++++------------ api/sync.php | 9 ++ core.php | 91 +++++++++++++++++++ online_curve.php | 67 ++++---------- 4 files changed, 285 insertions(+), 103 deletions(-) diff --git a/admin.php b/admin.php index fc6b2dc..add7a55 100644 --- a/admin.php +++ b/admin.php @@ -2682,67 +2682,182 @@ async function loadLands(el) { `; html += ''; - // 领地列表 - html += '

📍 领地列表

'; - if (lands.length === 0) { - html += '
暂无领地数据
Java端会自动同步领地数据到此
'; - } else { - html += '
'; - html += ''; - html += ''; - for (const l of lands) { - const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1)); - html += ` - - - - - - - - `; - } - html += '
ID名称所有者世界坐标面积操作
${l.id}${escAdmHtml(l.name)}${escAdmHtml(l.owner || '无')}${escAdmHtml(l.world)}${l.x1},${l.z1} → ${l.x2},${l.z2}${size} 格² - - -
'; - } + // 领地列表(3.11内测版:分页渲染,表格挪到 renderLandListSec) + html += '
'; + html += '

📍 领地列表

'; + html += landPerCtlHtml(); + html += '
'; + html += '
'; - // 权限商店 - html += '

🛒 权限商店

'; + // 权限商店(3.11内测版:分页渲染,表格挪到 renderLandShopSec) const allShop = shopRes.items || []; - if (allShop.length === 0) { - html += '
暂无权限商品
'; - } else { - html += '
'; - html += ''; - html += ''; - for (const s of allShop) { - const dur = s.duration >= 86400 ? Math.floor(s.duration/86400)+'天' : s.duration >= 3600 ? Math.floor(s.duration/3600)+'小时' : Math.floor(s.duration/60)+'分钟'; - const statusColor = s.status === 'active' ? '#4caf50' : s.status === 'sold' ? '#ff9800' : '#f44336'; - html += ` - - - - - - - - - `; - } - html += '
ID领地卖家价格时长状态买家操作
${s.id}${escAdmHtml(s.land_name)}${escAdmHtml(s.seller)}${s.price}💰${dur}${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'}${s.buyer ? escAdmHtml(s.buyer) : '-'} - ${s.status === 'active' ? `` : ''} -
'; - } + html += '

🛒 权限商店

'; + html += '
'; + // 缓存全量数据供分页渲染(3.11内测版) + window._landCache = { lands: lands, shopAll: allShop, cfg: cfg }; el.innerHTML = html; + renderLandListSec(); + renderLandShopSec(); } catch(e) { console.error('[LANDS] Error:', e); el.innerHTML = `
加载失败: ${escAdmHtml(e.message)}
`; } } +// ===== 领地管理分页(3.11内测版)===== +// 全量数据缓存在 window._landCache,渲染函数只画当前页; +// 每页条数:预制10/20/30 + 自定义(不设封顶),localStorage 记忆。 +window._landPg = { list: 1, shop: 1 }; +function landPerPage() { + const v = parseInt(localStorage.getItem('sdf_land_perpage') || '10', 10); + return v > 0 ? v : 10; // 自定义不设封顶,仅下限1 +} +function landSetPer(v) { + let n = parseInt(v, 10); + if (!(n > 0)) n = 10; + localStorage.setItem('sdf_land_perpage', String(n)); + window._landPg.list = 1; + window._landPg.shop = 1; + renderLandListSec(); + renderLandShopSec(); +} +function landPerSelChange(v) { + if (v === 'custom') { + const inp = document.getElementById('landPerNum'); + if (inp) { inp.focus(); inp.select(); } + } else { + landSetPer(v); + } +} +function landPerCtlHtml() { + const per = landPerPage(); + const custom = !(per === 10 || per === 20 || per === 30); + return `
每页 + + + 个/页 +
`; +} +function landPageBox(total) { + const per = landPerPage(); + return Math.max(1, Math.ceil(total / per)); +} +function landPagerHtml(kind, total) { + if (total === 0) return ''; + const per = landPerPage(); + const pages = landPageBox(total); + let pg = window._landPg[kind] || 1; + if (pg > pages) pg = pages; + if (pg < 1) pg = 1; + window._landPg[kind] = pg; + const from = (pg - 1) * per + 1; + const to = Math.min(total, pg * per); + let s = Math.max(1, pg - 4), e = Math.min(pages, s + 8); + s = Math.max(1, e - 8); + let btns = ''; + const base = 'padding:3px 9px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;'; + for (let i = s; i <= e; i++) { + btns += i === pg + ? `` + : ``; + } + return `
+ 显示 ${from}-${to} / ${total} 条 · 共 ${pages} 页 + + + ${btns} + + +
`; +} +function landGoPage(kind, p) { + const cache = window._landCache || {}; + const total = kind === 'list' ? ((cache.lands || []).length) : ((cache.shopAll || []).length); + const pages = landPageBox(total); + let n = parseInt(p, 10); + if (!(n >= 1)) n = 1; + if (n > pages) n = pages; + window._landPg[kind] = n; + if (kind === 'list') renderLandListSec(); else renderLandShopSec(); +} +function renderLandListSec() { + const box = document.getElementById('landListSec'); + if (!box) return; + const lands = ((window._landCache || {}).lands) || []; + if (lands.length === 0) { + box.innerHTML = '
暂无领地数据
Java端会自动同步领地数据到此
'; + return; + } + const per = landPerPage(); + const pages = landPageBox(lands.length); + if (window._landPg.list > pages) window._landPg.list = pages; + if (window._landPg.list < 1) window._landPg.list = 1; + const rows = lands.slice((window._landPg.list - 1) * per, window._landPg.list * per); + let html = '
'; + html += ''; + html += ''; + for (const l of rows) { + const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1)); + html += ` + + + + + + + + `; + } + html += '
ID名称所有者世界坐标面积操作
${l.id}${escAdmHtml(l.name)}${escAdmHtml(l.owner || '无')}${escAdmHtml(l.world)}${l.x1},${l.z1} → ${l.x2},${l.z2}${size} 格² + + +
'; + html += landPagerHtml('list', lands.length); + box.innerHTML = html; +} +function renderLandShopSec() { + const box = document.getElementById('landShopSec'); + if (!box) return; + const allShop = ((window._landCache || {}).shopAll) || []; + if (allShop.length === 0) { + box.innerHTML = '
暂无权限商品
'; + return; + } + const per = landPerPage(); + const pages = landPageBox(allShop.length); + if (window._landPg.shop > pages) window._landPg.shop = pages; + if (window._landPg.shop < 1) window._landPg.shop = 1; + const rows = allShop.slice((window._landPg.shop - 1) * per, window._landPg.shop * per); + let html = '
'; + html += ''; + html += ''; + for (const s of rows) { + const dur = s.duration >= 86400 ? Math.floor(s.duration/86400)+'天' : s.duration >= 3600 ? Math.floor(s.duration/3600)+'小时' : Math.floor(s.duration/60)+'分钟'; + const statusColor = s.status === 'active' ? '#4caf50' : s.status === 'sold' ? '#ff9800' : '#f44336'; + html += ` + + + + + + + + + `; + } + html += '
ID领地卖家价格时长状态买家操作
${s.id}${escAdmHtml(s.land_name)}${escAdmHtml(s.seller)}${s.price}💰${dur}${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'}${s.buyer ? escAdmHtml(s.buyer) : '-'} + ${s.status === 'active' ? `` : ''} +
'; + html += landPagerHtml('shop', allShop.length); + box.innerHTML = html; +} + function showLandConfig() { const cfg = window._landCfgData || {}; const price = cfg.create_price_per_sqm || '10'; @@ -4177,9 +4292,9 @@ async function loadOnlineCurve(el) { return `
${b.hour.slice(0,2)}
`; }).join('')} -
横轴为 00–23 时,每个柱为该时段平均在线人数;快照每60秒自动记录一次。
+
横轴为 00–23 时,每个柱为该时段平均在线人数;快照由 Java 同步/心跳每60秒自动采集(停服时段记0)。
` : ` -
📊 数据收集中 — 页面每60秒记录一次快照,积累后将自动生成曲线
+
📊 数据收集中 — Java 同步/心跳每60秒自动采集,积累后将自动生成曲线
`} `; }; diff --git a/api/sync.php b/api/sync.php index 7aef641..35fcd7d 100644 --- a/api/sync.php +++ b/api/sync.php @@ -1558,6 +1558,8 @@ function checkAlignment() { @error_log("[checkAlignment] online心跳兜底刷新失败: " . $e->getMessage()); // 失败不影响对账本身 } + // ★ 3.11内测版:对账链路通也视为 Java 活着,顺手留曲线样本 + onlineCurveEnsureSnapshot(false, 'align'); } // ---- Java 报上来的本机 key + 指纹 ---- @@ -2249,6 +2251,9 @@ function syncOnlinePlayers() { $verifyRow = $verifyStmt->fetchArray(SQLITE3_ASSOC); @error_log("[syncOnlinePlayers] Synced $synced/$playerCount, DB now has " . ($verifyRow['cnt'] ?? 0) . " records"); + // ★ 3.11内测版:在线曲线快照统一采集(名单有变化的全量同步路径) + onlineCurveEnsureSnapshot(false, 'sync'); + success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家"); } @@ -2308,6 +2313,10 @@ function onlineHeartbeat() { error('心跳写入失败: ' . $e->getMessage()); } + // ★ 3.11内测版:在线曲线快照统一采集(名单无变化的轻量保活路径—— + // 玩家稳定在线时 Java 只走这条出口,采集必须挂这里才不会整段丢样本) + onlineCurveEnsureSnapshot(false, 'heartbeat'); + success([ 'last_seen' => $fresh, 'server_time' => $now, diff --git a/core.php b/core.php index 8569214..d20706c 100644 --- a/core.php +++ b/core.php @@ -2664,6 +2664,97 @@ function jsonResponse($data, $code = 200) { exit; } + +/** + * ===== 在线曲线快照统一采集(3.11内测版 · 2026-10-22)===== + * + * 背景:此前快照只在有人打开 online_curve.php 页面时才记录(页面加载 + 60秒 + * JS定时器 + ensureRecentSnapshot),三环根因导致数据丢失: + * ① 没人开页面 = 全天零样本,曲线空白; + * ② 停服后若浏览器页面仍开着,定时器拿冻结的 online_players 旧值记账 → + * 停服时段出现假在线曲线(如15点假数据); + * ③ Java 短暂抽风(<120秒)时恰无人看页 → 该分钟样本永久丢失。 + * + * 修法:采集上收到 core.php 全站唯一入口,多路触发统一挂载—— + * sync.php 的 syncOnlinePlayers / onlineHeartbeat / checkAlignment(cat=online) + * 以及 online_curve.php 页面。只要 Java 与 PHP 的同步链路活着(名单变化推送、 + * 轻量心跳、对账任一),就自动留样本,不再依赖有没有人开页面。 + * + * 心跳判据(与 admin 后台 120 秒归零口径一致): + * online_player_hb.last_seen 距现在 <=120秒 → Java 活着 → 记 COUNT(online_players); + * Java 短抽风(<120秒)时表里是最后已知值,样本不丢; + * >120秒 → 记 0(服务端已停/抽风超阈值,杜绝停服后伪造曲线)。 + * + * 其余:60秒节流(全站唯一写者,force 可穿透);保留7天(聚合只取近24小时, + * 多留供回溯排障);source 列标记样本来源(sync/heartbeat/align/page)便于排障。 + * + * @return array ['count'=>int 当前在线(陈旧记0口径), 'recorded'=>bool 本次是否落样本, 'stale'=>bool 心跳是否陈旧] + */ +function onlineCurveEnsureSnapshot($force = false, $source = 'page') { + $now = time(); + $res = ['count' => 0, 'recorded' => false, 'stale' => true]; + try { + $db = getDB(); + $db->exec('PRAGMA busy_timeout=5000'); + $db->exec("CREATE TABLE IF NOT EXISTS online_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_time INTEGER NOT NULL, + online_count INTEGER NOT NULL DEFAULT 0, + source TEXT DEFAULT '' + )"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_snap_time ON online_snapshots(snapshot_time)"); + // 老表无 source 列 → 幂等迁移补列 + $hasSource = false; + $cols = @$db->query("PRAGMA table_info(online_snapshots)"); + while ($cols && ($c = $cols->fetchArray(SQLITE3_ASSOC))) { + if (($c['name'] ?? '') === 'source') { $hasSource = true; break; } + } + if (!$hasSource) @$db->exec("ALTER TABLE online_snapshots ADD COLUMN source TEXT DEFAULT ''"); + + // 心跳判据:<=120秒新鲜 → Java 活着(与 admin 后台归零口径一致) + $stale = true; + try { + @$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)"); + $hb = $db->query("SELECT last_seen FROM online_player_hb WHERE id = 1"); + $hbRow = $hb ? $hb->fetchArray(SQLITE3_ASSOC) : null; + if ($hbRow && (int)$hbRow['last_seen'] > 0 && ($now - (int)$hbRow['last_seen']) <= 120) { + $stale = false; + } + } catch (\Throwable $eHeartbeat) { + // 心跳表异常按陈旧处理(记0),不影响采集函数本身 + } + $count = 0; + if (!$stale) { + $r = $db->query("SELECT COUNT(*) AS cnt FROM online_players"); + $row = $r ? $r->fetchArray(SQLITE3_ASSOC) : null; + $count = (int)($row['cnt'] ?? 0); + } + $res = ['count' => $count, 'recorded' => false, 'stale' => $stale]; + + // 60秒节流:全站唯一写者,防止多路触发在同分钟内重复落样本 + if (!$force) { + $mx = $db->query("SELECT MAX(snapshot_time) AS t FROM online_snapshots"); + $mxRow = $mx ? $mx->fetchArray(SQLITE3_ASSOC) : null; + $last = (int)($mxRow['t'] ?? 0); + if ($last > 0 && ($now - $last) < 60) return $res; + } + + $stmt = $db->prepare("INSERT INTO online_snapshots (snapshot_time, online_count, source) VALUES (:t, :c, :s)"); + $stmt->bindValue(':t', $now, SQLITE3_INTEGER); + $stmt->bindValue(':c', $count, SQLITE3_INTEGER); + $stmt->bindValue(':s', (string)$source, SQLITE3_TEXT); + $stmt->execute(); + $res['recorded'] = true; + + // 保留7天(聚合取近24小时,多留供回溯排障) + $db->exec("DELETE FROM online_snapshots WHERE snapshot_time < " . ($now - 604800)); + $GLOBALS['ONLINE_CURVE_SOURCE'] = $source; + } catch (\Throwable $e) { + debugLog('[onlineCurve] 采集异常(' . $source . '): ' . $e->getMessage()); + } + return $res; +} + function success($data = null, $message = 'ok') { jsonResponse(['success' => true, 'message' => $message, 'data' => $data]); } diff --git a/online_curve.php b/online_curve.php index 3ca871c..7b80000 100644 --- a/online_curve.php +++ b/online_curve.php @@ -5,59 +5,25 @@ if (session_status() === PHP_SESSION_NONE) session_start(); $action = getParam('action', 'page'); -// ===== 初始化快照表 ===== +// ===== 快照采集已上收 core.php(3.11内测版)===== +// 全站唯一采集函数 onlineCurveEnsureSnapshot() 由 core.php 提供: +// sync.php 三个入口(sync_online_players / online_heartbeat / 对账兜底) + 本页面 +// 多路触发统一写入,60秒节流,心跳判据(>120秒记0防停服假曲线),保留7天。 +// 本地三个函数改薄壳保持旧调用点兼容。 function ensureSnapshotTable() { - try { - $db = getDB(); - $db->exec("CREATE TABLE IF NOT EXISTS online_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snapshot_time INTEGER NOT NULL, - online_count INTEGER NOT NULL DEFAULT 0 - )"); - $db->exec("CREATE INDEX IF NOT EXISTS idx_snap_time ON online_snapshots(snapshot_time)"); - // 清理24小时前的旧快照(保留24小时数据) - $db->exec("DELETE FROM online_snapshots WHERE snapshot_time < " . (time() - 86400)); - } catch (\Throwable $e) { - debugLog('[online_curve] 建表异常: ' . $e->getMessage()); - } + onlineCurveEnsureSnapshot(false, 'page'); } -// ===== 记录快照 ===== +// ===== 记录快照(薄壳:是否真正落样本由 core 60秒节流裁决) ===== function recordSnapshot() { - ensureSnapshotTable(); - $count = 0; - try { - $db = getDB(); - $res = $db->query("SELECT COUNT(*) AS cnt FROM online_players"); - $row = $res->fetchArray(SQLITE3_ASSOC); - $count = (int)($row['cnt'] ?? 0); - $now = time(); - $stmt = $db->prepare("INSERT INTO online_snapshots (snapshot_time, online_count) VALUES (:t, :c)"); - $stmt->bindValue(':t', $now, SQLITE3_INTEGER); - $stmt->bindValue(':c', $count, SQLITE3_INTEGER); - $stmt->execute(); - } catch (\Throwable $e) { - debugLog('[online_curve] 记录快照异常: ' . $e->getMessage()); - } - return $count; + $r = onlineCurveEnsureSnapshot(false, 'page'); + return $r['count']; } -// ===== 确保有最新快照(如果上次快照超过60秒则自动记录) ===== +// ===== 确保有最新快照(薄壳:节流由 core 统一处理) ===== function ensureRecentSnapshot() { - try { - $db = getDB(); - $res = $db->query("SELECT MAX(snapshot_time) AS last_time FROM online_snapshots"); - $row = $res->fetchArray(SQLITE3_ASSOC); - $lastTime = (int)($row['last_time'] ?? 0); - $now = time(); - // 没有快照,或上次快照超过 55 秒(留 5 秒余量防止频繁写) - if ($lastTime === 0 || ($now - $lastTime) > 55) { - return recordSnapshot(); - } - } catch (\Throwable $e) { - debugLog('[online_curve] ensureRecentSnapshot 异常: ' . $e->getMessage()); - } - return null; + $r = onlineCurveEnsureSnapshot(false, 'page'); + return $r['recorded'] ? $r['count'] : null; } // ===== 获取24小时聚合数据(按小时) ===== @@ -69,7 +35,7 @@ function getHourlyData() { $since = time() - 86400; // 按自然小时聚合:分桶到 h0~h23,取平均值 $buckets = []; - for ($i = 0; $i < 24; $i++) $buckets[$i] = ['hour' => $i, 'count' => 0, 'samples' => 0]; + for ($i = 0; $i < 24; $i++) $buckets[$i] = ['hour' => $i, 'count' => 0, 'samples' => 0, 'max' => 0]; try { $db = getDB(); $stmt = $db->prepare("SELECT snapshot_time, online_count FROM online_snapshots WHERE snapshot_time >= :since ORDER BY snapshot_time ASC"); @@ -81,6 +47,7 @@ function getHourlyData() { $h = (int)date('G', $snapshotTime); $buckets[$h]['count'] += (int)$row['online_count']; $buckets[$h]['samples']++; + if ((int)$row['online_count'] > $buckets[$h]['max']) $buckets[$h]['max'] = (int)$row['online_count']; } } catch (\Throwable $e) { debugLog('[online_curve] 查询异常: ' . $e->getMessage()); @@ -91,7 +58,7 @@ function getHourlyData() { $result[] = [ 'hour' => sprintf('%02d:00', $b['hour']), 'avg' => $avg, - 'max' => $b['samples'] > 0 ? round($b['count'] / max(1,$b['samples']), 1) : 0, // same as avg for now + 'max' => $b['max'], 'samples' => $b['samples'], ]; } @@ -156,7 +123,7 @@ body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui

📈 24小时在线曲线

-
每60秒刷新 · 自动记录快照
+
每60秒刷新 · Java同步自动采集
加载中...
@@ -174,7 +141,7 @@ body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui