diff --git a/.gitignore b/.gitignore index 67173cf..e782dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ # ---------- 构建产物 ---------- diff --git a/README.md b/README.md index ae18eeb..96502ec 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ ### 安装依赖 +需要 Node.js 22 或更高版本。 + ```bash # 后端 cd backend -npm install +npm install --ignore-scripts # 前端 cd frontend @@ -129,12 +131,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 部分完成,未完成项保留为待办。 diff --git a/backend/db/db.js b/backend/db/db.js index f9ef39c..ed09455 100644 --- a/backend/db/db.js +++ b/backend/db/db.js @@ -276,12 +276,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'); diff --git a/backend/lib/auth.js b/backend/lib/auth.js index de7c952..df534dc 100644 --- a/backend/lib/auth.js +++ b/backend/lib/auth.js @@ -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, diff --git a/backend/lib/updateWorker.js b/backend/lib/updateWorker.js new file mode 100644 index 0000000..5cf1efe --- /dev/null +++ b/backend/lib/updateWorker.js @@ -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; }); +} diff --git a/backend/lib/updater.js b/backend/lib/updater.js index fe949ec..ffad44c 100644 --- a/backend/lib/updater.js +++ b/backend/lib/updater.js @@ -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) diff --git a/backend/lib/webUpdate.js b/backend/lib/webUpdate.js new file mode 100644 index 0000000..9c85f35 --- /dev/null +++ b/backend/lib/webUpdate.js @@ -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 }; diff --git a/backend/package-lock.json b/backend/package-lock.json index acbcd52..f0a542b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,22 +1,19 @@ { "name": "trade-management-backend", - "version": "1.0.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trade-management-backend", - "version": "1.0.0", + "version": "1.1.1", "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" - } } } } diff --git a/backend/package.json b/backend/package.json index ad47502..2c0e7c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,8 @@ "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" } } diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 36e8551..c5ab536 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -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); diff --git a/backend/routes/logs.js b/backend/routes/logs.js index 30853e0..f2125f9 100644 --- a/backend/routes/logs.js +++ b/backend/routes/logs.js @@ -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: '新建权限组', diff --git a/backend/routes/system.js b/backend/routes/system.js index 9365e13..ba9c922 100644 --- a/backend/routes/system.js +++ b/backend/routes/system.js @@ -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) { diff --git a/backend/server.js b/backend/server.js index cc47b88..0a43e0f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 { + 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,跟支出明细页的合计口径一致。 */ @@ -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 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 }); @@ -925,6 +929,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 @@ -1226,7 +1231,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' }, @@ -1258,9 +1263,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(' · '); @@ -1694,18 +1722,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, diff --git a/backend/test/regressions.test.js b/backend/test/regressions.test.js new file mode 100644 index 0000000..0d3a7b9 --- /dev/null +++ b/backend/test/regressions.test.js @@ -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', 'scope-other@example.test']) { + 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'), ''); + }); +}); diff --git a/backend/test/web-update.test.js b/backend/test/web-update.test.js new file mode 100644 index 0000000..28fc074 --- /dev/null +++ b/backend/test/web-update.test.js @@ -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, /自动恢复失败/); + }); +} diff --git a/docs/web-updates.md b/docs/web-updates.md new file mode 100644 index 0000000..f120e54 --- /dev/null +++ b/docs/web-updates.md @@ -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 +/system-update/<任务ID>/update.log +/system-update/<任务ID>/trade-before-update.db +/system-update/status.json +``` + +日志与备份不会自动过期,管理员可按自己的备份策略清理。状态接口只向管理员返回日志末尾 12 KB。 + +自动恢复只回退应用代码与依赖,不自动覆盖业务数据库,以保留更新期间的业务写入。后续发布应保持数据库迁移向后兼容;如果新版本迁移了不兼容的数据结构,需要管理员停服后从备份恢复数据库,恢复时同时处理旧 WAL/SHM。 + +意外断电或更新任务被终止后,网页会识别任务已停止并提示失败。若残留 `backend/node_modules.update-old` 或 `frontend/dist.update-old`,新任务会停止并提示人工检查;先确认服务实际运行版本和数据库,再决定恢复或清理这些目录,避免覆盖恢复所需文件。 + +Windows 本地环境支持检查更新,安装按钮会提示只支持 Linux systemd 部署。 + +## 用户名修改 + +「个人资料 → 修改用户名」输入新用户名和当前密码即可。新用户名为 3–50 位字母、数字或 `_ . @ -`,以字母、数字或下划线开头;不能与其他用户的用户名重名(不区分大小写)。修改保留当前会话,其他会话失效,之后用新用户名登录。业务归属使用用户 ID,不会随用户名变化。 + +初始管理员只在用户表为空时创建;管理员改名之后,重新启动不会重新生成默认账号。登录页面不展示默认用户名或密码。 diff --git a/frontend/src/amountInWords.js b/frontend/src/amountInWords.js index e3f0658..f1a8ef5 100644 --- a/frontend/src/amountInWords.js +++ b/frontend/src/amountInWords.js @@ -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') diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index 33dc692..f6a1b56 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -148,6 +148,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 +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) => { if (!value) return '' try { @@ -205,7 +243,7 @@ function SystemUpdateSettings() { return (

系统与更新

-

查看当前版本与运行环境,并从官方仓库检查是否有新版本。

+

查看当前版本;系统管理员可在此安装更新。

当前版本

@@ -229,16 +267,16 @@ function SystemUpdateSettings() { value={repo} placeholder="owner/repo" onChange={(e) => setRepo(e.target.value)} - disabled={checking} + disabled />
-

默认抓取官方仓库,也支持填完整地址(https://github.com/owner/repo)。

+

更新源由服务器配置,网页安装使用此仓库的最新正式发布版本或版本标签。

检查更新

-
@@ -300,6 +338,33 @@ function SystemUpdateSettings() { )}
+ {isAdmin && ( +
+

安装更新

+

系统先构建新版本并备份数据库,再切换应用和重启。重启时页面可能短暂断开,进度会自动恢复。

+ {job?.capability && !job.capability.supported &&

{job.capability.reason}

} + {result?.hasUpdate && job?.capability?.supported && !updating && !(job?.state === 'completed' && job?.version === result.latest) && ( +
+
+ + setUpdatePassword(event.target.value)} /> +
+ +
+ )} + {job && job.state !== 'idle' && ( +
+ {updating && }{job.message} +
+ )} + {connectionLost &&

暂时无法连接服务,正在自动重试…

} + {job?.state === 'completed' && } + {job?.log &&
更新日志
{job.log}
} +
+ )} ) } diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 4b3e85f..d904ed4 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -95,7 +95,6 @@ function LoginPage() { {busy ? '登录中…' : '登 录'} -

首次使用默认账号 admin,登录后请在「个人资料」里修改密码。

{brand.name} · 外贸供应链管理系统

diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index a210529..7bf8b7d 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -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, - role: payload.role, - org_id: payload.org_id === '' ? undefined : Number(payload.org_id) + ...(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() {
- setField('org_id', event.target.value)}> {organizations.map(org => ( @@ -267,7 +289,7 @@ function ProfilePage() {
- setField('role', event.target.value)}> {roles.map(role => ( ))} @@ -293,6 +315,25 @@ function ProfilePage() { {/* ---------------- 修改密码 ---------------- */}
+
修改用户名
+
+

当前用户名:{user?.username}。修改后请使用新用户名登录,业务数据归属保持不变。

+
+ + setUsername(event.target.value)} placeholder="3–50 位字母、数字或 _ . @ -" /> +
+
+ + setUsernamePassword(event.target.value)} placeholder="输入当前密码以验证身份" /> +
+ {usernameNotice &&

{usernameNotice.text}

} + +
+
修改密码
{[