Files
Drsakura 13d67c804f 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
2026-09-17 14:53:02 +08:00

722 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 产品目录(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 };