feat(payment): 后台「支付配置」支持微信支付与支付宝凭据管理
后端: - 新增 payment_configs 单行配置表;APIv3 密钥、应用私钥/公钥以 AES-256-GCM 密文入库(enc:v1 前缀,主密钥取 PAYMENT_SECRET_KEY,缺省回退 JWT_SECRET 派生) - 证书/私钥落 PAYMENT_CERT_DIR(默认 /data/payment-certs),目录 0700 / 文件 0600,不经任何静态路由暴露 - 接口:GET/PUT /api/admin/payment/config、POST /upload(PEM 校验 + 微信证书序列号自动回填)、POST /test(真打官方接口:微信 /v3/certificates 签名校验、支付宝 alipay.trade.query 签名校验)、POST /wechat/platform-certs(APIv3 密钥解密平台证书并落盘) - 未配置凭据时测试返回 skipped 而不是 500;敏感字段在返回体里一律脱敏 前端: - /payments 改为「通用设置 + 微信支付 / 支付宝 / Waffo 海外支付」页签(原 Waffo 页整页迁到 views/payments/Waffo.vue) - 微信:mchid、AppID、APIv3 密钥(密码框 + 掩码回显 + 更换/清除)、证书序列号、支付方式多选、三项证书上传、自动获取平台证书 - 支付宝:app_id、应用私钥(粘贴/上传)、支付宝公钥、网关、RSA2、回调地址 - 菜单归入「审计与设置 → 支付配置」
This commit is contained in:
1 parent
630f2819cb
commit
7d53c6ed3c
21 files changed
+3090
-6
No files matched your search
@@ -61,6 +61,7 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
|
||||
&model.AuditLog{}, // 操作日志 // Logan.Li
|
||||
&model.LicenseTemplate{}, // License 模板 // Logan.Li
|
||||
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
|
||||
&model.PaymentConfig{}, // 支付配置(微信/支付宝,敏感字段 AES-GCM 加密) // Logan.Li
|
||||
// 核心授权域
|
||||
&model.Product{}, // 产品 // Logan.Li
|
||||
&model.Customer{}, // 客户 // Logan.Li
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/internal/dto"
|
||||
paymentservice "backend/internal/service/payment"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PaymentConfigController 支付配置管理控制器(仅管理员,挂在 JWT 保护组下) // Logan.Li
|
||||
type PaymentConfigController struct {
|
||||
svc *paymentservice.ConfigService
|
||||
}
|
||||
|
||||
// NewPaymentConfigController 创建控制器 // Logan.Li
|
||||
func NewPaymentConfigController(db *gorm.DB, cfg *config.Config) *PaymentConfigController {
|
||||
return &PaymentConfigController{svc: paymentservice.NewConfigService(db, cfg)}
|
||||
}
|
||||
|
||||
// GetConfig 获取支付配置(敏感字段脱敏) // Logan.Li
|
||||
func (ctrl *PaymentConfigController) GetConfig(c *gin.Context) {
|
||||
data, err := ctrl.svc.Get()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data})
|
||||
}
|
||||
|
||||
// UpdateConfig 保存支付配置 // Logan.Li
|
||||
func (ctrl *PaymentConfigController) UpdateConfig(c *gin.Context) {
|
||||
var req dto.PaymentConfigUpdate
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误:" + err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := ctrl.svc.Update(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "支付配置已保存", "data": data})
|
||||
}
|
||||
|
||||
// Upload 上传证书/私钥(multipart:kind + file) // Logan.Li
|
||||
func (ctrl *PaymentConfigController) Upload(c *gin.Context) {
|
||||
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||
if kind == "" {
|
||||
kind = strings.TrimSpace(c.Query("kind"))
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "未收到文件(字段名应为 file)"})
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(f, 256*1024))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ctrl.svc.Upload(kind, fileHeader.Filename, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": data.Message, "data": data})
|
||||
}
|
||||
|
||||
// Test 用已保存凭据做一次最小连通性校验 // Logan.Li
|
||||
func (ctrl *PaymentConfigController) Test(c *gin.Context) {
|
||||
var req dto.PaymentTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// 允许空 body,默认测试全部渠道 // Logan.Li
|
||||
req = dto.PaymentTestRequest{}
|
||||
}
|
||||
data := ctrl.svc.Test(req.Channel)
|
||||
// 业务失败也返回 200 + code=0,结果在 results[].status 中体现,避免前端弹 500 // Logan.Li
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "测试完成", "data": data})
|
||||
}
|
||||
|
||||
// FetchWechatPlatformCerts 自动获取微信支付平台证书 // Logan.Li
|
||||
func (ctrl *PaymentConfigController) FetchWechatPlatformCerts(c *gin.Context) {
|
||||
items, msg, err := ctrl.svc.FetchWechatPlatformCerts()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1, "message": msg, "data": gin.H{"items": []any{}}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": msg, "data": gin.H{"items": items}})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package dto
|
||||
|
||||
// ============ 支付配置(微信支付 / 支付宝) ============
|
||||
|
||||
// PaymentFileInfo 已上传证书/密钥文件的可展示元信息(不含任何密钥内容) // Logan.Li
|
||||
type PaymentFileInfo struct {
|
||||
Configured bool `json:"configured"`
|
||||
FileName string `json:"file_name"`
|
||||
Fingerprint string `json:"fingerprint"` // SHA-256 指纹(前 16 位展示)
|
||||
Subject string `json:"subject"` // 证书 CN / 密钥类型
|
||||
NotBefore string `json:"not_before"` // 证书生效时间(pem 可解析时)
|
||||
NotAfter string `json:"not_after"` // 证书到期时间
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ParseError string `json:"parse_error"`
|
||||
}
|
||||
|
||||
// WechatPaymentView 微信支付配置(脱敏视图) // Logan.Li
|
||||
type WechatPaymentView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MchID string `json:"mch_id"`
|
||||
AppID string `json:"app_id"`
|
||||
APIv3KeyConfigured bool `json:"api_v3_key_configured"`
|
||||
APIv3KeyMasked string `json:"api_v3_key_masked"` // 仅显示后 4 位
|
||||
APIv3KeyLength int `json:"api_v3_key_length"`
|
||||
SerialNo string `json:"serial_no"`
|
||||
PayMethods []string `json:"pay_methods"` // native / h5 / jsapi
|
||||
NotifyURL string `json:"notify_url"` // 只读
|
||||
CertFile PaymentFileInfo `json:"cert_file"`
|
||||
KeyFile PaymentFileInfo `json:"key_file"`
|
||||
PlatformCertFile PaymentFileInfo `json:"platform_cert_file"`
|
||||
Ready bool `json:"ready"`
|
||||
Missing []string `json:"missing"`
|
||||
}
|
||||
|
||||
// AlipayPaymentView 支付宝配置(脱敏视图) // Logan.Li
|
||||
type AlipayPaymentView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppID string `json:"app_id"`
|
||||
PrivateKeyConfigured bool `json:"private_key_configured"`
|
||||
PrivateKeyMasked string `json:"private_key_masked"` // 指纹脱敏,绝不回显内容
|
||||
PrivateKeySource string `json:"private_key_source"` // paste / upload / ""
|
||||
PublicKeyConfigured bool `json:"public_key_configured"`
|
||||
PublicKeyMasked string `json:"public_key_masked"`
|
||||
Gateway string `json:"gateway"` // 默认 https://openapi.alipay.com/gateway.do
|
||||
SignType string `json:"sign_type"` // 固定 RSA2
|
||||
NotifyURL string `json:"notify_url"`
|
||||
PrivateKeyFile PaymentFileInfo `json:"private_key_file"`
|
||||
PublicKeyFile PaymentFileInfo `json:"public_key_file"`
|
||||
Ready bool `json:"ready"`
|
||||
Missing []string `json:"missing"`
|
||||
}
|
||||
|
||||
// PaymentConfigView 支付配置整体视图(GET 返回,全部脱敏) // Logan.Li
|
||||
type PaymentConfigView struct {
|
||||
Mode string `json:"mode"` // test / live
|
||||
Wechat WechatPaymentView `json:"wechat"`
|
||||
Alipay AlipayPaymentView `json:"alipay"`
|
||||
MasterKeyOrigin string `json:"master_key_origin"` // env / jwt_secret
|
||||
EncryptionEnabled bool `json:"encryption_enabled"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WechatPaymentUpdate 微信支付保存请求(空字符串 = 保持原值) // Logan.Li
|
||||
type WechatPaymentUpdate struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
MchID *string `json:"mch_id"`
|
||||
AppID *string `json:"app_id"`
|
||||
APIv3Key string `json:"api_v3_key"` // 32 位;留空表示不修改
|
||||
ClearAPIv3Key bool `json:"clear_api_v3_key"`
|
||||
SerialNo *string `json:"serial_no"`
|
||||
PayMethods []string `json:"pay_methods"`
|
||||
}
|
||||
|
||||
// AlipayPaymentUpdate 支付宝保存请求(空字符串 = 保持原值) // Logan.Li
|
||||
type AlipayPaymentUpdate struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
AppID *string `json:"app_id"`
|
||||
PrivateKey string `json:"private_key"` // 可粘贴 PEM / 裸 base64
|
||||
ClearPrivateKey bool `json:"clear_private_key"`
|
||||
PublicKey string `json:"public_key"`
|
||||
ClearPublicKey bool `json:"clear_public_key"`
|
||||
Gateway *string `json:"gateway"`
|
||||
SignType *string `json:"sign_type"`
|
||||
}
|
||||
|
||||
// PaymentConfigUpdate 支付配置保存请求 // Logan.Li
|
||||
type PaymentConfigUpdate struct {
|
||||
Mode *string `json:"mode"` // test / live
|
||||
Wechat *WechatPaymentUpdate `json:"wechat"`
|
||||
Alipay *AlipayPaymentUpdate `json:"alipay"`
|
||||
}
|
||||
|
||||
// PaymentUploadResponse 证书/密钥上传结果 // Logan.Li
|
||||
type PaymentUploadResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
File PaymentFileInfo `json:"file"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// PaymentTestRequest 连通性测试请求 // Logan.Li
|
||||
type PaymentTestRequest struct {
|
||||
Channel string `json:"channel"` // wechat / alipay / all(默认)
|
||||
}
|
||||
|
||||
// PaymentTestResult 单渠道连通性测试结果 // Logan.Li
|
||||
type PaymentTestResult struct {
|
||||
Channel string `json:"channel"`
|
||||
Status string `json:"status"` // ok / fail / skipped
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Detail string `json:"detail"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
// PaymentTestResponse 连通性测试整体结果 // Logan.Li
|
||||
type PaymentTestResponse struct {
|
||||
Results []PaymentTestResult `json:"results"`
|
||||
}
|
||||
|
||||
// WechatPlatformCertItem 微信支付平台证书条目 // Logan.Li
|
||||
type WechatPlatformCertItem struct {
|
||||
SerialNo string `json:"serial_no"`
|
||||
Subject string `json:"subject"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
SavedPath string `json:"-"` // 不回显绝对路径
|
||||
IsSaved bool `json:"is_saved"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PaymentConfig 支付渠道配置(单行表,固定使用 ID=1)。
|
||||
// 敏感字段(APIv3 密钥、应用私钥)以 AES-GCM 密文入库,绝不存明文。 // Logan.Li
|
||||
type PaymentConfig struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Mode string `json:"mode" gorm:"size:10;default:'test'"` // test / live
|
||||
|
||||
// ============ 微信支付(APIv3) ============
|
||||
WechatEnabled bool `json:"wechat_enabled" gorm:"default:false"`
|
||||
WechatMchID string `json:"wechat_mch_id" gorm:"size:64"`
|
||||
WechatAppID string `json:"wechat_app_id" gorm:"size:64"`
|
||||
WechatAPIv3KeyEnc string `json:"-" gorm:"column:wechat_apiv3_key_enc;type:text"` // AES-GCM 密文
|
||||
WechatSerialNo string `json:"wechat_serial_no" gorm:"size:128"` // 商户 API 证书序列号
|
||||
WechatCertPath string `json:"-" gorm:"column:wechat_cert_path;size:512"` // apiclient_cert.pem 落地路径
|
||||
WechatKeyPath string `json:"-" gorm:"column:wechat_key_path;size:512"` // apiclient_key.pem 落地路径
|
||||
WechatPlatformCertPath string `json:"-" gorm:"column:wechat_platform_cert_path;size:512"`
|
||||
WechatPayMethods string `json:"wechat_pay_methods" gorm:"size:64;default:'native'"` // 逗号分隔 native,h5,jsapi
|
||||
|
||||
// ============ 支付宝(开放平台) ============
|
||||
AlipayEnabled bool `json:"alipay_enabled" gorm:"default:false"`
|
||||
AlipayAppID string `json:"alipay_app_id" gorm:"size:64"`
|
||||
AlipayPrivateKeyEnc string `json:"-" gorm:"column:alipay_private_key_enc;type:text"` // AES-GCM 密文(粘贴来源)
|
||||
AlipayPublicKeyEnc string `json:"-" gorm:"column:alipay_public_key_enc;type:text"` // AES-GCM 密文(粘贴来源)
|
||||
AlipayPrivateKeyPath string `json:"-" gorm:"column:alipay_private_key_path;size:512"`
|
||||
AlipayPublicKeyPath string `json:"-" gorm:"column:alipay_public_key_path;size:512"`
|
||||
AlipayGateway string `json:"alipay_gateway" gorm:"size:255"`
|
||||
AlipaySignType string `json:"alipay_sign_type" gorm:"size:20;default:'RSA2'"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名 // Logan.Li
|
||||
func (PaymentConfig) TableName() string {
|
||||
return "payment_configs"
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 支付配置敏感字段的对称加密实现(AES-256-GCM)。
|
||||
//
|
||||
// 设计要点:
|
||||
// 1. 主密钥来自环境变量 PAYMENT_SECRET_KEY(32 字节 hex / base64,或任意口令经 SHA-256 派生);
|
||||
// 2. 未设置时退化为用 JWT_SECRET 做域分离派生,保证开箱可用,但轮换 JWT_SECRET 会失去解密能力;
|
||||
// 3. 密文格式 enc:v1:<base64url(nonce||ciphertext)>,前缀便于未来平滑升级算法。 // Logan.Li
|
||||
const (
|
||||
// EnvPaymentMasterKey 加密主密钥环境变量名 // Logan.Li
|
||||
EnvPaymentMasterKey = "PAYMENT_SECRET_KEY"
|
||||
// cipherPrefix 密文前缀 // Logan.Li
|
||||
cipherPrefix = "enc:v1:"
|
||||
// cipherAAD 附加认证数据,避免密文被跨用途搬运 // Logan.Li
|
||||
cipherAAD = "opsx-licence/payment-config/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
masterKeyOnce sync.Once
|
||||
masterKeyBytes []byte
|
||||
masterKeyErr error
|
||||
masterKeyOrigin string
|
||||
)
|
||||
|
||||
// PaymentMasterKeyOrigin 返回主密钥来源(env / jwt_secret),供页面提示 // Logan.Li
|
||||
func PaymentMasterKeyOrigin() string {
|
||||
_, _ = masterKey()
|
||||
return masterKeyOrigin
|
||||
}
|
||||
|
||||
// masterKey 惰性解析主密钥,仅解析一次 // Logan.Li
|
||||
func masterKey() ([]byte, error) {
|
||||
masterKeyOnce.Do(func() {
|
||||
if raw := strings.TrimSpace(os.Getenv(EnvPaymentMasterKey)); raw != "" {
|
||||
key, err := parseKeyMaterial(raw)
|
||||
if err != nil {
|
||||
masterKeyErr = err
|
||||
return
|
||||
}
|
||||
masterKeyBytes = key
|
||||
masterKeyOrigin = "env"
|
||||
return
|
||||
}
|
||||
if jwt := strings.TrimSpace(os.Getenv("JWT_SECRET")); jwt != "" {
|
||||
sum := sha256.Sum256([]byte(cipherAAD + "|jwt:" + jwt))
|
||||
masterKeyBytes = sum[:]
|
||||
masterKeyOrigin = "jwt_secret"
|
||||
log.Printf("[支付配置] 未设置 %s,已用 JWT_SECRET 派生加密主密钥;生产环境建议显式设置独立主密钥", EnvPaymentMasterKey)
|
||||
return
|
||||
}
|
||||
masterKeyOrigin = "none"
|
||||
masterKeyErr = errors.New("未配置加密主密钥:请设置环境变量 " + EnvPaymentMasterKey + "(32 字节 hex/base64,或任意口令)")
|
||||
})
|
||||
return masterKeyBytes, masterKeyErr
|
||||
}
|
||||
|
||||
// parseKeyMaterial 把环境变量值解析成 32 字节密钥 // Logan.Li
|
||||
func parseKeyMaterial(raw string) ([]byte, error) {
|
||||
// 1) 64 位 hex
|
||||
if len(raw) == 64 {
|
||||
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
// 2) base64(标准 / URL / 无填充)
|
||||
for _, enc := range []*base64.Encoding{
|
||||
base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding,
|
||||
} {
|
||||
if b, err := enc.DecodeString(raw); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
// 3) 任意口令:SHA-256 派生
|
||||
if len(raw) >= 8 {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return sum[:], nil
|
||||
}
|
||||
return nil, errors.New("PAYMENT_SECRET_KEY 过短(至少 8 个字符,推荐 openssl rand -hex 32)")
|
||||
}
|
||||
|
||||
// EncryptSecret 加密敏感字段;空串直接返回空串 // Logan.Li
|
||||
func EncryptSecret(plain string) (string, error) {
|
||||
if strings.TrimSpace(plain) == "" {
|
||||
return "", nil
|
||||
}
|
||||
key, err := masterKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plain), []byte(cipherAAD))
|
||||
return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// DecryptSecret 解密敏感字段;无前缀视为历史明文直接返回 // Logan.Li
|
||||
func DecryptSecret(stored string) (string, error) {
|
||||
raw := strings.TrimSpace(stored)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(raw, cipherPrefix) {
|
||||
return raw, nil
|
||||
}
|
||||
key, err := masterKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(raw, cipherPrefix))
|
||||
if err != nil {
|
||||
return "", errors.New("密文格式错误")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload) < gcm.NonceSize() {
|
||||
return "", errors.New("密文长度异常")
|
||||
}
|
||||
nonce, cipherText := payload[:gcm.NonceSize()], payload[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, cipherText, []byte(cipherAAD))
|
||||
if err != nil {
|
||||
return "", errors.New("解密失败:加密主密钥(PAYMENT_SECRET_KEY)与保存时不一致")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// MaskTail 脱敏展示:仅保留末尾 keep 位,其余用掩码填充到 total 位 // Logan.Li
|
||||
func MaskTail(secret string, keep, total int) string {
|
||||
s := strings.TrimSpace(secret)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if total <= 0 {
|
||||
total = len(s)
|
||||
}
|
||||
if total < keep {
|
||||
total = keep
|
||||
}
|
||||
if len(s) <= keep {
|
||||
return strings.Repeat("•", total-len(s)) + s
|
||||
}
|
||||
return strings.Repeat("•", total-keep) + s[len(s)-keep:]
|
||||
}
|
||||
|
||||
// FingerprintSHA256 计算内容指纹,用于「已配置但不回显」字段 // Logan.Li
|
||||
func FingerprintSHA256(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ShortFingerprint 指纹取前 16 位做展示 // Logan.Li
|
||||
func ShortFingerprint(fp string) string {
|
||||
if len(fp) <= 16 {
|
||||
return fp
|
||||
}
|
||||
return fp[:16]
|
||||
}
|
||||
|
||||
// NormalizePEM 把可能是裸 base64 的密钥补全成 PEM 块 // Logan.Li
|
||||
func NormalizePEM(raw, begin, end string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
// 已含 PEM 头则原样(统一换行)
|
||||
if strings.Contains(s, "-----BEGIN") {
|
||||
return strings.ReplaceAll(s, "\r\n", "\n")
|
||||
}
|
||||
body := strings.Join(strings.Fields(s), "")
|
||||
var b strings.Builder
|
||||
b.WriteString(begin)
|
||||
b.WriteString("\n")
|
||||
for i := 0; i < len(body); i += 64 {
|
||||
j := i + 64
|
||||
if j > len(body) {
|
||||
j = len(body)
|
||||
}
|
||||
b.WriteString(body[i:j])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(end)
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain 固定测试用加密主密钥(masterKey 只会解析一次) // Logan.Li
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv(EnvPaymentMasterKey, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
os.Setenv("PAYMENT_CERT_DIR", os.TempDir()+"/opsx-licence-test-certs")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// TestEncryptDecryptRoundTrip 验证 AES-GCM 加解密与密文格式 // Logan.Li
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
plain := "wx-api-v3-key-32-bytes-example!!"
|
||||
enc, err := EncryptSecret(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(enc, cipherPrefix) {
|
||||
t.Fatalf("密文缺少前缀: %s", enc)
|
||||
}
|
||||
if strings.Contains(enc, plain) {
|
||||
t.Fatalf("密文中出现了明文")
|
||||
}
|
||||
got, err := DecryptSecret(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Fatalf("解密结果不一致: %q", got)
|
||||
}
|
||||
|
||||
// 同一明文两次加密应产生不同密文(随机 nonce) // Logan.Li
|
||||
enc2, _ := EncryptSecret(plain)
|
||||
if enc == enc2 {
|
||||
t.Fatalf("两次加密结果相同,nonce 未随机化")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncryptSecretEmpty 空值不应产生密文 // Logan.Li
|
||||
func TestEncryptSecretEmpty(t *testing.T) {
|
||||
enc, err := EncryptSecret(" ")
|
||||
if err != nil || enc != "" {
|
||||
t.Fatalf("空值加密应返回空串,得到 %q err=%v", enc, err)
|
||||
}
|
||||
if v, err := DecryptSecret(""); err != nil || v != "" {
|
||||
t.Fatalf("空密文应返回空串,得到 %q err=%v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecryptTampered 篡改密文必须解密失败 // Logan.Li
|
||||
func TestDecryptTampered(t *testing.T) {
|
||||
enc, _ := EncryptSecret("secret-value")
|
||||
tampered := enc[:len(enc)-4] + "AAAA"
|
||||
if _, err := DecryptSecret(tampered); err == nil {
|
||||
t.Fatalf("篡改后的密文不应解密成功")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaskTail 脱敏仅保留末位 // Logan.Li
|
||||
func TestMaskTail(t *testing.T) {
|
||||
masked := MaskTail("0123456789abcdef0123456789abcdef", 4, 32)
|
||||
if !strings.HasSuffix(masked, "cdef") {
|
||||
t.Fatalf("脱敏结果应保留后 4 位: %s", masked)
|
||||
}
|
||||
if len([]rune(masked)) != 32 {
|
||||
t.Fatalf("脱敏长度应为 32: %d", len([]rune(masked)))
|
||||
}
|
||||
if MaskTail("", 4, 32) != "" {
|
||||
t.Fatalf("空值脱敏应为空串")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPayMethods 支付方式校验/去重与展示解析 // Logan.Li
|
||||
func TestPayMethods(t *testing.T) {
|
||||
got, err := normalizePayMethods([]string{"native", " h5 ", "native", "JSAPI"})
|
||||
if err != nil {
|
||||
t.Fatalf("合法支付方式不应报错: %v", err)
|
||||
}
|
||||
want := []string{"native", "h5", "jsapi"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("解析结果数量不符: %v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("解析结果不符: %v", got)
|
||||
}
|
||||
}
|
||||
if _, err := normalizePayMethods([]string{"alipay"}); err == nil {
|
||||
t.Fatalf("非法支付方式应报错")
|
||||
}
|
||||
if _, err := normalizePayMethods(nil); err == nil {
|
||||
t.Fatalf("空支付方式应报错")
|
||||
}
|
||||
if def := splitPayMethods(""); len(def) != 1 || def[0] != "native" {
|
||||
t.Fatalf("空串展示应回退 native: %v", def)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizePEM 裸 base64 自动补 PEM 头 // Logan.Li
|
||||
func TestNormalizePEM(t *testing.T) {
|
||||
out := NormalizePEM("QUJDREVGRw==", "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||
if !strings.HasPrefix(out, "-----BEGIN PUBLIC KEY-----") || !strings.Contains(out, "QUJDREVGRw==") {
|
||||
t.Fatalf("PEM 补全失败: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 证书/密钥落地目录相关逻辑。
|
||||
// 目录必须位于仓库之外(容器内为数据卷 /data/payment-certs),权限 0700,文件 0600,
|
||||
// 不通过任何静态路由对外暴露。 // Logan.Li
|
||||
|
||||
// EnvPaymentCertDir 证书目录覆盖变量 // Logan.Li
|
||||
const EnvPaymentCertDir = "PAYMENT_CERT_DIR"
|
||||
|
||||
// 允许上传的文件类型 // Logan.Li
|
||||
const (
|
||||
UploadKindWechatCert = "wechat_cert" // apiclient_cert.pem
|
||||
UploadKindWechatKey = "wechat_key" // apiclient_key.pem
|
||||
UploadKindWechatPlatformCert = "wechat_platform_cert" // 微信支付平台证书
|
||||
UploadKindAlipayPrivateKey = "alipay_private_key" // 支付宝应用私钥
|
||||
UploadKindAlipayPublicKey = "alipay_public_key" // 支付宝公钥
|
||||
)
|
||||
|
||||
// fixedFileNames 每种类型固定落地文件名,避免用户文件名注入 // Logan.Li
|
||||
var fixedFileNames = map[string]string{
|
||||
UploadKindWechatCert: "wechat_apiclient_cert.pem",
|
||||
UploadKindWechatKey: "wechat_apiclient_key.pem",
|
||||
UploadKindWechatPlatformCert: "wechat_platform_cert.pem",
|
||||
UploadKindAlipayPrivateKey: "alipay_app_private_key.pem",
|
||||
UploadKindAlipayPublicKey: "alipay_public_key.pem",
|
||||
}
|
||||
|
||||
// IsValidUploadKind 判断上传类型是否受支持 // Logan.Li
|
||||
func IsValidUploadKind(kind string) bool {
|
||||
_, ok := fixedFileNames[kind]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsCertKind 该类文件应为 X.509 证书 // Logan.Li
|
||||
func IsCertKind(kind string) bool {
|
||||
return kind == UploadKindWechatCert || kind == UploadKindWechatPlatformCert
|
||||
}
|
||||
|
||||
// CertDir 返回证书目录(默认容器数据卷内,仓库之外) // Logan.Li
|
||||
func CertDir() string {
|
||||
if d := strings.TrimSpace(os.Getenv(EnvPaymentCertDir)); d != "" {
|
||||
return d
|
||||
}
|
||||
return "/data/payment-certs"
|
||||
}
|
||||
|
||||
// ensureCertDir 确保目录存在且权限为 0700,必要时回退到临时目录 // Logan.Li
|
||||
func ensureCertDir() (string, error) {
|
||||
dir := CertDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
_ = os.Chmod(dir, 0o700)
|
||||
return dir, nil
|
||||
}
|
||||
fallback := filepath.Join(os.TempDir(), "opsx-licence-payment-certs")
|
||||
if err := os.MkdirAll(fallback, 0o700); err != nil {
|
||||
return "", errors.New("无法创建证书目录,请检查 PAYMENT_CERT_DIR 配置")
|
||||
}
|
||||
_ = os.Chmod(fallback, 0o700)
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// WriteSecretFile 以 0600 权限原子写入敏感文件 // Logan.Li
|
||||
func WriteSecretFile(kind string, content []byte) (string, error) {
|
||||
name, ok := fixedFileNames[kind]
|
||||
if !ok {
|
||||
return "", errors.New("不支持的上传类型")
|
||||
}
|
||||
dir, err := ensureCertDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(dir, name)
|
||||
tmp, err := os.CreateTemp(dir, ".upload-*")
|
||||
if err != nil {
|
||||
return "", errors.New("写入证书文件失败")
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
tmp.Close()
|
||||
return "", errors.New("写入证书文件失败")
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", errors.New("写入证书文件失败")
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o600); err != nil {
|
||||
return "", errors.New("设置证书文件权限失败")
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return "", errors.New("保存证书文件失败")
|
||||
}
|
||||
_ = os.Chmod(target, 0o600)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// ReadSecretFile 读取已配置的证书/密钥文件 // Logan.Li
|
||||
func ReadSecretFile(path string) ([]byte, error) {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return nil, errors.New("未配置文件")
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取文件失败(%s)", filepath.Base(p))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// BuildFileInfo 生成可展示的文件元信息(不含内容) // Logan.Li
|
||||
func BuildFileInfo(path string, wantCert bool) dto.PaymentFileInfo {
|
||||
info := dto.PaymentFileInfo{}
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return info
|
||||
}
|
||||
info.Configured = true
|
||||
info.FileName = filepath.Base(p)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
info.ParseError = "文件不可读"
|
||||
return info
|
||||
}
|
||||
info.SizeBytes = int64(len(b))
|
||||
sum := sha256.Sum256(b)
|
||||
info.Fingerprint = hex.EncodeToString(sum[:])[:16]
|
||||
if wantCert {
|
||||
cert, err := ParseCertificatePEM(b)
|
||||
if err != nil {
|
||||
info.ParseError = "证书解析失败:" + err.Error()
|
||||
return info
|
||||
}
|
||||
info.Subject = cert.Subject.CommonName
|
||||
info.NotBefore = cert.NotBefore.Format("2006-01-02 15:04:05")
|
||||
info.NotAfter = cert.NotAfter.Format("2006-01-02 15:04:05")
|
||||
return info
|
||||
}
|
||||
info.Subject = "私钥"
|
||||
return info
|
||||
}
|
||||
|
||||
// ParseCertificatePEM 解析 X.509 证书 PEM // Logan.Li
|
||||
func ParseCertificatePEM(content []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(content)
|
||||
if block == nil {
|
||||
return nil, errors.New("不是合法的 PEM 证书")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("证书内容不合法")
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// ParsePrivateKeyPEM 解析 RSA/EC 私钥(兼容 PKCS#1 / PKCS#8 / SEC1 / 裸 base64) // Logan.Li
|
||||
func ParsePrivateKeyPEM(content []byte) (any, error) {
|
||||
text := string(content)
|
||||
if block, _ := pem.Decode(content); block != nil {
|
||||
text = string(block.Bytes)
|
||||
} else if !strings.Contains(text, "-----BEGIN") {
|
||||
// 支付宝后台复制的是裸 base64,补 PEM 头再解析 // Logan.Li
|
||||
normalized := NormalizePEM(text, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||
if block2, _ := pem.Decode([]byte(normalized)); block2 != nil {
|
||||
text = string(block2.Bytes)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("私钥 PEM 解析失败")
|
||||
}
|
||||
raw := []byte(text)
|
||||
|
||||
if key, err := x509.ParsePKCS1PrivateKey(raw); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if keyAny, err := x509.ParsePKCS8PrivateKey(raw); err == nil {
|
||||
switch k := keyAny.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return k, nil
|
||||
case *ecdsa.PrivateKey:
|
||||
return k, nil
|
||||
default:
|
||||
return nil, errors.New("不支持的私钥类型(需要 RSA)")
|
||||
}
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(raw); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("私钥格式无法识别(需 PEM 或裸 base64 的 RSA 私钥)")
|
||||
}
|
||||
|
||||
// ParseRSAPrivateKey 强制解析为 RSA 私钥(微信/支付宝签名均需要 RSA) // Logan.Li
|
||||
func ParseRSAPrivateKey(content []byte) (*rsa.PrivateKey, error) {
|
||||
key, err := ParsePrivateKeyPEM(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("当前仅支持 RSA 私钥")
|
||||
}
|
||||
if err := rsaKey.Validate(); err != nil {
|
||||
return nil, errors.New("RSA 私钥校验失败")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
// ParseRSAPublicKey 解析支付宝公钥(支持证书 / PKIX / PKCS#1 / 裸 base64) // Logan.Li
|
||||
func ParseRSAPublicKey(content []byte) (*rsa.PublicKey, error) {
|
||||
trimmed := strings.TrimSpace(string(content))
|
||||
if strings.Contains(trimmed, "BEGIN CERTIFICATE") {
|
||||
if cert, err := ParseCertificatePEM(content); err == nil {
|
||||
if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
|
||||
return pub, nil
|
||||
}
|
||||
return nil, errors.New("证书内公钥不是 RSA")
|
||||
}
|
||||
}
|
||||
var der []byte
|
||||
if block, _ := pem.Decode(content); block != nil {
|
||||
der = block.Bytes
|
||||
} else {
|
||||
normalized := NormalizePEM(trimmed, "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||
block, _ := pem.Decode([]byte(normalized))
|
||||
if block == nil {
|
||||
return nil, errors.New("公钥格式无法识别")
|
||||
}
|
||||
der = block.Bytes
|
||||
}
|
||||
if pubAny, err := x509.ParsePKIXPublicKey(der); err == nil {
|
||||
if pub, ok := pubAny.(*rsa.PublicKey); ok {
|
||||
return pub, nil
|
||||
}
|
||||
return nil, errors.New("公钥不是 RSA")
|
||||
}
|
||||
if pub, err := x509.ParsePKCS1PublicKey(der); err == nil {
|
||||
return pub, nil
|
||||
}
|
||||
return nil, errors.New("公钥解析失败(请填写支付宝公钥,非应用公钥)")
|
||||
}
|
||||
|
||||
// FileModTime 取文件修改时间(页面展示用) // Logan.Li
|
||||
func FileModTime(path string) string {
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
return st.ModTime().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CertSerialHex 取证书序列号(大写十六进制,用于与填写值比对) // Logan.Li
|
||||
func CertSerialHex(cert *x509.Certificate) string {
|
||||
if cert == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(cert.SerialNumber.Text(16))
|
||||
}
|
||||
|
||||
// Sha1Fingerprint 证书 SHA-1 指纹(微信后台展示口径) // Logan.Li
|
||||
func Sha1Fingerprint(cert *x509.Certificate) string {
|
||||
if cert == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(cert.Raw)
|
||||
hexStr := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
parts := make([]string, 0, len(hexStr)/2)
|
||||
for i := 0; i < len(hexStr); i += 2 {
|
||||
parts = append(parts, hexStr[i:i+2])
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// NowUnix 当前时间戳(测试用) // Logan.Li
|
||||
func NowUnix() int64 { return time.Now().Unix() }
|
||||
@@ -0,0 +1,397 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 支付通道配置(微信支付 APIv3 / 支付宝开放平台)的读写层。
|
||||
//
|
||||
// 安全约定:
|
||||
// 1. 单行表 payment_configs(固定 ID=1),敏感字段只存 AES-GCM 密文(config_crypto.go)
|
||||
// 或 0600 权限的仓库外文件(config_files.go);
|
||||
// 2. 对外只返回 dto 脱敏视图,永不回显明文密钥/私钥;
|
||||
// 3. 证书/私钥文件路径不参与 JSON 输出,只以文件名+指纹的形式展示。 // Logan.Li
|
||||
|
||||
const (
|
||||
// DefaultAlipayGateway 支付宝正式网关 // Logan.Li
|
||||
DefaultAlipayGateway = "https://openapi.alipay.com/gateway.do"
|
||||
// WechatNotifyPath 微信支付回调路径 // Logan.Li
|
||||
WechatNotifyPath = "/api/pay/wechat/notify"
|
||||
// AlipayNotifyPath 支付宝回调路径 // Logan.Li
|
||||
AlipayNotifyPath = "/api/pay/alipay/notify"
|
||||
// EnvPublicBaseURL 对外访问基址(生成回调地址用,缺省用 licence.rlwj.cn) // Logan.Li
|
||||
EnvPublicBaseURL = "PAYMENT_PUBLIC_BASE_URL"
|
||||
// defaultPublicBaseURL 未配置时的默认基址 // Logan.Li
|
||||
defaultPublicBaseURL = "https://licence.rlwj.cn"
|
||||
)
|
||||
|
||||
// AlipayNotifyURL 支付宝回调地址(供签名请求携带) // Logan.Li
|
||||
var AlipayNotifyURL = joinURL(publicBaseURL(), AlipayNotifyPath)
|
||||
|
||||
// ConfigService 支付通道配置服务 // Logan.Li
|
||||
type ConfigService struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewConfigService 创建服务实例(cfg 允许缺省,便于测试) // Logan.Li
|
||||
func NewConfigService(db *gorm.DB, cfg ...*config.Config) *ConfigService {
|
||||
s := &ConfigService{db: db}
|
||||
if len(cfg) > 0 {
|
||||
s.cfg = cfg[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// loadRow 读取单行配置,缺失时按默认值建行 // Logan.Li
|
||||
func (s *ConfigService) loadRow() (*model.PaymentConfig, error) {
|
||||
var row model.PaymentConfig
|
||||
err := s.db.First(&row).Error
|
||||
if err == nil {
|
||||
if strings.TrimSpace(row.Mode) == "" {
|
||||
row.Mode = "test"
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("读取支付配置失败")
|
||||
}
|
||||
row = model.PaymentConfig{
|
||||
ID: 1,
|
||||
Mode: "test",
|
||||
WechatPayMethods: "native",
|
||||
AlipayGateway: DefaultAlipayGateway,
|
||||
AlipaySignType: "RSA2",
|
||||
}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, errors.New("初始化支付配置失败")
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// Get 返回脱敏后的完整配置视图 // Logan.Li
|
||||
func (s *ConfigService) Get() (*dto.PaymentConfigView, error) {
|
||||
row, err := s.loadRow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := publicBaseURL()
|
||||
view := &dto.PaymentConfigView{
|
||||
Mode: normalizeMode(row.Mode),
|
||||
MasterKeyOrigin: PaymentMasterKeyOrigin(),
|
||||
EncryptionEnabled: PaymentMasterKeyOrigin() != "none",
|
||||
UpdatedAt: row.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
view.Wechat = s.buildWechatView(row, base)
|
||||
view.Alipay = s.buildAlipayView(row, base)
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// buildWechatView 组装微信支付脱敏视图(含就绪度与缺失项) // Logan.Li
|
||||
func (s *ConfigService) buildWechatView(row *model.PaymentConfig, base string) dto.WechatPaymentView {
|
||||
v := dto.WechatPaymentView{
|
||||
Enabled: row.WechatEnabled,
|
||||
MchID: row.WechatMchID,
|
||||
AppID: row.WechatAppID,
|
||||
SerialNo: strings.ToUpper(strings.TrimSpace(row.WechatSerialNo)),
|
||||
PayMethods: splitPayMethods(row.WechatPayMethods),
|
||||
NotifyURL: joinURL(base, WechatNotifyPath),
|
||||
CertFile: BuildFileInfo(row.WechatCertPath, true),
|
||||
KeyFile: BuildFileInfo(row.WechatKeyPath, false),
|
||||
PlatformCertFile: BuildFileInfo(row.WechatPlatformCertPath, true),
|
||||
}
|
||||
if plain, err := DecryptSecret(row.WechatAPIv3KeyEnc); err == nil && plain != "" {
|
||||
v.APIv3KeyConfigured = true
|
||||
v.APIv3KeyLength = len(plain)
|
||||
v.APIv3KeyMasked = MaskTail(plain, 4, 32)
|
||||
}
|
||||
if v.MchID == "" {
|
||||
v.Missing = append(v.Missing, "商户号 mchid")
|
||||
}
|
||||
if v.AppID == "" {
|
||||
v.Missing = append(v.Missing, "AppID(需已在商户平台绑定)")
|
||||
}
|
||||
if !v.APIv3KeyConfigured {
|
||||
v.Missing = append(v.Missing, "APIv3 密钥")
|
||||
} else if v.APIv3KeyLength != 32 {
|
||||
v.Missing = append(v.Missing, "APIv3 密钥应为 32 位(当前 "+strconv.Itoa(v.APIv3KeyLength)+" 位)")
|
||||
}
|
||||
if v.SerialNo == "" {
|
||||
v.Missing = append(v.Missing, "商户 API 证书序列号")
|
||||
}
|
||||
if !v.CertFile.Configured {
|
||||
v.Missing = append(v.Missing, "apiclient_cert.pem 未上传")
|
||||
}
|
||||
if !v.KeyFile.Configured {
|
||||
v.Missing = append(v.Missing, "apiclient_key.pem 未上传")
|
||||
}
|
||||
if v.CertFile.Configured && v.SerialNo != "" {
|
||||
if b, err := ReadSecretFile(row.WechatCertPath); err == nil {
|
||||
if cert, err := ParseCertificatePEM(b); err == nil {
|
||||
if actual := CertSerialHex(cert); actual != "" && !strings.EqualFold(actual, v.SerialNo) {
|
||||
v.Missing = append(v.Missing, "证书序列号与已上传证书不一致(证书实际为 "+actual+")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v.Ready = len(v.Missing) == 0
|
||||
return v
|
||||
}
|
||||
|
||||
// buildAlipayView 组装支付宝脱敏视图 // Logan.Li
|
||||
func (s *ConfigService) buildAlipayView(row *model.PaymentConfig, base string) dto.AlipayPaymentView {
|
||||
v := dto.AlipayPaymentView{
|
||||
Enabled: row.AlipayEnabled,
|
||||
AppID: row.AlipayAppID,
|
||||
Gateway: firstNonEmptyStr(row.AlipayGateway, DefaultAlipayGateway),
|
||||
SignType: firstNonEmptyStr(row.AlipaySignType, "RSA2"),
|
||||
NotifyURL: joinURL(base, AlipayNotifyPath),
|
||||
PrivateKeyFile: BuildFileInfo(row.AlipayPrivateKeyPath, false),
|
||||
PublicKeyFile: BuildFileInfo(row.AlipayPublicKeyPath, false),
|
||||
}
|
||||
if plain, err := DecryptSecret(row.AlipayPrivateKeyEnc); err == nil && plain != "" {
|
||||
v.PrivateKeyConfigured = true
|
||||
v.PrivateKeySource = "paste"
|
||||
v.PrivateKeyMasked = "指纹 " + ShortFingerprint(FingerprintSHA256([]byte(plain)))
|
||||
}
|
||||
if v.PrivateKeyFile.Configured {
|
||||
v.PrivateKeyConfigured = true
|
||||
v.PrivateKeySource = "upload"
|
||||
v.PrivateKeyMasked = "指纹 " + v.PrivateKeyFile.Fingerprint
|
||||
}
|
||||
if plain, err := DecryptSecret(row.AlipayPublicKeyEnc); err == nil && plain != "" {
|
||||
v.PublicKeyConfigured = true
|
||||
v.PublicKeyMasked = "指纹 " + ShortFingerprint(FingerprintSHA256([]byte(plain)))
|
||||
} else if v.PublicKeyFile.Configured {
|
||||
v.PublicKeyConfigured = true
|
||||
v.PublicKeyMasked = "指纹 " + v.PublicKeyFile.Fingerprint
|
||||
}
|
||||
if v.AppID == "" {
|
||||
v.Missing = append(v.Missing, "app_id")
|
||||
}
|
||||
if !v.PrivateKeyConfigured {
|
||||
v.Missing = append(v.Missing, "应用私钥")
|
||||
}
|
||||
if !v.PublicKeyConfigured {
|
||||
v.Missing = append(v.Missing, "支付宝公钥(非应用公钥)")
|
||||
}
|
||||
v.Ready = len(v.Missing) == 0
|
||||
return v
|
||||
}
|
||||
|
||||
// Update 保存配置(指针为 nil 的字段表示不改动) // Logan.Li
|
||||
func (s *ConfigService) Update(req dto.PaymentConfigUpdate) (*dto.PaymentConfigView, error) {
|
||||
row, err := s.loadRow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Mode != nil {
|
||||
mode := strings.ToLower(strings.TrimSpace(*req.Mode))
|
||||
if mode != "test" && mode != "live" {
|
||||
return nil, errors.New("运行模式只能是 test 或 live")
|
||||
}
|
||||
row.Mode = mode
|
||||
}
|
||||
|
||||
if in := req.Wechat; in != nil {
|
||||
if in.Enabled != nil {
|
||||
row.WechatEnabled = *in.Enabled
|
||||
}
|
||||
if in.MchID != nil {
|
||||
row.WechatMchID = strings.TrimSpace(*in.MchID)
|
||||
}
|
||||
if in.AppID != nil {
|
||||
row.WechatAppID = strings.TrimSpace(*in.AppID)
|
||||
}
|
||||
if in.SerialNo != nil {
|
||||
row.WechatSerialNo = strings.ToUpper(strings.TrimSpace(*in.SerialNo))
|
||||
}
|
||||
if len(in.PayMethods) > 0 {
|
||||
methods, merr := normalizePayMethods(in.PayMethods)
|
||||
if merr != nil {
|
||||
return nil, merr
|
||||
}
|
||||
row.WechatPayMethods = strings.Join(methods, ",")
|
||||
}
|
||||
if in.ClearAPIv3Key {
|
||||
row.WechatAPIv3KeyEnc = ""
|
||||
} else if key := strings.TrimSpace(in.APIv3Key); key != "" {
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("APIv3 密钥必须为 32 位字符(当前 %d 位)", len(key))
|
||||
}
|
||||
enc, eerr := EncryptSecret(key)
|
||||
if eerr != nil {
|
||||
return nil, eerr
|
||||
}
|
||||
row.WechatAPIv3KeyEnc = enc
|
||||
}
|
||||
}
|
||||
|
||||
if in := req.Alipay; in != nil {
|
||||
if in.Enabled != nil {
|
||||
row.AlipayEnabled = *in.Enabled
|
||||
}
|
||||
if in.AppID != nil {
|
||||
row.AlipayAppID = strings.TrimSpace(*in.AppID)
|
||||
}
|
||||
if in.Gateway != nil {
|
||||
gw := strings.TrimSpace(*in.Gateway)
|
||||
if gw == "" {
|
||||
gw = DefaultAlipayGateway
|
||||
}
|
||||
if u, uerr := url.Parse(gw); uerr != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return nil, errors.New("支付宝网关地址不合法(需完整的 http/https 地址)")
|
||||
}
|
||||
row.AlipayGateway = gw
|
||||
}
|
||||
if in.SignType != nil {
|
||||
st := strings.ToUpper(strings.TrimSpace(*in.SignType))
|
||||
if st == "" {
|
||||
st = "RSA2"
|
||||
}
|
||||
if st != "RSA2" && st != "RSA" {
|
||||
return nil, errors.New("签名类型仅支持 RSA2(推荐)或 RSA")
|
||||
}
|
||||
row.AlipaySignType = st
|
||||
}
|
||||
if in.ClearPrivateKey {
|
||||
row.AlipayPrivateKeyEnc = ""
|
||||
row.AlipayPrivateKeyPath = ""
|
||||
} else if pk := strings.TrimSpace(in.PrivateKey); pk != "" {
|
||||
normalized := NormalizePEM(pk, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||
if _, perr := ParseRSAPrivateKey([]byte(normalized)); perr != nil {
|
||||
return nil, errors.New("应用私钥无法解析:" + perr.Error())
|
||||
}
|
||||
enc, eerr := EncryptSecret(normalized)
|
||||
if eerr != nil {
|
||||
return nil, eerr
|
||||
}
|
||||
row.AlipayPrivateKeyEnc = enc
|
||||
row.AlipayPrivateKeyPath = "" // 粘贴优先,清掉旧文件避免来源歧义 // Logan.Li
|
||||
}
|
||||
if in.ClearPublicKey {
|
||||
row.AlipayPublicKeyEnc = ""
|
||||
row.AlipayPublicKeyPath = ""
|
||||
} else if pub := strings.TrimSpace(in.PublicKey); pub != "" {
|
||||
normalized := NormalizePEM(pub, "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||
if _, perr := ParseRSAPublicKey([]byte(normalized)); perr != nil {
|
||||
return nil, errors.New("支付宝公钥无法解析(注意是支付宝公钥,不是应用公钥):" + perr.Error())
|
||||
}
|
||||
enc, eerr := EncryptSecret(normalized)
|
||||
if eerr != nil {
|
||||
return nil, eerr
|
||||
}
|
||||
row.AlipayPublicKeyEnc = enc
|
||||
row.AlipayPublicKeyPath = ""
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.Save(row).Error; err != nil {
|
||||
return nil, errors.New("保存支付配置失败")
|
||||
}
|
||||
return s.Get()
|
||||
}
|
||||
|
||||
// alipayPrivateKey 取支付宝应用私钥(粘贴值优先,其次已上传文件) // Logan.Li
|
||||
func (s *ConfigService) alipayPrivateKey(row *model.PaymentConfig) (string, error) {
|
||||
if plain, err := DecryptSecret(row.AlipayPrivateKeyEnc); err == nil && strings.TrimSpace(plain) != "" {
|
||||
return plain, nil
|
||||
}
|
||||
if b, err := ReadSecretFile(row.AlipayPrivateKeyPath); err == nil && len(b) > 0 {
|
||||
return string(b), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// alipayPublicKey 取支付宝公钥(粘贴值优先,其次已上传文件) // Logan.Li
|
||||
func (s *ConfigService) alipayPublicKey(row *model.PaymentConfig) (string, error) {
|
||||
if plain, err := DecryptSecret(row.AlipayPublicKeyEnc); err == nil && strings.TrimSpace(plain) != "" {
|
||||
return plain, nil
|
||||
}
|
||||
if b, err := ReadSecretFile(row.AlipayPublicKeyPath); err == nil && len(b) > 0 {
|
||||
return string(b), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ============ 通用工具 ============
|
||||
|
||||
// publicBaseURL 对外访问基址(用于生成回调地址) // Logan.Li
|
||||
func publicBaseURL() string {
|
||||
if v := strings.TrimSpace(os.Getenv(EnvPublicBaseURL)); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return defaultPublicBaseURL
|
||||
}
|
||||
|
||||
// joinURL 拼接基址与路径 // Logan.Li
|
||||
func joinURL(base, path string) string {
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
// normalizeMode 归一化运行模式 // Logan.Li
|
||||
func normalizeMode(mode string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(mode), "live") {
|
||||
return "live"
|
||||
}
|
||||
return "test"
|
||||
}
|
||||
|
||||
// normalizePayMethods 校验并去重支付方式 // Logan.Li
|
||||
func normalizePayMethods(in []string) ([]string, error) {
|
||||
allowed := map[string]bool{"native": true, "h5": true, "jsapi": true}
|
||||
out := make([]string, 0, len(in))
|
||||
seen := map[string]bool{}
|
||||
for _, m := range in {
|
||||
k := strings.ToLower(strings.TrimSpace(m))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if !allowed[k] {
|
||||
return nil, errors.New("支付方式只能是 native(扫码)/ h5 / jsapi")
|
||||
}
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("至少选择一种支付方式")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitPayMethods 逗号分隔串 → 切片 // Logan.Li
|
||||
func splitPayMethods(raw string) []string {
|
||||
out := make([]string, 0, 3)
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
if v := strings.ToLower(strings.TrimSpace(p)); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"native"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// itoa 整数转字符串(避免各文件重复引入 strconv 别名) // Logan.Li
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 支付渠道连通性测试(只做最小校验,不下单、不写业务数据)。
|
||||
// 无真实凭据时返回 status=skipped,绝不以 500 结束。 // Logan.Li
|
||||
|
||||
// 微信支付 APIv3 基础地址 // Logan.Li
|
||||
const wechatAPIBase = "https://api.mch.weixin.qq.com"
|
||||
|
||||
// Test 执行连通性测试(channel: wechat / alipay / all) // Logan.Li
|
||||
func (s *ConfigService) Test(channel string) *dto.PaymentTestResponse {
|
||||
ch := strings.ToLower(strings.TrimSpace(channel))
|
||||
resp := &dto.PaymentTestResponse{Results: []dto.PaymentTestResult{}}
|
||||
if ch == "" || ch == "all" {
|
||||
resp.Results = append(resp.Results, s.TestWechat(), s.TestAlipay())
|
||||
return resp
|
||||
}
|
||||
switch ch {
|
||||
case "wechat":
|
||||
resp.Results = append(resp.Results, s.TestWechat())
|
||||
case "alipay":
|
||||
resp.Results = append(resp.Results, s.TestAlipay())
|
||||
default:
|
||||
resp.Results = append(resp.Results, dto.PaymentTestResult{
|
||||
Channel: ch, Status: "fail", OK: false,
|
||||
Message: "未知渠道:" + ch + "(仅支持 wechat / alipay / all)",
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ============ 支付宝 ============
|
||||
|
||||
// TestAlipay 调用支付宝网关做一次签名校验请求 // Logan.Li
|
||||
func (s *ConfigService) TestAlipay() dto.PaymentTestResult {
|
||||
res := dto.PaymentTestResult{Channel: "alipay"}
|
||||
row, err := s.loadRow()
|
||||
if err != nil {
|
||||
res.Status, res.Message = "fail", err.Error()
|
||||
return res
|
||||
}
|
||||
appID := strings.TrimSpace(row.AlipayAppID)
|
||||
privPEM, _ := s.alipayPrivateKey(row)
|
||||
var missing []string
|
||||
if appID == "" {
|
||||
missing = append(missing, "app_id")
|
||||
}
|
||||
if strings.TrimSpace(privPEM) == "" {
|
||||
missing = append(missing, "应用私钥")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
res.Status = "skipped"
|
||||
res.Message = "未配置:" + strings.Join(missing, "、") + "(跳过连通性校验)"
|
||||
return res
|
||||
}
|
||||
|
||||
key, err := ParseRSAPrivateKey([]byte(privPEM))
|
||||
if err != nil {
|
||||
res.Status, res.Message = "fail", "应用私钥不可用:"+err.Error()
|
||||
return res
|
||||
}
|
||||
gateway := strings.TrimSpace(row.AlipayGateway)
|
||||
if gateway == "" {
|
||||
gateway = DefaultAlipayGateway
|
||||
}
|
||||
|
||||
params := map[string]string{
|
||||
"app_id": appID,
|
||||
"method": "alipay.trade.query",
|
||||
"format": "JSON",
|
||||
"charset": "utf-8",
|
||||
"sign_type": "RSA2",
|
||||
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"version": "1.0",
|
||||
"notify_url": AlipayNotifyURL,
|
||||
"biz_content": `{"out_trade_no":"OPSX-CONFIG-CHECK"}`,
|
||||
}
|
||||
sign, err := alipaySign(params, key)
|
||||
if err != nil {
|
||||
res.Status, res.Message = "fail", "生成签名失败:"+err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
form := make([]string, 0, len(params)+1)
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
form = append(form, k+"="+urlEncode(params[k]))
|
||||
}
|
||||
form = append(form, "sign="+urlEncode(sign))
|
||||
|
||||
start := time.Now()
|
||||
status, body, err := httpPostForm(gateway, strings.Join(form, "&"), 12*time.Second)
|
||||
res.LatencyMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
res.Status = "fail"
|
||||
res.Message = "网关不可达:" + err.Error()
|
||||
return res
|
||||
}
|
||||
res.HTTPStatus = status
|
||||
|
||||
var wrapper struct {
|
||||
Resp json.RawMessage `json:"alipay_trade_query_response"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapper); err != nil || len(wrapper.Resp) == 0 {
|
||||
res.Status = "fail"
|
||||
res.Message = "网关返回非预期内容(HTTP " + itoa(status) + ")"
|
||||
res.Detail = truncateText(string(body), 300)
|
||||
return res
|
||||
}
|
||||
var biz struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
SubCode string `json:"sub_code"`
|
||||
SubMsg string `json:"sub_msg"`
|
||||
}
|
||||
_ = json.Unmarshal(wrapper.Resp, &biz)
|
||||
res.Detail = fmt.Sprintf("code=%s sub_code=%s sub_msg=%s", biz.Code, biz.SubCode, biz.SubMsg)
|
||||
|
||||
// 支付宝公钥验签(若已配置,能顺带验证公钥是否正确) // Logan.Li
|
||||
pubNote := ""
|
||||
if pubPEM, _ := s.alipayPublicKey(row); strings.TrimSpace(pubPEM) != "" {
|
||||
if pub, perr := ParseRSAPublicKey([]byte(pubPEM)); perr == nil && wrapper.Sign != "" {
|
||||
if verr := alipayVerify(wrapper.Resp, wrapper.Sign, pub); verr == nil {
|
||||
pubNote = ";支付宝公钥验签通过"
|
||||
} else {
|
||||
pubNote = ";注意:响应验签失败,支付宝公钥可能与应用不匹配"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case biz.Code == "10000":
|
||||
res.Status, res.OK = "ok", true
|
||||
res.Message = "签名校验通过:支付宝网关正常应答(交易查询成功)" + pubNote
|
||||
case strings.EqualFold(biz.SubCode, "ACQ.TRADE_NOT_EXIST"):
|
||||
res.Status, res.OK = "ok", true
|
||||
res.Message = "签名校验通过:网关已受理请求(测试单号不存在,属预期)" + pubNote
|
||||
case isAlipayAuthError(biz.Code, biz.SubCode):
|
||||
res.Status = "fail"
|
||||
res.Message = "签名/应用校验失败:" + firstNonEmptyStr(biz.SubMsg, biz.Msg) + "(请核对 app_id、应用私钥与密钥格式)"
|
||||
default:
|
||||
res.Status = "fail"
|
||||
res.Message = "网关返回业务错误:" + firstNonEmptyStr(biz.SubMsg, biz.Msg, biz.Code) + pubNote
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// isAlipayAuthError 判断是否属于签名/应用身份类错误 // Logan.Li
|
||||
func isAlipayAuthError(code, subCode string) bool {
|
||||
sc := strings.ToLower(strings.TrimSpace(subCode))
|
||||
c := strings.TrimSpace(code)
|
||||
for _, k := range []string{"isv.invalid-signature", "isv.missing-signature", "isv.invalid-app-id", "isv.invalid-encrypt-type", "isv.insufficient-isv-permissions"} {
|
||||
if sc == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return c == "40002" || c == "40006"
|
||||
}
|
||||
|
||||
// alipaySign 按支付宝规则(去 sign、字典序、k=v&…)做 SHA256withRSA 签名 // Logan.Li
|
||||
func alipaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "sign" || strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, k+"="+params[k])
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.Join(parts, "&")))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// alipayVerify 用支付宝公钥校验响应签名 // Logan.Li
|
||||
func alipayVerify(content []byte, signB64 string, pub *rsa.PublicKey) error {
|
||||
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signB64))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig)
|
||||
}
|
||||
|
||||
// ============ 通用 HTTP 辅助 ============
|
||||
|
||||
// httpPostForm 以 form-urlencoded 提交 // Logan.Li
|
||||
func httpPostForm(url, body string, timeout time.Duration) (int, []byte, error) {
|
||||
client := &http.Client{Timeout: timeout}
|
||||
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||
req.Header.Set("User-Agent", "opsx-licence/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
return resp.StatusCode, data, nil
|
||||
}
|
||||
|
||||
// urlEncode 表单值转义(空格用 %20,符合支付宝要求) // Logan.Li
|
||||
func urlEncode(s string) string {
|
||||
var b strings.Builder
|
||||
for _, c := range []byte(s) {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
b.WriteString("%")
|
||||
b.WriteString(strings.ToUpper(hex2(c)))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// hex2 单字节转两位十六进制 // Logan.Li
|
||||
func hex2(c byte) string {
|
||||
const digits = "0123456789abcdef"
|
||||
return string([]byte{digits[c>>4], digits[c&0x0f]})
|
||||
}
|
||||
|
||||
// truncateText 按字符截断(waffo_api.go 的 truncate 按字节,中文会切坏) // Logan.Li
|
||||
func truncateText(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// firstNonEmptyStr 返回第一个非空字符串 // Logan.Li
|
||||
func firstNonEmptyStr(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// randomNonce 生成随机串(微信 nonce_str) // Logan.Li
|
||||
func randomNonce(n int) (string, error) {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = charset[int(buf[i])%len(charset)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 微信支付 APIv3 连通性校验:用 mchid + 证书序列号 + 商户私钥请求 /v3/certificates。
|
||||
// 200 = 三者匹配且签名通过;401 = 签名/身份被拒;无凭据则 skipped。 // Logan.Li
|
||||
|
||||
// TestWechat 校验微信支付凭据是否可用 // Logan.Li
|
||||
func (s *ConfigService) TestWechat() dto.PaymentTestResult {
|
||||
res := dto.PaymentTestResult{Channel: "wechat"}
|
||||
row, err := s.loadRow()
|
||||
if err != nil {
|
||||
res.Status, res.Message = "fail", err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if strings.TrimSpace(row.WechatMchID) == "" {
|
||||
missing = append(missing, "商户号 mchid")
|
||||
}
|
||||
if strings.TrimSpace(row.WechatSerialNo) == "" {
|
||||
missing = append(missing, "证书序列号 serial_no")
|
||||
}
|
||||
if strings.TrimSpace(row.WechatKeyPath) == "" {
|
||||
missing = append(missing, "apiclient_key.pem")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
res.Status = "skipped"
|
||||
res.Message = "未配置:" + strings.Join(missing, "、") + "(跳过连通性校验)"
|
||||
return res
|
||||
}
|
||||
|
||||
keyBytes, err := ReadSecretFile(row.WechatKeyPath)
|
||||
if err != nil {
|
||||
res.Status, res.Message = "fail", "商户 API 私钥不可用:"+err.Error()
|
||||
return res
|
||||
}
|
||||
key, err := ParseRSAPrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
res.Status, res.Message = "fail", "商户 API 私钥解析失败:"+err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
// 上传了证书时先做本地一致性检查,避免把「序列号填错」误报成签名错误 // Logan.Li
|
||||
if strings.TrimSpace(row.WechatCertPath) != "" {
|
||||
if certBytes, cerr := ReadSecretFile(row.WechatCertPath); cerr == nil {
|
||||
if cert, perr := ParseCertificatePEM(certBytes); perr == nil {
|
||||
actual := CertSerialHex(cert)
|
||||
if !strings.EqualFold(actual, strings.TrimSpace(row.WechatSerialNo)) {
|
||||
res.Status = "fail"
|
||||
res.Message = "证书序列号与 apiclient_cert.pem 不匹配:证书实际序列号为 " + actual
|
||||
res.Detail = "请在微信商户平台「API 安全」核对证书序列号,或点上传后自动回填"
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
status, body, headers, err := wechatAPIGet("/v3/certificates", row.WechatMchID, row.WechatSerialNo, key)
|
||||
res.LatencyMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
res.Status = "fail"
|
||||
res.Message = "微信支付接口不可达:" + err.Error()
|
||||
return res
|
||||
}
|
||||
res.HTTPStatus = status
|
||||
|
||||
var errBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &errBody)
|
||||
|
||||
// 平台证书响应验签(若已上传平台证书,可顺带验证其有效性) // Logan.Li
|
||||
verifyNote := ""
|
||||
if strings.TrimSpace(row.WechatPlatformCertPath) != "" && status == 200 {
|
||||
verifyNote = verifyWechatResponse(row.WechatPlatformCertPath, headers, body)
|
||||
}
|
||||
|
||||
switch {
|
||||
case status == 200:
|
||||
res.Status, res.OK = "ok", true
|
||||
res.Message = "签名校验通过:mchid / 证书序列号 / 商户私钥匹配,平台证书接口可访问" + verifyNote
|
||||
var certResp struct {
|
||||
Data []struct {
|
||||
SerialNo string `json:"serial_no"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &certResp) == nil {
|
||||
res.Detail = fmt.Sprintf("平台证书数量=%d", len(certResp.Data))
|
||||
}
|
||||
case status == 401:
|
||||
res.Status = "fail"
|
||||
res.Message = "认证失败(HTTP 401):请核对商户号、证书序列号与 apiclient_key.pem 是否为同一套(" +
|
||||
firstNonEmptyStr(errBody.Code, "SIGN_ERROR") + ")"
|
||||
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
|
||||
case status == 403:
|
||||
res.Status, res.OK = "ok", true
|
||||
res.Message = "签名校验通过,但该商户/证书无「查询平台证书」权限(HTTP 403),实际下单前请确认权限集"
|
||||
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
|
||||
default:
|
||||
res.Status = "fail"
|
||||
res.Message = fmt.Sprintf("微信支付返回异常状态 HTTP %d(%s)", status, firstNonEmptyStr(errBody.Code, "-"))
|
||||
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// wechatAPIGet 发起一次 APIv3 GET 请求并返回状态码/响应体/响应头 // Logan.Li
|
||||
func wechatAPIGet(path, mchID, serialNo string, key *rsa.PrivateKey) (int, []byte, http.Header, error) {
|
||||
nonce, err := randomNonce(32)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
ts := fmt.Sprintf("%d", time.Now().Unix())
|
||||
sig, err := signWechatV3("GET", path, ts, nonce, "", key)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
auth := fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
|
||||
mchID, nonce, sig, ts, serialNo)
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, wechatAPIBase+path, nil)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "opsx-licence/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
return resp.StatusCode, body, resp.Header, nil
|
||||
}
|
||||
|
||||
// signWechatV3 生成 APIv3 请求签名(SHA256withRSA + 换行拼接的规范串) // Logan.Li
|
||||
func signWechatV3(method, urlPath, timestamp, nonce, body string, key *rsa.PrivateKey) (string, error) {
|
||||
message := method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||
digest := sha256.Sum256([]byte(message))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// verifyWechatResponse 用平台证书校验应答签名,返回给用户看的一句话说明 // Logan.Li
|
||||
func verifyWechatResponse(platformCertPath string, headers http.Header, body []byte) string {
|
||||
sigB64 := headers.Get("Wechatpay-Signature")
|
||||
ts := headers.Get("Wechatpay-Timestamp")
|
||||
nonce := headers.Get("Wechatpay-Nonce")
|
||||
if sigB64 == "" || ts == "" || nonce == "" {
|
||||
return ""
|
||||
}
|
||||
certBytes, err := ReadSecretFile(platformCertPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cert, err := ParseCertificatePEM(certBytes)
|
||||
if err != nil {
|
||||
return ";平台证书不可解析,已跳过应答验签"
|
||||
}
|
||||
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return ";平台证书公钥非 RSA,已跳过应答验签"
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
return ";平台证书应答验签失败(签名格式异常)"
|
||||
}
|
||||
message := ts + "\n" + nonce + "\n" + string(body) + "\n"
|
||||
digest := sha256.Sum256([]byte(message))
|
||||
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig); err != nil {
|
||||
return ";注意:平台证书应答验签失败,平台证书可能已过期或被替换"
|
||||
}
|
||||
return ";平台证书应答验签通过"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 证书 / 私钥上传处理:校验 PEM 可解析 → 0600 落地到仓库外目录 → 更新配置行。 // Logan.Li
|
||||
|
||||
// maxUploadBytes 单个上传文件大小上限(PEM 文本,64KB 足够) // Logan.Li
|
||||
const maxUploadBytes = 64 * 1024
|
||||
|
||||
// Upload 处理一次证书/密钥上传 // Logan.Li
|
||||
func (s *ConfigService) Upload(kind, filename string, content []byte) (*dto.PaymentUploadResponse, error) {
|
||||
if !IsValidUploadKind(kind) {
|
||||
return nil, errors.New("不支持的上传类型(可选:wechat_cert / wechat_key / wechat_platform_cert / alipay_private_key / alipay_public_key)")
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return nil, errors.New("上传文件为空")
|
||||
}
|
||||
if len(content) > maxUploadBytes {
|
||||
return nil, errors.New("文件过大(上限 64KB)")
|
||||
}
|
||||
|
||||
row, err := s.loadRow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var certSerial, note string
|
||||
// 1) 内容校验(解析失败直接拒绝入库) // Logan.Li
|
||||
switch kind {
|
||||
case UploadKindWechatCert, UploadKindWechatPlatformCert:
|
||||
cert, perr := ParseCertificatePEM(content)
|
||||
if perr != nil {
|
||||
return nil, errors.New("证书文件校验失败:" + perr.Error())
|
||||
}
|
||||
certSerial = CertSerialHex(cert)
|
||||
case UploadKindWechatKey:
|
||||
normalized := NormalizePEM(string(content), "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||
if _, perr := ParseRSAPrivateKey([]byte(normalized)); perr != nil {
|
||||
return nil, errors.New("商户 API 私钥校验失败:" + perr.Error())
|
||||
}
|
||||
content = []byte(normalized)
|
||||
case UploadKindAlipayPrivateKey:
|
||||
normalized := NormalizePEM(string(content), "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||
if _, perr := ParseRSAPrivateKey([]byte(normalized)); perr != nil {
|
||||
return nil, errors.New("应用私钥校验失败:" + perr.Error())
|
||||
}
|
||||
content = []byte(normalized)
|
||||
case UploadKindAlipayPublicKey:
|
||||
normalized := NormalizePEM(string(content), "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||
if _, perr := ParseRSAPublicKey([]byte(normalized)); perr != nil {
|
||||
return nil, errors.New("支付宝公钥校验失败:" + perr.Error())
|
||||
}
|
||||
content = []byte(normalized)
|
||||
}
|
||||
|
||||
// 2) 落地文件(0600,仓库外目录) // Logan.Li
|
||||
path, err := WriteSecretFile(kind, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3) 更新配置行指向新文件 // Logan.Li
|
||||
switch kind {
|
||||
case UploadKindWechatCert:
|
||||
row.WechatCertPath = path
|
||||
if strings.TrimSpace(row.WechatSerialNo) == "" && certSerial != "" {
|
||||
row.WechatSerialNo = certSerial
|
||||
note = "已自动回填证书序列号"
|
||||
} else if certSerial != "" && !strings.EqualFold(strings.TrimSpace(row.WechatSerialNo), certSerial) {
|
||||
note = "注意:填写的序列号与证书实际序列号不一致(证书:" + certSerial + "),请核对"
|
||||
}
|
||||
case UploadKindWechatKey:
|
||||
row.WechatKeyPath = path
|
||||
case UploadKindWechatPlatformCert:
|
||||
row.WechatPlatformCertPath = path
|
||||
case UploadKindAlipayPrivateKey:
|
||||
row.AlipayPrivateKeyPath = path
|
||||
row.AlipayPrivateKeyEnc = "" // 上传后清掉旧的粘贴值,避免来源歧义 // Logan.Li
|
||||
case UploadKindAlipayPublicKey:
|
||||
row.AlipayPublicKeyPath = path
|
||||
row.AlipayPublicKeyEnc = ""
|
||||
}
|
||||
|
||||
if err := s.db.Save(row).Error; err != nil {
|
||||
return nil, errors.New("保存上传记录失败")
|
||||
}
|
||||
|
||||
msg := "上传成功:" + filename
|
||||
if note != "" {
|
||||
msg += "(" + note + ")"
|
||||
}
|
||||
return &dto.PaymentUploadResponse{
|
||||
Kind: kind,
|
||||
File: BuildFileInfo(path, IsCertKind(kind)),
|
||||
Message: msg,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 微信支付平台证书自动获取(APIv3 GET /v3/certificates)。
|
||||
// 平台证书用于回调/应答验签,属于公开证书,但仍落在受保护目录。 // Logan.Li
|
||||
|
||||
// wechatCertResponse /v3/certificates 响应结构(仅取所需字段) // Logan.Li
|
||||
type wechatCertResponse struct {
|
||||
Data []struct {
|
||||
SerialNo string `json:"serial_no"`
|
||||
EffectiveTime string `json:"effective_time"`
|
||||
ExpireTime string `json:"expire_time"`
|
||||
EncryptCertificate struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
} `json:"encrypt_certificate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// FetchWechatPlatformCerts 拉取并保存平台证书,返回证书清单 // Logan.Li
|
||||
func (s *ConfigService) FetchWechatPlatformCerts() ([]dto.WechatPlatformCertItem, string, error) {
|
||||
row, err := s.loadRow()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var missing []string
|
||||
if strings.TrimSpace(row.WechatMchID) == "" {
|
||||
missing = append(missing, "商户号 mchid")
|
||||
}
|
||||
if strings.TrimSpace(row.WechatSerialNo) == "" {
|
||||
missing = append(missing, "证书序列号 serial_no")
|
||||
}
|
||||
if strings.TrimSpace(row.WechatKeyPath) == "" {
|
||||
missing = append(missing, "apiclient_key.pem")
|
||||
}
|
||||
if strings.TrimSpace(row.WechatAPIv3KeyEnc) == "" {
|
||||
missing = append(missing, "APIv3 密钥")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, "未配置:" + strings.Join(missing, "、") + "(跳过自动获取)", nil
|
||||
}
|
||||
|
||||
keyBytes, err := ReadSecretFile(row.WechatKeyPath)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("商户 API 私钥不可用:" + err.Error())
|
||||
}
|
||||
priv, err := ParseRSAPrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("商户 API 私钥解析失败:" + err.Error())
|
||||
}
|
||||
apiV3Key, err := DecryptSecret(row.WechatAPIv3KeyEnc)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
status, body, _, err := wechatAPIGet("/v3/certificates", row.WechatMchID, row.WechatSerialNo, priv)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("微信支付接口不可达:" + err.Error())
|
||||
}
|
||||
if status != 200 {
|
||||
var eb struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &eb)
|
||||
return nil, "获取平台证书失败:HTTP " + itoa(status) + " " + firstNonEmptyStr(eb.Code, "") + " " + truncateText(eb.Message, 120), nil
|
||||
}
|
||||
|
||||
var resp wechatCertResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, "平台证书响应解析失败", nil
|
||||
}
|
||||
if len(resp.Data) == 0 {
|
||||
return nil, "商户平台未返回任何平台证书", nil
|
||||
}
|
||||
|
||||
items := make([]dto.WechatPlatformCertItem, 0, len(resp.Data))
|
||||
type candidate struct {
|
||||
item dto.WechatPlatformCertItem
|
||||
pem []byte
|
||||
exp string
|
||||
}
|
||||
cands := make([]candidate, 0, len(resp.Data))
|
||||
|
||||
for _, d := range resp.Data {
|
||||
pemBytes, derr := DecryptWechatResource(apiV3Key, d.EncryptCertificate.AssociatedData,
|
||||
d.EncryptCertificate.Nonce, d.EncryptCertificate.Ciphertext)
|
||||
if derr != nil {
|
||||
items = append(items, dto.WechatPlatformCertItem{SerialNo: d.SerialNo, Subject: "解密失败:" + derr.Error()})
|
||||
continue
|
||||
}
|
||||
item := dto.WechatPlatformCertItem{
|
||||
SerialNo: d.SerialNo,
|
||||
NotBefore: formatWechatTime(d.EffectiveTime),
|
||||
NotAfter: formatWechatTime(d.ExpireTime),
|
||||
}
|
||||
if cert, perr := ParseCertificatePEM(pemBytes); perr == nil {
|
||||
item.Subject = cert.Subject.CommonName
|
||||
}
|
||||
cands = append(cands, candidate{item: item, pem: pemBytes, exp: d.ExpireTime})
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
if len(cands) == 0 {
|
||||
return items, "平台证书全部解密失败,请核对 APIv3 密钥是否正确", nil
|
||||
}
|
||||
// 取有效期最晚的一张落地(微信通常只返回一张,未来可能轮换为多张) // Logan.Li
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].exp > cands[j].exp })
|
||||
saved, err := WriteSecretFile(UploadKindWechatPlatformCert, cands[0].pem)
|
||||
if err != nil {
|
||||
return items, "", err
|
||||
}
|
||||
row.WechatPlatformCertPath = saved
|
||||
if err := s.db.Save(row).Error; err != nil {
|
||||
return items, "", errors.New("保存平台证书记录失败")
|
||||
}
|
||||
for i := range items {
|
||||
if items[i].SerialNo == cands[0].item.SerialNo {
|
||||
items[i].IsSaved = true
|
||||
}
|
||||
}
|
||||
return items, "已获取并保存平台证书(序列号 " + cands[0].item.SerialNo + ")", nil
|
||||
}
|
||||
|
||||
// DecryptWechatResource 用 APIv3 密钥解密微信回调/证书资源(AES-256-GCM) // Logan.Li
|
||||
func DecryptWechatResource(apiV3Key, associatedData, nonce, ciphertext string) ([]byte, error) {
|
||||
keyBytes := []byte(apiV3Key)
|
||||
if len(keyBytes) != 32 {
|
||||
return nil, errors.New("APIv3 密钥必须为 32 字节")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return nil, errors.New("密文解码失败")
|
||||
}
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := gcm.Open(nil, []byte(nonce), data, []byte(associatedData))
|
||||
if err != nil {
|
||||
return nil, errors.New("解密失败(APIv3 密钥不匹配)")
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// formatWechatTime 微信 RFC3339 时间转本地展示格式 // Logan.Li
|
||||
func formatWechatTime(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return t.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"backend/internal/controller/entitlement"
|
||||
"backend/internal/controller/license"
|
||||
"backend/internal/controller/order"
|
||||
paymentctl "backend/internal/controller/payment"
|
||||
"backend/internal/controller/plan"
|
||||
"backend/internal/controller/product"
|
||||
"backend/internal/controller/setting"
|
||||
@@ -106,6 +107,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||
analyticsController := analytics.NewAnalyticsController(db)
|
||||
settingController := setting.NewSettingController(db)
|
||||
syncController := synccontroller.NewController(db, cfg)
|
||||
paymentConfigController := paymentctl.NewPaymentConfigController(db, cfg)
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -288,6 +290,16 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||
{
|
||||
register(paymentGroup, "POST", "/waffo/debug", settingController.DebugWaffo)
|
||||
}
|
||||
|
||||
// 支付配置(微信支付 / 支付宝,仅管理员) // Logan.Li
|
||||
paymentConfig := protected.Group("/admin/payment")
|
||||
{
|
||||
register(paymentConfig, "GET", "/config", paymentConfigController.GetConfig)
|
||||
register(paymentConfig, "PUT", "/config", paymentConfigController.UpdateConfig)
|
||||
register(paymentConfig, "POST", "/upload", paymentConfigController.Upload)
|
||||
register(paymentConfig, "POST", "/test", paymentConfigController.Test)
|
||||
register(paymentConfig, "POST", "/wechat/platform-certs", paymentConfigController.FetchWechatPlatformCerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user