""" Feed Router — посты для карусели (новости / партнёры / контакты / приложения). Публичные эндпоинты: - GET /feed/{section}?lang={lang} — лента постов с локализацией - GET /feed/post/{id}?lang={lang} — один пост Админские эндпоинты: - POST /admin/feed/post — создать пост + автоперевод на 4 языка - PUT /admin/feed/post/{id} — редактировать (опционально перевести заново) - POST /admin/feed/post/{id}/delete — удалить (вложения удалит CASCADE) - POST /admin/feed/upload-image — загрузить картинку (multipart, до 3 на пост) - POST /admin/feed/translate — перевести title+body вручную (без сохранения) Логика локализации: - В БД хранятся 4 версии поста (title_pl/ru/uk/en, body_pl/ru/uk/en). - Юзер получает только свою версию. - Fallback: _{lang} → _en → _pl. """ import logging import json import re import uuid import os from pathlib import Path from typing import Optional, Any import httpx from fastapi import APIRouter, Request, HTTPException, UploadFile, File, Form from auth import require_auth from database import get_pool from config import ( TOGETHER_API_KEY, ADMIN_IDS, FEED_SECTIONS, FEED_TARGET_LANGUAGES, MAX_FEED_ATTACHMENTS, MAX_FEED_IMAGE_SIZE_BYTES, FEED_ALLOWED_IMAGE_TYPES, UPLOADS_DIR, ) logger = logging.getLogger(__name__) router = APIRouter(tags=["feed"]) TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions" TRANSLATE_MODEL = "MiniMaxAI/MiniMax-M3" # 🔧 ФИКС: бэк отдаёт полный URL картинки, чтобы фронт не гадал, # куда склеивать относительный путь /uploads/feed/... API_PUBLIC_URL = "https://api.filwordspl.com" # ============ HELPERS ============ def _require_admin(telegram_id: int) -> None: """Проверка, что пользователь — админ. Бросает 403 если нет.""" if telegram_id not in ADMIN_IDS: raise HTTPException(403, "Access denied") def _localized_field(row: dict, field: str, lang: str) -> str: """ Достаёт локализованное поле из строки БД. Fallback: _{lang} → _en → _pl → ''. """ if not row: return "" return ( row.get(f"{field}_{lang}") or row.get(f"{field}_en") or row.get(f"{field}_pl") or "" ) def _serialize_post(row: dict, attachments: list, lang: str) -> dict: """ Собирает финальный объект поста для фронта: локализованный title/body, ссылки, метаданные, картинки. """ return { "id": row["id"], "section": row["section"], "title": _localized_field(row, "title", lang), "body": _localized_field(row, "body", lang), "images": [ f"{API_PUBLIC_URL}{a['file_path']}" if a["file_path"].startswith("/") else a["file_path"] for a in attachments ], "linkUrl": row.get("link_url"), "telegramUrl": row.get("telegram_url"), "createdAt": row["created_at"].isoformat() if row.get("created_at") else None, "updatedAt": row["updated_at"].isoformat() if row.get("updated_at") else None, "expiresAt": row["expires_at"].isoformat() if row.get("expires_at") else None, "isPinned": bool(row.get("is_pinned")), } # ============ ПУБЛИЧНЫЕ ЭНДПОИНТЫ ============ @router.get("/feed/{section}") async def get_feed_section(request: Request, section: str, lang: Optional[str] = None): """ Возвращает ленту постов для секции (news / partners / contacts / apps). - Закреплённые посты — сверху. - Остальные — по created_at DESC. - Истёкшие (expires_at < NOW()) — НЕ отдаём. - Опубликованные (is_published = TRUE) — только они. """ telegram_id, _ = await require_auth(request) if section not in FEED_SECTIONS: raise HTTPException(400, f"Invalid section: {section}") pool = await get_pool() async with pool.acquire() as conn: # Если lang не передан — берём из профиля юзера if not lang or lang not in FEED_TARGET_LANGUAGES: lang = await conn.fetchval( "SELECT app_language FROM users WHERE telegram_id = $1", telegram_id, ) or "pl" if lang not in FEED_TARGET_LANGUAGES: lang = "pl" # Тянем посты: закреплённые сверху, потом по дате rows = await conn.fetch(""" SELECT * FROM feed_posts WHERE section = $1 AND is_published = TRUE AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY is_pinned DESC, created_at DESC """, section) if not rows: return {"posts": [], "section": section, "lang": lang} # Одним запросом тянем все вложения для всех постов (без N+1) post_ids = [r["id"] for r in rows] att_rows = await conn.fetch(""" SELECT post_id, file_path, order_number FROM feed_attachments WHERE post_id = ANY($1::bigint[]) ORDER BY post_id, order_number ASC """, post_ids) # Группируем вложения по post_id att_by_post: dict[int, list] = {} for a in att_rows: att_by_post.setdefault(a["post_id"], []).append(dict(a)) posts = [ _serialize_post(dict(r), att_by_post.get(r["id"], []), lang) for r in rows ] return {"posts": posts, "section": section, "lang": lang} @router.get("/feed/post/{post_id}") async def get_feed_post(request: Request, post_id: int, lang: Optional[str] = None): """Возвращает один пост по ID (с локализацией).""" telegram_id, _ = await require_auth(request) pool = await get_pool() async with pool.acquire() as conn: if not lang or lang not in FEED_TARGET_LANGUAGES: lang = await conn.fetchval( "SELECT app_language FROM users WHERE telegram_id = $1", telegram_id, ) or "pl" if lang not in FEED_TARGET_LANGUAGES: lang = "pl" row = await conn.fetchrow(""" SELECT * FROM feed_posts WHERE id = $1 AND is_published = TRUE """, post_id) if not row: raise HTTPException(404, "Post not found") att_rows = await conn.fetch(""" SELECT file_path, order_number FROM feed_attachments WHERE post_id = $1 ORDER BY order_number ASC """, post_id) return _serialize_post(dict(row), [dict(a) for a in att_rows], lang) # ============ AI-ПЕРЕВОД ============ async def translate_to_all_languages( title: str, body: str, source_lang: str, ) -> dict: """ Переводит title и body с source_lang на все 4 языка (PL/RU/UK/EN). Возвращает dict с 8 полями: title_pl, title_ru, title_uk, title_en, body_pl, body_ru, body_uk, body_en. Если AI упал или вернул невалидный JSON — возвращает пустой dict. Caller должен решить, использовать ли fallback (source_lang для всех языков). """ if not TOGETHER_API_KEY: logger.error("TOGETHER_API_KEY not configured — cannot translate") return {} if source_lang not in FEED_TARGET_LANGUAGES: logger.error(f"Invalid source_lang: {source_lang}") return {} system_prompt = f"""Ты — переводчик. Переведи title и body на 4 языка: PL, RU, UK, EN. Язык оригинала: {source_lang}. Верни СТРОГО JSON без markdown и без пояснений: {{ "title_pl": "...", "title_ru": "...", "title_uk": "...", "title_en": "...", "body_pl": "...", "body_ru": "...", "body_uk": "...", "body_en": "..." }} ВАЖНО: - Если source_lang = ru, то title_ru и body_ru оставь БЕЗ изменений. - Если source_lang = pl, то title_pl и body_pl оставь БЕЗ изменений. - Если source_lang = uk, то title_uk и body_uk оставь БЕЗ изменений. - Если source_lang = en, то title_en и body_en оставь БЕЗ изменений. - Сохраняй переносы строк внутри body (используй \\n в JSON). - Не добавляй никаких полей кроме указанных 8. - Если title пустой — оставь все title_* пустыми строками.""" user_prompt = f"title: {title}\nbody: {body}" try: async with httpx.AsyncClient(timeout=90.0) as client: response = await client.post( TOGETHER_API_URL, headers={ "Authorization": f"Bearer {TOGETHER_API_KEY}", "Content-Type": "application/json", }, json={ "model": TRANSLATE_MODEL, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "max_tokens": 2000, "temperature": 0.3, }, ) if response.status_code != 200: logger.error( f"Translate API error: {response.status_code}, body={response.text[:200]}" ) return {} data = response.json() raw = data["choices"][0]["message"]["content"].strip() # Если AI обернул в ```json ... ``` — снимаем обёртку if raw.startswith("```"): raw = re.sub(r'^```(?:json)?\s*', '', raw) raw = re.sub(r'\s*```$', '', raw) # Иногда AI добавляет текст до/после JSON — вырезаем первый {...} json_match = re.search(r'\{.*\}', raw, re.DOTALL) if not json_match: logger.error(f"AI response has no JSON: {raw[:200]}") return {} parsed = json.loads(json_match.group(0)) # Валидация: все 8 полей должны быть строками expected_keys = [ "title_pl", "title_ru", "title_uk", "title_en", "body_pl", "body_ru", "body_uk", "body_en", ] result = {} for key in expected_keys: value = parsed.get(key, "") result[key] = str(value) if value is not None else "" # Гарантия: source_lang остаётся без изменений result[f"title_{source_lang}"] = title result[f"body_{source_lang}"] = body return result except json.JSONDecodeError as e: logger.error(f"Failed to parse AI JSON: {e}") return {} except Exception as e: logger.error(f"Translate error: {str(e)}") return {} # ============ АДМИНСКИЕ ЭНДПОИНТЫ ============ @router.post("/admin/feed/post") async def admin_create_feed_post(request: Request): """ Создать feed-пост. Тело запроса (JSON): { "section": "news" | "partners" | "contacts" | "apps", "source_language": "ru" | "pl" | "uk" | "en", "title": "Заголовок на source_language", "body": "Текст поста на source_language", "link_url": "https://..." (опционально), "telegram_url": "https://t.me/..." (опционально), "is_pinned": false (опционально), "expires_at": "2026-10-15T23:59:59Z" (опционально, для partners) } Backend автоматически переведёт title/body на 4 языка через MiniMax-M3. Если перевод упал — сохраняем source_language для всех языков (fallback). """ telegram_id, _ = await require_auth(request) _require_admin(telegram_id) body = await request.json() section = body.get("section", "").strip() source_language = body.get("source_language", "ru").strip().lower() title = (body.get("title") or "").strip() post_body = (body.get("body") or "").strip() link_url = (body.get("link_url") or "").strip() or None telegram_url = (body.get("telegram_url") or "").strip() or None is_pinned = bool(body.get("is_pinned", False)) expires_at_raw = body.get("expires_at") # Валидация if section not in FEED_SECTIONS: raise HTTPException(400, f"Invalid section. Allowed: {sorted(FEED_SECTIONS)}") if source_language not in FEED_TARGET_LANGUAGES: raise HTTPException(400, f"Invalid source_language. Allowed: {FEED_TARGET_LANGUAGES}") if not title and not post_body: raise HTTPException(400, "Either title or body is required") if len(title) > 200: raise HTTPException(400, "Title too long (max 200 characters)") if len(post_body) > 5000: raise HTTPException(400, "Body too long (max 5000 characters)") # Парсим expires_at если передан expires_at = None if expires_at_raw: try: from datetime import datetime, timezone # Поддерживаем ISO 8601 с 'Z' или без normalized = expires_at_raw.replace("Z", "+00:00") expires_at = datetime.fromisoformat(normalized) if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) except (ValueError, AttributeError): raise HTTPException(400, "Invalid expires_at format (use ISO 8601)") # Переводим на 4 языка logger.info(f"Translating feed post from {source_language} → PL/RU/UK/EN") translations = await translate_to_all_languages(title, post_body, source_language) # Fallback: если перевод не удался — копируем source для всех языков if not translations: logger.warning("Translation failed — falling back to source_language for all") translations = {} for lang in FEED_TARGET_LANGUAGES: translations[f"title_{lang}"] = title translations[f"body_{lang}"] = post_body pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow(""" INSERT INTO feed_posts ( section, source_language, title_pl, title_ru, title_uk, title_en, body_pl, body_ru, body_uk, body_en, link_url, telegram_url, author_id, is_pinned, is_published, expires_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, TRUE, $15 ) RETURNING id """, section, source_language, translations["title_pl"], translations["title_ru"], translations["title_uk"], translations["title_en"], translations["body_pl"], translations["body_ru"], translations["body_uk"], translations["body_en"], link_url, telegram_url, telegram_id, is_pinned, expires_at, ) return {"status": "ok", "post_id": row["id"]} @router.put("/admin/feed/post/{post_id}") async def admin_update_feed_post(request: Request, post_id: int): """ Редактировать feed-пост. Тело запроса (JSON) — все поля опциональны: { "title": "...", (на source_language) "body": "...", (на source_language) "link_url": "..." | null, "telegram_url": "..." | null, "is_pinned": true/false, "is_published": true/false, "expires_at": "..." | null, "translate_again": true — перевести заново через AI } """ telegram_id, _ = await require_auth(request) _require_admin(telegram_id) body = await request.json() pool = await get_pool() async with pool.acquire() as conn: existing = await conn.fetchrow(""" SELECT * FROM feed_posts WHERE id = $1 """, post_id) if not existing: raise HTTPException(404, "Post not found") source_language = existing["source_language"] # Мерджим поля: если не передано — оставляем старое title = body.get("title", existing.get(f"title_{source_language}") or "") post_body = body.get("body", existing.get(f"body_{source_language}") or "") link_url = body.get("link_url", existing["link_url"]) telegram_url = body.get("telegram_url", existing["telegram_url"]) is_pinned = body.get("is_pinned", existing["is_pinned"]) is_published = body.get("is_published", existing["is_published"]) # expires_at: явное None → очистить, отсутствие ключа → оставить if "expires_at" in body: expires_at_raw = body.get("expires_at") if expires_at_raw is None: expires_at = None else: try: from datetime import datetime, timezone normalized = str(expires_at_raw).replace("Z", "+00:00") expires_at = datetime.fromisoformat(normalized) if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) except (ValueError, AttributeError): raise HTTPException(400, "Invalid expires_at format (use ISO 8601)") else: expires_at = existing["expires_at"] # Если translate_again = True — переводим заново if body.get("translate_again"): logger.info(f"Re-translating feed post #{post_id} from {source_language}") translations = await translate_to_all_languages(title, post_body, source_language) if not translations: logger.warning("Translation failed — keeping old translations for other languages") # Оставляем старые версии для не-source языков, обновляем только source translations = { "title_pl": existing["title_pl"], "title_ru": existing["title_ru"], "title_uk": existing["title_uk"], "title_en": existing["title_en"], "body_pl": existing["body_pl"], "body_ru": existing["body_ru"], "body_uk": existing["body_uk"], "body_en": existing["body_en"], } translations[f"title_{source_language}"] = title translations[f"body_{source_language}"] = post_body else: # Без translate_again — обновляем ТОЛЬКО source_language, остальные не трогаем translations = { "title_pl": existing["title_pl"], "title_ru": existing["title_ru"], "title_uk": existing["title_uk"], "title_en": existing["title_en"], "body_pl": existing["body_pl"], "body_ru": existing["body_ru"], "body_uk": existing["body_uk"], "body_en": existing["body_en"], } translations[f"title_{source_language}"] = title translations[f"body_{source_language}"] = post_body await conn.execute(""" UPDATE feed_posts SET title_pl = $1, title_ru = $2, title_uk = $3, title_en = $4, body_pl = $5, body_ru = $6, body_uk = $7, body_en = $8, link_url = $9, telegram_url = $10, is_pinned = $11, is_published = $12, expires_at = $13, updated_at = NOW(), edited_at = NOW() WHERE id = $14 """, translations["title_pl"], translations["title_ru"], translations["title_uk"], translations["title_en"], translations["body_pl"], translations["body_ru"], translations["body_uk"], translations["body_en"], link_url, telegram_url, is_pinned, is_published, expires_at, post_id, ) return {"status": "ok", "post_id": post_id} # 🔧 ФИКС (18.09.2026): POST вместо DELETE. # Причина: Telegram WebView (iOS / macOS) не пропускает DELETE из-за CORS preflight, # который Cloudflare кеширует на 20 дней. POST без Content-Type — simple request, # preflight не отправляется, Cloudflare не вмешивается. # # 🔧 ФИКС (19.09.2026): удаляем не только запись из БД, но и файлы с диска. # Раньше файлы копились мёртвым грузом (27 мёртвых файлов за день тестов). @router.post("/admin/feed/post/{post_id}/delete") async def admin_delete_feed_post(request: Request, post_id: int): """ Удалить feed-пост (POST вместо DELETE — обход CORS preflight в WebView). Что делаем: 1. Читаем пути всех файлов поста из feed_attachments ДО удаления из БД. 2. Удаляем пост из БД (ON DELETE CASCADE уберёт вложения). 3. Удаляем файлы с диска. Если файл уже удалён вручную / нет прав — не роняем запрос, только логируем. Пост из БД уже удалён — файл вторичен. """ telegram_id, _ = await require_auth(request) _require_admin(telegram_id) pool = await get_pool() async with pool.acquire() as conn: # 1. Читаем пути файлов ДО удаления attachment_rows = await conn.fetch(""" SELECT file_path FROM feed_attachments WHERE post_id = $1 """, post_id) # 2. Удаляем пост (CASCADE уберёт feed_attachments) result = await conn.execute(""" DELETE FROM feed_posts WHERE id = $1 """, post_id) affected = int(result.split()[-1]) if affected == 0: raise HTTPException(404, "Post not found") logger.info(f"Feed post #{post_id} deleted by admin {telegram_id}") # 3. Удаляем файлы с диска (вне pool.acquire — чтобы не держать коннект) # file_path в БД = "/uploads/feed/16_0_abc.jpg" # на диске = "/root/filwordspl/backend/uploads/feed/16_0_abc.jpg" # UPLOADS_DIR = "/root/filwordspl/backend/uploads" (Path) removed_count = 0 failed_count = 0 for row in attachment_rows: file_path = row["file_path"] # "/uploads/feed/16_0_abc.jpg" # Убираем префикс "/uploads/" → "feed/16_0_abc.jpg" rel_path = file_path.replace("/uploads/", "", 1).lstrip("/") abs_path = Path(UPLOADS_DIR) / rel_path try: if abs_path.exists(): abs_path.unlink() removed_count += 1 logger.info(f"[delete_feed_post] removed file: {abs_path}") else: logger.warning(f"[delete_feed_post] file not found: {abs_path}") except Exception as e: failed_count += 1 # Не роняем запрос — пост уже удалён из БД, файл вторичен logger.error(f"[delete_feed_post] failed to remove {abs_path}: {e}") if removed_count or failed_count: logger.info( f"[delete_feed_post] post #{post_id}: " f"files removed={removed_count}, failed={failed_count}" ) return {"status": "ok", "files_removed": removed_count} @router.post("/admin/feed/upload-image") async def admin_upload_feed_image( request: Request, post_id: int = Form(...), file: UploadFile = File(...), ): """ Загрузить картинку к посту. - Максимум MAX_FEED_ATTACHMENTS (3) на пост. - Максимум MAX_FEED_IMAGE_SIZE_MB (5 МБ) на файл. - Только image/jpeg, image/png, image/webp, image/gif. Возвращает путь к файлу (используется как URL на фронте). """ telegram_id, _ = await require_auth(request) _require_admin(telegram_id) # Валидация MIME-типа content_type = (file.content_type or "").lower() if content_type not in FEED_ALLOWED_IMAGE_TYPES: raise HTTPException( 400, f"Invalid image type: {content_type}. Allowed: {sorted(FEED_ALLOWED_IMAGE_TYPES)}", ) # Читаем файл, проверяем размер content = await file.read() if len(content) == 0: raise HTTPException(400, "Empty file") if len(content) > MAX_FEED_IMAGE_SIZE_BYTES: raise HTTPException( 400, f"File too large: {len(content)} bytes. Max: {MAX_FEED_IMAGE_SIZE_BYTES} bytes", ) pool = await get_pool() async with pool.acquire() as conn: # Проверяем, что пост существует post_exists = await conn.fetchval( "SELECT 1 FROM feed_posts WHERE id = $1", post_id ) if not post_exists: raise HTTPException(404, "Post not found") # Проверяем лимит вложений current_count = await conn.fetchval(""" SELECT COUNT(*) FROM feed_attachments WHERE post_id = $1 """, post_id) or 0 if current_count >= MAX_FEED_ATTACHMENTS: raise HTTPException( 400, f"Max {MAX_FEED_ATTACHMENTS} attachments per post (already {current_count})", ) # Готовим имя файла: {post_id}_{order}_{uuid8}.{ext} ext_map = { "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp", "image/gif": "gif", } ext = ext_map.get(content_type, "jpg") order_number = current_count # 0, 1, 2 filename = f"{post_id}_{order_number}_{uuid.uuid4().hex[:8]}.{ext}" # Сохраняем на диск feed_uploads_dir = Path(UPLOADS_DIR) / "feed" feed_uploads_dir.mkdir(parents=True, exist_ok=True) file_path_disk = feed_uploads_dir / filename file_path_disk.write_bytes(content) # Публичный URL для фронта (относительный — фронт сам подставит API_BASE) public_path = f"/uploads/feed/{filename}" # Пишем в БД await conn.execute(""" INSERT INTO feed_attachments (post_id, file_path, file_type, order_number) VALUES ($1, $2, $3, $4) """, post_id, public_path, "image", order_number) return { "status": "ok", "file_path": public_path, "order_number": order_number, } @router.post("/admin/feed/translate") async def admin_translate_feed(request: Request): """ Перевести title+body БЕЗ сохранения в БД. Используется для превью перевода в админ-UI. Тело запроса: { "title": "...", "body": "...", "source_language": "ru" } Возвращает: { "title_pl": "...", "title_ru": "...", "title_uk": "...", "title_en": "...", "body_pl": "...", "body_ru": "...", "body_uk": "...", "body_en": "..." } """ telegram_id, _ = await require_auth(request) _require_admin(telegram_id) body = await request.json() title = (body.get("title") or "").strip() post_body = (body.get("body") or "").strip() source_language = body.get("source_language", "ru").strip().lower() if source_language not in FEED_TARGET_LANGUAGES: raise HTTPException(400, f"Invalid source_language. Allowed: {FEED_TARGET_LANGUAGES}") if not title and not post_body: raise HTTPException(400, "Either title or body is required") translations = await translate_to_all_languages(title, post_body, source_language) if not translations: raise HTTPException(500, "Translation failed (AI unavailable or invalid response)") return translations