feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+167
View File
@@ -0,0 +1,167 @@
using dy.net.model.dto;
using dy.net.model.entity;
using MailKit.Security;
using Microsoft.AspNetCore.DataProtection;
using MimeKit;
using SqlSugar;
namespace dy.net.service
{
public sealed class EmailNotificationSettingsService
{
private const string SettingsId = "default";
private readonly ISqlSugarClient _db;
private readonly IDataProtector _protector;
private readonly IEmailNotificationSender _sender;
public EmailNotificationSettingsService(
ISqlSugarClient db,
IDataProtectionProvider provider,
IEmailNotificationSender sender)
{
_db = db;
_protector = provider.CreateProtector("dysync.email.password.v1");
_sender = sender;
}
public async Task<EmailNotificationSettings> GetAsync() =>
await _db.Queryable<EmailNotificationSettings>().InSingleAsync(SettingsId)
?? new EmailNotificationSettings();
public async Task<string> GetPasswordAsync(EmailNotificationSettings settings = null)
{
settings ??= await GetAsync();
if (string.IsNullOrWhiteSpace(settings.ProtectedPassword)) return string.Empty;
try { return _protector.Unprotect(settings.ProtectedPassword); }
catch (Exception ex)
{
Serilog.Log.Error(ex, "SMTP 密码解密失败,请重新输入密码");
return string.Empty;
}
}
public async Task<EmailNotificationSettings> BuildCandidateAsync(EmailNotificationSettingsDto dto)
{
dto ??= new EmailNotificationSettingsDto();
var existing = await GetAsync();
var password = string.IsNullOrWhiteSpace(dto.Password)
? await GetPasswordAsync(existing)
: dto.Password;
var candidate = new EmailNotificationSettings
{
Id = SettingsId,
Enabled = dto.Enabled,
Host = dto.Host?.Trim(),
Port = dto.Port <= 0 ? 465 : dto.Port,
SecurityMode = dto.SecurityMode,
UserName = dto.UserName?.Trim(),
ProtectedPassword = string.IsNullOrWhiteSpace(password) ? null : _protector.Protect(password),
FromAddress = dto.FromAddress?.Trim(),
FromName = Limit(dto.FromName, 200),
Recipients = NormalizeRecipients(dto.Recipients),
LastTestedAt = existing.LastTestedAt,
LastTestMessage = existing.LastTestMessage
};
Validate(candidate, password, requireComplete: candidate.Enabled);
return candidate;
}
public async Task<EmailNotificationSettingsDto> SaveAsync(EmailNotificationSettingsDto dto)
{
var candidate = await BuildCandidateAsync(dto);
await _db.Storageable(candidate).ExecuteCommandAsync();
return ToDto(candidate);
}
public async Task<string> TestAsync(EmailNotificationSettingsDto dto, CancellationToken cancellationToken)
{
var candidate = await BuildCandidateAsync(dto);
var password = await GetPasswordAsync(candidate);
Validate(candidate, password, requireComplete: true);
var now = DateTime.Now;
await _sender.SendAsync(
candidate,
password,
"dysync.net 邮箱通知测试成功",
$"<h2>邮箱通知配置可用</h2><p>测试时间:{now:yyyy-MM-dd HH:mm:ss}</p><p>开播通知将只在博主首次进入直播状态时发送。</p>",
cancellationToken);
candidate.LastTestedAt = DateTime.Now;
candidate.LastTestMessage = "测试邮件发送成功";
await _db.Storageable(candidate).ExecuteCommandAsync();
return candidate.LastTestMessage;
}
public async Task<string> GetReadinessErrorAsync()
{
var settings = await GetAsync();
if (!settings.Enabled) return "请先在系统设置中启用邮箱通知";
var password = await GetPasswordAsync(settings);
try
{
Validate(settings, password, requireComplete: true);
return null;
}
catch (InvalidOperationException ex)
{
return "邮箱通知配置不完整:" + ex.Message;
}
}
public EmailNotificationSettingsDto ToDto(EmailNotificationSettings settings) => new()
{
Enabled = settings.Enabled,
Host = settings.Host,
Port = settings.Port,
SecurityMode = settings.SecurityMode,
UserName = settings.UserName,
HasPassword = !string.IsNullOrWhiteSpace(settings.ProtectedPassword),
FromAddress = settings.FromAddress,
FromName = settings.FromName,
Recipients = settings.Recipients,
LastTestedAt = settings.LastTestedAt,
LastTestMessage = settings.LastTestMessage
};
public static SecureSocketOptions ToSocketOptions(EmailSecurityMode mode) => mode switch
{
EmailSecurityMode.None => SecureSocketOptions.None,
EmailSecurityMode.StartTls => SecureSocketOptions.StartTls,
_ => SecureSocketOptions.SslOnConnect
};
private static void Validate(EmailNotificationSettings settings, string password, bool requireComplete)
{
if (settings.Port is < 1 or > 65535) throw new InvalidOperationException("SMTP 端口必须在 165535 之间");
if (!requireComplete) return;
if (string.IsNullOrWhiteSpace(settings.Host)) throw new InvalidOperationException("请填写 SMTP 服务器");
if (string.IsNullOrWhiteSpace(settings.FromAddress) || !MailboxAddress.TryParse(settings.FromAddress, out _))
throw new InvalidOperationException("发件邮箱格式不正确");
if (ParseRecipients(settings.Recipients).Count == 0) throw new InvalidOperationException("请至少填写一个收件邮箱");
if (!string.IsNullOrWhiteSpace(settings.UserName) && string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("请填写 SMTP 密码或授权码");
}
public static List<MailboxAddress> ParseRecipients(string recipients)
{
var result = new List<MailboxAddress>();
foreach (var value in (recipients ?? string.Empty)
.Split(new[] { ',', ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Take(20))
{
if (!MailboxAddress.TryParse(value, out var address))
throw new InvalidOperationException($"收件邮箱格式不正确:{value}");
result.Add(address);
}
return result;
}
private static string NormalizeRecipients(string recipients) =>
string.Join(";", ParseRecipients(recipients).Select(x => x.Address));
private static string Limit(string value, int maxLength)
{
value = value?.Trim();
return value != null && value.Length > maxLength ? value[..maxLength] : value;
}
}
}