feat(payment): 后台「支付配置」支持微信支付与支付宝凭据管理
后端: - 新增 payment_configs 单行配置表;APIv3 密钥、应用私钥/公钥以 AES-256-GCM 密文入库(enc:v1 前缀,主密钥取 PAYMENT_SECRET_KEY,缺省回退 JWT_SECRET 派生) - 证书/私钥落 PAYMENT_CERT_DIR(默认 /data/payment-certs),目录 0700 / 文件 0600,不经任何静态路由暴露 - 接口:GET/PUT /api/admin/payment/config、POST /upload(PEM 校验 + 微信证书序列号自动回填)、POST /test(真打官方接口:微信 /v3/certificates 签名校验、支付宝 alipay.trade.query 签名校验)、POST /wechat/platform-certs(APIv3 密钥解密平台证书并落盘) - 未配置凭据时测试返回 skipped 而不是 500;敏感字段在返回体里一律脱敏 前端: - /payments 改为「通用设置 + 微信支付 / 支付宝 / Waffo 海外支付」页签(原 Waffo 页整页迁到 views/payments/Waffo.vue) - 微信:mchid、AppID、APIv3 密钥(密码框 + 掩码回显 + 更换/清除)、证书序列号、支付方式多选、三项证书上传、自动获取平台证书 - 支付宝:app_id、应用私钥(粘贴/上传)、支付宝公钥、网关、RSA2、回调地址 - 菜单归入「审计与设置 → 支付配置」
This commit is contained in:
1 parent
630f2819cb
commit
7d53c6ed3c
21 files changed
+3090
-6
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user