@alizadefaig/pos-server 1.2.26 → 1.2.27
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 +85 -0
- package/app/core/resources/casts.js +55 -0
- package/app/core/services/AuthService.js +468 -0
- package/app/core/services/InitService.js +277 -0
- package/app/core/services/KdsService.js +639 -0
- package/app/core/services/OrderProductService.js +309 -0
- package/app/core/services/OrderService.js +903 -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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Где сейчас стоит сетевой принтер — привязка по MAC (фронт с VITE_PRINTER_MAC шлёт
|
|
5
|
+
* `mac_binding: true`, выключить на кассе — PRINTER_MAC_BINDING=false).
|
|
6
|
+
*
|
|
7
|
+
* У LAN-принтера в базе записан IP, а роутер заведения раздаёт адреса по DHCP: после
|
|
8
|
+
* перезагрузки принтер встаёт на другой адрес, и чеки цеха уходят в пустоту (или на чужое
|
|
9
|
+
* устройство, которое заняло старый адрес). MAC у принтера постоянный.
|
|
10
|
+
*
|
|
11
|
+
* Порядок проверки (как у evopos):
|
|
12
|
+
* 1. адрес из кэша или из задания: порт 9100 открыт и ARP показывает на нём наш MAC — печатаем;
|
|
13
|
+
* 2. ARP-таблица ОС: наш MAC уже виден на другом адресе — проверяем порт и берём его;
|
|
14
|
+
* 3. скан своей подсети (не шире /24) пачками по BATCH с коротким таймаутом, после каждой
|
|
15
|
+
* пачки — ARP: соединение наполняет таблицу ОС и для закрытых портов.
|
|
16
|
+
*
|
|
17
|
+
* ARP не знает адрес (принтер за маршрутизатором, в другой подсети) — сверять не с чем, IP из
|
|
18
|
+
* задания считается верным: так работала печать до привязки. На адресе ДРУГОЙ MAC — туда не
|
|
19
|
+
* печатаем: ответ `printer_not_found` честнее, чем чек на чужом принтере.
|
|
20
|
+
*
|
|
21
|
+
* Проверенный адрес живёт VERIFY_EVERY_MS: поток цеховых чеков не сканирует сеть. Неудачная
|
|
22
|
+
* печать сбрасывает проверку — следующее задание проверит адрес заново.
|
|
23
|
+
*/
|
|
24
|
+
const {execFile} = require('node:child_process');
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const net = require('node:net');
|
|
27
|
+
const os = require('node:os');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const PRINTER_PORT = 9100;
|
|
31
|
+
const BATCH = 48;
|
|
32
|
+
const SCAN_TIMEOUT_MS = 400;
|
|
33
|
+
const VERIFY_TIMEOUT_MS = 1000;
|
|
34
|
+
const VERIFY_EVERY_MS = 60000;
|
|
35
|
+
const LEARN_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
const CACHE_FILE = 'printer-addresses.json';
|
|
37
|
+
|
|
38
|
+
const enabled = (body) => Boolean(body)
|
|
39
|
+
&& body.mac_binding === true
|
|
40
|
+
&& process.env.PRINTER_MAC_BINDING !== 'false'
|
|
41
|
+
&& Boolean(body.printer)
|
|
42
|
+
&& body.printer.type === 'LAN';
|
|
43
|
+
|
|
44
|
+
/** aa:bb:cc:dd:ee:ff — нижний регистр, два знака на октет. Пустой, нулевой и broadcast → null. */
|
|
45
|
+
const normalizeMac = (value) => {
|
|
46
|
+
if (typeof value !== 'string') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const parts = value.trim().toLowerCase().split(/[:-]/);
|
|
50
|
+
if (parts.length !== 6 || !parts.every((part) => /^[0-9a-f]{1,2}$/.test(part))) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const mac = parts.map((part) => part.padStart(2, '0')).join(':');
|
|
54
|
+
if (mac === '00:00:00:00:00:00' || mac === 'ff:ff:ff:ff:ff:ff') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return mac;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const IPV4 = /\b(\d{1,3}(?:\.\d{1,3}){3})\b/;
|
|
61
|
+
const MAC = /\b([0-9a-f]{1,2}(?:[:-][0-9a-f]{1,2}){5})\b/i;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Разбор вывода arp / /proc/net/arp → Map(ip → mac). В строке ищутся IPv4 и MAC, поэтому один
|
|
65
|
+
* разбор понимает все форматы:
|
|
66
|
+
* Windows `arp -a`: ` 192.168.1.40 aa-bb-cc-dd-ee-04 dynamic`
|
|
67
|
+
* macOS `arp -an`: `? (192.168.1.40) at aa:bb:cc:dd:ee:4 on en0 ifscope [ethernet]`
|
|
68
|
+
* Linux `arp -an`: `? (192.168.1.40) at aa:bb:cc:dd:ee:04 [ether] on eth0`
|
|
69
|
+
* /proc/net/arp: `192.168.1.40 0x1 0x2 aa:bb:cc:dd:ee:04 * eth0`
|
|
70
|
+
* Строки `(incomplete)` и нулевые MAC (/proc, flags 0x0) отбрасываются нормализацией.
|
|
71
|
+
*/
|
|
72
|
+
const parseArp = (text) => {
|
|
73
|
+
const table = new Map();
|
|
74
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
75
|
+
const ip = line.match(IPV4);
|
|
76
|
+
const mac = line.match(MAC);
|
|
77
|
+
if (!ip || !mac) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const normalized = normalizeMac(mac[1]);
|
|
81
|
+
if (normalized) {
|
|
82
|
+
table.set(ip[1], normalized);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return table;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const systemArp = () => new Promise((resolve) => {
|
|
89
|
+
const windows = process.platform === 'win32';
|
|
90
|
+
execFile('arp', windows ? ['-a'] : ['-an'], {timeout: 2000, windowsHide: true}, (error, stdout) => {
|
|
91
|
+
if (!error && stdout) {
|
|
92
|
+
resolve(String(stdout));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Linux без net-tools: та же таблица в /proc.
|
|
96
|
+
fs.readFile('/proc/net/arp', 'utf8', (readError, text) => resolve(readError ? '' : text));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const probe = (ip, timeout, port = PRINTER_PORT) => new Promise((resolve) => {
|
|
101
|
+
const socket = new net.Socket();
|
|
102
|
+
let done = false;
|
|
103
|
+
const finish = (open) => {
|
|
104
|
+
if (done) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
done = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
socket.destroy();
|
|
110
|
+
resolve(open);
|
|
111
|
+
};
|
|
112
|
+
const timer = setTimeout(() => finish(false), timeout);
|
|
113
|
+
socket.once('connect', () => finish(true));
|
|
114
|
+
socket.once('error', () => finish(false));
|
|
115
|
+
socket.connect(port, ip);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const ipToInt = (ip) => ip.split('.').reduce((sum, part) => (sum * 256) + Number(part), 0);
|
|
119
|
+
const intToIp = (value) => [24, 16, 8, 0].map((shift) => Math.floor(value / (2 ** shift)) % 256).join('.');
|
|
120
|
+
|
|
121
|
+
/** Адреса своих подсетей (IPv4, не loopback), каждая не шире /24, без своего адреса. */
|
|
122
|
+
const subnetHosts = () => {
|
|
123
|
+
const hosts = new Set();
|
|
124
|
+
const own = new Set();
|
|
125
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
126
|
+
for (const item of list || []) {
|
|
127
|
+
if (item.family !== 'IPv4' && item.family !== 4) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (item.internal || item.address.startsWith('169.254.')) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
own.add(item.address);
|
|
134
|
+
const address = ipToInt(item.address);
|
|
135
|
+
// Сеть шире /24 (например, /16) целиком не сканируем: берём /24 вокруг своего адреса.
|
|
136
|
+
const mask = Math.max(ipToInt(item.netmask || '255.255.255.0'), ipToInt('255.255.255.0'));
|
|
137
|
+
const network = Math.floor(address / (2 ** 32 - mask)) * (2 ** 32 - mask);
|
|
138
|
+
const size = 2 ** 32 - mask;
|
|
139
|
+
for (let offset = 1; offset < size - 1; offset += 1) {
|
|
140
|
+
hosts.add(intToIp(network + offset));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
own.forEach((address) => hosts.delete(address));
|
|
145
|
+
return [...hosts];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ------------------------------------------------------------------ состояние
|
|
149
|
+
|
|
150
|
+
const hooks = {arp: systemArp, probe, hosts: subnetHosts};
|
|
151
|
+
const verified = new Map(); // mac → {ip, at}
|
|
152
|
+
const learned = new Map(); // ip → {mac, at}
|
|
153
|
+
const searches = new Map(); // mac → Promise<ip|null>
|
|
154
|
+
let loaded = false;
|
|
155
|
+
|
|
156
|
+
const cacheFile = () => (process.env.CAFEPOS_DATA_DIR ? path.join(process.env.CAFEPOS_DATA_DIR, CACHE_FILE) : null);
|
|
157
|
+
|
|
158
|
+
// Кэш mac → ip на диске: после перезапуска кассы без интернета снапшот отдаёт старый IP, а
|
|
159
|
+
// облака, которое записало бы новый, нет — без файла каждый старт начинался бы со скана.
|
|
160
|
+
const load = () => {
|
|
161
|
+
if (loaded) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
loaded = true;
|
|
165
|
+
const file = cacheFile();
|
|
166
|
+
if (!file) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
171
|
+
for (const [mac, ip] of Object.entries(data || {})) {
|
|
172
|
+
if (normalizeMac(mac) && typeof ip === 'string' && IPV4.test(ip)) {
|
|
173
|
+
verified.set(normalizeMac(mac), {ip, at: 0});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (e) {
|
|
177
|
+
// нет файла или битый — начинаем с пустого
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const save = () => {
|
|
182
|
+
const file = cacheFile();
|
|
183
|
+
if (!file) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const data = Object.fromEntries([...verified.entries()].map(([mac, entry]) => [mac, entry.ip]));
|
|
187
|
+
const tmp = `${file}.tmp`;
|
|
188
|
+
try {
|
|
189
|
+
fs.writeFileSync(tmp, JSON.stringify(data), {mode: 0o600});
|
|
190
|
+
fs.renameSync(tmp, file);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn('[printer-mac] cache not saved:', e.message);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const remember = (mac, ip) => {
|
|
197
|
+
const previous = verified.get(mac);
|
|
198
|
+
verified.set(mac, {ip, at: Date.now()});
|
|
199
|
+
if (!previous || previous.ip !== ip) {
|
|
200
|
+
save();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// ------------------------------------------------------------------ проверка и поиск
|
|
205
|
+
|
|
206
|
+
const readArp = async () => parseArp(await hooks.arp());
|
|
207
|
+
|
|
208
|
+
/** 'ok' — на адресе наш принтер (или сверить нечем), 'closed' — порт не отвечает, 'mismatch' — чужой MAC. */
|
|
209
|
+
const check = async (ip, mac, timeout = VERIFY_TIMEOUT_MS) => {
|
|
210
|
+
if (!ip || !(await hooks.probe(ip, timeout))) {
|
|
211
|
+
return 'closed';
|
|
212
|
+
}
|
|
213
|
+
const seen = (await readArp()).get(ip);
|
|
214
|
+
if (!seen) {
|
|
215
|
+
return 'ok';
|
|
216
|
+
}
|
|
217
|
+
return seen === mac ? 'ok' : 'mismatch';
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const scan = async (mac) => {
|
|
221
|
+
// Ступень 2: ОС уже знает, где этот MAC.
|
|
222
|
+
for (const [ip, seen] of await readArp()) {
|
|
223
|
+
if (seen === mac && await check(ip, mac) === 'ok') {
|
|
224
|
+
return ip;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Ступень 3: скан подсети. Пачкой, а не всей подсетью разом: слабая касса и дешёвый роутер
|
|
228
|
+
// плохо переносят сотни одновременных соединений.
|
|
229
|
+
const hosts = hooks.hosts();
|
|
230
|
+
for (let start = 0; start < hosts.length; start += BATCH) {
|
|
231
|
+
const batch = hosts.slice(start, start + BATCH);
|
|
232
|
+
const open = (await Promise.all(batch.map(async (ip) => ((await hooks.probe(ip, SCAN_TIMEOUT_MS)) ? ip : null))))
|
|
233
|
+
.filter(Boolean);
|
|
234
|
+
if (!open.length) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const table = await readArp();
|
|
238
|
+
const found = open.find((ip) => table.get(ip) === mac);
|
|
239
|
+
if (found) {
|
|
240
|
+
return found;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Поиск по MAC; одновременные задания на один принтер ждут один скан. */
|
|
247
|
+
const findByMac = (mac) => {
|
|
248
|
+
if (!searches.has(mac)) {
|
|
249
|
+
const search = scan(mac).finally(() => searches.delete(mac));
|
|
250
|
+
searches.set(mac, search);
|
|
251
|
+
}
|
|
252
|
+
return searches.get(mac);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Адрес для печати.
|
|
257
|
+
* @returns {Promise<{ip: string|null, mac: string|null, moved: boolean, learnedMac: string|null,
|
|
258
|
+
* error?: string}>}
|
|
259
|
+
* moved — принтер найден не там, где записан (ответ фронту: resolved_ip → printers.syncAddress);
|
|
260
|
+
* learnedMac — MAC принтера, у которого его ещё нет в базе (из ARP после прошлых печатей).
|
|
261
|
+
*/
|
|
262
|
+
const resolve = async (printer) => {
|
|
263
|
+
load();
|
|
264
|
+
const recorded = printer.ip_address || null;
|
|
265
|
+
const mac = normalizeMac(printer.mac_address);
|
|
266
|
+
if (!mac) {
|
|
267
|
+
return {ip: recorded, mac: null, moved: false, learnedMac: recorded ? await learn(recorded) : null};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const cached = verified.get(mac);
|
|
271
|
+
if (cached && Date.now() - cached.at < VERIFY_EVERY_MS) {
|
|
272
|
+
return {ip: cached.ip, mac, moved: cached.ip !== recorded, learnedMac: null};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let mismatch = false;
|
|
276
|
+
for (const ip of [...new Set([cached && cached.ip, recorded].filter(Boolean))]) {
|
|
277
|
+
const state = await check(ip, mac);
|
|
278
|
+
if (state === 'ok') {
|
|
279
|
+
remember(mac, ip);
|
|
280
|
+
return {ip, mac, moved: ip !== recorded, learnedMac: null};
|
|
281
|
+
}
|
|
282
|
+
mismatch = mismatch || state === 'mismatch';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const found = await findByMac(mac);
|
|
286
|
+
if (found) {
|
|
287
|
+
console.log(`[printer-mac] ${mac}: ${recorded} → ${found}`);
|
|
288
|
+
remember(mac, found);
|
|
289
|
+
return {ip: found, mac, moved: found !== recorded, learnedMac: null};
|
|
290
|
+
}
|
|
291
|
+
if (mismatch) {
|
|
292
|
+
return {ip: null, mac, moved: false, learnedMac: null, error: 'printer_not_found'};
|
|
293
|
+
}
|
|
294
|
+
// Принтер выключен или сеть недоступна: печать по записанному адресу упадёт так же, как до привязки.
|
|
295
|
+
return {ip: recorded, mac, moved: false, learnedMac: null};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** MAC принтера без привязки: читаем ARP не чаще раза в LEARN_TTL_MS на адрес. */
|
|
299
|
+
const learn = async (ip) => {
|
|
300
|
+
const known = learned.get(ip);
|
|
301
|
+
if (known && Date.now() - known.at < LEARN_TTL_MS) {
|
|
302
|
+
return known.mac;
|
|
303
|
+
}
|
|
304
|
+
const mac = (await readArp()).get(ip) || null;
|
|
305
|
+
learned.set(ip, {mac, at: Date.now()});
|
|
306
|
+
return mac;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** Печать не удалась — следующее задание проверит адрес заново, а не поверит кэшу. */
|
|
310
|
+
const invalidate = (mac, ip = null) => {
|
|
311
|
+
const entry = verified.get(mac);
|
|
312
|
+
if (entry) {
|
|
313
|
+
entry.at = 0;
|
|
314
|
+
}
|
|
315
|
+
if (ip) {
|
|
316
|
+
learned.delete(ip);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** Скан принтеров (/printers.get): добавить MAC из одной ARP-таблицы. */
|
|
321
|
+
const withMacs = async (devices) => {
|
|
322
|
+
const table = await readArp();
|
|
323
|
+
return devices.map((device) => (device.type === 'LAN' && table.get(device.ip_address)
|
|
324
|
+
? {...device, mac_address: table.get(device.ip_address)}
|
|
325
|
+
: device));
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Только для тестов: подмена ARP, пробы порта и списка хостов; сброс состояния. */
|
|
329
|
+
const setHooks = (overrides = {}) => {
|
|
330
|
+
Object.assign(hooks, {arp: systemArp, probe, hosts: subnetHosts}, overrides);
|
|
331
|
+
verified.clear();
|
|
332
|
+
learned.clear();
|
|
333
|
+
searches.clear();
|
|
334
|
+
loaded = false;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
enabled,
|
|
339
|
+
normalizeMac,
|
|
340
|
+
parseArp,
|
|
341
|
+
subnetHosts,
|
|
342
|
+
resolve,
|
|
343
|
+
findByMac,
|
|
344
|
+
invalidate,
|
|
345
|
+
withMacs,
|
|
346
|
+
setHooks,
|
|
347
|
+
VERIFY_EVERY_MS,
|
|
348
|
+
};
|
package/app/proxy.js
CHANGED
|
@@ -3,12 +3,13 @@ const {getLogger} = require("./logger/Log");
|
|
|
3
3
|
const {createProxyMiddleware, fixRequestBody} = require("http-proxy-middleware");
|
|
4
4
|
const moment = require("moment");
|
|
5
5
|
const {v4: uuidv4} = require('uuid');
|
|
6
|
+
const {isSyncEnabled} = require('./sync/config');
|
|
7
|
+
const {redactHeaders, redactBody} = require('./security');
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
function isLocalRoute(req, app) {
|
|
9
11
|
const path = req.path;
|
|
10
12
|
const method = req.method.toLowerCase();
|
|
11
|
-
console.log(app._router);
|
|
12
13
|
// Проверяем стек роутов Express
|
|
13
14
|
return app._router.stack.some((layer) => {
|
|
14
15
|
if (!layer.route) return false; // Это не роут
|
|
@@ -38,10 +39,11 @@ const loadProxy = (app) => {
|
|
|
38
39
|
traceId,
|
|
39
40
|
timeStamp,
|
|
40
41
|
request: {
|
|
41
|
-
|
|
42
|
+
// Токены, ключи и пароли в лог не пишем.
|
|
43
|
+
headers: redactHeaders(req.headers),
|
|
42
44
|
method: req.method,
|
|
43
45
|
url: req.url,
|
|
44
|
-
body: req.body,
|
|
46
|
+
body: redactBody(req.body),
|
|
45
47
|
params: req.params,
|
|
46
48
|
}
|
|
47
49
|
})
|
|
@@ -94,6 +96,12 @@ const loadProxy = (app) => {
|
|
|
94
96
|
error: err
|
|
95
97
|
})
|
|
96
98
|
|
|
99
|
+
// С синком (модель F) фронт в офлайне ходит сюда за событиями, которых нет в
|
|
100
|
+
// ядре, — ему нужен честный 502, а не 200 с ошибкой в теле. Без синка ответ
|
|
101
|
+
// прежний: на 200 рассчитывают старые клиенты.
|
|
102
|
+
if (isSyncEnabled() && typeof res.writeHead === 'function' && !res.headersSent) {
|
|
103
|
+
res.writeHead(502, {'Content-Type': 'application/json'});
|
|
104
|
+
}
|
|
97
105
|
res.end(JSON.stringify({
|
|
98
106
|
error: 'Bad Gateway',
|
|
99
107
|
message: err.message,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const {getConfig} = require('../config');
|
|
3
|
+
const syncConfig = require('../sync/config');
|
|
4
|
+
const {socketRequest} = require('../socket/request');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Облачный Node, модель M (OFFLINE-FIRST-PLAN §12): реестр сигнальных сокетов мастер-касс.
|
|
8
|
+
*
|
|
9
|
+
* Заведение в режиме local: истина — SQLite мастер-кассы. Терминалы, которые работают через
|
|
10
|
+
* облако (PWA, телефоны официантов, кассы без офлайна), шлют события как раньше; Laravel
|
|
11
|
+
* (LocalModeMiddleware) не исполняет их сам, а отправляет сюда — POST /master/request, — и мы
|
|
12
|
+
* передаём событие мастеру по его сигнальному сокету (master:request, ответ ack).
|
|
13
|
+
*
|
|
14
|
+
* Обратно по тому же сокету мастер шлёт:
|
|
15
|
+
* master:broadcast — бродкаст ядра кассы → клиентам заведения, подключённым к облаку;
|
|
16
|
+
* master:terminalRequest — запрос к терминалу, который есть только в облаке (печать).
|
|
17
|
+
*
|
|
18
|
+
* Личность проверяется один раз на подключение: sync.mode в Laravel с токеном кассы
|
|
19
|
+
* (token_matches — токен выдан ЭТОМУ терминалу). До проверки сокет в реестре не виден.
|
|
20
|
+
* Реестр живёт в памяти процесса: при нескольких репликах Node нужны sticky-сессии.
|
|
21
|
+
*/
|
|
22
|
+
const masters = new Map();
|
|
23
|
+
|
|
24
|
+
const PRINT_EVENTS = new Set(['orders.sendToWorkshops', 'orders.printBillReceipt']);
|
|
25
|
+
|
|
26
|
+
const bucket = (companyId) => {
|
|
27
|
+
if (!masters.has(companyId)) {
|
|
28
|
+
masters.set(companyId, new Map());
|
|
29
|
+
}
|
|
30
|
+
return masters.get(companyId);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const unregister = (socket) => {
|
|
34
|
+
const companyMasters = masters.get(socket.masterCompanyId);
|
|
35
|
+
if (companyMasters && companyMasters.get(socket.masterTerminalId) === socket) {
|
|
36
|
+
companyMasters.delete(socket.masterTerminalId);
|
|
37
|
+
if (!companyMasters.size) {
|
|
38
|
+
masters.delete(socket.masterCompanyId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Проверка через Laravel: токен выдан этому терминалу этой компании. */
|
|
44
|
+
const verify = async (auth) => {
|
|
45
|
+
if (!auth.token || !auth.company_id || !auth.team_id || !auth.terminal_id) {
|
|
46
|
+
return {ok: false, reason: 'missing_credentials'};
|
|
47
|
+
}
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = await axios.post(`${getConfig().apiUrl}/api/sync.mode`, {}, {
|
|
51
|
+
headers: {
|
|
52
|
+
Accept: 'application/json',
|
|
53
|
+
Authorization: `Bearer ${auth.token}`,
|
|
54
|
+
'team-id': String(auth.team_id),
|
|
55
|
+
'company-id': String(auth.company_id),
|
|
56
|
+
'terminal-id': String(auth.terminal_id),
|
|
57
|
+
},
|
|
58
|
+
timeout: syncConfig.relayTimeouts().verify,
|
|
59
|
+
validateStatus: () => true,
|
|
60
|
+
});
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return {ok: false, reason: 'verify_unavailable'};
|
|
63
|
+
}
|
|
64
|
+
if (response.status !== 200 || !response.data || response.data.token_matches !== true) {
|
|
65
|
+
return {ok: false, reason: `verify_failed_${response.status}`};
|
|
66
|
+
}
|
|
67
|
+
return {ok: true};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Сигнальный сокет с handshake.auth.master. company_id в query сокета обязан совпасть с
|
|
72
|
+
* тем, что проверено токеном, иначе касса слушала бы комнату чужой компании.
|
|
73
|
+
*/
|
|
74
|
+
const register = (socket) => {
|
|
75
|
+
const auth = socket.handshake.auth || {};
|
|
76
|
+
const queryCompanyId = String(socket.handshake.query.company_id || '');
|
|
77
|
+
|
|
78
|
+
socket.on('disconnect', () => unregister(socket));
|
|
79
|
+
|
|
80
|
+
(async () => {
|
|
81
|
+
const result = String(auth.company_id || '') === queryCompanyId
|
|
82
|
+
? await verify(auth)
|
|
83
|
+
: {ok: false, reason: 'company_mismatch'};
|
|
84
|
+
if (!socket.connected) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
console.warn(`[relay] master socket refused (${result.reason}) terminal=${auth.terminal_id}`);
|
|
89
|
+
socket.emit('master:ready', {ok: false, reason: result.reason});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
socket.masterCompanyId = queryCompanyId;
|
|
94
|
+
socket.masterTerminalId = String(auth.terminal_id);
|
|
95
|
+
bucket(socket.masterCompanyId).set(socket.masterTerminalId, socket);
|
|
96
|
+
attachHandlers(socket);
|
|
97
|
+
socket.emit('master:ready', {ok: true});
|
|
98
|
+
console.log(`[relay] master connected company=${socket.masterCompanyId} terminal=${socket.masterTerminalId}`);
|
|
99
|
+
})().catch((e) => console.error('[relay] master register failed:', e.message));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const attachHandlers = (socket) => {
|
|
103
|
+
const companyId = socket.masterCompanyId;
|
|
104
|
+
|
|
105
|
+
// Бродкаст ядра мастера → остальные сокеты заведения в облаке (не обратно мастеру).
|
|
106
|
+
socket.on('master:broadcast', (payload) => {
|
|
107
|
+
if (!payload || typeof payload.event !== 'string' || !payload.event) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Служебные каналы адресные: мастер не может через бродкаст отвечать за других.
|
|
111
|
+
if (/^(api|request|response|master):/.test(payload.event)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const args = Array.isArray(payload.args) ? payload.args : [];
|
|
115
|
+
socket.to(companyId).emit(payload.event, ...args);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Печать/запрос на терминал, подключённый только к облаку. Только терминалы своей компании.
|
|
119
|
+
socket.on('master:terminalRequest', async (payload, ack) => {
|
|
120
|
+
if (typeof ack !== 'function') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!payload || !payload.terminal_id || !payload.event) {
|
|
124
|
+
ack({success: false, message: 'Invalid request'});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const response = await socketRequest({
|
|
128
|
+
terminalId: String(payload.terminal_id),
|
|
129
|
+
companyId,
|
|
130
|
+
eventName: String(payload.event),
|
|
131
|
+
data: payload.data ?? null,
|
|
132
|
+
requestId: payload.request_id ?? null,
|
|
133
|
+
timeout: Number(payload.timeout) > 0 ? Number(payload.timeout) : undefined,
|
|
134
|
+
});
|
|
135
|
+
ack(response);
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* POST /master/request от Laravel → мастер-касса. Возвращает {status, body} для ответа Laravel:
|
|
141
|
+
* {relayed:true, status, body} — ответ мастера; relayed:false — мастера нет/не успел.
|
|
142
|
+
*/
|
|
143
|
+
const relayRequest = async (payload) => {
|
|
144
|
+
const companyId = String(payload.company_id || '');
|
|
145
|
+
const terminalId = String(payload.master_terminal_id || '');
|
|
146
|
+
const socket = masters.get(companyId)?.get(terminalId);
|
|
147
|
+
if (!socket || !socket.connected) {
|
|
148
|
+
return {status: 503, body: {relayed: false, error_code: 'master_offline'}};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const timeouts = syncConfig.relayTimeouts();
|
|
152
|
+
const waitMs = PRINT_EVENTS.has(payload.event) ? timeouts.print : timeouts.api;
|
|
153
|
+
try {
|
|
154
|
+
const ack = await socket.timeout(waitMs).emitWithAck('master:request', {
|
|
155
|
+
company_id: companyId,
|
|
156
|
+
master_terminal_id: terminalId,
|
|
157
|
+
epoch: payload.epoch,
|
|
158
|
+
event: payload.event,
|
|
159
|
+
headers: payload.headers || {},
|
|
160
|
+
params: payload.params || {},
|
|
161
|
+
});
|
|
162
|
+
return {status: 200, body: ack || {relayed: false, error_code: 'master_error'}};
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return {status: 504, body: {relayed: false, error_code: 'master_timeout'}};
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const status = () => [...masters.entries()].map(([companyId, companyMasters]) => ({
|
|
169
|
+
company_id: companyId,
|
|
170
|
+
terminals: [...companyMasters.keys()],
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
module.exports = {register, relayRequest, status, _masters: masters};
|