@myassis/gateway 1.0.82 → 1.0.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +61 -36
- package/dist/middleware/broadcast.js +160 -0
- package/dist/routes/agent.js +4 -2
- package/dist/routes/auth.js +5 -4
- package/dist/services/ServiceManager.js +193 -63
- package/dist/services/WebSocketService.js +110 -47
- package/dist/services/dataService.js +5 -6
- package/dist/services/session/Session.js +137 -3
- package/dist/services/tools/plan.js +1 -10
- package/dist/stores/authStore.js +132 -23
- package/package.json +1 -1
|
@@ -112,65 +112,168 @@ function getStoredPid() {
|
|
|
112
112
|
* - 因此自杀场景改走 process.emit('SIGTERM'),复用 main.ts 已注册的优雅关闭逻辑,
|
|
113
113
|
* 并在响应返回后异步退出,避免 HTTP 调用方读不到结果。
|
|
114
114
|
*/
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
/**
|
|
116
|
+
* 探测进程是否存活(signal 0 不会真的发信号)
|
|
117
|
+
*/
|
|
118
|
+
function isPidAlive(pid) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 0);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 轮询等待指定进程退出,返回是否已退出
|
|
129
|
+
*/
|
|
130
|
+
async function waitForPidExit(pid, timeoutMs) {
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
while (Date.now() - start < timeoutMs) {
|
|
133
|
+
if (!isPidAlive(pid))
|
|
134
|
+
return true;
|
|
135
|
+
await new Promise(r => setTimeout(r, 200));
|
|
136
|
+
}
|
|
137
|
+
return !isPidAlive(pid);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 读取守护进程 PID
|
|
141
|
+
*/
|
|
142
|
+
function getDaemonPid() {
|
|
143
|
+
try {
|
|
144
|
+
if (!fs_1.default.existsSync(GATEWAY_DAEMON_PID_FILE))
|
|
145
|
+
return null;
|
|
146
|
+
// 去除 PowerShell Out-File -Encoding UTF8 写入的 BOM,否则 parseInt 会得到 NaN
|
|
147
|
+
const raw = fs_1.default.readFileSync(GATEWAY_DAEMON_PID_FILE, 'utf8').replace(/^\uFEFF/, '').trim();
|
|
148
|
+
const pid = parseInt(raw, 10);
|
|
149
|
+
return isNaN(pid) ? null : pid;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 结束守护进程(运行 launcher.ps1 的那个 PowerShell)
|
|
157
|
+
*
|
|
158
|
+
* 必须在结束 gateway 之前调用:守护进程每 5s 轮询一次,若发现 gateway 已退出
|
|
159
|
+
* 且此刻停止标记不存在,就会立即重新拉起,表现为「stop 之后网关又活了」。
|
|
160
|
+
*/
|
|
161
|
+
async function stopDaemonProcess() {
|
|
162
|
+
if (process.platform !== 'win32')
|
|
118
163
|
return;
|
|
119
|
-
|
|
120
|
-
if (
|
|
164
|
+
const daemonPid = getDaemonPid();
|
|
165
|
+
if (daemonPid && daemonPid !== process.pid && isPidAlive(daemonPid)) {
|
|
121
166
|
try {
|
|
122
|
-
|
|
167
|
+
process.kill(daemonPid, 'SIGTERM');
|
|
123
168
|
}
|
|
124
169
|
catch { /* ignore */ }
|
|
125
|
-
|
|
126
|
-
setTimeout(() => {
|
|
170
|
+
if (!(await waitForPidExit(daemonPid, 3000))) {
|
|
127
171
|
try {
|
|
128
|
-
process.
|
|
172
|
+
process.kill(daemonPid, 'SIGKILL');
|
|
129
173
|
}
|
|
130
174
|
catch { /* ignore */ }
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}, 100).unref();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// —— 场景 2:停的是别人(孤儿进程 / 旧实例)—— 才真正发信号
|
|
137
|
-
try {
|
|
138
|
-
process.kill(pid, 'SIGTERM');
|
|
139
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
175
|
+
await waitForPidExit(daemonPid, 2000);
|
|
176
|
+
}
|
|
140
177
|
}
|
|
141
|
-
catch { /* ignore */ }
|
|
142
|
-
// 强制终止(如果还没退出)
|
|
143
178
|
try {
|
|
144
|
-
|
|
179
|
+
fs_1.default.unlinkSync(GATEWAY_DAEMON_PID_FILE);
|
|
145
180
|
}
|
|
146
181
|
catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* PID 文件丢失/过期时的兜底:按 exe 路径匹配并结束 gateway 进程
|
|
185
|
+
*
|
|
186
|
+
* PID 文件位于 %TEMP%,可能被系统清理策略删除,此时仅靠 PID 会漏杀。
|
|
187
|
+
* 仅在打包为 exe 时启用:开发模式下 execPath 是 node.exe,按路径匹配会误杀其它 Node 进程。
|
|
188
|
+
*/
|
|
189
|
+
async function stopGatewayByExePath() {
|
|
190
|
+
if (process.platform !== 'win32' || !isPackagedExe())
|
|
191
|
+
return 0;
|
|
192
|
+
const exe = process.execPath;
|
|
193
|
+
const name = path_1.default.basename(exe, path_1.default.extname(exe));
|
|
194
|
+
const q = (v) => v.replace(/'/g, "''");
|
|
147
195
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
catch { /* ignore */ }
|
|
161
|
-
await new Promise(r => setTimeout(r, 500));
|
|
196
|
+
const { stdout } = await execAsync(`powershell -NoProfile -Command "Get-Process -Name '${q(name)}' -ErrorAction SilentlyContinue | ` +
|
|
197
|
+
`Where-Object { $_.Id -ne ${process.pid} -and $_.Path -ieq '${q(exe)}' } | ` +
|
|
198
|
+
`ForEach-Object { $_.Id }"`, { timeout: 10000, windowsHide: true });
|
|
199
|
+
const pids = stdout.split(/\r?\n/)
|
|
200
|
+
.map(line => parseInt(line.trim(), 10))
|
|
201
|
+
.filter(n => !isNaN(n) && n !== process.pid);
|
|
202
|
+
for (const orphan of pids) {
|
|
203
|
+
try {
|
|
204
|
+
process.kill(orphan, 'SIGTERM');
|
|
205
|
+
}
|
|
206
|
+
catch { /* ignore */ }
|
|
207
|
+
if (!(await waitForPidExit(orphan, 3000))) {
|
|
162
208
|
try {
|
|
163
|
-
process.kill(
|
|
209
|
+
process.kill(orphan, 'SIGKILL');
|
|
164
210
|
}
|
|
165
211
|
catch { /* ignore */ }
|
|
166
212
|
}
|
|
167
213
|
}
|
|
214
|
+
return pids.length;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 停止 Gateway 用户进程(优雅终止)
|
|
222
|
+
*
|
|
223
|
+
* 注意:当 pid 指向当前进程自己时,绝不能使用 process.kill(pid)。
|
|
224
|
+
* - Windows 上 Node 的 process.kill 等同 TerminateProcess,无清理机会,
|
|
225
|
+
* server.close() / WebSocket / 文件句柄都不会被释放,会导致后续 exe 解锁超时。
|
|
226
|
+
* - 因此自杀场景改走 process.emit('SIGTERM'),复用 main.ts 已注册的优雅关闭逻辑,
|
|
227
|
+
* 并在响应返回后异步退出,避免 HTTP 调用方读不到结果。
|
|
228
|
+
*
|
|
229
|
+
* 返回:self 表示停的是当前进程(无法在本进程内校验最终结果),
|
|
230
|
+
* stopped 表示已确认目标进程退出。
|
|
231
|
+
*/
|
|
232
|
+
async function stopGatewayProcess() {
|
|
233
|
+
const pid = getStoredPid();
|
|
234
|
+
// 先断开守护进程,否则它会在 gateway 退出后立刻把它重新拉起
|
|
235
|
+
await stopDaemonProcess();
|
|
236
|
+
// —— 场景 1:要停的就是自己 —— 走优雅退出
|
|
237
|
+
if (pid && pid === process.pid) {
|
|
238
|
+
try {
|
|
239
|
+
fs_1.default.unlinkSync(GATEWAY_PID_FILE);
|
|
240
|
+
}
|
|
168
241
|
catch { /* ignore */ }
|
|
242
|
+
// 异步触发,确保当前调用栈(含 HTTP 响应)有机会先返回
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
try {
|
|
245
|
+
process.emit('SIGTERM');
|
|
246
|
+
}
|
|
247
|
+
catch { /* ignore */ }
|
|
248
|
+
// 兜底:5s 内 SIGTERM handler 没把进程关掉,再强退
|
|
249
|
+
setTimeout(() => process.exit(0), 5000).unref();
|
|
250
|
+
}, 100).unref();
|
|
251
|
+
return { stopped: true, self: true };
|
|
252
|
+
}
|
|
253
|
+
// —— 场景 2:停的是别人(CLI 停后台实例 / 孤儿进程)—— 才真正发信号
|
|
254
|
+
let stopped = true;
|
|
255
|
+
if (pid && isPidAlive(pid)) {
|
|
169
256
|
try {
|
|
170
|
-
|
|
257
|
+
process.kill(pid, 'SIGTERM');
|
|
171
258
|
}
|
|
172
259
|
catch { /* ignore */ }
|
|
260
|
+
stopped = await waitForPidExit(pid, 5000);
|
|
261
|
+
if (!stopped) {
|
|
262
|
+
// 优雅关闭超时,强制终止
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pid, 'SIGKILL');
|
|
265
|
+
}
|
|
266
|
+
catch { /* ignore */ }
|
|
267
|
+
stopped = await waitForPidExit(pid, 3000);
|
|
268
|
+
}
|
|
173
269
|
}
|
|
270
|
+
try {
|
|
271
|
+
fs_1.default.unlinkSync(GATEWAY_PID_FILE);
|
|
272
|
+
}
|
|
273
|
+
catch { /* ignore */ }
|
|
274
|
+
// 兜底:清理没被 PID 文件记录到的残留进程
|
|
275
|
+
await stopGatewayByExePath();
|
|
276
|
+
return { stopped, self: false };
|
|
174
277
|
}
|
|
175
278
|
/**
|
|
176
279
|
* 写入 Gateway 启动脚本(用户级,不弹出窗口)
|
|
@@ -226,8 +329,14 @@ function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
|
|
|
226
329
|
'# 守护循环:监控进程状态,异常退出则自动重启',
|
|
227
330
|
'while ($true) {',
|
|
228
331
|
' try {',
|
|
229
|
-
' # 每
|
|
230
|
-
' Start-Sleep -Seconds
|
|
332
|
+
' # 每 1 秒检查一次:轮询间隔过长会错过 stop 写入的停止标记,导致误重启',
|
|
333
|
+
' Start-Sleep -Seconds 1',
|
|
334
|
+
'',
|
|
335
|
+
' # 优先判定停止标记:无论 gateway 是否已退出,主动停止时守护都应该退出',
|
|
336
|
+
' if (Test-Path $stopFlag) {',
|
|
337
|
+
" Write-Log '检测到停止标记,守护进程退出'",
|
|
338
|
+
' break',
|
|
339
|
+
' }',
|
|
231
340
|
'',
|
|
232
341
|
' # 进程已退出',
|
|
233
342
|
' if ($proc.HasExited) {',
|
|
@@ -364,6 +473,11 @@ async function startServiceWindows() {
|
|
|
364
473
|
return { success: true, message: 'Gateway 已在运行' };
|
|
365
474
|
}
|
|
366
475
|
try {
|
|
476
|
+
// 清除上一次 stop 可能残留的停止标记,否则新守护会立即退出
|
|
477
|
+
try {
|
|
478
|
+
fs_1.default.unlinkSync(GATEWAY_STOP_FLAG_FILE);
|
|
479
|
+
}
|
|
480
|
+
catch { /* ignore */ }
|
|
367
481
|
const exe = getGatewayExePath();
|
|
368
482
|
const workDir = path_1.default.dirname(exe);
|
|
369
483
|
if (!fs_1.default.existsSync(GATEWAY_LAUNCHER_FILE)) {
|
|
@@ -388,9 +502,15 @@ async function startServiceWindows() {
|
|
|
388
502
|
}
|
|
389
503
|
async function restartServiceWindows() {
|
|
390
504
|
try {
|
|
391
|
-
await stopService();
|
|
505
|
+
const stopResult = await stopService();
|
|
506
|
+
if (!stopResult.success) {
|
|
507
|
+
return { success: false, message: `重启失败(停止阶段): ${stopResult.message}` };
|
|
508
|
+
}
|
|
392
509
|
await new Promise(r => setTimeout(r, 1000));
|
|
393
|
-
await startService();
|
|
510
|
+
const startResult = await startService();
|
|
511
|
+
if (!startResult.success) {
|
|
512
|
+
return { success: false, message: `重启失败(启动阶段): ${startResult.message}` };
|
|
513
|
+
}
|
|
394
514
|
return { success: true, message: 'Gateway 重启成功' };
|
|
395
515
|
}
|
|
396
516
|
catch (err) {
|
|
@@ -399,23 +519,33 @@ async function restartServiceWindows() {
|
|
|
399
519
|
}
|
|
400
520
|
async function stopServiceWindows() {
|
|
401
521
|
try {
|
|
522
|
+
if (!isGatewayRunning() && !getDaemonPid()) {
|
|
523
|
+
return { success: true, message: 'Gateway 未在运行' };
|
|
524
|
+
}
|
|
402
525
|
// 写入停止标记,告知守护进程这是主动停止,不要重启
|
|
403
526
|
try {
|
|
527
|
+
fs_1.default.mkdirSync(path_1.default.dirname(GATEWAY_STOP_FLAG_FILE), { recursive: true });
|
|
404
528
|
fs_1.default.writeFileSync(GATEWAY_STOP_FLAG_FILE, '1', 'utf8');
|
|
405
529
|
}
|
|
406
530
|
catch { /* ignore */ }
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
531
|
+
const { stopped, self } = await stopGatewayProcess();
|
|
532
|
+
// 自停场景(HTTP 接口调用)退出发生在本函数返回之后,无法在此校验,
|
|
533
|
+
// 也不能删停止标记(它要留给守护进程判定主动停止)
|
|
534
|
+
if (self) {
|
|
535
|
+
return { success: true, message: 'Gateway 正在退出' };
|
|
536
|
+
}
|
|
537
|
+
// 确认退出后才能清理停止标记,否则残留守护会把 gateway 重新拉起
|
|
538
|
+
if (!stopped) {
|
|
539
|
+
return { success: false, message: 'Gateway 进程未能终止,请手动结束进程后重试' };
|
|
540
|
+
}
|
|
411
541
|
try {
|
|
412
542
|
fs_1.default.unlinkSync(GATEWAY_STOP_FLAG_FILE);
|
|
413
543
|
}
|
|
414
544
|
catch { /* ignore */ }
|
|
415
|
-
|
|
416
|
-
|
|
545
|
+
// 最终校验:避免在没真正停掉时还报成功
|
|
546
|
+
if (isGatewayRunning()) {
|
|
547
|
+
return { success: false, message: 'Gateway 仍在运行,停止失败' };
|
|
417
548
|
}
|
|
418
|
-
catch { /* ignore */ }
|
|
419
549
|
return { success: true, message: 'Gateway 已停止' };
|
|
420
550
|
}
|
|
421
551
|
catch (err) {
|
|
@@ -451,21 +581,21 @@ async function installLinux() {
|
|
|
451
581
|
if (installed)
|
|
452
582
|
await stopService();
|
|
453
583
|
const execStart = isPkg ? exe : `${exe} ${script}`;
|
|
454
|
-
const unitContent = `[Unit]
|
|
455
|
-
Description=${exports.SERVICE_DISPLAY_NAME}
|
|
456
|
-
After=network.target
|
|
457
|
-
|
|
458
|
-
[Service]
|
|
459
|
-
Type=simple
|
|
460
|
-
User=${process.env.USER || 'root'}
|
|
461
|
-
WorkingDirectory=${workDir}
|
|
462
|
-
ExecStart=${execStart}
|
|
463
|
-
Restart=always
|
|
464
|
-
RestartSec=5
|
|
465
|
-
Environment=NODE_ENV=production
|
|
466
|
-
|
|
467
|
-
[Install]
|
|
468
|
-
WantedBy=multi-user.target
|
|
584
|
+
const unitContent = `[Unit]
|
|
585
|
+
Description=${exports.SERVICE_DISPLAY_NAME}
|
|
586
|
+
After=network.target
|
|
587
|
+
|
|
588
|
+
[Service]
|
|
589
|
+
Type=simple
|
|
590
|
+
User=${process.env.USER || 'root'}
|
|
591
|
+
WorkingDirectory=${workDir}
|
|
592
|
+
ExecStart=${execStart}
|
|
593
|
+
Restart=always
|
|
594
|
+
RestartSec=5
|
|
595
|
+
Environment=NODE_ENV=production
|
|
596
|
+
|
|
597
|
+
[Install]
|
|
598
|
+
WantedBy=multi-user.target
|
|
469
599
|
`;
|
|
470
600
|
await fs_1.default.promises.writeFile('/tmp/myassis-gateway.service', unitContent, 'utf8');
|
|
471
601
|
await execAsync('cp /tmp/myassis-gateway.service /etc/systemd/system/myassis-gateway.service', { timeout: 10000 });
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.webSocketService = void 0;
|
|
8
8
|
const ws_1 = require("ws");
|
|
9
|
+
const uuid_1 = require("uuid");
|
|
9
10
|
const index_js_1 = require("../stores/index.js");
|
|
10
11
|
const shared_1 = require("@myassis/shared");
|
|
11
12
|
const Session_js_1 = require("./session/Session.js");
|
|
12
13
|
const logger = (0, shared_1.getLogger)('WebSocketService');
|
|
13
14
|
class WebSocketService {
|
|
14
15
|
wss = null;
|
|
16
|
+
/** userId -> (连接 id -> 连接):同一用户的多个终端可同时在线 */
|
|
15
17
|
clients = new Map();
|
|
16
18
|
heartbeatInterval = null;
|
|
17
19
|
/**
|
|
@@ -31,30 +33,29 @@ class WebSocketService {
|
|
|
31
33
|
return;
|
|
32
34
|
}
|
|
33
35
|
const userId = clientKey;
|
|
34
|
-
|
|
36
|
+
const connectionId = (0, uuid_1.v4)();
|
|
35
37
|
const client = {
|
|
38
|
+
id: connectionId,
|
|
39
|
+
clientId: this.getQueryParam(req, 'clientId'),
|
|
36
40
|
ws,
|
|
37
41
|
userId,
|
|
38
42
|
connectedAt: Date.now(),
|
|
39
43
|
isAlive: true,
|
|
40
44
|
};
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
this.clients.set(userId, client);
|
|
45
|
+
// 同一用户的多终端并存:按连接 id 保存,不再挤掉旧连接,
|
|
46
|
+
// 这样一个用户在不同终端都能收到同一份实时消息
|
|
47
|
+
const connections = this.getOrCreateConnections(userId);
|
|
48
|
+
connections.set(connectionId, client);
|
|
49
|
+
logger.info(`用户 ${userId} 已连接(当前连接数: ${connections.size})`);
|
|
47
50
|
// 心跳检测
|
|
48
51
|
ws.on('pong', () => {
|
|
49
|
-
|
|
50
|
-
this.clients.get(userId).isAlive = true;
|
|
51
|
-
}
|
|
52
|
+
client.isAlive = true;
|
|
52
53
|
});
|
|
53
54
|
// 接收消息
|
|
54
55
|
ws.on('message', (data) => {
|
|
55
56
|
try {
|
|
56
57
|
const message = JSON.parse(data.toString());
|
|
57
|
-
this.handleMessage(
|
|
58
|
+
this.handleMessage(client, message);
|
|
58
59
|
}
|
|
59
60
|
catch {
|
|
60
61
|
logger.warn(`用户 ${userId} 发送了无效消息`);
|
|
@@ -62,35 +63,34 @@ class WebSocketService {
|
|
|
62
63
|
});
|
|
63
64
|
// 断开连接
|
|
64
65
|
ws.on('close', () => {
|
|
65
|
-
|
|
66
|
-
this.
|
|
66
|
+
this.removeConnection(client);
|
|
67
|
+
logger.info(`用户 ${userId} 已断开(剩余连接数: ${this.getConnectionCount(userId)})`);
|
|
67
68
|
});
|
|
68
69
|
ws.on('error', (error) => {
|
|
69
70
|
logger.error(`用户 ${userId} 连接错误:`, error);
|
|
70
|
-
this.
|
|
71
|
+
this.removeConnection(client);
|
|
71
72
|
});
|
|
72
73
|
// 发送连接成功消息
|
|
73
|
-
this.
|
|
74
|
+
this.sendToConnection(client, {
|
|
74
75
|
type: 'connected',
|
|
75
76
|
payload: {
|
|
76
77
|
userId,
|
|
78
|
+
connectionId,
|
|
77
79
|
timestamp: Date.now(),
|
|
78
80
|
},
|
|
79
81
|
});
|
|
80
82
|
});
|
|
81
83
|
// 启动心跳检测
|
|
82
84
|
this.heartbeatInterval = setInterval(() => {
|
|
83
|
-
this.
|
|
84
|
-
const client = Array.from(this.clients.values()).find(c => c.ws === ws);
|
|
85
|
-
if (!client)
|
|
86
|
-
return;
|
|
85
|
+
this.forEachConnection((client) => {
|
|
87
86
|
if (!client.isAlive) {
|
|
88
87
|
logger.info(`用户 ${client.userId} 心跳超时,断开`);
|
|
89
|
-
this.
|
|
90
|
-
|
|
88
|
+
this.removeConnection(client);
|
|
89
|
+
client.ws.terminate();
|
|
90
|
+
return;
|
|
91
91
|
}
|
|
92
92
|
client.isAlive = false;
|
|
93
|
-
ws.ping();
|
|
93
|
+
client.ws.ping();
|
|
94
94
|
});
|
|
95
95
|
}, 30000);
|
|
96
96
|
logger.info('WebSocket 服务已启动,路径: /ws');
|
|
@@ -103,12 +103,7 @@ class WebSocketService {
|
|
|
103
103
|
*/
|
|
104
104
|
getClientKey(req) {
|
|
105
105
|
// 优先从 URL 查询参数中提取 token
|
|
106
|
-
|
|
107
|
-
const url = req.url || '';
|
|
108
|
-
const match = url.match(/[?&]token=([^&]+)/);
|
|
109
|
-
if (match) {
|
|
110
|
-
token = decodeURIComponent(match[1]);
|
|
111
|
-
}
|
|
106
|
+
const token = this.getQueryParam(req, 'token');
|
|
112
107
|
if (token) {
|
|
113
108
|
const userId = index_js_1.authStore.getUserId(token);
|
|
114
109
|
if (userId) {
|
|
@@ -117,13 +112,68 @@ class WebSocketService {
|
|
|
117
112
|
}
|
|
118
113
|
return null;
|
|
119
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* 读取连接 URL 上的查询参数
|
|
117
|
+
*/
|
|
118
|
+
getQueryParam(req, name) {
|
|
119
|
+
const url = req?.url || '';
|
|
120
|
+
const match = url.match(new RegExp(`[?&]${name}=([^&]+)`));
|
|
121
|
+
return match ? decodeURIComponent(match[1]) : undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 获取(必要时创建)用户的连接集合
|
|
125
|
+
*/
|
|
126
|
+
getOrCreateConnections(userId) {
|
|
127
|
+
let connections = this.clients.get(userId);
|
|
128
|
+
if (!connections) {
|
|
129
|
+
connections = new Map();
|
|
130
|
+
this.clients.set(userId, connections);
|
|
131
|
+
}
|
|
132
|
+
return connections;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 移除连接,用户没有连接后清理其条目
|
|
136
|
+
*/
|
|
137
|
+
removeConnection(client) {
|
|
138
|
+
const connections = this.clients.get(client.userId);
|
|
139
|
+
if (!connections)
|
|
140
|
+
return;
|
|
141
|
+
connections.delete(client.id);
|
|
142
|
+
if (connections.size === 0) {
|
|
143
|
+
this.clients.delete(client.userId);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 遍历所有连接
|
|
148
|
+
*/
|
|
149
|
+
forEachConnection(handler) {
|
|
150
|
+
this.clients.forEach((connections) => {
|
|
151
|
+
Array.from(connections.values()).forEach(handler);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 向单个连接发送消息
|
|
156
|
+
*/
|
|
157
|
+
sendToConnection(client, message) {
|
|
158
|
+
if (client.ws.readyState !== ws_1.WebSocket.OPEN)
|
|
159
|
+
return false;
|
|
160
|
+
try {
|
|
161
|
+
client.ws.send(JSON.stringify(message));
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
logger.error(`发送消息给用户 ${client.userId} 失败:`, error);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
120
169
|
/**
|
|
121
170
|
* 处理客户端发来的消息
|
|
122
171
|
*/
|
|
123
|
-
handleMessage(
|
|
172
|
+
handleMessage(client, message) {
|
|
173
|
+
const userId = client.userId;
|
|
124
174
|
switch (message.type) {
|
|
125
175
|
case 'ping':
|
|
126
|
-
this.
|
|
176
|
+
this.sendToConnection(client, { type: 'pong', payload: { timestamp: Date.now() } });
|
|
127
177
|
break;
|
|
128
178
|
case 'subscribe':
|
|
129
179
|
logger.debug(`用户 ${userId} 订阅: ${message.payload?.channel || 'all'}`);
|
|
@@ -140,28 +190,41 @@ class WebSocketService {
|
|
|
140
190
|
}
|
|
141
191
|
}
|
|
142
192
|
/**
|
|
143
|
-
*
|
|
193
|
+
* 向指定用户的所有终端发送消息
|
|
194
|
+
*
|
|
195
|
+
* 同一用户可能在多个终端登录,这里会广播给该用户的每个连接,
|
|
196
|
+
* 可通过 options.excludeClientId 跳过已经通过 SSE 收到事件的终端。
|
|
197
|
+
* 返回 true 表示至少有一个连接发送成功。
|
|
144
198
|
*/
|
|
145
|
-
sendToUser(userId, message) {
|
|
146
|
-
const
|
|
147
|
-
if (!
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
client.ws.send(JSON.stringify(message));
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
catch (error) {
|
|
155
|
-
logger.error(`发送消息给用户 ${userId} 失败:`, error);
|
|
199
|
+
sendToUser(userId, message, options = {}) {
|
|
200
|
+
const connections = this.clients.get(String(userId));
|
|
201
|
+
if (!connections || connections.size === 0) {
|
|
156
202
|
return false;
|
|
157
203
|
}
|
|
204
|
+
let sent = false;
|
|
205
|
+
Array.from(connections.values()).forEach((client) => {
|
|
206
|
+
if (options.excludeClientId && client.clientId === options.excludeClientId) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
sent = this.sendToConnection(client, message) || sent;
|
|
210
|
+
});
|
|
211
|
+
return sent;
|
|
158
212
|
}
|
|
159
213
|
/**
|
|
160
|
-
*
|
|
214
|
+
* 检查用户是否在线(任一终端在线即视为在线)
|
|
161
215
|
*/
|
|
162
216
|
isUserOnline(userId) {
|
|
163
|
-
|
|
164
|
-
|
|
217
|
+
return this.getConnectionCount(userId) > 0;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 获取用户当前的在线连接数
|
|
221
|
+
*/
|
|
222
|
+
getConnectionCount(userId) {
|
|
223
|
+
const connections = this.clients.get(String(userId));
|
|
224
|
+
if (!connections)
|
|
225
|
+
return 0;
|
|
226
|
+
return Array.from(connections.values())
|
|
227
|
+
.filter((client) => client.ws.readyState === ws_1.WebSocket.OPEN).length;
|
|
165
228
|
}
|
|
166
229
|
/**
|
|
167
230
|
* 发送任务通知
|
|
@@ -212,7 +275,7 @@ class WebSocketService {
|
|
|
212
275
|
*/
|
|
213
276
|
broadcast(message) {
|
|
214
277
|
const data = JSON.stringify(message);
|
|
215
|
-
this.
|
|
278
|
+
this.forEachConnection((client) => {
|
|
216
279
|
if (client.ws.readyState === ws_1.WebSocket.OPEN) {
|
|
217
280
|
try {
|
|
218
281
|
client.ws.send(data);
|
|
@@ -243,7 +306,7 @@ class WebSocketService {
|
|
|
243
306
|
clearInterval(this.heartbeatInterval);
|
|
244
307
|
this.heartbeatInterval = null;
|
|
245
308
|
}
|
|
246
|
-
this.
|
|
309
|
+
this.forEachConnection((client) => {
|
|
247
310
|
client.ws.close(1001, 'Server shutting down');
|
|
248
311
|
});
|
|
249
312
|
this.clients.clear();
|
|
@@ -54,8 +54,8 @@ exports.authService = {
|
|
|
54
54
|
},
|
|
55
55
|
// 登出
|
|
56
56
|
logout: async (token, refreshToken) => {
|
|
57
|
-
|
|
58
|
-
const revokeToken = refreshToken ||
|
|
57
|
+
// 按 accessToken 定位当前终端的 refreshToken,避免踢掉其他终端
|
|
58
|
+
const revokeToken = refreshToken || index_js_2.authStore.getRefreshTokenByToken(token);
|
|
59
59
|
if (revokeToken) {
|
|
60
60
|
try {
|
|
61
61
|
await index_js_1.authApi.logout(revokeToken, token);
|
|
@@ -76,12 +76,11 @@ exports.authService = {
|
|
|
76
76
|
// 删除账号
|
|
77
77
|
deleteAccount: (password, token) => index_js_1.authApi.deleteAccount({ password }, token),
|
|
78
78
|
// 获取当前用户(从内存)
|
|
79
|
-
getUser: (token) => index_js_2.authStore.
|
|
79
|
+
getUser: (token) => index_js_2.authStore.getByToken(token)?.user || null,
|
|
80
80
|
// 刷新 Token
|
|
81
81
|
refresh: async (refreshToken) => {
|
|
82
|
-
//
|
|
83
|
-
const oldToken =
|
|
84
|
-
.find(x => x.refreshToken === refreshToken)?.accessToken;
|
|
82
|
+
// 刷新前先定位该终端的旧 accessToken
|
|
83
|
+
const oldToken = index_js_2.authStore.getByRefreshToken(refreshToken)?.accessToken;
|
|
85
84
|
const response = await index_js_1.authApi.refresh(refreshToken);
|
|
86
85
|
const accessToken = response.data?.accessToken ?? response.accessToken;
|
|
87
86
|
const newRefreshToken = response.data?.refreshToken ?? response.refreshToken;
|