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