diff --git a/bff_models.py b/bff_models.py index 0e4d3d8..54d26e0 100644 --- a/bff_models.py +++ b/bff_models.py @@ -144,6 +144,7 @@ class CompanyProfileResponse(BaseModel): name: str key: str commission: float + agent_commission: float class AccountProfileResponse(BaseModel): firstName: Optional[str] = None @@ -186,4 +187,4 @@ class IntegrationTokenCreateRequest(BaseModel): class IntegrationTokenUpdateRequest(BaseModel): id: int - description: str \ No newline at end of file + description: str diff --git a/call_sale_api.py b/call_sale_api.py new file mode 100644 index 0000000..3157e2f --- /dev/null +++ b/call_sale_api.py @@ -0,0 +1,60 @@ +import requests +import json + +# Конфигурация API +BASE_URL = "http://127.0.0.1:8001" +API_KEY = "7c06945b-f4b8-4929-8350-b9841405a609" +REF = "8c514fcb-7a79-4ed1-9c8a-0af2fcab88c0" + +# Данные для запроса на создание продажи +# Замените эти значения на актуальные для вашей продажи +sale_data = { + "cost": 100.50, # Стоимость продажи + "ref": REF, # Ваш реферальный код + "sale_id": "UNIQUE_SALE_ID_12345" # Уникальный идентификатор продажи для вашей компании +} + +# Эндпоинты +token_endpoint = f"{BASE_URL}/token" +sale_endpoint = f"{BASE_URL}/sale" + +# Шаг 1: Получение JWT токена +print(f"Отправка запроса на получение токена на {token_endpoint}") +token_headers = { + "X-API-Key": API_KEY, + "Content-Type": "application/json" +} + +try: + token_response = requests.post(token_endpoint, headers=token_headers) + token_response.raise_for_status() + + token_data = token_response.json() + jwt_token = token_data["access_token"] + print("JWT токен успешно получен.") + +except requests.exceptions.RequestException as e: + print(f"Произошла ошибка при получении токена: {e}") + if hasattr(e, 'response') and e.response is not None: + print("Тело ответа с ошибкой:", e.response.json()) + exit() # Прерываем выполнение, если не удалось получить токен + +# Шаг 2: Вызов эндпоинта /sale с использованием полученного JWT токена +headers_with_jwt = { + "Authorization": f"Bearer {jwt_token}", + "Content-Type": "application/json" +} + +print(f"Отправка запроса на {sale_endpoint} с данными: {sale_data}") + +try: + sale_response = requests.post(sale_endpoint, headers=headers_with_jwt, data=json.dumps(sale_data)) + sale_response.raise_for_status() # Вызовет исключение для ошибок HTTP (4xx или 5xx) + + print("Статус ответа:", sale_response.status_code) + print("Тело ответа:", sale_response.json()) + +except requests.exceptions.RequestException as e: + print(f"Произошла ошибка при вызове API sale: {e}") + if hasattr(e, 'response') and e.response is not None: + print("Тело ответа с ошибкой:", e.response.json()) \ No newline at end of file diff --git a/fill_db.py b/fill_db.py index 93ea8bd..19e3ef8 100644 --- a/fill_db.py +++ b/fill_db.py @@ -85,6 +85,7 @@ def fill_db(): company = Company( name="RE: Premium", commission=10.0, + agent_commission=15.0, key="re-premium-key", ) session.add(company) @@ -174,7 +175,7 @@ def fill_db(): sale_count = random.randint(20, int(20 * 1.25)) # от 20 до 25 for _ in range(sale_count): cost = round(random.uniform(100, 1000), 2) - crediting = round(cost * random.uniform(0.5, 1.0), 2) + crediting = round(cost * (company.agent_commission / 100.0), 2) # Генерируем случайную дату и время в пределах последних 7 дней end_dttm = datetime.utcnow() diff --git a/helpers_bff.py b/helpers_bff.py index 7717faa..7522117 100644 --- a/helpers_bff.py +++ b/helpers_bff.py @@ -3,12 +3,13 @@ from passlib.context import CryptContext from typing import Optional from datetime import datetime, timedelta from bff_models import Token, TransactionStatus -from sql_models import Company, TgAgent, Account, AgentBalance, AgentTransaction, PartnerTransaction, Sale, Ref +from sql_models import Company, TgAgent, Account, AgentBalance, AgentTransaction, PartnerTransaction, Sale, Ref, IntegrationToken, CompanyBalance from hashlib import sha256 import jwt from jwt.exceptions import InvalidTokenError -from fastapi import HTTPException, status, Depends, Request +from fastapi import HTTPException, status, Depends, Request, Header from fastapi.security import OAuth2PasswordBearer +import hashlib # Конфигурация AUTH_DATABASE_ADDRESS = "sqlite:///partner.db" @@ -18,9 +19,60 @@ SECRET_KEY = "supersecretkey" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 +# JWT Configuration for Integration API +INTEGRATION_SECRET_KEY = "your-super-secret-jwt-key" # TODO: Замените на реальный секретный ключ из переменных окружения +INTEGRATION_ALGORITHM = "HS256" +INTEGRATION_ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # Токен действителен 7 дней + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +def get_integration_db(): + with Session(AUTH_DB_ENGINE) as session: + yield session + +def create_integration_jwt_token(company_id: int): + expires = datetime.utcnow() + timedelta(minutes=INTEGRATION_ACCESS_TOKEN_EXPIRE_MINUTES) + payload = { + "sub": str(company_id), + "exp": expires, + "type": "access" + } + return jwt.encode(payload, INTEGRATION_SECRET_KEY, algorithm=INTEGRATION_ALGORITHM) + +async def get_current_company_from_jwt( + token: str = Depends(OAuth2PasswordBearer(tokenUrl="/token")), + db: Session = Depends(get_integration_db) +): + """ + Зависимость для получения текущей компании на основе JWT токена для Integration API. + """ + try: + payload = jwt.decode(token, INTEGRATION_SECRET_KEY, algorithms=[INTEGRATION_ALGORITHM]) + company_id: int = int(payload.get("sub")) + if company_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительная полезная нагрузка токена", + headers={"WWW-Authenticate": "Bearer"}, + ) + company = db.exec(select(Company).where(Company.id == company_id)).first() + if not company: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Компания не найдена") + return company + except InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен", + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Срок действия токена истек", + headers={"WWW-Authenticate": "Bearer"}, + ) + def get_tg_agent_by_tg_id(db: Session, tg_id: int) -> Optional[TgAgent]: statement = select(TgAgent).where(TgAgent.tg_id == tg_id) return db.exec(statement).first() diff --git a/integration_api.py b/integration_api.py index dd9f7be..c7e60d1 100644 --- a/integration_api.py +++ b/integration_api.py @@ -1,75 +1,169 @@ -from fastapi import FastAPI, HTTPException, status, Depends, Request +from fastapi import FastAPI, Depends, HTTPException, status, Header +from fastapi.security import OAuth2PasswordBearer from sqlmodel import Session, select from typing import Optional -from datetime import datetime, timedelta -import jwt -from jwt.exceptions import InvalidTokenError -from pydantic import BaseModel +from datetime import datetime +import hashlib +import uuid -from sql_models import Sale -from helpers_bff import get_db, AUTH_DB_ENGINE # Assuming these are the correct imports +from sql_models import Company, IntegrationToken, Ref, Sale, AgentTransaction, PartnerTransaction, AgentBalance, TgAgent, CompanyBalance +from integration_models import Token, SaleCreateRequest, SaleCreateResponse, TransactionStatus +from helpers_bff import AUTH_DB_ENGINE, get_integration_db, create_integration_jwt_token, get_current_company_from_jwt app = FastAPI() -# Конфигурация для интеграционного API токена -INTEGRATION_SECRET_KEY = "your-integration-super-secret-key" # Смените это на безопасный ключ! -INTEGRATION_ALGORITHM = "HS256" -INTEGRATION_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа +#7c06945b-f4b8-4929-8350-b9841405a609 -class IntegrationTokenData(BaseModel): - client_id: str -class SaleCreate(BaseModel): - cost: float - crediting: float - ref_id: int - sale_id: str - company_id: int - sale_date: datetime = datetime.utcnow() +@app.post("/token", tags=["integration"], response_model=Token) +async def get_token_for_api_key( + x_api_key: str = Header(..., alias="X-API-Key"), + db: Session = Depends(get_integration_db) +): + """ + Обменивает API-ключ на JWT токен. + """ -def create_integration_access_token(data: dict, expires_delta: timedelta = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=INTEGRATION_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, INTEGRATION_SECRET_KEY, algorithm=INTEGRATION_ALGORITHM) - return encoded_jwt + if not x_api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="API-ключ не предоставлен", + headers={"WWW-Authenticate": "Bearer"}, + ) -async def verify_integration_token(request: Request): - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate integration credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Bearer "): - raise credentials_exception - token = auth_header.replace("Bearer ", "").strip() - try: - payload = jwt.decode(token, INTEGRATION_SECRET_KEY, algorithms=[INTEGRATION_ALGORITHM]) - client_id: str = payload.get("client_id") - if client_id is None: - raise credentials_exception - # Здесь вы можете добавить логику для проверки client_id, например, из базы данных - except InvalidTokenError: - raise credentials_exception - return True # Токен действителен + api_key_hash = hashlib.sha256(x_api_key.encode()).hexdigest() + integration_token_db = db.exec( + select(IntegrationToken).where(IntegrationToken.token_hash == api_key_hash) + ).first() -@app.post("/sale", status_code=status.HTTP_201_CREATED) -async def upload_sale(sale_data: SaleCreate, db: Session = Depends(get_db), verified: bool = Depends(verify_integration_token)): - if not verified: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authorized") + if not integration_token_db: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверный API-ключ", + headers={"WWW-Authenticate": "Bearer"}, + ) - db_sale = Sale(cost=sale_data.cost, crediting=sale_data.crediting, ref=sale_data.ref_id, sale_id=sale_data.sale_id, company_id=sale_data.company_id, sale_date=sale_data.sale_date) - db.add(db_sale) + # Обновляем use_dttm токена + integration_token_db.use_dttm = datetime.utcnow() + db.add(integration_token_db) db.commit() - db.refresh(db_sale) - return {"message": "Sale uploaded successfully", "sale_id": db_sale.id} + db.refresh(integration_token_db) -@app.get("/generate-integration-token") -async def generate_token_endpoint(client_id: str): - token_data = {"client_id": client_id} - token = create_integration_access_token(token_data) - return {"access_token": token, "token_type": "bearer"} \ No newline at end of file + jwt_token = create_integration_jwt_token(integration_token_db.company_id) + return {"access_token": jwt_token, "token_type": "bearer"} + +@app.post("/sale", tags=["integration"], response_model=SaleCreateResponse) +async def create_sale( + req: SaleCreateRequest, + company: Company = Depends(get_current_company_from_jwt), # Используем новую зависимость + db: Session = Depends(get_integration_db) +): + """ + Регистрирует новую продажу в системе. + """ + # 1. Найти Ref по `ref` и `company.id` + # Сначала находим TgAgent, связанный с компанией, затем Ref + tg_agent = db.exec( + select(TgAgent) + .join(Ref) + .where(TgAgent.company_id == company.id) + .where(Ref.ref == req.ref) + ).first() + + if not tg_agent: + raise HTTPException(status_code=404, detail="Реферальная ссылка не найдена или не принадлежит данной компании") + + referral = db.exec(select(Ref).where(Ref.ref == req.ref).where(Ref.tg_agent_id == tg_agent.id)).first() + if not referral: + raise HTTPException(status_code=404, detail="Реферальная ссылка не найдена") + + # 2. Проверить, что sale_id уникален для данной компании + existing_sale = db.exec( + select(Sale) + .where(Sale.company_id == company.id) + .where(Sale.sale_id == req.sale_id) + ).first() + + if existing_sale: + raise HTTPException(status_code=400, detail="Продажа с таким sale_id уже существует для данной компании") + + # 3. Рассчитать crediting + crediting_amount = req.cost * (company.agent_commission / 100.0) + + # 4. Создать Sale + new_sale = Sale( + cost=req.cost, + crediting=crediting_amount, + ref=referral.id, + sale_id=req.sale_id, + company_id=company.id, + sale_dttm=datetime.utcnow() + ) + db.add(new_sale) + db.commit() + db.refresh(new_sale) + + # 5. Обновить AgentBalance и CompanyBalance + # AgentBalance + agent_balance = db.exec(select(AgentBalance).where(AgentBalance.tg_agent_id == tg_agent.id)).first() + if not agent_balance: + agent_balance = AgentBalance(tg_agent_id=tg_agent.id, available_balance=0.0, frozen_balance=0.0) + db.add(agent_balance) + db.commit() + db.refresh(agent_balance) + + # CompanyBalance + company_balance = db.exec(select(CompanyBalance).where(CompanyBalance.company_id == company.id)).first() + if not company_balance: + company_balance = CompanyBalance(company_id=company.id, available_balance=0.0, pending_balance=0.0) + db.add(company_balance) + db.commit() + db.refresh(company_balance) + + # Создать AgentTransaction + agent_transaction_status = TransactionStatus.NEW if company.auto_approve_transactions else TransactionStatus.WAITING + agent_transaction = AgentTransaction( + tg_agent_id=tg_agent.id, + amount=crediting_amount, + status=agent_transaction_status.value, + transaction_group=uuid.uuid4() + ) + db.add(agent_transaction) + db.commit() + db.refresh(agent_transaction) + + # Создать PartnerTransaction + partner_transaction_status = TransactionStatus.NEW if company.auto_approve_transactions else TransactionStatus.PROCESS + partner_transaction = PartnerTransaction( + company_id=company.id, + type="sale_crediting", + amount=crediting_amount, + status=partner_transaction_status.value, + transaction_group=agent_transaction.transaction_group, + agent_transaction_id=agent_transaction.id + ) + db.add(partner_transaction) + db.commit() + db.refresh(partner_transaction) + + # Обновление балансов в зависимости от auto_approve_transactions + if company.auto_approve_transactions: + agent_balance.available_balance += crediting_amount + company_balance.available_balance -= crediting_amount + company_balance.updated_dttm = datetime.utcnow() + else: + agent_balance.frozen_balance += crediting_amount + company_balance.pending_balance -= crediting_amount + company_balance.updated_dttm = datetime.utcnow() + + db.add(agent_balance) + db.add(company_balance) + db.commit() + db.refresh(agent_balance) + db.refresh(company_balance) + + return { + "msg": "Продажа успешно зарегистрирована", + "sale_id": new_sale.sale_id, + "crediting": new_sale.crediting + } diff --git a/integration_models.py b/integration_models.py new file mode 100644 index 0000000..ae20a9a --- /dev/null +++ b/integration_models.py @@ -0,0 +1,34 @@ +from typing import Optional +from datetime import datetime +from pydantic import BaseModel +import uuid +from enum import Enum + +# Models for /token endpoint +class Token(BaseModel): + access_token: str + token_type: str + +class IntegrationTokenResponse(BaseModel): + msg: str + company_name: str + company_key: str + +# Models for /sale endpoint +class SaleCreateRequest(BaseModel): + ref: str + sale_id: str + cost: float + +class SaleCreateResponse(BaseModel): + msg: str + sale_id: str + crediting: float + +class TransactionStatus(str, Enum): + NEW = "new" + PROCESS = "process" + WAITING = "waiting" + DONE = "done" + CANCELED = "canceled" + ERROR = "error" \ No newline at end of file diff --git a/main.py b/main.py index 5c69819..113e594 100644 --- a/main.py +++ b/main.py @@ -544,7 +544,8 @@ def get_account_profile(current_account: Account = Depends(get_current_account), "company": { "name": company.name, "key": company.key, - "commission": company.commission + "commission": company.commission, + "agent_commission": company.agent_commission } } diff --git a/requirements.txt b/requirements.txt index 6c178a2..3c5addd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,146 +1,54 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml -o requirements.txt annotated-types==0.7.0 - # via pydantic anyio==4.8.0 - # via - # httpx - # starlette - # watchfiles bcrypt==4.2.1 - # via passlib behave==1.2.6 - # via epai-auth (pyproject.toml) certifi==2024.12.14 - # via - # httpcore - # httpx - # requests cffi==1.17.1 - # via cryptography charset-normalizer==3.4.2 - # via requests click==8.1.8 - # via - # rich-toolkit - # typer - # uvicorn +colorama==0.4.6 cryptography==44.0.0 - # via epai-auth (pyproject.toml) dnspython==2.7.0 - # via email-validator email-validator==2.2.0 - # via fastapi fastapi==0.115.6 - # via epai-auth (pyproject.toml) fastapi-cli==0.0.7 - # via fastapi greenlet==3.1.1 - # via sqlalchemy h11==0.14.0 - # via - # httpcore - # uvicorn httpcore==1.0.7 - # via httpx httptools==0.6.4 - # via uvicorn httpx==0.28.1 - # via fastapi idna==3.10 - # via - # anyio - # email-validator - # httpx - # requests jinja2==3.1.5 - # via fastapi markdown-it-py==3.0.0 - # via rich markupsafe==3.0.2 - # via jinja2 mdurl==0.1.2 - # via markdown-it-py parse==1.20.2 - # via - # behave - # parse-type parse-type==0.6.4 - # via behave passlib==1.7.4 - # via epai-auth (pyproject.toml) psycopg2==2.9.10 - # via epai-auth (pyproject.toml) pycparser==2.22 - # via cffi pydantic==2.10.5 - # via - # fastapi - # pydantic-settings - # sqlmodel pydantic-core==2.27.2 - # via pydantic pydantic-settings==2.9.1 - # via epai-auth (pyproject.toml) pygments==2.19.1 - # via rich pyjwt==2.10.1 - # via epai-auth (pyproject.toml) python-dotenv==1.0.1 - # via - # pydantic-settings - # uvicorn python-multipart==0.0.20 - # via fastapi pyyaml==6.0.2 - # via uvicorn requests==2.32.3 - # via epai-auth (pyproject.toml) rich==13.9.4 - # via - # rich-toolkit - # typer rich-toolkit==0.12.0 - # via fastapi-cli ruff==0.9.1 - # via epai-auth (pyproject.toml) shellingham==1.5.4 - # via typer six==1.17.0 - # via - # behave - # parse-type sniffio==1.3.1 - # via anyio sqlalchemy==2.0.37 - # via sqlmodel sqlmodel==0.0.22 - # via epai-auth (pyproject.toml) starlette==0.41.3 - # via fastapi typer==0.15.1 - # via fastapi-cli typing-extensions==4.12.2 - # via - # anyio - # fastapi - # pydantic - # pydantic-core - # rich-toolkit - # sqlalchemy - # typer - # typing-inspection typing-inspection==0.4.0 - # via pydantic-settings urllib3==2.4.0 - # via requests uvicorn==0.34.0 - # via - # fastapi - # fastapi-cli -#uvloop==0.21.0 - # via uvicorn watchfiles==1.0.4 - # via uvicorn websockets==14.1 - # via uvicorn diff --git a/sql_models.py b/sql_models.py index 1a1dc04..1ac2564 100644 --- a/sql_models.py +++ b/sql_models.py @@ -7,7 +7,8 @@ from sqlalchemy import Column, String class Company(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str - commission: float # процент комиссии + commission: float # процент комиссии, который взымается за пользование сервисом + agent_commission: float = Field(default=0.0) # процент от продаж, который начисляется агенту key: str = Field(index=True, unique=True) create_dttm: datetime = Field(default_factory=datetime.utcnow) update_dttm: datetime = Field(default_factory=datetime.utcnow)