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.
This commit is contained in:
Drsakura
2026-10-22 17:24:19 +08:00
parent dc607e4ea3
commit 1ffcc1ea07
20 changed files with 905 additions and 585 deletions
+4
View File
@@ -1,5 +1,9 @@
# ---------- 依赖 ---------- # ---------- 依赖 ----------
node_modules/ node_modules/
backend/node_modules.update-new/
backend/node_modules.update-old/
frontend/dist.update-new/
frontend/dist.update-old/
.pnpm-store/ .pnpm-store/
# ---------- 构建产物 ---------- # ---------- 构建产物 ----------
+12 -2
View File
@@ -47,10 +47,12 @@
### 安装依赖 ### 安装依赖
需要 Node.js 22 或更高版本。
```bash ```bash
# 后端 # 后端
cd backend cd backend
npm install npm install --ignore-scripts
# 前端 # 前端
cd frontend cd frontend
@@ -129,12 +131,20 @@ SQLite 单文件存储:
| --- | --- | --- | | --- | --- | --- |
| `DATA_DIR` | `backend/data` | 数据库与汇率缓存所在目录 | | `DATA_DIR` | `backend/data` | 数据库与汇率缓存所在目录 |
| `PORT` | `4300` | 后端监听端口 | | `PORT` | `4300` | 后端监听端口 |
| `SECURE_COOKIE` | `0` | HTTPS 部署设为 `1`,为会话 Cookie 添加 `Secure`;本地 HTTP 开发保持默认 |
| `CONTRACT_RETENTION_DAYS` | `7` | 合同导入原文件的保留天数 | | `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` 文件。 > 注意:后端**没有引入 dotenv**,不会自动读取 `.env` 文件。
> 请通过系统环境变量、启动脚本或容器编排来设置上述变量。 > 请通过系统环境变量、启动脚本或容器编排来设置上述变量。
## 网页更新与账号
管理员可在「设置 → 系统与更新」输入当前密码安装新版本,查看更新进度和日志。支持 Linux systemd 部署,包含构建、数据库备份、服务重启及失败后的应用恢复;首次需要部署包含此功能的版本。详见 [网页更新配置与恢复说明](docs/web-updates.md)。
所有用户可在「个人资料 → 修改用户名」验证当前密码后修改自己的登录名;业务数据归属不变,其它设备需重新登录。
## 开发计划 ## 开发计划
Phase 1 至 Phase 3 已完成;Phase 4、Phase 5 部分完成,未完成项保留为待办。 Phase 1 至 Phase 3 已完成;Phase 4、Phase 5 部分完成,未完成项保留为待办。
+3 -3
View File
@@ -276,12 +276,12 @@ function ensureColumns() {
ensureColumns(); ensureColumns();
// 创建默认管理员账号(如果不存在) // 只为空库创建初始管理员;改名后重启不能重新生成默认账号。
function createDefaultAdmin() { function createDefaultAdmin() {
const crypto = require('crypto'); 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 // 生成默认密码哈希:admin123
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync('admin123', salt, 64).toString('hex'); const hash = crypto.scryptSync('admin123', salt, 64).toString('hex');
+21 -2
View File
@@ -41,6 +41,22 @@ function verifyPassword(plain, stored) {
return crypto.timingSafeEqual(a, b); 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 */ /* ---------------------------------------------------------------- cookie */
function parseCookies(header) { function parseCookies(header) {
@@ -62,12 +78,13 @@ function parseCookies(header) {
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) { function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
res.append('Set-Cookie', [ 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('; ')); ].join('; '));
} }
function clearSessionCookie(res) { function clearSessionCookie(res) {
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); setSessionCookie(res, '', 0);
} }
function clientIp(req) { function clientIp(req) {
@@ -110,6 +127,7 @@ function sessionUser(req) {
return null; return null;
} }
if (!row.is_active) return null; if (!row.is_active) return null;
if (row.locked_until && Date.parse(row.locked_until) > Date.now()) return null;
return row; return row;
} }
@@ -210,6 +228,7 @@ module.exports = {
LOCK_MS, LOCK_MS,
hashPassword, hashPassword,
verifyPassword, verifyPassword,
verifyCurrentPassword,
parseCookies, parseCookies,
setSessionCookie, setSessionCookie,
clearSessionCookie, clearSessionCookie,
+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 出口通 —— 系统版本检查 * happy 出口通 —— 系统版本检查
* *
* 与 sku-manager 的 updater 不同,这里**只做检查,不做自动替换**: * 本模块检查 GitHub Release/tag;网页安装入口见 webUpdate.js,
* happy 是整体应用(前端 dist + 后端服务 + 数据库),更新应当走 * 独立更新任务见 updateWorker.js(构建、备份、切换与重启)。
* `git pull` + 重新构建 + 重启服务,而不是像 sku-manager 那样下载 zip
* 换版本目录。所以本模块只负责回答一个问题:
* 远端仓库的版本号 vs 本机版本号,谁新?
* *
* 版本来源,按优先级: * 版本来源,按优先级:
* 1. GitHub Releases 的 latest.tag_name —— 推荐用法(打 tag 并发 Release) * 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", "name": "trade-management-backend",
"version": "1.0.0", "version": "1.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trade-management-backend", "name": "trade-management-backend",
"version": "1.0.0", "version": "1.1.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"better-sqlite3-multiple-ciphers": "^13.0.3", "better-sqlite3-multiple-ciphers": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "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" "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"nodemon": "^3.0.1"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -32,20 +29,6 @@
"node": ">= 0.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": { "node_modules/append-field": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -58,16 +41,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/better-sqlite3-multiple-ciphers": {
"version": "13.0.3", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-13.0.3.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-13.0.3.tgz",
@@ -80,19 +53,6 @@
"node": ">=22" "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": { "node_modules/body-parser": {
"version": "1.20.8", "version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@ -117,54 +77,6 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -214,46 +126,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -290,12 +162,6 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -416,9 +282,9 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
@@ -440,9 +306,9 @@
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.15.1", "qs": "~6.16.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -461,19 +327,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -510,21 +363,6 @@
"node": ">= 0.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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -571,19 +409,6 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -596,16 +421,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -662,13 +477,6 @@
"node": ">=0.10.0" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -684,58 +492,6 @@
"node": ">= 0.10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -805,43 +561,6 @@
"node": ">= 0.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": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -849,22 +568,21 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "1.4.5-lts.2", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"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.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.0.0", "busboy": "^1.6.0",
"concat-stream": "^1.5.2", "type-is": "^1.6.18"
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">= 6.0.0" "node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/negotiator": { "node_modules/negotiator": {
@@ -885,70 +603,6 @@
"node": "^18 || ^20 || >= 21" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -997,29 +651,10 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"forwarded": "0.2.0", "forwarded": "0.2.0",
@@ -1027,19 +662,16 @@
}, },
"engines": { "engines": {
"node": ">= 0.10" "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": { "node_modules/qs": {
"version": "6.15.3", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
@@ -1076,40 +708,6 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1136,19 +734,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1272,19 +857,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1302,47 +874,6 @@
"node": ">=10.0.0" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1352,16 +883,6 @@
"node": ">=0.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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1375,19 +896,6 @@
"node": ">= 0.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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1397,12 +905,6 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1432,15 +934,6 @@
"engines": { "engines": {
"node": ">=0.8" "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"
}
} }
} }
} }
+3 -5
View File
@@ -5,7 +5,8 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js" "dev": "node --watch server.js",
"test": "node --test"
}, },
"keywords": [ "keywords": [
"trade", "trade",
@@ -18,10 +19,7 @@
"better-sqlite3-multiple-ciphers": "^13.0.3", "better-sqlite3-multiple-ciphers": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "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" "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); 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) { if (req.body?.role !== undefined) {
const role = String(req.body.role || '').trim(); const role = String(req.body.role || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内'); 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 —— 改密码:三段校验全过才落库 */ /** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
router.patch('/me/password', (req, res) => { router.patch('/me/password', (req, res) => {
try { try {
@@ -163,10 +193,7 @@ router.patch('/me/password', (req, res) => {
if (next.length < 6) throw badRequest('新密码至少 6 位'); if (next.length < 6) throw badRequest('新密码至少 6 位');
if (next !== confirm) throw badRequest('两次输入的新密码不一致'); if (next !== confirm) throw badRequest('两次输入的新密码不一致');
if (next === current) throw badRequest('新密码不能与当前密码相同'); if (next === current) throw badRequest('新密码不能与当前密码相同');
if (!auth.verifyPassword(current, req.user.password_hash)) { auth.verifyCurrentPassword(req, current);
auth.audit({ username: req.user.username, action: 'password_change_failed', detail: '当前密码不正确', req, ok: 0 });
throw badRequest('当前密码不正确');
}
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id); .run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
+4 -1
View File
@@ -23,7 +23,7 @@ const router = express.Router();
* 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。 * 不会丢记录 —— 宁可直接显示,也不要因为漏配而看不见。
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
const CATEGORIES = [ 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: 'user', label: '用户', match: /^user_/ },
{ key: 'permission', label: '权限', match: /^permission_group_/ }, { key: 'permission', label: '权限', match: /^permission_group_/ },
{ key: 'org', label: '组织', match: /^(organization_|branding_)/ }, { key: 'org', label: '组织', match: /^(organization_|branding_)/ },
@@ -43,6 +43,9 @@ const ACTION_LABELS = {
profile_update: '更新个人资料', profile_update: '更新个人资料',
password_change: '修改密码', password_change: '修改密码',
password_change_failed: '修改密码失败', password_change_failed: '修改密码失败',
password_verify_failed: '身份密码验证失败',
username_change: '修改用户名',
system_update: '启动系统更新',
user_create: '新建用户', user_create: '新建用户',
user_update: '更新用户', user_update: '更新用户',
permission_group_create: '新建权限组', permission_group_create: '新建权限组',
+39 -3
View File
@@ -5,8 +5,44 @@
const express = require('express'); const express = require('express');
const updater = require('../lib/updater'); const updater = require('../lib/updater');
const branding = require('../lib/branding'); 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(); 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 次配额,连续点「检查更新」不该把额度刷完。 // 简易缓存:GitHub 未认证请求每小时只有 60 次配额,连续点「检查更新」不该把额度刷完。
const CACHE_TTL_MS = 30 * 1000; const CACHE_TTL_MS = 30 * 1000;
@@ -16,7 +52,7 @@ let cache = { key: '', at: 0, payload: null };
* GET /api/system/info * GET /api/system/info
* 当前版本与运行环境。轻量、不发外部请求,设置页打开时就能调。 * 当前版本与运行环境。轻量、不发外部请求,设置页打开时就能调。
*/ */
router.get('/info', (req, res) => { router.get('/info', auth.requireAuth, (req, res) => {
try { try {
res.json(updater.systemInfo()); res.json(updater.systemInfo());
} catch (error) { } catch (error) {
@@ -28,9 +64,9 @@ router.get('/info', (req, res) => {
* GET /api/system/check-update?repo=owner/repo&force=1 * GET /api/system/check-update?repo=owner/repo&force=1
* 对比远端仓库版本与本机版本。repo 省略时用默认仓库(Drsakura/happy-export)。 * 对比远端仓库版本与本机版本。repo 省略时用默认仓库(Drsakura/happy-export)。
*/ */
router.get('/check-update', async (req, res) => { router.get('/check-update', auth.requireAuth, async (req, res) => {
try { 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'; const force = String(req.query.force || '') === '1';
if (!force && cache.payload && cache.key === repo && Date.now() - cache.at < CACHE_TTL_MS) { if (!force && cache.payload && cache.key === repo && Date.now() - cache.at < CACHE_TTL_MS) {
+39 -9
View File
@@ -766,8 +766,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 { 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 看不出赚没赚 —— /* 带上杂费合计:订单列表上只看 total_amount 看不出赚没赚 ——
快递费吊卡费加起来几百块,小单子能把毛利吃掉一截。 快递费吊卡费加起来几百块,小单子能把毛利吃掉一截。
逐行取两位再 SUM,跟支出明细页的合计口径一致。 */ 逐行取两位再 SUM,跟支出明细页的合计口径一致。 */
@@ -782,8 +785,9 @@ app.get('/api/orders', (req, res) => {
(SELECT COUNT(*) FROM order_expenses e WHERE e.order_id = o.id) AS expense_count (SELECT COUNT(*) FROM order_expenses e WHERE e.order_id = o.id) AS expense_count
FROM orders o FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id LEFT JOIN customers c ON o.customer_id = c.id
${orderFilter}
ORDER BY o.order_date DESC ORDER BY o.order_date DESC
`).all(); `).all(...orderParams);
res.json(orders); res.json(orders);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -925,6 +929,7 @@ app.get('/api/orders/:id/expenses', auth.requirePermission('order.view'), (req,
try { try {
const order = getOrder(req.params.id); const order = getOrder(req.params.id);
if (!order) return res.status(404).json({ error: '订单不存在' }); if (!order) return res.status(404).json({ error: '订单不存在' });
if (!canTouchOrderExpense(req.user, order)) return res.status(403).json({ error: '这张订单不在你名下' });
const expenses = db.prepare(` const expenses = db.prepare(`
SELECT e.*, s.name AS supplier_name, u.display_name AS created_by_name SELECT e.*, s.name AS supplier_name, u.display_name AS created_by_name
FROM order_expenses e FROM order_expenses e
@@ -1226,7 +1231,7 @@ app.post('/api/assistant/command', async (req, res) => {
// 全局搜索:仅索引业务表的非敏感字段,查询参数使用绑定变量。 // 全局搜索:查询参数使用绑定变量;客户及其关联记录必须遵守客户归属范围。
const SEARCH_SOURCES = [ 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: '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' }, { table: 'product_groups', type: 'product', typeLabel: '产品分组', title: ['name', 'group_key'], detail: ['brand', 'category', 'notes'], path: '/products' },
@@ -1258,9 +1263,32 @@ app.get('/api/search', (req, res) => {
const columns = existingColumns(source.table); const columns = existingColumns(source.table);
const searchable = [...source.title, ...source.detail].filter(column => columns.has(column)); const searchable = [...source.title, ...source.detail].filter(column => columns.has(column));
if (!searchable.length) continue; if (!searchable.length) continue;
const where = searchable.map(column => `CAST(${column} AS TEXT) LIKE ?`).join(' OR '); const where = searchable.map(column => `CAST(s.${column} AS TEXT) LIKE ?`).join(' OR ');
const selected = ['rowid AS _search_id', ...searchable].join(', '); const selected = ['s.rowid AS _search_id', ...searchable.map(column => `s.${column}`)].join(', ');
const rows = db.prepare(`SELECT ${selected} FROM ${source.table} WHERE ${where} LIMIT 8`).all(...searchable.map(() => pattern)); 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 => { rows.forEach(row => {
const title = source.title.map(column => row[column]).filter(Boolean).join(' · ') || source.typeLabel; 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(' · '); const detail = source.detail.map(column => row[column]).filter(value => value !== undefined && value !== null && value !== '').join(' · ');
@@ -1694,18 +1722,20 @@ app.post('/api/quotes', auth.requirePermission('quote.edit'), (req, res) => {
const provided = pick('unit_price'); const provided = pick('unit_price');
// 前端为了即时预览会自己算一次并传上来;没传就按同一口径在后端补算,兜住 // 前端为了即时预览会自己算一次并传上来;没传就按同一口径在后端补算,兜住
const unitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate); const rawUnitPrice = provided !== null ? provided : priceFromCost(purchasePrice, exchangeRate, markupRate);
if (unitPrice === null || unitPrice < 0) { if (rawUnitPrice === null || rawUnitPrice < 0) {
throw badRequest(`${label}的单价无法确定:请填单价,或同时填齐采购价 / 汇率 / 加价率`); throw badRequest(`${label}的单价无法确定:请填单价,或同时填齐采购价 / 汇率 / 加价率`);
} }
// 行金额必须由单据上实际存储、显示的四位单价计算。
const unitPrice = roundTo(rawUnitPrice, 4);
return { return {
sku, sku,
productName: String(raw?.product_name || sku).trim(), productName: String(raw?.product_name || sku).trim(),
productNameEn: raw?.product_name_en ? String(raw.product_name_en).trim() : null, productNameEn: raw?.product_name_en ? String(raw.product_name_en).trim() : null,
spec: raw?.spec ? String(raw.spec).trim() : null, spec: raw?.spec ? String(raw.spec).trim() : null,
qty, qty,
unitPrice: roundTo(unitPrice, 4), unitPrice,
totalPrice: roundTo(unitPrice * qty, 2), totalPrice: roundTo(unitPrice * qty, 2),
currency, currency,
purchasePrice, exchangeRate, markupRate, purchasePrice, exchangeRate, markupRate,
+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, /自动恢复失败/);
});
}
+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,不会随用户名变化。
初始管理员只在用户表为空时创建;管理员改名之后,重新启动不会重新生成默认账号。登录页面不展示默认用户名或密码。
+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 * 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 结尾,这是单证上的固定套路。 * 1. 整句以 SAY 开头、以 ONLY 结尾,这是单证上的固定套路。
* 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。 * 2. 百位与十位之间用 AND(英式写法),如 TWO HUNDRED AND FIFTY-FIVE。
* 「组」与「组」之间不重复加 AND,避免 ONE THOUSAND AND TWO HUNDRED AND ... 这种啰嗦写法。 * 最后的个位组小于 100 时,前面补 AND,如 ONE THOUSAND AND FIVE。
* 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词, * 3. 货币名固定用复数形式(US DOLLARS / EUROS),因为这里是货币名称而非计量词,
* 单证上不写 ONE US DOLLAR。 * 单证上不写 ONE US DOLLAR。
* 4. 日元等无小数币种不带辅币段;小数位按币种配置取。 * 4. 日元等无小数币种不带辅币段;小数位按币种配置取。
@@ -22,7 +22,7 @@ const ONES = [
'SEVENTEEN', 'EIGHTEEN', 'NINETEEN' 'SEVENTEEN', 'EIGHTEEN', 'NINETEEN'
] ]
const TENS = ['', '', 'TWENTY', 'THIRTY', 'FORTY', 'FIFTY', 'SIXTY', 'SEVENTY', 'EIGHTY', 'NINETY'] 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) rest = Math.floor(rest / 1000)
scale += 1 scale += 1
} }
if (value >= 1000 && value % 1000 > 0 && value % 1000 < 100) {
groups[groups.length - 1] = `AND ${groups[groups.length - 1]}`
}
return groups.join(' ') return groups.join(' ')
} }
@@ -124,6 +127,7 @@ export function amountToWords(amount, currency = 'USD') {
/* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */ /* 先四舍五入到「最小单位」,避免 255.36 这类浮点误差把小数位算成 35.999… */
const total = Math.round(Math.abs(num) * factor) const total = Math.round(Math.abs(num) * factor)
const major = Math.floor(total / factor) const major = Math.floor(total / factor)
if (!Number.isSafeInteger(major)) return ''
const minor = total % factor const minor = total % factor
const parts = [`SAY ${meta.head}`] const parts = [`SAY ${meta.head}`]
@@ -132,8 +136,8 @@ export function amountToWords(amount, currency = 'USD') {
// 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略) // 有辅币时,主币为 0 也要读作 ZERO(单证上不会省略)
if (major === 0) parts.push('ZERO') if (major === 0) parts.push('ZERO')
parts.push('AND') parts.push('AND')
parts.push(integerToWords(minor))
parts.push(meta.centsHead) parts.push(meta.centsHead)
parts.push(integerToWords(minor))
} }
parts.push('ONLY') parts.push('ONLY')
+69 -4
View File
@@ -148,6 +148,12 @@ function ImportExportSettings() {
版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。 版本号以顶层 package.json 为准;远端取 Release(优先)或 tag。
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
function SystemUpdateSettings() { 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 brand = useBranding()
const [info, setInfo] = useState(null) const [info, setInfo] = useState(null)
const [repo, setRepo] = useState('') const [repo, setRepo] = useState('')
@@ -193,6 +199,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) => { const fmtTime = (value) => {
if (!value) return '' if (!value) return ''
try { try {
@@ -205,7 +243,7 @@ function SystemUpdateSettings() {
return ( return (
<div className="settings-section"> <div className="settings-section">
<h3>系统与更新</h3> <h3>系统与更新</h3>
<p className="settings-description">查看当前版本与运行环境,并从官方仓库检查是否有新版本。</p> <p className="settings-description">查看当前版本;系统管理员可在此安装更新。</p>
<div className="settings-group"> <div className="settings-group">
<h4>当前版本</h4> <h4>当前版本</h4>
@@ -229,16 +267,16 @@ function SystemUpdateSettings() {
value={repo} value={repo}
placeholder="owner/repo" placeholder="owner/repo"
onChange={(e) => setRepo(e.target.value)} onChange={(e) => setRepo(e.target.value)}
disabled={checking} disabled
/> />
</div> </div>
<p className="help-text">默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。</p> <p className="help-text">更新源由服务器配置,网页安装使用此仓库的最新正式发布版本或版本标签。</p>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h4>检查更新</h4> <h4>检查更新</h4>
<div className="settings-actions"> <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 ? '检查中…' : '检查更新'} {checking ? '检查中…' : '检查更新'}
</button> </button>
</div> </div>
@@ -300,6 +338,33 @@ function SystemUpdateSettings() {
</> </>
)} )}
</div> </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> </div>
) )
} }
-1
View File
@@ -95,7 +95,6 @@ function LoginPage() {
{busy ? '登录中…' : '登 录'} {busy ? '登录中…' : '登 录'}
</button> </button>
<p className="login-tip">首次使用默认账号 <code>admin</code>,登录后请在「个人资料」里修改密码。</p>
</form> </form>
</div> </div>
<p className="login-copyright">{brand.name} · 外贸供应链管理系统</p> <p className="login-copyright">{brand.name} · 外贸供应链管理系统</p>
+47 -6
View File
@@ -5,7 +5,7 @@ import {
SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon, SaveIcon, KeyIcon, CameraIcon, LogoutIcon, BuildingIcon, ShieldIcon,
EyeIcon, EyeOffIcon, UserIcon EyeIcon, EyeOffIcon, UserIcon
} from '../components/Icons' } from '../components/Icons'
import { useAuth, roleLabel } from '../auth' import { useAuth, roleLabel, hasPermission } from '../auth'
import { setPageTitle } from '../branding' import { setPageTitle } from '../branding'
import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard' import { setUnsavedGuard, clearUnsavedGuard } from '../unsavedGuard'
@@ -60,6 +60,11 @@ function ProfilePage() {
const [notice, setNotice] = useState(null) const [notice, setNotice] = useState(null)
const fileRef = useRef(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 [password, setPassword] = useState({ current: '', next: '', confirm: '' })
const [passwordBusy, setPasswordBusy] = useState(false) const [passwordBusy, setPasswordBusy] = useState(false)
const [passwordNotice, setPasswordNotice] = useState(null) const [passwordNotice, setPasswordNotice] = useState(null)
@@ -108,14 +113,16 @@ function ProfilePage() {
phone: payload.phone, phone: payload.phone,
address: payload.address, address: payload.address,
avatar: payload.avatar, avatar: payload.avatar,
role: payload.role, ...(hasPermission(user, 'user.manage') ? {
org_id: payload.org_id === '' ? undefined : Number(payload.org_id) role: payload.role,
org_id: payload.org_id === '' ? undefined : Number(payload.org_id)
} : {})
}) })
const updated = response.data.user const updated = response.data.user
setUser(updated) setUser(updated)
setSaved(toForm(updated)) setSaved(toForm(updated))
return updated return updated
}, [setUser]) }, [setUser, user])
/* 让外壳在跳转前能问一句「要不要先保存」 */ /* 让外壳在跳转前能问一句「要不要先保存」 */
useEffect(() => { 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 => { const submitPassword = async event => {
event.preventDefault() event.preventDefault()
if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return } if (!password.current) { setPasswordNotice({ type: 'error', text: '请输入当前密码' }); return }
@@ -258,7 +280,7 @@ function ProfilePage() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label><BuildingIcon size={13} /> 所属组织</label> <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> <option value="">未归属</option>
{organizations.map(org => ( {organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option> <option key={org.id} value={org.id}>{org.name}{org.member_count ? `(${org.member_count} 人)` : ''}</option>
@@ -267,7 +289,7 @@ function ProfilePage() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label><ShieldIcon size={13} /> 权限角色</label> <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 => ( {roles.map(role => (
<option key={role.value} value={role.value}>{role.label}</option> <option key={role.value} value={role.value}>{role.label}</option>
))} ))}
@@ -293,6 +315,25 @@ function ProfilePage() {
{/* ---------------- 修改密码 ---------------- */} {/* ---------------- 修改密码 ---------------- */}
<section className="panel"> <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> <div className="panel-title"><KeyIcon size={15} /> 修改密码</div>
<form className="profile-fields profile-fields-single" onSubmit={submitPassword}> <form className="profile-fields profile-fields-single" onSubmit={submitPassword}>
{[ {[