#!/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 local file="$1" ; shift local line="$*" grep -qxF "$line" "$file" 2>/dev/null || echo "$line" >> "$file" } write_file() { # write_file <<'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": ""} 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 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