Compare commits

..

3 Commits

Author SHA1 Message Date
c06be7b5f2 feat(/me): subscription data + rw controls 2026-08-03 11:15:11 +07:00
801014c392 feat: referal spec on registration 2026-08-03 10:35:26 +07:00
680c15414c feat: GET /me endpoint 2026-08-03 10:23:44 +07:00
13 changed files with 226 additions and 36 deletions

View File

@@ -0,0 +1,34 @@
"""+user.referal_code
Revision ID: 135672cdf14a
Revises: 72e78a8a43cf
Create Date: 2026-08-03 10:26:22.290547
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '135672cdf14a'
down_revision: Union[str, Sequence[str], None] = '72e78a8a43cf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('referal_code', sa.TEXT(), nullable=False))
op.create_unique_constraint(None, 'users', ['referal_code'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='unique')
op.drop_column('users', 'referal_code')
# ### end Alembic commands ###

View File

@@ -22,6 +22,7 @@ class Settings(BaseSettings):
pally_token: str = Field()
remnawave_base_url: str = Field()
remnawave_sub_url: str = Field()
remnawave_token: str = Field()
remnawave_default_squads_raw: str = Field(alias="REMNAWAVE_DEFAULT_SQUADS_UUIDS")

View File

@@ -1,3 +1,5 @@
import secrets
import string
from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, REAL, TEXT, VARCHAR, ForeignKey
@@ -9,6 +11,11 @@ if TYPE_CHECKING:
from db.models import Order, Session, Subscription
def generate_ref_code(length: int = 8):
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
class User(Base):
__tablename__ = "users"
@@ -19,6 +26,9 @@ class User(Base):
hashed_password: Mapped[str] = mapped_column(VARCHAR(255), nullable=True)
telegram_id: Mapped[int] = mapped_column(BIGINT, unique=True, nullable=True)
referal_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
referal_code: Mapped[str] = mapped_column(
TEXT, nullable=False, unique=True, default=generate_ref_code
)
balance: Mapped[float] = mapped_column(REAL, nullable=False, default=0)

70
external/rw.py vendored
View File

@@ -431,6 +431,27 @@ def get_sdk() -> RemnawaveSDK | None:
return RemnawaveSDK(base_url=cfg.remnawave_base_url, token=cfg.remnawave_token)
async def get_rw_user(
sdk: RemnawaveSDK, telegram_id: int | None = None, username: str | None = None
) -> RWUserInfo | None:
rw_user = None
if telegram_id is not None:
rw_user = await get_user_by_telegram_id(sdk, telegram_id)
if rw_user is None:
rw_username = _build_rw_username(telegram_id=telegram_id, username=username)
if rw_username is None:
logger.warning(
"Cannot build username for telegram_id=%s username=%s",
telegram_id,
username,
)
return None
rw_user = await get_user_by_username(sdk, rw_username)
return rw_user
async def sync_subscription_by_telegram_id(
*,
expires_at: datetime,
@@ -443,38 +464,29 @@ async def sync_subscription_by_telegram_id(
logger.info("Skipping RW subscription sync: RemnaWave is not configured")
return False
rw_user = None
if telegram_id is not None:
rw_user = await get_user_by_telegram_id(sdk, telegram_id)
rw_user = await get_rw_user(sdk, telegram_id=telegram_id, username=username)
if rw_user is None:
rw_username = _build_rw_username(telegram_id=telegram_id, username=username)
if rw_username is None:
logger.warning(
"Skipping RW subscription sync: cannot build username for telegram_id=%s username=%s",
telegram_id,
username,
)
return False
rw_user = await get_user_by_username(sdk, rw_username)
if rw_user is None:
rw_user = await create_user(
sdk=sdk,
username=rw_username,
telegram_id=telegram_id,
expire_at=expires_at,
hwid_device_limit=None if devices == 0 else devices,
squad_uuids=cfg.remnawave_default_squads,
)
if rw_user is None:
logger.warning(
"Failed to create RW user for telegram_id=%s username=%s",
telegram_id,
username,
)
return False
rw_user = await create_user(
sdk=sdk,
username=rw_username,
telegram_id=telegram_id,
expire_at=expires_at,
hwid_device_limit=None if devices == 0 else devices,
squad_uuids=cfg.remnawave_default_squads,
)
if rw_user is None:
logger.warning(
"Failed to create RW user for telegram_id=%s username=%s",
telegram_id,
username,
)
return False
expire_synced = await update_expire_at(sdk, rw_user.uuid, expires_at)
devices_synced = await set_hwid_limit(sdk, rw_user.uuid, devices)
return expire_synced and devices_synced
def build_subscription_link(short_uuid: str):
return cfg.remnawave_sub_url.rstrip("/") + f"/{short_uuid}"

View File

@@ -24,17 +24,24 @@ class UserRepository:
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def get_user_by_ref_code(self, ref_code: str) -> User | None:
stmt = select(User).where(User.referal_code == ref_code)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def create(
self,
*,
username: str | None = None,
hashed_password: str | None = None,
telegram_id: int | None = None,
referal_id: int | None = None,
) -> User:
obj = User(
username=username,
hashed_password=hashed_password,
telegram_id=telegram_id,
referal_id=referal_id,
)
self.session.add(obj)
await self.session.commit()

View File

@@ -4,5 +4,12 @@ from .auth import router as auth_router
from .orders import router as orders_router
from .payments import payment_routers
from .plans import router as plans_router
from .users import router as users_routers
routers: list[APIRouter] = [auth_router, plans_router, orders_router, *payment_routers]
routers: list[APIRouter] = [
auth_router,
plans_router,
orders_router,
users_routers,
*payment_routers,
]

View File

@@ -28,9 +28,20 @@ async def signup(req: UserRegistration, session: AsyncSession = Depends(get_db))
raise HTTPException(status_code=409, detail="User already exists")
password_hash = hash_password(req.password)
user = await users_repo.create(username=req.username, hashed_password=password_hash)
referal_id = None
if req.referal_code:
referal = await users_repo.get_user_by_ref_code(req.referal_code)
referal_id = referal.id if referal else None
user = await users_repo.create(
username=req.username, hashed_password=password_hash, referal_id=referal_id
)
return JSONResponse(
UserInfo(username=user.username, telegram_id=user.telegram_id).model_dump(),
UserInfo(
username=user.username,
telegram_id=user.telegram_id,
referal_code=user.referal_code,
).model_dump(),
status_code=201,
)
raise HTTPException(status_code=400, detail="Unsupported provider")

74
routes/users.py Normal file
View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException
from core.deps import get_auth_context
from external.rw import build_subscription_link, delete_hwid, get_hwid_list, get_rw_user, get_sdk
from schemas.common import OperationData
from schemas.devices import Device
from schemas.dto import AuthContext
from schemas.user import SubscriptionData, UserInfo
router = APIRouter(prefix="/users")
@router.get("/me", response_model=UserInfo)
async def get_me(ctx: AuthContext = Depends(get_auth_context)):
return UserInfo(
username=ctx.user.username,
telegram_id=ctx.user.telegram_id,
referal_code=ctx.user.referal_code,
)
@router.get("/subscription")
async def get_subscription(ctx: AuthContext = Depends(get_auth_context)):
sub = ctx.user.subscription
if not sub:
return SubscriptionData(
devices=None,
expires_at=None,
addon_ids=[],
subscription_link=None,
)
rw_user = await get_rw_user(get_sdk(), ctx.user.telegram_id, ctx.user.username)
return SubscriptionData(
devices=sub.devices,
expires_at=sub.expires_at,
addon_ids=[a.id for a in sub.addons],
subscription_link=build_subscription_link(rw_user.short_uuid),
)
@router.get("/subscription/hwid", response_model=list[Device])
async def get_hwid(ctx: AuthContext = Depends(get_auth_context)):
rw_user = await get_rw_user(get_sdk(), ctx.user.telegram_id, ctx.user.username)
if not rw_user:
raise HTTPException(403, detail="RW user not found.")
hwid_list = await get_hwid_list(get_sdk(), user_uuid=rw_user.uuid)
devices = []
if not hwid_list:
return devices
for device in hwid_list:
devices.append(
Device(
os=device.platform,
model=device.device_model,
client=device.user_agent.split("/")[0],
hwid=device.hwid,
)
)
return devices
@router.delete("/subscription/hwid")
async def delete_hwid_endpoint(hwid: str, ctx: AuthContext = Depends(get_auth_context)):
rw_user = await get_rw_user(get_sdk(), ctx.user.telegram_id, ctx.user.username)
if not rw_user:
raise HTTPException(403, detail="RW user not found.")
success = await delete_hwid(get_sdk(), rw_user.uuid, hwid)
return OperationData(success=success)

5
schemas/common.py Normal file
View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel, Field
class OperationData(BaseModel):
success: bool = Field(False)

8
schemas/devices.py Normal file
View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel, Field
class Device(BaseModel):
os: str | None = Field(None)
model: str | None = Field(None)
client: str | None = Field(None)
hwid: str = Field()

View File

@@ -9,4 +9,6 @@ class UserRegistration(BaseModel):
username: str | None = Field(None)
password: str | None = Field(None)
referal_code: str | None = Field(None)
provider: ProvidersType

View File

@@ -1,6 +1,21 @@
from pydantic import BaseModel, Field
from datetime import datetime
from pydantic import BaseModel, Field, computed_field
class SubscriptionData(BaseModel):
devices: int | None = Field(None)
expires_at: datetime | None = Field(None)
addon_ids: list[str] = Field(default_factory=list)
subscription_link: str | None = Field(None)
@property
@computed_field
def has_subscription(self) -> bool:
return all([self.devices, self.expires_at, self.subscription_link, self.addon_ids])
class UserInfo(BaseModel):
username: str | None = Field()
telegram_id: str | None = Field()
username: str | None = Field(None)
telegram_id: str | None = Field(None)
referal_code: str = Field()

View File

@@ -18,6 +18,10 @@ async def authorize_user(
return UserLogin(
access_token=key_pair.access_token,
refresh_token=key_pair.refresh_token,
user=UserInfo(username=user.username, telegram_id=user.telegram_id),
user=UserInfo(
username=user.username,
telegram_id=user.telegram_id,
referal_code=user.referal_code,
),
expires_at=key_pair.expires_at,
)