feat: finalize core modules and gateway

This commit is contained in:
2025-11-23 18:53:12 +08:00
parent 429d4fb747
commit ae273e510a
115 changed files with 4695 additions and 223 deletions

View File

@@ -0,0 +1,35 @@
using System.Net.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Module.Sms.Abstractions;
using TakeoutSaaS.Module.Sms.Models;
using TakeoutSaaS.Module.Sms.Options;
namespace TakeoutSaaS.Module.Sms.Services;
/// <summary>
/// 阿里云短信发送实现(简化版,占位可扩展正式签名流程)。
/// </summary>
public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor<SmsOptions> optionsMonitor, ILogger<AliyunSmsSender> logger)
: ISmsSender
{
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
/// <inheritdoc />
public SmsProviderKind Provider => SmsProviderKind.Aliyun;
/// <inheritdoc />
public Task<SmsSendResult> SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (options.UseMock)
{
logger.LogInformation("Mock 发送阿里云短信到 {Phone}, Template:{Template}", request.PhoneNumber, request.TemplateCode);
return Task.FromResult(new SmsSendResult { Success = true, Message = "Mocked" });
}
// 占位:保留待接入阿里云正式签名流程,当前返回未实现。
logger.LogWarning("阿里云短信尚未启用,请配置腾讯云或开启 UseMock。");
return Task.FromResult(new SmsSendResult { Success = false, Message = "Aliyun SMS not enabled" });
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Module.Sms.Abstractions;
using TakeoutSaaS.Module.Sms.Options;
namespace TakeoutSaaS.Module.Sms.Services;
/// <summary>
/// 短信服务商解析器。
/// </summary>
public sealed class SmsSenderResolver(IOptionsMonitor<SmsOptions> optionsMonitor, IEnumerable<ISmsSender> senders) : ISmsSenderResolver
{
private readonly IReadOnlyDictionary<SmsProviderKind, ISmsSender> _map = senders.ToDictionary(x => x.Provider);
/// <inheritdoc />
public ISmsSender Resolve(SmsProviderKind? provider = null)
{
var key = provider ?? optionsMonitor.CurrentValue.Provider;
if (_map.TryGetValue(key, out var sender))
{
return sender;
}
throw new InvalidOperationException($"未注册短信服务商:{key}");
}
}

View File

@@ -0,0 +1,136 @@
using System.Globalization;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Module.Sms.Abstractions;
using TakeoutSaaS.Module.Sms.Models;
using TakeoutSaaS.Module.Sms.Options;
namespace TakeoutSaaS.Module.Sms.Services;
/// <summary>
/// 腾讯云短信发送实现TC3-HMAC 签名)。
/// </summary>
public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor<SmsOptions> optionsMonitor, ILogger<TencentSmsSender> logger)
: ISmsSender
{
private const string Service = "sms";
private const string Action = "SendSms";
private const string Version = "2021-01-11";
/// <inheritdoc />
public SmsProviderKind Provider => SmsProviderKind.Tencent;
/// <inheritdoc />
public async Task<SmsSendResult> SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (options.UseMock)
{
logger.LogInformation("Mock 发送短信到 {Phone}, Template:{Template}, Vars:{Vars}", request.PhoneNumber, request.TemplateCode, JsonSerializer.Serialize(request.Variables));
return new SmsSendResult { Success = true, Message = "Mocked" };
}
var tencent = options.Tencent;
var payload = BuildPayload(request, tencent);
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var date = DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var host = new Uri(tencent.Endpoint).Host;
var canonicalRequest = BuildCanonicalRequest(payload, host, tencent.Endpoint.StartsWith("https", StringComparison.OrdinalIgnoreCase));
var stringToSign = BuildStringToSign(canonicalRequest, timestamp, date);
var signature = Sign(stringToSign, tencent.SecretKey, date);
using var httpClient = httpClientFactory.CreateClient(nameof(TencentSmsSender));
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tencent.Endpoint)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("Host", host);
httpRequest.Headers.Add("X-TC-Action", Action);
httpRequest.Headers.Add("X-TC-Version", Version);
httpRequest.Headers.Add("X-TC-Timestamp", timestamp.ToString(CultureInfo.InvariantCulture));
httpRequest.Headers.Add("X-TC-Region", tencent.Region);
httpRequest.Headers.Add("Authorization",
$"TC3-HMAC-SHA256 Credential={tencent.SecretId}/{date}/{Service}/tc3_request, SignedHeaders=content-type;host, Signature={signature}");
var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("腾讯云短信发送失败:{Status} {Content}", response.StatusCode, content);
return new SmsSendResult { Success = false, Message = content };
}
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement.GetProperty("Response");
var status = root.GetProperty("SendStatusSet")[0];
var code = status.GetProperty("Code").GetString();
var message = status.GetProperty("Message").GetString();
var requestId = root.GetProperty("RequestId").GetString();
var success = string.Equals(code, "Ok", StringComparison.OrdinalIgnoreCase);
return new SmsSendResult
{
Success = success,
RequestId = requestId,
Message = message
};
}
private static string BuildPayload(SmsSendRequest request, TencentSmsOptions options)
{
var payload = new
{
PhoneNumberSet = new[] { request.PhoneNumber },
SmsSdkAppId = options.SdkAppId,
SignName = request.SignName ?? options.SignName,
TemplateId = request.TemplateCode,
TemplateParamSet = request.Variables.Values.ToArray()
};
return JsonSerializer.Serialize(payload);
}
private static string BuildCanonicalRequest(string payload, string host, bool useHttps)
{
_ = useHttps;
var hashedPayload = HashSha256(payload);
var canonicalHeaders = $"content-type:application/json\nhost:{host}\n";
return $"POST\n/\n\n{canonicalHeaders}\ncontent-type;host\n{hashedPayload}";
}
private static string BuildStringToSign(string canonicalRequest, long timestamp, string date)
{
var hashedRequest = HashSha256(canonicalRequest);
return $"TC3-HMAC-SHA256\n{timestamp}\n{date}/{Service}/tc3_request\n{hashedRequest}";
}
private static string Sign(string stringToSign, string secretKey, string date)
{
static byte[] HmacSha256(byte[] key, string msg) => new HMACSHA256(key).ComputeHash(Encoding.UTF8.GetBytes(msg));
var secretDate = HmacSha256(Encoding.UTF8.GetBytes($"TC3{secretKey}"), date);
var secretService = HmacSha256(secretDate, Service);
var secretSigning = HmacSha256(secretService, "tc3_request");
var signatureBytes = new HMACSHA256(secretSigning).ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
return Convert.ToHexString(signatureBytes).ToLowerInvariant();
}
private static string HashSha256(string raw)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
var builder = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes)
{
builder.Append(b.ToString("x2", CultureInfo.InvariantCulture));
}
return builder.ToString();
}
}