第八章-二次开发实战-业务功能扩展

第八章:二次开发实战-业务功能扩展

目录

  1. 事件总线使用
  2. 定时任务开发
  3. 文件上传与存储
  4. 微信对接开发
  5. 短信与邮件发送
  6. 数据导入导出
  7. SignalR实时通信
  8. 第三方系统集成

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二次开发中常用的功能扩展:

  1. 事件总线:实现模块解耦通信
  2. 定时任务:Cron表达式和作业管理
  3. 文件存储:本地和OSS文件上传
  4. 微信对接:小程序登录和微信支付
  5. 短信邮件:验证码和通知发送
  6. 数据导入导出:Excel模板导入导出
  7. SignalR通信:实时消息推送
  8. 第三方集成:OAuth登录和API对接

掌握这些功能扩展能力,可以应对大多数业务场景的开发需求。在下一章中,我们将学习系统部署和运维相关知识。

posted @ 2025-11-29 13:06  我才是银古  阅读(0)  评论(0)    收藏  举报