work-license-server 0.0.0-stage → 1.0.0
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/README.md +17 -2
- package/bin/server.mjs +14 -0
- package/package.json +29 -4
- package/src/bots.js +311 -0
- package/src/catalog.js +451 -0
- package/src/crypto.js +120 -0
- package/src/index.js +17 -0
- package/src/landing.js +981 -0
- package/src/server.js +290 -0
- package/src/store.js +758 -0
- package/src/yookassa.js +184 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-сервер приложения work.odant.org:
|
|
3
|
+
* - GET / — Продающий лендинг ПАК WORK + калькулятор + консоль ботов;
|
|
4
|
+
* - GET /api/catalog — Спецификация ПАК (AMD Ryzen 495 8085 192GB 4TB) и тарифы Start/Business/Enterprise;
|
|
5
|
+
* - POST /api/quote — Расчёт стоимости заказа;
|
|
6
|
+
* - POST /api/license/issue — Выпуск базовой лицензии ПАК (5 вечных мест + 1 год поддержки);
|
|
7
|
+
* - POST /api/license/sync — Онлайн-синхронизация лицензии с ПАК клиента (привязка HWID + node-key.json);
|
|
8
|
+
* - GET /api/license/:id — Статус и сертификат лицензии;
|
|
9
|
+
* - POST /api/orders/checkout — Создание заказа и платежа ЮKassa;
|
|
10
|
+
* - POST /api/payments/yookassa/webhook — Webhook уведомлений ЮKassa (payment.succeeded / payment.canceled);
|
|
11
|
+
* - GET /pay/sandbox/:orderId — Встроенная страница оплаты песочницы ЮKassa;
|
|
12
|
+
* - POST /api/bots/max/webhook — Webhook бота поддержки в мессенджере MAX;
|
|
13
|
+
* - POST /api/bots/telegram/webhook — Webhook бота поддержки в Telegram;
|
|
14
|
+
* - POST /api/bots/vk/webhook — Webhook бота поддержки в VK;
|
|
15
|
+
* - POST /api/tickets/:id/reply — Ответ инженера поддержки клиенту с отправкой в MAX / Telegram / VK;
|
|
16
|
+
* - GET /api/admin/overview — Сводка по лицензиям, заказам и тикетам.
|
|
17
|
+
*/
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import {
|
|
20
|
+
PAK_PRODUCT,
|
|
21
|
+
SLA_TIERS,
|
|
22
|
+
calculateOrderQuote,
|
|
23
|
+
FRANCHISE_PROGRAM,
|
|
24
|
+
REGIONS_CATALOG,
|
|
25
|
+
PARTNER_TRAINING_TRACKS,
|
|
26
|
+
calculateFranchiseFee,
|
|
27
|
+
calculatePartnerDiscount,
|
|
28
|
+
calculatePartnerEconomics,
|
|
29
|
+
calculateRegionPartnerQuota,
|
|
30
|
+
} from './catalog.js';
|
|
31
|
+
import { LicenseStore } from './store.js';
|
|
32
|
+
import { YooKassaService } from './yookassa.js';
|
|
33
|
+
import { SupportBotsHub } from './bots.js';
|
|
34
|
+
import { renderLandingPage, renderSandboxPaymentPage } from './landing.js';
|
|
35
|
+
|
|
36
|
+
async function readJsonBody(req, maxBytes = 1024 * 1024) {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
for await (const ch of req) {
|
|
40
|
+
size += ch.length;
|
|
41
|
+
if (size > maxBytes)
|
|
42
|
+
throw new Error('Request body too large');
|
|
43
|
+
chunks.push(ch);
|
|
44
|
+
}
|
|
45
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
46
|
+
if (!raw)
|
|
47
|
+
return {};
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sendJson(res, status, data) {
|
|
52
|
+
const body = JSON.stringify(data, null, 2);
|
|
53
|
+
res.writeHead(status, {
|
|
54
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
55
|
+
'Access-Control-Allow-Origin': '*',
|
|
56
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
57
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
58
|
+
});
|
|
59
|
+
res.end(body);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sendHtml(res, status, html) {
|
|
63
|
+
res.writeHead(status, {
|
|
64
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
65
|
+
});
|
|
66
|
+
res.end(html);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createLicenseServerApp(options = {}) {
|
|
70
|
+
const store = options.store || new LicenseStore({ dataDir: options.dataDir || './data' });
|
|
71
|
+
let bots = null;
|
|
72
|
+
const yookassa = options.yookassa || new YooKassaService({
|
|
73
|
+
store,
|
|
74
|
+
shopId: options.yookassaShopId,
|
|
75
|
+
secretKey: options.yookassaSecretKey,
|
|
76
|
+
publicOrigin: options.publicOrigin || 'https://work.odant.org',
|
|
77
|
+
fetchImpl: options.fetchImpl,
|
|
78
|
+
onOrderPaid: async (order, license) => {
|
|
79
|
+
await bots?.notifyLicenseOrderPaid(order, license);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
bots = options.bots || new SupportBotsHub({
|
|
83
|
+
store,
|
|
84
|
+
yookassa,
|
|
85
|
+
maxBotSecret: options.maxBotSecret,
|
|
86
|
+
telegramBotSecret: options.telegramBotSecret,
|
|
87
|
+
vkBotSecret: options.vkBotSecret,
|
|
88
|
+
vkConfirmationCode: options.vkConfirmationCode,
|
|
89
|
+
publicOrigin: options.publicOrigin || 'https://work.odant.org',
|
|
90
|
+
fetchImpl: options.fetchImpl,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
async function handler(req, res) {
|
|
94
|
+
try {
|
|
95
|
+
if (req.method === 'OPTIONS') {
|
|
96
|
+
res.writeHead(204, {
|
|
97
|
+
'Access-Control-Allow-Origin': '*',
|
|
98
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
99
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
100
|
+
});
|
|
101
|
+
res.end();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const hostHeader = req.headers.host || 'work.odant.org';
|
|
106
|
+
const proto = req.headers['x-forwarded-proto'] || 'http';
|
|
107
|
+
const reqOrigin = `${proto}://${hostHeader}`;
|
|
108
|
+
const url = new URL(req.url || '/', reqOrigin);
|
|
109
|
+
const pathname = url.pathname;
|
|
110
|
+
|
|
111
|
+
// 1. Главная страница лендинга work.odant.org
|
|
112
|
+
if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
|
|
113
|
+
return sendHtml(res, 200, renderLandingPage({ publicKey: store.authorityKeys.publicKey }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Каталог ПАК, тарифов SLA и партнёрской франшизы
|
|
117
|
+
if (req.method === 'GET' && pathname === '/api/catalog') {
|
|
118
|
+
return sendJson(res, 200, {
|
|
119
|
+
product: PAK_PRODUCT,
|
|
120
|
+
tiers: Object.values(SLA_TIERS),
|
|
121
|
+
franchiseProgram: FRANCHISE_PROGRAM,
|
|
122
|
+
regions: store.listRegionsWithQuotas(),
|
|
123
|
+
trainingTracks: PARTNER_TRAINING_TRACKS,
|
|
124
|
+
authorityPublicKey: store.authorityKeys.publicKey,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2.1 Партнёрская программа: справочник регионов с квотами по населению, шкала скидок 10%-50% и курсы сертификации
|
|
129
|
+
if (req.method === 'GET' && pathname === '/api/partners/program') {
|
|
130
|
+
return sendJson(res, 200, {
|
|
131
|
+
franchiseProgram: FRANCHISE_PROGRAM,
|
|
132
|
+
regions: store.listRegionsWithQuotas(),
|
|
133
|
+
trainingTracks: PARTNER_TRAINING_TRACKS,
|
|
134
|
+
partners: store.listPartners(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2.2 Калькулятор экономики партнёра (стоимость франшизы по региону, квота по населению, скидка 10%-50% от объёма продаж)
|
|
139
|
+
if (req.method === 'POST' && pathname === '/api/partners/calc') {
|
|
140
|
+
const body = await readJsonBody(req);
|
|
141
|
+
const econ = calculatePartnerEconomics(body);
|
|
142
|
+
const quota = store.getRegionPartnerQuotaStatus(body.regionCode || 'RU-SVE', body.populationMillions);
|
|
143
|
+
return sendJson(res, 200, { ...econ, regionQuota: quota });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2.3 Регистрация регионального партнёра-франчайзи (с проверкой квоты партнёров пропорционально населению региона)
|
|
147
|
+
if (req.method === 'POST' && pathname === '/api/partners/register') {
|
|
148
|
+
const body = await readJsonBody(req);
|
|
149
|
+
const partner = store.registerPartner(body);
|
|
150
|
+
return sendJson(res, 200, partner);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2.4 Прохождение аттестации и выдача сертификата специалиста партнёра
|
|
154
|
+
if (req.method === 'POST' && pathname === '/api/partners/certify') {
|
|
155
|
+
const body = await readJsonBody(req);
|
|
156
|
+
const result = store.certifyPartnerSpecialist(body);
|
|
157
|
+
return sendJson(res, 200, result);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 2.5 Выдача и привязка лицензии ПАК самим партнёром (с 1 годом гарантии на оборудование и доп. услугами партнёра)
|
|
161
|
+
if (req.method === 'POST' && pathname === '/api/partners/issue-license') {
|
|
162
|
+
const body = await readJsonBody(req);
|
|
163
|
+
const result = store.partnerIssueLicense(body);
|
|
164
|
+
return sendJson(res, 200, result);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 3. Калькулятор стоимости
|
|
168
|
+
if (req.method === 'POST' && pathname === '/api/quote') {
|
|
169
|
+
const body = await readJsonBody(req);
|
|
170
|
+
return sendJson(res, 200, calculateOrderQuote(body));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 4. Выпуск базовой лицензии ПАК (5 вечных мест + 1 год поддержки)
|
|
174
|
+
if (req.method === 'POST' && pathname === '/api/license/issue') {
|
|
175
|
+
const body = await readJsonBody(req);
|
|
176
|
+
const summary = store.issueOrUpdateLicense(body);
|
|
177
|
+
return sendJson(res, 200, summary);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 5. Синхронизация лицензии с ПАК клиента
|
|
181
|
+
if (req.method === 'POST' && pathname === '/api/license/sync') {
|
|
182
|
+
const body = await readJsonBody(req);
|
|
183
|
+
const summary = store.issueOrUpdateLicense({
|
|
184
|
+
licenseId: body.licenseId,
|
|
185
|
+
hwid: body.hwid,
|
|
186
|
+
nodeFingerprint: body.nodeFingerprint,
|
|
187
|
+
customer: body.customer,
|
|
188
|
+
});
|
|
189
|
+
return sendJson(res, 200, summary);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 6. Получение лицензии по ID / HWID
|
|
193
|
+
if (req.method === 'GET' && pathname.startsWith('/api/license/')) {
|
|
194
|
+
const id = decodeURIComponent(pathname.slice('/api/license/'.length));
|
|
195
|
+
const summary = store.getLicenseSummary(id);
|
|
196
|
+
if (!summary)
|
|
197
|
+
return sendJson(res, 404, { error: 'Лицензия не найдена' });
|
|
198
|
+
return sendJson(res, 200, summary);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 7. Оформление заказа и создание платежа в ЮKassa
|
|
202
|
+
if (req.method === 'POST' && pathname === '/api/orders/checkout') {
|
|
203
|
+
const body = await readJsonBody(req);
|
|
204
|
+
const order = store.createOrder(body);
|
|
205
|
+
const payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
|
|
206
|
+
return sendJson(res, 200, {
|
|
207
|
+
...payment,
|
|
208
|
+
order,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 8. Песочница оплаты ЮKassa
|
|
213
|
+
if (req.method === 'GET' && pathname.startsWith('/pay/sandbox/')) {
|
|
214
|
+
const orderId = decodeURIComponent(pathname.slice('/pay/sandbox/'.length));
|
|
215
|
+
const order = store.getOrder(orderId);
|
|
216
|
+
if (!order)
|
|
217
|
+
return sendHtml(res, 404, '<h1>Заказ не найден</h1>');
|
|
218
|
+
return sendHtml(res, 200, renderSandboxPaymentPage(order));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 9. Webhook от ЮKassa
|
|
222
|
+
if (req.method === 'POST' && pathname === '/api/payments/yookassa/webhook') {
|
|
223
|
+
const body = await readJsonBody(req);
|
|
224
|
+
const result = await yookassa.handleWebhook(body);
|
|
225
|
+
return sendJson(res, 200, result);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 10. Webhooks ботов поддержки: MAX, Telegram, VK
|
|
229
|
+
if (req.method === 'POST' && pathname === '/api/bots/max/webhook') {
|
|
230
|
+
const body = await readJsonBody(req);
|
|
231
|
+
const result = await bots.handleMaxWebhook(body, reqOrigin);
|
|
232
|
+
return sendJson(res, 200, result);
|
|
233
|
+
}
|
|
234
|
+
if (req.method === 'POST' && pathname === '/api/bots/telegram/webhook') {
|
|
235
|
+
const body = await readJsonBody(req);
|
|
236
|
+
const result = await bots.handleTelegramWebhook(body, reqOrigin);
|
|
237
|
+
return sendJson(res, 200, result);
|
|
238
|
+
}
|
|
239
|
+
if (req.method === 'POST' && pathname === '/api/bots/vk/webhook') {
|
|
240
|
+
const body = await readJsonBody(req);
|
|
241
|
+
const vkRes = await bots.handleVkWebhook(body, reqOrigin);
|
|
242
|
+
if (vkRes.isVkConfirmation) {
|
|
243
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
244
|
+
res.end(vkRes.body);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
return sendJson(res, 200, vkRes.result);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 11. Ответ инженера по тикету поддержки
|
|
251
|
+
if (req.method === 'POST' && pathname.startsWith('/api/tickets/') && pathname.endsWith('/reply')) {
|
|
252
|
+
const ticketId = decodeURIComponent(pathname.slice('/api/tickets/'.length, -'/reply'.length));
|
|
253
|
+
const body = await readJsonBody(req);
|
|
254
|
+
const ticket = store.replyToTicket(ticketId, body);
|
|
255
|
+
await bots.deliverEngineerReply(ticket);
|
|
256
|
+
return sendJson(res, 200, ticket);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 12. Обзор для панели управления
|
|
260
|
+
if (req.method === 'GET' && pathname === '/api/admin/overview') {
|
|
261
|
+
return sendJson(res, 200, {
|
|
262
|
+
licenses: store.listLicenses(),
|
|
263
|
+
tickets: store.listTickets(),
|
|
264
|
+
orders: Object.values(store.state.orders),
|
|
265
|
+
partners: store.listPartners(),
|
|
266
|
+
regions: store.listRegionsWithQuotas(),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return sendJson(res, 404, { error: 'Not Found', path: pathname });
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
return sendJson(res, 400, { error: err?.message || String(err) });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
store,
|
|
279
|
+
yookassa,
|
|
280
|
+
bots,
|
|
281
|
+
handler,
|
|
282
|
+
listen(port = Number(process.env.PORT || 8020), host = '0.0.0.0') {
|
|
283
|
+
const server = http.createServer(handler);
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
server.once('error', reject);
|
|
286
|
+
server.listen(port, host, () => resolve(server));
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|