# APIAlertService **Repository Path**: chenglin860/APIAlertService ## Basic Information - **Project Name**: APIAlertService - **Description**: .NET 8 后台服务开发:API 即将到期服务实战(含短信,邮件,飞书三种提醒具体实现)。在 SaaS 化 API 服务管理平台中,如何及时提醒客户 API 即将到期或调用额度即将用完,是提升客户体验的关键功能。本文将介绍如何使用 .NET 8 + BackgroundService 开发一个高性能的定时提醒服务,支持邮件、短信、飞书三种通知渠道。 - **Primary Language**: C# - **License**: AGPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-03-30 - **Last Updated**: 2026-05-16 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # APIAlertService 使用 .NET 8 后台服务开发,主要用于数显API 即将到期提醒服务的功能,含短信,邮件,飞书三种提醒具体实现 ## 前言 在 SaaS 化 API 服务管理平台中,如何及时提醒客户 API 即将到期,是提升客户体验的关键功能。本文将介绍如何使用 .NET 8 + BackgroundService 开发一个高性能的定时提醒服务,支持**邮件、短信、飞书( Lark)**三种通知渠道。 ## 技术栈 - **.NET 8** - 比较主流的 LTS 版本 - **BackgroundService** - Microsoft.Extensions.Hosting 内置的后台服务基类 - **Dapper** - 轻量级 ORM,用于数据库访问,本实例中连接的是SQL Server - **Serilog** - 结构化日志框架 - **Redis** - 用于防重复发送的缓存 - **SMTP** - 邮件发送协议 - **TCP** - 短信网关通信 - **Lark Open API** - 飞书消息推送 ## 项目架构 ``` PushService/ ├── Services/ │ └── APISubscribersAlertService.cs # 核心提醒服务 ├── Agents/ │ ├── LarkService.cs # 飞书消息服务 │ ├── EmailService.cs # 邮件发送服务 │ └── SmsService.cs # 短信发送服务 ├── Common/ │ ├── RedisHelper.cs # Redis 封装 │ └── HttpClientHelper.cs # HTTP 客户端封装 ├── DAL/ │ └── DatabaseFactory.cs # Dapper 数据库访问 ├── Config/ │ └── AppSettings.cs # 配置模型 └── Program.cs # 服务入口 ``` ## 核心代码实现 ### 1. 服务配置 (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 } } } ``` **配置说明:** - `SendAPIReminderConfig`: "9|10" 表示每天 9 点开始执行,最晚 11 点结束 - `SmsServer`: 格式为 `服务器IP;默认端口;备用端口;短信签名;错误阈值;报警手机;测试手机号` - `Lark`: 飞书开放平台应用配置 - `Email`: SMTP 邮件配置 ### 2. 配置模型 ```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 防重复发送机制(核心亮点) 这是本文的核心亮点。我们使用 Redis Set 集合来记录当天已经发送过的提醒,通过 TTL 实现第二天自动失效。 ```csharp private const string AlertKeyPrefix = "alert:expire:sent:"; private readonly TimeSpan _ttlToNextDay; public APISubscribersAlertService(...) { _ttlToNextDay = GetTtlToNextDay(); } // 计算到第二天0点的剩余时间 private TimeSpan GetTtlToNextDay() { var now = DateTime.Now; return now.Date.AddDays(1) - now; } ``` **Redis Key 设计说明:** ``` Key: alert:expire:sent:2026-03-30 Value: (Redis Set 集合,包含多个已发送的记录) TTL: 约 10 小时(到当晚 24:00 自动失效) ``` **Set 中的 Value 格式:** ``` 格式: {ApiKey}:{剩余天数}:{渠道} 示例说明: abc123456:1:SMS → ApiKey 为 abc123456 的客户,还剩 1 天到期,已发送短信提醒 abc123456:4:Lark → 同一客户,还剩 4 天到期,已发送飞书提醒 abc123456:7:Email → 同一客户,还剩 7 天到期,已发送邮件提醒 ``` 这样设计的好处: 1. **同一个 ApiKey,不同剩余天数,不同渠道** - 可以独立判断是否需要发送 2. **第二天自动失效** - TTL 到了后 Key 自动删除,第三天可以重新发送 1/4/7 天提醒 3. **内存操作性能高** - 不需要每次查询数据库 ### 4. 核心提醒服务 ```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 var sendEnd = now.Date.Add(_endTimeSendReminder); // 11:00 // 已超过截止时间,跳到第二天 if (now >= sendEnd) { await SleepToNextDay(stoppingToken); continue; } // 未到开始时间,等待 if (now < sendStart) { await Task.Delay(sendStart - now, stoppingToken); continue; } // 执行提醒 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}"; // 从 Redis 获取当天已发送的记录 var sentSet = await _redis.SetMembersAsync(sentKey); // 查询即将到期的客户 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; // 短信提醒 - 检查是否已发送 if (!string.IsNullOrWhiteSpace(mobile)) { var sentValue = $"{apiKey}:{remaining}:SMS"; if (!sentSet.Contains(sentValue)) { var content = $"【API提醒】您的API服务即将在{remaining}天后到期,请及时续费。"; var result = await _smsService.SendMessageAsync(mobile, content); if (result) await _redis.SetAddAsync(sentKey, sentValue, _ttlToNextDay); } } // 飞书提醒 - 同理 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); } } // 邮件提醒 - 同理 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. 短信发送服务 短信通过 TCP 协议与短信网关通信,支持主备双通道: ```csharp public class SmsService : ISmsService { private readonly string _smsIp, _smsType, _testMobiles; private readonly int _smsPortDefault, _smsPortStandby; public async Task SendMessageAsync(string mobile, string content) { // 测试环境保护:只有白名单手机号才发送 if (!string.IsNullOrWhiteSpace(_testMobiles)) { var testList = _testMobiles.Split(','); if (!testList.Contains(mobile)) return true; } // 主通道发送 var result = await TrySendAsync(_smsIp, _smsPortDefault, mobile, content); // 失败则用备用通道 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(); // 构建二进制协议(GB2312编码) var gb2312 = Encoding.GetEncoding("gb2312"); var packet = BuildPacket(gb2312, _smsType, mobile, content); await stream.WriteAsync(packet); // 读取响应,flag=0 表示成功 var flag = BitConverter.ToUInt16(receiveBytes, 4); return flag == 0; } } ``` ### 6. 飞书消息服务 ```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) { // 先获取 access_token var token = await GetAccessTokenAsync(); // 发送消息 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. 邮件发送服务 ```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 服务注册 ```csharp static void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddSingleton(configuration); // 配置 var appSettings = new AppSettings(); configuration.GetSection("AppSettings").Bind(appSettings); services.AddSingleton(appSettings); // 数据库 var connectionString = configuration.GetConnectionString("DefaultConnection"); services.AddSingleton(new DatabaseFactory(connectionString))); // HTTP 客户端 services.AddSingleton(); // Redis var redisConn = configuration["RedisConnection"]; var redisPrefix = configuration["RedisKeyPrefix"] ?? "SA:"; services.AddSingleton(sp => new RedisHelper(redisConn, redisPrefix, sp.GetRequiredService>())); // 通知服务 var larkConfig = configuration.GetSection("AppSettings:Lark").Get(); services.AddSingleton(sp => new LarkService( larkConfig.AppId, larkConfig.AppKey, sp.GetRequiredService(), sp.GetRequiredService>())); services.AddSingleton(); services.AddSingleton(); // 注册后台服务 services.AddHostedService(); } ``` ## 关键设计点 ### 1. 时间窗口控制 服务只在每天 9:00-11:00 执行提醒,避免深夜打扰客户休息: ```csharp var sendStart = now.Date.AddHours(9); // 9:00 开始 var sendEnd = now.Date.AddHours(11); // 11:00 结束 ``` ### 2. 多次提醒机制 通过配置 `_remainingDaysForExpireReminder = { 1, 4, 7 }`,在客户 API 到期前 1、4、7 天分别发送提醒,提升续费率。 ### 3. Redis 防重复(核心) - 使用 **Redis Set** 存储当天已发送记录 - **Value 格式**:`{ApiKey}:{剩余天数}:{渠道}`,确保不同组合独立判断 - **TTL 自动失效**:计算到第二天 0 点的剩余时间作为 TTL,第二天自动失效后可重新发送 - **服务重启无影响**:重启后继续使用同一个 Key,已发送的不会重复 ### 4. 三通道通知 | 渠道 | 协议 | 特点 | |------|------|------| | 短信 | TCP Socket | 主备双通道自动切换 | | 飞书 | HTTPS REST API | 支持卡片消息、@提醒 | | 邮件 | SMTP | 支持 HTML 格式 | ### 5. 测试环境隔离 短信服务支持白名单机制,只有配置中列出的手机号才会实际发送,其他号码只记录日志不发送。 ### 6. 优雅关闭 BackgroundService 支持 CancellationToken,可收到停止信号后优雅退出: ```csharp protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { // 业务逻辑 } } ``` ## 总结 本文详细介绍了使用 .NET 8 + BackgroundService 开发多渠道 API 提醒服务的完整方案,核心亮点是使用 **Redis Set + TTL** 实现防重复发送机制,确保: 1. 同一天内不重复发送 2. 第二天自动失效可重新发送 3. 服务重启后继续生效 该架构具有良好的扩展性,可以轻松添加更多通知渠道(如钉钉、企业微信等)。 ## NuGet 包依赖 | 包名 | 版本 | |------|------| | 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 |