@myassis/gateway 1.0.83 → 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 +4 -0
- package/dist/middleware/broadcast.js +160 -0
- package/dist/routes/agent.js +4 -2
- package/dist/routes/auth.js +5 -4
- package/dist/services/WebSocketService.js +110 -47
- package/dist/services/dataService.js +5 -6
- package/dist/services/session/Session.js +62 -3
- package/dist/stores/authStore.js +132 -23
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -25,6 +25,7 @@ const upload_js_1 = __importDefault(require("./routes/upload.js"));
|
|
|
25
25
|
const version_js_1 = __importDefault(require("./routes/version.js"));
|
|
26
26
|
const quota_js_1 = __importDefault(require("./routes/quota.js"));
|
|
27
27
|
const errorHandler_js_1 = require("./middleware/errorHandler.js");
|
|
28
|
+
const broadcast_js_1 = require("./middleware/broadcast.js");
|
|
28
29
|
const index_js_2 = require("./stores/index.js");
|
|
29
30
|
const persistStore_js_1 = require("./stores/persistStore.js");
|
|
30
31
|
const WebSocketService_js_1 = require("./services/WebSocketService.js");
|
|
@@ -273,6 +274,9 @@ else {
|
|
|
273
274
|
app.get('/health', (req, res) => {
|
|
274
275
|
res.json({ status: 'ok', service: 'gateway', version: '2.0.0', wsOnline: WebSocketService_js_1.webSocketService.getOnlineCount() });
|
|
275
276
|
});
|
|
277
|
+
// 写请求成功后向该用户的其他终端广播数据变更(多终端同步)。
|
|
278
|
+
// 必须注册在业务路由之前:它通过包装 res.json 生效。
|
|
279
|
+
app.use(broadcast_js_1.broadcastDataChanges);
|
|
276
280
|
// Routes
|
|
277
281
|
app.use('/api/v1/auth', auth_js_1.default);
|
|
278
282
|
app.use('/api/v1/agent', agent_js_1.default);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 数据变更广播中间件
|
|
4
|
+
*
|
|
5
|
+
* 同一账号可能在多个终端登录。任何一个终端调用写接口(POST/PUT/PATCH/DELETE)
|
|
6
|
+
* 修改了数据后,其他终端的本地缓存就过期了,必须重新拉取才能看到最新状态。
|
|
7
|
+
* 本中间件在写请求成功返回后,向该用户的其他终端广播一条 `data_changed`,
|
|
8
|
+
* 由终端决定刷新哪部分数据。
|
|
9
|
+
*
|
|
10
|
+
* 设计取舍:
|
|
11
|
+
* - 只广播「什么变了」,不广播变更后的完整数据。写接口的响应体格式各异
|
|
12
|
+
* (有的返回实体、有的只返回 success),统一封装成本高且容易泄漏内部结构;
|
|
13
|
+
* 让终端按资源类型走既有的加载逻辑更可靠。
|
|
14
|
+
* - 只在响应成功时广播。失败的写请求没有改变任何状态,广播会导致无谓刷新。
|
|
15
|
+
* - 发起端通过 clientId 排除。它自己已经拿到了响应,会走本地更新逻辑,
|
|
16
|
+
* 再收一次广播会造成重复请求甚至覆盖掉刚提交的本地状态。
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.broadcastDataChanges = void 0;
|
|
20
|
+
const shared_1 = require("@myassis/shared");
|
|
21
|
+
const WebSocketService_js_1 = require("../services/WebSocketService.js");
|
|
22
|
+
const logger = (0, shared_1.getLogger)('middleware/broadcast');
|
|
23
|
+
/**
|
|
24
|
+
* 路由前缀 -> 资源类型。
|
|
25
|
+
*
|
|
26
|
+
* 以挂载路径的第一段判定,避免为每个 handler 单独标注。
|
|
27
|
+
*/
|
|
28
|
+
const RESOURCE_BY_MOUNT = {
|
|
29
|
+
agent: 'agent',
|
|
30
|
+
models: 'model',
|
|
31
|
+
skills: 'skill',
|
|
32
|
+
tasks: 'task',
|
|
33
|
+
settings: 'settings',
|
|
34
|
+
quota: 'quota',
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* 不广播的路径片段。
|
|
38
|
+
*
|
|
39
|
+
* - auth/service/version:认证与本机服务管理,属于终端本地行为,
|
|
40
|
+
* 广播出去会让其他终端做无意义的刷新甚至误判登录态。
|
|
41
|
+
* - stream/reset:会话流有专门的 session_stream 同步通道(见 Session.streamChat),
|
|
42
|
+
* 这里再广播一次会造成重复渲染。
|
|
43
|
+
* - upload:上传结果通过消息体引用,消息本身会广播。
|
|
44
|
+
* - parse/api-key/rate/rating:不改变列表结构,或属于一次性动作。
|
|
45
|
+
*/
|
|
46
|
+
const SKIPPED_PATH_SEGMENTS = ['stream', 'reset', 'parse', 'api-key', 'set-current'];
|
|
47
|
+
/** 业务路由的公共前缀,用于从完整路径中定位挂载段 */
|
|
48
|
+
const API_PREFIX_SEGMENTS = ['api', 'v1'];
|
|
49
|
+
/** HTTP 方法 -> 变更动作 */
|
|
50
|
+
function toAction(method) {
|
|
51
|
+
switch (method.toUpperCase()) {
|
|
52
|
+
case 'POST':
|
|
53
|
+
return 'created';
|
|
54
|
+
case 'PUT':
|
|
55
|
+
case 'PATCH':
|
|
56
|
+
return 'updated';
|
|
57
|
+
case 'DELETE':
|
|
58
|
+
return 'deleted';
|
|
59
|
+
default:
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 从请求路径推断资源类型。
|
|
65
|
+
*
|
|
66
|
+
* 注意:本中间件挂在 app 级别(早于所有 router.use),此时 Express 还没有匹配到
|
|
67
|
+
* 任何 router,req.baseUrl 恒为空字符串,req.path 才是完整路径 /api/v1/agent/xxx。
|
|
68
|
+
* 因此必须跳过 api/v1 前缀后取挂载段,不能依赖 req.baseUrl。
|
|
69
|
+
*/
|
|
70
|
+
function toResource(path) {
|
|
71
|
+
const segments = path.split('/').filter(Boolean);
|
|
72
|
+
let index = 0;
|
|
73
|
+
while (index < API_PREFIX_SEGMENTS.length && segments[index] === API_PREFIX_SEGMENTS[index]) {
|
|
74
|
+
index += 1;
|
|
75
|
+
}
|
|
76
|
+
const mount = segments[index];
|
|
77
|
+
return mount ? RESOURCE_BY_MOUNT[mount] ?? null : null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 细化 agent 路由下的资源类型。
|
|
81
|
+
*
|
|
82
|
+
* /api/v1/agent 同时承载 agent、session 和 message 三种资源,
|
|
83
|
+
* 只看挂载路径会把会话与消息变更都标成 agent,导致终端刷新范围过大。
|
|
84
|
+
*/
|
|
85
|
+
function refineAgentResource(path) {
|
|
86
|
+
if (/\/messages(\/|$)/.test(path))
|
|
87
|
+
return 'message';
|
|
88
|
+
if (/\/sessions(\/|$)/.test(path))
|
|
89
|
+
return 'session';
|
|
90
|
+
return 'agent';
|
|
91
|
+
}
|
|
92
|
+
/** 响应体是否表示成功(无法判定时按成功处理) */
|
|
93
|
+
function isSuccessful(statusCode, body) {
|
|
94
|
+
if (statusCode < 200 || statusCode >= 300)
|
|
95
|
+
return false;
|
|
96
|
+
if (body && typeof body === 'object' && 'success' in body) {
|
|
97
|
+
return body.success !== false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 广播数据变更中间件。
|
|
103
|
+
*
|
|
104
|
+
* 挂在路由之前,通过包装 res.json 在响应发出后广播,
|
|
105
|
+
* 这样不需要改动任何 handler。
|
|
106
|
+
*/
|
|
107
|
+
function broadcastDataChanges(req, res, next) {
|
|
108
|
+
const action = toAction(req.method);
|
|
109
|
+
if (!action) {
|
|
110
|
+
next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const resource = toResource(req.path);
|
|
114
|
+
if (!resource) {
|
|
115
|
+
next();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const pathSegments = req.path.split('/').filter(Boolean);
|
|
119
|
+
if (pathSegments.some((segment) => SKIPPED_PATH_SEGMENTS.includes(segment))) {
|
|
120
|
+
next();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const originalJson = res.json.bind(res);
|
|
124
|
+
res.json = (body) => {
|
|
125
|
+
// 先把响应交回给发起端,广播失败不能影响本次请求
|
|
126
|
+
const result = originalJson(body);
|
|
127
|
+
try {
|
|
128
|
+
// userId 由 requireAuth 写入;未认证的写请求无从判断归属,直接跳过
|
|
129
|
+
const userId = req.userId;
|
|
130
|
+
if (userId && isSuccessful(res.statusCode, body)) {
|
|
131
|
+
const finalResource = resource === 'agent' ? refineAgentResource(req.path) : resource;
|
|
132
|
+
WebSocketService_js_1.webSocketService.sendToUser(String(userId), {
|
|
133
|
+
type: 'data_changed',
|
|
134
|
+
payload: {
|
|
135
|
+
resource: finalResource,
|
|
136
|
+
action,
|
|
137
|
+
// 提供原始路径与方法,便于终端做更精细的判断或排查问题
|
|
138
|
+
path: req.originalUrl,
|
|
139
|
+
method: req.method,
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
},
|
|
142
|
+
}, { excludeClientId: getClientId(req) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
logger.error('广播数据变更失败:', error);
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
};
|
|
150
|
+
next();
|
|
151
|
+
}
|
|
152
|
+
exports.broadcastDataChanges = broadcastDataChanges;
|
|
153
|
+
/** 读取发起端的终端标识 */
|
|
154
|
+
function getClientId(req) {
|
|
155
|
+
const header = req.headers['x-client-id'];
|
|
156
|
+
if (typeof header === 'string' && header)
|
|
157
|
+
return header;
|
|
158
|
+
const fromBody = req.body?.clientId;
|
|
159
|
+
return typeof fromBody === 'string' && fromBody ? fromBody : undefined;
|
|
160
|
+
}
|
package/dist/routes/agent.js
CHANGED
|
@@ -440,7 +440,9 @@ router.post('/sessions/:sessionId/stream', ensureAgentManager, async (req, res)
|
|
|
440
440
|
try {
|
|
441
441
|
const userId = req.userId;
|
|
442
442
|
const { sessionId } = req.params;
|
|
443
|
-
const { content, attachments, userMessageId, assistantMessageId } = req.body;
|
|
443
|
+
const { content, attachments, userMessageId, assistantMessageId, clientId } = req.body;
|
|
444
|
+
// 终端标识:用于把本次流事件同步给该用户的其他终端时排除发起端
|
|
445
|
+
const streamClientId = clientId || req.headers['x-client-id'] || undefined;
|
|
444
446
|
if (!content && attachments.length === 0) {
|
|
445
447
|
return res.status(400).json({ success: false, error: 'Content or attachments are required' });
|
|
446
448
|
}
|
|
@@ -454,7 +456,7 @@ router.post('/sessions/:sessionId/stream', ensureAgentManager, async (req, res)
|
|
|
454
456
|
res.setHeader('Connection', 'keep-alive');
|
|
455
457
|
res.flushHeaders();
|
|
456
458
|
// Stream response
|
|
457
|
-
await session.streamChat(content, attachments || [], res, userMessageId, assistantMessageId);
|
|
459
|
+
await session.streamChat(content, attachments || [], res, userMessageId, assistantMessageId, false, streamClientId);
|
|
458
460
|
return;
|
|
459
461
|
}
|
|
460
462
|
res.status(404).json({ success: false, error: 'Session not found' });
|
package/dist/routes/auth.js
CHANGED
|
@@ -100,9 +100,8 @@ router.post('/refresh', async (req, res) => {
|
|
|
100
100
|
res.status(400).json({ success: false, error: 'Missing refresh token' });
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
|
-
// 刷新前先找到持有该 refreshToken 的旧 accessToken
|
|
104
|
-
const oldToken =
|
|
105
|
-
.find(x => x.refreshToken === refreshToken)?.accessToken;
|
|
103
|
+
// 刷新前先找到持有该 refreshToken 的旧 accessToken,用于定位该终端的会话
|
|
104
|
+
const oldToken = index_js_2.authStore.getByRefreshToken(refreshToken)?.accessToken;
|
|
106
105
|
const response = await index_js_1.authApi.refresh(refreshToken);
|
|
107
106
|
// server 可能返回 {accessToken} 或 {data:{accessToken}},两种都兼容
|
|
108
107
|
const accessToken = response.data?.accessToken ?? response.accessToken;
|
|
@@ -139,7 +138,9 @@ router.post('/refresh', async (req, res) => {
|
|
|
139
138
|
router.post('/logout', auth_js_1.requireAuth, async (req, res) => {
|
|
140
139
|
const token = req.token;
|
|
141
140
|
// 优先用客户端上报的 refreshToken,缺失时回退到 Gateway 本地持久化的那一个
|
|
142
|
-
|
|
141
|
+
// 只登出当前终端:refreshToken 需按本次请求的 accessToken 定位,
|
|
142
|
+
// 否则会失效该用户在其他终端的会话
|
|
143
|
+
const refreshToken = req.body?.refreshToken || index_js_2.authStore.getRefreshTokenByToken(token) || undefined;
|
|
143
144
|
try {
|
|
144
145
|
if (refreshToken) {
|
|
145
146
|
// 用 AsyncLocalStorage 将 token 注入 server 请求上下文;
|
|
@@ -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;
|
|
@@ -40,6 +40,13 @@ const ContextBuilder_js_1 = require("../memory/ContextBuilder.js");
|
|
|
40
40
|
const SessionManager_js_1 = require("./SessionManager.js");
|
|
41
41
|
const logger = (0, shared_1.getLogger)('Session');
|
|
42
42
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
43
|
+
/**
|
|
44
|
+
* 不向其他终端镜像的 SSE 事件类型。
|
|
45
|
+
*
|
|
46
|
+
* heartbeat 仅用于保持 SSE 连接存活;approval_pending 需要发起端弹窗确认,
|
|
47
|
+
* 广播出去会让多个终端同时弹窗并重复回传批准结果。
|
|
48
|
+
*/
|
|
49
|
+
const NON_MIRRORED_SSE_EVENTS = new Set(['heartbeat', 'approval_pending']);
|
|
43
50
|
// 批准等待缓存:token -> { resolve, reject, expiresAt }
|
|
44
51
|
const approvalWaiters = new Map();
|
|
45
52
|
/**
|
|
@@ -501,9 +508,21 @@ class Session {
|
|
|
501
508
|
* 用动态 import 获取 webSocketService:WebSocketService 反向依赖本文件的
|
|
502
509
|
* handleApprovalResponse,静态引入会形成循环依赖。
|
|
503
510
|
*/
|
|
504
|
-
async
|
|
511
|
+
async getWebSocketService() {
|
|
505
512
|
try {
|
|
506
513
|
const { webSocketService } = await Promise.resolve().then(() => __importStar(require('../WebSocketService.js')));
|
|
514
|
+
return webSocketService;
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
logger.error('加载 WebSocketService 失败:', error);
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async notifyPlanUpdate() {
|
|
522
|
+
try {
|
|
523
|
+
const webSocketService = await this.getWebSocketService();
|
|
524
|
+
if (!webSocketService)
|
|
525
|
+
return;
|
|
507
526
|
webSocketService.sendToUser(String(this.userId), {
|
|
508
527
|
type: 'plan_updated',
|
|
509
528
|
payload: {
|
|
@@ -587,7 +606,7 @@ class Session {
|
|
|
587
606
|
* Stream chat response (SSE) - 使用 ModelSelector 根据选择的模型调用
|
|
588
607
|
* 支持工具调用流程
|
|
589
608
|
*/
|
|
590
|
-
async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false) {
|
|
609
|
+
async streamChat(content, attachments = [], res, userMessageId, assistantMessageId, childAgent = false, clientId) {
|
|
591
610
|
// 如果正在生成,等待当前生成完成(最多等待 2 秒)
|
|
592
611
|
if (this.isGenerating) {
|
|
593
612
|
const maxWait = 2000;
|
|
@@ -615,8 +634,35 @@ class Session {
|
|
|
615
634
|
this.currentMessageId = assistantMessageId;
|
|
616
635
|
const memoryManager = new MemoryManager_js_1.MemoryManager(this, this.abortController.signal, childAgent, res);
|
|
617
636
|
const historyMessages = await memoryManager.getHistoryMessagesAsync();
|
|
618
|
-
// SSE
|
|
637
|
+
// 同一用户可能同时在多个终端登录,而 SSE 只能回给发起请求的那个终端。
|
|
638
|
+
// 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
|
|
639
|
+
// 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
|
|
640
|
+
const wsService = childAgent ? null : await this.getWebSocketService();
|
|
641
|
+
/** 把 SSE 事件镜像给当前用户的其他终端(排除发起端,避免重复渲染) */
|
|
642
|
+
const mirrorToOtherClients = (data) => {
|
|
643
|
+
if (!wsService)
|
|
644
|
+
return;
|
|
645
|
+
if (NON_MIRRORED_SSE_EVENTS.has(data?.type))
|
|
646
|
+
return;
|
|
647
|
+
try {
|
|
648
|
+
wsService.sendToUser(String(this.userId), {
|
|
649
|
+
type: 'session_stream',
|
|
650
|
+
payload: {
|
|
651
|
+
sessionId: this.id,
|
|
652
|
+
agentId: this.agentId,
|
|
653
|
+
userMessageId,
|
|
654
|
+
assistantMessageId,
|
|
655
|
+
event: data,
|
|
656
|
+
},
|
|
657
|
+
}, { excludeClientId: clientId });
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
logger.error('SSE mirror error:', error);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
// SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
|
|
619
664
|
const sendSSE = (res, data) => {
|
|
665
|
+
mirrorToOtherClients(data);
|
|
620
666
|
if (!res)
|
|
621
667
|
return;
|
|
622
668
|
try {
|
|
@@ -636,6 +682,19 @@ class Session {
|
|
|
636
682
|
clearInterval(heartbeatInterval);
|
|
637
683
|
}
|
|
638
684
|
}, 15000);
|
|
685
|
+
// 其他终端没有参与本次请求,需要补一条用户消息才能对齐会话。
|
|
686
|
+
// 必须在 message_start 之前同步:否则接收端会先插入助手占位,
|
|
687
|
+
// 导致助手消息排在用户消息前面。
|
|
688
|
+
//
|
|
689
|
+
// 同时带上助手占位标记:接收端据此在用户消息后面立即插入「思考中」
|
|
690
|
+
// 占位气泡,与发起端表现一致(占位文案由接收端按自身语言生成)。
|
|
691
|
+
if (!childAgent) {
|
|
692
|
+
mirrorToOtherClients({
|
|
693
|
+
type: 'user_message',
|
|
694
|
+
message: this.getUserMessage(content, userMessageId, attachments),
|
|
695
|
+
assistantPlaceholder: true,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
639
698
|
// Send message start event
|
|
640
699
|
sendSSE(res, { type: 'message_start' });
|
|
641
700
|
// Build messages for API call (保留 tool_call_id 等必要字段)
|
package/dist/stores/authStore.js
CHANGED
|
@@ -17,8 +17,36 @@ const logger = (0, shared_1.getLogger)('authStore');
|
|
|
17
17
|
// 认证数据存储文件路径(使用应用数据目录,支持 STORAGE_DIR 环境变量)
|
|
18
18
|
const AUTH_STORAGE_DIR = process.env.STORAGE_DIR || path_1.default.join(os_1.default.homedir(), 'myassis-gateway-storage');
|
|
19
19
|
const AUTH_STORAGE_FILE = path_1.default.join(AUTH_STORAGE_DIR, 'auth', 'auth.json');
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* 内存中的认证数据:userId -> 该用户的所有终端会话(新的在前)。
|
|
22
|
+
*
|
|
23
|
+
* 同一账号可以在多个终端(桌面、移动端等)同时登录,每个终端持有
|
|
24
|
+
* 各自的 accessToken/refreshToken,所以这里必须保存多条而不是一条,
|
|
25
|
+
* 否则后登录的终端会把先登录的挤下线,先登录终端的请求会全部 401。
|
|
26
|
+
*/
|
|
21
27
|
let authMap = null;
|
|
28
|
+
/** 单个用户保留的最大会话数,防止反复登录导致无限增长 */
|
|
29
|
+
const MAX_SESSIONS_PER_USER = 20;
|
|
30
|
+
/** 会话是否已过期(没有 expiresAt 的旧数据视为未过期) */
|
|
31
|
+
function isExpired(auth) {
|
|
32
|
+
return typeof auth.expiresAt === 'number' && auth.expiresAt <= Date.now();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 规整某个用户的会话列表:去掉过期会话、按登录时间倒序、限制数量。
|
|
36
|
+
* 全部过期时保留最新一条,避免用户凭据被清空后无法自愈刷新。
|
|
37
|
+
*/
|
|
38
|
+
function normalizeSessions(sessions) {
|
|
39
|
+
const sorted = [...sessions].sort((a, b) => (b.loginAt ?? 0) - (a.loginAt ?? 0));
|
|
40
|
+
const alive = sorted.filter((x) => !isExpired(x));
|
|
41
|
+
const kept = alive.length > 0 ? alive : sorted.slice(0, 1);
|
|
42
|
+
return kept.slice(0, MAX_SESSIONS_PER_USER);
|
|
43
|
+
}
|
|
44
|
+
/** 遍历所有用户的所有会话 */
|
|
45
|
+
function allSessions() {
|
|
46
|
+
if (!authMap)
|
|
47
|
+
return [];
|
|
48
|
+
return Array.from(authMap.values()).flat();
|
|
49
|
+
}
|
|
22
50
|
// 确保存储目录存在
|
|
23
51
|
function ensureStorageDir() {
|
|
24
52
|
const dir = path_1.default.dirname(AUTH_STORAGE_FILE);
|
|
@@ -32,8 +60,13 @@ function loadFromFile() {
|
|
|
32
60
|
if (fs_1.default.existsSync(AUTH_STORAGE_FILE)) {
|
|
33
61
|
const data = fs_1.default.readFileSync(AUTH_STORAGE_FILE, 'utf-8');
|
|
34
62
|
const obj = JSON.parse(data);
|
|
63
|
+
// 兼容旧格式:userId -> AuthData(单终端),统一升级为会话数组
|
|
64
|
+
const entries = Object.entries(obj).map(([userId, value]) => {
|
|
65
|
+
const sessions = Array.isArray(value) ? value : [value];
|
|
66
|
+
return [userId, normalizeSessions(sessions.filter(Boolean))];
|
|
67
|
+
});
|
|
35
68
|
logger.debug('Auth data loaded from file');
|
|
36
|
-
return new Map(
|
|
69
|
+
return new Map(entries.filter(([, sessions]) => sessions.length > 0));
|
|
37
70
|
}
|
|
38
71
|
}
|
|
39
72
|
catch (error) {
|
|
@@ -90,36 +123,89 @@ exports.authStore = {
|
|
|
90
123
|
if (authMap === null) {
|
|
91
124
|
this.load();
|
|
92
125
|
}
|
|
93
|
-
|
|
126
|
+
const userId = authData.user.id;
|
|
127
|
+
const session = { ...authData, loginAt: authData.loginAt ?? Date.now() };
|
|
128
|
+
// 多终端并存:新增一条会话,而不是覆盖该用户已有的会话。
|
|
129
|
+
// 同一 token/refreshToken 重复登录时替换旧记录,避免重复堆积。
|
|
130
|
+
const existing = (authMap.get(userId) ?? []).filter((x) => x.accessToken !== session.accessToken &&
|
|
131
|
+
(!session.refreshToken || x.refreshToken !== session.refreshToken));
|
|
132
|
+
authMap.set(userId, normalizeSessions([session, ...existing]));
|
|
94
133
|
saveAuthToFile(authMap);
|
|
95
134
|
// 用户登录后初始化 SessionManager
|
|
96
|
-
(0, index_js_1.getSessionManager)(
|
|
97
|
-
logger.debug(
|
|
135
|
+
(0, index_js_1.getSessionManager)(userId).initialize();
|
|
136
|
+
logger.debug(`Auth saved for user: ${authData.user.nickname}(会话数: ${authMap.get(userId).length})`);
|
|
98
137
|
},
|
|
99
|
-
|
|
138
|
+
/**
|
|
139
|
+
* 获取所有认证用户(每个用户一条代表性会话)
|
|
140
|
+
*
|
|
141
|
+
* 供后台任务按用户维度轮询使用,因此这里按用户去重,
|
|
142
|
+
* 需要某个用户的全部终端会话时用 getSessions。
|
|
143
|
+
*/
|
|
100
144
|
getAll() {
|
|
101
145
|
if (authMap === null) {
|
|
102
146
|
this.load();
|
|
103
147
|
}
|
|
104
|
-
|
|
148
|
+
const result = new Map();
|
|
149
|
+
authMap.forEach((sessions, userId) => {
|
|
150
|
+
const current = normalizeSessions(sessions)[0];
|
|
151
|
+
if (current) {
|
|
152
|
+
result.set(userId, current);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return result;
|
|
105
156
|
},
|
|
106
157
|
/**
|
|
107
|
-
*
|
|
158
|
+
* 获取某个用户的所有终端会话(新的在前)
|
|
159
|
+
*/
|
|
160
|
+
getSessions(userId) {
|
|
161
|
+
if (authMap === null) {
|
|
162
|
+
this.load();
|
|
163
|
+
}
|
|
164
|
+
return normalizeSessions(authMap.get(String(userId)) ?? []);
|
|
165
|
+
},
|
|
166
|
+
/**
|
|
167
|
+
* 获取用户当前可用的认证数据(多终端时取最新的有效会话)
|
|
108
168
|
*/
|
|
109
169
|
get(userId) {
|
|
110
|
-
return
|
|
170
|
+
return this.getSessions(userId)[0] || null;
|
|
171
|
+
},
|
|
172
|
+
/**
|
|
173
|
+
* 按 accessToken 精确获取某个终端的认证数据
|
|
174
|
+
*/
|
|
175
|
+
getByToken(token) {
|
|
176
|
+
if (authMap === null) {
|
|
177
|
+
this.load();
|
|
178
|
+
}
|
|
179
|
+
return allSessions().find((x) => x.accessToken === token) || null;
|
|
180
|
+
},
|
|
181
|
+
/**
|
|
182
|
+
* 按 refreshToken 精确获取某个终端的认证数据
|
|
183
|
+
*/
|
|
184
|
+
getByRefreshToken(refreshToken) {
|
|
185
|
+
if (authMap === null) {
|
|
186
|
+
this.load();
|
|
187
|
+
}
|
|
188
|
+
return allSessions().find((x) => x.refreshToken === refreshToken) || null;
|
|
111
189
|
},
|
|
112
190
|
/**
|
|
113
191
|
* 获取用户信息
|
|
114
192
|
*/
|
|
115
193
|
getUser(userId) {
|
|
116
|
-
return
|
|
194
|
+
return this.get(userId)?.user || null;
|
|
117
195
|
},
|
|
118
196
|
/**
|
|
119
|
-
* 获取刷新 Token
|
|
197
|
+
* 获取刷新 Token(多终端时取最新会话的)
|
|
120
198
|
*/
|
|
121
199
|
getRefreshToken(userId) {
|
|
122
|
-
return
|
|
200
|
+
return this.get(userId)?.refreshToken || null;
|
|
201
|
+
},
|
|
202
|
+
/**
|
|
203
|
+
* 按 accessToken 获取对应终端的刷新 Token
|
|
204
|
+
*
|
|
205
|
+
* 登出只应失效发起登出的那个终端,所以不能退化成用户维度的最新会话。
|
|
206
|
+
*/
|
|
207
|
+
getRefreshTokenByToken(token) {
|
|
208
|
+
return this.getByToken(token)?.refreshToken || null;
|
|
123
209
|
},
|
|
124
210
|
/**
|
|
125
211
|
* 更新 Token
|
|
@@ -138,8 +224,9 @@ exports.authStore = {
|
|
|
138
224
|
return false;
|
|
139
225
|
}
|
|
140
226
|
// 按旧 token 定位记录;若传入的旧 token 已被替换过,则回退用新 token 匹配(幂等)
|
|
141
|
-
const
|
|
142
|
-
|
|
227
|
+
const sessions = allSessions();
|
|
228
|
+
const auth = sessions.find(x => x.accessToken === oldToken)
|
|
229
|
+
|| sessions.find(x => x.accessToken === newToken);
|
|
143
230
|
if (!auth) {
|
|
144
231
|
logger.warn('updateToken: no auth record matched the given token');
|
|
145
232
|
return false;
|
|
@@ -161,7 +248,7 @@ exports.authStore = {
|
|
|
161
248
|
*/
|
|
162
249
|
getUserId(token) {
|
|
163
250
|
if (authMap) {
|
|
164
|
-
return
|
|
251
|
+
return allSessions().find(x => x.accessToken === token)?.user?.id || null;
|
|
165
252
|
}
|
|
166
253
|
else {
|
|
167
254
|
return null;
|
|
@@ -176,22 +263,24 @@ exports.authStore = {
|
|
|
176
263
|
logger.debug('Auth cleared');
|
|
177
264
|
},
|
|
178
265
|
/**
|
|
179
|
-
*
|
|
266
|
+
* 移除某个用户的全部终端会话(注销账号等场景)
|
|
180
267
|
* @returns 是否确实移除了记录
|
|
181
268
|
*/
|
|
182
269
|
remove(userId) {
|
|
183
270
|
if (!authMap) {
|
|
184
271
|
this.load();
|
|
185
272
|
}
|
|
186
|
-
const removed = authMap.delete(userId);
|
|
273
|
+
const removed = authMap.delete(String(userId));
|
|
187
274
|
if (removed) {
|
|
188
275
|
saveAuthToFile(authMap);
|
|
189
|
-
logger.debug('
|
|
276
|
+
logger.debug('All auth sessions removed for user:', userId);
|
|
190
277
|
}
|
|
191
278
|
return removed;
|
|
192
279
|
},
|
|
193
280
|
/**
|
|
194
|
-
* 按 accessToken
|
|
281
|
+
* 按 accessToken 移除单个终端的会话
|
|
282
|
+
*
|
|
283
|
+
* 只下线发起登出的终端,该用户在其他终端的登录状态保持不变。
|
|
195
284
|
* @returns 是否确实移除了记录
|
|
196
285
|
*/
|
|
197
286
|
removeByToken(token) {
|
|
@@ -199,17 +288,37 @@ exports.authStore = {
|
|
|
199
288
|
if (!userId) {
|
|
200
289
|
return false;
|
|
201
290
|
}
|
|
202
|
-
|
|
291
|
+
const sessions = authMap.get(userId) ?? [];
|
|
292
|
+
const remaining = sessions.filter((x) => x.accessToken !== token);
|
|
293
|
+
if (remaining.length === sessions.length) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (remaining.length > 0) {
|
|
297
|
+
authMap.set(userId, remaining);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
authMap.delete(userId);
|
|
301
|
+
}
|
|
302
|
+
saveAuthToFile(authMap);
|
|
303
|
+
logger.debug(`Auth session removed for user: ${userId}(剩余会话数: ${remaining.length})`);
|
|
304
|
+
return true;
|
|
203
305
|
},
|
|
204
306
|
/**
|
|
205
307
|
* 更新用户信息
|
|
206
308
|
*/
|
|
207
309
|
updateUser(token, userData) {
|
|
208
310
|
if (authMap) {
|
|
209
|
-
const
|
|
210
|
-
|
|
311
|
+
const target = this.getByToken(token);
|
|
312
|
+
if (!target) {
|
|
313
|
+
logger.warn('updateUser: no auth session matched the given token');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// 用户资料是账号级信息,需要同步到该用户的所有终端会话
|
|
317
|
+
this.getSessions(target.user.id).forEach((auth) => {
|
|
318
|
+
auth.user = { ...auth.user, ...userData };
|
|
319
|
+
});
|
|
211
320
|
saveAuthToFile(authMap);
|
|
212
|
-
logger.debug('User updated:',
|
|
321
|
+
logger.debug('User updated:', target.user?.nickname || target.user?.account);
|
|
213
322
|
}
|
|
214
323
|
},
|
|
215
324
|
};
|