diff --git a/docs/WechatOpenAI/README.md b/docs/WechatOpenAI/README.md
index c340fa28..4e6ec75f 100644
--- a/docs/WechatOpenAI/README.md
+++ b/docs/WechatOpenAI/README.md
@@ -1,12 +1,13 @@
# SKIT.FlurlHttpClient.Wechat.OpenAI
-基于 `Flurl.Http` 的[微信对话开放平台](https://openai.weixin.qq.com/) HTTP API SDK。
+基于 `Flurl.Http` 的[微信对话开放平台](https://chatbot.weixin.qq.com/) HTTP API SDK。
---
## 功能
- 基于微信对话开放平台 API 封装。
+- 提供了微信对话开放平台所需的 AES、SHA-1 等算法工具类。
- 提供了解析回调通知事件等扩展方法。
---
diff --git a/src/SKIT.FlurlHttpClient.Wechat.OpenAI/README.md b/src/SKIT.FlurlHttpClient.Wechat.OpenAI/README.md
index f839d8b4..0622a3e4 100644
--- a/src/SKIT.FlurlHttpClient.Wechat.OpenAI/README.md
+++ b/src/SKIT.FlurlHttpClient.Wechat.OpenAI/README.md
@@ -9,6 +9,7 @@
### 【功能特性】
- 基于微信对话开放平台 API 封装。
+- 提供了微信对话开放平台所需的 AES、SHA-1 等算法工具类。
- 提供了解析回调通知事件等扩展方法。
---
diff --git a/src/SKIT.FlurlHttpClient.Wechat.OpenAI/Utilities/AESUtility.cs b/src/SKIT.FlurlHttpClient.Wechat.OpenAI/Utilities/AESUtility.cs
new file mode 100644
index 00000000..4239b5bf
--- /dev/null
+++ b/src/SKIT.FlurlHttpClient.Wechat.OpenAI/Utilities/AESUtility.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Security.Cryptography;
+
+namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Utilities
+{
+ using SKIT.FlurlHttpClient.Primitives;
+
+ ///
+ /// AES 算法工具类。
+ ///
+ public static class AESUtility
+ {
+ ///
+ /// 基于 CBC 模式解密数据。
+ ///
+ /// AES 密钥字节数组。
+ /// 初始化向量字节数组。
+ /// 待解密数据字节数组。
+ /// 解密后的数据字节数组。
+ public static byte[] DecryptWithCBC(byte[] keyBytes, byte[] ivBytes, byte[] cipherBytes)
+ {
+ if (keyBytes is null) throw new ArgumentNullException(nameof(keyBytes));
+ if (ivBytes is null) throw new ArgumentNullException(nameof(ivBytes));
+ if (cipherBytes is null) throw new ArgumentNullException(nameof(cipherBytes));
+
+ using (SymmetricAlgorithm aes = Aes.Create())
+ {
+ aes.Mode = CipherMode.CBC;
+ aes.Padding = PaddingMode.PKCS7;
+ aes.Key = keyBytes;
+ aes.IV = ivBytes;
+
+ using ICryptoTransform transform = aes.CreateDecryptor();
+ return transform.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
+ }
+ }
+
+ ///
+ /// 基于 CBC 模式解密数据。
+ ///
+ /// 经过编码后的(通常为 Base64)AES 密钥。
+ /// 经过编码后的(通常为 Base64)初始化向量。
+ /// 经过编码后的(通常为 Base64)待解密数据。
+ /// 解密后的数据。
+ public static EncodedString DecryptWithCBC(EncodedString encodingKey, EncodedString encodingIV, EncodedString encodingCipher)
+ {
+ if (encodingKey.Value is null) throw new ArgumentNullException(nameof(encodingKey));
+ if (encodingIV.Value is null) throw new ArgumentNullException(nameof(encodingIV));
+ if (encodingCipher.Value is null) throw new ArgumentNullException(nameof(encodingCipher));
+
+ byte[] plainBytes = DecryptWithCBC(
+ keyBytes: EncodedString.FromString(encodingKey, fallbackEncodingKind: EncodingKinds.Base64),
+ ivBytes: EncodedString.FromString(encodingIV, fallbackEncodingKind: EncodingKinds.Base64),
+ cipherBytes: EncodedString.FromString(encodingCipher, fallbackEncodingKind: EncodingKinds.Base64)
+ );
+ return EncodedString.ToLiteralString(plainBytes);
+ }
+
+ ///
+ /// 基于 CBC 模式加密数据。
+ ///
+ /// AES 密钥字节数组。
+ /// 初始化向量字节数组。
+ /// 待加密数据字节数组。
+ /// 加密后的数据字节数组。
+ public static byte[] EncryptWithCBC(byte[] keyBytes, byte[] ivBytes, byte[] plainBytes)
+ {
+ if (keyBytes is null) throw new ArgumentNullException(nameof(keyBytes));
+ if (ivBytes is null) throw new ArgumentNullException(nameof(ivBytes));
+ if (plainBytes is null) throw new ArgumentNullException(nameof(plainBytes));
+
+ using (SymmetricAlgorithm aes = Aes.Create())
+ {
+ aes.Mode = CipherMode.CBC;
+ aes.Padding = PaddingMode.PKCS7;
+ aes.Key = keyBytes;
+ aes.IV = ivBytes;
+
+ using ICryptoTransform transform = aes.CreateEncryptor();
+ return transform.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
+ }
+ }
+
+ ///
+ /// 基于 CBC 模式加密数据。
+ ///
+ /// 经过编码后的(通常为 Base64)AES 密钥。
+ /// 经过编码后的(通常为 Base64)初始化向量。
+ /// 待加密数据。
+ /// 经过 Base64 编码的加密后的数据。
+ public static EncodedString EncryptWithCBC(EncodedString encodingKey, EncodedString encodingIV, string plainData)
+ {
+ if (encodingKey.Value is null) throw new ArgumentNullException(nameof(encodingKey));
+ if (encodingIV.Value is null) throw new ArgumentNullException(nameof(encodingIV));
+ if (plainData is null) throw new ArgumentNullException(nameof(plainData));
+
+ byte[] plainBytes = EncryptWithCBC(
+ keyBytes: EncodedString.FromString(encodingKey, fallbackEncodingKind: EncodingKinds.Base64),
+ ivBytes: EncodedString.FromString(encodingIV, fallbackEncodingKind: EncodingKinds.Base64),
+ plainBytes: EncodedString.FromLiteralString(plainData)
+ );
+ return EncodedString.ToBase64String(plainBytes);
+ }
+ }
+}
diff --git a/test/SKIT.FlurlHttpClient.Wechat.OpenAI.UnitTests/TestCase_ToolsAESUtilityTests.cs b/test/SKIT.FlurlHttpClient.Wechat.OpenAI.UnitTests/TestCase_ToolsAESUtilityTests.cs
new file mode 100644
index 00000000..4b71f4a7
--- /dev/null
+++ b/test/SKIT.FlurlHttpClient.Wechat.OpenAI.UnitTests/TestCase_ToolsAESUtilityTests.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Text;
+using Xunit;
+
+namespace SKIT.FlurlHttpClient.Wechat.OpenAI.UnitTests
+{
+ using SKIT.FlurlHttpClient.Primitives;
+
+ public class TestCase_ToolsAESUtilityTests
+ {
+ [Fact(DisplayName = "测试用例:AES-CBC 加密")]
+ public void TestAESCBCEncryption()
+ {
+ string key = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz=";
+ string iv = "q1Os1ZMe0nG28KUEx9lg3A=="; // iv 是 key 的前 16 个字节
+ string plainText = "{\"answer_type\":\"text\",\"text_info\":{\"short_answer\":\"answer\"}}";
+
+ string expectedCipherData = "aJhHfz6xc9iQiTLwusQe0HYKT6itYwq/YgQHltmLPf2UfpD+8ODJ8lrrxOMxy5NiALZqz1eYGtwD7cLQDP3ADg==";
+ string actualCipherData = Utilities.AESUtility.EncryptWithCBC(encodingKey: new EncodedString(key, EncodingKinds.Base64), encodingIV: new EncodedString(iv, EncodingKinds.Base64), plainData: plainText)!;
+
+ Assert.Equal(expectedCipherData, actualCipherData, ignoreCase: true);
+ }
+
+ [Fact(DisplayName = "测试用例:AES-CBC 解密")]
+ public void TestAESCBCDecryption()
+ {
+ string key = "q1Os1ZMe0nG28KUEx9lg3HjK7V5QyXvi212fzsgDqgz=";
+ string iv = "q1Os1ZMe0nG28KUEx9lg3A=="; // iv 是 key 的前 16 个字节
+ string cipherText = "rUWkvTY9vRPOeVDSH/IdNXHmvgsUQtPkp7QtBQjSS1tcuTHGPWv8O3PlxbnsjCogsM7+EY+As4yF2kp4yxXpP2U7RmbDsU/luRO/EqkpFFsoxMZZArz2XH1YeSdnDyHYPWzjiicBYjNiqqpTMX8ekrqooN0cCEH7JBcbEe6btmiK8hZkysKTUJfG1DTpbONxON5+YuVPelVpzW5ry9sRYLDcqhImMb9FqI+BlIVAIXt5g+e70rheSqpeXz98pEROx7yPeRi3tXPAibuwg+vKDhoN6LuM0hzvyNzPjwK2gMmQB5yVuBZUalYIIZTVaMNGu4H6RK6MovLyM2cKfMUTphKaBBKpAvsV0o4/QRY0MvxeRYvZAQXEzOG3dJ7BRB2KEqBKttT7jMK8MO5HEXDE0CJxtNI4Rjww9XYmPhBM7lOZSF97YNEg1NhwcXvUc3YcrR334PhWJeu2dZCHaJzBqVXFxq/WprNHM0Gw06o6p5oWb4/nzXKYbpJWDyqTN/aztwo5sppHwlYrzNzF7gERP691qoabTHiCd0H+Ea3t65gTyNo2+ssvS1RVsKubApS4BkbZb/EaZCTKP20pcvDBoJk3QLi8ObyBq8sIcLwVjzelLMUgCDa059gBuao+S9qdHXebEZyS49BqAxngMWjHU5uCRO/x2b9w8nwfCCT8b0Q=";
+
+ string expectedPlainData = "{\"RequestId\":\"123123456456789789123456789\",\"SessionId\":\"12345678901234567_12345678909876543\",\"Query\":\"北京限行尾号是多少\",\"SkillName\":\"限行\",\"IntentName\":\"查限行尾号\",\"Slots\":[{\"SlotName\":\"from_loc\",\"SlotValue\":\"北京\",\"NormalizeValue\":\"{\\\"type\\\":\\\"LOC_CHINA_CITY\\\",\\\"city\\\":\\\"北京市\\\",\\\"city_simple\\\":\\\"北京\\\",\\\"loc_ori\\\":\\\"北京\\\"}\"}],\"Timestamp\":1704135845,\"Signature\":\"96f439043e1f7d2bb38162e35406f173\",\"ThirdApiId\":1234,\"ThirdApiName\":\"车辆限行\",\"UserId\":\"97f7e892\"}";
+ string actualPlainData = Utilities.AESUtility.DecryptWithCBC(encodingKey: new EncodedString(key, EncodingKinds.Base64), encodingIV: new EncodedString(iv, EncodingKinds.Base64), encodingCipher: new EncodedString(cipherText, EncodingKinds.Base64))!;
+
+ Assert.Equal(expectedPlainData, actualPlainData, ignoreCase: true);
+ }
+ }
+}