Merge pull request #23 from 3252a8/feature/migration-remnashop
remnashop migration feature, install wizard
This commit is contained in:
@@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
|
||||
|
||||
## Совместимость
|
||||
|
||||
|
||||
@@ -444,6 +444,38 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые ref-ссылки Remnashop",
|
||||
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые промокоды Remnashop",
|
||||
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||
"string",
|
||||
"migrations",
|
||||
"Последний импорт Remnashop",
|
||||
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_NOTES",
|
||||
"text",
|
||||
"migrations",
|
||||
"Заметки по миграции Remnashop",
|
||||
"Внутренние заметки оператора по перенесенному инстансу.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
@@ -776,6 +808,7 @@ def manifest_payload() -> List[dict]:
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
"migrations": 13,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
|
||||
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -1292,17 +1294,35 @@ async def _link_telegram_to_user(
|
||||
return current_user
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
|
||||
if settings is None:
|
||||
return False
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _strip_referral_param_prefix(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
preserve_current_u_prefix: bool,
|
||||
) -> str:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return ""
|
||||
|
||||
value_lower = value.lower()
|
||||
if value_lower.startswith("ref_u"):
|
||||
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
|
||||
value = value[5:]
|
||||
elif value_lower.startswith("ref_"):
|
||||
value = value[4:]
|
||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value and value[0].lower() == "u" and len(value) == 10:
|
||||
value = value[1:]
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||
@@ -1310,26 +1330,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
return value.upper()
|
||||
|
||||
|
||||
def _referral_param_lookup_candidates(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> List[str]:
|
||||
if not remnashop_compat:
|
||||
normalized = _normalize_referral_param(raw)
|
||||
return [normalized] if normalized else []
|
||||
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
|
||||
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
candidates.append(value[1:])
|
||||
|
||||
unique: List[str] = []
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_id(
|
||||
session: AsyncSession,
|
||||
raw_referral_param: Optional[str],
|
||||
*,
|
||||
current_user_id: Optional[int],
|
||||
settings: Optional[Settings] = None,
|
||||
) -> Optional[int]:
|
||||
normalized = _normalize_referral_param(raw_referral_param)
|
||||
if not normalized:
|
||||
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||
candidates = _referral_param_lookup_candidates(
|
||||
raw_referral_param,
|
||||
remnashop_compat=remnashop_compat,
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
ref_user = None
|
||||
if normalized.isdigit():
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
||||
if not ref_user:
|
||||
return None
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
return None
|
||||
return int(ref_user.user_id)
|
||||
for normalized in candidates:
|
||||
ref_user = None
|
||||
if normalized.isdigit() and not remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
normalized,
|
||||
include_legacy=remnashop_compat,
|
||||
)
|
||||
if not ref_user and normalized.isdigit() and remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
continue
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
continue
|
||||
return int(ref_user.user_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _apply_referral_to_existing_user(
|
||||
@@ -1345,6 +1403,7 @@ async def _apply_referral_to_existing_user(
|
||||
session,
|
||||
raw_referral_param,
|
||||
current_user_id=int(user.user_id),
|
||||
settings=request.app["settings"],
|
||||
)
|
||||
if not referred_by_id:
|
||||
return False
|
||||
@@ -1427,6 +1486,7 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
current_user_id=user_id,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, created = await user_dal.create_user(
|
||||
session,
|
||||
|
||||
@@ -41,6 +41,67 @@ from db.models import User
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _referral_code_lookup_candidates(
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> list[str]:
|
||||
value = str(raw_ref_value or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
stripped_current_prefix = value[1:]
|
||||
if remnashop_compat:
|
||||
candidates.append(stripped_current_prefix)
|
||||
else:
|
||||
candidates = [stripped_current_prefix]
|
||||
|
||||
unique: list[str] = []
|
||||
for candidate in candidates:
|
||||
candidate = candidate.strip()
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_from_start_ref(
|
||||
session: AsyncSession,
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
settings: Settings,
|
||||
current_user_id: int,
|
||||
) -> Optional[int]:
|
||||
ref_user: Optional[User] = None
|
||||
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != current_user_id:
|
||||
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
|
||||
|
||||
include_legacy = _remnashop_referral_compat_enabled(settings)
|
||||
if not ref_user:
|
||||
for code in _referral_code_lookup_candidates(
|
||||
raw_ref_value,
|
||||
remnashop_compat=include_legacy,
|
||||
):
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
code,
|
||||
include_legacy=include_legacy,
|
||||
)
|
||||
if ref_user:
|
||||
break
|
||||
|
||||
if ref_user and ref_user.user_id != current_user_id:
|
||||
return int(ref_user.user_id)
|
||||
return None
|
||||
|
||||
|
||||
async def should_show_trial_button(
|
||||
settings: Settings,
|
||||
subscription_service: SubscriptionService,
|
||||
@@ -412,14 +473,10 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
||||
"ref_match"
|
||||
)
|
||||
)
|
||||
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
|
||||
)
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@@ -536,22 +593,12 @@ async def start_command_handler(
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id
|
||||
):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||
session,
|
||||
raw_ref_value,
|
||||
settings=settings,
|
||||
current_user_id=user_id,
|
||||
)
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
|
||||
@@ -38,8 +38,12 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
||||
preserve_case = bool(
|
||||
getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False)
|
||||
)
|
||||
code_input_clean = (code_input or "").strip()[:100]
|
||||
lookup_code = code_input_clean if preserve_case else code_input_clean.upper()
|
||||
code_display = html_escape(lookup_code[:100], quote=False)
|
||||
throttle_identifier = self._throttle_identifier(user_id)
|
||||
|
||||
throttle = await security_dal.check_throttle(
|
||||
@@ -54,7 +58,7 @@ class PromoCodeService:
|
||||
)
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper
|
||||
session, lookup_code, preserve_case=preserve_case
|
||||
)
|
||||
|
||||
if not promo_data:
|
||||
@@ -74,6 +78,8 @@ class PromoCodeService:
|
||||
)
|
||||
return False, _("promo_code_not_found", code=code_display)
|
||||
|
||||
applied_code = str(promo_data.code or lookup_code)
|
||||
code_display = html_escape(applied_code[:100], quote=False)
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id
|
||||
)
|
||||
@@ -86,7 +92,7 @@ class PromoCodeService:
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
reason=f"promo code {code_input_upper}",
|
||||
reason=f"promo code {applied_code}",
|
||||
)
|
||||
|
||||
if new_end_date:
|
||||
@@ -109,7 +115,7 @@ class PromoCodeService:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=code_input_upper,
|
||||
promo_code=applied_code,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None,
|
||||
email=getattr(user, "email", None) if user else None,
|
||||
|
||||
@@ -290,6 +290,24 @@ class Settings(BaseSettings):
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
||||
)
|
||||
MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Accept referral links imported from snoups/remnashop via legacy_referral_codes."
|
||||
),
|
||||
)
|
||||
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Try exact legacy Remnashop promo codes before uppercase normalization.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Timestamp of the latest Remnashop import run, managed by the import script.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_NOTES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Operator notes for instances migrated from Remnashop.",
|
||||
)
|
||||
|
||||
APP_RUNTIME_MODE: str = Field(
|
||||
default="production",
|
||||
|
||||
@@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt
|
||||
return await session.get(PromoCode, promo_code_id)
|
||||
|
||||
|
||||
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]:
|
||||
code = str(code_str or "").strip()
|
||||
if not code:
|
||||
return []
|
||||
candidates = [code] if preserve_case else []
|
||||
upper_code = code.upper()
|
||||
if upper_code not in candidates:
|
||||
candidates.append(upper_code)
|
||||
return candidates
|
||||
|
||||
|
||||
async def get_promo_code_by_code(
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
"""Get promo code by code string (regardless of active status)"""
|
||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(PromoCode.code == candidate)
|
||||
result = await session.execute(stmt)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_active_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == candidate,
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(
|
||||
|
||||
@@ -4,7 +4,7 @@ import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, case, delete, desc, func, or_, update
|
||||
from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
@@ -14,6 +14,8 @@ from ..models import (
|
||||
AdAttribution,
|
||||
EmailVerificationCode,
|
||||
HwidDevicePurchase,
|
||||
LegacyImportMapping,
|
||||
LegacyReferralCode,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
normalized = user.referral_code.strip()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
@@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
user_data["referral_code"] = user_data["referral_code"].strip()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
@@ -567,6 +569,19 @@ async def merge_users(
|
||||
await session.execute(
|
||||
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyReferralCode)
|
||||
.where(LegacyReferralCode.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyImportMapping)
|
||||
.where(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(source_user_id),
|
||||
)
|
||||
.values(target_id=str(target_user_id))
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(MessageLog)
|
||||
@@ -590,13 +605,60 @@ async def merge_users(
|
||||
return target
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
async def get_user_by_referral_code(
|
||||
session: AsyncSession,
|
||||
referral_code: str,
|
||||
*,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[User]:
|
||||
normalized = referral_code.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
upper_normalized = normalized.upper()
|
||||
if upper_normalized != normalized:
|
||||
stmt = select(User).where(User.referral_code == upper_normalized)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if not include_legacy:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if upper_normalized != normalized:
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(
|
||||
LegacyReferralCode.code == upper_normalized,
|
||||
LegacyReferralCode.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -1020,6 +1082,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
||||
await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(LegacyImportMapping).where(
|
||||
or_(
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "subscriptions",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Subscription.subscription_id, String)).where(
|
||||
Subscription.user_id == user_id
|
||||
)
|
||||
),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "payments",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
|
||||
|
||||
@@ -1070,6 +1070,79 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -
|
||||
)
|
||||
|
||||
|
||||
def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if "users" in table_names:
|
||||
columns = {col["name"]: col for col in inspector.get_columns("users")}
|
||||
referral_column = columns.get("referral_code")
|
||||
length = getattr(referral_column.get("type"), "length", None) if referral_column else None
|
||||
if referral_column and (length is None or int(length) < 64):
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_referral_codes (
|
||||
legacy_code_id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'remnashop',
|
||||
code VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
for stmt in [
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source "
|
||||
"ON legacy_referral_codes (source)"
|
||||
),
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)",
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id "
|
||||
"ON legacy_referral_codes (user_id)"
|
||||
),
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active "
|
||||
"ON legacy_referral_codes (is_active)"
|
||||
),
|
||||
]:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_import_mappings (
|
||||
source VARCHAR(64) NOT NULL,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
source_id VARCHAR(128) NOT NULL,
|
||||
target_table VARCHAR(128) NOT NULL,
|
||||
target_id VARCHAR(128) NOT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
PRIMARY KEY (source, entity_type, source_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target
|
||||
ON legacy_import_mappings (target_table, target_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1247,6 +1320,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||
),
|
||||
Migration(
|
||||
id="0034_add_legacy_import_compatibility",
|
||||
description="Store legacy import mappings and referral codes for source-bot migrations",
|
||||
upgrade=_migration_0034_add_legacy_import_compatibility,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+30
-1
@@ -43,7 +43,7 @@ class User(Base):
|
||||
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(64), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -396,6 +396,35 @@ class PromoCodeActivation(Base):
|
||||
)
|
||||
|
||||
|
||||
class LegacyReferralCode(Base):
|
||||
__tablename__ = "legacy_referral_codes"
|
||||
|
||||
legacy_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(64), nullable=False, default="remnashop", index=True)
|
||||
code = Column(String(128), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
__table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),)
|
||||
|
||||
|
||||
class LegacyImportMapping(Base):
|
||||
__tablename__ = "legacy_import_mappings"
|
||||
|
||||
source = Column(String(64), primary_key=True)
|
||||
entity_type = Column(String(64), primary_key=True)
|
||||
source_id = Column(String(128), primary_key=True)
|
||||
target_table = Column(String(128), nullable=False)
|
||||
target_id = Column(String(128), nullable=False)
|
||||
metadata_json = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational one-shot scripts shipped with the backend image."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,44 @@ docker compose ps
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
|
||||
## Интерактивный install wizard
|
||||
|
||||
Для нового сервера скачайте install-скрипт и запустите его:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
|
||||
sh install.sh
|
||||
```
|
||||
|
||||
Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет:
|
||||
|
||||
- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`);
|
||||
- сгенерировать минимальный `.env`, включая пароли и стабильные secrets;
|
||||
- сохранить backup существующих файлов перед перезаписью;
|
||||
- подготовить writable `data/` для файлов приложения;
|
||||
- запустить `docker compose pull && docker compose up -d`;
|
||||
- проверить текущий стек через `docker compose ps` и логи `migrate`;
|
||||
- запустить миграцию из поддерживаемых ботов: Remnashop и старый
|
||||
`remnawave-tg-shop`;
|
||||
|
||||
Для тестирования другой ветки или форка задайте источник перед запуском:
|
||||
|
||||
```bash
|
||||
MINISHOP_INSTALL_REPO=3252a8/remnawave-minishop \
|
||||
MINISHOP_INSTALL_REF=main \
|
||||
sh install.sh
|
||||
```
|
||||
|
||||
Миграция Remnashop в wizard сначала запускает `dry-run`, показывает JSON-сводку
|
||||
и только после отдельного подтверждения применяет изменения в целевую БД. Если
|
||||
указать старый Remnashop `.env`, wizard передаст importer-у `APP_CRYPT_KEY`,
|
||||
Remnawave API settings и поддерживаемые payment provider settings из таблицы
|
||||
`payment_gateways`. После применения wizard печатает новые webhook URL для
|
||||
Remnawave Panel и платежных провайдеров.
|
||||
Миграция со старого `remnawave-tg-shop` работает как upgrade совместимой БД:
|
||||
либо копирует старый Docker volume, либо делает `pg_dump` по source DSN,
|
||||
восстанавливает дамп в целевую compose-БД и запускает сервис `migrate`.
|
||||
|
||||
Обычный `docker compose up -d --build` поднимает:
|
||||
|
||||
- `postgres` и `redis` с проверками здоровья;
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
| Источник | Поддерживаемый случай | Документы |
|
||||
| --- | --- | --- |
|
||||
| [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) |
|
||||
| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов, промокодов и поддерживаемых платежных настроек | [Инструкция](remnashop.md) |
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Миграция из Remnashop
|
||||
|
||||
Remnashop импортируется через общий скрипт импорта `backend/scripts/import_legacy.py`.
|
||||
Самый удобный путь - интерактивный install wizard:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
|
||||
sh install.sh
|
||||
```
|
||||
|
||||
В меню выберите `Install new stack and run migration` для нового сервера
|
||||
или `Run migration only`, если compose-папка и `.env` уже готовы.
|
||||
|
||||
## Что переносится
|
||||
|
||||
- пользователи Telegram, username, email, Remnawave UUID и метаданные профиля;
|
||||
- старые referral codes и связи рефералов;
|
||||
- подписки, сроки, лимиты трафика, HWID/device limit и UUID подписок панели;
|
||||
- платежи и статусы платежей;
|
||||
- промокоды на дни подписки и их активации, если таблицы есть в source DB;
|
||||
- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`;
|
||||
- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes.
|
||||
|
||||
Данные, которые не имеют прямого аналога, сохраняются в служебных таблицах миграции или
|
||||
message logs как заметки, чтобы администратор мог проверить их после переноса.
|
||||
|
||||
## Настройки и платежные провайдеры
|
||||
|
||||
Если указать старый Remnashop `.env`, importer дополнительно переносит часть
|
||||
настроек в админские overrides:
|
||||
|
||||
- `REMNAWAVE_HOST` -> `PANEL_API_URL`;
|
||||
- `REMNAWAVE_TOKEN` -> `PANEL_API_KEY`;
|
||||
- `REMNAWAVE_WEBHOOK_SECRET` -> `PANEL_WEBHOOK_SECRET`;
|
||||
- `BOT_SUPPORT_USERNAME` -> `SUPPORT_LINK`;
|
||||
- `APP_DEFAULT_LOCALE` -> `DEFAULT_LANGUAGE`.
|
||||
|
||||
`BOT_MINI_APP` из Remnashop не переносится автоматически. В Remnashop эта
|
||||
переменная управляет кнопкой подключения к subscription page или внешнему Mini
|
||||
App, а не веб-кабинетом Remnashop. В Minishop `SUBSCRIPTION_MINI_APP_URL`
|
||||
должен указывать на текущий frontend/Mini App этого стека; wizard настраивает
|
||||
его из `WEBHOOK_HOST`/`MINIAPP_HOST` или `MINIAPP_PUBLIC_URL`.
|
||||
|
||||
Значения-заглушки вроде `change_me` importer пропускает, чтобы случайно не
|
||||
записать шаблонные секреты в рабочую конфигурацию.
|
||||
|
||||
Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`.
|
||||
Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA,
|
||||
CryptoPay, Heleket, FreeKassa и Platega. Для них importer переносит флаги
|
||||
включения, API-ключи/merchant IDs и прямые технические параметры, без которых
|
||||
провайдер не сможет работать: YooKassa receipt email/VAT, FreeKassa second
|
||||
secret/payment method/server IP и Platega payment method.
|
||||
|
||||
Provider currency и supported-currency ограничения не переносятся автоматически:
|
||||
в Minishop валюта платежа управляется тарифами и `DEFAULT_CURRENCY_SYMBOL`.
|
||||
Если старый gateway Remnashop был настроен на нестандартную валюту, importer
|
||||
оставит предупреждение в JSON-сводке; проверьте `CRYPTOPAY_ASSET`,
|
||||
`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES` или
|
||||
`PLATEGA_SUPPORTED_CURRENCIES` вручную.
|
||||
|
||||
Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас
|
||||
не имеют прямого аналога в Minishop. Если они были в Remnashop, importer
|
||||
оставит предупреждение в JSON-сводке и notes миграции, а настроить их нужно
|
||||
вручную или через будущий отдельный provider.
|
||||
|
||||
Remnashop может хранить секреты в формате `enc_...`. Для расшифровки нужен
|
||||
старый `APP_CRYPT_KEY`; проще всего указать путь к старому `.env` в wizard или
|
||||
передать `--source-env-file`. Если ключ не передан или неверный, зашифрованные
|
||||
значения будут пропущены с предупреждением, остальные данные продолжат
|
||||
импортироваться.
|
||||
|
||||
После успешного применения wizard печатает список новых адресов webhook. Их
|
||||
нужно указать во внешних сервисах вместо старых Remnashop URL:
|
||||
|
||||
- Remnawave Panel -> `WEBHOOK_URL`: `WEBHOOK_BASE_URL` + `/webhook/panel`;
|
||||
- YooKassa HTTP notifications URL: `WEBHOOK_BASE_URL` + `/webhook/yookassa`;
|
||||
- WATA webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/wata`;
|
||||
- CryptoBot/Crypto Pay webhook URL: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`;
|
||||
- Heleket payment webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/heleket`;
|
||||
- FreeKassa notification/result URL: `WEBHOOK_BASE_URL` + `/webhook/freekassa`;
|
||||
- Platega webhook URL: `WEBHOOK_BASE_URL` + `/webhook/platega`;
|
||||
- Telegram webhook `WEBHOOK_BASE_URL` + `/tg/webhook` выставляется ботом
|
||||
автоматически при старте.
|
||||
|
||||
## Flow wizard
|
||||
|
||||
1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через
|
||||
`raw.githubusercontent.com`, без клонирования репозитория.
|
||||
2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`.
|
||||
3. Опционально указываете путь к старому Remnashop `.env` для `APP_CRYPT_KEY`,
|
||||
Remnawave API settings и переносимых settings.
|
||||
4. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN.
|
||||
5. При необходимости указываете JSON map тарифов Remnashop в локальные
|
||||
`tariff_key`, например `{"basic": "standard_month"}`.
|
||||
6. Wizard запускает `dry-run` и показывает JSON-сводку.
|
||||
7. После подтверждения `y` importer применяет изменения, печатает список новых
|
||||
webhook URL для Remnawave Panel и платежных провайдеров, затем перезапускает
|
||||
`backend`/`worker`, чтобы настройки совместимости перечитались.
|
||||
|
||||
Если source DB находится на том же Docker host, помните, что DSN выполняется
|
||||
из backend-контейнера. Для подключения к сервису вне compose-сети может
|
||||
понадобиться host name вроде `host.docker.internal`, внешний адрес сервера или
|
||||
ручное подключение контейнеров к общей Docker network.
|
||||
|
||||
## Ручной запуск
|
||||
|
||||
Если нужно запустить importer без wizard:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend \
|
||||
python backend/scripts/import_legacy.py \
|
||||
--source-type remnashop \
|
||||
--source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \
|
||||
--source-schema public \
|
||||
--source-env-file /path/to/remnashop/.env \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
После успешного `dry-run` повторите команду без `--dry-run`. По умолчанию
|
||||
режим конфликтов `merge`: существующие пользователи и платежи сопоставляются,
|
||||
а новые записи добавляются. Для узкого импорта используйте `--only`, например
|
||||
`--only users,referrals,promocodes`.
|
||||
@@ -1,90 +1,60 @@
|
||||
# Миграция с `remnawave-tg-shop` (≤ v2.7.0) на `remnawave-minishop` (v3.4+)
|
||||
# Миграция с `remnawave-tg-shop` на `remnawave-minishop`
|
||||
|
||||
Эта страница - готовый сценарий для legacy-стека `remnawave-tg-shop`. Это единственная миграция с другого бота, которая сейчас описана в документации. Для других Telegram-ботов, самописных панелей и ручных таблиц готового сценария пока нет: их нельзя переносить по этой инструкции без отдельного анализа схемы БД, тарифов, платежей и связи с Remnawave Panel.
|
||||
|
||||
Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта.
|
||||
|
||||
## Короткий путь без смены ветки и сборки
|
||||
|
||||
Если вы используете только готовые Docker-образы и не собираете проект
|
||||
локально, git-команды из ручного способа не нужны. Достаточно обновить
|
||||
compose-файл до одного из готовых примеров в `deploy/examples` и
|
||||
перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси -
|
||||
`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть
|
||||
такие же самостоятельные папки.
|
||||
|
||||
Минимальная последовательность:
|
||||
Для переноса со старого родственного стека используйте общий install wizard:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
|
||||
# Скопируйте старый .env в выбранную папку примера и обновите значения там.
|
||||
cp .env deploy/examples/no-proxy/.env
|
||||
nano deploy/examples/no-proxy/.env
|
||||
|
||||
# Подготовьте стек из готовых образов.
|
||||
IMAGE_TAG=3.4.0 docker compose \
|
||||
--env-file deploy/examples/no-proxy/.env \
|
||||
-f deploy/examples/no-proxy/docker-compose.yml \
|
||||
up --no-start
|
||||
|
||||
# Нужно только при переходе со старого имени volume remnawave-tg-shop-db-data.
|
||||
# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите.
|
||||
docker run --rm \
|
||||
-v remnawave-tg-shop-db-data:/from:ro \
|
||||
-v remnawave-minishop-db-data:/to \
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
|
||||
IMAGE_TAG=3.4.0 docker compose \
|
||||
--env-file deploy/examples/no-proxy/.env \
|
||||
-f deploy/examples/no-proxy/docker-compose.yml \
|
||||
up -d
|
||||
docker compose \
|
||||
--env-file deploy/examples/no-proxy/.env \
|
||||
-f deploy/examples/no-proxy/docker-compose.yml \
|
||||
logs migrate
|
||||
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
|
||||
sh install.sh
|
||||
```
|
||||
|
||||
Сервис `migrate` сам применит недостающие схемные миграции к перенесённому
|
||||
тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и
|
||||
`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми.
|
||||
В меню выберите `Install new stack and run migration` для нового
|
||||
сервера или `Run migration only`, если compose-папка уже готова. Затем
|
||||
выберите источник `Old remnawave-tg-shop`.
|
||||
|
||||
Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop`
|
||||
(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до
|
||||
текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли
|
||||
две независимые перетряски, и скрипт пытается отработать обе одной командой:
|
||||
Wizard поддерживает два способа переноса:
|
||||
|
||||
1. **Переименование стека** (v3.1.0): контейнеры и тома `remnawave-tg-shop-*`
|
||||
стали `remnawave-minishop-*`. Простой `docker compose up -d` после
|
||||
`git pull` создаёт пустую БД — без переноса тома данные теряются.
|
||||
2. **Разделение бота на сервисы** (v3.4.0): из одного контейнера выделены
|
||||
`backend`, `worker`, `frontend`, `migrate` + новые `postgres`, `redis`.
|
||||
Появились новые volumes `redis-data` и `shop-data`, новые обязательные
|
||||
переменные окружения, а схема БД обновляется автоматически one-shot
|
||||
сервисом `migrate`.
|
||||
- `Copy old Docker volumes` - для старого compose-стека на том же Docker host.
|
||||
Скрипт подготавливает новый stack, копирует
|
||||
`remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально
|
||||
переносит Caddy volumes и запускает новый stack.
|
||||
- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN.
|
||||
Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает
|
||||
`pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает
|
||||
сервис `migrate`.
|
||||
|
||||
После миграции `docker compose ps` должен показать как минимум: `backend`,
|
||||
`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0).
|
||||
Логи: `docker compose logs -f backend worker frontend`.
|
||||
В обоих режимах старые volumes и старая БД не удаляются автоматически.
|
||||
|
||||
Доступные пути:
|
||||
## Как работает перенос
|
||||
|
||||
- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка
|
||||
останавливает старый стек, накатывает свежий код, переносит том БД,
|
||||
поднимает новые сервисы. Идемпотентный.
|
||||
- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно
|
||||
понимать каждое действие или выполнить выборочно.
|
||||
`remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы.
|
||||
После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие
|
||||
миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`,
|
||||
затем последовательные записи `schema_migrations`. Это one-shot сервис: он
|
||||
должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`.
|
||||
|
||||
В обоих случаях:
|
||||
При volume-миграции wizard:
|
||||
|
||||
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай
|
||||
отката;
|
||||
- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`)
|
||||
тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться
|
||||
в rate limit;
|
||||
- схема БД обновляется автоматически: при первом `docker compose up -d` сервис
|
||||
`migrate` накатывает на перенесённый том все недостающие миграции (от
|
||||
alembic-схемы v2.7.0 до текущей).
|
||||
1. Останавливает известные контейнеры старого и переходного стеков, если вы
|
||||
подтверждаете этот шаг.
|
||||
2. Запускает `docker compose up --no-start`, чтобы Docker Compose создал новые
|
||||
volumes.
|
||||
3. Копирует старый volume БД:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v remnawave-tg-shop-db-data:/from:ro \
|
||||
-v remnawave-minishop-db-data:/to \
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
```
|
||||
|
||||
4. Если старые Caddy volumes существуют, переносит
|
||||
`remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и
|
||||
`remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`.
|
||||
5. Запускает новый stack через Docker Compose.
|
||||
|
||||
Если целевой DB volume уже непустой, wizard не перетирает его молча: он
|
||||
останавливается и просит отдельное подтверждение на продолжение без копирования
|
||||
старой БД.
|
||||
|
||||
## Что меняется в архитектуре
|
||||
|
||||
@@ -93,256 +63,55 @@ docker compose \
|
||||
| Версия | Сервисы |
|
||||
| --- | --- |
|
||||
| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` |
|
||||
| `v3.1.x–v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` |
|
||||
| `v3.4+` (текущая) | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` |
|
||||
|
||||
Внутри Docker-сети сервисы доступны по коротким DNS-именам (`backend`, `worker`,
|
||||
`frontend`, `postgres`, `redis`), а не по полному `container_name`. Это важно
|
||||
для внешнего reverse-proxy — см. раздел [Внешний reverse-proxy](#внешний-reverse-proxy) ниже.
|
||||
| `v3.1.x-v3.3.x` | `remnawave-minishop`, `remnawave-minishop-db` |
|
||||
| `v3.4+` | `remnawave-minishop-backend`, `remnawave-minishop-worker`, `remnawave-minishop-frontend`, `remnawave-minishop-migrate`, `remnawave-minishop-postgres`, `remnawave-minishop-redis` |
|
||||
|
||||
**Volumes**:
|
||||
|
||||
| Volume | v2.7.0 | v3.4+ | Что внутри |
|
||||
| --- | --- | --- | --- |
|
||||
| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL |
|
||||
| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) |
|
||||
| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji |
|
||||
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте |
|
||||
| Volume | Что происходит |
|
||||
| --- | --- |
|
||||
| `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN |
|
||||
| `remnawave-minishop-redis-data` | создается пустым |
|
||||
| `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную |
|
||||
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy |
|
||||
|
||||
`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего
|
||||
долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется
|
||||
из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете
|
||||
тарифы через админ-панель).
|
||||
Доступные compose-профили: `docker-compose.yml`,
|
||||
`deploy/examples/caddy/docker-compose.yml`,
|
||||
`deploy/examples/nginx/docker-compose.yml`,
|
||||
`deploy/examples/newt/docker-compose.yml`,
|
||||
`deploy/examples/no-proxy/docker-compose.yml`.
|
||||
|
||||
## Переменные окружения, которые могли исчезнуть или переехать
|
||||
## Переменные окружения
|
||||
|
||||
Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно
|
||||
менялось между v2.7.0 и v3.4+:
|
||||
Перед запуском нового стека проверьте `.env`. Самые важные изменения:
|
||||
|
||||
| Было (v2.7.0) | Стало (v3.4+) | Действие |
|
||||
| Было | Стало | Действие |
|
||||
| --- | --- | --- |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). |
|
||||
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
|
||||
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
|
||||
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
|
||||
| `REFERRAL_ENABLED` | удалена | Реферальная программа активна по умолчанию. В legacy-режиме без JSON-каталога отключайте платежные бонусы через нули в `REFERRAL_BONUS_DAYS_*` и `REFEREE_BONUS_DAYS_*`; в JSON-тарифах обнуляйте или удаляйте `referral_bonus_days_inviter` и `referral_bonus_days_referee` у period-тарифов. |
|
||||
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
|
||||
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
|
||||
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
|
||||
| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. |
|
||||
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. |
|
||||
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. |
|
||||
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. |
|
||||
| `POSTGRES_HOST=remnawave-tg-shop-db` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. |
|
||||
| `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. |
|
||||
| - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. |
|
||||
| - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. |
|
||||
|
||||
Полный референс — [docs/getting-started/configuration.md](../getting-started/configuration.md). Скрипт миграции
|
||||
эти переменные **не правит** автоматически (только `POSTGRES_HOST`), потому
|
||||
что у каждой инсталляции свой шаблон `.env` с кастомными значениями. Лучше
|
||||
сравнить свой `.env` с `.env.example` глазами один раз, чем получить
|
||||
несовместимый шаблон автоматом.
|
||||
Остальные продуктовые настройки удобнее проверить после первого входа в
|
||||
админку.
|
||||
|
||||
## Автоматический способ (через скрипт)
|
||||
## Reverse Proxy
|
||||
|
||||
Если helper ещё не лежит у вас локально, запускайте его прямо из `raw` из
|
||||
корня старого репозитория:
|
||||
В старом стеке часто был один upstream `remnawave-tg-shop:8000`. В текущем
|
||||
split-arch stack маршруты разделены:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
```
|
||||
|
||||
> Команда выше рассчитана на `bash` / Git Bash / WSL. Если вы запускаете из
|
||||
> PowerShell, удобнее сначала открыть Git Bash.
|
||||
|
||||
Если вы уже подтянули новую версию и файл есть локально, можно запускать так:
|
||||
|
||||
```bash
|
||||
bash scripts/migrate_to_minishop.sh
|
||||
```
|
||||
|
||||
По умолчанию скрипт работает с `docker-compose.yml` и переключается на ветку
|
||||
`main`. Можно переопределить через переменные окружения:
|
||||
|
||||
| Переменная | Назначение | По умолчанию |
|
||||
| ----------------- | ----------------------------------------------------------------------- | ---------------------- |
|
||||
| `PROJECT_ROOT` | Явный путь к корню старого репозитория, если запуск не из него | текущая директория |
|
||||
| `COMPOSE_FILE` | Какой compose-файл стартовать в конце | `docker-compose.yml` |
|
||||
| `TARGET_BRANCH` | На какую ветку переключаться и подтягивать обновления | `main` |
|
||||
| `GIT_REMOTE` | Какой remote использовать для `fetch`/`pull` | `origin` |
|
||||
| `NEW_ORIGIN_URL` | Если задано и не совпадает с URL выбранного remote — он будет обновлён | (не меняется) |
|
||||
| `ASSUME_YES` | `1` — не задавать интерактивных вопросов | `0` |
|
||||
|
||||
Примеры:
|
||||
|
||||
```bash
|
||||
# Caddy-вариант из raw-файла.
|
||||
# Перед запуском скопируйте старый .env в deploy/examples/caddy/.env
|
||||
# и заполните WEBHOOK_HOST / MINIAPP_HOST.
|
||||
COMPOSE_FILE=deploy/examples/caddy/docker-compose.yml \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
|
||||
# С переключением origin на форк 3252a8
|
||||
NEW_ORIGIN_URL=https://github.com/3252a8/remnawave-minishop.git \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
|
||||
# Без интерактива
|
||||
ASSUME_YES=1 \
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/migrate_to_minishop.sh)
|
||||
```
|
||||
|
||||
Что делает скрипт:
|
||||
|
||||
1. **Останавливает текущий стек**: ищет известные контейнеры старой схемы
|
||||
(`remnawave-tg-shop`, `…-db`, `…-caddy`), переходного периода
|
||||
(`remnawave-minishop`, `…-db`, `…-caddy`) и новой схемы
|
||||
(`…-backend`, `…-worker`, `…-frontend`, `…-migrate`, `…-postgres`, `…-redis`)
|
||||
и останавливает их, если запущены. Безопасно при повторном запуске.
|
||||
2. **Переключает `origin`**, если задана переменная `NEW_ORIGIN_URL`, иначе
|
||||
оставляет как есть.
|
||||
3. **Подтягивает целевую ветку** (`git fetch` + `git switch` + `git pull --ff-only`).
|
||||
Прерывается, если в рабочем дереве есть незакоммиченные изменения.
|
||||
4. **Правит `POSTGRES_HOST` в `.env`** (только для bare-metal сценариев — в
|
||||
compose это значение перебивает `environment:` блок).
|
||||
5. **Подготавливает новый стек в режиме `--no-start`**, чтобы Compose сам
|
||||
создал тома `db-data`, `redis-data`, `shop-data` и не ругался на уже
|
||||
существующий volume.
|
||||
6. **Переносит том БД** `remnawave-tg-shop-db-data` → `remnawave-minishop-db-data`
|
||||
(и Caddy-тома, если применимо) через одноразовый `alpine`-контейнер. Если
|
||||
новый том уже непустой — копирование пропускается. Новые volumes
|
||||
`redis-data` и `shop-data` остаются пустыми (их и не должно быть в старом
|
||||
стеке).
|
||||
7. **Стартует новый стек** (`docker compose up -d --remove-orphans` плюс
|
||||
`--build` для локальной сборки). `migrate` отработает первым, накатит
|
||||
на перенесённый том все недостающие миграции (от alembic-схемы v2.7.0 до
|
||||
текущей) и завершится. Затем стартуют `backend`, `worker`, `frontend`.
|
||||
|
||||
Скрипт идемпотентен: повторный запуск ничего не сломает, просто пропустит уже
|
||||
выполненные шаги.
|
||||
|
||||
После того как убедитесь, что бот работает и данные на месте, удалите старые
|
||||
тома:
|
||||
|
||||
```bash
|
||||
docker volume rm remnawave-tg-shop-db-data
|
||||
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Ручной способ
|
||||
|
||||
1. **Остановите старый стек и обновите код:**
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
git fetch origin
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
```
|
||||
|
||||
2. **(Только для bare-metal без compose)** обновите `.env`, если в нём ещё
|
||||
жёстко прописан старый контейнер БД:
|
||||
|
||||
```bash
|
||||
sed -i.bak 's/^POSTGRES_HOST=remnawave-tg-shop-db$/POSTGRES_HOST=remnawave-minishop-db/' .env
|
||||
```
|
||||
|
||||
Под `docker compose up` это не нужно: compose сам выставляет
|
||||
`POSTGRES_HOST: postgres` (имя сервиса) в `environment:` и `.env`-значение
|
||||
не используется.
|
||||
|
||||
3. **Проверьте `.env`** на наличие переменных, которые исчезли или
|
||||
переименовались — см. раздел
|
||||
[Переменные окружения](#переменные-окружения-которые-могли-исчезнуть-или-переехать)
|
||||
выше. Главное: `WEBHOOK_SECRET_TOKEN` (бывший `TELEGRAM_WEBHOOK_SECRET`),
|
||||
обязательный `WEBHOOK_BASE_URL` и наличие `REDIS_URL` (по умолчанию задано
|
||||
в compose).
|
||||
|
||||
4. **Подготовьте новый стек без запуска**, чтобы Compose создал новые volumes
|
||||
(`db-data`, `redis-data`, `shop-data`) и контейнеры:
|
||||
|
||||
```bash
|
||||
# Локальная сборка
|
||||
docker compose up --no-start --build
|
||||
|
||||
# Или готовый Caddy-вариант из GHCR-образов
|
||||
cp .env deploy/examples/caddy/.env
|
||||
nano deploy/examples/caddy/.env
|
||||
docker compose \
|
||||
--env-file deploy/examples/caddy/.env \
|
||||
-f deploy/examples/caddy/docker-compose.yml \
|
||||
up --no-start
|
||||
|
||||
# Другие готовые варианты:
|
||||
# deploy/examples/nginx/docker-compose.yml
|
||||
# deploy/examples/newt/docker-compose.yml
|
||||
# deploy/examples/no-proxy/docker-compose.yml
|
||||
```
|
||||
|
||||
5. **Перенесите том БД в новое имя:**
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v remnawave-tg-shop-db-data:/from:ro \
|
||||
-v remnawave-minishop-db-data:/to \
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
```
|
||||
|
||||
`remnawave-minishop-redis-data` и `remnawave-minishop-shop-data` — новые,
|
||||
переносить нечего. Они инициализируются на лету: Redis пуст, а `data/`
|
||||
наполняется при первом обращении к настройкам Web App / каталогу тарифов.
|
||||
|
||||
6. **(Только для Caddy)** перенесите тома Caddy с TLS-сертификатами и
|
||||
состоянием ACME:
|
||||
|
||||
```bash
|
||||
for v in caddy-data caddy-config; do
|
||||
docker run --rm \
|
||||
-v "remnawave-tg-shop-$v":/from:ro \
|
||||
-v "remnawave-minishop-$v":/to \
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
done
|
||||
```
|
||||
|
||||
7. **Запустите новый стек:**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# или
|
||||
docker compose \
|
||||
--env-file deploy/examples/caddy/.env \
|
||||
-f deploy/examples/caddy/docker-compose.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Сервис `migrate` запустится первым, обнаружит перенесённый том,
|
||||
применит недостающие схемные миграции (`Base.metadata.create_all` +
|
||||
последовательные миграции `0001..00NN` из `backend/db/migrator.py`) и
|
||||
выйдет с кодом 0. Только после этого стартуют `backend` и `worker`.
|
||||
|
||||
8. **Проверьте состояние:**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f backend worker frontend
|
||||
docker compose logs migrate # должен закончиться "Migrator: migration 00NN applied successfully"
|
||||
```
|
||||
|
||||
9. **(Опционально) удалите старые тома**, когда убедитесь, что новый стек
|
||||
стабилен:
|
||||
|
||||
```bash
|
||||
docker volume rm remnawave-tg-shop-db-data
|
||||
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Внешний reverse-proxy
|
||||
|
||||
В v2.7.0 был один upstream — `remnawave-tg-shop:8000`. В v3.4+ функциональность
|
||||
разнесена по портам и сервисам:
|
||||
|
||||
| Назначение | DNS-имя сервиса | Порт |
|
||||
| Назначение | Service | Port |
|
||||
| --- | --- | --- |
|
||||
| Telegram / платежные / вебхуки панели | `backend` | `8080` |
|
||||
| Health-чек | `backend` | `8080` (`/healthz`) |
|
||||
| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) |
|
||||
| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) |
|
||||
| Telegram, платежные и panel webhooks | `backend` | `8080` |
|
||||
| Health-check | `backend` | `8080` (`/healthz`) |
|
||||
| Web App API и auth | `backend` | `8081` внутри Docker-сети |
|
||||
| Статический Web App frontend | `frontend` | `80` |
|
||||
|
||||
Минимальная замена для внешнего Nginx, который раньше слал всё на один
|
||||
upstream:
|
||||
Минимальная схема для внешнего Nginx:
|
||||
|
||||
```nginx
|
||||
upstream remnawave_backend_webhooks { server backend:8080; }
|
||||
@@ -351,33 +120,32 @@ upstream remnawave_frontend { server frontend:80; }
|
||||
server {
|
||||
server_name app.domain.com;
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
# ssl_certificate / ssl_certificate_key — без изменений
|
||||
|
||||
location /webhook/ { proxy_pass http://remnawave_backend_webhooks; }
|
||||
location /healthz { proxy_pass http://remnawave_backend_webhooks; }
|
||||
location / { proxy_pass http://remnawave_frontend; }
|
||||
location / { proxy_pass http://remnawave_frontend; }
|
||||
}
|
||||
```
|
||||
|
||||
Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в
|
||||
[docs/getting-started/deployment.md](../getting-started/deployment.md) и [docs/features/web-app.md](../features/web-app.md). Если раньше прокси указывал на
|
||||
`remnawave-tg-shop:8000` напрямую, после миграции нужно либо переключиться на
|
||||
`backend:8080` / `frontend:80`, либо использовать готовый Caddy/Nginx/Newt
|
||||
пример, который уже знает правильную маршрутизацию.
|
||||
Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную
|
||||
маршрутизацию.
|
||||
|
||||
## Если что-то пошло не так
|
||||
## Проверка
|
||||
|
||||
`migrate` упал → читайте `docker compose logs migrate`. Том БД остался
|
||||
не тронут, можно откатиться, переключив compose-файл обратно на старый
|
||||
коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data`
|
||||
(пока вы его не удалили).
|
||||
После переноса:
|
||||
|
||||
`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо
|
||||
`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте
|
||||
свежий секрет в `.env` и перезапустите — Telegram переустановит webhook
|
||||
автоматически.
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs migrate
|
||||
docker compose logs -f backend worker frontend
|
||||
```
|
||||
|
||||
Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`),
|
||||
а внешний прокси шлёт на `frontend:80`, а не на старый
|
||||
`remnawave-tg-shop:8000`.
|
||||
`migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`,
|
||||
`postgres` и `redis` должны быть running/healthy.
|
||||
|
||||
Когда убедитесь, что новый stack работает, старые volumes можно удалить вручную:
|
||||
|
||||
```bash
|
||||
docker volume rm remnawave-tg-shop-db-data
|
||||
docker volume rm remnawave-tg-shop-caddy-data remnawave-tg-shop-caddy-config 2>/dev/null || true
|
||||
```
|
||||
|
||||
@@ -348,6 +348,7 @@
|
||||
devices: "Устройства",
|
||||
subscription_guides: "Connection guides",
|
||||
system: "Система",
|
||||
migrations: "Миграции",
|
||||
};
|
||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||
}
|
||||
|
||||
@@ -571,9 +571,7 @@
|
||||
{#each openedUserDetail.subscriptions.slice(0, 8) as sub}
|
||||
<div class="admin-mini-list-row">
|
||||
<div>
|
||||
<strong
|
||||
>{subscriptionDisplayLabel(sub)}</strong
|
||||
>
|
||||
<strong>{subscriptionDisplayLabel(sub)}</strong>
|
||||
<small
|
||||
>{at(
|
||||
"user_history_until",
|
||||
|
||||
@@ -1110,11 +1110,13 @@
|
||||
"admin_settings_section_devices": "Devices",
|
||||
"admin_settings_section_support": "Support",
|
||||
"admin_settings_section_system": "System",
|
||||
"admin_settings_section_migrations": "Migrations",
|
||||
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
||||
"admin_settings_field_telemetry_enabled_description": "Sends one anonymous heartbeat per day (version, OS, locale, user-count range). No personal data, tokens or domains. Helps gauge how many installs are active and which versions are in use. Toggling this off takes effect without a restart.",
|
||||
"admin_settings_subsection_common": "Common",
|
||||
"admin_settings_subsection_checkout": "Checkout",
|
||||
"admin_settings_subsection_remnawave": "Remnawave",
|
||||
"admin_settings_subsection_remnashop": "Remnashop",
|
||||
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
||||
"admin_settings_subsection_yookassa": "YooKassa",
|
||||
"admin_settings_subsection_freekassa": "FreeKassa",
|
||||
@@ -1129,6 +1131,14 @@
|
||||
"admin_settings_provider_webhook_base_missing": "Set WEBHOOK_BASE_URL in .env to show the full URL for {path}.",
|
||||
"admin_settings_provider_admin_only_label": "Only for admins",
|
||||
"admin_settings_provider_admin_only_description": "Shows this provider only to admins. Webhooks and payment status handling remain active for test payments.",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_label": "Remnashop ref-code compatibility",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_description": "Allows old Remnashop referral codes to resolve without changing their case or format. The importer enables it automatically.",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_label": "Remnashop promo-code compatibility",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_description": "Looks up promo codes in the original case first, then falls back to the current rules. Regular promo codes keep working as before.",
|
||||
"admin_settings_field_migration_remnashop_imported_at_label": "Remnashop import date",
|
||||
"admin_settings_field_migration_remnashop_imported_at_description": "Operational marker for the latest Remnashop import, stored as an ISO timestamp.",
|
||||
"admin_settings_field_migration_remnashop_notes_label": "Remnashop import notes",
|
||||
"admin_settings_field_migration_remnashop_notes_description": "Short importer summary: migrated entities and enabled compatibility modes.",
|
||||
"admin_settings_validation_errors": "Errors: {errors}",
|
||||
"admin_settings_save_error": "Error: {error}",
|
||||
"admin_sync_started": "Synchronization started",
|
||||
|
||||
@@ -1110,11 +1110,13 @@
|
||||
"admin_settings_section_devices": "Устройства",
|
||||
"admin_settings_section_support": "Поддержка",
|
||||
"admin_settings_section_system": "Система",
|
||||
"admin_settings_section_migrations": "Миграции",
|
||||
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
|
||||
"admin_settings_subsection_common": "Общие",
|
||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||
"admin_settings_subsection_remnawave": "Remnawave",
|
||||
"admin_settings_subsection_remnashop": "Remnashop",
|
||||
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
||||
"admin_settings_subsection_yookassa": "YooKassa",
|
||||
"admin_settings_subsection_freekassa": "FreeKassa",
|
||||
@@ -1129,6 +1131,14 @@
|
||||
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
||||
"admin_settings_provider_admin_only_label": "Только для админов",
|
||||
"admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_label": "Совместимость ref-кодов Remnashop",
|
||||
"admin_settings_field_migration_remnashop_referral_code_compat_enabled_description": "Разрешает вход по старым ref-кодам Remnashop без изменения их регистра и формата. Включается импортёром автоматически.",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_label": "Совместимость промокодов Remnashop",
|
||||
"admin_settings_field_migration_remnashop_promo_code_compat_enabled_description": "Ищет промокоды сначала в исходном регистре, затем по текущим правилам. Обычные промокоды продолжают работать как раньше.",
|
||||
"admin_settings_field_migration_remnashop_imported_at_label": "Дата импорта Remnashop",
|
||||
"admin_settings_field_migration_remnashop_imported_at_description": "Служебная отметка последнего импорта Remnashop в ISO-формате.",
|
||||
"admin_settings_field_migration_remnashop_notes_label": "Заметки импорта Remnashop",
|
||||
"admin_settings_field_migration_remnashop_notes_description": "Краткая сводка импортёра: какие сущности перенесены и какие совместимые режимы включены.",
|
||||
"admin_settings_validation_errors": "Ошибки: {errors}",
|
||||
"admin_settings_save_error": "Ошибка: {error}",
|
||||
"admin_sync_started": "Синхронизация запущена",
|
||||
|
||||
+1233
File diff suppressed because it is too large
Load Diff
@@ -1,318 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=""
|
||||
|
||||
OLD_PREFIX="remnawave-tg-shop"
|
||||
NEW_PREFIX="remnawave-minishop"
|
||||
OLD_DB_VOLUME="${OLD_PREFIX}-db-data"
|
||||
NEW_DB_VOLUME="${NEW_PREFIX}-db-data"
|
||||
OLD_CADDY_VOLUMES=("${OLD_PREFIX}-caddy-data" "${OLD_PREFIX}-caddy-config")
|
||||
NEW_CADDY_VOLUMES=("${NEW_PREFIX}-caddy-data" "${NEW_PREFIX}-caddy-config")
|
||||
# Container names span three eras: original ``remnawave-tg-shop*`` (≤ v2.7.0),
|
||||
# the renamed but still single-container ``remnawave-minishop*`` (v3.1.0 –
|
||||
# v3.3.x), and the split-arch stack introduced in v3.4.0 (backend / worker /
|
||||
# frontend / migrate / postgres / redis, plus optional caddy). The list is
|
||||
# used only to stop existing containers before migration, so it is safe —
|
||||
# and idempotent — to include every known name from every era.
|
||||
KNOWN_CONTAINERS=(
|
||||
# v2.7.0 (upstream remnawave-tg-shop):
|
||||
"${OLD_PREFIX}"
|
||||
"${OLD_PREFIX}-db"
|
||||
"${OLD_PREFIX}-caddy"
|
||||
# v3.1.0 – v3.2.x (renamed but still one container):
|
||||
"${NEW_PREFIX}"
|
||||
"${NEW_PREFIX}-db"
|
||||
"${NEW_PREFIX}-caddy"
|
||||
# v3.4.0+ (split architecture):
|
||||
"${NEW_PREFIX}-backend"
|
||||
"${NEW_PREFIX}-worker"
|
||||
"${NEW_PREFIX}-frontend"
|
||||
"${NEW_PREFIX}-migrate"
|
||||
"${NEW_PREFIX}-postgres"
|
||||
"${NEW_PREFIX}-redis"
|
||||
)
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Ошибка: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Не найдено обязательное средство \`$1\` в PATH."
|
||||
}
|
||||
|
||||
resolve_root() {
|
||||
if [[ -n "${PROJECT_ROOT:-}" ]]; then
|
||||
[[ -d "$PROJECT_ROOT" ]] || die "PROJECT_ROOT не существует: $PROJECT_ROOT"
|
||||
(cd -- "$PROJECT_ROOT" >/dev/null && pwd -P)
|
||||
return
|
||||
fi
|
||||
|
||||
local git_root
|
||||
if git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
printf '%s\n' "$git_root"
|
||||
return
|
||||
fi
|
||||
|
||||
pwd -P
|
||||
}
|
||||
|
||||
run() {
|
||||
log "+ $*"
|
||||
"$@"
|
||||
}
|
||||
|
||||
container_exists() {
|
||||
docker inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
container_running() {
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]]
|
||||
}
|
||||
|
||||
stop_container() {
|
||||
local name="$1"
|
||||
|
||||
if ! container_exists "$name"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if container_running "$name"; then
|
||||
docker stop "$name" >/dev/null
|
||||
fi
|
||||
docker rm "$name" >/dev/null
|
||||
}
|
||||
|
||||
volume_exists() {
|
||||
docker volume inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
volume_is_empty() {
|
||||
docker run --rm -v "$1:/data" alpine sh -c 'test -z "$(find /data -mindepth 1 -print -quit)"' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
copy_volume() {
|
||||
local source="$1"
|
||||
local target="$2"
|
||||
|
||||
if ! volume_exists "$source"; then
|
||||
log " - Пропускаю том \`$source\`: исходный том не найден."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if volume_exists "$target" && ! volume_is_empty "$target"; then
|
||||
log " - Пропускаю том \`$target\`: он уже не пустой."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! volume_exists "$target"; then
|
||||
die "Целевой том \`$target\` не создан Compose. Сначала нужно подготовить новый стек в режиме \`--no-start\`."
|
||||
fi
|
||||
|
||||
docker run --rm -v "$source:/from:ro" -v "$target:/to" alpine sh -c 'cd /from && cp -a . /to/'
|
||||
}
|
||||
|
||||
is_old_postgres_host() {
|
||||
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${OLD_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
|
||||
}
|
||||
|
||||
is_new_postgres_host() {
|
||||
grep -Eq "^[[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*${NEW_PREFIX}-db[[:space:]]*(#.*)?$" "$ROOT/.env"
|
||||
}
|
||||
|
||||
update_postgres_host() {
|
||||
if is_old_postgres_host; then
|
||||
sed -i.bak -E "s|^([[:space:]]*POSTGRES_HOST[[:space:]]*=[[:space:]]*)${OLD_PREFIX}-db([[:space:]]*(#.*)?)$|\\1${NEW_PREFIX}-db\\2|" "$ROOT/.env"
|
||||
log " - \`.env\` обновлён, резервная копия сохранена в \`.env.bak\`."
|
||||
elif is_new_postgres_host; then
|
||||
log " - \`POSTGRES_HOST\` уже указывает на новый контейнер, ничего менять не нужно."
|
||||
else
|
||||
log " - \`POSTGRES_HOST\` не похож на старую схему, пропускаю изменение."
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd git
|
||||
require_cmd docker
|
||||
docker info >/dev/null
|
||||
|
||||
ROOT="$(resolve_root)"
|
||||
|
||||
local compose_file="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
local target_branch="${TARGET_BRANCH:-main}"
|
||||
local git_remote="${GIT_REMOTE:-origin}"
|
||||
local new_origin_url="${NEW_ORIGIN_URL:-}"
|
||||
local assume_yes="${ASSUME_YES:-0}"
|
||||
local current_origin
|
||||
local current_branch
|
||||
local remote_ref
|
||||
local head_commit
|
||||
local compose_has_build=0
|
||||
local compose_has_caddy=0
|
||||
local -a compose_cmd
|
||||
local -a running_containers=()
|
||||
local -a summary=()
|
||||
local -a up_args
|
||||
local name
|
||||
local source
|
||||
local target
|
||||
local answer
|
||||
|
||||
if [[ $compose_file != /* ]]; then
|
||||
compose_file="$ROOT/$compose_file"
|
||||
fi
|
||||
[[ -f "$compose_file" ]] || die "Compose-файл не найден: $compose_file"
|
||||
[[ -e "$ROOT/.git" ]] || die "Скрипт нужно запускать из корня git-репозитория."
|
||||
[[ -f "$ROOT/.env" ]] || die "Не найден \`.env\` в корне репозитория."
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
compose_cmd=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose_cmd=(docker-compose)
|
||||
else
|
||||
die "Не найден ни \`docker compose\`, ни \`docker-compose\`."
|
||||
fi
|
||||
|
||||
if [[ -n "$(git -C "$ROOT" status --porcelain=v1)" ]]; then
|
||||
die "В рабочем дереве есть незакоммиченные изменения. Сначала сохраните их, чтобы миграция не затёрла чужие правки."
|
||||
fi
|
||||
|
||||
if grep -Eq '^[[:space:]]*build:[[:space:]]*' "$compose_file"; then
|
||||
compose_has_build=1
|
||||
fi
|
||||
if grep -Eq '^[[:space:]]*caddy:[[:space:]]*$' "$compose_file"; then
|
||||
compose_has_caddy=1
|
||||
fi
|
||||
|
||||
for name in "${KNOWN_CONTAINERS[@]}"; do
|
||||
if container_exists "$name"; then
|
||||
running_containers+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
current_origin="$(git -C "$ROOT" remote get-url "$git_remote")"
|
||||
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
|
||||
summary+=( "обновить $git_remote с \`$current_origin\` на \`$new_origin_url\`" )
|
||||
fi
|
||||
summary+=( "скачать ветку \`$target_branch\` из \`$git_remote\`" )
|
||||
if volume_exists "$OLD_DB_VOLUME"; then
|
||||
summary+=( "проверить том БД \`$OLD_DB_VOLUME\` и перенести в \`$NEW_DB_VOLUME\` при необходимости" )
|
||||
fi
|
||||
if ((compose_has_caddy)); then
|
||||
for i in 0 1; do
|
||||
source="${OLD_CADDY_VOLUMES[$i]}"
|
||||
target="${NEW_CADDY_VOLUMES[$i]}"
|
||||
if volume_exists "$source"; then
|
||||
summary+=( "проверить том \`$source\` и перенести в \`$target\` при необходимости" )
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if is_old_postgres_host; then
|
||||
summary+=( "обновить \`POSTGRES_HOST\` в \`.env\`" )
|
||||
fi
|
||||
summary+=( "подготовить новый стек через Compose в режиме \`--no-start\`" )
|
||||
summary+=( "запустить compose-файл \`$(basename "$compose_file")\`" )
|
||||
|
||||
if [[ "$assume_yes" != "1" ]]; then
|
||||
if [[ ! -t 0 ]]; then
|
||||
die "Скрипт ожидает интерактивное подтверждение. Запустите с \`ASSUME_YES=1\` для неинтерактивного режима."
|
||||
fi
|
||||
log "План миграции:"
|
||||
for name in "${summary[@]}"; do
|
||||
log " - $name"
|
||||
done
|
||||
read -r -p "Продолжить? [y/N]: " answer
|
||||
case "$answer" in
|
||||
y|Y|yes|YES|Yes)
|
||||
;;
|
||||
*)
|
||||
die "Миграция отменена пользователем."
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
log "1. Останавливаю старый стек"
|
||||
if ((${#running_containers[@]})); then
|
||||
for name in "${running_containers[@]}"; do
|
||||
stop_container "$name"
|
||||
log " - контейнер \`$name\` остановлен/удалён"
|
||||
done
|
||||
else
|
||||
log " - запущенных контейнеров старой схемы не найдено"
|
||||
fi
|
||||
|
||||
if [[ -n "$new_origin_url" && "$current_origin" != "$new_origin_url" ]]; then
|
||||
log "2. Обновляю origin"
|
||||
run git -C "$ROOT" remote set-url "$git_remote" "$new_origin_url"
|
||||
else
|
||||
log "2. Origin уже актуален, пропускаю"
|
||||
fi
|
||||
|
||||
log "3. Обновляю git до ветки \`$target_branch\`"
|
||||
run git -C "$ROOT" fetch "$git_remote" "$target_branch"
|
||||
|
||||
current_branch="$(git -C "$ROOT" branch --show-current || true)"
|
||||
if [[ -z "$current_branch" ]]; then
|
||||
current_branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
fi
|
||||
|
||||
if [[ "$current_branch" != "$target_branch" ]]; then
|
||||
if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$target_branch"; then
|
||||
run git -C "$ROOT" switch "$target_branch"
|
||||
else
|
||||
run git -C "$ROOT" switch -c "$target_branch" --track "$git_remote/$target_branch"
|
||||
fi
|
||||
else
|
||||
log " - уже на ветке \`$target_branch\`"
|
||||
fi
|
||||
|
||||
remote_ref="$(git -C "$ROOT" rev-parse "$git_remote/$target_branch")"
|
||||
head_commit="$(git -C "$ROOT" rev-parse HEAD)"
|
||||
if [[ "$head_commit" != "$remote_ref" ]]; then
|
||||
run git -C "$ROOT" pull --ff-only "$git_remote" "$target_branch"
|
||||
else
|
||||
log " - локальная ветка уже совпадает с удалённой, \`git pull\` не нужен"
|
||||
fi
|
||||
|
||||
log "4. Обновляю \`.env\`"
|
||||
update_postgres_host
|
||||
|
||||
log "5. Подготавливаю новый стек через Compose"
|
||||
if ((compose_has_build)); then
|
||||
run "${compose_cmd[@]}" -f "$compose_file" up --no-start --build
|
||||
else
|
||||
run "${compose_cmd[@]}" -f "$compose_file" up --no-start
|
||||
fi
|
||||
|
||||
log "6. Переношу тома"
|
||||
if copy_volume "$OLD_DB_VOLUME" "$NEW_DB_VOLUME"; then
|
||||
log " - БД перенесена в \`$NEW_DB_VOLUME\`"
|
||||
fi
|
||||
if ((compose_has_caddy)); then
|
||||
for i in 0 1; do
|
||||
source="${OLD_CADDY_VOLUMES[$i]}"
|
||||
target="${NEW_CADDY_VOLUMES[$i]}"
|
||||
if copy_volume "$source" "$target"; then
|
||||
log " - \`$source\` перенесён в \`$target\`"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
log "7. Запускаю новый стек"
|
||||
if ((compose_has_build)); then
|
||||
up_args=(up -d --build --remove-orphans)
|
||||
else
|
||||
up_args=(up -d --remove-orphans)
|
||||
fi
|
||||
run "${compose_cmd[@]}" -f "$compose_file" "${up_args[@]}"
|
||||
run "${compose_cmd[@]}" -f "$compose_file" ps
|
||||
|
||||
log "Готово."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -44,6 +44,13 @@ BACKUP_SETTINGS = (
|
||||
"BACKUP_COMPOSE_ENABLED",
|
||||
)
|
||||
|
||||
REMNASHOP_MIGRATION_SETTINGS = (
|
||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||
"MIGRATION_REMNASHOP_NOTES",
|
||||
)
|
||||
|
||||
ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
|
||||
"admin_tariffs_trial_title",
|
||||
"admin_tariffs_trial_subtitle",
|
||||
@@ -204,6 +211,27 @@ def test_backup_settings_i18n_keys_exist():
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_remnashop_migration_settings_i18n_keys_exist():
|
||||
manifest = _manifest_by_key()
|
||||
|
||||
for setting_key in REMNASHOP_MIGRATION_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
assert field["section"] == "migrations"
|
||||
assert field["section_order"] == 13
|
||||
assert field["subsection"] == "Remnashop"
|
||||
assert field["i18n_subsection_key"] == "admin_settings_subsection_remnashop"
|
||||
|
||||
for language in ("ru", "en"):
|
||||
messages = _locale(language)
|
||||
|
||||
assert "admin_settings_section_migrations" in messages
|
||||
assert "admin_settings_subsection_remnashop" in messages
|
||||
for setting_key in REMNASHOP_MIGRATION_SETTINGS:
|
||||
field = manifest[setting_key]
|
||||
assert field["i18n_label_key"] in messages
|
||||
assert field["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_backup_required_numeric_settings_reject_empty_values():
|
||||
with pytest.raises(ValueError):
|
||||
coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "")
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install.sh"
|
||||
|
||||
|
||||
def test_shell_installer_help_does_not_require_python():
|
||||
if not shutil.which("sh"):
|
||||
pytest.skip("sh is not available on this platform")
|
||||
|
||||
result = subprocess.run(
|
||||
["sh", str(INSTALL_SCRIPT), "--help"],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
assert "MINISHOP_INSTALL_REPO" in result.stdout
|
||||
assert "dry-run" in result.stdout
|
||||
assert "LEGACY_TGSHOP_SOURCE_DSN" in result.stdout
|
||||
|
||||
|
||||
def test_shell_installer_is_the_only_install_entrypoint():
|
||||
assert INSTALL_SCRIPT.exists()
|
||||
assert not (REPO_ROOT / "scripts" / "install.py").exists()
|
||||
|
||||
|
||||
def test_shell_installer_downloads_raw_files_and_runs_import_in_container():
|
||||
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert script.startswith("#!/bin/sh")
|
||||
assert "raw.githubusercontent.com" in script
|
||||
assert "git clone" not in script
|
||||
assert "backend python backend/scripts/import_legacy.py" in script
|
||||
assert "Optional source Remnashop .env path" in script
|
||||
assert "--source-env-file /tmp/remnashop.env" in script
|
||||
assert "--dry-run" in script
|
||||
assert "Install new stack and run migration" in script
|
||||
assert "Run migration only" in script
|
||||
|
||||
|
||||
def test_shell_installer_supports_legacy_tgshop_volume_and_dsn_paths():
|
||||
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert "Old remnawave-tg-shop" in script
|
||||
assert "remnawave-tg-shop-db-data" in script
|
||||
assert "remnawave-minishop-db-data" in script
|
||||
assert "pg_dump --clean --if-exists" in script
|
||||
assert "run_compose run --rm migrate" in script
|
||||
|
||||
|
||||
def test_shell_installer_only_prepares_data_mount_not_runtime_content():
|
||||
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert 'data_dir="$TARGET_DIR/data"' in script
|
||||
assert 'mkdir -p "$data_dir"' in script
|
||||
assert 'chown "$APP_UID:$APP_GID" "$data_dir"' in script
|
||||
assert "data_dir/themes" not in script
|
||||
assert "webapp-logo" not in script
|
||||
assert "webapp-emoji" not in script
|
||||
assert "locales-overrides.json" not in script
|
||||
|
||||
|
||||
def test_shell_installer_prints_remnashop_webhook_checklist():
|
||||
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert "remnashop_webhook_checklist" in script
|
||||
assert "Remnawave Panel -> WEBHOOK_URL" in script
|
||||
assert "PANEL_WEBHOOK_SECRET" in script
|
||||
assert "/webhook/panel" in script
|
||||
assert "/webhook/yookassa" in script
|
||||
assert "/webhook/wata" in script
|
||||
assert "/webhook/cryptopay" in script
|
||||
assert "/webhook/heleket" in script
|
||||
assert "/webhook/freekassa" in script
|
||||
assert "/webhook/platega" in script
|
||||
assert "/tg/webhook" in script
|
||||
@@ -1,25 +1,16 @@
|
||||
"""Pin facts that ``docs/migrations/remnawave-tg-shop.md`` and
|
||||
``scripts/migrate_to_minishop.sh`` rely on.
|
||||
|
||||
Both documents are written for a user upgrading from ``remnawave-tg-shop``
|
||||
(v2.7.0 era) to the current split-arch ``remnawave-minishop`` (v3.4+). They
|
||||
make concrete claims about:
|
||||
|
||||
* the set of container names produced by today's compose files;
|
||||
* the set of volume names produced by today's compose files;
|
||||
* the eras the migration script is allowed to stop containers from.
|
||||
|
||||
If any of these drift apart from reality, the migration document silently
|
||||
goes stale. These tests fail loudly instead.
|
||||
"""
|
||||
"""Pin facts shared by the migration docs and the unified install wizard."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnawave-tg-shop.md"
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh"
|
||||
REMNASHOP_DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnashop.md"
|
||||
INSTALL_SCRIPT_PATH = REPO_ROOT / "scripts" / "install.sh"
|
||||
REMOVED_SCRIPT_PATH = REPO_ROOT / "scripts" / "migrate_to_minishop.sh"
|
||||
COMPOSE_FILES = (
|
||||
REPO_ROOT / "docker-compose.yml",
|
||||
REPO_ROOT / "deploy" / "examples" / "caddy" / "docker-compose.yml",
|
||||
@@ -28,7 +19,6 @@ COMPOSE_FILES = (
|
||||
REPO_ROOT / "deploy" / "examples" / "no-proxy" / "docker-compose.yml",
|
||||
)
|
||||
|
||||
# Names that the current architecture must produce in at least one compose file.
|
||||
EXPECTED_CONTAINER_NAMES = {
|
||||
"remnawave-minishop-backend",
|
||||
"remnawave-minishop-worker",
|
||||
@@ -52,15 +42,27 @@ def _all_compose_text() -> str:
|
||||
return "\n".join(_read(path) for path in COMPOSE_FILES if path.is_file())
|
||||
|
||||
|
||||
def _known_containers_from_install_script() -> set[str]:
|
||||
text = _read(INSTALL_SCRIPT_PATH)
|
||||
match = re.search(r'^KNOWN_LEGACY_CONTAINERS="([^"]+)"', text, flags=re.MULTILINE)
|
||||
if not match:
|
||||
return set()
|
||||
return set(match.group(1).split())
|
||||
|
||||
|
||||
class MigrationDocumentationFactsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.doc = _read(DOC_PATH)
|
||||
self.script = _read(SCRIPT_PATH)
|
||||
self.script = _read(INSTALL_SCRIPT_PATH)
|
||||
self.compose = _all_compose_text()
|
||||
|
||||
def test_unified_install_script_is_the_only_migration_entrypoint(self):
|
||||
self.assertTrue(INSTALL_SCRIPT_PATH.is_file())
|
||||
self.assertFalse(REMOVED_SCRIPT_PATH.exists())
|
||||
self.assertIn("scripts/install.sh", self.doc)
|
||||
self.assertNotIn("migrate_to_minishop.sh", self.doc)
|
||||
|
||||
def test_doc_lists_every_running_container_in_current_compose(self):
|
||||
"""The architecture table must reflect what ``docker compose up``
|
||||
actually produces today."""
|
||||
missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc)
|
||||
self.assertFalse(
|
||||
missing,
|
||||
@@ -77,152 +79,94 @@ class MigrationDocumentationFactsTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_doc_warns_about_renamed_telegram_webhook_secret(self):
|
||||
# This is the single rename most likely to bite a v2.7.0 → HEAD user.
|
||||
self.assertIn("TELEGRAM_WEBHOOK_SECRET", self.doc)
|
||||
self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc)
|
||||
|
||||
def test_doc_says_webhook_base_url_is_required(self):
|
||||
# Polling mode was dropped — without WEBHOOK_BASE_URL the bot refuses
|
||||
# to start. A user migrating from v2.7.0 (where it was optional) must
|
||||
# be told this explicitly.
|
||||
block = self.doc.lower()
|
||||
self.assertIn("webhook_base_url", block)
|
||||
# "обязательна" is the marker text in the env-vars table.
|
||||
self.assertIn("обязательн", block)
|
||||
|
||||
def test_doc_mentions_migrate_one_shot_service(self):
|
||||
# The migrate sidecar is what makes schema migrations transparent on
|
||||
# the second-stage upgrade. Don't bury it.
|
||||
self.assertIn("migrate", self.doc)
|
||||
# "one-shot" or "разовый" / "однораз" text variants accepted.
|
||||
normalized = self.doc.lower()
|
||||
self.assertIn("migrate", normalized)
|
||||
self.assertTrue(
|
||||
"one-shot" in normalized or "однораз" in normalized,
|
||||
"migration doc must describe `migrate` as a one-shot service",
|
||||
)
|
||||
|
||||
def test_doc_mentions_postgres_host_compose_override_caveat(self):
|
||||
# Otherwise users follow the sed-fix step blindly and then panic
|
||||
# because their .env still has the "wrong" hostname under compose.
|
||||
self.assertIn("POSTGRES_HOST", self.doc)
|
||||
# Russian: "переопределя…" / "перебивает" indicate the override is documented.
|
||||
text = self.doc.lower()
|
||||
self.assertTrue(
|
||||
"переопредел" in text or "перебивает" in text,
|
||||
"переопредел" in text or "service name" in text,
|
||||
"migration doc must explain that compose overrides POSTGRES_HOST",
|
||||
)
|
||||
|
||||
def test_doc_describes_redis_data_and_shop_data_as_fresh(self):
|
||||
# We do not migrate redis-data or shop-data — make sure that's said
|
||||
# so users don't try to copy them from the old stack.
|
||||
text = self.doc.lower()
|
||||
self.assertIn("redis-data", text)
|
||||
self.assertIn("shop-data", text)
|
||||
# Some phrasing variant must say it's empty / fresh / new on purpose.
|
||||
self.assertTrue(
|
||||
any(marker in text for marker in ("пустым", "создаётся пуст", "новые,", "пуст —"))
|
||||
)
|
||||
self.assertTrue(any(marker in text for marker in ("пустым", "создается пуст")))
|
||||
|
||||
def test_doc_explains_reverse_proxy_no_longer_single_upstream(self):
|
||||
# The note "rename remnawave-tg-shop → remnawave-minishop in your
|
||||
# proxy config" used to be enough; after the split it's wrong.
|
||||
# Make sure both backend:8080 and frontend:80 are documented.
|
||||
self.assertIn("backend:8080", self.doc)
|
||||
self.assertIn("frontend:80", self.doc)
|
||||
|
||||
|
||||
def _known_containers_from_script() -> set[str]:
|
||||
"""Parse the literal ``KNOWN_CONTAINERS`` array from the script source.
|
||||
|
||||
The array is defined with ``${OLD_PREFIX}`` / ``${NEW_PREFIX}`` placeholders
|
||||
that we substitute here. Doing this statically (rather than sourcing the
|
||||
script in bash) avoids running ``main`` and keeps the test independent of
|
||||
a bash interpreter being available at runtime.
|
||||
"""
|
||||
text = _read(SCRIPT_PATH)
|
||||
|
||||
prefix_match = re.search(r'^OLD_PREFIX="([^"]+)"', text, flags=re.MULTILINE)
|
||||
new_match = re.search(r'^NEW_PREFIX="([^"]+)"', text, flags=re.MULTILINE)
|
||||
if not prefix_match or not new_match:
|
||||
return set()
|
||||
old_prefix = prefix_match.group(1)
|
||||
new_prefix = new_match.group(1)
|
||||
|
||||
# ``\n)`` as a boundary: a non-greedy ``.*?\)`` would stop at the first
|
||||
# close-paren in a comment like ``# (v2.7.0 upstream remnawave-tg-shop)``.
|
||||
array_match = re.search(
|
||||
r"KNOWN_CONTAINERS=\((.*?)\n\)",
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
if not array_match:
|
||||
return set()
|
||||
|
||||
body = array_match.group(1)
|
||||
# Strip line comments and quotes, then expand the two placeholders.
|
||||
names: set[str] = set()
|
||||
for raw_line in body.splitlines():
|
||||
line = raw_line.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
for token in re.findall(r'"([^"]+)"', line):
|
||||
expanded = token.replace("${OLD_PREFIX}", old_prefix).replace(
|
||||
"${NEW_PREFIX}", new_prefix
|
||||
)
|
||||
names.add(expanded)
|
||||
return names
|
||||
def test_doc_mentions_both_supported_tgshop_migration_methods(self):
|
||||
self.assertIn("Copy old Docker volumes", self.doc)
|
||||
self.assertIn("Dump from a source PostgreSQL DSN", self.doc)
|
||||
self.assertIn("pg_dump", self.doc)
|
||||
|
||||
|
||||
class MigrationScriptCoverageTests(unittest.TestCase):
|
||||
class InstallWizardCoverageTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.script = _read(SCRIPT_PATH)
|
||||
self.known = _known_containers_from_script()
|
||||
self.script = _read(INSTALL_SCRIPT_PATH)
|
||||
self.known = _known_containers_from_install_script()
|
||||
|
||||
def test_known_containers_covers_split_arch(self):
|
||||
"""A re-run on a partially migrated stack must be able to stop the new
|
||||
containers, otherwise ``docker compose up`` later fails with name
|
||||
conflicts."""
|
||||
missing = sorted(EXPECTED_CONTAINER_NAMES - self.known)
|
||||
self.assertFalse(
|
||||
missing,
|
||||
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\nactual: {sorted(self.known)}",
|
||||
f"KNOWN_LEGACY_CONTAINERS missing split-arch entries: {missing}",
|
||||
)
|
||||
|
||||
def test_known_containers_still_covers_legacy_eras(self):
|
||||
# We must also keep stopping the original (v2.7.0) and intermediate
|
||||
# (v3.1.x – v3.2.x) container names.
|
||||
def test_known_containers_still_covers_old_eras(self):
|
||||
for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"):
|
||||
with self.subTest(container=legacy):
|
||||
self.assertIn(legacy, self.known)
|
||||
|
||||
def test_script_is_syntactically_valid_bash(self):
|
||||
# The script is curl|bash'ed from raw.githubusercontent in the docs,
|
||||
# so a syntax break is a hard regression.
|
||||
import shutil
|
||||
import subprocess
|
||||
def test_installer_contains_tgshop_volume_and_dsn_paths(self):
|
||||
self.assertIn("run_tgshop_volume_migration", self.script)
|
||||
self.assertIn("run_tgshop_dsn_migration", self.script)
|
||||
self.assertIn("remnawave-tg-shop-db-data", self.script)
|
||||
self.assertIn("pg_dump --clean --if-exists", self.script)
|
||||
self.assertIn("run_compose run --rm migrate", self.script)
|
||||
|
||||
bash = shutil.which("bash")
|
||||
if not bash: # pragma: no cover
|
||||
self.skipTest("bash not available in PATH")
|
||||
def test_script_is_syntactically_valid_sh_and_bash(self):
|
||||
sh = shutil.which("sh")
|
||||
if not sh: # pragma: no cover
|
||||
self.skipTest("sh not available in PATH")
|
||||
result = subprocess.run(
|
||||
[bash, "-n", str(SCRIPT_PATH)],
|
||||
[sh, "-n", str(INSTALL_SCRIPT_PATH)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.returncode,
|
||||
0,
|
||||
(
|
||||
"bash -n flagged migrate_to_minishop.sh:\n"
|
||||
f"stdout={result.stdout}\nstderr={result.stderr}"
|
||||
),
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
bash = shutil.which("bash")
|
||||
if bash:
|
||||
result = subprocess.run(
|
||||
[bash, "-n", str(INSTALL_SCRIPT_PATH)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
class DocComposeFileReferencesTests(unittest.TestCase):
|
||||
"""The doc links the user to specific compose files — they must exist."""
|
||||
|
||||
def test_referenced_compose_files_exist(self):
|
||||
doc = _read(DOC_PATH)
|
||||
for relpath in (
|
||||
@@ -234,40 +178,47 @@ class DocComposeFileReferencesTests(unittest.TestCase):
|
||||
):
|
||||
with self.subTest(path=relpath):
|
||||
self.assertIn(relpath, doc)
|
||||
self.assertTrue(
|
||||
(REPO_ROOT / relpath).is_file(),
|
||||
f"{relpath} is referenced in migrations/remnawave-tg-shop.md "
|
||||
"but missing on disk",
|
||||
)
|
||||
self.assertTrue((REPO_ROOT / relpath).is_file())
|
||||
|
||||
def test_doc_references_migrator_module_path(self):
|
||||
# The doc tells users to expect ``backend/db/migrator.py`` migrations
|
||||
# to apply via the migrate service. If the file moves, the doc lies.
|
||||
doc = _read(DOC_PATH)
|
||||
self.assertIn("backend/db/migrator.py", doc)
|
||||
self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file())
|
||||
|
||||
|
||||
class MigrationFootprintRegexTests(unittest.TestCase):
|
||||
"""Spot-check that what compose actually defines matches what we documented."""
|
||||
class RemnashopMigrationDocumentationFactsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.doc = _read(REMNASHOP_DOC_PATH)
|
||||
|
||||
def test_doc_mentions_env_payment_gateways_and_encrypted_secrets(self):
|
||||
self.assertIn("--source-env-file", self.doc)
|
||||
self.assertIn("APP_CRYPT_KEY", self.doc)
|
||||
self.assertIn("payment_gateways", self.doc)
|
||||
self.assertIn("enc_", self.doc)
|
||||
|
||||
def test_doc_lists_new_webhook_paths_after_migration(self):
|
||||
for path in (
|
||||
"/webhook/panel",
|
||||
"/webhook/yookassa",
|
||||
"/webhook/wata",
|
||||
"/webhook/cryptopay",
|
||||
"/webhook/heleket",
|
||||
"/webhook/freekassa",
|
||||
"/webhook/platega",
|
||||
"/tg/webhook",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertIn(path, self.doc)
|
||||
|
||||
|
||||
class MigrationFootprintRegexTests(unittest.TestCase):
|
||||
def test_every_compose_volume_documented(self):
|
||||
"""If a future compose file introduces a new ``remnawave-minishop-*``
|
||||
named volume, the migration doc must call out whether it carries data
|
||||
from the old stack or starts fresh."""
|
||||
doc = _read(DOC_PATH)
|
||||
compose_text = _all_compose_text()
|
||||
# Find every named volume of the form ``remnawave-minishop-<key>-data``.
|
||||
defined = set(re.findall(r"remnawave-minishop-[\w-]+-data", compose_text))
|
||||
# Caddy-only volumes only ship in the caddy compose file but are still
|
||||
# documented; allow them either way.
|
||||
for volume in defined:
|
||||
with self.subTest(volume=volume):
|
||||
self.assertIn(
|
||||
volume,
|
||||
doc,
|
||||
f"new volume {volume} is defined in compose but missing from migration doc",
|
||||
)
|
||||
self.assertIn(volume, doc)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from scripts.import_legacy import (
|
||||
parse_remnashop_env_text,
|
||||
remnashop_env_overrides,
|
||||
remnashop_months_from_plan_snapshot,
|
||||
remnashop_payment_gateway_overrides,
|
||||
remnashop_post_migration_actions,
|
||||
remnashop_pricing_amount,
|
||||
remnashop_pricing_currency,
|
||||
remnashop_sale_mode,
|
||||
remnashop_traffic_gb_to_bytes,
|
||||
remnashop_transaction_status,
|
||||
)
|
||||
|
||||
|
||||
def test_remnashop_pricing_helpers_read_final_amount_and_currency():
|
||||
pricing = {"final_amount": "199.50", "currency": "rub"}
|
||||
|
||||
assert remnashop_pricing_amount(pricing) == 199.5
|
||||
assert remnashop_pricing_currency(pricing) == "RUB"
|
||||
|
||||
|
||||
def test_remnashop_traffic_limit_is_converted_from_gib():
|
||||
assert remnashop_traffic_gb_to_bytes(10) == 10 * 1024**3
|
||||
assert remnashop_traffic_gb_to_bytes(None) is None
|
||||
|
||||
|
||||
def test_remnashop_status_and_sale_mode_mapping_matches_current_payment_model():
|
||||
assert remnashop_transaction_status("COMPLETED", "YOOKASSA") == "succeeded"
|
||||
assert remnashop_transaction_status("PENDING", "WATA") == "pending_wata"
|
||||
assert remnashop_transaction_status("CANCELED", "WATA") == "canceled"
|
||||
assert remnashop_sale_mode("NEW") == "subscription"
|
||||
assert remnashop_sale_mode("RENEW") == "subscription"
|
||||
assert remnashop_sale_mode("CHANGE") == "tariff_upgrade"
|
||||
|
||||
|
||||
def test_remnashop_plan_months_prefers_snapshot_then_dates():
|
||||
assert remnashop_months_from_plan_snapshot({"duration_days": 90}) == 3
|
||||
assert remnashop_months_from_plan_snapshot({"months": 12}) == 12
|
||||
assert (
|
||||
remnashop_months_from_plan_snapshot(
|
||||
{},
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
expire_at=datetime(2026, 4, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
== 3
|
||||
)
|
||||
|
||||
|
||||
def test_remnashop_env_parser_and_overrides_map_safe_values():
|
||||
env = parse_remnashop_env_text(
|
||||
"""
|
||||
# old Remnashop
|
||||
export REMNAWAVE_HOST=panel.example.com
|
||||
REMNAWAVE_TOKEN='panel-token#kept'
|
||||
REMNAWAVE_WEBHOOK_SECRET="panel secret"
|
||||
BOT_SUPPORT_USERNAME=@support_bot # comment
|
||||
APP_DEFAULT_LOCALE=en
|
||||
BOT_MINI_APP=https://app.example.com/
|
||||
APP_DOMAIN=old.example.com
|
||||
"""
|
||||
)
|
||||
|
||||
assert env["REMNAWAVE_TOKEN"] == "panel-token#kept"
|
||||
assert env["BOT_SUPPORT_USERNAME"] == "@support_bot"
|
||||
|
||||
overrides = remnashop_env_overrides(env)
|
||||
assert overrides == {
|
||||
"PANEL_API_URL": "https://panel.example.com/api",
|
||||
"PANEL_API_KEY": "panel-token#kept",
|
||||
"PANEL_WEBHOOK_SECRET": "panel secret",
|
||||
"SUPPORT_LINK": "https://t.me/support_bot",
|
||||
"DEFAULT_LANGUAGE": "en",
|
||||
}
|
||||
|
||||
|
||||
def test_remnashop_env_overrides_skip_placeholders_and_mini_app():
|
||||
overrides = remnashop_env_overrides(
|
||||
{
|
||||
"REMNAWAVE_HOST": "change_me",
|
||||
"REMNAWAVE_TOKEN": "change_me",
|
||||
"REMNAWAVE_WEBHOOK_SECRET": "change_me",
|
||||
"BOT_SUPPORT_USERNAME": "change_me",
|
||||
"APP_DEFAULT_LOCALE": "change_me",
|
||||
"BOT_MINI_APP": "https://old-mini-app.example.com/",
|
||||
}
|
||||
)
|
||||
|
||||
assert overrides == {}
|
||||
|
||||
|
||||
def test_remnashop_yookassa_gateway_maps_to_current_provider_settings():
|
||||
result = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "YOOKASSA",
|
||||
"currency": "RUB",
|
||||
"is_active": True,
|
||||
"settings": {
|
||||
"shop_id": "shop-1",
|
||||
"api_key": "secret",
|
||||
"customer": "receipt@example.com",
|
||||
"vat_code": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["supported"] is True
|
||||
assert result["provider_ids"] == ["yookassa"]
|
||||
assert result["overrides"] == {
|
||||
"YOOKASSA_ENABLED": True,
|
||||
"YOOKASSA_SHOP_ID": "shop-1",
|
||||
"YOOKASSA_SECRET_KEY": "secret",
|
||||
"YOOKASSA_DEFAULT_RECEIPT_EMAIL": "receipt@example.com",
|
||||
"YOOKASSA_VAT_CODE": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_remnashop_free_kassa_and_platega_gateways_map_available_settings():
|
||||
freekassa = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "FREEKASSA",
|
||||
"is_active": True,
|
||||
"settings": {
|
||||
"shop_id": "merchant",
|
||||
"api_key": "api",
|
||||
"secret_word_2": "notify-secret",
|
||||
"payment_system_id": 42,
|
||||
"customer_ip": "203.0.113.10",
|
||||
"customer_email": "payer@example.com",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert freekassa["provider_ids"] == ["freekassa"]
|
||||
assert freekassa["overrides"]["FREEKASSA_SECOND_SECRET"] == "notify-secret"
|
||||
assert freekassa["overrides"]["FREEKASSA_PAYMENT_METHOD_ID"] == 42
|
||||
assert any("customer_email" in warning for warning in freekassa["warnings"])
|
||||
|
||||
platega = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "PLATEGA",
|
||||
"currency": "RUB",
|
||||
"is_active": True,
|
||||
"settings": {
|
||||
"merchant_id": "merchant",
|
||||
"api_key": "secret",
|
||||
"payment_method": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert platega["provider_ids"] == ["platega_sbp"]
|
||||
assert platega["overrides"]["PLATEGA_SBP_ENABLED"] is True
|
||||
assert platega["overrides"]["PLATEGA_SBP_METHOD"] == 2
|
||||
assert "PLATEGA_SUPPORTED_CURRENCIES" not in platega["overrides"]
|
||||
|
||||
|
||||
def test_remnashop_crypto_provider_currency_is_not_imported_as_setting_override():
|
||||
cryptopay = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "CRYPTOPAY",
|
||||
"currency": "USD",
|
||||
"is_active": True,
|
||||
"settings": {"api_key": "crypto-token"},
|
||||
}
|
||||
)
|
||||
assert cryptopay["overrides"] == {
|
||||
"CRYPTOPAY_ENABLED": True,
|
||||
"CRYPTOPAY_TOKEN": "crypto-token",
|
||||
}
|
||||
assert any("source currency was USD" in warning for warning in cryptopay["warnings"])
|
||||
|
||||
heleket = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "HELEKET",
|
||||
"currency": "USD",
|
||||
"is_active": True,
|
||||
"settings": {"merchant_id": "merchant", "api_key": "secret"},
|
||||
}
|
||||
)
|
||||
assert heleket["overrides"] == {
|
||||
"HELEKET_ENABLED": True,
|
||||
"HELEKET_MERCHANT_ID": "merchant",
|
||||
"HELEKET_API_KEY": "secret",
|
||||
}
|
||||
assert "HELEKET_CURRENCY" not in heleket["overrides"]
|
||||
|
||||
|
||||
def test_remnashop_unsupported_gateway_is_reported_without_overrides():
|
||||
result = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "ROBOKASSA",
|
||||
"is_active": True,
|
||||
"settings": {"merchant_login": "shop"},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["supported"] is False
|
||||
assert result["provider_ids"] == []
|
||||
assert result["overrides"] == {}
|
||||
|
||||
|
||||
def test_remnashop_encrypted_gateway_settings_need_app_crypt_key():
|
||||
result = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "WATA",
|
||||
"is_active": True,
|
||||
"settings": {"api_key": "enc_not-a-fernet-token"},
|
||||
}
|
||||
)
|
||||
|
||||
assert result["overrides"] == {"WATA_ENABLED": True}
|
||||
assert any("APP_CRYPT_KEY" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
def test_remnashop_encrypted_gateway_settings_decrypt_with_app_crypt_key():
|
||||
cryptography = pytest.importorskip("cryptography.fernet")
|
||||
key = cryptography.Fernet.generate_key().decode()
|
||||
token = cryptography.Fernet(key.encode()).encrypt(b"wata-token").decode()
|
||||
|
||||
result = remnashop_payment_gateway_overrides(
|
||||
{
|
||||
"type": "WATA",
|
||||
"is_active": True,
|
||||
"settings": {"api_key": f"enc_{token}"},
|
||||
},
|
||||
crypt_key=key,
|
||||
)
|
||||
|
||||
assert result["overrides"] == {
|
||||
"WATA_ENABLED": True,
|
||||
"WATA_API_TOKEN": "wata-token",
|
||||
}
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
def test_remnashop_post_migration_actions_include_new_webhook_urls():
|
||||
actions = remnashop_post_migration_actions(
|
||||
target_webhook_base_url="https://webhooks.example.com/",
|
||||
imported_provider_ids=["yookassa", "wata", "yookassa"],
|
||||
source_env={"APP_DOMAIN": "old.example.com"},
|
||||
)
|
||||
|
||||
assert actions["remnawave_panel"]["new_url"] == "https://webhooks.example.com/webhook/panel"
|
||||
assert actions["telegram"]["new_url"] == "https://webhooks.example.com/tg/webhook"
|
||||
assert actions["source_urls"]["payments"] == "https://old.example.com/api/v1/payments/<gateway>"
|
||||
assert actions["payment_providers"] == [
|
||||
{
|
||||
"provider": "yookassa",
|
||||
"new_url": "https://webhooks.example.com/webhook/yookassa",
|
||||
"where": "YooKassa merchant cabinet -> HTTP notifications URL",
|
||||
},
|
||||
{
|
||||
"provider": "wata",
|
||||
"new_url": "https://webhooks.example.com/webhook/wata",
|
||||
"where": "WATA merchant dashboard -> webhook/callback URL",
|
||||
},
|
||||
]
|
||||
@@ -1,5 +1,11 @@
|
||||
from db.migrator import MIGRATIONS
|
||||
from db.models import SupportTicket, SupportTicketMessage, User
|
||||
from db.models import (
|
||||
LegacyImportMapping,
|
||||
LegacyReferralCode,
|
||||
SupportTicket,
|
||||
SupportTicketMessage,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def test_support_migration_is_registered_after_existing_revisions():
|
||||
@@ -39,3 +45,18 @@ def test_trial_eligibility_reset_migration_and_model_are_registered():
|
||||
"0032_add_telegram_notification_status"
|
||||
)
|
||||
assert "trial_eligibility_reset_at" in User.__table__.columns
|
||||
|
||||
|
||||
def test_legacy_import_compatibility_migration_and_models_are_registered():
|
||||
ids = [migration.id for migration in MIGRATIONS]
|
||||
|
||||
assert "0034_add_legacy_import_compatibility" in ids
|
||||
assert ids.index("0034_add_legacy_import_compatibility") > ids.index(
|
||||
"0033_add_trial_eligibility_reset_marker"
|
||||
)
|
||||
assert User.__table__.columns["referral_code"].type.length == 64
|
||||
assert LegacyReferralCode.__tablename__ == "legacy_referral_codes"
|
||||
assert LegacyImportMapping.__tablename__ == "legacy_import_mappings"
|
||||
assert "uq_legacy_referral_source_code" in {
|
||||
constraint.name for constraint in LegacyReferralCode.__table__.constraints
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
self.assertIn("support_ticket_messages", update_tables)
|
||||
self.assertIn("email_verification_codes", delete_tables)
|
||||
self.assertIn("legacy_referral_codes", delete_tables)
|
||||
self.assertIn("legacy_import_mappings", delete_tables)
|
||||
session.delete.assert_awaited_once_with(user)
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
@@ -366,6 +368,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("payments", update_tables)
|
||||
self.assertIn("promo_code_activations", update_tables)
|
||||
self.assertIn("user_payment_methods", update_tables)
|
||||
self.assertIn("legacy_referral_codes", update_tables)
|
||||
self.assertIn("legacy_import_mappings", update_tables)
|
||||
self.assertIn("message_logs", update_tables)
|
||||
self.assertIn("users", update_tables)
|
||||
self.assertIn("user_payment_methods", delete_tables)
|
||||
|
||||
@@ -183,7 +183,10 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route",
|
||||
("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route",
|
||||
("GET", "/api/admin/logs"): "admin_logs_route",
|
||||
("GET", "/api/admin/broadcast/audience-counts"): "admin_broadcast_audience_counts_route",
|
||||
(
|
||||
"GET",
|
||||
"/api/admin/broadcast/audience-counts",
|
||||
): "admin_broadcast_audience_counts_route",
|
||||
("POST", "/api/admin/broadcast"): "admin_broadcast_route",
|
||||
("POST", "/api/admin/sync"): "admin_sync_route",
|
||||
("GET", "/api/admin/ads"): "admin_ads_list_route",
|
||||
|
||||
Reference in New Issue
Block a user