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

430 lines
16 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: '登录已过期' });
}
};
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}`);
});