work-license-server 1.0.0 → 1.0.2

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/src/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * HTTP-сервер приложения work.odant.org:
3
- * - GET / — Продающий лендинг ПАК WORK + калькулятор + консоль ботов;
4
- * - GET /api/catalog — Спецификация ПАК (AMD Ryzen 495 8085 192GB 4TB) и тарифы Start/Business/Enterprise;
3
+ * - GET / — Витрина ODANT-WORK (платформа, ИИ-агенты, тарифы, заказ, франшиза, поддержка);
4
+ * - GET /favicon.ico, /assets/* — Логотип ODANT, стили и скрипт витрины, контуры субъектов РФ;
5
+ * - GET /api/catalog — Спецификация комплекса (AMD Ryzen™ AI Max+ PRO 495, 192 ГБ, 4 ТБ) и тарифы Start/Business/Enterprise;
5
6
  * - POST /api/quote — Расчёт стоимости заказа;
6
7
  * - POST /api/license/issue — Выпуск базовой лицензии ПАК (5 вечных мест + 1 год поддержки);
7
8
  * - POST /api/license/sync — Онлайн-синхронизация лицензии с ПАК клиента (привязка HWID + node-key.json);
@@ -16,9 +17,13 @@
16
17
  * - GET /api/admin/overview — Сводка по лицензиям, заказам и тикетам.
17
18
  */
18
19
  import http from 'node:http';
20
+ import fs from 'node:fs';
21
+ import crypto from 'node:crypto';
19
22
  import {
20
23
  PAK_PRODUCT,
21
24
  SLA_TIERS,
25
+ SEAT_VOLUME_DISCOUNTS,
26
+ calculateSeatVolumeDiscount,
22
27
  calculateOrderQuote,
23
28
  FRANCHISE_PROGRAM,
24
29
  REGIONS_CATALOG,
@@ -31,6 +36,7 @@ import {
31
36
  import { LicenseStore } from './store.js';
32
37
  import { YooKassaService } from './yookassa.js';
33
38
  import { SupportBotsHub } from './bots.js';
39
+ import { LicenseServerAiAgent } from './agent.js';
34
40
  import { renderLandingPage, renderSandboxPaymentPage } from './landing.js';
35
41
 
36
42
  async function readJsonBody(req, maxBytes = 1024 * 1024) {
@@ -59,6 +65,42 @@ function sendJson(res, status, data) {
59
65
  res.end(body);
60
66
  }
61
67
 
68
+ const ASSETS_DIR = new URL('../assets/', import.meta.url);
69
+ const STATIC_ASSETS = Object.freeze({
70
+ '/favicon.ico': { file: 'odant-logo.png', type: 'image/png' },
71
+ '/assets/odant-logo.png': { file: 'odant-logo.png', type: 'image/png' },
72
+ '/assets/russia-regions.json': { file: 'russia-regions.json', type: 'application/json; charset=utf-8' },
73
+ '/assets/landing.css': { file: 'landing.css', type: 'text/css; charset=utf-8' },
74
+ '/assets/landing.js': { file: 'landing.js', type: 'text/javascript; charset=utf-8' },
75
+ });
76
+ const assetCache = new Map();
77
+ let assetVersionCache = '';
78
+
79
+ /** Версия статики для сброса кэша браузера: короткий хеш стилей и скрипта витрины. */
80
+ function assetVersion() {
81
+ if (assetVersionCache && !process.env.WORK_DEV)
82
+ return assetVersionCache;
83
+ const hash = crypto.createHash('sha1');
84
+ for (const file of ['landing.css', 'landing.js'])
85
+ hash.update(fs.readFileSync(new URL(file, ASSETS_DIR)));
86
+ assetVersionCache = hash.digest('hex').slice(0, 10);
87
+ return assetVersionCache;
88
+ }
89
+
90
+ function sendStaticAsset(res, asset) {
91
+ let body = assetCache.get(asset.file);
92
+ if (!body) {
93
+ body = fs.readFileSync(new URL(asset.file, ASSETS_DIR));
94
+ if (!process.env.WORK_DEV)
95
+ assetCache.set(asset.file, body);
96
+ }
97
+ res.writeHead(200, {
98
+ 'Content-Type': asset.type,
99
+ 'Cache-Control': process.env.WORK_DEV ? 'no-cache' : 'public, max-age=3600',
100
+ });
101
+ res.end(body);
102
+ }
103
+
62
104
  function sendHtml(res, status, html) {
63
105
  res.writeHead(status, {
64
106
  'Content-Type': 'text/html; charset=utf-8',
@@ -89,6 +131,11 @@ export function createLicenseServerApp(options = {}) {
89
131
  publicOrigin: options.publicOrigin || 'https://work.odant.org',
90
132
  fetchImpl: options.fetchImpl,
91
133
  });
134
+ const agent = options.agent || new LicenseServerAiAgent({
135
+ store,
136
+ yookassa,
137
+ fetchImpl: options.fetchImpl,
138
+ });
92
139
 
93
140
  async function handler(req, res) {
94
141
  try {
@@ -108,9 +155,19 @@ export function createLicenseServerApp(options = {}) {
108
155
  const url = new URL(req.url || '/', reqOrigin);
109
156
  const pathname = url.pathname;
110
157
 
111
- // 1. Главная страница лендинга work.odant.org
112
- if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
113
- return sendHtml(res, 200, renderLandingPage({ publicKey: store.authorityKeys.publicKey }));
158
+ // 1. Главная страница лендинга work.odant.org и панель администратора (/admin)
159
+ if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html' || pathname === '/admin')) {
160
+ return sendHtml(res, 200, renderLandingPage({
161
+ publicKey: store.authorityKeys.publicKey,
162
+ regions: store.listRegionsWithQuotas(),
163
+ assetVersion: assetVersion(),
164
+ initialStage: pathname === '/admin' ? 'admin' : '',
165
+ }));
166
+ }
167
+
168
+ // 1.1 Статика лендинга: логотип ODANT, стили, клиентский скрипт и контуры субъектов РФ
169
+ if (req.method === 'GET' && STATIC_ASSETS[pathname]) {
170
+ return sendStaticAsset(res, STATIC_ASSETS[pathname]);
114
171
  }
115
172
 
116
173
  // 2. Каталог ПАК, тарифов SLA и партнёрской франшизы
@@ -118,6 +175,7 @@ export function createLicenseServerApp(options = {}) {
118
175
  return sendJson(res, 200, {
119
176
  product: PAK_PRODUCT,
120
177
  tiers: Object.values(SLA_TIERS),
178
+ volumeDiscounts: SEAT_VOLUME_DISCOUNTS,
121
179
  franchiseProgram: FRANCHISE_PROGRAM,
122
180
  regions: store.listRegionsWithQuotas(),
123
181
  trainingTracks: PARTNER_TRAINING_TRACKS,
@@ -125,6 +183,22 @@ export function createLicenseServerApp(options = {}) {
125
183
  });
126
184
  }
127
185
 
186
+ // 2.0 Вход / регистрация пользователя (для доступа к разделу докупки пользовательских лицензий)
187
+ if (req.method === 'POST' && (pathname === '/api/auth/login' || pathname === '/api/auth/register')) {
188
+ const body = await readJsonBody(req);
189
+ const user = store.registerOrLoginUser(body);
190
+ return sendJson(res, 200, user);
191
+ }
192
+
193
+ if (req.method === 'GET' && pathname === '/api/auth/me') {
194
+ const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
195
+ const key = url.searchParams.get('sessionKey') || authHeader;
196
+ const user = store.findUserBySessionKey(key);
197
+ if (!user)
198
+ return sendJson(res, 401, { authenticated: false });
199
+ return sendJson(res, 200, { authenticated: true, user });
200
+ }
201
+
128
202
  // 2.1 Партнёрская программа: справочник регионов с квотами по населению, шкала скидок 10%-50% и курсы сертификации
129
203
  if (req.method === 'GET' && pathname === '/api/partners/program') {
130
204
  return sendJson(res, 200, {
@@ -164,6 +238,28 @@ export function createLicenseServerApp(options = {}) {
164
238
  return sendJson(res, 200, result);
165
239
  }
166
240
 
241
+ // 2.6 Резервирование региональной франшизы с оплатой невозвратного залога 100 000 ₽ через ЮKassa
242
+ if (req.method === 'POST' && pathname === '/api/partners/reserve') {
243
+ const body = await readJsonBody(req);
244
+ const reserved = store.reserveFranchiseSlot(body);
245
+ const payment = await yookassa.createPaymentForOrder(reserved.order, { originOverride: reqOrigin });
246
+ return sendJson(res, 200, {
247
+ ...payment,
248
+ reservation: reserved.reservation,
249
+ order: reserved.order,
250
+ regionQuota: reserved.regionQuota,
251
+ });
252
+ }
253
+
254
+ // 2.7 Собственный ИИ-агент лицензионного сервера ODANT-WORK
255
+ if (req.method === 'POST' && pathname === '/api/agent/chat') {
256
+ const body = await readJsonBody(req);
257
+ const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
258
+ const user = store.findUserBySessionKey(body.sessionKey || authHeader);
259
+ const reply = await agent.chat({ message: body.message || body.text || '', history: body.history || [], user });
260
+ return sendJson(res, 200, reply);
261
+ }
262
+
167
263
  // 3. Калькулятор стоимости
168
264
  if (req.method === 'POST' && pathname === '/api/quote') {
169
265
  const body = await readJsonBody(req);
@@ -263,6 +359,7 @@ export function createLicenseServerApp(options = {}) {
263
359
  tickets: store.listTickets(),
264
360
  orders: Object.values(store.state.orders),
265
361
  partners: store.listPartners(),
362
+ reservations: Object.values(store.state.reservations || {}),
266
363
  regions: store.listRegionsWithQuotas(),
267
364
  });
268
365
  }
@@ -278,6 +375,7 @@ export function createLicenseServerApp(options = {}) {
278
375
  store,
279
376
  yookassa,
280
377
  bots,
378
+ agent,
281
379
  handler,
282
380
  listen(port = Number(process.env.PORT || 8020), host = '0.0.0.0') {
283
381
  const server = http.createServer(handler);
package/src/store.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  PAK_PRODUCT,
13
13
  SLA_TIERS,
14
14
  calculateOrderQuote,
15
+ FRANCHISE_PROGRAM,
15
16
  REGIONS_CATALOG,
16
17
  PARTNER_TRAINING_TRACKS,
17
18
  calculateFranchiseFee,
@@ -35,7 +36,9 @@ export class LicenseStore {
35
36
  tickets: {},
36
37
  botBindings: {},
37
38
  partners: {},
39
+ reservations: {},
38
40
  certificates: {},
41
+ users: {},
39
42
  };
40
43
  this._load();
41
44
  }
@@ -51,7 +54,9 @@ export class LicenseStore {
51
54
  this.state.tickets = raw.tickets || {};
52
55
  this.state.botBindings = raw.botBindings || {};
53
56
  this.state.partners = raw.partners || {};
57
+ this.state.reservations = raw.reservations || {};
54
58
  this.state.certificates = raw.certificates || {};
59
+ this.state.users = raw.users || {};
55
60
  }
56
61
  }
57
62
  catch { /* начинаем с чистого состояния */ }
@@ -427,6 +432,21 @@ export class LicenseStore {
427
432
  if (paymentId)
428
433
  ord.paymentId = paymentId;
429
434
 
435
+ if (ord.kind === 'franchise_reservation' && ord.reservationId && this.state.reservations[ord.reservationId]) {
436
+ const resv = this.state.reservations[ord.reservationId];
437
+ resv.depositPaid = true;
438
+ resv.paidAt = ord.paidAt;
439
+ resv.paymentId = ord.paymentId;
440
+ resv.status = 'deposit_paid';
441
+ this._save();
442
+ return {
443
+ order: ord,
444
+ reservation: resv,
445
+ regionQuota: this.getRegionPartnerQuotaStatus(resv.regionCode),
446
+ alreadyProcessed: false,
447
+ };
448
+ }
449
+
430
450
  const license = this.grantAddonSubscription(ord.licenseId, {
431
451
  tier: ord.tier,
432
452
  seats: ord.addonSeats,
@@ -569,17 +589,197 @@ export class LicenseStore {
569
589
  }
570
590
 
571
591
  /**
572
- * Получить статус квоты партнёров по региону (макс. число партнёров пропорционально населению).
592
+ * Зарегистрировать или авторизовать пользователя личного кабинета ODANT-WORK.
593
+ * Докупка дополнительных пользовательских лицензий доступна только зарегистрированным пользователям.
594
+ */
595
+ registerOrLoginUser({ email = '', name = '', companyName = '', inn = '', licenseId = '' } = {}) {
596
+ const cleanEmail = String(email || '').trim().toLowerCase();
597
+ if (!cleanEmail || !cleanEmail.includes('@'))
598
+ throw new Error('Укажите корректный email для входа в личный кабинет ODANT-WORK');
599
+
600
+ let user = this.state.users[cleanEmail];
601
+ const now = Date.now();
602
+ if (!user) {
603
+ let lic = licenseId ? this.findLicense(licenseId) : null;
604
+ if (!lic) {
605
+ const summary = this.issueOrUpdateLicense({
606
+ customer: {
607
+ name: companyName || name || 'Организация ODANT-WORK',
608
+ inn: inn || '',
609
+ email: cleanEmail,
610
+ },
611
+ });
612
+ lic = summary.certificate;
613
+ }
614
+ user = {
615
+ userId: 'USR-' + crypto.createHash('sha256').update(cleanEmail).digest('hex').slice(0, 10).toUpperCase(),
616
+ email: cleanEmail,
617
+ name: String(name || companyName || cleanEmail.split('@')[0]).trim(),
618
+ companyName: String(companyName || lic.customer?.name || 'Организация ODANT-WORK').trim(),
619
+ inn: String(inn || lic.customer?.inn || '').trim(),
620
+ licenseId: lic.licenseId,
621
+ sessionKey: 'wks_' + crypto.randomBytes(16).toString('hex'),
622
+ createdAt: now,
623
+ lastLoginAt: now,
624
+ };
625
+ this.state.users[cleanEmail] = user;
626
+ }
627
+ else {
628
+ user.lastLoginAt = now;
629
+ if (name) user.name = String(name).trim();
630
+ if (companyName) user.companyName = String(companyName).trim();
631
+ if (inn) user.inn = String(inn).trim();
632
+ if (licenseId && this.findLicense(licenseId)) {
633
+ user.licenseId = this.findLicense(licenseId).licenseId;
634
+ }
635
+ }
636
+ this._save();
637
+ return {
638
+ userId: user.userId,
639
+ email: user.email,
640
+ name: user.name,
641
+ companyName: user.companyName,
642
+ inn: user.inn,
643
+ licenseId: user.licenseId,
644
+ sessionKey: user.sessionKey,
645
+ license: this.getLicenseSummary(user.licenseId),
646
+ };
647
+ }
648
+
649
+ findUserBySessionKey(sessionKey = '') {
650
+ const key = String(sessionKey || '').trim();
651
+ if (!key)
652
+ return null;
653
+ const user = Object.values(this.state.users).find(u => u.sessionKey === key);
654
+ if (!user)
655
+ return null;
656
+ return {
657
+ userId: user.userId,
658
+ email: user.email,
659
+ name: user.name,
660
+ companyName: user.companyName,
661
+ inn: user.inn,
662
+ licenseId: user.licenseId,
663
+ license: this.getLicenseSummary(user.licenseId),
664
+ };
665
+ }
666
+
667
+ /**
668
+ * Получить статус квоты и вакантных франшиз по региону (макс. число партнёров пропорционально населению).
573
669
  */
574
670
  getRegionPartnerQuotaStatus(regionCode = 'RU-SVE', customPopulationMillions = null) {
575
671
  const info = calculateFranchiseFee(regionCode, customPopulationMillions);
576
672
  const existingInRegion = Object.values(this.state.partners).filter(
577
673
  p => p.regionCode === info.regionCode && p.status !== 'revoked'
578
674
  );
675
+ const reservedInRegion = Object.values(this.state.reservations || {}).filter(
676
+ r => r.regionCode === info.regionCode && r.status !== 'canceled' && !r.convertedToPartnerId
677
+ );
678
+ const occupied = existingInRegion.length + reservedInRegion.length;
679
+ const vacantFranchises = Math.max(0, info.maxPartnersQuota - occupied);
579
680
  return {
580
681
  ...info,
581
682
  activePartnersCount: existingInRegion.length,
582
- availablePartnerSlots: Math.max(0, info.maxPartnersQuota - existingInRegion.length),
683
+ reservedFranchisesCount: reservedInRegion.length,
684
+ occupiedFranchisesCount: occupied,
685
+ availablePartnerSlots: vacantFranchises,
686
+ vacantFranchises,
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Зарезервировать региональную франшизу с выставлением счёта ЮKassa на невозвратный залог 100 000 ₽.
692
+ * Полный паушальный взнос (500 000 ₽ × индекс региона) фиксируется в договоре резерва, полная оплата — в течение 1 года.
693
+ */
694
+ reserveFranchiseSlot({
695
+ regionCode = 'RU-SVE',
696
+ companyName = '',
697
+ inn = '',
698
+ email = '',
699
+ phone = '',
700
+ contactPerson = '',
701
+ returnUrl = '',
702
+ } = {}) {
703
+ if (!companyName)
704
+ throw new Error('Укажите наименование компании для резервирования региональной франшизы');
705
+
706
+ const quotaStatus = this.getRegionPartnerQuotaStatus(regionCode);
707
+ if (quotaStatus.vacantFranchises <= 0) {
708
+ throw new Error(
709
+ `В регионе «${quotaStatus.regionName}» нет вакантных франшиз `
710
+ + `(занято ${quotaStatus.occupiedFranchisesCount} из ${quotaStatus.maxPartnersQuota}, пропорционально населению ${quotaStatus.populationMillions} млн чел.)`
711
+ );
712
+ }
713
+
714
+ const now = Date.now();
715
+ const reservationId = 'RES-' + quotaStatus.regionCode.replace(/^RU-/, '') + '-' + crypto.randomBytes(3).toString('hex').toUpperCase();
716
+ const depositRub = FRANCHISE_PROGRAM.reservationDepositRub;
717
+ const orderId = 'ORD-FR-' + Date.now().toString(36).toUpperCase() + '-' + crypto.randomBytes(2).toString('hex').toUpperCase();
718
+
719
+ const reservation = {
720
+ reservationId,
721
+ orderId,
722
+ regionCode: quotaStatus.regionCode,
723
+ regionName: quotaStatus.regionName,
724
+ district: quotaStatus.district,
725
+ companyName: String(companyName).trim(),
726
+ inn: String(inn || '').trim(),
727
+ email: String(email || 'franchise@odant.org').trim(),
728
+ phone: String(phone || '').trim(),
729
+ contactPerson: String(contactPerson || '').trim(),
730
+ franchiseFeeRub: quotaStatus.franchiseFeeRub,
731
+ nonRefundableDepositRub: depositRub,
732
+ remainingFranchiseFeeRub: Math.max(0, quotaStatus.franchiseFeeRub - depositRub),
733
+ fullPaymentPeriodMonths: FRANCHISE_PROGRAM.fullPaymentPeriodMonths,
734
+ fullPaymentDeadlineAt: now + 365 * 24 * 3600_000,
735
+ status: 'reserved_pending_deposit',
736
+ depositPaid: false,
737
+ createdAt: now,
738
+ expiresAt: now + 365 * 24 * 3600_000,
739
+ };
740
+
741
+ const order = {
742
+ orderId,
743
+ kind: 'franchise_reservation',
744
+ reservationId,
745
+ licenseId: reservationId,
746
+ regionCode: quotaStatus.regionCode,
747
+ regionName: quotaStatus.regionName,
748
+ tierName: `Резерв франшизы (${quotaStatus.regionName})`,
749
+ addonSeats: 1,
750
+ years: 1,
751
+ amountRub: depositRub,
752
+ franchiseFeeRub: quotaStatus.franchiseFeeRub,
753
+ items: [
754
+ {
755
+ title: `Невозвратный залог за резервирование региональной франшизы ODANT-WORK (${quotaStatus.regionName}, паушальный взнос ${quotaStatus.franchiseFeeRub.toLocaleString('ru-RU')} ₽, полная оплата в течение 1 года)`,
756
+ quantity: 1,
757
+ unitPriceRub: depositRub,
758
+ totalRub: depositRub,
759
+ },
760
+ ],
761
+ customer: {
762
+ name: reservation.companyName,
763
+ inn: reservation.inn,
764
+ email: reservation.email,
765
+ phone: reservation.phone,
766
+ },
767
+ status: 'pending',
768
+ paymentId: '',
769
+ confirmationUrl: '',
770
+ returnUrl: returnUrl || 'https://work.odant.org',
771
+ createdAt: now,
772
+ paidAt: null,
773
+ };
774
+
775
+ this.state.reservations[reservationId] = reservation;
776
+ this.state.orders[orderId] = order;
777
+ this._save();
778
+
779
+ return {
780
+ reservation,
781
+ order,
782
+ regionQuota: this.getRegionPartnerQuotaStatus(quotaStatus.regionCode),
583
783
  };
584
784
  }
585
785