Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
800b0ba2c5
104
.env.example
Normal file
104
.env.example
Normal file
@ -0,0 +1,104 @@
|
||||
# ============================================================
|
||||
# SuperApp SaaS Platform - Environment Configuration Example
|
||||
# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید.
|
||||
# ============================================================
|
||||
|
||||
# ---- General ----
|
||||
ENVIRONMENT=development # development | staging | production
|
||||
DEBUG=true
|
||||
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
SERVICE_NAME=core-service
|
||||
API_V1_PREFIX=/api/v1
|
||||
|
||||
# ---- Platform Branding (White-label) ----
|
||||
# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید.
|
||||
PLATFORM_NAME=Torbatyar
|
||||
PLATFORM_SUPPORT_EMAIL=support@example.com
|
||||
PLATFORM_PRIMARY_COLOR=#0284c7
|
||||
PLATFORM_SECONDARY_COLOR=#0f172a
|
||||
# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً <slug>.torbatyar.xyz)
|
||||
# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً <slug>.torbatyar.ir)
|
||||
PLATFORM_BASE_DOMAIN=torbatyar.ir
|
||||
|
||||
# ---- Core Database (core_platform_db) ----
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=superapp
|
||||
POSTGRES_PASSWORD=superapp_password
|
||||
POSTGRES_DB=core_platform_db
|
||||
# async driver برای FastAPI/SQLAlchemy
|
||||
DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db
|
||||
# sync driver برای Alembic
|
||||
DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db
|
||||
|
||||
# ---- Redis ----
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
FEATURE_ACCESS_CACHE_TTL=300 # ثانیه
|
||||
TENANT_METADATA_CACHE_TTL=300
|
||||
|
||||
# ---- Celery ----
|
||||
CELERY_BROKER_URL=redis://redis:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
|
||||
# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ----
|
||||
# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156
|
||||
# Dev محلی: میتوانید hosts را به 127.0.0.1 اشاره دهید.
|
||||
KEYCLOAK_ENABLED=true
|
||||
KEYCLOAK_SERVER_URL=http://keycloak:8080
|
||||
KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir
|
||||
KEYCLOAK_HOSTNAME=auth.torbatyar.ir
|
||||
KEYCLOAK_HOSTNAME_STRICT_HTTPS=false
|
||||
KEYCLOAK_REALM=superapp
|
||||
KEYCLOAK_CLIENT_ID=core-service
|
||||
KEYCLOAK_CLIENT_SECRET=change-me
|
||||
JWT_ALGORITHM=RS256
|
||||
JWT_AUDIENCE=account
|
||||
JWT_VERIFY_SIGNATURE=true
|
||||
# در production و Docker باید true باشد؛ در تستها false
|
||||
AUTH_REQUIRED=true
|
||||
|
||||
# ---- Identity & Access Service ----
|
||||
IDENTITY_SERVICE_URL=http://identity-access-service:8001
|
||||
IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db
|
||||
IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db
|
||||
KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend
|
||||
KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity
|
||||
IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service
|
||||
FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback
|
||||
CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir,http://localhost:3000
|
||||
CORE_SERVICE_URL=http://core-service:8000
|
||||
|
||||
# ---- OTP / Payamak SMS (Core Service) ----
|
||||
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
||||
PAYAMAK_USERNAME=9155105404
|
||||
PAYAMAK_FROM=9982004961
|
||||
PAYAMAK_FROM_NUMBER=9982004961
|
||||
# ApiKey را در پارامتر password ارسال کنید (مستندات ملیپیامک)
|
||||
PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
|
||||
PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f
|
||||
PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f
|
||||
PAYAMAK_BODY_ID=245189
|
||||
# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber
|
||||
JWT_SECRET=change-me-jwt-secret
|
||||
OTP_EXPIRE_SECONDS=120
|
||||
|
||||
# شمارههای ادمین پلتفرم (OTP) — با کاما جدا شوند
|
||||
PLATFORM_ADMIN_MOBILES=09155105404
|
||||
|
||||
# ---- Frontend (Next.js) ----
|
||||
FRONTEND_PORT=3000
|
||||
NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir
|
||||
NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir
|
||||
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
|
||||
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
||||
|
||||
INTERNAL_TOKEN_SECRET=change-me-internal-secret
|
||||
|
||||
# ---- Keycloak Bootstrap ----
|
||||
KEYCLOAK_ADMIN=admin
|
||||
KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.egg
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# Databases / local
|
||||
*.sqlite3
|
||||
*.db
|
||||
test.db
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
frontend/.next/
|
||||
frontend/node_modules/
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Deploy temp artifacts
|
||||
scripts/_deploy_bundle.tar.gz
|
||||
scripts/_tmp_*
|
||||
scripts/_check_*
|
||||
scripts/_fix_*
|
||||
scripts/_debug_*
|
||||
scripts/_vols.py
|
||||
scripts/_restart_*
|
||||
scripts/_reset_*
|
||||
scripts/_verify*
|
||||
scripts/_finalize_*
|
||||
101
README.md
Normal file
101
README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# SuperApp SaaS Platform
|
||||
|
||||
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
|
||||
این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
|
||||
|
||||
> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
|
||||
|
||||
## معماری در یک نگاه
|
||||
|
||||
- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
|
||||
- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را میشناسد.
|
||||
- **ارتباط بین سرویسها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
|
||||
- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
|
||||
|
||||
جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
|
||||
|
||||
| فایل | توضیح |
|
||||
| --- | --- |
|
||||
| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
|
||||
| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
|
||||
| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویسها |
|
||||
| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعهدهنده |
|
||||
| [`docs/progress.md`](./docs/progress.md) | چکلیست پیشرفت فازها |
|
||||
| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
|
||||
|
||||
## ساختار پروژه
|
||||
|
||||
```
|
||||
superapp-platform/
|
||||
├── backend/
|
||||
│ ├── core-service/ # FastAPI Core Platform
|
||||
│ ├── shared-lib/ # کتابخانه مشترک backend
|
||||
│ └── services/ # placeholder سرویسهای آینده
|
||||
├── frontend/ # Next.js (کاملاً جدا)
|
||||
│ ├── app/
|
||||
│ ├── components/
|
||||
│ ├── lib/
|
||||
│ ├── hooks/
|
||||
│ ├── styles/
|
||||
│ └── public/
|
||||
├── docs/
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## راهاندازی سریع (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
سایت: http://localhost:3000
|
||||
|
||||
سرویسها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
|
||||
|
||||
- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
|
||||
- Core API: http://localhost:8000 (مستندات: http://localhost:8000/docs)
|
||||
- Identity API: http://localhost:8001 (SSO: http://localhost:8001/docs)
|
||||
- Keycloak: http://localhost:8080 (admin/admin — کاربر نمونه: platform.admin/admin123)
|
||||
- PostgreSQL: `localhost:5432`
|
||||
- Redis: `localhost:6379`
|
||||
|
||||
> Frontend داخل Docker با hot-reload اجرا میشود؛ دیگر نیازی به `npm run dev` جداگانه نیست.
|
||||
|
||||
## راهاندازی محلی
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend/core-service
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
# ترجیحاً از Docker (همراه بقیه سرویسها):
|
||||
docker compose up -d --build
|
||||
|
||||
# یا اجرای مستقیم برای دیباگ UI:
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Frontend: http://localhost:3000
|
||||
|
||||
## اجرای تستها (Backend)
|
||||
|
||||
```bash
|
||||
cd backend/core-service
|
||||
pytest -q
|
||||
```
|
||||
|
||||
## فازهای بعدی
|
||||
|
||||
سرویسهای آینده در [`backend/services/`](./backend/services) بهصورت placeholder آمادهاند.
|
||||
UI کامل در [`frontend/`](./frontend) توسعه مییابد.
|
||||
8
TorbatYar.code-workspace
Normal file
8
TorbatYar.code-workspace
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
31
backend/README.md
Normal file
31
backend/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Backend
|
||||
|
||||
تمام کد منبع backend فقط در این پوشه قرار دارد.
|
||||
|
||||
## ساختار
|
||||
```
|
||||
backend/
|
||||
├── core-service/ # سرویس Core Platform (FastAPI)
|
||||
├── shared-lib/ # کتابخانه مشترک بین سرویسهای backend
|
||||
└── services/ # placeholder سرویسهای آینده
|
||||
```
|
||||
|
||||
## قوانین (اجباری)
|
||||
- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
|
||||
- frontend در پوشه جداگانه `frontend/` قرار دارد.
|
||||
- ارتباط با frontend فقط از طریق REST API.
|
||||
|
||||
## راهاندازی
|
||||
```bash
|
||||
cd backend/core-service
|
||||
python -m venv .venv
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## تستها
|
||||
```bash
|
||||
cd backend/core-service
|
||||
pytest -q
|
||||
```
|
||||
28
backend/core-service/Dockerfile
Normal file
28
backend/core-service/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
# ============================================================
|
||||
# Core Platform Service - Dockerfile
|
||||
# Build context: ریشه مخزن (برای دسترسی به backend/shared-lib)
|
||||
# ============================================================
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/shared-lib/ /shared-lib/
|
||||
COPY backend/core-service/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN sed -i 's#-e ../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
COPY backend/core-service/ /app/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
22
backend/core-service/Dockerfile.dev
Normal file
22
backend/core-service/Dockerfile.dev
Normal file
@ -0,0 +1,22 @@
|
||||
# Dev image: فقط وابستگیها — کد با volume mount و uvicorn --reload
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/shared-lib/ /shared-lib/
|
||||
COPY backend/core-service/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN sed -i 's#-e ../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
EXPOSE 8000
|
||||
44
backend/core-service/alembic.ini
Normal file
44
backend/core-service/alembic.ini
Normal file
@ -0,0 +1,44 @@
|
||||
# پیکربندی Alembic برای Core Platform Service.
|
||||
# آدرس دیتابیس از env خوانده میشود (در alembic/env.py تنظیم شده)،
|
||||
# بنابراین sqlalchemy.url اینجا خالی است.
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
18
backend/core-service/alembic/README.md
Normal file
18
backend/core-service/alembic/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Alembic Migrations
|
||||
|
||||
مدیریت نسخههای اسکیمای دیتابیس `core_platform_db`.
|
||||
|
||||
## دستورها
|
||||
|
||||
```bash
|
||||
# ساخت migration جدید بر اساس تغییر مدلها
|
||||
alembic revision --autogenerate -m "توضیح تغییر"
|
||||
|
||||
# اعمال migrationها
|
||||
alembic upgrade head
|
||||
|
||||
# بازگشت یک مرحله
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده میشود.
|
||||
59
backend/core-service/alembic/env.py
Normal file
59
backend/core-service/alembic/env.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""محیط اجرای Alembic.
|
||||
|
||||
از درایور sync (psycopg) برای migrationها استفاده میشود؛ آدرس آن از
|
||||
تنظیمات (settings.database_url_sync) خوانده میشود تا چیزی hardcode نشود.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
|
||||
# ثبت همه مدلها روی Base.metadata برای autogenerate.
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url_sync,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/core-service/alembic/script.py.mako
Normal file
26
backend/core-service/alembic/script.py.mako
Normal file
@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
35
backend/core-service/alembic/versions/0001_initial.py
Normal file
35
backend/core-service/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""initial core platform schema
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-07-06
|
||||
|
||||
این migration اولیه، اسکیمای کامل Core Platform را از روی metadata مدلها
|
||||
میسازد تا با تعریف مدلها همیشه هماهنگ بماند. migrationهای بعدی بهصورت
|
||||
autogenerate و افزایشی ساخته خواهند شد.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
# import مدلها تا همه جدولها روی metadata ثبت شوند.
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
Base.metadata.drop_all(bind=bind)
|
||||
64
backend/core-service/alembic/versions/0002_users.py
Normal file
64
backend/core-service/alembic/versions/0002_users.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""users table
|
||||
|
||||
Revision ID: 0002_users
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-07-06
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_users"
|
||||
down_revision: Union[str, None] = "0001_initial"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("mobile", sa.String(length=15), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum(
|
||||
"user",
|
||||
"pending_tenant_admin",
|
||||
"tenant_admin",
|
||||
"platform_admin",
|
||||
name="user_role",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("active", "inactive", "suspended", name="user_status"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_users_mobile", "users", ["mobile"], unique=True)
|
||||
op.create_index("ix_users_status", "users", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_status", table_name="users")
|
||||
op.drop_index("ix_users_mobile", table_name="users")
|
||||
op.drop_table("users")
|
||||
op.execute("DROP TYPE IF EXISTS user_role")
|
||||
op.execute("DROP TYPE IF EXISTS user_status")
|
||||
37
backend/core-service/alembic/versions/0003_platform_admin.py
Normal file
37
backend/core-service/alembic/versions/0003_platform_admin.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""promote platform admin mobiles
|
||||
|
||||
Revision ID: 0003_platform_admin
|
||||
Revises: 0002_users
|
||||
Create Date: 2026-07-07
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003_platform_admin"
|
||||
down_revision: Union[str, None] = "0002_users"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET role = 'platform_admin'
|
||||
WHERE mobile IN ('09155105404', '9155105404')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET role = 'pending_tenant_admin'
|
||||
WHERE mobile IN ('09155105404', '9155105404')
|
||||
AND role = 'platform_admin'
|
||||
"""
|
||||
)
|
||||
35
backend/core-service/alembic/versions/0004_user_sso_link.py
Normal file
35
backend/core-service/alembic/versions/0004_user_sso_link.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""user SSO link and mobile verification fields
|
||||
|
||||
Revision ID: 0004_user_sso_link
|
||||
Revises: 0003_platform_admin
|
||||
Create Date: 2026-07-07
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_user_sso_link"
|
||||
down_revision: Union[str, None] = "0003_platform_admin"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("keycloak_sub", sa.String(length=100), nullable=True))
|
||||
op.add_column("users", sa.Column("email", sa.String(length=255), nullable=True))
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("mobile_verified", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
)
|
||||
op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"], unique=True)
|
||||
op.alter_column("users", "mobile_verified", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_keycloak_sub", table_name="users")
|
||||
op.drop_column("users", "mobile_verified")
|
||||
op.drop_column("users", "email")
|
||||
op.drop_column("users", "keycloak_sub")
|
||||
183
backend/core-service/alembic/versions/0005_tenant_onboarding.py
Normal file
183
backend/core-service/alembic/versions/0005_tenant_onboarding.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""tenant onboarding: profile fields, memberships, domain verification, default plans
|
||||
|
||||
Revision ID: 0005_tenant_onboarding
|
||||
Revises: 0004_user_sso_link
|
||||
Create Date: 2026-07-07
|
||||
|
||||
این migration زیرساخت فاز Onboarding/Activation را اضافه میکند:
|
||||
- فیلدهای پروفایل/برندینگ و onboarding_completed روی tenants
|
||||
- مقادیر جدید چرخه عمر tenant_status (draft/pending_activation/archived)
|
||||
- ستونهای is_primary و verification_status روی domains
|
||||
- جدول tenant_memberships (رابطه واقعی user <-> tenant)
|
||||
- ستون current_tenant_id روی users (tenant انتخابی جاری کاربر)
|
||||
- seed پلنهای پیشفرض FREE و STARTER
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_tenant_onboarding"
|
||||
down_revision: Union[str, None] = "0004_user_sso_link"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
is_postgres = bind.dialect.name == "postgresql"
|
||||
|
||||
# ---- ۱) مقادیر جدید چرخه عمر tenant_status ----
|
||||
if is_postgres:
|
||||
for value in ("DRAFT", "PENDING_ACTIVATION", "ARCHIVED"):
|
||||
op.execute(f"ALTER TYPE tenant_status ADD VALUE IF NOT EXISTS '{value}'")
|
||||
|
||||
# ---- ۲) پروفایل/برندینگ روی tenants ----
|
||||
op.add_column("tenants", sa.Column("business_type", sa.String(length=100), nullable=True))
|
||||
op.add_column(
|
||||
"tenants",
|
||||
sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="fa-IR"),
|
||||
)
|
||||
op.add_column(
|
||||
"tenants",
|
||||
sa.Column("timezone", sa.String(length=50), nullable=False, server_default="Asia/Tehran"),
|
||||
)
|
||||
op.add_column("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True))
|
||||
op.add_column("tenants", sa.Column("secondary_color", sa.String(length=20), nullable=True))
|
||||
op.add_column("tenants", sa.Column("logo_url", sa.String(length=500), nullable=True))
|
||||
op.add_column("tenants", sa.Column("favicon_url", sa.String(length=500), nullable=True))
|
||||
op.add_column(
|
||||
"tenants",
|
||||
sa.Column(
|
||||
"onboarding_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")
|
||||
),
|
||||
)
|
||||
op.alter_column("tenants", "default_locale", server_default=None)
|
||||
op.alter_column("tenants", "timezone", server_default=None)
|
||||
op.alter_column("tenants", "onboarding_completed", server_default=None)
|
||||
|
||||
# ---- ۳) وضعیت تأیید دامنه ----
|
||||
domain_verification_status = sa.Enum(
|
||||
"pending", "verified", "failed", name="domain_verification_status"
|
||||
)
|
||||
domain_verification_status.create(bind, checkfirst=True)
|
||||
op.add_column(
|
||||
"domains", sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false"))
|
||||
)
|
||||
op.add_column(
|
||||
"domains",
|
||||
sa.Column(
|
||||
"verification_status",
|
||||
domain_verification_status,
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
),
|
||||
)
|
||||
op.execute("UPDATE domains SET verification_status = 'verified' WHERE is_verified = true")
|
||||
op.alter_column("domains", "is_primary", server_default=None)
|
||||
op.alter_column("domains", "verification_status", server_default=None)
|
||||
|
||||
# ---- ۴) tenant انتخابی جاری کاربر ----
|
||||
op.add_column("users", sa.Column("current_tenant_id", sa.UUID(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_users_current_tenant_id",
|
||||
"users",
|
||||
"tenants",
|
||||
["current_tenant_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# ---- ۵) جدول tenant_memberships ----
|
||||
op.create_table(
|
||||
"tenant_memberships",
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("tenant_id", sa.UUID(), nullable=False),
|
||||
sa.Column("user_id", sa.UUID(), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.Enum(
|
||||
"platform_admin",
|
||||
"tenant_owner",
|
||||
"tenant_admin",
|
||||
"tenant_editor",
|
||||
"tenant_viewer",
|
||||
name="tenant_membership_role",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("active", "invited", "disabled", name="tenant_membership_status"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("is_owner", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
|
||||
)
|
||||
op.create_index("ix_tenant_memberships_tenant_id", "tenant_memberships", ["tenant_id"])
|
||||
op.create_index("ix_tenant_memberships_user_id", "tenant_memberships", ["user_id"])
|
||||
op.create_index("ix_tenant_memberships_role", "tenant_memberships", ["role"])
|
||||
op.create_index("ix_tenant_memberships_status", "tenant_memberships", ["status"])
|
||||
op.alter_column("tenant_memberships", "is_owner", server_default=None)
|
||||
|
||||
# ---- ۶) seed پلنهای پیشفرض (idempotent — بر اساس code) ----
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO plans (id, code, name, description, is_active)
|
||||
VALUES (CAST(:id AS uuid), 'FREE', 'رایگان', 'پلن پیشفرض onboarding — بدون هزینه', true)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
"""
|
||||
).bindparams(id=str(uuid.uuid4()))
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO plans (id, code, name, description, is_active)
|
||||
VALUES (CAST(:id AS uuid), 'STARTER', 'استارتر', 'پلن مقدماتی برای کسبوکارهای کوچک', true)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
"""
|
||||
).bindparams(id=str(uuid.uuid4()))
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
op.execute("DELETE FROM plans WHERE code IN ('FREE', 'STARTER')")
|
||||
|
||||
op.drop_index("ix_tenant_memberships_status", table_name="tenant_memberships")
|
||||
op.drop_index("ix_tenant_memberships_role", table_name="tenant_memberships")
|
||||
op.drop_index("ix_tenant_memberships_user_id", table_name="tenant_memberships")
|
||||
op.drop_index("ix_tenant_memberships_tenant_id", table_name="tenant_memberships")
|
||||
op.drop_table("tenant_memberships")
|
||||
op.execute("DROP TYPE IF EXISTS tenant_membership_status")
|
||||
op.execute("DROP TYPE IF EXISTS tenant_membership_role")
|
||||
|
||||
op.drop_constraint("fk_users_current_tenant_id", "users", type_="foreignkey")
|
||||
op.drop_column("users", "current_tenant_id")
|
||||
|
||||
op.drop_column("domains", "verification_status")
|
||||
op.drop_column("domains", "is_primary")
|
||||
sa.Enum(name="domain_verification_status").drop(bind, checkfirst=True)
|
||||
|
||||
op.drop_column("tenants", "onboarding_completed")
|
||||
op.drop_column("tenants", "favicon_url")
|
||||
op.drop_column("tenants", "logo_url")
|
||||
op.drop_column("tenants", "secondary_color")
|
||||
op.drop_column("tenants", "primary_color")
|
||||
op.drop_column("tenants", "timezone")
|
||||
op.drop_column("tenants", "default_locale")
|
||||
op.drop_column("tenants", "business_type")
|
||||
|
||||
# توجه: مقادیر جدید tenant_status (draft/pending_activation/archived) در
|
||||
# PostgreSQL قابل حذف مستقیم از نوع enum نیستند؛ در صورت نیاز باید نوع را
|
||||
# بازسازی کرد. در این downgrade نادیده گرفته میشود.
|
||||
3
backend/core-service/app/__init__.py
Normal file
3
backend/core-service/app/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Core Platform Service - بسته اصلی برنامه."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
backend/core-service/app/api/__init__.py
Normal file
1
backend/core-service/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""لایه API."""
|
||||
106
backend/core-service/app/api/deps.py
Normal file
106
backend/core-service/app/api/deps.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Dependencyهای مشترک API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, get_optional_user
|
||||
from app.models.user import User
|
||||
from app.services.user_service import UserService
|
||||
from shared.exceptions import TenantNotResolvedError
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"get_pagination",
|
||||
"require_tenant",
|
||||
"get_current_core_user",
|
||||
"get_optional_core_user",
|
||||
"TenantResolution",
|
||||
"get_tenant_resolution",
|
||||
"AsyncSession",
|
||||
]
|
||||
|
||||
|
||||
def get_pagination(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> PaginationParams:
|
||||
return PaginationParams(page=page, page_size=page_size)
|
||||
|
||||
|
||||
def require_tenant(request: Request) -> UUID:
|
||||
"""اطمینان از resolve شدن tenant برای endpointهای tenant-aware.
|
||||
|
||||
این dependency مقدار tenant_id را از request.state (که middleware پر
|
||||
کرده) میخواند و در نبود آن خطای مناسب برمیگرداند.
|
||||
"""
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
if tenant_id is None:
|
||||
raise TenantNotResolvedError(
|
||||
"tenant قابل تشخیص نبود. یکی از هدرهای X-Tenant-ID یا "
|
||||
"X-Tenant-Slug را ارسال کنید یا از دامنه معتبر استفاده نمایید."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
async def get_current_core_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""کاربر Core متناظر با هویت جاری (JWT محلی یا Keycloak SSO).
|
||||
|
||||
برای endpointهای onboarding/tenant-context استفاده میشود که نیاز به
|
||||
رکورد واقعی users دارند (نه فقط claims توکن).
|
||||
"""
|
||||
return await UserService(db).resolve_current(user)
|
||||
|
||||
|
||||
async def get_optional_core_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser | None = Depends(get_optional_user),
|
||||
) -> User | None:
|
||||
if user is None:
|
||||
return None
|
||||
try:
|
||||
return await UserService(db).resolve_current(user)
|
||||
except Exception: # noqa: BLE001 - عدم لینک بودن یعنی کاربر Core ندارد
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantResolution:
|
||||
"""نتیجه تشخیص tenant جاری request (هدر/دامنه/tenant انتخابی کاربر)."""
|
||||
|
||||
tenant_id: UUID | None
|
||||
tenant_slug: str | None
|
||||
source: str # "header" | "domain" | "user_default" | "none"
|
||||
|
||||
|
||||
async def get_tenant_resolution(
|
||||
request: Request,
|
||||
core_user: User | None = Depends(get_optional_core_user),
|
||||
) -> TenantResolution:
|
||||
"""تشخیص tenant جاری: اول هدر/دامنه (middleware)، سپس tenant انتخابی کاربر.
|
||||
|
||||
این dependency جایگزین middleware نمیشود و endpointهای platform-admin
|
||||
فعلی را تحت تأثیر قرار نمیدهد (صرفاً یک ابزار کمکی جدید است).
|
||||
"""
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
tenant_slug = getattr(request.state, "tenant_slug", None)
|
||||
if tenant_id is not None:
|
||||
return TenantResolution(tenant_id=tenant_id, tenant_slug=tenant_slug, source="header")
|
||||
|
||||
if core_user is not None and core_user.current_tenant_id is not None:
|
||||
return TenantResolution(
|
||||
tenant_id=core_user.current_tenant_id,
|
||||
tenant_slug=None,
|
||||
source="user_default",
|
||||
)
|
||||
|
||||
return TenantResolution(tenant_id=None, tenant_slug=None, source="none")
|
||||
34
backend/core-service/app/api/v1/__init__.py
Normal file
34
backend/core-service/app/api/v1/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""API نسخه ۱: تجمیع routerها."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
domains,
|
||||
features,
|
||||
health,
|
||||
me,
|
||||
onboarding,
|
||||
plans,
|
||||
service_registry,
|
||||
subscriptions,
|
||||
tenant_context,
|
||||
tenants,
|
||||
)
|
||||
from app.api.v1.admin import tenants as admin_tenants
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# health خارج از prefix نسخه هم در main اضافه میشود؛ اینجا برای کامل بودن.
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(admin_tenants.router)
|
||||
api_router.include_router(tenants.router)
|
||||
api_router.include_router(domains.router)
|
||||
api_router.include_router(plans.router)
|
||||
api_router.include_router(features.router)
|
||||
api_router.include_router(subscriptions.router)
|
||||
api_router.include_router(service_registry.router)
|
||||
api_router.include_router(me.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
api_router.include_router(tenant_context.router)
|
||||
|
||||
__all__ = ["api_router", "health"]
|
||||
4
backend/core-service/app/api/v1/admin/__init__.py
Normal file
4
backend/core-service/app/api/v1/admin/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Routerهای پنل ادمین."""
|
||||
from app.api.v1.admin import tenants
|
||||
|
||||
__all__ = ["tenants"]
|
||||
63
backend/core-service/app/api/v1/admin/tenants.py
Normal file
63
backend/core-service/app/api/v1/admin/tenants.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""APIهای مدیریت Tenant برای پنل ادمین."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination
|
||||
from app.core.security import require_authenticated
|
||||
from app.schemas.tenant import AdminTenantCreate, TenantRead, TenantUpdate
|
||||
from app.services.tenant_service import TenantService
|
||||
from shared.pagination import Page, PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/admin/tenants", tags=["admin-tenants"])
|
||||
|
||||
|
||||
@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant(
|
||||
payload: AdminTenantCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_authenticated),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).create_for_user(user, payload)
|
||||
return TenantRead.model_validate(tenant)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[TenantRead])
|
||||
async def list_tenants(
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_authenticated),
|
||||
) -> Page[TenantRead]:
|
||||
items, total = await TenantService(db).list_for_user(
|
||||
user, offset=pagination.offset, limit=pagination.limit
|
||||
)
|
||||
return Page.create(
|
||||
[TenantRead.model_validate(t) for t in items], total, pagination
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantRead)
|
||||
async def get_tenant(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_authenticated),
|
||||
) -> TenantRead:
|
||||
svc = TenantService(db)
|
||||
tenant = await svc.get(tenant_id)
|
||||
svc.ensure_access(tenant, user)
|
||||
return TenantRead.model_validate(tenant)
|
||||
|
||||
|
||||
@router.patch("/{tenant_id}", response_model=TenantRead)
|
||||
async def update_tenant(
|
||||
tenant_id: UUID,
|
||||
payload: TenantUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_authenticated),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).update(tenant_id, payload, user=user)
|
||||
return TenantRead.model_validate(tenant)
|
||||
32
backend/core-service/app/api/v1/auth.py
Normal file
32
backend/core-service/app/api/v1/auth.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""APIهای احراز هویت OTP."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.auth import (
|
||||
OtpRequestPayload,
|
||||
OtpRequestResponse,
|
||||
OtpVerifyPayload,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/otp/request", response_model=OtpRequestResponse)
|
||||
async def request_otp(
|
||||
payload: OtpRequestPayload,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> OtpRequestResponse:
|
||||
return await AuthService(db).request_otp(payload.mobile, force_sms=payload.force_sms, context=payload.context)
|
||||
|
||||
|
||||
@router.post("/otp/verify", response_model=TokenResponse)
|
||||
async def verify_otp(
|
||||
payload: OtpVerifyPayload,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
return await AuthService(db).verify_otp(payload)
|
||||
59
backend/core-service/app/api/v1/domains.py
Normal file
59
backend/core-service/app/api/v1/domains.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""APIهای مدیریت و resolve دامنه."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.security import require_authenticated, require_tenant_admin
|
||||
from app.schemas.domain import (
|
||||
DomainCreate,
|
||||
DomainRead,
|
||||
DomainResolveRequest,
|
||||
DomainResolveResponse,
|
||||
)
|
||||
from app.services.domain_service import DomainService
|
||||
from app.services.tenant_service import TenantService
|
||||
|
||||
router = APIRouter(tags=["domains"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tenants/{tenant_id}/domains",
|
||||
response_model=DomainRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_domain(
|
||||
tenant_id: UUID,
|
||||
payload: DomainCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_tenant_admin),
|
||||
) -> DomainRead:
|
||||
domain = await DomainService(db).create(tenant_id, payload)
|
||||
return DomainRead.model_validate(domain)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/domains", response_model=list[DomainRead])
|
||||
async def list_domains(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> list[DomainRead]:
|
||||
domains = await DomainService(db).list_by_tenant(tenant_id)
|
||||
return [DomainRead.model_validate(d) for d in domains]
|
||||
|
||||
|
||||
@router.post("/domains/resolve", response_model=DomainResolveResponse)
|
||||
async def resolve_domain(
|
||||
payload: DomainResolveRequest, db: AsyncSession = Depends(get_db)
|
||||
) -> DomainResolveResponse:
|
||||
domain = await DomainService(db).resolve(payload.domain)
|
||||
tenant = await TenantService(db).get(domain.tenant_id)
|
||||
return DomainResolveResponse(
|
||||
tenant_id=tenant.id,
|
||||
tenant_slug=tenant.slug,
|
||||
domain=domain.domain,
|
||||
is_verified=domain.is_verified,
|
||||
)
|
||||
37
backend/core-service/app/api/v1/features.py
Normal file
37
backend/core-service/app/api/v1/features.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""APIهای مدیریت قابلیتها (Features)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination
|
||||
from app.core.security import require_authenticated, require_platform_admin
|
||||
from app.schemas.plan import FeatureCreate, FeatureRead
|
||||
from app.services.plan_service import PlanService
|
||||
from shared.pagination import Page, PaginationParams
|
||||
|
||||
router = APIRouter(prefix="/features", tags=["features"])
|
||||
|
||||
|
||||
@router.post("", response_model=FeatureRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_feature(
|
||||
payload: FeatureCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> FeatureRead:
|
||||
feature = await PlanService(db).create_feature(payload)
|
||||
return FeatureRead.model_validate(feature)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[FeatureRead])
|
||||
async def list_features(
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> Page[FeatureRead]:
|
||||
items, total = await PlanService(db).list_features(
|
||||
offset=pagination.offset, limit=pagination.limit
|
||||
)
|
||||
return Page.create(
|
||||
[FeatureRead.model_validate(f) for f in items], total, pagination
|
||||
)
|
||||
24
backend/core-service/app/api/v1/health.py
Normal file
24
backend/core-service/app/api/v1/health.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Endpoint سلامت سرویس."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import __version__
|
||||
from app.core.cache import cache
|
||||
from app.core.config import settings
|
||||
from app.schemas.common import HealthResponse
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
"""بررسی سلامت سرویس و وابستگیها (Redis)."""
|
||||
redis_ok = await cache.ping()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service=settings.service_name,
|
||||
version=__version__,
|
||||
environment=settings.environment,
|
||||
dependencies={"redis": "ok" if redis_ok else "unavailable"},
|
||||
)
|
||||
46
backend/core-service/app/api/v1/me.py
Normal file
46
backend/core-service/app/api/v1/me.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""API `/me`: هویت جاری، عضویتها و نیاز به onboarding."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_core_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import MembershipSummary, MeResponse
|
||||
from app.services.tenant_context_service import TenantContextService
|
||||
|
||||
router = APIRouter(prefix="/me", tags=["me"])
|
||||
|
||||
|
||||
@router.get("", response_model=MeResponse)
|
||||
async def get_me(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> MeResponse:
|
||||
memberships = await TenantContextService(db).list_memberships_summary(core_user)
|
||||
|
||||
onboarding_required = True
|
||||
if memberships:
|
||||
current = next(
|
||||
(m for m in memberships if m.tenant_id == core_user.current_tenant_id),
|
||||
memberships[0],
|
||||
)
|
||||
onboarding_required = not current.onboarding_completed
|
||||
|
||||
return MeResponse(
|
||||
user_id=core_user.id,
|
||||
mobile=core_user.mobile,
|
||||
email=core_user.email,
|
||||
platform_role=core_user.role.value,
|
||||
onboarding_required=onboarding_required,
|
||||
current_tenant_id=core_user.current_tenant_id,
|
||||
memberships=memberships,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants", response_model=list[MembershipSummary])
|
||||
async def get_my_tenants(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> list[MembershipSummary]:
|
||||
return await TenantContextService(db).list_memberships_summary(core_user)
|
||||
77
backend/core-service/app/api/v1/onboarding.py
Normal file
77
backend/core-service/app/api/v1/onboarding.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""APIهای Onboarding: ایجاد و فعالسازی tenant توسط کاربر جاری."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_core_user, get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import (
|
||||
OnboardingBrandingUpdate,
|
||||
OnboardingDomainUpdate,
|
||||
OnboardingTenantCreate,
|
||||
TenantContextRead,
|
||||
)
|
||||
from app.services.membership_service import MembershipService
|
||||
from app.services.onboarding_service import OnboardingService
|
||||
from app.services.tenant_context_service import TenantContextService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/onboarding/tenant", tags=["onboarding"])
|
||||
|
||||
|
||||
@router.post("", response_model=TenantContextRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_onboarding_tenant(
|
||||
payload: OnboardingTenantCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> TenantContextRead:
|
||||
tenant = await OnboardingService(db).create_tenant(core_user, payload)
|
||||
return await TenantContextService(db).build(tenant, core_user)
|
||||
|
||||
|
||||
@router.patch("/{tenant_id}/branding", response_model=TenantContextRead)
|
||||
async def update_onboarding_branding(
|
||||
tenant_id: UUID,
|
||||
payload: OnboardingBrandingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> TenantContextRead:
|
||||
await MembershipService(db).ensure_role(
|
||||
tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
|
||||
)
|
||||
tenant = await OnboardingService(db).update_branding(tenant_id, payload)
|
||||
return await TenantContextService(db).build(tenant, core_user)
|
||||
|
||||
|
||||
@router.patch("/{tenant_id}/domain", response_model=TenantContextRead)
|
||||
async def update_onboarding_domain(
|
||||
tenant_id: UUID,
|
||||
payload: OnboardingDomainUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> TenantContextRead:
|
||||
await MembershipService(db).ensure_role(
|
||||
tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
|
||||
)
|
||||
tenant = await OnboardingService(db).update_domain(tenant_id, payload)
|
||||
return await TenantContextService(db).build(tenant, core_user)
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/complete", response_model=TenantContextRead)
|
||||
async def complete_onboarding(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> TenantContextRead:
|
||||
await MembershipService(db).ensure_role(
|
||||
tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
|
||||
)
|
||||
tenant = await OnboardingService(db).complete(tenant_id)
|
||||
return await TenantContextService(db).build(tenant, core_user)
|
||||
57
backend/core-service/app/api/v1/plans.py
Normal file
57
backend/core-service/app/api/v1/plans.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""APIهای مدیریت پلنها و اتصال قابلیت به پلن."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination
|
||||
from app.core.security import require_authenticated, require_platform_admin
|
||||
from app.schemas.plan import (
|
||||
PlanCreate,
|
||||
PlanFeatureCreate,
|
||||
PlanFeatureRead,
|
||||
PlanRead,
|
||||
)
|
||||
from app.services.plan_service import PlanService
|
||||
from shared.pagination import Page, PaginationParams
|
||||
|
||||
router = APIRouter(prefix="/plans", tags=["plans"])
|
||||
|
||||
|
||||
@router.post("", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_plan(
|
||||
payload: PlanCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> PlanRead:
|
||||
plan = await PlanService(db).create_plan(payload)
|
||||
return PlanRead.model_validate(plan)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[PlanRead])
|
||||
async def list_plans(
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> Page[PlanRead]:
|
||||
items, total = await PlanService(db).list_plans(
|
||||
offset=pagination.offset, limit=pagination.limit
|
||||
)
|
||||
return Page.create([PlanRead.model_validate(p) for p in items], total, pagination)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/features",
|
||||
response_model=PlanFeatureRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def attach_feature_to_plan(
|
||||
plan_id: UUID,
|
||||
payload: PlanFeatureCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> PlanFeatureRead:
|
||||
plan_feature = await PlanService(db).attach_feature(plan_id, payload)
|
||||
return PlanFeatureRead.model_validate(plan_feature)
|
||||
54
backend/core-service/app/api/v1/service_registry.py
Normal file
54
backend/core-service/app/api/v1/service_registry.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""APIهای ServiceRegistry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination
|
||||
from app.core.security import require_authenticated, require_platform_admin
|
||||
from app.schemas.service_registry import (
|
||||
ServiceCreate,
|
||||
ServiceRead,
|
||||
ServiceStatusUpdate,
|
||||
)
|
||||
from app.services.service_registry_service import ServiceRegistryService
|
||||
from shared.pagination import Page, PaginationParams
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["service-registry"])
|
||||
|
||||
|
||||
@router.post("", response_model=ServiceRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_service(
|
||||
payload: ServiceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> ServiceRead:
|
||||
service = await ServiceRegistryService(db).create(payload)
|
||||
return ServiceRead.model_validate(service)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[ServiceRead])
|
||||
async def list_services(
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> Page[ServiceRead]:
|
||||
items, total = await ServiceRegistryService(db).list(
|
||||
offset=pagination.offset, limit=pagination.limit
|
||||
)
|
||||
return Page.create(
|
||||
[ServiceRead.model_validate(s) for s in items], total, pagination
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{service_id}/status", response_model=ServiceRead)
|
||||
async def update_service_status(
|
||||
service_id: UUID,
|
||||
payload: ServiceStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> ServiceRead:
|
||||
service = await ServiceRegistryService(db).update_status(service_id, payload.status)
|
||||
return ServiceRead.model_validate(service)
|
||||
61
backend/core-service/app/api/v1/subscriptions.py
Normal file
61
backend/core-service/app/api/v1/subscriptions.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""APIهای اشتراک و بررسی دسترسی قابلیت."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.security import require_authenticated, require_tenant_admin
|
||||
from app.schemas.subscription import (
|
||||
FeatureCheckRequest,
|
||||
FeatureCheckResponse,
|
||||
SubscriptionCreate,
|
||||
SubscriptionRead,
|
||||
)
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["subscription"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription",
|
||||
response_model=SubscriptionRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_subscription(
|
||||
tenant_id: UUID,
|
||||
payload: SubscriptionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_tenant_admin),
|
||||
) -> SubscriptionRead:
|
||||
subscription = await SubscriptionService(db).create(tenant_id, payload)
|
||||
return SubscriptionRead.model_validate(subscription)
|
||||
|
||||
|
||||
@router.get("/subscription", response_model=SubscriptionRead)
|
||||
async def get_subscription(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> SubscriptionRead:
|
||||
subscription = await SubscriptionService(db).get_current(tenant_id)
|
||||
return SubscriptionRead.model_validate(subscription)
|
||||
|
||||
|
||||
@router.post("/features/check", response_model=FeatureCheckResponse)
|
||||
async def check_feature(
|
||||
tenant_id: UUID,
|
||||
payload: FeatureCheckRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> FeatureCheckResponse:
|
||||
result = await EntitlementService(db).evaluate(tenant_id, payload.feature_key)
|
||||
return FeatureCheckResponse(
|
||||
tenant_id=tenant_id,
|
||||
feature_key=payload.feature_key,
|
||||
has_access=result.has_access,
|
||||
reason=result.reason,
|
||||
)
|
||||
46
backend/core-service/app/api/v1/tenant_context.py
Normal file
46
backend/core-service/app/api/v1/tenant_context.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""APIهای Tenant Context جاری: `/tenant/current` و `/tenant/switch`."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_core_user, get_db
|
||||
from app.models.enums import MembershipStatus
|
||||
from app.models.user import User
|
||||
from app.repositories.tenant import TenantRepository
|
||||
from app.schemas.onboarding import TenantContextRead, TenantSwitchRequest
|
||||
from app.services.membership_service import MembershipService
|
||||
from app.services.tenant_context_service import TenantContextService
|
||||
from shared.exceptions import ForbiddenError, NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/tenant", tags=["tenant-context"])
|
||||
|
||||
|
||||
@router.get("/current", response_model=TenantContextRead)
|
||||
async def get_current_tenant(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> TenantContextRead:
|
||||
ctx_service = TenantContextService(db)
|
||||
tenant = await ctx_service.resolve_current_tenant(core_user)
|
||||
return await ctx_service.build(tenant, core_user)
|
||||
|
||||
|
||||
@router.post("/switch", response_model=TenantContextRead)
|
||||
async def switch_tenant(
|
||||
payload: TenantSwitchRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
core_user: User = Depends(get_current_core_user),
|
||||
) -> TenantContextRead:
|
||||
membership = await MembershipService(db).get(payload.tenant_id, core_user.id)
|
||||
if membership is None or membership.status != MembershipStatus.ACTIVE:
|
||||
raise ForbiddenError("عضو این tenant نیستید یا عضویت غیرفعال است")
|
||||
|
||||
core_user.current_tenant_id = payload.tenant_id
|
||||
await db.commit()
|
||||
|
||||
tenant = await TenantRepository(db).get(payload.tenant_id)
|
||||
if tenant is None:
|
||||
raise NotFoundError(f"tenant یافت نشد: {payload.tenant_id}")
|
||||
ctx_service = TenantContextService(db)
|
||||
return await ctx_service.build(tenant, core_user)
|
||||
80
backend/core-service/app/api/v1/tenants.py
Normal file
80
backend/core-service/app/api/v1/tenants.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""APIهای مدیریت Tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination
|
||||
from app.core.security import require_authenticated, require_platform_admin, require_tenant_admin
|
||||
from app.schemas.tenant import TenantCreate, TenantRead, TenantUpdate
|
||||
from app.services.tenant_service import TenantService
|
||||
from shared.pagination import Page, PaginationParams
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["tenants"])
|
||||
|
||||
|
||||
@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant(
|
||||
payload: TenantCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).create(payload)
|
||||
return TenantRead.model_validate(tenant)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[TenantRead])
|
||||
async def list_tenants(
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> Page[TenantRead]:
|
||||
items, total = await TenantService(db).list(
|
||||
offset=pagination.offset, limit=pagination.limit
|
||||
)
|
||||
return Page.create(
|
||||
[TenantRead.model_validate(t) for t in items], total, pagination
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantRead)
|
||||
async def get_tenant(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_authenticated),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).get(tenant_id)
|
||||
return TenantRead.model_validate(tenant)
|
||||
|
||||
|
||||
@router.patch("/{tenant_id}", response_model=TenantRead)
|
||||
async def update_tenant(
|
||||
tenant_id: UUID,
|
||||
payload: TenantUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_tenant_admin),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).update(tenant_id, payload)
|
||||
return TenantRead.model_validate(tenant)
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/suspend", response_model=TenantRead)
|
||||
async def suspend_tenant(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).suspend(tenant_id)
|
||||
return TenantRead.model_validate(tenant)
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/activate", response_model=TenantRead)
|
||||
async def activate_tenant(
|
||||
tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_platform_admin),
|
||||
) -> TenantRead:
|
||||
tenant = await TenantService(db).activate(tenant_id)
|
||||
return TenantRead.model_validate(tenant)
|
||||
1
backend/core-service/app/core/__init__.py
Normal file
1
backend/core-service/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""زیرساخت هسته: config، database، cache، security، logging."""
|
||||
129
backend/core-service/app/core/cache.py
Normal file
129
backend/core-service/app/core/cache.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""لایه Cache مبتنی بر Redis (async).
|
||||
|
||||
اگر Redis در دسترس نباشد (مثلاً در تستها)، عملیات cache بهصورت
|
||||
graceful نادیده گرفته میشوند تا برنامه از کار نیفتد.
|
||||
|
||||
کلیدهای پیشنهادی:
|
||||
tenant:{tenant_id}:metadata
|
||||
tenant:{tenant_id}:features
|
||||
feature_access:{tenant_id}:{feature_key}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""پوشش نازک روی redis.asyncio با تحمل خطا (fail-open)."""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self._url = url
|
||||
self._client: aioredis.Redis | None = None
|
||||
self._enabled = True
|
||||
|
||||
@property
|
||||
def client(self) -> aioredis.Redis | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
if self._client is None:
|
||||
self._client = aioredis.from_url(
|
||||
self._url, encoding="utf-8", decode_responses=True
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
client = self.client
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
return await client.get(key)
|
||||
except Exception as exc: # pragma: no cover - وابسته به محیط
|
||||
logger.warning("cache_get_failed", extra={"key": key, "error": str(exc)})
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int | None = None) -> None:
|
||||
client = self.client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
await client.set(key, value, ex=ttl)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("cache_set_failed", extra={"key": key, "error": str(exc)})
|
||||
|
||||
async def ttl(self, key: str) -> int | None:
|
||||
"""ثانیه باقیمانده TTL — None اگر کلید نیست یا Redis غیرفعال."""
|
||||
client = self.client
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
remaining = await client.ttl(key)
|
||||
if remaining is None or remaining < 0:
|
||||
return None
|
||||
return int(remaining)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("cache_ttl_failed", extra={"key": key, "error": str(exc)})
|
||||
return None
|
||||
|
||||
async def delete(self, *keys: str) -> None:
|
||||
client = self.client
|
||||
if client is None or not keys:
|
||||
return
|
||||
try:
|
||||
await client.delete(*keys)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("cache_delete_failed", extra={"keys": keys, "error": str(exc)})
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> None:
|
||||
"""حذف کلیدها بر اساس الگو (برای invalidate کردن گروهی)."""
|
||||
client = self.client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
async for key in client.scan_iter(match=pattern):
|
||||
await client.delete(key)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("cache_delete_pattern_failed", extra={"pattern": pattern, "error": str(exc)})
|
||||
|
||||
async def ping(self) -> bool:
|
||||
client = self.client
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
return bool(await client.ping())
|
||||
except Exception: # pragma: no cover
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
try:
|
||||
await self._client.aclose()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
self._client = None
|
||||
|
||||
def disable(self) -> None:
|
||||
"""غیرفعالسازی cache (مثلاً در تستها)."""
|
||||
self._enabled = False
|
||||
|
||||
|
||||
# ---- کمککنندههای ساخت کلید (تا نام کلیدها یکسان بماند) ----
|
||||
def tenant_metadata_key(tenant_id: str) -> str:
|
||||
return f"tenant:{tenant_id}:metadata"
|
||||
|
||||
|
||||
def tenant_features_key(tenant_id: str) -> str:
|
||||
return f"tenant:{tenant_id}:features"
|
||||
|
||||
|
||||
def feature_access_key(tenant_id: str, feature_key: str) -> str:
|
||||
return f"feature_access:{tenant_id}:{feature_key}"
|
||||
|
||||
|
||||
cache = CacheClient(settings.redis_url)
|
||||
154
backend/core-service/app/core/config.py
Normal file
154
backend/core-service/app/core/config.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""تنظیمات مرکزی برنامه با Pydantic v2 Settings.
|
||||
|
||||
همه مقادیر از متغیرهای محیطی (env) خوانده میشوند تا هیچ چیز در کد
|
||||
hardcode نشود (نام برند، رنگها، آدرسها و ...).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ---- General ----
|
||||
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
service_name: str = "core-service"
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
# ---- Platform Branding (White-label) ----
|
||||
platform_name: str = "SuperApp"
|
||||
platform_support_email: str = "support@example.com"
|
||||
platform_primary_color: str = "#0284c7"
|
||||
platform_secondary_color: str = "#0f172a"
|
||||
# دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
|
||||
platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
|
||||
|
||||
# ---- Database ----
|
||||
# آدرس async برای برنامه و sync برای Alembic.
|
||||
database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"
|
||||
database_url_sync: str = "postgresql+psycopg://superapp:superapp_password@localhost:5432/core_platform_db"
|
||||
db_echo: bool = False
|
||||
db_pool_size: int = 10
|
||||
db_max_overflow: int = 20
|
||||
|
||||
# ---- Redis ----
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
feature_access_cache_ttl: int = 300
|
||||
tenant_metadata_cache_ttl: int = 300
|
||||
|
||||
# ---- Celery ----
|
||||
celery_broker_url: str = "redis://localhost:6379/1"
|
||||
celery_result_backend: str = "redis://localhost:6379/2"
|
||||
|
||||
# ---- Keycloak / SSO ----
|
||||
keycloak_enabled: bool = False
|
||||
keycloak_server_url: str = "http://localhost:8080"
|
||||
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_realm: str = "superapp"
|
||||
keycloak_client_id: str = "core-service"
|
||||
keycloak_client_secret: str = "change-me"
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_audience: str = "account"
|
||||
jwt_verify_signature: bool = False
|
||||
# اگر true باشد همه endpointهای محافظتشده نیازمند JWT معتبر هستند.
|
||||
auth_required: bool = False
|
||||
cors_origins: str = Field(
|
||||
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||
validation_alias="CORS_ORIGINS",
|
||||
)
|
||||
|
||||
# ---- Identity Service ----
|
||||
identity_service_url: str = "http://localhost:8001"
|
||||
|
||||
# ---- Internal Service Tokens ----
|
||||
internal_token_secret: str = "change-me-internal-secret"
|
||||
|
||||
# ---- OTP / Payamak SMS ----
|
||||
payamak_username: str = "9155105404"
|
||||
payamak_from: str = Field(default="9982004961", validation_alias="PAYAMAK_FROM")
|
||||
payamak_api_key: str = Field(default="", validation_alias="PAYAMAK_API_KEY")
|
||||
payamak_apikey: str = Field(default="", validation_alias="PAYAMAK_APIKEY")
|
||||
payamak_password: str = Field(default="", validation_alias="PAYAMAK_PASSWORD")
|
||||
payamak_body_id: int = Field(default=245189, validation_alias="PAYAMAK_BODY_ID")
|
||||
otp_expire_seconds: int = 120
|
||||
|
||||
# ---- Platform bootstrap admins (OTP) ----
|
||||
platform_admin_mobiles: str = Field(
|
||||
default="",
|
||||
validation_alias="PLATFORM_ADMIN_MOBILES",
|
||||
)
|
||||
|
||||
@property
|
||||
def platform_admin_mobile_set(self) -> set[str]:
|
||||
from app.utils.phone import normalize_mobile
|
||||
|
||||
result: set[str] = set()
|
||||
for raw in self.platform_admin_mobiles.split(","):
|
||||
item = raw.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
result.add(normalize_mobile(item))
|
||||
except ValueError:
|
||||
continue
|
||||
return result
|
||||
|
||||
@property
|
||||
def payamak_auth_secret(self) -> str:
|
||||
"""ApiKey در پارامتر password (طبق مستندات ملیپیامک)."""
|
||||
return (self.payamak_api_key or self.payamak_apikey or self.payamak_password).strip()
|
||||
|
||||
@property
|
||||
def payamak_verification_body_id(self) -> int:
|
||||
"""شناسه الگوی کد تأیید — همان PAYAMAK_BODY_ID در torbatkar-back."""
|
||||
return self.payamak_body_id
|
||||
|
||||
# ---- Local JWT (OTP users) ----
|
||||
jwt_secret: str = "change-me-jwt-secret"
|
||||
jwt_access_token_expire_seconds: int = 3600
|
||||
jwt_refresh_token_expire_seconds: int = 604800
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.environment == "production"
|
||||
|
||||
@property
|
||||
def keycloak_browser_url(self) -> str:
|
||||
return self.keycloak_public_url or self.keycloak_server_url
|
||||
|
||||
@property
|
||||
def keycloak_public_realm_url(self) -> str:
|
||||
return f"{self.keycloak_browser_url}/realms/{self.keycloak_realm}"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def jwks_url(self) -> str:
|
||||
"""آدرس JWKS در Keycloak برای اعتبارسنجی امضای JWT."""
|
||||
return (
|
||||
f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
|
||||
"/protocol/openid-connect/certs"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""نمونه cache شده تنظیمات (singleton)."""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
50
backend/core-service/app/core/database.py
Normal file
50
backend/core-service/app/core/database.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""راهاندازی SQLAlchemy 2.x بهصورت async.
|
||||
|
||||
- Base declarative مشترک همه مدلها
|
||||
- Engine و session factory
|
||||
- Dependency برای دریافت AsyncSession در FastAPI
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""پایه declarative مشترک برای همه مدلهای Core."""
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.db_echo,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency: یک AsyncSession در طول عمر request فراهم میکند.
|
||||
|
||||
در صورت بروز خطا rollback و در پایان session بسته میشود.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
61
backend/core-service/app/core/logging.py
Normal file
61
backend/core-service/app/core/logging.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""پیکربندی لاگگیری ساختیافته (structured logging).
|
||||
|
||||
از logging استاندارد پایتون با فرمت JSON-friendly استفاده میکنیم تا در
|
||||
production قابل جمعآوری توسط ابزارهای log aggregation باشد.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_configured = False
|
||||
|
||||
|
||||
class _KeyValueFormatter(logging.Formatter):
|
||||
"""فرمت ساده key=value برای خوانایی و پردازش ماشینی."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
base: dict[str, Any] = {
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"service": settings.service_name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
base["exc"] = self.formatException(record.exc_info)
|
||||
# فیلدهای اضافی که با extra ارسال شدهاند
|
||||
for key, value in record.__dict__.items():
|
||||
if key in ("args", "msg", "levelname", "name", "exc_info", "exc_text",
|
||||
"stack_info", "created", "msecs", "relativeCreated", "levelno",
|
||||
"pathname", "filename", "module", "funcName", "lineno",
|
||||
"processName", "process", "threadName", "thread", "taskName"):
|
||||
continue
|
||||
base.setdefault(key, value)
|
||||
return " ".join(f"{k}={v!r}" for k, v in base.items())
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""پیکربندی سراسری لاگگیری (idempotent)."""
|
||||
global _configured
|
||||
if _configured:
|
||||
return
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(_KeyValueFormatter())
|
||||
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(settings.log_level.upper())
|
||||
|
||||
# کاهش نویز لاگ کتابخانههای پرحرف
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
_configured = True
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
configure_logging()
|
||||
return logging.getLogger(name)
|
||||
97
backend/core-service/app/core/security.py
Normal file
97
backend/core-service/app/core/security.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""امنیت هسته: اعتبارسنجی JWT و dependencyهای احراز هویت/مجوز."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.auth import JWTSettings, JWTValidator, PlatformRole, has_any_role
|
||||
from shared.exceptions import ForbiddenError, UnauthorizedError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
_token_service = TokenService()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_jwt_validator() -> JWTValidator:
|
||||
return JWTValidator(
|
||||
JWTSettings(
|
||||
keycloak_enabled=settings.keycloak_enabled,
|
||||
keycloak_server_url=settings.keycloak_server_url,
|
||||
keycloak_realm=settings.keycloak_realm,
|
||||
jwt_algorithm=settings.jwt_algorithm,
|
||||
jwt_audience=settings.jwt_audience,
|
||||
jwt_verify_signature=settings.jwt_verify_signature,
|
||||
jwt_issuer=settings.keycloak_public_realm_url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
# در تستها یا حالت bootstrap بدون SSO
|
||||
return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||
|
||||
token = credentials.credentials
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
|
||||
|
||||
alg = header.get("alg", "")
|
||||
if alg == "HS256":
|
||||
return _token_service.validate_access_token(token)
|
||||
return await get_jwt_validator().validate(token)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> CurrentUser | None:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
return None
|
||||
token = credentials.credentials
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if header.get("alg") == "HS256":
|
||||
try:
|
||||
return _token_service.validate_access_token(token)
|
||||
except UnauthorizedError:
|
||||
return None
|
||||
try:
|
||||
return await get_jwt_validator().validate(token)
|
||||
except UnauthorizedError:
|
||||
return None
|
||||
|
||||
|
||||
def require_roles(*roles: PlatformRole | str):
|
||||
"""Dependency factory: کاربر باید حداقل یکی از نقشها را داشته باشد."""
|
||||
|
||||
async def _checker(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return user
|
||||
if not has_any_role(user, *roles):
|
||||
raise ForbiddenError("دسترسی کافی برای این عملیات ندارید")
|
||||
return user
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
require_platform_admin = require_roles(PlatformRole.PLATFORM_ADMIN)
|
||||
require_tenant_admin = require_roles(
|
||||
PlatformRole.PLATFORM_ADMIN, PlatformRole.TENANT_ADMIN
|
||||
)
|
||||
require_authenticated = get_current_user
|
||||
84
backend/core-service/app/main.py
Normal file
84
backend/core-service/app/main.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""نقطه ورود برنامه FastAPI برای Core Platform Service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app import __version__
|
||||
from app.api.v1 import api_router, health
|
||||
from app.core.cache import cache
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.middlewares.tenant import TenantResolutionMiddleware
|
||||
from shared.exceptions import AppError
|
||||
from shared.responses import ErrorDetail, ErrorResponse
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("service_starting", extra={"service": settings.service_name})
|
||||
yield
|
||||
await cache.close()
|
||||
logger.info("service_stopped")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=f"{settings.platform_name} - Core Platform Service",
|
||||
version=__version__,
|
||||
description="سرویس هسته پلتفرم SaaS چندمستأجری (فاز ۱).",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS - در production باید origins محدود شود.
|
||||
# allow_origin_regex برای سابدامین tenantها (<slug>.torbatyar.ir)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Middleware تشخیص tenant.
|
||||
app.add_middleware(
|
||||
TenantResolutionMiddleware, base_domain=settings.platform_base_domain or None
|
||||
)
|
||||
|
||||
# ثبت exception handlerها برای تبدیل خطاهای دامنه به پاسخ استاندارد.
|
||||
_register_exception_handlers(app)
|
||||
|
||||
# routerها
|
||||
app.include_router(health.router)
|
||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
response = ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code=exc.error_code, message=exc.message, details=exc.details
|
||||
)
|
||||
)
|
||||
return JSONResponse(status_code=exc.status_code, content=response.model_dump())
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
|
||||
response = ErrorResponse(
|
||||
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
|
||||
)
|
||||
return JSONResponse(status_code=500, content=response.model_dump())
|
||||
|
||||
|
||||
app = create_app()
|
||||
1
backend/core-service/app/middlewares/__init__.py
Normal file
1
backend/core-service/app/middlewares/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Middlewareهای برنامه."""
|
||||
108
backend/core-service/app/middlewares/tenant.py
Normal file
108
backend/core-service/app/middlewares/tenant.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""Middleware تشخیص Tenant.
|
||||
|
||||
ترتیب تشخیص tenant:
|
||||
1. هدر X-Tenant-ID
|
||||
2. هدر X-Tenant-Slug
|
||||
3. Subdomain (از روی Host)
|
||||
4. Custom domain (از روی Host)
|
||||
|
||||
نتیجه در request.state قرار میگیرد:
|
||||
request.state.tenant_id
|
||||
request.state.tenant_slug
|
||||
|
||||
توجه: این middleware اگر tenant پیدا نشد خطا نمیدهد؛ صرفاً state را خالی
|
||||
میگذارد. اعمال اجبار روی endpointهای tenant-aware از طریق dependency
|
||||
(require_tenant) انجام میشود.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.cache import cache, tenant_metadata_key
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.logging import get_logger
|
||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||
from shared.tenant import HEADER_TENANT_ID, HEADER_TENANT_SLUG
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TenantResolutionMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, *, base_domain: str | None = None) -> None:
|
||||
super().__init__(app)
|
||||
# base_domain برای تشخیص subdomain استفاده میشود (مثلاً example.com).
|
||||
self.base_domain = base_domain
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
request.state.tenant_id = None
|
||||
request.state.tenant_slug = None
|
||||
|
||||
try:
|
||||
await self._resolve(request)
|
||||
except Exception as exc: # pragma: no cover - نباید request را بشکند
|
||||
logger.warning("tenant_resolution_error", extra={"error": str(exc)})
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
async def _resolve(self, request: Request) -> None:
|
||||
# ۱) هدر X-Tenant-ID
|
||||
raw_id = request.headers.get(HEADER_TENANT_ID)
|
||||
if raw_id:
|
||||
try:
|
||||
tenant_id = UUID(raw_id)
|
||||
except ValueError:
|
||||
tenant_id = None
|
||||
if tenant_id is not None:
|
||||
await self._set_from_tenant_id(request, tenant_id)
|
||||
if request.state.tenant_id is not None:
|
||||
return
|
||||
|
||||
# ۲) هدر X-Tenant-Slug
|
||||
slug = request.headers.get(HEADER_TENANT_SLUG)
|
||||
if slug:
|
||||
await self._set_from_slug(request, slug.strip().lower())
|
||||
if request.state.tenant_id is not None:
|
||||
return
|
||||
|
||||
# ۳ و ۴) subdomain یا custom domain از روی Host
|
||||
host = request.headers.get("host", "").split(":")[0].strip().lower()
|
||||
if not host:
|
||||
return
|
||||
|
||||
# اگر base_domain تعریف شده و host زیردامنه آن است → slug = بخش اول.
|
||||
if self.base_domain and host.endswith("." + self.base_domain):
|
||||
sub = host[: -(len(self.base_domain) + 1)]
|
||||
if sub and sub != "www":
|
||||
await self._set_from_slug(request, sub)
|
||||
if request.state.tenant_id is not None:
|
||||
return
|
||||
|
||||
# در غیر این صورت host را بهعنوان custom domain در نظر بگیر.
|
||||
await self._set_from_domain(request, host)
|
||||
|
||||
async def _set_from_tenant_id(self, request: Request, tenant_id: UUID) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
tenant = await TenantRepository(session).get(tenant_id)
|
||||
if tenant is not None:
|
||||
request.state.tenant_id = tenant.id
|
||||
request.state.tenant_slug = tenant.slug
|
||||
|
||||
async def _set_from_slug(self, request: Request, slug: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
tenant = await TenantRepository(session).get_by_slug(slug)
|
||||
if tenant is not None:
|
||||
request.state.tenant_id = tenant.id
|
||||
request.state.tenant_slug = tenant.slug
|
||||
|
||||
async def _set_from_domain(self, request: Request, host: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
domain = await DomainRepository(session).get_by_domain(host)
|
||||
if domain is not None:
|
||||
tenant = await TenantRepository(session).get(domain.tenant_id)
|
||||
if tenant is not None:
|
||||
request.state.tenant_id = tenant.id
|
||||
request.state.tenant_slug = tenant.slug
|
||||
38
backend/core-service/app/models/__init__.py
Normal file
38
backend/core-service/app/models/__init__.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""مدلهای SQLAlchemy 2.x برای Core Platform.
|
||||
|
||||
توجه: import همه مدلها در این ماژول لازم است تا Alembic و Base.metadata
|
||||
همه جدولها را بشناسند.
|
||||
"""
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.domain import Domain
|
||||
from app.models.events import InboxEvent, OutboxEvent
|
||||
from app.models.membership import TenantMembership
|
||||
from app.models.plan import Feature, Plan, PlanFeature
|
||||
from app.models.registry import (
|
||||
InternalServiceToken,
|
||||
ModuleRegistry,
|
||||
ServiceRegistry,
|
||||
TenantModuleAccess,
|
||||
)
|
||||
from app.models.subscription import TenantFeatureAccess, TenantSubscription
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Tenant",
|
||||
"User",
|
||||
"TenantMembership",
|
||||
"Domain",
|
||||
"Plan",
|
||||
"Feature",
|
||||
"PlanFeature",
|
||||
"TenantSubscription",
|
||||
"TenantFeatureAccess",
|
||||
"ServiceRegistry",
|
||||
"ModuleRegistry",
|
||||
"TenantModuleAccess",
|
||||
"InternalServiceToken",
|
||||
"OutboxEvent",
|
||||
"InboxEvent",
|
||||
"AuditLog",
|
||||
]
|
||||
35
backend/core-service/app/models/audit.py
Normal file
35
backend/core-service/app/models/audit.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""مدل AuditLog: ثبت رخدادهای مهم برای پیگیری و امنیت."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class AuditLog(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
resource_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
# از نام ستون meta_data برای پرهیز از تداخل با کلمه رزروشده metadata استفاده شده.
|
||||
meta_data: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_audit_logs_tenant_id", "tenant_id"),
|
||||
Index("ix_audit_logs_created_at", "created_at"),
|
||||
)
|
||||
32
backend/core-service/app/models/base.py
Normal file
32
backend/core-service/app/models/base.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Mixinهای مشترک مدلها: شناسه UUID و زمانهای ایجاد/بهروزرسانی."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
"""کلید اصلی از نوع UUID با مقدار پیشفرض تولیدشده در سمت برنامه."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""ستونهای created_at و updated_at با مدیریت خودکار."""
|
||||
|
||||
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,
|
||||
)
|
||||
56
backend/core-service/app/models/domain.py
Normal file
56
backend/core-service/app/models/domain.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""مدل Domain: دامنهها/زیردامنههای هر tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.enums import DomainType, DomainVerificationStatus
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class Domain(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "domains"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# دامنه بهصورت یکتا در کل پلتفرم (مثلاً acme.example.com یا shop.acme.ir).
|
||||
domain: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
domain_type: Mapped[DomainType] = mapped_column(
|
||||
SAEnum(DomainType, name="domain_type"),
|
||||
default=DomainType.SUBDOMAIN,
|
||||
nullable=False,
|
||||
)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# آیا این دامنه، دامنه اصلی/پیشفرض tenant است (مثلاً زیردامنه ساختهشده در onboarding).
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
verification_status: Mapped[DomainVerificationStatus] = mapped_column(
|
||||
SAEnum(
|
||||
DomainVerificationStatus,
|
||||
name="domain_verification_status",
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
),
|
||||
default=DomainVerificationStatus.PENDING,
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_domains_domain", "domain", unique=True),
|
||||
Index("ix_domains_tenant_id", "tenant_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<Domain id={self.id} domain={self.domain!r} tenant_id={self.tenant_id}>"
|
||||
79
backend/core-service/app/models/enums.py
Normal file
79
backend/core-service/app/models/enums.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Enumهای دامنه مدلهای Core."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class TenantStatus(str, enum.Enum):
|
||||
# چرخه عمر عملیاتی (فاز onboarding)
|
||||
DRAFT = "draft"
|
||||
PENDING_ACTIVATION = "pending_activation"
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
ARCHIVED = "archived"
|
||||
# مقادیر قدیمی (سازگاری با فازهای قبلی)
|
||||
INACTIVE = "inactive"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class TenantMembershipRole(str, enum.Enum):
|
||||
"""نقش کاربر داخل یک tenant مشخص (متفاوت از نقش سراسری JWT)."""
|
||||
|
||||
PLATFORM_ADMIN = "platform_admin"
|
||||
TENANT_OWNER = "tenant_owner"
|
||||
TENANT_ADMIN = "tenant_admin"
|
||||
TENANT_EDITOR = "tenant_editor"
|
||||
TENANT_VIEWER = "tenant_viewer"
|
||||
|
||||
|
||||
class MembershipStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INVITED = "invited"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class DomainVerificationStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
VERIFIED = "verified"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DomainType(str, enum.Enum):
|
||||
SUBDOMAIN = "subdomain"
|
||||
CUSTOM_DOMAIN = "custom_domain"
|
||||
|
||||
|
||||
class SubscriptionStatus(str, enum.Enum):
|
||||
TRIALING = "trialing"
|
||||
ACTIVE = "active"
|
||||
PAST_DUE = "past_due"
|
||||
CANCELED = "canceled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class LimitPeriod(str, enum.Enum):
|
||||
"""دوره سقف مصرف یک قابلیت."""
|
||||
|
||||
DAY = "day"
|
||||
MONTH = "month"
|
||||
YEAR = "year"
|
||||
TOTAL = "total" # بدون بازنشانی دورهای
|
||||
|
||||
|
||||
class ServiceStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
MAINTENANCE = "maintenance"
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
USER = "user"
|
||||
PENDING_TENANT_ADMIN = "pending_tenant_admin"
|
||||
TENANT_ADMIN = "tenant_admin"
|
||||
PLATFORM_ADMIN = "platform_admin"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
SUSPENDED = "suspended"
|
||||
62
backend/core-service/app/models/events.py
Normal file
62
backend/core-service/app/models/events.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""مدلهای Outbox و Inbox برای الگوی رویداد قابل اعتماد.
|
||||
|
||||
OutboxEvent: رویدادهای خروجی که باید منتشر شوند.
|
||||
InboxEvent: رویدادهای ورودی برای idempotency و جلوگیری از پردازش تکراری.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.types import GUID
|
||||
from shared.events import EventStatus
|
||||
|
||||
|
||||
class OutboxEvent(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "outbox_events"
|
||||
|
||||
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
aggregate_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
status: Mapped[EventStatus] = mapped_column(
|
||||
SAEnum(EventStatus, name="event_status"),
|
||||
default=EventStatus.PENDING,
|
||||
nullable=False,
|
||||
)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_outbox_events_status", "status"),
|
||||
Index("ix_outbox_events_tenant_id", "tenant_id"),
|
||||
)
|
||||
|
||||
|
||||
class InboxEvent(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "inbox_events"
|
||||
|
||||
# شناسه یکتای رویداد منبع برای جلوگیری از پردازش تکراری (idempotency).
|
||||
event_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, unique=True)
|
||||
source_service: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_inbox_events_event_id", "event_id", unique=True),
|
||||
Index("ix_inbox_events_tenant_id", "tenant_id"),
|
||||
)
|
||||
62
backend/core-service/app/models/membership.py
Normal file
62
backend/core-service/app/models/membership.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""مدل TenantMembership: رابطه واقعی کاربر ↔ tenant (فاز Onboarding).
|
||||
|
||||
هر کاربر میتواند در آینده عضو چند tenant باشد؛ نقش و وضعیت عضویت او در هر
|
||||
tenant مستقل از نقش سراسری (User.role / JWT roles) نگهداری میشود.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MembershipStatus, TenantMembershipRole
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class TenantMembership(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "tenant_memberships"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
role: Mapped[TenantMembershipRole] = mapped_column(
|
||||
SAEnum(
|
||||
TenantMembershipRole,
|
||||
name="tenant_membership_role",
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
),
|
||||
default=TenantMembershipRole.TENANT_VIEWER,
|
||||
nullable=False,
|
||||
)
|
||||
status: Mapped[MembershipStatus] = mapped_column(
|
||||
SAEnum(
|
||||
MembershipStatus,
|
||||
name="tenant_membership_status",
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
),
|
||||
default=MembershipStatus.ACTIVE,
|
||||
nullable=False,
|
||||
)
|
||||
is_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
|
||||
Index("ix_tenant_memberships_tenant_id", "tenant_id"),
|
||||
Index("ix_tenant_memberships_user_id", "user_id"),
|
||||
Index("ix_tenant_memberships_role", "role"),
|
||||
Index("ix_tenant_memberships_status", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<TenantMembership tenant_id={self.tenant_id} user_id={self.user_id} "
|
||||
f"role={self.role}>"
|
||||
)
|
||||
74
backend/core-service/app/models/plan.py
Normal file
74
backend/core-service/app/models/plan.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""مدلهای Plan، Feature و PlanFeature.
|
||||
|
||||
Plan: بسته اشتراک.
|
||||
Feature: یک قابلیت قابل کنترل (مثلاً accounting.invoice.create).
|
||||
PlanFeature: نگاشت قابلیتها به هر پلن به همراه سقف مصرف.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.enums import LimitPeriod
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class Plan(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "plans"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
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
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_plans_code", "code", unique=True),)
|
||||
|
||||
|
||||
class Feature(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "features"
|
||||
|
||||
# کلید یکتای قابلیت، مثال: accounting.invoice.create
|
||||
feature_key: Mapped[str] = mapped_column(String(150), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# سرویسی که این قابلیت به آن تعلق دارد، مثال: accounting، crm.
|
||||
service_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_features_feature_key", "feature_key", unique=True),
|
||||
Index("ix_features_service_key", "service_key"),
|
||||
)
|
||||
|
||||
|
||||
class PlanFeature(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "plan_features"
|
||||
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
feature_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# سقف مصرف؛ None یعنی نامحدود.
|
||||
limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
limit_period: Mapped[LimitPeriod | None] = mapped_column(
|
||||
SAEnum(LimitPeriod, name="limit_period"), nullable=True
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("plan_id", "feature_id", name="uq_plan_feature"),
|
||||
Index("ix_plan_features_plan_id", "plan_id"),
|
||||
Index("ix_plan_features_feature_id", "feature_id"),
|
||||
)
|
||||
92
backend/core-service/app/models/registry.py
Normal file
92
backend/core-service/app/models/registry.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""مدلهای رجیستری سرویس/ماژول و توکنهای داخلی سرویس.
|
||||
|
||||
- ServiceRegistry: ثبت سرویسهای داخلی آینده و آدرسهای آنها.
|
||||
- ModuleRegistry: ماژولهای قابل فعال/غیرفعالسازی برای tenantها.
|
||||
- TenantModuleAccess: وضعیت فعال بودن هر ماژول برای هر tenant.
|
||||
- InternalServiceToken: توکن امن ارتباط بین سرویسها (hash ذخیره میشود).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ServiceStatus
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class ServiceRegistry(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "service_registry"
|
||||
|
||||
service_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status: Mapped[ServiceStatus] = mapped_column(
|
||||
SAEnum(ServiceStatus, name="service_status"),
|
||||
default=ServiceStatus.ACTIVE,
|
||||
nullable=False,
|
||||
)
|
||||
health_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_service_registry_service_key", "service_key", unique=True),)
|
||||
|
||||
|
||||
class ModuleRegistry(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "module_registry"
|
||||
|
||||
module_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# ماژول سیستمی همیشه فعال است و توسط tenant قابل غیرفعالسازی نیست.
|
||||
is_system_module: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
__table_args__ = (Index("ix_module_registry_module_key", "module_key", unique=True),)
|
||||
|
||||
|
||||
class TenantModuleAccess(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "tenant_module_access"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
module_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("module_registry.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "module_id", name="uq_tenant_module_access"),
|
||||
Index("ix_tenant_module_access_tenant_id", "tenant_id"),
|
||||
)
|
||||
|
||||
|
||||
class InternalServiceToken(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "internal_service_tokens"
|
||||
|
||||
service_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# فقط hash توکن ذخیره میشود، نه خود توکن.
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
# اسکوپها بهصورت رشتهی جداشده با کاما یا فضای مجاز.
|
||||
scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
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
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_internal_service_tokens_service_key", "service_key"),
|
||||
Index("ix_internal_service_tokens_token_hash", "token_hash", unique=True),
|
||||
)
|
||||
66
backend/core-service/app/models/subscription.py
Normal file
66
backend/core-service/app/models/subscription.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""مدلهای TenantSubscription و TenantFeatureAccess."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SubscriptionStatus
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class TenantSubscription(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "tenant_subscriptions"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
status: Mapped[SubscriptionStatus] = mapped_column(
|
||||
SAEnum(SubscriptionStatus, name="subscription_status"),
|
||||
default=SubscriptionStatus.ACTIVE,
|
||||
nullable=False,
|
||||
)
|
||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_tenant_subscriptions_tenant_id", "tenant_id"),
|
||||
Index("ix_tenant_subscriptions_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
class TenantFeatureAccess(UUIDPrimaryKeyMixin, Base):
|
||||
"""دسترسی سفارشی/override یک tenant به یک قابلیت (فراتر از پلن)."""
|
||||
|
||||
__tablename__ = "tenant_feature_access"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
feature_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
# سقف سفارشی؛ در صورت وجود، جایگزین سقف پلن میشود. None یعنی نامحدود.
|
||||
custom_limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
used_value: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "feature_id", name="uq_tenant_feature_access"),
|
||||
Index("ix_tenant_feature_access_tenant_id", "tenant_id"),
|
||||
Index("ix_tenant_feature_access_feature_id", "feature_id"),
|
||||
)
|
||||
49
backend/core-service/app/models/tenant.py
Normal file
49
backend/core-service/app/models/tenant.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""مدل Tenant: مستأجر اصلی پلتفرم."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import TenantStatus
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class Tenant(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# slug یکتا برای تشخیص tenant (مثلاً در subdomain).
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
status: Mapped[TenantStatus] = mapped_column(
|
||||
SAEnum(TenantStatus, name="tenant_status"),
|
||||
default=TenantStatus.ACTIVE,
|
||||
nullable=False,
|
||||
)
|
||||
# شناسه کاربر مالک (از Identity Service آینده؛ اینجا فقط ذخیره میشود).
|
||||
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
|
||||
# ---- پروفایل/تنظیمات tenant (فاز onboarding) ----
|
||||
business_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="fa-IR", nullable=False)
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Tehran", nullable=False)
|
||||
primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
secondary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
favicon_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
onboarding_completed: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_tenants_slug", "slug", unique=True),
|
||||
Index("ix_tenants_status", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<Tenant id={self.id} slug={self.slug!r} status={self.status}>"
|
||||
41
backend/core-service/app/models/types.py
Normal file
41
backend/core-service/app/models/types.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""انواع سفارشی قابل حمل دیتابیس.
|
||||
|
||||
GUID: روی PostgreSQL از نوع UUID و روی SQLite (تستها) از CHAR(36)
|
||||
استفاده میکند تا مدلها هم در production و هم در تست کار کنند.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.types import CHAR, TypeDecorator
|
||||
|
||||
|
||||
class GUID(TypeDecorator):
|
||||
"""نوع شناسه سراسری منطبق با UUID.
|
||||
|
||||
در PostgreSQL بهصورت native UUID و در سایر دیالکتها بهصورت رشته
|
||||
۳۶ کاراکتری ذخیره میشود.
|
||||
"""
|
||||
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if dialect.name == "postgresql":
|
||||
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
53
backend/core-service/app/models/user.py
Normal file
53
backend/core-service/app/models/user.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""مدل User: کاربران ثبتنامشده از طریق OTP."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Enum as SAEnum
|
||||
from sqlalchemy import ForeignKey, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import UserRole, UserStatus
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
mobile: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
|
||||
keycloak_sub: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
mobile_verified: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
SAEnum(
|
||||
UserRole,
|
||||
name="user_role",
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
),
|
||||
default=UserRole.PENDING_TENANT_ADMIN,
|
||||
nullable=False,
|
||||
)
|
||||
status: Mapped[UserStatus] = mapped_column(
|
||||
SAEnum(
|
||||
UserStatus,
|
||||
name="user_status",
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
),
|
||||
default=UserStatus.ACTIVE,
|
||||
nullable=False,
|
||||
)
|
||||
# tenant انتخابشده جاری کاربر (برای پشتیبانی چندtenant در آینده).
|
||||
current_tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
GUID(), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_users_mobile", "mobile", unique=True),
|
||||
Index("ix_users_keycloak_sub", "keycloak_sub", unique=True),
|
||||
Index("ix_users_status", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<User id={self.id} mobile={self.mobile!r} role={self.role}>"
|
||||
1
backend/core-service/app/repositories/__init__.py
Normal file
1
backend/core-service/app/repositories/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""لایه Repository: دسترسی به داده جدا از منطق کسبوکار."""
|
||||
46
backend/core-service/app/repositories/base.py
Normal file
46
backend/core-service/app/repositories/base.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Repository پایه با عملیات CRUD عمومی (async).
|
||||
|
||||
هدف: جلوگیری از تکرار کد و متمرکز کردن الگوهای دسترسی به داده.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, Sequence, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelT]):
|
||||
model: type[ModelT]
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, entity_id: UUID) -> ModelT | None:
|
||||
return await self.session.get(self.model, entity_id)
|
||||
|
||||
async def add(self, entity: ModelT) -> ModelT:
|
||||
self.session.add(entity)
|
||||
await self.session.flush()
|
||||
return entity
|
||||
|
||||
async def delete(self, entity: ModelT) -> None:
|
||||
await self.session.delete(entity)
|
||||
await self.session.flush()
|
||||
|
||||
async def list(
|
||||
self, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[ModelT]:
|
||||
stmt = select(self.model).offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count(self) -> int:
|
||||
stmt = select(func.count()).select_from(self.model)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
48
backend/core-service/app/repositories/membership.py
Normal file
48
backend/core-service/app/repositories/membership.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Repository مربوط به TenantMembership."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.enums import MembershipStatus
|
||||
from app.models.membership import TenantMembership
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class TenantMembershipRepository(BaseRepository[TenantMembership]):
|
||||
model = TenantMembership
|
||||
|
||||
async def get_by_tenant_and_user(
|
||||
self, tenant_id: UUID, user_id: UUID
|
||||
) -> TenantMembership | None:
|
||||
stmt = select(TenantMembership).where(
|
||||
TenantMembership.tenant_id == tenant_id,
|
||||
TenantMembership.user_id == user_id,
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_user(self, user_id: UUID) -> Sequence[TenantMembership]:
|
||||
stmt = select(TenantMembership).where(TenantMembership.user_id == user_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
|
||||
stmt = select(TenantMembership).where(TenantMembership.tenant_id == tenant_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_active_owners(self, tenant_id: UUID) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(TenantMembership)
|
||||
.where(
|
||||
TenantMembership.tenant_id == tenant_id,
|
||||
TenantMembership.is_owner.is_(True),
|
||||
TenantMembership.status == MembershipStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
57
backend/core-service/app/repositories/plan.py
Normal file
57
backend/core-service/app/repositories/plan.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""Repository مربوط به Plan، Feature و PlanFeature."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.plan import Feature, Plan, PlanFeature
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class PlanRepository(BaseRepository[Plan]):
|
||||
model = Plan
|
||||
|
||||
async def get_by_code(self, code: str) -> Plan | None:
|
||||
stmt = select(Plan).where(Plan.code == code)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def code_exists(self, code: str) -> bool:
|
||||
stmt = select(func.count()).select_from(Plan).where(Plan.code == code)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one()) > 0
|
||||
|
||||
|
||||
class FeatureRepository(BaseRepository[Feature]):
|
||||
model = Feature
|
||||
|
||||
async def get_by_key(self, feature_key: str) -> Feature | None:
|
||||
stmt = select(Feature).where(Feature.feature_key == feature_key)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def key_exists(self, feature_key: str) -> bool:
|
||||
stmt = select(func.count()).select_from(Feature).where(
|
||||
Feature.feature_key == feature_key
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one()) > 0
|
||||
|
||||
|
||||
class PlanFeatureRepository(BaseRepository[PlanFeature]):
|
||||
model = PlanFeature
|
||||
|
||||
async def get(self, plan_id: UUID, feature_id: UUID) -> PlanFeature | None:
|
||||
stmt = select(PlanFeature).where(
|
||||
PlanFeature.plan_id == plan_id,
|
||||
PlanFeature.feature_id == feature_id,
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_plan(self, plan_id: UUID) -> Sequence[PlanFeature]:
|
||||
stmt = select(PlanFeature).where(PlanFeature.plan_id == plan_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
36
backend/core-service/app/repositories/registry.py
Normal file
36
backend/core-service/app/repositories/registry.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""Repository مربوط به ServiceRegistry و رویدادها."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.events import OutboxEvent
|
||||
from app.models.registry import ServiceRegistry
|
||||
from app.repositories.base import BaseRepository
|
||||
from shared.events import EventStatus
|
||||
|
||||
|
||||
class ServiceRegistryRepository(BaseRepository[ServiceRegistry]):
|
||||
model = ServiceRegistry
|
||||
|
||||
async def get_by_key(self, service_key: str) -> ServiceRegistry | None:
|
||||
stmt = select(ServiceRegistry).where(
|
||||
ServiceRegistry.service_key == service_key
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class OutboxRepository(BaseRepository[OutboxEvent]):
|
||||
model = OutboxEvent
|
||||
|
||||
async def list_pending(self, limit: int = 100) -> Sequence[OutboxEvent]:
|
||||
stmt = (
|
||||
select(OutboxEvent)
|
||||
.where(OutboxEvent.status == EventStatus.PENDING)
|
||||
.order_by(OutboxEvent.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
39
backend/core-service/app/repositories/subscription.py
Normal file
39
backend/core-service/app/repositories/subscription.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Repository مربوط به اشتراک و دسترسی قابلیت tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.subscription import TenantFeatureAccess, TenantSubscription
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class SubscriptionRepository(BaseRepository[TenantSubscription]):
|
||||
model = TenantSubscription
|
||||
|
||||
async def get_active_by_tenant(self, tenant_id: UUID) -> TenantSubscription | None:
|
||||
"""آخرین اشتراک tenant را برمیگرداند (جدیدترین بر اساس created_at)."""
|
||||
stmt = (
|
||||
select(TenantSubscription)
|
||||
.where(TenantSubscription.tenant_id == tenant_id)
|
||||
.order_by(TenantSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class TenantFeatureAccessRepository(BaseRepository[TenantFeatureAccess]):
|
||||
model = TenantFeatureAccess
|
||||
|
||||
async def get(
|
||||
self, tenant_id: UUID, feature_id: UUID
|
||||
) -> TenantFeatureAccess | None:
|
||||
stmt = select(TenantFeatureAccess).where(
|
||||
TenantFeatureAccess.tenant_id == tenant_id,
|
||||
TenantFeatureAccess.feature_id == feature_id,
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
65
backend/core-service/app/repositories/tenant.py
Normal file
65
backend/core-service/app/repositories/tenant.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""Repository مربوط به Tenant و Domain."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.domain import Domain
|
||||
from app.models.tenant import Tenant
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class TenantRepository(BaseRepository[Tenant]):
|
||||
model = Tenant
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Tenant | None:
|
||||
stmt = select(Tenant).where(Tenant.slug == slug)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def slug_exists(self, slug: str) -> bool:
|
||||
stmt = select(func.count()).select_from(Tenant).where(Tenant.slug == slug)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one()) > 0
|
||||
|
||||
async def list_by_owner(
|
||||
self, owner_user_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[Tenant]:
|
||||
stmt = (
|
||||
select(Tenant)
|
||||
.where(Tenant.owner_user_id == owner_user_id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_by_owner(self, owner_user_id: UUID) -> int:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(Tenant)
|
||||
.where(Tenant.owner_user_id == owner_user_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class DomainRepository(BaseRepository[Domain]):
|
||||
model = Domain
|
||||
|
||||
async def get_by_domain(self, domain: str) -> Domain | None:
|
||||
stmt = select(Domain).where(Domain.domain == domain)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
|
||||
stmt = select(Domain).where(Domain.tenant_id == tenant_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def domain_exists(self, domain: str) -> bool:
|
||||
stmt = select(func.count()).select_from(Domain).where(Domain.domain == domain)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one()) > 0
|
||||
21
backend/core-service/app/repositories/user.py
Normal file
21
backend/core-service/app/repositories/user.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Repository مربوط به User."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class UserRepository(BaseRepository[User]):
|
||||
model = User
|
||||
|
||||
async def get_by_mobile(self, mobile: str) -> User | None:
|
||||
stmt = select(User).where(User.mobile == mobile)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_keycloak_sub(self, keycloak_sub: str) -> User | None:
|
||||
stmt = select(User).where(User.keycloak_sub == keycloak_sub)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
1
backend/core-service/app/schemas/__init__.py
Normal file
1
backend/core-service/app/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""اسکیماهای Pydantic v2 برای ورودی/خروجی API."""
|
||||
57
backend/core-service/app/schemas/auth.py
Normal file
57
backend/core-service/app/schemas/auth.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""اسکیماهای احراز هویت OTP."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.utils.phone import normalize_mobile, normalize_otp_digits
|
||||
|
||||
|
||||
class OtpRequestPayload(BaseModel):
|
||||
mobile: str = Field(..., min_length=10, max_length=15)
|
||||
force_sms: bool = False
|
||||
context: Literal["public", "admin"] = "public"
|
||||
|
||||
@field_validator("mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value: str) -> str:
|
||||
return normalize_mobile(value)
|
||||
|
||||
|
||||
class OtpRequestResponse(BaseModel):
|
||||
message: str
|
||||
expires_in: int
|
||||
skipped: bool = False
|
||||
|
||||
|
||||
class OtpVerifyPayload(BaseModel):
|
||||
mobile: str = Field(..., min_length=10, max_length=15)
|
||||
code: str = Field(..., min_length=4, max_length=8)
|
||||
context: Literal["public", "admin"] = "public"
|
||||
keycloak_sub: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
@field_validator("mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value: str) -> str:
|
||||
return normalize_mobile(value)
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, value: str) -> str:
|
||||
normalized = normalize_otp_digits(value)
|
||||
if not normalized:
|
||||
raise ValueError("کد تأیید نامعتبر است")
|
||||
return normalized
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
is_new_user: bool = False
|
||||
user_id: UUID
|
||||
role: str
|
||||
18
backend/core-service/app/schemas/common.py
Normal file
18
backend/core-service/app/schemas/common.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""اسکیماهای پایه و مشترک."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
"""پایه اسکیماهای خروجی که از مدل ORM ساخته میشوند."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
service: str
|
||||
version: str
|
||||
environment: str
|
||||
dependencies: dict[str, str] = {}
|
||||
46
backend/core-service/app/schemas/domain.py
Normal file
46
backend/core-service/app/schemas/domain.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""اسکیماهای Domain."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.models.enums import DomainType
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class DomainCreate(BaseModel):
|
||||
domain: str = Field(..., min_length=1, max_length=255)
|
||||
domain_type: DomainType = DomainType.SUBDOMAIN
|
||||
|
||||
@field_validator("domain")
|
||||
@classmethod
|
||||
def normalize_domain(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class DomainRead(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
domain: str
|
||||
domain_type: DomainType
|
||||
is_verified: bool
|
||||
verified_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DomainResolveRequest(BaseModel):
|
||||
domain: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
@field_validator("domain")
|
||||
@classmethod
|
||||
def normalize_domain(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class DomainResolveResponse(BaseModel):
|
||||
tenant_id: UUID
|
||||
tenant_slug: str
|
||||
domain: str
|
||||
is_verified: bool
|
||||
19
backend/core-service/app/schemas/membership.py
Normal file
19
backend/core-service/app/schemas/membership.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""اسکیماهای TenantMembership."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.enums import MembershipStatus, TenantMembershipRole
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class MembershipRead(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
user_id: UUID
|
||||
role: TenantMembershipRole
|
||||
status: MembershipStatus
|
||||
is_owner: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
88
backend/core-service/app/schemas/onboarding.py
Normal file
88
backend/core-service/app/schemas/onboarding.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""اسکیماهای Onboarding و Tenant Context."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.models.enums import MembershipStatus, TenantMembershipRole, TenantStatus
|
||||
from app.schemas.domain import DomainRead
|
||||
from app.schemas.tenant import SLUG_PATTERN, TenantRead
|
||||
|
||||
|
||||
class OnboardingTenantCreate(BaseModel):
|
||||
"""گام ۱ onboarding: ایجاد tenant توسط کاربر جاری."""
|
||||
|
||||
business_name: str = Field(..., min_length=1, max_length=255)
|
||||
slug: str = Field(..., min_length=1, max_length=100)
|
||||
business_type: str | None = Field(default=None, max_length=100)
|
||||
default_locale: str = Field(default="fa-IR", max_length=20)
|
||||
timezone: str = Field(default="Asia/Tehran", max_length=50)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
if not SLUG_PATTERN.match(value):
|
||||
raise ValueError(
|
||||
"slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class OnboardingBrandingUpdate(BaseModel):
|
||||
"""گام ۲ onboarding: برندینگ."""
|
||||
|
||||
primary_color: str | None = Field(default=None, max_length=20)
|
||||
secondary_color: str | None = Field(default=None, max_length=20)
|
||||
logo_url: str | None = Field(default=None, max_length=500)
|
||||
favicon_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class OnboardingDomainUpdate(BaseModel):
|
||||
"""گام ۳ onboarding: دامنه اختصاصی (زیردامنه بهصورت خودکار از slug ساخته میشود)."""
|
||||
|
||||
custom_domain: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("custom_domain")
|
||||
@classmethod
|
||||
def normalize_domain(cls, value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class MembershipSummary(BaseModel):
|
||||
tenant_id: UUID
|
||||
tenant_name: str
|
||||
tenant_slug: str
|
||||
tenant_status: TenantStatus
|
||||
onboarding_completed: bool
|
||||
role: TenantMembershipRole
|
||||
status: MembershipStatus
|
||||
is_owner: bool
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
user_id: UUID
|
||||
mobile: str
|
||||
email: str | None
|
||||
platform_role: str
|
||||
onboarding_required: bool
|
||||
current_tenant_id: UUID | None
|
||||
memberships: list[MembershipSummary]
|
||||
|
||||
|
||||
class TenantContextRead(BaseModel):
|
||||
tenant: TenantRead
|
||||
role: TenantMembershipRole | None = None
|
||||
is_owner: bool = False
|
||||
plan_code: str | None = None
|
||||
plan_name: str | None = None
|
||||
subscription_status: str | None = None
|
||||
domains: list[DomainRead] = Field(default_factory=list)
|
||||
primary_domain: str | None = None
|
||||
|
||||
|
||||
class TenantSwitchRequest(BaseModel):
|
||||
tenant_id: UUID
|
||||
62
backend/core-service/app/schemas/plan.py
Normal file
62
backend/core-service/app/schemas/plan.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""اسکیماهای Plan، Feature و PlanFeature."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.enums import LimitPeriod
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
# ---- Plan ----
|
||||
class PlanCreate(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PlanRead(ORMModel):
|
||||
id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ---- Feature ----
|
||||
class FeatureCreate(BaseModel):
|
||||
feature_key: str = Field(..., min_length=1, max_length=150)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
service_key: str = Field(..., min_length=1, max_length=100)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FeatureRead(ORMModel):
|
||||
id: UUID
|
||||
feature_key: str
|
||||
name: str
|
||||
description: str | None
|
||||
service_key: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
# ---- PlanFeature ----
|
||||
class PlanFeatureCreate(BaseModel):
|
||||
feature_id: UUID
|
||||
limit_value: int | None = Field(default=None, ge=0)
|
||||
limit_period: LimitPeriod | None = None
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class PlanFeatureRead(ORMModel):
|
||||
id: UUID
|
||||
plan_id: UUID
|
||||
feature_id: UUID
|
||||
limit_value: int | None
|
||||
limit_period: LimitPeriod | None
|
||||
is_enabled: bool
|
||||
32
backend/core-service/app/schemas/service_registry.py
Normal file
32
backend/core-service/app/schemas/service_registry.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""اسکیماهای ServiceRegistry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.enums import ServiceStatus
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class ServiceCreate(BaseModel):
|
||||
service_key: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
base_url: str | None = Field(default=None, max_length=500)
|
||||
health_check_url: str | None = Field(default=None, max_length=500)
|
||||
status: ServiceStatus = ServiceStatus.ACTIVE
|
||||
|
||||
|
||||
class ServiceStatusUpdate(BaseModel):
|
||||
status: ServiceStatus
|
||||
|
||||
|
||||
class ServiceRead(ORMModel):
|
||||
id: UUID
|
||||
service_key: str
|
||||
name: str
|
||||
base_url: str | None
|
||||
status: ServiceStatus
|
||||
health_check_url: str | None
|
||||
created_at: datetime
|
||||
40
backend/core-service/app/schemas/subscription.py
Normal file
40
backend/core-service/app/schemas/subscription.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""اسکیماهای اشتراک و بررسی دسترسی قابلیت."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.enums import SubscriptionStatus
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
plan_id: UUID
|
||||
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
trial_ends_at: datetime | None = None
|
||||
|
||||
|
||||
class SubscriptionRead(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
plan_id: UUID
|
||||
status: SubscriptionStatus
|
||||
starts_at: datetime | None
|
||||
ends_at: datetime | None
|
||||
trial_ends_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeatureCheckRequest(BaseModel):
|
||||
feature_key: str
|
||||
|
||||
|
||||
class FeatureCheckResponse(BaseModel):
|
||||
tenant_id: UUID
|
||||
feature_key: str
|
||||
has_access: bool
|
||||
reason: str | None = None
|
||||
79
backend/core-service/app/schemas/tenant.py
Normal file
79
backend/core-service/app/schemas/tenant.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""اسکیماهای Tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.models.enums import TenantStatus
|
||||
from app.schemas.common import ORMModel
|
||||
|
||||
SLUG_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
slug: str = Field(..., min_length=1, max_length=100)
|
||||
owner_user_id: UUID | None = None
|
||||
custom_domain: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
if not SLUG_PATTERN.match(value):
|
||||
raise ValueError(
|
||||
"slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
|
||||
)
|
||||
return value
|
||||
|
||||
@field_validator("custom_domain")
|
||||
@classmethod
|
||||
def normalize_domain(cls, value: str | None) -> str | None:
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class AdminTenantCreate(BaseModel):
|
||||
"""ایجاد tenant توسط کاربر احراز هویتشده (مالک = کاربر جاری)."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
slug: str = Field(..., min_length=1, max_length=100)
|
||||
custom_domain: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, value: str) -> str:
|
||||
return TenantCreate.validate_slug(value)
|
||||
|
||||
@field_validator("custom_domain")
|
||||
@classmethod
|
||||
def normalize_domain(cls, value: str | None) -> str | None:
|
||||
return TenantCreate.normalize_domain(value)
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
status: TenantStatus | None = None
|
||||
owner_user_id: UUID | None = None
|
||||
|
||||
|
||||
class TenantRead(ORMModel):
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
status: TenantStatus
|
||||
owner_user_id: UUID | None
|
||||
business_type: str | None = None
|
||||
default_locale: str = "fa-IR"
|
||||
timezone: str = "Asia/Tehran"
|
||||
primary_color: str | None = None
|
||||
secondary_color: str | None = None
|
||||
logo_url: str | None = None
|
||||
favicon_url: str | None = None
|
||||
onboarding_completed: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
1
backend/core-service/app/services/__init__.py
Normal file
1
backend/core-service/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""لایه Service: منطق کسبوکار Core Platform."""
|
||||
107
backend/core-service/app/services/auth_service.py
Normal file
107
backend/core-service/app/services/auth_service.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""سرویس احراز هویت: ثبتنام خودکار و صدور توکن."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.enums import UserRole, UserStatus
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.schemas.auth import OtpRequestResponse, OtpVerifyPayload, TokenResponse
|
||||
from app.services.otp_service import OtpService
|
||||
from app.services.payamak_client import SmsDeliveryError
|
||||
from app.services.token_service import TokenService
|
||||
from shared.exceptions import AppError, UnauthorizedError
|
||||
|
||||
|
||||
class SmsSendError(AppError):
|
||||
status_code = 502
|
||||
error_code = "sms_delivery_failed"
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.users = UserRepository(session)
|
||||
self.otp = OtpService()
|
||||
self.tokens = TokenService()
|
||||
|
||||
async def request_otp(self, mobile: str, *, force_sms: bool = False, context: str = "public") -> OtpRequestResponse:
|
||||
try:
|
||||
expires_in, skipped = await self.otp.request_otp(mobile, force=force_sms)
|
||||
except SmsDeliveryError as exc:
|
||||
raise SmsSendError(str(exc)) from exc
|
||||
message = "کد قبلاً ارسال شده" if skipped else "کد تأیید ارسال شد"
|
||||
return OtpRequestResponse(
|
||||
message=message,
|
||||
expires_in=expires_in,
|
||||
skipped=skipped,
|
||||
)
|
||||
|
||||
async def verify_otp(self, payload: OtpVerifyPayload) -> TokenResponse:
|
||||
if not await self.otp.verify_otp(payload.mobile, payload.code):
|
||||
raise UnauthorizedError("کد تأیید نامعتبر یا منقضی شده است")
|
||||
|
||||
user = await self.users.get_by_mobile(payload.mobile)
|
||||
is_new_user = user is None
|
||||
is_platform_admin = payload.mobile in settings.platform_admin_mobile_set
|
||||
|
||||
if is_new_user:
|
||||
if is_platform_admin:
|
||||
role = UserRole.PLATFORM_ADMIN
|
||||
elif payload.context == "admin":
|
||||
role = UserRole.PENDING_TENANT_ADMIN
|
||||
else:
|
||||
role = UserRole.USER
|
||||
user = User(
|
||||
mobile=payload.mobile,
|
||||
role=role,
|
||||
status=UserStatus.ACTIVE,
|
||||
mobile_verified=True,
|
||||
keycloak_sub=payload.keycloak_sub,
|
||||
email=payload.email,
|
||||
)
|
||||
await self.users.add(user)
|
||||
await self._log_registration(user)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
else:
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
raise UnauthorizedError("حساب کاربری غیرفعال است")
|
||||
if is_platform_admin and user.role != UserRole.PLATFORM_ADMIN:
|
||||
user.role = UserRole.PLATFORM_ADMIN
|
||||
user.mobile_verified = True
|
||||
if payload.keycloak_sub and not user.keycloak_sub:
|
||||
user.keycloak_sub = payload.keycloak_sub
|
||||
if payload.email and not user.email:
|
||||
user.email = payload.email
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
|
||||
access_token, expires_in = self.tokens.create_access_token(
|
||||
user_id=user.id,
|
||||
mobile=user.mobile,
|
||||
role=user.role,
|
||||
)
|
||||
refresh_token = self.tokens.create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=expires_in,
|
||||
is_new_user=is_new_user,
|
||||
user_id=user.id,
|
||||
role=user.role.value,
|
||||
)
|
||||
|
||||
async def _log_registration(self, user: User) -> None:
|
||||
log = AuditLog(
|
||||
tenant_id=None,
|
||||
actor_user_id=user.id,
|
||||
action="user.registered",
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
meta_data={"mobile": user.mobile, "role": user.role.value},
|
||||
)
|
||||
self.session.add(log)
|
||||
58
backend/core-service/app/services/domain_service.py
Normal file
58
backend/core-service/app/services/domain_service.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""سرویس مدیریت و resolve دامنهها."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.domain import Domain
|
||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||
from app.schemas.domain import DomainCreate
|
||||
from app.services.event_service import EventService
|
||||
from shared.events import CoreEventType
|
||||
from shared.exceptions import ConflictError, NotFoundError
|
||||
|
||||
|
||||
class DomainService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = DomainRepository(session)
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.events = EventService(session)
|
||||
|
||||
async def create(self, tenant_id: UUID, data: DomainCreate) -> Domain:
|
||||
tenant = await self.tenant_repo.get(tenant_id)
|
||||
if tenant is None:
|
||||
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
|
||||
if await self.repo.domain_exists(data.domain):
|
||||
raise ConflictError(
|
||||
f"دامنه تکراری است: {data.domain}", error_code="domain_taken"
|
||||
)
|
||||
|
||||
domain = Domain(
|
||||
tenant_id=tenant_id,
|
||||
domain=data.domain,
|
||||
domain_type=data.domain_type,
|
||||
)
|
||||
await self.repo.add(domain)
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.DOMAIN_CREATED.value,
|
||||
aggregate_type="domain",
|
||||
aggregate_id=str(domain.id),
|
||||
tenant_id=tenant_id,
|
||||
payload={"domain": domain.domain, "type": domain.domain_type.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(domain)
|
||||
return domain
|
||||
|
||||
async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
|
||||
return await self.repo.list_by_tenant(tenant_id)
|
||||
|
||||
async def resolve(self, domain: str) -> Domain:
|
||||
"""یافتن دامنه و tenant متناظر آن."""
|
||||
found = await self.repo.get_by_domain(domain.strip().lower())
|
||||
if found is None:
|
||||
raise NotFoundError(f"دامنه یافت نشد: {domain}")
|
||||
return found
|
||||
117
backend/core-service/app/services/entitlement_service.py
Normal file
117
backend/core-service/app/services/entitlement_service.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""سرویس Entitlement: بررسی دسترسی tenant به یک قابلیت.
|
||||
|
||||
قوانین بررسی (به ترتیب):
|
||||
1. ابتدا Redis cache بررسی میشود.
|
||||
2. اگر در cache نبود، از دیتابیس محاسبه میشود.
|
||||
3. نتیجه با TTL مشخص در Redis ذخیره میشود.
|
||||
4. اگر اشتراک tenant فعال نبود → دسترسی false.
|
||||
5. اگر قابلیت در پلن فعال نبود → false.
|
||||
6. اگر دسترسی سفارشی (custom access) وجود داشت، آن اولویت دارد.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cache import cache, feature_access_key
|
||||
from app.core.config import settings
|
||||
from app.models.enums import SubscriptionStatus, TenantStatus
|
||||
from app.repositories.plan import FeatureRepository, PlanFeatureRepository
|
||||
from app.repositories.subscription import (
|
||||
SubscriptionRepository,
|
||||
TenantFeatureAccessRepository,
|
||||
)
|
||||
from app.repositories.tenant import TenantRepository
|
||||
|
||||
_ALLOWED_SUBSCRIPTION_STATUSES = {
|
||||
SubscriptionStatus.ACTIVE,
|
||||
SubscriptionStatus.TRIALING,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessResult:
|
||||
has_access: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class EntitlementService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.feature_repo = FeatureRepository(session)
|
||||
self.plan_feature_repo = PlanFeatureRepository(session)
|
||||
self.subscription_repo = SubscriptionRepository(session)
|
||||
self.feature_access_repo = TenantFeatureAccessRepository(session)
|
||||
|
||||
async def check_feature_access(
|
||||
self, tenant_id: UUID, feature_key: str
|
||||
) -> bool:
|
||||
"""بررسی ساده دسترسی؛ فقط مقدار boolean برمیگرداند."""
|
||||
result = await self.evaluate(tenant_id, feature_key)
|
||||
return result.has_access
|
||||
|
||||
async def evaluate(self, tenant_id: UUID, feature_key: str) -> AccessResult:
|
||||
"""بررسی کامل دسترسی همراه با دلیل، با استفاده از cache."""
|
||||
cache_key = feature_access_key(str(tenant_id), feature_key)
|
||||
|
||||
cached = await cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return AccessResult(has_access=cached == "1", reason="cache")
|
||||
|
||||
result = await self._evaluate_from_db(tenant_id, feature_key)
|
||||
|
||||
await cache.set(
|
||||
cache_key,
|
||||
"1" if result.has_access else "0",
|
||||
ttl=settings.feature_access_cache_ttl,
|
||||
)
|
||||
return result
|
||||
|
||||
async def _evaluate_from_db(
|
||||
self, tenant_id: UUID, feature_key: str
|
||||
) -> AccessResult:
|
||||
# ۱) tenant باید وجود داشته و فعال باشد.
|
||||
tenant = await self.tenant_repo.get(tenant_id)
|
||||
if tenant is None:
|
||||
return AccessResult(False, "tenant_not_found")
|
||||
if tenant.status != TenantStatus.ACTIVE:
|
||||
return AccessResult(False, "tenant_inactive")
|
||||
|
||||
# ۲) قابلیت باید وجود داشته و فعال باشد.
|
||||
feature = await self.feature_repo.get_by_key(feature_key)
|
||||
if feature is None:
|
||||
return AccessResult(False, "feature_not_found")
|
||||
if not feature.is_active:
|
||||
return AccessResult(False, "feature_inactive")
|
||||
|
||||
# ۳) بررسی override سفارشی tenant (بالاترین اولویت).
|
||||
custom = await self.feature_access_repo.get(tenant_id, feature.id)
|
||||
if custom is not None:
|
||||
if not custom.is_enabled:
|
||||
return AccessResult(False, "custom_disabled")
|
||||
# اگر سقف سفارشی تعریف شده و مصرف از آن گذشته باشد → عدم دسترسی.
|
||||
if (
|
||||
custom.custom_limit_value is not None
|
||||
and custom.used_value >= custom.custom_limit_value
|
||||
):
|
||||
return AccessResult(False, "custom_limit_exceeded")
|
||||
return AccessResult(True, "custom_enabled")
|
||||
|
||||
# ۴) اشتراک tenant باید فعال باشد.
|
||||
subscription = await self.subscription_repo.get_active_by_tenant(tenant_id)
|
||||
if subscription is None:
|
||||
return AccessResult(False, "no_subscription")
|
||||
if subscription.status not in _ALLOWED_SUBSCRIPTION_STATUSES:
|
||||
return AccessResult(False, "subscription_inactive")
|
||||
|
||||
# ۵) قابلیت باید در پلن اشتراک فعال باشد.
|
||||
plan_feature = await self.plan_feature_repo.get(
|
||||
subscription.plan_id, feature.id
|
||||
)
|
||||
if plan_feature is None or not plan_feature.is_enabled:
|
||||
return AccessResult(False, "feature_not_in_plan")
|
||||
|
||||
return AccessResult(True, "plan_enabled")
|
||||
38
backend/core-service/app/services/event_service.py
Normal file
38
backend/core-service/app/services/event_service.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""سرویس ثبت رویداد در Outbox.
|
||||
|
||||
طبق الگوی Outbox، رویدادها ابتدا در همان تراکنش دیتابیس ذخیره میشوند و
|
||||
سپس توسط worker (Celery) منتشر میگردند. این کار atomicity را تضمین میکند.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.events import OutboxEvent
|
||||
|
||||
|
||||
class EventService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def record(
|
||||
self,
|
||||
*,
|
||||
event_type: str,
|
||||
aggregate_type: str,
|
||||
aggregate_id: str | None = None,
|
||||
tenant_id: UUID | None = None,
|
||||
payload: dict | None = None,
|
||||
) -> OutboxEvent:
|
||||
"""ثبت یک رویداد جدید در Outbox (بدون commit؛ در تراکنش جاری)."""
|
||||
event = OutboxEvent(
|
||||
event_type=event_type,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=aggregate_id,
|
||||
tenant_id=tenant_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
self.session.add(event)
|
||||
await self.session.flush()
|
||||
return event
|
||||
83
backend/core-service/app/services/membership_service.py
Normal file
83
backend/core-service/app/services/membership_service.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""سرویس مدیریت عضویت کاربر در tenant (TenantMembership)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import MembershipStatus, TenantMembershipRole
|
||||
from app.models.membership import TenantMembership
|
||||
from app.repositories.membership import TenantMembershipRepository
|
||||
from shared.auth import PlatformRole, has_role
|
||||
from shared.exceptions import ForbiddenError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
# نقشهایی که اجازه مدیریت تنظیمات tenant (برندینگ/دامنه/تکمیل onboarding) را دارند.
|
||||
TENANT_MANAGER_ROLES = {TenantMembershipRole.TENANT_OWNER, TenantMembershipRole.TENANT_ADMIN}
|
||||
|
||||
|
||||
class MembershipService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = TenantMembershipRepository(session)
|
||||
|
||||
async def create_owner_membership(
|
||||
self, tenant_id: UUID, user_id: UUID
|
||||
) -> TenantMembership:
|
||||
existing = await self.repo.get_by_tenant_and_user(tenant_id, user_id)
|
||||
if existing is not None:
|
||||
existing.role = TenantMembershipRole.TENANT_OWNER
|
||||
existing.is_owner = True
|
||||
existing.status = MembershipStatus.ACTIVE
|
||||
await self.session.commit()
|
||||
await self.session.refresh(existing)
|
||||
return existing
|
||||
|
||||
membership = TenantMembership(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
role=TenantMembershipRole.TENANT_OWNER,
|
||||
is_owner=True,
|
||||
status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
await self.repo.add(membership)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(membership)
|
||||
return membership
|
||||
|
||||
async def get(self, tenant_id: UUID, user_id: UUID) -> TenantMembership | None:
|
||||
return await self.repo.get_by_tenant_and_user(tenant_id, user_id)
|
||||
|
||||
async def list_for_user(self, user_id: UUID) -> Sequence[TenantMembership]:
|
||||
return await self.repo.list_by_user(user_id)
|
||||
|
||||
async def list_for_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
|
||||
return await self.repo.list_by_tenant(tenant_id)
|
||||
|
||||
async def ensure_role(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
core_user_id: UUID,
|
||||
current_user: CurrentUser,
|
||||
allowed_roles: set[TenantMembershipRole] = frozenset(TENANT_MANAGER_ROLES),
|
||||
) -> TenantMembership | None:
|
||||
"""اطمینان از اینکه کاربر جاری نقش کافی روی این tenant دارد.
|
||||
|
||||
platform_admin همیشه دسترسی کامل دارد (bypass).
|
||||
"""
|
||||
if has_role(current_user, PlatformRole.PLATFORM_ADMIN):
|
||||
return None
|
||||
|
||||
membership = await self.get(tenant_id, core_user_id)
|
||||
if (
|
||||
membership is None
|
||||
or membership.status != MembershipStatus.ACTIVE
|
||||
or membership.role not in allowed_roles
|
||||
):
|
||||
raise ForbiddenError("دسترسی کافی برای مدیریت این tenant ندارید")
|
||||
return membership
|
||||
|
||||
async def count_active_owners(self, tenant_id: UUID) -> int:
|
||||
return await self.repo.count_active_owners(tenant_id)
|
||||
175
backend/core-service/app/services/onboarding_service.py
Normal file
175
backend/core-service/app/services/onboarding_service.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""سرویس Onboarding: ایجاد و فعالسازی tenant توسط کاربر جاری.
|
||||
|
||||
جریان کامل:
|
||||
۱. POST /onboarding/tenant → ایجاد tenant (status=pending_activation) + عضویت owner + پلن پیشفرض + زیردامنه خودکار
|
||||
۲. PATCH /onboarding/tenant/{id}/branding → تنظیم رنگ/لوگو
|
||||
۳. PATCH /onboarding/tenant/{id}/domain → افزودن دامنه اختصاصی (اختیاری)
|
||||
۴. POST /onboarding/tenant/{id}/complete → اعتبارسنجی و فعالسازی نهایی (status=active)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.domain import Domain
|
||||
from app.models.enums import DomainType, DomainVerificationStatus, SubscriptionStatus, TenantStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||
from app.schemas.onboarding import (
|
||||
OnboardingBrandingUpdate,
|
||||
OnboardingDomainUpdate,
|
||||
OnboardingTenantCreate,
|
||||
)
|
||||
from app.schemas.subscription import SubscriptionCreate
|
||||
from app.schemas.tenant import TenantCreate
|
||||
from app.services.event_service import EventService
|
||||
from app.services.membership_service import MembershipService
|
||||
from app.services.plan_service import PlanService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.tenant_service import TenantService
|
||||
from shared.events import CoreEventType
|
||||
from shared.exceptions import ConflictError, ValidationAppError
|
||||
|
||||
|
||||
class OnboardingService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.tenant_service = TenantService(session)
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.domain_repo = DomainRepository(session)
|
||||
self.membership_service = MembershipService(session)
|
||||
self.plan_service = PlanService(session)
|
||||
self.subscription_service = SubscriptionService(session)
|
||||
self.events = EventService(session)
|
||||
|
||||
async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant:
|
||||
tenant = await self.tenant_service.create(
|
||||
TenantCreate(
|
||||
name=data.business_name,
|
||||
slug=data.slug,
|
||||
owner_user_id=core_user.id,
|
||||
)
|
||||
)
|
||||
|
||||
tenant.status = TenantStatus.PENDING_ACTIVATION
|
||||
tenant.business_type = data.business_type
|
||||
tenant.default_locale = data.default_locale
|
||||
tenant.timezone = data.timezone
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
|
||||
await self.membership_service.create_owner_membership(tenant.id, core_user.id)
|
||||
|
||||
plan = await self.plan_service.ensure_default_plan()
|
||||
await self.subscription_service.create(
|
||||
tenant.id,
|
||||
SubscriptionCreate(plan_id=plan.id, status=SubscriptionStatus.ACTIVE),
|
||||
)
|
||||
|
||||
await self._create_default_subdomain(tenant)
|
||||
|
||||
if core_user.current_tenant_id is None:
|
||||
core_user.current_tenant_id = tenant.id
|
||||
await self.session.commit()
|
||||
|
||||
await self.session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
async def _create_default_subdomain(self, tenant: Tenant) -> None:
|
||||
base_domain = (settings.platform_base_domain or "").strip().lower()
|
||||
if not base_domain:
|
||||
return
|
||||
host = f"{tenant.slug}.{base_domain}"
|
||||
if await self.domain_repo.domain_exists(host):
|
||||
return
|
||||
domain = Domain(
|
||||
tenant_id=tenant.id,
|
||||
domain=host,
|
||||
domain_type=DomainType.SUBDOMAIN,
|
||||
is_primary=True,
|
||||
is_verified=True,
|
||||
verification_status=DomainVerificationStatus.VERIFIED,
|
||||
)
|
||||
await self.domain_repo.add(domain)
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.DOMAIN_CREATED.value,
|
||||
aggregate_type="domain",
|
||||
aggregate_id=str(domain.id),
|
||||
tenant_id=tenant.id,
|
||||
payload={"domain": host, "type": "subdomain", "auto_assigned": True},
|
||||
)
|
||||
await self.session.commit()
|
||||
|
||||
async def update_branding(
|
||||
self, tenant_id: UUID, data: OnboardingBrandingUpdate
|
||||
) -> Tenant:
|
||||
tenant = await self.tenant_service.get(tenant_id)
|
||||
if data.primary_color is not None:
|
||||
tenant.primary_color = data.primary_color
|
||||
if data.secondary_color is not None:
|
||||
tenant.secondary_color = data.secondary_color
|
||||
if data.logo_url is not None:
|
||||
tenant.logo_url = data.logo_url
|
||||
if data.favicon_url is not None:
|
||||
tenant.favicon_url = data.favicon_url
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
async def update_domain(self, tenant_id: UUID, data: OnboardingDomainUpdate) -> Tenant:
|
||||
tenant = await self.tenant_service.get(tenant_id)
|
||||
if data.custom_domain:
|
||||
if await self.domain_repo.domain_exists(data.custom_domain):
|
||||
raise ConflictError(
|
||||
f"دامنه تکراری است: {data.custom_domain}", error_code="domain_taken"
|
||||
)
|
||||
domain = Domain(
|
||||
tenant_id=tenant.id,
|
||||
domain=data.custom_domain,
|
||||
domain_type=DomainType.CUSTOM_DOMAIN,
|
||||
is_primary=False,
|
||||
is_verified=False,
|
||||
verification_status=DomainVerificationStatus.PENDING,
|
||||
)
|
||||
await self.domain_repo.add(domain)
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.DOMAIN_CREATED.value,
|
||||
aggregate_type="domain",
|
||||
aggregate_id=str(domain.id),
|
||||
tenant_id=tenant.id,
|
||||
payload={"domain": data.custom_domain, "type": "custom"},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
async def complete(self, tenant_id: UUID) -> Tenant:
|
||||
tenant = await self.tenant_service.get(tenant_id)
|
||||
|
||||
if not tenant.name or not tenant.slug:
|
||||
raise ValidationAppError(
|
||||
"اطلاعات کسبوکار (نام/slug) تکمیل نشده است", error_code="onboarding_incomplete"
|
||||
)
|
||||
|
||||
owners = await self.membership_service.count_active_owners(tenant_id)
|
||||
if owners < 1:
|
||||
raise ValidationAppError(
|
||||
"tenant باید حداقل یک مالک فعال داشته باشد", error_code="owner_required"
|
||||
)
|
||||
|
||||
tenant.onboarding_completed = True
|
||||
tenant.status = TenantStatus.ACTIVE
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.TENANT_ACTIVATED.value,
|
||||
aggregate_type="tenant",
|
||||
aggregate_id=str(tenant.id),
|
||||
tenant_id=tenant.id,
|
||||
payload={"onboarding_completed": True},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
await self.tenant_service._invalidate_cache(tenant.id)
|
||||
return tenant
|
||||
77
backend/core-service/app/services/otp_service.py
Normal file
77
backend/core-service/app/services/otp_service.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""سرویس OTP — همان زنجیره torbatkar-back/User/utils.send_phone_otp_shared."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import secrets
|
||||
|
||||
from app.core.cache import cache
|
||||
from app.core.config import settings
|
||||
from app.services.payamak_client import PayamakClient, SmsDeliveryError, _normalize_phone_for_sms
|
||||
from app.utils.phone import normalize_mobile, normalize_otp_digits
|
||||
|
||||
_memory_otp: dict[str, str] = {}
|
||||
|
||||
|
||||
def _otp_key(mobile: str) -> str:
|
||||
return f"otp:{mobile}"
|
||||
|
||||
|
||||
class OtpService:
|
||||
def __init__(self) -> None:
|
||||
self.sms = PayamakClient()
|
||||
|
||||
def _generate_code(self) -> str:
|
||||
if settings.environment == "test":
|
||||
return "1234"
|
||||
return str(random.randint(1111, 9999))
|
||||
|
||||
async def _get_stored_code(self, mobile: str) -> str | None:
|
||||
key = _otp_key(mobile)
|
||||
stored = await cache.get(key)
|
||||
if stored is None and cache.client is None:
|
||||
stored = _memory_otp.get(mobile)
|
||||
return stored
|
||||
|
||||
async def request_otp(self, mobile: str, *, force: bool = False) -> tuple[int, bool]:
|
||||
"""
|
||||
Returns:
|
||||
(expires_in, skipped) — skipped=True یعنی کد قبلی هنوز معتبر است (torbatkar).
|
||||
"""
|
||||
normalized = normalize_mobile(mobile)
|
||||
phone_for_sms = _normalize_phone_for_sms(normalized)
|
||||
if not phone_for_sms:
|
||||
raise SmsDeliveryError("شماره موبایل نامعتبر است")
|
||||
|
||||
key = _otp_key(normalized)
|
||||
if not force:
|
||||
stored = await self._get_stored_code(normalized)
|
||||
if stored:
|
||||
remaining = await cache.ttl(key)
|
||||
expires_in = remaining if remaining else settings.otp_expire_seconds
|
||||
return expires_in, True
|
||||
|
||||
code = self._generate_code()
|
||||
ttl = settings.otp_expire_seconds
|
||||
|
||||
await cache.set(key, code, ttl=ttl)
|
||||
if cache.client is None:
|
||||
_memory_otp[normalized] = code
|
||||
|
||||
await self.sms.send_otp(phone_for_sms, code)
|
||||
return ttl, False
|
||||
|
||||
async def verify_otp(self, mobile: str, code: str) -> bool:
|
||||
normalized_code = normalize_otp_digits(code)
|
||||
if not normalized_code:
|
||||
return False
|
||||
|
||||
normalized = normalize_mobile(mobile)
|
||||
key = _otp_key(normalized)
|
||||
stored = await self._get_stored_code(normalized)
|
||||
|
||||
if stored is None or not secrets.compare_digest(stored, normalized_code):
|
||||
return False
|
||||
|
||||
await cache.delete(key)
|
||||
_memory_otp.pop(normalized, None)
|
||||
return True
|
||||
245
backend/core-service/app/services/payamak_client.py
Normal file
245
backend/core-service/app/services/payamak_client.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""کلاینت Payamak — پورت مستقیم torbatkar-back/SMSPanel/utils.py (send_pattern_sms)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# همان ثابتهای torbatkar-back
|
||||
PAYAMAK_TIMEOUT = (18, 45)
|
||||
PAYAMAK_PATTERN_URL = "https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber"
|
||||
PAYAMAK_REST_PATTERN_URL = "https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber"
|
||||
|
||||
|
||||
class SmsDeliveryError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def payamak_is_configured() -> bool:
|
||||
return bool(settings.payamak_username and settings.payamak_auth_secret)
|
||||
|
||||
|
||||
def _normalize_phone_for_sms(phone: str | None) -> str | None:
|
||||
"""نرمالسازی شماره به فرمت 09xxxxxxxxx — همان torbatkar."""
|
||||
if phone is None:
|
||||
return None
|
||||
digits = re.sub(r"\D", "", str(phone))
|
||||
if digits.startswith("0098"):
|
||||
digits = digits[4:]
|
||||
elif digits.startswith("98") and len(digits) >= 12:
|
||||
digits = digits[2:]
|
||||
if len(digits) == 10:
|
||||
digits = "0" + digits
|
||||
if len(digits) == 11 and digits.startswith("0"):
|
||||
return digits
|
||||
return None
|
||||
|
||||
|
||||
def _parse_payamak_response_body(raw_text: str) -> dict[str, Any]:
|
||||
if not raw_text or not str(raw_text).strip():
|
||||
return {}
|
||||
raw_text = str(raw_text).strip()
|
||||
try:
|
||||
data = json.loads(raw_text)
|
||||
if isinstance(data, dict) and isinstance(data.get("d"), str):
|
||||
try:
|
||||
inner = json.loads(data["d"])
|
||||
if isinstance(inner, dict):
|
||||
return inner
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return data if isinstance(data, dict) else {"value": data}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
m = re.search(r"<string[^>]*>([\s\S]*?)</string>", raw_text, re.I)
|
||||
if m:
|
||||
inner = m.group(1).strip()
|
||||
try:
|
||||
parsed = json.loads(inner)
|
||||
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
m = re.search(r"<SendByBaseNumberResult[^>]*>([^<]+)</SendByBaseNumberResult>", raw_text, re.I)
|
||||
if m:
|
||||
value = m.group(1).strip()
|
||||
try:
|
||||
n = int(value)
|
||||
return {"RetStatus": n} if n > 0 else {"RetStatus": n, "raw": value}
|
||||
except ValueError:
|
||||
return {"raw": value}
|
||||
m = re.search(r'"RetStatus"\s*:\s*(-?\d+)', raw_text)
|
||||
if m:
|
||||
return {"RetStatus": int(m.group(1)), "_parse_fallback": True}
|
||||
return {"raw": raw_text[:800]}
|
||||
|
||||
|
||||
def _payamak_pattern_logical_success(response_data: dict[str, Any]) -> tuple[bool, str | None]:
|
||||
if not isinstance(response_data, dict):
|
||||
return False, "پاسخ نامعتبر از پنل"
|
||||
ss = (response_data.get("StrRetStatus") or response_data.get("strRetStatus") or "").strip()
|
||||
if ss.lower() == "ok":
|
||||
return True, None
|
||||
rs = response_data.get("RetStatus")
|
||||
try:
|
||||
n = int(float(rs))
|
||||
if n > 0:
|
||||
return True, None
|
||||
return False, ss or f"خطای پنل پیامک (کد: {n})"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
raw = response_data.get("raw")
|
||||
if raw:
|
||||
return False, str(raw)[:300]
|
||||
return False, "نتوانستیم نتیجه ارسال را تشخیص دهیم"
|
||||
|
||||
|
||||
def send_pattern_sms_sync(
|
||||
to: str,
|
||||
text: list[str] | str,
|
||||
body_id: int,
|
||||
*,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ارسال پترن — کپی منطق torbatkar-back/SMSPanel/utils.send_pattern_sms."""
|
||||
username = username or settings.payamak_username
|
||||
password = password or settings.payamak_auth_secret
|
||||
to = _normalize_phone_for_sms(to) or str(to)
|
||||
|
||||
if not all([username, password, to, text, body_id]):
|
||||
return {"success": False, "error": "پارامترهای لازم ارسال نشده است"}
|
||||
|
||||
if not isinstance(text, list):
|
||||
text = [text]
|
||||
|
||||
try:
|
||||
bid = int(body_id)
|
||||
except (TypeError, ValueError):
|
||||
return {"success": False, "error": "شناسه الگو (bodyId) نامعتبر است"}
|
||||
|
||||
form_items = [
|
||||
("username", str(username)),
|
||||
("password", str(password)),
|
||||
("to", str(to)),
|
||||
("bodyId", str(bid)),
|
||||
]
|
||||
for part in text:
|
||||
form_items.append(("text", str(part)))
|
||||
|
||||
json_payload = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"to": str(to),
|
||||
"text": text,
|
||||
"bodyId": bid,
|
||||
}
|
||||
|
||||
text_param = (
|
||||
json.dumps(text, ensure_ascii=False)
|
||||
if isinstance(text, list) and len(text) > 1
|
||||
else str(text[0] if isinstance(text, list) else text)
|
||||
)
|
||||
|
||||
rest_form = {
|
||||
"username": str(username),
|
||||
"password": str(password),
|
||||
"to": str(to),
|
||||
"bodyId": str(bid),
|
||||
"text": text_param,
|
||||
}
|
||||
|
||||
attempts = [
|
||||
("rest_https", PAYAMAK_REST_PATTERN_URL, {"data": rest_form}),
|
||||
("asmx_https", PAYAMAK_PATTERN_URL, {"data": form_items}),
|
||||
]
|
||||
|
||||
response = None
|
||||
response_data: dict[str, Any] = {}
|
||||
success = False
|
||||
err_logical: str | None = None
|
||||
last_transport_error: str | None = None
|
||||
|
||||
for _label, url, req_kw in attempts:
|
||||
try:
|
||||
req_kw = {**req_kw, "timeout": PAYAMAK_TIMEOUT}
|
||||
response = requests.post(url, **req_kw)
|
||||
response_data = _parse_payamak_response_body(response.text)
|
||||
ok_http = response.status_code == 200
|
||||
ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
|
||||
success = ok_http and ok_logical
|
||||
if success:
|
||||
break
|
||||
if not ok_logical and err_logical:
|
||||
last_transport_error = err_logical
|
||||
except requests.RequestException as exc:
|
||||
last_transport_error = str(exc)
|
||||
continue
|
||||
|
||||
if not success:
|
||||
try:
|
||||
response = requests.post(PAYAMAK_PATTERN_URL, json=json_payload, timeout=PAYAMAK_TIMEOUT)
|
||||
response_data = _parse_payamak_response_body(response.text)
|
||||
ok_http = response.status_code == 200
|
||||
ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
|
||||
success = ok_http and ok_logical
|
||||
except requests.RequestException as exc:
|
||||
last_transport_error = str(exc)
|
||||
|
||||
err_msg = None if success else (err_logical or last_transport_error or "خطا در ارسال پیامک")
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": str(response_data.get("RetStatus", "")),
|
||||
"api_response": response_data,
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": err_msg,
|
||||
"api_response": response_data,
|
||||
}
|
||||
|
||||
|
||||
class PayamakClient:
|
||||
async def send_otp(self, mobile: str, code: str) -> None:
|
||||
if not payamak_is_configured():
|
||||
if settings.environment in ("development", "test"):
|
||||
logger.info("payamak_otp_dev_mode", extra={"mobile": mobile, "code": code})
|
||||
return
|
||||
raise SmsDeliveryError("تنظیمات API پیامک در سرور پیکربندی نشده است")
|
||||
|
||||
phone_for_sms = _normalize_phone_for_sms(mobile)
|
||||
if not phone_for_sms:
|
||||
raise SmsDeliveryError("شماره موبایل نامعتبر است")
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
send_pattern_sms_sync,
|
||||
phone_for_sms,
|
||||
[str(code)],
|
||||
settings.payamak_verification_body_id,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
err = result.get("error") or "ارسال پیامک ناموفق بود"
|
||||
if "name resolution" in str(err).lower() or "resolve" in str(err).lower():
|
||||
err = "سرویس پیامک موقتاً در دسترس نیست. لطفاً چند دقیقه بعد دوباره تلاش کنید."
|
||||
logger.error(
|
||||
"payamak_otp_failed",
|
||||
extra={"mobile": phone_for_sms, "error": err, "api": result.get("api_response")},
|
||||
)
|
||||
raise SmsDeliveryError(err)
|
||||
|
||||
logger.info(
|
||||
"payamak_otp_sent",
|
||||
extra={"mobile": phone_for_sms, "message_id": result.get("message_id")},
|
||||
)
|
||||
127
backend/core-service/app/services/plan_service.py
Normal file
127
backend/core-service/app/services/plan_service.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""سرویس مدیریت پلنها، قابلیتها و اتصال آنها."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.plan import Feature, Plan, PlanFeature
|
||||
from app.repositories.plan import (
|
||||
FeatureRepository,
|
||||
PlanFeatureRepository,
|
||||
PlanRepository,
|
||||
)
|
||||
from app.schemas.plan import FeatureCreate, PlanCreate, PlanFeatureCreate
|
||||
from shared.exceptions import ConflictError, NotFoundError
|
||||
|
||||
|
||||
class PlanService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.plan_repo = PlanRepository(session)
|
||||
self.feature_repo = FeatureRepository(session)
|
||||
self.plan_feature_repo = PlanFeatureRepository(session)
|
||||
|
||||
# ---- Plans ----
|
||||
async def create_plan(self, data: PlanCreate) -> Plan:
|
||||
if await self.plan_repo.code_exists(data.code):
|
||||
raise ConflictError(f"کد پلن تکراری است: {data.code}", error_code="plan_code_taken")
|
||||
plan = Plan(
|
||||
code=data.code,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
await self.plan_repo.add(plan)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(plan)
|
||||
return plan
|
||||
|
||||
async def list_plans(self, *, offset: int, limit: int) -> tuple[Sequence[Plan], int]:
|
||||
items = await self.plan_repo.list(offset=offset, limit=limit)
|
||||
total = await self.plan_repo.count()
|
||||
return items, total
|
||||
|
||||
async def get_plan(self, plan_id: UUID) -> Plan:
|
||||
plan = await self.plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
raise NotFoundError(f"پلن یافت نشد: {plan_id}")
|
||||
return plan
|
||||
|
||||
async def ensure_default_plan(
|
||||
self, *, code: str = "FREE", name: str = "رایگان"
|
||||
) -> Plan:
|
||||
"""پلن پیشفرض را برمیگرداند؛ در صورت نبود، آن را میسازد (idempotent).
|
||||
|
||||
برای اطمینان از وجود پلن پیشفرض هنگام onboarding استفاده میشود؛ در
|
||||
کنار seed مربوط به migration (که در محیط production زودتر اجرا میشود).
|
||||
"""
|
||||
plan = await self.plan_repo.get_by_code(code)
|
||||
if plan is not None:
|
||||
return plan
|
||||
plan = Plan(
|
||||
code=code,
|
||||
name=name,
|
||||
description="پلن پیشفرض تخصیصیافته در زمان onboarding",
|
||||
is_active=True,
|
||||
)
|
||||
await self.plan_repo.add(plan)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(plan)
|
||||
return plan
|
||||
|
||||
# ---- Features ----
|
||||
async def create_feature(self, data: FeatureCreate) -> Feature:
|
||||
if await self.feature_repo.key_exists(data.feature_key):
|
||||
raise ConflictError(
|
||||
f"feature_key تکراری است: {data.feature_key}",
|
||||
error_code="feature_key_taken",
|
||||
)
|
||||
feature = Feature(
|
||||
feature_key=data.feature_key,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
service_key=data.service_key,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
await self.feature_repo.add(feature)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(feature)
|
||||
return feature
|
||||
|
||||
async def list_features(self, *, offset: int, limit: int) -> tuple[Sequence[Feature], int]:
|
||||
items = await self.feature_repo.list(offset=offset, limit=limit)
|
||||
total = await self.feature_repo.count()
|
||||
return items, total
|
||||
|
||||
# ---- Plan <-> Feature ----
|
||||
async def attach_feature(self, plan_id: UUID, data: PlanFeatureCreate) -> PlanFeature:
|
||||
plan = await self.plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
raise NotFoundError(f"پلن یافت نشد: {plan_id}")
|
||||
feature = await self.feature_repo.get(data.feature_id)
|
||||
if feature is None:
|
||||
raise NotFoundError(f"قابلیت یافت نشد: {data.feature_id}")
|
||||
|
||||
existing = await self.plan_feature_repo.get(plan_id, data.feature_id)
|
||||
if existing is not None:
|
||||
# بهروزرسانی تنظیمات موجود بهجای ایجاد رکورد تکراری.
|
||||
existing.limit_value = data.limit_value
|
||||
existing.limit_period = data.limit_period
|
||||
existing.is_enabled = data.is_enabled
|
||||
await self.session.commit()
|
||||
await self.session.refresh(existing)
|
||||
return existing
|
||||
|
||||
plan_feature = PlanFeature(
|
||||
plan_id=plan_id,
|
||||
feature_id=data.feature_id,
|
||||
limit_value=data.limit_value,
|
||||
limit_period=data.limit_period,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
await self.plan_feature_repo.add(plan_feature)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(plan_feature)
|
||||
return plan_feature
|
||||
@ -0,0 +1,51 @@
|
||||
"""سرویس مدیریت ServiceRegistry (ثبت سرویسهای داخلی آینده)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import ServiceStatus
|
||||
from app.models.registry import ServiceRegistry
|
||||
from app.repositories.registry import ServiceRegistryRepository
|
||||
from app.schemas.service_registry import ServiceCreate
|
||||
from shared.exceptions import ConflictError, NotFoundError
|
||||
|
||||
|
||||
class ServiceRegistryService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = ServiceRegistryRepository(session)
|
||||
|
||||
async def create(self, data: ServiceCreate) -> ServiceRegistry:
|
||||
if await self.repo.get_by_key(data.service_key) is not None:
|
||||
raise ConflictError(
|
||||
f"service_key تکراری است: {data.service_key}",
|
||||
error_code="service_key_taken",
|
||||
)
|
||||
service = ServiceRegistry(
|
||||
service_key=data.service_key,
|
||||
name=data.name,
|
||||
base_url=data.base_url,
|
||||
health_check_url=data.health_check_url,
|
||||
status=data.status,
|
||||
)
|
||||
await self.repo.add(service)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(service)
|
||||
return service
|
||||
|
||||
async def list(self, *, offset: int, limit: int) -> tuple[Sequence[ServiceRegistry], int]:
|
||||
items = await self.repo.list(offset=offset, limit=limit)
|
||||
total = await self.repo.count()
|
||||
return items, total
|
||||
|
||||
async def update_status(self, service_id: UUID, status: ServiceStatus) -> ServiceRegistry:
|
||||
service = await self.repo.get(service_id)
|
||||
if service is None:
|
||||
raise NotFoundError(f"سرویس یافت نشد: {service_id}")
|
||||
service.status = status
|
||||
await self.session.commit()
|
||||
await self.session.refresh(service)
|
||||
return service
|
||||
62
backend/core-service/app/services/subscription_service.py
Normal file
62
backend/core-service/app/services/subscription_service.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""سرویس مدیریت اشتراک tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cache import cache
|
||||
from app.models.subscription import TenantSubscription
|
||||
from app.repositories.plan import PlanRepository
|
||||
from app.repositories.subscription import SubscriptionRepository
|
||||
from app.repositories.tenant import TenantRepository
|
||||
from app.schemas.subscription import SubscriptionCreate
|
||||
from app.services.event_service import EventService
|
||||
from shared.events import CoreEventType
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = SubscriptionRepository(session)
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.plan_repo = PlanRepository(session)
|
||||
self.events = EventService(session)
|
||||
|
||||
async def create(self, tenant_id: UUID, data: SubscriptionCreate) -> TenantSubscription:
|
||||
tenant = await self.tenant_repo.get(tenant_id)
|
||||
if tenant is None:
|
||||
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
|
||||
plan = await self.plan_repo.get(data.plan_id)
|
||||
if plan is None:
|
||||
raise NotFoundError(f"پلن یافت نشد: {data.plan_id}")
|
||||
|
||||
subscription = TenantSubscription(
|
||||
tenant_id=tenant_id,
|
||||
plan_id=data.plan_id,
|
||||
status=data.status,
|
||||
starts_at=data.starts_at,
|
||||
ends_at=data.ends_at,
|
||||
trial_ends_at=data.trial_ends_at,
|
||||
)
|
||||
await self.repo.add(subscription)
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.SUBSCRIPTION_CREATED.value,
|
||||
aggregate_type="subscription",
|
||||
aggregate_id=str(subscription.id),
|
||||
tenant_id=tenant_id,
|
||||
payload={"plan_id": str(data.plan_id), "status": data.status.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(subscription)
|
||||
# با تغییر اشتراک، cache دسترسی قابلیتها باید باطل شود.
|
||||
await cache.delete_pattern(f"feature_access:{tenant_id}:*")
|
||||
await cache.delete(f"tenant:{tenant_id}:features")
|
||||
return subscription
|
||||
|
||||
async def get_current(self, tenant_id: UUID) -> TenantSubscription:
|
||||
subscription = await self.repo.get_active_by_tenant(tenant_id)
|
||||
if subscription is None:
|
||||
raise NotFoundError(f"اشتراکی برای tenant یافت نشد: {tenant_id}")
|
||||
return subscription
|
||||
89
backend/core-service/app/services/tenant_context_service.py
Normal file
89
backend/core-service/app/services/tenant_context_service.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""ساخت TenantContext کامل (tenant + پلن + دامنهها + نقش کاربر) برای frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.repositories.membership import TenantMembershipRepository
|
||||
from app.repositories.plan import PlanRepository
|
||||
from app.repositories.subscription import SubscriptionRepository
|
||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||
from app.schemas.domain import DomainRead
|
||||
from app.schemas.onboarding import MembershipSummary, TenantContextRead
|
||||
from app.schemas.tenant import TenantRead
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
class TenantContextService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.tenant_repo = TenantRepository(session)
|
||||
self.domain_repo = DomainRepository(session)
|
||||
self.membership_repo = TenantMembershipRepository(session)
|
||||
self.subscription_repo = SubscriptionRepository(session)
|
||||
self.plan_repo = PlanRepository(session)
|
||||
|
||||
async def build(self, tenant: Tenant, core_user: User | None) -> TenantContextRead:
|
||||
membership = None
|
||||
if core_user is not None:
|
||||
membership = await self.membership_repo.get_by_tenant_and_user(
|
||||
tenant.id, core_user.id
|
||||
)
|
||||
|
||||
domains = await self.domain_repo.list_by_tenant(tenant.id)
|
||||
primary = next((d for d in domains if d.is_primary), None)
|
||||
if primary is None and domains:
|
||||
primary = domains[0]
|
||||
|
||||
subscription = await self.subscription_repo.get_active_by_tenant(tenant.id)
|
||||
plan = await self.plan_repo.get(subscription.plan_id) if subscription else None
|
||||
|
||||
return TenantContextRead(
|
||||
tenant=TenantRead.model_validate(tenant),
|
||||
role=membership.role if membership else None,
|
||||
is_owner=bool(membership.is_owner) if membership else False,
|
||||
plan_code=plan.code if plan else None,
|
||||
plan_name=plan.name if plan else None,
|
||||
subscription_status=subscription.status.value if subscription else None,
|
||||
domains=[DomainRead.model_validate(d) for d in domains],
|
||||
primary_domain=primary.domain if primary else None,
|
||||
)
|
||||
|
||||
async def resolve_current_tenant(self, core_user: User) -> Tenant:
|
||||
"""tenant جاری کاربر را برمیگرداند (بر اساس current_tenant_id یا اولین عضویت)."""
|
||||
if core_user.current_tenant_id is not None:
|
||||
tenant = await self.tenant_repo.get(core_user.current_tenant_id)
|
||||
if tenant is not None:
|
||||
return tenant
|
||||
|
||||
memberships = await self.membership_repo.list_by_user(core_user.id)
|
||||
if memberships:
|
||||
tenant = await self.tenant_repo.get(memberships[0].tenant_id)
|
||||
if tenant is not None:
|
||||
return tenant
|
||||
|
||||
raise NotFoundError("هیچ tenantی برای کاربر جاری یافت نشد")
|
||||
|
||||
async def list_memberships_summary(self, core_user: User) -> list[MembershipSummary]:
|
||||
memberships = await self.membership_repo.list_by_user(core_user.id)
|
||||
result: list[MembershipSummary] = []
|
||||
for m in memberships:
|
||||
tenant = await self.tenant_repo.get(m.tenant_id)
|
||||
if tenant is None:
|
||||
continue
|
||||
result.append(
|
||||
MembershipSummary(
|
||||
tenant_id=tenant.id,
|
||||
tenant_name=tenant.name,
|
||||
tenant_slug=tenant.slug,
|
||||
tenant_status=tenant.status,
|
||||
onboarding_completed=tenant.onboarding_completed,
|
||||
role=m.role,
|
||||
status=m.status,
|
||||
is_owner=m.is_owner,
|
||||
)
|
||||
)
|
||||
return result
|
||||
156
backend/core-service/app/services/tenant_service.py
Normal file
156
backend/core-service/app/services/tenant_service.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""سرویس مدیریت Tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.cache import cache, tenant_metadata_key
|
||||
from app.models.enums import DomainType, TenantStatus
|
||||
from app.models.tenant import Tenant
|
||||
from app.repositories.tenant import DomainRepository, TenantRepository
|
||||
from app.schemas.tenant import AdminTenantCreate, TenantCreate, TenantUpdate
|
||||
from app.services.event_service import EventService
|
||||
from shared.auth import PlatformRole, has_role
|
||||
from shared.events import CoreEventType
|
||||
from shared.exceptions import ConflictError, ForbiddenError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class TenantService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = TenantRepository(session)
|
||||
self.domain_repo = DomainRepository(session)
|
||||
self.events = EventService(session)
|
||||
|
||||
async def create(self, data: TenantCreate) -> Tenant:
|
||||
# slug باید در کل پلتفرم یکتا باشد.
|
||||
if await self.repo.slug_exists(data.slug):
|
||||
raise ConflictError(f"slug تکراری است: {data.slug}", error_code="slug_taken")
|
||||
|
||||
tenant = Tenant(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
owner_user_id=data.owner_user_id,
|
||||
status=TenantStatus.ACTIVE,
|
||||
)
|
||||
await self.repo.add(tenant)
|
||||
if data.custom_domain:
|
||||
await self._add_custom_domain(tenant.id, data.custom_domain)
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.TENANT_CREATED.value,
|
||||
aggregate_type="tenant",
|
||||
aggregate_id=str(tenant.id),
|
||||
tenant_id=tenant.id,
|
||||
payload={"slug": tenant.slug, "name": tenant.name},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
async def create_for_user(self, user: CurrentUser, data: AdminTenantCreate) -> Tenant:
|
||||
owner_id = UUID(user.user_id)
|
||||
return await self.create(
|
||||
TenantCreate(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
owner_user_id=owner_id,
|
||||
custom_domain=data.custom_domain,
|
||||
)
|
||||
)
|
||||
|
||||
async def _add_custom_domain(self, tenant_id: UUID, domain: str) -> None:
|
||||
if await self.domain_repo.domain_exists(domain):
|
||||
raise ConflictError(
|
||||
f"دامنه تکراری است: {domain}", error_code="domain_taken"
|
||||
)
|
||||
from app.models.domain import Domain
|
||||
|
||||
await self.domain_repo.add(
|
||||
Domain(
|
||||
tenant_id=tenant_id,
|
||||
domain=domain,
|
||||
domain_type=DomainType.CUSTOM_DOMAIN,
|
||||
)
|
||||
)
|
||||
|
||||
async def get(self, tenant_id: UUID) -> Tenant:
|
||||
tenant = await self.repo.get(tenant_id)
|
||||
if tenant is None:
|
||||
raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
|
||||
return tenant
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Tenant | None:
|
||||
return await self.repo.get_by_slug(slug)
|
||||
|
||||
async def list(self, *, offset: int, limit: int) -> tuple[Sequence[Tenant], int]:
|
||||
items = await self.repo.list(offset=offset, limit=limit)
|
||||
total = await self.repo.count()
|
||||
return items, total
|
||||
|
||||
async def list_for_user(
|
||||
self, user: CurrentUser, *, offset: int, limit: int
|
||||
) -> tuple[Sequence[Tenant], int]:
|
||||
if has_role(user, PlatformRole.PLATFORM_ADMIN):
|
||||
return await self.list(offset=offset, limit=limit)
|
||||
owner_id = UUID(user.user_id)
|
||||
items = await self.repo.list_by_owner(owner_id, offset=offset, limit=limit)
|
||||
total = await self.repo.count_by_owner(owner_id)
|
||||
return items, total
|
||||
|
||||
def ensure_access(self, tenant: Tenant, user: CurrentUser) -> None:
|
||||
if has_role(user, PlatformRole.PLATFORM_ADMIN):
|
||||
return
|
||||
if str(tenant.owner_user_id) != user.user_id:
|
||||
raise ForbiddenError("دسترسی به این tenant ندارید")
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, data: TenantUpdate, user: CurrentUser | None = None
|
||||
) -> Tenant:
|
||||
tenant = await self.get(tenant_id)
|
||||
if user is not None:
|
||||
self.ensure_access(tenant, user)
|
||||
if data.name is not None:
|
||||
tenant.name = data.name
|
||||
if data.status is not None:
|
||||
tenant.status = data.status
|
||||
if data.owner_user_id is not None:
|
||||
tenant.owner_user_id = data.owner_user_id
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
await self._invalidate_cache(tenant_id)
|
||||
return tenant
|
||||
|
||||
async def suspend(self, tenant_id: UUID) -> Tenant:
|
||||
tenant = await self.get(tenant_id)
|
||||
tenant.status = TenantStatus.SUSPENDED
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.TENANT_SUSPENDED.value,
|
||||
aggregate_type="tenant",
|
||||
aggregate_id=str(tenant.id),
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
await self._invalidate_cache(tenant_id)
|
||||
return tenant
|
||||
|
||||
async def activate(self, tenant_id: UUID) -> Tenant:
|
||||
tenant = await self.get(tenant_id)
|
||||
tenant.status = TenantStatus.ACTIVE
|
||||
await self.events.record(
|
||||
event_type=CoreEventType.TENANT_ACTIVATED.value,
|
||||
aggregate_type="tenant",
|
||||
aggregate_id=str(tenant.id),
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(tenant)
|
||||
await self._invalidate_cache(tenant_id)
|
||||
return tenant
|
||||
|
||||
async def _invalidate_cache(self, tenant_id: UUID) -> None:
|
||||
await cache.delete(tenant_metadata_key(str(tenant_id)))
|
||||
await cache.delete_pattern(f"feature_access:{tenant_id}:*")
|
||||
83
backend/core-service/app/services/token_service.py
Normal file
83
backend/core-service/app/services/token_service.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""تولید و اعتبارسنجی JWT محلی برای کاربران OTP."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.enums import UserRole
|
||||
from shared.exceptions import UnauthorizedError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
OTP_ISSUER = "core-service-otp"
|
||||
|
||||
|
||||
class TokenService:
|
||||
def create_access_token(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
mobile: str,
|
||||
role: UserRole,
|
||||
) -> tuple[str, int]:
|
||||
expires_in = settings.jwt_access_token_expire_seconds
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"preferred_username": mobile,
|
||||
"realm_access": {"roles": [role.value]},
|
||||
"iss": OTP_ISSUER,
|
||||
"aud": settings.jwt_audience,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
|
||||
}
|
||||
token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
return token, expires_in
|
||||
|
||||
def validate_access_token(self, token: str) -> CurrentUser:
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
settings.jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
audience=settings.jwt_audience,
|
||||
issuer=OTP_ISSUER,
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
|
||||
|
||||
realm_access = claims.get("realm_access") or {}
|
||||
roles = list(realm_access.get("roles", [])) if isinstance(realm_access, dict) else []
|
||||
return CurrentUser(
|
||||
user_id=str(claims.get("sub", "")),
|
||||
username=claims.get("preferred_username"),
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
def create_refresh_token(self, user_id: UUID) -> str:
|
||||
expires_in = settings.jwt_refresh_token_expire_seconds
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"type": "refresh",
|
||||
"iss": OTP_ISSUER,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
def validate_refresh_token(self, token: str) -> UUID:
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
settings.jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
issuer=OTP_ISSUER,
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise UnauthorizedError(f"توکن refresh نامعتبر است: {exc}") from exc
|
||||
if claims.get("type") != "refresh":
|
||||
raise UnauthorizedError("توکن refresh نامعتبر است")
|
||||
return UUID(str(claims["sub"]))
|
||||
49
backend/core-service/app/services/user_service.py
Normal file
49
backend/core-service/app/services/user_service.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""سرویس کمکی برای resolve کردن کاربر Core از روی CurrentUser (JWT).
|
||||
|
||||
توکن ممکن است از دو منبع باشد:
|
||||
۱. JWT محلی OTP (HS256) — sub برابر با users.id است.
|
||||
۲. JWT مرکزی Keycloak (RS256) — sub برابر با users.keycloak_sub است.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from shared.exceptions import ForbiddenError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = UserRepository(session)
|
||||
|
||||
async def resolve_current(self, current_user: CurrentUser) -> User:
|
||||
"""کاربر Core متناظر با هویت جاری را پیدا میکند.
|
||||
|
||||
اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
|
||||
تأیید نشده)، خطای قابل مدیریت در frontend برمیگرداند تا کاربر به
|
||||
جریان تکمیل موبایل هدایت شود.
|
||||
"""
|
||||
candidate_id: UUID | None = None
|
||||
try:
|
||||
candidate_id = UUID(str(current_user.user_id))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
candidate_id = None
|
||||
|
||||
if candidate_id is not None:
|
||||
user = await self.repo.get(candidate_id)
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
user = await self.repo.get_by_keycloak_sub(str(current_user.user_id))
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
raise ForbiddenError(
|
||||
"پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
|
||||
error_code="core_profile_not_linked",
|
||||
)
|
||||
0
backend/core-service/app/tests/__init__.py
Normal file
0
backend/core-service/app/tests/__init__.py
Normal file
54
backend/core-service/app/tests/conftest.py
Normal file
54
backend/core-service/app/tests/conftest.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""پیکربندی مشترک تستها (pytest).
|
||||
|
||||
تستها روی SQLite (aiosqlite) اجرا میشوند تا نیازی به PostgreSQL/Redis
|
||||
واقعی نباشد. متغیرهای محیطی پیش از import ماژولهای برنامه تنظیم میشوند تا
|
||||
engine و session با SQLite ساخته شوند.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# --- تنظیم محیط تست پیش از هر import از برنامه ---
|
||||
os.environ["ENVIRONMENT"] = "test"
|
||||
os.environ["AUTH_REQUIRED"] = "false"
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_core.db"
|
||||
os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_core.db"
|
||||
os.environ["KEYCLOAK_ENABLED"] = "false"
|
||||
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
|
||||
os.environ["JWT_SECRET"] = "test-jwt-secret"
|
||||
|
||||
import pytest_asyncio # noqa: E402
|
||||
from httpx import ASGITransport, AsyncClient # noqa: E402
|
||||
|
||||
import app.models # noqa: E402,F401 (ثبت مدلها روی metadata)
|
||||
from app.core.cache import cache # noqa: E402
|
||||
from app.core.database import AsyncSessionLocal, Base, engine # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
# در تستها Redis در دسترس نیست؛ cache را غیرفعال میکنیم (fail-open).
|
||||
cache.disable()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_setup():
|
||||
"""ساخت و پاکسازی اسکیمای دیتابیس برای هر تست (ایزوله)."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_setup):
|
||||
"""کلاینت HTTP async برای فراخوانی API درونفرآیندی."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session(db_setup):
|
||||
"""یک AsyncSession برای تستهای سطح سرویس/repository."""
|
||||
async with AsyncSessionLocal() as s:
|
||||
yield s
|
||||
41
backend/core-service/app/tests/test_domains.py
Normal file
41
backend/core-service/app/tests/test_domains.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""تستهای مدیریت و resolve دامنه."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def _create_tenant(client, slug="domainco"):
|
||||
resp = await client.post("/api/v1/tenants", json={"name": "Domain Co", "slug": slug})
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
async def test_create_domain(client):
|
||||
tenant_id = await _create_tenant(client)
|
||||
resp = await client.post(
|
||||
f"/api/v1/tenants/{tenant_id}/domains",
|
||||
json={"domain": "shop.example.com", "domain_type": "custom_domain"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["domain"] == "shop.example.com"
|
||||
assert data["tenant_id"] == tenant_id
|
||||
|
||||
|
||||
async def test_resolve_domain(client):
|
||||
tenant_id = await _create_tenant(client, slug="resolveco")
|
||||
await client.post(
|
||||
f"/api/v1/tenants/{tenant_id}/domains",
|
||||
json={"domain": "resolve.example.com"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/domains/resolve", json={"domain": "resolve.example.com"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["tenant_id"] == tenant_id
|
||||
assert data["tenant_slug"] == "resolveco"
|
||||
|
||||
|
||||
async def test_resolve_unknown_domain_returns_404(client):
|
||||
resp = await client.post(
|
||||
"/api/v1/domains/resolve", json={"domain": "nope.example.com"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
107
backend/core-service/app/tests/test_features.py
Normal file
107
backend/core-service/app/tests/test_features.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""تستهای قابلیتها و بررسی دسترسی (Entitlement)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def _setup_tenant_with_plan(client, *, slug, feature_key, attach=True, active=True):
|
||||
"""ساخت tenant، پلن، قابلیت، اتصال و اشتراک برای سناریوی تست."""
|
||||
tenant_id = (
|
||||
await client.post("/api/v1/tenants", json={"name": "T", "slug": slug})
|
||||
).json()["id"]
|
||||
|
||||
plan_id = (
|
||||
await client.post(
|
||||
"/api/v1/plans", json={"code": f"plan-{slug}", "name": "Plan"}
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
feature_id = (
|
||||
await client.post(
|
||||
"/api/v1/features",
|
||||
json={
|
||||
"feature_key": feature_key,
|
||||
"name": "Feature",
|
||||
"service_key": feature_key.split(".")[0],
|
||||
},
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
if attach:
|
||||
await client.post(
|
||||
f"/api/v1/plans/{plan_id}/features",
|
||||
json={"feature_id": feature_id, "is_enabled": True},
|
||||
)
|
||||
|
||||
await client.post(
|
||||
f"/api/v1/tenants/{tenant_id}/subscription",
|
||||
json={"plan_id": plan_id, "status": "active" if active else "expired"},
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
async def test_create_feature(client):
|
||||
resp = await client.post(
|
||||
"/api/v1/features",
|
||||
json={
|
||||
"feature_key": "accounting.invoice.create",
|
||||
"name": "ساخت فاکتور",
|
||||
"service_key": "accounting",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["feature_key"] == "accounting.invoice.create"
|
||||
|
||||
|
||||
async def test_feature_key_unique(client):
|
||||
payload = {
|
||||
"feature_key": "crm.lead.create",
|
||||
"name": "Lead",
|
||||
"service_key": "crm",
|
||||
}
|
||||
first = await client.post("/api/v1/features", json=payload)
|
||||
assert first.status_code == 201
|
||||
second = await client.post("/api/v1/features", json=payload)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
async def test_check_feature_access_granted(client):
|
||||
tenant_id = await _setup_tenant_with_plan(
|
||||
client, slug="grant", feature_key="ecommerce.product.create"
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/tenants/{tenant_id}/features/check",
|
||||
json={"feature_key": "ecommerce.product.create"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["has_access"] is True
|
||||
assert body["reason"] == "plan_enabled"
|
||||
|
||||
|
||||
async def test_check_feature_access_denied_when_not_in_plan(client):
|
||||
tenant_id = await _setup_tenant_with_plan(
|
||||
client,
|
||||
slug="denyplan",
|
||||
feature_key="live_chat.widget.enable",
|
||||
attach=False,
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/tenants/{tenant_id}/features/check",
|
||||
json={"feature_key": "live_chat.widget.enable"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_access"] is False
|
||||
|
||||
|
||||
async def test_check_feature_access_denied_when_subscription_inactive(client):
|
||||
tenant_id = await _setup_tenant_with_plan(
|
||||
client,
|
||||
slug="expired",
|
||||
feature_key="ai_assistant.chat.reply",
|
||||
active=False,
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/tenants/{tenant_id}/features/check",
|
||||
json={"feature_key": "ai_assistant.chat.reply"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_access"] is False
|
||||
11
backend/core-service/app/tests/test_health.py
Normal file
11
backend/core-service/app/tests/test_health.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""تست endpoint سلامت."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def test_health_ok(client):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"]
|
||||
assert "redis" in body["dependencies"]
|
||||
55
backend/core-service/app/tests/test_middleware.py
Normal file
55
backend/core-service/app/tests/test_middleware.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""تست Middleware تشخیص tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.middlewares.tenant import TenantResolutionMiddleware
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
def _build_probe_app() -> FastAPI:
|
||||
"""اپ کوچک برای بازتاب tenant تشخیصدادهشده از request.state."""
|
||||
probe = FastAPI()
|
||||
probe.add_middleware(TenantResolutionMiddleware)
|
||||
|
||||
@probe.get("/whoami")
|
||||
async def whoami(request: Request):
|
||||
tid = getattr(request.state, "tenant_id", None)
|
||||
slug = getattr(request.state, "tenant_slug", None)
|
||||
return {"tenant_id": str(tid) if tid else None, "tenant_slug": slug}
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
async def test_resolve_by_tenant_slug_header(session):
|
||||
tenant = Tenant(name="Mid Co", slug="midco")
|
||||
session.add(tenant)
|
||||
await session.commit()
|
||||
|
||||
transport = ASGITransport(app=_build_probe_app())
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "midco"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tenant_slug"] == "midco"
|
||||
|
||||
|
||||
async def test_resolve_by_tenant_id_header(session):
|
||||
tenant = Tenant(name="Mid Co2", slug="midco2")
|
||||
session.add(tenant)
|
||||
await session.commit()
|
||||
tid = str(tenant.id)
|
||||
|
||||
transport = ASGITransport(app=_build_probe_app())
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
resp = await ac.get("/whoami", headers={"X-Tenant-ID": tid})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tenant_id"] == tid
|
||||
|
||||
|
||||
async def test_unresolved_tenant_is_none(session):
|
||||
transport = ASGITransport(app=_build_probe_app())
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "unknown-xyz"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tenant_id"] is None
|
||||
216
backend/core-service/app/tests/test_onboarding.py
Normal file
216
backend/core-service/app/tests/test_onboarding.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""تستهای فاز ۳: onboarding و فعالسازی workspace (tenant)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_auth(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_REQUIRED", "true")
|
||||
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
|
||||
monkeypatch.setenv("PLATFORM_BASE_DOMAIN", "localhost")
|
||||
from app.core.config import get_settings, settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
fresh = get_settings()
|
||||
monkeypatch.setattr(settings, "auth_required", True)
|
||||
monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
|
||||
monkeypatch.setattr(settings, "platform_base_domain", fresh.platform_base_domain)
|
||||
|
||||
|
||||
async def _login(client, mobile: str) -> dict:
|
||||
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/otp/verify", json={"mobile": mobile, "code": "1234"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
return {"Authorization": f"Bearer {data['access_token']}"}, data["user_id"]
|
||||
|
||||
|
||||
async def test_me_requires_onboarding_for_new_user(client):
|
||||
headers, user_id = await _login(client, "09121110001")
|
||||
|
||||
resp = await client.get("/api/v1/me", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user_id"] == user_id
|
||||
assert data["onboarding_required"] is True
|
||||
assert data["current_tenant_id"] is None
|
||||
assert data["memberships"] == []
|
||||
|
||||
|
||||
async def test_full_onboarding_flow(client):
|
||||
headers, user_id = await _login(client, "09121110002")
|
||||
|
||||
create = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={
|
||||
"business_name": "کافه تربت",
|
||||
"slug": "cafe-torbat-2",
|
||||
"business_type": "cafe",
|
||||
"default_locale": "fa-IR",
|
||||
"timezone": "Asia/Tehran",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert create.status_code == 201, create.text
|
||||
body = create.json()
|
||||
tenant_id = body["tenant"]["id"]
|
||||
assert body["tenant"]["status"] == "pending_activation"
|
||||
assert body["tenant"]["onboarding_completed"] is False
|
||||
assert body["role"] == "tenant_owner"
|
||||
assert body["is_owner"] is True
|
||||
assert body["plan_code"] == "FREE"
|
||||
assert body["primary_domain"] == "cafe-torbat-2.localhost"
|
||||
assert body["domains"][0]["domain_type"] == "subdomain"
|
||||
assert body["domains"][0]["is_verified"] is True
|
||||
|
||||
# اگر دوباره تلاش کنیم tenant دیگری بسازیم روی همین slug، باید خطای تکراری بگیریم.
|
||||
dup = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "X", "slug": "cafe-torbat-2"},
|
||||
headers=headers,
|
||||
)
|
||||
assert dup.status_code == 409
|
||||
assert dup.json()["error"]["code"] == "slug_taken"
|
||||
|
||||
branding = await client.patch(
|
||||
f"/api/v1/onboarding/tenant/{tenant_id}/branding",
|
||||
json={"primary_color": "#112233", "secondary_color": "#445566"},
|
||||
headers=headers,
|
||||
)
|
||||
assert branding.status_code == 200
|
||||
assert branding.json()["tenant"]["primary_color"] == "#112233"
|
||||
|
||||
domain = await client.patch(
|
||||
f"/api/v1/onboarding/tenant/{tenant_id}/domain",
|
||||
json={"custom_domain": "cafe2.example.com"},
|
||||
headers=headers,
|
||||
)
|
||||
assert domain.status_code == 200
|
||||
hosts = [d["domain"] for d in domain.json()["domains"]]
|
||||
assert "cafe2.example.com" in hosts
|
||||
|
||||
complete = await client.post(
|
||||
f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
|
||||
)
|
||||
assert complete.status_code == 200
|
||||
completed_body = complete.json()
|
||||
assert completed_body["tenant"]["status"] == "active"
|
||||
assert completed_body["tenant"]["onboarding_completed"] is True
|
||||
|
||||
me = await client.get("/api/v1/me", headers=headers)
|
||||
me_data = me.json()
|
||||
assert me_data["onboarding_required"] is False
|
||||
assert me_data["current_tenant_id"] == tenant_id
|
||||
assert len(me_data["memberships"]) == 1
|
||||
assert me_data["memberships"][0]["role"] == "tenant_owner"
|
||||
|
||||
current = await client.get("/api/v1/tenant/current", headers=headers)
|
||||
assert current.status_code == 200
|
||||
assert current.json()["tenant"]["id"] == tenant_id
|
||||
|
||||
my_tenants = await client.get("/api/v1/me/tenants", headers=headers)
|
||||
assert my_tenants.status_code == 200
|
||||
slugs = [t["tenant_slug"] for t in my_tenants.json()]
|
||||
assert "cafe-torbat-2" in slugs
|
||||
|
||||
|
||||
async def test_onboarding_tenant_requires_auth(client):
|
||||
resp = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "X", "slug": "no-auth-tenant"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_branding_forbidden_for_non_member(client):
|
||||
headers_owner, _ = await _login(client, "09121110003")
|
||||
create = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "Owner Co", "slug": "owner-co"},
|
||||
headers=headers_owner,
|
||||
)
|
||||
tenant_id = create.json()["tenant"]["id"]
|
||||
|
||||
headers_other, _ = await _login(client, "09121110004")
|
||||
resp = await client.patch(
|
||||
f"/api/v1/onboarding/tenant/{tenant_id}/branding",
|
||||
json={"primary_color": "#000000"},
|
||||
headers=headers_other,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_complete_fails_without_required_fields(client):
|
||||
headers, _ = await _login(client, "09121110005")
|
||||
create = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "بدون برند", "slug": "no-brand-co"},
|
||||
headers=headers,
|
||||
)
|
||||
tenant_id = create.json()["tenant"]["id"]
|
||||
|
||||
# بدون تنظیم برندینگ کامل هم میتوان تکمیل کرد (فیلدهای برندینگ اختیاریاند)،
|
||||
# اما تکمیل روی tenant دیگری که عضو آن نیستیم باید 403/404 بدهد.
|
||||
other_headers, _ = await _login(client, "09121110006")
|
||||
resp = await client.post(
|
||||
f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=other_headers
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
ok = await client.post(
|
||||
f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["tenant"]["status"] == "active"
|
||||
|
||||
|
||||
async def test_tenant_switch_between_multiple_tenants(client):
|
||||
headers, user_id = await _login(client, "09121110007")
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "First Co", "slug": "first-co"},
|
||||
headers=headers,
|
||||
)
|
||||
first_id = first.json()["tenant"]["id"]
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "Second Co", "slug": "second-co"},
|
||||
headers=headers,
|
||||
)
|
||||
second_id = second.json()["tenant"]["id"]
|
||||
|
||||
# اولین tenant ساختهشده بهعنوان current انتخاب میشود (چون current_tenant_id قبلاً خالی بود)؛
|
||||
# ساخت tenant دوم آن را تغییر نمیدهد.
|
||||
me = await client.get("/api/v1/me", headers=headers)
|
||||
assert me.json()["current_tenant_id"] == first_id
|
||||
assert len(me.json()["memberships"]) == 2
|
||||
|
||||
switch = await client.post(
|
||||
"/api/v1/tenant/switch", json={"tenant_id": second_id}, headers=headers
|
||||
)
|
||||
assert switch.status_code == 200
|
||||
assert switch.json()["tenant"]["id"] == second_id
|
||||
|
||||
current = await client.get("/api/v1/tenant/current", headers=headers)
|
||||
assert current.json()["tenant"]["id"] == second_id
|
||||
|
||||
|
||||
async def test_tenant_switch_forbidden_for_non_member(client):
|
||||
headers_owner, _ = await _login(client, "09121110008")
|
||||
create = await client.post(
|
||||
"/api/v1/onboarding/tenant",
|
||||
json={"business_name": "Solo Co", "slug": "solo-co"},
|
||||
headers=headers_owner,
|
||||
)
|
||||
tenant_id = create.json()["tenant"]["id"]
|
||||
|
||||
headers_other, _ = await _login(client, "09121110009")
|
||||
resp = await client.post(
|
||||
"/api/v1/tenant/switch", json={"tenant_id": tenant_id}, headers=headers_other
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
128
backend/core-service/app/tests/test_otp_auth.py
Normal file
128
backend/core-service/app/tests/test_otp_auth.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""تستهای OTP و ثبتنام خودکار."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_auth(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_REQUIRED", "true")
|
||||
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
|
||||
from app.core.config import get_settings, settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
fresh = get_settings()
|
||||
monkeypatch.setattr(settings, "auth_required", True)
|
||||
monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
|
||||
|
||||
|
||||
async def test_otp_request_and_verify_new_user(client):
|
||||
mobile = "09123456789"
|
||||
req = await client.post(
|
||||
"/api/v1/auth/otp/request",
|
||||
json={"mobile": mobile, "context": "admin"},
|
||||
)
|
||||
assert req.status_code == 200
|
||||
assert req.json()["expires_in"] == 120
|
||||
|
||||
verify = await client.post(
|
||||
"/api/v1/auth/otp/verify",
|
||||
json={"mobile": mobile, "code": "1234", "context": "admin"},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
data = verify.json()
|
||||
assert data["is_new_user"] is True
|
||||
assert data["role"] == "pending_tenant_admin"
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
|
||||
|
||||
async def test_otp_verify_existing_user(client):
|
||||
mobile = "09111111111"
|
||||
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
first = await client.post(
|
||||
"/api/v1/auth/otp/verify",
|
||||
json={"mobile": mobile, "code": "1234"},
|
||||
)
|
||||
assert first.json()["is_new_user"] is True
|
||||
|
||||
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
second = await client.post(
|
||||
"/api/v1/auth/otp/verify",
|
||||
json={"mobile": mobile, "code": "1234"},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["is_new_user"] is False
|
||||
|
||||
|
||||
async def test_otp_verify_invalid_code(client):
|
||||
mobile = "09222222222"
|
||||
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/otp/verify",
|
||||
json={"mobile": mobile, "code": "000000"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_otp_request_skips_unexpired_code(client):
|
||||
mobile = "09444444444"
|
||||
first = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
assert first.status_code == 200
|
||||
assert first.json()["skipped"] is False
|
||||
|
||||
second = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
assert second.status_code == 200
|
||||
body = second.json()
|
||||
assert body["skipped"] is True
|
||||
assert body["message"] == "کد قبلاً ارسال شده"
|
||||
|
||||
forced = await client.post(
|
||||
"/api/v1/auth/otp/request",
|
||||
json={"mobile": mobile, "force_sms": True},
|
||||
)
|
||||
assert forced.status_code == 200
|
||||
assert forced.json()["skipped"] is False
|
||||
|
||||
|
||||
async def test_platform_admin_mobile_gets_admin_role(client, monkeypatch):
|
||||
from app.core.config import get_settings, settings
|
||||
|
||||
monkeypatch.setenv("PLATFORM_ADMIN_MOBILES", "09155105404")
|
||||
get_settings.cache_clear()
|
||||
fresh = get_settings()
|
||||
monkeypatch.setattr(settings, "platform_admin_mobiles", fresh.platform_admin_mobiles)
|
||||
|
||||
mobile = "09155105404"
|
||||
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
verify = await client.post(
|
||||
"/api/v1/auth/otp/verify",
|
||||
json={"mobile": mobile, "code": "1234"},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
assert verify.json()["role"] == "platform_admin"
|
||||
|
||||
|
||||
async def test_admin_tenant_with_otp_token(client):
|
||||
mobile = "09333333333"
|
||||
await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
|
||||
auth = await client.post(
|
||||
"/api/v1/auth/otp/verify",
|
||||
json={"mobile": mobile, "code": "1234"},
|
||||
)
|
||||
token = auth.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/admin/tenants",
|
||||
json={"name": "My Shop", "slug": "my-shop"},
|
||||
headers=headers,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["slug"] == "my-shop"
|
||||
assert body["owner_user_id"] == auth.json()["user_id"]
|
||||
|
||||
listed = await client.get("/api/v1/admin/tenants", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["meta"]["total_items"] == 1
|
||||
35
backend/core-service/app/tests/test_outbox.py
Normal file
35
backend/core-service/app/tests/test_outbox.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""تست پردازش رویدادهای Outbox."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.events import OutboxEvent
|
||||
from app.services.tenant_service import TenantService
|
||||
from app.schemas.tenant import TenantCreate
|
||||
from app.workers.tasks import process_pending_outbox
|
||||
from shared.events import EventStatus
|
||||
|
||||
|
||||
async def test_outbox_event_recorded_on_tenant_create(session):
|
||||
await TenantService(session).create(
|
||||
TenantCreate(name="Evt Co", slug="evtco")
|
||||
)
|
||||
result = await session.execute(select(OutboxEvent))
|
||||
events = result.scalars().all()
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "tenant.created"
|
||||
assert events[0].status == EventStatus.PENDING
|
||||
|
||||
|
||||
async def test_process_pending_outbox(session):
|
||||
await TenantService(session).create(
|
||||
TenantCreate(name="Proc Co", slug="procco")
|
||||
)
|
||||
summary = await process_pending_outbox(session)
|
||||
assert summary["processed"] == 1
|
||||
assert summary["failed"] == 0
|
||||
|
||||
result = await session.execute(select(OutboxEvent))
|
||||
events = result.scalars().all()
|
||||
assert all(e.status == EventStatus.PROCESSED for e in events)
|
||||
assert all(e.processed_at is not None for e in events)
|
||||
35
backend/core-service/app/tests/test_payamak_client.py
Normal file
35
backend/core-service/app/tests/test_payamak_client.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""تست پارس و نرمالسازی Payamak — هممنطق torbatkar-back/SMSPanel/utils."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.payamak_client import (
|
||||
_normalize_phone_for_sms,
|
||||
_parse_payamak_response_body,
|
||||
_payamak_pattern_logical_success,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_phone_eleven_digits():
|
||||
assert _normalize_phone_for_sms("09123456789") == "09123456789"
|
||||
assert _normalize_phone_for_sms("9123456789") == "09123456789"
|
||||
assert _normalize_phone_for_sms("+989123456789") == "09123456789"
|
||||
assert _normalize_phone_for_sms("00989123456789") == "09123456789"
|
||||
assert _normalize_phone_for_sms("invalid") is None
|
||||
|
||||
|
||||
def test_parse_asmx_success():
|
||||
xml = """<?xml version="1.0"?>
|
||||
<SendByBaseNumberResponse>
|
||||
<SendByBaseNumberResult>98765432101234567</SendByBaseNumberResult>
|
||||
</SendByBaseNumberResponse>"""
|
||||
data = _parse_payamak_response_body(xml)
|
||||
ok, err = _payamak_pattern_logical_success(data)
|
||||
assert ok is True
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_parse_asmx_error():
|
||||
xml = "<SendByBaseNumberResult>-4</SendByBaseNumberResult>"
|
||||
data = _parse_payamak_response_body(xml)
|
||||
ok, err = _payamak_pattern_logical_success(data)
|
||||
assert ok is False
|
||||
assert err is not None
|
||||
57
backend/core-service/app/tests/test_tenants.py
Normal file
57
backend/core-service/app/tests/test_tenants.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""تستهای مدیریت Tenant."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def _create_tenant(client, slug="acme", name="Acme Inc"):
|
||||
return await client.post("/api/v1/tenants", json={"name": name, "slug": slug})
|
||||
|
||||
|
||||
async def test_create_tenant(client):
|
||||
resp = await _create_tenant(client)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["slug"] == "acme"
|
||||
assert data["status"] == "active"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_create_tenant_normalizes_slug(client):
|
||||
resp = await _create_tenant(client, slug="Acme-Co")
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["slug"] == "acme-co"
|
||||
|
||||
|
||||
async def test_slug_must_be_unique(client):
|
||||
first = await _create_tenant(client, slug="dup")
|
||||
assert first.status_code == 201
|
||||
second = await _create_tenant(client, slug="dup")
|
||||
assert second.status_code == 409
|
||||
assert second.json()["error"]["code"] == "slug_taken"
|
||||
|
||||
|
||||
async def test_get_and_list_tenants(client):
|
||||
created = await _create_tenant(client, slug="listme")
|
||||
tenant_id = created.json()["id"]
|
||||
|
||||
got = await client.get(f"/api/v1/tenants/{tenant_id}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["id"] == tenant_id
|
||||
|
||||
listed = await client.get("/api/v1/tenants")
|
||||
assert listed.status_code == 200
|
||||
body = listed.json()
|
||||
assert body["meta"]["total_items"] >= 1
|
||||
assert len(body["items"]) >= 1
|
||||
|
||||
|
||||
async def test_suspend_and_activate(client):
|
||||
created = await _create_tenant(client, slug="susp")
|
||||
tenant_id = created.json()["id"]
|
||||
|
||||
suspended = await client.post(f"/api/v1/tenants/{tenant_id}/suspend")
|
||||
assert suspended.status_code == 200
|
||||
assert suspended.json()["status"] == "suspended"
|
||||
|
||||
activated = await client.post(f"/api/v1/tenants/{tenant_id}/activate")
|
||||
assert activated.status_code == 200
|
||||
assert activated.json()["status"] == "active"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user