Files
Blizzard 5d7eca5de3 fix(billing): 微信支付钉死「微信支付公钥」验签体系 —— 商户 2025-09 开户没有平台证书
用户拿旧项目代码对出来的真问题:我此前用 WithWechatPayAutoAuthCipher(平台证书
模式,APIv3 密钥自动下载平台证书验签),但 2024 起新注册商户只发「微信支付公钥」
(PUB_KEY_ID_ 开头)、没有平台证书——在该商户号上初始化/回调验签都会挂。

- 改 WithWechatPayPublicKeyAuthCipher(商户私钥+公钥ID+公钥文件);回调验签用
  NewSHA256WithRSAPubkeyVerifier;平台证书模式不留双模式赘肉(YAGNI)。
- Config 增 public_key_path/public_key_id(必填,公钥文件同样只存路径);
  admin 卡片补两字段;env 兜底加 WECHAT_PUBLIC_KEY(_ID)。
- 顺手修 live 撞出的真 bug:sundynix_setting.value 是 varchar(255),
  支付配置 JSON(含加密密钥)一条就超(SQLSTATE 22001)→ 改 text。
live:列类型已迁 text;缺公钥两项报「配置不全,缺: public_key_path,
public_key_id」;GET 回显含新字段。go 6 包测试+tsc+41 vitest 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:12:26 +08:00

115 lines
4.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/payment"
"github.com/sundynix/sundynix-shared/secrets"
)
// 微信支付配置控制面:配置进 DBsundynix_setting)、改完热生效不重启;
// APIv3 密钥入库前 AES-GCM 加密(与模型 API Key 同一把 SUNDYNIX_SECRET_KEY),
// 私钥文件留在服务器磁盘,库里只存路径。env 仍作兜底(DB 优先 → env → 隐藏)。
// SettingWechatPay 是 settings KV 里的键,值为 payment.Config 的 JSONapiv3_key 为密文)。
const SettingWechatPay = "payment_wechat"
// loadWechatConfig 读支付配置:DB 优先(解密 apiv3),空则回退 env。
func (h *Handler) loadWechatConfig(ctx context.Context) payment.Config {
raw := h.db.GetSetting(ctx, SettingWechatPay)
if raw == "" {
return payment.ConfigFromEnv()
}
var c payment.Config
if err := json.Unmarshal([]byte(raw), &c); err != nil {
return payment.ConfigFromEnv()
}
if c.APIv3Key != "" {
if plain, err := secrets.Decrypt(c.APIv3Key); err == nil {
c.APIv3Key = plain
}
}
return c
}
// InitWechat 启动时装配微信渠道(DB 优先 → env)。失败只降级隐藏,不阻断启动。
func (h *Handler) InitWechat(ctx context.Context) {
_ = h.pay.Reload(ctx, h.loadWechatConfig(ctx))
}
// AdminGetWechatPay: GET /api/v1/admin/payment/wechat —— 当前配置(密钥不回显)+ 渠道状态。
func (h *Handler) AdminGetWechatPay(c *gin.Context) {
cfg := h.loadWechatConfig(c.Request.Context())
enabled, reason := h.pay.Status()
c.JSON(http.StatusOK, gin.H{
"config": gin.H{
"mchid": cfg.MchID,
"cert_serial": cfg.CertSerial,
"private_key_path": cfg.PrivateKeyPath,
"public_key_path": cfg.PublicKeyPath,
"public_key_id": cfg.PublicKeyID,
"appid": cfg.AppID,
"notify_url": cfg.NotifyURL,
"has_apiv3_key": cfg.APIv3Key != "", // 密钥只报有无,明文永不回显
},
"enabled": enabled,
"reason": reason,
})
}
// AdminSaveWechatPay: PUT /api/v1/admin/payment/wechat —— 保存配置并热重载渠道。
// apiv3_key 留空 = 沿用已存的(只写不回显的编辑语义,同模型 Key)。
func (h *Handler) AdminSaveWechatPay(c *gin.Context) {
var b struct {
MchID string `json:"mchid"`
CertSerial string `json:"cert_serial"`
PrivateKeyPath string `json:"private_key_path"`
APIv3Key string `json:"apiv3_key"`
AppID string `json:"appid"`
NotifyURL string `json:"notify_url"`
PublicKeyPath string `json:"public_key_path"`
PublicKeyID string `json:"public_key_id"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ctx := c.Request.Context()
apiv3 := strings.TrimSpace(b.APIv3Key)
if apiv3 == "" {
apiv3 = h.loadWechatConfig(ctx).APIv3Key // 留空沿用旧密钥(此处为明文,稍后统一加密入库)
}
cfg := payment.Config{
MchID: strings.TrimSpace(b.MchID),
CertSerial: strings.TrimSpace(b.CertSerial),
PrivateKeyPath: strings.TrimSpace(b.PrivateKeyPath),
APIv3Key: apiv3,
AppID: strings.TrimSpace(b.AppID),
NotifyURL: strings.TrimSpace(b.NotifyURL),
PublicKeyPath: strings.TrimSpace(b.PublicKeyPath),
PublicKeyID: strings.TrimSpace(b.PublicKeyID),
}
stored := cfg
if stored.APIv3Key != "" {
enc, err := secrets.Encrypt(stored.APIv3Key)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
return
}
stored.APIv3Key = enc
}
raw, _ := json.Marshal(stored)
if err := h.db.SetSetting(ctx, SettingWechatPay, string(raw)); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 热重载:配置有毛病只会让渠道隐藏并给出原因,不影响其它功能。
_ = h.pay.Reload(ctx, cfg)
enabled, reason := h.pay.Status()
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": enabled, "reason": reason})
}