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:
Drsakura
2026-10-23 13:46:26 +08:00
parent 1ffcc1ea07
commit 1a759ef37d
17 changed files with 2516 additions and 19 deletions
+357
View File
@@ -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
}