feat: add partner system and withdrawal management to cabinet

- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
This commit is contained in:
Fringg
2026-02-17 09:51:36 +03:00
parent df5415f30b
commit 58bfaeaddb
10 changed files with 1629 additions and 0 deletions
+193
View File
@@ -7194,6 +7194,27 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с миграцией DateTime колонок')
logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PARTNER_APPLICATIONS ===')
partner_apps_ready = await create_partner_applications_table()
if partner_apps_ready:
logger.info('✅ Таблица partner_applications готова')
else:
logger.warning('⚠️ Проблемы с таблицей partner_applications')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ PARTNER_STATUS В USERS ===')
partner_status_ready = await add_user_partner_status_column()
if partner_status_ready:
logger.info('✅ Колонка partner_status в users готова')
else:
logger.warning('⚠️ Проблемы с колонкой partner_status')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ PARTNER_USER_ID В ADVERTISING_CAMPAIGNS ===')
campaign_partner_ready = await add_campaign_partner_user_id_column()
if campaign_partner_ready:
logger.info('✅ Колонка partner_user_id в advertising_campaigns готова')
else:
logger.warning('⚠️ Проблемы с колонкой partner_user_id')
async with engine.begin() as conn:
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
@@ -7244,6 +7265,166 @@ async def run_universal_migration():
return False
async def create_partner_applications_table() -> bool:
"""Создаёт таблицу для заявок на партнёрский статус."""
try:
if await check_table_exists('partner_applications'):
logger.debug('Таблица partner_applications уже существует')
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE partner_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
company_name VARCHAR(255),
website_url VARCHAR(500),
telegram_channel VARCHAR(255),
description TEXT,
expected_monthly_referrals INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_comment TEXT,
approved_commission_percent INTEGER,
processed_by INTEGER,
processed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE partner_applications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
company_name VARCHAR(255),
website_url VARCHAR(500),
telegram_channel VARCHAR(255),
description TEXT,
expected_monthly_referrals INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_comment TEXT,
approved_commission_percent INTEGER,
processed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
"""
else: # mysql
create_sql = """
CREATE TABLE partner_applications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
company_name VARCHAR(255),
website_url VARCHAR(500),
telegram_channel VARCHAR(255),
description TEXT,
expected_monthly_referrals INT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_comment TEXT,
approved_commission_percent INT,
processed_by INT,
processed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
await conn.execute(text(create_sql))
logger.info('✅ Таблица partner_applications создана')
try:
await conn.execute(
text('CREATE INDEX idx_partner_applications_user_id ON partner_applications(user_id)')
)
await conn.execute(text('CREATE INDEX idx_partner_applications_status ON partner_applications(status)'))
except Exception:
pass
return True
except Exception as error:
logger.error('❌ Ошибка создания таблицы partner_applications', error=error)
return False
async def add_user_partner_status_column() -> bool:
"""Добавляет колонку partner_status в таблицу users."""
try:
if await check_column_exists('users', 'partner_status'):
logger.info('️ Колонка partner_status в users уже существует')
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type in ('sqlite', 'postgresql'):
await conn.execute(
text("ALTER TABLE users ADD COLUMN partner_status VARCHAR(20) NOT NULL DEFAULT 'none'")
)
else: # MySQL
await conn.execute(
text("ALTER TABLE users ADD COLUMN partner_status VARCHAR(20) NOT NULL DEFAULT 'none'")
)
logger.info('✅ Колонка partner_status добавлена в users')
return True
except Exception as error:
logger.error('❌ Ошибка добавления колонки partner_status', error=error)
return False
async def add_campaign_partner_user_id_column() -> bool:
"""Добавляет колонку partner_user_id в таблицу advertising_campaigns."""
try:
if await check_column_exists('advertising_campaigns', 'partner_user_id'):
logger.info('️ Колонка partner_user_id в advertising_campaigns уже существует')
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(
text('ALTER TABLE advertising_campaigns ADD COLUMN partner_user_id INTEGER REFERENCES users(id)')
)
elif db_type == 'postgresql':
await conn.execute(
text(
'ALTER TABLE advertising_campaigns ADD COLUMN partner_user_id INTEGER '
'REFERENCES users(id) ON DELETE SET NULL'
)
)
await conn.execute(
text(
'CREATE INDEX IF NOT EXISTS idx_advertising_campaigns_partner_user_id '
'ON advertising_campaigns(partner_user_id)'
)
)
else: # MySQL
await conn.execute(text('ALTER TABLE advertising_campaigns ADD COLUMN partner_user_id INT NULL'))
await conn.execute(
text(
'ALTER TABLE advertising_campaigns ADD CONSTRAINT fk_campaigns_partner '
'FOREIGN KEY (partner_user_id) REFERENCES users(id) ON DELETE SET NULL'
)
)
logger.info('✅ Колонка partner_user_id добавлена в advertising_campaigns')
return True
except Exception as error:
logger.error('❌ Ошибка добавления колонки partner_user_id', error=error)
return False
async def check_migration_status():
logger.info('=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===')
@@ -7312,6 +7493,9 @@ async def check_migration_status():
'users_yandex_id_column': False,
'users_discord_id_column': False,
'users_vk_id_column': False,
'partner_applications_table': False,
'users_partner_status_column': False,
'campaigns_partner_user_id_column': False,
}
status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup')
@@ -7449,6 +7633,12 @@ async def check_migration_status():
status['users_discord_id_column'] = await check_column_exists('users', 'discord_id')
status['users_vk_id_column'] = await check_column_exists('users', 'vk_id')
status['partner_applications_table'] = await check_table_exists('partner_applications')
status['users_partner_status_column'] = await check_column_exists('users', 'partner_status')
status['campaigns_partner_user_id_column'] = await check_column_exists(
'advertising_campaigns', 'partner_user_id'
)
async with engine.begin() as conn:
duplicates_check = await conn.execute(
text("""
@@ -7523,6 +7713,9 @@ async def check_migration_status():
'users_yandex_id_column': 'Колонка yandex_id в users',
'users_discord_id_column': 'Колонка discord_id в users',
'users_vk_id_column': 'Колонка vk_id в users',
'partner_applications_table': 'Таблица partner_applications',
'users_partner_status_column': 'Колонка partner_status в users',
'campaigns_partner_user_id_column': 'Колонка partner_user_id в advertising_campaigns',
}
for check_key, check_status in status.items():