关键修复: 不再直读WAL活库(会撞checkpoint报malformed), 改VACUUM INTO每5分钟一致性快照; 前端数据加载失败显式提示+重试; 看板显示快照时间
This commit is contained in:
+5
-4
@@ -6,10 +6,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8300:8300" # 仅内网访问; 公网需经OpenResty反代,勿直接转发
|
- "8300:8300" # 仅内网访问; 公网需经OpenResty反代,勿直接转发
|
||||||
volumes:
|
volumes:
|
||||||
# 生产库只读挂载(宿主机路径)
|
# 活库所在目录(供VACUUM INTO生成一致性快照, 不直接读)
|
||||||
- /opt/hermes/data/recruitment-data/recruitment-live.db:/data/recruitment.db:ro
|
- /opt/hermes/data/recruitment-data:/src
|
||||||
# 自身状态(users.db/secret)持久化
|
# 自身状态(users.db/secret/快照)持久化
|
||||||
- ./data:/data/admin
|
- ./data:/data/admin
|
||||||
environment:
|
environment:
|
||||||
- RECRUITMENT_DB=/data/recruitment.db
|
- RECRUITMENT_SRC=/src/recruitment-live.db
|
||||||
|
- RECRUITMENT_DB=/data/admin/snapshot.db
|
||||||
- ADMIN_DIR=/data/admin
|
- ADMIN_DIR=/data/admin
|
||||||
|
|||||||
+7
-2
@@ -144,7 +144,11 @@ async function enter(m){
|
|||||||
me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden');
|
me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden');
|
||||||
$('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`;
|
$('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`;
|
||||||
if(m.role==='super'){$('userPanel').classList.remove('hidden');loadUsers()}
|
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=`<div class="card" style="grid-column:1/-1"><div class="k">⚠ 数据加载失败</div><div style="font-size:14px">${esc(e.message)} <button class="btn sub small" onclick="loadStats().then(()=>loadList(1)).catch(e=>dataErr(e))">重试</button></div></div>`;
|
||||||
|
toast('数据加载失败: '+e.message);
|
||||||
}
|
}
|
||||||
async function loadStats(){
|
async function loadStats(){
|
||||||
const s=await api('/api/stats');
|
const s=await api('/api/stats');
|
||||||
@@ -152,7 +156,8 @@ async function loadStats(){
|
|||||||
<div class="card"><div class="k">累计候选人</div><div class="v">${s.total}</div></div>
|
<div class="card"><div class="k">累计候选人</div><div class="v">${s.total}</div></div>
|
||||||
<div class="card"><div class="k">今日新增</div><div class="v">${s.today}</div></div>
|
<div class="card"><div class="k">今日新增</div><div class="v">${s.today}</div></div>
|
||||||
<div class="card"><div class="k">累计打招呼</div><div class="v">${s.greetedTotal}</div></div>
|
<div class="card"><div class="k">累计打招呼</div><div class="v">${s.greetedTotal}</div></div>
|
||||||
${s.byLevel.map(x=>`<div class="card"><div class="k">${esc(x.k)}</div><div class="v">${x.c}</div></div>`).join('')}`;
|
${s.byLevel.map(x=>`<div class="card"><div class="k">${esc(x.k)}</div><div class="v">${x.c}</div></div>`).join('')}
|
||||||
|
<div class="card"><div class="k">数据快照</div><div class="v" style="font-size:16px">${s.snapshotAt?new Date(new Date(s.snapshotAt).getTime()+8*3600e3).toISOString().slice(11,19):'-'}<span style="font-size:11px;color:var(--sub)"> +8 每5分钟</span></div></div>`;
|
||||||
const mx=Math.max(1,...s.last7.map(d=>d.c));
|
const mx=Math.max(1,...s.last7.map(d=>d.c));
|
||||||
$('trend').innerHTML=s.last7.map(d=>`<div class="b">
|
$('trend').innerHTML=s.last7.map(d=>`<div class="b">
|
||||||
<div class="lbl">${d.c}人/${d.greeted}招呼</div>
|
<div class="lbl">${d.c}人/${d.greeted}招呼</div>
|
||||||
|
|||||||
@@ -45,8 +45,34 @@ udb.exec(`CREATE TABLE IF NOT EXISTS users (
|
|||||||
)`);
|
)`);
|
||||||
const userCount = () => udb.prepare('SELECT COUNT(*) c FROM users').get().c;
|
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 }
|
const sessions = new Map(); // sid -> { uid, role, expires }
|
||||||
@@ -122,6 +148,7 @@ function requireSuper(req, res, next) {
|
|||||||
app.get('/api/stats', requireAuth, (req, res) => {
|
app.get('/api/stats', requireAuth, (req, res) => {
|
||||||
const today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
|
const today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
|
||||||
res.json({
|
res.json({
|
||||||
|
snapshotAt,
|
||||||
total: rdb.prepare('SELECT COUNT(*) c FROM candidates').get().c,
|
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,
|
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,
|
greetedTotal: rdb.prepare('SELECT COUNT(*) c FROM candidates WHERE greeted = 1').get().c,
|
||||||
|
|||||||
Reference in New Issue
Block a user