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,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
|
||||
Reference in New Issue
Block a user