🕐 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 config import ADMIN_CHAT_ID, get_topic_id
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
import aiohttp
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
# Ищем посты которые пора отправить
|
# Ищем посты которые пора отправить
|
||||||
|
# SELECT FOR UPDATE — блокировка на уровне БД
|
||||||
stmt = (
|
stmt = (
|
||||||
select(ScheduledPost)
|
select(ScheduledPost)
|
||||||
.where(ScheduledPost.status == 'pending')
|
.where(ScheduledPost.status == 'pending')
|
||||||
|
|
@ -310,11 +317,17 @@ class Scheduler:
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
posts_to_send = list(result.scalars().all())
|
posts_to_send = list(result.scalars().all())
|
||||||
|
|
||||||
if not posts_to_send:
|
if not posts_to_send:
|
||||||
|
self._scheduled_posts_lock = False
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"📅 Найдено {len(posts_to_send)} постов для отправки")
|
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:
|
for post in posts_to_send:
|
||||||
try:
|
try:
|
||||||
|
|
@ -447,3 +460,6 @@ class Scheduler:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка обработки запланированных постов: {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")
|
@app.get("/api/scheduled_posts/list")
|
||||||
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc())
|
stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc())
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
posts = list(result.scalars().all())
|
posts = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Часовой пояс пользователя (UTC+4 для Ульяновска)
|
||||||
|
user_tz_offset = timedelta(hours=4) # Europe/Ulyanovsk
|
||||||
|
|
||||||
posts_data = []
|
posts_data = []
|
||||||
for post in posts:
|
for post in posts:
|
||||||
|
# Конвертируем UTC в локальное время
|
||||||
|
local_time = post.scheduled_time + user_tz_offset
|
||||||
|
|
||||||
posts_data.append({
|
posts_data.append({
|
||||||
"id": post.id,
|
"id": post.id,
|
||||||
"text": post.text,
|
"text": post.text,
|
||||||
"topic_name": post.topic_name,
|
"topic_name": post.topic_name,
|
||||||
"topic_emoji": post.get_topic_emoji(),
|
"topic_emoji": post.get_topic_emoji(),
|
||||||
"recipients": post.recipients,
|
"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": post.status,
|
||||||
"status_emoji": post.get_status_emoji(),
|
"status_emoji": post.get_status_emoji(),
|
||||||
"created_at": post.created_at.strftime('%d.%m.%Y %H:%M'),
|
"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({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
"posts": posts_data
|
"posts": posts_data,
|
||||||
|
"timezone": "UTC+4 (Екатеринбург/Ульяновск)"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,19 +96,20 @@
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Дата публикации</label>
|
<label class="form-label">Дата публикации</label>
|
||||||
<input type="date" class="form-control" id="postDate" required>
|
<input type="date" class="form-control" id="postDate" required>
|
||||||
<small class="text-muted">Когда опубликовать</small>
|
<small class="text-muted">🕐 По твоему времени (UTC+4)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Время публикации</label>
|
<label class="form-label">Время публикации</label>
|
||||||
<input type="time" class="form-control" id="postTime" required>
|
<input type="time" class="form-control" id="postTime" required>
|
||||||
<small class="text-muted">Во сколько опубликовать</small>
|
<small class="text-muted">🕐 По твоему времени (UTC+4)</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle"></i>
|
<i class="bi bi-info-circle"></i>
|
||||||
<b>Информация:</b> Пост будет автоматически отправлен в выбранную тему форума в указанное время.
|
<b>Часовой пояс:</b> Все времена указаны в твоём локальном времени (UTC+4, Екатеринбург/Ульяновск).
|
||||||
|
<br>Бот автоматически конвертирует время в UTC для публикации по расписанию.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-success btn-lg">
|
<button type="submit" class="btn btn-success btn-lg">
|
||||||
|
|
@ -140,8 +141,15 @@
|
||||||
<script>
|
<script>
|
||||||
// Установка минимальной даты (сегодня)
|
// Установка минимальной даты (сегодня)
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date();
|
||||||
document.getElementById('postDate').setAttribute('min', today);
|
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();
|
loadPosts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue