v1.0 招聘数据后台: 首账号超管+HR账号+登录, 看板/候选人筛选/详情/CSV导出/账号管理, 只读live库, Docker部署
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// 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;
|
||||
|
||||
// ---------- 招聘库(只读) ----------
|
||||
const rdb = new Database(RECRUITMENT_DB, { 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({
|
||||
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(),
|
||||
last7: 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','localtime','-6 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()}`));
|
||||
Reference in New Issue
Block a user