From 0e5895bdc4363cff6aced452e6ef964e8e0c8b50 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sun, 10 May 2026 17:44:21 +0300 Subject: [PATCH] feat(infra): Python FastAPI backend + Docker compose for VPS deploy (GigaChat with Russian root CA) --- backend-py/.dockerignore | 6 + backend-py/Dockerfile | 28 +++ backend-py/app/__init__.py | 0 backend-py/app/ai.py | 152 ++++++++++++++ backend-py/app/auth.py | 52 +++++ backend-py/app/config.py | 42 ++++ backend-py/app/main.py | 401 ++++++++++++++++++++++++++++++++++++ backend-py/app/sheets.py | 214 +++++++++++++++++++ backend-py/app/telegram.py | 28 +++ backend-py/requirements.txt | 7 + bot/.dockerignore | 6 + bot/Dockerfile | 17 ++ deploy/.env.example | 27 +++ deploy/Caddyfile.snippet | 20 ++ deploy/docker-compose.yml | 46 +++++ 15 files changed, 1046 insertions(+) create mode 100644 backend-py/.dockerignore create mode 100644 backend-py/Dockerfile create mode 100644 backend-py/app/__init__.py create mode 100644 backend-py/app/ai.py create mode 100644 backend-py/app/auth.py create mode 100644 backend-py/app/config.py create mode 100644 backend-py/app/main.py create mode 100644 backend-py/app/sheets.py create mode 100644 backend-py/app/telegram.py create mode 100644 backend-py/requirements.txt create mode 100644 bot/.dockerignore create mode 100644 bot/Dockerfile create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile.snippet create mode 100644 deploy/docker-compose.yml diff --git a/backend-py/.dockerignore b/backend-py/.dockerignore new file mode 100644 index 0000000..efed36a --- /dev/null +++ b/backend-py/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.env +.env.local +credentials.json +.venv/ diff --git a/backend-py/Dockerfile b/backend-py/Dockerfile new file mode 100644 index 0000000..ffdf71e --- /dev/null +++ b/backend-py/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.12-slim + +# НУЦ Минцифры root CA — для GigaChat SSL. +# Скачиваем актуальный bundle на этапе сборки и добавляем в системный trust store. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL -o /usr/local/share/ca-certificates/russian_trusted_root_ca.crt \ + https://gu-st.ru/content/Other/doc/russian_trusted_root_ca.cer \ + && curl -fsSL -o /usr/local/share/ca-certificates/russian_trusted_sub_ca.crt \ + https://gu-st.ru/content/Other/doc/russian_trusted_sub_ca.cer \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app /app/app + +# httpx по умолчанию использует certifi → принудительно указываем системный bundle, +# куда мы добавили НУЦ Минцифры +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/backend-py/app/__init__.py b/backend-py/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend-py/app/ai.py b/backend-py/app/ai.py new file mode 100644 index 0000000..0196e06 --- /dev/null +++ b/backend-py/app/ai.py @@ -0,0 +1,152 @@ +"""GigaChat client — OAuth + chat completions.""" +from __future__ import annotations +import json +import re +import threading +import time +import uuid +from typing import Any +import httpx + +from .config import get_config + +_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" +_CHAT_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions" + +_lock = threading.Lock() +_token: str | None = None +_token_expires_at: float = 0.0 + + +def _get_token() -> str: + global _token, _token_expires_at + with _lock: + # 5-минутный запас перед истечением + if _token and time.time() < _token_expires_at - 300: + return _token + + cfg = get_config() + rq_uid = str(uuid.uuid4()) + with httpx.Client(timeout=15.0) as client: + resp = client.post( + _AUTH_URL, + headers={ + "Authorization": f"Basic {cfg.gigachat_auth_key}", + "RqUID": rq_uid, + "Accept": "application/json", + }, + data={"scope": cfg.gigachat_scope}, + ) + resp.raise_for_status() + data = resp.json() + _token = data.get("access_token") or data.get("tok") + if not _token: + raise RuntimeError(f"No access_token in GigaChat response: {data}") + # expires_at в миллисекундах unix + expires_at_ms = data.get("expires_at") or data.get("exp") or 0 + _token_expires_at = (expires_at_ms / 1000) if expires_at_ms else (time.time() + 1500) + return _token + + +SYSTEM_PROMPT_PICKER = ( + "Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n" + "Помогаешь менеджерам салонов согласовать с клиентом комплект техники.\n\n" + "Принципы подбора:\n" + "1. Уважай ценовой коридор. У каждой категории `price_ranges.{cat}.from..to` — попадай в него (±5%).\n" + "2. Уважай предпочтения по брендам: сначала preferred (★), потом acceptable (✓).\n" + "3. Учитывай инфраструктуру: газ исключает индукцию; нет вентиляции = только рециркуляция (угольный фильтр).\n" + "4. Учитывай приоритеты выбора (`priorities`): «цена/качество» → балансные модели; «отзывы» → проверенные хиты; «дизайн» → подбирай эстетику; «технологичность» → топовые фичи.\n" + "5. Если клиент явно отметил features в `per_cat.{cat}.features` — обязательно ставь модели с этими фичами.\n" + "6. ВАЖНО: каждую тех. фичу в highlights ОБЯЗАТЕЛЬНО объясняй простым языком в скобках.\n\n" + "Примеры пояснений:\n" + " «NoFrost (не нужно размораживать вручную)»\n" + " «PowerBoost (форсаж — кипятит за минуту)»\n" + " «FlexZone (объединяет зоны под большую сковороду)»\n" + " «4D HotAir (конвекция с 4 сторон — равномерное запекание)»\n" + " «Термощуп (готовит до точной температуры)»\n" + " «AquaStop (защита от протечек)»\n" + " «Инвертор (тише и экономия ~30% электричества)»\n\n" + "Формат ответа — валидный JSON без markdown:\n" + "{\n" + ' "summary": "1-2 предложения общего вывода",\n' + ' "items": [{\n' + ' "category": "fridge",\n' + ' "brand": "Bosch",\n' + ' "model": "Serie 4 60см",\n' + ' "price_rub": 79990,\n' + ' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n' + ' "caveats": "Глубина 660мм — на 60мм больше стандартной ниши",\n' + ' "match_score": 0.92,\n' + ' "tier_signal": "middle"\n' + " }],\n" + ' "total_price_rub": 350000,\n' + ' "budget_status": "в_рамках|превышение|значительно_ниже",\n' + ' "client_temperature": "premium|middle|budget|mixed",\n' + ' "warnings": [],\n' + ' "next_steps": []\n' + "}\n\n" + "Не выдумывай несуществующие артикулы — указывай линейку (Bosch Serie 4 60см)." +) + + +def call_ai(user_prompt: str, system_prompt: str | None = None, + temperature: float = 0.3, max_tokens: int = 4000) -> dict[str, Any]: + """Вызов GigaChat. Возвращает {json, text, tokens, model, error?}.""" + cfg = get_config() + try: + token = _get_token() + except Exception as e: + return {"json": None, "text": f"AI auth: {e}", "tokens": 0, "model": cfg.gigachat_model, "error": True} + + payload = { + "model": cfg.gigachat_model, + "temperature": temperature, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system_prompt or SYSTEM_PROMPT_PICKER}, + {"role": "user", "content": user_prompt}, + ], + } + + try: + with httpx.Client(timeout=60.0) as client: + resp = client.post( + _CHAT_URL, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/json", + }, + content=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + ) + except Exception as e: + return {"json": None, "text": f"AI network: {e}", "tokens": 0, "model": cfg.gigachat_model, "error": True} + + if resp.status_code >= 400: + try: + j = resp.json() + err_msg = j.get("message") or (j.get("error") or {}).get("message") or resp.text[:300] + except Exception: + err_msg = resp.text[:300] + return {"json": None, "text": f"AI HTTP {resp.status_code}: {err_msg}", + "tokens": 0, "model": cfg.gigachat_model, "error": True} + + data = resp.json() + choice = (data.get("choices") or [{}])[0] + response_text = (choice.get("message") or {}).get("content", "") + tokens = (data.get("usage") or {}).get("total_tokens", 0) + actual_model = data.get("model", cfg.gigachat_model) + + json_obj = None + if response_text: + try: + json_obj = json.loads(response_text) + except json.JSONDecodeError: + stripped = re.sub(r"^```(?:json)?\s*", "", response_text.strip()) + stripped = re.sub(r"\s*```\s*$", "", stripped) + try: + json_obj = json.loads(stripped) + except json.JSONDecodeError: + pass + + return {"json": json_obj, "text": response_text, "tokens": tokens, "model": actual_model} diff --git a/backend-py/app/auth.py b/backend-py/app/auth.py new file mode 100644 index 0000000..edfb606 --- /dev/null +++ b/backend-py/app/auth.py @@ -0,0 +1,52 @@ +"""Telegram WebApp initData verification (HMAC-SHA-256).""" +from __future__ import annotations +import hmac +import hashlib +import json +import time +from typing import Any +from urllib.parse import parse_qsl + + +def verify_init_data(init_data: str, bot_token: str, max_age_sec: int = 86400) -> dict[str, Any] | None: + """ + Проверяет подпись initData от Telegram WebApp. + Возвращает распарсенные данные с ключом 'user' (dict) или None при невалидной/просроченной подписи. + + Спецификация: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + """ + if not init_data: + return None + parsed = dict(parse_qsl(init_data, keep_blank_values=True)) + received_hash = parsed.pop("hash", None) + if not received_hash: + return None + + # data_check_string: ключ=значение, отсортированы алфавитно, разделитель \n + data_check_string = "\n".join(f"{k}={parsed[k]}" for k in sorted(parsed)) + + # secret_key = HMAC-SHA-256(key="WebAppData", data=BOT_TOKEN) + secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest() + expected_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() + + if not hmac.compare_digest(expected_hash, received_hash): + return None + + # Свежесть подписи (24 часа по умолчанию) + auth_date = int(parsed.get("auth_date", "0")) + if time.time() - auth_date > max_age_sec: + return None + + user = None + if "user" in parsed: + try: + user = json.loads(parsed["user"]) + except json.JSONDecodeError: + user = None + + return { + "user": user, + "auth_date": auth_date, + "start_param": parsed.get("start_param"), + "chat_instance": parsed.get("chat_instance"), + } diff --git a/backend-py/app/config.py b/backend-py/app/config.py new file mode 100644 index 0000000..f31f4e6 --- /dev/null +++ b/backend-py/app/config.py @@ -0,0 +1,42 @@ +"""Конфиг бэкенда — читается из переменных окружения.""" +from __future__ import annotations +import os +from dataclasses import dataclass +from functools import lru_cache + + +@dataclass(frozen=True) +class Config: + bot_token: str + admin_tg_id: int + sheet_id: str + google_credentials_path: str + + gigachat_auth_key: str + gigachat_model: str + gigachat_scope: str + + active_period_days: int + grace_period_days: int + + +def _required(name: str) -> str: + val = os.getenv(name) + if not val: + raise RuntimeError(f"Missing required env var: {name}") + return val + + +@lru_cache(maxsize=1) +def get_config() -> Config: + return Config( + bot_token=_required("BOT_TOKEN"), + admin_tg_id=int(os.getenv("ADMIN_TG_ID", "0")), + sheet_id=_required("SHEET_ID"), + google_credentials_path=os.getenv("GOOGLE_CREDENTIALS_PATH", "/app/credentials.json"), + gigachat_auth_key=_required("GIGACHAT_AUTH_KEY"), + gigachat_model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"), + gigachat_scope=os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_PERS"), + active_period_days=int(os.getenv("ACTIVE_PERIOD_DAYS", "90")), + grace_period_days=int(os.getenv("GRACE_PERIOD_DAYS", "14")), + ) diff --git a/backend-py/app/main.py b/backend-py/app/main.py new file mode 100644 index 0000000..b80d16c --- /dev/null +++ b/backend-py/app/main.py @@ -0,0 +1,401 @@ +"""ЗОВ Backend — FastAPI app. Полный порт Apps Script Code.gs.""" +from __future__ import annotations +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Any +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from .config import get_config +from .auth import verify_init_data +from . import sheets, ai, telegram as tg + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") +log = logging.getLogger("zov.backend") + +app = FastAPI(title="ZOV Backend", version="2.0") + +# CORS — MiniApp хостится на github.io, бэкенд на api.wasrusgen1.pro. +# Простые запросы (text/plain или без Content-Type) не триггерят preflight. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], +) + + +# ================================================================= +# Health & ping +# ================================================================= + +@app.get("/healthz") +async def healthz(): + return {"ok": True, "service": "zov-tech-backend", "time": _now_iso()} + + +@app.get("/") +async def root(): + return { + "status": "ok", + "service": "zov-tech-backend", + "version": "2.0", + "available_paths": ["me", "measurement", "podbor", "ping", "test_ai", "test_telegram", "seed_admin"], + } + + +# ================================================================= +# Compatibility layer with Apps Script (?path=X) +# ================================================================= + +@app.post("/") +async def root_post(request: Request): + return await _dispatch_post(request) + + +@app.api_route("/exec", methods=["GET", "POST"]) +async def apps_script_compat(request: Request): + """Эмулируем поведение `/exec?path=X` чтобы старый MiniApp-код тоже работал.""" + if request.method == "GET": + path = request.query_params.get("path", "") + if path == "ping": + return {"pong": True, "time": _now_iso()} + if path == "seed_admin": + return JSONResponse(_handle_seed_admin()) + if path == "test_ai" or path == "test_claude": + return JSONResponse(_handle_test_ai()) + if path == "test_telegram": + return JSONResponse(_handle_test_telegram()) + return {"status": "ok", "service": "zov-tech-backend"} + return await _dispatch_post(request) + + +async def _dispatch_post(request: Request): + path = request.query_params.get("path", "") + try: + body = await request.json() + except Exception: + body = {} + + handlers = { + "me": _handle_me, + "measurement": _handle_measurement, + "podbor": _handle_podbor, + "ping": lambda b: {"pong": True, "time": _now_iso()}, + "seed_admin": lambda b: _handle_seed_admin(), + "test_ai": lambda b: _handle_test_ai(), + "test_claude": lambda b: _handle_test_ai(), + "test_telegram": lambda b: _handle_test_telegram(), + } + fn = handlers.get(path) + if not fn: + return JSONResponse({"error": "unknown_path", "path": path}, status_code=404) + + try: + return JSONResponse(fn(body)) + except Exception as e: + log.exception("api error on path=%s", path) + sheets.log_event("api_error", None, {"path": path, "error": str(e)}) + return JSONResponse({"error": str(e)}, status_code=500) + + +# ================================================================= +# Native /api/* routes (preferred for new MiniApp) +# ================================================================= + +@app.post("/api/me") +async def api_me(request: Request): + body = await _safe_json(request) + return _handle_me(body) + + +@app.post("/api/measurement") +async def api_measurement(request: Request): + body = await _safe_json(request) + return _handle_measurement(body) + + +@app.post("/api/podbor") +async def api_podbor(request: Request): + body = await _safe_json(request) + return _handle_podbor(body) + + +@app.get("/api/test_ai") +async def api_test_ai(): + return _handle_test_ai() + + +@app.get("/api/test_telegram") +async def api_test_telegram(): + return _handle_test_telegram() + + +@app.get("/api/seed_admin") +async def api_seed_admin(): + return _handle_seed_admin() + + +# ================================================================= +# Handlers +# ================================================================= + +def _handle_me(body: dict[str, Any]) -> dict[str, Any]: + cfg = get_config() + init_data = body.get("initData") or "" + auth = verify_init_data(init_data, cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + + tg_user = auth["user"] + tg_id = tg_user["id"] + start_param = body.get("startParam") or auth.get("start_param") + explicit_role = body.get("role") if body.get("role") in ("manager", "client") else None + user = sheets.get_or_create_user(tg_user, start_param, explicit_role) + + if user.get("role") == "manager": + m = sheets.get_manager_profile(tg_id) or { + "full_name": user.get("full_name", ""), "salon": "", + "is_zov_employee": False, "status": "lapsed", "active_until": None, + } + return { + "role": "manager", + "user": { + "tg_id": tg_id, + "full_name": m.get("full_name") or user.get("full_name", ""), + "salon": m.get("salon", ""), + "avatar_initial": _initial(m.get("full_name") or tg_user.get("first_name", "")), + }, + "status": m.get("status", "lapsed"), + "status_until": _format_date(m.get("active_until")), + } + + # client + c = sheets.get_client_profile(tg_id) or {} + manager = None + mgr_id = c.get("manager_tg_id") + if mgr_id: + try: + mgr_id_int = int(mgr_id) + mp = sheets.get_manager_profile(mgr_id_int) + if mp: + manager = {"full_name": mp.get("full_name"), "salon": mp.get("salon")} + except (TypeError, ValueError): + pass + + full_name = c.get("full_name") or user.get("full_name", "") + return { + "role": "client", + "user": { + "tg_id": tg_id, + "full_name": full_name, + "avatar_initial": _initial(full_name or tg_user.get("first_name", "")), + }, + "manager": manager, + } + + +def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + + m = body.get("measurement") or {} + measurement_id = _short_id() + filled_by = "manager_for_client" if user.get("role") == "manager" else "client_self" + + client_tg_id = m.get("client_tg_id") if user.get("role") == "manager" else tg_id + manager_tg_id = tg_id if user.get("role") == "manager" else ( + sheets.find_row("Clients", "tg_id", tg_id) or {} + ).get("manager_tg_id", "") + + sheets.append_row("Measurements", [ + measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "", + filled_by, + m.get("layout", ""), m.get("area_m2", ""), m.get("ceiling_mm", ""), + json.dumps(m.get("walls") or {}, ensure_ascii=False), + json.dumps(m.get("openings") or {}, ensure_ascii=False), + json.dumps(m.get("infra") or {}, ensure_ascii=False), + json.dumps(m.get("niches") or {}, ensure_ascii=False), + ",".join(m.get("photos") or []), + m.get("notes", ""), + "submitted", + ]) + + if client_tg_id: + sheets.update_cell_by_key("Clients", "tg_id", client_tg_id, "last_measurement_id", measurement_id) + + if filled_by == "client_self" and manager_tg_id: + tg.send_message( + manager_tg_id, + f"📐 Новый замер от клиента {user.get('full_name') or tg_id}.\n" + f"Площадь: {m.get('area_m2', '?')} м², форма: {m.get('layout', '?')}.\n" + f"Открыть в кабинете для просмотра." + ) + + sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by}) + return {"ok": True, "id": measurement_id} + + +def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]: + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + if user.get("role") != "manager": + return {"error": "only_manager_can_request_podbor"} + + checklist = body.get("checklist") or {} + client_name = body.get("client_name", "") + client_tg_id = body.get("client_tg_id", "") + measurement_id = body.get("measurement_id", "") + lead_id = _short_id() + + sheets.append_row("Leads", [ + lead_id, _now_iso(), tg_id, client_tg_id, client_name, measurement_id, + json.dumps(checklist, ensure_ascii=False), + "", "", 0, False, "new", 0, + ]) + + user_prompt = ( + f"Подбери технику для следующего клиента:\n\n" + f"{json.dumps({'client': {'name': client_name}, 'checklist': checklist}, ensure_ascii=False, indent=2)}" + ) + ai_result = ai.call_ai(user_prompt) + + # Update lead row with AI response + sheets.update_cell_by_key("Leads", "id", lead_id, "ai_response", + json.dumps(ai_result.get("json") or ai_result.get("text", ""), ensure_ascii=False)) + sheets.update_cell_by_key("Leads", "id", lead_id, "ai_model", ai_result.get("model", "")) + sheets.update_cell_by_key("Leads", "id", lead_id, "ai_tokens_used", ai_result.get("tokens", 0)) + sheets.update_cell_by_key("Leads", "id", lead_id, "sent_to_tg", True) + + summary_text = _format_podbor_for_telegram(ai_result, client_name) + tg.send_message(tg_id, summary_text) + + sheets.log_event("podbor_completed", tg_id, {"id": lead_id, "tokens": ai_result.get("tokens", 0)}) + return {"ok": True, "id": lead_id, "summary": summary_text} + + +def _handle_test_ai() -> dict[str, Any]: + cfg = get_config() + res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?", + system_prompt="Ты — кратко и по делу отвечаешь. Без markdown.") + return { + "ok": not res.get("error"), + "provider": "GigaChat", + "model": res.get("model", cfg.gigachat_model), + "response_text": (res.get("text") or "")[:500], + "tokens": res.get("tokens", 0), + } + + +def _handle_test_telegram() -> dict[str, Any]: + cfg = get_config() + ok = tg.send_message( + cfg.admin_tg_id, + "🟢 Привет из Python-бэкенда на VPS! Связка backend↔бот работает.", + ) + return {"ok": ok, "sent_to": cfg.admin_tg_id} + + +def _handle_seed_admin() -> dict[str, Any]: + cfg = get_config() + admin_id = cfg.admin_tg_id + if sheets.find_row("Managers", "tg_id", admin_id): + return {"ok": True, "status": "already_seeded", "admin_id": admin_id} + sheets.append_row("Managers", [ + admin_id, "Руслан Васильев", "vasrusgen@gmail.com", "", + "ЗОВ — куратор сети", "Санкт-Петербург", + True, "active", "", "", 0, 0, 0, "MGR_ADMIN", + ]) + if not sheets.find_user(admin_id): + sheets.append_row("Users", [ + admin_id, "VASRUSGEN", "Руслан", "Васильев", "manager", + _now_iso(), _now_iso(), "", + ]) + return {"ok": True, "status": "seeded", "admin_id": admin_id, "full_name": "Руслан Васильев"} + + +# ================================================================= +# Helpers +# ================================================================= + +def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str) -> str: + if ai_result.get("error"): + return f"❌ Не удалось получить подбор от AI.\n{ai_result.get('text', '')}" + j = ai_result.get("json") + if not j: + return "Подбор готов\n\n" + (ai_result.get("text") or "")[:3500] + + lines = ["✅ Подбор готов"] + if client_name: + lines.append(f"Клиент: {client_name}") + lines.append("") + if j.get("summary"): + lines.append(j["summary"]) + lines.append("") + + for item in (j.get("items") or []): + lines.append(f"{item.get('brand', '')} {item.get('model', '')}") + if item.get("price_rub"): + lines.append(f"💰 {_format_price(item['price_rub'])} ₽") + if item.get("highlights"): + lines.append("✓ " + ", ".join(item["highlights"])) + if item.get("caveats"): + lines.append(f"⚠️ {item['caveats']}") + lines.append("") + + if j.get("total_price_rub"): + lines.append(f"ИТОГО: {_format_price(j['total_price_rub'])} ₽ · {j.get('budget_status', '')}") + if j.get("warnings"): + lines.append("\n⚠️ " + "; ".join(j["warnings"])) + return "\n".join(lines) + + +def _format_price(n: int | float) -> str: + if n is None: + return "—" + s = str(int(round(float(n)))) + # Разделители тысяч пробелом + return " ".join([s[max(0, len(s) - 3 * (i + 1)):len(s) - 3 * i] for i in range((len(s) + 2) // 3)][::-1]).strip() + + +def _initial(name: str) -> str: + return ((name or "").strip()[:1] or "?").upper() + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _format_date(d) -> str | None: + if not d: + return None + if isinstance(d, datetime): + return d.strftime("%d.%m.%Y") + return str(d) + + +def _short_id() -> str: + return uuid.uuid4().hex[:13] + + +async def _safe_json(request: Request) -> dict[str, Any]: + try: + return await request.json() + except Exception: + return {} diff --git a/backend-py/app/sheets.py b/backend-py/app/sheets.py new file mode 100644 index 0000000..b9f0cc9 --- /dev/null +++ b/backend-py/app/sheets.py @@ -0,0 +1,214 @@ +"""Тонкая обёртка над Google Sheets через gspread + service account.""" +from __future__ import annotations +import threading +from datetime import datetime, timedelta, timezone +from typing import Any +import gspread +from google.oauth2.service_account import Credentials + +from .config import get_config + +_SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] +_lock = threading.Lock() +_client: gspread.Client | None = None +_book: gspread.Spreadsheet | None = None + + +def _client_book() -> tuple[gspread.Client, gspread.Spreadsheet]: + global _client, _book + with _lock: + if _client is None: + cfg = get_config() + creds = Credentials.from_service_account_file(cfg.google_credentials_path, scopes=_SCOPES) + _client = gspread.authorize(creds) + _book = _client.open_by_key(cfg.sheet_id) + return _client, _book # type: ignore + + +def sheet(name: str) -> gspread.Worksheet: + _, book = _client_book() + return book.worksheet(name) + + +def append_row(name: str, row: list[Any]) -> None: + sheet(name).append_row(row, value_input_option="USER_ENTERED") + + +def find_row(sheet_name: str, key_col: str, key_val: Any) -> dict[str, Any] | None: + """Линейный поиск по колонке-ключу. Возвращает строку как dict или None.""" + s = sheet(sheet_name) + rows = s.get_all_values() + if not rows: + return None + headers = rows[0] + if key_col not in headers: + return None + idx = headers.index(key_col) + for r in rows[1:]: + if len(r) > idx and str(r[idx]).strip() == str(key_val).strip(): + return dict(zip(headers, r + [""] * (len(headers) - len(r)))) + return None + + +def update_cell_by_key(sheet_name: str, key_col: str, key_val: Any, target_col: str, new_val: Any) -> bool: + s = sheet(sheet_name) + rows = s.get_all_values() + if not rows: + return False + headers = rows[0] + if key_col not in headers or target_col not in headers: + return False + key_idx = headers.index(key_col) + target_idx = headers.index(target_col) + for i, r in enumerate(rows[1:], start=2): + if len(r) > key_idx and str(r[key_idx]).strip() == str(key_val).strip(): + s.update_cell(i, target_idx + 1, new_val) + return True + return False + + +def get_setting(key: str) -> str | None: + row = find_row("Settings", "key", key) + return (row or {}).get("value") + + +# === Доменные хелперы === + +def find_user(tg_id: int) -> dict[str, Any] | None: + if not tg_id: + return None + row = find_row("Users", "tg_id", tg_id) + if not row: + return None + full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() + or row.get("tg_username", "")) + return {**row, "full_name": full_name} + + +def get_or_create_user(tg_user: dict[str, Any], start_param: str | None, + explicit_role: str | None = None) -> dict[str, Any]: + cfg = get_config() + tg_id = tg_user["id"] + admin_id = cfg.admin_tg_id + + existing = find_user(tg_id) + now = _now() + + if existing: + update_cell_by_key("Users", "tg_id", tg_id, "last_seen_at", now) + # Админ всегда manager + if tg_id == admin_id and existing.get("role") != "manager": + update_cell_by_key("Users", "tg_id", tg_id, "role", "manager") + ensure_admin_manager(tg_user) + existing["role"] = "manager" + elif explicit_role and tg_id != admin_id and existing.get("role") != explicit_role: + update_cell_by_key("Users", "tg_id", tg_id, "role", explicit_role) + existing["role"] = explicit_role + return existing + + # Новый пользователь + role = "client" + invite_code = "" + if tg_id == admin_id: + role = "manager" + elif explicit_role in ("manager", "client"): + role = explicit_role + elif start_param and start_param.startswith("client_inv_"): + role = "client" + invite_code = start_param + + append_row("Users", [ + tg_id, + tg_user.get("username", ""), + tg_user.get("first_name", ""), + tg_user.get("last_name", ""), + role, + now, + now, + invite_code, + ]) + if tg_id == admin_id: + ensure_admin_manager(tg_user) + return find_user(tg_id) or {} + + +def ensure_admin_manager(tg_user: dict[str, Any]) -> None: + tg_id = tg_user["id"] + if find_row("Managers", "tg_id", tg_id): + return + full_name = (f"{tg_user.get('first_name', '')} {tg_user.get('last_name', '')}".strip() + or tg_user.get("username", "") or str(tg_id)) + append_row("Managers", [ + tg_id, full_name, "vasrusgen@gmail.com", "", + "ЗОВ — куратор сети", "Санкт-Петербург", + True, "active", "", "", 0, 0, 0, "MGR_ADMIN", + ]) + + +def get_manager_profile(tg_id: int) -> dict[str, Any] | None: + cfg = get_config() + row = find_row("Managers", "tg_id", tg_id) + if not row: + return None + is_zov = str(row.get("is_zov_employee", "")).lower() in ("true", "1", "да", "yes") + + last_order = _parse_date(row.get("last_order_date")) + active_period = int(get_setting("ACTIVE_PERIOD_DAYS") or cfg.active_period_days) + grace_period = int(get_setting("GRACE_PERIOD_DAYS") or cfg.grace_period_days) + + active_until = None + status = "lapsed" + if is_zov: + status = "active" + elif last_order: + active_until = last_order + timedelta(days=active_period) + grace_until = active_until + timedelta(days=grace_period) + now = _now() + if now <= active_until: + status = "active" + elif now <= grace_until: + status = "grace" + else: + status = "lapsed" + + return { + **row, + "is_zov_employee": is_zov, + "active_until": active_until, + "status": status, + } + + +def get_client_profile(tg_id: int) -> dict[str, Any] | None: + return find_row("Clients", "tg_id", tg_id) + + +def log_event(event: str, tg_id: int | None, payload: dict[str, Any] | None = None) -> None: + import json + try: + append_row("Logs", [ + _now(), event, tg_id or "", + json.dumps(payload, ensure_ascii=False) if payload else "", + ]) + except Exception: + pass + + +def _now() -> datetime: + return datetime.now(timezone.utc).astimezone() + + +def _parse_date(v: Any) -> datetime | None: + if not v: + return None + if isinstance(v, datetime): + return v + s = str(v).strip() + if not s: + return None + for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%d.%m.%Y %H:%M:%S"): + try: + return datetime.strptime(s, fmt) + except ValueError: + continue + return None diff --git a/backend-py/app/telegram.py b/backend-py/app/telegram.py new file mode 100644 index 0000000..55f442e --- /dev/null +++ b/backend-py/app/telegram.py @@ -0,0 +1,28 @@ +"""Тонкая обёртка над Telegram Bot API для отправки уведомлений из backend.""" +from __future__ import annotations +import httpx + +from .config import get_config + + +def send_message(chat_id: int | str, text: str, **kwargs) -> bool: + """Отправляет сообщение пользователю/чату. Возвращает True при успехе.""" + cfg = get_config() + if not cfg.bot_token or not chat_id: + return False + payload = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, + } + payload.update(kwargs) + try: + with httpx.Client(timeout=15.0) as client: + resp = client.post( + f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage", + json=payload, + ) + return 200 <= resp.status_code < 300 + except Exception: + return False diff --git a/backend-py/requirements.txt b/backend-py/requirements.txt new file mode 100644 index 0000000..44ee7de --- /dev/null +++ b/backend-py/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +pydantic>=2.9 +httpx>=0.27.0 +gspread>=6.0.0 +google-auth>=2.30.0 +python-dotenv>=1.0.0 diff --git a/bot/.dockerignore b/bot/.dockerignore new file mode 100644 index 0000000..1ff9326 --- /dev/null +++ b/bot/.dockerignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.env +.env.local +credentials.json diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..7029cd5 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app + +ENV PYTHONIOENCODING=utf-8 +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "main.py"] diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..071339e --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,27 @@ +# ============================================================ +# Скопируйте в /opt/zov-tech/deploy/.env на сервере и заполните. +# Не коммитить! +# ============================================================ + +# Telegram bot +BOT_TOKEN=8281503057:AAEXmOepY8quH8E3RqOjFbgn7owV1ngnbGA +ADMIN_TG_ID=5937498515 + +# GigaChat (от Сбера) +GIGACHAT_AUTH_KEY=MDE5ZTExY2ItNDgzZi03ZWY4LTk2YjctZjAxNzQ4ZWEwNmVkOmQ1Mzc0OWVlLWUyYjItNDg2Zi04NTk1LWRmNmNlYzQ5M2JjMw== +GIGACHAT_MODEL=GigaChat-Pro +GIGACHAT_SCOPE=GIGACHAT_API_PERS + +# Google Sheet (БД) +SHEET_ID=1vAB3u4iOz45awVLp5Pc1X-y5NzPbMugTlagplVdSiR8 +GOOGLE_CREDENTIALS_PATH=/app/credentials.json + +# MiniApp (используется ботом) +MINIAPP_URL=https://wasrusgen.github.io/zov-tech/ + +# Бэкенд URL для бота (внутри Docker — имя сервиса) +BACKEND_URL=http://backend:8000 + +# Бизнес-правила +ACTIVE_PERIOD_DAYS=90 +GRACE_PERIOD_DAYS=14 diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet new file mode 100644 index 0000000..78e8c0d --- /dev/null +++ b/deploy/Caddyfile.snippet @@ -0,0 +1,20 @@ +# Snippet добавляется в /opt/furniture/deploy/Caddyfile. +# Caddy автоматически получает SSL для api.wasrusgen1.pro через Let's Encrypt. + +api.wasrusgen1.pro { + reverse_proxy zov-backend:8000 + + encode zstd gzip + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + log { + output file /data/zov-access.log { + roll_size 10mb + roll_keep 5 + } + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..c044e9e --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,46 @@ +services: + backend: + build: + context: ../backend-py + dockerfile: Dockerfile + image: zov-tech-backend:latest + container_name: zov-backend + restart: unless-stopped + env_file: + - .env + volumes: + - ./credentials.json:/app/credentials.json:ro + networks: + - web # внешняя сеть от deploy-стека (Caddy там) + - internal + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3); sys.exit(0 if r.status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + bot: + build: + context: ../bot + dockerfile: Dockerfile + image: zov-tech-bot:latest + container_name: zov-bot + restart: unless-stopped + env_file: + - .env + networks: + - internal + depends_on: + backend: + condition: service_healthy + +networks: + # Использует уже существующую сеть от furniture-deploy stack — там Caddy + web: + name: deploy_web + external: true + internal: + driver: bridge