feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)

本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
This commit is contained in:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+306 -1
View File
@@ -16,9 +16,25 @@ const db = new Database(DB_PATH);
// 启用外键约束
db.pragma('foreign_keys = ON');
// 性能优化
/* ---------------------------------------------------------------------------
* 并发与性能配置 —— 多人共用这套库的命脉,全部显式声明,不依赖驱动默认值。
*
* journal_mode=WAL 读写互不阻塞。这是多用户能同时用的前提,
* 否则一个写操作会锁住全库所有的读。
* synchronous=NORMAL 配合 WAL 的平衡点:断电最多丢最后一个事务,
* 但不会损坏数据库文件。
* busy_timeout=5000 写冲突时排队等 5 秒再报错,而不是立刻抛 SQLITE_BUSY。
* ⚠️ 这一项此前一直是 5000,但那是 better-sqlite3 的
* 驱动默认值——一旦换驱动、或哪天默认值变了,
* 「多人同时保存」就会开始直接报错。必须显式写死。
* 单次写事务只需 1–5ms,5 秒足够排完几十人的队列。
*
* mmap_size 刻意不设:本库走 SQLCipher 加密路径,内存映射在加密库上
* 历史有兼容性坑,而库只有几 MB,收益远小于风险。
* --------------------------------------------------------------------------- */
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('busy_timeout = 5000');
// 初始化数据库结构
function initDatabase() {
@@ -46,6 +62,190 @@ function ensureColumns() {
db.exec("ALTER TABLE todos ADD COLUMN list TEXT NOT NULL DEFAULT 'schedule'");
console.log('✓ 迁移:todos.list 列已补齐');
}
// 老数据库不会重新执行 schema.sql,这里只补新增表和新增列,不改动已有数据。
db.exec(`
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_no TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
inquiry_date TEXT NOT NULL,
title TEXT, status TEXT NOT NULL DEFAULT 'draft', currency TEXT DEFAULT 'USD',
source TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inquiry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL REFERENCES inquiries(id), sku TEXT NOT NULL,
product_name TEXT, supplier_id INTEGER REFERENCES suppliers(id), supplier_name TEXT,
purchase_price REAL NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'CNY',
exchange_rate REAL NOT NULL DEFAULT 1, markup_rate REAL NOT NULL DEFAULT 0,
markup_factor REAL NOT NULL DEFAULT 1, exw REAL NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 1, packaging_snapshot TEXT, product_snapshot TEXT,
quoted_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id), activity_type TEXT NOT NULL,
content TEXT NOT NULL, next_action TEXT, next_action_date TEXT,
contact_id INTEGER REFERENCES contacts(id), inquiry_id INTEGER REFERENCES inquiries(id),
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS product_packaging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE REFERENCES products(sku), inner_pack_qty INTEGER, carton_qty INTEGER,
carton_length REAL, carton_width REAL, carton_height REAL, carton_weight REAL,
unit_length REAL, unit_width REAL, unit_height REAL, unit_weight REAL,
packaging_material TEXT, notes TEXT, created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_no TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'queued',
file_count INTEGER NOT NULL DEFAULT 0, source_files TEXT,
parse_method TEXT NOT NULL DEFAULT 'rules', rule_version TEXT NOT NULL DEFAULT 'mvp-v1',
notes TEXT, created_at TEXT NOT NULL, updated_at TEXT, completed_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id), filename TEXT NOT NULL,
row_no INTEGER, raw_text TEXT, sku TEXT, product_name TEXT, purchase_price REAL,
currency TEXT, quantity INTEGER, status TEXT NOT NULL DEFAULT 'pending_review',
review_note TEXT, reviewed_by INTEGER REFERENCES users(id), reviewed_at TEXT,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku);
CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date);
CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);
/* 组织与权限组(多用户阶段):老库补表。organizations 必须建在 users 补列之前,
因为下面会给 users 加带外键的 org_id 列。 */
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, code TEXT, description TEXT,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, description TEXT, is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
permission_key TEXT NOT NULL, UNIQUE (group_id, permission_key)
);
CREATE TABLE IF NOT EXISTS role_permission_groups (
role TEXT PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES permission_groups(id), updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pg_permissions_group ON permission_group_permissions(group_id);
/* 产品目录本地化(SKU 模块并入后):小推车 + 二进制存储。老库补表。
图片与合同原件单独成表,而不是给 product_images / contracts 加 BLOB 列 ——
那两张表都用 SELECT * 查,二进制挂在同一行会让每次列表查询都把整张图读进内存。 */
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE, qty INTEGER, note TEXT,
sort INTEGER DEFAULT 0, added_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS product_image_files (
image_id INTEGER PRIMARY KEY, bytes BLOB NOT NULL, size INTEGER, stored_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_files (
contract_id INTEGER PRIMARY KEY, filename TEXT, mime TEXT, size INTEGER,
sha256 TEXT, bytes BLOB NOT NULL, stored_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_cart_items_sort ON cart_items(sort);
`);
const columnMigrations = {
inquiries: [
['inquiry_no', "TEXT NOT NULL DEFAULT ''"], ['customer_id', 'INTEGER'], ['inquiry_date', "TEXT NOT NULL DEFAULT ''"],
['title', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'draft'"], ['currency', "TEXT DEFAULT 'USD'"], ['source', 'TEXT'],
['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
inquiry_items: [
['inquiry_id', 'INTEGER'], ['sku', "TEXT NOT NULL DEFAULT ''"], ['product_name', 'TEXT'], ['supplier_id', 'INTEGER'],
['supplier_name', 'TEXT'], ['purchase_price', 'REAL NOT NULL DEFAULT 0'], ['currency', "TEXT NOT NULL DEFAULT 'CNY'"],
['exchange_rate', 'REAL NOT NULL DEFAULT 1'], ['markup_rate', 'REAL NOT NULL DEFAULT 0'], ['markup_factor', 'REAL NOT NULL DEFAULT 1'],
['exw', 'REAL NOT NULL DEFAULT 0'], ['quantity', 'INTEGER NOT NULL DEFAULT 1'], ['packaging_snapshot', 'TEXT'],
['product_snapshot', 'TEXT'], ['quoted_at', "TEXT NOT NULL DEFAULT ''"], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
follow_up_records: [
['customer_id', 'INTEGER'], ['activity_type', "TEXT NOT NULL DEFAULT 'note'"], ['content', "TEXT NOT NULL DEFAULT ''"],
['next_action', 'TEXT'], ['next_action_date', 'TEXT'], ['contact_id', 'INTEGER'], ['inquiry_id', 'INTEGER'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
product_packaging: [
['sku', "TEXT NOT NULL DEFAULT ''"], ['inner_pack_qty', 'INTEGER'], ['carton_qty', 'INTEGER'], ['carton_length', 'REAL'],
['carton_width', 'REAL'], ['carton_height', 'REAL'], ['carton_weight', 'REAL'], ['unit_length', 'REAL'], ['unit_width', 'REAL'],
['unit_height', 'REAL'], ['unit_weight', 'REAL'], ['packaging_material', 'TEXT'], ['notes', 'TEXT'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
contract_import_jobs: [
['job_no', "TEXT NOT NULL DEFAULT ''"], ['status', "TEXT NOT NULL DEFAULT 'queued'"], ['file_count', 'INTEGER NOT NULL DEFAULT 0'],
['source_files', 'TEXT'], ['parse_method', "TEXT NOT NULL DEFAULT 'rules'"], ['rule_version', "TEXT NOT NULL DEFAULT 'mvp-v1'"],
// column_map 存本次认列结果(字段→列号/表头/得分):解析不对时要能自证认了哪几列
['column_map', 'TEXT'], ['parse_source', "TEXT DEFAULT 'rules'"],
['notes', 'TEXT'], ['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT'], ['completed_at', 'TEXT']
],
contract_import_items: [
['job_id', 'INTEGER'], ['filename', "TEXT NOT NULL DEFAULT ''"], ['row_no', 'INTEGER'], ['raw_text', 'TEXT'], ['sku', 'TEXT'],
['product_name', 'TEXT'], ['spec', 'TEXT'], ['purchase_price', 'REAL'], ['currency', 'TEXT'], ['quantity', 'INTEGER'],
['moq', 'INTEGER'], ['parse_source', "TEXT DEFAULT 'rules'"],
['status', "TEXT NOT NULL DEFAULT 'pending_review'"], ['review_note', 'TEXT'], ['reviewed_by', 'INTEGER'], ['reviewed_at', 'TEXT'],
// 入库留痕:committed_at 记入库时间,product_sku 记实际落库货号(撞号会是 -COPY2)
['committed_at', 'TEXT'], ['product_sku', 'TEXT'],
['created_at', "TEXT NOT NULL DEFAULT ''"], ['updated_at', 'TEXT']
],
// 报价单明细的成本口径快照(老库补列)
quotation_items: [
['purchase_price', 'REAL'], ['exchange_rate', 'REAL'], ['markup_rate', 'REAL']
],
// 报价单条款结构化字段(老库补列)。payment_terms / delivery_terms 继续存可读文本。
customer_quotations: [
['pi_no', 'TEXT'], ['payment_method', 'TEXT'], ['deposit_percent', 'REAL'], ['balance_percent', 'REAL'],
['delivery_method', 'TEXT'], ['delivery_port', 'TEXT'], ['validity_days', 'INTEGER'], ['internal_notes', 'TEXT']
],
// 个人资料与组织归属(老库补列)
users: [
['position', 'TEXT'], ['phone', 'TEXT'], ['address', 'TEXT'], ['avatar', 'TEXT'],
['org_id', 'INTEGER'], ['updated_at', 'TEXT']
],
// 产品目录本地化(SKU 模块并入):上传者 / 归属组织 / 撞号副本来源,以及调价人(老库补列)
products: [
['uploader', 'TEXT'], ['org_id', 'INTEGER'], ['duplicated_from', 'TEXT']
],
price_history: [
['changed_by', 'TEXT']
],
// 客户成交状态(新客开发页按 pending / won / lost 分栏)+ 归属与公海(老库补列)
customers: [
['deal_status', "TEXT DEFAULT 'pending'"], ['org_id', 'INTEGER'],
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
['created_by', 'INTEGER'],
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
]
};
for (const [table, columns] of Object.entries(columnMigrations)) {
const existing = new Set(db.pragma(`table_info(${table})`).map(column => column.name));
for (const [name, definition] of columns) {
if (!existing.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
/* settings 是键值表,权限点自动补齐的台账记在这里;老库万一没有也兜一下 */
db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)');
/* 依赖「补列之后才存在」的索引,放在这里建 */
db.exec('CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id)');
/* 客户列表现在是「按归属过滤」,owner_id / pool_status 都得有索引 */
db.exec('CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_customers_pool ON customers(pool_status)');
}
ensureColumns();
@@ -75,4 +275,109 @@ function createDefaultAdmin() {
createDefaultAdmin();
// ---------------------------------------------------------------------------
// 组织与权限组的开箱数据:只在首次运行写入,之后一律以库里的为准。
// 老库(已有 admin 用户)也会走这里补默认组织与默认权限组。
// ---------------------------------------------------------------------------
function seedAccessControl() {
const now = new Date().toISOString();
const { DEFAULT_PERMISSION_GROUPS } = require('../lib/permissions');
/* 默认组织:让存量用户立刻有归属,「同组织」口径从一开始就成立 */
let org = db.prepare('SELECT id FROM organizations ORDER BY id LIMIT 1').get();
if (!org) {
const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)')
.run('happy出口通', 'HAPPY', '默认组织 —— 团队共享的产品/客户资源以此为单位', now);
org = { id: result.lastInsertRowid };
console.log('✓ 默认组织已创建:happy出口通');
}
db.prepare('UPDATE users SET org_id = ?, updated_at = ? WHERE org_id IS NULL').run(org.id, now);
/* 默认权限组 + 角色指派 */
const groupCount = db.prepare('SELECT COUNT(*) AS c FROM permission_groups').get().c;
if (groupCount === 0) {
const insertGroup = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, ?, ?)');
const insertPermission = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
const assignRole = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)');
for (const group of DEFAULT_PERMISSION_GROUPS) {
const id = insertGroup.run(group.name, group.description, group.is_system, now).lastInsertRowid;
for (const key of group.permissions) insertPermission.run(id, key);
for (const role of group.roles) assignRole.run(role, id, now);
}
console.log('✓ 默认权限组与角色指派已创建');
}
syncNewPermissions();
adoptOrphanCustomers(org.id, now);
}
/**
* 新增权限点的自动补齐。
*
* 权限组只在「首次建库」时按 DEFAULT_PERMISSION_GROUPS 灌一遍,之后库里的勾选状态
* 就是唯一事实 —— 所以后续版本新增的权限点,老库永远不会自动拿到(典型症状:
* 升级后某个功能对所有人都是「没有权限」)。这里把「全库都不认识的 key」
* 按默认配置补进对应组,补过就记在 settings 台账里,之后绝不再碰:
* Wayne 手工取消勾选的动作不会被这段代码在下次启动时倒回去。
*/
function syncNewPermissions() {
const { DEFAULT_PERMISSION_GROUPS, PERMISSION_KEYS } = require('../lib/permissions');
const LEDGER = 'perm_autogrant';
let granted = [];
try {
granted = JSON.parse(db.prepare('SELECT value FROM settings WHERE key = ?').get(LEDGER)?.value || '[]');
} catch {
granted = [];
}
const grantedSet = new Set(Array.isArray(granted) ? granted : []);
const known = new Set(
db.prepare('SELECT DISTINCT permission_key FROM permission_group_permissions').all()
.map(row => row.permission_key)
);
/* 本版本新增的 = 代码里有、但全库任何组都没拥有过、也没被自动补过 */
const fresh = PERMISSION_KEYS.filter(key => !known.has(key) && !grantedSet.has(key));
if (!fresh.length) return;
const groupByName = db.prepare('SELECT id FROM permission_groups WHERE name = ?');
const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
const added = [];
for (const group of DEFAULT_PERMISSION_GROUPS) {
const row = groupByName.get(group.name);
if (!row) continue;
for (const key of group.permissions) {
if (!fresh.includes(key)) continue;
insert.run(row.id, key);
added.push(`${group.name}:${key}`);
}
}
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run(LEDGER, JSON.stringify([...grantedSet, ...fresh]));
if (added.length) console.log(`✓ 新增权限点已补齐:${added.join(', ')}`);
}
/**
* 存量客户归属回填。
*
* 「谁建的谁看得见」这条一旦生效,老库里 owner_id 为空的客户就会变成
* 「除了最高级别管理账号谁都看不见」—— 升个级把老板的客户弄丢是不可接受的。
* 所以统一认给最早的系统管理员,org 也一并补上。
*/
function adoptOrphanCustomers(orgId, now) {
const orphans = db.prepare('SELECT COUNT(*) AS c FROM customers WHERE owner_id IS NULL').get().c;
if (!orphans) return;
const owner = db.prepare("SELECT id, org_id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (!owner) return;
db.prepare(`UPDATE customers
SET owner_id = ?,
created_by = COALESCE(created_by, ?),
org_id = COALESCE(org_id, ?),
updated_at = COALESCE(updated_at, ?)
WHERE owner_id IS NULL`).run(owner.id, owner.id, owner.org_id || orgId, now);
console.log(`✓ 存量客户归属回填:${orphans} 条 → 用户 #${owner.id}`);
}
seedAccessControl();
module.exports = db;
+229
View File
@@ -51,6 +51,11 @@ CREATE TABLE IF NOT EXISTS products (
last_updated TEXT,
source_contract TEXT,
confidence TEXT DEFAULT 'high',
-- 上传者 / 归属组织 / 撞号副本来源:产品库是组织共享的,谁都能传自己那一版,
-- 「谁传的、复制自谁」必须可追,见 lib/skuCatalog.js 的 insertItem。
uploader TEXT,
org_id INTEGER,
duplicated_from TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
@@ -110,6 +115,8 @@ CREATE TABLE IF NOT EXISTS price_history (
supplier_id INTEGER REFERENCES suppliers(id),
source_contract TEXT,
confidence TEXT,
-- 谁改的价:报价单里的采购价要能追到出处,和 insertItem 的手工留痕同源
changed_by TEXT,
changed_at TEXT NOT NULL
);
@@ -142,6 +149,39 @@ CREATE TABLE IF NOT EXISTS product_images (
created_at TEXT NOT NULL
);
/* 小推车:产品页里挑出来的货号暂存区,出报价单前的中转台。
全局唯一一份(不按用户分车)—— 这是当前口径,改成分人分车要同时动前端。 */
CREATE TABLE IF NOT EXISTS cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
qty INTEGER,
note TEXT,
sort INTEGER DEFAULT 0,
added_at TEXT NOT NULL
);
/* ---------------------------------------------------------------------------
* 二进制存储:图片与合同原件存进库,不摊在磁盘上。
* 单独成表而不是给 product_images / contracts 加 BLOB 列 —— 那两张表都用
* SELECT * 查,把二进制挂在同一行会让每次列表查询都把整张图读进内存。
* --------------------------------------------------------------------------- */
CREATE TABLE IF NOT EXISTS product_image_files (
image_id INTEGER PRIMARY KEY REFERENCES product_images(id),
bytes BLOB NOT NULL,
size INTEGER,
stored_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_files (
contract_id INTEGER PRIMARY KEY REFERENCES contracts(id),
filename TEXT,
mime TEXT,
size INTEGER,
sha256 TEXT,
bytes BLOB NOT NULL,
stored_at TEXT
);
-- ================== 客户管理(CRM) ==================
CREATE TABLE IF NOT EXISTS customers (
@@ -156,7 +196,17 @@ CREATE TABLE IF NOT EXISTS customers (
email TEXT,
rating TEXT,
status TEXT DEFAULT 'active',
/* 开发结果:pending 未成交 / won 已成交 / lost 已流失(新客开发页按此分栏) */
deal_status TEXT DEFAULT 'pending',
/* 归属:谁建的归谁,默认只有归属人看得见;创建人主动丢进公海才对同组织开放 */
owner_id INTEGER REFERENCES users(id),
/* created_by 单独记「谁建的」—— 公海被领取后 owner_id 会换人,创建人这一笔仍要留痕 */
created_by INTEGER REFERENCES users(id),
org_id INTEGER REFERENCES organizations(id),
/* pool_status:owned 名下有主 / public 已丢入公海(同组织可见、可领取) */
pool_status TEXT DEFAULT 'owned',
pool_at TEXT,
pool_note TEXT,
source TEXT,
tags TEXT,
notes TEXT,
@@ -275,10 +325,20 @@ CREATE TABLE IF NOT EXISTS customer_quotations (
quote_date TEXT NOT NULL,
valid_until TEXT,
currency TEXT DEFAULT 'USD',
-- 条款结构化字段:payment_terms / delivery_terms 仍保留可读文本,供老页面直接展示
pi_no TEXT,
payment_method TEXT,
deposit_percent REAL,
balance_percent REAL,
delivery_method TEXT,
delivery_port TEXT,
validity_days INTEGER,
payment_terms TEXT,
delivery_terms TEXT,
status TEXT DEFAULT 'draft',
notes TEXT,
-- 系统备注:只给内部看,报价单对外不显示
internal_notes TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT
@@ -295,6 +355,10 @@ CREATE TABLE IF NOT EXISTS quotation_items (
unit_price REAL NOT NULL,
total_price REAL NOT NULL,
currency TEXT DEFAULT 'USD',
-- 成本口径快照:报价单发出去之后,供应商再调价也不能让这张单的历史毛利变样
purchase_price REAL,
exchange_rate REAL,
markup_rate REAL,
notes TEXT,
created_at TEXT NOT NULL
);
@@ -339,20 +403,63 @@ CREATE TABLE IF NOT EXISTS prospect_leads (
-- ================== 系统管理 ==================
-- 组织(团队):产品/客户的归属单位,「同组织的人均可参考」以它为边界。
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
code TEXT,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT,
/* role = 权限角色(董事长 / 总经理 / 销售经理 / 销售业务员 …),也是「角色 → 权限组」映射的键 */
role TEXT DEFAULT 'user',
/* position = 对外展示的职位称谓,纯文字,与角色解耦 */
position TEXT,
phone TEXT,
address TEXT,
avatar TEXT,
org_id INTEGER REFERENCES organizations(id),
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT,
last_login_at TEXT,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
-- 权限组:一套命名的权限范围。角色被指派进某个组,即获得该组的全部权限点。
CREATE TABLE IF NOT EXISTS permission_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
);
-- 权限组 → 权限点(permission_key 由后端 PERMISSIONS 清单定义)
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
permission_key TEXT NOT NULL,
UNIQUE (group_id, permission_key)
);
-- 角色 → 权限组(一个角色同时只属于一个组,改指派即换组)
CREATE TABLE IF NOT EXISTS role_permission_groups (
role TEXT PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES permission_groups(id),
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -401,3 +508,125 @@ CREATE INDEX IF NOT EXISTS idx_prospect_leads_run ON prospect_leads(run_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at);
-- ================== 询盘与报价主链路 ==================
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_no TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL REFERENCES customers(id),
inquiry_date TEXT NOT NULL,
title TEXT,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT DEFAULT 'USD',
source TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS inquiry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL REFERENCES inquiries(id),
sku TEXT NOT NULL,
product_name TEXT,
supplier_id INTEGER REFERENCES suppliers(id),
supplier_name TEXT,
purchase_price REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
exchange_rate REAL NOT NULL DEFAULT 1,
markup_rate REAL NOT NULL DEFAULT 0,
markup_factor REAL NOT NULL DEFAULT 1,
exw REAL NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
packaging_snapshot TEXT,
product_snapshot TEXT,
quoted_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS follow_up_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id),
activity_type TEXT NOT NULL,
content TEXT NOT NULL,
next_action TEXT,
next_action_date TEXT,
contact_id INTEGER REFERENCES contacts(id),
inquiry_id INTEGER REFERENCES inquiries(id),
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS product_packaging (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE REFERENCES products(sku),
inner_pack_qty INTEGER,
carton_qty INTEGER,
carton_length REAL,
carton_width REAL,
carton_height REAL,
carton_weight REAL,
unit_length REAL,
unit_width REAL,
unit_height REAL,
unit_weight REAL,
packaging_material TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_no TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'queued',
file_count INTEGER NOT NULL DEFAULT 0,
source_files TEXT,
parse_method TEXT NOT NULL DEFAULT 'rules',
rule_version TEXT NOT NULL DEFAULT 'mvp-v1',
-- column_map:本次认列结果(字段 → 列号/表头/得分),出问题时唯一能自证的现场
column_map TEXT,
-- parse_source:这份结果是怎么来的 —— rules(字符规则)/ ai(模型解析)
parse_source TEXT DEFAULT 'rules',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS contract_import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES contract_import_jobs(id),
filename TEXT NOT NULL,
row_no INTEGER,
raw_text TEXT,
sku TEXT,
product_name TEXT,
spec TEXT,
purchase_price REAL,
currency TEXT,
quantity INTEGER,
moq INTEGER,
status TEXT NOT NULL DEFAULT 'pending_review',
review_note TEXT,
parse_source TEXT DEFAULT 'rules',
reviewed_by INTEGER REFERENCES users(id),
reviewed_at TEXT,
-- committed_at / product_sku:入库留痕 —— 什么时候进的库、进成了哪个货号
-- (撞号时实际落库的是 -COPY2,光看 sku 列看不出来)
committed_at TEXT,
product_sku TEXT,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_inquiries_customer ON inquiries(customer_id);
CREATE INDEX IF NOT EXISTS idx_inquiries_status_date ON inquiries(status, inquiry_date);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_inquiry ON inquiry_items(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_inquiry_items_sku ON inquiry_items(sku);
CREATE INDEX IF NOT EXISTS idx_follow_up_customer_date ON follow_up_records(customer_id, next_action_date);
CREATE INDEX IF NOT EXISTS idx_follow_up_inquiry ON follow_up_records(inquiry_id);
CREATE INDEX IF NOT EXISTS idx_product_packaging_sku ON product_packaging(sku);
CREATE INDEX IF NOT EXISTS idx_contract_import_jobs_status ON contract_import_jobs(status, created_at);
CREATE INDEX IF NOT EXISTS idx_contract_import_items_job ON contract_import_items(job_id, status);
+195
View File
@@ -0,0 +1,195 @@
/**
* 常用大模型接口预设(单一事实来源)。
*
* 口径(Wayne 2026-09-15):
* - 别人部署这套系统后,应该「选一个预设 → 填 key → 测通 → 拉模型 → 分配用途」四步搞定,
* 而不是让他自己去查 base_url 和兼容模式;
* - 预设只是**便利清单**,不是白名单:所有字段都能手改(自定义预设就是空模板);
* - 模型清单同理:能拉就拉真实的,拉不到就用这里的推荐值兜底。
*
* mode 的含义(= 界面上的「接口兼容模式」):
* openai 走 OpenAI 风格 `POST {base}/chat/completions` + `GET {base}/models`
* anthropic 走 Claude Messages API `POST {base}/messages` + `GET {base}/models`
*/
/**
* 两种协议的 Base URL 写法**不一样**,界面上的示例要分别给(Wayne 2026-09-15):
* 本系统是「Base URL + 固定后缀」的拼法,所以用户填的东西必须写到能接后缀的那一层:
* openai → {base}/chat/completions、{base}/models
* anthropic → {base}/messages、{base}/models
* 最容易踩的坑是照抄 Anthropic SDK 文档里的 base_url —— SDK 会自己补 /v1,本系统不补。
*/
const MODES = [
{
value: 'openai',
label: 'OpenAI 兼容',
hint: '大多数国内厂商与本地推理服务都兼容这套路由',
base_placeholder: 'https://api.deepseek.com/v1',
base_suffix: '/chat/completions',
/* 界面上的示例说明:写清楚「填到哪一层」+「结尾长什么样因厂商而异」 */
base_hint: '只填到服务根路径,系统会在后面自动拼 /chat/completions 与 /models。'
+ '结尾不一定是 /v1 —— DeepSeek、Kimi 是 /v1,火山方舟是 /api/v3,智谱是 /api/paas/v4,照服务商文档填即可。',
base_examples: [
{ label: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },
{ label: '火山方舟', url: 'https://ark.cn-beijing.volces.com/api/v3' }
]
},
{
value: 'anthropic',
label: 'Anthropic Messages',
hint: 'Claude 原生协议的 /messages 路由',
base_placeholder: 'https://api.anthropic.com/v1',
base_suffix: '/messages',
/* ⚠️ 这条是本轮要讲清楚的区别:SDK 文档的 base_url 少了 /v1,直接抄会 404 */
base_hint: '填的这一层后面会拼 /messages(不是 /chat/completions),所以必须带上 /v1:'
+ '官方是 https://api.anthropic.com/v1,Kimi 是 https://api.moonshot.cn/anthropic/v1,'
+ '智谱是 https://open.bigmodel.cn/api/anthropic/v1。'
+ '注意各家 SDK 文档里的 base_url 通常写到 /anthropic 为止(由 SDK 补 /v1),本系统不补,别直接照抄。',
base_examples: [
{ label: 'Claude 官方', url: 'https://api.anthropic.com/v1' },
{ label: 'Kimi', url: 'https://api.moonshot.cn/anthropic/v1' },
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/anthropic/v1' }
]
}
]
/** 模型用途:一个用途 = 系统里一处需要智能能力的地方,各自可挂不同模型 */
const PURPOSES = [
{ key: 'assistant_chat', label: '智能助手对话', hint: '右上角小皮的问答与指令理解', vision: false },
{ key: 'contract_parse', label: '合同智能解析', hint: '合同清洗中心抽取品名、价格、账期等字段', vision: false },
{ key: 'ocr', label: '图片 / 扫描件识别', hint: 'PDF 扫描件与图片资料,需要多模态模型', vision: true },
{ key: 'customer_profile', label: '客户画像分析', hint: '辅助判断客户跟进优先级', vision: false },
{ key: 'quote_suggest', label: '报价建议', hint: '基于历史成交价给出报价参考', vision: false }
]
const PRESETS = [
{
key: 'openai',
label: 'OpenAI',
base_url: 'https://api.openai.com/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://platform.openai.com/api-keys',
note: '官方直连,国内网络通常需要自备代理',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o4-mini']
},
{
key: 'anthropic',
label: 'Anthropic Claude',
base_url: 'https://api.anthropic.com/v1',
mode: 'anthropic',
key_hint: 'sk-ant-...',
docs: 'https://console.anthropic.com/settings/keys',
note: '长文档与合同条款理解表现好',
models: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-3-5-haiku-latest']
},
{
/* 和下面的 Kimi(OpenAI 兼容)是两个不同的地址,放一起就是为了让对比一眼可见 */
key: 'moonshot-anthropic',
label: '月之暗面 Kimi · Anthropic 协议',
base_url: 'https://api.moonshot.cn/anthropic/v1',
mode: 'anthropic',
key_hint: 'sk-...',
docs: 'https://platform.moonshot.cn/console/api-keys',
note: '同一个 Kimi key,但地址与下面那条「Kimi」不一样:这条是 /anthropic/v1(Claude 协议),'
+ '那条是 /v1(OpenAI 协议),填错会 404',
models: ['kimi-k2-0905-preview', 'moonshot-v1-128k']
},
{
key: 'zhipu-anthropic',
label: '智谱 GLM · Anthropic 协议',
base_url: 'https://open.bigmodel.cn/api/anthropic/v1',
mode: 'anthropic',
key_hint: 'xxxxx.xxxxx',
docs: 'https://open.bigmodel.cn/usercenter/apikeys',
note: '同一家的 Anthropic 兼容入口是 /api/anthropic/v1,OpenAI 兼容是 /api/paas/v4',
models: ['glm-4-plus', 'glm-4-air']
},
{
key: 'deepseek',
label: 'DeepSeek 深度求索',
base_url: 'https://api.deepseek.com/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://platform.deepseek.com/api_keys',
note: '性价比高,中文业务理解稳定',
models: ['deepseek-chat', 'deepseek-reasoner']
},
{
key: 'moonshot',
label: '月之暗面 Kimi',
base_url: 'https://api.moonshot.cn/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://platform.moonshot.cn/console/api-keys',
note: '长上下文,适合整份合同通读',
models: ['kimi-k2-0905-preview', 'moonshot-v1-32k', 'moonshot-v1-128k']
},
{
key: 'qwen',
label: '阿里通义千问(DashScope 兼容模式)',
base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
mode: 'openai',
key_hint: 'sk-...',
docs: 'https://bailian.console.aliyun.com/',
note: 'qwen-vl 系列可用于图片识别',
models: ['qwen-max', 'qwen-plus', 'qwen-turbo', 'qwen-vl-max']
},
{
key: 'zhipu',
label: '智谱 GLM',
base_url: 'https://open.bigmodel.cn/api/paas/v4',
mode: 'openai',
key_hint: 'xxxxx.xxxxx',
docs: 'https://open.bigmodel.cn/usercenter/apikeys',
note: 'glm-4v 支持图片输入',
models: ['glm-4-plus', 'glm-4-air', 'glm-4-flash', 'glm-4v-plus']
},
{
key: 'doubao',
label: '火山方舟(豆包)',
base_url: 'https://ark.cn-beijing.volces.com/api/v3',
mode: 'openai',
key_hint: '火山方舟 API Key',
docs: 'https://console.volcengine.com/ark',
note: '模型名要填方舟上的「推理接入点 ID」(ep-开头),或直接填模型名',
models: []
},
{
key: 'openrouter',
label: 'OpenRouter(聚合网关)',
base_url: 'https://openrouter.ai/api/v1',
mode: 'openai',
key_hint: 'sk-or-...',
docs: 'https://openrouter.ai/keys',
note: '一个 key 打通多家模型,适合先试后定',
models: []
},
{
key: 'ollama',
label: '本地 Ollama',
base_url: 'http://127.0.0.1:11434/v1',
mode: 'openai',
key_hint: '本地服务通常留空',
docs: 'https://ollama.com/download',
note: '完全离线,数据不出本机;密钥可留空',
needs_key: false,
models: ['qwen2.5:7b', 'qwen2.5:14b', 'llama3.1:8b', 'deepseek-r1:7b']
},
{
key: 'custom',
label: '自定义 / 私有部署',
base_url: '',
mode: 'openai',
key_hint: '按你的网关要求填写',
docs: '',
note: '任何兼容 OpenAI 或 Anthropic 协议的网关都可以接',
models: []
}
]
const PRESET_MAP = new Map(PRESETS.map(item => [item.key, item]));
const PURPOSE_KEYS = PURPOSES.map(item => item.key);
module.exports = { MODES, PRESETS, PRESET_MAP, PURPOSES, PURPOSE_KEYS };
+487
View File
@@ -0,0 +1,487 @@
/**
* 多模型接入:模型服务商的存取、连通性测试、模型清单拉取与按用途调用。
*
* 存储(settings 键值表,跟权限点台账同一张表):
* ai.providers JSON 数组,一家服务商一条
* ai.assignments JSON 对象,用途 → { provider_id, model }
*
* 安全口径:
* - api_key 明文存在本机 SQLite 里,**永远不出现在任何 HTTP 响应中**(一律打码);
* - 上游返回的错误文本会先过一遍脱敏,避免把 key 回显给浏览器。
*
* 这里只依赖 Node 内置 fetch(Node 18+),不引第三方 SDK —— 各家协议就两种,
* 引 SDK 反而会让「换个私有网关」变得麻烦。
*/
const db = require('../db/db');
const { PRESET_MAP, PURPOSE_KEYS } = require('./aiPresets');
const KEY_PROVIDERS = 'ai.providers';
const KEY_ASSIGNMENTS = 'ai.assignments';
const TEST_TIMEOUT_MS = 12000;
const CHAT_TIMEOUT_MS = 90000;
/* ------------------------------------------------------------------ 存取 */
function readJson(key, fallback) {
try {
const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)?.value;
if (!raw) return fallback;
const parsed = JSON.parse(raw);
return parsed ?? fallback;
} catch {
return fallback;
}
}
function writeJson(key, value) {
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, JSON.stringify(value));
}
function listProviders() {
const list = readJson(KEY_PROVIDERS, []);
return Array.isArray(list) ? list : [];
}
function saveProviders(list) {
writeJson(KEY_PROVIDERS, list);
}
function getProvider(id) {
return listProviders().find(item => item.id === String(id)) || null;
}
function listAssignments() {
const map = readJson(KEY_ASSIGNMENTS, {});
return map && typeof map === 'object' && !Array.isArray(map) ? map : {};
}
function saveAssignments(map) {
writeJson(KEY_ASSIGNMENTS, map);
}
/* ------------------------------------------------------------------ 打码 */
function maskKey(key) {
const value = String(key || '');
if (!value) return '';
if (value.length <= 8) return '••••';
return `${value.slice(0, 4)}••••${value.slice(-4)}`;
}
/** 把上游报错里的密钥抹掉再往外抛 —— 有些网关会把请求头原样回显 */
function sanitize(text, key) {
let out = String(text || '');
if (key) out = out.split(String(key)).join('••••');
return out.slice(0, 600);
}
/** 对外输出:密钥只给「有没有」和「长什么样」,绝不给原值 */
function publicProvider(provider) {
const preset = PRESET_MAP.get(provider.preset) || null;
return {
id: provider.id,
name: provider.name,
preset: provider.preset,
preset_label: preset?.label || '自定义',
base_url: provider.base_url,
mode: provider.mode,
models: Array.isArray(provider.models) ? provider.models : [],
enabled: provider.enabled !== false,
note: provider.note || '',
has_key: Boolean(provider.api_key),
key_masked: maskKey(provider.api_key),
last_test: provider.last_test || null,
created_at: provider.created_at || null,
updated_at: provider.updated_at || null
};
}
/* ------------------------------------------------------------------ 写入 */
function badRequest(message) {
return Object.assign(new Error(message), { statusCode: 400 });
}
function normalizeBaseUrl(input) {
const value = String(input || '').trim().replace(/\/+$/, '');
if (!value) throw badRequest('接口地址不能为空');
let url;
try {
url = new URL(value);
} catch {
throw badRequest('接口地址格式不正确,需要以 http:// 或 https:// 开头');
}
if (!/^https?:$/.test(url.protocol)) throw badRequest('接口地址只支持 http 与 https');
return value;
}
function normalizeMode(input) {
const value = String(input || 'openai').trim();
if (!['openai', 'anthropic'].includes(value)) throw badRequest('接口兼容模式只能是 openai 或 anthropic');
return value;
}
function normalizeModels(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('模型清单格式不正确');
return [...new Set(input.map(item => String(item).trim()).filter(Boolean))].slice(0, 200);
}
/**
* 新建 / 更新一家服务商。
* api_key 的三种输入:
* 省略或传 undefined → 保持原值(编辑时前端不回传明文)
* 传空串 → 清空密钥(本地 Ollama 这类不需要密钥)
* 传新值 → 覆盖
*/
function upsertProvider(input, id) {
const list = listProviders();
const now = new Date().toISOString();
const existing = id ? list.find(item => item.id === String(id)) : null;
if (id && !existing) throw badRequest('该模型接入不存在');
const presetKey = String(input.preset || existing?.preset || 'custom').trim();
const preset = PRESET_MAP.get(presetKey) || null;
const mode = normalizeMode(input.mode || existing?.mode || preset?.mode || 'openai');
const baseUrl = input.base_url === undefined && !existing
? normalizeBaseUrl(preset?.base_url)
: normalizeBaseUrl(input.base_url === undefined ? existing.base_url : input.base_url);
const name = String(input.name ?? existing?.name ?? preset?.label ?? '未命名接入').trim();
if (!name) throw badRequest('接入名称不能为空');
if (name.length > 40) throw badRequest('接入名称不能超过 40 个字');
let apiKey = existing?.api_key || '';
if (input.api_key !== undefined) {
const value = String(input.api_key ?? '').trim();
/* 前端拿到的是打码值,万一被原样回传就当没改 */
apiKey = value.includes('••••') ? apiKey : value;
}
const models = normalizeModels(input.models);
const next = {
id: existing?.id || `p_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
name,
preset: preset?.key || 'custom',
base_url: baseUrl,
mode,
api_key: apiKey,
models: models === undefined ? (existing?.models || preset?.models || []) : models,
enabled: input.enabled === undefined ? (existing?.enabled !== false) : Boolean(input.enabled),
note: String(input.note ?? existing?.note ?? '').trim().slice(0, 200),
last_test: existing?.last_test || null,
created_at: existing?.created_at || now,
updated_at: now
};
const nextList = existing
? list.map(item => (item.id === existing.id ? next : item))
: [...list, next];
saveProviders(nextList);
return next;
}
function removeProvider(id) {
const list = listProviders();
const hit = list.find(item => item.id === String(id));
if (!hit) throw badRequest('该模型接入不存在');
saveProviders(list.filter(item => item.id !== hit.id));
/* 引用它的用途分配一并清掉,避免留下指向空气的配置 */
const assignments = listAssignments();
let touched = 0;
for (const purpose of Object.keys(assignments)) {
if (assignments[purpose]?.provider_id === hit.id) {
delete assignments[purpose];
touched += 1;
}
}
if (touched) saveAssignments(assignments);
return { removed: publicProvider(hit), cleared_assignments: touched };
}
function rememberTest(id, result) {
const list = listProviders();
const next = list.map(item => (item.id === String(id)
? { ...item, last_test: result, updated_at: new Date().toISOString() }
: item));
saveProviders(next);
}
/* ------------------------------------------------------------------ 调用 */
/** 统一的 HTTP 出口:超时、错误文本脱敏、JSON 解析兜底都在这里 */
async function requestJson(url, { method = 'POST', headers = {}, body, timeoutMs = TEST_TIMEOUT_MS, apiKey = '' } = {}) {
const started = Date.now();
let response;
try {
response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs)
});
} catch (error) {
const reason = error?.name === 'TimeoutError'
? `请求超时(${Math.round(timeoutMs / 1000)} 秒)`
: `无法连接:${sanitize(error?.cause?.code || error?.message || '网络错误', apiKey)}`;
return { ok: false, status: 0, latencyMs: Date.now() - started, error: reason };
}
const latencyMs = Date.now() - started;
const text = await response.text().catch(() => '');
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!response.ok) {
const upstream = data?.error?.message || data?.message || data?.error || text || `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
latencyMs,
error: sanitize(typeof upstream === 'string' ? upstream : JSON.stringify(upstream), apiKey),
data
};
}
return { ok: true, status: response.status, latencyMs, data, text };
}
function buildHeaders(provider, extra = {}) {
const headers = { 'content-type': 'application/json', ...extra };
const key = provider.api_key;
if (provider.mode === 'anthropic') {
headers['anthropic-version'] = '2023-06-01';
if (key) headers['x-api-key'] = key;
} else if (key) {
headers.authorization = `Bearer ${key}`;
}
return headers;
}
/** 探测用的最小对话请求:一条 user 消息、只要 1 个 token */
function probeBody(provider, model) {
if (provider.mode === 'anthropic') {
return { model, max_tokens: 16, messages: [{ role: 'user', content: 'ping' }] };
}
return { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 16, temperature: 0, stream: false };
}
function chatPath(provider) {
return provider.mode === 'anthropic'
? `${provider.base_url}/messages`
: `${provider.base_url}/chat/completions`;
}
/** 从各家返回体里把正文抠出来 */
function extractChatText(provider, data) {
if (!data) return '';
if (provider.mode === 'anthropic') {
const blocks = Array.isArray(data.content) ? data.content : [];
return blocks.filter(block => block?.type === 'text').map(block => block.text).join('').trim();
}
const message = data.choices?.[0]?.message;
if (!message) return '';
if (typeof message.content === 'string') return message.content.trim();
/* 有些网关把 content 拆成数组(含 reasoning 之类),这里只取文本段 */
if (Array.isArray(message.content)) {
return message.content.filter(part => part?.type === 'text' || typeof part === 'string')
.map(part => (typeof part === 'string' ? part : part.text)).join('').trim();
}
return '';
}
/**
* 连通性测试。
* 策略:优先发一条最小对话(最能说明「这个 key 能不能真的用」);
* 若该模型不可用再退一步只拉模型清单。
*/
async function testProvider(provider) {
const model = (provider.models || [])[0];
if (!model) {
const models = await fetchModels(provider);
if (!models.ok) return { ok: false, at: new Date().toISOString(), latency_ms: models.latencyMs, detail: models.error, mode: provider.mode };
return {
ok: true,
at: new Date().toISOString(),
latency_ms: models.latencyMs,
detail: `接口可访问,读到 ${models.models.length} 个模型(未配置模型名,跳过对话测试)`,
mode: provider.mode
};
}
const result = await requestJson(chatPath(provider), {
method: 'POST',
headers: buildHeaders(provider),
body: probeBody(provider, model),
timeoutMs: TEST_TIMEOUT_MS,
apiKey: provider.api_key
});
if (!result.ok) {
return {
ok: false,
at: new Date().toISOString(),
latency_ms: result.latencyMs,
detail: `模型 ${model} 调用失败:${result.error}`,
mode: provider.mode,
status: result.status
};
}
const text = extractChatText(provider, result.data);
return {
ok: true,
at: new Date().toISOString(),
latency_ms: result.latencyMs,
detail: `模型 ${model} 响应正常${text ? `(返回 ${text.slice(0, 20)})` : ''}`,
mode: provider.mode,
model
};
}
/** 拉模型清单;协议不支持或没权限时回退到预设里的推荐清单 */
async function fetchModels(provider) {
const result = await requestJson(`${provider.base_url}/models`, {
method: 'GET',
headers: buildHeaders(provider),
timeoutMs: TEST_TIMEOUT_MS,
apiKey: provider.api_key
});
const preset = PRESET_MAP.get(provider.preset);
if (!result.ok) {
const fallback = preset?.models || [];
if (fallback.length) {
return {
ok: true,
source: 'preset',
latencyMs: result.latencyMs,
models: fallback,
detail: `该服务未开放模型列表接口(${result.error}),已回退到预设推荐清单`
};
}
return { ok: false, source: 'none', latencyMs: result.latencyMs, models: [], error: result.error };
}
const rows = Array.isArray(result.data?.data) ? result.data.data
: Array.isArray(result.data?.models) ? result.data.models
: Array.isArray(result.data) ? result.data : [];
const models = [...new Set(rows.map(row => String(row?.id || row?.name || row || '').trim()).filter(Boolean))];
if (models.length) return { ok: true, source: 'api', latencyMs: result.latencyMs, models, detail: `读取到 ${models.length} 个模型` };
const fallback = preset?.models || [];
return {
ok: fallback.length > 0,
source: fallback.length ? 'preset' : 'none',
latencyMs: result.latencyMs,
models: fallback,
detail: fallback.length ? '接口未返回模型清单,已回退到预设推荐' : '接口未返回任何模型',
error: fallback.length ? undefined : '接口未返回任何模型'
};
}
/** 按用途找「用哪家、哪个模型」 */
function resolveAssignment(purpose) {
const assignment = listAssignments()[purpose];
if (!assignment?.provider_id || !assignment?.model) return null;
const provider = getProvider(assignment.provider_id);
if (!provider || provider.enabled === false) return null;
return { provider, model: assignment.model };
}
/**
* 真正的对话调用(按用途自动选模型)。
* 未配置时返回 null,调用方据此回退到本地规则逻辑。
*/
async function chatByPurpose(purpose, messages, options = {}) {
const resolved = resolveAssignment(purpose);
if (!resolved) return null;
const { provider, model } = resolved;
const result = await requestJson(chatPath(provider), {
method: 'POST',
headers: buildHeaders(provider),
body: {
model,
messages: provider.mode === 'anthropic'
? messages.filter(message => message.role !== 'system')
: messages,
...(provider.mode === 'anthropic'
? {
max_tokens: options.maxTokens || 1024,
...(messages.find(message => message.role === 'system')
? { system: messages.find(message => message.role === 'system').content }
: {})
}
: {
max_tokens: options.maxTokens || 1024,
temperature: options.temperature === undefined ? 0.3 : options.temperature,
stream: false
})
},
timeoutMs: options.timeoutMs || CHAT_TIMEOUT_MS,
apiKey: provider.api_key
});
if (!result.ok) {
return {
ok: false,
provider: provider.name,
provider_id: provider.id,
model,
error: result.error,
status: result.status
};
}
return {
ok: true,
provider: provider.name,
provider_id: provider.id,
model,
text: extractChatText(provider, result.data),
latency_ms: result.latencyMs,
usage: result.data?.usage || null
};
}
/** 概览:给界面显示「接了几家、分配了几个用途」 */
function overview() {
const providers = listProviders();
const assignments = listAssignments();
const assigned = PURPOSE_KEYS.filter(key => {
const item = assignments[key];
return item?.provider_id && item?.model && providers.some(p => p.id === item.provider_id && p.enabled !== false);
});
const healthy = providers.filter(p => p.last_test?.ok);
return {
provider_count: providers.length,
enabled_count: providers.filter(p => p.enabled !== false).length,
healthy_count: healthy.length,
assigned_purposes: assigned,
last_test_at: healthy.map(p => p.last_test?.at).sort().pop() || null
};
}
module.exports = {
MASK_PLACEHOLDER: '••••',
listProviders,
getProvider,
publicProvider,
upsertProvider,
removeProvider,
listAssignments,
saveAssignments,
resolveAssignment,
testProvider,
fetchModels,
chatByPurpose,
rememberTest,
maskKey,
overview,
PURPOSE_KEYS
};
+227
View File
@@ -0,0 +1,227 @@
/**
* 鉴权与会话(多用户阶段)。
*
* 设计取舍:
* - 密码哈希沿用 db.js 建默认管理员时的格式 `scrypt$salt$hash`,不引第三方依赖
* (本机没有 bcrypt,也不想去碰原生模块编译),Node 内置 crypto.scryptSync 足够。
* - 会话复用已有的 sessions 表,浏览器侧只放一个 HttpOnly cookie(happy_sid),
* 不把用户信息塞进 token,避免改资料后前端还拿着旧值。
* - 权限一律「角色 → 权限组 → 权限点」三级推导,用户表不直接存权限。
*/
const crypto = require('crypto');
const db = require('../db/db');
const { PERMISSION_KEYS } = require('./permissions');
const COOKIE_NAME = 'happy_sid';
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_FAILED = 5;
const LOCK_MS = 15 * 60 * 1000;
/* ---------------------------------------------------------------- 密码 */
function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(String(plain), salt, 64).toString('hex');
return `scrypt$${salt}$${hash}`;
}
function verifyPassword(plain, stored) {
const parts = String(stored || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const [, salt, expected] = parts;
let actual;
try {
actual = crypto.scryptSync(String(plain), salt, 64).toString('hex');
} catch {
return false;
}
const a = Buffer.from(actual, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/* ---------------------------------------------------------------- cookie */
function parseCookies(header) {
const out = {};
if (!header) return out;
for (const piece of String(header).split(';')) {
const index = piece.indexOf('=');
if (index < 0) continue;
const key = piece.slice(0, index).trim();
if (!key) continue;
try {
out[key] = decodeURIComponent(piece.slice(index + 1).trim());
} catch {
out[key] = piece.slice(index + 1).trim();
}
}
return out;
}
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
res.append('Set-Cookie', [
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
].join('; '));
}
function clearSessionCookie(res) {
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
function clientIp(req) {
const raw = req?.headers?.['x-forwarded-for'] || req?.socket?.remoteAddress || '';
return String(raw).split(',')[0].trim().replace(/^::ffff:/, '') || null;
}
/* ---------------------------------------------------------------- 会话 */
function createSession(userId, req) {
const now = new Date();
const id = crypto.randomBytes(32).toString('hex');
db.prepare(`INSERT INTO sessions (id, user_id, created_at, last_seen_at, expires_at, user_agent, ip)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
id, userId, now.toISOString(), now.toISOString(),
new Date(now.getTime() + SESSION_TTL_MS).toISOString(),
String(req?.headers?.['user-agent'] || '').slice(0, 200), clientIp(req)
);
/* 顺手清掉这把用户的过期会话,避免 sessions 表无限增长 */
db.prepare('DELETE FROM sessions WHERE user_id = ? AND expires_at < ?').run(userId, now.toISOString());
return id;
}
function destroySession(sessionId) {
if (sessionId) db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
}
/** 从请求里解出当前用户(含 session id),未登录返回 null */
function sessionUser(req) {
const sessionId = parseCookies(req?.headers?.cookie)[COOKIE_NAME];
if (!sessionId) return null;
const row = db.prepare(`
SELECT u.*, s.id AS sid, s.expires_at AS session_expires_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id = ?
`).get(sessionId);
if (!row) return null;
if (new Date(row.session_expires_at).getTime() < Date.now()) {
destroySession(sessionId);
return null;
}
if (!row.is_active) return null;
return row;
}
function touchSession(sessionId) {
/* 每小时落一次 last_seen_at 就够,避免每个请求都写库 */
try {
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ? AND last_seen_at < ?')
.run(new Date().toISOString(), sessionId, new Date(Date.now() - 3600 * 1000).toISOString());
} catch { /* 非关键路径 */ }
}
/* ---------------------------------------------------------------- 权限 */
function permissionsOfRole(role) {
if (!role) return [];
if (role === 'admin') return PERMISSION_KEYS.slice();
return db.prepare(`
SELECT p.permission_key AS key
FROM role_permission_groups r
JOIN permission_group_permissions p ON p.group_id = r.group_id
WHERE r.role = ?
`).all(role).map(row => row.key);
}
function permissionGroupOfRole(role) {
if (!role) return null;
return db.prepare(`
SELECT g.id, g.name, g.description
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
WHERE r.role = ?
`).get(role) || null;
}
/** 输出给前端的用户对象:绝不带 password_hash */
function publicUser(row) {
if (!row) return null;
const org = row.org_id
? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id)
: null;
return {
id: row.id,
username: row.username,
display_name: row.display_name || row.username,
email: row.email || '',
role: row.role || '',
position: row.position || '',
phone: row.phone || '',
address: row.address || '',
avatar: row.avatar || '',
is_active: !!row.is_active,
created_at: row.created_at,
last_login_at: row.last_login_at,
org: org || null,
permission_group: permissionGroupOfRole(row.role),
permissions: permissionsOfRole(row.role)
};
}
/* ---------------------------------------------------------------- 中间件 */
function attachUser(req, res, next) {
const user = sessionUser(req);
req.user = user;
if (user) touchSession(user.sid);
next();
}
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: '请先登录' });
next();
}
function requirePermission(key) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const granted = req.user.role === 'admin' || permissionsOfRole(req.user.role).includes(key);
if (!granted) return res.status(403).json({ error: '当前账号没有该操作权限' });
next();
};
}
/* ---------------------------------------------------------------- 审计 */
function audit({ username, action, detail, req, ok = 1 }) {
try {
db.prepare(`INSERT INTO audit_log (at, username, action, detail, ip, user_agent, ok)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
new Date().toISOString(), username || null, action, detail || null,
clientIp(req), String(req?.headers?.['user-agent'] || '').slice(0, 200), ok ? 1 : 0
);
} catch { /* 审计是旁路,失败不能影响业务 */ }
}
module.exports = {
COOKIE_NAME,
SESSION_TTL_MS,
MAX_FAILED,
LOCK_MS,
hashPassword,
verifyPassword,
parseCookies,
setSessionCookie,
clearSessionCookie,
clientIp,
createSession,
destroySession,
sessionUser,
permissionsOfRole,
permissionGroupOfRole,
publicUser,
attachUser,
requireAuth,
requirePermission,
audit
};
+71
View File
@@ -0,0 +1,71 @@
/**
* 系统标识与组织名称(单一事实来源)。
*
* 口径(Wayne 2026-09-15 定,别再改回去):
* - **系统名是作者固定署名,不可修改**:登录页、顶栏、浏览器标题一律显示
* SYSTEM.name,且**不跟随组织名** —— 这套系统会被别人拿去部署,
* 把牌子固定在作者名下是刻意保留的来源标识(引流)。
* - **组织名(公司名)可以改**:它只是业务字段,决定「同组织」的资源边界、
* 以及权限组/用户下拉里的显示;改组织名**不会**影响界面上的系统名。
* - 之前那套 `settings['brand.name'] / brand.subtitle` 覆盖已作废,
* 历史遗留的这两个键即使还在库里也不再被读取。
*/
const db = require('../db/db');
/** 系统标识:作者固定署名,界面一律用它;不读 settings,不跟随组织名 */
const SYSTEM = {
name: 'happy出口通',
subtitle: 'Ai Export System'
};
/** 第一个组织视为「主组织」:开箱数据只有一个,组织名以它为准 */
function primaryOrg() {
try {
return db.prepare('SELECT id, name, code, description FROM organizations ORDER BY id LIMIT 1').get() || null;
} catch {
return null;
}
}
/**
* 给前端用的标识信息。
* 故意做得轻:登录页、顶栏、浏览器标题都会调它。
* name / subtitle 恒为固定值;org_* 只是顺带给设置页展示「当前公司名」。
*/
function getBranding() {
const org = primaryOrg();
return {
/* 界面牌子:固定 */
name: SYSTEM.name,
subtitle: SYSTEM.subtitle,
/* 前端据此把「系统标识」渲染成只读、不提供输入框 */
system_locked: true,
/* 公司/组织名单独给出:设置页要能看见它当前是什么(这个是可改的) */
org_name: org?.name || '',
org_id: org?.id ?? null
};
}
/** 改组织名(顺带把 code / 描述也支持了,组织与品牌设置页要用) */
function updateOrg(id, { name, code, description } = {}) {
const row = db.prepare('SELECT * FROM organizations WHERE id = ?').get(Number(id));
if (!row) throw Object.assign(new Error('组织不存在'), { statusCode: 400 });
const nextName = name === undefined ? row.name : String(name || '').trim();
if (!nextName) throw Object.assign(new Error('组织名称不能为空'), { statusCode: 400 });
if (nextName.length > 40) throw Object.assign(new Error('组织名称不能超过 40 个字'), { statusCode: 400 });
const clash = db.prepare('SELECT id FROM organizations WHERE name = ? AND id <> ?').get(nextName, row.id);
if (clash) throw Object.assign(new Error('已存在同名组织'), { statusCode: 400 });
db.prepare('UPDATE organizations SET name = ?, code = ?, description = ? WHERE id = ?').run(
nextName,
code === undefined ? row.code : (String(code || '').trim() || null),
description === undefined ? row.description : (String(description || '').trim() || null),
row.id
);
return db.prepare('SELECT id, name, code, description FROM organizations WHERE id = ?').get(row.id);
}
module.exports = { SYSTEM, getBranding, updateOrg, primaryOrg };
+165
View File
@@ -0,0 +1,165 @@
/**
* 合同原件的磁盘留存 + 到期清理。
*
* 为什么放磁盘而不是继续用 `contract_files` 的 BLOB:
* 留原件的唯一目的是「复核那几天能回去核对一眼」,7 天后就要删掉省 VPS 空间。
* 而 SQLite 删掉 BLOB 后空间并不会还给系统(要 VACUUM 整库重写才还),
* 与「省空间」的目标正好相反。磁盘文件删掉就是删掉。
*
* 目录结构:<DATA_DIR>/contracts/<job_no>/<清洗过的原文件名>
* —— 用 job_no 分目录,清理时按目录整删即可,不需要额外记路径(也就不需要加字段)。
*
* ⚠️ 安全:目录名与文件名都来自用户,必须防越权路径。
* job_no 由服务端生成(CIMP-数字-数字),文件名统一过 sanitizeFileName,
* 拼好之后再校验 resolved 路径确实落在 contracts 根目录之内。
*/
const fs = require('fs');
const path = require('path');
const RETENTION_DAYS = Number(process.env.CONTRACT_RETENTION_DAYS || 7);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
const ROOT = path.join(DATA_DIR, 'contracts');
/** 清洗文件名:去掉路径分隔与控制字符,压掉长度,保留中文 */
function sanitizeFileName(name) {
const base = path.basename(String(name || '')).replace(/[\u0000-\u001f\u007f]/g, '').trim();
const safe = base.replace(/[\\/:*?"<>|]/g, '_').replace(/^\.+/, '');
const trimmed = safe.slice(0, 120) || 'contract';
return trimmed;
}
/** 防越权:任何对外暴露的路径都要过这一关 */
function resolveInside(jobNo, fileName) {
const dir = path.join(ROOT, String(jobNo || '').replace(/[^\w-]/g, ''));
const target = path.join(dir, sanitizeFileName(fileName));
const rootResolved = path.resolve(ROOT);
const targetResolved = path.resolve(target);
if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + path.sep)) return null;
return targetResolved;
}
/**
* 保存一份原件。失败不抛 —— 原件只是辅助材料,解析结果才是主产物,
* 不能因为磁盘满了就把整次导入判失败。
*/
function saveOriginal(jobNo, file) {
try {
if (!jobNo || !file?.buffer?.length) return null;
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
fs.mkdirSync(dir, { recursive: true });
// 同名文件加序号后缀,避免后来者覆盖前一个
const wanted = sanitizeFileName(file.originalname);
let target = path.join(dir, wanted);
let seq = 1;
const ext = path.extname(wanted);
const stem = path.basename(wanted, ext);
while (fs.existsSync(target)) {
seq += 1;
target = path.join(dir, `${stem}-${seq}${ext}`);
}
fs.writeFileSync(target, file.buffer);
return { name: path.basename(target), size: file.buffer.length };
} catch {
return null;
}
}
/** 列出某个任务还留着的原件 */
function listOriginals(jobNo) {
try {
const dir = path.join(ROOT, String(jobNo).replace(/[^\w-]/g, ''));
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).map(name => {
const stat = fs.statSync(path.join(dir, name));
return { name, size: stat.size, stored_at: stat.mtime.toISOString() };
});
} catch {
return [];
}
}
/** 取某一份原件(下载用)。不存在返回 null —— 界面据此显示「已过期清理」。 */
function readOriginal(jobNo, fileName) {
const target = resolveInside(jobNo, fileName);
if (!target) return null;
try {
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return null;
return { path: target, name: path.basename(target), buffer: fs.readFileSync(target) };
} catch {
return null;
}
}
/**
* 清理过期原件。
*
* 到期判定用**任务创建时间**(库里查得到的权威时间),不用目录 mtime ——
* mtime 会被「同 job 再传一次文件」刷新,导致该清的清不掉。
* 库里查不到对应任务的孤儿目录,按目录 mtime 兜底删。
*/
function purgeExpired(db, retentionDays = RETENTION_DAYS) {
const removed = [];
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
let dirs = [];
try {
if (!fs.existsSync(ROOT)) return { removed, scanned: 0 };
dirs = fs.readdirSync(ROOT).filter(name => {
try { return fs.statSync(path.join(ROOT, name)).isDirectory(); } catch { return false; }
});
} catch {
return { removed, scanned: 0 };
}
for (const jobNo of dirs) {
const dir = path.join(ROOT, jobNo);
let expired = false;
try {
const job = db.prepare('SELECT created_at FROM contract_import_jobs WHERE job_no = ?').get(jobNo);
const stamp = job?.created_at ? Date.parse(job.created_at) : fs.statSync(dir).mtimeMs;
expired = Number.isFinite(stamp) && stamp < cutoffMs;
} catch {
expired = false;
}
if (!expired) continue;
try {
fs.rmSync(dir, { recursive: true, force: true });
removed.push(jobNo);
} catch {
/* 删不掉(占着句柄等)下一轮再试,不影响服务 */
}
}
return { removed, scanned: dirs.length };
}
/**
* 启动时清一次 + 之后每 6 小时一次。
* 用 unref() 让定时器不阻止进程退出(否则 nodemon 重启会挂着)。
*/
function startPurgeSchedule(db, logger = console) {
const run = () => {
try {
const { removed } = purgeExpired(db);
if (removed.length) logger.log(`[contracts] 已清理 ${removed.length} 个到期的合同原件(保留 ${RETENTION_DAYS} 天)`);
} catch (error) {
logger.error?.('[contracts] 清理合同原件失败:', error.message);
}
};
run();
const timer = setInterval(run, 6 * 60 * 60 * 1000);
timer.unref?.();
return timer;
}
module.exports = {
RETENTION_DAYS,
ROOT,
sanitizeFileName,
saveOriginal,
listOriginals,
readOriginal,
purgeExpired,
startPurgeSchedule
};
+609
View File
@@ -0,0 +1,609 @@
/**
* 通用表格解析引擎:把「供应商合同 / 报价单」这类文件认成结构化的行。
*
* 设计目标(三条,缺一不可):
*
* 1. **按字符认列,不按行列位置。**
* 规则只描述「这一列的标题大概长什么样」(关键词 / 正则),不写死「第 3 列是价格」。
* 同一份表加一列、调一下顺序,识别结果不变。
*
* 2. **只采纳「标题对得上、且值确实是数值」的列。**
* 数值类字段(采购价 / 数量 / 起订量)在认列之后还要过一道**列级体检**:
* 这一列的非空值里能解析成数字的比例达标,才允许采纳。
* 这一条是「通用」的关键 —— 它挡住的不是错别字,而是「品名里带个『价』字」
* 这类只看标题一定会踩的坑。
*
* 3. **通用、不绑定行业。**
* 默认真名词典按「语义」组织(中/英 + 常见缩写),词典里**不允许出现任何行业专有词**
* (五金、灯具、化工……一概不写)。换一个行业只是换个文件,不是改代码。
* 词典本身也写成**数据**:可在 `settings['contract.rules']` 或
* `DATA_DIR/contract-rules.json` 里增删关键词、排除词、阈值,覆盖默认值。
*
* 表头不在第一行也能认:标题行 / 公司抬头 / 日期这些常常占掉前几行,
* 所以会**在前若干行里挑「规则命中最多」的那一行当表头**,而不是无脑取第 1 行。
*/
const path = require('path');
const fs = require('fs');
const db = require('../db/db');
let XLSX = null;
try {
// 读 xlsx/xls 用;放在 try 里,没装也不至于整个服务起不来
XLSX = require('xlsx');
} catch {
XLSX = null;
}
const SETTINGS_KEY = 'contract.rules';
const DEFAULT_OPTIONS = {
/** 表头最多往下找几行 */
header_search_rows: 12,
/** 认定「这行是表头」至少要有几个字段命中 */
header_min_hits: 2,
/** 认列的最低分(低于此分视为没认出来) */
min_score: 45,
/** 数值列体检:能解析成数字的比例下限 */
min_numeric_ratio: 0.6,
/** 数值列体检最多抽几行 */
numeric_sample: 40
};
/* ------------------------------------------------------------------ 词典 */
/**
* 默认规则。
*
* 约定:
* type text / number —— number 的列要过数值体检
* keys 命中即得分的关键词(中英混排,不分大小写)
* exclude 硬排除:命中就直接判定「这一列不是这个字段」
* regex 额外的正则特征(写字符串,用 /.../i 形式)
*
* ⚠️ 这里的词必须是**通用词**。加词前先问:换个行业还成立吗?
*/
const DEFAULT_FIELDS = {
sku: {
label: '货号',
type: 'text',
keys: [
'sku', 'item code', 'item no', 'item number', 'product code', 'product no', 'product number',
'part no', 'part number', 'model no', 'model number', 'article no', 'article number',
'style no', 'style number', 'catalog no', 'cat no', 'ref no', 'reference',
'code', 'model', 'article', 'product id', 'item id',
'货号', '产品编号', '产品编码', '产品货号', '商品编号', '物料编号', '物料编码', '物料号',
'型号', '型号规格', '编号', '款号', '图号', '零件号', '存货编码', '存货编号'
],
exclude: ['数量', '价', 'price', 'cost', 'amount', '名称', 'name', 'qty', 'quantity']
},
product_name: {
label: '品名',
type: 'text',
keys: [
'product name', 'item name', 'goods name', 'commodity', 'product description',
'item description', 'description', 'product', 'item', 'title', 'goods',
'品名', '产品名称', '商品名称', '货物名称', '产品描述', '商品描述',
'品名规格', '名称', '描述', '品名及规格', '产品'
],
exclude: ['货号', '编号', '编码', 'code', 'no.', '数量', '价', 'price', 'amount', 'qty', '规格型号']
},
spec: {
label: '规格',
type: 'text',
keys: [
'spec', 'specification', 'specifications', 'size', 'dimension', 'dimensions',
'material', 'model spec', 'type',
'规格', '规格型号', '尺寸', '材质', '参数', '规格参数', '规格描述'
],
exclude: ['货号', '编号', '价', 'price', '数量', 'qty', 'amount', '名称']
},
purchase_price: {
label: '采购价',
type: 'number',
keys: [
'purchase price', 'buying price', 'cost price', 'factory price', 'unit price',
'net price', 'exw price', 'fob price', 'ex works', 'exw', 'fob', 'cost', 'price',
'采购价', '采购单价', '采购价格', '供货价', '供货单价', '出厂价', '出厂单价',
'成本价', '成本单价', '未税价', '不含税价', '含税价', '工厂价', '底价', '单价', '价格', '报价'
],
exclude: [
'总价', '合计', '金额', '总额', '小计', 'amount', 'total', 'sum',
'售价', '零售', '建议零售', 'retail', 'msrp', 'selling', 'sell price',
'折扣', 'discount', '毛利', '利润', 'profit', 'margin', '汇率', 'rate', '税点', '运费', 'freight'
]
},
currency: {
label: '币种',
type: 'text',
keys: ['currency', 'cur', 'unit', '币种', '货币', '币别', '结算币种', '单价币种', '计价币种'],
exclude: ['数量', 'qty', '价', 'price', '货号', '编号']
},
quantity: {
label: '数量',
type: 'number',
keys: [
'quantity', 'qty', 'order qty', 'order quantity', 'purchase qty', 'purchase quantity',
'ordered qty', 'amount of order', 'pcs', 'sets', 'qty(pcs)',
'数量', '订购量', '订购数量', '采购数量', '采购量', '订货量', '订货数量', '数量(pcs)', '件数', '个数'
],
exclude: ['价', 'price', 'cost', 'amount', '金额', '总', '合计', 'moq', '起订', 'min', '最少']
},
moq: {
label: '起订量',
type: 'number',
keys: [
'moq', 'minimum order quantity', 'min order qty', 'min order', 'minimum qty', 'min qty',
'起订量', '最小起订量', '起订数量', '最少订量', '最小订量', '最小起订'
],
exclude: ['价', 'price', 'cost', 'amount', '金额']
}
};
/* -------------------------------------------------------------- 配置读取 */
function readJsonFile(file) {
try {
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
/** 用户覆盖文件放哪:与数据库同目录,便于随库一起备份 */
function overrideFile() {
const dir = process.env.DATA_DIR || path.join(__dirname, '../data');
return path.join(dir, 'contract-rules.json');
}
function readOverride() {
/* 优先级:DATA_DIR/contract-rules.json(真正意义的「配置文件」) > settings 表。
前者适合开源用户直接改文件、连界面都不进;后者留给以后的界面化编辑。 */
const fromFile = readJsonFile(overrideFile());
if (fromFile && typeof fromFile === 'object') return { source: 'file', data: fromFile };
try {
const raw = db.prepare('SELECT value FROM settings WHERE key = ?').get(SETTINGS_KEY)?.value;
if (raw) return { source: 'settings', data: JSON.parse(raw) };
} catch {
/* 表还没建或值是脏的,当没有覆盖处理 */
}
return { source: null, data: {} };
}
/**
* 合并默认规则与用户覆盖。
* 覆盖写法(只写要改的部分即可):
* {
* "fields": { "purchase_price": { "add": ["供货价"], "exclude": ["吊牌价"] } },
* "options": { "min_numeric_ratio": 0.5 },
* "disable": ["spec"]
* }
*/
function loadRules() {
const { source, data } = readOverride();
const options = { ...DEFAULT_OPTIONS, ...(data.options || {}) };
const disabled = new Set(Array.isArray(data.disable) ? data.disable : []);
const fields = {};
for (const [key, def] of Object.entries(DEFAULT_FIELDS)) {
if (disabled.has(key)) continue;
const patch = data.fields?.[key] || {};
const keys = [...def.keys, ...(Array.isArray(patch.add) ? patch.add : [])]
.filter(item => !(Array.isArray(patch.remove) && patch.remove.includes(item)));
const exclude = [...def.exclude, ...(Array.isArray(patch.exclude) ? patch.exclude : [])];
fields[key] = {
...def,
keys,
exclude,
// type 允许被覆盖(有人想把「规格」当数值列 тоже可以)
type: patch.type || def.type,
regex: Array.isArray(patch.regex) ? patch.regex : []
};
}
return { fields, options, override_source: source };
}
/* ------------------------------------------------------------ 文本与数字 */
/** 全角 → 半角、统一括号、去零宽字符。中文表格里这三样最常见。 */
function normalizeText(value) {
return String(value ?? '')
.replace(/[\uFF01-\uFF5E]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.replace(/\u3000/g, ' ')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim();
}
/** 表头归一:小写、去空白、去尾部标点,让「采购价 (元)」和「采购价」能对上 */
function normalizeHeader(value) {
return normalizeText(value)
.toLowerCase()
.replace(/\s+/g, '')
.replace(/[::、,,.。**·\-—]+$/g, '')
.replace(/[((].*?[))]$/g, ''); // 去掉尾部的单位括号
}
const hasCJK = text => /[\u4E00-\u9FFF]/.test(text);
/**
* 把单元格里的值转成数字。
* 处理:全角数字、千分位(含欧洲写法 1.234,56)、币种符号、单位后缀、
* 括号负数(会计写法)、前后空白与不可见字符。
* 返回 null 表示「这不是一个数字」——调用方据此做列级体检。
*/
function toNumber(value) {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
let text = normalizeText(value).replace(/\s/g, '');
if (!text) return null;
// 括号负数:(12.5) → -12.5
let negative = false;
if (/^\(.*\)$/.test(text)) {
negative = true;
text = text.slice(1, -1);
}
// 欧洲写法:1.234,56 —— 逗号是小数点、点是千分位。必须先判,否则会被当千分位剥掉
if (/^\d{1,3}(\.\d{3})+(,\d+)?$/.test(text)) {
text = text.replace(/\./g, '').replace(',', '.');
} else {
text = text.replace(/,/g, '');
}
// 剥掉一切非数字字符(币种符号、单位、文字都在这步掉)
text = text.replace(/[^0-9.\-+]/g, '');
if (!text) return null;
/* 符号只允许出现在最前面。
⚠️ 不能「把 - 全省掉」—— 那样「12-15」这种区间会被拼成 1215,
比留空更糟:它会当成真价继续往产品库里走。区间一律判为非数字。 */
let sign = 1;
if (/^[-+]/.test(text)) {
if (text[0] === '-') sign = -1;
text = text.slice(1);
}
if (/[-+]/.test(text)) return null;
if (!/^\d*\.?\d+$/.test(text)) return null;
const num = Number(text) * sign * (negative ? -1 : 1);
return Number.isFinite(num) ? num : null;
}
/** 从整行的内容里猜币种 —— 没有币种列时用 */
const CURRENCY_HINTS = [
[/¥|¥|人民币|rmb|cny|元/i, 'CNY'],
[/\$|usd|美元|美金/i, 'USD'],
[/€|eur|欧元/i, 'EUR'],
[/£|gbp|英镑/i, 'GBP'],
[/jpy|日元|円/i, 'JPY'],
[/krw|韩元|₩/i, 'KRW'],
[/aud|澳元/i, 'AUD'],
[/cad|加元/i, 'CAD'],
[/hkd|港币|港元/i, 'HKD'],
[/inr|卢比/i, 'INR'],
[/vnd|越南盾|đ/i, 'VND'],
[/thb|泰铢|฿/i, 'THB']
];
function guessCurrency(...texts) {
const joined = texts.map(item => String(item ?? '')).join(' ');
if (!joined.trim()) return null;
for (const [pattern, code] of CURRENCY_HINTS) {
if (pattern.test(joined)) return code;
}
// 三字母代码(USD / CNY…)单独兜一次,避免被上面的贪婪匹配漏掉
const code = joined.match(/\b([A-Z]{3})\b/);
return code ? code[1] : null;
}
/* ---------------------------------------------------------------- 列匹配 */
/** 单个「表头 × 字段」打分。返回 0 = 不匹配;exclude 命中直接 0。 */
function scoreHeader(header, field) {
const head = normalizeHeader(header);
if (!head) return 0;
for (const bad of field.exclude) {
const token = normalizeHeader(bad);
if (token && head.includes(token)) return 0;
}
let best = 0;
for (const rawKey of field.keys) {
const key = normalizeHeader(rawKey);
if (!key) continue;
if (head === key) return 100;
if (hasCJK(key)) {
// 中文:包含即可,但关键词越长越可信(「采购单价」远比「价」可信)
if (head.includes(key)) best = Math.max(best, 50 + key.length * 8);
} else {
// 拉丁:短词(no / moq / ref…)必须落在词边界上,否则 notes 会被当成 no
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const boundary = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`);
if (boundary.test(head)) best = Math.max(best, 55 + Math.min(key.length, 12) * 3);
}
}
for (const pattern of field.regex) {
try {
const [source, flags] = pattern.split(/\/([a-z]*)$/);
if (new RegExp(source.replace(/^\//, ''), flags || 'i').test(header)) best = Math.max(best, 85);
} catch {
/* 用户写的正则不合法就当没写,不能让一份配置文件把整条解析链路打挂 */
}
}
return best;
}
/**
* 认列:把「表头行」映射成「字段 → 列号」。
*
* 两步走:
* ① 每个数值字段先过**列级体检** —— 候选列的数字占比不达标直接淘汰(拦「品名带价字」这类误判)
* ② 所有候选按分排序,贪心分配:字段没被占、列也没被占才落座
* (贪心而不是「每个字段各取最高分」,是为了避免两列抢同一个字段时出现重复占用)
*/
function matchColumns(headers, dataRows, rules) {
const { fields, options } = rules;
const candidates = [];
const rejected = [];
for (const [fieldKey, field] of Object.entries(fields)) {
for (let col = 0; col < headers.length; col += 1) {
const score = scoreHeader(headers[col], field);
if (score < options.min_score) continue;
if (field.type === 'number') {
const sample = dataRows
.map(row => row[col])
.filter(value => String(value ?? '').trim() !== '')
.slice(0, options.numeric_sample);
if (!sample.length) {
rejected.push({ field: fieldKey, col, header: headers[col], reason: '整列为空' });
continue;
}
const ok = sample.filter(value => toNumber(value) !== null).length;
const ratio = ok / sample.length;
if (ratio < options.min_numeric_ratio) {
rejected.push({
field: fieldKey, col, header: headers[col],
reason: `数值占比 ${Math.round(ratio * 100)}% 低于 ${Math.round(options.min_numeric_ratio * 100)}%`
});
continue;
}
}
candidates.push({ field: fieldKey, col, header: String(headers[col] ?? '').trim(), score });
}
}
candidates.sort((a, b) => b.score - a.score || a.col - b.col);
const usedField = new Set();
const usedCol = new Set();
const map = {};
for (const item of candidates) {
if (usedField.has(item.field) || usedCol.has(item.col)) continue;
usedField.add(item.field);
usedCol.add(item.col);
map[item.field] = item;
}
return { map, rejected };
}
/**
* 挑表头行。
* 真实文件里表头常常不在第 1 行(上面还有公司抬头、报价日期、说明……),
* 所以在前若干行里找「字段命中最多」的那一行,而不是无脑取第 1 行。
*/
function detectHeaderRow(matrix, rules) {
const limit = Math.min(matrix.length, rules.options.header_search_rows);
let best = { index: 0, hits: 0, matched: null };
for (let i = 0; i < limit; i += 1) {
const row = matrix[i] || [];
const { map } = matchColumns(row, matrix.slice(i + 1), rules);
const hits = Object.keys(map).length;
// 命中更多者胜;打平取靠前的(抬头行通常在上面)
if (hits > best.hits) best = { index: i, hits, matched: map };
}
return best;
}
/* ---------------------------------------------------------------- 读文件 */
/** CSV / TSV / TXT:按首行含不含 Tab 决定分隔符(沿用原有口径) */
function readDelimited(buffer, filename) {
const text = buffer.toString('utf8').replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/);
const delimiter = /\.tsv$/i.test(filename) || (lines[0] || '').includes('\t') ? '\t'
: (lines[0] || '').includes(';') && !(lines[0] || '').includes(',') ? ';'
: ',';
return lines.map(line => splitDelimited(line, delimiter));
}
/** 按分隔符切一行,但引号里的分隔符不算("A,B" 是一格) */
function splitDelimited(line, delimiter) {
const cells = [];
let current = '';
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (ch === '"') {
if (quoted && line[i + 1] === '"') { current += '"'; i += 1; } else quoted = !quoted;
} else if (ch === delimiter && !quoted) {
cells.push(current);
current = '';
} else {
current += ch;
}
}
cells.push(current);
return cells.map(cell => cell.trim());
}
/** xlsx / xls:交给 SheetJS;多 sheet 时取「非空单元格最多」的那个(通常就是明细表) */
function readWorkbook(buffer) {
if (!XLSX) throw new Error('未安装 xlsx 解析库,无法读取 Excel 文件');
const book = XLSX.read(buffer, { type: 'buffer' });
let best = { matrix: [], cells: -1, sheet: null };
for (const name of book.SheetNames || []) {
const sheet = book.Sheets[name];
if (!sheet) continue;
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '', blankrows: true });
const cells = matrix.reduce((sum, row) => sum + row.filter(cell => String(cell ?? '').trim() !== '').length, 0);
if (cells > best.cells) best = { matrix, cells, sheet: name };
}
return best;
}
function parseMatrixToRows(matrix, headerIndex, columnMap, fallbackCurrency = null) {
const fields = Object.keys(columnMap);
const rows = [];
for (let i = headerIndex + 1; i < matrix.length; i += 1) {
const raw = matrix[i] || [];
const pick = field => {
const hit = columnMap[field];
if (!hit) return null;
const value = normalizeText(raw[hit.col]);
return value === '' ? null : value;
};
const row = {
// row_no 用「文件里的真实行号」(1 起),方便人回去对着原文件找
row_no: i + 1,
raw_text: raw.map(cell => String(cell ?? '').trim()).filter(Boolean).join(' | ').slice(0, 500),
sku: pick('sku'),
product_name: pick('product_name'),
spec: pick('spec'),
currency: pick('currency'),
quantity: toNumber(pick('quantity')),
moq: toNumber(pick('moq')),
purchase_price: toNumber(pick('purchase_price'))
};
/* 币种三级兜底:本行的币种列(上面 pick 过了) → 本行里的符号(¥ / USD / 元)
→ 整份文件的线索(表头常写「单价(元)」「Unit Price(USD)」)。
只按单行猜会出现「同一列有的行有币种、有的行没有」这种不一致。 */
if (!row.currency) row.currency = guessCurrency(raw.join(' ')) || fallbackCurrency;
// 整行该抓的全空 —— 大概率是空行或小计行,丢掉
const meaningful = [row.sku, row.product_name, row.spec, row.purchase_price, row.quantity, row.moq]
.some(value => value !== null && value !== undefined && value !== '');
if (meaningful) rows.push(row);
}
return { rows, fields };
}
/**
* 解析一个上传文件 → { rows, headers, header_row, column_map, rejected, notes }
* 这是对外的唯一入口。
*/
function parseUpload(file, rules = loadRules()) {
const filename = String(file.originalname || '');
const isExcel = /\.(xlsx|xlsm|xls)$/i.test(filename);
let matrix;
let sheetName = null;
if (isExcel) {
const book = readWorkbook(file.buffer);
matrix = book.matrix;
sheetName = book.sheet;
} else {
matrix = readDelimited(file.buffer, filename);
}
// 去掉整行全空的(xlsx 里尾部常有大量空行)
matrix = matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== ''));
if (!matrix.length) {
return { rows: [], headers: [], header_row: null, column_map: {}, rejected: [], notes: '文件里没有读到任何内容' };
}
const header = detectHeaderRow(matrix, rules);
const headers = matrix[header.index] || [];
const { map, rejected } = matchColumns(headers, matrix.slice(header.index + 1), rules);
/* 文件级币种线索:表头 + 表头以上的抬头行(「报价单(美元)」「单价(元)」都在这)
—— 没有币种列时靠它,保证整列币种一致 */
const titleRows = matrix.slice(0, header.index).flat();
const fileCurrency = guessCurrency(headers.join(' '), titleRows.join(' '));
const { rows } = parseMatrixToRows(matrix, header.index, map, fileCurrency);
const notes = rows.length
? `表头在第 ${header.index + 1} 行,认出 ${Object.keys(map).length} 个字段${sheetName ? `(工作表 ${sheetName})` : ''}`
: '表头行没有认出任何字段,建议人工补录或用 AI 解析';
return {
rows,
headers: headers.map(cell => String(cell ?? '').trim()),
header_row: header.index + 1,
column_map: map,
rejected,
currency: fileCurrency,
notes
};
}
/** 读成二维数组(xlsx / csv / tsv / txt 统一入口),AI 解析与规则解析共用 */
function readMatrix(buffer, filename) {
const isExcel = /\.(xlsx|xlsm|xls)$/i.test(String(filename || ''));
const matrix = isExcel ? readWorkbook(buffer).matrix : readDelimited(buffer, filename);
return matrix.filter(row => row.some(cell => String(cell ?? '').trim() !== ''));
}
/** 二维数组 → CSV 文本,喂给模型用 */
function matrixToText(matrix, { maxRows = 200, maxChars = 12000 } = {}) {
const lines = [];
let chars = 0;
for (const row of matrix) {
const line = row.map(cell => {
const text = String(cell ?? '').replace(/\s+/g, ' ').trim();
return /[",]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}).join(',').replace(/(?:,)+$/, '');
if (!line.trim()) continue;
if (lines.length >= maxRows || chars + line.length > maxChars) {
lines.push('…(余下内容已截断)');
break;
}
lines.push(line);
chars += line.length;
}
return lines.join('\n');
}
/**
* 把上传的原件转成「给模型看的文本」。
* ⚠️ 只做格式转换,不做任何猜测 —— 猜是模型的事,转换是我们的事。
*/
function describeForAi(buffer, filename, options) {
const matrix = readMatrix(buffer, filename);
return { text: matrixToText(matrix, options), row_count: matrix.length };
}
/** 给界面用:把认列结果翻译成人能读的一段话 */
function describeMatch(columnMap, rejected = []) {
const picked = Object.entries(columnMap)
.map(([field, hit]) => `${DEFAULT_FIELDS[field]?.label || field}←「${hit.header}」(${hit.score}分)`)
.join(',');
const miss = rejected.length
? `;被否掉:${rejected.map(item => `${item.header}(${item.reason})`).join(',')}`
: '';
return (picked || '没有认出任一字段') + miss;
}
module.exports = {
DEFAULT_FIELDS,
DEFAULT_OPTIONS,
loadRules,
overrideFile,
parseUpload,
readMatrix,
matrixToText,
describeForAi,
matchColumns,
detectHeaderRow,
describeMatch,
toNumber,
normalizeHeader,
guessCurrency,
scoreHeader
};
+91
View File
@@ -0,0 +1,91 @@
/**
* 客户归属与可见性口径(唯一事实来源)。
*
* Wayne 2026-09-14 定的规矩:
* ① 客户有「所属」:谁建的归谁,默认只有归属人自己看得见;
* ② 创建人主动丢进公海(pool_status = 'public')后,同组织成员都能看见、都能领取;
* ③ 其余情况只有拿到 `customer.view_all` 的最高级别管理账号能查看。
*
* 「能看」与「能改」是两档:
* 公海里的客户同组织成员都能看见(不然没法判断值不值得领),
* 但只有归属人或管理账号能改 —— 别人想改得先「领取」。
*
* ⚠️ 注意 req.user 是 users 表的原始行(attachUser 直接塞的是 sessionUser 的结果),
* 上面**没有** permissions 字段,权限必须用 auth.permissionsOfRole(role) 现推。
*/
const auth = require('./auth');
/** 越过归属看全部客户的权限点 */
const VIEW_ALL = 'customer.view_all';
/** 是否属于「最高级别系统管理账号」 */
function seesAll(user) {
if (!user || !user.role) return false;
return auth.permissionsOfRole(user.role).includes(VIEW_ALL);
}
/**
* 列表可见性 → 可直接拼进 WHERE 的条件数组 + 参数。
*
* scope:
* 'mine' 我的客户(默认)—— owner_id = 我
* 'all' 全部客户 —— 需 customer.view_all,无权限时退回 'mine' 而不是报错
* 'pool' 客户公海 —— pool_status = 'public',且限定本组织
*
* 调用方拼 SQL 时,customers 表必须别名为 **c**。
*/
function scopeFilter(user, scope = 'mine') {
const conditions = [];
const params = [];
if (scope === 'pool') {
conditions.push("c.pool_status = 'public'");
if (user && user.org_id) {
/* 公海是「组织(团队)」的资源,只放本组织的进来。
存量没 org_id 的老数据不设门槛,免得刚升级就凭空少一批。 */
conditions.push('(c.org_id IS NULL OR c.org_id = ?)');
params.push(user.org_id);
}
return { conditions, params };
}
if (scope === 'all' && seesAll(user)) {
return { conditions, params };
}
/* 「我的客户」= 归我 + 还没交出去。
丢进公海后这条就从我的列表里移走,只留在「客户公海」——
否则用户会疑惑「我都丢出去了怎么还在我这儿」。 */
conditions.push('c.owner_id = ?');
conditions.push("COALESCE(c.pool_status, 'owned') <> 'public'");
params.push(user ? user.id : -1);
return { conditions, params };
}
/** 公海是「组织(团队)」的资源:两边都记了组织且不一致,就是外人 */
function inSameOrg(user, customer) {
const mine = user?.org_id;
const theirs = customer?.org_id;
/* 存量数据可能没组织,不设门槛 —— 与 scopeFilter 里 pool 的写法保持一致 */
if (!mine || !theirs) return true;
return Number(mine) === Number(theirs);
}
/** 能不能看这条客户(详情页、跟进时间线) */
function canView(user, customer) {
if (!customer) return false;
if (seesAll(user)) return true;
if (user && Number(customer.owner_id) === Number(user.id)) return true;
/* 公海客户同组织可见;别组织的公海客户连详情都不该看到 */
if (customer.pool_status === 'public' && inSameOrg(user, customer)) return true;
return false;
}
/** 能不能改这条客户(基本信息、联系人、跟进记录) */
function canEdit(user, customer) {
if (!customer) return false;
if (seesAll(user)) return true;
return !!user && Number(customer.owner_id) === Number(user.id);
}
module.exports = { VIEW_ALL, seesAll, scopeFilter, inSameOrg, canView, canEdit };
+32 -16
View File
@@ -23,8 +23,8 @@ const CACHE_FILE = path.join(DATA_DIR, 'fx-cache.json');
/** 内存缓存的保鲜期:1 小时后下次访问会去拉新数据。 */
const TTL_MS = 60 * 60 * 1000;
/** 单个数据源的超时。 */
const TIMEOUT_MS = 8000;
/** 单个数据源的超时。多个源是并行竞速的,所以这基本等于整体最坏耗时。 */
const TIMEOUT_MS = 5000;
const USER_AGENT = 'happy-export-fx';
/* ------------------------------ 数据源 ------------------------------ */
@@ -149,25 +149,41 @@ let memory = readDiskCache();
/** 同一个进程里并发调用只发一次网络请求。 */
let inflight = null;
/**
* 刷新汇率表。
*
* 所有数据源**并行竞速**,第一个拿到完整数据的胜出。
* 早先这里是串行 for 循环,一次网络抖动要等 8s × 2 = 16s 才回退,
* 用户看到的是「刷新失败 + 3 天前的旧数据」——其实另一个源可能早就成功了。
* 并行之后最坏情况只等一个超时周期。
*/
async function refresh() {
for (const provider of PROVIDERS) {
const attempts = PROVIDERS.map(async provider => {
const json = await fetchJson(provider.url);
const picked = json && provider.pick(json);
if (!picked) continue;
if (!picked) throw new Error(`${provider.id} 无数据`);
const rates = sanitizeRates(picked.rates);
if (Object.keys(rates).length < 10) continue; // 明显不完整,换下一个源
const payload = {
rates,
updatedAt: picked.updatedAt,
fetchedAt: Date.now(),
source: provider.id,
sourceLabel: provider.label
};
memory = payload;
writeDiskCache(payload);
return payload;
if (Object.keys(rates).length < 10) throw new Error(`${provider.id} 数据不完整`);
return { provider, rates, updatedAt: picked.updatedAt };
});
let winner;
try {
winner = await Promise.any(attempts);
} catch {
return null; // 所有源都失败,交给 getRates 走缓存 / 离线兜底
}
return null;
const payload = {
rates: winner.rates,
updatedAt: winner.updatedAt,
fetchedAt: Date.now(),
source: winner.provider.id,
sourceLabel: winner.provider.label
};
memory = payload;
writeDiskCache(payload);
return payload;
}
/**
+123
View File
@@ -0,0 +1,123 @@
/**
* 系统权限点清单(单一事实来源)。
*
* 设计口径(Wayne 2026-09-14 定):
* - 「角色」是职位显示(董事长 / 总经理 / 销售经理 / 销售业务员 …),本身不带权限;
* - 权限全部收敛到「权限组」:一个组勾选一批权限点(即下面的 key);
* - 把角色指派进某个组(role_permission_groups),该角色下的所有用户即获得这组权限。
*
* 命名约定:`模块.动作`。前端菜单过滤、路由守卫、后端 requirePermission 都用同一批 key。
*/
const PERMISSIONS = [
/* 工作台 */
{ key: 'dashboard.view', label: '查看工作台', group: '工作台' },
/* 业务管理 */
{ key: 'inquiry.view', label: '查看询盘管理', group: '业务管理' },
{ key: 'inquiry.edit', label: '编辑询盘管理', group: '业务管理' },
{ key: 'quote.view', label: '查看客户报价单', group: '业务管理' },
{ key: 'quote.edit', label: '编辑客户报价单', group: '业务管理' },
{ key: 'contract.view', label: '查看 PI 合同', group: '业务管理' },
{ key: 'contract.edit', label: '编辑 PI 合同', group: '业务管理' },
{ key: 'purchase.view', label: '查看采购单', group: '业务管理' },
{ key: 'purchase.edit', label: '编辑采购单', group: '业务管理' },
{ key: 'order.view', label: '查看订单管理', group: '业务管理' },
{ key: 'order.edit', label: '编辑订单管理', group: '业务管理' },
/* 客户管理 */
{ key: 'customer.view', label: '查看我的客户', group: '客户管理' },
{ key: 'customer.edit', label: '编辑我的客户', group: '客户管理' },
/* 客户默认「谁建的谁看得见」,丢进公海才对同组织开放。
拿到这个点才越过归属看全部客户 —— 按 Wayne 的口径只给最高级别的系统管理组。 */
{ key: 'customer.view_all', label: '查看全部客户(含他人归属)', group: '客户管理' },
{ key: 'prospect.view', label: '新客开发', group: '客户管理' },
{ key: 'pool.view', label: '客户公海', group: '客户管理' },
{ key: 'pool.claim', label: '客户公海领取', group: '客户管理' },
/* 采购管理 */
{ key: 'product.view', label: '查看产品管理', group: '采购管理' },
{ key: 'product.edit', label: '编辑产品管理', group: '采购管理' },
{ key: 'supplier.view', label: '查看供应商管理', group: '采购管理' },
{ key: 'supplier.edit', label: '编辑供应商管理', group: '采购管理' },
{ key: 'cleanup.view', label: '合同清洗中心', group: '采购管理' },
/* 系统管理 */
{ key: 'org.manage', label: '组织管理', group: '系统管理' },
/* 组织与品牌(改公司名 / 组织代码 / 描述)单独成点,与「组织管理」解耦 ——
Wayne 2026-09-15 的口径:这块**只允许高管改**,所以它得能单独勾给「公司高管」组,
而不是搭在系统管理权限上。前端设置页分类、PATCH /api/organizations/:id 与
PUT /api/branding 都认这一个点。 */
{ key: 'branding.manage', label: '组织与品牌(公司信息)', group: '系统管理' },
{ key: 'user.manage', label: '用户管理', group: '系统管理' },
{ key: 'perm.manage', label: '权限组管理', group: '系统管理' },
{ key: 'ai.manage', label: '智能配置(大模型接入与用途分配)', group: '系统管理' },
{ key: 'audit.view', label: '审计日志', group: '系统管理' }
]
const PERMISSION_KEYS = PERMISSIONS.map(item => item.key)
/* 角色清单:Wayne 列举的四个业务角色 + 系统管理员。权限组管理页的「角色指派」用它做候选。 */
const ROLES = [
{ value: 'admin', label: '系统管理员' },
{ value: 'chairman', label: '董事长' },
{ value: 'general_manager', label: '总经理' },
{ value: 'sales_manager', label: '销售经理' },
{ value: 'sales_rep', label: '销售业务员' }
]
/* 开箱默认权限组。仅在首次建库/首次运行时写入,之后以库里的为准。 */
const DEFAULT_PERMISSION_GROUPS = [
{
name: '超级管理员组',
description: '全部权限,含组织、用户与权限组管理',
is_system: 1,
permissions: PERMISSION_KEYS,
/* 只挂 admin。董事长 / 总经理改走下面的「公司高管」组 ——
一个角色同时只能属于一个组(role_permission_groups.role 是主键),
让他们留在超管组就等于「高管 = 系统管理员」,与 Wayne 2026-09-15 的分权口径冲突。 */
roles: ['admin']
},
{
name: '销售管理组',
description: '业务全流程可读可写,可维护客户、产品与供应商,无系统管理权限',
is_system: 0,
/* 注意:`customer.view_all` 必须显式排除 —— 它不含 .manage 后缀,
否则会被这个过滤器顺带带上,销售经理就能看到全公司的客户了。
`branding.manage` 反过来说:它**靠** .manage 后缀被自动排除,
销售经理永远拿不到改公司信息的权限(Wayne 口径:只有高管能改)。 */
permissions: PERMISSION_KEYS.filter(key =>
!key.endsWith('.manage') && key !== 'audit.view' && key !== 'customer.view_all'),
roles: ['sales_manager']
},
{
name: '销售业务组',
description: '基础业务操作:维护自己名下的客户与询盘报价,产品只读',
is_system: 0,
permissions: [
'dashboard.view',
'inquiry.view', 'inquiry.edit',
'quote.view', 'quote.edit',
'contract.view',
'purchase.view',
'order.view',
'customer.view', 'customer.edit',
'prospect.view', 'pool.view', 'pool.claim',
'product.view', 'supplier.view', 'cleanup.view'
],
roles: ['sales_rep']
},
{
name: '公司高管',
description: '董事长 / 总经理:除用户、权限组、智能配置与系统日志外的全部权限,含改公司信息',
is_system: 0,
/* 与线上库(2026-09-15)一致:全量权限里摘掉四个「系统管理员专属」的点。
user.manage / perm.manage 是账号治理权,ai.manage 会看到所有模型密钥,
audit.view 能看到全公司的操作轨迹 —— Wayne 口径是「系统日志仅系统管理员可见」。 */
permissions: PERMISSION_KEYS.filter(key =>
!['user.manage', 'perm.manage', 'ai.manage', 'audit.view'].includes(key)),
roles: ['chairman', 'general_manager']
}
]
module.exports = { PERMISSIONS, PERMISSION_KEYS, ROLES, DEFAULT_PERMISSION_GROUPS }
+297
View File
@@ -0,0 +1,297 @@
/**
* 产品目录(SKU 模块)的本地数据层。
*
* 这块原先是独立的 sku-manager 服务(3300),happy 通过 /api/sku 代理访问它。
* 合并后数据直接落在 trade.db:少一个进程、少一个端口,开源部署只跑 4300 + 5300。
*
* 表结构沿用 sku-manager 那套(products / product_groups / suppliers / item_attributes /
* product_images / price_history / cart_items),列名保持一致 —— 迁移是按原 id 整表搬过来的,
* 所以这里的外键关系与历史数据完全对得上,不需要任何 id 映射。
*/
const crypto = require('crypto');
const db = require('../db/db');
/* --------------------------------- 规范化 --------------------------------- */
/**
* 货号规范化:去掉空格、连字符、下划线、斜杠、点,再转大写。
* 合同里的「GD-100F」和产品库里的「GD100F」必须归到同一条。
*/
function normalizeSku(raw) {
if (!raw) return null;
return String(raw).toUpperCase().replace(/[\s\-_./]/g, '').trim();
}
/**
* 产品归组键:同一单品的不同尺寸归为一个产品。
* 只剥「尺寸/规格」类字样(8x10、200mm、1/2"、6寸),件数(6件套)保留 ——
* 套装件数不同是不同的产品。
*/
function groupKey(name) {
if (!name) return null;
let s = String(name).replace(/\s+/g, '');
s = s.replace(/\d+(\.\d+)?[x×*]\d+(\.\d+)?(mm|cm)?/gi, '');
s = s.replace(/\d+\/\d+(["″”寸]|英寸)?/g, '');
s = s.replace(/\d+(\.\d+)?(mm|cm|寸|英寸|inch|in|"|″|”|米)/gi, '');
s = s.replace(/[-—–()()]/g, '');
const key = s.toUpperCase();
return key || String(name).trim().toUpperCase();
}
/* ------------------------------ 二进制存储 ------------------------------ */
const MIME_BY_EXT = {
'.pdf': 'application/pdf',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp'
};
function mimeFor(name) {
const i = String(name).lastIndexOf('.');
return (i >= 0 && MIME_BY_EXT[String(name).slice(i).toLowerCase()]) || 'application/octet-stream';
}
const syncImageStmt = db.prepare(
`INSERT INTO product_image_files (image_id, bytes, size, stored_at)
VALUES (@image_id, @bytes, @size, @stored_at)
ON CONFLICT(image_id) DO UPDATE SET bytes = excluded.bytes, size = excluded.size, stored_at = excluded.stored_at`
);
const getImageStmt = db.prepare('SELECT bytes, size FROM product_image_files WHERE image_id = ?');
const delImageStmt = db.prepare('DELETE FROM product_image_files WHERE image_id = ?');
const storeImage = (imageId, buf) =>
syncImageStmt.run({ image_id: imageId, bytes: buf, size: buf.length, stored_at: new Date().toISOString() });
const getImage = (imageId) => getImageStmt.get(imageId);
const deleteImage = (imageId) => delImageStmt.run(imageId);
/** 批量删图(批量删产品时用),避免逐条 delete。 */
function deleteImagesFor(imageIds) {
if (!imageIds.length) return;
db.prepare(`DELETE FROM product_image_files WHERE image_id IN (${imageIds.map(() => '?').join(',')})`)
.run(...imageIds);
}
/* ------------------------------- 合同原件 ------------------------------- */
const putContractStmt = db.prepare(
`INSERT INTO contract_files (contract_id, filename, mime, size, sha256, bytes, stored_at)
VALUES (@contract_id, @filename, @mime, @size, @sha256, @bytes, @stored_at)
ON CONFLICT(contract_id) DO UPDATE SET
filename = excluded.filename, mime = excluded.mime, size = excluded.size,
sha256 = excluded.sha256, bytes = excluded.bytes, stored_at = excluded.stored_at`
);
/** 把一份合同原件收进库。返回 null 表示没收到(不算致命,解析结果才是主产物)。 */
function storeContractFile(contractId, bytes, displayName) {
if (!bytes || !bytes.length) return null;
const filename = displayName || `contract-${contractId}`;
putContractStmt.run({
contract_id: contractId,
filename,
mime: mimeFor(filename),
size: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes,
stored_at: new Date().toISOString()
});
return { size: bytes.length };
}
const getContractFile = (contractId) =>
db.prepare('SELECT filename, mime, size, bytes FROM contract_files WHERE contract_id = ?').get(contractId);
/* --------------------------------- 参数 --------------------------------- */
const getItemAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE sku = ? ORDER BY sort, id'
);
const getGroupAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE group_id = ? AND sku IS NULL ORDER BY sort, id'
);
/** 整组替换参数(货号级或产品级)。传入 [{name, value}],空名跳过。 */
function replaceAttrs({ sku = null, groupId = null }, attrs) {
if (!Array.isArray(attrs)) return;
db.transaction(() => {
if (sku) db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
else db.prepare('DELETE FROM item_attributes WHERE group_id = ? AND sku IS NULL').run(groupId);
const ins = db.prepare(
'INSERT INTO item_attributes (group_id, sku, name, value, sort) VALUES (@group_id, @sku, @name, @value, @sort)'
);
attrs.forEach((a, i) => {
const name = String(a?.name || '').trim();
if (!name) return;
ins.run({ group_id: sku ? null : groupId, sku, name, value: String(a?.value ?? '').trim() || null, sort: i });
});
})();
}
/** 产品组变空后清理掉,避免列表里留下没有货号的壳(有图片的保留)。 */
function cleanupGroupIfEmpty(groupId) {
if (!groupId) return;
const hasItems = db.prepare('SELECT 1 FROM products WHERE group_id = ? LIMIT 1').get(groupId);
const hasImages = db.prepare('SELECT 1 FROM product_images WHERE group_id = ? LIMIT 1').get(groupId);
if (!hasItems && !hasImages) {
db.prepare('DELETE FROM item_attributes WHERE group_id = ?').run(groupId);
db.prepare('DELETE FROM product_groups WHERE id = ?').run(groupId);
}
}
/* --------------------------------- 货号 --------------------------------- */
function validatePriceMoq(b) {
const price = b.price === '' || b.price == null ? null : Number(b.price);
if (price !== null && (!Number.isFinite(price) || price < 0)) return { error: '采购价必须是非负数字' };
const moq = b.moq === '' || b.moq == null ? null : Number(b.moq);
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return { error: '起订量必须是非负数字' };
return { price, moq };
}
/**
* 新增货号。
*
* 货号重复**不当错误** —— 产品库是组织共享的,谁都能上传自己那一版:
* 撞号时落成兄弟副本 `<SKU>-COPY2 / -COPY3 …`,display_sku 加「-副本N」,
* 并用 duplicated_from 记住它复制自哪个货号,界面上标出来,避免两条同名货号看着像脏数据。
* 无论是否重复都记 uploader / org_id;手工价同时写 changed_by,保证「谁传的、谁改的价」可追。
*/
function insertItem(b, groupId, supplierId, actor = null, actorOrg = null, options = {}) {
const displaySku = String(b.sku || '').trim();
const base = normalizeSku(displaySku);
if (!base) return { error: '货号不能为空' };
const v = validatePriceMoq(b);
if (v.error) return v;
const taken = (candidate) => !!db.prepare('SELECT sku FROM products WHERE sku = ?').get(candidate);
let sku = base;
let display = displaySku;
let duplicatedFrom = null;
if (taken(base)) {
duplicatedFrom = base;
let n = 2;
while (taken(`${base}-COPY${n}`)) n += 1;
sku = `${base}-COPY${n}`;
display = `${displaySku}-副本${n}`;
}
const now = new Date().toISOString();
/* options 只在「带来源的批量导入」里传(目前是合同清洗中心的入库):
sourceContract —— 价格出处,只记合同**文件名**(Wayne 2026-09-16:不记路径、不记任务号)
parseMethod —— 'manual' | 'rules' | 'ai',用来区分这条货号是手录还是合同带进来的
currency —— 新建时按合同的币种落库;不传仍然是 CNY(产品库原有口径)
⚠️ 更新已有货号时**不动 currency** —— 汇率口径牵一发动全身,不在这里顺手改。 */
const sourceContract = options.sourceContract || null;
const parseMethod = options.parseMethod || 'manual';
const currency = String(options.currency || 'CNY').toUpperCase();
db.transaction(() => {
db.prepare(
`INSERT INTO products (sku, display_sku, name, spec, price, currency, moq, description,
supplier_id, contract_id, group_id, parse_method,
last_updated, source_contract, confidence,
uploader, org_id, duplicated_from, created_at, updated_at)
VALUES (@sku, @display_sku, @name, @spec, @price, @currency, @moq, @description,
@supplier_id, NULL, @group_id, @parse_method, @now, @source_contract, 'high',
@uploader, @org_id, @duplicated_from, @now, @now)`
).run({
sku,
display_sku: display,
name: b.name?.trim() || null,
spec: b.spec?.trim() || null,
price: v.price,
moq: v.moq,
currency,
description: b.description?.trim() || null,
supplier_id: supplierId,
group_id: groupId,
parse_method: parseMethod,
source_contract: sourceContract,
now,
uploader: actor,
org_id: actorOrg,
duplicated_from: duplicatedFrom
});
/* 手工价格同样留痕,保证「每个价格都能追到出处」这条不破功。
带来源时(合同入库)把合同文件名当出处写进去,手工录入才写「手工录入」。 */
if (v.price !== null) {
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, NULL, @price, NULL, @moq, @currency, @supplier_id, @source_contract, 'high', @now, @changed_by)`
).run({
sku,
price: v.price,
moq: v.moq,
currency,
supplier_id: supplierId,
source_contract: sourceContract || '手工录入',
now,
changed_by: actor
});
}
})();
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
return { sku, displaySku: display, duplicatedFrom };
}
/* ------------------------------- 产品解析 ------------------------------- */
/**
* 解析货号,返回统一的 { product, packaging } 视图。
*
* 合并前这里要先打 SKU 服务、失败再回退本地镜像(镜像只兜底,绝不拿过期价去报价)。
* 现在数据就在本库,直接查即可 —— 单表主键命中,比原来走一趟 HTTP 快得多。
* packaging 始终取自本地 product_packaging(询盘/报价用的包装快照来源)。
*/
function resolveProduct(sku) {
const raw = String(sku || '').trim();
if (!raw) throw new Error('SKU 不能为空');
const key = normalizeSku(raw);
const row = db.prepare(
`SELECT p.*, s.name AS supplier_name FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id WHERE p.sku = ?`
).get(key);
if (!row) throw new Error(`产品 SKU 不存在:${raw}`);
const product = {
...row,
sku: row.sku,
display_sku: row.display_sku || row.sku,
name: row.name || row.name_en || row.sku,
name_en: row.name_en || null,
price: row.price,
currency: row.currency || 'CNY',
moq: row.moq === undefined ? null : row.moq
};
const packaging = db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(row.sku) || null;
return { product, packaging };
}
module.exports = {
normalizeSku,
groupKey,
mimeFor,
storeImage,
getImage,
deleteImage,
deleteImagesFor,
storeContractFile,
getContractFile,
getItemAttrs,
getGroupAttrs,
replaceAttrs,
cleanupGroupIfEmpty,
validatePriceMoq,
insertItem,
resolveProduct
};
+14 -1
View File
@@ -12,7 +12,8 @@
"better-sqlite3-multiple-ciphers": "^13.0.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"nodemon": "^3.0.1"
@@ -1420,6 +1421,18 @@
"node": ">= 0.8"
}
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+7 -2
View File
@@ -7,14 +7,19 @@
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": ["trade", "crm", "supply-chain"],
"keywords": [
"trade",
"crm",
"supply-chain"
],
"author": "",
"license": "ISC",
"dependencies": {
"better-sqlite3-multiple-ciphers": "^13.0.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"nodemon": "^3.0.1"
+464
View File
@@ -0,0 +1,464 @@
/**
* 组织与权限组(多用户阶段的权限中枢)。
* 挂载点:/api
*
* 口径(Wayne 2026-09-14 定):
* - 角色只是职位标签,本身不带权限;
* - 权限组 = 一批权限点;把角色指派进某个组,该角色下所有用户即获得这组权限。
* 所以「角色 → 组」是唯一的授权入口,permission_group_permissions 是唯一的权限范围来源。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const branding = require('../lib/branding');
const { PERMISSIONS, PERMISSION_KEYS, ROLES } = require('../lib/permissions');
const router = express.Router();
const ROLE_VALUES = ROLES.map(item => item.value);
const KEY_SET = new Set(PERMISSION_KEYS);
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/必须|不能为空|不存在|无效|格式|已存在|不可|请先/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/** 校验并归一化权限点数组 */
function normalizePermissions(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('权限范围格式不正确');
const list = [...new Set(input.map(key => String(key).trim()).filter(Boolean))];
const unknown = list.filter(key => !KEY_SET.has(key));
if (unknown.length) throw badRequest(`未知的权限点:${unknown.join('、')}`);
return list;
}
/** 校验并归一化角色数组 */
function normalizeRoles(input) {
if (input === undefined) return undefined;
if (!Array.isArray(input)) throw badRequest('角色指派格式不正确');
const list = [...new Set(input.map(role => String(role).trim()).filter(Boolean))];
const unknown = list.filter(role => !ROLE_VALUES.includes(role));
if (unknown.length) throw badRequest(`未知的角色:${unknown.join('、')}`);
return list;
}
function groupDetail(row) {
const permissions = db.prepare('SELECT permission_key FROM permission_group_permissions WHERE group_id = ?')
.all(row.id).map(item => item.permission_key);
const roles = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?')
.all(row.id).map(item => item.role);
return {
id: row.id,
name: row.name,
description: row.description || '',
is_system: !!row.is_system,
permissions,
roles,
created_at: row.created_at,
updated_at: row.updated_at
};
}
/** 写入某个组的权限范围(整组替换) */
function replacePermissions(groupId, permissions) {
db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(groupId);
const insert = db.prepare('INSERT OR IGNORE INTO permission_group_permissions (group_id, permission_key) VALUES (?, ?)');
for (const key of permissions) insert.run(groupId, key);
}
/**
* 写入某个组的角色指派。
* 先把原本指向本组、但不在新名单里的角色解绑(role 表主键唯一,只能改指处),
* 再把新名单里的角色指到本组。
*/
function replaceRoles(groupId, roles, nowIso) {
const current = db.prepare('SELECT role FROM role_permission_groups WHERE group_id = ?').all(groupId).map(row => row.role);
const keep = new Set(roles);
for (const role of current) {
if (!keep.has(role)) db.prepare('DELETE FROM role_permission_groups WHERE role = ?').run(role);
}
const assign = db.prepare('INSERT OR REPLACE INTO role_permission_groups (role, group_id, updated_at) VALUES (?, ?, ?)');
for (const role of roles) assign.run(role, groupId, nowIso);
}
/* ------------------------------------------------------------ 权限清单 */
/** GET /api/permissions —— 权限点清单 + 可选角色(权限组管理页的左栏) */
router.get('/permissions', auth.requireAuth, (req, res) => {
try {
const groups = [];
for (const item of PERMISSIONS) {
let bucket = groups.find(group => group.title === item.group);
if (!bucket) { bucket = { title: item.group, items: [] }; groups.push(bucket); }
bucket.items.push({ key: item.key, label: item.label });
}
res.json({ permissions: PERMISSIONS, groups, roles: ROLES });
} catch (error) {
fail(res, error);
}
});
/** GET /api/roles —— 角色与各自所在的权限组(指派一览) */
router.get('/roles', auth.requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT r.role, r.group_id, g.name AS group_name
FROM role_permission_groups r JOIN permission_groups g ON g.id = r.group_id
`).all();
const map = new Map(rows.map(row => [row.role, row]));
res.json({
roles: ROLES.map(item => ({
...item,
group_id: map.get(item.value)?.group_id || null,
group_name: map.get(item.value)?.group_name || null
}))
});
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 权限组 */
router.get('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM permission_groups ORDER BY is_system DESC, id ASC').all();
res.json({ groups: rows.map(groupDetail) });
} catch (error) {
fail(res, error);
}
});
router.post('/permission-groups', auth.requirePermission('perm.manage'), (req, res) => {
try {
const name = String(req.body?.name || '').trim();
if (!name) throw badRequest('权限组名称不能为空');
if (db.prepare('SELECT id FROM permission_groups WHERE name = ?').get(name)) throw badRequest('该权限组名称已存在');
const permissions = normalizePermissions(req.body?.permissions) || [];
const roles = normalizeRoles(req.body?.roles) || [];
const now = new Date().toISOString();
const result = db.prepare('INSERT INTO permission_groups (name, description, is_system, created_at) VALUES (?, ?, 0, ?)')
.run(name, String(req.body?.description || '').trim() || null, now);
replacePermissions(result.lastInsertRowid, permissions);
replaceRoles(result.lastInsertRowid, roles, now);
auth.audit({ username: req.user.username, action: 'permission_group_create', detail: `新建权限组「${name}」`, req });
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ group: groupDetail(row), message: '权限组已创建' });
} catch (error) {
fail(res, error);
}
});
router.patch('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
if (!row) throw badRequest('权限组不存在');
const now = new Date().toISOString();
const fields = [];
const values = [];
if (req.body?.name !== undefined) {
const name = String(req.body.name || '').trim();
if (!name) throw badRequest('权限组名称不能为空');
const clash = db.prepare('SELECT id FROM permission_groups WHERE name = ? AND id != ?').get(name, id);
if (clash) throw badRequest('该权限组名称已存在');
fields.push('name = ?'); values.push(name);
}
if (req.body?.description !== undefined) {
fields.push('description = ?'); values.push(String(req.body.description || '').trim() || null);
}
if (fields.length) {
fields.push('updated_at = ?'); values.push(now); values.push(id);
db.prepare(`UPDATE permission_groups SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
const permissions = normalizePermissions(req.body?.permissions);
if (permissions) replacePermissions(id, permissions);
const roles = normalizeRoles(req.body?.roles);
if (roles) replaceRoles(id, roles, now);
auth.audit({ username: req.user.username, action: 'permission_group_update', detail: `更新权限组「${row.name}」`, req });
const updated = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
res.json({ group: groupDetail(updated), message: '权限组已保存' });
} catch (error) {
fail(res, error);
}
});
router.delete('/permission-groups/:id', auth.requirePermission('perm.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM permission_groups WHERE id = ?').get(id);
if (!row) throw badRequest('权限组不存在');
if (row.is_system) throw badRequest('系统内置权限组不可删除');
const occupied = db.prepare('SELECT COUNT(*) AS c FROM role_permission_groups WHERE group_id = ?').get(id).c;
if (occupied > 0) throw badRequest('该权限组下仍指派有角色,请先把角色改派到其它组');
db.prepare('DELETE FROM permission_group_permissions WHERE group_id = ?').run(id);
db.prepare('DELETE FROM permission_groups WHERE id = ?').run(id);
auth.audit({ username: req.user.username, action: 'permission_group_delete', detail: `删除权限组「${row.name}」`, req });
res.json({ ok: true, message: '权限组已删除' });
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 组织 */
router.get('/organizations', auth.requireAuth, (req, res) => {
try {
const rows = db.prepare(`
SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.org_id = o.id) AS member_count
FROM organizations o ORDER BY o.id ASC
`).all();
res.json({ organizations: rows });
} catch (error) {
fail(res, error);
}
});
router.post('/organizations', auth.requirePermission('org.manage'), (req, res) => {
try {
const name = String(req.body?.name || '').trim();
if (!name) throw badRequest('组织名称不能为空');
if (db.prepare('SELECT id FROM organizations WHERE name = ?').get(name)) throw badRequest('该组织名称已存在');
const now = new Date().toISOString();
const result = db.prepare('INSERT INTO organizations (name, code, description, created_at) VALUES (?, ?, ?, ?)')
.run(name, String(req.body?.code || '').trim() || null, String(req.body?.description || '').trim() || null, now);
auth.audit({ username: req.user.username, action: 'organization_create', detail: `新建组织「${name}」`, req });
res.status(201).json({ organization: db.prepare('SELECT * FROM organizations WHERE id = ?').get(result.lastInsertRowid) });
} catch (error) {
fail(res, error);
}
});
/* ------------------------------------------------------------ 组织改名与系统标识 */
/**
* PATCH /api/organizations/:id —— 改组织名/代码/描述(branding.manage)
* 组织名 = 部署方自己的公司名,可自由改;它只作业务字段用(资源边界 + 下拉显示),
* **不会**影响界面上的系统名(系统名固定,见 lib/branding.js)。
*
* ⚠️ 权限点从 `org.manage` 换成 `branding.manage`(Wayne 2026-09-15):
* 这块**只允许高管改**,所以拆成独立权限点,只勾给「公司高管」组 + 超级管理员组,
* 销售经理/业务员永远拿不到。
*/
router.patch('/organizations/:id', auth.requirePermission('branding.manage'), (req, res) => {
try {
const before = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(Number(req.params.id));
if (!before) throw badRequest('组织不存在');
const organization = branding.updateOrg(req.params.id, req.body || {});
auth.audit({
username: req.user.username,
action: 'organization_update',
detail: `组织「${before.name}」→「${organization.name}」`,
req
});
res.json({ organization });
} catch (error) {
fail(res, error);
}
});
/** GET /api/branding —— 当前系统标识 + 公司名(登录即可读,设置页要用) */
router.get('/branding', auth.requireAuth, (req, res) => {
try {
res.json(branding.getBranding());
} catch (error) {
fail(res, error);
}
});
/**
* PUT /api/branding —— 已停用:系统名是作者固定署名,任何角色都改不了。
* 保留这条路由是为了给调用方一个明确的 403 说明,而不是含糊的 404。
* 想换公司门面的,请改组织名(PATCH /api/organizations/:id)。
*/
router.put('/branding', auth.requirePermission('branding.manage'), (req, res) => {
res.status(403).json({
error: `「${branding.SYSTEM.name}」是作者固定署名,不可修改;如需换成贵公司的名字,请修改组织名称。`
});
});
/* ------------------------------------------------------------ 用户 */
/** 用户行 → 输出对象(绝不带 password_hash),附带角色/权限组/组织的可读名 */
function userDetail(row) {
const roleMeta = ROLES.find(item => item.value === row.role);
const group = db.prepare(`
SELECT g.id, g.name FROM role_permission_groups r
JOIN permission_groups g ON g.id = r.group_id WHERE r.role = ?
`).get(row.role || '') || null;
const org = row.org_id
? db.prepare('SELECT id, name, code FROM organizations WHERE id = ?').get(row.org_id)
: null;
return {
id: row.id,
username: row.username,
display_name: row.display_name || row.username,
email: row.email || '',
phone: row.phone || '',
role: row.role || '',
role_label: roleMeta?.label || row.role || '—',
position: row.position || '',
org_id: row.org_id || null,
org_name: org?.name || '',
permission_group_name: group?.name || '',
is_active: !!row.is_active,
created_at: row.created_at,
last_login_at: row.last_login_at
};
}
function normalizeRole(value) {
const role = String(value || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest(`角色不正确(可选:${ROLE_VALUES.join('、')})`);
return role;
}
function normalizeOrgId(value) {
if (value === null || value === undefined || value === '') return null;
const id = Number(value);
const org = db.prepare('SELECT id FROM organizations WHERE id = ?').get(id);
if (!org) throw badRequest('所属组织不存在');
return id;
}
/** GET /api/users —— 用户列表(user.manage) */
router.get('/users', auth.requirePermission('user.manage'), (req, res) => {
try {
const rows = db.prepare('SELECT * FROM users ORDER BY id ASC').all();
res.json({ users: rows.map(userDetail) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/users —— 新建用户(user.manage) */
router.post('/users', auth.requirePermission('user.manage'), (req, res) => {
try {
const username = String(req.body?.username || '').trim();
if (!username) throw badRequest('登录账号不能为空');
if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
throw badRequest('账号只能是 2–32 位字母 / 数字 / 下划线 / 点 / 横线');
}
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) throw badRequest('该账号已存在');
const password = String(req.body?.password || '');
if (password.length < 6) throw badRequest('密码至少 6 位');
const role = normalizeRole(req.body?.role);
const orgId = normalizeOrgId(req.body?.org_id);
const now = new Date().toISOString();
const result = db.prepare(`
INSERT INTO users (username, password_hash, display_name, email, phone, role, position, org_id, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
`).run(
username,
auth.hashPassword(password),
String(req.body?.display_name || '').trim() || null,
String(req.body?.email || '').trim() || null,
String(req.body?.phone || '').trim() || null,
role,
String(req.body?.position || '').trim() || null,
orgId,
now
);
auth.audit({ username: req.user.username, action: 'user_create', detail: `新建用户「${username}」(${role})`, req });
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ user: userDetail(row), message: '用户已创建' });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/users/:id —— 编辑用户 / 重置密码 / 停用(user.manage) */
router.patch('/users/:id', auth.requirePermission('user.manage'), (req, res) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
if (!row) throw badRequest('用户不存在');
const body = req.body || {};
const isSelf = row.id === req.user.id;
/* 自己的账号:不许改角色(防止把自己锁在门外)、不许停用 */
if (isSelf && body.role !== undefined && String(body.role) !== row.role) {
throw badRequest('不能修改自己的角色');
}
if (isSelf && body.is_active !== undefined && !body.is_active) {
throw badRequest('不能停用自己的账号');
}
const fields = [];
const values = [];
if (body.display_name !== undefined) {
fields.push('display_name = ?'); values.push(String(body.display_name || '').trim() || null);
}
if (body.email !== undefined) {
fields.push('email = ?'); values.push(String(body.email || '').trim() || null);
}
if (body.phone !== undefined) {
fields.push('phone = ?'); values.push(String(body.phone || '').trim() || null);
}
if (body.position !== undefined) {
fields.push('position = ?'); values.push(String(body.position || '').trim() || null);
}
if (body.org_id !== undefined) {
fields.push('org_id = ?'); values.push(normalizeOrgId(body.org_id));
}
if (body.role !== undefined) {
const role = normalizeRole(body.role);
if (row.role === 'admin' && role !== 'admin') {
/* 防止把最后一个系统管理员改成普通角色 */
const adminCount = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND is_active = 1").get().c;
if (adminCount <= 1) throw badRequest('系统至少要保留一名可用的系统管理员');
}
fields.push('role = ?'); values.push(role);
}
if (body.is_active !== undefined) {
fields.push('is_active = ?'); values.push(body.is_active ? 1 : 0);
}
if (body.password !== undefined) {
const password = String(body.password || '');
if (password.length < 6) throw badRequest('密码至少 6 位');
fields.push('password_hash = ?'); values.push(auth.hashPassword(password));
fields.push('failed_count = ?'); values.push(0);
fields.push('locked_until = ?'); values.push(null);
}
if (!fields.length) throw badRequest('没有要更新的内容');
const now = new Date().toISOString();
fields.push('updated_at = ?'); values.push(now); values.push(id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
/* 改密/停用后踢掉该用户除当前会话外的所有会话(当前管理员自己的会话只在做自改密码时保留) */
if (body.password !== undefined || body.is_active === false) {
const keep = isSelf ? req.user.sid : null;
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(id, keep || '');
}
const summary = [
body.password !== undefined ? '重置密码' : null,
body.is_active !== undefined ? (body.is_active ? '启用' : '停用') : null,
body.role !== undefined && String(body.role) !== row.role ? `角色改为 ${body.role}` : null
].filter(Boolean).join('、') || '更新资料';
auth.audit({ username: req.user.username, action: 'user_update', detail: `${summary}:「${row.username}」`, req });
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
res.json({ user: userDetail(updated), message: '用户已保存' });
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+212
View File
@@ -0,0 +1,212 @@
/**
* 智能配置:多模型接入、连通性测试、模型清单、用途分配。
* 挂载点:/api/ai
*
* 权限:
* - 接入配置的读与写都要 `ai.manage`(配置里有 base_url 与密钥打码,属系统管理面);
* - `POST /api/ai/chat` 只要登录 —— 它是给所有用户用的对话能力,不是管理动作。
*
* 多模型并行(Wayne 2026-09-15):
* 可以同时接多家、每家多个模型,然后按「用途」把不同模型派给不同活 ——
* 合同解析用长文本强的,图片识别用多模态的,日常对话用便宜的。
*/
const express = require('express');
const auth = require('../lib/auth');
const ai = require('../lib/aiProviders');
const { MODES, PRESETS, PURPOSES, PURPOSE_KEYS } = require('../lib/aiPresets');
const router = express.Router();
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/不能为空|不正确|不存在|无效|格式|只能|超过/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/** GET /api/ai/config —— 一次拿全:预设、兼容模式、用途、已接入的服务商、用途分配 */
router.get('/config', auth.requirePermission('ai.manage'), (req, res) => {
try {
res.json({
presets: PRESETS,
modes: MODES,
purposes: PURPOSES,
providers: ai.listProviders().map(ai.publicProvider),
assignments: ai.listAssignments(),
overview: ai.overview()
});
} catch (error) {
fail(res, error);
}
});
/** GET /api/ai/status —— 轻量状态:小皮这类普通用户界面用(不含任何配置细节) */
router.get('/status', auth.requireAuth, (req, res) => {
try {
const resolved = ai.resolveAssignment('assistant_chat');
res.json({
configured: Boolean(resolved),
assistant: resolved ? { provider: resolved.provider.name, model: resolved.model } : null
});
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers —— 新增一家模型服务 */
router.post('/providers', auth.requirePermission('ai.manage'), (req, res) => {
try {
const provider = ai.upsertProvider(req.body || {});
auth.audit({
username: req.user.username,
action: 'ai_provider_create',
detail: `接入模型服务「${provider.name}」(${provider.base_url} · ${provider.mode})`,
req
});
res.status(201).json({ provider: ai.publicProvider(provider) });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/ai/providers/:id —— 改配置(密钥不回传,省略即保持原值) */
router.patch('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
try {
const provider = ai.upsertProvider(req.body || {}, req.params.id);
auth.audit({
username: req.user.username,
action: 'ai_provider_update',
detail: `更新接入「${provider.name}」:${provider.enabled === false ? '停用' : '启用'} · 模型 ${provider.models.length} 个`,
req
});
res.json({ provider: ai.publicProvider(provider) });
} catch (error) {
fail(res, error);
}
});
/** DELETE /api/ai/providers/:id —— 移除接入(顺手清掉指向它的用途分配) */
router.delete('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
try {
const result = ai.removeProvider(req.params.id);
auth.audit({
username: req.user.username,
action: 'ai_provider_delete',
detail: `移除接入「${result.removed.name}」${result.cleared_assignments ? `(同时清掉 ${result.cleared_assignments} 处用途分配)` : ''}`,
req
});
res.json(result);
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers/:id/test —— 连通性测试(真发请求) */
router.post('/providers/:id/test', auth.requirePermission('ai.manage'), async (req, res) => {
try {
const provider = ai.getProvider(req.params.id);
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
const result = await ai.testProvider(provider);
ai.rememberTest(provider.id, {
ok: result.ok,
at: result.at,
latency_ms: result.latency_ms,
detail: result.detail
});
auth.audit({
username: req.user.username,
action: 'ai_provider_test',
detail: `连通测试「${provider.name}」:${result.ok ? '通过' : '失败'} · ${result.detail}`,
req,
ok: result.ok ? 1 : 0
});
res.json({ result, provider: ai.publicProvider(ai.getProvider(provider.id)) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers/:id/models —— 拉取可用模型(save=1 时顺便存进该接入) */
router.post('/providers/:id/models', auth.requirePermission('ai.manage'), async (req, res) => {
try {
const provider = ai.getProvider(req.params.id);
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
const result = await ai.fetchModels(provider);
if (!result.ok && !result.models.length) {
return res.status(400).json({ error: result.error || '未能读取模型清单' });
}
let saved = null;
if (String(req.body?.save || '') === '1' && result.models.length) {
saved = ai.publicProvider(ai.upsertProvider({ models: result.models }, provider.id));
}
res.json({ models: result.models, source: result.source, detail: result.detail, provider: saved });
} catch (error) {
fail(res, error);
}
});
/**
* PUT /api/ai/assignments —— 保存「用途 → 模型」分配。
* 传 { assignments: { purpose: { provider_id, model } | null } },整体替换。
*/
router.put('/assignments', auth.requirePermission('ai.manage'), (req, res) => {
try {
const input = req.body?.assignments;
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)) {
throw Object.assign(new Error('分配内容格式不正确'), { statusCode: 400 });
}
const providers = ai.listProviders();
const next = {};
for (const [purpose, value] of Object.entries(input)) {
if (!PURPOSE_KEYS.includes(purpose)) {
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
}
if (value === null || value === undefined || value === '') continue;
const provider = providers.find(item => item.id === String(value.provider_id));
if (!provider) throw Object.assign(new Error(`用途「${purpose}」指向的模型接入不存在`), { statusCode: 400 });
const model = String(value.model || '').trim();
if (!model) continue;
next[purpose] = { provider_id: provider.id, model };
}
ai.saveAssignments(next);
auth.audit({
username: req.user.username,
action: 'ai_assignment_update',
detail: `用途分配:${Object.entries(next).map(([k, v]) => `${k}→${v.model}`).join(',') || '(全部清空)'}`,
req
});
res.json({ assignments: next, overview: ai.overview() });
} catch (error) {
fail(res, error);
}
});
/**
* POST /api/ai/chat —— 按用途发起一次真实对话(试跑 / 小皮内部使用)。
* body: { prompt | messages, purpose, temperature }
*/
router.post('/chat', auth.requireAuth, async (req, res) => {
try {
const purpose = String(req.body?.purpose || 'assistant_chat');
if (!PURPOSE_KEYS.includes(purpose)) {
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
}
const messages = Array.isArray(req.body?.messages) && req.body.messages.length
? req.body.messages.map(item => ({ role: item.role === 'assistant' ? 'assistant' : 'user', content: String(item.content || '') }))
: [{ role: 'user', content: String(req.body?.prompt || '').trim() }];
if (!messages[0].content) throw Object.assign(new Error('请输入内容'), { statusCode: 400 });
const result = await ai.chatByPurpose(purpose, messages, {
temperature: req.body?.temperature
});
if (!result) {
return res.status(409).json({ error: '还没有为这个用途分配模型,请先在「设置 → 智能配置」里接入并分配' });
}
if (!result.ok) return res.status(502).json({ error: `${result.provider} 调用失败:${result.error}`, ...result });
res.json(result);
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+182
View File
@@ -0,0 +1,182 @@
/**
* 账号与个人资料。
* 挂载点:/api(路由内部自带 /auth/* 与 /me* 前缀)
*
* 免登录白名单在 server.js 的 /api 登录门里声明(health / auth\/ / system\/),
* 所以这里 /auth/login、/auth/logout、/auth/me 都可以在没有会话时访问。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const { ROLES } = require('../lib/permissions');
const router = express.Router();
const ROLE_VALUES = ROLES.map(item => item.value);
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/必须|不能为空|不正确|不存在|不一致|格式|至少|已停用|已锁定/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/* ------------------------------------------------------------ 登录相关 */
/** POST /api/auth/login */
router.post('/auth/login', (req, res) => {
try {
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (!username) throw badRequest('请输入用户名');
if (!password) throw badRequest('请输入密码');
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
auth.audit({ username, action: 'login_failed', detail: '用户名不存在', req, ok: 0 });
throw badRequest('用户名或密码不正确');
}
if (user.locked_until && new Date(user.locked_until).getTime() > Date.now()) {
const minutes = Math.max(1, Math.ceil((new Date(user.locked_until).getTime() - Date.now()) / 60000));
auth.audit({ username, action: 'login_blocked', detail: '账号锁定中', req, ok: 0 });
throw badRequest(`账号已锁定,请 ${minutes} 分钟后再试`);
}
if (!user.is_active) {
auth.audit({ username, action: 'login_blocked', detail: '账号已停用', req, ok: 0 });
throw badRequest('账号已停用,请联系管理员');
}
if (!auth.verifyPassword(password, user.password_hash)) {
const failed = Number(user.failed_count || 0) + 1;
const lockedUntil = failed >= auth.MAX_FAILED
? new Date(Date.now() + auth.LOCK_MS).toISOString()
: null;
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?').run(failed, lockedUntil, user.id);
auth.audit({ username, action: 'login_failed', detail: `密码错误(第 ${failed} 次)`, req, ok: 0 });
throw badRequest(lockedUntil
? `密码连续错误 ${failed} 次,账号已锁定 15 分钟`
: '用户名或密码不正确');
}
const now = new Date().toISOString();
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL, last_login_at = ? WHERE id = ?').run(now, user.id);
const sessionId = auth.createSession(user.id, req);
auth.setSessionCookie(res, sessionId);
auth.audit({ username: user.username, action: 'login', detail: '登录成功', req });
res.json({ user: auth.publicUser({ ...user, last_login_at: now }) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/auth/logout */
router.post('/auth/logout', (req, res) => {
try {
if (req.user) {
auth.destroySession(req.user.sid);
auth.audit({ username: req.user.username, action: 'logout', detail: '退出登录', req });
}
auth.clearSessionCookie(res);
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
});
/** GET /api/auth/me —— 前端启动时用它判断是否已登录 */
router.get('/auth/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: '请先登录' });
res.json({ user: auth.publicUser(req.user) });
});
/* ------------------------------------------------------------ 个人资料 */
const PROFILE_TEXT_FIELDS = [
['display_name', '显示名'],
['email', '联系邮箱'],
['phone', '电话'],
['address', '地址'],
['position', '职位'],
['avatar', '头像']
];
/** PATCH /api/me —— 保存个人资料 */
router.patch('/me', (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const fields = [];
const values = [];
for (const [key, label] of PROFILE_TEXT_FIELDS) {
if (req.body?.[key] === undefined) continue;
const value = String(req.body[key] ?? '').trim();
if (key === 'display_name' && !value) throw badRequest('显示名不能为空');
if (key === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw badRequest('联系邮箱格式不正确');
}
fields.push(`${key} = ?`);
values.push(value || null);
}
if (req.body?.role !== undefined) {
const role = String(req.body.role || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内');
fields.push('role = ?');
values.push(role);
}
if (req.body?.org_id !== undefined) {
const orgId = Number(req.body.org_id);
if (!Number.isInteger(orgId) || !db.prepare('SELECT id FROM organizations WHERE id = ?').get(orgId)) {
throw badRequest('所属组织不存在');
}
fields.push('org_id = ?');
values.push(orgId);
}
if (!fields.length) throw badRequest('没有需要保存的内容');
fields.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(req.user.id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
auth.audit({ username: updated.username, action: 'profile_update', detail: '更新个人资料', req });
res.json({ user: auth.publicUser(updated), message: '个人资料已保存' });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
router.patch('/me/password', (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const current = String(req.body?.current_password || '');
const next = String(req.body?.new_password || '');
const confirm = String(req.body?.confirm_password || '');
if (!current) throw badRequest('请输入当前密码');
if (!next) throw badRequest('请输入新密码');
if (next.length < 6) throw badRequest('新密码至少 6 位');
if (next !== confirm) throw badRequest('两次输入的新密码不一致');
if (next === current) throw badRequest('新密码不能与当前密码相同');
if (!auth.verifyPassword(current, req.user.password_hash)) {
auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 });
throw badRequest('当前密码不正确');
}
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
/* 保住当前会话,踢掉其它设备上已登录的会话 */
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
auth.audit({ username: req.user.username, action: 'password_change', detail: '修改密码成功', req });
res.json({ ok: true, message: '密码已修改' });
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+431
View File
@@ -0,0 +1,431 @@
/**
* 合同清洗中心:解析规则、AI 兜底解析、批量入库、原件下载。
* 挂载点:/api/contracts(挂在登录门之后,见 server.js)
*
* 与 server.js 里那三个基础接口(上传 / 任务列表 / 单条复核)的分工:
* 那边只管「把文件变成待复核的行」,这边管「行怎么认出来的、怎么进产品库」。
*
* 权限口径:仍然沿用 `cleanup.view` —— 系统里目前没有 `cleanup.edit` 这个点,
* 复核与入库都属于「合同清洗」这一个能力块。等以后要拆细粒度再补点。
*/
const express = require('express');
const path = require('path');
const db = require('../db/db');
const auth = require('../lib/auth');
const catalog = require('../lib/skuCatalog');
const parser = require('../lib/contractParser');
const files = require('../lib/contractFiles');
const aiProviders = require('../lib/aiProviders');
const router = express.Router();
const nowIso = () => new Date().toISOString();
function badRequest(message) {
return Object.assign(new Error(message), { statusCode: 400 });
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode || (/不存在|无效|格式|不能为空|请先|没有|已过期/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
const getJob = id => db.prepare('SELECT * FROM contract_import_jobs WHERE id = ?').get(Number(id));
/* ============================== 解析规则 ============================== */
/**
* 当前生效的规则(内置默认 + 用户覆盖)。
* 写这个接口是为了让「通用性」可验证:用户能一眼看到系统到底认哪些词,
* 再照着 JSON 覆盖文件去加自己行业的说法。
*/
router.get('/parse-rules', (req, res) => {
try {
const rules = parser.loadRules();
res.json({
options: rules.options,
fields: Object.entries(rules.fields).map(([key, field]) => ({
key,
label: field.label,
type: field.type,
keys: field.keys,
exclude: field.exclude,
regex: field.regex || []
})),
override_source: rules.override_source,
override_file: parser.overrideFile,
hint: '覆盖文件(可选):在数据目录放 contract-rules.json,或在设置表 settings["contract.rules"] 写同样的 JSON。'
});
} catch (error) { fail(res, error); }
});
/** 保存覆盖(存 settings;文件形式的覆盖优先级更高,见 contractParser.readOverride) */
router.put('/parse-rules', auth.requirePermission('cleanup.view'), (req, res) => {
try {
const body = req.body;
if (!body || typeof body !== 'object' || Array.isArray(body)) throw badRequest('规则必须是 JSON 对象');
const allowed = ['fields', 'options', 'disable'];
if (!Object.keys(body).some(key => allowed.includes(key))) {
throw badRequest(`规则里至少要有一项:${allowed.join(' / ')}`);
}
if (body.fields && (typeof body.fields !== 'object' || Array.isArray(body.fields))) throw badRequest('fields 必须是对象');
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('contract.rules', JSON.stringify(body));
auth.audit({ username: req.user?.username, action: 'contract_rules_update', detail: '更新合同解析规则覆盖', req });
res.json({ ok: true, rules: parser.loadRules(), message: '规则已保存,下次解析立即生效' });
} catch (error) { fail(res, error); }
});
/* ============================== AI 解析 ============================== */
/** 从模型返回里把 JSON 数组抠出来:容忍 ```json 代码块、前后废话 */
function extractJsonArray(text) {
let body = String(text || '').trim();
body = body.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
const start = body.indexOf('[');
const end = body.lastIndexOf(']');
if (start < 0 || end <= start) return null;
try {
const parsed = JSON.parse(body.slice(start, end + 1));
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
const AI_FIELDS = ['sku', 'product_name', 'spec', 'purchase_price', 'currency', 'quantity', 'moq'];
const AI_SYSTEM_PROMPT = [
'你是外贸采购数据抽取引擎。用户会给你一张供应商合同/报价单里的表格(CSV 文本,可能由 Excel 转出,列的顺序与命名都不固定)。',
'请把每一行商品抽成 JSON。',
'',
'硬性要求:',
'1) 只输出一个 JSON 数组,不要任何解释、不要 markdown 代码块、不要多余字段。',
'2) purchase_price 必须是数值(采购价/供货价/出厂价/单价/EXW/FOB 都算);',
' 金额合计、总价、小计、税额、运费、建议零售价一律**不要**当采购价。',
'3) quantity 是数量,moq 是最小起订量,两者不要混。',
'4) currency 用 ISO 三字码(CNY/USD/EUR…),没有线索就 null。',
`5) 每个对象只允许这些键:${AI_FIELDS.join(', ')}。`,
'6) 抽不到的字段填 null,**不要编造**。',
'7) 忽略小计/合计/备注/页脚/公司抬头这类非商品行。'
].join('\n');
/**
* 用模型重解析一个任务。
*
* 为什么用「原件」而不是「已解析的行」:规则解析失败时(正是需要 AI 的场景),
* 队列里可能只有一行占位记录,把那些行喂给模型等于让它猜一份残缺的表。
* 所以优先读 7 天内还留着的原件;原件过期了才退回用队列里的原文。
*/
router.post('/import-jobs/:id/ai-parse', auth.requirePermission('cleanup.view'), async (req, res) => {
try {
const job = getJob(req.params.id);
if (!job) throw badRequest('导入任务不存在');
const resolved = aiProviders.resolveAssignment('contract_parse');
if (!resolved) {
throw badRequest('还没有给「合同智能解析」分配模型:请到 设置 → 智能配置 里,把某个接入分配给这个用途');
}
// 拼素材:优先原件,其次队列里的 raw_text
let sourceText = '';
let sourceLabel = '';
let sourceNames = [];
try { sourceNames = JSON.parse(job.source_files || '[]'); } catch { sourceNames = []; }
for (const name of sourceNames) {
const original = files.readOriginal(job.job_no, name);
if (!original) continue;
const described = parser.describeForAi(original.buffer, original.name, { maxRows: 120, maxChars: 9000 });
if (!described.text) continue;
sourceText += `\n### 文件:${original.name}\n${described.text}\n`;
sourceLabel = original.name;
if (sourceText.length > 16000) break;
}
if (!sourceText) {
const rows = db.prepare(
'SELECT row_no, raw_text FROM contract_import_items WHERE job_id = ? AND raw_text IS NOT NULL ORDER BY row_no LIMIT 120'
).all(job.id);
if (rows.length >= 2) {
sourceText = rows.map(row => row.raw_text).join('\n');
sourceLabel = '队列内已解析的原文';
}
}
if (!sourceText.trim()) {
throw badRequest('拿不到可解析的内容:原件已过保留期(7 天)被清理,请把文件重新上传一次');
}
const answer = await aiProviders.chatByPurpose('contract_parse', [
{ role: 'system', content: AI_SYSTEM_PROMPT },
{ role: 'user', content: `请抽取下面表格里的商品行:\n\n${sourceText}` }
], { maxTokens: 4000, temperature: 0 });
if (!answer) throw badRequest('没有可用的模型接入');
if (!answer.ok) throw Object.assign(new Error(`模型调用失败:${answer.error}`), { statusCode: 502 });
const list = extractJsonArray(answer.text);
if (!list || !list.length) throw badRequest('模型没有返回可用的 JSON 数组,建议稍后重试或改用手工补录');
const items = list.map((row, index) => ({
sku: row?.sku ? String(row.sku).trim().slice(0, 120) : null,
product_name: row?.product_name ? String(row.product_name).trim().slice(0, 200) : null,
spec: row?.spec ? String(row.spec).trim().slice(0, 200) : null,
purchase_price: parser.toNumber(row?.purchase_price),
currency: row?.currency ? String(row.currency).trim().toUpperCase().slice(0, 8) : null,
quantity: parser.toNumber(row?.quantity),
moq: parser.toNumber(row?.moq),
row_no: index + 1
})).filter(row => AI_FIELDS.some(key => row[key] !== null));
if (!items.length) throw badRequest('模型返回的内容里没有可用的字段,建议改用手工补录');
const now = nowIso();
let replaced = 0;
db.transaction(() => {
/* 只清掉「上一次机器解析出来的、还没被人处理过」的行。
已经复核过/已入库的行必须留着 —— 用户可能在 AI 之前已经手工修过几条。 */
const stale = db.prepare(
`SELECT COUNT(*) AS count FROM contract_import_items
WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL
AND COALESCE(parse_source, 'rules') = 'rules'`
).get(job.id).count;
db.prepare(
`DELETE FROM contract_import_items
WHERE job_id = ? AND status = 'pending_review' AND committed_at IS NULL
AND COALESCE(parse_source, 'rules') = 'rules'`
).run(job.id);
replaced = stale;
const ins = db.prepare(
`INSERT INTO contract_import_items (job_id, filename, row_no, raw_text, sku, product_name, spec,
purchase_price, currency, quantity, moq, status, review_note,
parse_source, created_at, updated_at)
VALUES (@job_id, @filename, @row_no, @raw_text, @sku, @product_name, @spec,
@purchase_price, @currency, @quantity, @moq, 'pending_review', @review_note,
'ai', @now, @now)`
);
for (const item of items) {
ins.run({
job_id: job.id,
filename: sourceLabel || (sourceNames[0] || 'AI 解析'),
row_no: item.row_no,
raw_text: JSON.stringify(item).slice(0, 500),
sku: item.sku,
product_name: item.product_name,
spec: item.spec,
purchase_price: item.purchase_price,
currency: item.currency,
quantity: item.quantity,
moq: item.moq,
review_note: 'AI 解析结果,提交前请复核',
now
});
}
db.prepare(`UPDATE contract_import_jobs SET status = 'pending_review', parse_source = 'ai',
notes = ?, updated_at = ? WHERE id = ?`)
.run(`AI 解析:${answer.provider} / ${answer.model} · 抽出 ${items.length} 行`, now, job.id);
})();
auth.audit({
username: req.user?.username,
action: 'contract_ai_parse',
detail: `任务 ${job.job_no} 用 AI 解析出 ${items.length} 行(替换未复核的规则行 ${replaced} 条)`,
req
});
res.json({
ok: true,
job: getJob(job.id),
extracted: items.length,
replaced,
provider: answer.provider,
model: answer.model,
message: `AI 解析出 ${items.length} 行,已进入待复核队列`
});
} catch (error) { fail(res, error); }
});
/* ============================== 入库(批量) ============================== */
/**
* 把勾选的明细写进产品库。
*
* 口径(Wayne 2026-09-16 定):
* - 已存在的货号 → 更新采购价(并写一条价格历史,来源记合同文件名)
* - 库里没有的货号 → 新建(沿用产品库原有的「撞号落 -COPY2」规则)
* - 入库即视为复核通过,同时补上 reviewed_by / committed_at —— 这两项以前是空的
*
* 逐条一个事务:一次勾几百条时不能让写锁一直握着(同上传那里的取舍)。
*/
router.post('/import-items/commit', auth.requirePermission('cleanup.view'), (req, res) => {
try {
const rawIds = Array.isArray(req.body?.ids) ? req.body.ids : [];
const ids = [...new Set(rawIds.map(Number).filter(Number.isInteger))].slice(0, 2000);
if (!ids.length) throw badRequest('请先勾选要入库的明细');
const actor = req.user?.username || null;
const orgId = req.user?.org_id ?? null;
const now = nowIso();
const summary = { updated: 0, created: 0, skipped: [], jobs: new Set(), contracts: new Set() };
for (const id of ids) {
const item = db.prepare('SELECT * FROM contract_import_items WHERE id = ?').get(id);
if (!item) { summary.skipped.push({ id, reason: '明细不存在' }); continue; }
if (item.committed_at) {
summary.skipped.push({ id, sku: item.product_sku, reason: '之前已经入库过了' });
continue;
}
const skuText = String(item.sku || '').trim();
if (!skuText) { summary.skipped.push({ id, reason: '没有货号,无法入库' }); continue; }
const price = parser.toNumber(item.purchase_price);
const moq = parser.toNumber(item.moq);
const key = catalog.normalizeSku(skuText);
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(key);
try {
let productSku = key;
if (existing) {
if (price === null) { summary.skipped.push({ id, sku: existing.display_sku || key, reason: '没有采购价,未更新' }); continue; }
db.transaction(() => {
/* 只补空字段,不覆盖已有的人工命名 —— 导入是来「补价」的,不是来改名的 */
db.prepare(
`UPDATE products SET
price = @price,
moq = COALESCE(@moq, moq),
name = COALESCE(NULLIF(name, ''), @name),
spec = COALESCE(NULLIF(spec, ''), @spec),
source_contract = @source_contract,
parse_method = @parse_method,
confidence = 'high',
last_updated = @now, updated_at = @now
WHERE sku = @sku`
).run({
sku: key,
price,
moq,
name: item.product_name?.trim() || null,
spec: item.spec?.trim() || null,
source_contract: item.filename,
parse_method: item.parse_source === 'ai' ? 'ai' : 'rules',
now
});
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, @old_price, @price, @old_moq, @moq, @currency, @supplier_id, @source, 'high', @now, @actor)`
).run({
sku: key,
old_price: existing.price,
price,
old_moq: existing.moq,
moq,
currency: existing.currency || 'CNY',
supplier_id: existing.supplier_id ?? null,
// 价格历史里只记合同文件名(不记路径、不记任务号)
source: item.filename,
now,
actor
});
})();
summary.updated += 1;
} else {
const created = catalog.insertItem(
{ sku: skuText, name: item.product_name, spec: item.spec, price, moq },
null, null, actor, orgId,
{
sourceContract: item.filename,
parseMethod: item.parse_source === 'ai' ? 'ai' : 'rules',
currency: item.currency || 'CNY'
}
);
if (created?.error) { summary.skipped.push({ id, reason: created.error }); continue; }
productSku = created.sku;
summary.created += 1;
}
db.prepare(
`UPDATE contract_import_items
SET status = 'approved', reviewed_by = ?, reviewed_at = ?,
updated_at = ?, committed_at = ?, product_sku = ?
WHERE id = ?`
).run(req.user?.id ?? null, now, now, now, productSku, id);
summary.jobs.add(item.job_id);
summary.contracts.add(item.filename);
} catch (error) {
summary.skipped.push({ id, reason: error.message });
}
}
// 任务的「待复核」可能清空了,状态跟着走(和单条复核同一套派生规则)
for (const jobId of summary.jobs) {
const remaining = db.prepare(
`SELECT COUNT(*) AS count FROM contract_import_items WHERE job_id = ? AND status = 'pending_review'`
).get(jobId).count;
db.prepare('UPDATE contract_import_jobs SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?')
.run(remaining ? 'pending_review' : 'reviewed', now, remaining ? null : now, jobId);
}
auth.audit({
username: actor,
action: 'contract_commit',
detail: `合同入库:更新 ${summary.updated} 条、新建 ${summary.created} 条、跳过 ${summary.skipped.length} 条`
+ (summary.contracts.size ? `(来源:${[...summary.contracts].join('、')})` : ''),
req
});
res.json({
ok: true,
updated: summary.updated,
created: summary.created,
skipped: summary.skipped,
message: `入库完成:更新 ${summary.updated} 条、新建 ${summary.created} 条`
+ (summary.skipped.length ? `、跳过 ${summary.skipped.length} 条` : '')
});
} catch (error) { fail(res, error); }
});
/* ============================== 原件(7 天) ============================== */
router.get('/import-jobs/:id/files', (req, res) => {
try {
const job = getJob(req.params.id);
if (!job) throw badRequest('导入任务不存在');
const list = files.listOriginals(job.job_no);
const created = Date.parse(job.created_at);
const expires = Number.isFinite(created)
? new Date(created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
: null;
res.json({
files: list,
retention_days: files.RETENTION_DAYS,
expires_at: expires,
expired: list.length === 0 && Number.isFinite(created) && Date.now() > created + files.RETENTION_DAYS * 24 * 60 * 60 * 1000
});
} catch (error) { fail(res, error); }
});
/** 下载一份原件。过期清理后返回 410,让界面能说清「原件已清理」而不是报一个坏链接。 */
router.get('/import-jobs/:id/file', (req, res) => {
try {
const job = getJob(req.params.id);
if (!job) throw badRequest('导入任务不存在');
const name = String(req.query.name || '').trim();
if (!name) throw badRequest('缺少文件名参数 name');
const file = files.readOriginal(job.job_no, name);
if (!file) {
return res.status(410).json({
error: `原件已过保留期(${files.RETENTION_DAYS} 天)被自动清理,解析结果不受影响`
});
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(path.basename(file.name))}`
);
res.end(file.buffer);
} catch (error) { fail(res, error); }
});
module.exports = router;
+210
View File
@@ -0,0 +1,210 @@
/**
* 系统日志(审计轨迹)—— 读取 audit_log 并提供筛选、统计与导出。
*
* 为什么要有这一页:Wayne 2026-09-15 参照 GoodJob 的系统日志页提出 —— 登录、改密、
* 用户/权限组/组织变更、客户归属流转、商机状态、AI 接入变更都会写 audit_log,
* 但这些记录此前只能在库里看见,界面上没有任何入口。
*
* ⚠️ 挂载点刻意**不放在 /api/system/ 下** —— 那个前缀在免登录白名单里
* (`/^\/api\/(health|auth\/|system\/)/`),放在那儿等于把全公司的操作日志公开。
*
* 权限:`audit.view`,按 Wayne 的口径**只授给系统管理员组**(见 lib/permissions.js)。
* 界面入口同理:没这个点的人,设置里根本看不到「系统日志」这一项。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const router = express.Router();
/* ---------------------------------------------------------------------------
* 分类:按 action 前缀归组。新增 action 时如果这里没覆盖,会落到「系统」,
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
* ------------------------------------------------------------------------- */
const CATEGORIES = [
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_)/ },
{ key: 'user', label: '用户', match: /^user_/ },
{ key: 'permission', label: '权限', match: /^permission_group_/ },
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ },
{ key: 'customer', label: '客户', match: /^(customer_|pool_|prospect_)/ },
{ key: 'contract', label: '合同', match: /^contract_/ },
{ key: 'ai', label: '智能', match: /^ai_/ }
]
const CATEGORY_LABELS = { system: '系统', ...Object.fromEntries(CATEGORIES.map(c => [c.key, c.label])) }
/* 动作的中文名。查不到就显示原始 action —— 便于新动作上线时立刻可读,不用等改代码。 */
const ACTION_LABELS = {
login: '登录成功',
login_failed: '登录失败',
login_blocked: '登录被拦截',
logout: '退出登录',
profile_update: '更新个人资料',
password_change: '修改密码',
password_change_failed: '修改密码失败',
user_create: '新建用户',
user_update: '更新用户',
permission_group_create: '新建权限组',
permission_group_update: '更新权限组',
permission_group_delete: '删除权限组',
organization_create: '新建组织',
organization_update: '更新组织信息',
branding_update: '更新系统标识',
customer_create: '新建客户',
customer_to_pool: '客户丢入公海',
customer_claim: '公海领取客户',
prospect_deal_status: '商机状态变更',
ai_provider_create: '新增模型接入',
ai_provider_update: '更新模型接入',
ai_provider_delete: '移除模型接入',
ai_provider_test: '测试模型连通性',
ai_assignment_update: '调整模型用途分配',
contract_import: '导入合同',
contract_commit: '合同入库',
contract_ai_parse: '合同 AI 解析',
contract_rules_update: '更新合同解析规则'
}
function categoryOf(action = '') {
const hit = CATEGORIES.find(c => c.match.test(action))
return hit ? hit.key : 'system'
}
/* 等级:成功 / 信息 / 警告 / 错误。登录失败算「警告」——它通常是输错密码,
而不是系统故障,混进错误里会把真正的故障淹掉。 */
const WRITE_ACTION = /_(create|update|delete|change|claim)$/
function levelOf(row) {
if (row.ok) {
if (WRITE_ACTION.test(row.action) || row.action === 'customer_to_pool') return 'success'
return 'info'
}
return /^(login_failed|login_blocked)$/.test(row.action) ? 'warning' : 'error'
}
const LEVEL_LABELS = { info: '信息', success: '成功', warning: '警告', error: '错误' }
/* ---------------------------------------------------------------------------
* 过滤条件只在这里拼一次:列表、计数、统计、导出共用,避免四处走散。
* 全部走占位参数,不拼字符串。
* ------------------------------------------------------------------------- */
function buildFilter(query) {
const where = []
const params = []
const level = String(query.level || '').trim()
const category = String(query.category || '').trim()
const keyword = String(query.q || '').trim()
if (keyword) {
where.push('(username LIKE ? OR action LIKE ? OR detail LIKE ? OR ip LIKE ?)')
const like = `%${keyword}%`
params.push(like, like, like, like)
}
/* 分类与等级是「从 action / ok 算出来的」,SQL 里没有这两列,
所以先选出候选行再在内存里筛 —— 日志量级(万行内)完全够用。 */
const rows = db.prepare(
`SELECT id, at, username, action, detail, ip, user_agent, ok FROM audit_log
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY id DESC LIMIT 20000`
).all(...params)
const decorated = rows.map(row => ({
...row,
ok: row.ok ? 1 : 0,
category: categoryOf(row.action),
level: levelOf(row),
action_label: ACTION_LABELS[row.action] || row.action
}))
return decorated.filter(row =>
(!level || row.level === level) && (!category || row.category === category))
}
function dayStartIso() {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d.toISOString()
}
function statsOf(rows) {
/* 统计永远基于「当前筛选结果」,这样切了分类/等级之后四个数字才对得上列表。 */
const today = dayStartIso()
return {
total: rows.length,
today: rows.filter(r => r.at >= today).length,
warning: rows.filter(r => r.level === 'warning').length,
error: rows.filter(r => r.level === 'error').length
}
}
function csvCell(value) {
const text = value === null || value === undefined ? '' : String(value)
return `"${text.replace(/"/g, '""')}"`
}
/** GET /api/logs —— 分页列表(带统计与可选分类) */
router.get('/logs', auth.requirePermission('audit.view'), (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200)
const offset = Math.max(Number(req.query.offset) || 0, 0)
const all = buildFilter(req.query)
const categoryCounts = {}
for (const row of all) categoryCounts[row.category] = (categoryCounts[row.category] || 0) + 1
res.json({
items: all.slice(offset, offset + limit),
total: all.length,
limit,
offset,
stats: statsOf(all),
categories: Object.entries(CATEGORY_LABELS)
.map(([key, label]) => ({ key, label, count: categoryCounts[key] || 0 }))
.filter(item => item.count > 0),
levels: Object.entries(LEVEL_LABELS).map(([key, label]) => ({
key, label, count: all.filter(r => r.level === key).length
}))
})
} catch (error) {
res.status(500).json({ error: '读取日志失败:' + error.message })
}
});
/** GET /api/logs/export —— 导出当前筛选结果为 CSV(带 BOM,Excel 直接打开不乱码) */
router.get('/logs/export', auth.requirePermission('audit.view'), (req, res) => {
try {
const rows = buildFilter(req.query).slice(0, 5000)
const header = ['时间', '等级', '分类', '操作人', '动作', '说明', 'IP', '结果']
const lines = [header.map(csvCell).join(',')]
for (const row of rows) {
lines.push([
row.at,
LEVEL_LABELS[row.level] || row.level,
CATEGORY_LABELS[row.category] || row.category,
row.username || '—',
row.action_label,
row.detail || '',
row.ip || '',
row.ok ? '成功' : '失败'
].map(csvCell).join(','))
}
auth.audit({
username: req.user.username,
action: 'audit_export',
detail: `导出系统日志 ${rows.length} 条`,
req
})
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="system-logs-${stamp}.csv"`)
res.send('\uFEFF' + lines.join('\r\n'))
} catch (error) {
res.status(500).json({ error: '导出日志失败:' + error.message })
}
});
module.exports = router;
+113
View File
@@ -0,0 +1,113 @@
/**
* 新客开发的「开发结果管理」。
* 挂载点:/api/prospects
*
* 口径:新客开发页只做两件事 —— ① 用线索池找到新客户;② 管理开发结果。
* 开发结果就落在 customers.deal_status 上:pending 未成交 / won 已成交 / lost 已流失。
* 这样「我的客户」和「新客开发」看到的是同一批客户,不会出现两套数据打架。
*
* 归属口径(Wayne 2026-09-14):客户「谁建的谁看得见」,所以本页也按归属过滤,
* 否则业务员在「新客开发」里能直接看到并标记同事的客户。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const access = require('../lib/customerAccess');
const router = express.Router();
const DEAL_STATUSES = ['pending', 'won', 'lost'];
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode || (/不存在|无效|格式|不能为空/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
function countBy(status, user) {
const { conditions, params } = access.scopeFilter(user, 'all');
const where = ["COALESCE(c.deal_status, 'pending') = ?", ...conditions];
return db.prepare(`SELECT COUNT(*) AS c FROM customers c WHERE ${where.join(' AND ')}`)
.get(status, ...params).c;
}
/** GET /api/prospects?deal_status=won|lost|pending&q= */
router.get('/', auth.requirePermission('prospect.view'), (req, res) => {
try {
const status = String(req.query.deal_status || '').trim();
const keyword = String(req.query.q || '').trim();
/* 归属过滤与本页自己的筛选条件合并 —— 归属条件务必放进来,它是硬边界 */
const scope = access.scopeFilter(req.user, 'all');
const conditions = [...scope.conditions];
const params = [...scope.params];
if (DEAL_STATUSES.includes(status)) {
conditions.push("COALESCE(c.deal_status, 'pending') = ?");
params.push(status);
}
if (keyword) {
conditions.push('(c.company LIKE ? OR c.company_en LIKE ? OR c.country LIKE ? OR c.email LIKE ?)');
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
const items = db.prepare(`
SELECT c.*,
u.display_name AS owner_name,
(SELECT COUNT(*) FROM contacts ct WHERE ct.customer_id = c.id) AS contact_count,
(SELECT COUNT(*) FROM customer_quotations q WHERE q.customer_id = c.id) AS quote_count,
(SELECT MAX(f.created_at) FROM follow_up_records f WHERE f.customer_id = c.id) AS last_follow_at
FROM customers c
LEFT JOIN users u ON c.owner_id = u.id
${conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''}
ORDER BY COALESCE(c.updated_at, c.created_at) DESC
LIMIT 300
`).all(...params);
res.json({
items,
stats: {
pending: countBy('pending', req.user),
won: countBy('won', req.user),
lost: countBy('lost', req.user)
}
});
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/prospects/:id —— 标记成交状态 */
router.patch('/:id', auth.requirePermission('prospect.view'), (req, res) => {
try {
const id = Number(req.params.id);
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
if (!customer) throw badRequest('客户不存在');
/* 标记成交 = 改客户数据,别人的客户不能改 */
if (!access.canEdit(req.user, customer)) {
const error = new Error('这条客户不在你名下,请先到客户公海领取');
error.statusCode = 403;
throw error;
}
const status = String(req.body?.deal_status || '').trim();
if (!DEAL_STATUSES.includes(status)) throw badRequest('成交状态取值无效');
db.prepare('UPDATE customers SET deal_status = ?, updated_at = ? WHERE id = ?')
.run(status, new Date().toISOString(), id);
auth.audit({
username: req.user.username,
action: 'prospect_deal_status',
detail: `客户「${customer.company}」标记为 ${status}`,
req
});
res.json({ ok: true, item: db.prepare('SELECT * FROM customers WHERE id = ?').get(id) });
} catch (error) {
fail(res, error);
}
});
module.exports = router;
+721
View File
@@ -0,0 +1,721 @@
/**
* 产品目录(SKU)接口 —— 本地实现。
*
* 这些路径以前是 4300 原样转发给 sku-manager(3300)的,现在直接落在本库执行。
* **路径一个字没改**(/api/sku/… 与 /product-images/…),所以前端零改动。
*
* 两处刻意偏离原实现,都是因为 happy 这边多了一张 product_packaging 表:
* · 删除货号 / 批量删产品时,连带清掉它的包装参数 —— product_packaging.sku 有外键
* 指向 products(sku),不清就删不动;产品都没了,包装参数留着也没意义。
* · 其余照搬 sku-manager 的口径,包括「调价历史一律保留备查」「撞号落副本不报错」。
*/
const express = require('express');
const multer = require('multer');
const path = require('path');
const db = require('../db/db');
const catalog = require('../lib/skuCatalog');
const {
groupKey, normalizeSku, getItemAttrs, getGroupAttrs, replaceAttrs,
cleanupGroupIfEmpty, insertItem, getContractFile
} = catalog;
/* ------------------------------ 上传(图片) ------------------------------ */
const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
/* 直接进内存 → 立刻写进库里,磁盘上不留明文副本(合约与产品图都属于机密数据) */
const imageUpload = multer({
storage: multer.memoryStorage(),
defParamCharset: 'utf8',
fileFilter: (req, file, cb) => {
if (!ALLOWED_IMAGE_EXT.has(path.extname(file.originalname).toLowerCase())) return cb(null, false);
cb(null, true);
},
limits: { files: 20, fileSize: 15 * 1024 * 1024 }
});
/* -------------------------------- 公共辅助 -------------------------------- */
/** 当前操作者。产品库要留「谁传的、谁改的价」,登录用户就是唯一来源。 */
const actorOf = (req) => req.user?.display_name || req.user?.username || null;
const actorOrgOf = (req) => req.user?.org_id ?? null;
const fail = (res, error, statusCode) =>
res.status(statusCode || error?.statusCode || 500).json({ error: error?.message || '请求处理失败' });
const api = express.Router();
const images = express.Router();
/* =============================== 查询与检索 =============================== */
/**
* 产品级搜索:一行一个产品,聚合价格区间/货号数;命中的货号单独列出。
*
* 用 LIKE '%kw%' 全表扫 —— 前导通配符**用不上 B-tree 索引**,加索引也没用。
* 当前规模(516 组 / 1574 货号)端到端 P50 才 8ms 出头,远不到要优化的地步。
* 真要等到十万级货号再考虑 FTS5 虚拟表,而不是加索引。
*/
api.get('/search', (req, res) => {
const q = (req.query.q || '').trim();
const supplierId = req.query.supplier_id;
const onlyLow = req.query.only_low === '1';
const where = [];
const params = {};
if (q) {
where.push(`g.id IN (
SELECT DISTINCT g2.id FROM product_groups g2
LEFT JOIN products p2 ON p2.group_id = g2.id
LEFT JOIN item_attributes a2 ON (a2.group_id = g2.id OR a2.sku = p2.sku)
WHERE g2.name LIKE @q OR g2.brand LIKE @q OR g2.category LIKE @q OR g2.description LIKE @q
OR p2.sku LIKE @q OR p2.display_sku LIKE @q OR p2.name LIKE @q
OR p2.spec LIKE @q OR p2.description LIKE @q
OR a2.name LIKE @q OR a2.value LIKE @q
)`);
params.q = `%${q}%`;
}
if (supplierId === 'none') {
where.push('g.supplier_id IS NULL');
} else if (supplierId) {
where.push('g.supplier_id = @supplier_id');
params.supplier_id = Number(supplierId);
}
if (onlyLow) {
where.push(`EXISTS (SELECT 1 FROM products pl WHERE pl.group_id = g.id AND pl.confidence = 'low')`);
}
const groups = db.prepare(
`SELECT g.id, g.name, g.brand, g.category, g.supplier_id,
s.name AS supplier_name, s.short_name AS supplier_short,
(SELECT COUNT(*) FROM products p WHERE p.group_id = g.id) AS item_count,
(SELECT MIN(p.price) FROM products p WHERE p.group_id = g.id) AS price_min,
(SELECT MAX(p.price) FROM products p WHERE p.group_id = g.id) AS price_max,
(SELECT COUNT(*) FROM products p WHERE p.group_id = g.id AND p.confidence = 'low') AS low_count,
(SELECT MAX(p.last_updated) FROM products p WHERE p.group_id = g.id) AS last_updated,
(SELECT filename FROM product_images i WHERE i.group_id = g.id
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb,
(SELECT COUNT(*) FROM product_images i WHERE i.group_id = g.id) AS image_count
FROM product_groups g
LEFT JOIN suppliers s ON s.id = g.supplier_id
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY last_updated DESC LIMIT 300`
).all(params);
// 搜索词直接命中了哪些货号 —— 前端高亮用
if (q && groups.length) {
const ids = groups.map((g) => g.id);
const hits = db.prepare(
`SELECT p.group_id, p.sku, p.display_sku FROM products p
WHERE p.group_id IN (${ids.map(() => '?').join(',')})
AND (p.sku LIKE ? OR p.display_sku LIKE ? OR p.spec LIKE ? OR p.description LIKE ?
OR p.sku IN (SELECT a.sku FROM item_attributes a WHERE a.sku IS NOT NULL AND (a.name LIKE ? OR a.value LIKE ?)))`
).all(...ids, params.q, params.q, params.q, params.q, params.q, params.q);
const byGroup = {};
for (const h of hits) (byGroup[h.group_id] = byGroup[h.group_id] || []).push({ sku: h.sku, display_sku: h.display_sku });
for (const g of groups) g.matched_items = byGroup[g.id] || [];
}
res.json(groups);
});
/** 产品详情:产品 + 全部货号(含各自参数)+ 产品级参数 + 图片。 */
api.get('/group/:id', (req, res) => {
const id = Number(req.params.id);
const group = db.prepare(
`SELECT g.*, s.name AS supplier_name FROM product_groups g
LEFT JOIN suppliers s ON s.id = g.supplier_id WHERE g.id = ?`
).get(id);
if (!group) return res.status(404).json({ error: 'not found' });
const items = db.prepare(
`SELECT p.*, c.terms AS contract_terms,
(SELECT pi.filename FROM product_images pi
WHERE pi.group_id = p.group_id ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb
FROM products p
LEFT JOIN contracts c ON c.id = p.contract_id
WHERE p.group_id = ? ORDER BY p.display_sku`
).all(id);
for (const it of items) it.attributes = getItemAttrs.all(it.sku);
res.json({
group,
groupAttributes: getGroupAttrs.all(id),
items,
images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id)
});
});
/** 编辑产品(名称/品牌/分类/供应商/说明/产品级参数)。 */
api.put('/group/:id', (req, res) => {
const id = Number(req.params.id);
const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id);
if (!g) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
const name = b.name?.trim();
if (!name) return res.status(400).json({ error: '产品名称不能为空' });
db.prepare(
`UPDATE product_groups SET name=@name, group_key=@group_key, brand=@brand,
category=@category, description=@description, supplier_id=@supplier_id, updated_at=@now WHERE id=@id`
).run({
id,
name,
group_key: groupKey(name),
brand: b.brand?.trim() || null,
category: b.category?.trim() || null,
description: b.description?.trim() || null,
supplier_id: b.supplier_id ? Number(b.supplier_id) : null,
now: new Date().toISOString()
});
if (Array.isArray(b.attributes)) replaceAttrs({ groupId: id }, b.attributes);
res.json({ ok: true });
});
/* ================================ 货号读写 ================================ */
/** 新建产品(必须带第一个货号)。 */
api.post('/groups', (req, res) => {
const b = req.body || {};
const name = b.name?.trim();
if (!name) return res.status(400).json({ error: '产品名称不能为空' });
if (!b.item || !String(b.item.sku || '').trim()) return res.status(400).json({ error: '至少需要一个货号' });
const supplierId = b.supplier_id ? Number(b.supplier_id) : null;
const groupId = db.prepare(
`INSERT INTO product_groups (name, group_key, brand, category, description, supplier_id, created_at)
VALUES (@name, @group_key, @brand, @category, @description, @supplier_id, @created_at)`
).run({
name,
group_key: groupKey(name),
brand: b.brand?.trim() || null,
category: b.category?.trim() || null,
description: b.description?.trim() || null,
supplier_id: supplierId,
created_at: new Date().toISOString()
}).lastInsertRowid;
const r = insertItem({ ...b.item, name }, groupId, supplierId, actorOf(req), actorOrgOf(req));
if (r.error) {
cleanupGroupIfEmpty(groupId);
return res.status(400).json(r);
}
res.json({
group_id: groupId,
sku: r.sku,
display_sku: r.displaySku,
duplicated_from: r.duplicatedFrom || null,
uploader: actorOf(req)
});
});
/** 给已有产品加货号。 */
api.post('/group/:id/items', (req, res) => {
const id = Number(req.params.id);
const g = db.prepare('SELECT * FROM product_groups WHERE id = ?').get(id);
if (!g) return res.status(404).json({ error: 'not found' });
const r = insertItem({ ...req.body, name: req.body?.name ?? g.name }, id, g.supplier_id, actorOf(req), actorOrgOf(req));
if (r.error) return res.status(400).json(r);
res.json({
sku: r.sku,
display_sku: r.displaySku,
duplicated_from: r.duplicatedFrom || null,
uploader: actorOf(req)
});
});
/** 货号详情(编辑器用):含参数与调价历史。 */
api.get('/product/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
const product = db.prepare(
`SELECT p.*, s.name AS supplier_name, c.terms AS contract_terms
FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id
LEFT JOIN contracts c ON c.id = p.contract_id
WHERE p.sku = ?`
).get(sku);
if (!product) return res.status(404).json({ error: 'not found' });
product.attributes = getItemAttrs.all(sku);
const history = db.prepare(
`SELECT h.*, s.name AS supplier_name FROM price_history h
LEFT JOIN suppliers s ON s.id = h.supplier_id
WHERE h.sku = ? ORDER BY h.changed_at DESC LIMIT 50`
).all(sku);
res.json({ product, history });
});
/** 手工修正货号 —— 表头千奇百怪,总有识别不准需要人工改的。 */
api.put('/product/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku);
if (!existing) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
const moq = b.moq === '' || b.moq === null || b.moq === undefined ? null : Number(b.moq);
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return res.status(400).json({ error: '起订量必须是非负数字' });
/* 价格只有在没有合同来源时才允许手改 —— 合同解析出来的价格必须保持可追溯,
否则下次导入同一份合同又被覆盖,反而让人以为改丢了。 */
const priceEditable = !existing.contract_id;
let price = existing.price;
if (priceEditable && b.price !== undefined) {
price = b.price === '' || b.price === null ? null : Number(b.price);
if (price !== null && (!Number.isFinite(price) || price < 0)) {
return res.status(400).json({ error: '采购价必须是非负数字' });
}
}
// 移动到其他产品 / 独立成新产品
let targetGroup = existing.group_id;
const oldGroup = existing.group_id;
if (b.group_id === 'new') {
targetGroup = db.prepare(
`INSERT INTO product_groups (name, group_key, supplier_id, created_at)
VALUES (@name, @group_key, @supplier_id, @created_at)`
).run({
name: b.name?.trim() || existing.name || existing.display_sku || sku,
group_key: 'SKU:' + sku,
supplier_id: existing.supplier_id ?? null,
created_at: new Date().toISOString()
}).lastInsertRowid;
} else if (b.group_id !== undefined && b.group_id !== null && b.group_id !== '') {
const g = db.prepare('SELECT id FROM product_groups WHERE id = ?').get(Number(b.group_id));
if (!g) return res.status(400).json({ error: '目标产品不存在' });
targetGroup = g.id;
}
const now = new Date().toISOString();
db.transaction(() => {
db.prepare(
`UPDATE products SET
name = @name, spec = @spec, description = @description,
price = @price, moq = @moq, group_id = @group_id,
last_updated = @now, updated_at = @now
WHERE sku = @sku`
).run({
sku,
name: b.name?.trim() || null,
spec: b.spec?.trim() || null,
description: b.description?.trim() || null,
price,
moq,
group_id: targetGroup,
now
});
if (priceEditable && (price !== existing.price || moq !== existing.moq)) {
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, @old_price, @new_price, @old_moq, @new_moq, 'CNY',
@supplier_id, '手工修改', 'high', @now, @changed_by)`
).run({
sku,
old_price: existing.price,
new_price: price,
old_moq: existing.moq,
new_moq: moq,
supplier_id: existing.supplier_id ?? null,
now,
changed_by: actorOf(req)
});
}
})();
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
if (targetGroup !== oldGroup) cleanupGroupIfEmpty(oldGroup);
res.json({ ok: true, priceEditable, group_id: targetGroup });
});
/** 删除货号(调价历史保留备查;所在产品变空则一并清理)。 */
api.delete('/product/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
const existing = db.prepare('SELECT * FROM products WHERE sku = ?').get(sku);
if (!existing) return res.status(404).json({ error: 'not found' });
db.transaction(() => {
db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
db.prepare('DELETE FROM cart_items WHERE sku = ?').run(sku);
// happy 特有:包装参数挂在外键上,产品没了必须先摘掉,否则删不动
db.prepare('DELETE FROM product_packaging WHERE sku = ?').run(sku);
db.prepare('DELETE FROM products WHERE sku = ?').run(sku);
})();
cleanupGroupIfEmpty(existing.group_id);
res.json({ ok: true });
});
/** 批量删除产品(整个 SPU 连同下挂货号)。 */
api.post('/groups/delete', (req, res) => {
const ids = (Array.isArray(req.body?.ids) ? req.body.ids : [])
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
if (!ids.length) return res.status(400).json({ error: '没有选中任何产品' });
const ph = ids.map(() => '?').join(',');
const imgIds = db.prepare(`SELECT id FROM product_images WHERE group_id IN (${ph})`).all(...ids).map((r) => r.id);
let groups = 0;
let items = 0;
db.transaction(() => {
// 图片二进制跟着一起删,否则库里会积一堆没人引用的 BLOB,越攒越大
catalog.deleteImagesFor(imgIds);
const skus = db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph})`).all(...ids);
items = skus.length;
if (skus.length) {
const sph = skus.map(() => '?').join(',');
const args = skus.map((r) => r.sku);
db.prepare(`DELETE FROM item_attributes WHERE sku IN (${sph})`).run(...args);
db.prepare(`DELETE FROM cart_items WHERE sku IN (${sph})`).run(...args);
db.prepare(`DELETE FROM product_packaging WHERE sku IN (${sph})`).run(...args);
db.prepare(`DELETE FROM products WHERE sku IN (${sph})`).run(...args);
}
db.prepare(`DELETE FROM item_attributes WHERE group_id IN (${ph})`).run(...ids);
db.prepare(`DELETE FROM product_images WHERE group_id IN (${ph})`).run(...ids);
groups = db.prepare(`DELETE FROM product_groups WHERE id IN (${ph})`).run(...ids).changes;
})();
res.json({ ok: true, groups, items });
});
/* ================================= 小推车 ================================= */
const CART_COLUMNS = `
c.sku, c.qty, c.note, c.sort, c.added_at,
p.display_sku, p.spec, p.price, p.currency, p.moq,
p.description, p.confidence, p.last_updated,
g.id AS group_id, g.name AS product_name, g.brand, g.category,
s.name AS supplier_name, s.short_name AS supplier_short,
(SELECT filename FROM product_images i WHERE i.group_id = g.id
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS image`;
const cartRows = () => db.prepare(
`SELECT ${CART_COLUMNS}
FROM cart_items c
JOIN products p ON p.sku = c.sku
LEFT JOIN product_groups g ON g.id = p.group_id
LEFT JOIN suppliers s ON s.id = p.supplier_id
ORDER BY c.sort, c.id`
).all();
api.get('/cart', (req, res) => res.json(cartRows()));
/**
* 加入小推车。可以直接给货号,也可以给产品 id(该产品下所有货号一起进)。
* 已在车里的不动 —— 重复点「加入」不该把用户排好的顺序打乱。
*/
api.post('/cart', (req, res) => {
const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase());
const groupIds = (Array.isArray(req.body?.group_ids) ? req.body.group_ids : [])
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0);
if (groupIds.length) {
const ph = groupIds.map(() => '?').join(',');
for (const r of db.prepare(`SELECT sku FROM products WHERE group_id IN (${ph}) ORDER BY display_sku`).all(...groupIds)) {
skus.push(r.sku);
}
}
if (!skus.length) return res.status(400).json({ error: '没有可加入的货号' });
const now = new Date().toISOString();
let added = 0;
db.transaction(() => {
let sort = db.prepare('SELECT COALESCE(MAX(sort), -1) AS m FROM cart_items').get().m;
const exists = db.prepare('SELECT 1 FROM cart_items WHERE sku = ?');
const real = db.prepare('SELECT 1 FROM products WHERE sku = ?');
const ins = db.prepare('INSERT INTO cart_items (sku, sort, added_at) VALUES (@sku, @sort, @added_at)');
for (const sku of skus) {
if (exists.get(sku) || !real.get(sku)) continue;
ins.run({ sku, sort: ++sort, added_at: now });
added += 1;
}
})();
res.json({ ok: true, added, rows: cartRows() });
});
api.patch('/cart/:sku', (req, res) => {
const sku = req.params.sku.toUpperCase();
if (!db.prepare('SELECT 1 FROM cart_items WHERE sku = ?').get(sku)) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
if ('qty' in b) {
// 空/0 表示「没定数量」,出报价单时按未填处理,不要落成 0
const n = Number(b.qty);
db.prepare('UPDATE cart_items SET qty = ? WHERE sku = ?').run(Number.isFinite(n) && n > 0 ? Math.round(n) : null, sku);
}
if ('note' in b) {
db.prepare('UPDATE cart_items SET note = ? WHERE sku = ?').run(String(b.note || '').trim() || null, sku);
}
res.json({ ok: true, rows: cartRows() });
});
api.delete('/cart/:sku', (req, res) => {
db.prepare('DELETE FROM cart_items WHERE sku = ?').run(req.params.sku.toUpperCase());
res.json({ ok: true, rows: cartRows() });
});
api.post('/cart/clear', (req, res) => {
db.prepare('DELETE FROM cart_items').run();
res.json({ ok: true, rows: [] });
});
/** 拖拽排序落库。只认车里已有的货号,顺序按传入数组重排。 */
api.put('/cart/order', (req, res) => {
const skus = (Array.isArray(req.body?.skus) ? req.body.skus : []).map((s) => String(s).toUpperCase());
db.transaction(() => {
const upd = db.prepare('UPDATE cart_items SET sort = ? WHERE sku = ?');
skus.forEach((sku, i) => upd.run(i, sku));
})();
res.json({ ok: true, rows: cartRows() });
});
/* =============================== 参数与图片 =============================== */
/** 参数名自动补全 —— 输过一次的参数名,下次直接选。 */
api.get('/attr-names', (req, res) => {
const q = (req.query.q || '').trim();
const rows = q
? db.prepare('SELECT DISTINCT name FROM item_attributes WHERE name LIKE ? ORDER BY name LIMIT 30').all(`%${q}%`)
: db.prepare('SELECT DISTINCT name FROM item_attributes ORDER BY name LIMIT 30').all();
res.json(rows.map((r) => r.name));
});
/**
* 批量取货号缩略图。
*
* 「凡是出现货号的地方最前面都要有缩略图」,那些位置(询盘明细、报价明细、合同待复核、
* 选品器)手里只有货号、没有图片字段 —— 逐个调 /api/sku/product/:sku 既慢又拿不到图,
* 所以给一个一次问一批的入口。
*
* 图片挂在**产品组**上(product_images.group_id),先按货号找到组再取该组主图。
* 大小写不敏感(客户端传来的货号大小写不一定规范)。单次上限 500,防止被塞超大数组。
*/
api.get('/thumbs', (req, res) => {
const skus = [...new Set(String(req.query.skus || '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 500);
if (!skus.length) return res.json({ thumbs: {} });
const rows = db.prepare(
`SELECT p.sku,
(SELECT pi.filename FROM product_images pi
WHERE pi.group_id = p.group_id AND pi.group_id IS NOT NULL
ORDER BY pi.is_primary DESC, pi.id ASC LIMIT 1) AS thumb
FROM products p
WHERE p.sku IN (${skus.map(() => '?').join(',')}) COLLATE NOCASE`
).all(...skus);
const thumbs = {};
for (const row of rows) if (row.thumb) thumbs[row.sku] = row.thumb;
res.json({ thumbs });
});
api.post('/group/:id/images', imageUpload.array('images', 20), (req, res) => {
const id = Number(req.params.id);
if (!db.prepare('SELECT id FROM product_groups WHERE id = ?').get(id)) return res.status(404).json({ error: 'not found' });
if (!req.files || !req.files.length) {
return res.status(400).json({ error: '没有收到图片(只支持 jpg/png/webp/gif/bmp)' });
}
const existing = db.prepare('SELECT COUNT(*) AS n FROM product_images WHERE group_id = ?').get(id).n;
const insert = db.prepare(
`INSERT INTO product_images (sku, group_id, filename, original_name, is_primary, created_at)
VALUES ('', @group_id, @filename, @original_name, @is_primary, @created_at)`
);
db.transaction(() => {
req.files.forEach((f, i) => {
const ext = path.extname(f.originalname).toLowerCase();
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
const info = insert.run({
group_id: id,
filename,
original_name: f.originalname,
is_primary: existing === 0 && i === 0 ? 1 : 0,
created_at: new Date().toISOString()
});
// 二进制直接进库;文件本来就在内存里,磁盘上不留明文副本
catalog.storeImage(info.lastInsertRowid, f.buffer);
});
})();
res.json({
images: db.prepare('SELECT * FROM product_images WHERE group_id = ? ORDER BY is_primary DESC, id ASC').all(id)
});
});
api.post('/images/:id/primary', (req, res) => {
const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id));
if (!img) return res.status(404).json({ error: 'not found' });
db.transaction(() => {
db.prepare('UPDATE product_images SET is_primary = 0 WHERE group_id = ?').run(img.group_id);
db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(img.id);
})();
res.json({ ok: true });
});
api.delete('/images/:id', (req, res) => {
const img = db.prepare('SELECT * FROM product_images WHERE id = ?').get(Number(req.params.id));
if (!img) return res.status(404).json({ error: 'not found' });
db.prepare('DELETE FROM product_images WHERE id = ?').run(img.id);
// 删掉记录后把封面顺延给剩下的第一张
if (img.is_primary) {
const next = db.prepare('SELECT id FROM product_images WHERE group_id = ? ORDER BY id ASC LIMIT 1').get(img.group_id);
if (next) db.prepare('UPDATE product_images SET is_primary = 1 WHERE id = ?').run(next.id);
}
catalog.deleteImage(img.id);
res.json({ ok: true });
});
/** 下载合同原件(原件只存在库里,磁盘上没有)。 */
api.get('/contracts/:id/file', (req, res) => {
const f = getContractFile(Number(req.params.id));
if (!f) return res.status(404).json({ error: '库里没有这份合同的原件' });
res.setHeader('Content-Type', f.mime || 'application/octet-stream');
res.setHeader(
'Content-Disposition',
`attachment; filename="contract"; filename*=UTF-8''${encodeURIComponent(f.filename)}`
);
res.end(f.bytes);
});
/* ================================== 统计 ================================== */
api.get('/stats', (req, res) => {
res.json(db.prepare(
`SELECT
(SELECT COUNT(*) FROM product_groups) AS total_groups,
(SELECT COUNT(*) FROM products) AS total_skus,
(SELECT COUNT(*) FROM products WHERE confidence = 'low') AS low_confidence,
(SELECT COUNT(*) FROM suppliers) AS total_suppliers,
(SELECT COUNT(*) FROM contracts) AS contracts_processed`
).get());
});
/* ================================= 供应商 ================================= */
api.get('/suppliers', (req, res) => {
res.json(db.prepare(
`SELECT s.*,
(SELECT COUNT(*) FROM product_groups g WHERE g.supplier_id = s.id) AS group_count,
(SELECT COUNT(*) FROM products p WHERE p.supplier_id = s.id) AS sku_count
FROM suppliers s ORDER BY s.name`
).all());
});
/** 没归属供应商的产品数 —— 手工建的货常常一时定不下供应商。 */
api.get('/suppliers/unassigned/count', (req, res) => {
res.json(db.prepare('SELECT COUNT(*) AS count FROM product_groups WHERE supplier_id IS NULL').get());
});
api.get('/suppliers/:id', (req, res) => {
const id = Number(req.params.id);
const supplier = db.prepare('SELECT * FROM suppliers WHERE id = ?').get(id);
if (!supplier) return res.status(404).json({ error: 'not found' });
res.json({
supplier,
products: db.prepare(
`SELECT p.*, g.name AS group_name,
(SELECT filename FROM product_images i WHERE i.group_id = p.group_id
ORDER BY i.is_primary DESC, i.id ASC LIMIT 1) AS thumb
FROM products p LEFT JOIN product_groups g ON g.id = p.group_id
WHERE p.supplier_id = ? ORDER BY p.last_updated DESC LIMIT 500`
).all(id),
contracts: db.prepare('SELECT * FROM contracts WHERE supplier_id = ? ORDER BY processed_at DESC LIMIT 50').all(id)
});
});
const SUPPLIER_FIELDS = [
'name', 'short_name', 'contact_person', 'phone',
'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'
];
const supplierPayload = (body) => {
const out = {};
for (const f of SUPPLIER_FIELDS) out[f] = body?.[f] ?? null;
return out;
};
api.post('/suppliers', (req, res) => {
const data = supplierPayload(req.body);
if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' });
try {
const now = new Date().toISOString();
const info = db.prepare(
`INSERT INTO suppliers (name, short_name, contact_person, phone, email, address, website,
payment_terms, main_categories, notes, created_at, updated_at)
VALUES (@name, @short_name, @contact_person, @phone, @email, @address, @website,
@payment_terms, @main_categories, @notes, @created_at, @updated_at)`
).run({ ...data, created_at: now, updated_at: now });
res.json({ id: info.lastInsertRowid });
} catch (err) {
if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' });
fail(res, err);
}
});
api.put('/suppliers/:id', (req, res) => {
const data = supplierPayload(req.body);
if (!data.name) return res.status(400).json({ error: '供应商名称不能为空' });
try {
db.prepare(
`UPDATE suppliers SET name=@name, short_name=@short_name, contact_person=@contact_person,
phone=@phone, email=@email, address=@address, website=@website,
payment_terms=@payment_terms, main_categories=@main_categories, notes=@notes,
updated_at=@updated_at WHERE id=@id`
).run({ ...data, id: Number(req.params.id), updated_at: new Date().toISOString() });
res.json({ ok: true });
} catch (err) {
if (String(err.message).includes('UNIQUE')) return res.status(409).json({ error: '该供应商名称已存在' });
fail(res, err);
}
});
api.delete('/suppliers/:id', (req, res) => {
const id = Number(req.params.id);
// 产品保留原价,只是变成「未指派供应商」,而不是跟着被删
db.transaction(() => {
db.prepare('UPDATE products SET supplier_id = NULL WHERE supplier_id = ?').run(id);
db.prepare('UPDATE product_groups SET supplier_id = NULL WHERE supplier_id = ?').run(id);
db.prepare('DELETE FROM suppliers WHERE id = ?').run(id);
})();
res.json({ ok: true });
});
/* ============================== 产品图片文件 ============================== */
/**
* /product-images/<文件名>。
*
* 图片存在库里(product_image_files),按文件名去查而不是拼磁盘路径 ——
* 顺带也就没有路径穿越这回事了。
*
* 这里要求登录:产品图属于商业数据,原先挂在裸 static 上等于「猜到文件名就能拿图」。
* 页面里的 <img> 是同源请求,会带上会话 cookie,所以登录后照常显示。
*/
images.get('/:filename', (req, res) => {
if (!req.user) return res.status(401).end();
const name = req.params.filename;
const img = db.prepare('SELECT id FROM product_images WHERE filename = ?').get(name);
if (!img) return res.status(404).end();
const blob = catalog.getImage(img.id);
if (!blob) return res.status(404).end();
res.setHeader('Content-Type', catalog.mimeFor(name));
// 文件名带时间戳+随机串,内容不会变,可以让浏览器长缓存。
// private:机密数据,不许中间代理留副本。
res.setHeader('Cache-Control', 'private, max-age=604800');
res.end(blob.bytes);
});
module.exports = { api, images };
+14
View File
@@ -4,6 +4,7 @@
*/
const express = require('express');
const updater = require('../lib/updater');
const branding = require('../lib/branding');
const router = express.Router();
@@ -45,4 +46,17 @@ router.get('/check-update', async (req, res) => {
}
});
/**
* GET /api/system/branding
* 品牌与组织名。公开接口 —— 登录页在拿到会话之前就得知道系统叫什么。
* 只回显示用的名字,不含任何业务数据。
*/
router.get('/branding', (req, res) => {
try {
res.json(branding.getBranding());
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+1101 -87
View File
File diff suppressed because it is too large Load Diff