@alizadefaig/pos-server 1.2.26 → 1.2.28
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/app/core/Context.js +170 -0
- package/app/core/auth/index.js +133 -0
- package/app/core/auth/license.js +45 -0
- package/app/core/auth/posMode.js +260 -0
- package/app/core/auth/sanctum.js +209 -0
- package/app/core/auth/scope.js +231 -0
- package/app/core/emitter.js +67 -0
- package/app/core/errors.js +18 -0
- package/app/core/guard.js +54 -0
- package/app/core/idempotency.js +196 -0
- package/app/core/index.js +374 -0
- package/app/core/models/Order.js +176 -0
- package/app/core/models/OrderProduct.js +249 -0
- package/app/core/repositories/EventRepository.js +51 -0
- package/app/core/repositories/OrderProductRepository.js +225 -0
- package/app/core/repositories/OrderRepository.js +274 -0
- package/app/core/repositories/OutboxRepository.js +52 -0
- package/app/core/repositories/PaymentRepository.js +43 -0
- package/app/core/repositories/PrinterRepository.js +179 -0
- package/app/core/repositories/ProductRelations.js +148 -0
- package/app/core/repositories/SnapshotRepository.js +197 -0
- package/app/core/resources/HallResource.js +54 -0
- package/app/core/resources/OrderResource.js +177 -0
- package/app/core/resources/ProductResource.js +186 -0
- package/app/core/resources/ShiftResource.js +42 -0
- package/app/core/resources/TeamResource.js +117 -0
- package/app/core/resources/casts.js +55 -0
- package/app/core/services/AuthService.js +468 -0
- package/app/core/services/InitService.js +279 -0
- package/app/core/services/KdsService.js +639 -0
- package/app/core/services/OrderProductService.js +309 -0
- package/app/core/services/OrderService.js +911 -0
- package/app/core/services/PrintService.js +715 -0
- package/app/core/services/PrinterAdminService.js +324 -0
- package/app/core/services/ReportService.js +581 -0
- package/app/core/services/ShiftService.js +199 -0
- package/app/core/services/StorageService.js +425 -0
- package/app/core/services/TelegramService.js +162 -0
- package/app/core/support/db.js +48 -0
- package/app/core/support/ids.js +9 -0
- package/app/core/support/imageData.js +135 -0
- package/app/core/support/images.js +92 -0
- package/app/core/support/lanHandoff.js +105 -0
- package/app/core/support/lang.js +98 -0
- package/app/core/support/lock.js +63 -0
- package/app/core/support/num.js +51 -0
- package/app/core/support/php.js +125 -0
- package/app/core/support/pin.js +34 -0
- package/app/core/support/receipts.js +278 -0
- package/app/core/support/rows.js +38 -0
- package/app/core/support/str.js +31 -0
- package/app/core/support/teamSettings.js +77 -0
- package/app/core/support/telegram.js +253 -0
- package/app/core/support/telegramReport.js +143 -0
- package/app/core/support/time.js +58 -0
- package/app/core/support/tz.js +80 -0
- package/app/core/support/unlock.js +113 -0
- package/app/core/timers.js +151 -0
- package/app/db/Database.js +197 -1
- package/app/db/Migrator.js +45 -0
- package/app/db/driver.js +248 -0
- package/app/db/migrations/001_snapshot.sql +214 -0
- package/app/db/migrations/002_orders.sql +190 -0
- package/app/db/migrations/003_idempotency.sql +18 -0
- package/app/db/migrations/004_shifts.sql +21 -0
- package/app/db/migrations/005_printers.sql +43 -0
- package/app/db/migrations/006_sync.sql +136 -0
- package/app/db/migrations/007_printer_mac.sql +4 -0
- package/app/db/migrations/008_kds.sql +94 -0
- package/app/db/migrations/009_product_categories.sql +20 -0
- package/app/db/migrations/010_printers_sync.sql +43 -0
- package/app/db/migrations/011_team_logos.sql +13 -0
- package/app/db/migrations/012_category_workshop.sql +4 -0
- package/app/monitor.js +185 -0
- package/app/printers/address.js +348 -0
- package/app/proxy.js +11 -3
- package/app/relay/masters.js +173 -0
- package/app/security.js +218 -0
- package/app/socket/auth.js +160 -0
- package/app/socket/request.js +87 -0
- package/app/socket/server.js +121 -19
- package/app/sync/bridge.js +162 -0
- package/app/sync/cloud.js +120 -0
- package/app/sync/config.js +185 -0
- package/app/sync/discovery.js +546 -0
- package/app/sync/index.js +1644 -0
- package/app/sync/kv.js +26 -0
- package/app/sync/lan.js +139 -0
- package/app/sync/lanDevices.js +177 -0
- package/app/sync/lanFront.js +431 -0
- package/app/sync/mirror.js +236 -0
- package/app/sync/push.js +324 -0
- package/app/sync/reconcile.js +161 -0
- package/app/sync/signal.js +202 -0
- package/app/sync/snapshot.js +189 -0
- package/app/sync/store.js +234 -0
- package/app/tcp.js +11 -2
- package/package.json +6 -3
- package/routes/common/api/auth.js +38 -0
- package/routes/common/api/kds.js +37 -0
- package/routes/common/api/orderProducts.js +81 -0
- package/routes/common/api/orders.js +176 -0
- package/routes/common/api/printersCore.js +35 -0
- package/routes/common/api/reports.js +29 -0
- package/routes/common/socket.js +15 -35
- package/routes/common/tax.js +29 -7
- package/routes/desktop/api/offline.js +146 -0
- package/routes/desktop/api/printers.js +288 -5
- package/routes/desktop/local.js +130 -0
- package/routes/server/api/login.js +37 -0
- package/routes/server/api/platform.js +31 -0
- package/routes/server/api/shifts.js +25 -0
- package/routes/server/master.js +24 -0
- package/server.js +72 -3
- package/utils.js +6 -0
- package/tests/usbDiagnose.js +0 -59
- package/tests/usbPrinterTest.js +0 -20
package/app/socket/server.js
CHANGED
|
@@ -5,6 +5,40 @@ const https = require("https");
|
|
|
5
5
|
const {getConfig} = require("../config");
|
|
6
6
|
const {getLogger} = require("../logger/Log");
|
|
7
7
|
const {socketSendByFilter} = require("../../utils");
|
|
8
|
+
const {canHandle: coreCanHandle, dispatch: coreDispatch} = require("../core");
|
|
9
|
+
const {socketGuard} = require("../sync/lan");
|
|
10
|
+
const {socketAllowRequest} = require("../security");
|
|
11
|
+
const {socketAuth} = require("./auth");
|
|
12
|
+
const syncConfig = require("../sync/config");
|
|
13
|
+
|
|
14
|
+
// Офлайн-контур (модель F): клиентские бродкасты фронта, которые надо разнести по залу.
|
|
15
|
+
// Служебные каналы (api:*, ответы на request:*) адресные — их не ретранслируем.
|
|
16
|
+
const isRelayable = (eventName) => !eventName.startsWith('api:') && !eventName.startsWith('response:');
|
|
17
|
+
|
|
18
|
+
// Контекст запроса телефона по LAN совпадает с тем, по которому его пустили. Пустой заголовок
|
|
19
|
+
// подменой не считается: lanDeviceHeaders заполнит его значением устройства. Сверяются все
|
|
20
|
+
// варианты имени (`company-id` и `Company-Id` в одном запросе), а не только первый найденный.
|
|
21
|
+
const LAN_HEADERS = [['terminal-id', 'terminal_id'], ['company-id', 'company_id'], ['team-id', 'team_id']];
|
|
22
|
+
const lanHeadersMatch = (device, headers) => {
|
|
23
|
+
const fields = new Map(LAN_HEADERS);
|
|
24
|
+
return Object.entries(headers && typeof headers === 'object' ? headers : {}).every(([key, raw]) => {
|
|
25
|
+
const field = fields.get(key.toLowerCase());
|
|
26
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
27
|
+
return !field || value === undefined || value === null || value === ''
|
|
28
|
+
|| String(value) === String(device[field]);
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const lanDeviceHeaders = (device, headers) => {
|
|
32
|
+
const names = new Set(LAN_HEADERS.map(([header]) => header));
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const [key, value] of Object.entries(headers && typeof headers === 'object' ? headers : {})) {
|
|
35
|
+
if (!names.has(key.toLowerCase())) result[key] = value;
|
|
36
|
+
}
|
|
37
|
+
for (const [header, field] of LAN_HEADERS) {
|
|
38
|
+
if (device[field]) result[header] = device[field];
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
8
42
|
|
|
9
43
|
// Keep-alive agent — переиспользуем TCP-соединения к Laravel вместо новых на каждый запрос.
|
|
10
44
|
// БАГ C: maxSockets 64→128 (запас; после async-печати длинных холдов почти нет). env-настраиваемо.
|
|
@@ -48,7 +82,8 @@ module.exports = (server = null) => {
|
|
|
48
82
|
if (server && !serverSocket) {
|
|
49
83
|
// Дефолт = поведение как сейчас (нативные heartbeat/адаптер). Опции добавляются
|
|
50
84
|
// только при явном включении флагов — это и есть «правка под флагом с откатом».
|
|
51
|
-
|
|
85
|
+
// allowRequest: WebSocket не подчиняется CORS — Origin проверяем сами (no-op без офлайна).
|
|
86
|
+
const serverOptions = { cors: { origin: '*' }, allowRequest: socketAllowRequest };
|
|
52
87
|
if (WS_HEARTBEAT_ENABLED) {
|
|
53
88
|
serverOptions.pingInterval = WS_PING_INTERVAL; // быстрый детект мёртвого сокета (~15с)
|
|
54
89
|
serverOptions.pingTimeout = WS_PING_TIMEOUT;
|
|
@@ -61,6 +96,10 @@ module.exports = (server = null) => {
|
|
|
61
96
|
};
|
|
62
97
|
}
|
|
63
98
|
serverSocket = new Server(server, serverOptions);
|
|
99
|
+
// Мастер зала слушает 0.0.0.0 — сокет без ключа зала не пускаем (no-op без синка/ключа).
|
|
100
|
+
serverSocket.use(socketGuard);
|
|
101
|
+
// Облачный Node: токен сокета проверяет Laravel (SOCKET_AUTH, по умолчанию off).
|
|
102
|
+
serverSocket.use(socketAuth);
|
|
64
103
|
|
|
65
104
|
const logger = getLogger();
|
|
66
105
|
|
|
@@ -90,7 +129,10 @@ module.exports = (server = null) => {
|
|
|
90
129
|
const companyId = socket.handshake.query.company_id;
|
|
91
130
|
const terminalId = socket.handshake.query.terminal_id;
|
|
92
131
|
let userId = socket.handshake.query.user_id;
|
|
93
|
-
|
|
132
|
+
// Телефон, пришедший по LAN со своим токеном (socketGuard), — обычный клиент,
|
|
133
|
+
// прокси-сокетом он себя не объявит.
|
|
134
|
+
const lanDevice = socket.data.lanDevice || null;
|
|
135
|
+
const isProxy = lanDevice ? false : (socket.handshake.query.is_proxy_server ?? false);
|
|
94
136
|
const PROXY_TARGET = getConfig().apiUrl;
|
|
95
137
|
|
|
96
138
|
socket.join(companyId);
|
|
@@ -100,12 +142,16 @@ module.exports = (server = null) => {
|
|
|
100
142
|
socket.userId = userId;
|
|
101
143
|
socket.isProxy = isProxy;
|
|
102
144
|
|
|
145
|
+
// С синком фронт подключается к локальному Node только офлайн: облаку о его
|
|
146
|
+
// присутствии и событиях сообщить нечем (запросы висели бы до таймаута TCP).
|
|
147
|
+
const reportToCloud = !syncConfig.isSyncEnabled();
|
|
148
|
+
|
|
103
149
|
if (!isProxy) {
|
|
104
150
|
// При connectionStateRecovery 'connection' ре-фаерится на восстановленном
|
|
105
151
|
// сокете (socket.recovered=true) — повторный presence-POST не нужен (терминал
|
|
106
152
|
// уже зарегистрирован, комнаты восстановлены). Обработчики ниже регистрируем
|
|
107
153
|
// всегда (объект сокета новый).
|
|
108
|
-
if (!socket.recovered) {
|
|
154
|
+
if (!socket.recovered && reportToCloud) {
|
|
109
155
|
axios.post(`${PROXY_TARGET}/api/socket/connection`, {
|
|
110
156
|
companyId,
|
|
111
157
|
terminalId,
|
|
@@ -126,6 +172,35 @@ module.exports = (server = null) => {
|
|
|
126
172
|
// Немедленно подтверждаем получение — фронт знает что запрос дошёл
|
|
127
173
|
socket.emit(ackEvent);
|
|
128
174
|
|
|
175
|
+
// Телефон по LAN проверен при подключении по своему терминалу и заведению.
|
|
176
|
+
// Заголовки запроса обязаны говорить о том же: иначе по токену одного
|
|
177
|
+
// терминала можно было бы действовать от имени другого.
|
|
178
|
+
if (lanDevice) {
|
|
179
|
+
if (!lanHeadersMatch(lanDevice, headers)) {
|
|
180
|
+
socket.emit(responseEvent, {
|
|
181
|
+
success: false,
|
|
182
|
+
error: { success: false, message: 'Forbidden', error_code: 'lan_device_mismatch' },
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
headers = lanDeviceHeaders(lanDevice, headers);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Офлайн-ядро: событие обрабатывается локальной БД, в Laravel не идёт.
|
|
190
|
+
// canHandle учитывает флаг OFFLINE_CORE — при выключенном флаге (по умолчанию)
|
|
191
|
+
// всегда false, и запрос уходит наверх ровно как раньше.
|
|
192
|
+
if (coreCanHandle(event)) {
|
|
193
|
+
try {
|
|
194
|
+
const result = await coreDispatch(event, { headers: headers ?? {}, params: data ?? {} });
|
|
195
|
+
if (HOT_LOGS) console.log(`[api:res] ${event} ✓ local ${Date.now() - t0}ms id=${request_id.slice(0, 8)}`);
|
|
196
|
+
socket.emit(responseEvent, { success: true, data: result });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (HOT_LOGS) console.log(`[api:res] ${event} ✗ local status=${err?.status} id=${request_id.slice(0, 8)}`);
|
|
199
|
+
socket.emit(responseEvent, { success: false, error: err?.body ?? { message: err.message } });
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
129
204
|
// Прокидываем Idempotency-Key если прислан
|
|
130
205
|
const idempotencyKey = headers?.['Idempotency-Key'] ?? headers?.['idempotency-key'];
|
|
131
206
|
const upstreamHeaders = {
|
|
@@ -158,13 +233,15 @@ module.exports = (server = null) => {
|
|
|
158
233
|
socket.on('setUserId', (id) => {
|
|
159
234
|
userId = id;
|
|
160
235
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
236
|
+
if (reportToCloud) {
|
|
237
|
+
axios.post(`${PROXY_TARGET}/api/socket/setUserId`, {
|
|
238
|
+
companyId,
|
|
239
|
+
terminalId,
|
|
240
|
+
userId,
|
|
241
|
+
}).catch((err) => {
|
|
242
|
+
logger.error(err);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
168
245
|
|
|
169
246
|
|
|
170
247
|
if (id) {
|
|
@@ -174,6 +251,7 @@ module.exports = (server = null) => {
|
|
|
174
251
|
|
|
175
252
|
|
|
176
253
|
socket.on('disconnect', () => {
|
|
254
|
+
if (!reportToCloud) return;
|
|
177
255
|
axios.post(`${PROXY_TARGET}/api/socket/disconnect`, {
|
|
178
256
|
companyId,
|
|
179
257
|
terminalId,
|
|
@@ -185,17 +263,41 @@ module.exports = (server = null) => {
|
|
|
185
263
|
|
|
186
264
|
socket.onAny((eventName, ...data) => {
|
|
187
265
|
if (eventName.startsWith('api:')) return; // handled by api:request handler
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
266
|
+
if (reportToCloud) {
|
|
267
|
+
axios.post(`${PROXY_TARGET}/api/socket/${eventName}`, {
|
|
268
|
+
companyId,
|
|
269
|
+
terminalId,
|
|
270
|
+
userId,
|
|
271
|
+
data
|
|
272
|
+
}).catch((err) => {
|
|
273
|
+
logger.error(err);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
socket.to(companyId).emit(eventName, ...data);
|
|
277
|
+
|
|
278
|
+
// Ведомый терминал: остальной зал сидит на мастере — отдаём бродкаст туда.
|
|
279
|
+
if (!reportToCloud && syncConfig.role() === 'follower' && isRelayable(eventName)) {
|
|
280
|
+
const bridge = require('../sync').bridge();
|
|
281
|
+
if (bridge) bridge.forward(eventName, ...data);
|
|
282
|
+
}
|
|
283
|
+
// Мастер заведения (модель M): терминалы, работающие через облако, тоже
|
|
284
|
+
// должны увидеть бродкаст. Вне режима local — no-op.
|
|
285
|
+
if (!reportToCloud && syncConfig.role() !== 'follower' && isRelayable(eventName)) {
|
|
286
|
+
require('../sync').relayBroadcast(eventName, data);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
} else if (socket.handshake.auth?.master && syncConfig.isMasterRelayEnabled()) {
|
|
290
|
+
// Облачный Node: сигнальный сокет мастер-кассы. Личность проверяется через
|
|
291
|
+
// Laravel, до ответа master:ready канал ретрансляции закрыт.
|
|
292
|
+
require('../relay/masters').register(socket);
|
|
293
|
+
} else if (syncConfig.isSyncEnabled() && syncConfig.role() !== 'follower') {
|
|
294
|
+
// Мастер зала: прокси-сокет — это мост ведомого терминала. Его клиентские
|
|
295
|
+
// бродкасты (orders:closing…) раздаём остальным терминалам компании.
|
|
296
|
+
socket.onAny((eventName, ...data) => {
|
|
297
|
+
if (!isRelayable(eventName)) return;
|
|
196
298
|
socket.to(companyId).emit(eventName, ...data);
|
|
299
|
+
require('../sync').relayBroadcast(eventName, data);
|
|
197
300
|
});
|
|
198
|
-
} else {
|
|
199
301
|
}
|
|
200
302
|
});
|
|
201
303
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const {io} = require('socket.io-client');
|
|
2
|
+
const {lanKey} = require('./config');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Мост ведомого терминала к мастеру зала (socket.io).
|
|
6
|
+
*
|
|
7
|
+
* Офлайн фронт ведомого подключён к СВОЕМУ Node, а чеки живут у мастера. Мост:
|
|
8
|
+
* мастер → фронт: бродкасты мастера (orders:patch и т.п.) пересылаются в комнату компании
|
|
9
|
+
* своего сокет-сервера — фронт видит их так же, как видел бы из облака;
|
|
10
|
+
* мастер → фронт: request:<event>:<id> (печать через терминал) — адресный запрос к своему
|
|
11
|
+
* фронту, ответ уходит мастеру как response:<event>:<id>;
|
|
12
|
+
* фронт → мастер: клиентские бродкасты фронта (orders:closing…) уходят мастеру, а он
|
|
13
|
+
* раздаёт их остальным терминалам зала.
|
|
14
|
+
*
|
|
15
|
+
* Мост подключается с terminal_id этого терминала: так мастер находит его по terminalId,
|
|
16
|
+
* когда нужно напечатать на принтере, подключённом к этой кассе.
|
|
17
|
+
*/
|
|
18
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.OFFLINE_BRIDGE_REQUEST_TIMEOUT_MS || 120000);
|
|
19
|
+
|
|
20
|
+
const findSocket = (server, terminalId) => {
|
|
21
|
+
if (!server) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
for (const socket of server.sockets.sockets.values()) {
|
|
25
|
+
if (socket.terminalId === terminalId && !socket.isProxy) {
|
|
26
|
+
return socket;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Запасной вариант адресного запроса, если app/socket/request.js ещё нет. */
|
|
33
|
+
const fallbackRequest = ({terminalId, eventName, data, requestId, timeout}) => new Promise((resolve) => {
|
|
34
|
+
const server = require('../socket/server')();
|
|
35
|
+
const socket = findSocket(server, terminalId);
|
|
36
|
+
if (!socket) {
|
|
37
|
+
resolve({success: false, message: 'No socket'});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const responseEvent = `response:${eventName}:${requestId}`;
|
|
41
|
+
let done = false;
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
if (!done) {
|
|
44
|
+
done = true;
|
|
45
|
+
socket.off(responseEvent, onResponse);
|
|
46
|
+
resolve({success: false, message: 'Timeout error'});
|
|
47
|
+
}
|
|
48
|
+
}, timeout);
|
|
49
|
+
const onResponse = (payload) => {
|
|
50
|
+
if (!done) {
|
|
51
|
+
done = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve(payload);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
socket.once(responseEvent, onResponse);
|
|
57
|
+
socket.emit(`request:${eventName}:${requestId}`, data);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const localRequest = (options) => {
|
|
61
|
+
try {
|
|
62
|
+
const module_ = require('../socket/request');
|
|
63
|
+
if (module_ && typeof module_.socketRequest === 'function') {
|
|
64
|
+
return module_.socketRequest(options);
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e.code !== 'MODULE_NOT_FOUND') {
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return fallbackRequest(options);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
class MasterBridge {
|
|
75
|
+
/**
|
|
76
|
+
* @param {{masterUrl: string, session: object, isRelaying: () => boolean}} options
|
|
77
|
+
* isRelaying — пересылать ли бродкасты: только пока фронт работает через локальный Node,
|
|
78
|
+
* в облачном режиме он получает их из облака, и копия от мастера была бы дублем.
|
|
79
|
+
*/
|
|
80
|
+
constructor({masterUrl, session, isRelaying}) {
|
|
81
|
+
this.masterUrl = masterUrl;
|
|
82
|
+
this.session = session;
|
|
83
|
+
this.isRelaying = isRelaying;
|
|
84
|
+
this.socket = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get connected() {
|
|
88
|
+
return Boolean(this.socket && this.socket.connected);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
start() {
|
|
92
|
+
if (this.socket || !this.session.company_id) {
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
this.socket = io(this.masterUrl, {
|
|
96
|
+
query: {
|
|
97
|
+
company_id: this.session.company_id,
|
|
98
|
+
terminal_id: this.session.terminal_id || '',
|
|
99
|
+
is_proxy_server: 'true',
|
|
100
|
+
},
|
|
101
|
+
auth: lanKey() ? {lan_key: lanKey()} : {},
|
|
102
|
+
reconnection: true,
|
|
103
|
+
reconnectionDelay: 1000,
|
|
104
|
+
reconnectionDelayMax: 5000,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.socket.onAny((eventName, ...args) => {
|
|
108
|
+
if (eventName.startsWith('request:')) {
|
|
109
|
+
this.handleRequest(eventName, args[0]).catch((e) => {
|
|
110
|
+
console.error('[sync] bridge request failed:', e.message);
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (eventName.startsWith('response:') || !this.isRelaying()) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const server = require('../socket/server')();
|
|
118
|
+
if (server) {
|
|
119
|
+
server.to(this.session.company_id).emit(eventName, ...args);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async handleRequest(eventName, data) {
|
|
126
|
+
const rest = eventName.slice('request:'.length);
|
|
127
|
+
const separator = rest.lastIndexOf(':');
|
|
128
|
+
if (separator <= 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const name = rest.slice(0, separator);
|
|
132
|
+
const requestId = rest.slice(separator + 1);
|
|
133
|
+
const result = await localRequest({
|
|
134
|
+
terminalId: this.session.terminal_id,
|
|
135
|
+
eventName: name,
|
|
136
|
+
data,
|
|
137
|
+
requestId,
|
|
138
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
139
|
+
});
|
|
140
|
+
if (this.socket) {
|
|
141
|
+
this.socket.emit(`response:${name}:${requestId}`, result);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Клиентский бродкаст фронта → мастеру (он раздаст его остальным терминалам). */
|
|
146
|
+
forward(eventName, ...args) {
|
|
147
|
+
if (this.connected && this.isRelaying()) {
|
|
148
|
+
this.socket.emit(eventName, ...args);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
stop() {
|
|
153
|
+
if (this.socket) {
|
|
154
|
+
this.socket.offAny();
|
|
155
|
+
this.socket.removeAllListeners();
|
|
156
|
+
this.socket.disconnect();
|
|
157
|
+
this.socket = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {MasterBridge, localRequest};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const http = require('node:http');
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
const {healthPath, timeouts, stripSlash} = require('./config');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HTTP-клиент синка к облаку. Ходит от имени терминала (сессия из POST /local/session) —
|
|
8
|
+
* те же заголовки, что шлёт фронт, поэтому Laravel видит обычный запрос кассы.
|
|
9
|
+
*
|
|
10
|
+
* Каждый ответ — сигнал о связи для монитора: нет ответа или 5xx — провал, любой другой
|
|
11
|
+
* ответ — связь есть (даже 4xx: облако живо, просто запрос ему не понравился).
|
|
12
|
+
*/
|
|
13
|
+
const httpAgent = new http.Agent({keepAlive: true, maxSockets: 8});
|
|
14
|
+
const httpsAgent = new https.Agent({keepAlive: true, maxSockets: 8});
|
|
15
|
+
|
|
16
|
+
/** Сеть/таймаут/5xx: облако недоступно, операцию надо повторить позже. */
|
|
17
|
+
class CloudUnavailable extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CloudUnavailable';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Облако ответило 4xx: связь есть, но запрос отвергнут. */
|
|
25
|
+
class CloudRequestError extends Error {
|
|
26
|
+
constructor(event, status, body) {
|
|
27
|
+
super(`${event}: HTTP ${status}${body && body.message ? ` ${body.message}` : ''}`);
|
|
28
|
+
this.name = 'CloudRequestError';
|
|
29
|
+
this.status = status;
|
|
30
|
+
this.body = body;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Старый облачный Node при упавшем Laravel отвечает 200 с таким телом (см. app/proxy.js).
|
|
35
|
+
const isBadGateway = (data) => Boolean(data && typeof data === 'object' && data.error === 'Bad Gateway');
|
|
36
|
+
|
|
37
|
+
/** api_url фронта бывает и с хвостом /api — приводим к адресу хоста. */
|
|
38
|
+
const normalizeApiUrl = (url) => stripSlash(url).replace(/\/api$/, '');
|
|
39
|
+
|
|
40
|
+
class CloudClient {
|
|
41
|
+
constructor(session, {onLink = null} = {}) {
|
|
42
|
+
this.session = session;
|
|
43
|
+
this.onLink = onLink || (() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get baseUrl() {
|
|
47
|
+
return normalizeApiUrl(this.session.api_url);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
headers(extra = {}) {
|
|
51
|
+
const session = this.session;
|
|
52
|
+
const headers = {
|
|
53
|
+
Accept: 'application/json',
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
timestamp: String(Math.floor(Date.now() / 1000)),
|
|
56
|
+
};
|
|
57
|
+
if (session.token) {
|
|
58
|
+
headers.Authorization = `Bearer ${session.token}`;
|
|
59
|
+
}
|
|
60
|
+
if (session.team_id) {
|
|
61
|
+
headers['team-id'] = session.team_id;
|
|
62
|
+
}
|
|
63
|
+
if (session.company_id) {
|
|
64
|
+
headers['company-id'] = session.company_id;
|
|
65
|
+
}
|
|
66
|
+
if (session.terminal_id) {
|
|
67
|
+
headers['terminal-id'] = session.terminal_id;
|
|
68
|
+
}
|
|
69
|
+
if (session.employee_id) {
|
|
70
|
+
headers['employee-id'] = session.employee_id;
|
|
71
|
+
}
|
|
72
|
+
return {...headers, ...extra};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async call(event, data = {}, {timeout = timeouts().api, idempotencyKey = null} = {}) {
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await axios.post(`${this.baseUrl}/api/${event}`, data, {
|
|
79
|
+
headers: this.headers(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {}),
|
|
80
|
+
timeout,
|
|
81
|
+
validateStatus: () => true,
|
|
82
|
+
httpAgent,
|
|
83
|
+
httpsAgent,
|
|
84
|
+
});
|
|
85
|
+
} catch (e) {
|
|
86
|
+
this.onLink(false, `${event}: ${e.code || e.message}`);
|
|
87
|
+
throw new CloudUnavailable(`${event}: ${e.code || e.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (response.status >= 500 || isBadGateway(response.data)) {
|
|
91
|
+
this.onLink(false, `${event}: HTTP ${response.status}`);
|
|
92
|
+
throw new CloudUnavailable(`${event}: HTTP ${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
this.onLink(true);
|
|
95
|
+
if (response.status >= 400) {
|
|
96
|
+
throw new CloudRequestError(event, response.status, response.data);
|
|
97
|
+
}
|
|
98
|
+
return response.data;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Проверка связи. Через облачный Node /up доходит до Laravel — проверяется весь путь. */
|
|
102
|
+
async health() {
|
|
103
|
+
try {
|
|
104
|
+
const response = await axios.get(`${this.baseUrl}${healthPath()}`, {
|
|
105
|
+
timeout: timeouts().health,
|
|
106
|
+
validateStatus: () => true,
|
|
107
|
+
httpAgent,
|
|
108
|
+
httpsAgent,
|
|
109
|
+
});
|
|
110
|
+
const ok = response.status < 500 && !isBadGateway(response.data);
|
|
111
|
+
this.onLink(ok, ok ? null : `health: HTTP ${response.status}`);
|
|
112
|
+
return ok;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
this.onLink(false, `health: ${e.code || e.message}`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {CloudClient, CloudUnavailable, CloudRequestError, normalizeApiUrl, isBadGateway};
|