diff --git a/System.Application.Services.SmsSender/Models/Abstractions/ISmsResult.cs b/System.Application.Services.SmsSender/Models/Abstractions/ISmsResult.cs new file mode 100644 index 000000000..d86386dbe --- /dev/null +++ b/System.Application.Services.SmsSender/Models/Abstractions/ISmsResult.cs @@ -0,0 +1,20 @@ +namespace System.Application.Models.Abstractions +{ + public interface ISmsResult + { + /// + /// 是否成功 + /// + bool IsSuccess { get; } + + /// + /// 返回的内容 + /// + object? Result { get; } + + /// + /// 返回的HTTP状态码 + /// + int HttpStatusCode { get; } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/Abstractions/SmsResult.cs b/System.Application.Services.SmsSender/Models/Abstractions/SmsResult.cs new file mode 100644 index 000000000..2a1b5b4a1 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/Abstractions/SmsResult.cs @@ -0,0 +1,27 @@ +using System.Application.Columns; + +namespace System.Application.Models.Abstractions +{ + public abstract class SmsResult : + JsonModel, + IResult + where TImplement : SmsResult + { + public bool IsSuccess { get; set; } + + public object? Result { get; set; } + + public int HttpStatusCode { get; set; } + } + + public class SmsResult : SmsResult, ISmsResult, IResult + where TResult : JsonModel + where TImplement : SmsResult + { + public new TResult? Result { get; set; } + + public object? ResultObject { get => base.Result; set => base.Result = value; } + + object? ISmsResult.Result => ResultObject; + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/AlibabaCloud/AlibabaCloudResult.cs b/System.Application.Services.SmsSender/Models/AlibabaCloud/AlibabaCloudResult.cs new file mode 100644 index 000000000..2c5a455cc --- /dev/null +++ b/System.Application.Services.SmsSender/Models/AlibabaCloud/AlibabaCloudResult.cs @@ -0,0 +1,12 @@ +namespace System.Application.Models.AlibabaCloud +{ + public class AlibabaCloudResult : JsonModel where T : AlibabaCloudResult + { + /// + /// https://help.aliyun.com/document_detail/55323.html + /// + public string? Code { get; set; } + + public virtual bool IsOK() => Code?.Equals("OK", StringComparison.OrdinalIgnoreCase) ?? false; + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/AlibabaCloud/SendSmsAlibabaCloudResult.cs b/System.Application.Services.SmsSender/Models/AlibabaCloud/SendSmsAlibabaCloudResult.cs new file mode 100644 index 000000000..2f381dc68 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/AlibabaCloud/SendSmsAlibabaCloudResult.cs @@ -0,0 +1,11 @@ +namespace System.Application.Models.AlibabaCloud +{ + public sealed class SendSmsAlibabaCloudResult : AlibabaCloudResult + { + public string? Message { get; set; } + + public string? RequestId { get; set; } + + public string? BizId { get; set; } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/AlibabaCloud/SmsOptions.cs b/System.Application.Services.SmsSender/Models/AlibabaCloud/SmsOptions.cs new file mode 100644 index 000000000..a7947930c --- /dev/null +++ b/System.Application.Services.SmsSender/Models/AlibabaCloud/SmsOptions.cs @@ -0,0 +1,34 @@ +namespace System.Application.Models.AlibabaCloud +{ + public class SmsOptions + { + /// + /// 平台分配的appkey + /// + public string? AccessKeyId { get; set; } + + /// + /// 平台分配的appSecret + /// + public string? AccessKeySecret { get; set; } + + /// + /// 短信内容签名,正式环境后申请APP名称签名,此处填写APP名称 + /// + public string? SignName { get; set; } + + public SmsOptionsTemplateId[]? Templates { get; set; } + + /// + /// (默认)短信模板ID,发送国际/港澳台消息时,请使用国际/港澳台短信模版 + /// + public string? DefaultTemplateCode { get; set; } + + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(AccessKeyId) && + !string.IsNullOrWhiteSpace(AccessKeySecret) && + !string.IsNullOrWhiteSpace(SignName); + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/CheckSmsResult.cs b/System.Application.Services.SmsSender/Models/CheckSmsResult.cs new file mode 100644 index 000000000..38cd64f9b --- /dev/null +++ b/System.Application.Services.SmsSender/Models/CheckSmsResult.cs @@ -0,0 +1,15 @@ +using System.Application.Models.Abstractions; + +namespace System.Application.Models +{ + public sealed class CheckSmsResult : SmsResult, ICheckSmsResult + { + public bool IsCheckSuccess { get; set; } + } + + public class CheckSmsResult : SmsResult>, ICheckSmsResult + where TResult : JsonModel + { + public bool IsCheckSuccess { get; set; } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/ICheckSmsResult.cs b/System.Application.Services.SmsSender/Models/ICheckSmsResult.cs new file mode 100644 index 000000000..eea5ea648 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/ICheckSmsResult.cs @@ -0,0 +1,15 @@ +using System.Application.Models.Abstractions; + +namespace System.Application.Models +{ + /// + /// 验证短信验证码接口返回模型 + /// + public interface ICheckSmsResult : ISmsResult + { + /// + /// 是否校验成功 + /// + bool IsCheckSuccess { get; } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/ISendSmsResult.cs b/System.Application.Services.SmsSender/Models/ISendSmsResult.cs new file mode 100644 index 000000000..d99e65035 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/ISendSmsResult.cs @@ -0,0 +1,11 @@ +using System.Application.Models.Abstractions; + +namespace System.Application.Models +{ + /// + /// 发送短信验证码接口返回模型 + /// + public interface ISendSmsResult : ISmsResult + { + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/NetEaseCloud/NetEaseCloudResult.cs b/System.Application.Services.SmsSender/Models/NetEaseCloud/NetEaseCloudResult.cs new file mode 100644 index 000000000..e713be161 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/NetEaseCloud/NetEaseCloudResult.cs @@ -0,0 +1,24 @@ +using N_JsonProperty = Newtonsoft.Json.JsonPropertyAttribute; +using S_JsonProperty = System.Text.Json.Serialization.JsonPropertyNameAttribute; + +namespace System.Application.Models.NetEaseCloud +{ + public class NetEaseCloudResult : JsonModel where T : NetEaseCloudResult + { + [N_JsonProperty(code)] + [S_JsonProperty(code)] + public SendSmsResponseCode Code { get; set; } + + protected const string code = nameof(code); + protected const string msg = nameof(msg); + protected const string obj = nameof(obj); + + public virtual bool IsOK() => Code == SendSmsResponseCode.操作成功; + + public virtual bool IsCheckSmsFail() => Code == SendSmsResponseCode.验证失败; + } + + public sealed class NetEaseCloudResult : NetEaseCloudResult + { + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/NetEaseCloud/SendSmsNetEaseCloudResult.cs b/System.Application.Services.SmsSender/Models/NetEaseCloud/SendSmsNetEaseCloudResult.cs new file mode 100644 index 000000000..34433a548 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/NetEaseCloud/SendSmsNetEaseCloudResult.cs @@ -0,0 +1,16 @@ +using N_JsonProperty = Newtonsoft.Json.JsonPropertyAttribute; +using S_JsonProperty = System.Text.Json.Serialization.JsonPropertyNameAttribute; + +namespace System.Application.Models.NetEaseCloud +{ + public sealed class SendSmsNetEaseCloudResult : NetEaseCloudResult + { + [N_JsonProperty(msg)] + [S_JsonProperty(msg)] + public string? Msg { get; set; } + + [N_JsonProperty(obj)] + [S_JsonProperty(obj)] + public string? Obj { get; set; } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/NetEaseCloud/SendSmsResponseCode.cs b/System.Application.Services.SmsSender/Models/NetEaseCloud/SendSmsResponseCode.cs new file mode 100644 index 000000000..68fabc00b --- /dev/null +++ b/System.Application.Services.SmsSender/Models/NetEaseCloud/SendSmsResponseCode.cs @@ -0,0 +1,26 @@ +namespace System.Application.Models.NetEaseCloud +{ + /// + /// http://dev.netease.im/docs/product/IM%E5%8D%B3%E6%97%B6%E9%80%9A%E8%AE%AF/%E6%9C%8D%E5%8A%A1%E7%AB%AFAPI%E6%96%87%E6%A1%A3/code%E7%8A%B6%E6%80%81%E8%A1%A8 + /// + public enum SendSmsResponseCode + { + 操作成功 = 200, + 被封禁 = 301, + + // ReSharper disable once InconsistentNaming + IP限制 = 315, + + 非法操作或没有权限 = 403, + 对象不存在 = 404, + + /// + /// 验证失败(短信服务) + /// + 验证失败 = 413, + + 参数错误 = 414, + 频率控制 = 416, + 服务器内部错误 = 500 + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/NetEaseCloud/SmsOptions.cs b/System.Application.Services.SmsSender/Models/NetEaseCloud/SmsOptions.cs new file mode 100644 index 000000000..50ce0f0d4 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/NetEaseCloud/SmsOptions.cs @@ -0,0 +1,26 @@ +namespace System.Application.Models.NetEaseCloud +{ + public class SmsOptions + { + /// + /// 开发者平台分配的appkey + /// + public string? AppKey { get; set; } + + /// + /// 开发者平台分配的appSecret + /// + public string? AppSecret { get; set; } + + /// + /// 开发者平台分配的模板标志 + /// + public SmsOptionsTemplateId[]? Templates { get; set; } + + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(AppKey) && + !string.IsNullOrWhiteSpace(AppSecret); + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/SendSmsResult.cs b/System.Application.Services.SmsSender/Models/SendSmsResult.cs new file mode 100644 index 000000000..d88a5534b --- /dev/null +++ b/System.Application.Services.SmsSender/Models/SendSmsResult.cs @@ -0,0 +1,13 @@ +using System.Application.Models.Abstractions; + +namespace System.Application.Models +{ + public sealed class SendSmsResult : SmsResult, ISendSmsResult + { + } + + public class SendSmsResult : SmsResult>, ISendSmsResult + where TResult : JsonModel + { + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Models/SmsOptionsTemplateId.cs b/System.Application.Services.SmsSender/Models/SmsOptionsTemplateId.cs new file mode 100644 index 000000000..51b1170b9 --- /dev/null +++ b/System.Application.Services.SmsSender/Models/SmsOptionsTemplateId.cs @@ -0,0 +1,15 @@ +namespace System.Application.Models +{ + public class SmsOptionsTemplateId + { + /// + /// 开发者平台分配的模板标志 + /// + public T? Template { get; set; } + + /// + /// 用于发送验证码的用途 + /// + public int Type { get; set; } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/README.md b/System.Application.Services.SmsSender/README.md new file mode 100644 index 000000000..bf9cedfbd --- /dev/null +++ b/System.Application.Services.SmsSender/README.md @@ -0,0 +1,7 @@ +# SmsSender 统一短信发送服务 +适用于 **ASP.NET Core 5.0** 的统一的短信服务接口供业务层调用,~~可多个提供商同时使用~~,通过替换实现类的方式无缝切换提供商 +~~可以稍微修改兼容 ```.NET Framework 4.6.1 & ASP.NET 4.x+```,但没必要~~ +- 目前支持的提供商 + - [阿里云](https://help.aliyun.com/product/44282.html) + - [网易云信](https://yunxin.163.com/sms) + - [世纪互联蓝云](https://bccs.21vbluecloud.com/intro/sms) diff --git a/System.Application.Services.SmsSender/Services/AlibabaCloud/SmsSenderProvider.cs b/System.Application.Services.SmsSender/Services/AlibabaCloud/SmsSenderProvider.cs new file mode 100644 index 000000000..e20afb915 --- /dev/null +++ b/System.Application.Services.SmsSender/Services/AlibabaCloud/SmsSenderProvider.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Application.Models; +using System.Application.Models.AlibabaCloud; +using System.Application.Services.Implementation; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Application.Services.AlibabaCloud +{ + /// + /// 短信服务提供商 - 阿里云 + /// + public class SmsSenderProvider : AbstractSmsSender, ISmsSender + { + public const string Name = nameof(AlibabaCloud); + + public override string Channel => Name; + + public override bool SupportCheck => false; + + readonly HttpClient httpClient; + readonly SmsOptions options; + readonly ILogger logger; + readonly JsonSerializer jsonSerializer = new(); + + public SmsSenderProvider(ILogger logger, SmsOptions options, HttpClient httpClient) + { + this.logger = logger; + this.options = options; + this.httpClient = httpClient; + } + + #region 常量 + + /// + /// 短信API产品名称(短信产品名固定,无需修改) + /// + const string product = "Dysmsapi"; + /// + /// 短信API产品域名(接口地址固定,无需修改) + /// + const string domain = "dysmsapi.aliyuncs.com"; + + #endregion + + #region helpers + + const string ISO8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + const string encode_text = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~"; + + static string FormatIso8601Date(DateTimeOffset date) + => date.ToUniversalTime().ToString(ISO8601_DATE_FORMAT, CultureInfo.CreateSpecificCulture("en-US")); + + static string AcsUrlEncode(string s) + { + var values = Encoding.UTF8.GetBytes(s).Select(x => + { + char c = (char)x; + if (encode_text.Contains(c)) + { + return c.ToString(); + } + else + { + return "%" + string.Format(CultureInfo.InvariantCulture, "{0:X2}", x); + } + }); + + return string.Join(string.Empty, values); + } + + private static string HashSign(string s, string secret) + { + using var hash = new HMACSHA1(Encoding.UTF8.GetBytes(secret.ToCharArray())); + return Convert.ToBase64String(hash.ComputeHash(Encoding.UTF8.GetBytes(s))); + } + + #endregion + + public override async Task SendSmsAsync(string number, string message, int type, CancellationToken cancellationToken) + { + var now = DateTimeOffset.Now; + var template_code = options.Templates?.FirstOrDefault(x => x.Type == type)?.Template ?? options.DefaultTemplateCode; + + var templateParam = "{\"code\":\"" + message + "\"}"; // JsonConvert.SerializeObject(new { code = message }); + + var args = new SortedDictionary(StringComparer.Ordinal) // https://help.aliyun.com/document_detail/56189.html + { +#region (系统参数)为POP协议的基本参数,有 + + { "AccessKeyId", options.AccessKeyId.ThrowIsNull(nameof(options.AccessKeyId)) }, + { "Timestamp", FormatIso8601Date(now) }, // 格式为:yyyy-MM-dd’T’HH:mm:ss’Z’;时区为:GMT + { "Format", "JSON" }, // 没传默认为JSON,可选填值:XML + { "SignatureMethod", "HMAC-SHA1" }, // 建议固定值:HMAC-SHA1 + { "SignatureVersion", "1.0" }, // 建议固定值:1.0 + { "SignatureNonce", Guid.NewGuid().ToStringN() + Random2.GenerateRandomString(3) }, + // 用于请求的防重放攻击,每次请求唯一,JAVA语言建议用:java.util.UUID.randomUUID()生成即可 + //{ "Signature", "" }, // 最终生成的签名结果值 + + #endregion + +#region 业务参数 + + { "Action", "SendSms"}, // API的命名,固定值,如发送短信API的值为:SendSms + { "Version", "2017-05-25"}, // API的版本,固定值,如短信API的值为:2017-05-25 + { "RegionId", "cn-hangzhou"}, // API支持的RegionID,如短信API的值为:cn-hangzhou + + // https://help.aliyun.com/document_detail/55284.html + + { "PhoneNumbers", number}, // 短信接收号码,支持以逗号分隔的形式进行批量调用,批量上限为1000个手机号码,批量调用相对于单条调用及时性稍有延迟,验证码类型的短信推荐使用单条调用的方式;发送国际/港澳台消息时,接收号码格式为00+国际区号+号码,如“0085200000000” + { "SignName", options.SignName.ThrowIsNull(nameof(options.SignName))}, // 短信签名 正式环境要写入APP的名称。 + { "TemplateCode", template_code.ThrowIsNull(nameof(template_code))}, // 短信模板ID,发送国际/港澳台消息时,请使用国际/港澳台短信模版 + { "TemplateParam", templateParam}, // 短信模板变量替换JSON串,友情提示:如果JSON中需要带换行符,请参照标准的JSON协议。 + +#endregion + }; + + var queryArray = args.Select(x => $"{AcsUrlEncode(x.Key)}={AcsUrlEncode(x.Value)}"); + var queryString = string.Join("&", queryArray); + + var signArray = new[] { + "GET", + "&", + AcsUrlEncode("/"), + "&", + AcsUrlEncode(queryString), + }; + + var signString = string.Join(string.Empty, signArray); + signString = HashSign(signString, options.AccessKeySecret + "&"); + signString = AcsUrlEncode(signString); + + args.Add("Signature", signString); + + var requestUri = $"https://{domain}?Signature={signString}&{queryString}"; + var response = await httpClient.GetAsync(requestUri, cancellationToken); + + var isSuccess = false; + SendSmsAlibabaCloudResult? jsonObject = null; + + if (response.IsSuccessStatusCode) + { + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(stream, Encoding.UTF8); + using var json = new JsonTextReader(reader); + jsonObject = jsonSerializer.Deserialize(json); + isSuccess = jsonObject != default && jsonObject.IsOK(); + } + + var result = new SendSmsResult + { + HttpStatusCode = (int)response.StatusCode, + IsSuccess = isSuccess, + Result = jsonObject, + ResultObject = jsonObject, + }; + + if (!result.IsSuccess) + { + logger.LogError( + $"调用阿里云短信接口失败," + + $"手机号码:{PhoneNumberHelper.ToStringHideMiddleFour(number)}," + + $"短信内容:{message}," + + $"短信类型:{type}," + + $"HTTP状态码:{result.HttpStatusCode}"); + } + + return result; + } + + public override Task CheckSmsAsync(string __, string ___, CancellationToken _) + { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Services/ISmsSender.cs b/System.Application.Services.SmsSender/Services/ISmsSender.cs new file mode 100644 index 000000000..7591fce53 --- /dev/null +++ b/System.Application.Services.SmsSender/Services/ISmsSender.cs @@ -0,0 +1,64 @@ +using System.Application.Models; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Application.Services +{ + /// + /// 短信发送服务 + /// + public interface ISmsSender + { + /// + /// [通过手机号码注册账号]用于注册 + /// + const int SendSmsRequestType_Register = 200; + /// + /// [通过手机号码重设密码]找回密码 + /// + const int SendSmsRequestType_ForgotPassword = 201; + /// + /// [换新手机号要旧手机号短信验证]绑定新手机号 + /// + const int SendSmsRequestType_ChangePhoneNumberNew = 202; + /// + /// [新手机号的短信验证]换绑手机(安全验证) + /// + const int SendSmsRequestType_ChangePhoneNumberValidation = 203; + + /// + /// 发送短信验证码 + /// + /// 收信人手机号码。 + /// 发送的短信内容。 + /// 发送短信用途。 + /// + /// + Task SendSmsAsync(string number, string message, int type, CancellationToken cancellationToken = default); + + /// + /// 校验短信验证码,某些平台要求校验短信也要调用他提供的接口 + /// + /// 收信人手机号码。 + /// 短信验证码内容。 + /// + Task CheckSmsAsync(string number, string message, CancellationToken cancellationToken = default); + + /// + /// 短信提供渠道显示名称 + /// + string Channel { get; } + + /// + /// 短信提供渠道是否支持校验短信验证码 + /// + bool SupportCheck { get; } + + /// + /// 生成短信随机验证码 + /// + /// 长度 + /// + string GenerateRandomNum(int length); + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Services/Implementation/AbstractSmsSender.cs b/System.Application.Services.SmsSender/Services/Implementation/AbstractSmsSender.cs new file mode 100644 index 000000000..9f5ecf582 --- /dev/null +++ b/System.Application.Services.SmsSender/Services/Implementation/AbstractSmsSender.cs @@ -0,0 +1,27 @@ +using System.Application.Models; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Application.Services.Implementation +{ + public abstract class AbstractSmsSender : ISmsSender + { + public abstract string Channel { get; } + + public abstract bool SupportCheck { get; } + + public abstract Task CheckSmsAsync(string number, string message, CancellationToken cancellationToken); + + public abstract Task SendSmsAsync(string number, string message, int type, CancellationToken cancellationToken); + + /// + /// 生成随机短信验证码值,某些平台可能提供了随机生成,可以重写该函数替换 + /// + /// + /// + public virtual string GenerateRandomNum(int length) + { + return Random2.GenerateRandomNum((ushort)length).ToString(); + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Services/Implementation/DebugSmsSenderProvider.cs b/System.Application.Services.SmsSender/Services/Implementation/DebugSmsSenderProvider.cs new file mode 100644 index 000000000..dc67d4af8 --- /dev/null +++ b/System.Application.Services.SmsSender/Services/Implementation/DebugSmsSenderProvider.cs @@ -0,0 +1,41 @@ +using System.Application.Models; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Application.Services.Implementation +{ + /// + /// 调试模式下可使用此实现,验证码全输6 + /// + public sealed class DebugSmsSenderProvider : ISmsSender + { + public const string Name = nameof(DebugSmsSenderProvider); + + public string Channel => Name; + + public bool SupportCheck => true; + + public Task CheckSmsAsync(string __, string ___, CancellationToken _) + { + return Task.FromResult(new CheckSmsResult + { + IsSuccess = true, + IsCheckSuccess = true, + }); + } + + public Task SendSmsAsync(string __, string ___, int ____, CancellationToken _) + { + return Task.FromResult(new SendSmsResult + { + IsSuccess = true, + }); + } + + public string GenerateRandomNum(int length) + { + return string.Join(null, new char[length].Select(x => '6')); + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Services/NetEaseCloud/SmsSenderProvider.cs b/System.Application.Services.SmsSender/Services/NetEaseCloud/SmsSenderProvider.cs new file mode 100644 index 000000000..f8f3c475b --- /dev/null +++ b/System.Application.Services.SmsSender/Services/NetEaseCloud/SmsSenderProvider.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Application.Models; +using System.Application.Models.Abstractions; +using System.Application.Models.NetEaseCloud; +using System.Application.Services.Implementation; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CSR = System.Application.Models.CheckSmsResult; +using SSR = System.Application.Models.SendSmsResult; +using SSRR = System.Application.Models.NetEaseCloud.SendSmsNetEaseCloudResult; + +/* +返回说明 +http 响应:json +发送成功则返回相关信息。msg字段表示此次发送的sendid;obj字段表示此次发送的验证码。 +"Content-Type": "application/json; charset=utf-8" +{ +"code": 200, +"msg": "88", +"obj": "1908" +} +主要的返回码 +200、315、403、414、416、500 +200 操作成功 +315 IP限制 +403 非法操作或没有权限 +414 参数错误 +416 频率控制 +500 服务器内部错误 + */ + +namespace System.Application.Services.NetEaseCloud +{ + /// + /// 短信服务(网易云实现) 需要实现校验接口 + /// 参考资料: + /// http://dev.netease.im/docs/product/%E7%9F%AD%E4%BF%A1/%E7%9F%AD%E4%BF%A1%E6%8E%A5%E5%8F%A3%E6%8C%87%E5%8D%97 + /// http://dev.netease.im/docs/product/IM%E5%8D%B3%E6%97%B6%E9%80%9A%E8%AE%AF/%E6%9C%8D%E5%8A%A1%E7%AB%AFAPI%E6%96%87%E6%A1%A3?#接口概述 + /// + public class SmsSenderProvider : AbstractSmsSender, ISmsSender + { + public const string Name = nameof(NetEaseCloud); + + public override string Channel => Name; + + public override bool SupportCheck => true; + + readonly HttpClient httpClient; + readonly SmsOptions options; + readonly ILogger logger; + readonly JsonSerializer jsonSerializer = new(); + + public SmsSenderProvider(ILogger logger, SmsOptions options, HttpClient httpClient) + { + this.logger = logger; + this.options = options; + this.httpClient = httpClient; + } + + #region 常量 + + /// + /// 接口地址。 + /// + const string SmsSendApiUrl = "https://api.netease.im/sms/sendcode.action"; + + const string SmsCheckApiUrl = "https://api.netease.im/sms/verifycode.action"; + + #endregion 常量 + + #region helpers + + /// + /// 当前UTC时间戳,从1970年1月1日0点0 分0 秒开始到现在的秒数(String) + /// + /// + static string CurTime() => UnixTimestamp.ToTimestampS(DateTime.UtcNow).ToString(); + + static string Nonce() => Guid.NewGuid().ToStringN() + DateTime.Now.Ticks; + + async Task PostAsync(string requestUri, Dictionary args, CancellationToken cancellationToken) + where T : SmsResult, new() + where TResult : NetEaseCloudResult + { + var nonce = Nonce(); + var curTime = CurTime(); + var checkSum = Hashs.String.SHA1(options.AppSecret + nonce + curTime); + // ↑ SHA1(AppSecret + Nonce + CurTime),三个参数拼接的字符串, + // 进行SHA1哈希计算,转化成16进制字符(String,小写)。 +#pragma warning disable CS8620 // 由于引用类型的可为 null 性差异,实参不能用于形参。 + var content = new FormUrlEncodedContent(args); +#pragma warning restore CS8620 // 由于引用类型的可为 null 性差异,实参不能用于形参。 + content.Headers.ContentType.ThrowIsNull(nameof(content.Headers.ContentType)).CharSet = "utf-8"; + // ↑ application/x-www-form-urlencoded;charset=utf-8 + + var request_args = new + { + body = args, + headers = new Dictionary { + { nameof(options.AppKey), options.AppKey.ThrowIsNull(nameof(options.AppKey)) }, + { nameof(Nonce), nonce }, + { nameof(CurTime), curTime }, + { "CheckSum", checkSum }, + }, + }; + + var request = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = content, + }; + + foreach (var header in request_args.headers) + request.Headers.Add(header.Key, header.Value); + + var isSuccess = false; + TResult? jsonObject = null; + var response = await httpClient.SendAsync(request, cancellationToken); + + if (response.IsSuccessStatusCode) + { + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(stream, Encoding.UTF8); + using var json = new JsonTextReader(reader); + jsonObject = jsonSerializer.Deserialize(json); + isSuccess = jsonObject != default && jsonObject.IsOK(); + } + + return new T + { + HttpStatusCode = (int)response.StatusCode, + IsSuccess = isSuccess, + Result = jsonObject, + ResultObject = jsonObject, + }; + } + + #endregion helpers + + public override async Task SendSmsAsync(string number, string message, int type, CancellationToken cancellationToken) + { + var dictionary = new Dictionary { { "mobile", number } }; + var template_id = options.Templates?.FirstOrDefault(x => x.Type == type)?.Template; + if (template_id.HasValue) + dictionary.Add("templateid", template_id.Value.ToString()); + dictionary.Add("authCode", message); + + var result = await PostAsync(SmsSendApiUrl, dictionary, cancellationToken); + if (!result.IsSuccess) + { + logger.LogError( + $"调用网易云短信接口失败," + + $"手机号码:{number}," + + $"短信内容:{message}," + + $"短信类型:{type}," + + $"HTTP状态码:{result.HttpStatusCode}"); + } + + return result; + } + + public override async Task CheckSmsAsync(string number, string message, CancellationToken cancellationToken) + { + var dictionary = new Dictionary { { "mobile", number }, { "code", message } }; + + var result = await PostAsync(SmsCheckApiUrl, dictionary, cancellationToken); + + if (!result.IsSuccess) + { + if (result.Result != default && result.Result.IsCheckSmsFail()) + { + result.IsCheckSuccess = true; + } + else + { + logger.LogError( + $"调用网易云短信验证接口失败," + + $"手机号码:{number}," + + $"短信内容:{message}," + + $"HTTP状态码:{result.HttpStatusCode}"); + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/Services/_21VianetBlueCloud/SmsSenderProvider.cs b/System.Application.Services.SmsSender/Services/_21VianetBlueCloud/SmsSenderProvider.cs new file mode 100644 index 000000000..d09be2697 --- /dev/null +++ b/System.Application.Services.SmsSender/Services/_21VianetBlueCloud/SmsSenderProvider.cs @@ -0,0 +1,30 @@ +using System.Application.Models; +using System.Application.Services.Implementation; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Application.Services._21VianetBlueCloud +{ + /// + /// 短信服务提供商 - 世纪互联蓝云 + /// + public class SmsSenderProvider : AbstractSmsSender, ISmsSender + { + public const string Name = nameof(_21VianetBlueCloud); + + public override string Channel => Name; + + public override bool SupportCheck => throw new NotImplementedException(); + + public override Task SendSmsAsync(string number, string message, int type, CancellationToken cancellationToken) + { + // 明天再写 + throw new NotImplementedException(); + } + + public override Task CheckSmsAsync(string number, string message, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/System.Application.Services.SmsSender/System.Application.Services.SmsSender.csproj b/System.Application.Services.SmsSender/System.Application.Services.SmsSender.csproj new file mode 100644 index 000000000..ce4c64690 --- /dev/null +++ b/System.Application.Services.SmsSender/System.Application.Services.SmsSender.csproj @@ -0,0 +1,22 @@ + + + + net5.0 + System.Application + + + + + Properties\AssemblyInfo.cs + + + + + + + + + + + + diff --git a/System.Application.SteamTools.Services.CloudService.Models/Services.CloudService/Constants.cs b/System.Application.SteamTools.Services.CloudService.Models/Services.CloudService/Constants.cs index 6fbf66457..ed365afc8 100644 --- a/System.Application.SteamTools.Services.CloudService.Models/Services.CloudService/Constants.cs +++ b/System.Application.SteamTools.Services.CloudService.Models/Services.CloudService/Constants.cs @@ -19,12 +19,6 @@ namespace System.Application.Services.CloudService } } - public const string HeaderAppVersion = "App-Version"; - - public const string HeaderSecurityKey = "App-S-Key"; - - public const string HeaderRspCiphertext = "App-Rsp-Ciphertext"; - /// /// 短信间隔,60秒 /// diff --git a/System.Application.SteamTools/ModelValidatorProvider.cs b/System.Application.SteamTools/ModelValidatorProvider.cs index 063b8d314..7b1cf4861 100644 --- a/System.Application.SteamTools/ModelValidatorProvider.cs +++ b/System.Application.SteamTools/ModelValidatorProvider.cs @@ -169,7 +169,7 @@ namespace System.Application /// /// 手机号码 /// - public const int PhoneNumber = PhoneNumberHelper.Length; + public const int PhoneNumber = PhoneNumberHelper.ChineseMainlandPhoneNumberLength; /// /// 昵称 diff --git a/System.Common.ClientLib.Droid/Application/Services/Implementation/PlatformTelephonyServiceImpl.cs b/System.Common.ClientLib.Droid/Application/Services/Implementation/PlatformTelephonyServiceImpl.cs index d4ee4e171..8aebbf648 100644 --- a/System.Common.ClientLib.Droid/Application/Services/Implementation/PlatformTelephonyServiceImpl.cs +++ b/System.Common.ClientLib.Droid/Application/Services/Implementation/PlatformTelephonyServiceImpl.cs @@ -15,7 +15,7 @@ namespace System.Application.Services.Implementation { var telephony = XEPlatform.AppContext.GetSystemService(Context.TelephonyService); var value = telephony.Line1Number; - return PhoneNumberHelper.GetChineseMainlandPhoneNumber11(value); + return PhoneNumberHelper.GetChineseMainlandPhoneNumber(value); } } } \ No newline at end of file diff --git a/System.Common.CoreLib/Application/Columns/IResult.cs b/System.Common.CoreLib/Application/Columns/IResult.cs new file mode 100644 index 000000000..f20f38bd0 --- /dev/null +++ b/System.Common.CoreLib/Application/Columns/IResult.cs @@ -0,0 +1,7 @@ +namespace System.Application.Columns +{ + public interface IResult + { + T Result { get; set; } + } +} \ No newline at end of file diff --git a/System.Common.CoreLib/Application/Models/JsonModel.cs b/System.Common.CoreLib/Application/Models/JsonModel.cs new file mode 100644 index 000000000..34c1aaeda --- /dev/null +++ b/System.Common.CoreLib/Application/Models/JsonModel.cs @@ -0,0 +1,32 @@ +namespace System.Application.Models +{ + public abstract class JsonModel + { + public override string ToString() + { + return Serializable.SJSON(this); + } + } + + public abstract class JsonModel : JsonModel where T : JsonModel + { + public static T? Parse(string value) + { + return Serializable.DJSON(value); + } + + public static bool TryParse(string value, out T? result) + { + try + { + result = Parse(value); + return true; + } + catch + { + result = default; + return false; + } + } + } +} \ No newline at end of file diff --git a/System.Common.CoreLib/Application/PhoneNumberHelper.cs b/System.Common.CoreLib/Application/PhoneNumberHelper.cs index 33888ac51..b693cc99c 100644 --- a/System.Common.CoreLib/Application/PhoneNumberHelper.cs +++ b/System.Common.CoreLib/Application/PhoneNumberHelper.cs @@ -1,13 +1,14 @@ using System.Application.Columns; using System.Common; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Properties; namespace System.Application { public static class PhoneNumberHelper { const char HideChar = '*'; - public const int Length = 11; /// /// 手机号码隐藏中间四位数字 @@ -40,12 +41,14 @@ namespace System.Application /// public static string ToStringHideMiddleFour(this IReadOnlyPhoneNumber phoneNumber, char hideChar = HideChar) => ToStringHideMiddleFour(phoneNumber.PhoneNumber, hideChar); + public const int ChineseMainlandPhoneNumberLength = 11; + /// /// 获取中国大陆地区11位手机号码 /// /// /// - public static string? GetChineseMainlandPhoneNumber11(string? value) + public static string? GetChineseMainlandPhoneNumber(string? value) { if (!string.IsNullOrWhiteSpace(value)) { @@ -72,5 +75,94 @@ namespace System.Application } return null; } + + /// + /// 中国大陆地区手机号码号段 + /// + static class ChineseMainlandPhoneNumberSegment + { + /// + /// 中国电信 + /// + public static byte[] ChinaTelecom = { 133, 153, 177, 173, 180, 181, 189, 199, 149 }; + + /// + /// 中国联通 + /// + public static byte[] ChinaUnicom = { 130, 131, 132, 155, 156, 185, 186, 145, 176, 166, 146, 175 }; + + /// + /// 中国移动 + /// + public static byte[] ChinaMobile = { 139, 138, 137, 136, 135, 134, 147, 150, 151, 152, 157, 158, 159, 178, 182, 183, 184, 187, 188, 198, 148, 172 }; + + /// + /// 虚拟运营商 + /// + public static byte[] MobileVirtualNetwork = { 170, 171 }; + + public static byte[] Other = { 190, 197, 196, 192 }; + + static Lazy mSummary = new(() => new[] + { + ChinaTelecom, + ChinaUnicom, + ChinaMobile, + MobileVirtualNetwork + }.SelectMany(b => b).ToArray()); + + /// + /// 总号段 + /// + public static byte[] Summary => mSummary.Value; + + /// + /// 黑名单 + /// + public static readonly byte[] Blacklist = new byte[] { + 148, 149, 146, // 物联网号段 + }; + } + + /// + /// 中国大陆地区手机号码号段验证使用 黑名单() 或 白名单() + /// + public static bool ChineseMainlandPhoneNumberSegmentVerifyUseBlacklistOrWhitelist { get; set; } + + /// + /// 中国大陆地区手机号码号段验证(通常仅服务端验证) + /// + /// + /// + /// + public static bool IsChineseMainlandPhoneNumberSegment(string value, + [NotNullWhen(false)] out string? errMsg) + { + var segment = value.Substring(0, 3).TryParseByte(); + if (!segment.HasValue) + { + errMsg = string.Format(SR.UnsupportedPhoneNumberSegment_, 0); + return false; + } + if (ChineseMainlandPhoneNumberSegmentVerifyUseBlacklistOrWhitelist) + { + if (ChineseMainlandPhoneNumberSegment.Blacklist.Contains(segment.Value)) + { + errMsg = string.Format(SR.UnsupportedPhoneNumberSegment_, segment ?? 0); + return false; + } + } + else + { + if (!ChineseMainlandPhoneNumberSegment.Summary.Contains(segment.Value)) + { + errMsg = string.Format(SR.UnsupportedPhoneNumberSegment_, segment ?? 0); + return false; + } + } + + errMsg = null; + return true; + } } } \ No newline at end of file diff --git a/System.Common.CoreLib/Extensions/StringExtensions.cs b/System.Common.CoreLib/Extensions/StringExtensions.cs index 1f427d492..93ce60dd2 100644 --- a/System.Common.CoreLib/Extensions/StringExtensions.cs +++ b/System.Common.CoreLib/Extensions/StringExtensions.cs @@ -52,12 +52,63 @@ namespace System #region TryParse + /// + /// 尝试将数字的字符串表示形式转换为它的等效 16 位有符号整数 + /// + /// + /// + public static short? TryParseInt16(this string value) => short.TryParse(value, out var temp) ? temp : null; + + /// + /// 尝试将数字的字符串表示形式转换为它的等效 16 位无符号整数 + /// + /// + /// + public static ushort? TryParseUInt16(this string value) => ushort.TryParse(value, out var temp) ? temp : null; + /// /// 尝试将数字的字符串表示形式转换为它的等效 32 位有符号整数 /// /// /// - public static int? TryParseInt32(this string value) => int.TryParse(value, out var temp) ? temp as int? : null; + public static int? TryParseInt32(this string value) => int.TryParse(value, out var temp) ? temp : null; + + /// + /// 尝试将数字的字符串表示形式转换为它的等效 32 位无符号整数 + /// + /// + /// + public static uint? TryParseUInt32(this string value) => uint.TryParse(value, out var temp) ? temp : null; + + /// + /// 尝试将数字的字符串表示形式转换为它的等效 64 位有符号整数 + /// + /// + /// + public static long? TryParseInt64(this string value) => long.TryParse(value, out var temp) ? temp : null; + + /// + /// 尝试将数字的字符串表示形式转换为它的等效 64 位无符号整数 + /// + /// + /// + public static ulong? TryParseUInt64(this string value) => ulong.TryParse(value, out var temp) ? temp : null; + + /// + /// 尝试将数字的字符串表示形式转换为它的等效 byte 值 + /// + /// + /// + public static byte? TryParseByte(this string value) => byte.TryParse(value, out var temp) ? temp : null; + + public static bool? TryParseBoolean(this string value) => bool.TryParse(value, out var temp) ? temp : null; + + /// + /// 尝试将数字的字符串表示形式转换为它的等效 sbyte 值 + /// + /// + /// + public static sbyte? TryParseSByte(this string value) => sbyte.TryParse(value, out sbyte temp) ? temp : null; #endregion diff --git a/System.Common.CoreLib/Properties/SR.Designer.cs b/System.Common.CoreLib/Properties/SR.Designer.cs index 2952bba8c..6001f9573 100644 --- a/System.Common.CoreLib/Properties/SR.Designer.cs +++ b/System.Common.CoreLib/Properties/SR.Designer.cs @@ -248,5 +248,14 @@ namespace System.Properties { return ResourceManager.GetString("Unknown", resourceCulture); } } + + /// + /// 查找类似 不受支持的手机号码号段({0}) 的本地化字符串。 + /// + internal static string UnsupportedPhoneNumberSegment_ { + get { + return ResourceManager.GetString("UnsupportedPhoneNumberSegment_", resourceCulture); + } + } } } diff --git a/System.Common.CoreLib/Properties/SR.resx b/System.Common.CoreLib/Properties/SR.resx index 8aabbd38e..509d2419a 100644 --- a/System.Common.CoreLib/Properties/SR.resx +++ b/System.Common.CoreLib/Properties/SR.resx @@ -181,4 +181,7 @@ 未知 + + 不受支持的手机号码号段({0}) + \ No newline at end of file diff --git a/System.Common.CoreLib/System.Common.CoreLib.csproj b/System.Common.CoreLib/System.Common.CoreLib.csproj index 3a4562ad8..1f64c54fd 100644 --- a/System.Common.CoreLib/System.Common.CoreLib.csproj +++ b/System.Common.CoreLib/System.Common.CoreLib.csproj @@ -6,16 +6,16 @@ - - - - - - - - - - + + + + + + + + + +