const express = require('express'); const http = require('http'); const { WebSocketServer } = require('ws'); const jwt = require('jsonwebtoken'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const db = require('./db'); const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); // 环境变量 const USERS = {}; (process.env.USERS || '').split(',').forEach(u => { const [name, pass] = u.split(':'); if (name) USERS[name] = pass; }); const ADMINS = (process.env.ADMINS || '').split(','); const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY; const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-' + Date.now(); // 初始化用户 const insertUser = db.prepare('INSERT OR IGNORE INTO users (username, password, is_admin) VALUES (?, ?, ?)'); for (const [username, password] of Object.entries(USERS)) { const isAdmin = ADMINS.includes(username) ? 1 : 0; insertUser.run(username, password, isAdmin); } // 上传配置 const uploadsDir = '/app/data/uploads'; if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); const storage = multer.diskStorage({ destination: uploadsDir, filename: (req, file, cb) => { const ext = path.extname(file.originalname) || '.jpg'; const safeName = Date.now() + '-' + Math.random().toString(36).substring(2, 8) + ext; cb(null, safeName); } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, // 5MB fileFilter: (req, file, cb) => { const allowed = ['image/jpeg','image/png','image/gif']; cb(null, allowed.includes(file.mimetype)); } }); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/uploads', express.static(uploadsDir)); // 登录 app.post('/api/login', (req, res) => { const { username, password } = req.body; if (USERS[username] && USERS[username] === password) { const token = jwt.sign({ username, isAdmin: ADMINS.includes(username) }, JWT_SECRET, { expiresIn: '7d' }); return res.json({ token, username, isAdmin: ADMINS.includes(username) }); } res.status(401).json({ error: '用户名或密码错误' }); }); const auth = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) return res.status(401).json({ error: '未登录' }); const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, JWT_SECRET); req.user = decoded; next(); } catch (e) { res.status(401).json({ error: '登录已过期' }); } }; app.get('/api/user', auth, (req, res) => { res.json({ username: req.user.username, isAdmin: req.user.isAdmin }); }); // 群聊列表(带最后消息) app.get('/api/rooms', auth, (req, res) => { const rooms = db.prepare('SELECT * FROM rooms').all(); const result = rooms.map(room => { const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(room.id); return { ...room, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }; }); res.json(result); }); app.post('/api/rooms', auth, (req, res) => { if (!req.user.isAdmin) return res.status(403).json({ error: '仅管理员可创建' }); const { name, whiteList } = req.body; const id = uuidv4(); const now = new Date().toLocaleString('zh-CN', { hour12: false }); db.prepare('INSERT INTO rooms (id, name, created_by, white_list, created_at) VALUES (?,?,?,?,?)').run(id, name, req.user.username, whiteList || '', now); broadcast({ type: 'room_created', room: { id, name, white_list: whiteList || '' } }); res.json({ id, name }); }); app.get('/api/rooms/:roomId/messages', auth, (req, res) => { const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY timestamp ASC').all(req.params.roomId); res.json(msgs); }); // 发送消息(统一入口) app.post('/api/rooms/:roomId/messages', auth, (req, res) => { const { text, attachments } = req.body; const roomId = req.params.roomId; const username = req.user.username; const timestamp = new Date().toLocaleString('zh-CN', { hour12: false }); const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run( roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, timestamp ); const msg = { id: result.lastInsertRowid, room_id: roomId, user: username, text: text || '', attachments: attachments || [], timestamp }; // 房间最后消息预览 const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId); broadcastToRoom(roomId, { type: 'new_message', message: msg, room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } }); // 加入对话历史 addToHistory(roomId, 'user', `${username}: ${text || '图片'}`); res.json(msg); }); app.post('/api/upload', auth, upload.single('file'), (req, res) => { if (!req.file) return res.status(400).json({ error: '请上传图片' }); res.json({ path: '/uploads/' + req.file.filename }); }); app.get('/api/rooms/:roomId/purchases', auth, (req, res) => { const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(req.params.roomId); res.json(purchases); }); app.get('/api/purchases/:id', auth, (req, res) => { const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id); if (!pur) return res.status(404).json({ error: '未找到' }); const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY timestamp ASC').all(req.params.id); const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id); res.json({ ...pur, history, attachments }); }); app.get('/api/summary', auth, (req, res) => { const rooms = db.prepare('SELECT id, name FROM rooms').all(); const summary = rooms.map(room => { const stats = db.prepare(`SELECT COUNT(*) as count, SUM(amount) as total FROM purchases WHERE room_id = ?`).get(room.id); return { room_id: room.id, room_name: room.name, purchase_count: stats.count || 0, total_amount: stats.total || 0 }; }); res.json(summary); }); app.get('/api/rooms/:roomId/purchases/export', auth, (req, res) => { const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ?').all(req.params.roomId); let csv = '时间,事项,金额,付款方式,状态,申请人\n'; purchases.forEach(p => { csv += `${p.created_at},"${p.item||''}",${p.amount},"${p.method||''}","${p.status}","${p.applicant}"\n`; }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="purchases-${req.params.roomId}.csv"`); res.send('\uFEFF' + csv); }); // WebSocket 管理 const clients = new Map(); wss.on('connection', (ws, req) => { console.log('✅ WebSocket 客户端已连接'); const url = new URL(req.url, 'http://localhost'); const token = url.searchParams.get('token'); if (!token) return ws.close(); let username; try { const decoded = jwt.verify(token, JWT_SECRET); username = decoded.username; } catch (e) { return ws.close(); } ws.username = username; clients.set(ws, { username, roomId: null }); ws.on('message', (data) => { try { const msg = JSON.parse(data); if (msg.type === 'join') { ws.roomId = msg.roomId; clients.set(ws, { username, roomId: msg.roomId }); } // 不再处理 message 类型,消息通过 HTTP API 发送 } catch (e) {} }); ws.on('close', () => { clients.delete(ws); }); }); function broadcastToRoom(roomId, data) { const message = JSON.stringify(data); clients.forEach((info, ws) => { if (info.roomId === roomId && ws.readyState === 1) { ws.send(message); } }); } function broadcast(data) { const message = JSON.stringify(data); clients.forEach((ws) => { if (ws.readyState === 1) ws.send(message); }); } // 文本消息存储并广播(用于 AI 回复等内部消息) function storeAndBroadcastText(roomId, user, text) { const timestamp = new Date().toLocaleString('zh-CN', { hour12: false }); const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, timestamp); const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp }; const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId); broadcastToRoom(roomId, { type: 'new_message', message: msg, room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } }); return msg; } // 对话历史 const conversationHistory = new Map(); function getHistory(roomId) { if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); return conversationHistory.get(roomId); } function addToHistory(roomId, role, content) { const history = getHistory(roomId); history.push({ role, content }); if (history.length > 60) conversationHistory.set(roomId, history.slice(-40)); } // AI 处理相关函数(保持不变) async function analyzeWithDeepSeek(text, historyMessages, username) { const systemPrompt = `你是智能财务助手“小财”。结合对话历史理解用户意图。只返回JSON。 意图分类: - 采购/付款/发票相关:action="purchase",提取purchase_item, amount, method, status, applicant, created_time(可选,根据聊天中的时间推断,格式yyyy/MM/dd HH:mm:ss) - 查询汇总:action="query" - 聊天:action="chat",reply简短回复 - 删除:action="delete",delete_item物品名 - 忽略:action="ignore" 采购规则: - 金额缺失=0,方式缺失="未指定",申请人缺失="${username}"。 - 状态:已付→已采购,发票→发票已收,否则待采购。 - 用户补充信息时更新已有记录。 - 时间提取:如果用户提到具体时间(如“6月30日”),请设置created_time字段,否则留空。`; const messages = [ { role: 'system', content: systemPrompt }, ...historyMessages.slice(-30), { role: 'user', content: `${username}: ${text}` } ]; const res = await fetch('https://api.deepseek.com/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` }, body: JSON.stringify({ model: 'deepseek-v4-flash', messages, temperature: 0.1, stream: false }) }); const data = await res.json(); return JSON.parse(data.choices[0].message.content.replace(/```json|```/g, '').trim()); } function handleAIResult(aiResult, roomId, username, originalText, attachments) { const now = new Date().toLocaleString('zh-CN', { hour12: false }); if (aiResult.action === 'ask') { storeAndBroadcastText(roomId, '小财', aiResult.question); addToHistory(roomId, 'assistant', aiResult.question); return; } if (aiResult.action === 'delete') { const itemName = aiResult.delete_item; const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`); if (purchases.length === 0) { storeAndBroadcastText(roomId, '小财', `没有找到与“${itemName}”相关的采购记录。`); addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`); return; } const deleteAttachments = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?'); const deleteHistory = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?'); const deletePurchase = db.prepare('DELETE FROM purchases WHERE id = ?'); for (const p of purchases) { deleteAttachments.run(p.id); deleteHistory.run(p.id); deletePurchase.run(p.id); } const reply = `已删除采购记录:${itemName}(共 ${purchases.length} 条)`; storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply); broadcastToRoom(roomId, { type: 'purchase_updated' }); return; } if (aiResult.action === 'purchase') { const createdTime = aiResult.created_time || now; let purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成'); let replyText = ''; if (!purchase) { const id = uuidv4(); db.prepare('INSERT INTO purchases (id, room_id, item, amount, method, status, applicant, created_at) VALUES (?,?,?,?,?,?,?,?)').run( id, roomId, aiResult.purchase_item, aiResult.amount, aiResult.method, aiResult.status || '待处理', aiResult.applicant || username, createdTime ); db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, 'AI 创建采购条目', '小财', now); replyText = `✅ 已记录采购:${aiResult.purchase_item},金额 ¥${aiResult.amount},状态 ${aiResult.status || '待处理'}`; purchase = { id }; } else { db.prepare('UPDATE purchases SET amount = ?, method = ?, status = ?, updated_at = ? WHERE id = ?').run( aiResult.amount, aiResult.method, aiResult.status, now, purchase.id ); db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新采购信息(金额:${aiResult.amount},状态:${aiResult.status})`, username, now); replyText = `🔄 已更新采购「${aiResult.purchase_item}」:金额 ¥${aiResult.amount},状态 ${aiResult.status}`; } if (attachments?.length) { const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)'); attachments.forEach(fp => insertAttach.run(purchase.id, fp, username, now)); } storeAndBroadcastText(roomId, '小财', replyText); addToHistory(roomId, 'assistant', replyText); broadcastToRoom(roomId, { type: 'purchase_updated' }); return; } if (aiResult.action === 'query') { const purchaseData = db.prepare('SELECT item, amount, method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId) .map(p => `${p.item} ¥${p.amount} ${p.status}`).join('\n'); const summaryPrompt = `根据以下采购记录,用自然语言回答用户查询“${originalText}”。采购记录:\n${purchaseData || '暂无记录'}`; callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply); }); return; } if (aiResult.action === 'chat') { const reply = aiResult.reply || '好的,有什么需要帮助的吗?'; storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply); return; } } async function callDeepSeekForSummary(prompt) { const res = await fetch('https://api.deepseek.com/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` }, body: JSON.stringify({ model: 'deepseek-v4-flash', messages: [ { role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' }, { role: 'user', content: prompt } ], temperature: 0.3, stream: false }) }); const data = await res.json(); return data.choices[0].message.content; } server.listen(process.env.PORT || 3000, () => { console.log(`Server running on port ${process.env.PORT || 3000}`); });