数据迁移不再整体替换 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
358 lines
12 KiB
JavaScript
358 lines
12 KiB
JavaScript
/**
|
||
* 数据包(.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
|
||
}
|