Files
ai-xiaocai/server/server.js
2026-07-22 10:49:52 +08:00

451 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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 = 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);
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.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);
});
// 清空所有采购数据(管理员)
app.post('/api/rooms/:roomId/purchases/clear', auth, adminOnly, (req, res) => {
const roomId = req.params.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);
broadcastToRoom(roomId, { type: 'purchase_updated' });
res.json({ success: true, message: '所有采购数据已清空' });
});
// 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: ADMINS.includes(username) });
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 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));
}
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
const systemPrompt = `你是智能财务助手“小财”。结合对话历史理解用户意图。只返回JSON。
意图分类:
- 采购/付款/发票相关action="purchase"提取purchase_item, amount, method, status, applicant, created_time
- 查询汇总action="query"
- 聊天action="chat"reply简短回复
- 删除指定物品action="delete"delete_item物品名
- 清空所有采购数据仅管理员action="clear_all",注意:只有管理员可以请求清空,如果不是管理员但要求清空,应拒绝并告知只有管理员可操作。
- 忽略action="ignore"
采购规则:
- 金额缺失=0方式缺失="未指定",申请人缺失="${username}"。
- 状态:已付→已采购,发票→发票已收,否则待采购。
- 时间提取如果用户提到具体时间设置created_time字段格式yyyy/MM/dd HH:mm:ss。
当前用户${username},管理员状态:${isAdmin}`;
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 === 'clear_all') {
// 管理员才可以清空,但已经由 AI 判断了权限,这里再检查一次
// 清空所有采购数据(当前房间)
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;
}
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}`);
});