Compare commits
1 Commits
main
..
0b785db1b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b785db1b3 |
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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
|
||||
@@ -1,33 +1,160 @@
|
||||
# Proxy Finder Module
|
||||
# Freelancer Match — Умная площадка для фрилансеров
|
||||
|
||||
## find_anon_proxies.py — Поиск анонимных прокси
|
||||
Площадка для фрилансеров и заказчиков с AI-подбором, escrow-гарантом сделок и рейтинговой системой.
|
||||
|
||||
### Использование:
|
||||
```bash
|
||||
python3 /root/.openclaw/workspace/scripts/proxy-finder/find_anon_proxies.py
|
||||
```
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Что делает:
|
||||
1. Загружает прокси из 6 источников (proxyscrape, spys.me, sslproxies.org и др.)
|
||||
2. Парсит IP:PORT форматы
|
||||
3. Фильтрует локальные/резервированные адреса
|
||||
4. Проверяет работоспособность через curl
|
||||
5. Тестирует анонимность (проверка X-Forwarded-For)
|
||||
### Требования
|
||||
- Docker + Docker Compose
|
||||
- Python 3.12+ (для локальной разработки)
|
||||
- Node.js 20+ (для фронтенда)
|
||||
- PostgreSQL 16+
|
||||
- Redis 7+
|
||||
|
||||
### Результат:
|
||||
Сохраняется в `anonymous_proxies.txt` — формат: `IP:PORT (HTTP/SOCKS5)`
|
||||
|
||||
---
|
||||
|
||||
## Использование прокси для запросов:
|
||||
### Запуск через Docker Compose
|
||||
|
||||
```bash
|
||||
# HTTP-запрос через прокси
|
||||
curl -s --proxy http://PROXY_IP:PORT https://example.com
|
||||
# 1. Скопируйте .env.example в .env и заполните переменные
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# SOCKS5-запрос через прокси
|
||||
curl -s --socks5 PROXY_IP:PORT https://example.com
|
||||
# 2. Запустите стек
|
||||
docker compose up -d --build
|
||||
|
||||
# Python requests с прокси
|
||||
python3 -c "import requests; r = requests.get('https://example.com', proxies={'http': 'socks5://PROXY_IP:PORT'}); print(r.text)"
|
||||
# 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
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
101.255.209.93:8090
|
||||
162.240.19.30:80
|
||||
85.117.56.85:8080
|
||||
163.172.129.144:3128
|
||||
97.213.76.123:80
|
||||
197.221.234.149:80
|
||||
138.201.198.53:80
|
||||
154.66.159.34:800
|
||||
38.127.172.7:37234
|
||||
176.115.146.232:8080
|
||||
191.37.33.38:42999
|
||||
110.164.175.110:8080
|
||||
165.154.7.156:8888
|
||||
103.172.17.51:8080
|
||||
185.128.240.2:3128
|
||||
45.232.152.2:8080
|
||||
103.13.204.84:8082
|
||||
103.85.183.30:4995
|
||||
43.133.187.166:3128
|
||||
186.194.62.202:999
|
||||
82.115.60.51:80
|
||||
176.94.224.86:8080
|
||||
43.167.199.44:3128
|
||||
190.60.34.250:999
|
||||
49.148.47.13:8080
|
||||
147.231.163.133:80
|
||||
38.19.42.176:999
|
||||
117.55.203.165:8899
|
||||
207.246.234.115:4669
|
||||
150.136.163.51:80
|
||||
4.233.138.204:8888
|
||||
207.180.254.198:8080
|
||||
157.66.16.38:8070
|
||||
217.160.39.73:18080
|
||||
124.106.83.244:8083
|
||||
77.235.31.24:8080
|
||||
163.172.167.48:80
|
||||
113.11.37.81:2505
|
||||
43.255.159.94:3129
|
||||
167.88.164.6:3129
|
||||
79.76.121.87:3128
|
||||
169.239.208.70:8080
|
||||
103.10.228.119:8080
|
||||
12.50.107.217:80
|
||||
157.230.241.91:3128
|
||||
149.28.137.172:8888
|
||||
210.79.146.82:8085
|
||||
103.1.51.177:8181
|
||||
103.124.199.116:8080
|
||||
54.38.35.209:3128
|
||||
103.17.215.9:8089
|
||||
206.189.144.164:10808
|
||||
178.128.59.180:18080
|
||||
42.116.10.196:443
|
||||
70.35.196.194:8082
|
||||
5.101.5.160:2080
|
||||
176.88.166.165:8080
|
||||
38.171.255.232:999
|
||||
103.170.22.145:8080
|
||||
103.19.58.134:8080
|
||||
3.110.246.113:3128
|
||||
103.47.13.41:8080
|
||||
172.93.101.178:3128
|
||||
103.125.38.50:8080
|
||||
103.108.146.142:8080
|
||||
45.139.91.62:8080
|
||||
38.75.81.10:999
|
||||
160.19.19.170:3125
|
||||
103.224.55.154:8090
|
||||
115.187.29.25:89
|
||||
129.151.130.247:1111
|
||||
178.156.206.253:8118
|
||||
45.4.202.147:999
|
||||
103.84.228.12:3128
|
||||
177.139.174.81:8080
|
||||
181.37.240.89:999
|
||||
103.129.127.244:8088
|
||||
202.51.106.229:8080
|
||||
154.126.213.152:8070
|
||||
210.16.85.42:8080
|
||||
103.153.190.43:8080
|
||||
38.199.67.10:999
|
||||
103.156.248.102:8080
|
||||
103.44.2.177:8080
|
||||
84.22.42.41:8080
|
||||
103.173.141.10:8080
|
||||
170.245.132.81:999
|
||||
104.152.50.252:8000
|
||||
36.92.104.123:8000
|
||||
37.35.65.190:8080
|
||||
103.84.177.28:8083
|
||||
38.226.241.242:8080
|
||||
194.180.188.100:8080
|
||||
103.29.239.132:8080
|
||||
77.240.97.77:8080
|
||||
103.231.236.235:8182
|
||||
27.116.41.156:8470
|
||||
103.156.74.209:3125
|
||||
38.172.128.139:999
|
||||
51.161.137.166:8080
|
||||
45.231.221.129:999
|
||||
103.137.218.166:83
|
||||
38.188.186.47:999
|
||||
187.62.65.5:8080
|
||||
103.68.215.46:8080
|
||||
210.177.178.148:80
|
||||
157.10.184.115:8080
|
||||
79.106.33.26:8079
|
||||
152.228.145.27:8080
|
||||
190.103.205.253:9097
|
||||
41.59.90.171:80
|
||||
186.216.208.98:3128
|
||||
84.244.119.36:23500
|
||||
38.188.48.65:8080
|
||||
43.133.30.18:3128
|
||||
101.255.157.6:8080
|
||||
131.222.247.180:8080
|
||||
103.131.232.9:8080
|
||||
103.209.36.58:8080
|
||||
200.8.200.191:8088
|
||||
190.83.40.166:3128
|
||||
156.232.99.66:10808
|
||||
131.222.247.238:8080
|
||||
82.39.154.145:8080
|
||||
201.222.50.218:80
|
||||
124.106.223.156:9999
|
||||
65.108.103.19:80
|
||||
221.120.208.25:8080
|
||||
196.1.93.10:80
|
||||
194.14.207.87:80
|
||||
103.204.46.130:8090
|
||||
103.173.214.187:8080
|
||||
74.62.179.122:8080
|
||||
75.84.71.14:80
|
||||
197.221.240.176:80
|
||||
157.100.12.154:999
|
||||
102.36.160.95:8080
|
||||
103.125.155.230:8080
|
||||
190.26.209.126:999
|
||||
178.156.224.42:3128
|
||||
79.110.192.44:8081
|
||||
186.96.15.70:8080
|
||||
196.251.223.54:8080
|
||||
103.158.96.77:8080
|
||||
181.143.145.98:8080
|
||||
165.0.136.30:8080
|
||||
103.180.123.103:8090
|
||||
103.175.240.65:7777
|
||||
103.25.220.22:8081
|
||||
161.49.87.222:8095
|
||||
45.188.167.25:999
|
||||
103.164.171.210:8080
|
||||
154.9.30.1:50000
|
||||
43.153.199.126:8888
|
||||
103.118.44.33:8080
|
||||
175.143.19.216:8081
|
||||
103.99.27.43:3125
|
||||
180.191.2.165:8081
|
||||
103.247.14.222:8080
|
||||
185.65.247.133:48049
|
||||
181.143.42.138:8080
|
||||
197.221.249.196:80
|
||||
135.125.154.101:8899
|
||||
91.122.216.160:8080
|
||||
34.143.154.126:8888
|
||||
103.120.175.243:9191
|
||||
103.191.171.142:8181
|
||||
103.13.192.76:8080
|
||||
103.227.187.11:6090
|
||||
139.255.5.98:443
|
||||
103.242.106.155:3125
|
||||
190.97.228.67:999
|
||||
43.133.1.198:3128
|
||||
43.167.214.133:3128
|
||||
43.245.93.193:53805
|
||||
103.152.239.127:3125
|
||||
192.248.95.98:54126
|
||||
138.252.158.7:8080
|
||||
168.243.77.190:999
|
||||
103.187.86.10:8182
|
||||
103.51.223.133:8080
|
||||
138.121.114.54:8080
|
||||
85.117.61.108:8080
|
||||
208.67.28.19:58090
|
||||
43.229.79.190:8080
|
||||
149.86.206.27:8080
|
||||
139.135.182.132:8081
|
||||
168.228.176.12:3139
|
||||
161.49.90.70:1337
|
||||
114.8.131.181:8080
|
||||
177.234.194.234:999
|
||||
103.180.126.236:8080
|
||||
141.136.13.51:8080
|
||||
180.180.218.250:8080
|
||||
164.90.223.123:3128
|
||||
213.131.85.30:1976
|
||||
143.208.84.2:8589
|
||||
103.126.87.120:8082
|
||||
47.52.223.161:5872
|
||||
103.192.174.154:8080
|
||||
103.175.236.180:8382
|
||||
149.28.87.103:8888
|
||||
212.231.191.23:80
|
||||
122.117.203.252:3128
|
||||
108.161.135.118:80
|
||||
103.106.79.98:8080
|
||||
45.179.200.38:999
|
||||
103.112.131.14:8080
|
||||
103.194.175.51:7777
|
||||
45.167.126.21:999
|
||||
103.111.225.106:8080
|
||||
34.43.46.91:80
|
||||
61.19.145.66:8080
|
||||
45.224.23.238:999
|
||||
38.54.116.154:3128
|
||||
190.60.60.37:8080
|
||||
43.133.22.248:9091
|
||||
103.41.88.182:84
|
||||
45.189.252.18:999
|
||||
86.127.243.197:80
|
||||
181.188.216.3:8080
|
||||
222.252.14.70:8443
|
||||
109.224.242.151:8080
|
||||
85.214.107.177:80
|
||||
70.61.188.34:3128
|
||||
51.81.6.158:3128
|
||||
217.162.8.134:80
|
||||
103.80.83.27:8080
|
||||
43.153.182.147:3128
|
||||
193.38.224.169:8081
|
||||
177.234.226.83:1994
|
||||
119.2.45.81:3125
|
||||
187.102.211.253:999
|
||||
89.28.81.217:8443
|
||||
103.156.17.139:8818
|
||||
64.49.15.225:8443
|
||||
38.19.111.74:8080
|
||||
201.140.209.33:3128
|
||||
103.3.59.208:8080
|
||||
204.157.251.213:999
|
||||
112.198.138.14:8082
|
||||
113.192.30.27:7777
|
||||
103.15.214.70:8080
|
||||
103.1.93.184:55443
|
||||
110.34.13.4:8080
|
||||
81.0.49.104:20500
|
||||
180.195.60.98:8081
|
||||
103.145.34.100:1111
|
||||
161.49.215.28:10101
|
||||
202.138.240.249:8080
|
||||
192.203.0.250:999
|
||||
103.144.102.60:8181
|
||||
124.121.186.200:8080
|
||||
85.214.204.79:80
|
||||
185.231.59.189:8080
|
||||
110.34.1.178:7777
|
||||
45.174.77.1:999
|
||||
180.191.233.18:5050
|
||||
103.154.77.46:1111
|
||||
180.191.254.10:8081
|
||||
103.165.155.195:8080
|
||||
157.20.157.24:8080
|
||||
122.3.145.194:8083
|
||||
45.65.227.161:999
|
||||
87.106.120.212:3128
|
||||
186.125.6.241:8080
|
||||
43.167.187.107:3128
|
||||
103.209.38.132:8080
|
||||
43.167.245.99:3128
|
||||
82.22.184.158:3128
|
||||
119.93.83.106:8082
|
||||
124.104.197.204:8080
|
||||
183.110.216.128:8090
|
||||
167.99.124.118:80
|
||||
102.0.25.184:8080
|
||||
51.79.71.202:8080
|
||||
45.198.8.204:8080
|
||||
103.178.21.104:3125
|
||||
186.250.202.104:8080
|
||||
45.32.69.101:9000
|
||||
103.43.191.71:8888
|
||||
106.0.158.114:8080
|
||||
103.164.214.122:8080
|
||||
124.156.230.244:3128
|
||||
103.61.16.20:8780
|
||||
206.135.55.224:999
|
||||
103.26.176.25:8080
|
||||
103.245.110.198:1452
|
||||
43.133.169.167:3128
|
||||
181.204.185.98:8081
|
||||
103.171.194.52:84
|
||||
125.209.110.83:39617
|
||||
185.219.86.12:8080
|
||||
105.22.37.218:8080
|
||||
50.114.33.3:8080
|
||||
62.90.70.144:7443
|
||||
45.224.23.235:999
|
||||
103.169.38.66:8080
|
||||
45.155.102.216:3128
|
||||
181.115.67.3:999
|
||||
174.104.115.21:80
|
||||
104.161.23.122:5003
|
||||
45.169.148.2:999
|
||||
43.160.246.46:3128
|
||||
103.118.224.19:5678
|
||||
154.17.8.103:1680
|
||||
190.110.226.122:80
|
||||
142.147.119.181:8080
|
||||
189.193.225.86:999
|
||||
114.111.151.41:80
|
||||
190.61.63.106:8080
|
||||
46.203.233.116:3128
|
||||
103.67.85.150:3127
|
||||
103.49.166.193:83
|
||||
176.99.134.183:8090
|
||||
180.232.171.210:8080
|
||||
45.240.232.62:8080
|
||||
105.27.130.22:9812
|
||||
176.12.65.24:443
|
||||
190.97.231.0:999
|
||||
103.110.10.162:1992
|
||||
176.61.151.123:80
|
||||
103.133.24.73:8787
|
||||
89.43.134.35:8080
|
||||
163.227.146.17:8181
|
||||
47.236.86.147:443
|
||||
201.140.185.41:8081
|
||||
38.43.93.161:8000
|
||||
38.194.251.246:999
|
||||
45.179.244.8:999
|
||||
200.215.229.33:999
|
||||
194.58.42.190:3128
|
||||
36.95.208.10:8080
|
||||
149.62.230.50:8080
|
||||
103.231.239.137:58080
|
||||
18.169.141.215:3128
|
||||
60.249.149.98:8080
|
||||
190.97.253.233:999
|
||||
156.155.50.93:8080
|
||||
210.61.216.63:60808
|
||||
165.227.169.229:3080
|
||||
203.146.80.235:8080
|
||||
103.97.140.64:8080
|
||||
41.184.92.220:80
|
||||
43.167.213.156:3128
|
||||
45.239.48.98:999
|
||||
51.178.253.98:80
|
||||
160.19.19.100:8080
|
||||
109.199.125.66:3128
|
||||
101.255.208.18:8090
|
||||
190.131.205.150:999
|
||||
103.76.12.158:8080
|
||||
104.194.148.188:3128
|
||||
45.89.82.210:8080
|
||||
103.229.164.178:3128
|
||||
49.0.1.60:8080
|
||||
38.199.71.79:999
|
||||
212.67.31.235:8080
|
||||
95.78.161.82:7777
|
||||
102.164.255.155:8080
|
||||
157.20.252.7:8097
|
||||
118.163.13.200:8080
|
||||
43.251.253.40:8080
|
||||
92.255.248.78:8080
|
||||
104.248.156.141:8888
|
||||
160.19.16.101:8181
|
||||
143.198.135.176:80
|
||||
103.102.153.215:33128
|
||||
38.127.172.219:37234
|
||||
150.107.136.205:39843
|
||||
13.114.160.78:80
|
||||
101.255.209.182:1111
|
||||
45.225.89.145:999
|
||||
38.211.24.242:8080
|
||||
177.234.247.238:999
|
||||
14.240.238.209:8080
|
||||
95.3.69.222:8080
|
||||
20.110.224.54:3128
|
||||
165.232.119.106:3128
|
||||
103.76.109.167:80
|
||||
103.102.14.64:666
|
||||
68.183.139.61:3128
|
||||
41.184.92.219:80
|
||||
103.242.105.58:8080
|
||||
152.32.68.171:65535
|
||||
52.140.40.92:80
|
||||
202.179.69.216:58080
|
||||
140.245.255.83:3128
|
||||
92.119.164.84:1100
|
||||
190.225.182.3:999
|
||||
102.38.13.9:19000
|
||||
103.70.44.6:8080
|
||||
182.253.228.155:80
|
||||
34.140.137.151:80
|
||||
187.62.241.136:8080
|
||||
103.177.8.119:8080
|
||||
177.93.39.75:999
|
||||
193.178.203.141:8080
|
||||
64.181.246.100:3128
|
||||
157.245.159.127:3128
|
||||
109.120.184.202:1080
|
||||
157.180.84.115:443
|
||||
47.238.203.170:50000
|
||||
81.90.29.194:10808
|
||||
91.186.213.124:1081
|
||||
87.121.47.93:8080
|
||||
92.118.112.32:1082
|
||||
72.56.238.99:9090
|
||||
203.162.13.222:6868
|
||||
8.215.25.3:2080
|
||||
185.200.188.234:10001
|
||||
174.137.134.182:2999
|
||||
202.28.194.139:31280
|
||||
91.107.182.124:82
|
||||
71.198.208.169:443
|
||||
159.195.49.27:8888
|
||||
82.146.38.71:443
|
||||
187.72.215.33:3128
|
||||
113.160.132.26:8080
|
||||
47.84.204.82:80
|
||||
117.236.124.166:3128
|
||||
103.69.96.15:8888
|
||||
217.154.155.115:8080
|
||||
62.133.62.184:1082
|
||||
54.38.138.60:3128
|
||||
200.227.89.50:3128
|
||||
54.38.139.182:3128
|
||||
94.158.49.82:3128
|
||||
91.188.213.143:1080
|
||||
135.136.188.42:1080
|
||||
94.198.218.123:3128
|
||||
14.143.222.113:57748
|
||||
103.167.61.162:3128
|
||||
110.49.66.210:8080
|
||||
45.157.140.12:1080
|
||||
151.241.234.208:8443
|
||||
34.96.238.40:8080
|
||||
62.133.62.17:1081
|
||||
64.188.77.26:3128
|
||||
202.49.176.24:2080
|
||||
85.234.100.149:8080
|
||||
93.113.63.11:3128
|
||||
141.11.243.30:3128
|
||||
195.158.8.123:3128
|
||||
94.182.225.248:3128
|
||||
193.151.151.177:3129
|
||||
81.168.119.85:443
|
||||
159.223.87.50:443
|
||||
45.95.233.237:1082
|
||||
178.250.156.112:443
|
||||
5.181.178.46:8080
|
||||
202.61.225.20:3128
|
||||
65.108.159.129:8081
|
||||
54.170.110.107:3128
|
||||
152.32.132.190:7890
|
||||
199.189.255.230:1080
|
||||
213.21.254.26:1081
|
||||
65.108.203.37:28080
|
||||
79.175.188.203:443
|
||||
103.130.63.15:1080
|
||||
38.75.82.216:999
|
||||
38.224.21.1:999
|
||||
77.242.21.133:8080
|
||||
144.202.14.153:50000
|
||||
35.212.167.35:8888
|
||||
82.207.117.120:8080
|
||||
191.5.38.6:54121
|
||||
47.89.184.18:3128
|
||||
8.209.255.13:3128
|
||||
8.213.151.128:3128
|
||||
47.91.65.23:3128
|
||||
47.243.92.199:3128
|
||||
18.139.186.25:3128
|
||||
43.110.40.117:8888
|
||||
135.181.113.216:16379
|
||||
104.244.78.150:5555
|
||||
182.53.202.208:8080
|
||||
194.87.219.78:2080
|
||||
35.203.180.73:8080
|
||||
144.31.222.106:7890
|
||||
@@ -0,0 +1,34 @@
|
||||
# ============================================
|
||||
# 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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""AI endpoints: матчинг, рекомендации."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.ai_match import AIMatchRequest, AIMatchResponse
|
||||
from app.services.ai_service import find_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/ai", tags=["ai"])
|
||||
|
||||
|
||||
@router.post("/match-project", response_model=list[AIMatchResponse])
|
||||
async def match_project(data: AIMatchRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Подобрать фрилансеров для проекта через AI."""
|
||||
|
||||
matches = await find_matches(
|
||||
db=db, project_id=data.project_id, limit=data.limit, min_score=data.min_score
|
||||
)
|
||||
|
||||
return [AIMatchResponse(**m) for m in matches]
|
||||
|
||||
|
||||
@router.post("/generate-cover-letter")
|
||||
async def generate_cover_letter(project_title: str, freelancer_skills: list[str]):
|
||||
"""Сгенерировать сопроводительное письмо для заявки."""
|
||||
|
||||
# Placeholder — в продакшене вызов LLM
|
||||
return {
|
||||
"cover_letter": f"Здравствуйте! Я заинтересован в проекте '{project_title}'. Мой опыт работы с [{', '.join(freelancer_skills)}] позволяет качественно выполнить задачу."
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Auth endpoints: регистрация, логин, OAuth."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import (
|
||||
hash_password, verify_password, create_access_token, create_refresh_token, get_current_user
|
||||
)
|
||||
from app.schemas.user import UserCreate, UserLogin
|
||||
from app.schemas.auth import TokenPair
|
||||
from app.models.user import User, FreelancerProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenPair)
|
||||
async def register(data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Регистрация нового пользователя."""
|
||||
|
||||
# Проверка email
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Email уже зарегистрирован")
|
||||
|
||||
# Создание пользователя
|
||||
user = User(
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
full_name=data.full_name,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Создаём профиль фрилансера по умолчанию
|
||||
if data.role in ("freelancer", "both"):
|
||||
profile = FreelancerProfile(
|
||||
user_id=user.id,
|
||||
skills=data.full_name or [], # заполняется позже
|
||||
)
|
||||
db.add(profile)
|
||||
await db.commit()
|
||||
|
||||
# Генерация токенов
|
||||
access_token = create_access_token({"sub": str(user.id), "role": data.role})
|
||||
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||
|
||||
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
"""Логин пользователя."""
|
||||
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
|
||||
access_token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
refresh_token = create_refresh_token({"sub": str(user.id)})
|
||||
|
||||
return TokenPair(access_token=access_token, refresh_token=refresh_token)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair)
|
||||
async def refresh(token: dict = Depends(get_current_user)):
|
||||
"""Обновление access-токена."""
|
||||
new_access = create_access_token({"sub": token["id"], "role": token["role"]})
|
||||
return TokenPair(access_token=new_access, refresh_token="")
|
||||
|
||||
|
||||
@router.get("/me", response_model=dict)
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
"""Получить текущего пользователя."""
|
||||
return {"user_id": user["id"], "role": user["role"]}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Escrow endpoints (гарант)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.escrow import EscrowCreate, EscrowRelease
|
||||
from app.models.escrow import EscrowTransaction
|
||||
from app.models.project import Project
|
||||
|
||||
router = APIRouter(prefix="/api/escrow", tags=["escrow"])
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_escrow(data: EscrowCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Создать escrow-транзакцию."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
transaction = EscrowTransaction(
|
||||
project_id=data.project_id,
|
||||
client_id=data.client_id,
|
||||
freelancer_id=data.freelancer_id,
|
||||
amount=data.amount,
|
||||
status="pending",
|
||||
)
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
return {
|
||||
"id": str(transaction.id),
|
||||
"status": transaction.status,
|
||||
"amount": float(transaction.amount),
|
||||
"payment_url": f"https://stripe.com/pay/{transaction.id}", # Stripe redirect
|
||||
}
|
||||
|
||||
|
||||
@router.post("/release")
|
||||
async def release_escrow(data: EscrowRelease, db: AsyncSession = Depends(get_db)):
|
||||
"""Освободить средства фрилансеру."""
|
||||
|
||||
result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == data.transaction_id))
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if not transaction or transaction.status != "locked":
|
||||
raise HTTPException(status_code=400, detail="Транзакция не может быть разблокирована")
|
||||
|
||||
# Комиссия платформы 10%
|
||||
commission = transaction.amount * 0.10
|
||||
freelancer_amount = transaction.amount - commission
|
||||
|
||||
transaction.status = "released"
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": str(transaction.id),
|
||||
"status": "released",
|
||||
"freelancer_payout": float(freelancer_amount),
|
||||
"platform_commission": float(commission),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dispute")
|
||||
async def dispute_escrow(transaction_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Открыть спор по escrow."""
|
||||
|
||||
result = await db.execute(select(EscrowTransaction).where(EscrowTransaction.id == transaction_id))
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Транзакция не найдена")
|
||||
|
||||
transaction.status = "disputed"
|
||||
await db.commit()
|
||||
|
||||
return {"status": "dispute_opened", "transaction_id": str(transaction.id)}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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)}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Endpoints для проектов."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||
from app.models.user import User
|
||||
from app.models.project import Project
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse)
|
||||
async def create_project(data: ProjectCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
|
||||
"""Создать новый проект."""
|
||||
|
||||
project = Project(
|
||||
client_id=user["id"],
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
category=data.category,
|
||||
required_skills=data.required_skills,
|
||||
budget_min=data.budget_min,
|
||||
budget_max=data.budget_max,
|
||||
deadline=data.deadline,
|
||||
)
|
||||
db.add(project)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
return ProjectResponse(
|
||||
id=str(project.id),
|
||||
title=project.title,
|
||||
description=project.description,
|
||||
category=project.category,
|
||||
required_skills=project.required_skills,
|
||||
budget_min=float(project.budget_min) if project.budget_min else None,
|
||||
budget_max=float(project.budget_max) if project.budget_max else None,
|
||||
status=project.status,
|
||||
deadline=str(project.deadline) if project.deadline else None,
|
||||
created_at=str(project.created_at),
|
||||
updated_at=str(project.updated_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectResponse])
|
||||
async def list_projects(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
category: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
):
|
||||
"""Список проектов с фильтрацией."""
|
||||
|
||||
stmt = select(Project)
|
||||
|
||||
if status_filter:
|
||||
stmt = stmt.where(Project.status == status_filter)
|
||||
if category:
|
||||
stmt = stmt.where(Project.category == category)
|
||||
|
||||
stmt = stmt.order_by(desc(Project.created_at)).offset((page - 1) * limit).limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
projects = result.scalars().all()
|
||||
|
||||
return [ProjectResponse(
|
||||
id=str(p.id), title=p.title, description=p.description, category=p.category,
|
||||
required_skills=p.required_skills, budget_min=float(p.budget_min) if p.budget_min else None,
|
||||
budget_max=float(p.budget_max) if p.budget_max else None, status=p.status,
|
||||
deadline=str(p.deadline) if p.deadline else None, created_at=str(p.created_at), updated_at=str(p.updated_at),
|
||||
) for p in projects]
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Получить проект по ID."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
return ProjectResponse(
|
||||
id=str(project.id), title=project.title, description=project.description, category=project.category,
|
||||
required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None,
|
||||
budget_max=float(project.budget_max) if project.budget_max else None, status=project.status,
|
||||
deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(project_id: str, data: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
"""Обновить проект."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(project, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
return ProjectResponse(
|
||||
id=str(project.id), title=project.title, description=project.description, category=project.category,
|
||||
required_skills=project.required_skills, budget_min=float(project.budget_min) if project.budget_min else None,
|
||||
budget_max=float(project.budget_max) if project.budget_max else None, status=project.status,
|
||||
deadline=str(project.deadline) if project.deadline else None, created_at=str(project.created_at), updated_at=str(project.updated_at),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Endpoints для заявок фрилансеров."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.schemas.proposal import ProposalCreate, ProposalResponse
|
||||
from app.models.project import Project
|
||||
from app.models.proposal import Proposal
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}/proposals", tags=["proposals"])
|
||||
|
||||
|
||||
@router.post("", response_model=ProposalResponse)
|
||||
async def create_proposal(
|
||||
project_id: str, data: ProposalCreate, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Фрилансер подаёт заявку на проект."""
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Проект не найден")
|
||||
|
||||
proposal = Proposal(
|
||||
project_id=project_id,
|
||||
freelancer_id=user["id"],
|
||||
cover_letter=data.cover_letter,
|
||||
proposed_price=data.proposed_price,
|
||||
estimated_days=data.estimated_days,
|
||||
)
|
||||
db.add(proposal)
|
||||
await db.commit()
|
||||
await db.refresh(proposal)
|
||||
|
||||
return ProposalResponse(
|
||||
id=str(proposal.id), project_id=project_id, freelancer_id=user["id"],
|
||||
cover_letter=proposal.cover_letter, proposed_price=float(proposal.proposed_price) if proposal.proposed_price else None,
|
||||
estimated_days=proposal.estimated_days, status=proposal.status, created_at=str(proposal.created_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProposalResponse])
|
||||
async def list_proposals(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Список заявок на проект."""
|
||||
|
||||
result = await db.execute(select(Proposal).where(Proposal.project_id == project_id).order_by(desc(Proposal.created_at)))
|
||||
proposals = result.scalars().all()
|
||||
|
||||
return [ProposalResponse(
|
||||
id=str(p.id), project_id=p.project_id, freelancer_id=p.freelancer_id, cover_letter=p.cover_letter,
|
||||
proposed_price=float(p.proposed_price) if p.proposed_price else None, estimated_days=p.estimated_days,
|
||||
status=p.status, created_at=str(p.created_at),
|
||||
) for p in proposals]
|
||||
|
||||
|
||||
@router.patch("/{proposal_id}/status")
|
||||
async def update_proposal_status(proposal_id: str, status: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Обновить статус заявки (accept/reject)."""
|
||||
|
||||
result = await db.execute(select(Proposal).where(Proposal.id == proposal_id))
|
||||
proposal = result.scalar_one_or_none()
|
||||
|
||||
if not proposal:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
|
||||
proposal.status = status
|
||||
await db.commit()
|
||||
|
||||
return {"status": "updated", "proposal_id": str(proposal.id)}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Конфигурация приложения."""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# БД
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# OpenAI (для AI-матчинга)
|
||||
OPENAI_API_KEY: str = ""
|
||||
|
||||
# OAuth
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GITHUB_CLIENT_ID: str = ""
|
||||
GITHUB_CLIENT_SECRET: str = ""
|
||||
|
||||
# Stripe (Escrow)
|
||||
STRIPE_SECRET_KEY: str = ""
|
||||
STRIPE_WEBHOOK_SECRET: str = ""
|
||||
|
||||
# Email
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
EMAIL_FROM: str = "noreply@freelancermatch.com"
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000", "https://freelancermatch.com"]
|
||||
|
||||
# AI Matching
|
||||
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||
MATCH_MIN_SCORE: float = 0.7
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Подключение к PostgreSQL и Redis."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Зависимость для получения сессии БД."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Безопасность: JWT, хеширование паролей."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def create_refresh_token(data: dict[str, Any]) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
data_copy = data.copy()
|
||||
data_copy.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(data_copy, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict[str, Any]:
|
||||
"""Извлечь текущего пользователя из токена."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
user_id = payload.get("sub")
|
||||
role = payload.get("role")
|
||||
if not user_id or not role:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return {"id": user_id, "role": role}
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Модели 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",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Модель AI-рекомендаций."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AIMatch(Base):
|
||||
__tablename__ = "ai_matches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
match_score: Mapped[float] = mapped_column(Float(precision=5, scale=4), nullable=False)
|
||||
reasons: Mapped[list] = mapped_column("reasons", postgresql.JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Модель Escrow-транзакций (гарант)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class EscrowTransaction(Base):
|
||||
__tablename__ = "escrow_transactions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Float(precision=10, scale=2), nullable=False)
|
||||
status: Mapped[str] = mapped_column(Enum("pending", "locked", "released", "disputed", "refunded"), default="pending")
|
||||
released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Модель сообщений чата."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
work_session_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("work_sessions.id"), nullable=False)
|
||||
sender_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attachments: Mapped[list | None] = mapped_column("attachments", postgresql.JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Модель 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())
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Модель уведомлений."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Boolean, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(50)) # proposal_received, payment_released, etc.
|
||||
title: Mapped[str | None] = mapped_column(String(255))
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
is_read: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Модель портфолио фрилансера."""
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Модели проектов и заявок."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
category: Mapped[str | None] = mapped_column(String(100))
|
||||
required_skills: Mapped[list] = mapped_column("required_skills", postgresql.JSONB, default=list)
|
||||
skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector
|
||||
budget_min: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
budget_max: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
status: Mapped[str] = mapped_column(Enum("open", "in_progress", "completed", "cancelled"), default="open")
|
||||
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
client = relationship("User", foreign_keys=[client_id])
|
||||
proposals: Mapped[list["Proposal"]] = relationship(back_populates="project")
|
||||
|
||||
|
||||
class Proposal(Base):
|
||||
__tablename__ = "proposals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
cover_letter: Mapped[str | None] = mapped_column(Text)
|
||||
proposed_price: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
estimated_days: Mapped[int | None] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(Enum("pending", "accepted", "rejected"), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="proposals")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Модель отзывов."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Review(Base):
|
||||
__tablename__ = "reviews"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
reviewer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
reviewee_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
rating: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Модель 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))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Модели пользователей."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
full_name: Mapped[str | None] = mapped_column(String(100))
|
||||
avatar_url: Mapped[str | None] = mapped_column(Text)
|
||||
role: Mapped[str] = mapped_column(Enum("client", "freelancer", "both"), nullable=False, default="freelancer")
|
||||
is_verified: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
freelancer_profile: Mapped["FreelancerProfile | None"] = relationship(back_populates="user", uselist=False)
|
||||
client_profile: Mapped["ClientProfile | None"] = relationship(back_populates="user", uselist=False)
|
||||
|
||||
|
||||
class FreelancerProfile(Base):
|
||||
__tablename__ = "freelancer_profiles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False)
|
||||
bio: Mapped[str | None] = mapped_column(Text)
|
||||
skills: Mapped[list] = mapped_column("skills", postgresql.JSONB, default=list)
|
||||
skill_embeddings: Mapped[list | None] = mapped_column("skill_embeddings") # pgvector VECTOR(1536)
|
||||
hourly_rate: Mapped[float | None] = mapped_column(Float(precision=10, scale=2))
|
||||
portfolio_items: Mapped[list] = mapped_column("portfolio_items", postgresql.JSONB, default=list)
|
||||
experience_years: Mapped[int | None] = mapped_column(Integer)
|
||||
languages: Mapped[list] = mapped_column("languages", postgresql.JSONB, default=list)
|
||||
rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0)
|
||||
total_jobs_completed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
response_time_hours: Mapped[int | None] = mapped_column(Integer)
|
||||
is_online: Mapped[bool] = mapped_column(default=False)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="freelancer_profile")
|
||||
|
||||
|
||||
class ClientProfile(Base):
|
||||
__tablename__ = "client_profiles"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, nullable=False)
|
||||
company_name: Mapped[str | None] = mapped_column(String(255))
|
||||
industry: Mapped[str | None] = mapped_column(String(100))
|
||||
budget_range: Mapped[dict | None] = mapped_column("budget_range", postgresql.JSONB)
|
||||
total_spent: Mapped[float] = mapped_column(Float(precision=10, scale=6), default=0.0)
|
||||
rating: Mapped[float] = mapped_column(Float(precision=3, scale=2), default=5.0)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="client_profile")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Модель верификации профиля (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())
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Модель рабочей сессии."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class WorkSession(Base):
|
||||
__tablename__ = "work_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
|
||||
freelancer_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
client_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
hours_worked: Mapped[float] = mapped_column(Float(precision=5, scale=2), default=0.0)
|
||||
status: Mapped[str] = mapped_column(Enum("active", "paused", "completed"), default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Pydantic схемы для валидации запросов/ответов."""
|
||||
|
||||
from app.schemas.user import UserCreate, UserLogin, UserProfileUpdate, FreelancerProfileCreate
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||
from app.schemas.proposal import ProposalCreate, ProposalResponse
|
||||
from app.schemas.ai_match import AIMatchRequest, AIMatchResponse
|
||||
from app.schemas.escrow import EscrowCreate, EscrowRelease
|
||||
from app.schemas.auth import TokenPair
|
||||
|
||||
__all__ = [
|
||||
"UserCreate", "UserLogin", "UserProfileUpdate", "FreelancerProfileCreate",
|
||||
"ProjectCreate", "ProjectUpdate", "ProjectResponse",
|
||||
"ProposalCreate", "ProposalResponse",
|
||||
"AIMatchRequest", "AIMatchResponse",
|
||||
"EscrowCreate", "EscrowRelease",
|
||||
"TokenPair",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Схемы 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]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Схемы авторизации."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Схемы 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 транзакции")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Схемы проектов."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
title: str = Field(..., min_length=5, max_length=255)
|
||||
description: str = Field(..., min_length=20)
|
||||
category: str | None = None
|
||||
required_skills: list[str] = []
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
deadline: str | None = None # ISO format
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
budget_min: float | None = None
|
||||
budget_max: float | None = None
|
||||
deadline: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
category: str | None
|
||||
required_skills: list[str]
|
||||
budget_min: float | None
|
||||
budget_max: float | None
|
||||
status: str
|
||||
deadline: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Схемы заявок."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProposalCreate(BaseModel):
|
||||
cover_letter: str | None = None
|
||||
proposed_price: float | None = None
|
||||
estimated_days: int | None = None
|
||||
|
||||
|
||||
class ProposalResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
freelancer_id: str
|
||||
cover_letter: str | None
|
||||
proposed_price: float | None
|
||||
estimated_days: int | None
|
||||
status: str
|
||||
created_at: str
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Схемы отзывов и рейтингов."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Схемы пользователей."""
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str # min 12 chars
|
||||
role: str = "freelancer" # client | freelancer | both
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class FreelancerProfileCreate(BaseModel):
|
||||
bio: str | None = None
|
||||
skills: list[str] = []
|
||||
hourly_rate: float | None = None
|
||||
experience_years: int | None = None
|
||||
languages: list[str] = []
|
||||
@@ -0,0 +1,150 @@
|
||||
"""AI-сервис для матчинга фрилансеров и проектов."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import openai
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.project import Project
|
||||
from app.models.user import FreelancerProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_embedding(text: str) -> list[float]:
|
||||
"""Получить эмбеддинг через OpenAI."""
|
||||
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
response = await client.embeddings.create(
|
||||
model=settings.EMBEDDING_MODEL,
|
||||
input=text,
|
||||
)
|
||||
return response.data[0].embedding
|
||||
|
||||
|
||||
async def calculate_match_score(project: Project, freelancer: FreelancerProfile) -> dict[str, Any]:
|
||||
"""Рассчитать score совпадения проекта и фрилансера."""
|
||||
|
||||
# 1. Semantic similarity (если есть эмбеддинги)
|
||||
skill_similarity = _cosine_similarity(
|
||||
project.skill_embeddings or [],
|
||||
freelancer.skill_embeddings or []
|
||||
) if project.skill_embeddings and freelancer.skill_embeddings else 0.5
|
||||
|
||||
# 2. Опыт
|
||||
exp_score = min(freelancer.experience_years / max(project.required_skills.__len__() * 3, 3), 1.0) \
|
||||
if freelancer.experience_years else 0.5
|
||||
|
||||
# 3. Рейтинг
|
||||
rating_weight = (freelancer.rating - 4.0) * 0.5 + 0.5
|
||||
|
||||
# 4. Время ответа
|
||||
response_score = max(0, 1 - (freelancer.response_time_hours or 24) / 24)
|
||||
|
||||
# 5. Совпадение навыков
|
||||
skills_match = len(set(project.required_skills) & set(freelancer.skills)) / \
|
||||
max(len(project.required_skills), 1) if project.required_skills else 0
|
||||
|
||||
# Взвешенная сумма
|
||||
score = (
|
||||
skill_similarity * 0.35 +
|
||||
exp_score * 0.20 +
|
||||
rating_weight * 0.15 +
|
||||
response_score * 0.10 +
|
||||
skills_match * 0.20
|
||||
)
|
||||
|
||||
# Генерация причин через LLM
|
||||
reasons = await _generate_reasons(project, freelancer)
|
||||
|
||||
return {
|
||||
"match_score": round(score, 4),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
async def find_matches(
|
||||
db: AsyncSession, project_id: str, limit: int = 10, min_score: float | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Найти лучших фрилансеров для проекта."""
|
||||
|
||||
# Получаем проект
|
||||
from app.models.ai_match import AIMatch
|
||||
|
||||
# Проверяем кэш в Redis
|
||||
import redis.asyncio as aioredis
|
||||
r = aioredis.from_url(settings.REDIS_URL)
|
||||
cache_key = f"ai_matches:{project_id}"
|
||||
cached = await r.get(cache_key)
|
||||
|
||||
if cached:
|
||||
return __import__("json").loads(cached)
|
||||
|
||||
# Запрос к БД (упрощённый — в продакшене нужен pgvector query)
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(FreelancerProfile).order_by(
|
||||
FreelancerProfile.rating.desc()
|
||||
).limit(limit * 2)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
freelancers = result.scalars().all()
|
||||
|
||||
matches = []
|
||||
for freelancer in freelancers:
|
||||
match_data = await calculate_match_score(project, freelancer)
|
||||
|
||||
if min_score and match_data["match_score"] < min_score:
|
||||
continue
|
||||
|
||||
matches.append({
|
||||
"freelancer_id": str(freelancer.user_id),
|
||||
"name": freelancer.user.full_name or "Аноним",
|
||||
"skills_matched": list(set(project.required_skills) & set(freelancer.skills)),
|
||||
**match_data,
|
||||
})
|
||||
|
||||
matches.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
top_matches = matches[:limit]
|
||||
|
||||
# Сохраняем в Redis на 1 час
|
||||
await r.setex(cache_key, 3600, __import__("json").dumps(top_matches))
|
||||
|
||||
return top_matches
|
||||
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Косинусное сходство двух векторов."""
|
||||
if not a or not b:
|
||||
return 0.5
|
||||
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = (sum(x ** 2 for x in a) ** 0.5)
|
||||
norm_b = (sum(y ** 2 for y in b) ** 0.5)
|
||||
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.5
|
||||
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
async def _generate_reasons(project: Project, freelancer: FreelancerProfile) -> list[str]:
|
||||
"""Генерация причин совпадения через LLM."""
|
||||
|
||||
reasons = []
|
||||
|
||||
if freelancer.experience_years and freelancer.experience_years >= 3:
|
||||
reasons.append(f"Опыт {freelancer.experience_years} лет в разработке")
|
||||
|
||||
matched_skills = set(project.required_skills) & set(freelancer.skills)
|
||||
if matched_skills:
|
||||
reasons.append(f"Совпадение навыков: {', '.join(list(matched_skills)[:3])}")
|
||||
|
||||
if freelancer.rating >= 4.5:
|
||||
reasons.append("Высокий рейтинг")
|
||||
|
||||
if not reasons:
|
||||
reasons.append("Профиль соответствует требованиям проекта")
|
||||
|
||||
return reasons
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.37
|
||||
alembic==1.14.1
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.7.1
|
||||
pydantic[email]==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
openai==1.58.1
|
||||
redis[hiredis]==5.2.1
|
||||
celery==5.4.0
|
||||
stripe==11.3.0
|
||||
python-multipart==0.0.18
|
||||
@@ -0,0 +1 @@
|
||||
# Tests for freelancer-match backend
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Конфигурация тестов."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Тесты для 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"
|
||||
@@ -1,233 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
find_anon_proxies.py — Поиск анонимных прокси-серверов
|
||||
|
||||
Источники:
|
||||
- https://spys.me/proxy.txt (текстовый формат)
|
||||
- https://www.sslproxies.org/ (HTML таблица)
|
||||
- http://netips.ch/proxylist/anonymous.html (HTML таблица)
|
||||
- https://openproxy.space/list (JSON)
|
||||
|
||||
Проверка:
|
||||
- HTTP-прокси (GET к google.com через curl --proxy)
|
||||
- SOCKS5 прокси (через curl --socks5)
|
||||
- Анонимность (проверка X-Forwarded-For заголовка)
|
||||
- Скорость ответа
|
||||
|
||||
Вывод:
|
||||
- Сохраняет в /root/.openclaw/workspace/scripts/proxy-finder/anonymous_proxies.txt
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
|
||||
def fetch_with_curl(url):
|
||||
"""Загрузка через curl с таймаутом."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['curl', '-sL', '--connect-timeout', '5', url],
|
||||
capture_output=True, text=True, timeout=8
|
||||
)
|
||||
if result.returncode == 0 and len(result.stdout) > 100:
|
||||
return url, result.stdout
|
||||
except Exception as e:
|
||||
print(f"[!] Ошибка загрузки {url}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def parse_spys_me(data):
|
||||
"""Парсинг spys.me формата."""
|
||||
proxies = []
|
||||
for line in data.splitlines():
|
||||
match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*:\s*(\d+)', line)
|
||||
if match:
|
||||
ip, port = match.group(1), int(match.group(2))
|
||||
octets = [int(x) for x in ip.split('.')]
|
||||
if not (octets[0] == 10 or (octets[0] == 172 and 16 <= octets[1] <= 31) or
|
||||
octets[0] == 192 and octets[1] == 168 or octets[0] == 127 or octets[0] == 0):
|
||||
proxies.append((ip, port))
|
||||
return proxies
|
||||
|
||||
|
||||
def parse_html_table(data):
|
||||
"""Парсинг HTML-таблиц (sslproxies.org, netips.ch)."""
|
||||
proxies = []
|
||||
for match in re.finditer(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s*:\s*(\d+)', data):
|
||||
ip, port = match.group(1), int(match.group(2))
|
||||
octets = [int(x) for x in ip.split('.')]
|
||||
if not (octets[0] == 10 or (octets[0] == 172 and 16 <= octets[1] <= 31) or
|
||||
octets[0] == 192 and octets[1] == 168 or octets[0] == 127 or octets[0] == 0):
|
||||
proxies.append((ip, port))
|
||||
return proxies
|
||||
|
||||
|
||||
def parse_proxies(data, source_url):
|
||||
"""Умный парсинг в зависимости от источника."""
|
||||
if 'spys.me' in source_url:
|
||||
return parse_spys_me(data)
|
||||
else:
|
||||
# HTML таблицы — ищем IP:PORT паттерн
|
||||
return parse_html_table(data)
|
||||
|
||||
|
||||
def check_proxy_http(ip, port):
|
||||
"""Проверка HTTP-прокси через curl."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'--proxy', f'http://{ip}:{port}',
|
||||
'https://httpbin.org/ip'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0 and ip in result.stdout:
|
||||
return (ip, port, 'HTTP')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_proxy_socks5(ip, port):
|
||||
"""Проверка SOCKS5-прокси через curl."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'--socks5', f'{ip}:{port}',
|
||||
'https://httpbin.org/ip'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0 and ip in result.stdout:
|
||||
return (ip, port, 'SOCKS5')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_anonymity(ip, port, proxy_type):
|
||||
"""Проверка анонимности прокси."""
|
||||
try:
|
||||
if proxy_type == 'HTTP':
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'--proxy', f'http://{ip}:{port}',
|
||||
'https://httpbin.org/headers'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'--socks5', f'{ip}:{port}',
|
||||
'https://httpbin.org/headers'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Проверяем отсутствие X-Forwarded-For (анонимный прокси)
|
||||
if 'X-Forwarded-For' not in result.stdout.lower():
|
||||
return (ip, port, proxy_type, True) # Анонимный
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_speed(ip, port, proxy_type):
|
||||
"""Проверка скорости прокси."""
|
||||
try:
|
||||
if proxy_type == 'HTTP':
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'--proxy', f'http://{ip}:{port}',
|
||||
'-o', '/dev/null', '-w', '%{time_total}',
|
||||
'https://httpbin.org/ip'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
['curl', '-s', '--connect-timeout', '5',
|
||||
'--socks5', f'{ip}:{port}',
|
||||
'-o', '/dev/null', '-w', '%{time_total}',
|
||||
'https://httpbin.org/ip'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return float(result.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("[*] Поиск анонимных прокси...")
|
||||
|
||||
sources = [
|
||||
'https://spys.me/proxy.txt',
|
||||
'https://www.sslproxies.org/',
|
||||
'http://netips.ch/proxylist/anonymous.html',
|
||||
]
|
||||
|
||||
all_proxies = []
|
||||
print("[*] Загрузка из источников...")
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [executor.submit(fetch_with_curl, url) for url in sources]
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
url, data = result
|
||||
proxies = parse_proxies(data, url)
|
||||
print(f" [+] {url}: найдено {len(proxies)} прокси")
|
||||
all_proxies.extend(proxies)
|
||||
|
||||
# Убираем дубликаты
|
||||
seen = set()
|
||||
unique_proxies = []
|
||||
for ip, port in all_proxies:
|
||||
key = f"{ip}:{port}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_proxies.append((ip, port))
|
||||
|
||||
print(f"\n[*] Всего уникальных прокси: {len(unique_proxies)}")
|
||||
|
||||
# Сохраняем все найденные (быстрый режим)
|
||||
output_file = '/root/.openclaw/workspace/scripts/proxy-finder/anonymous_proxies.txt'
|
||||
with open(output_file, 'w') as f:
|
||||
for ip, port in unique_proxies[:500]: # Ограничиваем до 500 для скорости
|
||||
f.write(f"{ip}:{port} (unverified)\n")
|
||||
|
||||
print(f" [+] Сохранено {len(unique_proxies[:500])} прокси в {output_file}")
|
||||
|
||||
# Опциональная проверка (медленная)
|
||||
if len(sys.argv) > 1 and sys.argv[1] == '--verify':
|
||||
print("[*] Проверка HTTP-прокси...")
|
||||
working_http = []
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [executor.submit(check_proxy_http, ip, port) for ip, port in unique_proxies[:200]]
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
working_http.append(result)
|
||||
|
||||
print(f" [+] Рабочих HTTP-прокси: {len(working_http)}")
|
||||
|
||||
# Проверяем анонимность и скорость
|
||||
anon_proxies = []
|
||||
for ip, port, ptype in working_http[:50]:
|
||||
result = check_anonymity(ip, port, ptype)
|
||||
if result:
|
||||
speed = check_speed(ip, port, ptype)
|
||||
anon_proxies.append((ip, port, ptype, speed))
|
||||
|
||||
print(f" [+] Анонимных прокси: {len(anon_proxies)}")
|
||||
|
||||
# Обновляем файл с проверенными
|
||||
with open(output_file, 'w') as f:
|
||||
for ip, port, ptype, speed in anon_proxies:
|
||||
f.write(f"{ip}:{port} ({ptype}, {speed:.2f}s)\n")
|
||||
|
||||
print(f"\n[+] Результат сохранён в {output_file}")
|
||||
return unique_proxies
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,86 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(12),
|
||||
fullName: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
|
||||
async function fetchProjects() {
|
||||
const res = await fetch("/api/projects?status=open");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: projects, isLoading } = useQuery({ queryKey: ["projects"], queryFn: fetchProjects });
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0a;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/lib/providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Freelancer Match — Умная площадка для фрилансеров",
|
||||
description: "AI-подбор фрилансеров, escrow-гарант сделок",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 };
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "freelancer-match-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[*] Тестирую 482 SOCKS5 прокси...
|
||||
[+] WORKING: 104.152.50.252:8000 -> 104.152.50.252
|
||||
[+] WORKING: 170.106.136.181:31002 -> 170.106.136.181
|
||||
[+] WORKING: 154.89.148.25:443 -> 154.89.148.25
|
||||
[+] WORKING: 8.215.25.3:2080 -> 8.215.25.3
|
||||
[+] WORKING: 91.107.182.124:82 -> 91.107.182.124
|
||||
[+] WORKING: 159.223.87.50:443 -> 159.223.87.50
|
||||
[+] WORKING: 62.133.62.3:1082 -> 62.133.62.3
|
||||
|
||||
[+] Итого рабочих: 7 из 482
|
||||
их: 7 из 482
|
||||
.207
|
||||
[+] WORKING: 144.31.222.106:7890 -> 104.28.155.180
|
||||
|
||||
[+] Итого рабочих: 9 из 482
|
||||
@@ -1,7 +0,0 @@
|
||||
104.152.50.252:8000 (exit IP: 104.152.50.252)
|
||||
170.106.136.181:31002 (exit IP: 170.106.136.181)
|
||||
154.89.148.25:443 (exit IP: 154.89.148.25)
|
||||
8.215.25.3:2080 (exit IP: 8.215.25.3)
|
||||
91.107.182.124:82 (exit IP: 91.107.182.124)
|
||||
159.223.87.50:443 (exit IP: 159.223.87.50)
|
||||
62.133.62.3:1082 (exit IP: 62.133.62.3)
|
||||
Reference in New Issue
Block a user