// 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 )`); const userCount = () => udb.prepare('SELECT COUNT(*) c FROM users').get().c; // ---------- 招聘库(读快照,不直读活库) ---------- // 活库是 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'); 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)) return res.status(401).json({ error: '用户名或密码错误' }); if (u.disabled) return res.status(403).json({ error: '账号已停用' }); udb.prepare("UPDATE users SET last_login_at = datetime('now','localtime') WHERE id = ?").run(u.id); 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); 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(), byCity: rdb.prepare("SELECT expected_city k, COUNT(*) c FROM candidates WHERE expected_city != '' GROUP BY expected_city 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 } = req.query; const where = []; const params = []; 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 (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, substr(scraped_at,1,10) scraped_at, notes FROM candidates ${w} ORDER BY scraped_at DESC, rowid DESC LIMIT ? OFFSET ?`).all(...params, size, (page - 1) * size); 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: '不存在' }); res.json(row); }); app.get('/api/export.csv', requireAuth, (req, res) => { const { level, greeted, date_from, date_to, q } = req.query; const where = []; const params = []; if (level) { where.push('score_level = ?'); params.push(level); } if (greeted === '1' || greeted === '0') { where.push('greeted = ?'); params.push(greeted); } 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))); 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); 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 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: '不存在' }); res.json({ ok: true }); }); app.listen(PORT, '0.0.0.0', () => console.log(`[zhaopin-admin] listening on :${PORT}, db=${RECRUITMENT_DB}, users=${userCount()}`));