mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 14:04:48 +00:00
feat(infra): Python FastAPI backend + Docker compose for VPS deploy (GigaChat with Russian root CA)
This commit is contained in:
parent
01aa47773e
commit
0e5895bdc4
6
backend-py/.dockerignore
Normal file
6
backend-py/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.local
|
||||
credentials.json
|
||||
.venv/
|
||||
28
backend-py/Dockerfile
Normal file
28
backend-py/Dockerfile
Normal file
@ -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=*"]
|
||||
0
backend-py/app/__init__.py
Normal file
0
backend-py/app/__init__.py
Normal file
152
backend-py/app/ai.py
Normal file
152
backend-py/app/ai.py
Normal file
@ -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}
|
||||
52
backend-py/app/auth.py
Normal file
52
backend-py/app/auth.py
Normal file
@ -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"),
|
||||
}
|
||||
42
backend-py/app/config.py
Normal file
42
backend-py/app/config.py
Normal file
@ -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")),
|
||||
)
|
||||
401
backend-py/app/main.py
Normal file
401
backend-py/app/main.py
Normal file
@ -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"📐 Новый замер от клиента <b>{user.get('full_name') or tg_id}</b>.\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 "<b>Подбор готов</b>\n\n" + (ai_result.get("text") or "")[:3500]
|
||||
|
||||
lines = ["✅ <b>Подбор готов</b>"]
|
||||
if client_name:
|
||||
lines.append(f"Клиент: <b>{client_name}</b>")
|
||||
lines.append("")
|
||||
if j.get("summary"):
|
||||
lines.append(j["summary"])
|
||||
lines.append("")
|
||||
|
||||
for item in (j.get("items") or []):
|
||||
lines.append(f"<b>{item.get('brand', '')} {item.get('model', '')}</b>")
|
||||
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"<b>ИТОГО: {_format_price(j['total_price_rub'])} ₽</b> · {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 {}
|
||||
214
backend-py/app/sheets.py
Normal file
214
backend-py/app/sheets.py
Normal file
@ -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
|
||||
28
backend-py/app/telegram.py
Normal file
28
backend-py/app/telegram.py
Normal file
@ -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
|
||||
7
backend-py/requirements.txt
Normal file
7
backend-py/requirements.txt
Normal file
@ -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
|
||||
6
bot/.dockerignore
Normal file
6
bot/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.local
|
||||
credentials.json
|
||||
17
bot/Dockerfile
Normal file
17
bot/Dockerfile
Normal file
@ -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"]
|
||||
27
deploy/.env.example
Normal file
27
deploy/.env.example
Normal file
@ -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
|
||||
20
deploy/Caddyfile.snippet
Normal file
20
deploy/Caddyfile.snippet
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
46
deploy/docker-compose.yml
Normal file
46
deploy/docker-compose.yml
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user