From 34652019bbaf830337fdbb5c548f44b44bd75642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 31 Jul 2026 13:24:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=99=8E=E7=9A=AE=E6=A3=8B=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=88=E7=AD=BE=E5=90=8D?= =?UTF-8?q?/=E4=B8=8B=E5=8D=95/=E9=AA=8C=E7=AD=BE=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=99=8D=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- styleagent/payment/gateway.go | 122 +++++++++++++++++++++++++++++ styleagent/payment/gateway_test.go | 49 ++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 styleagent/payment/gateway.go create mode 100644 styleagent/payment/gateway_test.go diff --git a/styleagent/payment/gateway.go b/styleagent/payment/gateway.go new file mode 100644 index 0000000..6155187 --- /dev/null +++ b/styleagent/payment/gateway.go @@ -0,0 +1,122 @@ +package payment + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// 虎皮棋聚合支付适配器:签名/HTTP 细节全部收敛在本包,业务层不感知。 +// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。 + +type Config struct { + AppId string + AppSecret string + NotifyUrl string + Channel string // 逗号分隔,如 "alipay,wechat" + ApiBase string + Enabled bool +} + +func GetConfig(ctx context.Context) Config { + cfg := Config{ + AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(), + AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(), + NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(), + Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(), + ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(), + } + cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != "" + return cfg +} + +// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分) +func CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) { + cfg := GetConfig(ctx) + if !cfg.Enabled { + return "", errors.New("支付未开通,请在 config.yml 配置 payment") + } + channel := "alipay" + if first := strings.Split(cfg.Channel, ",")[0]; first != "" { + channel = first + } + params := map[string]string{ + "appid": cfg.AppId, + "trade_order_id": orderNo, + "total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100), + "title": "形象会员", + "notify_url": cfg.NotifyUrl, + "type": channel, + "version": "1.1", + "nonce_str": nonce(), + } + params["hash"] = Sign(params, cfg.AppSecret) + + var resp struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Url string `json:"url"` + } + if _, err := g.Client().SetTimeout(10 * time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params, &resp); err != nil { + return "", fmt.Errorf("虎皮棋下单失败: %w", err) + } + if resp.Errcode != 0 { + return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg) + } + if resp.Url == "" { + return "", errors.New("虎皮棋下单失败: 返回为空") + } + return resp.Url, nil +} + +// Sign 参数名升序拼接 key=value,追加 secret 后 md5 hex +func Sign(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if params[k] == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + for i, k := range keys { + if i > 0 { + sb.WriteString("&") + } + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(params[k]) + } + sb.WriteString(secret) + sum := md5.Sum([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较 +func VerifyNotify(params map[string]string, hash, secret string) bool { + if hash == "" || secret == "" { + return false + } + cp := make(map[string]string, len(params)) + for k, v := range params { + if k != "hash" { + cp[k] = v + } + } + return Sign(cp, secret) == strings.ToLower(hash) +} + +func nonce() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/styleagent/payment/gateway_test.go b/styleagent/payment/gateway_test.go new file mode 100644 index 0000000..a9ea01a --- /dev/null +++ b/styleagent/payment/gateway_test.go @@ -0,0 +1,49 @@ +package payment + +import "testing" + +func TestSignDeterministic(t *testing.T) { + params := map[string]string{ + "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", + } + s1 := Sign(params, "secret123") + s2 := Sign(params, "secret123") + if s1 != s2 { + t.Fatalf("相同参数签名应一致: %s != %s", s1, s2) + } + if s1 == "" { + t.Fatal("签名不应为空") + } +} + +func TestSignChangesWithSecret(t *testing.T) { + params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"} + if Sign(params, "a") == Sign(params, "b") { + t.Fatal("不同 secret 签名应不同") + } +} + +func TestVerifyNotify(t *testing.T) { + params := map[string]string{ + "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", + "status": "OD", "hash": "", + } + hash := Sign(params, "secret123") + if !VerifyNotify(params, hash, "secret123") { + t.Fatal("正确签名应通过验签") + } + params["total_fee"] = "0.01" + if VerifyNotify(params, hash, "secret123") { + t.Fatal("篡改参数后应验签失败") + } + if VerifyNotify(params, hash, "wrong-secret") { + t.Fatal("错误 secret 应验签失败") + } +} + +func TestGetConfigDisabledWhenEmpty(t *testing.T) { + cfg := GetConfig(t.Context()) + if cfg.Enabled { + t.Fatal("默认配置(key 为空)应 disabled") + } +}