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=`
${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,