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);
+}