341 lines
18 KiB
JavaScript
341 lines
18 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 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);
|
|
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 SESSION_HOURS = 12;
|
|
function newSession(uid, role) {
|
|
const sid = crypto.randomBytes(24).toString('hex');
|
|
sessions.set(sid, { uid, role, expires: Date.now() + SESSION_HOURS * 3600e3 });
|
|
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;
|
|
const s = sessions.get(sid);
|
|
if (!s || s.expires < Date.now()) { sessions.delete(sid); 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);
|
|
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/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);
|
|
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: 'done', text: '已收工', chatLeft: s.chatLeft };
|
|
}
|
|
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()}`));
|