From 1a759ef37d1f803e99f8c4129b56184430e9f6c5 Mon Sep 17 00:00:00 2001 From: Drsakura Date: Fri, 23 Oct 2026 13:46:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(data):=20=E4=B8=9A=E5=8A=A1=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=8E=E7=B3=BB=E7=BB=9F=E6=95=B0=E6=8D=AE=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E7=9A=84=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据迁移不再整体替换 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 --- README.md | 5 + backend/lib/dataTransfer/bundle.js | 357 +++++++++++++++++ backend/lib/dataTransfer/catalog.js | 151 ++++++++ backend/lib/dataTransfer/import.js | 325 ++++++++++++++++ backend/lib/dataTransfer/index.js | 139 +++++++ backend/lib/permissions.js | 9 +- backend/routes/dataTransfer.js | 222 +++++++++++ backend/server.js | 3 + backend/test/data-transfer-api.test.js | 191 +++++++++ backend/test/data-transfer.test.js | 208 ++++++++++ docs/data-transfer.md | 127 ++++++ frontend/src/App.jsx | 2 + frontend/src/components/Settings.jsx | 20 +- frontend/src/main.jsx | 1 + frontend/src/navigation.js | 6 +- frontend/src/pages/DataTransfer.jsx | 510 +++++++++++++++++++++++++ frontend/src/styles/data-transfer.css | 259 +++++++++++++ 17 files changed, 2516 insertions(+), 19 deletions(-) create mode 100644 backend/lib/dataTransfer/bundle.js create mode 100644 backend/lib/dataTransfer/catalog.js create mode 100644 backend/lib/dataTransfer/import.js create mode 100644 backend/lib/dataTransfer/index.js create mode 100644 backend/routes/dataTransfer.js create mode 100644 backend/test/data-transfer-api.test.js create mode 100644 backend/test/data-transfer.test.js create mode 100644 docs/data-transfer.md create mode 100644 frontend/src/pages/DataTransfer.jsx create mode 100644 frontend/src/styles/data-transfer.css diff --git a/README.md b/README.md index 96502ec..2943d78 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ ### 系统管理 - **用户管理** - 账号、角色与启停用 - **权限组管理** - 角色 → 权限组 → 权限点三级授权 +- **数据迁移** - 业务数据按类别导出/导入(产品、客户、订单…),账号与系统设置不随包迁移 - **系统日志** - 登录、改密与数据变更审计 ## 技术栈 @@ -121,6 +122,10 @@ SQLite 单文件存储: - 支持备份和迁移 - 数据目录与上传文件已在 `.gitignore` 中排除,不会入库 +> **换机器 / 上云请不要直接覆盖 `trade.db`。** 整个库里有账号、权限、组织、设置(含大模型密钥)、 +> 登录会话与审计日志,直接替换等于换掉一整套后台。请用「系统管理 · 数据迁移」按类别 +> 导出业务数据包再导入,系统数据保持目标部署自己那份。详见 [业务数据导入导出](docs/data-transfer.md)。 + > **已知限制:数据库当前未加密。** 驱动选用的是 `better-sqlite3-multiple-ciphers` > (本身具备 SQLCipher 能力),但代码尚未设置加密密钥,`trade.db` 以明文存放。 > 部署到共享主机或不可信环境前,请自行启用磁盘加密。 diff --git a/backend/lib/dataTransfer/bundle.js b/backend/lib/dataTransfer/bundle.js new file mode 100644 index 0000000..dd4a62b --- /dev/null +++ b/backend/lib/dataTransfer/bundle.js @@ -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 +} diff --git a/backend/lib/dataTransfer/catalog.js b/backend/lib/dataTransfer/catalog.js new file mode 100644 index 0000000..82e0570 --- /dev/null +++ b/backend/lib/dataTransfer/catalog.js @@ -0,0 +1,151 @@ +/** + * 业务数据目录 —— 导入 / 导出的唯一事实来源。 + * + * 设计口径(Wayne 2026-10-23 定): + * ① **业务数据与系统数据必须分开**。业务表(客户、订单、产品…)可以整包搬去另一个 + * 部署;系统表(用户、权限组、组织、会话、设置、审计)**永不随包走** —— + * 设置里还存着大模型密钥,会话里是登录态,搬过去等于给别人开后台。 + * ② **归属一律给导入人**。同事之间快速同步、或职责交接时,「谁导入就归谁」, + * 导完再由管理员在界面上重新分配,比在包里猜账号可靠。 + * ③ **冲突不覆盖**。线上已有的同一条记录(同货号 / 同供应商名 / 同订单号…)跳过并报告, + * 绝不静默改掉线上数据。 + * + * 表清单一律按**依赖顺序**排列:父表在前、子表在后。导入时逐行插入,外键始终成立, + * 不需要 `PRAGMA foreign_keys=OFF` 这种「先塞进去再祈祷」的做法。 + * 新增业务表时,只要在 TABLES 里按依赖顺序补一条,导出、导入、界面三处自动跟上。 + */ + +/** 可选的数据类别:一个类别 = 一组完整的业务闭环,可以单独导出/导入 */ +const CATEGORY_LIST = [ + { + key: 'catalog', + label: '产品目录', + description: '供应商、产品与品类、阶梯价、价格历史、包装、产品图片、合同认列记录' + }, + { + key: 'customers', + label: '客户与往来', + description: '客户、联系人、商机、询盘、客户报价单、跟进记录与时间线' + }, + { + key: 'orders', + label: '订单与支出', + description: '订单、订单明细、订单杂费(快递费/吊卡费等)' + }, + { + key: 'todos', + label: '待办与日程', + description: '待办事项与日历日程(不含账号信息)' + }, + { + key: 'prospects', + label: '新客开发', + description: '开发任务与抓取到的线索' + } +] + +/** + * 业务表清单(依赖顺序!)。 + * + * key —— 判定「线上是否已有同一条记录」的列。全部非空才参与去重; + * 某列为空时该行按新增处理(宁可多一条,也不误判成重复而丢数据)。 + * contentKey —— 没有自然业务键的**流水表**(如价格历史)用整行内容比对。 + * 价格历史里同一 SKU 在同一时间戳可能有多条改价(一次批量导入就会这样), + * 拿 (sku, changed_at) 当键会把其中一部分当成重复静默丢掉 —— 实测 643 行丢 186 行。 + * 整行比对等价于「内容完全相同才算重复」,不会误杀。 + * stub —— 被别的表引用、但包里没带父记录时,允许用 key[0] 建一条最小记录 + * (例如导入订单时可以只补一个只有公司名的客户壳)。 + * label —— 报告里给人看的名字。 + * + * 刻意不在清单里的表: + * users / sessions / organizations / permission_groups / permission_group_permissions / + * role_permission_groups / settings / audit_log —— 系统数据,永不导出、永不导入。 + * cart_items —— 只是本机「小推车」的临时挑选,不属于需要迁移的业务数据。 + */ +const TABLES = [ + /* ---------------- 产品目录 ---------------- */ + { table: 'suppliers', label: '供应商', category: 'catalog', key: ['name'], stub: true }, + { table: 'product_groups', label: '产品组', category: 'catalog', key: ['name', 'brand'], stub: true }, + /* 合同是产品价格的来源凭据:products.contract_id 指向它,所以必须排在 products 之前 */ + { table: 'contracts', label: '合同认列记录', category: 'catalog', key: ['filename', 'processed_at'] }, + { table: 'products', label: '产品', category: 'catalog', key: ['sku'] }, + { table: 'supplier_quotes', label: '供应商报价', category: 'catalog', key: ['supplier_id', 'quote_date'] }, + { table: 'tier_prices', label: '阶梯价', category: 'catalog', key: ['sku', 'moq_from', 'currency'] }, + { table: 'price_history', label: '价格历史', category: 'catalog', contentKey: true }, + { table: 'item_attributes', label: '产品属性', category: 'catalog', key: ['sku', 'name'] }, + { table: 'product_images', label: '产品图片', category: 'catalog', key: ['sku', 'filename'] }, + { table: 'product_image_files', label: '产品图片文件', category: 'catalog', key: ['image_id'] }, + { table: 'contract_files', label: '合同原件', category: 'catalog', key: ['contract_id'] }, + { table: 'product_packaging', label: '包装规格', category: 'catalog', key: ['sku'] }, + { table: 'contract_import_jobs', label: '清洗任务', category: 'catalog', key: ['job_no'] }, + { table: 'contract_import_items', label: '清洗明细', category: 'catalog', key: ['job_id', 'filename', 'row_no'] }, + + /* ---------------- 客户与往来 ---------------- */ + { table: 'customers', label: '客户', category: 'customers', key: ['company'], stub: true }, + { table: 'contacts', label: '联系人', category: 'customers', key: ['customer_id', 'name'] }, + { table: 'deals', label: '商机', category: 'customers', key: ['customer_id', 'title'] }, + { table: 'customer_quotations', label: '客户报价单', category: 'customers', key: ['quote_no'] }, + { table: 'quotation_items', label: '报价明细', category: 'customers', key: ['quotation_id', 'sku'] }, + { table: 'inquiries', label: '询盘', category: 'customers', key: ['inquiry_no'] }, + { table: 'inquiry_items', label: '询盘明细', category: 'customers', key: ['inquiry_id', 'sku'] }, + { table: 'follow_up_records', label: '跟进记录', category: 'customers', key: ['customer_id', 'created_at'] }, + + /* ---------------- 订单 ---------------- */ + { table: 'orders', label: '订单', category: 'orders', key: ['order_no'] }, + { table: 'order_items', label: '订单明细', category: 'orders', key: ['order_id', 'sku', 'unit_price'] }, + { table: 'order_expenses', label: '订单杂费', category: 'orders', key: ['order_id', 'category', 'amount', 'spent_at'] }, + + /* activities 引用 customers / deals / orders 三张表,所以必须排在订单之后 —— + 否则「时间线关联到哪张订单」这个外键会在导入时解析不到而丢成 NULL。 + 它属于「客户」类别,但依赖顺序优先于类别分组(类别只影响界面勾选)。 */ + { table: 'activities', label: '客户时间线', category: 'customers', key: ['customer_id', 'activity_type', 'created_at'] }, + + /* ---------------- 待办(todos 引用客户/商机/订单,故排在它们之后) ---------------- */ + { table: 'todos', label: '待办与日程', category: 'todos', key: ['title', 'created_at'] }, + + /* ---------------- 新客开发 ---------------- */ + { table: 'prospect_runs', label: '开发任务', category: 'prospects', key: ['name', 'created_at'] }, + { table: 'prospect_leads', label: '开发线索', category: 'prospects', key: ['run_id', 'company'] } +] + +/** + * 「归属人」类列:导入时一律改写成执行导入的人。 + * 这些列不随包迁移 —— 包里存的是原机器的用户 id,到这边既无意义也可能撞上别人。 + */ +const OWNER_COLUMNS = new Set(['owner_id', 'created_by', 'assigned_to', 'reviewed_by']) + +/** 归属组织:导入到当前部署的哪个组织(多组织阶段目前只有一个主组织) */ +const ORG_COLUMNS = new Set(['org_id']) + +const CATEGORY_KEYS = CATEGORY_LIST.map(item => item.key) + +/** 按类别取表(保持依赖顺序) */ +function tablesOfCategory(category) { + return TABLES.filter(item => item.category === category) +} + +/** 展开选择:'all' 或省略时返回全部业务表 */ +function resolveCategories(input) { + const list = Array.isArray(input) + ? input.map(item => String(item || '').trim()).filter(Boolean) + : String(input || '').split(',').map(item => item.trim()).filter(Boolean) + if (!list.length || list.includes('all')) return CATEGORY_KEYS.slice() + const unknown = list.filter(item => !CATEGORY_KEYS.includes(item)) + if (unknown.length) { + const error = new Error(`未知的数据类别:${unknown.join('、')}`) + error.statusCode = 400 + throw error + } + /* 去重 + 保持 CATEGORY_LIST 的固定顺序,报告顺序才稳定 */ + return CATEGORY_KEYS.filter(key => list.includes(key)) +} + +module.exports = { + CATEGORY_LIST, + CATEGORY_KEYS, + TABLES, + OWNER_COLUMNS, + ORG_COLUMNS, + tablesOfCategory, + resolveCategories +} diff --git a/backend/lib/dataTransfer/import.js b/backend/lib/dataTransfer/import.js new file mode 100644 index 0000000..67f8a36 --- /dev/null +++ b/backend/lib/dataTransfer/import.js @@ -0,0 +1,325 @@ +/** + * 业务数据导入内核。 + * + * 三条硬规则(对应 Wayne 2026-10-23 定的口径): + * ① **归属一律给导入人** —— owner_id / created_by / assigned_to 全部改写成本次导入的账号; + * ② **冲突跳过,绝不覆盖** —— 线上已有同一条记录就跳过,并在报告里点名; + * ③ **父记录可补壳** —— 只导订单时,客户按公司名找不到就建一条「只有公司名」的客户, + * 报告里单列「自动补建」,导完提示补全信息。 + * + * 这个函数是「纯函数式」的:给它一个已打开的库实例,它只在这个实例里写。 + * 因此同一个实现既能跑在**真实库**(apply),也能跑在**库的副本**(preview)—— + * 预览报告与实际导入完全同源,不存在「预览说能导、真导却失败」的两套逻辑。 + */ +const { TABLES, OWNER_COLUMNS, ORG_COLUMNS, resolveCategories, CATEGORY_LIST } = require('./catalog') +const { tableInfo, foreignKeys, tableExists, decodeValue } = require('./bundle') + +const quote = name => `"${String(name).replace(/"/g, '""')}"` +const MAX_WARNINGS_PER_TABLE = 20 + +/** 导入目标组织:优先取导入人所属组织,否则退回第一个组织 */ +function targetOrgId(db, user) { + if (user && user.org_id) return user.org_id + const row = db.prepare('SELECT id FROM organizations ORDER BY id LIMIT 1').get() + return row ? row.id : null +} + +function makeContext(db, user) { + return { + db, + userId: user ? user.id : null, + orgId: targetOrgId(db, user), + now: new Date().toISOString(), + ids: new Map(), // table -> Map(包内旧 id -> 本库新 id) + stubTotals: new Map(), + statements: new Map() + } +} + +function idMap(ctx, table) { + if (!ctx.ids.has(table)) ctx.ids.set(table, new Map()) + return ctx.ids.get(table) +} + +/** + * 按自然键找已存在的记录(供去重与父记录解析共用)。 + * + * 键里允许出现空值:product_groups 的键是「名称 + 品牌」,而品牌经常为空 —— + * 若要求每列都非空才算可比较,这类表就会永远判定「不存在」,重复导入时成倍增长。 + * 所以空值按 `IS NULL` 比较,只要键里至少有一列有值就参与去重。 + */ +function findByKey(db, table, keySpec, values) { + const clauses = [] + const params = [] + for (const key of keySpec) { + const value = values[key] + if (value === undefined) return null + if (value === null || value === '') { + clauses.push(`(${quote(key)} IS NULL OR ${quote(key)} = '')`) + } else { + clauses.push(`${quote(key)} = ?`) + params.push(value) + } + } + if (!clauses.length) return null + try { + return db.prepare(`SELECT * FROM ${quote(table)} WHERE ${clauses.join(' AND ')} LIMIT 1`).get(...params) || null + } catch { + return null + } +} + +/** 父记录的自然键快照 → 列值对象 */ +function keyValuesFromSnapshot(parentSpec, snapshot) { + if (snapshot === null || snapshot === undefined) return null + if (typeof snapshot === 'object') return snapshot + return { [parentSpec.key[0]]: snapshot } +} +/** 补一条最小父记录(客户只有公司名、供应商只有名字、产品组只有名字) */ +function createStub(ctx, parentSpec, keyValues) { + const value = keyValues ? keyValues[parentSpec.key[0]] : null + if (!value) return null + const { db, now } = ctx + if (parentSpec.table === 'customers') { + /* 补出来的客户直接归导入人、状态置为名下(不给 pooled 状态,避免导完冒出一堆公海客户) */ + const result = db.prepare(` + INSERT INTO customers (company, owner_id, created_by, org_id, status, pool_status, created_at) + VALUES (?, ?, ?, ?, 'active', 'owned', ?) + `).run(String(value), ctx.userId, ctx.userId, ctx.orgId, now) + ctx.stubTotals.set('customers', (ctx.stubTotals.get('customers') || 0) + 1) + return result.lastInsertRowid + } + if (parentSpec.table === 'suppliers') { + const result = db.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run(String(value), now) + ctx.stubTotals.set('suppliers', (ctx.stubTotals.get('suppliers') || 0) + 1) + return result.lastInsertRowid + } + if (parentSpec.table === 'product_groups') { + const result = db.prepare('INSERT INTO product_groups (name, created_at) VALUES (?, ?)').run(String(value), now) + ctx.stubTotals.set('product_groups', (ctx.stubTotals.get('product_groups') || 0) + 1) + return result.lastInsertRowid + } + return null +} + +/** + * 解析一个外键值: + * ① 本包里刚导进去的同一条(id 映射表命中) + * ② 本库已存在(按父记录自然键查) + * ③ 都没有 → 允许补壳的补一个,否则置空 + */ +function resolveParent(ctx, ref, rawValue, snapshot) { + const parentSpec = TABLES.find(item => item.table === ref.table) + const mapped = ctx.ids.get(ref.table) + if (mapped && mapped.has(rawValue)) return mapped.get(rawValue) + + if (parentSpec && parentSpec.key && parentSpec.key.length) { + /* 父表主键就是自然键(products.sku):父记录在就沿用原值,不在就置空由约束决定成败 */ + if (ref.to && ref.to !== 'id') { + const exists = ctx.db.prepare(`SELECT 1 FROM ${quote(ref.table)} WHERE ${quote(ref.to)} = ?`).get(rawValue) + return exists ? rawValue : null + } + const keyValues = keyValuesFromSnapshot(parentSpec, snapshot) + if (keyValues) { + const row = findByKey(ctx.db, ref.table, parentSpec.key, keyValues) + if (row) { + if (mapped) mapped.set(rawValue, row.id) + return row.id + } + if (parentSpec.stub) { + const created = createStub(ctx, parentSpec, keyValues) + if (created) { + if (mapped) mapped.set(rawValue, created) + return created + } + } + } + } + return null +} + +function prepareStatement(ctx, sql) { + if (!ctx.statements.has(sql)) ctx.statements.set(sql, ctx.db.prepare(sql)) + return ctx.statements.get(sql) +} + +/** 单表导入 */ +function importTable(ctx, spec, rows, info) { + const stat = { + table: spec.table, + label: spec.label, + category: spec.category, + rows: rows.length, + insert: 0, + skip: 0, + failed: 0, + warnings: [] + } + const warn = message => { + if (stat.warnings.length < MAX_WARNINGS_PER_TABLE) stat.warnings.push(message) + } + + const columnsByName = new Map(info.columns.map(column => [column.name, column])) + const fkByColumn = new Map(foreignKeys(ctx.db, spec.table).map(ref => [ref.from, ref])) + const unknownColumns = new Set() + + /* 去重用的列:普通表用目录里定义的业务键;流水表(price_history)用整行内容。 + 整行比对时排除自增主键与「归属人 / 组织」—— 这两类本来就会被改写成本次导入的人, + 不该参与「是不是同一条记录」的判断。 */ + const keyColumns = spec.contentKey + ? info.columns + .map(column => column.name) + .filter(name => !(info.autoId && name === info.pk) && !OWNER_COLUMNS.has(name) && !ORG_COLUMNS.has(name)) + : spec.key + + /* 流水表允许同一个包里出现逐列完全相同的两行(源库确实会这样:一次批量改价写两条)。 + 它们对线上来说是两条独立的历史记录,必须都带过去;只跟**导入前**existing 的比对。 + 普通表不允许:两个同货号的行塞进去会撞主键,那是数据本身有问题,跳过并报告更合适。 */ + const duplicatesInsidePackage = new Set() + + for (let index = 0; index < rows.length; index++) { + const raw = rows[index] + try { + const values = {} + const refs = raw.__refs && typeof raw.__refs === 'object' ? raw.__refs : {} + + for (const name of Object.keys(raw)) { + if (name === '__refs') continue + const column = columnsByName.get(name) + if (!column) { unknownColumns.add(name); continue } + const value = decodeValue(raw[name], info.blobs.has(name)) + + /* 归属人 / 归属组织:一律换成「本次导入的人 / 当前组织」 */ + if (OWNER_COLUMNS.has(name)) { + values[name] = value === null ? null : ctx.userId + continue + } + if (ORG_COLUMNS.has(name)) { + values[name] = ctx.orgId + continue + } + + const ref = fkByColumn.get(name) + if (ref) { + if (value === null) { values[name] = null; continue } + const mapped = resolveParent(ctx, ref, value, refs[name]) + if (mapped === null || mapped === undefined) { + /* 父记录既不在包里也补不出来:外键置空,由 NOT NULL 约束决定该行成败 */ + values[name] = null + } else { + values[name] = mapped + } + continue + } + values[name] = value + } + + /* 客户:归属强制给导入人;本来就是公海的客户改成「名下有主」, + 否则会出现「归我所有却标记在公海」这种自相矛盾的状态。 */ + if (spec.table === 'customers') { + values.owner_id = ctx.userId + values.created_by = ctx.userId + values.org_id = ctx.orgId + if (values.pool_status === 'public') { + values.pool_status = 'owned' + values.pool_at = null + values.pool_note = null + } + } + + /* 去重:键里至少一列有值才判定;某列为空按 NULL 比较,不因此放弃去重 + (否则「品牌为空的产品组」这种表每次重导都会翻倍) */ + const keyValues = {} + let keyReady = false + for (const key of keyColumns) { + const value = values[key] + if (value === undefined || value === null || value === '') { + keyValues[key] = null + continue + } + keyValues[key] = value + keyReady = true + } + if (keyReady) { + const fingerprint = keyColumns.map(key => `${key}=${keyValues[key] === null ? '\u0000' : keyValues[key]}`).join('\u0001') + const repeatInsidePackage = duplicatesInsidePackage.has(fingerprint) + const existing = (!repeatInsidePackage || !spec.contentKey) + ? findByKey(ctx.db, spec.table, keyColumns, keyValues) + : null + if (existing) { + stat.skip++ + if (info.autoId && raw.id !== undefined && raw.id !== null && existing[info.pk]) { + idMap(ctx, spec.table).set(raw.id, existing[info.pk]) + } + continue + } + duplicatesInsidePackage.add(fingerprint) + } + + const names = Object.keys(values).filter(name => !(info.autoId && name === info.pk)) + if (!names.length) throw new Error('没有可写入的字段') + + const sql = `INSERT INTO ${quote(spec.table)} (${names.map(quote).join(', ')}) VALUES (${names.map(() => '?').join(', ')})` + const result = prepareStatement(ctx, sql).run(...names.map(name => values[name])) + + stat.insert++ + if (info.autoId) { + const oldId = raw.id + if (oldId !== undefined && oldId !== null) idMap(ctx, spec.table).set(oldId, result.lastInsertRowid) + } + } catch (error) { + stat.failed++ + warn(`第 ${index + 1} 行:${String(error.message || error).slice(0, 160)}`) + } + } + + if (unknownColumns.size) { + warn(`目标库缺少列 ${[...unknownColumns].join('、')}(版本较旧?),这些字段已忽略`) + } + return stat +} + +/** + * 把数据包写进给定的库实例。 + * 调用方负责包在事务里(见 index.js 的 applyImport)——这里出现异常就整体回滚。 + */ +function importInto(db, bundle, { categories, user }) { + const selected = new Set(resolveCategories(categories)) + const ctx = makeContext(db, user) + const tables = [] + const missing = [] + + for (const spec of TABLES) { + if (!selected.has(spec.category)) continue + const rows = bundle.rows.get(spec.table) + if (!rows || !rows.length) continue + if (!tableExists(db, spec.table)) { missing.push(spec.table); continue } + const info = tableInfo(db, spec.table) + if (!info) { missing.push(spec.table); continue } + tables.push(importTable(ctx, spec, rows, info)) + } + + const totals = tables.reduce((acc, item) => { + acc.rows += item.rows + acc.insert += item.insert + acc.skip += item.skip + acc.failed += item.failed + return acc + }, { rows: 0, insert: 0, skip: 0, failed: 0 }) + + const stubbed = [...ctx.stubTotals.entries()].map(([table, count]) => ({ + table, + label: (TABLES.find(item => item.table === table) || {}).label || table, + count + })) + + return { + categories: CATEGORY_LIST.filter(item => selected.has(item.key)).map(item => ({ key: item.key, label: item.label })), + tables, + totals, + stubbed, + missing + } +} + +module.exports = { importInto, targetOrgId } diff --git a/backend/lib/dataTransfer/index.js b/backend/lib/dataTransfer/index.js new file mode 100644 index 0000000..6f62d97 --- /dev/null +++ b/backend/lib/dataTransfer/index.js @@ -0,0 +1,139 @@ +/** + * 数据迁移的门面:把「读包、预览、真导、备份」串起来,路由只调这一层。 + * + * 预览为什么要开一个库副本: + * 预览必须回答「照这样导,线上会变成什么样」。如果只是把行数点一遍,就答不了 + * 「哪一行会因为外键或非空约束失败」。所以预览在做一次 VACUUM INTO 出来的副本上 + * **真跑一遍导入**,跑完把副本删掉 —— 报告里的数字与实际导入完全同源。 + * 库只有几 MB,拷一份比维护两套判断逻辑便宜得多。 + */ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const Database = require('better-sqlite3-multiple-ciphers') + +const { CATEGORY_LIST, TABLES, resolveCategories } = require('./catalog') +const { writeBundle, readBundle, tableExists } = require('./bundle') +const { importInto } = require('./import') + +const escapePath = file => String(file).replace(/'/g, "''") + +/** 本机各类别各表当前行数(界面用来显示「这次会导出多少」) */ +function listCatalog(db) { + return CATEGORY_LIST.map(category => ({ + key: category.key, + label: category.label, + description: category.description, + tables: TABLES.filter(spec => spec.category === category.key).map(spec => { + let rows = 0 + if (tableExists(db, spec.table)) { + try { rows = db.prepare(`SELECT COUNT(*) AS n FROM "${spec.table}"`).get().n } catch { rows = 0 } + } + return { table: spec.table, label: spec.label, rows } + }) + })) +} + +/** 导出到可写流(HTTP 响应直接接) */ +async function exportToStream(db, output, options) { + await writeBundle(db, output, options) +} + +/** 导出的文件名:带时间戳,便于在服务器上分辨先后 */ +function exportFileName(date = new Date()) { + const pad = value => String(value).padStart(2, '0') + const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}` + return `happy-export-${stamp}.hdata.gz` +} + +function readFileMeta(bundle, filePath, fileName) { + return { + name: fileName || path.basename(filePath), + size: bundle.size, + exportedAt: bundle.header.exportedAt, + exporter: bundle.header.exporter || null, + app: bundle.header.app, + version: bundle.header.version, + categories: bundle.categories + } +} + +/** + * 预览:在库副本上真跑一遍导入,返回与实际导入同源的报告。 + * 副本用完即删,线上库不受任何影响。 + */ +async function previewImport(sourceDb, filePath, { categories, user, workDir, fileName }) { + fs.mkdirSync(workDir, { recursive: true }) + const bundle = await readBundle(filePath) + const selected = resolveCategories(categories && categories.length ? categories : bundle.categories) + const available = selected.filter(key => bundle.categories.includes(key)) + if (!available.length) { + const error = new Error('所选类别在数据包里没有内容,无法导入') + error.statusCode = 400 + throw error + } + + const copyPath = path.join(workDir, `preview-${crypto.randomBytes(6).toString('hex')}.db`) + if (fs.existsSync(copyPath)) fs.unlinkSync(copyPath) + sourceDb.exec(`VACUUM INTO '${escapePath(copyPath)}'`) + + const copy = new Database(copyPath) + try { + copy.pragma('foreign_keys = ON') + copy.pragma('busy_timeout = 5000') + const plan = copy.transaction(() => importInto(copy, bundle, { categories: available, user }))() + return { file: readFileMeta(bundle, filePath, fileName), plan, availableCategories: available } + } finally { + try { copy.close() } catch { /* 副本关闭失败不影响结果 */ } + try { fs.unlinkSync(copyPath) } catch { /* 副本已被占用就留着,下次预览会覆盖 */ } + } +} + +/** + * 真正导入:先给线上库做一份完整备份(留档可回滚),再在一个事务里写入。 + * 任何未预期的异常都会整体回滚,备份文件保留在 backupDir。 + */ +async function applyImport(db, filePath, { categories, user, backupDir, fileName }) { + fs.mkdirSync(backupDir, { recursive: true }) + const bundle = await readBundle(filePath) + const selected = resolveCategories(categories && categories.length ? categories : bundle.categories) + const available = selected.filter(key => bundle.categories.includes(key)) + if (!available.length) { + const error = new Error('所选类别在数据包里没有内容,无法导入') + error.statusCode = 400 + throw error + } + + /* 时间戳精确到秒,再拼一段随机后缀:同一秒内连续导两次也不会撞出 + 「output file already exists」,而文件名照样按时间可排序。 */ + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14) + const backupPath = path.join(backupDir, `pre-import-${stamp}-${crypto.randomBytes(3).toString('hex')}.db`) + db.exec(`VACUUM INTO '${escapePath(backupPath)}'`) + + const plan = db.transaction(() => importInto(db, bundle, { categories: available, user }))() + return { file: readFileMeta(bundle, filePath, fileName), plan, backup: backupPath, availableCategories: available } +} + +/** 清理过期的上传包与预览副本(默认 24 小时) */ +function cleanupUploads(dir, { maxAgeMs = 24 * 60 * 60 * 1000 } = {}) { + let removed = 0 + try { + for (const name of fs.readdirSync(dir)) { + const file = path.join(dir, name) + try { + const stat = fs.statSync(file) + if (Date.now() - stat.mtimeMs > maxAgeMs) { fs.unlinkSync(file); removed++ } + } catch { /* 单个文件失败不影响其它清理 */ } + } + } catch { /* 目录还不存在 */ } + return removed +} + +module.exports = { + listCatalog, + exportToStream, + exportFileName, + previewImport, + applyImport, + cleanupUploads +} diff --git a/backend/lib/permissions.js b/backend/lib/permissions.js index 9d6de42..fbec71a 100644 --- a/backend/lib/permissions.js +++ b/backend/lib/permissions.js @@ -56,7 +56,10 @@ const PERMISSIONS = [ { key: 'user.manage', label: '用户管理', group: '系统管理' }, { key: 'perm.manage', label: '权限组管理', group: '系统管理' }, { key: 'ai.manage', label: '智能配置(大模型接入与用途分配)', group: '系统管理' }, - { key: 'audit.view', label: '审计日志', group: '系统管理' } + { key: 'audit.view', label: '审计日志', group: '系统管理' }, + /* 业务数据的导入导出:一次能把几千条商品/客户写进库里,也能把整库业务数据带走, + 所以只给超级管理员组。包内永远不会包含用户、权限、组织、设置与会话。 */ + { key: 'data.transfer', label: '数据导入导出(业务数据迁移)', group: '系统管理' } ] const PERMISSION_KEYS = PERMISSIONS.map(item => item.key) @@ -91,7 +94,7 @@ const DEFAULT_PERMISSION_GROUPS = [ `branding.manage` 反过来说:它**靠** .manage 后缀被自动排除, 销售经理永远拿不到改公司信息的权限(Wayne 口径:只有高管能改)。 */ permissions: PERMISSION_KEYS.filter(key => - !key.endsWith('.manage') && key !== 'audit.view' && key !== 'customer.view_all'), + !key.endsWith('.manage') && !['audit.view', 'customer.view_all', 'data.transfer'].includes(key)), roles: ['sales_manager'] }, { @@ -119,7 +122,7 @@ const DEFAULT_PERMISSION_GROUPS = [ user.manage / perm.manage 是账号治理权,ai.manage 会看到所有模型密钥, audit.view 能看到全公司的操作轨迹 —— Wayne 口径是「系统日志仅系统管理员可见」。 */ permissions: PERMISSION_KEYS.filter(key => - !['user.manage', 'perm.manage', 'ai.manage', 'audit.view'].includes(key)), + !['user.manage', 'perm.manage', 'ai.manage', 'audit.view', 'data.transfer'].includes(key)), roles: ['chairman', 'general_manager'] } ] diff --git a/backend/routes/dataTransfer.js b/backend/routes/dataTransfer.js new file mode 100644 index 0000000..5b68933 --- /dev/null +++ b/backend/routes/dataTransfer.js @@ -0,0 +1,222 @@ +/** + * 数据迁移接口(业务数据导入 / 导出)。 + * 挂载点:/api/data + * + * 与「系统更新」的区别:这里动的是**业务数据**,不碰用户、权限、组织、设置与日志。 + * 权限点 data.transfer 只授予超级管理员组 —— 一次导入能把几千条商品写进库里, + * 不该出现在业务员的菜单里。 + */ +const express = require('express') +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const multer = require('multer') + +const db = require('../db/db') +const auth = require('../lib/auth') +const transfer = require('../lib/dataTransfer') +const { MAX_BUNDLE_BYTES } = require('../lib/dataTransfer/bundle') +const { resolveCategories } = require('../lib/dataTransfer/catalog') + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data') +const TRANSFER_DIR = path.join(DATA_DIR, 'transfers') +const UPLOAD_DIR = path.join(TRANSFER_DIR, 'uploads') +const BACKUP_DIR = path.join(DATA_DIR, 'backups') + +fs.mkdirSync(UPLOAD_DIR, { recursive: true }) + +const router = express.Router() +const guard = auth.requirePermission('data.transfer') + +const upload = multer({ + dest: UPLOAD_DIR, + limits: { fileSize: MAX_BUNDLE_BYTES, files: 1 } +}) + +function safeUnlink(file) { + try { fs.unlinkSync(file) } catch { /* 已不存在 */ } +} + +/** 请求里的类别既可能是 "a,b",也可能是 ["a","b"](表单或 JSON 都兼容) */ +function parseCategories(input) { + if (Array.isArray(input)) return input + if (typeof input === 'string' && input.trim()) { + try { + const parsed = JSON.parse(input) + if (Array.isArray(parsed)) return parsed + } catch { /* 不是 JSON 就按逗号分隔 */ } + return input.split(',').map(item => item.trim()).filter(Boolean) + } + return [] +} + +/** multipart 里的中文文件名是裸字节,按 UTF-8 重解一次(与 server.js 同一套判断) */ +function decodeOriginalName(name) { + const raw = String(name || '') + if (!raw || !/[\u0080-\u00ff]/.test(raw)) return raw + const restored = Buffer.from(raw, 'latin1').toString('utf8') + return restored.includes('\ufffd') ? raw : restored +} + +/* ------------------------------------------------------------------ 目录 */ + +/** GET /api/data/catalog —— 各类别与各表在本机的行数,界面用 */ +router.get('/catalog', guard, (req, res) => { + try { + res.setHeader('Cache-Control', 'no-store') + res.json({ + categories: transfer.listCatalog(db), + limits: { maxBytes: MAX_BUNDLE_BYTES } + }) + } catch (error) { + res.status(500).json({ error: error.message }) + } +}) + +/* ------------------------------------------------------------------ 导出 */ + +/** GET /api/data/export?categories=catalog,orders(省略或 all = 全部业务数据) */ +router.get('/export', guard, async (req, res) => { + let categories + try { + categories = resolveCategories(req.query.categories) + } catch (error) { + return res.status(error.statusCode || 400).json({ error: error.message }) + } + + const fileName = transfer.exportFileName() + res.setHeader('Content-Type', 'application/gzip') + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`) + res.setHeader('Cache-Control', 'no-store') + + try { + await transfer.exportToStream(db, res, { categories, exporter: req.user }) + auth.audit({ + username: req.user.username, + action: 'data_export', + detail: `导出业务数据:${categories.join('、')}(${fileName})`, + req + }) + } catch (error) { + /* 已经开始下发就只能断开 —— 让浏览器看到传输失败,而不是拿到一个半截包当成功 */ + if (!res.headersSent) res.status(500).json({ error: error.message }) + else res.destroy() + } +}) + +/* ------------------------------------------------------------------ 导入 */ + +/** + * POST /api/data/import/preview —— 上传数据包并出预览报告(不写线上库) + * 表单:file(数据包)、categories(可选,逗号分隔或 JSON 数组) + */ +router.post('/import/preview', guard, upload.single('file'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: '请选择要导入的数据包(.hdata.gz)' }) + + const id = crypto.randomBytes(12).toString('hex') + const target = path.join(TRANSFER_DIR, `${id}.hdata`) + const fileName = decodeOriginalName(req.file.originalname) + + try { + fs.renameSync(req.file.path, target) + /* 顺手清掉 24 小时前的旧包,避免上传目录无限增长(本次刚移入的 mtime 是现在,不会被删) */ + transfer.cleanupUploads(TRANSFER_DIR) + + const categories = parseCategories(req.body?.categories) + const preview = await transfer.previewImport(db, target, { + categories, + user: req.user, + workDir: TRANSFER_DIR, + fileName + }) + res.json({ id, fileName, ...preview }) + } catch (error) { + safeUnlink(target) + safeUnlink(req.file.path) + res.status(error.statusCode || 400).json({ error: error.message }) + } +}) + +/** + * POST /api/data/import/replan —— 用已上传的包按新类别重算预览(不重新上传) + * 请求体:{ id, categories, fileName? } + */ +router.post('/import/replan', guard, async (req, res) => { + const id = String(req.body?.id || '') + if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '导入任务标识无效,请重新上传数据包' }) + + const file = path.join(TRANSFER_DIR, `${id}.hdata`) + if (!fs.existsSync(file)) { + return res.status(404).json({ error: '数据包已过期(超过 24 小时),请重新上传后再预览一次' }) + } + + try { + const preview = await transfer.previewImport(db, file, { + categories: parseCategories(req.body?.categories), + user: req.user, + workDir: TRANSFER_DIR, + fileName: req.body?.fileName ? decodeOriginalName(req.body.fileName) : undefined + }) + res.json({ id, fileName: req.body?.fileName, ...preview }) + } catch (error) { + res.status(error.statusCode || 400).json({ error: error.message }) + } +}) + +/** + * POST /api/data/import/apply —— 按预览过的数据包真导 + * 请求体:{ id, categories? } + */ +router.post('/import/apply', guard, async (req, res) => { + const id = String(req.body?.id || '') + if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '导入任务标识无效,请重新上传数据包' }) + + const file = path.join(TRANSFER_DIR, `${id}.hdata`) + if (!fs.existsSync(file)) { + return res.status(404).json({ error: '数据包已过期(超过 24 小时),请重新上传后再预览一次' }) + } + + try { + const categories = parseCategories(req.body?.categories) + const result = await transfer.applyImport(db, file, { + categories, + user: req.user, + backupDir: BACKUP_DIR + }) + + const summary = result.plan.tables + .filter(item => item.insert || item.skip || item.failed) + .map(item => `${item.label} 新增 ${item.insert} / 跳过 ${item.skip}${item.failed ? ` / 失败 ${item.failed}` : ''}`) + .join(',') + auth.audit({ + username: req.user.username, + action: 'data_import', + detail: `导入业务数据:${result.plan.categories.map(item => item.label).join('、')}(${summary});备份 ${path.basename(result.backup)}`, + req + }) + + safeUnlink(file) + res.json({ fileName: result.file.name, ...result }) + } catch (error) { + res.status(error.statusCode || 500).json({ error: error.message }) + } +}) + +/** DELETE /api/data/import/:id —— 用户取消时丢掉临时包 */ +router.delete('/import/:id', guard, (req, res) => { + const id = String(req.params.id || '') + if (!/^[a-f0-9]{24}$/.test(id)) return res.status(400).json({ error: '标识无效' }) + safeUnlink(path.join(TRANSFER_DIR, `${id}.hdata`)) + res.json({ ok: true }) +}) + +/* multer 的体积/文件数错误要变成可读提示,不能落成 500 */ +router.use((error, req, res, next) => { + if (!error) return next() + if (error.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: `数据包超过 ${MAX_BUNDLE_BYTES / 1024 / 1024} MB 上限,请按类别分开导出后导入` }) + } + return res.status(error.statusCode || 400).json({ error: error.message }) +}) + +module.exports = router diff --git a/backend/server.js b/backend/server.js index 0a43e0f..6d054cb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -160,6 +160,9 @@ app.get('/api/health', (req, res) => { // 系统信息与版本更新检查 app.use('/api/system', systemRoutes); +// 业务数据导入导出(系统数据不在包内):类别可选,单独导 SKU / 客户 / 订单,也可整体进出 +app.use('/api/data', require('./routes/dataTransfer')); + // 账号、个人资料与改密 app.use('/api', authRoutes); diff --git a/backend/test/data-transfer-api.test.js b/backend/test/data-transfer-api.test.js new file mode 100644 index 0000000..c60acc1 --- /dev/null +++ b/backend/test/data-transfer-api.test.js @@ -0,0 +1,191 @@ +/** + * 数据迁移接口的端到端测试。 + * + * 起两个真实后端进程: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() + } +}) diff --git a/backend/test/data-transfer.test.js b/backend/test/data-transfer.test.js new file mode 100644 index 0000000..6a5b95f --- /dev/null +++ b/backend/test/data-transfer.test.js @@ -0,0 +1,208 @@ +/** + * 业务数据导入导出的回归测试。 + * + * 全程在临时 DATA_DIR 里跑,不碰 backend/data/trade.db。 + * 覆盖: + * ① 系统表绝不进包(users / settings / audit_log …) + * ② 导出 → 导入到「另一个空部署」后行数与内容一致,BLOB 完整 + * ③ 重复导入同一包:全部跳过,不产生重复数据(冲突不覆盖) + * ④ 只导订单:客户自动补壳、产品不跟着进来 + * ⑤ 包被改坏:校验失败并拒绝导入 + */ +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 zlib = require('node:zlib') + +const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-transfer-')) +process.env.DATA_DIR = DATA_DIR + +const db = require('../db/db') +const transfer = require('../lib/dataTransfer') +const { readBundle, writeBundleFile, foreignKeys } = require('../lib/dataTransfer/bundle') +const { TABLES } = require('../lib/dataTransfer/catalog') + +const workDir = path.join(DATA_DIR, 'work') +fs.mkdirSync(workDir, { recursive: true }) + +const IMAGE_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]) + +function seed() { + const now = '2026-01-01T00:00:00.000Z' + const admin = db.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get() + db.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run('宁波腾达', now) + db.prepare('INSERT INTO product_groups (name, group_key, created_at) VALUES (?, ?, ?)').run('扭矩扳手', 'torque', now) + db.prepare(`INSERT INTO products (sku, name, price, currency, supplier_id, group_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run('TD-100', '扭矩扳手 100N', 88, 'CNY', 1, 1, now) + db.prepare(`INSERT INTO products (sku, name, price, currency, supplier_id, created_at) + VALUES (?, ?, ?, ?, ?, ?)`).run('TD-200', '扭矩扳手 200N', 128, 'CNY', 1, now) + db.prepare('INSERT INTO product_packaging (sku, carton_qty, created_at) VALUES (?, ?, ?)').run('TD-100', 10, now) + db.prepare('INSERT INTO product_images (sku, filename, created_at) VALUES (?, ?, ?)').run('TD-100', 'a.png', now) + db.prepare('INSERT INTO product_image_files (image_id, bytes, size, stored_at) VALUES (?, ?, ?, ?)') + .run(1, IMAGE_BYTES, IMAGE_BYTES.length, now) + db.prepare('INSERT INTO price_history (sku, old_price, new_price, changed_at) VALUES (?, ?, ?, ?)') + .run('TD-100', 80, 88, now) + db.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) + db.prepare('INSERT INTO contacts (customer_id, name, email, created_at) VALUES (?, ?, ?, ?)') + .run(1, 'Hans', 'hans@acme.de', now) + db.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) + db.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) +} + +/** 用「源库的副本 + 清空业务表」模拟另一台全新部署:schema 与线上完全一致,只有系统数据 */ +function cloneEmptyDeployment(source, label) { + const file = path.join(workDir, `${label}.db`) + source.exec(`VACUUM INTO '${file.replace(/'/g, "''")}'`) + const Database = require('better-sqlite3-multiple-ciphers') + const copy = new Database(file) + copy.pragma('foreign_keys = ON') + for (const table of ['order_items', 'orders', 'contacts', 'customers', 'price_history', 'product_image_files', + 'product_images', 'product_packaging', 'products', 'product_groups', 'suppliers', 'contracts']) { + copy.exec(`DELETE FROM "${table}"`) + } + return copy +} + +test.after(() => { + try { db.close() } catch { /* 已关闭 */ } + try { fs.rmSync(DATA_DIR, { recursive: true, force: true }) } catch { /* Windows 偶发占用 */ } +}) + +test('导出包只含业务表,系统表一律不出现', async () => { + seed() + const file = path.join(workDir, 'all.hdata.gz') + await writeBundleFile(db, file, { categories: ['all'], exporter: db.prepare('SELECT * FROM users LIMIT 1').get() }) + const bundle = await readBundle(file) + + for (const table of ['users', 'sessions', 'settings', 'audit_log', 'organizations', 'permission_groups']) { + assert.equal(bundle.rows.has(table), false, `${table} 不应出现在数据包里`) + } + assert.equal(bundle.rows.get('products').length, 2) + assert.equal(bundle.rows.get('orders').length, 1) + assert.deepEqual(bundle.categories.sort(), ['catalog', 'customers', 'orders', 'prospects', 'todos']) +}) + +test('导入到另一台空部署:行数一致、归属给导入人、BLOB 完整', async () => { + const file = path.join(workDir, 'all.hdata.gz') + const target = cloneEmptyDeployment(db, 'target-all') + const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get() + + const result = await transfer.applyImport(target, file, { + user: importer, + backupDir: path.join(workDir, 'backups') + }) + + assert.ok(result.backup && fs.existsSync(result.backup), '导入前必须留一份备份') + assert.equal(result.plan.totals.failed, 0) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 2) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM suppliers').get().n, 1) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM orders').get().n, 1) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM customers').get().n, 1) + + const customer = target.prepare('SELECT * FROM customers WHERE company = ?').get('ACME GmbH') + assert.equal(customer.owner_id, importer.id, '客户归属应改写为导入人') + + const bytes = target.prepare('SELECT bytes FROM product_image_files').get().bytes + assert.deepEqual(Buffer.from(bytes), IMAGE_BYTES, 'BLOB 必须逐字节一致') + + /* 系统数据没被覆盖:导入前后用户表还是目标库自己那份 */ + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM users').get().n, 1) + + target.close() +}) + +test('同一包再导一次:全部跳过,不产生重复', async () => { + const file = path.join(workDir, 'all.hdata.gz') + const target = cloneEmptyDeployment(db, 'target-repeat') + const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get() + + const first = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') }) + assert.ok(first.plan.totals.insert > 0) + + const second = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') }) + assert.equal(second.plan.totals.insert, 0, '第二次导入不应新增任何行') + assert.equal(second.plan.totals.skip, first.plan.totals.rows) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 2) + + target.close() +}) + +test('只导订单:客户自动补壳,产品数据不跟进来', async () => { + const file = path.join(workDir, 'orders-only.hdata.gz') + await writeBundleFile(db, file, { categories: ['orders'] }) + const bundle = await readBundle(file) + assert.deepEqual([...bundle.rows.keys()].sort(), ['order_items', 'orders'], '只选订单类别时不应带出产品/客户') + + const target = cloneEmptyDeployment(db, 'target-orders') + const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get() + const result = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') }) + + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM orders').get().n, 1) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM order_items').get().n, 1) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 0, '未选产品类别就不该有产品') + + const stub = target.prepare('SELECT * FROM customers').get() + assert.equal(stub.company, 'ACME GmbH', '应按 __refs 快照补出客户公司名') + assert.equal(stub.owner_id, importer.id) + assert.deepEqual(result.plan.stubbed.map(item => item.table), ['customers']) + + target.close() +}) + +test('流水表:包内逐列相同的记录全部保留,重导仍全部跳过', async () => { + /* 源库确实会这样:一次批量改价会在同一毫秒写两条一模一样的 price_history。 + 拿 (sku, changed_at) 当去重键会把其中一条当重复丢掉(实测 643 行丢 186 行), + 所以流水表改成「整行内容比对」,且允许同一个包里出现相同的两行。 */ + const now = '2026-02-02T00:00:00.000Z' + const insert = db.prepare('INSERT INTO price_history (sku, old_price, new_price, changed_at) VALUES (?, ?, ?, ?)') + insert.run('TD-200', 100, 110, now) + insert.run('TD-200', 100, 110, now) + + const file = path.join(workDir, 'history.hdata.gz') + await writeBundleFile(db, file, { categories: ['catalog'] }) + const bundle = await readBundle(file) + assert.equal(bundle.rows.get('price_history').length, 3, '源库 1 条 + 新增 2 条都应进包') + + const target = cloneEmptyDeployment(db, 'target-history') + const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get() + const first = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') }) + assert.equal(first.plan.totals.failed, 0) + assert.equal(target.prepare('SELECT COUNT(*) AS n FROM price_history').get().n, 3, '逐列相同的历史记录也要全部带过去') + + const second = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') }) + assert.equal(second.plan.totals.insert, 0, '第二次导入不应新增') + + target.close() +}) + +test('目录顺序满足外键依赖:父表一定排在子表前面', () => { + /* 导入是逐表逐行插入的,顺序错了就会撞外键。这条测试保证以后新增业务表时 + 有人把表插到错误位置会立刻红。 */ + const known = new Set(TABLES.map(item => item.table)) + const seen = new Set() + for (const spec of TABLES) { + for (const ref of foreignKeys(db, spec.table)) { + if (ref.table === spec.table) continue + if (!known.has(ref.table)) continue // 指向 users / organizations 等系统表的列不参与 + assert.ok(seen.has(ref.table), `${spec.table}.${ref.from} 引用的 ${ref.table} 必须排在它前面`) + } + seen.add(spec.table) + } +}) + +test('包被改坏:sha256 校验失败并拒绝导入', async () => { + const file = path.join(workDir, 'all.hdata.gz') + const lines = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8').split('\n').filter(Boolean) + const productLine = lines.findIndex(line => line.includes('"t":"products"') && line.includes('TD-100')) + assert.ok(productLine > 0) + lines[productLine] = lines[productLine].replace('TD-100', 'TD-999') + const broken = path.join(workDir, 'broken.hdata') + fs.writeFileSync(broken, lines.join('\n') + '\n') + + await assert.rejects(() => readBundle(broken), /校验失败/) +}) diff --git a/docs/data-transfer.md b/docs/data-transfer.md new file mode 100644 index 0000000..2f8f1f2 --- /dev/null +++ b/docs/data-transfer.md @@ -0,0 +1,127 @@ +# 业务数据导入导出(数据迁移) + +> 对应 `backend/lib/dataTransfer/`、`backend/routes/dataTransfer.js`、`frontend/src/pages/DataTransfer.jsx`。 +> 界面入口:侧栏 · 系统管理 · **数据迁移**(权限点 `data.transfer`,默认只给超级管理员组)。 + +## 为什么不再「整个 trade.db 换过去」 + +把本地 `trade.db` 直接覆盖到服务器,会把**整套系统状态**一起带过去:`users`(密码哈希)、 +`sessions`(登录态)、`settings`(里面存着大模型 API Key 与合同认列规则)、`permission_groups`、 +`audit_log`。这等于让「传数据」变成「换一套后台」,而且线上一旦有自己的账号和配置就会被抹掉。 + +所以数据分成两类,边界写死在 `backend/lib/dataTransfer/catalog.js`: + +| 业务数据(可导出 / 可导入) | 系统数据(永不进包、永不覆盖) | +| --- | --- | +| 供应商、产品组、合同认列记录、产品、供应商报价、阶梯价、价格历史、产品属性、产品图片(含 BLOB)、合同原件(含 BLOB)、包装规格、清洗任务与明细 | 用户与密码 | +| 客户、联系人、商机、客户报价单与明细、询盘与明细、客户时间线、跟进记录 | 登录会话 | +| 订单、订单明细、订单杂费 | 组织信息 | +| 待办与日程 | 权限组、角色指派 | +| 新客开发任务与线索 | 系统设置(AI 密钥、合同规则、权限台账) | +| | 审计日志 | + +`cart_items`(本机小推车)也不在迁移范围 —— 它只是临时挑选状态,不属于要交接的数据。 + +## 数据包格式 + +文件名形如 `happy-export-20261023-1512.hdata.gz`,内容是 **gzip 压缩的 JSONL**: + +``` +{"type":"header","format":"happy-data","version":1,"exportedAt":"…","categories":[…],"tableNames":[…],"exporter":{…}} +{"t":"suppliers","r":{"name":"宁波腾达","created_at":"…"}} +{"t":"products","r":{"sku":"TD-100","price":88,"__refs":{"supplier_id":"宁波腾达"}}} +… +{"type":"footer","tables":{"products":{"rows":1576,"sha256":"…"}},"rows":3854} +``` + +- **不用 zip、不直接拷 .db**:gzip + JSONL 只用 Node 内置能力,逐行流式读写, + 未来合同原件变成几十 MB 也不会整包读进内存; +- **每张表一个 sha256 + 行数**:导入前先校验,整包任一表对不上就整体拒绝 —— + 半个包导进去比导不进去更难收拾; +- `__refs` 是父记录的**自然键快照**(如订单里的客户公司名)。只导订单时, + 目标库靠它按公司名找到客户,或补一个只有公司名的客户壳。 + +当前这套真实数据(1576 个产品、823 条合同、272 个供应商…)导出后约 **1.22 MB**、3854 行。 + +## 导入规则 + +1. **归属一律给导入人。** `owner_id` / `created_by` / `assigned_to` / `reviewed_by` 全部改写成 + 执行导入的账号,`org_id` 改写成该账号所属组织。同事之间快速同步、或职责交接时, + 导完由管理员在客户页重新分配即可。 +2. **冲突跳过,绝不覆盖。** 线上已有同一条记录就跳过,并写进报告: + 产品按 `sku`、供应商按 `name`、订单按 `order_no`、客户按 `company`、报价单按 `quote_no`… + (完整对照见 `catalog.js` 的 `key`)。 +3. **流水表按整行内容比对。** 价格历史这类没有自然业务键的表,用「所有列都相同」 + 判断重复 —— 曾经用 `(sku, changed_at)` 当键,实测把 643 行里的 186 行当成重复静默丢掉了 + (一次批量改价会在同一毫秒写多条)。同一个包里逐列完全相同的两行也会都保留。 +4. **父记录可补壳。** 只导订单而没导客户时,按 `__refs` 里的公司名补一个客户, + 报告里单列「自动补建」,导完提示补全联系人、地址等信息。 +5. **一个事务 + 导入前备份。** 写入在一个事务里完成,异常整体回滚; + 导入前用 `VACUUM INTO` 把线上库备份到 `DATA_DIR/backups/pre-import-<时间>-<随机>.db`。 +6. **预览等于真跑一遍。** 预览把库 `VACUUM INTO` 成副本,在副本上执行同一套导入代码, + 跑完删掉副本 —— 所以预览报告里的「新增 / 跳过 / 失败」与真导结果同源, + 不存在「预览说能导、真导却失败」的两套逻辑。 + +## 操作步骤:本地 → 云端 + +1. **服务器先升级到含本功能的版本**(见下节),重启后管理员登录。 +2. **本地**(有真实数据的那台):侧栏 · 系统管理 · 数据迁移 → 勾选类别 + (全选 = 全部业务数据,也可以只勾产品 → 只导 SKU)→ 导出,得到一个 `.hdata.gz`。 +3. **云端**:同一页面上传该文件 → 等预览报告 → 检查「将新增 / 跳过」→ 确认导入。 +4. 核对:工作台统计里的产品 / 供应商 / 客户 / 订单数量应与本地一致; + 报告里的「自动补建」条目需要人工补全。 + +本次迁移的预期结果(本地 2026-10-23 的库): + +``` +包内 3854 行 → 新增 3854 / 跳过 0 / 失败 0 +products 1576 · contracts 823 · price_history 643 · product_groups 516 · suppliers 272 +``` + +## 服务器部署 + +```bash +cd /opt/1panel/www/sites/erp.foohere.de/index +git pull +cd backend && npm install --omit=dev --ignore-scripts +cd ../frontend && npm install && npm run build +systemctl restart erp +sleep 3 && curl -s http://127.0.0.1:4300/api/health +``` + +部署后**现有账号与设置保持不变**(这正是与整库替换的区别);新增的权限点 `data.transfer` +会由 `db.js` 的 `syncNewPermissions()` 自动补进超级管理员组。 + +反向也成立:云端 → 本地同样走这一页,包是同一个格式。 + +## 接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/data/catalog` | 类别与各表行数(界面用) | +| GET | `/api/data/export?categories=catalog,orders` | 导出,`all` 或省略 = 全部 | +| POST | `/api/data/import/preview` | multipart `file`,上传并出预览报告,不写库 | +| POST | `/api/data/import/replan` | `{ id, categories }`,换类别重算预览,不重传 | +| POST | `/api/data/import/apply` | `{ id, categories }`,真导(带备份) | +| DELETE | `/api/data/import/:id` | 取消,丢弃临时包 | + +全部要登录 + `data.transfer` 权限;导出与导入都会写 `audit_log`。 + +## 限制 + +- 单包上限 **100 MB / 200 万行**,超过请按类别分开导; +- 上传的包保存在 `DATA_DIR/transfers/`,**24 小时后过期**(每次上传会顺带清理旧包); +- 导入备份 `DATA_DIR/backups/` **不会自动清理**,请自行定期删除; +- 导入是同步执行的(几千行级别毫秒到秒级),包特别大时会占住事件循环。 + +## 维护:新增业务表时 + +在 `backend/lib/dataTransfer/catalog.js` 的 `TABLES` 里**按依赖顺序**加一条: + +```js +{ table: 'my_table', label: '中文名', category: 'catalog', key: ['业务唯一键'] } +``` + +- `key`:判重列;没有自然键的流水表改用 `contentKey: true`; +- 父记录允许补壳的表加 `stub: true`(目前只有 customers / suppliers / product_groups); +- 依赖顺序错了会撞外键。`backend/test/data-transfer.test.js` 会校验顺序与系统表是否被误导出。 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index da733f9..52365a3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -50,6 +50,7 @@ const ProfilePage = React.lazy(() => import('./pages/ProfilePage')) const UserManagement = React.lazy(() => import('./pages/UserManagement')) const PermissionGroups = React.lazy(() => import('./pages/PermissionGroups')) const SystemLogs = React.lazy(() => import('./pages/SystemLogs')) +const DataTransfer = React.lazy(() => import('./pages/DataTransfer')) /* -------------------------------------------------------------- 启动与兜底 */ @@ -317,6 +318,7 @@ function Shell({ theme, onThemeChange }) { } /> )} /> )} /> + )} /> )} /> } /> diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index f6a1b56..e46702f 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, Suspense } from 'react' import axios from 'axios' -import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons' +import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons' import { useAuth, hasPermission } from '../auth' import { useBranding } from '../branding' @@ -10,11 +10,12 @@ const AIConfigSettings = React.lazy(() => import('./AIConfigSettings')) const OrgBrandingSettings = React.lazy(() => import('./OrgBrandingSettings')) /* 设置分类。带 permission 的项只对有该权限点的人显示 —— - 智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 */ + 智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 + 数据导入导出已从设置里移出,做成独立页面「数据迁移」(侧栏 · 系统管理), + 因为那里要放类别勾选和逐表明细,塞进这个 1000px 的模态框里看不清。 */ const CATEGORIES = [ { key: 'appearance', label: '外观与主题', icon: TuneIcon }, { key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' }, - { key: 'import-export', label: '数据交换', icon: ExchangeIcon }, { key: 'ai-config', label: '智能配置', icon: SparkIcon, permission: 'ai.manage' }, { key: 'system-update', label: '系统与更新', icon: RefreshIcon } ] @@ -89,7 +90,6 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu

正在载入…

}> {current === 'appearance' && } {current === 'organization' && } - {current === 'import-export' && } {current === 'ai-config' && } {current === 'system-update' && }
@@ -128,17 +128,7 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti ) } -function ImportExportSettings() { - return ( -
-

导入 / 导出

-

管理业务数据的导入、导出与本地备份。

-

导出数据

-

导入数据

支持 CSV 和 Excel 格式文件

-

备份与恢复

建议定期备份数据库

-
- ) -} +/* 数据导入导出已移出设置:见 pages/DataTransfer.jsx(侧栏 · 系统管理 · 数据迁移) */ /* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */ diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 2d32f0a..6cb8b99 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,6 +5,7 @@ import './styles/industrial.css' import './styles/index.css' import './styles/luxury.css' import './styles/stage1.css' +import './styles/data-transfer.css' ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/frontend/src/navigation.js b/frontend/src/navigation.js index b956e4a..ec9182b 100644 --- a/frontend/src/navigation.js +++ b/frontend/src/navigation.js @@ -11,7 +11,8 @@ import { CleanupIcon, GlobeIcon, ShieldIcon, - ActivityIcon + ActivityIcon, + ExchangeIcon } from './components/Icons' /* 工作台独立置顶,不属于任何分组 */ @@ -64,6 +65,8 @@ export const NAV_GROUPS = [ items: [ { to: '/users', label: '用户管理', icon: UsersIcon, permission: 'user.manage' }, { to: '/permission-groups', label: '权限组管理', icon: ShieldIcon, permission: 'perm.manage' }, + /* 业务数据的导入导出(不含账号/权限/设置)。整库业务数据能被一页带走,故只给超管组。 */ + { to: '/data-transfer', label: '数据迁移', icon: ExchangeIcon, permission: 'data.transfer' }, /* 审计轨迹。Wayne 口径:只有系统管理员能看,故认 audit.view(该点只授予超级管理员组)。 */ { to: '/logs', label: '系统日志', icon: ActivityIcon, permission: 'audit.view' } ] @@ -96,5 +99,6 @@ export const ROUTE_PERMISSIONS = { '/contract-cleanup': 'cleanup.view', '/users': 'user.manage', '/permission-groups': 'perm.manage', + '/data-transfer': 'data.transfer', '/logs': 'audit.view' } diff --git a/frontend/src/pages/DataTransfer.jsx b/frontend/src/pages/DataTransfer.jsx new file mode 100644 index 0000000..358f259 --- /dev/null +++ b/frontend/src/pages/DataTransfer.jsx @@ -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 ( +
+
+
+

数据迁移

+

把业务数据搬到另一个部署,或从备份包把数据导回来。按类别选择,冲突只跳过不覆盖。

+
+
+ +
+
+ +
+
数据边界
+
+
+

会随包迁移

+

供应商与产品、客户与联系人、询盘报价、订单与杂费、跟进时间线与待办、新客线索。

+
+
+

永不随包迁移

+

用户与密码、权限组、组织信息、系统设置(含大模型密钥)、登录会话、系统日志。目标部署继续用自己的那套。

+
+
+

导入的记录一律归到执行导入的账号名下,导完可在客户/订单页重新分配。

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* ---------------------------------------------------------- 导出 */} +
+
导出
+

+ 勾选要带走的数据类别。全选就是「全部业务数据」;只勾订单也能单独带走,不会连带客户档案。 +

+ +
+ {categories.map(category => { + const checked = exportPicks.includes(category.key) + return ( + + ) + })} + {!categories.length && !loading &&

没有可导出的类别。

} +
+ +
+ + + +
+

+ 共 {num(pickedRows)} 行;包内每张表带 sha256,导入前会先校验完整性。 +

+
+ + {/* ---------------------------------------------------------- 导入 */} +
+
导入
+ + {(phase === 'idle' || phase === 'checking') && ( + <> +

把另一个部署导出的 .hdata.gz 拖进来。导入前会先出一份预览报告,确认后才写入。

+
{ event.preventDefault(); if (phase === 'idle') setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={event => { if (phase === 'idle') onDrop(event) }} + > + {phase === 'checking' ? ( + <> + +

正在上传并校验数据包…

+
+ +
+

{percent}%{percent >= 100 ? ' · 正在逐表核对校验值' : ''}

+ + ) : ( + <> + +

把数据包拖到这里

+

或者

+ +

单包上限 {fmtSize(catalog?.limits?.maxBytes || 100 * 1024 * 1024)}

+ + )} +
+ + )} + + {(phase === 'ready' || phase === 'applying') && plan && ( + <> +
+ {preview.file?.name || file?.name} + + {fmtSize(preview.file?.size)} · 导出于 {fmtTime(preview.file?.exportedAt)} + {preview.file?.exporter?.display_name ? ` · 来自 ${preview.file.exporter.display_name}` : ''} + +
+ +
+ 导入类别 + {availableKeys.map(key => { + const category = categoryByKey.get(key) + const on = importPicks.includes(key) + return ( + + ) + })} + {replanning && } +
+ +
+ + + + + + + + + + + + {plan.tables.map(item => ( + + + + + + + + ))} + + + + + + + + + + +
数据表包内将新增跳过失败
{item.label}{num(item.rows)}{num(item.insert)}{num(item.skip)}{num(item.failed)}
合计{num(plan.totals.rows)}{num(plan.totals.insert)}{num(plan.totals.skip)}{num(plan.totals.failed)}
+
+ + {plan.stubbed?.length > 0 && ( +

+ 包里没有父记录,将自动补建:{plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')} + (只有名称,导完请补全信息)。 +

+ )} + + {plan.tables.some(item => item.warnings?.length) && ( +
+ 预览告警({plan.tables.reduce((sum, item) => sum + (item.warnings?.length || 0), 0)} 条) + {plan.tables.filter(item => item.warnings?.length).map(item => ( +
+ {item.label} +
    {item.warnings.map((text, index) =>
  • {text}
  • )}
+
+ ))} +
+ )} + +

导入前会自动把当前线上库备份成一份 .db 文件,失败可整体回滚。

+ +
+ + +
+ + )} + + {phase === 'done' && result && ( + <> +
+ + + 导入完成:新增 {num(result.plan?.totals?.insert)} 行,跳过 {num(result.plan?.totals?.skip)} 行 + {result.plan?.totals?.failed ? `,失败 ${num(result.plan.totals.failed)} 行` : ''}。 + +
+

导入前的备份:{result.backup}

+ {result.plan?.stubbed?.length > 0 && ( +

+ 自动补建:{result.plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')} —— 这些只有名称,请补全联系人、地址等信息。 +

+ )} + {result.plan?.tables?.some(item => item.failed) && ( +
+ 未导入的行({result.plan.tables.reduce((sum, item) => sum + (item.failed || 0), 0)} 行) + {result.plan.tables.filter(item => item.failed).map(item => ( +
+ {item.label} +
    {item.warnings.map((text, index) =>
  • {text}
  • )}
+
+ ))} +
+ )} +
+ +
+ + )} +
+
+
+ ) +} + +export default DataTransfer diff --git a/frontend/src/styles/data-transfer.css b/frontend/src/styles/data-transfer.css new file mode 100644 index 0000000..648297d --- /dev/null +++ b/frontend/src/styles/data-transfer.css @@ -0,0 +1,259 @@ +/* --------------------------------------------------------------------------- + 数据迁移页(/data-transfer) + 只做这一页需要的样式,配色全部沿用主题变量,昼夜两套主题自动跟随。 +--------------------------------------------------------------------------- */ + +.transfer-page .settings-description { + margin: 0 0 14px; + color: var(--text-secondary); + font-size: 13.5px; + line-height: 1.7; +} + +.transfer-boundary { + margin-bottom: 18px; +} + +.transfer-boundary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.transfer-boundary-grid h4 { + margin: 0 0 6px; + color: var(--text-primary); + font-size: 13.5px; +} + +.transfer-boundary-grid p { + margin: 0; + color: var(--text-tertiary); + font-size: 13px; + line-height: 1.7; +} + +.transfer-error { + margin-bottom: 16px; +} + +.transfer-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + align-items: start; +} + +.transfer-card { + min-height: 260px; +} + +/* ---------------------------------------------------------------- 导出 */ + +.transfer-picks { + display: flex; + flex-direction: column; + gap: 8px; +} + +.transfer-pick { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 11px 13px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--surface-deep); + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.transfer-pick:hover { + border-color: var(--primary-border); +} + +.transfer-pick.is-on { + border-color: var(--primary-border); + background: var(--primary-bg); +} + +.transfer-pick input { + margin-top: 3px; + accent-color: var(--primary-light); +} + +.transfer-pick-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.transfer-pick-body strong { + color: var(--text-primary); + font-size: 13.5px; +} + +.transfer-pick-body small { + color: var(--text-tertiary); + font-size: 12.5px; + line-height: 1.6; +} + +.transfer-pick em { + flex: 0 0 auto; + color: var(--text-tertiary); + font: 12px var(--font-mono); + font-style: normal; + white-space: nowrap; +} + +.transfer-pick.is-on em { + color: var(--primary-light); +} + +/* ---------------------------------------------------------------- 导入 */ + +.transfer-drop { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 26px 20px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--surface-deep); + color: var(--text-tertiary); + text-align: center; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.transfer-drop.is-drag { + border-color: var(--primary-border); + background: var(--primary-bg); + color: var(--primary-light); +} + +.transfer-drop p { + margin: 0; + font-size: 13.5px; +} + +.transfer-drop strong { + color: var(--text-primary); + font-size: 14px; +} + +.transfer-drop .file-label { + position: relative; + overflow: hidden; +} + +.transfer-drop .file-label input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.transfer-progress { + width: min(280px, 100%); + height: 4px; + border-radius: 999px; + background: var(--border-light); + overflow: hidden; +} + +.transfer-progress i { + display: block; + height: 100%; + background: var(--primary-light); + transition: width 160ms linear; +} + +.transfer-file { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; + padding: 11px 13px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--surface-deep); +} + +.transfer-file strong { + color: var(--text-primary); + font-size: 13.5px; + word-break: break-all; +} + +.transfer-file small { + color: var(--text-tertiary); + font-size: 12.5px; +} + +.transfer-cats { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.transfer-report { + max-height: 320px; +} + +.transfer-report tfoot td { + border-top: 1px solid var(--border-strong); + background: var(--surface-raised); +} + +.transfer-report .transfer-failed { + color: var(--danger); + font-weight: 700; +} + +.transfer-stub-note { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.transfer-details { + margin-top: 10px; + padding: 10px 13px; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--surface-deep); + color: var(--text-tertiary); + font-size: 13px; +} + +.transfer-details summary { + cursor: pointer; + color: var(--text-secondary); +} + +.transfer-details ul { + margin: 6px 0 0; + padding-left: 18px; + line-height: 1.7; +} + +.transfer-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 16px; +} + +@media (max-width: 1080px) { + .transfer-grid, + .transfer-boundary-grid { + grid-template-columns: minmax(0, 1fr); + } +}