@myassis/gateway 1.0.85 → 1.0.87

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.
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * Gateway 能力位
4
+ *
5
+ * Desktop 需要在连接前知道当前网关支持哪些链路能力,
6
+ * 才能决定是否展示「中继模式」入口、以及是否提示用户升级网关。
7
+ * 能力位通过 /health 暴露(无需鉴权,Desktop 在登录前即可探测)。
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.getFeatures = exports.getRelayStatus = exports.RELAY_SUPPORTED = exports.RELAY_PROTOCOL_VERSION = void 0;
11
+ const RelayClient_js_1 = require("../services/relay/RelayClient.js");
12
+ const relayConfig_js_1 = require("../services/relay/relayConfig.js");
13
+ const protocol_js_1 = require("../services/relay/protocol.js");
14
+ Object.defineProperty(exports, "RELAY_PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_js_1.RELAY_PROTOCOL_VERSION; } });
15
+ /**
16
+ * 本网关是否已内置中继客户端实现。
17
+ * 与 RelayClient 的存在与否绑定;Desktop 据此判断是否展示中继入口。
18
+ */
19
+ exports.RELAY_SUPPORTED = true;
20
+ /** 汇总中继状态 */
21
+ function getRelayStatus() {
22
+ const credential = (0, relayConfig_js_1.getRelayCredential)();
23
+ const status = RelayClient_js_1.relayClient.getStatus();
24
+ return {
25
+ supported: exports.RELAY_SUPPORTED,
26
+ enabled: (0, relayConfig_js_1.isRelayEnabled)(),
27
+ connected: status.connected,
28
+ paired: credential !== null,
29
+ // gatewayId 是中继寻址标识,未配对时不暴露空串以免前端误判为已配对
30
+ gatewayId: credential?.gatewayId,
31
+ protocolVersion: protocol_js_1.RELAY_PROTOCOL_VERSION,
32
+ };
33
+ }
34
+ exports.getRelayStatus = getRelayStatus;
35
+ /**
36
+ * 能力位列表。
37
+ * 采用 `名称@版本` 形式,便于 Desktop 做区间兼容判断。
38
+ */
39
+ function getFeatures() {
40
+ const features = ['direct@1'];
41
+ if (exports.RELAY_SUPPORTED) {
42
+ features.push(`relay@${protocol_js_1.RELAY_PROTOCOL_VERSION}`);
43
+ }
44
+ return features;
45
+ }
46
+ exports.getFeatures = getFeatures;
package/dist/main.js CHANGED
@@ -10,6 +10,7 @@ const helmet_1 = __importDefault(require("helmet"));
10
10
  const compression_1 = __importDefault(require("compression"));
11
11
  const http_1 = __importDefault(require("http"));
12
12
  const index_js_1 = require("./config/index.js");
13
+ const capabilities_js_1 = require("./config/capabilities.js");
13
14
  const shared_1 = require("@myassis/shared");
14
15
  const auth_js_1 = __importDefault(require("./routes/auth.js"));
15
16
  const agent_js_1 = __importDefault(require("./routes/agent.js"));
@@ -24,12 +25,14 @@ const tasks_js_1 = __importDefault(require("./routes/tasks.js"));
24
25
  const upload_js_1 = __importDefault(require("./routes/upload.js"));
25
26
  const version_js_1 = __importDefault(require("./routes/version.js"));
26
27
  const quota_js_1 = __importDefault(require("./routes/quota.js"));
28
+ const relay_js_1 = __importDefault(require("./routes/relay.js"));
27
29
  const errorHandler_js_1 = require("./middleware/errorHandler.js");
28
30
  const broadcast_js_1 = require("./middleware/broadcast.js");
29
31
  const index_js_2 = require("./stores/index.js");
30
32
  const persistStore_js_1 = require("./stores/persistStore.js");
31
33
  const WebSocketService_js_1 = require("./services/WebSocketService.js");
32
34
  const TaskSchedulerService_js_1 = require("./services/TaskSchedulerService.js");
35
+ const RelayClient_js_1 = require("./services/relay/RelayClient.js");
33
36
  const ServiceManager_js_1 = require("./services/ServiceManager.js");
34
37
  const logger = (0, shared_1.getLogger)('index');
35
38
  // 内置放通的官方域名(含子域名),无需用户手动 addCors
@@ -165,26 +168,26 @@ if (cliCommand) {
165
168
  }
166
169
  }
167
170
  else if (cliCommand === '--help' || cliCommand === '-h') {
168
- console.log(`我的助手 Gateway CLI
169
-
170
- 用法: gateway <命令>
171
-
172
- 服务管理命令:
173
- install 安装 Gateway 服务(后台运行)
174
- uninstall 卸载 Gateway 服务
175
- start 启动 Gateway 服务
176
- stop 停止 Gateway 服务
177
- restart 重启 Gateway 服务
178
- update 更新 Gateway(需重新安装)
179
- status 查看服务状态
180
-
181
- CORS 管理命令:
182
- addCors <域名> 添加允许的跨域域名
183
- removeCors <域名> 移除允许的跨域域名
184
- listCors 列出所有跨域域名(含内置域名)
185
-
186
- 其他:
187
- --help, -h 显示本帮助信息
171
+ console.log(`我的助手 Gateway CLI
172
+
173
+ 用法: gateway <命令>
174
+
175
+ 服务管理命令:
176
+ install 安装 Gateway 服务(后台运行)
177
+ uninstall 卸载 Gateway 服务
178
+ start 启动 Gateway 服务
179
+ stop 停止 Gateway 服务
180
+ restart 重启 Gateway 服务
181
+ update 更新 Gateway(需重新安装)
182
+ status 查看服务状态
183
+
184
+ CORS 管理命令:
185
+ addCors <域名> 添加允许的跨域域名
186
+ removeCors <域名> 移除允许的跨域域名
187
+ listCors 列出所有跨域域名(含内置域名)
188
+
189
+ 其他:
190
+ --help, -h 显示本帮助信息
188
191
  `);
189
192
  }
190
193
  else {
@@ -272,7 +275,15 @@ else {
272
275
  app.use(express_1.default.json({ limit: '10mb' }));
273
276
  // Health check
274
277
  app.get('/health', (req, res) => {
275
- res.json({ status: 'ok', service: 'gateway', version: '2.0.0', wsOnline: WebSocketService_js_1.webSocketService.getOnlineCount() });
278
+ res.json({
279
+ status: 'ok',
280
+ service: 'gateway',
281
+ version: '2.0.0',
282
+ wsOnline: WebSocketService_js_1.webSocketService.getOnlineCount(),
283
+ // 能力位:Desktop 据此决定是否展示中继入口、是否提示升级网关
284
+ features: (0, capabilities_js_1.getFeatures)(),
285
+ relay: (0, capabilities_js_1.getRelayStatus)(),
286
+ });
276
287
  });
277
288
  // 写请求成功后向该用户的其他终端广播数据变更(多终端同步)。
278
289
  // 必须注册在业务路由之前:它通过包装 res.json 生效。
@@ -291,6 +302,7 @@ else {
291
302
  app.use('/api/v1/upload', upload_js_1.default);
292
303
  app.use('/api/v1/version', version_js_1.default);
293
304
  app.use('/api/v1/quota', quota_js_1.default);
305
+ app.use('/api/v1/relay', relay_js_1.default);
294
306
  // Load auth from persistent storage on startup
295
307
  index_js_2.authStore.load();
296
308
  // Error handler
@@ -301,9 +313,17 @@ else {
301
313
  const configuredPort = index_js_1.appConfig.port;
302
314
  let schedulerStarted = false;
303
315
  let webSocketInitialized = false;
316
+ let relayStarted = false;
304
317
  const startServer = (port) => {
305
318
  const handleListenError = (err) => {
306
319
  server.removeListener('error', handleListenError);
320
+ // 关键:listen(port, cb) 以 once('listening') 注册 cb,而端口占用走的是 error
321
+ // 事件,cb 并不会被移除、会一直挂在同一个 server 上。不在这里摘掉的话,
322
+ // 下一个端口监听成功时**上一次的 cb 也会触发**,且其闭包里的 port 是旧值。
323
+ // 实测症状:先打印「running on port 3001」再打印「3001 被占用,回退 3002」,
324
+ // 并把 3001 当作实际端口交给中继回环,请求于是被转发到同机的另一个网关进程
325
+ // (隧道在线、/health 正常,但所有 /api/v1/* 返回 404)。
326
+ server.removeListener('listening', handleListening);
307
327
  if (err.code === 'EADDRINUSE') {
308
328
  const nextPort = port + 1;
309
329
  if (nextPort <= (configuredPort + 10)) {
@@ -319,14 +339,17 @@ else {
319
339
  logger.error(`Server error: ${err.message}`);
320
340
  process.exit(1);
321
341
  };
322
- server.once('error', handleListenError);
323
- server.listen(port, () => {
342
+ const handleListening = () => {
324
343
  server.removeListener('error', handleListenError);
325
- if (port !== configuredPort) {
326
- logger.info(`Port ${configuredPort} is in use, fallback to port ${port}`);
344
+ // 实际端口只认 server.address():闭包里的 port 只是「这次尝试的端口」,不等于
345
+ // 「最终监听的端口」。回环转发与 WS 广播都依赖它,取错就会打到别的进程上。
346
+ const address = server.address();
347
+ const actualPort = typeof address === 'object' && address ? address.port : port;
348
+ if (actualPort !== configuredPort) {
349
+ logger.info(`Port ${configuredPort} is in use, fallback to port ${actualPort}`);
327
350
  }
328
351
  // 动态更新 appConfig.port(供 WebSocket 广播实际端口)
329
- index_js_1.appConfig.port = port;
352
+ index_js_1.appConfig.port = actualPort;
330
353
  // HTTP Server 监听成功后再初始化 WebSocket,避免端口冲突时 WebSocketServer 误报 EADDRINUSE
331
354
  if (!webSocketInitialized) {
332
355
  WebSocketService_js_1.webSocketService.initialize(server);
@@ -337,16 +360,27 @@ else {
337
360
  TaskSchedulerService_js_1.taskSchedulerService.start();
338
361
  schedulerStarted = true;
339
362
  }
340
- logger.info(`我的助手 Gateway Service running on port ${port}`);
363
+ // 中继客户端必须在此启动而非模块顶层:回环转发需要**实际**监听端口,
364
+ // 而端口存在被占用后自动顺延的逻辑(3001 → 3002…),配置值不可信。
365
+ if (!relayStarted) {
366
+ RelayClient_js_1.relayClient.start(actualPort);
367
+ relayStarted = true;
368
+ }
369
+ logger.info(`我的助手 Gateway Service running on port ${actualPort}`);
341
370
  // 启动自愈:检查开机自启 Run key 是否健康,不健康则自动 rewrite。
342
371
  // 异步执行,失败不影响主流程。
343
372
  (0, ServiceManager_js_1.ensureAutoStartHealthy)().catch(() => { });
344
- });
373
+ };
374
+ server.once('error', handleListenError);
375
+ server.once('listening', handleListening);
376
+ server.listen(port);
345
377
  };
346
378
  startServer(configuredPort);
347
379
  // 优雅关闭
348
380
  process.on('SIGTERM', () => {
349
381
  logger.info('收到 SIGTERM,正在关闭...');
382
+ // 先停中继:让远端客户端立刻收到明确的流中止,而不是等隧道超时
383
+ RelayClient_js_1.relayClient.stop('网关正在关闭');
350
384
  WebSocketService_js_1.webSocketService.shutdown();
351
385
  TaskSchedulerService_js_1.taskSchedulerService.stop();
352
386
  server.close(() => {
@@ -356,6 +390,8 @@ else {
356
390
  });
357
391
  process.on('SIGINT', () => {
358
392
  logger.info('收到 SIGINT,正在关闭...');
393
+ // 先停中继:让远端客户端立刻收到明确的流中止,而不是等隧道超时
394
+ RelayClient_js_1.relayClient.stop('网关正在关闭');
359
395
  WebSocketService_js_1.webSocketService.shutdown();
360
396
  TaskSchedulerService_js_1.taskSchedulerService.stop();
361
397
  server.close(() => {
@@ -366,6 +366,33 @@ router.get('/sessions/:sessionId/messages', ensureAgentManager, async (req, res)
366
366
  res.status(500).json({ success: false, error: 'Failed to get messages' });
367
367
  }
368
368
  });
369
+ /**
370
+ * GET /api/agent/sessions/:sessionId/messages/sync?sinceSeq=N
371
+ *
372
+ * 断线重连后的增量补拉。WebSocket 重连退避最长 30s,这期间的会话流
373
+ * 事件无法补发,不拉一次就会永久分歧。只回 seq 大于终端游标的部分,
374
+ * 同时回完整 id 列表用于剪除离线期间被删掉的消息。
375
+ *
376
+ * 注意路径要注册在 /messages/:messageId 类路由之前,否则 sync 会被当成 messageId。
377
+ */
378
+ router.get('/sessions/:sessionId/messages/sync', ensureAgentManager, async (req, res) => {
379
+ try {
380
+ const userId = req.userId;
381
+ const { sessionId } = req.params;
382
+ const sinceSeq = parseInt(req.query.sinceSeq, 10) || 0;
383
+ const session = (0, index_js_2.getSessionManager)(userId).getSession(sessionId);
384
+ if (!session) {
385
+ return res.status(404).json({ success: false, error: 'Session not found' });
386
+ }
387
+ session.loadMessages();
388
+ const result = session.getMessagesChangedSince(sinceSeq);
389
+ return res.json({ success: true, data: result });
390
+ }
391
+ catch (error) {
392
+ logger.error(`Sync messages error: ${error}`);
393
+ res.status(500).json({ success: false, error: 'Failed to sync messages' });
394
+ }
395
+ });
369
396
  /**
370
397
  * DELETE /api/agent/sessions/:sessionId/messages/:messageId
371
398
  * Delete a specific message
@@ -7,6 +7,7 @@ const auth_js_1 = require("../middleware/auth.js");
7
7
  const index_js_3 = require("../api/index.js");
8
8
  const shared_1 = require("@myassis/shared");
9
9
  const system_js_1 = require("@myassis/shared/dist/utils/system.js");
10
+ const relayConfig_js_1 = require("../services/relay/relayConfig.js");
10
11
  const logger = (0, shared_1.getLogger)('auth');
11
12
  const router = (0, express_1.Router)();
12
13
  // Get publish token - 获取发布技能库所需的 token
@@ -201,4 +202,81 @@ router.post('/account/delete', auth_js_1.requireAuth, async (req, res) => {
201
202
  });
202
203
  }
203
204
  });
205
+ /**
206
+ * POST /api/v1/auth/adopt-token
207
+ *
208
+ * 让网关「采信」一个由 Server 签发的 accessToken。
209
+ *
210
+ * 为什么需要它:中继模式下 Desktop 无法向网关登录 —— 网关的 /auth/login 会把
211
+ * 凭据转发给 Server 换 token,但走中继访问网关本身就要求先有 Server 的 JWT,
212
+ * 形成死锁。同时网关的 requireAuth 只认自己 authStore 里的 token,
213
+ * 因此即便 Desktop 直接向 Server 登录成功,后续中继请求在网关侧仍是 401。
214
+ *
215
+ * 解法:Desktop 在中继模式下直连 Server 登录(Server 本就是签发方),
216
+ * 再用拿到的 token 调本接口,由网关向 Server 验签并确认属主后写入 authStore。
217
+ *
218
+ * 三道防线(缺一不可):
219
+ * 1. 本接口不能用 requireAuth(此时 token 尚未被采信),改为向 Server 的
220
+ * /auth/me 验签 —— 网关不持有 JWT_SECRET,无法自行验签,这与既有设计一致;
221
+ * 2. 校验 token 属主必须等于配对时记录的 userId。否则任何知道 gatewayId 的人
222
+ * 都能用自己的账号让别人的网关采信自己的 token,等于远程登录别人的电脑;
223
+ * 3. 走中继到达这里的请求,已先被 Server 的 hasGatewayAccess 拦过一层授权。
224
+ */
225
+ router.post('/adopt-token', async (req, res) => {
226
+ try {
227
+ const { accessToken, refreshToken, expiresIn } = req.body ?? {};
228
+ if (typeof accessToken !== 'string' || !accessToken) {
229
+ res.status(400).json({ success: false, error: 'Missing accessToken' });
230
+ return;
231
+ }
232
+ const credential = (0, relayConfig_js_1.getRelayCredential)();
233
+ if (!credential) {
234
+ // 未配对的网关不应接受任何令牌注入:此时没有「属主」可供比对
235
+ res.status(403).json({ success: false, error: 'Gateway is not paired' });
236
+ return;
237
+ }
238
+ // 向 Server 验签并取得该 token 的真实属主
239
+ let user;
240
+ try {
241
+ const me = await (0, index_js_3.runWithToken)(accessToken, () => index_js_1.authApi.me(accessToken));
242
+ user = me?.data ?? me?.user;
243
+ }
244
+ catch (error) {
245
+ logger.warn('adopt-token 验签失败:', error?.message);
246
+ res.status(401).json({ success: false, error: 'Invalid token' });
247
+ return;
248
+ }
249
+ if (!user?.id) {
250
+ res.status(401).json({ success: false, error: 'Invalid token' });
251
+ return;
252
+ }
253
+ // 属主校验:只有配对该网关的用户能让它采信自己的令牌。
254
+ // 注意这里必须对「userId 缺失」也拒绝,而不是用 `credential.userId &&` 短路 ——
255
+ // 否则一条没有属主的旧凭据(早期版本遗留,或 persistStore 被手工改过)
256
+ // 会让本接口退化成「任何人都能注入令牌」,属于典型的 fail-open。
257
+ if (!credential.userId || String(user.id) !== String(credential.userId)) {
258
+ logger.warn(`adopt-token 属主校验失败: token=${user.id} paired=${credential.userId ?? '(缺失)'}`);
259
+ // 与越权访问返回同样笼统的 403,不透出属主信息
260
+ res.status(403).json({ success: false, error: 'Forbidden' });
261
+ return;
262
+ }
263
+ index_js_2.authStore.save({
264
+ accessToken,
265
+ refreshToken: typeof refreshToken === 'string' ? refreshToken : undefined,
266
+ expiresIn: typeof expiresIn === 'number' ? expiresIn : undefined,
267
+ expiresAt: typeof expiresIn === 'number' ? Date.now() + expiresIn * 1000 : undefined,
268
+ user: {
269
+ id: String(user.id),
270
+ nickname: String(user.nickname ?? user.account ?? ''),
271
+ account: String(user.account ?? ''),
272
+ },
273
+ });
274
+ logger.info(`已采信中继登录令牌 user=${user.id}`);
275
+ res.json({ success: true, data: { user } });
276
+ }
277
+ catch (error) {
278
+ logger.error('adopt-token error:', error);
279
+ res.status(500).json({ success: false, error: error.message || 'adopt-token failed' });
280
+ }
281
+ });
204
282
  exports.default = router;
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /**
3
+ * 中继管理路由
4
+ *
5
+ * 供 Desktop 在**直连模式下**管理中继:查看状态、开关、与 Server 完成配对。
6
+ * 注意配对必须先直连一次网关来完成(需要用户在本机确认),
7
+ * 这是「知道 gatewayId 就能远程敲别人电脑」的核心防线。
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.consumePairCode = void 0;
14
+ const express_1 = require("express");
15
+ const axios_1 = __importDefault(require("axios"));
16
+ const crypto_1 = __importDefault(require("crypto"));
17
+ const shared_1 = require("@myassis/shared");
18
+ const auth_js_1 = require("../middleware/auth.js");
19
+ const RelayClient_js_1 = require("../services/relay/RelayClient.js");
20
+ const relayConfig_js_1 = require("../services/relay/relayConfig.js");
21
+ const protocol_js_1 = require("../services/relay/protocol.js");
22
+ const logger = (0, shared_1.getLogger)('routes/relay');
23
+ const router = (0, express_1.Router)();
24
+ /** 配对码有效期:足够用户在另一台设备上输入,又不至于长期可用 */
25
+ const PAIR_CODE_TTL_MS = 5 * 60 * 1000;
26
+ /**
27
+ * 一次性配对码(仅存内存)。
28
+ * 不落盘是有意的:网关重启后旧码即失效,减少泄露窗口。
29
+ */
30
+ let pairCode = null;
31
+ /** 生成 6 位数字配对码,用 crypto 而非 Math.random 避免可预测 */
32
+ function generatePairCode() {
33
+ return String(crypto_1.default.randomInt(0, 1000000)).padStart(6, '0');
34
+ }
35
+ /**
36
+ * GET /api/v1/relay/status
37
+ * 中继状态。不需要鉴权:Desktop 在登录前也要据此决定展示哪些入口。
38
+ */
39
+ router.get('/status', (req, res) => {
40
+ const status = RelayClient_js_1.relayClient.getStatus();
41
+ res.json({
42
+ success: true,
43
+ data: {
44
+ enabled: (0, relayConfig_js_1.isRelayEnabled)(),
45
+ paired: (0, relayConfig_js_1.getRelayCredential)() !== null,
46
+ relayUrl: (0, relayConfig_js_1.getRelayBaseUrl)(),
47
+ protocolVersion: protocol_js_1.RELAY_PROTOCOL_VERSION,
48
+ ...status,
49
+ },
50
+ });
51
+ });
52
+ /**
53
+ * POST /api/v1/relay/pair
54
+ * 与 Server 完成配对:用当前登录用户的 token 注册本网关,换回 gatewayId 与 secret。
55
+ */
56
+ router.post('/pair', auth_js_1.requireAuth, async (req, res) => {
57
+ try {
58
+ const relayUrl = (0, relayConfig_js_1.getRelayBaseUrl)();
59
+ if (!relayUrl) {
60
+ res.status(500).json({ success: false, error: '未配置中继入口地址(RELAY_URL / SERVER_BASE_URL)' });
61
+ return;
62
+ }
63
+ const name = typeof req.body?.name === 'string' && req.body.name.trim()
64
+ ? req.body.name.trim()
65
+ : `${process.platform}-gateway`;
66
+ const response = await axios_1.default.post(`${relayUrl}/api/v1/gateways/register`, { name, platform: process.platform, version: getLocalVersion() }, {
67
+ timeout: 15000,
68
+ headers: { Authorization: `Bearer ${req.token}`, 'Content-Type': 'application/json' },
69
+ });
70
+ const data = response.data?.data;
71
+ if (!data?.gatewayId || !data?.secret) {
72
+ res.status(502).json({ success: false, error: 'Server 未返回有效的配对凭据' });
73
+ return;
74
+ }
75
+ // 属主必须记下来:它是 adopt-token 判断「谁有权让本网关采信令牌」的唯一依据。
76
+ // requireAuth 正常情况下一定填了 req.userId,但这里不做乐观假设 —— 一条缺属主的
77
+ // 凭据会让后续属主校验失去依据,宁可当场失败。
78
+ if (!req.userId) {
79
+ res.status(500).json({ success: false, error: '无法确定配对用户,请重新登录后再试' });
80
+ return;
81
+ }
82
+ (0, relayConfig_js_1.setRelayCredential)({
83
+ gatewayId: data.gatewayId,
84
+ secret: data.secret,
85
+ pairedAt: Date.now(),
86
+ userId: String(req.userId),
87
+ });
88
+ logger.info(`配对成功 gatewayId=${data.gatewayId}`);
89
+ // 配对不隐含开启:是否启用外网可达仍交由用户显式决定
90
+ res.json({ success: true, data: { gatewayId: data.gatewayId, enabled: (0, relayConfig_js_1.isRelayEnabled)() } });
91
+ }
92
+ catch (error) {
93
+ logger.error('配对失败:', error);
94
+ res.status(error.response?.status || 500).json({
95
+ success: false,
96
+ error: error.response?.data?.error || error.message || '配对失败',
97
+ });
98
+ }
99
+ });
100
+ /**
101
+ * POST /api/v1/relay/enable
102
+ * 开启中继并立即建连。
103
+ */
104
+ router.post('/enable', auth_js_1.requireAuth, (req, res) => {
105
+ if (!(0, relayConfig_js_1.getRelayCredential)()) {
106
+ res.status(400).json({ success: false, error: '尚未配对,请先调用 /relay/pair' });
107
+ return;
108
+ }
109
+ (0, relayConfig_js_1.setRelayEnabled)(true);
110
+ RelayClient_js_1.relayClient.enable();
111
+ res.json({ success: true, data: RelayClient_js_1.relayClient.getStatus() });
112
+ });
113
+ /**
114
+ * POST /api/v1/relay/disable
115
+ * 关闭中继并断开隧道。
116
+ */
117
+ router.post('/disable', auth_js_1.requireAuth, (req, res) => {
118
+ (0, relayConfig_js_1.setRelayEnabled)(false);
119
+ RelayClient_js_1.relayClient.stop('用户关闭中继');
120
+ res.json({ success: true, data: RelayClient_js_1.relayClient.getStatus() });
121
+ });
122
+ /**
123
+ * POST /api/v1/relay/unpair
124
+ * 解绑:清除本地凭据并断开。Server 侧吊销由 Desktop 另行调用,
125
+ * 此处不代劳——网关可能已无法访问 Server,不能因此阻塞本地清理。
126
+ */
127
+ router.post('/unpair', auth_js_1.requireAuth, (req, res) => {
128
+ (0, relayConfig_js_1.setRelayEnabled)(false);
129
+ RelayClient_js_1.relayClient.stop('已解绑');
130
+ (0, relayConfig_js_1.clearRelayCredential)();
131
+ res.json({ success: true });
132
+ });
133
+ /**
134
+ * POST /api/v1/relay/pair-code
135
+ * 生成一次性配对码,供用户在新设备(尤其是网页版)上确认接入。
136
+ */
137
+ router.post('/pair-code', auth_js_1.requireAuth, (req, res) => {
138
+ pairCode = { code: generatePairCode(), expiresAt: Date.now() + PAIR_CODE_TTL_MS };
139
+ res.json({ success: true, data: { code: pairCode.code, expiresAt: pairCode.expiresAt } });
140
+ });
141
+ /**
142
+ * 校验配对码(供中继链路上的接入确认使用)。
143
+ * 一次性:校验通过即失效,避免被重复利用。
144
+ */
145
+ function consumePairCode(code) {
146
+ if (!pairCode)
147
+ return false;
148
+ if (Date.now() > pairCode.expiresAt) {
149
+ pairCode = null;
150
+ return false;
151
+ }
152
+ // 定长比较,避免通过响应时间侧信道逐位猜测
153
+ const ok = code.length === pairCode.code.length &&
154
+ crypto_1.default.timingSafeEqual(Buffer.from(code), Buffer.from(pairCode.code));
155
+ if (ok)
156
+ pairCode = null;
157
+ return ok;
158
+ }
159
+ exports.consumePairCode = consumePairCode;
160
+ /** 读取网关自身版本 */
161
+ function getLocalVersion() {
162
+ try {
163
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
164
+ const { readFileSync } = require('fs');
165
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
166
+ const { resolve } = require('path');
167
+ return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version || 'unknown';
168
+ }
169
+ catch {
170
+ return 'unknown';
171
+ }
172
+ }
173
+ exports.default = router;
@@ -12,6 +12,7 @@ const axios_1 = __importDefault(require("axios"));
12
12
  const shared_1 = require("@myassis/shared");
13
13
  const os_1 = __importDefault(require("os"));
14
14
  const net_1 = __importDefault(require("net"));
15
+ const index_js_1 = require("../config/index.js");
15
16
  const logger = (0, shared_1.getLogger)('ServiceManager');
16
17
  const execAsync = (0, util_1.promisify)(child_process_1.exec);
17
18
  exports.SERVICE_NAME = 'myassis-gateway';
@@ -71,6 +72,51 @@ function getGatewayExePath() {
71
72
  const nodeExec = getNodeExec();
72
73
  return nodeExec;
73
74
  }
75
+ /**
76
+ * 探测 Gateway 是否真的在提供服务。
77
+ *
78
+ * 仅看 PID 是不够的:启错的进程(比如裸 node REPL)照样存活、PID 也写入了,
79
+ * start 会报「启动成功」而客户端根本连不上。这里真实请求一次 /health。
80
+ */
81
+ async function isGatewayServing(timeoutMs = 1500) {
82
+ try {
83
+ const res = await axios_1.default.get(`http://127.0.0.1:${index_js_1.appConfig.port}/health`, {
84
+ timeout: timeoutMs,
85
+ // 只要能返回 HTTP 响应就说明服务已监听,状态码不重要
86
+ validateStatus: () => true,
87
+ });
88
+ return !!res;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ /**
95
+ * Gateway 启动需要的命令行参数。
96
+ *
97
+ * 打包为 exe 时 exe 本身就是程序,无需参数;
98
+ * npm install -g 安装时 exe 是 node.exe,必须把 dist/index.js 传给它——
99
+ * 否则启动的是一个没有脚本的裸 node(REPL),进程存在、PID 也写了,
100
+ * 但根本没有监听端口,表现为「start 报成功但连不上」。
101
+ */
102
+ function getGatewayArgs() {
103
+ if (isPackagedExe())
104
+ return [];
105
+ const script = getServiceScript();
106
+ return script ? [script] : [];
107
+ }
108
+ /**
109
+ * Gateway 运行的工作目录。
110
+ *
111
+ * 非打包模式下不能用 node.exe 所在目录(例如 C:\Program Files\nodejs),
112
+ * 否则相对路径资源与数据目录定位都会错;应该用包目录。
113
+ */
114
+ function getGatewayWorkDir() {
115
+ if (isPackagedExe())
116
+ return path_1.default.dirname(process.execPath);
117
+ const script = getServiceScript();
118
+ return script ? path_1.default.dirname(path_1.default.dirname(script)) : path_1.default.dirname(process.execPath);
119
+ }
74
120
  /**
75
121
  * 检查 Gateway 进程是否在运行
76
122
  */
@@ -281,9 +327,15 @@ async function stopGatewayProcess() {
281
327
  function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
282
328
  // 写入 BOM + UTF-8 内容
283
329
  const bs = (p) => p.replace(/\\/g, '\\\\');
330
+ // npm 安装模式下 exe 是 node.exe,必须带上入口脚本才能真正启动服务
331
+ const launchArgs = getGatewayArgs();
332
+ const argListLiteral = launchArgs.length > 0
333
+ ? '@(' + launchArgs.map(a => `'${bs(a)}'`).join(', ') + ')'
334
+ : '@()';
284
335
  const content = [
285
336
  "$ErrorActionPreference = 'Continue'",
286
337
  `$exe = '${bs(exePath)}'`,
338
+ `$gatewayArgs = ${argListLiteral}`,
287
339
  `$workDir = '${bs(workDir)}'`,
288
340
  `$pidFile = '${bs(GATEWAY_PID_FILE)}'`,
289
341
  `$stopFlag = '${bs(GATEWAY_STOP_FLAG_FILE)}'`,
@@ -306,7 +358,13 @@ function writeGatewayLauncherScript(exePath, scriptPath, workDir) {
306
358
  ' # 1) 管道缓冲区填满后 gateway 卡死(4KB 缓冲区无人读取)',
307
359
  ' # 2) 父 PS 句柄继承导致 gateway 写 stdout 崩溃',
308
360
  ' try {',
309
- ' $proc = Start-Process -FilePath $exe -WorkingDirectory $workDir -WindowStyle Hidden -PassThru -ErrorAction Stop',
361
+ ' # 生产模式标记:main.ts 靠它判定是否重定向 stdout 到日志文件',
362
+ " $env:NODE_ENV = 'production'",
363
+ ' if ($gatewayArgs.Count -gt 0) {',
364
+ ' $proc = Start-Process -FilePath $exe -ArgumentList $gatewayArgs -WorkingDirectory $workDir -WindowStyle Hidden -PassThru -ErrorAction Stop',
365
+ ' } else {',
366
+ ' $proc = Start-Process -FilePath $exe -WorkingDirectory $workDir -WindowStyle Hidden -PassThru -ErrorAction Stop',
367
+ ' }',
310
368
  ' if ($proc -and $proc.Id) {',
311
369
  ' $proc.Id.ToString() | Out-File -FilePath $pidFile -Encoding UTF8',
312
370
  " Write-Log \"Gateway 已启动,PID=$($proc.Id)\"",
@@ -427,7 +485,7 @@ async function unregisterRunKey() {
427
485
  // ─── Windows 用户级实现 ─────────────────────────────────────
428
486
  async function installWindows() {
429
487
  const exe = getGatewayExePath();
430
- const workDir = path_1.default.dirname(exe);
488
+ const workDir = getGatewayWorkDir();
431
489
  logger.info(`installWindows: exe=${exe}, workDir=${workDir}`);
432
490
  const { installed } = await queryServiceWindows();
433
491
  if (installed)
@@ -469,7 +527,7 @@ async function uninstallWindows() {
469
527
  }
470
528
  }
471
529
  async function startServiceWindows() {
472
- if (isGatewayRunning()) {
530
+ if (isGatewayRunning() && await isGatewayServing()) {
473
531
  return { success: true, message: 'Gateway 已在运行' };
474
532
  }
475
533
  try {
@@ -479,20 +537,32 @@ async function startServiceWindows() {
479
537
  }
480
538
  catch { /* ignore */ }
481
539
  const exe = getGatewayExePath();
482
- const workDir = path_1.default.dirname(exe);
540
+ const workDir = getGatewayWorkDir();
483
541
  if (!fs_1.default.existsSync(GATEWAY_LAUNCHER_FILE)) {
484
542
  writeGatewayLauncherScript(exe, GATEWAY_LAUNCHER_FILE, workDir);
485
543
  }
486
544
  // 使用 Start-Process 在后台启动守护脚本(-WindowStyle Hidden 隐藏窗口)
487
545
  // 守护脚本会持续运行并监控 gateway 进程,异常时自动重启
488
546
  await execAsync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File','${GATEWAY_LAUNCHER_FILE}' -WindowStyle Hidden"`, { timeout: 5000, windowsHide: true });
489
- const maxWait = 8000;
547
+ // 等到真正能接请求为止:进程起来到监听端口之间还要跑迁移、加载模型等,
548
+ // 只等 PID 会在服务就绪前就报成功,紧跟着的连接就会失败。
549
+ const maxWait = 30000;
490
550
  const start = Date.now();
491
- while (!isGatewayRunning() && Date.now() - start < maxWait) {
551
+ let processSeen = false;
552
+ while (Date.now() - start < maxWait) {
553
+ if (isGatewayRunning()) {
554
+ processSeen = true;
555
+ if (await isGatewayServing()) {
556
+ return { success: true, message: 'Gateway 启动成功(已启用异常自动重启)' };
557
+ }
558
+ }
492
559
  await new Promise(r => setTimeout(r, 500));
493
560
  }
494
- if (isGatewayRunning()) {
495
- return { success: true, message: 'Gateway 启动成功(已启用异常自动重启)' };
561
+ if (processSeen) {
562
+ return {
563
+ success: false,
564
+ message: `Gateway 进程已启动但 ${index_js_1.appConfig.port} 端口未就绪,请查看日志: ${path_1.default.join(GATEWAY_DATA_DIR, 'gateway-daemon.log')}`,
565
+ };
496
566
  }
497
567
  return { success: false, message: 'Gateway 进程未能启动,请检查日志' };
498
568
  }
@@ -719,7 +789,7 @@ async function ensureAutoStartHealthy() {
719
789
  logger.warn(`检测到 Run key 不健康,自动修复。current='${currentValue}'`);
720
790
  // 4) 重写 launcher 脚本到新路径
721
791
  const exe = getGatewayExePath();
722
- const workDir = path_1.default.dirname(exe);
792
+ const workDir = getGatewayWorkDir();
723
793
  writeGatewayLauncherScript(exe, GATEWAY_LAUNCHER_FILE, workDir);
724
794
  // 5) 重新注册完整 powershell 命令行
725
795
  await registerRunKey(GATEWAY_LAUNCHER_FILE);