Files
marriage/scripts/models.sh
Andrey K. Choi cc87dcc0fa
Some checks failed
continuous-integration/drone/push Build is failing
api development
2025-08-08 21:58:36 +09:00

1565 lines
58 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# -------------------------------------------------------------------
# Apply models + CRUD + API + JWT auth to the existing scaffold
# Requires: the scaffold created earlier (services/* exist)
# -------------------------------------------------------------------
ROOT_DIR="."
SERVICES=(auth profiles match chat payments)
ensure_line() {
# ensure_line <file> <literal_line>
local file="$1" ; shift
local line="$*"
grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file"
}
write_file() {
# write_file <path> <<'EOF' ... EOF
local path="$1"
mkdir -p "$(dirname "$path")"
# The content will be provided by heredoc by the caller
cat > "$path"
}
append_file() {
local path="$1"
mkdir -p "$(dirname "$path")"
cat >> "$path"
}
require_file() {
local path="$1"
if [[ ! -f "$path" ]]; then
echo "ERROR: Missing $path. Run scaffold.sh first." >&2
exit 1
fi
}
# Basic checks
require_file docker-compose.yml
# -------------------------------------------------------------------
# 1) .env.example — добавить JWT настройки (общие для всех сервисов)
# -------------------------------------------------------------------
ENV_FILE=".env.example"
require_file "$ENV_FILE"
ensure_line "$ENV_FILE" "# ---------- JWT / Auth ----------"
ensure_line "$ENV_FILE" "JWT_SECRET=devsecret_change_me"
ensure_line "$ENV_FILE" "JWT_ALGORITHM=HS256"
ensure_line "$ENV_FILE" "ACCESS_TOKEN_EXPIRES_MIN=15"
ensure_line "$ENV_FILE" "REFRESH_TOKEN_EXPIRES_MIN=43200 # 30 days"
# -------------------------------------------------------------------
# 2) requirements.txt — добавить PyJWT везде, в auth — ещё passlib[bcrypt]
# -------------------------------------------------------------------
for s in "${SERVICES[@]}"; do
REQ="services/$s/requirements.txt"
require_file "$REQ"
ensure_line "$REQ" "PyJWT>=2.8"
if [[ "$s" == "auth" ]]; then
ensure_line "$REQ" "passlib[bcrypt]>=1.7"
fi
done
# -------------------------------------------------------------------
# 3) Общая безопасность (JWT) для всех сервисов
# В auth добавим + генерацию токенов, в остальных — верификация и RBAC
# -------------------------------------------------------------------
for s in "${SERVICES[@]}"; do
SEC="services/$s/src/app/core/security.py"
mkdir -p "$(dirname "$SEC")"
if [[ "$s" == "auth" ]]; then
write_file "$SEC" <<'PY'
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any, Callable, Optional
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRES_MIN = int(os.getenv("ACCESS_TOKEN_EXPIRES_MIN", "15"))
REFRESH_TOKEN_EXPIRES_MIN = int(os.getenv("REFRESH_TOKEN_EXPIRES_MIN", "43200"))
class TokenType(str, Enum):
access = "access"
refresh = "refresh"
class UserClaims(BaseModel):
sub: str
email: str
role: str
type: str
exp: int
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
def _create_token(token_type: TokenType, *, sub: str, email: str, role: str, expires_minutes: int) -> str:
now = datetime.now(timezone.utc)
exp = now + timedelta(minutes=expires_minutes)
payload: dict[str, Any] = {
"sub": sub,
"email": email,
"role": role,
"type": token_type.value,
"exp": int(exp.timestamp()),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def create_access_token(*, sub: str, email: str, role: str) -> str:
return _create_token(TokenType.access, sub=sub, email=email, role=role, expires_minutes=ACCESS_TOKEN_EXPIRES_MIN)
def create_refresh_token(*, sub: str, email: str, role: str) -> str:
return _create_token(TokenType.refresh, sub=sub, email=email, role=role, expires_minutes=REFRESH_TOKEN_EXPIRES_MIN)
def decode_token(token: str) -> UserClaims:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return UserClaims(**payload)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
return decode_token(token)
def require_roles(*roles: str) -> Callable[[UserClaims], UserClaims]:
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
if roles and user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return dep
PY
else
write_file "$SEC" <<'PY'
from __future__ import annotations
import os
from enum import Enum
from typing import Any, Callable
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
JWT_SECRET = os.getenv("JWT_SECRET", "devsecret_change_me")
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
class UserClaims(BaseModel):
sub: str
email: str
role: str
type: str
exp: int
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v1/token")
def decode_token(token: str) -> UserClaims:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return UserClaims(**payload)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> UserClaims:
return decode_token(token)
def require_roles(*roles: str):
def dep(user: UserClaims = Depends(get_current_user)) -> UserClaims:
if roles and user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return dep
PY
fi
done
# -------------------------------------------------------------------
# 4) AUTH service — модели, CRUD, токены, эндпоинты
# -------------------------------------------------------------------
# models
write_file services/auth/src/app/models/user.py <<'PY'
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.session import Base
class Role(str, Enum):
ADMIN = "ADMIN"
MATCHMAKER = "MATCHMAKER"
CLIENT = "CLIENT"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
role: Mapped[str] = mapped_column(String(32), default=Role.CLIENT.value, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
PY
write_file services/auth/src/app/models/__init__.py <<'PY'
from .user import User, Role # noqa: F401
PY
# schemas
write_file services/auth/src/app/schemas/user.py <<'PY'
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, EmailStr, ConfigDict
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
role: str = "CLIENT"
is_active: bool = True
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: Optional[str] = None
role: str = "CLIENT"
class UserUpdate(BaseModel):
full_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
class UserRead(BaseModel):
id: str
email: EmailStr
full_name: Optional[str] = None
role: str
is_active: bool
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
PY
# passwords
write_file services/auth/src/app/core/passwords.py <<'PY'
from passlib.context import CryptContext
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(p: str) -> str:
return _pwd.hash(p)
def verify_password(p: str, hashed: str) -> bool:
return _pwd.verify(p, hashed)
PY
# repositories
write_file services/auth/src/app/repositories/user_repository.py <<'PY'
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy.orm import Session
from sqlalchemy import select, update, delete
from app.models.user import User
class UserRepository:
def __init__(self, db: Session):
self.db = db
def get(self, user_id) -> Optional[User]:
return self.db.get(User, user_id)
def get_by_email(self, email: str) -> Optional[User]:
stmt = select(User).where(User.email == email)
return self.db.execute(stmt).scalar_one_or_none()
def list(self, *, offset: int = 0, limit: int = 50) -> Sequence[User]:
stmt = select(User).offset(offset).limit(limit).order_by(User.created_at.desc())
return self.db.execute(stmt).scalars().all()
def create(self, *, email: str, password_hash: str, full_name: str | None, role: str) -> User:
user = User(email=email, password_hash=password_hash, full_name=full_name, role=role)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def update(self, user: User, **fields) -> User:
for k, v in fields.items():
if v is not None:
setattr(user, k, v)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return user
def delete(self, user: User) -> None:
self.db.delete(user)
self.db.commit()
PY
# services
write_file services/auth/src/app/services/user_service.py <<'PY'
from __future__ import annotations
from typing import Optional
from sqlalchemy.orm import Session
from app.repositories.user_repository import UserRepository
from app.core.passwords import hash_password, verify_password
from app.models.user import User
class UserService:
def __init__(self, db: Session):
self.repo = UserRepository(db)
# CRUD
def create_user(self, *, email: str, password: str, full_name: str | None, role: str) -> User:
if self.repo.get_by_email(email):
raise ValueError("Email already in use")
pwd_hash = hash_password(password)
return self.repo.create(email=email, password_hash=pwd_hash, full_name=full_name, role=role)
def get_user(self, user_id) -> Optional[User]:
return self.repo.get(user_id)
def get_by_email(self, email: str) -> Optional[User]:
return self.repo.get_by_email(email)
def list_users(self, *, offset: int = 0, limit: int = 50):
return self.repo.list(offset=offset, limit=limit)
def update_user(self, user: User, *, full_name: str | None = None, role: str | None = None,
is_active: bool | None = None, password: str | None = None) -> User:
fields = {}
if full_name is not None: fields["full_name"] = full_name
if role is not None: fields["role"] = role
if is_active is not None: fields["is_active"] = is_active
if password: fields["password_hash"] = hash_password(password)
return self.repo.update(user, **fields)
def delete_user(self, user: User) -> None:
self.repo.delete(user)
# Auth
def authenticate(self, *, email: str, password: str) -> Optional[User]:
user = self.repo.get_by_email(email)
if not user or not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
PY
# api routes
write_file services/auth/src/app/api/routes/auth.py <<'PY'
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.user import UserCreate, LoginRequest, TokenPair, UserRead
from app.services.user_service import UserService
from app.core.security import create_access_token, create_refresh_token, get_current_user, UserClaims
router = APIRouter(prefix="/v1", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=201)
def register(payload: UserCreate, db: Session = Depends(get_db)):
svc = UserService(db)
try:
user = svc.create_user(email=payload.email, password=payload.password,
full_name=payload.full_name, role=payload.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return user
@router.post("/token", response_model=TokenPair)
def token(payload: LoginRequest, db: Session = Depends(get_db)):
svc = UserService(db)
user = svc.authenticate(email=payload.email, password=payload.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
access = create_access_token(sub=str(user.id), email=user.email, role=user.role)
refresh = create_refresh_token(sub=str(user.id), email=user.email, role=user.role)
return TokenPair(access_token=access, refresh_token=refresh)
class RefreshRequest(LoginRequest.__class__):
refresh_token: str # type: ignore
@router.post("/refresh", response_model=TokenPair)
def refresh_token(req: dict):
# expects: {"refresh_token": "<token>"}
from app.core.security import decode_token
token = req.get("refresh_token")
if not token:
raise HTTPException(status_code=400, detail="Missing refresh_token")
claims = decode_token(token)
if claims.type != "refresh":
raise HTTPException(status_code=400, detail="Not a refresh token")
access = create_access_token(sub=claims.sub, email=claims.email, role=claims.role)
refresh = create_refresh_token(sub=claims.sub, email=claims.email, role=claims.role)
return TokenPair(access_token=access, refresh_token=refresh)
@router.get("/me", response_model=UserRead)
def me(claims: UserClaims = Depends(get_current_user), db: Session = Depends(get_db)):
svc = UserService(db)
u = svc.get_user(claims.sub)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u
PY
write_file services/auth/src/app/api/routes/users.py <<'PY'
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import require_roles
from app.schemas.user import UserRead, UserUpdate, UserCreate
from app.services.user_service import UserService
router = APIRouter(prefix="/v1/users", tags=["users"])
@router.get("", response_model=list[UserRead])
def list_users(offset: int = 0, limit: int = Query(50, le=200), db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
return UserService(db).list_users(offset=offset, limit=limit)
@router.post("", response_model=UserRead, status_code=201)
def create_user(payload: UserCreate, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
try:
return UserService(db).create_user(email=payload.email, password=payload.password,
full_name=payload.full_name, role=payload.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{user_id}", response_model=UserRead)
def get_user(user_id: str, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
u = UserService(db).get_user(user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return u
@router.patch("/{user_id}", response_model=UserRead)
def update_user(user_id: str, payload: UserUpdate, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
svc = UserService(db)
u = svc.get_user(user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return svc.update_user(u, full_name=payload.full_name, role=payload.role,
is_active=payload.is_active, password=payload.password)
@router.delete("/{user_id}", status_code=204)
def delete_user(user_id: str, db: Session = Depends(get_db),
_: dict = Depends(require_roles("ADMIN"))):
svc = UserService(db)
u = svc.get_user(user_id)
if not u:
return
svc.delete_user(u)
PY
# main.py update for auth
write_file services/auth/src/app/main.py <<'PY'
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.auth import router as auth_router
from .api.routes.users import router as users_router
app = FastAPI(title="AUTH Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "auth"}
app.include_router(ping_router, prefix="/v1")
app.include_router(auth_router)
app.include_router(users_router)
PY
# -------------------------------------------------------------------
# 5) PROFILES service — Profile + Photo CRUD + поиск
# -------------------------------------------------------------------
write_file services/profiles/src/app/models/profile.py <<'PY'
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.db.session import Base
class Profile(Base):
__tablename__ = "profiles"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
gender: Mapped[str] = mapped_column(String(16), nullable=False) # male/female/other
birthdate: Mapped[date | None] = mapped_column(Date, default=None)
city: Mapped[str | None] = mapped_column(String(120), default=None)
bio: Mapped[str | None] = mapped_column(Text, default=None)
languages: Mapped[dict | None] = mapped_column(JSONB, default=list) # e.g., ["ru","en"]
interests: Mapped[dict | None] = mapped_column(JSONB, default=list)
preferences: Mapped[dict | None] = mapped_column(JSONB, default=dict)
verification_status: Mapped[str] = mapped_column(String(16), default="unverified")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
photos = relationship("Photo", back_populates="profile", cascade="all, delete-orphan")
PY
write_file services/profiles/src/app/models/photo.py <<'PY'
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.db.session import Base
class Photo(Base):
__tablename__ = "photos"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
url: Mapped[str] = mapped_column(String(500), nullable=False)
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/approved/rejected
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
profile = relationship("Profile", back_populates="photos", primaryjoin="Photo.profile_id==Profile.id", viewonly=True)
PY
write_file services/profiles/src/app/models/__init__.py <<'PY'
from .profile import Profile # noqa
from .photo import Photo # noqa
PY
write_file services/profiles/src/app/schemas/profile.py <<'PY'
from __future__ import annotations
from datetime import date
from typing import Optional, Any
from pydantic import BaseModel, ConfigDict
class PhotoCreate(BaseModel):
url: str
is_main: bool = False
class PhotoRead(BaseModel):
id: str
url: str
is_main: bool
status: str
model_config = ConfigDict(from_attributes=True)
class ProfileCreate(BaseModel):
gender: str
birthdate: Optional[date] = None
city: Optional[str] = None
bio: Optional[str] = None
languages: Optional[list[str]] = None
interests: Optional[list[str]] = None
preferences: Optional[dict[str, Any]] = None
class ProfileUpdate(BaseModel):
gender: Optional[str] = None
birthdate: Optional[date] = None
city: Optional[str] = None
bio: Optional[str] = None
languages: Optional[list[str]] = None
interests: Optional[list[str]] = None
preferences: Optional[dict[str, Any]] = None
verification_status: Optional[str] = None
class ProfileRead(BaseModel):
id: str
user_id: str
gender: str
birthdate: Optional[date] = None
city: Optional[str] = None
bio: Optional[str] = None
languages: Optional[list[str]] = None
interests: Optional[list[str]] = None
preferences: Optional[dict] = None
verification_status: str
model_config = ConfigDict(from_attributes=True)
PY
write_file services/profiles/src/app/repositories/profile_repository.py <<'PY'
from __future__ import annotations
from typing import Optional, Sequence
from datetime import date, timedelta
from sqlalchemy import select, and_
from sqlalchemy.orm import Session
from app.models.profile import Profile
from app.models.photo import Photo
class ProfileRepository:
def __init__(self, db: Session):
self.db = db
# Profile CRUD
def create_profile(self, *, user_id, **fields) -> Profile:
p = Profile(user_id=user_id, **fields)
self.db.add(p)
self.db.commit()
self.db.refresh(p)
return p
def get_profile(self, profile_id) -> Optional[Profile]:
return self.db.get(Profile, profile_id)
def get_by_user(self, user_id) -> Optional[Profile]:
stmt = select(Profile).where(Profile.user_id == user_id)
return self.db.execute(stmt).scalar_one_or_none()
def update_profile(self, profile: Profile, **fields) -> Profile:
for k, v in fields.items():
if v is not None:
setattr(profile, k, v)
self.db.add(profile)
self.db.commit()
self.db.refresh(profile)
return profile
def delete_profile(self, profile: Profile) -> None:
self.db.delete(profile)
self.db.commit()
def list_profiles(self, *, gender: str | None = None, city: str | None = None,
age_min: int | None = None, age_max: int | None = None,
offset: int = 0, limit: int = 50) -> Sequence[Profile]:
stmt = select(Profile)
conds = []
if gender:
conds.append(Profile.gender == gender)
if city:
conds.append(Profile.city == city)
# Age filter -> birthdate between (today - age_max) and (today - age_min)
if age_min is not None or age_max is not None:
today = date.today()
if age_min is not None:
max_birthdate = date(today.year - age_min, today.month, today.day)
conds.append(Profile.birthdate <= max_birthdate)
if age_max is not None:
min_birthdate = date(today.year - age_max - 1, today.month, today.day) + timedelta(days=1)
conds.append(Profile.birthdate >= min_birthdate)
if conds:
stmt = stmt.where(and_(*conds))
stmt = stmt.offset(offset).limit(limit).order_by(Profile.created_at.desc())
return self.db.execute(stmt).scalars().all()
# Photos
def add_photo(self, *, profile_id, url: str, is_main: bool) -> Photo:
photo = Photo(profile_id=profile_id, url=url, is_main=is_main)
self.db.add(photo)
if is_main:
# unset other main photos
self.db.execute(select(Photo).where(Photo.profile_id == profile_id))
self.db.commit()
self.db.refresh(photo)
return photo
def list_photos(self, *, profile_id) -> Sequence[Photo]:
stmt = select(Photo).where(Photo.profile_id == profile_id)
return self.db.execute(stmt).scalars().all()
def get_photo(self, photo_id) -> Optional[Photo]:
return self.db.get(Photo, photo_id)
def delete_photo(self, photo: Photo) -> None:
self.db.delete(photo)
self.db.commit()
PY
write_file services/profiles/src/app/services/profile_service.py <<'PY'
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import Optional
from app.repositories.profile_repository import ProfileRepository
from app.models.profile import Profile
from app.models.photo import Photo
class ProfileService:
def __init__(self, db: Session):
self.repo = ProfileRepository(db)
def create_profile(self, *, user_id, **fields) -> Profile:
return self.repo.create_profile(user_id=user_id, **fields)
def get_profile(self, profile_id) -> Optional[Profile]:
return self.repo.get_profile(profile_id)
def get_by_user(self, user_id) -> Optional[Profile]:
return self.repo.get_by_user(user_id)
def update_profile(self, profile: Profile, **fields) -> Profile:
return self.repo.update_profile(profile, **fields)
def delete_profile(self, profile: Profile) -> None:
return self.repo.delete_profile(profile)
def list_profiles(self, **filters):
return self.repo.list_profiles(**filters)
# photos
def add_photo(self, profile_id, *, url: str, is_main: bool) -> Photo:
return self.repo.add_photo(profile_id=profile_id, url=url, is_main=is_main)
def list_photos(self, profile_id):
return self.repo.list_photos(profile_id=profile_id)
def get_photo(self, photo_id) -> Photo | None:
return self.repo.get_photo(photo_id)
def delete_photo(self, photo: Photo) -> None:
self.repo.delete_photo(photo)
PY
write_file services/profiles/src/app/api/routes/profiles.py <<'PY'
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import get_current_user, require_roles, UserClaims
from app.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate, PhotoCreate, PhotoRead
from app.services.profile_service import ProfileService
router = APIRouter(prefix="/v1", tags=["profiles"])
@router.post("/profiles", response_model=ProfileRead, status_code=201)
def create_profile(payload: ProfileCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
if svc.get_by_user(user.sub):
raise HTTPException(status_code=400, detail="Profile already exists")
p = svc.create_profile(user_id=user.sub, **payload.model_dump(exclude_none=True))
return p
@router.get("/profiles/me", response_model=ProfileRead)
def get_my_profile(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
p = svc.get_by_user(user.sub)
if not p:
raise HTTPException(status_code=404, detail="Profile not found")
return p
@router.get("/profiles", response_model=list[ProfileRead])
def list_profiles(gender: str | None = None, city: str | None = None,
age_min: int | None = Query(None, ge=18, le=120),
age_max: int | None = Query(None, ge=18, le=120),
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
return ProfileService(db).list_profiles(gender=gender, city=city, age_min=age_min, age_max=age_max, offset=offset, limit=limit)
@router.get("/profiles/{profile_id}", response_model=ProfileRead)
def get_profile(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
p = ProfileService(db).get_profile(profile_id)
if not p:
raise HTTPException(status_code=404, detail="Profile not found")
return p
@router.patch("/profiles/{profile_id}", response_model=ProfileRead)
def update_profile(profile_id: str, payload: ProfileUpdate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
p = svc.get_profile(profile_id)
if not p:
raise HTTPException(status_code=404, detail="Profile not found")
if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
raise HTTPException(status_code=403, detail="Not allowed")
return svc.update_profile(p, **payload.model_dump(exclude_none=True))
@router.delete("/profiles/{profile_id}", status_code=204)
def delete_profile(profile_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
p = svc.get_profile(profile_id)
if not p:
return
if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
raise HTTPException(status_code=403, detail="Not allowed")
svc.delete_profile(p)
# Photos
@router.post("/profiles/{profile_id}/photos", response_model=PhotoRead, status_code=201)
def add_photo(profile_id: str, payload: PhotoCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
p = svc.get_profile(profile_id)
if not p:
raise HTTPException(status_code=404, detail="Profile not found")
if str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER"):
raise HTTPException(status_code=403, detail="Not allowed")
photo = svc.add_photo(profile_id, url=payload.url, is_main=payload.is_main)
return photo
@router.get("/profiles/{profile_id}/photos", response_model=list[PhotoRead])
def list_photos(profile_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
return svc.list_photos(profile_id)
@router.delete("/photos/{photo_id}", status_code=204)
def delete_photo(photo_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ProfileService(db)
photo = svc.get_photo(photo_id)
if not photo:
return
# Lookup profile to check ownership
p = svc.get_profile(photo.profile_id)
if not p or (str(p.user_id) != user.sub and user.role not in ("ADMIN","MATCHMAKER")):
raise HTTPException(status_code=403, detail="Not allowed")
svc.delete_photo(photo)
PY
# main.py for profiles
write_file services/profiles/src/app/main.py <<'PY'
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.profiles import router as profiles_router
app = FastAPI(title="PROFILES Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "profiles"}
app.include_router(ping_router, prefix="/v1")
app.include_router(profiles_router)
PY
# -------------------------------------------------------------------
# 6) MATCH service — пары и статусы (proposed/accepted/rejected/blocked)
# -------------------------------------------------------------------
write_file services/match/src/app/models/pair.py <<'PY'
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, Float, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.session import Base
class MatchPair(Base):
__tablename__ = "match_pairs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# User IDs to validate permissions; profile IDs можно добавить позже
user_id_a: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
user_id_b: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
status: Mapped[str] = mapped_column(String(16), default="proposed") # proposed/accepted/rejected/blocked
score: Mapped[float | None] = mapped_column(Float, default=None)
notes: Mapped[str | None] = mapped_column(String(1000), default=None)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) # matchmaker/admin
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
PY
write_file services/match/src/app/models/__init__.py <<'PY'
from .pair import MatchPair # noqa
PY
write_file services/match/src/app/schemas/pair.py <<'PY'
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, ConfigDict
class PairCreate(BaseModel):
user_id_a: str
user_id_b: str
score: Optional[float] = None
notes: Optional[str] = None
class PairUpdate(BaseModel):
score: Optional[float] = None
notes: Optional[str] = None
class PairRead(BaseModel):
id: str
user_id_a: str
user_id_b: str
status: str
score: Optional[float] = None
notes: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
PY
write_file services/match/src/app/repositories/pair_repository.py <<'PY'
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy import select, or_
from sqlalchemy.orm import Session
from app.models.pair import MatchPair
class PairRepository:
def __init__(self, db: Session):
self.db = db
def create(self, **fields) -> MatchPair:
obj = MatchPair(**fields)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get(self, pair_id) -> Optional[MatchPair]:
return self.db.get(MatchPair, pair_id)
def list(self, *, for_user_id: str | None = None, status: str | None = None,
offset: int = 0, limit: int = 50) -> Sequence[MatchPair]:
stmt = select(MatchPair)
if for_user_id:
stmt = stmt.where(or_(MatchPair.user_id_a == for_user_id, MatchPair.user_id_b == for_user_id))
if status:
stmt = stmt.where(MatchPair.status == status)
stmt = stmt.offset(offset).limit(limit).order_by(MatchPair.created_at.desc())
return self.db.execute(stmt).scalars().all()
def update(self, obj: MatchPair, **fields) -> MatchPair:
for k, v in fields.items():
if v is not None:
setattr(obj, k, v)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def delete(self, obj: MatchPair) -> None:
self.db.delete(obj)
self.db.commit()
PY
write_file services/match/src/app/services/pair_service.py <<'PY'
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import Optional
from app.repositories.pair_repository import PairRepository
from app.models.pair import MatchPair
class PairService:
def __init__(self, db: Session):
self.repo = PairRepository(db)
def create(self, **fields) -> MatchPair:
return self.repo.create(**fields)
def get(self, pair_id) -> Optional[MatchPair]:
return self.repo.get(pair_id)
def list(self, **filters):
return self.repo.list(**filters)
def update(self, obj: MatchPair, **fields) -> MatchPair:
return self.repo.update(obj, **fields)
def delete(self, obj: MatchPair) -> None:
return self.repo.delete(obj)
def set_status(self, obj: MatchPair, status: str) -> MatchPair:
return self.repo.update(obj, status=status)
PY
write_file services/match/src/app/api/routes/pairs.py <<'PY'
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import get_current_user, require_roles, UserClaims
from app.schemas.pair import PairCreate, PairUpdate, PairRead
from app.services.pair_service import PairService
router = APIRouter(prefix="/v1/pairs", tags=["pairs"])
@router.post("", response_model=PairRead, status_code=201)
def create_pair(payload: PairCreate, db: Session = Depends(get_db),
user: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
svc = PairService(db)
return svc.create(user_id_a=payload.user_id_a, user_id_b=payload.user_id_b,
score=payload.score, notes=payload.notes, created_by=user.sub)
@router.get("", response_model=list[PairRead])
def list_pairs(for_user_id: str | None = None, status: str | None = None,
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
_: UserClaims = Depends(get_current_user)):
return PairService(db).list(for_user_id=for_user_id, status=status, offset=offset, limit=limit)
@router.get("/{pair_id}", response_model=PairRead)
def get_pair(pair_id: str, db: Session = Depends(get_db), _: UserClaims = Depends(get_current_user)):
obj = PairService(db).get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return obj
@router.patch("/{pair_id}", response_model=PairRead)
def update_pair(pair_id: str, payload: PairUpdate, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
return svc.update(obj, **payload.model_dump(exclude_none=True))
@router.post("/{pair_id}/accept", response_model=PairRead)
def accept(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
# Validate that current user participates
if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
raise HTTPException(status_code=403, detail="Not allowed")
return svc.set_status(obj, "accepted")
@router.post("/{pair_id}/reject", response_model=PairRead)
def reject(pair_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
raise HTTPException(status_code=404, detail="Not found")
if user.sub not in (str(obj.user_id_a), str(obj.user_id_b)):
raise HTTPException(status_code=403, detail="Not allowed")
return svc.set_status(obj, "rejected")
@router.delete("/{pair_id}", status_code=204)
def delete_pair(pair_id: str, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
svc = PairService(db)
obj = svc.get(pair_id)
if not obj:
return
svc.delete(obj)
PY
write_file services/match/src/app/main.py <<'PY'
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.pairs import router as pairs_router
app = FastAPI(title="MATCH Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "match"}
app.include_router(ping_router, prefix="/v1")
app.include_router(pairs_router)
PY
# -------------------------------------------------------------------
# 7) CHAT service — комнаты и сообщения (REST, без WS)
# -------------------------------------------------------------------
write_file services/chat/src/app/models/chat.py <<'PY'
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.session import Base
class ChatRoom(Base):
__tablename__ = "chat_rooms"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title: Mapped[str | None] = mapped_column(String(255), default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class ChatParticipant(Base):
__tablename__ = "chat_participants"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
class Message(Base):
__tablename__ = "chat_messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
sender_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
PY
write_file services/chat/src/app/models/__init__.py <<'PY'
from .chat import ChatRoom, ChatParticipant, Message # noqa
PY
write_file services/chat/src/app/schemas/chat.py <<'PY'
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from typing import Optional
class RoomCreate(BaseModel):
title: Optional[str] = None
participants: list[str] # user IDs
class RoomRead(BaseModel):
id: str
title: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class MessageCreate(BaseModel):
content: str
class MessageRead(BaseModel):
id: str
room_id: str
sender_id: str
content: str
model_config = ConfigDict(from_attributes=True)
PY
write_file services/chat/src/app/repositories/chat_repository.py <<'PY'
from __future__ import annotations
from typing import Sequence, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, or_
from app.models.chat import ChatRoom, ChatParticipant, Message
class ChatRepository:
def __init__(self, db: Session):
self.db = db
# Rooms
def create_room(self, title: str | None) -> ChatRoom:
r = ChatRoom(title=title)
self.db.add(r)
self.db.commit()
self.db.refresh(r)
return r
def add_participant(self, room_id, user_id, is_admin: bool = False) -> ChatParticipant:
p = ChatParticipant(room_id=room_id, user_id=user_id, is_admin=is_admin)
self.db.add(p)
self.db.commit()
self.db.refresh(p)
return p
def list_rooms_for_user(self, user_id) -> Sequence[ChatRoom]:
stmt = select(ChatRoom).join(ChatParticipant, ChatParticipant.room_id == ChatRoom.id)\
.where(ChatParticipant.user_id == user_id)
return self.db.execute(stmt).scalars().all()
def get_room(self, room_id) -> Optional[ChatRoom]:
return self.db.get(ChatRoom, room_id)
# Messages
def create_message(self, room_id, sender_id, content: str) -> Message:
m = Message(room_id=room_id, sender_id=sender_id, content=content)
self.db.add(m)
self.db.commit()
self.db.refresh(m)
return m
def list_messages(self, room_id, *, offset: int = 0, limit: int = 100) -> Sequence[Message]:
stmt = select(Message).where(Message.room_id == room_id).offset(offset).limit(limit).order_by(Message.created_at.asc())
return self.db.execute(stmt).scalars().all()
PY
write_file services/chat/src/app/services/chat_service.py <<'PY'
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import Optional, Sequence
from app.repositories.chat_repository import ChatRepository
from app.models.chat import ChatRoom, ChatParticipant, Message
class ChatService:
def __init__(self, db: Session):
self.repo = ChatRepository(db)
def create_room(self, *, title: str | None, participant_ids: list[str], creator_id: str) -> ChatRoom:
room = self.repo.create_room(title)
# creator -> admin
self.repo.add_participant(room.id, creator_id, is_admin=True)
for uid in participant_ids:
if uid != creator_id:
self.repo.add_participant(room.id, uid, is_admin=False)
return room
def list_rooms_for_user(self, user_id: str) -> Sequence[ChatRoom]:
return self.repo.list_rooms_for_user(user_id)
def get_room(self, room_id: str) -> ChatRoom | None:
return self.repo.get_room(room_id)
def create_message(self, room_id: str, sender_id: str, content: str) -> Message:
return self.repo.create_message(room_id, sender_id, content)
def list_messages(self, room_id: str, offset: int = 0, limit: int = 100):
return self.repo.list_messages(room_id, offset=offset, limit=limit)
PY
write_file services/chat/src/app/api/routes/chat.py <<'PY'
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import get_current_user, UserClaims
from app.schemas.chat import RoomCreate, RoomRead, MessageCreate, MessageRead
from app.services.chat_service import ChatService
router = APIRouter(prefix="/v1", tags=["chat"])
@router.post("/rooms", response_model=RoomRead, status_code=201)
def create_room(payload: RoomCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.create_room(title=payload.title, participant_ids=payload.participants, creator_id=user.sub)
return room
@router.get("/rooms", response_model=list[RoomRead])
def my_rooms(db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
return ChatService(db).list_rooms_for_user(user.sub)
@router.get("/rooms/{room_id}", response_model=RoomRead)
def get_room(room_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
room = ChatService(db).get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Not found")
# NOTE: для простоты опускаем проверку участия (добавьте в проде)
return room
@router.post("/rooms/{room_id}/messages", response_model=MessageRead, status_code=201)
def send_message(room_id: str, payload: MessageCreate, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
msg = svc.create_message(room_id, user.sub, payload.content)
return msg
@router.get("/rooms/{room_id}/messages", response_model=list[MessageRead])
def list_messages(room_id: str, offset: int = 0, limit: int = Query(100, le=500),
db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
svc = ChatService(db)
room = svc.get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
return svc.list_messages(room_id, offset=offset, limit=limit)
PY
write_file services/chat/src/app/main.py <<'PY'
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.chat import router as chat_router
app = FastAPI(title="CHAT Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "chat"}
app.include_router(ping_router, prefix="/v1")
app.include_router(chat_router)
PY
# -------------------------------------------------------------------
# 8) PAYMENTS service — инвойсы (простая версия)
# -------------------------------------------------------------------
write_file services/payments/src/app/models/payment.py <<'PY'
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.session import Base
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12,2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="USD")
status: Mapped[str] = mapped_column(String(16), default="pending") # pending/paid/canceled
description: Mapped[str | None] = mapped_column(String(500), default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
PY
write_file services/payments/src/app/models/__init__.py <<'PY'
from .payment import Invoice # noqa
PY
write_file services/payments/src/app/schemas/payment.py <<'PY'
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, ConfigDict
class InvoiceCreate(BaseModel):
client_id: str
amount: float
currency: str = "USD"
description: Optional[str] = None
class InvoiceUpdate(BaseModel):
amount: Optional[float] = None
currency: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
class InvoiceRead(BaseModel):
id: str
client_id: str
amount: float
currency: str
status: str
description: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
PY
write_file services/payments/src/app/repositories/payment_repository.py <<'PY'
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.models.payment import Invoice
class PaymentRepository:
def __init__(self, db: Session):
self.db = db
def create_invoice(self, **fields) -> Invoice:
obj = Invoice(**fields)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get_invoice(self, inv_id) -> Optional[Invoice]:
return self.db.get(Invoice, inv_id)
def list_invoices(self, *, client_id: str | None = None, status: str | None = None,
offset: int = 0, limit: int = 50) -> Sequence[Invoice]:
stmt = select(Invoice)
if client_id:
stmt = stmt.where(Invoice.client_id == client_id)
if status:
stmt = stmt.where(Invoice.status == status)
stmt = stmt.offset(offset).limit(limit).order_by(Invoice.created_at.desc())
return self.db.execute(stmt).scalars().all()
def update_invoice(self, obj: Invoice, **fields) -> Invoice:
for k, v in fields.items():
if v is not None:
setattr(obj, k, v)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def delete_invoice(self, obj: Invoice) -> None:
self.db.delete(obj)
self.db.commit()
PY
write_file services/payments/src/app/services/payment_service.py <<'PY'
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import Optional
from app.repositories.payment_repository import PaymentRepository
from app.models.payment import Invoice
class PaymentService:
def __init__(self, db: Session):
self.repo = PaymentRepository(db)
def create_invoice(self, **fields) -> Invoice:
return self.repo.create_invoice(**fields)
def get_invoice(self, inv_id) -> Invoice | None:
return self.repo.get_invoice(inv_id)
def list_invoices(self, **filters):
return self.repo.list_invoices(**filters)
def update_invoice(self, obj: Invoice, **fields) -> Invoice:
return self.repo.update_invoice(obj, **fields)
def delete_invoice(self, obj: Invoice) -> None:
return self.repo.delete_invoice(obj)
def mark_paid(self, obj: Invoice) -> Invoice:
return self.repo.update_invoice(obj, status="paid")
PY
write_file services/payments/src/app/api/routes/payments.py <<'PY'
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import get_current_user, require_roles, UserClaims
from app.schemas.payment import InvoiceCreate, InvoiceUpdate, InvoiceRead
from app.services.payment_service import PaymentService
router = APIRouter(prefix="/v1/invoices", tags=["payments"])
@router.post("", response_model=InvoiceRead, status_code=201)
def create_invoice(payload: InvoiceCreate, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
return PaymentService(db).create_invoice(**payload.model_dump(exclude_none=True))
@router.get("", response_model=list[InvoiceRead])
def list_invoices(client_id: str | None = None, status: str | None = None,
offset: int = 0, limit: int = Query(50, le=200),
db: Session = Depends(get_db),
user: UserClaims = Depends(get_current_user)):
# Клиент видит только свои инвойсы, админ/матчмейкер — любые
if user.role in ("ADMIN","MATCHMAKER"):
return PaymentService(db).list_invoices(client_id=client_id, status=status, offset=offset, limit=limit)
else:
return PaymentService(db).list_invoices(client_id=user.sub, status=status, offset=offset, limit=limit)
@router.get("/{inv_id}", response_model=InvoiceRead)
def get_invoice(inv_id: str, db: Session = Depends(get_db), user: UserClaims = Depends(get_current_user)):
inv = PaymentService(db).get_invoice(inv_id)
if not inv:
raise HTTPException(status_code=404, detail="Not found")
if user.role not in ("ADMIN","MATCHMAKER") and str(inv.client_id) != user.sub:
raise HTTPException(status_code=403, detail="Not allowed")
return inv
@router.patch("/{inv_id}", response_model=InvoiceRead)
def update_invoice(inv_id: str, payload: InvoiceUpdate, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
svc = PaymentService(db)
inv = svc.get_invoice(inv_id)
if not inv:
raise HTTPException(status_code=404, detail="Not found")
return svc.update_invoice(inv, **payload.model_dump(exclude_none=True))
@router.post("/{inv_id}/mark-paid", response_model=InvoiceRead)
def mark_paid(inv_id: str, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN","MATCHMAKER"))):
svc = PaymentService(db)
inv = svc.get_invoice(inv_id)
if not inv:
raise HTTPException(status_code=404, detail="Not found")
return svc.mark_paid(inv)
@router.delete("/{inv_id}", status_code=204)
def delete_invoice(inv_id: str, db: Session = Depends(get_db),
_: UserClaims = Depends(require_roles("ADMIN"))):
svc = PaymentService(db)
inv = svc.get_invoice(inv_id)
if not inv:
return
svc.delete_invoice(inv)
PY
write_file services/payments/src/app/main.py <<'PY'
from fastapi import FastAPI
from .api.routes.ping import router as ping_router
from .api.routes.payments import router as payments_router
app = FastAPI(title="PAYMENTS Service")
@app.get("/health")
def health():
return {"status": "ok", "service": "payments"}
app.include_router(ping_router, prefix="/v1")
app.include_router(payments_router)
PY
# -------------------------------------------------------------------
# 9) Обновить __init__.py пакетов (если scaffold создал пустые)
# -------------------------------------------------------------------
for s in "${SERVICES[@]}"; do
touch "services/$s/src/app/__init__.py"
touch "services/$s/src/app/api/__init__.py"
touch "services/$s/src/app/api/routes/__init__.py"
touch "services/$s/src/app/core/__init__.py"
touch "services/$s/src/app/db/__init__.py"
touch "services/$s/src/app/repositories/__init__.py"
touch "services/$s/src/app/schemas/__init__.py"
touch "services/$s/src/app/services/__init__.py"
done
for s in auth profiles match chat payments; do
docker compose run --rm $s alembic revision --autogenerate -m "init"
done
echo "✅ Models + CRUD + API + Auth applied."
cat <<'NEXT'
Next steps:
1) Сгенерируйте первичные миграции по моделям:
for s in auth profiles match chat payments; do
docker compose run --rm $s alembic revision --autogenerate -m "init"
done
2) Поднимите окружение (alembic upgrade выполнится в entrypoint):
docker compose up --build
3) Получите токен:
POST http://localhost:8080/auth/v1/register
POST http://localhost:8080/auth/v1/token
-> Authorization: Bearer <access_token>
4) Проверьте CRUD:
- Profiles: GET http://localhost:8080/profiles/v1/profiles/me
- Match: POST http://localhost:8080/match/v1/pairs
- Chat: POST http://localhost:8080/chat/v1/rooms
- Payments: POST http://localhost:8080/payments/v1/invoices
Замечания по безопасности/продакшену:
- Секрет JWT смените в .env (JWT_SECRET), храните в секретах CI/CD.
- Сроки жизни токенов подберите под бизнес-политику.
- Для refresh-токенов сейчас статeless-вариант; при необходимости добавьте
хранилище jti/ревокацию.
- Для CHAT и MATCH в проде добавьте строгую проверку участия/прав.
- В PROFILES поля languages/interests/preferences — JSONB; при желании замените
на нормализованные таблицы или ARRAY.
NEXT