feat: 批量发送邮件 API(模板、SMTP 配置、发送记录)

新增模板/账号持久化、SMTP 测试与单封发送接口,发送成功后写入邮件记录表供前端列表展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-06-29 19:56:35 +08:00
parent 1c0dce9956
commit e4894f9627
22 changed files with 913 additions and 0 deletions

57
db.sql
View File

@ -10,6 +10,9 @@
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; 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_system_other_config`;
DROP TABLE IF EXISTS `t_warehouse_address`; DROP TABLE IF EXISTS `t_warehouse_address`;
DROP TABLE IF EXISTS `t_notice_read`; 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`) KEY `idx_system_other_config_enabled` (`Enabled`, `Id`)
) COMMENT='系统其他配置表'; ) 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` ( CREATE TABLE `t_notice` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message', `NoticeType` VARCHAR(16) NOT NULL COMMENT '通知类型:notice/message',

View File

@ -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='批量发送邮件记录';

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,8 @@
namespace Oms.Application.Dtos.System;
public class BatchEmailRecordListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 30;
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -0,0 +1,6 @@
namespace Oms.Application.Dtos.System;
public class BatchEmailTemplateDeleteInput
{
public string Name { get; set; } = string.Empty;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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}");
}
}
}

View File

@ -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;
/// <summary>
/// 批量发送邮件:模板、邮箱配置、SMTP 测试与单封发送。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-System")]
public class SystemBatchEmailService : ApplicationService
{
private const string SecretMask = "******";
private const string DefaultTemplateName = "默认模板";
private ISystemBatchEmailTemplateRepository TemplateRepository =>
LazyServiceProvider.GetRequiredService<ISystemBatchEmailTemplateRepository>();
private ISystemBatchEmailAccountRepository AccountRepository =>
LazyServiceProvider.GetRequiredService<ISystemBatchEmailAccountRepository>();
private ISystemBatchEmailRecordRepository RecordRepository =>
LazyServiceProvider.GetRequiredService<ISystemBatchEmailRecordRepository>();
/// <summary>
/// 查询当前用户邮件发送记录(分页)。
/// </summary>
[HttpGet("/system/batch-email/records/list")]
public async Task<OmsPageResult<BatchEmailRecordOutput>> 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<BatchEmailRecordOutput>
{
Items = items.Select(MapRecord).ToList(),
Total = total
};
}
[HttpGet("/system/batch-email/templates/list")]
public async Task<List<BatchEmailTemplateOutput>> 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();
}
/// <summary>
/// 保存邮件模板(按名称 upsert)。
/// </summary>
[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);
}
/// <summary>
/// 删除邮件模板。
/// </summary>
[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);
}
}
/// <summary>
/// 查询当前用户邮箱配置。
/// </summary>
[HttpGet("/system/batch-email/account")]
public async Task<BatchEmailAccountOutput?> GetAccountAsync()
{
var ownerUserId = GetCurrentUserId();
var entity = await AccountRepository.GetByOwnerAsync(ownerUserId);
return entity == null ? null : MapAccount(entity, maskSecret: true);
}
/// <summary>
/// 保存当前用户邮箱配置。
/// </summary>
[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);
}
/// <summary>
/// 测试 SMTP 连接(向发件账号自身发送测试邮件)。
/// </summary>
[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);
}
/// <summary>
/// 发送单封邮件(批量发送时由前端逐条调用)。
/// </summary>
[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<string> 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<int>();
return id > 0 ? id : 0;
}
catch
{
return 0;
}
}
}

View File

@ -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
{
/// <summary>
/// 所属用户(RBAC UserId)。
/// </summary>
public int OwnerUserId { get; set; }
/// <summary>
/// SMTP 登录账号。
/// </summary>
public string SmtpUsername { get; set; } = string.Empty;
/// <summary>
/// SMTP 密码/授权码。
/// </summary>
public string SmtpPassword { get; set; } = string.Empty;
/// <summary>
/// SMTP 类型:qiye / qiye_ssl / aliyun / aliyun_ssl / mxhichina。
/// </summary>
public string ServerType { get; set; } = "qiye";
/// <summary>
/// 发件人显示名称。
/// </summary>
[SugarColumn(IsNullable = true)]
public string? FromName { get; set; }
}

View File

@ -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; }
/// <summary>
/// 发送人展示名。
/// </summary>
public string Sender { get; set; } = string.Empty;
/// <summary>
/// 发件邮箱账号。
/// </summary>
[SugarColumn(IsNullable = true)]
public string? SenderEmail { get; set; }
}

View File

@ -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
{
/// <summary>
/// 所属用户(RBAC UserId)。
/// </summary>
public int OwnerUserId { get; set; }
/// <summary>
/// 模板名称。
/// </summary>
public string TemplateName { get; set; } = string.Empty;
/// <summary>
/// 邮件主题(支持占位符)。
/// </summary>
public string Subject { get; set; } = string.Empty;
/// <summary>
/// 邮件正文(支持占位符)。
/// </summary>
[SugarColumn(ColumnDataType = "text", IsNullable = true)]
public string Content { get; set; } = string.Empty;
/// <summary>
/// 排序(越小越靠前)。
/// </summary>
public int SortOrder { get; set; }
}

View File

@ -0,0 +1,9 @@
using Fake.Domain.Repositories;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Domain.SystemAggregate.Repositories;
public interface ISystemBatchEmailAccountRepository : IRepository<SystemBatchEmailAccount>
{
Task<SystemBatchEmailAccount?> GetByOwnerAsync(int ownerUserId, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,13 @@
using Fake.Domain.Repositories;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Domain.SystemAggregate.Repositories;
public interface ISystemBatchEmailRecordRepository : IRepository<SystemBatchEmailRecord>
{
Task<(List<SystemBatchEmailRecord> Items, long Total)> GetPagedByOwnerAsync(
int ownerUserId,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,14 @@
using Fake.Domain.Repositories;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Domain.SystemAggregate.Repositories;
public interface ISystemBatchEmailTemplateRepository : IRepository<SystemBatchEmailTemplate>
{
Task<List<SystemBatchEmailTemplate>> GetListByOwnerAsync(int ownerUserId, CancellationToken cancellationToken = default);
Task<SystemBatchEmailTemplate?> GetByOwnerAndNameAsync(
int ownerUserId,
string templateName,
CancellationToken cancellationToken = default);
}

View File

@ -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<SystemBatchEmailAccount>,
ISystemBatchEmailAccountRepository,
IScopedDependency
{
public Task<SystemBatchEmailAccount?> GetByOwnerAsync(
int ownerUserId,
CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
}

View File

@ -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<SystemBatchEmailRecord>,
ISystemBatchEmailRecordRepository,
IScopedDependency
{
public async Task<(List<SystemBatchEmailRecord> 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<string, bool>
{
[nameof(SystemBatchEmailRecord.SendTime)] = false,
[nameof(SystemBatchEmailRecord.Id)] = false
},
cancellationToken);
return (items, total);
}
}

View File

@ -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<SystemBatchEmailTemplate>,
ISystemBatchEmailTemplateRepository,
IScopedDependency
{
public async Task<List<SystemBatchEmailTemplate>> 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<SystemBatchEmailTemplate?> GetByOwnerAndNameAsync(
int ownerUserId,
string templateName,
CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(
x => x.OwnerUserId == ownerUserId && x.TemplateName == templateName,
cancellationToken);
}