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 @@
-
-
-
+
-
-
+
+
+
+
+
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 `
`;
+ return ``;
+ }).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 += ``;
+ 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 => `

`).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 };