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-логина.
|
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
|
||||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
- [Миграция с 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
|
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||||
),
|
),
|
||||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
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 ─────────────────────────────────────────────
|
# ─── Notifications ─────────────────────────────────────────────
|
||||||
SettingField(
|
SettingField(
|
||||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||||
@@ -776,6 +808,7 @@ def manifest_payload() -> List[dict]:
|
|||||||
"devices": 10,
|
"devices": 10,
|
||||||
"subscription_guides": 10,
|
"subscription_guides": 10,
|
||||||
"system": 12,
|
"system": 12,
|
||||||
|
"migrations": 13,
|
||||||
}
|
}
|
||||||
exclusive_map = {
|
exclusive_map = {
|
||||||
key: opposite
|
key: opposite
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
|||||||
session,
|
session,
|
||||||
referral_param,
|
referral_param,
|
||||||
current_user_id=None,
|
current_user_id=None,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
db_user, _ = await user_dal.create_email_user(
|
db_user, _ = await user_dal.create_email_user(
|
||||||
session,
|
session,
|
||||||
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
|||||||
session,
|
session,
|
||||||
referral_param,
|
referral_param,
|
||||||
current_user_id=None,
|
current_user_id=None,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
db_user, _ = await user_dal.create_email_user(
|
db_user, _ = await user_dal.create_email_user(
|
||||||
session,
|
session,
|
||||||
@@ -1292,17 +1294,35 @@ async def _link_telegram_to_user(
|
|||||||
return current_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()
|
value = (raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return ""
|
||||||
|
|
||||||
value_lower = value.lower()
|
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:]
|
value = value[5:]
|
||||||
elif value_lower.startswith("ref_"):
|
elif value_lower.startswith("ref_"):
|
||||||
value = value[4:]
|
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:]
|
value = value[1:]
|
||||||
|
|
||||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||||
@@ -1310,27 +1330,65 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
|||||||
return value.upper()
|
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(
|
async def _resolve_referrer_id(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
raw_referral_param: Optional[str],
|
raw_referral_param: Optional[str],
|
||||||
*,
|
*,
|
||||||
current_user_id: Optional[int],
|
current_user_id: Optional[int],
|
||||||
|
settings: Optional[Settings] = None,
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
normalized = _normalize_referral_param(raw_referral_param)
|
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||||
if not normalized:
|
candidates = _referral_param_lookup_candidates(
|
||||||
|
raw_referral_param,
|
||||||
|
remnashop_compat=remnashop_compat,
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
for normalized in candidates:
|
||||||
ref_user = None
|
ref_user = None
|
||||||
if normalized.isdigit():
|
if normalized.isdigit() and not remnashop_compat:
|
||||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||||
if not ref_user:
|
if not ref_user:
|
||||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
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:
|
if not ref_user:
|
||||||
return None
|
continue
|
||||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||||
return None
|
continue
|
||||||
return int(ref_user.user_id)
|
return int(ref_user.user_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _apply_referral_to_existing_user(
|
async def _apply_referral_to_existing_user(
|
||||||
request: web.Request,
|
request: web.Request,
|
||||||
@@ -1345,6 +1403,7 @@ async def _apply_referral_to_existing_user(
|
|||||||
session,
|
session,
|
||||||
raw_referral_param,
|
raw_referral_param,
|
||||||
current_user_id=int(user.user_id),
|
current_user_id=int(user.user_id),
|
||||||
|
settings=request.app["settings"],
|
||||||
)
|
)
|
||||||
if not referred_by_id:
|
if not referred_by_id:
|
||||||
return False
|
return False
|
||||||
@@ -1427,6 +1486,7 @@ async def _ensure_user_from_telegram(
|
|||||||
session,
|
session,
|
||||||
referral_param or telegram_user.get("start_param"),
|
referral_param or telegram_user.get("start_param"),
|
||||||
current_user_id=user_id,
|
current_user_id=user_id,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
db_user, created = await user_dal.create_user(
|
db_user, created = await user_dal.create_user(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -41,6 +41,67 @@ from db.models import User
|
|||||||
router = Router(name="user_start_router")
|
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(
|
async def should_show_trial_button(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
@@ -412,14 +473,10 @@ async def ensure_required_channel_subscription(
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
|
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
|
||||||
@router.message(
|
@router.message(
|
||||||
CommandStart(
|
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
|
||||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
|
||||||
"ref_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"^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"^ticket_(\d+)$").as_("ticket_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_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:
|
if ref_match:
|
||||||
raw_ref_value = ref_match.group(1)
|
raw_ref_value = ref_match.group(1)
|
||||||
if raw_ref_value.isdigit():
|
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||||
if settings.LEGACY_REFS:
|
session,
|
||||||
potential_referrer_id = int(raw_ref_value)
|
raw_ref_value,
|
||||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
settings=settings,
|
||||||
session, potential_referrer_id
|
current_user_id=user_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
|
|
||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ class PromoCodeService:
|
|||||||
user_lang: str,
|
user_lang: str,
|
||||||
) -> Tuple[bool, datetime | str]:
|
) -> Tuple[bool, datetime | str]:
|
||||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
preserve_case = bool(
|
||||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
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_identifier = self._throttle_identifier(user_id)
|
||||||
|
|
||||||
throttle = await security_dal.check_throttle(
|
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(
|
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:
|
if not promo_data:
|
||||||
@@ -74,6 +78,8 @@ class PromoCodeService:
|
|||||||
)
|
)
|
||||||
return False, _("promo_code_not_found", code=code_display)
|
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(
|
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||||
session, promo_data.promo_code_id, user_id
|
session, promo_data.promo_code_id, user_id
|
||||||
)
|
)
|
||||||
@@ -86,7 +92,7 @@ class PromoCodeService:
|
|||||||
session=session,
|
session=session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
bonus_days=bonus_days,
|
bonus_days=bonus_days,
|
||||||
reason=f"promo code {code_input_upper}",
|
reason=f"promo code {applied_code}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_end_date:
|
if new_end_date:
|
||||||
@@ -109,7 +115,7 @@ class PromoCodeService:
|
|||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
await notification_service.notify_promo_activation(
|
await notification_service.notify_promo_activation(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
promo_code=code_input_upper,
|
promo_code=applied_code,
|
||||||
bonus_days=bonus_days,
|
bonus_days=bonus_days,
|
||||||
username=user.username if user else None,
|
username=user.username if user else None,
|
||||||
email=getattr(user, "email", None) if user else None,
|
email=getattr(user, "email", None) if user else None,
|
||||||
|
|||||||
@@ -290,6 +290,24 @@ class Settings(BaseSettings):
|
|||||||
default=True,
|
default=True,
|
||||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
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(
|
APP_RUNTIME_MODE: str = Field(
|
||||||
default="production",
|
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)
|
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)"""
|
"""Get promo code by code string (regardless of active status)"""
|
||||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||||
|
stmt = select(PromoCode).where(PromoCode.code == candidate)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
promo = result.scalar_one_or_none()
|
||||||
|
if promo:
|
||||||
|
return promo
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_active_promo_code_by_code_str(
|
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]:
|
) -> Optional[PromoCode]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||||
stmt = select(PromoCode).where(
|
stmt = select(PromoCode).where(
|
||||||
PromoCode.code == code_str.upper(),
|
PromoCode.code == candidate,
|
||||||
PromoCode.is_active == True,
|
PromoCode.is_active == True,
|
||||||
PromoCode.current_activations < PromoCode.max_activations,
|
PromoCode.current_activations < PromoCode.max_activations,
|
||||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
|
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
|
||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
promo = result.scalar_one_or_none()
|
||||||
|
if promo:
|
||||||
|
return promo
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_promo_codes(
|
async def get_all_active_promo_codes(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import string
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
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.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
@@ -14,6 +14,8 @@ from ..models import (
|
|||||||
AdAttribution,
|
AdAttribution,
|
||||||
EmailVerificationCode,
|
EmailVerificationCode,
|
||||||
HwidDevicePurchase,
|
HwidDevicePurchase,
|
||||||
|
LegacyImportMapping,
|
||||||
|
LegacyReferralCode,
|
||||||
MessageLog,
|
MessageLog,
|
||||||
Payment,
|
Payment,
|
||||||
PromoCodeActivation,
|
PromoCodeActivation,
|
||||||
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
|||||||
Returns the existing or newly generated code.
|
Returns the existing or newly generated code.
|
||||||
"""
|
"""
|
||||||
if user.referral_code:
|
if user.referral_code:
|
||||||
normalized = user.referral_code.strip().upper()
|
normalized = user.referral_code.strip()
|
||||||
if normalized != user.referral_code:
|
if normalized != user.referral_code:
|
||||||
user.referral_code = normalized
|
user.referral_code = normalized
|
||||||
await session.flush()
|
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"):
|
if not user_data.get("referral_code"):
|
||||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||||
else:
|
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
|
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -567,6 +569,19 @@ async def merge_users(
|
|||||||
await session.execute(
|
await session.execute(
|
||||||
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
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(
|
await session.execute(
|
||||||
update(MessageLog)
|
update(MessageLog)
|
||||||
@@ -590,13 +605,60 @@ async def merge_users(
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
async def get_user_by_referral_code(
|
||||||
normalized = referral_code.strip().upper()
|
session: AsyncSession,
|
||||||
|
referral_code: str,
|
||||||
|
*,
|
||||||
|
include_legacy: bool = False,
|
||||||
|
) -> Optional[User]:
|
||||||
|
normalized = referral_code.strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stmt = select(User).where(User.referral_code == normalized)
|
stmt = select(User).where(User.referral_code == normalized)
|
||||||
result = await session.execute(stmt)
|
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(
|
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(UserBilling).where(UserBilling.user_id == user_id))
|
||||||
await session.execute(delete(AdAttribution).where(AdAttribution.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(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(Payment).where(Payment.user_id == user_id))
|
||||||
await session.execute(delete(Subscription).where(Subscription.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] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
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",
|
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
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())
|
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
is_banned = Column(Boolean, default=False)
|
is_banned = Column(Boolean, default=False)
|
||||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
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)
|
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), 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):
|
class MessageLog(Base):
|
||||||
__tablename__ = "message_logs"
|
__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
|
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` поднимает:
|
Обычный `docker compose up -d --build` поднимает:
|
||||||
|
|
||||||
- `postgres` и `redis` с проверками здоровья;
|
- `postgres` и `redis` с проверками здоровья;
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
| Источник | Поддерживаемый случай | Документы |
|
| Источник | Поддерживаемый случай | Документы |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) |
|
| [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.
|
Для переноса со старого родственного стека используйте общий install wizard:
|
||||||
|
|
||||||
Автоматический скрипт ниже рассчитан именно на родственный стек `remnawave-tg-shop`, где структура БД и Docker volumes известны заранее. Для других ботов нужен отдельный адаптер экспорта/импорта.
|
|
||||||
|
|
||||||
## Короткий путь без смены ветки и сборки
|
|
||||||
|
|
||||||
Если вы используете только готовые Docker-образы и не собираете проект
|
|
||||||
локально, git-команды из ручного способа не нужны. Достаточно обновить
|
|
||||||
compose-файл до одного из готовых примеров в `deploy/examples` и
|
|
||||||
перенести/обновить БД. Самый прямой вариант без встроенного обратного прокси -
|
|
||||||
`deploy/examples/no-proxy/docker-compose.yml`; для Caddy, Nginx и Newt есть
|
|
||||||
такие же самостоятельные папки.
|
|
||||||
|
|
||||||
Минимальная последовательность:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh
|
||||||
|
sh install.sh
|
||||||
|
```
|
||||||
|
|
||||||
# Скопируйте старый .env в выбранную папку примера и обновите значения там.
|
В меню выберите `Install new stack and run migration` для нового
|
||||||
cp .env deploy/examples/no-proxy/.env
|
сервера или `Run migration only`, если compose-папка уже готова. Затем
|
||||||
nano deploy/examples/no-proxy/.env
|
выберите источник `Old remnawave-tg-shop`.
|
||||||
|
|
||||||
# Подготовьте стек из готовых образов.
|
Wizard поддерживает два способа переноса:
|
||||||
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.
|
- `Copy old Docker volumes` - для старого compose-стека на том же Docker host.
|
||||||
# Если у вас уже есть remnawave-minishop-db-data, этот шаг пропустите.
|
Скрипт подготавливает новый stack, копирует
|
||||||
docker run --rm \
|
`remnawave-tg-shop-db-data` в `remnawave-minishop-db-data`, опционально
|
||||||
|
переносит Caddy volumes и запускает новый stack.
|
||||||
|
- `Dump from a source PostgreSQL DSN` - для старой БД, доступной по DSN.
|
||||||
|
Скрипт поднимает целевой `postgres`, сбрасывает целевую БД, делает
|
||||||
|
`pg_dump` из старой БД, восстанавливает дамп в compose-БД и запускает
|
||||||
|
сервис `migrate`.
|
||||||
|
|
||||||
|
В обоих режимах старые volumes и старая БД не удаляются автоматически.
|
||||||
|
|
||||||
|
## Как работает перенос
|
||||||
|
|
||||||
|
`remnawave-tg-shop` и `remnawave-minishop` имеют совместимую историю схемы.
|
||||||
|
После переноса старой PostgreSQL-БД сервис `migrate` накатывает недостающие
|
||||||
|
миграции из `backend/db/migrator.py`: сначала применяются `Base.metadata`,
|
||||||
|
затем последовательные записи `schema_migrations`. Это one-shot сервис: он
|
||||||
|
должен завершиться с кодом `0`, после чего стартуют `backend` и `worker`.
|
||||||
|
|
||||||
|
При volume-миграции wizard:
|
||||||
|
|
||||||
|
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-tg-shop-db-data:/from:ro \
|
||||||
-v remnawave-minishop-db-data:/to \
|
-v remnawave-minishop-db-data:/to \
|
||||||
alpine sh -c "cd /from && cp -a . /to"
|
alpine sh -c "cd /from && cp -a . /to"
|
||||||
|
```
|
||||||
|
|
||||||
IMAGE_TAG=3.4.0 docker compose \
|
4. Если старые Caddy volumes существуют, переносит
|
||||||
--env-file deploy/examples/no-proxy/.env \
|
`remnawave-tg-shop-caddy-data` -> `remnawave-minishop-caddy-data` и
|
||||||
-f deploy/examples/no-proxy/docker-compose.yml \
|
`remnawave-tg-shop-caddy-config` -> `remnawave-minishop-caddy-config`.
|
||||||
up -d
|
5. Запускает новый stack через Docker Compose.
|
||||||
docker compose \
|
|
||||||
--env-file deploy/examples/no-proxy/.env \
|
|
||||||
-f deploy/examples/no-proxy/docker-compose.yml \
|
|
||||||
logs migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
Сервис `migrate` сам применит недостающие схемные миграции к перенесённому
|
Если целевой DB volume уже непустой, wizard не перетирает его молча: он
|
||||||
тому PostgreSQL. Новые тома `remnawave-minishop-redis-data` и
|
останавливается и просит отдельное подтверждение на продолжение без копирования
|
||||||
`remnawave-minishop-shop-data` переносить не нужно: они создаются пустыми.
|
старой БД.
|
||||||
|
|
||||||
Этот документ описывает обновление стека, поднятого по `remnawave-tg-shop`
|
|
||||||
(включая последний релиз `v2.7.0` форка `kavore/remnawave-tg-shop`), до
|
|
||||||
текущей версии `remnawave-minishop` (v3.4+). Между этими версиями произошли
|
|
||||||
две независимые перетряски, и скрипт пытается отработать обе одной командой:
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
После миграции `docker compose ps` должен показать как минимум: `backend`,
|
|
||||||
`worker`, `frontend`, `postgres`, `redis` (running) и `migrate` (exited 0).
|
|
||||||
Логи: `docker compose logs -f backend worker frontend`.
|
|
||||||
|
|
||||||
Доступные пути:
|
|
||||||
|
|
||||||
- [Автоматический](#автоматический-способ-через-скрипт) — скрипт-обёртка
|
|
||||||
останавливает старый стек, накатывает свежий код, переносит том БД,
|
|
||||||
поднимает новые сервисы. Идемпотентный.
|
|
||||||
- [Ручной](#ручной-способ) — те же шаги командами, для тех, кому нужно
|
|
||||||
понимать каждое действие или выполнить выборочно.
|
|
||||||
|
|
||||||
В обоих случаях:
|
|
||||||
|
|
||||||
- старые тома **не удаляются** автоматически — это безопасный бэкап на случай
|
|
||||||
отката;
|
|
||||||
- сертификаты Caddy (если используется `deploy/examples/caddy/docker-compose.yml`)
|
|
||||||
тоже переносятся, чтобы Let's Encrypt не выписывал их заново и не упереться
|
|
||||||
в rate limit;
|
|
||||||
- схема БД обновляется автоматически: при первом `docker compose up -d` сервис
|
|
||||||
`migrate` накатывает на перенесённый том все недостающие миграции (от
|
|
||||||
alembic-схемы v2.7.0 до текущей).
|
|
||||||
|
|
||||||
## Что меняется в архитектуре
|
## Что меняется в архитектуре
|
||||||
|
|
||||||
@@ -93,256 +63,55 @@ docker compose \
|
|||||||
| Версия | Сервисы |
|
| Версия | Сервисы |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` |
|
| `v2.7.0` | `remnawave-tg-shop`, `remnawave-tg-shop-db` |
|
||||||
| `v3.1.x–v3.3.x` | `remnawave-minishop`, `remnawave-minishop-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` |
|
| `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) ниже.
|
|
||||||
|
|
||||||
**Volumes**:
|
**Volumes**:
|
||||||
|
|
||||||
| Volume | v2.7.0 | v3.4+ | Что внутри |
|
| Volume | Что происходит |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- |
|
||||||
| `remnawave-minishop-db-data` | переименовать из `remnawave-tg-shop-db-data` | переносится скриптом | PostgreSQL |
|
| `remnawave-minishop-db-data` | переносится из `remnawave-tg-shop-db-data` или восстанавливается из source DSN |
|
||||||
| `remnawave-minishop-redis-data` | — | создаётся пустым | Redis (FSM, rate-limit, cache, очередь вебхуков, distributed locks) |
|
| `remnawave-minishop-redis-data` | создается пустым |
|
||||||
| `remnawave-minishop-shop-data` | — | создаётся пустым | `/app/data`: `tariffs.json`, темы Web App, кэш логотипа/emoji |
|
| `remnawave-minishop-shop-data` | создается пустым; runtime-файлы в `/app/data` дальше настраиваются через админку или вручную |
|
||||||
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переименовать из `remnawave-tg-shop-caddy-*` | переносится скриптом | только при Caddy-варианте |
|
| `remnawave-minishop-caddy-data` / `remnawave-minishop-caddy-config` | переносятся из `remnawave-tg-shop-caddy-*`, если старый стек использовал Caddy |
|
||||||
|
|
||||||
`redis-data` и `shop-data` стартуют пустыми — это нормально. Redis ничего
|
Доступные compose-профили: `docker-compose.yml`,
|
||||||
долгоживущего не хранит (всё либо FSM, либо кеш с TTL), а `data/` инициализируется
|
`deploy/examples/caddy/docker-compose.yml`,
|
||||||
из образа при первом старте (`tariffs.json` пуст пока вы не сконфигурируете
|
`deploy/examples/nginx/docker-compose.yml`,
|
||||||
тарифы через админ-панель).
|
`deploy/examples/newt/docker-compose.yml`,
|
||||||
|
`deploy/examples/no-proxy/docker-compose.yml`.
|
||||||
|
|
||||||
## Переменные окружения, которые могли исчезнуть или переехать
|
## Переменные окружения
|
||||||
|
|
||||||
Перед запуском нового стека проверьте `.env`. Ниже — только то, что точно
|
Перед запуском нового стека проверьте `.env`. Самые важные изменения:
|
||||||
менялось между v2.7.0 и v3.4+:
|
|
||||||
|
|
||||||
| Было (v2.7.0) | Стало (v3.4+) | Действие |
|
| Было | Стало | Действие |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Переименовать. Если пусто — будет сгенерирован при старте, но тогда Telegram переустановит webhook (на это не реагирует существующий запрос). |
|
| `TELEGRAM_WEBHOOK_SECRET` | `WEBHOOK_SECRET_TOKEN` | Перенести значение или сгенерировать новый stable secret. |
|
||||||
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь генерируется из `BOT_TOKEN` автоматически. |
|
| `TELEGRAM_WEBHOOK_PATH` | удалена | Путь вебхука теперь рассчитывается автоматически. |
|
||||||
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается автоматически, как только задан `REQUIRED_CHANNEL_ID`. |
|
| `REQUIRED_CHANNEL_SUBSCRIBE_TO_USE` | удалена | Гейт включается, когда задан `REQUIRED_CHANNEL_ID`. |
|
||||||
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars (XTR) используются напрямую. |
|
| `STARS_PROVIDER_TOKEN` | удалена | Telegram Stars используются напрямую. |
|
||||||
| `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` | `postgres` внутри Compose | В compose-файлах `POSTGRES_HOST` переопределяется service name `postgres`. |
|
||||||
| `POSTGRES_HOST=remnawave-tg-shop-db` | в `.env` — `remnawave-minishop-db` или пусто | Под compose значение всё равно переопределяется на сервисное имя `postgres` (см. `environment:` в compose-файлах), поэтому скрипт правит `.env` только для bare-metal сценариев. |
|
| `WEBHOOK_BASE_URL` | обязательна | Без публичного URL backend не стартует корректно. |
|
||||||
| `WEBHOOK_BASE_URL` | **обязательна** | Polling-режим удалён, без публичного URL бот не стартует. |
|
| - | `REDIS_URL=redis://redis:6379/0` | В compose-профилях задано автоматически. |
|
||||||
| — | `REDIS_URL=redis://redis:6379/0` | Обязательна для воркера, очередей и rate-limit. По умолчанию в compose-файлах уже задана. |
|
| - | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App и каталога тарифов. |
|
||||||
| — | `WEBAPP_SESSION_SECRET`, `WEBAPP_ENABLED`, `WEBAPP_SERVER_PORT`, `WEBAPP_THEMES_DIR`, `TARIFFS_CONFIG_PATH` | Новые настройки Web App / тарифного каталога. Безопасные дефолты есть в `.env.example`. |
|
|
||||||
|
|
||||||
Полный референс — [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
|
| Назначение | Service | Port |
|
||||||
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-имя сервиса | Порт |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Telegram / платежные / вебхуки панели | `backend` | `8080` |
|
| Telegram, платежные и panel webhooks | `backend` | `8080` |
|
||||||
| Health-чек | `backend` | `8080` (`/healthz`) |
|
| Health-check | `backend` | `8080` (`/healthz`) |
|
||||||
| Web App API (`/api/*`, `/auth/*`, ассеты тем и логотипов) | `backend` | `8081` (доступен только из Docker-сети) |
|
| Web App API и auth | `backend` | `8081` внутри Docker-сети |
|
||||||
| Статический фронт Web App | `frontend` | `80` (внутри `frontend` уже проксирует `/api/*` и `/auth/*` на `backend:8081`) |
|
| Статический Web App frontend | `frontend` | `80` |
|
||||||
|
|
||||||
Минимальная замена для внешнего Nginx, который раньше слал всё на один
|
Минимальная схема для внешнего Nginx:
|
||||||
upstream:
|
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
upstream remnawave_backend_webhooks { server backend:8080; }
|
upstream remnawave_backend_webhooks { server backend:8080; }
|
||||||
@@ -351,8 +120,6 @@ upstream remnawave_frontend { server frontend:80; }
|
|||||||
server {
|
server {
|
||||||
server_name app.domain.com;
|
server_name app.domain.com;
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
|
||||||
# ssl_certificate / ssl_certificate_key — без изменений
|
|
||||||
|
|
||||||
location /webhook/ { proxy_pass http://remnawave_backend_webhooks; }
|
location /webhook/ { proxy_pass http://remnawave_backend_webhooks; }
|
||||||
location /healthz { proxy_pass http://remnawave_backend_webhooks; }
|
location /healthz { proxy_pass http://remnawave_backend_webhooks; }
|
||||||
@@ -360,24 +127,25 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Полные примеры (Caddy, Nginx, Newt/Pangolin и запуск без обратного прокси) — в
|
Готовые Caddy, Nginx, Pangolin/Newt и no-proxy профили уже содержат нужную
|
||||||
[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
|
|
||||||
пример, который уже знает правильную маршрутизацию.
|
|
||||||
|
|
||||||
## Если что-то пошло не так
|
## Проверка
|
||||||
|
|
||||||
`migrate` упал → читайте `docker compose logs migrate`. Том БД остался
|
После переноса:
|
||||||
не тронут, можно откатиться, переключив compose-файл обратно на старый
|
|
||||||
коммит и подняв старый стек на старом томе `remnawave-tg-shop-db-data`
|
|
||||||
(пока вы его не удалили).
|
|
||||||
|
|
||||||
`backend` не стартует → чаще всего `WEBHOOK_BASE_URL` пуст, либо
|
```bash
|
||||||
`WEBHOOK_SECRET_TOKEN` отличается от того, что Telegram ждёт. Поставьте
|
docker compose ps
|
||||||
свежий секрет в `.env` и перезапустите — Telegram переустановит webhook
|
docker compose logs migrate
|
||||||
автоматически.
|
docker compose logs -f backend worker frontend
|
||||||
|
```
|
||||||
|
|
||||||
Web App пуст / 502 → проверьте, что `frontend` живёт (`docker compose ps`),
|
`migrate` должен завершиться успешно, а `backend`, `worker`, `frontend`,
|
||||||
а внешний прокси шлёт на `frontend:80`, а не на старый
|
`postgres` и `redis` должны быть running/healthy.
|
||||||
`remnawave-tg-shop:8000`.
|
|
||||||
|
Когда убедитесь, что новый 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: "Устройства",
|
devices: "Устройства",
|
||||||
subscription_guides: "Connection guides",
|
subscription_guides: "Connection guides",
|
||||||
system: "Система",
|
system: "Система",
|
||||||
|
migrations: "Миграции",
|
||||||
};
|
};
|
||||||
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
return adminText(`settings_section_${id}`, {}, map[id] || id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,9 +571,7 @@
|
|||||||
{#each openedUserDetail.subscriptions.slice(0, 8) as sub}
|
{#each openedUserDetail.subscriptions.slice(0, 8) as sub}
|
||||||
<div class="admin-mini-list-row">
|
<div class="admin-mini-list-row">
|
||||||
<div>
|
<div>
|
||||||
<strong
|
<strong>{subscriptionDisplayLabel(sub)}</strong>
|
||||||
>{subscriptionDisplayLabel(sub)}</strong
|
|
||||||
>
|
|
||||||
<small
|
<small
|
||||||
>{at(
|
>{at(
|
||||||
"user_history_until",
|
"user_history_until",
|
||||||
|
|||||||
@@ -1110,11 +1110,13 @@
|
|||||||
"admin_settings_section_devices": "Devices",
|
"admin_settings_section_devices": "Devices",
|
||||||
"admin_settings_section_support": "Support",
|
"admin_settings_section_support": "Support",
|
||||||
"admin_settings_section_system": "System",
|
"admin_settings_section_system": "System",
|
||||||
|
"admin_settings_section_migrations": "Migrations",
|
||||||
"admin_settings_field_telemetry_enabled_label": "Anonymous install analytics",
|
"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_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_common": "Common",
|
||||||
"admin_settings_subsection_checkout": "Checkout",
|
"admin_settings_subsection_checkout": "Checkout",
|
||||||
"admin_settings_subsection_remnawave": "Remnawave",
|
"admin_settings_subsection_remnawave": "Remnawave",
|
||||||
|
"admin_settings_subsection_remnashop": "Remnashop",
|
||||||
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
||||||
"admin_settings_subsection_yookassa": "YooKassa",
|
"admin_settings_subsection_yookassa": "YooKassa",
|
||||||
"admin_settings_subsection_freekassa": "FreeKassa",
|
"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_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_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_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_validation_errors": "Errors: {errors}",
|
||||||
"admin_settings_save_error": "Error: {error}",
|
"admin_settings_save_error": "Error: {error}",
|
||||||
"admin_sync_started": "Synchronization started",
|
"admin_sync_started": "Synchronization started",
|
||||||
|
|||||||
@@ -1110,11 +1110,13 @@
|
|||||||
"admin_settings_section_devices": "Устройства",
|
"admin_settings_section_devices": "Устройства",
|
||||||
"admin_settings_section_support": "Поддержка",
|
"admin_settings_section_support": "Поддержка",
|
||||||
"admin_settings_section_system": "Система",
|
"admin_settings_section_system": "Система",
|
||||||
|
"admin_settings_section_migrations": "Миграции",
|
||||||
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
"admin_settings_field_telemetry_enabled_label": "Анонимная статистика установки",
|
||||||
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
|
"admin_settings_field_telemetry_enabled_description": "Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число пользователей в виде диапазона. Без персональных данных, токенов и доменов. Помогает оценить число активных установок и используемые версии. Отключение применяется без перезапуска.",
|
||||||
"admin_settings_subsection_common": "Общие",
|
"admin_settings_subsection_common": "Общие",
|
||||||
"admin_settings_subsection_checkout": "Оформление оплаты",
|
"admin_settings_subsection_checkout": "Оформление оплаты",
|
||||||
"admin_settings_subsection_remnawave": "Remnawave",
|
"admin_settings_subsection_remnawave": "Remnawave",
|
||||||
|
"admin_settings_subsection_remnashop": "Remnashop",
|
||||||
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
"admin_settings_subsection_telegram_stars": "Telegram Stars",
|
||||||
"admin_settings_subsection_yookassa": "YooKassa",
|
"admin_settings_subsection_yookassa": "YooKassa",
|
||||||
"admin_settings_subsection_freekassa": "FreeKassa",
|
"admin_settings_subsection_freekassa": "FreeKassa",
|
||||||
@@ -1129,6 +1131,14 @@
|
|||||||
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
"admin_settings_provider_webhook_base_missing": "Укажите WEBHOOK_BASE_URL в .env, чтобы увидеть полный адрес для {path}.",
|
||||||
"admin_settings_provider_admin_only_label": "Только для админов",
|
"admin_settings_provider_admin_only_label": "Только для админов",
|
||||||
"admin_settings_provider_admin_only_description": "Показывает провайдер только администраторам. Вебхуки и обработка статусов остаются активными для тестовых платежей.",
|
"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_validation_errors": "Ошибки: {errors}",
|
||||||
"admin_settings_save_error": "Ошибка: {error}",
|
"admin_settings_save_error": "Ошибка: {error}",
|
||||||
"admin_sync_started": "Синхронизация запущена",
|
"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",
|
"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_TARIFF_SETTINGS_PAGE_KEYS = {
|
||||||
"admin_tariffs_trial_title",
|
"admin_tariffs_trial_title",
|
||||||
"admin_tariffs_trial_subtitle",
|
"admin_tariffs_trial_subtitle",
|
||||||
@@ -204,6 +211,27 @@ def test_backup_settings_i18n_keys_exist():
|
|||||||
assert field["i18n_description_key"] in messages
|
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():
|
def test_backup_required_numeric_settings_reject_empty_values():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
coerce_value(get_field_by_key("BACKUP_INTERVAL_SECONDS"), "")
|
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
|
"""Pin facts shared by the migration docs and the unified install wizard."""
|
||||||
``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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
DOC_PATH = REPO_ROOT / "docs" / "migrations" / "remnawave-tg-shop.md"
|
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 = (
|
COMPOSE_FILES = (
|
||||||
REPO_ROOT / "docker-compose.yml",
|
REPO_ROOT / "docker-compose.yml",
|
||||||
REPO_ROOT / "deploy" / "examples" / "caddy" / "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",
|
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 = {
|
EXPECTED_CONTAINER_NAMES = {
|
||||||
"remnawave-minishop-backend",
|
"remnawave-minishop-backend",
|
||||||
"remnawave-minishop-worker",
|
"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())
|
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):
|
class MigrationDocumentationFactsTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.doc = _read(DOC_PATH)
|
self.doc = _read(DOC_PATH)
|
||||||
self.script = _read(SCRIPT_PATH)
|
self.script = _read(INSTALL_SCRIPT_PATH)
|
||||||
self.compose = _all_compose_text()
|
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):
|
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)
|
missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
missing,
|
missing,
|
||||||
@@ -77,152 +79,94 @@ class MigrationDocumentationFactsTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_doc_warns_about_renamed_telegram_webhook_secret(self):
|
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("TELEGRAM_WEBHOOK_SECRET", self.doc)
|
||||||
self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc)
|
self.assertIn("WEBHOOK_SECRET_TOKEN", self.doc)
|
||||||
|
|
||||||
def test_doc_says_webhook_base_url_is_required(self):
|
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()
|
block = self.doc.lower()
|
||||||
self.assertIn("webhook_base_url", block)
|
self.assertIn("webhook_base_url", block)
|
||||||
# "обязательна" is the marker text in the env-vars table.
|
|
||||||
self.assertIn("обязательн", block)
|
self.assertIn("обязательн", block)
|
||||||
|
|
||||||
def test_doc_mentions_migrate_one_shot_service(self):
|
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()
|
normalized = self.doc.lower()
|
||||||
|
self.assertIn("migrate", normalized)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
"one-shot" in normalized or "однораз" in normalized,
|
"one-shot" in normalized or "однораз" in normalized,
|
||||||
"migration doc must describe `migrate` as a one-shot service",
|
"migration doc must describe `migrate` as a one-shot service",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_doc_mentions_postgres_host_compose_override_caveat(self):
|
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)
|
self.assertIn("POSTGRES_HOST", self.doc)
|
||||||
# Russian: "переопределя…" / "перебивает" indicate the override is documented.
|
|
||||||
text = self.doc.lower()
|
text = self.doc.lower()
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
"переопредел" in text or "перебивает" in text,
|
"переопредел" in text or "service name" in text,
|
||||||
"migration doc must explain that compose overrides POSTGRES_HOST",
|
"migration doc must explain that compose overrides POSTGRES_HOST",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_doc_describes_redis_data_and_shop_data_as_fresh(self):
|
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()
|
text = self.doc.lower()
|
||||||
self.assertIn("redis-data", text)
|
self.assertIn("redis-data", text)
|
||||||
self.assertIn("shop-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):
|
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("backend:8080", self.doc)
|
||||||
self.assertIn("frontend:80", self.doc)
|
self.assertIn("frontend:80", self.doc)
|
||||||
|
|
||||||
|
def test_doc_mentions_both_supported_tgshop_migration_methods(self):
|
||||||
def _known_containers_from_script() -> set[str]:
|
self.assertIn("Copy old Docker volumes", self.doc)
|
||||||
"""Parse the literal ``KNOWN_CONTAINERS`` array from the script source.
|
self.assertIn("Dump from a source PostgreSQL DSN", self.doc)
|
||||||
|
self.assertIn("pg_dump", self.doc)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationScriptCoverageTests(unittest.TestCase):
|
class InstallWizardCoverageTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.script = _read(SCRIPT_PATH)
|
self.script = _read(INSTALL_SCRIPT_PATH)
|
||||||
self.known = _known_containers_from_script()
|
self.known = _known_containers_from_install_script()
|
||||||
|
|
||||||
def test_known_containers_covers_split_arch(self):
|
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)
|
missing = sorted(EXPECTED_CONTAINER_NAMES - self.known)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
missing,
|
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):
|
def test_known_containers_still_covers_old_eras(self):
|
||||||
# We must also keep stopping the original (v2.7.0) and intermediate
|
|
||||||
# (v3.1.x – v3.2.x) container names.
|
|
||||||
for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"):
|
for legacy in ("remnawave-tg-shop", "remnawave-tg-shop-db", "remnawave-minishop-db"):
|
||||||
with self.subTest(container=legacy):
|
with self.subTest(container=legacy):
|
||||||
self.assertIn(legacy, self.known)
|
self.assertIn(legacy, self.known)
|
||||||
|
|
||||||
def test_script_is_syntactically_valid_bash(self):
|
def test_installer_contains_tgshop_volume_and_dsn_paths(self):
|
||||||
# The script is curl|bash'ed from raw.githubusercontent in the docs,
|
self.assertIn("run_tgshop_volume_migration", self.script)
|
||||||
# so a syntax break is a hard regression.
|
self.assertIn("run_tgshop_dsn_migration", self.script)
|
||||||
import shutil
|
self.assertIn("remnawave-tg-shop-db-data", self.script)
|
||||||
import subprocess
|
self.assertIn("pg_dump --clean --if-exists", self.script)
|
||||||
|
self.assertIn("run_compose run --rm migrate", self.script)
|
||||||
|
|
||||||
bash = shutil.which("bash")
|
def test_script_is_syntactically_valid_sh_and_bash(self):
|
||||||
if not bash: # pragma: no cover
|
sh = shutil.which("sh")
|
||||||
self.skipTest("bash not available in PATH")
|
if not sh: # pragma: no cover
|
||||||
|
self.skipTest("sh not available in PATH")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[bash, "-n", str(SCRIPT_PATH)],
|
[sh, "-n", str(INSTALL_SCRIPT_PATH)],
|
||||||
check=False,
|
check=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
result.returncode,
|
|
||||||
0,
|
bash = shutil.which("bash")
|
||||||
(
|
if bash:
|
||||||
"bash -n flagged migrate_to_minishop.sh:\n"
|
result = subprocess.run(
|
||||||
f"stdout={result.stdout}\nstderr={result.stderr}"
|
[bash, "-n", str(INSTALL_SCRIPT_PATH)],
|
||||||
),
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
|
||||||
class DocComposeFileReferencesTests(unittest.TestCase):
|
class DocComposeFileReferencesTests(unittest.TestCase):
|
||||||
"""The doc links the user to specific compose files — they must exist."""
|
|
||||||
|
|
||||||
def test_referenced_compose_files_exist(self):
|
def test_referenced_compose_files_exist(self):
|
||||||
doc = _read(DOC_PATH)
|
doc = _read(DOC_PATH)
|
||||||
for relpath in (
|
for relpath in (
|
||||||
@@ -234,40 +178,47 @@ class DocComposeFileReferencesTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
with self.subTest(path=relpath):
|
with self.subTest(path=relpath):
|
||||||
self.assertIn(relpath, doc)
|
self.assertIn(relpath, doc)
|
||||||
self.assertTrue(
|
self.assertTrue((REPO_ROOT / relpath).is_file())
|
||||||
(REPO_ROOT / relpath).is_file(),
|
|
||||||
f"{relpath} is referenced in migrations/remnawave-tg-shop.md "
|
|
||||||
"but missing on disk",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_doc_references_migrator_module_path(self):
|
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)
|
doc = _read(DOC_PATH)
|
||||||
self.assertIn("backend/db/migrator.py", doc)
|
self.assertIn("backend/db/migrator.py", doc)
|
||||||
self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file())
|
self.assertTrue((REPO_ROOT / "backend" / "db" / "migrator.py").is_file())
|
||||||
|
|
||||||
|
|
||||||
class MigrationFootprintRegexTests(unittest.TestCase):
|
class RemnashopMigrationDocumentationFactsTests(unittest.TestCase):
|
||||||
"""Spot-check that what compose actually defines matches what we documented."""
|
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):
|
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)
|
doc = _read(DOC_PATH)
|
||||||
compose_text = _all_compose_text()
|
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))
|
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:
|
for volume in defined:
|
||||||
with self.subTest(volume=volume):
|
with self.subTest(volume=volume):
|
||||||
self.assertIn(
|
self.assertIn(volume, doc)
|
||||||
volume,
|
|
||||||
doc,
|
|
||||||
f"new volume {volume} is defined in compose but missing from migration doc",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
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.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():
|
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"
|
"0032_add_telegram_notification_status"
|
||||||
)
|
)
|
||||||
assert "trial_eligibility_reset_at" in User.__table__.columns
|
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("support_ticket_messages", update_tables)
|
||||||
self.assertIn("email_verification_codes", delete_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.delete.assert_awaited_once_with(user)
|
||||||
session.flush.assert_awaited_once()
|
session.flush.assert_awaited_once()
|
||||||
|
|
||||||
@@ -366,6 +368,8 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("payments", update_tables)
|
self.assertIn("payments", update_tables)
|
||||||
self.assertIn("promo_code_activations", update_tables)
|
self.assertIn("promo_code_activations", update_tables)
|
||||||
self.assertIn("user_payment_methods", 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("message_logs", update_tables)
|
||||||
self.assertIn("users", update_tables)
|
self.assertIn("users", update_tables)
|
||||||
self.assertIn("user_payment_methods", delete_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",
|
("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route",
|
||||||
("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route",
|
("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route",
|
||||||
("GET", "/api/admin/logs"): "admin_logs_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/broadcast"): "admin_broadcast_route",
|
||||||
("POST", "/api/admin/sync"): "admin_sync_route",
|
("POST", "/api/admin/sync"): "admin_sync_route",
|
||||||
("GET", "/api/admin/ads"): "admin_ads_list_route",
|
("GET", "/api/admin/ads"): "admin_ads_list_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user