diff --git a/.env.example b/.env.example index 8aee7f7..5d5de35 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,22 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success # ============================================ # Docker Compose # ============================================ + +# ============================================ +# 支付配置加密(后台「支付配置」页 · 微信支付 / 支付宝) +# ============================================ +# 敏感字段(微信 APIv3 密钥、支付宝应用私钥/公钥)入库前用 AES-256-GCM 加密, +# 主密钥只从环境变量读取,绝不写入数据库或仓库。生成方式二选一: +# openssl rand -hex 32 # 64 位 hex +# openssl rand -base64 32 # 32 字节 base64 +# 未设置时系统会退化为用 JWT_SECRET 派生主密钥(可开箱使用,但轮换 JWT_SECRET 会导致 +# 已保存的支付密钥无法解密,必须重新填写)——生产环境请显式设置本变量。 +# 注意:主密钥一旦更换,已保存的密文全部失效。 +# PAYMENT_SECRET_KEY= + +# 支付证书/私钥落地目录(容器内数据卷,位于仓库之外); +# 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs +# PAYMENT_CERT_DIR=/data/payment-certs # 宿主机映射端口(容器内固定 9001) HOST_PORT=9001 VERSION=latest diff --git a/backend/database/database.go b/backend/database/database.go index beb0a97..c5d7d1c 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -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 diff --git a/backend/internal/controller/payment/config_controller.go b/backend/internal/controller/payment/config_controller.go new file mode 100644 index 0000000..105a734 --- /dev/null +++ b/backend/internal/controller/payment/config_controller.go @@ -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}}) +} diff --git a/backend/internal/dto/payment_config.go b/backend/internal/dto/payment_config.go new file mode 100644 index 0000000..acb0cd7 --- /dev/null +++ b/backend/internal/dto/payment_config.go @@ -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"` +} diff --git a/backend/internal/model/payment_config.go b/backend/internal/model/payment_config.go new file mode 100644 index 0000000..b42f608 --- /dev/null +++ b/backend/internal/model/payment_config.go @@ -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" +} diff --git a/backend/internal/service/payment/config_crypto.go b/backend/internal/service/payment/config_crypto.go new file mode 100644 index 0000000..fbdeb8b --- /dev/null +++ b/backend/internal/service/payment/config_crypto.go @@ -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:,前缀便于未来平滑升级算法。 // 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() +} diff --git a/backend/internal/service/payment/config_crypto_test.go b/backend/internal/service/payment/config_crypto_test.go new file mode 100644 index 0000000..f041ba2 --- /dev/null +++ b/backend/internal/service/payment/config_crypto_test.go @@ -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) + } +} diff --git a/backend/internal/service/payment/config_files.go b/backend/internal/service/payment/config_files.go new file mode 100644 index 0000000..d1d6325 --- /dev/null +++ b/backend/internal/service/payment/config_files.go @@ -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() } diff --git a/backend/internal/service/payment/config_store.go b/backend/internal/service/payment/config_store.go new file mode 100644 index 0000000..bc85042 --- /dev/null +++ b/backend/internal/service/payment/config_store.go @@ -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) +} diff --git a/backend/internal/service/payment/config_test_alipay.go b/backend/internal/service/payment/config_test_alipay.go new file mode 100644 index 0000000..b375208 --- /dev/null +++ b/backend/internal/service/payment/config_test_alipay.go @@ -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 +} diff --git a/backend/internal/service/payment/config_test_wechat.go b/backend/internal/service/payment/config_test_wechat.go new file mode 100644 index 0000000..dd8bc6c --- /dev/null +++ b/backend/internal/service/payment/config_test_wechat.go @@ -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 ";平台证书应答验签通过" +} diff --git a/backend/internal/service/payment/config_upload.go b/backend/internal/service/payment/config_upload.go new file mode 100644 index 0000000..f87cabd --- /dev/null +++ b/backend/internal/service/payment/config_upload.go @@ -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 +} diff --git a/backend/internal/service/payment/config_wechat_cert.go b/backend/internal/service/payment/config_wechat_cert.go new file mode 100644 index 0000000..ff7e20b --- /dev/null +++ b/backend/internal/service/payment/config_wechat_cert.go @@ -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 +} diff --git a/backend/router/router.go b/backend/router/router.go index 9529718..d1c3c53 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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) + } } } diff --git a/frontend/src/api/payment.js b/frontend/src/api/payment.js new file mode 100644 index 0000000..0653f04 --- /dev/null +++ b/frontend/src/api/payment.js @@ -0,0 +1,46 @@ +import request from './request' + +// 支付通道配置(微信支付 / 支付宝)API // Logan.Li + +// 读取配置(密钥一律脱敏返回) +export function getPaymentConfig() { + return request({ url: '/admin/payment/config', method: 'get' }) +} + +// 保存配置 +export function updatePaymentConfig(data) { + return request({ url: '/admin/payment/config', method: 'put', data }) +} + +// 上传证书 / 私钥(kind: wechat_cert | wechat_key | wechat_platform_cert | alipay_private_key | alipay_public_key) +export function uploadPaymentFile(kind, file) { + const form = new FormData() + form.append('kind', kind) + form.append('file', file) + return request({ + url: '/admin/payment/upload', + method: 'post', + data: form, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, + }) +} + +// 连通性测试(channel: wechat | alipay | all) +export function testPaymentConfig(channel = 'all') { + return request({ + url: '/admin/payment/test', + method: 'post', + data: { channel }, + timeout: 60000, + }) +} + +// 自动获取并保存微信支付平台证书 +export function fetchWechatPlatformCerts() { + return request({ + url: '/admin/payment/wechat/platform-certs', + method: 'post', + timeout: 60000, + }) +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 6bf8887..8029326 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -51,11 +51,6 @@ - - - - - @@ -69,6 +64,9 @@ 操作日志 + + 支付配置 + 系统设置 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 88cfce8..e6322d6 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -58,7 +58,7 @@ const routes = [ { path: 'payments', name: 'Payments', - component: () => import('@/views/Payments.vue'), + component: () => import('@/views/payments/index.vue'), meta: { title: '支付配置', icon: 'Wallet' }, }, { diff --git a/frontend/src/views/payments/Alipay.vue b/frontend/src/views/payments/Alipay.vue new file mode 100644 index 0000000..12ac40a --- /dev/null +++ b/frontend/src/views/payments/Alipay.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/frontend/src/views/Payments.vue b/frontend/src/views/payments/Waffo.vue similarity index 100% rename from frontend/src/views/Payments.vue rename to frontend/src/views/payments/Waffo.vue diff --git a/frontend/src/views/payments/WechatPay.vue b/frontend/src/views/payments/WechatPay.vue new file mode 100644 index 0000000..f939148 --- /dev/null +++ b/frontend/src/views/payments/WechatPay.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/frontend/src/views/payments/index.vue b/frontend/src/views/payments/index.vue new file mode 100644 index 0000000..369d4f1 --- /dev/null +++ b/frontend/src/views/payments/index.vue @@ -0,0 +1,214 @@ + + + + +