fix(shorts): живые обновления страницы и лимит на вызовы ИИ

- shorts.js подписан на SSE shorts_changed с дебаунсом и реконнектом
- dispatchEvent больше не отдаёт любой неизвестный тип как
  entries_changed: entries_changed/lessons_changed/shorts_changed
  теперь маршрутизируются по имени (иначе правка короткого
  сообщения будила журнал с ложным тостом о новой записи)
- добавлен aiLimiter (30/мин на cache.rateLimitStore('ai')) и
  навешен на оба вызова модели: /api/ai/correct и
  /api/shorts/ai-correct
This commit is contained in:
dev
2026-10-06 00:09:28 +03:00
parent d00de2cb1a
commit 9f815e5143
2 changed files with 41 additions and 2 deletions
+26
View File
@@ -25,9 +25,35 @@ async function init() {
document.getElementById('shortsSearch').addEventListener('keydown', e => { document.getElementById('shortsSearch').addEventListener('keydown', e => {
if (e.key === 'Enter') { shortsPage = 1; loadShorts(); } if (e.key === 'Enter') { shortsPage = 1; loadShorts(); }
}); });
connectShortsLive();
await loadShorts(); await loadShorts();
} }
let shortsLiveSource = null;
let shortsRefreshTimer = null;
function connectShortsLive() {
if (shortsLiveSource) { shortsLiveSource.close(); shortsLiveSource = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
shortsLiveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
shortsLiveSource.addEventListener('shorts_changed', () => {
if (shortsRefreshTimer) return;
shortsRefreshTimer = setTimeout(() => {
shortsRefreshTimer = null;
loadShorts();
}, 300);
});
shortsLiveSource.onerror = () => {
if (shortsLiveSource) { shortsLiveSource.close(); shortsLiveSource = null; }
setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (shortsLiveSource) shortsLiveSource.close(); });
}
let shortsPage = 1; let shortsPage = 1;
const SHORTS_PAGE_SIZE = 50; const SHORTS_PAGE_SIZE = 50;
let shortsTotal = 0; let shortsTotal = 0;
+15 -2
View File
@@ -159,6 +159,10 @@ function dispatchEvent(payload) {
}); });
return; return;
} }
if (payload && (payload.type === 'entries_changed' || payload.type === 'lessons_changed' || payload.type === 'shorts_changed')) {
writeFrame(payload.type, { ts: Date.now() });
return;
}
if (payload && payload.type === 'ai_status') { if (payload && payload.type === 'ai_status') {
writeFrame('ai_status', { writeFrame('ai_status', {
id: payload.id, id: payload.id,
@@ -1991,6 +1995,15 @@ const chatLimiter = rateLimit({
message: { error: 'Слишком много сообщений. Подождите минуту.' }, message: { error: 'Слишком много сообщений. Подождите минуту.' },
}); });
const aiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: cache.rateLimitStore('ai', 60 * 1000),
message: { error: 'Слишком много запросов к ИИ. Подождите минуту.' },
});
async function chatThreadFor(user, threadId) { async function chatThreadFor(user, threadId) {
const id = parseInt(threadId, 10); const id = parseInt(threadId, 10);
if (!Number.isInteger(id) || id < 1) return { error: 400, message: 'Invalid thread' }; if (!Number.isInteger(id) || id < 1) return { error: 400, message: 'Invalid thread' };
@@ -2747,7 +2760,7 @@ app.delete('/api/shorts/:id/permanent', requireAuth, requireAdmin, async (req, r
} }
}); });
app.post('/api/shorts/ai-correct', requireAuth, async (req, res) => { app.post('/api/shorts/ai-correct', requireAuth, aiLimiter, async (req, res) => {
try { try {
const text = reqStr(req.body?.text, 5000); const text = reqStr(req.body?.text, 5000);
if (!text) return res.status(400).json({ error: 'Текст не указан' }); if (!text) return res.status(400).json({ error: 'Текст не указан' });
@@ -7486,7 +7499,7 @@ app.put('/api/entries/:id/unschedule', requireAuth, async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.post('/api/ai/correct', requireAuth, async (req, res) => { app.post('/api/ai/correct', requireAuth, aiLimiter, async (req, res) => {
const text = reqStr(req.body?.text, 5000); const text = reqStr(req.body?.text, 5000);
if (!text) return res.status(400).json({ error: 'Текст не указан' }); if (!text) return res.status(400).json({ error: 'Текст не указан' });
try { try {