Подписочная модель кажется простой только на бумаге. Пользователь выбирает план, платит деньги, получает доступ — что может быть проще? Но стоит копнуть глубже, и выясняется, что нужно учесть десятки сценариев: что делать с частичными платежами, как обрабатывать отклоненные транзакции, что происходит при смене тарифа посреди периода, как корректно рассчитать пропорциональные суммы при апгрейде...
В последние годы подписочная модель захватила интернет. Netflix, Spotify, Adobe Creative Cloud — все перешли на recurring billing. SaaS-продукты без подписок сейчас выглядят анахронизмом. И каждый стартап мечтает о стабильном месячном доходе вместо разовых продаж. Проблема в том, что реализация подписок — это не просто создание таблицы в базе данных. Это целая экосистема взаимосвязанных компонентов: платежные шлюзы с их капризными API, налоговое законодательство разных стран, требования PCI DSS, обработка споров и возвратов. Каждый элемент может сломаться в самый неподходящий момент.
Современные фреймворки вроде ASP.NET MVC упрощают разработку веб-приложений, но подписочную логику все равно приходится писать с нуля. Entity Framework поможет с базой данных, ASP.NET Identity — с авторизацией, но бизнес-логику подписок никто за вас не реализует. И тут начинается настоящее веселье: транзакционность операций, состояния подписок, интеграция с платежками, обработка ошибок...
Архитектура модуля: от монолита к слоеной структуре
Когда я впервые столкнулся с необходимостью проектировать модуль подписок, наивно полагал, что достаточно создать пару контроллеров и модель данных. Результатом стал монолитный код, который через полгода превратился в неуправляемого франкенштейна. Контроллер раздулся до 800 строк, а бизнес-логика размазалась по всему приложению.
Архитектура подписочного модуля должна быть слоеной, как торт наполеон — каждый слой отвечает за свою задачу и не лезет в чужие дела. Это не дань моде на "чистую архитектуру", а практическая необходимость. Подписки затрагивают практически все части системы: аутентификацию, авторизацию, платежи, уведомления, отчеты.
На самом верху располагается презентационный слой — контроллеры MVC и Web API. Их единственная задача — принимать HTTP-запросы и возвращать ответы. Никакой бизнес-логики! Контроллер должен быть тонким как лист бумаги. Получил запрос, передал в сервисный слой, получил результат, вернул клиенту. Точка.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| [ApiController]
[Route("api/subscriptions")]
public class SubscriptionController : ControllerBase
{
private readonly ISubscriptionService _subscriptionService;
public SubscriptionController(ISubscriptionService subscriptionService)
{
_subscriptionService = subscriptionService;
}
[HttpPost("subscribe")]
public async Task<ActionResult<SubscriptionResponse>> Subscribe(
[FromBody] SubscriptionRequest request)
{
var result = await _subscriptionService.CreateSubscriptionAsync(
request.UserId, request.PlanId, request.PaymentToken);
return result.IsSuccess
? Ok(result.Value)
: BadRequest(result.Error);
}
} |
|
Сразу под презентационным слоем находится сервисный слой — мозг всей операции. Здесь живет бизнес-логика: валидация данных, расчет сумм, обработка состояний подписок. Сервисы оркестрируют работу репозиториев, внешних API и других компонентов. Они знают, что делать, но не знают, как именно это делается — это задача нижних слоев.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| public class SubscriptionService : ISubscriptionService
{
private readonly ISubscriptionRepository _repository;
private readonly IPaymentService _paymentService;
private readonly INotificationService _notificationService;
public async Task<Result<Subscription>> CreateSubscriptionAsync(
int userId, int planId, string paymentToken)
{
var user = await _repository.GetUserAsync(userId);
if (user == null) return Result.Fail<Subscription>("User not found");
var plan = await _repository.GetPlanAsync(planId);
if (plan == null) return Result.Fail<Subscription>("Plan not found");
var paymentResult = await _paymentService.ProcessPaymentAsync(
paymentToken, plan.Price);
if (!paymentResult.IsSuccess)
return Result.Fail<Subscription>("Payment failed");
var subscription = new Subscription(userId, planId, DateTime.UtcNow);
await _repository.SaveSubscriptionAsync(subscription);
await _notificationService.SendWelcomeEmailAsync(user.Email);
return Result.Success(subscription);
}
} |
|
Слой доступа к данным изолирует бизнес-логику от конкретной реализации хранилища. Сегодня у вас SQL Server, завтра захотите перейти на PostgreSQL или вообще на MongoDB — репозиторий остается неизменным. Паттерн Repository + Unit of Work здесь работает как часы.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class SubscriptionRepository : ISubscriptionRepository
{
private readonly ApplicationDbContext _context;
public async Task<Subscription> GetActiveSubscriptionAsync(int userId)
{
return await _context.Subscriptions
.Where(s => s.UserId == userId && s.IsActive)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
public async Task SaveSubscriptionAsync(Subscription subscription)
{
_context.Subscriptions.Add(subscription);
await _context.SaveChangesAsync();
}
} |
|
Особое внимание нужно уделить слою внешних интеграций. Платежные системы, почтовые сервисы, CRM — все эти внешние зависимости должны быть спрятаны за абстракциями. Когда Stripe изменит свой API (а они это делают регулярно), вы поменяете только реализацию интерфейса, не трогая бизнес-логику.
Domain Events — еще один важный паттерн для подписочных систем. Когда пользователь оформляет подписку, нужно отправить welcome-письмо, начислить бонусы партнеру, обновить статистику. Вместо захламления сервиса этими "побочными эффектами", используйте события.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| public class SubscriptionCreated : IDomainEvent
{
public int UserId { get; }
public int PlanId { get; }
public DateTime CreatedAt { get; }
public SubscriptionCreated(int userId, int planId)
{
UserId = userId;
PlanId = planId;
CreatedAt = DateTime.UtcNow;
}
} |
|
Конфигурация зависимостей в Startup.cs превращается в симфонию интерфейсов. DI-контейнер ASP.NET Core справляется с этой задачей отлично, но важно не переборщить с абстракциями. Каждый интерфейс должен иметь четкое обоснование своего существования.
Слоеная архитектура кажется избыточной для простых случаев, но подписочные системы имеют тенденцию быстро усложняться. То, что сегодня выглядит как простая покупка доступа, завтра превращается в сложную систему с пробными периодами, скидками, партнерскими программами и интеграциями с дюжиной внешних сервисов. Я видел проекты, где отказ от слоеной архитектуры приводил к техническому долгу размером с государственный бюджет. Рефакторинг такого кода занимал месяцы, а риск что-то сломать был запредельным. Лучше потратить время на правильное проектирование сразу, чем потом расхлебывать последствия.
Разница между ASP.NET Core 2, ASP.NET Core MVC, ASP.NET MVC 5 и ASP.NET WEBAPI 2 Здравствуйте. Я в бекенд разработке полный ноль. В чем разница между вышеперечисленными... ASP.NET MVC 4,ASP.NET MVC 4.5 и ASP.NET MVC 5 большая ли разница между ними? Начал во всю осваивать технологию,теперь хочу с книжкой посидеть и вдумчиво перебрать всё то что... Стоит ли изучать asp.net mvc 4 из за скорого выхода asn.net mvc vNext ? Доброго вечера!
Как я узнал, Microsoft скоро планирует выпустить новый веб-фреймворк с названием... ASP.NET MVC VS .NET CORE MVC Ку, можете подкинуть статейку где подробно описывается разница между этими двумя технологиями плз....
Создание моделей данных для подписок и пользователей
Проектирование моделей данных для подписочной системы — это как закладка фундамента небоскреба. Ошибешься здесь, и потом будешь расплачиваться миграциями, костылями в коде и бессонными ночами. Я это проходил — и не раз.
Начинается все невинно. Создаешь модель Subscription с парой полей, думаешь: "Ну что тут сложного?" А через полгода выясняется, что нужно хранить историю изменений тарифов, отслеживать частичные отмены, учитывать налоги по регионам... И твоя изначально простая модель превращается в монстра с 30 полями и связями во все стороны. Базовая модель подписки должна отражать жизненный цикл subscription'а. У каждой подписки есть состояние: активная, приостановленная, отмененная, просроченная. Но это только верхушка айсберга.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| public class Subscription
{
public int Id { get; set; }
public int UserId { get; set; }
public int PlanId { get; set; }
public SubscriptionStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ActivatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? CanceledAt { get; set; }
public string CancellationReason { get; set; }
public decimal AmountPaid { get; set; }
public string Currency { get; set; }
public string ExternalPaymentId { get; set; }
public virtual User User { get; set; }
public virtual SubscriptionPlan Plan { get; set; }
public virtual ICollection<SubscriptionHistory> History { get; set; }
}
public enum SubscriptionStatus
{
Pending,
Active,
Suspended,
Canceled,
Expired,
Failed
} |
|
Модель пользователя расширяет стандартную IdentityUser из ASP.NET Identity. Тут важно не переборщить — не стоит пихать в User все подряд. Профиль пользователя, настройки, статистика — для них лучше создать отдельные сущности.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class ApplicationUser : IdentityUser
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime LastLoginAt { get; set; }
public bool IsBlocked { get; set; }
public string TimeZone { get; set; }
public string PreferredLanguage { get; set; }
public virtual UserProfile Profile { get; set; }
public virtual ICollection<Subscription> Subscriptions { get; set; }
public virtual ICollection<PaymentMethod> PaymentMethods { get; set; }
} |
|
Тарифные планы — отдельная песня. Казалось бы, что сложного: название, цена, описание. Но реальность бьет по лицу сразу же. Нужны пробные периоды, разные цены для разных регионов, скидки для студентов, корпоративные тарифы...
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public class SubscriptionPlan
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal BasePrice { get; set; }
public string Currency { get; set; }
public BillingCycle BillingCycle { get; set; }
public int TrialDays { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public int MaxUsers { get; set; }
public int MaxProjects { get; set; }
public long StorageLimit { get; set; }
public virtual ICollection<PlanFeature> Features { get; set; }
public virtual ICollection<PlanPricing> RegionalPricing { get; set; }
}
public enum BillingCycle
{
Monthly,
Quarterly,
Yearly,
Lifetime
} |
|
История подписок критически важна для отладки и аудита. Когда пользователь жалуется, что с него списали лишние деньги, без истории изменений вы слепые котята. Каждое изменение статуса, каждая операция должна логироваться.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| public class SubscriptionHistory
{
public int Id { get; set; }
public int SubscriptionId { get; set; }
public SubscriptionStatus PreviousStatus { get; set; }
public SubscriptionStatus NewStatus { get; set; }
public string Reason { get; set; }
public DateTime ChangedAt { get; set; }
public string ChangedBy { get; set; }
public string AdditionalData { get; set; }
public virtual Subscription Subscription { get; set; }
} |
|
Платежные методы — еще один краеугольный камень. Пользователи хотят сохранять карты, менять их, использовать разные способы оплаты. PCI DSS запрещает хранить полные данные карт, поэтому работаем с токенами от платежных систем.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public class PaymentMethod
{
public int Id { get; set; }
public int UserId { get; set; }
public PaymentType Type { get; set; }
public string Token { get; set; }
public string LastFourDigits { get; set; }
public string ExpiryMonth { get; set; }
public string ExpiryYear { get; set; }
public string CardBrand { get; set; }
public bool IsDefault { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public virtual ApplicationUser User { get; set; }
}
public enum PaymentType
{
CreditCard,
DebitCard,
PayPal,
BankTransfer,
Cryptocurrency
} |
|
При проектировании моделей важно думать о будущих расширениях. JSON-поля в современных базах данных — палочка-выручалочка для хранения дополнительных метаданных. Но не злоупотребляйте — поиск и индексирование JSON'а может быть болезненным. Связи между моделями требуют особого внимания. Один пользователь может иметь несколько подписок (активную и приостановленные), подписка может переходить между планами, у плана могут быть региональные цены. Все эти связи должны быть явно определены в контексте Entity Framework.
Валидация на уровне модели поможет избежать некорректных данных в базе. Data Annotations — простой способ добавить базовые проверки, но для сложной бизнес-логики лучше использовать FluentValidation в сервисном слое.
| C# | 1
2
3
4
5
6
7
| [Required]
[Range(0.01, 999999.99)]
public decimal BasePrice { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } |
|
Индексы базы данных планируйте заранее. Поиск активных подписок по пользователю, фильтрация по статусу, сортировка по дате создания — все эти операции должны быть быстрыми. Составные индексы на (UserId, Status, ExpiresAt) могут серьезно ускорить запросы. Не забывайте про soft delete для критичных данных. Подписки, платежи, пользователи — их лучше помечать как удаленные, а не стирать физически. Восстановление случайно удаленных данных займет минуты, а не часы.
Настройка Entity Framework и миграций базы данных
Entity Framework в подписочных системах ведет себя как капризный ребенок — то работает идеально, то подкидывает сюрпризы в самый неподходящий момент. Особенно это касается миграций, которые могут превратить деплой в рулетку.
Конфигурация DbContext для подписок требует особого подхода. Обычные настройки тут не прокатят — слишком много связанных данных, сложных запросов и потенциальных конфликтов параллельного доступа.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| public class SubscriptionDbContext : IdentityDbContext<ApplicationUser>
{
public DbSet<Subscription> Subscriptions { get; set; }
public DbSet<SubscriptionPlan> SubscriptionPlans { get; set; }
public DbSet<SubscriptionHistory> SubscriptionHistories { get; set; }
public DbSet<PaymentMethod> PaymentMethods { get; set; }
public DbSet<Invoice> Invoices { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Subscription configuration
modelBuilder.Entity<Subscription>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Status).HasConversion<int>();
entity.Property(e => e.AmountPaid).HasPrecision(10, 2);
entity.Property(e => e.Currency).HasMaxLength(3);
entity.HasIndex(e => new { e.UserId, e.Status, e.ExpiresAt })
.HasDatabaseName("IX_Subscription_User_Status_Expires");
entity.HasOne(d => d.User)
.WithMany(p => p.Subscriptions)
.HasForeignKey(d => d.UserId)
.OnDelete(DeleteBehavior.Restrict);
});
// Soft delete filter
modelBuilder.Entity<Subscription>()
.HasQueryFilter(e => !e.IsDeleted);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlServer(connectionString, options =>
{
options.MigrationsHistoryTable("__EFMigrationsHistory", "subscription");
options.EnableRetryOnFailure(3, TimeSpan.FromSeconds(10), null);
options.CommandTimeout(30);
});
}
}
} |
|
Миграции в подписочных системах — это отдельный вид искусства. Данные пользователей и финансовая информация требуют особой осторожности. Одна неправильная миграция может стоить компании репутации и денег.
Создание начальной миграции должно включать все базовые таблицы сразу. Попытки добавлять таблицы по одной приводят к хаотичной структуре и проблемам с зависимостями.
| Bash | 1
| Add-Migration InitialSubscriptionSchema -Context SubscriptionDbContext |
|
Но настоящие проблемы начинаются с data migrations. Когда нужно изменить структуру существующих подписок или перенести пользователей на новые планы, Entity Framework не поможет — придется писать SQL руками.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| public partial class MigrateExistingSubscriptionsToNewPlans : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Сначала структурные изменения
migrationBuilder.AddColumn<int>(
name: "NewPlanId",
table: "Subscriptions",
nullable: true);
// Потом миграция данных
migrationBuilder.Sql(@"
UPDATE Subscriptions
SET NewPlanId = CASE
WHEN PlanId = 1 THEN 10 -- Basic -> Starter
WHEN PlanId = 2 THEN 11 -- Pro -> Professional
WHEN PlanId = 3 THEN 12 -- Enterprise -> Business
ELSE PlanId
END
WHERE PlanId IN (1, 2, 3)
");
// Проверяем результат
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM Subscriptions WHERE NewPlanId IS NULL AND Status = 1)
BEGIN
RAISERROR('Migration failed: some active subscriptions have no new plan', 16, 1)
END
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Откат данных перед структурными изменениями
migrationBuilder.Sql(@"
UPDATE Subscriptions
SET PlanId = CASE
WHEN NewPlanId = 10 THEN 1
WHEN NewPlanId = 11 THEN 2
WHEN NewPlanId = 12 THEN 3
ELSE NewPlanId
END
WHERE NewPlanId IN (10, 11, 12)
");
migrationBuilder.DropColumn(
name: "NewPlanId",
table: "Subscriptions");
}
} |
|
Connection string для продакшена должен быть настроен с умом. Connection pooling, timeout'ы, retry policy — все это критично для стабильной работы подписочной системы. Я видел приложения, которые падали под нагрузкой из-за неправильно настроенного пула соединений.
| JSON | 1
2
3
4
5
| {
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=SubscriptionDB;Trusted_Connection=true;MultipleActiveResultSets=true;Connection Timeout=30;Command Timeout=60;Pooling=true;Max Pool Size=100;Min Pool Size=5"
}
} |
|
Окружения разработки, тестирования и продакшена должны использовать идентичные схемы базы данных. Малейшее расхождение приведет к багам, которые проявятся только на проде. Автоматизация деплоя миграций — не роскошь, а необходимость.
Backup базы перед применением миграций — святое правило. Особенно для миграций, которые изменяют или удаляют данные. Я всегда создаю скрипт отката вручную, даже если EF генерирует Down-метод автоматически. Seeding тестовых данных должен быть частью миграций. Базовые тарифные планы, демо-пользователи, справочники — все это должно появляться в базе автоматически после применения миграций.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Seed data for subscription plans
modelBuilder.Entity<SubscriptionPlan>().HasData(
new SubscriptionPlan
{
Id = 1,
Name = "Free",
BasePrice = 0,
BillingCycle = BillingCycle.Monthly,
IsActive = true,
MaxUsers = 1,
StorageLimit = 1024 * 1024 * 1024 // 1GB
},
new SubscriptionPlan
{
Id = 2,
Name = "Professional",
BasePrice = 29.99m,
BillingCycle = BillingCycle.Monthly,
IsActive = true,
MaxUsers = 10,
StorageLimit = 100L * 1024 * 1024 * 1024 // 100GB
}
);
} |
|
Мониторинг миграций в продакшене должен быть настроен заранее. Логирование времени выполнения, количества затронутых записей, ошибок — все это поможет быстро диагностировать проблемы. EF Core предоставляет хорошие возможности для логирования, но их нужно правильно настроить.
Rollback стратегия — это не паранойя, а здравый смысл. Не все миграции можно откатить автоматически, особенно те, что удаляют данные. Планируйте откат заранее, тестируйте его на копии продакшена, документируйте процедуру восстановления.
Индексация и оптимизация запросов для больших объемов подписок
Когда у вас десятки тысяч подписок, Entity Framework начинает показывать свой характер. Запросы, которые летали на тестовых данных, вдруг начинают тормозить на секундах. А когда пользователей становится миллион, каждый неоптимальный запрос превращается в бутылочное горлышко.
Первый удар приходится на запросы активных подписок. Казалось бы, что сложного — выбрать все записи со статусом "Active"? Но без правильного индекса такой запрос будет сканировать всю таблицу. А если добавить фильтрацию по дате окончания, то все станет еще хуже.
| C# | 1
2
3
4
5
| // Медленный запрос без индекса
var activeSubscriptions = await context.Subscriptions
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.ExpiresAt > DateTime.UtcNow)
.ToListAsync(); |
|
Составной индекс на (UserId, Status, ExpiresAt) решает большинство проблем с производительностью. Но порядок полей критично важен — неправильная последовательность может свести пользу индекса к нулю.
| SQL | 1
2
3
| CREATE INDEX IX_Subscription_OptimizedLookup
ON Subscriptions (UserId, STATUS, ExpiresAt DESC)
INCLUDE (PlanId, CreatedAt, AmountPaid); |
|
Include-столбцы — это серебряная пуля для covering queries. Когда индекс содержит все нужные данные, SQL Server не лезет в основную таблицу. Запрос выполняется только по индексу, что дает колоссальный прирост скорости.
Партиционирование становится необходимостью при миллионах записей. Можно разделить подписки по месяцам или по статусам. Старые неактивные подписки отправляются в архивную партицию, а рабочие запросы выполняются только по активным данным.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Оптимизированный запрос с учетом партиционирования
var userActiveSubscription = await context.Subscriptions
.Where(s => s.UserId == userId)
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(s => s.CreatedAt)
.Select(s => new SubscriptionDto
{
Id = s.Id,
PlanName = s.Plan.Name,
ExpiresAt = s.ExpiresAt
})
.FirstOrDefaultAsync(); |
|
Проекция через Select спасает от загрузки лишних данных. Зачем тащить из базы все поля сущности, если нужны только три? Entity Framework достаточно умен, чтобы сгенерировать оптимальный SQL с нужными столбцами.
Кеширование запросов — обязательное требование для часто используемых данных. Список тарифных планов, настройки системы, статистика — все это можно закешировать на уровне приложения. MemoryCache ASP.NET Core прекрасно справляется с этой задачей.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public async Task<List<SubscriptionPlan>> GetActivePlansAsync()
{
const string cacheKey = "active_subscription_plans";
if (!_cache.TryGetValue(cacheKey, out List<SubscriptionPlan> plans))
{
plans = await _context.SubscriptionPlans
.Where(p => p.IsActive)
.OrderBy(p => p.BasePrice)
.ToListAsync();
_cache.Set(cacheKey, plans, TimeSpan.FromMinutes(15));
}
return plans;
} |
|
Пагинация для больших результирующих наборов — не роскошь, а необходимость. Skip/Take в Entity Framework работает не оптимально на больших офсетах. Cursor-based пагинация через Id или CreatedAt показывает стабильную производительность даже на миллионных таблицах. Мониторинг медленных запросов должен быть настроен с первого дня. SQL Server Profiler, PostgreSQL pg_stat_statements, логи Entity Framework — все эти инструменты помогают выявить проблемные запросы до того, как они убьют производительность в продакшене.
Статистика использования индексов показывает реальную картину. Missing index suggestions от SQL Server часто дают ценные подсказки для оптимизации. Но слепо создавать все предлагаемые индексы нельзя — каждый индекс замедляет операции записи.
| SQL | 1
2
3
4
5
6
7
8
9
10
11
| -- Анализ использования индексов
SELECT
i.name AS index_name,
s.user_seeks,
s.user_scans,
s.user_lookups,
s.user_updates
FROM sys.indexes i
INNER JOIN sys.dm_db_index_usage_stats s ON i.object_id = s.object_id
WHERE OBJECT_NAME(i.object_id) = 'Subscriptions'
ORDER BY s.user_seeks + s.user_scans + s.user_lookups DESC; |
|
Архивирование старых данных помогает поддерживать размер основных таблиц в разумных пределах. Подписки старше двух лет можно переносить в архивные таблицы, оставляя в основной таблице только актуальные данные. Это радикально улучшает производительность всех операций.
Реализация бизнес-логики подписок
Бизнес-логика подписок — это минное поле, где каждый шаг может взорваться багом в продакшене. За годы разработки подписочных систем я понял: самые простые с виду операции таят в себе десятки подводных камней. Создать подписку? Легко. Но что делать с неудавшимся платежом? А если пользователь меняет план в середине периода? А если он отменяет подписку, но потом передумывает?
Жизненный цикл подписки сложнее, чем кажется. У меня есть диаграмма состояний, которую я рисовал три раза — каждый раз находились новые переходы между статусами. Pending может перейти в Active или Failed, Active может стать Suspended или Canceled, но Canceled иногда возвращается в Active через Reactivated...
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
| public class SubscriptionService
{
private readonly ISubscriptionRepository _repository;
private readonly IPaymentService _paymentService;
private readonly INotificationService _notifications;
private readonly ILogger<SubscriptionService> _logger;
public async Task<Result<Subscription>> CreateSubscriptionAsync(
CreateSubscriptionRequest request)
{
try
{
await using var transaction = await _repository.BeginTransactionAsync();
// Валидируем пользователя
var user = await _repository.GetUserByIdAsync(request.UserId);
if (user == null)
return Result.Fail<Subscription>("User not found");
// Проверяем существующие активные подписки
var existingSubscription = await _repository
.GetActiveSubscriptionAsync(request.UserId);
if (existingSubscription != null && !request.AllowUpgrade)
return Result.Fail<Subscription>("User already has active subscription");
// Получаем план
var plan = await _repository.GetPlanByIdAsync(request.PlanId);
if (plan == null || !plan.IsActive)
return Result.Fail<Subscription>("Invalid subscription plan");
// Создаем подписку в статусе Pending
var subscription = new Subscription
{
UserId = request.UserId,
PlanId = request.PlanId,
Status = SubscriptionStatus.Pending,
CreatedAt = DateTime.UtcNow,
Currency = plan.Currency,
AmountToBePaid = CalculateAmount(plan, request.PromoCode)
};
await _repository.SaveSubscriptionAsync(subscription);
// Обрабатываем платеж
var paymentResult = await ProcessPaymentAsync(subscription, request.PaymentToken);
if (!paymentResult.IsSuccess)
{
subscription.Status = SubscriptionStatus.Failed;
subscription.FailureReason = paymentResult.Error;
await _repository.UpdateSubscriptionAsync(subscription);
await transaction.RollbackAsync();
return Result.Fail<Subscription>(paymentResult.Error);
}
// Активируем подписку
subscription.Status = SubscriptionStatus.Active;
subscription.ActivatedAt = DateTime.UtcNow;
subscription.ExpiresAt = CalculateExpirationDate(plan);
subscription.ExternalPaymentId = paymentResult.Value.TransactionId;
// Деактивируем старую подписку если есть
if (existingSubscription != null)
{
await DeactivateSubscriptionAsync(existingSubscription, "Upgraded to new plan");
}
await _repository.UpdateSubscriptionAsync(subscription);
await transaction.CommitAsync();
// Отправляем уведомления
await _notifications.SendWelcomeEmailAsync(user.Email, subscription);
_logger.LogInformation("Subscription {SubscriptionId} created for user {UserId}",
subscription.Id, request.UserId);
return Result.Success(subscription);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create subscription for user {UserId}", request.UserId);
return Result.Fail<Subscription>("Failed to create subscription");
}
}
} |
|
Обработка статусов — это отдельная наука. Я использую паттерн State Machine, где каждый переход между состояниями проверяется и логируется. Нельзя просто взять и поменять статус с Active на Canceled — нужно учесть refund policy, период уведомления, права доступа.
Расчет сумм к оплате кажется тривиальным, но дьявол в деталях. Налоги по регионам, скидки, промокоды, пропорциональное начисление при смене плана — все это нужно считать с точностью до копейки. Ошибка в один цент может обернуться судебными исками.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| private decimal CalculateAmount(SubscriptionPlan plan, string promoCode = null)
{
decimal baseAmount = plan.BasePrice;
// Применяем промокод
if (!string.IsNullOrEmpty(promoCode))
{
var discount = await _repository.GetValidPromoCodeAsync(promoCode);
if (discount != null)
{
baseAmount = discount.Type == DiscountType.Percentage
? baseAmount * (1 - discount.Value / 100)
: Math.Max(0, baseAmount - discount.Value);
}
}
// Добавляем налоги
var taxRate = await _taxService.GetTaxRateAsync(user.Country, plan.Category);
var totalAmount = baseAmount * (1 + taxRate);
return Math.Round(totalAmount, 2, MidpointRounding.AwayFromZero);
} |
|
Обновление подписок требует особой осторожности с параллельными операциями. Два одновременных запроса на изменение одной подписки могут привести к race condition и потере данных. Оптимистические блокировки через версионирование спасают ситуацию.
Отмена подписок — это не просто смена статуса. Нужно учесть cancellation policy, возможность возврата средств, период grace period. Некоторые подписки отменяются немедленно, другие — только в конце билингового периода. А корпоративные клиенты вообще могут требовать индивидуальных условий отмены. Автоматическое продление подписок запускается по cron job'у, но логика должна быть устойчива к сбоям. Если платеж не прошел, нужно повторить попытку через день, потом через неделю. После нескольких неудачных попыток подписка переводится в статус Suspended, но не удаляется — пользователь может исправить проблемы с картой.
Event-driven архитектура помогает развязать компоненты системы. Когда подписка создается, генерируется событие SubscriptionCreated. Его обрабатывают разные сервисы: отправка писем, начисление бонусов партнерам, обновление статистики. Каждый сервис работает независимо, и сбой одного не роняет всю систему.
Валидация бизнес-правил должна быть централизована в доменных сервисах. Нельзя разбрасывать проверки по контроллерам и репозиториям — это приведет к дублированию кода и противоречивой логике. Все правила в одном месте, все исключения задокументированы.
Паттерн Repository для работы с данными
Repository в подписочных системах — это не дань архитектурной моде, а жизненная необходимость. Когда вам нужно поддерживать несколько баз данных, тестировать бизнес-логику без реальной базы или просто изолировать Entity Framework от остальной системы, без Repository не обойтись. Я помню проект, где мы напрямую использовали DbContext в сервисах. Казалось, что Repository — это лишний слой абстракции. Но когда потребовалось добавить кеширование запросов, логирование обращений к базе и fallback на Redis при недоступности основной базы, пришлось переписывать полсистемы. Repository решил бы все эти проблемы элегантно.
Базовый интерфейс Repository должен покрывать основные операции CRUD, но не превращаться в универсальный монстр со ста методами. Каждая сущность получает свой специализированный репозиторий с методами, отражающими реальные потребности бизнеса.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| public interface ISubscriptionRepository
{
Task<Subscription> GetByIdAsync(int id);
Task<Subscription> GetActiveSubscriptionAsync(int userId);
Task<List<Subscription>> GetUserSubscriptionsAsync(int userId);
Task<List<Subscription>> GetExpiringSubscriptionsAsync(DateTime date);
Task<Subscription> SaveAsync(Subscription subscription);
Task<bool> UpdateAsync(Subscription subscription);
Task<bool> CancelAsync(int subscriptionId, string reason);
Task<IDbTransaction> BeginTransactionAsync();
}
public class SubscriptionRepository : ISubscriptionRepository
{
private readonly SubscriptionDbContext _context;
private readonly IMemoryCache _cache;
private readonly ILogger<SubscriptionRepository> _logger;
public SubscriptionRepository(
SubscriptionDbContext context,
IMemoryCache cache,
ILogger<SubscriptionRepository> logger)
{
_context = context;
_cache = cache;
_logger = logger;
}
public async Task<Subscription> GetActiveSubscriptionAsync(int userId)
{
var cacheKey = $"active_subscription_{userId}";
if (_cache.TryGetValue(cacheKey, out Subscription cachedSubscription))
{
return cachedSubscription;
}
var subscription = await _context.Subscriptions
.Include(s => s.Plan)
.Where(s => s.UserId == userId)
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (subscription != null)
{
_cache.Set(cacheKey, subscription, TimeSpan.FromMinutes(5));
}
return subscription;
}
public async Task<List<Subscription>> GetExpiringSubscriptionsAsync(DateTime date)
{
return await _context.Subscriptions
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.ExpiresAt.Date == date.Date)
.Include(s => s.User)
.Include(s => s.Plan)
.ToListAsync();
}
} |
|
Кеширование на уровне Repository дает максимальную гибкость. Можно кешировать отдельные сущности, результаты сложных запросов или даже целые коллекции. Cache invalidation — больная тема, но здесь она решается локально, внутри репозитория, не затрагивая бизнес-логику. Specialized queries лучше выносить в отдельные методы репозитория, чем городить универсальные механизмы фильтрации. GetExpiringSubscriptions, GetOverduePayments, GetHighValueCustomers — такие методы четко выражают бизнес-интенции и легко оптимизируются.
Транзакционность в Repository требует особого внимания. Unit of Work паттерн здесь работает идеально, но можно обойтись и простыми транзакциями через BeginTransaction. Главное — не забывать про using statements и правильную обработку ошибок.
Generic Repository — спорная тема. С одной стороны, он уменьшает дублирование кода. С другой — приводит к размытым интерфейсам и сложностям с оптимизацией специфичных запросов. Я предпочитаю специализированные репозитории с базовым абстрактным классом для общей функциональности. Testability — главное преимущество Repository паттерна. Mock'и для интерфейсов создаются тривиально, unit-тесты сервисов запускаются мгновенно, а интеграционные тесты используют in-memory базу или test containers. Без Repository тестирование превращается в кошмар зависимостей.
Создание сервисного слоя для управления подписками
Сервисный слой — это дирижер оркестра подписочной системы. Если Repository знает, как достать данные из базы, а контроллеры умеют обработать HTTP-запросы, то сервисы решают, что делать с этими данными. Здесь живет вся бизнес-логика, все хитрые алгоритмы расчетов и все те "если-то-иначе", которые превращают простые CRUD-операции в осмысленные бизнес-процессы.
За годы разработки я понял: толстые контроллеры — это путь к техническому долгу размером с небольшую страну. А тонкие сервисы, которые делегируют все Repository, превращают систему в procedural код с объектно-ориентированным фасадом. Правильный сервисный слой находится где-то посередине — он знает о бизнесе все, но делегирует техническиe детали.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
| public interface ISubscriptionManagementService
{
Task<Result<SubscriptionDto>> CreateSubscriptionAsync(CreateSubscriptionCommand command);
Task<Result<SubscriptionDto>> UpgradeSubscriptionAsync(UpgradeSubscriptionCommand command);
Task<Result> CancelSubscriptionAsync(CancelSubscriptionCommand command);
Task<Result<SubscriptionDto>> ReactivateSubscriptionAsync(int subscriptionId, int userId);
Task<Result<decimal>> CalculateUpgradeCostAsync(int subscriptionId, int newPlanId);
Task<List<SubscriptionDto>> GetUserSubscriptionHistoryAsync(int userId);
Task<Result> ProcessRenewalAsync(int subscriptionId);
}
public class SubscriptionManagementService : ISubscriptionManagementService
{
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IPaymentService _paymentService;
private readonly IUserRepository _userRepository;
private readonly IPlanRepository _planRepository;
private readonly INotificationService _notificationService;
private readonly ISubscriptionCalculator _calculator;
private readonly IEventPublisher _eventPublisher;
private readonly ILogger<SubscriptionManagementService> _logger;
public async Task<Result<SubscriptionDto>> UpgradeSubscriptionAsync(UpgradeSubscriptionCommand command)
{
try
{
// Получаем текущую подписку
var currentSubscription = await _subscriptionRepository.GetActiveSubscriptionAsync(command.UserId);
if (currentSubscription == null)
return Result.Fail<SubscriptionDto>("No active subscription found");
// Проверяем новый план
var newPlan = await _planRepository.GetByIdAsync(command.NewPlanId);
if (newPlan == null || !newPlan.IsActive)
return Result.Fail<SubscriptionDto>("Invalid plan selected");
if (newPlan.Id == currentSubscription.PlanId)
return Result.Fail<SubscriptionDto>("Already subscribed to this plan");
// Рассчитываем стоимость апгрейда
var upgradeCost = await _calculator.CalculateUpgradeCostAsync(
currentSubscription, newPlan, DateTime.UtcNow);
if (upgradeCost < 0)
return Result.Fail<SubscriptionDto>("Downgrade not supported through upgrade endpoint");
// Обрабатываем платеж если нужно доплатить
PaymentResult paymentResult = null;
if (upgradeCost > 0)
{
paymentResult = await _paymentService.ProcessPaymentAsync(new PaymentRequest
{
Amount = upgradeCost,
Currency = newPlan.Currency,
PaymentToken = command.PaymentToken,
UserId = command.UserId,
Description = $"Upgrade to {newPlan.Name}"
});
if (!paymentResult.IsSuccess)
return Result.Fail<SubscriptionDto>($"Payment failed: {paymentResult.ErrorMessage}");
}
// Обновляем подписку
using var transaction = await _subscriptionRepository.BeginTransactionAsync();
currentSubscription.PlanId = command.NewPlanId;
currentSubscription.UpdatedAt = DateTime.UtcNow;
// Пересчитываем дату окончания
var remainingTime = currentSubscription.ExpiresAt - DateTime.UtcNow;
currentSubscription.ExpiresAt = DateTime.UtcNow.Add(
_calculator.AdjustRemainingTime(remainingTime, currentSubscription.Plan, newPlan));
await _subscriptionRepository.UpdateAsync(currentSubscription);
// Записываем в историю
await _subscriptionRepository.AddHistoryEntryAsync(new SubscriptionHistory
{
SubscriptionId = currentSubscription.Id,
Action = SubscriptionAction.Upgraded,
PreviousPlanId = currentSubscription.PlanId,
NewPlanId = command.NewPlanId,
Amount = upgradeCost,
PaymentId = paymentResult?.TransactionId,
CreatedAt = DateTime.UtcNow
});
await transaction.CommitAsync();
// Публикуем событие
await _eventPublisher.PublishAsync(new SubscriptionUpgraded
{
SubscriptionId = currentSubscription.Id,
UserId = command.UserId,
PreviousPlanId = currentSubscription.PlanId,
NewPlanId = command.NewPlanId,
UpgradeCost = upgradeCost
});
// Отправляем уведомление
var user = await _userRepository.GetByIdAsync(command.UserId);
await _notificationService.SendUpgradeConfirmationAsync(user.Email, currentSubscription, newPlan);
return Result.Success(MapToDto(currentSubscription));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to upgrade subscription for user {UserId} to plan {PlanId}",
command.UserId, command.NewPlanId);
return Result.Fail<SubscriptionDto>("Upgrade failed due to system error");
}
}
} |
|
Orchestration — ключевое слово здесь. Сервис не занимается низкоуровневыми операциями с базой данных, не знает деталей HTTP-протокола, не парсит JSON. Он знает, в каком порядке вызывать другие сервисы, как обрабатывать их результаты и что делать при ошибках.
Validation логика в сервисах должна фокусироваться на бизнес-правилах, а не на формате данных. "Пользователь не может апгрейдиться на тот же план" — это бизнес-правило для сервиса. "Email должен содержать @" — это формальная валидация для уровня презентации.
Error handling в сервисном слое — это искусство баланса между информативностью и безопасностью. Пользователю нужно понять, что пошло не так, но не получить доступ к внутренним деталям системы. Result pattern помогает элегантно передавать как успешные результаты, так и ошибки без exception'ов.
Transaction management на уровне сервисов позволяет обеспечить консистентность данных при сложных операциях. Апгрейд подписки затрагивает минимум три таблицы: Subscriptions, SubscriptionHistory, Payments. Все изменения должны быть атомарными — либо все успешно, либо ничего не меняется.
Паттерн Unit of Work для транзакционности операций
Unit of Work — это паттерн, который я полюбил после того, как в третий раз за месяц чинил баг с неконсистентными данными в подписочной системе. Представьте: пользователь покупает подписку, платеж проходит успешно, но из-за сбоя сети запись в базу не попадает. Пользователь остается без доступа, но деньги списаны. Саппорт в панике, финансисты требуют объяснений, а я сижу и думаю: "Почему я не использовал Unit of Work с самого начала?"
Суть паттерна проста: все изменения в рамках бизнес-операции собираются в одном месте и фиксируются атомарно. Либо все изменения проходят успешно, либо ни одно не применяется. В контексте подписок это критично — слишком много связанных данных меняется одновременно.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
| public interface IUnitOfWork : IDisposable
{
ISubscriptionRepository Subscriptions { get; }
IUserRepository Users { get; }
IPaymentRepository Payments { get; }
ISubscriptionHistoryRepository History { get; }
Task<int> SaveChangesAsync();
Task BeginTransactionAsync();
Task CommitAsync();
Task RollbackAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly SubscriptionDbContext _context;
private IDbContextTransaction _transaction;
private bool _disposed;
private ISubscriptionRepository _subscriptions;
private IUserRepository _users;
private IPaymentRepository _payments;
private ISubscriptionHistoryRepository _history;
public UnitOfWork(SubscriptionDbContext context)
{
_context = context;
}
public ISubscriptionRepository Subscriptions =>
_subscriptions ??= new SubscriptionRepository(_context);
public IUserRepository Users =>
_users ??= new UserRepository(_context);
public IPaymentRepository Payments =>
_payments ??= new PaymentRepository(_context);
public ISubscriptionHistoryRepository History =>
_history ??= new SubscriptionHistoryRepository(_context);
public async Task BeginTransactionAsync()
{
_transaction = await _context.Database.BeginTransactionAsync();
}
public async Task CommitAsync()
{
try
{
await _context.SaveChangesAsync();
if (_transaction != null)
{
await _transaction.CommitAsync();
}
}
catch
{
await RollbackAsync();
throw;
}
}
public async Task RollbackAsync()
{
if (_transaction != null)
{
await _transaction.RollbackAsync();
}
}
public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
public void Dispose()
{
if (!_disposed)
{
_transaction?.Dispose();
_context?.Dispose();
_disposed = true;
}
}
} |
|
В сервисном слое Unit of Work превращает хаотичные операции с репозиториями в четко структурированные транзакции. Создание подписки теперь выглядит как единая атомарная операция, где любая ошибка откатывает все изменения:
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| public async Task<Result<Subscription>> CreateSubscriptionWithUnitOfWorkAsync(CreateSubscriptionRequest request)
{
using var unitOfWork = _unitOfWorkFactory.Create();
try
{
await unitOfWork.BeginTransactionAsync();
// Создаем подписку
var subscription = new Subscription
{
UserId = request.UserId,
PlanId = request.PlanId,
Status = SubscriptionStatus.Pending,
CreatedAt = DateTime.UtcNow
};
await unitOfWork.Subscriptions.AddAsync(subscription);
// Записываем платеж
var payment = new Payment
{
UserId = request.UserId,
Amount = request.Amount,
Status = PaymentStatus.Processing,
ExternalTransactionId = request.PaymentToken
};
await unitOfWork.Payments.AddAsync(payment);
// Добавляем запись в историю
var historyEntry = new SubscriptionHistory
{
SubscriptionId = subscription.Id,
Action = "Created",
Details = $"Subscription created for plan {request.PlanId}",
CreatedAt = DateTime.UtcNow
};
await unitOfWork.History.AddAsync(historyEntry);
// Обновляем статистику пользователя
var user = await unitOfWork.Users.GetByIdAsync(request.UserId);
user.LastSubscriptionDate = DateTime.UtcNow;
user.TotalSubscriptions++;
await unitOfWork.Users.UpdateAsync(user);
// Фиксируем все изменения
await unitOfWork.CommitAsync();
return Result.Success(subscription);
}
catch (Exception ex)
{
await unitOfWork.RollbackAsync();
_logger.LogError(ex, "Failed to create subscription for user {UserId}", request.UserId);
return Result.Fail<Subscription>("Failed to create subscription");
}
} |
|
Lazy loading репозиториев в Unit of Work экономит память и время инициализации. Если операция не требует работы с платежами, соответствующий репозиторий просто не создается. Это особенно важно в высоконагруженных системах, где каждая миллисекунда на счету.
Change tracking в Entity Framework работает на уровне контекста, поэтому Unit of Work идеально интегрируется с EF Core. Все изменения отслеживаются автоматически, а SaveChangesAsync() применяет их за одну операцию. Никаких ручных вызовов Update() для каждой сущности.
Nested transactions — сложная тема в Unit of Work. SQL Server поддерживает savepoints, но не все базы данных одинаково полезны. Лучше проектировать операции так, чтобы избежать вложенных транзакций, или использовать компенсирующие действия через Saga pattern для распределенных операций.
Реализация State Machine для статусов подписок
Когда я впервые столкнулся с задачей управления статусами подписок, наивно полагал, что достаточно enum'а с несколькими значениями и простых if-else конструкций. Через месяц код превратился в спагетти из условий: "если статус Active, то можно перейти в Suspended, но только если не прошло больше 7 дней с последнего платежа, при условии что пользователь не корпоративный..." Именно тогда я понял, что статусы подписок — это классический случай для State Machine.
State Machine — это не академическая абстракция, а практический инструмент для контроля сложных переходов между состояниями. В подписочных системах статусы меняются по строгим правилам: Pending может стать Active или Failed, Active может перейти в Suspended или Canceled, но нельзя напрямую из Failed попасть в Active без прохождения через Pending.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
| public enum SubscriptionStatus
{
Pending,
Active,
Suspended,
Canceled,
Expired,
Failed,
PendingCancellation,
Refunded
}
public interface ISubscriptionStateMachine
{
Task<StateTransitionResult> TransitionAsync(Subscription subscription, SubscriptionStatus targetStatus, StateTransitionContext context);
bool CanTransition(SubscriptionStatus currentStatus, SubscriptionStatus targetStatus);
List<SubscriptionStatus> GetAllowedTransitions(SubscriptionStatus currentStatus);
}
public class SubscriptionStateMachine : ISubscriptionStateMachine
{
private readonly Dictionary<SubscriptionStatus, List<SubscriptionStatus>> _allowedTransitions;
private readonly Dictionary<(SubscriptionStatus, SubscriptionStatus), Func<Subscription, StateTransitionContext, Task<bool>>> _transitionGuards;
private readonly Dictionary<(SubscriptionStatus, SubscriptionStatus), Func<Subscription, StateTransitionContext, Task>> _transitionActions;
private readonly ILogger<SubscriptionStateMachine> _logger;
public SubscriptionStateMachine(ILogger<SubscriptionStateMachine> logger)
{
_logger = logger;
_allowedTransitions = InitializeTransitions();
_transitionGuards = InitializeGuards();
_transitionActions = InitializeActions();
}
private Dictionary<SubscriptionStatus, List<SubscriptionStatus>> InitializeTransitions()
{
return new Dictionary<SubscriptionStatus, List<SubscriptionStatus>>
{
[SubscriptionStatus.Pending] = new List<SubscriptionStatus>
{
SubscriptionStatus.Active,
SubscriptionStatus.Failed,
SubscriptionStatus.Canceled
},
[SubscriptionStatus.Active] = new List<SubscriptionStatus>
{
SubscriptionStatus.Suspended,
SubscriptionStatus.PendingCancellation,
SubscriptionStatus.Expired,
SubscriptionStatus.Canceled
},
[SubscriptionStatus.Suspended] = new List<SubscriptionStatus>
{
SubscriptionStatus.Active,
SubscriptionStatus.Canceled,
SubscriptionStatus.Expired
},
[SubscriptionStatus.PendingCancellation] = new List<SubscriptionStatus>
{
SubscriptionStatus.Canceled,
SubscriptionStatus.Active
},
[SubscriptionStatus.Failed] = new List<SubscriptionStatus>
{
SubscriptionStatus.Pending,
SubscriptionStatus.Canceled
},
[SubscriptionStatus.Expired] = new List<SubscriptionStatus>
{
SubscriptionStatus.Active,
SubscriptionStatus.Canceled
},
[SubscriptionStatus.Canceled] = new List<SubscriptionStatus>
{
SubscriptionStatus.Pending
},
[SubscriptionStatus.Refunded] = new List<SubscriptionStatus>()
};
}
} |
|
Guards — это проверки, которые определяют, можно ли выполнить переход в конкретной ситуации. Например, переход из Active в Suspended возможен только если есть проблемы с платежом или пользователь превысил лимиты. Без guards State Machine превращается в бесполезную схему.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| private Dictionary<(SubscriptionStatus, SubscriptionStatus), Func<Subscription, StateTransitionContext, Task<bool>>> InitializeGuards()
{
return new Dictionary<(SubscriptionStatus, SubscriptionStatus), Func<Subscription, StateTransitionContext, Task<bool>>>
{
[(SubscriptionStatus.Pending, SubscriptionStatus.Active)] = async (subscription, context) =>
{
// Можно активировать только если платеж прошел успешно
return context.PaymentResult != null && context.PaymentResult.IsSuccess;
},
[(SubscriptionStatus.Active, SubscriptionStatus.Suspended)] = async (subscription, context) =>
{
// Приостановить можно только при проблемах с платежом или превышении лимитов
return context.Reason == SuspensionReason.PaymentFailed ||
context.Reason == SuspensionReason.LimitExceeded;
},
[(SubscriptionStatus.Suspended, SubscriptionStatus.Active)] = async (subscription, context) =>
{
// Реактивировать можно только если проблемы решены
var daysSuspended = (DateTime.UtcNow - subscription.SuspendedAt.Value).TotalDays;
return daysSuspended <= 30 && context.PaymentResult?.IsSuccess == true;
},
[(SubscriptionStatus.PendingCancellation, SubscriptionStatus.Active)] = async (subscription, context) =>
{
// Отменить отмену можно только до конца биллингового периода
return subscription.ExpiresAt > DateTime.UtcNow.AddDays(1);
}
};
} |
|
Transition Actions выполняют побочные эффекты при смене статуса: отправка уведомлений, логирование, обновление связанных данных. Эти действия инкапсулированы в State Machine и выполняются автоматически при успешном переходе.
Основной метод TransitionAsync оркестрирует весь процесс смены статуса: проверяет допустимость перехода, выполняет guards, применяет изменения и запускает actions. Это единая точка входа для всех изменений статуса, что гарантирует консистентность данных.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
| public async Task<StateTransitionResult> TransitionAsync(Subscription subscription, SubscriptionStatus targetStatus, StateTransitionContext context)
{
var currentStatus = subscription.Status;
// Проверяем базовую допустимость перехода
if (!CanTransition(currentStatus, targetStatus))
{
return StateTransitionResult.Failed($"Transition from {currentStatus} to {targetStatus} is not allowed");
}
// Выполняем guard проверки
var guardKey = (currentStatus, targetStatus);
if (_transitionGuards.ContainsKey(guardKey))
{
var canTransition = await _transitionGuards[guardKey](subscription, context);
if (!canTransition)
{
return StateTransitionResult.Failed($"Guard condition failed for transition {currentStatus} -> {targetStatus}");
}
}
// Сохраняем предыдущий статус для истории
var previousStatus = subscription.Status;
// Выполняем переход
subscription.Status = targetStatus;
subscription.StatusChangedAt = DateTime.UtcNow;
subscription.StatusChangedBy = context.UserId;
// Обновляем специфичные для статуса поля
UpdateStatusSpecificFields(subscription, targetStatus, context);
try
{
// Выполняем action'ы для данного перехода
if (_transitionActions.ContainsKey(guardKey))
{
await _transitionActions[guardKey](subscription, context);
}
_logger.LogInformation("Subscription {SubscriptionId} transitioned from {PreviousStatus} to {NewStatus}",
subscription.Id, previousStatus, targetStatus);
return StateTransitionResult.Success(subscription);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to execute transition actions for subscription {SubscriptionId}", subscription.Id);
return StateTransitionResult.Failed($"Transition actions failed: {ex.Message}");
}
}
private void UpdateStatusSpecificFields(Subscription subscription, SubscriptionStatus newStatus, StateTransitionContext context)
{
switch (newStatus)
{
case SubscriptionStatus.Active:
subscription.ActivatedAt = DateTime.UtcNow;
subscription.SuspendedAt = null;
subscription.CanceledAt = null;
break;
case SubscriptionStatus.Suspended:
subscription.SuspendedAt = DateTime.UtcNow;
subscription.SuspensionReason = context.Reason?.ToString();
break;
case SubscriptionStatus.Canceled:
subscription.CanceledAt = DateTime.UtcNow;
subscription.CancellationReason = context.CancellationReason;
break;
case SubscriptionStatus.Failed:
subscription.FailedAt = DateTime.UtcNow;
subscription.FailureReason = context.FailureReason;
break;
}
} |
|
Интеграция State Machine в сервисный слой делает код предсказуемым и безопасным. Вместо прямого изменения статусов все операции идут через машину состояний, что исключает невалидные переходы и гарантирует выполнение всех необходимых побочных эффектов. Тестирование State Machine становится систематическим процессом: каждый возможный переход тестируется отдельно, guards проверяются с разными входными данными, actions мокаются для изоляции тестов. Покрытие достигает 100% естественным образом. Визуализация State Machine через диаграммы состояний помогает в общении с бизнесом и документировании системы. Когда продакт-менеджер просит добавить новый статус "PartiallyRefunded", диаграмма четко показывает, где его разместить и какие переходы добавить.
Валидация данных и обработка ошибок
Валидация в подписочных системах — это не просто проверка формата email'а или длины пароля. Это многослойная оборона против некорректных данных, которые могут привести к финансовым потерям, юридическим проблемам или полной остановке биллинга. Я помню случай, когда отсутствие валидации суммы платежа позволило пользователю создать подписку за -100 долларов. Система автоматически пыталась "списать" отрицательную сумму, что привело к зачислению денег на карту пользователя вместо списания.
Первый уровень валидации — модель. Data Annotations здесь выполняют базовые проверки, но важно не переборщить с логикой в атрибутах. Сложные бизнес-правила должны жить в сервисном слое, а не размазываться по аннотациям.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| public class CreateSubscriptionRequest
{
[Required(ErrorMessage = "User ID is required")]
[Range(1, int.MaxValue, ErrorMessage = "User ID must be positive")]
public int UserId { get; set; }
[Required(ErrorMessage = "Plan ID is required")]
[Range(1, int.MaxValue, ErrorMessage = "Plan ID must be positive")]
public int PlanId { get; set; }
[Required(ErrorMessage = "Payment token is required")]
[StringLength(255, MinimumLength = 10, ErrorMessage = "Payment token must be between 10 and 255 characters")]
public string PaymentToken { get; set; }
[Range(0.01, 999999.99, ErrorMessage = "Amount must be between 0.01 and 999999.99")]
public decimal Amount { get; set; }
[RegularExpression("^[A-Z]{3}$", ErrorMessage = "Currency must be a valid 3-letter ISO code")]
public string Currency { get; set; }
[EmailAddress(ErrorMessage = "Invalid email format")]
public string UserEmail { get; set; }
} |
|
Второй уровень — контроллеры. ModelState.IsValid здесь работает как первый фильтр, но важно правильно обработать ошибки валидации. Возвращать пользователю техническую информацию об ошибках — плохая идея. Лучше логировать детали, а клиенту отдавать понятные сообщения.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| [HttpPost]
public async Task<ActionResult<SubscriptionResponse>> CreateSubscription([FromBody] CreateSubscriptionRequest request)
{
if (!ModelState.IsValid)
{
var errors = ModelState
.Where(x => x.Value.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Errors.Select(e => e.ErrorMessage).ToArray()
);
_logger.LogWarning("Validation failed for subscription creation: {@Errors}", errors);
return BadRequest(new {
Message = "Validation failed",
Errors = errors
});
}
var result = await _subscriptionService.CreateSubscriptionAsync(request);
return result.IsSuccess
? Ok(result.Value)
: BadRequest(new { Message = result.ErrorMessage });
} |
|
FluentValidation предоставляет более гибкие возможности для сложных сценариев валидации. Условная валидация, cross-field проверки, асинхронная валидация — все это недоступно в Data Annotations, но критично для подписочных систем.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| public class CreateSubscriptionValidator : AbstractValidator<CreateSubscriptionRequest>
{
private readonly IPlanRepository _planRepository;
private readonly IUserRepository _userRepository;
public CreateSubscriptionValidator(IPlanRepository planRepository, IUserRepository userRepository)
{
_planRepository = planRepository;
_userRepository = userRepository;
RuleFor(x => x.UserId)
.MustAsync(UserExistsAndActive)
.WithMessage("User does not exist or is inactive");
RuleFor(x => x.PlanId)
.MustAsync(PlanExistsAndActive)
.WithMessage("Selected plan is not available");
RuleFor(x => x.Amount)
.MustAsync(MatchesPlanPrice)
.WithMessage("Amount does not match plan price");
RuleFor(x => x.PaymentToken)
.MustAsync(BeValidPaymentToken)
.WithMessage("Invalid or expired payment token");
When(x => !string.IsNullOrEmpty(x.UserEmail), () => {
RuleFor(x => x.UserEmail)
.MustAsync(MatchUserEmail)
.WithMessage("Email does not match user account");
});
}
private async Task<bool> UserExistsAndActive(int userId, CancellationToken token)
{
var user = await _userRepository.GetByIdAsync(userId);
return user != null && !user.IsBlocked;
}
private async Task<bool> PlanExistsAndActive(int planId, CancellationToken token)
{
var plan = await _planRepository.GetByIdAsync(planId);
return plan != null && plan.IsActive;
}
private async Task<bool> MatchesPlanPrice(CreateSubscriptionRequest request, decimal amount, ValidationContext<CreateSubscriptionRequest> context, CancellationToken token)
{
var plan = await _planRepository.GetByIdAsync(request.PlanId);
if (plan == null) return false;
var calculatedAmount = await CalculateFinalAmount(plan, request.UserId);
return Math.Abs(amount - calculatedAmount) < 0.01m;
}
} |
|
Обработка ошибок должна быть централизована через глобальный exception handler. Неперехваченные исключения в подписочных системах особенно опасны — они могут оставить данные в неконсистентном состоянии или привести к двойному списанию средств.
Логирование ошибок требует особого внимания к персональным данным. PII (Personally Identifiable Information) не должны попадать в логи, но контекст ошибки должен быть достаточным для диагностики. Structured logging с полями UserId, SubscriptionId, OperationType помогает быстро находить проблемы без раскрытия чувствительной информации. Result pattern избавляет от необходимости throwing exceptions в бизнес-логике. Success/Failure состояния с типизированными ошибками делают код предсказуемым и тестируемым. Особенно это важно в асинхронных операциях с платежными системами, где exception может потеряться в недрах Task.
Контроллеры и представления
Контроллеры в подписочной системе — это тонкая прослойка между пользователем и бизнес-логикой. За годы разработки я видел контроллеры, которые превратились в монстров на тысячи строк, и контроллеры-пустышки, которые тупо проксировали вызовы в сервисы. Правильный баланс найти сложно, но критично важно.
Главное правило: контроллер должен знать только о HTTP и ничего больше. Получил запрос, провалидировал базовые параметры, передал в сервис, получил результат, вернул ответ. Никакой бизнес-логики, никаких прямых обращений к базе данных, никаких вычислений стоимости или проверок статусов подписок.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| [ApiController]
[Route("api/[controller]")]
[Authorize]
public class SubscriptionsController : ControllerBase
{
private readonly ISubscriptionManagementService _subscriptionService;
private readonly IMapper _mapper;
[HttpGet]
public async Task<ActionResult<List<SubscriptionDto>>> GetMySubscriptions()
{
var userId = GetCurrentUserId();
var subscriptions = await _subscriptionService.GetUserSubscriptionsAsync(userId);
return Ok(_mapper.Map<List<SubscriptionDto>>(subscriptions));
}
[HttpPost]
public async Task<ActionResult<SubscriptionDto>> CreateSubscription([FromBody] CreateSubscriptionRequest request)
{
request.UserId = GetCurrentUserId();
var result = await _subscriptionService.CreateSubscriptionAsync(request);
if (!result.IsSuccess)
return BadRequest(new { error = result.ErrorMessage });
return CreatedAtAction(nameof(GetSubscription),
new { id = result.Value.Id },
_mapper.Map<SubscriptionDto>(result.Value));
}
[HttpPut("{id}/cancel")]
public async Task<ActionResult> CancelSubscription(int id, [FromBody] CancelSubscriptionRequest request)
{
var userId = GetCurrentUserId();
var result = await _subscriptionService.CancelSubscriptionAsync(id, userId, request.Reason);
return result.IsSuccess ? NoContent() : BadRequest(new { error = result.ErrorMessage });
}
} |
|
Action фильтры экономят кучу дублирующегося кода. Проверка прав доступа к подписке, логирование операций, валидация токенов — все это выносится в атрибуты и применяется декларативно. Код контроллера остается чистым и фокусируется только на своих задачах. Model binding и validation работают автоматически, но требуют правильной настройки. Custom model binders для сложных типов, validation attributes для бизнес-правил, error handling для некорректных JSON — все эти мелочи складываются в качественный API.
Представления в MVC-части системы должны быть максимально глупыми. Вся логика остается в контроллерах и сервисах, view только отображает данные. Razor Pages здесь работают лучше классических View, особенно для простых CRUD-операций с подписками.
Создание API для фронтенда
API для фронтенда в подписочных системах — это не просто RESTful endpoints с CRUD-операциями. Это продуманный интерфейс, который должен предоставлять фронтенду ровно те данные, которые нужны для конкретных экранов, в правильном формате и с минимальным количеством запросов. Я помню проект, где мобильное приложение делало 15 запросов для отображения одного экрана подписок. Пользователи жаловались на медленную загрузку, а серверы падали под нагрузкой.
Принцип BFF (Backend for Frontend) здесь работает отлично. Разные клиенты — веб, мобайл, admin panel — имеют разные потребности в данных. Веб-интерфейс может показывать подробную историю платежей с графиками, мобайл ограничится базовой информацией о текущей подписке, а админка требует расширенных данных для саппорта.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| [ApiController]
[Route("api/v1/subscriptions")]
public class SubscriptionApiController : ControllerBase
{
private readonly ISubscriptionService _subscriptionService;
private readonly IMapper _mapper;
[HttpGet("dashboard")]
public async Task<ActionResult<SubscriptionDashboardDto>> GetDashboard()
{
var userId = GetCurrentUserId();
var dashboard = await _subscriptionService.GetUserDashboardAsync(userId);
return Ok(new SubscriptionDashboardDto
{
CurrentSubscription = _mapper.Map<CurrentSubscriptionDto>(dashboard.ActiveSubscription),
UsageStatistics = _mapper.Map<UsageStatsDto>(dashboard.Usage),
UpcomingPayments = _mapper.Map<List<UpcomingPaymentDto>>(dashboard.UpcomingPayments),
RecommendedPlans = _mapper.Map<List<PlanDto>>(dashboard.RecommendedUpgrades)
});
}
[HttpGet("plans")]
public async Task<ActionResult<List<PublicPlanDto>>> GetAvailablePlans()
{
var userId = GetCurrentUserId();
var plans = await _subscriptionService.GetAvailablePlansAsync(userId);
return Ok(plans.Select(p => new PublicPlanDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.GetPriceForUser(userId),
Currency = p.Currency,
BillingCycle = p.BillingCycle.ToString(),
Features = p.Features.Select(f => f.Name).ToList(),
IsCurrentPlan = p.Id == GetCurrentUserPlanId(),
IsUpgrade = p.Price > GetCurrentPlanPrice(),
DiscountPercentage = CalculateDiscount(p, userId)
}));
}
} |
|
DTO (Data Transfer Objects) для API должны быть специфичными для каждого endpoint'а. Никаких универсальных объектов, которые везде таскают лишние поля! Фронтенд получает только необходимые данные, что экономит трафик и ускоряет сериализацию. Версионирование API критично важно для стабильности фронтенд-приложений. Когда бизнес требует изменить структуру подписок, старые версии мобильных приложений не должны сломаться. URL versioning (/api/v1/, /api/v2/) — простой и понятный подход, который хорошо работает с кешированием.
GraphQL может быть отличной альтернативой REST для сложных клиентов. Фронтенд сам определяет, какие поля ему нужны, что особенно удобно для дашбордов с настраиваемыми виджетами. Но в подписочных системах важно контролировать доступ к чувствительным данным через field-level authorization.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| [HttpPost("preview")]
public async Task<ActionResult<SubscriptionPreviewDto>> PreviewSubscriptionChange([FromBody] ChangeSubscriptionRequest request)
{
var userId = GetCurrentUserId();
var preview = await _subscriptionService.PreviewChangeAsync(userId, request.NewPlanId, request.EffectiveDate);
return Ok(new SubscriptionPreviewDto
{
CurrentPlan = preview.CurrentPlan.Name,
NewPlan = preview.NewPlan.Name,
ProratedAmount = preview.ProratedAmount,
NextBillingDate = preview.NextBillingDate,
ImmediateCharge = preview.ImmediateCharge,
Savings = preview.EstimatedMonthlySavings,
EffectiveDate = preview.EffectiveDate,
CancellationPolicy = preview.CancellationTerms
});
} |
|
Кеширование ответов API экономит ресурсы и улучшает user experience. Список тарифных планов меняется редко, поэтому его можно кешировать на часы. Текущая подписка пользователя — на минуты. HTTP cache headers (ETag, Last-Modified) позволяют браузеру повторно использовать данные без лишних запросов к серверу.
Error handling должен быть предсказуемым и понятным для фронтенд-разработчиков. Стандартизированные коды ошибок, понятные сообщения, дополнительные детали для отладки — все это помогает быстро диагностировать проблемы. Rate limiting защищает API от злоупотреблений и помогает справедливо распределять ресурсы между пользователями. Особенно важно ограничить endpoints для создания и изменения подписок — эти операции ресурсоемкие и критичные для бизнеса.
Интеграция с платежными системами
Интеграция с платежными шлюзами — это место, где большинство разработчиков подписочных систем получают свой первый седой волос. Казалось бы, что сложного: отправил запрос в Stripe, получил ответ, списал деньги. Но реальность оказывается жестче: webhook'и приходят не в том порядке, транзакции зависают в pending, карты блокируются посреди ночи, а валютные курсы скачут быстрее американских горок. За время работы с платежными системами я понял: никогда не доверяй первому ответу от платежного API. Статус "success" может смениться на "failed" через несколько минут. Поэтому вся архитектура должна быть построена на принципе eventual consistency — данные могут быть временно несогласованными, но в итоге система приведет их к корректному состоянию.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| public interface IPaymentService
{
Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);
Task<PaymentResult> ProcessSubscriptionPaymentAsync(CreateSubscriptionPaymentRequest request);
Task<RefundResult> RefundPaymentAsync(string transactionId, decimal amount, string reason);
Task<PaymentMethod> SavePaymentMethodAsync(int userId, string paymentToken);
Task<bool> ValidateWebhookSignatureAsync(string payload, string signature);
Task ProcessWebhookAsync(string eventType, string payload);
}
public class StripePaymentService : IPaymentService
{
private readonly StripeClient _stripeClient;
private readonly IPaymentRepository _paymentRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IConfiguration _configuration;
private readonly ILogger<StripePaymentService> _logger;
public async Task<PaymentResult> ProcessSubscriptionPaymentAsync(CreateSubscriptionPaymentRequest request)
{
try
{
// Создаем Payment Intent в Stripe
var paymentIntentOptions = new PaymentIntentCreateOptions
{
Amount = (long)(request.Amount * 100), // Stripe работает в центах
Currency = request.Currency.ToLower(),
PaymentMethod = request.PaymentMethodId,
CustomerId = request.StripeCustomerId,
Confirm = true,
Metadata = new Dictionary<string, string>
{
["user_id"] = request.UserId.ToString(),
["subscription_id"] = request.SubscriptionId.ToString(),
["plan_id"] = request.PlanId.ToString()
},
Description = $"Subscription to {request.PlanName}",
ReceiptEmail = request.UserEmail
};
var service = new PaymentIntentService(_stripeClient);
var paymentIntent = await service.CreateAsync(paymentIntentOptions);
// Сохраняем платеж в нашей базе в статусе Processing
var payment = new Payment
{
UserId = request.UserId,
SubscriptionId = request.SubscriptionId,
Amount = request.Amount,
Currency = request.Currency,
ExternalTransactionId = paymentIntent.Id,
Status = PaymentStatus.Processing,
PaymentMethodLast4 = GetLastFourDigits(request.PaymentMethodId),
CreatedAt = DateTime.UtcNow,
PaymentProvider = "Stripe"
};
await _paymentRepository.SaveAsync(payment);
return paymentIntent.Status switch
{
"succeeded" => PaymentResult.Success(paymentIntent.Id, payment.Id),
"requires_action" => PaymentResult.RequiresAction(paymentIntent.ClientSecret, payment.Id),
"processing" => PaymentResult.Processing(paymentIntent.Id, payment.Id),
_ => PaymentResult.Failed($"Unexpected status: {paymentIntent.Status}", payment.Id)
};
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe payment failed for user {UserId}, subscription {SubscriptionId}",
request.UserId, request.SubscriptionId);
return PaymentResult.Failed($"Payment failed: {ex.StripeError?.Message ?? ex.Message}");
}
}
} |
|
Идемпотентность — святой грааль платежных систем. Если пользователь дважды кликнул на кнопку "Купить", система не должна списать деньги дважды. Idempotency keys решают эту проблему элегантно: каждый запрос получает уникальный ключ, и повторные запросы с тем же ключом возвращают результат первой операции.
Webhook'и от платежных систем — это асинхронный способ получения обновлений статусов платежей. Но они приходят не мгновенно, могут дублироваться, теряться в сети или приходить в неправильном порядке. Поэтому webhook processor должен быть идемпотентным и устойчивым к сбоям.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| [HttpPost("webhooks/stripe")]
public async Task<IActionResult> StripeWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"];
try
{
var stripeEvent = EventUtility.ConstructEvent(json, signature, _webhookSecret);
// Проверяем, не обрабатывали ли мы уже это событие
var existingWebhook = await _webhookRepository.GetByEventIdAsync(stripeEvent.Id);
if (existingWebhook != null)
{
_logger.LogInformation("Webhook {EventId} already processed, skipping", stripeEvent.Id);
return Ok();
}
// Сохраняем webhook для идемпотентности
await _webhookRepository.SaveAsync(new WebhookEvent
{
EventId = stripeEvent.Id,
EventType = stripeEvent.Type,
ProcessedAt = DateTime.UtcNow,
Payload = json
});
// Обрабатываем событие асинхронно
await _backgroundTaskQueue.QueueBackgroundWorkItemAsync(async token =>
{
await ProcessStripeWebhookAsync(stripeEvent);
});
return Ok();
}
catch (StripeException ex)
{
_logger.LogError(ex, "Invalid Stripe webhook signature");
return BadRequest("Invalid signature");
}
} |
|
Retry механизмы критично важны при работе с внешними API. Сеть может быть нестабильной, платежный провайдер может временно недоступен, rate limit может сработать неожиданно. Exponential backoff с jitter'ом — проверенная стратегия для повторных попыток, которая не создает thundering herd эффект.
Мониторинг платежей должен быть настроен с первого дня. Dashboard с real-time метриками успешности платежей, alerts на критическое падение conversion rate, логирование всех suspicious activities — все это помогает быстро реагировать на проблемы до того, как они повлияют на выручку. Security в платежных интеграциях требует максимального внимания. PCI DSS compliance, шифрование чувствительных данных, безопасное хранение API ключей, валидация webhook подписей — пренебрежение любым из этих аспектов может обернуться катастрофой.
Обработка коллбеков и вебхуков
Webhook'и — это та часть подписочной системы, которая работает, пока вы спите, и ломается, когда вы меньше всего этого ожидаете. За годы работы с платежными системами я понял: если что-то может пойти не так с webhook'ами, оно обязательно пойдет не так именно в пятницу вечером, когда вы уже выключили компьютер. Суть webhook'ов проста — платежная система отправляет HTTP POST-запрос на ваш сервер, когда происходит какое-то событие. Пользователь оплатил подписку, карта была отклонена, произошел chargeback — обо всем этом вы узнаете через webhook'и. Но дьявол, как всегда, в деталях.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
| [HttpPost("webhooks/payment")]
[AllowAnonymous]
public async Task<IActionResult> ProcessPaymentWebhook()
{
var requestId = Guid.NewGuid().ToString();
var body = await ReadRequestBodyAsync();
var signature = Request.Headers["X-Payment-Signature"].FirstOrDefault();
_logger.LogInformation("Received webhook {RequestId}, signature: {Signature}", requestId, signature);
try
{
// Валидируем подпись
if (!await _webhookValidator.ValidateSignatureAsync(body, signature))
{
_logger.LogWarning("Invalid webhook signature for request {RequestId}", requestId);
return BadRequest("Invalid signature");
}
// Парсим payload
var webhookEvent = JsonSerializer.Deserialize<WebhookPayload>(body);
// Проверяем идемпотентность
var existingEvent = await _webhookRepository.GetByEventIdAsync(webhookEvent.EventId);
if (existingEvent != null)
{
_logger.LogInformation("Webhook {EventId} already processed, returning success", webhookEvent.EventId);
return Ok();
}
// Сохраняем событие
var webhookRecord = new WebhookRecord
{
EventId = webhookEvent.EventId,
EventType = webhookEvent.Type,
Payload = body,
ReceivedAt = DateTime.UtcNow,
ProcessingStatus = WebhookProcessingStatus.Pending,
RequestId = requestId
};
await _webhookRepository.SaveAsync(webhookRecord);
// Обрабатываем асинхронно
await _backgroundProcessor.EnqueueAsync(new ProcessWebhookCommand
{
WebhookRecordId = webhookRecord.Id,
EventType = webhookEvent.Type,
Payload = body
});
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process webhook {RequestId}", requestId);
return StatusCode(500);
}
}
public async Task ProcessWebhookInBackground(ProcessWebhookCommand command)
{
var webhook = await _webhookRepository.GetByIdAsync(command.WebhookRecordId);
try
{
webhook.ProcessingStatus = WebhookProcessingStatus.Processing;
webhook.ProcessingStartedAt = DateTime.UtcNow;
await _webhookRepository.UpdateAsync(webhook);
var result = await ProcessWebhookByType(command.EventType, command.Payload);
webhook.ProcessingStatus = result.IsSuccess
? WebhookProcessingStatus.Completed
: WebhookProcessingStatus.Failed;
webhook.ProcessingCompletedAt = DateTime.UtcNow;
webhook.ErrorMessage = result.IsSuccess ? null : result.ErrorMessage;
webhook.RetryCount = result.IsSuccess ? 0 : webhook.RetryCount + 1;
await _webhookRepository.UpdateAsync(webhook);
}
catch (Exception ex)
{
webhook.ProcessingStatus = WebhookProcessingStatus.Failed;
webhook.ErrorMessage = ex.Message;
webhook.RetryCount++;
await _webhookRepository.UpdateAsync(webhook);
// Планируем повторную обработку если не превышен лимит
if (webhook.RetryCount < 5)
{
var delay = TimeSpan.FromMinutes(Math.Pow(2, webhook.RetryCount));
await _backgroundProcessor.EnqueueDelayedAsync(command, delay);
}
}
} |
|
Идемпотентность webhook'ов — это не роскошь, а жизненная необходимость. Платежные системы могут отправить одно и то же событие несколько раз, особенно если ваш сервер не отвечает достаточно быстро. Duplicate processing может привести к двойному начислению средств или некорректным изменениям статусов подписок.
Очередность событий — еще одна боль. Webhook о создании подписки может прийти после webhook'а о первом платеже. Система должна корректно обрабатывать такие ситуации, либо выстраивая события в правильном порядке, либо делая обработку устойчивой к неправильной последовательности. Background processing критично важен для webhook'ов. HTTP-запрос от платежной системы должен получить ответ максимально быстро, иначе провайдер решит, что ваш сервер недоступен, и начнет exponential backoff с повторными отправками. Все тяжелые операции — обновление базы данных, отправка уведомлений, интеграции с другими системами — должны выполняться асинхронно.
Retry mechanism для failed webhook'ов должен быть умным. Не все ошибки имеют смысл повторять — если событие ссылается на несуществующую подписку, повторные попытки ничего не изменят. А temporary failures вроде database timeouts или network issues стоит переповторить с разумными интервалами.
Мониторинг webhook processing помогает быстро выявлять проблемы. Dashboard с метриками success rate, средним временем обработки, количеством failed events должен быть под постоянным наблюдением. Алерты на критическое падение success rate или резкий рост failed events могут спасти от финансовых потерь.
Логирование операций для аудита платежей
Логирование в подписочных системах — это не просто техническая необходимость для отладки, а юридическое требование для соответствия финансовым регуляциям. Когда пользователь спорит чарджбек на $500, вам понадобится детальная история всех операций с его подпиской. Без proper logging вы останетесь беззащитными перед платежными системами и регуляторами.
За годы работы с financial compliance я понял: логировать нужно все, но с умом. Простое "payment processed" в лог-файле не поможет, когда нужно доказать, что транзакция была легитимной. А избыточное логирование PII может нарушить GDPR и привести к штрафам больше суммы спорного платежа.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
| public class PaymentAuditLogger : IPaymentAuditLogger
{
private readonly ILogger<PaymentAuditLogger> _logger;
private readonly IAuditRepository _auditRepository;
private readonly IUserContextProvider _userContext;
public async Task LogPaymentAttempt(PaymentAttemptEvent paymentEvent)
{
var auditEntry = new PaymentAuditEntry
{
EventType = AuditEventType.PaymentAttempt,
UserId = paymentEvent.UserId,
SubscriptionId = paymentEvent.SubscriptionId,
Amount = paymentEvent.Amount,
Currency = paymentEvent.Currency,
PaymentMethodFingerprint = HashPaymentMethod(paymentEvent.PaymentMethodId),
IPAddress = _userContext.GetClientIP(),
UserAgent = _userContext.GetUserAgent(),
SessionId = _userContext.GetSessionId(),
Timestamp = DateTime.UtcNow,
CorrelationId = paymentEvent.CorrelationId,
Metadata = new Dictionary<string, object>
{
["payment_provider"] = paymentEvent.Provider,
["billing_country"] = paymentEvent.BillingCountry,
["plan_id"] = paymentEvent.PlanId,
["is_retry"] = paymentEvent.IsRetryAttempt,
["retry_count"] = paymentEvent.RetryCount
}
};
await _auditRepository.SaveAuditEntryAsync(auditEntry);
_logger.LogInformation(
"Payment attempt logged: User {UserId}, Amount {Amount} {Currency}, Correlation {CorrelationId}",
paymentEvent.UserId,
paymentEvent.Amount,
paymentEvent.Currency,
paymentEvent.CorrelationId);
}
public async Task LogPaymentResult(PaymentResultEvent resultEvent)
{
var auditEntry = new PaymentAuditEntry
{
EventType = resultEvent.IsSuccess ? AuditEventType.PaymentSuccess : AuditEventType.PaymentFailure,
UserId = resultEvent.UserId,
SubscriptionId = resultEvent.SubscriptionId,
ExternalTransactionId = resultEvent.ExternalTransactionId,
Amount = resultEvent.Amount,
Currency = resultEvent.Currency,
ProcessingTimeMs = resultEvent.ProcessingTime.TotalMilliseconds,
ErrorCode = resultEvent.ErrorCode,
ErrorMessage = SanitizeErrorMessage(resultEvent.ErrorMessage),
Timestamp = DateTime.UtcNow,
CorrelationId = resultEvent.CorrelationId,
Metadata = new Dictionary<string, object>
{
["gateway_response_code"] = resultEvent.GatewayResponseCode,
["risk_score"] = resultEvent.RiskScore,
["processor_reference"] = resultEvent.ProcessorReference,
["decline_reason"] = resultEvent.DeclineReason
}
};
await _auditRepository.SaveAuditEntryAsync(auditEntry);
}
} |
|
Structured logging с правильными полями делает audit trail полезным для анализа. Correlation ID связывает все события одной бизнес-операции, даже если они происходят в разных сервисах и в течение нескольких минут. IP-адрес и User Agent помогают выявлять подозрительную активность и fraud attempts. Data retention для audit logs требует balance между compliance требованиями и storage costs. Платежные данные нужно хранить годами для tax reporting и dispute resolution, но детальные технические логи можно агрегировать через несколько месяцев. Холодное хранение снижает затраты без потери функциональности.
Anonymization techniques защищают персональные данные в логах. Хеширование payment method ID позволяет связывать транзакции одного пользователя без раскрытия номера карты. Email masking показывает домен, но скрывает имя пользователя. IP-адреса можно truncate, оставляя информацию о регионе. Real-time monitoring audit events помогает выявлять аномалии быстро. Внезапный рост failed payments, unusual geographic patterns, множественные попытки оплаты с одного IP — все эти patterns могут указывать на fraud или технические проблемы. Automated alerts позволяют реагировать до эскалации проблемы.
Compliance reporting генерируется автоматически из audit logs. Monthly payment volume reports, PCI DSS access logs, GDPR data processing records — правильно структурированные логи превращают мучительный manual reporting в automated процесс. Регуляторы получают необходимые данные, а команда экономит недели работы.
CORS и безопасность API-эндпоинтов
CORS в подписочных системах — это не просто техническая формальность, а критически важный элемент безопасности. Я помню случай, когда неправильно настроенные CORS-политики позволили злоумышленникам выполнять subscription API calls с поддельных доменов. Результат: сотни фиктивных подписок и головная боль на несколько недель.
Cross-Origin Resource Sharing становится особенно коварным в subscription API, потому что здесь крутятся реальные деньги. Если обычный REST API можно защитить простым "*" для development, то платежные эндпоинты требуют хирургической точности в настройке origins.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| public class SubscriptionCorsConfiguration
{
public void ConfigureCors(IServiceCollection services, IConfiguration configuration)
{
services.AddCors(options =>
{
options.AddPolicy("SubscriptionApiPolicy", builder =>
{
var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
var isProduction = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Production";
if (isProduction)
{
// Продакшен: только явно разрешенные домены
builder
.WithOrigins(allowedOrigins)
.WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
.WithHeaders("Authorization", "Content-Type", "X-Requested-With", "X-Idempotency-Key")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10));
}
else
{
// Development: более гибкие правила но с ограничениями
builder
.SetIsOriginAllowed(origin =>
{
var uri = new Uri(origin);
return uri.Host == "localhost" ||
uri.Host == "127.0.0.1" ||
allowedOrigins.Contains(origin);
})
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
});
// Отдельная политика для webhook endpoints
options.AddPolicy("WebhookPolicy", builder =>
{
builder
.AllowAnyOrigin() // Webhook'и приходят от платежных систем
.WithMethods("POST")
.WithHeaders("Content-Type", "X-Stripe-Signature", "X-PayPal-Signature");
});
});
}
} |
|
Security headers должны быть настроены агрессивно для subscription endpoints. Content Security Policy, X-Frame-Options, X-Content-Type-Options — эти headers защищают от clickjacking attacks и script injection, что особенно важно когда пользователь вводит данные платежной карты.
API Rate Limiting — обязательное требование для защиты от abuse. Попытки создания множественных подписок, spam-запросы на изменение тарифов, brute force атаки на payment endpoints — все это нужно блокировать на уровне middleware до попадания в бизнес-логику.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| [EnableRateLimiting("SubscriptionApiPolicy")]
[HttpPost("create")]
public async Task<ActionResult<SubscriptionDto>> CreateSubscription([FromBody] CreateSubscriptionRequest request)
{
var clientIP = HttpContext.Connection.RemoteIpAddress?.ToString();
var rateLimitKey = $"create_subscription:{clientIP}:{User.Identity.GetUserId()}";
var isAllowed = await _rateLimiter.CheckLimitAsync(rateLimitKey, 3, TimeSpan.FromMinutes(5));
if (!isAllowed)
{
_logger.LogWarning("Rate limit exceeded for subscription creation from IP {IP}", clientIP);
return StatusCode(429, new { error = "Too many subscription attempts. Please try again later." });
}
var result = await _subscriptionService.CreateSubscriptionAsync(request);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.ErrorMessage);
} |
|
JWT validation требует особого внимания к signature verification и token expiration. Subscription API endpoints должны проверять не только валидность токена, но и права пользователя на выполнение операций с конкретной подпиской. User может иметь валидный JWT, но не иметь прав на отмену чужой подписки. Input validation должна быть параноидальной. SQL injection через subscription parameters, XSS в user comments, path traversal в file uploads — все классические атаки адаптируются для subscription systems. White-list validation работает лучше black-list approaches.
HTTPS Enforcement обязателен для всех subscription endpoints. Redirect middleware должен принудительно перенаправлять HTTP requests на HTTPS, а HSTS headers предотвращают downgrade attacks. Certificate pinning на клиентской стороне добавляет дополнительный уровень защиты от man-in-the-middle атак. Anti-fraud measures должны быть встроены в API layer. Geolocation checks, device fingerprinting, behavioral analysis — современные subscription системы анализируют паттерны использования в real-time и блокируют подозрительные операции до их завершения. Machine learning models помогают выявлять sophisticated fraud schemes, которые обходят простые rule-based filters.
Middleware для обработки истекших подписок
Middleware для истекших подписок — это молчаливый страж, который работает на каждом запросе и следит за тем, чтобы пользователи с просроченными подписками не получали доступ к премиум-функциям. Я помню проект, где отсутствие такого middleware привело к тому, что пользователи месяцами пользовались платным контентом бесплатно — просто потому, что система проверяла статус подписки только при входе в систему.
Основная идея middleware проста: перехватывать HTTP-запросы до их попадания в контроллеры и проверять актуальность подписки пользователя. Но реализация требует тонкости — нельзя дергать базу данных на каждом запросе, иначе производительность упадет в разы.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| public class SubscriptionValidationMiddleware
{
private readonly RequestDelegate _next;
private readonly IServiceProvider _serviceProvider;
private readonly IMemoryCache _cache;
private readonly ILogger<SubscriptionValidationMiddleware> _logger;
private readonly HashSet<string> _excludedPaths;
public SubscriptionValidationMiddleware(
RequestDelegate next,
IServiceProvider serviceProvider,
IMemoryCache cache,
ILogger<SubscriptionValidationMiddleware> logger,
IConfiguration configuration)
{
_next = next;
_serviceProvider = serviceProvider;
_cache = cache;
_logger = logger;
_excludedPaths = configuration.GetSection("SubscriptionMiddleware:ExcludedPaths")
.Get<string[]>()?.ToHashSet() ?? new HashSet<string>();
}
public async Task InvokeAsync(HttpContext context)
{
// Пропускаем публичные endpoints
if (ShouldSkipValidation(context))
{
await _next(context);
return;
}
var userId = GetUserIdFromContext(context);
if (userId == null)
{
await _next(context);
return;
}
var subscriptionStatus = await GetUserSubscriptionStatusAsync(userId.Value);
if (subscriptionStatus == SubscriptionValidationResult.Expired)
{
await HandleExpiredSubscription(context, userId.Value);
return;
}
if (subscriptionStatus == SubscriptionValidationResult.Suspended)
{
await HandleSuspendedSubscription(context, userId.Value);
return;
}
// Добавляем информацию о подписке в контекст для использования в контроллерах
context.Items["UserSubscriptionStatus"] = subscriptionStatus;
await _next(context);
}
private bool ShouldSkipValidation(HttpContext context)
{
var path = context.Request.Path.Value?.ToLowerInvariant();
return path == null ||
_excludedPaths.Any(excluded => path.StartsWith(excluded)) ||
path.StartsWith("/api/auth/") ||
path.StartsWith("/api/webhooks/") ||
path.StartsWith("/health") ||
context.Request.Method == "OPTIONS";
}
} |
|
Кеширование результатов проверки критично важно для производительности. Статус подписки меняется не так часто, поэтому можно безопасно кешировать результат на несколько минут. Но важно правильно инвалидировать кеш при изменениях подписки.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| private async Task<SubscriptionValidationResult> GetUserSubscriptionStatusAsync(int userId)
{
var cacheKey = $"user_subscription_status_{userId}";
if (_cache.TryGetValue(cacheKey, out SubscriptionValidationResult cachedResult))
{
return cachedResult;
}
using var scope = _serviceProvider.CreateScope();
var subscriptionService = scope.ServiceProvider.GetRequiredService<ISubscriptionService>();
var subscription = await subscriptionService.GetActiveSubscriptionAsync(userId);
var result = subscription switch
{
null => SubscriptionValidationResult.NoSubscription,
{ Status: SubscriptionStatus.Active } when subscription.ExpiresAt > DateTime.UtcNow =>
SubscriptionValidationResult.Active,
{ Status: SubscriptionStatus.Active } when subscription.ExpiresAt <= DateTime.UtcNow =>
SubscriptionValidationResult.Expired,
{ Status: SubscriptionStatus.Suspended } => SubscriptionValidationResult.Suspended,
_ => SubscriptionValidationResult.Invalid
};
// Кешируем результат с разным TTL в зависимости от статуса
var cacheDuration = result switch
{
SubscriptionValidationResult.Active => TimeSpan.FromMinutes(5),
SubscriptionValidationResult.Expired => TimeSpan.FromMinutes(1),
SubscriptionValidationResult.Suspended => TimeSpan.FromMinutes(2),
_ => TimeSpan.FromMinutes(10)
};
_cache.Set(cacheKey, result, cacheDuration);
return result;
}
private async Task HandleExpiredSubscription(HttpContext context, int userId)
{
_logger.LogInformation("Blocked access for user {UserId} with expired subscription", userId);
if (context.Request.Path.StartsWithSegments("/api"))
{
// API запрос - возвращаем JSON с информацией об истечении
context.Response.StatusCode = 402; // Payment Required
context.Response.ContentType = "application/json";
var response = new
{
error = "subscription_expired",
message = "Your subscription has expired. Please renew to continue using this service.",
renewal_url = "/billing/renew",
grace_period_days = CalculateGracePeriodDays(userId)
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response));
}
else
{
// Web запрос - редирект на страницу обновления подписки
context.Response.Redirect("/subscription/expired");
}
} |
|
Grace period handling добавляет человечности в систему. Вместо жесткого отключения доступа в момент истечения подписки, можно дать пользователю несколько дней на решение проблем с оплатой. Это особенно важно для B2B клиентов, где процесс оплаты может занимать время. Background job для массовой обработки истекших подписок работает в связке с middleware. Каждую ночь job сканирует базу данных, находит просроченные подписки и обновляет их статусы. Это гарантирует, что middleware не пропустит истекшие подписки из-за проблем с кешированием. Metrics collection в middleware помогает отслеживать здоровье подписочной системы. Количество заблокированных запросов, топ-10 endpoints с истекшими подписками, conversion rate от блокировки до продления — все эти метрики дают ценную информацию для бизнеса и техкоманды.
Интеграция с внешними CRM-системами для синхронизации данных
Интеграция подписочной системы с CRM — это как свидание вслепую между двумя сложными системами, которые говорят на разных языках и имеют совершенно разные представления о том, что такое "клиент". Я видел проекты, где на синхронизацию с Salesforce уходило больше времени, чем на всю подписочную логику вместе взятую. И это при том, что на бумаге все выглядело просто: "Нужно передавать данные о подписках в CRM для sales team'а".
Основная проблема не в технических деталях API, а в принципиально разном понимании данных. CRM оперирует leads, opportunities, contacts, accounts. Подписочная система знает users, subscriptions, payments, plans. И где-то между этими мирами должен существовать мост, который переводит один язык на другой без потери смысла.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
| public interface ICrmIntegrationService
{
Task SyncUserToCrmAsync(int userId, CrmSyncOptions options);
Task SyncSubscriptionToCrmAsync(int subscriptionId);
Task HandleSubscriptionStatusChangeAsync(int subscriptionId, SubscriptionStatus oldStatus, SubscriptionStatus newStatus);
Task<CrmSyncResult> BulkSyncUsersAsync(IEnumerable<int> userIds);
Task ProcessWebhookFromCrmAsync(string webhook, string payload);
}
public class SalesforceCrmService : ICrmIntegrationService
{
private readonly ISalesforceClient _salesforceClient;
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly ICrmMappingService _mappingService;
private readonly ILogger<SalesforceCrmService> _logger;
public async Task SyncSubscriptionToCrmAsync(int subscriptionId)
{
var subscription = await _subscriptionRepository.GetByIdWithDetailsAsync(subscriptionId);
if (subscription == null) return;
var crmContact = await FindOrCreateCrmContact(subscription.User);
var crmOpportunity = await _mappingService.MapSubscriptionToOpportunity(subscription, crmContact);
try
{
if (crmOpportunity.Id == null)
{
// Создаем новую возможность в CRM
var createResult = await _salesforceClient.CreateOpportunityAsync(crmOpportunity);
if (createResult.IsSuccess)
{
await UpdateSubscriptionWithCrmId(subscriptionId, createResult.Id);
await CreateSubscriptionHistory(subscriptionId, "CRM_SYNC_CREATED", createResult.Id);
}
}
else
{
// Обновляем существующую
var updateResult = await _salesforceClient.UpdateOpportunityAsync(crmOpportunity);
if (updateResult.IsSuccess)
{
await CreateSubscriptionHistory(subscriptionId, "CRM_SYNC_UPDATED", crmOpportunity.Id);
}
}
}
catch (CrmApiException ex) when (ex.IsRateLimit)
{
// Откладываем синхронизацию при превышении лимитов
await _backgroundQueue.EnqueueDelayedAsync(
new SyncSubscriptionCommand(subscriptionId),
TimeSpan.FromMinutes(15));
}
}
private async Task<CrmContact> FindOrCreateCrmContact(ApplicationUser user)
{
// Ищем существующий контакт по email
var existingContact = await _salesforceClient.FindContactByEmailAsync(user.Email);
if (existingContact != null)
{
return existingContact;
}
// Создаем новый контакт
var newContact = new CrmContact
{
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
Company = user.CompanyName ?? "Individual",
Source = "Subscription System",
CreatedDate = user.CreatedAt,
CustomFields = new Dictionary<string, object>
{
["Subscription_User_Id__c"] = user.Id,
["First_Subscription_Date__c"] = user.FirstSubscriptionDate,
["Total_Lifetime_Value__c"] = await CalculateLifetimeValue(user.Id)
}
};
var createResult = await _salesforceClient.CreateContactAsync(newContact);
return createResult.IsSuccess ? createResult.Contact : null;
}
} |
|
Mapping между системами — это отдельная наука. Один subscription может превратиться в несколько CRM записей: Contact для пользователя, Account для компании, Opportunity для текущей подписки, плюс несколько Activities для истории платежей. И все эти связи должны поддерживаться в синхронизированном состоянии. Конфликты данных неизбежны при двусторонней синхронизации. Sales manager изменил название компании в CRM, а пользователь обновил профиль в subscription system. Какие данные считать актуальными? Timestamp-based conflict resolution работает не всегда — иногда нужны более sophisticated стратегии с human approval для критичных изменений.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| public async Task<ConflictResolutionResult> ResolveDataConflict(DataConflict conflict)
{
var strategy = _conflictStrategies[conflict.FieldName];
return strategy switch
{
ConflictStrategy.CrmWins => await ApplyCrmData(conflict),
ConflictStrategy.SubscriptionWins => await ApplySubscriptionData(conflict),
ConflictStrategy.MostRecent => await ApplyMostRecentData(conflict),
ConflictStrategy.ManualReview => await QueueForManualReview(conflict),
_ => await ApplyDefaultStrategy(conflict)
};
}
private async Task ProcessBidirectionalSync()
{
// Синхронизируем изменения из CRM в subscription system
var crmChanges = await _salesforceClient.GetChangedRecordsSince(_lastSyncTime);
foreach (var change in crmChanges)
{
var subscriptionUserId = change.GetField("Subscription_User_Id__c");
if (subscriptionUserId != null)
{
await ProcessCrmChangeInSubscriptionSystem(change, subscriptionUserId);
}
}
// Синхронизируем изменения из subscription system в CRM
var subscriptionChanges = await _subscriptionRepository.GetChangedSubscriptionsSince(_lastSyncTime);
foreach (var subscription in subscriptionChanges)
{
await SyncSubscriptionToCrmAsync(subscription.Id);
}
} |
|
Webhooks от CRM систем добавляют real-time dimension к синхронизации. Когда sales rep закрывает deal в Salesforce, subscription system может автоматически активировать соответствующую подписку. Но webhook ordering и idempotency здесь еще важнее, чем с платежными системами — CRM данные имеют сложные зависимости.
Bulk operations critical для initial sync и массовых обновлений. Загрузка 100,000 пользователей в CRM по одному займет дни и превысит все rate limits. Batch API всех major CRM платформ требует специального handling — chunking данных, async processing, error recovery для частично failed batches. Performance monitoring CRM интеграций показывает bottlenecks, которые не видны в обычных метриках приложения. Latency CRM API может варьироваться от милисекунд до минут, в зависимости от нагрузки на их системы. Circuit breaker pattern prevents cascade failures когда CRM недоступна, но синхронизация должна продолжаться после восстановления связи.
Error handling в CRM интеграциях особенно коварен — failed sync может оставить данные в inconsistent состоянии между системами. Compensation patterns помогают откатить частичные изменения, а dead letter queues сохраняют failed sync attempts для manual investigation. The key is maintaining audit trail of all sync operations, чтобы можно было reconstruct что пошло не так и когда.
Безопасность и производительность
Безопасность и производительность в подписочных системах — это не две отдельные проблемы, а два аспекта одной задачи. Медленная система открывает уязвимости для DoS-атак, а избыточная защита может убить производительность. За годы оптимизации подписочных платформ я понял: нельзя жертвовать одним ради другого — нужно найти баланс, который обеспечит и скорость работы, и надежную защиту.
Первая линия обороны — это правильная архитектура, которая не создает bottlenecks. Я видел системы, где каждая проверка подписки вызывала 5 SQL-запросов и 3 обращения к внешним API. При нагрузке в 1000 RPS такая архитектура превращалась в черную дыру, поглощающую все ресурсы сервера. А когда система тормозит, злоумышленники получают дополнительное время для exploiting уязвимостей. Caching стратегии должны учитывать security implications. Кешировать данные подписки можно и нужно, но важно правильно разграничить, что кешируется и для кого. User-specific данные никогда не должны попадать в shared cache — это прямая дорога к data leaks между пользователями. Я использую многоуровневое кеширование: публичные данные (список планов) в Redis shared cache, пользовательские данные в локальном memory cache с коротким TTL.
Database indexing критически важен не только для скорости, но и для предотвращения resource exhaustion attacks. Злоумышленник может специально создавать медленные запросы, которые блокируют таблицы подписок надолго. Правильные индексы и query timeouts предотвращают такие атаки. Connection pooling тоже играет роль в безопасности — ограниченный pool размер не позволяет exhaustить database connections через массивные паралельные запросы.
Load balancing и circuit breakers защищают от cascade failures. Когда один instance подписочной системы падает под нагрузкой, load balancer должен перенаправить трафик на здоровые серверы. Circuit breaker предотвращает отправку запросов на падающие dependencies, давая им время восстановиться. Это особенно важно для платежных интеграций — failed payment gateway не должен роняться всю систему.
Rate limiting на уровне приложения дополняет network-level защиту. Distributed rate limiting через Redis позволяет координировать limits между multiple instances. Sliding window algorithms обеспечивают более справедливое распределение запросов по сравнению с простыми fixed window approaches. При этом legitimate пользователи не страдают от агрессивных limits, а attackers эффективно блокируются.
Защита от SQL-инъекций и XSS
SQL-инъекции в подписочных системах — это не просто теоретическая угроза из учебников по безопасности. Это реальная проблема, которая может привести к утечке данных миллионов пользователей, включая платежную информацию. Я помню инцидент, когда одна подписочная платформа потеряла данные 2 миллионов клиентов из-за SQL-инъекции в форме поиска по подпискам. Злоумышленник использовал UNION SELECT для извлечения хешей паролей и токенов платежных карт.
Entity Framework защищает от большинства SQL-инъекций автоматически, но только при правильном использовании. Опасность скрывается в raw SQL queries и динамически построенных LINQ-выражениях. Когда бизнес требует сложную фильтрацию подписок по множеству критериев, легко соскользнуть в string concatenation для построения запросов.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // ОПАСНО - уязвимо для SQL-инъекций
public async Task<List<Subscription>> SearchSubscriptions(string userEmail, string planName)
{
var sql = $"SELECT * FROM Subscriptions s INNER JOIN Users u ON s.UserId = u.Id WHERE u.Email LIKE '%{userEmail}%' AND s.PlanName LIKE '%{planName}%'";
return await _context.Subscriptions.FromSqlRaw(sql).ToListAsync();
}
// БЕЗОПАСНО - использует параметризованные запросы
public async Task<List<Subscription>> SearchSubscriptionsSafe(string userEmail, string planName)
{
return await _context.Subscriptions
.Where(s => s.User.Email.Contains(userEmail) && s.Plan.Name.Contains(planName))
.ToListAsync();
}
// Если raw SQL неизбежен - используем параметры
public async Task<List<Subscription>> GetSubscriptionsByCustomQuery(string status, decimal minAmount)
{
var sql = "SELECT * FROM Subscriptions WHERE Status = {0} AND Amount >= {1}";
return await _context.Subscriptions
.FromSqlRaw(sql, status, minAmount)
.ToListAsync();
} |
|
Dynamic LINQ становится ловушкой при построении сложных фильтров. Когда пользователь может выбирать поля для сортировки и фильтрации через UI, возникает соблазн передавать эти параметры напрямую в OrderBy или Where. Но злоумышленник может подсунуть вместо честного "CreatedAt" что-то вроде "1=1; DROP TABLE Users; --".
Stored procedures не панацея, но дополнительный уровень защиты. Критические операции вроде обновления статуса подписки или расчета billing amounts можно вынести в stored procedures с строгой валидацией параметров. Это ограничивает attack surface и упрощает аудит изменений в базе данных.
XSS-атаки в подписочных системах особенно коварны, потому что здесь пользователи вводят много данных: названия компаний, адреса, комментарии к отмене подписок. Каждое поле ввода — потенциальная точка для injection malicious scripts. А когда эти данные отображаются в admin панели или emails, зараженный скрипт может выполниться в контексте привилегированного пользователя.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| // Модель с автоматическим HTML-кодированием
public class SubscriptionCancellationRequest
{
[Required]
[MaxLength(500)]
[RegularExpression(@"^[a-zA-Z0-9\s.,!?-]+$", ErrorMessage = "Invalid characters in reason")]
public string CancellationReason { get; set; }
[HtmlSanitize] // Custom attribute для санитизации
public string AdditionalComments { get; set; }
}
// Custom validation attribute для санитизации HTML
public class HtmlSanitizeAttribute : ValidationAttribute
{
public override bool IsValid(object value)
{
if (value is string stringValue)
{
var sanitized = HtmlSanitizer.Sanitize(stringValue);
return sanitized == stringValue;
}
return true;
}
}
// В контроллере - дополнительная валидация
[HttpPost]
public async Task<ActionResult> CancelSubscription([FromBody] SubscriptionCancellationRequest request)
{
// Санитизация входных данных
request.CancellationReason = HtmlEncoder.Default.Encode(request.CancellationReason ?? string.Empty);
request.AdditionalComments = SanitizeUserInput(request.AdditionalComments);
var result = await _subscriptionService.CancelSubscriptionAsync(request);
return result.IsSuccess ? Ok() : BadRequest(result.ErrorMessage);
}
private string SanitizeUserInput(string input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
// Удаляем потенциально опасные теги и атрибуты
var sanitizer = new HtmlSanitizer();
sanitizer.AllowedTags.Clear();
sanitizer.AllowedAttributes.Clear();
return sanitizer.Sanitize(input);
} |
|
Content Security Policy headers критически важны для предотвращения XSS. Правильно настроенный CSP блокирует execution любых inline scripts и ограничивает sources для загрузки JavaScript. В subscription systems это особенно важно, потому что здесь часто используются third-party скрипты для payment processing. Output encoding должен применяться везде, где пользовательские данные отображаются в HTML. Razor views в ASP.NET MVC автоматически кодируют вывод через @, но если используется @Html.Raw или manual string concatenation, кодирование теряется. Email templates тоже требуют внимания — malicious script в email может выполниться при открытии письма в веб-клиенте.
CSRF protection обязателен для всех форм изменения подписок. Anti-forgery tokens предотвращают cross-site request forgery, когда злоумышленный сайт отправляет запросы от имени авторизованного пользователя. Особенно критично защитить endpoints для смены тарифного плана и отмены подписок — эти операции имеют финансовые последствия и должны выполняться только по явному намерению пользователя.
Кеширование данных подписок
Кеширование в подписочных системах — это не просто optimization technique, а жизненная необходимость. Когда каждый HTTP-запрос проверяет статус подписки, количество обращений к базе данных растет экспоненциально с ростом пользователей. Я помню проект, где отсутствие кеширования привело к тому, что база данных падала под нагрузкой уже при 500 одновременных пользователях. После внедрения многоуровневого кеширования та же система спокойно выдерживала 10,000 concurrent users.
Основная проблема кеширования подписок — это invalidation complexity. Статус подписки может измениться по десятку причин: истечение срока, failed payment, manual suspension, plan upgrade. И каждое изменение должно немедленно отразиться во всех cache layers. Stale cache в subscription system может привести к тому, что пользователь с просроченной подпиской получит доступ к premium контенту.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
| public class SubscriptionCacheService : ISubscriptionCacheService
{
private readonly IMemoryCache _memoryCache;
private readonly IDistributedCache _distributedCache;
private readonly ISubscriptionRepository _repository;
private readonly ILogger<SubscriptionCacheService> _logger;
private readonly CacheInvalidationService _invalidationService;
public async Task<Subscription> GetActiveSubscriptionAsync(int userId)
{
// L1 Cache - Memory Cache (самый быстрый)
var memoryCacheKey = $"subscription:active:{userId}";
if (_memoryCache.TryGetValue(memoryCacheKey, out Subscription memoryResult))
{
return memoryResult;
}
// L2 Cache - Distributed Cache (Redis)
var distributedCacheKey = $"sub:active:{userId}";
var cachedData = await _distributedCache.GetStringAsync(distributedCacheKey);
if (!string.IsNullOrEmpty(cachedData))
{
var subscription = JsonSerializer.Deserialize<Subscription>(cachedData);
// Обновляем L1 cache
_memoryCache.Set(memoryCacheKey, subscription, TimeSpan.FromMinutes(2));
return subscription;
}
// Cache miss - идем в базу данных
var dbSubscription = await _repository.GetActiveSubscriptionAsync(userId);
if (dbSubscription != null)
{
// Кешируем в оба уровня
var serialized = JsonSerializer.Serialize(dbSubscription);
await _distributedCache.SetStringAsync(distributedCacheKey, serialized, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
_memoryCache.Set(memoryCacheKey, dbSubscription, TimeSpan.FromMinutes(2));
}
return dbSubscription;
}
public async Task InvalidateUserSubscriptionAsync(int userId)
{
var tasks = new List<Task>
{
_distributedCache.RemoveAsync($"sub:active:{userId}"),
_distributedCache.RemoveAsync($"sub:all:{userId}"),
_distributedCache.RemoveAsync($"sub:history:{userId}")
};
// Инвалидируем локальный кеш
_memoryCache.Remove($"subscription:active:{userId}");
_memoryCache.Remove($"subscriptions:all:{userId}");
await Task.WhenAll(tasks);
// Уведомляем другие instances о необходимости инвалидации
await _invalidationService.BroadcastInvalidationAsync(userId);
}
} |
|
Write-through кеширование обеспечивает консистентность при обновлениях. Когда подписка изменяется, новые данные записываются одновременно и в базу данных, и в cache. Это гарантирует, что cache всегда содержит актуальную информацию, но увеличивает latency операций записи. Cache warming стратегии помогают избежать cache miss storms. При запуске приложения или после массовой инвалидации cache'а, все запросы идут в базу данных одновременно. Pre-warming критичных данных (активные подписки VIP пользователей, популярные тарифные планы) предотвращает cascade database overload.
Distributed cache coordination между multiple application instances требует sophisticated invalidation mechanisms. Когда один instance обновляет подписку, все остальные должны узнать об этом и очистить свои локальные cache'и. Redis pub/sub или SignalR backplane решают эту проблему элегантно.
TTL (Time To Live) настройки должны balancе между performance и data freshness. Активные подписки можно кешировать на 5-10 минут — они меняются редко. Expired подписки лучше кешировать на 1-2 минуты — пользователь может быстро продлить подписку. Payment processing данные вообще не стоит кешировать — они слишком volatile.
Conditional caching based на subscription tier добавляет intelligence в cache strategy. VIP пользователей можно кешировать агрессивнее — у них больше tolerance к eventual consistency, зато performance критичен. Free tier пользователей кешируем меньше — они чаще меняют планы и experimenting с системой. Cache metrics должны отслеживаться в real-time. Hit ratio, average response time, cache memory usage, invalidation frequency — все эти показатели помогают fine-tune cache configuration. Low hit ratio указывает на неправильные TTL settings или слишком частые invalidations. High memory usage может привести к cache evictions и degraded performance.
Реализация JWT-токенов для авторизации API подписок
JWT-токены в подписочных системах — это не просто модный тренд в веб-разработке, а практическая необходимость для stateless авторизации API. Когда у вас несколько микросервисов, мобильные приложения и веб-клиенты, session-based аутентификация превращается в головную боль. Я помню проект, где мы пытались синхронизировать сессии между тремя разными приложениями — результатом стал код, который больше походил на spaghetti чем на архитектуру.
JWT решает проблему элегантно: токен содержит всю необходимую информацию о пользователе и его подписке прямо внутри себя. Но здесь кроется подвох — в subscription systems информация о подписке меняется часто, а токен живет долго. Пользователь может отменить подписку, но его JWT еще час будет говорить, что он premium subscriber.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| public class SubscriptionJwtService : IJwtService
{
private readonly IConfiguration _configuration;
private readonly ISubscriptionService _subscriptionService;
private readonly ILogger<SubscriptionJwtService> _logger;
public async Task<string> GenerateTokenAsync(ApplicationUser user)
{
var subscription = await _subscriptionService.GetActiveSubscriptionAsync(user.Id);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
// Подписочные claims
new Claim("subscription_status", subscription?.Status.ToString() ?? "None"),
new Claim("plan_id", subscription?.PlanId.ToString() ?? "0"),
new Claim("expires_at", subscription?.ExpiresAt.ToString("O") ?? string.Empty),
new Claim("subscription_tier", GetSubscriptionTier(subscription)),
// Дополнительные claims для авторизации
new Claim("can_create_projects", CanCreateProjects(subscription).ToString()),
new Claim("max_storage_gb", GetMaxStorage(subscription).ToString()),
new Claim("api_rate_limit", GetApiRateLimit(subscription).ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"]));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15), // Короткий срок жизни
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private string GetSubscriptionTier(Subscription subscription)
{
return subscription?.Plan?.Name switch
{
"Free" => "basic",
"Pro" => "professional",
"Enterprise" => "enterprise",
_ => "none"
};
}
} |
|
Short-lived tokens с refresh механизмом решают проблему stale subscription data. Access token живет 15-30 минут, refresh token — несколько дней. При каждом обновлении access token'а система проверяет актуальный статус подписки и включает fresh данные в новый токен. Это balances между performance и data accuracy.
Custom claims для subscription-specific авторизации делают JWT по-настоящему мощным инструментом. Вместо проверки подписки на каждом запросе, middleware читает claims прямо из токена. "can_create_projects", "max_api_calls_per_hour", "storage_limit_gb" — все эти права кодируются в токене при его создании.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| [Authorize]
[RequiresClaim("subscription_tier", "professional", "enterprise")]
public class ProjectController : ControllerBase
{
[HttpPost]
[RequiresClaim("can_create_projects", "true")]
public async Task<ActionResult<ProjectDto>> CreateProject([FromBody] CreateProjectRequest request)
{
var maxProjects = int.Parse(User.FindFirst("max_projects")?.Value ?? "0");
var currentProjects = await _projectService.GetUserProjectCountAsync(GetCurrentUserId());
if (currentProjects >= maxProjects)
return BadRequest("Project limit exceeded for your subscription tier");
var result = await _projectService.CreateProjectAsync(request);
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.ErrorMessage);
}
}
public class RequiresClaimAttribute : Attribute, IAuthorizationRequirement
{
public string ClaimType { get; }
public string[] AllowedValues { get; }
public RequiresClaimAttribute(string claimType, params string[] allowedValues)
{
ClaimType = claimType;
AllowedValues = allowedValues;
}
} |
|
Token revocation становится проблемой при критичных изменениях подписки. Когда пользователь отменяет подписку или его аккаунт блокируется, все выданные токены должны стать недействительными немедленно. Blacklist approach с Redis cache решает эту проблему — revoked токены сохраняются в cache до их natural expiration.
Signature verification должна быть bulletproof в production. Private keys для подписи токенов хранятся в Azure Key Vault или аналогичных secure storage systems. Key rotation каждые несколько месяцев обеспечивает дополнительную безопасность, но требует careful coordination чтобы не сломать active sessions. Rate limiting на основе JWT claims позволяет implement tiered API access без дополнительных database lookups. Free tier users получают 100 requests/hour, Pro users — 10,000, Enterprise — unlimited. Rate limiter читает limit прямо из токена и применяет appropriate restrictions.
Subscription webhooks должны trigger token invalidation при значительных изменениях. Downgrade с Enterprise на Free tier требует немедленной invalidation всех active токенов пользователя — иначе он сможет пользоваться enterprise features до истечения токена. Background job сканирует expired subscriptions и добавляет соответствующие токены в blacklist.
Реализация идемпотентности операций подписки
Идемпотентность в подписочных системах — это не академическая концепция, а спасательный круг в океане сетевых сбоев и пользовательских ошибок. Когда пользователь нервно кликает "Оплатить" пять раз подряд, система не должна создавать пять подписок. Когда webhook от платежной системы приходит три раза из-за таймаутов, обработка должна произойти только один раз. Я видел системы, где отсутствие идемпотентности приводило к тому, что с пользователей списывались деньги за несуществующие подписки или создавались дублирующие аккаунты.
Основа идемпотентности — это уникальные ключи операций. Каждый критичный запрос должен содержать идентификатор, который остается неизменным при повторных попытках. Для API endpoints это обычно специальный header Idempotency-Key, для внутренних операций — комбинация из user ID, operation type и timestamp.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
| public class IdempotentSubscriptionService : ISubscriptionService
{
private readonly ISubscriptionRepository _repository;
private readonly IIdempotencyStore _idempotencyStore;
private readonly IPaymentService _paymentService;
private readonly ILogger<IdempotentSubscriptionService> _logger;
public async Task<Result<Subscription>> CreateSubscriptionAsync(
CreateSubscriptionRequest request,
string idempotencyKey)
{
// Проверяем, не обрабатывали ли мы уже этот запрос
var existingResult = await _idempotencyStore.GetResultAsync(idempotencyKey);
if (existingResult != null)
{
_logger.LogInformation("Returning cached result for idempotency key {Key}", idempotencyKey);
return existingResult.IsSuccess
? Result.Success(existingResult.Subscription)
: Result.Fail<Subscription>(existingResult.ErrorMessage);
}
// Блокируем параллельные запросы с тем же ключом
using var lockHandle = await _idempotencyStore.AcquireLockAsync(idempotencyKey, TimeSpan.FromMinutes(2));
if (lockHandle == null)
{
return Result.Fail<Subscription>("Duplicate request in progress");
}
try
{
// Повторно проверяем после получения блокировки
existingResult = await _idempotencyStore.GetResultAsync(idempotencyKey);
if (existingResult != null)
{
return existingResult.IsSuccess
? Result.Success(existingResult.Subscription)
: Result.Fail<Subscription>(existingResult.ErrorMessage);
}
// Выполняем основную логику
var subscription = new Subscription
{
UserId = request.UserId,
PlanId = request.PlanId,
Status = SubscriptionStatus.Pending,
CreatedAt = DateTime.UtcNow,
IdempotencyKey = idempotencyKey
};
await _repository.SaveAsync(subscription);
var paymentResult = await _paymentService.ProcessPaymentAsync(new PaymentRequest
{
Amount = request.Amount,
Currency = request.Currency,
PaymentMethodId = request.PaymentMethodId,
IdempotencyKey = $"{idempotencyKey}_payment"
});
if (paymentResult.IsSuccess)
{
subscription.Status = SubscriptionStatus.Active;
subscription.ActivatedAt = DateTime.UtcNow;
subscription.ExternalPaymentId = paymentResult.TransactionId;
await _repository.UpdateAsync(subscription);
}
else
{
subscription.Status = SubscriptionStatus.Failed;
subscription.FailureReason = paymentResult.ErrorMessage;
await _repository.UpdateAsync(subscription);
}
// Сохраняем результат для будущих повторных запросов
await _idempotencyStore.StoreResultAsync(idempotencyKey, new IdempotencyResult
{
IsSuccess = paymentResult.IsSuccess,
Subscription = paymentResult.IsSuccess ? subscription : null,
ErrorMessage = paymentResult.IsSuccess ? null : paymentResult.ErrorMessage,
CreatedAt = DateTime.UtcNow
});
return paymentResult.IsSuccess
? Result.Success(subscription)
: Result.Fail<Subscription>(paymentResult.ErrorMessage);
}
catch (Exception ex)
{
await _idempotencyStore.StoreResultAsync(idempotencyKey, new IdempotencyResult
{
IsSuccess = false,
ErrorMessage = "Internal server error",
CreatedAt = DateTime.UtcNow
});
_logger.LogError(ex, "Failed to create subscription with idempotency key {Key}", idempotencyKey);
throw;
}
}
} |
|
Distributed locking критичен для предотвращения race conditions. Когда два одинаковых запроса приходят одновременно, только один должен выполнить реальную работу, второй должен ждать результата первого. Redis с его atomic operations идеально подходит для реализации distributed locks с automatic expiration. Webhook processing требует особого подхода к идемпотентности. Event ID от платежной системы становится natural idempotency key, но важно обрабатывать ситуации, когда webhook приходит раньше API response. Иногда система узнает об успешном платеже из webhook'а раньше, чем получает positive response от payment API.
Cleanup механизмы предотвращают бесконечный рост idempotency store. Старые записи нужно удалять через reasonable время — обычно 24-48 часов для API operations и несколько дней для critical business operations. При этом важно не удалить записи слишком рано, иначе legitimate retries будут обработаны как новые операции.
Error handling в идемпотентных операциях должен различать transient и permanent errors. Network timeouts или temporary database unavailability не должны кешироваться — повторный запрос может быть успешным. А вот invalid payment method или insufficient funds — это permanent errors, которые стоит закешировать, чтобы не повторять бесполезные операции.
Rate limiting для защиты от DDoS-атак
Rate limiting в подписочных системах — это не просто защита от злоумышленников, а инструмент обеспечения справедливого распределения ресурсов между всеми пользователями. Я помню инцидент, когда один разработчик запустил бота для массового тестирования API подписок и случайно положил всю систему. Пока мы разбирались с проблемой, тысячи реальных пользователей не могли оплатить подписки. После этого случая я понял: rate limiting — это не paranoia, а базовая гигиена любой публичной системы.
Классические DDoS-атаки против subscription API особенно коварны, потому что каждый запрос на создание или изменение подписки требует множественных операций: проверка базы данных, обращение к платежному API, отправка уведомлений. Даже небольшой поток "легитимных" запросов может overwhelm систему, если они приходят слишком быстро.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
| public class AdvancedRateLimitingMiddleware
{
private readonly RequestDelegate _next;
private readonly IDistributedCache _cache;
private readonly IConfiguration _configuration;
private readonly ILogger<AdvancedRateLimitingMiddleware> _logger;
public async Task InvokeAsync(HttpContext context)
{
var endpoint = context.GetEndpoint();
var rateLimitPolicy = GetRateLimitPolicy(context.Request.Path, context.Request.Method);
if (rateLimitPolicy == null)
{
await _next(context);
return;
}
var clientIdentifier = GetClientIdentifier(context);
var rateLimitResult = await CheckRateLimitAsync(clientIdentifier, rateLimitPolicy);
// Добавляем headers с информацией о лимитах
context.Response.Headers["X-RateLimit-Limit"] = rateLimitPolicy.RequestsPerWindow.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, rateLimitPolicy.RequestsPerWindow - rateLimitResult.CurrentCount).ToString();
context.Response.Headers["X-RateLimit-Reset"] = rateLimitResult.WindowResetTime.ToString();
if (rateLimitResult.IsLimitExceeded)
{
await HandleRateLimitExceeded(context, clientIdentifier, rateLimitPolicy);
return;
}
await _next(context);
}
private async Task<RateLimitResult> CheckRateLimitAsync(string clientId, RateLimitPolicy policy)
{
var now = DateTimeOffset.UtcNow;
var windowStart = now.Truncate(policy.WindowSize);
var cacheKey = $"ratelimit:{clientId}:{windowStart.ToUnixTimeSeconds()}";
var currentCountStr = await _cache.GetStringAsync(cacheKey);
var currentCount = int.TryParse(currentCountStr, out var count) ? count : 0;
if (currentCount >= policy.RequestsPerWindow)
{
return new RateLimitResult
{
IsLimitExceeded = true,
CurrentCount = currentCount,
WindowResetTime = windowStart.Add(policy.WindowSize)
};
}
// Атомарно увеличиваем счетчик
await _cache.SetStringAsync(cacheKey, (currentCount + 1).ToString(), new DistributedCacheEntryOptions
{
AbsoluteExpiration = windowStart.Add(policy.WindowSize).AddMinutes(1)
});
return new RateLimitResult
{
IsLimitExceeded = false,
CurrentCount = currentCount + 1,
WindowResetTime = windowStart.Add(policy.WindowSize)
};
}
private string GetClientIdentifier(HttpContext context)
{
// Приоритизируем аутентифицированных пользователей
var userId = context.User?.Identity?.GetUserId();
if (!string.IsNullOrEmpty(userId))
return $"user:{userId}";
// Для неаутентифицированных - используем IP + User-Agent fingerprint
var clientIP = context.Connection.RemoteIpAddress?.ToString();
var userAgent = context.Request.Headers["User-Agent"].ToString();
var fingerprint = ComputeFingerprint(clientIP, userAgent);
return $"anonymous:{fingerprint}";
}
} |
|
Sliding window algorithm обеспечивает более плавное распределение requests по сравнению с fixed window approach. Вместо резкого сброса лимита каждую минуту, sliding window учитывает requests за последние N секунд на момент каждого запроса. Это предотвращает burst traffic в начале каждого временного окна. Tiered rate limiting based на subscription level добавляет business logic в защиту от DDoS. Free tier пользователи получают строгие лимиты — 10 requests per minute для subscription API. Paid subscribers получают более generous limits, а Enterprise клиенты могут иметь практически unlimited access. Это balances защиту системы с user experience. Adaptive rate limiting реагирует на текущую загрузку системы. Когда CPU utilization или database response time превышают пороговые значения, лимиты автоматически ужесточаются для всех пользователей. Как только система восстанавливается, лимиты возвращаются к normal levels. Это обеспечивает graceful degradation вместо complete system failure.
Geolocation-based limiting помогает идентифицировать подозрительную активность. Если с одного IP-адреса приходят запросы якобы от пользователей из разных стран, это может указывать на compromised accounts или bot activity. Дополнительные ограничения для таких patterns помогают минимизировать damage.
Circuit breaker pattern интегрируется с rate limiting для protection downstream services. Когда payment gateway начинает отвечать медленно или возвращать errors, circuit breaker temporarily блокирует новые payment requests, независимо от rate limits. Это предотвращает cascade failures и дает external services время на восстановление. Human-friendly error messages важны даже при rate limiting. Вместо technical "429 Too Many Requests" пользователи должны получать понятные объяснения: "Слишком много попыток создать подписку. Подождите 2 минуты и попробуйте снова." Информация о том, когда можно повторить запрос, снижает user frustration и support tickets.
Monitoring и alerting для rate limiting events помогают distinguish между legitimate traffic spikes и actual attacks. Dashboard с real-time метриками показывает top blocked IPs, most hit endpoints, patterns во времени. Automated alerts на unusual spikes в blocked requests позволяют security team quickly respond to potential threats.
Асинхронная обработка платежей
Асинхронная обработка платежей в подписочных системах — это не техническая блажь, а жизненная необходимость. Когда я впервые столкнулся с необходимостью обрабатывать тысячи recurring платежей одновременно, наивно запустил их синхронно в цикле. Результат был предсказуем: через час работы система зависла намертво, база данных была заблокирована deadlock'ами, а payment gateway заблокировал наш IP за превышение rate limits. С того момента я понял: платежи и синхронность несовместимы как огонь и порох.
Основная проблема синхронной обработки платежей в том, что каждая транзакция может занимать от нескольких секунд до минут. Умножьте это на тысячи пользователей, и получите катастрофу. А если добавить сетевые таймауты, retry logic и webhook обработку, то время выполнения становится непредсказуемым. Async approach превращает это chaos в контролируемый поток.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
| public interface IAsyncPaymentProcessor
{
Task QueuePaymentAsync(PaymentRequest request);
Task ProcessScheduledPaymentsAsync();
Task HandlePaymentCallbackAsync(PaymentCallback callback);
Task RetryFailedPaymentsAsync();
}
public class BackgroundPaymentProcessor : BackgroundService, IAsyncPaymentProcessor
{
private readonly IServiceProvider _serviceProvider;
private readonly IMessageQueue _messageQueue;
private readonly ILogger<BackgroundPaymentProcessor> _logger;
private readonly SemaphoreSlim _processingLimiter;
public BackgroundPaymentProcessor(
IServiceProvider serviceProvider,
IMessageQueue messageQueue,
ILogger<BackgroundPaymentProcessor> logger)
{
_serviceProvider = serviceProvider;
_messageQueue = messageQueue;
_logger = logger;
_processingLimiter = new SemaphoreSlim(10, 10); // Максимум 10 параллельных платежей
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in _messageQueue.ConsumeAsync<PaymentQueueMessage>(stoppingToken))
{
await _processingLimiter.WaitAsync(stoppingToken);
// Обрабатываем каждый платеж в отдельной задаче
_ = Task.Run(async () =>
{
try
{
await ProcessSinglePaymentAsync(message);
}
finally
{
_processingLimiter.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSinglePaymentAsync(PaymentQueueMessage message)
{
using var scope = _serviceProvider.CreateScope();
var paymentService = scope.ServiceProvider.GetRequiredService<IPaymentService>();
var subscriptionService = scope.ServiceProvider.GetRequiredService<ISubscriptionService>();
try
{
// Проверяем актуальность запроса
var subscription = await subscriptionService.GetByIdAsync(message.SubscriptionId);
if (subscription == null || subscription.Status != SubscriptionStatus.PendingPayment)
{
_logger.LogWarning("Payment request {RequestId} is no longer valid", message.RequestId);
return;
}
// Обрабатываем платеж
var paymentResult = await paymentService.ProcessPaymentAsync(new PaymentRequest
{
Amount = message.Amount,
Currency = message.Currency,
PaymentMethodId = message.PaymentMethodId,
Description = $"Subscription renewal for {subscription.Plan.Name}",
IdempotencyKey = message.IdempotencyKey
});
if (paymentResult.IsSuccess)
{
await subscriptionService.ActivateSubscriptionAsync(
message.SubscriptionId,
paymentResult.TransactionId);
_logger.LogInformation("Payment processed successfully for subscription {SubscriptionId}",
message.SubscriptionId);
}
else
{
await HandlePaymentFailureAsync(message, paymentResult.ErrorMessage);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process payment for subscription {SubscriptionId}",
message.SubscriptionId);
// Планируем повторную попытку
if (message.RetryCount < 3)
{
await ScheduleRetryAsync(message, TimeSpan.FromMinutes(Math.Pow(2, message.RetryCount)));
}
}
}
} |
|
Message queues становятся backbone всей async payment processing architecture. Redis Streams, RabbitMQ, Azure Service Bus — выбор технологии зависит от scale и requirements, но принципы остаются неизменными. Каждый payment request превращается в message, которое обрабатывается independently от других. Это обеспечивает fault isolation — failed payment не влияет на обработку других транзакций.
Batch processing критически важен для recurring payments. Когда наступает время списания monthly subscriptions, система может генерировать десятки тысяч payment requests одновременно. Отправлять их по одному неэффективно, batch'ами по 100-500 — оптимально. Batch size подбирается экспериментально в зависимости от capabilities payment gateway и database performance. Retry mechanisms должны быть sophisticated но не aggressive. Transient network errors стоит retry немедленно, declined cards — через несколько часов, insufficient funds — через день или два. Exponential backoff prevents hammering payment gateways, но важно не делать delays слишком длинными — пользователь может захотеть исправить проблему с картой быстро.
Circuit breaker pattern защищает payment gateway от overload во время mass processing events. Если success rate платежей падает ниже определенного threshold, circuit breaker temporarily останавливает новые payment attempts и переводит их в delayed retry queue. Это gives payment provider время на восстановление и prevents cascade failures.
Dead letter queues собирают payment messages, которые не удалось обработать после всех retry attempts. Это не означает, что платеж lost forever — manual investigation может выявить systemic issues или data corruption problems. Support team может reprocess такие платежи после исправления underlying issues. Monitoring async payment processing требует specialized metrics. Queue depth показывает backlog размер, processing rate — throughput системы, error rate by category — patterns in failures. Real-time dashboards с этими метриками позволяют operations team быстро реагировать на проблемы до их escalation.
Graceful shutdown особенно важен для payment processors. Когда приложение перезапускается для deploy'я, все активные payment operations должны завершиться корректно. Abrupt termination может оставить платежи в inconsistent state — деньги списаны, но subscription не активирован. Proper shutdown coordination через CancellationToken'ы и drain periods предотвращает такие scenarios.
Мониторинг производительности базы данных
Мониторинг базы данных в подписочной системе — это как кардиограмма для сердца пациента в реанимации. Малейшие отклонения в метриках могут сигнализировать о надвигающейся катастрофе, которая парализует всю платежную систему. Я помню ночь, когда наша база данных начала деградировать во время массового продления подписок — response time медленно рос с 50мс до 5 секунд, но никто не заметил до тех пор, пока пользователи не начали жаловаться на зависшие платежи.
За годы эксплуатации subscription платформ я понял: мониторить нужно не только очевидные вещи вроде CPU и memory usage, но и специфические для подписочных систем метрики. Количество активных подключений во время billing cycles, lock waits при concurrent subscription updates, query execution plans для сложных reports — все это требует постоянного внимания.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| public class DatabasePerformanceMonitor : BackgroundService
{
private readonly IDbConnection _connection;
private readonly IMetricsCollector _metricsCollector;
private readonly IAlertingService _alertingService;
private readonly ILogger<DatabasePerformanceMonitor> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CollectPerformanceMetrics();
await AnalyzeSlowQueries();
await MonitorConnectionPool();
await CheckLockingStatistics();
await ValidateIndexUsage();
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to collect database performance metrics");
}
}
}
private async Task CollectPerformanceMetrics()
{
var metrics = await _connection.QuerySingleAsync<DatabaseMetrics>(@"
SELECT
(SELECT COUNT(*) FROM sys.dm_exec_connections) AS ActiveConnections,
(SELECT COUNT(*) FROM sys.dm_exec_requests WHERE blocking_session_id > 0) AS BlockedQueries,
(SELECT AVG(CAST(wait_time_ms AS FLOAT)) FROM sys.dm_os_wait_stats WHERE wait_type NOT LIKE '%SLEEP%') AS AverageWaitTime,
(SELECT COUNT(*) FROM Subscriptions WHERE Status = 1) AS ActiveSubscriptions,
(SELECT COUNT(*) FROM Payments WHERE CreatedAt > DATEADD(MINUTE, -5, GETUTCDATE())) AS RecentPayments
");
_metricsCollector.Gauge("database.connections.active", metrics.ActiveConnections);
_metricsCollector.Gauge("database.queries.blocked", metrics.BlockedQueries);
_metricsCollector.Gauge("database.wait_time.average_ms", metrics.AverageWaitTime);
_metricsCollector.Gauge("subscriptions.active.count", metrics.ActiveSubscriptions);
_metricsCollector.Gauge("payments.recent.count", metrics.RecentPayments);
if (metrics.ActiveConnections > 80)
await _alertingService.SendAlertAsync("High connection count", $"Active connections: {metrics.ActiveConnections}");
}
private async Task AnalyzeSlowQueries()
{
var slowQueries = await _connection.QueryAsync<SlowQueryInfo>(@"
SELECT TOP 10
text,
execution_count,
total_elapsed_time / execution_count AS avg_elapsed_time,
total_logical_reads / execution_count AS avg_logical_reads,
creation_time
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle)
WHERE text LIKE '%Subscription%' OR text LIKE '%Payment%'
ORDER BY total_elapsed_time DESC
");
foreach (var query in slowQueries)
{
if (query.AverageElapsedTime > 1000) // Больше 1 секунды
{
_metricsCollector.Counter("database.slow_queries").Increment();
_logger.LogWarning("Slow query detected: {Query}, Avg time: {Time}ms",
query.Text.Substring(0, Math.Min(100, query.Text.Length)),
query.AverageElapsedTime);
}
}
}
} |
|
Real-time alerting должен быть настроен с smart thresholds, а не fixed values. Connection pool размер в 50 соединений может быть normal load в обычное время, но критичным во время массового renewal процесса. Adaptive alerting учитывает исторические patterns и day-of-week variations, снижая false positive rate.
Index fragmentation analysis помогает поддерживать query performance на приемлемом уровне. Таблицы подписок и платежей растут быстро и непредсказуемо — fragmentation накапливается незаметно, но влияет на скорость запросов существенно. Automated maintenance jobs должны rebuild или reorganize индексы based on fragmentation metrics, но timing critical — нельзя запускать maintenance во время peak load periods. Query plan monitoring выявляет regression в performance после schema changes или data growth. Plan cache analysis показывает, когда optimizer начинает выбирать suboptimal execution plans из-за outdated statistics или parameter sniffing issues. Forced plan guides иногда необходимы для критичных queries, но require careful maintenance.
Wait statistics analysis раскрывает bottlenecks, невидимые в standard CPU/memory metrics. PAGEIOLATCH_SH waits указывают на disk I/O problems, LCK_M_X waits — на locking contention, CXPACKET waits — на parallelism issues. Каждый тип wait требует different optimization approach: faster storage, better indexing, query tuning, или hardware scaling.
Deadlock monitoring особенно важен в subscription systems из-за frequent updates одних и тех же records. Когда user upgrade subscription одновременно с automatic renewal processing, deadlock неизбежен без proper lock ordering. Deadlock graphs помогают identify problematic query patterns и redesign transaction logic для minimization conflicts.
Resource consumption tracking показывает trends в database growth и helps capacity planning. Table size growth, index space usage, transaction log size — все эти метрики имеют seasonal patterns в subscription businesses. Holiday seasons, end-of-month billing cycles, promotional campaigns — все это influence database load predictably.
Масштабирование под высокие нагрузки
Масштабирование подписочной системы под высокие нагрузки — это как подготовка к урагану, который обязательно придет, но неизвестно когда и какой силы. Я помню Black Friday 2019 года, когда наша платформа получила в 50 раз больше трафика, чем обычно. Система, которая спокойно обслуживала тысячи пользователей в день, внезапно столкнулась с сотнями тысяч одновременных запросов. Результат был предсказуем — каскадные отказы, зависшие платежи и бессонная ночь для всей команды.
Первое правило масштабирования subscription систем — никогда не масштабируй все сразу. Horizontal scaling должен быть селективным и основанным на actual bottlenecks. Application servers масштабируются легко через load balancer, но database остается single point of failure. Redis cache можно кластеризовать, но session affinity может создать uneven load distribution.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| public class ScalableSubscriptionArchitecture
{
private readonly IConfiguration _configuration;
private readonly IServiceDiscovery _serviceDiscovery;
private readonly ILoadBalancer _loadBalancer;
public async Task<ServiceEndpoint> GetOptimalEndpointAsync(RequestType requestType)
{
var availableServices = await _serviceDiscovery.GetHealthyServicesAsync("subscription-api");
// Routing based on request type
return requestType switch
{
RequestType.ReadOnly => GetLeastLoadedReadReplica(availableServices),
RequestType.PaymentProcessing => GetDedicatedPaymentProcessor(availableServices),
RequestType.BulkOperations => GetHighCapacityInstance(availableServices),
_ => GetBalancedEndpoint(availableServices)
};
}
private ServiceEndpoint GetDedicatedPaymentProcessor(List<ServiceEndpoint> services)
{
// Payment operations идут на специализированные инстансы с higher memory и CPU
return services
.Where(s => s.Tags.Contains("payment-optimized"))
.OrderBy(s => s.CurrentLoad)
.FirstOrDefault();
}
} |
|
Unit-тесты для критичных компонентов
Unit-тесты для подписочных систем — это не формальность для повышения code coverage, а страховка от финансовых потерь. Когда я впервые столкнулся с багом в production, который дважды списывал деньги с пользователей из-за неправильной логики retry, понял: каждый метод, который касается денег, должен быть протестирован до последней строки кода.
Тестирование subscription logic начинается с изоляции зависимостей. Entity Framework, payment gateways, email services — все это должно быть заmock'ано для получения predictable test environment. InMemory database провайдеры работают хорошо для простых cases, но сложные SQL queries могут вести себя по-разному в InMemory и real SQL Server.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| public class SubscriptionServiceTests
{
private readonly Mock<IPaymentService> _mockPaymentService;
private readonly Mock<INotificationService> _mockNotificationService;
private readonly Mock<ITimeProvider> _mockTimeProvider;
private readonly TestDbContext _dbContext;
private readonly SubscriptionService _subscriptionService;
public SubscriptionServiceTests()
{
_mockPaymentService = new Mock<IPaymentService>();
_mockNotificationService = new Mock<INotificationService>();
_mockTimeProvider = new Mock<ITimeProvider>();
_dbContext = CreateInMemoryDbContext();
_subscriptionService = new SubscriptionService(
_dbContext,
_mockPaymentService.Object,
_mockNotificationService.Object,
_mockTimeProvider.Object);
}
[Fact]
public async Task CreateSubscription_WithValidData_ShouldCreateActiveSubscription()
{
// Arrange
var currentTime = new DateTime(2024, 1, 15);
_mockTimeProvider.Setup(x => x.UtcNow).Returns(currentTime);
var user = await CreateTestUserAsync();
var plan = await CreateTestPlanAsync(price: 29.99m, durationDays: 30);
_mockPaymentService
.Setup(x => x.ProcessPaymentAsync(It.IsAny<PaymentRequest>()))
.ReturnsAsync(PaymentResult.Success("txn_123", 29.99m));
var request = new CreateSubscriptionRequest
{
UserId = user.Id,
PlanId = plan.Id,
PaymentMethodId = "pm_test_123"
};
// Act
var result = await _subscriptionService.CreateSubscriptionAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal(SubscriptionStatus.Active, result.Value.Status);
Assert.Equal(currentTime.AddDays(30), result.Value.ExpiresAt);
Assert.Equal("txn_123", result.Value.ExternalPaymentId);
// Проверяем, что payment service был вызван с правильными параметрами
_mockPaymentService.Verify(x => x.ProcessPaymentAsync(
It.Is<PaymentRequest>(r => r.Amount == 29.99m && r.UserId == user.Id)),
Times.Once);
}
} |
|
Граничные случаи требуют особого внимания в subscription тестах. Что происходит, когда подписка истекает ровно в момент renewal attempt? Как система обрабатывает subscription upgrade в последний день billing period? Эти edge cases редко встречаются в production, но когда встречаются — могут привести к serious financial discrepancies.
State-based testing проверяет, что subscription state transitions происходят корректно. State machine для подписок имеет десятки possible transitions, каждый должен быть протестирован отдельно. Invalid transitions должны быть explicitly blocked и результировать в appropriate error messages.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| [Theory]
[InlineData(SubscriptionStatus.Active, SubscriptionStatus.Suspended, true)]
[InlineData(SubscriptionStatus.Suspended, SubscriptionStatus.Active, true)]
[InlineData(SubscriptionStatus.Canceled, SubscriptionStatus.Active, false)]
[InlineData(SubscriptionStatus.Expired, SubscriptionStatus.Suspended, false)]
public async Task TransitionSubscription_ShouldValidateStateTransitions(
SubscriptionStatus fromStatus,
SubscriptionStatus toStatus,
bool shouldSucceed)
{
// Arrange
var subscription = await CreateSubscriptionWithStatus(fromStatus);
// Act
var result = await _subscriptionService.TransitionStatusAsync(
subscription.Id, toStatus, "Test transition");
// Assert
Assert.Equal(shouldSucceed, result.IsSuccess);
if (shouldSucceed)
{
var updatedSubscription = await _dbContext.Subscriptions
.FindAsync(subscription.Id);
Assert.Equal(toStatus, updatedSubscription.Status);
}
} |
|
Интеграционные тесты с моками платежек
Интеграционные тесты для платежных систем — это тот случай, когда mock'и не просто удобство, а жизненная необходимость. Я помню проект, где команда решила тестировать интеграцию с реальным Stripe API в staging окружении. Результат: за неделю тестирования накопилось $3000 test charges, которые потом пришлось manually refund'ить. С тех пор я понял — для платежных integration тестов mock'и священны.
Основная сложность mock'ов платежных систем в том, что они должны точно имитировать поведение real API, включая edge cases и error scenarios. Stripe может вернуть "requires_action" для 3D Secure, PayPal иногда отвечает с 30-секундной задержкой, а некоторые gateways возвращают cryptic error codes без human-readable descriptions.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
| public class PaymentGatewayMockService : IPaymentService
{
private readonly Dictionary<string, PaymentMethodBehavior> _paymentMethodBehaviors;
private readonly ILogger<PaymentGatewayMockService> _logger;
private readonly Random _random = new();
public PaymentGatewayMockService()
{
_paymentMethodBehaviors = new Dictionary<string, PaymentMethodBehavior>
{
["pm_card_visa"] = PaymentMethodBehavior.Success,
["pm_card_declined"] = PaymentMethodBehavior.DeclinedInsufficientFunds,
["pm_card_3ds"] = PaymentMethodBehavior.Requires3DSecure,
["pm_card_fraud"] = PaymentMethodBehavior.DeclinedFraud,
["pm_card_timeout"] = PaymentMethodBehavior.NetworkTimeout,
["pm_card_processing"] = PaymentMethodBehavior.ProcessingDelay
};
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
// Симулируем network latency
var delay = _paymentMethodBehaviors.GetValueOrDefault(request.PaymentMethodId) switch
{
PaymentMethodBehavior.NetworkTimeout => TimeSpan.FromSeconds(30),
PaymentMethodBehavior.ProcessingDelay => TimeSpan.FromSeconds(5 + _random.Next(10)),
_ => TimeSpan.FromMilliseconds(100 + _random.Next(400))
};
await Task.Delay(delay);
// Определяем поведение на основе payment method ID
var behavior = _paymentMethodBehaviors.GetValueOrDefault(request.PaymentMethodId, PaymentMethodBehavior.Success);
return behavior switch
{
PaymentMethodBehavior.Success => PaymentResult.Success(
$"pi_mock_{Guid.NewGuid().ToString("N")[..8]}",
request.Amount),
PaymentMethodBehavior.DeclinedInsufficientFunds => PaymentResult.Failed(
"Your card was declined.",
"insufficient_funds"),
PaymentMethodBehavior.Requires3DSecure => PaymentResult.RequiresAction(
$"pi_mock_{Guid.NewGuid().ToString("N")[..8]}_secret",
"authentication_required"),
PaymentMethodBehavior.DeclinedFraud => PaymentResult.Failed(
"Your card was declined.",
"suspected_fraud"),
PaymentMethodBehavior.NetworkTimeout => throw new HttpRequestException("Request timeout"),
PaymentMethodBehavior.ProcessingDelay => PaymentResult.Processing(
$"pi_mock_{Guid.NewGuid().ToString("N")[..8]}"),
_ => PaymentResult.Failed("Unknown error occurred", "generic_decline")
};
}
// Mock для webhook events
public async Task TriggerWebhookAsync(string eventType, object eventData)
{
var webhook = new
{
id = $"evt_mock_{Guid.NewGuid().ToString("N")[..8]}",
type = eventType,
data = new { @object = eventData },
created = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};
var webhookJson = JsonSerializer.Serialize(webhook);
// Симулируем отправку webhook на наш endpoint
using var httpClient = new HttpClient();
await httpClient.PostAsync("https://localhost:5001/webhooks/payment",
new StringContent(webhookJson, Encoding.UTF8, "application/json"));
}
} |
|
Сценарное тестирование через mock'и позволяет покрыть все possible payment flows без real money transactions. Happy path, различные decline reasons, network failures, webhook delays — все это можно протестировать deterministically. Особенно важно тестировать rare scenarios вроде partial captures или disputed charges.
Webhook simulation — отдельная задача в integration testing. Real payment webhooks приходят асинхронно, могут дублироваться или приходить в неправильном порядке. Mock webhook service должен имитировать эти patterns и позволять tests trigger specific webhook scenarios on demand.
State consistency проверки критичны в integration тестах. После successful payment subscription должна активироваться, payment record должна сохраниться, user должен получить confirmation email. Integration tests проверяют весь end-to-end flow, а не isolated unit behavior.
| C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| [Fact]
public async Task CompleteSubscriptionFlow_WithMockedPayment_ShouldUpdateAllRelatedEntities()
{
// Arrange
var testUser = await CreateTestUserAsync();
var testPlan = await CreateTestPlanAsync();
var mockPaymentService = new PaymentGatewayMockService();
// Настраиваем DI container для использования mock'ов
var services = new ServiceCollection()
.AddDbContext<TestDbContext>(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString()))
.AddScoped<IPaymentService>(_ => mockPaymentService)
.AddScoped<ISubscriptionService, SubscriptionService>()
.AddLogging();
using var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var subscriptionService = scope.ServiceProvider.GetRequiredService<ISubscriptionService>();
var dbContext = scope.ServiceProvider.GetRequiredService<TestDbContext>();
// Act
var createResult = await subscriptionService.CreateSubscriptionAsync(new CreateSubscriptionRequest
{
UserId = testUser.Id,
PlanId = testPlan.Id,
PaymentMethodId = "pm_card_visa" // Mock successful payment
});
// Assert - проверяем все аспекты successful subscription creation
Assert.True(createResult.IsSuccess);
var subscription = await dbContext.Subscriptions
.Include(s => s.User)
.Include(s => s.Plan)
.FirstAsync(s => s.UserId == testUser.Id);
Assert.Equal(SubscriptionStatus.Active, subscription.Status);
Assert.NotNull(subscription.ExternalPaymentId);
Assert.True(subscription.ExpiresAt > DateTime.UtcNow);
var paymentRecord = await dbContext.Payments
.FirstOrDefaultAsync(p => p.SubscriptionId == subscription.Id);
Assert.NotNull(paymentRecord);
Assert.Equal(PaymentStatus.Completed, paymentRecord.Status);
Assert.Equal(testPlan.BasePrice, paymentRecord.Amount);
// Trigger webhook to simulate real-world async behavior
await mockPaymentService.TriggerWebhookAsync("payment_intent.succeeded", new
{
id = subscription.ExternalPaymentId,
amount = (int)(testPlan.BasePrice * 100),
status = "succeeded"
});
// Даем время на обработку webhook
await Task.Delay(100);
// Проверяем, что webhook был обработан корректно
var updatedSubscription = await dbContext.Subscriptions.FindAsync(subscription.Id);
Assert.Equal(SubscriptionStatus.Active, updatedSubscription.Status);
} |
|
Замок-ключ принцип работает отлично для payment integration mocks: каждый test case имеет specific payment method ID, который deterministically triggers определенное поведение. Это делает tests predictable и eliminates flaky test scenarios связанные с random payment outcomes.
Стоит ли изучать ASP.NET MVC 4 не зная просто ASP.NET? Стоит ли сразу изучать ASP.NET MVC не зная просто ASP.NET?
И еще вопрос: мне нужно освоить MVC... Перенос с ASP.NET на ASP.NET MVC Доброго времени суток!
Вопрос в следующем: имеются файлы проекта на ASP.NET и действующий проект... ASP.NET или ASP.NET MVC Посоветуйте какую технологию лучше начать изучать ASP.NET или ASP.NET MVC. Не содной ни c другой... Чем отличается ASP.NET от ASP.NET MVC, и что лучше подходит для моего приложения Дорогие знатоки, я прочитал Шилдта C# и WPF Мак-Дональда, но до сих пор я не сильно понимаю чем... ASP.NET и ASP.NET MVC Добрый день, форумчане.
Объясните мне, пожалуйста, простым языком, чем отличаются технологии... Объясните в двух словах, в чём отличие ASP.NET от ASP.NET MVC Можно и не в двух... ASP.NET MVC или ASP.NET Core Добрый вечер, подскажите что лучшие изучать ASP.NET MVC или ASP.NET Core ? Как я понимаю ASP.NET... Чем ASP.NET отличается от ASP.NET MVC? Доброго времени суток форумчане!
Хотелось бы подтянуться в области backend'а, но вот не могу... ASP.NET Core или ASP.NET MVC Здравствуйте
После изучение основ c# я решил выбрать направление веб разработки. Подскажите какие... Какая разница между ASP .Net Core и ASP .Net Core MVC? Какая разница между ASP .Net Core и ASP .Net Core MVC? Или я может что-то не так понял? И... Как переделать проект ASP.NET WebForms в ASP.NET MVC 5 Есть маленький проектик, который я выращиваю.
Началось всё с ASP.NET 4 WebForms (.Net Framework... Миграция с Asp.NET на Asp.NET MVC. Ошибка в маршрутизации Всем привет.
Есть проект(ИС на чистом Asp.NET) который нужно перенести на Asp.NET MVC.
Не...
|