2026-08-27 12:07:06 +08:00
// 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' );
2026-08-31 10:28:31 +08:00
const { renderResumeHTML , CSS : ZR_CSS } = require ( './scripts/resume-renderer' );
2026-08-27 12:07:06 +08:00
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
)` );
2026-08-27 16:14:34 +08:00
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'))
)` );
2026-08-27 12:07:06 +08:00
const userCount = () => udb . prepare ( 'SELECT COUNT(*) c FROM users' ). get (). c ;
2026-08-27 16:14:34 +08:00
// 访问/审计日志(仅超管可查)
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 ( _ ) { /* 记日志失败不影响业务 */ }
}
2026-08-27 13:50:18 +08:00
// ---------- 招聘库(读快照,不直读活库) ----------
// 活库是 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 );
2026-08-28 13:45:19 +08:00
try { rdb . close (); } catch ( _ ) {}
rdb = new Database ( SNAP_PATH , { readonly : true });
2026-08-27 13:50:18 +08:00
snapshotAt = new Date (). toISOString ();
2026-08-28 13:45:19 +08:00
console . log ( `[snapshot] ok ${ snapshotAt } (reopened)` );
2026-08-27 13:50:18 +08:00
} 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 ;
}
2026-08-28 13:45:19 +08:00
let rdb = new Database ( SNAP_PATH , { readonly : true });
// 注意: makeSnapshot 用 rename 覆盖快照文件后, 已打开的连接仍指向旧 inode(读到陈旧数据),
// 必须在每次快照后重开连接(rdb 为 let 就是为此)。
2026-08-27 12:07:06 +08:00
2026-08-28 13:59:26 +08:00
// ---------- 会话(持久化到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)` );
2026-08-27 12:07:06 +08:00
function newSession ( uid , role ) {
const sid = crypto . randomBytes ( 24 ). toString ( 'hex' );
2026-08-28 13:59:26 +08:00
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 ( _ ) {}
2026-08-27 12:07:06 +08:00
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 ;
2026-08-28 13:59:26 +08:00
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 ; }
2026-08-27 12:07:06 +08:00
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' );
2026-08-27 16:14:34 +08:00
logAccess ( req , String ( username ). trim (), 'register_super' , '首个账号成为超管' );
2026-08-27 12:07:06 +08:00
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 ());
2026-08-27 16:14:34 +08:00
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 : '账号已停用' }); }
2026-08-27 12:07:06 +08:00
udb . prepare ( "UPDATE users SET last_login_at = datetime('now','localtime') WHERE id = ?" ). run ( u . id );
2026-08-27 16:14:34 +08:00
logAccess ( req , u . username , 'login_ok' );
2026-08-27 12:07:06 +08:00
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 );
2026-08-27 16:14:34 +08:00
const me = authUser ( req );
if ( me ) logAccess ( req , me . username , 'logout' );
2026-08-28 13:59:26 +08:00
if ( sid ) {
sessions . delete ( sid );
try { udb . prepare ( 'DELETE FROM sessions WHERE sid = ?' ). run ( sid ); } catch ( _ ) {}
}
2026-08-27 12:07:06 +08:00
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 ({
2026-08-27 13:50:18 +08:00
snapshotAt ,
2026-08-27 12:07:06 +08:00
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 (),
2026-08-27 14:37:06 +08:00
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,
2026-08-27 12:07:06 +08:00
SUM(CASE WHEN score_level='推荐' THEN 1 ELSE 0 END) rec,
SUM(CASE WHEN greeted=1 THEN 1 ELSE 0 END) greeted
2026-08-27 14:37:06 +08:00
FROM candidates WHERE scraped_at >= date('now','-29 days','start of day')
2026-08-27 12:07:06 +08:00
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 ) => {
2026-08-27 15:45:26 +08:00
const { level , greeted , account , date_from , date_to , q , today } = req . query ;
2026-08-27 12:07:06 +08:00
const where = []; const params = [];
2026-08-27 15:45:26 +08:00
if ( level === '有效' ) { where . push ( "score_level IN ('推荐','待定')" ); }
else if ( level ) { where . push ( 'score_level = ?' ); params . push ( level ); }
2026-08-27 12:07:06 +08:00
if ( greeted === '1' || greeted === '0' ) { where . push ( 'greeted = ?' ); params . push ( greeted ); }
if ( account ) { where . push ( 'account = ?' ); params . push ( account ); }
2026-08-27 15:45:26 +08:00
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 ());
}
2026-08-27 12:07:06 +08:00
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,
2026-08-27 15:45:26 +08:00
score, score_level, role_tag, greeted, account, scraped_at, notes
2026-08-27 12:07:06 +08:00
FROM candidates ${ w } ORDER BY scraped_at DESC, rowid DESC LIMIT ? OFFSET ?` ). all (... params , size , ( page - 1 ) * size );
2026-08-27 15:45:26 +08:00
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 ; }
2026-08-27 12:07:06 +08:00
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 : '不存在' });
2026-08-27 16:04:46 +08:00
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 );
2026-08-27 12:07:06 +08:00
res . json ( row );
});
2026-08-31 10:28:31 +08:00
// 简历智联样式渲染(服务端渲染, 样式随响应内联)
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 ) } ` );
});
2026-08-27 12:07:06 +08:00
app . get ( '/api/export.csv' , requireAuth , ( req , res ) => {
2026-08-27 15:45:26 +08:00
const { level , greeted , date_from , date_to , q , today } = req . query ;
2026-08-27 12:07:06 +08:00
const where = []; const params = [];
2026-08-27 15:45:26 +08:00
if ( level === '有效' ) { where . push ( "score_level IN ('推荐','待定')" ); }
else if ( level ) { where . push ( 'score_level = ?' ); params . push ( level ); }
2026-08-27 12:07:06 +08:00
if ( greeted === '1' || greeted === '0' ) { where . push ( 'greeted = ?' ); params . push ( greeted ); }
2026-08-27 15:45:26 +08:00
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 ());
}
2026-08-27 12:07:06 +08:00
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 {
2026-08-27 16:27:07 +08:00
const info = udb . prepare ( "INSERT INTO users (username, password, role) VALUES (?, ?, 'hr')" )
2026-08-27 12:07:06 +08:00
. run ( String ( username ). trim (), hashPassword ( String ( password )));
2026-08-27 16:14:34 +08:00
logAccess ( req , req . user . username , 'user_create' , `创建 ${ String ( username ). trim () } ` );
2026-08-27 12:07:06 +08:00
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 );
2026-08-27 16:14:34 +08:00
logAccess ( req , req . user . username , 'user_toggle' , ` ${ u . disabled ? '启用' : '停用' } ${ u . username } ` );
2026-08-27 12:07:06 +08:00
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位' });
2026-08-27 16:14:34 +08:00
const u = udb . prepare ( 'SELECT username FROM users WHERE id = ?' ). get ( req . params . id );
2026-08-27 12:07:06 +08:00
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 : '不存在' });
2026-08-27 16:14:34 +08:00
logAccess ( req , req . user . username , 'user_resetpwd' , `重置 ${ u ? u . username : req . params . id } 密码` );
res . json ({ ok : true });
});
2026-08-27 18:17:59 +08:00
// ---------- 筛选任务状态(所有登录用户) ----------
// 状态文件 + 进程存活双重核对: runner被强停时不写状态文件,仅信文件会假"运行中"
const http = require ( 'http' );
function hermesRunnerProcs () {
return new Promise (( resolve ) => {
const req = http . request (
2026-08-27 18:18:53 +08:00
{ socketPath : '/var/run/docker.sock' , path : '/containers/hermes/top?ps_args=-eo%20pid,args' , method : 'GET' , timeout : 3000 },
2026-08-27 18:17:59 +08:00
( 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 ) => {
2026-08-27 17:17:06 +08:00
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' ]);
2026-08-27 18:17:59 +08:00
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 ));
2026-08-27 17:17:06 +08:00
const out = {};
for ( const acct of [ 'hr1' , 'hr2' ]) {
2026-08-27 18:17:59 +08:00
let s = null ;
try { s = JSON . parse ( fs . readFileSync ( path . join ( stateDir , `state- ${ acct } .json` ), 'utf8' )); } catch ( _ ) {}
const isAlive = alive ( acct );
2026-08-31 14:50:09 +08:00
// 等待扫码优先于"进程活着=运行中": 登录过期时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 ;
}
2026-08-27 18:17:59 +08:00
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 };
2026-08-31 14:50:09 +08:00
else out [ acct ] = { state : 'stopped' , text : `已停止( ${ s . status } )` }; // 未知状态如实显示,不再误标"已收工"
2026-08-27 17:17:06 +08:00
}
res . json ( out );
});
2026-08-27 16:40:29 +08:00
// ---------- 筛选规则文档(所有登录用户) ----------
app . get ( '/api/rules' , requireAuth , ( req , res ) => {
res . type ( 'text/markdown; charset=utf-8' ). send ( fs . readFileSync ( path . join ( __dirname , 'public' , 'rules.md' ), 'utf8' ));
});
2026-08-27 16:14:34 +08:00
// ---------- 访问记录(超管) ----------
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' , '清空访问记录' );
2026-08-27 12:07:06 +08:00
res . json ({ ok : true });
});
app . listen ( PORT , '0.0.0.0' , () => console . log ( `[zhaopin-admin] listening on : ${ PORT } , db= ${ RECRUITMENT_DB } , users= ${ userCount () } ` ));