diff --git a/docs/v2.md b/docs/v2.md new file mode 100644 index 0000000..8f7ceca --- /dev/null +++ b/docs/v2.md @@ -0,0 +1,148 @@ +# 小财记账系统重构计划 + +> **文档状态**:讨论稿 +> **创建日期**:2026-07-22 +> **目的**:识别当前系统的设计缺陷与脆弱点,规划结构化改进方案,从“能用”走向“好用”。 + +--- + +## 一、当前系统现状概述 + +经过多轮迭代,系统已具备以下核心功能: + +- 多用户登录、群聊管理、白名单权限 +- 聊天消息发送(文本+图片)、WebSocket实时推送 +- AI自动识别采购/付款/发票意图,创建采购记录 +- 采购清单(按月汇总)、采购详情(含操作历史、附件) +- 删除/清空采购记录(管理员确认流程) +- 图片压缩上传、附件关联 +- 移动端滑动手势、双击全屏、输入中气泡 +- 导出CSV、全局采购汇总 + +**但所有功能都是“快速响应需求”的结果,缺乏统一设计,代码结构脆弱,修改一处容易引发多处问题。** + +--- + +## 二、当前系统的主要问题与重构方向 + +### 2.1 数据库与领域模型 + +**存在问题**: + +- `purchases` 表字段通过多次 `ALTER TABLE` 添加,历史数据可能格式不一致(如 `created_at` 有时间和无时间两种格式)。 +- 金额计算规则散落在多处代码中,存在“AI返回金额”、“系统自动计算金额”两套逻辑,容易出错。 +- 状态机不明确(待处理/已采购/发票已收/已完成),更新时未严格约束状态转换。 +- 删除/清空操作依赖业务层手动管理级联,SQLite外键未开启。 + +**改进方向**: + +1. **统一时间格式**:所有 `created_at` 和 `updated_at` 强制使用 `yyyy/MM/dd HH:mm:ss` 格式,在服务端工具函数中生成,不再依赖数据库默认值。 +2. **金额计算唯一化**:`amount` 字段仅由系统计算(`quantity * unit_price + freight`),AI 不再提供该字段。删除所有 AI 返回 `amount` 的处理逻辑。 +3. **定义采购状态机**:`待采购 → 已采购 → 发票已收 → 已完成`,更新状态时检查合法性。 +4. **开启外键约束**:在 `db.js` 中启用 `PRAGMA foreign_keys = ON;`,确保级联删除安全。 + +--- + +### 2.2 AI 交互层 + +**存在问题**: + +- Prompt 分散在 `analyzeWithDeepSeek` 函数中,规则越来越多,难以维护。 +- AI 返回的 JSON 格式不稳定,有时带 `reply` 有时不带,有时 `action` 与字段不匹配。 +- 上下文管理简陋:对话历史只保留最近 40 条,无摘要压缩,token 使用效率低。 +- 附件补充、删除确认、权限判断等场景,AI 表现不稳定,经常误判意图。 + +**改进方向**: + +1. **Prompt 模板化**:将 system prompt 拆分为多个模块(角色定义、意图分类、采购规则、删除规则、附件规则、权限规则),动态拼接。 +2. **JSON Schema 校验**:定义每种 `action` 对应的必选/可选字段,服务端在解析 AI 返回后立即校验,不符合的驳回或修正。 +3. **上下文管理升级**: + - 近期消息(30 条)保持完整 `messages` 数组。 + - 超过 30 条时,调用 AI 生成结构化摘要(当前采购清单),作为 `system` 消息注入。 +4. **附件与更新逻辑分离**: + - 附件请求仅返回 `purchase_item`,不包含金额/数量字段,后端强制匹配已有记录并插入附件。 + - 更新请求必须明确给出变化字段,后端对比后仅更新真正变化的列。 + +--- + +### 2.3 后端架构 + +**存在问题**: + +- 所有业务逻辑(AI 调用、数据库操作、权限判断)混在 `server.js` 单文件中,超过 600 行,难以维护。 +- 错误处理不统一:有的地方静默失败,有的地方只打印日志,用户得不到反馈。 +- 权限检查仅通过中间件 `adminOnly`,但删除/清空的权限还依赖 AI 判断,存在双重标准。 + +**改进方向**: + +1. **模块化拆分**: + - `routes/`:Express 路由(auth, rooms, messages, purchases, upload) + - `ai/`:Prompt 模板、AI 调用、结果解析与校验 + - `db/`:数据库操作封装(含迁移逻辑) + - `ws/`:WebSocket 管理(连接、房间、广播) +2. **统一错误处理中间件**:自定义错误类,全局捕获并返回标准 JSON 格式。 +3. **权限统一**:删除/清空操作仅在后端检查管理员权限,AI 不再参与权限判断(只负责识别意图)。 + +--- + +### 2.4 前端架构 + +**存在问题**: + +- 单文件 HTML 包含所有 CSS/JS,超过 800 行,难以维护。 +- 状态管理混乱:`messageCache`、`rooms`、`pendingFiles` 等全局变量散落,修改时容易出现不一致。 +- 消息渲染使用 `innerHTML` 直接拼接,存在 XSS 风险(虽然做了部分转义,但 Markdown 渲染绕过)。 +- 图片加载、滚动位置控制不稳定,经常出现自动跳转。 + +**改进方向**: + +1. **文件拆分**:CSS 独立文件、JS 按功能模块拆分(登录、聊天、清单、管理),使用 ES modules 或构建工具(如 esbuild)打包。 +2. **虚拟 DOM 或模板引擎**:考虑引入轻量框架(如 Preact 或 lit-html),避免手动拼接 HTML,同时解决 XSS 问题。 +3. **状态管理**:将所有全局状态集中到一个 `store` 对象,通过事件或 Proxy 驱动 UI 更新。 +4. **消息渲染优化**:图片加载完成后才调整滚动位置,防止跳动;使用懒加载。 + +--- + +### 2.5 安全性 + +**存在问题**: + +- JWT secret 使用随机字符串,但未设置过期刷新机制,长期有效。 +- 文件上传仅限制格式和大小,未限制上传频率,有被滥用的风险。 +- 用户密码在环境变量中明文存储,传输时未加密(仅 HTTPS 保护)。 + +**改进方向**: + +1. **JWT 刷新**:增加 refresh token 机制,短期 access token(1小时)+ 长期 refresh token(7天)。 +2. **上传频率限制**:使用 `express-rate-limit` 限制每用户每天上传次数。 +3. **密码哈希**:用户密码存储为 bcrypt 哈希,不再明文比较(需要修改登录逻辑和 `.env` 配置方式)。 + +--- + +### 2.6 运维与部署 + +**存在问题**: + +- 数据库迁移依赖手动 `ALTER TABLE` 加 `try/catch`,日志不完善,问题难排查。 +- 图片存储使用本地磁盘,容器重建后丢失(除非挂载卷)。 +- 无备份机制,数据风险高。 + +**改进方向**: + +1. **自动化迁移**:使用 `better-sqlite3` 的迁移工具或自定义版本管理系统,记录已执行迁移。 +2. **对象存储**:支持 S3 兼容存储(如 MinIO、阿里云 OSS)作为图片存储后端,本地仅做缓存。 +3. **定时备份**:通过 cronjob 定期导出 SQLite 数据库和上传目录。 + +--- + +## 三、优先重构顺序建议 + +| 优先级 | 模块 | 原因 | +|--------|------|------| +| **P0** | AI交互层(Prompt模板化、JSON Schema校验、附件逻辑分离) | 直接影响用户体验,当前最不稳定 | +| **P0** | 金额计算唯一化、时间格式统一 | 数据一致性基础,错误频发 | +| **P1** | 后端模块化拆分 | 为后续开发铺路,当前单文件难以维护 | +| **P1** | 权限模型统一 | 安全风险,目前依赖AI判断不可靠 | +| **P2** | 前端状态管理与文件拆分 | 改善开发体验和运行稳定性 | +| **P3** | 安全增强(JWT、密码哈希、上传限制) | 系统公开使用后必须处理 | +| **P3** | 运维自动化(备份、存储迁移) | 数据安全,长期运行保障 | diff --git a/server/ai/client.js b/server/ai/client.js new file mode 100644 index 0000000..fce9cee --- /dev/null +++ b/server/ai/client.js @@ -0,0 +1,19 @@ +// DeepSeek API 客户端 +const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || ''; + +async function callDeepSeek(messages, temperature = 0.1) { + 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, 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))); + } + return data.choices[0].message.content; +} + +module.exports = { callDeepSeek }; diff --git a/server/ai/prompt.js b/server/ai/prompt.js new file mode 100644 index 0000000..c7f308b --- /dev/null +++ b/server/ai/prompt.js @@ -0,0 +1,54 @@ +// AI Prompt 模板 — 模块化拼接 +function buildSystemPrompt(username, isAdmin) { + const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); + + const role = `你是智能财务助手"小财"。当前服务器时间:${now}。结合对话历史理解用户意图,只返回JSON。`; + + const intent = ` +意图分类: +- 采购/付款/发票相关:action="purchase",提取字段:purchase_item, quantity(默认1), unit_price(默认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"`; + + const permission = ` +⚠️ 权限规则: +- 当前用户${username},管理员状态:${isAdmin}。仅管理员可执行删除/清空操作。 +- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明"仅管理员可操作"。 +- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。`; + + const deleteRules = ` +⚠️ 删除规则: +- 特殊批量删除:delete_item="__AMOUNT_ZERO__"(金额为0)、"__NULL_NAME__"(空名称) +- 精确删除:用户指定数量/金额时同时返回 quantity/amount 字段,只匹配一条 +- 不要返回 action="ask" 处理删除请求`; + + const attachmentRules = ` +⚠️ 附件规则: +- 图片+物品名("这是XX的图片")→ 只返回 purchase_item,绝对不要 is_new/quantity/unit_price +- 引用历史图片("上面/前面/刚刚那张")→ use_recent_image: true +- 纯图片+物品名不是新建采购,系统自动关联附件`; + + const prohibitionRules = ` +⚠️ 绝对禁止: +- 图片+物品名请求 → 只能返回 purchase_item,禁止返回 is_new、quantity、unit_price、amount`; + + const purchaseRules = ` +⚠️ 采购规则: +- 只有明确消费意图(购买/采购/下单/付款/买了/花了)才返回 action="purchase" +- 纯陈述(如"前天deep")→ action="ignore" +- 信息不完整 → action="ask" 追问 +- 新建 vs 更新: + * "昨天/今天/又/再/新/另一批"等重复购买 → is_new: true(强制新建) + * "改一下/更新/修改/调整"等 → 不提供 is_new(匹配已有更新) + * "纯图片+物品名"不是新建,不要返回 is_new + * 无法确定 → 默认 is_new: true +- amount 由系统自动计算(quantity × unit_price + freight),AI 无需提供 +- 无法确定物品名 → action="ask" 追问`; + + return [role, intent, permission, deleteRules, attachmentRules, prohibitionRules, purchaseRules].join('\n'); +} + +module.exports = { buildSystemPrompt }; diff --git a/server/ai/validator.js b/server/ai/validator.js new file mode 100644 index 0000000..f2f92eb --- /dev/null +++ b/server/ai/validator.js @@ -0,0 +1,40 @@ +// JSON Schema 校验 — 验证 AI 返回的 JSON 结构 +const schemas = { + purchase: { required: ['purchase_item'], optional: ['quantity','unit_price','freight','payment_method','invoice_type','status','applicant','remarks','created_time','is_new','use_recent_image'] }, + query: { required: [], optional: [] }, + chat: { required: ['reply'], optional: [] }, + delete: { required: ['delete_item'], optional: ['quantity','amount'] }, + clear_all: { required: [], optional: [] }, + ignore: { required: [], optional: [] }, +}; + +function validate(aiResult) { + if (!aiResult || !aiResult.action) { + return { valid: false, error: '缺少 action 字段' }; + } + const schema = schemas[aiResult.action]; + if (!schema) { + return { valid: false, error: `未知 action: ${aiResult.action}` }; + } + for (const field of schema.required) { + if (aiResult[field] === undefined || aiResult[field] === null) { + return { valid: false, error: `action=${aiResult.action} 缺少必填字段: ${field}` }; + } + } + return { valid: true }; +} + +// 修复常见问题 +function normalize(aiResult) { + // action="ask" 兼容 reply/question + if (aiResult.action === 'ask') { + aiResult.question = aiResult.question || aiResult.reply || '请提供更多信息。'; + } + // chat 兼容 reply 为空 + if (aiResult.action === 'chat' && !aiResult.reply) { + aiResult.reply = '好的。'; + } + return aiResult; +} + +module.exports = { validate, normalize }; diff --git a/server/db.js b/server/db.js index 63ca632..2e606e1 100644 --- a/server/db.js +++ b/server/db.js @@ -9,9 +9,12 @@ if (!fs.existsSync(dataDir)) { const dbPath = path.join(dataDir, 'xiaocai.db'); const db = new Database(dbPath); -db.pragma('journal_mode = WAL'); -// 创建基础表(如果不存在) +// 关键 pragma +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// 创建基础表 db.exec(` CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, @@ -75,7 +78,7 @@ db.exec(` ); `); -// 为已有数据库添加可能缺失的列(安全迁移,忽略错误) +// 安全迁移(忽略已存在的列) const migrations = [ `ALTER TABLE purchases ADD COLUMN quantity INTEGER DEFAULT 1`, `ALTER TABLE purchases ADD COLUMN unit_price REAL DEFAULT 0`, @@ -85,13 +88,8 @@ const migrations = [ `ALTER TABLE purchases ADD COLUMN remarks TEXT DEFAULT ''`, `ALTER TABLE rooms ADD COLUMN white_list TEXT DEFAULT ''`, ]; - for (const sql of migrations) { - try { - db.exec(sql); - } catch (e) { - // 列已存在或其他错误,忽略 - } + try { db.exec(sql); } catch (e) { /* 列已存在 */ } } module.exports = db; diff --git a/server/handlers.js b/server/handlers.js new file mode 100644 index 0000000..98a0488 --- /dev/null +++ b/server/handlers.js @@ -0,0 +1,231 @@ +// AI 结果处理 + 共享状态 +const { v4: uuidv4 } = require('uuid'); +const db = require('../db'); +const { timestamp, normalizeTime } = require('../utils'); +const { broadcastToRoom } = require('../ws'); + +// 对话历史(内存) +const conversationHistory = new Map(); +function getHistory(roomId) { if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); return conversationHistory.get(roomId); } +function addToHistory(roomId, role, content) { const h = getHistory(roomId); h.push({ role, content }); if (h.length > 60) conversationHistory.set(roomId, h.slice(-40)); } + +// 待处理操作 +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 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 id 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; +} + +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; } +} + +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 { + let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?'; + const params = [roomId, `%${itemName}%`]; + if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); } + if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); } + console.log('🔍 删除查询:', query, params); + purchases = db.prepare(query).all(...params); + } + 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') { + const item = aiResult.purchase_item; + if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) { + console.log('🚫 拒绝无效 purchase_item:', JSON.stringify(item)); + storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。'); + addToHistory(roomId, 'assistant', '请提供具体的物品名称。'); + return; + } + console.log('🛒 purchase 分支, item:', item, 'attachments:', JSON.stringify(attachments)); + + 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 id DESC LIMIT 1").get(roomId); + 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 = normalizeTime(aiResult.created_time); + let isNew = aiResult.is_new === true; + let purchase; + if (isNew && attachments?.length > 0) { + const existing = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`); + if (existing) { console.log('🛡️ 兜底纠正: 转为更新模式'); isNew = false; purchase = existing; } + } + if (!isNew && !purchase) { + 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, isNew ? '(强制新建)' : 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 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 + }; + newVals.amount = newVals.quantity * newVals.unit_price + 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); }); + db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length} 个`, username, now); + if (replyText.includes('没有发生') || usedRecentImage) { + replyText = `📎 已为「${aiResult.purchase_item}」添加 ${attachments.length} 个附件`; + } + console.log('✅ 附件完成, replyText:', replyText); + } else if (usedRecentImage) { + replyText = '❌ 未找到最近的图片消息,请先发送图片再试。'; + } + + 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 { callDeepSeek } = require('../ai/client'); + const messages = [ + { role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' }, + { role: 'user', content: prompt } + ]; + return callDeepSeek(messages, 0.3); +} + +module.exports = { + getHistory, addToHistory, + hasPendingAction, setPendingAction, clearPendingAction, + handleAIResult, executePendingAction, storeAndBroadcastText +}; diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..ec45a48 --- /dev/null +++ b/server/middleware/auth.js @@ -0,0 +1,23 @@ +// 认证与授权中间件 +const jwt = require('jsonwebtoken'); +const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-2024'; + +function authMiddleware(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: '登录已过期' }); + } +} + +function adminOnly(req, res, next) { + if (!req.user.isAdmin) return res.status(403).json({ error: '仅管理员可执行此操作' }); + next(); +} + +module.exports = { authMiddleware, adminOnly, JWT_SECRET }; diff --git a/server/public/css/style.css b/server/public/css/style.css new file mode 100644 index 0000000..ef308a2 --- /dev/null +++ b/server/public/css/style.css @@ -0,0 +1,111 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; height: 100vh; display: flex; justify-content: center; align-items: center; } +.app { width: 100%; max-width: 420px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); position: relative; } +.hidden { display: none !important; } + +.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; } +.header-left { display: flex; align-items: center; gap: 8px; } +.header-left h2 { font-size: 18px; font-weight: 500; } +.icon-btn { background: none; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; border-radius: 50%; } +.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + +#create-room-btn { visibility: hidden; } +#create-room-btn.visible { visibility: visible; } + +.chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; } +.chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; } +.chat-item:active { background: #f9f9f9; } +.avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; } +.chat-info { flex: 1; min-width: 0; } +.chat-name { font-size: 16px; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.chat-right { display: flex; flex-direction: column; align-items: flex-end; margin-left: 8px; flex-shrink: 0; min-width: 56px; } +.chat-time { font-size: 12px; color: #aaa; white-space: nowrap; } +.room-actions { display: flex; gap: 4px; margin-top: 4px; } +.edit-room-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; padding: 2px; } +.edit-room-btn svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; } + +.summary-card { background: #f0f7ff; margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } +.summary-card h4 { font-size: 16px; font-weight: 500; } +.summary-card .summary-preview { font-size: 14px; color: #555; } + +.chat-window { display: flex; flex-direction: column; height: 100%; } +.chat-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; align-items: center; min-height: 48px; } +.chat-header .back-btn { margin-right: 12px; } +.chat-header .chat-title { font-size: 17px; font-weight: 500; flex: 1; } +.messages { flex: 1; overflow-y: auto; padding: 12px; background: #fafafa; } +.msg { margin-bottom: 12px; display: flex; flex-direction: column; } +.msg.me { align-items: flex-end; } +.msg-bubble { max-width: 75%; padding: 10px 14px; border-radius: 20px; font-size: 16px; line-height: 1.5; word-wrap: break-word; overflow-wrap: break-word; cursor: pointer; } +.msg.me .msg-bubble { background: #d1f0d1; border-bottom-right-radius: 4px; } +.msg.other .msg-bubble { background: #fff; border: 1px solid #eee; border-bottom-left-radius: 4px; } +.msg-user { font-size: 12px; color: #888; margin-bottom: 2px; margin-left: 4px; } +.msg.me .msg-user { text-align: right; margin-right: 4px; } +.msg-time { font-size: 11px; color: #aaa; margin-top: 2px; } +.msg-bubble h1, .msg-bubble h2, .msg-bubble h3 { font-size: 16px; margin: 5px 0; } +.msg-bubble table { width: 100%; border-collapse: collapse; margin: 8px 0; } +.msg-bubble th, .msg-bubble td { border: 1px solid #ddd; padding: 4px; text-align: left; font-size: 14px; } +.msg-bubble th { background: #f0f0f0; } +.msg-bubble pre { background: #f0f0f0; padding: 8px; border-radius: 4px; overflow-x: auto; } +.msg-bubble code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-size: 14px; } + +.input-area { padding: 8px 12px; border-top: 1px solid #eee; display: flex; align-items: center; background: #fff; gap: 8px; } +.input-area textarea { flex: 1; border: 1px solid #ddd; border-radius: 12px; padding: 10px 12px; font-size: 16px; outline: none; resize: none; min-height: 40px; max-height: 120px; overflow-y: hidden; line-height: 1.4; } +.file-upload-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; } +.file-upload-btn svg { width: 24px; height: 24px; stroke: #666; fill: none; stroke-width: 2; } +.send-btn { background: #3b82f6; border: none; color: #fff; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; } +.send-btn svg { width: 18px; height: 18px; fill: #fff; } + +.purchase-panel-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #fff; z-index: 10; display: flex; flex-direction: column; } +.purchase-panel-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; } +.purchase-panel-body { flex: 1; overflow-y: auto; padding: 12px; } +.purchase-month-group { margin-bottom: 20px; } +.purchase-month-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; } +.purchase-item { padding: 12px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; } +.purchase-item:active { background: #f9f9f9; } +.purchase-item .item-main { display: flex; justify-content: space-between; align-items: baseline; } +.purchase-item .item-name { font-size: 16px; font-weight: 500; } +.purchase-item .item-amount { font-size: 16px; font-weight: 600; } +.purchase-item .item-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 13px; color: #888; flex-wrap: wrap; gap: 4px; } +.status-badge { background: #fef3c7; padding: 1px 6px; border-radius: 8px; font-size: 12px; } +.status-badge.done { background: #d1fae5; } + +.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 100; } +.modal { background: #fff; width: 90%; max-width: 400px; max-height: 80vh; border-radius: 12px; overflow: auto; padding: 16px; position: relative; } +.modal .modal-close-btn { position: absolute; top: 10px; right: 12px; background: none; border: none; cursor: pointer; } +.modal .modal-close-btn svg { width: 20px; height: 20px; stroke: #888; fill: none; stroke-width: 2; } +.modal h3 { margin-bottom: 12px; font-size: 18px; padding-right: 30px; } +.modal input { width: 100%; padding: 8px; margin: 8px 0; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; } +.modal button { margin-right: 8px; padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 16px; } +.modal .primary-btn { background: #3b82f6; color: #fff; } +.history-list { list-style: none; font-size: 14px; } +.history-list li { padding: 6px 0; border-bottom: 1px solid #f0f0f0; display: flex; } +.history-time { min-width: 80px; color: #888; margin-right: 10px; font-size: 13px; } +.img-thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin: 4px; border: 1px solid #eee; } +.attachments { display: flex; flex-wrap: wrap; margin: 8px 0; } +.msg-img { max-width: 200px; border-radius: 8px; margin: 4px 0; } +.msg-placeholder .msg-bubble { opacity: 0.6; } + +.version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; } + +.fullscreen-overlay { + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; + z-index: 200; cursor: pointer; +} +.fullscreen-overlay img { max-width: 100%; max-height: 100%; object-fit: contain; } +.fullscreen-overlay .fullscreen-text { + background: #fff; padding: 20px; border-radius: 8px; max-width: 90%; max-height: 80%; + overflow: auto; font-size: 16px; line-height: 1.5; color: #333; +} +.fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; } +.fullscreen-overlay .fullscreen-text table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; +} +.fullscreen-overlay .fullscreen-text th, +.fullscreen-overlay .fullscreen-text td {border: 1px solid #ddd;padding: 4px;text-align: left;font-size: 14px;} +.fullscreen-overlay .fullscreen-text th {background: #f0f0f0;} + +.detail-row { margin-bottom: 8px; } diff --git a/server/public/index.html b/server/public/index.html index b17d8f4..ce1801f 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -8,119 +8,7 @@ - +
@@ -132,7 +20,7 @@

-
v2.5
+
v2.6
@@ -151,7 +39,7 @@ - @@ -188,7 +76,6 @@ - - + - - + + + + + diff --git a/server/public/js/auth.js b/server/public/js/auth.js new file mode 100644 index 0000000..2ec09fd --- /dev/null +++ b/server/public/js/auth.js @@ -0,0 +1,54 @@ +// 认证与初始化 +async function init() { + const token = Store.getToken(); + if (token) { + try { + const res = await fetch('/api/user', { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) { + const user = await res.json(); + document.getElementById('login-page').classList.add('hidden'); + document.getElementById('main-page').classList.remove('hidden'); + if (localStorage.getItem('isAdmin') === 'true') { + document.getElementById('create-room-btn').classList.add('visible'); + } else { + document.getElementById('create-room-btn').classList.remove('visible'); + } + initWebSocket(); + loadRooms(); + updateSummaryPreview(); + initSwipeGestures(); + return; + } + } catch(e) {} + Store.clearAuth(); + } + document.getElementById('login-page').classList.remove('hidden'); + document.getElementById('main-page').classList.add('hidden'); +} + +async function login() { + const u = document.getElementById('login-user').value; + const p = document.getElementById('login-pass').value; + const res = await fetch('/api/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u, password: p }) + }); + if (res.ok) { + const data = await res.json(); + Store.saveAuth(data); + document.getElementById('login-page').classList.add('hidden'); + document.getElementById('main-page').classList.remove('hidden'); + if (data.isAdmin) { + document.getElementById('create-room-btn').classList.add('visible'); + } else { + document.getElementById('create-room-btn').classList.remove('visible'); + } + initWebSocket(); + loadRooms(); + updateSummaryPreview(); + initSwipeGestures(); + } else { + const err = await res.json(); + document.getElementById('login-error').textContent = err.error || '登录失败'; + } +} diff --git a/server/public/js/chat.js b/server/public/js/chat.js new file mode 100644 index 0000000..34f1f98 --- /dev/null +++ b/server/public/js/chat.js @@ -0,0 +1,167 @@ +// 工具函数 +if (typeof marked !== 'undefined') marked.setOptions({ breaks: true, gfm: true, sanitize: false }); + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function compressImage(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width, height = img.height; + if (width > 1200) { height = height * (1200 / width); width = 1200; } + canvas.width = width; canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); + }); +} + +// 消息渲染 +const messagesContainer = document.getElementById('messages-container'); + +function renderMessageHTML(msg) { + const currentUser = localStorage.getItem('currentUser'); + const isMe = msg.user === currentUser; + const name = msg.user === '小财' ? '小财' : msg.user; + let contentHtml = msg.text ? escapeHtml(msg.text) : ''; + if (msg.user === '小财' && typeof marked !== 'undefined' && msg.text) { + try { contentHtml = marked.parse(msg.text); } catch(e) {} + } + let attachHtml = ''; + if (msg.attachments && msg.attachments.length) { + try { + const files = typeof msg.attachments === 'string' ? JSON.parse(msg.attachments) : msg.attachments; + attachHtml = files.map(f => { + const isImage = /\.(jpg|jpeg|png|gif)$/i.test(f); + if (isImage) return `${f}`; + return `
📎 ${f.split('/').pop()}
`; + }).join(''); + } catch(e) {} + } + return ` +
+
${name}
+
${contentHtml}${attachHtml}
+
${msg.timestamp}
+
`; +} + +function renderAllMessages(msgs) { + Store.messageCache = msgs || []; + messagesContainer.innerHTML = Store.messageCache.map(renderMessageHTML).join(''); + scrollToBottom(); + bindMessageEvents(); +} + +function appendMessage(msg) { + Store.messageCache.push(msg); + messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg)); + scrollToBottom(); + bindMessageEvents(); + clearTempBubble(); +} + +function replaceMessage(msgId, newMsg) { + const idx = Store.messageCache.findIndex(m => m.id == msgId); + if (idx !== -1) { + Store.messageCache[idx] = newMsg; + const oldEl = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`); + if (oldEl) oldEl.outerHTML = renderMessageHTML(newMsg); + } else { + appendMessage(newMsg); + } + bindMessageEvents(); +} + +function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; } + +// 图片/气泡双击 +function bindMessageEvents() { + document.querySelectorAll('.msg-img').forEach(img => { + img.removeEventListener('dblclick', onImageDblClick); + img.addEventListener('dblclick', onImageDblClick); + }); + document.querySelectorAll('.msg-bubble').forEach(bubble => { + bubble.removeEventListener('dblclick', onBubbleDblClick); + bubble.addEventListener('dblclick', onBubbleDblClick); + }); +} + +function onImageDblClick(e) { + e.stopPropagation(); + const overlay = document.createElement('div'); + overlay.className = 'fullscreen-overlay'; + overlay.innerHTML = ``; + overlay.addEventListener('click', () => overlay.remove()); + document.body.appendChild(overlay); +} + +function onImgThumbDblClick(img) { + const overlay = document.createElement('div'); + overlay.className = 'fullscreen-overlay'; + overlay.innerHTML = ``; + overlay.addEventListener('click', () => overlay.remove()); + document.body.appendChild(overlay); +} + +function onBubbleDblClick(e) { + e.stopPropagation(); + const bubble = e.currentTarget; + const clone = bubble.cloneNode(true); + const overlay = document.createElement('div'); + overlay.className = 'fullscreen-overlay'; + const container = document.createElement('div'); + container.className = 'fullscreen-text'; + container.innerHTML = clone.innerHTML; + overlay.appendChild(container); + overlay.addEventListener('click', () => overlay.remove()); + document.body.appendChild(overlay); +} + +// 临时气泡 + 降级轮询 +function insertTempBubble() { + clearTempBubble(); + const tempId = 'temp_' + Date.now(); + const tempMsg = { id: tempId, user: '小财', text: '输入中...', attachments: [], timestamp: '', isTemp: true }; + Store.messageCache.push(tempMsg); + messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg)); + scrollToBottom(); + Store.tempBubbleTimer = setTimeout(() => { clearTempBubble(); }, 8000); + if (Store.pollTimer) clearInterval(Store.pollTimer); + let pollCount = 0; + Store.pollTimer = setInterval(async () => { + pollCount++; + if (!Store.currentRoom || pollCount > 5) { clearInterval(Store.pollTimer); Store.pollTimer = null; return; } + try { + const token = Store.getToken(); + const res = await fetch('/api/rooms/' + Store.currentRoom + '/messages', { headers: { 'Authorization': `Bearer ${token}` } }); + const msgs = await res.json(); + const lastCacheId = Store.messageCache.filter(m => !m.isTemp && !m.isPlaceholder).slice(-1)[0]?.id; + const newMsgs = msgs.filter(m => !lastCacheId || m.id > lastCacheId); + if (newMsgs.length) { + clearInterval(Store.pollTimer); Store.pollTimer = null; + clearTempBubble(); + newMsgs.forEach(m => appendMessage(m)); + } + } catch(e) {} + }, 2000); +} + +function clearTempBubble() { + if (Store.tempBubbleTimer) { clearTimeout(Store.tempBubbleTimer); Store.tempBubbleTimer = null; } + if (Store.pollTimer) { clearInterval(Store.pollTimer); Store.pollTimer = null; } + Store.messageCache = Store.messageCache.filter(m => !m.isTemp); + const tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]'); + tempEls.forEach(el => el.remove()); +} diff --git a/server/public/js/purchases.js b/server/public/js/purchases.js new file mode 100644 index 0000000..6b41f74 --- /dev/null +++ b/server/public/js/purchases.js @@ -0,0 +1,182 @@ +// 采购相关功能 +async function loadPurchases() { + if (!Store.currentRoom) return; + const token = Store.getToken(); + const res = await fetch('/api/rooms/' + Store.currentRoom + '/purchases', { headers: { 'Authorization': `Bearer ${token}` } }); + const items = await res.json(); + renderPurchasePanel(items); +} + +function renderPurchasePanel(items) { + const container = document.getElementById('purchase-panel-body'); + if (items.length === 0) { container.innerHTML = '

暂无采购记录

'; return; } + const groups = {}; + items.forEach(item => { + const month = formatMonth(item.created_at); + if (!groups[month]) groups[month] = []; + groups[month].push(item); + }); + let html = ''; + Object.keys(groups).sort().reverse().forEach(month => { + const monthItems = groups[month]; + const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0); + html += `
${month}¥${monthTotal}
`; + monthItems.forEach(item => { + const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || ''; + const dateShort = item.created_at.split(' ')[0]?.substring(5) || ''; + html += ` +
+
${escapeHtml(item.item)}¥${item.amount}
+
+ ${dateShort} ${timeShort} + ×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''} + ${item.status} +
+
`; + }); + html += '
'; + }); + container.innerHTML = html; +} + +function formatMonth(dateStr) { + const clean = dateStr.replace(/-/g, '/'); + const parts = clean.split('/'); + if (parts.length >= 2) return `${parts[0]}年${parseInt(parts[1], 10)}月`; + return dateStr.substring(0,7); +} + +function openPurchasePanel() { + document.getElementById('purchase-panel-body').innerHTML = '

加载中...

'; + document.getElementById('purchase-panel-overlay').classList.remove('hidden'); + loadPurchases(); +} + +function closePurchasePanel() { document.getElementById('purchase-panel-overlay').classList.add('hidden'); } + +async function openDetail(pid) { + const token = Store.getToken(); + const res = await fetch('/api/purchases/' + pid, { headers: { 'Authorization': `Bearer ${token}` } }); + const p = await res.json(); + document.getElementById('detail-title').textContent = p.item; + let attachHtml = ''; + if (p.attachments?.length) attachHtml = '
' + p.attachments.map(a => `${a.file_path}`).join('') + '
'; + const historyHtml = p.history?.map(h => `
  • ${h.timestamp} ${h.user}: ${h.action}
  • `).join('') || ''; + document.getElementById('detail-content').innerHTML = ` +
    数量:${p.quantity}
    +
    单价:¥${p.unit_price}
    +
    邮费:¥${p.freight}
    +
    总金额:¥${p.amount}
    +
    付款方式:${p.payment_method || '未指定'}
    +
    发票:${p.invoice_type || '无票'}
    +
    状态:${p.status}
    +
    申请人:${p.applicant}
    +
    采购时间:${p.created_at}
    +
    备注:${p.remarks || '无'}
    + ${attachHtml} +

    操作历史

    + `; + document.getElementById('detail-modal').classList.remove('hidden'); +} + +function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); } + +async function openSummary() { + const token = Store.getToken(); + const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } }); + const summary = await res.json(); + let html = ''; + if (summary.length === 0) html = '

    暂无采购记录

    '; + else { + summary.forEach(s => { + html += `

    ${escapeHtml(s.room_name)}

    ${s.purchase_count} 条,合计 ¥${s.total_amount}

    `; + }); + } + document.getElementById('detail-title').textContent = '采购汇总'; + document.getElementById('detail-content').innerHTML = html; + document.getElementById('detail-modal').classList.remove('hidden'); +} + +// 群聊管理 +function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); } +function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); } +async function confirmCreateRoom() { + const name = document.getElementById('new-room-name').value.trim(); + if (!name) return alert('请输入名称'); + const whiteList = document.getElementById('white-list').value.trim(); + const token = Store.getToken(); + await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) }); + closeCreateModal(); + loadRooms(); +} + +function openManageRoom(roomId) { + const room = Store.rooms.find(r => r.id === roomId); + if (!room) return; + document.getElementById('manage-title').textContent = '编辑群聊'; + document.getElementById('manage-room-name').value = room.name; + document.getElementById('manage-white-list').value = room.white_list || ''; + document.getElementById('manage-modal').dataset.roomId = roomId; + document.getElementById('manage-modal').classList.remove('hidden'); +} + +function closeManageModal() { document.getElementById('manage-modal').classList.add('hidden'); } + +async function saveRoom() { + const roomId = document.getElementById('manage-modal').dataset.roomId; + const name = document.getElementById('manage-room-name').value.trim(); + const whiteList = document.getElementById('manage-white-list').value.trim(); + if (!name) return alert('请输入群聊名称'); + const token = Store.getToken(); + const res = await fetch('/api/rooms/' + roomId, { + method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, whiteList }) + }); + if (res.ok) { closeManageModal(); loadRooms(); } + else { const err = await res.json(); alert(err.error || '更新失败'); } +} + +async function deleteRoom() { + if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return; + const roomId = document.getElementById('manage-modal').dataset.roomId; + const token = Store.getToken(); + const res = await fetch('/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) { + closeManageModal(); + loadRooms(); + if (Store.currentRoom === roomId) showList(); + } else { const err = await res.json(); alert(err.error || '删除失败'); } +} + +// 滑动手势 +function initSwipeGestures() { + const chatPage = document.getElementById('chat-page'); + const purchaseOverlay = document.getElementById('purchase-panel-overlay'); + let startX = 0, startY = 0; + + function handleTouchStart(e) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; } + + function handleTouchEnd(e, target) { + if (!startX) return; + const endX = e.changedTouches[0].clientX; + const endY = e.changedTouches[0].clientY; + const diffX = endX - startX; + const diffY = endY - startY; + if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) { + if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) { + if (Math.abs(diffX) > 30) closePurchasePanel(); + } else if (target === chatPage) { + if (diffX > 30) showList(); + else if (diffX < -30) openPurchasePanel(); + } else if (target === purchaseOverlay) { + if (Math.abs(diffX) > 30) closePurchasePanel(); + } + } + startX = 0; startY = 0; + } + + chatPage.addEventListener('touchstart', handleTouchStart, { passive: true }); + chatPage.addEventListener('touchend', (e) => handleTouchEnd(e, chatPage), { passive: true }); + purchaseOverlay.addEventListener('touchstart', handleTouchStart, { passive: true }); + purchaseOverlay.addEventListener('touchend', (e) => handleTouchEnd(e, purchaseOverlay), { passive: true }); +} diff --git a/server/public/js/store.js b/server/public/js/store.js new file mode 100644 index 0000000..4d08421 --- /dev/null +++ b/server/public/js/store.js @@ -0,0 +1,36 @@ +// 全局状态管理 +const Store = { + ws: null, + currentRoom: null, + rooms: [], + pendingUploads: [], + messageCache: [], + tempBubbleTimer: null, + wsReconnectTimer: null, + reconnectAttempts: 0, + pollTimer: null, + + MAX_RECONNECT_DELAY: 30000, + + getToken() { return localStorage.getItem('token'); }, + saveAuth(data) { + localStorage.setItem('token', data.token); + localStorage.setItem('currentUser', data.username); + localStorage.setItem('isAdmin', data.isAdmin); + }, + clearAuth() { + localStorage.removeItem('token'); + localStorage.removeItem('currentUser'); + localStorage.removeItem('isAdmin'); + }, + logout() { + if (Store.ws) { Store.ws.close(); Store.ws = null; } + Store.clearAuth(); + document.getElementById('login-page').classList.remove('hidden'); + document.getElementById('main-page').classList.add('hidden'); + Store.currentRoom = null; + Store.rooms = []; + document.getElementById('chat-list-container').innerHTML = ''; + document.getElementById('messages-container').innerHTML = ''; + }, +}; diff --git a/server/public/js/ws.js b/server/public/js/ws.js new file mode 100644 index 0000000..abe5da0 --- /dev/null +++ b/server/public/js/ws.js @@ -0,0 +1,180 @@ +// WebSocket 连接管理 +function initWebSocket() { + const token = Store.getToken(); + if (!token) return; + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + Store.ws = new WebSocket(`${protocol}//${location.host}?token=${token}`); + Store.ws.onopen = () => { + console.log('WebSocket 已连接'); + Store.reconnectAttempts = 0; + if (Store.currentRoom) Store.ws.send(JSON.stringify({ type: 'join', roomId: Store.currentRoom })); + }; + Store.ws.onmessage = (e) => { + const data = JSON.parse(e.data); + console.log('📩 WS 收到:', data.type); + if (data.type === 'new_message') { + const msg = data.message; + if (Store.currentRoom === msg.room_id) appendMessage(msg); + if (data.room_preview) { + const room = Store.rooms.find(r => r.id === data.room_preview.room_id); + if (room) { room.last_message = data.room_preview.last_message; room.last_time = data.room_preview.last_time; renderChatList(); } + } + } else if (data.type === 'purchase_updated') { + if (Store.currentRoom) loadPurchases(); + updateSummaryPreview(); + } else if (data.type === 'room_created') { + loadRooms(); + } else if (data.type === 'room_updated') { + const room = Store.rooms.find(r => r.id === data.room.id); + if (room) { room.name = data.room.name; room.white_list = data.room.white_list; renderChatList(); } + } else if (data.type === 'room_deleted') { + Store.rooms = Store.rooms.filter(r => r.id !== data.roomId); + renderChatList(); + if (Store.currentRoom === data.roomId) showList(); + } + }; + Store.ws.onerror = (e) => console.error('WebSocket 错误', e); + Store.ws.onclose = (event) => { + console.log('WebSocket 关闭,代码:', event.code); + if (event.code === 1000) return; + scheduleReconnect(); + }; +} + +function scheduleReconnect() { + if (Store.wsReconnectTimer) clearTimeout(Store.wsReconnectTimer); + const delay = Math.min(1000 * Math.pow(2, Store.reconnectAttempts), Store.MAX_RECONNECT_DELAY); + Store.reconnectAttempts++; + Store.wsReconnectTimer = setTimeout(() => { console.log('尝试重连...'); initWebSocket(); }, delay); +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + if (!Store.ws || Store.ws.readyState !== WebSocket.OPEN) { + if (Store.wsReconnectTimer) clearTimeout(Store.wsReconnectTimer); + initWebSocket(); + } + } +}); + +// 聊天列表 +async function loadRooms() { + const token = Store.getToken(); + const res = await fetch('/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.status === 401) { Store.clearAuth(); location.reload(); return; } + Store.rooms = await res.json(); + renderChatList(); +} + +function renderChatList() { + const container = document.getElementById('chat-list-container'); + const isAdmin = localStorage.getItem('isAdmin') === 'true'; + container.innerHTML = Store.rooms.map(room => { + const lastMsg = room.last_message || '暂无消息'; + const lastTime = room.last_time || ''; + return ` +
    +
    ${escapeHtml(room.name.charAt(0))}
    +
    +
    ${escapeHtml(room.name)}
    +
    ${escapeHtml(lastMsg)}
    +
    +
    +
    ${lastTime}
    + ${isAdmin ? `
    + +
    ` : ''} +
    +
    `; + }).join(''); +} + +async function openChat(roomId, autoOpenPurchase = false) { + Store.currentRoom = roomId; + clearTempBubble(); + document.getElementById('list-page').classList.add('hidden'); + document.getElementById('chat-page').classList.remove('hidden'); + const room = Store.rooms.find(r => r.id === roomId); + document.getElementById('current-chat-name').textContent = room?.name || ''; + document.getElementById('purchase-panel-overlay').classList.add('hidden'); + if (autoOpenPurchase) setTimeout(() => openPurchasePanel(), 200); + const token = Store.getToken(); + const res = await fetch('/api/rooms/' + roomId + '/messages', { headers: { 'Authorization': `Bearer ${token}` } }); + const msgs = await res.json(); + renderAllMessages(msgs); + if (Store.ws && Store.ws.readyState === WebSocket.OPEN) Store.ws.send(JSON.stringify({ type: 'join', roomId })); +} + +function showList() { + clearTempBubble(); + document.getElementById('chat-page').classList.add('hidden'); + document.getElementById('list-page').classList.remove('hidden'); + Store.currentRoom = null; + loadRooms(); + updateSummaryPreview(); +} + +async function sendMessage() { + const input = document.getElementById('msg-input'); + const text = input.value.trim(); + if (!text && Store.pendingUploads.length === 0) return; + if (!Store.currentRoom) return; + const token = Store.getToken(); + try { + const res = await fetch('/api/rooms/' + Store.currentRoom + '/messages', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: text || '', attachments: Store.pendingUploads }) + }); + const newMsg = await res.json(); + appendMessage(newMsg); + input.value = ''; + Store.pendingUploads = []; + insertTempBubble(); + } catch(e) { alert('发送失败,请重试'); } +} + +async function handleFileSelect(event) { + const files = event.target.files; + if (!files.length) return; + const token = Store.getToken(); + const placeholderId = 'placeholder_' + Date.now(); + const placeholderMsg = { + id: placeholderId, room_id: Store.currentRoom, user: localStorage.getItem('currentUser'), + text: '图片发送中...', attachments: [], timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true + }; + appendMessage(placeholderMsg); + try { + for (let file of files) { + let blob = file; + if (file.size > 1 * 1024 * 1024) blob = await compressImage(file); + const formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg'); + const res = await fetch('/api/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); + const data = await res.json(); + if (data.path) Store.pendingUploads.push(data.path); + } + Store.messageCache = Store.messageCache.filter(m => m.id !== placeholderId); + const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`); + if (placeholderEl) placeholderEl.remove(); + sendMessage(); + } catch(e) { + Store.messageCache = Store.messageCache.filter(m => m.id !== placeholderId); + const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`); + if (placeholderEl) placeholderEl.remove(); + alert('图片发送失败,请重试'); + } +} + +async function updateSummaryPreview() { + const token = Store.getToken(); + try { + const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } }); + const summary = await res.json(); + const totalItems = summary.reduce((s, r) => s + r.purchase_count, 0); + const totalAmount = summary.reduce((s, r) => s + r.total_amount, 0); + const preview = document.getElementById('summary-preview'); + if (preview) preview.innerText = `共 ${totalItems} 条,合计 ¥${totalAmount}`; + } catch(e) {} +} diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..5655782 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,27 @@ +// 用户认证路由 +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { JWT_SECRET } = require('../middleware/auth'); +const router = express.Router(); + +const USERS = {}; +(process.env.USERS || '').split(',').forEach(u => { + const [name, pass] = u.split(':').map(s => s.trim()); + if (name && pass) USERS[name] = pass; +}); +const ADMINS = (process.env.ADMINS || '').split(',').map(s => s.trim()).filter(Boolean); + +router.post('/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: '用户名或密码错误' }); +}); + +router.get('/user', require('../middleware/auth').authMiddleware, (req, res) => { + res.json({ username: req.user.username, isAdmin: req.user.isAdmin }); +}); + +module.exports = router; diff --git a/server/routes/messages.js b/server/routes/messages.js new file mode 100644 index 0000000..9691eb5 --- /dev/null +++ b/server/routes/messages.js @@ -0,0 +1,70 @@ +// 消息路由 + AI 分析 +const express = require('express'); +const { authMiddleware } = require('../middleware/auth'); +const { broadcastToRoomExcludeSelf } = require('../ws'); +const { buildSystemPrompt } = require('../ai/prompt'); +const { validate, normalize } = require('../ai/validator'); +const { callDeepSeek } = require('../ai/client'); +const { getHistory, addToHistory, hasPendingAction, handleAIResult, executePendingAction } = require('../handlers'); +const db = require('../db'); +const { timestamp } = require('../utils'); + +const router = express.Router(); + +router.get('/:roomId/messages', authMiddleware, (req, res) => { + const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY id ASC').all(req.params.roomId); + res.json(msgs); +}); + +router.post('/:roomId/messages', authMiddleware, async (req, res) => { + const { text, attachments } = req.body; + console.log('📨 收到消息, text:', (text||'').substring(0,30), 'attachments:', JSON.stringify(attachments)); + 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 id 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); } +}); + +async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) { + const systemPrompt = buildSystemPrompt(username, isAdmin); + const messages = [ + { role: 'system', content: systemPrompt }, + ...historyMessages.slice(-30), + { role: 'user', content: `${username}: ${text}` } + ]; + const content = await callDeepSeek(messages, 0.1); + console.log('🤖 AI 返回:', content); + const parsed = JSON.parse(content.replace(/```json|```/g, '').trim()); + normalize(parsed); + const v = validate(parsed); + if (!v.valid) console.warn('⚠️ AI 返回校验失败:', v.error); + return parsed; +} + +module.exports = router; diff --git a/server/routes/purchases.js b/server/routes/purchases.js new file mode 100644 index 0000000..0bf5364 --- /dev/null +++ b/server/routes/purchases.js @@ -0,0 +1,48 @@ +// 采购相关路由 +const express = require('express'); +const { authMiddleware } = require('../middleware/auth'); +const db = require('../db'); + +const router = express.Router(); + +router.get('/rooms/:roomId/purchases', authMiddleware, (req, res) => { + const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(req.params.roomId); + res.json(purchases); +}); + +router.get('/purchases/:id', authMiddleware, (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 id 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 }); +}); + +router.get('/summary', authMiddleware, (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); +}); + +router.get('/rooms/:roomId/purchases/export', authMiddleware, (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); +}); + +module.exports = router; diff --git a/server/routes/rooms.js b/server/routes/rooms.js new file mode 100644 index 0000000..1eaa9c8 --- /dev/null +++ b/server/routes/rooms.js @@ -0,0 +1,57 @@ +// 群聊管理路由 +const express = require('express'); +const { v4: uuidv4 } = require('uuid'); +const { authMiddleware, adminOnly } = require('../middleware/auth'); +const { broadcast } = require('../ws'); +const db = require('../db'); +const { timestamp } = require('../utils'); + +const router = express.Router(); + +router.get('/', authMiddleware, (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 id DESC LIMIT 1').get(room.id); + return { ...room, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }; + }); + res.json(result); +}); + +router.post('/', authMiddleware, 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 }); +}); + +router.put('/:roomId', authMiddleware, 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 }); +}); + +router.delete('/:roomId', authMiddleware, 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 }); +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js index 9d72852..785729a 100644 --- a/server/server.js +++ b/server/server.js @@ -1,588 +1,61 @@ +// 入口文件 — 组装所有模块 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 wsModule = require('./ws'); +const { authMiddleware } = require('./middleware/auth'); 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(); +// 中间件 +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); -// 时区 -const TIMEZONE = 'Asia/Shanghai'; -function timestamp() { - return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false }); -} -function normalizeTime(str) { - if (!str) return timestamp(); - // 已经是标准格式 yyyy/M/d HH:mm:ss 直接返回 - if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(str)) return str; - // 纯日期 yyyy-MM-dd 或 yyyy/MM/dd → 补充 00:00:00 - const clean = str.replace(/-/g, '/'); - if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(clean)) return clean + ' 00:00:00'; - // 带时间但分隔符是 - → 统一换成 / - const withSlash = str.replace(/-/g, '/'); - if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(withSlash)) return withSlash; - // 无法识别,返回当前时间 - return timestamp(); -} -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'; +// 上传目录 +const uploadsDir = path.join(__dirname, '..', 'data', 'uploads'); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); +app.use('/uploads', express.static(uploadsDir)); + +// Multer 配置 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 ext = path.extname(file.originalname); + const safeName = uuidv4().replace(/-/g, '').substring(0, 12); + cb(null, safeName + ext); } }); const upload = multer({ storage, - limits: { fileSize: 5 * 1024 * 1024 }, + limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { - const allowed = ['image/jpeg','image/png','image/gif']; + const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; 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.use('/api', require('./routes/auth')); +app.use('/api/rooms', require('./routes/rooms')); +app.use('/api/rooms', require('./routes/messages')); +app.use('/api', require('./routes/purchases')); -// 登录 -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 id 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 id ASC').all(req.params.roomId); - res.json(msgs); -}); - -app.post('/api/rooms/:roomId/messages', auth, async (req, res) => { - const { text, attachments } = req.body; - console.log('📨 收到消息, text:', (text||'').substring(0,30), 'attachments:', JSON.stringify(attachments)); - 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 id 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) => { +// 上传 +app.post('/api/upload', authMiddleware, 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 id 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)); -}); +wsModule.init(wss); -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 id 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 填那个物品名。 - -⚠️ 删除规则补充(重要): -- 如果用户说"删除数量X的那条/那个记录",你必须同时返回 quantity 字段,值为 X。 -- 如果用户提到"金额为Y的/价格Y的"来区分记录,你必须同时返回 amount 字段,值为 Y。 -- 提供这些字段后,系统只会精确删除匹配的那一条,避免误删同名记录。 - -⚠️ 附件补充规则: -- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX采购里", - 你只需返回 purchase_item 为 XX,**绝对不要**返回 quantity、unit_price、amount、freight、payment_method 等任何字段。 -- 当消息中只有图片且文本明确指出"这是XX的图片"时,提取 purchase_item 为 XX,其余字段全部省略。 -- 如果用户要求"把刚才/之前的图片作为附件存到XX采购",或说"上面/前面/刚刚那张是XX的图片""那张图片存到XX"等引用历史上传图片的表述,你必须返回 {"action":"purchase","purchase_item":"XX","use_recent_image":true}, - 不提供任何数量、价格字段,系统会自动查找最近一张图片并关联。 - -⚠️ 绝对禁止规则: -- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX",你**只能返回** purchase_item, - **绝对不允许返回** is_new、quantity、unit_price、amount 等任何其他字段。 -- 纯图片+指定物品名的请求不是新建采购,系统会自动关联附件到已有记录。 - -⚠️ 采购规则: -- 只有用户明确表示"购买/采购/下单/付款/买了/花了"等消费意图时才返回 action="purchase"。 -- 如果用户只是陈述事实或描述物品(如"前天deep"),没有明确采购意图,必须返回 action="ignore"。 -- 如果信息不完整、无法确定具体物品名称,必须返回 action="ask" 追问,绝不允许猜测创建记录。 -- 新建 vs 更新(极其重要): - * 如果用户提到"昨天/今天/刚刚/又/再/新/另一批/另外"等时间或重复购买词汇,必须返回 "is_new": true,系统会强制创建新记录。 - * "纯图片+物品名"的附件请求**不是**新建,不要返回 is_new。 - * 如果用户明确说"改一下/更新/修改/调整/更正"等词汇,才不提供 is_new 或设为 false,系统会尝试匹配已有记录更新。 - * 当无法确定是新建还是更新时,默认视为新建(返回 is_new: 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 { - // 动态构建查询:根据 AI 提供的 quantity/amount 精确匹配 - let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?'; - const params = [roomId, `%${itemName}%`]; - if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); } - if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); } - console.log('🔍 删除查询:', query, params); - purchases = db.prepare(query).all(...params); - } - 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') { - const item = aiResult.purchase_item; - // 兜底拦截:物品名为空/null/undefined/过短(<2字符) → 拒绝创建 - if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) { - console.log('🚫 拒绝无效 purchase_item:', JSON.stringify(item)); - storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。'); - addToHistory(roomId, 'assistant', '请提供具体的物品名称。'); - return; - } - console.log('🛒 purchase 分支, item:', item, 'attachments:', JSON.stringify(attachments)); - // 引用最近图片:用户要求把之前的图片关联到某采购 - 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 id 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 = normalizeTime(aiResult.created_time); - // 新建 vs 更新:AI 标记 is_new 时强制新建,不尝试匹配已有记录 - let isNew = aiResult.is_new === true; - let purchase; - // 兜底纠正:如果 AI 误将"纯附件请求"标记为 is_new,强制转为匹配已有记录 - if (isNew && attachments?.length > 0) { - const existing = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`); - if (existing) { - console.log('🛡️ 兜底纠正: is_new 但附件非空且已有同名记录,转为更新模式'); - isNew = false; - purchase = existing; - } - } - if (!isNew && !purchase) { - 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, isNew ? '(强制新建)' : 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); }); - db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length} 个`, username, now); - // 如果字段没有变化,覆盖回复消息 - if (replyText.includes('没有发生') || usedRecentImage) { - replyText = `📎 已为「${aiResult.purchase_item}」添加 ${attachments.length} 个附件`; - } - console.log('✅ 附件完成, 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}`)); +// 启动 +const PORT = process.env.PORT || 3000; +server.listen(PORT, () => console.log(`Server running on port ${PORT}`)); diff --git a/server/utils.js b/server/utils.js new file mode 100644 index 0000000..f3bbfb0 --- /dev/null +++ b/server/utils.js @@ -0,0 +1,18 @@ +// 公共工具函数 +const TIMEZONE = 'Asia/Shanghai'; + +function timestamp() { + return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false }); +} + +function normalizeTime(str) { + if (!str) return timestamp(); + if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(str)) return str; + const clean = str.replace(/-/g, '/'); + if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(clean)) return clean + ' 00:00:00'; + const withSlash = str.replace(/-/g, '/'); + if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(withSlash)) return withSlash; + return timestamp(); +} + +module.exports = { timestamp, normalizeTime }; diff --git a/server/ws/index.js b/server/ws/index.js new file mode 100644 index 0000000..8cb7e32 --- /dev/null +++ b/server/ws/index.js @@ -0,0 +1,56 @@ +// WebSocket 管理模块 +const jwt = require('jsonwebtoken'); +const ADMINS = (process.env.ADMINS || '').split(',').map(s => s.trim()).filter(Boolean); +const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-2024'; + +const clients = new Map(); + +function init(wss) { + 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((info, ws) => { if (ws.readyState === 1) ws.send(message); }); +} + +module.exports = { init, broadcastToRoomExcludeSelf, broadcastToRoom, broadcast };