diff --git a/public/index.html b/public/index.html
index 88f381e..29eb49b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -86,6 +86,8 @@ tr.today:hover td{background:#fde68a!important}
#detailModal.hidden{display:none}
#userModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
#userModal.hidden{display:none}
+#accessModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
+#accessModal.hidden{display:none}
@@ -93,6 +95,7 @@ tr.today:hover td{background:#fde68a!important}
📋 招聘数据后台
+
@@ -178,8 +181,20 @@ tr.today:hover td{background:#fde68a!important}
提示:停用后该账号无法登录,已登录的会话在下次请求时失效;启用后恢复。
+
+
+
+
访问记录(最近500条)
+
+
+
+
+
+
+
@@ -231,7 +246,7 @@ async function logout(){await api('/api/logout',{method:'POST'});location.reload
async function enter(m){
me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden');
$('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`;
- if(m.role==='super'){$('userMgrBtn').classList.remove('hidden')}
+ if(m.role==='super'){$('userMgrBtn').classList.remove('hidden');$('accessLogBtn').classList.remove('hidden')}
loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e));
}
function dataErr(e){
@@ -366,6 +381,23 @@ async function addUser(){
try{await api('/api/users',{method:'POST',body:JSON.stringify({username:$('nu_user').value,password:$('nu_pass').value})});toast('已创建');$('nu_user').value=$('nu_pass').value='';loadUsers()}
catch(e){toast(e.message)}
}
+async function openAccessModal(){
+ $('accessModal').classList.remove('hidden');
+ loadAccessLog().catch(()=>toast('访问记录加载失败'));
+}
+async function loadAccessLog(){
+ const list=await api('/api/access-log');
+ const actNames={login_ok:['登录成功','rec'],login_fail:['登录失败','irr'],logout:['退出','no'],register_super:['初始化超管','yes'],user_create:['创建账号','yes'],user_toggle:['停用/启用','maybe'],user_resetpwd:['重置密码','maybe'],log_clear:['清空记录','maybe']};
+ $('alogBody').innerHTML=list.length?list.map(l=>`
+ | ${esc(l.created_at)} | ${esc(l.username)||'-'} |
+ ${(actNames[l.action]||[l.action,'no'])[0]} |
+ ${esc(l.detail)||'-'} | ${esc(l.ip)||'-'} |
`).join('')
+ :'| 暂无记录 |
';
+}
+async function clearAccessLog(){
+ if(!confirm('确定清空全部访问记录吗?此操作不可恢复'))return;
+ try{await api('/api/access-log/clear',{method:'POST'});toast('已清空');loadAccessLog()}catch(e){toast(e.message)}
+}
async function toggleUser(id,name,disabled){
const verb=disabled?'启用':'停用';
if(!confirm(`确定${verb}账号「${name}」吗?`))return;
diff --git a/scripts/smoke.js b/scripts/smoke.js
index 8fb3f54..bddf26f 100644
--- a/scripts/smoke.js
+++ b/scripts/smoke.js
@@ -106,6 +106,13 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await p.screenshot({ path: '/tmp/smoke-shots/04-users.png' });
await p.evaluate(() => document.getElementById('userModal').classList.add('hidden'));
+ console.log('== 7b. 访问记录(超管) ==');
+ ok('顶栏访问记录入口可见', await $('#accessLogBtn').isVisible());
+ await p.evaluate(() => openAccessModal()); await sleep(600);
+ const alogText = await p.evaluate(() => document.getElementById('alogBody').innerText);
+ ok('访问记录含本次登录', alogText.includes('登录成功') && alogText.includes(SU));
+ await p.evaluate(() => document.getElementById('accessModal').classList.add('hidden'));
+
console.log('== 8. 退出 → HR登录 ==');
await p.evaluate(() => logout()); await sleep(800);
await p.goto(BASE + '/', { waitUntil: 'networkidle' });
@@ -113,6 +120,9 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await $('#loginBtn').click(); await sleep(900);
ok('HR进入主界面', await $('#main').isVisible());
ok('HR看不到账号管理入口', await p.evaluate(() => getComputedStyle(document.getElementById('userMgrBtn')).display === 'none'));
+ ok('HR看不到访问记录入口', await p.evaluate(() => getComputedStyle(document.getElementById('accessLogBtn')).display === 'none'));
+ const alogForbidden = await p.evaluate(async () => (await fetch('/api/access-log')).status);
+ ok('HR访问记录API被403拦截', alogForbidden === 403);
const forbidden = await p.evaluate(async () => {
const r = await fetch('/api/users'); return r.status;
});
diff --git a/server.js b/server.js
index 7a19e60..b769eb8 100644
--- a/server.js
+++ b/server.js
@@ -43,8 +43,24 @@ udb.exec(`CREATE TABLE IF NOT EXISTS users (
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 生成一致性快照再读。
@@ -111,19 +127,26 @@ app.post('/api/register', (req, res) => { // 仅无任何账号时开
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)) return res.status(401).json({ error: '用户名或密码错误' });
- if (u.disabled) return res.status(403).json({ error: '账号已停用' });
+ 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 });
@@ -237,8 +260,9 @@ 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')")
+ 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: '用户名已存在' });
@@ -249,13 +273,26 @@ app.post('/api/users/:id/toggle', requireAuth, requireSuper, (req, res) => {
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 });
+});
+
+// ---------- 访问记录(超管) ----------
+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 });
});