数据迁移不再整体替换 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
192 lines
9.4 KiB
JavaScript
192 lines
9.4 KiB
JavaScript
/**
|
|
* 数据迁移接口的端到端测试。
|
|
*
|
|
* 起两个真实后端进程: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()
|
|
}
|
|
})
|