# APIAlertService **Repository Path**: chenglin860/APIAlertService ## Basic Information - **Project Name**: APIAlertService - **Description**: .NET 8 后台服务开发:API 即将到期服务实战(含短信,邮件,飞书三种提醒具体实现)。在 SaaS 化 API 服务管理平台中,如何及时提醒客户 API 即将到期或调用额度即将用完,是提升客户体验的关键功能。本文将介绍如何使用 .NET 8 + BackgroundService 开发一个高性能的定时提醒服务,支持邮件、短信、飞书三种通知渠道。 - **Primary Language**: C# - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-03-30 - **Last Updated**: 2026-08-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # APIAlertService: A .NET 8 Background Service for API Expiration Alerts via SMS, Email, and Lark(FeiShu) ## Foreword In a SaaS-based API service management platform, how to promptly remind customers that their API is about to expire is a key feature for enhancing customer experience. This document introduces how to develop a high-performance scheduled alert service using .NET 8 + BackgroundService, supporting three notification channels: **Email, SMS, and Lark**. ## Technology Stack - **.NET 8** - A mainstream LTS (Long-Term Support) version. - **BackgroundService** - The built-in background service base class from Microsoft.Extensions.Hosting. - **Dapper** - A lightweight ORM (Object-Relational Mapper) for database access, connecting to SQL Server in this example. - **Serilog** - A structured logging framework. - **Redis** - Used for caching to prevent duplicate sends. - **SMTP** - Simple Mail Transfer Protocol for email. - **TCP** - Transmission Control Protocol for SMS gateway communication. - **Lark Open API** - For Lark (Feishu) message push. ## Project Architecture ``` PushService/ ├── Services/ │ └── APISubscribersAlertService.cs # Core Alert Service ├── Agents/ │ ├── LarkService.cs # Lark Message Service │ ├── EmailService.cs # Email Sending Service │ └── SmsService.cs # SMS Sending Service ├── Common/ │ ├── RedisHelper.cs # Redis Wrapper │ └── HttpClientHelper.cs # HTTP Client Wrapper ├── DAL/ │ └── DatabaseFactory.cs # Dapper Database Access ├── Config/ │ └── AppSettings.cs # Configuration Models └── Program.cs # Service Entry Point ``` ## Core Code Implementation ### 1. Service Configuration (appsettings.json) ```json { "ConnectionStrings": { "DefaultConnection": "server=localhost;database=APIPower;uid=sa;pwd=xxx;TrustServerCertificate=True" }, "AppSettings": { "SendAPIReminderConfig": "9|10", "SmsServer": "192.168.1.254;8816;8817;your_sign;10;1313344xxxx;1371122xxxx", "Lark": { "AppId": "xxxxx", "AppKey": "xxxxx", "AccountSuffix": "@yourcompany.com" }, "Email": { "Enabled": true, "SmtpHost": "smtp.xxx.com", "SmtpPort": 587, "SmtpUsername": "noreply@yourcompany.com", "SmtpPassword": "xxx", "FromEmail": "noreply@yourcompany.com", "FromName": "API Service", "EnableSsl": true } } } ``` **Configuration Explanation:** - `SendAPIReminderConfig`: "9|10" means execution starts daily at 9:00 AM and ends at 11:00 AM at the latest. - `SmsServer`: Format is `ServerIP;DefaultPort;BackupPort;SMS_Signature;Error_Threshold;Alert_Mobile;Test_MobileNumbers` - `Lark`: Configuration for Lark Open Platform application. - `Email`: SMTP email configuration. ### 2. Configuration Models ```csharp namespace PushService.Config { public class AppSettings { public string SendAPIReminderConfig { get; set; } = "9|10"; public string SmsServer { get; set; } = ""; public LarkConfig Lark { get; set; } = new(); public EmailConfig Email { get; set; } = new(); } public class LarkConfig { public string AppId { get; set; } = ""; public string AppKey { get; set; } = ""; public string AccountSuffix { get; set; } = "@yourcompany.com"; } public class EmailConfig { public bool Enabled { get; set; } = true; public string SmtpHost { get; set; } = ""; public int SmtpPort { get; set; } = 587; public string SmtpUsername { get; set; } = ""; public string SmtpPassword { get; set; } = ""; public string FromEmail { get; set; } = ""; public string FromName { get; set; } = "API Service"; public bool EnableSsl { get; set; } = true; } } ``` ### 3. Redis Duplicate Prevention Mechanism (Core Highlight) This is the core highlight. We use a Redis Set to record alerts that have already been sent on the current day, with a TTL (Time To Live) set to automatically expire at midnight the next day. ```csharp private const string AlertKeyPrefix = "alert:expire:sent:"; private readonly TimeSpan _ttlToNextDay; public APISubscribersAlertService(...) { _ttlToNextDay = GetTtlToNextDay(); } // Calculate the time remaining until midnight (00:00) the next day. private TimeSpan GetTtlToNextDay() { var now = DateTime.Now; return now.Date.AddDays(1) - now; } ``` **Redis Key Design Explanation:** ``` Key: alert:expire:sent:2026-03-30 Value: (A Redis Set containing multiple sent records) TTL: Approximately 10 hours (automatically expires at midnight). ``` **Format of Values in the Set:** ``` Format: {ApiKey}:{RemainingDays}:{Channel} Example: abc123456:1:SMS → Customer with ApiKey 'abc123456', 1 day remaining, SMS alert sent. abc123456:4:Lark → Same customer, 4 days remaining, Lark alert sent. abc123456:7:Email → Same customer, 7 days remaining, Email alert sent. ``` **Benefits of this design:** 1. **Same ApiKey, different remaining days, different channels** - Independent judgment for each combination on whether to send. 2. **Automatic expiration the next day** - The key is deleted when TTL expires, allowing the 1/4/7-day reminders to be resent on the third day. 3. **High-performance in-memory operation** - No need to query the database every time. ### 4. Core Alert Service ```csharp public class APISubscribersAlertService : BackgroundService { private readonly IDatabaseFactory _db; private readonly ILarkService _larkService; private readonly IEmailService _emailService; private readonly ISmsService _smsService; private readonly IRedisHelper _redis; private readonly int[] _remainingDaysForExpireReminder = { 1, 4, 7 }; private readonly TimeSpan _startTimeSendReminder; private readonly TimeSpan _endTimeSendReminder; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var now = DateTime.Now; var sendStart = now.Date.Add(_startTimeSendReminder); // 9:00 AM var sendEnd = now.Date.Add(_endTimeSendReminder); // 11:00 AM // Past the end time, skip to the next day. if (now >= sendEnd) { await SleepToNextDay(stoppingToken); continue; } // Before the start time, wait. if (now < sendStart) { await Task.Delay(sendStart - now, stoppingToken); continue; } // Execute alerts. try { await ServiceExpireAlertAsync(); } catch (Exception ex) { _logger.LogError(ex, "ServiceExpireAlert error"); } await SleepToNextDay(stoppingToken); } } private async Task ServiceExpireAlertAsync() { var now = DateTime.Now; var todayStr = now.ToString("yyyy-MM-dd"); var sentKey = $"{AlertKeyPrefix}{todayStr}"; // Get records already sent today from Redis. var sentSet = await _redis.SetMembersAsync(sentKey); // Query customers with expiring APIs. var sql = @"SELECT ApiKey, CompanyName, ContactPhone, OwnerAccount, Email, ExpireDate FROM ApiCustomer WHERE IsEnabled = 1 AND ApiType <> 'Trial' AND (@now BETWEEN StartDate AND ExpireDate) AND ExpireDate IN (...)"; foreach (DataRow row in dt.Rows) { var apiKey = row["ApiKey"].ToString(); var mobile = row["ContactPhone"]?.ToString(); var sales = row["OwnerAccount"]?.ToString(); var email = row["Email"]?.ToString(); var remaining = (Convert.ToDateTime(row["ExpireDate"]).Date - now.Date).Days; // SMS Alert - Check if already sent. if (!string.IsNullOrWhiteSpace(mobile)) { var sentValue = $"{apiKey}:{remaining}:SMS"; if (!sentSet.Contains(sentValue)) { var content = $"【API Reminder】Your API service will expire in {remaining} day(s). Please renew promptly."; var result = await _smsService.SendMessageAsync(mobile, content); if (result) await _redis.SetAddAsync(sentKey, sentValue, _ttlToNextDay); } } // Lark Alert - Same logic. if (!string.IsNullOrWhiteSpace(sales)) { var sentValue = $"{apiKey}:{remaining}:Lark"; if (!sentSet.Contains(sentValue)) { var larkContent = BuildLarkCardMsg(...); var result = await _larkService.SendMsgAsync(...); if (result.Succeeded) await _redis.SetAddAsync(sentKey, sentValue, _ttlToNextDay); } } // Email Alert - Same logic. if (!string.IsNullOrWhiteSpace(email)) { var sentValue = $"{apiKey}:{remaining}:Email"; if (!sentSet.Contains(sentValue)) { var htmlContent = BuildEmailHtml(...); var result = await _emailService.SendEmailAsync(email, subject, htmlContent); if (result) await _redis.SetAddAsync(sentKey, sentValue, _ttlToNextDay); } } } } } ``` ### 5. SMS Service SMS uses TCP protocol to communicate with the SMS gateway, supporting primary/backup dual channels. ```csharp public class SmsService : ISmsService { private readonly string _smsIp, _smsType, _testMobiles; private readonly int _smsPortDefault, _smsPortStandby; public async Task SendMessageAsync(string mobile, string content) { // Test Environment Protection: Only send to whitelisted phone numbers. if (!string.IsNullOrWhiteSpace(_testMobiles)) { var testList = _testMobiles.Split(','); if (!testList.Contains(mobile)) return true; } // Try primary channel. var result = await TrySendAsync(_smsIp, _smsPortDefault, mobile, content); // If fails, try backup channel. if (!result) result = await TrySendAsync(_smsIp, _smsPortStandby, mobile, content); return result; } private async Task TrySendAsync(string ip, int port, string mobile, string content) { using var client = new TcpClient(); await client.ConnectAsync(ip, port); var stream = client.GetStream(); // Build binary protocol (GB2312 encoding). var gb2312 = Encoding.GetEncoding("gb2312"); var packet = BuildPacket(gb2312, _smsType, mobile, content); await stream.WriteAsync(packet); // Read response, flag=0 means success. var flag = BitConverter.ToUInt16(receiveBytes, 4); return flag == 0; } } ``` ### 6. Lark Message Service ```csharp public class LarkService : ILarkService { private readonly string _appId, _appKey; private readonly IHttpClientHelper _httpClient; private const string BaseUrl = "https://open.feishu.cn/open-apis"; public async Task> SendMsgAsync(string openId, string msgJson) { // First, get access_token. var token = await GetAccessTokenAsync(); // Send the message. var url = $"{BaseUrl}/message/v4/send/"; var response = await _httpClient.PostJsonAsync(url, JsonConvert.DeserializeObject(msgJson), token); return JsonConvert.DeserializeObject(response); } private async Task GetAccessTokenAsync() { var url = $"{BaseUrl}/auth/v3/tenant_access_token/internal/"; var data = new { app_id = _appId, app_secret = _appKey }; var response = await _httpClient.PostJsonAsync(url, data); var dto = JsonConvert.DeserializeObject(response); return dto.TenantAccessToken; } } ``` ### 7. Email Service ```csharp public class EmailService : IEmailService { private readonly SmtpClient _smtpClient; private readonly string _fromEmail, _fromName; public async Task SendEmailAsync(string to, string subject, string htmlBody) { try { using var message = new MailMessage { From = new MailAddress(_fromEmail, _fromName, Encoding.UTF8), Subject = subject, Body = htmlBody, IsBodyHtml = true }; message.To.Add(new MailAddress(to)); await _smtpClient.SendMailAsync(message); return true; } catch (Exception ex) { _logger.LogError(ex, "SendEmail failed"); return false; } } } ``` ### 8. Program.cs Service Registration ```csharp static void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddSingleton(configuration); // Configuration var appSettings = new AppSettings(); configuration.GetSection("AppSettings").Bind(appSettings); services.AddSingleton(appSettings); // Database var connectionString = configuration.GetConnectionString("DefaultConnection"); services.AddSingleton(new DatabaseFactory(connectionString))); // HTTP Client services.AddSingleton(); // Redis var redisConn = configuration["RedisConnection"]; var redisPrefix = configuration["RedisKeyPrefix"] ?? "SA:"; services.AddSingleton(sp => new RedisHelper(redisConn, redisPrefix, sp.GetRequiredService>())); // Notification Services var larkConfig = configuration.GetSection("AppSettings:Lark").Get(); services.AddSingleton(sp => new LarkService( larkConfig.AppId, larkConfig.AppKey, sp.GetRequiredService(), sp.GetRequiredService>())); services.AddSingleton(); services.AddSingleton(); // Register Background Service services.AddHostedService(); } ``` ## Key Design Points ### 1. Time Window Control The service only executes alerts between 9:00 AM and 11:00 AM daily to avoid disturbing customers late at night. ```csharp var sendStart = now.Date.AddHours(9); // Start at 9:00 var sendEnd = now.Date.AddHours(11); // End at 11:00 ``` ### 2. Multiple Reminder Mechanism By configuring `_remainingDaysForExpireReminder = { 1, 4, 7 }`, reminders are sent 1, 4, and 7 days before the customer's API expires, improving renewal rates. ### 3. Redis Duplicate Prevention (Core) - Uses a **Redis Set** to store records of alerts sent for the current day. - **Value Format**: `{ApiKey}:{RemainingDays}:{Channel}`, ensuring independent judgment for different combinations. - **Automatic TTL Expiration**: Calculates the time remaining until 00:00 the next day as the TTL. Keys automatically expire the next day, allowing resends. - **Service Restart Tolerant**: Uses the same key after restart, preventing resends of already processed alerts. ### 4. Three-Channel Notification | Channel | Protocol | Characteristics | |---------|----------|-----------------| | SMS | TCP Socket | Automatic failover between primary/backup channels. | | Lark | HTTPS REST API | Supports card messages, @mentions. | | Email | SMTP | Supports HTML formatting. | ### 5. Test Environment Isolation The SMS service supports a whitelist mechanism. Only phone numbers listed in the configuration receive actual SMS, while others are only logged and not sent. ### 6. Graceful Shutdown BackgroundService supports CancellationToken, enabling graceful exit upon receiving a stop signal. ```csharp protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { // Business logic } } ``` ## Summary This document detailed a complete solution for developing a multi-channel API reminder service using .NET 8 + BackgroundService. The core highlight is the use of a **Redis Set + TTL** mechanism to prevent duplicate sends, ensuring: 1. No duplicate sends within the same day. 2. Automatic expiration allows resends the next day. 3. Persistence remains effective after a service restart. This architecture is highly extensible, making it easy to add more notification channels (e.g., DingTalk, WeCom, etc.). ## NuGet Package Dependencies | Package Name | Version | |--------------|---------| | Dapper | 2.1.35 | | Microsoft.Data.SqlClient | 5.2.0 | | Microsoft.Extensions.Configuration.EnvironmentVariables | 8.0.0 | | Microsoft.Extensions.Configuration.Json | 8.0.0 | | Microsoft.Extensions.DependencyInjection | 8.0.0 | | Microsoft.Extensions.Hosting | 8.0.0 | | Microsoft.Extensions.Hosting.WindowsServices | 8.0.0 | | Microsoft.Extensions.Logging | 8.0.0 | | Microsoft.VisualStudio.Azure.Containers.Tools.Targets | 1.22.1 | | Newtonsoft.Json | 13.0.3 | | Serilog | 3.1.1 | | Serilog.AspNetCore | 8.0.3 | | Serilog.Extensions.Logging | 8.0.0 | | Serilog.Settings.Configuration | 8.0.4 | | Serilog.Sinks.File | 5.0.0 | | StackExchange.Redis | 2.7.33 |