527 lines
27 KiB
JavaScript
527 lines
27 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 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 username = req.user.username;
|
||
const isAdmin = req.user.isAdmin;
|
||
let rooms;
|
||
if (isAdmin) {
|
||
rooms = db.prepare('SELECT * FROM rooms').all();
|
||
} else {
|
||
rooms = db.prepare("SELECT * FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`);
|
||
}
|
||
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 username = req.user.username;
|
||
const isAdmin = req.user.isAdmin;
|
||
let rooms;
|
||
if (isAdmin) {
|
||
rooms = db.prepare('SELECT id, name FROM rooms').all();
|
||
} else {
|
||
// 使用与群聊列表完全相同的白名单过滤方式
|
||
rooms = db.prepare("SELECT id, name FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`)
|
||
}
|
||
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 delA = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
|
||
const delH = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
|
||
const delP = db.prepare('DELETE FROM purchases WHERE id = ?');
|
||
for (const id of purchaseIds) { delA.run(id); delH.run(id); delP.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) { 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 分析(明确权限不足时返回 chat 而非 ignore)
|
||
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
|
||
- 查询汇总:action="query"
|
||
- 聊天:action="chat",reply简短回复
|
||
- 删除指定物品:action="delete",delete_item为物品名
|
||
- 清空所有采购数据:action="clear_all"
|
||
- 忽略(仅当完全无关):action="ignore"
|
||
|
||
⚠️ 重要权限规则:
|
||
- ⚠️ 当前用户${username},管理员状态:${isAdmin}。仅管理员可执行删除/清空操作。
|
||
- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明“仅管理员可操作”。
|
||
- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。
|
||
- 如果用户要求“删除金额为0的记录”或“删除没有名字的记录”等无法用具体物品名描述的删除请求,你可以使用以下特殊 delete_item 值:
|
||
- 删除所有金额为0的记录:delete_item="__AMOUNT_ZERO__"
|
||
- 删除物品名为空、null、undefined 的记录:delete_item="__NULL_NAME__"
|
||
- 不要再返回 action="ask" 来处理删除请求,只要确定是删除意图,就必须返回 action="delete" 并填好 delete_item。
|
||
- 若用户描述的是特定记录(如“名字叫XXX的”),则 delete_item 填那个物品名。
|
||
|
||
⚠️ 附件补充规则:
|
||
- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX采购里",
|
||
你只需返回 purchase_item 为 XX,**绝对不要**返回 quantity、unit_price、amount、freight、payment_method 等任何字段。
|
||
- 当消息中只有图片且文本明确指出"这是XX的图片"时,提取 purchase_item 为 XX,其余字段全部省略。
|
||
- 如果用户要求"把刚才/之前的图片作为附件存到XX采购",你可以返回 {"action":"purchase","purchase_item":"XX","use_recent_image":true},
|
||
不提供任何数量、价格字段,系统会自动查找最近一张图片并关联。
|
||
|
||
⚠️ 采购规则:
|
||
- 用户补充信息时更新已有记录。更新时请返回完整字段值,包括未变化的字段。如果用户要求修改时间,请生成正确的created_time字段。
|
||
- 总金额(amount)由系统自动按“数量×单价+邮费”计算,你无需提供该字段。
|
||
- 如果无法确定 purchase_item(物品名称),必须 action="ask" 并追问“请提供物品名称”,绝不允许将 purchase_item 设为空字符串、null 或 undefined。`;
|
||
|
||
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();
|
||
if (!data.choices || !data.choices[0]) {
|
||
console.error('🤖 DeepSeek 返回异常:', JSON.stringify(data));
|
||
throw new Error('DeepSeek API 返回异常: ' + (data.error?.message || JSON.stringify(data)));
|
||
}
|
||
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') { const q = aiResult.question || aiResult.reply || '请提供更多信息。'; storeAndBroadcastText(roomId, '小财', q); addToHistory(roomId, 'assistant', q); return; }
|
||
if (aiResult.action === 'delete') {
|
||
const itemName = aiResult.delete_item;
|
||
let purchases;
|
||
if (itemName === '__AMOUNT_ZERO__') {
|
||
purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND amount = 0').all(roomId);
|
||
} else if (itemName === '__NULL_NAME__') {
|
||
purchases = db.prepare("SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND (item IS NULL OR item = '' OR item = 'null' OR item = 'undefined')").all(roomId);
|
||
} else {
|
||
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) {
|
||
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : `"${itemName}"`);
|
||
storeAndBroadcastText(roomId, '小财', `没有找到与"${label}"相关的采购记录。`);
|
||
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, '小财', '当前房间没有采购记录,无需清空。'); return; }
|
||
storeAndBroadcastText(roomId, '小财', `⚠️ 即将清空当前房间的 ${count} 条采购数据,请回复“确认”继续,否则忽略。`);
|
||
addToHistory(roomId, 'assistant', `请求清空${count}条记录`);
|
||
setPendingAction(roomId, username, { type: 'clear', data: {} });
|
||
return;
|
||
}
|
||
if (aiResult.action === 'purchase') {
|
||
// 引用最近图片:用户要求把之前的图片关联到某采购
|
||
let usedRecentImage = false;
|
||
if (aiResult.use_recent_image) {
|
||
console.log('🔍 use_recent_image: 查找最近图片消息, roomId=', roomId);
|
||
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY timestamp DESC LIMIT 1").get(roomId);
|
||
console.log('🔍 最近消息:', recentMsg ? recentMsg.attachments : '(无)');
|
||
if (recentMsg) {
|
||
try {
|
||
const files = JSON.parse(recentMsg.attachments);
|
||
if (files.length) { attachments = files; usedRecentImage = true; console.log('✅ 引用图片:', files.length, '个'); }
|
||
} catch(e) { console.error('❌ 解析附件失败:', e); }
|
||
}
|
||
}
|
||
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}%`, '已完成');
|
||
console.log('📦 purchase_item:', aiResult.purchase_item, purchase ? '(已有)' : '(新建)');
|
||
let replyText = '';
|
||
const quantity = aiResult.quantity || 1;
|
||
const unitPrice = aiResult.unit_price || 0;
|
||
const freight = aiResult.freight || 0;
|
||
const amount = quantity * unitPrice + 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
|
||
};
|
||
if (aiResult.amount !== undefined && aiResult.amount !== null) {
|
||
// 始终根据数量、单价、邮费重新计算总金额,确保准确
|
||
// newVals.amount = aiResult.amount;
|
||
newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight;
|
||
} 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) {
|
||
console.log('📎 插入附件:', attachments.length, '个, purchase_id=', purchase.id);
|
||
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
|
||
attachments.forEach(fp => { console.log(' 📎', fp); insertAttach.run(purchase.id, fp, username, now); });
|
||
if (usedRecentImage) {
|
||
replyText = `📎 已为「${aiResult.purchase_item}」关联 ${attachments.length} 个附件`;
|
||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length} 个`, username, now);
|
||
console.log('✅ usedRecentImage 完成, replyText:', replyText);
|
||
}
|
||
} else if (usedRecentImage) {
|
||
replyText = `❌ 未找到最近的图片消息,请先发送图片再试。`;
|
||
console.log('⚠️ usedRecentImage 但附件为空');
|
||
}
|
||
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') { storeAndBroadcastText(roomId, '小财', aiResult.reply || '好的。'); addToHistory(roomId, 'assistant', aiResult.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}`));
|