This commit is contained in:
mei
2026-08-11 10:03:40 +08:00
parent 2086bd79cc
commit fb326279fa
25 changed files with 2590 additions and 2379 deletions
+227 -6
View File
@@ -9,6 +9,40 @@ const PORT = process.env.PORT || 3001;
// 内存会话存储
const sessions = new Map();
// 会话最近活跃时间(token -> 时间戳),用于判断在线状态
const sessionActivity = new Map();
// 最近活跃时间在此窗口内视为在线
const ONLINE_WINDOW_MS = 10 * 60 * 1000;
function touchSession(token) {
sessionActivity.set(token, Date.now());
}
function dropSession(token) {
sessions.delete(token);
sessionActivity.delete(token);
}
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
function getOnlineUserMap() {
const now = Date.now();
const map = new Map();
for (const [token, sessionUser] of sessions.entries()) {
const active = sessionActivity.get(token) || 0;
if (now - active <= ONLINE_WINDOW_MS) {
if (!map.has(sessionUser.id) || active > map.get(sessionUser.id)) {
map.set(sessionUser.id, active);
}
}
}
return map;
}
function formatActive(ts) {
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// 登录验证中间件(仅对 /api 路由生效)
function authMiddleware(req, res, next) {
@@ -21,6 +55,7 @@ function authMiddleware(req, res, next) {
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
}
req.user = sessions.get(token);
touchSession(token);
next();
}
@@ -522,7 +557,7 @@ function logUserAction(username, action, detail, operator) {
app.get('/api/users', requireAdmin, (req, res) => {
const { keyword, role } = req.query;
let sql = `
SELECT u.id, u.username, u.role, u.display_name, u.created_at, u.personnel_id, p.name AS personnel_name, p.department
SELECT u.id, u.username, u.role, u.display_name, u.created_at, u.last_login, u.personnel_id, p.name AS personnel_name, p.department
FROM users u LEFT JOIN personnel p ON u.personnel_id = p.id
WHERE 1=1
`;
@@ -536,7 +571,12 @@ app.get('/api/users', requireAdmin, (req, res) => {
params.push(role);
}
sql += ' ORDER BY u.created_at ASC';
res.json({ success: true, data: db.prepare(sql).all(...params) });
const onlineMap = getOnlineUserMap();
const data = db.prepare(sql).all(...params).map(u => {
const active = onlineMap.get(u.id);
return { ...u, is_online: !!active, last_active: active ? formatActive(active) : null };
});
res.json({ success: true, data });
});
// 账户操作日志(管理员)
@@ -550,7 +590,8 @@ app.get('/api/users/stats/summary', requireAdmin, (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const admins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
const users = total - admins;
res.json({ success: true, data: { total, admins, users } });
const online = getOnlineUserMap().size;
res.json({ success: true, data: { total, admins, users, online } });
});
// 创建独立账户(管理员)
@@ -640,7 +681,7 @@ app.delete('/api/users/:id', requireAdmin, (req, res) => {
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
// 使关联的会话失效
for (const [token, sessionUser] of sessions.entries()) {
if (sessionUser.id === target.id) sessions.delete(token);
if (sessionUser.id === target.id) dropSession(token);
}
logUserAction(target.username, '删除', '账户已删除', getOperator(req));
res.json({ success: true, message: '账户删除成功' });
@@ -652,6 +693,180 @@ app.delete('/api/users/logs/clear', requireAdmin, (req, res) => {
res.json({ success: true, message: '账户日志已清空' });
});
// ==================== 消息对话 API ====================
// 校验当前用户是否为会话成员
function isConversationMember(convId, userId) {
return !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id = ? AND user_id = ?').get(convId, userId);
}
function getUserDisplayName(userId) {
const u = db.prepare('SELECT display_name, username FROM users WHERE id = ?').get(userId);
return u ? (u.display_name || u.username) : '';
}
// 全部用户简表(选择聊天对象/建群成员,含在线状态)
app.get('/api/chat/users', (req, res) => {
const rows = db.prepare('SELECT id, username, display_name, role FROM users ORDER BY created_at ASC').all();
const onlineMap = getOnlineUserMap();
const data = rows.map(u => ({ ...u, is_online: onlineMap.has(u.id) }));
res.json({ success: true, data });
});
// 当前用户的会话列表(含最后一条消息与未读数)
app.get('/api/chat/conversations', (req, res) => {
const me = req.user.id;
const convs = db.prepare(`
SELECT c.id, c.type, c.name, c.creator_id, c.created_at, m.last_read_message_id
FROM conversations c JOIN conversation_members m ON m.conversation_id = c.id
WHERE m.user_id = ?
`).all(me);
const data = convs.map(c => {
const members = db.prepare(`
SELECT u.id, u.display_name, u.username
FROM conversation_members m JOIN users u ON u.id = m.user_id
WHERE m.conversation_id = ?
`).all(c.id);
const last = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT 1').get(c.id);
const unread = db.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ? AND id > ? AND sender_id != ?')
.get(c.id, c.last_read_message_id || 0, me).count;
return {
id: c.id,
type: c.type,
name: c.type === 'group' ? c.name : ((members.find(x => x.id !== me) || {}).display_name || ''),
creator_id: c.creator_id,
member_count: members.length,
members,
last_message: last ? { content: last.content, created_at: last.created_at, sender_name: getUserDisplayName(last.sender_id) } : null,
unread
};
});
// 按最后消息时间倒序(无消息的按创建时间)
data.sort((a, b) => (b.last_message?.created_at || b.created_at || '').localeCompare(a.last_message?.created_at || a.created_at || ''));
res.json({ success: true, data });
});
// 创建会话:单聊(type=direct + userId,已有则复用)或群聊(type=group + name + memberIds)
app.post('/api/chat/conversations', (req, res) => {
const { type, userId, name, memberIds } = req.body;
const me = req.user;
if (type === 'direct') {
if (!userId || userId === me.id) {
return res.status(400).json({ success: false, message: '请选择对话对象' });
}
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!target) {
return res.status(404).json({ success: false, message: '用户不存在' });
}
// 复用已有的单聊会话
const existing = db.prepare(`
SELECT c.id FROM conversations c WHERE c.type = 'direct'
AND EXISTS (SELECT 1 FROM conversation_members m WHERE m.conversation_id = c.id AND m.user_id = ?)
AND EXISTS (SELECT 1 FROM conversation_members m WHERE m.conversation_id = c.id AND m.user_id = ?)
`).get(me.id, userId);
if (existing) {
return res.json({ success: true, data: { id: existing.id }, message: '会话已存在' });
}
const id = uuidv4();
db.prepare("INSERT INTO conversations (id, type, creator_id) VALUES (?, 'direct', ?)").run(id, me.id);
const insMember = db.prepare('INSERT INTO conversation_members (conversation_id, user_id, role) VALUES (?, ?, ?)');
insMember.run(id, me.id, 'member');
insMember.run(id, userId, 'member');
return res.json({ success: true, data: { id }, message: '会话创建成功' });
}
if (type === 'group') {
if (!name || !String(name).trim()) {
return res.status(400).json({ success: false, message: '请输入群名称' });
}
if (!Array.isArray(memberIds) || memberIds.filter(id => id !== me.id).length < 1) {
return res.status(400).json({ success: false, message: '请至少选择1名其他群成员' });
}
const id = uuidv4();
db.prepare("INSERT INTO conversations (id, type, name, creator_id) VALUES (?, 'group', ?, ?)").run(id, String(name).trim(), me.id);
const insMember = db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id, user_id, role) VALUES (?, ?, ?)');
insMember.run(id, me.id, 'owner');
for (const uid of memberIds) {
if (uid !== me.id && db.prepare('SELECT id FROM users WHERE id = ?').get(uid)) {
insMember.run(id, uid, 'member');
}
}
logUserAction(me.username, '创建群组', `创建群组:${String(name).trim()}`, me.display_name || me.username);
return res.json({ success: true, data: { id }, message: '群组创建成功' });
}
return res.status(400).json({ success: false, message: '不支持的会话类型' });
});
// 获取会话消息(默认最新 100 条;after 游标增量拉取),同时标记已读
app.get('/api/chat/conversations/:id/messages', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
const after = parseInt(req.query.after) || 0;
const limit = Math.min(parseInt(req.query.limit) || 100, 200);
let rows;
if (after > 0) {
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? AND id > ? ORDER BY id ASC LIMIT ?').all(req.params.id, after, limit);
} else {
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?').all(req.params.id, limit).reverse();
}
const senderCache = {};
const data = rows.map(m => {
if (!senderCache[m.sender_id]) senderCache[m.sender_id] = getUserDisplayName(m.sender_id);
return { ...m, sender_name: senderCache[m.sender_id] };
});
// 标记已读到最新一条
const maxId = data.length ? data[data.length - 1].id : after;
if (maxId > 0) {
db.prepare('UPDATE conversation_members SET last_read_message_id = MAX(last_read_message_id, ?) WHERE conversation_id = ? AND user_id = ?')
.run(maxId, req.params.id, req.user.id);
}
res.json({ success: true, data });
});
// 发送消息
app.post('/api/chat/conversations/:id/messages', (req, res) => {
const content = String(req.body.content || '').trim();
if (!content) {
return res.status(400).json({ success: false, message: '消息不能为空' });
}
if (content.length > 2000) {
return res.status(400).json({ success: false, message: '消息过长(最多 2000 字)' });
}
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
const result = db.prepare('INSERT INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)')
.run(req.params.id, req.user.id, content);
const msg = db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid);
// 自己发送的消息直接标记已读
db.prepare('UPDATE conversation_members SET last_read_message_id = ? WHERE conversation_id = ? AND user_id = ?')
.run(msg.id, req.params.id, req.user.id);
res.json({ success: true, data: { ...msg, sender_name: req.user.display_name || req.user.username } });
});
// 删除/解散会话(群聊仅群主可解散,单聊任一方可删除)
app.delete('/api/chat/conversations/:id', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
if (!isConversationMember(req.params.id, req.user.id)) {
return res.status(403).json({ success: false, message: '您不是该会话的成员' });
}
if (conv.type === 'group' && conv.creator_id !== req.user.id) {
return res.status(403).json({ success: false, message: '只有群主可以解散群组' });
}
db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(req.params.id);
db.prepare('DELETE FROM conversation_members WHERE conversation_id = ?').run(req.params.id);
db.prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);
res.json({ success: true, message: conv.type === 'group' ? '群组已解散' : '会话已删除' });
});
// ==================== 认证 API ====================
// 登录
@@ -668,6 +883,9 @@ app.post('/api/auth/login', (req, res) => {
const token = uuidv4();
sessions.set(token, user);
touchSession(token);
// 记录最后登录时间
db.prepare("UPDATE users SET last_login=datetime('now','localtime') WHERE id=?").run(user.id);
res.json({
success: true,
@@ -688,7 +906,7 @@ app.get('/api/auth/me', (req, res) => {
// 登出
app.post('/api/auth/logout', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) sessions.delete(token);
if (token) dropSession(token);
res.json({ success: true, message: '登出成功' });
});
@@ -704,7 +922,10 @@ app.patch('/api/auth/password', (req, res) => {
}
db.prepare('UPDATE users SET password=? WHERE id=?').run(newPassword, req.user.id);
logUserAction(dbUser.username, '修改密码', '用户自行修改密码', dbUser.display_name || dbUser.username);
res.json({ success: true, message: '密码修改成功' });
// 修改成功后使当前会话失效,要求用新密码重新登录
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) dropSession(token);
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' });
});
// ==================== 天气 API ====================