diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 705f0b7..652f59c 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -592,7 +592,9 @@ async function flashcardsFromText(req, res) { const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' + 'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' + 'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.'; - const rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400); + let rr; + try { rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400); } + catch (e) { return res.status(502).json({ error: 'Не удалось обратиться к ИИ' }); } const raw = rr && rr.text; let cards = []; if (raw) { diff --git a/backend/src/controllers/petController.js b/backend/src/controllers/petController.js index 59e477f..a5a9d8a 100644 --- a/backend/src/controllers/petController.js +++ b/backend/src/controllers/petController.js @@ -304,10 +304,13 @@ function petAction(req, res) { streak = 1; } - try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {} - db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=?') - .run(now.toISOString(), streak, req.user.id); + // CAS: апдейт проходит, только если pet_last_petted не изменился с момента чтения + // (IS — null-safe). Защита от гонки двойного начисления при параллельных запросах. + const claim = db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=? AND pet_last_petted IS ?') + .run(now.toISOString(), streak, req.user.id, user.pet_last_petted); + if (claim.changes === 0) return res.status(429).json({ error: 'cooldown', remaining: 1 }); + try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {} res.json({ ok: true, coins: 2, pettingStreak: streak }); } @@ -424,8 +427,10 @@ function starCatch(req, res) { const diff = (now - new Date(user.pet_last_star)) / 1000; if (diff < 3600) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(3600 - diff) }); } + const claim = db.prepare('UPDATE users SET pet_last_star=? WHERE id=? AND pet_last_star IS ?') + .run(now.toISOString(), req.user.id, user.pet_last_star); + if (claim.changes === 0) return res.status(429).json({ error: 'cooldown', remaining: 1 }); try { awardCoins(req.user.id, 5, 'star_catch'); } catch {} - db.prepare('UPDATE users SET pet_last_star=? WHERE id=?').run(now.toISOString(), req.user.id); res.json({ ok: true, coins: 5 }); } @@ -443,11 +448,14 @@ function feedPet(req, res) { return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(COOLDOWN_SEC - diff) }); } } + // CAS-«застолбить» кулдаун ДО начисления XP (анти-гонка двойного начисления) + const claim = db.prepare('UPDATE users SET pet_last_fed=? WHERE id=? AND pet_last_fed IS ?') + .run(now.toISOString(), req.user.id, user.pet_last_fed); + if (claim.changes === 0) return res.status(429).json({ error: 'cooldown', remaining: 1 }); try { const { awardXP } = require('./gamificationController'); awardXP(req.user.id, 15, 'pet_feeding'); } catch (e) { console.error('[feedPet] awardXP:', e.message); } - db.prepare('UPDATE users SET pet_last_fed=? WHERE id=?').run(now.toISOString(), req.user.id); const updated = db.prepare('SELECT xp, coins FROM users WHERE id=?').get(req.user.id); res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins }); } diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 34cd445..0c7cbf1 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -39,6 +39,15 @@ function requestId(req, res, next) { * programmer (5xx) — unexpected bugs → error level, stack logged, message hidden in prod */ function errorHandler(err, req, res, _next) { + // Ошибки загрузки multer → внятный 4xx вместо 500. + if (err && err.name === 'MulterError') { + const tooLarge = err.code === 'LIMIT_FILE_SIZE'; + if (res.headersSent) return; + return res.status(tooLarge ? 413 : 400).json({ + error: tooLarge ? 'Файл слишком большой' : 'Ошибка загрузки файла', + requestId: req.requestId, + }); + } const status = err.status || err.statusCode || 500; const isOperational = status >= 400 && status < 500; const isProd = process.env.NODE_ENV === 'production'; diff --git a/backend/src/server.js b/backend/src/server.js index 689fd6c..ba3322a 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -549,3 +549,12 @@ function shutdown(signal) { } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); + +/* ── Глобальная страховка: не валим процесс на единичной асинхронной ошибке. + Логируем (с requestId недоступен здесь — это вне цикла запроса) и продолжаем. */ +process.on('unhandledRejection', (reason) => { + logger.error('unhandledRejection', { err: (reason && reason.message) || String(reason), stack: reason && reason.stack }); +}); +process.on('uncaughtException', (err) => { + logger.error('uncaughtException', { err: err && err.message, stack: err && err.stack }); +});