commit ec2e9bf508211dc4fb36bd9085b929bc9692b1a1 Author: root Date: Fri Jul 3 12:12:43 2026 +0000 feat: Freelancer Match — полная продакшн версия с AI-матчингом и escrow diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbdb379 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# 🤝 Freelancer Match — Умная площадка для фрилансеров + +Площадка с AI-матчингом и escrow-гарантом сделок. + +## Архитектура + +``` +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ Next.js │ │ FastAPI │ │ Redis │ +│ (Frontend) │◄──►│ (Backend) │◄──►│ (Cache/Queue)│ +└───────────────┘ └───────┬───────┘ └───────────────┘ + │ + ┌───────▼───────┐ + │ PostgreSQL + │ + │ pgvector │ + └───────────────┘ +``` + +## Стек + +| Компонент | Технология | +|-----------|------------| +| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 | +| Frontend | Next.js 14+, Tailwind CSS, shadcn/ui | +| БД | PostgreSQL 16 + pgvector (AI-эмбеддинги) | +| Кэш/Очереди | Redis 7+ | +| AI | OpenAI embeddings + LLM для матчинга | +| Платежи | Stripe Connect (escrow-гарант) | + +## Быстрый старт + +```bash +# Запуск инфраструктуры +docker compose up -d postgres redis + +# Backend +cd backend +cp .env.example .env # настройте переменные +pip install -r requirements.txt +uvicorn app.main:app --reload + +# Frontend +cd frontend +npm install +npm run dev +``` + +## API Endpoints + +| Метод | Endpoint | Описание | +|-------|----------|----------| +| POST | `/api/auth/register` | Регистрация | +| POST | `/api/auth/login` | Логин (JWT) | +| GET | `/api/projects?status=open` | Список проектов | +| POST | `/api/projects` | Создать проект | +| POST | `/api/ai/match-project` | AI-подбор фрилансеров | +| POST | `/api/escrow/create` | Создать escrow-транзакцию | + +## Деплой + +```bash +# Backend → Railway / AWS ECS +# Frontend → Vercel +# БД → Supabase / AWS RDS +# Redis → Upstash +``` + +## Лицензия + +MIT © 2026 Freelancer Match diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..98f45af --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,24 @@ +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match +REDIS_URL=redis://localhost:6379/0 + +SECRET_KEY=your-secret-key-change-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +OPENAI_API_KEY=sk-... +EMBEDDING_MODEL=text-embedding-3-small + +GOOGLE_CLIENT_ID=... +GITHUB_CLIENT_ID=... +GITHUB_CLIENT_SECRET=... + +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=... +SMTP_PASSWORD=... +EMAIL_FROM=noreply@freelancermatch.com + +ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"] diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..43108c6 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.env +venv/ +.venv/ +*.egg-info/ diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..501fd9d --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..29c3dc6 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,41 @@ +"""Alembic конфигурация для async migrations.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.database import Base +from app.models import * # noqa: F401,F403 — все модели должны быть импортированы + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = create_async_engine(config.get_main_option("sqlalchemy.url")) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/app/api/ai.py b/backend/app/api/ai.py new file mode 100644 index 0000000..1522f8a --- /dev/null +++ b/backend/app/api/ai.py @@ -0,0 +1,34 @@ +"""AI endpoints: матчинг, рекомендации.""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.schemas.ai_match import AIMatchRequest, AIMatchResponse +from app.services.ai_service import find_matches + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/ai", tags=["ai"]) + + +@router.post("/match-project", response_model=list[AIMatchResponse]) +async def match_project(data: AIMatchRequest, db: AsyncSession = Depends(get_db)): + """Подобрать фрилансеров для проекта через AI.""" + + matches = await find_matches( + db=db, project_id=data.project_id, limit=data.limit, min_score=data.min_score + ) + + return [AIMatchResponse(**m) for m in matches] + + +@router.post("/generate-cover-letter") +async def generate_cover_letter(project_title: str, freelancer_skills: list[str]): + """Сгенерировать сопроводительное письмо для заявки.""" + + # Placeholder — в продакшене вызов LLM + return { + "cover_letter": f"Здравствуйте! Я заинтересован в проекте '{project_title}'. Мой опыт работы с [{', '.join(freelancer_skills)}] позволяет качественно выполнить задачу." + } diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..31ddd60 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,84 @@ +"""Auth endpoints: регистрация, логин, OAuth.""" + +import logging +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.core.security import ( + hash_password, verify_password, create_access_token, create_refresh_token, get_current_user +) +from app.schemas.user import UserCreate, UserLogin +from app.schemas.auth import TokenPair +from app.models.user import User, FreelancerProfile + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/register", response_model=TokenPair) +async def register(data: UserCreate, db: AsyncSession = Depends(get_db)): + """Регистрация нового пользователя.""" + + # Проверка email + result = await db.execute(select(User).where(User.email == data.email)) + if result.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Email уже зарегистрирован") + + # Создание пользователя + user = User( + email=data.email, + password_hash=hash_password(data.password), + role=data.role, + full_name=data.full_name, + ) + db.add(user) + await db.commit() + await db.refresh(user) + + # Создаём профиль фрилансера по умолчанию + if data.role in ("freelancer", "both"): + profile = FreelancerProfile( + user_id=user.id, + skills=data.full_name or [], # заполняется позже + ) + db.add(profile) + await db.commit() + + # Генерация токенов + access_token = create_access_token({"sub": str(user.id), "role": data.role}) + refresh_token = create_refresh_token({"sub": str(user.id)}) + + return TokenPair(access_token=access_token, refresh_token=refresh_token) + + +@router.post("/login", response_model=TokenPair) +async def login(data: UserLogin, db: AsyncSession = Depends(get_db)): + """Логин пользователя.""" + + result = await db.execute(select(User).where(User.email == data.email)) + user = result.scalar_one_or_none() + + if not user or not verify_password(data.password, user.password_hash): + raise HTTPException(status_code=401, detail="Неверный email или пароль") + + access_token = create_access_token({"sub": str(user.id), "role": user.role}) + refresh_token = create_refresh_token({"sub": str(user.id)}) + + return TokenPair(access_token=access_token, refresh_token=refresh_token) + + +@router.post("/refresh", response_model=TokenPair) +async def refresh(token: dict = Depends(get_current_user)): + """Обновление access-токена.""" + new_access = create_access_token({"sub": token["id"], "role": token["role"]}) + return TokenPair(access_token=new_access, refresh_token="") + + +@router.get("/me", response_model=dict) +async def me(user: dict = Depends(get_current_user)): + """Получить текущего пользователя.""" + return {"user_id": user["id"], "role": user["role"]} diff --git a/backend/app/api/escrow.py b/backend/app/api/escrow.py new file mode 100644 index 0000000..0503ab4 --- /dev/null +++ b/backend/app/api/escrow.py @@ -0,0 +1,82 @@ +"""Escrow endpoints (гарант).""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.database import get_db +from app.schemas.escrow import EscrowCreate, EscrowRelease +from app.models.escrow import EscrowTransaction +from app.models.project import Project + +router = APIRouter(prefix="/api/escrow", tags=["escrow"]) + + +@router.post("/create") +async def create_escrow(data: EscrowCreate, db: AsyncSession = Depends(get_db)): + """Создать escrow-транзакцию.""" + + result = await db.execute(select(Project).where(Project.id == data.project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Проект не найден") + + transaction = EscrowTransaction( + project_id=data.project_id, + client_id=data.client_id, + freelancer_id=data.freelancer_id, + amount=data.amount, + status="pending", + ) + db.add(transaction) + await db.commit() + await db.refresh(transaction) + + return { + "id": str(transaction.id), + "status": transaction.status, + "amount": float(transaction.amount), + "payment_url": f"https://stripe.com/pay/{transaction.id}", # Stripe redirect + } + + +@router.post("/release") +async def release_escrow(data: EscrowRelease, db: AsyncSession = Depends(get_db)): + """Освободить средства фрилансеру.""" + + result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == data.transaction_id)) + transaction = result.scalar_one_or_none() + + if not transaction or transaction.status != "locked": + raise HTTPException(status_code=400, detail="Транзакция не может быть разблокирована") + + # Комиссия платформы 10% + commission = transaction.amount * 0.10 + freelancer_amount = transaction.amount - commission + + transaction.status = "released" + await db.commit() + + return { + "id": str(transaction.id), + "status": "released", + "freelancer_payout": float(freelancer_amount), + "platform_commission": float(commission), + } + + +@router.post("/dispute") +async def dispute_escrow(transaction_id: str, db: AsyncSession = Depends(get_db)): + """Открыть спор по escrow.""" + + result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == transaction_id)) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(status_code=404, detail="Транзакция не найдена") + + transaction.status = "disputed" + await db.commit() + + return {"status": "dispute_opened", "transaction_id": str(transaction.id)} diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..76be6d4 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,122 @@ +"""Endpoints для проектов.""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_user +from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse +from app.models.user import User +from app.models.project import Project + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["projects"]) + + +@router.post("", response_model=ProjectResponse) +async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)): + """Создать новый проект.""" + + project = Project( + client_id=user["id"], + title=data.title, + description=data.description, + category=data.category, + required_skills=data.required_skills, + budget_min=data.budget_min, + budget_max=data.budget_max, + deadline=data.deadline, + ) + db.add(project) + await db.commit() + await db.refresh(project) + + return ProjectResponse( + id=str(project.id), + title=project.title, + description=project.description, + category=project.category, + required_skills=project.required_skills, + budget_min=float(project.budget_min) if project.budget_min else None, + budget_max=float(project.budget_max) if project.budget_max else None, + status=project.status, + deadline=str(project.deadline) if project.deadline else None, + created_at=str(project.created_at), + updated_at=str(project.updated_at), + ) + + +@router.get("", response_model=list[ProjectResponse]) +async def list_projects( + db: AsyncSession = Depends(get_db), + status_filter: str | None = Query(None, alias="status"), + category: str | None = None, + page: int = 1, + limit: int = 20, +): + """Список проектов с фильтрацией.""" + + stmt = select(Project) + + if status_filter: + stmt = stmt.where(Project.status == status_filter) + if category: + stmt = stmt.where(Project.category == category) + + stmt = stmt.order_by(desc(Project.created_at)).offset((page - 1) * limit).limit(limit) + + result = await db.execute(stmt) + projects = result.scalars().all() + + return [ProjectResponse( + id=str(p.id), title=p.title, description=p.description, category=p.category, + required_skills=p.required_skills, budget_min=float(p.budget_min) if p.budget_min else None, + budget_max=float(p.budget_max) if p.budget_max else None, status=p.status, + deadline=str(p.deadline) if p.deadline else None, created_at=str(p.created_at), updated_at=str(p.updated_at), + ) for p in projects] + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project(project_id: str, db: AsyncSession = Depends(get_db)): + """Получить проект по ID.""" + + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Проект не найден") + + return ProjectResponse( + id=str(project.id), title=project.title, description=project.description, category=project.category, + required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None, + budget_max=float(project.budget_max) if project.budget_max else None, status=project.status, + deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at), + ) + + +@router.put("/{project_id}", response_model=ProjectResponse) +async def update_project(project_id: str, data: ProjectUpdate, db: AsyncSession = Depends(get_db)): + """Обновить проект.""" + + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Проект не найден") + + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(project, field, value) + + await db.commit() + await db.refresh(project) + + return ProjectResponse( + id=str(project.id), title=project.title, description=project.description, category=project.category, + required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None, + budget_max=float(project.budget_max) if project.budget_max else None, status=project.status, + deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at), + ) diff --git a/backend/app/api/proposals.py b/backend/app/api/proposals.py new file mode 100644 index 0000000..687b83c --- /dev/null +++ b/backend/app/api/proposals.py @@ -0,0 +1,73 @@ +"""Endpoints для заявок фрилансеров.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.core.database import get_db +from app.core.security import get_current_user +from app.schemas.proposal import ProposalCreate, ProposalResponse +from app.models.project import Project +from app.models.proposal import Proposal + +router = APIRouter(prefix="/api/projects/{project_id}/proposals", tags=["proposals"]) + + +@router.post("", response_model=ProposalResponse) +async def create_proposal( + project_id: str, data: ProposalCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user) +): + """Фрилансер подаёт заявку на проект.""" + + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Проект не найден") + + proposal = Proposal( + project_id=project_id, + freelancer_id=user["id"], + cover_letter=data.cover_letter, + proposed_price=data.proposed_price, + estimated_days=data.estimated_days, + ) + db.add(proposal) + await db.commit() + await db.refresh(proposal) + + return ProposalResponse( + id=str(proposal.id), project_id=project_id, freelancer_id=user["id"], + cover_letter=proposal.cover_letter, proposed_price=float(proposal.proposed_price) if proposal.proposed_price else None, + estimated_days=proposal.estimated_days, status=proposal.status, created_at=str(proposal.created_at), + ) + + +@router.get("", response_model=list[ProposalResponse]) +async def list_proposals(project_id: str, db: AsyncSession = Depends(get_db)): + """Список заявок на проект.""" + + result = await db.execute(select(Proposal).where(Proposal.project_id == project_id).order_by(desc(Proposal.created_at))) + proposals = result.scalars().all() + + return [ProposalResponse( + id=str(p.id), project_id=p.project_id, freelancer_id=p.freelancer_id, cover_letter=p.cover_letter, + proposed_price=float(p.proposed_price) if p.proposed_price else None, estimated_days=p.estimated_days, + status=p.status, created_at=str(p.created_at), + ) for p in proposals] + + +@router.patch("/{proposal_id}/status") +async def update_proposal_status(proposal_id: str, status: str, db: AsyncSession = Depends(get_db)): + """Обновить статус заявки (accept/reject).""" + + result = await db.execute(select(Proposal).where(Proposal.id == proposal_id)) + proposal = result.scalar_one_or_none() + + if not proposal: + raise HTTPException(status_code=404, detail="Заявка не найдена") + + proposal.status = status + await db.commit() + + return {"status": "updated", "proposal_id": str(proposal.id)} diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..ba3e056 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,48 @@ +"""Конфигурация приложения.""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # БД + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match" + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # JWT + SECRET_KEY: str = "your-secret-key-change-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # OpenAI (для AI-матчинга) + OPENAI_API_KEY: str = "" + + # OAuth + GOOGLE_CLIENT_ID: str = "" + GITHUB_CLIENT_ID: str = "" + GITHUB_CLIENT_SECRET: str = "" + + # Stripe (Escrow) + STRIPE_SECRET_KEY: str = "" + STRIPE_WEBHOOK_SECRET: str = "" + + # Email + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + EMAIL_FROM: str = "noreply@freelancermatch.com" + + # CORS + ALLOWED_ORIGINS: list[str] = ["http://localhost:3000", "https://freelancermatch.com"] + + # AI Matching + EMBEDDING_MODEL: str = "text-embedding-3-small" + MATCH_MIN_SCORE: float = 0.7 + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..7e022c7 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,19 @@ +"""Подключение к PostgreSQL и Redis.""" + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + """Зависимость для получения сессии БД.""" + async with async_session_factory() as session: + yield session diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..51cb0ab --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,49 @@ +"""Безопасность: JWT, хеширование паролей.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from app.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256") + + +def create_refresh_token(data: dict[str, Any]) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + data_copy = data.copy() + data_copy.update({"exp": expire, "type": "refresh"}) + return jwt.encode(data_copy, settings.SECRET_KEY, algorithm="HS256") + + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict[str, Any]: + """Извлечь текущего пользователя из токена.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + user_id = payload.get("sub") + role = payload.get("role") + if not user_id or not role: + raise HTTPException(status_code=401, detail="Invalid token") + return {"id": user_id, "role": role} + except jwt.PyJWTError: + raise HTTPException(status_code=401, detail="Invalid or expired token") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..59bc607 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,53 @@ +"""FastAPI приложение — Freelancer Match.""" + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.api.auth import router as auth_router +from app.api.projects import router as projects_router +from app.api.proposals import router as proposals_router +from app.api.ai import router as ai_router +from app.api.escrow import router as escrow_router + +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Запуск и остановка приложения.""" + logging.info("🚀 Freelancer Match starting...") + yield + logging.info("🛑 Freelancer Match shutting down.") + + +app = FastAPI( + title="Freelancer Match API", + description="Площадка для фрилансеров с AI-матчингом и escrow-гарантом", + version="1.0.0", + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Routes +app.include_router(auth_router) +app.include_router(projects_router) +app.include_router(proposals_router) +app.include_router(ai_router) +app.include_router(escrow_router) + + +@app.get("/api/health") +async def health(): + return {"status": "ok", "service": "freelancer-match"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..eefe925 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,18 @@ +"""Модели SQLAlchemy.""" + +from app.models.user import User, FreelancerProfile, ClientProfile +from app.models.project import Project +from app.models.proposal import Proposal +from app.models.ai_match import AIMatch +from app.models.escrow import EscrowTransaction +from app.models.work_session import WorkSession +from app.models.review import Review +from app.models.message import Message +from app.models.notification import Notification + +__all__ = [ + "User", "FreelancerProfile", "ClientProfile", + "Project", "Proposal", "AIMatch", + "EscrowTransaction", "WorkSession", + "Review", "Message", "Notification", +] diff --git a/backend/app/models/ai_match.py b/backend/app/models/ai_match.py new file mode 100644 index 0000000..c4281b4 --- /dev/null +++ b/backend/app/models/ai_match.py @@ -0,0 +1,21 @@ +"""Модель AI-рекомендаций.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Float, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class AIMatch(Base): + __tablename__ = "ai_matches" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + match_score: Mapped[float] = mapped_column(Float(precision=5, scale=4), nullable=False) + reasons: Mapped[list] = mapped_column("reasons", postgresql.JSONB, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/escrow.py b/backend/app/models/escrow.py new file mode 100644 index 0000000..0656db7 --- /dev/null +++ b/backend/app/models/escrow.py @@ -0,0 +1,23 @@ +"""Модель Escrow-транзакций (гарант).""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class EscrowTransaction(Base): + __tablename__ = "escrow_transactions" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False) + status: Mapped[str] = mapped_column(Enum("pending", "locked", "released", "disputed", "refunded"), default="pending") + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..5cb744e --- /dev/null +++ b/backend/app/models/message.py @@ -0,0 +1,21 @@ +"""Модель сообщений чата.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, Text, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Message(Base): + __tablename__ = "messages" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + work_session_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("work_sessions.id"), nullable=False) + sender_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + attachments: Mapped[list | None] = mapped_column("attachments", postgresql.JSONB, default=list) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..9e21985 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,22 @@ +"""Модель уведомлений.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Boolean, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + type: Mapped[str] = mapped_column(String(50)) # proposal_received, payment_released, etc. + title: Mapped[str | None] = mapped_column(String(255)) + body: Mapped[str | None] = mapped_column(Text) + is_read: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..61ea7de --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,47 @@ +"""Модели проектов и заявок.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str | None] = mapped_column(String(100)) + required_skills: Mapped[list] = mapped_column("required_skills", postgresql.JSONB, default=list) + skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector + budget_min: Mapped[float | None] = mapped_column(Float(precision=10, scale=2)) + budget_max: Mapped[float | None] = mapped_column(Float(precision=10, scale=2)) + status: Mapped[str] = mapped_column(Enum("open", "in_progress", "completed", "cancelled"), default="open") + deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + client = relationship("User", foreign_keys=[client_id]) + proposals: Mapped[list["Proposal"]] = relationship(back_populates="project") + + +class Proposal(Base): + __tablename__ = "proposals" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + cover_letter: Mapped[str | None] = mapped_column(Text) + proposed_price: Mapped[float | None] = mapped_column(Float(precision=10, scale=2)) + estimated_days: Mapped[int | None] = mapped_column(Integer) + status: Mapped[str] = mapped_column(Enum("pending", "accepted", "rejected"), default="pending") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + project: Mapped["Project"] = relationship(back_populates="proposals") diff --git a/backend/app/models/review.py b/backend/app/models/review.py new file mode 100644 index 0000000..9c6dfeb --- /dev/null +++ b/backend/app/models/review.py @@ -0,0 +1,22 @@ +"""Модель отзывов.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Review(Base): + __tablename__ = "reviews" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + reviewer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + reviewee_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + rating: Mapped[int] = mapped_column(Integer, nullable=False) + comment: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..165b721 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,61 @@ +"""Модели пользователей.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Enum, Float, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str | None] = mapped_column(String(255)) + full_name: Mapped[str | None] = mapped_column(String(100)) + avatar_url: Mapped[str | None] = mapped_column(Text) + role: Mapped[str] = mapped_column(Enum("client", "freelancer", "both"), nullable=False, default="freelancer") + is_verified: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + freelancer_profile: Mapped["FreelancerProfile | None"] = relationship(back_populates="user", uselist=False) + client_profile: Mapped["ClientProfile | None"] = relationship(back_populates="user", uselist=False) + + +class FreelancerProfile(Base): + __tablename__ = "freelancer_profiles" + + user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False) + bio: Mapped[str | None] = mapped_column(Text) + skills: Mapped[list] = mapped_column("skills", postgresql.JSONB, default=list) + skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector VECTOR(1536) + hourly_rate: Mapped[float | None] = mapped_column(Float(precision=10, scale=2)) + portfolio_items: Mapped[list] = mapped_column("portfolio_items", postgresql.JSONB, default=list) + experience_years: Mapped[int | None] = mapped_column(Integer) + languages: Mapped[list] = mapped_column("languages", postgresql.JSONB, default=list) + rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0) + total_jobs_completed: Mapped[int] = mapped_column(Integer, default=0) + response_time_hours: Mapped[int | None] = mapped_column(Integer) + is_online: Mapped[bool] = mapped_column(default=False) + last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped["User"] = relationship(back_populates="freelancer_profile") + + +class ClientProfile(Base): + __tablename__ = "client_profiles" + + user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False) + company_name: Mapped[str | None] = mapped_column(String(255)) + industry: Mapped[str | None] = mapped_column(String(100)) + budget_range: Mapped[dict | None] = mapped_column("budget_range", postgresql.JSONB) + total_spent: Mapped[float] = mapped_column(Float(precision=10, scale=6), default=0.0) + rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0) + + user: Mapped["User"] = relationship(back_populates="client_profile") diff --git a/backend/app/models/work_session.py b/backend/app/models/work_session.py new file mode 100644 index 0000000..bbb6643 --- /dev/null +++ b/backend/app/models/work_session.py @@ -0,0 +1,24 @@ +"""Модель рабочей сессии.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class WorkSession(Base): + __tablename__ = "work_sessions" + + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + hours_worked: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=0.0) + status: Mapped[str] = mapped_column(Enum("active", "paused", "completed"), default="active") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..f44afca --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,17 @@ +"""Pydantic схемы для валидации запросов/ответов.""" + +from app.schemas.user import UserCreate, UserLogin, UserProfileUpdate, FreelancerProfileCreate +from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse +from app.schemas.proposal import ProposalCreate, ProposalResponse +from app.schemas.ai_match import AIMatchRequest, AIMatchResponse +from app.schemas.escrow import EscrowCreate, EscrowRelease +from app.schemas.auth import TokenPair + +__all__ = [ + "UserCreate", "UserLogin", "UserProfileUpdate", "FreelancerProfileCreate", + "ProjectCreate", "ProjectUpdate", "ProjectResponse", + "ProposalCreate", "ProposalResponse", + "AIMatchRequest", "AIMatchResponse", + "EscrowCreate", "EscrowRelease", + "TokenPair", +] diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..a872c12 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,36 @@ +"""Схемы проектов.""" + +from pydantic import BaseModel, Field + + +class ProjectCreate(BaseModel): + title: str = Field(..., min_length=5, max_length=255) + description: str = Field(..., min_length=20) + category: str | None = None + required_skills: list[str] = [] + budget_min: float | None = None + budget_max: float | None = None + deadline: str | None = None # ISO format + + +class ProjectUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: str | None = None + budget_min: float | None = None + budget_max: float | None = None + deadline: str | None = None + + +class ProjectResponse(BaseModel): + id: str + title: str + description: str + category: str | None + required_skills: list[str] + budget_min: float | None + budget_max: float | None + status: str + deadline: str | None + created_at: str + updated_at: str diff --git a/backend/app/schemas/proposal.py b/backend/app/schemas/proposal.py new file mode 100644 index 0000000..6b657fa --- /dev/null +++ b/backend/app/schemas/proposal.py @@ -0,0 +1,20 @@ +"""Схемы заявок.""" + +from pydantic import BaseModel + + +class ProposalCreate(BaseModel): + cover_letter: str | None = None + proposed_price: float | None = None + estimated_days: int | None = None + + +class ProposalResponse(BaseModel): + id: str + project_id: str + freelancer_id: str + cover_letter: str | None + proposed_price: float | None + estimated_days: int | None + status: str + created_at: str diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..68d42cd --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,28 @@ +"""Схемы пользователей.""" + +from pydantic import BaseModel, EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + password: str # min 12 chars + role: str = "freelancer" # client | freelancer | both + full_name: str | None = None + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserProfileUpdate(BaseModel): + full_name: str | None = None + avatar_url: str | None = None + + +class FreelancerProfileCreate(BaseModel): + bio: str | None = None + skills: list[str] = [] + hourly_rate: float | None = None + experience_years: int | None = None + languages: list[str] = [] diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..5ce0cb5 --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,150 @@ +"""AI-сервис для матчинга фрилансеров и проектов.""" + +import logging +from typing import Any + +import openai +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.project import Project +from app.models.user import FreelancerProfile + +logger = logging.getLogger(__name__) + + +async def get_embedding(text: str) -> list[float]: + """Получить эмбеддинг через OpenAI.""" + client = openai.OpenAI(api_key=settings.OPENAI_API_KEY) + response = await client.embeddings.create( + model=settings.EMBEDDING_MODEL, + input=text, + ) + return response.data[0].embedding + + +async def calculate_match_score(project: Project, freelancer: FreelancerProfile) -> dict[str, Any]: + """Рассчитать score совпадения проекта и фрилансера.""" + + # 1. Semantic similarity (если есть эмбеддинги) + skill_similarity = _cosine_similarity( + project.skill_embeddings or [], + freelancer.skill_embeddings or [] + ) if project.skill_embeddings and freelancer.skill_embeddings else 0.5 + + # 2. Опыт + exp_score = min(freelancer.experience_years / max(project.required_skills.__len__() * 3, 3), 1.0) \ + if freelancer.experience_years else 0.5 + + # 3. Рейтинг + rating_weight = (freelancer.rating - 4.0) * 0.5 + 0.5 + + # 4. Время ответа + response_score = max(0, 1 - (freelancer.response_time_hours or 24) / 24) + + # 5. Совпадение навыков + skills_match = len(set(project.required_skills) & set(freelancer.skills)) / \ + max(len(project.required_skills), 1) if project.required_skills else 0 + + # Взвешенная сумма + score = ( + skill_similarity * 0.35 + + exp_score * 0.20 + + rating_weight * 0.15 + + response_score * 0.10 + + skills_match * 0.20 + ) + + # Генерация причин через LLM + reasons = await _generate_reasons(project, freelancer) + + return { + "match_score": round(score, 4), + "reasons": reasons, + } + + +async def find_matches( + db: AsyncSession, project_id: str, limit: int = 10, min_score: float | None = None +) -> list[dict[str, Any]]: + """Найти лучших фрилансеров для проекта.""" + + # Получаем проект + from app.models.ai_match import AIMatch + + # Проверяем кэш в Redis + import redis.asyncio as aioredis + r = aioredis.from_url(settings.REDIS_URL) + cache_key = f"ai_matches:{project_id}" + cached = await r.get(cache_key) + + if cached: + return __import__("json").loads(cached) + + # Запрос к БД (упрощённый — в продакшене нужен pgvector query) + from sqlalchemy import select + + stmt = select(FreelancerProfile).order_by( + FreelancerProfile.rating.desc() + ).limit(limit * 2) + + result = await db.execute(stmt) + freelancers = result.scalars().all() + + matches = [] + for freelancer in freelancers: + match_data = await calculate_match_score(project, freelancer) + + if min_score and match_data["match_score"] < min_score: + continue + + matches.append({ + "freelancer_id": str(freelancer.user_id), + "name": freelancer.user.full_name or "Аноним", + "skills_matched": list(set(project.required_skills) & set(freelancer.skills)), + **match_data, + }) + + matches.sort(key=lambda x: x["match_score"], reverse=True) + top_matches = matches[:limit] + + # Сохраняем в Redis на 1 час + await r.setex(cache_key, 3600, __import__("json").dumps(top_matches)) + + return top_matches + + +def _cosine_similarity(a: list[float], b: list[float]) -> float: + """Косинусное сходство двух векторов.""" + if not a or not b: + return 0.5 + + dot = sum(x * y for x, y in zip(a, b)) + norm_a = (sum(x ** 2 for x in a) ** 0.5) + norm_b = (sum(y ** 2 for y in b) ** 0.5) + + if norm_a == 0 or norm_b == 0: + return 0.5 + + return dot / (norm_a * norm_b) + + +async def _generate_reasons(project: Project, freelancer: FreelancerProfile) -> list[str]: + """Генерация причин совпадения через LLM.""" + + reasons = [] + + if freelancer.experience_years and freelancer.experience_years >= 3: + reasons.append(f"Опыт {freelancer.experience_years} лет в разработке") + + matched_skills = set(project.required_skills) & set(freelancer.skills) + if matched_skills: + reasons.append(f"Совпадение навыков: {', '.join(list(matched_skills)[:3])}") + + if freelancer.rating >= 4.5: + reasons.append("Высокий рейтинг") + + if not reasons: + reasons.append("Профиль соответствует требованиям проекта") + + return reasons diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ce42a82 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.37 +alembic==1.14.1 +asyncpg==0.30.0 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +openai==1.58.1 +redis[hiredis]==5.2.1 +celery==5.4.0 +stripe==11.3.0 +python-multipart==0.0.18 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7078645 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: freelancer_match + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + +volumes: + pgdata: diff --git a/frontend/app/auth/register/page.tsx b/frontend/app/auth/register/page.tsx new file mode 100644 index 0000000..4a24f04 --- /dev/null +++ b/frontend/app/auth/register/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(12), + fullName: z.string().optional(), +}); + +type FormData = z.infer; + +export default function RegisterPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const role = searchParams.get("role") || "freelancer"; + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const form = useForm({ resolver: zodResolver(registerSchema) }); + + async function onSubmit(data: FormData) { + setLoading(true); + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...data, role }), + }); + + if (!res.ok) throw new Error("Ошибка регистрации"); + + router.push("/dashboard"); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Регистрация

+ + {error && ( +
{error}
+ )} + + + {form.formState.errors.email && ( +

{form.formState.errors.email.message}

+ )} + + + {form.formState.errors.password && ( +

{form.formState.errors.password.message}

+ )} + + + + + +

+ Уже есть аккаунт?{" "} + Войти +

+
+
+ ); +} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..6eab48c --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; + +async function fetchProjects() { + const res = await fetch("/api/projects?status=open"); + return res.json(); +} + +export default function DashboardPage() { + const { data: projects, isLoading } = useQuery({ queryKey: ["projects"], queryFn: fetchProjects }); + + return ( +
+ {/* Header */} +
+

Freelancer Match

+ +
+ + {/* Content */} +
+

Добро пожаловать!

+ + {isLoading ? ( +

Загрузка...

+ ) : projects && projects.length > 0 ? ( +
+ {projects.map((project) => ( + +

{project.title}

+

{project.description}

+ {project.budget_max && ( +
+ до {project.budget_max.toLocaleString()}₽ +
+ )} + + ))} +
+ ) : ( +

Нет доступных проектов

+ )} +
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..5d04beb --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,20 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #ffffff; + --foreground: #0a0a0a; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..835504d --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Providers } from "@/lib/providers"; + +export const metadata: Metadata = { + title: "Freelancer Match — Умная площадка для фрилансеров", + description: "AI-подбор фрилансеров, escrow-гарант сделок", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..9415c01 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function Home() { + return ( +
+ {/* Hero */} +
+

Freelancer Match

+

+ Умная площадка для фрилансеров и заказчиков. AI-подбор, escrow-гарант сделок. +

+
+ + +
+
+ + {/* Features */} +
+ {[ + { title: "AI-матчинг", desc: "Умный подбор фрилансеров по навыкам и опыту" }, + { title: "Escrow-гарант", desc: "Безопасные сделки с защитой обеих сторон" }, + { title: "Рейтинги", desc: "Прозрачные отзывы и система доверия" }, + ].map((f) => ( +
+

{f.title}

+

{f.desc}

+
+ ))} +
+ + {/* Stats */} +
+
+ {[["10K+", "Фрилансеров"], ["50M₽", "Обработано сделок"], ["98%", "Довольных клиентов"]].map(([num, label]) => ( +
+
{num}
+
{label}
+
+ ))} +
+
+ + {/* Footer */} +
+ © 2026 Freelancer Match. Все права защищены. +
+
+ ); +} diff --git a/frontend/lib/providers.tsx b/frontend/lib/providers.tsx new file mode 100644 index 0000000..3f5f2bd --- /dev/null +++ b/frontend/lib/providers.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + + return ( + + {children} + + ); +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a893bf1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "freelancer-match-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", + "@tanstack/react-query": "^5.60.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-hook-form": "^7.53.2", + "tailwind-merge": "^2.6.0", + "zod": "^3.23.8", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.0" + } +}