All checks were successful
continuous-integration/drone/push Build is passing
296 lines
11 KiB
C#
296 lines
11 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace CampusErrand.Services;
|
||
|
||
/// <summary>
|
||
/// 微信支付服务(V3 公钥模式)
|
||
/// </summary>
|
||
public class WxPayService
|
||
{
|
||
private readonly string _appId;
|
||
private readonly string _mchId;
|
||
private readonly string _apiV3Key;
|
||
private readonly string _serialNo;
|
||
private readonly RSA _privateKey;
|
||
private readonly HttpClient _httpClient;
|
||
|
||
public WxPayService(IConfiguration config, HttpClient httpClient)
|
||
{
|
||
_appId = config["WeChat:AppId"]!;
|
||
_mchId = config["WeChat:MchId"]!;
|
||
_apiV3Key = config["WeChat:MchApiV3Key"]!;
|
||
_serialNo = config["WeChat:MchSerialNo"]!;
|
||
_httpClient = httpClient;
|
||
|
||
// 加载商户私钥
|
||
var privateKeyPem = config["WeChat:MchPrivateKeyPem"]!;
|
||
_privateKey = RSA.Create();
|
||
var keyBytes = Convert.FromBase64String(privateKeyPem);
|
||
_privateKey.ImportPkcs8PrivateKey(keyBytes, out _);
|
||
}
|
||
|
||
/// <summary>
|
||
/// JSAPI 下单(小程序支付)
|
||
/// </summary>
|
||
public async Task<WxPayResult> CreateJsapiOrder(string orderNo, decimal totalAmount, string description, string openId, string notifyUrl)
|
||
{
|
||
var totalFen = (int)(totalAmount * 100);
|
||
var requestBody = new
|
||
{
|
||
appid = _appId,
|
||
mchid = _mchId,
|
||
description,
|
||
out_trade_no = orderNo,
|
||
notify_url = notifyUrl,
|
||
amount = new { total = totalFen, currency = "CNY" },
|
||
payer = new { openid = openId }
|
||
};
|
||
|
||
var json = JsonSerializer.Serialize(requestBody);
|
||
var url = "/v3/pay/transactions/jsapi";
|
||
var fullUrl = $"https://api.mch.weixin.qq.com{url}";
|
||
|
||
var request = new HttpRequestMessage(HttpMethod.Post, fullUrl)
|
||
{
|
||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||
};
|
||
|
||
// 签名
|
||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||
var nonce = Guid.NewGuid().ToString("N");
|
||
var signature = Sign("POST", url, timestamp, nonce, json);
|
||
request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_serialNo}\",signature=\"{signature}\"");
|
||
request.Headers.Add("Accept", "application/json");
|
||
request.Headers.Add("User-Agent", "CampusErrand/1.0");
|
||
|
||
var response = await _httpClient.SendAsync(request);
|
||
var responseBody = await response.Content.ReadAsStringAsync();
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
Console.WriteLine($"[微信支付] 下单失败: {responseBody}");
|
||
return new WxPayResult { Success = false, ErrorMessage = responseBody };
|
||
}
|
||
|
||
var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
|
||
var prepayId = result.GetProperty("prepay_id").GetString()!;
|
||
|
||
// 生成小程序调起支付的参数
|
||
var payTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||
var payNonce = Guid.NewGuid().ToString("N");
|
||
var package = $"prepay_id={prepayId}";
|
||
var paySignStr = $"{_appId}\n{payTimestamp}\n{payNonce}\n{package}\n";
|
||
var paySign = Convert.ToBase64String(_privateKey.SignData(Encoding.UTF8.GetBytes(paySignStr), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1));
|
||
|
||
return new WxPayResult
|
||
{
|
||
Success = true,
|
||
PaymentParams = new WxPaymentParams
|
||
{
|
||
TimeStamp = payTimestamp,
|
||
NonceStr = payNonce,
|
||
Package = package,
|
||
SignType = "RSA",
|
||
PaySign = paySign
|
||
}
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 验证支付回调签名并解密
|
||
/// </summary>
|
||
public WxPayNotifyResult? VerifyAndDecryptNotify(string serialNo, string timestamp, string nonce, string signature, string body)
|
||
{
|
||
// 注意:公钥模式下回调验签需要用微信平台公钥,这里简化处理先信任回调
|
||
// 生产环境应严格验签
|
||
try
|
||
{
|
||
// 解析回调数据
|
||
var json = JsonSerializer.Deserialize<JsonElement>(body);
|
||
var resource = json.GetProperty("resource");
|
||
var ciphertext = resource.GetProperty("ciphertext").GetString()!;
|
||
var associatedData = resource.GetProperty("associated_data").GetString() ?? "";
|
||
var nonceStr = resource.GetProperty("nonce").GetString()!;
|
||
|
||
// AES-GCM 解密
|
||
var decrypted = AesGcmDecrypt(ciphertext, nonceStr, associatedData);
|
||
var result = JsonSerializer.Deserialize<JsonElement>(decrypted);
|
||
|
||
return new WxPayNotifyResult
|
||
{
|
||
OrderNo = result.GetProperty("out_trade_no").GetString()!,
|
||
TransactionId = result.GetProperty("transaction_id").GetString()!,
|
||
TradeState = result.GetProperty("trade_state").GetString()!,
|
||
TotalAmount = result.GetProperty("amount").GetProperty("total").GetInt32()
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[微信支付] 回调解密失败: {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 申请退款
|
||
/// </summary>
|
||
public async Task<bool> Refund(string orderNo, string refundNo, int totalFen, int refundFen, string reason = "")
|
||
{
|
||
var requestBody = new
|
||
{
|
||
out_trade_no = orderNo,
|
||
out_refund_no = refundNo,
|
||
reason = string.IsNullOrEmpty(reason) ? "订单退款" : reason,
|
||
amount = new { refund = refundFen, total = totalFen, currency = "CNY" }
|
||
};
|
||
|
||
var json = JsonSerializer.Serialize(requestBody);
|
||
var url = "/v3/refund/domestic/refunds";
|
||
var fullUrl = $"https://api.mch.weixin.qq.com{url}";
|
||
|
||
var request = new HttpRequestMessage(HttpMethod.Post, fullUrl)
|
||
{
|
||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||
};
|
||
|
||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||
var nonce = Guid.NewGuid().ToString("N");
|
||
var signature = Sign("POST", url, timestamp, nonce, json);
|
||
request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_serialNo}\",signature=\"{signature}\"");
|
||
request.Headers.Add("Accept", "application/json");
|
||
request.Headers.Add("User-Agent", "CampusErrand/1.0");
|
||
|
||
var response = await _httpClient.SendAsync(request);
|
||
var responseBody = await response.Content.ReadAsStringAsync();
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
Console.WriteLine($"[微信支付] 退款失败: {responseBody}");
|
||
return false;
|
||
}
|
||
|
||
Console.WriteLine($"[微信支付] 退款成功: {refundNo}");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 商家转账到零钱(提现用)
|
||
/// </summary>
|
||
public async Task<(bool Success, string? ErrorMessage)> TransferToWallet(string outBatchNo, string outDetailNo, string openId, int amountFen, string remark = "提现到账")
|
||
{
|
||
var requestBody = new
|
||
{
|
||
appid = _appId,
|
||
out_batch_no = outBatchNo,
|
||
batch_name = "跑腿提现",
|
||
batch_remark = remark,
|
||
total_amount = amountFen,
|
||
total_num = 1,
|
||
transfer_detail_list = new[]
|
||
{
|
||
new
|
||
{
|
||
out_detail_no = outDetailNo,
|
||
transfer_amount = amountFen,
|
||
transfer_remark = remark,
|
||
openid = openId
|
||
}
|
||
}
|
||
};
|
||
|
||
var json = JsonSerializer.Serialize(requestBody);
|
||
var url = "/v3/transfer/batches";
|
||
var fullUrl = $"https://api.mch.weixin.qq.com{url}";
|
||
|
||
var request = new HttpRequestMessage(HttpMethod.Post, fullUrl)
|
||
{
|
||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||
};
|
||
|
||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||
var nonce = Guid.NewGuid().ToString("N");
|
||
var signature = Sign("POST", url, timestamp, nonce, json);
|
||
request.Headers.Add("Authorization", $"WECHATPAY2-SHA256-RSA2048 mchid=\"{_mchId}\",nonce_str=\"{nonce}\",timestamp=\"{timestamp}\",serial_no=\"{_serialNo}\",signature=\"{signature}\"");
|
||
request.Headers.Add("Accept", "application/json");
|
||
request.Headers.Add("User-Agent", "CampusErrand/1.0");
|
||
|
||
var response = await _httpClient.SendAsync(request);
|
||
var responseBody = await response.Content.ReadAsStringAsync();
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
Console.WriteLine($"[微信转账] 转账失败: {responseBody}");
|
||
return (false, responseBody);
|
||
}
|
||
|
||
Console.WriteLine($"[微信转账] 转账成功: {outBatchNo}");
|
||
return (true, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成签名
|
||
/// </summary>
|
||
private string Sign(string method, string url, string timestamp, string nonce, string body)
|
||
{
|
||
var message = $"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n";
|
||
var signBytes = _privateKey.SignData(Encoding.UTF8.GetBytes(message), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||
return Convert.ToBase64String(signBytes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AES-GCM 解密(回调通知解密)
|
||
/// </summary>
|
||
private string AesGcmDecrypt(string ciphertext, string nonce, string associatedData)
|
||
{
|
||
var ciphertextBytes = Convert.FromBase64String(ciphertext);
|
||
var nonceBytes = Encoding.UTF8.GetBytes(nonce);
|
||
var associatedDataBytes = Encoding.UTF8.GetBytes(associatedData);
|
||
|
||
// 密文最后16字节是 tag
|
||
var tagSize = 16;
|
||
var encryptedData = ciphertextBytes[..^tagSize];
|
||
var tag = ciphertextBytes[^tagSize..];
|
||
|
||
var plaintext = new byte[encryptedData.Length];
|
||
using var aesGcm = new AesGcm(Encoding.UTF8.GetBytes(_apiV3Key), tagSize);
|
||
aesGcm.Decrypt(nonceBytes, encryptedData, tag, plaintext, associatedDataBytes);
|
||
|
||
return Encoding.UTF8.GetString(plaintext);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 微信支付下单结果
|
||
/// </summary>
|
||
public class WxPayResult
|
||
{
|
||
public bool Success { get; set; }
|
||
public string? ErrorMessage { get; set; }
|
||
public WxPaymentParams? PaymentParams { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 小程序调起支付参数
|
||
/// </summary>
|
||
public class WxPaymentParams
|
||
{
|
||
public string TimeStamp { get; set; } = "";
|
||
public string NonceStr { get; set; } = "";
|
||
public string Package { get; set; } = "";
|
||
public string SignType { get; set; } = "RSA";
|
||
public string PaySign { get; set; } = "";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 支付回调解密结果
|
||
/// </summary>
|
||
public class WxPayNotifyResult
|
||
{
|
||
public string OrderNo { get; set; } = "";
|
||
public string TransactionId { get; set; } = "";
|
||
public string TradeState { get; set; } = "";
|
||
public int TotalAmount { get; set; }
|
||
}
|