Files
opsx-licence/backend/internal/service/payment/config_store.go
T
Logan.Li 7d53c6ed3c 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、回调地址
- 菜单归入「审计与设置 → 支付配置」
2026-11-01 05:59:51 +08:00

398 lines
13 KiB
Go
Raw 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 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)
}