chore(release): 1.2.0 —— 数据迁移功能发版
- 版本号 1.1.1 → 1.2.0(root / backend / frontend 与两个 lockfile): 部署后 /api/health 与「系统与更新」能直接分辨线上跑的是哪份代码,否则换库前后都显示 1.1.1。 - API 测试补齐:只导订单的包内容、/api/data/catalog、换类别预览(replan)、取消上传后不可再导。 - 文档补排查表:413、版本确认、权限位缺失、SQLITE_BUSY、包过期、整体回滚。 - 已验证 data.transfer 只补给超级管理员组(admin),董事长/总经理/销售经理都拿不到。
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-management-backend",
|
"name": "trade-management-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trade-management-backend",
|
"name": "trade-management-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-management-backend",
|
"name": "trade-management-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "外贸供应链管理系统 - 后端",
|
"description": "外贸供应链管理系统 - 后端",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const crypto = require('node:crypto')
|
|||||||
const { spawn } = require('node:child_process')
|
const { spawn } = require('node:child_process')
|
||||||
|
|
||||||
const BACKEND = path.join(__dirname, '..')
|
const BACKEND = path.join(__dirname, '..')
|
||||||
|
const { readBundle } = require('../lib/dataTransfer/bundle')
|
||||||
|
|
||||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-api-transfer-'))
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-api-transfer-'))
|
||||||
const sourceDir = path.join(tmpRoot, 'source')
|
const sourceDir = path.join(tmpRoot, 'source')
|
||||||
const targetDir = path.join(tmpRoot, 'target')
|
const targetDir = path.join(tmpRoot, 'target')
|
||||||
@@ -111,8 +113,64 @@ test('数据迁移接口端到端:导出 → 预览 → 导入 → 去重 →
|
|||||||
assert.ok(bytes.length > 200, '导出的包不应该是空的')
|
assert.ok(bytes.length > 200, '导出的包不应该是空的')
|
||||||
assert.equal(bytes[0], 0x1f, '导出内容应是 gzip')
|
assert.equal(bytes[0], 0x1f, '导出内容应是 gzip')
|
||||||
|
|
||||||
/* ---------------- 预览(不写库) ---------------- */
|
/* ---------------- 只导订单:包内不应出现产品 ---------------- */
|
||||||
|
const ordersResponse = await fetch(`http://127.0.0.1:${PORT_A}/api/data/export?categories=orders`, {
|
||||||
|
headers: { cookie: cookieA }
|
||||||
|
})
|
||||||
|
assert.equal(ordersResponse.status, 200)
|
||||||
|
const ordersFile = path.join(tmpRoot, 'orders.hdata.gz')
|
||||||
|
fs.writeFileSync(ordersFile, Buffer.from(await ordersResponse.arrayBuffer()))
|
||||||
|
const ordersBundle = await readBundle(ordersFile)
|
||||||
|
assert.deepEqual([...ordersBundle.rows.keys()].sort(), ['order_items', 'orders'])
|
||||||
|
|
||||||
|
/* ---------------- 目录接口 ---------------- */
|
||||||
|
const catalog = await (await fetch(`http://127.0.0.1:${PORT_A}/api/data/catalog`, { headers: { cookie: cookieA } })).json()
|
||||||
|
assert.equal(catalog.categories.length, 5)
|
||||||
|
assert.ok(catalog.categories.find(item => item.key === 'catalog').tables.length >= 10)
|
||||||
|
assert.ok(catalog.categories.find(item => item.key === 'catalog').tables.some(item => item.rows > 0))
|
||||||
|
|
||||||
const cookieB = await login(PORT_B)
|
const cookieB = await login(PORT_B)
|
||||||
|
|
||||||
|
/* ---------------- 换类别预览 / 取消上传 ---------------- */
|
||||||
|
/* 界面上的类别勾选走 replan:包已经在服务器上,换类别不该重传整个文件 */
|
||||||
|
const probe = new FormData()
|
||||||
|
probe.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz')
|
||||||
|
const probePreview = await (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, {
|
||||||
|
method: 'POST', headers: { cookie: cookieB }, body: probe
|
||||||
|
})).json()
|
||||||
|
|
||||||
|
const replan = async (categories) => (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/replan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { cookie: cookieB, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: probePreview.id, categories })
|
||||||
|
})).json()
|
||||||
|
|
||||||
|
const onlyCustomers = await replan(['customers'])
|
||||||
|
assert.ok(onlyCustomers.plan.tables.length > 0)
|
||||||
|
assert.ok(onlyCustomers.plan.tables.every(item => item.category === 'customers'), '选客户类别时不应带出订单表')
|
||||||
|
|
||||||
|
const onlyOrders = await replan(['orders'])
|
||||||
|
assert.ok(onlyOrders.plan.tables.every(item => item.category === 'orders'))
|
||||||
|
assert.equal(onlyOrders.plan.totals.rows, 2, '订单类别只有 order + order_item 两行')
|
||||||
|
assert.deepEqual(onlyOrders.plan.stubbed.map(item => item.table), ['customers'], '只导订单应给客户补壳')
|
||||||
|
assert.equal(onlyOrders.plan.totals.insert, 2)
|
||||||
|
|
||||||
|
const stillEmpty = await (await fetch(`http://127.0.0.1:${PORT_B}/api/dashboard`, { headers: { cookie: cookieB } })).json()
|
||||||
|
assert.equal(stillEmpty.stats.products, 0, '换类别预览同样不能写库')
|
||||||
|
|
||||||
|
/* 取消:临时包应被丢弃,之后再用这个 id 导入应拿到 404 */
|
||||||
|
const cancelled = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/${probePreview.id}`, {
|
||||||
|
method: 'DELETE', headers: { cookie: cookieB }
|
||||||
|
})
|
||||||
|
assert.equal(cancelled.status, 200)
|
||||||
|
const afterCancel = 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: probePreview.id, categories: ['orders'] })
|
||||||
|
})
|
||||||
|
assert.equal(afterCancel.status, 404, '已取消的数据包不能再导入')
|
||||||
|
|
||||||
|
/* ---------------- 预览(不写库) ---------------- */
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz')
|
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`, {
|
const previewResponse = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, {
|
||||||
|
|||||||
+16
-2
@@ -86,11 +86,14 @@ git pull
|
|||||||
cd backend && npm install --omit=dev --ignore-scripts
|
cd backend && npm install --omit=dev --ignore-scripts
|
||||||
cd ../frontend && npm install && npm run build
|
cd ../frontend && npm install && npm run build
|
||||||
systemctl restart erp
|
systemctl restart erp
|
||||||
sleep 3 && curl -s http://127.0.0.1:4300/api/health
|
sleep 3 && curl -s http://127.0.0.1:4300/api/health # 应返回 "version":"1.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
版本号从 `1.1.1` 升到 `1.2.0` 就是为了这一步:/api/health 与「设置 → 系统与更新」都能一眼看出线上跑的是哪份代码。
|
||||||
|
|
||||||
部署后**现有账号与设置保持不变**(这正是与整库替换的区别);新增的权限点 `data.transfer`
|
部署后**现有账号与设置保持不变**(这正是与整库替换的区别);新增的权限点 `data.transfer`
|
||||||
会由 `db.js` 的 `syncNewPermissions()` 自动补进超级管理员组。
|
会由 `db.js` 的 `syncNewPermissions()` 自动补进超级管理员组 —— 已实测:只补给「超级管理员组 / admin」,
|
||||||
|
董事长、总经理、销售经理都拿不到。
|
||||||
|
|
||||||
反向也成立:云端 → 本地同样走这一页,包是同一个格式。
|
反向也成立:云端 → 本地同样走这一页,包是同一个格式。
|
||||||
|
|
||||||
@@ -131,6 +134,17 @@ cd /opt/1panel/www/sites/erp.foohere.de/index/backend
|
|||||||
|
|
||||||
全部要登录 + `data.transfer` 权限;导出与导入都会写 `audit_log`。
|
全部要登录 + `data.transfer` 权限;导出与导入都会写 `audit_log`。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
| 现象 | 原因与处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 上传后 413 / 上传被截断 | nginx(或 CDN)的 `client_max_body_size` 小于包体积。加 `client_max_body_size 100m;` 后 reload;急的话改走命令行通道 |
|
||||||
|
| 接口 401、页面打不开 | 新代码没部署:`curl -s http://127.0.0.1:4300/api/health` 应返回 `1.2.0` |
|
||||||
|
| 侧栏没有「数据迁移」 | 权限点只给超管组。用 admin 登录;或在权限组管理里给对应组勾上 `data.transfer` |
|
||||||
|
| 导入报 `SQLITE_BUSY` | 有别的长事务(例如大合同解析)占着写锁。等几秒重试,或先 `systemctl stop erp` 再用命令行导入 |
|
||||||
|
| 「数据包已过期」 | 上传的包只保留 24 小时,重新上传即可 |
|
||||||
|
| 想整体回滚 | `systemctl stop erp` 后用 `backend/data/backups/pre-import-*.db` 覆盖 `trade.db`,并删掉 `trade.db-wal` / `trade.db-shm`,再启动 |
|
||||||
|
|
||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 单包上限 **100 MB / 200 万行**,超过请按类别分开导;
|
- 单包上限 **100 MB / 200 万行**,超过请按类别分开导;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-management-frontend",
|
"name": "trade-management-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trade-management-frontend",
|
"name": "trade-management-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-management-frontend",
|
"name": "trade-management-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "外贸供应链管理系统 - 前端",
|
"description": "外贸供应链管理系统 - 前端",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "happy-export",
|
"name": "happy-export",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
|
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Reference in New Issue
Block a user