forked from lunanting/cangku
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
- retiredFeatures 移出对应退役名单与清表语句,改为正常保留
- 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
- 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
- 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
- MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)
验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
--ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致
未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
212 lines
6.9 KiB
JavaScript
212 lines
6.9 KiB
JavaScript
import fs from 'node:fs';
|
||
import net from 'node:net';
|
||
import path from 'node:path';
|
||
import process from 'node:process';
|
||
import { spawn } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { loadConnectionConfig } from './remote-access-common.mjs';
|
||
|
||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
|
||
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
|
||
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
|
||
|
||
/**
|
||
* 解析后端实际监听端口。
|
||
* 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。
|
||
* 前端 dev server 的 /api 代理必须指向同一端口,否则请求会被转发到空端口。
|
||
*/
|
||
function resolveBackendPort() {
|
||
const fromEnv = Number(process.env.CANGKU_BACKEND_PORT);
|
||
if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) {
|
||
return fromEnv;
|
||
}
|
||
|
||
const envPath = path.join(ROOT_DIR, 'backend', '.env');
|
||
if (fs.existsSync(envPath)) {
|
||
const matched = fs.readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/mu);
|
||
const fromFile = Number(matched?.[1]);
|
||
if (Number.isInteger(fromFile) && fromFile > 0 && fromFile < 65536) {
|
||
return fromFile;
|
||
}
|
||
}
|
||
|
||
return 4001;
|
||
}
|
||
|
||
export function buildSshArgs(config) {
|
||
return [
|
||
'-N', '-T',
|
||
'-o', 'BatchMode=yes',
|
||
'-o', 'ExitOnForwardFailure=yes',
|
||
'-o', 'ServerAliveInterval=30',
|
||
'-o', 'ServerAliveCountMax=3',
|
||
'-o', 'StrictHostKeyChecking=yes',
|
||
'-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`,
|
||
'-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`,
|
||
'-i', path.join(ACCESS_DIR, 'id_ed25519'),
|
||
'-p', String(config.ssh.port),
|
||
'-L', `127.0.0.1:${config.localDatabasePort}:${config.ssh.remoteDatabaseHost}:${config.ssh.remoteDatabasePort}`,
|
||
`${config.ssh.user}@${config.ssh.host}`,
|
||
];
|
||
}
|
||
|
||
function waitForPort(port, timeoutMs = 60000) {
|
||
const started = Date.now();
|
||
return new Promise((resolve, reject) => {
|
||
const attempt = () => {
|
||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||
socket.once('connect', () => {
|
||
socket.destroy();
|
||
resolve();
|
||
});
|
||
socket.once('error', () => {
|
||
socket.destroy();
|
||
if (Date.now() - started >= timeoutMs) {
|
||
reject(new Error(`SSH 隧道未能在 ${timeoutMs / 1000} 秒内监听端口 ${port}`));
|
||
return;
|
||
}
|
||
setTimeout(attempt, 250);
|
||
});
|
||
};
|
||
attempt();
|
||
});
|
||
}
|
||
|
||
function waitForFile(filePath) {
|
||
return new Promise((resolve) => {
|
||
const timer = setInterval(() => {
|
||
if (fs.existsSync(filePath)) {
|
||
clearInterval(timer);
|
||
resolve();
|
||
}
|
||
}, 500);
|
||
});
|
||
}
|
||
|
||
function waitForExit(child, timeoutMs = 5000) {
|
||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
||
return Promise.race([
|
||
new Promise((resolve) => child.once('exit', resolve)),
|
||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||
]);
|
||
}
|
||
|
||
export async function main(mode = process.argv[2] || 'tunnel') {
|
||
if (!['tunnel', 'dev', 'backend'].includes(mode)) {
|
||
throw new Error(`未知启动模式:${mode}`);
|
||
}
|
||
|
||
const children = new Set();
|
||
let shuttingDown = false;
|
||
let restartingBackend = false;
|
||
let backend = null;
|
||
|
||
const stopChildren = () => {
|
||
shuttingDown = true;
|
||
for (const child of children) {
|
||
if (!child.killed) child.kill('SIGTERM');
|
||
}
|
||
};
|
||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||
process.once(signal, stopChildren);
|
||
}
|
||
|
||
const startTunnel = async () => {
|
||
const { value: config } = loadConnectionConfig(ROOT_DIR);
|
||
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
|
||
children.add(tunnel);
|
||
let ready = false;
|
||
const earlyExit = new Promise((resolve, reject) => {
|
||
tunnel.once('exit', (code) => {
|
||
children.delete(tunnel);
|
||
if (!ready) {
|
||
reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`));
|
||
} else if (!shuttingDown) {
|
||
console.error(`SSH 隧道已退出(${code ?? 'unknown'})`);
|
||
stopChildren();
|
||
process.exitCode = code || 1;
|
||
}
|
||
});
|
||
});
|
||
try {
|
||
await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]);
|
||
ready = true;
|
||
} catch (error) {
|
||
if (!tunnel.killed) tunnel.kill('SIGTERM');
|
||
throw error;
|
||
}
|
||
console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`);
|
||
return tunnel;
|
||
};
|
||
|
||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const startApplication = (args, kind) => {
|
||
const childEnvironment = {
|
||
...process.env,
|
||
CANGKU_REMOTE_SUPERVISOR: 'true',
|
||
};
|
||
if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) {
|
||
childEnvironment.VITE_API_PROXY_TARGET = `http://localhost:${resolveBackendPort()}`;
|
||
}
|
||
const child = spawn(npmCommand, args, {
|
||
cwd: ROOT_DIR,
|
||
stdio: 'inherit',
|
||
env: childEnvironment,
|
||
// Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL
|
||
shell: process.platform === 'win32',
|
||
});
|
||
children.add(child);
|
||
child.once('exit', (code) => {
|
||
children.delete(child);
|
||
if (kind === 'backend' && restartingBackend) return;
|
||
if (!shuttingDown) {
|
||
console.error(`${kind === 'backend' ? '后端' : '前端'}进程已退出(${code ?? 'unknown'})`);
|
||
stopChildren();
|
||
process.exitCode = code || 1;
|
||
}
|
||
});
|
||
return child;
|
||
};
|
||
|
||
const hasConnection = fs.existsSync(CONNECTION_PATH);
|
||
if (hasConnection) {
|
||
if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH);
|
||
await startTunnel();
|
||
} else if (mode === 'tunnel') {
|
||
throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key');
|
||
}
|
||
|
||
if (mode === 'tunnel') return;
|
||
|
||
backend = startApplication(
|
||
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
|
||
'backend'
|
||
);
|
||
if (mode === 'dev') {
|
||
startApplication(['--prefix', 'frontend', 'run', 'dev'], 'frontend');
|
||
}
|
||
|
||
if (!hasConnection) {
|
||
console.log('首次启动:请在安装页面输入配置请求地址和 API Key。');
|
||
await waitForFile(RESTART_MARKER_PATH);
|
||
restartingBackend = true;
|
||
if (!backend.killed) backend.kill('SIGTERM');
|
||
await waitForExit(backend);
|
||
restartingBackend = false;
|
||
fs.unlinkSync(RESTART_MARKER_PATH);
|
||
await startTunnel();
|
||
backend = startApplication(
|
||
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
|
||
'backend'
|
||
);
|
||
}
|
||
}
|
||
|
||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||
main().catch((error) => {
|
||
console.error(`远程运行启动失败:${error.message}`);
|
||
process.exitCode = 1;
|
||
});
|
||
}
|