Skip to content

Commit 921b968

Browse files
committed
feat(openai): 新增 OpenAI v2 版接口客户端,并实现加解密及签名中间件
1 parent ad9b5a1 commit 921b968

24 files changed

+807
-14
lines changed

docs/WechatOpenAI/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# SKIT.FlurlHttpClient.Wechat.OpenAI
1+
# SKIT.FlurlHttpClient.Wechat.OpenAI
22

33
基于 `Flurl.Http`[微信对话开放平台](https://chatbot.weixin.qq.com/) HTTP API SDK。
44

@@ -7,7 +7,8 @@
77
## 功能
88

99
- 基于微信对话开放平台 API 封装。
10-
- 提供了微信对话开放平台所需的 AES、SHA-1 等算法工具类。
10+
- 针对 v2 版接口,请求时自动生成签名,无需开发者手动干预。
11+
- 提供了微信对话开放平台所需的 AES、MD5、SHA-1 等算法工具类。
1112
- 提供了解析回调通知事件等扩展方法。
1213

1314
---

src/SKIT.FlurlHttpClient.Wechat.Api/Interceptors/WechatApiSecurityApiInterceptor.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.Collections.Specialized;
44
using System.Linq;
5+
using System.Net;
56
using System.Net.Http;
67
using System.Text;
78
using System.Text.Json;
@@ -299,12 +300,12 @@ public override async Task AfterCallAsync(HttpInterceptorContext context, Cancel
299300

300301
if (context.FlurlCall.HttpRequestMessage.Method != HttpMethod.Post)
301302
return;
302-
if (context.FlurlCall.HttpRequestMessage.RequestUri is null)
303-
return;
304-
if (!IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
303+
if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
305304
return;
306305
if (context.FlurlCall.HttpResponseMessage is null)
307306
return;
307+
if (context.FlurlCall.HttpResponseMessage.StatusCode != HttpStatusCode.OK)
308+
return;
308309

309310
string urlpath = GetRequestUrlPath(context.FlurlCall.HttpRequestMessage.RequestUri);
310311
byte[] respBytes = Array.Empty<byte>();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Flurl.Http;
6+
7+
namespace SKIT.FlurlHttpClient.Wechat.OpenAI
8+
{
9+
public static class WechatOpenAIClientExecuteTokenExtensions
10+
{
11+
/// <summary>
12+
/// <para>异步调用 [POST] /v2/token 接口。</para>
13+
/// <para>
14+
/// REF: <br/>
15+
/// <![CDATA[ https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html ]]>
16+
/// </para>
17+
/// </summary>
18+
/// <param name="client"></param>
19+
/// <param name="request"></param>
20+
/// <param name="cancellationToken"></param>
21+
/// <returns></returns>
22+
public static async Task<Models.TokenV2Response> ExecuteTokenV2Async(this WechatOpenAIClient client, Models.TokenV2Request request, CancellationToken cancellationToken = default)
23+
{
24+
if (client is null) throw new ArgumentNullException(nameof(client));
25+
if (request is null) throw new ArgumentNullException(nameof(request));
26+
27+
IFlurlRequest flurlReq = client
28+
.CreateFlurlRequest(request, HttpMethod.Post, "v2", "token");
29+
30+
return await client.SendFlurlRequestAsJsonAsync<Models.TokenV2Response>(flurlReq, data: request, cancellationToken: cancellationToken).ConfigureAwait(false);
31+
}
32+
}
33+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Flurl.Http;
8+
9+
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Interceptors
10+
{
11+
using SKIT.FlurlHttpClient.Internal;
12+
13+
internal class WechatOpenAIRequestEncryptionInterceptor : HttpInterceptor
14+
{
15+
/**
16+
* REF:
17+
* https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html
18+
*/
19+
private static readonly ISet<string> ENCRYPT_REQUIRED_URLS = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
20+
{
21+
"/v2/bot/query"
22+
};
23+
24+
private readonly string _baseUrl;
25+
private readonly string _encodingAESKey;
26+
private readonly Func<string, bool>? _customEncryptedRequestPathMatcher;
27+
28+
public WechatOpenAIRequestEncryptionInterceptor(string baseUrl, string encodingAESKey, Func<string, bool>? customEncryptedRequestPathMatcher)
29+
{
30+
_baseUrl = baseUrl;
31+
_encodingAESKey = encodingAESKey;
32+
_customEncryptedRequestPathMatcher = customEncryptedRequestPathMatcher;
33+
34+
// AES 密钥的长度不是 4 的倍数需要补齐,确保其始终为有效的 Base64 字符串
35+
const int MULTI = 4;
36+
int tLen = _encodingAESKey.Length;
37+
int tRem = tLen % MULTI;
38+
if (tRem > 0)
39+
{
40+
_encodingAESKey = _encodingAESKey.PadRight(tLen - tRem + MULTI, '=');
41+
}
42+
}
43+
44+
public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
45+
{
46+
if (context is null) throw new ArgumentNullException(nameof(context));
47+
if (context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to encrypt request. This interceptor must be called before request completed.");
48+
49+
if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
50+
return;
51+
52+
byte[] reqBytes = Array.Empty<byte>();
53+
if (context.FlurlCall.HttpRequestMessage?.Content is not null)
54+
{
55+
if (context.FlurlCall.HttpRequestMessage.Content is not MultipartFormDataContent)
56+
{
57+
reqBytes = await
58+
#if NET5_0_OR_GREATER
59+
context.FlurlCall.HttpRequestMessage.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
60+
#else
61+
_AsyncEx.RunTaskWithCancellationTokenAsync(context.FlurlCall.HttpRequestMessage.Content.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false);
62+
#endif
63+
}
64+
}
65+
66+
byte[] reqBytesEncrypted = Array.Empty<byte>();
67+
try
68+
{
69+
const int AES_BLOCK_SIZE = 16;
70+
byte[] keyBytes = Convert.FromBase64String(_encodingAESKey);
71+
byte[] ivBytes = new byte[AES_BLOCK_SIZE]; // iv 是 key 的前 16 个字节
72+
Buffer.BlockCopy(keyBytes, 0, ivBytes, 0, ivBytes.Length);
73+
74+
reqBytesEncrypted = Utilities.AESUtility.EncryptWithCBC(
75+
keyBytes: keyBytes,
76+
ivBytes: ivBytes,
77+
plainBytes: reqBytes
78+
)!;
79+
}
80+
catch (Exception ex)
81+
{
82+
throw new WechatOpenAIException("Failed to encrypt request. Please see the inner exception for more details.", ex);
83+
}
84+
85+
context.FlurlCall.HttpRequestMessage!.Content?.Dispose();
86+
context.FlurlCall.HttpRequestMessage!.Content = new ByteArrayContent(reqBytesEncrypted);
87+
context.FlurlCall.Request.WithHeader(HttpHeaders.ContentType, MimeTypes.Text);
88+
}
89+
90+
public override async Task AfterCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
91+
{
92+
if (context is null) throw new ArgumentNullException(nameof(context));
93+
if (!context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to decrypt response. This interceptor must be called after request completed.");
94+
95+
if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
96+
return;
97+
if (context.FlurlCall.HttpResponseMessage is null)
98+
return;
99+
if (context.FlurlCall.HttpResponseMessage.StatusCode != HttpStatusCode.OK)
100+
return;
101+
102+
byte[] respBytes = Array.Empty<byte>();
103+
if (context.FlurlCall.HttpResponseMessage.Content is not null)
104+
{
105+
HttpContent httpContent = context.FlurlCall.HttpResponseMessage.Content;
106+
respBytes = await
107+
#if NET5_0_OR_GREATER
108+
httpContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
109+
#else
110+
_AsyncEx.RunTaskWithCancellationTokenAsync(httpContent.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false);
111+
#endif
112+
}
113+
114+
byte[] respBytesDecrypted;
115+
try
116+
{
117+
const int AES_BLOCK_SIZE = 16;
118+
byte[] keyBytes = Convert.FromBase64String(_encodingAESKey);
119+
byte[] ivBytes = new byte[AES_BLOCK_SIZE]; // iv 是 key 的前 16 个字节
120+
Buffer.BlockCopy(keyBytes, 0, ivBytes, 0, ivBytes.Length);
121+
122+
respBytesDecrypted = Utilities.AESUtility.DecryptWithCBC(
123+
keyBytes: keyBytes,
124+
ivBytes: ivBytes,
125+
cipherBytes: respBytes
126+
)!;
127+
}
128+
catch (Exception ex)
129+
{
130+
throw new WechatOpenAIException("Failed to decrypt response. Please see the inner exception for more details.", ex);
131+
}
132+
133+
context.FlurlCall.HttpResponseMessage!.Content?.Dispose();
134+
context.FlurlCall.HttpResponseMessage!.Content = new ByteArrayContent(respBytesDecrypted);
135+
}
136+
137+
private string GetRequestUrlPath(Uri uri)
138+
{
139+
return uri.AbsoluteUri.Substring(0, uri.AbsoluteUri.Length - uri.Query.Length);
140+
}
141+
142+
private bool IsRequestUrlPathMatched(Uri uri)
143+
{
144+
string absoluteUrl = GetRequestUrlPath(uri);
145+
if (!absoluteUrl.StartsWith(_baseUrl))
146+
return false;
147+
148+
string relativeUrl = absoluteUrl.Substring(_baseUrl.TrimEnd('/').Length);
149+
if (!ENCRYPT_REQUIRED_URLS.Contains(relativeUrl))
150+
{
151+
if (_customEncryptedRequestPathMatcher is not null)
152+
return _customEncryptedRequestPathMatcher(relativeUrl);
153+
}
154+
155+
return true;
156+
}
157+
}
158+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Flurl.Http;
6+
7+
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Interceptors
8+
{
9+
using SKIT.FlurlHttpClient.Internal;
10+
11+
internal class WechatOpenAIRequestSigningInterceptor : HttpInterceptor
12+
{
13+
private readonly string _token;
14+
15+
public WechatOpenAIRequestSigningInterceptor(string token)
16+
{
17+
_token = token;
18+
}
19+
20+
public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
21+
{
22+
if (context is null) throw new ArgumentNullException(nameof(context));
23+
if (context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to sign request. This interceptor must be called before request completed.");
24+
25+
if (context.FlurlCall.HttpRequestMessage.RequestUri is null)
26+
return;
27+
28+
string timestamp = DateTimeOffset.Now.ToLocalTime().ToUnixTimeSeconds().ToString();
29+
string nonce = Guid.NewGuid().ToString("N");
30+
string body = string.Empty;
31+
if (context.FlurlCall.HttpRequestMessage?.Content is not null)
32+
{
33+
if (context.FlurlCall.HttpRequestMessage.Content is MultipartFormDataContent)
34+
{
35+
body = string.Empty;
36+
}
37+
else
38+
{
39+
body = await
40+
#if NET5_0_OR_GREATER
41+
context.FlurlCall.HttpRequestMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
42+
#else
43+
_AsyncEx.RunTaskWithCancellationTokenAsync(context.FlurlCall.HttpRequestMessage.Content.ReadAsStringAsync(), cancellationToken).ConfigureAwait(false);
44+
#endif
45+
}
46+
}
47+
48+
string signData = $"{_token}{timestamp}{nonce}{Utilities.MD5Utility.Hash(body).Value!.ToLower()}";
49+
string sign = Utilities.MD5Utility.Hash(signData).Value!.ToLower();
50+
51+
context.FlurlCall.Request.WithHeader("timestamp", timestamp);
52+
context.FlurlCall.Request.WithHeader("nonce", nonce);
53+
context.FlurlCall.Request.WithHeader("sign", sign);
54+
}
55+
}
56+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Models
2+
{
3+
/// <summary>
4+
/// <para>表示 [POST] /v2/token 接口的请求。</para>
5+
/// </summary>
6+
public class TokenV2Request : WechatOpenAIRequest
7+
{
8+
/// <summary>
9+
/// 获取或设置操作数据的管理员 ID。
10+
/// </summary>
11+
[Newtonsoft.Json.JsonProperty("account")]
12+
[System.Text.Json.Serialization.JsonPropertyName("account")]
13+
public string Account { get; set; } = string.Empty;
14+
}
15+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Models
2+
{
3+
/// <summary>
4+
/// <para>表示 [POST] /v2/token 接口的响应。</para>
5+
/// </summary>
6+
public class TokenV2Response : WechatOpenAIResponse<TokenV2Response.Types.Data>
7+
{
8+
public static class Types
9+
{
10+
public class Data
11+
{
12+
/// <summary>
13+
/// 获取或设置接口访问令牌。
14+
/// </summary>
15+
[Newtonsoft.Json.JsonProperty("access_token")]
16+
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
17+
public string AccessToken { get; set; } = default!;
18+
}
19+
}
20+
}
21+
}

src/SKIT.FlurlHttpClient.Wechat.OpenAI/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
### 【功能特性】
1010

1111
- 基于微信对话开放平台 API 封装。
12-
- 提供了微信对话开放平台所需的 AES、SHA-1 等算法工具类。
12+
- 针对 v2 版接口,请求时自动生成签名,无需开发者手动干预。
13+
- 提供了微信对话开放平台所需的 AES、MD5、SHA-1 等算法工具类。
1314
- 提供了解析回调通知事件等扩展方法。
1415

1516
---

src/SKIT.FlurlHttpClient.Wechat.OpenAI/Settings/Credentials.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,29 @@ namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Settings
55
public sealed class Credentials
66
{
77
/// <summary>
8-
/// 初始化客户端时 <see cref="WechatChatbotClientOptions.AppId"/> 的副本。
8+
/// 初始化客户端时 <see cref="WechatOpenAIClientOptions.AppId"/> / <see cref="WechatChatbotClientOptions.AppId"/> 的副本。
99
/// </summary>
1010
public string AppId { get; }
1111

1212
/// <summary>
13-
/// 初始化客户端时 <see cref="WechatChatbotClientOptions.Token"/> 的副本。
13+
/// 初始化客户端时 <see cref="WechatOpenAIClientOptions.Token"/> / <see cref="WechatChatbotClientOptions.Token"/> 的副本。
1414
/// </summary>
1515
public string Token { get; }
1616

1717
/// <summary>
18-
/// 初始化客户端时 <see cref="WechatChatbotClientOptions.EncodingAESKey"/> 的副本。
18+
/// 初始化客户端时 <see cref="WechatOpenAIClientOptions.EncodingAESKey"/> / <see cref="WechatChatbotClientOptions.EncodingAESKey"/> 的副本。
1919
/// </summary>
2020
public string EncodingAESKey { get; }
2121

22+
internal Credentials(WechatOpenAIClientOptions options)
23+
{
24+
if (options is null) throw new ArgumentNullException(nameof(options));
25+
26+
AppId = options.AppId;
27+
Token = options.Token;
28+
EncodingAESKey = options.EncodingAESKey;
29+
}
30+
2231
internal Credentials(WechatChatbotClientOptions options)
2332
{
2433
if (options is null) throw new ArgumentNullException(nameof(options));

0 commit comments

Comments
 (0)