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

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

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

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

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
This commit is contained in:
Drsakura
2026-09-17 14:53:02 +08:00
parent 436519e0a6
commit 13d67c804f
77 changed files with 18407 additions and 955 deletions
+297
View File
@@ -0,0 +1,297 @@
/**
* 产品目录(SKU 模块)的本地数据层。
*
* 这块原先是独立的 sku-manager 服务(3300),happy 通过 /api/sku 代理访问它。
* 合并后数据直接落在 trade.db:少一个进程、少一个端口,开源部署只跑 4300 + 5300。
*
* 表结构沿用 sku-manager 那套(products / product_groups / suppliers / item_attributes /
* product_images / price_history / cart_items),列名保持一致 —— 迁移是按原 id 整表搬过来的,
* 所以这里的外键关系与历史数据完全对得上,不需要任何 id 映射。
*/
const crypto = require('crypto');
const db = require('../db/db');
/* --------------------------------- 规范化 --------------------------------- */
/**
* 货号规范化:去掉空格、连字符、下划线、斜杠、点,再转大写。
* 合同里的「GD-100F」和产品库里的「GD100F」必须归到同一条。
*/
function normalizeSku(raw) {
if (!raw) return null;
return String(raw).toUpperCase().replace(/[\s\-_./]/g, '').trim();
}
/**
* 产品归组键:同一单品的不同尺寸归为一个产品。
* 只剥「尺寸/规格」类字样(8x10、200mm、1/2"、6寸),件数(6件套)保留 ——
* 套装件数不同是不同的产品。
*/
function groupKey(name) {
if (!name) return null;
let s = String(name).replace(/\s+/g, '');
s = s.replace(/\d+(\.\d+)?[x×*]\d+(\.\d+)?(mm|cm)?/gi, '');
s = s.replace(/\d+\/\d+(["″”寸]|英寸)?/g, '');
s = s.replace(/\d+(\.\d+)?(mm|cm|寸|英寸|inch|in|"|″|”|米)/gi, '');
s = s.replace(/[-—–()()]/g, '');
const key = s.toUpperCase();
return key || String(name).trim().toUpperCase();
}
/* ------------------------------ 二进制存储 ------------------------------ */
const MIME_BY_EXT = {
'.pdf': 'application/pdf',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv',
'.txt': 'text/plain',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp'
};
function mimeFor(name) {
const i = String(name).lastIndexOf('.');
return (i >= 0 && MIME_BY_EXT[String(name).slice(i).toLowerCase()]) || 'application/octet-stream';
}
const syncImageStmt = db.prepare(
`INSERT INTO product_image_files (image_id, bytes, size, stored_at)
VALUES (@image_id, @bytes, @size, @stored_at)
ON CONFLICT(image_id) DO UPDATE SET bytes = excluded.bytes, size = excluded.size, stored_at = excluded.stored_at`
);
const getImageStmt = db.prepare('SELECT bytes, size FROM product_image_files WHERE image_id = ?');
const delImageStmt = db.prepare('DELETE FROM product_image_files WHERE image_id = ?');
const storeImage = (imageId, buf) =>
syncImageStmt.run({ image_id: imageId, bytes: buf, size: buf.length, stored_at: new Date().toISOString() });
const getImage = (imageId) => getImageStmt.get(imageId);
const deleteImage = (imageId) => delImageStmt.run(imageId);
/** 批量删图(批量删产品时用),避免逐条 delete。 */
function deleteImagesFor(imageIds) {
if (!imageIds.length) return;
db.prepare(`DELETE FROM product_image_files WHERE image_id IN (${imageIds.map(() => '?').join(',')})`)
.run(...imageIds);
}
/* ------------------------------- 合同原件 ------------------------------- */
const putContractStmt = db.prepare(
`INSERT INTO contract_files (contract_id, filename, mime, size, sha256, bytes, stored_at)
VALUES (@contract_id, @filename, @mime, @size, @sha256, @bytes, @stored_at)
ON CONFLICT(contract_id) DO UPDATE SET
filename = excluded.filename, mime = excluded.mime, size = excluded.size,
sha256 = excluded.sha256, bytes = excluded.bytes, stored_at = excluded.stored_at`
);
/** 把一份合同原件收进库。返回 null 表示没收到(不算致命,解析结果才是主产物)。 */
function storeContractFile(contractId, bytes, displayName) {
if (!bytes || !bytes.length) return null;
const filename = displayName || `contract-${contractId}`;
putContractStmt.run({
contract_id: contractId,
filename,
mime: mimeFor(filename),
size: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes,
stored_at: new Date().toISOString()
});
return { size: bytes.length };
}
const getContractFile = (contractId) =>
db.prepare('SELECT filename, mime, size, bytes FROM contract_files WHERE contract_id = ?').get(contractId);
/* --------------------------------- 参数 --------------------------------- */
const getItemAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE sku = ? ORDER BY sort, id'
);
const getGroupAttrs = db.prepare(
'SELECT id, name, value, sort FROM item_attributes WHERE group_id = ? AND sku IS NULL ORDER BY sort, id'
);
/** 整组替换参数(货号级或产品级)。传入 [{name, value}],空名跳过。 */
function replaceAttrs({ sku = null, groupId = null }, attrs) {
if (!Array.isArray(attrs)) return;
db.transaction(() => {
if (sku) db.prepare('DELETE FROM item_attributes WHERE sku = ?').run(sku);
else db.prepare('DELETE FROM item_attributes WHERE group_id = ? AND sku IS NULL').run(groupId);
const ins = db.prepare(
'INSERT INTO item_attributes (group_id, sku, name, value, sort) VALUES (@group_id, @sku, @name, @value, @sort)'
);
attrs.forEach((a, i) => {
const name = String(a?.name || '').trim();
if (!name) return;
ins.run({ group_id: sku ? null : groupId, sku, name, value: String(a?.value ?? '').trim() || null, sort: i });
});
})();
}
/** 产品组变空后清理掉,避免列表里留下没有货号的壳(有图片的保留)。 */
function cleanupGroupIfEmpty(groupId) {
if (!groupId) return;
const hasItems = db.prepare('SELECT 1 FROM products WHERE group_id = ? LIMIT 1').get(groupId);
const hasImages = db.prepare('SELECT 1 FROM product_images WHERE group_id = ? LIMIT 1').get(groupId);
if (!hasItems && !hasImages) {
db.prepare('DELETE FROM item_attributes WHERE group_id = ?').run(groupId);
db.prepare('DELETE FROM product_groups WHERE id = ?').run(groupId);
}
}
/* --------------------------------- 货号 --------------------------------- */
function validatePriceMoq(b) {
const price = b.price === '' || b.price == null ? null : Number(b.price);
if (price !== null && (!Number.isFinite(price) || price < 0)) return { error: '采购价必须是非负数字' };
const moq = b.moq === '' || b.moq == null ? null : Number(b.moq);
if (moq !== null && (!Number.isFinite(moq) || moq < 0)) return { error: '起订量必须是非负数字' };
return { price, moq };
}
/**
* 新增货号。
*
* 货号重复**不当错误** —— 产品库是组织共享的,谁都能上传自己那一版:
* 撞号时落成兄弟副本 `<SKU>-COPY2 / -COPY3 …`,display_sku 加「-副本N」,
* 并用 duplicated_from 记住它复制自哪个货号,界面上标出来,避免两条同名货号看着像脏数据。
* 无论是否重复都记 uploader / org_id;手工价同时写 changed_by,保证「谁传的、谁改的价」可追。
*/
function insertItem(b, groupId, supplierId, actor = null, actorOrg = null, options = {}) {
const displaySku = String(b.sku || '').trim();
const base = normalizeSku(displaySku);
if (!base) return { error: '货号不能为空' };
const v = validatePriceMoq(b);
if (v.error) return v;
const taken = (candidate) => !!db.prepare('SELECT sku FROM products WHERE sku = ?').get(candidate);
let sku = base;
let display = displaySku;
let duplicatedFrom = null;
if (taken(base)) {
duplicatedFrom = base;
let n = 2;
while (taken(`${base}-COPY${n}`)) n += 1;
sku = `${base}-COPY${n}`;
display = `${displaySku}-副本${n}`;
}
const now = new Date().toISOString();
/* options 只在「带来源的批量导入」里传(目前是合同清洗中心的入库):
sourceContract —— 价格出处,只记合同**文件名**(Wayne 2026-09-16:不记路径、不记任务号)
parseMethod —— 'manual' | 'rules' | 'ai',用来区分这条货号是手录还是合同带进来的
currency —— 新建时按合同的币种落库;不传仍然是 CNY(产品库原有口径)
⚠️ 更新已有货号时**不动 currency** —— 汇率口径牵一发动全身,不在这里顺手改。 */
const sourceContract = options.sourceContract || null;
const parseMethod = options.parseMethod || 'manual';
const currency = String(options.currency || 'CNY').toUpperCase();
db.transaction(() => {
db.prepare(
`INSERT INTO products (sku, display_sku, name, spec, price, currency, moq, description,
supplier_id, contract_id, group_id, parse_method,
last_updated, source_contract, confidence,
uploader, org_id, duplicated_from, created_at, updated_at)
VALUES (@sku, @display_sku, @name, @spec, @price, @currency, @moq, @description,
@supplier_id, NULL, @group_id, @parse_method, @now, @source_contract, 'high',
@uploader, @org_id, @duplicated_from, @now, @now)`
).run({
sku,
display_sku: display,
name: b.name?.trim() || null,
spec: b.spec?.trim() || null,
price: v.price,
moq: v.moq,
currency,
description: b.description?.trim() || null,
supplier_id: supplierId,
group_id: groupId,
parse_method: parseMethod,
source_contract: sourceContract,
now,
uploader: actor,
org_id: actorOrg,
duplicated_from: duplicatedFrom
});
/* 手工价格同样留痕,保证「每个价格都能追到出处」这条不破功。
带来源时(合同入库)把合同文件名当出处写进去,手工录入才写「手工录入」。 */
if (v.price !== null) {
db.prepare(
`INSERT INTO price_history (sku, old_price, new_price, old_moq, new_moq, currency,
supplier_id, source_contract, confidence, changed_at, changed_by)
VALUES (@sku, NULL, @price, NULL, @moq, @currency, @supplier_id, @source_contract, 'high', @now, @changed_by)`
).run({
sku,
price: v.price,
moq: v.moq,
currency,
supplier_id: supplierId,
source_contract: sourceContract || '手工录入',
now,
changed_by: actor
});
}
})();
if (Array.isArray(b.attributes)) replaceAttrs({ sku }, b.attributes);
return { sku, displaySku: display, duplicatedFrom };
}
/* ------------------------------- 产品解析 ------------------------------- */
/**
* 解析货号,返回统一的 { product, packaging } 视图。
*
* 合并前这里要先打 SKU 服务、失败再回退本地镜像(镜像只兜底,绝不拿过期价去报价)。
* 现在数据就在本库,直接查即可 —— 单表主键命中,比原来走一趟 HTTP 快得多。
* packaging 始终取自本地 product_packaging(询盘/报价用的包装快照来源)。
*/
function resolveProduct(sku) {
const raw = String(sku || '').trim();
if (!raw) throw new Error('SKU 不能为空');
const key = normalizeSku(raw);
const row = db.prepare(
`SELECT p.*, s.name AS supplier_name FROM products p
LEFT JOIN suppliers s ON s.id = p.supplier_id WHERE p.sku = ?`
).get(key);
if (!row) throw new Error(`产品 SKU 不存在:${raw}`);
const product = {
...row,
sku: row.sku,
display_sku: row.display_sku || row.sku,
name: row.name || row.name_en || row.sku,
name_en: row.name_en || null,
price: row.price,
currency: row.currency || 'CNY',
moq: row.moq === undefined ? null : row.moq
};
const packaging = db.prepare('SELECT * FROM product_packaging WHERE sku = ?').get(row.sku) || null;
return { product, packaging };
}
module.exports = {
normalizeSku,
groupKey,
mimeFor,
storeImage,
getImage,
deleteImage,
deleteImagesFor,
storeContractFile,
getContractFile,
getItemAttrs,
getGroupAttrs,
replaceAttrs,
cleanupGroupIfEmpty,
validatePriceMoq,
insertItem,
resolveProduct
};