mirror of
https://gitverse.ru/kpa39l/md2vk.git
synced 2026-09-29 01:50:04 +00:00
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""API v1: эндпоинты публикации, конвертации, управления аккаунтами."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models import User, VkAccount, Publication
|
|
from app.security import decrypt_token, encrypt_token
|
|
from app.vk_client import VkClient, VkApiError
|
|
from app.converters.markdown_to_vk import markdown_to_vk, format_data_json
|
|
from app.api.schemas import (
|
|
AccountCreateRequest,
|
|
AccountResponse,
|
|
AccountListResponse,
|
|
PublishRequest,
|
|
PublishResponse,
|
|
ConvertRequest,
|
|
ConvertResponse,
|
|
PublicationFilterRequest,
|
|
PublicationItem,
|
|
PublicationListResponse,
|
|
)
|
|
from app.api.deps import get_current_user_from_header, get_user_by_api_key
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
|
|
|
|
|
# ─── Health ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ─── Аккаунты ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/accounts", response_model=AccountResponse)
|
|
async def create_account(
|
|
req: AccountCreateRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Добавить VK-аккаунт. Проверяет токен через VK API перед сохранением."""
|
|
user = await get_user_by_api_key(req.api_key, db)
|
|
|
|
# Проверяем токен через VK API
|
|
vk = VkClient(req.access_token)
|
|
try:
|
|
is_valid, display_name = await vk.check_token()
|
|
if not is_valid:
|
|
raise HTTPException(status_code=400, detail=f"VK token invalid: {display_name}")
|
|
except VkApiError as e:
|
|
raise HTTPException(status_code=400, detail=f"VK API error: {e}")
|
|
finally:
|
|
await vk.close()
|
|
|
|
# Шифруем токен перед сохранением
|
|
encrypted = encrypt_token(req.access_token)
|
|
name = req.display_name or display_name or f"VK-{req.vk_user_id}"
|
|
|
|
# Проверяем, нет ли уже такого VK-аккаунта у пользователя
|
|
existing = await db.execute(
|
|
select(VkAccount).where(
|
|
VkAccount.user_id == user.id,
|
|
VkAccount.vk_user_id == req.vk_user_id,
|
|
VkAccount.is_active == True,
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(status_code=409, detail="This VK account is already registered")
|
|
|
|
account = VkAccount(
|
|
user_id=user.id,
|
|
vk_user_id=req.vk_user_id,
|
|
display_name=name,
|
|
access_token_enc=encrypted,
|
|
token_type=req.token_type,
|
|
)
|
|
db.add(account)
|
|
await db.flush()
|
|
await db.refresh(account)
|
|
|
|
return AccountResponse(
|
|
id=account.id,
|
|
vk_user_id=account.vk_user_id,
|
|
display_name=account.display_name,
|
|
token_type=account.token_type,
|
|
is_active=account.is_active,
|
|
created_at=account.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/accounts", response_model=AccountListResponse)
|
|
async def list_accounts(
|
|
user: User = Depends(get_current_user_from_header),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Список VK-аккаунтов текущего пользователя."""
|
|
result = await db.execute(
|
|
select(VkAccount).where(VkAccount.user_id == user.id, VkAccount.is_active == True)
|
|
)
|
|
accounts = result.scalars().all()
|
|
|
|
return AccountListResponse(
|
|
accounts=[
|
|
AccountResponse(
|
|
id=a.id,
|
|
vk_user_id=a.vk_user_id,
|
|
display_name=a.display_name,
|
|
token_type=a.token_type,
|
|
is_active=a.is_active,
|
|
created_at=a.created_at,
|
|
)
|
|
for a in accounts
|
|
]
|
|
)
|
|
|
|
|
|
@router.delete("/accounts/{account_id}", status_code=204)
|
|
async def delete_account(
|
|
account_id: int,
|
|
user: User = Depends(get_current_user_from_header),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Удалить VK-аккаунт (soft delete)."""
|
|
result = await db.execute(
|
|
select(VkAccount).where(
|
|
VkAccount.id == account_id,
|
|
VkAccount.user_id == user.id,
|
|
)
|
|
)
|
|
account = result.scalar_one_or_none()
|
|
if account is None:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
account.is_active = False
|
|
await db.flush()
|
|
|
|
|
|
# ─── Публикация ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/publish", response_model=PublishResponse)
|
|
async def publish(
|
|
req: PublishRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Опубликовать пост на стене VK."""
|
|
# Аутентификация по api_key из тела запроса
|
|
user = await get_user_by_api_key(req.api_key, db)
|
|
|
|
# Получаем VK-аккаунт
|
|
result = await db.execute(
|
|
select(VkAccount).where(
|
|
VkAccount.id == req.vk_account_id,
|
|
VkAccount.user_id == user.id,
|
|
VkAccount.is_active == True,
|
|
)
|
|
)
|
|
account = result.scalar_one_or_none()
|
|
if account is None:
|
|
raise HTTPException(status_code=404, detail="VK account not found")
|
|
|
|
# Расшифровываем токен (только в памяти!)
|
|
token = decrypt_token(account.access_token_enc)
|
|
if token is None:
|
|
raise HTTPException(status_code=500, detail="Failed to decrypt VK token (key mismatch?)")
|
|
|
|
# Конвертируем Markdown
|
|
chunks = markdown_to_vk(req.message_md)
|
|
if not chunks or not chunks[0].text.strip():
|
|
raise HTTPException(status_code=400, detail="Empty message after markdown conversion")
|
|
|
|
chunk = chunks[0]
|
|
fd_json = format_data_json(chunk.items) if chunk.items else None
|
|
|
|
# Создаём запись о публикации
|
|
publication = Publication(
|
|
vk_account_id=account.id,
|
|
status="draft",
|
|
markdown_original=req.message_md,
|
|
vk_text=chunk.text,
|
|
vk_format_data=fd_json,
|
|
scheduled_at=req.publish_date,
|
|
)
|
|
db.add(publication)
|
|
await db.flush()
|
|
|
|
# Если отложенная — сохраняем и выходим
|
|
if req.publish_date:
|
|
publication.status = "scheduled"
|
|
await db.flush()
|
|
return PublishResponse(
|
|
success=True,
|
|
publication_id=publication.id,
|
|
)
|
|
|
|
# Публикуем через VK API
|
|
vk = VkClient(token)
|
|
try:
|
|
result = await vk.wall_post(
|
|
message=chunk.text,
|
|
owner_id=account.vk_user_id if account.token_type == "group" else None,
|
|
from_group=(account.token_type == "group"),
|
|
friends_only=req.friends_only,
|
|
publish_date=int(req.publish_date.timestamp()) if req.publish_date else None,
|
|
attachments=req.attachments,
|
|
signed=req.signed,
|
|
format_data=chunk.items if chunk.items else None,
|
|
)
|
|
|
|
post_id = result.get("post_id")
|
|
owner_id = result.get("owner_id") or account.vk_user_id
|
|
|
|
publication.status = "published"
|
|
publication.vk_post_id = post_id
|
|
publication.vk_owner_id = owner_id
|
|
publication.published_at = datetime.utcnow()
|
|
await db.flush()
|
|
|
|
url = f"https://vk.com/wall{owner_id}_{post_id}"
|
|
|
|
return PublishResponse(
|
|
success=True,
|
|
publication_id=publication.id,
|
|
vk_post_id=post_id,
|
|
vk_owner_id=owner_id,
|
|
url=url,
|
|
)
|
|
|
|
except VkApiError as e:
|
|
publication.status = "error"
|
|
publication.error_message = str(e)
|
|
await db.flush()
|
|
return PublishResponse(
|
|
success=False,
|
|
publication_id=publication.id,
|
|
error=str(e),
|
|
)
|
|
finally:
|
|
await vk.close()
|
|
|
|
|
|
@router.post("/convert", response_model=ConvertResponse)
|
|
async def convert(req: ConvertRequest):
|
|
"""Конвертировать Markdown в VK format_data (без публикации)."""
|
|
chunks = markdown_to_vk(req.message_md)
|
|
if not chunks:
|
|
return ConvertResponse(text="", format_data={"version": 1, "items": []})
|
|
|
|
chunk = chunks[0]
|
|
items = []
|
|
for i in chunk.items:
|
|
items.append({"type": i.type, "offset": i.offset, "length": i.length, "url": i.url})
|
|
|
|
return ConvertResponse(
|
|
text=chunk.text,
|
|
format_data={"version": 1, "items": items},
|
|
)
|
|
|
|
|
|
# ─── Архив публикаций ──────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/publications", response_model=PublicationListResponse)
|
|
async def list_publications(
|
|
req: PublicationFilterRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Архив публикаций с фильтрацией."""
|
|
user = await get_user_by_api_key(req.api_key, db)
|
|
|
|
# Базовый запрос — только публикации пользователя
|
|
base_filter = VkAccount.user_id == user.id
|
|
|
|
query = (
|
|
select(Publication)
|
|
.join(VkAccount)
|
|
.where(base_filter)
|
|
)
|
|
count_query = (
|
|
select(func.count(Publication.id))
|
|
.join(VkAccount)
|
|
.where(base_filter)
|
|
)
|
|
|
|
if req.vk_account_id is not None:
|
|
query = query.where(Publication.vk_account_id == req.vk_account_id)
|
|
count_query = count_query.where(Publication.vk_account_id == req.vk_account_id)
|
|
if req.status is not None:
|
|
query = query.where(Publication.status == req.status)
|
|
count_query = count_query.where(Publication.status == req.status)
|
|
|
|
total_result = await db.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
query = query.order_by(Publication.created_at.desc()).offset(req.offset).limit(req.limit)
|
|
result = await db.execute(query)
|
|
publications = result.scalars().all()
|
|
|
|
return PublicationListResponse(
|
|
publications=[
|
|
PublicationItem(
|
|
id=p.id,
|
|
vk_account_id=p.vk_account_id,
|
|
status=p.status,
|
|
markdown_original=p.markdown_original,
|
|
vk_post_id=p.vk_post_id,
|
|
vk_owner_id=p.vk_owner_id,
|
|
scheduled_at=p.scheduled_at,
|
|
published_at=p.published_at,
|
|
error_message=p.error_message,
|
|
created_at=p.created_at,
|
|
)
|
|
for p in publications
|
|
],
|
|
total=total,
|
|
) |