数据迁移不再整体替换 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
152 lines
8.4 KiB
JavaScript
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
|
|
}
|