外观
后端框架
后端框架基于 Express 4 搭建,提供完整的中间件管线、参数校验、统一响应格式和错误处理。
服务端的启动入口是 src/bin/www.mts 文件。
src/bin/www.mts 文件内容
typescript
/**
* Module dependencies.
*/
import { createServer } from "node:http";
import app, { applyFavicon, applyWechatMenu } from "#src/app";
import { logger } from "#utils/LogUtils";
import { SERVER_PORT as port, SERVER_HOST, HOME_URL } from "#utils/ConstantUtils";
import { sendSystemEmailSafe } from "#utils/EmailUtils";
import { printNetworkInterfaces } from "#utils/NetworkUtils";
import { websocketRoutes } from "#websockets/index";
/**
* Get port from environment and store in Express.
*/
app.set("port", port);
/**
* Create HTTP server.
*/
logger.info(`Creating server...`);
const server = createServer(app);
logger.info(`Server is created.`);
server.on("upgrade", function upgrade(request, socket, head) {
if (!request.url) {
return;
}
const { pathname } = new URL(request.url, "ws://base.url");
const route = websocketRoutes.find((r) => r.path === pathname);
if (route) {
route.server.handleUpgrade(request, socket, head, function done(ws) {
route.server.emit("connection", ws, request);
});
return;
}
socket.destroy();
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port, SERVER_HOST);
server.on("error", onError);
server.on("listening", onListening);
/**
* Event listener for HTTP server "error" event.
*/
function onError(error: { syscall: string; code: string }): void {
if (error.syscall !== "listen") {
throw error;
}
const bind = `Port ${port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case "EACCES":
logger.error(bind + " requires elevated privileges");
process.exit(1);
break;
case "EADDRINUSE":
logger.error(bind + " is already in use");
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address();
const bind = typeof addr === "string" ? "pipe " + addr : "port " + (addr?.port || "unknown");
logger.info("Listening on " + bind);
printNetworkInterfaces();
logger.info(`[SERVER ADDRESS]: ${JSON.stringify(addr)}`);
void sendSystemEmailSafe({ text: `${HOME_URL}网站已启动,监听端口为${bind}` });
logger.info(`Server is running on port ${port}`);
setTimeout(() => {
applyFavicon().catch((err) => {
logger.error("Failed to applyFavicon", err);
});
applyWechatMenu().catch((err) => {
logger.error("Failed to applyWechatMenu", err);
});
}, 1000);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
其中 Express 应用的定义入口是 src/app.mts 文件。
src/app.mts 文件内容
typescript
import express from "express";
import { renderFile } from "ejs";
import favicon from "serve-favicon";
import cookieParser from "cookie-parser";
import bodyParser from "body-parser";
import bodyParserXml from "body-parser-xml";
import compression from "compression";
import { createProxyMiddleware } from "http-proxy-middleware";
import { isPathExists } from "nsuite";
import routerRoot from "#routes/root";
import { registerRoutesExceptForRoot } from "#routes/index";
import { logger } from "#utils/LogUtils";
import { joinPath } from "#utils/PathUtils";
import {
NODE_ENV,
JWT_SECRET_KEY,
SUB_APPS,
PATH_PUBLIC,
MODE,
SITE_LOGO_PATH,
STATIC_PREFIX,
PATH_VIEWS,
SERVER_HOST,
RATE_LIMIT_PER_15_MINUTES,
SUPPORT_HISTORY_MODE,
} from "#utils/ConstantUtils";
import { responseMiddleware } from "#middlewares/ResponseMiddleware";
import { doScheduleJobs } from "#schedules/index";
import { clientIpMiddleware } from "#middlewares/ClientIpMiddleware";
import { blockIpMiddleware } from "#middlewares/BlockIpMiddleware";
import { rateLimitMiddleware } from "#middlewares/RateLimitMiddleware";
import { sessionMiddleware } from "#middlewares/SessionMiddleware";
import { notFoundMiddleware } from "#middlewares/NotFoundMiddleware";
import { errorMiddleware } from "#middlewares/ErrorMiddleware";
import { createWechatMenu } from "#services/wechat";
import { performanceMiddleware } from "#middlewares/PerformanceMiddleware";
import { helmetMiddleware } from "#middlewares/HelmetMiddleware";
import { imageToBuffer } from "#utils/BufferUtils";
import { initializeDatabase } from "#models/index";
import { corsMiddleware } from "#middlewares/CorsMiddleware";
import { recoverStuckProcessing } from "#services/paperSummarizer";
doScheduleJobs()
.then(() => {
logger.info("定时任务启动成功");
})
.catch((err) => {
logger.error("定时任务启动失败", err);
});
export const applyWechatMenu = async () => {
const SHOULD_CREATE_WECHAT_MENU = false;
if (SHOULD_CREATE_WECHAT_MENU) {
await createWechatMenu();
logger.info("微信菜单创建成功");
}
};
// 初始化数据库表
await initializeDatabase();
// 恢复卡住的处理状态
recoverStuckProcessing().catch((err) => {
logger.error("恢复卡住的处理状态失败", err);
});
const app = express();
// Help secure Express apps by setting HTTP response headers.
app.use(helmetMiddleware);
app.use(corsMiddleware("cross-origin"));
// 如果你的应用运行在反向代理(如Nginx)后面,请设置 trust proxy,以便可以正常通过req.ip获取访问者的ip
// 信任直接连接到应用的第一个代理,否则容易被绕过基于IP的速率限制等访问限制。
app.set("trust proxy", 1);
app.use(clientIpMiddleware());
if (NODE_ENV === "production") {
app.use(blockIpMiddleware());
}
app.use(rateLimitMiddleware(RATE_LIMIT_PER_15_MINUTES));
// view engine setup
app.engine("ejs", renderFile);
app.set("view engine", "ejs");
app.set("views", PATH_VIEWS);
// 设置构建时间戳,用于静态资源缓存控制
// 每次应用启动时生成新的时间戳,确保发版后刷新缓存
app.locals.BUILD_TIMESTAMP = Date.now();
// 固定返回的JSON格式
app.use(responseMiddleware());
// handle favicon
export const applyFavicon = async () => {
const urlFavicon = `${STATIC_PREFIX}${SITE_LOGO_PATH}`;
try {
// 先尝试找CDN中的favicon
const [errBuffer, dataBuffer] = await imageToBuffer({ url: urlFavicon });
if (errBuffer) {
logger.warn(`Favicon not found from url: ${urlFavicon}`, errBuffer);
return;
}
const middlewareFavicon = favicon(dataBuffer.buffer);
app.use(middlewareFavicon);
logger.info(`Favicon applied from url: ${urlFavicon}`);
} catch (e) {
logger.warn(`Favicon not found from url: ${urlFavicon}`, e);
// 如果CDN上未上传favicon则尝试使用本地的favicon
const pathFavicon = joinPath(PATH_PUBLIC, SITE_LOGO_PATH);
if (await isPathExists(pathFavicon)) {
const middlewareFavicon = favicon(pathFavicon);
app.use(middlewareFavicon);
} else {
logger.warn(`Favicon not found: ${pathFavicon}`);
}
}
};
app.use(performanceMiddleware());
bodyParserXml(bodyParser);
app.use(
bodyParser.json({
limit: "100000kb",
}),
);
app.use(
bodyParser.urlencoded({
extended: false,
limit: "100000kb",
}),
);
app.use(
bodyParser.xml({
defaultCharset: "utf-8",
}),
);
app.use(cookieParser(JWT_SECRET_KEY));
app.use(
compression({
threshold: 1024,
filter: (req, res) => {
if (req.headers["x-no-compression"]) {
// 不压缩响应
return false;
}
return compression.filter(req, res);
},
}),
);
const proxyRoute = (routeName: string, target: string) => {
app.use(
routeName,
createProxyMiddleware({
target,
changeOrigin: true,
on: {
proxyReq: (proxyReq, req: ExpressRequest) => {
const body = req.body;
if (body) {
const contentType = req.get("Content-Type") || "";
if (!contentType.includes("multipart/form-data")) {
const bodyData = JSON.stringify(body);
proxyReq.setHeader("Content-Length", Buffer.byteLength(bodyData));
// stream the content
proxyReq.write(bodyData);
}
}
},
},
}),
);
};
proxyRoute("/test/commonrail", `https://test.51gonggui.com/commonrail`);
proxyRoute("/production/commonrail", `https://wx.51gonggui.com/commonrail`);
if (NODE_ENV === "development") {
// #region proxySpa
SUB_APPS.forEach(({ appName, appPort }) => {
proxyRoute(`/${appName}`, `http://${SERVER_HOST}:${appPort}/${appName}`);
});
// #endregion proxySpa
app.get(`/robots.txt`, (_req, res) => {
res.sendFile(joinPath(PATH_PUBLIC, `robots-${MODE}.txt`));
});
} else {
if (SUPPORT_HISTORY_MODE === "1") {
// 对于public/*目录下的【单页应用】,支持history模式(注意:不是单页应用不要用这个函数,比如vitepress的编译产物就不是SPA)
const supportSpaHistoryMode = (routeName: string) => {
app.get(`${routeName}/*`, (req, res) => {
res.sendFile(joinPath(PATH_PUBLIC, `${routeName}/index.html`));
});
};
for (const { appName } of SUB_APPS) {
supportSpaHistoryMode(`/${appName}`);
}
}
}
// #region registerRoutes
app.use("/", sessionMiddleware, routerRoot);
// Express looks up the files in the order in which you set the static directories with the express.static middleware function.
app.use(express.static(joinPath(PATH_PUBLIC)));
registerRoutesExceptForRoot(app);
// #endregion registerRoutes
// catch 404 and forward to error handler
app.use(notFoundMiddleware());
app.use(errorMiddleware());
export default app;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
请求流向
后端框架采用经典的分层架构(Controller → Service → Model),中间件管线覆盖请求处理全流程。
text
请求
↓
中间件
↓
路由
↓
Controller 层(src/controllers/) → HTTP 请求处理
↓
Service 层(src/services/) → 业务逻辑 + 数据访问
↓
Model 层(src/models/) → 数据模型定义
↓
Database(MySQL)1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13