diff --git a/db.sql b/db.sql index 3ec60e7..6da1c1f 100644 --- a/db.sql +++ b/db.sql @@ -10,6 +10,9 @@ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; +DROP TABLE IF EXISTS `t_system_batch_email_record`; +DROP TABLE IF EXISTS `t_system_batch_email_account`; +DROP TABLE IF EXISTS `t_system_batch_email_template`; DROP TABLE IF EXISTS `t_system_other_config`; DROP TABLE IF EXISTS `t_warehouse_address`; DROP TABLE IF EXISTS `t_notice_read`; @@ -854,6 +857,60 @@ CREATE TABLE `t_system_other_config` ( KEY `idx_system_other_config_enabled` (`Enabled`, `Id`) ) COMMENT='系统其他配置表'; +CREATE TABLE `t_system_batch_email_template` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OwnerUserId` INT NOT NULL DEFAULT 0 COMMENT '所属 RBAC 用户 Id', + `TemplateName` VARCHAR(128) NOT NULL COMMENT '模板名称', + `Subject` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '邮件主题', + `Content` TEXT NOT NULL COMMENT '邮件正文', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_batch_email_tpl_owner_name` (`OwnerUserId`, `TemplateName`), + KEY `idx_batch_email_tpl_owner` (`OwnerUserId`, `SortOrder`, `Id`) +) COMMENT='批量发送邮件模板'; + +CREATE TABLE `t_system_batch_email_account` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OwnerUserId` INT NOT NULL DEFAULT 0 COMMENT '所属 RBAC 用户 Id', + `SmtpUsername` VARCHAR(256) NOT NULL COMMENT 'SMTP 账号', + `SmtpPassword` VARCHAR(512) NOT NULL COMMENT 'SMTP 密码/授权码', + `ServerType` VARCHAR(32) NOT NULL DEFAULT 'qiye' COMMENT 'SMTP 类型', + `FromName` VARCHAR(128) NULL COMMENT '发件人显示名', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_batch_email_account_owner` (`OwnerUserId`) +) COMMENT='批量发送邮件 SMTP 账号配置'; + +CREATE TABLE `t_system_batch_email_record` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OwnerUserId` INT NOT NULL DEFAULT 0 COMMENT '所属 RBAC 用户 Id', + `ToEmail` VARCHAR(256) NOT NULL COMMENT '收件邮箱', + `Subject` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '邮件主题', + `Content` TEXT NOT NULL COMMENT '邮件正文', + `SendTime` DATETIME NOT NULL COMMENT '发送时间', + `Sender` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '发送人', + `SenderEmail` VARCHAR(256) NULL COMMENT '发件邮箱', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_batch_email_record_owner_time` (`OwnerUserId`, `SendTime`, `Id`) +) COMMENT='批量发送邮件记录'; + CREATE TABLE `t_notice` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message', diff --git a/sql/migrate_t_system_batch_email.sql b/sql/migrate_t_system_batch_email.sql new file mode 100644 index 0000000..905272f --- /dev/null +++ b/sql/migrate_t_system_batch_email.sql @@ -0,0 +1,54 @@ +-- 批量发送邮件:模板与 SMTP 账号配置 +CREATE TABLE IF NOT EXISTS `t_system_batch_email_template` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OwnerUserId` INT NOT NULL DEFAULT 0 COMMENT '所属 RBAC 用户 Id', + `TemplateName` VARCHAR(128) NOT NULL COMMENT '模板名称', + `Subject` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '邮件主题', + `Content` TEXT NOT NULL COMMENT '邮件正文', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_batch_email_tpl_owner_name` (`OwnerUserId`, `TemplateName`), + KEY `idx_batch_email_tpl_owner` (`OwnerUserId`, `SortOrder`, `Id`) +) COMMENT='批量发送邮件模板'; + +CREATE TABLE IF NOT EXISTS `t_system_batch_email_account` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OwnerUserId` INT NOT NULL DEFAULT 0 COMMENT '所属 RBAC 用户 Id', + `SmtpUsername` VARCHAR(256) NOT NULL COMMENT 'SMTP 账号', + `SmtpPassword` VARCHAR(512) NOT NULL COMMENT 'SMTP 密码/授权码', + `ServerType` VARCHAR(32) NOT NULL DEFAULT 'qiye' COMMENT 'SMTP 类型', + `FromName` VARCHAR(128) NULL COMMENT '发件人显示名', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_batch_email_account_owner` (`OwnerUserId`) +) COMMENT='批量发送邮件 SMTP 账号配置'; + +CREATE TABLE IF NOT EXISTS `t_system_batch_email_record` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OwnerUserId` INT NOT NULL DEFAULT 0 COMMENT '所属 RBAC 用户 Id', + `ToEmail` VARCHAR(256) NOT NULL COMMENT '收件邮箱', + `Subject` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '邮件主题', + `Content` TEXT NOT NULL COMMENT '邮件正文', + `SendTime` DATETIME NOT NULL COMMENT '发送时间', + `Sender` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '发送人', + `SenderEmail` VARCHAR(256) NULL COMMENT '发件邮箱', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_batch_email_record_owner_time` (`OwnerUserId`, `SendTime`, `Id`) +) COMMENT='批量发送邮件记录'; diff --git a/src/Oms.Application/Dtos/System/BatchEmailAccountOutput.cs b/src/Oms.Application/Dtos/System/BatchEmailAccountOutput.cs new file mode 100644 index 0000000..8a4c262 --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailAccountOutput.cs @@ -0,0 +1,12 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailAccountOutput +{ + public string SmtpUsername { get; set; } = string.Empty; + + public string SmtpPassword { get; set; } = string.Empty; + + public string ServerType { get; set; } = "qiye"; + + public string? FromName { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailAccountSaveInput.cs b/src/Oms.Application/Dtos/System/BatchEmailAccountSaveInput.cs new file mode 100644 index 0000000..affdb04 --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailAccountSaveInput.cs @@ -0,0 +1,12 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailAccountSaveInput +{ + public string SmtpUsername { get; set; } = string.Empty; + + public string SmtpPassword { get; set; } = string.Empty; + + public string ServerType { get; set; } = "qiye"; + + public string? FromName { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailRecordListInput.cs b/src/Oms.Application/Dtos/System/BatchEmailRecordListInput.cs new file mode 100644 index 0000000..efb72de --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailRecordListInput.cs @@ -0,0 +1,8 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailRecordListInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 30; +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailRecordOutput.cs b/src/Oms.Application/Dtos/System/BatchEmailRecordOutput.cs new file mode 100644 index 0000000..8092384 --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailRecordOutput.cs @@ -0,0 +1,16 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailRecordOutput +{ + public int Id { get; set; } + + public string ToEmail { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; + + public DateTime SendTime { get; set; } + + public string Sender { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailSendOneInput.cs b/src/Oms.Application/Dtos/System/BatchEmailSendOneInput.cs new file mode 100644 index 0000000..d2a7279 --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailSendOneInput.cs @@ -0,0 +1,20 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailSendOneInput +{ + public string SmtpUsername { get; set; } = string.Empty; + + public string SmtpPassword { get; set; } = string.Empty; + + public string? ServerType { get; set; } + + public string To { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Body { get; set; } = string.Empty; + + public string? BodyType { get; set; } + + public string? FromName { get; set; } +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailTemplateDeleteInput.cs b/src/Oms.Application/Dtos/System/BatchEmailTemplateDeleteInput.cs new file mode 100644 index 0000000..67aa4fd --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailTemplateDeleteInput.cs @@ -0,0 +1,6 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailTemplateDeleteInput +{ + public string Name { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailTemplateOutput.cs b/src/Oms.Application/Dtos/System/BatchEmailTemplateOutput.cs new file mode 100644 index 0000000..08f291f --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailTemplateOutput.cs @@ -0,0 +1,10 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailTemplateOutput +{ + public string Name { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailTemplateSaveInput.cs b/src/Oms.Application/Dtos/System/BatchEmailTemplateSaveInput.cs new file mode 100644 index 0000000..ce2d2db --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailTemplateSaveInput.cs @@ -0,0 +1,10 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailTemplateSaveInput +{ + public string Name { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/BatchEmailTestConnectionInput.cs b/src/Oms.Application/Dtos/System/BatchEmailTestConnectionInput.cs new file mode 100644 index 0000000..12e9b63 --- /dev/null +++ b/src/Oms.Application/Dtos/System/BatchEmailTestConnectionInput.cs @@ -0,0 +1,12 @@ +namespace Oms.Application.Dtos.System; + +public class BatchEmailTestConnectionInput +{ + public string SmtpUsername { get; set; } = string.Empty; + + public string SmtpPassword { get; set; } = string.Empty; + + public string? ServerType { get; set; } + + public string? FromName { get; set; } +} diff --git a/src/Oms.Application/Services/System/BatchEmailSmtpHelper.cs b/src/Oms.Application/Services/System/BatchEmailSmtpHelper.cs new file mode 100644 index 0000000..5be7ca5 --- /dev/null +++ b/src/Oms.Application/Services/System/BatchEmailSmtpHelper.cs @@ -0,0 +1,122 @@ +using System.Net; +using System.Net.Mail; +using Fake.Application; + +namespace Oms.Application.Services.System; + +internal static class BatchEmailSmtpHelper +{ + private const string SecretMask = "******"; + + internal static bool IsSecretMask(string? value) => + string.Equals(value?.Trim(), SecretMask, StringComparison.Ordinal); + + internal sealed record SmtpEndpoint(string Host, int Port, bool EnableSsl); + + internal static SmtpEndpoint ResolveEndpoint(string? serverType) + { + return (serverType ?? string.Empty).Trim().ToLowerInvariant() switch + { + "qiye_ssl" => new SmtpEndpoint("smtp.mxhichina.com", 465, true), + "aliyun" => new SmtpEndpoint("smtp.aliyun.com", 25, false), + "aliyun_ssl" => new SmtpEndpoint("smtp.aliyun.com", 465, true), + "mxhichina" => new SmtpEndpoint("smtp.mxhichina.com", 25, false), + _ => new SmtpEndpoint("smtp.mxhichina.com", 25, false) + }; + } + + internal static async Task TestConnectionAsync( + string smtpUsername, + string smtpPassword, + string? serverType, + CancellationToken cancellationToken = default) + { + ValidateCredentials(smtpUsername, smtpPassword); + var username = smtpUsername.Trim(); + var endpoint = ResolveEndpoint(serverType); + using var client = CreateClient(endpoint, username, smtpPassword); + using var message = new MailMessage(username, username) + { + Subject = "OMS SMTP Test", + Body = "OMS batch email connection test" + }; + await SendWithClientAsync(client, message, cancellationToken); + } + + internal static async Task SendAsync( + string smtpUsername, + string smtpPassword, + string? serverType, + string to, + string subject, + string body, + string? bodyType, + string? fromName, + CancellationToken cancellationToken = default) + { + ValidateCredentials(smtpUsername, smtpPassword); + var recipient = to.Trim(); + if (string.IsNullOrWhiteSpace(recipient) || !recipient.Contains('@')) + { + throw new BusinessException("收件人邮箱无效"); + } + + var username = smtpUsername.Trim(); + var endpoint = ResolveEndpoint(serverType); + using var client = CreateClient(endpoint, username, smtpPassword); + var fromAddress = new MailAddress( + username, + string.IsNullOrWhiteSpace(fromName) ? username : fromName.Trim()); + using var message = new MailMessage + { + From = fromAddress, + Subject = subject ?? string.Empty, + Body = body ?? string.Empty, + IsBodyHtml = string.Equals(bodyType, "html", StringComparison.OrdinalIgnoreCase) + }; + message.To.Add(recipient); + await SendWithClientAsync(client, message, cancellationToken); + } + + private static void ValidateCredentials(string smtpUsername, string smtpPassword) + { + if (string.IsNullOrWhiteSpace(smtpUsername)) + { + throw new BusinessException("SMTP 账号不能为空"); + } + + if (string.IsNullOrWhiteSpace(smtpPassword) || IsSecretMask(smtpPassword)) + { + throw new BusinessException("SMTP 密码不能为空"); + } + } + + private static SmtpClient CreateClient(SmtpEndpoint endpoint, string username, string password) => + new(endpoint.Host, endpoint.Port) + { + EnableSsl = endpoint.EnableSsl, + DeliveryMethod = SmtpDeliveryMethod.Network, + UseDefaultCredentials = false, + Credentials = new NetworkCredential(username, password), + Timeout = 30_000 + }; + + private static async Task SendWithClientAsync( + SmtpClient client, + MailMessage message, + CancellationToken cancellationToken) + { + try + { + await client.SendMailAsync(message, cancellationToken); + } + catch (SmtpException ex) + { + throw new BusinessException($"SMTP 发送失败:{ex.Message}"); + } + catch (Exception ex) when (ex is InvalidOperationException or FormatException) + { + throw new BusinessException($"SMTP 发送失败:{ex.Message}"); + } + } +} diff --git a/src/Oms.Application/Services/System/SystemBatchEmailService.cs b/src/Oms.Application/Services/System/SystemBatchEmailService.cs new file mode 100644 index 0000000..f7eac04 --- /dev/null +++ b/src/Oms.Application/Services/System/SystemBatchEmailService.cs @@ -0,0 +1,360 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.System; +using Oms.Application.Shared; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; + +namespace Oms.Application.Services.System; + +/// +/// 批量发送邮件:模板、邮箱配置、SMTP 测试与单封发送。 +/// +[ApiExplorerSettings(GroupName = "OMS-System")] +public class SystemBatchEmailService : ApplicationService +{ + private const string SecretMask = "******"; + private const string DefaultTemplateName = "默认模板"; + + private ISystemBatchEmailTemplateRepository TemplateRepository => + LazyServiceProvider.GetRequiredService(); + + private ISystemBatchEmailAccountRepository AccountRepository => + LazyServiceProvider.GetRequiredService(); + + private ISystemBatchEmailRecordRepository RecordRepository => + LazyServiceProvider.GetRequiredService(); + + /// + /// 查询当前用户邮件发送记录(分页)。 + /// + [HttpGet("/system/batch-email/records/list")] + public async Task> GetRecordListAsync([FromQuery] BatchEmailRecordListInput input) + { + var ownerUserId = GetCurrentUserId(); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var (items, total) = await RecordRepository.GetPagedByOwnerAsync(ownerUserId, pageIndex, pageSize); + return new OmsPageResult + { + Items = items.Select(MapRecord).ToList(), + Total = total + }; + } + [HttpGet("/system/batch-email/templates/list")] + public async Task> GetTemplateListAsync() + { + var ownerUserId = GetCurrentUserId(); + var list = await TemplateRepository.GetListByOwnerAsync(ownerUserId); + if (list.Count == 0) + { + await EnsureDefaultTemplateAsync(ownerUserId); + list = await TemplateRepository.GetListByOwnerAsync(ownerUserId); + } + + return list.Select(MapTemplate).ToList(); + } + + /// + /// 保存邮件模板(按名称 upsert)。 + /// + [HttpPost("/system/batch-email/templates/save")] + public async Task SaveTemplateAsync(BatchEmailTemplateSaveInput input) + { + var name = input.Name?.Trim() ?? string.Empty; + var content = input.Content?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) + { + throw new BusinessException("模板名称不能为空"); + } + + if (string.IsNullOrWhiteSpace(content)) + { + throw new BusinessException("模板内容不能为空"); + } + + var ownerUserId = GetCurrentUserId(); + var subject = string.IsNullOrWhiteSpace(input.Subject) ? name : input.Subject.Trim(); + var existing = await TemplateRepository.GetByOwnerAndNameAsync(ownerUserId, name); + if (existing == null) + { + var count = (await TemplateRepository.GetListByOwnerAsync(ownerUserId)).Count; + await TemplateRepository.InsertAsync(new SystemBatchEmailTemplate + { + OwnerUserId = ownerUserId, + TemplateName = name, + Subject = subject, + Content = input.Content ?? string.Empty, + SortOrder = count + }); + return; + } + + existing.Subject = subject; + existing.Content = input.Content ?? string.Empty; + await TemplateRepository.UpdateAsync(existing); + } + + /// + /// 删除邮件模板。 + /// + [HttpPost("/system/batch-email/templates/delete")] + public async Task DeleteTemplateAsync(BatchEmailTemplateDeleteInput input) + { + var name = input.Name?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) + { + throw new BusinessException("模板名称不能为空"); + } + + var ownerUserId = GetCurrentUserId(); + var entity = await TemplateRepository.GetByOwnerAndNameAsync(ownerUserId, name); + if (entity == null) + { + throw new BusinessException("模板不存在"); + } + + await TemplateRepository.DeleteAsync(entity); + var remain = await TemplateRepository.GetListByOwnerAsync(ownerUserId); + if (remain.Count == 0) + { + await EnsureDefaultTemplateAsync(ownerUserId); + } + } + + /// + /// 查询当前用户邮箱配置。 + /// + [HttpGet("/system/batch-email/account")] + public async Task GetAccountAsync() + { + var ownerUserId = GetCurrentUserId(); + var entity = await AccountRepository.GetByOwnerAsync(ownerUserId); + return entity == null ? null : MapAccount(entity, maskSecret: true); + } + + /// + /// 保存当前用户邮箱配置。 + /// + [HttpPost("/system/batch-email/account/save")] + public async Task SaveAccountAsync(BatchEmailAccountSaveInput input) + { + var username = input.SmtpUsername?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(username)) + { + throw new BusinessException("SMTP 账号不能为空"); + } + + var password = input.SmtpPassword ?? string.Empty; + var ownerUserId = GetCurrentUserId(); + var entity = await AccountRepository.GetByOwnerAsync(ownerUserId); + if (string.IsNullOrWhiteSpace(password) || (BatchEmailSmtpHelper.IsSecretMask(password) && entity == null)) + { + throw new BusinessException("SMTP 密码不能为空"); + } + + var serverType = NormalizeServerType(input.ServerType); + if (entity == null) + { + await AccountRepository.InsertAsync(new SystemBatchEmailAccount + { + OwnerUserId = ownerUserId, + SmtpUsername = username, + SmtpPassword = password, + ServerType = serverType, + FromName = NormalizeOptional(input.FromName) + }); + return; + } + + entity.SmtpUsername = username; + if (!BatchEmailSmtpHelper.IsSecretMask(password)) + { + entity.SmtpPassword = password; + } + + entity.ServerType = serverType; + entity.FromName = NormalizeOptional(input.FromName); + await AccountRepository.UpdateAsync(entity); + } + + /// + /// 测试 SMTP 连接(向发件账号自身发送测试邮件)。 + /// + [HttpPost("/system/batch-email/test-connection")] + public async Task TestConnectionAsync(BatchEmailTestConnectionInput input) + { + var password = await ResolvePasswordForSendAsync(input.SmtpPassword); + await BatchEmailSmtpHelper.TestConnectionAsync( + input.SmtpUsername, + password, + input.ServerType); + } + + /// + /// 发送单封邮件(批量发送时由前端逐条调用)。 + /// + [HttpPost("/system/batch-email/send-one")] + public async Task SendOneAsync(BatchEmailSendOneInput input) + { + var password = await ResolvePasswordForSendAsync(input.SmtpPassword); + await BatchEmailSmtpHelper.SendAsync( + input.SmtpUsername, + password, + input.ServerType, + input.To, + input.Subject, + input.Body, + input.BodyType, + input.FromName); + + var ownerUserId = GetCurrentUserId(); + var sender = ResolveSenderDisplay(input); + await RecordRepository.InsertAsync(new SystemBatchEmailRecord + { + OwnerUserId = ownerUserId, + ToEmail = input.To.Trim(), + Subject = input.Subject ?? string.Empty, + Content = input.Body ?? string.Empty, + SendTime = DateTime.Now, + Sender = sender, + SenderEmail = input.SmtpUsername.Trim() + }); + } + + private async Task ResolvePasswordForSendAsync(string? passwordInput) + { + if (!string.IsNullOrWhiteSpace(passwordInput) && !BatchEmailSmtpHelper.IsSecretMask(passwordInput)) + { + return passwordInput; + } + + var ownerUserId = GetCurrentUserId(); + var entity = await AccountRepository.GetByOwnerAsync(ownerUserId); + if (entity == null || string.IsNullOrWhiteSpace(entity.SmtpPassword)) + { + throw new BusinessException("SMTP 密码不能为空"); + } + + return entity.SmtpPassword; + } + + private async Task EnsureDefaultTemplateAsync(int ownerUserId) + { + var existing = await TemplateRepository.GetByOwnerAndNameAsync(ownerUserId, DefaultTemplateName); + if (existing != null) + { + return; + } + + await TemplateRepository.InsertAsync(new SystemBatchEmailTemplate + { + OwnerUserId = ownerUserId, + TemplateName = DefaultTemplateName, + Subject = "订单通知 - {客户单号}", + Content = """ +尊敬的客户:您好! + +感谢您选择阿里巴巴及我们的物流服务,衷心感谢您的信任与支持! + + + +您的订单{客户单号},最新物流轨迹显示:{最新轨迹}。为避免包裹滞留或退回,烦请您及时联系收件人核实以下信息: + + + +1.收件地址是否准确、完整(含邮编、门牌号等); + +2.收件人联系方式是否畅通; + +3.是否需协调二次派送时间。 + + + +后续如有物流疑问,可随时通过平台客服联系我们,我们将全力协助处理! + +顺祝商祺! + + + +Alibaba.com Logistics + +一代国际深圳仓客户服务中心 +""", + SortOrder = 0 + }); + } + + private static BatchEmailTemplateOutput MapTemplate(SystemBatchEmailTemplate entity) => + new() + { + Name = entity.TemplateName, + Subject = entity.Subject, + Content = entity.Content + }; + + private static BatchEmailAccountOutput MapAccount(SystemBatchEmailAccount entity, bool maskSecret) => + new() + { + SmtpUsername = entity.SmtpUsername, + SmtpPassword = maskSecret && !string.IsNullOrWhiteSpace(entity.SmtpPassword) ? SecretMask : entity.SmtpPassword, + ServerType = entity.ServerType, + FromName = entity.FromName + }; + + private static BatchEmailRecordOutput MapRecord(SystemBatchEmailRecord entity) => + new() + { + Id = entity.Id, + ToEmail = entity.ToEmail, + Subject = entity.Subject, + Content = entity.Content, + SendTime = entity.SendTime, + Sender = entity.Sender + }; + + private string ResolveSenderDisplay(BatchEmailSendOneInput input) + { + if (!string.IsNullOrWhiteSpace(input.FromName)) + { + return input.FromName.Trim(); + } + + if (!string.IsNullOrWhiteSpace(CurrentUser.UserName)) + { + return CurrentUser.UserName.Trim(); + } + + return input.SmtpUsername.Trim(); + } + + private static string NormalizeServerType(string? serverType) + { + var value = (serverType ?? "qiye").Trim().ToLowerInvariant(); + return value switch + { + "qiye" or "qiye_ssl" or "aliyun" or "aliyun_ssl" or "mxhichina" => value, + _ => "qiye" + }; + } + + private static string? NormalizeOptional(string? value) + { + var text = value?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private int GetCurrentUserId() + { + try + { + var id = CurrentUser.GetId(); + return id > 0 ? id : 0; + } + catch + { + return 0; + } + } +} diff --git a/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailAccount.cs b/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailAccount.cs new file mode 100644 index 0000000..e120e12 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailAccount.cs @@ -0,0 +1,34 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_system_batch_email_account")] +public class SystemBatchEmailAccount : OmsAuditedAggregateRoot +{ + /// + /// 所属用户(RBAC UserId)。 + /// + public int OwnerUserId { get; set; } + + /// + /// SMTP 登录账号。 + /// + public string SmtpUsername { get; set; } = string.Empty; + + /// + /// SMTP 密码/授权码。 + /// + public string SmtpPassword { get; set; } = string.Empty; + + /// + /// SMTP 类型:qiye / qiye_ssl / aliyun / aliyun_ssl / mxhichina。 + /// + public string ServerType { get; set; } = "qiye"; + + /// + /// 发件人显示名称。 + /// + [SugarColumn(IsNullable = true)] + public string? FromName { get; set; } +} diff --git a/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailRecord.cs b/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailRecord.cs new file mode 100644 index 0000000..bb7ea0c --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailRecord.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_system_batch_email_record")] +public class SystemBatchEmailRecord : OmsAuditedAggregateRoot +{ + public int OwnerUserId { get; set; } + + public string ToEmail { get; set; } = string.Empty; + + public string Subject { get; set; } = string.Empty; + + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string Content { get; set; } = string.Empty; + + public DateTime SendTime { get; set; } + + /// + /// 发送人展示名。 + /// + public string Sender { get; set; } = string.Empty; + + /// + /// 发件邮箱账号。 + /// + [SugarColumn(IsNullable = true)] + public string? SenderEmail { get; set; } +} diff --git a/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailTemplate.cs b/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailTemplate.cs new file mode 100644 index 0000000..24af092 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Entities/SystemBatchEmailTemplate.cs @@ -0,0 +1,34 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.SystemAggregate.Entities; + +[SugarTable("t_system_batch_email_template")] +public class SystemBatchEmailTemplate : OmsAuditedAggregateRoot +{ + /// + /// 所属用户(RBAC UserId)。 + /// + public int OwnerUserId { get; set; } + + /// + /// 模板名称。 + /// + public string TemplateName { get; set; } = string.Empty; + + /// + /// 邮件主题(支持占位符)。 + /// + public string Subject { get; set; } = string.Empty; + + /// + /// 邮件正文(支持占位符)。 + /// + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string Content { get; set; } = string.Empty; + + /// + /// 排序(越小越靠前)。 + /// + public int SortOrder { get; set; } +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailAccountRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailAccountRepository.cs new file mode 100644 index 0000000..8858470 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailAccountRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface ISystemBatchEmailAccountRepository : IRepository +{ + Task GetByOwnerAsync(int ownerUserId, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailRecordRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailRecordRepository.cs new file mode 100644 index 0000000..95eb3d2 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailRecordRepository.cs @@ -0,0 +1,13 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface ISystemBatchEmailRecordRepository : IRepository +{ + Task<(List Items, long Total)> GetPagedByOwnerAsync( + int ownerUserId, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailTemplateRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailTemplateRepository.cs new file mode 100644 index 0000000..8251126 --- /dev/null +++ b/src/Oms.Domain/SystemAggregate/Repositories/ISystemBatchEmailTemplateRepository.cs @@ -0,0 +1,14 @@ +using Fake.Domain.Repositories; +using Oms.Domain.SystemAggregate.Entities; + +namespace Oms.Domain.SystemAggregate.Repositories; + +public interface ISystemBatchEmailTemplateRepository : IRepository +{ + Task> GetListByOwnerAsync(int ownerUserId, CancellationToken cancellationToken = default); + + Task GetByOwnerAndNameAsync( + int ownerUserId, + string templateName, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/System/SystemBatchEmailAccountRepository.cs b/src/Oms.SqlSugarCore/System/SystemBatchEmailAccountRepository.cs new file mode 100644 index 0000000..607748c --- /dev/null +++ b/src/Oms.SqlSugarCore/System/SystemBatchEmailAccountRepository.cs @@ -0,0 +1,17 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class SystemBatchEmailAccountRepository + : OmsSqlSugarRepository, + ISystemBatchEmailAccountRepository, + IScopedDependency +{ + public Task GetByOwnerAsync( + int ownerUserId, + CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); +} diff --git a/src/Oms.SqlSugarCore/System/SystemBatchEmailRecordRepository.cs b/src/Oms.SqlSugarCore/System/SystemBatchEmailRecordRepository.cs new file mode 100644 index 0000000..be225d5 --- /dev/null +++ b/src/Oms.SqlSugarCore/System/SystemBatchEmailRecordRepository.cs @@ -0,0 +1,32 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class SystemBatchEmailRecordRepository + : OmsSqlSugarRepository, + ISystemBatchEmailRecordRepository, + IScopedDependency +{ + public async Task<(List Items, long Total)> GetPagedByOwnerAsync( + int ownerUserId, + int pageIndex, + int pageSize, + CancellationToken cancellationToken = default) + { + var total = await CountAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + var items = await GetPagedListAsync( + x => x.OwnerUserId == ownerUserId, + pageIndex, + pageSize, + new Dictionary + { + [nameof(SystemBatchEmailRecord.SendTime)] = false, + [nameof(SystemBatchEmailRecord.Id)] = false + }, + cancellationToken); + return (items, total); + } +} diff --git a/src/Oms.SqlSugarCore/System/SystemBatchEmailTemplateRepository.cs b/src/Oms.SqlSugarCore/System/SystemBatchEmailTemplateRepository.cs new file mode 100644 index 0000000..6665488 --- /dev/null +++ b/src/Oms.SqlSugarCore/System/SystemBatchEmailTemplateRepository.cs @@ -0,0 +1,31 @@ +using Fake.DependencyInjection; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.System; + +public class SystemBatchEmailTemplateRepository + : OmsSqlSugarRepository, + ISystemBatchEmailTemplateRepository, + IScopedDependency +{ + public async Task> GetListByOwnerAsync( + int ownerUserId, + CancellationToken cancellationToken = default) + { + var list = await GetListAsync(x => x.OwnerUserId == ownerUserId, cancellationToken: cancellationToken); + return list + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToList(); + } + + public Task GetByOwnerAndNameAsync( + int ownerUserId, + string templateName, + CancellationToken cancellationToken = default) => + FirstOrDefaultAsync( + x => x.OwnerUserId == ownerUserId && x.TemplateName == templateName, + cancellationToken); +}