Files
zhaopin-admin/server.js
T

377 lines
21 KiB
JavaScript

// zhaopin-admin — 招聘数据简单后台
// 模型: 首个注册账号=超管(super), 之后注册关闭, 超管创建HR账号; 全部接口需登录
// 数据: 只读挂载生产库(live.db); 自身状态存 /data/admin/(users.db + secret)
'use strict';
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const { renderResumeHTML, CSS: ZR_CSS } = require('./scripts/resume-renderer');
const PORT = process.env.PORT || 8300;
const ADMIN_DIR = process.env.ADMIN_DIR || '/data/admin';
const RECRUITMENT_DB = process.env.RECRUITMENT_DB || '/data/recruitment.db';
fs.mkdirSync(ADMIN_DIR, { recursive: true });
// ---------- 会话密钥(首次启动生成,持久化) ----------
const SECRET_FILE = path.join(ADMIN_DIR, 'secret');
if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32));
const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim();
// ---------- 密码: scrypt ----------
function hashPassword(pwd) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(pwd, salt, 32).toString('hex');
return `${salt}:${hash}`;
}
function verifyPassword(pwd, stored) {
const [salt, hash] = String(stored).split(':');
if (!salt || !hash) return false;
const calc = crypto.scryptSync(pwd, salt, 32).toString('hex');
return crypto.timingSafeEqual(Buffer.from(calc), Buffer.from(hash));
}
// ---------- 用户库 ----------
const udb = new Database(path.join(ADMIN_DIR, 'users.db'));
udb.pragma('journal_mode = WAL');
udb.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'hr',
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
last_login_at TEXT
)`);
udb.exec(`CREATE TABLE IF NOT EXISTS access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)`);
const userCount = () => udb.prepare('SELECT COUNT(*) c FROM users').get().c;
// 访问/审计日志(仅超管可查)
function logAccess(req, username, action, detail = '') {
try {
const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '';
udb.prepare('INSERT INTO access_log (username, action, detail, ip) VALUES (?,?,?,?)').run(username, action, detail, ip);
} catch (_) { /* 记日志失败不影响业务 */ }
}
// ---------- 招聘库(读快照,不直读活库) ----------
// 活库是 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);
try { rdb.close(); } catch (_) {}
rdb = new Database(SNAP_PATH, { readonly: true });
snapshotAt = new Date().toISOString();
console.log(`[snapshot] ok ${snapshotAt} (reopened)`);
} 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;
}
let rdb = new Database(SNAP_PATH, { readonly: true });
// 注意: makeSnapshot 用 rename 覆盖快照文件后, 已打开的连接仍指向旧 inode(读到陈旧数据),
// 必须在每次快照后重开连接(rdb 为 let 就是为此)。
// ---------- 会话(持久化到users.db, 部署重启不再全员登出) ----------
const sessions = new Map(); // sid -> { uid, role, expires } (内存缓存,源在DB)
const SESSION_HOURS = 168; // 7天
udb.exec(`CREATE TABLE IF NOT EXISTS sessions (sid TEXT PRIMARY KEY, uid INTEGER NOT NULL, expires INTEGER NOT NULL)`);
function newSession(uid, role) {
const sid = crypto.randomBytes(24).toString('hex');
const expires = Date.now() + SESSION_HOURS * 3600e3;
sessions.set(sid, { uid, role, expires });
try { udb.prepare('INSERT OR REPLACE INTO sessions (sid, uid, expires) VALUES (?,?,?)').run(sid, uid, expires); } catch (_) {}
return sid;
}
function signSid(sid) { return sid + '.' + crypto.createHmac('sha256', SECRET).update(sid).digest('hex').slice(0, 16); }
function parseSid(cookie) {
const m = /session=([a-f0-9]+\.[a-f0-9]+)/.exec(cookie || '');
if (!m) return null;
const [sid, sig] = m[1].split('.');
if (crypto.createHmac('sha256', SECRET).update(sid).digest('hex').slice(0, 16) !== sig) return null;
return sid;
}
function authUser(req) {
const sid = parseSid(req.headers.cookie);
if (!sid) return null;
let s = sessions.get(sid);
if (!s) { // 容器重启后从DB恢复
const row = udb.prepare('SELECT uid, expires FROM sessions WHERE sid = ?').get(sid);
if (row) {
if (row.expires < Date.now()) { udb.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); return null; }
const u = udb.prepare('SELECT id, role FROM users WHERE id = ? AND disabled = 0').get(row.uid);
if (!u) return null;
s = { uid: u.id, role: u.role, expires: row.expires };
sessions.set(sid, s);
}
}
if (!s || s.expires < Date.now()) { sessions.delete(sid); try { udb.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); } catch (_) {} return null; }
return udb.prepare('SELECT id, username, role FROM users WHERE id = ? AND disabled = 0').get(s.uid) || null;
}
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// ---------- 认证接口 ----------
app.get('/api/bootstrap', (req, res) => res.json({ needsInit: userCount() === 0 }));
app.post('/api/register', (req, res) => { // 仅无任何账号时开放, 首个=超管
if (userCount() > 0) return res.status(403).json({ error: '注册已关闭,请联系管理员创建账号' });
const { username, password } = req.body || {};
if (!username || !password || String(username).length < 2 || String(password).length < 6)
return res.status(400).json({ error: '用户名至少2位,密码至少6位' });
const info = udb.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)')
.run(String(username).trim(), hashPassword(String(password)), 'super');
logAccess(req, String(username).trim(), 'register_super', '首个账号成为超管');
res.json({ ok: true, role: 'super', id: info.lastInsertRowid });
});
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {};
const u = udb.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim());
if (!u || !verifyPassword(String(password || ''), u.password)) {
logAccess(req, String(username || ''), 'login_fail', '用户名或密码错误');
return res.status(401).json({ error: '用户名或密码错误' });
}
if (u.disabled) { logAccess(req, u.username, 'login_fail', '账号已停用'); return res.status(403).json({ error: '账号已停用' }); }
udb.prepare("UPDATE users SET last_login_at = datetime('now','localtime') WHERE id = ?").run(u.id);
logAccess(req, u.username, 'login_ok');
res.setHeader('Set-Cookie', `session=${signSid(newSession(u.id, u.role))}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_HOURS * 3600}`);
res.json({ ok: true, username: u.username, role: u.role });
});
app.post('/api/logout', (req, res) => {
const sid = parseSid(req.headers.cookie);
const me = authUser(req);
if (me) logAccess(req, me.username, 'logout');
if (sid) {
sessions.delete(sid);
try { udb.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); } catch (_) {}
}
res.setHeader('Set-Cookie', 'session=; HttpOnly; Path=/; Max-Age=0');
res.json({ ok: true });
});
app.get('/api/me', (req, res) => {
const u = authUser(req);
if (!u) return res.status(401).json({ error: '未登录' });
res.json({ username: u.username, role: u.role });
});
function requireAuth(req, res, next) {
const u = authUser(req);
if (!u) return res.status(401).json({ error: '未登录' });
req.user = u; next();
}
function requireSuper(req, res, next) {
if (req.user.role !== 'super') return res.status(403).json({ error: '需要超管权限' });
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,
byLevel: rdb.prepare("SELECT COALESCE(score_level,'?') k, COUNT(*) c FROM candidates GROUP BY score_level").all(),
byEducation: rdb.prepare("SELECT COALESCE(NULLIF(education,''),'未知') k, COUNT(*) c FROM candidates GROUP BY education ORDER BY c DESC LIMIT 8").all(),
byAccount: rdb.prepare("SELECT COALESCE(NULLIF(account,''),'未知') k, COUNT(*) c, SUM(greeted) g FROM candidates GROUP BY account ORDER BY c DESC").all(),
last30: rdb.prepare(`SELECT substr(scraped_at,1,10) d, COUNT(*) c,
SUM(CASE WHEN score_level='推荐' THEN 1 ELSE 0 END) rec,
SUM(CASE WHEN greeted=1 THEN 1 ELSE 0 END) greeted
FROM candidates WHERE scraped_at >= date('now','-29 days','start of day')
GROUP BY substr(scraped_at,1,10) ORDER BY d`).all(),
lastBatch: rdb.prepare('SELECT created_at, mode, new_count, greet_count FROM scrape_log ORDER BY id DESC LIMIT 1').get() || null,
});
});
app.get('/api/candidates', requireAuth, (req, res) => {
const { level, greeted, account, date_from, date_to, q, today } = req.query;
const where = []; const params = [];
if (level === '有效') { where.push("score_level IN ('推荐','待定')"); }
else if (level) { where.push('score_level = ?'); params.push(level); }
if (greeted === '1' || greeted === '0') { where.push('greeted = ?'); params.push(greeted); }
if (account) { where.push('account = ?'); params.push(account); }
if (today === '1') { // 北京时间的"今天"(库里是UTC时间戳)
const bjToday = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
where.push('scraped_at >= ?'); params.push(new Date(bjToday + 'T00:00:00+08:00').toISOString());
}
if (date_from) { where.push('scraped_at >= ?'); params.push(date_from + 'T00:00'); }
if (date_to) { where.push('scraped_at <= ?'); params.push(date_to + 'T23:59'); }
if (q) { where.push('(name LIKE ? OR school LIKE ? OR notes LIKE ?)'); const like = `%${q}%`; params.push(like, like, like); }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const page = Math.max(1, parseInt(req.query.page) || 1);
const size = Math.min(100, Math.max(10, parseInt(req.query.size) || 20));
const total = rdb.prepare(`SELECT COUNT(*) c FROM candidates ${w}`).get(...params).c;
const rows = rdb.prepare(`SELECT id, name, age, education, experience, school, expected_city, expected_salary,
score, score_level, role_tag, greeted, account, scraped_at, notes
FROM candidates ${w} ORDER BY scraped_at DESC, rowid DESC LIMIT ? OFFSET ?`).all(...params, size, (page - 1) * size);
const bjToday = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
const toBjDay = (s) => s ? new Date(new Date(s).getTime() + 8 * 3600e3).toISOString().slice(0, 10) : '';
for (const r of rows) { r.scraped_at = toBjDay(r.scraped_at); r.is_today = r.scraped_at === bjToday ? 1 : 0; }
res.json({ total, page, size, rows });
});
app.get('/api/candidates/:id', requireAuth, (req, res) => {
const row = rdb.prepare(`SELECT id, name, age, education, experience, exp_years, school, expected_city,
expected_salary, score, score_level, role_tag, greeted, greeted_at, account, activity,
raw_text, education_detail, work_detail, cert_detail, score_details, notes, scraped_at, updated_at
FROM candidates WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: '不存在' });
const bj = (s) => s ? new Date(new Date(s).getTime() + 8 * 3600e3).toISOString().slice(0, 19).replace('T', ' ') : s;
row.scraped_at = bj(row.scraped_at);
row.updated_at = bj(row.updated_at);
if (row.greeted_at) row.greeted_at = bj(row.greeted_at);
res.json(row);
});
// 简历智联样式渲染(服务端渲染, 样式随响应内联)
app.get('/api/candidates/:id/resume-html', requireAuth, (req, res) => {
const row = rdb.prepare('SELECT * FROM candidates WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: '不存在' });
res.type('text/html; charset=utf-8').send(`<style>${ZR_CSS}</style>${renderResumeHTML(row)}`);
});
app.get('/api/export.csv', requireAuth, (req, res) => {
const { level, greeted, date_from, date_to, q, today } = req.query;
const where = []; const params = [];
if (level === '有效') { where.push("score_level IN ('推荐','待定')"); }
else if (level) { where.push('score_level = ?'); params.push(level); }
if (greeted === '1' || greeted === '0') { where.push('greeted = ?'); params.push(greeted); }
if (today === '1') {
const bjToday = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
where.push('scraped_at >= ?'); params.push(new Date(bjToday + 'T00:00:00+08:00').toISOString());
}
if (date_from) { where.push('scraped_at >= ?'); params.push(date_from + 'T00:00'); }
if (date_to) { where.push('scraped_at <= ?'); params.push(date_to + 'T23:59'); }
if (q) { where.push('(name LIKE ? OR school LIKE ? OR notes LIKE ?)'); const like = `%${q}%`; params.push(like, like, like); }
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
const rows = rdb.prepare(`SELECT name, age, education, experience, school, expected_city, expected_salary,
score, score_level, role_tag, greeted, account, substr(scraped_at,1,10) scraped_at, substr(notes,1,80) notes
FROM candidates ${w} ORDER BY scraped_at DESC LIMIT 10000`).all(...params);
const esc = (v) => '"' + String(v ?? '').replace(/"/g, '""') + '"';
const header = '姓名,年龄,学历,经验,学校,期望城市,期望薪资,评分,级别,岗位标签,已打招呼,账号,抓取日期,门槛备注';
const csv = '\ufeff' + [header, ...rows.map(r2 => [r2.name, r2.age, r2.education, r2.experience, r2.school, r2.expected_city, r2.expected_salary, r2.score, r2.score_level, r2.role_tag, r2.greeted ? '是' : '否', r2.account, r2.scraped_at, r2.notes].map(esc).join(','))].join('\r\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="candidates-${Date.now()}.csv"`);
res.send(csv);
});
// ---------- 账号管理(超管) ----------
app.get('/api/users', requireAuth, requireSuper, (req, res) => {
res.json(udb.prepare('SELECT id, username, role, disabled, created_at, last_login_at FROM users ORDER BY id').all());
});
app.post('/api/users', requireAuth, requireSuper, (req, res) => {
const { username, password } = req.body || {};
if (!username || !password || String(password).length < 6) return res.status(400).json({ error: '用户名必填,密码至少6位' });
try {
const info = udb.prepare("INSERT INTO users (username, password, role) VALUES (?, ?, 'hr')")
.run(String(username).trim(), hashPassword(String(password)));
logAccess(req, req.user.username, 'user_create', `创建 ${String(username).trim()}`);
res.json({ ok: true, id: info.lastInsertRowid });
} catch (e) {
res.status(400).json({ error: '用户名已存在' });
}
});
app.post('/api/users/:id/toggle', requireAuth, requireSuper, (req, res) => {
const u = udb.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!u) return res.status(404).json({ error: '不存在' });
if (u.role === 'super' && !u.disabled) return res.status(400).json({ error: '不能停用自己的超管账号' });
udb.prepare('UPDATE users SET disabled = ? WHERE id = ?').run(u.disabled ? 0 : 1, u.id);
logAccess(req, req.user.username, 'user_toggle', `${u.disabled ? '启用' : '停用'} ${u.username}`);
res.json({ ok: true, disabled: u.disabled ? 0 : 1 });
});
app.post('/api/users/:id/resetpwd', requireAuth, requireSuper, (req, res) => {
const { password } = req.body || {};
if (!password || String(password).length < 6) return res.status(400).json({ error: '密码至少6位' });
const u = udb.prepare('SELECT username FROM users WHERE id = ?').get(req.params.id);
const info = udb.prepare('UPDATE users SET password = ? WHERE id = ?').run(hashPassword(String(password)), req.params.id);
if (!info.changes) return res.status(404).json({ error: '不存在' });
logAccess(req, req.user.username, 'user_resetpwd', `重置 ${u ? u.username : req.params.id} 密码`);
res.json({ ok: true });
});
// ---------- 筛选任务状态(所有登录用户) ----------
// 状态文件 + 进程存活双重核对: runner被强停时不写状态文件,仅信文件会假"运行中"
const http = require('http');
function hermesRunnerProcs() {
return new Promise((resolve) => {
const req = http.request(
{ socketPath: '/var/run/docker.sock', path: '/containers/hermes/top?ps_args=-eo%20pid,args', method: 'GET', timeout: 3000 },
(r) => { let d = ''; r.on('data', (c) => d += c); r.on('end', () => { try { resolve(JSON.parse(d).Processes || []); } catch (_) { resolve([]); } }); }
);
req.on('error', () => resolve([]));
req.end();
});
}
app.get('/api/task-status', requireAuth, async (req, res) => {
const stateDir = process.env.STATE_DIR || '/state';
const bjToday = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
const doneSet = new Set(['time_cutoff', 'chat_exhausted', 'done']);
const errSet = new Set(['login_expired', 'alert', 'tunnel_failed', 'platform_blocked', 'qr_expired', 'error']);
const procs = await hermesRunnerProcs();
const alive = (acct) => procs.some((p) => /daily-runner(\.js)?\s/.test(String(p)) && String(p).includes(acct));
const out = {};
for (const acct of ['hr1', 'hr2']) {
let s = null;
try { s = JSON.parse(fs.readFileSync(path.join(stateDir, `state-${acct}.json`), 'utf8')); } catch (_) {}
const isAlive = alive(acct);
// 等待扫码优先于"进程活着=运行中": 登录过期时runner进程活着但只是在等扫码
if (s && s.date === bjToday && (s.status === 'waiting_relogin' || s.status === 'qr_expired')) {
out[acct] = {
state: 'waiting', text: '等待扫码',
detail: s.status === 'qr_expired' ? '二维码已过期,需重启任务' : (isAlive ? '登录过期,群里扫码后自动复工' : '扫码监听进程已退出,需人工处理'),
};
continue;
}
if (isAlive) { out[acct] = { state: 'running', text: '运行中', chatLeft: s && s.chatLeft }; continue; }
if (!s || s.date !== bjToday) { out[acct] = { state: 'idle', text: '今日未运行' }; continue; }
if (s.status === 'running') out[acct] = { state: 'stopped', text: '已停止', detail: '状态未更新(进程已退出)' };
else if (s.status === 'stopped_by_user') out[acct] = { state: 'stopped', text: '手动停止' };
else if (doneSet.has(s.status)) out[acct] = { state: 'done', text: '已收工', chatLeft: s.chatLeft };
else if (errSet.has(s.status)) out[acct] = { state: 'error', text: '异常停止', detail: s.status };
else out[acct] = { state: 'stopped', text: `已停止(${s.status})` }; // 未知状态如实显示,不再误标"已收工"
}
res.json(out);
});
// ---------- 筛选规则文档(所有登录用户) ----------
app.get('/api/rules', requireAuth, (req, res) => {
res.type('text/markdown; charset=utf-8').send(fs.readFileSync(path.join(__dirname, 'public', 'rules.md'), 'utf8'));
});
// ---------- 访问记录(超管) ----------
app.get('/api/access-log', requireAuth, requireSuper, (req, res) => {
res.json(udb.prepare('SELECT id, username, action, detail, ip, created_at FROM access_log ORDER BY id DESC LIMIT 500').all());
});
app.post('/api/access-log/clear', requireAuth, requireSuper, (req, res) => {
udb.prepare('DELETE FROM access_log').run();
logAccess(req, req.user.username, 'log_clear', '清空访问记录');
res.json({ ok: true });
});
app.listen(PORT, '0.0.0.0', () => console.log(`[zhaopin-admin] listening on :${PORT}, db=${RECRUITMENT_DB}, users=${userCount()}`));