feat: 生产化改造(服务端安装程序/PWA/Docker+HTTPS)并修复侧边栏点击无反应、SPA 静态托管 dotfiles 两个缺陷

- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用
- 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500
- 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、
  /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件
- Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用
- 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据)
- 新增 Docker/Compose/Nginx 部署样例与 docs/production.md
- 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
This commit is contained in:
LoveChengke committed 2026-09-12 00:20:21 +08:00
1 parent 6e4c7c1050
commit 5545ef6f55
46 files changed
+3020 -152

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import type { RequestHandler } from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import { env } from '../config/env.js';
import { logger } from '../lib/logger.js';
/**
* 安全响应头(helmet)。
*
* CSP 说明:
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
*/
export function securityHeaders(): RequestHandler {
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
if (Array.isArray(env.corsOrigins)) {
for (const origin of env.corsOrigins) connectSrc.add(origin);
}
return helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'data:'],
'connect-src': [...connectSrc],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
// 允许桌面客户端等跨源场景读取静态资源与接口响应
crossOriginResourcePolicy: { policy: 'cross-origin' },
referrerPolicy: { policy: 'no-referrer' },
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
});
}
/** 通用接口限流:防止单 IP 高频刷接口 */
export function apiRateLimiter(): RequestHandler {
const limiter = rateLimit({
windowMs: 5 * 60 * 1000,
limit: env.isProduction ? 600 : 3000,
standardHeaders: 'draft-7',
legacyHeaders: false,
skip: () => !env.rateLimitEnabled,
handler: (req, res) => {
logger.warn(`触发接口限流:${req.ip ?? '-'} ${req.method} ${req.originalUrl}`);
res.status(429).json({
success: false,
data: null,
message: '请求过于频繁,请稍后再试',
code: 'TOO_MANY_REQUESTS',
});
},
});
return limiter;
}
/** 登录接口专用限流:防暴力破解 */
export function loginRateLimiter(): RequestHandler {
return rateLimit({
windowMs: 10 * 60 * 1000,
limit: env.isProduction ? 20 : 200,
standardHeaders: 'draft-7',
legacyHeaders: false,
skipSuccessfulRequests: true,
skip: () => !env.rateLimitEnabled,
handler: (req, res) => {
logger.warn(`登录尝试过于频繁:${req.ip ?? '-'}`);
res.status(429).json({
success: false,
data: null,
message: '登录尝试过于频繁,请 10 分钟后再试',
code: 'TOO_MANY_REQUESTS',
});
},
});
}
/** 请求耗时日志(生产 info,开发 debug) */
export function requestLogger(): RequestHandler {
return (req, res, next) => {
const startedAt = process.hrtime.bigint();
res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`;
if (res.statusCode >= 500) logger.error(message);
else if (res.statusCode >= 400) logger.warn(message);
else if (env.isProduction) logger.info(message);
else logger.debug(message);
});
next();
};
}