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:
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user