- 删除无数据库表的实体: UserDetail, UserAddress, PaymentOrder, Admin, AdminLoginLog, AdminOperationLog, Picture, Delivery - 删除关联服务: AddressService, PaymentService, PaymentOrderService, PaymentRewardDispatcher, DefaultPaymentRewardHandler - 删除关联接口: IAddressService, IPaymentService, IPaymentOrderService, IPaymentRewardHandler, IPaymentRewardDispatcher - 删除关联控制器: AddressController - 删除关联DTO: AddressModels, CreatePaymentOrderRequest, PaymentOrderDto, PaymentOrderQueryRequest - 删除关联测试: PaymentOrderServicePropertyTests, PaymentRewardDispatcherPropertyTests - 修复实体字段映射: User, UserLoginLog, UserRefreshToken, Config, OrderNotify - 更新 NotifyController 移除 IPaymentOrderService 依赖 - 更新 ServiceModule 移除已删除服务的DI注册 - 更新 MiAssessmentDbContext 移除已删除实体的DbSet和OnModelCreating配置
923 lines
35 KiB
C#
923 lines
35 KiB
C#
using System.Net.Http.Headers;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using MiAssessment.Core.Interfaces;
|
||
using MiAssessment.Model.Data;
|
||
using MiAssessment.Model.Entities;
|
||
using MiAssessment.Model.Models.Payment;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace MiAssessment.Core.Services;
|
||
|
||
/// <summary>
|
||
/// 微信支付 V3 服务实现
|
||
/// 提供基于 RSA-SHA256 签名和 AES-256-GCM 加密的 V3 版本支付功能
|
||
/// </summary>
|
||
public class WechatPayV3Service : IWechatPayV3Service
|
||
{
|
||
private readonly MiAssessmentDbContext _dbContext;
|
||
private readonly HttpClient _httpClient;
|
||
private readonly ILogger<WechatPayV3Service> _logger;
|
||
private readonly IWechatPayConfigService _configService;
|
||
|
||
/// <summary>
|
||
/// V3 JSAPI 下单 API 地址
|
||
/// </summary>
|
||
private const string V3_JSAPI_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi";
|
||
|
||
/// <summary>
|
||
/// V3 订单查询 API 地址(商户订单号)
|
||
/// </summary>
|
||
private const string V3_QUERY_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{0}";
|
||
|
||
/// <summary>
|
||
/// V3 关闭订单 API 地址
|
||
/// </summary>
|
||
private const string V3_CLOSE_URL = "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{0}/close";
|
||
|
||
/// <summary>
|
||
/// V3 退款 API 地址
|
||
/// </summary>
|
||
private const string V3_REFUND_URL = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds";
|
||
|
||
/// <summary>
|
||
/// 随机字符串字符集
|
||
/// </summary>
|
||
private const string NONCE_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||
|
||
public WechatPayV3Service(
|
||
MiAssessmentDbContext dbContext,
|
||
HttpClient httpClient,
|
||
ILogger<WechatPayV3Service> logger,
|
||
IWechatPayConfigService configService)
|
||
{
|
||
_dbContext = dbContext;
|
||
_httpClient = httpClient;
|
||
_logger = logger;
|
||
_configService = configService;
|
||
}
|
||
|
||
#region 下单接口
|
||
|
||
/// <inheritdoc />
|
||
public async Task<WechatPayResult> CreateJsapiOrderAsync(WechatPayRequest request)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation("开始创建 V3 JSAPI 支付订单: OrderNo={OrderNo}, UserId={UserId}, Amount={Amount}",
|
||
request.OrderNo, request.UserId, request.Amount);
|
||
|
||
// 1. 获取用户信息和 OpenId
|
||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == request.UserId);
|
||
if (user == null)
|
||
{
|
||
_logger.LogWarning("用户不存在: UserId={UserId}", request.UserId);
|
||
return new WechatPayResult { Status = 0, Msg = "用户不存在" };
|
||
}
|
||
|
||
var openId = string.IsNullOrEmpty(request.OpenId) ? user.OpenId : request.OpenId;
|
||
if (string.IsNullOrEmpty(openId))
|
||
{
|
||
_logger.LogWarning("用户 OpenId 为空: UserId={UserId}", request.UserId);
|
||
return new WechatPayResult { Status = 0, Msg = "用户 OpenId 不存在" };
|
||
}
|
||
|
||
// 2. 获取商户配置
|
||
var merchantConfig = _configService.GetMerchantByOrderNo(request.OrderNo);
|
||
|
||
// 验证 V3 配置
|
||
if (string.IsNullOrEmpty(merchantConfig.ApiV3Key) ||
|
||
string.IsNullOrEmpty(merchantConfig.CertSerialNo) ||
|
||
string.IsNullOrEmpty(merchantConfig.PrivateKeyPath))
|
||
{
|
||
_logger.LogError("V3 配置不完整: MchId={MchId}", merchantConfig.MchId);
|
||
return new WechatPayResult { Status = 0, Msg = "V3 支付配置不完整" };
|
||
}
|
||
|
||
_logger.LogDebug("使用 V3 商户配置: MchId={MchId}, AppId={AppId}", merchantConfig.MchId, merchantConfig.AppId);
|
||
|
||
// 3. 读取私钥
|
||
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath);
|
||
if (string.IsNullOrEmpty(privateKey))
|
||
{
|
||
_logger.LogError("读取私钥失败: Path={Path}", merchantConfig.PrivateKeyPath);
|
||
return new WechatPayResult { Status = 0, Msg = "读取商户私钥失败" };
|
||
}
|
||
|
||
// 4. 构建 V3 请求
|
||
var totalFee = (int)Math.Round(request.Amount * 100); // 转换为分
|
||
var v3Request = new WechatPayV3JsapiRequest
|
||
{
|
||
AppId = merchantConfig.AppId,
|
||
MchId = merchantConfig.MchId,
|
||
Description = TruncateDescription(request.Body, 127),
|
||
OutTradeNo = request.OrderNo,
|
||
NotifyUrl = merchantConfig.NotifyUrl,
|
||
Amount = new WechatPayV3Amount { Total = totalFee, Currency = "CNY" },
|
||
Payer = new WechatPayV3Payer { OpenId = openId },
|
||
Attach = request.Attach
|
||
};
|
||
|
||
var requestBody = JsonSerializer.Serialize(v3Request, new JsonSerializerOptions
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||
});
|
||
|
||
// 5. 生成签名并发送请求
|
||
var timestamp = GetTimestamp();
|
||
var nonceStr = GenerateNonceStr();
|
||
var url = "/v3/pay/transactions/jsapi";
|
||
var signature = GenerateSignature("POST", url, timestamp, nonceStr, requestBody, privateKey);
|
||
|
||
// 6. 构建 Authorization 头
|
||
var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\"";
|
||
|
||
// 7. 发送请求
|
||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, V3_JSAPI_URL);
|
||
httpRequest.Headers.Add("Authorization", authorization);
|
||
httpRequest.Headers.Add("Accept", "application/json");
|
||
httpRequest.Headers.Add("User-Agent", "MiAssessment/1.0");
|
||
httpRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
|
||
|
||
_logger.LogDebug("V3 下单请求: URL={Url}, Body={Body}", V3_JSAPI_URL, requestBody);
|
||
|
||
var response = await _httpClient.SendAsync(httpRequest);
|
||
var responseContent = await response.Content.ReadAsStringAsync();
|
||
|
||
_logger.LogDebug("V3 下单响应: StatusCode={StatusCode}, Body={Body}", response.StatusCode, responseContent);
|
||
|
||
// 8. 处理响应
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
var errorResponse = JsonSerializer.Deserialize<WechatPayV3ErrorResponse>(responseContent);
|
||
var errorMsg = GetV3ErrorMessage(errorResponse?.Code ?? "UNKNOWN", errorResponse?.Message ?? "未知错误");
|
||
_logger.LogWarning("V3 下单失败: Code={Code}, Message={Message}", errorResponse?.Code, errorResponse?.Message);
|
||
return new WechatPayResult { Status = 0, Msg = $"支付失败({errorMsg})" };
|
||
}
|
||
|
||
var v3Response = JsonSerializer.Deserialize<WechatPayV3JsapiResponse>(responseContent);
|
||
if (v3Response == null || string.IsNullOrEmpty(v3Response.PrepayId))
|
||
{
|
||
_logger.LogError("V3 下单成功但 prepay_id 为空");
|
||
return new WechatPayResult { Status = 0, Msg = "网络故障,请稍后重试(prepay_id为空)" };
|
||
}
|
||
|
||
// 9. 保存订单通知记录
|
||
await SaveOrderNotifyAsync(request.OrderNo, merchantConfig.NotifyUrl, nonceStr, request.Amount, request.Attach, openId);
|
||
|
||
// 10. 构建返回给前端的支付参数(V3 使用 RSA 签名)
|
||
var payTimestamp = GetTimestamp();
|
||
var payNonceStr = GenerateNonceStr();
|
||
var packageStr = $"prepay_id={v3Response.PrepayId}";
|
||
var paySign = GeneratePaySign(merchantConfig.AppId, payTimestamp, payNonceStr, v3Response.PrepayId, privateKey);
|
||
|
||
_logger.LogInformation("V3 支付订单创建成功: OrderNo={OrderNo}, PrepayId={PrepayId}", request.OrderNo, v3Response.PrepayId);
|
||
|
||
return new WechatPayResult
|
||
{
|
||
Status = 1,
|
||
Msg = "success",
|
||
Data = new WechatPayData
|
||
{
|
||
AppId = merchantConfig.AppId,
|
||
TimeStamp = payTimestamp,
|
||
NonceStr = payNonceStr,
|
||
Package = packageStr,
|
||
SignType = "RSA",
|
||
PaySign = paySign,
|
||
IsWeixin = 1
|
||
}
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "创建 V3 支付订单异常: OrderNo={OrderNo}", request.OrderNo);
|
||
return new WechatPayResult { Status = 0, Msg = "系统错误,请稍后重试" };
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 订单管理接口
|
||
|
||
/// <inheritdoc />
|
||
public async Task<WechatPayV3QueryResult> QueryOrderAsync(string orderNo)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation("开始查询 V3 订单: OrderNo={OrderNo}", orderNo);
|
||
|
||
var merchantConfig = _configService.GetMerchantByOrderNo(orderNo);
|
||
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!);
|
||
|
||
var url = $"/v3/pay/transactions/out-trade-no/{orderNo}?mchid={merchantConfig.MchId}";
|
||
var fullUrl = string.Format(V3_QUERY_URL, orderNo) + $"?mchid={merchantConfig.MchId}";
|
||
|
||
var timestamp = GetTimestamp();
|
||
var nonceStr = GenerateNonceStr();
|
||
var signature = GenerateSignature("GET", url, timestamp, nonceStr, "", privateKey);
|
||
|
||
var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\"";
|
||
|
||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, fullUrl);
|
||
httpRequest.Headers.Add("Authorization", authorization);
|
||
httpRequest.Headers.Add("Accept", "application/json");
|
||
|
||
var response = await _httpClient.SendAsync(httpRequest);
|
||
var responseContent = await response.Content.ReadAsStringAsync();
|
||
|
||
_logger.LogDebug("V3 订单查询响应: StatusCode={StatusCode}, Body={Body}", response.StatusCode, responseContent);
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
var errorResponse = JsonSerializer.Deserialize<WechatPayV3ErrorResponse>(responseContent);
|
||
return new WechatPayV3QueryResult
|
||
{
|
||
Success = false,
|
||
ErrorCode = errorResponse?.Code,
|
||
ErrorMessage = errorResponse?.Message
|
||
};
|
||
}
|
||
|
||
var queryResponse = JsonSerializer.Deserialize<WechatPayV3QueryResponse>(responseContent);
|
||
return new WechatPayV3QueryResult
|
||
{
|
||
Success = true,
|
||
TradeState = queryResponse?.TradeState ?? "",
|
||
TradeStateDesc = queryResponse?.TradeStateDesc ?? "",
|
||
TransactionId = queryResponse?.TransactionId,
|
||
OutTradeNo = queryResponse?.OutTradeNo,
|
||
TotalAmount = queryResponse?.Amount?.Total,
|
||
SuccessTime = queryResponse?.SuccessTime
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "查询 V3 订单异常: OrderNo={OrderNo}", orderNo);
|
||
return new WechatPayV3QueryResult
|
||
{
|
||
Success = false,
|
||
ErrorCode = "SYSTEM_ERROR",
|
||
ErrorMessage = ex.Message
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<WechatPayV3CloseResult> CloseOrderAsync(string orderNo)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation("开始关闭 V3 订单: OrderNo={OrderNo}", orderNo);
|
||
|
||
var merchantConfig = _configService.GetMerchantByOrderNo(orderNo);
|
||
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!);
|
||
|
||
var url = $"/v3/pay/transactions/out-trade-no/{orderNo}/close";
|
||
var fullUrl = string.Format(V3_CLOSE_URL, orderNo);
|
||
|
||
var requestBody = JsonSerializer.Serialize(new WechatPayV3CloseRequest { MchId = merchantConfig.MchId });
|
||
|
||
var timestamp = GetTimestamp();
|
||
var nonceStr = GenerateNonceStr();
|
||
var signature = GenerateSignature("POST", url, timestamp, nonceStr, requestBody, privateKey);
|
||
|
||
var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\"";
|
||
|
||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, fullUrl);
|
||
httpRequest.Headers.Add("Authorization", authorization);
|
||
httpRequest.Headers.Add("Accept", "application/json");
|
||
httpRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
|
||
|
||
var response = await _httpClient.SendAsync(httpRequest);
|
||
|
||
_logger.LogDebug("V3 关闭订单响应: StatusCode={StatusCode}", response.StatusCode);
|
||
|
||
// HTTP 204 表示成功
|
||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||
{
|
||
_logger.LogInformation("V3 订单关闭成功: OrderNo={OrderNo}", orderNo);
|
||
return new WechatPayV3CloseResult { Success = true };
|
||
}
|
||
|
||
var responseContent = await response.Content.ReadAsStringAsync();
|
||
var errorResponse = JsonSerializer.Deserialize<WechatPayV3ErrorResponse>(responseContent);
|
||
return new WechatPayV3CloseResult
|
||
{
|
||
Success = false,
|
||
ErrorCode = errorResponse?.Code,
|
||
ErrorMessage = errorResponse?.Message
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "关闭 V3 订单异常: OrderNo={OrderNo}", orderNo);
|
||
return new WechatPayV3CloseResult
|
||
{
|
||
Success = false,
|
||
ErrorCode = "SYSTEM_ERROR",
|
||
ErrorMessage = ex.Message
|
||
};
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 退款接口
|
||
|
||
/// <inheritdoc />
|
||
public async Task<WechatPayV3RefundResult> RefundAsync(WechatPayV3RefundRequest request)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation("开始 V3 退款: OrderNo={OrderNo}, RefundNo={RefundNo}, RefundAmount={RefundAmount}",
|
||
request.OrderNo, request.RefundNo, request.RefundAmount);
|
||
|
||
var merchantConfig = _configService.GetMerchantByOrderNo(request.OrderNo);
|
||
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!);
|
||
|
||
var apiRequest = new WechatPayV3RefundApiRequest
|
||
{
|
||
OutTradeNo = request.OrderNo,
|
||
TransactionId = request.TransactionId,
|
||
OutRefundNo = request.RefundNo,
|
||
Reason = request.Reason,
|
||
NotifyUrl = request.NotifyUrl,
|
||
Amount = new WechatPayV3RefundAmount
|
||
{
|
||
Refund = request.RefundAmount,
|
||
Total = request.TotalAmount,
|
||
Currency = "CNY"
|
||
}
|
||
};
|
||
|
||
var requestBody = JsonSerializer.Serialize(apiRequest, new JsonSerializerOptions
|
||
{
|
||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||
});
|
||
|
||
var url = "/v3/refund/domestic/refunds";
|
||
var timestamp = GetTimestamp();
|
||
var nonceStr = GenerateNonceStr();
|
||
var signature = GenerateSignature("POST", url, timestamp, nonceStr, requestBody, privateKey);
|
||
|
||
var authorization = $"WECHATPAY2-SHA256-RSA2048 mchid=\"{merchantConfig.MchId}\",nonce_str=\"{nonceStr}\",timestamp=\"{timestamp}\",serial_no=\"{merchantConfig.CertSerialNo}\",signature=\"{signature}\"";
|
||
|
||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, V3_REFUND_URL);
|
||
httpRequest.Headers.Add("Authorization", authorization);
|
||
httpRequest.Headers.Add("Accept", "application/json");
|
||
httpRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
|
||
|
||
var response = await _httpClient.SendAsync(httpRequest);
|
||
var responseContent = await response.Content.ReadAsStringAsync();
|
||
|
||
_logger.LogDebug("V3 退款响应: StatusCode={StatusCode}, Body={Body}", response.StatusCode, responseContent);
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
var errorResponse = JsonSerializer.Deserialize<WechatPayV3ErrorResponse>(responseContent);
|
||
return new WechatPayV3RefundResult
|
||
{
|
||
Success = false,
|
||
ErrorCode = errorResponse?.Code,
|
||
ErrorMessage = errorResponse?.Message
|
||
};
|
||
}
|
||
|
||
var refundResponse = JsonSerializer.Deserialize<WechatPayV3RefundApiResponse>(responseContent);
|
||
return new WechatPayV3RefundResult
|
||
{
|
||
Success = true,
|
||
RefundId = refundResponse?.RefundId,
|
||
OutRefundNo = refundResponse?.OutRefundNo,
|
||
Status = refundResponse?.Status,
|
||
RefundAmount = refundResponse?.Amount?.Refund
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "V3 退款异常: OrderNo={OrderNo}", request.OrderNo);
|
||
return new WechatPayV3RefundResult
|
||
{
|
||
Success = false,
|
||
ErrorCode = "SYSTEM_ERROR",
|
||
ErrorMessage = ex.Message
|
||
};
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 签名与验签
|
||
|
||
/// <inheritdoc />
|
||
public string GenerateSignature(string method, string url, string timestamp, string nonce, string body, string privateKey)
|
||
{
|
||
// 构建签名字符串
|
||
// 格式:HTTP方法\nURL\n时间戳\n随机串\n请求体\n
|
||
var signatureString = $"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n";
|
||
|
||
// 使用 RSA-SHA256 签名
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(privateKey);
|
||
|
||
var signatureBytes = rsa.SignData(
|
||
Encoding.UTF8.GetBytes(signatureString),
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1);
|
||
|
||
return Convert.ToBase64String(signatureBytes);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public bool VerifyNotifySignature(string timestamp, string nonce, string body, string signature, string serialNo)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogDebug("开始验证 V3 回调签名: Timestamp={Timestamp}, Nonce={Nonce}, SerialNo={SerialNo}",
|
||
timestamp, nonce, serialNo);
|
||
|
||
// 参数验证
|
||
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(nonce) ||
|
||
string.IsNullOrEmpty(body) || string.IsNullOrEmpty(signature))
|
||
{
|
||
_logger.LogWarning("V3 回调签名验证参数不完整");
|
||
return false;
|
||
}
|
||
|
||
// 获取商户配置
|
||
var merchantConfig = _configService.GetDefaultConfig();
|
||
|
||
if (string.IsNullOrEmpty(merchantConfig.WechatPublicKeyPath))
|
||
{
|
||
_logger.LogError("微信支付公钥路径未配置");
|
||
return false;
|
||
}
|
||
|
||
// 验证公钥ID是否匹配(如果配置了)
|
||
if (!string.IsNullOrEmpty(merchantConfig.WechatPublicKeyId) &&
|
||
!string.IsNullOrEmpty(serialNo) &&
|
||
merchantConfig.WechatPublicKeyId != serialNo)
|
||
{
|
||
_logger.LogWarning("微信支付公钥ID不匹配: Expected={Expected}, Actual={Actual}",
|
||
merchantConfig.WechatPublicKeyId, serialNo);
|
||
// 继续验证,因为可能是微信更换了公钥
|
||
}
|
||
|
||
var publicKey = ReadPublicKey(merchantConfig.WechatPublicKeyPath);
|
||
if (string.IsNullOrEmpty(publicKey))
|
||
{
|
||
_logger.LogError("读取微信支付公钥失败: Path={Path}", merchantConfig.WechatPublicKeyPath);
|
||
return false;
|
||
}
|
||
|
||
// 构建验签字符串
|
||
// 格式:时间戳\n随机串\n请求体\n
|
||
var verifyString = $"{timestamp}\n{nonce}\n{body}\n";
|
||
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(publicKey);
|
||
|
||
var signatureBytes = Convert.FromBase64String(signature);
|
||
var isValid = rsa.VerifyData(
|
||
Encoding.UTF8.GetBytes(verifyString),
|
||
signatureBytes,
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1);
|
||
|
||
if (isValid)
|
||
{
|
||
_logger.LogDebug("V3 回调签名验证成功");
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning("V3 回调签名验证失败");
|
||
}
|
||
|
||
return isValid;
|
||
}
|
||
catch (FormatException ex)
|
||
{
|
||
_logger.LogError(ex, "V3 回调签名 Base64 解码失败");
|
||
return false;
|
||
}
|
||
catch (CryptographicException ex)
|
||
{
|
||
_logger.LogError(ex, "V3 回调签名验证加密异常");
|
||
return false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "验证回调签名异常");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用指定的公钥验证回调签名
|
||
/// </summary>
|
||
/// <param name="timestamp">时间戳</param>
|
||
/// <param name="nonce">随机串</param>
|
||
/// <param name="body">请求体</param>
|
||
/// <param name="signature">签名</param>
|
||
/// <param name="publicKey">公钥 PEM 内容</param>
|
||
/// <returns>签名是否有效</returns>
|
||
public bool VerifyNotifySignatureWithPublicKey(string timestamp, string nonce, string body, string signature, string publicKey)
|
||
{
|
||
try
|
||
{
|
||
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(nonce) ||
|
||
string.IsNullOrEmpty(body) || string.IsNullOrEmpty(signature) ||
|
||
string.IsNullOrEmpty(publicKey))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 构建验签字符串
|
||
var verifyString = $"{timestamp}\n{nonce}\n{body}\n";
|
||
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(publicKey);
|
||
|
||
var signatureBytes = Convert.FromBase64String(signature);
|
||
return rsa.VerifyData(
|
||
Encoding.UTF8.GetBytes(verifyString),
|
||
signatureBytes,
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "使用指定公钥验证签名异常");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 加解密
|
||
|
||
/// <inheritdoc />
|
||
public string DecryptNotifyResource(string ciphertext, string nonce, string associatedData, string apiV3Key)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogDebug("开始解密 V3 回调数据: Nonce={Nonce}, AssociatedData={AssociatedData}",
|
||
nonce, associatedData);
|
||
|
||
// 参数验证
|
||
if (string.IsNullOrEmpty(ciphertext))
|
||
{
|
||
throw new ArgumentException("密文不能为空", nameof(ciphertext));
|
||
}
|
||
if (string.IsNullOrEmpty(nonce))
|
||
{
|
||
throw new ArgumentException("随机串不能为空", nameof(nonce));
|
||
}
|
||
if (string.IsNullOrEmpty(apiV3Key))
|
||
{
|
||
throw new ArgumentException("APIv3 密钥不能为空", nameof(apiV3Key));
|
||
}
|
||
if (apiV3Key.Length != 32)
|
||
{
|
||
throw new ArgumentException("APIv3 密钥长度必须为 32 字节", nameof(apiV3Key));
|
||
}
|
||
|
||
var ciphertextBytes = Convert.FromBase64String(ciphertext);
|
||
var nonceBytes = Encoding.UTF8.GetBytes(nonce);
|
||
var associatedDataBytes = string.IsNullOrEmpty(associatedData)
|
||
? Array.Empty<byte>()
|
||
: Encoding.UTF8.GetBytes(associatedData);
|
||
var keyBytes = Encoding.UTF8.GetBytes(apiV3Key);
|
||
|
||
// AES-256-GCM 解密
|
||
// 密文最后 16 字节是 authentication tag
|
||
const int tagLength = 16;
|
||
|
||
if (ciphertextBytes.Length < tagLength)
|
||
{
|
||
throw new ArgumentException("密文长度不足,无法提取认证标签", nameof(ciphertext));
|
||
}
|
||
|
||
var actualCiphertext = ciphertextBytes[..^tagLength];
|
||
var tag = ciphertextBytes[^tagLength..];
|
||
|
||
using var aesGcm = new AesGcm(keyBytes, tagLength);
|
||
var plaintext = new byte[actualCiphertext.Length];
|
||
aesGcm.Decrypt(nonceBytes, actualCiphertext, tag, plaintext, associatedDataBytes);
|
||
|
||
var result = Encoding.UTF8.GetString(plaintext);
|
||
_logger.LogDebug("V3 回调数据解密成功");
|
||
return result;
|
||
}
|
||
catch (FormatException ex)
|
||
{
|
||
_logger.LogError(ex, "V3 回调数据 Base64 解码失败");
|
||
throw new InvalidOperationException("密文 Base64 解码失败", ex);
|
||
}
|
||
catch (CryptographicException ex)
|
||
{
|
||
_logger.LogError(ex, "V3 回调数据解密失败(可能是密钥错误或数据被篡改)");
|
||
throw new InvalidOperationException("解密失败,可能是密钥错误或数据被篡改", ex);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "解密回调数据失败");
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 使用 AES-256-GCM 加密数据(用于测试)
|
||
/// </summary>
|
||
/// <param name="plaintext">明文</param>
|
||
/// <param name="nonce">随机串(12 字节)</param>
|
||
/// <param name="associatedData">附加数据</param>
|
||
/// <param name="apiV3Key">APIv3 密钥(32 字节)</param>
|
||
/// <returns>Base64 编码的密文(包含认证标签)</returns>
|
||
public string EncryptNotifyResource(string plaintext, string nonce, string associatedData, string apiV3Key)
|
||
{
|
||
if (string.IsNullOrEmpty(plaintext))
|
||
{
|
||
throw new ArgumentException("明文不能为空", nameof(plaintext));
|
||
}
|
||
if (string.IsNullOrEmpty(nonce))
|
||
{
|
||
throw new ArgumentException("随机串不能为空", nameof(nonce));
|
||
}
|
||
if (string.IsNullOrEmpty(apiV3Key) || apiV3Key.Length != 32)
|
||
{
|
||
throw new ArgumentException("APIv3 密钥长度必须为 32 字节", nameof(apiV3Key));
|
||
}
|
||
|
||
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||
var nonceBytes = Encoding.UTF8.GetBytes(nonce);
|
||
var associatedDataBytes = string.IsNullOrEmpty(associatedData)
|
||
? Array.Empty<byte>()
|
||
: Encoding.UTF8.GetBytes(associatedData);
|
||
var keyBytes = Encoding.UTF8.GetBytes(apiV3Key);
|
||
|
||
const int tagLength = 16;
|
||
var ciphertext = new byte[plaintextBytes.Length];
|
||
var tag = new byte[tagLength];
|
||
|
||
using var aesGcm = new AesGcm(keyBytes, tagLength);
|
||
aesGcm.Encrypt(nonceBytes, plaintextBytes, ciphertext, tag, associatedDataBytes);
|
||
|
||
// 将密文和标签合并
|
||
var result = new byte[ciphertext.Length + tag.Length];
|
||
ciphertext.CopyTo(result, 0);
|
||
tag.CopyTo(result, ciphertext.Length);
|
||
|
||
return Convert.ToBase64String(result);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 回调格式识别
|
||
|
||
/// <summary>
|
||
/// 检测回调数据是否为 V3 格式
|
||
/// V3 格式特征:JSON 格式且包含 resource 字段
|
||
/// </summary>
|
||
/// <param name="notifyBody">回调请求体</param>
|
||
/// <returns>是否为 V3 格式</returns>
|
||
public bool IsV3NotifyFormat(string notifyBody)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(notifyBody))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var trimmedBody = notifyBody.TrimStart();
|
||
|
||
// V3 格式是 JSON,以 { 开头
|
||
if (!trimmedBody.StartsWith('{'))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 尝试解析为 JSON 并检查是否包含 resource 字段
|
||
using var doc = JsonDocument.Parse(notifyBody);
|
||
var root = doc.RootElement;
|
||
|
||
// V3 回调必须包含 resource 字段
|
||
return root.TryGetProperty("resource", out _);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// JSON 解析失败,不是 V3 格式
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检测回调数据是否为 V2 格式
|
||
/// V2 格式特征:XML 格式,以 <xml> 开头
|
||
/// </summary>
|
||
/// <param name="notifyBody">回调请求体</param>
|
||
/// <returns>是否为 V2 格式</returns>
|
||
public bool IsV2NotifyFormat(string notifyBody)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(notifyBody))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var trimmedBody = notifyBody.TrimStart();
|
||
|
||
// V2 格式是 XML,以 < 开头
|
||
if (!trimmedBody.StartsWith('<'))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 检查是否包含 <xml> 标签(不区分大小写)
|
||
return trimmedBody.Contains("<xml>", StringComparison.OrdinalIgnoreCase) ||
|
||
trimmedBody.Contains("<xml ", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检测回调格式并返回版本
|
||
/// </summary>
|
||
/// <param name="notifyBody">回调请求体</param>
|
||
/// <returns>回调版本:V3、V2 或 Unknown</returns>
|
||
public NotifyVersion DetectNotifyVersion(string notifyBody)
|
||
{
|
||
if (IsV3NotifyFormat(notifyBody))
|
||
{
|
||
return NotifyVersion.V3;
|
||
}
|
||
|
||
if (IsV2NotifyFormat(notifyBody))
|
||
{
|
||
return NotifyVersion.V2;
|
||
}
|
||
|
||
return NotifyVersion.Unknown;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 辅助方法
|
||
|
||
/// <inheritdoc />
|
||
public string GeneratePaySign(string appId, string timestamp, string nonceStr, string prepayId, string privateKey)
|
||
{
|
||
// 小程序调起支付签名字符串
|
||
// 格式:appId\n时间戳\n随机串\nprepay_id=xxx\n
|
||
var signatureString = $"{appId}\n{timestamp}\n{nonceStr}\nprepay_id={prepayId}\n";
|
||
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(privateKey);
|
||
|
||
var signatureBytes = rsa.SignData(
|
||
Encoding.UTF8.GetBytes(signatureString),
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1);
|
||
|
||
return Convert.ToBase64String(signatureBytes);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string GenerateNonceStr(int length = 32)
|
||
{
|
||
var random = new Random();
|
||
var result = new char[length];
|
||
|
||
for (int i = 0; i < length; i++)
|
||
{
|
||
result[i] = NONCE_CHARS[random.Next(NONCE_CHARS.Length)];
|
||
}
|
||
|
||
return new string(result);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string GetTimestamp()
|
||
{
|
||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string ReadPrivateKey(string privateKeyPath)
|
||
{
|
||
try
|
||
{
|
||
// 支持相对路径和绝对路径
|
||
var fullPath = Path.IsPathRooted(privateKeyPath)
|
||
? privateKeyPath
|
||
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, privateKeyPath);
|
||
|
||
if (!File.Exists(fullPath))
|
||
{
|
||
_logger.LogError("私钥文件不存在: {Path}", fullPath);
|
||
return string.Empty;
|
||
}
|
||
|
||
return File.ReadAllText(fullPath);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "读取私钥文件失败: {Path}", privateKeyPath);
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string ReadPublicKey(string publicKeyPath)
|
||
{
|
||
try
|
||
{
|
||
var fullPath = Path.IsPathRooted(publicKeyPath)
|
||
? publicKeyPath
|
||
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, publicKeyPath);
|
||
|
||
if (!File.Exists(fullPath))
|
||
{
|
||
_logger.LogError("公钥文件不存在: {Path}", fullPath);
|
||
return string.Empty;
|
||
}
|
||
|
||
return File.ReadAllText(fullPath);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "读取公钥文件失败: {Path}", publicKeyPath);
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 截断商品描述(V3 限制最大 127 字符)
|
||
/// </summary>
|
||
private static string TruncateDescription(string description, int maxLength)
|
||
{
|
||
if (string.IsNullOrEmpty(description))
|
||
{
|
||
return "商品购买";
|
||
}
|
||
|
||
return description.Length <= maxLength ? description : description[..maxLength];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存订单通知记录
|
||
/// </summary>
|
||
private async Task SaveOrderNotifyAsync(string orderNo, string notifyUrl, string nonceStr, decimal amount, string attach, string openId)
|
||
{
|
||
var orderNotify = new OrderNotify
|
||
{
|
||
OrderNo = orderNo,
|
||
NotifyUrl = notifyUrl,
|
||
NonceStr = nonceStr,
|
||
PayTime = DateTime.Now,
|
||
PayAmount = amount,
|
||
Status = 0,
|
||
RetryCount = 0,
|
||
Attach = attach,
|
||
OpenId = openId,
|
||
CreateTime = DateTime.Now,
|
||
UpdateTime = DateTime.Now
|
||
};
|
||
|
||
_dbContext.OrderNotifies.Add(orderNotify);
|
||
await _dbContext.SaveChangesAsync();
|
||
|
||
_logger.LogDebug("保存订单通知记录: OrderNo={OrderNo}, NotifyUrl={NotifyUrl}", orderNo, notifyUrl);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取 V3 错误消息
|
||
/// </summary>
|
||
private static string GetV3ErrorMessage(string code, string message)
|
||
{
|
||
var errorMessages = new Dictionary<string, string>
|
||
{
|
||
{ "PARAM_ERROR", "参数错误" },
|
||
{ "OUT_TRADE_NO_USED", "订单号已使用" },
|
||
{ "ORDER_NOT_EXIST", "订单不存在" },
|
||
{ "ORDER_CLOSED", "订单已关闭" },
|
||
{ "SIGN_ERROR", "签名错误" },
|
||
{ "MCH_NOT_EXISTS", "商户号不存在" },
|
||
{ "APPID_MCHID_NOT_MATCH", "AppID和商户号不匹配" },
|
||
{ "FREQUENCY_LIMITED", "请求频率超限" },
|
||
{ "SYSTEM_ERROR", "系统错误" },
|
||
{ "INVALID_REQUEST", "请求参数无效" },
|
||
{ "OPENID_MISMATCH", "OpenID不匹配" },
|
||
{ "NOAUTH", "商户未开通此接口权限" },
|
||
{ "NOT_ENOUGH", "用户账户余额不足" },
|
||
{ "TRADE_ERROR", "交易错误" }
|
||
};
|
||
|
||
return errorMessages.TryGetValue(code, out var msg) ? msg : message;
|
||
}
|
||
|
||
#endregion
|
||
}
|