From 494977d376542e01e2e7c6fd505366dd55795fd4 Mon Sep 17 00:00:00 2001 From: kicer Date: Wed, 22 Jul 2026 10:26:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=B0=8F=E8=B4=A2?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + docker-compose.yml | 23 ++ server/Dockerfile | 7 + server/db.js | 78 +++++ server/package.json | 14 + server/public/index.html | 675 +++++++++++++++++++++++++++++++++++++++ server/server.js | 396 +++++++++++++++++++++++ 7 files changed, 1195 insertions(+) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 server/Dockerfile create mode 100644 server/db.js create mode 100644 server/package.json create mode 100644 server/public/index.html create mode 100644 server/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a354493 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/data/ +/.env diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c0ebab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + xiaocai: + build: ./server + restart: unless-stopped + expose: + - "3000" # 仅内网暴露 + volumes: + - ./data:/app/data # 持久化数据 + - ./server/public:/app/public + environment: + - USERS=${USERS} + - ADMINS=${ADMINS} + - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} + - PORT=3000 + - VIRTUAL_HOST=ai.foresh.com + - VIRTUAL_PORT=3000 + - CERT_NAME=default + networks: + - my-app-network + +networks: + my-app-network: + external: true diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..3d8812f --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,7 @@ +FROM node:18-alpine +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..1fa596a --- /dev/null +++ b/server/db.js @@ -0,0 +1,78 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +// 确保数据目录存在 +const dataDir = '/app/data'; +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +const dbPath = path.join(dataDir, 'xiaocai.db'); +const db = new Database(dbPath); + + +// 启用 WAL 模式提升并发 +db.pragma('journal_mode = WAL'); + +// 创建表 +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password TEXT NOT NULL, + is_admin INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL, + white_list TEXT DEFAULT '', -- 逗号分隔用户名,空表示所有人 + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + user TEXT NOT NULL, + text TEXT, + attachments TEXT, -- JSON array of file paths + timestamp TEXT DEFAULT (datetime('now','localtime')), + FOREIGN KEY(room_id) REFERENCES rooms(id) + ); + + CREATE TABLE IF NOT EXISTS purchases ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + item TEXT, + amount REAL, + method TEXT, + status TEXT DEFAULT '待处理', + applicant TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT DEFAULT (datetime('now','localtime')), + ai_notes TEXT, + FOREIGN KEY(room_id) REFERENCES rooms(id) + ); + + CREATE TABLE IF NOT EXISTS purchase_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_id TEXT NOT NULL, + action TEXT NOT NULL, + user TEXT NOT NULL, + timestamp TEXT DEFAULT (datetime('now','localtime')), + attachments TEXT, + FOREIGN KEY(purchase_id) REFERENCES purchases(id) + ); + + CREATE TABLE IF NOT EXISTS purchase_attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_id TEXT NOT NULL, + file_path TEXT NOT NULL, + uploaded_by TEXT NOT NULL, + timestamp TEXT DEFAULT (datetime('now','localtime')), + FOREIGN KEY(purchase_id) REFERENCES purchases(id) + ); +`); + +module.exports = db; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..c449874 --- /dev/null +++ b/server/package.json @@ -0,0 +1,14 @@ +{ + "name": "xiaocai-ai", + "version": "1.0.0", + "description": "AI-powered internal chat with automatic purchase tracking", + "main": "server.js", + "dependencies": { + "express": "^4.18.2", + "ws": "^8.13.0", + "better-sqlite3": "^9.4.3", + "multer": "^1.4.5-lts.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^9.0.0" + } +} diff --git a/server/public/index.html b/server/public/index.html new file mode 100644 index 0000000..b0809f8 --- /dev/null +++ b/server/public/index.html @@ -0,0 +1,675 @@ + + + + + + 小财记账 + + + + +
+ + + + + + + + + + + +
+ + + + diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..8eda128 --- /dev/null +++ b/server/server.js @@ -0,0 +1,396 @@ +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 }, // 5MB + 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); +}); + +// 发送消息(统一入口) +app.post('/api/rooms/:roomId/messages', auth, (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); + broadcastToRoom(roomId, { + 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); +}); + +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 类型,消息通过 HTTP API 发送 + } catch (e) {} + }); + + ws.on('close', () => { + clients.delete(ws); + }); +}); + +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}` } + ]; + + 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(); + return JSON.parse(data.choices[0].message.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}`); +});