Files
Drsakura 1a759ef37d feat(data): 业务数据与系统数据分离的导入导出
数据迁移不再整体替换 trade.db —— 那条路会把账号、权限组、组织、设置(含大模型密钥)、
登录会话与审计日志一起搬到目标部署。改为按类别搬运业务数据,系统数据永远是目标部署自己那份。

- backend/lib/dataTransfer:表目录(30 张业务表,依赖顺序)、gzip JSONL 数据包(每表 sha256)、导入内核
- 归属统一给导入人;冲突跳过不覆盖;父记录按 __refs 自然键快照补壳
- 流水表(price_history)按整行内容判重:用 (sku, changed_at) 当键实测 643 行丢 186 行
- 预览在库副本上真跑一遍导入,报告与真导同源;导入前 VACUUM INTO 备份,单事务写入
- 新权限点 data.transfer(仅超管组),导出/导入均写审计
- 前端「数据迁移」页:按类别勾选导出、上传后预览逐表明细、确认导入;移除设置里的静态占位「数据交换」
- 测试:单测 7 项(含目录外键顺序、BLOB、重复导入、包被改坏)、API 端到端 1 项(两个真实实例互传)
- 用真实库副本彩排:3854 行 0 失败,29 张业务表行数全部一致,包 1.22 MB
2026-10-23 13:46:26 +08:00

152 lines
8.4 KiB
JavaScript

/**
* 业务数据目录 —— 导入 / 导出的唯一事实来源。
*
* 设计口径(Wayne 2026-10-23 定):
* ① **业务数据与系统数据必须分开**。业务表(客户、订单、产品…)可以整包搬去另一个
* 部署;系统表(用户、权限组、组织、会话、设置、审计)**永不随包走** ——
* 设置里还存着大模型密钥,会话里是登录态,搬过去等于给别人开后台。
* ② **归属一律给导入人**。同事之间快速同步、或职责交接时,「谁导入就归谁」,
* 导完再由管理员在界面上重新分配,比在包里猜账号可靠。
* ③ **冲突不覆盖**。线上已有的同一条记录(同货号 / 同供应商名 / 同订单号…)跳过并报告,
* 绝不静默改掉线上数据。
*
* 表清单一律按**依赖顺序**排列:父表在前、子表在后。导入时逐行插入,外键始终成立,
* 不需要 `PRAGMA foreign_keys=OFF` 这种「先塞进去再祈祷」的做法。
* 新增业务表时,只要在 TABLES 里按依赖顺序补一条,导出、导入、界面三处自动跟上。
*/
/** 可选的数据类别:一个类别 = 一组完整的业务闭环,可以单独导出/导入 */
const CATEGORY_LIST = [
{
key: 'catalog',
label: '产品目录',
description: '供应商、产品与品类、阶梯价、价格历史、包装、产品图片、合同认列记录'
},
{
key: 'customers',
label: '客户与往来',
description: '客户、联系人、商机、询盘、客户报价单、跟进记录与时间线'
},
{
key: 'orders',
label: '订单与支出',
description: '订单、订单明细、订单杂费(快递费/吊卡费等)'
},
{
key: 'todos',
label: '待办与日程',
description: '待办事项与日历日程(不含账号信息)'
},
{
key: 'prospects',
label: '新客开发',
description: '开发任务与抓取到的线索'
}
]
/**
* 业务表清单(依赖顺序!)。
*
* key —— 判定「线上是否已有同一条记录」的列。全部非空才参与去重;
* 某列为空时该行按新增处理(宁可多一条,也不误判成重复而丢数据)。
* contentKey —— 没有自然业务键的**流水表**(如价格历史)用整行内容比对。
* 价格历史里同一 SKU 在同一时间戳可能有多条改价(一次批量导入就会这样),
* 拿 (sku, changed_at) 当键会把其中一部分当成重复静默丢掉 —— 实测 643 行丢 186 行。
* 整行比对等价于「内容完全相同才算重复」,不会误杀。
* stub —— 被别的表引用、但包里没带父记录时,允许用 key[0] 建一条最小记录
* (例如导入订单时可以只补一个只有公司名的客户壳)。
* label —— 报告里给人看的名字。
*
* 刻意不在清单里的表:
* users / sessions / organizations / permission_groups / permission_group_permissions /
* role_permission_groups / settings / audit_log —— 系统数据,永不导出、永不导入。
* cart_items —— 只是本机「小推车」的临时挑选,不属于需要迁移的业务数据。
*/
const TABLES = [
/* ---------------- 产品目录 ---------------- */
{ table: 'suppliers', label: '供应商', category: 'catalog', key: ['name'], stub: true },
{ table: 'product_groups', label: '产品组', category: 'catalog', key: ['name', 'brand'], stub: true },
/* 合同是产品价格的来源凭据:products.contract_id 指向它,所以必须排在 products 之前 */
{ table: 'contracts', label: '合同认列记录', category: 'catalog', key: ['filename', 'processed_at'] },
{ table: 'products', label: '产品', category: 'catalog', key: ['sku'] },
{ table: 'supplier_quotes', label: '供应商报价', category: 'catalog', key: ['supplier_id', 'quote_date'] },
{ table: 'tier_prices', label: '阶梯价', category: 'catalog', key: ['sku', 'moq_from', 'currency'] },
{ table: 'price_history', label: '价格历史', category: 'catalog', contentKey: true },
{ table: 'item_attributes', label: '产品属性', category: 'catalog', key: ['sku', 'name'] },
{ table: 'product_images', label: '产品图片', category: 'catalog', key: ['sku', 'filename'] },
{ table: 'product_image_files', label: '产品图片文件', category: 'catalog', key: ['image_id'] },
{ table: 'contract_files', label: '合同原件', category: 'catalog', key: ['contract_id'] },
{ table: 'product_packaging', label: '包装规格', category: 'catalog', key: ['sku'] },
{ table: 'contract_import_jobs', label: '清洗任务', category: 'catalog', key: ['job_no'] },
{ table: 'contract_import_items', label: '清洗明细', category: 'catalog', key: ['job_id', 'filename', 'row_no'] },
/* ---------------- 客户与往来 ---------------- */
{ table: 'customers', label: '客户', category: 'customers', key: ['company'], stub: true },
{ table: 'contacts', label: '联系人', category: 'customers', key: ['customer_id', 'name'] },
{ table: 'deals', label: '商机', category: 'customers', key: ['customer_id', 'title'] },
{ table: 'customer_quotations', label: '客户报价单', category: 'customers', key: ['quote_no'] },
{ table: 'quotation_items', label: '报价明细', category: 'customers', key: ['quotation_id', 'sku'] },
{ table: 'inquiries', label: '询盘', category: 'customers', key: ['inquiry_no'] },
{ table: 'inquiry_items', label: '询盘明细', category: 'customers', key: ['inquiry_id', 'sku'] },
{ table: 'follow_up_records', label: '跟进记录', category: 'customers', key: ['customer_id', 'created_at'] },
/* ---------------- 订单 ---------------- */
{ table: 'orders', label: '订单', category: 'orders', key: ['order_no'] },
{ table: 'order_items', label: '订单明细', category: 'orders', key: ['order_id', 'sku', 'unit_price'] },
{ table: 'order_expenses', label: '订单杂费', category: 'orders', key: ['order_id', 'category', 'amount', 'spent_at'] },
/* activities 引用 customers / deals / orders 三张表,所以必须排在订单之后 ——
否则「时间线关联到哪张订单」这个外键会在导入时解析不到而丢成 NULL。
它属于「客户」类别,但依赖顺序优先于类别分组(类别只影响界面勾选)。 */
{ table: 'activities', label: '客户时间线', category: 'customers', key: ['customer_id', 'activity_type', 'created_at'] },
/* ---------------- 待办(todos 引用客户/商机/订单,故排在它们之后) ---------------- */
{ table: 'todos', label: '待办与日程', category: 'todos', key: ['title', 'created_at'] },
/* ---------------- 新客开发 ---------------- */
{ table: 'prospect_runs', label: '开发任务', category: 'prospects', key: ['name', 'created_at'] },
{ table: 'prospect_leads', label: '开发线索', category: 'prospects', key: ['run_id', 'company'] }
]
/**
* 「归属人」类列:导入时一律改写成执行导入的人。
* 这些列不随包迁移 —— 包里存的是原机器的用户 id,到这边既无意义也可能撞上别人。
*/
const OWNER_COLUMNS = new Set(['owner_id', 'created_by', 'assigned_to', 'reviewed_by'])
/** 归属组织:导入到当前部署的哪个组织(多组织阶段目前只有一个主组织) */
const ORG_COLUMNS = new Set(['org_id'])
const CATEGORY_KEYS = CATEGORY_LIST.map(item => item.key)
/** 按类别取表(保持依赖顺序) */
function tablesOfCategory(category) {
return TABLES.filter(item => item.category === category)
}
/** 展开选择:'all' 或省略时返回全部业务表 */
function resolveCategories(input) {
const list = Array.isArray(input)
? input.map(item => String(item || '').trim()).filter(Boolean)
: String(input || '').split(',').map(item => item.trim()).filter(Boolean)
if (!list.length || list.includes('all')) return CATEGORY_KEYS.slice()
const unknown = list.filter(item => !CATEGORY_KEYS.includes(item))
if (unknown.length) {
const error = new Error(`未知的数据类别:${unknown.join('、')}`)
error.statusCode = 400
throw error
}
/* 去重 + 保持 CATEGORY_LIST 的固定顺序,报告顺序才稳定 */
return CATEGORY_KEYS.filter(key => list.includes(key))
}
module.exports = {
CATEGORY_LIST,
CATEGORY_KEYS,
TABLES,
OWNER_COLUMNS,
ORG_COLUMNS,
tablesOfCategory,
resolveCategories
}