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:
+306
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
};
|
||||
Generated
+14
-1
@@ -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,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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user