430 lines
16 KiB
JavaScript
430 lines
16 KiB
JavaScript
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 },
|
||
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);
|
||
});
|
||
|
||
// 发送消息(统一入口,并触发 AI)
|
||
app.post('/api/rooms/:roomId/messages', auth, async (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);
|
||
const preview = {
|
||
room_id: roomId,
|
||
last_message: lastMsg ? lastMsg.text : '',
|
||
last_time: lastMsg ? lastMsg.timestamp : ''
|
||
};
|
||
|
||
// 广播给房间内其他成员(排除自己)
|
||
broadcastToRoomExcludeSelf(roomId, username, {
|
||
type: 'new_message',
|
||
message: msg,
|
||
room_preview: preview
|
||
});
|
||
|
||
// 加入对话历史
|
||
addToHistory(roomId, 'user', `${username}: ${text || '图片'}`);
|
||
|
||
// 先响应客户端
|
||
res.json(msg);
|
||
|
||
// 异步触发 AI 分析
|
||
try {
|
||
const history = getHistory(roomId);
|
||
const historyMessages = history.map(e => ({ role: e.role, content: e.content }));
|
||
const aiResponse = await analyzeWithDeepSeek(text || '', historyMessages, username);
|
||
if (aiResponse && aiResponse.action !== 'ignore') {
|
||
handleAIResult(aiResponse, roomId, username, text || '', attachments || []);
|
||
}
|
||
} catch (e) {
|
||
console.error('AI 分析失败:', e);
|
||
}
|
||
});
|
||
|
||
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 类型
|
||
} catch (e) {}
|
||
});
|
||
|
||
ws.on('close', () => {
|
||
clients.delete(ws);
|
||
});
|
||
});
|
||
|
||
function broadcastToRoomExcludeSelf(roomId, excludeUsername, data) {
|
||
const message = JSON.stringify(data);
|
||
clients.forEach((info, ws) => {
|
||
if (info.roomId === roomId && info.username !== excludeUsername && ws.readyState === 1) {
|
||
ws.send(message);
|
||
}
|
||
});
|
||
}
|
||
|
||
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}` }
|
||
];
|
||
|
||
console.log('🤖 调用 DeepSeek...');
|
||
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();
|
||
const content = data.choices[0].message.content;
|
||
console.log('🤖 AI 返回:', content);
|
||
return JSON.parse(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}`);
|
||
});
|