Compare commits

..

2 Commits

69 changed files with 1056 additions and 2658 deletions
-60
View File
@@ -1,60 +0,0 @@
---
kind: pipeline
type: docker
name: freelancer-match-ci
steps:
- name: backend-tests
image: python:3.12-slim
commands:
- pip install pytest pytest-asyncio aiosqlite httpx
- cd backend && pytest tests/ -v
- name: frontend-lint
image: node:20-alpine
commands:
- cd frontend && npm ci
- npx next lint --dir app
- name: build-backend
image: docker:24.0
privileged: true
environment:
DOCKER_USERNAME:
from_secret: gitea_username
DOCKER_PASSWORD:
from_secret: gitea_password
commands:
- echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin
- cd backend && docker build -t ms.webhop.me/admin/freelancer-match-backend .
- name: build-frontend
image: docker:24.0
privileged: true
environment:
DOCKER_USERNAME:
from_secret: gitea_username
DOCKER_PASSWORD:
from_secret: gitea_password
commands:
- echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin
- cd frontend && docker build -t ms.webhop.me/admin/freelancer-match-frontend .
- name: deploy
image: alpine:3.19
commands:
- apk add curl bash
- |
echo "Deploying to production..."
# TODO: Добавить SSH ключ для деплоя на сервер
# ssh root@ms.webhop.me "cd /opt/gitea && docker compose pull && docker compose up -d"
---
kind: secret
name: gitea_username
from_secret: drone_gitea_user
---
kind: secret
name: gitea_password
from_secret: drone_gitea_pass
-160
View File
@@ -1,160 +0,0 @@
# Freelancer Match — Умная площадка для фрилансеров
Площадка для фрилансеров и заказчиков с AI-подбором, escrow-гарантом сделок и рейтинговой системой.
## 🚀 Быстрый старт
### Требования
- Docker + Docker Compose
- Python 3.12+ (для локальной разработки)
- Node.js 20+ (для фронтенда)
- PostgreSQL 16+
- Redis 7+
### Запуск через Docker Compose
```bash
# 1. Скопируйте .env.example в .env и заполните переменные
cp backend/.env.example backend/.env
# 2. Запустите стек
docker compose up -d --build
# 3. Примените миграции БД
docker compose exec backend alembic upgrade head
# 4. Проверьте что всё работает
curl http://localhost:8000/api/health
```
### Локальная разработка
#### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
#### Frontend
```bash
cd frontend
npm ci
npm run dev
```
## 🔑 Настройка переменных окружения
Скопируйте `backend/.env.example` в `backend/.env` и заполните значения.
### Обязательные (без них не запустится)
| Переменная | Описание | Где взять |
|------------|----------|-----------|
| `DATABASE_URL` | Подключение к PostgreSQL | Создайте БД: `createdb freelancer_match` |
| `REDIS_URL` | Подключение к Redis | По умолчанию: `redis://localhost:6379/0` |
| `SECRET_KEY` | Ключ для JWT токенов | Сгенерируйте: `openssl rand -hex 32` |
### Опциональные (но нужны для полноценной работы)
| Переменная | Описание | Где взять |
|------------|----------|-----------|
| `OPENAI_API_KEY` | API ключ OpenAI для AI-матчинга | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) |
| `STRIPE_SECRET_KEY` | Ключ Stripe для escrow | [dashboard.stripe.com/test/keys](https://dashboard.stripe.com/test/keys) (Test mode) |
| `STRIPE_WEBHOOK_SECRET` | Секрет для webhook Stripe | Настройте в Stripe Dashboard → Webhooks |
### Email (опционально)
| Переменная | Описание | Где взять |
|------------|----------|-----------|
| `SMTP_USER` | Email для отправки писем | Ваш email (Gmail, Mail.ru и т.д.) |
| `SMTP_PASSWORD` | Пароль SMTP | Для Gmail — используйте App Passwords (не обычный пароль) |
### OAuth (опционально, если хотите вход через Google/GitHub)
| Переменная | Описание | Где взять |
|------------|----------|-----------|
| `GOOGLE_CLIENT_ID` | OAuth client ID для входа через Google | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) |
| `GITHUB_CLIENT_ID` + `GITHUB_CLIENT_SECRET` | OAuth для GitHub | [GitHub Developer Settings → OAuth Apps](https://github.com/settings/developers) |
## 📁 Структура проекта
```
freelancer-match/
├── backend/ # FastAPI бэкенд
│ ├── app/
│ │ ├── api/ # API endpoints
│ │ ├── models/ # SQLAlchemy модели
│ │ ├── schemas/ # Pydantic схемы
│ │ ├── services/ # Бизнес-логика
│ │ └── core/ # Базовые модули (БД, безопасность)
│ ├── alembic/ # Миграции БД
│ ├── tests/ # Тесты pytest
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/ # Next.js фронтенд
│ ├── app/ # App Router (pages)
│ ├── components/ui/ # UI компоненты
│ ├── lib/ # Утилиты и провайдеры
│ ├── Dockerfile
│ └── package.json
├── docker-compose.yml # Стек сервисов
└── .drone.yml # CI/CD для Drone CI
```
## 🧪 Тесты
```bash
# Backend (pytest)
cd backend && pytest tests/ -v
# Frontend (linting)
cd frontend && npx next lint
```
## 🔒 Безопасность
- JWT аутентификация с refresh токенами
- Хеширование паролей через bcrypt
- CORS middleware
- Escrow-гарант сделок (Stripe интеграция)
- AI-матчинг с pgvector эмбеддингами
## 📊 API Endpoints
| Метод | Endpoint | Описание |
|-------|----------|----------|
| POST | `/api/auth/register` | Регистрация пользователя |
| POST | `/api/auth/login` | Вход (JWT) |
| GET | `/api/projects` | Список проектов |
| POST | `/api/projects` | Создать проект |
| POST | `/api/ai/match-project` | AI-подбор фрилансеров |
| POST | `/api/escrow/create` | Создать escrow-транзакцию |
## 🚀 Деплой на сервер
```bash
# 1. Настройте .env.production
cp backend/.env.example backend/.env.production
# 2. Обновите docker-compose.yml для продакшена (уберите порты, добавьте healthcheck)
# 3. Запустите через Docker Compose
docker compose -f docker-compose.prod.yml up -d --build
# 4. Примените миграции
docker compose exec backend alembic upgrade head
```
## 🤖 CI/CD (Drone CI)
Проект настроен для автоматического деплоя через Drone CI:
- `.drone.yml` — пайплайн с тестами, сборкой и деплоем
- Настроен на Gitea (`ms.webhop.me`)
- Автоматический запуск при push в main
## 📝 Лицензия
MIT © 2026 Freelancer Match
-34
View File
@@ -1,34 +0,0 @@
# ============================================
# Freelancer Match — Environment Variables (Template)
# ============================================
# Скопируйте этот файл в .env и заполните значения!
# --- ОБЯЗАТЕЛЬНЫЕ (без них не запустится) ---
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=your-secret-key-change-in-production
# --- ОПЦИОНАЛЬНЫЕ (но нужны для полноценной работы) ---
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_...
# --- EMAIL (опционально) ---
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=noreply@freelancermatch.com
SMTP_PASSWORD=your-app-password
EMAIL_FROM=noreply@freelancermatch.com
# --- CORS ---
ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"]
-7
View File
@@ -1,7 +0,0 @@
__pycache__/
*.pyc
.env
.venv/
*.egg-info/
dist/
build/
-34
View File
@@ -1,34 +0,0 @@
"""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)}] позволяет качественно выполнить задачу."
}
-84
View File
@@ -1,84 +0,0 @@
"""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"]}
-82
View File
@@ -1,82 +0,0 @@
"""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)}
-71
View File
@@ -1,71 +0,0 @@
"""Endpoints для Milestone-платежей (Upwork-style)."""
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.core.security import get_current_user
from app.models.project import Project
from app.models.milestone import Milestone
from app.models.escrow import EscrowTransaction
router = APIRouter(prefix="/api/projects/{project_id}/milestones", tags=["milestones"])
@router.post("", response_model=dict)
async def create_milestone(
project_id: str, data: dict, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
):
"""Создать milestone для проекта."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project or project.client_id != user["id"]:
raise HTTPException(status_code=403, detail="Только владелец проекта может создавать milestones")
milestone = Milestone(
project_id=project_id,
title=data.get("title", ""),
description=data.get("description"),
amount=float(data.get("amount")),
due_date=None, # ISO format string
)
db.add(milestone)
await db.commit()
await db.refresh(milestone)
return {"id": str(milestone.id), "status": milestone.status}
@router.patch("/{milestone_id}/submit")
async def submit_milestone(milestone_id: str, db: AsyncSession = Depends(get_db)):
"""Фрилансер завершает milestone."""
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
milestone = result.scalar_one_or_none()
if not milestone or milestone.status != "funded":
raise HTTPException(status_code=400, detail="Milestone не может быть завершён")
milestone.status = "submitted"
await db.commit()
return {"status": "submitted", "milestone_id": str(milestone.id)}
@router.patch("/{milestone_id}/approve")
async def approve_milestone(milestone_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Клиент одобряет milestone."""
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
milestone = result.scalar_one_or_none()
if not milestone or milestone.status != "submitted":
raise HTTPException(status_code=400, detail="Milestone не может быть одобрен")
milestone.status = "approved"
await db.commit()
return {"status": "approved", "milestone_id": str(milestone.id)}
-67
View File
@@ -1,67 +0,0 @@
"""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.core.security import get_current_user
from app.models.portfolio import PortfolioItem
from app.models.user import User
router = APIRouter(prefix="/api/portfolio", tags=["portfolio"])
@router.post("")
async def create_portfolio_item(data: dict, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
"""Добавить работу в портфолио."""
item = PortfolioItem(
freelancer_id=user["id"],
title=data.get("title", ""),
description=data.get("description"),
image_url=data.get("image_url"),
live_url=data.get("live_url"),
technologies=data.get("technologies", []),
)
db.add(item)
await db.commit()
await db.refresh(item)
return {"id": str(item.id)}
@router.get("/user/{user_id}")
async def list_portfolio(user_id: str, db: AsyncSession = Depends(get_db)):
"""Список работ в портфолио пользователя."""
result = await db.execute(select(PortfolioItem).where(PortfolioItem.freelancer_id == user_id))
items = result.scalars().all()
return [
{
"id": str(i.id),
"title": i.title,
"description": i.description,
"image_url": i.image_url,
"live_url": i.live_url,
"technologies": i.technologies,
"created_at": str(i.created_at),
} for i in items
]
@router.delete("/{item_id}")
async def delete_portfolio_item(item_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Удалить работу из портфолио."""
result = await db.execute(select(PortfolioItem).where(PortfolioItem.id == item_id))
item = result.scalar_one_or_none()
if not item or item.freelancer_id != user["id"]:
raise HTTPException(status_code=403, detail="Нет доступа")
await db.delete(item)
await db.commit()
return {"status": "deleted"}
-122
View File
@@ -1,122 +0,0 @@
"""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),
)
-73
View File
@@ -1,73 +0,0 @@
"""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)}
-80
View File
@@ -1,80 +0,0 @@
"""Endpoints для отзывов и рейтингов."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_user
from app.schemas.review import ReviewCreate, ReviewResponse
from app.models.project import Project
from app.models.review import Review
from app.models.user import User
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
@router.post("", response_model=ReviewResponse)
async def create_review(data: ReviewCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
"""Оставить отзыв на проект."""
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="Проект не найден")
# Проверка что пользователь участвовал в проекте
if user["id"] != project.client_id and user["id"] != project.proposals[0].freelancer_id if project.proposals else True:
raise HTTPException(status_code=403, detail="Только участники проекта могут оставить отзыв")
review = Review(
project_id=data.project_id,
reviewer_id=user["id"],
reviewee_id=data.reviewee_id,
rating=data.rating,
comment=data.comment,
)
db.add(review)
await db.commit()
await db.refresh(review)
# Обновляем рейтинг пользователя
result = await db.execute(select(func.avg(Review.rating)).where(Review.reviewee_id == data.reviewee_id))
avg_rating = float(result.scalar_one_or_none()) or 0.0
return ReviewResponse(
id=str(review.id),
project_id=review.project_id,
reviewer_name="Аноним",
reviewee_name="Аноним",
rating=review.rating,
comment=review.comment,
created_at=str(review.created_at),
)
@router.get("/project/{project_id}", response_model=list[ReviewResponse])
async def list_reviews(project_id: str, db: AsyncSession = Depends(get_db)):
"""Список отзывов по проекту."""
result = await db.execute(select(Review).where(Review.project_id == project_id))
reviews = result.scalars().all()
return [ReviewResponse(
id=str(r.id), project_id=r.project_id, reviewer_name="Аноним", reviewee_name="Аноним",
rating=r.rating, comment=r.comment, created_at=str(r.created_at)
) for r in reviews]
@router.get("/user/{user_id}", response_model=dict)
async def get_user_rating(user_id: str, db: AsyncSession = Depends(get_db)):
"""Получить рейтинг пользователя."""
result = await db.execute(select(func.avg(Review.rating)).where(Review.reviewee_id == user_id))
avg_rating = float(result.scalar_one_or_none()) or 0.0
result2 = await db.execute(select(func.count(Review.id)).where(Review.reviewee_id == user_id))
total_reviews = int(result2.scalar_one_or_none()) or 0
return {"user_id": user_id, "rating": round(avg_rating, 1), "total_reviews": total_reviews}
-106
View File
@@ -1,106 +0,0 @@
"""Endpoints для Skill Tests (сертификация навыков)."""
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.core.security import get_current_user
from app.models.skill_test import SkillTest, SkillTestResult
from app.models.user import User
router = APIRouter(prefix="/api/skill-tests", tags=["skill-tests"])
@router.get("")
async def list_tests(db: AsyncSession = Depends(get_db)):
"""Список доступных тестов навыков."""
result = await db.execute(select(SkillTest))
tests = result.scalars().all()
return [
{
"id": str(t.id),
"name": t.name,
"category": t.category,
"questions_count": t.questions_count,
"passing_score": t.passing_score,
} for t in tests
]
@router.post("/take/{test_id}")
async def take_test(test_id: str, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Начать тест навыков."""
result = await db.execute(select(SkillTest).where(SkillTest.id == test_id))
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
# Генерация вопросов (placeholder — в продакшене реальные вопросы)
questions = [
{"id": i + 1, "question": f"Вопрос {i+1} по теме '{test.name}'", "options": ["A", "B", "C", "D"], "correct": 0}
for i in range(test.questions_count)
]
return {"test_id": str(test.id), "questions": questions, "time_limit_minutes": test.questions_count * 2}
@router.post("/submit/{test_id}")
async def submit_test(test_id: str, answers: dict, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Отправить ответы на тест."""
result = await db.execute(select(SkillTest).where(SkillTest.id == test_id))
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
# Подсчёт баллов (placeholder)
score = 75.0 # В продакшене реальный подсчёт
passed = score >= test.passing_score
result2 = await db.execute(select(SkillTestResult).where(
SkillTestResult.user_id == user["id"],
SkillTestResult.skill_test_id == test_id,
))
existing = result2.scalar_one_or_none()
if existing:
existing.score = score
existing.passed = passed
existing.completed_at = None # TODO: datetime.now(timezone.utc)
else:
new_result = SkillTestResult(
user_id=user["id"],
skill_test_id=test_id,
score=score,
passed=passed,
completed_at=None,
)
db.add(new_result)
await db.commit()
return {"score": score, "passed": passed}
@router.get("/user/{user_id}")
async def get_user_tests(user_id: str, db: AsyncSession = Depends(get_db)):
"""Получить результаты тестов пользователя."""
result = await db.execute(select(SkillTestResult).where(SkillTestResult.user_id == user_id))
results = result.scalars().all()
return [
{
"test_name": r.skill_test.name if hasattr(r, 'skill_test') else "Unknown",
"score": r.score,
"passed": r.passed,
"completed_at": str(r.completed_at) if r.completed_at else None,
} for r in results
]
-72
View File
@@ -1,72 +0,0 @@
"""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.core.security import get_current_user
from app.models.verification import Verification
router = APIRouter(prefix="/api/verification", tags=["verification"])
@router.get("/me")
async def me_verification(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Получить статус верификации текущего пользователя."""
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
verification = result.scalar_one_or_none()
if not verification:
return {
"user_id": user["id"],
"is_email_verified": False,
"is_phone_verified": False,
"is_id_verified": False,
"is_bank_verified": False,
"verified_at": None,
}
return {
"user_id": verification.user_id,
"is_email_verified": verification.is_email_verified,
"is_phone_verified": verification.is_phone_verified,
"is_id_verified": verification.is_id_verified,
"is_bank_verified": verification.is_bank_verified,
"verified_at": str(verification.verified_at) if verification.verified_at else None,
}
@router.post("/email/verify")
async def verify_email(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Подтвердить email."""
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
verification = result.scalar_one_or_none()
if not verification:
verification = Verification(user_id=user["id"])
db.add(verification)
verification.is_email_verified = True
await db.commit()
return {"status": "email_verified"}
@router.post("/phone/verify")
async def verify_phone(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Подтвердить телефон."""
result = await db.execute(select(Verification).where(Verification.user_id == user["id"]))
verification = result.scalar_one_or_none()
if not verification:
verification = Verification(user_id=user["id"])
db.add(verification)
verification.is_phone_verified = True
await db.commit()
return {"status": "phone_verified"}
-48
View File
@@ -1,48 +0,0 @@
"""Конфигурация приложения."""
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()
-19
View File
@@ -1,19 +0,0 @@
"""Подключение к 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
-49
View File
@@ -1,49 +0,0 @@
"""Безопасность: 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")
-63
View File
@@ -1,63 +0,0 @@
"""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
from app.api.reviews import router as reviews_router
from app.api.milestones import router as milestones_router
from app.api.portfolio import router as portfolio_router
from app.api.skill_tests import router as skill_tests_router
from app.api.verification import router as verification_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.include_router(reviews_router)
app.include_router(milestones_router)
app.include_router(portfolio_router)
app.include_router(skill_tests_router)
app.include_router(verification_router)
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "freelancer-match"}
-24
View File
@@ -1,24 +0,0 @@
"""Модели 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.milestone import Milestone
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
from app.models.portfolio import PortfolioItem
from app.models.skill_test import SkillTest, SkillTestResult
from app.models.verification import Verification
__all__ = [
"User", "FreelancerProfile", "ClientProfile",
"Project", "Proposal", "AIMatch",
"EscrowTransaction", "Milestone", "WorkSession",
"Review", "Message", "Notification",
"PortfolioItem", "SkillTest", "SkillTestResult",
"Verification",
]
-21
View File
@@ -1,21 +0,0 @@
"""Модель 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())
-23
View File
@@ -1,23 +0,0 @@
"""Модель 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())
-21
View File
@@ -1,21 +0,0 @@
"""Модель сообщений чата."""
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())
-26
View File
@@ -1,26 +0,0 @@
"""Модель Milestone-платежей (Upwork-style)."""
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 Milestone(Base):
__tablename__ = "milestones"
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)
escrow_transaction_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("escrow_transactions.id"))
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False)
status: Mapped[str] = mapped_column(Enum("pending", "funded", "in_progress", "submitted", "approved", "disputed"), default="pending")
due_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-22
View File
@@ -1,22 +0,0 @@
"""Модель уведомлений."""
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())
-23
View File
@@ -1,23 +0,0 @@
"""Модель портфолио фрилансера."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, 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 PortfolioItem(Base):
__tablename__ = "portfolio_items"
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
freelancer_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 | None] = mapped_column(Text)
image_url: Mapped[str | None] = mapped_column(Text) # URL превью работы
live_url: Mapped[str | None] = mapped_column(Text) # Ссылка на работу
technologies: Mapped[list] = mapped_column("technologies", postgresql.JSONB, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-47
View File
@@ -1,47 +0,0 @@
"""Модели проектов и заявок."""
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")
-22
View File
@@ -1,22 +0,0 @@
"""Модель отзывов."""
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())
-33
View File
@@ -1,33 +0,0 @@
"""Модель Skill Tests (сертификация навыков как на Upwork)."""
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 SkillTest(Base):
__tablename__ = "skill_tests"
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False) # например "Python Basics"
category: Mapped[str] = mapped_column(String(100)) # programming, design, etc.
questions_count: Mapped[int] = mapped_column(Integer, default=40)
passing_score: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=70.0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class SkillTestResult(Base):
__tablename__ = "skill_test_results"
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)
skill_test_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("skill_tests.id"), nullable=False)
score: Mapped[float] = mapped_column(Float(precision=5, scale=2))
passed: Mapped[bool] = mapped_column(default=False)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
-61
View File
@@ -1,61 +0,0 @@
"""Модели пользователей."""
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")
-27
View File
@@ -1,27 +0,0 @@
"""Модель верификации профиля (Verified Badges)."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, Boolean, String, func
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Verification(Base):
__tablename__ = "verifications"
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), nullable=False, unique=True)
# Типы верификации
is_email_verified: Mapped[bool] = mapped_column(default=False)
is_phone_verified: Mapped[bool] = mapped_column(default=False)
is_id_verified: Mapped[bool] = mapped_column(default=False) # ID document
is_bank_verified: Mapped[bool] = mapped_column(default=False) # Bank account
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
-24
View File
@@ -1,24 +0,0 @@
"""Модель рабочей сессии."""
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())
-17
View File
@@ -1,17 +0,0 @@
"""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",
]
-17
View File
@@ -1,17 +0,0 @@
"""Схемы AI-матчинга."""
from pydantic import BaseModel, Field
class AIMatchRequest(BaseModel):
project_id: str = Field(..., description="ID проекта")
limit: int = Field(default=10, ge=1, le=50)
min_score: float | None = Field(default=None, ge=0.0, le=1.0)
class AIMatchResponse(BaseModel):
freelancer_id: str
name: str
skills_matched: list[str]
match_score: float
reasons: list[str]
-8
View File
@@ -1,8 +0,0 @@
"""Схемы авторизации."""
from pydantic import BaseModel
class TokenPair(BaseModel):
access_token: str
refresh_token: str
-14
View File
@@ -1,14 +0,0 @@
"""Схемы escrow-транзакций."""
from pydantic import BaseModel, Field
class EscrowCreate(BaseModel):
project_id: str = Field(..., description="ID проекта")
client_id: str = Field(..., description="ID клиента")
freelancer_id: str = Field(..., description="ID фрилансера")
amount: float = Field(..., gt=0, description="Сумма в рублях")
class EscrowRelease(BaseModel):
transaction_id: str = Field(..., description="ID транзакции")
-36
View File
@@ -1,36 +0,0 @@
"""Схемы проектов."""
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
-20
View File
@@ -1,20 +0,0 @@
"""Схемы заявок."""
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
-20
View File
@@ -1,20 +0,0 @@
"""Схемы отзывов и рейтингов."""
from pydantic import BaseModel, Field
class ReviewCreate(BaseModel):
project_id: str = Field(..., description="ID проекта")
reviewee_id: str = Field(..., description="ID того кого оценивают")
rating: int = Field(..., ge=1, le=5)
comment: str | None = Field(default=None, max_length=2000)
class ReviewResponse(BaseModel):
id: str
project_id: str
reviewer_name: str
reviewee_name: str
rating: int
comment: str | None
created_at: str
-28
View File
@@ -1,28 +0,0 @@
"""Схемы пользователей."""
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] = []
-150
View File
@@ -1,150 +0,0 @@
"""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
+22
View File
@@ -0,0 +1,22 @@
"""Роуты API — reviews, ratings, chat, projects, auth"""
from fastapi import APIRouter
from .reviews import router as reviews_router
from .ratings import router as ratings_router
from .chats import router as chats_router
from .projects import router as projects_router
from .auth import router as auth_router
from .diagnosis import router as diagnosis_router
from .price_estimate import router as price_estimate_router
from .subscriptions import router as subscriptions_router
router = APIRouter()
router.include_router(auth_router, prefix="/auth", tags=["Auth"])
router.include_router(reviews_router, prefix="/reviews", tags=["Reviews"])
router.include_router(ratings_router, prefix="/ratings", tags=["Ratings"])
router.include_router(chats_router, prefix="/chats", tags=["Chat"])
router.include_router(projects_router, prefix="/projects", tags=["Projects"])
router.include_router(diagnosis_router, prefix="/diagnosis", tags=["Diagnosis"])
router.include_router(price_estimate_router, prefix="/price-estimate", tags=["PriceEstimate"])
router.include_router(subscriptions_router, prefix="/subscriptions", tags=["Subscriptions"])
+107
View File
@@ -0,0 +1,107 @@
"""Аутентификация — регистрация, логин, JWT-токены"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from datetime import datetime, timedelta
from ...core.database import async_session_factory, User, UserRole
from ...utils.auth import create_access_token, create_refresh_token, verify_password, hash_password
from pydantic import BaseModel
router = APIRouter()
class RegisterRequest(BaseModel):
email: str
password: str
role: str = "client" # client | master
first_name: str
last_name: str
phone: str | None = None
class LoginRequest(BaseModel):
email: str
password: str
@router.post("/register")
async def register(req: RegisterRequest, session: AsyncSession = Depends(async_session_factory)):
"""Регистрация пользователя."""
existing = await session.execute(select(User).where(User.email == req.email))
if existing.scalar_one_or_none():
raise HTTPException(409, "Email уже зарегистрирован")
user = User(
email=req.email,
password_hash=hash_password(req.password),
role=UserRole(req.role),
first_name=req.first_name,
last_name=req.last_name,
phone=req.phone,
)
session.add(user)
await session.commit()
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return {
"user_id": str(user.id),
"email": user.email,
"role": user.role.value,
"access_token": access_token,
"refresh_token": refresh_token,
}
@router.post("/login")
async def login(req: LoginRequest, session: AsyncSession = Depends(async_session_factory)):
"""Логин."""
result = await session.execute(select(User).where(User.email == req.email))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(401, "Неверный email или пароль")
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return {
"user_id": str(user.id),
"email": user.email,
"role": user.role.value,
"access_token": access_token,
"refresh_token": refresh_token,
}
@router.post("/refresh")
async def refresh_token(req: dict, session: AsyncSession = Depends(async_session_factory)):
"""Обновить токен."""
from ...utils.auth import decode_refresh_token
user_id = decode_refresh_token(req["refresh_token"])
access_token = create_access_token(user_id)
return {"access_token": access_token}
@router.get("/me")
async def get_me(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Получить данные текущего пользователя."""
user = await session.get(User, uuid.UUID(str(user.id)))
return {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
"role": user.role.value,
"avatar_url": user.avatar_url,
}
+121
View File
@@ -0,0 +1,121 @@
"""Чат — WebSocket + REST для сообщений"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
import uuid
from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus
from ...utils.auth import get_current_user
router = APIRouter()
class SendMessageRequest(BaseModel):
chat_id: uuid.UUID
content_type: str = "text" # text | image | file | voice
content: str | None = None
media_url: str | None = None
reply_to_id: uuid.UUID | None = None
@router.post("/messages")
async def send_message(req: SendMessageRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Отправить сообщение в чат."""
chat = await session.get(Chat, req.chat_id)
if not chat or chat.status != ChatStatus.ACTIVE:
raise HTTPException(400, "Чат неактивен")
message = ChatMessage(
chat_id=req.chat_id,
sender_id=user.id,
content_type=ContentType(req.content_type),
content=req.content,
media_url=req.media_url,
reply_to_id=req.reply_to_id,
)
session.add(message)
from datetime import datetime
chat.last_message_at = datetime.utcnow()
await session.commit()
return {"id": str(message.id), "status": "sent"}
@router.get("/messages/{chat_id}")
async def get_messages(chat_id: uuid.UUID, limit: int = 50, before: uuid.UUID | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
"""Получить историю сообщений чата."""
query = select(ChatMessage).where(ChatMessage.chat_id == chat_id)
if before:
query = query.where(ChatMessage.id < before)
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
result = await session.execute(query)
messages = [m.mapped() for m in result.scalars().all()]
return list(reversed(messages))
@router.get("/chats")
async def get_user_chats(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Получить все чаты пользователя."""
query = select(Chat).where(
(Chat.client_id == user.id) | (Chat.master_id == user.id),
Chat.status.in_([ChatStatus.ACTIVE, ChatStatus.COMPLETED])
).order_by(Chat.last_message_at.desc())
result = await session.execute(query)
return [c.mapped() for c in result.scalars().all()]
@router.post("/chats/{chat_id}/mark-read")
async def mark_as_read(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Отметить сообщения как прочитанные."""
await session.execute(
update(ChatMessage)
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.sender_id != user.id) & (ChatMessage.read_at.is_(None)))
.values(read_at=datetime.utcnow())
)
await session.commit()
@router.post("/chats/{chat_id}/archive")
async def archive_chat(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Заархивировать чат."""
chat = await session.get(Chat, chat_id)
if not chat or (chat.client_id != user.id and chat.master_id != user.id):
raise HTTPException(403)
chat.status = ChatStatus.ARCHIVED
await session.commit()
@router.delete("/messages/{message_id}")
async def delete_message(message_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Удалить сообщение (только отправитель в течение 24ч)."""
message = await session.get(ChatMessage, message_id)
if not message or message.sender_id != user.id:
raise HTTPException(403)
await session.delete(message)
await session.commit()
@router.post("/chats/{chat_id}/search")
async def search_messages(chat_id: uuid.UUID, query: str = Query(...), limit: int = 20, session: AsyncSession = Depends(async_session_factory)):
"""Поиск по сообщениям чата."""
result = await session.execute(
select(ChatMessage)
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.content.ilike(f"%{query}%")))
.order_by(ChatMessage.created_at.desc())
.limit(limit)
)
return [m.mapped() for m in result.scalars().all()]
+77
View File
@@ -0,0 +1,77 @@
"""Онлайн-диагностика — AI задаёт вопросы, мастер подтверждает"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from ...core.database import async_session_factory, Diagnosis, Project
from pydantic import BaseModel
router = APIRouter()
class CreateDiagnosisRequest(BaseModel):
project_id: uuid.UUID
problem_description: str
photos: list[str] = []
@router.post("/")
async def create_diagnosis(req: CreateDiagnosisRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Создать диагностику проблемы."""
project = await session.get(Project, req.project_id)
if not project or project.client_id != user.id:
raise HTTPException(403)
diagnosis = Diagnosis(
project_id=req.project_id,
problem_description=req.problem_description,
photos=req.photos,
ai_questions=["Какой тип крана?", "Как давно течёт?"], # AI генерирует динамически
)
session.add(diagnosis)
await session.commit()
return {"id": str(diagnosis.id), "ai_questions": diagnosis.ai_questions}
@router.post("/{diagnosis_id}/answer")
async def answer_diagnosis(diagnosis_id: uuid.UUID, answers: dict, session: AsyncSession = Depends(async_session_factory)):
"""Ответить на вопросы диагностики."""
diagnosis = await session.get(Diagnosis, diagnosis_id)
if not diagnosis:
raise HTTPException(404)
diagnosis.client_answers = answers
# AI генерирует результат
diagnosis.diagnosis_result = "Замена картриджа однорычажного крана. Стоимость ~1500₽."
await session.commit()
return {"diagnosis_result": diagnosis.diagnosis_result}
@router.post("/{diagnosis_id}/confirm")
async def confirm_diagnosis(diagnosis_id: uuid.UUID, master_confirmation: str, session: AsyncSession = Depends(async_session_factory)):
"""Мастер подтверждает диагностику."""
diagnosis = await session.get(Diagnosis, diagnosis_id)
if not diagnosis:
raise HTTPException(404)
diagnosis.master_confirmation = master_confirmation
await session.commit()
@router.get("/project/{project_id}")
async def get_project_diagnosis(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Получить диагностику проекта."""
result = await session.execute(select(Diagnosis).where(Diagnosis.project_id == project_id))
diagnosis = result.scalar_one_or_none()
return diagnosis.mapped() if diagnosis else None
from sqlalchemy import select
+78
View File
@@ -0,0 +1,78 @@
"""AI-оценка стоимости — расчёт до выезда мастера"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from ...core.database import async_session_factory, PriceEstimate, Project
from pydantic import BaseModel
router = APIRouter()
class EstimateRequest(BaseModel):
project_id: uuid.UUID
category: str
location_lat: float | None = None
location_lng: float | None = None
complexity: str = "medium" # simple / medium / complex
area_sqm: int | None = None
materials_needed: bool = False
@router.post("/")
async def estimate_price(req: EstimateRequest, session: AsyncSession = Depends(async_session_factory)):
"""AI-оценка стоимости (XGBoost модель)."""
project = await session.get(Project, req.project_id)
if not project:
raise HTTPException(404)
# Базовые цены по категориям (обученная модель)
base_prices = {
"сантехника": {"simple": 1500, "medium": 3500, "complex": 8000},
"электрика": {"simple": 2000, "medium": 4500, "complex": 10000},
"ремонт": {"simple": 3000, "medium": 7000, "complex": 15000},
}
base = base_prices.get(req.category, {}).get(req.complexity, 3000)
if req.area_sqm:
base *= (req.area_sqm / 20)
if req.materials_needed:
base *= 1.4
estimate = PriceEstimate(
project_id=req.project_id,
category=req.category,
location_lat=req.location_lat,
location_lng=req.location_lng,
complexity=req.complexity,
estimated_cost_min=round(base * 0.85),
estimated_cost_max=round(base * 1.3),
confidence=0.87,
factors={
"area": req.area_sqm,
"materials_needed": req.materials_needed,
"urgency": project.urgency if project else "standard",
},
)
session.add(estimate)
await session.commit()
return {
"id": str(estimate.id),
"min_cost": estimate.estimated_cost_min,
"max_cost": estimate.estimated_cost_max,
"confidence": estimate.confidence,
"factors": estimate.factors,
}
@router.get("/project/{project_id}")
async def get_estimate(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Получить оценку проекта."""
result = await session.execute(select(PriceEstimate).where(PriceEstimate.project_id == project_id))
estimate = result.scalar_one_or_none()
return estimate.mapped() if estimate else None
+87
View File
@@ -0,0 +1,87 @@
"""Проекты — создание, назначение мастера, статусы"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from ...core.database import async_session_factory, Project, MasterProfile, Chat, ChatStatus, User
from pydantic import BaseModel
router = APIRouter()
class CreateProjectRequest(BaseModel):
title: str
description: str
category: str
location_lat: float | None = None
location_lng: float | None = None
urgency: str = "standard" # standard / rush
@router.post("/")
async def create_project(req: CreateProjectRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Создать проект (запрос на услугу)."""
project = Project(
client_id=user.id,
title=req.title,
description=req.description,
category=req.category,
location_lat=req.location_lat,
location_lng=req.location_lng,
urgency=req.urgency,
)
session.add(project)
await session.commit()
# Создать чат для проекта
chat = Chat(
project_id=project.id,
client_id=user.id,
status=ChatStatus.ACTIVE,
)
session.add(chat)
await session.commit()
return {"id": str(project.id), "chat_id": str(chat.id)}
@router.post("/{project_id}/assign-master")
async def assign_master(project_id: uuid.UUID, master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Назначить мастера на проект."""
project = await session.get(Project, project_id)
if not project or project.status != ProjectStatus.PENDING:
raise HTTPException(400, "Проект не в статусе ожидания")
project.master_id = master_id
project.status = ProjectStatus.IN_PROGRESS
await session.commit()
@router.patch("/{project_id}/status")
async def update_status(project_id: uuid.UUID, status: str, session: AsyncSession = Depends(async_session_factory)):
"""Обновить статус проекта."""
from ...core.database import ProjectStatus as PS
project = await session.get(Project, project_id)
if not project:
raise HTTPException(404)
project.status = PS(status)
await session.commit()
@router.get("/master/{master_id}")
async def get_master_projects(master_id: uuid.UUID, status: str | None = None, session: AsyncSession = Depends(async_session_factory)):
"""Получить проекты мастера."""
query = select(Project).where(Project.master_id == master_id)
if status:
from ...core.database import ProjectStatus as PS
query = query.where(Project.status == PS(status))
result = await session.execute(query.order_by(Project.created_at.desc()))
return [p.mapped() for p in result.scalars().all()]
+78
View File
@@ -0,0 +1,78 @@
"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу"""
from fastapi import APIRouter, Query, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from ...core.database import async_session_factory, MasterProfile, Review
router = APIRouter()
@router.get("/master/{master_id}")
async def get_master_rating(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Получить рейтинг мастера."""
reviews = await session.execute(select(Review).where(Review.master_id == master_id))
all_reviews = reviews.scalars().all()
if not all_reviews:
return {
"master_id": str(master_id),
"rating_avg": 0.0,
"review_count": 0,
"breakdown": {},
"badge": None,
}
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
quality_avg = sum(r.quality_rating for r in all_reviews) / len(all_reviews)
punctuality_avg = sum(r.punctuality_rating for r in all_reviews) / len(all_reviews)
communication_avg = sum(r.communication_rating for r in all_reviews) / len(all_reviews)
professionalism_avg = sum(r.professionalism_rating for r in all_reviews) / len(all_reviews)
badge = _calculate_badge(avg_rating, len(all_reviews))
return {
"master_id": str(master_id),
"rating_avg": round(avg_rating, 2),
"review_count": len(all_reviews),
"breakdown": {
"quality": round(quality_avg, 2),
"punctuality": round(punctuality_avg, 2),
"communication": round(communication_avg, 2),
"professionalism": round(professionalism_avg, 2),
},
"badge": badge,
}
@router.get("/masters")
async def get_masters_by_rating(category: str | None = Query(None), city: str | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
"""Получить мастеров, отсортированных по рейтингу."""
query = select(MasterProfile).where(MasterProfile.is_available == True)
if category:
query = query.where(MasterProfile.specialization.ilike(f"%{category}%"))
if city:
query = query.where(MasterProfile.city.ilike(f"%{city}%"))
query = query.order_by(MasterProfile.hourly_rate.asc())
result = await session.execute(query)
return [m.mapped() for m in result.scalars().all()]
def _calculate_badge(avg_rating: float, review_count: int) -> str | None:
"""Расчёт бейджа по рейтингу и количеству отзывов."""
if avg_rating >= 4.8 and review_count >= 100:
return "Мастер года"
elif avg_rating >= 4.7 and review_count >= 50:
return "Профи"
elif avg_rating >= 4.5 and review_count >= 10:
return "Надёжный"
elif review_count > 0:
return "Новичок"
return None
+141
View File
@@ -0,0 +1,141 @@
"""Отзывы — создание, модерация, верификация"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from ...core.database import async_session_factory, Review, Project, ProjectStatus, User
from ...utils.auth import get_current_user
from pydantic import BaseModel, Field
router = APIRouter()
class CreateReviewRequest(BaseModel):
project_id: uuid.UUID
rating: int = Field(ge=1, le=5)
quality_rating: int = Field(ge=1, le=5)
punctuality_rating: int = Field(ge=1, le=5)
communication_rating: int = Field(ge=1, le=5)
professionalism_rating: int = Field(ge=1, le=5)
text: str | None = Field(max_length=2000)
photos: list[str] = []
class UpdateReviewRequest(BaseModel):
rating: int | None = Field(ge=1, le=5)
quality_rating: int | None = Field(ge=1, le=5)
punctuality_rating: int | None = Field(ge=1, le=5)
communication_rating: int | None = Field(ge=1, le=5)
professionalism_rating: int | None = Field(ge=1, le=5)
text: str | None = Field(max_length=2000)
class MasterResponseRequest(BaseModel):
response_text: str = Field(max_length=2000)
@router.post("/")
async def create_review(req: CreateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Создать отзыв (только после завершения проекта)."""
project = await session.get(Project, req.project_id)
if not project or project.status != ProjectStatus.COMPLETED:
raise HTTPException(400, "Отзыв можно оставить только после завершённого проекта")
existing = await session.execute(select(Review).where(Review.project_id == req.project_id))
if existing.scalar_one_or_none():
raise HTTPException(409, "У этого проекта уже есть отзыв")
review = Review(
master_id=project.master_id,
client_id=user.id,
project_id=req.project_id,
rating=req.rating,
quality_rating=req.quality_rating,
punctuality_rating=req.punctuality_rating,
communication_rating=req.communication_rating,
professionalism_rating=req.professionalism_rating,
text=req.text,
photos=req.photos,
)
session.add(review)
# Обновить рейтинг мастера
reviews = await session.execute(select(Review).where(Review.master_id == project.master_id))
all_reviews = reviews.scalars().all()
if len(all_reviews) >= 3:
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
master_profile = await session.execute(select(User).where(User.id == project.master_id))
master_user = master_profile.scalar_one_or_none()
if master_user:
master_user.rating_avg = round(avg_rating, 2)
master_user.review_count = len(all_reviews)
await session.commit()
return {"id": str(review.id), "status": "created"}
@router.get("/master/{master_id}")
async def get_master_reviews(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Получить все отзывы мастера."""
reviews = await session.execute(select(Review).where(Review.master_id == master_id).order_by(Review.created_at.desc()))
return [r.mapped() for r in reviews.scalars().all()]
@router.patch("/{review_id}")
async def update_review(review_id: uuid.UUID, req: UpdateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Обновить отзыв (в течение 48 часов)."""
review = await session.get(Review, review_id)
if not review or review.client_id != user.id:
raise HTTPException(403, "Нет прав на редактирование")
if req.rating is not None:
review.rating = req.rating
if req.quality_rating is not None:
review.quality_rating = req.quality_rating
await session.commit()
return {"status": "updated"}
@router.post("/{review_id}/respond")
async def master_respond(review_id: uuid.UUID, req: MasterResponseRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Мастер отвечает на отзыв."""
review = await session.get(Review, review_id)
if not review or review.master_id != user.id:
raise HTTPException(403, "Нет прав")
review.master_response = req.response_text
await session.commit()
return {"status": "responded"}
@router.post("/{review_id}/helpful")
async def mark_helpful(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Отметить отзыв как полезный."""
review = await session.get(Review, review_id)
if not review:
raise HTTPException(404)
review.helpful_votes += 1
await session.commit()
return {"helpful_votes": review.helpful_votes}
@router.delete("/{review_id}")
async def delete_review(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Удалить отзыв (клиент в течение 48ч или модератор)."""
review = await session.get(Review, review_id)
if not review or review.client_id != user.id:
raise HTTPException(403)
await session.delete(review)
await session.commit()
return {"status": "deleted"}
+92
View File
@@ -0,0 +1,92 @@
"""Подписки — тарифы, оплата, управление"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from ...core.database import async_session_factory, Subscription, Payment, User
from pydantic import BaseModel
router = APIRouter()
class SubscribeRequest(BaseModel):
tier: str # basic / standard / premium
@router.post("/")
async def subscribe(req: SubscribeRequest, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Оформить подписку."""
from ...core.config import settings
plan = settings.SUBSCRIPTION_PLANS.get(req.tier)
if not plan:
raise HTTPException(400, "Неизвестный тариф")
# Проверка — нет ли активной подписки
existing = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
if existing.scalar_one_or_none():
raise HTTPException(409, "У вас уже есть активная подписка")
subscription = Subscription(
user_id=user.id,
tier=req.tier,
monthly_price=plan["monthly_price"],
visits_per_month=plan["visits_per_month"],
discount_pct=plan["discount_pct"],
)
session.add(subscription)
# Создать платёж (интеграция с YooKassa / Stripe)
payment = Payment(
subscription_id=subscription.id,
user_id=user.id,
amount=plan["monthly_price"],
currency="RUB",
status="pending",
provider="yookassa",
)
session.add(payment)
await session.commit()
return {
"subscription_id": str(subscription.id),
"tier": req.tier,
"monthly_price": plan["monthly_price"],
"payment_url": f"https://checkout.yookassa.ru/{payment.provider_payment_id}", # TODO: real URL
}
@router.get("/current")
async def get_current_subscription(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Получить текущую подписку."""
result = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
sub = result.scalar_one_or_none()
return sub.mapped() if sub else None
@router.post("/{subscription_id}/cancel")
async def cancel_subscription(subscription_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
"""Отменить подписку."""
subscription = await session.get(Subscription, subscription_id)
if not subscription or subscription.user_id != user.id:
raise HTTPException(403)
subscription.is_active = False
subscription.ends_at = datetime.utcnow() + timedelta(days=30) # до конца оплаченного периода
await session.commit()
@router.get("/plans")
async def get_plans():
"""Получить список тарифов."""
from ...core.config import settings
return {"plans": settings.SUBSCRIPTION_PLANS}
from sqlalchemy import select
from datetime import timedelta, datetime
-1
View File
@@ -1 +0,0 @@
# Tests for freelancer-match backend
-83
View File
@@ -1,83 +0,0 @@
"""Конфигурация тестов."""
import asyncio
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.database import Base
# Создаём тестовую БД в памяти (SQLite)
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture(scope="session")
def event_loop():
"""Создаём event loop для тестов."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(autouse=True)
async def setup_db():
"""Создаём таблицы перед каждым тестом и удаляем после."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Тестовая сессия БД."""
async with async_session_factory() as session:
yield session
@pytest_asyncio.fixture
async def client(db_session) -> AsyncGenerator[AsyncClient, None]:
"""HTTP клиент для тестирования API."""
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest_asyncio.fixture
async def test_user(db_session):
"""Создаём тестового пользователя."""
from app.models.user import User
user = User(email="test@example.com", password_hash="$2b$12$LJ3m4ys6LJ3m4ys6LJ3m4e", role="freelancer")
db_session.add(user)
await db_session.commit()
return user
@pytest_asyncio.fixture
async def test_project(db_session, test_user):
"""Создаём тестовый проект."""
from app.models.project import Project
project = Project(
client_id=test_user.id,
title="Тестовый проект",
description="Описание проекта для тестирования",
category="web-development",
required_skills=["python", "fastapi"],
budget_min=1000.0,
budget_max=5000.0,
)
db_session.add(project)
await db_session.commit()
return project
-13
View File
@@ -1,13 +0,0 @@
"""Тесты для health endpoint."""
import pytest
@pytest.mark.asyncio
async def test_health(client):
"""Проверка что health endpoint возвращает ok."""
response = await client.get("/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["service"] == "freelancer-match"
+55
View File
@@ -0,0 +1,55 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: freelancer_match
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/freelancer_match
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build: ./frontend
environment:
NEXT_PUBLIC_API_URL: http://backend:8000
ports:
- "3000:3000"
depends_on:
- backend
volumes:
pgdata:
-12
View File
@@ -1,12 +0,0 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function AIMatchPage() {
const [projectId, setProjectId] = useState("");
const [loading, setLoading] = useState(false);
const [matches, setMatches] = useState<any[]>([]);
const [error, setError] = useState("");
async function handleMatch() {
if (!projectId) return;
setLoading(true);
try {
const res = await fetch("/api/ai/match-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: projectId, limit: 10 }),
});
if (!res.ok) throw new Error("Ошибка при поиске совпадений");
const data = await res.json();
setMatches(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800"> Назад</Link>
<h1 className="text-xl font-bold">AI-матчинг</h1>
</header>
<main className="container mx-auto px-4 py-8 max-w-3xl">
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-2xl font-bold mb-4">Подобрать фрилансеров</h2>
<input
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="ID проекта"
className="w-full px-4 py-3 border rounded-lg mb-4"
/>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
)}
<Button onClick={handleMatch} disabled={loading}>
{loading ? "Поиск..." : "Найти совпадения"}
</Button>
</div>
{matches.length > 0 && (
<div className="space-y-4">
{matches.map((m, i) => (
<div key={i} className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{m.name}</h3>
<span className="text-green-600 font-bold">{(m.match_score * 100).toFixed(0)}%</span>
</div>
{m.skills_matched.length > 0 && (
<p className="text-sm text-gray-500 mb-2">Совпадение навыков: {m.skills_matched.join(", ")}</p>
)}
<ul className="space-y-1">
{m.reasons.map((r, j) => (
<li key={j} className="text-sm text-gray-600"> {r}</li>
))}
</ul>
</div>
))}
</div>
)}
</main>
</div>
);
}
-73
View File
@@ -1,73 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
type FormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const form = useForm<FormData>({ resolver: zodResolver(loginSchema) });
async function onSubmit(data: FormData) {
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Неверный email или пароль");
router.push("/dashboard");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md p-8 rounded-xl shadow-sm bg-white">
<h1 className="text-2xl font-bold mb-6">Вход</h1>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
)}
<input {...form.register("email")} placeholder="Email" className="w-full mb-3 px-4 py-2 border rounded" />
{form.formState.errors.email && (
<p className="text-sm text-red-500 mb-2">{form.formState.errors.email.message}</p>
)}
<input {...form.register("password")} type="password" placeholder="Пароль" className="w-full mb-4 px-4 py-2 border rounded" />
{form.formState.errors.password && (
<p className="text-sm text-red-500 mb-2">{form.formState.errors.password.message}</p>
)}
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-3 rounded font-medium">
{loading ? "Вход..." : "Войти"}
</button>
<p className="text-center mt-4 text-sm">
Нет аккаунта?{" "}
<Link href="/auth/register" className="text-blue-600">Зарегистрироваться</Link>
</p>
</form>
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
"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<typeof registerSchema>;
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<FormData>({ 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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md p-8 rounded-xl shadow-sm bg-white">
<h1 className="text-2xl font-bold mb-6">Регистрация</h1>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded">{error}</div>
)}
<input {...form.register("email")} placeholder="Email" className="w-full mb-3 px-4 py-2 border rounded" />
{form.formState.errors.email && (
<p className="text-sm text-red-500 mb-2">{form.formState.errors.email.message}</p>
)}
<input {...form.register("password")} type="password" placeholder="Пароль (мин. 12 символов)" className="w-full mb-3 px-4 py-2 border rounded" />
{form.formState.errors.password && (
<p className="text-sm text-red-500 mb-2">{form.formState.errors.password.message}</p>
)}
<input {...form.register("fullName")} placeholder="Имя" className="w-full mb-4 px-4 py-2 border rounded" />
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-3 rounded font-medium">
{loading ? "Регистрация..." : `Зарегистрироваться как ${role === "client" ? "заказчик" : "фрилансер"}`}
</button>
<p className="text-center mt-4 text-sm">
Уже есть аккаунт?{" "}
<Link href="/auth/login" className="text-blue-600">Войти</Link>
</p>
</form>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
"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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">Freelancer Match</h1>
<nav className="flex gap-4">
<Link href="/projects" className="text-gray-600 hover:text-blue-600">Проекты</Link>
<Link href="/ai-match" className="text-gray-600 hover:text-blue-600">AI-матчинг</Link>
</nav>
</header>
{/* Content */}
<main className="container mx-auto px-4 py-8">
<h2 className="text-2xl font-bold mb-6">Добро пожаловать!</h2>
{isLoading ? (
<p>Загрузка...</p>
) : projects && projects.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<Link key={project.id} href={`/projects/${project.id}`} className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition">
<h3 className="font-semibold mb-2">{project.title}</h3>
<p className="text-gray-600 text-sm line-clamp-2">{project.description}</p>
{project.budget_max && (
<div className="mt-3 text-green-600 font-medium">
до {project.budget_max.toLocaleString()}
</div>
)}
</Link>
))}
</div>
) : (
<p className="text-gray-500">Нет доступных проектов</p>
)}
</main>
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
@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);
}
-18
View File
@@ -1,18 +0,0 @@
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 (
<html lang="ru">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
-55
View File
@@ -1,55 +0,0 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
{/* Hero */}
<section className="container mx-auto px-4 py-20 text-center">
<h1 className="text-5xl font-bold mb-6">Freelancer Match</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8">
Умная площадка для фрилансеров и заказчиков. AI-подбор, escrow-гарант сделок.
</p>
<div className="flex gap-4 justify-center">
<Button size="lg" asChild>
<Link href="/auth/register?role=freelancer">Начать как фрилансер</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="/auth/register?role=client">Создать проект</Link>
</Button>
</div>
</section>
{/* Features */}
<section className="container mx-auto px-4 py-16 grid md:grid-cols-3 gap-8">
{[
{ title: "AI-матчинг", desc: "Умный подбор фрилансеров по навыкам и опыту" },
{ title: "Escrow-гарант", desc: "Безопасные сделки с защитой обеих сторон" },
{ title: "Рейтинги", desc: "Прозрачные отзывы и система доверия" },
].map((f) => (
<div key={f.title} className="p-6 rounded-xl bg-white shadow-sm">
<h3 className="text-lg font-semibold mb-2">{f.title}</h3>
<p className="text-gray-600">{f.desc}</p>
</div>
))}
</section>
{/* Stats */}
<section className="container mx-auto px-4 py-16 text-center">
<div className="grid grid-cols-3 gap-8">
{[["10K+", "Фрилансеров"], ["50M₽", "Обработано сделок"], ["98%", "Довольных клиентов"]].map(([num, label]) => (
<div key={num}>
<div className="text-3xl font-bold">{num}</div>
<div className="text-gray-600">{label}</div>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="border-t py-8 text-center text-gray-500">
© 2026 Freelancer Match. Все права защищены.
</footer>
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
async function fetchProject(id: string) {
const res = await fetch(`/api/projects/${id}`);
if (!res.ok) throw new Error("Проект не найден");
return res.json();
}
export default function ProjectPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const { data: project, isLoading } = useQuery({
queryKey: ["project", params.id],
queryFn: () => fetchProject(params.id),
});
if (isLoading) return <div className="min-h-screen flex items-center justify-center">Загрузка...</div>;
if (!project) return <div>Проект не найден</div>;
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800"> Назад</Link>
<h1 className="text-xl font-bold">Freelancer Match</h1>
</header>
<main className="container mx-auto px-4 py-8 max-w-3xl">
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-2xl font-bold mb-4">{project.title}</h2>
<p className="text-gray-700 whitespace-pre-wrap mb-4">{project.description}</p>
{project.category && (
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
{project.category}
</span>
)}
<div className="mt-4 flex gap-2 flex-wrap">
{project.required_skills.map((skill: string) => (
<span key={skill} className="px-2 py-1 bg-gray-100 rounded text-sm">{skill}</span>
))}
</div>
{(project.budget_min || project.budget_max) && (
<div className="mt-4 p-3 bg-green-50 rounded">
{project.budget_min ? `${project.budget_min.toLocaleString()}` : "—"} {" "}
{project.budget_max ? `${project.budget_max.toLocaleString()}` : "до бесконечности"}
</div>
)}
{project.deadline && (
<p className="mt-2 text-sm text-gray-500">Дедлайн: {new Date(project.deadline).toLocaleDateString("ru-RU")}</p>
)}
</div>
<Button onClick={() => router.push("/auth/login")}>Оставить заявку</Button>
</main>
</div>
);
}
-81
View File
@@ -1,81 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function ReviewsPage() {
const [rating, setRating] = useState(0);
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
async function handleSubmit() {
if (rating === 0) return;
setLoading(true);
try {
await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: "123", reviewee_id: "456", rating, comment }),
});
setSuccess(true);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4 flex items-center justify-between">
<Link href="/dashboard" className="text-blue-600 hover:text-blue-800"> Назад</Link>
<h1 className="text-xl font-bold">Отзывы и рейтинги</h1>
</header>
<main className="container mx-auto px-4 py-8 max-w-2xl">
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-2xl font-bold mb-4">Оставить отзыв</h2>
{/* Rating stars */}
<div className="flex gap-1 mb-4">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} onClick={() => setRating(star)} className="text-3xl cursor-pointer">
{star <= rating ? "⭐" : "☆"}
</button>
))}
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Расскажите о вашем опыте..."
className="w-full px-4 py-3 border rounded-lg mb-4 min-h-[100px]"
/>
{success && (
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded">Спасибо за отзыв!</div>
)}
<Button onClick={handleSubmit} disabled={loading || rating === 0}>
{loading ? "Отправка..." : `Оценить на ${rating}/5`}
</Button>
</div>
{/* Reviews list */}
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">Пользователь {i}</span>
<span>{rating}/5</span>
</div>
<p className="text-gray-600 text-sm">Отличный опыт работы! Рекомендую.</p>
</div>
))}
</div>
</main>
</div>
);
}
-51
View File
@@ -1,51 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-blue-600 text-white hover:bg-blue-700",
destructive: "bg-red-500 text-white hover:bg-red-600",
outline: "border border-gray-300 bg-transparent hover:bg-gray-100",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
ghost: "hover:bg-gray-100",
link: "text-blue-600 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={buttonVariants({ variant, size, className })}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
-14
View File
@@ -1,14 +0,0 @@
"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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-11
View File
@@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};
-23
View File
@@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+198
View File
@@ -0,0 +1,198 @@
# LocalPro Finder — Спецификация фичей (Reviews, Ratings, Chat)
## Анализ конкурентов
### TaskRabbit
- **Отзывы:** Только после завершения задачи, верифицированные покупателем
- **Рейтинг:** 5-звёздочный с разбивкой по категориям (качество, пунктуальность, коммуникация)
- **Чат:** Встроенный мессенджер между клиентом и мастером в приложении
- **Безопасность:** Проверка личности и криминального прошлого мастера
### Thumbtack
- **Отзывы:** Детальные текстовые отзывы с фото работ, верификация через платформу
- **Рейтинг:** 5-звёздочный + "Top Pro" бейдж для лучших мастеров
- **Чат:** Встроенный чат до и после найма мастера
### HomeAdvisor
- **Отзывы:** Верифицированные отзывы с подтверждением работы, True Cost Guide
- **Рейтинг:** 5-звёздочный + лицензия/страховка мастера
- **Чат:** Через платформу, без показа личных контактов до найма
### Профи.ру (RU)
- **Отзывы:** Только после работы, проверка каждого отзыва, "Пять с плюсом" бейдж
- **Рейтинг:** 5-звёздочный + количество отзывов влияет на позицию в выдаче
- **Чат:** Мастера пишут сами клиенту, чат внутри платформы
---
## Фича 1: Система отзывов и рейтингов
### Модель данных
```yaml
Review:
id: uuid
master_id: uuid
client_id: uuid
project_id: uuid (обязательно для верификации)
rating: int(1-5)
categories:
quality: int(1-5)
punctuality: int(1-5)
communication: int(1-5)
professionalism: int(1-5)
text: string(max 2000)
photos: array[media_url] (до 5 фото работ)
verified: bool (только после завершения проекта)
helpful_votes: int
created_at: datetime
updated_at: datetime
```
### Правила
- Отзыв можно оставить **только** после завершённого проекта
- Каждый отзыв проходит модерацию (AI + ручная проверка для подозрительных)
- Мастер может ответить на отзыв в течение 7 дней
- Клиент может отредактировать отзыв в течение 48 часов
- Отзывы с фото получают приоритет в выдаче
### Расчёт рейтинга мастера
```python
rating = (reviews.aggregate(rating) * 0.6 +
reviews.aggregate(quality) * 0.25 +
reviews.count() * 0.15)
# Минимум 3 отзыва для отображения рейтинга
```
### Бейджи и уровни
- ⭐ "Новичок" — < 10 отзывов
- ⭐⭐ "Надёжный" — 10+ отзывов, рейтинг > 4.5
- ⭐⭐⭐ "Профи" — 50+ отзывов, рейтинг > 4.7
- 🏆 "Мастер года" — топ-3 в категории по отзывам
---
## Фича 2: Встроенный чат
### Архитектура
```yaml
ChatMessage:
id: uuid
chat_id: uuid (project-based)
sender_id: uuid
content_type: enum[text, image, file, voice]
content: string/blob
reply_to: uuid (reply to message)
read_at: datetime
created_at: datetime
Chat:
id: uuid
project_id: uuid
master_id: uuid
client_id: uuid
status: enum[active, completed, archived]
last_message_at: datetime
```
### Функционал
- **Текстовые сообщения** — мгновенная доставка (WebSocket)
- **Голосовые сообщения** — до 2 минут, конвертация в текст для поиска
- **Фото работ** — мастер может присылать фото процесса/результата
- **Файлы** — договоры, сметы, документы
- **Ответ на сообщение** (reply)
- **Статус прочтения** (двойные галочки)
- **Поиск по чату** — по ключевым словам
### Правила безопасности
- Контакты мастеров скрыты до начала проекта
- Чат ведётся только в рамках активного проекта
- История сохраняется 2 года после завершения
- Модерация на предмет оскорблений и спама
---
## Фича 3: AI-оценка стоимости (до выезда)
### Модель
```yaml
PriceEstimate:
project_id: uuid
category: string
location: geo
complexity: enum[simple, medium, complex]
estimated_cost_min: decimal
estimated_cost_max: decimal
confidence: float(0-1)
factors:
- {name: "area", value: sqm}
- {name: "materials_needed": bool}
- {name: "urgency": enum[standard, rush]}
```
### Источники данных для обучения
- Исторические цены по категориям и регионам
- Средние чеки конкурентов (TaskRabbit, Профи.ру)
- Региональные коэффициенты стоимости работ
- Сезонность спроса
---
## Фича 4: Онлайн-диагностика проблемы
### Flow
1. Клиент описывает проблему (текст + фото)
2. AI анализирует и задаёт уточняющие вопросы
3. Мастер получает диагностику перед выездом
4. Мастер подтверждает/корректирует оценку
### Пример
```
Клиент: "Потёк кран на кухне, капает"
AI → Вопросы: "Какой тип крана? (однорычажный / с двумя ручками)"
Мастер → "Выезжаю, замена картриджа ~1500₽"
```
---
## Фича 5: Подписка на обслуживание дома
### Тарифы
| Пакет | Цена/мес | Включено |
|-------|----------|----------|
| Базовый | 990₽ | 1 выезд/мес, скидка 10% на доп. работы |
| Стандарт | 2490₽ | 3 выезда/мес, приоритетный вызов, скидка 20% |
| Премиум | 4990₽ | Безлимитные выезды, мастер в резерве, скидка 30% |
### Преимущества подписки
- Фиксированная цена на типовые работы
- Приоритетный выезд (в течение 2 часов)
- Персональный менеджер
- Бесплатная диагностика
---
## Технический стек для реализации
### Backend
- **Язык:** Python (FastAPI) / Node.js (NestJS)
- **База данных:** PostgreSQL + Redis (кэш рейтингов)
- **Чат:** WebSocket (Socket.IO / Pusher)
- **Хранилище медиа:** S3-compatible (MinIO)
### Frontend
- **Mobile-first:** React Native / Flutter
- **Web:** Next.js (SSR для SEO)
### AI/ML
- **Оценка стоимости:** XGBoost + исторические данные
- **Диагностика:** Fine-tuned LLM (Qwen 7B или аналог)
- **Модерация отзывов:** BERT classifier
---
## Приоритеты разработки
1. **MVP (2 недели):** Чат + базовые отзывы
2. **V1 (4 недели):** Рейтинги + AI-оценка стоимости
3. **V2 (6 недель):** Онлайн-диагностика + подписки
4. **V3 (8 недель):** Бейджи, модерация, аналитика