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
+510
View File
@@ -0,0 +1,510 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import {
CheckIcon,
CloseIcon,
DownloadIcon,
ExchangeIcon,
RefreshIcon,
ShieldIcon,
UploadIcon
} from '../components/Icons'
import { setPageTitle } from '../branding'
/* ---------------------------------------------------------------------------
数据迁移:业务数据的导出与导入。
Wayne 2026-10-23 的口径:
· 只搬业务数据(产品、客户、订单…),**系统数据(账号、权限、组织、设置、会话、日志)永不随包走**;
· 可以按类别单独导(只导 SKU / 只导订单),也可以一次全导;
· 冲突一律跳过并写进报告,绝不覆盖线上已有记录;
· 导入的归属统一给执行导入的人,导完再由管理员重新分配。
后端同源实现见 backend/lib/dataTransfer。
--------------------------------------------------------------------------- */
const fmtTime = value => {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
const fmtSize = bytes => {
const size = Number(bytes || 0)
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`
return `${size} B`
}
const num = value => Number(value || 0).toLocaleString('zh-CN')
/** axios 用 responseType:'blob' 时,服务端的 JSON 错误也变成了 Blob —— 要读出来才看得见原因 */
async function errorMessage(error, fallback) {
const data = error?.response?.data
if (data instanceof Blob) {
try {
const parsed = JSON.parse(await data.text())
return parsed.error || fallback
} catch {
return fallback
}
}
return data?.error || error?.message || fallback
}
function DataTransfer() {
const [catalog, setCatalog] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
/* 导出 */
const [exportPicks, setExportPicks] = useState([])
const [exporting, setExporting] = useState(false)
/* 导入:idle → checking(上传+校验)→ ready(预览)→ applying → done */
const [phase, setPhase] = useState('idle')
const [jobId, setJobId] = useState('')
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)
const [importPicks, setImportPicks] = useState([])
const [result, setResult] = useState(null)
const [percent, setPercent] = useState(0)
const [replanning, setReplanning] = useState(false)
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
useEffect(() => { setPageTitle('数据迁移') }, [])
const loadCatalog = useCallback(async () => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/data/catalog')
setCatalog(data)
const keys = (data.categories || []).map(item => item.key)
setExportPicks(current => {
const kept = current.filter(key => keys.includes(key))
return kept.length ? kept : keys
})
} catch (e) {
setError(await errorMessage(e, '读取数据目录失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadCatalog() }, [loadCatalog])
const categories = catalog?.categories || []
const categoryByKey = useMemo(() => {
const map = new Map()
for (const item of categories) map.set(item.key, item)
return map
}, [categories])
const rowsOf = useCallback(category => (category?.tables || []).reduce((sum, item) => sum + item.rows, 0), [])
const allPicked = categories.length > 0 && exportPicks.length === categories.length
const pickedRows = categories.filter(item => exportPicks.includes(item.key)).reduce((sum, item) => sum + rowsOf(item), 0)
/* ------------------------------------------------------------ 导出 */
const toggleExport = key => {
setExportPicks(current => current.includes(key) ? current.filter(item => item !== key) : [...current, key])
}
const doExport = async () => {
if (!exportPicks.length) return
setExporting(true)
setError('')
try {
const response = await axios.get('/api/data/export', {
params: { categories: exportPicks.join(',') },
responseType: 'blob',
timeout: 10 * 60 * 1000
})
const disposition = String(response.headers['content-disposition'] || '')
const match = /filename="?([^";]+)"?/.exec(disposition)
const name = match ? match[1] : `happy-export-${Date.now()}.hdata.gz`
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = name
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
setError(await errorMessage(e, '导出失败'))
} finally {
setExporting(false)
}
}
/* ------------------------------------------------------------ 导入 */
const startPreview = async picked => {
if (!picked) return
setFile(picked)
setPreview(null)
setResult(null)
setPercent(0)
setPhase('checking')
setError('')
const form = new FormData()
form.append('file', picked)
try {
const { data } = await axios.post('/api/data/import/preview', form, {
timeout: 10 * 60 * 1000,
onUploadProgress: event => {
if (event.total) setPercent(Math.round((event.loaded / event.total) * 100))
}
})
setJobId(data.id)
setPreview(data)
setImportPicks(data.availableCategories || data.file?.categories || [])
setPhase('ready')
} catch (e) {
setError(await errorMessage(e, '数据包解析失败'))
setPhase('idle')
setFile(null)
}
}
/** 切换类别后重算预览:包已经在服务器上,不用重传 */
const toggleImport = async key => {
if (replanning) return
const next = importPicks.includes(key)
? importPicks.filter(item => item !== key)
: [...importPicks, key]
if (!next.length) return
setImportPicks(next)
setReplanning(true)
setError('')
try {
const { data } = await axios.post('/api/data/import/replan', {
id: jobId,
categories: next,
fileName: file?.name || ''
})
setPreview(data)
} catch (e) {
/* 重算失败就把勾选退回去,别让「界面上的类别」和「报告里的数字」对不上 */
setImportPicks(importPicks)
setError(await errorMessage(e, '重新计算预览失败'))
} finally {
setReplanning(false)
}
}
const doApply = async () => {
setPhase('applying')
setError('')
try {
const { data } = await axios.post('/api/data/import/apply', {
id: jobId,
categories: importPicks
}, { timeout: 10 * 60 * 1000 })
setResult(data)
setPhase('done')
loadCatalog()
} catch (e) {
setError(await errorMessage(e, '导入失败'))
setPhase('ready')
}
}
const reset = () => {
if (jobId && phase !== 'done') {
/* 取消时让服务端把临时包丢掉,不用等 24 小时过期 */
axios.delete(`/api/data/import/${jobId}`).catch(() => {})
}
setPhase('idle')
setJobId('')
setFile(null)
setPreview(null)
setResult(null)
setImportPicks([])
setPercent(0)
setError('')
if (inputRef.current) inputRef.current.value = ''
}
const onDrop = event => {
event.preventDefault()
setDragging(false)
const picked = event.dataTransfer?.files?.[0]
if (picked) startPreview(picked)
}
const busy = phase === 'checking' || phase === 'applying' || replanning
const plan = preview?.plan
const availableKeys = preview?.availableCategories || preview?.file?.categories || []
return (
<div className="page-container transfer-page">
<div className="page-header">
<div>
<h1 className="page-title">数据迁移</h1>
<p className="page-subtitle">把业务数据搬到另一个部署,或从备份包把数据导回来。按类别选择,冲突只跳过不覆盖。</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary" onClick={loadCatalog} disabled={loading || exporting || busy}>
<RefreshIcon size={14} /> {loading ? '读取中…' : '刷新'}
</button>
</div>
</div>
<div className="panel transfer-boundary">
<div className="panel-title"><ShieldIcon size={16} /> 数据边界</div>
<div className="transfer-boundary-grid">
<div>
<h4>会随包迁移</h4>
<p>供应商与产品、客户与联系人、询盘报价、订单与杂费、跟进时间线与待办、新客线索。</p>
</div>
<div>
<h4>永不随包迁移</h4>
<p>用户与密码、权限组、组织信息、系统设置(含大模型密钥)、登录会话、系统日志。目标部署继续用自己的那套。</p>
</div>
</div>
<p className="help-text">导入的记录一律归到执行导入的账号名下,导完可在客户/订单页重新分配。</p>
</div>
{error && (
<div className="update-status is-error transfer-error" role="alert">
<CloseIcon size={15} /> <span>{error}</span>
</div>
)}
<div className="transfer-grid">
{/* ---------------------------------------------------------- 导出 */}
<section className="panel transfer-card">
<div className="panel-title"><DownloadIcon size={16} /> 导出</div>
<p className="settings-description">
勾选要带走的数据类别。全选就是「全部业务数据」;只勾订单也能单独带走,不会连带客户档案。
</p>
<div className="transfer-picks">
{categories.map(category => {
const checked = exportPicks.includes(category.key)
return (
<label key={category.key} className={`transfer-pick${checked ? ' is-on' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleExport(category.key)}
aria-label={`导出${category.label}`}
/>
<span className="transfer-pick-body">
<strong>{category.label}</strong>
<small>{category.description}</small>
</span>
<em>{num(rowsOf(category))} 行</em>
</label>
)
})}
{!categories.length && !loading && <p className="muted-note">没有可导出的类别。</p>}
</div>
<div className="transfer-actions">
<button type="button" className="btn btn-secondary" onClick={() => setExportPicks(categories.map(item => item.key))} disabled={allPicked || !categories.length}>
全选
</button>
<button type="button" className="btn btn-secondary" onClick={() => setExportPicks([])} disabled={!exportPicks.length}>
全不选
</button>
<button type="button" className="btn btn-primary" onClick={doExport} disabled={!exportPicks.length || exporting}>
<DownloadIcon size={14} />
{exporting ? '正在生成…' : allPicked ? '导出全部业务数据' : `导出所选(${exportPicks.length} 类)`}
</button>
</div>
<p className="help-text">
共 {num(pickedRows)} 行;包内每张表带 sha256,导入前会先校验完整性。
</p>
</section>
{/* ---------------------------------------------------------- 导入 */}
<section className="panel transfer-card">
<div className="panel-title"><UploadIcon size={16} /> 导入</div>
{(phase === 'idle' || phase === 'checking') && (
<>
<p className="settings-description">把另一个部署导出的 <code>.hdata.gz</code> 拖进来。导入前会先出一份预览报告,确认后才写入。</p>
<div
className={`transfer-drop${dragging ? ' is-drag' : ''}${phase === 'checking' ? ' is-busy' : ''}`}
onDragOver={event => { event.preventDefault(); if (phase === 'idle') setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={event => { if (phase === 'idle') onDrop(event) }}
>
{phase === 'checking' ? (
<>
<span className="spinner" />
<p><strong>正在上传并校验数据包…</strong></p>
<div className="transfer-progress" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${percent}%` }} />
</div>
<p className="muted-note">{percent}%{percent >= 100 ? ' · 正在逐表核对校验值' : ''}</p>
</>
) : (
<>
<UploadIcon size={26} />
<p><strong>把数据包拖到这里</strong></p>
<p className="muted-note">或者</p>
<label className="btn btn-secondary file-label">
选择文件
<input
ref={inputRef}
type="file"
accept=".gz,.hdata,application/gzip"
onChange={event => { startPreview(event.target.files?.[0]); event.target.value = '' }}
/>
</label>
<p className="muted-note">单包上限 {fmtSize(catalog?.limits?.maxBytes || 100 * 1024 * 1024)}</p>
</>
)}
</div>
</>
)}
{(phase === 'ready' || phase === 'applying') && plan && (
<>
<div className="transfer-file">
<strong>{preview.file?.name || file?.name}</strong>
<small>
{fmtSize(preview.file?.size)} · 导出于 {fmtTime(preview.file?.exportedAt)}
{preview.file?.exporter?.display_name ? ` · 来自 ${preview.file.exporter.display_name}` : ''}
</small>
</div>
<div className="transfer-cats">
<span className="muted-note">导入类别</span>
{availableKeys.map(key => {
const category = categoryByKey.get(key)
const on = importPicks.includes(key)
return (
<button
key={key}
type="button"
className={`chip-filter${on ? ' is-active' : ''}`}
onClick={() => toggleImport(key)}
disabled={replanning || phase === 'applying'}
aria-pressed={on}
>
{category?.label || key}
</button>
)
})}
{replanning && <span className="spinner" />}
</div>
<div className="table-wrap transfer-report">
<table className="data-table">
<thead>
<tr>
<th>数据表</th>
<th className="num">包内</th>
<th className="num">将新增</th>
<th className="num">跳过</th>
<th className="num">失败</th>
</tr>
</thead>
<tbody>
{plan.tables.map(item => (
<tr key={item.table}>
<td>{item.label}</td>
<td className="num">{num(item.rows)}</td>
<td className="num">{num(item.insert)}</td>
<td className="num">{num(item.skip)}</td>
<td className={`num${item.failed ? ' transfer-failed' : ''}`}>{num(item.failed)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td><strong>合计</strong></td>
<td className="num"><strong>{num(plan.totals.rows)}</strong></td>
<td className="num"><strong>{num(plan.totals.insert)}</strong></td>
<td className="num"><strong>{num(plan.totals.skip)}</strong></td>
<td className="num"><strong>{num(plan.totals.failed)}</strong></td>
</tr>
</tfoot>
</table>
</div>
{plan.stubbed?.length > 0 && (
<p className="help-text transfer-stub-note">
<ExchangeIcon size={14} /> 包里没有父记录,将自动补建:{plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')}
(只有名称,导完请补全信息)。
</p>
)}
{plan.tables.some(item => item.warnings?.length) && (
<details className="transfer-details">
<summary>预览告警({plan.tables.reduce((sum, item) => sum + (item.warnings?.length || 0), 0)} 条)</summary>
{plan.tables.filter(item => item.warnings?.length).map(item => (
<div key={item.table}>
<strong>{item.label}</strong>
<ul>{item.warnings.map((text, index) => <li key={index}>{text}</li>)}</ul>
</div>
))}
</details>
)}
<p className="help-text">导入前会自动把当前线上库备份成一份 .db 文件,失败可整体回滚。</p>
<div className="transfer-actions">
<button type="button" className="btn btn-ghost" onClick={reset} disabled={phase === 'applying'}>
重新选择
</button>
<button
type="button"
className="btn btn-primary"
onClick={doApply}
disabled={!importPicks.length || replanning || phase === 'applying' || !plan.totals.insert}
>
{phase === 'applying' ? '正在导入…' : plan.totals.insert ? `确认导入(新增 ${num(plan.totals.insert)} 行)` : '没有可新增的记录'}
</button>
</div>
</>
)}
{phase === 'done' && result && (
<>
<div className="update-status is-ok">
<CheckIcon size={15} />
<span>
导入完成:新增 {num(result.plan?.totals?.insert)} 行,跳过 {num(result.plan?.totals?.skip)} 行
{result.plan?.totals?.failed ? `,失败 ${num(result.plan.totals.failed)} 行` : ''}。
</span>
</div>
<p className="help-text">导入前的备份:{result.backup}</p>
{result.plan?.stubbed?.length > 0 && (
<p className="help-text">
自动补建:{result.plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')} —— 这些只有名称,请补全联系人、地址等信息。
</p>
)}
{result.plan?.tables?.some(item => item.failed) && (
<details className="transfer-details" open>
<summary>未导入的行({result.plan.tables.reduce((sum, item) => sum + (item.failed || 0), 0)} 行)</summary>
{result.plan.tables.filter(item => item.failed).map(item => (
<div key={item.table}>
<strong>{item.label}</strong>
<ul>{item.warnings.map((text, index) => <li key={index}>{text}</li>)}</ul>
</div>
))}
</details>
)}
<div className="transfer-actions">
<button type="button" className="btn btn-primary" onClick={reset}>继续导入</button>
</div>
</>
)}
</section>
</div>
</div>
)
}
export default DataTransfer