feat(payment): 支持微信支付V3证书PEM内容存储到数据库

- WechatPayMerchantConfig 新增 PrivateKeyContent/WechatPublicKeyContent 字段
- WechatPayV3Service 新增 ResolvePrivateKey/ResolvePublicKey 优先读数据库内容
- 后台管理页面改为文本域粘贴PEM内容,路径作为备选
- 完全向后兼容,原文件路径方式依然可用
- 迁移服务器只需在后台重新配置即可,无需拷贝证书文件
This commit is contained in:
zpc 2026-02-21 13:28:42 +08:00
parent f082f20fc8
commit 1bd6683cb8
7 changed files with 309 additions and 191 deletions

View File

@ -108,6 +108,12 @@ public class WeixinPayMerchant
[JsonPropertyName("private_key_path")]
public string? PrivateKeyPath { get; set; }
/// <summary>
/// 商户私钥PEM内容V3版本使用优先级高于路径
/// </summary>
[JsonPropertyName("private_key_content")]
public string? PrivateKeyContent { get; set; }
/// <summary>
/// 微信支付公钥IDV3版本使用
/// </summary>
@ -119,6 +125,12 @@ public class WeixinPayMerchant
/// </summary>
[JsonPropertyName("wechat_public_key_path")]
public string? WechatPublicKeyPath { get; set; }
/// <summary>
/// 微信支付公钥PEM内容V3版本使用优先级高于路径
/// </summary>
[JsonPropertyName("wechat_public_key_content")]
public string? WechatPublicKeyContent { get; set; }
}

View File

@ -61,6 +61,12 @@ public class WeixinPayMerchant
[JsonPropertyName("private_key_path")]
public string? PrivateKeyPath { get; set; }
/// <summary>
/// 商户私钥PEM内容优先级高于路径
/// </summary>
[JsonPropertyName("private_key_content")]
public string? PrivateKeyContent { get; set; }
/// <summary>
/// 微信支付公钥ID
/// </summary>
@ -73,6 +79,12 @@ public class WeixinPayMerchant
[JsonPropertyName("wechat_public_key_path")]
public string? WechatPublicKeyPath { get; set; }
/// <summary>
/// 微信支付公钥PEM内容优先级高于路径
/// </summary>
[JsonPropertyName("wechat_public_key_content")]
public string? WechatPublicKeyContent { get; set; }
/// <summary>
/// API密钥V2版本使用
/// </summary>

View File

@ -125,10 +125,14 @@ export interface WeixinPayMerchant {
cert_serial_no?: string
/** 商户私钥文件路径 */
private_key_path?: string
/** 商户私钥PEM内容优先级高于路径 */
private_key_content?: string
/** 微信支付公钥ID */
wechat_public_key_id?: string
/** 微信支付公钥文件路径 */
wechat_public_key_path?: string
/** 微信支付公钥PEM内容优先级高于路径 */
wechat_public_key_content?: string
/** API密钥V2 */
api_key?: string
/** 证书路径V2 */

View File

@ -99,19 +99,43 @@
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商户私钥路径">
<el-input v-model="merchant.private_key_path" placeholder="apiclient_key.pem 文件路径" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="微信支付公钥ID">
<el-input v-model="merchant.wechat_public_key_id" placeholder="微信支付公钥ID" clearable />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">证书配置二选一粘贴内容 填写服务器路径</el-divider>
<!-- 商户私钥 -->
<el-form-item label="商户私钥内容">
<el-input
v-model="merchant.private_key_content"
type="textarea"
:rows="4"
placeholder="粘贴 apiclient_key.pem 文件内容(-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----"
/>
<div class="form-item-tip">推荐方式直接粘贴PEM文件内容迁移服务器无需重新上传证书文件</div>
</el-form-item>
<el-form-item label="商户私钥路径">
<el-input v-model="merchant.private_key_path" placeholder="备选apiclient_key.pem 服务器文件路径" clearable />
<div class="form-item-tip">仅当未填写私钥内容时使用</div>
</el-form-item>
<!-- 微信支付公钥 -->
<el-form-item label="微信支付公钥内容">
<el-input
v-model="merchant.wechat_public_key_content"
type="textarea"
:rows="4"
placeholder="粘贴 pub_key.pem 文件内容(-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----"
/>
<div class="form-item-tip">推荐方式直接粘贴PEM文件内容</div>
</el-form-item>
<el-form-item label="微信支付公钥路径">
<el-input v-model="merchant.wechat_public_key_path" placeholder="pub_key.pem 文件路径" clearable />
<el-input v-model="merchant.wechat_public_key_path" placeholder="备选pub_key.pem 服务器文件路径" clearable />
<div class="form-item-tip">仅当未填写公钥内容时使用</div>
</el-form-item>
</template>
@ -205,8 +229,10 @@ function addMerchant() {
api_v3_key: '',
cert_serial_no: '',
private_key_path: '',
private_key_content: '',
wechat_public_key_id: '',
wechat_public_key_path: '',
wechat_public_key_content: '',
api_key: '',
cert_path: '',
is_enabled: '1'

View File

@ -71,8 +71,10 @@ public class WechatPayConfigService : IWechatPayConfigService
ApiV3Key = m.ApiV3Key,
CertSerialNo = m.CertSerialNo,
PrivateKeyPath = m.PrivateKeyPath,
PrivateKeyContent = m.PrivateKeyContent,
WechatPublicKeyId = m.WechatPublicKeyId,
WechatPublicKeyPath = m.WechatPublicKeyPath,
WechatPublicKeyContent = m.WechatPublicKeyContent,
NotifyUrl = !string.IsNullOrEmpty(m.NotifyUrl) ? m.NotifyUrl : "https://api.zfunbox.cn/api/notify"
});
}
@ -215,8 +217,10 @@ public class WechatPayConfigService : IWechatPayConfigService
ApiV3Key = selectedMerchant.ApiV3Key,
CertSerialNo = selectedMerchant.CertSerialNo,
PrivateKeyPath = selectedMerchant.PrivateKeyPath,
PrivateKeyContent = selectedMerchant.PrivateKeyContent,
WechatPublicKeyId = selectedMerchant.WechatPublicKeyId,
WechatPublicKeyPath = selectedMerchant.WechatPublicKeyPath
WechatPublicKeyPath = selectedMerchant.WechatPublicKeyPath,
WechatPublicKeyContent = selectedMerchant.WechatPublicKeyContent
};
}
@ -304,8 +308,10 @@ public class WechatPayConfigService : IWechatPayConfigService
[JsonPropertyName("api_v3_key")] public string? ApiV3Key { get; set; }
[JsonPropertyName("cert_serial_no")] public string? CertSerialNo { get; set; }
[JsonPropertyName("private_key_path")] public string? PrivateKeyPath { get; set; }
[JsonPropertyName("private_key_content")] public string? PrivateKeyContent { get; set; }
[JsonPropertyName("wechat_public_key_id")] public string? WechatPublicKeyId { get; set; }
[JsonPropertyName("wechat_public_key_path")] public string? WechatPublicKeyPath { get; set; }
[JsonPropertyName("wechat_public_key_content")] public string? WechatPublicKeyContent { get; set; }
[JsonPropertyName("notify_url")] public string? NotifyUrl { get; set; }
}
private class DbWeixinPayConfig

View File

@ -90,7 +90,7 @@ public class WechatPayV3Service : IWechatPayV3Service
// 验证 V3 配置
if (string.IsNullOrEmpty(merchantConfig.ApiV3Key) ||
string.IsNullOrEmpty(merchantConfig.CertSerialNo) ||
string.IsNullOrEmpty(merchantConfig.PrivateKeyPath))
(string.IsNullOrEmpty(merchantConfig.PrivateKeyContent) && string.IsNullOrEmpty(merchantConfig.PrivateKeyPath)))
{
_logger.LogError("V3 配置不完整: MchId={MchId}", merchantConfig.MchId);
return new WechatPayResult { Status = 0, Msg = "V3 支付配置不完整" };
@ -98,11 +98,11 @@ public class WechatPayV3Service : IWechatPayV3Service
_logger.LogDebug("使用 V3 商户配置: MchId={MchId}, AppId={AppId}", merchantConfig.MchId, merchantConfig.AppId);
// 3. 读取私钥
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath);
// 3. 读取私钥优先使用数据库中的PEM内容
var privateKey = ResolvePrivateKey(merchantConfig);
if (string.IsNullOrEmpty(privateKey))
{
_logger.LogError("读取私钥失败: Path={Path}", merchantConfig.PrivateKeyPath);
_logger.LogError("读取私钥失败: MchId={MchId}", merchantConfig.MchId);
return new WechatPayResult { Status = 0, Msg = "读取商户私钥失败" };
}
@ -211,7 +211,7 @@ public class WechatPayV3Service : IWechatPayV3Service
_logger.LogInformation("开始查询 V3 订单: OrderNo={OrderNo}", orderNo);
var merchantConfig = _configService.GetMerchantByOrderNo(orderNo);
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!);
var privateKey = ResolvePrivateKey(merchantConfig);
var url = $"/v3/pay/transactions/out-trade-no/{orderNo}?mchid={merchantConfig.MchId}";
var fullUrl = string.Format(V3_QUERY_URL, orderNo) + $"?mchid={merchantConfig.MchId}";
@ -274,7 +274,7 @@ public class WechatPayV3Service : IWechatPayV3Service
_logger.LogInformation("开始关闭 V3 订单: OrderNo={OrderNo}", orderNo);
var merchantConfig = _configService.GetMerchantByOrderNo(orderNo);
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!);
var privateKey = ResolvePrivateKey(merchantConfig);
var url = $"/v3/pay/transactions/out-trade-no/{orderNo}/close";
var fullUrl = string.Format(V3_CLOSE_URL, orderNo);
@ -337,7 +337,7 @@ public class WechatPayV3Service : IWechatPayV3Service
request.OrderNo, request.RefundNo, request.RefundAmount);
var merchantConfig = _configService.GetMerchantByOrderNo(request.OrderNo);
var privateKey = ReadPrivateKey(merchantConfig.PrivateKeyPath!);
var privateKey = ResolvePrivateKey(merchantConfig);
var apiRequest = new WechatPayV3RefundApiRequest
{
@ -451,9 +451,9 @@ public class WechatPayV3Service : IWechatPayV3Service
// 获取商户配置
var merchantConfig = _configService.GetDefaultConfig();
if (string.IsNullOrEmpty(merchantConfig.WechatPublicKeyPath))
if (string.IsNullOrEmpty(merchantConfig.WechatPublicKeyContent) && string.IsNullOrEmpty(merchantConfig.WechatPublicKeyPath))
{
_logger.LogError("微信支付公钥路径未配置");
_logger.LogError("微信支付公钥未配置(内容和路径均为空)");
return false;
}
@ -467,10 +467,10 @@ public class WechatPayV3Service : IWechatPayV3Service
// 继续验证,因为可能是微信更换了公钥
}
var publicKey = ReadPublicKey(merchantConfig.WechatPublicKeyPath);
var publicKey = ResolvePublicKey(merchantConfig);
if (string.IsNullOrEmpty(publicKey))
{
_logger.LogError("读取微信支付公钥失败: Path={Path}", merchantConfig.WechatPublicKeyPath);
_logger.LogError("读取微信支付公钥失败: MchId={MchId}", merchantConfig.MchId);
return false;
}
@ -853,6 +853,54 @@ public class WechatPayV3Service : IWechatPayV3Service
}
}
/// <summary>
/// 解析商户私钥优先使用数据库中的PEM内容fallback到文件路径
/// </summary>
/// <param name="config">商户配置</param>
/// <returns>私钥PEM内容</returns>
private string ResolvePrivateKey(WechatPayMerchantConfig config)
{
// 优先使用数据库中存储的PEM内容
if (!string.IsNullOrEmpty(config.PrivateKeyContent))
{
_logger.LogDebug("使用数据库中的商户私钥内容");
return config.PrivateKeyContent;
}
// fallback到文件路径
if (!string.IsNullOrEmpty(config.PrivateKeyPath))
{
return ReadPrivateKey(config.PrivateKeyPath);
}
_logger.LogError("商户私钥未配置(内容和路径均为空): MchId={MchId}", config.MchId);
return string.Empty;
}
/// <summary>
/// 解析微信支付公钥优先使用数据库中的PEM内容fallback到文件路径
/// </summary>
/// <param name="config">商户配置</param>
/// <returns>公钥PEM内容</returns>
private string ResolvePublicKey(WechatPayMerchantConfig config)
{
// 优先使用数据库中存储的PEM内容
if (!string.IsNullOrEmpty(config.WechatPublicKeyContent))
{
_logger.LogDebug("使用数据库中的微信支付公钥内容");
return config.WechatPublicKeyContent;
}
// fallback到文件路径
if (!string.IsNullOrEmpty(config.WechatPublicKeyPath))
{
return ReadPublicKey(config.WechatPublicKeyPath);
}
_logger.LogError("微信支付公钥未配置(内容和路径均为空): MchId={MchId}", config.MchId);
return string.Empty;
}
/// <summary>
/// 截断商品描述V3 限制最大 127 字符)
/// </summary>