Add VK OAuth implicit flow and fix sync issues
- Replace manual community token entry with OAuth button that redirects to VK authorization and auto-saves token via /vk/callback - Fix groups.get API call (was groups.getById) to correctly retrieve admin group id and name from user token response - Fix price comparison: VK price.amount is in roubles, not kopecks - Keep manual token input as fallback when VK_CLIENT_ID is not set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
||||||
|
|
||||||
|
VK_CLIENT_ID: str = ""
|
||||||
|
VK_CLIENT_SECRET: str = ""
|
||||||
VK_API_VERSION: str = "5.131"
|
VK_API_VERSION: str = "5.131"
|
||||||
|
|
||||||
# Docker compose vars (ignored in app, kept for env compatibility)
|
# Docker compose vars (ignored in app, kept for env compatibility)
|
||||||
|
|||||||
141
web/routes/vk.py
141
web/routes/vk.py
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -15,6 +16,57 @@ from web.models import User, VkConnection
|
|||||||
router = APIRouter(prefix="/vk")
|
router = APIRouter(prefix="/vk")
|
||||||
|
|
||||||
VK_API_URL = "https://api.vk.com/method"
|
VK_API_URL = "https://api.vk.com/method"
|
||||||
|
VK_OAUTH_URL = "https://oauth.vk.com/authorize"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_group_info(token: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Returns (group_id, group_name) for the first admin group, or (None, None)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{VK_API_URL}/groups.get",
|
||||||
|
params={
|
||||||
|
"access_token": token,
|
||||||
|
"v": settings.VK_API_VERSION,
|
||||||
|
"filter": "admin",
|
||||||
|
"extended": 1,
|
||||||
|
"count": 1,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if "error" not in data:
|
||||||
|
items = data.get("response", {}).get("items", [])
|
||||||
|
if items:
|
||||||
|
return str(items[0].get("id", "")), items[0].get("name")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_connection(db: Session, user_id: int, token: str,
|
||||||
|
group_id: str | None, group_name: str | None) -> None:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
connection = db.query(VkConnection).filter(VkConnection.user_id == user_id).first()
|
||||||
|
if connection:
|
||||||
|
connection.access_token = token
|
||||||
|
connection.vk_user_id = group_id
|
||||||
|
connection.first_name = group_name
|
||||||
|
connection.last_name = None
|
||||||
|
connection.is_online = True
|
||||||
|
connection.last_checked_at = now
|
||||||
|
else:
|
||||||
|
db.add(VkConnection(
|
||||||
|
user_id=user_id,
|
||||||
|
access_token=token,
|
||||||
|
vk_user_id=group_id,
|
||||||
|
first_name=group_name,
|
||||||
|
last_name=None,
|
||||||
|
is_online=True,
|
||||||
|
last_checked_at=now,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -33,6 +85,46 @@ def vk_page(
|
|||||||
"user": user,
|
"user": user,
|
||||||
"connection": connection,
|
"connection": connection,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"vk_client_id": settings.VK_CLIENT_ID,
|
||||||
|
"callback_url": f"{settings.BASE_URL}/vk/callback",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect")
|
||||||
|
def vk_connect(
|
||||||
|
request: Request,
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Redirect to VK OAuth authorization page."""
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
if not settings.VK_CLIENT_ID:
|
||||||
|
return RedirectResponse("/vk?error=no_client_id", 303)
|
||||||
|
|
||||||
|
params = urlencode({
|
||||||
|
"client_id": settings.VK_CLIENT_ID,
|
||||||
|
"scope": "market,groups",
|
||||||
|
"redirect_uri": f"{settings.BASE_URL}/vk/callback",
|
||||||
|
"display": "page",
|
||||||
|
"response_type": "token",
|
||||||
|
"v": settings.VK_API_VERSION,
|
||||||
|
})
|
||||||
|
return RedirectResponse(f"{VK_OAUTH_URL}?{params}", 302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
def vk_callback(
|
||||||
|
request: Request,
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Landing page after VK OAuth. JS reads the token from the URL fragment and POSTs it."""
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("vk_callback.html", {
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +134,7 @@ async def vk_token(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User | None = Depends(get_current_user),
|
user: User | None = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Save a manually entered VK community access token."""
|
"""Save a VK user access token (from manual entry or OAuth callback)."""
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
@@ -51,52 +143,11 @@ async def vk_token(
|
|||||||
if not token:
|
if not token:
|
||||||
return RedirectResponse("/vk?error=empty_token", 303)
|
return RedirectResponse("/vk?error=empty_token", 303)
|
||||||
|
|
||||||
# Fetch community info to validate the token and get group name/id
|
group_id, group_name = await _fetch_group_info(token)
|
||||||
group_id = None
|
if not group_id:
|
||||||
group_name = None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{VK_API_URL}/groups.getById",
|
|
||||||
params={"access_token": token, "v": settings.VK_API_VERSION},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
if "error" in data:
|
|
||||||
return RedirectResponse("/vk?error=invalid_token", 303)
|
return RedirectResponse("/vk?error=invalid_token", 303)
|
||||||
response = data.get("response", [])
|
|
||||||
groups = response if isinstance(response, list) else response.get("groups", [])
|
|
||||||
if groups:
|
|
||||||
group_id = str(groups[0].get("id", ""))
|
|
||||||
group_name = groups[0].get("name")
|
|
||||||
elif resp.status_code == 401:
|
|
||||||
return RedirectResponse("/vk?error=invalid_token", 303)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
now = datetime.utcnow()
|
|
||||||
if connection:
|
|
||||||
connection.access_token = token
|
|
||||||
connection.vk_user_id = group_id
|
|
||||||
connection.first_name = group_name
|
|
||||||
connection.last_name = None
|
|
||||||
connection.is_online = True
|
|
||||||
connection.last_checked_at = now
|
|
||||||
else:
|
|
||||||
connection = VkConnection(
|
|
||||||
user_id=user.id,
|
|
||||||
access_token=token,
|
|
||||||
vk_user_id=group_id,
|
|
||||||
first_name=group_name,
|
|
||||||
last_name=None,
|
|
||||||
is_online=True,
|
|
||||||
last_checked_at=now,
|
|
||||||
)
|
|
||||||
db.add(connection)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
_save_connection(db, user.id, token, group_id, group_name)
|
||||||
return RedirectResponse("/connections", 303)
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger mt-4">
|
<div class="alert alert-danger mt-4">
|
||||||
{% if error == "invalid_token" %}
|
{% if error == "invalid_token" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Убедитесь, что скопировали ключ доступа сообщества правильно.
|
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен или у него нет прав администратора сообщества.
|
||||||
{% elif error == "empty_token" %}
|
{% elif error == "empty_token" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Введите ключ доступа.
|
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
|
||||||
|
{% elif error == "no_client_id" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Автоматическое подключение не настроено. Введите токен вручную.
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -47,10 +49,10 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<p class="text-muted small mb-2">Обновить ключ доступа:</p>
|
<p class="text-muted small mb-2">Обновить токен пользователя:</p>
|
||||||
<form method="post" action="/vk/token">
|
<form method="post" action="/vk/token">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый ключ доступа" required>
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен пользователя" required>
|
||||||
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -64,25 +66,31 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{# ── NOT CONNECTED STATE ── #}
|
{# ── NOT CONNECTED STATE ── #}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted mb-3">
|
{% if vk_client_id %}
|
||||||
Для подключения вам нужен <strong>ключ доступа сообщества</strong> ВКонтакте.
|
<p class="text-muted mb-4">
|
||||||
Синхронизация товаров работает только через сообщество.
|
Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="d-grid mb-3">
|
||||||
<ol class="text-muted small mb-4">
|
<a href="/vk/connect" class="btn btn-primary btn-lg">
|
||||||
<li class="mb-1">Откройте <a href="https://vk.com" target="_blank" rel="noopener">vk.com <i class="bi bi-box-arrow-up-right small"></i></a> и перейдите в управление вашим сообществом.</li>
|
<i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
|
||||||
<li class="mb-1">Перейдите в раздел <strong>Настройки → Работа с API</strong>.</li>
|
</a>
|
||||||
<li class="mb-1">Создайте ключ доступа с правами <strong>Управление товарами</strong> и <strong>Управление сообществом</strong>.</li>
|
</div>
|
||||||
<li class="mb-1">Скопируйте ключ и вставьте его в поле ниже.</li>
|
<hr class="my-4">
|
||||||
</ol>
|
<p class="text-muted small mb-2">Или введите токен вручную:</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Для синхронизации товаров необходим <strong>токен пользователя</strong> ВКонтакте
|
||||||
|
с правами на управление товарами сообщества.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/vk/token">
|
<form method="post" action="/vk/token">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label small text-muted">Ключ доступа сообщества</label>
|
<label class="form-label small text-muted">Токен пользователя ВКонтакте</label>
|
||||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте ключ доступа" required autofocus>
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен пользователя" required {% if vk_client_id %}{% else %}autofocus{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button type="submit" class="btn btn-primary">Подключить</button>
|
<button type="submit" class="btn {% if vk_client_id %}btn-outline-secondary{% else %}btn-primary{% endif %}">Подключить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
web/templates/vk_callback.html
Normal file
47
web/templates/vk_callback.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||||
|
<div class="card shadow-sm mt-4 text-center">
|
||||||
|
<div class="card-body py-5">
|
||||||
|
<div id="state-loading">
|
||||||
|
<div class="spinner-border text-primary mb-3" role="status"></div>
|
||||||
|
<p class="text-muted mb-0">Подключение ВКонтакте…</p>
|
||||||
|
</div>
|
||||||
|
<div id="state-error" class="d-none">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill text-danger fs-1 mb-3 d-block"></i>
|
||||||
|
<p class="text-muted mb-3" id="error-message">Не удалось получить токен от ВКонтакте.</p>
|
||||||
|
<a href="/vk" class="btn btn-outline-secondary">Попробовать снова</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="token-form" method="post" action="/vk/token" class="d-none">
|
||||||
|
<input type="hidden" name="token" id="token-input">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var hash = window.location.hash.slice(1);
|
||||||
|
var params = {};
|
||||||
|
hash.split("&").forEach(function (part) {
|
||||||
|
var kv = part.split("=");
|
||||||
|
params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || "");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.access_token) {
|
||||||
|
document.getElementById("token-input").value = params.access_token;
|
||||||
|
document.getElementById("token-form").submit();
|
||||||
|
} else {
|
||||||
|
var msg = params.error_description || params.error || "Авторизация отклонена.";
|
||||||
|
document.getElementById("error-message").textContent = msg;
|
||||||
|
document.getElementById("state-loading").classList.add("d-none");
|
||||||
|
document.getElementById("state-error").classList.remove("d-none");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user