CI / backend (push) Canceled after 0s
- 新增 app_configs 表与 appconfig 注册表:读取优先级 页面 → .env → 默认,删除即回退 - 页面可配 7 项:支付回调基址、成功回跳、Webhook 密钥(加密入库)、未验签放行开关、CORS、可信代理、GeoIP 路径 - 引导密钥(JWT_SECRET / OPSLIC_MASTER_KEY / OFFLINE_LICENSE_SEED)与部署参数只读展示掩码+指纹,永不入库 - 校验:URL 格式、密钥长度≥16、CIDR、release 下禁止开启未验签回调;变更写审计日志 - 消费点改造:支付回调地址、Waffo 回跳、webhook 验签、可信代理、CORS、GeoIP 统一走配置中心 - 前端新增「审计与设置 → 运行配置」页(分组表格 + 来源标记 + 编辑弹窗 + 恢复默认) - .env.example 补注释:哪些必须留在 .env、哪些已收敛到页面
398 lines
13 KiB
Go
398 lines
13 KiB
Go
package payment
|
||
|
||
import (
|
||
"backend/config"
|
||
"backend/internal/dto"
|
||
"backend/internal/model"
|
||
"backend/internal/service/appconfig"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"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(appconfig.String(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)
|
||
}
|