neuroalpha 0.0.0-stage → 0.2.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 +164 -3
- package/bin/cli.js +69 -0
- package/config/strategy.json +33 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +156 -0
- package/package.json +43 -6
- package/public/app.js +2410 -0
- package/public/index.html +337 -0
- package/public/landing.html +870 -0
- package/public/legal.html +90 -0
- package/public/styles.css +343 -0
- package/server/api/routes.js +333 -0
- package/server/auth/index.js +340 -0
- package/server/billing/yookassa.js +132 -0
- package/server/brokers/index.js +645 -0
- package/server/config.js +44 -0
- package/server/data/moex.js +165 -0
- package/server/db.js +420 -0
- package/server/hub.js +14 -0
- package/server/index.js +92 -0
- package/server/llm/client.js +187 -0
- package/server/llm/providers.js +355 -0
- package/server/model/dataset.js +352 -0
- package/server/model/forecast.js +121 -0
- package/server/model/gpu-worker.html +11 -0
- package/server/model/gpu-worker.js +60 -0
- package/server/model/linalg.js +142 -0
- package/server/model/live.js +486 -0
- package/server/model/metrics.js +134 -0
- package/server/model/models.js +459 -0
- package/server/model/profiles.js +166 -0
- package/server/model/research.js +880 -0
- package/server/model/tf-node.js +26 -0
- package/server/model/transformer-core.js +330 -0
- package/server/model/transformer-gpu.js +221 -0
- package/server/model/transformer-pool.js +200 -0
- package/server/model/transformer-worker.js +28 -0
- package/server/model/transformer.js +57 -0
- package/server/model/vocab.js +28 -0
- package/server/news/index.js +309 -0
- package/server/notify/index.js +168 -0
- package/server/params-meta.js +63 -0
- package/server/settings.js +79 -0
- package/server/strategist/analytics.js +150 -0
- package/server/strategist/index.js +484 -0
- package/server/strategist/tools.js +302 -0
- package/server/util.js +9 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { ROOT, strategyDefaults } from '../config.js';
|
|
5
|
+
import { activeModelInfo } from '../llm/client.js';
|
|
6
|
+
import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel } from '../llm/providers.js';
|
|
7
|
+
import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory } from '../db.js';
|
|
8
|
+
import { PLANS, effectivePlan, publicUser, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, parseCookies } from '../auth/index.js';
|
|
9
|
+
import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
|
|
10
|
+
import { publicNotifyConfig, saveNotifyConfig, notifyUser } from '../notify/index.js';
|
|
11
|
+
import { PARAMS } from '../params-meta.js';
|
|
12
|
+
import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
|
|
13
|
+
import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
|
|
14
|
+
import { forecastNow } from '../model/forecast.js';
|
|
15
|
+
import { profilesInfo, startProfilesRefresh } from '../model/profiles.js';
|
|
16
|
+
import { live } from '../model/live.js';
|
|
17
|
+
import { brokersState, saveBrokerAccount, deleteBrokerAccount, syncBrokerAccount, prepareBrokerOrders, executeBrokerOrders, rejectBrokerOrders } from '../brokers/index.js';
|
|
18
|
+
import { listNews } from '../news/index.js';
|
|
19
|
+
import * as strategist from '../strategist/index.js';
|
|
20
|
+
import { hub } from '../hub.js';
|
|
21
|
+
|
|
22
|
+
export function createRouter() {
|
|
23
|
+
const r = express.Router();
|
|
24
|
+
const wrap = (fn) => (req, res) => Promise.resolve().then(() => fn(req, res)).catch((e) => res.status(400).json({ error: e.message }));
|
|
25
|
+
|
|
26
|
+
r.get('/config', (req, res) => res.json({
|
|
27
|
+
defaults: strategyDefaults, params: PARAMS,
|
|
28
|
+
llm: activeModelInfo(),
|
|
29
|
+
user: publicUser(req.user),
|
|
30
|
+
cloudMode: process.env.CLOUD_MODE === '1',
|
|
31
|
+
plans: PLANS,
|
|
32
|
+
notify: publicNotifyConfig(),
|
|
33
|
+
billing: { enabled: publicBillingConfig().enabled },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// ---------- авторизация, профиль и тарифы ----------
|
|
37
|
+
const setCookie = (res, sid, maxAgeSec = 30 * 86400) => {
|
|
38
|
+
res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}`);
|
|
39
|
+
};
|
|
40
|
+
r.get('/auth/me', (req, res) => res.json({
|
|
41
|
+
user: publicUser(req.user),
|
|
42
|
+
cloudMode: process.env.CLOUD_MODE === '1',
|
|
43
|
+
plans: PLANS,
|
|
44
|
+
notify: publicNotifyConfig(),
|
|
45
|
+
billing: { enabled: publicBillingConfig().enabled },
|
|
46
|
+
}));
|
|
47
|
+
r.post('/auth/register', wrap((req, res) => {
|
|
48
|
+
const u = registerUser(req.body || {});
|
|
49
|
+
const token = createSession(u.id);
|
|
50
|
+
setCookie(res, token);
|
|
51
|
+
res.json({ user: publicUser(u), token });
|
|
52
|
+
}));
|
|
53
|
+
r.post('/auth/login', wrap((req, res) => {
|
|
54
|
+
const u = loginUser(req.body || {});
|
|
55
|
+
const token = createSession(u.id);
|
|
56
|
+
setCookie(res, token);
|
|
57
|
+
res.json({ user: publicUser(u), token });
|
|
58
|
+
}));
|
|
59
|
+
r.post('/auth/logout', (req, res) => {
|
|
60
|
+
const cookies = parseCookies(req.headers?.cookie);
|
|
61
|
+
if (cookies.sid) deleteSession(cookies.sid);
|
|
62
|
+
setCookie(res, 'logged_out', 365 * 86400);
|
|
63
|
+
res.json({ ok: true });
|
|
64
|
+
});
|
|
65
|
+
r.post('/auth/notify-code', wrap((req, res) => {
|
|
66
|
+
if (!req.user) throw new Error('Войдите в аккаунт');
|
|
67
|
+
const code = ensureNotifyCode(req.user.id);
|
|
68
|
+
res.json({ code, notify: publicNotifyConfig() });
|
|
69
|
+
}));
|
|
70
|
+
r.post('/auth/notify-test', wrap(async (req, res) => {
|
|
71
|
+
if (!req.user) throw new Error('Войдите в аккаунт');
|
|
72
|
+
const sent = await notifyUser(req.user.id, 'НейроАльфа: тестовое уведомление успешно доставлено.');
|
|
73
|
+
if (!sent.tg && !sent.max) throw new Error('Сначала привяжите Telegram или MAX по вашему коду привязки');
|
|
74
|
+
res.json({ ok: true, ...sent });
|
|
75
|
+
}));
|
|
76
|
+
r.post('/auth/redeem-promo', wrap((req, res) => {
|
|
77
|
+
if (!req.user) throw new Error('Войдите в аккаунт для активации промокода');
|
|
78
|
+
const user = redeemPromoCode(req.user.id, req.body?.code);
|
|
79
|
+
res.json({ user });
|
|
80
|
+
}));
|
|
81
|
+
r.post('/auth/api-key/regenerate', wrap((req, res) => {
|
|
82
|
+
if (!req.user) throw new Error('Войдите в аккаунт');
|
|
83
|
+
const apiKey = ensureApiKey(req.user.id, true);
|
|
84
|
+
res.json({ apiKey, user: publicUser({ ...req.user, api_key: apiKey }) });
|
|
85
|
+
}));
|
|
86
|
+
// Проверка API-ключа внешним npx-клиентом
|
|
87
|
+
r.post('/license/verify', wrap((req, res) => {
|
|
88
|
+
const authHeader = String(req.headers?.authorization || '');
|
|
89
|
+
const key = String(req.body?.apiKey || (authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '') || '').trim();
|
|
90
|
+
const u = getUserByApiKey(key);
|
|
91
|
+
if (!u) return res.status(401).json({ valid: false, error: 'Неверный или отозванный API-ключ' });
|
|
92
|
+
const p = effectivePlan(u);
|
|
93
|
+
res.json({
|
|
94
|
+
valid: true,
|
|
95
|
+
email: u.email,
|
|
96
|
+
name: u.name,
|
|
97
|
+
plan: p.id,
|
|
98
|
+
planName: p.name,
|
|
99
|
+
planUntil: u.plan_until || 0,
|
|
100
|
+
limits: p,
|
|
101
|
+
});
|
|
102
|
+
}));
|
|
103
|
+
// Активация API-ключа облачной подписки на локальном npx-клиенте
|
|
104
|
+
r.post('/license/activate', wrap(async (req, res) => {
|
|
105
|
+
const lic = await activateLocalLicense(req.body?.apiKey, req.body?.cloudUrl);
|
|
106
|
+
res.json({ ok: true, license: lic, user: publicUser(req.user) });
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
// ---------- оплата через ЮKassa ----------
|
|
110
|
+
r.post('/billing/checkout', wrap(async (req, res) => {
|
|
111
|
+
const origin = `${req.protocol}://${req.get('host')}`;
|
|
112
|
+
res.json(await createYookassaPayment({
|
|
113
|
+
user: req.user,
|
|
114
|
+
plan: req.body?.plan,
|
|
115
|
+
months: req.body?.months || 1,
|
|
116
|
+
returnOrigin: req.body?.returnOrigin || origin,
|
|
117
|
+
}));
|
|
118
|
+
}));
|
|
119
|
+
r.post('/billing/verify', wrap(async (req, res) => {
|
|
120
|
+
if (!req.user) throw new Error('Войдите в аккаунт');
|
|
121
|
+
if (req.body?.paymentId) return res.json(await verifyYookassaPayment(req.body.paymentId));
|
|
122
|
+
return res.json(await verifyUserLatestPayment(req.user.id));
|
|
123
|
+
}));
|
|
124
|
+
r.post('/billing/yookassa/webhook', wrap(async (req, res) => {
|
|
125
|
+
const pid = req.body?.object?.id;
|
|
126
|
+
if (pid) await verifyYookassaPayment(pid).catch(() => {});
|
|
127
|
+
res.json({ ok: true });
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// ---------- публичная витрина для лендинга ----------
|
|
131
|
+
r.get('/public/showcase', (req, res) => {
|
|
132
|
+
const runs = listShowcaseRuns().map((x) => {
|
|
133
|
+
const full = getRun(x.id);
|
|
134
|
+
return {
|
|
135
|
+
id: x.id,
|
|
136
|
+
title: x.params?._title || `Эталонная стратегия №${x.id}`,
|
|
137
|
+
params: { modelHorizon: x.params?.modelHorizon, modelTopK: x.params?.modelTopK, modelDirection: x.params?.modelDirection, modelType: x.params?.modelType },
|
|
138
|
+
metrics: x.metrics?.full || {},
|
|
139
|
+
oos: x.metrics?.model?.comparison?.[x.metrics?.model?.chosen || 'attention']?.outOfSample || {},
|
|
140
|
+
period: x.metrics?.period || {},
|
|
141
|
+
curve: full?.extra?.curves?.[x.metrics?.model?.chosen || 'attention']?.slice(-180) || [],
|
|
142
|
+
marketCurve: full?.extra?.curves?.market?.slice(-180) || [],
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
if (!runs.length) {
|
|
146
|
+
try {
|
|
147
|
+
const ref = JSON.parse(fs.readFileSync(path.join(ROOT, 'investor', 'данные.json'), 'utf8').replace(/^\uFEFF/, ''));
|
|
148
|
+
if (ref?.cmp?.attention?.full) {
|
|
149
|
+
runs.push({
|
|
150
|
+
id: 234,
|
|
151
|
+
title: 'Эталонная стратегия «Внимание через карту»',
|
|
152
|
+
params: { modelHorizon: 2, modelTopK: ref.params?.topK || 2, modelDirection: 'long', modelType: 'attention' },
|
|
153
|
+
metrics: ref.cmp.attention.full,
|
|
154
|
+
oos: ref.cmp.attention.oos || {},
|
|
155
|
+
period: ref.period || {},
|
|
156
|
+
curve: (ref.curves?.attention || []).slice(-180),
|
|
157
|
+
marketCurve: (ref.curves?.market || []).slice(-180),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} catch { /* ignore */ }
|
|
161
|
+
}
|
|
162
|
+
res.json({ runs, plans: PLANS });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------- админ-панель (пользователи, оплаты, ЮKassa, боты Telegram и MAX, витрина) ----------
|
|
166
|
+
const requireAdmin = (req) => {
|
|
167
|
+
if (!req.user || req.user.role !== 'admin') throw new Error('Доступно только администратору платформы');
|
|
168
|
+
};
|
|
169
|
+
r.get('/admin/state', wrap((req, res) => {
|
|
170
|
+
requireAdmin(req);
|
|
171
|
+
res.json({ users: listAllUsers(), payments: listAllPayments(), promos: listPromoCodes(), billing: publicBillingConfig(), notify: publicNotifyConfig() });
|
|
172
|
+
}));
|
|
173
|
+
r.post('/admin/promo', wrap((req, res) => {
|
|
174
|
+
requireAdmin(req);
|
|
175
|
+
res.json({ promos: createPromoCode(req.body || {}) });
|
|
176
|
+
}));
|
|
177
|
+
r.post('/admin/user-plan', wrap((req, res) => {
|
|
178
|
+
requireAdmin(req);
|
|
179
|
+
res.json({ user: setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30), users: listAllUsers() });
|
|
180
|
+
}));
|
|
181
|
+
r.post('/admin/billing', wrap((req, res) => {
|
|
182
|
+
requireAdmin(req);
|
|
183
|
+
res.json(saveBillingConfig(req.body || {}));
|
|
184
|
+
}));
|
|
185
|
+
r.post('/admin/notify', wrap((req, res) => {
|
|
186
|
+
requireAdmin(req);
|
|
187
|
+
res.json(saveNotifyConfig(req.body || {}));
|
|
188
|
+
}));
|
|
189
|
+
r.put('/runs/:id/showcase', wrap((req, res) => {
|
|
190
|
+
requireAdmin(req);
|
|
191
|
+
res.json(setRunShowcase(Number(req.params.id), Boolean(req.body?.showcase)));
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
// ---------- модели ИИ ----------
|
|
195
|
+
const llmState = () => ({ providers: listProviders(), selected: getSelected(), active: activeModelInfo() });
|
|
196
|
+
const llmChanged = () => {
|
|
197
|
+
hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
|
|
198
|
+
hub.emit({ type: 'chat:state', state: strategist.chatState() });
|
|
199
|
+
};
|
|
200
|
+
r.get('/llm/providers', wrap((req, res) => res.json(llmState())));
|
|
201
|
+
r.post('/llm/providers', wrap((req, res) => { const p = addProvider(req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
|
|
202
|
+
r.put('/llm/providers/:id', wrap((req, res) => { const p = updateProvider(req.params.id, req.body); llmChanged(); res.json({ provider: p, ...llmState() }); }));
|
|
203
|
+
r.delete('/llm/providers/:id', wrap((req, res) => { deleteProvider(req.params.id); llmChanged(); res.json(llmState()); }));
|
|
204
|
+
r.get('/llm/providers/:id/remote-models', wrap(async (req, res) => res.json({ models: await fetchRemoteModels(req.params.id) })));
|
|
205
|
+
r.post('/llm/test', wrap(async (req, res) => res.json(await testProvider(req.body?.providerId, req.body?.model))));
|
|
206
|
+
r.post('/llm/select', wrap((req, res) => { selectModel(req.body?.providerId, req.body?.model); llmChanged(); res.json(llmState()); }));
|
|
207
|
+
|
|
208
|
+
// ---------- настройки ----------
|
|
209
|
+
r.get('/settings', (req, res) => res.json(getSettings()));
|
|
210
|
+
r.put('/settings', wrap((req, res) => res.json(updateSettings(req.body || {}, { source: 'user' }))));
|
|
211
|
+
r.post('/settings/reset', (req, res) => res.json(resetSettings('user')));
|
|
212
|
+
r.get('/settings/history', (req, res) => res.json(listSettingsHistory(Math.min(200, Number(req.query.limit) || 50))));
|
|
213
|
+
r.post('/settings/history/:id/revert', wrap((req, res) => res.json(revertSettingsChange(req.params.id))));
|
|
214
|
+
|
|
215
|
+
// ---------- исследования ----------
|
|
216
|
+
r.get('/runs', (req, res) => res.json(listRuns()));
|
|
217
|
+
r.get('/runs/:id', (req, res) => {
|
|
218
|
+
const run = getRun(Number(req.params.id));
|
|
219
|
+
if (!run) return res.status(404).json({ error: 'Не найдено' });
|
|
220
|
+
res.json({ ...run, trades: getTrades(run.id) });
|
|
221
|
+
});
|
|
222
|
+
r.put('/runs/:id/title', wrap((req, res) => {
|
|
223
|
+
const updated = updateRunTitle(Number(req.params.id), req.body?.title || '');
|
|
224
|
+
if (!updated) return res.status(404).json({ error: 'Не найдено' });
|
|
225
|
+
res.json(updated);
|
|
226
|
+
}));
|
|
227
|
+
r.post('/runs/:id/stability', wrap((req, res) => res.json(startStabilityCheck(Number(req.params.id)))));
|
|
228
|
+
r.post('/runs/:id/stability/cancel', (req, res) => res.json({ ok: cancelStabilityCheck(Number(req.params.id)) }));
|
|
229
|
+
r.delete('/runs/:id', wrap((req, res) => {
|
|
230
|
+
const run = getRun(Number(req.params.id));
|
|
231
|
+
if (run?.status === 'running') throw new Error('Эксперимент ещё идёт — сначала отмените его');
|
|
232
|
+
deleteRun(Number(req.params.id));
|
|
233
|
+
res.json({ ok: true });
|
|
234
|
+
}));
|
|
235
|
+
// Очистка истории прогонов: оставить ровно keep лучших по итогу (0 — удалить все завершённые), идущие не трогать
|
|
236
|
+
r.post('/runs/cleanup', wrap((req, res) => {
|
|
237
|
+
const raw = Number(req.body?.keep);
|
|
238
|
+
const keep = Number.isFinite(raw) ? Math.max(0, Math.min(50, Math.floor(raw))) : 0;
|
|
239
|
+
const all = allRuns();
|
|
240
|
+
const done = all.filter((r) => r.mode === 'model' && r.status === 'done');
|
|
241
|
+
const ranked = [...done].sort((a, b) => (b.metrics?.full?.returnPct ?? -1e9) - (a.metrics?.full?.returnPct ?? -1e9));
|
|
242
|
+
const keepSet = new Set(ranked.slice(0, keep).map((r) => r.id));
|
|
243
|
+
let deleted = 0;
|
|
244
|
+
for (const r of all) {
|
|
245
|
+
if (r.mode !== 'model' || r.status === 'running' || keepSet.has(r.id)) continue;
|
|
246
|
+
deleteRun(r.id);
|
|
247
|
+
deleted++;
|
|
248
|
+
}
|
|
249
|
+
res.json({ deleted, kept: [...keepSet] });
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
r.post('/model/research', wrap((req, res) => {
|
|
253
|
+
const plan = effectivePlan(req.user);
|
|
254
|
+
if (countUserRunsToday() >= plan.maxRunsPerDay) {
|
|
255
|
+
throw new Error(`Достигнут дневной лимит исследований по тарифу «${plan.name}» (${plan.maxRunsPerDay} в сутки). Перейдите на тариф «Инвестор» или «Профи».`);
|
|
256
|
+
}
|
|
257
|
+
const body = req.body || {};
|
|
258
|
+
const merged = { ...getSettings(), ...body };
|
|
259
|
+
if ((merged.modelUseTransformer || merged.modelType === 'transformer') && !plan.allowTransformer) {
|
|
260
|
+
throw new Error('Обучение нейросети-трансформера доступно на тарифе «Профи / Лаборатория».');
|
|
261
|
+
}
|
|
262
|
+
const { runId, reused } = startModelResearch(merged, { source: 'user', force: !!body.force });
|
|
263
|
+
res.json({ runId, reused: !!reused });
|
|
264
|
+
}));
|
|
265
|
+
r.post('/model/:id/cancel', (req, res) => res.json({ ok: cancelModelResearch(Number(req.params.id)) }));
|
|
266
|
+
r.get('/model/forecast', wrap(async (req, res) => res.json(await forecastNow(getSettings(), { force: req.query.force === '1' }))));
|
|
267
|
+
|
|
268
|
+
// ---------- описания компаний (текстовая карта) ----------
|
|
269
|
+
r.get('/profiles', wrap(async (req, res) => res.json(await profilesInfo())));
|
|
270
|
+
r.post('/profiles/refresh', wrap((req, res) => res.json(startProfilesRefresh())));
|
|
271
|
+
|
|
272
|
+
// ---------- живая проверка (несколько виртуальных счетов) ----------
|
|
273
|
+
r.get('/live/status', (req, res) => res.json(live.status()));
|
|
274
|
+
r.post('/live/start', wrap(async (req, res) => {
|
|
275
|
+
const plan = effectivePlan(req.user);
|
|
276
|
+
const b = req.body || {};
|
|
277
|
+
const runId = await live.start(getSettings(), { source: 'user', baseRunId: b.baseRunId || null, name: b.name || '', capital: b.capital || null, maxAccounts: plan.maxVirtualAccounts });
|
|
278
|
+
res.json({ runId, status: live.status() });
|
|
279
|
+
}));
|
|
280
|
+
r.post('/live/stop', (req, res) => res.json({ ok: live.stop(req.body?.runId || null), status: live.status() }));
|
|
281
|
+
r.delete('/live/:id', (req, res) => res.json({ ok: live.remove(Number(req.params.id)), status: live.status() }));
|
|
282
|
+
|
|
283
|
+
// ---------- брокерские счета (Т-Банк, Финам, Алор, БКС) ----------
|
|
284
|
+
r.get('/brokers', (req, res) => res.json(brokersState()));
|
|
285
|
+
r.post('/brokers', wrap((req, res) => {
|
|
286
|
+
const plan = effectivePlan(req.user);
|
|
287
|
+
const cur = brokersState().accounts.length;
|
|
288
|
+
if (!req.body?.id && cur >= plan.maxBrokerAccounts) {
|
|
289
|
+
throw new Error(`По вашему тарифу «${plan.name}» доступно подключение до ${plan.maxBrokerAccounts} брокерских счетов. Перейдите на тариф «Инвестор» или «Профи».`);
|
|
290
|
+
}
|
|
291
|
+
res.json(saveBrokerAccount(req.body || {}));
|
|
292
|
+
}));
|
|
293
|
+
r.put('/brokers/:id', wrap((req, res) => res.json(saveBrokerAccount({ ...(req.body || {}), id: req.params.id }))));
|
|
294
|
+
r.delete('/brokers/:id', wrap((req, res) => res.json(deleteBrokerAccount(req.params.id))));
|
|
295
|
+
r.post('/brokers/:id/sync', wrap(async (req, res) => res.json(await syncBrokerAccount(req.params.id))));
|
|
296
|
+
r.post('/brokers/:id/prepare', wrap(async (req, res) => res.json(await prepareBrokerOrders(req.params.id))));
|
|
297
|
+
r.post('/brokers/:id/execute', wrap(async (req, res) => res.json(await executeBrokerOrders(req.params.id))));
|
|
298
|
+
r.post('/brokers/:id/reject', wrap((req, res) => res.json(rejectBrokerOrders(req.params.id))));
|
|
299
|
+
|
|
300
|
+
// ---------- новости (только для прогноза «сейчас» и живой проверки) ----------
|
|
301
|
+
r.get('/news', (req, res) => res.json({
|
|
302
|
+
items: listNews({ symbol: req.query.symbol || undefined, hours: Number(req.query.hours) || 72, limit: Number(req.query.limit) || 30 }),
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
// ---------- журнал гипотез ----------
|
|
306
|
+
r.get('/journal', (req, res) => res.json(listJournal(500)));
|
|
307
|
+
r.post('/journal', wrap((req, res) => {
|
|
308
|
+
const id = addJournal({ ...req.body, source: 'user' });
|
|
309
|
+
hub.emit({ type: 'journal:update' });
|
|
310
|
+
res.json({ id });
|
|
311
|
+
}));
|
|
312
|
+
r.delete('/journal/:id', (req, res) => { deleteJournal(Number(req.params.id)); hub.emit({ type: 'journal:update' }); res.json({ ok: true }); });
|
|
313
|
+
|
|
314
|
+
// ---------- чат со Стратегом ----------
|
|
315
|
+
r.get('/chat', (req, res) => res.json(strategist.history()));
|
|
316
|
+
r.post('/chat/send', wrap((req, res) => {
|
|
317
|
+
const plan = effectivePlan(req.user);
|
|
318
|
+
if (countUserChatToday() >= plan.maxChatPerDay) {
|
|
319
|
+
throw new Error(`Достигнут дневной лимит сообщений ИИ-Стратегу по тарифу «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
|
|
320
|
+
}
|
|
321
|
+
res.json({ message: strategist.sendUserMessage(req.body?.text, req.body?.mode) });
|
|
322
|
+
}));
|
|
323
|
+
r.post('/chat/action', wrap((req, res) => {
|
|
324
|
+
const { msgId, callId, decision, comment } = req.body || {};
|
|
325
|
+
strategist.resolveAction(Number(msgId), String(callId), decision, comment);
|
|
326
|
+
res.json({ ok: true });
|
|
327
|
+
}));
|
|
328
|
+
r.post('/chat/stop', (req, res) => { strategist.stopAgent(); res.json({ ok: true }); });
|
|
329
|
+
r.post('/chat/mode', wrap((req, res) => { strategist.setMode(req.body?.mode, req.body?.budget); res.json(strategist.chatState()); }));
|
|
330
|
+
r.post('/chat/new', (req, res) => { strategist.resetConversation(); res.json(strategist.history()); });
|
|
331
|
+
|
|
332
|
+
return r;
|
|
333
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
+
import { ROOT, DATA_DIR } from '../config.js';
|
|
6
|
+
import { db, kvGet, kvSet, registerUidResolver } from '../db.js';
|
|
7
|
+
|
|
8
|
+
const ctx = new AsyncLocalStorage();
|
|
9
|
+
registerUidResolver(() => ctx.getStore()?.user?.id || 1);
|
|
10
|
+
|
|
11
|
+
// ---------- шифрование секретов (AES-256-GCM) ----------
|
|
12
|
+
let masterKey = null;
|
|
13
|
+
function getMasterKey() {
|
|
14
|
+
if (masterKey) return masterKey;
|
|
15
|
+
const envKey = (process.env.MASTER_KEY || '').trim();
|
|
16
|
+
if (envKey) {
|
|
17
|
+
masterKey = crypto.createHash('sha256').update(envKey).digest();
|
|
18
|
+
return masterKey;
|
|
19
|
+
}
|
|
20
|
+
const keyPath = path.join(DATA_DIR, '.master.key');
|
|
21
|
+
try {
|
|
22
|
+
if (fs.existsSync(keyPath)) {
|
|
23
|
+
const hex = fs.readFileSync(keyPath, 'utf8').trim();
|
|
24
|
+
if (hex.length >= 64) { masterKey = Buffer.from(hex.slice(0, 64), 'hex'); return masterKey; }
|
|
25
|
+
}
|
|
26
|
+
const rnd = crypto.randomBytes(32);
|
|
27
|
+
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
|
|
28
|
+
fs.writeFileSync(keyPath, rnd.toString('hex'), { mode: 0o600 });
|
|
29
|
+
masterKey = rnd;
|
|
30
|
+
} catch {
|
|
31
|
+
masterKey = crypto.createHash('sha256').update(ROOT).digest();
|
|
32
|
+
}
|
|
33
|
+
return masterKey;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function encryptSecret(plain) {
|
|
37
|
+
const s = String(plain ?? '').trim();
|
|
38
|
+
if (!s) return '';
|
|
39
|
+
if (s.startsWith('enc:v1:')) return s;
|
|
40
|
+
const iv = crypto.randomBytes(12);
|
|
41
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', getMasterKey(), iv);
|
|
42
|
+
const enc = Buffer.concat([cipher.update(s, 'utf8'), cipher.final()]);
|
|
43
|
+
const tag = cipher.getAuthTag();
|
|
44
|
+
return `enc:v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function decryptSecret(val) {
|
|
48
|
+
const s = String(val ?? '').trim();
|
|
49
|
+
if (!s) return '';
|
|
50
|
+
if (!s.startsWith('enc:v1:')) return s;
|
|
51
|
+
try {
|
|
52
|
+
const [, , ivHex, tagHex, encHex] = s.split(':');
|
|
53
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', getMasterKey(), Buffer.from(ivHex, 'hex'));
|
|
54
|
+
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
|
55
|
+
return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8');
|
|
56
|
+
} catch {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- хэширование паролей (scrypt) ----------
|
|
62
|
+
export function hashPassword(password) {
|
|
63
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
64
|
+
const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
|
65
|
+
return `${salt}:${hash}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function verifyPassword(password, stored) {
|
|
69
|
+
if (!stored || !stored.includes(':')) return false;
|
|
70
|
+
const [salt, hex] = stored.split(':');
|
|
71
|
+
const hash = crypto.scryptSync(String(password), salt, 64);
|
|
72
|
+
const target = Buffer.from(hex, 'hex');
|
|
73
|
+
return hash.length === target.length && crypto.timingSafeEqual(hash, target);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- пользователи и сессии ----------
|
|
77
|
+
export const PLANS = {
|
|
78
|
+
free: { id: 'free', name: 'Старт (Витрина)', priceRub: 0, maxRunsPerDay: 2, maxVirtualAccounts: 1, maxBrokerAccounts: 0, maxChatPerDay: 10, allowTransformer: false },
|
|
79
|
+
investor: { id: 'investor', name: 'Инвестор (Автопилот)', priceRub: 3900, maxRunsPerDay: 30, maxVirtualAccounts: 3, maxBrokerAccounts: 2, maxChatPerDay: 150, allowTransformer: false },
|
|
80
|
+
pro: { id: 'pro', name: 'Профи / Лаборатория', priceRub: 9900, maxRunsPerDay: 500, maxVirtualAccounts: 5, maxBrokerAccounts: 5, maxChatPerDay: 1000, allowTransformer: true },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function generateApiKey() {
|
|
84
|
+
return `na_live_${crypto.randomBytes(16).toString('hex')}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function ensureApiKey(userId, regenerate = false) {
|
|
88
|
+
const u = getUserById(userId);
|
|
89
|
+
if (!u) throw new Error('Пользователь не найден');
|
|
90
|
+
if (u.api_key && !regenerate) return u.api_key;
|
|
91
|
+
const key = generateApiKey();
|
|
92
|
+
db.prepare('UPDATE users SET api_key = ? WHERE id = ?').run(key, u.id);
|
|
93
|
+
return key;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getUserByApiKey(apiKey) {
|
|
97
|
+
const clean = String(apiKey || '').trim();
|
|
98
|
+
if (!clean.startsWith('na_live_')) return null;
|
|
99
|
+
return db.prepare('SELECT * FROM users WHERE api_key = ?').get(clean) || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- синхронизация лицензии для локального запуска (npx neuroalpha --key ...) ----------
|
|
103
|
+
export function getLocalLicense() {
|
|
104
|
+
return kvGet('cloudLicense') || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function activateLocalLicense(apiKey, cloudUrl = '') {
|
|
108
|
+
const cleanKey = String(apiKey || '').trim();
|
|
109
|
+
if (!cleanKey.startsWith('na_live_')) throw new Error('Ключ должен начинаться с na_live_…');
|
|
110
|
+
const baseUrl = String(cloudUrl || process.env.CLOUD_URL || '').trim().replace(/\/+$/, '');
|
|
111
|
+
// 1) если ключ есть в этой же БД — проверяем напрямую
|
|
112
|
+
const localUser = getUserByApiKey(cleanKey);
|
|
113
|
+
if (localUser) {
|
|
114
|
+
const p = effectivePlan(localUser);
|
|
115
|
+
const lic = {
|
|
116
|
+
apiKey: cleanKey,
|
|
117
|
+
cloudUrl: baseUrl || 'local',
|
|
118
|
+
email: localUser.email,
|
|
119
|
+
name: localUser.name,
|
|
120
|
+
plan: p.id,
|
|
121
|
+
planName: p.name,
|
|
122
|
+
planUntil: localUser.plan_until || 0,
|
|
123
|
+
verifiedAt: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
kvSet('cloudLicense', lic);
|
|
126
|
+
return lic;
|
|
127
|
+
}
|
|
128
|
+
// 2) иначе проверяем об облачный сервер
|
|
129
|
+
if (!baseUrl) throw new Error('Ключ не найден локально. Укажите адрес облачного сервера или активируйте ключ на сайте');
|
|
130
|
+
const r = await fetch(`${baseUrl}/api/license/verify`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cleanKey}` },
|
|
133
|
+
body: JSON.stringify({ apiKey: cleanKey }),
|
|
134
|
+
signal: AbortSignal.timeout(15000),
|
|
135
|
+
});
|
|
136
|
+
const j = await r.json().catch(() => ({}));
|
|
137
|
+
if (!r.ok || !j.valid) throw new Error(j.error || `Не удалось проверить ключ (HTTP ${r.status})`);
|
|
138
|
+
const lic = {
|
|
139
|
+
apiKey: cleanKey,
|
|
140
|
+
cloudUrl: baseUrl,
|
|
141
|
+
email: j.email,
|
|
142
|
+
name: j.name,
|
|
143
|
+
plan: j.plan || 'free',
|
|
144
|
+
planName: j.planName || PLANS[j.plan]?.name || 'Старт',
|
|
145
|
+
planUntil: j.planUntil || 0,
|
|
146
|
+
verifiedAt: Date.now(),
|
|
147
|
+
};
|
|
148
|
+
kvSet('cloudLicense', lic);
|
|
149
|
+
// применяем тариф к локальному пользователю #1
|
|
150
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = 1').run(lic.plan, lic.planUntil);
|
|
151
|
+
return lic;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function effectivePlan(user) {
|
|
155
|
+
if (!user) return PLANS.free;
|
|
156
|
+
// В локальном режиме (npx): если активирован лицензионный ключ — действует тариф подписки;
|
|
157
|
+
// если ADMIN_ALL_PRO=1 или это явно админ облака — действует его тариф
|
|
158
|
+
if (process.env.CLOUD_MODE !== '1' && user.id === 1) {
|
|
159
|
+
const lic = getLocalLicense();
|
|
160
|
+
if (lic?.plan && PLANS[lic.plan]) {
|
|
161
|
+
if (lic.plan !== 'free' && lic.planUntil > 0 && lic.planUntil < Date.now()) return PLANS.free;
|
|
162
|
+
return PLANS[lic.plan];
|
|
163
|
+
}
|
|
164
|
+
if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.free;
|
|
165
|
+
return PLANS.pro;
|
|
166
|
+
}
|
|
167
|
+
if (user.role === 'admin') return PLANS.pro;
|
|
168
|
+
const p = PLANS[user.plan] || PLANS.free;
|
|
169
|
+
if (p.id !== 'free' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.free;
|
|
170
|
+
return p;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function publicUser(u) {
|
|
174
|
+
if (!u) return null;
|
|
175
|
+
if (!u.api_key) {
|
|
176
|
+
try { u.api_key = ensureApiKey(u.id); } catch { /* ignore */ }
|
|
177
|
+
}
|
|
178
|
+
const plan = effectivePlan(u);
|
|
179
|
+
return {
|
|
180
|
+
id: u.id,
|
|
181
|
+
email: u.email,
|
|
182
|
+
name: u.name || 'Инвестор',
|
|
183
|
+
role: u.role || 'user',
|
|
184
|
+
plan: plan.id,
|
|
185
|
+
planName: plan.name,
|
|
186
|
+
planUntil: u.plan_until || 0,
|
|
187
|
+
limits: plan,
|
|
188
|
+
apiKey: u.api_key || null,
|
|
189
|
+
tgConnected: Boolean(u.tg_chat_id),
|
|
190
|
+
maxConnected: Boolean(u.max_chat_id),
|
|
191
|
+
notifyCode: u.notify_code || null,
|
|
192
|
+
localLicense: process.env.CLOUD_MODE !== '1' ? getLocalLicense() : null,
|
|
193
|
+
createdAt: u.created_at,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function getUserById(id) {
|
|
198
|
+
return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function currentUser() {
|
|
202
|
+
const store = ctx.getStore();
|
|
203
|
+
if (store?.user) return store.user;
|
|
204
|
+
return getUserById(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function currentUserId() {
|
|
208
|
+
const store = ctx.getStore();
|
|
209
|
+
return store?.user?.id || 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function runWithUser(user, fn) {
|
|
213
|
+
return ctx.run({ user }, fn);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function parseCookies(cookieHeader = '') {
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const part of String(cookieHeader || '').split(';')) {
|
|
219
|
+
const i = part.indexOf('=');
|
|
220
|
+
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function userFromRequest(req) {
|
|
226
|
+
const cookies = parseCookies(req.headers?.cookie);
|
|
227
|
+
const authHeader = String(req.headers?.authorization || '');
|
|
228
|
+
const apiKeyHeader = String(req.headers?.['x-api-key'] || '').trim();
|
|
229
|
+
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
230
|
+
const apiCandidate = apiKeyHeader || (bearer.startsWith('na_live_') ? bearer : '');
|
|
231
|
+
if (apiCandidate) {
|
|
232
|
+
const byKey = getUserByApiKey(apiCandidate);
|
|
233
|
+
if (byKey) return byKey;
|
|
234
|
+
}
|
|
235
|
+
const token = cookies.sid || bearer;
|
|
236
|
+
if (token) {
|
|
237
|
+
const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
238
|
+
if (sess) {
|
|
239
|
+
const u = getUserById(sess.user_id);
|
|
240
|
+
if (u) return u;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// В локальном режиме (без CLOUD_MODE=1) по умолчанию открывается владелец (id = 1)
|
|
244
|
+
if (process.env.CLOUD_MODE !== '1' && cookies.sid !== 'logged_out') {
|
|
245
|
+
return getUserById(1);
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function createSession(userId, days = 30) {
|
|
251
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
|
|
254
|
+
.run(token, Number(userId), now, now + days * 86400000);
|
|
255
|
+
return token;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function deleteSession(token) {
|
|
259
|
+
if (!token) return;
|
|
260
|
+
db.prepare('DELETE FROM sessions WHERE token = ?').run(String(token));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function registerUser({ email, password, name }) {
|
|
264
|
+
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
265
|
+
if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email');
|
|
266
|
+
if (!password || String(password).length < 6) throw new Error('Пароль должен быть не короче 6 символов');
|
|
267
|
+
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(cleanEmail);
|
|
268
|
+
if (existing) throw new Error('Пользователь с таким email уже зарегистрирован');
|
|
269
|
+
const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
270
|
+
const apiKey = generateApiKey();
|
|
271
|
+
const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, created_at)
|
|
272
|
+
VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?)`).run(cleanEmail, String(name || cleanEmail.split('@')[0]).trim(), hashPassword(password), code, apiKey, Date.now());
|
|
273
|
+
return getUserById(Number(r.lastInsertRowid));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function loginUser({ email, password }) {
|
|
277
|
+
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
278
|
+
const u = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
|
|
279
|
+
if (!u || !verifyPassword(password, u.pass_hash)) throw new Error('Неверный email или пароль');
|
|
280
|
+
return u;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function ensureNotifyCode(userId) {
|
|
284
|
+
const u = getUserById(userId);
|
|
285
|
+
if (!u) throw new Error('Пользователь не найден');
|
|
286
|
+
if (u.notify_code) return u.notify_code;
|
|
287
|
+
const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
288
|
+
db.prepare('UPDATE users SET notify_code = ? WHERE id = ?').run(code, u.id);
|
|
289
|
+
return code;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function listAllUsers() {
|
|
293
|
+
return db.prepare('SELECT * FROM users ORDER BY id ASC').all().map(publicUser);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function setUserPlan(userId, plan, days = 30) {
|
|
297
|
+
if (!(plan in PLANS)) throw new Error(`Неизвестный тариф: ${plan}`);
|
|
298
|
+
const until = plan === 'free' ? 0 : Date.now() + Math.max(1, Number(days) || 30) * 86400000;
|
|
299
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(plan, until, Number(userId));
|
|
300
|
+
return publicUser(getUserById(userId));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------- промокоды ----------
|
|
304
|
+
export function createPromoCode({ code, plan = 'investor', days = 14, maxUses = 50 }) {
|
|
305
|
+
const clean = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
|
|
306
|
+
if (clean.length < 3) throw new Error('Код должен содержать минимум 3 символа (латиница/цифры)');
|
|
307
|
+
if (!(plan in PLANS) || plan === 'free') throw new Error('Выберите тариф Инвестор или Профи');
|
|
308
|
+
const d = Math.max(1, Math.min(365, Math.round(Number(days) || 14)));
|
|
309
|
+
const m = Math.max(1, Math.min(10000, Math.round(Number(maxUses) || 50)));
|
|
310
|
+
db.prepare(`INSERT OR REPLACE INTO promocodes (code, plan, days, max_uses, used_count, created_at)
|
|
311
|
+
VALUES (?, ?, ?, ?, COALESCE((SELECT used_count FROM promocodes WHERE code = ?), 0), ?)`).run(clean, plan, d, m, clean, Date.now());
|
|
312
|
+
return listPromoCodes();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function listPromoCodes() {
|
|
316
|
+
return db.prepare('SELECT * FROM promocodes ORDER BY created_at DESC').all();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function redeemPromoCode(userId, rawCode) {
|
|
320
|
+
if (!userId) throw new Error('Войдите в аккаунт для активации промокода');
|
|
321
|
+
const clean = String(rawCode || '').trim().toUpperCase();
|
|
322
|
+
const promo = db.prepare('SELECT * FROM promocodes WHERE UPPER(code) = ?').get(clean);
|
|
323
|
+
if (!promo) throw new Error('Промокод не найден');
|
|
324
|
+
if (promo.used_count >= promo.max_uses) throw new Error('Лимит активаций этого промокода исчерпан');
|
|
325
|
+
const already = db.prepare('SELECT 1 FROM promocode_uses WHERE code = ? AND user_id = ?').get(promo.code, Number(userId));
|
|
326
|
+
if (already) throw new Error('Вы уже активировали этот промокод');
|
|
327
|
+
const u = getUserById(userId);
|
|
328
|
+
const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
|
|
329
|
+
const nextUntil = baseTs + promo.days * 86400000;
|
|
330
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(promo.plan, nextUntil, u.id);
|
|
331
|
+
db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
|
|
332
|
+
db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
|
|
333
|
+
return publicUser(getUserById(userId));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function authMiddleware(req, res, next) {
|
|
337
|
+
const u = userFromRequest(req);
|
|
338
|
+
req.user = u;
|
|
339
|
+
return runWithUser(u, () => next());
|
|
340
|
+
}
|