From 520ffb78a1f78ef524fd56d6725e7e3f19860ec4 Mon Sep 17 00:00:00 2001 From: mzl92 Date: Thu, 27 Aug 2026 13:50:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B3=E9=94=AE=E4=BF=AE=E5=A4=8D:=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=9B=B4=E8=AF=BBWAL=E6=B4=BB=E5=BA=93(?= =?UTF-8?q?=E4=BC=9A=E6=92=9Echeckpoint=E6=8A=A5malformed),=20=E6=94=B9VAC?= =?UTF-8?q?UUM=20INTO=E6=AF=8F5=E5=88=86=E9=92=9F=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7=E5=BF=AB=E7=85=A7;=20=E5=89=8D=E7=AB=AF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=8F=90=E7=A4=BA+=E9=87=8D=E8=AF=95;=20=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=BF=AB=E7=85=A7=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 9 +++++---- public/index.html | 9 +++++++-- server.js | 31 +++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 699d188..e4f15cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,11 @@ services: ports: - "8300:8300" # 仅内网访问; 公网需经OpenResty反代,勿直接转发 volumes: - # 生产库只读挂载(宿主机路径) - - /opt/hermes/data/recruitment-data/recruitment-live.db:/data/recruitment.db:ro - # 自身状态(users.db/secret)持久化 + # 活库所在目录(供VACUUM INTO生成一致性快照, 不直接读) + - /opt/hermes/data/recruitment-data:/src + # 自身状态(users.db/secret/快照)持久化 - ./data:/data/admin environment: - - RECRUITMENT_DB=/data/recruitment.db + - RECRUITMENT_SRC=/src/recruitment-live.db + - RECRUITMENT_DB=/data/admin/snapshot.db - ADMIN_DIR=/data/admin diff --git a/public/index.html b/public/index.html index 8e46515..5625e1d 100644 --- a/public/index.html +++ b/public/index.html @@ -144,7 +144,11 @@ async function enter(m){ me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden'); $('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`; if(m.role==='super'){$('userPanel').classList.remove('hidden');loadUsers()} - loadStats();loadList(1); + loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e)); +} +function dataErr(e){ + $('statCards').innerHTML=`
⚠ 数据加载失败
${esc(e.message)}
`; + toast('数据加载失败: '+e.message); } async function loadStats(){ const s=await api('/api/stats'); @@ -152,7 +156,8 @@ async function loadStats(){
累计候选人
${s.total}
今日新增
${s.today}
累计打招呼
${s.greetedTotal}
- ${s.byLevel.map(x=>`
${esc(x.k)}
${x.c}
`).join('')}`; + ${s.byLevel.map(x=>`
${esc(x.k)}
${x.c}
`).join('')} +
数据快照
${s.snapshotAt?new Date(new Date(s.snapshotAt).getTime()+8*3600e3).toISOString().slice(11,19):'-'} +8 每5分钟
`; const mx=Math.max(1,...s.last7.map(d=>d.c)); $('trend').innerHTML=s.last7.map(d=>`
${d.c}人/${d.greeted}招呼
diff --git a/server.js b/server.js index 01afaae..b3b6ddf 100644 --- a/server.js +++ b/server.js @@ -45,8 +45,34 @@ udb.exec(`CREATE TABLE IF NOT EXISTS users ( )`); const userCount = () => udb.prepare('SELECT COUNT(*) c FROM users').get().c; -// ---------- 招聘库(只读) ---------- -const rdb = new Database(RECRUITMENT_DB, { readonly: true }); +// ---------- 招聘库(读快照,不直读活库) ---------- +// 活库是 WAL 模式且被 Hermes 持续写入,直接单文件挂载读会在 checkpoint 期间报 +// "database disk image is malformed"。改为定期 VACUUM INTO 生成一致性快照再读。 +const RECRUITMENT_SRC = process.env.RECRUITMENT_SRC || ''; +const SNAP_PATH = RECRUITMENT_DB; // 读取目标=快照文件 +let snapshotAt = null; +function makeSnapshot() { + if (!RECRUITMENT_SRC) return; // 未配置源=直接读单文件模式(仅测试) + const tmp = SNAP_PATH + '.tmp'; + try { + const src = new Database(RECRUITMENT_SRC, { readonly: true }); + fs.rmSync(tmp, { force: true }); + src.exec(`VACUUM INTO '${tmp}'`); + src.close(); + fs.renameSync(tmp, SNAP_PATH); + snapshotAt = new Date().toISOString(); + console.log(`[snapshot] ok ${snapshotAt}`); + } catch (e) { + console.log(`[snapshot] failed(继续用旧快照): ${String(e.message).slice(0, 120)}`); + } +} +if (RECRUITMENT_SRC) { + makeSnapshot(); // 启动先做一次 + setInterval(makeSnapshot, 5 * 60 * 1000).unref(); // 每5分钟刷新 +} else { + snapshotAt = fs.existsSync(SNAP_PATH) ? fs.statSync(SNAP_PATH).mtime.toISOString() : null; +} +const rdb = new Database(SNAP_PATH, { readonly: true }); // ---------- 会话(内存态,重启需重登) ---------- const sessions = new Map(); // sid -> { uid, role, expires } @@ -122,6 +148,7 @@ function requireSuper(req, res, next) { app.get('/api/stats', requireAuth, (req, res) => { const today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10); res.json({ + snapshotAt, total: rdb.prepare('SELECT COUNT(*) c FROM candidates').get().c, today: rdb.prepare("SELECT COUNT(*) c FROM candidates WHERE scraped_at >= ?").get(today + 'T00:00').c, greetedTotal: rdb.prepare('SELECT COUNT(*) c FROM candidates WHERE greeted = 1').get().c,