|
|
@@ -0,0 +1,481 @@
|
|
|
+using Microsoft.AspNetCore.WebUtilities;
|
|
|
+using Microsoft.Graph;
|
|
|
+using Microsoft.Graph.Models;
|
|
|
+using Microsoft.Graph.Models.ODataErrors;
|
|
|
+using Microsoft.Kiota.Abstractions.Authentication;
|
|
|
+using System.Collections.Concurrent;
|
|
|
+using System.Text.Json;
|
|
|
+using System.Text.Json.Serialization;
|
|
|
+using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
|
+
|
|
|
+namespace OASystem.API.OAMethodLib.Hotmail
|
|
|
+{
|
|
|
+ public class HotmailService
|
|
|
+ {
|
|
|
+ private readonly IHttpClientFactory _httpClientFactory;
|
|
|
+ private readonly IConfiguration _config;
|
|
|
+ private readonly SqlSugarClient _sqlSugar;
|
|
|
+ public const string RedisKeyPrefix = "MailAlchemy:Token:";
|
|
|
+
|
|
|
+ public HotmailService(IHttpClientFactory httpClientFactory, IConfiguration config, SqlSugarClient sqlSugar)
|
|
|
+ {
|
|
|
+ _httpClientFactory = httpClientFactory;
|
|
|
+ _config = config;
|
|
|
+ _sqlSugar = sqlSugar;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 统一获取 Redis Key
|
|
|
+ /// </summary>
|
|
|
+ public static string GetRedisKey(string email) => $"{RedisKeyPrefix}{email.Trim().ToLower()}";
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// hotmail 信息验证
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="config"></param>
|
|
|
+ /// <returns></returns>
|
|
|
+ public (bool, string) ConfigVerify(HotmailConfig? config)
|
|
|
+ {
|
|
|
+ if (config == null) return (true, "当前用户未配置 hotmail 基础信息。");
|
|
|
+ if (string.IsNullOrEmpty(config.UserName)) return (true, "当前用户未配置 hotmail 基础信息。");
|
|
|
+ if (string.IsNullOrEmpty(config.ClientId)) return (true, "当前用户未配置 hotmail 租户标识符 (Guid)。");
|
|
|
+ if (string.IsNullOrEmpty(config.TenantId)) return (true, "当前用户未配置 hotmail 应用程序的客户端标识。");
|
|
|
+ if (string.IsNullOrEmpty(config.ClientSecret)) return (true, "当前用户未配置 hotmail 应用程序密钥。");
|
|
|
+ if (string.IsNullOrEmpty(config.RedirectUri)) return (true, "当前用户未配置 hotmail OAuth2 回调重定向地址。");
|
|
|
+ return (true, "");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Microsoft 鉴权预处理
|
|
|
+ /// </summary>
|
|
|
+ public async Task<(int status, string msg)> PrepareAuth(int userId)
|
|
|
+ {
|
|
|
+ // 1. 基础配置校验 (SqlSugar 优化)
|
|
|
+ var userConfig = await GetUserMailConfig(userId);
|
|
|
+ if (userConfig == null || string.IsNullOrWhiteSpace(userConfig.UserName))
|
|
|
+ return (-1, "账号基础配置缺失");
|
|
|
+
|
|
|
+ // 2. 状态检查 (Redis)
|
|
|
+ var redisKey = GetRedisKey(userConfig.UserName);
|
|
|
+ var repo = RedisRepository.RedisFactory.CreateRedisRepository();
|
|
|
+ var cachedJson = await repo.StringGetAsync<string>(redisKey);
|
|
|
+
|
|
|
+ if (!string.IsNullOrWhiteSpace(cachedJson))
|
|
|
+ return (0, "已通过验证,无需重复操作");
|
|
|
+
|
|
|
+ // 3. 参数净化与严谨性
|
|
|
+ var clientId = userConfig.ClientId?.Trim();
|
|
|
+ var redirectUri = userConfig.RedirectUri?.Trim().Split('\r', '\n')[0]; // 取第一行并修剪
|
|
|
+
|
|
|
+ if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri))
|
|
|
+ return (-1, "ClientId 或 RedirectUri 配置无效");
|
|
|
+
|
|
|
+ // 4. 构建长效授权 URL
|
|
|
+ const string authEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
|
+
|
|
|
+ var queryParams = new Dictionary<string, string?>
|
|
|
+ {
|
|
|
+ { "client_id", clientId },
|
|
|
+ { "response_type", "code" },
|
|
|
+ { "redirect_uri", redirectUri },
|
|
|
+ { "response_mode", "query" },
|
|
|
+ // 核心:必须包含 offline_access 且建议加上 openid
|
|
|
+ { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" },
|
|
|
+ { "state", userId.ToString() }, // 简单场景使用 userId,安全场景建议使用加密 Hash
|
|
|
+ { "prompt", "consent" } // 关键:确保触发长效令牌授权
|
|
|
+ };
|
|
|
+
|
|
|
+ var authUrl = QueryHelpers.AddQueryString(authEndpoint, queryParams);
|
|
|
+
|
|
|
+ // 准则 4a: 直接返回结果
|
|
|
+ return (1, authUrl);
|
|
|
+ }
|
|
|
+
|
|
|
+ public async Task<List<MailDto>> GetMergedMessagesAsync(List<string> emails, DateTime cstStart, DateTime cstEnd)
|
|
|
+ {
|
|
|
+ // 线程安全的合并容器
|
|
|
+ var allMessages = new ConcurrentBag<MailDto>();
|
|
|
+
|
|
|
+ // 转换过滤条件 (建议预先处理)
|
|
|
+ string startFilter = CommonFun.ToGraphUtcString(cstStart);
|
|
|
+ string endFilter = CommonFun.ToGraphUtcString(cstEnd);
|
|
|
+
|
|
|
+ // 配置并发参数:限制最大并行度,防止被 Graph API 熔断
|
|
|
+ var parallelOptions = new ParallelOptions
|
|
|
+ {
|
|
|
+ MaxDegreeOfParallelism = 5 // 根据服务器性能调整
|
|
|
+ };
|
|
|
+
|
|
|
+ await Parallel.ForEachAsync(emails, parallelOptions, async (email, ct) =>
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var client = await GetClientAsync(email);
|
|
|
+ var response = await client.Me.Messages.GetAsync(q =>
|
|
|
+ {
|
|
|
+ q.QueryParameters.Filter = $"receivedDateTime ge {startFilter} and receivedDateTime le {endFilter}";
|
|
|
+ q.QueryParameters.Select = new[] { "id", "subject", "from", "bodyPreview", "receivedDateTime" };
|
|
|
+ q.QueryParameters.Orderby = new[] { "receivedDateTime desc" };
|
|
|
+ q.QueryParameters.Top = 50; // 生产环境建议增加 Top 限制
|
|
|
+ }, ct);
|
|
|
+
|
|
|
+ if (response?.Value != null)
|
|
|
+ {
|
|
|
+ var chinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
|
|
|
+
|
|
|
+ foreach (var m in response.Value)
|
|
|
+ {
|
|
|
+ allMessages.Add(new MailDto
|
|
|
+ {
|
|
|
+ MessageId = m.Id,
|
|
|
+ Subject = m.Subject,
|
|
|
+ Content = m.BodyPreview,
|
|
|
+ From = m.From?.EmailAddress?.Address,
|
|
|
+ To = email,
|
|
|
+ ReceivedTime = m.ReceivedDateTime?.DateTime != null
|
|
|
+ ? TimeZoneInfo.ConvertTimeFromUtc(m.ReceivedDateTime.Value.DateTime, chinaTimeZone)
|
|
|
+ : DateTime.MinValue,
|
|
|
+ Source = email // 显式来源
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ // 生产环境应接入 ILogger
|
|
|
+ //_logger.LogError(ex, "Failed to fetch mail for {Email}", email);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 最终排序并输出
|
|
|
+ return allMessages.OrderByDescending(m => m.ReceivedTime).ToList();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 指定账户发送邮件
|
|
|
+ /// </summary>
|
|
|
+ public async Task<MailSendResult> SendMailAsync(string fromEmail, MailDto mail)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var client = await GetClientAsync(fromEmail);
|
|
|
+
|
|
|
+ var requestBody = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody
|
|
|
+ {
|
|
|
+ Message = new Message
|
|
|
+ {
|
|
|
+ Subject = mail.Subject,
|
|
|
+ Body = new ItemBody
|
|
|
+ {
|
|
|
+ Content = mail.Content,
|
|
|
+ ContentType = BodyType.Html
|
|
|
+ },
|
|
|
+ ToRecipients = new List<Recipient>
|
|
|
+ {
|
|
|
+ new Recipient { EmailAddress = new EmailAddress { Address = mail.To } }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 执行发送
|
|
|
+ await client.Me.SendMail.PostAsync(requestBody);
|
|
|
+
|
|
|
+ return new MailSendResult { IsSuccess = true, Message = "邮件发送成功!" };
|
|
|
+ }
|
|
|
+ catch (ODataError odataError) // 捕获 Graph 特有异常
|
|
|
+ {
|
|
|
+ // 常见的错误:ErrorInvalidUser, ErrorQuotaExceeded, ErrorMessageSubmissionBlocked
|
|
|
+ var code = odataError.Error?.Code ?? "Unknown";
|
|
|
+ var msg = odataError.Error?.Message ?? "微软 API 调用异常";
|
|
|
+
|
|
|
+ return new MailSendResult
|
|
|
+ {
|
|
|
+ IsSuccess = false,
|
|
|
+ ErrorCode = code,
|
|
|
+ Message = $"发送失败: {msg}"
|
|
|
+ };
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ return new MailSendResult
|
|
|
+ {
|
|
|
+ IsSuccess = false,
|
|
|
+ ErrorCode = "InternalError",
|
|
|
+ Message = $"系统内部错误: {ex.Message}"
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 获取邮箱配置信息 - single
|
|
|
+ /// </summary>
|
|
|
+ /// <returns></returns>
|
|
|
+ public async Task<HotmailConfig?> GetUserMailConfig(int userId)
|
|
|
+ {
|
|
|
+ var allConfigs = await GetUserMailConfigListAsync();
|
|
|
+
|
|
|
+ if (allConfigs == null || !allConfigs.Any()) return null;
|
|
|
+
|
|
|
+ var userConfig = allConfigs.FirstOrDefault(x => x.UserId == userId);
|
|
|
+
|
|
|
+ return userConfig;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 获取邮箱配置信息 - ALL
|
|
|
+ /// </summary>
|
|
|
+ /// <returns></returns>
|
|
|
+ public async Task<List<HotmailConfig>?> GetUserMailConfigListAsync()
|
|
|
+ {
|
|
|
+ var remark = await _sqlSugar.Queryable<Sys_SetData>()
|
|
|
+ .Where(x => x.IsDel == 0 && x.Id == 1555 && x.STid == 137)
|
|
|
+ .Select(x => x.Remark)
|
|
|
+ .FirstAsync();
|
|
|
+ if (string.IsNullOrWhiteSpace(remark)) return null;
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var allConfigs = JsonConvert.DeserializeObject<List<HotmailConfig>>(remark);
|
|
|
+ return allConfigs;
|
|
|
+ }
|
|
|
+ catch (Exception)
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 线程锁
|
|
|
+ /// </summary>
|
|
|
+ private static readonly ConcurrentDictionary<string, SemaphoreSlim> _userLocks = new ConcurrentDictionary<string, SemaphoreSlim>();
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 获取 Graph 客户端,处理 Token 自动刷新 (线程安全版)
|
|
|
+ /// </summary>
|
|
|
+ private async Task<GraphServiceClient> GetClientAsync(string email)
|
|
|
+ {
|
|
|
+ // 获取或创建针对该 Email 的独立信号量锁
|
|
|
+ var userLock = _userLocks.GetOrAdd(email, _ => new SemaphoreSlim(1, 1));
|
|
|
+
|
|
|
+ await userLock.WaitAsync();
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var redisKey = GetRedisKey(email);
|
|
|
+ // 建议:每次获取 Repo 实例,避免单例 Repo 内部并发冲突
|
|
|
+ var repo = RedisRepository.RedisFactory.CreateRedisRepository();
|
|
|
+ var cachedJson = await repo.StringGetAsync<string>(redisKey);
|
|
|
+
|
|
|
+ if (string.IsNullOrEmpty(cachedJson))
|
|
|
+ throw new UnauthorizedAccessException($"Account {email} not initialized in Redis.");
|
|
|
+
|
|
|
+ var token = System.Text.Json.JsonSerializer.Deserialize<UserToken>(cachedJson!)!;
|
|
|
+
|
|
|
+ // 令牌过期预校验 (带锁保护,防止并发刷新导致的 Token 失效)
|
|
|
+ if (token.ExpiresAt < DateTime.UtcNow.AddMinutes(5))
|
|
|
+ {
|
|
|
+ // 内部逻辑:调用 Graph 刷新接口 -> 更新 token 对象 -> 写入 Redis
|
|
|
+ token = await RefreshAndSaveTokenAsync(token);
|
|
|
+ // 调试建议:记录刷新日志
|
|
|
+ // _logger.LogInformation("Token refreshed for {Email}", email);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 构造认证提供者 (Scoped 局部化)
|
|
|
+ // 使用 StaticTokenProvider 封装当前的 AccessToken
|
|
|
+ var tokenProvider = new StaticTokenProvider(token.AccessToken);
|
|
|
+ var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
|
|
|
+
|
|
|
+ // 4. 返回全新的客户端实例,确保 RequestAdapter 隔离
|
|
|
+ return new GraphServiceClient(authProvider);
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ // _logger.LogError(ex, "GetClientAsync failed for {Email}", email);
|
|
|
+ throw;
|
|
|
+ }
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ userLock.Release(); // 必须在 finally 中释放锁
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public async Task<UserToken> RefreshAndSaveTokenAsync(UserToken oldToken)
|
|
|
+ {
|
|
|
+ // 1. 实时获取该用户对应的配置信息
|
|
|
+ // 准则:不再信任全局 _config,而是根据 Email 溯源配置
|
|
|
+ var allConfigs = await GetUserMailConfigListAsync();
|
|
|
+ var currentConfig = allConfigs?.FirstOrDefault(x =>
|
|
|
+ x.UserName.Equals(oldToken.Email, StringComparison.OrdinalIgnoreCase));
|
|
|
+
|
|
|
+ if (currentConfig == null)
|
|
|
+ throw new Exception($"刷新失败:未能在配置库中找到账号 {oldToken.Email} 的关联 Client 信息。");
|
|
|
+
|
|
|
+ // 2. 使用该账号专属的凭据构造请求
|
|
|
+ var httpClient = _httpClientFactory.CreateClient();
|
|
|
+ var kvp = new Dictionary<string, string>
|
|
|
+ {
|
|
|
+ { "client_id", currentConfig.ClientId.Trim() },
|
|
|
+ { "client_secret", currentConfig.ClientSecret.Trim() },
|
|
|
+ { "grant_type", "refresh_token" },
|
|
|
+ { "refresh_token", oldToken.RefreshToken },
|
|
|
+ { "scope", "openid offline_access Mail.ReadWrite Mail.Send User.Read" } // 保持 Scope 一致性
|
|
|
+ };
|
|
|
+
|
|
|
+ var response = await httpClient.PostAsync("https://login.microsoftonline.com/common/oauth2/v2.0/token", new FormUrlEncodedContent(kvp));
|
|
|
+
|
|
|
+ if (!response.IsSuccessStatusCode)
|
|
|
+ {
|
|
|
+ var error = await response.Content.ReadAsStringAsync();
|
|
|
+ throw new Exception($"微软刷新接口拒绝请求: {error}");
|
|
|
+ }
|
|
|
+
|
|
|
+ using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
|
|
+ var root = doc.RootElement;
|
|
|
+
|
|
|
+ // 3. 构造新令牌 (注意:每次刷新都会返回新的 RefreshToken,必须覆盖旧的)
|
|
|
+ var newToken = new UserToken
|
|
|
+ {
|
|
|
+ Email = oldToken.Email,
|
|
|
+ AccessToken = root.GetProperty("access_token").GetString()!,
|
|
|
+ // 关键:微软可能会滚动更新 RefreshToken,务必取回最新的
|
|
|
+ RefreshToken = root.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : oldToken.RefreshToken,
|
|
|
+ ExpiresAt = DateTime.UtcNow.AddSeconds(root.GetProperty("expires_in").GetInt32()),
|
|
|
+ Source = "Microsoft_Graph_Refreshed"
|
|
|
+ };
|
|
|
+
|
|
|
+ // 4. 同步更新 Redis (保持 90 天长效)
|
|
|
+ var redisKey = GetRedisKey(oldToken.Email);
|
|
|
+ await RedisRepository.RedisFactory.CreateRedisRepository()
|
|
|
+ .StringSetAsync(redisKey, JsonSerializer.Serialize(newToken), TimeSpan.FromDays(90));
|
|
|
+
|
|
|
+ return newToken;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 静态 Token 提供者辅助类
|
|
|
+ /// </summary>
|
|
|
+ public class StaticTokenProvider : IAccessTokenProvider
|
|
|
+ {
|
|
|
+ private readonly string _token;
|
|
|
+ public StaticTokenProvider(string token) => _token = token;
|
|
|
+ public Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object>? context = null, CancellationToken ct = default) => Task.FromResult(_token);
|
|
|
+ public AllowedHostsValidator AllowedHostsValidator { get; } = new();
|
|
|
+ }
|
|
|
+
|
|
|
+ #region 数据模型
|
|
|
+
|
|
|
+ public class MailSendResult
|
|
|
+ {
|
|
|
+ public bool IsSuccess { get; set; }
|
|
|
+ public string Message { get; set; } = string.Empty;
|
|
|
+ public string? ErrorCode { get; set; } // Microsoft 提供的错误码
|
|
|
+ public string Source => "Microsoft_Graph_API";
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Hotmail 邮件服务 OAuth2 配置信息实体
|
|
|
+ /// </summary>
|
|
|
+ public class HotmailConfig
|
|
|
+ {
|
|
|
+ /// <summary>
|
|
|
+ /// 用户唯一标识
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("userId")]
|
|
|
+ public int UserId { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 账号用户名
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("userName")]
|
|
|
+ public string UserName { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Azure AD 租户标识符 (Guid)
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("tenantId")]
|
|
|
+ public string TenantId { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 注册应用程序的客户端标识
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("clientId")]
|
|
|
+ public string ClientId { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 客户端密钥(敏感数据建议加密存储)
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("clientSecret")]
|
|
|
+ public string ClientSecret { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 租户类型(如 common, organizations 或具体域名)
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("tenant")]
|
|
|
+ public string Tenant { get; set; } = "common";
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// OAuth2 回调重定向地址
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("redirectUri")]
|
|
|
+ public string RedirectUri { get; set; }
|
|
|
+ }
|
|
|
+
|
|
|
+ public class UserToken
|
|
|
+ {
|
|
|
+ public string Email { get; set; }
|
|
|
+ public string AccessToken { get; set; }
|
|
|
+ public string RefreshToken { get; set; }
|
|
|
+ public DateTime ExpiresAt { get; set; }
|
|
|
+
|
|
|
+ public string Source { get; set; }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 邮件请求对象
|
|
|
+ /// </summary>
|
|
|
+ public class MailDto
|
|
|
+ {
|
|
|
+ /// <summary>
|
|
|
+ /// 邮件唯一标识符 (UID/Message-ID)
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("messageId")]
|
|
|
+ public string? MessageId { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 邮件主题
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("subject")]
|
|
|
+ public string? Subject { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 发件人地址 (e.g. "sender@example.com")
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("from")]
|
|
|
+ public string? From { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 收件人地址
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("to")]
|
|
|
+ public string? To { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 邮件正文内容 (HTML 或纯文本)
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("content")]
|
|
|
+ public string? Content { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 接收时间 - 使用 DateTimeOffset 以确保跨时区准确性
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("receivedTime")]
|
|
|
+ public DateTimeOffset? ReceivedTime { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 数据来源标识 (用于区分不同配置源或采集渠道,如 "Hotmail", "Gmail", "Sys_SetData")
|
|
|
+ /// </summary>
|
|
|
+ [JsonPropertyName("source")]
|
|
|
+ public string? Source { get; set; } = "Hotmail";
|
|
|
+ }
|
|
|
+ #endregion
|
|
|
+ }
|
|
|
+}
|