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
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 数据包(.hdata)的写出与读取。
|
||||
*
|
||||
* 格式:gzip 压缩的 JSONL,三部分
|
||||
* 1. header —— 格式版本、导出时间、导出人、包含的类别与表
|
||||
* 2. 每行一条记录 { "t": "products", "r": { ... } },BLOB 列以 base64 字符串承载
|
||||
* 3. footer —— 每张表的行数与 sha256
|
||||
*
|
||||
* 为什么不是 zip、为什么不像数据库备份那样直接拷 .db:
|
||||
* - .db 备份是整库,连 users / settings / sessions 一起搬,正是这次要分开的东西;
|
||||
* - zip 需要额外依赖,而 gzip + JSONL 只用 Node 内置能力,还能逐行流式读写
|
||||
* (合同原件是 BLOB,未来单个文件几十 MB,不能整包读进内存);
|
||||
* - 每张表单独一个 sha256,导入前就能判断包是否被截断 / 传坏,
|
||||
* 而不是解析到一半才炸在某个 JSON 语法错误上。
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const zlib = require('zlib')
|
||||
const crypto = require('crypto')
|
||||
const readline = require('readline')
|
||||
const { Readable } = require('stream')
|
||||
const { pipeline } = require('stream/promises')
|
||||
|
||||
const { TABLES, OWNER_COLUMNS, ORG_COLUMNS, resolveCategories, CATEGORY_LIST } = require('./catalog')
|
||||
|
||||
const FORMAT = 'happy-data'
|
||||
const FORMAT_VERSION = 1
|
||||
const APP_NAME = 'happy出口通'
|
||||
|
||||
/** 单包上限:超过就提示按类别分开导,避免一次把几个 GB 读进内存 */
|
||||
const MAX_BUNDLE_BYTES = 100 * 1024 * 1024
|
||||
const MAX_ROWS = 2_000_000
|
||||
|
||||
/* ------------------------------------------------------------------ 元数据 */
|
||||
|
||||
const quote = name => `"${String(name).replace(/"/g, '""')}"`
|
||||
|
||||
/** 目标库 / 源库的物理结构:列、BLOB 列、主键列 */
|
||||
function tableInfo(db, table) {
|
||||
let columns
|
||||
try {
|
||||
columns = db.pragma(`table_info(${quote(table)})`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!columns || !columns.length) return null
|
||||
const pk = columns.find(column => Number(column.pk) === 1) || null
|
||||
return {
|
||||
columns: columns.map(column => ({ name: column.name, type: String(column.type || '').toUpperCase() })),
|
||||
blobs: new Set(columns.filter(column => String(column.type || '').toUpperCase().includes('BLOB')).map(column => column.name)),
|
||||
pk: pk ? pk.name : null,
|
||||
autoId: !!pk && pk.name === 'id'
|
||||
}
|
||||
}
|
||||
|
||||
/** 外键:{ from: 本表列, table: 父表, to: 父表列 } */
|
||||
function foreignKeys(db, table) {
|
||||
let list
|
||||
try {
|
||||
list = db.pragma(`foreign_key_list(${quote(table)})`)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return (list || []).map(item => ({ from: item.from, table: item.table, to: item.to }))
|
||||
}
|
||||
|
||||
/** 包内是否出现这个表(老库可能缺新表,按实际存在处理) */
|
||||
function tableExists(db, table) {
|
||||
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 导出 */
|
||||
|
||||
function encodeValue(value, isBlob) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (isBlob) return Buffer.isBuffer(value) ? value.toString('base64') : Buffer.from(String(value)).toString('base64')
|
||||
if (Buffer.isBuffer(value)) return value.toString('base64')
|
||||
return value
|
||||
}
|
||||
|
||||
function decodeValue(value, isBlob) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (isBlob) return Buffer.from(String(value), 'base64')
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一张表读成「行对象」,并按需附上父记录的自然键快照。
|
||||
*
|
||||
* 为什么要附 `__refs`:只导出订单时,订单里的 customer_id 在目标库里根本不存在。
|
||||
* 有了客户公司名这份快照,导入方就能按公司名找到客户、找不到才补一个空壳,
|
||||
* 而不是整行订单都导不进去。
|
||||
*/
|
||||
function* rowsOfTable(db, table, { info, refs, refCache }) {
|
||||
const columns = info.columns.map(column => column.name)
|
||||
const select = columns.map(quote).join(', ')
|
||||
const orderBy = info.autoId ? 'id' : quote(info.pk || columns[0])
|
||||
const statement = db.prepare(`SELECT ${select} FROM ${quote(table)} ORDER BY ${orderBy}`)
|
||||
|
||||
for (const row of statement.iterate()) {
|
||||
const payload = {}
|
||||
for (const name of columns) {
|
||||
const value = encodeValue(row[name], info.blobs.has(name))
|
||||
/* 空值不写进包里,体积能小一截(products 有 1576 行、不少列是空的) */
|
||||
if (value !== null) payload[name] = value
|
||||
}
|
||||
|
||||
const refValues = {}
|
||||
for (const ref of refs) {
|
||||
const raw = row[ref.from]
|
||||
if (raw === null || raw === undefined) continue
|
||||
const snapshot = refSnapshot(db, ref, raw, refCache)
|
||||
if (snapshot) refValues[ref.from] = snapshot
|
||||
}
|
||||
if (Object.keys(refValues).length) payload.__refs = refValues
|
||||
|
||||
yield payload
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 父记录的自然键快照。
|
||||
* 父表主键本身就是自然键(products.sku)时,值已经在行里,不用再查;
|
||||
* 主键是自增 id(customers / suppliers / product_groups)时按 id 取一次并缓存。
|
||||
*/
|
||||
function refSnapshot(db, ref, value, cache) {
|
||||
if (ref.to && ref.to !== 'id') return value
|
||||
const spec = TABLES.find(item => item.table === ref.table)
|
||||
if (!spec || !spec.key.length) return null
|
||||
const cacheKey = `${ref.table}:${value}`
|
||||
if (cache.has(cacheKey)) return cache.get(cacheKey)
|
||||
let snapshot = null
|
||||
try {
|
||||
const row = db.prepare(`SELECT ${spec.key.map(quote).join(', ')} FROM ${quote(ref.table)} WHERE id = ?`).get(value)
|
||||
if (row) snapshot = spec.key.length === 1 ? row[spec.key[0]] : Object.fromEntries(spec.key.map(key => [key, row[key]]))
|
||||
} catch { /* 父记录已被删:留空,导入时按「找不到父记录」处理 */ }
|
||||
cache.set(cacheKey, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** 选中类别 → 实际要导出的表(跳过源库里不存在的表) */
|
||||
function planTables(db, categories) {
|
||||
const selected = new Set(resolveCategories(categories))
|
||||
return TABLES.filter(spec => selected.has(spec.category) && tableExists(db, spec.table))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数据包的行流(不含 gzip)。
|
||||
* 交给 Readable.from 之后由 gzip 压到 HTTP 响应里 —— 逐行 yield,不整包驻留内存。
|
||||
*/
|
||||
async function* bundleLines(db, { categories, exporter }) {
|
||||
const plans = planTables(db, categories)
|
||||
const header = {
|
||||
type: 'header',
|
||||
format: FORMAT,
|
||||
version: FORMAT_VERSION,
|
||||
app: APP_NAME,
|
||||
exportedAt: new Date().toISOString(),
|
||||
categories: resolveCategories(categories),
|
||||
tableNames: plans.map(spec => spec.table),
|
||||
exporter: exporter ? { id: exporter.id, username: exporter.username, display_name: exporter.display_name } : null
|
||||
}
|
||||
yield JSON.stringify(header) + '\n'
|
||||
|
||||
const tableMeta = {}
|
||||
let totalRows = 0
|
||||
|
||||
for (const spec of plans) {
|
||||
const info = tableInfo(db, spec.table)
|
||||
if (!info) continue
|
||||
const refs = foreignKeys(db, spec.table).filter(ref =>
|
||||
!OWNER_COLUMNS.has(ref.from) && !ORG_COLUMNS.has(ref.from)
|
||||
&& TABLES.some(item => item.table === ref.table))
|
||||
const refCache = new Map()
|
||||
const hash = crypto.createHash('sha256')
|
||||
let count = 0
|
||||
|
||||
for (const payload of rowsOfTable(db, spec.table, { info, refs, refCache })) {
|
||||
const line = JSON.stringify({ t: spec.table, r: payload })
|
||||
hash.update(line)
|
||||
count++
|
||||
yield line + '\n'
|
||||
}
|
||||
|
||||
tableMeta[spec.table] = { rows: count, sha256: hash.digest('hex') }
|
||||
totalRows += count
|
||||
}
|
||||
|
||||
yield JSON.stringify({
|
||||
type: 'footer',
|
||||
version: FORMAT_VERSION,
|
||||
tables: tableMeta,
|
||||
rows: totalRows,
|
||||
categories: header.categories
|
||||
}) + '\n'
|
||||
}
|
||||
|
||||
/** 把数据包写到任意可写流(HTTP 响应 / 文件) */
|
||||
async function writeBundle(db, output, options) {
|
||||
const gzip = zlib.createGzip({ level: 6 })
|
||||
await pipeline(Readable.from(bundleLines(db, options)), gzip, output)
|
||||
}
|
||||
|
||||
/** 导出到文件(命令行工具与测试用) */
|
||||
async function writeBundleFile(db, filePath, options) {
|
||||
await writeBundle(db, fs.createWriteStream(filePath), options)
|
||||
return fs.statSync(filePath).size
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 读取 */
|
||||
|
||||
function openMaybeGzip(filePath) {
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
const magic = Buffer.alloc(2)
|
||||
const read = fs.readSync(fd, magic, 0, 2, 0)
|
||||
fs.closeSync(fd)
|
||||
const isGzip = read === 2 && magic[0] === 0x1f && magic[1] === 0x8b
|
||||
return fs.createReadStream(filePath).pipe(isGzip ? zlib.createGunzip() : new (require('stream').PassThrough)())
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验数据包。
|
||||
*
|
||||
* 校验不做「尽力而为」:行数或 sha256 对不上就整体拒绝,并指出是哪张表 ——
|
||||
* 半个包导进去比导不进去更难收拾。
|
||||
*/
|
||||
async function readBundle(filePath) {
|
||||
const stat = fs.statSync(filePath)
|
||||
if (stat.size > MAX_BUNDLE_BYTES) {
|
||||
const error = new Error(`数据包 ${(stat.size / 1024 / 1024).toFixed(1)} MB,超过 ${MAX_BUNDLE_BYTES / 1024 / 1024} MB 上限。请按类别分开导出。`)
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
const input = openMaybeGzip(filePath)
|
||||
const reader = readline.createInterface({ input, crlfDelay: Infinity })
|
||||
|
||||
let header = null
|
||||
let footer = null
|
||||
const rows = new Map()
|
||||
const hashes = new Map()
|
||||
const counts = new Map()
|
||||
let lineNo = 0
|
||||
|
||||
for await (const raw of reader) {
|
||||
const line = raw.trim()
|
||||
if (!line) continue
|
||||
lineNo++
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
const error = new Error(`数据包第 ${lineNo} 行不是合法 JSON,文件可能已损坏`)
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
if (parsed.type === 'header') {
|
||||
if (header) {
|
||||
const error = new Error('数据包出现重复的头部')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
if (parsed.format !== FORMAT) {
|
||||
const error = new Error('这不是本系统的数据包(缺少 happy-data 标识)')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
if (Number(parsed.version) > FORMAT_VERSION) {
|
||||
const error = new Error(`数据包版本 ${parsed.version} 高于本系统支持的 ${FORMAT_VERSION},请先升级系统`)
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
header = parsed
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed.type === 'footer') {
|
||||
footer = parsed
|
||||
continue
|
||||
}
|
||||
|
||||
if (!header) {
|
||||
const error = new Error('数据包缺少头部,无法识别格式')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
if (!parsed.t || !parsed.r || typeof parsed.r !== 'object') {
|
||||
const error = new Error(`数据包第 ${lineNo} 行缺少表名或记录内容`)
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!rows.has(parsed.t)) {
|
||||
rows.set(parsed.t, [])
|
||||
hashes.set(parsed.t, crypto.createHash('sha256'))
|
||||
counts.set(parsed.t, 0)
|
||||
}
|
||||
const list = rows.get(parsed.t)
|
||||
if (list.length >= MAX_ROWS) {
|
||||
const error = new Error(`数据包内 ${parsed.t} 超过 ${MAX_ROWS} 行,请按类别分开导入`)
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
list.push(parsed.r)
|
||||
counts.set(parsed.t, counts.get(parsed.t) + 1)
|
||||
hashes.get(parsed.t).update(line)
|
||||
}
|
||||
|
||||
if (!header) {
|
||||
const error = new Error('数据包为空或缺少头部')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
if (!footer) {
|
||||
const error = new Error('数据包缺少结尾清单,可能传输中断。请重新导出后上传。')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
const mismatches = []
|
||||
for (const [table, meta] of Object.entries(footer.tables || {})) {
|
||||
const actualRows = counts.get(table) || 0
|
||||
if (actualRows !== meta.rows) mismatches.push(`${table} 行数应为 ${meta.rows},实际 ${actualRows}`)
|
||||
else {
|
||||
const actualHash = (hashes.get(table) || crypto.createHash('sha256')).digest('hex')
|
||||
if (actualHash !== meta.sha256) mismatches.push(`${table} 校验值不符`)
|
||||
}
|
||||
}
|
||||
if (mismatches.length) {
|
||||
const error = new Error(`数据包校验失败:${mismatches.slice(0, 4).join(';')}${mismatches.length > 4 ? ` 等 ${mismatches.length} 项` : ''}。请重新导出后上传。`)
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
header,
|
||||
footer,
|
||||
rows,
|
||||
size: stat.size,
|
||||
categories: (header.categories || []).filter(key => CATEGORY_LIST.some(item => item.key === key))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FORMAT,
|
||||
FORMAT_VERSION,
|
||||
MAX_BUNDLE_BYTES,
|
||||
tableInfo,
|
||||
foreignKeys,
|
||||
tableExists,
|
||||
decodeValue,
|
||||
bundleLines,
|
||||
writeBundle,
|
||||
writeBundleFile,
|
||||
planTables,
|
||||
readBundle
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 业务数据目录 —— 导入 / 导出的唯一事实来源。
|
||||
*
|
||||
* 设计口径(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
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 业务数据导入内核。
|
||||
*
|
||||
* 三条硬规则(对应 Wayne 2026-10-23 定的口径):
|
||||
* ① **归属一律给导入人** —— owner_id / created_by / assigned_to 全部改写成本次导入的账号;
|
||||
* ② **冲突跳过,绝不覆盖** —— 线上已有同一条记录就跳过,并在报告里点名;
|
||||
* ③ **父记录可补壳** —— 只导订单时,客户按公司名找不到就建一条「只有公司名」的客户,
|
||||
* 报告里单列「自动补建」,导完提示补全信息。
|
||||
*
|
||||
* 这个函数是「纯函数式」的:给它一个已打开的库实例,它只在这个实例里写。
|
||||
* 因此同一个实现既能跑在**真实库**(apply),也能跑在**库的副本**(preview)——
|
||||
* 预览报告与实际导入完全同源,不存在「预览说能导、真导却失败」的两套逻辑。
|
||||
*/
|
||||
const { TABLES, OWNER_COLUMNS, ORG_COLUMNS, resolveCategories, CATEGORY_LIST } = require('./catalog')
|
||||
const { tableInfo, foreignKeys, tableExists, decodeValue } = require('./bundle')
|
||||
|
||||
const quote = name => `"${String(name).replace(/"/g, '""')}"`
|
||||
const MAX_WARNINGS_PER_TABLE = 20
|
||||
|
||||
/** 导入目标组织:优先取导入人所属组织,否则退回第一个组织 */
|
||||
function targetOrgId(db, user) {
|
||||
if (user && user.org_id) return user.org_id
|
||||
const row = db.prepare('SELECT id FROM organizations ORDER BY id LIMIT 1').get()
|
||||
return row ? row.id : null
|
||||
}
|
||||
|
||||
function makeContext(db, user) {
|
||||
return {
|
||||
db,
|
||||
userId: user ? user.id : null,
|
||||
orgId: targetOrgId(db, user),
|
||||
now: new Date().toISOString(),
|
||||
ids: new Map(), // table -> Map(包内旧 id -> 本库新 id)
|
||||
stubTotals: new Map(),
|
||||
statements: new Map()
|
||||
}
|
||||
}
|
||||
|
||||
function idMap(ctx, table) {
|
||||
if (!ctx.ids.has(table)) ctx.ids.set(table, new Map())
|
||||
return ctx.ids.get(table)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按自然键找已存在的记录(供去重与父记录解析共用)。
|
||||
*
|
||||
* 键里允许出现空值:product_groups 的键是「名称 + 品牌」,而品牌经常为空 ——
|
||||
* 若要求每列都非空才算可比较,这类表就会永远判定「不存在」,重复导入时成倍增长。
|
||||
* 所以空值按 `IS NULL` 比较,只要键里至少有一列有值就参与去重。
|
||||
*/
|
||||
function findByKey(db, table, keySpec, values) {
|
||||
const clauses = []
|
||||
const params = []
|
||||
for (const key of keySpec) {
|
||||
const value = values[key]
|
||||
if (value === undefined) return null
|
||||
if (value === null || value === '') {
|
||||
clauses.push(`(${quote(key)} IS NULL OR ${quote(key)} = '')`)
|
||||
} else {
|
||||
clauses.push(`${quote(key)} = ?`)
|
||||
params.push(value)
|
||||
}
|
||||
}
|
||||
if (!clauses.length) return null
|
||||
try {
|
||||
return db.prepare(`SELECT * FROM ${quote(table)} WHERE ${clauses.join(' AND ')} LIMIT 1`).get(...params) || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 父记录的自然键快照 → 列值对象 */
|
||||
function keyValuesFromSnapshot(parentSpec, snapshot) {
|
||||
if (snapshot === null || snapshot === undefined) return null
|
||||
if (typeof snapshot === 'object') return snapshot
|
||||
return { [parentSpec.key[0]]: snapshot }
|
||||
}
|
||||
/** 补一条最小父记录(客户只有公司名、供应商只有名字、产品组只有名字) */
|
||||
function createStub(ctx, parentSpec, keyValues) {
|
||||
const value = keyValues ? keyValues[parentSpec.key[0]] : null
|
||||
if (!value) return null
|
||||
const { db, now } = ctx
|
||||
if (parentSpec.table === 'customers') {
|
||||
/* 补出来的客户直接归导入人、状态置为名下(不给 pooled 状态,避免导完冒出一堆公海客户) */
|
||||
const result = db.prepare(`
|
||||
INSERT INTO customers (company, owner_id, created_by, org_id, status, pool_status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'active', 'owned', ?)
|
||||
`).run(String(value), ctx.userId, ctx.userId, ctx.orgId, now)
|
||||
ctx.stubTotals.set('customers', (ctx.stubTotals.get('customers') || 0) + 1)
|
||||
return result.lastInsertRowid
|
||||
}
|
||||
if (parentSpec.table === 'suppliers') {
|
||||
const result = db.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run(String(value), now)
|
||||
ctx.stubTotals.set('suppliers', (ctx.stubTotals.get('suppliers') || 0) + 1)
|
||||
return result.lastInsertRowid
|
||||
}
|
||||
if (parentSpec.table === 'product_groups') {
|
||||
const result = db.prepare('INSERT INTO product_groups (name, created_at) VALUES (?, ?)').run(String(value), now)
|
||||
ctx.stubTotals.set('product_groups', (ctx.stubTotals.get('product_groups') || 0) + 1)
|
||||
return result.lastInsertRowid
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个外键值:
|
||||
* ① 本包里刚导进去的同一条(id 映射表命中)
|
||||
* ② 本库已存在(按父记录自然键查)
|
||||
* ③ 都没有 → 允许补壳的补一个,否则置空
|
||||
*/
|
||||
function resolveParent(ctx, ref, rawValue, snapshot) {
|
||||
const parentSpec = TABLES.find(item => item.table === ref.table)
|
||||
const mapped = ctx.ids.get(ref.table)
|
||||
if (mapped && mapped.has(rawValue)) return mapped.get(rawValue)
|
||||
|
||||
if (parentSpec && parentSpec.key && parentSpec.key.length) {
|
||||
/* 父表主键就是自然键(products.sku):父记录在就沿用原值,不在就置空由约束决定成败 */
|
||||
if (ref.to && ref.to !== 'id') {
|
||||
const exists = ctx.db.prepare(`SELECT 1 FROM ${quote(ref.table)} WHERE ${quote(ref.to)} = ?`).get(rawValue)
|
||||
return exists ? rawValue : null
|
||||
}
|
||||
const keyValues = keyValuesFromSnapshot(parentSpec, snapshot)
|
||||
if (keyValues) {
|
||||
const row = findByKey(ctx.db, ref.table, parentSpec.key, keyValues)
|
||||
if (row) {
|
||||
if (mapped) mapped.set(rawValue, row.id)
|
||||
return row.id
|
||||
}
|
||||
if (parentSpec.stub) {
|
||||
const created = createStub(ctx, parentSpec, keyValues)
|
||||
if (created) {
|
||||
if (mapped) mapped.set(rawValue, created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function prepareStatement(ctx, sql) {
|
||||
if (!ctx.statements.has(sql)) ctx.statements.set(sql, ctx.db.prepare(sql))
|
||||
return ctx.statements.get(sql)
|
||||
}
|
||||
|
||||
/** 单表导入 */
|
||||
function importTable(ctx, spec, rows, info) {
|
||||
const stat = {
|
||||
table: spec.table,
|
||||
label: spec.label,
|
||||
category: spec.category,
|
||||
rows: rows.length,
|
||||
insert: 0,
|
||||
skip: 0,
|
||||
failed: 0,
|
||||
warnings: []
|
||||
}
|
||||
const warn = message => {
|
||||
if (stat.warnings.length < MAX_WARNINGS_PER_TABLE) stat.warnings.push(message)
|
||||
}
|
||||
|
||||
const columnsByName = new Map(info.columns.map(column => [column.name, column]))
|
||||
const fkByColumn = new Map(foreignKeys(ctx.db, spec.table).map(ref => [ref.from, ref]))
|
||||
const unknownColumns = new Set()
|
||||
|
||||
/* 去重用的列:普通表用目录里定义的业务键;流水表(price_history)用整行内容。
|
||||
整行比对时排除自增主键与「归属人 / 组织」—— 这两类本来就会被改写成本次导入的人,
|
||||
不该参与「是不是同一条记录」的判断。 */
|
||||
const keyColumns = spec.contentKey
|
||||
? info.columns
|
||||
.map(column => column.name)
|
||||
.filter(name => !(info.autoId && name === info.pk) && !OWNER_COLUMNS.has(name) && !ORG_COLUMNS.has(name))
|
||||
: spec.key
|
||||
|
||||
/* 流水表允许同一个包里出现逐列完全相同的两行(源库确实会这样:一次批量改价写两条)。
|
||||
它们对线上来说是两条独立的历史记录,必须都带过去;只跟**导入前**existing 的比对。
|
||||
普通表不允许:两个同货号的行塞进去会撞主键,那是数据本身有问题,跳过并报告更合适。 */
|
||||
const duplicatesInsidePackage = new Set()
|
||||
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const raw = rows[index]
|
||||
try {
|
||||
const values = {}
|
||||
const refs = raw.__refs && typeof raw.__refs === 'object' ? raw.__refs : {}
|
||||
|
||||
for (const name of Object.keys(raw)) {
|
||||
if (name === '__refs') continue
|
||||
const column = columnsByName.get(name)
|
||||
if (!column) { unknownColumns.add(name); continue }
|
||||
const value = decodeValue(raw[name], info.blobs.has(name))
|
||||
|
||||
/* 归属人 / 归属组织:一律换成「本次导入的人 / 当前组织」 */
|
||||
if (OWNER_COLUMNS.has(name)) {
|
||||
values[name] = value === null ? null : ctx.userId
|
||||
continue
|
||||
}
|
||||
if (ORG_COLUMNS.has(name)) {
|
||||
values[name] = ctx.orgId
|
||||
continue
|
||||
}
|
||||
|
||||
const ref = fkByColumn.get(name)
|
||||
if (ref) {
|
||||
if (value === null) { values[name] = null; continue }
|
||||
const mapped = resolveParent(ctx, ref, value, refs[name])
|
||||
if (mapped === null || mapped === undefined) {
|
||||
/* 父记录既不在包里也补不出来:外键置空,由 NOT NULL 约束决定该行成败 */
|
||||
values[name] = null
|
||||
} else {
|
||||
values[name] = mapped
|
||||
}
|
||||
continue
|
||||
}
|
||||
values[name] = value
|
||||
}
|
||||
|
||||
/* 客户:归属强制给导入人;本来就是公海的客户改成「名下有主」,
|
||||
否则会出现「归我所有却标记在公海」这种自相矛盾的状态。 */
|
||||
if (spec.table === 'customers') {
|
||||
values.owner_id = ctx.userId
|
||||
values.created_by = ctx.userId
|
||||
values.org_id = ctx.orgId
|
||||
if (values.pool_status === 'public') {
|
||||
values.pool_status = 'owned'
|
||||
values.pool_at = null
|
||||
values.pool_note = null
|
||||
}
|
||||
}
|
||||
|
||||
/* 去重:键里至少一列有值才判定;某列为空按 NULL 比较,不因此放弃去重
|
||||
(否则「品牌为空的产品组」这种表每次重导都会翻倍) */
|
||||
const keyValues = {}
|
||||
let keyReady = false
|
||||
for (const key of keyColumns) {
|
||||
const value = values[key]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
keyValues[key] = null
|
||||
continue
|
||||
}
|
||||
keyValues[key] = value
|
||||
keyReady = true
|
||||
}
|
||||
if (keyReady) {
|
||||
const fingerprint = keyColumns.map(key => `${key}=${keyValues[key] === null ? '\u0000' : keyValues[key]}`).join('\u0001')
|
||||
const repeatInsidePackage = duplicatesInsidePackage.has(fingerprint)
|
||||
const existing = (!repeatInsidePackage || !spec.contentKey)
|
||||
? findByKey(ctx.db, spec.table, keyColumns, keyValues)
|
||||
: null
|
||||
if (existing) {
|
||||
stat.skip++
|
||||
if (info.autoId && raw.id !== undefined && raw.id !== null && existing[info.pk]) {
|
||||
idMap(ctx, spec.table).set(raw.id, existing[info.pk])
|
||||
}
|
||||
continue
|
||||
}
|
||||
duplicatesInsidePackage.add(fingerprint)
|
||||
}
|
||||
|
||||
const names = Object.keys(values).filter(name => !(info.autoId && name === info.pk))
|
||||
if (!names.length) throw new Error('没有可写入的字段')
|
||||
|
||||
const sql = `INSERT INTO ${quote(spec.table)} (${names.map(quote).join(', ')}) VALUES (${names.map(() => '?').join(', ')})`
|
||||
const result = prepareStatement(ctx, sql).run(...names.map(name => values[name]))
|
||||
|
||||
stat.insert++
|
||||
if (info.autoId) {
|
||||
const oldId = raw.id
|
||||
if (oldId !== undefined && oldId !== null) idMap(ctx, spec.table).set(oldId, result.lastInsertRowid)
|
||||
}
|
||||
} catch (error) {
|
||||
stat.failed++
|
||||
warn(`第 ${index + 1} 行:${String(error.message || error).slice(0, 160)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownColumns.size) {
|
||||
warn(`目标库缺少列 ${[...unknownColumns].join('、')}(版本较旧?),这些字段已忽略`)
|
||||
}
|
||||
return stat
|
||||
}
|
||||
|
||||
/**
|
||||
* 把数据包写进给定的库实例。
|
||||
* 调用方负责包在事务里(见 index.js 的 applyImport)——这里出现异常就整体回滚。
|
||||
*/
|
||||
function importInto(db, bundle, { categories, user }) {
|
||||
const selected = new Set(resolveCategories(categories))
|
||||
const ctx = makeContext(db, user)
|
||||
const tables = []
|
||||
const missing = []
|
||||
|
||||
for (const spec of TABLES) {
|
||||
if (!selected.has(spec.category)) continue
|
||||
const rows = bundle.rows.get(spec.table)
|
||||
if (!rows || !rows.length) continue
|
||||
if (!tableExists(db, spec.table)) { missing.push(spec.table); continue }
|
||||
const info = tableInfo(db, spec.table)
|
||||
if (!info) { missing.push(spec.table); continue }
|
||||
tables.push(importTable(ctx, spec, rows, info))
|
||||
}
|
||||
|
||||
const totals = tables.reduce((acc, item) => {
|
||||
acc.rows += item.rows
|
||||
acc.insert += item.insert
|
||||
acc.skip += item.skip
|
||||
acc.failed += item.failed
|
||||
return acc
|
||||
}, { rows: 0, insert: 0, skip: 0, failed: 0 })
|
||||
|
||||
const stubbed = [...ctx.stubTotals.entries()].map(([table, count]) => ({
|
||||
table,
|
||||
label: (TABLES.find(item => item.table === table) || {}).label || table,
|
||||
count
|
||||
}))
|
||||
|
||||
return {
|
||||
categories: CATEGORY_LIST.filter(item => selected.has(item.key)).map(item => ({ key: item.key, label: item.label })),
|
||||
tables,
|
||||
totals,
|
||||
stubbed,
|
||||
missing
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { importInto, targetOrgId }
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 数据迁移的门面:把「读包、预览、真导、备份」串起来,路由只调这一层。
|
||||
*
|
||||
* 预览为什么要开一个库副本:
|
||||
* 预览必须回答「照这样导,线上会变成什么样」。如果只是把行数点一遍,就答不了
|
||||
* 「哪一行会因为外键或非空约束失败」。所以预览在做一次 VACUUM INTO 出来的副本上
|
||||
* **真跑一遍导入**,跑完把副本删掉 —— 报告里的数字与实际导入完全同源。
|
||||
* 库只有几 MB,拷一份比维护两套判断逻辑便宜得多。
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const Database = require('better-sqlite3-multiple-ciphers')
|
||||
|
||||
const { CATEGORY_LIST, TABLES, resolveCategories } = require('./catalog')
|
||||
const { writeBundle, readBundle, tableExists } = require('./bundle')
|
||||
const { importInto } = require('./import')
|
||||
|
||||
const escapePath = file => String(file).replace(/'/g, "''")
|
||||
|
||||
/** 本机各类别各表当前行数(界面用来显示「这次会导出多少」) */
|
||||
function listCatalog(db) {
|
||||
return CATEGORY_LIST.map(category => ({
|
||||
key: category.key,
|
||||
label: category.label,
|
||||
description: category.description,
|
||||
tables: TABLES.filter(spec => spec.category === category.key).map(spec => {
|
||||
let rows = 0
|
||||
if (tableExists(db, spec.table)) {
|
||||
try { rows = db.prepare(`SELECT COUNT(*) AS n FROM "${spec.table}"`).get().n } catch { rows = 0 }
|
||||
}
|
||||
return { table: spec.table, label: spec.label, rows }
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/** 导出到可写流(HTTP 响应直接接) */
|
||||
async function exportToStream(db, output, options) {
|
||||
await writeBundle(db, output, options)
|
||||
}
|
||||
|
||||
/** 导出的文件名:带时间戳,便于在服务器上分辨先后 */
|
||||
function exportFileName(date = new Date()) {
|
||||
const pad = value => String(value).padStart(2, '0')
|
||||
const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
return `happy-export-${stamp}.hdata.gz`
|
||||
}
|
||||
|
||||
function readFileMeta(bundle, filePath, fileName) {
|
||||
return {
|
||||
name: fileName || path.basename(filePath),
|
||||
size: bundle.size,
|
||||
exportedAt: bundle.header.exportedAt,
|
||||
exporter: bundle.header.exporter || null,
|
||||
app: bundle.header.app,
|
||||
version: bundle.header.version,
|
||||
categories: bundle.categories
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览:在库副本上真跑一遍导入,返回与实际导入同源的报告。
|
||||
* 副本用完即删,线上库不受任何影响。
|
||||
*/
|
||||
async function previewImport(sourceDb, filePath, { categories, user, workDir, fileName }) {
|
||||
fs.mkdirSync(workDir, { recursive: true })
|
||||
const bundle = await readBundle(filePath)
|
||||
const selected = resolveCategories(categories && categories.length ? categories : bundle.categories)
|
||||
const available = selected.filter(key => bundle.categories.includes(key))
|
||||
if (!available.length) {
|
||||
const error = new Error('所选类别在数据包里没有内容,无法导入')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
const copyPath = path.join(workDir, `preview-${crypto.randomBytes(6).toString('hex')}.db`)
|
||||
if (fs.existsSync(copyPath)) fs.unlinkSync(copyPath)
|
||||
sourceDb.exec(`VACUUM INTO '${escapePath(copyPath)}'`)
|
||||
|
||||
const copy = new Database(copyPath)
|
||||
try {
|
||||
copy.pragma('foreign_keys = ON')
|
||||
copy.pragma('busy_timeout = 5000')
|
||||
const plan = copy.transaction(() => importInto(copy, bundle, { categories: available, user }))()
|
||||
return { file: readFileMeta(bundle, filePath, fileName), plan, availableCategories: available }
|
||||
} finally {
|
||||
try { copy.close() } catch { /* 副本关闭失败不影响结果 */ }
|
||||
try { fs.unlinkSync(copyPath) } catch { /* 副本已被占用就留着,下次预览会覆盖 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正导入:先给线上库做一份完整备份(留档可回滚),再在一个事务里写入。
|
||||
* 任何未预期的异常都会整体回滚,备份文件保留在 backupDir。
|
||||
*/
|
||||
async function applyImport(db, filePath, { categories, user, backupDir, fileName }) {
|
||||
fs.mkdirSync(backupDir, { recursive: true })
|
||||
const bundle = await readBundle(filePath)
|
||||
const selected = resolveCategories(categories && categories.length ? categories : bundle.categories)
|
||||
const available = selected.filter(key => bundle.categories.includes(key))
|
||||
if (!available.length) {
|
||||
const error = new Error('所选类别在数据包里没有内容,无法导入')
|
||||
error.statusCode = 400
|
||||
throw error
|
||||
}
|
||||
|
||||
/* 时间戳精确到秒,再拼一段随机后缀:同一秒内连续导两次也不会撞出
|
||||
「output file already exists」,而文件名照样按时间可排序。 */
|
||||
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
|
||||
const backupPath = path.join(backupDir, `pre-import-${stamp}-${crypto.randomBytes(3).toString('hex')}.db`)
|
||||
db.exec(`VACUUM INTO '${escapePath(backupPath)}'`)
|
||||
|
||||
const plan = db.transaction(() => importInto(db, bundle, { categories: available, user }))()
|
||||
return { file: readFileMeta(bundle, filePath, fileName), plan, backup: backupPath, availableCategories: available }
|
||||
}
|
||||
|
||||
/** 清理过期的上传包与预览副本(默认 24 小时) */
|
||||
function cleanupUploads(dir, { maxAgeMs = 24 * 60 * 60 * 1000 } = {}) {
|
||||
let removed = 0
|
||||
try {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const file = path.join(dir, name)
|
||||
try {
|
||||
const stat = fs.statSync(file)
|
||||
if (Date.now() - stat.mtimeMs > maxAgeMs) { fs.unlinkSync(file); removed++ }
|
||||
} catch { /* 单个文件失败不影响其它清理 */ }
|
||||
}
|
||||
} catch { /* 目录还不存在 */ }
|
||||
return removed
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listCatalog,
|
||||
exportToStream,
|
||||
exportFileName,
|
||||
previewImport,
|
||||
applyImport,
|
||||
cleanupUploads
|
||||
}
|
||||
@@ -56,7 +56,10 @@ const PERMISSIONS = [
|
||||
{ key: 'user.manage', label: '用户管理', group: '系统管理' },
|
||||
{ key: 'perm.manage', label: '权限组管理', group: '系统管理' },
|
||||
{ key: 'ai.manage', label: '智能配置(大模型接入与用途分配)', group: '系统管理' },
|
||||
{ key: 'audit.view', label: '审计日志', group: '系统管理' }
|
||||
{ key: 'audit.view', label: '审计日志', group: '系统管理' },
|
||||
/* 业务数据的导入导出:一次能把几千条商品/客户写进库里,也能把整库业务数据带走,
|
||||
所以只给超级管理员组。包内永远不会包含用户、权限、组织、设置与会话。 */
|
||||
{ key: 'data.transfer', label: '数据导入导出(业务数据迁移)', group: '系统管理' }
|
||||
]
|
||||
|
||||
const PERMISSION_KEYS = PERMISSIONS.map(item => item.key)
|
||||
@@ -91,7 +94,7 @@ const DEFAULT_PERMISSION_GROUPS = [
|
||||
`branding.manage` 反过来说:它**靠** .manage 后缀被自动排除,
|
||||
销售经理永远拿不到改公司信息的权限(Wayne 口径:只有高管能改)。 */
|
||||
permissions: PERMISSION_KEYS.filter(key =>
|
||||
!key.endsWith('.manage') && key !== 'audit.view' && key !== 'customer.view_all'),
|
||||
!key.endsWith('.manage') && !['audit.view', 'customer.view_all', 'data.transfer'].includes(key)),
|
||||
roles: ['sales_manager']
|
||||
},
|
||||
{
|
||||
@@ -119,7 +122,7 @@ const DEFAULT_PERMISSION_GROUPS = [
|
||||
user.manage / perm.manage 是账号治理权,ai.manage 会看到所有模型密钥,
|
||||
audit.view 能看到全公司的操作轨迹 —— Wayne 口径是「系统日志仅系统管理员可见」。 */
|
||||
permissions: PERMISSION_KEYS.filter(key =>
|
||||
!['user.manage', 'perm.manage', 'ai.manage', 'audit.view'].includes(key)),
|
||||
!['user.manage', 'perm.manage', 'ai.manage', 'audit.view', 'data.transfer'].includes(key)),
|
||||
roles: ['chairman', 'general_manager']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 数据迁移接口(业务数据导入 / 导出)。
|
||||
* 挂载点:/api/data
|
||||
*
|
||||
* 与「系统更新」的区别:这里动的是**业务数据**,不碰用户、权限、组织、设置与日志。
|
||||
* 权限点 data.transfer 只授予超级管理员组 —— 一次导入能把几千条商品写进库里,
|
||||
* 不该出现在业务员的菜单里。
|
||||
*/
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const multer = require('multer')
|
||||
|
||||
const db = require('../db/db')
|
||||
const auth = require('../lib/auth')
|
||||
const transfer = require('../lib/dataTransfer')
|
||||
const { MAX_BUNDLE_BYTES } = require('../lib/dataTransfer/bundle')
|
||||
const { resolveCategories } = require('../lib/dataTransfer/catalog')
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data')
|
||||
const TRANSFER_DIR = path.join(DATA_DIR, 'transfers')
|
||||
const UPLOAD_DIR = path.join(TRANSFER_DIR, 'uploads')
|
||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
||||
|
||||
const router = express.Router()
|
||||
const guard = auth.requirePermission('data.transfer')
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: MAX_BUNDLE_BYTES, files: 1 }
|
||||
})
|
||||
|
||||
function safeUnlink(file) {
|
||||
try { fs.unlinkSync(file) } catch { /* 已不存在 */ }
|
||||
}
|
||||
|
||||
/** 请求里的类别既可能是 "a,b",也可能是 ["a","b"](表单或 JSON 都兼容) */
|
||||
function parseCategories(input) {
|
||||
if (Array.isArray(input)) return input
|
||||
if (typeof input === 'string' && input.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch { /* 不是 JSON 就按逗号分隔 */ }
|
||||
return input.split(',').map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** multipart 里的中文文件名是裸字节,按 UTF-8 重解一次(与 server.js 同一套判断) */
|
||||
function decodeOriginalName(name) {
|
||||
const raw = String(name || '')
|
||||
if (!raw || !/[\u0080-\u00ff]/.test(raw)) return raw
|
||||
const restored = Buffer.from(raw, 'latin1').toString('utf8')
|
||||
return restored.includes('\ufffd') ? raw : restored
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 目录 */
|
||||
|
||||
/** GET /api/data/catalog —— 各类别与各表在本机的行数,界面用 */
|
||||
router.get('/catalog', guard, (req, res) => {
|
||||
try {
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.json({
|
||||
categories: transfer.listCatalog(db),
|
||||
limits: { maxBytes: MAX_BUNDLE_BYTES }
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ 导出 */
|
||||
|
||||
/** GET /api/data/export?categories=catalog,orders(省略或 all = 全部业务数据) */
|
||||
router.get('/export', guard, async (req, res) => {
|
||||
let categories
|
||||
try {
|
||||
categories = resolveCategories(req.query.categories)
|
||||
} catch (error) {
|
||||
return res.status(error.statusCode || 400).json({ error: error.message })
|
||||
}
|
||||
|
||||
const fileName = transfer.exportFileName()
|
||||
res.setHeader('Content-Type', 'application/gzip')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`)
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
|
||||
try {
|
||||
await transfer.exportToStream(db, res, { categories, exporter: req.user })
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'data_export',
|
||||
detail: `导出业务数据:${categories.join('、')}(${fileName})`,
|
||||
req
|
||||
})
|
||||
} catch (error) {
|
||||
/* 已经开始下发就只能断开 —— 让浏览器看到传输失败,而不是拿到一个半截包当成功 */
|
||||
if (!res.headersSent) res.status(500).json({ error: error.message })
|
||||
else res.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ 导入 */
|
||||
|
||||
/**
|
||||
* POST /api/data/import/preview —— 上传数据包并出预览报告(不写线上库)
|
||||
* 表单:file(数据包)、categories(可选,逗号分隔或 JSON 数组)
|
||||
*/
|
||||
router.post('/import/preview', guard, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: '请选择要导入的数据包(.hdata.gz)' })
|
||||
|
||||
const id = crypto.randomBytes(12).toString('hex')
|
||||
const target = path.join(TRANSFER_DIR, `${id}.hdata`)
|
||||
const fileName = decodeOriginalName(req.file.originalname)
|
||||
|
||||
try {
|
||||
fs.renameSync(req.file.path, target)
|
||||
/* 顺手清掉 24 小时前的旧包,避免上传目录无限增长(本次刚移入的 mtime 是现在,不会被删) */
|
||||
transfer.cleanupUploads(TRANSFER_DIR)
|
||||
|
||||
const categories = parseCategories(req.body?.categories)
|
||||
const preview = await transfer.previewImport(db, target, {
|
||||
categories,
|
||||
user: req.user,
|
||||
workDir: TRANSFER_DIR,
|
||||
fileName
|
||||
})
|
||||
res.json({ id, fileName, ...preview })
|
||||
} catch (error) {
|
||||
safeUnlink(target)
|
||||
safeUnlink(req.file.path)
|
||||
res.status(error.statusCode || 400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/data/import/replan —— 用已上传的包按新类别重算预览(不重新上传)
|
||||
* 请求体:{ id, categories, fileName? }
|
||||
*/
|
||||
router.post('/import/replan', guard, async (req, res) => {
|
||||
const id = String(req.body?.id || '')
|
||||
if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '导入任务标识无效,请重新上传数据包' })
|
||||
|
||||
const file = path.join(TRANSFER_DIR, `${id}.hdata`)
|
||||
if (!fs.existsSync(file)) {
|
||||
return res.status(404).json({ error: '数据包已过期(超过 24 小时),请重新上传后再预览一次' })
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await transfer.previewImport(db, file, {
|
||||
categories: parseCategories(req.body?.categories),
|
||||
user: req.user,
|
||||
workDir: TRANSFER_DIR,
|
||||
fileName: req.body?.fileName ? decodeOriginalName(req.body.fileName) : undefined
|
||||
})
|
||||
res.json({ id, fileName: req.body?.fileName, ...preview })
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/data/import/apply —— 按预览过的数据包真导
|
||||
* 请求体:{ id, categories? }
|
||||
*/
|
||||
router.post('/import/apply', guard, async (req, res) => {
|
||||
const id = String(req.body?.id || '')
|
||||
if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '导入任务标识无效,请重新上传数据包' })
|
||||
|
||||
const file = path.join(TRANSFER_DIR, `${id}.hdata`)
|
||||
if (!fs.existsSync(file)) {
|
||||
return res.status(404).json({ error: '数据包已过期(超过 24 小时),请重新上传后再预览一次' })
|
||||
}
|
||||
|
||||
try {
|
||||
const categories = parseCategories(req.body?.categories)
|
||||
const result = await transfer.applyImport(db, file, {
|
||||
categories,
|
||||
user: req.user,
|
||||
backupDir: BACKUP_DIR
|
||||
})
|
||||
|
||||
const summary = result.plan.tables
|
||||
.filter(item => item.insert || item.skip || item.failed)
|
||||
.map(item => `${item.label} 新增 ${item.insert} / 跳过 ${item.skip}${item.failed ? ` / 失败 ${item.failed}` : ''}`)
|
||||
.join(',')
|
||||
auth.audit({
|
||||
username: req.user.username,
|
||||
action: 'data_import',
|
||||
detail: `导入业务数据:${result.plan.categories.map(item => item.label).join('、')}(${summary});备份 ${path.basename(result.backup)}`,
|
||||
req
|
||||
})
|
||||
|
||||
safeUnlink(file)
|
||||
res.json({ fileName: result.file.name, ...result })
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/data/import/:id —— 用户取消时丢掉临时包 */
|
||||
router.delete('/import/:id', guard, (req, res) => {
|
||||
const id = String(req.params.id || '')
|
||||
if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '标识无效' })
|
||||
safeUnlink(path.join(TRANSFER_DIR, `${id}.hdata`))
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
/* multer 的体积/文件数错误要变成可读提示,不能落成 500 */
|
||||
router.use((error, req, res, next) => {
|
||||
if (!error) return next()
|
||||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: `数据包超过 ${MAX_BUNDLE_BYTES / 1024 / 1024} MB 上限,请按类别分开导出后导入` })
|
||||
}
|
||||
return res.status(error.statusCode || 400).json({ error: error.message })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -160,6 +160,9 @@ app.get('/api/health', (req, res) => {
|
||||
// 系统信息与版本更新检查
|
||||
app.use('/api/system', systemRoutes);
|
||||
|
||||
// 业务数据导入导出(系统数据不在包内):类别可选,单独导 SKU / 客户 / 订单,也可整体进出
|
||||
app.use('/api/data', require('./routes/dataTransfer'));
|
||||
|
||||
// 账号、个人资料与改密
|
||||
app.use('/api', authRoutes);
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 数据迁移接口的端到端测试。
|
||||
*
|
||||
* 起两个真实后端进程:A 有业务数据(源部署),B 是全新部署(目标)。
|
||||
* 全程只走 HTTP,验证:
|
||||
* ① 导出接口真的能流出一个数据包(不是把整个库塞进内存再吐)
|
||||
* ② 预览接口给出的报告与真导结果一致,且预览不写线上库
|
||||
* ③ 导入后业务表行数对上,系统表(users)仍是 B 自己的
|
||||
* ④ 同一包再导一次:新增 0 行
|
||||
* ⑤ 没有 data.transfer 权限的业务员拿到 403
|
||||
*
|
||||
* 注意:这里必须真的 spawn 进程,所以在受限沙箱里会拿到 EPERM —— 属于已知边界。
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const crypto = require('node:crypto')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const BACKEND = path.join(__dirname, '..')
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-api-transfer-'))
|
||||
const sourceDir = path.join(tmpRoot, 'source')
|
||||
const targetDir = path.join(tmpRoot, 'target')
|
||||
const bundlePath = path.join(tmpRoot, 'bundle.hdata.gz')
|
||||
|
||||
/* 源部署:先用库模块建库并灌一点真实形状的数据 */
|
||||
process.env.DATA_DIR = sourceDir
|
||||
const sourceDb = require('../db/db')
|
||||
|
||||
const PORT_A = 20000 + Math.floor(Math.random() * 8000)
|
||||
const PORT_B = PORT_A + 1
|
||||
|
||||
function seed() {
|
||||
const now = '2026-01-01T00:00:00.000Z'
|
||||
const admin = sourceDb.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
sourceDb.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run('宁波腾达', now)
|
||||
sourceDb.prepare('INSERT INTO product_groups (name, created_at) VALUES (?, ?)').run('扭矩扳手', now)
|
||||
sourceDb.prepare('INSERT INTO products (sku, name, price, currency, supplier_id, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run('TD-100', '扭矩扳手 100N', 88, 'CNY', 1, 1, now)
|
||||
sourceDb.prepare('INSERT INTO products (sku, name, price, currency, supplier_id, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run('TD-200', '扭矩扳手 200N', 128, 'CNY', 1, now)
|
||||
sourceDb.prepare('INSERT INTO customers (company, country, owner_id, created_by, org_id, pool_status, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run('ACME GmbH', 'DE', admin.id, admin.id, admin.org_id || 1, 'owned', 'active', now)
|
||||
sourceDb.prepare('INSERT INTO orders (order_no, customer_id, order_date, total_amount, currency, status, owner_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run('PO-2026-001', 1, '2026-01-02', 880, 'USD', 'confirmed', admin.id, now)
|
||||
sourceDb.prepare('INSERT INTO order_items (order_id, sku, product_name, qty, unit_price, total_price, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'TD-100', '扭矩扳手 100N', 10, 88, 880, now)
|
||||
}
|
||||
|
||||
function startServer(dataDir, port) {
|
||||
const child = spawn(process.execPath, ['server.js'], {
|
||||
cwd: BACKEND,
|
||||
env: { ...process.env, DATA_DIR: dataDir, PORT: String(port) },
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.on('error', () => { /* 由健康检查超时统一报错 */ })
|
||||
return child
|
||||
}
|
||||
|
||||
async function waitForHealth(port, timeoutMs = 20000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`)
|
||||
if (response.ok) return true
|
||||
} catch { /* 还没起来 */ }
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
throw new Error(`端口 ${port} 的后端在 ${timeoutMs}ms 内没有就绪`)
|
||||
}
|
||||
|
||||
async function login(port, username = 'admin', password = 'admin123') {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
assert.equal(response.status, 200, `登录失败:${await response.text()}`)
|
||||
const cookies = typeof response.headers.getSetCookie === 'function'
|
||||
? response.headers.getSetCookie()
|
||||
: [response.headers.get('set-cookie')]
|
||||
return cookies.filter(Boolean).map(item => item.split(';')[0]).join('; ')
|
||||
}
|
||||
|
||||
function stopServer(child) {
|
||||
try { child.kill() } catch { /* 已退出 */ }
|
||||
}
|
||||
|
||||
test('数据迁移接口端到端:导出 → 预览 → 导入 → 去重 → 权限', async () => {
|
||||
seed()
|
||||
sourceDb.close()
|
||||
|
||||
const serverA = startServer(sourceDir, PORT_A)
|
||||
const serverB = startServer(targetDir, PORT_B)
|
||||
|
||||
try {
|
||||
await waitForHealth(PORT_A)
|
||||
await waitForHealth(PORT_B)
|
||||
|
||||
/* ---------------- 导出 ---------------- */
|
||||
const cookieA = await login(PORT_A)
|
||||
const exportResponse = await fetch(`http://127.0.0.1:${PORT_A}/api/data/export?categories=all`, {
|
||||
headers: { cookie: cookieA }
|
||||
})
|
||||
assert.equal(exportResponse.status, 200)
|
||||
assert.match(exportResponse.headers.get('content-type') || '', /gzip/)
|
||||
const bytes = Buffer.from(await exportResponse.arrayBuffer())
|
||||
fs.writeFileSync(bundlePath, bytes)
|
||||
assert.ok(bytes.length > 200, '导出的包不应该是空的')
|
||||
assert.equal(bytes[0], 0x1f, '导出内容应是 gzip')
|
||||
|
||||
/* ---------------- 预览(不写库) ---------------- */
|
||||
const cookieB = await login(PORT_B)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz')
|
||||
const previewResponse = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieB },
|
||||
body: form
|
||||
})
|
||||
const preview = await previewResponse.json()
|
||||
assert.equal(previewResponse.status, 200, JSON.stringify(preview))
|
||||
assert.ok(preview.id, '预览应返回任务标识')
|
||||
assert.equal(preview.plan.totals.failed, 0, JSON.stringify(preview.plan.tables))
|
||||
assert.equal(preview.plan.totals.insert, preview.plan.totals.rows, '首次导入应全部是新增')
|
||||
|
||||
/* 预览不能写线上库 */
|
||||
const dashboardBefore = await (await fetch(`http://127.0.0.1:${PORT_B}/api/dashboard`, { headers: { cookie: cookieB } })).json()
|
||||
assert.equal(dashboardBefore.stats.products, 0, '预览阶段不应写入产品')
|
||||
|
||||
/* ---------------- 真导 ---------------- */
|
||||
const applyResponse = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/apply`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieB, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: preview.id, categories: preview.availableCategories })
|
||||
})
|
||||
const applied = await applyResponse.json()
|
||||
assert.equal(applyResponse.status, 200, JSON.stringify(applied))
|
||||
assert.equal(applied.plan.totals.insert, preview.plan.totals.insert, '预览与真导的新增行数必须一致')
|
||||
assert.ok(applied.backup && fs.existsSync(applied.backup), '导入前必须留下数据库备份')
|
||||
|
||||
const dashboardAfter = await (await fetch(`http://127.0.0.1:${PORT_B}/api/dashboard`, { headers: { cookie: cookieB } })).json()
|
||||
assert.equal(dashboardAfter.stats.products, 2)
|
||||
assert.equal(dashboardAfter.stats.suppliers, 1)
|
||||
assert.equal(dashboardAfter.stats.customers, 1)
|
||||
assert.equal(dashboardAfter.stats.orders, 1)
|
||||
|
||||
/* 客户归属 = B 的 admin(导入人),而不是源库的用户 id */
|
||||
const me = await (await fetch(`http://127.0.0.1:${PORT_B}/api/auth/me`, { headers: { cookie: cookieB } })).json()
|
||||
const customerDetail = await (await fetch(`http://127.0.0.1:${PORT_B}/api/customers/1`, { headers: { cookie: cookieB } })).json()
|
||||
assert.equal(customerDetail.customer.owner_id, me.user.id)
|
||||
|
||||
/* ---------------- 重复导入:新增 0 ---------------- */
|
||||
const form2 = new FormData()
|
||||
form2.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz')
|
||||
const preview2 = await (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, {
|
||||
method: 'POST', headers: { cookie: cookieB }, body: form2
|
||||
})).json()
|
||||
const apply2 = await (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/apply`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieB, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: preview2.id, categories: preview2.availableCategories })
|
||||
})).json()
|
||||
assert.equal(apply2.plan.totals.insert, 0, '重复导入不应新增任何行')
|
||||
|
||||
/* ---------------- 权限:业务员拿不到 ---------------- */
|
||||
const Database = require('better-sqlite3-multiple-ciphers')
|
||||
const salt = crypto.randomBytes(16).toString('hex')
|
||||
const hash = crypto.scryptSync('rep123456', salt, 64).toString('hex')
|
||||
const direct = new Database(path.join(targetDir, 'trade.db'))
|
||||
direct.pragma('busy_timeout = 5000')
|
||||
direct.prepare(`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`).run('rep', `scrypt$${salt}$${hash}`, '业务员', 'sales_rep', new Date().toISOString())
|
||||
direct.close()
|
||||
|
||||
const repCookie = await login(PORT_B, 'rep', 'rep123456')
|
||||
const denied = await fetch(`http://127.0.0.1:${PORT_B}/api/data/catalog`, { headers: { cookie: repCookie } })
|
||||
assert.equal(denied.status, 403, '没有 data.transfer 权限的账号必须被挡住')
|
||||
|
||||
const anonymous = await fetch(`http://127.0.0.1:${PORT_B}/api/data/catalog`)
|
||||
assert.equal(anonymous.status, 401)
|
||||
} finally {
|
||||
stopServer(serverA)
|
||||
stopServer(serverB)
|
||||
setTimeout(() => {
|
||||
try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* Windows 上可能还有句柄 */ }
|
||||
}, 500).unref()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 业务数据导入导出的回归测试。
|
||||
*
|
||||
* 全程在临时 DATA_DIR 里跑,不碰 backend/data/trade.db。
|
||||
* 覆盖:
|
||||
* ① 系统表绝不进包(users / settings / audit_log …)
|
||||
* ② 导出 → 导入到「另一个空部署」后行数与内容一致,BLOB 完整
|
||||
* ③ 重复导入同一包:全部跳过,不产生重复数据(冲突不覆盖)
|
||||
* ④ 只导订单:客户自动补壳、产品不跟着进来
|
||||
* ⑤ 包被改坏:校验失败并拒绝导入
|
||||
*/
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const zlib = require('node:zlib')
|
||||
|
||||
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-transfer-'))
|
||||
process.env.DATA_DIR = DATA_DIR
|
||||
|
||||
const db = require('../db/db')
|
||||
const transfer = require('../lib/dataTransfer')
|
||||
const { readBundle, writeBundleFile, foreignKeys } = require('../lib/dataTransfer/bundle')
|
||||
const { TABLES } = require('../lib/dataTransfer/catalog')
|
||||
|
||||
const workDir = path.join(DATA_DIR, 'work')
|
||||
fs.mkdirSync(workDir, { recursive: true })
|
||||
|
||||
const IMAGE_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff])
|
||||
|
||||
function seed() {
|
||||
const now = '2026-01-01T00:00:00.000Z'
|
||||
const admin = db.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
db.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run('宁波腾达', now)
|
||||
db.prepare('INSERT INTO product_groups (name, group_key, created_at) VALUES (?, ?, ?)').run('扭矩扳手', 'torque', now)
|
||||
db.prepare(`INSERT INTO products (sku, name, price, currency, supplier_id, group_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('TD-100', '扭矩扳手 100N', 88, 'CNY', 1, 1, now)
|
||||
db.prepare(`INSERT INTO products (sku, name, price, currency, supplier_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run('TD-200', '扭矩扳手 200N', 128, 'CNY', 1, now)
|
||||
db.prepare('INSERT INTO product_packaging (sku, carton_qty, created_at) VALUES (?, ?, ?)').run('TD-100', 10, now)
|
||||
db.prepare('INSERT INTO product_images (sku, filename, created_at) VALUES (?, ?, ?)').run('TD-100', 'a.png', now)
|
||||
db.prepare('INSERT INTO product_image_files (image_id, bytes, size, stored_at) VALUES (?, ?, ?, ?)')
|
||||
.run(1, IMAGE_BYTES, IMAGE_BYTES.length, now)
|
||||
db.prepare('INSERT INTO price_history (sku, old_price, new_price, changed_at) VALUES (?, ?, ?, ?)')
|
||||
.run('TD-100', 80, 88, now)
|
||||
db.prepare(`INSERT INTO customers (company, country, owner_id, created_by, org_id, pool_status, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run('ACME GmbH', 'DE', admin.id, admin.id, admin.org_id || 1, 'owned', 'active', now)
|
||||
db.prepare('INSERT INTO contacts (customer_id, name, email, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(1, 'Hans', '[email protected]', now)
|
||||
db.prepare(`INSERT INTO orders (order_no, customer_id, order_date, total_amount, currency, status, owner_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run('PO-2026-001', 1, '2026-01-02', 880, 'USD', 'confirmed', admin.id, now)
|
||||
db.prepare('INSERT INTO order_items (order_id, sku, product_name, qty, unit_price, total_price, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'TD-100', '扭矩扳手 100N', 10, 88, 880, now)
|
||||
}
|
||||
|
||||
/** 用「源库的副本 + 清空业务表」模拟另一台全新部署:schema 与线上完全一致,只有系统数据 */
|
||||
function cloneEmptyDeployment(source, label) {
|
||||
const file = path.join(workDir, `${label}.db`)
|
||||
source.exec(`VACUUM INTO '${file.replace(/'/g, "''")}'`)
|
||||
const Database = require('better-sqlite3-multiple-ciphers')
|
||||
const copy = new Database(file)
|
||||
copy.pragma('foreign_keys = ON')
|
||||
for (const table of ['order_items', 'orders', 'contacts', 'customers', 'price_history', 'product_image_files',
|
||||
'product_images', 'product_packaging', 'products', 'product_groups', 'suppliers', 'contracts']) {
|
||||
copy.exec(`DELETE FROM "${table}"`)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
try { db.close() } catch { /* 已关闭 */ }
|
||||
try { fs.rmSync(DATA_DIR, { recursive: true, force: true }) } catch { /* Windows 偶发占用 */ }
|
||||
})
|
||||
|
||||
test('导出包只含业务表,系统表一律不出现', async () => {
|
||||
seed()
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
await writeBundleFile(db, file, { categories: ['all'], exporter: db.prepare('SELECT * FROM users LIMIT 1').get() })
|
||||
const bundle = await readBundle(file)
|
||||
|
||||
for (const table of ['users', 'sessions', 'settings', 'audit_log', 'organizations', 'permission_groups']) {
|
||||
assert.equal(bundle.rows.has(table), false, `${table} 不应出现在数据包里`)
|
||||
}
|
||||
assert.equal(bundle.rows.get('products').length, 2)
|
||||
assert.equal(bundle.rows.get('orders').length, 1)
|
||||
assert.deepEqual(bundle.categories.sort(), ['catalog', 'customers', 'orders', 'prospects', 'todos'])
|
||||
})
|
||||
|
||||
test('导入到另一台空部署:行数一致、归属给导入人、BLOB 完整', async () => {
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
const target = cloneEmptyDeployment(db, 'target-all')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
|
||||
const result = await transfer.applyImport(target, file, {
|
||||
user: importer,
|
||||
backupDir: path.join(workDir, 'backups')
|
||||
})
|
||||
|
||||
assert.ok(result.backup && fs.existsSync(result.backup), '导入前必须留一份备份')
|
||||
assert.equal(result.plan.totals.failed, 0)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 2)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM suppliers').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM orders').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM customers').get().n, 1)
|
||||
|
||||
const customer = target.prepare('SELECT * FROM customers WHERE company = ?').get('ACME GmbH')
|
||||
assert.equal(customer.owner_id, importer.id, '客户归属应改写为导入人')
|
||||
|
||||
const bytes = target.prepare('SELECT bytes FROM product_image_files').get().bytes
|
||||
assert.deepEqual(Buffer.from(bytes), IMAGE_BYTES, 'BLOB 必须逐字节一致')
|
||||
|
||||
/* 系统数据没被覆盖:导入前后用户表还是目标库自己那份 */
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM users').get().n, 1)
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('同一包再导一次:全部跳过,不产生重复', async () => {
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
const target = cloneEmptyDeployment(db, 'target-repeat')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
|
||||
const first = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.ok(first.plan.totals.insert > 0)
|
||||
|
||||
const second = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.equal(second.plan.totals.insert, 0, '第二次导入不应新增任何行')
|
||||
assert.equal(second.plan.totals.skip, first.plan.totals.rows)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 2)
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('只导订单:客户自动补壳,产品数据不跟进来', async () => {
|
||||
const file = path.join(workDir, 'orders-only.hdata.gz')
|
||||
await writeBundleFile(db, file, { categories: ['orders'] })
|
||||
const bundle = await readBundle(file)
|
||||
assert.deepEqual([...bundle.rows.keys()].sort(), ['order_items', 'orders'], '只选订单类别时不应带出产品/客户')
|
||||
|
||||
const target = cloneEmptyDeployment(db, 'target-orders')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
const result = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM orders').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM order_items').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 0, '未选产品类别就不该有产品')
|
||||
|
||||
const stub = target.prepare('SELECT * FROM customers').get()
|
||||
assert.equal(stub.company, 'ACME GmbH', '应按 __refs 快照补出客户公司名')
|
||||
assert.equal(stub.owner_id, importer.id)
|
||||
assert.deepEqual(result.plan.stubbed.map(item => item.table), ['customers'])
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('流水表:包内逐列相同的记录全部保留,重导仍全部跳过', async () => {
|
||||
/* 源库确实会这样:一次批量改价会在同一毫秒写两条一模一样的 price_history。
|
||||
拿 (sku, changed_at) 当去重键会把其中一条当重复丢掉(实测 643 行丢 186 行),
|
||||
所以流水表改成「整行内容比对」,且允许同一个包里出现相同的两行。 */
|
||||
const now = '2026-02-02T00:00:00.000Z'
|
||||
const insert = db.prepare('INSERT INTO price_history (sku, old_price, new_price, changed_at) VALUES (?, ?, ?, ?)')
|
||||
insert.run('TD-200', 100, 110, now)
|
||||
insert.run('TD-200', 100, 110, now)
|
||||
|
||||
const file = path.join(workDir, 'history.hdata.gz')
|
||||
await writeBundleFile(db, file, { categories: ['catalog'] })
|
||||
const bundle = await readBundle(file)
|
||||
assert.equal(bundle.rows.get('price_history').length, 3, '源库 1 条 + 新增 2 条都应进包')
|
||||
|
||||
const target = cloneEmptyDeployment(db, 'target-history')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
const first = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.equal(first.plan.totals.failed, 0)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM price_history').get().n, 3, '逐列相同的历史记录也要全部带过去')
|
||||
|
||||
const second = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.equal(second.plan.totals.insert, 0, '第二次导入不应新增')
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('目录顺序满足外键依赖:父表一定排在子表前面', () => {
|
||||
/* 导入是逐表逐行插入的,顺序错了就会撞外键。这条测试保证以后新增业务表时
|
||||
有人把表插到错误位置会立刻红。 */
|
||||
const known = new Set(TABLES.map(item => item.table))
|
||||
const seen = new Set()
|
||||
for (const spec of TABLES) {
|
||||
for (const ref of foreignKeys(db, spec.table)) {
|
||||
if (ref.table === spec.table) continue
|
||||
if (!known.has(ref.table)) continue // 指向 users / organizations 等系统表的列不参与
|
||||
assert.ok(seen.has(ref.table), `${spec.table}.${ref.from} 引用的 ${ref.table} 必须排在它前面`)
|
||||
}
|
||||
seen.add(spec.table)
|
||||
}
|
||||
})
|
||||
|
||||
test('包被改坏:sha256 校验失败并拒绝导入', async () => {
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
const lines = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8').split('\n').filter(Boolean)
|
||||
const productLine = lines.findIndex(line => line.includes('"t":"products"') && line.includes('TD-100'))
|
||||
assert.ok(productLine > 0)
|
||||
lines[productLine] = lines[productLine].replace('TD-100', 'TD-999')
|
||||
const broken = path.join(workDir, 'broken.hdata')
|
||||
fs.writeFileSync(broken, lines.join('\n') + '\n')
|
||||
|
||||
await assert.rejects(() => readBundle(broken), /校验失败/)
|
||||
})
|
||||
Reference in New Issue
Block a user