fix(reliability): multer-ошибки, process-хендлеры, анти-гонка питомца, flashcards (Спринт2)

- errorHandler: MulterError → 413 «слишком большой» / 400 (а не 500).
- server: process.on(unhandledRejection/uncaughtException) — глобальная страховка
  с логированием, процесс не падает от единичной асинхронной ошибки.
- pet: атомарный CAS на кулдаунах petAction/starCatch/feedPet
  (UPDATE ... WHERE last IS ?, начисление только при changes=1) — нет двойного
  начисления при параллельных запросах. Проверено на семантике node:sqlite.
- assistant.flashcardsFromText: await callLLMFailover в try/catch → 502 вместо
  необработанного отклонения промиса.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 22:08:02 +03:00
parent 646e93cf46
commit 09c6c2b21d
4 changed files with 34 additions and 6 deletions
@@ -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) {
+13 -5
View File
@@ -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 });
}