cangku/scripts/start-remote.mjs
lunanting b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

稳定性:
- 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500
- 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML
- 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗
- 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00

329 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
/**
* 解析 ssh 可执行文件路径。
* 隧道子进程是按「裸命令名 ssh」拉起的,依赖 PATH —— 而计划任务 / 非交互式
* 启动时 PATH 往往不含 OpenSSH 目录,隧道就会直接 spawn ENOENT,
* 表现为「服务起不来但日志没线索」。这里改成绝对路径优先,找不到再退回裸名。
*/
function resolveSshBinary() {
const candidates = [
process.env.CANGKU_SSH,
'C:\\Windows\\System32\\OpenSSH\\ssh.exe',
'C:\\Program Files\\OpenSSH-Win64\\ssh.exe',
'C:\\Program Files (x86)\\OpenSSH-Win64\\ssh.exe',
].filter(Boolean);
for (const candidate of candidates) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
/* 忽略不可访问的候选路径 */
}
}
return 'ssh';
}
const SSH_BINARY = resolveSshBinary();
/**
* 解析后端实际监听端口。
* 优先级: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;
}
/**
* 隧道重连退避:1s → 2s → 4s … 封顶 30s。
* 首次失败也立即重试(等价 autossh 的 AUTOSSH_GATETIME=0),避免网络尚未就绪时白等。
*/
const TUNNEL_RETRY_BASE_MS = 1000;
const TUNNEL_RETRY_MAX_MS = 30000;
const TUNNEL_RETRY_EXPONENT_CAP = 10;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function computeTunnelRetryDelay(attempt) {
const exponent = Math.min(attempt, TUNNEL_RETRY_EXPONENT_CAP);
return Math.min(TUNNEL_RETRY_BASE_MS * 2 ** exponent, TUNNEL_RETRY_MAX_MS);
}
export function buildSshArgs(config) {
// 隧道稳定性:这条隧道承载「系统配置库」,一旦掉线监督器会连带重启后端
// (约 20~90 秒不可用)。日志里高发的 `client_loop: send disconnect: Connection reset`
// 属于长时间空闲被中间设备掐断,因此这里缩短保活探测间隔、开启 TCPKeepAlive,
// 并给建连加超时,避免连接挂在半死状态。
return [
'-N', '-T',
'-o', 'BatchMode=yes',
// 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止;
// 真正的根治是下面的退避重连循环。
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=15',
'-o', 'ServerAliveCountMax=3',
'-o', 'TCPKeepAlive=yes',
'-o', 'ConnectTimeout=10',
'-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);
}
let tunnelRetryAttempt = 0;
let resolveTunnelReady = null;
const tunnelReady = new Promise((resolve) => {
resolveTunnelReady = resolve;
});
/**
* 启动一条 SSH 隧道并等它监听就绪。
* 返回 { tunnel, exitPromise };隧道结束由 exitPromise 上报退出码,交给
* superviseTunnel 决定重连 —— 这里**不再**牵连后端/前端。
*/
const startTunnel = async () => {
const { value: config } = loadConnectionConfig(ROOT_DIR);
const tunnel = spawn(SSH_BINARY, buildSshArgs(config), {
cwd: ROOT_DIR,
stdio: 'inherit',
// Windows 下必须显式隐藏:父进程没有控制台时,spawn 一个控制台程序
// 会让系统新分配一个控制台窗口(就是那个反复弹出的 ssh.exe 黑窗)。
// 隧道每 60 秒重连一次,不加这个就会一直弹。
windowsHide: true,
});
children.add(tunnel);
const exitPromise = new Promise((resolve) => {
tunnel.once('exit', (code) => {
children.delete(tunnel);
resolve(code);
});
});
// 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection
const outcome = await Promise.race([
waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })),
exitPromise.then((code) => ({ ready: false, code })),
]);
if (!outcome.ready) {
if (!tunnel.killed) tunnel.kill('SIGTERM');
throw new Error(`SSH 隧道在建立前退出(${outcome.code ?? 'unknown'})`);
}
console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`);
return { tunnel, exitPromise };
};
/**
* 隧道自愈循环(Windows 版 autossh 的语义)。
*
* 隧道断开只重连隧道本身,后端/前端进程保持不动。这是原先缺失的容错:
* 旧实现在隧道 exit 时直接 stopChildren(),把整个开发栈一起杀掉,于是每一次
* 远端 RST 都升级成一次「网站打不开」,还得靠外部守护清残留、整树重启。
*
* 建立失败走指数退避(1s→30s);建立成功后重置退避,断开则固定 1s 后重连。
*/
const superviseTunnel = async () => {
for (;;) {
if (shuttingDown) return;
let session;
try {
session = await startTunnel();
tunnelRetryAttempt = 0;
if (resolveTunnelReady) {
resolveTunnelReady();
resolveTunnelReady = null;
}
} catch (error) {
if (shuttingDown) return;
const delay = computeTunnelRetryDelay(tunnelRetryAttempt);
tunnelRetryAttempt += 1;
console.error(`SSH 隧道建立失败(${error.message}),${delay}ms 后重试`);
await sleep(delay);
continue;
}
const code = await session.exitPromise;
if (shuttingDown) return;
console.error(`SSH 隧道已退出(${code ?? 'unknown'}),正在重连…(后端/前端不受影响)`);
await sleep(TUNNEL_RETRY_BASE_MS);
}
};
// 守护循环本身理论上不抛错,兜底仍保留:真异常时按老路径收尾,避免留下半死进程
const startTunnelSupervisor = () => {
superviseTunnel().catch((error) => {
console.error(`隧道守护异常终止:${error.message}`);
stopChildren();
process.exitCode = 1;
});
};
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',
// 同理:避免 cmd.exe / node 弹出控制台窗口
windowsHide: true,
});
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);
startTunnelSupervisor();
await tunnelReady;
} 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);
startTunnelSupervisor();
await tunnelReady;
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;
});
}