HaniBlindBox/server/HoneyBox/src/HoneyBox.Core/Services/WechatService.cs
gpu 9867b87594 feat: 实现原生微信支付功能
- 修改 ConfigService.GetPlatformConfigAsync 返回 isWebPay=false,关闭 Web 支付
- 重写 WechatService.CreatePayOrderAsync 实现原生微信支付:
  - 调用微信统一下单 API 获取 prepay_id
  - 生成 MD5 签名
  - 返回前端 uni.requestPayment 所需的参数(appId, timeStamp, nonceStr, package, signType, paySign)
- 更新 IWechatService 接口,添加 NativePayParams 类型
- 更新 WarehouseModels.cs,SendResultDto 使用 NativePayResultDto
- 更新 WarehouseService.SendPrizesAsync 使用新的支付参数格式
- 更新 appsettings.json 配置微信支付商户信息
2026-01-24 13:15:12 +08:00

698 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using HoneyBox.Core.Interfaces;
using HoneyBox.Model.Data;
using HoneyBox.Model.Entities;
using HoneyBox.Model.Models.Auth;
using HoneyBox.Model.Models.Payment;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace HoneyBox.Core.Services;
/// <summary>
/// 微信服务实现
/// </summary>
public class WechatService : IWechatService
{
private readonly HttpClient _httpClient;
private readonly ILogger<WechatService> _logger;
private readonly WechatSettings _wechatSettings;
private readonly WechatPaySettings _wechatPaySettings;
private readonly IRedisService _redisService;
private readonly HoneyBoxDbContext _dbContext;
// 微信API端点
private const string WechatCodeToSessionUrl = "https://api.weixin.qq.com/sns/jscode2session";
private const string WechatGetPhoneNumberUrl = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
private const string WechatGetAccessTokenUrl = "https://api.weixin.qq.com/cgi-bin/token";
// Redis缓存键前缀
private const string AccessTokenCacheKeyPrefix = "wechat:access_token:";
public WechatService(
HttpClient httpClient,
ILogger<WechatService> logger,
WechatSettings wechatSettings,
IOptions<WechatPaySettings> wechatPaySettings,
IRedisService redisService,
HoneyBoxDbContext dbContext)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_wechatSettings = wechatSettings ?? throw new ArgumentNullException(nameof(wechatSettings));
_wechatPaySettings = wechatPaySettings?.Value ?? throw new ArgumentNullException(nameof(wechatPaySettings));
_redisService = redisService ?? throw new ArgumentNullException(nameof(redisService));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
/// <summary>
/// 获取微信openid和unionid
/// </summary>
public async Task<WechatAuthResult> GetOpenIdAsync(string code)
{
_logger.LogInformation("[微信登录] 开始处理code={Code}", code);
if (string.IsNullOrWhiteSpace(code))
{
_logger.LogWarning("[微信登录] code为空");
return new WechatAuthResult
{
Success = false,
ErrorMessage = "授权code不能为空"
};
}
try
{
// 记录配置信息(脱敏)
var maskedAppId = _wechatSettings.AppId?.Length > 8
? $"{_wechatSettings.AppId.Substring(0, 4)}****{_wechatSettings.AppId.Substring(_wechatSettings.AppId.Length - 4)}"
: "未配置";
var maskedSecret = string.IsNullOrEmpty(_wechatSettings.AppSecret)
? "未配置"
: $"{_wechatSettings.AppSecret.Substring(0, 4)}****";
_logger.LogInformation("[微信登录] 配置信息: AppId={AppId}, AppSecret={AppSecret}", maskedAppId, maskedSecret);
var url = $"{WechatCodeToSessionUrl}?appid={_wechatSettings.AppId}&secret={_wechatSettings.AppSecret}&js_code={code}&grant_type=authorization_code";
_logger.LogInformation("[微信登录] 调用微信API: {Url}", WechatCodeToSessionUrl);
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
_logger.LogInformation("[微信登录] 微信API响应状态码: {StatusCode}", response.StatusCode);
_logger.LogInformation("[微信登录] 微信API响应内容: {Content}", content);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("[微信登录] 微信API返回HTTP错误 {StatusCode}: {Content}", response.StatusCode, content);
return new WechatAuthResult
{
Success = false,
ErrorMessage = "微信API调用失败"
};
}
using var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// 检查是否有错误
if (root.TryGetProperty("errcode", out var errCode) && errCode.GetInt32() != 0)
{
var errMsg = root.TryGetProperty("errmsg", out var msg) ? msg.GetString() : "未知错误";
_logger.LogWarning("[微信登录] 微信API返回业务错误: errcode={ErrorCode}, errmsg={ErrorMessage}", errCode.GetInt32(), errMsg);
return new WechatAuthResult
{
Success = false,
ErrorMessage = $"微信授权失败: {errMsg}"
};
}
// 提取openid和unionid
var openId = root.TryGetProperty("openid", out var openIdProp) ? openIdProp.GetString() : null;
var unionId = root.TryGetProperty("unionid", out var unionIdProp) ? unionIdProp.GetString() : null;
var sessionKey = root.TryGetProperty("session_key", out var sessionKeyProp) ? sessionKeyProp.GetString() : null;
_logger.LogInformation("[微信登录] 解析结果: openid={OpenId}, unionid={UnionId}, session_key={SessionKey}",
openId ?? "null",
unionId ?? "null",
string.IsNullOrEmpty(sessionKey) ? "null" : "已获取");
if (string.IsNullOrEmpty(openId))
{
_logger.LogError("[微信登录] 微信API响应中缺少openid");
return new WechatAuthResult
{
Success = false,
ErrorMessage = "微信返回数据异常"
};
}
_logger.LogInformation("[微信登录] 成功获取openid: {OpenId}", openId);
return new WechatAuthResult
{
Success = true,
OpenId = openId,
UnionId = unionId
};
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "[微信登录] HTTP请求异常: {Message}", ex.Message);
return new WechatAuthResult
{
Success = false,
ErrorMessage = "网络连接失败"
};
}
catch (JsonException ex)
{
_logger.LogError(ex, "[微信登录] JSON解析异常: {Message}", ex.Message);
return new WechatAuthResult
{
Success = false,
ErrorMessage = "响应数据格式错误"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "[微信登录] 未知异常: {Message}", ex.Message);
return new WechatAuthResult
{
Success = false,
ErrorMessage = "系统错误"
};
}
}
/// <summary>
/// 获取微信授权的手机号
/// </summary>
public async Task<WechatMobileResult> GetMobileAsync(string code)
{
if (string.IsNullOrWhiteSpace(code))
{
_logger.LogWarning("GetMobileAsync called with empty code");
return new WechatMobileResult
{
Success = false,
ErrorMessage = "授权code不能为空"
};
}
try
{
// 1. 先获取 access_token
var accessToken = await GetAccessTokenAsync();
if (string.IsNullOrEmpty(accessToken))
{
_logger.LogError("Failed to get access_token for phone number API");
return new WechatMobileResult
{
Success = false,
ErrorMessage = "获取access_token失败"
};
}
// 2. 使用 access_token 作为 URL 参数code 放在请求体中
var url = $"{WechatGetPhoneNumberUrl}?access_token={accessToken}";
var requestBody = new { code = code };
var jsonContent = new StringContent(
JsonSerializer.Serialize(requestBody),
System.Text.Encoding.UTF8,
"application/json");
_logger.LogInformation("Calling WeChat API to get phone number with access_token");
var response = await _httpClient.PostAsync(url, jsonContent);
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogError("WeChat API returned error status {StatusCode}: {Content}", response.StatusCode, content);
return new WechatMobileResult
{
Success = false,
ErrorMessage = "微信API调用失败"
};
}
using var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// 检查是否有错误
if (root.TryGetProperty("errcode", out var errCode) && errCode.GetInt32() != 0)
{
var errMsg = root.TryGetProperty("errmsg", out var msg) ? msg.GetString() : "未知错误";
_logger.LogWarning("WeChat API returned error: {ErrorCode} - {ErrorMessage}", errCode.GetInt32(), errMsg);
return new WechatMobileResult
{
Success = false,
ErrorMessage = $"获取手机号失败: {errMsg}"
};
}
// 提取手机号
string? mobile = null;
if (root.TryGetProperty("phone_info", out var phoneInfo))
{
mobile = phoneInfo.TryGetProperty("phoneNumber", out var phoneNumber)
? phoneNumber.GetString()
: null;
}
if (string.IsNullOrEmpty(mobile))
{
_logger.LogError("WeChat API response missing phone number");
return new WechatMobileResult
{
Success = false,
ErrorMessage = "微信返回数据异常"
};
}
_logger.LogInformation("Successfully retrieved phone number from WeChat API");
return new WechatMobileResult
{
Success = true,
Mobile = mobile
};
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request error when calling WeChat API");
return new WechatMobileResult
{
Success = false,
ErrorMessage = "网络连接失败"
};
}
catch (JsonException ex)
{
_logger.LogError(ex, "JSON parsing error when processing WeChat API response");
return new WechatMobileResult
{
Success = false,
ErrorMessage = "响应数据格式错误"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error when calling WeChat API");
return new WechatMobileResult
{
Success = false,
ErrorMessage = "系统错误"
};
}
}
/// <summary>
/// 获取小程序接口调用凭证access_token
/// </summary>
/// <param name="appId">小程序AppId可选不传则使用默认配置</param>
/// <returns>access_token失败返回null</returns>
public async Task<string?> GetAccessTokenAsync(string? appId = null)
{
try
{
// 确定使用哪个AppId和AppSecret
var targetAppId = appId ?? _wechatSettings.AppId;
var targetAppSecret = GetAppSecretByAppId(targetAppId);
if (string.IsNullOrEmpty(targetAppId) || string.IsNullOrEmpty(targetAppSecret))
{
_logger.LogError("无法获取access_tokenAppId或AppSecret为空");
return null;
}
// 尝试从Redis缓存获取
var cacheKey = $"{AccessTokenCacheKeyPrefix}{targetAppId}";
var cachedToken = await _redisService.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cachedToken))
{
_logger.LogDebug("从缓存获取access_token: AppId={AppId}", targetAppId);
return cachedToken;
}
// 调用微信API获取新的access_token
var url = $"{WechatGetAccessTokenUrl}?grant_type=client_credential&appid={targetAppId}&secret={targetAppSecret}";
_logger.LogInformation("调用微信API获取access_token: AppId={AppId}", targetAppId);
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogError("微信API返回错误状态 {StatusCode}: {Content}", response.StatusCode, content);
return null;
}
using var jsonDoc = JsonDocument.Parse(content);
var root = jsonDoc.RootElement;
// 检查是否有错误
if (root.TryGetProperty("errcode", out var errCode) && errCode.GetInt32() != 0)
{
var errMsg = root.TryGetProperty("errmsg", out var msg) ? msg.GetString() : "未知错误";
_logger.LogWarning("微信API返回错误: {ErrorCode} - {ErrorMessage}", errCode.GetInt32(), errMsg);
return null;
}
// 提取access_token和过期时间
var accessToken = root.TryGetProperty("access_token", out var tokenProp) ? tokenProp.GetString() : null;
var expiresIn = root.TryGetProperty("expires_in", out var expiresProp) ? expiresProp.GetInt32() : 7200;
if (string.IsNullOrEmpty(accessToken))
{
_logger.LogError("微信API响应中缺少access_token");
return null;
}
// 缓存access_token提前5分钟过期避免边界问题
var cacheExpiry = TimeSpan.FromSeconds(Math.Max(expiresIn - 300, 60));
await _redisService.SetStringAsync(cacheKey, accessToken, cacheExpiry);
_logger.LogInformation("成功获取access_token: AppId={AppId}, ExpiresIn={ExpiresIn}s", targetAppId, expiresIn);
return accessToken;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "获取access_token时HTTP请求错误");
return null;
}
catch (JsonException ex)
{
_logger.LogError(ex, "获取access_token时JSON解析错误");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "获取access_token时发生未知错误");
return null;
}
}
/// <summary>
/// 根据AppId获取对应的AppSecret
/// </summary>
private string GetAppSecretByAppId(string appId)
{
// 首先检查默认配置
if (_wechatSettings.AppId == appId)
{
return _wechatSettings.AppSecret;
}
// 从小程序配置列表中查找
var miniprogram = _wechatPaySettings.Miniprograms.FirstOrDefault(m => m.AppId == appId);
if (miniprogram != null)
{
return miniprogram.AppSecret;
}
// 如果都没找到返回默认配置的AppSecret
_logger.LogWarning("未找到AppId {AppId} 的配置使用默认AppSecret", appId);
return _wechatSettings.AppSecret;
}
/// <summary>
/// 创建支付订单(原生微信支付)
/// </summary>
public async Task<CreatePayResult> CreatePayOrderAsync(CreatePayRequest request)
{
try
{
_logger.LogInformation("[创建支付订单] 开始处理: UserId={UserId}, Price={Price}, Title={Title}, Attach={Attach}",
request.UserId, request.Price, request.Title, request.Attach);
// 如果金额为0直接返回成功免费订单
if (request.Price <= 0)
{
var freeOrderNo = GenerateOrderNo(request.Prefix, "MON", "YD", "MP0");
_logger.LogInformation("[创建支付订单] 免费订单,直接返回成功: OrderNo={OrderNo}", freeOrderNo);
return new CreatePayResult
{
Status = 1,
OrderNo = freeOrderNo,
Res = null
};
}
// 获取商户配置
var merchantConfig = GetMerchantConfig();
if (merchantConfig == null)
{
_logger.LogError("[创建支付订单] 未找到商户配置");
return new CreatePayResult
{
Status = 0,
Message = "支付配置错误",
OrderNo = string.Empty
};
}
// 生成订单号:前缀 + 商户前缀 + 小程序前缀 + 支付类型 + 时间戳 + 随机数
var orderNo = GenerateOrderNo(request.Prefix, merchantConfig.OrderPrefix, "YD", "MP0");
// 截取标题最多30个字符
var title = request.Title.Length > 30 ? request.Title.Substring(0, 30) : request.Title;
// 生成随机字符串
var nonceStr = GenerateNonceStr();
var callbackNonceStr = GenerateNonceStr();
// 生成回调通知URL
var notifyUrl = GenerateNotifyUrl(request.Attach, request.UserId, orderNo, callbackNonceStr);
// 获取客户端IP
var clientIp = "127.0.0.1"; // 实际应从请求中获取
// 构建统一下单参数
var unifiedOrderParams = new SortedDictionary<string, string>
{
{ "appid", _wechatSettings.AppId },
{ "mch_id", merchantConfig.MchId },
{ "nonce_str", nonceStr },
{ "body", title },
{ "attach", request.Attach },
{ "out_trade_no", orderNo },
{ "notify_url", notifyUrl },
{ "total_fee", ((int)(request.Price * 100)).ToString() }, // 转换为分
{ "spbill_create_ip", clientIp },
{ "trade_type", "JSAPI" },
{ "openid", request.OpenId }
};
// 生成签名
var sign = MakeSign(unifiedOrderParams, merchantConfig.Key);
unifiedOrderParams.Add("sign", sign);
// 转换为XML
var xmlData = DictToXml(unifiedOrderParams);
_logger.LogInformation("[创建支付订单] 调用微信统一下单API: OrderNo={OrderNo}", orderNo);
// 调用微信统一下单API
var content = new StringContent(xmlData, System.Text.Encoding.UTF8, "application/xml");
var response = await _httpClient.PostAsync(_wechatPaySettings.UnifiedOrderUrl, content);
var responseContent = await response.Content.ReadAsStringAsync();
_logger.LogInformation("[创建支付订单] 微信统一下单响应: {Response}", responseContent);
// 解析响应
var result = XmlToDict(responseContent);
if (result.TryGetValue("return_code", out var returnCode) && returnCode == "SUCCESS" &&
result.TryGetValue("result_code", out var resultCode) && resultCode == "SUCCESS")
{
// 获取 prepay_id
if (!result.TryGetValue("prepay_id", out var prepayId))
{
_logger.LogError("[创建支付订单] 微信返回数据缺少 prepay_id");
return new CreatePayResult
{
Status = 0,
Message = "微信返回数据异常",
OrderNo = string.Empty
};
}
// 保存订单通知记录
var orderNotify = new OrderNotify
{
OrderNo = orderNo,
NotifyUrl = notifyUrl,
NonceStr = callbackNonceStr,
PayTime = DateTime.UtcNow,
PayAmount = request.Price,
Status = 0, // 待支付
RetryCount = 0,
Attach = request.Attach,
OpenId = request.OpenId,
Extend = JsonSerializer.Serialize(new { orderType = request.Attach, title = title }),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_dbContext.OrderNotifies.Add(orderNotify);
await _dbContext.SaveChangesAsync();
// 生成前端支付参数
var timeStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var payNonceStr = GenerateNonceStr();
var payParams = new SortedDictionary<string, string>
{
{ "appId", _wechatSettings.AppId },
{ "timeStamp", timeStamp },
{ "nonceStr", payNonceStr },
{ "package", $"prepay_id={prepayId}" },
{ "signType", "MD5" }
};
var paySign = MakeSign(payParams, merchantConfig.Key);
_logger.LogInformation("[创建支付订单] 订单创建成功: OrderNo={OrderNo}, PrepayId={PrepayId}", orderNo, prepayId);
return new CreatePayResult
{
Status = 1,
OrderNo = orderNo,
Res = new NativePayParams
{
AppId = _wechatSettings.AppId,
TimeStamp = timeStamp,
NonceStr = payNonceStr,
Package = $"prepay_id={prepayId}",
SignType = "MD5",
PaySign = paySign
}
};
}
else
{
// 解析错误信息
var errorMsg = "微信支付接口返回异常";
if (result.TryGetValue("return_msg", out var returnMsg))
{
errorMsg = returnMsg;
}
else if (result.TryGetValue("err_code_des", out var errCodeDes))
{
errorMsg = errCodeDes;
}
_logger.LogError("[创建支付订单] 微信统一下单失败: {Error}, Response: {Response}", errorMsg, responseContent);
return new CreatePayResult
{
Status = 0,
Message = errorMsg,
OrderNo = string.Empty
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[创建支付订单] 创建失败: UserId={UserId}", request.UserId);
return new CreatePayResult
{
Status = 0,
Message = "创建支付订单失败",
OrderNo = string.Empty
};
}
}
/// <summary>
/// 获取商户配置
/// </summary>
private WechatPayMerchantConfig? GetMerchantConfig()
{
// 优先使用默认商户配置
if (!string.IsNullOrEmpty(_wechatPaySettings.DefaultMerchant?.MchId))
{
return _wechatPaySettings.DefaultMerchant;
}
// 从商户列表中获取第一个
return _wechatPaySettings.Merchants.FirstOrDefault();
}
/// <summary>
/// 生成随机字符串
/// </summary>
private static string GenerateNonceStr(int length = 32)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var random = new Random();
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[random.Next(s.Length)]).ToArray());
}
/// <summary>
/// 生成回调通知URL
/// </summary>
private string GenerateNotifyUrl(string orderType, int userId, string orderNo, string nonceStr)
{
var baseUrl = _wechatPaySettings.NotifyBaseUrl.TrimEnd('/');
return $"{baseUrl}/api/pay/notify?payment_type=wxpay&order_type={orderType}&user_id={userId}&order_no={orderNo}&nonce_str={nonceStr}";
}
/// <summary>
/// 生成签名MD5
/// </summary>
private static string MakeSign(SortedDictionary<string, string> parameters, string key)
{
var sb = new System.Text.StringBuilder();
foreach (var kvp in parameters)
{
if (!string.IsNullOrEmpty(kvp.Value) && kvp.Key != "sign")
{
sb.Append($"{kvp.Key}={kvp.Value}&");
}
}
sb.Append($"key={key}");
using var md5 = System.Security.Cryptography.MD5.Create();
var inputBytes = System.Text.Encoding.UTF8.GetBytes(sb.ToString());
var hashBytes = md5.ComputeHash(inputBytes);
return BitConverter.ToString(hashBytes).Replace("-", "").ToUpper();
}
/// <summary>
/// 字典转XML
/// </summary>
private static string DictToXml(SortedDictionary<string, string> dict)
{
var sb = new System.Text.StringBuilder();
sb.Append("<xml>");
foreach (var kvp in dict)
{
sb.Append($"<{kvp.Key}><![CDATA[{kvp.Value}]]></{kvp.Key}>");
}
sb.Append("</xml>");
return sb.ToString();
}
/// <summary>
/// XML转字典
/// </summary>
private static Dictionary<string, string> XmlToDict(string xml)
{
var dict = new Dictionary<string, string>();
try
{
var doc = System.Xml.Linq.XDocument.Parse(xml);
if (doc.Root != null)
{
foreach (var element in doc.Root.Elements())
{
dict[element.Name.LocalName] = element.Value;
}
}
}
catch
{
// 解析失败返回空字典
}
return dict;
}
/// <summary>
/// 生成订单号
/// 格式:前缀(3位) + 商户前缀(3位) + 项目前缀(2位) + 支付类型(3位) + 时间戳 + 随机数
/// </summary>
private string GenerateOrderNo(string prefix, string merchantPrefix, string projectPrefix, string payType)
{
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
var random = new Random().Next(1000, 9999);
return $"{prefix}{merchantPrefix}{projectPrefix}{payType}{timestamp}{random}";
}
}