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