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:
Logan.Li committed 2026-11-01 05:59:51 +08:00
1 parent 630f2819cb
commit 7d53c6ed3c
21 files changed
+3090 -6

No files matched your search

+1
View File
@@ -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}})
}
+129
View File
@@ -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"`
}
+39
View File
@@ -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
}
+12
View File
@@ -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)
}
}
}