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

@@ -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)
}