9 Commits
Author SHA1 Message Date
DrsakuraandClaude Opus 5 241fb229c6 feat(crm): 建档一步到位 + 客户详情加公司简介
痛点:原来建客户只能填公司名等 7 个字段,联系人必须先保存、进详情页、
再加一遍,一件事拆成三步;详情页也没地方记公司背景。

建档表单
· 补上后端本来就收但表单没暴露的字段:公司英文名、地址、来源
· 新增「主要联系人」一组(姓名/职位/邮箱/电话/WhatsApp),自动标为主联系人
· 联系人与客户在**同一个事务**里创建 —— 分两个请求的话,客户写成功、
  联系人写失败会留下一条没人能联系的半成品客户
· 姓名留空则整块不创建,「只想先记个公司名」的用法完全不受影响

公司简介(customers 新增 5 列)
· founded_year / employee_count / annual_revenue / profile,外加复用 address
· employee_count 与 annual_revenue 用 TEXT 而非数字:实际拿到的都是
  「50-200 人」「€5-10M」这种带单位的区间,用途是给人看、不参与计算
· founded_year 卡 1800~今年:见过把「2024 年营收」当成立年份填进来的,
  也见过手滑打成 20225,不合理的值宁可拒掉
· 一个字段都没填时显示引导语而不是一排「未填写」—— 说明白这些信息拿来
  干什么,人才有动力填
· 跟「编辑客户信息」分成两个弹窗:基本信息是成交必须的,公司简介是
  了解多少写多少,混在一起会让人以为后者也得填

为 AI 背调预留:research_at / research_sources 两列已建,背调结果将落到
上述字段,并要求每条结论带来源 URL。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-28 16:40:29 +08:00
DrsakuraandClaude Opus 5 7281c41434 chore(gitignore): 忽略数据迁移导出文件与交接文档
v1.2.0 的数据迁移功能会在 transfer-out/ 产出 .hdata.gz —— 实测当前那份是
3854 条业务记录、4.5MB,含真实供应商名与采购价,跟 backend/data/ 同级别。
仓库已设为公开,这个绝不能入库。

*.hdata / *.hdata.gz 不限目录忽略:导出路径在界面里可改,只盯
transfer-out/ 会漏。

顺带忽略交接文档 —— 它逐条列了线上尚未修复的安全问题,
公开仓库里等于给人一份路线图。

历史已核:从未提交过任何 .hdata / transfer-out/ 内容。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-28 15:52:10 +08:00
Drsakura 4f88a814d5 fix(data-ui): 导入类别改为常驻复选框,取消勾选不再从列表消失
之前顶部类别取自服务端返回的「本次已选类别」,取消一个后列表被重算,那一项直接消失 ——
把勾选框做成了单向筛选。现在改为取包头里的全部类别(preview.file.categories)常驻显示,
勾选状态只决定预览与导入范围;取消最后一类时给明确提示,不再静默无响应。
2026-10-23 14:23:53 +08:00
Drsakura ee7550c890 fix(data): 清理临时目录时跳过子目录,并回收 multer 残片
transfers/ 下面还套着 multer 的 uploads/ 子目录;原来只清理一层,目录本身会被当成过期文件去 unlink(失败但刷日志),
而 uploads/ 里因体积超限被丢弃的残片又永远不会被清。现在只删普通文件,并两处都清。
2026-10-23 13:52:14 +08:00
Drsakura 5acfbcea7d chore(release): 1.2.0 —— 数据迁移功能发版
- 版本号 1.1.1 → 1.2.0(root / backend / frontend 与两个 lockfile):
  部署后 /api/health 与「系统与更新」能直接分辨线上跑的是哪份代码,否则换库前后都显示 1.1.1。
- API 测试补齐:只导订单的包内容、/api/data/catalog、换类别预览(replan)、取消上传后不可再导。
- 文档补排查表:413、版本确认、权限位缺失、SQLITE_BUSY、包过期、整体回滚。
- 已验证 data.transfer 只补给超级管理员组(admin),董事长/总经理/销售经理都拿不到。
2026-10-23 13:51:13 +08:00
Drsakura 1e7c3fdcc1 feat(data): 命令行导入导出通道 + 修上传解析的句柄泄漏
- scripts/data-transfer.js:tables / export / import --preview|--apply,与网页版共用同一套内核。
  用于 nginx/CDN 挡住 1.22MB 上传时的备用通道,也可挂 cron 做异地备份。
- readBundle:校验失败中途抛错时关闭 readStream 与 readline,避免反复上传坏包耗尽文件句柄。
- docs:补命令行用法与体积限制说明。
2026-10-23 13:48:53 +08:00
Drsakura 1a759ef37d feat(data): 业务数据与系统数据分离的导入导出
数据迁移不再整体替换 trade.db —— 那条路会把账号、权限组、组织、设置(含大模型密钥)、
登录会话与审计日志一起搬到目标部署。改为按类别搬运业务数据,系统数据永远是目标部署自己那份。

- backend/lib/dataTransfer:表目录(30 张业务表,依赖顺序)、gzip JSONL 数据包(每表 sha256)、导入内核
- 归属统一给导入人;冲突跳过不覆盖;父记录按 __refs 自然键快照补壳
- 流水表(price_history)按整行内容判重:用 (sku, changed_at) 当键实测 643 行丢 186 行
- 预览在库副本上真跑一遍导入,报告与真导同源;导入前 VACUUM INTO 备份,单事务写入
- 新权限点 data.transfer(仅超管组),导出/导入均写审计
- 前端「数据迁移」页:按类别勾选导出、上传后预览逐表明细、确认导入;移除设置里的静态占位「数据交换」
- 测试:单测 7 项(含目录外键顺序、BLOB、重复导入、包被改坏)、API 端到端 1 项(两个真实实例互传)
- 用真实库副本彩排:3854 行 0 失败,29 张业务表行数全部一致,包 1.22 MB
2026-10-23 13:46:26 +08:00
Drsakura 1ffcc1ea07 feat: add admin web updates and verified username changes
Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming.

Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
2026-10-22 17:24:19 +08:00
DrsakuraandClaude Opus 5 dc607e4ea3 perf(static): 带 hash 的前端资源缓存一年,index.html 不缓存
线上实测 /assets/index-u1PUen4p.js 返回的是 express.static 的默认
Cache-Control: public, max-age=0 —— 每开一次页面就要为每个 JS/CSS 跑一趟
回源校验。而这些文件名里本来就带内容 hash,内容一变文件名就变。

分两档:
  assets/*    max-age=31536000, immutable(连 304 都不用问)
  index.html  no-cache

index.html 必须不缓存:它文件名固定、里面引用的才是带 hash 的资源名。
缓存住的话发了新版用户还拿着旧 index.html、指向旧资源,等于白更新。

SPA fallback 那条单独加了一遍 no-cache —— 它走 sendFile,不经过
express.static 的 setHeaders,/orders 这类深链拿到的同样是 index.html。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-22 16:17:44 +08:00
42 changed files with 4137 additions and 635 deletions
+17
View File
@@ -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
+17 -2
View File
@@ -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
View File
@@ -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');
+10
View File
@@ -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
View File
@@ -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,
+373
View File
@@ -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
}
+151
View File
@@ -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
}
+325
View File
@@ -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 }
+141
View File
@@ -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
}
+6 -3
View File
@@ -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']
}
]
+149
View File
@@ -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; });
}
+2 -5
View File
@@ -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)
+83
View File
@@ -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 };
+28 -535
View File
@@ -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"
}
}
}
}
+4 -6
View File
@@ -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
View File
@@ -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);
+224
View File
@@ -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
+4 -1
View File
@@ -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: '新建权限组',
+39 -3
View File
@@ -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) {
+162
View File
@@ -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
View File
@@ -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'));
});
// 启动服务器
+249
View File
@@ -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()
}
})
+208
View File
@@ -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), /校验失败/)
})
+224
View File
@@ -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'), '');
});
});
+76
View File
@@ -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, /自动恢复失败/);
});
}
+165
View File
@@ -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` 会校验顺序与系统表是否被误导出。
+62
View File
@@ -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,不会随用户名变化。
初始管理员只在用户表为空时创建;管理员改名之后,重新启动不会重新生成默认账号。登录页面不展示默认用户名或密码。
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-management-frontend",
"version": "1.1.0",
"version": "1.3.0",
"description": "外贸供应链管理系统 - 前端",
"type": "module",
"scripts": {
+2
View File
@@ -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>
+9 -5
View File
@@ -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')
+74 -19
View File
@@ -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>
)
}
+1
View File
@@ -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>
+5 -1
View File
@@ -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'
}
+137 -1
View File
@@ -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}
+98 -11
View File
@@ -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
+522
View File
@@ -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
-1
View File
@@ -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>
+45 -4
View File
@@ -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}>
{[
+282
View File
@@ -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);
}
}
+46
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "happy-export",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
"repository": {