update
This commit is contained in:
@@ -1,12 +1,37 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('./database');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// 聊天附件存储目录
|
||||
const uploadsDir = path.join(__dirname, 'uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// multer 磁盘存储:文件名用 uuid + 安全扩展名,单文件上限 100MB
|
||||
const MIME_EXT = {
|
||||
'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp',
|
||||
'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov', 'video/x-msvideo': '.avi', 'video/x-matroska': '.mkv'
|
||||
};
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
const rawExt = path.extname(file.originalname || '').toLowerCase();
|
||||
const ext = MIME_EXT[file.mimetype] || (/^\.[a-z0-9]{1,8}$/.test(rawExt) ? rawExt : '.bin');
|
||||
cb(null, uuidv4() + ext);
|
||||
}
|
||||
}),
|
||||
limits: { fileSize: 100 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
// 内存会话存储
|
||||
const sessions = new Map();
|
||||
// 会话最近活跃时间(token -> 时间戳),用于判断在线状态
|
||||
@@ -50,7 +75,8 @@ function authMiddleware(req, res, next) {
|
||||
if (publicPaths.some(path => req.path.startsWith(path))) {
|
||||
return next();
|
||||
}
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
// 优先取请求头 token;img/video/下载链接无法携带 Authorization 头,允许查询参数回退
|
||||
const token = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
|
||||
if (!token || !sessions.has(token)) {
|
||||
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
|
||||
}
|
||||
@@ -87,6 +113,52 @@ function logDeviceAction(deviceId, deviceName, action, detail, operator) {
|
||||
`).run(uuidv4(), deviceId, deviceName || '', action, detail || '', operator || '');
|
||||
}
|
||||
|
||||
// ==================== 推送事件(客户端轮询拉取的原生推送源) ====================
|
||||
|
||||
// 定期清理 7 天前的推送事件
|
||||
function cleanPushEvents() {
|
||||
db.prepare("DELETE FROM push_events WHERE created_at < datetime('now','localtime','-7 day')").run();
|
||||
}
|
||||
cleanPushEvents();
|
||||
setInterval(cleanPushEvents, 6 * 60 * 60 * 1000);
|
||||
|
||||
// 向指定用户写入一条推送事件
|
||||
function pushTo(userId, kind, title, body) {
|
||||
if (!userId) return;
|
||||
db.prepare('INSERT INTO push_events (user_id, kind, title, body) VALUES (?, ?, ?, ?)')
|
||||
.run(userId, kind, String(title || ''), String(body || '').slice(0, 200));
|
||||
}
|
||||
|
||||
// 向多个用户推送(自动去重、跳过发送者)
|
||||
function pushToMany(userIds, kind, title, body, excludeId) {
|
||||
const seen = new Set();
|
||||
for (const uid of userIds) {
|
||||
if (!uid || uid === excludeId || seen.has(uid)) continue;
|
||||
seen.add(uid);
|
||||
pushTo(uid, kind, title, body);
|
||||
}
|
||||
}
|
||||
|
||||
// 向指定角色的全部用户推送
|
||||
function pushToRole(role, kind, title, body, excludeId) {
|
||||
const rows = db.prepare('SELECT id FROM users WHERE role = ?').all(role);
|
||||
pushToMany(rows.map(r => r.id), kind, title, body, excludeId);
|
||||
}
|
||||
|
||||
// 向全部用户推送
|
||||
function pushToAll(kind, title, body, excludeId) {
|
||||
const rows = db.prepare('SELECT id FROM users').all();
|
||||
pushToMany(rows.map(r => r.id), kind, title, body, excludeId);
|
||||
}
|
||||
|
||||
// 客户端拉取自己的新推送事件(after 游标为上次拉到的最大 id)
|
||||
app.get('/api/push/events', (req, res) => {
|
||||
const after = parseInt(req.query.after) || 0;
|
||||
const rows = db.prepare('SELECT * FROM push_events WHERE user_id = ? AND id > ? ORDER BY id ASC LIMIT 50')
|
||||
.all(req.user.id, after);
|
||||
res.json({ success: true, data: rows });
|
||||
});
|
||||
|
||||
// 写入维修记录
|
||||
function addMaintenanceRecord(deviceId, deviceName, type, remark, operator) {
|
||||
db.prepare(`
|
||||
@@ -236,6 +308,8 @@ app.patch('/api/devices/:id/repair', (req, res) => {
|
||||
`).run(req.params.id);
|
||||
addMaintenanceRecord(req.params.id, device.name, '报修', remark || '', getOperator(req));
|
||||
logDeviceAction(req.params.id, device.name, '报修', remark || '设备已标记为维修中', getOperator(req));
|
||||
// 推送给全部管理员处理
|
||||
pushToRole('admin', 'repair_request', '设备报修', `${getOperator(req)} 报修:${device.name}${remark ? '(' + remark + ')' : ''}`, req.user.id);
|
||||
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
|
||||
res.json({ success: true, data: updated, message: '报修成功,设备已标记为维修中' });
|
||||
});
|
||||
@@ -262,6 +336,8 @@ app.patch('/api/devices/:id/status', requireAdmin, (req, res) => {
|
||||
}
|
||||
if (device.status === '维修中' && status === '正常') {
|
||||
addMaintenanceRecord(req.params.id, device.name, '维修完成', '维修完成,状态恢复正常', getOperator(req));
|
||||
// 维修完成推送给全部用户
|
||||
pushToAll('repair_done', '设备维修完成', `设备「${device.name}」已维修好,状态恢复正常`, req.user.id);
|
||||
}
|
||||
logDeviceAction(req.params.id, device.name, '修改状态', `${device.status} → ${status}`, getOperator(req));
|
||||
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
|
||||
@@ -705,6 +781,65 @@ function getUserDisplayName(userId) {
|
||||
return u ? (u.display_name || u.username) : '';
|
||||
}
|
||||
|
||||
// 媒体消息在会话列表的预览文案
|
||||
function messagePreview(m) {
|
||||
if (m.msg_type === 'image') return '[图片]';
|
||||
if (m.msg_type === 'video') return '[视频]';
|
||||
if (m.msg_type === 'file') return '[文件] ' + (m.content || '');
|
||||
return m.content;
|
||||
}
|
||||
|
||||
// 上传聊天附件(图片/视频/文件),返回 fileId 供发送消息时引用
|
||||
app.post('/api/chat/upload', upload.single('file'), (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, message: '未接收到文件' });
|
||||
}
|
||||
const id = uuidv4();
|
||||
db.prepare('INSERT INTO chat_files (id, uploader_id, file_name, stored_name, file_size, file_mime) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, req.user.id, req.file.originalname, req.file.filename, req.file.size, req.file.mimetype || '');
|
||||
res.json({
|
||||
success: true,
|
||||
data: { fileId: id, fileName: req.file.originalname, fileSize: req.file.size, fileMime: req.file.mimetype || '' }
|
||||
});
|
||||
});
|
||||
|
||||
// multer 错误处理(超大文件等)
|
||||
app.use('/api/chat/upload', (err, req, res, next) => {
|
||||
if (err) {
|
||||
const msg = err.code === 'LIMIT_FILE_SIZE' ? '文件超过 100MB 限制' : '上传失败';
|
||||
return res.status(400).json({ success: false, message: msg });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 下载/预览聊天附件:仅上传者可访问,或所在会话的成员可访问
|
||||
app.get('/api/chat/files/:id', (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.id);
|
||||
if (!file) {
|
||||
return res.status(404).json({ success: false, message: '文件不存在' });
|
||||
}
|
||||
if (file.uploader_id !== req.user.id) {
|
||||
const inConv = db.prepare(`
|
||||
SELECT 1 FROM conversation_members m
|
||||
WHERE m.user_id = ? AND EXISTS (
|
||||
SELECT 1 FROM messages msg WHERE msg.file_id = ? AND msg.conversation_id = m.conversation_id
|
||||
)
|
||||
`).get(req.user.id, file.id);
|
||||
if (!inConv) {
|
||||
return res.status(403).json({ success: false, message: '无权访问该文件' });
|
||||
}
|
||||
}
|
||||
const filePath = path.join(uploadsDir, file.stored_name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ success: false, message: '文件已丢失' });
|
||||
}
|
||||
const inline = (file.file_mime || '').startsWith('image/') || (file.file_mime || '').startsWith('video/');
|
||||
const encoded = encodeURIComponent(file.file_name);
|
||||
res.setHeader('Content-Type', file.file_mime || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encoded}`);
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// 全部用户简表(选择聊天对象/建群成员,含在线状态)
|
||||
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();
|
||||
@@ -737,7 +872,7 @@ app.get('/api/chat/conversations', (req, res) => {
|
||||
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,
|
||||
last_message: last ? { content: messagePreview(last), created_at: last.created_at, sender_name: getUserDisplayName(last.sender_id) } : null,
|
||||
unread
|
||||
};
|
||||
});
|
||||
@@ -805,6 +940,12 @@ app.get('/api/chat/conversations/:id/messages', (req, res) => {
|
||||
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
|
||||
return res.status(404).json({ success: false, message: '会话不存在' });
|
||||
}
|
||||
// 标记已读到会话最新一条(先于返回,保证本次返回的消息自带已读回执)
|
||||
const latest = db.prepare('SELECT MAX(id) as maxId FROM messages WHERE conversation_id = ?').get(req.params.id);
|
||||
if (latest && latest.maxId) {
|
||||
db.prepare('UPDATE conversation_members SET last_read_message_id = MAX(last_read_message_id, ?) WHERE conversation_id = ? AND user_id = ?')
|
||||
.run(latest.maxId, req.params.id, req.user.id);
|
||||
}
|
||||
const after = parseInt(req.query.after) || 0;
|
||||
const limit = Math.min(parseInt(req.query.limit) || 100, 200);
|
||||
let rows;
|
||||
@@ -813,40 +954,102 @@ app.get('/api/chat/conversations/:id/messages', (req, res) => {
|
||||
} else {
|
||||
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?').all(req.params.id, limit).reverse();
|
||||
}
|
||||
const readMap = buildReadStatusMap(req.params.id, req.user.id);
|
||||
const fileCache = {};
|
||||
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 item = { ...m, sender_name: senderCache[m.sender_id], read_status: readMap[m.id] || null };
|
||||
if (m.file_id) {
|
||||
if (!fileCache[m.file_id]) fileCache[m.file_id] = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(m.file_id);
|
||||
const f = fileCache[m.file_id];
|
||||
if (f) {
|
||||
item.file_name = f.file_name;
|
||||
item.file_size = f.file_size;
|
||||
item.file_mime = f.file_mime;
|
||||
item.file_url = `/api/chat/files/${f.id}`;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
// 标记已读到最新一条
|
||||
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 字)' });
|
||||
// 计算会话内各消息的已读回执:read_count=已读的其他成员数,total_others=其他成员总数,unread_users=未读成员名单
|
||||
function buildReadStatusMap(convId, meId) {
|
||||
const members = db.prepare('SELECT user_id, last_read_message_id FROM conversation_members WHERE conversation_id = ?').all(convId);
|
||||
const others = members.filter(m => m.user_id !== meId);
|
||||
const msgs = db.prepare('SELECT id, sender_id FROM messages WHERE conversation_id = ?').all(convId);
|
||||
const nameCache = {};
|
||||
const map = {};
|
||||
for (const m of msgs) {
|
||||
const unreadUsers = others
|
||||
.filter(o => (o.last_read_message_id || 0) < m.id)
|
||||
.map(o => {
|
||||
if (!nameCache[o.user_id]) nameCache[o.user_id] = getUserDisplayName(o.user_id);
|
||||
return { id: o.user_id, name: nameCache[o.user_id] };
|
||||
});
|
||||
map[m.id] = {
|
||||
read_count: others.length - unreadUsers.length,
|
||||
total_others: others.length,
|
||||
unread_users: unreadUsers
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// 轻量已读回执轮询(不标记已读,仅返回各消息的已读进度)
|
||||
app.get('/api/chat/conversations/:id/read-status', (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 result = db.prepare('INSERT INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)')
|
||||
.run(req.params.id, req.user.id, content);
|
||||
res.json({ success: true, data: buildReadStatusMap(req.params.id, req.user.id) });
|
||||
});
|
||||
|
||||
// 发送消息:文本(content)或媒体(type=image/video/file + fileId,引用已上传附件)
|
||||
app.post('/api/chat/conversations/:id/messages', (req, res) => {
|
||||
const msgType = ['text', 'image', 'video', 'file'].includes(req.body.type) ? req.body.type : 'text';
|
||||
const content = String(req.body.content || '').trim();
|
||||
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: '会话不存在' });
|
||||
}
|
||||
let fileId = '';
|
||||
let finalContent = content;
|
||||
if (msgType !== 'text') {
|
||||
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.body.fileId);
|
||||
if (!file || file.uploader_id !== req.user.id) {
|
||||
return res.status(400).json({ success: false, message: '附件不存在,请重新上传' });
|
||||
}
|
||||
fileId = file.id;
|
||||
if (!finalContent) finalContent = file.file_name;
|
||||
} else if (!content) {
|
||||
return res.status(400).json({ success: false, message: '消息不能为空' });
|
||||
}
|
||||
if (finalContent.length > 2000) {
|
||||
return res.status(400).json({ success: false, message: '消息过长(最多 2000 字)' });
|
||||
}
|
||||
const result = db.prepare('INSERT INTO messages (conversation_id, sender_id, content, msg_type, file_id) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(req.params.id, req.user.id, finalContent, msgType, fileId);
|
||||
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 } });
|
||||
const data = { ...msg, sender_name: req.user.display_name || req.user.username };
|
||||
if (fileId) {
|
||||
const f = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(fileId);
|
||||
data.file_name = f.file_name;
|
||||
data.file_size = f.file_size;
|
||||
data.file_mime = f.file_mime;
|
||||
data.file_url = `/api/chat/files/${f.id}`;
|
||||
}
|
||||
// 推送给会话其他成员(群聊标题=群名,单聊标题=发送者)
|
||||
const memberRows = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id = ?').all(req.params.id);
|
||||
const senderName = req.user.display_name || req.user.username;
|
||||
const pushTitle = conv.type === 'group' ? (conv.name || '群聊消息') : senderName;
|
||||
pushToMany(memberRows.map(m => m.user_id), 'chat', pushTitle, `${senderName}:${messagePreview(msg)}`, req.user.id);
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
// 删除/解散会话(群聊仅群主可解散,单聊任一方可删除)
|
||||
@@ -861,6 +1064,19 @@ app.delete('/api/chat/conversations/:id', (req, res) => {
|
||||
if (conv.type === 'group' && conv.creator_id !== req.user.id) {
|
||||
return res.status(403).json({ success: false, message: '只有群主可以解散群组' });
|
||||
}
|
||||
// 清理会话内附件的磁盘文件与记录
|
||||
const fileRows = db.prepare(`
|
||||
SELECT f.id, f.stored_name FROM chat_files f
|
||||
WHERE f.id IN (SELECT file_id FROM messages WHERE conversation_id = ? AND file_id != '')
|
||||
AND NOT EXISTS (SELECT 1 FROM messages m2 WHERE m2.file_id = f.id AND m2.conversation_id != ?)
|
||||
`).all(req.params.id, req.params.id);
|
||||
for (const f of fileRows) {
|
||||
const p = path.join(uploadsDir, f.stored_name);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
}
|
||||
if (fileRows.length) {
|
||||
db.prepare(`DELETE FROM chat_files WHERE id IN (SELECT file_id FROM messages WHERE conversation_id = ? AND file_id != '')`).run(req.params.id);
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user