Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
241fb229c6 | ||
|
|
7281c41434 | ||
|
|
4f88a814d5 | ||
|
|
ee7550c890 | ||
|
|
5acfbcea7d | ||
|
|
1e7c3fdcc1 | ||
|
|
1a759ef37d | ||
|
|
1ffcc1ea07 | ||
|
|
dc607e4ea3 |
+17
@@ -1,5 +1,9 @@
|
||||
# ---------- 依赖 ----------
|
||||
node_modules/
|
||||
backend/node_modules.update-new/
|
||||
backend/node_modules.update-old/
|
||||
frontend/dist.update-new/
|
||||
frontend/dist.update-old/
|
||||
.pnpm-store/
|
||||
|
||||
# ---------- 构建产物 ----------
|
||||
@@ -37,3 +41,16 @@ tmp/
|
||||
# ---------- 设计参考素材(仅本地参考,一律不入库) ----------
|
||||
icons8-*
|
||||
design-refs/
|
||||
|
||||
# ---------- 数据迁移导出文件(v1.2.0 的数据迁移功能产物) ----------
|
||||
# transfer-out/ 里是完整的业务数据导出(供应商、采购价、合同记录),
|
||||
# 跟 backend/data/ 同级别的机密。仓库是公开的,这个绝不能入库。
|
||||
# .hdata / .hdata.gz 不限目录一律忽略 —— 导出路径可以在界面里改,
|
||||
# 只盯 transfer-out/ 会漏。
|
||||
transfer-out/
|
||||
*.hdata
|
||||
*.hdata.gz
|
||||
|
||||
# ---------- 交接文档 ----------
|
||||
# 列举了线上系统尚未修复的安全问题,公开仓库里等于给人一份路线图
|
||||
happy-export-交接-plan.md
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
### 系统管理
|
||||
- **用户管理** - 账号、角色与启停用
|
||||
- **权限组管理** - 角色 → 权限组 → 权限点三级授权
|
||||
- **数据迁移** - 业务数据按类别导出/导入(产品、客户、订单…),账号与系统设置不随包迁移
|
||||
- **系统日志** - 登录、改密与数据变更审计
|
||||
|
||||
## 技术栈
|
||||
@@ -47,10 +48,12 @@
|
||||
|
||||
### 安装依赖
|
||||
|
||||
需要 Node.js 22 或更高版本。
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
npm install
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
@@ -119,6 +122,10 @@ SQLite 单文件存储:
|
||||
- 支持备份和迁移
|
||||
- 数据目录与上传文件已在 `.gitignore` 中排除,不会入库
|
||||
|
||||
> **换机器 / 上云请不要直接覆盖 `trade.db`。** 整个库里有账号、权限、组织、设置(含大模型密钥)、
|
||||
> 登录会话与审计日志,直接替换等于换掉一整套后台。请用「系统管理 · 数据迁移」按类别
|
||||
> 导出业务数据包再导入,系统数据保持目标部署自己那份。详见 [业务数据导入导出](docs/data-transfer.md)。
|
||||
|
||||
> **已知限制:数据库当前未加密。** 驱动选用的是 `better-sqlite3-multiple-ciphers`
|
||||
> (本身具备 SQLCipher 能力),但代码尚未设置加密密钥,`trade.db` 以明文存放。
|
||||
> 部署到共享主机或不可信环境前,请自行启用磁盘加密。
|
||||
@@ -129,12 +136,20 @@ SQLite 单文件存储:
|
||||
| --- | --- | --- |
|
||||
| `DATA_DIR` | `backend/data` | 数据库与汇率缓存所在目录 |
|
||||
| `PORT` | `4300` | 后端监听端口 |
|
||||
| `SECURE_COOKIE` | `0` | HTTPS 部署设为 `1`,为会话 Cookie 添加 `Secure`;本地 HTTP 开发保持默认 |
|
||||
| `CONTRACT_RETENTION_DAYS` | `7` | 合同导入原文件的保留天数 |
|
||||
| `HAPPY_UPDATE_REPO` | `Drsakura/happy-export` | 在线更新检查所用的 GitHub 仓库 |
|
||||
| `HAPPY_UPDATE_REPO` | `Drsakura/happy-export` | 网页更新使用的 GitHub 公共仓库 |
|
||||
| `HAPPY_UPDATE_SERVICE` | `erp` | 网页更新重启的 systemd 服务名,不含 `.service` 后缀 |
|
||||
|
||||
> 注意:后端**没有引入 dotenv**,不会自动读取 `.env` 文件。
|
||||
> 请通过系统环境变量、启动脚本或容器编排来设置上述变量。
|
||||
|
||||
## 网页更新与账号
|
||||
|
||||
管理员可在「设置 → 系统与更新」输入当前密码安装新版本,查看更新进度和日志。支持 Linux systemd 部署,包含构建、数据库备份、服务重启及失败后的应用恢复;首次需要部署包含此功能的版本。详见 [网页更新配置与恢复说明](docs/web-updates.md)。
|
||||
|
||||
所有用户可在「个人资料 → 修改用户名」验证当前密码后修改自己的登录名;业务数据归属不变,其它设备需重新登录。
|
||||
|
||||
## 开发计划
|
||||
|
||||
Phase 1 至 Phase 3 已完成;Phase 4、Phase 5 部分完成,未完成项保留为待办。
|
||||
|
||||
+19
-4
@@ -256,7 +256,22 @@ function ensureColumns() {
|
||||
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
|
||||
['created_by', 'INTEGER'],
|
||||
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
|
||||
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
|
||||
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT'],
|
||||
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。
|
||||
全部选填 —— 这些是「了解到多少写多少」的东西,不是业务必填项。
|
||||
|
||||
employee_count / annual_revenue 用 TEXT 而不是数字:实际拿到的几乎都是
|
||||
「50-200 人」「€5-10M」这种区间带单位的表述,硬塞进 INTEGER 要么丢精度
|
||||
要么存不进去,还得再加个单位列。这里的用途是「给人看」,不参与计算。 */
|
||||
['founded_year', 'INTEGER'],
|
||||
['employee_count', 'TEXT'],
|
||||
['annual_revenue', 'TEXT'],
|
||||
['profile', 'TEXT'],
|
||||
/* 背调留痕:什么时候查的、信息来自哪些页面。
|
||||
没有来源的「AI 说这家公司有 200 人」是不能用来做决策的,
|
||||
所以来源 URL 必须跟结论一起存,界面上要能点开核对。 */
|
||||
['research_at', 'TEXT'],
|
||||
['research_sources', 'TEXT']
|
||||
]
|
||||
};
|
||||
for (const [table, columns] of Object.entries(columnMigrations)) {
|
||||
@@ -276,12 +291,12 @@ function ensureColumns() {
|
||||
|
||||
ensureColumns();
|
||||
|
||||
// 创建默认管理员账号(如果不存在)
|
||||
// 只为空库创建初始管理员;改名后重启不能重新生成默认账号。
|
||||
function createDefaultAdmin() {
|
||||
const crypto = require('crypto');
|
||||
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin');
|
||||
const existingUser = db.prepare('SELECT id FROM users LIMIT 1').get();
|
||||
|
||||
if (!adminExists) {
|
||||
if (!existingUser) {
|
||||
// 生成默认密码哈希:admin123
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync('admin123', salt, 64).toString('hex');
|
||||
|
||||
@@ -207,6 +207,16 @@ CREATE TABLE IF NOT EXISTS customers (
|
||||
pool_status TEXT DEFAULT 'owned',
|
||||
pool_at TEXT,
|
||||
pool_note TEXT,
|
||||
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。全部选填。
|
||||
employee_count / annual_revenue 用 TEXT 而非数字:实际拿到的几乎都是
|
||||
「50-200 人」「€5-10M」这种带单位的区间表述,用途是给人看、不参与计算。 */
|
||||
founded_year INTEGER,
|
||||
employee_count TEXT,
|
||||
annual_revenue TEXT,
|
||||
profile TEXT,
|
||||
/* 背调留痕:没有来源的结论不能拿来做决策,来源 URL 必须跟结论一起存 */
|
||||
research_at TEXT,
|
||||
research_sources TEXT,
|
||||
source TEXT,
|
||||
tags TEXT,
|
||||
notes TEXT,
|
||||
|
||||
+21
-2
@@ -41,6 +41,22 @@ function verifyPassword(plain, stored) {
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/* 密码复核与登录共享失败次数和锁定,避免已登录会话无限猜密码。 */
|
||||
function verifyCurrentPassword(req, password) {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
const reject = (message, statusCode) => { throw Object.assign(new Error(message), { statusCode }); };
|
||||
if (user.locked_until && Date.parse(user.locked_until) > Date.now()) reject('账号已锁定,请稍后再试', 429);
|
||||
if (!password) reject('请输入当前密码', 400);
|
||||
if (!verifyPassword(String(password), user.password_hash)) {
|
||||
const failed = Number(user.failed_count || 0) + 1;
|
||||
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?')
|
||||
.run(failed, failed >= MAX_FAILED ? new Date(Date.now() + LOCK_MS).toISOString() : null, user.id);
|
||||
audit({ username: user.username, action: 'password_verify_failed', detail: '敏感操作密码验证失败', req, ok: 0 });
|
||||
reject(failed >= MAX_FAILED ? '密码连续错误,账号已锁定 15 分钟' : '当前密码不正确', failed >= MAX_FAILED ? 429 : 400);
|
||||
}
|
||||
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL WHERE id = ?').run(user.id);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- cookie */
|
||||
|
||||
function parseCookies(header) {
|
||||
@@ -62,12 +78,13 @@ function parseCookies(header) {
|
||||
|
||||
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
|
||||
res.append('Set-Cookie', [
|
||||
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
|
||||
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`,
|
||||
...(process.env.SECURE_COOKIE === '1' ? ['Secure'] : [])
|
||||
].join('; '));
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
setSessionCookie(res, '', 0);
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
@@ -110,6 +127,7 @@ function sessionUser(req) {
|
||||
return null;
|
||||
}
|
||||
if (!row.is_active) return null;
|
||||
if (row.locked_until && Date.parse(row.locked_until) > Date.now()) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -210,6 +228,7 @@ module.exports = {
|
||||
LOCK_MS,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
verifyCurrentPassword,
|
||||
parseCookies,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* 数据包(.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)())
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐行吐出,并在**任何**退出路径上关掉底层流。
|
||||
*
|
||||
* 校验失败时 readBundle 会中途抛错,如果不在这里兜住,文件句柄就跟着异常一起漏掉 ——
|
||||
* 服务器上反复上传坏包会把 fd 耗光(排查起来极难,因为错误信息只有「数据包损坏」)。
|
||||
* 包在外层 for-await 里的生成器,在 break / throw / 正常结束三种情况下都会走到 finally。
|
||||
*/
|
||||
async function* linesOf(reader, input) {
|
||||
try {
|
||||
for await (const line of reader) yield line
|
||||
} finally {
|
||||
try { reader.close() } catch { /* 已经关了 */ }
|
||||
try { input.destroy() } catch { /* 已经释放 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验数据包。
|
||||
*
|
||||
* 校验不做「尽力而为」:行数或 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 linesOf(reader, input)) {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 数据迁移的门面:把「读包、预览、真导、备份」串起来,路由只调这一层。
|
||||
*
|
||||
* 预览为什么要开一个库副本:
|
||||
* 预览必须回答「照这样导,线上会变成什么样」。如果只是把行数点一遍,就答不了
|
||||
* 「哪一行会因为外键或非空约束失败」。所以预览在做一次 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)
|
||||
/* 只删普通文件:目录里还套着 multer 的 uploads/,别把目录本身当过期文件删 */
|
||||
if (!stat.isFile()) continue
|
||||
if (Date.now() - stat.mtimeMs > maxAgeMs) { fs.unlinkSync(file); removed++ }
|
||||
} catch { /* 单个文件失败不影响其它清理 */ }
|
||||
}
|
||||
} catch { /* 目录还不存在 */ }
|
||||
return removed
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listCatalog,
|
||||
exportToStream,
|
||||
exportFileName,
|
||||
previewImport,
|
||||
applyImport,
|
||||
cleanupUploads
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// 被复制到数据目录后独立运行;仅用内置模块,ERP 重启或替换依赖均不影响任务。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
async function executeUpdate(config, hooks = {}) {
|
||||
const { root, data, dir, jobDir, service, repo, tag, version, node, port } = config;
|
||||
const statusFile = path.join(dir, 'status.json');
|
||||
const logFile = path.join(jobDir, 'update.log');
|
||||
const stage = path.join(jobDir, 'source');
|
||||
const backendModules = path.join(root, 'backend/node_modules');
|
||||
const frontendDist = path.join(root, 'frontend/dist');
|
||||
const newModules = `${backendModules}.update-new`;
|
||||
const oldModules = `${backendModules}.update-old`;
|
||||
const newDist = `${frontendDist}.update-new`;
|
||||
const oldDist = `${frontendDist}.update-old`;
|
||||
let switched = false;
|
||||
let oldCommit;
|
||||
let status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
||||
function progress(state, message, extra = {}) {
|
||||
status = { ...status, state, message, updatedAt: new Date().toISOString(), ...extra };
|
||||
const temporary = `${statusFile}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(status), { mode: 0o600 });
|
||||
fs.renameSync(temporary, statusFile);
|
||||
}
|
||||
function run(command, args, cwd = root, timeout = 10 * 60 * 1000) {
|
||||
if (hooks.run) return hooks.run(command, args, cwd);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd, env: { ...process.env, PATH: `${path.dirname(node)}:${process.env.PATH || ''}` }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
const consume = chunk => {
|
||||
output = (output + chunk.toString()).slice(-12000);
|
||||
fs.appendFileSync(logFile, chunk);
|
||||
};
|
||||
child.stdout.on('data', consume);
|
||||
child.stderr.on('data', consume);
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeout);
|
||||
child.on('error', error => { clearTimeout(timer); reject(error); });
|
||||
child.on('close', code => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve(output.trim());
|
||||
else reject(new Error(`${command} 执行失败(${code}),请查看更新日志`));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function healthy(expected) {
|
||||
if (hooks.healthy) return hooks.healthy(expected);
|
||||
for (let attempt = 0; attempt < 45; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1500) });
|
||||
const info = await response.json();
|
||||
if (response.ok && info.status === 'ok' && (!expected || info.version === expected)) return;
|
||||
} catch { /* 重启期间暂不可用 */ }
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new Error('重启后健康检查未通过');
|
||||
}
|
||||
async function main() {
|
||||
try {
|
||||
progress('preparing', '正在下载新版本');
|
||||
oldCommit = await run('git', ['rev-parse', 'HEAD']);
|
||||
await run('git', ['fetch', '--no-tags', `https://github.com/${repo}.git`, `refs/tags/${tag}`]);
|
||||
const target = await run('git', ['rev-parse', 'FETCH_HEAD^{commit}']);
|
||||
await run('git', ['worktree', 'add', '--detach', stage, target]);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8'));
|
||||
if (manifest.version !== version) throw new Error('版本标签与应用版本不一致,已取消更新');
|
||||
progress('installing', '正在安装新版本依赖');
|
||||
await run('npm', ['ci', '--omit=dev', '--ignore-scripts'], path.join(stage, 'backend'));
|
||||
await run('npm', ['ci', '--include=dev'], path.join(stage, 'frontend'));
|
||||
progress('building', '正在构建新版本页面');
|
||||
await run('npm', ['run', 'build'], path.join(stage, 'frontend'));
|
||||
await run(node, ['--check', path.join(stage, 'backend/server.js')]);
|
||||
// 在目标文件系统中准备目录,再通过 rename 切换,避免跨盘移动失败。
|
||||
for (const entry of [newModules, newDist, oldModules, oldDist]) {
|
||||
if (fs.existsSync(entry)) throw new Error('发现上次更新残留目录,请管理员检查后清理');
|
||||
}
|
||||
fs.cpSync(path.join(stage, 'backend/node_modules'), newModules, { recursive: true, verbatimSymlinks: true });
|
||||
fs.cpSync(path.join(stage, 'frontend/dist'), newDist, { recursive: true });
|
||||
progress('backing_up', '正在备份数据库');
|
||||
const dbPath = path.join(data, 'trade.db');
|
||||
const backup = path.join(jobDir, 'trade-before-update.db');
|
||||
if (!fs.existsSync(dbPath)) throw new Error('未找到业务数据库,已取消更新');
|
||||
if (hooks.backup) await hooks.backup(backup);
|
||||
else {
|
||||
const Database = require(path.join(backendModules, 'better-sqlite3-multiple-ciphers'));
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try { await db.backup(backup); } finally { db.close(); }
|
||||
}
|
||||
fs.chmodSync(backup, 0o600);
|
||||
// 下载安装期间如果部署代码被人工改动,则保留这些修改并取消更新。
|
||||
if ((await run('git', ['status', '--porcelain', '--untracked-files=normal'])).trim()
|
||||
|| await run('git', ['rev-parse', 'HEAD']) !== oldCommit) throw new Error('部署代码在更新过程中发生变化,已取消更新');
|
||||
progress('deploying', '正在切换应用版本', { previousCommit: oldCommit, targetCommit: target });
|
||||
switched = true;
|
||||
fs.renameSync(backendModules, oldModules);
|
||||
fs.renameSync(newModules, backendModules);
|
||||
if (fs.existsSync(frontendDist)) fs.renameSync(frontendDist, oldDist);
|
||||
fs.renameSync(newDist, frontendDist);
|
||||
await run('git', ['reset', '--hard', target]);
|
||||
progress('restarting', '正在重启服务,请稍候');
|
||||
await run('systemctl', ['restart', `${service}.service`], root, 60000);
|
||||
await healthy(version);
|
||||
switched = false;
|
||||
try {
|
||||
fs.rmSync(oldModules, { recursive: true, force: true });
|
||||
fs.rmSync(oldDist, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
// 服务已验证成功,清理旧目录失败不能触发半套依赖的回退。
|
||||
fs.appendFileSync(logFile, `\n旧版本目录清理失败:${cleanupError.message}\n`);
|
||||
}
|
||||
progress('completed', '更新完成,可刷新页面使用新版本', { finishedAt: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
fs.appendFileSync(logFile, `\n${error.stack}\n`);
|
||||
if (switched) {
|
||||
try {
|
||||
progress('rolling_back', '更新失败,正在恢复上一版应用');
|
||||
await run('systemctl', ['stop', `${service}.service`], root, 60000);
|
||||
await run('git', ['reset', '--hard', oldCommit]);
|
||||
if (fs.existsSync(oldModules)) {
|
||||
fs.rmSync(backendModules, { recursive: true, force: true });
|
||||
fs.renameSync(oldModules, backendModules);
|
||||
}
|
||||
if (fs.existsSync(oldDist)) {
|
||||
fs.rmSync(frontendDist, { recursive: true, force: true });
|
||||
fs.renameSync(oldDist, frontendDist);
|
||||
}
|
||||
await run('systemctl', ['start', `${service}.service`], root, 60000);
|
||||
await healthy();
|
||||
progress('failed', `${error.message};已恢复上一版应用,数据库备份已保留`, { finishedAt: new Date().toISOString() });
|
||||
} catch (rollbackError) {
|
||||
fs.appendFileSync(logFile, `\n${rollbackError.stack}\n`);
|
||||
progress('failed', `${error.message};自动恢复失败,请管理员查看日志和数据库备份`, { finishedAt: new Date().toISOString() });
|
||||
}
|
||||
} else progress('failed', error.message, { finishedAt: new Date().toISOString() });
|
||||
} finally {
|
||||
// 数据库备份与日志长期保留;不自动覆盖线上库,防止回滚丢失期间的写入。
|
||||
try { await run('git', ['worktree', 'remove', '--force', stage]); } catch { /* 保留现场 */ }
|
||||
for (const entry of [newModules, newDist]) {
|
||||
try { fs.rmSync(entry, { recursive: true, force: true }); } catch { /* 保留现场 */ }
|
||||
}
|
||||
fs.rmSync(path.join(dir, 'lock'), { force: true });
|
||||
}
|
||||
}
|
||||
await main();
|
||||
}
|
||||
module.exports = { executeUpdate };
|
||||
if (require.main === module) {
|
||||
const config = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
executeUpdate(config).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* happy 出口通 —— 系统版本检查
|
||||
*
|
||||
* 与 sku-manager 的 updater 不同,这里**只做检查,不做自动替换**:
|
||||
* happy 是整体应用(前端 dist + 后端服务 + 数据库),更新应当走
|
||||
* `git pull` + 重新构建 + 重启服务,而不是像 sku-manager 那样下载 zip
|
||||
* 换版本目录。所以本模块只负责回答一个问题:
|
||||
* 远端仓库的版本号 vs 本机版本号,谁新?
|
||||
* 本模块检查 GitHub Release/tag;网页安装入口见 webUpdate.js,
|
||||
* 独立更新任务见 updateWorker.js(构建、备份、切换与重启)。
|
||||
*
|
||||
* 版本来源,按优先级:
|
||||
* 1. GitHub Releases 的 latest.tag_name —— 推荐用法(打 tag 并发 Release)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** 管理员网页更新入口。安装由独立 systemd 单元执行,重启 ERP 不会杀掉更新任务。 */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const exec = promisify(execFile);
|
||||
const updater = require('./updater');
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'backend/data'));
|
||||
const DIR = path.join(DATA, 'system-update');
|
||||
const STATUS = path.join(DIR, 'status.json');
|
||||
const LOCK = path.join(DIR, 'lock');
|
||||
const SERVICE = process.env.HAPPY_UPDATE_SERVICE || 'erp';
|
||||
|
||||
let lastInspection = 0;
|
||||
async function readStatus() {
|
||||
let status;
|
||||
try { status = JSON.parse(fs.readFileSync(STATUS, 'utf8')); }
|
||||
catch { return { state: 'idle', message: '尚未执行更新' }; }
|
||||
if (process.platform === 'linux' && /^[a-f0-9]{16}$/.test(status.id || '')
|
||||
&& !['idle', 'completed', 'failed'].includes(status.state)
|
||||
&& Date.now() - Date.parse(status.startedAt) > 60000 && Date.now() - lastInspection > 30000) {
|
||||
lastInspection = Date.now();
|
||||
try {
|
||||
const { stdout } = await exec('systemctl', ['show', `happy-update-${status.id}.service`, '--property=ActiveState', '--value'], { timeout: 5000 });
|
||||
if (['inactive', 'failed'].includes(stdout.trim())) {
|
||||
// 再读一次,避免 worker 刚完成时被旧状态覆盖。
|
||||
const latest = JSON.parse(fs.readFileSync(STATUS, 'utf8'));
|
||||
if (latest.id === status.id && !['completed', 'failed'].includes(latest.state)) {
|
||||
status = { ...latest, state: 'failed', message: '更新任务意外中断,请查看日志;数据库备份与恢复目录已保留', finishedAt: new Date().toISOString() };
|
||||
fs.writeFileSync(STATUS, JSON.stringify(status), { mode: 0o600 });
|
||||
fs.rmSync(LOCK, { force: true });
|
||||
} else status = latest;
|
||||
}
|
||||
} catch { /* systemd 暂不可达时保留状态,不能认定任务已终止 */ }
|
||||
}
|
||||
return status;
|
||||
}
|
||||
function capability() {
|
||||
return { supported: process.platform === 'linux', service: SERVICE,
|
||||
reason: process.platform === 'linux' ? '' : '网页安装支持 Linux systemd 部署;当前环境可检查版本。' };
|
||||
}
|
||||
async function start(req) {
|
||||
if (!capability().supported) throw Object.assign(new Error(capability().reason), { statusCode: 400 });
|
||||
if (!/^[a-zA-Z0-9_.@-]+$/.test(SERVICE)) throw new Error('更新服务名配置无效');
|
||||
await exec('systemctl', ['is-active', `${SERVICE}.service`], { timeout: 10000 });
|
||||
const dirty = await exec('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: ROOT, timeout: 10000 });
|
||||
if (dirty.stdout.trim()) throw Object.assign(new Error('部署目录有未提交或未跟踪的文件,请先处理后再更新'), { statusCode: 409 });
|
||||
const repo = updater.normalizeRepo(process.env.HAPPY_UPDATE_REPO || updater.DEFAULT_REPO);
|
||||
if (!repo || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) throw new Error('服务器更新源配置无效');
|
||||
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
||||
try { fs.writeFileSync(LOCK, String(Date.now()), { flag: 'wx', mode: 0o600 }); }
|
||||
catch (error) {
|
||||
if (error.code === 'EEXIST') throw Object.assign(new Error('已有更新任务正在执行'), { statusCode: 409 });
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const result = await updater.checkUpdate(repo);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
if (!result.hasUpdate) throw Object.assign(new Error('当前已是最新版本'), { statusCode: 409 });
|
||||
if (!/^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(result.tag)) throw new Error('版本标签无效');
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const jobDir = path.join(DIR, id);
|
||||
fs.mkdirSync(jobDir, { mode: 0o700 });
|
||||
const worker = path.join(jobDir, 'worker.js');
|
||||
fs.copyFileSync(path.join(__dirname, 'updateWorker.js'), worker);
|
||||
const config = path.join(jobDir, 'config.json');
|
||||
fs.writeFileSync(config, JSON.stringify({ root: ROOT, data: DATA, dir: DIR, jobDir, service: SERVICE,
|
||||
repo, tag: result.tag, version: result.latest, node: process.execPath, port: process.env.PORT || '4300' }), { mode: 0o600 });
|
||||
const status = { id, state: 'queued', message: '更新任务已创建', version: result.latest,
|
||||
startedAt: new Date().toISOString(), username: req.user.username };
|
||||
fs.writeFileSync(STATUS, JSON.stringify(status), { mode: 0o600 });
|
||||
await exec('systemd-run', ['--unit', `happy-update-${id}`, '--collect', '--property=Type=exec',
|
||||
process.execPath, worker, config], { timeout: 15000 });
|
||||
return status;
|
||||
} catch (error) {
|
||||
fs.rmSync(LOCK, { force: true });
|
||||
fs.writeFileSync(STATUS, JSON.stringify({ state: 'failed', message: error.message, finishedAt: new Date().toISOString() }), { mode: 0o600 });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
module.exports = { start, readStatus, capability };
|
||||
Generated
+28
-535
@@ -1,22 +1,19 @@
|
||||
{
|
||||
"name": "trade-management-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trade-management-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.4.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -32,20 +29,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
@@ -58,16 +41,6 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3-multiple-ciphers": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-13.0.3.tgz",
|
||||
@@ -80,19 +53,6 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.8",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||
@@ -117,54 +77,6 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
@@ -214,46 +126,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"engines": [
|
||||
"node >= 0.8"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -290,12 +162,6 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -416,9 +282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
|
||||
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
@@ -440,9 +306,9 @@
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"path-to-regexp": "~0.1.13",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"qs": "~6.16.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -461,19 +327,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -510,21 +363,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -571,19 +409,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -596,16 +421,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -662,13 +477,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -684,58 +492,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -805,43 +561,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -849,22 +568,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
"busboy": "^1.6.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
@@ -885,70 +603,6 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^4",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^10.2.1",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^7.5.3",
|
||||
"simple-update-notifier": "^2.0.0",
|
||||
"supports-color": "^5.5.0",
|
||||
"touch": "^3.1.0",
|
||||
"undefsafe": "^2.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"nodemon": "bin/nodemon.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nodemon"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -997,29 +651,10 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -1027,19 +662,16 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -1076,40 +708,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1136,19 +734,6 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -1272,19 +857,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1302,47 +874,6 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1352,16 +883,6 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/touch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
||||
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1375,19 +896,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -1397,12 +905,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -1432,15 +934,6 @@
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "trade-management-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"description": "外贸供应链管理系统 - 后端",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "node --watch server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
"trade",
|
||||
@@ -18,10 +19,7 @@
|
||||
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.4.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
+31
-4
@@ -121,6 +121,11 @@ router.patch('/me', (req, res) => {
|
||||
values.push(value || null);
|
||||
}
|
||||
|
||||
if (req.body?.role !== undefined || req.body?.org_id !== undefined) {
|
||||
if (!auth.permissionsOfRole(req.user.role).includes('user.manage')) {
|
||||
return res.status(403).json({ error: '角色与组织请联系管理员修改' });
|
||||
}
|
||||
}
|
||||
if (req.body?.role !== undefined) {
|
||||
const role = String(req.body.role || '').trim();
|
||||
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内');
|
||||
@@ -150,6 +155,31 @@ router.patch('/me', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** PATCH /api/me/username —— 账号绑定用户 id,改名不改变业务数据归属。 */
|
||||
router.patch('/me/username', auth.requireAuth, (req, res) => {
|
||||
try {
|
||||
const username = String(req.body?.username || '').trim();
|
||||
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.@-]{2,49}$/.test(username)) {
|
||||
throw badRequest('用户名需为 3–50 位字母、数字或 _ . @ -,以字母、数字或下划线开头');
|
||||
}
|
||||
auth.verifyCurrentPassword(req, req.body?.current_password);
|
||||
if (username === req.user.username) throw badRequest('新用户名与当前用户名相同');
|
||||
const change = db.transaction(() => {
|
||||
if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?').get(username, req.user.id)) {
|
||||
const error = badRequest('该用户名已被使用');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
db.prepare('UPDATE users SET username = ?, updated_at = ? WHERE id = ?')
|
||||
.run(username, new Date().toISOString(), req.user.id);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
|
||||
});
|
||||
change();
|
||||
auth.audit({ username, action: 'username_change', detail: `用户名由 ${req.user.username} 修改为 ${username}`, req });
|
||||
res.json({ user: auth.publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id)), message: '用户名已修改,其它设备需重新登录' });
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
|
||||
router.patch('/me/password', (req, res) => {
|
||||
try {
|
||||
@@ -163,10 +193,7 @@ router.patch('/me/password', (req, res) => {
|
||||
if (next.length < 6) throw badRequest('新密码至少 6 位');
|
||||
if (next !== confirm) throw badRequest('两次输入的新密码不一致');
|
||||
if (next === current) throw badRequest('新密码不能与当前密码相同');
|
||||
if (!auth.verifyPassword(current, req.user.password_hash)) {
|
||||
auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 });
|
||||
throw badRequest('当前密码不正确');
|
||||
}
|
||||
auth.verifyCurrentPassword(req, current);
|
||||
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
|
||||
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 数据迁移接口(业务数据导入 / 导出)。
|
||||
* 挂载点:/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)
|
||||
/* multer 落盘后因体积/字段错误被丢弃的残片留在 uploads/,一并清 */
|
||||
transfer.cleanupUploads(UPLOAD_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
|
||||
@@ -23,7 +23,7 @@ const router = express.Router();
|
||||
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
|
||||
* ------------------------------------------------------------------------- */
|
||||
const CATEGORIES = [
|
||||
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_)/ },
|
||||
{ key: 'auth', label: '认证', match: /^(login|logout|password_|profile_|username_)/ },
|
||||
{ key: 'user', label: '用户', match: /^user_/ },
|
||||
{ key: 'permission', label: '权限', match: /^permission_group_/ },
|
||||
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ },
|
||||
@@ -43,6 +43,9 @@ const ACTION_LABELS = {
|
||||
profile_update: '更新个人资料',
|
||||
password_change: '修改密码',
|
||||
password_change_failed: '修改密码失败',
|
||||
password_verify_failed: '身份密码验证失败',
|
||||
username_change: '修改用户名',
|
||||
system_update: '启动系统更新',
|
||||
user_create: '新建用户',
|
||||
user_update: '更新用户',
|
||||
permission_group_create: '新建权限组',
|
||||
|
||||
@@ -5,8 +5,44 @@
|
||||
const express = require('express');
|
||||
const updater = require('../lib/updater');
|
||||
const branding = require('../lib/branding');
|
||||
const auth = require('../lib/auth');
|
||||
const webUpdate = require('../lib/webUpdate');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const router = express.Router();
|
||||
function adminOnly(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: '仅系统管理员可执行系统更新' });
|
||||
next();
|
||||
}
|
||||
router.get('/update/status', adminOnly, async (req, res) => {
|
||||
const status = await webUpdate.readStatus();
|
||||
let log = '';
|
||||
if (/^[a-f0-9]{16}$/.test(status.id || '')) {
|
||||
const data = path.resolve(process.env.DATA_DIR || path.join(__dirname, '../data'));
|
||||
try {
|
||||
const file = path.join(data, 'system-update', status.id, 'update.log');
|
||||
const fd = fs.openSync(file, 'r');
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size;
|
||||
const buffer = Buffer.alloc(Math.min(size, 12000));
|
||||
fs.readSync(fd, buffer, 0, buffer.length, Math.max(0, size - buffer.length));
|
||||
log = buffer.toString('utf8');
|
||||
} finally { fs.closeSync(fd); }
|
||||
} catch { /* 任务尚未开始写日志 */ }
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({ ...status, log, capability: webUpdate.capability() });
|
||||
});
|
||||
router.post('/update', adminOnly, async (req, res) => {
|
||||
try {
|
||||
auth.verifyCurrentPassword(req, req.body?.current_password);
|
||||
const status = await webUpdate.start(req);
|
||||
auth.audit({ username: req.user.username, action: 'system_update', detail: `网页更新至 ${status.version}`, req });
|
||||
res.status(202).json(status);
|
||||
} catch (error) { res.status(error.statusCode || 500).json({ error: error.message }); }
|
||||
});
|
||||
|
||||
// 简易缓存:GitHub 未认证请求每小时只有 60 次配额,连续点「检查更新」不该把额度刷完。
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
@@ -16,7 +52,7 @@ let cache = { key: '', at: 0, payload: null };
|
||||
* GET /api/system/info
|
||||
* 当前版本与运行环境。轻量、不发外部请求,设置页打开时就能调。
|
||||
*/
|
||||
router.get('/info', (req, res) => {
|
||||
router.get('/info', auth.requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(updater.systemInfo());
|
||||
} catch (error) {
|
||||
@@ -28,9 +64,9 @@ router.get('/info', (req, res) => {
|
||||
* GET /api/system/check-update?repo=owner/repo&force=1
|
||||
* 对比远端仓库版本与本机版本。repo 省略时用默认仓库(Drsakura/happy-export)。
|
||||
*/
|
||||
router.get('/check-update', async (req, res) => {
|
||||
router.get('/check-update', auth.requireAuth, async (req, res) => {
|
||||
try {
|
||||
const repo = updater.normalizeRepo(req.query.repo) || updater.DEFAULT_REPO;
|
||||
const repo = updater.normalizeRepo(process.env.HAPPY_UPDATE_REPO) || updater.DEFAULT_REPO;
|
||||
const force = String(req.query.force || '') === '1';
|
||||
|
||||
if (!force && cache.payload && cache.key === repo && Date.now() - cache.at < CACHE_TTL_MS) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 命令行版数据迁移 —— 网页「数据迁移」的备用通道。
|
||||
*
|
||||
* 什么时候用它:
|
||||
* · 服务器的 nginx / CDN 限制了上传体积,1 MB 以上的包传不上去;
|
||||
* · 想用 scp 直接把包丢到服务器再导,不经过浏览器;
|
||||
* · 定期把业务数据导出来做异地备份(配合 cron)。
|
||||
*
|
||||
* 与网页版共用 backend/lib/dataTransfer 的同一套代码:同样的判重、归属改写、补壳与备份。
|
||||
*
|
||||
* 用法(在 backend/ 目录下执行,Node ≥ 22):
|
||||
* node scripts/data-transfer.js export --out ./data.hdata.gz [--categories catalog,orders]
|
||||
* node scripts/data-transfer.js import ./data.hdata.gz --preview [--categories catalog]
|
||||
* node scripts/data-transfer.js import ./data.hdata.gz --apply [--user admin] [--categories catalog]
|
||||
* node scripts/data-transfer.js tables
|
||||
*
|
||||
* 环境变量 DATA_DIR 与网页版一致(默认 backend/data)。
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const command = args[0]
|
||||
|
||||
function flag(name) {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
if (index < 0) return undefined
|
||||
const value = args[index + 1]
|
||||
return !value || value.startsWith('--') ? true : value
|
||||
}
|
||||
|
||||
function positional() {
|
||||
return args.slice(1).filter((item, index, list) => !item.startsWith('--') && !(list[index - 1] || '').startsWith('--'))
|
||||
}
|
||||
|
||||
function usage(message) {
|
||||
if (message) console.error(`✗ ${message}\n`)
|
||||
console.error(`用法:
|
||||
node scripts/data-transfer.js tables
|
||||
node scripts/data-transfer.js export --out <文件.hdata.gz> [--categories catalog,customers,orders,todos,prospects]
|
||||
node scripts/data-transfer.js import <文件.hdata.gz> --preview [--categories …]
|
||||
node scripts/data-transfer.js import <文件.hdata.gz> --apply [--user admin] [--categories …]`)
|
||||
process.exit(message ? 1 : 0)
|
||||
}
|
||||
|
||||
if (!command || ['-h', '--help', 'help'].includes(command)) usage()
|
||||
|
||||
const db = require('../db/db')
|
||||
const transfer = require('../lib/dataTransfer')
|
||||
const { readBundle } = require('../lib/dataTransfer/bundle')
|
||||
const { CATEGORY_LIST } = require('../lib/dataTransfer/catalog')
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data')
|
||||
const TRANSFER_DIR = path.join(DATA_DIR, 'transfers')
|
||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||
|
||||
function defaultUser() {
|
||||
const username = flag('user')
|
||||
const row = username
|
||||
? db.prepare('SELECT * FROM users WHERE username = ?').get(String(username))
|
||||
: db.prepare("SELECT * FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get()
|
||||
if (!row) {
|
||||
console.error(username ? `✗ 找不到用户 ${username}` : '✗ 数据库里没有 admin 账号')
|
||||
process.exit(1)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function categories() {
|
||||
const value = flag('categories')
|
||||
return value && value !== true ? String(value).split(',').map(item => item.trim()).filter(Boolean) : []
|
||||
}
|
||||
|
||||
function printPlan(plan) {
|
||||
console.log('')
|
||||
console.log('表名 包内 新增 跳过 失败')
|
||||
for (const item of plan.tables) {
|
||||
console.log(`${item.label.padEnd(26)}${String(item.rows).padStart(6)}${String(item.insert).padStart(7)}${String(item.skip).padStart(7)}${String(item.failed).padStart(7)}`)
|
||||
}
|
||||
console.log('')
|
||||
console.log(`合计:包内 ${plan.totals.rows} → 新增 ${plan.totals.insert} / 跳过 ${plan.totals.skip} / 失败 ${plan.totals.failed}`)
|
||||
if (plan.stubbed.length) {
|
||||
console.log(`自动补建:${plan.stubbed.map(item => `${item.label} ${item.count} 条(只有名称,请补全)`).join('、')}`)
|
||||
}
|
||||
const failures = plan.tables.filter(item => item.failed)
|
||||
if (failures.length) {
|
||||
console.log('失败明细:')
|
||||
for (const item of failures) for (const warning of item.warnings) console.log(` ${item.label}: ${warning}`)
|
||||
}
|
||||
if (plan.missing.length) console.log(`目标库缺少表:${plan.missing.join('、')}`)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
if (command === 'tables') {
|
||||
for (const category of transfer.listCatalog(db)) {
|
||||
const rows = category.tables.reduce((sum, item) => sum + item.rows, 0)
|
||||
console.log(`${category.key.padEnd(10)}${category.label}(${rows} 行)`)
|
||||
for (const item of category.tables) console.log(` ${item.table.padEnd(24)}${item.label.padEnd(18)}${item.rows}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (command === 'export') {
|
||||
const out = flag('out')
|
||||
if (!out || out === true) usage('导出需要 --out <文件路径>')
|
||||
const target = path.resolve(String(out))
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
const started = Date.now()
|
||||
await transfer.exportToStream(db, fs.createWriteStream(target), {
|
||||
categories: categories(),
|
||||
exporter: defaultUser()
|
||||
})
|
||||
const size = fs.statSync(target).size
|
||||
console.log(`✓ 已导出 ${target}(${(size / 1024 / 1024).toFixed(2)} MB,${Date.now() - started}ms)`)
|
||||
console.log(' 包内不含用户、权限组、组织、设置(含 AI 密钥)、登录会话与审计日志。')
|
||||
return
|
||||
}
|
||||
|
||||
if (command === 'import') {
|
||||
const file = positional()[0] || flag('file')
|
||||
if (!file || file === true) usage('导入需要数据包路径')
|
||||
const source = path.resolve(String(file))
|
||||
if (!fs.existsSync(source)) usage(`找不到数据包:${source}`)
|
||||
const user = defaultUser()
|
||||
|
||||
if (flag('preview')) {
|
||||
/* 预览在库副本上真跑一遍,与网页版同源;线上库不受影响 */
|
||||
const preview = await transfer.previewImport(db, source, {
|
||||
categories: categories(),
|
||||
user,
|
||||
workDir: TRANSFER_DIR,
|
||||
fileName: path.basename(source)
|
||||
})
|
||||
console.log(`数据包:${preview.file.name} · ${(preview.file.size / 1024 / 1024).toFixed(2)} MB · 导出于 ${preview.file.exportedAt}`)
|
||||
printPlan(preview.plan)
|
||||
console.log('\n这只是预览,线上库没有变化。确认无误后加 --apply 执行导入。')
|
||||
return
|
||||
}
|
||||
|
||||
if (flag('apply')) {
|
||||
const result = await transfer.applyImport(db, source, {
|
||||
categories: categories(),
|
||||
user,
|
||||
backupDir: BACKUP_DIR,
|
||||
fileName: path.basename(source)
|
||||
})
|
||||
console.log(`数据包:${result.file.name} · 归属给 ${user.username}`)
|
||||
printPlan(result.plan)
|
||||
console.log(`\n✓ 导入完成。导入前备份:${result.backup}`)
|
||||
return
|
||||
}
|
||||
|
||||
usage('导入需要 --preview(只看报告)或 --apply(真正写入)')
|
||||
}
|
||||
|
||||
usage(`未知命令:${command}`)
|
||||
})().catch(error => {
|
||||
console.error(`✗ ${error.message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
+112
-14
@@ -126,8 +126,27 @@ function badRequest(message) {
|
||||
且表头必须正好在第一行。现在换成「关键词/正则 + 权重打分 + 表头行探测 + 数值列体检」,
|
||||
并支持 xlsx/xls —— 换行业只需改配置,不用改代码。 */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 静态文件服务
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
//
|
||||
// 缓存分两档,因为这两类文件的更新方式完全不同:
|
||||
//
|
||||
// assets/* 文件名里带内容 hash(index-u1PUen4p.js),内容一变文件名就变,
|
||||
// 所以可以放心缓存一年;immutable 让浏览器连 304 都不用问。
|
||||
// express.static 默认是 max-age=0,等于每开一次页面就为每个
|
||||
// JS/CSS 跑一趟回源校验 —— 挂在 CDN 后面尤其浪费。
|
||||
//
|
||||
// index.html 文件名固定,里面引用的才是带 hash 的资源名。它**必须不缓存**,
|
||||
// 否则发了新版用户还拿着旧 index.html、指向旧资源,等于白更新。
|
||||
// ---------------------------------------------------------------------------
|
||||
const DIST_DIR = path.join(__dirname, '../frontend/dist');
|
||||
|
||||
app.use(express.static(DIST_DIR, {
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('index.html')) res.setHeader('Cache-Control', 'no-cache');
|
||||
else res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
}));
|
||||
|
||||
// API 路由
|
||||
app.get('/api/health', (req, res) => {
|
||||
@@ -141,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);
|
||||
|
||||
@@ -342,6 +364,14 @@ app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) =
|
||||
只有拿到 customer.view_all 的管理账号才允许代别人建档。 */
|
||||
const requestedOwner = customerAccess.seesAll(req.user) ? numberOrNull(req.body?.owner_id) : null;
|
||||
const ownerId = requestedOwner || (req.user ? req.user.id : null);
|
||||
|
||||
/* 主要联系人:建档时一并填,不用「先存公司 → 进详情页 → 再加联系人」三步。
|
||||
联系人在另一张表,所以必须跟客户放进**同一个事务** —— 分两次写的话,
|
||||
客户写成功、联系人写失败就留下一条没人能联系的半成品客户。 */
|
||||
const pc = req.body?.primary_contact || {};
|
||||
const contactName = String(pc.name || '').trim();
|
||||
|
||||
const create = db.transaction(() => {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes,
|
||||
owner_id, created_by, org_id, source, status, pool_status, created_at)
|
||||
@@ -350,15 +380,31 @@ app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) =
|
||||
email || null, phone || null, notes || null, ownerId,
|
||||
req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null,
|
||||
source || null, status, nowIso());
|
||||
|
||||
const customerId = result.lastInsertRowid;
|
||||
if (contactName) {
|
||||
db.prepare(`
|
||||
INSERT INTO contacts (customer_id, name, name_en, position, email, phone, whatsapp, wechat, is_primary, notes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
`).run(customerId, contactName,
|
||||
pc.name_en || null, pc.position || null, pc.email || null, pc.phone || null,
|
||||
pc.whatsapp || null, pc.wechat || null, pc.notes || null, nowIso());
|
||||
}
|
||||
return customerId;
|
||||
});
|
||||
|
||||
const customerId = create();
|
||||
|
||||
auth.audit({
|
||||
username: req.user?.username,
|
||||
action: 'customer_create',
|
||||
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}`,
|
||||
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}${contactName ? `,主联系人 ${contactName}` : ''}`,
|
||||
req
|
||||
});
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
id: customerId,
|
||||
owner_id: ownerId,
|
||||
contact_created: !!contactName,
|
||||
message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)'
|
||||
});
|
||||
} catch (error) { jsonError(res, error); }
|
||||
@@ -374,13 +420,31 @@ app.patch('/api/customers/:id', auth.requirePermission('customer.edit'), (req, r
|
||||
}
|
||||
const fields = [];
|
||||
const values = [];
|
||||
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone', 'notes', 'source']) {
|
||||
/* employee_count / annual_revenue / profile 是「公司简介」卡片的字段,
|
||||
走同一条通道 —— 它们跟基本信息一样是人填的文本,没有特殊校验。 */
|
||||
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone',
|
||||
'notes', 'source', 'employee_count', 'annual_revenue', 'profile']) {
|
||||
if (req.body?.[key] === undefined) continue;
|
||||
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
|
||||
if (key === 'company' && !value) throw badRequest('公司名称不能为空');
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(value || null);
|
||||
}
|
||||
/* 成立年份单独处理:它是数字,且范围要卡住 —— 见过 AI 背调把「2024 年营收」
|
||||
当成立年份填进来的,也见过手滑打成 20225。不合理的值宁可拒掉也别存。 */
|
||||
if (req.body?.founded_year !== undefined) {
|
||||
const raw = req.body.founded_year;
|
||||
if (raw === null || raw === '') {
|
||||
fields.push('founded_year = ?'); values.push(null);
|
||||
} else {
|
||||
const year = Number(raw);
|
||||
const thisYear = new Date().getFullYear();
|
||||
if (!Number.isInteger(year) || year < 1800 || year > thisYear) {
|
||||
throw badRequest(`成立年份应在 1800 ~ ${thisYear} 之间`);
|
||||
}
|
||||
fields.push('founded_year = ?'); values.push(year);
|
||||
}
|
||||
}
|
||||
if (req.body?.owner_id !== undefined) {
|
||||
/* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */
|
||||
if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属');
|
||||
@@ -747,8 +811,11 @@ app.put('/api/products/:sku/packaging', auth.requirePermission('product.edit'),
|
||||
});
|
||||
|
||||
// 订单路由
|
||||
app.get('/api/orders', (req, res) => {
|
||||
app.get('/api/orders', auth.requirePermission('order.view'), (req, res) => {
|
||||
try {
|
||||
const canViewAllOrders = auth.permissionsOfRole(req.user.role).includes('order.edit');
|
||||
const orderFilter = canViewAllOrders ? '' : 'WHERE o.owner_id IS NULL OR o.owner_id = ?';
|
||||
const orderParams = canViewAllOrders ? [] : [req.user.id];
|
||||
/* 带上杂费合计:订单列表上只看 total_amount 看不出赚没赚 ——
|
||||
快递费吊卡费加起来几百块,小单子能把毛利吃掉一截。
|
||||
逐行取两位再 SUM,跟支出明细页的合计口径一致。 */
|
||||
@@ -763,8 +830,9 @@ app.get('/api/orders', (req, res) => {
|
||||
(SELECT COUNT(*) FROM order_expenses e WHERE e.order_id = o.id) AS expense_count
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
${orderFilter}
|
||||
ORDER BY o.order_date DESC
|
||||
`).all();
|
||||
`).all(...orderParams);
|
||||
res.json(orders);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -906,6 +974,7 @@ app.get('/api/orders/:id/expenses', auth.requirePermission('order.view'), (req,
|
||||
try {
|
||||
const order = getOrder(req.params.id);
|
||||
if (!order) return res.status(404).json({ error: '订单不存在' });
|
||||
if (!canTouchOrderExpense(req.user, order)) return res.status(403).json({ error: '这张订单不在你名下' });
|
||||
const expenses = db.prepare(`
|
||||
SELECT e.*, s.name AS supplier_name, u.display_name AS created_by_name
|
||||
FROM order_expenses e
|
||||
@@ -1207,7 +1276,7 @@ app.post('/api/assistant/command', async (req, res) => {
|
||||
|
||||
|
||||
|
||||
// 全局搜索:仅索引业务表的非敏感字段,查询参数使用绑定变量。
|
||||
// 全局搜索:查询参数使用绑定变量;客户及其关联记录必须遵守客户归属范围。
|
||||
const SEARCH_SOURCES = [
|
||||
{ table: 'suppliers', type: 'supplier', typeLabel: '供应商', title: ['name', 'short_name'], detail: ['contact_person', 'phone', 'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'], path: '/suppliers' },
|
||||
{ table: 'product_groups', type: 'product', typeLabel: '产品分组', title: ['name', 'group_key'], detail: ['brand', 'category', 'notes'], path: '/products' },
|
||||
@@ -1239,9 +1308,32 @@ app.get('/api/search', (req, res) => {
|
||||
const columns = existingColumns(source.table);
|
||||
const searchable = [...source.title, ...source.detail].filter(column => columns.has(column));
|
||||
if (!searchable.length) continue;
|
||||
const where = searchable.map(column => `CAST(${column} AS TEXT) LIKE ?`).join(' OR ');
|
||||
const selected = ['rowid AS _search_id', ...searchable].join(', ');
|
||||
const rows = db.prepare(`SELECT ${selected} FROM ${source.table} WHERE ${where} LIMIT 8`).all(...searchable.map(() => pattern));
|
||||
const where = searchable.map(column => `CAST(s.${column} AS TEXT) LIKE ?`).join(' OR ');
|
||||
const selected = ['s.rowid AS _search_id', ...searchable.map(column => `s.${column}`)].join(', ');
|
||||
const conditions = [`(${where})`];
|
||||
const params = searchable.map(() => pattern);
|
||||
let from = `${source.table} s`;
|
||||
if (['customers', 'contacts', 'activities', 'deals', 'todos'].includes(source.table)) {
|
||||
const link = source.table === 'customers' ? 's.id' : 's.customer_id';
|
||||
from += ` LEFT JOIN customers c ON c.id = ${link}`;
|
||||
const scope = customerAccess.scopeFilter(req.user, 'all');
|
||||
if (scope.conditions.length) {
|
||||
const owned = scope.conditions.join(' AND ');
|
||||
// 没有关联客户的工作台记录仍可检索;关联客户的记录不能绕过归属。
|
||||
conditions.push(['activities', 'todos'].includes(source.table)
|
||||
? `(s.customer_id IS NULL OR (${owned}))`
|
||||
: `(${owned})`);
|
||||
}
|
||||
params.push(...scope.params);
|
||||
}
|
||||
if (['orders', 'order_items'].includes(source.table)
|
||||
&& !auth.permissionsOfRole(req.user.role).includes('order.edit')) {
|
||||
const orderLink = source.table === 'orders' ? 's.id' : 's.order_id';
|
||||
from += ` JOIN orders o ON o.id = ${orderLink}`;
|
||||
conditions.push('(o.owner_id IS NULL OR o.owner_id = ?)');
|
||||
params.push(req.user.id);
|
||||
}
|
||||
const rows = db.prepare(`SELECT ${selected} FROM ${from} WHERE ${conditions.join(' AND ')} LIMIT 8`).all(...params);
|
||||
rows.forEach(row => {
|
||||
const title = source.title.map(column => row[column]).filter(Boolean).join(' · ') || source.typeLabel;
|
||||
const detail = source.detail.map(column => row[column]).filter(value => value !== undefined && value !== null && value !== '').join(' · ');
|
||||
@@ -1675,18 +1767,20 @@ app.post('/api/quotes', auth.requirePermission('quote.edit'), (req, res) => {
|
||||
|
||||
const provided = pick('unit_price');
|
||||
// 前端为了即时预览会自己算一次并传上来;没传就按同一口径在后端补算,兜住
|
||||
const unitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate);
|
||||
if (unitPrice === null || unitPrice < 0) {
|
||||
const rawUnitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate);
|
||||
if (rawUnitPrice === null || rawUnitPrice < 0) {
|
||||
throw badRequest(`${label}的单价无法确定:请填单价,或同时填齐采购价 / 汇率 / 加价率`);
|
||||
}
|
||||
|
||||
// 行金额必须由单据上实际存储、显示的四位单价计算。
|
||||
const unitPrice = roundTo(rawUnitPrice, 4);
|
||||
return {
|
||||
sku,
|
||||
productName: String(raw?.product_name || sku).trim(),
|
||||
productNameEn: raw?.product_name_en ? String(raw.product_name_en).trim() : null,
|
||||
spec: raw?.spec ? String(raw.spec).trim() : null,
|
||||
qty,
|
||||
unitPrice: roundTo(unitPrice, 4),
|
||||
unitPrice,
|
||||
totalPrice: roundTo(unitPrice * qty, 2),
|
||||
currency,
|
||||
purchasePrice, exchangeRate, markupRate,
|
||||
@@ -1801,8 +1895,12 @@ app.use((error, req, res, next) => {
|
||||
});
|
||||
|
||||
// SPA fallback - 所有未匹配的路由返回 index.html
|
||||
// 这里走的是 sendFile,不经过上面 express.static 的 setHeaders,
|
||||
// 所以 no-cache 得自己加一遍:/orders 这类深链拿到的也是 index.html,
|
||||
// 被缓存住的话发新版后用户照样打不到新资源。
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 数据迁移接口的端到端测试。
|
||||
*
|
||||
* 起两个真实后端进程: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 { readBundle } = require('../lib/dataTransfer/bundle')
|
||||
|
||||
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 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)
|
||||
|
||||
/* ---------------- 换类别预览 / 取消上传 ---------------- */
|
||||
/* 界面上的类别勾选走 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()
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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', '[email protected]', 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), /校验失败/)
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { mkdtempSync, rmSync } = require('node:fs');
|
||||
const { tmpdir } = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const net = require('node:net');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
// 所有写入都在独立临时库,不能加载本地业务库。
|
||||
test('security and quotation regressions', { timeout: 30000 }, async t => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'happy-regression-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const db = require('../db/db');
|
||||
const auth = require('../lib/auth');
|
||||
let server;
|
||||
t.after(async () => {
|
||||
if (server && server.exitCode === null) {
|
||||
const exited = once(server, 'exit');
|
||||
server.kill();
|
||||
await exited;
|
||||
}
|
||||
db.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const admin = db.prepare("SELECT * FROM users WHERE role = 'admin'").get();
|
||||
const repId = db.prepare('INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run('regression_rep', auth.hashPassword('test-password'), 'sales_rep', now).lastInsertRowid;
|
||||
const insertCustomer = db.prepare('INSERT INTO customers (company, owner_id, pool_status, created_at) VALUES (?, ?, ?, ?)');
|
||||
const ownId = insertCustomer.run('scope-own', repId, 'owned', now).lastInsertRowid;
|
||||
const otherId = insertCustomer.run('scope-other', admin.id, 'owned', now).lastInsertRowid;
|
||||
const poolId = insertCustomer.run('scope-pool', repId, 'public', now).lastInsertRowid;
|
||||
for (const [id, marker] of [[ownId, 'scope-own'], [otherId, 'scope-other'], [poolId, 'scope-pool']]) {
|
||||
db.prepare('INSERT INTO contacts (customer_id, name, email, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(id, marker, `${marker}@example.test`, now);
|
||||
db.prepare('INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(id, 'call', marker, now);
|
||||
db.prepare('INSERT INTO deals (customer_id, title, stage, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(id, marker, 'new', now);
|
||||
db.prepare('INSERT INTO todos (customer_id, title, created_at) VALUES (?, ?, ?)').run(id, marker, now);
|
||||
}
|
||||
db.prepare('INSERT INTO activities (activity_type, content, created_at) VALUES (?, ?, ?)').run('todo', 'unlinked-activity', now);
|
||||
db.prepare('INSERT INTO todos (title, created_at) VALUES (?, ?)').run('unlinked-todo', now);
|
||||
const insertOrder = db.prepare('INSERT INTO orders (order_no, customer_id, order_date, owner_id, created_at) VALUES (?, ?, ?, ?, ?)');
|
||||
const ownOrderId = insertOrder.run('REP-OWN', ownId, now.slice(0, 10), repId, now).lastInsertRowid;
|
||||
const otherOrderId = insertOrder.run('ADMIN-OTHER', otherId, now.slice(0, 10), admin.id, now).lastInsertRowid;
|
||||
insertOrder.run('LEGACY-UNOWNED', otherId, now.slice(0, 10), null, now);
|
||||
const adminCookie = `happy_sid=${auth.createSession(admin.id)}`;
|
||||
const repCookie = `happy_sid=${auth.createSession(repId)}`;
|
||||
|
||||
// 由系统分配空闲端口,避免干扰已在运行的开发服务。
|
||||
const probe = net.createServer();
|
||||
probe.listen(0, '127.0.0.1');
|
||||
await once(probe, 'listening');
|
||||
const port = probe.address().port;
|
||||
await new Promise(resolve => probe.close(resolve));
|
||||
server = spawn(process.execPath, [path.join(__dirname, '../server.js')], {
|
||||
env: { ...process.env, PORT: String(port), SECURE_COOKIE: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
let logs = '';
|
||||
server.stdout.on('data', chunk => { logs += chunk; });
|
||||
server.stderr.on('data', chunk => { logs += chunk; });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* 等待启动 */ }
|
||||
if (attempt >= 100 || server.exitCode !== null) throw new Error(`Server startup failed: ${logs}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
const search = async (term, cookie) => {
|
||||
const res = await fetch(`${base}/api/search?q=${encodeURIComponent(term)}`, { headers: { cookie } });
|
||||
assert.equal(res.status, 200);
|
||||
return (await res.json()).results;
|
||||
};
|
||||
|
||||
await t.test('search isolates customers, contacts, activities, deals and todos', async () => {
|
||||
assert.equal((await fetch(`${base}/api/search?q=scope`)).status, 401);
|
||||
for (const term of ['scope-other', 'scope-pool', '[email protected]']) {
|
||||
assert.deepEqual(await search(term, repCookie), []);
|
||||
assert.ok((await search(term, adminCookie)).length > 0);
|
||||
}
|
||||
const own = await search('scope-own', repCookie);
|
||||
assert.equal(own.length, 5);
|
||||
assert.deepEqual(new Set(own.map(r => r.typeLabel)), new Set(['客户', '客户联系人', '活动', '商机', '待办']));
|
||||
assert.ok((await search('unlinked-activity', repCookie)).length > 0);
|
||||
assert.ok((await search('unlinked-todo', repCookie)).length > 0);
|
||||
// 通配符不能令 OR 条件跳过归属检查。
|
||||
const broad = JSON.stringify(await search('%', repCookie));
|
||||
assert.ok(!broad.includes('scope-other') && !broad.includes('scope-pool'));
|
||||
});
|
||||
|
||||
await t.test('order lists, global search and expense details respect ownership', async () => {
|
||||
const listRes = await fetch(`${base}/api/orders`, { headers: { cookie: repCookie } });
|
||||
assert.equal(listRes.status, 200);
|
||||
const visibleOrders = await listRes.json();
|
||||
assert.ok(visibleOrders.some(order => order.id === ownOrderId));
|
||||
assert.ok(visibleOrders.some(order => order.order_no === 'LEGACY-UNOWNED'));
|
||||
assert.ok(!visibleOrders.some(order => order.id === otherOrderId));
|
||||
assert.deepEqual(await search('ADMIN-OTHER', repCookie), []);
|
||||
assert.ok((await search('ADMIN-OTHER', adminCookie)).length > 0);
|
||||
assert.equal((await fetch(`${base}/api/orders/${otherOrderId}/expenses`, { headers: { cookie: repCookie } })).status, 403);
|
||||
});
|
||||
|
||||
await t.test('session cookies support HTTP development and secure HTTPS login/logout', () => {
|
||||
for (const secure of ['0', '1']) {
|
||||
process.env.SECURE_COOKIE = secure;
|
||||
const cookies = [];
|
||||
const res = { append: (key, value) => cookies.push(value) };
|
||||
auth.setSessionCookie(res, 'test-session');
|
||||
auth.clearSessionCookie(res);
|
||||
for (const cookie of cookies) {
|
||||
assert.equal(cookie.includes('; Secure'), secure === '1');
|
||||
assert.ok(cookie.includes('HttpOnly') && cookie.includes('SameSite=Lax'));
|
||||
}
|
||||
assert.ok(cookies[1].includes('Max-Age=0'));
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('stored quotation amounts use the displayed rounded price', async () => {
|
||||
const res = await fetch(`${base}/api/quotes`, {
|
||||
method: 'POST', headers: { cookie: adminCookie, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer_id: ownId, items: [
|
||||
{ sku: 'rounding-a', unit_price: 2.99995, qty: 1000 },
|
||||
{ sku: 'rounding-b', unit_price: 1.00005, qty: 10000 }
|
||||
] })
|
||||
});
|
||||
assert.equal(res.status, 201, await res.clone().text());
|
||||
const { items } = await res.json();
|
||||
assert.deepEqual(items.map(i => [i.unit_price, i.total_price]), [[3, 3000], [1.0001, 10001]]);
|
||||
});
|
||||
|
||||
await t.test('multer 2 accepts contract multipart uploads', async () => {
|
||||
const form = new FormData();
|
||||
form.append('files', new Blob(['SKU,Name,Price\nTEST-01,Test product,12.50']), 'regression.csv');
|
||||
const res = await fetch(`${base}/api/contracts/import`, { method: 'POST', headers: { cookie: adminCookie }, body: form });
|
||||
assert.equal(res.status, 201, await res.clone().text());
|
||||
});
|
||||
|
||||
await t.test('only admins can start updates or inspect update logs', async () => {
|
||||
assert.equal((await fetch(`${base}/api/system/update/status`)).status, 401);
|
||||
assert.equal((await fetch(`${base}/api/system/update/status`, { headers: { cookie: repCookie } })).status, 403);
|
||||
assert.equal((await fetch(`${base}/api/system/update/status`, { headers: { cookie: adminCookie } })).status, 200);
|
||||
const post = async (cookie, password) => fetch(`${base}/api/system/update`, {
|
||||
method: 'POST', headers: { ...(cookie ? { cookie } : {}), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: password })
|
||||
});
|
||||
assert.equal((await post('', 'admin123')).status, 401);
|
||||
assert.equal((await post(repCookie, 'test-password')).status, 403);
|
||||
const wrong = await post(adminCookie, 'wrong-password');
|
||||
assert.equal(wrong.status, 400);
|
||||
assert.match((await wrong.json()).error, /密码不正确/);
|
||||
});
|
||||
|
||||
await t.test('profile cannot promote a sales rep to admin', async () => {
|
||||
const res = await fetch(`${base}/api/me`, { method: 'PATCH', headers: { cookie: repCookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 'admin' }) });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal(db.prepare('SELECT role FROM users WHERE id = ?').get(repId).role, 'sales_rep');
|
||||
});
|
||||
|
||||
await t.test('username changes verify password, preserve ownership and expire other sessions', async () => {
|
||||
const patch = async body => fetch(`${base}/api/me/username`, {
|
||||
method: 'PATCH', headers: { cookie: adminCookie, 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
assert.equal((await patch({ username: 'renamed_admin' })).status, 400);
|
||||
assert.equal((await patch({ username: 'renamed_admin', current_password: 'wrong-password' })).status, 400);
|
||||
assert.equal((await patch({ username: 'REGRESSION_REP', current_password: 'admin123' })).status, 409);
|
||||
assert.equal((await patch({ username: 'bad name', current_password: 'admin123' })).status, 400);
|
||||
const otherSession = auth.createSession(admin.id);
|
||||
const res = await patch({ username: 'renamed_admin', current_password: 'admin123' });
|
||||
assert.equal(res.status, 200, await res.clone().text());
|
||||
const renamed = (await res.json()).user;
|
||||
assert.equal(renamed.id, admin.id);
|
||||
assert.equal(renamed.username, 'renamed_admin');
|
||||
assert.equal(db.prepare('SELECT owner_id FROM customers WHERE id = ?').get(otherId).owner_id, admin.id);
|
||||
assert.equal(db.prepare('SELECT id FROM sessions WHERE id = ?').get(otherSession), undefined);
|
||||
assert.equal((await fetch(`${base}/api/auth/me`, { headers: { cookie: adminCookie } })).status, 200);
|
||||
const login = async username => fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: 'admin123' })
|
||||
});
|
||||
assert.equal((await login('admin')).status, 400);
|
||||
assert.equal((await login('renamed_admin')).status, 200);
|
||||
// 在第二个进程重新初始化数据库,改名后不能重建带默认密码的 admin。
|
||||
const child = spawn(process.execPath, ['-e', "require('./db/db').close()"], {
|
||||
cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR: dataDir }, stdio: 'ignore'
|
||||
});
|
||||
const [code] = await once(child, 'exit');
|
||||
assert.equal(code, 0);
|
||||
assert.equal(db.prepare("SELECT id FROM users WHERE username = 'admin'").get(), undefined);
|
||||
});
|
||||
|
||||
await t.test('password verification locks repeated guesses before sensitive operations', async () => {
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
const res = await fetch(`${base}/api/me/username`, {
|
||||
method: 'PATCH', headers: { cookie: repCookie, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'another_name', current_password: 'wrong-password' })
|
||||
});
|
||||
assert.equal(res.status, attempt === 5 ? 429 : 400);
|
||||
}
|
||||
assert.equal((await fetch(`${base}/api/auth/me`, { headers: { cookie: repCookie } })).status, 401);
|
||||
const rep = db.prepare('SELECT username, locked_until FROM users WHERE id = ?').get(repId);
|
||||
assert.equal(rep.username, 'regression_rep');
|
||||
assert.ok(Date.parse(rep.locked_until) > Date.now());
|
||||
});
|
||||
|
||||
await t.test('amount words separate minor units and preserve group conjunctions', async () => {
|
||||
const { amountToWords: words } = await import(pathToFileURL(path.join(__dirname, '../../frontend/src/amountInWords.js')));
|
||||
const cases = [
|
||||
[100.01, 'SAY US DOLLARS ONE HUNDRED AND CENTS ONE ONLY'],
|
||||
[1005, 'SAY US DOLLARS ONE THOUSAND AND FIVE ONLY'],
|
||||
[1015, 'SAY US DOLLARS ONE THOUSAND AND FIFTEEN ONLY'],
|
||||
[2001, 'SAY US DOLLARS TWO THOUSAND AND ONE ONLY'],
|
||||
[1200, 'SAY US DOLLARS ONE THOUSAND TWO HUNDRED ONLY'],
|
||||
[1000005, 'SAY US DOLLARS ONE MILLION AND FIVE ONLY'],
|
||||
[0, 'SAY US DOLLARS ZERO ONLY'],
|
||||
[0.5, 'SAY US DOLLARS ZERO AND CENTS FIFTY ONLY']
|
||||
];
|
||||
for (const [amount, expected] of cases) assert.equal(words(amount), expected);
|
||||
assert.equal(words(1e15, 'JPY'), 'SAY JAPANESE YEN ONE QUADRILLION ONLY');
|
||||
assert.equal(words(1e20), '');
|
||||
assert.equal(words('invalid'), '');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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 Database = require('better-sqlite3-multiple-ciphers');
|
||||
const { executeUpdate } = require('../lib/updateWorker');
|
||||
|
||||
for (const mode of ['success', 'build-failure', 'restart-failure', 'health-failure', 'rollback-failure']) {
|
||||
test(`web update deployment: ${mode}`, async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-update-'));
|
||||
let live;
|
||||
t.after(() => {
|
||||
if (live?.open) live.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const data = path.join(root, 'backend/data');
|
||||
const dir = path.join(data, 'system-update');
|
||||
const jobDir = path.join(dir, 'test-job');
|
||||
const stage = path.join(jobDir, 'source');
|
||||
const modules = path.join(root, 'backend/node_modules');
|
||||
const dist = path.join(root, 'frontend/dist');
|
||||
for (const folder of [jobDir, modules, dist]) fs.mkdirSync(folder, { recursive: true });
|
||||
fs.writeFileSync(path.join(modules, 'marker'), 'old modules');
|
||||
fs.writeFileSync(path.join(dist, 'index.html'), 'old page');
|
||||
fs.writeFileSync(path.join(root, 'server-code'), 'old');
|
||||
fs.writeFileSync(path.join(dir, 'status.json'), JSON.stringify({ state: 'queued' }));
|
||||
fs.writeFileSync(path.join(dir, 'lock'), 'locked');
|
||||
live = new Database(path.join(data, 'trade.db'));
|
||||
live.pragma('journal_mode = WAL');
|
||||
live.exec('CREATE TABLE assets (value TEXT); INSERT INTO assets VALUES (\'real business data\')');
|
||||
const commands = [];
|
||||
const hooks = {
|
||||
run: async (command, args, cwd) => {
|
||||
commands.push([command, ...args]);
|
||||
if (command === 'git') {
|
||||
if (args[0] === 'rev-parse') return args[1] === 'HEAD' ? 'old-commit' : 'new-commit';
|
||||
if (args[0] === 'status') return '';
|
||||
if (args[0] === 'worktree' && args[1] === 'add') {
|
||||
for (const folder of ['backend/node_modules', 'frontend/dist']) fs.mkdirSync(path.join(stage, folder), { recursive: true });
|
||||
fs.writeFileSync(path.join(stage, 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
||||
fs.writeFileSync(path.join(stage, 'backend/node_modules/marker'), 'new modules');
|
||||
fs.writeFileSync(path.join(stage, 'frontend/dist/index.html'), 'new page');
|
||||
}
|
||||
if (args[0] === 'reset') fs.writeFileSync(path.join(root, 'server-code'), args[2] === 'old-commit' ? 'old' : 'new');
|
||||
if (args[0] === 'worktree' && args[1] === 'remove') fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
if (command === 'npm' && args[0] === 'run' && mode === 'build-failure') throw new Error('build failed');
|
||||
if (command === 'systemctl' && args[0] === 'restart' && ['restart-failure', 'rollback-failure'].includes(mode)) throw new Error('restart failed');
|
||||
if (command === 'systemctl' && args[0] === 'start' && mode === 'rollback-failure') throw new Error('rollback failed');
|
||||
return '';
|
||||
},
|
||||
backup: destination => live.backup(destination),
|
||||
healthy: async expected => { if (expected && mode === 'health-failure') throw new Error('health check failed'); }
|
||||
};
|
||||
await executeUpdate({ root, data, dir, jobDir, service: 'erp', repo: 'owner/repo', tag: 'v2.0.0',
|
||||
version: '2.0.0', node: process.execPath, port: '4300' }, hooks);
|
||||
const status = JSON.parse(fs.readFileSync(path.join(dir, 'status.json'), 'utf8'));
|
||||
const success = mode === 'success';
|
||||
assert.equal(status.state, success ? 'completed' : 'failed');
|
||||
assert.equal(fs.readFileSync(path.join(modules, 'marker'), 'utf8'), success ? 'new modules' : 'old modules');
|
||||
assert.equal(fs.readFileSync(path.join(dist, 'index.html'), 'utf8'), success ? 'new page' : 'old page');
|
||||
assert.equal(fs.readFileSync(path.join(root, 'server-code'), 'utf8'), success ? 'new' : 'old');
|
||||
assert.equal(fs.existsSync(path.join(dir, 'lock')), false);
|
||||
assert.equal(live.prepare('SELECT value FROM assets').get().value, 'real business data');
|
||||
if (mode === 'build-failure') {
|
||||
assert.ok(!commands.some(args => args[0] === 'systemctl' || args[1] === 'reset'));
|
||||
} else {
|
||||
const backup = new Database(path.join(jobDir, 'trade-before-update.db'), { readonly: true });
|
||||
assert.equal(backup.prepare('SELECT value FROM assets').get().value, 'real business data');
|
||||
backup.close();
|
||||
}
|
||||
if (['restart-failure', 'health-failure'].includes(mode)) assert.match(status.message, /已恢复上一版/);
|
||||
if (mode === 'rollback-failure') assert.match(status.message, /自动恢复失败/);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
# 业务数据导入导出(数据迁移)
|
||||
|
||||
> 对应 `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 # 应返回 "version":"1.2.0"
|
||||
```
|
||||
|
||||
版本号从 `1.1.1` 升到 `1.2.0` 就是为了这一步:/api/health 与「设置 → 系统与更新」都能一眼看出线上跑的是哪份代码。
|
||||
|
||||
部署后**现有账号与设置保持不变**(这正是与整库替换的区别);新增的权限点 `data.transfer`
|
||||
会由 `db.js` 的 `syncNewPermissions()` 自动补进超级管理员组 —— 已实测:只补给「超级管理员组 / admin」,
|
||||
董事长、总经理、销售经理都拿不到。
|
||||
|
||||
反向也成立:云端 → 本地同样走这一页,包是同一个格式。
|
||||
|
||||
## 命令行(备用通道)
|
||||
|
||||
浏览器上传被 nginx / CDN 的体积限制挡住(默认 `client_max_body_size 1m`,而真实包 1.22 MB 就会 413),
|
||||
或者想把包 `scp` 上去直接在服务器导,用同仓库的 CLI:
|
||||
|
||||
```bash
|
||||
cd /opt/1panel/www/sites/erp.foohere.de/index/backend
|
||||
|
||||
# 各类别、各表当前行数
|
||||
/usr/bin/node scripts/data-transfer.js tables
|
||||
|
||||
# 先预览:在库副本上真跑一遍,线上库不动
|
||||
/usr/bin/node scripts/data-transfer.js import /root/bundle.hdata.gz --preview
|
||||
|
||||
# 确认后导入:自动备份到 data/backups/,归属给 --user 指定的账号
|
||||
/usr/bin/node scripts/data-transfer.js import /root/bundle.hdata.gz --apply --user admin
|
||||
|
||||
# 定期导出做异地备份(配合 cron)
|
||||
/usr/bin/node scripts/data-transfer.js export --out /root/backup-$(date +%F).hdata.gz
|
||||
```
|
||||
|
||||
与网页版**共用同一套代码**(判重、归属改写、补壳、备份完全一致),
|
||||
`--categories catalog,orders` 可以只搬某几类。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 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`。
|
||||
|
||||
## 排查
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
| --- | --- |
|
||||
| 上传后 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 万行**,超过请按类别分开导;
|
||||
- 上传的包保存在 `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` 会校验顺序与系统表是否被误导出。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 管理员网页更新
|
||||
|
||||
管理员进入「设置 → 系统与更新」,检查新版本后输入当前密码,点击「安装」。任务进度和日志会在网页显示;重启过程中连接短暂中断,页面会自动重试,完成后点击「刷新使用新版本」。普通账号只能检查版本,不能安装或读取更新日志。
|
||||
|
||||
## 首次启用
|
||||
|
||||
这项功能需要先把包含网页更新功能的版本部署到服务器一次。之后通过 GitHub 发布更高版本的 Release 或 tag 即可在网页安装。发布标签与仓库顶层 `package.json` 的版本必须一致;版本号必须高于当前版本。更新不自动跟随 main 的每一次提交。
|
||||
|
||||
运行条件:
|
||||
|
||||
- Linux + systemd、Node.js 22 或更高版本、Git、npm。
|
||||
- 应用目录为 Git 工作副本,没有未提交的修改或未跟踪文件(已忽略的数据、依赖和构建目录不受影响)。
|
||||
- ERP 服务账号可以创建 systemd 临时单元、启停 ERP 服务,并读写应用目录和数据目录。现有 root 运行的 `erp.service` 满足此权限条件。
|
||||
- 服务器能够访问 GitHub、npm registry 和项目依赖来源。
|
||||
- 磁盘可容纳临时源码、两套后端依赖、前端构建和一份数据库备份。
|
||||
|
||||
默认服务名为 `erp`,对应 `erp.service`。如服务名不同,在 ERP 的 systemd 配置中增加:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=HAPPY_UPDATE_SERVICE=your-service-name
|
||||
Environment=SECURE_COOKIE=1
|
||||
```
|
||||
|
||||
服务名不含 `.service` 后缀。应用代码仍由现有 ERP 单元启动,更新任务用相同的 Node 可执行文件运行,并将该 Node 所在目录放在 PATH 首位,避免服务器旧 Node 抢先被使用。
|
||||
|
||||
更新源默认是 `Drsakura/happy-export`,可以通过服务器环境变量 `HAPPY_UPDATE_REPO=owner/repo` 配置;网页不能临时指定其他安装来源。当前自动安装从 GitHub 公共 HTTPS 仓库获取版本,不提供私有仓库令牌配置。
|
||||
|
||||
## 执行过程
|
||||
|
||||
1. 管理员权限校验、当前密码验证、并发更新锁和工作目录检查。
|
||||
2. 读取配置仓库的最新 Release/tag,获取指定标签的提交,在独立 Git worktree 准备新版本。
|
||||
3. 后端 `npm ci --omit=dev --ignore-scripts`,前端 `npm ci --include=dev`,再构建前端。
|
||||
4. 使用 SQLite 在线备份 API 备份数据库,包含 WAL 中尚未合并的数据。
|
||||
5. 再次确认运行目录没有发生代码修改,切换后端依赖、前端构建和 Git 提交。
|
||||
6. 重启 ERP 服务,核验健康状态及实际版本,成功后删除旧依赖和旧前端构建。
|
||||
|
||||
下载、安装或构建失败不会切换正在运行的应用。切换后重启或健康检查失败,会尝试恢复旧 Git 提交、后端依赖和前端构建并重启。任务通过独立 systemd 临时服务执行,因此 ERP 重启不会终止更新任务。
|
||||
|
||||
## 日志、备份与恢复
|
||||
|
||||
每次任务的日志和数据库备份保存在:
|
||||
|
||||
```text
|
||||
<DATA_DIR>/system-update/<任务ID>/update.log
|
||||
<DATA_DIR>/system-update/<任务ID>/trade-before-update.db
|
||||
<DATA_DIR>/system-update/status.json
|
||||
```
|
||||
|
||||
日志与备份不会自动过期,管理员可按自己的备份策略清理。状态接口只向管理员返回日志末尾 12 KB。
|
||||
|
||||
自动恢复只回退应用代码与依赖,不自动覆盖业务数据库,以保留更新期间的业务写入。后续发布应保持数据库迁移向后兼容;如果新版本迁移了不兼容的数据结构,需要管理员停服后从备份恢复数据库,恢复时同时处理旧 WAL/SHM。
|
||||
|
||||
意外断电或更新任务被终止后,网页会识别任务已停止并提示失败。若残留 `backend/node_modules.update-old` 或 `frontend/dist.update-old`,新任务会停止并提示人工检查;先确认服务实际运行版本和数据库,再决定恢复或清理这些目录,避免覆盖恢复所需文件。
|
||||
|
||||
Windows 本地环境支持检查更新,安装按钮会提示只支持 Linux systemd 部署。
|
||||
|
||||
## 用户名修改
|
||||
|
||||
「个人资料 → 修改用户名」输入新用户名和当前密码即可。新用户名为 3–50 位字母、数字或 `_ . @ -`,以字母、数字或下划线开头;不能与其他用户的用户名重名(不区分大小写)。修改保留当前会话,其他会话失效,之后用新用户名登录。业务归属使用用户 ID,不会随用户名变化。
|
||||
|
||||
初始管理员只在用户表为空时创建;管理员改名之后,重新启动不会重新生成默认账号。登录页面不展示默认用户名或密码。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trade-management-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trade-management-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trade-management-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"description": "外贸供应链管理系统 - 前端",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 }) {
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/users" element={guard('/users', <UserManagement />)} />
|
||||
<Route path="/permission-groups" element={guard('/permission-groups', <PermissionGroups />)} />
|
||||
<Route path="/data-transfer" element={guard('/data-transfer', <DataTransfer />)} />
|
||||
<Route path="/logs" element={guard('/logs', <SystemLogs />)} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* 金额英文大写(外贸单证用)
|
||||
*
|
||||
* 目标格式与信用证 / 发票上的写法一致:
|
||||
* 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND THIRTY-SIX CENTS ONLY
|
||||
* 255.36 USD → SAY US DOLLARS TWO HUNDRED AND FIFTY-FIVE AND CENTS THIRTY-SIX ONLY
|
||||
* 1000 CNY → SAY CHINESE YUAN ONE THOUSAND ONLY
|
||||
* 0.5 EUR → SAY EUROS ZERO AND FIFTY CENTS ONLY
|
||||
* 0.5 EUR → SAY EUROS ZERO AND CENTS FIFTY ONLY
|
||||
*
|
||||
* 几条实务约定(不是「随便翻译一下」):
|
||||
* 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。
|
||||
* 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。
|
||||
* 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。
|
||||
* 最后的个位组小于 100 时,前面补 AND,如 ONE THOUSAND AND FIVE。
|
||||
* 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词,
|
||||
* 单证上不写 ONE US DOLLAR。
|
||||
* 4. 日元等无小数币种不带辅币段;小数位按币种配置取。
|
||||
@@ -22,7 +22,7 @@ const ONES = [
|
||||
'SEVENTEEN', 'EIGHTEEN', 'NINETEEN'
|
||||
]
|
||||
const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY']
|
||||
const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION']
|
||||
const SCALES = ['', 'THOUSAND', 'MILLION', 'BILLION', 'TRILLION', 'QUADRILLION']
|
||||
|
||||
/**
|
||||
* 币种配置。
|
||||
@@ -100,6 +100,9 @@ function integerToWords(value) {
|
||||
rest = Math.floor(rest / 1000)
|
||||
scale += 1
|
||||
}
|
||||
if (value >= 1000 && value % 1000 > 0 && value % 1000 < 100) {
|
||||
groups[groups.length - 1] = `AND ${groups[groups.length - 1]}`
|
||||
}
|
||||
return groups.join(' ')
|
||||
}
|
||||
|
||||
@@ -124,6 +127,7 @@ export function amountToWords(amount, currency = 'USD') {
|
||||
/* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */
|
||||
const total = Math.round(Math.abs(num) * factor)
|
||||
const major = Math.floor(total / factor)
|
||||
if (!Number.isSafeInteger(major)) return ''
|
||||
const minor = total % factor
|
||||
|
||||
const parts = [`SAY ${meta.head}`]
|
||||
@@ -132,8 +136,8 @@ export function amountToWords(amount, currency = 'USD') {
|
||||
// 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略)
|
||||
if (major === 0) parts.push('ZERO')
|
||||
parts.push('AND')
|
||||
parts.push(integerToWords(minor))
|
||||
parts.push(meta.centsHead)
|
||||
parts.push(integerToWords(minor))
|
||||
}
|
||||
parts.push('ONLY')
|
||||
|
||||
|
||||
@@ -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
|
||||
<Suspense fallback={<div className="settings-section"><p className="settings-description">正在载入…</p></div>}>
|
||||
{current === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
|
||||
{current === 'organization' && <OrgBrandingSettings />}
|
||||
{current === 'import-export' && <ImportExportSettings />}
|
||||
{current === 'ai-config' && <AIConfigSettings />}
|
||||
{current === 'system-update' && <SystemUpdateSettings />}
|
||||
</Suspense>
|
||||
@@ -128,17 +128,7 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti
|
||||
)
|
||||
}
|
||||
|
||||
function ImportExportSettings() {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>导入 / 导出</h3>
|
||||
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
|
||||
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
|
||||
<div className="settings-group"><h4>导入数据</h4><div className="settings-actions"><label className="btn btn-secondary file-label">导入供应商数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入产品数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入客户数据<input type="file" accept=".csv,.xlsx" /></label></div><p className="help-text">支持 CSV 和 Excel 格式文件</p></div>
|
||||
<div className="settings-group"><h4>备份与恢复</h4><div className="settings-actions"><button className="btn btn-primary">创建完整备份</button><label className="btn btn-secondary file-label">恢复备份<input type="file" accept=".db,.backup" /></label></div><p className="help-text">建议定期备份数据库</p></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/* 数据导入导出已移出设置:见 pages/DataTransfer.jsx(侧栏 · 系统管理 · 数据迁移) */
|
||||
|
||||
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
|
||||
|
||||
@@ -148,6 +138,12 @@ function ImportExportSettings() {
|
||||
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
|
||||
--------------------------------------------------------------------------- */
|
||||
function SystemUpdateSettings() {
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const [job, setJob] = useState(null)
|
||||
const [updatePassword, setUpdatePassword] = useState('')
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [connectionLost, setConnectionLost] = useState(false)
|
||||
const brand = useBranding()
|
||||
const [info, setInfo] = useState(null)
|
||||
const [repo, setRepo] = useState('')
|
||||
@@ -193,6 +189,38 @@ function SystemUpdateSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const updating = starting || (job && !['idle', 'completed', 'failed'].includes(job.state))
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return
|
||||
let alive = true
|
||||
let timer
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/system/update/status', { timeout: 8000 })
|
||||
if (alive) { setJob(data); setConnectionLost(false) }
|
||||
} catch {
|
||||
if (alive) setConnectionLost(true)
|
||||
} finally {
|
||||
if (alive) timer = setTimeout(poll, 2500)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
return () => { alive = false; clearTimeout(timer) }
|
||||
}, [isAdmin])
|
||||
|
||||
const install = async event => {
|
||||
event.preventDefault()
|
||||
setStarting(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await axios.post('/api/system/update', { current_password: updatePassword }, { timeout: 60000 })
|
||||
setJob(current => ({ ...current, ...data }))
|
||||
setUpdatePassword('')
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.error || '未能确认更新任务状态,请查看下方进度后再试')
|
||||
} finally { setStarting(false) }
|
||||
}
|
||||
|
||||
const fmtTime = (value) => {
|
||||
if (!value) return ''
|
||||
try {
|
||||
@@ -205,7 +233,7 @@ function SystemUpdateSettings() {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>系统与更新</h3>
|
||||
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p>
|
||||
<p className="settings-description">查看当前版本;系统管理员可在此安装更新。</p>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>当前版本</h4>
|
||||
@@ -229,16 +257,16 @@ function SystemUpdateSettings() {
|
||||
value={repo}
|
||||
placeholder="owner/repo"
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
disabled={checking}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<p className="help-text">默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。</p>
|
||||
<p className="help-text">更新源由服务器配置,网页安装使用此仓库的最新正式发布版本或版本标签。</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>检查更新</h4>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo}>
|
||||
<button className="btn btn-primary" onClick={check} disabled={checking || loadingInfo || updating}>
|
||||
{checking ? '检查中…' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -300,6 +328,33 @@ function SystemUpdateSettings() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="settings-group">
|
||||
<h4>安装更新</h4>
|
||||
<p className="help-text">系统先构建新版本并备份数据库,再切换应用和重启。重启时页面可能短暂断开,进度会自动恢复。</p>
|
||||
{job?.capability && !job.capability.supported && <p className="help-text">{job.capability.reason}</p>}
|
||||
{result?.hasUpdate && job?.capability?.supported && !updating && !(job?.state === 'completed' && job?.version === result.latest) && (
|
||||
<form onSubmit={install}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="update-current-password">管理员当前密码</label>
|
||||
<input id="update-current-password" className="form-input" type="password" autoComplete="current-password"
|
||||
required value={updatePassword} onChange={event => setUpdatePassword(event.target.value)} />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={starting || !updatePassword}>
|
||||
{starting ? '正在创建任务…' : `安装 v${result.latest}`}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{job && job.state !== 'idle' && (
|
||||
<div role="status" className={`update-status ${job.state === 'failed' ? 'is-error' : job.state === 'completed' ? 'is-ok' : 'is-checking'}`}>
|
||||
{updating && <span className="spinner" />}<span>{job.message}</span>
|
||||
</div>
|
||||
)}
|
||||
{connectionLost && <p role="status" className="help-text">暂时无法连接服务,正在自动重试…</p>}
|
||||
{job?.state === 'completed' && <button className="btn btn-primary" onClick={() => window.location.reload()}>刷新使用新版本</button>}
|
||||
{job?.log && <details><summary>更新日志</summary><pre className="update-notes">{job.log}</pre></details>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
|
||||
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
|
||||
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon, BuildingIcon
|
||||
} from '../components/Icons'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
@@ -298,6 +298,94 @@ function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
|
||||
}
|
||||
|
||||
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
|
||||
/**
|
||||
* 公司简介编辑。
|
||||
*
|
||||
* 跟「编辑客户信息」分成两个弹窗,因为这两组字段的性质不同:
|
||||
* 基本信息是成交必须的(公司名、国家、联系方式),公司简介是了解到多少写多少
|
||||
* 的背景资料。混在一个表单里会让人以为后面这些也得填。
|
||||
*/
|
||||
function CompanyProfileModal({ customer, onClose, onSaved }) {
|
||||
const [form, setForm] = useState({
|
||||
founded_year: customer.founded_year || '',
|
||||
employee_count: customer.employee_count || '',
|
||||
annual_revenue: customer.annual_revenue || '',
|
||||
address: customer.address || '',
|
||||
profile: customer.profile || ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
/* 成立年份空字符串要转成 null,否则后端会拿 '' 去做数字校验然后报错。
|
||||
其余字段后端会把空串当 null 处理,直接传即可。 */
|
||||
await axios.patch(`/api/customers/${customer.id}`, {
|
||||
...form,
|
||||
founded_year: String(form.founded_year).trim() === '' ? null : Number(form.founded_year)
|
||||
})
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || '保存失败,请稍后重试')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>公司简介</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="modal-body">
|
||||
<p className="form-hint">全部选填。谈判和定价时,这些背景常常比联系方式更有用。</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>成立年份</label>
|
||||
<input type="number" min="1800" max={new Date().getFullYear()} placeholder="1998"
|
||||
value={form.founded_year} onChange={e => set('founded_year', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>员工规模</label>
|
||||
<input type="text" placeholder="50-200 人"
|
||||
value={form.employee_count} onChange={e => set('employee_count', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>年营收</label>
|
||||
<input type="text" placeholder="€5-10M"
|
||||
value={form.annual_revenue} onChange={e => set('annual_revenue', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>办公地址</label>
|
||||
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>公司简介</label>
|
||||
<textarea rows="5" placeholder="主营业务、渠道、规模、合作过的品牌……"
|
||||
value={form.profile} onChange={e => set('profile', e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? '保存中…' : '保存'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerProfileModal({ customer, onClose, onSaved }) {
|
||||
const [form, setForm] = useState({
|
||||
company: customer.company || '',
|
||||
@@ -416,6 +504,7 @@ function CustomerDetail() {
|
||||
const [contactError, setContactError] = useState('')
|
||||
const [savingContact, setSavingContact] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [companyOpen, setCompanyOpen] = useState(false)
|
||||
/* 正在编辑的跟进记录;null = 没打开 */
|
||||
const [followUpEditing, setFollowUpEditing] = useState(null)
|
||||
|
||||
@@ -483,6 +572,13 @@ function CustomerDetail() {
|
||||
const contacts = data?.contacts || []
|
||||
const inquiries = data?.inquiries || []
|
||||
|
||||
/* 公司简介卡片:一个字段都没填就整块显示引导语,而不是摆一排「未填写」。
|
||||
address 也算进来 —— 它虽然存在基本信息里,但在这张卡上是「办公地址」。 */
|
||||
const hasCompanyProfile = !!(customer && (
|
||||
customer.founded_year || customer.employee_count ||
|
||||
customer.annual_revenue || customer.profile || customer.address
|
||||
))
|
||||
|
||||
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
|
||||
const nextFollowUp = useMemo(() => {
|
||||
const dated = followups.filter(record => record.next_action_date)
|
||||
@@ -757,6 +853,38 @@ function CustomerDetail() {
|
||||
{customer.notes && <p className="notes-box">{customer.notes}</p>}
|
||||
</section>
|
||||
|
||||
{/* 公司简介:了解到多少写多少,全部选填。
|
||||
也是 AI 背调结果的落点 —— 查到的成立年份/规模/营收会填进这里。 */}
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><BuildingIcon size={17} /> 公司简介</h2>
|
||||
<p className="panel-sub">了解到多少写多少,都不是必填</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCompanyOpen(true)}>
|
||||
<PencilIcon size={14} /> 编辑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasCompanyProfile ? (
|
||||
<>
|
||||
<div className="kv-grid">
|
||||
<div className="kv"><span className="kv-label">成立于</span><span className={`kv-value${customer.founded_year ? '' : ' is-empty'}`}>{customer.founded_year || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">员工规模</span><span className={`kv-value${customer.employee_count ? '' : ' is-empty'}`}>{customer.employee_count || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">年营收</span><span className={`kv-value${customer.annual_revenue ? '' : ' is-empty'}`}>{customer.annual_revenue || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">办公地址</span><span className={`kv-value${customer.address ? '' : ' is-empty'}`}>{customer.address || '未填写'}</span></div>
|
||||
</div>
|
||||
{customer.profile && <p className="company-profile-text">{customer.profile}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="panel-empty-hint">
|
||||
还没填公司信息。成立年份、员工规模、年营收、业务简介这些,
|
||||
在谈判和定价时常常比联系方式更有用。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
@@ -909,6 +1037,14 @@ function CustomerDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companyOpen && (
|
||||
<CompanyProfileModal
|
||||
customer={customer}
|
||||
onClose={() => setCompanyOpen(false)}
|
||||
onSaved={async () => { setCompanyOpen(false); await load() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileOpen && (
|
||||
<CustomerProfileModal
|
||||
customer={customer}
|
||||
|
||||
@@ -28,15 +28,16 @@ function CustomerManagement() {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [formData, setFormData] = useState({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
/* 建档时能填的东西尽量都放这儿 —— 原来只有公司名能填,联系人要等保存完
|
||||
进详情页再加一遍,等于把一件事拆成三步。主联系人在另一张表,由后端在
|
||||
同一个事务里一并建(分两个请求的话中间失败会留下没人能联系的半成品)。 */
|
||||
const EMPTY_FORM = {
|
||||
company: '', company_en: '', country: '', industry: '', website: '',
|
||||
email: '', phone: '', address: '', source: '', notes: '',
|
||||
primary_contact: { name: '', position: '', email: '', phone: '', whatsapp: '' }
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState(EMPTY_FORM)
|
||||
|
||||
const fetchCustomers = useCallback(async (nextScope = scope) => {
|
||||
setError('')
|
||||
@@ -69,9 +70,13 @@ function CustomerManagement() {
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
try {
|
||||
const response = await axios.post('/api/customers', formData)
|
||||
/* 联系人没填姓名就不提交这一块 —— 后端只认 name 非空才建联系人,
|
||||
但显式不传更清楚,也省得将来后端改了判断条件就冒出空记录 */
|
||||
const payload = { ...formData }
|
||||
if (!payload.primary_contact?.name?.trim()) delete payload.primary_contact
|
||||
const response = await axios.post('/api/customers', payload)
|
||||
setShowAddModal(false)
|
||||
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
|
||||
setFormData(EMPTY_FORM)
|
||||
const newId = response.data?.id
|
||||
await fetchCustomers(scope)
|
||||
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
|
||||
@@ -344,6 +349,88 @@ function CustomerManagement() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>公司英文名</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="单证上用的抬头"
|
||||
value={formData.company_en}
|
||||
onChange={e => setFormData({ ...formData, company_en: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>来源</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="展会 / 阿里询盘 / 老客介绍"
|
||||
value={formData.source}
|
||||
onChange={e => setFormData({ ...formData, source: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主要联系人:建档时顺手填掉,不用保存完再进详情页加一遍。
|
||||
姓名留空则整块不创建,所以「只想先记个公司名」的场景不受影响。 */}
|
||||
<div className="form-subhead">
|
||||
主要联系人
|
||||
<small>填了姓名才会创建,自动标为主联系人;留空可以之后再加</small>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.primary_contact.name}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, name: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>职位</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="采购经理"
|
||||
value={formData.primary_contact.position}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, position: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>联系人邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.primary_contact.email}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, email: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>联系人电话</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.primary_contact.phone}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, phone: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>WhatsApp</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+49 176 1234567"
|
||||
value={formData.primary_contact.whatsapp}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, whatsapp: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
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 [categoryHint, setCategoryHint] = useState('')
|
||||
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)
|
||||
setCategoryHint('')
|
||||
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 || phase === 'applying') return
|
||||
const on = importPicks.includes(key)
|
||||
/* 全部取消等于「什么都不导」,服务端把空列表当成「全选」——
|
||||
所以这里明确挡住,并说明原因,而不是点了没反应 */
|
||||
if (on && importPicks.length === 1) {
|
||||
setCategoryHint('至少要勾一类。想放弃这次导入,点下面的「重新选择」。')
|
||||
return
|
||||
}
|
||||
setCategoryHint('')
|
||||
const next = on ? importPicks.filter(item => item !== key) : [...importPicks, key]
|
||||
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)
|
||||
setCategoryHint('')
|
||||
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
|
||||
/* 顶部类别必须取「包里有哪几类」,而不是「这次选了哪几类」——
|
||||
后者会随着取消勾选而缩短,表现就是「点一下那一类就消失了」。
|
||||
preview.file.categories 来自包头,重算预览也不会变。 */
|
||||
const categoriesInFile = preview?.file?.categories || []
|
||||
|
||||
return (
|
||||
<div className="page-container transfer-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">数据迁移</h1>
|
||||
<p className="page-subtitle">把业务数据搬到另一个部署,或从备份包把数据导回来。按类别选择,冲突只跳过不覆盖。</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={loadCatalog} disabled={loading || exporting || busy}>
|
||||
<RefreshIcon size={14} /> {loading ? '读取中…' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel transfer-boundary">
|
||||
<div className="panel-title"><ShieldIcon size={16} /> 数据边界</div>
|
||||
<div className="transfer-boundary-grid">
|
||||
<div>
|
||||
<h4>会随包迁移</h4>
|
||||
<p>供应商与产品、客户与联系人、询盘报价、订单与杂费、跟进时间线与待办、新客线索。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>永不随包迁移</h4>
|
||||
<p>用户与密码、权限组、组织信息、系统设置(含大模型密钥)、登录会话、系统日志。目标部署继续用自己的那套。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="help-text">导入的记录一律归到执行导入的账号名下,导完可在客户/订单页重新分配。</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="update-status is-error transfer-error" role="alert">
|
||||
<CloseIcon size={15} /> <span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="transfer-grid">
|
||||
{/* ---------------------------------------------------------- 导出 */}
|
||||
<section className="panel transfer-card">
|
||||
<div className="panel-title"><DownloadIcon size={16} /> 导出</div>
|
||||
<p className="settings-description">
|
||||
勾选要带走的数据类别。全选就是「全部业务数据」;只勾订单也能单独带走,不会连带客户档案。
|
||||
</p>
|
||||
|
||||
<div className="transfer-picks">
|
||||
{categories.map(category => {
|
||||
const checked = exportPicks.includes(category.key)
|
||||
return (
|
||||
<label key={category.key} className={`transfer-pick${checked ? ' is-on' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleExport(category.key)}
|
||||
aria-label={`导出${category.label}`}
|
||||
/>
|
||||
<span className="transfer-pick-body">
|
||||
<strong>{category.label}</strong>
|
||||
<small>{category.description}</small>
|
||||
</span>
|
||||
<em>{num(rowsOf(category))} 行</em>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{!categories.length && !loading && <p className="muted-note">没有可导出的类别。</p>}
|
||||
</div>
|
||||
|
||||
<div className="transfer-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setExportPicks(categories.map(item => item.key))} disabled={allPicked || !categories.length}>
|
||||
全选
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setExportPicks([])} disabled={!exportPicks.length}>
|
||||
全不选
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={doExport} disabled={!exportPicks.length || exporting}>
|
||||
<DownloadIcon size={14} />
|
||||
{exporting ? '正在生成…' : allPicked ? '导出全部业务数据' : `导出所选(${exportPicks.length} 类)`}
|
||||
</button>
|
||||
</div>
|
||||
<p className="help-text">
|
||||
共 {num(pickedRows)} 行;包内每张表带 sha256,导入前会先校验完整性。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- 导入 */}
|
||||
<section className="panel transfer-card">
|
||||
<div className="panel-title"><UploadIcon size={16} /> 导入</div>
|
||||
|
||||
{(phase === 'idle' || phase === 'checking') && (
|
||||
<>
|
||||
<p className="settings-description">把另一个部署导出的 <code>.hdata.gz</code> 拖进来。导入前会先出一份预览报告,确认后才写入。</p>
|
||||
<div
|
||||
className={`transfer-drop${dragging ? ' is-drag' : ''}${phase === 'checking' ? ' is-busy' : ''}`}
|
||||
onDragOver={event => { event.preventDefault(); if (phase === 'idle') setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={event => { if (phase === 'idle') onDrop(event) }}
|
||||
>
|
||||
{phase === 'checking' ? (
|
||||
<>
|
||||
<span className="spinner" />
|
||||
<p><strong>正在上传并校验数据包…</strong></p>
|
||||
<div className="transfer-progress" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||
<i style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<p className="muted-note">{percent}%{percent >= 100 ? ' · 正在逐表核对校验值' : ''}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon size={26} />
|
||||
<p><strong>把数据包拖到这里</strong></p>
|
||||
<p className="muted-note">或者</p>
|
||||
<label className="btn btn-secondary file-label">
|
||||
选择文件
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".gz,.hdata,application/gzip"
|
||||
onChange={event => { startPreview(event.target.files?.[0]); event.target.value = '' }}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted-note">单包上限 {fmtSize(catalog?.limits?.maxBytes || 100 * 1024 * 1024)}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(phase === 'ready' || phase === 'applying') && plan && (
|
||||
<>
|
||||
<div className="transfer-file">
|
||||
<strong>{preview.file?.name || file?.name}</strong>
|
||||
<small>
|
||||
{fmtSize(preview.file?.size)} · 导出于 {fmtTime(preview.file?.exportedAt)}
|
||||
{preview.file?.exporter?.display_name ? ` · 来自 ${preview.file.exporter.display_name}` : ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="transfer-cats">
|
||||
<span className="muted-note">导入类别</span>
|
||||
{categoriesInFile.map(key => {
|
||||
const category = categoryByKey.get(key)
|
||||
const on = importPicks.includes(key)
|
||||
return (
|
||||
<label key={key} className={`chip-filter transfer-cat${on ? ' is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="transfer-cat-box"
|
||||
checked={on}
|
||||
disabled={replanning || phase === 'applying'}
|
||||
onChange={() => toggleImport(key)}
|
||||
/>
|
||||
{category?.label || key}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{replanning && <span className="spinner" />}
|
||||
</div>
|
||||
{categoryHint && <p className="help-text transfer-hint">{categoryHint}</p>}
|
||||
|
||||
<div className="table-wrap transfer-report">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>数据表</th>
|
||||
<th className="num">包内</th>
|
||||
<th className="num">将新增</th>
|
||||
<th className="num">跳过</th>
|
||||
<th className="num">失败</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.tables.map(item => (
|
||||
<tr key={item.table}>
|
||||
<td>{item.label}</td>
|
||||
<td className="num">{num(item.rows)}</td>
|
||||
<td className="num">{num(item.insert)}</td>
|
||||
<td className="num">{num(item.skip)}</td>
|
||||
<td className={`num${item.failed ? ' transfer-failed' : ''}`}>{num(item.failed)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>合计</strong></td>
|
||||
<td className="num"><strong>{num(plan.totals.rows)}</strong></td>
|
||||
<td className="num"><strong>{num(plan.totals.insert)}</strong></td>
|
||||
<td className="num"><strong>{num(plan.totals.skip)}</strong></td>
|
||||
<td className="num"><strong>{num(plan.totals.failed)}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{plan.stubbed?.length > 0 && (
|
||||
<p className="help-text transfer-stub-note">
|
||||
<ExchangeIcon size={14} /> 包里没有父记录,将自动补建:{plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')}
|
||||
(只有名称,导完请补全信息)。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan.tables.some(item => item.warnings?.length) && (
|
||||
<details className="transfer-details">
|
||||
<summary>预览告警({plan.tables.reduce((sum, item) => sum + (item.warnings?.length || 0), 0)} 条)</summary>
|
||||
{plan.tables.filter(item => item.warnings?.length).map(item => (
|
||||
<div key={item.table}>
|
||||
<strong>{item.label}</strong>
|
||||
<ul>{item.warnings.map((text, index) => <li key={index}>{text}</li>)}</ul>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p className="help-text">导入前会自动把当前线上库备份成一份 .db 文件,失败可整体回滚。</p>
|
||||
|
||||
<div className="transfer-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={reset} disabled={phase === 'applying'}>
|
||||
重新选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={doApply}
|
||||
disabled={!importPicks.length || replanning || phase === 'applying' || !plan.totals.insert}
|
||||
>
|
||||
{phase === 'applying' ? '正在导入…' : plan.totals.insert ? `确认导入(新增 ${num(plan.totals.insert)} 行)` : '没有可新增的记录'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && (
|
||||
<>
|
||||
<div className="update-status is-ok">
|
||||
<CheckIcon size={15} />
|
||||
<span>
|
||||
导入完成:新增 {num(result.plan?.totals?.insert)} 行,跳过 {num(result.plan?.totals?.skip)} 行
|
||||
{result.plan?.totals?.failed ? `,失败 ${num(result.plan.totals.failed)} 行` : ''}。
|
||||
</span>
|
||||
</div>
|
||||
<p className="help-text">导入前的备份:{result.backup}</p>
|
||||
{result.plan?.stubbed?.length > 0 && (
|
||||
<p className="help-text">
|
||||
自动补建:{result.plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')} —— 这些只有名称,请补全联系人、地址等信息。
|
||||
</p>
|
||||
)}
|
||||
{result.plan?.tables?.some(item => item.failed) && (
|
||||
<details className="transfer-details" open>
|
||||
<summary>未导入的行({result.plan.tables.reduce((sum, item) => sum + (item.failed || 0), 0)} 行)</summary>
|
||||
{result.plan.tables.filter(item => item.failed).map(item => (
|
||||
<div key={item.table}>
|
||||
<strong>{item.label}</strong>
|
||||
<ul>{item.warnings.map((text, index) => <li key={index}>{text}</li>)}</ul>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
<div className="transfer-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={reset}>继续导入</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTransfer
|
||||
@@ -95,7 +95,6 @@ function LoginPage() {
|
||||
{busy ? '登录中…' : '登 录'}
|
||||
</button>
|
||||
|
||||
<p className="login-tip">首次使用默认账号 <code>admin</code>,登录后请在「个人资料」里修改密码。</p>
|
||||
</form>
|
||||
</div>
|
||||
<p className="login-copyright">{brand.name} · 外贸供应链管理系统</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon,
|
||||
EyeIcon, EyeOffIcon, UserIcon
|
||||
} from '../components/Icons'
|
||||
import { useAuth, roleLabel } from '../auth'
|
||||
import { useAuth, roleLabel, hasPermission } from '../auth'
|
||||
import { setPageTitle } from '../branding'
|
||||
import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard'
|
||||
|
||||
@@ -60,6 +60,11 @@ function ProfilePage() {
|
||||
const [notice, setNotice] = useState(null)
|
||||
const fileRef = useRef(null)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [usernamePassword, setUsernamePassword] = useState('')
|
||||
const [usernameBusy, setUsernameBusy] = useState(false)
|
||||
const [usernameNotice, setUsernameNotice] = useState(null)
|
||||
const canManageUsers = hasPermission(user, 'user.manage')
|
||||
const [password, setPassword] = useState({ current: '', next: '', confirm: '' })
|
||||
const [passwordBusy, setPasswordBusy] = useState(false)
|
||||
const [passwordNotice, setPasswordNotice] = useState(null)
|
||||
@@ -108,14 +113,16 @@ function ProfilePage() {
|
||||
phone: payload.phone,
|
||||
address: payload.address,
|
||||
avatar: payload.avatar,
|
||||
...(hasPermission(user, 'user.manage') ? {
|
||||
role: payload.role,
|
||||
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
|
||||
} : {})
|
||||
})
|
||||
const updated = response.data.user
|
||||
setUser(updated)
|
||||
setSaved(toForm(updated))
|
||||
return updated
|
||||
}, [setUser])
|
||||
}, [setUser, user])
|
||||
|
||||
/* 让外壳在跳转前能问一句「要不要先保存」 */
|
||||
useEffect(() => {
|
||||
@@ -155,6 +162,21 @@ function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const submitUsername = async event => {
|
||||
event.preventDefault()
|
||||
setUsernameBusy(true)
|
||||
setUsernameNotice(null)
|
||||
try {
|
||||
const { data } = await axios.patch('/api/me/username', { username: username.trim(), current_password: usernamePassword })
|
||||
setUser(data.user)
|
||||
setUsername('')
|
||||
setUsernamePassword('')
|
||||
setUsernameNotice({ type: 'ok', text: data.message })
|
||||
} catch (error) {
|
||||
setUsernameNotice({ type: 'error', text: error?.response?.data?.error || '修改用户名失败' })
|
||||
} finally { setUsernameBusy(false) }
|
||||
}
|
||||
|
||||
const submitPassword = async event => {
|
||||
event.preventDefault()
|
||||
if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return }
|
||||
@@ -258,7 +280,7 @@ function ProfilePage() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><BuildingIcon size={13} /> 所属组织</label>
|
||||
<select value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
|
||||
<select disabled={!canManageUsers} value={form.org_id} onChange={event => setField('org_id', event.target.value)}>
|
||||
<option value="">未归属</option>
|
||||
{organizations.map(org => (
|
||||
<option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option>
|
||||
@@ -267,7 +289,7 @@ function ProfilePage() {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label><ShieldIcon size={13} /> 权限角色</label>
|
||||
<select value={form.role} onChange={event => setField('role', event.target.value)}>
|
||||
<select disabled={!canManageUsers} value={form.role} onChange={event => setField('role', event.target.value)}>
|
||||
{roles.map(role => (
|
||||
<option key={role.value} value={role.value}>{role.label}</option>
|
||||
))}
|
||||
@@ -293,6 +315,25 @@ function ProfilePage() {
|
||||
|
||||
{/* ---------------- 修改密码 ---------------- */}
|
||||
<section className="panel">
|
||||
<div className="panel-title"><UserIcon size={15} /> 修改用户名</div>
|
||||
<form className="profile-fields profile-fields-single" onSubmit={submitUsername}>
|
||||
<p className="field-hint">当前用户名:{user?.username}。修改后请使用新用户名登录,业务数据归属保持不变。</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-username">新用户名</label>
|
||||
<input id="new-username" autoComplete="username" value={username} minLength={3} maxLength={50} required
|
||||
onChange={event => setUsername(event.target.value)} placeholder="3–50 位字母、数字或 _ . @ -" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username-password">当前密码</label>
|
||||
<input id="username-password" type="password" autoComplete="current-password" required value={usernamePassword}
|
||||
onChange={event => setUsernamePassword(event.target.value)} placeholder="输入当前密码以验证身份" />
|
||||
</div>
|
||||
{usernameNotice && <p role="status" className={`profile-notice ${usernameNotice.type}`}>{usernameNotice.text}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={usernameBusy}>
|
||||
{usernameBusy ? '提交中…' : '确认修改用户名'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="panel-title"><KeyIcon size={15} /> 修改密码</div>
|
||||
<form className="profile-fields profile-fields-single" onSubmit={submitPassword}>
|
||||
{[
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
数据迁移页(/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-cat {
|
||||
cursor: pointer;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.transfer-cat-box {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin: 0;
|
||||
accent-color: var(--primary-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transfer-cat-box:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.transfer-hint {
|
||||
margin: -4px 0 10px;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1722,3 +1722,49 @@ body { -webkit-font-smoothing: auto; }
|
||||
.oe-summary { flex-direction: column; }
|
||||
.oe-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* ---------------------- 客户建档表单:分组小标题 ---------------------- */
|
||||
|
||||
/* 新增客户表单里把「主要联系人」跟公司字段分开 —— 混在一起会让人以为
|
||||
联系人也是必填的,而它是「填了才建,留空无所谓」。 */
|
||||
.form-subhead {
|
||||
margin: 18px 0 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-subhead small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ---------------------- 客户详情:公司简介卡片 ---------------------- */
|
||||
|
||||
.company-profile-text {
|
||||
margin: 12px 0 0;
|
||||
padding: 11px 13px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--surface-deep);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 一个字段都没填时的引导语。比摆一排「未填写」有用:
|
||||
它说明白这些信息拿来干什么,人才有动力去填。 */
|
||||
.panel-empty-hint {
|
||||
margin: 4px 0 0;
|
||||
padding: 16px 14px;
|
||||
border: 1px dashed var(--border-light);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "happy-export",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user