-
Notifications
You must be signed in to change notification settings - Fork 292
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(openai): 新增 OpenAI v2 版接口客户端,并实现加解密及签名中间件
- Loading branch information
Showing
24 changed files
with
807 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
...SKIT.FlurlHttpClient.Wechat.OpenAI/Extensions/WechatOpenAIClientExecuteTokenExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
using System; | ||
using System.Net.Http; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Flurl.Http; | ||
|
||
namespace SKIT.FlurlHttpClient.Wechat.OpenAI | ||
{ | ||
public static class WechatOpenAIClientExecuteTokenExtensions | ||
{ | ||
/// <summary> | ||
/// <para>异步调用 [POST] /v2/token 接口。</para> | ||
/// <para> | ||
/// REF: <br/> | ||
/// <![CDATA[ https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html ]]> | ||
/// </para> | ||
/// </summary> | ||
/// <param name="client"></param> | ||
/// <param name="request"></param> | ||
/// <param name="cancellationToken"></param> | ||
/// <returns></returns> | ||
public static async Task<Models.TokenV2Response> ExecuteTokenV2Async(this WechatOpenAIClient client, Models.TokenV2Request request, CancellationToken cancellationToken = default) | ||
{ | ||
if (client is null) throw new ArgumentNullException(nameof(client)); | ||
if (request is null) throw new ArgumentNullException(nameof(request)); | ||
|
||
IFlurlRequest flurlReq = client | ||
.CreateFlurlRequest(request, HttpMethod.Post, "v2", "token"); | ||
|
||
return await client.SendFlurlRequestAsJsonAsync<Models.TokenV2Response>(flurlReq, data: request, cancellationToken: cancellationToken).ConfigureAwait(false); | ||
} | ||
} | ||
} |
158 changes: 158 additions & 0 deletions
158
...IT.FlurlHttpClient.Wechat.OpenAI/Interceptors/WechatOpenAIRequestEncryptionInterceptor.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Net; | ||
using System.Net.Http; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Flurl.Http; | ||
|
||
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Interceptors | ||
{ | ||
using SKIT.FlurlHttpClient.Internal; | ||
|
||
internal class WechatOpenAIRequestEncryptionInterceptor : HttpInterceptor | ||
{ | ||
/** | ||
* REF: | ||
* https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html | ||
*/ | ||
private static readonly ISet<string> ENCRYPT_REQUIRED_URLS = new HashSet<string>(StringComparer.OrdinalIgnoreCase) | ||
{ | ||
"/v2/bot/query" | ||
}; | ||
|
||
private readonly string _baseUrl; | ||
private readonly string _encodingAESKey; | ||
private readonly Func<string, bool>? _customEncryptedRequestPathMatcher; | ||
|
||
public WechatOpenAIRequestEncryptionInterceptor(string baseUrl, string encodingAESKey, Func<string, bool>? customEncryptedRequestPathMatcher) | ||
{ | ||
_baseUrl = baseUrl; | ||
_encodingAESKey = encodingAESKey; | ||
_customEncryptedRequestPathMatcher = customEncryptedRequestPathMatcher; | ||
|
||
// AES 密钥的长度不是 4 的倍数需要补齐,确保其始终为有效的 Base64 字符串 | ||
const int MULTI = 4; | ||
int tLen = _encodingAESKey.Length; | ||
int tRem = tLen % MULTI; | ||
if (tRem > 0) | ||
{ | ||
_encodingAESKey = _encodingAESKey.PadRight(tLen - tRem + MULTI, '='); | ||
} | ||
} | ||
|
||
public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default) | ||
{ | ||
if (context is null) throw new ArgumentNullException(nameof(context)); | ||
if (context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to encrypt request. This interceptor must be called before request completed."); | ||
|
||
if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri)) | ||
return; | ||
|
||
byte[] reqBytes = Array.Empty<byte>(); | ||
if (context.FlurlCall.HttpRequestMessage?.Content is not null) | ||
{ | ||
if (context.FlurlCall.HttpRequestMessage.Content is not MultipartFormDataContent) | ||
{ | ||
reqBytes = await | ||
#if NET5_0_OR_GREATER | ||
context.FlurlCall.HttpRequestMessage.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); | ||
#else | ||
_AsyncEx.RunTaskWithCancellationTokenAsync(context.FlurlCall.HttpRequestMessage.Content.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false); | ||
#endif | ||
} | ||
} | ||
|
||
byte[] reqBytesEncrypted = Array.Empty<byte>(); | ||
try | ||
{ | ||
const int AES_BLOCK_SIZE = 16; | ||
byte[] keyBytes = Convert.FromBase64String(_encodingAESKey); | ||
byte[] ivBytes = new byte[AES_BLOCK_SIZE]; // iv 是 key 的前 16 个字节 | ||
Buffer.BlockCopy(keyBytes, 0, ivBytes, 0, ivBytes.Length); | ||
|
||
reqBytesEncrypted = Utilities.AESUtility.EncryptWithCBC( | ||
keyBytes: keyBytes, | ||
ivBytes: ivBytes, | ||
plainBytes: reqBytes | ||
)!; | ||
} | ||
catch (Exception ex) | ||
{ | ||
throw new WechatOpenAIException("Failed to encrypt request. Please see the inner exception for more details.", ex); | ||
} | ||
|
||
context.FlurlCall.HttpRequestMessage!.Content?.Dispose(); | ||
context.FlurlCall.HttpRequestMessage!.Content = new ByteArrayContent(reqBytesEncrypted); | ||
context.FlurlCall.Request.WithHeader(HttpHeaders.ContentType, MimeTypes.Text); | ||
} | ||
|
||
public override async Task AfterCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default) | ||
{ | ||
if (context is null) throw new ArgumentNullException(nameof(context)); | ||
if (!context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to decrypt response. This interceptor must be called after request completed."); | ||
|
||
if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri)) | ||
return; | ||
if (context.FlurlCall.HttpResponseMessage is null) | ||
return; | ||
if (context.FlurlCall.HttpResponseMessage.StatusCode != HttpStatusCode.OK) | ||
return; | ||
|
||
byte[] respBytes = Array.Empty<byte>(); | ||
if (context.FlurlCall.HttpResponseMessage.Content is not null) | ||
{ | ||
HttpContent httpContent = context.FlurlCall.HttpResponseMessage.Content; | ||
respBytes = await | ||
#if NET5_0_OR_GREATER | ||
httpContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); | ||
#else | ||
_AsyncEx.RunTaskWithCancellationTokenAsync(httpContent.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false); | ||
#endif | ||
} | ||
|
||
byte[] respBytesDecrypted; | ||
try | ||
{ | ||
const int AES_BLOCK_SIZE = 16; | ||
byte[] keyBytes = Convert.FromBase64String(_encodingAESKey); | ||
byte[] ivBytes = new byte[AES_BLOCK_SIZE]; // iv 是 key 的前 16 个字节 | ||
Buffer.BlockCopy(keyBytes, 0, ivBytes, 0, ivBytes.Length); | ||
|
||
respBytesDecrypted = Utilities.AESUtility.DecryptWithCBC( | ||
keyBytes: keyBytes, | ||
ivBytes: ivBytes, | ||
cipherBytes: respBytes | ||
)!; | ||
} | ||
catch (Exception ex) | ||
{ | ||
throw new WechatOpenAIException("Failed to decrypt response. Please see the inner exception for more details.", ex); | ||
} | ||
|
||
context.FlurlCall.HttpResponseMessage!.Content?.Dispose(); | ||
context.FlurlCall.HttpResponseMessage!.Content = new ByteArrayContent(respBytesDecrypted); | ||
} | ||
|
||
private string GetRequestUrlPath(Uri uri) | ||
{ | ||
return uri.AbsoluteUri.Substring(0, uri.AbsoluteUri.Length - uri.Query.Length); | ||
} | ||
|
||
private bool IsRequestUrlPathMatched(Uri uri) | ||
{ | ||
string absoluteUrl = GetRequestUrlPath(uri); | ||
if (!absoluteUrl.StartsWith(_baseUrl)) | ||
return false; | ||
|
||
string relativeUrl = absoluteUrl.Substring(_baseUrl.TrimEnd('/').Length); | ||
if (!ENCRYPT_REQUIRED_URLS.Contains(relativeUrl)) | ||
{ | ||
if (_customEncryptedRequestPathMatcher is not null) | ||
return _customEncryptedRequestPathMatcher(relativeUrl); | ||
} | ||
|
||
return true; | ||
} | ||
} | ||
} |
56 changes: 56 additions & 0 deletions
56
src/SKIT.FlurlHttpClient.Wechat.OpenAI/Interceptors/WechatOpenAIRequestSigningInterceptor.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
using System; | ||
using System.Net.Http; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Flurl.Http; | ||
|
||
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Interceptors | ||
{ | ||
using SKIT.FlurlHttpClient.Internal; | ||
|
||
internal class WechatOpenAIRequestSigningInterceptor : HttpInterceptor | ||
{ | ||
private readonly string _token; | ||
|
||
public WechatOpenAIRequestSigningInterceptor(string token) | ||
{ | ||
_token = token; | ||
} | ||
|
||
public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default) | ||
{ | ||
if (context is null) throw new ArgumentNullException(nameof(context)); | ||
if (context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to sign request. This interceptor must be called before request completed."); | ||
|
||
if (context.FlurlCall.HttpRequestMessage.RequestUri is null) | ||
return; | ||
|
||
string timestamp = DateTimeOffset.Now.ToLocalTime().ToUnixTimeSeconds().ToString(); | ||
string nonce = Guid.NewGuid().ToString("N"); | ||
string body = string.Empty; | ||
if (context.FlurlCall.HttpRequestMessage?.Content is not null) | ||
{ | ||
if (context.FlurlCall.HttpRequestMessage.Content is MultipartFormDataContent) | ||
{ | ||
body = string.Empty; | ||
} | ||
else | ||
{ | ||
body = await | ||
#if NET5_0_OR_GREATER | ||
context.FlurlCall.HttpRequestMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||
#else | ||
_AsyncEx.RunTaskWithCancellationTokenAsync(context.FlurlCall.HttpRequestMessage.Content.ReadAsStringAsync(), cancellationToken).ConfigureAwait(false); | ||
#endif | ||
} | ||
} | ||
|
||
string signData = $"{_token}{timestamp}{nonce}{Utilities.MD5Utility.Hash(body).Value!.ToLower()}"; | ||
string sign = Utilities.MD5Utility.Hash(signData).Value!.ToLower(); | ||
|
||
context.FlurlCall.Request.WithHeader("timestamp", timestamp); | ||
context.FlurlCall.Request.WithHeader("nonce", nonce); | ||
context.FlurlCall.Request.WithHeader("sign", sign); | ||
} | ||
} | ||
} |
15 changes: 15 additions & 0 deletions
15
src/SKIT.FlurlHttpClient.Wechat.OpenAI/Models/OpenAI/Token/TokenV2Request.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Models | ||
{ | ||
/// <summary> | ||
/// <para>表示 [POST] /v2/token 接口的请求。</para> | ||
/// </summary> | ||
public class TokenV2Request : WechatOpenAIRequest | ||
{ | ||
/// <summary> | ||
/// 获取或设置操作数据的管理员 ID。 | ||
/// </summary> | ||
[Newtonsoft.Json.JsonProperty("account")] | ||
[System.Text.Json.Serialization.JsonPropertyName("account")] | ||
public string Account { get; set; } = string.Empty; | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
src/SKIT.FlurlHttpClient.Wechat.OpenAI/Models/OpenAI/Token/TokenV2Response.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Models | ||
{ | ||
/// <summary> | ||
/// <para>表示 [POST] /v2/token 接口的响应。</para> | ||
/// </summary> | ||
public class TokenV2Response : WechatOpenAIResponse<TokenV2Response.Types.Data> | ||
{ | ||
public static class Types | ||
{ | ||
public class Data | ||
{ | ||
/// <summary> | ||
/// 获取或设置接口访问令牌。 | ||
/// </summary> | ||
[Newtonsoft.Json.JsonProperty("access_token")] | ||
[System.Text.Json.Serialization.JsonPropertyName("access_token")] | ||
public string AccessToken { get; set; } = default!; | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.