第八章:二次开发实战-业务功能扩展
目录
1. 事件总线使用
1.1 事件总线概述
Admin.NET使用Furion的事件总线实现模块间解耦通信。事件总线支持:
- 同步/异步事件
- 延迟事件
- 重试机制
- 事件持久化
1.2 定义事件
// EventBus/OrderEvent.cs
namespace MyCompany.Application;
/// <summary>
/// 订单创建事件源
/// </summary>
public class OrderCreatedEventSource : IEventSource
{
/// <summary>
/// 事件Id
/// </summary>
public string EventId => "Order:Created";
/// <summary>
/// 事件时间
/// </summary>
public DateTime EventTime { get; set; } = DateTime.Now;
/// <summary>
/// 取消令牌
/// </summary>
public CancellationToken CancellationToken { get; set; }
/// <summary>
/// 订单Id
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// 订单编号
/// </summary>
public string OrderNo { get; set; }
/// <summary>
/// 订单金额
/// </summary>
public decimal TotalAmount { get; set; }
/// <summary>
/// 客户Id
/// </summary>
public long CustomerId { get; set; }
}
/// <summary>
/// 订单状态变更事件源
/// </summary>
public class OrderStatusChangedEventSource : IEventSource
{
public string EventId => "Order:StatusChanged";
public DateTime EventTime { get; set; } = DateTime.Now;
public CancellationToken CancellationToken { get; set; }
public long OrderId { get; set; }
public string OrderNo { get; set; }
public int OldStatus { get; set; }
public int NewStatus { get; set; }
}
1.3 发布事件
// Service/OrderService.cs
public class OrderService : IDynamicApiController, ITransient
{
private readonly IEventPublisher _eventPublisher;
public OrderService(IEventPublisher eventPublisher)
{
_eventPublisher = eventPublisher;
}
/// <summary>
/// 创建订单
/// </summary>
public async Task<long> Create(CreateOrderInput input)
{
// 创建订单逻辑...
var order = new Order { /* ... */ };
await _orderRep.InsertAsync(order);
// 发布订单创建事件
await _eventPublisher.PublishAsync(new OrderCreatedEventSource
{
OrderId = order.Id,
OrderNo = order.OrderNo,
TotalAmount = order.TotalAmount,
CustomerId = order.CustomerId
});
return order.Id;
}
/// <summary>
/// 修改订单状态
/// </summary>
public async Task UpdateStatus(long orderId, int status)
{
var order = await _orderRep.GetByIdAsync(orderId);
var oldStatus = (int)order.Status;
order.Status = (OrderStatusEnum)status;
await _orderRep.UpdateAsync(order);
// 发布状态变更事件
await _eventPublisher.PublishAsync(new OrderStatusChangedEventSource
{
OrderId = order.Id,
OrderNo = order.OrderNo,
OldStatus = oldStatus,
NewStatus = status
});
}
/// <summary>
/// 发布延迟事件(15分钟后自动取消未支付订单)
/// </summary>
public async Task ScheduleAutoCancelOrder(long orderId)
{
await _eventPublisher.PublishDelayAsync(new OrderAutoCancelEventSource
{
OrderId = orderId
}, TimeSpan.FromMinutes(15));
}
}
1.4 订阅事件
// EventBus/OrderEventHandler.cs
namespace MyCompany.Application;
/// <summary>
/// 订单事件处理器
/// </summary>
public class OrderEventHandler : IEventSubscriber
{
private readonly ILogger<OrderEventHandler> _logger;
private readonly SqlSugarRepository<SysUser> _userRep;
private readonly IEmailService _emailService;
public OrderEventHandler(
ILogger<OrderEventHandler> logger,
SqlSugarRepository<SysUser> userRep,
IEmailService emailService)
{
_logger = logger;
_userRep = userRep;
_emailService = emailService;
}
/// <summary>
/// 处理订单创建事件
/// </summary>
[EventSubscribe("Order:Created")]
public async Task HandleOrderCreated(EventHandlerExecutingContext context)
{
var eventSource = context.Source as OrderCreatedEventSource;
_logger.LogInformation($"订单创建事件:订单号={eventSource.OrderNo},金额={eventSource.TotalAmount}");
// 发送通知邮件
var customer = await _userRep.GetByIdAsync(eventSource.CustomerId);
if (!string.IsNullOrEmpty(customer?.Email))
{
await _emailService.SendAsync(new EmailMessage
{
To = customer.Email,
Subject = "订单创建成功",
Body = $"您的订单 {eventSource.OrderNo} 已创建成功,订单金额:¥{eventSource.TotalAmount}"
});
}
// 其他业务处理...
}
/// <summary>
/// 处理订单状态变更事件
/// </summary>
[EventSubscribe("Order:StatusChanged")]
public async Task HandleOrderStatusChanged(EventHandlerExecutingContext context)
{
var eventSource = context.Source as OrderStatusChangedEventSource;
_logger.LogInformation($"订单状态变更:订单号={eventSource.OrderNo},{eventSource.OldStatus} -> {eventSource.NewStatus}");
// 记录操作日志
// 发送状态变更通知
// 触发后续业务流程
}
/// <summary>
/// 处理订单自动取消事件
/// </summary>
[EventSubscribe("Order:AutoCancel")]
public async Task HandleOrderAutoCancel(EventHandlerExecutingContext context)
{
var eventSource = context.Source as OrderAutoCancelEventSource;
// 检查订单是否仍未支付
// 执行取消订单逻辑
// 恢复库存
}
}
2. 定时任务开发
2.1 创建作业类
// Job/OrderStatisticsJob.cs
namespace MyCompany.Application;
/// <summary>
/// 订单统计作业
/// </summary>
[JobDetail("job_order_statistics", Description = "每日订单统计",
GroupName = "statistics", Concurrent = false)]
[Daily(TriggerId = "trigger_order_statistics", Description = "每天凌晨1点执行")]
public class OrderStatisticsJob : IJob
{
private readonly ILogger<OrderStatisticsJob> _logger;
private readonly ISqlSugarClient _db;
public OrderStatisticsJob(ILogger<OrderStatisticsJob> logger, ISqlSugarClient db)
{
_logger = logger;
_db = db;
}
public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
_logger.LogInformation("开始执行订单统计作业");
try
{
var yesterday = DateTime.Today.AddDays(-1);
// 统计昨日订单
var statistics = await _db.Queryable<Order>()
.Where(o => o.CreateTime >= yesterday && o.CreateTime < DateTime.Today)
.GroupBy(o => o.Status)
.Select(o => new
{
Status = o.Status,
Count = SqlFunc.AggregateCount(o.Id),
TotalAmount = SqlFunc.AggregateSum(o.TotalAmount)
})
.ToListAsync();
// 保存统计结果
foreach (var stat in statistics)
{
await _db.Insertable(new OrderStatistics
{
StatDate = yesterday,
Status = (int)stat.Status,
OrderCount = stat.Count,
TotalAmount = stat.TotalAmount
}).ExecuteCommandAsync();
}
_logger.LogInformation($"订单统计完成,共{statistics.Count}条记录");
}
catch (Exception ex)
{
_logger.LogError(ex, "订单统计作业执行失败");
throw;
}
}
}
2.2 Cron表达式作业
// Job/DataCleanupJob.cs
/// <summary>
/// 数据清理作业
/// </summary>
[JobDetail("job_data_cleanup", Description = "数据清理")]
[Cron("0 0 3 * * ?", TriggerId = "trigger_data_cleanup", Description = "每天凌晨3点执行")]
public class DataCleanupJob : IJob
{
private readonly ISqlSugarClient _db;
private readonly ILogger<DataCleanupJob> _logger;
public DataCleanupJob(ISqlSugarClient db, ILogger<DataCleanupJob> logger)
{
_db = db;
_logger = logger;
}
public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
{
// 清理30天前的操作日志
var cleanupDate = DateTime.Now.AddDays(-30);
var count = await _db.Deleteable<SysLogOp>()
.Where(l => l.CreateTime < cleanupDate)
.ExecuteCommandAsync();
_logger.LogInformation($"清理操作日志{count}条");
// 清理临时文件
var tempFiles = await _db.Queryable<SysFile>()
.Where(f => f.FileType == "temp" && f.CreateTime < cleanupDate)
.ToListAsync();
foreach (var file in tempFiles)
{
// 删除物理文件
// 删除数据库记录
}
}
}
2.3 动态作业管理
// Service/JobManageService.cs
/// <summary>
/// 作业管理服务
/// </summary>
[ApiDescriptionSettings(Order = 50)]
public class JobManageService : IDynamicApiController, ITransient
{
private readonly ISchedulerFactory _schedulerFactory;
public JobManageService(ISchedulerFactory schedulerFactory)
{
_schedulerFactory = schedulerFactory;
}
/// <summary>
/// 添加作业
/// </summary>
public async Task AddJob(AddJobInput input)
{
var scheduler = _schedulerFactory.GetJob(input.JobId);
if (scheduler == null)
{
throw Oops.Oh("作业不存在");
}
// 添加触发器
scheduler.AddTrigger(new CronTrigger(input.Cron)
{
TriggerId = input.TriggerId,
Description = input.Description
});
await scheduler.StartAsync();
}
/// <summary>
/// 暂停作业
/// </summary>
public async Task PauseJob(string jobId)
{
var scheduler = _schedulerFactory.GetJob(jobId);
await scheduler?.PauseAsync();
}
/// <summary>
/// 恢复作业
/// </summary>
public async Task ResumeJob(string jobId)
{
var scheduler = _schedulerFactory.GetJob(jobId);
await scheduler?.ResumeAsync();
}
/// <summary>
/// 立即执行
/// </summary>
public async Task TriggerJob(string jobId)
{
var scheduler = _schedulerFactory.GetJob(jobId);
await scheduler?.TriggerAsync();
}
/// <summary>
/// 获取所有作业
/// </summary>
public List<JobInfo> GetAllJobs()
{
return _schedulerFactory.GetJobs()
.Select(j => new JobInfo
{
JobId = j.JobId,
Description = j.Description,
Status = j.GetStatus().ToString()
})
.ToList();
}
}
3. 文件上传与存储
3.1 文件上传服务
// Service/FileUploadService.cs
/// <summary>
/// 文件上传服务
/// </summary>
[ApiDescriptionSettings(Order = 80)]
public class FileUploadService : IDynamicApiController, ITransient
{
private readonly SqlSugarRepository<SysFile> _fileRep;
private readonly OSSProviderOptions _ossOptions;
private readonly IWebHostEnvironment _env;
public FileUploadService(
SqlSugarRepository<SysFile> fileRep,
IOptions<OSSProviderOptions> ossOptions,
IWebHostEnvironment env)
{
_fileRep = fileRep;
_ossOptions = ossOptions.Value;
_env = env;
}
/// <summary>
/// 上传文件
/// </summary>
[DisplayName("上传文件")]
public async Task<FileUploadOutput> Upload([FromForm] IFormFile file, [FromForm] string? folder)
{
if (file == null || file.Length == 0)
throw Oops.Oh("请选择文件");
// 验证文件类型
var allowedTypes = new[] { ".jpg", ".jpeg", ".png", ".gif", ".pdf", ".doc", ".docx", ".xls", ".xlsx" };
var extension = Path.GetExtension(file.FileName).ToLower();
if (!allowedTypes.Contains(extension))
throw Oops.Oh("不支持的文件类型");
// 验证文件大小(10MB)
if (file.Length > 10 * 1024 * 1024)
throw Oops.Oh("文件大小不能超过10MB");
// 生成文件名
var fileName = $"{YitIdHelper.NextId()}{extension}";
var datePath = DateTime.Now.ToString("yyyy/MM/dd");
var relativePath = Path.Combine(folder ?? "upload", datePath, fileName);
// 保存文件
string url;
if (_ossOptions.IsEnable)
{
// 上传到OSS
url = await UploadToOss(file, relativePath);
}
else
{
// 上传到本地
url = await UploadToLocal(file, relativePath);
}
// 保存文件记录
var sysFile = new SysFile
{
FileName = file.FileName,
Suffix = extension,
SizeKb = file.Length / 1024,
FilePath = relativePath,
Url = url,
Provider = _ossOptions.IsEnable ? _ossOptions.Provider : "Local"
};
await _fileRep.InsertAsync(sysFile);
return new FileUploadOutput
{
Id = sysFile.Id,
FileName = sysFile.FileName,
Url = url
};
}
/// <summary>
/// 上传到本地
/// </summary>
private async Task<string> UploadToLocal(IFormFile file, string relativePath)
{
var absolutePath = Path.Combine(_env.WebRootPath, relativePath);
var directory = Path.GetDirectoryName(absolutePath);
if (!Directory.Exists(directory))
Directory.CreateDirectory(directory!);
using var stream = new FileStream(absolutePath, FileMode.Create);
await file.CopyToAsync(stream);
return "/" + relativePath.Replace("\\", "/");
}
/// <summary>
/// 上传到OSS
/// </summary>
private async Task<string> UploadToOss(IFormFile file, string relativePath)
{
var ossService = App.GetService<IOSSService>();
using var stream = file.OpenReadStream();
var result = await ossService.PutObjectAsync(relativePath, stream);
return result.Url;
}
/// <summary>
/// 批量上传
/// </summary>
[DisplayName("批量上传文件")]
public async Task<List<FileUploadOutput>> BatchUpload([FromForm] List<IFormFile> files)
{
var results = new List<FileUploadOutput>();
foreach (var file in files)
{
var result = await Upload(file, null);
results.Add(result);
}
return results;
}
/// <summary>
/// 删除文件
/// </summary>
[HttpPost]
[DisplayName("删除文件")]
public async Task Delete(long id)
{
var file = await _fileRep.GetByIdAsync(id);
if (file == null) return;
// 删除物理文件
if (file.Provider == "Local")
{
var path = Path.Combine(_env.WebRootPath, file.FilePath);
if (File.Exists(path))
File.Delete(path);
}
else
{
var ossService = App.GetService<IOSSService>();
await ossService.RemoveObjectAsync(file.FilePath);
}
// 删除记录
await _fileRep.DeleteAsync(file);
}
}
3.2 OSS配置
{
"OSSProvider": {
"IsEnable": true,
"Provider": "Aliyun",
"Endpoint": "oss-cn-hangzhou.aliyuncs.com",
"AccessKey": "your-access-key",
"SecretKey": "your-secret-key",
"BucketName": "your-bucket",
"IsEnableHttps": true,
"IsEnableCname": false
}
}
4. 微信对接开发
4.1 微信小程序登录
// Service/WechatMiniService.cs
/// <summary>
/// 微信小程序服务
/// </summary>
[ApiDescriptionSettings("微信", Order = 60)]
public class WechatMiniService : IDynamicApiController, ITransient
{
private readonly WechatApiClient _wechatClient;
private readonly SqlSugarRepository<SysWechatUser> _wechatUserRep;
private readonly SqlSugarRepository<SysUser> _userRep;
public WechatMiniService(
WechatApiClient wechatClient,
SqlSugarRepository<SysWechatUser> wechatUserRep,
SqlSugarRepository<SysUser> userRep)
{
_wechatClient = wechatClient;
_wechatUserRep = wechatUserRep;
_userRep = userRep;
}
/// <summary>
/// 小程序登录
/// </summary>
[AllowAnonymous]
[DisplayName("小程序登录")]
public async Task<WechatLoginOutput> Login(WechatLoginInput input)
{
// 调用微信接口获取OpenId
var request = new SnsJsCode2SessionRequest
{
JsCode = input.Code
};
var response = await _wechatClient.ExecuteSnsJsCode2SessionAsync(request);
if (!response.IsSuccessful())
throw Oops.Oh($"微信登录失败:{response.ErrorMessage}");
var openId = response.OpenId;
var sessionKey = response.SessionKey;
// 查找或创建微信用户
var wechatUser = await _wechatUserRep.GetFirstAsync(u => u.OpenId == openId);
if (wechatUser == null)
{
// 创建新用户
wechatUser = new SysWechatUser
{
OpenId = openId,
SessionKey = sessionKey,
NickName = input.NickName,
Avatar = input.AvatarUrl,
Gender = input.Gender
};
await _wechatUserRep.InsertAsync(wechatUser);
// 创建系统用户(可选)
if (input.AutoRegister)
{
var user = new SysUser
{
Account = $"wx_{openId[^8..]}",
NickName = input.NickName,
Avatar = input.AvatarUrl,
AccountType = AccountTypeEnum.NormalUser
};
await _userRep.InsertAsync(user);
wechatUser.UserId = user.Id;
await _wechatUserRep.UpdateAsync(wechatUser);
}
}
else
{
// 更新SessionKey
wechatUser.SessionKey = sessionKey;
await _wechatUserRep.UpdateAsync(wechatUser);
}
// 生成Token
var token = GenerateToken(wechatUser);
return new WechatLoginOutput
{
Token = token,
UserId = wechatUser.UserId,
OpenId = openId,
IsNew = wechatUser.UserId == null
};
}
/// <summary>
/// 获取手机号
/// </summary>
[DisplayName("获取手机号")]
public async Task<string> GetPhoneNumber(string code)
{
var request = new WxaBusinessGetUserPhoneNumberRequest
{
Code = code
};
var response = await _wechatClient.ExecuteWxaBusinessGetUserPhoneNumberAsync(request);
if (!response.IsSuccessful())
throw Oops.Oh($"获取手机号失败:{response.ErrorMessage}");
return response.PhoneInfo.PhoneNumber;
}
}
4.2 微信支付
// Service/WechatPayService.cs
/// <summary>
/// 微信支付服务
/// </summary>
[ApiDescriptionSettings("支付", Order = 55)]
public class WechatPayService : IDynamicApiController, ITransient
{
private readonly WechatTenpayClient _payClient;
private readonly SqlSugarRepository<SysWechatPay> _payRep;
private readonly WechatPayOptions _options;
public WechatPayService(
WechatTenpayClient payClient,
SqlSugarRepository<SysWechatPay> payRep,
IOptions<WechatPayOptions> options)
{
_payClient = payClient;
_payRep = payRep;
_options = options.Value;
}
/// <summary>
/// 创建支付订单(小程序)
/// </summary>
[DisplayName("创建支付订单")]
public async Task<WechatPayOutput> CreateOrder(CreatePayOrderInput input)
{
// 生成商户订单号
var outTradeNo = $"PAY{DateTime.Now:yyyyMMddHHmmss}{new Random().Next(1000, 9999)}";
// 创建支付请求
var request = new CreatePayTransactionJsapiRequest
{
OutTradeNumber = outTradeNo,
AppId = _options.AppId,
Description = input.Description,
NotifyUrl = _options.NotifyUrl,
Amount = new CreatePayTransactionJsapiRequest.Types.Amount
{
Total = (int)(input.Amount * 100)
},
Payer = new CreatePayTransactionJsapiRequest.Types.Payer
{
OpenId = input.OpenId
}
};
var response = await _payClient.ExecuteCreatePayTransactionJsapiAsync(request);
if (!response.IsSuccessful())
throw Oops.Oh($"创建支付订单失败:{response.ErrorMessage}");
// 保存支付记录
var payRecord = new SysWechatPay
{
OutTradeNo = outTradeNo,
TransactionId = response.PrepayId,
TotalAmount = input.Amount,
PayStatus = PayStatusEnum.Pending,
OrderId = input.OrderId,
OpenId = input.OpenId
};
await _payRep.InsertAsync(payRecord);
// 生成小程序支付参数
var payParams = _payClient.GenerateParametersForJsapiPayRequest(
_options.AppId, response.PrepayId);
return new WechatPayOutput
{
OutTradeNo = outTradeNo,
TimeStamp = payParams["timeStamp"],
NonceStr = payParams["nonceStr"],
Package = payParams["package"],
SignType = payParams["signType"],
PaySign = payParams["paySign"]
};
}
/// <summary>
/// 支付回调
/// </summary>
[AllowAnonymous]
[HttpPost]
public async Task<IActionResult> PayNotify()
{
var context = App.HttpContext;
// 验证签名
var timestamp = context.Request.Headers["Wechatpay-Timestamp"].FirstOrDefault();
var nonce = context.Request.Headers["Wechatpay-Nonce"].FirstOrDefault();
var signature = context.Request.Headers["Wechatpay-Signature"].FirstOrDefault();
var serialNumber = context.Request.Headers["Wechatpay-Serial"].FirstOrDefault();
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
var valid = _payClient.VerifyEventSignature(timestamp, nonce, body, signature, serialNumber);
if (!valid)
return new BadRequestResult();
// 解析通知内容
var notification = _payClient.DeserializeEvent(body);
var resource = _payClient.DecryptEventResource<TransactionResource>(notification);
// 更新支付状态
var payRecord = await _payRep.GetFirstAsync(p => p.OutTradeNo == resource.OutTradeNumber);
if (payRecord != null && payRecord.PayStatus == PayStatusEnum.Pending)
{
payRecord.TransactionId = resource.TransactionId;
payRecord.PayStatus = PayStatusEnum.Success;
payRecord.PayTime = DateTime.Now;
await _payRep.UpdateAsync(payRecord);
// 触发支付成功事件
await App.GetService<IEventPublisher>().PublishAsync(new PaySuccessEventSource
{
OrderId = payRecord.OrderId,
OutTradeNo = payRecord.OutTradeNo,
Amount = payRecord.TotalAmount
});
}
return new ContentResult
{
Content = "{\"code\":\"SUCCESS\",\"message\":\"成功\"}",
ContentType = "application/json"
};
}
/// <summary>
/// 退款
/// </summary>
[DisplayName("申请退款")]
public async Task<RefundOutput> Refund(RefundInput input)
{
var payRecord = await _payRep.GetFirstAsync(p => p.OutTradeNo == input.OutTradeNo);
if (payRecord == null)
throw Oops.Oh("支付记录不存在");
var outRefundNo = $"REF{DateTime.Now:yyyyMMddHHmmss}{new Random().Next(1000, 9999)}";
var request = new CreateRefundDomesticRefundRequest
{
OutTradeNumber = input.OutTradeNo,
OutRefundNumber = outRefundNo,
Reason = input.Reason,
Amount = new CreateRefundDomesticRefundRequest.Types.Amount
{
Refund = (int)(input.RefundAmount * 100),
Total = (int)(payRecord.TotalAmount * 100),
Currency = "CNY"
},
NotifyUrl = _options.RefundNotifyUrl
};
var response = await _payClient.ExecuteCreateRefundDomesticRefundAsync(request);
if (!response.IsSuccessful())
throw Oops.Oh($"退款失败:{response.ErrorMessage}");
return new RefundOutput
{
OutRefundNo = outRefundNo,
Status = response.Status
};
}
}
5. 短信与邮件发送
5.1 短信服务
// Service/SmsService.cs
/// <summary>
/// 短信服务
/// </summary>
public class SmsService : ISmsService, ITransient
{
private readonly SmsOptions _options;
private readonly SysCacheService _cache;
private readonly ILogger<SmsService> _logger;
public SmsService(
IOptions<SmsOptions> options,
SysCacheService cache,
ILogger<SmsService> logger)
{
_options = options.Value;
_cache = cache;
_logger = logger;
}
/// <summary>
/// 发送验证码
/// </summary>
public async Task<bool> SendVerifyCode(string phone, string templateId = null)
{
// 验证手机号格式
if (!Regex.IsMatch(phone, @"^1[3-9]\d{9}$"))
throw Oops.Oh("手机号格式不正确");
// 检查发送频率(1分钟内只能发送一次)
var cacheKey = $"sms:limit:{phone}";
if (_cache.Exists(cacheKey))
throw Oops.Oh("发送太频繁,请稍后再试");
// 生成验证码
var code = new Random().Next(100000, 999999).ToString();
// 发送短信
var success = await SendSms(phone, templateId ?? _options.VerifyCodeTemplateId, new { code });
if (success)
{
// 缓存验证码(5分钟有效)
_cache.Set($"sms:code:{phone}", code, TimeSpan.FromMinutes(5));
// 设置发送限制(1分钟)
_cache.Set(cacheKey, "1", TimeSpan.FromMinutes(1));
}
return success;
}
/// <summary>
/// 验证验证码
/// </summary>
public bool VerifyCode(string phone, string code)
{
var cacheKey = $"sms:code:{phone}";
var cachedCode = _cache.Get<string>(cacheKey);
if (string.IsNullOrEmpty(cachedCode))
throw Oops.Oh("验证码已过期");
if (cachedCode != code)
throw Oops.Oh("验证码不正确");
// 验证成功后删除缓存
_cache.Remove(cacheKey);
return true;
}
/// <summary>
/// 发送短信
/// </summary>
private async Task<bool> SendSms(string phone, string templateId, object templateParams)
{
try
{
// 阿里云短信
var client = CreateAliyunClient();
var request = new SendSmsRequest
{
PhoneNumbers = phone,
SignName = _options.SignName,
TemplateCode = templateId,
TemplateParam = JSON.Serialize(templateParams)
};
var response = await client.SendSmsAsync(request);
if (response.Body.Code == "OK")
{
_logger.LogInformation($"短信发送成功:{phone}");
return true;
}
else
{
_logger.LogWarning($"短信发送失败:{phone},{response.Body.Message}");
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"短信发送异常:{phone}");
return false;
}
}
private Client CreateAliyunClient()
{
var config = new Config
{
AccessKeyId = _options.AccessKeyId,
AccessKeySecret = _options.AccessKeySecret,
Endpoint = "dysmsapi.aliyuncs.com"
};
return new Client(config);
}
}
5.2 邮件服务
// Service/EmailService.cs
/// <summary>
/// 邮件服务
/// </summary>
public class EmailService : IEmailService, ITransient
{
private readonly EmailOptions _options;
private readonly ILogger<EmailService> _logger;
public EmailService(IOptions<EmailOptions> options, ILogger<EmailService> logger)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// 发送邮件
/// </summary>
public async Task<bool> SendAsync(EmailMessage message)
{
try
{
var email = new MimeMessage();
email.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
email.To.Add(MailboxAddress.Parse(message.To));
email.Subject = message.Subject;
var builder = new BodyBuilder();
if (message.IsHtml)
{
builder.HtmlBody = message.Body;
}
else
{
builder.TextBody = message.Body;
}
// 添加附件
if (message.Attachments?.Any() == true)
{
foreach (var attachment in message.Attachments)
{
builder.Attachments.Add(attachment.FileName, attachment.Content);
}
}
email.Body = builder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_options.Host, _options.Port, _options.UseSsl);
await client.AuthenticateAsync(_options.UserName, _options.Password);
await client.SendAsync(email);
await client.DisconnectAsync(true);
_logger.LogInformation($"邮件发送成功:{message.To}");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, $"邮件发送失败:{message.To}");
return false;
}
}
/// <summary>
/// 发送模板邮件
/// </summary>
public async Task<bool> SendTemplateAsync(string to, string templateName, object data)
{
// 获取模板内容
var template = await GetTemplate(templateName);
// 渲染模板
var body = RenderTemplate(template, data);
return await SendAsync(new EmailMessage
{
To = to,
Subject = template.Subject,
Body = body,
IsHtml = true
});
}
}
6. 数据导入导出
6.1 Excel导出
// Service/ExportService.cs
/// <summary>
/// 导出服务
/// </summary>
public class ExportService : ITransient
{
/// <summary>
/// 导出Excel
/// </summary>
public async Task<IActionResult> ExportExcel<T>(List<T> data, string fileName = "导出数据") where T : class, new()
{
var bytes = await data.ExportExcel();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = $"{fileName}_{DateTime.Now:yyyyMMddHHmmss}.xlsx"
};
}
/// <summary>
/// 导出带模板的Excel
/// </summary>
public async Task<IActionResult> ExportWithTemplate<T>(List<T> data, string templatePath, string fileName) where T : class, new()
{
var exporter = new ExcelExporter();
var bytes = await exporter.ExportByTemplate(templatePath, data);
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = $"{fileName}_{DateTime.Now:yyyyMMddHHmmss}.xlsx"
};
}
}
/// <summary>
/// 产品导出DTO
/// </summary>
[ExcelExporter(Name = "产品列表", AutoCenter = true)]
public class ProductExportDto
{
[ExporterHeader(DisplayName = "产品编码", Width = 15)]
public string Code { get; set; }
[ExporterHeader(DisplayName = "产品名称", Width = 25)]
public string Name { get; set; }
[ExporterHeader(DisplayName = "分类", Width = 15)]
public string CategoryName { get; set; }
[ExporterHeader(DisplayName = "规格型号", Width = 20)]
public string Specification { get; set; }
[ExporterHeader(DisplayName = "单位", Width = 10)]
public string Unit { get; set; }
[ExporterHeader(DisplayName = "单价", Width = 12, Format = "#,##0.00")]
public decimal Price { get; set; }
[ExporterHeader(DisplayName = "库存", Width = 10)]
public int StockQty { get; set; }
[ExporterHeader(DisplayName = "状态", Width = 10)]
public string StatusText { get; set; }
[ExporterHeader(DisplayName = "创建时间", Width = 20, Format = "yyyy-MM-dd HH:mm:ss")]
public DateTime? CreateTime { get; set; }
}
6.2 Excel导入
// Service/ImportService.cs
/// <summary>
/// 导入服务
/// </summary>
public class ImportService : IDynamicApiController, ITransient
{
private readonly SqlSugarRepository<Product> _productRep;
public ImportService(SqlSugarRepository<Product> productRep)
{
_productRep = productRep;
}
/// <summary>
/// 导入产品
/// </summary>
[DisplayName("导入产品")]
public async Task<ImportResult> ImportProducts([FromForm] IFormFile file)
{
if (file == null)
throw Oops.Oh("请选择文件");
var result = new ImportResult();
var importer = new ExcelImporter();
using var stream = file.OpenReadStream();
var importResult = await importer.Import<ProductImportDto>(stream);
if (importResult.HasError)
{
result.Success = false;
result.Message = "导入失败,数据验证错误";
result.Errors = importResult.RowErrors.Select(e => new ImportError
{
RowNumber = e.RowIndex,
FieldName = e.FieldName,
Message = e.Message
}).ToList();
return result;
}
var products = new List<Product>();
foreach (var item in importResult.Data)
{
// 检查编码是否重复
var exist = await _productRep.IsAnyAsync(p => p.Code == item.Code);
if (exist)
{
result.Errors.Add(new ImportError
{
RowNumber = item.RowNumber,
FieldName = "Code",
Message = $"产品编码 {item.Code} 已存在"
});
continue;
}
products.Add(item.Adapt<Product>());
}
if (products.Any())
{
await _productRep.InsertRangeAsync(products);
}
result.Success = !result.Errors.Any();
result.TotalCount = importResult.Data.Count;
result.SuccessCount = products.Count;
result.FailCount = result.Errors.Count;
return result;
}
/// <summary>
/// 下载导入模板
/// </summary>
[DisplayName("下载导入模板")]
public async Task<IActionResult> DownloadTemplate()
{
var exporter = new ExcelExporter();
var bytes = await exporter.ExportHeaderAsByteArray<ProductImportDto>();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = "产品导入模板.xlsx"
};
}
}
/// <summary>
/// 产品导入DTO
/// </summary>
[ExcelImporter(IsLabelingError = true)]
public class ProductImportDto
{
[ImporterHeader(Name = "产品编码")]
[Required(ErrorMessage = "产品编码不能为空")]
public string Code { get; set; }
[ImporterHeader(Name = "产品名称")]
[Required(ErrorMessage = "产品名称不能为空")]
public string Name { get; set; }
[ImporterHeader(Name = "分类编码")]
public string CategoryCode { get; set; }
[ImporterHeader(Name = "规格型号")]
public string Specification { get; set; }
[ImporterHeader(Name = "单位")]
public string Unit { get; set; }
[ImporterHeader(Name = "单价")]
[Range(0, double.MaxValue, ErrorMessage = "单价必须大于等于0")]
public decimal Price { get; set; }
[ImporterHeader(Name = "库存数量")]
[Range(0, int.MaxValue, ErrorMessage = "库存必须大于等于0")]
public int StockQty { get; set; }
[ImporterHeader(IsIgnore = true)]
public int RowNumber { get; set; }
}
7. SignalR实时通信
7.1 创建Hub
// Hub/NotificationHub.cs
/// <summary>
/// 通知Hub
/// </summary>
[Authorize]
public class NotificationHub : Hub
{
private readonly ILogger<NotificationHub> _logger;
private readonly SysCacheService _cache;
public NotificationHub(ILogger<NotificationHub> logger, SysCacheService cache)
{
_logger = logger;
_cache = cache;
}
/// <summary>
/// 连接成功
/// </summary>
public override async Task OnConnectedAsync()
{
var userId = Context.User?.FindFirstValue(ClaimConst.UserId);
var connectionId = Context.ConnectionId;
if (!string.IsNullOrEmpty(userId))
{
// 保存连接映射
await Groups.AddToGroupAsync(connectionId, $"user_{userId}");
_cache.Set($"connection:{connectionId}", userId, TimeSpan.FromDays(1));
_logger.LogInformation($"用户 {userId} 已连接,ConnectionId: {connectionId}");
}
await base.OnConnectedAsync();
}
/// <summary>
/// 断开连接
/// </summary>
public override async Task OnDisconnectedAsync(Exception exception)
{
var connectionId = Context.ConnectionId;
var userId = _cache.Get<string>($"connection:{connectionId}");
if (!string.IsNullOrEmpty(userId))
{
await Groups.RemoveFromGroupAsync(connectionId, $"user_{userId}");
_cache.Remove($"connection:{connectionId}");
_logger.LogInformation($"用户 {userId} 已断开,ConnectionId: {connectionId}");
}
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// 加入群组
/// </summary>
public async Task JoinGroup(string groupName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// 离开群组
/// </summary>
public async Task LeaveGroup(string groupName)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// 发送消息给指定用户
/// </summary>
public async Task SendToUser(string userId, string message)
{
await Clients.Group($"user_{userId}").SendAsync("ReceiveMessage", message);
}
/// <summary>
/// 发送消息给群组
/// </summary>
public async Task SendToGroup(string groupName, string message)
{
await Clients.Group(groupName).SendAsync("ReceiveMessage", message);
}
}
7.2 发送消息服务
// Service/NotificationService.cs
/// <summary>
/// 通知服务
/// </summary>
public class NotificationService : ITransient
{
private readonly IHubContext<NotificationHub> _hubContext;
private readonly SqlSugarRepository<SysNotice> _noticeRep;
public NotificationService(
IHubContext<NotificationHub> hubContext,
SqlSugarRepository<SysNotice> noticeRep)
{
_hubContext = hubContext;
_noticeRep = noticeRep;
}
/// <summary>
/// 发送通知给指定用户
/// </summary>
public async Task SendToUser(long userId, NotificationMessage message)
{
await _hubContext.Clients.Group($"user_{userId}")
.SendAsync("ReceiveNotification", message);
}
/// <summary>
/// 发送通知给多个用户
/// </summary>
public async Task SendToUsers(List<long> userIds, NotificationMessage message)
{
var groups = userIds.Select(id => $"user_{id}").ToList();
await _hubContext.Clients.Groups(groups)
.SendAsync("ReceiveNotification", message);
}
/// <summary>
/// 发送广播通知
/// </summary>
public async Task Broadcast(NotificationMessage message)
{
await _hubContext.Clients.All
.SendAsync("ReceiveNotification", message);
}
/// <summary>
/// 强制用户下线
/// </summary>
public async Task ForceOffline(long userId, string reason)
{
await _hubContext.Clients.Group($"user_{userId}")
.SendAsync("ForceOffline", new { reason });
}
}
/// <summary>
/// 通知消息
/// </summary>
public class NotificationMessage
{
public string Title { get; set; }
public string Content { get; set; }
public string Type { get; set; }
public DateTime Time { get; set; } = DateTime.Now;
public object Data { get; set; }
}
7.3 前端连接
// utils/signalr.ts
import * as signalR from '@microsoft/signalr'
import { useUserStore } from '@/stores/modules/user'
class SignalRService {
private connection: signalR.HubConnection | null = null
async start() {
const userStore = useUserStore()
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notification', {
accessTokenFactory: () => userStore.token
})
.withAutomaticReconnect()
.build()
// 接收通知
this.connection.on('ReceiveNotification', (message) => {
console.log('收到通知:', message)
ElNotification({
title: message.title,
message: message.content,
type: message.type || 'info'
})
})
// 强制下线
this.connection.on('ForceOffline', (data) => {
ElMessageBox.alert(data.reason, '您已被强制下线', {
confirmButtonText: '确定',
callback: () => {
userStore.logout()
location.reload()
}
})
})
await this.connection.start()
console.log('SignalR 已连接')
}
async stop() {
if (this.connection) {
await this.connection.stop()
this.connection = null
}
}
}
export const signalRService = new SignalRService()
8. 第三方系统集成
8.1 OAuth第三方登录
// Service/OAuthService.cs
/// <summary>
/// 第三方登录服务
/// </summary>
[ApiDescriptionSettings("OAuth", Order = 40)]
public class OAuthService : IDynamicApiController, ITransient
{
private readonly GithubAuthHandler _githubHandler;
private readonly GiteeAuthHandler _giteeHandler;
/// <summary>
/// 获取GitHub授权链接
/// </summary>
[AllowAnonymous]
public string GetGithubAuthUrl(string redirectUri)
{
return _githubHandler.GetAuthorizeUrl(new AuthorizeRequest
{
RedirectUri = redirectUri,
State = Guid.NewGuid().ToString("N")
});
}
/// <summary>
/// GitHub登录回调
/// </summary>
[AllowAnonymous]
public async Task<LoginOutput> GithubCallback(string code, string state)
{
// 获取AccessToken
var tokenResponse = await _githubHandler.GetAccessTokenAsync(new AccessTokenRequest
{
Code = code
});
// 获取用户信息
var userInfo = await _githubHandler.GetUserInfoAsync(tokenResponse.AccessToken);
// 查找或创建用户
var user = await FindOrCreateUser("github", userInfo.Id, userInfo);
// 生成系统Token
return GenerateLoginOutput(user);
}
/// <summary>
/// 钉钉扫码登录
/// </summary>
[AllowAnonymous]
public async Task<LoginOutput> DingTalkCallback(string code)
{
// 钉钉登录逻辑
}
/// <summary>
/// 企业微信登录
/// </summary>
[AllowAnonymous]
public async Task<LoginOutput> WorkWeixinCallback(string code)
{
// 企业微信登录逻辑
}
}
8.2 WebAPI对接
// Service/ThirdPartyService.cs
/// <summary>
/// 第三方API服务
/// </summary>
public class ThirdPartyApiService : ITransient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ThirdPartyApiService> _logger;
public ThirdPartyApiService(
IHttpClientFactory httpClientFactory,
ILogger<ThirdPartyApiService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// 调用ERP接口
/// </summary>
public async Task<ErpResult<T>> CallErpApi<T>(string api, object data)
{
var client = _httpClientFactory.CreateClient("ERP");
var content = new StringContent(
JSON.Serialize(data),
Encoding.UTF8,
"application/json"
);
try
{
var response = await client.PostAsync(api, content);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogError($"ERP接口调用失败:{api},状态码:{response.StatusCode},响应:{responseContent}");
throw Oops.Oh($"ERP接口调用失败:{response.StatusCode}");
}
return JSON.Deserialize<ErpResult<T>>(responseContent);
}
catch (Exception ex)
{
_logger.LogError(ex, $"ERP接口调用异常:{api}");
throw;
}
}
/// <summary>
/// 同步库存到ERP
/// </summary>
public async Task SyncStockToErp(List<StockSyncData> stockList)
{
var result = await CallErpApi<bool>("/api/stock/sync", new
{
stocks = stockList
});
if (!result.Success)
{
throw Oops.Oh($"库存同步失败:{result.Message}");
}
}
}
// 配置HttpClient
services.AddHttpClient("ERP", client =>
{
client.BaseAddress = new Uri(erpConfig.BaseUrl);
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {erpConfig.ApiKey}");
client.Timeout = TimeSpan.FromSeconds(30);
});
总结
本章详细介绍了Admin.NET二次开发中常用的功能扩展:
- 事件总线:实现模块解耦通信
- 定时任务:Cron表达式和作业管理
- 文件存储:本地和OSS文件上传
- 微信对接:小程序登录和微信支付
- 短信邮件:验证码和通知发送
- 数据导入导出:Excel模板导入导出
- SignalR通信:实时消息推送
- 第三方集成:OAuth登录和API对接
掌握这些功能扩展能力,可以应对大多数业务场景的开发需求。在下一章中,我们将学习系统部署和运维相关知识。