diff -ruN a/controller/relay.go b/controller/relay.go
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -134,8 +134,16 @@
relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
+ return
+ }
+
+ // 6bee: per-account output cap and concurrency slot (service/sixbee_limits.go)
+ releaseSlot, limitErr := service.SixbeeAdmitRequest(c, relayInfo)
+ if limitErr != nil {
+ newAPIError = limitErr
return
}
+ defer releaseSlot()
defer func() {
recovered := recover()
diff -ruN a/controller/sixbee_epay.go b/controller/sixbee_epay.go
--- a/controller/sixbee_epay.go
+++ b/controller/sixbee_epay.go
@@ -0,0 +1,99 @@
+package controller
+
+// 6bee: adapter for the owner's private Epay gateway.
+//
+// The private gateway rejects orders without a signed 10-digit Unix
+// `timestamp` (it allows 300 s of clock skew), which go-epay's Purchase does
+// not send. Fields and MD5 signing are otherwise the same as go-epay, except
+// that `device` is left out so the gateway picks PC or mobile checkout from
+// the customer's own browser (the form is posted by that browser).
+//
+// sixbeeEpayNotifyMatches adds two checks before a signed notify credits a
+// balance: the notify is for our PID, and its amount equals the amount the
+// order was created with.
+//
+// With SIXBEE_PAY_ORIGIN set, orders use the gateway's same-domain checkout
+// instead of the browser form (controller/sixbee_pay.go), so customers only
+// ever see pay.6bee.ai and the payment provider's own pages.
+
+import (
+ "errors"
+ "fmt"
+ "path"
+ "strconv"
+ "time"
+
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
+
+ "github.com/Calcium-Ion/go-epay/epay"
+ "github.com/gin-gonic/gin"
+ "github.com/shopspring/decimal"
+)
+
+// sixbeeEpayPurchase places an Epay order. It returns the URL the console
+// posts the customer's browser to and the form fields (none in site mode).
+func sixbeeEpayPurchase(c *gin.Context, client *epay.Client, args *epay.PurchaseArgs) (string, map[string]string, error) {
+ origin, err := sixbeePayOrigin()
+ if err != nil {
+ return "", nil, err
+ }
+ if origin == nil {
+ return sixbeeEpayRedirectPurchase(client, args)
+ }
+ return sixbeeSitePurchase(c, client, args, origin)
+}
+
+// sixbeeEpayRedirectPurchase is the browser-form order (the gateway's own pages).
+func sixbeeEpayRedirectPurchase(client *epay.Client, args *epay.PurchaseArgs) (string, map[string]string, error) {
+ if client == nil || client.Config == nil || client.BaseUrl == nil || args == nil ||
+ args.NotifyUrl == nil || args.ReturnUrl == nil {
+ return "", nil, errors.New("epay: incomplete purchase arguments")
+ }
+ params := map[string]string{
+ "pid": client.Config.PartnerID,
+ "type": args.Type,
+ "out_trade_no": args.ServiceTradeNo,
+ "notify_url": args.NotifyUrl.String(),
+ "return_url": args.ReturnUrl.String(),
+ "name": args.Name,
+ "money": args.Money,
+ "timestamp": strconv.FormatInt(time.Now().Unix(), 10),
+ }
+ u := *client.BaseUrl
+ u.Path = path.Join(u.Path, epay.PurchaseUrl)
+ signed, err := epay.GenerateParamsWithError(params, client.Config.Key)
+ if err != nil {
+ return "", nil, err
+ }
+ return u.String(), signed, nil
+}
+
+// sixbeeEpayNotifyMatches is called after the signature has been verified.
+// orderMoney is the amount stored on the local order (0 when the order is not
+// found; the caller's normal not-found handling then applies).
+func sixbeeEpayNotifyMatches(params map[string]string, orderMoney float64, orderFound bool) error {
+ if params["pid"] != operation_setting.EpayId {
+ return fmt.Errorf("pid %q is not ours", params["pid"])
+ }
+ if !orderFound {
+ return nil
+ }
+ paid, err := decimal.NewFromString(params["money"])
+ if err != nil {
+ return fmt.Errorf("invalid money %q", params["money"])
+ }
+ want := decimal.NewFromFloat(orderMoney).Round(2)
+ if !paid.Round(2).Equal(want) {
+ return fmt.Errorf("money %s does not match the order amount %s", paid.String(), want.StringFixed(2))
+ }
+ return nil
+}
+
+func sixbeeTopUpMoney(tradeNo string) (float64, bool) {
+ topUp := model.GetTopUpByTradeNo(tradeNo)
+ if topUp == nil {
+ return 0, false
+ }
+ return topUp.Money, true
+}
diff -ruN a/controller/sixbee_epay_test.go b/controller/sixbee_epay_test.go
--- a/controller/sixbee_epay_test.go
+++ b/controller/sixbee_epay_test.go
@@ -0,0 +1,75 @@
+package controller
+
+import (
+ "crypto/md5"
+ "fmt"
+ "net/url"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/setting/operation_setting"
+
+ "github.com/Calcium-Ion/go-epay/epay"
+)
+
+func TestSixbeeEpayPurchaseSignsTimestamp(t *testing.T) {
+ key := "0123456789abcdef0123456789abcdef"
+ client, err := epay.NewClient(&epay.Config{PartnerID: "1008", Key: key}, "https://pay.example.net/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ notify, _ := url.Parse("https://console.example.com/api/user/epay/notify")
+ ret, _ := url.Parse("https://console.example.com/console/log")
+ uri, params, err := sixbeeEpayRedirectPurchase(client, &epay.PurchaseArgs{
+ Type: "usdc", ServiceTradeNo: "USR1NOabc1234567890", Name: "TUC10", Money: "10.00",
+ Device: epay.PC, NotifyUrl: notify, ReturnUrl: ret,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if uri != "https://pay.example.net/submit.php" {
+ t.Fatalf("uri = %s", uri)
+ }
+ ts, err := strconv.ParseInt(params["timestamp"], 10, 64)
+ if err != nil || len(params["timestamp"]) != 10 || time.Now().Unix()-ts > 5 || ts-time.Now().Unix() > 5 {
+ t.Fatalf("timestamp = %q", params["timestamp"])
+ }
+ if _, ok := params["device"]; ok {
+ t.Fatal("device must be left to the gateway")
+ }
+ // The same string the PHP gateway signs: sorted non-empty fields without sign/sign_type, then the key.
+ want := fmt.Sprintf("%x", md5.Sum([]byte(
+ "money=10.00&name=TUC10¬ify_url="+notify.String()+"&out_trade_no=USR1NOabc1234567890&pid=1008&return_url="+ret.String()+
+ "×tamp="+params["timestamp"]+"&type=usdc"+key)))
+ if params["sign"] != want || params["sign_type"] != "MD5" {
+ t.Fatalf("sign = %s, want %s", params["sign"], want)
+ }
+}
+
+func TestSixbeeEpayNotifyMatches(t *testing.T) {
+ old := operation_setting.EpayId
+ operation_setting.EpayId = "1008"
+ defer func() { operation_setting.EpayId = old }()
+
+ cases := []struct {
+ name string
+ params map[string]string
+ money float64
+ found bool
+ ok bool
+ }{
+ {"php float money", map[string]string{"pid": "1008", "money": "10"}, 10, true, true},
+ {"two decimals", map[string]string{"pid": "1008", "money": "9.50"}, 9.5, true, true},
+ {"wrong pid", map[string]string{"pid": "1003", "money": "10"}, 10, true, false},
+ {"lower amount", map[string]string{"pid": "1008", "money": "1"}, 10, true, false},
+ {"garbage amount", map[string]string{"pid": "1008", "money": "ten"}, 10, true, false},
+ {"order not found is left to RechargeEpay", map[string]string{"pid": "1008", "money": "1"}, 0, false, true},
+ }
+ for _, tc := range cases {
+ err := sixbeeEpayNotifyMatches(tc.params, tc.money, tc.found)
+ if (err == nil) != tc.ok {
+ t.Errorf("%s: err = %v", tc.name, err)
+ }
+ }
+}
diff -ruN a/controller/sixbee_notify.go b/controller/sixbee_notify.go
--- a/controller/sixbee_notify.go
+++ b/controller/sixbee_notify.go
@@ -0,0 +1,156 @@
+package controller
+
+// 6bee patch: handlers for Telegram linking, the unsubscribe link in broadcast emails and the
+// owner's broadcasts (service/sixbee_notify.go, service/sixbee_broadcast.go).
+
+import (
+ "html"
+ "net/http"
+ "strconv"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+// SixbeeTelegramStatus tells the console whether Telegram notices are available and which chat,
+// if any, the signed-in account has linked.
+func SixbeeTelegramStatus(c *gin.Context) {
+ bot := service.SixbeeTelegramBot()
+ data := gin.H{"available": bot != "", "bot_username": bot, "linked": false}
+ if link, linked := service.SixbeeTelegramLinkOf(c.GetInt("id")); linked {
+ data["linked"] = true
+ data["username"] = link.Username
+ data["name"] = link.Name
+ data["linked_at"] = link.CreatedAt
+ }
+ common.ApiSuccess(c, data)
+}
+
+// SixbeeTelegramLinkStart issues the one-time t.me link the user opens to link a chat.
+func SixbeeTelegramLinkStart(c *gin.Context) {
+ link, err := service.SixbeeTelegramLinkStart(c.GetInt("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{"url": link, "expires_in": 600})
+}
+
+func SixbeeTelegramUnlink(c *gin.Context) {
+ if err := service.SixbeeTelegramUnlink(c.GetInt("id")); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, nil)
+}
+
+// SixbeeUnsubscribe is the link at the foot of broadcast emails. GET shows a confirmation page
+// (mail scanners that open links must not unsubscribe anyone); POST turns the kind off.
+func SixbeeUnsubscribe(c *gin.Context) {
+ userId, _ := strconv.Atoi(c.Query("u"))
+ kind, signature := c.Query("k"), c.Query("s")
+ title, body := "6bee.ai", ""
+ status := http.StatusOK
+ switch {
+ case !service.SixbeeUnsubscribeValid(userId, kind, signature):
+ status = http.StatusNotFound
+ body = `
这个退订链接无效。
This unsubscribe link is not valid.
`
+ case c.Request.Method == http.MethodPost:
+ if err := service.SixbeeSetNoticePreference(userId, kind, false); err != nil {
+ common.SysError("6bee: unsubscribe failed: " + err.Error())
+ status = http.StatusInternalServerError
+ body = `退订失败,请稍后再试。
Unsubscribing failed. Please try again later.
`
+ break
+ }
+ body = `已退订,你不会再收到这类通知(邮箱和 Telegram)。
You are unsubscribed and will no longer receive these notices, by email or Telegram.
+想重新接收,可在控制台「个人设置 → 通知」打开。
To receive them again, turn them on under Profile → Notifications in the console.
`
+ default:
+ what := "公告 · announcements"
+ if kind == service.SixbeeBroadcastPromo {
+ what = "活动通知 · promotions"
+ }
+ body = `不再接收 6bee.ai 的` + html.EscapeString(what) + `?
Stop receiving them from 6bee.ai?
+
+余额提醒不受影响。
Low balance reminders are not affected.
`
+ }
+ page := `
+` + title + `6bee.ai
` + body + ``
+ c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Data(status, "text/html; charset=utf-8", []byte(page))
+}
+
+type sixbeeBroadcastRequest struct {
+ Kind string `json:"kind"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ LinkUrl string `json:"link_url"`
+ Test bool `json:"test"`
+}
+
+// SixbeeBroadcastList returns the latest broadcasts with their progress, and how many accounts
+// each kind would reach now.
+func SixbeeBroadcastList(c *gin.Context) {
+ broadcasts, err := service.SixbeeBroadcastList()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ audience, err := service.SixbeeBroadcastAudienceOf()
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{"items": broadcasts, "audience": audience, "telegram_bot": service.SixbeeTelegramBot()})
+}
+
+// SixbeeBroadcastSend sends a test to the author (test: true) or starts sending to everyone.
+func SixbeeBroadcastSend(c *gin.Context) {
+ var req sixbeeBroadcastRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ApiErrorT(c, "Invalid parameters")
+ return
+ }
+ broadcast := service.SixbeeBroadcast{Kind: req.Kind, Title: req.Title, Content: req.Content, LinkUrl: req.LinkUrl, CreatedBy: c.GetInt("id")}
+ if req.Test {
+ emailSent, telegramSent, err := service.SixbeeBroadcastTest(&broadcast, c.GetInt("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{"email": emailSent, "telegram": telegramSent})
+ return
+ }
+ if err := service.SixbeeBroadcastStart(&broadcast); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, broadcast)
+}
+
+func SixbeeBroadcastResume(c *gin.Context) {
+ id, _ := strconv.Atoi(c.Param("id"))
+ if err := service.SixbeeBroadcastResume(id); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, nil)
+}
+
+func SixbeeBroadcastStop(c *gin.Context) {
+ id, _ := strconv.Atoi(c.Param("id"))
+ if err := service.SixbeeBroadcastStop(id); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, nil)
+}
diff -ruN a/controller/sixbee_pay.go b/controller/sixbee_pay.go
--- a/controller/sixbee_pay.go
+++ b/controller/sixbee_pay.go
@@ -0,0 +1,670 @@
+package controller
+
+// 6bee: same-domain checkout for the owner's private Epay gateway.
+//
+// With SIXBEE_PAY_ORIGIN set (https://pay.6bee.ai), an Epay order is placed
+// server to server: POST mapi.php with checkout_mode=site. The gateway's
+// answer is checked against the site key and only its display fields are
+// kept, in Redis under a random token: an Alipay QR code or official Alipay
+// WAP link, or a USDT/USDC address with the exact amount and expiry. The
+// console sends the customer to https://pay.6bee.ai/pay/, rendered
+// here, so the browser never receives the gateway's or the wallet service's
+// domain.
+//
+// Alipay and the wallet service post their asynchronous notifications to
+// https://pay.6bee.ai/.well-known/epay-provider/{type}/{trade_no}. That
+// route forwards the untouched request to the gateway, which verifies it and
+// then notifies /api/user/epay/notify on pay.6bee.ai as before. The relay
+// never credits anything itself.
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+
+ "github.com/Calcium-Ion/go-epay/epay"
+ "github.com/boombuler/barcode/qr"
+ "github.com/gin-gonic/gin"
+ "github.com/shopspring/decimal"
+)
+
+const (
+ sixbeePayCurrency = "USD" // the gateway site for 6bee.ai is priced in USD (Price = 1)
+ sixbeePayRecordPrefix = "sixbee:pay:"
+ sixbeePayRecordTTL = 24 * time.Hour
+ sixbeeAlipayQRLife = 10 * time.Minute // the gateway's Alipay precreate timeout_express
+ sixbeeAlipayWapLife = 30 * time.Minute
+ sixbeeGatewayMaxBody = 64 << 10
+)
+
+var (
+ sixbeePayTokenRe = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
+ sixbeeGatewayTradeRe = regexp.MustCompile(`^[0-9]{19}$`)
+ sixbeeCryptoAmountRe = regexp.MustCompile(`^(0|[1-9][0-9]{0,11})(\.[0-9]{1,8})?$`)
+ sixbeeTronAddressRe = regexp.MustCompile(`^T[1-9A-HJ-NP-Za-km-z]{33}$`)
+ sixbeeSolAddressRe = regexp.MustCompile(`^[1-9A-HJ-NP-Za-km-z]{32,44}$`)
+
+ // USDT and USDC are charged 1:1 with the USD price. The only difference
+ // allowed is the gateway's collision tail: when another open order on the
+ // same address has the same amount it adds 0.01 per clash (5.00, 5.01, ...).
+ sixbeeCryptoCent = decimal.New(1, -2)
+ sixbeeCryptoTailMax = decimal.New(10, -2)
+ sixbeeMobileUARe = regexp.MustCompile(`(?i)android|iphone|ipod|mobile|harmonyos|windows phone`)
+
+ // Provider types the relay forwards; the gateway only uses these for 6bee.ai.
+ sixbeeRelayTypes = map[string]bool{"alipay": true, "usdt": true, "usdc": true}
+ sixbeeRelayHeaders = []string{
+ "Content-Type",
+ "Wechatpay-Timestamp", "Wechatpay-Nonce", "Wechatpay-Serial", "Wechatpay-Signature",
+ "Request-Time", "Client-Id", "Signature",
+ }
+ // Crypto tokens: payment type → token and network the gateway must answer with.
+ sixbeeCryptoAssets = map[string][2]string{"usdt": {"USDT", "TRON"}, "usdc": {"USDC", "SOLANA"}}
+)
+
+// Server-to-server calls to the gateway: no redirects, short timeout.
+var sixbeeGatewayHTTP = &http.Client{
+ Timeout: 20 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+}
+
+// Storage and order lookups, replaceable in tests.
+var (
+ sixbeePayPut = func(token string, value string) error {
+ if !common.RedisEnabled {
+ return errors.New("redis is not enabled")
+ }
+ return common.RedisSet(sixbeePayRecordPrefix+token, value, sixbeePayRecordTTL)
+ }
+ sixbeePayGet = func(token string) (string, error) {
+ if !common.RedisEnabled {
+ return "", errors.New("redis is not enabled")
+ }
+ return common.RedisGet(sixbeePayRecordPrefix + token)
+ }
+ sixbeeOrderStatus = func(tradeNo string) (string, bool) {
+ if strings.HasPrefix(tradeNo, "SUB") {
+ if order := model.GetSubscriptionOrderByTradeNo(tradeNo); order != nil {
+ return order.Status, true
+ }
+ return "", false
+ }
+ if topUp := model.GetTopUpByTradeNo(tradeNo); topUp != nil {
+ return topUp.Status, true
+ }
+ return "", false
+ }
+)
+
+type sixbeePayRecord struct {
+ TradeNo string `json:"trade_no"`
+ GatewayTradeNo string `json:"gateway_trade_no"`
+ Type string `json:"type"` // alipay | usdt | usdc
+ PayType string `json:"pay_type"` // qrcode | jump | wallet
+ Code string `json:"code,omitempty"`
+ Link string `json:"link,omitempty"`
+ Token string `json:"token,omitempty"`
+ Network string `json:"network,omitempty"`
+ CryptoAmount string `json:"crypto_amount,omitempty"`
+ ExpiresAt int64 `json:"expires_at"`
+ PricingAmount string `json:"pricing_amount"`
+ PricingCurrency string `json:"pricing_currency"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+// sixbeePayOrigin is SIXBEE_PAY_ORIGIN, e.g. https://pay.6bee.ai. Unset: nil
+// (browser-form orders). Set but not an https origin: an error, so orders
+// fail instead of silently falling back to the gateway's own pages.
+func sixbeePayOrigin() (*url.URL, error) {
+ raw := strings.TrimSpace(os.Getenv("SIXBEE_PAY_ORIGIN"))
+ if raw == "" {
+ return nil, nil
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil ||
+ (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
+ return nil, fmt.Errorf("SIXBEE_PAY_ORIGIN must be an https origin, got %q", raw)
+ }
+ return &url.URL{Scheme: "https", Host: u.Host}, nil
+}
+
+func sixbeeIsPayHost(host string, origin *url.URL) bool {
+ if origin == nil {
+ return false
+ }
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ return strings.EqualFold(host, origin.Hostname())
+}
+
+// sixbeeGatewayBase is the admin's PayAddress, which must be https.
+func sixbeeGatewayBase(client *epay.Client) (*url.URL, error) {
+ if client == nil || client.BaseUrl == nil {
+ return nil, errors.New("epay: gateway is not configured")
+ }
+ u := *client.BaseUrl
+ if u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" {
+ return nil, errors.New("epay: PayAddress must be an https URL")
+ }
+ return &u, nil
+}
+
+func sixbeePayScene(paymentType string, userAgent string) (string, error) {
+ switch paymentType {
+ case "alipay":
+ if sixbeeMobileUARe.MatchString(userAgent) {
+ return "wap", nil // official Alipay H5 page, which opens the app
+ }
+ return "qrcode", nil
+ case "usdt", "usdc":
+ return "wallet", nil
+ }
+ return "", fmt.Errorf("epay: %q has no same-domain checkout", paymentType)
+}
+
+func sixbeeNewPayToken() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func sixbeeSitePurchase(c *gin.Context, client *epay.Client, args *epay.PurchaseArgs, origin *url.URL) (string, map[string]string, error) {
+ if client == nil || client.Config == nil || args == nil || args.NotifyUrl == nil {
+ return "", nil, errors.New("epay: incomplete purchase arguments")
+ }
+ base, err := sixbeeGatewayBase(client)
+ if err != nil {
+ return "", nil, err
+ }
+ ctx := context.Background()
+ userAgent, clientIP := "", ""
+ if c != nil && c.Request != nil {
+ ctx = c.Request.Context()
+ userAgent = c.Request.UserAgent()
+ if ip := net.ParseIP(c.ClientIP()); ip != nil {
+ clientIP = ip.String()
+ }
+ }
+ scene, err := sixbeePayScene(args.Type, userAgent)
+ if err != nil {
+ return "", nil, err
+ }
+ token, err := sixbeeNewPayToken()
+ if err != nil {
+ return "", nil, err
+ }
+ notifyURL := *origin
+ notifyURL.Path = args.NotifyUrl.Path // /api/user/epay/notify or /api/subscription/epay/notify
+ pageURL := *origin
+ pageURL.Path = "/pay/" + token
+
+ params := map[string]string{
+ "pid": client.Config.PartnerID,
+ "type": args.Type,
+ "out_trade_no": args.ServiceTradeNo,
+ "notify_url": notifyURL.String(),
+ "return_url": pageURL.String(),
+ "name": args.Name,
+ "money": args.Money,
+ "currency": sixbeePayCurrency,
+ "checkout_mode": "site",
+ "pay_scene": scene,
+ "timestamp": strconv.FormatInt(time.Now().Unix(), 10),
+ }
+ if clientIP != "" {
+ params["clientip"] = clientIP
+ }
+ signed, err := epay.GenerateParamsWithError(params, client.Config.Key)
+ if err != nil {
+ return "", nil, err
+ }
+ fields, err := sixbeeGatewayPost(ctx, base, "mapi.php", signed)
+ if err != nil {
+ return "", nil, err
+ }
+ rec, err := sixbeeParseSiteResponse(fields, client.Config.Key, args, scene, base.Hostname(), time.Now())
+ if err != nil {
+ return "", nil, err
+ }
+ rec.TradeNo = args.ServiceTradeNo
+ raw, err := json.Marshal(rec)
+ if err != nil {
+ return "", nil, err
+ }
+ if err := sixbeePayPut(token, string(raw)); err != nil {
+ return "", nil, fmt.Errorf("epay: could not store the checkout page: %w", err)
+ }
+ return pageURL.String(), map[string]string{}, nil
+}
+
+func sixbeeGatewayPost(ctx context.Context, base *url.URL, endpoint string, form map[string]string) (map[string]string, error) {
+ u := *base
+ u.Path = path.Join("/", base.Path, endpoint)
+ values := url.Values{}
+ for k, v := range form {
+ values.Set(k, v)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(values.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ resp, err := sixbeeGatewayHTTP.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("epay: gateway request failed: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, sixbeeGatewayMaxBody+1))
+ if err != nil {
+ return nil, fmt.Errorf("epay: reading the gateway answer failed: %w", err)
+ }
+ if len(body) > sixbeeGatewayMaxBody {
+ return nil, errors.New("epay: gateway answer is too large")
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("epay: gateway answered HTTP %d", resp.StatusCode)
+ }
+ return sixbeeDecodeGatewayJSON(body)
+}
+
+// sixbeeDecodeGatewayJSON flattens the gateway's JSON object to the string
+// values its signature covers (numbers keep their literal form).
+func sixbeeDecodeGatewayJSON(body []byte) (map[string]string, error) {
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.UseNumber()
+ var raw map[string]any
+ if err := dec.Decode(&raw); err != nil {
+ return nil, errors.New("epay: gateway answer is not a JSON object")
+ }
+ out := make(map[string]string, len(raw))
+ for k, v := range raw {
+ switch t := v.(type) {
+ case string:
+ out[k] = t
+ case json.Number:
+ out[k] = t.String()
+ case nil:
+ default:
+ return nil, fmt.Errorf("epay: gateway field %q is not a scalar", k)
+ }
+ }
+ return out, nil
+}
+
+// sixbeeGatewaySign is the gateway's MD5 signature: non-empty fields except
+// sign/sign_type, sorted by name, joined as k=v&k=v, then the site key.
+func sixbeeGatewaySign(fields map[string]string, key string) string {
+ keys := make([]string, 0, len(fields))
+ for k, v := range fields {
+ if k == "sign" || k == "sign_type" || v == "" {
+ continue
+ }
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ var b strings.Builder
+ for i, k := range keys {
+ if i > 0 {
+ b.WriteByte('&')
+ }
+ b.WriteString(k)
+ b.WriteByte('=')
+ b.WriteString(fields[k])
+ }
+ b.WriteString(key)
+ sum := md5.Sum([]byte(b.String()))
+ return hex.EncodeToString(sum[:])
+}
+
+func sixbeeParseSiteResponse(f map[string]string, key string, args *epay.PurchaseArgs, scene string, gatewayHost string, now time.Time) (*sixbeePayRecord, error) {
+ if f["code"] != "0" {
+ msg := f["msg"]
+ if len(msg) > 200 {
+ msg = msg[:200]
+ }
+ if msg == "" {
+ msg = "no message"
+ }
+ return nil, fmt.Errorf("epay: gateway refused the order: %s", msg)
+ }
+ sign := strings.ToLower(f["sign"])
+ if len(sign) != 32 || subtle.ConstantTimeCompare([]byte(sign), []byte(sixbeeGatewaySign(f, key))) != 1 {
+ return nil, errors.New("epay: gateway answer signature does not verify")
+ }
+ ts, err := strconv.ParseInt(f["timestamp"], 10, 64)
+ if err != nil || ts < now.Unix()-300 || ts > now.Unix()+300 {
+ return nil, errors.New("epay: gateway answer timestamp is missing or stale")
+ }
+ if !sixbeeGatewayTradeRe.MatchString(f["trade_no"]) {
+ return nil, errors.New("epay: gateway trade_no is not 19 digits")
+ }
+ want, err1 := decimal.NewFromString(args.Money)
+ got, err2 := decimal.NewFromString(f["pricing_amount"])
+ if err1 != nil || err2 != nil || !got.Equal(want) || f["pricing_currency"] != sixbeePayCurrency {
+ return nil, fmt.Errorf("epay: gateway priced the order at %s %s, expected %s %s",
+ f["pricing_amount"], f["pricing_currency"], args.Money, sixbeePayCurrency)
+ }
+ info := f["pay_info"]
+ if info == "" || len(info) > 4096 {
+ return nil, errors.New("epay: gateway pay_info is empty or too long")
+ }
+ if gatewayHost != "" && strings.Contains(strings.ToLower(info), strings.ToLower(gatewayHost)) {
+ return nil, errors.New("epay: gateway pay_info names the gateway itself")
+ }
+ rec := &sixbeePayRecord{
+ GatewayTradeNo: f["trade_no"],
+ Type: args.Type,
+ PayType: f["pay_type"],
+ PricingAmount: f["pricing_amount"],
+ PricingCurrency: f["pricing_currency"],
+ CreatedAt: now.Unix(),
+ }
+ switch scene {
+ case "qrcode":
+ if f["pay_type"] != "qrcode" || !sixbeeIsURL(info, "qr.alipay.com", "") {
+ return nil, fmt.Errorf("epay: expected an Alipay QR code, got pay_type %q", f["pay_type"])
+ }
+ rec.Code = info
+ rec.ExpiresAt = now.Add(sixbeeAlipayQRLife).Unix()
+ case "wap":
+ if f["pay_type"] != "jump" || !sixbeeIsURL(info, "openapi.alipay.com", "/gateway.do") {
+ return nil, fmt.Errorf("epay: expected an official Alipay link, got pay_type %q", f["pay_type"])
+ }
+ rec.Link = info
+ rec.ExpiresAt = now.Add(sixbeeAlipayWapLife).Unix()
+ case "wallet":
+ if f["pay_type"] != "wallet" {
+ return nil, fmt.Errorf("epay: expected wallet details, got pay_type %q", f["pay_type"])
+ }
+ if err := sixbeeParseWallet(info, args.Type, now, rec); err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("epay: unknown checkout scene %q", scene)
+ }
+ return rec, nil
+}
+
+func sixbeeIsURL(raw string, host string, pathPrefix string) bool {
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Host, host) || u.User != nil {
+ return false
+ }
+ return pathPrefix == "" || u.Path == pathPrefix
+}
+
+func sixbeeParseWallet(info string, paymentType string, now time.Time, rec *sixbeePayRecord) error {
+ asset, ok := sixbeeCryptoAssets[paymentType]
+ if !ok {
+ return fmt.Errorf("epay: %q is not a crypto payment type", paymentType)
+ }
+ dec := json.NewDecoder(strings.NewReader(info))
+ dec.UseNumber()
+ var w map[string]any
+ if err := dec.Decode(&w); err != nil {
+ return errors.New("epay: wallet details are not JSON")
+ }
+ str := func(k string) string {
+ switch v := w[k].(type) {
+ case string:
+ return v
+ case json.Number:
+ return v.String()
+ }
+ return ""
+ }
+ token, network, amount, address := str("token"), str("network"), str("amount"), str("address")
+ if str("kind") != "crypto" || token != asset[0] || network != asset[1] {
+ return fmt.Errorf("epay: wallet is %s on %s, expected %s on %s", token, network, asset[0], asset[1])
+ }
+ if !sixbeeCryptoAmountRe.MatchString(amount) {
+ return fmt.Errorf("epay: wallet amount %q is not a decimal", amount)
+ }
+ d, err := decimal.NewFromString(amount)
+ if err != nil || !d.IsPositive() {
+ return fmt.Errorf("epay: wallet amount %q is not positive", amount)
+ }
+ usd, err := decimal.NewFromString(rec.PricingAmount)
+ tail := d.Sub(usd)
+ if err != nil || tail.IsNegative() || tail.GreaterThan(sixbeeCryptoTailMax) || !tail.Mod(sixbeeCryptoCent).IsZero() {
+ return fmt.Errorf("epay: wallet amount %s %s is not 1:1 with %s USD", amount, token, rec.PricingAmount)
+ }
+ addressOK := sixbeeSolAddressRe.MatchString(address)
+ if network == "TRON" {
+ addressOK = sixbeeTronAddressRe.MatchString(address)
+ }
+ if !addressOK || str("qr_content") != address {
+ return errors.New("epay: wallet address is malformed or differs from its QR content")
+ }
+ expires, err := strconv.ParseInt(str("expires_at"), 10, 64)
+ if err != nil || expires <= now.Unix() || expires > now.Add(7*24*time.Hour).Unix() {
+ return errors.New("epay: wallet expiry is missing or out of range")
+ }
+ rec.Token, rec.Network, rec.CryptoAmount, rec.Code, rec.ExpiresAt = token, network, amount, address, expires
+ return nil
+}
+
+func sixbeeLoadPayRecord(token string) *sixbeePayRecord {
+ if !sixbeePayTokenRe.MatchString(token) {
+ return nil
+ }
+ raw, err := sixbeePayGet(token)
+ if err != nil || raw == "" {
+ return nil
+ }
+ var rec sixbeePayRecord
+ if err := json.Unmarshal([]byte(raw), &rec); err != nil || rec.TradeNo == "" {
+ return nil
+ }
+ return &rec
+}
+
+// sixbeePayState is what the page shows: pending, paid, expired, failed or unknown.
+func sixbeePayState(rec *sixbeePayRecord, now time.Time) string {
+ status, found := sixbeeOrderStatus(rec.TradeNo)
+ if !found {
+ return "unknown"
+ }
+ switch status {
+ case common.TopUpStatusSuccess:
+ return "paid"
+ case common.TopUpStatusFailed:
+ return "failed"
+ case common.TopUpStatusExpired:
+ return "expired"
+ }
+ if rec.ExpiresAt > 0 && now.Unix() > rec.ExpiresAt {
+ return "expired"
+ }
+ return "pending"
+}
+
+func sixbeeQRSVG(content string, label string) (template.HTML, error) {
+ code, err := qr.Encode(content, qr.M, qr.Auto)
+ if err != nil {
+ return "", err
+ }
+ bounds := code.Bounds()
+ n := bounds.Dx()
+ dark := func(x, y int) bool {
+ r, _, _, _ := code.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
+ return r < 0x8000
+ }
+ const quiet = 4
+ var b strings.Builder
+ fmt.Fprintf(&b, ``)
+ return template.HTML(b.String()), nil
+}
+
+// SixbeePayPage renders https://pay.6bee.ai/pay/. The console posts an
+// empty form here (it opens Epay checkouts with a form POST); that is
+// answered with 303 to the same page.
+func SixbeePayPage(c *gin.Context) {
+ origin, _ := sixbeePayOrigin()
+ if !sixbeeIsPayHost(c.Request.Host, origin) {
+ c.String(http.StatusNotFound, "not found")
+ return
+ }
+ token := c.Param("token")
+ if c.Request.Method == http.MethodPost {
+ if !sixbeePayTokenRe.MatchString(token) {
+ c.String(http.StatusNotFound, "not found")
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/pay/"+token)
+ return
+ }
+ now := time.Now()
+ lang := sixbeePayLang(c)
+ rec := sixbeeLoadPayRecord(token)
+ view := sixbeeBuildPayView(rec, token, lang, now)
+ nonce, err := sixbeeNewPayToken()
+ if err != nil {
+ c.String(http.StatusInternalServerError, "error")
+ return
+ }
+ view.Nonce = nonce
+ var buf bytes.Buffer
+ if err := sixbeePayTemplate.Execute(&buf, view); err != nil {
+ logger.LogError(c.Request.Context(), common.LogText("6bee pay page render failed token=%s error=%q", token, err.Error()))
+ c.String(http.StatusInternalServerError, "error")
+ return
+ }
+ sixbeePayHeaders(c, nonce)
+ status := http.StatusOK
+ if rec == nil {
+ status = http.StatusNotFound
+ }
+ c.Data(status, "text/html; charset=utf-8", buf.Bytes())
+}
+
+// SixbeePayStatus is polled by the page: {"status": pending|paid|expired|failed|unknown}.
+func SixbeePayStatus(c *gin.Context) {
+ origin, _ := sixbeePayOrigin()
+ if !sixbeeIsPayHost(c.Request.Host, origin) {
+ c.String(http.StatusNotFound, "not found")
+ return
+ }
+ c.Header("Cache-Control", "no-store")
+ c.Header("X-Content-Type-Options", "nosniff")
+ rec := sixbeeLoadPayRecord(c.Param("token"))
+ if rec == nil {
+ c.JSON(http.StatusNotFound, gin.H{"status": "unknown"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": sixbeePayState(rec, time.Now())})
+}
+
+// SixbeeEpayProviderRelay forwards a payment provider's notification to the
+// gateway unchanged and returns the gateway's answer unchanged.
+func SixbeeEpayProviderRelay(c *gin.Context) {
+ origin, _ := sixbeePayOrigin()
+ if !sixbeeIsPayHost(c.Request.Host, origin) {
+ c.String(http.StatusNotFound, "not found")
+ return
+ }
+ paymentType, tradeNo := c.Param("type"), c.Param("trade_no")
+ if !sixbeeRelayTypes[paymentType] || !sixbeeGatewayTradeRe.MatchString(tradeNo) {
+ c.String(http.StatusNotFound, "not found")
+ return
+ }
+ body, err := io.ReadAll(io.LimitReader(c.Request.Body, sixbeeGatewayMaxBody+1))
+ if err != nil || len(body) == 0 || len(body) > sixbeeGatewayMaxBody {
+ c.String(http.StatusRequestEntityTooLarge, "invalid body")
+ return
+ }
+ base, err := sixbeeGatewayBase(GetEpayClient())
+ if err != nil {
+ c.String(http.StatusServiceUnavailable, "relay unavailable")
+ return
+ }
+ target := *base
+ target.Path = path.Join("/", base.Path, "pay/notify", tradeNo) + "/"
+ target.RawQuery = ""
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body))
+ if err != nil {
+ c.String(http.StatusServiceUnavailable, "relay unavailable")
+ return
+ }
+ for _, name := range sixbeeRelayHeaders {
+ if v := c.GetHeader(name); v != "" {
+ req.Header.Set(name, v)
+ }
+ }
+ resp, err := sixbeeGatewayHTTP.Do(req)
+ if err != nil {
+ logger.LogWarn(c.Request.Context(), common.LogText("Epay provider relay failed type=%s trade_no=%s client_ip=%s error=%q", paymentType, tradeNo, c.ClientIP(), err.Error()))
+ c.String(http.StatusBadGateway, "relay failed")
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 && resp.StatusCode < 400 {
+ c.String(http.StatusBadGateway, "invalid upstream response")
+ return
+ }
+ out, _ := io.ReadAll(io.LimitReader(resp.Body, sixbeeGatewayMaxBody))
+ contentType := resp.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = "text/plain; charset=utf-8"
+ }
+ logger.LogInfo(c.Request.Context(), common.LogText("Epay provider relay type=%s trade_no=%s client_ip=%s gateway_status=%d", paymentType, tradeNo, c.ClientIP(), resp.StatusCode))
+ c.Header("Cache-Control", "no-store")
+ c.Data(resp.StatusCode, contentType, out)
+}
+
+func sixbeePayHeaders(c *gin.Context, nonce string) {
+ c.Header("Content-Security-Policy", "default-src 'none'; style-src 'nonce-"+nonce+"'; script-src 'nonce-"+nonce+
+ "'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
+ c.Header("Cache-Control", "no-store")
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Header("X-Frame-Options", "DENY")
+ c.Header("X-Robots-Tag", "noindex, nofollow")
+}
diff -ruN a/controller/sixbee_pay_page.go b/controller/sixbee_pay_page.go
--- a/controller/sixbee_pay_page.go
+++ b/controller/sixbee_pay_page.go
@@ -0,0 +1,371 @@
+package controller
+
+// 6bee: the pay.6bee.ai checkout page (see controller/sixbee_pay.go).
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/shopspring/decimal"
+)
+
+type sixbeePayView struct {
+ Lang string
+ Nonce string
+ T map[string]string
+ Title string
+ State string // pending | paid | expired | failed | unknown | error | missing
+ Kind string // qrcode | jump | wallet
+ ShowPay bool
+ Amount string
+ QR template.HTML
+ AppLink template.URL
+ Network string
+ Address string
+ CryptoAmount string
+ Token string
+ Warnings []string
+ ExpiresAt int64
+ Now int64
+ StatusURL string
+ ConsoleURL string
+ TradeNo string
+}
+
+var sixbeePayText = map[string]map[string]string{
+ "zh": {
+ "title_alipay": "支付宝付款",
+ "title_crypto": "%s 付款",
+ "scan_alipay": "打开支付宝,扫一扫付款",
+ "open_alipay_app": "在手机上?打开支付宝付款",
+ "open_alipay": "打开支付宝付款",
+ "jump_hint": "将前往支付宝官方页面完成付款",
+ "network": "网络",
+ "address": "收款地址",
+ "send_amount": "转账金额",
+ "copy": "复制",
+ "copied": "已复制",
+ "warn_network": "只能通过 %[1]s 网络转入 %[2]s,其他网络或币种的转账无法到账。",
+ "warn_exact": "请按上面的金额精确转账,少转或多转都不会自动到账。",
+ "warn_expiry": "请在倒计时结束前完成转账;过期后请回到控制台重新下单。",
+ "time_left": "剩余时间",
+ "pending": "等待付款…",
+ "paid": "付款成功,余额已到账。",
+ "back_soon": "正在返回控制台…",
+ "expired": "订单已过期,请回到控制台重新下单。",
+ "failed": "付款未成功,请回到控制台重新下单。",
+ "unknown": "找不到这笔订单,请回到控制台重新下单。",
+ "error": "付款信息加载失败,请回到控制台重新下单。",
+ "missing_title": "付款页面不存在",
+ "missing": "链接可能已过期,请回到控制台重新下单。",
+ "order": "订单号",
+ "back": "返回控制台",
+ "qr_label": "付款二维码",
+ "net_TRON": "TRON(TRC20)",
+ "net_SOLANA": "Solana",
+ },
+ "en": {
+ "title_alipay": "Pay with Alipay",
+ "title_crypto": "Pay with %s",
+ "scan_alipay": "Scan with the Alipay app to pay",
+ "open_alipay_app": "On your phone? Open Alipay",
+ "open_alipay": "Open Alipay to pay",
+ "jump_hint": "You'll finish paying on Alipay's own page",
+ "network": "Network",
+ "address": "Address",
+ "send_amount": "Amount",
+ "copy": "Copy",
+ "copied": "Copied",
+ "warn_network": "Send %[2]s on the %[1]s network only. Other networks or tokens will not arrive.",
+ "warn_exact": "Send exactly the amount above. A different amount is not credited automatically.",
+ "warn_expiry": "Finish the transfer before the timer runs out. After that, start a new top-up in the console.",
+ "time_left": "Time left",
+ "pending": "Waiting for payment…",
+ "paid": "Payment received. Your balance has been topped up.",
+ "back_soon": "Returning to the console…",
+ "expired": "This order has expired. Start a new top-up in the console.",
+ "failed": "The payment did not go through. Start a new top-up in the console.",
+ "unknown": "This order can't be found. Start a new top-up in the console.",
+ "error": "The payment details could not be loaded. Start a new top-up in the console.",
+ "missing_title": "Payment page not found",
+ "missing": "The link may have expired. Start a new top-up in the console.",
+ "order": "Order",
+ "back": "Back to console",
+ "qr_label": "Payment QR code",
+ "net_TRON": "TRON (TRC20)",
+ "net_SOLANA": "Solana",
+ },
+}
+
+func sixbeePayLang(c *gin.Context) string {
+ switch c.Query("lang") {
+ case "en":
+ return "en"
+ case "zh":
+ return "zh"
+ }
+ accept := strings.ToLower(strings.TrimSpace(c.GetHeader("Accept-Language")))
+ first := strings.TrimSpace(strings.SplitN(accept, ",", 2)[0])
+ if accept == "" || strings.HasPrefix(first, "zh") {
+ return "zh"
+ }
+ return "en"
+}
+
+// sixbeeFormatDecimal shows a gateway amount ("6.67000000") for people. fixed >= 0 rounds
+// to that many places ("6.67"); fixed < 0 keeps the exact value without trailing zeros but
+// with at least two decimals ("1.00", "10.000123"), so a crypto amount is never rounded.
+// A value that is not a decimal is shown as given.
+func sixbeeFormatDecimal(raw string, fixed int32) string {
+ d, err := decimal.NewFromString(strings.TrimSpace(raw))
+ if err != nil {
+ return raw
+ }
+ if fixed >= 0 {
+ return d.StringFixed(fixed)
+ }
+ s := d.String()
+ if i := strings.IndexByte(s, '.'); i < 0 || len(s)-i-1 < 2 {
+ return d.StringFixed(2)
+ }
+ return s
+}
+
+func sixbeeBuildPayView(rec *sixbeePayRecord, token string, lang string, now time.Time) sixbeePayView {
+ t := sixbeePayText[lang]
+ v := sixbeePayView{
+ Lang: map[string]string{"zh": "zh-CN", "en": "en"}[lang],
+ T: t,
+ Now: now.Unix(),
+ ConsoleURL: paymentReturnPath("/wallet"),
+ }
+ if rec == nil {
+ v.State = "missing"
+ v.Title = t["missing_title"]
+ return v
+ }
+ v.State = sixbeePayState(rec, now)
+ v.StatusURL = "/pay/" + token + "/status"
+ v.TradeNo = rec.TradeNo
+ v.Kind = rec.PayType
+ v.Amount = sixbeeFormatDecimal(rec.PricingAmount, 2) + " " + rec.PricingCurrency
+ v.ExpiresAt = rec.ExpiresAt
+ if rec.Type == "alipay" {
+ v.Title = t["title_alipay"]
+ } else {
+ v.Title = fmt.Sprintf(t["title_crypto"], rec.Token)
+ }
+ // Once an order is closed its QR code and address are no longer shown.
+ v.ShowPay = v.State == "pending"
+ if !v.ShowPay {
+ return v
+ }
+ switch rec.PayType {
+ case "qrcode":
+ svg, err := sixbeeQRSVG(rec.Code, t["qr_label"])
+ if err != nil {
+ v.State, v.ShowPay = "error", false
+ return v
+ }
+ v.QR = svg
+ v.AppLink = template.URL(rec.Code) // checked: https://qr.alipay.com/…
+ case "jump":
+ v.AppLink = template.URL(rec.Link) // checked: https://openapi.alipay.com/gateway.do…
+ v.ExpiresAt = 0 // Alipay's page keeps its own time
+ case "wallet":
+ svg, err := sixbeeQRSVG(rec.Code, t["qr_label"])
+ if err != nil {
+ v.State, v.ShowPay = "error", false
+ return v
+ }
+ v.QR = svg
+ v.Address = rec.Code
+ v.CryptoAmount = sixbeeFormatDecimal(rec.CryptoAmount, -1) // exact; trailing zeros dropped
+ v.Token = rec.Token
+ v.Network = t["net_"+rec.Network]
+ if v.Network == "" {
+ v.Network = rec.Network
+ }
+ v.Warnings = []string{
+ fmt.Sprintf(t["warn_network"], v.Network, rec.Token),
+ t["warn_exact"],
+ t["warn_expiry"],
+ }
+ default:
+ v.State, v.ShowPay = "error", false
+ }
+ return v
+}
+
+var sixbeePayTemplate = template.Must(template.New("pay").Parse(`
+
+
+
+
+
+
+
+{{.Title}} · 6bee.ai
+
+
+
+
+
+
+
+{{- if eq .State "missing"}}
+{{.T.missing_title}}
+{{.T.missing}}
+{{- else}}
+{{.Title}}
+{{.Amount}}
+{{- if .ShowPay}}
+
+{{- if eq .Kind "qrcode"}}
+
{{.QR}}
+
{{.T.scan_alipay}}
+
{{.T.open_alipay_app}}
+{{- else if eq .Kind "jump"}}
+
{{.T.open_alipay}}
+
{{.T.jump_hint}}
+{{- else if eq .Kind "wallet"}}
+
{{.T.network}}{{.Network}}
+
{{.QR}}
+
{{.T.address}}{{.Address}}
+
{{.T.send_amount}}{{.CryptoAmount}} {{.Token}}
+
{{range .Warnings}}- {{.}}
{{end}}
+{{- end}}
+{{- if .ExpiresAt}}
+
{{.T.time_left}} --:--
+{{- end}}
+
+{{- end}}
+{{index .T .State}}
+{{- end}}
+
+
+
+
+
+
+`))
diff -ruN a/controller/sixbee_pay_test.go b/controller/sixbee_pay_test.go
--- a/controller/sixbee_pay_test.go
+++ b/controller/sixbee_pay_test.go
@@ -0,0 +1,399 @@
+package controller
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
+
+ "github.com/Calcium-Ion/go-epay/epay"
+ "github.com/gin-gonic/gin"
+)
+
+const sixbeeTestKey = "0123456789abcdef0123456789abcdef"
+
+func sixbeeSigned(fields map[string]string) map[string]string {
+ fields["sign_type"] = "MD5"
+ fields["sign"] = sixbeeGatewaySign(fields, sixbeeTestKey)
+ return fields
+}
+
+func TestSixbeeGatewaySignMatchesThePHPGateway(t *testing.T) {
+ f := map[string]string{"trade_no": "2026110920391191756", "code": "0", "pay_type": "qrcode",
+ "pay_info": "https://qr.alipay.com/bax1", "empty": "", "sign": "x", "sign_type": "MD5", "timestamp": "1794227949"}
+ sum := md5.Sum([]byte("code=0&pay_info=https://qr.alipay.com/bax1&pay_type=qrcode×tamp=1794227949&trade_no=2026110920391191756" + sixbeeTestKey))
+ if got := sixbeeGatewaySign(f, sixbeeTestKey); got != hex.EncodeToString(sum[:]) {
+ t.Fatalf("sign = %s", got)
+ }
+}
+
+func sixbeeQRAnswer(now time.Time) map[string]string {
+ return sixbeeSigned(map[string]string{
+ "code": "0", "trade_no": "2026110920391191756", "pay_type": "qrcode", "pay_info": "https://qr.alipay.com/bax08812abc",
+ "pricing_amount": "5.00", "pricing_currency": "USD", "payment_amount": "33.37", "payment_currency": "CNY",
+ "fx_rate": "6.6740", "timestamp": strconv.FormatInt(now.Unix(), 10),
+ })
+}
+
+func sixbeeWalletAnswer(now time.Time, wallet map[string]any) map[string]string {
+ raw, _ := json.Marshal(wallet)
+ return sixbeeSigned(map[string]string{
+ "code": "0", "trade_no": "2026110920391191757", "pay_type": "wallet", "pay_info": string(raw),
+ "pricing_amount": "5.00", "pricing_currency": "USD", "payment_currency": "USDT",
+ "timestamp": strconv.FormatInt(now.Unix(), 10),
+ })
+}
+
+func TestSixbeeParseSiteResponse(t *testing.T) {
+ now := time.Unix(1794227949, 0)
+ args := &epay.PurchaseArgs{Type: "alipay", Money: "5.00"}
+ rec, err := sixbeeParseSiteResponse(sixbeeQRAnswer(now), sixbeeTestKey, args, "qrcode", "pay.gateway.example", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Code != "https://qr.alipay.com/bax08812abc" || rec.ExpiresAt != now.Add(10*time.Minute).Unix() {
+ t.Fatalf("record = %+v", rec)
+ }
+
+ // 1:1 with the 5.00 USD price, plus the gateway's 0.01 collision tail at most ten times.
+ for _, amount := range []string{"5", "5.00000000", "5.01", "5.10"} {
+ wallet := map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": amount,
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() + 1800}
+ rec, err = sixbeeParseSiteResponse(sixbeeWalletAnswer(now, wallet), sixbeeTestKey, &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet", "pay.gateway.example", now)
+ if err != nil {
+ t.Fatalf("amount %s: %v", amount, err)
+ }
+ if rec.Code != wallet["address"] || rec.CryptoAmount != amount || rec.Network != "TRON" || rec.ExpiresAt != now.Unix()+1800 {
+ t.Fatalf("wallet record = %+v", rec)
+ }
+ }
+
+ resign := func(f map[string]string) map[string]string {
+ delete(f, "sign")
+ return sixbeeSigned(f)
+ }
+ bad := []struct {
+ name string
+ f map[string]string
+ args *epay.PurchaseArgs
+ scene string
+ }{
+ {"refused", map[string]string{"code": "-1", "msg": "IP not allowed"}, args, "qrcode"},
+ {"bad signature", func() map[string]string {
+ f := sixbeeQRAnswer(now)
+ f["pay_info"] = "https://qr.alipay.com/other"
+ return f
+ }(), args, "qrcode"},
+ {"stale", resign(func() map[string]string {
+ f := sixbeeQRAnswer(now)
+ f["timestamp"] = strconv.FormatInt(now.Unix()-900, 10)
+ return f
+ }()), args, "qrcode"},
+ {"other amount", sixbeeQRAnswer(now), &epay.PurchaseArgs{Type: "alipay", Money: "50.00"}, "qrcode"},
+ {"other currency", resign(func() map[string]string { f := sixbeeQRAnswer(now); f["pricing_currency"] = "CNY"; return f }()), args, "qrcode"},
+ {"gateway page", resign(func() map[string]string {
+ f := sixbeeQRAnswer(now)
+ f["pay_type"], f["pay_info"] = "qrcode", "https://pay.gateway.example/pay/qrcode/2026110920391191756/"
+ return f
+ }()), args, "qrcode"},
+ {"not alipay", resign(func() map[string]string {
+ f := sixbeeQRAnswer(now)
+ f["pay_info"] = "https://evil.example/qr"
+ return f
+ }()), args, "qrcode"},
+ {"jump for qrcode", resign(func() map[string]string { f := sixbeeQRAnswer(now); f["pay_type"] = "jump"; return f }()), args, "qrcode"},
+ {"wap not official", resign(func() map[string]string {
+ f := sixbeeQRAnswer(now)
+ f["pay_type"], f["pay_info"] = "jump", "https://openapi.alipay.com.evil.example/gateway.do"
+ return f
+ }()), args, "wap"},
+ {"wrong network", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "SOLANA", "amount": "5",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() + 60}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ {"qr differs from address", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": "5",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsYYYYYYYYYY", "expires_at": now.Unix() + 60}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ {"below 1:1", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": "4.99",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() + 60}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ {"rate applied", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": "36.50",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() + 60}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ {"sub-cent tail", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": "5.0012",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() + 60}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ {"tail too long", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": "5.11",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() + 60}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ {"expired wallet", sixbeeWalletAnswer(now, map[string]any{"kind": "crypto", "token": "USDT", "network": "TRON", "amount": "5",
+ "address": "TTestAddressForUnitTestsXXXXXXXXXX", "qr_content": "TTestAddressForUnitTestsXXXXXXXXXX", "expires_at": now.Unix() - 1}),
+ &epay.PurchaseArgs{Type: "usdt", Money: "5.00"}, "wallet"},
+ }
+ for _, tc := range bad {
+ if _, err := sixbeeParseSiteResponse(tc.f, sixbeeTestKey, tc.args, tc.scene, "pay.gateway.example", now); err == nil {
+ t.Errorf("%s: accepted", tc.name)
+ }
+ }
+}
+
+func sixbeeTestGateway(t *testing.T, handler http.HandlerFunc) (*epay.Client, func()) {
+ t.Helper()
+ srv := httptest.NewTLSServer(handler)
+ oldClient := sixbeeGatewayHTTP
+ sixbeeGatewayHTTP = srv.Client()
+ sixbeeGatewayHTTP.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+ client, err := epay.NewClient(&epay.Config{PartnerID: "1008", Key: sixbeeTestKey}, srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldAddress, oldID, oldKey := operation_setting.PayAddress, operation_setting.EpayId, operation_setting.EpayKey
+ operation_setting.PayAddress, operation_setting.EpayId, operation_setting.EpayKey = srv.URL, "1008", sixbeeTestKey
+ return client, func() {
+ srv.Close()
+ sixbeeGatewayHTTP = oldClient
+ operation_setting.PayAddress, operation_setting.EpayId, operation_setting.EpayKey = oldAddress, oldID, oldKey
+ }
+}
+
+func sixbeeMemoryStore(t *testing.T) map[string]string {
+ store := map[string]string{}
+ oldPut, oldGet := sixbeePayPut, sixbeePayGet
+ sixbeePayPut = func(token, value string) error { store[token] = value; return nil }
+ sixbeePayGet = func(token string) (string, error) { return store[token], nil }
+ t.Cleanup(func() { sixbeePayPut, sixbeePayGet = oldPut, oldGet })
+ return store
+}
+
+func TestSixbeeSitePurchase(t *testing.T) {
+ t.Setenv("SIXBEE_PAY_ORIGIN", "https://pay.example.com")
+ store := sixbeeMemoryStore(t)
+ var got url.Values
+ client, done := sixbeeTestGateway(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/mapi.php" || r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+ _ = r.ParseForm()
+ got = r.PostForm
+ _ = json.NewEncoder(w).Encode(sixbeeQRAnswer(time.Now()))
+ })
+ defer done()
+
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/user/pay", nil)
+ c.Request.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/141.0")
+ notify, _ := url.Parse("https://console.example.com/api/user/epay/notify")
+ ret, _ := url.Parse("https://console.example.com/usage-logs")
+ uri, params, err := sixbeeEpayPurchase(c, client, &epay.PurchaseArgs{
+ Type: "alipay", ServiceTradeNo: "USR1NOabc1231794227949", Name: "TUC5", Money: "5.00",
+ Device: epay.PC, NotifyUrl: notify, ReturnUrl: ret,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(params) != 0 || !strings.HasPrefix(uri, "https://pay.example.com/pay/") {
+ t.Fatalf("uri = %s params = %v", uri, params)
+ }
+ token := strings.TrimPrefix(uri, "https://pay.example.com/pay/")
+ if !sixbeePayTokenRe.MatchString(token) || store[token] == "" {
+ t.Fatalf("token %q not stored", token)
+ }
+ want := map[string]string{"checkout_mode": "site", "pay_scene": "qrcode", "currency": "USD", "pid": "1008", "type": "alipay",
+ "notify_url": "https://pay.example.com/api/user/epay/notify", "return_url": uri, "money": "5.00"}
+ for k, v := range want {
+ if got.Get(k) != v {
+ t.Errorf("%s = %q, want %q", k, got.Get(k), v)
+ }
+ }
+ fields := map[string]string{}
+ for k := range got {
+ fields[k] = got.Get(k)
+ }
+ if got.Get("sign") != sixbeeGatewaySign(fields, sixbeeTestKey) {
+ t.Error("order signature does not verify")
+ }
+ rec := sixbeeLoadPayRecord(token)
+ if rec == nil || rec.TradeNo != "USR1NOabc1231794227949" || rec.GatewayTradeNo != "2026110920391191756" {
+ t.Fatalf("record = %+v", rec)
+ }
+
+ c.Request.Header.Set("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile/15E148")
+ if scene, _ := sixbeePayScene("alipay", c.Request.UserAgent()); scene != "wap" {
+ t.Errorf("mobile scene = %s", scene)
+ }
+ if _, err := sixbeePayScene("wxpay", ""); err == nil {
+ t.Error("wxpay has no site checkout here")
+ }
+
+ t.Setenv("SIXBEE_PAY_ORIGIN", "http://pay.example.com")
+ if _, _, err := sixbeeEpayPurchase(c, client, &epay.PurchaseArgs{Type: "alipay", NotifyUrl: notify, ReturnUrl: ret}); err == nil {
+ t.Error("an http origin must not fall back to the gateway's own pages")
+ }
+}
+
+func TestSixbeePayPageAndStatus(t *testing.T) {
+ t.Setenv("SIXBEE_PAY_ORIGIN", "https://pay.example.com")
+ store := sixbeeMemoryStore(t)
+ orderStatus := common.TopUpStatusPending
+ oldStatus := sixbeeOrderStatus
+ sixbeeOrderStatus = func(string) (string, bool) { return orderStatus, true }
+ defer func() { sixbeeOrderStatus = oldStatus }()
+
+ now := time.Now()
+ rec := sixbeePayRecord{TradeNo: "USR1NOabc1231794227949", Type: "usdt", PayType: "wallet", Code: "TTestAddressForUnitTestsXXXXXXXXXX",
+ Token: "USDT", Network: "TRON", CryptoAmount: "5.0012", ExpiresAt: now.Unix() + 1800, PricingAmount: "5.00", PricingCurrency: "USD"}
+ raw, _ := json.Marshal(rec)
+ token := "AAAAAAAAAAAAAAAAAAAAAA"
+ store[token] = string(raw)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ SetSixbeePayRoutesForTest(r)
+ get := func(method, target, host string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(method, target, nil)
+ req.Host = host
+ req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
+ r.ServeHTTP(w, req)
+ return w
+ }
+
+ w := get(http.MethodGet, "/pay/"+token, "pay.example.com")
+ body := w.Body.String()
+ if w.Code != 200 || !strings.Contains(body, "TTestAddressForUnitTestsXXXXXXXXXX") || !strings.Contains(body, "5.0012 USDT") ||
+ !strings.Contains(body, "TRON(TRC20)") || !strings.Contains(body, "