Files
ai-xiaocai/server/server.js
2026-07-22 12:03:30 +08:00

546 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 TIMEZONE = 'Asia/Shanghai';
function timestamp() {
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
}
function currentTimeStr() {
return timestamp();
}
// 初始化用户
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: '登录已过期' });
}
};
const adminOnly = (req, res, next) => {
if (!req.user.isAdmin) return res.status(403).json({ error: '仅管理员可执行此操作' });
next();
};
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, adminOnly, (req, res) => {
const { name, whiteList } = req.body;
const id = uuidv4();
const now = timestamp();
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.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
const { name, whiteList } = req.body;
const roomId = req.params.roomId;
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId);
if (!room) return res.status(404).json({ error: '群聊不存在' });
db.prepare('UPDATE rooms SET name = ?, white_list = ? WHERE id = ?').run(name, whiteList || '', roomId);
broadcast({ type: 'room_updated', room: { id: roomId, name, white_list: whiteList || '' } });
res.json({ success: true });
});
app.delete('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
const roomId = req.params.roomId;
db.prepare('DELETE FROM messages WHERE room_id = ?').run(roomId);
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId);
broadcast({ type: 'room_deleted', roomId });
res.json({ success: true });
});
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, async (req, res) => {
const { text, attachments } = req.body;
const roomId = req.params.roomId;
const username = req.user.username;
const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run(
roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, now
);
const msg = {
id: result.lastInsertRowid,
room_id: roomId,
user: username,
text: text || '',
attachments: attachments || [],
timestamp: now
};
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
broadcastToRoomExcludeSelf(roomId, username, {
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);
if (text && text.trim() === '确认' && hasPendingAction(roomId, username)) {
executePendingAction(roomId, username);
return;
}
try {
const history = getHistory(roomId);
const historyMessages = history.map(e => ({ role: e.role, content: e.content }));
const aiResponse = await analyzeWithDeepSeek(text || '', historyMessages, username, req.user.isAdmin);
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.quantity},${p.unit_price},${p.amount},${p.freight},"${p.payment_method||''}","${p.invoice_type||''}","${p.status}","${p.applicant}","${p.remarks||''}"\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;
ws.isAdmin = ADMINS.includes(username);
clients.set(ws, { username, roomId: null, isAdmin: ws.isAdmin });
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, isAdmin: ws.isAdmin });
}
} 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);
});
}
function storeAndBroadcastText(roomId, user, text) {
const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
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 pendingActions = new Map();
function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); }
function clearPendingAction(roomId, username) { pendingActions.delete(`${roomId}:${username}`); }
function executePendingAction(roomId, username) {
const action = pendingActions.get(`${roomId}:${username}`);
if (!action) return false;
clearPendingAction(roomId, username);
const now = timestamp();
try {
if (action.type === 'delete') {
const { itemName, purchaseIds } = action.data;
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 id of purchaseIds) { deleteAttachments.run(id); deleteHistory.run(id); deletePurchase.run(id); }
const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`;
storeAndBroadcastText(roomId, '小财', reply);
addToHistory(roomId, 'assistant', reply);
} else if (action.type === 'clear') {
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
const reply = '✅ 已清空当前房间的所有采购数据。';
storeAndBroadcastText(roomId, '小财', reply);
addToHistory(roomId, 'assistant', reply);
}
broadcastToRoom(roomId, { type: 'purchase_updated' });
return true;
} catch (e) { console.error('执行待处理操作失败:', e); storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
}
// 对话历史
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, isAdmin) {
const systemPrompt = `你是智能财务助手“小财”。当前服务器时间:${currentTimeStr()}。结合对话历史理解用户意图只返回JSON。
意图分类:
- 采购/付款/发票相关action="purchase",提取以下字段(所有字段可选,缺失则不返回):
purchase_item (物品名称), quantity (数量默认1), unit_price (单价默认0), amount (总金额默认0),
freight (邮费默认0), payment_method (付款方式:支付宝/微信/对公转账/现金等,淘宝购买默认支付宝),
invoice_type (发票类型:增票/普票/无票,默认无票), status (待采购/已采购/发票已收), applicant (申请人),
remarks (备注,重要信息如“下次换商家”“缺少配件”等), created_time (如果用户提到具体时间则按yyyy/MM/dd HH:mm:ss格式输出)
- 查询汇总action="query"
- 聊天action="chat"reply简短回复
- 删除指定物品action="delete"delete_item为物品名
- 清空所有采购数据action="clear_all"
- 忽略action="ignore"
权限规则:删除和清空仅限管理员。当前用户${username},管理员状态:${isAdmin}
采购规则:用户补充信息时更新已有记录。更新时请返回完整字段值,包括未变化的字段。
如果用户要求修改时间请生成正确的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 = timestamp();
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 id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`);
if (purchases.length === 0) {
storeAndBroadcastText(roomId, '小财', `没有找到与“${itemName}”相关的采购记录。`);
addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`);
return;
}
let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复“确认”继续:\n\n`;
purchases.forEach(p => {
confirmText += `${p.item} | ¥${p.amount} | ${p.status} | ${p.applicant} | ${p.created_at}\n`;
});
confirmText += `\n如果不删除,请忽略此消息。`;
storeAndBroadcastText(roomId, '小财', confirmText);
addToHistory(roomId, 'assistant', confirmText);
setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
return;
}
if (aiResult.action === 'clear_all') {
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
if (count === 0) {
storeAndBroadcastText(roomId, '小财', '当前房间没有采购记录,无需清空。');
addToHistory(roomId, 'assistant', '当前房间没有采购记录,无需清空。');
return;
}
const confirmText = `⚠️ 即将清空当前房间的 ${count} 条采购数据,请回复“确认”继续,否则忽略。`;
storeAndBroadcastText(roomId, '小财', confirmText);
addToHistory(roomId, 'assistant', confirmText);
setPendingAction(roomId, username, { type: 'clear', data: {} });
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 = '';
// 计算总金额:优先使用 AI 提供的 amount否则根据单价*数量+邮费计算
const quantity = aiResult.quantity || 1;
const unitPrice = aiResult.unit_price || 0;
const freight = aiResult.freight || 0;
let amount;
if (aiResult.amount !== undefined && aiResult.amount !== null) {
amount = aiResult.amount;
} else {
amount = unitPrice * quantity + freight;
}
if (!purchase) {
const id = uuidv4();
const item = aiResult.purchase_item;
const paymentMethod = aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || '未指定'));
const invoiceType = aiResult.invoice_type || '无票';
const status = aiResult.status || '待处理';
const applicant = aiResult.applicant || username;
const remarks = aiResult.remarks || '';
db.prepare(`INSERT INTO purchases (id, room_id, item, quantity, unit_price, amount, freight, payment_method, invoice_type, status, applicant, remarks, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
id, roomId, item, quantity, unitPrice, amount, freight, paymentMethod, invoiceType, status, applicant, remarks, createdTime
);
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, `创建采购:${item},数量${quantity},金额¥${amount},状态${status}`, '小财', now);
replyText = `✅ 已记录采购:${item},数量 ${quantity},金额 ¥${amount},状态 ${status}`;
purchase = { id };
} else {
const old = purchase;
// 合并新值
const newVals = {
quantity: aiResult.quantity ?? old.quantity,
unit_price: aiResult.unit_price ?? old.unit_price,
freight: aiResult.freight ?? old.freight,
payment_method: aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || old.payment_method)),
invoice_type: aiResult.invoice_type || old.invoice_type,
status: aiResult.status || old.status,
applicant: aiResult.applicant || old.applicant,
remarks: aiResult.remarks !== undefined ? aiResult.remarks : old.remarks,
created_at: aiResult.created_time || old.created_at
};
// 重新计算金额,除非 AI 明确给了 amount
if (aiResult.amount !== undefined && aiResult.amount !== null) {
newVals.amount = aiResult.amount;
} else {
newVals.amount = newVals.unit_price * newVals.quantity + newVals.freight;
}
const historyChanges = [];
let changed = false;
for (const [field, newVal] of Object.entries(newVals)) {
if (String(newVal) !== String(old[field])) {
changed = true;
historyChanges.push(`${field}: ${old[field]}${newVal}`);
db.prepare(`UPDATE purchases SET ${field} = ? WHERE id = ?`).run(newVal, purchase.id);
}
}
if (changed) {
db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, purchase.id);
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新:${historyChanges.join('')}`, username, now);
replyText = `🔄 已更新「${aiResult.purchase_item}」:${historyChanges.join('')}`;
} else {
replyText = ` 采购「${aiResult.purchase_item}」的信息没有发生变化。`;
}
}
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, payment_method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId)
.map(p => `${p.item} ¥${p.amount} ${p.payment_method||''} ${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}`);
});