Skip to content
Merged
7 changes: 6 additions & 1 deletion backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ const (
ConnectionPoolIsolationAccountProxy = "account_proxy"
)

// DefaultUpstreamResponseReadMaxBytes 上游非流式响应体的默认读取上限。
// 128 MB 可容纳 2-3 张 4K PNG(base64 膨胀 33%,单张 4K PNG 最坏约 67MB base64)。
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024

type Config struct {
Server ServerConfig `mapstructure:"server"`
Log LogConfig `mapstructure:"log"`
Expand Down Expand Up @@ -1407,7 +1412,7 @@ func setDefaults() {
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
viper.SetDefault("gateway.antigravity_extra_retries", 10)
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
viper.SetDefault("gateway.upstream_response_read_max_bytes", int64(8*1024*1024))
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
viper.SetDefault("gateway.gemini_debug_response_headers", false)
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
Expand Down
10 changes: 9 additions & 1 deletion backend/internal/handler/payment_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ type CreateOrderRequest struct {
PaymentType string `json:"payment_type" binding:"required"`
OrderType string `json:"order_type"`
PlanID int64 `json:"plan_id"`
// IsMobile lets the frontend declare its mobile status directly. When
// nil we fall back to User-Agent heuristics (which miss iPadOS / some
// embedded browsers that strip the "Mobile" keyword).
IsMobile *bool `json:"is_mobile,omitempty"`
}

// CreateOrder creates a new payment order.
Expand All @@ -222,12 +226,16 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
return
}

mobile := isMobile(c)
if req.IsMobile != nil {
mobile = *req.IsMobile
}
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
UserID: subject.UserID,
Amount: req.Amount,
PaymentType: req.PaymentType,
ClientIP: c.ClientIP(),
IsMobile: isMobile(c),
IsMobile: mobile,
SrcHost: c.Request.Host,
SrcURL: c.Request.Referer(),
OrderType: req.OrderType,
Expand Down
21 changes: 17 additions & 4 deletions backend/internal/payment/crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import (
"strings"
)

// AES256KeySize is the required key length (in bytes) for AES-256-GCM.
const AES256KeySize = 32

// Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key.
// The output format is "iv:authTag:ciphertext" where each component is base64-encoded,
// matching the Node.js crypto.ts format for cross-compatibility.
//
// Deprecated: payment provider configs are now stored as plaintext JSON.
// This function is kept only for seeding legacy ciphertext in tests and for
// the transitional Decrypt fallback. Scheduled for removal after all live
// deployments complete migration by re-saving their configs.
func Encrypt(plaintext string, key []byte) (string, error) {
if len(key) != 32 {
return "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
if len(key) != AES256KeySize {
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
}

block, err := aes.NewCipher(key)
Expand Down Expand Up @@ -51,9 +59,14 @@ func Encrypt(plaintext string, key []byte) (string, error) {

// Decrypt decrypts a ciphertext string produced by Encrypt.
// The input format is "iv:authTag:ciphertext" where each component is base64-encoded.
//
// Deprecated: payment provider configs are now stored as plaintext JSON.
// This function remains only as a read-path fallback for pre-migration
// ciphertext records. Scheduled for removal once all deployments re-save
// their provider configs through the admin UI.
func Decrypt(ciphertext string, key []byte) (string, error) {
if len(key) != 32 {
return "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
if len(key) != AES256KeySize {
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
}

parts := strings.SplitN(ciphertext, ":", 3)
Expand Down
37 changes: 30 additions & 7 deletions backend/internal/payment/load_balancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
if err != nil {
return nil, fmt.Errorf("decrypt instance %d config: %w", selected.ID, err)
}
if config == nil {
config = map[string]string{}
}

if selected.PaymentMode != "" {
config["paymentMode"] = selected.PaymentMode
Expand All @@ -275,16 +278,36 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
}, nil
}

func (lb *DefaultLoadBalancer) decryptConfig(encrypted string) (map[string]string, error) {
plaintext, err := Decrypt(encrypted, lb.encryptionKey)
if err != nil {
return nil, err
// decryptConfig parses a stored provider config.
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext.
// Unreadable values (legacy ciphertext without a valid key, or malformed data)
// are treated as empty so the service keeps running while the admin re-enters
// the config via the UI.
//
// TODO(deprecated-legacy-ciphertext): The AES fallback branch below is a
// transitional compatibility shim for pre-plaintext records. Remove it (and
// the encryptionKey field + the Decrypt import) after a few releases once all
// live deployments have re-saved their provider configs through the UI.
func (lb *DefaultLoadBalancer) decryptConfig(stored string) (map[string]string, error) {
if stored == "" {
return nil, nil
}
var config map[string]string
if err := json.Unmarshal([]byte(plaintext), &config); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
if err := json.Unmarshal([]byte(stored), &config); err == nil {
return config, nil
}
// Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
if len(lb.encryptionKey) == AES256KeySize {
//nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
if plaintext, err := Decrypt(stored, lb.encryptionKey); err == nil {
if err := json.Unmarshal([]byte(plaintext), &config); err == nil {
return config, nil
}
}
}
return config, nil
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
"stored_len", len(stored))
return nil, nil
}

// GetInstanceDailyAmount returns the total completed order amount for an instance today.
Expand Down
97 changes: 97 additions & 0 deletions backend/internal/payment/load_balancer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,103 @@ func TestStartOfDay(t *testing.T) {
}
}

func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
t.Parallel()

key := make([]byte, AES256KeySize)
for i := range key {
key[i] = byte(i + 1)
}
wrongKey := make([]byte, AES256KeySize)
for i := range wrongKey {
wrongKey[i] = byte(0xFF - i)
}

plaintextJSON := `{"appId":"app-123","secret":"sec-xyz"}`

legacyEncrypted, err := Encrypt(plaintextJSON, key)
if err != nil {
t.Fatalf("seed Encrypt: %v", err)
}

tests := []struct {
name string
stored string
key []byte
want map[string]string
}{
{
name: "empty stored returns nil map",
stored: "",
key: key,
want: nil,
},
{
name: "plaintext JSON parses directly",
stored: plaintextJSON,
key: nil,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
name: "plaintext JSON works even with key present",
stored: plaintextJSON,
key: key,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
name: "legacy ciphertext with correct key decrypts",
stored: legacyEncrypted,
key: key,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
name: "legacy ciphertext with no key treated as empty",
stored: legacyEncrypted,
key: nil,
want: nil,
},
{
name: "legacy ciphertext with wrong key treated as empty",
stored: legacyEncrypted,
key: wrongKey,
want: nil,
},
{
name: "garbage data treated as empty",
stored: "not-json-and-not-ciphertext",
key: key,
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
lb := NewDefaultLoadBalancer(nil, tt.key)
got, err := lb.decryptConfig(tt.stored)
if err != nil {
t.Fatalf("decryptConfig unexpected error: %v", err)
}
if !stringMapEqual(got, tt.want) {
t.Fatalf("decryptConfig = %v, want %v", got, tt.want)
}
})
}
}

// stringMapEqual compares two map[string]string values; nil and empty are equal.
func stringMapEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if bv, ok := b[k]; !ok || bv != v {
return false
}
}
return true
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
50 changes: 27 additions & 23 deletions backend/internal/payment/provider/alipay.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (

// Alipay product codes.
const (
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
alipayProductCodeWapPay = "QUICK_WAP_WAY"
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
)

// Alipay response constants.
Expand Down Expand Up @@ -79,7 +79,12 @@ func (a *Alipay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeAlipay}
}

// CreatePayment creates an Alipay payment page URL.
// CreatePayment creates an Alipay payment using redirect-only flow:
// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to.
// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a
// new window; Alipay's own page then shows login/QR. We intentionally do
// NOT encode the URL into a QR on the client (it isn't a scannable payload
// and would produce an invalid scan result).
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := a.getClient()
if err != nil {
Expand All @@ -96,31 +101,31 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque
}

if req.IsMobile {
return a.createTrade(client, req, notifyURL, returnURL, true)
return a.createWapTrade(client, req, notifyURL, returnURL)
}
return a.createTrade(client, req, notifyURL, returnURL, false)
return a.createPagePayTrade(client, req, notifyURL, returnURL)
}

func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
if isMobile {
param := alipay.TradeWapPay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodeWapPay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL

payURL, err := client.TradeWapPay(param)
if err != nil {
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
}, nil
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
param := alipay.TradeWapPay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodeWapPay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL

payURL, err := client.TradeWapPay(param)
if err != nil {
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
}, nil
}

func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
param := alipay.TradePagePay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
Expand All @@ -136,7 +141,6 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
QRCode: payURL.String(),
}, nil
}

Expand Down
21 changes: 21 additions & 0 deletions backend/internal/service/admin_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,15 @@ func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userI
}

func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) {
// 校验用户专属分组倍率:必须 > 0(nil 合法,表示清除专属倍率)
if input.GroupRates != nil {
for groupID, rate := range input.GroupRates {
if rate != nil && *rate <= 0 {
return nil, fmt.Errorf("rate_multiplier must be > 0 (group_id=%d)", groupID)
}
}
}

user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return nil, err
Expand Down Expand Up @@ -811,6 +820,10 @@ func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, erro
}

func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
if input.RateMultiplier <= 0 {
return nil, errors.New("rate_multiplier must be > 0")
}

platform := input.Platform
if platform == "" {
platform = PlatformAnthropic
Expand Down Expand Up @@ -1050,6 +1063,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
group.Platform = input.Platform
}
if input.RateMultiplier != nil {
if *input.RateMultiplier <= 0 {
return nil, errors.New("rate_multiplier must be > 0")
}
group.RateMultiplier = *input.RateMultiplier
}
if input.IsExclusive != nil {
Expand Down Expand Up @@ -1286,6 +1302,11 @@ func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, gro
if s.userGroupRateRepo == nil {
return nil
}
for _, e := range entries {
if e.RateMultiplier <= 0 {
return fmt.Errorf("rate_multiplier must be > 0 (user_id=%d)", e.UserID)
}
}
return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
}

Expand Down
Loading
Loading