🕐 fix: локальное время UTC+4 + защита от дублирования постов
- Timezone: веб-панель показывает локальное время (UTC+4, Ульяновск)
- Timezone: конвертация UTC → локальное при отображении
- Duplicate fix: промежуточный статус 'sending' в БД
- Duplicate fix: только один процесс берет пост
- UI: подсказки 'По твоему времени (UTC+4)'
- Тест: пост 5 опубликован ровно 1 раз ✅
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
63d059ddb0
commit
ccc816ce4f
4 changed files with 135 additions and 10 deletions
93
send_timezone_and_duplicate_fix.py
Normal file
93
send_timezone_and_duplicate_fix.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Уведомление о исправлении часового пояса и защите от дублей
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
|
||||
from config import BOT_TOKEN, ADMIN_USER_ID, get_proxy_url
|
||||
|
||||
async def send_timezone_and_duplicate_fix():
|
||||
"""Уведомление об исправлениях"""
|
||||
proxy_url = get_proxy_url()
|
||||
|
||||
if proxy_url:
|
||||
session = AiohttpSession(proxy=proxy_url)
|
||||
bot = Bot(token=BOT_TOKEN, session=session, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
else:
|
||||
bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
|
||||
message = """
|
||||
✅ <b>ДВА ИСПРАВЛЕНИЯ!</b>
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🕐 <b>1. ЛОКАЛЬНОЕ ВРЕМЯ (UTC+4)</b>
|
||||
|
||||
✅ <b>Что изменилось:</b>
|
||||
|
||||
❌ <b>Было:</b>
|
||||
• Время в веб-панели показывалось в UTC
|
||||
• Ты выбирал 20:30 → сохранялось как 20:30 UTC
|
||||
• Реально публиковалось в 00:30 по Ульяновску!
|
||||
|
||||
✅ <b>Стало:</b>
|
||||
• В веб-панели показывает ТВОЁ время (UTC+4)
|
||||
• Ты выбираешь 20:30 → сохраняется как 16:30 UTC
|
||||
• Публикуется ровно в 20:30 по Ульяновску! 🎯
|
||||
|
||||
📋 <b>Подсказки в форме:</b>
|
||||
"🕐 По твоему времени (UTC+4)"
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🔄 <b>2. ЗАЩИТА ОТ ДУБЛИРОВАНИЯ</b>
|
||||
|
||||
✅ <b>Что было:</b>
|
||||
• Пост публиковался ДВАЖДЫ в чат
|
||||
• Несколько процессов scheduler работали одновременно
|
||||
|
||||
✅ <b>Что стало:</b>
|
||||
• Добавлена блокировка (_scheduled_posts_lock)
|
||||
• Только ОДИН процесс может публиковать посты
|
||||
• Остальные пропускают обработку
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🧪 <b>ТЕСТ:</b>
|
||||
|
||||
Создан тестовый пост (ID=4)
|
||||
Время: через 5 минут от сейчас
|
||||
Должен опубликоваться ОДИН раз ✅
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🌐 <b>ПРОВЕРЬ В ВЕБ-ПАНЕЛИ:</b>
|
||||
|
||||
1. Зайди в http://localhost:8000/scheduled_posts
|
||||
2. Нажми Ctrl+Shift+R (сброс кэша!)
|
||||
3. Посмотри на список постов
|
||||
4. Время должно быть ПО ТВОЕМУ! (UTC+4)
|
||||
|
||||
<i>Жду 5 минут и проверю что тестовый пост опубликовался 1 раз!</i>
|
||||
"""
|
||||
|
||||
try:
|
||||
await bot.send_message(ADMIN_USER_ID, message)
|
||||
print("✅ Уведомление отправлено!")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка: {e}")
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(send_timezone_and_duplicate_fix())
|
||||
|
|
@ -300,9 +300,16 @@ class Scheduler:
|
|||
from config import ADMIN_CHAT_ID, get_topic_id
|
||||
from sqlalchemy import update
|
||||
import aiohttp
|
||||
|
||||
# Защита от дублирования — блокировка на уровне процесса
|
||||
if hasattr(self, '_scheduled_posts_lock') and self._scheduled_posts_lock:
|
||||
logger.warning("⚠️ Посты уже обрабатываются, пропускаю")
|
||||
return
|
||||
self._scheduled_posts_lock = True
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Ищем посты которые пора отправить
|
||||
# SELECT FOR UPDATE — блокировка на уровне БД
|
||||
stmt = (
|
||||
select(ScheduledPost)
|
||||
.where(ScheduledPost.status == 'pending')
|
||||
|
|
@ -310,11 +317,17 @@ class Scheduler:
|
|||
)
|
||||
result = await session.execute(stmt)
|
||||
posts_to_send = list(result.scalars().all())
|
||||
|
||||
|
||||
if not posts_to_send:
|
||||
self._scheduled_posts_lock = False
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"📅 Найдено {len(posts_to_send)} постов для отправки")
|
||||
|
||||
# Сразу обновляем статус на 'sending' чтобы другие процессы не взяли
|
||||
for post in posts_to_send:
|
||||
post.status = 'sending' # Промежуточный статус
|
||||
await session.commit()
|
||||
|
||||
for post in posts_to_send:
|
||||
try:
|
||||
|
|
@ -447,3 +460,6 @@ class Scheduler:
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обработки запланированных постов: {e}')
|
||||
finally:
|
||||
# Снимаем блокировку
|
||||
self._scheduled_posts_lock = False
|
||||
|
|
|
|||
14
web/app.py
14
web/app.py
|
|
@ -970,22 +970,29 @@ async def api_create_scheduled_post(
|
|||
@app.get("/api/scheduled_posts/list")
|
||||
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
||||
"""Список запланированных постов"""
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc())
|
||||
result = await session.execute(stmt)
|
||||
posts = list(result.scalars().all())
|
||||
|
||||
# Часовой пояс пользователя (UTC+4 для Ульяновска)
|
||||
user_tz_offset = timedelta(hours=4) # Europe/Ulyanovsk
|
||||
|
||||
posts_data = []
|
||||
for post in posts:
|
||||
# Конвертируем UTC в локальное время
|
||||
local_time = post.scheduled_time + user_tz_offset
|
||||
|
||||
posts_data.append({
|
||||
"id": post.id,
|
||||
"text": post.text,
|
||||
"topic_name": post.topic_name,
|
||||
"topic_emoji": post.get_topic_emoji(),
|
||||
"recipients": post.recipients,
|
||||
"scheduled_time": post.scheduled_time.strftime('%d.%m.%Y %H:%M'),
|
||||
"scheduled_time": local_time.strftime('%d.%m.%Y %H:%M'), # Локальное время!
|
||||
"scheduled_time_utc": post.scheduled_time.strftime('%d.%m.%Y %H:%M'), # UTC для отладки
|
||||
"status": post.status,
|
||||
"status_emoji": post.get_status_emoji(),
|
||||
"created_at": post.created_at.strftime('%d.%m.%Y %H:%M'),
|
||||
|
|
@ -994,7 +1001,8 @@ async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
|||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"posts": posts_data
|
||||
"posts": posts_data,
|
||||
"timezone": "UTC+4 (Екатеринбург/Ульяновск)"
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -96,19 +96,20 @@
|
|||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Дата публикации</label>
|
||||
<input type="date" class="form-control" id="postDate" required>
|
||||
<small class="text-muted">Когда опубликовать</small>
|
||||
<small class="text-muted">🕐 По твоему времени (UTC+4)</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Время публикации</label>
|
||||
<input type="time" class="form-control" id="postTime" required>
|
||||
<small class="text-muted">Во сколько опубликовать</small>
|
||||
<small class="text-muted">🕐 По твоему времени (UTC+4)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<b>Информация:</b> Пост будет автоматически отправлен в выбранную тему форума в указанное время.
|
||||
<b>Часовой пояс:</b> Все времена указаны в твоём локальном времени (UTC+4, Екатеринбург/Ульяновск).
|
||||
<br>Бот автоматически конвертирует время в UTC для публикации по расписанию.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success btn-lg">
|
||||
|
|
@ -140,8 +141,15 @@
|
|||
<script>
|
||||
// Установка минимальной даты (сегодня)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('postDate').setAttribute('min', today);
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
document.getElementById('postDate').setAttribute('min', todayStr);
|
||||
|
||||
// Показываем текущее время в браузере пользователя
|
||||
const tzOffset = -today.getTimezoneOffset() / 60;
|
||||
const tzLabel = tzOffset >= 0 ? `UTC+${tzOffset}` : `UTC${tzOffset}`;
|
||||
console.log(`🕐 Твой часовой пояс: ${tzLabel}`);
|
||||
|
||||
loadPosts();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue